小维的学习笔记和工具

Redis与数据库缓存一致性:我踩过的坑和最终选型

去年我们团队做了一个电商项目,QPS峰值大概在5000左右,商品详情页用了Redis做缓存。

上线第一个月一切正常。第二个月开始,客服陆续收到用户反馈:订单状态显示不对,明明是已支付的订单,页面还显示待付款;库存扣减了,页面还显示有货。

查日志发现是缓存和数据库不一致了。这问题不致命,但足够恶心——用户投诉、客诉压力、研发背锅。

那段时间我每天下班都在想:到底怎么才能让Redis里的数据和MySQL保持一致?

三个月折腾下来,试了五六种方案,踩了无数坑。这篇笔记是我自己的总结,希望能帮同样被这问题折磨的人少走弯路。

一、先搞清楚:问题到底出在哪?

1.1 什么是缓存一致性?

简单说:数据库里存的是最新数据,Redis里存的也是最新数据,两者要同步。

问题是,更新操作不是原子的。你要么先改DB再改Redis,要么先改Redis再改DB,不管哪种顺序,中间都有一段时间窗口,另一个请求可能读到脏数据

1.2 为什么这么难?

核心原因是两个存储系统的操作无法在同一个事务里完成。MySQL的事务和Redis的事务是两码事,没法保证同时成功或同时失败。

我们做个实验(表1):

访问方式耗时(ms)QPS
直接查DB150-2005000
查Redis(命中)5-1010万+
查Redis(穿透)160-2104800

数据来源:某商品详情页压测(集群配置:4C8G×3)

看出问题了吗?缓存命中时性能起飞,但只要不一致,用户看到的就是错误数据。

二、常见的缓存更新策略(按坑深排序)

2.1 先写Redis,再写MySQL(千万别用)

做法

java

redis.set(key, newValue);    // 先更新缓存
db.update(sql, newValue);    // 再更新数据库

我踩的坑
有一次缓存更新成功了,数据库更新因为死锁报错,结果Redis里是新数据,MySQL里是旧数据。用户读到新数据,下单时发现库存不够(因为数据库里库存没扣成功)——灾难性后果

更坑的是,如果写Redis成功、写DB失败,你要么不回滚Redis(数据就永远不一致),要么再写一次Redis恢复旧值。但恢复旧值的时候如果又有其他请求进来,又乱了。

结论:这个方案我直接拉黑,生产环境绝对不用。

2.2 先写MySQL,再写Redis(有并发坑)

做法

java

db.update(sql, newValue);    // 先更新数据库
redis.set(key, newValue);    // 再更新缓存

问题
假设两个并发请求:

  • 请求A把age从18改成19
  • 请求B把age改成25

正常顺序:A改DB→A改Redis→B改DB→B改Redis,最终age=25,没问题。

但实际可能是:A改DB→B改DB→B改Redis→A改Redis,结果age=25(DB)和age=26(Redis),因为A把19+1变成了20,但B已经把DB改成25了

这就是缓存乱序问题——更新操作的先后顺序乱了。

2.3 先删缓存,再写MySQL(经典坑)

做法

java

redis.del(key);              // 先删缓存
db.update(sql, newValue);    // 再更新数据库

血泪史
某个版本我们上线了这个方案,结果线上出现严重问题:

时间线程A(写)线程B(读)
T1删除缓存
T2读缓存未命中
T3从DB读到旧数据
T4把旧数据写回缓存
T5更新DB为新数据

结果:缓存里是旧数据,DB里是新数据

这个问题的关键是读请求在写请求更新DB之前把旧数据写回了缓存。后来我们统计,这种问题出现的概率大概在5%左右,但一旦出现,持续的时间可能很长。

2.4 先写MySQL,再删缓存(主流方案)

做法

java

db.update(sql, newValue);    // 先更新数据库
redis.del(key);              // 再删除缓存

为什么这个相对靠谱
因为删除缓存的操作是幂等的,而且即使删除失败,也只是多一次缓存穿透,不会导致数据永久不一致

但仍有问题
还是并发场景:

  • T1:线程A更新数据库
  • T2:线程B读缓存(命中旧数据)
  • T3:线程A删除缓存

线程B在T2时刻读到了旧数据。虽然概率很小,但确实存在。

三、几种优化方案(我试过的)

3.1 延迟双删

代码

java

redis.del(key);                    // 第一删
db.update(sql, newValue);          // 更新DB
Thread.sleep(500);                  // 延迟500ms
redis.del(key);                    // 第二删

原理:等500ms,让可能发生的“读请求回写旧数据”操作完成,再删一次。

我们踩的坑

  • sleep 500ms 写死,结果主从延迟有时候超过1秒,还是会出问题
  • 高并发下,500ms的等待对性能影响不小

改进

java

// 动态计算延迟时间
int sleepTime = Math.max(50, getAverageReplicationDelay() * 1.3);
Thread.sleep(sleepTime);

根据最近10次主从延迟平均值动态调整

3.2 消息队列异步重试

做法

java

db.update(sql, newValue);
mq.send("cache_clean", key);    // 发送删除缓存消息

消费者收到消息后删除缓存,如果删除失败,MQ自带的重试机制会再试几次

优点

  • 解耦,业务代码不用关心缓存删除结果
  • 重试机制保证最终一致性

缺点

  • 引入MQ,架构变复杂
  • 有短暂的不一致窗口(消息处理延迟)

3.3 基于Binlog的异步同步(Canal方案)

做法

  • 应用程序只操作数据库
  • Canal监听MySQL的binlog
  • 解析binlog里的数据变更
  • 异步更新Redis

流程图解

text

MySQL binlog → Canal → MQ → 消费者 → Redis更新

优点

  • 和业务代码完全解耦,代码里一行都不用改
  • 没有并发乱序问题,binlog里的顺序就是真实的执行顺序
  • 可以同时更新多个下游(ES、缓存、其他服务)

缺点

  • 引入Canal、MQ,架构重
  • binlog解析有延迟,通常是秒级
  • Canal挂了怎么办?需要高可用部署

适用场景

  • 大型微服务架构
  • 写并发高,需要异步削峰
  • 能接受秒级最终一致性

四、方案对比(我自己的评分)

方案一致性等级复杂度实时性适用场景
先写DB再删缓存★☆☆大多数业务,读多写少
延迟双删中上★★☆对一致性比较敏感的中小项目
MQ异步删除高(最终)★★★高并发,能接受短暂不一致
Canal+MQ高(最终)★★★★低(秒级)大型系统,写多读少,需解耦

数据参考自某电商压测对比

五、最佳实践:我现在怎么选?

经过三个月的折腾,我总结了一套按场景选型的策略:

5.1 中小型项目(QPS < 5000)

:先更新数据库,再删除缓存 + 重试兜底

java

public void updateWithCacheClean(Product product) {
    try {
        // 1. 更新数据库
        productDao.update(product);

        // 2. 删除缓存
        redisTemplate.delete("product:" + product.getId());
    } catch (Exception e) {
        // 3. 删除失败,扔进重试队列
        retryQueue.offer(new CacheCleanTask(product.getId()));
        log.error("缓存删除失败,已加入重试队列", e);
    }
}

关键点

  • 删除缓存失败不要吞异常,一定要重试
  • 可以用本地内存队列+定时任务重试,简单够用
  • 缓存设置过期时间作为兜底(比如1小时)

5.2 高并发核心场景(QPS > 5000,如库存、订单)

:先更新数据库,再删除缓存 + 延迟双删

java

public void updateStock(Stock stock) {
    // 第一删
    redisTemplate.delete("stock:" + stock.getId());

    // 更新数据库
    stockDao.update(stock);

    // 动态计算延迟时间
    int replicaDelay = getReplicaDelay(); // 从监控系统获取主从延迟
    int sleepTime = Math.max(50, replicaDelay * 2);

    // 延迟后第二删
    scheduledExecutor.schedule(() -> {
        redisTemplate.delete("stock:" + stock.getId());
    }, sleepTime, TimeUnit.MILLISECONDS);
}

关键点

  • 延迟时间要动态计算,不能写死
  • 第二删失败也要重试
  • 配合版本号机制,避免旧数据覆盖新数据

5.3 大型微服务架构(多个下游依赖)

:Canal + MQ

yaml

# 架构组件
- MySQL: 数据源
- Canal: 监听binlog
- RocketMQ: 异步消息
- Consumer: 更新Redis、ES、其他服务

关键点

  • 保证MQ的Topic有序性,避免乱序
  • 消费端做好幂等
  • 监控Canal的延迟,报警阈值设3秒以上

六、避坑指南(我流过的血)

坑1:缓存乱序问题

现象:明明数据库顺序更新正确,缓存却乱了

原因:两个并发请求,更新缓存的顺序和更新数据库的顺序不一致

解决

  • 不用“更新缓存”,只用“删除缓存”
  • 或者用版本号机制:缓存里带version,更新时判断版本号

坑2:主从延迟导致第二删失效

现象:延迟双删的sleep时间不够,读请求从从库读到旧数据写回缓存

解决:sleep时间根据主从延迟动态调整

坑3:缓存穿透引发雪崩

现象:删除缓存后,大量请求穿透到数据库

解决

  • 热点数据不设置过期时间(永不过期)
  • 用互斥锁控制回写,同一时刻只有一个线程去查库

坑4:分布式锁性能崩盘

现象:为了强一致性加分布式锁,结果锁竞争把QPS干到200

解决:能用最终一致性就别用强一致,强一致就别指望缓存

七、写在最后:没有银弹

折腾了三个月,我最大的感悟是:缓存一致性没有完美的方案,只有适合业务的方案

你要问我现在怎么选,我的回答是:

  • 实时性要求高、并发中等:先更新数据库再删缓存 + 重试
  • 一致性要求高、能接受短暂不一致:延迟双删 + 动态延迟
  • 大型系统、多个下游:Canal + MQ
  • 强一致性要求:别用缓存,直接查库

最后送自己一句话:缓存是性能的加速器,也是一致性的麻烦制造者。引入缓存之前,先想清楚你能不能接受它的副作用。

未经允许不得转载:小维的学习笔记和工具 » Redis与数据库缓存一致性:我踩过的坑和最终选型