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 抖动)按数据特性选对,把「缓存挂了不影响可用性」的降级做好,缓存就从「定时炸弹」变成「可靠加速」。记住:没有缓存的系统慢而不乱,乱而不稳的缓存比慢更可怕。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。