Redis高可用心路历程
- 1、单节点下的redis
- 热点数据的缓存
- 计数器
- 缓存过期时间
- 分布式锁
- 单节点带来的问题
- 1. redis单点发生故障,数据丢失,影响整体服务应用
- 2、单节点redis自身资源有限,无法承载更多资源分配
- 3、并发访问,给服务器主机带来压力,性能瓶颈
- 2、主从复制
- 主从复制的作用
- 如何进行主从复制
- 什么时候进行全量同步
- 主从复制存在的问题
- 3、哨兵集群
- 哨兵的作用
- 集群故障恢复原理
- 为什么是哨兵集群?
- 哨兵集群带来的问题
- 4、分片集群
- 为什么要引入分片集群
- 哨兵集群留下的问题
- 海量数据的问题
- 高并发写的问题
- 分片集群
- 分片集群的好处
- 使用建议
1、单节点下的redis
正常业务中数据库承受大量的读写请求,造成数据库压力过大从而导致服务器宕机等情况时有发生,即使通过数据库的横向拓展,通过搭建主从复制实现读写分离或者垂直拓展实现分库分表的策略,虽然都在一定程度上能够缓解单节点数据库服务器的压力,但是随着业务量的持续增大,通过持续投钱买服务器显然并不是合理的方案
为了缓和CPU与内存之间的速度差异,计算机的制造商为CPU增加了缓存,从而缓存的概念深入人心,而java程序员都不陌生的redis,在使用起来既满足我们大部分业务需求但同时也带了部分隐患
热点数据的缓存
redis作为热点数据的缓存实现,客户端请求先去redis查询,redis没有再去数据库查询,再根据结果完成缓存重建,这是最常规的redis缓存使用方式,但是同时也引出了诸多问题
缓存穿透:客户端请求的id在redis缓存中没有,在数据库中也没有,短时间有大量的请求都会到达数据库,数据库会承受巨大的压力甚至可能导致服务器宕机而redis缓存也失去了意义
解决方案:
- 缓存空对象:当请求的id在redis缓存中没有,数据库也查不到时,返回null并且在redis缓存中存入空对象,缓存过期时间不要太长,存在数据的短期不一致的情况
- 布隆过滤器:依靠redis中bitmap特殊的数据结构,可以把bitmap理解为一个巨大的二进制数组,通过0跟1代表数据是否存在,可以提前把某个热点数据的id通过一定的hash算法存储到对应的bitmap中,客户端请求的id也通过同样的hash算法后去bitmap中查找,若为1则数据一定存在,再去redis或数据库中查找
- 做足权限校验或者id基础校验,加强id生成规律,避免恶意请求
缓存击穿:某个高并发访问并且缓存重建逻辑复杂的key突然过期,在缓存重建期间大量的请求达到数据库导致服务器存在处理缓慢的情况甚至可能导致服务器宕机
解决方案:
- 互斥锁:当a线程在redis中缓存未命中,则通过获取redis的分布式锁,获取锁成功则去数据库中查询数据完成缓存重建,期间b线程同样缓存未命中,尝试获取互斥锁失败则进入短时间的阻塞,期间通过循环尝试获取数据,若此时a线程还未完成缓存重建,则b线程依旧不断阻塞、查询缓存的自旋操作,若a线程在数据库查询的数据为null,则缓存空对象;
- 逻辑过期:对高并发并且缓存重建逻辑复杂的key不在redis中设置缓存过期时间,通过创建DTO对象,内置逻辑过期时间字段、缓存数据字段,当a线程查询到数据时,判断时间字段保存的值是否小于当前时间,如是则说明缓存已过期,则a线程尝试获取redis的分布式锁成功,并开辟新线程去完成缓存重建,当前a线程返回逻辑过期数据(存在数据的短期不一致问题),此时其他线程获取到缓存的数据,判断是逻辑过期的,也尝试获取互斥锁失败,说明新开辟的线程还没有完成缓存重建,则依旧返回逻辑过期数据,若新开辟的线程查询数据库中的数据为null,则缓存空对象
通过对比互斥锁与逻辑过期的解决方案,可以发现其实是在数据的一致性跟服务的可用性之间做抉择,如选择互斥锁,则是牺牲了响应时间,保证了数据的一致性;若选择的事逻辑过期的方案,则是保证了服务的可用性,牺牲的数据的一致性,存在数据的短期不一致的问题
计数器
在当个jvm进程中通常使用JUC(java.util.concurrent)包下的AtomicInteger类使用的cas,避免了使用synchronized带了的性能损耗,但是在分布式集群部署下,多进程的jvm则不能够满足要求。Redis6.0之前只对外提供了单线程服务,即redis的所有单条命令都是原子性的,并且通过redis的单线程,可以将并行的请求转换为串行执行,依托这个特性,可以通过redis incr命令进行一些计数的操作,并且在多进程jvm下可见
常见的业务使用:秒杀业务,比如说某件商品的秒杀,肯定要判断商品的库存,依托每次都去数据库中查询,如果使用悲观锁(synchonized,数据库中查询库存,如果大于0,则执行更新操作)效率一定很低,通过改进乐观锁的sql,在update时set stockNum=stockNum-1 where stockNum>0;这个策略效率肯定比悲观锁要好,但是如果某件商品的库存是1w,同时有100w用户在抢购,岂不是如此大的并发量同时落到数据库中了吗? 所以我们在换种策略,在秒杀活动开始前,提前把商品的库存放入redis缓存中,通过incr -1的命令完成库存的自减,要知道官方提供的数据,redis每秒读11w次,写8.1w次,在库存为0时,对其他请求可以立刻返回失败,其次结合lua脚本,可以同时可以判断用户是否已下单(满足一人一单的需求)
缓存过期时间
使用redis命令时set nx px,可以设置缓存过期时间,通过这种缓存自动过期的策略,可以实现的业务场景有:优惠卷自动过期、订单超时未支付过期、手机验证码失效的场景
但是如果缓存的数据没有设置过期时间,当达到内存阈值瓶颈时,要依托内存淘汰策略去删除key
分布式锁
单节点的redis,独立于多节点部署的jvm进程之外,面对synchronized和ReentrantLock只在同一进程内有效的锁而分布式集群下部署的jvm则失去了对共享资源争夺的互斥性,多节点都能同时获取锁成功,此时需要存在多进程jvm都可见的锁,也就是分布式锁,分布式锁的实现方案业内常见的redis和zookeeper;
redis分布式锁常用的方式,依托string结构,set key value,nx:not exist 不存在则创建,px:设置锁的过期时间避免死锁
存在的问题:
-
死锁:如果某一线程加锁后,没来得及删除锁,服务器就宕机了,那其他jvm进程中的线程则再也获取不到锁,也就是set nx key value失败,则要设置锁的过期时间 ,避免死锁
-
锁误删:
- 为什么会存在锁误删的情况,假如现在有abc3个线程,a线程获取锁并设置锁的过期时间为10s,a线程被某个业务阻塞了,阻塞时间大于锁的过期时间,10s后锁自动过期了,b线程此时获取锁成功(锁的过期时间也为10s),b线程开始执行自己的业务,就在此时a线程阻塞结束了,执行释放锁的逻辑,则会把b线程加的锁给删除了,之后c开始加锁,恶性循环开始了;
- 所以我们要防止锁误删逻辑,就需要设置锁表示,通过UUID + 当前线程id,再解锁时判断锁是否是自己的,有人就会问,判断当前线程id就足够了,为什么还要加UUID,因为可能存在不同jvm进程刚好当时线程id是一致的情况,所以要多设置个UUID,保证这种偶然性的出现;
- 到这里问题还没有解决, 为了防止锁误删,解锁的操作分为:判断锁是否是自己的,然后在删除锁,那么可能会出现这种情况:当前有abc线程,a线程获取锁 set nx px key value(UUID+threadID),执行完业务后准备释放锁,a线程判断当前的锁是自己的,准备执行del锁操作时,a线程此时上下文切换了,或者被阻塞了,一段时间后锁自动过期了,此时b线程成功获取到锁后,a线程又上下文切换回来了,继续向下执行把b线程加的锁给删除了,又开始恶性循环了;
- 必须保证判断锁标识、删除锁的两步操作时原子性的,此时要借助lua脚本,保证删除锁的操作时原子性的
-
锁不可重入:jvm实现的synchronized锁和java api实现的ReentrantLock底层都维护了一个整型字段,用于锁重入的实现,而redis获取锁并没有锁重入的实现,导致同一线程多次获取锁的业务无需求法实现
-
获取锁失败不可重试:使用set nx px 单次获取锁失败立即返回失败,不会再有重试的机会,造成不能灵活的控制业务,充分利用cpu
-
主从复制引起的锁重复添加成功的情况:为了保存redis的高可用性,搭建了主从集群,实现主从复制,现有ab线程,假设a现在在master 1 加锁成功,在master1 跟slave1 数据同步前,master出现脑裂的情况,master突然网络异常,但是仍旧是正常运行的,sentinel集群判定该master为客观下线后,开始自动故障转移,选举slave1为新的主节点master2,此时b线程在新的主节点又加锁成功,此时master1和master2都存在同一个key
针对上面这些问题,可以用redisson客户端解决这些问题
-
redisson 利用hash结构记录线程id和重入次数<k1,<k2,v>>,实现锁的可重入
- k1就是对应入参getName():key
- k2对应入参getLockName(threadId):UUID+threadId
- v对应的是锁的重入次数
-
获取锁失败的线程,通过订阅释放锁的信号,灵活控制锁的重试等待,cpu利用率比较高,不会无限等待
-
超时续约:利用watchDog,每隔一段时间(releaseTime /3)重置超时时间,但是只有tryLock方法中参数leaseTime释放锁时间为-1时才能够启用超时续约
-
利用multiLock:通过搭建多个独立的Redis节点,必须在所有节点获取到锁才算真正的获取锁成功,避免主从复制带来的重复加锁成功的情况
单节点带来的问题
1. redis单点发生故障,数据丢失,影响整体服务应用
如果部署redis服务器发生故障,如果只使用RDB的持久化策略,可能会丢失最后一次RDB后的数据,并且重启服务器的这段期间,服务都是不可用的状态,况且不清楚服务器宕机的具体原因,单节点下的redis并没有故障自动恢复的能力,导致长时间的服务不可用,此时就需要备份数据,通过冗余数据来保证服务的可用性;
2、单节点redis自身资源有限,无法承载更多资源分配
redis的缓存是基于内存实现的,单节点内存是有限的,如果内存占满了会启用淘汰策略,而这个期间客户端的连接请求都会超时,造成服务的短暂不可用,删除一部分缓存,删除缓存的操作并不是我们通过业务主观的操作,可能导致部分查询缓存的业务失效,从而使大量请求达到数据库;随着业务的发展,数据量的增大,当存在单台服务器的存储上限和算力上限影响业务的正常使用,此时就需要通过一些策略去横向拓展存储和算力
3、并发访问,给服务器主机带来压力,性能瓶颈
客户端的每一个tcp连接都会消耗redis服务器的资源,虽然redis官方号称每秒读11w次,写8.1w次,但是这仅仅是统计了理想状态下的读写请求,并没有其他外力因素,比如说此时主进程需要fork出子进程完成RDB,或者执行rebgwriteaof,对aof文件进行重写等影响redis服务器性能的操作,此时需要通过部署主从节点,对读写请求分开处理,从而提高redis服务器集群的响应能力,提高整体算力,在数据量非常大的情况下,还需要通过搭建分片集群,提高redis服务集群的存储上限
2、主从复制
为了提高redis的并发量,通过搭建redis的主从集群,利用读写分离来提高并发量;通过redis来缓存数据,客户端对redis的操作肯定是读多写少的情况,读操作主从库都可以接收,写操作,首先到主库上执行,在通过主从复制同步给从库
主从复制的作用
- 数据冗余:冗余并不是完全不被允许的,对于数据的热备份,通过添加多余的服务器来完成数据的热备份,在主机节点宕机时,可以快速地完成数据的恢复,但是还是存在一定的数据丢失,因为主从同步并不是实时的,需要我们去通过代码策略去避免主从复制带来数据丢失的情况(redisson的redLock、mutilLock),数据冗余是持久化之外的一种数据冗余方式。
- 故障恢复:当主节点出现问题时,可以由从节点提供服务,实现快速地故障恢复,这也是一种服务的冗余
- 负载均衡:在实现主从复制的基础上,配合读写分离策略,分担服务器负载,在读多写少的场景下,通过多个从节点分担负载,可以大大提高redis服务器的并发量
- 高可用基石:主从复制是所有中间件实现集群或者分片集群的高可用基础
如何进行主从复制
每台redis服务节点默认的角色都是master,可以登录redis服务端,通过info replication命令查看当前服务节点的主从关系,也可以通过slaveof ip port 主master 设置从节点绑定的主节点
主从复制是通过RBD实现的,分为全量复制和增量复制
全量复制:
- 从节点执行slaveof master_ip master_port 命令后,slave向master发送psync命令,同时携带runId、offset,每个节点都有自己的runId,第一次进行主从复制发送的runId是自己的runId,offset默认为-1,master接收到psync命令后,发现runId不是自己,则返回fullresync命令,并且携带自身的runId,以及offset(为下一次增量同步做准备)
- master执行bgsave命令,fork出子进程将内存中的快照数据保存在RBD文件中,再次期间master接收的写命令会写入到repl_backlog中,在全量复制完成后,在将repl_backlog的数据发送给slave
- slave接收到RBD文件后,会先清空本地的数据,然后回放master发送的RDB文件完成数据同步
增量复制:
从节点向master发送psync命令,请求的runId是第一次全量同步时接收master的runId,以及当前同步的offset,master接收到psync命令后,确认runId是自己,就从repl_backlog_buffer(环形缓冲区),该缓冲区中存放了master 的offset,以及诸多slave 的salve_repl_offset,用于保存master与slave之间的数据的差异,master就将这部分差异的数据repl_backlog发送给slave
什么时候进行全量同步
- 主从同步时,slave节点执行slaveof master_ip port_ip ,发送psync,携带runId,offset,第一次请求数据同步是全量同步,master通过校验runId不是自己的runId,则返回fullresync命令开始全量同步
- 主从节点由于网络原因断开连接,这期间的数据差异会在repl_backlog_buffer的环形缓冲区体现出来,master的offset在环形缓冲区中一直领先于slave的slave_repl_offset,当master的offset领先超过一整圈时会覆盖slave_repl_offset,网络恢复,slave节点请求增量同步,但是请求的offset在repl_backlog_buffer已经找不到了,此时要进行全量同步
主从复制存在的问题
1、搭建主从集群后,Slave节点宕机恢复后可以找master节点同步数据,那master节点宕机怎么办?
2、Master宕机期间,重启数据恢复期间,都不能接收客户端的写请求该怎么办?
3、脑裂以及redis的数据丢失
-
异步复制导致的数据丢失
因为主从复制是通过bgsave进行的复制是异步的,所以可能有部分数据还没复制到slave,master就宕机了,此时这些部分数据就丢失了 -
脑裂导致的数据丢失
脑裂,主从集群中,master如果网络异常,被哨兵集群判定为客观下线,但是实际上master还运行着,开启选举,将最接近master的slave节点通过slave no one 将slave切换成主节点,其他slave执行slaveof ip port完成主从切换,此时整个集群就会有两个master;
此时虽然某个slave被切换成了master,但是可能client还没来得及切换到新的master,还继续写向旧master的数据可能也丢失了,因此旧master再次恢复的时候,会被作为一个slave挂到新的master上去,自己的数据会清空,重新从新的master复制数据
3、哨兵集群
使用redis主从集群架构后,实现读写分离,但是不能够保证主节点宕机后依旧能够响应客户端请求,当然我们可以通过人工的方式手动执行 slaveof no one去完成slave切换为master,然后通过slaveof ip port命令去告知其他从节点更换了新的master,但是我们更希望的是提供故障的自动解决,如果由人工完成,则需要增加人力成本,且容易产生人工错误,还会造成一段时间的程序不可用;当master节点异常时自动从多个slave中选举出最接近master节点的新master,redis为我们提供了哨兵集群,保证Redis的高可用,使得系统更加健壮
哨兵的作用
- 监听:哨兵集群会监听主从节点的状况,通过每秒发送一次ping确认整个主从集群中的每个节点是否能够正常响应
- 故障转移(failover):当确认master节点客观下线后,自动从slave选举出新的节点
- 告知:Sentinel充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端(将从节点切换为主节点,而从节点是负责读,主节点负责写,在节点切换后需要通知java客户端)
集群故障恢复原理
会从slave集群中选择与master数据最接近的slave作为新的master节点,一旦发现master故障,sentinel需要在salve中选择一个作为新的master,选择依据是这样的:
- 首先会判断slave节点与master节点断开时间长短,如果超过指定值(down-after-milliseconds * 10)则会排除该slave节点
- 然后判断slave节点的slave-priority值,越小优先级越高,如果是0则永不参与选举
- 如果slave-prority一样,则判断slave节点的offset值,越大说明数据越新,优先级越高
- 最后是判断slave节点的运行id大小,越小优先级越高。
当选出一个新的master后,该如何实现切换呢?
流程如下:
- sentinel给备选的slave1节点发送slaveof no one命令,让该节点成为master
- sentinel给所有其它slave发送slaveof ip port 命令,让这些slave成为新master的从节点,开始从新的master上同步数据。
- 最后,sentinel将故障节点标记为slave,当故障节点恢复后会自动成为新的master的slave节点
为什么是哨兵集群?
- 首先哨兵本身也有单点故障的问题,所以在一个一主多从的Redis系统中,可以使用多个哨兵进行监控,哨兵不仅会监控主数据库和从数据库,哨兵之间也会相互监控。每一个哨兵都是一个独立的进程,作为进程,它会独立运行;
- 哨兵集群必须部署2个以上节点,因为当一个哨兵发现master节点未响应判定该master为主观下线,此时就需要其他哨兵节点去监测master节点的情况,需要达到设定的quorum值,才能将该节点设定为客观下线,此时才能够从哨兵集群中选举出leader(第一个发现master主观下线的哨兵节点)去完成故障自动转移;
- 如果哨兵集群仅仅部署了个2个哨兵实例,那么它的majority就是2(2的majority=2,3的majority=2,5的majority=3,4的majority=2),如果其中一个哨兵宕机了,就无法满足majority>=2这个条件,那么在master发生故障的时候也就无法进行主从切换
哨兵集群带来的问题
- 是一种中心化的集群实现方案:始终只有一个Redis主机来接收和处理写请求,写操作受单机瓶颈影响。
- 集群里所有节点保存的都是全量数据,浪费内存空间,没有真正实现分布式存储。数据量过大时,主从同步严重影响master的性能。
- Redis主机宕机后,哨兵模式正在投票选举的情况之外,因为投票选举结束之前,谁也不知道主机和从机是谁,此时Redis也会开启保护机制,禁止写操作,直到选举出了新的Redis主机。
主从模式或哨兵模式每个节点存储的数据都是全量的数据,数据量过大时,就需要对存储的数据进行分片后存储到多个redis实例上。此时就要用到Redis Sharding技术。
4、分片集群
为什么要引入分片集群
哨兵集群留下的问题
Redis的master宕机后,在主从切换的过程中,Redis开启了保护机制,禁止一切的写操作,直到选举出新的Redis主节点
海量数据的问题
为了提供主从同步的性能,我们通过不会将的redis的master内存设置得太高,如果内存设置得太高,在一定频率下进行RDB持久化或者多从节点进行全量同步时会有很多进程争夺的磁盘带宽,并且redis的master主节点内存过大还会导致fork出子进程时阻塞的时间过长,此时无法接受客户端的写请求。
但是如果降低master节点的内存上限,此时还有海量数据该如何存储?
高并发写的问题
我们通过搭建主从集群、哨兵集群来保证服务的高可用,并且为了适应读多写少的情况,通过读写分离分担master服务器压力,来解决高并发读的问题,并且从节点故障恢复后可以通过主从复制中的全量同步或者增量同步来保证数据的一致性,主节点宕机后通过哨兵集群完成服务的自动故障转移,保证读的可靠性,但是高并发写的问题依旧没有解决
分片集群
其他中间件也是通过主从复制来解决高并发读的问题,通过多主多从来解决高并发写的问题,在redis中提供了分片集群也是以多主多从的形式来解决高并发写的问题
使用分片集群与之前的主从集群、哨兵集群的区别:
- 集群中有多个master,每个master保存的不同的数据,注意使用分片集群后<数据是跟着插槽走的,不会因为每次连接到不同的master节点后导致出现数据查询不到的问题,每个master可以通过主从复制有多个slave节点;多主多从后可以解决海量数据存储的问题,并且当个master redis节点的内存也不用设置得太高,同时通过多个master节点可以将高并发的写请求通过负载均衡分散到多个master节点,解决高并发写的问题
- 使用分片集群后就不需要用到哨兵集群了,因为master之间通过ping来监测着彼此的健康状态,master同时也扮演着哨兵的角色,某一个master宕机, 其他的master也会进行投票,从主观下线到客观下线的一个过程,最后完成主从的切换
- 分片集群采用虚拟哈希槽分区而非一致性hash算法,预先分配一些卡槽,所有的key根据哈希函数映射到这些槽内,每一个分区的master节点负责维护一部分槽以及槽锁映射的键
分片集群的好处
- 分片集群完全是去中心化的思想,采用多主多从的模式,所有的节点彼此通过ping来相互监测健康状态,内部使用二进制协议优化传输速度和网络带宽
- 客户端与分片redis集群中的某个节点直连,具体看key通过crc16算法计算出在哪个插槽对应的哪个redis节点,客户端不需要连接集群中的所有节点
- 全量数据分布在0~16383插槽上,总共16384个插槽,如果是3主3从模式,则会把16384个插槽分配在3个主节点上称作3个分配,同时主节点又会跟从节点进行主从复制同步数据;每个主节点负责维护一部分槽,以及槽锁映射的键值数据,集群中每个节点都有全部插槽的信息,通过插槽每个node结点都知道数据具体存储到哪个node上,通过crc16算法计算出key如果不在本分片,在会路由到其他分片
使用建议
主从集群:数据量不大,业务中只需要满足读写分离,并且对服务的可用性不高,允许短暂的服务不可用带来的风险,并且需要手动完成主从切换,则单使用主从集群完全够用
哨兵集群:数据量不大,并且对服务的可用性要求比较高,可以使用主从集群搭配哨兵集群,完成故障的自动转移,主节点不可用时自动完成主从切换
分片集群:主要针对海量数据、高并发、高可用的场景
以上便是Redis在多种业务场景下的使用方案,如有误解,请在评论区指出,谢谢