1. 数据库与缓存双写一致性概述
在互联网应用开发中,缓存技术是提升系统性能的标配方案。以电商系统为例,商品详情页的QPS可能高达数万次,如果每次都直接查询数据库,MySQL实例很可能会不堪重负。引入Redis等内存缓存后,95%以上的请求可以直接从缓存获取数据,这能显著降低数据库负载。
但缓存就像一面镜子,需要时刻保持与数据库的同步。当数据发生变化时,如何确保缓存与数据库的一致性就成为了系统设计的难点。我在多个电商和社交类项目中,曾遇到过因缓存不一致导致的商品超卖、用户信息显示错误等问题。下面我将结合实战经验,详细剖析各种双写方案的优劣。
2. 常见双写方案深度解析
2.1 缓存TTL方案
TTL(Time To Live)是最简单的缓存更新策略。我们在写入缓存时设置一个过期时间,例如:
java复制// 设置缓存并指定30分钟过期
redisTemplate.opsForValue().set("product:1001", productDetail, 30, TimeUnit.MINUTES);
适用场景:
- 数据变化频率较低(如企业介绍信息)
- 对一致性要求不高的场景(如文章阅读量统计)
- 缓存数据计算成本低的场景
潜在问题:
- 数据更新后无法立即反映到前端,存在时间窗口内的不一致
- 缓存雪崩风险:大量缓存同时过期会导致数据库瞬时压力激增
实际经验:在内容管理系统中,我们曾对文章详情使用10分钟TTL,配合随机30秒的抖动值(jitter)避免集中过期,效果良好。
2.2 先更新数据库再更新缓存
这种方案的典型实现如下:
java复制public void updateProduct(Product product) {
// 1. 更新数据库
productDao.update(product);
// 2. 更新缓存
redisTemplate.opsForValue().set("product:"+product.getId(), product);
}
竞态条件分析:
假设两个并发请求:
- 请求A将库存从100改为80
- 请求B将库存从100改为90
可能出现的执行序列:
- A更新数据库(库存=80)
- B更新数据库(库存=90)
- B更新缓存(库存=90)
- A更新缓存(库存=80)
最终缓存中的80与数据库中的90不一致。
不推荐原因:
- 写多读少场景下,频繁更新缓存造成资源浪费
- 缓存数据可能需要复杂计算(如聚合统计),每次更新都重复计算不划算
- 并发写时容易出现竞态条件
2.3 先删除缓存再更新数据库
基础实现代码:
java复制public void updateProduct(Product product) {
// 1. 删除缓存
redisTemplate.delete("product:"+product.getId());
// 2. 更新数据库
productDao.update(product);
}
脏读问题场景:
- 请求A删除缓存
- 请求B查询缓存未命中,从数据库读取旧值
- 请求B将旧值写入缓存
- 请求A更新数据库
此时缓存中是旧数据,数据库是新数据。
2.3.1 延时双删优化方案
改进后的延时双删方案:
java复制public void updateProduct(Product product) {
// 第一次删除
redisTemplate.delete("product:"+product.getId());
// 更新数据库
productDao.update(product);
// 异步延时删除
asyncDeleteAfter("product:"+product.getId(), 1000);
}
private void asyncDeleteAfter(String key, long delayMillis) {
new Thread(() -> {
try {
Thread.sleep(delayMillis);
redisTemplate.delete(key);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
}
关键参数选择:
- 延时时间:通常取业务平均读耗时的1.5-2倍
- 异步处理:避免阻塞主线程
- 重试机制:建议实现删除失败后的重试逻辑
主从架构调整:
对于MySQL主从复制场景,需要额外考虑主从同步延迟:
java复制// 在从库同步完成后二次删除
while(!isSlaveSynced()) {
Thread.sleep(200);
}
redisTemplate.delete(key);
2.4 先更新数据库再删除缓存(Cache-Aside Pattern)
这是目前最被推崇的方案,Facebook等公司广泛采用。其核心逻辑:
java复制public Product getProduct(long id) {
// 读缓存
Product product = redisTemplate.opsForValue().get("product:"+id);
if (product == null) {
// 读数据库
product = productDao.get(id);
// 写缓存
redisTemplate.opsForValue().set("product:"+id, product);
}
return product;
}
public void updateProduct(Product product) {
// 更新数据库
productDao.update(product);
// 删除缓存
redisTemplate.delete("product:"+product.getId());
}
异常情况分析:
虽然理论上存在读比写慢导致不一致的可能,但实际业务中:
- 数据库写操作通常需要加锁、事务等,耗时比读长
- 缓存操作通常在1ms内完成
- 出现不一致的时间窗口极短
实战建议:
- 对关键业务数据实现删除重试机制
- 记录删除失败的key,通过定时任务补偿
- 监控缓存与数据库的一致性差异
3. 高级保障方案
3.1 消息队列确保删除
引入RabbitMQ或Kafka的可靠消费机制:
java复制public void updateProduct(Product product) {
// 更新数据库
productDao.update(product);
// 发送删除消息
mqTemplate.send("cache.delete", "product:"+product.getId());
}
// 消费者
@RabbitListener(queues = "cache.delete")
public void handleCacheDelete(String key) {
try {
redisTemplate.delete(key);
} catch (Exception e) {
// 记录失败并重试
retryService.scheduleRetry(key);
}
}
优缺点对比:
| 优点 | 缺点 |
|---|---|
| 解耦业务逻辑 | 系统复杂度增加 |
| 支持重试机制 | 消息延迟导致短暂不一致 |
| 可扩展性强 | 需要维护消息队列 |
3.2 Binlog订阅方案(Canal)
阿里巴巴开源的Canal可以完美解决这个问题:

实施步骤:
- 部署Canal服务,伪装为MySQL从库
- 配置需要监听的数据库和表
- 编写客户端消费变更事件
- 根据变更事件删除对应缓存
示例代码:
java复制@CanalEventListener
public class CacheEvictListener {
@ListenPoint(
destination = "example",
schema = "ecommerce",
table = "products"
)
public void onProductUpdate(CanalEntry.EventType eventType, Product product) {
if (eventType == CanalEntry.EventType.UPDATE) {
redisTemplate.delete("product:"+product.getId());
}
}
}
性能数据:
在某电商平台的实际测试中:
- 平均延迟:120ms
- 99分位延迟:350ms
- 吞吐量:单实例可处理20k+事件/秒
4. 方案选型指南
根据业务特点选择合适方案:
一致性要求等级:
- 强一致性(金融交易):避免使用缓存,或采用同步写策略
- 最终一致性(大多数业务):Cache-Aside + 重试机制
- 弱一致性(统计类数据):TTL自动过期
技术选型矩阵:
| 方案 | 一致性 | 复杂度 | 适用场景 |
|---|---|---|---|
| TTL | 弱 | 低 | 非关键数据 |
| 双更新 | 最终 | 中 | 写少读多 |
| 先删后更 | 最终 | 高 | 写多读少 |
| 先更后删 | 最终 | 中 | 通用场景 |
| 消息队列 | 最终 | 高 | 关键业务 |
| Canal | 最终 | 很高 | 大型系统 |
特殊场景处理:
- 秒杀商品:采用本地缓存+Redis原子操作
- 库存扣减:Redis DECR + 异步落库
- 分布式锁:Redisson实现跨JVM互斥
5. 实战问题排查记录
案例1:缓存穿透
现象:某商品被删除后,缓存中一直为空,导致大量请求直达数据库
解决:对不存在的key也进行缓存,设置较短TTL
java复制public Product getProduct(long id) {
String key = "product:"+id;
Product product = redisTemplate.opsForValue().get(key);
if (product == null) {
product = productDao.get(id);
if (product == null) {
// 缓存空对象
redisTemplate.opsForValue().set(key, new NullValue(), 30, TimeUnit.SECONDS);
} else {
redisTemplate.opsForValue().set(key, product);
}
}
return product instanceof NullValue ? null : product;
}
案例2:热点key重建
现象:某明星离婚公告导致缓存频繁失效,数据库压力剧增
解决:采用互斥锁重建缓存
java复制public Product getProductWithLock(long id) {
String key = "product:"+id;
Product product = redisTemplate.opsForValue().get(key);
if (product == null) {
String lockKey = "lock:"+key;
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
try {
product = productDao.get(id);
redisTemplate.opsForValue().set(key, product);
} finally {
redisTemplate.delete(lockKey);
}
} else {
Thread.sleep(50);
return getProductWithLock(id);
}
}
return product;
}
在分布式系统中,缓存一致性没有银弹。经过多个项目的实践验证,我建议大部分场景采用Cache-Aside模式,配合消息队列或Canal实现删除保障。对于特别关键的业务,可以引入版本号或时间戳机制进行更精确的控制。记住:设计缓存策略时,一定要考虑业务对一致性的实际需求,避免过度设计。