最难不过二叉树

缓存一致性的探讨

2023-07-26

缓存一致性是分布式系统中一个常见的问题,根据业务场景的不同,我们针对这个问题会有不同的技术方案。缓存一致性问题指的是数据库(MySQL或者Mongodb等)和分布式缓存(Redis)的数据一致问题,在互联网后台中,Redis作为缓存组件会存储DB的数据,我们的服务请求数据时首先从Redis上取,如果redis没有该数据就会转向MySQL等DB去取,数据取回来后再将数据回写到缓存中。这是读数据的例子,我们注意到,读数据也会触发写缓存的操作。在数据更新的场景,我们如果采取不合适的策略,就有可能导致某一时刻缓存和DB的数据存在差异。

根据业务场景的不同,我们对数据一致性的要求并不一样,有些场景要求强一致性,即需要任意时刻DB和缓存的数据都必须保持一致;有些场景要求最终一致性,即允许缓存和DB有一小段时间内数据不一致,但过一段时间后两者数据会走向一致。

在日常后台开发中,经常需要面对这些缓存一致性问题,举个例子,我们在设计投票功能时,玩家的票数会记录在MySQL和Redis中,A玩家给B玩家投票时,票数会更新到MySQL,然后再更新到Redis,各种服务需要获取玩家当前票数时,就先从Redis里获取,如果Redis没有再从MySQL里面取。

这是一个读多写少的场景,我们可以理解为投票这种写操作的频率远小于获取投票数据这种读操作,因为读票数是个并发量巨大的操作,因此需要加上缓存来保护MySQL。对于这种场景,我们怎么保证缓存一致性呢?

最终一致性场景

比如再以我们上面的投票场景为例,我们这个投票场景对于票数的实时显示并不是强制要求,因此我们可以在业务上解决这个数据更新缓慢的情况:玩家在投票之后,我们弹出一句提示:投票成功,票数稍后更新。因为很多情况下玩家并不在意票数的实时显示,更关心的是自己是否成功投票(获得相应的奖励),因此票数的实时更新并非要一定准确无误。在这个业务场景下,我们倾向于使用这个最终一致性的方案满足业务需求。

技术方案:先更新MySQL,再删除Redis key,Redis key需要设置超时删除时间,这个时间根据业务来决定。

这个方案适用于对数据一致性要求不太高的场景,接受数据在某个时间段数据不一致,该方案只保证数据的最终一致性。考虑以下这个案例:

假设有两个请求,A请求是读,B请求是更新,可能会出现的问题

  1. 缓存刚好失效
  2. 线程A查数据库,得到旧值
  3. 线程B更新数据库
  4. 线程B删除缓存
  5. 线程A更新缓存

这个场景下缓存就保留了A写入的旧数据,但是因为我们设置了数据的超时删除状态,因此这个旧的数据就会在key超时或者有新的更新操作时被刷新到新数据。换言之,这个缓存一致性方案存在一定几率系统在某个时间段内缓存和数据库数据不一致的情形,但这不一定就不能接受,需要按照业务场景需求来判断。

这里还想讨论一下,上面说到的缓存DB数据不一致的情况的出现概率高吗?因为已知读数据库耗时远小于写耗时,因此上面的第4步比第5步先执行的概率其实不高,因为线程A先发起读DB,那么大概率也是线程A更新缓存会更快一些。但是也不排除A的更新缓存操作的请求因为网络波动或者GC原因导致A的更新缓存晚于B的删除缓存操作,总体来说,上面提到的先更新数据库再删除缓存的策略,在大概率下都是能保证数据一致性的。

因为有小概率出现数据不一致的时间段,因此有方案采取先删除缓存,然后更新数据再延时删缓存的策略。跟上面谈到的方案唯一不同就是采取多次删除缓存,在更新数据库后,延时一段时间,再删一次缓存,以消除潜在的旧数据残留的问题。但是延时双删是否就解决问题了呢?个人认为还是没有,延时是指多久呢,这个需要通过实际业务测量才能得到这个经验值。但是如果我们读数据库回写缓存的请求还是晚于这个延时删除执行呢?因此这个延时双删的策略还是只是一个最终一致性的策略,因此跟上面的策略没有本质区别,但好处是降低了缓存DB数据不一致的概率。所以,对于非强一致性业务场景,推荐先更新数据库,再删除缓存,同时记得设置数据的超时删除时间。

还有些方案是引入订阅binglog和消息队列来实现最终一致性的,个人认为引入新的组件异步更新会大大增加了系统的复杂度,暂时还没遇到需要采取这个方式的应用场景。

强一致性场景

如果我们的投票场景是一个非常注重实时性的场景,即玩家看到的票数就是最新的票数,不能存在丝毫的误差,那就是一个数据强一致性场景。针对这个场景,我们就必须通过一些重的操作来保证数据一致性,这个重操作需要消耗系统性能和削弱系统的并发处理能力。

移除缓存层

最无脑的手段,移除缓存层,直接读写数据库。因为强一致性的约束,如果我们的请求量不多,我们可以考虑直接移除缓存,转为直接读写数据库,用最简单的方法解决强一致性问题。

分布式锁

如果我们的读操作很多,那么我们还是需要保留缓存应对海量读操作。这里最容易想到的就是利用分布式锁,我们对同一个数据进行操作时(读和写,读写数据库和写缓存捆绑为一个操作,可以理解为只要读写DB,就要加锁),需要先申请分布式锁,待整个操作完成后,再释放锁。这样加锁的操作是很笨重的,系统的处理能力大大降低,因为每当缓存cache miss、数据更新时就会触发加锁。

引入消息队列串行处理

跟分布式锁的方案没有什么差别,只是利用消息队列代替锁来保证并发安全。还是这个场景:

  1. 缓存刚好失效
  2. 线程A查数据库,得到旧值
  3. 线程B更新数据库

此时按照顺序入消息队列,A操作先进入队列,B操作再进入。因为队列内的操作是同步操作,必须保证出队的操作必须完整执行完才可以执行后面的操作,因此A,B的串行执行就避免了缓存数据库不一致的情况。这种方式跟加锁方式有着同样的问题,处理时间过长,系统处理性能大大降低。这种在产品表现的差异就是,玩家点了投票之后,可能需要等比较长的时间才能收到“投票成功”的提示,此时可以在产品中展示转菊花等提示表示你的请求正在处理,以提升产品体验。