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