缓存一致性治理方案

缓存一致性治理方案

引入缓存

最简单的场景是, 业务在起步阶段, 流量很小, 读写请求直接操作数据库即可, 这时候的架构是这样的

img

随着业务量的增长, 请求量越来越多, 每次请求都直接打到数据库的话, 会出现性能问题, 这时候, 缓存出现了

img

常用的缓存中间件是Redis(还有其他优秀的中间件,但我没接触过), 加入缓存之后, 随之而来的一个问题是, 数据如何放入缓存

db的数据直接全量刷入缓存, 不设置失效时间, 通过定时任务或者是DTS同步db的数据变动到缓存, 所有读请求走缓存

img

这种方案下, 读请求可以直接命中缓存, 性能高, 但是缓存利用率很低, 存在很多低读取的冷数据, 另外即使你的数据同步做的再好, 也不可能做到0延时的同步, 所以数据不一致肯定会出现

利用率和一致性问题

先说利用率, 我们可以缓存热数据, 逻辑是这样

  • 所有写请求走db
  • 读请求先走缓存, 未命中则从db读取
  • 写入缓存, 设立失效时间

img

随着时间的推移, 缓存中的冷数据都会过期, 并且没有读取请求就不会重建, 一段时候后, 缓存中的数据就都是热数据了.

再说一致性, 我们知道, 定时任务和DTS是不可能做到0延时的, 所以当接收到写请求, db和缓存需要一起更新, 这里会存在一个先后的问题

  1. db先更新, 缓存后更新
  2. 缓存先更新, db后更新

因为操作分两步, 所以就会存在一成功,二失败的情况.

  1. db更新成功, 缓存更新失败, 则读请求打到缓存上, 响应的是旧值, 当旧缓存过期后, 才会重建新值的缓存, 用户层面的感受是, 更改未生效或者过了一段时间才生效.
  2. 缓存更新成功, db更新失败, 则读请求打到缓存上, 响应的是最新值, 过了一段时间, 缓存失效, 从db读取了旧值进行重建, 用户层面的感受是, 当时更改成功了, 但是过了一段时间又改回去了.

先后问题先按住不表, 说下并发问题

场景: T1和T2两个线程, 同时更新同一条数据, 更新方案是 「先db,后缓存」, 那么会发生什么

  1. T1更新db x = 1
  2. T2更新db x = 2
  3. T2更新缓存 x = 2
  4. T1更新缓存 x = 1

最终结果 db x = 2 , 缓存 x = 1, 数据不一致了, 「先缓存,后db」的方案逻辑是一样的, 就不浪费笔墨了.

如何解决并发带来的数据不一致? 常用方案是分布式锁

线程更新数据之前, 需要先申请锁, 拿到锁的线程才允许进行更新动作, 否则阻塞或者返回失败后重试.

无论是先更新缓存还是先更新db, 都会导致利用率低的问题, 因为所有的写请求都无脑去更新缓存, 肯定会导致缓存中存在冷数据. 并且通过加锁来保证一致性, 会占用机器性能,降低响应速度, 这与引入缓存的初衷相悖.

删除缓存

  1. 先删除缓存, 后更新db
  2. 先更新db, 后删除缓存

同样的,看下第二步失败的情景

  1. 缓存删除后, db更新失败, 下一次读请求访问缓存, 会去db读取数据并重建缓存, 用户层面的感受是, 更改失败了
  2. db更新后, 缓存删除失败, 下一次读请求返回了缓存中的旧值, 只有当旧值失效, 新的读请求重建缓存后, 数据才一致 , 用户层面的感受是, 更改失败了, 但过了一段时间刷新看看又好了.

再看并发问题 ,方案1在并发读写情况下

  1. T1删除了缓存
  2. T2读取缓存, 未命中, 从数据库读取了旧值重建缓存 x = 1
  3. T1更新了db x = 2

最终结果 缓存 x = 1, db x = 2, 数据不一致

方案2在并发读写的情况下

  1. 缓存中无x的值
  2. T1读取到数据库数据 x = 1
  3. T2更新db x = 2
  4. T2 删除了缓存
  5. T1重建了缓存 x = 1

最终结果 db x = 2, 缓存 x = 1, 数据不一致, 但是这种情况概率很低, 因为它需要满足三个条件

  1. 缓存刚好失效 , 对应第一步
  2. 读写并发
  3. 更新数据库 + 删除缓存的耗时(3 , 4 步) 小于 读取数据库+ 重建缓存的耗时 (2, 5 步)

理论上, 数据库写的耗时是会比读的耗时多的, 因为写需要加锁, 所以3, 4步 的耗时 大概率是大于 2, 5 步, 所以方案2在很大概率上是可以保证一致性的.

如何确保两步都成功

无论是双更新还是删除缓存的方案, 只要第二步失败, 就会导致数据不一致, 如何保证第二步成功就是解决问题的关键

最简单的方案 : 重试 , 当第二步失败, 就发起重试, 但是无脑重试也会带来别的问题

  1. 立刻重试, 大概是还是失败
  2. 重试次数过多, 占用线程资源

看到这里估计有经验的开发第一反应就是异步

异步重试, 可以选择开一个专用的重试线程, 但我觉得更好的方案是MQ, 将重试请求扔到MQ中, 由专门的消费者来重试, 直到成功 , 这样就完美解决 1和2两个问题.

多说一句, 引入MQ一样会带来更多问题, 增加更多的维护成本, 这取决于你的系统对一致性要求高不高, 没有银弹, 只有取舍.

引入MQ后, 架构变成了这样

img

另外一种方案, 订阅数据库binlog(阿里的canal), 再操作缓存

img

至此, 我们的方案是 先更新db, 再删除缓存, 配合MQ或者是canal做重试

主从延迟和延迟双删

先删除缓存,后更新db的方案下, 是由于读线程重建了缓存而导致的不一致, 如果我们在读线程建立缓存之后, 写线程再去做一次删除动作, 则数据一致性就可以得到保证

在读写分离 + 主从延迟的情况下, 先更新db, 后删除缓存的方案, 还是会出现不一致

  1. T1更新主库 x = 2
  2. T1 删除缓存
  3. T2 查询缓存, 未命中, 读取从库得到旧值 x = 1
  4. T2 将 x = 1 写入缓存
  5. 从库从主库完成同步

最终结果 db = 2 , 缓存 = 1, 这里就要引出延迟双删策略了

T1在执行完删除动作后, 可以先休眠一会, 然后再进行一次删除

或者T1在执行完删除动作后, 生成一条延时消息, 放入MQ, 由消费者延时删除缓存

两个关键 :

  1. T1的休眠时间或者是延时消息的延迟时间, 需要大于主从复制的延迟时间
  2. T1的休眠时间或者是延时消息的延迟时间, 需要大于T2查db加写缓存的时间

高并发的场景下, 这个延迟时间是很难估计的, 一般是凭经验设置, 比如1-5秒 , 尽可能的降低不一致的概率

强一致性

要想做到强一致,最常见的方案是 2PC、3PC、Paxos、Raft 这类一致性协议,但它们的性能往往比较差,而且这些方案也比较复杂,还要考虑各种容错问题。

相反,这时我们换个角度思考一下,我们引入缓存的目的是什么?

没错,性能

一旦我们决定使用缓存,那必然要面临一致性问题。性能和一致性就像天平的两端,无法做到都满足要求。

而且,就拿我们前面讲到的方案来说,当操作数据库和缓存完成之前,只要有其它请求可以进来,都有可能查到「中间状态」的数据。

所以如果非要追求强一致,那必须要求所有更新操作完成之前期间,不能有「任何请求」进来。

虽然我们可以通过加「分布锁」的方式来实现,但我们也要付出相应的代价,甚至很可能会超过引入缓存带来的性能提升。

所以,既然决定使用缓存,就必须容忍「一致性」问题,我们只能尽可能地去降低问题出现的概率。

同时我们也要知道,缓存都是有「失效时间」的,就算在这期间存在短期不一致,我们依旧有失效时间来兜底,这样也能达到最终一致。

总结

  1. 提高系统的响应速度, 可以引入缓存
  2. 引入缓存后, 要考虑db和缓存的一致性问题 , 有两种方案
    1. 双更新
    2. 更新db, 删除缓存
  3. 双更新的方案在并发场景下无法保证一致性, 需要加分布式锁, 但是加锁会占用机器性能,降低响应速度, 这与引入缓存的初衷相悖.
  4. 采用先删缓存, 后更新db的方案, 在并发场景下会有不一致的情况, 解决方案是 延迟双删
  5. 采用先更新db , 后删除缓存的方案, 为了保证两步都执行成功, 可以使用MQ或者binlog订阅的方式进行重试
  6. 在读写分离 + 主从复制的情况下, 采用先更新db, 后删除缓存的方案, 在并发场景下会有不一致的情况, 解决方案是 延迟双删

心得

  1. 性能和一致性不能同时满足,为了性能考虑,通常会采用「最终一致性」的方案
  2. 掌握缓存和数据库一致性问题,核心问题有 3 点:缓存利用率、并发、缓存 + 数据库一起成功问题
  3. 失败场景下要保证一致性,常见手段就是「重试」,同步重试会影响吞吐量,所以通常会采用异步重试的方案
  4. 订阅变更日志的思想,本质是把权威数据源(例如 MySQL)当做 leader 副本,让其它异质系统(例如 Redis / Elasticsearch)成为它的 follower 副本,通过同步变更日志的方式,保证 leader 和 follower 之间保持一致