强一致性做不到,除非你不用缓存。
用了缓存,就必然存在数据库和缓存不一致的窗口期。我们能做的是尽量缩短这个窗口,以及出了不一致能兜底。
为什么会不一致
最简单的场景:
1. 用户A修改数据,更新了数据库
2. 还没来得及更新缓存
3. 用户B查询,命中缓存,拿到旧数据
这就是不一致。
更复杂的场景涉及并发:
线程1: 更新数据库 → 删除缓存
线程2: 查询缓存(未命中) → 查数据库 → 写缓存
如果时序是这样:
1. 线程1 更新数据库(新值)
2. 线程2 查数据库(拿到新值)
3. 线程1 删除缓存
4. 线程2 写缓存(新值)
这种情况没问题。
但如果是这样:
1. 线程2 查数据库(旧值)
2. 线程1 更新数据库(新值)
3. 线程1 删除缓存
4. 线程2 写缓存(旧值)
缓存里存的就是旧值,不一致了。
这种并发导致的不一致是最难处理的。
常用的几种做法
Cache Aside,最常用的:
读:先读缓存,没有就读数据库,然后写缓存 写:先更新数据库,再删除缓存
// 读
public User getUser(Long id) {
// 1. 先查缓存
User user = redis.get("user:" + id);
if (user != null) {
return user;
}
// 2. 缓存没有,查数据库
user = db.selectById(id);
if (user != null) {
// 3. 写入缓存
redis.setex("user:" + id, 3600, user);
}
return user;
}
// 写
public void updateUser(User user) {
// 1. 先更新数据库
db.update(user);
// 2. 再删除缓存
redis.del("user:" + id);
}
为什么是删除缓存而不是更新缓存?
因为更新缓存在并发场景下更容易出问题:
线程1更新数据为A,线程2更新数据为B
1. 线程1 更新数据库为A
2. 线程2 更新数据库为B
3. 线程2 更新缓存为B
4. 线程1 更新缓存为A
结果:数据库是B,缓存是A,不一致
删除缓存就没这个问题——不管谁先删,最终缓存都是空的,下次读会从数据库加载最新值。
延迟双删,针对并发问题加层保险:
public void updateUser(User user) {
// 1. 先删缓存
redis.del("user:" + id);
// 2. 更新数据库
db.update(user);
// 3. 延迟一段时间再删一次
Thread.sleep(500); // 或者用消息队列延迟
redis.del("user:" + id);
}
第一次删除是为了让并发的读请求去数据库拿最新数据。 延迟删除是为了清理掉在更新数据库期间可能被写入的旧缓存。
延迟时间怎么定?通常设置为业务读请求的耗时 + 几百毫秒buffer。
binlog同步,更可靠但也更重。监听MySQL的binlog,数据变更时自动删缓存:
MySQL → binlog → Canal/Maxwell → 消费程序 → 更新Redis
// Canal消费示例
@CanalEventListener
public void onEvent(CanalEntry entry) {
if (entry.getTableName().equals("user")) {
Long userId = entry.getColumn("id");
if (entry.getEventType() == UPDATE || entry.getEventType() == DELETE) {
redis.del("user:" + userId);
}
}
}
优点:
- 业务代码不用关心缓存更新
- 可靠性高,binlog不会丢
缺点:
- 架构复杂,多了Canal这个组件
- 有延迟(通常毫秒到秒级)
实际项目怎么选
大部分场景:Cache Aside就够了
读多写少的数据(用户信息、商品详情),Cache Aside + 合理的过期时间,基本够用。短暂的不一致业务上能接受。
写比较频繁:考虑延迟双删
比如库存、积分这种更新频繁的,加上延迟双删降低不一致的概率。
强一致性要求高:binlog同步
金融场景、交易相关的,上Canal。或者干脆不用缓存,直接读数据库。
一定能接受最终一致:设置过期时间兜底
不管用什么方案,一定要给缓存设置过期时间。就算前面的逻辑出问题了,过期后也能自动恢复一致。
一些踩过的坑
缓存击穿导致的不一致
热点数据过期瞬间,大量请求打到数据库,同时回写缓存。如果这时候正好有更新操作,很容易写入旧值。
解决:用分布式锁保证只有一个请求回写缓存。
public User getUser(Long id) {
User user = redis.get("user:" + id);
if (user != null) return user;
// 加锁,只有一个请求去查数据库
String lockKey = "lock:user:" + id;
if (redis.setnx(lockKey, "1", 10)) {
try {
// 双重检查
user = redis.get("user:" + id);
if (user != null) return user;
user = db.selectById(id);
redis.setex("user:" + id, 3600, user);
} finally {
redis.del(lockKey);
}
} else {
// 没拿到锁,等一下重试
Thread.sleep(50);
return getUser(id);
}
return user;
}
删除缓存失败
网络抖动导致删除失败,缓存里还是旧数据。
解决:删除失败时发到消息队列,异步重试。
public void updateUser(User user) {
db.update(user);
try {
redis.del("user:" + id);
} catch (Exception e) {
// 删除失败,发消息异步重试
mq.send("cache-delete-retry", "user:" + id);
}
}
读写分离的坑
如果数据库是主从架构,写主库、读从库。从库有延迟,可能刚更新完主库,删了缓存,结果读请求去从库拿到旧数据又写回缓存。
解决:
- 关键业务强制读主库
- 或者延迟双删的时间设置长一点,覆盖主从延迟
一次真实的线上事故
去年遇到过一个case,分享一下。
电商系统,商品详情页用了缓存。某天运营反馈:改了商品价格,页面上还是老价格,过了好几分钟才变过来。
排查下来,问题出在这:
1. 运营在后台改价格,更新数据库
2. 代码里更新数据库后删缓存
3. 删缓存的时候,Redis连接超时了(那段时间Redis有点抖动)
4. 代码吞了异常,没重试
5. 缓存里还是老价格,要等30分钟过期才会更新
问题代码:
public void updatePrice(Long productId, BigDecimal price) {
db.updatePrice(productId, price);
try {
redis.del("product:" + productId);
} catch (Exception e) {
log.error("删除缓存失败", e); // 只打了个日志,没处理
}
}
修复方案:
public void updatePrice(Long productId, BigDecimal price) {
db.updatePrice(productId, price);
// 删缓存,失败重试3次
int retry = 0;
while (retry < 3) {
try {
redis.del("product:" + productId);
return;
} catch (Exception e) {
retry++;
if (retry >= 3) {
// 重试失败,发消息队列异步处理
mq.send("cache-invalidate", "product:" + productId);
log.error("删缓存失败,已发MQ", e);
}
Thread.sleep(100);
}
}
}
// 消费者兜底
@MQListener("cache-invalidate")
public void onCacheInvalidate(String key) {
redis.del(key);
}
还加了个兜底:缓存过期时间从30分钟改成5分钟。就算前面都失败了,最多5分钟也能恢复。
这个事故给我的教训:删缓存失败一定要有兜底机制,不能只打个日志就完了。
总结
| 方案 | 复杂度 | 一致性 | 适用场景 |
|---|---|---|---|
| Cache Aside | 低 | 最终一致 | 大部分读多写少场景 |
| 延迟双删 | 中 | 最终一致(更好) | 写频繁场景 |
| binlog同步 | 高 | 最终一致(可靠) | 一致性要求高的场景 |
| 不用缓存 | - | 强一致 | 金融核心交易 |
记住几个原则:
- 更新数据库后删缓存,不是更新缓存
- 一定设置过期时间兜底
- 能接受最终一致就行,别追求强一致