Redis 8.12-rc2 的 SEXPIRE:读路径砍掉一次往返,代价是 intset 直接跳成 hashtable
Redis 的 set 一直只有整个 key 一个过期时间。如果想让集合里的某一个成员单独消失——比如活跃会话集合中的某个 token,或者权限集合里的临时授权——只能自己搭侧索引:一个以过期时间为 score 的 sorted set,每次读的时候查一遍,再用 cron 定期清理。Redis 8.12(目前是 release candidate 2)把这个能力直接做进了 set 类型。因为 RC 还没有 Docker 镜像,我从源码编译了一份,实测了「服务器接管这件事」相比「应用代码自己处理」到底变了什么。
新增命令包括:SEXPIRE、SPEXPIRE、SEXPIREAT、SPEXPIREAT 给指定成员设过期时间,SPERSIST 清除过期时间,STTL、SPTTL、SEXPIRETIME、SPEXPIRETIME 读回过期信息。这些命令目前都还没有官方命令参考页,语法是从 COMMAND DOCS 和 release notes 里拼出来的。
读侧收益
这个特性的价值不在「设过期」,而在「之后判断成员是否还在」。旧模式下,「这个成员还有效吗」意味着 SISMEMBER 加上一次针对自己维护的过期索引的 ZSCORE,再在客户端和当前时间比较。新模式下,SISMEMBER 本身就已经反映了 TTL——成员过期后服务器已经把它删掉了。
准备两个 2000 成员的 set,给每个成员同样的 TTL,然后对两种模式各跑 2000 次有效性检查,重复三轮:
| run | 旧模式(SISMEMBER + ZSCORE) | 新模式(仅 SISMEMBER) | 差异 |
|---|---|---|---|
| 1 | 997.7 ms | 475.8 ms | 快 52.3% |
| 2 | 889.0 ms | 444.6 ms | 快 50.0% |
| 3 | 994.3 ms | 423.6 ms | 快 57.4% |
每次读少一次往返,三轮都稳定:大约省一半时间。本机回环上往返本来就便宜,仍然能砍 50%;换成真实网络到托管 Redis 实例,去掉一次往返只会更重要——不过这里只测了回环,没跑过的数字不写。
写侧代价
设过期这一步没有任何加速。对写 TTL 本身做基准,2000 个成员、三轮,旧模式是往侧索引里 ZADD,新模式是 SEXPIRE:
| run | 旧模式(ZADD 到侧索引) | 新模式(SEXPIRE) | 差异 |
|---|---|---|---|
| 1 | 497.2 ms | 506.7 ms | 慢 1.9% |
| 2 | 430.0 ms | 438.9 ms | 慢 2.1% |
| 3 | 483.7 ms | 599.7 ms | 慢 24.0% |
SEXPIRE 从来没有比一条 ZADD 更快,某一轮还慢了四分之一。知道它内部要干什么就不奇怪了:在 set 的内部结构里定位成员、可能要把整个 set 的编码转换掉以容纳 per-member 元数据、再把它排进主动过期周期。往一个无关的 sorted set 里 ZADD 什么都不用做。这个特性的节省完全在读侧——你不再需要那个侧索引。如果你的负载只写带过期的成员、从来不回查,这个特性只会让你多付一点,什么也不还。
内存与编码突变
这部分没什么人提。一个从来没有成员 TTL 的 set 会尽可能紧凑地存成员:小整数集合用 intset,小字符串集合用 listpack。一旦任何成员带上 TTL,集合就需要为每个成员准备一个独立的过期槽位,不管这个成员有没有 TTL,编码也随之改变。
对 listpack 编码的小字符串集合,转成 listpackex 后内存增加 42–75%,集合越小越糟,因为每条记录的开销占比更大:
| 集合大小 | 之前 | 对单个成员执行 SEXPIRE 之后 | 开销 |
|---|---|---|---|
| 10 成员 | 70 bytes | 122 bytes | +74.3% |
| 50 成员 | 270 bytes | 402 bytes | +48.9% |
| 100 成员 | 521 bytes | 753 bytes | +44.5% |
| 128 成员 | 689 bytes | 977 bytes | +41.8% |
这算预期内,虽然没写进文档。真正没预料到的是:小的 intset——300 个普通整数——根本没有对应的 TTL 感知编码。给其中一个成员设 TTL 不会转成某种整数专用的带过期编码,而是直接跳到完整 hashtable:
encoding before: intset, 636 bytes
encoding after SEXPIRE on one member: hashtable, 10388 bytes
delta: +9752 bytes (+1533.3%)
为了 300 个整数里的一个设过期,内存涨了 15 倍。而且回不去了。我用 SPERSIST 把 TTL 又删掉,集合仍然停在 listpackex/hashtable,不会退回 listpack/intset:
> SPERSIST t MEMBERS 1 a
(integer) 1
> OBJECT ENCODING t
"listpackex"
如果你打算在一个基本稳定、很少碰过期逻辑的 set 上用这个特性,要知道:从内存角度看,碰一次就是永久的,除非这个 key 因为别的原因被重写。
拒绝的用法
SEXPIRE 不接受负 TTL,这和 key 级别的 EXPIRE 有明显区别——后者负数或过去时间会立即删除 key:
> SEXPIRE myset -1 MEMBERS 1 x
(error) ERR invalid expire time, must be >= 0
想立即删除成员还是用 SREM;SEXPIRE 传 0 是可以的,会在下一轮过期扫描时删掉,但负值直接被拒。类型不对同样拒绝:
> SET strkey hello
> SEXPIRE strkey 100 MEMBERS 1 x
(error) WRONGTYPE Operation against a key holding the wrong kind of value
key 不存在或成员不存在,STTL 都返回 -2,和之前 hash field TTL 那批命令一致。
并发
我用 50 个线程对同一个成员同时发起 50 个 SEXPIRE,每个设置不同的 TTL。没有报错、没有数据损坏,最终 TTL 等于服务器最后执行的那次调用——Redis 无论多少客户端连接都串行执行命令。这与其说是对这个特性的测试,不如说是提醒:常规的 Redis 保证在这里依然成立,per-member TTL 没有在服务器内部引入新的竞态。真正要担心的是你这边,读取成员和基于它做动作之间的竞态,那一点没变。
派生命令是否保留 TTL
关于 SINTERSTORE 和 SUNIONSTORE 是否把成员 TTL 保留到目标集合,我没找到文档说明,于是直接测了。结论是保留:
> SADD s1 x y z
> SEXPIRE s1 100 MEMBERS 1 x
> SINTERSTORE dest s1
> STTL dest MEMBERS 1 x
(integer) 100
x 的 TTL 带着相同的剩余时间落到了 dest 上。我原本有一半预期是 store 类操作会把集合重建一遍、丢掉 per-member 元数据——毕竟有些操作会悄悄重置计数器或者类型。它没有。
INFO 观测
INFO stats 多了两个计数器:expired_subkeys 和 expired_subkeys_active。INFO keyspace 多了 subexpiry 字段,显示当前数据库里有多少个 key 至少有一个成员或 field 带 TTL。我给一个 5 成员的 set 里的两个成员设了短 TTL,之后再没读过这个 key,观察计数器自己变化:
keyspace right after: subexpiry=1
expired_subkeys_active before: 3
(... 等 3 秒,完全不访问这个 key ...)
expired_subkeys_active after: 5
keyspace after: subexpiry=0
这确认了后台主动过期周期确实会在没有任何读取的情况下清扫过期的 set 成员。对于「给 key 设了 TTL 之后就很少再碰」的负载,这一点很重要:没有主动清扫,过期但未被读取的成员占的内存会一直留着,直到某次读这个 key 触发惰性清理。
自己踩的坑
我一开始想验证 SINTERSTORE 是否保留 TTL,写的是 SINTERSTORE dest 1 s1——把 SINTERCARD 的 numkeys 参数风格照搬了过来。结果每次都返回空集合,有没有 TTL 都一样。差一点就写成「只要任一源成员带 TTL,SINTERSTORE 就会静默丢掉所有内容」,那会是个很抓人的发现,也完全是错的。
SINTERSTORE 根本不接受 numkeys 参数,语法就是 SINTERSTORE destination key [key ...]。我那个 1 被当成名为 "1" 的 key,它不存在,所以交集永远是空,跟 TTL 毫无关系。把同一条错误命令跑在一个完全没有 TTL 活动的 set 上,同样返回空——这就是破绽:真正的 TTL 相关 bug 不会在一个完全没碰过的 set 上也出错。
如何本地构建复现
8.12 RC2 目前还没有 Docker 镜像,从源码编译是最快的路径:
git clone --depth 1 --branch 8.12-rc2 https://github.com/redis/redis.git
cd redis && make -j4 BUILD_TLS=no
./src/redis-server --daemonize yes --save "" --port 6379
上面那些数字背后的命令,都是对着这个服务器跑的:
redis-cli SADD myset a b c
redis-cli SEXPIRE myset 100 MEMBERS 1 a
redis-cli STTL myset MEMBERS 1 a # 100
redis-cli STTL myset MEMBERS 1 b # -1, no TTL set
redis-cli OBJECT ENCODING myset # listpackex, once any member has a TTL
两个计时基准,用 redis-py:
import redis, time
r = redis.Redis(port=6379)
N = 2000
r.sadd('newset', *[f'm{i}' for i in range(N)])
for i in range(N):
r.execute_command('SEXPIRE', 'newset', 600, 'MEMBERS', 1, f'm{i}')
r.sadd('oldset', *[f'm{i}' for i in range(N)])
now = time.time()
for i in range(N):
r.zadd('oldset:idx', {f'm{i}': now + 600})
t0 = time.perf_counter()
for i in range(N):
r.sismember('newset', f'm{i}')
print('native:', time.perf_counter() - t0)
t0 = time.perf_counter()
for i in range(N):
r.sismember('oldset', f'm{i}')
r.zscore('oldset:idx', f'm{i}')
print('side-index:', time.perf_counter() - t0)
结论取舍
如果你维护侧索引的唯一目的就是让单个 set 成员过期,而且检查成员的频率高于设置过期,那么迁到 8.12 的 SEXPIRE 能让每次读少一次往返、读吞吐大约翻倍;代价是集合内存占用永久性跳一级,写路径也不会比原来更快。切换之前先判断这两个代价哪个对你更贵——如果你只是设过期、从来不回查,那就没必要换。