欢迎光临
我们一直在努力

缓存一致性实战:先更新库还是先删缓存?延迟双删与最终一致

缓存一致性实战:先更新库还是先删缓存?延迟双删与最终一致

“缓存和数据库不一致"不是 bug,是设计决策——你要选的是"不一致多久、谁来兜底”。

用户改了昵称,列表页刷出来还是旧的;评论区删了内容,详情页还能看见——这类工单的根源都一样:缓存与数据库的一致性策略没想清楚。这篇把四种主流方案摆在一起对比,讲清各自的坑和适用场景,最后给你一套我实际在用的组合拳。

一、先把问题拆开:为什么会有不一致

缓存的读写路径天然是两步操作(缓存一次、数据库一次),而这两步之间没有任何原子性保障。不一致的窗口 = 两次操作之间的时间差 + 任何一方的失败。设计缓存策略,本质上是在回答三个问题:

  • 写的时候,先动缓存还是先动库?
  • 动缓存,是删(invalidation)还是改(write-through)?
  • 读到旧数据,可接受多久?
  • 先把一个执念放下:强一致基本做不到(除非缓存和库在同一事务里,那性能也就没了)。生产上追求的是最终一致 + 窗口尽量小 + 有兜底。

    二、四种方案逐个拆

    方案 1:先更新数据库,再更新缓存 ❌

    直觉方案,但有两个致命问题:

    • 并发写导致脏数据:线程 A 更新库(price=100) → 线程 B 更新库(price=200) → B 更新缓存(200) → A 更新缓存(100)。库里是 200,缓存里是 100,且再也不会被纠正(直到过期)。
    • 写多读少时白算:每次写都更新缓存,但这个值可能根本没人读。

    结论:别用"更新缓存",写操作只做失效(删除),把计算推迟到下一次读。

    方案 2:先删除缓存,再更新数据库 ❌

    并发下的经典灾难:

    读线程(缓存未命中) 写线程
    │ │
    ├─ 查缓存 miss │
    ├─ 查库,得旧值 100 │
    │ ├─ 删缓存
    │ └─ 更新库 → 200
    └─ 把旧值 100 写回缓存 ← 脏数据驻留直到过期

    写库和"读旧值回填"赛跑,读线程赢了就脏了。用得少,除非配合下面的延迟双删。

    方案 3:Cache-Aside(先更新库,再删缓存)✅ 默认选择

    这是绝大多数业务的正确答案:

    # 读
    def get(key):
    v = redis.get(key)
    if v is not None:
    return v
    v = db.query(key)
    redis.setex(key, ttl, v) # 回填带过期时间
    return v

    # 写
    def update(key, value):
    db.update(key, value) # 1. 先更新数据库
    redis.delete(key) # 2. 再删除缓存

    它也会有不一致的窗口(写库成功、删缓存失败;或读线程在删除后瞬间回填旧值),但窗口是毫秒级且被下一个 TTL 兜底。把它的两个残余风险补掉:

    • 删缓存失败 → 删除操作放进重试队列(本地重试 3 次 → 仍失败发消息队列异步重试),保证最终删掉。
    • 读写并发回填旧值 → 窗口极小(要求"读请求在写库前查库、在删缓存后回填"这个交错),加上 TTL 30 分钟兜底,绝大多数业务可以接受。不能接受的,用方案 4。

    方案 4:延迟双删(对付"先删后更"或高并发回填)

    对方案 2 的补丁,也可用于写后读敏感场景:

    def update(key, value):
    redis.delete(key) # 第一次删
    db.update(key, value) # 更新库
    time.sleep(0.5) # 等"正在飞的旧读"全部回填完
    redis.delete(key) # 第二次删,清掉可能被回填的旧值

    sleep 的时间 = 读请求的回填窗口(一般几百毫秒,按你服务的 P99 读耗时定)。生产上这个延迟通常不用线程睡,而是丢进延迟队列异步执行,避免占着写请求的时间。

    三、TTL 是最后的兜底,不是可选项

    任何策略都会有漏网之鱼,TTL 是最终防线。设置原则:

    数据特征TTL 建议说明
    配置类、基本不改 1 小时 ~ 1 天 长缓存 + 主动失效
    用户资料、商品详情 5 ~ 30 分钟 短 TTL 减少不一致窗口
    排行榜、计数类 30 秒 ~ 5 分钟 允许短暂不准,或走定时重算
    实时性要求极高 不缓存或极短 TTL 直读库 + 库侧优化

    同一个 key 的 TTL 加随机抖动(ttl + random(0, 60)),防止大量 key 同时过期造成缓存雪崩——这是另一个话题,但写策略时顺手就做了。

    四、进阶:靠 binlog 驱动的最终一致(订阅变更)

    手动删缓存有个心累的地方:每个写入口都要记得删,漏一处就是线上事故。更工程化的做法是把"删缓存"从业务代码里拿走,交给数据库变更流:

    业务只写数据库 → Canal/Debezium 订阅 binlog → 变更消息进队列 → 消费者删除对应缓存

    好处:业务代码零侵入、不会漏删、天然有重试。代价:多一套订阅链路要维护。数据一致性敏感、写入口分散(多个服务写同一张表)的项目值得上;单体小项目,Cache-Aside + TTL 足够。

    五、一次真实事故的复盘

    用户反馈"修改头像后,详情页头像还是旧的,要等半小时"。排查:写入口走了"更新库 + 删缓存",但详情页读的是另一个 key(user:detail:{id},聚合了昵称头像),写入口只删了 user:{id}。修复两层:

  • 删除动作封装成 invalidate_user_cache(user_id),内部一次删掉该用户所有相关 key(用 key 模板清单管理);
  • 聚合页 TTL 从 30 分钟降到 10 分钟兜底。
  • 教训:失效不是删一个 key,是失效"关于这条数据的所有缓存视图"。把 key 的清单和失效函数写在一起,改数据模型时同步改失效逻辑,这是缓存设计里最重要的纪律。

    六、决策速查表

    场景推荐
    一般业务(读多写少) Cache-Aside(先更库后删缓存)+ TTL 5~30 分钟
    删除失败不能忍 删除进重试队列,最终一致
    写后读不能容忍旧值 延迟双删 / 写后短暂回源直读库
    写入口分散、多服务 binlog 订阅(Canal/Debezium)统一失效
    计数、排行榜 短 TTL 或定时任务重算,放弃实时一致

    七、总结

    缓存一致性三句话:写操作只删不改;先更库后删缓存是默认答案;TTL 是永远在岗的兜底。剩下一件事是纪律——把"哪些缓存依赖这份数据"写进失效函数,而不是散落在每个接口里。下次再收到"改了怎么还是旧的"的工单,按这张速查表对号入座,十分钟内能定位到是哪一环。

    赞(0)
    未经允许不得转载:171主机测评 » 缓存一致性实战:先更新库还是先删缓存?延迟双删与最终一致
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址