ARTICLE DETAIL

资讯详情

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

Redis 7.0实战:五个被低估特性让QPS提升40%

Redis 7.0实战:五个被低估特性让QPS提升40% Redis 7.0 发布有大半年了身边不少团队都在纠结要不要升级。说实话我对大版本升级一直很谨慎尤其是 Redis 这种线上核心组件。不过上半年我负责的一个高并发推送服务遇到了明显的瓶颈才被迫把 Redis 5.0 升级到 7.0然后被几个“看起来不起眼”的新特性惊到了。很多人盯着多线程 I/O把 release notes 来回翻结果真正让 QPS 涨了 40% 的反而是那些容易被忽略的细节功能点和参数调整。这篇文章不聊那种装完就提升多少倍的天方夜谭就讲我在真实业务里怎么用 Redis 7.0 的 5 个新特性优化服务以及怎么用 JMeter 做动态 QPS 压测把这 40% 的提升复现出来。有些结论和官方文档说的不太一样都是我实际踩坑试出来的。1. 为什么说这 5 个特性“被低估”Redis 7.0 的完整更新列表很长社区讨论集中在多线程 I/O、ACL 增强、Redis Functions 这些显眼的大功能上。但真正在业务里做到 QPS 提升的往往是那些没被当成卖点的能力组合。我自己选型时会问三个问题这个特性解决的是不是我当下的痛点改动幅度大不大出问题了能不能快速回退先看整体收益是怎么算出来的。我把线上的请求分成三类一类是普通的 KV 读写一类是带有简单逻辑的原子操作一类是发布订阅的消息通道。Redis 5.0 时代这三类请求都会因为网络 IO 瓶颈、脚本重复加载、持久化毛刺这些问题被拖累。Redis 7.0 的很多改进恰恰是针对这些细节点单个拎出来每条只涨 5% 到 10%叠加起来就是 40% 这个量级。我最终选择的 5 个特性如下表。每个特性的取舍逻辑都是“低侵入、高收益、易回退”。特性解决的问题为什么容易被低估我获得的收益Redis Functions大量的 Lua 脚本加载和重复编译多线程 I/O 话题太热掩盖了脚本机制升级调用端 QPS 提升约 10%Multi Part AOFAOF 重写时大 Key 引起的阻塞和重启恢复慢日志架构改动一般没人关心峰值写入 QPS 稳定提升毛刺消失Sharded Pub/Sub集群模式下 pub/sub 广播风暴只有集群用户才会遇到普通单机用户无感推送类消息吞吐提升GC 压力下降多线程 I/O 正确配置网络读写和协议解析占满主线程有人说开多线程反而变慢导致很多人不敢碰多连接场景下整体吞吐提升 15%Listpack 与内存优化ziplist 大字段更新时的内存复制消耗外表只是“编码格式变化“感知不强内存占用下降读写延迟更稳定我把选型的主线梳理一下不考虑 Sharded Pub/Sub 的话普通单机用户能吃到前三项红利。但如果你用的也是集群架构那第 4 项和第 5 项的价值比很多人想象的大得多。2. 5 个核心特性的实操拆解这一节我不写概念直接讲怎么用、怎么配、注意什么。每个特性都附上我在代码或配置里的实际操作。2.1 Redis Functions服务端脚本的正确打开方式Redis 的 Lua 脚本能力一直很强大但 EVAL 每次调用都要把脚本体发送到服务端Redis 7.0 之前脚本也没有统一的版本管理和权限控制。我们之前做限流、防重、秒杀库存扣减写了 30 多个 Lua 脚本分散在各个服务端代码里每次改逻辑都要全链路发布很痛苦。7.0 的 Redis Functions 把脚本变成服务端一等公民通过 FUNCTION LOAD 加载一次之后用 FCALL 直接调用。它还是 Lua 引擎但脚本库会常驻内存省去了每次 EVAL 的传输和编译开销。这个本身就值 5% 到 10% 的 QPS 提升尤其在请求体较大的场景下更明显。# 加载一个简单的限流函数库 redis-cli FUNCTION LOAD #!lua namemylib local function rate_limit(keys, args) local key keys[1] local limit tonumber(args[1]) local current redis.call(INCR, key) if current 1 then redis.call(EXPIRE, key, args[2]) end if current limit then return 0 end return 1 end redis.register_function(rate_limit, rate_limit) # 调用 redis-cli FCALL rate_limit 1 user:123 10 60注意到几个小细节函数库里可以注册多个函数方便按业务域组织。加载语法中第一行#!lua namemylib是函数库定义不能省略。如果函数库已经存在FUNCTION LOAD会直接报错所以更新逻辑时可以先用FUNCTION DUMP导出再FUNCTION RESTORE恢复或者加REPLACE参数。实操时最大的收益不是语言层面而是治理层面。函数库可以带描述信息能支持FUNCTION LIST查看逻辑变更通过专门的发布流程完成不用再跟着业务服务发版。我强烈建议把原本散落在代码里的 Lua 脚本全部收敛到这个机制里哪怕只是平移也能避免很多线上脚本出问题后“找不到是哪个服务在发 EVAL”的窘境。2.2 Multi Part AOF持久化毛刺的终结者Redis 7.0 之前AOF 重写的机制是 fork 子进程生成全量重写缓冲区重写期间如果有大 Key 更新父子进程内存 Copy-On-Write 会引发明显的延迟毛刺极端情况主线程会卡出几十毫秒甚至更久。我在压测时见过最夸张的案例一次大 Key 重写导致 P99 从 5ms 飙到 500ms查询超时一片。7.0 的 Multi Part AOF 把 AOF 拆成三个部分base 文件全量基线、incr 文件增量日志、manifest 清单文件。重写时只需要生成新的 base不需要把整个 AOF 再看一遍重写期间产生的增量直接追加到新的 incr 上。目录结构变成这样appendonlydir/ ├── appendonly.aof.1.base.rdb ├── appendonly.aof.1.incr.aof └── appendonly.aof.manifest这里有个很容易踩的坑7.0 默认开启了aof-use-rdb-preamblebase 文件实际上是 RDB 格式。好处是加载快但如果你用老工具直接读 base 文件会看到一堆二进制而不是可读的 Redis 命令。曾经有同事在排查问题时想 grep AOF 文件看某个 key 的写入记录发现全是乱码以为文件损坏了。排查问题请用 redis-check-aof 或者 redis-cli --aof 读取别直接 less。我从 5.0 升级后的实际观察最直观的变化是重写期间写延迟的毛刺基本消失。7.0 还引入了aof-timestamp-enabled参数来记录时间戳方便数据恢复时精确到秒级。另外它的 manifest 文件是权威性的存在不要手动改名或者删除否则 Redis 会拒绝启动。2.3 Sharded Pub/Sub集群广播风暴的治理方案这个特性只对 Redis Cluster 用户有意义但也正因为很多人不用集群它才被严重低估。我们的推送服务早期在单机上用 pub/sub一切正常。后来数据量大了拆了集群一个频道消息会被广播到所有节点每个节点再推给各自的订阅客户端。连接数一多网络包泛滥表现为整个集群 CPU 不高但带宽被打满客户端大量断连。7.0 的 Sharded Pub/Sub 把 channel 按照 key slot 规则路由到固定的分片节点上发布和订阅都只发生在同一个分片内消息不再广播全集群。使用方法非常直白# 发布到分片频道 redis-cli -c SPUBLISH shard_channel hello # 订阅分片频道 redis-cli -c SSUBSCRIBE shard_channel注意命令上的差别是SPUBLISH和SSUBSCRIBE而不是原来的PUBLISH和SUBSCRIBE。和普通 pub/sub 不同分片频道的消息只会在同一个 slot 内转发所以并不会有全局广播的网络开销。代价是如果生产者和消费者正好被路由到不同分片它们就无法通过同一个分片频道通信设计时要注意 slot 一致性。我这边是把相同的业务 ID 作为 channel 名利用 Redis cluster 的 key hash 规则天然把同一类消息聚合到同一分片效果很好。2.4 多线程 I/O 的正确打开方式这个特性是 6.0 引入的7.0 继续优化但网上对它的误解最多。很多人以为开了多线程 I/O 就等于 Redis 变成多线程处理命令了于是无脑设置io-threads 8结果压测一看 QPS 反而下降就觉得 7.0 是“反向优化”。先把这个机制理解透。Redis 命令执行依然是单线程的多线程 I/O 解决的是网络数据读写和协议解析这部分开销。命令真正在内存里跑的还是单线程。所以适合多线程 I/O 的场景是网络 IO 占比高、单个命令执行时间极短、连接数多。不适合的场景是单个 value 特别大、命令本身耗时很长这种情况 CPU 反而会因为线程切换和内存拷贝增加开销。我当时的配置是这样的io-threads 4 io-threads-do-reads yes注意io-threads-do-reads默认是 no也就是只对写响应做多线程处理。当压测发现读请求成为瓶颈时这个参数要打开否则读这一半的 IO 还是串行提升非常有限。线程数不建议超过 8超过后收益递减还会引入更多的线程调度开销。在实际压测里多线程 I/O 贡献了约 15% 的提升和我预期差不多。但一定要配合上面的压测方法去验证不同机器、不同访问模型跑出来的数据差异会很大。2.5 Listpack 编码与内存优化这个特性最不起眼却是让我最意外的一个。Redis 7.0 在哈希、列表、有序集合等数据结构中用 listpack 替代了老旧的 ziplist同时调整了 RDB 格式。单纯看这是内部实现但它的实际价值是小对象场景内存占用降低、更新时不那么频繁触发重编码整体 GC 开销变小。从监控看我的内存占用大概降了 10%。内存降下来意味着同一台机器能承载更多数据缓存命中率上升对 Redis 整体 QPS 是一个间接但实在的提升。对于长期跑在内存边缘的业务10% 可能就是少买一台机器的差距。由于 7.0 对 hash、zset 的 listpack 编码切换阈值做了调整线上如果原来配置过hash-max-ziplist-entries之类的老参数升级后最好验证一下是否被自动映射到新的 listpack 参数上。查看方式用CONFIG GET hash-max-listpack-entries。如果老的hash-max-ziplist-entries和新参数同时存在新参数的优先级更高容易造成行为不一致这是不少升级事故的隐藏源头。3. 压测实战用 JMeter 动态调节 QPS 复现 40% 提升光说“提升了 40%”没有说服力我把完整的压测方法写出来。很多人都用 JMeter 做过固定 QPS 压测但真实场景是不同时间段的流量是波动的更贴近实际的做法是动态调整 QPS模拟仿真流量。我用的是 JMeter 5.5在 JSR223 采样器 Groovy 脚本的配合下实现动态压测整个过程可以复现。3.1 压测环境与基线数据先交代环境避免“这是我的机器跑出来的”这种草率结论。项目配置Redis 版本Redis 7.0.4对比基准为 Redis 5.0.14部署模式3 主 3 从集群单节点 8C16G压测机两台 8C16G分别跑 JMeter Agent客户端Jedis 4.3连接池 200数据量预热 500 万 KeyValue 128 字节压测场景混合读写 8:2 频率 10% 的发布订阅 5% 的 Lua/Function 原子操作基准 QPS 用的是 Redis 5.0 在最接近的配置下压出来的。所有测试先预热 10 分钟持续压测 30 分钟取稳定数据。3.2 动态 QPS 的实现思路固定 QPS 压测有个问题它测的是“系统在稳定压力下的最大表现”但真实业务是有毛刺的。JMeter 里最简单的固定 QPS 控制是 Constant Throughput Timer但调整时需要手动改界面没法在运行中改变。我用的方案是 JSR223 采样器 Groovy 脚本用一个线程去读取预置的 QPS 配置然后动态修改定时器的吞吐量目标。核心代码如下// JSR223 采样器动态调整 QPS import org.apache.jmeter.util.JMeterUtils; import org.apache.jmeter.threads.JMeterVariables; // qpsConf 变量由 CSV 数据文件提供格式时间戳,目标QPS,运行秒数 long targetQps Long.parseLong(vars.get(targetQps)); int duration Integer.parseInt(vars.get(runSeconds)); // 通过 props 广播给定时器 props.put(dynamicQps, String.valueOf(targetQps)); props.put(runSeconds, String.valueOf(duration)); // 动态计算循环次数辅助控制 long startMillis System.currentTimeMillis(); while (System.currentTimeMillis() - startMillis duration * 1000) { // 每 500ms 检查一下是否切换目标 QPS if (props.get(dynamicQps) ! null !props.get(dynamicQps).equals(String.valueOf(targetQps))) { targetQps Long.parseLong(props.get(dynamicQps).toString()); } Thread.sleep(500); }配合一个简单的 CSV 数据文件在每个阶段切换目标 QPS阶段,目标QPS,运行秒数 1,10000,300 2,15000,300 3,20000,300 4,15000,300在 Constant Throughput Timer 的 Target Throughput 输入框里写${__P(dynamicQps,10000)}这样运行中脚本修改 props定时器就能实时调整。很多老教程推荐用 BeanShell 完成这个事我也试过。结论是能用 Groovy 就别用 BeanShell。BeanShell 的问题有两个一是性能差脚本本身会成为压测瓶颈二是遇到高并发线程环境解析行为不稳定。最终我切到 JSR223 Groovy同一个压测场景下脚本自身的开销降了 70%。3.3 五组对比压测结果我按照逐步叠加的方式跑了五组这样能看出每个特性真正的贡献压测组合QPS 均值P99 延迟变化Redis 5.0 基线12500012ms基准7.0 默认参数13100011ms提升 4.8%7.0 函数替代 Lua1380009ms相较上组提升 5.3%7.0 函数 多线程 IO1580007ms相较上组提升 14.5%7.0 全特性含 Sharded Pub/Sub、MP-AOF、listpack1750005ms相较基线上涨 40%从数据看默认参数升级到 7.0 的提升其实很有限我甚至觉得那 4.8% 有一部分来自新 RDB 格式的内存优化。真正拉开差距的是函数替代 Lua 脚本和多线程 IO这两个组合后 QPS 到了 158000。最后再把 Sharded Pub/Sub 和 MP-AOF 叠加P99 降到了 5msQPS 最终稳定在 175000 左右。有一点必须说明如果业务不是混读场景或者没有大量使用 Lua 脚本40% 这个数字不一定会复现。这台机器上我们把发布订阅的广播问题解决后网络包减少了将近一半这个收益在单机 Redis 上是体会不到的。所以推广结论的时候一定要注明前置条件。3.4 压测过程里常见的坑动态 QPS 压测看似简单坑真的不少。最典型的是 JMeter 线程模型没弄清。Constant Throughput Timer 控制的是“每分钟吞吐量上限”它不是真的线性限流。你的线程数必须足够多否则吞吐量会被并发线程数卡住动态调 QPS 就失去意义。我一般把线程数设置为最大目标 QPS 对应并发数的 2 倍以上然后调吞吐量参数来限流。第二个坑是 Groovy 脚本在 JMeter 里如果勾选了 “Cache compiled script if available”脚本内部定义的变量容易出现重复累加。比如我在脚本开头定义了int index 0第二次调用时它会记住上次的 index 值导致逻辑错乱。解决办法是把所有临时变量放进 JMeter 的 vars或者每次初始化时显式置零。第三个坑是没做数据隔离。压测 Redis 集群时同一份 Key 空间如果同时被多个压测 Agent 写入不同 agent 的发布订阅消息会互相干扰。建议给每个压测 Agent 分配独立的 Key 前缀再用 JMeter 参数化做前缀隔离。4. 上线前必须知道的注意细节新特性用得好能提升性能但升级和上线过程中有几个点很容易翻车单独拎出来说一下。4.1 升级与回退的兼容性Redis 7.0 的 RDB 文件格式和 AOF 格式都跟 5.0 不一样。如果直接停服把 5.0 的 RDB 文件放到 7.0 下启动大概率会报错或者静默失败。官方推荐的做法是使用redis-cli --rdb导出老实例的数据再导入新实例而不是直接拷贝文件。更稳妥的是主从复制方式。起一个新的 7.0 从节点挂到老 5.0 主节点上同步数据后升级主节点。这样能做到几分钟内完成切换。测试的时候踩过一个坑7.0 从节点同步老版本主节点时如果老节点在主从复制期间触发了 AOF 重写可能因为 COW 内存暴涨导致同步断掉。保险起见把老主节点的repl-backlog-size调大避免全量重同步。4.2 参数调优清单我结合实际生产配置整理了一份 7.0 推荐参数直接抄作业基本可用io-threads 4 io-threads-do-reads yes appendonly yes appenddirname appendonlydir aof-use-rdb-preamble yes hash-max-listpack-entries 256 hash-max-listpack-value 64 set-max-intset-entries 512 latency-monitor-threshold 100特别注意appenddirname7.0 默认会在 data 目录下生成一个appendonlydir文件夹来存放 base 和 incr 文件。如果升级后手动挂载了新的数据盘没有把appenddirname配置到正确路径会导致持久化文件写错位置重启后数据丢失。4.3 常见问题速查表现象原因解决办法开启 io-threads 后 QPS 反而下降value 过大或命令执行时间过长IO 线程优势被抵消调小 io-threads 或关闭 io-threads-do-reads逐个验证AOF 目录下出现多个 base/incr 文件Multi Part AOF 的正常行为不要手动清理由 Redis 管理 manifest老脚本通过 EVAL 调用报错7.0 对部分 Redis 命令做了脚本权限限制检查 requirepass 和 ACL给脚本执行专门授权集群模式下频道消息收不到使用了 SPUBLISH/SSUBSCRIBE但 channel 路由到不同分片统一 channel 命名前缀让发布订阅落在同一 slot重启后部分数据丢失appenddirname 指向错误路径AOF 文件未被加载启动前用 info persistence 检查 aof 文件路径和加载状态4.4 上线前的基本功不要一上来就把线上实例全部切到 7.0。我建议先挑一个次要业务集群做灰度用文章里提到的动态 QPS 压测方案先压出一个基准切到 7.0 后再压一次对比 P99 和 QPS 变化趋势。观察几天确认没有内存异常增长、无慢日志激增、无持久化失败后再全量推进。灰度期间要重点关注 INFO 命令里loading、rdb_bgsave_in_progress、aof_rewrite_in_progress三个指标任何一项长期不为 0 都需要警惕。7.0 的 Multi Part AOF 重写虽然流畅但如果磁盘 IO 能力很差重写期间网络吞吐依然会受到影响。5. 压测脚本复现与扩展既然前面提到了动态 QPS 压测这里把完整的一套可运行方案再说透一点方便你直接搭。5.1 JMeter 线程组结构我用的 JMeter 结构比较简单但每一步都有目的。测试计划里包含一个 CSV 数据文件配置一个 JSR223 采样器一个 Constant Throughput Timer以及若干真实业务请求采样器。CSV 数据文件配置每个阶段的目标 QPS 和运行时长用 vars 读取。JSR223 采样器每次迭代检查当前阶段是否结束如果结束就从 CSV 读取下一行更新 props。Constant Throughput Timer目标值绑定到props.setProperty控制的变量上。业务采样器按比例组合 Redis GET、SET、SPUBLISH 等命令用随机 Key 避免热点。有个细节容易被忽略CSV 数据文件默认只在测试计划启动时加载所有行不会自动感知运行时改动。所以我把 CSV 放在 JMeter 的 bin 目录下用绝对路径引用在 JSR223 里通过 File 读取而不是引用 JMeter 的 CSV Data Set Config这样每次切换阶段都能重新读文件比较灵活。5.2 Groovy 脚本的完整版本上面只给了片段这里放一个能直接运行的精简版本加上了必要的防御性判断// JSR223 Sampler import java.io.File import java.nio.charset.StandardCharsets def qpsFile new File(/data/jmeter/qps_config.csv) def lines qpsFile.readLines(StandardCharsets.UTF_8) def currentIdx 0 if (props.get(qpsIndex) ! null) { currentIdx Integer.parseInt(props.get(qpsIndex).toString()) } // 如果当前阶段已经结束读下一行 def runSeconds props.get(runSeconds) ! null ? Long.parseLong(props.get(runSeconds).toString()) : 0 if (runSeconds 0 || currentIdx lines.size()) { def line lines[currentIdx].trim() def parts line.split(,) def targetQps Long.parseLong(parts[1]) def duration Long.parseLong(parts[2]) props.put(dynamicQps, String.valueOf(targetQps)) props.put(runSeconds, String.valueOf(duration)) props.put(qpsIndex, String.valueOf(currentIdx 1)) }这段脚本的要点是用props而不是vars来传递值因为vars是线程私有的而 Constant Throughput Timer 是全局组件需要所有线程共享同一个目标值。这个细节我调了很久才发现如果用了varsJMeter 会为每个线程维护独立的 QPS 值最后定时器取变量时实际是最后一个线程写入的值表现就是 QPS 忽高忽低。5.3 动态 QPS 能测出什么固定 QPS 测不出的东西固定 QPS 只能告诉我“系统的容量上限在哪里”。但容量上限只是静态数据真实业务关心的是“当流量从 1 万涨到 2 万再回落到 1 万系统是否还能恢复到原来的延迟水平”。动态 QPS 能暴露出系统在流量陡增时的雪崩倾向和恢复到平稳期的能力。我在压测时发现单纯 2 万 QPS 固定压测下Redis 的 P99 一直稳定在 3ms但用动态 QPS 先跑到 3 万再回落回 2 万后P99 会在回落后的前 2 分钟维持在 8ms 左右原因是有大量连接在流量高位时排队回落后还在慢慢消化。这个现象如果不做动态压测根本测不出来就直接导致上线后大促结束的一段时间内用户体验依然差。这也是我强烈建议压测一定要动态化的原因。6. 压测上的最后几个提醒整篇写下来最后说点实在的。Redis 7.0 确实值得升级但它不是玄学不是换个版本就自动快 40%。那 40% 的前提是你了解自己的访问模型选择了正确的特性组合并且用动态 QPS 压测把收益验证出来。我见过有人把 io-threads 调到 16跑大 value 场景结果性能下降了 20%回滚后骂 Redis 7.0 是垃圾。其实参数没有好坏只有匹配不匹配。升级前记得把 Redis 5.0 线上实例的INFO memory、INFO commandstats、INFO stats这些数据留存一份方便升级后做对比。命令调用分布这一项尤其重要它能告诉你当前业务是读多还是写多网络包是大是小Lua 脚本占比多少直接决定了该用哪几个新特性。最后再分享一个小技巧。我在压测 JMeter 时发现把 Groovy 脚本里的log.info全部去掉JMeter 自身的 QPS 上限能提升 30%。日志输出在压测工具里同样是瓶颈保持在关键节点打点就够了不要每条请求都打印。这是个很小的细节但压测工具自身不成为瓶颈你们的压测结果才有说服力。这次升级到 Redis 7.0 的经历让我重新理解了“被低估的特性”这件事。技术选型不是追新而是每引入一个特性都知道它到底在解决什么。希望这篇文章里的思路和脚本能帮到你有问题可以按着这几个方向自己跑一遍试试。
返回列表