【架构实战】缓存一致性:Cache Aside与双写问题的破解
一、背景:缓存里的商品价格是错的
2021年双11,运营急call:"前台显示99元,用户下单页面显示129元,用户截图投诉了!"
查了半天:商品价格从129元改成了99元,数据库更新成功,但Redis里的缓存还是129元。缓存没失效,用户看到的是旧价格,直到10分钟后缓存过期才恢复正常。
更糟的是:那10分钟里,有200多个用户以错误的价格看到了商品,半数直接走了。
这不是Bug,是缓存一致性的经典问题:数据库更新了,缓存没更新。
团队复盘时发现,大家都听过"先更新数据库再删缓存",但很少有人真正理解为什么要这么做,以及这么做就万无一失了吗?
二、缓存一致性的核心矛盾
2.1 问题的本质
┌──────────┐
读取 │ Redis │ 读取 (快,但可能不是最新)
用户 ──┤ 缓存 ├── 应用 ──> MySQL (慢,但是最新)
写入 │ │ 写入
└──────────┘
核心矛盾:数据库和缓存是两个独立的数据源,更新操作无法原子性地同时更新两个存储。
2.2 不一致的场景
所有不一致都源于同一类并发时序问题:
时间线 线程A(读) 线程B(写)
T1 更新数据库 price=99
T2 读缓存 → 命中 → 返回129
T3 删除缓存
→ 用户看到了129(旧数据),而数据库已经是99
或者反过来:
T1 缓存过期,读数据库=129
T2 更新数据库 price=99
T3 删除缓存(但此时缓存已经过期了)
T4 把129写入缓存 ← 脏数据!
→ 缓存里是129,数据库是99
三、缓存更新策略对比
3.1 四种策略
| 策略 | 操作顺序 | 优点 | 缺点 | 一致性 |
|---|---|---|---|---|
| Cache Aside | 读: 先读缓存,miss再读DB并回填 写: 先更新DB,再删缓存 | 实现简单 | 需要处理并发 | 最终一致 |
| Read/Write Through | 缓存层代理读写,业务无感知 | 业务代码干净 | 缓存层复杂 | 强一致(同事务) |
| Write Behind | 先写缓存,异步写DB | 写入快 | 数据可能丢失 | 弱一致 |
| 双写 | 同时更新DB和缓存 | 实时性好 | 并发时易出错 | 最终一致 |
3.2 Cache Aside(旁路缓存)------最推荐的方案
读流程:
读请求 → 查缓存
├── 命中 → 直接返回
└── 未命中 → 查数据库 → 写入缓存 → 返回
写流程:
写请求 → 更新数据库 → 删除缓存(不是更新缓存!)
为什么是"删除缓存"而不是"更新缓存"?
更新缓存的陷阱:
线程A: 更新数据库 price=99
线程B: 更新数据库 price=129
线程B: 更新缓存 price=129 ← B的缓存更新先到达
线程A: 更新缓存 price=99 ← A的缓存更新后到达
结果:数据库里是129,缓存里却是99 → 价格显示错误
删除缓存不会有这个问题:
线程A: 更新数据库 price=99
线程B: 更新数据库 price=129
线程A: 删除缓存 ✓
线程B: 删除缓存 ✓
结果:缓存被删除,下次读取时从DB加载最新数据
3.3 核心实现
java
@Service
public class ProductService {
@Autowired
private ProductMapper productMapper;
@Autowired
private StringRedisTemplate redisTemplate;
private static final String CACHE_PREFIX = "product:";
private static final long CACHE_TTL = 3600; // 1小时
// 读:Cache Aside模式
public Product getProduct(Long productId) {
String cacheKey = CACHE_PREFIX + productId;
// 1. 查缓存
String json = redisTemplate.opsForValue().get(cacheKey);
if (json != null) {
return JSON.parseObject(json, Product.class);
}
// 2. 缓存miss,查数据库
Product product = productMapper.findById(productId);
if (product == null) {
// 缓存空值,防止穿透(后面文章会讲)
redisTemplate.opsForValue()
.set(cacheKey, "NULL", 60, TimeUnit.SECONDS);
return null;
}
// 3. 写入缓存
redisTemplate.opsForValue()
.set(cacheKey, JSON.toJSONString(product), CACHE_TTL, TimeUnit.SECONDS);
return product;
}
// 写:先更新DB,再删除缓存
@Transactional
public void updateProduct(Product product) {
// 1. 先更新数据库
productMapper.update(product);
// 2. 再删除缓存(在事务提交后执行)
String cacheKey = CACHE_PREFIX + product.getId();
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
redisTemplate.delete(cacheKey);
// 延迟双删(可选,进一步降低不一致概率)
// delayDelete(cacheKey, 500); // 500ms后再次删除
}
}
);
}
}
四、Cache Aside 的并发问题
4.1 极端情况下的不一致
即使使用了"先更新DB再删缓存",极端并发下仍可能不一致:
时间线 线程A(读) 线程B(写)
T1 缓存未命中,准备查数据库
T2 更新数据库 price=129
T3 删除缓存(这是空操作,因为T1时缓存已不存在)
T4 从数据库读出 price=129
T5 写入缓存 price=129
T6 更新数据库 price=99
T7 删除缓存 ← 这一步删的是T5写入的129
T8 缓存被删除,下次读会加载price=99 ✓ 最终一致
这个场景下最终还是一致的。真正有问题的是:
时间线 线程A(读) 线程B(写)
T1 缓存未命中,准备查数据库
T2 更新数据库 price=129
T3 删除缓存
T4 从数据库读出 price=129
T5 写入缓存 price=129
T6 用户看到了price=129(B的更新已生效)
→ 这个场景下数据是一致的!不一致发生的概率极低。
4.2 延迟双删(Double Delete)
为了进一步降低不一致概率,可以采用延迟双删:
java
@Transactional
public void updateProduct(Product product) {
// 1. 第一次删缓存
redisTemplate.delete(CACHE_PREFIX + product.getId());
// 2. 更新数据库
productMapper.update(product);
// 3. 事务提交后,延迟第二次删缓存
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
// 延迟500ms(等待其他线程的读操作完成)
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
redisTemplate.delete(CACHE_PREFIX + product.getId());
});
}
}
);
}
原理:第二次删除可以清理"读线程在写操作期间写入缓存的旧数据"。
适用场景:对一致性的容忍度极低的业务(如价格、库存)。
五、读写穿透策略
5.1 订阅Binlog异步更新缓存
这是目前大厂最主流的方案:监听MySQL的Binlog,异步更新缓存。
架构:
MySQL → Canal监听Binlog → Kafka → 缓存更新服务 → Redis
┌─────────┐ ┌─────────┐ ┌────────┐ ┌─────────┐
│ MySQL │────>│ Canal │────>│ Kafka │────>│ 缓存更新 │
│ (Binlog)│ │ (解析) │ │(消息) │ │ 服务 │
└─────────┘ └─────────┘ └────────┘ └────┬────┘
│
▼
┌─────────┐
│ Redis │
└─────────┘
java
// Canal监听Binlog的简化实现
@Component
public class BinlogCacheSync {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 处理Binlog事件
*/
public void handleBinlogEvent(CanalEntry entry) {
// 解析Binlog,获取变更的表和数据
String table = entry.getHeader().getTableName();
String eventType = entry.getHeader().getEventType().name();
if ("product".equals(table)) {
for (CanalEntry.RowData rowData : entry.getRowChange().getRowDatasList()) {
if ("UPDATE".equals(eventType) || "DELETE".equals(eventType)) {
// 解析变更后的数据
Long productId = parseProductId(rowData);
if ("DELETE".equals(eventType)) {
// 删除缓存
redisTemplate.delete("product:" + productId);
} else {
// 更新缓存(如果有的话,或者直接删除)
redisTemplate.delete("product:" + productId);
// 或者从DB重新加载后更新缓存
}
}
}
}
}
}
优点:
- 完全解耦:业务代码不需要关心缓存,只管写数据库
- 一致性高:只要Binlog不丢,缓存最终一定一致
- 可观测:Kafka消息可追踪,方便排查
缺点:
- 引入Canal + Kafka,架构复杂度增加
- 有延迟(通常100ms-500ms),不适用强一致场景
5.2 缓存一致性最佳实践总结
一致性保证的层次:
Level 1: 基础方案
写:更新DB → 删除缓存
读:查缓存 → miss → 查DB → 回填缓存
Level 2: 增强方案
写:删缓存 → 更新DB → 延迟双删
读:同上
Level 3: 企业方案
写:只更新DB → Canal监听Binlog → 异步更新/删缓存
读:同上
Level 4: 终极方案
Level 3 + 定时对账 + 不一致自动修复
六、常见坑点
坑1:在事务中操作缓存
java
// ❌ 错误:在事务中操作缓存
@Transactional
public void updateProduct(Product product) {
productMapper.update(product); // DB操作
redisTemplate.delete(cacheKey); // 缓存操作
}
// 问题:如果DB事务回滚了,缓存已经删了,导致缓存中数据丢失
// ✅ 正确:缓存操作在事务提交后
@Transactional
public void updateProduct(Product product) {
productMapper.update(product);
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
redisTemplate.delete(cacheKey); // 只在DB提交成功后删缓存
}
}
);
}
坑2:更新缓存而不是删除缓存
前面已经详细解释了,永远优先选择删缓存而不是更新缓存。更新缓存的并发问题无解。
坑3:高并发下缓存频繁失效
热点数据频繁更新,刚删除缓存就有大量请求涌入,全部打到DB。
解决:热点数据不强求实时一致,用本地缓存+短TTL(1-3秒)做缓冲。
七、总结
缓存一致性的核心原则:
- Cache Aside是基础:先更新DB,再删除缓存------最简单且有效的方案
- 删除优于更新:永远不要并发更新缓存,只删除
- 事务提交后才操作缓存:避免DB回滚但缓存已变更
- 延迟双删做兜底:对一致性要求高的场景,加延迟双删
- Binlog异步是最佳实践:业务代码零侵入,一致性有保障
做不到强一致就接受最终一致:
缓存一致性没有完美的实时方案。如果在业务上可以接受几百毫秒的不一致,那Cache Aside足够了。如果不能接受,那就别用缓存,直接读数据库。
缓存一致性的终极答案不是技术,是业务妥协。接受最终一致性,用对账系统兜底,比追求完美的一致性要靠谱得多。
个人观点,仅供参考