双写一致性问题
背景
在 缓存 + 数据库 的经典架构里,缓存通常作为加速层来缓解数据库压力。
但在更新数据时,如果操作顺序处理不当,就可能导致缓存与数据库之间出现短暂的不一致。
常见的更新顺序有两种:
先写数据库,再写缓存
- 优点:简单直观,能保证数据库最终一致性。
- 缺点:在删除缓存和数据库更新之间的时间差,可能有请求先查到老数据,一致性弱。
先写缓存,再写数据库
- 优点:能一定程度上减少脏读。
- 缺点:如果数据库更新失败,就会丢掉缓存,反而更糟。
因此我们大多时候会选择先写数据库再写缓存。
删除与更新
在写缓存的时候我们可以选择删除或者更新,大多数时候我们会选择更新,因为[1]:
删除缓存 (Cache-Aside) | 更新缓存 | |
---|---|---|
复杂程度 | 简单 | 复杂 |
幂等性 | 天然幂等(无论怎么删,最终结果都是缓存被删除了) | 非幂等操作 |
并发写安全 | 安全 (删除顺序不影响最终状态) | 不安全 (并发更新可能导致缓存数据错乱或覆盖,即使是redis也只保证单个命令是安全的) |
效率 | 高 (直接删除,不关心数据) | 低 (可能频繁更新一个后续无人读取的值,消耗CPU和带宽,特别是当你在维护一个复杂的缓存时) |
数据最终一致性 | 更易保证 (删除不依赖数据) | 更难保证 (依赖数据,若顺序错误可能脏甚至是错误) |
缓存失效策略(Cache-Aside / Lazy-Loading)
写数据库,删除缓存,下次请求重建缓存,简单可靠。
双删
双删也是经常提到的办法,属于一种增强版的写数据库后删除缓存,能够减少不一致窗口期。
在更新数据库之后,执行两次缓存删除操作:
事务A | 事务B |
---|---|
更新数据库 | |
删除缓存(第一次) | |
访问数据 | |
缓存未命中,读取数据库 | |
返回数据库中的数据 | |
延时一段时间后删除缓存(第二次) |
第二次删除缓存通常会延迟,目的是解决以下问题:
事务A | 事务B |
---|---|
写数据库 val = 37 | |
写数据库 val = 42 | |
写缓存 val = 42 | |
写缓存 val = 37 |
- 延迟时间的选择
延迟需要大于业务中可能的并发请求处理时间。 - 幂等性
删除缓存操作需要是幂等的,即多删几次不会有副作用。
双删缺陷
弱一致性
- 在第一次删除和第二次删除之间,如果有读请求访问数据,还是可能会从数据库加载旧数据回填缓存 → 读到脏数据。
- 因此双删保证的只是 最终一致性,无法保证请求级别的强一致性。
令人纠结的时间
- 延迟太短:第二次删除可能还没覆盖到并发读回填的缓存,最终一致性可能都无法保证(长时间或永久脏缓存)。
- 延迟太长:第二次删除的间隔内,缓存可能被大量请求读写回填,脏数据存在的时间过长(脏数据多)。
实际上,延迟双删更多是工程妥协:在读多写少、对短时间不一致可容忍的业务场景下适用(个人感觉有点骚操作,不过能保证最终一致性的同时还能减少脏数据时间总是好的)。
脚注
[1] 删除是 KISS原则和 YAGNI原则 的体现。此外,好的设计往往是对需求的精准把握,我们作为设计师要从需求好好考虑,找到最关键的问题,简单并不是简陋。