我是靠谱客的博主 悦耳画板,最近开发中收集的这篇文章主要介绍Redis 与 DB 的数据一致 / 双写一致性问题前言正文总结,觉得挺不错的,现在分享给大家,希望可以做个参考。

概述

前言

        Redis与MySQL的双写一致性如何保证?不管是工作还是面试,这都是老生常谈的问题。近期,我打算在公司做一期《分布式环境下如何保证数据一致性》的培训,所以决定把课题相关的资料好好整理了一下,希望可以成体系的研究一下。

        本文系统的介绍了 Redis 与 DB 双写数据一致性解决方案。当然,文章会参照最新的一些文章(毕竟知识点也就这么点东西),但是解决了内容重复、冗余的Bug,并且会重点介绍【多级缓存的一致性问题解决方案】。

你可能需要的文章:

  • 高性能系统架构设计之:多级缓存
  • Redis为什么快?基于内存就完事了?不要无视Redis如此完美的数据结构

正文

一、CAP理论

         所谓的一致性就是数据保持一致,在分布式系统中,可以理解为多个节点中数据的值是一致的。

  • 强一致性:这种一致性级别是最符合用户直觉的,它要求系统写入什么,读出来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大;
  • 弱一致性:这种一致性级别约束了系统在写入成功后,不承诺立即可以读到写入的值,也不承诺多久之后数据能够达到一致,但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到一致状态;
  • 最终一致性:最终一致性是弱一致性的一个特例,系统会保证在一定时间内,能够达到一个数据一致的状态。这里之所以将最终一致性单独提出来,是因为它是弱一致性中非常推崇的一种一致性模型,也是业界在大型分布式系统的数据一致性上比较推崇的模型。

        CAP理论,指的是在一个分布式系统中, Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可得兼。分布式系统要么满足CA,要么CP,要么AP,无法同时满足CAP。

        因为使用缓存提升性能,就是会有数据更新的延迟,如果需要数据库和缓存数据保持强一致,就不适合使用缓存。但是,通过一些方案优化处理,是可以保证弱一致性,最终一致性的。

        所以,我们在设计时结合业务仔细思考是否适合用缓存,然后缓存一定要设置过期时间,这个时间太短、或者太长都不好:

  • 太短的话:请求可能会比较多的落到数据库上,这也意味着失去了缓存的优势;
  • 太长的话:缓存中的脏数据会使系统长时间处于一个延迟的状态,而且系统中长时间没有人访问的数据一直存在内存中不过期,浪费内存。

重要:缓存是通过牺牲强一致性来提高性能的,这是由CAP理论决定的,缓存系统适用的场景就是非强一致性的场景,它属于 CAP 中的 AP。


二、常见方案

3种常用方案可以保证缓存与数据库数据的一致性:

  1. 延时双删;
  2. 重试机制删除缓存;
  3. 读取 biglog 异步删除缓存;

2.1 延时双删

方案:先删除缓存 → 再更新数据库 → 休眠一会(比如1秒),再次删除缓存 → 待后续请求落到DB后,将查询数据更新到缓存,去报后续请求一直落到缓存上。

        第二次删除的目的,是确保读请求结束,写请求可以删除读请求可能带来的缓存脏数据,因为删除缓存和更新DB之间会有时间差。

         这个休眠一会,一般多久呢?是1秒吗?

休眠时间 ≈ 读业务逻辑数据的耗时 + 几百毫秒。

2.2 重试机制删除缓存

考虑一个问题:如果延时双删策略的第二步删除缓存失败了呢?

结论:删除失败,会导致可能出现脏数据。

针对性的解决办法:失败那就多删除几次,保证删除缓存一定会成功,所以,就引入了重试机制

        重试机制方案:写请求更新数据库 → 如果缓存因为某些原因删除失败 → 把删除失败的key放到消息队列 → 消费消息队列的消息,获取要删除的key → 重试删除缓存操作。

        重试的方案还有很多,除了消息中间件,如:RocketMQ,还可以使用重试机制,如:spring-Retry,Guava-Retry...

2.3 biglog 异步删除缓存

问题:重试机制删除缓存还可以,但是它的缺点也很明显。

  • 使用 Retry 机制,会造成大量业务代码的入侵;
  • 如果删除失败源于系统问题,那失败就是必然, Retry 重试就会变成死循环导致系统卡死;

解决方案:跳出业务代码的圈子,其实我们还可以通过数据库的 binlog 日志,异步淘汰失败的key。

在这里插入图片描述

        以 Mysql 为例:可以使用阿里的 canal 将 binlog 日志采集发送到 RocketMQ 队列里面,然后编写一个简单的缓存删除消息者订阅 binlog 日志,根据更新 log 删除缓存,并且通过 ACK 机制确认处理这条更新 log,保证数据与缓存的一致性。

问题 - 1:异步删除策略,非常依赖 MQ 中间件的稳定性,而 MQ 有一个典型的问题,就是“消费丢失和重复消费”,如何避免?

        实际上,知识都是成体系的,MQ 的问题就用 MQ 的手段解决就好了,这里不展开说了,越说越分散没个头。以 RocketMQ 举例:

  • 为了确保一定消费成功:在消费的时候,我们就需要注入一个消费回调,失败了过一会就再重试消费;
  • 为了确保只消费一次:在消费的时候,做一个 setnx 原子性验证,保证不做重复的操作;
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 日志,然后进行分发和消费的操作,用昨晚删除的方式避免脏数据的出现。

        以上每一种方案都有自己的适用场景,在分布式系统中,缓存和数据库同时存在,如果有写操作的时候,「先操作数据库,再操作缓存」的双删策略,如下:

  1. 读取缓存中是否有相关数据;
  2. 如果缓存中有相关数据 value,则返回;
  3. 如果缓存中没有相关数据,则从数据库读取相关数据放入缓存中 key->value,再返回;
  4. 如果有更新数据,则先更新数据库,再删除缓存;
  5. 为了保证第四步删除缓存成功,使用 binlog 异步删除;
  6. 如果是主从数据库,binglog 取自于从库;
  7. 如果是一主多从,每个从库都要采集binlog,然后消费端收到最后一台binlog数据才删除缓存,或者为了简单,收到一次更新log,删除一次缓存。

三、多级缓存方案

        了解到了我们为什么要使用缓存,以及缓存能解决我们什么样的问题,紧接着又有新的问题产生了:Redis 服务可以减轻 DB 的压力,那如果大量的请求全部落在 Redis 服务上,Redsi 的压力如何解决?单纯的整合Redis缓存,那么可能出现如下的问题:

  • 热点数据的大量访问,能对系统造成各种网络开销,影响系统的性能;
  • 一旦集中式缓存发生雪崩了,或者缓存被击穿了,能造成数据库的压力增大,可能会被打死,造成数据库挂机状态,进而造成服务宕机;
  • 缓存雪崩,访问全部打在数据库上,数据库也可能会被打死;
  • ......

        为了解决以上可能出现的问题,让缓存层更稳定,健壮,现在多采用【多级缓存】方案。多级缓存就是充分利用请求处理的每个环节,分别添加缓存,减轻 Tomcat 压力,提升服务性能。

        下面选取2种简单的缓存架构,简单讲解一下原理:

 3.1 二级缓存架构

        二级缓存架构分级:

  • 1级为本地缓存,或者进程内的缓存(如:Ehcache):速度快,进程内可用;
  • 2级为集中式缓存(如:Redis):可同时为多节点提供服务;
数据库本地缓存分布式缓存
存储位置存盘,数据不丢失不存盘,之前的数据丢失不存盘,数据丢失
持久化可以不可以可以
访问速度最快
可扩展可存在其他机器的硬盘只能存在本机内存可存在其他机器的内存
使用场景需要实现持久化保存需要快速访问,但需要考虑内存大小1)需要快速访问,不需要考虑内存大小
2)需要实现持久化,但会丢失一些数据 
3)需要让缓存集中在一起,访问任一机器上内存中的数据都可以从缓存中得到

问题 - 1:为什么要将本地缓存与集中式缓存的结合使用?

        假设一个项目场景:有这么一个网站,某个页面每天的访问量是 1000万,每个页面从缓存读取的数据是 50K。缓存数据存放在一个 Redis 服务,机器使用千兆网卡。那么这个 Redis 一天要承受 500G 的数据流。而网站一般都会有高峰期和低峰期,两个时间流量的差异可能是百倍以上。假设高峰期每秒要承受的流量比平均值高 50 倍,也就是说高峰期 Redis 服务每秒要传输超过 250 兆的数据。请注意这个 250 兆的单位是 byte,而千兆网卡的单位是“bit” ,你懂了吗? 这已经远远超过 Redis 服务的网卡带宽。

        面对如此场景,一般我们会怎么做?

  1. 网卡从千兆升级到万兆:很多人不知道,这个看似简单的操作其实相当麻烦,特别是一些云主机根本没有万兆网卡给你使用,要命;
  2. 搭建 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:二级缓存缓存击穿解决方案

  • 设置热点数据永远不过期;
  • 如果过期则或者在快过期之前更新,如有变化,主动刷新缓存数据,同时也能保障数据一致性;
  • 加互斥锁,保障缓存中的数据,被第一次请求回填。注意:此方案不适用于超高并发场景。

3.2 三级缓存架构

        相比于二级缓存架构,三级缓存增加了接入层,如:Nginx。 三级缓存架构分级:

  • 1级为本地缓存,或者进程内的缓存(如:Ehcache):速度快,进程内可用;
  • 2级为集中式缓存(如:Redis):可同时为多节点提供服务;
  • 3级为接入层缓存(如:Nginx):速度快,进程内可用;

        对于高并发的请求,接入层 Nginx 有着巨大的作用,能反向代理,负载均衡,动静分离以及和 Lua 整合,可以实现请求定向分发等非常有用的功能,同理 Nginx 层也可以实现缓存的功能。

        利用接入层 Nginx 的进程内缓存,缓存极热数据的高并发访问。在接入层,当请求过来时,判断本地缓存中是否存在,如果存在着直接返回请求结果(或者展现静态资源的数据),这样的请求不会直接发送到后端服务层,减少了跨网络传输,大大缩短访问路径。

问题 - 1:如何使用 Nginx 作为L3本地缓存?

        我这里要介绍的,是使用 Nginx Lua 共享字典的方式实现三级缓存。

  • 声明一个共享内存区域 name,以充当基于 Lua 字典 ngx.shared.<name> 的共享存储。
syntax:lua_shared_dict <name> <size>
default: no
context: http
phase: depends on usage
  • 设置参数 siz 接受大小单位,如:k,m。
http {
    # 指定缓存信息
    lua_shared_dict seckill_cache 128m;
    ...
}
  • 然后在 lua 脚本中使用:local shared_memory = ngx.shared.seckill_cache,就可以取到放在共享内存中的数据。对共享内存的操作也是如 get,set 之类。
-优先从缓存获取,否则访问上游接口
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级缓存主要用于极热数据,如秒杀的商品数据(对于秒杀这样的场景,瞬间有十几万甚至上百万的请求要同时读取商品。如果没有命中本地缓存,可能导致缓存击穿。

        为了防止缓存击穿,同时也保持数据一致性,具体的方案为:

  1. 数据预热(或者叫预加载);
  2. 设置热点数据永远不过期,通过 ngx.shared.DICT的缓存的LRU机制去淘汰;
  3. 如果缓存主动更新,在快过期之前更新,如有变化,通过订阅变化的机制,主动本地刷新;
  4. 提供兜底方案,如果本地缓存没有,则通过后端服务获取数据,然后缓存起来。

总结

1. 简单了解下缓存击穿与缓存穿透的简单区别:

  • 缓存击穿:指数据库中有数据,但是缓存中没有,大量的请求打到数据库;
  • 缓存穿透:缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。

2. 缓存和DB的数据一致性如何保证:

  • 分布式系统中,缓存和数据库同时存在,先操作数据库,再操作缓存。
  • 为了保证删除缓存成功,根据适用场景,可以选择延时双删,重试机制删除缓存,或者使用 binlog 异步删除策略;
  • 针对 binlog 异步删除策略:如果是主从数据库,binglog 要取自于从库;如果是一主多从,每个从库都要采集binlog,然后消费端收到最后一台binlog数据才删除缓存,或者干错简单处理 - 收到一次更新 log,就删除一次缓存;
  • 多级缓存方案中,由于进程内缓存的存在,所以每次有更新操作,要利用 MQ 的广播模式通知到每台服务器进行数据同步;

3. 很重要的一点:

        使用缓存提升性能就会有数据更新的延迟,就无法使数据库和缓存数据保持强一致,所以上树的各种优化方案,都是以保证弱一致性,最终一致性为前提的。


最后

以上就是悦耳画板为你收集整理的Redis 与 DB 的数据一致 / 双写一致性问题前言正文总结的全部内容,希望文章能够帮你解决Redis 与 DB 的数据一致 / 双写一致性问题前言正文总结所遇到的程序开发问题。

如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。

本图文内容来源于网友提供,作为学习参考使用,或来自网络收集整理,版权属于原作者所有。
点赞(46)

评论列表共有 0 条评论

立即
投稿
返回
顶部