用 MemoryCache
配合 GetOrCreateAsync
是最简安全路径
高并发下直接读写缓存(比如先
TryGetValue再
Set)必然引发重复计算和缓存击穿。.NET 6+ 的
MemoryCache.GetOrCreateAsync内部已用
ConcurrentDictionary+ 懒初始化锁机制,能确保同一 key 的 factory 只执行一次,其余并发请求自动等待并复用结果。
关键点:
GetOrCreateAsync的 factory 返回
Task<t></t>,必须是异步加载逻辑(如调用数据库或 HTTP API),不能塞同步阻塞操作 缓存项过期后,下一次访问仍会触发 factory,但多个并发请求仍被串行化——这是预期行为,不是 bug 不要手动在 factory 里加
lock或
SemaphoreSlim,这会抵消框架内置的协调能力
var value = await _cache.GetOrCreateAsync("user:123", async entry =>
{
entry.SlidingExpiration = TimeSpan.FromMinutes(10);
return await _userService.GetUserByIdAsync(123); // 真实异步 IO
});
需要主动更新缓存时,用 Refresh
而非 Set
如果业务要求“后台更新数据后立刻刷新缓存”,直接
Set会覆盖正在被
GetOrCreateAsync执行中的 factory,导致脏数据或异常。正确做法是调用
Refresh—— 它不改变值,只重置过期计时器,并标记该 entry 为“已刷新”,避免其他线程误判为过期而重复加载。
典型场景:用户资料修改成功后同步刷新缓存
Refresh("user:123") 安全,不会中断正在进行的 GetOrCreateAsync
Remove("user:123") 后再 Set是危险的,可能引发瞬间大量并发回源 若需强制重载新值(而非仅重置过期),应配合
GetOrCreateAsync的
entry.SetOptions重新设置过期策略
自定义缓存键要防哈希冲突和并发竞争
缓存 key 是字符串,但业务中常拼接参数生成,比如
"order:" + orderId + ":summary"。高并发下若 key 生成逻辑含非线程安全状态(如静态
StringBuilder、共享变量),会导致 key 错乱,进而缓存污染或击穿。 永远用不可变、纯函数式方式构造 key:
$"order:{orderId}:summary",别用 string.Format配共享格式器 避免在 key 中嵌入动态时间戳(如
DateTime.Now.ToString("HHmm")),这会让缓存失效加速且无法共享
对复杂对象做 key 时,用 HashCode.Combine(a, b, c)生成 int 再转字符串,比
JsonSerializer.Serialize(obj)更轻量且确定性更强
警惕 PostEvictionCallbacks
中的并发副作用
注册缓存淘汰回调(
RegisterPostEvictionCallback)常用于清理关联资源,但回调执行时机不确定,且可能被多个线程并发触发(尤其当缓存批量清除时)。 回调函数内禁止调用可能再次触发缓存读写的代码,否则易形成递归淘汰 所有外部操作(如发 MQ、写日志)必须幂等;例如用
ConcurrentDictionary<string bool></string>记录是否已处理过某 key 的淘汰 不要在回调里试图重新
Set同一个 key——此时缓存已空,又没走
GetOrCreateAsync的协调流程,极易引发竞态 缓存安全的核心不在“加锁”,而在“让框架替你协调”。只要 factory 是纯异步、key 是确定性的、更新动作走
Refresh,95% 的高并发缓存问题就消失了。剩下那些,往往出在业务逻辑把缓存当数据库用了。
