《Redis深度历险》记录

基础与应用

分布式锁

分布式锁用于高并发或分布式事务中保护临界资源。

最常见的实现方式是使用 SET 命令的 NX (不存在时设置)和 PX (设置过期时间)选项:

1
SET lock_name my_random_value NX PX 30000
  • 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 客户端连接可能会因为空闲而被回收。所以需要加入重试机制。

结合之间的分布式锁内容,假设有一个操作加锁失败,那么可以怎么做:

  1. 抛出异常,通知用户稍后重试。
  2. sleep,等待锁释放。
  3. 操作扔进延时队列,异步处理。

对于上面的做法 3 ,取出来又失败了怎么办?解决方法有以下几种:

  • 有限重试 + 指数退避:简单而有效。
  • 再维持一个死信队列,超过最大次数后转入死信队列。
  • 动态优先级:类似第一种,但是复杂度会高一些,因为涉及到业务的优先级判定。

zset 实现,更优的选择

对于 zset 的两个值,Score 为任务执行时间戳,Member 为序列化的任务数据。

  1. 添加任务:ZADD delay_queue <执行时间戳> <任务数据>
  2. 获取到期任务:ZRANGEBYSCORE delay_queue 0 <当前时间戳>
  3. 移除已处理任务:ZREM delay_queue <任务数据>

以下是对比。

特性ZSet实现List实现
定时精度精确到秒级依赖消费者轮询间隔
任务排序自动按时间排序需要额外排序逻辑
批量获取一次性获取所有到期任务只能逐个获取
内存效率较高(自动清理已执行任务)较低(需手动删除)
实现复杂度中等简单
适合场景精确延时任务简单延时/普通队列

位图

位图就是 []byte ,即字符数组。

位图的操作如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 设置指定位的值
SETBIT key offset value  # value 只能是 0 或 1

# 获取指定位的值
GETBIT key offset

# 统计值为 1 的位数
BITCOUNT key [start end]

# 查找第一个设置为 0/1 的位
BITPOS key bit [start] [end]

同时位图还支持位运算(AND 等逻辑运算)。

位图的应用场景:

  • 典型的就是用户签到系统,可以快速查询定位用户签到情况。
  • 可以通过 BITCOUNT 与位运算实现活跃用户统计。
  • 实现布隆过滤器(后面会说明)。
  • RBAC ,快速检查用户权限。

大位图与稀疏位图

对于大位图,可以采取以下操作进行优化:

  • 分片。
  • 使用 BITFIELD 批量操作。
  • 定期合并碎片。

对于稀疏位图,Redis 做了以下优化:

  • 用 RLE 编码压缩:连续相同值的位会被压缩。
  • 分块存储:只存储非零的块。

HyperLogLog

HyperLogLog 是 Redis 提供的一种概率数据结构,用于高效地估计集合的基数(cardinality,即集合中不重复元素的数量)。它的核心原理基于以下特点:

  1. 概率算法:HyperLogLog 使用概率统计方法来估计基数,不是精确计算。
  2. 固定内存占用:每个 HyperLogLog 结构只需要 12KB 内存。
  3. 误差率低:标准误差率约为 0.81%,可以配置更低的误差率。
  4. 哈希函数:使用哈希函数将输入元素映射到二进制串。

它有以下几个基本操作:

  • PFADD key element [element ...]:添加元素到 HyperLogLog 。
  • PFCOUNT key [key ...]:返回 HyperLogLog 的基数估计值。
  • PFMERGE destkey sourcekey [sourcekey ...]:合并多个 HyperLogLog 。

HyperLogLog 比较适合以下场景:

  • 网站的 UV 统计:统计每天/每周/每月的独立访客数,相比使用集合存储每个用户 ID,HyperLogLog 节省大量内存。
  • 其他大规模数据的去重场景。

布隆过滤器(Bloom Filter)

布隆过滤器是一种空间高效的概率型数据结构,用于判断一个元素是否存在于集合中。它的核心特点是:

  1. 可能误判但不会漏判:如果布隆过滤器说某个元素不存在,则一定不存在;如果说存在,则可能存在(有一定误判率)。
  2. 空间效率极高:相比存储完整数据集,布隆过滤器使用的内存极少。
  3. 查询效率高:查询时间与集合大小无关,始终是 $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 去重等。

原理

  1. 初始化:创建一个长度为 m 的位数组(bit array),所有位初始化为 0 。
  2. 添加元素:
    • 使用 k 个不同的哈希函数对元素进行计算,得到 k 个哈希值。
    • 将这些哈希值对 m 取模,得到 k 个数组位置。
    • 将这些位置的值设为 1 。
  3. 查询元素:
    • 同样使用 k 个哈希函数计算元素的位置。
    • 如果所有对应位置的值都为 1,则返回"可能存在"。
    • 如果有任何一个位置为 0,则确定"不存在"。

可以看到布隆过滤器有三个参数,这些参数决定了误判率:

  • m:位数组大小(位数)。
  • k:哈希函数数量。
  • n:预期要插入的元素数量。

无论是在 Redis 中的 BF 指令组中,还是在相关库中,布隆过滤器的使用只会用到两个参数:误判率和预期插入元素数量,而位数组、哈希函数数量两个参数会自动计算。

限流算法

固定窗口计数器

固定窗口计数器算法是最简单的限流算法,它将时间划分为固定大小的窗口(如1秒、1分钟),每个窗口内维护一个请求计数器。

  • 时间分割:将时间线划分为固定长度的连续时间段(窗口)。
  • 计数机制:每个窗口独立计数,请求到达时当前窗口计数器加1 。
  • 限流判断:当窗口内计数超过预设阈值时,拒绝后续请求。
  • 窗口重置:时间进入下一个窗口时,计数器清零重新开始计数。

滑动窗口计数器

滑动窗口算法是对固定窗口算法的改进,通过更细粒度的时间记录来模拟滑动窗口效果。

  • 时间记录:不再使用固定窗口,而是记录每个请求的时间戳。
  • 滑动窗口:定义一个时间范围(如1分钟),只统计这个滑动窗口内的请求。
  • 过期清理:持续清理滑动窗口之前的旧请求记录。
  • 动态计数:实时计算当前滑动窗口内的请求总数。

漏斗限流

漏斗算法模拟了一个漏水的桶的物理过程:

  • 漏斗容器:想象一个容量固定的漏斗。
  • 进水过程:请求像水一样流入漏斗。
  • 漏水过程:漏斗以恒定速率"漏水"(处理请求)。
  • 限流判断:当漏斗中的水(待处理请求)超过容量时,拒绝新请求。

工作流程:

  1. 每个新请求到达时,计算自上次请求以来的漏水量。
  2. 更新当前漏斗中的水量(上次水量 - 漏水量)。
  3. 判断当前水量 + 1(新请求)是否超过容量。
  4. 不超过则接受请求,更新水量;否则拒绝。

令牌桶

令牌桶算法与漏斗算法相似但理念相反:

  • 令牌桶:想象一个装令牌的桶,容量固定。
  • 令牌生成:系统以固定速率向桶中添加令牌。
  • 请求消耗:每个请求需要获取一个令牌才能被处理。
  • 限流判断:当桶中没有令牌时,拒绝新请求。

工作流程:

  1. 计算自上次请求以来应该新增的令牌数(基于时间间隔和生成速率)。
  2. 更新当前令牌数(不超过桶容量)。
  3. 判断是否有足够令牌(至少1个)。
  4. 有则取走一个令牌接受请求,否则拒绝。
特性固定窗口计数器滑动窗口计数器漏斗限流令牌桶限流
限流精确度低(有边界问题)
突发流量处理允许窗口边界突发允许部分突发严格限制允许桶容量内的突发
内存消耗很小较大中等中等
实现复杂度非常简单中等较复杂较复杂
典型应用场景简单限流需求一般API限流严格速率控制的系统需要弹性处理突发的系统

SCAN 命令与大 Key 问题

SCAN 是 Redis 提供的渐进式遍历键空间的命令,解决了 KEYS 命令可能阻塞 Redis 的问题。

这是 SCAN 的基本用法:SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]

SCAN 命令有以下特点:

  1. 非阻塞式:不会像 KEYS 命令那样长时间阻塞服务器。
  2. 渐进式遍历:分批返回结果,避免一次性处理大量数据。
  3. 游标机制:使用游标(cursor)记录遍历位置。
  4. 可重复遍历:保证完整遍历所有键(但不保证不重复)。

大 Key 问题

大 Key 是指存储在 Redis 中具有较大体积的单个键值对,通常表现为以下几种形式:

  • Value 过大:单个 String 类型的值超过 10KB 。
  • 元素过多:Hash、List、Set、ZSet 等集合类型元素数量超过 5000 个。
  • 复合结构过大:复杂数据结构(如嵌套 Hash)占用内存过大。

大 Key 的危害:

  • 操作耗时增加,阻塞 Redis 单线程,降低持久化速度。
  • 内存使用不均,集群环境下数据分片不均。
  • 传输大 Key 消耗带宽,客户端处理耗时增加。

可以用 redis-cli 命令检测大 Key :

1
2
3
4
5
# 扫描大Key
redis-cli --bigkeys

# 内存分析
redis-cli --memkeys

可以使用 SCAN 命令:

1
2
# 自定义扫描大Key
redis-cli --scan --pattern '*' --count 1000 | while read key; do echo "$key: $(redis-cli memory usage $key)"; done | sort -n -k2 -t: | tail -n 20

也可以使用 MEMORY USAGE 命令:

1
MEMORY USAGE key_name

大 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 选举过程有三步:

  1. 竞选 Leader:
    • 检测到客观下线的 Sentinel 发起竞选,向其他 Sentinel 发送请求投票。
  2. 投票规则
    • 每个 Sentinel 在同一轮内只能投一票,先到先得。
    • 得票首先超过半数的 Sentinel 成为 Leader。
  3. 选举超时
    • 若未选出 Leader,随机等待后重试(避免冲突)。

Leader 主导的故障转移

在故障转移阶段,由 Leader 主导:

  1. 筛选新主节点:
    • 过滤不合格从节点:主观下线、断线、最后一次 PING 响应超时(>5秒)的节点。
    • 优先级排序:slave-priority 值最低的节点优先,若优先级相同,选择 复制偏移量(repl_offset)最大 的节点(数据最接近旧主节点),若偏移量相同,选择 RunID 字典序最小 的节点(随机选择依据)。
  2. 提升新主节点:向目标从节点发送 REPLICAOF NO ONE,使其成为主节点。该节点脱离复制关系,开始接受写请求,并生成自己的复制流。
  3. 重配置从节点:向其他从节点发送 REPLICAOF <new-master>,指向新主节点。从节点会清空旧数据,触发全量或增量同步。
  4. 更新集群状态:通知所有 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 映射到同一槽位。

    • 1
      2
      3
      
      # 以下两个 key 会被分配到相同槽位(仅计算 "user" 的 CRC16)
      user:{1000}:profile
      user:{1000}:orders
      
  • 数据倾斜问题:热点 key 或 Hash Tag 使用不当导致某些节点负载过高。

    • 解决方法:监控槽位分布,重新分配槽位。

槽位迁移

槽位迁移是 Redis Cluster 的核心功能之一,用于动态调整数据分布,实现集群的弹性扩缩容、负载均衡或故障恢复。

将哈希槽(Hash Slot)及其包含的 key 从一个节点转移到另一个节点的过程叫做槽位迁移。槽位迁移分为几步:

  1. 准备:

    • 标记源节点:通知源节点(迁移方)某个槽位准备迁出。

      • 此后,源节点会:
        • 正常处理该槽位的读请求。
        • 对写请求,若 key 未迁走则处理;若已迁走则返回 ASK 重定向。
    • 标记目标节点:目标节点会接受该槽位的 key,但仅处理带有 ASKING 命令的请求。

  2. 数据迁移:

    • 逐 key 迁移:从源节点向目标节点迁移数据。
      • 此命令是原子操作:key 会从源节点删除,并同步到目标节点。
    • 可批量迁移,避免阻塞集群。
  3. 完成迁移:

    • 更新集群配置:通知所有节点槽位归属变更。

对于在迁移期间的请求处理:

  • 未迁移的 key:由源节点正常处理。
  • 已迁移的 key:
    • 源节点返回 ASK <slot> <target-node> 重定向。
    • 客户端需先向目标节点发送 ASKING,再执行操作(临时访问)。
  • 迁移完成的 key:客户端收到 MOVED 重定向,更新本地缓存。

高可用

Cluster 支持主从复制,主节点故障时,从节点自动提升为新主节点。

节点间通过 Gossip 协议交换状态信息。若多数主节点认为某节点宕机,则触发故障转移。

重定向

支持 Cluster 协议的客户端会缓存槽位-节点映射表,直接路由请求:

  1. 计算 key 的 slot:CRC16(key) % 16384
  2. 查询本地缓存的槽位分布,发送请求到对应节点。

请求重定向分为两类:

  • 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-lrunoeviction
  • 高频写入场景监控 evicted_keys 指标,调整淘汰策略。