缓存与数据优化(Redis + 查询治理)
这一课你将能做到
- 用
IMemoryCache + IDistributedCache(Redis)搭出二级缓存,缓存热门题目列表与详情
- 亲手实现防缓存击穿的「单飞(singleflight)」模式,说清穿透/击穿/雪崩三者的修法
- 对照 Spring Cache/@Cacheable 与 RedisTemplate,把缓存策略知识完整迁移
为什么要学
判题平台的流量分布极不均匀:一道热门题的题面、通过率、排行榜会被反复读,数据库每秒扛几千次相同的 SELECT 是纯浪费。缓存的本质是用「一致性延迟」换「读吞吐」,难点不在读缓存写缓存,而在失效策略——这是面试和生产的共同考点。
人话:内存缓存是「自己口袋里的便签」,进程重启就没、多实例各存一份;Redis 是「楼下的公告栏」,所有实例看同一份。读多的数据贴便签,跨实例要一致的数据上公告栏。
先建立图像
Cache-Aside 读写路径(Spring @Cacheable 底层同款):
读:请求 → 查缓存 → 命中? ──是──→ 返回
│否
▼
查数据库 → 写入缓存(带TTL) → 返回
写:更新数据库 → 删除缓存(不是更新缓存!)→ 下次读自然回填
跟着做
步骤 1:注册两级缓存
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
builder.Services.AddMemoryCache(); // L1:进程内
builder.Services.AddStackExchangeRedisCache(opt => // L2:Redis
{
opt.Configuration = builder.Configuration.GetConnectionString("Redis");
opt.InstanceName = "oj:"; // 所有 key 自动加前缀
});
步骤 2:题目详情缓存(穿透防护:空值也缓存)
public class ProblemCacheService(OjDbContext db, IDistributedCache redis)
{
public async Task<ProblemDto?> GetAsync(long id, CancellationToken ct)
{
var key = $"problem:{id}";
var cached = await redis.GetStringAsync(key, ct);
if (cached is not null)
return cached == "\0null" ? null : JsonSerializer.Deserialize<ProblemDto>(cached);
var dto = await db.Problems.AsNoTracking()
.Where(p => p.Id == id)
.Select(p => new ProblemDto(p.Id, p.Title, p.Difficulty.ToString()))
.FirstOrDefaultAsync(ct);
// 防穿透:查不到也缓存一个占位短 TTL,恶意请求打不穿库
var payload = dto is null ? "\0null" : JsonSerializer.Serialize(dto);
await redis.SetStringAsync(key, payload,
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = dto is null ? TimeSpan.FromSeconds(30)
: TimeSpan.FromMinutes(10),
}, ct);
return dto;
}
// 一致性:先更新库,再删缓存(Cache-Aside 标准写法)
public async Task UpdateTitleAsync(long id, string title, CancellationToken ct)
{
await db.Problems.Where(p => p.Id == id).ExecuteUpdateAsync(
s => s.SetProperty(p => p.Title, title), ct);
await redis.RemoveAsync($"problem:{id}", ct);
}
}
IDistributedCache 的值是 byte[]/string,序列化由你决定——比 RedisTemplate 的 RedisSerializer 配置更原始,但透明。
步骤 3:热门列表 + 防击穿单飞模式
1000 个请求同时发现缓存失效,如果都去查库就是击穿。用 SemaphoreSlim 保证同一进程只有一个请求回源(分布式环境配合 Redis 锁,见面试题):
public class HotProblemsService(OjDbContext db, IMemoryCache cache)
{
private static readonly SemaphoreSlim _gate = new(1, 1);
private const string Key = "hot-problems";
public async Task<List<ProblemDto>> GetAsync(CancellationToken ct)
{
if (cache.TryGetValue(Key, out List<ProblemDto>? hit)) return hit!;
await _gate.WaitAsync(ct); // 同一时刻只放一个请求进来
try
{
if (cache.TryGetValue(Key, out hit)) return hit!; // 双重检查:排队期间可能已被回填
var list = await db.Problems.AsNoTracking()
.OrderByDescending(p => p.Submissions.Count(s => s.Status == "AC"))
.Take(50)
.Select(p => new ProblemDto(p.Id, p.Title, p.Difficulty.ToString()))
.ToListAsync(ct);
// 防雪崩:TTL 加随机抖动,避免大量 key 同一秒集体过期
var ttl = TimeSpan.FromMinutes(5).Add(TimeSpan.FromSeconds(Random.Shared.Next(0, 60)));
cache.Set(Key, list, ttl);
return list;
}
finally { _gate.Release(); }
}
}
步骤 4:排行榜用 SortedSet(等价 Redisson/RedisTemplate 的 ZSet 操作)
IDistributedCache 只会 KV,排行业务要直接用 StackExchange.Redis:
builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
ConnectionMultiplexer.Connect(builder.Configuration.GetConnectionString("Redis")!));
public class RankService(IConnectionMultiplexer redis)
{
private IDatabase Db => redis.GetDatabase();
public Task SubmitScoreAsync(string user, int score) =>
Db.SortedSetAddAsync("rank:weekly", user, score); // ZADD
public Task<long> GetRankAsync(string user) =>
Db.SortedSetRankAsync("rank:weekly", user, Order.Descending); // ZREVRANK
public async Task<List<string>> Top10Async() =>
[.. (await Db.SortedSetRangeByRankAsync("rank:weekly", 0, 9, Order.Descending)).Cast<RedisValue>()];
}
步骤 5:深分页治理(Offset → Keyset)
// ❌ page=10000 时 OFFSET 200000,数据库扫表越来越慢
// ✅ Keyset 分页:记录上一页最后一条的游标,走索引直达
var next = await db.Submissions.AsNoTracking()
.Where(s => s.Id > lastSeenId)
.OrderBy(s => s.Id)
.Take(20)
.ToListAsync();
// 响应里返回 items[^1].Id 作为下一页游标;无限滚动列表/导出任务都适用
讲清楚要点
- 先更新库再删缓存,不是更新缓存:并发写时「更新缓存」可能把旧值写回;「删缓存 + 读时回填」把一致性责任交给下一次读。残留不一致窗口由 TTL 兜底,这是权衡后的工程标准答案。
- 三类经典问题的修法清单:穿透(不存在的 key 反复查库)→ 空值缓存 + 入参校验 + 布隆过滤器;击穿(热 key 失效瞬间并发回源)→ 单飞互斥 + 逻辑过期;雪崩(大量 key 同时失效)→ TTL 随机抖动 + 多级缓存 + 熔断降级。
- 多实例部署必须想清楚缓存在哪一级:
IMemoryCache 每实例一份,改数据后其它实例的便签还是旧的;强一致需求放 Redis,容忍秒级陈旧的放内存。本站 Spring 项目的 Caffeine + Redis 组合面临完全相同的取舍。
- 缓存对象要可序列化且窄:缓存 DTO 投影而非 EF 实体(实体带导航属性序列化会拖出关联链),record +
JsonSerializer 默认 camelCase 即可,反序列化用 JsonSerializerOptions 统一配置避免字段名漂移。
你可能会懵的地方
-
@Cacheable 一行注解就能用,.NET 为什么全要手写?
.NET 生态没有官方 AOP 缓存注解;社区方案是 LazyCache、FusionCache(自带单飞与分布式刷新)这类库,或自己封一层。FusionCache 体验最接近 Spring Cache,生产项目推荐直接用它而不是裸 IDistributedCache。
-
TTL 设多长合理?
没有标准公式,看业务容忍度:题面 10 分钟、排行榜 5 秒、验证码 1 分钟。原则:越长越好(命中率),但一致性要求压得住。配合「写时主动删缓存」,长 TTL 风险可控。
-
Redis 挂了系统会怎样?
IDistributedCache 抛异常。生产做法:try/catch 降级直查数据库并告警——缓存是加速器不是依赖项。Spring 项目里同样要有这段降级逻辑,别让 Redis 故障升级成全站故障。
-
分布式锁怎么做?
SET key value NX PX 30000 的原子命令:Db.StringSetAsync("lock:judge", token, TimeSpan.FromSeconds(30), When.NotExists),释放时 Lua 脚本校验 token 防误删。这是 Redisson 看门狗的手工版,简单场景够用。
-
ExecuteUpdate 后缓存怎么同步?
任何「绕过实体修改」的路径(ExecuteUpdate、原生 SQL、其它服务写库)都必须显式删缓存——缓存失效逻辑要跟写路径走,不是跟 ORM 走。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 写库后「更新」缓存对象 | 并发写覆盖,缓存与库永久漂移 | 删缓存,读时回填 |
| 缓存 null 前不判断,直接缓存空集合永不过期 | 数据后来出现了也读不到 | 空值短 TTL(30s)或删缓存 |
| 所有 key 同一 TTL | 同一秒雪崩 | TTL + 随机抖动 |
| 把整个实体(含 Submissions 导航)序列化进缓存 | 序列化爆炸、循环引用 | 缓存窄 DTO |
| 用 IMemoryCache 存购物车/计数器这类跨实例数据 | 多实例各说各话 | 进 Redis(IDistributedCache / StackExchange.Redis) |
卡点急救
- Redis 连接失败
It was not possible to connect to redis:检查连接串与防火墙;本地开发 docker run -p 6379:6379 redis:7。注意 StackExchange.Redis 连接是懒建立的,报错可能在第一次读写时。
- 缓存读到旧数据:确认写路径都删了缓存(包括后台任务、批量脚本);加
InstanceName 前缀后排查时 key 要带前缀查。
- 反序列化抛
JsonException 字段缺失:新旧版本 DTO 结构变化,加 PropertyNameCaseInsensitive = true 与默认值处理;升级缓存结构时换新 key 版本号(problem:v2:{id})灰度切换。
面试常问
-
如何保证缓存与数据库一致性?
标准答案是 Cache-Aside:读时未命中回填;写时「先更新数据库、再删除缓存」,由下次读回填。极端并发下的短窗口不一致用 TTL 兜底;强一致场景改用延迟双删或订阅 binlog(Canal 类)异步删缓存。不建议「先删缓存再更新库」——删除后到提交前其它读会把旧值重新填进来。
-
缓存穿透、击穿、雪崩分别怎么防?
穿透(查不存在的数据):入参合法性校验、空值短缓存、布隆过滤器。击穿(热 key 过期瞬间并发回源):互斥单飞(进程内 SemaphoreSlim + 分布式锁)、热点 key 逻辑过期不物理过期。雪崩(大量 key 同时过期或 Redis 宕机):TTL 随机化、多级缓存、限流熔断降级直查库。
-
.NET 里 IDistributedCache 与直接用 StackExchange.Redis 的分工?
前者是框架抽象(可换 Redis/SQL Server 分布式缓存),够用于纯 KV 与 TTL 场景,代价是只有 byte[]/string 接口;后者提供 ZSet/Hash/Stream/PubSub 等完整数据结构与更精细的配置。工程惯例:简单缓存走抽象,结构化数据(排行榜、计数器、锁)直连 IConnectionMultiplexer。
自测三问
- 为什么写路径选「删缓存」而不是「更新缓存」?
- 单飞模式的双重检查为什么在锁内还要再查一次缓存?
- Keyset 分页为什么比 OFFSET 分页快?
小结一句话
缓存三板斧是「Cache-Aside 一致性 + 三防(穿透/击穿/雪崩)+ 两级缓存选型」,.NET 侧工具比 Spring 更裸,但策略完全同构——把 @Cacheable 底层那套手动实现一遍,反而是最好的理解方式。
延伸