缓存与并发控制

系统讲解 .NET 缓存体系与并发控制,覆盖 IMemoryCache 本地缓存、Redis 分布式缓存、缓存穿透与雪崩的防御,以及分布式锁与并发边界的一致性实践。

1. 缓存的基本模型与收益

一句话总结: 缓存用空间换时间,把昂贵的计算结果放在更快的存储里复用,命中率与一致性是缓存设计的两个核心变量。

缓存无处不在:CPU 多级缓存、内存缓存、Redis、CDN。对 .NET 服务来说,缓存的是计算成本高、读取频率高、变更频率低的数据——热点商品、配置、会话、聚合查询结果。缓存能显著降低数据库压力与响应延迟。

// 未加缓存的查询:每次都打数据库
public async Task<ProductDto> GetProductAsync(int id)
{
    var product = await _db.Products.FindAsync(id);
    return product is null ? throw new KeyNotFoundException() : Mapper.ToDto(product);
}

// 加入内存缓存
public async Task<ProductDto> GetProductCachedAsync(int id)
{
    if (_cache.TryGetValue($"product:{id}", out ProductDto? cached))
        return cached!;

    var dto = await GetProductAsync(id);
    _cache.Set($"product:{id}", dto, TimeSpan.FromMinutes(10));
    return dto;
}
缓存层级存储延迟量级
本地内存IMemoryCache纳秒~微秒
Redis网络内存亚毫秒~毫秒
数据库磁盘/内存毫秒级

避坑: 缓存不是免费的——每次命中省下的延迟,都要用缓存失效逻辑的复杂度来还。不是所有数据都值得缓存:变更频繁、一致性要求高、读取量小的数据,缓存反而引入不一致与失效风暴。

2. IMemoryCache 本地缓存

一句话总结: IMemoryCache 是进程内缓存,GetOrCreateAsync 封装「读缓存→miss 则计算→写入」的标准流程,配合过期策略与容量上限避免内存失控。

IMemoryCache 注入即用,GetOrCreateAsync 合并了命中与未命中的分支。过期策略有绝对过期与滑动过期,Size 与 SizeLimit 做容量管理,CancellationToken 支持缓存项级取消。

builder.Services.AddMemoryCache(options =>
{
    options.SizeLimit = 10_000;               // 缓存项数量上限
    options.CompactionPercentage = 0.5;       // 超限时压缩比例
});

public class ProductService(IMemoryCache cache, IProductRepository repo)
{
    public async Task<ProductDto> GetAsync(int id)
    {
        return await cache.GetOrCreateAsync($"product:{id}", async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
            entry.SlidingExpiration = TimeSpan.FromMinutes(5);
            entry.Size = 1;

            var product = await repo.FindByIdAsync(id)
                ?? throw new KeyNotFoundException();
            return Mapper.ToDto(product);
        }) ?? throw new KeyNotFoundException();
    }

    public void Invalidate(int id) => cache.Remove($"product:{id}");
}
API用途
GetOrCreateAsync命中返回,miss 计算并写入
Set/Remove手动写入/删除
AbsoluteExpiration绝对过期
SlidingExpiration滑动过期(无访问即失效)
Size/SizeLimit容量约束

避坑: 内存缓存的三大坑——其一,缓存持有大对象(整个列表)会撑爆内存,用 Size 限流;其二,滑动过期适合「一直在用」的数据,但热点数据被反复命中会永不失效,配合绝对过期兜底;其三,进程内缓存多实例间不一致,本地缓存只适合可容忍短暂不一致的场景。

3. Redis 分布式缓存

一句话总结: IDistributedCache 提供跨进程一致的缓存抽象,Redis 是事实标准,序列化策略与批量预热决定分布式缓存的效率与正确性。

多实例部署时本地缓存各自为政,IDistributedCache 把缓存放到所有实例共享的 Redis。StackExchange.Redis 客户端 + AddStackExchangeRedisCache 一行接入,值的序列化(JSON/Protobuf)与 key 前缀是工程细节。

builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
    options.InstanceName = "shop:";          // 统一前缀,避免键冲突
});

public class OrderSummaryService(IDistributedCache cache, IOrderRepository repo)
{
    public async Task<OrderSummary> GetSummaryAsync(int customerId)
    {
        var key = $"summary:{customerId}";
        var bytes = await cache.GetAsync(key);

        if (bytes is not null)
            return JsonSerializer.Deserialize<OrderSummary>(bytes)!;

        var summary = await repo.BuildSummaryAsync(customerId);
        var json = JsonSerializer.SerializeToUtf8Bytes(summary);
        await cache.SetAsync(key, json,
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15)
            });
        return summary;
    }
}
// 命中/未命中与 value 均为 UTF-8 字节
{
  "key": "shop:summary:42",
  "value": "{\"TotalOrders\":12,\"TotalAmount\":3456.78}",
  "ttl": 900
}
能力说明
跨实例共享所有节点读同一份缓存
序列化JSON/Protobuf,可配置
过期Redis TTL 由服务端执行
前缀隔离InstanceName 区分应用
高可用Redis Cluster / Sentinel

避坑: Redis 缓存需要处理两个现实——其一,值必须是可序列化的字节,别把 DbContext 或复杂对象直接塞进去;其二,Redis 挂了应用不能跟着挂——缓存是锦上添花,不能成为新的单点,要设置超时与降级(缓存不可用时直连数据库)。

4. 缓存穿透、击穿与雪崩

一句话总结: 穿透(查不存在的数据)、击穿(热点 key 过期)、雪崩(大量 key 同时过期)是缓存三大事故,各有针对性防御手段。

穿透是攻击/空数据打穿缓存直压数据库;击穿是单个热点 key 过期瞬间的并发回源;雪崩是大批 key 同时过期或 Redis 重启导致的集体回源。防御分别用「空值缓存 + 布隆过滤器」「互斥锁重建」「过期时间加随机抖动 + 多级缓存」。

// 防穿透:空值也缓存 + 布隆过滤器前置
public async Task<ProductDto?> GetAsync(int id)
{
    if (!_bloomFilter.Contains($"product:{id}"))
        return null;                          // 布隆过滤器判定不存在,直接短路

    var key = $"product:{id}";
    var cached = await _cache.GetAsync(key);
    if (cached is not null)
    {
        // 空值标记也缓存,防止空数据穿透
        return JsonSerializer.Deserialize<ProductDto?>(cached);
    }

    var dto = await _repo.FindByIdAsync(id);
    var json = JsonSerializer.SerializeToUtf8Bytes(dto);   // null 也写入
    await _cache.SetAsync(key, json, new DistributedCacheEntryOptions
    {
        AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
    });
    return dto;
}
// 防击穿:分布式锁保证只有一个请求重建热点 key
public async Task<ProductDto> GetHotAsync(int id)
{
    var key = $"hot:{id}";
    if (await _cache.GetAsync(key) is { } hit)
        return JsonSerializer.Deserialize<ProductDto>(hit)!;

    await using var gate = await _lock.AcquireAsync($"lock:{key}",
        TimeSpan.FromSeconds(5));            // 获取分布式锁

    // 双重检查:锁到手后可能已被其他线程重建
    if (await _cache.GetAsync(key) is { } fresh)
        return JsonSerializer.Deserialize<ProductDto>(fresh)!;

    var dto = await _repo.FindByIdAsync(id) ?? throw new KeyNotFoundException();
    var json = JsonSerializer.SerializeToUtf8Bytes(dto);
    // 加随机抖动,防雪崩
    var ttl = TimeSpan.FromMinutes(10 + Random.Shared.Next(0, 5));
    await _cache.SetAsync(key, json, new DistributedCacheEntryOptions
    {
        AbsoluteExpirationRelativeToNow = ttl
    });
    return dto;
}
事故特征防御
穿透空 key 反复打库空值缓存 / 布隆过滤器
击穿热点 key 过期瞬间互斥锁重建 / 永不过期+异步刷新
雪崩大批 key 同时过期TTL 随机抖动 / 多级缓存

避坑: 布隆过滤器有误判(说存在其实不存在),只用于「快速排除不存在」,不能替代空值缓存。分布式锁防击穿的代价是锁等待——热点 key 重建要快,锁要短;永远不要持有锁去做慢操作,否则把击穿变成击瘫。

5. 分布式锁与并发边界

一句话总结: 分布式锁让多实例对共享资源(库存、优惠券、定时任务)的互斥访问可控,Redis SET NX + 过期 + 续约是实现要点,锁的语义要与业务边界对齐。

多实例下 lock 关键字与 SemaphoreSlim 只在单进程有效。Redis 分布式锁用 SET key value NX PX 毫秒 原子占锁,业务完成或超时后释放。正确实现要处理:原子性(SetNx)、超时保护(PX)、误删他人锁(用唯一 value 校验)、续约(长任务)。

// 基于 StackExchange.Redis 的锁封装
public sealed class RedisDistributedLock
{
    private readonly IDatabase _db;
    private const string Script =
        """
        if redis.call('get', KEYS[1]) == ARGV[1] then
            return redis.call('del', KEYS[1])
        else
            return 0
        end
        """;

    public RedisDistributedLock(IConnectionMultiplexer redis)
        => _db = redis.GetDatabase();

    public async Task<LockHandle?> AcquireAsync(string key, TimeSpan expiry)
    {
        var token = Guid.NewGuid().ToString("N");   // 唯一身份
        var ok = await _db.StringSetAsync(key, token, expiry, When.NotExists);
        return ok ? new LockHandle(this, key, token) : null;
    }

    private async Task ReleaseAsync(string key, string token)
        => await _db.ScriptEvaluateAsync(Script, [key], [token]);
}

public sealed class LockHandle : IAsyncDisposable
{
    private readonly RedisDistributedLock _lock;
    private readonly string _key;
    private readonly string _token;

    public LockHandle(RedisDistributedLock l, string k, string t)
        => (_lock, _key, _token) = (l, k, t);

    public async ValueTask DisposeAsync()
        => await _lock.ReleaseSafeAsync(_key, _token);
}
环节实现要点
占锁SET NX PX 原子
释放Lua 校验唯一 token 后 DEL
超时PX 兜底,防死锁
续约长任务定期延长过期
等待带超时的自旋/阻塞

避坑: 分布式锁最常见的错误是忘了「锁内操作要短」——持锁做慢 IO,Redis 过期后锁就失去意义。另一坑是释放时直接 DEL 不校验 value,可能误删别人的锁(A 超时后 B 拿到锁,A 却把 B 的锁删了)。必须用唯一 token + Lua 校验释放。

6. 并发写入与乐观并发

一句话总结: 高并发写入靠乐观并发控制(版本号校验)而非粗暴锁库表,EF Core 的并发令牌与 Redis 事务/原子操作共同保证数据一致性。

业务层的并发写(库存扣减、余额变更)优先用「条件更新 + 版本校验」:更新语句带上原值条件,受影响行数为 0 即说明已被并发修改。EF Core 用 rowversion 并发令牌,Redis 用 Lua 脚本保证读取-修改-写入的原子性。

// EF Core 乐观并发:rowversion 令牌
public class Product
{
    public int Id { get; set; }
    public int Stock { get; set; }
    public byte[] RowVersion { get; set; } = [];   // 并发令牌
}

public async Task<bool> TryDeductStockAsync(int id, int qty)
{
    var product = await _db.Products.SingleAsync(p => p.Id == id);
    if (product.Stock < qty) return false;

    product.Stock -= qty;
    try
    {
        await _db.SaveChangesAsync();       // WHERE 带上 RowVersion
        return true;
    }
    catch (DbUpdateConcurrencyException)
    {
        return false;                        // 被并发修改,重试或失败
    }
}
// Redis Lua 原子扣减:读-改-写在服务端一次完成
var script = """
    local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
    if stock >= tonumber(ARGV[1]) then
        redis.call('DECRBY', KEYS[1], ARGV[1])
        return 1
    end
    return 0
    """;
var ok = await _db.ScriptEvaluateAsync(script, ["stock:42"], [qty]);
并发策略机制适用
乐观锁(版本号)更新带条件,0 行即冲突Web 高并发默认
分布式锁串行化整个临界区低频强一致
Lua 原子脚本服务端原子操作Redis 计数器
消息队列串行单消费者处理写削峰

避坑: 乐观并发冲突不是 bug 而是业务信号——正确姿势是捕获异常后重试、合并或提示用户,不要静默吞掉。SaveChanges 里一次更新多行时,任一行冲突整个事务失败,要保证调用方能按行处理。

7. 缓存一致性策略

一句话总结: 缓存与数据库的一致性是缓存最难的工程问题,Cache-Aside 是默认选择,更新缓存要「先写库再删缓存」并容忍短暂不一致,强一致场景干脆不缓存。

Cache-Aside(旁路缓存)是主流模式:读 miss 后回源写缓存,写操作先更新数据库再删除缓存。删除而非更新缓存,是为了避免并发下「缓存写入顺序」带来的脏值。需要更强一致时用订阅数据库变更(CDC)主动失效。

// Cache-Aside 写入路径:先写库,再删缓存
public async Task UpdateProductAsync(int id, ProductDto dto)
{
    await _repo.UpdateAsync(id, dto);               // 1. 先写数据库
    await _cache.RemoveAsync($"product:{id}");      // 2. 再删缓存

    // 为什么不更新缓存而是删除?
    // 并发场景下「写缓存」可能写入旧值覆盖新值;
    // 删除后下次读取重新回源,保证拿到最新。
}
// 订阅数据库变更:CDC 驱动的主动失效
public class ProductChangedConsumer(IMessageBus bus, IDistributedCache cache)
{
    public async Task HandleAsync(ProductChangedEvent evt)
    {
        // 收到 binlog/outbox 事件后精准删除相关 key
        await cache.RemoveAsync($"product:{evt.ProductId}");
        await cache.RemoveAsync("product:hot-list");   // 列表缓存也失效
    }
}
模式时机一致性
Cache-Aside读回源写、写删缓存最终一致
先写缓存写路径更新缓存易脏读,少用
订阅失效CDC 事件删 key强一致
不缓存一致性敏感绝对一致

避坑: 缓存一致性的第一原则是**「写库后删缓存」而不是「写库后写缓存」**——后者在并发下极易用旧值覆盖新值。缓存不是数据库的影子,它是「可丢弃的加速层」;接受最终一致,用 TTL 做兜底,强一致场景(支付、库存精确)直接走数据库。

8. 总结

环节要点
缓存模型空间换时间,命中率与一致性两个变量
本地缓存IMemoryCache + GetOrCreateAsync + 容量上限
分布式缓存IDistributedCache + Redis,序列化与降级
三大事故穿透(空值+布隆)、击穿(互斥重建)、雪崩(TTL 抖动)
分布式锁SET NX PX + 唯一 token 校验释放,锁内操作要短
并发写入乐观锁版本校验 + Redis Lua 原子操作
一致性Cache-Aside 先写库再删缓存,TTL 兜底

缓存是高性能 .NET 服务绕不开的加速器,也是事故的高发区。缓存的本质是「用一致性复杂度换取延迟收益」——把热点数据放进多级缓存,把防御手段(空值缓存、锁、TTL 抖动)按数据特性选对,把「缓存挂了不影响可用性」的降级做好,缓存就从「定时炸弹」变成「可靠加速」。记住:没有缓存的系统慢而不乱,乱而不稳的缓存比慢更可怕。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. 消息与后台任务
  2. 测试体系:xUnit 与 Moq
  3. 托管生命周期与部署