《Redis深度历险》记录
基础与应用
分布式锁
分布式锁用于高并发或分布式事务中保护临界资源。
最常见的实现方式是使用 SET 命令的 NX (不存在时设置)和 PX (设置过期时间)选项:
|
|
lock_name
: 锁的名称。my_random_value
: 唯一标识(通常使用 UUID)。NX
: 只有键不存在时才设置。PX 30000
: 设置 30 秒的过期时间。
释放分布式锁时需要验证唯一标识是否匹配,防止误删其他客户端的锁。
超时问题
超时问题有三类:
- 锁过期,可以通过锁续约机制,即获取锁后启动一个守护线程,定期检查锁是否仍由自己持有,如果是则延长锁的过期时间。
- 客户端获取锁后崩溃,没有主动释放锁,锁就只能等待超时后才能被其他客户端获取。所以要设置合理的锁超时时间。
- Redis 服务器和客户端之间的时钟不同步,可能导致锁提前或延后过期。尽量确保服务器和客户端时钟同步(NTP),同时适当增加锁的超时时间作为缓冲。
分布式锁的实现有 Redlock 算法、go-redis 中的简易 mutex
分布式锁。
延时队列
通过 List 来作为简易的消息队列使用。
通过 rpush
, lpush
控制入队,rpop
, lpop
控制出队。
使用 List 实现延时队列会出现一个问题,假设 List 为空,那么之后的 pop
操作就是死循环。此时可以使用阻塞读来避免这种情况,即使用 blpop
, brpop
两个命令代替之前的出队命令。阻塞读在队列没有数据的时候,会立即进入休眠状态。
同时需要注意,List 长时间为空,Redis 客户端连接可能会因为空闲而被回收。所以需要加入重试机制。
结合之间的分布式锁内容,假设有一个操作加锁失败,那么可以怎么做:
- 抛出异常,通知用户稍后重试。
- sleep,等待锁释放。
- 操作扔进延时队列,异步处理。
对于上面的做法 3 ,取出来又失败了怎么办?解决方法有以下几种:
- 有限重试 + 指数退避:简单而有效。
- 再维持一个死信队列,超过最大次数后转入死信队列。
- 动态优先级:类似第一种,但是复杂度会高一些,因为涉及到业务的优先级判定。
zset 实现,更优的选择
对于 zset 的两个值,Score 为任务执行时间戳,Member 为序列化的任务数据。
- 添加任务:
ZADD delay_queue <执行时间戳> <任务数据>
。 - 获取到期任务:
ZRANGEBYSCORE delay_queue 0 <当前时间戳>
。 - 移除已处理任务:
ZREM delay_queue <任务数据>
。
以下是对比。
特性 | ZSet实现 | List实现 |
---|---|---|
定时精度 | 精确到秒级 | 依赖消费者轮询间隔 |
任务排序 | 自动按时间排序 | 需要额外排序逻辑 |
批量获取 | 一次性获取所有到期任务 | 只能逐个获取 |
内存效率 | 较高(自动清理已执行任务) | 较低(需手动删除) |
实现复杂度 | 中等 | 简单 |
适合场景 | 精确延时任务 | 简单延时/普通队列 |
位图
位图就是 []byte
,即字符数组。
位图的操作如下:
同时位图还支持位运算(AND 等逻辑运算)。
位图的应用场景:
- 典型的就是用户签到系统,可以快速查询定位用户签到情况。
- 可以通过
BITCOUNT
与位运算实现活跃用户统计。 - 实现布隆过滤器(后面会说明)。
- RBAC ,快速检查用户权限。
大位图与稀疏位图
对于大位图,可以采取以下操作进行优化:
- 分片。
- 使用
BITFIELD
批量操作。 - 定期合并碎片。
对于稀疏位图,Redis 做了以下优化:
- 用 RLE 编码压缩:连续相同值的位会被压缩。
- 分块存储:只存储非零的块。
HyperLogLog
HyperLogLog 是 Redis 提供的一种概率数据结构,用于高效地估计集合的基数(cardinality,即集合中不重复元素的数量)。它的核心原理基于以下特点:
- 概率算法:HyperLogLog 使用概率统计方法来估计基数,不是精确计算。
- 固定内存占用:每个 HyperLogLog 结构只需要 12KB 内存。
- 误差率低:标准误差率约为 0.81%,可以配置更低的误差率。
- 哈希函数:使用哈希函数将输入元素映射到二进制串。
它有以下几个基本操作:
PFADD key element [element ...]
:添加元素到 HyperLogLog 。PFCOUNT key [key ...]
:返回 HyperLogLog 的基数估计值。PFMERGE destkey sourcekey [sourcekey ...]
:合并多个 HyperLogLog 。
HyperLogLog 比较适合以下场景:
- 网站的 UV 统计:统计每天/每周/每月的独立访客数,相比使用集合存储每个用户 ID,HyperLogLog 节省大量内存。
- 其他大规模数据的去重场景。
布隆过滤器(Bloom Filter)
布隆过滤器是一种空间高效的概率型数据结构,用于判断一个元素是否存在于集合中。它的核心特点是:
- 可能误判但不会漏判:如果布隆过滤器说某个元素不存在,则一定不存在;如果说存在,则可能存在(有一定误判率)。
- 空间效率极高:相比存储完整数据集,布隆过滤器使用的内存极少。
- 查询效率高:查询时间与集合大小无关,始终是 $O(k)$ ,$k$ 是哈希函数数量。
Redis 4.0+ 通过 BF
命令组支持布隆过滤器,基本命令:
BF.RESERVE key error_rate capacity
:创建布隆过滤器。BF.ADD key item
:添加元素。BF.MADD key item1 item2 ...
:批量添加。BF.EXISTS key item
:检查元素是否存在。BF.MEXISTS key item1 item2 ...
:批量检查。
Redis 的布隆过滤器会自动扩容。
它的应用场景有这些:
- 缓存穿透防护:在查询缓存前先检查布隆过滤器,避免对数据库的无效查询。
- 垃圾邮件过滤、爬虫 URL 去重等。
原理
- 初始化:创建一个长度为 m 的位数组(bit array),所有位初始化为 0 。
- 添加元素:
- 使用 k 个不同的哈希函数对元素进行计算,得到 k 个哈希值。
- 将这些哈希值对 m 取模,得到 k 个数组位置。
- 将这些位置的值设为 1 。
- 查询元素:
- 同样使用 k 个哈希函数计算元素的位置。
- 如果所有对应位置的值都为 1,则返回"可能存在"。
- 如果有任何一个位置为 0,则确定"不存在"。
可以看到布隆过滤器有三个参数,这些参数决定了误判率:
- m:位数组大小(位数)。
- k:哈希函数数量。
- n:预期要插入的元素数量。
无论是在 Redis 中的 BF
指令组中,还是在相关库中,布隆过滤器的使用只会用到两个参数:误判率和预期插入元素数量,而位数组、哈希函数数量两个参数会自动计算。
限流算法
固定窗口计数器
固定窗口计数器算法是最简单的限流算法,它将时间划分为固定大小的窗口(如1秒、1分钟),每个窗口内维护一个请求计数器。
- 时间分割:将时间线划分为固定长度的连续时间段(窗口)。
- 计数机制:每个窗口独立计数,请求到达时当前窗口计数器加1 。
- 限流判断:当窗口内计数超过预设阈值时,拒绝后续请求。
- 窗口重置:时间进入下一个窗口时,计数器清零重新开始计数。
滑动窗口计数器
滑动窗口算法是对固定窗口算法的改进,通过更细粒度的时间记录来模拟滑动窗口效果。
- 时间记录:不再使用固定窗口,而是记录每个请求的时间戳。
- 滑动窗口:定义一个时间范围(如1分钟),只统计这个滑动窗口内的请求。
- 过期清理:持续清理滑动窗口之前的旧请求记录。
- 动态计数:实时计算当前滑动窗口内的请求总数。
漏斗限流
漏斗算法模拟了一个漏水的桶的物理过程:
- 漏斗容器:想象一个容量固定的漏斗。
- 进水过程:请求像水一样流入漏斗。
- 漏水过程:漏斗以恒定速率"漏水"(处理请求)。
- 限流判断:当漏斗中的水(待处理请求)超过容量时,拒绝新请求。
工作流程:
- 每个新请求到达时,计算自上次请求以来的漏水量。
- 更新当前漏斗中的水量(上次水量 - 漏水量)。
- 判断当前水量 + 1(新请求)是否超过容量。
- 不超过则接受请求,更新水量;否则拒绝。
令牌桶
令牌桶算法与漏斗算法相似但理念相反:
- 令牌桶:想象一个装令牌的桶,容量固定。
- 令牌生成:系统以固定速率向桶中添加令牌。
- 请求消耗:每个请求需要获取一个令牌才能被处理。
- 限流判断:当桶中没有令牌时,拒绝新请求。
工作流程:
- 计算自上次请求以来应该新增的令牌数(基于时间间隔和生成速率)。
- 更新当前令牌数(不超过桶容量)。
- 判断是否有足够令牌(至少1个)。
- 有则取走一个令牌接受请求,否则拒绝。
特性 | 固定窗口计数器 | 滑动窗口计数器 | 漏斗限流 | 令牌桶限流 |
---|---|---|---|---|
限流精确度 | 低(有边界问题) | 中 | 高 | 高 |
突发流量处理 | 允许窗口边界突发 | 允许部分突发 | 严格限制 | 允许桶容量内的突发 |
内存消耗 | 很小 | 较大 | 中等 | 中等 |
实现复杂度 | 非常简单 | 中等 | 较复杂 | 较复杂 |
典型应用场景 | 简单限流需求 | 一般API限流 | 严格速率控制的系统 | 需要弹性处理突发的系统 |
SCAN 命令与大 Key 问题
SCAN 是 Redis 提供的渐进式遍历键空间的命令,解决了 KEYS 命令可能阻塞 Redis 的问题。
这是 SCAN 的基本用法:SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]
。
SCAN 命令有以下特点:
- 非阻塞式:不会像 KEYS 命令那样长时间阻塞服务器。
- 渐进式遍历:分批返回结果,避免一次性处理大量数据。
- 游标机制:使用游标(cursor)记录遍历位置。
- 可重复遍历:保证完整遍历所有键(但不保证不重复)。
大 Key 问题
大 Key 是指存储在 Redis 中具有较大体积的单个键值对,通常表现为以下几种形式:
- Value 过大:单个 String 类型的值超过 10KB 。
- 元素过多:Hash、List、Set、ZSet 等集合类型元素数量超过 5000 个。
- 复合结构过大:复杂数据结构(如嵌套 Hash)占用内存过大。
大 Key 的危害:
- 操作耗时增加,阻塞 Redis 单线程,降低持久化速度。
- 内存使用不均,集群环境下数据分片不均。
- 传输大 Key 消耗带宽,客户端处理耗时增加。
可以用 redis-cli
命令检测大 Key :
可以使用 SCAN 命令:
也可以使用 MEMORY USAGE 命令:
|
|
大 Key 有几种解决方法:
- 拆分大 Key。
- 压缩。
- 调整数据结构。
- 设置合理的过期时间。
- 渐进式删除:通过
UNLINK large_key
删除大 Key 。
集群
主从同步
Redis 的主从数据是异步同步,保证最终一致性。
增量同步
Redis 同步的是指令流,主节点会将对自己状态产生影响的指令记录在本地的内存 buffer 里。然后异步同步到从节点。从节点一遍执行同步的指令流来同步状态,一边向主节点反馈自己同步到哪里了。这种方式叫做增量同步。
Redis 的复制内存 buffer 是一个定长循环数组,内容满了会进行覆盖。如果遇到网络分区等状况,主从节点的同步出现延误,那 Redis 主节点 buffer 中待同步指令在同步之前就可能被后续新指令覆盖。为防止这种情况,需要用到快照同步。
快照同步与无盘复制
快照同步非常耗费资源,首先需要在主节点上进行一次 bgsave ,将当前内存的数据全部快照到磁盘中,然后再将快照文件全部传输到从节点。从节点接收完毕后,立即执行一次全量加载,加载之前需要将当前内存中的数据清空,加载完后通知主节点继续进行增量同步。在整个快照同步期间,主节点的 buffer 还在向前移动,如果快照同步的时间过长或复制 buffer 太小,会导致指令被覆盖情况。从节点刚加入集群时,要先进行一次快照同步。
Redis 支持无盘复制。主节点在生成快照时,直接将数据通过内存(套接字)发送给从节点,跳过磁盘持久化步骤。主节点一边生成快照,一边将数据流式传输给从节点,提升效率。好处是减少了复制延迟、避免磁盘 I/O 竞争,缺点就是没有磁盘备份、主节点负载会比较高。
Redis 的 WAIT
指令用于同步复制场景,确保数据写入操作在指定数量的副本节点(从节点)上完成后再返回成功,从而提供更强的数据一致性保证。
Sentinel
Redis Sentinel 是 Redis 官方提供的高可用性(HA)解决方案,用于管理 Redis 主从架构的故障自动转移和监控。
Sentinel 持续检查主节点和从节点的健康状态,当主节点故障时,Sentinel 会自动将一个从节点提升为主节点,并让其他从节点复制新主节点,确保服务持续可用。客户端通过 Sentinel 获取当前的主节点地址(动态更新),无需硬编码配置。可通过 API 或脚本通知管理员集群状态变化(如主节点切换)。
Sentinel 本身以多个实例(通常 ≥3)部署,避免单点故障,通过 Raft 算法决策故障转移。
Leader 选举
在故障检测阶段,分为两种情况:
- 主观下线:单个 Sentinel 检测到主节点无响应。
- 客观下线:多个 Sentinel 确认主节点故障(达成共识)。
当主节点缺失,就会触发 Raft 算法,进入主节点的选举。Sentinel Leader 选举过程有三步:
- 竞选 Leader:
- 检测到客观下线的 Sentinel 发起竞选,向其他 Sentinel 发送请求投票。
- 投票规则
- 每个 Sentinel 在同一轮内只能投一票,先到先得。
- 得票首先超过半数的 Sentinel 成为 Leader。
- 选举超时
- 若未选出 Leader,随机等待后重试(避免冲突)。
Leader 主导的故障转移
在故障转移阶段,由 Leader 主导:
- 筛选新主节点:
- 过滤不合格从节点:主观下线、断线、最后一次 PING 响应超时(>5秒)的节点。
- 优先级排序:
slave-priority
值最低的节点优先,若优先级相同,选择 复制偏移量(repl_offset
)最大 的节点(数据最接近旧主节点),若偏移量相同,选择 RunID 字典序最小 的节点(随机选择依据)。
- 提升新主节点:向目标从节点发送
REPLICAOF NO ONE
,使其成为主节点。该节点脱离复制关系,开始接受写请求,并生成自己的复制流。 - 重配置从节点:向其他从节点发送
REPLICAOF <new-master>
,指向新主节点。从节点会清空旧数据,触发全量或增量同步。 - 更新集群状态:通知所有 Sentinel 和客户端主节点已变更。
只有 Leader 能执行故障转移,Follower 仅响应投票。
相比于经典 Raft 设计,Sentinel 的 Raft 仅用于 Leader 选举,同时决策目标单一、无复杂状态机。
Cluster
Redis Cluster 是 Redis 官方提供的分布式数据分片解决方案,用于解决单机 Redis 的性能和存储瓶颈。它通过数据分片(Sharding)、高可用(HA)和自动故障转移来提供可扩展的 Redis 服务。
数据分片
Redis Cluster 将整个数据空间划分为 16384 个槽位,每个 key 通过 CRC16 哈希函数计算后,映射到其中一个槽位。这种方式称为哈希槽机制。每个节点负责一部分槽位(如 Node1 负责 0-5000,Node2 负责 5001-10000)。槽位支持动态扩缩。
CRC(Cyclic Redundancy Check):一种校验码算法,用于检测数据传输或存储中的错误。
CRC16:生成 16 位(2 字节)校验值的 CRC 算法,多项式为
0x1021
(Redis 使用的标准)。一致性哈希算法:用于分布式系统数据分片,能在节点增减时最小化数据迁移量。
- 哈希环:
- 将哈希值空间(如
0 ~ 2^32-1
)首尾相连形成一个环。- 节点和数据的 key 通过哈希函数映射到环上。
- 数据定位规则:每个 key 归属于环上顺时针方向最近的节点。
- 新增节点仅影响相邻区间数据,其他数据无需迁移。移除节点仅该节点上的数据需迁移到下一节点。
一致性哈希算法特点:
- 节点变化时,平均仅需迁移
K/N
的数据(K
=数据总量,N
=节点数),而传统哈希(如hash(key) % N
)需迁移近 100%。- 基础一致性哈希可能导致数据倾斜。每个物理节点映射为多个虚拟节点,均匀分散在环上。
- 新增节点不会导致已有数据重新映射到旧节点,避免无效迁移。
为什么选择 CRC16 :
- CRC16 是一种轻量级算法,仅需少量位操作。
- CRC16 的散列特性良好,能有效避免数据倾斜,哈希分布均匀。
为什么不用一致性哈希算法:
- 槽位可手动分配到指定节点,避免一致性哈希的随机性。
- 槽位是固定单元,迁移时只需移动槽内所有 key,无需遍历整个哈希环。
- 槽位映射表明确,主从切换无需重新哈希。
为什么槽位是 16384 个?
- 避免数据倾斜,同时减少节点间元数据同步开销。
- Redis 作者认为 16K 在集群规模(≤1000节点)和内存占用之间达到平衡。
数据分片存在一定限制:
跨槽位操作限制:事务中所有 key 必须在统一槽位。
解决方法:用 Hash Tag
{}
强制 key 映射到同一槽位。
数据倾斜问题:热点 key 或 Hash Tag 使用不当导致某些节点负载过高。
- 解决方法:监控槽位分布,重新分配槽位。
槽位迁移
槽位迁移是 Redis Cluster 的核心功能之一,用于动态调整数据分布,实现集群的弹性扩缩容、负载均衡或故障恢复。
将哈希槽(Hash Slot)及其包含的 key 从一个节点转移到另一个节点的过程叫做槽位迁移。槽位迁移分为几步:
准备:
标记源节点:通知源节点(迁移方)某个槽位准备迁出。
- 此后,源节点会:
- 正常处理该槽位的读请求。
- 对写请求,若 key 未迁走则处理;若已迁走则返回
ASK
重定向。
- 此后,源节点会:
标记目标节点:目标节点会接受该槽位的 key,但仅处理带有
ASKING
命令的请求。
数据迁移:
- 逐 key 迁移:从源节点向目标节点迁移数据。
- 此命令是原子操作:key 会从源节点删除,并同步到目标节点。
- 可批量迁移,避免阻塞集群。
- 逐 key 迁移:从源节点向目标节点迁移数据。
完成迁移:
- 更新集群配置:通知所有节点槽位归属变更。
对于在迁移期间的请求处理:
- 未迁移的 key:由源节点正常处理。
- 已迁移的 key:
- 源节点返回
ASK <slot> <target-node>
重定向。 - 客户端需先向目标节点发送
ASKING
,再执行操作(临时访问)。
- 源节点返回
- 迁移完成的 key:客户端收到
MOVED
重定向,更新本地缓存。
高可用
Cluster 支持主从复制,主节点故障时,从节点自动提升为新主节点。
节点间通过 Gossip 协议交换状态信息。若多数主节点认为某节点宕机,则触发故障转移。
重定向
支持 Cluster 协议的客户端会缓存槽位-节点映射表,直接路由请求:
- 计算 key 的 slot:
CRC16(key) % 16384
。 - 查询本地缓存的槽位分布,发送请求到对应节点。
请求重定向分为两类:
- MOVED 重定向:如果客户端访问了错误的节点,Redis 返回
MOVED
响应。 - ASK 重定向:在槽位迁移过程中,若数据尚未完全迁移源节点会返回
ASK
重定向,客户端需先发送ASKING
命令,再执行操作。
拓展
过期策略
过期策略分为两种:
- 惰式删除:当客户端尝试访问一个 key 时,Redis 会检查该 key 是否已过期。节省 CPU 资源,仅在使用时检查。若 key 长期不被访问,即使已过期也会占用内存(内存泄漏风险)。
- 定时删除:Redis 周期性(默认每秒 10 次)随机抽取部分 key 检查过期时间。占用少量 CPU 资源,可能仍有部分过期 key 未被清理。
LRU
当内存达到 maxmemory
限制时,Redis 会根据配置的淘汰策略删除 key 以释放空间。
通过 maxmemory-policy
配置:
策略 | 规则 | 适用场景 |
---|---|---|
noeviction | 不淘汰,写入操作返回错误(默认策略) | 数据不可丢失的严格场景 |
allkeys-lru | 从所有 key 中淘汰最近最少使用(LRU)的 key | 通用场景,关注热点数据 |
volatile-lru | 仅从设置了过期时间的 key 中淘汰 LRU key | 需保留持久化数据 |
allkeys-random | 随机删除所有 key | 无明确访问模式,快速清理 |
volatile-random | 随机删除带过期时间的 key | 混合使用持久化和临时数据 |
volatile-ttl | 优先删除剩余存活时间最短(TTL)的 key | 短期临时数据优先清理 |
- 对缓存类数据使用
allkeys-lru
+ 合理 TTL。 - 对持久化数据使用
volatile-lru
或noeviction
。 - 高频写入场景监控
evicted_keys
指标,调整淘汰策略。