Redis:延迟双删的适用边界与落地细节

延迟双删不是新概念,但线上一出缓存脏读,我曾经在项目中把它当成标准答案直接套进去。结果通常是代码写了两次删除,问题却没真正收住。

这篇就聚焦一个知识点:延迟双删到底解决什么问题,为什么它只能改善最终一致概率,以及在 .NET 服务里怎么把第二次删除做得更稳一点。

1. 问题背景:数据库已经更新,为什么缓存里还是旧值

聊一个高频场景:商品详情页读 Redis,后台商品编辑写数据库。读流量远大于写流量,最常见的缓存策略是 Cache Aside。

我的更新代码长这样:先更新数据库,再删除缓存。平时看起来没什么问题,但高并发下还是会偶发脏数据。业务侧看到的现象一般是:管理后台已经改价成功,前台用户短时间内还能查到旧价格。

关键不在"删没删缓存",而在并发时序。

一个典型过程是这样的:

  1. 线程 A 更新数据库中的商品价格
  2. 线程 A 删除 Redis 中的商品缓存
  3. 线程 B 正好在这个空档读缓存未命中,开始查数据库
  4. 线程 B 读到的仍然是旧值,或者读到了事务提交前的旧快照
  5. 线程 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. 原理解析:延迟双删到底在补哪一个洞

延迟双删的核心思路不复杂:

  1. 更新数据库
  2. 立即删除一次缓存
  3. 等一小段时间
  4. 再删除一次缓存

第二次删除的目标,不是补第一步删失败,而是补"旧值被重新回填"这个并发窗口。

2.1 它解决的是回填旧值,不是强一致

如果在第一次删除之后,正好有读请求把旧值塞回 Redis,第二次删除就有机会把这个旧值再清掉。这样后续请求再次 miss 时,会重新从数据库加载新值。

这也是为什么延迟双删本质上只是最终一致方案。它不是数据库事务的一部分,也不能保证所有读请求在任意时刻都看到新值。

2.2 延迟时间没有固定答案

很多文章会直接给一个建议值,比如 300ms 或 500ms。这个写法传播方便,但工程上不够严谨。

更稳的做法是按业务链路来估:延迟时间至少要覆盖一次典型读请求完成"查库 + 回填缓存"的时间上界。否则第二次删除过早执行,旧值还没来得及回填,第二次删除就等于白做。

反过来,延迟时间也不是越长越好。时间拉太长,不一致窗口本身也被放大了。

2.3 这套方案有明确适用边界

延迟双删更适合这些场景:

  • 读多写少
  • 可以容忍短暂脏读
  • 写路径集中,缓存失效逻辑比较容易统一

如果业务要求写后立刻全局可见,或者任何一次脏读都会带来明显资损,延迟双删就不够了。这种场景通常要继续往消息驱动失效、版本号比对、读写穿透控制这些更重的方案走。

3. 示例代码:从直接删缓存到可靠执行第二次删除

下面示例基于 ASP.NET Core 和 StackExchange.Redis。重点不是 Redis API 怎么调,而是第二次删除怎么落得更稳。

3.1 问题写法:更新数据库后只删一次缓存

csharp 复制代码
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 第一版延迟双删:思路对了,实现还不够稳

csharp 复制代码
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 更稳一点的落地方式:后台队列 + 托管服务

先把第二次删除抽成一个后台任务。

csharp 复制代码
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 统一执行第二次删除。

csharp 复制代码
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);
            }
        }
    }
}

业务更新路径只负责入队,不负责在请求线程里等第二次删除。

csharp 复制代码
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}";
}

最后把队列和托管服务注册进容器。

csharp 复制代码
builder.Services.AddSingleton<IDelayedCacheDeleteQueue, DelayedCacheDeleteQueue>();
builder.Services.AddHostedService<DelayedCacheDeleteWorker>();

需要说明的是,这里用的 Channel.CreateUnbounded 是进程内内存队列。相比直接 Task.Run,它的改进在于把所有第二次删除统一收口到一个后台 Worker 里,更容易观测和控制并发。但它没有解决进程级可靠性的问题------应用重启时,队列里还没执行的任务仍然会丢失。

如果业务对第二次删除的成功率有更高要求,可以考虑把任务持久化:写入数据库任务表、接入消息队列(如 RabbitMQ、Kafka),或者使用 Hangfire 这类带持久化的后台任务框架。这些方案的代价是引入额外依赖,适不适合引入取决于你们对这个"删除丢失"概率的容忍度。

3.4 延迟时间怎么定,别直接抄模板值

如果你们接口平时查库加回填缓存只要 20ms,延迟 500ms 可能太保守。如果某些慢查询高峰期能到 200ms 以上,延迟 50ms 又太短。

更实际的方式是结合你们自己的链路数据:

  1. 统计缓存 miss 后的查库耗时 P95/P99
  2. 看一次回填 Redis 的耗时上界
  3. 延迟时间至少覆盖这个窗口,再预留一点抖动空间

这不是一个固定配置,而是和你的读路径成本绑定。

4. 总结

延迟双删解决的不是"缓存删不掉",而是"旧值在并发窗口里被重新写回缓存"。它能改善最终一致概率,但给不了强一致保证。

如果你的业务能接受短暂脏读,这是一种成本不高、实现也不复杂的折中方案。但真正决定效果的,从来不是"删两次"这四个字,而是第二次删除能不能可靠执行,以及延迟时间是不是按真实链路调出来的。

相关推荐
木斯佳3 小时前
HarmonyOS 6 三方SDK对接:从半接模式看Share Kit原理——系统分享的运行机制与设计理念
设计模式·harmonyos·架构设计·分享·半接模式
telllong1 天前
消息总线设计:asyncio.Queue实战
python·架构设计·asyncio
黄俊懿3 天前
【架构师从入门到进阶】第二章:系统衡量指标——第一节:伸缩性、扩展性、安全性
分布式·后端·中间件·架构·系统架构·架构设计
带娃的IT创业者4 天前
WeClaw 日志分析实战:如何从海量日志中快速定位根因?
运维·python·websocket·jenkins·fastapi·架构设计·实时通信
wotaifuzao4 天前
从128-bit到16-bit:BLE UUID背后的带宽战争与架构设计
性能优化·蓝牙·uuid·低功耗蓝牙·架构设计·嵌入式开发·ble
带娃的IT创业者5 天前
WeClaw 架构演进史:从 0 到 1 构建跨平台 AI 助手的完整历程
人工智能·python·websocket·架构·fastapi·架构设计·实时通信
硅基喵6 天前
EF Core 拦截器实战:SaveChangesInterceptor、CommandInterceptor 与审计落地
架构设计·ef core
mingshili6 天前
[架构设计] 依赖注入优于单例模式
单例模式·架构设计
小邓的技术笔记6 天前
ASP.NET Core 外部依赖调用治理实战:HttpClientFactory、Polly 与幂等边界
架构设计