ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Redis 7.0性能优化实战:5个被低估的特性让QPS提升40%

Redis 7.0性能优化实战:5个被低估的特性让QPS提升40% 前阵子我把一个读多写少的会员服务从 Redis 6.2 升级到了 7.0。升级之前大家关注的基本都是“多线程 I/O”但真正把新版特性用起来之后我才发现几个被严重低估的“小功能”对 QPS 的帮助比想象中大得多。优化前峰值大约 6 万 QPS压测时偶尔超时优化后稳定在 8.5 万左右整体提升约 40%。这篇文章就围绕 Redis 7.0 里 5 个被低估但超有用的特性展开告诉你它们能解决什么问题、怎么配置、有哪些坑最后再分享一套用 JMeter 动态调整 QPS 的压测方法方便你验证优化效果。1. 整体设计与性能瓶颈拆解1.1 从一次线上超时开始当时我们的接口响应 P99 在 200ms 到 400ms 之间抖动查看 Redis 监控发现实例的 CPU 大约只有 45%但连接数上万个网络读写占了很大开销。进一步观察发现很多请求不是慢命令而是积压在网络 IO 和客户端往返上。这里就引出一个关键结论Redis 的 QPS 瓶颈往往不在命令执行本身而在网络读写、客户端 RTT、持久化刷盘和大结果集传输。7.0 版本里恰恰有一批特性是针对这些瓶颈设计的只是大多数人没有认真翻官方 changelog。1.2 五个特性如何组成一套组合拳我把优化目标拆成了五块瓶颈维度对应特性实际收益网络 IO 压力大多线程 I/O主线程专注命令执行读写 socket 交给额外线程客户端多次往返Redis Functions多次命令合并成一次原子调用大结果集传输SINTERCARD / ZMPOP 等新命令只取计算后的结果不传原始数据重复读请求客户端缓存 Tracking本地缓存直接回应服务端只推失效消息持久化抖动多部分 AOF降低重写期间的阻塞提升稳定性这五块不是孤立存在的。网络层压低了命令层的原子化降低了延迟大结果集优化减少了带宽占用客户端缓存又把大量读请求拦截在 Redis 之外持久化稳定后整个实例的响应曲线也不会再频繁出现毛刺。所有优化叠加起来才有 40% 的 QPS 提升。2. 特性一多线程 I/O 不是装饰品压榨并发网络吞吐2.1 原理与常见误区很多文章说“Redis 7.0 引入多线程”严格来讲不完全准确。Redis 6.0 已经有了多线程 I/O7.0 把它更完善了。真正的意思是命令执行依然由主线程单线程串行处理而网络读写 socket 的耗时操作可以拆给多个线程。打个比方主线程是收银员多线程 I/O 是多个店员帮顾客把商品从货架上拿过来。收银员还是一个人但不用亲自去仓库取货了单位时间能结账的顾客自然变多。所以它不是把 CPU 密集型复杂命令变快而是把“高并发简单命令”场景下的网络开销打薄。很多人误以为开启后所有命令都会变快。实测下来对 GET、SET、INCR、LPUSH 这类短命令收益最大对几千字节的大 value 也有一定帮助但对需要大量计算的复杂命令帮助有限。2.2 开启与参数配置方法是在 redis.conf 里找到下面几项去掉前面注释io-threads 4 io-threads-do-reads yesio-threads额外用于网络读写的线程数一般建议设置为 CPU 核心数的一半或等于核心数但不要超过 8。io-threads-do-reads yes默认只开启写线程读线程是关闭的。要提升读多场景的 QPS必须显式把它打开。我在一台 16 核物理机、客户端流量约 30% 写、70% 读的环境下从默认的io-threads 1调整到io-threads 4简单 SET/GET 压测的 QPS 从 5.6 万升到了 7.3 万左右。2.3 实操心得与坑io-threads 不要拍脑袋设 64。线程不是越多越好反而会因为锁竞争和上下文切换导致性能下降。建议先 4再看 CPU 使用率逐步往上加。只有多核机器有效。如果部署在 2 核的小机器上开多线程可能更慢。用info stats里的io_threaded_reads_processed和io_threaded_writes_processed确认是否生效。这两个计数持续增加说明线程在工作。升级后需要重启实例才能生效命令行CONFIG SET不支持动态开启读线程。3. 特性二Redis Functions把多次命令封装成一次原子调用3.1 Lua 脚本的老问题以前我们需要原子性时最常用 EVAL 执行 Lua 脚本。但它有几个让人头疼的地方脚本与业务代码耦合严重运维时根本不知道线上加载了哪些脚本。修改脚本后需要重新在客户端维护 SHA容易出现版本不一致。多个业务团队共用实例时脚本名冲突、权限混乱。Redis 7.0 提供的 Redis Functions 相当于官方脚本库方案把 Lua 脚本变成了可管理、可加载、可复用的函数。3.2 一个完整示例先注册一个库mylib里面注册一个incrby_with_expire函数FUNCTION LOAD #!lua namemylib redis.register_function(incrby_with_expire, function(keys, args) local result redis.call(INCRBY, keys[1], args[1]) redis.call(EXPIRE, keys[1], args[2]) return result end)调用时不需要重新传输整个脚本直接FCALL incrby_with_expire 1 user:login:count 100 3600user:login:count是传入的 key100是 INCRBY 的步长3600是过期时间。3.3 为什么 QPS 提升明显在没有 Functions 时INCRBY 和 EXPIRE 是两条命令会产生两次网络 RTT而用函数只需要一次。如果业务里要执行三条甚至五条命令提升更明显。我印象最深的是一个限流逻辑旧代码是先 GET 一个计数再判断再 INCR结果经常出现并发超限。改成 Functions 后把判断、计数、过期全部塞进一个原子调用代码变简单了Redis 侧处理的请求数也减少了。3.4 必须避开的坑函数内还是不能执行阻塞操作比如BLPOP、WAIT、SUBSCRIBE。红色警报。函数里的 Lua 运行超时默认 5 秒如果逻辑复杂会阻塞整个 Redis比普通 slow log 更严重。生产环境加载函数建议走配置/etc/redis/redis.conf里的function-load或运维平台统一注册不要在客户端随便FUNCTION LOAD。4. 特性三SINTERCARD / ZMPOP / LMPOP把笨重的读操作变轻4.1 传统交集统计的痛点很多实时推荐和好友关系场景需要算两个集合的交集数量比如“我和你有几个共同好友”。以前只能用SINTER把整个交集元素全部返回给客户端再在客户端统计数量。如果两个集合都很大结果集也可能很大。比如某个大 V 的粉丝集合有 500 万另一个用户有 200 万交集可能有几十万条这些元素全部序列化后从 Redis 传到客户端网络开销非常大。而业务方只是想知道“有多少”或“有没有”完全没必要把元素都拉出来。4.2 用 SINTERCARD / ZINTERCARD 只取计数Redis 7.0 新增SINTERCARD numkeys key [key ...] [LIMIT limit] ZINTERCARD numkeys key [key ...] [LIMIT limit]命令返回交集基数不返回元素本身。LIMIT 参数可以提前终止计算比如只要判断“交集是否大于 10 条”传入 LIMIT 10 就能在满足条件后立刻结束避免全量计算。示例SINTERCARD 2 user:1:following user:2:following SINTERCARD 2 hot:user:1:tags hot:user:2:tags LIMIT 10在同一个会员场景中我用两个单测集合模拟一个 10 万元素一个 8 万元素原来SINTER返回全部交集平均耗时 23ms改用SINTERCARD LIMIT 1后耗时降到 0.8ms。这种量级的差距对 QPS 的提升是肉眼可见的。4.3 队列场景的 LMPOP / ZMPOP另一个很容易被忽略的命令组是LMPOP和ZMPOP。它们可以同时从多个 List / Sorted Set 弹出一个或多个元素并支持方向LMPOP 2 queue:1 queue:2 LEFT COUNT 10 ZMPOP 3 zset:1 zset:2 zset:3 MIN COUNT 5以前要实现一个“多队列消费”的逻辑客户端得循环 LPOP 每个队列直到拿到非空结果这个过程中可能产生多次 RTT。用LMPOP一次调用Redis 内部按顺序查找第一个非空 List直接弹出元素极大节省客户端网络轮询。4.4 适用场景与限制适用于共同关注、黑名单校验、标签匹配、多队列任务分发等使用集合操作的业务。这些命令不是万能的。如果你需要交集的具体元素仍然要用SINTER或ZINTERSTORE。命令本身计算复杂度不低LIMIT只在“判断是否存在/是否达到阈值”时有优势不要过度依赖。5. 特性四客户端缓存 Tracking让读多写少场景直接起飞5.1 原理一句话说明Redis 6.0 就开始提供客户端缓存协议7.0 在实现对 RESP3 的支持上更加完善。简单说业务服务器在本地缓存 Redis 数据Redis 不推送数据本身只在 key 被修改或删除时向客户端推送一个invalidate失效消息。这样读请求的 90% 以上都能在本地缓存命中根本不进到 Redis 网络层。对读多写少的业务这是其他优化都很难带来的“降维打击”。5.2 开启方式示例使用 redis-cli 手动模拟HELLO 3 CLIENT TRACKING ON一个简单的GET user:123后这个 key 就会被服务端跟踪。当另一个连接执行SET user:123 new时跟踪连接会收到这样的失效消息user:123不同 Redis 客户端库都有对应的 API。比如 redis-py 在较新版本中可以通过 protocol3 支持 RESP3import redis r redis.Redis(hostlocalhost, port6379, protocol3) r.client_tracking_on()5.3 需要注意的几个前提必须设置合理的maxmemory和淘汰策略。否则客户端缓存会无限增长导致服务端内存被打爆。这是官方明确提醒过的重点。失效通知是异步的。从 Redis 修改 key 到客户端收到失效消息存在极小的延迟。所以对强一致要求极高的场景要慎用比如库存扣减类操作。如果客户端连接断开重连缓存里的数据需要自己做一次全量刷新否则会读到陈旧数据。不是所有客户端库都支持 RESP3 tracking接入前先确认版本。我们实际测试时把用户维表缓存全部切到 TrackingRedis 读 QPS 直接下降一半而业务观察到的一致性问题几乎为零。6. 特性五多部分 AOF 与持久化策略别让磁盘拖后腿6.1 7.0 的 AOF 发生了什么变化原先 Redis 的 AOF 是一个单文件重写时如果写得很频繁磁盘 IO 抖动会直接影响主线程。7.0 把 AOF 拆成了多个文件由一个 manifest 清单管理分为 base 文件和增量 incr 文件。好处很直接重写 AOF 时只需要写新的 base 文件不用动正在写入的增量文件。崩溃后恢复更快因为不需要对一个大文件做完整校验。文件同步策略更灵活减少磁盘瞬时压力。6.2 推荐配置appendonly yes appenddirname appendonlydir appendfsync everysec no-appendfsync-on-rewrite yes auto-aof-rewrite-percentage 100 auto-aof-rewrite-min-size 64mbappendfsync everysec每秒刷盘一次是性能和安全的折中。no-appendfsync-on-rewrite yesAOF 重写期间不执行 fsync避免磁盘写放大。auto-aof-rewrite-percentage 100AOF 体积比上次重写膨胀 100% 时触发重写。auto-aof-rewrite-min-size 64mb体积低于 64MB 不触发自动重写。6.3 为什么它能提升 QPS严格来说AOF 多文件并不会直接增加 QPS但它能把持久化对主线程的阻塞降到最低。之前每次 AOF 重写时我们的 p99 都会跳到 100ms 以上。升级到多部分 AOF 并调整no-appendfsync-on-rewrite yes后重写期间的 p99 基本保持稳定。QPS 曲线不再出现周期性下跌累计下来的吞吐自然就上去了。6.4 踩坑记录升级前一定要先备份 RDB因为 7.0 的 AOF 文件和旧版本不兼容直接启动会说文件格式不支持。如果项目里同时有多个 Redis 实例建议逐个升级不要在高峰期批量切换。appenddirname appendonlydir这个配置默认就会生效不要手动改到已有目录下容易导致旧文件加载问题。7. 压测验证用 JMeter 动态调整 QPS 复现 40% 提升7.1 压测目标设计为了验证几个特性综合效果我准备了一套压测方案读写比例70% 读30% 写。命令组合GET、SET、INCR、SINTERCARD 混合。压测前先灌一定量测试数据避免空集合场景。每轮压测持续 10 分钟观察 Redis 实例的 total_commands_processed 曲线。7.2 如何在 JMeter 中动态调整 QPS固定 QPS 压测虽然简单但无法真实反映系统在流量波动下的表现。所以我通过 JMeter 的 BeanShell 脚本动态调整目标 QPS。思路是测试计划里定义一个属性例如target_qps。Constant Throughput Timer 的目标值引用这个属性。用一个 JSR223/BeanShell Sampler 定时修改target_qps模拟流量从低到高再到低的曲线。参考脚本片段JSR223 Groovyprops.put(target_qps, 3000)Constant Throughput Timer 的 Target Throughput 一栏填写${__P(target_qps,3000)}注意这个属性值是全局的所有线程组共享改变后整个压测流量的目标 QPS 都会跟着变。如果你需要更平滑的阶梯可以在 BeanShell 脚本里按当前时间计算对应数值再写入 props。7.3 优化前后数据对比我们用同一套测试数据、同一台客户端机器先跑旧配置关闭多线程 IO、使用 SINTER 全量返回、不开启 Tracking再跑优化后的版本。指标优化前优化后总请求数10分钟约 3600 万约 5100 万平均 QPS约 60000约 85000P99 延迟85ms41ms超时比例1.2%0.03%Redis 实例 CPU45%68%从结果看实例 CPU 使用率上升了说明网络 IO 瓶颈得到缓解真正的 CPU 计算占比变大整体吞吐自然就提高了。这里也验证了前面说的观点Redis 的 CPU 不是永远不够用关键看有没有把无用开销压下去。8. 常见问题与排查技巧实录8.1 问题速查表现象可能原因处理方法开启 io-threads 后 QPS 反而下降机器核数太少或配置异常检查 CPU 核数建议 4 核以上再开io-threads 不要超过 8FCALL 调用报错Library not found函数库未加载或加载失败执行FUNCTION LIST查看已加载库检查 FUNCTION LOAD 时的 Lua 语法SINTERCARD 返回结果比预期少多 key 中某些 key 不存在确认 key 类型和数量TYPE key查看不存在的集合按空集处理客户端缓存开启后内存暴涨服务端未配置 maxmemory 或客户端重连缓存没清理设置 maxmemory 与淘汰策略客户端实现本地缓存容量上限和清理逻辑AOF 重写时仍出现阻塞appendfsync 仍为 always 或磁盘性能太差改为 everysec 或考虑换 SSD开启 no-appendfsync-on-rewriteJMeter 动态调整 QPS 不生效props修改后 Constant Throughput Timer 没有重新读取确认定时器表达式是${__P(target_qps,3000)}并检查脚本所在线程组是否已运行8.2 独家避坑技巧升级到 7.0 后先跑一遍redis-cli --bigkeys。多线程 I/O 对小 key 的收益最大如果实例里大 key 很多应该先处理大 key而不是急着开高 io-threads。新命令上线前用DRYRUN或测试环境验证语法。Redis 客户端的老版本可能不认识 7.0 的命令比如SINTERCARD需要把客户端库升级到支持 RESP3 的版本。线上开启客户端缓存要按“先灰度再放量”来。先用 10% 的流量验证失效消息延迟确认无误再全量开启。做 QPS 对比时要固定客户端机器数量和数据量。否则数据差异无法归因到 Redis 配置变更上。9. 最后想说的话这次优化归根结底不是把 Redis 换成了更快的数据库而是把 7.0 里每个“看起来不起眼”的能力都用到刀刃上。多线程 I/O 解决网络读写Functions 解决多次 RTT新命令解决大结果集客户端缓存解决重复读多部分 AOF 解决持久化抖动。五个点单独看都算不上革命但组合在一起QPS 提升 40% 并不是玄学。我个人在实际操作中最深的一点感受是别急着堆机器先把 Redis 自身的配置和协议能力吃透。性能优化最怕的是从表面搬运参数却不知道它在哪一层解决问题。多读几遍官方 changelog再针对自己的业务场景打组合拳往往比盲目调参更有用。希望这篇实战笔记能给你提供一些参考哪怕只是让你意识到 SINTERCARD 比 SINTER 更适合统计类业务这篇文章也算没白写。
返回列表