Redis 7 新特性:Function、Sharded Pub/Sub 与 listpack
第41章 Redis 7 新特性:Function、Sharded Pub/Sub 与 listpack
Redis 7.0 是继 Redis 6.0 之后最重大的版本升级,带来了三项架构级改进:以 Function 替代无状态 Lua 脚本、以 Sharded Pub/Sub 解决 Cluster 广播瓶颈、以 listpack 全面取代 ziplist。本章深入分析每项特性的设计动机、实现原理与生产使用注意事项。
41.1 Function:持久化的服务端脚本
41.1.1 Lua 脚本的历史局限
Redis 4.0 以前,服务端逻辑扩展的唯一手段是 EVAL 执行 Lua 脚本。EVAL 的设计是"临时性"的:
缺陷一:脚本不持久化
Redis 维护一张 lua_scripts 哈希表,key 是 SHA1,value 是脚本源码。SCRIPT LOAD 将脚本缓存到该表后,可以用 EVALSHA 调用。但这张表不写入 RDB / AOF,Redis 重启后缓存清空,客户端必须重新执行 SCRIPT LOAD。这在滚动重启、故障转移场景下极易引发 NOSCRIPT 错误。
缺陷二:无命名空间 所有脚本共用同一个 SHA1 命名空间。多团队、多应用共享一个 Redis 实例时,脚本管理混乱,无法按归属清理。
缺陷三:无版本管理 脚本内容变更后,SHA1 随之变化,客户端必须同步更新 SHA1,无法做到平滑的滚动更新。
缺陷四:只读副本限制
EVAL / EVALSHA 默认被认为是写操作,无法在只读副本(Replica)执行,即使脚本实际上只读。Redis 6.0 引入 EVAL_RO / EVALSHA_RO 作为补丁,但缺乏系统化声明机制。
41.1.2 Function 的设计
Redis 7.0 引入 Function,核心概念:
- Library(库):一个命名的容器,存储一组函数,是持久化单元。
- Function(函数):库内具名的可调用单元,通过
FCALL执行。 - Engine(引擎):当前仅支持 Lua(
#!lua),设计上支持扩展其他语言。
41.1.3 加载与调用
# 加载库(REPLACE 参数用于覆盖已有同名库)
FUNCTION LOAD "#!lua name=mylib\n
local function my_hello(keys, args)\n
return 'Hello ' .. args[1]\n
end\n
local function my_set(keys, args)\n
return redis.call('SET', keys[1], args[1])\n
end\n
redis.register_function('hello', my_hello)\n
redis.register_function('myset', my_set)"
# 调用函数(numkeys=0 表示不传 key 参数)
FCALL hello 0 World
# → "Hello World"
# 调用时传 key
FCALL myset 1 mykey myvalue
# → OK
# 只读版本:可在 Replica 节点执行
FCALL_RO hello 0 World
参数约定与 EVAL 相同:FCALL funcname numkeys [key [key ...]] [arg [arg ...]]
41.1.4 函数管理命令
# 列出所有库及其函数
FUNCTION LIST
# → 1) 1) "library_name"
# 2) "mylib"
# 2) 1) "engine"
# 2) "LUA"
# 3) 1) "functions"
# 2) 1) 1) "name" 2) "hello" 3) "description" 4) nil 5) "flags" 6) (empty array)
# 2) 1) "name" 2) "myset" ...
# 带 WITHCODE 参数查看源码
FUNCTION LIST WITHCODE
# 过滤特定库
FUNCTION LIST LIBRARYNAME mylib
# 删除库(同时删除库内所有函数)
FUNCTION DELETE mylib
# 导出所有函数(二进制,类似 DUMP)
FUNCTION DUMP
# → "\x\xf6\x00\xc3..."
# 从导出数据恢复(FLUSH 参数先清空现有函数)
FUNCTION RESTORE <dump-data>
FUNCTION RESTORE <dump-data> FLUSH
# 清空所有函数
FUNCTION FLUSH
FUNCTION FLUSH ASYNC # 异步清空
FUNCTION FLUSH SYNC # 同步清空
41.1.5 持久化机制
Function 的持久化由 Redis 自动处理:
- RDB:
FUNCTION DUMP格式的数据作为一个特殊 RDB 类型存储,重启时自动加载。 - AOF:
FUNCTION LOAD命令写入 AOF,重放时重新加载。 - 主从复制:
FUNCTION LOAD命令通过复制流传播到所有副本,副本无需额外处理。
这彻底解决了 Lua 脚本的重启丢失问题。
41.1.6 函数标志(Flags)
注册函数时可声明标志,影响 Redis 对函数的行为:
-- no-writes:声明函数不写入,允许在只读副本执行,也允许在 maxmemory 策略下执行
redis.register_function{function_name='readonly_fn', callback=my_fn, flags={'no-writes'}}
-- allow-oom:即使超出 maxmemory 也允许执行(谨慎使用)
redis.register_function{function_name='critical_fn', callback=my_fn, flags={'allow-oom'}}
-- allow-stale:允许在 replica 数据滞后时执行(配合 replica-serve-stale-data no 场景)
redis.register_function{function_name='stale_ok', callback=my_fn, flags={'allow-stale'}}
41.1.7 Function vs EVAL 对比
| 维度 | EVAL / EVALSHA | FUNCTION / FCALL |
|---|---|---|
| 持久化 | 不持久化,重启丢失 | 持久化到 RDB/AOF |
| 命名空间 | 无(SHA1) | 有(库名.函数名) |
| 版本管理 | 无(SHA1 变化即失效) | 支持 REPLACE 原子升级 |
| 只读副本 | 需 EVAL_RO(6.0+) | 声明 no-writes flag |
| 管理命令 | SCRIPT LIST/FLUSH | FUNCTION LIST/DELETE/DUMP |
| 推荐场景 | 一次性临时逻辑 | 生产长期使用的服务端逻辑 |
41.2 Sharded Pub/Sub(Redis 7.0)
41.2.1 传统 Pub/Sub 在 Cluster 下的问题
Redis Cluster 中,传统 PUBLISH 命令会将消息广播到集群中所有节点(包括所有 Master 和 Replica),由每个节点转发给订阅了该频道的本地客户端。
广播开销分析:
- 假设集群有 10 个 Master 节点,每秒 PUBLISH 10 万条消息
- 每条消息需广播到 9 个其他节点 = 90 万条节点间消息/秒
- 节点间使用 Gossip 协议通信,频繁广播会占用大量带宽和 CPU
本质问题:Pub/Sub 消息与 HashSlot 无关,不能利用 Cluster 的槽路由,所有节点都需要处理所有消息。
41.2.2 Sharded Pub/Sub 设计
Redis 7.0 引入三个新命令:SSUBSCRIBE、SUNSUBSCRIBE、SPUBLISH,按照频道名的 HashSlot 路由消息。
路由规则:
- 频道名按照与 Key 相同的规则计算 HashSlot(支持
{tag}语法) SPUBLISH只将消息发送到负责该槽的节点(通常是 1 个 Master)- 该节点只通知本地订阅了该频道的客户端,以及该节点的 Replica
# 订阅分片频道(客户端会自动路由到正确节点)
SSUBSCRIBE {user:1000}:events
# → 1) "ssubscribe"
# 2) "{user:1000}:events"
# 3) (integer) 1
# 取消订阅
SUNSUBSCRIBE {user:1000}:events
# 发布消息到分片频道(只到负责该槽的节点)
SPUBLISH {user:1000}:events "login"
# → (integer) 1 # 收到消息的订阅者数量(只含该节点)
# 查看分片频道订阅信息
PUBSUB SHARDCHANNELS # 列出所有分片频道
PUBSUB SHARDNUMSUB channel1 # 查看频道订阅数
41.2.3 使用限制与注意事项
- 不支持 Pattern 订阅:
SSUBSCRIBE不支持通配符,只能精确订阅。 - 客户端路由:客户端库需要能感知 Cluster 拓扑,自动将 SSUBSCRIBE/SPUBLISH 路由到正确节点。支持情况:
- Lettuce 6.2+:原生支持
- Jedis 4.0+:需配合 JedisCluster 使用
- redis-py 4.3+:支持
- 仅限 Cluster 模式:单机、哨兵模式无意义(所有槽在同一节点,等同于普通 Pub/Sub)。
41.2.4 传统 vs 分片 Pub/Sub 对比
| 维度 | PUBLISH / SUBSCRIBE | SPUBLISH / SSUBSCRIBE |
|---|---|---|
| 集群消息传播 | 广播到所有节点 O(N) | 仅目标节点 O(1) |
| 支持 Pattern | 是(PSUBSCRIBE) | 否 |
| 使用场景 | 单机 / 小集群 / 需要 Pattern | 大集群,按业务分片 |
| Redis 版本 | 2.0+ | 7.0+ Cluster |
| 客户端复杂度 | 低 | 需要 Cluster-aware 客户端 |
41.3 listpack 全面替换 ziplist
41.3.1 ziplist 的设计缺陷:连锁更新
ziplist 是 Redis 用于紧凑存储小数据量容器的编码方式,每个 entry 包含:
| prevlen (1 or 5 bytes) | encoding (1-9 bytes) | data |
prevlen 存储前一个 entry 的总长度,用于反向遍历。当前一个 entry 长度 < 254 字节时,prevlen 占 1 字节;≥ 254 字节时,占 5 字节。
连锁更新(Cascade Update):
当向 ziplist 中间插入一个 entry,使某个 entry 的 prevlen 从 1 字节扩展为 5 字节,后一个 entry 的 prevlen 也需要同步更新(因为前一个 entry 变大了 4 字节),这可能触发连锁的 prevlen 更新,最坏情况 O(N²) 时间复杂度。
41.3.2 listpack 的改进
listpack(紧凑列表)于 Redis 5.0 引入(最初用于 Stream),7.0 全面推广。每个 entry 格式:
| encoding-type (1 byte) | data | backlen (1-5 bytes) |
关键改动:backlen 存储当前 entry 自身的长度(而非前一个 entry 的长度),反向遍历时通过当前 entry 的 backlen 跳到自身头部,再读前一个 entry。这样插入操作不需要修改任何后续 entry,完全消除连锁更新。
内存节省:
- ziplist entry 最小占 11 字节(1 prevlen + 1 encoding + 1 data + 4 prev_raw_len 预留)
- listpack entry 最小占 7 字节,每个 entry 节省 1-5 字节(视 prevlen 长度)
41.3.3 Redis 7.0 中的配置参数迁移
# Hash
hash-max-listpack-entries 128 # 原 hash-max-ziplist-entries
hash-max-listpack-value 64 # 原 hash-max-ziplist-value
# ZSet
zset-max-listpack-entries 128 # 原 zset-max-ziplist-entries
zset-max-listpack-value 64 # 原 zset-max-ziplist-value
# List(7.0 起 List 小数据量使用 listpack,大数据量用 quicklist)
list-max-listpack-size -2 # 原 list-max-ziplist-size(已在 7.0 重命名)
旧参数名仍向后兼容,但建议迁移到新名称。
验证编码:
HSET myhash f1 v1
OBJECT ENCODING myhash
# → "listpack" (7.0 以前返回 "ziplist")
# 当超过阈值时自动升级
# 添加 129 个 field 后
OBJECT ENCODING myhash
# → "hashtable"
41.4 Multi-Part AOF(Redis 7.0)
41.4.1 传统 AOF rewrite 的问题
传统 AOF rewrite(BGREWRITEAOF)流程:
- Fork 子进程,将当前内存状态写为新的 AOF 文件(
appendonly.aof.tmp) - 主进程将 rewrite 期间的新写命令累积在 AOF rewrite buffer(内存中)
- 子进程完成后,主进程将 rewrite buffer 追加到新 AOF 文件
- 原子 rename:
appendonly.aof.tmp→appendonly.aof
问题:步骤 3 中追加 rewrite buffer 需要加锁,若 rewrite 期间写入量极大(buffer 达几百 MB),rename 前的追加操作会阻塞主线程数百毫秒。
41.4.2 Multi-Part AOF 架构
Redis 7.0 将 AOF 拆分为多个文件,由一个 manifest 文件管理:
/data/
appendonlydir/
appendonly.aof.1.base.rdb # BASE 文件(全量,可以是 RDB 格式)
appendonly.aof.1.incr.aof # 增量 AOF(第1轮)
appendonly.aof.2.incr.aof # 增量 AOF(第2轮,rewrite 后新建)
appendonly.aof.manifest # 清单文件
manifest 文件格式:
file appendonly.aof.1.base.rdb seq 1 type b
file appendonly.aof.1.incr.aof seq 1 type i
file appendonly.aof.2.incr.aof seq 2 type i
rewrite 流程改进:
- 主进程新建
appendonly.aof.2.incr.aof,后续新写命令写入此文件 - Fork 子进程,将当前内存状态写为新的 BASE 文件
- 子进程完成后,更新 manifest(新 BASE 替换旧 BASE,旧 INCR 文件可删除)
- 无需追加 buffer 到大文件,rewrite 期间主进程已实时写到新 INCR 文件
rewrite 完成时,主线程只需原子更新 manifest 文件(极小操作),完全消除了大文件追加导致的阻塞。
41.4.3 重启恢复流程
1. 读取 appendonly.aof.manifest
2. 加载 BASE 文件(RDB 格式:直接加载;AOF 格式:逐行重放)
3. 按 seq 顺序依次重放所有 INCR 文件
4. 恢复完成
配置参数(无需修改,7.0 默认启用):
appendonly yes
appenddirname "appendonlydir" # 7.0 新增,指定 AOF 目录名
aof-use-rdb-preamble yes # BASE 文件使用 RDB 格式(压缩更好)
41.5 其他重要新特性
41.5.1 LMPOP / ZMPOP(原子批量弹出)
# LMPOP:从多个 list 中弹出,返回第一个非空 list 的元素
# 语法:LMPOP numkeys key [key ...] LEFT|RIGHT [COUNT count]
LMPOP 2 list1 list2 LEFT COUNT 5
# → 1) "list1" # 来源 key
# 2) 1) "elem1"
# 2) "elem2"
# ...
# ZMPOP:从多个 ZSet 中弹出
# 语法:ZMPOP numkeys key [key ...] MIN|MAX [COUNT count]
ZMPOP 1 zset1 MIN COUNT 3
# → 1) "zset1"
# 2) 1) 1) "member1" 2) "1.0"
# 2) 1) "member2" 2) "2.0"
# 3) 1) "member3" 3) "3.0"
使用场景:多队列优先级消费、跨多个 ZSet 弹出任务。
41.5.2 SINTERCARD(集合交集基数)
# 只返回交集元素数量,不返回元素本身(避免大集合传输开销)
SINTERCARD 2 set1 set2
# → (integer) 42
# LIMIT 参数:最多返回 N 个(提前终止计算,性能优化)
SINTERCARD 2 set1 set2 LIMIT 10
# → (integer) 10 (实际交集可能更大,但最多计算到 10)
41.5.3 XAUTOCLAIM(Stream 自动声明)
# 自动声明超过指定毫秒未 ACK 的 PEL 消息,转移到新消费者
# 语法:XAUTOCLAIM key group consumer min-idle-time start [COUNT count] [JUSTID]
XAUTOCLAIM mystream mygroup consumer2 60000 0-0 COUNT 10
# → 1) "0-0" # 下次调用的起始 ID(游标)
# 2) 1) (消息列表) # 被转移的消息
# 3) 1) (已删除的消息 ID,PEL 中有但 Stream 中已不存在)
与 XCLAIM 相比,XAUTOCLAIM 可以一次性扫描并声明多条超时消息,适合构建消费者故障恢复逻辑。
41.5.4 版本升级注意事项
从 Redis 6.x 升级到 7.0 的兼容性检查清单:
# 1. 检查废弃命令使用情况
redis-cli COMMAND DOCS OBJECT # 查看命令文档,注意 deprecated 标记
# 2. 检查配置参数(新旧参数名均支持,但建议迁移)
grep -i ziplist /etc/redis/redis.conf # 找出旧参数名
# 3. 验证 AOF 目录(7.0 默认使用目录而非单文件)
# appendonly.aof → appendonlydir/appendonly.aof.*
# 升级前确保 appenddirname 路径可写
# 4. Cluster 模式:PUBLISH 行为不变,只有新增 SPUBLISH 才是分片
# 无需修改现有 Pub/Sub 代码,可按需迁移
本章小结
Redis 7.0 的三项核心改进各自解决了长期痛点:
- Function 让服务端脚本具备持久化、版本管理和命名空间,是生产环境替代 Lua EVAL 的正式方案。
- Sharded Pub/Sub 将 Cluster 的 Pub/Sub 从 O(N) 广播降为 O(1) 定向投递,解决了大集群的消息扩散瓶颈。
- listpack 通过消除
prevlen字段彻底根治了 ziplist 的连锁更新问题,并在 7.0 中成为 Hash、ZSet、List 的默认紧凑编码。 - Multi-Part AOF 从架构上消除了传统 AOF rewrite 的主线程阻塞窗口,提升了高写入场景的稳定性。
这些特性共同构成了 Redis 7 作为生产级分布式缓存/存储的坚实基础。