ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis 7.0 性能优化:五个被低估的新特性,助 QPS 提升 40%

Redis 7.0 性能优化:五个被低估的新特性,助 QPS 提升 40% 上个月帮一个电商团队做 Redis 7.0 集群性能优化刚升级完那几天群里最高频的问题是“为什么我把 io-threads 开到 8QPS 反而没什么变化”。说实话这个现象我见过太多次了。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 nameshoplib 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 nameshoplib\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.2ziplist时内存占用约 22GB升级到 Redis 7.0listpack后降到约 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 至少有几个副本已完成刷盘对本地实例来说通常传 1numreplicas表示要求多少个副本的 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或执行一次FLUSHALLRedis 主线程需要遍历并释放所有内存块。这个过程中所有其他命令都会被堵住。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% 是组合效果。我这次优化的电商会话服务负载模型大概是这样的数据量约 30GB90% 是小型 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设成了 4io-threads-do-reads也开启。它对这种高并发小包场景有正向收益但并不是最大的功臣。如果你在 8 核以下的机器上开 4 个 io-threads 反而可能造成上下文切换开销建议先从 2 开始压测观察收益再决定要不要加。最后分享一个个人习惯每次做完这类优化我都会用INFO commandstats记录改动前后每条命令的平均耗时和调用次数而不是只盯着总的 QPS。因为你很容易被总和数字骗过去——某个奇怪的慢命令可能在总量里占比不大但它是 p99 飙升的根源。能定位到具体命令级别的变化这次优化才算真正落实了。这次升级最大的体会是Redis 的性能提升不总在发布会标题里更多时候藏在那些不起眼的内部机制中值得花时间一个个挖出来验证。
返回列表