ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redis与MySQL数据一致性:缓存更新策略与工程实践

Redis与MySQL数据一致性:缓存更新策略与工程实践 想起一个真实事故。某业务上线第一天用户下单成功付款客服后台却查不到订单查Redis才看到订单数据还在MySQL里压根没落库。另一个事故是商品库存同步前端页面显示有货点进去却提示售罄运营截图投诉技术部“页面和后台数据对不上”。两个事故背后都是同一个问题Redis缓存和MySQL之间的数据没有对齐也就是常说的缓存与数据库数据一致性。这篇文章就用实际例子把Redis缓存和MySQL数据一致性这件事拆开讲透包含为什么会有不一致、业界常用的几种方案怎么选、扣库存场景完整落地代码、强一致要求下有什么手段以及线上排查时最容易踩的坑。面向的是后端开发者、架构设计学习者以及正在准备面试的人看完能直接照着设计一套能落地的方案。1. 为什么Redis和MySQL的数据会不一致1.1 缓存的定位挡流量的加速层不是数据的最终归宿先想明白一个问题MySQL是业务数据的真实存储Redis只是放在前面的加速层。用户读数据时先查RedisRedis没命中再查MySQL查到后把结果写回Redis后续请求直接走缓存这就是最常见的Cache Aside模式。问题就出在“先查缓存”这一步缓存里存的是旧值数据库里已经是新值读请求命中了旧缓存返回给用户的数据就是错的。为什么一定要加Redis而不是所有请求都打MySQL原因很现实MySQL单机能承受的并发有限通常几千到一万左右的QPS已经接近极限而Redis单机扛十万级QPS很轻松。引入了Redis就不可能在每次更新数据时让MySQL和Redis同时变化两次操作之间天然存在一个时间窗口。这个窗口在高并发下被放大不一致就出现了。生活里有个很贴切的类比MySQL是户口本Redis是贴在手机壳里的身份证复印件。户口本改了姓名复印件还印着旧名字这中间有人拿复印件办事办出来的就是旧信息。1.2 两个操作、一个时间差不一致的根源在现场用一个具体并发场景说明时间差的危害。商品库存初始值是10。请求A执行更新操作把库存改成9更新MySQL成功但还没来得及删除Redis中的库存缓存。请求B执行读操作先查Redis命中的还是旧的库存10直接返回给前端。此时页面上显示有货实际数据库里只剩9件如果按页面显示放用户下单就可能超卖。如果把操作顺序反过来先删Redis再写MySQL也一样有窗口请求A先删除了Redis中的库存缓存然后开始更新MySQL更新需要几百毫秒。请求B在A写库完成之前发起读Redis没命中去MySQL查到了旧值10此时A还没提交把10回填到Redis然后A提交事务数据库里变成9Redis里却一直是10。也就是说只要更新数据库和操作缓存之间不是原子的、没有严格串行就一定存在一个短暂窗口在这个窗口里读到旧缓存或回填旧值。这个窗口在高并发下被反复命中就会表现为偶发的脏数据、超卖、页面与后台不一致。不少人面试时被问“Redis和MySQL一致性怎么保证”脑子里只有“删缓存”这三个字但要真正讲清楚必须从时序竞争角度说明白窗口在哪里以及每个方案堵住了哪个窗口、还剩下哪个窗口。2. 三种主流处理顺序选型背后的逻辑2.1 先更新数据库再删缓存Cache Aside 黄金实践业界最常用、最稳妥的顺序是先更新MySQL成功后删除Redis缓存。它的核心逻辑是更新操作以MySQL为准MySQL成功后再清理缓存。下一次读请求发现缓存不存在重新从MySQL加载最新值回填Redis。为什么是删除缓存而不是更新缓存两个原因。第一更新一个字段时要重算整个业务对象的所有字段成本高、容易遗漏。比如订单对象有金额、状态、收货地址、优惠明细改完状态还要把整个对象序列化后写进Redis计算和序列化开销明显高于一个DEL命令。第二直接更新Redis存在丢失更新的风险两个写请求同时更新MySQL后提交的事务可能比先提交的慢到达Redis导致Redis里存的是旧值这个坑非常隐蔽。举个人人遇过的例子A请求把商品价格改成100B请求改成80两个请求同时执行MySQL最终值取决于提交顺序但Redis里如果恰好是先被B写入80、再被A写入100最终缓存和数据库就对不上了。所以先更新数据库再删缓存配合下一次读请求重建缓存是一种既简洁又抗并发覆盖的方案。代价是什么还是有一个小窗口写请求更新完MySQL但还没来得及删缓存读请求命中了旧缓存。这个窗口时间很短一般就是一次Redis删除命令的耗时毫秒级。对于大多数读多写少场景这个窗口造成的风险可以接受所以它成了默认方案。// 典型代码结构 public void updateStock(Long skuId, Integer count) { // 1. 更新MySQL stockMapper.updateCount(skuId, count); // 2. 删除Redis缓存 redisTemplate.delete(stock: skuId); }2.2 先删缓存再更新数据库为什么风险更大先删缓存再写库的做法在并发场景下风险更大。前面已经分析过删缓存和写库之间存在较长窗口读请求很容易在这个窗口内从MySQL读到旧值回填这个旧值会一直驻留到下一次删除。什么情况下才会有人用这个顺序通常是早期系统里的错误直觉认为“先让缓存失效再更新数据库用户下一次来读时拿到的一定是新数据”。但这句话只在线性请求下成立一旦并发读出现在窗口内旧值回填的问题就会暴露。真的要用这个顺序必须配合后续手段比如延时双删或版本号校验。没有后手直接用基本就是把一致性风险放在明面上。2.3 延时双删用第二次删除收敛竞争窗口延时双删是在先删缓存再写库或者先写库再删缓存的基础上增加第二次延迟删除先删除Redis中的缓存。更新MySQL。休眠一段时间。再次删除Redis中的缓存。第二次删除解决什么问题解决“读请求在窗口内把旧值回填到Redis”的问题。第一次删完缓存后读请求可能回填旧值等休眠结束数据已经在库里变成新值此时再删一次就把那个回填的旧值清掉下一次读请求就能读到新值。核心参数是休眠时间的设定。这个时间必须大于读请求从MySQL查数据到回填Redis的耗时。如果业务里一次读请求查库耗时在50到100毫秒休眠时间取200到500毫秒就够了如果查库过程中还要做复杂的表关联、RPC调用可能需要放大到1秒。延时双删不是银弹它只是把旧值在被回填后、在缓存里驻留的时间压缩到休眠窗口内。在这个窗口内的读请求仍然可能读到旧值但窗口结束后数据恢复一致。public void updateStockWithDelayDelete(Long skuId, Integer count) { // 第一次删除 redisTemplate.delete(stock: skuId); // 更新MySQL stockMapper.updateCount(skuId, count); // 延迟第二次删除 scheduledExecutor.schedule(() - redisTemplate.delete(stock: skuId), 500, TimeUnit.MILLISECONDS); }2.4 三种方案对比速查表方案操作顺序不一致窗口风险等级适用场景Cache Aside先更新库再删缓存更新MySQL后删除Redis删除前瞬间毫秒级低绝大多数读多写少业务先删缓存再更新数据库删除Redis后更新MySQL旧值回填后长时间驻留高不推荐单独使用延时双删删Redis、更新库、延迟再删Redis休眠窗口内中对一致性要求较高的热点数据成熟团队使用从我个人经验看先把Cache Aside作为默认方案落地是成本最低、收益最稳妥的选择。只有确认业务对短暂不一致零容忍或者线上出现明确脏读事故才值得上延时双删甚至更强的方案。3. 一个真实例子秒杀扣库存怎么做到基本一致3.1 业务拆解与代码骨架秒杀场景是最典型的缓存一致性案例。商品库存预热到Redis用户秒杀时先减Redis库存再异步扣减MySQL库存。这里有一个很常见的设计误区直接把Redis当库存台账使用MySQL和Redis各存一份两边数据天然需要同步。不少团队的做法是先把Redis当作扣减入口秒杀结束后再批量同步MySQL。这种设计在流量峰值时很爽但一致性风险被延后Redis扣了、MySQL没扣上或者MySQL扣了、Redis没扣上对账时就会出问题。更稳妥的做法是扣减动作以MySQL为基准Redis只承担读请求的加速。大致的代码骨架Transactional public boolean seckill(Long skuId, Long userId) { // 1. 乐观锁扣减MySQL库存 int rows stockMapper.deductStock(skuId, 1); if (rows 0) { throw new BizException(库存不足); } // 2. 创建订单 orderMapper.insert(new Order(skuId, userId)); // 3. 事务提交后删除Redis缓存通过TransactionSynchronization注册回调 TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { Override public void afterCommit() { redisTemplate.delete(stock: skuId); } } ); }这里有一个关键设计缓存删除动作必须在事务提交之后执行。如果在事务提交前就删除Redis事务还没提交别的读请求查MySQL看到的还是旧库存回填到Redis又变成旧值整个删除白做。3.2 事务边界缓存操作不能塞进MySQL事务把Redis删除操作放进事务里面是很多人忽略的细节。一个典型的错误写法Transactional public void updateStock(Long skuId, Integer count) { stockMapper.updateCount(skuId, count); redisTemplate.delete(stock: skuId); }看起来没问题但这行删除Redis的代码是在事务提交前执行的事务如果回滚缓存已经删掉而数据库还是旧值下一次读请求会把旧值重新回填缓存和数据库并不会因此不一致。真正的问题在于如果在删除Redis之后、事务提交之前读请求来了MySQL还没提交新值读请求查到旧值回填缓存被旧值占住事务提交后这条删除已经被执行过了不再有第二次删除缓存持续脏到过期。正确做法是注册事务同步回调或者使用Spring的TransactionalEventListener在事务提交后执行删除。这是严谨的落地代码和课堂伪代码之间最常见的差别。3.3 删除缓存失败后的兜底消息队列重试删缓存这个动作本身也可能失败。Redis超时、网络闪断、Key在删除瞬间已经过期都会导致DEL命令无法执行。缓存删不掉旧值就会一直驻留直到线程下一次删除或缓存过期。删除失败的兜底思路是重试重试的载体一般选消息队列或本地消息表。整体流程更新MySQL成功后事务提交时发送一条消息到MQ消息内容包含需要删除的Redis Key。消费者收到消息后执行Redis删除如果删除失败返回重试MQ按延迟队列策略重试。重试超过最大次数比如5次转人工或写入告警日志。这种设计把“删缓存”从一次网络调用变成一次异步任务提高了删除的可靠性。代价是架构多了一个组件团队需要接受MQ的引入成本。3.4 主从复制延迟下的一致性补充MySQL做了主从分离后一致性又多了一层变数。更新操作写主库读请求可能走从库。假设写请求更新主库并删除了Redis缓存读请求打到从库时从库可能还没同步到最新数据把旧值回填到RedisRedis里又出现了旧缓存。这时候纯靠延时双删不一定够因为第二次删除后下一次读请求依然可能从延迟的从库读到旧值并回填。一种工程化解法是让写请求删除缓存后读请求短暂时间内直接查主库或者读请求回填缓存时校验数据版本。更常见的企业方案是引入Canal订阅MySQL binlog监听到真实数据变更后再触发缓存删除。这样删除动作发生在数据真正变更之后能规避从库延迟窗口内回填旧值的问题。Canal方案本身复杂但如果团队已经有一套binlog订阅基础设施用起来会很顺手。4. 强一致场景下的方案边界4.1 分布式锁用串行化换一致性如果业务不能容忍毫秒级的不一致那就只能让所有对同一个Key的读写操作串行执行。做法是引入Redis分布式锁读之前先获取锁写也先获取锁拿到锁之后读或写都直接面向最新数据。这个方案的可靠性很高但代价是性能下降明显。同一把锁把QPS限制住了一旦锁获取冲突变多请求排队时间就会拉长。极端场景下甚至出现大量线程阻塞等待锁释放数据库QPS没有被打下去反而被应用层的串行逻辑拖垮。所以分布式锁适合“一致性要求极高、并发量又不算大”的场景比如用户余额变更、敏感状态流转。秒杀这种数十万并发场景用锁保护单Key基本等于自杀。4.2 版本号与时间戳让缓存自己识别过期给Redis Value增加一个版本号或时间戳读取时与应用内存中或请求头里携带的版本比对不一致就把缓存作废重建缓存。这种方案比延时双删更精细因为它不是盲目等待一个固定时间而是用数据本身来判断新旧。具体做法每次更新MySQL时同时把业务数据的版本号加一写入Redis时把版本号一起写入。读请求从缓存拿数据时如果发现版本号小于自己已知的最新值说明缓存陈旧立即删除缓存并回源MySQL查询新值。项目里直接用时间戳更常见比如Redis Value里存一个updateTime字段读取时比较时间戳。这个方案唯一的麻烦是业务代码要自己维护版本比较逻辑框架层不会自动帮你做。4.3 缓存不更新必要时把流量直接放回MySQL有一些监管、对账、资金类场景对数据一致性的要求是“零容忍”。这种场景再花哨的方案都没有意义最简单的方式就是干脆不写这类数据对应的缓存。数据永远从MySQL直读一致性问题直接从根上消失。另一种中间态是“只缓存只读数据、不缓存可写数据”商品详情页的标题、图片、详情文案这些很少变更的字段可以放Redis库存、金额、状态这些高频变更字段不放缓存。做了几年后再回头看很多一致性问题是架构上“不该缓存的数据为了性能硬塞缓存”导致的。能识别哪些数据值得缓存、哪些数据不能缓存比掌握一百种缓存删除技巧更值钱。5. 常见问题与企业级排查实录5.1 先把穿透、击穿、雪崩和一致性问题分开很多文章把缓存穿透、击穿、雪崩和缓存一致性放在一起讲实际上这是两类问题排查时要先分清楚。缓存穿透查询一个不存在的数据缓存没有、数据库也没有请求直接打到数据库黑客可以用大量不存在的ID刷穿缓存。缓存击穿热点Key在失效瞬间大量请求同时打到数据库。缓存雪崩大量Key同时过期流量瞬间打到数据库。缓存一致性缓存里的值和数据库里的值不一样。在线排查脏数据问题时如果根因分析先跑到“缓存穿透、击穿、雪崩”上面去了方向就错了。先确认线上Redis和MySQL两边的值再倒推到底哪一步时序出现竞态才是正确的排查路径。5.2 延时双删的延时时间究竟定多少延时时长没有教科书标准常见建议是500毫秒但在不同业务里这个值不一定合适。定得太短第二次删除执行时读请求还没来得及完成旧值回填等于没删定得太长窗口期内的脏数据会一直被读到。我的经验是先从监控日志里统计一次业务读请求从缓存Miss到MySQL查询再到Redis回填的耗时取P99值再乘以3到5倍得到一个保守的休眠时间。比如日志统计P99是120毫秒休眠时间设400到600毫秒是合理的。另外用固定线程池做延迟删除时要小心线程池排队。如果一次更新操作产生大量延迟删除任务任务排队时间过长第二次删除的实际执行时间远超预期效果就会打折扣。5.3 删除大key阻塞Redis主线程Redis是单线程模型删除大集合或者大字符串的Key时DEL命令会阻塞主线程期间所有命令都排队。线上Redis cluster删除一个几百MB的String Key会导致几十毫秒甚至上百毫秒的卡顿。删除缓存这个动作本身应该是一个轻量操作但如果业务不小心把大对象写进了Redis删除时就可能引发在线事故。Redis 4.0之后提供了UNLINK命令把释放内存的操作交给后台线程异步处理删除缓存时优先选用UNLINK而不是DEL。# 优先使用异步删除 UNLINK stock:1001 # 而不是 DEL stock:1001这个细节在面试里也算加分项但更重要的是在线上真的能帮你避开一次P1事故。5.4 key过期时间最后一道保险一切方案都有兜底缓存Key设置过期时间就是把兜底做在Redis本身即使前面所有删除机制都失效了缓存也会在过期后自动失效下一次读请求重新从MySQL加载最新数据最终回到一致状态。过期时间长一点还是短一点取决于业务容忍度。商品详情这类低频变化数据可以设置30分钟到几小时库存、价格这类需要快速感知变化的数据建议不超过几分钟。短过期时间会带来更多的缓存Miss和数据库回源性能收益下降需要平衡。5.5 巡检脚本从线上数据里找不一致证据最后分享一个排查手段。线上报警出现可疑脏数据时我会用一个简单的巡检脚本逐步定位先从Redis获取某个Key缓存的值再去MySQL查同一主键的最新值比对两边内容输出差异。# 伪代码逻辑 redis_value$(redis-cli GET stock:1001) mysql_value$(mysql -e select count from stock where sku_id1001) if [ $redis_value ! $mysql_value ]; then echo 不一致! redis$redis_value mysql$mysql_value fi这个脚本配合定时任务每天拉一次差异清单就能知道当前方案在实际流量下有没有漏网之鱼。很多隐藏的竞态问题不是靠推理发现的而是靠对账脚本在线上抓到第一手证据之后再反推出竞态路径。做过几轮大促后我的体会是先更新数据库再删除缓存加上合理的过期时间已经能覆盖绝大多数场景。强一致真有要求别指望靠调参数优化出来要么串行化要么干脆别缓存。项目里最怕的不是不知道方案而是一套方案里混着三四种不同的删除顺序每个开发写出来的行为都不一样这种隐性的不一致比任何技术选型都危险。把方案定成一个团队规范写入代码模板和评审检查项比在线上抢救数据要便宜得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进