1.概述
在现代分布式业务系统中,MySQL 和 Redis 通常被组合使用,以利用各自的优点。MySQL 是一种关系型数据库,提供强一致性和持久化存储,而 Redis 是一种内存数据存储,具有快速读取和写入的特点。然而,在使用 MySQL 和 Redis 的同时,确保它们之间的数据一致性是一个重要且具有挑战性的问题。本文将总结一些常见的数据同步一致性问题,并探讨解决这些问题的方法。
当面试官问:你们系统是怎么实现MySQL和Redis双写的?没有做过高并发的小伙伴听到这个问题都懵了~~~因为一般大家对MySQL和Redis配合使用的认知如下:
读取数据
读取数据流程大概是这样的:先查询缓存,获取到数据直接返回,查询不到,再从数据库DB查询数据,拿到数据之后更新缓存,再返回数据。流程图如下所示:
平时开发过程中读取数据基本上也都是按照这个流程操作的,因该不会存在异议吧。
写入数据
对MySQL和Redis进行数据双写同步操作流程就没么固定了,存在以下情况:
- 先更新数据库DB还是先更新缓存?
- 是更新缓存还是删除缓存?如果是删除缓存,那么是先更新数据库DB还是先删除缓存呢?
综合来看写入数据就存在了4种情况,如下图所示:
我们在平时开发过程中一般都会选择上面其中一种方式进行数据库与缓存的双写操作,普通的业务系统、后台管理这些系统选择什么方式怎么操作都是可以的,因为并发量不大,几乎不会存在数据库和缓存数据不一致的情况,即使存在不一致,我们通过设置一个有效的缓存过期时间,从而保证不一致只是短暂的,在某些系统业务场景下是完全可以容忍的。
所以接下来下面的讨论都是基于在并发量比较大且不能接受数据不一致情况下进行总结的
在分布式系统中保证数据一致性就是一个老难的问题,自古难以做到两全,即保证强一致性,就是要求写入什么,看到的就是什么,这就要求加锁,或者通过分布式事务机制(如两阶段提交协议)来确保 MySQL 和 Redis 更新操作的一致性,但是这些方式都性能开销大,实施复杂,对系统的响应时间有较大影响。所以现在大部分的分布式系统都会选择最终一致性方案,即系统保证在一段时间内使得数据一致。前面所说的设置合理缓存过期时间就是这种方案的体现所在。
2.高并发下数据库与缓存双写同步存在的问题
基于上面的概述可以知道读取数据的流程几乎是固定的,但是对于数据库与缓存双写就存在4种方式可供选择,下面我们就来看看这4种方式在高并发场景下进行数据双写同步怎么就有问题了:
2.1 先写 MySQL,再写 Redis
请求 A、B 都是先写 MySQL,然后再写 Redis,在高并发 情况下,如果请求 A 在写 Redis 时卡了一会(网络抖动或服务阻塞),请求 B 已经依次完成数据的更新,就会出现图中的问题。并发场景下,这样的情况是很容易出现的,每个线程的操作先后顺序不同,这样就导致请求B的缓存值被请求A给覆盖了,数据库中是请求B的新值,缓存中是请求A的旧值,并且会一直这么脏下去直到缓存失效(如果你设置了过期时间的话)。
2.2 先写Redis,再写MySQL
和上面的情况差不多,这里就只是调换数据库和缓存的写入顺序:先写Redis,再写MySQL,缓存存的是请求B的值,数据库存的是请求A的值,也是不一致了。
出现数据不一致的原因是因为写数据库和写缓存不是原子操作(原子不可再分割),即要么一起成功,要么一起失败。写数据库和写缓存是两个操作,分别操作不同的组件,在高并发场景下我们是没办法控制这些操作的执行顺序的,也就会出现上面叙述的数据不一致情况了。
既然写缓存都会存在数据不一致问题,索性就不要同步写缓存,而是删除缓存,让下一次读取数据按照上面所说的流程命中不了缓存查询数据库获取数据,写入缓存。下面两种方式就是基于删除缓存操作的。
2.3 先删除Redis,再更新MySQL
删除缓存是为了下一次读取数据命中不了缓存通过查询数据库重建缓存,这就变成删除缓存和读取数据的先后顺序的事情了:这里我们假设请求A是写数据,请求B是读取数据,如下图所示:
这个情况缓存存入的是旧值10,但是数据库已经写入了新值20,数据不一致了。
2.4 先更新MySQL,再删除缓存
这个方式就是我们使用缓存最常见最推荐的策略方式,Cache Aside 策略(也叫旁路缓存策略)。它的提出是为了尽可能地解决缓存与数据库的数据不一致问题。注意是尽可能,说明还是会出现极端情况下数据不一致的情况:
不过出现这种情况的概率并不高,因为写入缓存的操作通常比写入数据库快很多,很难出现上面这种,你写入一个缓存时长竟然大于操作数据库和删除缓存的时长,因为操作数据库一般都比操作缓存慢很多,即使是缓存服务的问题,那么写入缓存的也会早于删除缓存操作完成的。除非是你查询数据库和写入缓存
所以如果数据一致性要求比较高的业务功能,使用这种策略去实现即可,也比较推荐。
2.5 延时双删
流程大概如下,就不画图了,因为确定不了延时多久没办法准确画图反应
- 先删除缓存
- 更新数据库
- 延时:睡眠一段时间
- 再次删除缓存
延时主要是为了尽量保证读取数据的请求A的操作在写入数据请求B之前完成,这样再删除缓存,让下一次读取数据请求重建索引即可。但是这个具体睡眠多久时间不可控,也不能设置一个比较大的时间来保证读取请求早于写入请求完成,这严重影响了写入接口的性能,得不偿失,所以这种方案不太推荐,了解下即可。
项目推荐:基于SpringBoot2.x、SpringCloud和SpringCloudAlibaba企业级系统架构底层框架封装,解决业务开发时常见的非功能性需求,防止重复造轮子,方便业务快速开发和企业技术栈框架统一管理。引入组件化的思想实现高内聚低耦合并且高度可配置化,做到可插拔。严格控制包依赖和统一版本管理,做到最少化依赖。注重代码规范和注释,非常适合个人学习和企业使用
Github地址 :github.com/plasticene/...
Gitee地址 :gitee.com/plasticene3...
微信公众号 :Shepherd进阶笔记
交流探讨qun:Shepherd_126
3.数据一致性解决方案
���以这么说,数据库与缓存强一致性是做不到的,因为这涉及两个组件,典型的分布式系统中两个非原子操作,没办法一定保证要么都成功,要么都失败。只能保证最终一致性的同时尽量缩短延迟。
当然如果你采用是更新数据库的同时更新缓存来保证缓存命中率,为了解决并发问题,在更新缓存前先加一个分布式锁,因为这样在同一时间只允许一个线程更新缓存,就不会产生并发问题了。当然这么做对于写入的性能会有影响。不过给缓存加一个较短的过期时间,这样即使出现缓存不一致的情况,缓存的数据也会很快地过期,对业务的影响也是可以接受。
接下来我们说说两种保证最终一致性的同步方案。
3.1 消息队列
当下大部分的系统都有使用消息队列MQ(kakfa、rocketMQ、rabbitMQ...),并且我们一般双写缓存的数据都是业务系统的核心数据,因为高频使用才通过缓存来提高系统的嘛,比如订单系统的订单数据就是很好的一个例子,一般都会有关于订单变更的MQ消息topic供消费者订阅处理对应逻辑,所以我们就可以很方便的增加一个消费者订阅,该消费者完成同步写入缓存的逻辑。
唯一需要担心的一个问题是,如果丢消息了怎么办?因为现在消息是缓存数据的唯一来源,一旦出现丢消息,缓存里缺失的那条数据永远不会被补上。所以,必须保证整个消息链条的可靠性,不过好在现在的 MQ 集群,比如像 Kafka 或者 RocketMQ,它都有高可用和高可靠的保证机制,只要你正确配置好,是可以满足数据可靠性要求的。
关于kafka入门的请看之前总结的:kafka入门实战教程看这篇就够了
关于RabbitMQ的使用教程请看:消息队列RabbitMQ:基础篇
3.2 使用canal订阅binlog实时同步缓存
canal [kə'næl],译意为水道/管道/沟渠,主要用途是基于 MySQL 数据库增量日志解析,提供增量数据订阅和消费
Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。
详细教程请看之前总结的:超详细的canal使用教程及如何通过Spring Boot整合canal优雅实现缓存一致性解决方案
4.总结
MySQL 和 Redis 的数据一致性问题是分布式系统中的一个重要挑战。通过合理的设计和实现,可以有效地解决这些问题,保障系统的数据一致性。在实际应用中,需要根据具体场景选择适当的方法,并结合多种技术手段,以实现最佳的一致性保障。当然在并发量不大的业务系统中其实不需要这个数据一致性问题,采用上面的Cache Aside 旁路缓存策略,再设置一个合理的缓存过期时间即可。