Redis | Java后端
NoSQL
是什么
NoSql 全称 Not Only SQL,泛指非关系型数据库,是对关系型数据库的一种补充
为什么使用
关系型数据库的缺点
高并发下 IO 压力大
数据按行存储,即使只针对其中某一列进行运算,也会将整行数据从存储设备中读入内存,使得 IO 代价较大
为维护索引付出的代价大
为了提供丰富的查询能力,通常热点表都会有多个索引,以提供丰富的查询能力。一旦有了索引,数据的新增必然伴随着所有索引的新增,数据的更新也必然伴随着所有索引的更新,这不可避免地降低了关系型数据库的读写能力,索引越多读写能力越差
为维护数据一致性付出的代价大
数据一致性是关系型数据库的核心,但是为了维护数据一致性的代价也是非常大的,只要提供的隔离级别越高,读写性能必然越差。
水平扩展后带来的种种问题难处理
随着企业规模扩大,当数据库产生性能瓶颈,就需要读写分离、分库分表。做了分库之后,数据迁移(1 个库的数据按照一定规则打到 2 个库中)、跨库 join、分布式事务处理都是需要考虑的问题
表结构扩展不方便
由于数据库存储的是结构化数据,因此表结构 schema 是固定的,扩展不方便,如果需要修改表结构,需要执行 DDL 语句修改,修改期间会导致锁表,部分服务不可用
全文搜索功能弱
不具备分词能力,且 like 查询在以 % 开头的情况时无法命中索引
通常在企业规模不断扩大的情况下,一味指望通过增强数据库的能力来解决数据存储问题只是事倍功半
NoSQL 解决了传统关系型数据库难以解决的问题
- 高并发读/写
- 海量数据的高效率存储和访问
- 高可用性及高可扩展性
特点/优点
- 容易扩展,方便使用,数据之间没有关系
- 数据模型非常灵活,无须提前为要存储的数据建立字段类型,随时可以存储自定义的数据格式
- 适合大数据量、高性能的存储
- 具有高并发读/写、高可用性
缺点
没有执行 NoSQL 查询的标准语言
用于查询数据的语法因不同类型的 NoSQL 数据库而异
复杂查询效率低下
与 SQL 数据库不同,没有标准接口来执行复杂的查询
数据检索不一致
大多数 NoSQL 数据库都遵循 BASE 理论:基本可用、软状态、最终一致性
应用场景
- 对于大数据量、高并发的存储系统及相关应用
- 存储用户信息,如大型电商系统的购物车、会话等
- 对数据一致性要求不是很高的业务场景
- 对一些大型系统的日志信息的存储
- 对易变化、热点高频信息、关键字等信息的存储
- 对于多数据源的数据存储
- 对于给定 key 来映射一些复杂值的环境
Redis
是什么
全称 Remote Dictionary Server,本质上是一个 key-value 型的内存数据库,读写速度非常快,被广泛应用于缓存方向。除了做缓存之外,也经常用来做分布式锁等。
Redis 提供了多种数据类型来支持不同的业务场景,还支持事务 、持久化、多种集群方案。
单线程模型
Redis 基于 Reactor 模式开发了自己的网络事件处理器,这个处理器被称为文件事件处理器。由于文件事件处理器是单线程方式运行的,所以我们一般都说 Redis 是单线程模型。
虽然文件事件处理器以单线程方式运行,但通过使用 I/O 多路复用程序来监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
文件事件处理器既实现了高性能的网络通信模型,又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接,这保持了 Redis 内部单线程设计的简单性。
为什么 Redis 单线程还这么快
严格来说,Redis Server 是多线程的,只是他的请求处理整个流程是单线程处理的,我们平时所说的 Redis 单线程快是指他的请求处理过程非常地快。
纯内存操作,读写数据都是在内存中完成
键值 KV 内存数据库,查询的时间复杂度只有 O(1)
使用 I/O 多路复用技术,可以在单线程中监听多个 Socket 的请求并针对有活动的 Socket 采取反应
Redis 的大部分操作并不是 CPU 密集型任务,其瓶颈在于内存和网络带宽
如果单个 Redis 实例的性能不足以支撑业务,Redis 作者推荐部署多个 Redis 节点组成集群的方式来利用多 CPU 的能力,而不是在单个实例上使用多线程来处理
单线程自身的优势
- 没有多线程上下文切换的性能损耗
- 没有访问共享资源加锁的性能损耗
- 开发和调试非常友好,可维护性高
我们需要尽量避开单线程模型的短板
单个请求发生耗时比较久,那么整个 Redis 就会阻塞住,其他请求也无法进来
所以我们平时在使用 Redis 时,一定要避免非常耗时的操作:使用时间复杂度过高的方式获取数据、一次性获取过多的数据、大量key集中过期导致 Redis 淘汰 key 压力变大等等。
常见数据结构
string
简单的 key-value。
应用场景:
需要存储常规数据的场景
缓存 Session、Token、图片地址、序列化后的对象
需要计数的场景
用户单位时间的请求数(简单限流可以用到)、页面单位时间的访问数。
分布式锁
实现一个最简易的分布式锁,不建议。
list
双向链表,允许从链表两端推入或删除元素
应用场景:
信息流展示
最新文章、最新动态。
消息队列
功能过于简单,不建议。
hash
string 类型的 field 和 value 的映射表
适合用于存储简单对象,后续操作的时候,你可以直接修改这个对象中的某些字段的值。
应用场景:对象数据存储场景,例如购物车信息。

set
无序集合,元素不能重复。
可以基于 Set 轻易实现交集、并集、差集的操作。
应用场景:
- 需要存放的数据不能重复。
- 需要获取多个数据源交集和并集等场景,比如利用交集求共同好友、好友推荐。
- 需要随机获取数据源中的元素的场景,比如抽奖系统、随机点名等。
sortedset
有序集合,给每个元素绑定了一个权重参数 score,使得集合中的元素按 score 进行有序排列。
应用场景:需要随机获取数据源中的元素根据某个权重进行排序的场景,比如各种实时排行榜。
String 还是 Hash 存储对象数据更好呢?
简单对比一下二者:
- 对象存储方式:String 存储的是序列化后的对象数据,存放的是整个对象,操作简单直接。Hash 是对对象的每个字段单独存储,可以获取部分字段的信息,也可以修改或者添加部分字段,节省网络流量。如果对象中某些字段需要经常变动或者经常需要单独查询对象中的个别字段信息,Hash 就非常适合。
- 内存消耗:Hash 通常比 String 更节省内存,特别是在字段较多且字段长度较短时。Redis 对小型 Hash 进行优化(如使用 ziplist 存储),进一步降低内存占用。
- 复杂对象存储:String 在处理多层嵌套或复杂结构的对象时更方便,因为无需处理每个字段的独立存储和操作。
- 性能:String 的操作通常具有 O(1) 的时间复杂度,因为它存储的是整个对象,操作简单直接,整体读写的性能较好。Hash 由于需要处理多个字段的增删改查操作,在字段较多且经常变动的情况下,可能会带来额外的性能开销。
总结:
- 在绝大多数情况下,String 更适合存储对象数据,尤其是当对象结构简单且整体读写是主要操作时。
- 如果你需要频繁操作对象的部分字段或节省内存,Hash 可能是更好的选择。
持久化机制
目的:重用数据(比如重启机器、机器故障之后恢复数据);为了防止系统故障而将数据备份到一个远程位置
Redis 支持两种不同的持久化操作:RDB 和 AOF
RDB 快照
Redis 默认持久化方式
通过创建快照来获得存储在内存里面的数据在某个时间点上的副本
持久化策略:
1
2
3
4
5save 900 1 #在900秒(15分钟)之后,如果至少有1个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
save 300 10 #在300秒(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
save 60 10000 #在60秒(1分钟)之后,如果至少有10000个key发生变化,Redis就会自动触发BGSAVE命令创建快照。AOF 只追加文件
每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入到内存缓存,然后再根据配置来决定何时将其同步到硬盘中的 AOF 文件
持久化策略:
- 每次有数据修改发生时都会写入 AOF 文件
- 每秒钟同步一次
- 让操作系统决定何时进行同步
RDB vs AOF
- AOF 持久化更快
- RDB 恢复更快
- RDB 可能会丢失数据,而 AOF 可以做到几乎不丢失数据(牺牲效率)
- 如果同时使用 RDB 和 AOF 持久化,Redis 会优先使用 AOF 进行恢复数据
内存管理
为什么给缓存数据设置过期时间
缓解内存的消耗、有的业务场景就是需要某个数据只在某一时间段内存在(短信验证码)
过期数据的删除策略
惰性删除
只会在取出 key 的时候才对数据进行过期检查
这样对 CPU 最友好,但是可能会造成太多过期 key 没有被删除
定期删除
每隔一段时间抽取一批 key 执行删除过期 key 操作
Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 占用
定期删除对内存更加友好,惰性删除对 CPU 更加友好。Redis 两种都有采用
内存淘汰机制
- 最近最少使用(从已设置过期时间的数据集)
- 最近最少使用(整个数据集)
- 随机(从已设置过期时间的数据集)
- 随机(整个数据集)
- 最少使用(从已设置过期时间的数据集)
- 最少使用(整个数据集)
- 将要过期的(从已设置过期时间的数据集)
- 不淘汰,内存不足以容纳新写入数据时就报错
生产问题
缓存穿透
大量请求的 key 根本不存在于缓存中,导致请求直接到了数据库上,没有经过缓存这一层。举个例子:某个黑客故意制造我们缓存中不存在的 key 发起大量请求,导致大量请求落到数据库
解决方案:
最基本的就是首先做好参数校验,一些不合法的参数请求直接抛出异常信息返回给客户端
缓存无效 key
如果缓存和数据库都查不到某个 key 的数据就写一个到 Redis 中去并设置过期时间
但若是恶意攻击每次构建不同的请求 key,会导致 Redis 中缓存大量无效的 key 。很明显,这种方案并不能从根本上解决此问题。如果非要用这种方式来解决穿透问题的话,尽量将无效的 key 的过期时间设置短一点比如 1 分钟
布隆过滤器
布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在
大致原理:当一个元素加入布隆过滤器中时,使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值),根据得到的哈希值,在位数组中把对应下标的值置为 1;当需要判断一个元素是否存在于布隆过滤器时,会对给定元素再次进行相同的哈希计算,得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中
缓存雪崩
缓存在同一时间或者较短的一段时间内大面积的失效,后面的请求都直接落到了数据库上,造成数据库短时间内承受大量请求
解决方案:
针对 Redis 服务不可用的情况:
- 采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用
- 限流,避免同时处理大量的请求
针对热点缓存失效的情况:
- 设置不同的失效时间比如随机设置缓存的失效时间
- 缓存永不失效
缓存击穿
跟缓存雪崩有点类似,缓存雪崩是大规模的 key 失效,而缓存击穿是一个热点的 Key,有大并发集中对其进行访问,突然间这个 Key 失效了,导致大并发全部打在数据库上,导致数据库压力剧增
解决方案:
如果是热点数据,那么可以考虑设置永远不过期
在缓存失效时,设置一个互斥锁,只让一个请求通过,只有一个请求去数据库拉取数据
当然这样会导致系统的性能变差
事务
是什么
Redis 事务本质是一组命令的集合。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中
总结说:Redis 事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令
相关命令
- MULTI :开启事务,redis 会将后续的命令逐个放入队列中,然后使用 EXEC 命令来原子化执行这个命令系列
- EXEC:执行事务中的所有操作命令。
- DISCARD:取消事务,放弃执行事务块中的所有命令
- WATCH:监视若干 key,如果被监视的 key 在事务执行前中被修改,就不会执行事务
- UNWATCH:取消 WATCH 对所有 key 的监视
执行过程
- 开始:以 MULTI 命令开始一个事务
- 命令入队:将多个命令入队到事务中,接到这些命令并不会立即执行,而是放到等待执行的事务队列里面
- 执行:由 EXEC 命令触发事务
当一个客户端切换到事务状态之后, 服务器会根据这个客户端发来的不同命令执行不同的操作:
- 如果客户端发送的命令为 EXEC 、 DISCARD 、 WATCH 、 MULTI 四个命令的其中一个, 那么服务器立即执行这个命令
- 如果客户端发送的是除了以上四个命令以外的其他命令, 服务器才会将命令放入事务队列
能保证 ACID?
原子性 A
Redis 官方认为 Redis 的事务是原子性的:所有的命令,要么全部执行,要么全部不执行
执行失败的命令也算作已执行,所以事务中有命令执行失败后会继续执行余下的命令并不是回滚,而且 Redis 本身也不支持回滚
之所以很多人认为 Redis 的事务不具原子性,是因为他们对事务原子性的定义为“事务中的所有的命令要么全部执行成功,要么一个都不执行”
隔离性 I
Redis 处理请求是单线程,并且它保证在执行事务时,不会对事务进行中断,事务可以运行直到执行完所有事务队列中的命令为止。因此,Redis 的事务是总是带有隔离性的
持久性 D
Redis 事务的持久性由 Redis 所使用的持久化机制所决定。但即使是 AOF 也做不到不会丢失数据,因为命令执行完毕到完成持久化总有时间间隔
一致性 C
最终一致性
为什么不支持回滚
- Redis 认为执行失败的命令都是编程错误造成的,都是开发中能够被检测出来的,生产环境中不应该存在,所以 Redis 决定不为程序员的错误买单,不对回滚进行支持
- 不支持回滚使得 Redis 的内部可以保持简单且快速
主从复制
作用
数据冗余:实现数据的热备份,是持久化之外的一种数据冗余方式
故障恢复:当主节点出现问题时,可以由从节点提供服务,实现快速的故障恢复(实际上是一种服务的冗余)
负载均衡:在主从复制的基础上,配合读写分离,可以由主节点提供写服务,由从节点提供读服务,分担服务器负载
尤其是在写少读多的场景下,通过多个从节点分担读负载,可以大大提高 Redis 服务器的并发量
高可用基石:是哨兵和集群能够实施的基础
两个缓冲区
replication buffer
Redis 为 client 分配的写出缓冲区,所有数据的交互都是通过该缓冲区进行
在主从复制过程中,从库也是作为 Redis 客户端存在的,通过该缓冲区将命令发送到从库
repl_backlog_buffer
为了解决从库断开重连后,能够快速找到主从差异而设计的环形缓冲区(循环写),从而避免全量同步带来的性能开销
实现
主从复制共有三种模式:全量复制、基于长连接的命令传播、增量复制
全量复制(第一次同步)
- 第一阶段:主从库间建立连接、协商同步
- 第二阶段:主库将所有数据同步给从库。主库异步生成 RDB 文件发送给从库,从库接收后先清空当前数据库,然后加载 RDB 文件
- 第三阶段:主库会把第二阶段执行过程中新收到的写命令再发送给从库
命令传播
主从库在完成第一次同步后,双方之间就会维护一个 TCP 长连接,后续主库可以通过这个连接继续将写操作命令发送给从库
主库不仅会将写操作命令发送给从库,还会将写操作命令写入到 repl_backlog_buffer,并使用 master_repl_offset 来记录自己「写」到的位置。每个从库也会使用 slave_repl_offset 来记录自己「读」到的位置。这些都是为了应对网络断开的情况
增量复制
如果主从库命令传播时发生网络断开,重连后要都是直接重新进行一次全量复制,开销会非常大,所以为了尽量避免不必要的全量复制,引入了增量复制
重连后,从库将自己的 slave_repl_offset 发送给主库,主库根据自己的 master_repl_offset 和从库发来的 slave_repl_offset 之间的差距,然后来决定对该从库执行哪种同步操作:
- 如果该从库要读取的数据都在 repl_backlog_buffer 缓冲区里,那么主库只用将这些数据发送给从库,即增量复制
- 否则主库将采用全量复制的方式
主-从-从 模式
如果从库过多而且都与主库进行全量同步的话,会带来两个问题:
- 主库主线程忙于创建子线程生成 RDB 文件
- 传输 RDB 文件会占用主库的网络带宽,会对主库响应命令请求产生影响
为了分摊主库压力,可以通过“主-从-从”模式将主库生成 RDB 和传输 RDB 的压力以级联的方式分散到从库上
简单来说,在部署主从集群的时候可以手动选择一个从库(比如内存配置较高的从库)用于级联其他的从库。然后再选择一些从库(例如三分之一的从库)让它们与刚才选择的从库建立主从关系
其他面试题
为什么全量复制使用 RDB 文件而不是 AOF 文件?
- RDB 文件内容是经过优化压缩的二进制数据,文件很小;AOF 文件记录的是每一次写操作的命令,写操作多的话文件会变得很大,而且还会包括很多对同一个 key 的多次冗余操作(中间产物),所以选择 RDB 文件可以尽量降低对主库网络带宽的消耗
- 从库在加载 RDB 文件时,一是文件小,读取整个文件的速度会很快;二是因为 RDB 文件存储的都是二进制数据,从库直接按照 RDB 协议解析还原数据即可,速度会非常快,而 AOF 需要依次重放每个写命令,这个过程会经历冗长的处理逻辑,恢复速度相比 RDB 会慢得多
- AOF 默认不开启,如果仅仅为了主从复制而开启 AOF,会引起一些不必要的麻烦
什么是无磁盘复制模式
Redis 默认是磁盘复制,即主库给从库发送数据需要先写盘。毕竟写盘代价比较大,所以 Redis 从 2.8.18 版本开始尝试支持无磁盘的复制,即不经过磁盘直接从内存发送
主从复制架构中,过期 key 如何处理?
主库处理了一个 key 或者通过淘汰算法淘汰了一个 key,然后主库模拟一条 del 命令发送给从库,从库收到该命令后就进行删除 key 的操作
redis 主从如何做到故障自动切换?
由哨兵自动完成故障发现和故障转移并通知给应用方
怎么判断 redis 某个节点是否正常工作?
心跳检测机制
redis 是同步复制还是异步复制?
异步。主库每次收到写命令之后,先写到内部的缓冲区,然后异步发送给从库
哨兵
作用
实现主从节点故障转移
它会监测主节点是否存活,如果发现主节点挂了,它就会选举一个从节点切换为主节点,并且把新主节点的相关信息通知给从节点和客户端(监控、选主、通知)
如何判断主节点故障?
首先为减少误判,哨兵在部署的时候不会只部署一个,而是以哨兵集群的方式存在(至少三个),通过多个哨兵节点一起判断
主观下线
每个哨兵会每隔 1 秒给所有主从节点发送 PING 命令。节点收到 PING 命令后要发送一个响应命令给哨兵
如果有节点没有在规定的时间内响应某哨兵的 PING 命令,该哨兵就会将其标记为「主观下线」
主节点没有及时响应可能只是因为主节点的系统压力比较大或者网络发送了拥塞,而不是真的故障,所以就先标记为「主观下线」
客观下线
所以当一个哨兵判断主节点为「主观下线」后,就会向其他哨兵发起命令,其他哨兵收到这个命令后,就会根据自身和主节点的网络状况,做出赞成投票或者拒绝投票的响应
当赞同票数达到该哨兵配置文件中的 quorum 配置项设定的值后,主节点就会被该哨兵标记为「客观下线」
当主节点被判定为客观下线后,就要从节点中选出一个来做新的主节点
由哪个哨兵负责主从故障转移?
负责主从故障转移的哨兵将从标记主节点为客观下线的哨兵中选出,这些哨兵会向其他所有哨兵发送命令,表明自己希望负责主从切换,并让所有其他哨兵对它进行投票。胜出需要满足以下两个条件:
- 第一,拿到半数以上的赞成票(候选者会投给自己)
- 第二,拿到的票数同时还需要大于等于自己配置文件中的 quorum 值
显然,哨兵数量和 quorum 值会直接影响到哨兵集群是否能够判定主节点客观下线以及完成主从切换,为了避免人工介入,quorum 的值建议设置为哨兵个数的二分之一(向上取整),且哨兵个数应该为奇数
主从故障转移的过程
在已下线主节点属下的所有从节点里挑选出一个从节点,并将其转换为主节点
怎么挑选?首先排除网络状态不好的从节点(主从断连超过 10 次),然后 order by 优先级,复制进度 desc,ID 号
选出后哨兵向该从节点发送命令让其解除从节点的身份
让已下线主节点属下的所有从节点修改复制目标为新主节点
将新主节点的 IP 地址和信息,通过发布/订阅机制通知给客户端
继续监视旧主节点,当这个旧主节点重新上线时,将它设置为新主节点的从节点
哨兵集群是如何组成的?
设置单个哨兵时,只需要配置主节点名字、主节点的 IP 地址和端口号以及 quorum 值就完成了设置,而不需要填其他哨兵节点的信息,那这个哨兵是如何感知到其他哨兵,其他哨兵又是如何感知到这个哨兵?
答案是通过 Redis 的发布/订阅机制:主节点上有一个名为
__sentinel__:hello
的频道,不同哨兵就是通过它来相互发现,实现互相通信的。每个哨兵会把自己的 IP 地址和端口号发布到该频道,同时订阅该频道从上面获取其他哨兵的 IP 地址和端口号,然后相互建立网络连接哨兵集群会对从节点的运行状态进行监控,那哨兵集群如何知道从节点的信息?
主节点知道所有从节点的信息,所以哨兵会每 10 秒一次的频率向主节点发送 INFO 命令来获取所有从节点的信息,于是就能和从节点建立连接并进行监控
分片
为什么引入
读写分离架构的缺陷
不适合写多读少的应用
主从模式下实现读写分离的架构,可以让多个从服务器承载「读流量」,但面对「写流量」时,始终是只有主服务器在抗
不管是 Master 还是 Slave,每个节点都必须保存完整的数据,如果在数据量很大的情况下,集群的扩展能力受限于单个节点的存储能力,只能靠「纵向扩展」升级节点硬件能力。但升级至一定程度下,就不划算了。纵向扩展意味着「大内存」,Redis 持久化和主从复制(全量复制)的成本也会加大
Redis Cluster 优点
- Redis Cluster 是一种服务器分片(Sharding)技术,它既支持纵向扩展也就是主从复制,也支持横向扩展,实现了 Redis 的分布式存储,也就是将原本一台 Redis 实例维护的数据,改为由多个 Redis 实例共同维护
- 去中心架构,节点对等,可支持上千个节点
- 高可用性,提供了故障转移的功能,不再需要额外的哨兵集群
Hash Slot
常见的数据分片的策略有:范围分片、Hash 分片、一致性 Hash 算法、Hash Slot 哈希槽等
Redis Cluster 采用的是哈希槽
基本原理
整个 Redis Cluster 包含16384(0~16383)即 $2^{14}$ 个哈希槽,采用高质量的散列算法 CRC16(key) & 16383(拿 key 的 CRC16 循环冗余校验码对 16384 取模)使得存储在 Redis Cluster 中的所有键均匀映射到这些 slot 中,每一个节点负责维护一部分槽以及槽所映射的键值数据。至于哈希槽的分配,可以平均分,也可以手动设置
在集群的中每个 Redis 实例都会向其他实例传播自己所负责的哈希槽有哪些。这样一来,每台 Redis 实例都记录着所有哈希槽与实例的映射关系
特点
解耦数据和节点之间的关系,简化了扩容和收缩难度
节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据
支持节点、槽和键之间的映射查询,用于数据路由,在线集群伸缩等场景
分片实现
客户端分片
客户端直接选择正确的节点来写入和读取指定键
代理协助分片
客户端发送请求到代理上而不是 Redis 实例上,代理负责转发请求到正确的 Redis 实例,并返回响应给客户端
查询路由
客户端随机发送请求到一个实例,这个实例负责转发请求到正确的节点
为什么哈希槽是 16384 个?
Redis 实例之间通讯会相互交换槽信息,那如果槽过多,意味着网络包会变大,网络包变大,也就意味着会过度占用网络的带宽
另一方面,Redis 作者认为集群在一般情况下不会超过 1000 个实例,所以就取了 16384 个,认为可以将数据合理打散至 Redis 集群中的不同实例,又不会在交换数据时导致带宽占用过多
为什么不采用一致性 Hash 算法?
一致性 Hash 是什么
一致性 Hash 是一个 0 到 $2^{32}$-1 的闭合环型结构,拥有 $2^{32}$ 个桶空间,数据 key 和服务器节点都会经过 Hash 取模映射分布在这个哈希环上,一个数据所属的服务器就是在哈希环上位于它顺时针方向最近的服务器
一致性哈希算法比「传统固定取模」的好处就是:如果服务器集群中需要新增或删除某实例,只会影响一小部分的数据
不使用一致性哈希的原因
一致性哈希的节点分布基于圆环,无法很好的手动设置数据分布,比如有些节点的硬件差,希望少存一点数据,这种就无法实现。而哈希槽可以很灵活的配置每个节点占用哈希槽的数量
一致性哈希的某个服务器节点宕机后会将数据迁移到顺时针方向的下一个服务器结点。宕机原因只是因为服务器本身出现故障倒还好,如果是因为承受不了热点 key 的高并发访问,那下一台服务器大概率也承受不了,最终一损全损
在采用哈希槽的情况下中,如果一个主节点及其从节点全部宕机的其情况下使得它们负责的槽无法被获取,会返回错误信息(默认情况下整个集群还会不可用),这时候就需要人工介入处理,让问题提前暴露,强行人工高可用
Gossip 协议
Redis 集群是去中心化的,节点之间对等,就是因为通信底层使用了 gossip 协议
Gossip 协议又称 epidemic 协议,是基于流行病传播方式的节点或者进程之间信息交换的 P2P 协议,在分布式系统中被广泛使用
Gossip 协议的最大的好处是,即使集群节点的数量增加,每个节点的负载也不会增加很多,几乎是恒定的,因为节点是周期性地随机向少数几个节点发送消息,消息最终是通过多个轮次的散播而到达全网的。这就允许集群规模能横向扩展到数千个节点
扩容
首先将新节点加入到集群中,可以通过在集群中任何一个客户端执行 cluster meet 新节点 ip:端口,新结点默认是主节点
迁移数据
大致流程是,首先需要确定哪些槽需要被迁移到目标节点,然后获取槽中 key,将槽中的 key 全部迁移到目标节点,然后向集群所有主节点广播槽(数据)全部迁移到了目标节点
收缩
- 首先需要确认下线节点是否有负责的槽,如果有,需要把槽迁移到其他节点(原理与之前节点扩容的迁移槽过程一致),保证节点下线后整个集群槽节点映射的完整性
2) 如果下线节点不负责槽或者本身是从节点时,就通知集群内其他节点忘记下线节点,当所有的节点忘记改节点后可以正常关闭
请求重定向
节点收到请求后的处理过程:
- 计算出请求的 key 对应的槽,查询负责该槽的节点指针
- 节点指针不指向自己,则向客户端返回 Moved 重定向,客户端根据 MOVED 重定向所包含的内容确定目标节点重新发送请求并更新本地缓存
- 节点指针指向自己,且 key 在 slot 中,则返回该 key 对应结果;若正发生槽迁移,则向客户端返回 ASK 重定向,让客户端去请求新的 Redis 实例
ASK 和 MOVED 虽然都是对客户端的重定向控制,但是有着本质区别。ASK 重定向说明集群正在进行 slot 数据迁移,客户端无法知道什么时候迁移完成,因此只能是临时性的重定向,客户端不会更新 slot 到 Redis 节点的映射缓存。但是 MOVED 重定向说明键对应的槽已经明确指定到新的节点,因此需要更新 slot 到 Redis 节点的映射缓存
故障转移
当 Redis 集群内少量节点出现故障时通过自动故障转移保证集群可以正常对外提供服务
当某一个 Redis 节点客观下线时,Redis 集群会从其从节点中选出一个替代它,从而保证集群的高可用性。具体过程不赘述,是和哨兵类似的投票机制
但是有一点要注意,默认情况下,当集群 16384 个槽任何一个没有指派到节点时,整个集群都是不可用的。因此当持有槽的主节点下线时,从故障发现到完成转移期间整个集群是不可用状态。大多数业务都无法忍受这种情况,可以手动修改配置,让主节点故障时只影响它负责槽的相关命令执行,而不会影响其他主节点的可用性
缺陷
- 数据通过异步复制,不保证数据的强一致性
- Slave 在集群中充当冷备,不能缓解读压力
Redisson 分布式锁
Redisson 的分布式锁基于 Redis 的原子操作和 Lua 脚本实现。
加锁流程
当调用
RLock.lock()
或RLock.tryLock()
时,Redisson 会执行以下步骤:生成锁的唯一标识
Redisson 为当前线程生成一个唯一标识(例如 uuid:threadId),用于标识锁的持有者。
尝试加锁
Redisson 使用 Redis 的 SET 命令尝试在 Redis 中创建一个键值对:
1
SET myLock "uuid:threadId" NX PX 30000
Key 是锁的名称(例如 myLock),Value 是锁的唯一标识(通常是 UUID + 线程 ID),NX 表示只有键不存在时才会设置成功,PX 设置键的过期时间(单位:毫秒)。
如果 SET 命令成功,表示当前线程获取到了锁;如果 SET 命令失败(键已存在),表示锁已被其他线程持有。
重试机制
如果加锁失败,Redisson 会根据配置的重试次数和超时时间进行重试,直到加锁成功或超时。
锁的续期
如果加锁成功,Redisson 会启动一个后台线程(看门狗),定期检查锁是否仍然被当前线程持有。如果是,则自动延长锁的过期时间(默认每隔 10 秒续期一次)。
解锁流程
当调用 RLock.unlock() 时,Redisson 会执行以下步骤:
- Redisson 通过 Lua 脚本检查当前线程是否是锁的持有者。
- 如果当前线程是锁的持有者,则删除 Redis 中的锁键。
- 如果锁已过期或被其他线程持有,则忽略解锁操作。
确保在
finally
块中释放锁,避免死锁。锁的续期(看门狗机制)
为了防止锁在业务逻辑执行期间过期,Redisson 提供了看门狗机制:
自动续期:在加锁成功后,Redisson 会启动一个后台线程,定期检查锁是否仍然被当前线程持有。如果是,则自动延长锁的过期时间。
默认配置:
- 锁的默认过期时间为 30 秒。
- 看门狗每隔 10 秒检查一次并续期。
如果手动设置了锁的过期时间,看门狗机制会被禁用,不会自动续期。
1
lock.lock(10, TimeUnit.SECONDS); // 锁的过期时间为 10 秒,不会自动续期
业务逻辑在锁过期之前还未完成就完犊子了,所以:
避免手动设置过期时间
合理设置过期时间
手动续期
1
2
3
4
5
6
7
8
9
10try {
// 执行业务逻辑
while (业务逻辑未完成) {
// 手动续期锁
lock.expire(30, TimeUnit.SECONDS);
// 继续执行业务逻辑
}
} finally {
lock.unlock();
}或者:
1
2
3
4
5
6
7
8
9
10
11try {
while (业务逻辑未完成) {
long ttl = lock.remainTimeToLive(); // 获取锁的剩余时间
if (ttl < 10000) { // 如果剩余时间小于 10 秒
lock.expire(30, TimeUnit.SECONDS); // 手动续期
}
// 继续执行业务逻辑
}
} finally {
lock.unlock();
}兜底方案(避免数据不一致)
幂等性设计:确保业务逻辑可以安全地重试。
事务机制:使用数据库事务或其他事务机制,确保数据的一致性。
日志与监控:记录业务逻辑的执行状态,方便后续恢复或补偿。
lock() vs trylock()
主要区别在于等待锁的行为、返回值、锁的持有时间管理以及适用场景。
等待行为:
- lock() 无限等待。一直阻塞,直到成功获取锁。
- trylock() 有限等待。需要显式指定最大等待时间 waitTime,超时返回。如果设置为 0,表示立即返回。
返回值:
- lock() 无返回值。一定会获取锁。
- trylock() 返回 boolean。成功获取锁返回 true,否则返回 false。
锁的持有时间:
- lock() 默认使用看门狗机制,自动续期锁的过期时间。也可以显式设置锁的持有时间 leaseTime
- trylock() 必须显式指定锁的持有时间。不过也支持看门狗机制(leaseTime = -1)
适用场景:
- lock() 必须获取锁的场景,可接受无限等待。
- trylock() 尝试获取锁的场景,需要灵活控制等待时间和锁的持有时间。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 ZERO!