兄弟们,欢迎来到 Redis 进化日志的第七天。
在 Day 6 里,我们全副武装,用布隆过滤器和互斥锁挡住了外部黑客和流量洪峰。现在的系统看起来固若金汤,外人根本打不进来。
但是,别高兴得太早! 堡垒往往是从内部攻破的。
场景复现:
你的用户“张三”改名叫“罗峰”,点击了保存。
MySQL里的名字成功改成了“罗峰”。
但是!Redis里的缓存因为某种原因(网络抖动、逻辑错误)没更新,还是“张三”。
用户一刷新页面,看到的依然是“张三”。
用户怒了:“我明明改了啊?你们系统是不是坏了?”
这就是著名的双写一致性问题。
面试官问这个问题时,如果你张口就来:“先改数据库,再改缓存呗”,或者简单一句“延时双删”,那基本就离“回去等通知”不远了。因为这里面全是并发留下的坑!
今天,咱们就抽丝剥茧,把这个“死锁题”彻底解开。
一、 经典错题集:那些年我们踩过的坑 🕳️
在寻找真理之前,我们先得把错误的道路堵死。很多初学者(甚至有经验的开发)都犯过以下错误。
❌ 错误姿势 A:先更新数据库,再更新缓存
// 错误示范 public void update(User user) { userMapper.update(user); // 1. 写库 redisTemplate.opsForValue().set("user:" + user.getId(), user); // 2. 更新缓存 }Bug 分析:
假设线程 A 和线程 B 同时修改同一个数据。
线程 A把 DB 改成了 "V1"。
线程 B把 DB 改成了 "V2"(B 后发先至)。
关键点:线程 B 动作快,先把 Redis 改成了 "V2"。
线程 A 网络卡了一下,姗姗来迟,把 Redis 改回了 "V1"。
结果:数据库是新的 "V2",Redis 却是旧的 "V1"。脏数据诞生!
先改数据库:如果数据库更新成功但缓存删除失败(或在删除前的短暂时间窗内),会导致缓存中依然保留旧数据,造成数据不一致。
❌ 错误姿势 B:先删除缓存,再更新数据库
这是著名的Cache Aside Pattern(旁路缓存模式)的一种变种,但它在高并发下有大坑。
// 错误示范 public void update(User user) { redisTemplate.delete("user:" + user.getId()); // 1. 删缓存 userMapper.update(user); // 2. 写库 }Bug 分析:
线程 A删除了缓存,正准备去改 DB(还没改完,比如卡了 100ms)。
线程 B 来了,想查数据。发现缓存空的(Cache Miss)。
线程 B去查 DB。注意!这时候 A 还没改完,B 查到的是旧数据。
线程 B把旧数据写入 Redis。
线程 A 终于改完 DB 了(新数据)。
结果:数据库是新的,Redis 永远停留在了旧数据。这就是经典的“读写并发不一致”。
先删缓存:在高并发场景下,读请求极易在数据库更新完成前读取到旧值并重新写回缓存,导致缓存中长期驻留脏数据(这是致命的“读写并发”问题)
二、 进阶方案:延时双删 (Delayed Double Deletion) ⏳
这是很多老博客推荐的方案,也是面试时的“标准答案”之一。它的核心目的是为了解决上面“错误姿势 B”中,线程 B 把旧数据写回缓存的问题。
核心逻辑:
既然线程 B 可能把旧数据写回去,那我改完数据库后,睡一会,再删一次,把 B 写进去的脏数据干掉!
生产级代码模拟:
@Service public class UserService { @Autowired private StringRedisTemplate redisTemplate; @Autowired private UserMapper userMapper; public void updateUser(User user) { String key = "user:" + user.getId(); // 1. 先删除缓存 redisTemplate.delete(key); // 2. 更新数据库 userMapper.update(user); // 3. 延时删除 (使用异步线程,避免阻塞主线程) CompletableFuture.runAsync(() -> { try { // 为什么要睡?为了让线程 B (读请求) 有足够时间把脏数据写入缓存,我们再删 // 睡多久?通常建议:读业务逻辑耗时 + 几百毫秒 Thread.sleep(500); // 4. 再次删除缓存 (Double Delete) redisTemplate.delete(key); log.info("延时双删成功: {}", key); } catch (InterruptedException e) { log.error("延时双删异常", e); } }); } }灵魂拷问:
延时多久?这是一个玄学。睡短了,B 还没写完你就删了,没用;睡长了,这期间的数据都是脏的。
如果第二次删除失败了怎么办?虽然概率低,但一旦失败,脏数据就留下了。
结论:延时双删只能降低不一致的概率,无法保证强一致性。适合对数据准确性要求没那么苛刻的业务。
三、 终极方案 A:强一致性 (ReadWriteLock)
如果你的业务是**“必须保证数据绝对一致”**(比如金融金额、商品库存、抢购资格),别整那些虚的,必须上锁!
但我们不能用简单的synchronized,因为读多写少,互斥锁太慢了。我们要用Redisson 的分布式读写锁 (ReadWriteLock)。
原理:
读锁 (Read Lock):共享锁。大家都可以读,互不影响,并发性能高。
写锁 (Write Lock):排他锁。只要有人在写,谁都不能读,也不能写。
生产级代码实战:
@Service public class ProductService { @Autowired private RedissonClient redisson; @Autowired private StringRedisTemplate redisTemplate; @Autowired private ProductMapper productMapper; // 读操作:加读锁 public Product getProduct(Long id) { String key = "product:" + id; RReadWriteLock rwLock = redisson.getReadWriteLock("lock:product:" + id); RLock rLock = rwLock.readLock(); // 加读锁:允许多个线程同时读,但会阻塞写线程 rLock.lock(); try { // 1. 查缓存 String json = redisTemplate.opsForValue().get(key); if (StringUtils.isNotBlank(json)) { return JSON.parseObject(json, Product.class); } // 2. 查 DB Product product = productMapper.selectById(id); // 3. 回写 Redis (即便这里有延迟,因为有读锁,写线程进不来,数据是稳的) if (product != null) { redisTemplate.opsForValue().set(key, JSON.toJSONString(product)); } return product; } finally { rLock.unlock(); } } // 写操作:加写锁 public void updateProduct(Product product) { String key = "product:" + product.getId(); RReadWriteLock rwLock = redisson.getReadWriteLock("lock:product:" + product.getId()); RLock wLock = rwLock.writeLock(); // 加写锁:阻塞所有读线程和其他写线程 wLock.lock(); try { // 1. 更新数据库 productMapper.update(product); // 2. 删除缓存 (因为加了写锁,这期间没人能读,所以绝对安全) redisTemplate.delete(key); } finally { wLock.unlock(); } } }优缺点:
✅强一致性:绝对不会有脏数据,逻辑闭环。
❌性能损耗:写数据时会阻塞读请求。如果写操作很频繁,系统吞吐量会下降。
四、 终极方案 B:最终一致性 (Canal + MQ) 🚀
如果你的业务是“允许短暂延迟,但最终必须一致”(比如电商的商品详情页、朋友圈动态),那么异步同步是目前大厂最主流的选择。
核心思想:
让业务代码只负责改数据库,别管 Redis。Redis 的更新交给一个“旁观者”去处理。
架构组件:Canal + RocketMQ/Kafka
业务代码:只管
db.update(),执行完直接返回成功。Canal:这是一个阿里开源的中间件,它把自己伪装成 MySQL 的 Slave(从库)。
监听:一旦 MySQL 数据变了,Binlog 就会推给 Canal。
MQ:Canal 收到消息,发给 MQ。
消费者:专门的一个服务监听 MQ,收到消息后,去把 Redis 删掉(或者更新)。
为什么这是大厂首选?
彻底解耦:业务代码里不需要写一堆
redis.del(),也不用担心删失败影响主业务。重试机制 (Retry):如果删 Redis 失败了怎么办?MQ 自带重试机制!它会一直重试,直到成功为止。这就是最终一致性的保障。
面试可能会拷打你:
Q1: 为什么是“删除缓存”而不是“更新缓存”?
回答:
懒加载思想:如果我很频繁地修改数据库(比如 1 分钟改 100 次),但我每次都去更新缓存,而这 1 分钟内其实没人来查。那这 100 次缓存更新就是浪费性能。删除它,等真正有人查的时候再去计算并加载,更省资源。
并发安全:更新缓存容易出现“A 改库 -> B 改库 -> B 改缓存 -> A 改缓存”的乱序问题,导致脏数据。删除缓存则简单粗暴,避免了复杂的覆盖逻辑。
Q2: 延时双删如果第二次删除失败了怎么办?
回答:
这是一个概率问题。如果必须保证成功,延时双删就不够用了。
这种情况下,我会引入 消息队列 (MQ)。将“删除缓存”这个动作丢进 MQ,如果失败了,利用 MQ 的 ACK 机制 进行重试,直到删除成功为止,保证最终一致性。
Q3: 你们项目中是怎么做的?
回答 (根据实际情况选一个):
普通业务:我们用的是“先更新 DB,再删除缓存”。虽然理论上有极低概率发生不一致(读操作在 Cache Miss 时查到旧数据,且在写操作之后才写入缓存),但在实际生产中,写操作通常比读慢得多,这种情况很难发生。
核心业务:对于一定要精准的数据(如库存),我们配合Redisson 读写锁来保证强一致性。
总结:一张表治好选择困难症
面试官问你怎么选,你直接甩出这个表格,显得非常专业:
| 方案 | 特点 | 适用场景 | 复杂度 |
| 先删缓存 + 更新 DB | 简单,但有严重并发 Bug | 不推荐使用 | ⭐ |
| 先改 DB + 删缓存 | 最通用,偶发不一致 | 90% 的互联网业务 | ⭐ |
| 延时双删 | 缓解并发问题,需把控睡眠时间 | 对一致性有要求,无中间件 | ⭐⭐ |
| Redisson 读写锁 | 强一致性,写多读少性能差 | 金融、支付、强库存管理 | ⭐⭐⭐ |
| Canal + MQ | 最终一致性,高性能,解耦 | 首页广告、商品详情、高并发大厂架构 | ⭐⭐⭐⭐ |
一句话总结:
没有完美的架构,只有适合的架构。
想省事且并发低?先改 DB + 删缓存。
数据绝对不能错?读写锁保平安。
高并发且允许秒级延迟?Canal + MQ是王道。