数据库与缓存一致性
数据库与缓存异构,即作为两个数据源,很难保证强一致性,大方向的方案有:
- 使用2PC,XA等分布式事务,保证异构源之间的数据强一致性
- 尽量减少并发问题发生的时间窗口,设置兜底策略,保证数据的最终一致性
读写缓存
缓存、数据库双更
并发更新情况下,可能由于网络原因,出现更新顺序不可预测。缓存中出现旧数据。
更新频繁的情况下,会导致写频繁的数据也会被写入缓存,可能这部分缓存并没有读需求,即占据空间,也影响性能
我们希望频繁读的操作都即写操作,影响了缓存对读操作的优化
Cacheb Asided
旁路缓存,即以数据库数据为最新数据,即数据库与缓存的一致状态包括
- 缓存数据等于数据库数据
- 缓存没有对应数据。
更新逻辑:
- 读数据时,如果命中,则直接返回,未命中回源数据库查询,并更新缓存
- 写数据时,写入数据库,并删除对应缓存
以缓存优化读操作,并把更新操作延缓到下次读操作
数据更新策略包括:
- 先删除缓存,再更新DB
- 先更新DB,再删除缓存
先更后删
可能出现当一次更新时,数据库更新操作快于上一个cache miss的查询,导致缓存刚删除,就被cache miss的查询得到的旧值覆盖
- A线程cache miss 查询,获得数据库旧值
- B线程更新数据库为新值
- B线程 执行删除缓存
- A线程 在缓存中写入旧值
前提是发生cache miss,数据库读操作慢于一次后续的更新操作
可能性较低
先删后更
可能出现,删除后,还未更新时,发生cache miss,读取缓存旧值,并写入缓存。如果没有设置过期时间,脏数据在下次更新前都将存在。
由于数据库读操作快于写操作,所以发生概率较高。
数据库主从架构下,主从延迟会进一步增加可能出现并发问题的时间窗口。
主从延迟下,延迟时间就是窗口时间。
延迟双删
既然无论先删缓存还是先更新数据库都可能发生,旧值存在缓存中,那就延迟一定时间后,在进行一次缓存删除,在这次删除后,就达到了数据库与缓存的一致。即满足最终一致性。
延迟时间
延迟时间应该评估项目读取数据业务逻辑的平均耗时,延迟时间在此基础上增加数百ms。目的是刚好跨过上述并发问题发生的时间窗口,又不过于影响写数据延迟。
数据主从架构下,延迟时间还需要略大于主从延迟
设置过期时间
对数据存活时间进行兜底,达到数据不在缓存的一致状态。
适用场景
先更后删
实现成本低,并发问题时间窗口小,但删除失败可能导致不一致,且频繁删除缓存可能导致缓存穿透
适用于读多写少,接受最终一致性的场景
延迟双删
进一步在先更后删的方案下进行兜底,在延迟时间后能达到一致性(比设置过期时间更短)。但增加了操作耗时,仍然存在缓存穿透的可能。
适用于读多写少,接受短暂不一致的情况。
Write back
只对缓存进行读写,脏数据定时刷入数据库。类似mysql buffer pool或linux page cache
极致追求性能,适用于写频繁场景,不保证数据持久性,实现成本较高,需要考虑刷新策略和数据重建机制
Write/Read Through
将数据库和缓存作为一个数据源封装,由封装体内部自己维护一致性
Read
在查询中更新缓存,cache miss后更新缓存
Write
在更新时,
- 命中缓存,就更新缓存
- 没有命中缓存,就更新数据库
由内部维护一致性
适用场景
对业务友好,一致性取决于内部实现,一般保证最终一致性,适用于业务变化快的情况
Queue + retry
由于数据库与缓存的异步结构,即使减少的并发问题的窗口,都存在着第二步更新失败,导致数据不一致的情况。
为了解决两步操作的非原子性导致的数据不一致,引入重试机制。
同步重试存在:
- 立即重试失败概率很大
- 重试次数很难合理设置
- 同步重试阻塞请求
- 服务宕机会导致该次重试完全丢失
引入消息队列将当前重试操作与当前写入线程解藕,即异步重试
- 由消息队列保证重试的持久化和成功投递
- 写入线程不会被阻塞
实现方案
流程如下所示
- 更新数据库数据;
- 缓存因为种种问题删除失败
- 将需要删除的key发送至消息队列
- 自己消费消息,获得需要删除的key
- 继续重试删除操作,直到成功
适用场景
满足最终一致性,异步延迟较高,对业务代码造成大量的侵入。
consum binlog update database
将更新机制与业务代码解藕
实现方案
- 可以使用阿里的canal将binlog日志采集发送到MQ队列里面
- 然后通过ACK机制确认处理这条更新消息,删除缓存,保证数据缓存一致性
可以结合重试机制优化
删除缓存
存在延迟,可能导致缓存穿透,适用于读多写少的场景
更新缓存
不会导致缓存穿透,但不能处理db与缓存关系映射复杂的操作,也可能更新的数据并不会被读取,占据缓存空间
适用于缓存命中率高,映射规则简单、cache修改成本低的场景
强一致性方案
分布式锁
采用与缓存同介质的 redis 分布式锁,这样做的好处是若因为 redis 服务不可用导致的锁处理失败,对于缓存本身也就不可用,可以自动走降级方案。
Redis分布式锁可以在更新数据库->删除缓存,和查询DB->更新缓存的两个过程提供互斥操作,保证保证更新数据库、删除缓存两个操作中不会出现并发问题,提供强一致性保证
锁的范围
锁粒度 更新操作中加锁粒度有以下三种方案: 方案一:事务提交后加锁,只锁定删除缓存操作。对原事务无任何额外影响,但是在事务提交后到删除缓存之间存在与查询的并发可能性。 方案二:在事务提交前加锁,删除缓存后解锁。在满足一致性要求的前提下,锁的粒度可以做到最小,但是增加了 DB 事务的范围,若 redis 出现超时则可能导致事务时间拉长,进而影响 DB 操作性能。 方案三:在事务开始前加锁,删除缓存后解锁。锁的范围较大,但是能满足强一致性要求,对单个 DB 事务也基本无影响。
失败补偿
宕机情况
锁和缓存相同介质,Redis加锁失败,实际缓存也不可用,直接走DB降级查询
需要可靠地记录下哪些数据做了变更,待 redis 可用后需要进行恢复,需要将中间变更的记录对应的缓存全部删除。
方案:
我们的方案是构建一张简易的记录表(代表发生变更的 DB 数据),每次 DB 变更后,将该变更记录表的插入和业务 DB 操作放在一个事务中处理。事务提交后,对应的变更记录持久化,之后进行删除缓存,若缓存删除成功,则将对应的记录表数据也删除掉。若缓存删除失败,则可根据记录表的数据进行补偿删除,而在 redis 的恢复流程中,需要校验记录表中是否存在数据,若存在则表示有变更后的数据对应的缓存未清除,不可进行缓存读取的恢复。
超时情况
进行异步重试
分布式事务
\