1. 如何保证数据库和缓存双写一致性?
在高并发情况下,如果有大量的请求直接访问到数据库,由于数据库是将数据存储到磁盘当中的,每次访问时需要将数据以页的形式读取到内存当中,并且建立数据库连接、查询数据库中的数据和释放连接都需要额外的操作,因此当并发请求量过大时,请求直接打到DB势必会把DB打挂,于是乎我们开始使用Redis来作为缓存,将访问量大的热点Key存储在缓存中,请求可直接访问缓存得到对应的数据,避免了请求数据库的操作。同时Redis是基于内存的,并且Redis的键值对读写操作是单线程的,避免了线程之间切换的消耗和CPU的上下文切换,执行效率远高于MySQL。
但将MySQL中的热点数据存储到Redis中,相当于是将数据进行了二次拷贝,当修改某一方的数据时,若不即使同时更新数据,势必会造成数据不一致或脏读等问题,因此如何保证数据库和缓存双写一致性十分重要,尤其是在访问量较大的高并发环境下。
通常情况下,我们使用缓存的业务流程如下:
- 用户发起请求后,先查询缓存中是否有对应的数据,如果有则之间返回。
- 如果缓存没有数据,则继续在数据库中进行查询。
- 如果数据库有数据,则将查询出来的数据,更新到缓存中,然后返回该数据。
- 如果数据库中没有该数据,则直接返回空。
这个流程也是缓存的常见用法,初看可能觉得没有问题,但是有个问题:如果数据库中的某条数据,放入缓存之后,立马被更新了,那么该如何更新缓存呢?如果不更新缓存就会造成很长一段时间内(取决于缓存的过期时间),用户从缓存中请求到的数据都是脏数据,存在数据不一致的问题。
那么,该如何更新缓存呢?常见方案有四种:
- 先写缓存,后写数据库
- 先写数据库,后写缓存
- 先删缓存,后写数据库(推荐)
- 先写数据库,后删缓存(推荐)
1.1. 先写缓存,后写数据库
该方案最容易被想到,但也问题最大,最不推荐。
设想如下场景:当用户写完缓存,更改了缓存中的数据,准备写数据库时,网络发生了异常,导致写数据库失败,由于Redis中的事务不支持回滚,因此无法将缓存中已更改的数据状态进行撤销。其结果是缓存更新为了最新的数据,但数据库中仍存的是旧数据,导致用户从缓存中取到的数据都是数据库中不存在的”假数据“。因此先写缓存,后写数据库这个方案不可取,实际运用的也很少。
1.2. 先写数据库,后写缓存
如果把写数据库和写缓存操作放在同一个事务中,当写缓存失败时,由于数据库支持事务回滚,因此不存在因网络异常等问题导致数据库中更新了最新的数据,缓存中数据未更新的情况,因为如果发生异常,数据库中以修改的数据状态会进行回滚。但在高并发的业务场景中,写数据库和写缓存都是远程操作,并且分步骤进行,为了防止出现大事务,造成死锁,通过从不建议将写数据库和写缓存放在同一个事务当中,也就是说还是会出现:数据库是新数据,而缓存是旧数据,两边数据不一致的情况。
该方案在并发量较小的情况下可以使用,但在高并发场景下,先写数据库,后写缓存会带来其他的问题。
如果有两个写请求:a和b,它们同时请求到业务系统中,如下图所示:
- 请求a先到达,写完数据库后,在写缓存时发生了网络卡顿,未能及时写缓存。
- 此时请求b到达,先写了数据库。
- 然后请求b不存在网络问题,顺利写了缓存。
- 此时,请求a卡顿结束,也写了缓存。
很显然,在上述过程中,请求b更新的数据为应为最新的数据,但其在缓存中的新数据被请求a的旧数据覆盖了,导致数据库中存储的时请求b更新的新值,缓存中存储的是请求a的旧值。
在高并发场景中,如果多个线程同时执行先写数据库,再写缓存的操作,可能会出现数据库是新值,而缓存中是旧值,两边数据不一致的情况。
1.3. 先删缓存,后写数据库
假如在高并发场景下,对用一个用户的同一条数据,有一个读数据请求c和写数据请求d同时到达,如下图所示:
- 请求d先到达,删除了缓存中的数据,但由于网络原因,未及时将新的数据写入数据库中。
- 此时请求c到达,查询缓存发现没有数据,去数据库中查询除了旧的数据,然后更新到了缓存中。
- 此时请求d卡顿结束,将新值写入数据库中。
很容易看出,由于请求d在删除缓存中的数据后,未能及时更新数据库中的数据,导致读请求c查询到了数据库中的旧数据然后更新到缓存中,导致了数据库中的数据和缓存中的数据不一致的问题。
更正:图中步骤7写入旧值,步骤9要删掉。
为解决上述问题,需要在写请求更新数据库后,再次将缓存中的数据删一遍,防止旧数据存储在缓存中。这个方案也就是 延迟双删 。
该方案有个关键的地方:第二次删除缓存,并非在更新数据库后立即删除,而是在 延迟
一定时间间隔后。确保在其他请求将从数据库中读取到的旧数据写入缓存后,再删缓存,才能把旧值及时删除。
1.4. 先写数据库,后删缓存
假如在高并发场景下,有一个写请求e和读请求f同时到达,如下图所示:
- 请求e先到达,将新的数据写入数据库中时,缓存恰好过期时间到了,缓存失效,并且此时数据库更新操作未完成。
- 此时请求f到达,在缓存中未找到数据,查询数据库,取出旧的数据。
- 请求f准备将从数据库中取出的旧数据写入缓存时,发生了网络卡顿。
- 请求e顺利完成数据库更新操作,然后删除对应的缓存。
- 请求f卡顿结束,将旧数据写入到缓存中。
由此可以看出先写数据库,后删缓存仍然会导致数据不一致,但该情况很少发生,需要同时满足以下条件:
- 缓存刚好失效
- 请求f从数据库中查询旧数据,更新缓存的耗时,比请求e写数据库,并且删除缓存的耗时更长。
由于查询数据库的耗时一般比写数据库的耗时更快,更何况写完数据库,还要删除缓存,因此绝大多数情况下,写数据请求比读数据请求更耗时,以上案例中因写数据耗时小于读数据耗时而导致数据库和缓存中数据不一致的情况很少发生。
因此更推荐在高并发对数据一致性要去高的场景下,使用先写数据库,后删缓存的方案,其造成数据不一致的情况概率很小。
1.5. 总结
总的来说,前两种方案:先写缓存,后写数据库和先写数据库,后写缓存因为无法保证写数据库和写缓存这两步能够顺利执行,并且执行顺序会受到其他请求的影响,不推荐使用。后两种方案:先删缓存,后写数据库和先写数据库,后删缓存能够极大程度减少由于其他请求的插入或者网络卡顿导致的数据不一致性问题,但在高并发的场景下仍有可能发生,相比之下先写数据库,后删缓存的这个方案最优,发生数据不一致的概率最小。