文章目录
- 一、引言
- 二、场景来源
- 三、高并发解决方案
- 1. 先更新缓存,再更新数据库
- 2. 先更新数据库,再更新缓存
- 3. 先删除缓存,再更新数据库
- 4. 先更新数据库,再删除缓存
- 小结
- 四、拓展方案
- 1. 分布式锁与分布式事务
- 2. 消息队列
- 3. 监听binlog
- 五、总结
- 六、参考文章
一、引言
在现代互联网应用中,高并发场景下的数据访问是一个常见的挑战。为了提高数据访问速度,通常会使用 Redis 作为缓存层,但这也会带来数据一致性的难题。在四月份的时候,我参考现有资料编写了一篇数据库和缓存一致性处理方案的文档(原文链接:如何保证数据库、缓存的双写一致?),但总觉得内容有点空洞,介绍不够彻底。
本文将尝试重新介绍一下该问题。
二、场景来源
传统的系统通常基于 MySQL 和 Java 开发,虽然它们在数据持久化和事务处理方面表现出色,但在高并发场景下,单纯依赖数据库已经难以满足快速响应和高吞吐量的需求。而在现代互联网应用中,高性能和高可用性是系统设计的关键目标。
为了应对这一挑战,越来越多的系统开始引入 Redis 作为缓存层,以提升数据访问速度和系统整体性能。然而随着 Redis 的引入,读取数据的流程也随之变化。
如果是只读系统,这个流程也不错。但在高并发读写系统中,这个流程就有待完善啦!
三、高并发解决方案
引入Redis后,任何缓存数据的变更都可能会涉及如下三个操作:更新数据库、更新缓存和删除缓存。如果使用排列组合,可能的解决方案有四种:
- 先更新缓存,再更新数据库
- 先更新数据库,再更新缓存
- 先删除缓存,再更新数据库
- 先更新数据库,再删除缓存
我们逐个分析上述方案。
1. 先更新缓存,再更新数据库
该方案的步骤如下:
如果更新缓存成功后,数据库更新失败,就会出现数据库为旧值,缓存为新值的情况。后续的所有的读请求,在缓存未过期或缓存未重新正确更新的情况下,会一直保持脏数据(数据库中的值为旧值,而Redis缓存为新值),业务应该以数据库数据为准。
如果更新缓存成功,数据库更新失败,我们重新更新缓存呢?
抛开重新更新缓存时,要单表或多表重新查询数据,再更新数据带来的潜在性能问题,我们直接使用旧值更新,还可能更新失败,也有其他请求更新数据再次陷入脏数据的情况。
只要缓存进行了更新,后续的读请求在更新数据库前、更新数据库失败并重新更新缓存成功前,如果命中缓存,返回的数据都是未落库的脏数据。
结论:该方案不考虑。
2. 先更新数据库,再更新缓存
该方案的步骤如下:
如果数据库更新成功,缓存更新失败,会出现数据库为最新值,缓存为旧值的情况。后续的所有的读请求,在缓存未过期或缓存未重新正确更新的情况下,会一直保持数据不一致!
就算上述更新数据库、更新缓存的操作都成功,还是存在并发引发的一致性问题:
如上图,可以看到经过两次更新后,数据库n更新为3,而缓存n更新为2。在并发读写的场景下,数据存在不一致性问题。
结论:该方案不考虑。
3. 先删除缓存,再更新数据库
该方案的步骤如下:
这是一种很常见的方法。它逻辑较为简单,也易于理解和实现,理论上删除旧缓存后,下次读取时将从数据库获取最新数据。
但在高并发的极端情况下,删除缓存成功后,如果再有大量的并发请求进来,那么请求便会直接到达数据库,对数据库造成巨大的压力。即便使用了一些并发访问策略保障了只有一个请求到达数据库,那也相当于上述第一步的删除的数据又重新加载到Redis中,而且此方案还可能会发生数据不一致性问题。
通过上图发现删除缓存后,如果有并发读请求进来,那么查询缓存肯定是不存在,则去读取数据库。此时更新数据库n=2
的操作还未完成,所以读取到的仍然是旧值n=1
。设置缓存n=1
后,更新数据库n=2
完成。此时数据库n是新值2,而缓存是旧值1,出现了数据不一致的问题。
对此,我们采用延时双删策略优化。即在更新数据库之后,先延迟等待一会儿(等待时间参考该读请求的响应时间+几十毫秒),再继续删除缓存。这样做的目的是确保读请求结束(它已经在数据库中读取到了旧数据,后续会在该请求中更新缓存),写请求可以删除读请求造成的缓存脏数据,保证再删除缓存之后的所有读请求都能读到最新值。
可以看出此优化的关键在于再次删除前需要等待多长时间。这个时间一般都是根据历史查询请求的响应时间判断的,但实际情况会有浮动。这也导致如果等待的时间过短,则仍然会出现数据不一致的情况;等待时间过长,则等待期间出现数据不一致的时间变长。
另外延时双删策略还需要考虑如果再次删除缓存失败的情况如何处理?
如果再次删除失败将导致后续的所有的读请求,在缓存未过期或缓存未重新正确更新的情况下,会一直保持了数据的完全不一致!
这个在下文讨论。
结论:该方案不考虑。
4. 先更新数据库,再删除缓存
该方案的步骤如下:
对比以上方案,在大多数情况下,这种方案被认为是一个更好的选择。原因如下:
- 数据的一致性:这种方法更倾向于保持数据的最终一致性,即使缓存删除失败,也能保证数据的一致性不会长期受损。
- 用户体验:在方案3并发读写都成功的情况下,还是会出现数据不一致的情况,用户可能会一直看到旧数据,直到缓存过期。相比之下,该方案可以在某种程度上避免这种情况。
但该方案同样也会出现数据不一致性问题,如下图:
当数据库被更新后,缓存也被删除。接下来的出现读请求3.1和写请求3.2同时进来。
读请求先执行,读取缓存发现未命中后查询数据库并获取数值2,在准备更新缓存n=2
时,写请求执行并完成了更新数据库和删除缓存,然后读请求才更新缓存n=2
。此时,数据库为新值3,缓存为旧值2。
其实延迟双删策略,算是融合“先删除缓存,再更新数据库”和“先更新数据库,再删除缓存”的策略,可以解决大部分的数据一致性的业务逻辑处理问题。如果再次删除缓存失败,也可以通过重试机制进行一定程度的补救。
结论:推荐使用该方案。
小结
从上面的四种方案看,似乎没有一种方案真正能解决并发场景下MySQL数据与Redis缓存数据一致性的问题。如果业务要求必须要满足强一致性,那么不管如何优化缓存策略,似乎都无法满足,那最好的办法是不用缓存。
强一致性:它要求系统写入什么,读出来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大。
解决方案是读写串行化,而此方案会大大增加系统的处理效率,吞吐量也会大大降低。
另外在大型分布式系统中,其实分布式事务大多数情况都不会使用,因为维护成本太高了、复杂度也高。所以在分布式系统,我们一般都会推崇最终一致性,即这种一致性级别约束了系统在写入成功后,不承诺立即可以读到写入的值,也不承诺多久之后数据能够达到一致,但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到一致状态。
四、拓展方案
这种双写的场景,其实还有另外三种方案,虽然应用场景并不多,但也确实提供了不同的思路,可以参考下。
1. 分布式锁与分布式事务
使用分布式事务可以确保两个操作的原子性。步骤如下:
- 开启事务。
- 更新数据库。
- 更新 Redis 缓存。
- 提交事务。
读写操作流程如下:
写操作 | 读操作 |
---|---|
这种方式确保了数据库和缓存的一致性,适用于对数据一致性要求较高的场景。但它的实现比较复杂,增加了系统的复杂性。而且这种方式也会产生额外的性能开销。
2. 消息队列
使用消息队列可以异步处理数据更新操作,确保数据库和缓存的一致性。通过消息队列可以解耦数据更新的逻辑,提高系统的可扩展性和可靠性。
步骤如下:
- 更新数据库。
- 发送更新数据的消息到消息队列。
- 消费者从消息队列中读取更新数据的消息,删除 Redis 缓存。
3. 监听binlog
canal是阿里巴巴 MySQL binlog 增量订阅&消费组件。它基于 MySQL 数据库增量日志解析,提供增量数据订阅和消费。通过监听binlog,也可以实现双写一致性,步骤如下:
- 更新数据库
- 通过canal采集binlog日志,订阅更新信息并发送到消息队列
- 通过ACK手动机制确认处理这条更新消息,删除Redis缓存数据
尽管该方案看起来也不错了,但是因为引入额外的组件(如Canal、消息队列)复杂性增加了也不少,需要维护和监控这些组件的运行状态,保证组件运行正常。
五、总结
在高并发场景下,确保 MySQL 和 Redis 之间的数据一致性是分布式系统设计中的一个重要挑战。本文介绍了多种解决方案,每种方案都有其适用场景和优缺点。其实并没有一个最优解,更多的是需要综合考虑系统的具体需求、可用资源、性能要求、业务复杂性、维护成本等因素,最后确定出来的方案才是最适合的。
希望看到这里的你有所收获。
六、参考文章
- 如何下保证MySQL数据库与Redis缓存数据一致性?