赞
踩
Redis与MySQL的双写一致性如何保证?不管是工作还是面试,这都是老生常谈的问题。近期,我打算在公司做一期《分布式环境下如何保证数据一致性》的培训,所以决定把课题相关的资料好好整理了一下,希望可以成体系的研究一下。
本文系统的介绍了 Redis 与 DB 双写数据一致性解决方案。当然,文章会参照最新的一些文章(毕竟知识点也就这么点东西),但是解决了内容重复、冗余的Bug,并且会重点介绍【多级缓存的一致性问题解决方案】。
你可能需要的文章:
所谓的一致性就是数据保持一致,在分布式系统中,可以理解为多个节点中数据的值是一致的。
CAP理论,指的是在一个分布式系统中, Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可得兼。分布式系统要么满足CA,要么CP,要么AP,无法同时满足CAP。
因为使用缓存提升性能,就是会有数据更新的延迟,如果需要数据库和缓存数据保持强一致,就不适合使用缓存。但是,通过一些方案优化处理,是可以保证弱一致性,最终一致性的。
所以,我们在设计时结合业务仔细思考是否适合用缓存,然后缓存一定要设置过期时间,这个时间太短、或者太长都不好:
重要:缓存是通过牺牲强一致性来提高性能的,这是由CAP理论决定的,缓存系统适用的场景就是非强一致性的场景,它属于 CAP 中的 AP。
3种常用方案可以保证缓存与数据库数据的一致性:
方案:先删除缓存 → 再更新数据库 → 休眠一会(比如1秒),再次删除缓存 → 待后续请求落到DB后,将查询数据更新到缓存,去报后续请求一直落到缓存上。
第二次删除的目的,是确保读请求结束,写请求可以删除读请求可能带来的缓存脏数据,因为删除缓存和更新DB之间会有时间差。
这个休眠一会,一般多久呢?是1秒吗?
休眠时间 ≈ 读业务逻辑数据的耗时 + 几百毫秒。
考虑一个问题:如果延时双删策略的第二步删除缓存失败了呢?
结论:删除失败,会导致可能出现脏数据。
针对性的解决办法:失败那就多删除几次,保证删除缓存一定会成功,所以,就引入了重试机制。
重试机制方案:写请求更新数据库 → 如果缓存因为某些原因删除失败 → 把删除失败的key放到消息队列 → 消费消息队列的消息,获取要删除的key → 重试删除缓存操作。
重试的方案还有很多,除了消息中间件,如:RocketMQ,还可以使用重试机制,如:spring-Retry,Guava-Retry...
问题:重试机制删除缓存还可以,但是它的缺点也很明显。
解决方案:跳出业务代码的圈子,其实我们还可以通过数据库的 binlog 日志,异步淘汰失败的key。
以 Mysql 为例:可以使用阿里的 canal 将 binlog 日志采集发送到 RocketMQ 队列里面,然后编写一个简单的缓存删除消息者订阅 binlog 日志,根据更新 log 删除缓存,并且通过 ACK 机制确认处理这条更新 log,保证数据与缓存的一致性。
问题 - 1:异步删除策略,非常依赖 MQ 中间件的稳定性,而 MQ 有一个典型的问题,就是“消费丢失和重复消费”,如何避免?
实际上,知识都是成体系的,MQ 的问题就用 MQ 的手段解决就好了,这里不展开说了,越说越分散没个头。以 RocketMQ 举例:
- consumer.registerMessageListener(new MessageListenerConcurrently() {
- @Override
- public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
- System.out.println(Thread.currentThread().getName() + " Receive New Messages: " + msgs);
- delcache(key); //执行真正删除
- return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; //返回消费成功
- }
- });
业务实现消费回调的时候:
- 当且仅当此回调函数返回 ConsumeConcurrentlyStatus.CONSUME_SUCCESS,RocketMQ 才会认为这批消息(默认是1条)是消费完成的;
- 返回 ConsumeConcurrentlyStatus.RECONSUME_LATER,RocketMQ 会认为是失败了。
其实,除了 RocketMQ 方式的发布和订阅,使用 Redis 的 pub/sub订阅也可以实现异步删除的功能。
Redis通过 publish 和 subscribe 命令实现订阅和发布的功能。订阅者可以通过 subscribe 向redis server 订阅自己感兴趣的消息类型。redis 将信息类型称为通道(channel)。当发布者通过 publish 命令向 redis server 发送特定类型的信息时,订阅该消息类型的全部订阅者都会收到此消息。
问题 - 2:如果数据库不是单库,而是集群部署的主从数据库呢?
因为主从 DB 同步存在延时时间。如果删除缓存之后,数据同步到备库之前已经有请求过来时, 「会从备库中读到脏数据」,如何解决呢?解决方案如下流程图:
针对上面的情况,我们可以在从库读取 binglog 日志,然后进行分发和消费的操作,用昨晚删除的方式避免脏数据的出现。
以上每一种方案都有自己的适用场景,在分布式系统中,缓存和数据库同时存在,如果有写操作的时候,「先操作数据库,再操作缓存」的双删策略,如下:
了解到了我们为什么要使用缓存,以及缓存能解决我们什么样的问题,紧接着又有新的问题产生了:Redis 服务可以减轻 DB 的压力,那如果大量的请求全部落在 Redis 服务上,Redsi 的压力如何解决?单纯的整合Redis缓存,那么可能出现如下的问题:
为了解决以上可能出现的问题,让缓存层更稳定,健壮,现在多采用【多级缓存】方案。多级缓存就是充分利用请求处理的每个环节,分别添加缓存,减轻 Tomcat 压力,提升服务性能。
下面选取2种简单的缓存架构,简单讲解一下原理:
二级缓存架构分级:
数据库 | 本地缓存 | 分布式缓存 | |
---|---|---|---|
存储位置 | 存盘,数据不丢失 | 不存盘,之前的数据丢失 | 不存盘,数据丢失 |
持久化 | 可以 | 不可以 | 可以 |
访问速度 | 慢 | 最快 | 快 |
可扩展 | 可存在其他机器的硬盘 | 只能存在本机内存 | 可存在其他机器的内存 |
使用场景 | 需要实现持久化保存 | 需要快速访问,但需要考虑内存大小 | 1)需要快速访问,不需要考虑内存大小 2)需要实现持久化,但会丢失一些数据 3)需要让缓存集中在一起,访问任一机器上内存中的数据都可以从缓存中得到 |
问题 - 1:为什么要将本地缓存与集中式缓存的结合使用?
假设一个项目场景:有这么一个网站,某个页面每天的访问量是 1000万,每个页面从缓存读取的数据是 50K。缓存数据存放在一个 Redis 服务,机器使用千兆网卡。那么这个 Redis 一天要承受 500G 的数据流。而网站一般都会有高峰期和低峰期,两个时间流量的差异可能是百倍以上。假设高峰期每秒要承受的流量比平均值高 50 倍,也就是说高峰期 Redis 服务每秒要传输超过 250 兆的数据。请注意这个 250 兆的单位是 byte,而千兆网卡的单位是“bit” ,你懂了吗? 这已经远远超过 Redis 服务的网卡带宽。
面对如此场景,一般我们会怎么做?
相对而言,第二种方法是更合理的,技术上也相对成熟。为了提升性能,我们准备了5 台 Redis 服务来支撑业务量。
看似没毛病,实际上问题也不小:本身缓存的数据量并不大,1000 万高频次的缓存读写 Redis 也能轻松应付,可是因为带宽的问题就不得不付出 5 倍的成本?你不考虑成本但是有人会把成本放在第一位啊!!
所以,我们还有第三种方案:根据二八原则,我们把20%的热点数据,放在本地缓存,那么 Redis 上至少降低 80%的带宽流量,如此一来,甚至于一个很小的 Redis 集群可以轻松应付。这就是为什么要进程内和进程外缓存结合使用的答案。
问题 - 2:为什么要引入本地缓存?
相对于IO操作,Ehcache速度快,效率高;相对于Redis,Ehcache在进程内,比 Redis 的服务器距离应用服务器更近。所以:DB + Redis + LocalCache = 高效存储,高效访问。
问题 - 3:数据一致性问题?
好了,我们该步入正题了,二级缓存的数据一致性如何保证?
由于本地缓存只支持被该应用进程访问,一般无法被其他应用进程访问,如果对应的数据库数据存在数据更新,则需要同步更新不同节点的本地缓存副本,来保证数据一致性。本地缓存的更新,复杂度较高并且容易出错,如:基于 Redis 的发布订阅机制、或者消息队列MQ来同步更新各个部署节点。
延续上面说到的【 biglog 异步删除缓存】策略,可以通过biglog同步,来保障二级缓存的数据一致性,具体的架构如下:
RocketMQ 是支持广播模式的:
对于更新Redis来说,一个实例消费消息,完成 Redis 的更新;对于更新 Guava 或者其他1级缓存来说,每一个实例都需要消费消息,更新自己的存储。
@RocketMQMessageListener(topic = "seckillgood", consumerGroup = "UpdateGuava", messageModel = MessageModel.BROADCASTING)
问题 - 4:二级缓存缓存击穿解决方案
相比于二级缓存架构,三级缓存增加了接入层,如:Nginx。 三级缓存架构分级:
对于高并发的请求,接入层 Nginx 有着巨大的作用,能反向代理,负载均衡,动静分离以及和 Lua 整合,可以实现请求定向分发等非常有用的功能,同理 Nginx 层也可以实现缓存的功能。
利用接入层 Nginx 的进程内缓存,缓存极热数据的高并发访问。在接入层,当请求过来时,判断本地缓存中是否存在,如果存在着直接返回请求结果(或者展现静态资源的数据),这样的请求不会直接发送到后端服务层,减少了跨网络传输,大大缩短访问路径。
问题 - 1:如何使用 Nginx 作为L3本地缓存?
我这里要介绍的,是使用 Nginx Lua 共享字典的方式实现三级缓存。
ngx.shared.<name>
的共享存储。- syntax:lua_shared_dict <name> <size>
- default: no
- context: http
- phase: depends on usage
- http {
- # 指定缓存信息
- lua_shared_dict seckill_cache 128m;
- ...
- }
- -优先从缓存获取,否则访问上游接口
- local seckill_cache = ngx.shared.seckill_cache
- local goodIdCacheKey = "goodId_" .. goodId
- local goodCache = seckill_cache:get(goodIdCacheKey)
-
- if goodCache == "" or goodCache == nil then
-
- ngx.log(ngx.DEBUG,"cache not hited " .. goodId)
-
- -- 回源上游接口,比如Java 后端rest接口
- local res = ngx.location.capture("/stock-provider/api/seckill/good/detail/v1", {
- method = ngx.HTTP_POST,
- -- args = requestBody , -- 重要:将请求参数,原样向上游传递
- always_forward_body = false, -- 也可以设置为false 仅转发put和post请求方式中的body.
- })
-
- -- 返回上游接口的响应体 body
- goodCache = res.body;
-
- -- 单位为s
- seckill_cache:set(goodIdCacheKey, goodCache, 10 * 60 * 60)
-
- end
- ngx.say(goodCache);

问题 - 2:Lua 共享内存的淘汰机制?
ngx.shared.DICT 的实现是采用红黑树实现,当申请的缓存被占用完后如果有新数据需要存储则采用 LRU 算法淘汰掉“多余”的数据。
LRU算法:当数据在最近一段时间经常被访问,那么它在以后也会经常被访问,对于此类经常访问的数据,我们需要其能够快速命中,而不常访问的数据,我们在容量超出限制时,会将其淘汰。
问题 - 3:数据一致性问题?
3级缓存主要用于极热数据,如秒杀的商品数据(对于秒杀这样的场景,瞬间有十几万甚至上百万的请求要同时读取商品。如果没有命中本地缓存,可能导致缓存击穿。
为了防止缓存击穿,同时也保持数据一致性,具体的方案为:
使用缓存提升性能就会有数据更新的延迟,就无法使数据库和缓存数据保持强一致,所以上树的各种优化方案,都是以保证弱一致性,最终一致性为前提的。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。