CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
ASP.NET Core 实战入门/课时/数据存储与缓存
RawBlame
课时目录12 节

ASP.NET Core 实战入门

  1. 01ASP.NET Core 基础
  2. 02C# 与 .NET 基础
  3. 03Entity Framework Core
  4. 04依赖注入
  5. 05REST / GraphQL / gRPC
  6. 06数据存储与缓存
  7. 07SignalR 实时通信
  8. 08测试与 E2E
  9. 09消息队列
  10. 10微服务与网关
  11. 11日志、任务与工具链
  12. 12综合练习

数据存储与缓存

SQL/NoSQL、分布式缓存与 Elasticsearch。

35 分钟

缓存与数据优化(Redis + 查询治理)

这一课你将能做到

  1. 用 IMemoryCache + IDistributedCache(Redis)搭出二级缓存,缓存热门题目列表与详情
  2. 亲手实现防缓存击穿的「单飞(singleflight)」模式,说清穿透/击穿/雪崩三者的修法
  3. 对照 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 作为下一页游标;无限滚动列表/导出任务都适用

讲清楚要点

  1. 先更新库再删缓存,不是更新缓存:并发写时「更新缓存」可能把旧值写回;「删缓存 + 读时回填」把一致性责任交给下一次读。残留不一致窗口由 TTL 兜底,这是权衡后的工程标准答案。
  2. 三类经典问题的修法清单:穿透(不存在的 key 反复查库)→ 空值缓存 + 入参校验 + 布隆过滤器;击穿(热 key 失效瞬间并发回源)→ 单飞互斥 + 逻辑过期;雪崩(大量 key 同时失效)→ TTL 随机抖动 + 多级缓存 + 熔断降级。
  3. 多实例部署必须想清楚缓存在哪一级:IMemoryCache 每实例一份,改数据后其它实例的便签还是旧的;强一致需求放 Redis,容忍秒级陈旧的放内存。本站 Spring 项目的 Caffeine + Redis 组合面临完全相同的取舍。
  4. 缓存对象要可序列化且窄:缓存 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)

卡点急救

  1. Redis 连接失败 It was not possible to connect to redis:检查连接串与防火墙;本地开发 docker run -p 6379:6379 redis:7。注意 StackExchange.Redis 连接是懒建立的,报错可能在第一次读写时。
  2. 缓存读到旧数据:确认写路径都删了缓存(包括后台任务、批量脚本);加 InstanceName 前缀后排查时 key 要带前缀查。
  3. 反序列化抛 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。

自测三问

  1. 为什么写路径选「删缓存」而不是「更新缓存」?
  2. 单飞模式的双重检查为什么在锁内还要再查一次缓存?
  3. Keyset 分页为什么比 OFFSET 分页快?

小结一句话

缓存三板斧是「Cache-Aside 一致性 + 三防(穿透/击穿/雪崩)+ 两级缓存选型」,.NET 侧工具比 Spring 更裸,但策略完全同构——把 @Cacheable 底层那套手动实现一遍,反而是最好的理解方式。

延伸

  • 官方文档:ASP.NET Core 分布式缓存
  • 下一课:SignalR 实时推送——判题结果秒级到达页面,对照 Spring WebSocket/STOMP。
加载本节练习…
← 上一课REST / GraphQL / gRPC下一课 →SignalR 实时通信