Redis
本文以 Redis 8.2.x 为主,命令、配置和源码片段使用 Redis 8.2.3 核对。涉及旧版实现时单独注明版本。底层 C 源码片段用于说明结构,部分省略了无关字段或分支。
数据操作示例在 redis-cli 交互提示符内输入,配置和建群章节标注的 shell 命令在终端执行。各组数据操作在独立的空数据库中分别执行,展示的是 redis-cli 的 RESP2 格式化输出。
命令参数可查阅官方命令参考,版本范围以正文说明为准。
1 通用命令
1.1 KEYS
- 语法:
KEYS pattern - 功能:一次返回所有匹配模式的
key - 时间复杂度:
O(N),其中N是数据库中的键数
KEYS 会遍历整个数据库,大键空间下可能阻塞其他请求。日常遍历优先使用 SCAN cursor [MATCH pattern] [COUNT count],从游标 0 开始,直到返回游标再次为 0。COUNT 是工作量提示,单次返回数量不固定,也可能返回空集合但尚未结束。完整迭代可能重复返回键,迭代期间增删的键是否返回未定义。
MSET firstname Jack lastname Stuntman age 35
# "OK"
KEYS *name*
# 1) "firstname"
# 2) "lastname"
KEYS a??
# 1) "age"
KEYS *
# 1) "age"
# 2) "firstname"
# 3) "lastname"1.2 DEL
- 语法:
DEL key [key ...] - 功能:删除指定的
key,如果key不存在则忽略
返回实际删除的键数。删除大型集合可能需要同步释放大量内存,Redis 4.0 起可用 UNLINK 将键移出键空间,再按对象情况异步释放内存。
SET key1 "Hello"
# "OK"
SET key2 "World"
# "OK"
DEL key1 key2 key3
# (integer) 21.3 EXISTS
- 语法:
EXISTS key [key ...] - 功能:返回参数中存在的键数,重复传入同一个存在的键会重复计数
SET key1 "Hello"
# "OK"
EXISTS key1
# (integer) 1
EXISTS nosuchkey
# (integer) 0
SET key2 "World"
# "OK"
EXISTS key1 key2 nosuchkey
# (integer) 21.4 EXPIRE
- 语法:
EXPIRE key seconds [NX | XX | GT | LT] - 功能:设置
key的剩余存活时间,单位为秒,成功返回1,键不存在或条件不满足返回0
Redis 7.0 起支持条件选项:NX 仅给没有过期时间的键设置,XX 仅更新已有过期时间,GT 和 LT 分别要求新的过期时间更晚或更早。比较时,没有过期时间的键视为无限期。设置成功时,非正时长会立即删除键。INCR、HSET 等修改值的操作通常保留键的 TTL,SET 等覆盖操作则默认移除 TTL。
1.5 TTL
- 语法:
TTL key - 功能:查看指定
key的剩余有效时长(秒)
如果 key 存在但是没有设置过期时长,返回 -1。如果 key 不存在返回 -2。
2 String 类型
String 类型,也就是字符串类型,是 Redis 中最简单的存储类型。
String 保存二进制安全的字节序列,也能保存文本形式的整数或浮点数,部分命令会按数值解释其内容。
Redis 8.2.x 默认将单个字符串及请求中的 bulk string 限制为 512 MiB,相关限制由 proto-max-bulk-len 控制。底层可以采用 int、embstr 或 raw 编码,整数编码直接保存数值。
2.1 SET
- 语法:
SET key value [NX | XX] [GET] [EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL] - 功能:添加或修改一个 String 类型的键值对
- 可选项:
NX:只有key不存在时才设置XX:只有key已存在时才设置GET:返回写入前的字符串值,不存在时返回空值。旧值不是 String 时返回错误EX、PX:分别按秒、毫秒设置剩余存活时间EXAT、PXAT:分别按秒、毫秒指定绝对到期时间KEEPTTL:保留key原有的过期时长
普通 SET 可以覆盖其他类型的键,成功后默认清除原 TTL。条件不满足时不写入,不带 GET 时返回空值。KEEPTTL 在 Redis 6.0 加入,GET、EXAT、PXAT 在 6.2 加入,NX 与 GET 从 7.0 起可以一起使用。
2.2 GET
- 语法:
GET key - 功能:根据
key获取 String 类型的value
键不存在时返回空值,键存在但不是 String 时返回 WRONGTYPE 错误。
2.3 MSET
- 语法:
MSET key value [key value ...] - 功能:批量添加多个 String 类型的键值对
同一条 MSET 原子地写入所有键,覆盖旧值并移除被覆盖键的 TTL。Redis Cluster 要求这些键属于同一个槽。
2.4 MGET
- 语法:
MGET key [key ...] - 功能:批量获取多个
key的value
结果与参数顺序对应,不存在的键和非 String 类型的键都返回空值。
2.5 INCR
- 语法:
INCR key - 功能:整型自增
1
键不存在时按 0 初始化。INCR 和 INCRBY 使用有符号 64 位整数,内容不能解析为整数或计算溢出时返回错误。
2.6 INCRBY
- 语法:
INCRBY key increment - 功能:整型自增
increment
步长可以为负数,也可用 DECR、DECRBY 表达减法。
2.7 INCRBYFLOAT
- 语法:
INCRBYFLOAT key increment - 功能:浮点型自增
increment
步长可以为负数,键不存在时按 0 初始化。计算结果不能是 NaN 或无穷大。不应依赖浮点运算提供十进制金额的精确表示。
2.8 SETNX
Redis 2.6.12 起不推荐用于新代码,改用
SET key value NX。命令仍可使用。
- 语法:
SETNX key value - 功能:如果
key不存在才新增。
SETNX 是 SET if Not eXists 的简写。
需要同时设置过期时间时使用 SET key value NX EX seconds,避免将 SETNX 和 EXPIRE 分成两次操作。
2.9 SETEX
Redis 2.6.12 起不推荐用于新代码,改用
SET key value EX seconds。命令仍可使用。
- 语法:
SETEX key seconds value - 功能:新增
key并设置有效时长
3 Key 的层级格式
通常用 业务:对象:ID 命名键,例如 login:user:101。Redis 键空间是平面的,冒号用于分隔命名中的业务、对象和 ID。键是二进制安全的字节序列。
4 Hash 类型
Hash 类型保存无序的字段与值映射。字段和值都是二进制安全的字符串,同一个 Hash 中字段名唯一。
对象可以序列化后整体保存为 String,也可以将字段分别放入 Hash。后者便于单独读取和更新字段。
4.1 HSET
- 语法:
HSET key field value [field value ...] - 功能:添加或修改 Hash 的字段值,返回新增字段数,更新已有字段不计入返回值
4.2 HGET
- 语法:
HGET key field - 功能:读取字段值,键或字段不存在时返回空值
4.3 HMSET
Redis 4.0 起不推荐用于新代码,改用支持多个字段的
HSET。两者写入效果相同,但HMSET返回OK,HSET返回新增字段数。
- 语法:
HMSET key field value [field value ...] - 功能:批量添加多个 hash 类型
key的field的值
HMSET myhash field1 "Hello" field2 "World"
# "OK"
HGET myhash field1
# "Hello"
HGET myhash field2
# "World"4.4 HMGET
- 语法:
HMGET key field [field ...] - 功能:按参数顺序读取多个字段,不存在的键或字段对应空值
HSET myhash field1 "Hello" field2 "World"
# (integer) 2
HMGET myhash field1 field2 nofield
# 1) "Hello"
# 2) "World"
# 3) (nil)4.5 HGETALL
- 语法:
HGETALL key - 功能:返回所有字段和值。RESP2 是字段和值交替排列的数组,RESP3 是映射
HSET myhash field1 "Hello" field2 "World"
# (integer) 2
HGETALL myhash
# 1) "field1"
# 2) "Hello"
# 3) "field2"
# 4) "World"4.6 HKEYS
- 语法:
HKEYS key - 功能:返回所有字段名
HSET myhash field1 "Hello" field2 "World"
# (integer) 2
HKEYS myhash
# 1) "field1"
# 2) "field2"4.7 HVALS
- 语法:
HVALS key - 功能:返回所有字段值
HSET myhash field1 "Hello" field2 "World"
# (integer) 2
HVALS myhash
# 1) "Hello"
# 2) "World"4.8 HINCRBY
- 语法:
HINCRBY key field increment - 功能:将字段值按有符号 64 位整数增加指定步长,字段不存在时从
0开始,不能解析为整数或溢出时返回错误
HSET myhash field 5
# (integer) 1
HINCRBY myhash field 1
# (integer) 6
HINCRBY myhash field -10
# (integer) -44.9 HSETNX
- 语法:
HSETNX key field value - 功能:只有这个
key的字段不存在才能设置
HSETNX myhash field "Hello"
# (integer) 1
HSETNX myhash field "World"
# (integer) 0
HGET myhash field
# "Hello"5 List 类型
List 是有序的字符串列表,允许重复元素,支持从两端插入和弹出,也支持按索引访问。底层编码见后面的数据结构章节。
5.1 LPUSH
- 语法:
LPUSH key element [element ...] - 功能:向列表左侧插入元素,
key不存在则会创建
LPUSH mylist "world"
# (integer) 1
LPUSH mylist "hello"
# (integer) 25.2 RPUSH
- 语法:
RPUSH key element [element ...] - 功能:向列表右侧插入元素,
key不存在则会创建
RPUSH mylist "one" "two" "three" "four" "five"
# (integer) 55.3 LPOP
- 语法:
LPOP key [count] - 功能:从列表左侧移除元素
count 在 Redis 6.2 加入。不带 count 时返回单个元素,带 count 时返回数组。键不存在时返回空值。
RPUSH mylist "one" "two" "three" "four" "five"
# (integer) 5
LPOP mylist
# "one"
LPOP mylist 2
# 1) "two"
# 2) "three"5.4 RPOP
- 语法:
RPOP key [count] - 功能:从列表右侧移除元素
RPUSH mylist "one" "two" "three" "four" "five"
# (integer) 5
RPOP mylist
# "five"
RPOP mylist 2
# 1) "four"
# 2) "three"5.5 LRANGE
- 语法:
LRANGE key start stop - 功能:返回索引在
[start stop]内的所有元素
范围包含两端,索引从 0 开始,-1 表示最后一个元素。超出列表边界的范围会被截取,起始位置在列表末尾之外时返回空数组。
RPUSH mylist "one" "two" "three"
# (integer) 3
LRANGE mylist 0 0
# 1) "one"
LRANGE mylist -3 2
# 1) "one"
# 2) "two"
# 3) "three"
LRANGE mylist -100 100
# 1) "one"
# 2) "two"
# 3) "three"
LRANGE mylist 5 10
# (empty array)6 Set 类型
Set 是无序的字符串集合,可以采用 intset、listpack 或哈希表编码。它在逻辑上与 Java 的 HashSet 类似,具有以下特征:
- 无序
- 元素不重复
- 查找快
- 支持交集、并集、差集等功能
6.1 SADD
- 语法:
SADD key member [member ...] - 功能:添加成员,返回新增成员数,重复成员不计入
SADD myset "Hello" "World"
# (integer) 2
SADD myset "World"
# (integer) 06.2 SREM
- 语法:
SREM key member [member ...] - 功能:移除指定成员,返回实际移除数
SADD myset "one" "two" "three"
# (integer) 3
SREM myset "one" "three"
# (integer) 2
SREM myset "four"
# (integer) 06.3 SCARD
- 语法:
SCARD key - 功能:返回集合中的元素数量
SADD myset "one" "two" "three"
# (integer) 3
SCARD myset
# (integer) 36.4 SISMEMBER
- 语法:
SISMEMBER key member - 功能:判断元素是否在集合中
SADD myset "one"
# (integer) 1
SISMEMBER myset "one"
# (integer) 1
SISMEMBER myset "two"
# (integer) 06.5 SMEMBERS
- 语法:
SMEMBERS key - 功能:返回集合中的所有成员。大型集合可用
SSCAN分批遍历,游标语义与SCAN相同
SADD myset Hello World
# (integer) 2
SMEMBERS myset
# 1) "Hello"
# 2) "World"6.6 SINTER
- 语法:
SINTER key [key ...] - 功能:求所有集合的交集,任意输入键不存在时交集为空
SADD s1 a b c d
# (integer) 4
SADD s2 c
# (integer) 1
SADD s3 a c e
# (integer) 3
SINTER s1 s2 s3
# 1) "c"6.7 SDIFF
- 语法:
SDIFF key [key ...] - 功能:返回第一个集合中不属于后续任何集合的成员
SADD s1 a b c d
# (integer) 4
SADD s2 c
# (integer) 1
SADD s3 a c e
# (integer) 3
SDIFF s1 s2 s3
# 1) "d"
# 2) "b"6.8 SUNION
- 语法:
SUNION key [key ...] - 功能:求所有集合的并集,相同成员只返回一次
SADD s1 a b c d
# (integer) 4
SADD s2 c
# (integer) 1
SADD s3 a c e
# (integer) 3
SUNION s1 s2 s3
# 1) "c"
# 2) "e"
# 3) "b"
# 4) "d"
# 5) "a"7 SortedSet 类型
SortedSet(ZSet)保存成员与分数的映射。成员唯一,分数可以重复,按分数升序排列,分数相同时按成员的二进制字典序排列。小型集合使用 listpack,较大的集合使用跳表和字典。
分数使用 IEEE 754 双精度浮点数,区间 [-2^53, 2^53] 内的整数可以精确表示,NaN 不能作为分数。
SortedSet 具备下列特性:
- 可排序
- 元素不重复
- 查询速度快
因为 SortedSet 的可排序特性,经常被用来实现排行榜这样的功能。
7.1 ZADD
- 语法:
ZADD key [NX | XX] [GT | LT] [CH] [INCR] score member [score member ...] - 功能:添加元素到有序集合,如果已存在则更新
score - 可选项:
XX:仅更新已存在的元素。不添加新元素。NX:只添加新元素。不更新现有元素。GT、LT:仅在新分数更大或更小时更新,不限制新增成员。不能与NX一起使用CH:返回新增和分数发生变化的成员总数,默认仅返回新增数INCR:按ZINCRBY方式增加分数,此时只能指定一组分数与成员
ZADD myzset 1 one 1 uno 2 two 3 three
# (integer) 47.2 ZREM
- 语法:
ZREM key member [member ...] - 功能:删除有序集合中的指定元素
ZADD myzset 1 one 1 uno 2 two 3 three
# (integer) 4
ZREM myzset two
# (integer) 17.3 ZSCORE
- 语法:
ZSCORE key member - 功能:查询指定元素的
score
ZADD myzset 1 one
# (integer) 1
ZSCORE myzset one
# "1"7.4 ZRANK
- 语法:
ZRANK key member [WITHSCORE] - 功能:获取指定元素按升序排列的排名,从
0开始。WITHSCORE从 Redis 7.2 起可用,同时返回分数
ZADD z 1 one 2 two 3 three
# (integer) 3
ZRANK z three
# (integer) 2
ZRANK z four
# (nil)7.5 ZCARD
- 语法:
ZCARD key - 功能:获取有序集合中的元素数量
ZADD z 1 one 2 two 3 three
# (integer) 3
ZCARD z
# (integer) 37.6 ZCOUNT
- 语法:
ZCOUNT key min max - 功能:获取有序集合中的
score在[min, max]内的元素数量
边界默认包含,可以用 ( 排除边界,例如 ZCOUNT z (1 3。-inf 和 +inf 表示无穷边界。
ZADD z 1 one 2 two 3 three
# (integer) 3
ZCOUNT z -inf +inf
# (integer) 3
ZCOUNT z 2 3
# (integer) 27.7 ZINCRBY
- 语法:
ZINCRBY key increment member - 功能:让有序集合中指定元素的
score自增,步长为increment
ZADD myzset 1 "one" 2 "two"
# (integer) 2
ZINCRBY myzset 2 "one"
# "3"7.8 ZRANGE
- 语法:
ZRANGE key start stop [BYSCORE | BYLEX] [REV] [LIMIT offset count] [WITHSCORES] - 功能:按照
score升序排序后,获取指定排名范围内的元素,排名从 0 开始 - 参数:
- 默认:
start、stop是包含两端的排名范围,支持负索引 BYSCORE:改按分数边界查询,支持(、-inf和+infBYLEX:改按成员字典序边界查询,使用[、(、-、+表示边界。应保证所有成员分数相同,否则结果不具备该模式预期的语义REV:反向查询。配合分数或字典序查询时,先写较大边界,再写较小边界LIMIT:仅用于BYSCORE、BYLEX查询WITHSCORES:同时返回分数
- 默认:
ZADD z 1 one 2 two 3 three
# (integer) 3
ZRANGE z 1 99
# 1) "two"
# 2) "three"
ZRANGE z 0 1 WITHSCORES
# 1) "one"
# 2) "1"
# 3) "two"
# 4) "2"7.9 ZRANGEBYSCORE
Redis 6.2 起不推荐用于新代码,改用
ZRANGE key min max BYSCORE [WITHSCORES] [LIMIT offset count]。
- 语法:
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count] - 功能:按照
score排序后,获取指定score范围内的元素
7.10 ZDIFF
- 语法:
ZDIFF numkeys key [key ...] [WITHSCORES] - 功能:求第一个集合相对其余集合的差集,保留第一个集合的分数。Redis 6.2 加入
ZADD zset1 1 "one" 2 "two" 3 "three"
# (integer) 3
ZADD zset2 1 "one" 2 "two"
# (integer) 2
ZDIFF 2 zset1 zset2
# 1) "three"
ZDIFF 2 zset1 zset2 WITHSCORES
# 1) "three"
# 2) "3"7.11 ZINTER
- 语法:
ZINTER numkeys key [key ...] [WEIGHTS weight [weight ...]] [AGGREGATE SUM | MIN | MAX] [WITHSCORES] - 功能:求交集。Redis 6.2 加入,分数默认相加,
WEIGHTS指定各集合的权重,AGGREGATE指定聚合方式
ZADD zset1 1 "one" 2 "two"
# (integer) 2
ZADD zset2 1 "one" 2 "two" 3 "three"
# (integer) 3
ZINTER 2 zset1 zset2
# 1) "one"
# 2) "two"
ZINTER 2 zset1 zset2 WITHSCORES
# 1) "one"
# 2) "2"
# 3) "two"
# 4) "4"7.12 ZUNION
- 语法:
ZUNION numkeys key [key ...] [WEIGHTS weight [weight ...]] [AGGREGATE SUM | MIN | MAX] [WITHSCORES] - 功能:求并集。Redis 6.2 加入,分数默认相加,支持权重及
SUM、MIN、MAX聚合
ZADD zset1 1 "one" 2 "two"
# (integer) 2
ZADD zset2 1 "one" 2 "two" 3 "three"
# (integer) 3
ZUNION 2 zset1 zset2
# 1) "one"
# 2) "three"
# 3) "two"
ZUNION 2 zset1 zset2 WITHSCORES
# 1) "one"
# 2) "2"
# 3) "three"
# 4) "3"
# 5) "two"
# 6) "4"8 缓存穿透
请求的数据在缓存和数据库中都不存在,如果每次缓存未命中都查询数据库,重复的无效请求就会持续占用数据库资源。
常用处理方式:
- 缓存空结果,设置较短的 TTL,并在数据创建或更新后失效相关缓存。应区分“确实不存在”和查询失败,避免把暂时故障缓存为空结果。
- 使用布隆过滤器预先判断。过滤器可能误判为存在,仍需访问缓存或数据库。没有误判为不存在的前提是相关数据已正确加入过滤器,数据更新和过滤器维护必须协调。
- 校验请求参数,对异常请求限流。
9 缓存雪崩
大量键在短时间内过期,或者缓存服务不可用,可能使请求集中到达数据库。
可按触发原因采取措施:
- 在合理范围内给 TTL 增加随机偏移,错开批量预热和刷新时间。
- 配置主从复制与自动故障转移,降低缓存中断的概率。故障切换期间仍可能出现请求失败或数据丢失。
- 给回源请求限流、合并并发请求,并准备降级响应。
- 必要时增加本地缓存,同时处理一致性与失效问题。
10 缓存击穿
高并发访问的热点键失效后,大量请求同时重建同一份缓存,给数据库带来瞬时压力。重建越慢,并发回源越容易累积。
常用处理方式:
- 合并同一进程内的并发回源,或使用带超时的互斥锁。获得锁后再次检查缓存,锁值使用唯一标识,释放时原子地校验持有者,避免删除其他请求的锁。
- 使用逻辑过期,由少量请求触发后台刷新,其余请求暂时读取旧值。该方式允许短期陈旧数据,需要安排刷新失败后的重试或降级。
11 执行脚本
- 语法:
EVAL script numkeys [key [key ...]] [arg [arg ...]] - 功能:执行 Lua 脚本,键参数通过
KEYS读取,普通参数通过ARGV读取
EVAL "return ARGV[1]" 0 hello
# "hello"脚本在主线程原子执行,执行期间阻止其他数据命令交错运行,因此应限制脚本耗时。运行时错误之前已经完成的写入会保留。脚本访问的键应全部显式传入,Redis Cluster 中这些键必须属于同一个槽。参见官方脚本说明。
12 消息队列
12.1 基于 List
可以用 LPUSH 与 RPOP,或 RPUSH 与 LPOP 构造 FIFO 队列。队列为空时,普通弹出命令立即返回空值,BRPOP、BLPOP 则能阻塞等待。
多个消费者可以竞争弹出同一个 List 的消息,每条消息只会被其中一个弹出,并发消费者可能按不同顺序完成处理。消息弹出即离开队列,消费者崩溃后的恢复需要额外机制。
需要处理中消息的恢复机制时,可以用 Redis 6.2 加入的 LMOVE、BLMOVE 将消息原子地移到处理中列表,处理成功后再移除,并自行实现超时恢复与去重。List 本身没有消费者组和确认协议。消息持久性取决于持久化和复制配置,故障时仍可能丢失消息。
12.2 基于 PubSub
Pub/Sub 是发布订阅模型。消费者订阅 channel,生产者向 channel 发布消息,在线的匹配订阅者各收到一份。
SUBSCRIBE channel [channel ...]:订阅频道PUBLISH channel message:向频道发布消息PSUBSCRIBE pattern [pattern ...]:按模式订阅频道
Pub/Sub 提供至多一次投递,不保存可重放的消息,也没有确认和重试机制。断线期间的消息无法补读。慢消费者会积累输出缓冲,达到 client-output-buffer-limit pubsub 限制时连接可能被关闭,后续消息无法接收。需要历史回放或消费确认时应考虑 Stream。参见官方 Pub/Sub 说明。
12.3 基于 Stream
Stream 在 Redis 5.0 加入,以递增 ID 保存字段与值组成的消息条目,支持历史读取、阻塞等待和消费者组。
同一条消息可以被多个独立读取者或多个消费者组读取。可回溯的范围受裁剪、删除和持久化结果限制,故障恢复和重新投递也可能造成消息丢失或重复。
12.3.1 XADD
- 语法:
XADD key [NOMKSTREAM] [KEEPREF | DELREF | ACKED] [<MAXLEN | MINID> [= | ~] threshold [LIMIT count]] <* | id> field value [field value ...] - 功能:向 Stream 追加条目,返回条目 ID
- 参数:
NOMKSTREAM:键不存在时不创建 Stream,返回空值MAXLEN:按条目数量裁剪。=为精确裁剪,~为近似裁剪,近似结果可能超过阈值MINID:按 ID 裁剪,删除小于阈值 ID 的条目LIMIT:限制近似裁剪的工作量,仅与~配合使用KEEPREF:默认行为,裁剪条目时保留消费者组 PEL 中的引用DELREF:裁剪条目时同时移除所有消费者组中相应的 PEL 引用ACKED:仅裁剪已被所有消费者组读取并确认的条目,可能无法达到数量或 ID 阈值*:自动生成 ID,格式为毫秒时间戳-序号。显式 ID 必须大于 Stream 已记录的最大 ID,且不能为0-0
NOMKSTREAM、MINID、LIMIT 在 Redis 6.2 加入,毫秒时间戳-* 形式在 7.0 加入,KEEPREF、DELREF、ACKED 在 8.2 加入。后者用于控制裁剪时如何处理消费者组引用。
# 创建 users,并追加一条消息。下面的返回 ID 仅作示意
XADD users * name "zhh" age 18
# "1760774662027-0"12.3.2 XLEN
- 语法:
XLEN key - 功能:返回 Stream 当前保存的条目数,键不存在时返回
0
XADD users 1000-0 name zhh age 18
# "1000-0"
XLEN users
# (integer) 1
XADD users 1001-0 name howard age 17
# "1001-0"
XLEN users
# (integer) 212.3.3 XREAD
- 语法:
XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] id [id ...] - 功能:从一个或多个 Stream 读取 ID 大于指定 ID 的条目
- 参数:
COUNT:每个 Stream 最多返回的条目数BLOCK:没有可返回条目时等待,单位毫秒,0表示无限等待,超时返回空值STREAMS:先列出所有键,再列出与其一一对应的 ID0:读取当前保存的历史条目$:以执行命令时的最后一个 ID 为起点,只等待后续条目,不返回当时已有的最后一条
连续读取时,下一次请求应使用上次返回的最后一个 ID。$ 仅适合首次开始监听新消息,反复使用会跳过两次请求之间产生的条目。Redis 7.4 起还支持 +,读取对应 Stream 的最后一条,此时该 Stream 忽略 COUNT。参见 XREAD 官方参考。
XADD users 1000-0 name zhh age 18
# "1000-0"
XADD users 1001-0 name howard age 17
# "1001-0"
XADD users 1002-0 name John age 19
# "1002-0"
XREAD COUNT 2 STREAMS users 0
# 1) 1) "users"
# 2) 1) 1) "1000-0"
# 2) 1) "name"
# 2) "zhh"
# 3) "age"
# 4) "18"
# 2) 1) "1001-0"
# 2) 1) "name"
# 2) "howard"
# 3) "age"
# 4) "17"
# $ 以当前最后一条为起点,没有更新条目时立即返回空值
XREAD COUNT 1 STREAMS users $
# (nil)
# 连续读取时用上次收到的最后一个 ID,返回已有的 1002-0
XREAD COUNT 1 STREAMS users 1001-0
# 返回 1002-0 及其字段
# 阻塞等待后续消息。在第二个 redis-cli 终端于 5 秒内执行:
# XADD users 1003-0 name Alice age 20
XREAD BLOCK 5000 STREAMS users $
# 若第二个终端及时追加,返回 1003-0 及其字段,否则超时返回 (nil)12.4 Stream 消费者组
消费者组把新条目分配给组内消费者,并维护最后投递 ID(last-delivered-id)和待确认列表(PEL)。
默认情况下,投递后条目进入 PEL。业务处理成功后调用 XACK key group id [id ...],从该组的 PEL 中移除引用,Stream 条目本身仍然保留。消费者故障后的 pending 消息需要显式恢复,可以用 XPENDING 检查,再用 XCLAIM 或 Redis 6.2 加入的 XAUTOCLAIM 接管。重新投递可能造成重复处理,业务应按消息 ID 或业务标识去重。参见官方消费者组说明。
12.4.1 XGROUP CREATE
- 语法:
XGROUP CREATE key group <id | $> [MKSTREAM] [ENTRIESREAD entries-read] - 功能:创建消费者组,组已存在时返回错误
- 参数:
id:初始化最后投递 ID,0表示从已保存的历史条目开始$:跳过创建组时已有的条目,只消费后续条目MKSTREAM:键不存在时创建空 StreamENTRIESREAD:Redis 7.0 加入,用于初始化消费进度统计,不替代起始 ID
12.4.2 XGROUP DESTROY
- 语法:
XGROUP DESTROY key group - 功能:删除消费者组及其 PEL,保留 Stream。组存在时返回
1,否则返回0
12.4.3 XREADGROUP
- 语法:
XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] id [id ...] - 功能:以指定消费者身份从组内读取条目,消费者不存在时自动创建
- 参数:
>:获取尚未投递给该组任何消费者的新条目- 数值 ID:读取属于当前消费者、ID 大于该值的 pending 条目,此时
BLOCK、NOACK不生效 NOACK:新条目投递后不进入 PEL,适用于允许丢失、不需要确认的场景
XADD jobs 1000-0 task "send-email"
# "1000-0"
XGROUP CREATE jobs workers 0
# OK
XREADGROUP GROUP workers worker-1 COUNT 1 STREAMS jobs >
# 返回 1000-0 及其字段,此时该消息在 PEL 中
XACK jobs workers 1000-0
# (integer) 113 GEO
GEO 是 Geolocation 的简写形式,代表地理坐标。Redis 在 3.2 版本中加入了对 GEO 的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。
GEO 底层使用 ZSet,分数编码地理位置。经度范围为 [-180, 180],纬度范围约为 [-85.05112878, 85.05112878],单位均为度。编码会产生量化误差,距离采用球面近似。下面的地点名称和坐标用于演示查询。
13.1 GEOADD
- 语法:
GEOADD key [NX | XX] [CH] longitude latitude member [longitude latitude member ...] - 功能:添加或更新位置,默认返回新增成员数,
CH返回新增或位置变化的成员数。NX、XX、CH在 Redis 6.2 加入
## 虹桥机场经纬度: (121.34113, 31.19590)
## 龙华机场经纬度: (121.45298, 31.17156)
## 火车北站经纬度: (121.47039, 31.25210)
## 火车南站经纬度: (121.41757, 31.14980)
## 火车站经纬度: (121.45088, 31.25145)
GEOADD sh 121.34113 31.19590 hqjc 121.45298 31.17156 lhjc
# (integer) 2
GEOADD sh 121.47039 31.25210 hcbz 121.41757 31.14980 hcnz 121.45088 31.25145 hcz
# (integer) 313.2 GEODIST
- 语法:
GEODIST key member1 member2 [M | KM | FT | MI] - 功能:返回两个点之间的距离
- 参数:
M:以米为单位,默认值KM:以千米为单位FT:以英尺为单位MI:以英里为单位
## 虹桥机场经纬度: (121.34113, 31.19590)
## 龙华机场经纬度: (121.45298, 31.17156)
## 火车北站经纬度: (121.47039, 31.25210)
## 火车南站经纬度: (121.41757, 31.14980)
## 火车站经纬度: (121.45088, 31.25145)
GEOADD sh 121.34113 31.19590 hqjc 121.45298 31.17156 lhjc
# (integer) 2
GEOADD sh 121.47039 31.25210 hcbz 121.41757 31.14980 hcnz 121.45088 31.25145 hcz
# (integer) 3
GEODIST sh hqjc hcnz
# "8899.7525"
GEODIST sh hqjc hcnz M
# "8899.7525"
GEODIST sh hqjc hcnz KM
# "8.8998"13.3 GEOHASH
- 语法:
GEOHASH key member [member ...] - 功能:返回 hash 字符串形式的
member坐标
## 虹桥机场经纬度: (121.34113, 31.19590)
## 龙华机场经纬度: (121.45298, 31.17156)
GEOADD sh 121.34113 31.19590 hqjc 121.45298 31.17156 lhjc
# (integer) 2
GEOHASH sh hqjc
# 1) "wtw33pjcm30"13.4 GEOPOS
- 语法:
GEOPOS key member [member ...] - 功能:返回
member的坐标
## 虹桥机场经纬度: (121.34113, 31.19590)
## 龙华机场经纬度: (121.45298, 31.17156)
GEOADD sh 121.34113 31.19590 hqjc 121.45298 31.17156 lhjc
# (integer) 2
GEOPOS sh hqjc
# 1) 1) "121.34113222360611"
# 2) "31.19589943600125"13.5 GEORADIUS
Redis 6.2 起不推荐用于新代码,改用
GEOSEARCH和GEOSEARCHSTORE。命令仍可使用。
- 常用形式:
GEORADIUS key longitude latitude radius <M | KM | FT | MI> - 功能:指定圆心、半径,找到该圆内包含的所有
member
13.6 GEOSEARCH
- 语法:
GEOSEARCH key <FROMMEMBER member | FROMLONLAT longitude latitude> <BYRADIUS radius <M | KM | FT | MI> | BYBOX width height <M | KM | FT | MI>> [ASC | DESC] [COUNT count [ANY]] [WITHCOORD] [WITHDIST] [WITHHASH] - 功能:在圆形或矩形范围内搜索成员。默认不保证结果顺序,
ASC、DESC才按距离排序。COUNT count ANY可以提前结束搜索,不能保证返回最近的count个成员。Redis 6.2 加入
## 虹桥机场经纬度: (121.34113, 31.19590)
## 龙华机场经纬度: (121.45298, 31.17156)
## 火车北站经纬度: (121.47039, 31.25210)
## 火车南站经纬度: (121.41757, 31.14980)
## 火车站经纬度: (121.45088, 31.25145)
GEOADD sh 121.34113 31.19590 hqjc 121.45298 31.17156 lhjc
# (integer) 2
GEOADD sh 121.47039 31.25210 hcbz 121.41757 31.14980 hcnz 121.45088 31.25145 hcz
# (integer) 3
## 返回距离(121.45624, 31.21012)10km 内的所有 member
GEOSEARCH sh FROMLONLAT 121.45624 31.21012 BYRADIUS 10 KM ASC WITHDIST
# 1) 1) "lhjc"
# 2) "4.3000"
# 2) 1) "hcz"
# 2) "4.6252"
# 3) 1) "hcbz"
# 2) "4.8592"
# 4) 1) "hcnz"
# 2) "7.6520"13.7 GEOSEARCHSTORE
- 语法:
GEOSEARCHSTORE destination source <FROMMEMBER member | FROMLONLAT longitude latitude> <BYRADIUS radius <M | KM | FT | MI> | BYBOX width height <M | KM | FT | MI>> [ASC | DESC] [COUNT count [ANY]] [STOREDIST] - 功能:将搜索结果写入目标 ZSet,覆盖目标键。默认保留地理编码分数,使用
STOREDIST时改为保存指定单位下的距离分数。Redis 6.2 加入
14 Bitmap
Bitmap 使用 String 的各个二进制位。Redis 8.2.x 默认上限为 512 MiB,对应位偏移 0 到 2^32 - 1,限制与 proto-max-bulk-len 相关。对很大的偏移执行 SETBIT 会分配并填充中间空间,可能增加内存和延迟。
14.1 SETBIT
- 语法:
SETBIT key offset value - 功能:在
offset处存入一个1或0
SETBIT mykey 7 1
# (integer) 0
SETBIT mykey 7 0
# (integer) 114.2 GETBIT
- 语法:
GETBIT key offset - 功能:读取
offset处的位。键不存在或偏移超出已存储范围时返回0
SETBIT mykey 7 1
# (integer) 0
GETBIT mykey 7
# (integer) 1
GETBIT mykey 100
# (integer) 014.3 BITFIELD
- 常用读取形式:
BITFIELD key GET encoding offset - 功能:把连续的位解释为整数,也支持
SET encoding offset value和INCRBY encoding offset increment
encoding 使用 i 或 u 加位宽,例如 i8、u4。有符号位宽为 1 至 64,无符号位宽为 1 至 63。OVERFLOW WRAP | SAT | FAIL 控制后续数值写入的溢出行为,默认 WRAP。只读场景还可使用 Redis 6.0 加入的 BITFIELD_RO。
SETBIT bits 1 1
# (integer) 0
SETBIT bits 3 1
# (integer) 0
SETBIT bits 4 1
# (integer) 0
SETBIT bits 7 1
# (integer) 0
## 此时 bits = 01011001
## 从 bits[1] 开始读,读 4 位(1011),作为一个无符号数
BITFIELD bits GET u4 1
# 1) (integer) 1114.4 BITCOUNT
- 语法:
BITCOUNT key [start end [BYTE | BIT]] - 功能:统计 Bitmap 中值为
1的 bit 位的数量
范围默认按字节解释,Redis 7.0 起支持 BIT,范围包含两端,支持负索引。
SETBIT bits 1 1
# (integer) 0
SETBIT bits 3 1
# (integer) 0
SETBIT bits 4 1
# (integer) 0
SETBIT bits 7 1
# (integer) 0
## 此时 bits = 01011001
BITCOUNT bits
# (integer) 414.5 BITPOS
- 语法:
BITPOS key bit [start [end [BYTE | BIT]]] - 功能:查询 bit 数组中指定范围内第一个 0 或 1 出现的位置
返回相对整个字符串的位偏移,找不到时通常返回 -1。查找 0 且未指定结束位置时,可以把字符串末尾之外视为零填充,因此全为 1 的字符串也可能返回末尾之后的位置。BIT 单位选项在 Redis 7.0 加入。
SETBIT bits 1 1
# (integer) 0
SETBIT bits 3 1
# (integer) 0
SETBIT bits 4 1
# (integer) 0
SETBIT bits 7 1
# (integer) 0
## 此时 bits = 01011001
BITPOS bits 1
# (integer) 1
BITPOS bits 0
# (integer) 0
BITPOS bits 1 2 8 BIT
# (integer) 315 Redis 持久化
15.1 RDB
RDB 是 Redis 的二进制快照格式,记录生成快照时的数据集。恢复只能回到已保存的状态,最近一次快照之后的写入可能丢失。
# 同步生成快照,主线程会阻塞
SAVE
# OK
# 子进程生成快照,fork 仍可能造成短暂停顿
BGSAVE
# Background saving started正常关闭时是否保存 RDB 取决于持久化配置和 SHUTDOWN SAVE | NOSAVE 等参数。
Redis 内部有触发 RDB 的机制,可以在 redis.conf 文件中找到,格式如下:
# Redis 8.2.x 默认快照条件,满足任意一项后尝试 BGSAVE
save 3600 1 300 100 60 10000
# 默认启用压缩,按 CPU、磁盘空间与写入负载评估是否调整
rdbcompression yes
dbfilename dump.rdb
# 示例目录,应提前创建且当前用户可写
dir ./dataBGSAVE 使用 fork 创建子进程。子进程写快照,主进程继续服务,但创建子进程本身仍可能造成暂停。父子进程最初共享物理页,写时复制(copy-on-write)在内存页被修改时复制相关页。高写入负载下应为这些额外页面预留内存。
15.2 AOF
AOF(Append Only File)记录恢复数据集所需的写操作,写入命令可能转换为等效形式后记录。
AOF 默认关闭,下面给出开启 AOF 并采用每秒同步策略的配置:
# Redis 8.2.x:开启 AOF,采用每秒 fsync
appendonly yes
appendfilename "appendonly.aof"
appenddirname "appendonlydir"
appendfsync everysec
# 其他策略只能择一启用
# appendfsync always
# appendfsync no
# 默认允许在后台保存或重写期间继续 fsync
no-appendfsync-on-rewrite no
# Redis 7.0 起使用多文件 AOF,基础文件默认采用 RDB 格式
aof-use-rdb-preamble yesappendfsync 控制文件同步策略。always 每批写入都同步,everysec 通常每秒同步,故障时可能丢失约一秒写入,no 由操作系统安排同步。实际损失窗口受系统负载、存储和故障类型影响。
Redis 7.0 起采用多文件 AOF,由基础文件、增量文件和 manifest 组织。默认 aof-use-rdb-preamble yes,基础文件可以采用 RDB 格式,增量文件记录后续写操作。
BGREWRITEAOF 按当前数据集重建基础文件,去掉不再需要的历史操作,期间新增的写入继续记录在增量文件中。文件大小取决于数据、历史写入和基础文件格式。RDB 和 AOF 都启用时,重启优先使用 AOF 恢复。参见官方持久化说明。
Redis 也会在触发阈值时自动去重写 AOF 文件。阈值也可以在 redis.conf 中配置:
# AOF 文件比上次文件增长超过多少百分比则触发重写
auto-aof-rewrite-percentage 100
# AOF 文件体积最小多大以上才触发重写
auto-aof-rewrite-min-size 64mb16 Redis 主从
复制将主节点的数据同步到副本。读写分离可以分担读取负载,但写入仍集中在主节点,副本读也可能读到尚未更新的数据。复制本身不提供自动故障转移。
16.1 搭建主从实例
以下是在同一台机器上的本地实验,使用 Redis 8.2.x 的 redis-server、redis-cli,端口为 7001、7002、7003。实例仅监听本机,数据、配置和日志目录分别独立。实验关闭自动快照和 AOF,仅用于观察复制行为。
16.1.1 配置与启动
# 在 shell 中执行,需要 redis-server 在 PATH 中,且三个端口未被占用
labdir=$(mktemp -d "${TMPDIR:-/tmp}/redis-replication.XXXXXX")
for port in 7001 7002 7003; do
mkdir -p "$labdir/$port"
cat > "$labdir/$port/redis.conf" <<EOF
bind 127.0.0.1
port $port
daemonize yes
pidfile $labdir/$port/redis.pid
logfile $labdir/$port/redis.log
dir $labdir/$port
save ""
appendonly no
EOF
redis-server "$labdir/$port/redis.conf"
# daemonize 返回时监听端口可能尚未就绪
for attempt in {1..50}; do
redis-cli -p "$port" PING >/dev/null 2>&1 && break
sleep 0.1
done
redis-cli -p "$port" PING >/dev/null || exit 1
done16.1.2 配置复制关系
使用 REPLICAOF host port 把 7002、7003 配置为 7001 的副本。SLAVEOF 从 Redis 5.0 起不推荐用于新代码,但仍兼容。
# 在 shell 中配置两个副本
redis-cli -p 7002 REPLICAOF 127.0.0.1 7001
# OK
redis-cli -p 7003 REPLICAOF 127.0.0.1 7001
# OK
# 查看复制角色及连接状态,同步需要一定时间
redis-cli -p 7001 INFO replication
# role:master
# connected_slaves:2
# 其他字段省略
redis-cli -p 7002 INFO replication
# role:slave
# master_host:127.0.0.1
# master_port:7001
# master_link_status:up
# 其他字段省略命令修改运行状态,若要在重启后保留,可在配置文件中写入 replicaof 127.0.0.1 7001,或在配置文件可写时执行 CONFIG REWRITE。INFO replication 中部分字段仍沿用 slave 命名。
实验结束后逐个关闭实例:
for port in 7001 7002 7003; do
redis-cli -p "$port" SHUTDOWN NOSAVE
done16.2 数据同步原理
16.2.1 全量同步
副本通过 PSYNC 请求同步。没有可用的历史复制状态,或主节点无法提供所需增量时,会全量同步:主节点生成 RDB,副本加载快照,再接收快照生成期间积累的复制流。
复制状态主要由两个值描述:
- replication ID:标识一段复制历史。副本跟随主节点继承 ID,主节点角色变化时可以保留第二个 ID,用于一定范围内的部分同步。
- replication offset:复制流中的字节偏移。
相同 replication ID 下的相同 offset 对应相同的数据状态。
16.2.2 部分同步
断线重连或重启后能否部分同步,取决于副本是否保留有效复制状态、主节点是否接受该 replication ID,以及缺失的复制流是否仍在 backlog 中。
repl-backlog-size 控制历史复制流缓冲容量,旧内容会被覆盖。所需区间已经被覆盖时,需要全量同步。增大容量可以容忍更长的断连,但会消耗更多内存。
Redis 8.2.x 默认启用 repl-diskless-sync yes,全量同步可以由子进程直接把 RDB 写入网络,减少主节点的临时磁盘写入,仍有 fork、快照生成和网络传输开销。复制规模应结合数据集大小、带宽和恢复时间评估。级联复制可以分担主节点负载,也会增加同步路径和故障依赖。
复制通常是异步的,已返回成功的写入也可能在故障转移后丢失。WAIT 可以等待副本确认,Redis 7.2 加入的 WAITAOF 可以等待相应 AOF 同步,但都不能把 Redis 复制变成强一致系统。
17 Redis 哨兵
Sentinel 负责监控主从实例、协调自动故障转移,并向客户端提供当前主节点地址。客户端必须支持 Sentinel 服务发现,并在切换后重新连接。业务请求直接发送给数据节点,故障转移期间仍可能丢失写入。
17.1 服务状态监控
Sentinel 定期检查实例。某实例超过 down-after-milliseconds 没有有效响应时,当前 Sentinel 判定其主观下线(SDOWN)。对于主节点,当至少 quorum 个 Sentinel 同意其下线,才判定为客观下线(ODOWN)。副本的主观下线不使用同样的 ODOWN 投票流程。
quorum 决定下线判断门槛,执行故障转移还需要 Sentinel 多数派授权。例如 5 个 Sentinel 配置 quorum 2 时,两个可以形成 ODOWN 判断,但仍至少需要三个授权故障转移。一般部署至少 3 个 Sentinel,并分布在独立故障域。
17.2 选择新的主节点
执行故障转移的 Sentinel 先获得选举授权,再从合格副本中选择要提升的节点。选择条件包括:
- 排除不可达、状态不合格、
replica-priority 0的副本。 - 排除与主节点断连过久的副本。Redis 8.2.x 的阈值考虑
down-after-milliseconds × 10,还加上主节点已处于 SDOWN 的时长。 - 按
replica-priority升序选择,数值越小越优先。 - 优先选择 replication offset 更大的副本。
- 仍相同时按 run ID 的字典序升序选择。
17.3 故障转移流程
Sentinel 将选中的副本提升为主节点,再让其他副本跟随新的主节点。原主节点恢复后也被重新配置为副本。
Redis 8.2.x 的 Sentinel 内部仍发送兼容命令 SLAVEOF NO ONE、SLAVEOF host port,其效果对应 REPLICAOF。切换过程中客户端可能暂时失败或连接到旧主节点,需要设置合理的超时、重连和重试策略。参见官方 Sentinel 文档。
18 Redis 分片
18.1 Redis Cluster
Redis Cluster 将数据分配给多个主节点,每个主节点可配置副本,节点间使用 cluster bus 协调成员状态和故障转移。分片可以扩展总数据量和写入能力,但具体收益取决于键和请求是否均衡。
客户端请求了错误节点时,服务端通常返回 MOVED 或 ASK 重定向,由客户端访问目标节点。命令行使用 redis-cli -c 跟随重定向,应用使用支持 Redis Cluster 的客户端。Cluster 只支持数据库 0。
下面的实验使用 7101 至 7106,建立三个主节点及各一个副本,与前面的主从实验使用不同端口。所有节点在同一机器上仅用于演示,不能用于验证跨机器高可用。
# 在 shell 中执行,需要六个端口及其 +10000 的 cluster bus 端口未被占用
labdir=$(mktemp -d "${TMPDIR:-/tmp}/redis-cluster.XXXXXX")
for port in 7101 7102 7103 7104 7105 7106; do
mkdir -p "$labdir/$port"
cat > "$labdir/$port/redis.conf" <<EOF
bind 127.0.0.1
port $port
daemonize yes
pidfile $labdir/$port/redis.pid
logfile $labdir/$port/redis.log
dir $labdir/$port
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
save ""
appendonly no
EOF
redis-server "$labdir/$port/redis.conf"
# daemonize 返回时监听端口可能尚未就绪
for attempt in {1..50}; do
redis-cli -p "$port" PING >/dev/null 2>&1 && break
sleep 0.1
done
redis-cli -p "$port" PING >/dev/null || exit 1
done
redis-cli --cluster create \
127.0.0.1:7101 127.0.0.1:7102 127.0.0.1:7103 \
127.0.0.1:7104 127.0.0.1:7105 127.0.0.1:7106 \
--cluster-replicas 1
# 检查分配计划后输入 yes18.2 哈希槽
Redis Cluster 有 16384 个槽,编号 0 至 16383。槽分配给主节点,每个键映射到一个槽:
slot = CRC16_XMODEM(key 的有效部分) mod 16384hash tag 的规则是找到键中第一个 {,再找其后的第一个 }。两者之间非空时,只对其中内容计算,否则对整个键计算。不会在遇到空花括号后继续寻找下一组。
{user:101}:name与{user:101}:email使用相同有效部分user:101。foo{bar}{baz}使用bar。foo{}{bar}按整个键计算。
多键命令、事务和 Lua 脚本通常要求所涉及的键在同一槽。hash tag 可以实现这一点,过度集中也可能造成热点。参见官方 Cluster 规范。
18.3 集群伸缩
新实例须启用 cluster-enabled yes,使用独立目录和 cluster-config-file。
下面在同一个实验 shell 中继续使用 labdir,启动 7107 并加入集群:
# 继续使用 cluster-setup.sh 创建的 labdir
mkdir -p "$labdir/7107"
cat > "$labdir/7107/redis.conf" <<EOF
bind 127.0.0.1
port 7107
daemonize yes
pidfile $labdir/7107/redis.pid
logfile $labdir/7107/redis.log
dir $labdir/7107
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
save ""
appendonly no
EOF
redis-server "$labdir/7107/redis.conf"
for attempt in {1..50}; do
redis-cli -p 7107 PING >/dev/null 2>&1 && break
sleep 0.1
done
redis-cli -p 7107 PING >/dev/null || exit 1
redis-cli --cluster add-node 127.0.0.1:7107 127.0.0.1:7101redis-cli --cluster add-node new_host:new_port existing_host:existing_port 默认添加主节点。添加副本可以使用 --cluster-slave --cluster-master-id <id>,这是 redis-cli 的参数名。
新主节点最初没有槽,需要用 redis-cli --cluster reshard host:port 迁移槽。交互中指定槽数、目标节点 ID 和源节点 ID,检查迁移计划后确认:
redis-cli -p 7107 CLUSTER MYID
# 记录 7107 的节点 ID,供 reshard 的目标参数使用
redis-cli --cluster reshard 127.0.0.1:7101
# 依提示填写迁移槽数、目标 ID、源 ID,核对计划后确认实验结束后关闭本次启动的节点:
for port in 7101 7102 7103 7104 7105 7106 7107; do
redis-cli -p "$port" SHUTDOWN NOSAVE
done18.4 故障转移
主节点被足够多的主节点判断为故障后,合格副本可发起选举,获得持有槽的主节点多数派授权后接管槽。是否能够恢复服务取决于多数派是否可达、有无合格副本以及槽覆盖配置。
cluster-require-full-coverage yes 是默认配置,槽覆盖不完整时集群停止提供普通数据服务。故障转移期间可能出现服务中断,也可能丢失异步复制的最后一批写入。
18.4.1 手动故障转移
- 语法:
CLUSTER FAILOVER [FORCE | TAKEOVER] - 执行位置:准备提升的副本节点
普通 CLUSTER FAILOVER 先与主节点协调暂停写入,等待副本追平复制偏移,再请求选举授权并接管槽。
FORCE 跳过与主节点的协调,可在主节点不可达时使用,但仍需要多数派授权,可能丢失尚未复制的写入。TAKEOVER 进一步跳过正常的多数派授权,自行接管,可能造成配置冲突,应明确理解分区和数据风险后使用。参见 CLUSTER FAILOVER 官方参考。
18.5 Go 访问 Redis Cluster
本文的 go-redis 示例使用 v9.12.1 核对,两个文件各有自己的 main,应在独立 Go 模块中分别运行。先执行 go mod init example/redis-demo 和 go get github.com/redis/go-redis/v9@v9.12.1,再用 go run 文件名.go 运行所选示例。
go-redis v9 使用 ClusterClient 根据槽分配路由,处理 MOVED、ASK 等响应。示例连接上面的本地 Cluster,未设置认证,实际部署应按服务端配置传入凭据和 TLS 参数。
package main
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
func main() {
rdb := redis.NewClusterClient(&redis.ClusterOptions{
Addrs: []string{"127.0.0.1:7101", "127.0.0.1:7102", "127.0.0.1:7103"},
})
defer rdb.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := rdb.Set(ctx, "cluster-demo:name", "zhh", time.Minute).Err(); err != nil {
panic(err)
}
value, err := rdb.Get(ctx, "cluster-demo:name").Result()
if err != nil {
panic(err)
}
fmt.Println(value)
// 回调可能并发执行,覆盖客户端当前已知的主节点和副本
err = rdb.ForEachShard(ctx, func(ctx context.Context, shard *redis.Client) error {
return shard.Ping(ctx).Err()
})
if err != nil {
panic(err)
}
}Ring 用于多个独立 Redis 实例,按一致性哈希分配键。节点增减会改变映射,已有数据的迁移由应用安排。访问 Redis Cluster 时使用前述 ClusterClient。参见 go-redis 官方客户端说明。
19 最佳实践
19.1 键值设计
通常采用 [业务名]:[对象名]:[ID],例如 login:user:101,方便辨认业务范围并减少命名冲突。键名兼顾业务含义和长度,减少内存和传输开销。
涉及 Cluster 多键操作时,单独设计 hash tag。
19.2 批处理
小命令频繁往返时,网络延迟可能占主要成本。批处理可以减少等待响应的次数,但复杂命令、脚本或大型集合操作也可能由服务端 CPU 主导,应通过测量判断瓶颈。
19.2.1 多参数命令
可以优先使用 MSET、多字段 HSET、多成员 SADD 等命令。HMSET 已不推荐用于新代码。
过大的命令会长时间占用执行线程,并增加网络和内存压力,应根据值大小及延迟目标限制批量。
19.2.2 Pipeline
Pipeline 将多个命令连续写入连接,再读取对应响应,减少逐条等待往返的开销。不同数据类型的命令可放在同一批次中。
下面的 Go 示例默认连接 127.0.0.1:6379,可用 REDIS_ADDR 调整地址,在实验实例执行,每次运行会增加 pipeline-demo:counter 并设置一小时 TTL。
package main
import (
"context"
"fmt"
"os"
"time"
"github.com/redis/go-redis/v9"
)
func main() {
addr := os.Getenv("REDIS_ADDR")
if addr == "" {
addr = "127.0.0.1:6379"
}
rdb := redis.NewClient(&redis.Options{Addr: addr})
defer rdb.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pipe := rdb.Pipeline()
incr := pipe.Incr(ctx, "pipeline-demo:counter")
expire := pipe.Expire(ctx, "pipeline-demo:counter", time.Hour)
if _, err := pipe.Exec(ctx); err != nil {
panic(err)
}
// Exec 已经完成,才读取各命令结果
value, err := incr.Result()
if err != nil {
panic(err)
}
if err := expire.Err(); err != nil {
panic(err)
}
fmt.Println(value)
}Pipeline 不保证多个命令整体原子执行。应检查执行错误及各命令结果,连接中断时可能已有部分命令执行。对 INCR 等非幂等命令盲目重试可能重复修改数据。参见官方流水线说明。
19.2.3 Cluster 中的批处理
| 操作 | 槽要求 | 行为 |
|---|---|---|
单条 MSET 等多键命令 | 所有键在同一槽 | 不同槽时返回 CROSSSLOT |
ClusterClient Pipeline 中的多个独立命令 | 各命令分别满足自身的槽要求 | 客户端按目标节点组织请求,可以跨节点发送 |
| 使用相同 hash tag | 相关键位于同一槽 | 方便事务和多键操作,但可能造成热点 |
跨节点 Pipeline 不保证全局顺序或原子性,每条命令仍须满足自身的槽要求。耗时由节点负载、网络延迟和批量共同决定。
19.3 服务端配置
19.3.1 持久化与部署
持久化配置取决于允许丢失的数据量和恢复时间。可完全重建的缓存可以关闭持久化,但需要评估重启后的预热与回源压力。重要状态应根据需求选择 RDB、AOF 或同时启用,并保留独立备份、验证恢复流程。副本会同步误删和错误写入,不能替代备份。
重写阈值要结合磁盘空间和写入速率设置。no-appendfsync-on-rewrite yes 暂停后台保存或重写期间的 AOF fsync,写入仍会追加,代价是可能扩大故障丢失窗口。默认 no。
为数据集、复制缓冲、内存碎片、持久化期间的写时复制和操作系统预留容量。单实例大小应结合 fork 延迟、迁移时间和恢复目标确定。避免与其他服务争抢关键 CPU、内存和磁盘资源。
19.3.2 慢查询
SLOWLOG 记录命令执行耗时,不包含客户端网络传输等时间。
slowlog-log-slower-than:阈值,单位微秒,Redis 8.2.x 默认10000。0记录所有命令,负值关闭记录。slowlog-max-len:最多保留的记录数,默认128。
根据观测需求调整阈值和容量。命令参数可能出现在日志中,应按实际数据内容管理访问权限。
SLOWLOG LEN:查看记录数SLOWLOG GET [count]:读取最近记录,未指定数量时默认10条SLOWLOG RESET:清空记录
20 数据结构
以下实现以 Redis 8.2.3 为准。内部布局和阈值是实现细节,实际编码可通过 OBJECT ENCODING key 检查。
20.1 动态字符串 SDS
C 的普通字符串以首个 \0 为结束标记,strlen 需要遍历到该位置。字符数组可以保存二进制数据并修改内容,字符串字面量不应修改。
int main(void) {
const char *s = "hello"; // 字符串字面量不能修改
char editable[] = "hello";
editable[0] = 'H'; // 字符数组可以修改
return 0;
}SDS(Simple Dynamic String)通过头部记录长度和容量,再保存字节内容。结尾仍保留 \0 以兼容部分 C API,但判断内容长度不依赖它。
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; // buf 已保存的字符串字节数,不包含结束标识
uint8_t alloc; // buf 申请的总字节数,不包含结束标识
unsigned char flags; // 不同的 SDS 头类型,用来控制 SDS 的头大小
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len;
uint32_t alloc;
unsigned char flags;
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len;
uint64_t alloc;
unsigned char flags;
char buf[];
};
// 短字符串布局仍在使用,通常直接访问 flags
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
#define SDS_TYPE_5 0
#define SDS_TYPE_8 1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4sdshdr5 用 flags 的高 5 位记录短字符串长度,不单独保存容量。Redis 8.2.x 仍使用这种布局,代码通常直接访问 flags。
SDS 长度查询为 O(1),内容二进制安全,扩容时可以预留空间。Redis 8.2.3 的贪心扩容按所需长度计算:小于 1 MiB 时通常扩大到两倍,达到或超过 1 MiB 时通常额外预留 1 MiB。头部、终止符及分配器取整另计,也有不预分配的扩容路径。
键名通常以 SDS 布局保存,字符串值也可能使用 SDS。int 编码值直接保存整数,Redis 8.2.x 使用带键布局的 kvobj。
20.2 IntSet
IntSet 是 Set 的一种紧凑编码,把整数按升序保存在连续数组中。查找采用二分搜索,时间复杂度为 O(log N),插入和删除可能移动后续元素,最坏为 O(N)。
typedef struct intset {
uint32_t encoding; // 编码方式,支持存放 16、32、64 位整数
uint32_t length; // 元素个数
int8_t contents[]; // 整数数组,保存集合元素
} intset;
intset *intsetNew(void);
intset *intsetAdd(intset *is, int64_t value, uint8_t *success);
intset *intsetRemove(intset *is, int64_t value, int *success);
uint8_t intsetFind(intset *is, int64_t value);
int64_t intsetRandom(intset *is);
int64_t intsetMax(intset *is);
int64_t intsetMin(intset *is);
uint8_t intsetGet(intset *is, uint32_t pos, int64_t *value);
uint32_t intsetLen(const intset *is);
size_t intsetBlobLen(intset *is);
int intsetValidateIntegrity(const unsigned char *is, size_t size, int deep);整数宽度有三种,encoding 记录每个元素占用的字节数:
#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))加入超出当前宽度范围的整数时会升级。例如从 16 位升级到 32 位,先设置新编码并扩容,再从后向前按旧宽度读取、按新宽度写入,避免覆盖未搬移的数据。触发升级的负数放在最前面,正数放在最后面。删除元素不会自动降低整数宽度。
/* Upgrades the intset to a larger encoding and inserts the given integer. */
static intset *intsetUpgradeAndAdd(intset *is, int64_t value) {
uint8_t curenc = intrev32ifbe(is->encoding); // 获取当前 intset 编码
uint8_t newenc = _intsetValueEncoding(value); // 获取新编码
int length = intrev32ifbe(is->length); // 获取元素个数
int prepend = value < 0 ? 1 : 0;
// 重置编码与数组大小
is->encoding = intrev32ifbe(newenc);
is = intsetResize(is, intrev32ifbe(is->length) + 1);
// 倒序遍历,逐个搬运元素到新位置,_intsetGetEncoded 按照旧编码方式查找旧元素
while (length--)
_intsetSet(is, length + prepend, _intsetGetEncoded(is, length, curenc));
// 插入新元素,prepend 决定是队首还是队尾
if (prepend)
_intsetSet(is, 0, value);
else
_intsetSet(is, intrev32ifbe(is->length), value);
is->length = intrev32ifbe(intrev32ifbe(is->length) + 1);
return is;
}
/* Insert an integer in the intset */
intset *intsetAdd(intset *is, int64_t value, uint8_t *success) {
uint8_t valenc = _intsetValueEncoding(value); // 获取当前值编码
uint32_t pos; // 要插入的位置
if (success) *success = 1;
// 判断编码是不是超过了当前 intset 的编码
if (valenc > intrev32ifbe(is->encoding)) {
/* This always succeeds, so we don't need to curry *success. */
return intsetUpgradeAndAdd(is, value);
} else {
// 在当前 intset 中查找值与 value 一样的元素的角标 pos
if (intsetSearch(is, value, &pos)) {
if (success) *success = 0;
return is;
}
// 数组扩容
is = intsetResize(is, intrev32ifbe(is->length) + 1);
if (pos < intrev32ifbe(is->length)) intsetMoveTail(is, pos, pos + 1);
}
_intsetSet(is, pos, value); // 插入新元素
is->length = intrev32ifbe(intrev32ifbe(is->length) + 1);
return is;
}20.3 Dict
Dict 使用哈希桶与冲突链保存映射。Redis 8.2.x 的 dict 直接维护两张表的指针、已用元素数和容量指数,旧版独立 dictht 的字段布局不再适用。
// Redis 8.2.3,省略暂停标志和 metadata 等字段
struct dict {
dictType *type;
dictEntry **ht_table[2]; // 两张桶数组,第二张在 rehash 时使用
unsigned long ht_used[2]; // 两张表的元素数
long rehashidx; // -1 表示不在 rehash
// 其他字段省略
signed char ht_size_exp[2]; // 容量为 2^exp,-1 表示未分配
};下面是普通的带值 entry。某些只需要键的字典使用更紧凑的无值 entry。
struct dictEntry {
void *key; // 键
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v; // 值
struct dictEntry *next; // 下一个 Entry 的指针
};容量为 2^ht_size_exp,桶索引由哈希值与容量掩码计算。冲突较多时查找成本会上升,需要扩容或调整负载。
Redis 8.2.3 的一般扩容条件如下,还受字典类型回调和暂停自动调整等条件影响:
- 允许调整容量(
DICT_RESIZE_ENABLE)时,负载因子达到1触发扩容。 - 避免调整(
DICT_RESIZE_AVOID)时,负载因子达到强制阈值4仍可扩容。 - 禁止调整(
DICT_RESIZE_FORBID)时,不按这些条件扩容。
后台持久化期间通常避免调整,以降低写时复制开销。阈值 4 属于本版实现细节。
#define DICT_OK 0
#define DICT_ERR 1
typedef enum {
DICT_RESIZE_ENABLE,
DICT_RESIZE_AVOID,
DICT_RESIZE_FORBID,
} dictResizeEnable;
int dictExpandIfNeeded(dict *d) {
// 如果正在 rehash,则返回 OK
if (dictIsRehashing(d)) return DICT_OK;
// 如果哈希表为空,则初始化哈希表大小为默认大小 4
if (DICTHT_SIZE(d->ht_size_exp[0]) == 0) {
dictExpand(d, DICT_HT_INITIAL_SIZE);
return DICT_OK;
}
// 允许调整时负载因子达到 1,或未禁止调整时达到强制阈值 4
// dict_force_resize_ratio 在 Redis 8.2.3 中为 4
if ((dict_can_resize == DICT_RESIZE_ENABLE &&
d->ht_used[0] >= DICTHT_SIZE(d->ht_size_exp[0])) ||
(dict_can_resize != DICT_RESIZE_FORBID &&
d->ht_used[0] >= dict_force_resize_ratio * DICTHT_SIZE(d->ht_size_exp[0]))) {
// 扩容大小为 used+1,底层会对扩容大小进行判断,实际上找的是首个 >= used+1 的 2^n
if (dictTypeResizeAllowed(d, d->ht_used[0] + 1))
dictExpand(d, d->ht_used[0] + 1);
return DICT_OK;
}
return DICT_ERR;
}扩容或收缩时建立第二张表,逐步把旧表中的桶迁移过去,再释放旧表。迁移期间读操作需要考虑两张表,新插入元素进入新表。迁移由字典操作和后台维护分批推进,也可能被安全迭代等条件暂停。
这种渐进式 rehash 分摊了搬移成本,但分配新表等操作仍可能造成延迟。
20.4 ZipList 与 Listpack
ZipList 是 Redis 6.2.x 等旧版常见的紧凑容器。Redis 7.0 起,List、Hash、ZSet 的相关编码改用 listpack。
ZipList 的条目可概括为:
| prevlen | encoding | content |
|---|---|---|
| 前一条目的字节长度 | 当前内容编码 | 字符串或整数内容 |
prevlen 在前一条目长度小于 254 字节时占 1 字节,否则占 5 字节。前一条目变长可能让后续 prevlen 也变长,引发连锁更新。容器使用连续内存,插入、删除可能发生搬移或重新分配,两端操作也可能达到 O(N)。
Listpack 同样紧凑存储,条目保存自身的编码和内容,并在末尾记录用于反向遍历的长度信息(backlen)。后续条目不依赖前一条目的长度,避免了 ziplist 的这类连锁更新。修改时仍可能移动连续内存。参见 Redis 8.2.3 的 Listpack 实现。
20.5 QuickList
QuickList 在 Redis 3.2 加入,是由紧凑容器节点组成的双向链表。早期节点使用 ziplist,Redis 7.0 起使用 listpack。Redis 8.2.x 的节点可以保存打包的 listpack,也可以用 plain 节点保存单个大元素。
list-max-listpack-size 控制打包节点的大小。正值按元素数量限制,负值对应以下字节大小目标:
| 配置值 | 大小目标 |
|---|---|
-1 | 4 KiB |
-2 | 8 KiB,默认值 |
-3 | 16 KiB |
-4 | 32 KiB |
-5 | 64 KiB |
大小目标针对打包节点,单个大元素可超过该目标。旧配置名 list-max-ziplist-size 在 8.2.x 仍作为兼容别名,正文使用现行名称。
CONFIG GET list-max-listpack-size
# 1) "list-max-listpack-size"
# 2) "-2"list-compress-depth 控制首尾各保留多少个不压缩节点,0 表示关闭压缩,1 表示首尾各保留一个,依此类推。中间符合条件的打包节点使用 LZF,过小或压缩收益不足等节点不一定被压缩。
CONFIG GET list-compress-depth
# 1) "list-compress-depth"
# 2) "0"// Redis 8.2.3
// QL_FILL_BITS、QL_COMP_BITS、QL_BM_BITS 与指针宽度有关
// 64 位构建中分别为 16、16、4
typedef struct quicklist {
quicklistNode *head;
quicklistNode *tail;
unsigned long count; // 总元素数
unsigned long len; // 节点数
signed int fill : QL_FILL_BITS;
unsigned int compress : QL_COMP_BITS;
unsigned int bookmark_count : QL_BM_BITS;
quicklistBookmark bookmarks[];
} quicklist;// Redis 8.2.3,省略压缩状态标志
typedef struct quicklistNode {
struct quicklistNode *prev;
struct quicklistNode *next;
unsigned char *entry; // listpack、单个大元素或压缩数据
size_t sz; // 未压缩数据的字节数
unsigned int count : 16; // 元素数
unsigned int encoding : 2; // RAW=1,LZF=2
unsigned int container : 2; // PLAIN=1,PACKED=2
// 其他标志省略
} quicklistNode;20.6 SkipList
跳表用随机层高建立多级前向指针。Redis 的 ZSet 跳表先按分数排序,同分数时按成员字典序排序,还用 backward 支持反向遍历,用 span 支持排名计算。
查找、插入和删除的期望时间复杂度为 O(log N)。范围查询还与返回的元素数有关。
typedef struct zskiplist {
struct zskiplistNode *header, *tail; // 头尾节点指针
unsigned long length; // 节点数量
int level; // 最大的索引层级,默认是 1
} zskiplist;/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
sds ele; // 节点存储的值
double score; // 节点的分数,排序、查找用
struct zskiplistNode *backward; // 前一个节点指针
struct zskiplistLevel {
struct zskiplistNode *forward; // 下一个节点指针
unsigned long span; // 索引跨度
} level[]; // 多级索引数组
} zskiplistNode;20.7 RedisObject
redisObject(robj)记录值的逻辑类型、内部编码、引用计数和淘汰元信息。Redis 8.2.x 使用 kvobj 布局组织键和值,并以 iskvobj、expirable 等标志表示相关属性。
// Redis 8.2.3,LRU_BITS=24,OBJ_REFCOUNT_BITS=30
struct redisObject {
unsigned type : 4;
unsigned encoding : 4;
unsigned lru : LRU_BITS; // LRU 时钟或 LFU 元信息
unsigned iskvobj : 1; // 是否采用 kvobj 布局
unsigned expirable : 1; // 是否附有键级到期时间
unsigned refcount : OBJ_REFCOUNT_BITS;
void *ptr; // 数据指针或 int 编码整数
};编码常量保留了旧格式的编号。下面列出正文涉及的现行编码:
// Redis 8.2.3 中正文涉及的现行编码,旧格式常量省略
#define OBJ_ENCODING_RAW 0
#define OBJ_ENCODING_INT 1
#define OBJ_ENCODING_HT 2
#define OBJ_ENCODING_INTSET 6
#define OBJ_ENCODING_SKIPLIST 7
#define OBJ_ENCODING_EMBSTR 8
#define OBJ_ENCODING_QUICKLIST 9
#define OBJ_ENCODING_STREAM 10
#define OBJ_ENCODING_LISTPACK 11
#define OBJ_ENCODING_LISTPACK_EX 12| 逻辑类型 | Redis 8.2.x 的编码 |
|---|---|
| String | int、embstr、raw |
| List | listpack、quicklist |
| Set | intset、listpack、hashtable |
| ZSet | listpack、skiplist,后者同时维护字典 |
| Hash | listpack、listpackex、hashtable |
| Stream | stream,基于 radix tree 与 listpack |
Bitmap 使用 String,GEO 使用 ZSet,没有独立对象编码。
20.8 常见类型的编码选择
20.8.1 String
Redis 8.2.x 常见选择如下,实际编码还受写入命令和对象转换路径影响:
- 能解析为本机
long范围内整数的值,可用int编码,把整数存入ptr字段。64 位环境下通常对应有符号 64 位范围。 - 普通独立字符串对象的
embstr长度阈值为 44 字节,对象头和 SDS 一次分配。 - 写入键空间时,Redis 8.2.x 会创建
kvobj。是否继续嵌入字符串,还取决于键名和过期元数据能否满足kvobjSet的 64 字节大小判断。常见 64 位构建下,短键且没有附加过期时间时,判断式约为键长 + 值长 <= 41,附加过期时间时还需减去 8 字节。 - 较长字符串或需要转换的值使用
raw,对象头与 SDS 分开分配。
例如单字节键 a 写入 40 字节值时可保持 embstr,41 字节值则为 raw,键空间对象的布局还包含键名。APPEND 等修改也可能将 embstr 转为 raw。长度均按字节计算。
SET age 18
# OK
OBJECT ENCODING age
# "int"
SET name zhh
# OK
OBJECT ENCODING name
# "embstr"
# Redis 8.2.x 的键空间对象还要计入键名,以下使用单字节键
# 值长为 40 字节,键长与值长之和为 41
SET a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
# OK
OBJECT ENCODING a
# "embstr"
# 值长增加到 41 字节,创建 kvobj 时改用 raw
SET a aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
# OK
OBJECT ENCODING a
# "raw"20.8.2 List
Redis 3.2 至 6.2.x 的 List 使用带 ziplist 节点的 quicklist,Redis 7.0 改用带 listpack 节点的 quicklist。Redis 7.4 起,小型 List 可以直接使用单个 listpack,较大时转换为 quicklist。Redis 8.2.x 延续这两种编码,也会在满足条件时把缩小后的 quicklist 转回 listpack。
编码选择与 list-max-listpack-size 及元素大小有关。
20.8.3 Set
全部成员是合适的整数且数量不超过 set-max-intset-entries 时,通常使用 intset,默认阈值 512。Redis 7.2 起还支持 listpack,8.2.x 默认限制为 set-max-listpack-entries 128 和 set-max-listpack-value 64,分别约束成员数和成员字节长度。
超过相应条件后可转换为 hashtable。字典保存成员,可使用紧凑的无值节点。编码选择还受已有编码和转换路径影响。
20.8.4 ZSet
小型 ZSet 使用 listpack,成员和分数相邻保存。Redis 8.2.x 默认限制为 zset-max-listpack-entries 128 和 zset-max-listpack-value 64,后者约束成员字节长度。
较大时转换为 skiplist 编码,其 zset 同时维护字典和跳表:字典按成员查询分数,跳表按分数或排名查询。
// zset 结构
typedef struct zset {
dict *dict; // Dict 指针
zskiplist *zsl; // SkipList 指针
} zset;下面的函数创建 skiplist 编码对象:
robj *createZsetObject(void) {
zset *zs = zmalloc(sizeof(*zs));
robj *o;
zs->dict = dictCreate(&zsetDictType); // 创建 Dict
zs->zsl = zslCreate(); // 创建 SkipList
o = createObject(OBJ_ZSET,zs);
o->encoding = OBJ_ENCODING_SKIPLIST;
return o;
}20.8.5 Hash
普通小型 Hash 使用 listpack,字段与值相邻保存。默认 hash-max-listpack-entries 512 限制字段与值的对数,hash-max-listpack-value 64 限制单个字段或值的字节长度,超过条件时转换为 hashtable。
CONFIG GET hash-max-listpack-entries hash-max-listpack-value
# 1) "hash-max-listpack-entries"
# 2) "512"
# 3) "hash-max-listpack-value"
# 4) "64"
# 配置返回顺序不保证,数值为 Redis 8.2.x 默认值Redis 7.4 加入字段级过期。Redis 8.2.x 中,为 listpack Hash 的字段设置过期时间时,会使用带过期元数据的 listpackex,hashtable Hash 也支持字段过期。字段 TTL 控制单个字段,键 TTL 控制整个 Hash 的存活时间。参见 HEXPIRE 官方参考和 Redis 8.2.3 的 Hash 实现。
21 网络模型
21.1 用户态与内核态
用户态和内核态区分执行权限。应用通常通过系统调用请求内核管理的网络、文件和内存资源。x86 常用 Ring 3、Ring 0 表示相应特权级,其他体系结构的机制不同。
普通 socket 读写通常涉及用户缓冲区与内核缓冲区之间的数据复制。内核报告“可读”或“可写”,表示此时相应操作可以推进,不表示一个完整应用消息已经到达,也不表示所有待发送数据都能一次写完。
下面区分阻塞、非阻塞、多路复用、信号驱动和异步 IO。Redis 的客户端连接主要使用非阻塞 socket 与事件循环,多路复用负责发现就绪连接。
21.2 阻塞 IO
阻塞 socket 上的 recv 可等待数据到达,连接关闭或发生错误也会使调用返回。事件循环若在尚无数据的连接上阻塞,会延迟其他连接的处理。
21.3 非阻塞 IO
非阻塞 socket 上,暂时没有可读取数据时,recv 返回 -1 并设置 EAGAIN 或 EWOULDBLOCK。应用可以配合多路复用等待下一次就绪通知,普通 recv 的数据复制仍在调用中完成。
21.4 IO 多路复用
文件描述符(FD)标识进程打开的资源,socket 也使用 FD。多路复用让一个线程等待多个 FD 的就绪事件,再对相应连接执行读写。
| 接口 | 描述符限制 | 传入方式 | 返回后处理 |
|---|---|---|---|
select | Linux/glibc 常见 FD_SETSIZE 为 1024,描述符数值必须小于该值 | 每次传入 fd_set | 检查返回集合中的置位描述符 |
poll | 没有 fd_set 的固定上限,仍受资源限制 | 每次传入 pollfd 数组 | 遍历数组检查 revents |
epoll | 没有 fd_set 的固定上限,仍受描述符、内存及监听限制 | 维护已注册的监听集合 | 遍历返回的就绪事件 |
select
select 会修改传入集合,下次调用通常需要重新准备。固定大小的 fd_set 和扫描成本限制了其扩展性,描述符上限由平台和库实现决定。参见 select(2)。
poll
poll 使用 pollfd 数组,每个元素指定 FD 与关注事件,返回时在 revents 中给出状态。调用和返回处理都涉及整个数组,连接数量增加时扫描开销也随之增加。
#include <poll.h>
// 接口声明,struct pollfd 由头文件定义
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// timeout 单位为毫秒,负值表示无限等待,0 表示立即返回
// events 表示关注事件,revents 表示实际事件
// 常见标志包括 POLLIN、POLLOUT、POLLERR、POLLHUP、POLLNVAL返回值是有事件的数组元素数,0 表示超时,-1 表示错误。应用还要检查挂断和错误等事件。
epoll
epoll_create1 创建实例,epoll_ctl 增删改监听,epoll_wait 等待并返回就绪事件。监听集合保存在内核,不需要每次传入完整集合。事件交付仍有内核与用户态之间的复制和处理成本。
#include <sys/epoll.h>
// 接口声明,struct epoll_event 由头文件定义
int epoll_create1(int flags);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
// op 可取 EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL
// epoll_wait 的 timeout 单位为毫秒,-1 无限等待,0 立即返回LT 与 ET
- LT(Level Triggered):默认模式,只要相应就绪条件持续满足,后续等待仍可以报告该事件。例如缓冲区有未读数据时可以继续报告可读。
- ET(Edge Triggered):关注状态变化,不能依赖未处理完的就绪状态被反复报告。通常使用非阻塞 socket,循环读写直到
EAGAIN,并正确处理短读、短写及断连。
Redis 8.2.x 的 epoll 后端未设置 EPOLLET,使用 LT。参见 epoll(7)和 Redis 8.2.3 的 epoll 后端。
21.5 信号驱动 IO
通过异步通知配置,可以让就绪事件产生 SIGIO 等信号,应用再进行读写。普通信号可能合并,信号处理函数也受异步信号安全约束。
21.6 异步 IO
异步 IO 先提交操作,再通过完成通知或结果队列获取结果。就绪通知表示“现在可以尝试读写”,完成通知表示“提交的操作已经完成”,这是它与多路复用的区别。
Linux 的 POSIX AIO、原生 AIO 和 io_uring 是不同接口,能力与实现也不同。glibc 的 POSIX AIO 通常使用用户态线程实现。Redis 8.2.x 的常规客户端事件循环在 Linux 上使用 epoll。
21.7 Redis 的线程与进程
Redis 8.2.x 的常规数据命令主要由主线程串行执行,网络读写、协议解析和后台工作可以分配给其他线程。不同客户端命令的执行顺序由服务端调度决定。
需要区分:
- IO 线程处理连接读写和部分协议解析,常规数据结构操作仍在主线程。
- 后台线程承担部分 AOF fsync、关闭文件和延迟释放对象等工作。
BGSAVE、BGREWRITEAOF使用子进程。
Redis 6.0 引入可配置的多线程 IO,旧版可用 io-threads-do-reads yes 开启读取处理。Redis 8.0 起重构了 IO 线程机制,8.2.x 启用 io-threads N 后同时处理读、写及协议解析,io-threads-do-reads 已不再生效。默认 io-threads 1,相当于不启用额外 IO 线程。
是否增加线程应依据 CPU 与网络测量。大型集合操作、长 Lua 脚本等可能占用主线程较长时间,此时 CPU 也会成为瓶颈。参见 Redis 8.2.3 的 IO 线程实现和配置说明。
22 Redis 通信协议
22.1 RESP
RESP(REdis Serialization Protocol)是 Redis 客户端与服务端之间的序列化协议,常见传输是 TCP,也支持 Unix socket。协议用 ASCII 前缀标识数据类型,bulk string 按字节长度传输,可以包含任意二进制数据。
Redis 2.0 起使用 RESP2,Redis 6.0 加入 RESP3 和 HELLO 协商。Redis 8.2.x 的新连接初始使用 RESP2,客户端可以发送 HELLO 3 切换,部分驱动会主动协商 RESP3,因此应用实际使用的版本取决于驱动配置。
22.2 RESP2 数据类型
RESP2 用首字节区分五类数据,头部以 CRLF(\r\n)终止:
| 首字节 | 类型 | 示例 |
|---|---|---|
+ | 简单字符串 | +OK\r\n |
- | 错误 | -ERR message\r\n |
: | 有符号 64 位整数 | :1024\r\n |
$ | bulk string | $5\r\nhello\r\n |
* | 数组 | *2\r\n$1\r\na\r\n$1\r\nb\r\n |
$0\r\n\r\n 是空字符串,$-1\r\n 是空 bulk string,*-1\r\n 是空数组,与 *0\r\n 的零元素数组不同。空值不只表示键不存在,也可能表示阻塞读取超时等结果。数组元素可以嵌套,也可以是错误。
Redis 请求通常是 bulk string 数组。SET name zhh 可以表示为下面的字节序列,展示中的转义符表示实际 CR、LF 字节,长度按字节计算:
*3\r\n$3\r\nSET\r\n$4\r\nname\r\n$3\r\nzhh\r\nredis-cli --no-raw 将响应格式化后显示。观察原始响应时可用 TCP 工具或抓包,例如本地未启用 TLS 的实例:
printf '*1\r\n$4\r\nPING\r\n' | nc -N 127.0.0.1 6379
# 响应字节为 +PONG\r\n。-N 是 OpenBSD netcat 的选项RESP3 增加映射、集合、布尔值、浮点数和 push 等类型。解析器必须按已协商的版本处理响应。
22.3 用 Go 模拟客户端
下面的教学示例只实现 RESP2,处理简单字符串、错误、整数、bulk string 和递归数组。它区分空值与空字符串,按长度读取二进制内容,并在数组中保留错误元素,避免未消费完响应而破坏后续读取。
运行 go run docs/db/codes/redis/resp.go,默认连接本地 127.0.0.1:6379,也可通过 REDIS_ADDR 指定地址。示例使用 resp-demo:* 键,需在实验实例执行,未实现认证、TLS、连接池或重试。
package main
import (
"bufio"
"fmt"
"io"
"net"
"os"
"strconv"
"strings"
"time"
)
type redisError string
func (e redisError) Error() string { return string(e) }
func sendRequest(w *bufio.Writer, args ...string) error {
if _, err := fmt.Fprintf(w, "*%d\r\n", len(args)); err != nil {
return err
}
for _, arg := range args {
// len(string) 是字节数,参数可以含 NUL、CR、LF
if _, err := fmt.Fprintf(w, "$%d\r\n%s\r\n", len(arg), arg); err != nil {
return err
}
}
return w.Flush()
}
func readLine(r *bufio.Reader) (string, error) {
line, err := r.ReadString('\n')
if err != nil {
return "", err
}
if !strings.HasSuffix(line, "\r\n") {
return "", fmt.Errorf("invalid RESP line ending")
}
return line[:len(line)-2], nil
}
// err 表示传输或格式错误,服务端错误保留为 redisError 值。
// 数组中的错误也要完整读取,不能提前返回并留下未消费的元素。
func readResponse(r *bufio.Reader, depth int) (any, error) {
if depth > 64 {
return nil, fmt.Errorf("RESP nesting exceeds demo limit")
}
prefix, err := r.ReadByte()
if err != nil {
return nil, err
}
if !strings.ContainsRune("+-:$*", rune(prefix)) {
return nil, fmt.Errorf("unsupported RESP prefix %q", prefix)
}
line, err := readLine(r)
if err != nil {
return nil, err
}
switch prefix {
case '+':
return line, nil
case '-':
return redisError(line), nil
case ':':
return strconv.ParseInt(line, 10, 64)
}
n, err := strconv.ParseInt(line, 10, 64)
if err != nil || n < -1 {
return nil, fmt.Errorf("invalid RESP length %q", line)
}
if n == -1 {
return nil, nil
}
if prefix == '$' {
// 教学示例限制单个字符串为 64 MiB,区别于服务端配置
if n > 64<<20 {
return nil, fmt.Errorf("bulk string exceeds demo limit")
}
data := make([]byte, int(n)+2)
if _, err := io.ReadFull(r, data); err != nil {
return nil, err
}
if data[n] != '\r' || data[n+1] != '\n' {
return nil, fmt.Errorf("invalid bulk string ending")
}
return string(data[:n]), nil
}
if n > 1<<20 {
return nil, fmt.Errorf("array exceeds demo limit")
}
values := make([]any, int(n))
for i := range values {
values[i], err = readResponse(r, depth+1)
if err != nil {
return nil, err
}
}
return values, nil
}
func main() {
addr := os.Getenv("REDIS_ADDR")
if addr == "" {
addr = "127.0.0.1:6379"
}
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
panic(err)
}
defer conn.Close()
if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
panic(err)
}
r := bufio.NewReader(conn)
w := bufio.NewWriter(conn)
commands := [][]string{
{"SET", "resp-demo:name", "zhh\x00\r\n"},
{"SET", "resp-demo:empty", ""},
{"DEL", "resp-demo:missing"},
{"MGET", "resp-demo:name", "resp-demo:empty", "resp-demo:missing"},
{"LPOP", "resp-demo:name"}, // 演示服务端错误
{"PING"}, // 错误之后仍能读取下一条响应
}
for _, args := range commands {
if err := sendRequest(w, args...); err != nil {
panic(err)
}
value, err := readResponse(r, 0)
if err != nil {
panic(err)
}
if serverErr, ok := value.(redisError); ok {
fmt.Printf("%s error: %s\n", args[0], serverErr)
continue
}
fmt.Printf("%s: %#v\n", args[0], value)
}
}23 Redis 内存回收
64 位 Redis 默认 maxmemory 0,表示不设置数据内存上限。可以配置:
maxmemory 4gb这个值用于演示,设置时应为复制和 AOF 缓冲、内存碎片、写时复制及操作系统预留空间。maxmemory 约束参与淘汰计算的内存,实际进程 RSS 可以超过该值。
过期删除根据到期时间运行。内存淘汰则在超出相应内存限额时按 maxmemory-policy 处理,可能删除尚未过期甚至没有 TTL 的键。
23.1 过期删除
DB 与到期时间
Redis 8.2.x 的 redisDb 通过 kvstore *keys 管理键空间,通过 kvstore *expires 索引带键级过期时间的对象。到期时间以绝对毫秒时间戳附在 kvobj 的相应布局中。hexpires 用于组织 Hash 字段过期的维护。
// Redis 8.2.3,省略阻塞、WATCH、统计等字段
typedef struct redisDb {
kvstore *keys; // 键空间
kvstore *expires; // 带键级到期时间的对象索引
ebuckets hexpires; // Hash 字段过期维护
// 其他字段省略
int id;
long long avg_ttl;
unsigned long expires_cursor; // 主动过期扫描游标
} redisDb;这些索引、对象及元数据都有内存开销。普通实例默认有 16 个数据库,可通过 databases 配置,Cluster 只使用数据库 0。
惰性删除
访问键时检查到期时间,已经过期的键按不存在处理,再按实例角色和删除策略完成相应清理。它避免给每个键单独设置定时器,但不访问的过期键仍需要主动清理,否则可能继续占用内存。
主动删除
Redis 主动扫描过期索引,用时间预算限制单次工作量。Redis 8.2.3 默认 active-expire-effort 1 时:
- SLOW 周期由
serverCron触发,基础时间预算是调度周期的 25%。当实际hz为 10 时,约为 25 ms。动态 hz 及负载会影响调度与实际执行时间。 - 每轮数据库扫描以约 20 个带 TTL 的键为目标,也限制扫描的桶数。
- 估计过期占比超过 10% 时可继续扫描,直到扫描完成或时间预算耗尽。
- FAST 周期在事件循环的
beforeSleep中按条件触发,基础预算 1 ms,间隔至少为该预算的两倍。
active-expire-effort 可调整扫描力度和预算。Hash 字段有独立的过期维护。
23.2 内存淘汰
判断淘汰时,Redis 会从分配器统计的内存中扣除部分不计入淘汰的复制、AOF 缓冲等开销,再与 maxmemory 比较。INFO memory 的 mem_not_counted_for_evict 可用于观察被扣除的内存。
下面是 processCommand 中的相关片段,省略其他命令检查:
int processCommand(client *c)
{
// ...
// 如果服务器设置了 server.maxmemory 属性
if (server.maxmemory && !isInsideYieldingLongCommand())
{
// 尝试进行内存淘汰 performEvictions
int out_of_memory = (performEvictions() == EVICT_FAIL);
trackingHandlePendingKeyInvalidations();
if (server.current_client == NULL)
return C_ERR;
if (out_of_memory && is_denyoom_command)
{
rejectCommand(c, shared.oomerr);
return C_OK;
}
server.pre_command_oom_state = out_of_memory;
}
// ...
}Redis 8.2.x 有以下八种策略。候选不足、无法释放足够内存时,仍可能拒绝受内存限制的命令。
| 策略 | 候选范围 | 选择依据 |
|---|---|---|
noeviction | 不主动淘汰 | 默认策略,超过限额后拒绝可能增加内存且受 OOM 检查约束的命令 |
volatile-lru | 有 TTL 的键 | 近似 LRU |
allkeys-lru | 所有键 | 近似 LRU |
volatile-lfu | 有 TTL 的键 | 近似 LFU |
allkeys-lfu | 所有键 | 近似 LFU |
volatile-random | 有 TTL 的键 | 随机 |
allkeys-random | 所有键 | 随机 |
volatile-ttl | 有 TTL 的键 | 优先选择采样中更早到期的键 |
没有带 TTL 的键时,volatile-* 没有可淘汰候选,行为接近 noeviction。读取及部分删除等减少内存的操作通常仍能执行。参见官方淘汰说明。
LRU 与 LFU
LRU(Least Recently Used)考虑最近访问时间,LFU(Least Frequently Used)考虑近期访问频率。Redis 使用采样和候选池近似选择,并不维护完整的全局 LRU 链表或精确频次排序。maxmemory-samples 默认 5,影响采样精度与开销。
对象头中的 lru 字段占 24 bit:LRU 模式保存秒级时钟信息,LFU 模式把高 16 bit 用于分钟级时间,低 8 bit 用于概率计数。
// Redis 8.2.3 对象头中的淘汰字段,省略其他成员
struct redisObject {
unsigned type : 4;
unsigned encoding : 4;
unsigned lru : 24;
// LRU:秒级时钟,存在回绕
// LFU:高 16 位为分钟级时间,低 8 位为概率计数
// 其他成员见前面的 redisObject 定义
};Redis 8.2.3 的 LFU 计数初值为 5,增加概率为:
base = max(counter - 5, 0)
P = 1 / (base × lfu-log-factor + 1)lfu-log-factor 默认 10。访问时以概率 P 增加计数,计数最大为 255。
lfu-decay-time 默认 1 分钟。访问或评估淘汰候选时,根据经过的衰减周期减少计数,最低为 0。配置为 0 时不衰减。LFU 用概率计数与时间衰减估计近期访问频率,再通过采样选择淘汰候选。