缓存和数据库不一致,是后端开发里一个绕不开的问题。
很多人一开始觉得,不就是"写库之后删缓存"吗?但真到线上,你会发现事情没那么简单:
- 删缓存失败了怎么办?
- 并发读写会不会出现脏数据?
- 先删缓存还是先更新数据库?
- 延迟双删到底有没有必要?
- 要不要用 Canal 监听 binlog?
- 强一致和最终一致怎么取舍?
这篇文章不堆概念,直接从常见方案 → 容易踩坑的地方 → 工程上怎么选讲清楚。
一、先明确一个前提:缓存一致性不是"绝对一致"
很多文章一上来就讲方案,但容易忽略一个关键点:
在大多数业务场景里,缓存和数据库要的不是"强一致",而是"可接受的最终一致"。
比如商品详情页、用户信息、配置信息,通常允许短暂不一致。
但如果是库存、余额、订单状态这类场景,就不能只靠缓存兜底,甚至很多字段根本不适合放缓存。
所以第一步不是选方案,而是先判断:
表格
| 场景 | 一致性要求 | 建议 |
|---|---|---|
| 商品详情、文章、配置 | 最终一致,秒级可接受 | 适合 Redis 缓存 |
| 库存、余额、支付状态 | 强一致或准强一致 | 谨慎缓存,数据库为主 |
| 高频读、低频写 | 适合缓存 | 重点解决失效策略 |
| 高频写、低频读 | 不适合强缓存 | 缓存收益低,风险高 |
如果业务本身就要求"写完必须立刻看到最新值",那缓存方案再花哨也没用,应该优先考虑数据库一致性、事务、锁、版本号这些机制。
二、最常见的方案:Cache Aside Pattern
最经典的做法就是:
- 读:先读缓存,缓存没有再读数据库,然后回填缓存
- 写:先更新数据库,再删除缓存
伪代码大概长这样:
public User getUser(Long userId) {
String key = "user:" + userId;
// 1. 先查缓存
String cacheValue = redis.get(key);
if (cacheValue != null) {
return JSON.parseObject(cacheValue, User.class);
}
// 2. 缓存没有,查数据库
User user = userMapper.selectById(userId);
// 3. 回填缓存
if (user != null) {
redis.set(key, JSON.toJSONString(user), 60 * 5);
}
return user;
}
更新的时候:
@Transactional
public void updateUser(User user) {
// 1. 先更新数据库
userMapper.updateById(user);
// 2. 再删除缓存
redis.del("user:" + user.getId());
}
这个方案看起来很简单,也是大多数项目默认选择。
但它不是没有问题。
三、为什么是"删除缓存",不是"更新缓存"?
很多新人会问:
既然数据库更新了,为什么不直接把新值写进缓存?
因为更新缓存容易出问题。
举个例子:
线程 A 和线程 B 同时更新同一个用户。
如果采用"更新缓存":
- 线程 A 更新数据库为 v2
- 线程 B 更新数据库为 v3
- 线程 B 先更新缓存为 v3
- 线程 A 后更新缓存为 v2
结果数据库是 v3,缓存却是 v2,脏数据就来了。
而"删除缓存"相对更安全:
- 不主动写新值
- 下次读取时再回填
- 避免多个写请求竞争缓存值
所以一般推荐:
写操作优先删除缓存,而不是更新缓存。
但删除缓存也不是万无一失。
四、先删缓存,还是先更新数据库?
这里有三种组合:
表格
| 方案 | 说明 |
|---|---|
| 先删缓存,再更新数据库 | 容易出现并发脏读 |
| 先更新数据库,再删缓存 | 相对更稳,推荐 |
| 只更新数据库,不处理缓存 | 除非缓存 TTL 很短,否则风险大 |
1. 先删缓存,再更新数据库:不推荐
这个顺序最大的问题是并发读会导致脏数据。
时序大概是这样的:
- 线程 A 删除缓存
- 线程 B 读缓存,发现没有
- 线程 B 读数据库,拿到旧值
- 线程 B 把旧值写回缓存
- 线程 A 更新数据库为新值
最后数据库是新值,缓存还是旧值。
这个场景在实际业务里并不罕见,尤其是读多写少的接口,并发一上来就可能触发。
2. 先更新数据库,再删除缓存:更稳
这个顺序也有问题:
- 线程 A 更新数据库成功
- 线程 A 删除缓存失败
这时候数据库是新值,缓存还是旧值。
但相比"先删缓存",它的风险更可控。
因为删除缓存失败只是暂时不一致,下一次写操作还会再删一次;如果缓存设置了 TTL,也不会一直脏下去。
所以工程上一般默认:
先更新数据库,再删除缓存。
五、删除缓存失败了怎么办?
这才是真正落地时最头疼的问题。
数据库事务成功了,但 Redis 删除失败,怎么办?
常见做法有三种:
1. 重试删除
最简单的方式,就是在删除失败后重试。
@Transactional
public void updateUser(User user) {
userMapper.updateById(user);
boolean deleted = false;
int retryTimes = 3;
for (int i = 0; i < retryTimes; i++) {
if (redis.del("user:" + user.getId()) != null) {
deleted = true;
break;
}
}
if (!deleted) {
// 记录日志、报警、丢消息补偿
log.error("删除缓存失败,userId={}", user.getId());
}
}
这种方案简单,但不够可靠。
因为重试也可能失败,而且本地重试解决不了服务宕机、网络分区、Redis 异常这些情况。
2. 异步消息补偿
更稳一点的做法是:
数据库更新成功后,发一条消息到 MQ,由消费者负责删除缓存。
流程大概是:
- 更新数据库
- 发送"删除缓存"消息
- 消费者收到消息后删除 Redis
- 失败则重试
这种方式比本地重试可靠,但要注意:
- 消息不能丢
- 消费者要幂等
- 消息延迟会影响一致性窗口
- MQ 本身也要保证高可用
如果只是普通业务数据,用 MQ 补偿可以接受。
但如果是核心链路,还得考虑消息和数据库事务之间的一致性。
3. 监听 binlog 删除缓存
更彻底一点的做法是监听数据库 binlog,比如用 Canal。
流程:
- 业务服务只负责更新数据库
- Canal 监听 binlog
- 解析变更事件
- 异步删除对应缓存
这种方式的好处是业务代码和缓存失效逻辑解耦。
但缺点也很明显:
- 架构复杂度上升
- 需要维护 Canal、MQ、消费端
- binlog 解析有延迟
- 缓存 key 和数据库表字段映射要设计好
- 不是所有数据库都方便开 binlog
所以 binlog 方案不是不能用,而是别一上来就上。
六、延迟双删有没有必要?
延迟双删的说法很常见:
- 先删除缓存
- 更新数据库
- 延迟一段时间后再删除一次缓存
目的是处理这种场景:
- 缓存删除后
- 有读请求把旧值重新加载进缓存
- 数据库还没来得及更新完成
所以再延迟删一次,把可能被污染的缓存清掉。
伪代码:
public void updateUser(User user) {
redis.del("user:" + user.getId());
userMapper.updateById(user);
// 延迟再删一次
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
redis.del("user:" + user.getId());
});
}
这个方案不是完全没用,但实际工程里要谨慎。
问题在于:
- 延迟多久合适?没有标准答案
- 如果延迟太短,可能没覆盖到并发读
- 如果延迟太长,又会影响性能
- 异步删除失败怎么办?
- 本质上还是在打补丁
如果业务真的对一致性要求很高,延迟双删只能算辅助手段,不能当主方案。
更推荐的做法还是:
先更新数据库,再删除缓存,再配合重试、MQ 或 binlog 补偿。
七、缓存击穿、穿透、雪崩也会影响一致性
很多人把一致性问题只理解成"写操作没删缓存",其实读链路也会出问题。
1. 缓存穿透
缓存穿透是指查询一个根本不存在的数据,缓存和数据库都没有,每次请求都打到数据库。
常见解法:
- 缓存空值
- 布隆过滤器
- 参数校验前置
缓存空值要注意 TTL 不能太长,否则会影响数据恢复。
User user = userMapper.selectById(userId);
if (user == null) {
redis.set(key, "", 60);
return null;
}
redis.set(key, JSON.toJSONString(user), 60 * 5);
2. 缓存击穿
某个热点 key 突然失效,大量请求同时打到数据库。
常见解法:
- 互斥锁
- 逻辑过期
- 热点 key 永不过期 + 异步刷新
互斥锁简单,但会降低并发。
String lockKey = "lock:user:" + userId;
if (redis.set(lockKey, "1", "NX", "EX", 5)) {
try {
User user = userMapper.selectById(userId);
if (user != null) {
redis.set(key, JSON.toJSONString(user), 60 * 5);
}
} finally {
redis.del(lockKey);
}
}
3. 缓存雪崩
大量 key 同时过期,或者 Redis 挂了,导致数据库压力暴增。
常见解法:
-
TTL 加随机值
-
多级缓存
-
限流降级
-
Redis 高可用
int expire = 60 * 5 + RandomUtils.nextInt(0, 60);
redis.set(key, value, expire);
这些问题和一致性有关,但不是一回事。
一致性更关注"数据对不对",击穿、穿透、雪崩更关注"系统扛不扛得住"。
八、强一致场景怎么处理?
如果是库存、余额、优惠券核销、支付状态这种场景,不能只靠"删缓存"解决。
这类数据建议:
- 数据库事务为主
- 缓存只做加速读
- 写操作不能依赖缓存结果
- 关键操作加乐观锁或分布式锁
- 必要时直接不缓存,或者缓存很短时间
比如库存扣减,不能这样:
Integer stock = redis.get("stock:" + skuId);
if (stock > 0) {
stock--;
redis.set("stock:" + skuId, stock);
}
这种写法在并发场景下很容易出问题。
更稳的方式是数据库层面控制:
UPDATE stock
SET stock = stock - 1
WHERE sku_id = #{skuId}
AND stock > 0;
缓存可以后续异步刷新,但不能让缓存决定业务正确性。
九、工程上怎么选?
不用把方案想得太复杂,可以按这个顺序判断:
1. 普通读多写少场景
比如用户信息、商品详情、文章、配置。
推荐:
- 先更新数据库
- 再删除缓存
- 缓存设置合理 TTL
- 删除失败做重试或异步补偿
这个方案已经能覆盖大部分业务。
2. 对一致性要求稍高的场景
比如订单状态、用户等级、积分余额展示。
推荐:
- 先更新数据库
- 删除缓存失败后发 MQ 补偿
- 消费者幂等删除
- 关键接口可以加版本号或短缓存
3. 写操作非常频繁的场景
比如实时排行榜、计数器、频繁变更的状态。
推荐:
- 不要盲目缓存
- 如果缓存,TTL 要短
- 考虑批量刷新
- 避免每次写都删缓存造成大量失效
4. 强一致场景
比如库存、余额、支付、核销。
推荐:
- 数据库事务为主
- 缓存只做读加速
- 写链路不依赖缓存
- 必要时加锁或版本号控制
十、几个容易踩坑的点
1. 不要把缓存当成数据源
缓存只是数据库的加速层。
如果缓存和数据库冲突,最终应该以数据库为准。
2. 不要给所有字段都加缓存
有些字段更新频繁,但读取不频繁,缓存意义不大。
有些字段涉及金额、状态、权限,缓存错了代价很高。
3. 不要迷信延迟双删
延迟双删能缓解部分并发问题,但不能解决所有问题。
真正要解决一致性,还是要回到"删除失败怎么补偿"。
4. 不要忽略 TTL
TTL 是缓存一致性的最后一道兜底。
即使删除失败、补偿失败,TTL 到期后缓存也会自动失效。
但 TTL 不能太短,否则缓存没意义;也不能太长,否则不一致窗口太大。
5. 不要忽略监控
线上最怕的不是方案不完美,而是出了问题没人知道。
至少要监控:
- Redis 删除失败率
- 缓存命中率
- 数据库慢查询
- MQ 消费延迟
- 接口响应时间
- 关键业务数据不一致告警
十一、一个比较稳的通用方案
如果不想搞太复杂,可以先按这个来:
- 读请求:先查 Redis,没有再查数据库,查到后回填缓存
- 写请求:先更新数据库,再删除缓存
- 删除失败:本地重试 2~3 次
- 重试仍失败:发送 MQ 消息异步删除
- 消费者:幂等删除缓存,失败继续重试
- 所有缓存:设置合理 TTL
- 强一致字段:不缓存或极短缓存,数据库事务兜底
伪代码:
@Transactional
public void updateUser(User user) {
userMapper.updateById(user);
String key = "user:" + user.getId();
boolean deleted = false;
for (int i = 0; i < 3; i++) {
if (redis.del(key) != null) {
deleted = true;
break;
}
}
if (!deleted) {
cacheConsistencyProducer.sendDeleteMessage(key);
}
}
消费者:
public void handleDeleteMessage(String key) {
int retryTimes = 3;
for (int i = 0; i < retryTimes; i++) {
if (redis.del(key) != null) {
return;
}
}
log.error("缓存删除补偿失败,key={}", key);
}
这个方案不算最极致,但足够稳,也适合大多数项目。
十二、最后总结
Redis 缓存和数据库一致性,本质上是在权衡三件事:
- 一致性要求
- 系统复杂度
- 业务可接受的不一致窗口
不要一上来就追求强一致,也不要随便一句"最终一致"就把问题糊过去。
真正落地时,记住几个原则就够了:
- 写操作优先先更新数据库,再删除缓存
- 删除缓存失败必须有补偿机制
- 缓存 TTL 是兜底,不能省
- 强一致数据不要依赖缓存做最终判断
- 方案越复杂,越要评估维护成本
大部分项目,不需要 Canal、延迟双删、分布式事务一起上。
先把"先更新数据库,再删除缓存 + 失败补偿 + TTL 兜底"做稳,已经能解决绝大多数一致性问题。