一、开篇故事:仓库与橱窗的同步 🏪
想象商场的橱窗展示:
场景:商品价格更新
仓库(MySQL):
商品A: 100元
橱窗(Redis缓存):
商品A: 100元
顾客查询:
→ 看橱窗(Redis)→ 100元 ✅快速
问题来了:
仓库降价:商品A: 80元
但橱窗没更新:商品A: 100元 ❌
顾客:
"橱窗显示100,我就按100买!"
收银员:"仓库价格是80..."
顾客:"那你们数据不一致啊!💢"
这就是缓存一致性问题!
二、缓存一致性的挑战 ⚠️
2.1 为什么会不一致?
原因1:更新顺序问题
→ 先更新数据库,后删除缓存
→ 如果删除缓存失败 → 不一致
原因2:并发问题
线程A:读取缓存(未命中)→ 读数据库(旧值)→ 写缓存
线程B:更新数据库(新值)→ 删除缓存
时间线:如果A最后写缓存 → 缓存是旧值 → 不一致
原因3:缓存过期问题
→ 缓存过期了
→ 多个线程同时查询
→ 都发现缓存不存在
→ 都去查数据库
→ 缓存击穿!
2.2 强一致 vs 最终一致
强一致性(Strong Consistency):
→ 任何时刻,缓存和数据库完全一致
→ 代价:性能差、复杂度高
→ 适用:金融交易、库存扣减
最终一致性(Eventual Consistency):
→ 允许短暂不一致
→ 但最终会一致
→ 代价:性能好、复杂度低
→ 适用:商品详情、用户信息
结论:
→ 大部分场景使用最终一致性 ✅
→ 强一致性场景直接查数据库(不用缓存)
三、四种经典方案 🎯
3.1 方案1:Cache Aside(旁路缓存)⭐⭐⭐⭐⭐
最常用的方案!
读取逻辑
public User getUser(Long userId) {
// 1. 先查缓存
String cacheKey = "user:" + userId;
User user = redis.get(cacheKey);
if (user != null) {
return user; // 缓存命中 ✅
}
// 2. 缓存未命中,查数据库
user = userMapper.selectById(userId);
if (user != null) {
// 3. 写入缓存
redis.setex(cacheKey, 3600, user); // 1小时过期
}
return user;
}
更新逻辑(先更新DB,后删除缓存)
public void updateUser(User user) {
// 1. 先更新数据库
userMapper.updateById(user);
// 2. 后删除缓存
String cacheKey = "user:" + user.getId();
redis.del(cacheKey);
}
为什么不是"先删缓存,后更新DB"?
时间线(先删缓存):
T1: 线程A:删除缓存
T2: 线程B:查询缓存(未命中)→ 查数据库(旧值)→ 写缓存(旧值)
T3: 线程A:更新数据库(新值)
T4: 数据库是新值,缓存是旧值 ❌ 不一致!
时间线(先更新DB):
T1: 线程A:更新数据库(新值)
T2: 线程B:查询缓存(命中旧值)← 短暂不一致 ⚠️
T3: 线程A:删除缓存
T4: 线程B:查询缓存(未命中)→ 查数据库(新值)→ 写缓存(新值)✅
结论:
→ "先更新DB,后删缓存"更好
→ 不一致窗口更小
为什么不是"更新缓存"而是"删除缓存"?
方案A:更新缓存
1. 更新数据库
2. 更新缓存(写入新值)
问题:
→ 如果是复杂计算的值(如统计数据)
→ 每次更新都要重新计算
→ 浪费CPU
→ 如果缓存很少访问,浪费更新
方案B:删除缓存 ✅
1. 更新数据库
2. 删除缓存
3. 下次查询时,缓存未命中,查数据库,再写缓存
优点:
→ 懒加载(Lazy Loading)
→ 只有访问时才计算
→ 节省资源
删除缓存失败怎么办?
public void updateUser(User user) {
// 1. 更新数据库
userMapper.updateById(user);
// 2. 删除缓存
String cacheKey = "user:" + user.getId();
try {
redis.del(cacheKey);
} catch (Exception e) {
// 删除失败,记录日志
log.error("删除缓存失败:{}", cacheKey, e);
// 方案1:重试(3次)
for (int i = 0; i < 3; i++) {
try {
Thread.sleep(100);
redis.del(cacheKey);
return; // 成功
} catch (Exception retry) {
// 继续重试
}
}
// 方案2:发送到MQ,异步重试
mqProducer.send("cache_delete_topic", cacheKey);
}
}
// MQ消费者
@RabbitListener(queues = "cache_delete_queue")
public void deleteCacheRetry(String cacheKey) {
try {
redis.del(cacheKey);
} catch (Exception e) {
// 重试失败,记录到数据库,人工处理
failedTaskService.save(cacheKey, "cache_delete", e.getMessage());
}
}
3.2 方案2:Read/Write Through(读写穿透)⭐⭐⭐
缓存作为主存储,由缓存负责同步数据库
// 伪代码(实际需要缓存系统支持)
public User getUser(Long userId) {
// 直接查缓存,缓存自动加载数据库数据
return cache.get(userId, key -> {
// 缓存未命中时,自动回调此方法加载数据
return userMapper.selectById(userId);
});
}
public void updateUser(User user) {
// 直接更新缓存,缓存自动同步到数据库
cache.put(user.getId(), user, (key, value) -> {
// 缓存自动回调此方法,同步到数据库
userMapper.updateById(value);
});
}
特点:
✅ 应用无需关心缓存和数据库同步
✅ 逻辑简单
❌ 缓存系统需要支持(如Guava Cache、Caffeine)
❌ 不适合分布式缓存(Redis不直接支持)
❌ 写操作慢(同步写入)
3.3 方案3:Write Behind(异步写入)⭐⭐⭐
先写缓存,异步批量写入数据库
public void updateUser(User user) {
// 1. 立即更新缓存
redis.set("user:" + user.getId(), user);
// 2. 记录到写队列(异步)
writeQueue.offer(user);
}
// 后台线程,批量写入数据库
@Scheduled(fixedDelay = 1000) // 每秒执行一次
public void flushToDatabase() {
List<User> batch = new ArrayList<>();
writeQueue.drainTo(batch, 100); // 取出最多100条
if (!batch.isEmpty()) {
// 批量写入数据库
userMapper.batchUpdate(batch);
}
}
特点:
✅ 写入性能极高(只写缓存)
✅ 批量写入数据库(减少IO)
❌ 可能丢数据(缓存写入后,数据库未写入,宕机)
❌ 复杂度高
❌ 适合对一致性要求不高的场景(如浏览次数、点赞数)
3.4 方案4:双写一致性(两阶段提交)⭐⭐
同时写入缓存和数据库,使用分布式事务保证一致性
@GlobalTransactional // Seata分布式事务
public void updateUser(User user) {
// 1. 更新数据库
userMapper.updateById(user);
// 2. 更新缓存
redis.set("user:" + user.getId(), user);
// 如果任一失败,都会回滚
}
特点:
✅ 强一致性
❌ 性能差(分布式事务开销大)
❌ 复杂度高
❌ 不推荐(大部分场景不需要强一致性)
四、进阶方案:延迟双删 ⭐⭐⭐⭐
解决并发问题的方案
4.1 问题场景
时间线:
T1: 线程A:更新数据库(新值100)
T2: 线程B:查询缓存(未命中)
T3: 线程B:查询数据库(因为更新还没提交,读到旧值80)
T4: 线程A:删除缓存
T5: 线程A:提交事务
T6: 线程B:写入缓存(旧值80)❌
结果:
→ 数据库是新值100
→ 缓存是旧值80
→ 不一致!
4.2 延迟双删方案
public void updateUser(User user) {
String cacheKey = "user:" + user.getId();
// 1. 先删除缓存(第一次删除)
redis.del(cacheKey);
// 2. 更新数据库
userMapper.updateById(user);
// 3. 延迟后再删除一次缓存(第二次删除)
CompletableFuture.runAsync(() -> {
try {
// 延迟500ms(根据业务响应时间调整)
Thread.sleep(500);
redis.del(cacheKey);
} catch (Exception e) {
log.error("延迟删除缓存失败", e);
}
});
}
原理:
时间线(延迟双删):
T1: 线程A:删除缓存(第一次)
T2: 线程B:查询缓存(未命中)
T3: 线程B:查询数据库(旧值80,因为A还没提交)
T4: 线程A:更新数据库(新值100)
T5: 线程A:提交事务
T6: 线程B:写入缓存(旧值80)❌
T7: 线程A:延迟500ms后,删除缓存(第二次)✅
T8: 下次查询会读到新值100 ✅
优点:
✅ 解决并发问题
✅ 实现简单
缺点:
❌ 延迟时间不好确定
❌ 仍有短暂不一致窗口
五、终极方案:订阅Binlog ⭐⭐⭐⭐⭐
最可靠的方案!
5.1 原理
MySQL Binlog(二进制日志):
→ 记录所有数据变更
→ INSERT、UPDATE、DELETE
Canal(阿里开源):
→ 伪装成MySQL从库
→ 订阅Binlog
→ 解析变更事件
→ 发送到MQ或直接处理
5.2 实现
架构:
MySQL → Binlog → Canal → MQ → 缓存同步服务 → Redis
流程:
1. MySQL执行更新:UPDATE users SET name='Alice' WHERE id=1
2. 写入Binlog
3. Canal订阅到Binlog事件
4. Canal解析:表名=users, 操作=UPDATE, id=1, name=Alice
5. Canal发送到MQ(RocketMQ/Kafka)
6. 缓存同步服务消费MQ
7. 缓存同步服务:redis.del("user:1")
8. 完成
5.3 代码示例
// Canal客户端
@Component
public class CanalClient {
@Autowired
private RedisTemplate redisTemplate;
public void start() {
CanalConnector connector = CanalConnectors.newSingleConnector(
new InetSocketAddress("127.0.0.1", 11111),
"example", // destination
"", // username
"" // password
);
connector.connect();
connector.subscribe("mydb\.users"); // 订阅users表
connector.rollback();
while (true) {
// 获取数据
Message message = connector.getWithoutAck(100);
long batchId = message.getId();
if (batchId != -1) {
List<Entry> entries = message.getEntries();
for (Entry entry : entries) {
if (entry.getEntryType() == EntryType.ROWDATA) {
RowChange rowChange = RowChange.parseFrom(entry.getStoreValue());
EventType eventType = rowChange.getEventType();
for (RowData rowData : rowChange.getRowDatasList()) {
if (eventType == EventType.UPDATE ||
eventType == EventType.DELETE) {
// 解析id
Long userId = getColumnValue(rowData, "id");
// 删除缓存
String cacheKey = "user:" + userId;
redisTemplate.delete(cacheKey);
log.info("删除缓存:{}", cacheKey);
}
}
}
}
connector.ack(batchId); // 确认消费
}
}
}
}
优点:
✅ 最可靠(基于Binlog,不会丢失)
✅ 解耦(应用无需关心缓存更新)
✅ 实时性好
✅ 支持多种消费者(缓存、ES、数据仓库等)
缺点:
❌ 复杂度高(需要部署Canal)
❌ 运维成本高
❌ 需要开启Binlog(有性能开销)
六、不同场景的方案选择 📊
6.1 场景对比
| 场景 | 一致性要求 | 推荐方案 | 说明 |
|---|---|---|---|
| 商品详情 | 最终一致 | Cache Aside | 允许短暂不一致 |
| 用户信息 | 最终一致 | Cache Aside | 允许短暂不一致 |
| 库存扣减 | 强一致 | 不用缓存 | 直接查数据库+分布式锁 |
| 文章浏览数 | 弱一致 | Write Behind | 异步写入,性能好 |
| 金融账户 | 强一致 | 不用缓存 | 直接查数据库+事务 |
| 大型电商 | 最终一致 | 订阅Binlog | 最可靠 |
6.2 实战案例:电商商品详情
@Service
public class ProductService {
@Autowired
private RedisTemplate<String, Product> redisTemplate;
@Autowired
private ProductMapper productMapper;
// 查询商品(Cache Aside)
public Product getProduct(Long productId) {
String cacheKey = "product:" + productId;
// 1. 查缓存
Product product = redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product; // 缓存命中
}
// 2. 缓存未命中,加分布式锁(防止缓存击穿)
String lockKey = "product:lock:" + productId;
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
try {
// 再次检查缓存(double-check)
product = redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}
// 查数据库
product = productMapper.selectById(productId);
if (product != null) {
// 写缓存(1小时过期)
redisTemplate.opsForValue().set(
cacheKey,
product,
1,
TimeUnit.HOURS
);
} else {
// 防止缓存穿透:缓存空对象(5分钟)
redisTemplate.opsForValue().set(
cacheKey,
new Product(), // 空对象
5,
TimeUnit.MINUTES
);
}
return product;
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
} else {
// 没拿到锁,等待后重试
try {
Thread.sleep(100);
return getProduct(productId); // 递归重试
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
// 更新商品(先更新DB,后删除缓存)
@Transactional
public void updateProduct(Product product) {
// 1. 更新数据库
productMapper.updateById(product);
// 2. 删除缓存
String cacheKey = "product:" + product.getId();
redisTemplate.delete(cacheKey);
// 3. 延迟双删(可选)
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(500);
redisTemplate.delete(cacheKey);
} catch (Exception e) {
log.error("延迟删除缓存失败", e);
}
});
}
}
七、面试高频问题 🎤
Q1: 如何保证Redis缓存和MySQL数据的一致性?
答: 推荐方案:Cache Aside + 延迟双删 + 订阅Binlog
- Cache Aside:先更新数据库,后删除缓存
- 延迟双删:解决并发问题
- 订阅Binlog:终极保障(可选)
大部分场景使用Cache Aside即可,高要求场景使用订阅Binlog。
Q2: 为什么是"先更新DB,后删缓存"而不是"先删缓存,后更新DB"?
答: 因为"先删缓存"更容易出现不一致:
- 线程A删缓存 → 线程B查缓存未命中 → 线程B查DB(旧值)→ 线程B写缓存(旧值)→ 线程A更新DB(新值)→ 数据库新值,缓存旧值
而"先更新DB"不一致窗口更小,且可通过延迟双删解决。
Q3: 为什么不更新缓存,而是删除缓存?
答: 因为懒加载更高效:
- 如果缓存很少访问,更新浪费CPU
- 如果是计算型缓存,每次更新都要重新计算
- 删除后,下次查询时再加载,只有访问时才计算
Q4: 缓存和数据库双写,如何保证原子性?
答: 无法完全保证原子性(没有分布式事务)。只能:
- 先更新DB,后删缓存(Cache Aside)
- 删除失败时重试(MQ异步重试)
- 订阅Binlog作为兜底方案
- 设置缓存过期时间(最终一致性)
Q5: 如何解决缓存击穿、穿透、雪崩?
答:
- 缓存击穿(热点key过期):分布式锁 + double-check
- 缓存穿透(查询不存在的数据):缓存空对象 + 布隆过滤器
- 缓存雪崩(大量key同时过期):过期时间加随机值 + 多级缓存
八、总结口诀 📝
缓存一致不简单,
方案选择要慎重。
Cache Aside最常用,
先更DB后删缓。
删除缓存比更新好,
懒加载省资源。
延迟双删解并发,
Binlog订阅更可靠。
强一致别用缓存,
最终一致是常态。
监控重试要做好,
一致性能都要保!
参考资料 📚
下期预告: 155-MySQL的全文索引和空间索引的使用场景 🗺️
编写时间:2025年
作者:技术文档小助手 ✍️
版本:v1.0
愿你的缓存和数据库永远同步! 🔄✨