Redis高可用心路历程以及多种业务场景下的使用模式

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 valuenx:not exist 不存在则创建px:设置锁的过期时间避免死锁

存在的问题

  • 死锁:如果某一线程加锁后没来得及删除锁服务器就宕机了那其他jvm进程中的线程则再也获取不到锁也就是set nx key value失败则要设置锁的过期时间 避免死锁

  • 锁误删

    1. 为什么会存在锁误删的情况假如现在有abc3个线程a线程获取锁并设置锁的过期时间为10sa线程被某个业务阻塞了阻塞时间大于锁的过期时间10s后锁自动过期了b线程此时获取锁成功(锁的过期时间也为10s)b线程开始执行自己的业务就在此时a线程阻塞结束了执行释放锁的逻辑则会把b线程加的锁给删除了之后c开始加锁恶性循环开始了;
    2. 所以我们要防止锁误删逻辑就需要设置锁表示通过UUID + 当前线程id再解锁时判断锁是否是自己的有人就会问判断当前线程id就足够了为什么还要加UUID因为可能存在不同jvm进程刚好当时线程id是一致的情况所以要多设置个UUID保证这种偶然性的出现;
    3. 到这里问题还没有解决 为了防止锁误删解锁的操作分为:判断锁是否是自己的然后在删除锁那么可能会出现这种情况:当前有abc线程a线程获取锁 set nx px key value(UUID+threadID)执行完业务后准备释放锁a线程判断当前的锁是自己的准备执行del锁操作时a线程此时上下文切换了或者被阻塞了一段时间后锁自动过期了此时b线程成功获取到锁后a线程又上下文切换回来了继续向下执行把b线程加的锁给删除了又开始恶性循环了;
    4. 必须保证判断锁标识、删除锁的两步操作时原子性的此时要借助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实现的分为全量复制增量复制

全量复制

  1. 从节点执行slaveof master_ip master_port 命令后slave向master发送psync命令同时携带runId、offset每个节点都有自己的runId第一次进行主从复制发送的runId是自己的runIdoffset默认为-1master接收到psync命令后发现runId不是自己则返回fullresync命令并且携带自身的runId以及offset(为下一次增量同步做准备)
  2. master执行bgsave命令fork出子进程将内存中的快照数据保存在RBD文件中再次期间master接收的写命令会写入到repl_backlog中在全量复制完成后在将repl_backlog的数据发送给slave
  3. slave接收到RBD文件后会先清空本地的数据然后回放master发送的RDB文件完成数据同步

增量复制:

从节点向master发送psync命令请求的runId是第一次全量同步时接收master的runId以及当前同步的offsetmaster接收到psync命令后确认runId是自己就从repl_backlog_buffer(环形缓冲区该缓冲区中存放了master 的offset以及诸多slave 的salve_repl_offset用于保存master与slave之间的数据的差异master就将这部分差异的数据repl_backlog发送给slave

什么时候进行全量同步

  • 主从同步时slave节点执行slaveof master_ip port_ip 发送psync携带runIdoffset第一次请求数据同步是全量同步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的数据丢失

  1. 异步复制导致的数据丢失
    ​ 因为主从复制是通过bgsave进行的复制是异步的所以可能有部分数据还没复制到slavemaster就宕机了此时这些部分数据就丢失了

  2. 脑裂导致的数据丢失
    脑裂主从集群中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节点的新masterredis为我们提供了哨兵集群保证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=23的majority=25的majority=34的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在多种业务场景下的使用方案如有误解请在评论区指出谢谢

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: redis