c# 高并发下的缓存预热和数据同步方案

来源:这里教程网 时间:2026-02-21 17:37:54 作者:

缓存预热必须在应用启动后、首个请求前完成

高并发下,如果等第一个请求触发缓存加载,必然导致大量线程争抢初始化(即“缓存击穿”),尤其当

GetOrAdd
用的是非线程安全的工厂函数时,可能重复执行耗时操作。正确做法是:在
Program.cs
Startup.ConfigureServices
中显式调用预热逻辑,并确保其同步阻塞到完成。

使用
Task.Run(() => PreheatCache()).Wait()
强制同步等待(注意不要在 ASP.NET Core 的同步上下文里用
.Result
,易死锁)
预热函数内部应避免依赖
IHttpContextAccessor
等请求作用域服务;改用
IServiceScopeFactory
创建独立 scope
若预热数据量大,可分批 +
await Task.Delay(1)
防止单次占用主线程太久,但整体仍需在
WebApplication
构建完成前结束

分布式环境必须用带版本号的缓存键 + 原子写入

单机

MemoryCache
在多实例部署下完全失效。必须切换为
IDistributedCache
(如 Redis),且不能直接存原始对象——否则多个节点同时更新会覆盖彼此,造成脏数据。

缓存键格式建议为:
$"user:profile:v2:{userId}"
,其中
v2
是业务版本号,每次数据结构变更就升级,强制全量刷新
写入时用
distributedCache.SetStringAsync(key, json, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(2) })
,禁用滑动过期(
SlidingExpiration
),防止“越用越旧”
关键数据更新后,必须同步调用
distributedCache.RemoveAsync(key)
,而不是等过期;删除失败要记录告警,不可静默忽略

数据库与缓存一致性靠“先删缓存,再更DB”+ 延迟双删兜底

“先更DB,再删缓存”在并发更新时有概率导致缓存残留旧值(DB 更新成功,但删缓存失败或被覆盖)。生产环境必须用“删除-更新-延迟再删”三步法。

第一步:调用
distributedCache.RemoveAsync(key)
第二步:执行 EF Core 的
SaveChangesAsync()
第三步:启动后台任务,
await Task.Delay(TimeSpan.FromSeconds(500))
后再次
RemoveAsync(key)
—— 覆盖因主从延迟、重试机制导致的缓存回写
所有删缓存操作必须包裹
try/catch
,失败时写入本地队列(如
ConcurrentQueue
),由后台服务重试,不能丢弃

并发读场景下避免 Cache Stampede,用 SemaphoreSlim 限流重建

即使做了预热,缓存过期瞬间仍可能有上百请求同时发现缓存为空,全部涌入 DB。不能靠

GetOrCreateAsync
默认行为扛住——它的 factory 函数不是原子的。

为每个缓存键维护一个
ConcurrentDictionary<string semaphoreslim></string>
,键为 cache key,值为独占信号量
读取时:先
cache.TryGetValue(key, out var value)
;若为空,则
semaphores.GetOrAdd(key, _ => new SemaphoreSlim(1,1)).WaitAsync()
获得信号量后,再次检查缓存(double-check),未命中才重建;完成后
Release()
并移除该 semaphore(避免内存泄漏)
注意:不要用
lock
,它跨进程无效;也不要复用同一个
SemaphoreSlim
实例,会导致不同 key 互相阻塞
private static readonly ConcurrentDictionary<string, SemaphoreSlim> _semaphores = new();
public async Task<UserProfile> GetProfileAsync(int userId)
{
    var key = $"user:profile:v2:{userId}";
    if (_cache.TryGetValue(key, out UserProfile profile))
        return profile;
    var semaphore = _semaphores.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
    await semaphore.WaitAsync();
    try
    {
        // Double-check after acquiring semaphore
        if (_cache.TryGetValue(key, out profile))
            return profile;
        profile = await _db.Users.FirstAsync(u => u.Id == userId);
        await _cache.SetStringAsync(key, JsonSerializer.Serialize(profile), 
            new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) });
        return profile;
    }
    finally
    {
        semaphore.Release();
        _semaphores.TryRemove(key, out _);
    }
}
缓存键设计、删除时机、并发重建这三点一旦漏掉任意一个,高并发下的数据不一致就会变成偶发性线上事故——而这类问题往往在压测时不出,上线后半夜爆发。

相关推荐