Administrator
发布于 2019-01-11 / 2453 阅读
22

缓存与数据库一致性问题:先更新库还是先删缓存

code review 时,我们为一个顺序吵了半小时

一月中旬,同事小王提了个 PR,商品改价接口里他这么写的:

@Transactional
public void updatePrice(Long skuId, BigDecimal newPrice) {
    redisTemplate.delete("sku:" + skuId);   // 先删缓存
    skuMapper.updatePrice(skuId, newPrice); // 再改库
}

另一个同事评论说应该反过来,"先改库再删缓存,大家都这么写"。小王不服,说他看的文章里写的是先删缓存。两个人把我看的几篇博客都贴出来了,结论互相矛盾。

我翻了下聊天记录,发现这类争论在我们组发生过不止一次,但从来没人把几种顺序的失败场景完整列出来过。那就自己列一遍,把并发时序画清楚,看谁的说法站得住。

先把前提定下来

我们讨论的是 Redis 5.0 作为旁路缓存(cache aside),场景是商品详情:读 QPS 峰值 3000 左右,写(改价、改库存)QPS 不到 30。缓存命中率 98.5%,商品信息变更频率低。

两个基本事实必须先承认:

  • 缓存和数据库是两个独立的存储,没有跨存储的事务。 所以"强一致"在这套架构下根本做不到,只能追求最终一致,把不一致的时间窗口压到足够小。
  • 读流程是固定的:命中缓存直接返回,未命中则查库再回写缓存。所有不一致都出在"写"这一侧。

四种顺序,逐个拆时序

组合一:先更新缓存,再更新数据库

updateRedis(sku);
updateDb(sku);      // 这一步失败

改库失败(比如字段超长、锁超时),缓存里已经是新值,库里是旧值。之后所有读请求都拿到错误的新值,而且缓存不过期就永远是错的。这是四种里最差的一种,直接排除。

即使两步都成功,并发下也有问题:线程 A 先改价 99,线程 B 后改价 88,但 B 的更新缓存操作先执行,A 的后执行,结果缓存里是 99、库里是 88。

组合二:先更新数据库,再更新缓存

比组合一好一些,至少不会出现"缓存是新的、库是旧的"这种最难发现的错。但并发写同样会导致顺序颠倒,而且它有一个更实际的缺点:每次写都要重新构造缓存对象。我们商品详情的缓存值是一整个聚合 VO,要查 4 张表拼出来,耗时 35ms 左右。改价 QPS 30 意味着每秒多花 1 秒在这上面,而其中大部分缓存在写完之后根本没人读。写多读少的缓存值尤其亏。

组合三:先删缓存,再更新数据库

这就是小王的写法。它的失败场景需要三个动作交错:

时间线(并发读 + 并发写)
t1  写线程 A:delete cache                      缓存空
t2  读线程 B:cache miss,查库,读到旧值 V0
t3  写线程 A:update db → V1                     库已是新值
t4  读线程 B:把 V0 回写进缓存                    缓存变回旧值

之后直到缓存过期(我们设的 30 分钟),所有读请求拿到的都是旧值 V0。这个窗口要求"读线程在写线程删完缓存之后、更新库之前查库,并且在写线程更新库之后才回写",虽然条件苛刻,但它不是理论问题。我们线上缓存不是热点 key 时 miss 一个就要走库,查库 + 拼 VO 平均 42ms,而 A 更新库只要 8ms,B 的回写落在 A 之后的概率相当高。

组合四:先更新数据库,再删缓存

把顺序反过来,同样构造并发场景:

时间线
t1  读线程 B:cache miss,查库,读到旧值 V0
t2  写线程 A:update db → V1
t3  写线程 A:delete cache                      缓存空
t4  读线程 B:把 V0 回写进缓存                    缓存变回旧值

看起来也会出问题,但它需要 t4 发生在 t3 之后,而 B 在 t1 就已经拿到 V0 了,还要再走完"拼 VO、序列化、set 到 Redis"这一串。t3 的 delete 只有几十微秒。这个窗口极小,我在线上盯了两周没复现过一次。

更关键的是失败暴露的方式不一样:如果 A 更新完库、删缓存失败,那么缓存里是旧值,读请求拿到旧数据,属于"数据没更新",业务上能接受,而且缓存过期后会自愈。组合三则是"数据更新了但缓存被回写成旧的",更难发现。

所以争论的结论是:用"先更新数据库,再删缓存",并且只删不更新。小王改了 PR。

删除失败了怎么办:延迟双删

上面说的都是"删缓存成功"的理想情况。实际会碰到两个问题:

  • 删缓存那一步网络抖动失败了,脏数据要等 30 分钟过期
  • 主从延迟:读线程从还没同步的从库读到旧值再回写缓存

我们最后落地的方案是更新库 + 删缓存 + 一次延迟再删:

@Transactional
public void updatePrice(Long skuId, BigDecimal newPrice) {
    skuMapper.updatePrice(skuId, newPrice);
    redisTemplate.delete(key(skuId));
}

// 事务提交后异步执行,不占用请求线程
@Async("taskExecutor")
public void delayDoubleDelete(Long skuId) {
    try {
        Thread.sleep(500);                  // 略大于一次读请求的耗时 + 主从延迟
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
    redisTemplate.delete(key(skuId));
}

500ms 这个数字不是拍的。我们统计过商品详情读请求的 P99 是 180ms,主从延迟监控上 99 分位是 120ms,加起来再留一倍余量,取 500ms。代价是每个写操作多一次 Redis delete,QPS 30 的场景下完全无感。

注意 @Async 方法不能在同一个类里被 this 调用,否则代理不生效——这个坑我在另一篇写 AOP 的时候记过。

再往前一步:Canal 订阅 binlog

延迟双删解决的是"应用自己写的缓存",但我们的商品缓存还有别的地方在写:运营后台、数据修复脚本、DBA 手动改数据。这些入口不会调我们的删除逻辑,脏数据照样产生。上季度出过一次,DBA 批量修正了 2000 个商品价格,缓存里三天还是错的。

后来我们用了 Canal(阿里开源的 MySQL binlog 订阅组件,伪装成 MySQL slave 拉 binlog):

// Canal 客户端监听,部署在独立的缓存同步服务里
@CanalEventListener
public class SkuCacheListener {

    @ListenPoint(schema = "mall", table = "t_sku")
    public void onSkuChange(CanalEntry.EventType eventType, CanalEntry.RowData rowData) {
        Long skuId = Long.valueOf(getAfterColumn(rowData, "id"));
        redisTemplate.delete("sku:" + skuId);
    }
}

好处很明显:业务代码里彻底不用管缓存了,谁改库都逃不过 binlog,而且删除动作被收拢到一个地方,出问题好查。我们统计过,接了 Canal 之后商品缓存的不一致工单从每月 7 到 8 单降到 0。

代价也得说清楚:多了一个中间件要运维,Canal server 挂了缓存就停止更新(我们配了钉钉告警 + 每分钟检查位点延迟);binlog 到删除缓存之间有一段延迟,我们监控到的 P99 是 260ms,对商品信息这种场景够用了,秒杀库存就不能这么干。

所以不是所有场景都上 Canal。我们的判断标准是:写入口是否唯一。只有一个写入口的(比如用户资料,只由用户中心改)用延迟双删就够;多入口、被外部系统改的(商品、价格、库存配置)才值得上订阅。

下篇预告

这篇先把《缓存与数据库一致性问题:先更新库还是先删缓存》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。

参考