延迟双删不是新概念,但线上一出缓存脏读,我曾经在项目中把它当成标准答案直接套进去。结果通常是代码写了两次删除,问题却没真正收住。
这篇就聚焦一个知识点:延迟双删到底解决什么问题,为什么它只能改善最终一致概率,以及在 .NET 服务里怎么把第二次删除做得更稳一点。
1. 问题背景:数据库已经更新,为什么缓存里还是旧值
聊一个高频场景:商品详情页读 Redis,后台商品编辑写数据库。读流量远大于写流量,最常见的缓存策略是 Cache Aside。
我的更新代码长这样:先更新数据库,再删除缓存。平时看起来没什么问题,但高并发下还是会偶发脏数据。业务侧看到的现象一般是:管理后台已经改价成功,前台用户短时间内还能查到旧价格。
关键不在“删没删缓存”,而在并发时序。
一个典型过程是这样的:
- 线程 A 更新数据库中的商品价格
- 线程 A 删除 Redis 中的商品缓存
- 线程 B 正好在这个空档读缓存未命中,开始查数据库
- 线程 B 读到的仍然是旧值,或者读到了事务提交前的旧快照
- 线程 B 把旧值重新写回 Redis
这时候数据库是新值,缓存却又变回旧值了。问题根因不是删除动作本身,而是删除之后,旧数据又被别的请求回填进缓存。
如果只看文字,这个并发窗口不算直观。把它画成时序图会更清楚:
sequenceDiagram
autonumber
participant A as 写线程A
participant R as Redis
participant D as Database
participant B as 读线程B
A->>D: 更新商品价格为新值
A->>R: 删除商品缓存
B->>R: 读取商品缓存
R-->>B: 未命中
B->>D: 查询商品数据
D-->>B: 返回旧值或旧快照
B->>R: 回填旧值到缓存
B-->>B: 后续请求命中旧缓存
这张图里最关键的不是“删缓存”这一步,而是删完之后到下一次稳定回填新值之前,中间存在一个旧值重新进入 Redis 的窗口。延迟双删补的就是这个窗口。
2. 原理解析:延迟双删到底在补哪一个洞
延迟双删的核心思路不复杂:
- 更新数据库
- 立即删除一次缓存
- 等一小段时间
- 再删除一次缓存
第二次删除的目标,不是补第一步删失败,而是补“旧值被重新回填”这个并发窗口。
2.1 它解决的是回填旧值,不是强一致
如果在第一次删除之后,正好有读请求把旧值塞回 Redis,第二次删除就有机会把这个旧值再清掉。这样后续请求再次 miss 时,会重新从数据库加载新值。
这也是为什么延迟双删本质上只是最终一致方案。它不是数据库事务的一部分,也不能保证所有读请求在任意时刻都看到新值。
2.2 延迟时间没有固定答案
很多文章会直接给一个建议值,比如 300ms 或 500ms。这个写法传播方便,但工程上不够严谨。
更稳的做法是按业务链路来估:延迟时间至少要覆盖一次典型读请求完成“查库 + 回填缓存”的时间上界。否则第二次删除过早执行,旧值还没来得及回填,第二次删除就等于白做。
反过来,延迟时间也不是越长越好。时间拉太长,不一致窗口本身也被放大了。
2.3 这套方案有明确适用边界
延迟双删更适合这些场景:
- 读多写少
- 可以容忍短暂脏读
- 写路径集中,缓存失效逻辑比较容易统一
如果业务要求写后立刻全局可见,或者任何一次脏读都会带来明显资损,延迟双删就不够了。这种场景通常要继续往消息驱动失效、版本号比对、读写穿透控制这些更重的方案走。
3. 示例代码:从直接删缓存到可靠执行第二次删除
下面示例基于 ASP.NET Core 和 StackExchange.Redis。重点不是 Redis API 怎么调,而是第二次删除怎么落得更稳。
3.1 问题写法:更新数据库后只删一次缓存
using StackExchange.Redis;
public sealed record ProductSnapshot(long Id, decimal SalePrice, string DisplayName);
public interface IProductRepository
{
Task UpdateAsync(ProductSnapshot product, CancellationToken ct);
Task<ProductSnapshot?> GetByIdAsync(long productId, CancellationToken ct);
}
public sealed class ProductCacheService(
IProductRepository productRepository,
IDatabase cache)
{
public async Task UpdateAsync(ProductSnapshot product, CancellationToken ct)
{
var cacheKey = BuildCacheKey(product.Id);
await productRepository.UpdateAsync(product, ct);
await cache.KeyDeleteAsync(cacheKey);
}
public async Task<ProductSnapshot?> GetAsync(long productId, CancellationToken ct)
{
var cacheKey = BuildCacheKey(productId);
var cached = await cache.StringGetAsync(cacheKey);
if (cached.HasValue)
{
return JsonSerializer.Deserialize<ProductSnapshot>(cached!);
}
var product = await productRepository.GetByIdAsync(productId, ct);
if (product is null)
{
return null;
}
await cache.StringSetAsync(cacheKey, JsonSerializer.Serialize(product), TimeSpan.FromMinutes(10));
return product;
}
private static string BuildCacheKey(long productId) => $"product:detail:{productId}";
}
这版代码简洁,但它没有处理“旧值回填”的并发窗口。
3.2 第一版延迟双删:思路对了,实现还不够稳
public sealed class ProductCacheService(
IProductRepository productRepository,
IDatabase cache,
ILogger<ProductCacheService> logger)
{
public async Task UpdateAsync(ProductSnapshot product, CancellationToken ct)
{
var cacheKey = BuildCacheKey(product.Id);
await productRepository.UpdateAsync(product, ct);
await cache.KeyDeleteAsync(cacheKey);
_ = Task.Run(async () =>
{
try
{
await Task.Delay(TimeSpan.FromMilliseconds(300));
await cache.KeyDeleteAsync(cacheKey);
}
catch (Exception ex)
{
logger.LogError(ex, "Delayed cache delete failed. ProductId={ProductId}", product.Id);
}
});
}
private static string BuildCacheKey(long productId) => $"product:detail:{productId}";
}
这版已经表达了延迟双删的核心思路,但直接在请求里 Task.Run 还有几个明显问题:
- 应用重启时,第二次删除任务可能直接丢失
- 短时间大量写入时,会堆出很多后台任务
- 删除失败只能打日志,缺少统一重试入口
如果只停在这里,初学者会“知道怎么写”,但上线后还是容易出事故。
3.3 更稳一点的落地方式:后台队列 + 托管服务
先把第二次删除抽成一个后台任务。
using System.Threading.Channels;
public sealed record DelayedCacheDeleteJob(string CacheKey, TimeSpan Delay);
public interface IDelayedCacheDeleteQueue
{
ValueTask EnqueueAsync(DelayedCacheDeleteJob job, CancellationToken ct);
ValueTask<DelayedCacheDeleteJob> DequeueAsync(CancellationToken ct);
}
public sealed class DelayedCacheDeleteQueue : IDelayedCacheDeleteQueue
{
private readonly Channel<DelayedCacheDeleteJob> _channel = Channel.CreateUnbounded<DelayedCacheDeleteJob>();
public ValueTask EnqueueAsync(DelayedCacheDeleteJob job, CancellationToken ct)
=> _channel.Writer.WriteAsync(job, ct);
public ValueTask<DelayedCacheDeleteJob> DequeueAsync(CancellationToken ct)
=> _channel.Reader.ReadAsync(ct);
}
再用 BackgroundService 统一执行第二次删除。
using Microsoft.Extensions.Hosting;
using StackExchange.Redis;
public sealed class DelayedCacheDeleteWorker(
IDelayedCacheDeleteQueue queue,
IConnectionMultiplexer redis,
ILogger<DelayedCacheDeleteWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var cache = redis.GetDatabase();
while (!stoppingToken.IsCancellationRequested)
{
var job = await queue.DequeueAsync(stoppingToken);
try
{
await Task.Delay(job.Delay, stoppingToken);
await cache.KeyDeleteAsync(job.CacheKey);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Delayed cache delete failed. CacheKey={CacheKey}", job.CacheKey);
}
}
}
}
业务更新路径只负责入队,不负责在请求线程里等第二次删除。
public sealed class ProductCacheService(
IProductRepository productRepository,
IConnectionMultiplexer redis,
IDelayedCacheDeleteQueue delayedDeleteQueue)
{
public async Task UpdateAsync(ProductSnapshot product, CancellationToken ct)
{
var cacheKey = BuildCacheKey(product.Id);
var cache = redis.GetDatabase();
await productRepository.UpdateAsync(product, ct);
await cache.KeyDeleteAsync(cacheKey);
await delayedDeleteQueue.EnqueueAsync(
new DelayedCacheDeleteJob(cacheKey, TimeSpan.FromMilliseconds(300)),
ct);
}
private static string BuildCacheKey(long productId) => $"product:detail:{productId}";
}
最后把队列和托管服务注册进容器。
builder.Services.AddSingleton<IDelayedCacheDeleteQueue, DelayedCacheDeleteQueue>();
builder.Services.AddHostedService<DelayedCacheDeleteWorker>();
需要说明的是,这里用的 Channel.CreateUnbounded 是进程内内存队列。相比直接 Task.Run,它的改进在于把所有第二次删除统一收口到一个后台 Worker 里,更容易观测和控制并发。但它没有解决进程级可靠性的问题——应用重启时,队列里还没执行的任务仍然会丢失。
如果业务对第二次删除的成功率有更高要求,可以考虑把任务持久化:写入数据库任务表、接入消息队列(如 RabbitMQ、Kafka),或者使用 Hangfire 这类带持久化的后台任务框架。这些方案的代价是引入额外依赖,适不适合引入取决于你们对这个"删除丢失"概率的容忍度。
3.4 延迟时间怎么定,别直接抄模板值
如果你们接口平时查库加回填缓存只要 20ms,延迟 500ms 可能太保守。如果某些慢查询高峰期能到 200ms 以上,延迟 50ms 又太短。
更实际的方式是结合你们自己的链路数据:
- 统计缓存 miss 后的查库耗时 P95/P99
- 看一次回填 Redis 的耗时上界
- 延迟时间至少覆盖这个窗口,再预留一点抖动空间
这不是一个固定配置,而是和你的读路径成本绑定。
4. 总结
延迟双删解决的不是“缓存删不掉”,而是“旧值在并发窗口里被重新写回缓存”。它能改善最终一致概率,但给不了强一致保证。
如果你的业务能接受短暂脏读,这是一种成本不高、实现也不复杂的折中方案。但真正决定效果的,从来不是“删两次”这四个字,而是第二次删除能不能可靠执行,以及延迟时间是不是按真实链路调出来的。