ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

缓存一致性实战:从Cache Aside到延迟双删与binlog订阅

缓存一致性实战:从Cache Aside到延迟双删与binlog订阅 1. 缓存一致性到底在解决什么问题1.1 先从一个真实的线上事故说起我印象特别深的一次是给一个电商项目做订单列表优化。当时商品详情页的 QPS 很高数据库压力大得不行于是我们上了一层 Redis 缓存。上线前几天一切正常后来运营那边改了一个商品的价格从 199 改成 159结果用户在前台看到的还是 199下单的时候却是按 159 结算的。用户懵了客服也懵了运营更懵。这个问题就是典型的缓存一致性问题。数据库里的值已经变了缓存里的值还是旧的两边的数据对不上业务逻辑就会出乱子。很多同学第一次接触“缓存一致性”这个词会觉得它很玄乎其实说人话就是当数据同时存在于数据库和缓存两处时怎么保证读到的数据不会自相矛盾。它不是一个纯粹的存储问题而是“读”和“写”两条路径在并发场景下互相打架的结果。在我带过的团队里新人最容易犯的错就是给缓存加个过期时间就以为万事大吉。过期时间确实能兜底但它解决的是“最终一定会更新”而不是“更新期间读到旧值”。对于价格、库存、余额这类数据读到旧值的后果是真金白银的损失。1.2 读多写少场景下缓存的价值要理解一致性得先明白为什么大家非要加缓存。核心原因就一个数据库的读性能扛不住高并发。一个普通的 MySQL 实例单机稳定支撑的每秒查询数大概在几千这个量级具体取决于硬件和 SQL 复杂度。而 Redis 这类内存数据库单实例每秒处理十万级请求是很轻松的事。两者差了一到两个数量级。所以只要系统里有热点数据比如首页商品、热门文章、配置信息加缓存几乎是本能反应。但缓存带来收益的同时也顺手带来了复杂度。这个复杂度可以用一句很朴素的话概括一份数据两个存储写的时候先动谁、后动谁动完之后另一个要不要删、怎么删、删失败怎么办。这四个问号就是缓存一致性要回答的全部内容。不同的业务对此的容忍度完全不一样。像登录状态、商品浏览量这种短暂不一致没人会跳脚但像账户余额、库存扣减、优惠券张数那是不允许出现哪怕一秒的错误的。1.3 一致性有强弱之分先定标准再谈方案我在做方案评审的时候经常先抛一个问题给团队这个业务到底要强一致还是最终一致所谓强一致就是任何时刻读到的数据都必须等于数据库里的最新值。代价是性能大幅下降因为每次读都要去数据库确认缓存的加速意义几乎没了。所谓最终一致就是允许一小段时间内缓存里是旧值但经过一个确定的、有限的时间窗口后缓存会自动追平数据库。绝大多数互联网业务选的都是最终一致原因很现实性能比绝对正确更值钱而业务本身也经常能容忍毫秒到秒级的不一致。这两个概念一旦定下来方案选型的范围就小了一大半。要强一致那就老老实实读写都走数据库或者上分布式锁要最终一致才有删除缓存、延迟双删、订阅 binlog 这些玩法。所以别一上来就纠结“到底哪种方案最好”先问清楚业务能接受多大误差。2. 四种主流方案的原理和取舍2.1 先更新数据库再更新缓存为什么不推荐这是最直觉的方案写数据的时候先把数据库改掉然后把缓存里的值也改成新的。听起来很完美但它有个致命缺陷。设想两个并发的写请求 A 和 B 同时在跑。A 先把数据库更新成 10B 紧接着把数据库更新成 20。然后在更新缓存的时候由于网络抖动或者线程调度B 先执行了缓存更新把缓存设成 20A 后执行把缓存设成 10。结果数据库是 20缓存是 10两边彻底错位而且这个错误状态会一直持续到缓存过期。这种情况在并发写多的场景里出现的概率不低尤其是商品库存、集赞数量这种会被频繁修改的字段。更麻烦的是这个错位是静默的没有报错日志里也看不出来只能靠用户投诉暴露。还有一个更隐蔽的问题更新缓存往往是“无效写”。比如一个商品在一小时内被改了 100 次但只被读了 3 次那这 100 次缓存更新里有 97 次是白干的白白占了 Redis 的 CPU 和带宽。所以更新缓存这条路收益低、风险高我基本不在生产里用。2.2 先删缓存再更新数据库坑在哪里既然更新缓存不靠谱那改成删除呢写的时候把缓存删掉下次有人读的时候自然会从数据库加载最新值。这个思路是对的简化了写操作也避免了并发写的覆盖问题。但它有个很经典的失效场景读请求发生在写请求删除缓存之后、数据库更新之前。具体时序是这样的。写请求 W 先删掉了缓存然后还没来得急更新数据库此时读请求 R 进来发现缓存空了就从数据库读到旧值 199写进缓存。之后 W 才把数据库更新成 159。结果数据库是 159缓存是 199又错位了。这个窗口特别短短到很多人觉得“不可能这么巧”。但在高并发场景下每秒成千上万的请求这个短窗口被撞上的概率会随着流量上升而明显增大。这就是典型的“概率虽小但架不住次数多”。所以“先删缓存再更新数据库”虽然比更新缓存好但依然不能单独使用通常需要配合延迟双删来兜底。2.3 先更新数据库再删缓存为什么成了主流目前业界用得最多、也最推荐的是先更新数据库然后删除缓存通常被称为Cache Aside Pattern。它把数据库放在前面保证了只要数据库提交成功缓存就一定会被删掉。之后任何读请求发现缓存空了都会从数据库重新加载拿到的是最新值。这样就规避了 2.2 里那个“读到旧值回填缓存”的问题因为删除动作发生在数据库更新之后缓存被回填时数据库已经是最新的了。当然它也不是铜墙铁壁。有一种极端情况读请求 R 先查了缓存发现为空然后去查数据库这时候读到的还是旧值。在它还没把旧值写回缓存之前写请求 W 完成了数据库更新和缓存删除。再过一瞬间R 才把旧值写进缓存。这样缓存里就又留了一个旧值。这个场景要成立需要 R 的“查库回写”耗时大于 W 的“更新库删缓存”耗时通常是数据库主从延迟比较大、或者读请求被阻塞的时候才会发生。概率很低但不是零。业界常见的补救手段是给缓存设置过期时间作为最后一道防线。我个人的观点是先更库再删缓存配合合理过期时间能覆盖 99% 的业务场景。剩下的那 1%就得靠更重的方案来兜。2.4 延迟双删给极端情况加一层保险延迟双删的思路很直接写请求先删一次缓存然后更新数据库更新完再等一小段时间然后再删一次缓存。第一次删除是为了让读请求不要命中旧值第二次删除是为了清掉那些在“更新数据库”窗口期被回填进缓存的旧值。关键在于这个“一小段时间”要等多久。我一般这样估算延迟时间大于“读请求从数据库加载数据并写回缓存”的最大耗时。实际项目里这个耗时通常包括一次数据库查询加上一次 Redis 写入一般 100 毫秒到 500 毫秒之间。所以延迟设成 500 毫秒到 1 秒是比较保险的。但这个方案有两个现实问题。一是延迟会让写接口的响应变慢如果同步等待接口耗时直接增加几百毫秒用户体验会变差。二是如果系统是分布式部署第二次删除由哪个节点执行、怎么保证执行到都需要额外的调度机制。所以我更倾向用异步的方式做第二次删除比如丢到消息队列里延迟消费既不影响主流程又能保证执行。延迟双删适合对一致性要求比较高、又不方便引入 canal 这类中间件的场景。它算是“用一点复杂度换一点正确性”的折中方案。3. 生产级的落地细节与参数计算3.1 缓存过期时间到底设多少合适过期时间是缓存一致性的最后一道防线也是很多同学最容易拍脑袋决定的地方。我的经验是它不应该是随意填的数字而要和“能容忍的最大不一致时长”对齐。举几个不同类型的例子。商品详情这种数据用户能接受几分钟的延迟过期时间可以设 10 到 30 分钟。配置类数据变更不频繁设 1 小时甚至更长都行。而秒杀库存、活动状态这种用户对实时性要求极高过期时间就得压到几秒甚至更短。这里有个反直觉的点过期时间不是越短越好。设得太短缓存频繁失效大量请求会打到数据库缓存等于白加还可能在某个时间点集中失效引发缓存雪崩。我见过有人把过期时间设成 5 秒结果每隔 5 秒就有一波流量穿透到数据库数据库直接被打崩。我的做法是给过期时间加一个随机扰动。比如基准值 10 分钟实际过期值在 8 到 12 分钟之间随机。这样能避免大批 key 在同一时刻集体失效把雪崩的风险摊平。这个技巧几乎零成本但对稳定性帮助很大。3.2 删除失败怎么办重试和补偿怎么做删除缓存本身是一次网络调用它可能失败。如果删失败又不管它那条缓存就会一直是旧值直到过期。对于长过期时间的数据来说这可能是几十分钟的错误窗口不能接受。常规做法是引入重试。删除失败后不是立刻重试而是把这次删除任务丢到消息队列里由消费者异步重试几次。重试次数一般 2 到 3 次就够了太多会拖垮队列。如果重试多次还失败就记录下来进入人工介入或定时补偿流程。还有一个更彻底的做法叫binlog 订阅。就是让一个独立的服务去监听数据库的变更日志一旦有数据变更就由这个服务来负责删缓存。这样做的好处是业务代码里完全不关心缓存删除逻辑解耦而且 binlog 是数据库层面的记录只要数据库更新成功这个删除动作就一定会有可靠性比业务代码里手写删除高得多。Canal 就是这类方案的常见实现它伪装成 MySQL 的从库读取 binlog 后解析成业务事件。代价是系统里多了一个需要维护的组件对小团队来说有点重。所以我的建议是业务规模不大、团队人手有限就用先更库再删缓存加过期时间业务规模大了、对一致性要求高再考虑上 binlog 订阅。3.3 空值缓存和布隆过滤器的配合缓存一致性还有一个容易被忽略的兄弟问题缓存穿透。如果某个 key 在数据库里根本不存在那么每次读它都会查库缓存永远命中不了等于白加。这在恶意请求或者脏数据场景下会把数据库打穿。解决办法之一是空值缓存。数据库查不到的时候也在缓存里写一个空标记比如存一个特殊字符串过期时间设短一点比如 1 到 5 分钟。这样后续相同请求会直接命中这个空标记不会再去查库。但空值缓存有个风险如果这个数据后来被创建了而缓存里还留着旧的空标记读请求就会一直读到“不存在”。所以当有数据写入时必须同时清掉对应的空标记。这就把空值缓存和缓存一致性绑在了一起删除逻辑要覆盖得更全。另一种方案是布隆过滤器。在缓存前面加一层布隆过滤器所有可能存在的 key 都放进去。请求进来先问过滤器如果过滤器说不存在那就不用查库了直接返回。布隆过滤器的特点是有极小概率误判“存在”但绝不会误判“不存在”所以不会漏掉真实数据。我一般两个一起用布隆过滤器挡住绝大部分无效请求空值缓存兜住漏网的同时保证写操作时把两者的状态一起更新。这套组合下来穿透问题基本就稳了。4. 常见故障场景与排查实录4.1 缓存和数据库对不上时怎么定位线上出现数据不一致第一件事不是改代码而是确认不一致的范围和时间点。我通常会按下面这个顺序排查。先看是不是单条数据的问题。如果只有某个商品的价格不对那大概率是某次写操作删缓存失败或者延迟双删没执行到。这时候手动删一下这个 key 就能暂时恢复。如果是一批数据都不对那就要往方案设计上找。比如是不是用了更新缓存的方案并发写导致了覆盖或者是不是缓存和数据库的读写没有走同一套逻辑有人绕过封装直接操作了数据库。再看时间点。如果每次都是发版后一段时间开始不对那往往是代码逻辑变动引入的。如果是在流量高峰期出现的那更像是并发时序问题。我习惯在写操作里打上关键日志删缓存成功还是失败、耗时多少、对应的 key 是什么。这样出问题时可以直接搜日志定位不用靠猜。很多团队不愿意为删除操作加日志觉得是小事但真出问题时这几行日志能省下几个小时。4.2 数据库主从延迟放大不一致窗口现在稍微正规一点的系统数据库都是主从架构写走主库读走从库。这就引入了一个新的时间差从库的数据同步是有延迟的。这个延迟对缓存一致性的影响非常直接。写请求更新了主库然后删除了缓存。读请求来的时候缓存空了去从库查但此时从库还没同步到最新数据读到的还是旧值然后把这个旧值写回了缓存。等从库追平后缓存里依然是旧值因为它不会自动刷新。所以我一直强调缓存回填这条读路径绝对不能走从库必须走主库。虽然会增加主库压力但这是保证一致性的必要代价。或者换一个思路对一致性要求高的数据读操作干脆也走主库牺牲一点性能换正确性。如果实在不方便走主库那就得把过期时间压短并且缩短主从同步的延迟。主从延迟和网络、主库写入压力都有关系一般稳定在毫秒级但高峰期可能飙到几百毫秒这个要监控起来。4.3 一张表看清各种方案的适用边界方案一致性强度实现复杂度性能影响适用场景先更新数据库再更新缓存弱低读快不推荐生产使用先删缓存再更新数据库中低一般并发写极少的场景先更新数据库再删缓存较强低读快写一般绝大多数业务的首选延迟双删强中写稍慢一致性要求高的场景binlog 订阅删缓存很强高写快大型系统、多数据源这张表我在做技术分享时经常拿出来。选方案的时候先横着看“一致性强度”这一列定位到业务能接受的档位再看“实现复杂度”和团队能力是否匹配。很多团队一上来就想上最复杂的方案结果维护成本压垮了自己反而不如老老实实用最简单的先更库再删缓存。4.4 排查清单速查现象可能原因处理动作单条数据长期不对删除缓存失败且无重试手动删 key补日志和重试高峰期批量不对并发时序问题检查删除时机考虑延迟双删发版后开始不对代码逻辑变更对比新旧代码路径检查读写封装主从切换后不对回填走了从库强制回填走主库缓存频繁失效过期时间过短或集中失效加随机扰动适当延长过期这张表的价值在于它把“症状”和“动作”直接对应起来不需要在脑子里现推。我建议团队把这张表放进值班手册出问题时按表执行能省下大量沟通成本。5. 我踩过的坑和几条实在经验5.1 别在不该加缓存的地方加缓存这是我早年交过学费的地方。当时为了“优化性能”给一个用户的个人资料也加了缓存。结果用户改了昵称因为缓存逻辑有缺陷头像旁边显示的还是旧昵称用户自己看着别扭反馈了好几次。后来复盘发现这类数据的访问频率其实很低加缓存的收益本来就有限却引入了一致性维护的成本。判断要不要加缓存的标准不是“能不能加”而是“读的收益是否明显大于写的维护成本”。像配置、热点商品、排行榜这种读远大于写的数据加缓存天经地义像用户资料、订单详情这种几乎一人一份的数据加了反而添乱。所以我现在定方案第一步永远是看读写比。读写比至少 10 比 1 以上才值得认真考虑缓存策略。5.2 删除缓存一定要放在事务提交之后这个坑特别隐蔽。早期我把删除缓存的代码写在数据库事务里面看起来顺序没问题。但当并发量上来以后就出现了读到旧值的情况。原因在于事务还没提交的时候缓存已经被删了。这时候另一个读请求进来发现缓存空了去数据库查——因为事务还没提交它读到的是旧值然后把这个旧值写回了缓存。等事务提交后缓存里的旧值就留下了。正确做法是先把数据库事务提交确认提交成功之后再删缓存。这个顺序不能反。如果删除放在事务里不仅有一致性问题还会拉长事务时间影响数据库并发。这一点我在代码评审时发现过好几次属于特别容易写错的细节。5.3 读写封装必须统一别留后门团队大了以后最常见的隐患不是方案不好而是有人绕过封装直接操作数据库。比如某个紧急需求有人直接在 DAO 层写了更新语句忘了处理缓存。这种代码平时看不出问题一旦那条数据被读到就是脏数据。我的做法是把缓存操作和数据库操作封装在一起对外只暴露一个统一的方法读写都走它。同时加静态检查或者代码评审规范禁止在其他地方直接写更新数据库的代码。工具层面可以用一些扫描脚本定期找一下有没有绕过封装的直接操作。这件事说到底是个规范问题不是技术问题但破坏力比技术问题大得多。方案再完美只要有后门一致性就是空谈。5.4 监控比方案更重要最后想说的是缓存一致性很难做到百分百所以监控和快速发现比追求完美方案更实际。我会在几个关键位置埋点缓存命中率、删除失败次数、数据库和缓存的差异抽样对比。特别是抽样对比这一条做法是定时随机挑一些 key同时查数据库和缓存发现不一致就报警。虽然不能覆盖全部数据但能及时发现系统性问题。有了这些监控大部分一致性故障都能在用户投诉之前被发现。我的经验是线上真正让人头疼的往往不是“方案不够好”而是“出了问题不知道”等发现的时候错误数据已经积累了一大片清理起来非常痛苦。缓存一致性这个话题说到底没有银弹。它是业务容忍度、性能要求和实现复杂度之间的三角权衡。把读写比看清楚把容忍窗口定下来再选一个和团队能力匹配的方案大概率就能稳住。剩下的就是老老实实写日志、做监控、守规范把概率性的问题控制在可接受的范围内。
返回列表