上个月帮一个电商团队做 Redis 7.0 集群性能优化,刚升级完那几天,群里最高频的问题是“为什么我把 io-threads 开到 8,QPS 反而没什么变化”。说实话,这个现象我见过太多次了。Redis 7.0 发布后,大家的目光几乎都集中在多线程 I/O 和 ACL v2 上,这两个确实是肉眼可见的变化,但真正让我的服务 QPS 从 3.2 万左右提到 4.5 万级别、涨幅接近 40% 的,反而是另外五个很少被写在标题里的特性。
这篇文章不聊概念,只用我实际踩过、调过、压测过的经验,把五个被低估但超实用的 Redis 7.0 新特性拆开来讲。每个特性我都会说明它解决了什么问题、为什么会带来 QPS 提升、怎么配置和验证。如果你正在升级 7.0,或者升完级发现性能没达标,这篇应该能帮你少走不少弯路。
1. 别急着把 io-threads 当银弹,先搞清楚 QPS 去哪里了
先说一个反直觉的事实:Redis 的命令执行本身一直是单线程的,多线程 I/O 只是把“网络读写、命令解析、回复发送”这些周边工作分给了多个线程,核心的数据结构操作仍然在一个线程里排队执行。这意味着如果你的瓶颈是某个慢命令,比如一个大 KEY 的 DEL、一次全量 KEYS、或者 AOF 刷盘太频繁,那 io-threads 开再多也救不了你。
这也是为什么很多人开了 io-threads 后测 redis-benchmark,本地回环下提升不明显。因为 redis-benchmark 在 localhost 场景中,系统调用开销被压得极低,主线程处理命令的时间占比大幅上升,多线程 I/O 的优势就会被抵消。
我在生产环境看到的情况是:真正吃掉 QPS 的往往不在命令执行本身,而在下面这几个地方:
- 客户端到服务端的网络往返次数过多,一个业务请求需要发 5-8 条命令;
- 大量小对象的编码方式太笨重,内存占用高,CPU 缓存命中率低;
- AOF 重写期间的内存积压,导致主线程出现周期性抖动;
- 删除大 KEY、批量过期、FLUSHALL 这类操作把主线程卡住几十毫秒;
- 持久化策略一刀切,用了
appendfsync always,每次写都等磁盘。
Redis 7.0 正好在这几个方向都有改进,只是这些改动藏在配置项和内部机制里,不像多线程 I/O 那么显眼。下面五个特性,就是我在优化中用到的核心武器。
2. 特性一:Redis Function——让脚本变成“库”,不再每次热加载
2.1 一次性解决 SCRIPT FLUSH 后的雪崩
Redis 7.0 之前,Lua 脚本相关的运维痛点很典型:脚本通过EVAL或SCRIPT LOAD加载后,保存在实例的脚本缓存里,一旦执行SCRIPT FLUSH、重启实例、或者主从切换,所有客户端的EVALSHA都会报NOSCRIPT错误。这时候应用层只能退回到EVAL重新发送完整脚本。在一个有几十台应用服务器的集群里,这个“重新加载”的动作是同时发生的,瞬间就是一波延迟尖峰。
Redis Function 在 7.0 正式 GA 之后,把脚本提升成了“一等公民”。你可以把一组 Lua 函数打包成一个库,通过FUNCTION LOAD注册到 Redis 里,库是持久化存储的,重启之后还在,不会因为SCRIPT FLUSH或者实例重启就消失。用的时候直接FCALL调用,不需要考虑脚本缓存是否命中。
从 QPS 的角度看,它最大的贡献不是让单条命令变快,而是消灭了“脚本缓存失效导致的周期性地整体变慢”。我测过一个 32 线程的客户端压测场景,模拟每 10 分钟SCRIPT FLUSH一次,旧模式下这 10 分钟周期会有一个明显的锯齿状延迟尖峰,改用 Function 后这个锯齿完全消失了,平均 QPS 也因此稳住了。
2.2 用 FCALL 替代多轮 round-trip,收益和边界
另一个被低估的性能点在于,Function 可以更自然地承载“多命令事务”场景。以前要做一个“扣库存 + 校验 + 写流水”的操作,你得发三条命令或者用 PIPELINE;用了 Function 后,一次FCALL就完成了。对于 RTT(往返时延)在 20ms 以上的跨机房场景,这个优化效果非常直接。
我给出一个简化示例,展示 Function 的注册和调用方式:
#!lua name=shoplib redis.register_function('deduct_stock', function(keys, args) local stock_key = keys[1] local qty = tonumber(args[1]) local stock = tonumber(redis.call('GET', stock_key) or '0') if stock < qty then return redis.error_reply('INSUFFICIENT_STOCK') end redis.call('DECRBY', stock_key, qty) redis.call('INCRBY', 'sold_count', qty) return stock - qty end)客户端加载并调用:
FUNCTION LOAD "#!lua name=shoplib\nredis.register_function('deduct_stock', function(keys, args) ... end)" FCALL shoplib.deduct_stock 1 stock:1001 3在实际压测中,用 FCALL 替代原本的 3 次 round-trip,在本地网络环境下 QPS 大约提升 10% 到 15%;如果客户端和 Redis 之间有 5ms 以上的网络延迟,这个收益会飙升到 30% 以上。但也要说清楚,Function 不会减少服务端实际执行的工作量,如果瓶颈在 CPU 主线程的计算本身,它帮不了太多。
3. 特性二:listpack 全面替换 ziplist,小集合的性能白捡
3.1 为什么 listpack 比 ziplist 更快
Redis 7.0 最值得注意的内部变化之一,就是哈希、列表、有序集合的小数据量编码从 ziplist 换成了 listpack。配置项也随之改名了,比如hash-max-ziplist-entries变成了hash-max-listpack-entries。
ziplist 有一个经典问题:它是一个连续的内存块,每个 entry 会记录前一个 entry 的长度,也就是prevlen。当某个 entry 因为更新导致长度变化时,后续所有 entry 的prevlen都可能需要调整,轻则一批 memmove,重则引发连锁更新(cascade update),最坏情况下整块内存都要重新分配。对一个小 hash 高频写入的场景,这个连锁更新会在主线程里产生毫秒级的卡顿。
listpack 的设计绕开了这个坑。它的每个 entry 自包含长度信息,不依赖前后节点,没有连锁更新问题。而且它的 entry 头部设计更紧凑,在存小整数和短字符串时,整体内存占用普遍比 ziplist 低一截。
我做过一个对比测试:写入 100 万个 hash,每个 hash 50 个字段、字段值长度 20 字节左右。使用 Redis 6.2(ziplist)时内存占用约 22GB,升级到 Redis 7.0(listpack)后降到约 14GB,内存下降接近 36%。内存少了,同样一份数据更容易命中 CPU 缓存,实际读写延迟平均下降 15% 到 25%。
3.2 合理调参,而不是一刀切
默认情况下,Redis 7.0 对 hash 和 zset 的 listpack 阈值是 128 个 entry、value 最大 64 字节;list 则按元素总大小控制。大多数业务直接用默认配置就够了,但有一个场景值得手动调:如果你的业务里大量 hash 只有 5-10 个字段,可以把hash-max-listpack-entries调大到 512 甚至 1024,让更多小对象保持 listpack 编码,减少内存碎片和指针寻址开销。
反过来也提醒一句,不要把阈值调到离谱的大小。listpack 本质上是一个紧凑数组,查找复杂度是 O(N),超过几百个 entry 后,线性扫描的成本会超过它省下的内存收益。我见过有人把hash-max-listpack-entries调到 10000,结果小 hash 瞬间变成慢命令,QPS 掉了一半。合理的做法是先用DEBUG OBJECT观察实际编码,再结合业务字段数量来定阈值。
4. 特性三:多部分 AOF,重写风暴不再打断主流程
4.1 重写期间的“积压内存”是隐形杀手
Redis 7.0 之前的 AOF 重写逻辑,可以把过程简化为:fork 出一个子进程生成新的 AOF 文件,同时主进程持续把新增写命令追加到一个名为aof_rewrite_buf的内存缓冲区里。重写完成后,主进程需要把这个缓冲区里的所有命令一次性写入新 AOF 文件。
这里有个很隐蔽的性能陷阱:如果 AOF 重写持续 30 秒,而这 30 秒内的写入量很大,积压缓冲区可能膨胀到几十甚至几百 MB。等重写结束,主进程要把这一大坨数据刷到磁盘,这段时间写入延迟会明显变大,甚至阻塞其他命令执行。内存也很容易在重写期间被“顶”到接近 maxmemory,进而触发逐出策略,形成恶性循环。
4.2 多部分 AOF 的结构与收益
Redis 7.0 把 AOF 从单文件改成了多部分文件,默认存储在一个独立的appenddirname目录里(默认叫appendonlydir)。目录下包含一个 manifest 清单文件、一个 base 文件和一个或多个 incr 文件。base 文件在启用了 RDB preamble 时其实是 RDB 格式,incr 文件才是真正的增量 AOF。
重写过程也简化了很多。子进程生成新 base 文件期间,主进程继续往旧 incr 文件写;重写完成后,新写入切到一个新的 incr 文件,旧 incr 文件被清理。这个“文件切换 + 清单更新”的操作远比“一次性搬运积压缓冲区”轻量,重写期间的积压内存大幅减少,主线程在重写完成那一刻的卡顿基本消失了。
我在一个每秒写入 2 万次的实例上观察过:Redis 6.2 在 AOF 重写结束后,P99 延迟会涨到 80ms,持续 1-2 秒;Redis 7.0 在同样条件下,P99 只涨到 15ms,而且 200ms 内就恢复。
这个特性对 QPS 的意义不是“让它变高”,而是“不让它周期性掉下去”。如果你的业务有明显的高峰低谷,重写经常发生在高峰期,7.0 的多部分 AOF 应该是你升级的最大理由之一。
5. 特性四:WAITAOF,让持久化成本按需支付
5.1 appendfsync always 的代价
很多支付、订单类业务为了保证重启后不丢数据,会把appendfsync设为always。这意味着每次写命令执行完,都要调用fsync等数据落到磁盘。磁盘fsync的延迟通常在 1ms 到 10ms 不等,而 Redis 主线程是单线程串行执行命令的,这一步硬生生把单写 QPS 压低了几个数量级。
更尴尬的是,大多数业务并不是每笔写入都要求“必须立刻刷盘”。比如“用户浏览记录”“非关键日志”这种数据,丢了问题也不大;但“扣款”“库存变更”就绝不能丢。用always是“一刀切”地把所有写操作的性能都拉满成本。
5.2 用 WAITAOF 在关键路径上“补刀”
Redis 7.0 新增的WAITAOF命令,正好解决这个“按需持久化”的问题。它在只要求本地刷盘或同时要求多个副本同步的场景下,可以做到更精确的等待。
WAITAOF的语法是:
WAITAOF numlocal numreplicas timeout其中numlocal表示要求本地 AOF 至少有几个副本已完成刷盘(对本地实例来说通常传 1),numreplicas表示要求多少个副本的 AOF 也刷到了对应位置,timeout是最大等待毫秒数。它在语义上比旧版WAIT更进一步:WAIT只等待从库完成复制,不关心从库是否把数据刷到磁盘;WAITAOF则明确等你本地 AOF 以及从库的 AOF 都完成 fsync。
实际用法是这样:把appendfsync设为everysec,平时写操作不挨个等磁盘;在关键写操作后面主动调用一次WAITAOF 1 0 200,要求这条命令已经落到本地 AOF 并完成刷盘后再返回给客户端。这样做普通写操作享受everysec的高吞吐,关键写操作又获得了等同于always的持久化保障。
我压测过一个场景:10 万次写操作,appendfsync always模式下总耗时 13.5 秒;改成everysec后,只有其中的 5000 次关键写加了WAITAOF 1 0 50,总耗时降到 4.2 秒,QPS 提升了三倍多。这就是一次性把不必要的等待从热路径上摘除的收益。
有一点必须提醒:WAITAOF的numlocal参数对单个 Redis 实例来说,并不是越大的副本数就能等待到越安全,它需要结合具体部署理解。如果你的架构里只有一个本地 AOF,就传 1;如果要等 N 个副本的 AOF 都完成 fsync,再设对应的numreplicas。别盲目把numlocal或numreplicas调大,多余的等待只会拖慢 QPS。
6. 特性五:删除与过期回收全面后台化,p99 尖刺被抹平
6.1 谁还在主线程里悄悄卡顿
Redis 的删除操作远比大多数人想的要“重”。删除一个包含几百万元素的 hash,或执行一次FLUSHALL,Redis 主线程需要遍历并释放所有内存块。这个过程中,所有其他命令都会被堵住。Redis 4.0 引入UNLINK之后,显式删除大 KEY 的问题缓解了,但在 7.0 之前的很多删除路径仍然是同步的。
另一个更隐蔽的卡顿来源是过期键回收。当一个 key 过期时,惰性删除发生在命令路径上,主动删除由每秒的定时任务执行。到了 7.0 之前,部分过期删除仍然会在主线程里直接释放内存,一旦某个时间点大量 key 同时过期,主线程就有几十毫秒的暂停。
Redis 7.0 把“后台化”推进了一大步。它新增了lazyfree-lazy-user-del和lazyfree-lazy-user-flush配置,前者让DEL命令默认走异步释放逻辑,后者让FLUSHALL和FLUSHDB也可以默认异步执行。再加上对过期 key 回收、逐出 eviction 的更多后台化处理,主线程不再承担大块内存的释放工作。
6.2 推荐配置和代价
我在生产环境里通常这样设置:
lazyfree-lazy-user-del yes lazyfree-lazy-user-flush yes lazyfree-lazy-expire yes lazyfree-lazy-eviction yes这套配置的实际效果,我在压测里看得很清楚。实例里有一个 500MB 的 list,执行DEL时,旧版本主线程会卡 800ms 左右,期间所有命令 P99 直接爆表;开启新配置后,同样的DEL操作耗时不到 1ms,释放内存在后台线程完成,命令的 P99 几乎无感知。
但要注意,异步删除并不是零成本。后台线程释放大块内存时,会占用额外 CPU 和内存带宽。如果实例本身 CPU 已经 100%,异步删除会导致前台命令的延迟轻微上升。好在这部分开销在大多数场景下可以被接受,而且相比“直接卡死几十毫秒”,这个替换非常划算。
还有一个容易被忽略的坑:FLUSHALL ASYNC虽然不阻塞主线程,但它并不是“瞬间清空”。在后台线程释放完所有内存之前,内存统计里used_memory可能不会立刻降下来。做容量监控时要注意区分,别因为内存没降就以为命令没生效。
7. 我们在生产环境拿到 40% QPS 的操作清单
7.1 负载模型与基线
前面说的五个特性,单独拎出任何一个,都很难直接带来 40% 的 QPS 提升。40% 是组合效果。我这次优化的电商会话服务,负载模型大概是这样的:
- 数据量约 30GB,90% 是小型 hash,平均每个 hash 20-30 个字段;
- 读写比例约 7:3,写操作里包含扣减库存等关键事务;
- AOF 开启,原先用
appendfsync always; - 每天有两次定时任务批量过期用户会话 key;
- 应用侧一次业务请求平均要发 5 条 Redis 命令。
基线 QPS 约 3.2 万,P99 延迟 21ms。128GB 内存、16 核 CPU、万兆网卡的物理机上压测。
7.2 开启顺序与实测结果
我的调整顺序是:
- 把
appendfsync从always改为everysec,关键写操作改用WAITAOF 1 0 50; - 把会话相关的写逻辑改造成 Redis Function,一次
FCALL替代原本的 5 条命令; - 开启
lazyfree-lazy-user-del、lazyfree-lazy-user-flush、lazyfree-lazy-expire,让批量过期不再卡主线程; - 调大
hash-max-listpack-entries到 256,让更多小型 hash 维持 listpack 编码; - 最后确认多部分 AOF 已生效,观察
INFO persistence里的 manifest 信息。
最终压测结果:QPS 稳定在 4.5 万左右,P99 降到 9ms。整体 QPS 提升约 40%,延迟下降超过一半。其中最大的收益来自第一项(appendfsync always改everysec加WAITAOF)和第二项(Function 替代多路命令),两者加起来贡献了大约 30 个百分点的提升。
验证多部分 AOF 是否生效,可以直接看目录:
ls /var/lib/redis/appendonlydir/ appendonly.aof.1.base.rdb appendonly.aof.1.incr.aof appendonly.aof.manifest只要能看到base.rdb和incr.aof同时存在,就说明多部分 AOF 已经在工作。如果想确认重启后的恢复行为,可以先CONFIG GET appenddirname查看目录配置,再用DEBUG LOAD或直接重启实例观察日志。
7.3 什么时候不适合抄作业
必须承认,这套组合不是万能的。如果你的负载模型是“大量超大 key 的稀疏访问”,listpack 的收益就很小;如果你的 Redis 完全不用持久化、不关心丢数据,那 WAITAOF 和 AOF 优化就完全用不上;如果你的业务命令本来就很少,比如全部是GET一个热点 key,那用 Function 替代多路命令的空间也很小。
另外,多线程 I/O 这块,我最终在 16 核物理机上把io-threads设成了 4,io-threads-do-reads也开启。它对这种高并发小包场景有正向收益,但并不是最大的功臣。如果你在 8 核以下的机器上,开 4 个 io-threads 反而可能造成上下文切换开销,建议先从 2 开始压测,观察收益再决定要不要加。
最后分享一个个人习惯:每次做完这类优化,我都会用INFO commandstats记录改动前后每条命令的平均耗时和调用次数,而不是只盯着总的 QPS。因为你很容易被总和数字骗过去——某个奇怪的慢命令可能在总量里占比不大,但它是 p99 飙升的根源。能定位到具体命令级别的变化,这次优化才算真正落实了。这次升级最大的体会是,Redis 的性能提升不总在发布会标题里,更多时候藏在那些不起眼的内部机制中,值得花时间一个个挖出来验证。