引言
数据库跟缓存,或者用Mysql和Redis来代替,想必每个CRUD boy都不会陌生。本文要聊的也是一个经典问题,就是以怎样的方式去操作数据库和缓存比较合理。
在本文正式开始之前,我觉得我们需要先取得以下两点的共识:
缓存必须要有过期时间
保证数据库跟缓存的最终一致性即可,不必追求强一致性
为什么必须要有过期时间?首先对于缓存来说,当它的命中率越高的时候,我们的系统性能也就越好。如果某个缓存项没有过期时间,而它命中的概率又很低,这就是在浪费缓存的空间。而如果有了过期时间,且在某个缓存项经常被命中的情况下,我们可以在每次命中的时候都刷新一下它的过期时间,这样也就保证了热点数据会一直在缓存中存在,从而保证了缓存的命中率,提高了系统的性能。
设置过期时间还有一个好处,就是当数据库跟缓存出现数据不一致的情况时,这个可以作为一个最后的兜底手段。也就是说,当数据确实出现不一致的情况时,过期时间可以保证只有在出现不一致的时间点到缓存过期这段时间之内,数据库跟缓存的数据是不一致的,因此也保证了数据的最终一致性。
那么为什么不应该追求数据强一致性呢?这个主要是个权衡的问题。数据库跟缓存,以Mysql跟Redis举例,毕竟是两套系统,如果要保证强一致性,势必要引入2PC或Paxos等分布式一致性协议,或者是分布式锁等等,这个在实现上是有难度的,而且一定会对性能有影响。而且如果真的对数据的一致性要求这么高,那引入缓存是否真的有必要呢?直接读写数据库不是更简单吗?那究竟如何做到数据库跟缓存的数据强一致性呢?这是个比较复杂的问题,本文会在最后稍作展开。
本文主要在保证最终一致性的前提下进行方案讨论。
数据库和缓存的读写顺序
说到数据库和缓存的读写顺序,最经典的方案就是这个所谓的Cache Aside Pattern了。其实这个方案一点也不高大上,基本上我们平时都在用,只是未必知道名字而已,下面简单介绍一下这个方案的思路:
失效:程序先从缓存中读取数据,如果没有命中,则从数据库中读取,成功之后将数据放到缓存中
命中:程序先从缓存中读取数据,如果命中,则直接返回
更新:程序先更新数据库,在删除缓存
前两步跟数据读取顺序有关,我觉得大家对这样的设计应该都没有异议。读数据的时候当然要优先从缓存中读取,读不到当然要从数据库中读取,然后还要放到缓存中,否则下次请求过来还得从数据库中读取。关键问题在于第三点,也就是数据更新流程,为什么要先更新数据库?为什么之后要删除缓存而不是更新?这就是本文主要要讨论的问题。
总共大概有四种可能的选项(你不可能把数据库删了吧...):
先更新缓存,再更新数据库
先更新数据库,再更新缓存
先删除缓存,再更新数据库
先更新数据库,再删除缓存
接下来我们分情况逐个讨论一下:
先更新缓存,再更新数据库
我们都知道不管是操作数据库还是操作缓存,都有失败的可能。如果我们先更新缓存,再更新数据库,假设更新数据库失败了,那数据库中就存的是老数据。当然你可以选择重试更新数据库,那么再极端点,负责更新数据库的机器也宕机了,那么数据库中的数据将一直得不到更新,并且当缓存失效之后,其他机器再从数据库中读到的数据是老数据,然后再放到缓存中,这就导致先前的更新操作被丢失了,因此这么做的隐患是很大的。
从数据持久化的角度来说,数据库当然要比缓存做的好,我们也应当以数据库中的数据为主,所以需要更新数据的时候我们应当首先更新数据库,而不是缓存。
先更新数据库,再更新缓存
这里主要有两个问题,首先是并发的问题:假设线程A(或者机器A,道理是一样的)和线程B需要更新同一个数据,A先于B但时间间隔很短,那么就有可能会出现:
线程A更新了数据库
线程B更新了数据库
线程B更新了缓存
线程A更新了缓存
按理说线程B应该最后更新缓存,但是可能因为网络等原因,导致线程B先于线程A对缓存进行了更新,这就导致缓存中的数据不是最新的。
第二个问题是,我们不确定要更新的这个缓存项是否会被经常读取,假设每次更新数据库都会导致缓存的更新,有可能数据还没有被读取过就已经再次更新了,这就造成了缓存空间的浪费。另外,缓存中的值可能是经过一系列计算的,而并不是直接跟数据库中的数据对应的,频繁更新缓存会导致大量无效的计算,造成机器性能的浪费。
综上所述,更新缓存这一方案是不可取的,我们应当考虑删除缓存。
先删除缓存,再更新数据库
这个方案的问题也是很明显的,假设现在有两个请求,一个是写请求A,一个是读请求B,那么可能出现如下的执行序列:
请求A删除缓存
请求B读取缓存,发现不存在,从数据库中读取到旧值
请求A将新值写入数据库
请求B将旧值写入缓存
这样就会导致缓存中存的还是旧值,在缓存过期之前都无法读到新值。这个问题在数据库读写分离的情况下会更明显,因为主从同步需要时间,请求B获取到的数据很可能还是旧值,那么写入缓存中的也会是旧值。
先更新数据库,再删除缓存
终于来到我们最常用的方案了,但是最常用并不是说就一定不会有任何问题,我们依然假设有两个请求,请求A是查询请求,请求B是更新请求,那么可能会出现下述情形:
先前缓存刚好失效
请求A查数据库,得到旧值
请求B更新数据库
请求B删除缓存
请求A将旧值写入缓存
上述情况确实有可能出现,但是出现的概率可能不高,因为上述情形成立的条件是在读取数据时,缓存刚好失效,并且此时正好又有一个并发的写请求。考虑到数据库上的写操作一般都会比读操作要慢,(这里指的是在写数据库时,数据库一般都会上锁,而普通的查询语句是不会上锁的。当然,复杂的查询语句除外,但是这种语句的占比不会太高)并且联系常见的数据库读写分离的架构,可以合理认为在现实生活中,读请求的比例要远高于写请求,因此我们可以得出结论。这种情况下缓存中存在脏数据的可能性是不高的。
那如果是读写分离的场景下呢?如果按照如下所述的执行序列,一样会出问题:
请求A更新主库
请求A删除缓存
请求B查询缓存,没有命中,查询从库得到旧值
从库同步完毕
请求B将旧值写入缓存
如果数据库主从同步比较慢的话,同样会出现数据不一致的问题。事实上就是如此,毕竟我们操作的是两个系统,在高并发的场景下,我们很难去保证多个请求之间的执行顺序,或者就算做到了,也可能会在性能上付出极大的代价。那为什么我们还是应当采用先更新数据库,再删除缓存这个策略呢?首先,为什么要删除而不是更新缓存,这个在前面有分析,这里不再赘述。那为什么我们应当先更新数据库呢?因为缓存在数据持久化这方面往往没有数据库做得好,而且数据库中的数据是不存在过期这个概念的,我们应当以数据库中的数据为主,缓存因为有着过期时间这一概念,最终一定会跟数据库保持一致。
那如果我就是想解决上述说的这两个问题,在不要求强一致性的情况下可以怎么做呢?
其他Pattern
Read/Write Through Pattern
我们可以看到,在上面的Cache Aside套路中,我们的应用代码需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository)。所以,应用程序比较啰嗦。而Read/Write Through套路是把更新数据库(Repository)的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的Cache。
Read Through
Read Through 套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或LRU换出),Cache Aside是由调用方负责把数据加载入缓存,而Read Through则用缓存服务自己来加载,从而对应用方是透明的。
Write Through
Write Through 套路和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作)
Write Behind Caching Pattern
Write Behind 又叫 Write Back。一些了解Linux操作系统内核的同学对write back应该非常熟悉,这不就是Linux文件系统的Page Cache的算法吗?是的,你看基础这玩意全都是相通的。所以,基础很重要,我已经不是一次说过基础很重要这事了。
Write Back套路,一句说就是,在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是让数据的I/O操作飞快无比(因为直接操作内存嘛 ),因为异步,write backg还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。
但是,其带来的问题是,数据不是强一致性的,而且可能会丢失(我们知道Unix/Linux非正常关机会导致数据丢失,就是因为这个事)。在软件设计上,我们基本上不可能做出一个没有缺陷的设计,就像算法设计中的时间换空间,空间换时间一个道理,有时候,强一致性和高性能,高可用和高性性是有冲突的。软件设计从来都是取舍Trade-Off。
另外,Write Back实现逻辑比较复杂,因为他需要track有哪数据是被更新了的,需要刷到持久层上。操作系统的write back会在仅当这个cache需要失效的时候,才会被真正持久起来,比如,内存不够了,或是进程退出了等情况,这又叫lazy write。
有没有更好的思路?
其实在讨论最后一个方案时,我们没有考虑操作数据库或者操作缓存可能失败的情况,而这种情况也是客观存在的。那么在这里我们简单讨论下,首先是如果更新数据库失败了,其实没有太大关系,因为此时数据库和缓存中都还是老数据,不存在不一致的问题。假设删除缓存失败了呢?此时确实会存在数据不一致的情况。除了设置缓存过期时间这种兜底方案之外,如果我们希望尽可能保证缓存可以被及时删除,那么我们必须要考虑对删除操作进行重试。
你当然可以直接在代码中对删除操作进行重试,但是要知道如果是网络原因导致的失败,立刻进行重试操作很可能也是失败的,因此在每次重试之间你可能需要等待一段时间,比如几百毫秒甚至是秒级等待。为了不影响主流程的正常运行,你可能会将这个事情交给一个异步线程或者线程池来执行,但是如果机器此时也宕机了,这个删除操作也就丢失了。
那要怎么解决这个问题呢?首先可以考虑引入消息队列,OK我知道写入消息队列一样可能会失败,但是这是建立在缓存跟消息队列都不可用的情况下,应该说这样的概率是不高的。引入消息队列之后,就由消费端负责删除缓存以及重试,可能会慢一些但是可以保证操作不会丢失。
回到上述的两个问题中去,上述的两个问题的核心其实都在于将旧值写入了缓存,那么解决这个问题的办法其实就是要将缓存删除,考虑到网络问题导致的执行失败或执行顺序的问题,这里要进行的删除操作应当是异步延时操作。具体来说应该怎么做呢?就是参考前面说的,引入消息队列,在删除缓存失败的情况下,将删除缓存作为一条消息写入消息队列,然后由消费端进行慢慢的消费和重试。
那如果是读写分离场景呢?我们知道数据库(以Mysql为例)主从之间的数据同步是通过binlog同步来实现的,因此这里可以考虑订阅binlog(可以使用canal之类的中间件实现),提取出要删除的缓存项,然后作为消息写入消息队列,然后再由消费端进行慢慢的消费和重试。在这种情况下,程序可以不去主动删除缓存,但如果你希望缓存中尽快读取到最新的值,也可以考虑将缓存删除,那么就有可能出现又将旧值写入缓存,且缓存被重复删除的情况。但是一般来说这不会是个问题,首先旧值重新写入缓存,情况无非就是又退化到了程序没有主动删除缓存的这一情况,另外,重复删除缓存保证了数据库和缓存之间不会存在长时间的数据不一致。(为什么删除了缓存之后,还是有可能将旧值写入缓存?参见上面先更新数据库,再删除缓存的方案下,读写分离场景下的执行序列)当然我个人的建议是,如果你可以忍受一段时间之内的数据不一致,那就没必要自己再主动去删除缓存了。
要解决上述问题的核心就在于要实现异步延时删除这一策略,因此在这里我们需要引入消息队列。如果数据库采用读写分离架构,则需要考虑订阅binlog,否则一样可能会出现先删除,后同步完毕的情况。
缓存击穿
可能会有同学注意到,如果采用删除缓存的方案,在高并发场景下可能会导致缓存击穿(这个跟缓存穿透还有点区别),也就是大量的请求同时去查询同一个缓存,但是这个缓存又刚好过期或者被删除了,那么所有的请求全部都会打到数据库上,导致严重的性能问题。对于这个问题包括如何解决缓存穿透,后面我可能会考虑单独写文章来阐释一下,这里先简单说下解决思路,其实也就是上锁。
当一个线程需要去访问这个缓存的时候,如果发现缓存为空,则需要先去竞争一个锁,如果成功则进行正常的数据库读取和写入缓存这一操作,然后再释放锁,否则就等待一段时间之后,重新尝试读取缓存,如果还没有数据就继续去竞争锁。这个是单机场景,如果有多台机器同时去访问同一个缓存项该怎么办呢?如果机器数不是很多的话,这种情况一般来说也不会成为一个问题,不过这里有个优化点,就是从数据库读取到数据之后,再对缓存做一次判断,如果缓存中已经存在数据,就不需要再写一遍缓存了。但是如果机器数也很多的话,那么就得考虑上分布式锁了。此方案的问题是显而易见的,加锁尤其是加分布式锁会对系统性能有重大影响,而且分布式锁的实现非常考验开发者的经验和实力,在高并发场景下这一点显得尤为重要,因此我建议各位,不到万不得已的情况下,不要盲目上分布式锁。
怎么做到强一致性?
可能有同学就是要来抬杠,现有的这些方案还是不够完美,如果我就是想要做到强一致性可以怎么做?
上一致性协议当然是可以的,虽然成本也是非常客观的。2PC甚至是3PC本身是存在一定程度的缺陷的,所以如果要采用这个方案,那么在架构设计中要引入很多的容错,回退和兜底措施。那如果是上Paxos和Raft呢?那么你首先至少要看过这两者的相关论文,并且调研清楚目前市面上有哪些开源方案,并做好充分的验证,并且能够做到出了问题自己有能力修复...对了,我还没提到性能问题呢。
那除了一致性协议以外,有没有其他的思路?
我们先回到"先更新数据库,再删除缓存"这个方案本身上来,从字面上来看,这里有两步操作,因此在数据库更新之前,到缓存被删除这段时间之内,读请求读取到的都是脏数据。如果要实现这两者的强一致性,只能是在更新完数据库之前,所有的读请求都必须要被阻塞直到缓存最终被删除为止。如果是读写分离的场景,则要在更新完主库之前就开始阻塞读请求,直到主从同步完毕,且缓存被删除之后才能释放。
这个思路其实就是一种串行化的思路,写请求一定要在读请求之前完成,才能保证最新的数据对所有读请求来说是可见的。说到这里是不是让你想起了什么?比如volatile,内存屏障,ReadWriteLock,或者是数据库的共享锁,排他锁...当前场景可能不同,但是要面对的问题都是相似的。
现在回到问题本身,我们要怎么实现这种阻塞呢?可能有同学已经发现了,我们需要的其实是一种 分布式读写锁。对于写请求来说,在更新数据库之前,必须要先申请写锁,而其他线程或机器在读取数据之前,必须要先申请读锁。读锁是共享的,写锁是排他的,即如果读锁存在,可以继续申请读锁但无法申请写锁,如果写锁存在,则无论是读锁还是写锁都无法申请。只有实现了这种分布式读写锁,才能保证写请求在完成数据库和缓存的操作之前,读请求不会读取到脏数据。
注意,这里用到的分布式读写锁并没有解决缓存击穿的问题,因为从读请求的视角来看,如果发生了更新数据库的情况,读请求要么被阻塞,要么就是缓存为空,需要从数据库读取数据再写入缓存。为了防止因缓存失效或被删除导致大量请求直接打到数据库上导致数据库崩溃,你只能考虑加锁甚至是加分布式锁。
那么说到分布式读写锁,其实现一样有一定的难度。如果确定要使用,我建议使用Curator提供的InterProcessReadWriteLock,或者是Redisson提供的RReadWriteLock。对分布式读写锁的讨论超出了本文的范围,这里就不做过多展开了。
这里我只提出了我个人的想法,其他同学可能还会有自己的方案,但我相信不管是哪一种,为了要实现强一致性,系统的性能是一定要付出代价的,甚至可能会超出你引入缓存所得到的性能提升。
总结
在我看来所谓的架构设计,往往是要在众多的trade-off中选择最适合当前场景的。其实一旦在方案中使用了缓存,那往往也就意味着我们放弃了数据的强一致性,但这也意味着我们的系统在性能上能够得到一些提升。在如何使用缓存这个问题上有很多的讲究,比如过期时间的合理设置,怎么解决或规避缓存穿透,击穿甚至是雪崩的问题。后续有机会的话,我会逐步地阐释清楚这些问题的来龙去脉,以及如何去解决比较合适。
参考文献
https://juejin.im/post/5d7c7a14f265da03f47c4f93
https://coolshell.cn/articles/17416.html