目录
方案一:延迟双删
-
步骤
- 先删缓存: 在更新MySQL数据之前,先删除Redis中对应的缓存数据。
- 写数据库: 执行MySQL的Update写操作。
- (延迟后) 再删缓存: 在Step 2完成后,等待一段时间(比如500ms或1s),再次删除Redis中对应的缓存数据。
-
“延迟” 的原因详解
- 问题在于 “主从复制延迟” 或 “数据库集群同步延迟”。一般情况下数据库是主从模式,读写分离,MySQL的主库更新成功,不代表从库(或其他集群节点)立刻就能读到最新的数据。
- 想象一个场景,比如用户修改个人信息:
- 用户A发起更新操作(比如修改用户名),执行了Step1(删缓存)和Step2(更新主库)。
- 几乎在同时,用户B发起读取操作。
- B发现缓存没有(Step1已删),于是去读从库。
- 但是! 此时主库的新数据可能还没同步到从库,B从从库读到了旧的用户名数据。
- B把读到的旧数据,写回到了Redis缓存中。
- 这时,用户A的Step3(延迟删缓存)还没执行。缓存里就被B填满了旧数据!这就产生了脏数据。
- “延迟”的作用: 这个等待时间,就是为了让主库的数据有足够的时间同步到从库。等过了这个延迟时间,再次删除缓存(Step3),就能确保:
- 在延迟期间内,如果再有像B这样的请求读了从库旧数据并试图写入缓存,它写入的缓存会被Step3删除掉。
- 延迟过后,再有新的读取请求,发现缓存没有,会去读(已经同步了最新数据的)从库,取到新数据并写入缓存。
-
关键点
- 延迟时间估算: 延迟时间需要根据你的MySQL主从同步平均耗时来设定(比如观察监控,设置为平均耗时的2倍)。这不是一个精确值,但能大幅降低脏数据风险。
- 不保证强一致: 在Step1删除缓存后到Step3执行前的这段时间,以及主从延迟期间,依然存在短暂的不一致窗口。这是为了性能和可用性做的妥协(最终一致性)。
- 第二次删除失败怎么办? 这是个实际问题!面试官可能会追问。
常见方案:将第二次删除操作放入消息队列进行重试,或记录日志进行补偿。可使用:RabbitMQ/Kafka等中间件实现!
-
项目应用场景举例 (单体架构 - 博客系统)
- 用户编辑一篇已发布的博客文章(更新MySQL的
articles
表)。 - 流程:
- 先删Redis缓存:
DEL article:123
(假设123是文章ID)。 - 更新MySQL:
UPDATE articles SET content = '新内容' WHERE id = 123
。 - 等待500ms (预估数据库同步时间)。
- 再次执行:
DEL article:123
。
- 先删Redis缓存:
- 这样就能最大程度保证别人刷新页面看到的是最新文章内容,即使存在主从延迟。
- 用户编辑一篇已发布的博客文章(更新MySQL的
方案二:读写锁 (精简版解释)
-
核心思想
利用锁来控制对“特定数据”的并发访问,保证在更新数据时(写操作),相关的读操作(查缓存+查DB+回填缓存) 能看到一致的结果。 -
步骤
- 写操作 (更新数据):
- 获取该数据对应的写锁 (排他锁)。这个锁可以是Redis自己实现的分布式锁(如Redisson的
RReadWriteLock
),也可以是应用层的锁(需保证分布式环境有效)。关键:这个锁要能锁住“特定数据”(比如按ID锁)。 - 删除缓存。
- 更新数据库。
- 释放写锁。
- 获取该数据对应的写锁 (排他锁)。这个锁可以是Redis自己实现的分布式锁(如Redisson的
- 读操作 (获取数据):
- 尝试获取该数据对应的读锁 (共享锁)。
- 获取到读锁后,读缓存:
- 缓存命中 -> 返回数据 -> 释放读锁。
- 缓存未命中 -> 读数据库 -> 将数据库结果写入缓存 -> 返回数据 -> 释放读锁。
- 写操作 (更新数据):
-
为什么能保证一致性?(类比图书馆借书)
- 写锁 (排他锁): 就像图书管理员在更新某本书的信息(比如更正页码错误)。在管理员操作期间:
- 他会把这本书从书架上先拿走(相当于删缓存)。
- 然后在后台修改记录(更新DB)。
- 操作期间,不允许任何人(读者)同时读这本书(阻塞读操作),也不允许其他管理员修改这本书(阻塞其他写操作)。
- 读锁 (共享锁): 就像读者想借阅这本书。
- 如果管理员正在修改这本书(持有写锁),读者需要等待管理员完成(释放写锁)。
- 如果管理员没在修改,读者可以同时和多个读者一起阅读这本书(多个读锁可以共存)。
- 读者读完后,会把书放回书架(相当于可能回填缓存)。
- 锁导致的阻塞: 在写操作期间(持有写锁),读操作会被阻塞,直到写操作完成(释放写锁)。这就保证了在写操作进行中以及完成后,读操作要么读到新数据,要么需要重新从DB加载新数据(因为缓存被删了),不会读到写了一半的脏数据或旧数据。
- 写锁 (排他锁): 就像图书管理员在更新某本书的信息(比如更正页码错误)。在管理员操作期间:
-
关键点
- 锁粒度: 锁的粒度要合适(比如按数据ID锁)。锁整个库或表性能太差。
- 性能开销: 加锁解锁本身有开销,在高并发读场景下(多个读锁共存)性能尚可,但在高并发写场景下(写锁互斥)可能成为瓶颈。
- 分布式锁: 在微服务环境下,必须使用分布式锁(如Redis实现的Redisson RLock)才能让锁在所有服务实例间生效。
- 避免死锁: 确保锁最终一定会释放(设置超时时间)。
- 保证最终一致: 主要解决了并发冲突下的脏数据问题。
-
项目应用场景举例 (微服务项目 - 秒杀扣库存)
- 场景:多个用户同时抢购最后一件商品。
- 读写锁应用:
- 扣减库存 (写操作):
- 获取商品ID=1001的写锁。
- 删Redis缓存
stock:1001
。 - MySQL执行
UPDATE stock SET count = count - 1 WHERE id = 1001 AND count > 0
。 - 释放写锁。
- 查询库存 (读操作):
- 获取商品ID=1001的读锁。
- 查Redis缓存
stock:1001
。 - 如果没查到,查MySQL
SELECT count FROM stock WHERE id = 1001
。 - 将结果写入Redis缓存
stock:1001
。 - 返回库存数。
- 释放读锁。
- 扣减库存 (写操作):
- 这样能有效防止在扣减库存的瞬间,其他请求读到错误(未扣减)的库存数量并回填到缓存。
方案横向对比斟酌
Redis缓存同步方案对比表
对比维度 | 延迟双删 | 读写锁 |
---|---|---|
核心目标 | 降低主从延迟导致的脏数据风险,追求最终一致性 | 控制并发读写时序,在锁生效期内接近强一致 |
实现复杂度 | ★★☆☆☆ (简单) • 只需控制两次删除动作 + 延时队列 | ★★★★☆ (复杂) • 需引入分布式锁机制 • 要管理读写锁的生命周期 |
性能影响 | • 写操作:轻微延迟 (等主从同步) • 读操作:无阻塞 | • 写操作阻塞读操作 • 高并发写场景可能成瓶颈 • 读多写少时性能较好 |
一致性强度 | 最终一致 (存在短暂不一致窗口) | 准强一致 (写锁期间绝对一致,整体最终一致) |
可靠性关键点 | • 二次删除失败需补偿 (如MQ重试) • 依赖主从同步延迟时间的估算 | • 锁超时需合理设置 (防死锁) • 锁粒度要细 (如按数据ID锁) |
适用场景 | • 容忍短暂不一致 • 读多写少 • 主从延迟可控 • 案例:博客文章更新、商品信息修改 | • 对一致性要求高 • 写操作耗时短 • 并发冲突严重场景 • 案例:秒杀库存扣减、账户余额更新 |
不适用场景 | • 强一致性要求场景 • 主从延迟波动大 • 二次删除补偿机制缺失 | • 写操作耗时过长 • 超高并发写 • 无法承受读阻塞 |
项目落地案例 | 单体项目(苍穹外卖): • 更新菜品价格时,延迟双删防止用户看到旧价格 | 微服务项目: • 秒杀扣库存时用读写锁,避免超卖和脏读 |
面试追问防御点 | Q:第二次删除失败怎么办? • 答:MQ重试补偿 + 日志告警兜底 | Q:写锁阻塞导致系统卡顿怎么办? • 答:最小化锁内操作 + 设置锁超时 + 降级熔断 |