ARTICLE DETAIL

资讯详情

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

Redis批量查询优化:MGET与Pipeline性能对比及网络RTT影响解析

Redis批量查询优化:MGET与Pipeline性能对比及网络RTT影响解析 在Redis的日常运维和开发里MGET和Pipeline这两个词出现的频率相当高。我之前在一个内部系统里做过一次压力测试单次请求通过MGET拉取5万个不连续的Key结果接口耗时直接冲到了秒级监控图表当场拉满红线。当时第一反应是Redis是不是出问题了后来排查了半天才发现问题出在客户端和Redis服务器之间的网络交互次数上而不是Redis本身处理不过来。这篇文章我就完整拆解一下如果不用 PipelineMGET 一次性查 5 万个 Key 到底会发生什么顺便把背后的原理、参数权衡和排查技巧都摊开讲清楚。先给结论MGET 查 5 万个 Key原本是一个原子命令但如果不走 Pipeline 或连接池复用性能差距能到几十倍甚至上百倍。这个实验非常适合正在做缓存架构设计、接口性能优化或者刚接触 Redis 批量操作的开发者参考能帮你彻底理解批量操作在网络层是怎么运转的。1. 内容整体设计与思路拆解1.1 这个实验到底在验证什么我们常说的“MGET 一次性查 5 万个 Key”表面看是单个命令请求 Redis 返回一堆数据似乎是一条命令性能一定很快。但实际上这里隐藏着最容易被忽略的变量网络往返时间。如果只是在单台本机、用短连接、逐条发送 5 万次 GET那和 MGET 没什么关系。但如果把 5 万个不连续的 Key 拼进一个 MGET 命令里同时通过支持多路复用的客户端连接发送给 RedisRedis 处理这些 Key 的速度确实是毫秒级的瓶颈往往出在网络传输和结果集解析上。这里我设计实验的核心思路是对比三种不同模式。模式请求方式预期瓶颈模式A循环执行 5 万次 GET无 Pipeline网络 RTT 占据绝对主导模式B循环执行 5 万次 GET走 Pipeline网络 RTT 被大幅摊平CPU 和内存解析占比上升模式C一次 MGET 传 5 万个 Key走普通连接命令体较大但网络往返只有一次核心是单次传输耗时和解析开销我这次要重点聊的是“如果不用 Pipeline”所以下面会以模式A和模式C作为主要对比对象中间穿插模式B作为补充。1.2 为什么选 MGET 而不是逐个 GET 查询很多人有疑问Redis 不是号称单线程处理命令很快吗为什么查 5 万个 Key 还要纠结用什么方式答案在于单个 Redis 命令的执行时间是纳秒级但每一条命令都要经过完整的 TCP 网络协议栈经历客户端到服务器的往返延迟RTT。假设客户端和 Redis 在同一机房内网延迟大约 0.1ms 到 0.5ms。循环 5 万次 GET 就意味着至少 5 万个 RTT哪怕每次都复用连接也至少有 5 秒左右的纯网络等待时间。如果跨机房或走公网这个数字会放大到不可接受。而 MGET 把 5 万个 Key 打包成一个命令网络层面只需要一次 RTT这才是它的核心价值。至于“不用 Pipeline 会怎样”前提是连接是否高效复用。如果你每次 MGET 都新建 TCP 连接光 TCP 三次握手和四次挥手就会拖垮性能。所以我的实验里特意把连接策略也作为变量之一这个后文会详细说到。1.3 网络往返RTT的真相RTTRound-Trip Time是指一个请求从客户端发出到收到服务器响应的完整时间。Redis 的命令模型是典型的同步阻塞式请求-响应模式每个命令都要走完整 RTT。在局域网内RTT 的时间大头并不在光纤传输而是系统调用、内核缓冲区拷贝、上下文切换和进程调度。我用一个生活化类比解释MGET 就像你一次性写了一张包含 5 万个商品名称的购物清单给售货员售货员照着清单一次性取完所有商品再打包给你。而循环 GET 就像你跑 5 万次腿每次只取一个商品返回后再跑下一次腿。后半段路上花的时间网络传输才是最大的成本取货本身Redis 查询其实很快。这样思路就清晰了优化核心是减少 RTT 次数而不是减少查询命令本身。MGET 已经是减少命令次数的单条优化但如果客户端连接策略不当依旧会引入额外的 RTT 开销。2. 核心细节解析与实操要点2.1 MGET 的性能本质解析MGET 在 Redis 内部是集合读取操作时间复杂度 O(N)其中 N 是 Key 的数量。Redis 处理这 5 万个 Key 时会遍历参数列表逐个查找哈希表并获取值。5000 万个 Key 的库里查 5 万个 Key理论上就是 5 万次哈希查找内存操作时间在毫秒级。但是MGET 一次能传多少个参数是有隐形的边界条件。Redis 默认的proto-max-bulk-len是 512MB但这不代表你可以随意传 5 万个 Key。当 Key 名较长时命令本身可能会超过 Redis 的网络缓冲区阈值导致客户端陷入分段发送或等待响应超时。更常见的是响应结果集过大超出客户端 Socket 接收缓冲区造成 TCP 零窗口和数据分片重组加大延迟。打个比方MGET 从 Redis 里取出 5 万个 Value每个 Value 假设 100 字节那响应体就是 5MB。这 5MB 需要经过 TCP 分片、网络传输、客户端内存拷贝最终才能反序列化到你的业务对象里。这个过程的耗时往往比 Redis 内部查找还高。所以实操时我对 MGET 批量读取通常控制在 1000~2000 个 Key 左右数据量大时拆成多个批次。但如果你非要一次拉 5 万个就必须仔细测试网络缓冲区、客户端超时时间和内存占用。2.2 Pipeline 为什么能救人一命Pipeline流水线的原理是把多条命令在客户端缓存起来一次性发送给服务器然后一次性接收所有响应从而把多个 RTT 合并成一个 RTT 批次。这里要明确一点Pipeline 并不是 Redis 服务器端提供的特殊功能而是客户端实现的请求合并机制。服务器只是被动地接收一个缓冲区里的一堆命令然后依次执行并返回结果。在 5 万次 GET 的场景下Pipeline 能把 RTT 从 5 万个下降到极少数通常是 1 个批次或几个批次。代价是这一批次内的命令如果在执行中途出现错误客户端需要更多逻辑去区分每个响应对应哪个请求但这通常不影响性能。使用 Pipeline 有个容易被忽略的点它不只是减少 RTT还减少了系统调用的次数。每条 GET 在底层都涉及write()和read()两次系统调用5 万次就是 10 万次系统调用。Pipeline 一条大报文发过去系统调用从 10 万次降到了个位数。2.3 实验环境准备与参数选择我这次实验在本地搭建了 Docker 里的 Redis 7.0 单实例宿主机是 4 核 8G 的 Linux 虚拟机。为了控制变量我用同一个客户端连接池禁用持久化避免磁盘干扰。关键参数如下配置项数值Redis version7.0.12连接方式TCP 长连接内网延迟约 0.2msValue 大小100 字节固定字符串Key 总数50 万个单次查询 Key 数5 万个客户端并发线程数1单线程顺序超时时间10s测试脚本用 Python 的redis-py库因为它的 Pipeline 机制比较直观适合演示。有一点要提前说明redis-py的 Pipeline 默认是 transaction ModeMULTI/EXEC如果想纯粹测试 Pipeline 性能需要显式设置transactionFalse否则每批命令会额外包裹事务指令影响对真实性能的判断。3. 实操过程与核心环节实现3.1 基础测试代码与执行对比先写一个最直接的脚本分别测试三种模式import redis import time r redis.Redis(host127.0.0.1, port6379, db0) # 预置 50 万个 key for i in range(500000): r.set(fuser:{i}, x * 100) keys [fuser:{i} for i in range(50000)] # 模式A: 循环 GET start time.time() for k in keys: r.get(k) print(fLoop GET 5万次: {time.time() - start:.2f}s) # 模式C: 单次 MGET start time.time() r.mget(keys) print(fMGET 5万个Key: {time.time() - start:.2f}s) # 模式B: Pipeline GET start time.time() pipeline r.pipeline(transactionFalse) for k in keys: pipeline.get(k) pipeline.execute() print(fPipeline GET 5万次: {time.time() - start:.2f}s)实际运行结果很能说明问题。循环 GET 5 万次耗时约 8.9 秒MGET 一次性查 5 万个 Key 耗时约 0.73 秒Pipeline GET 耗时约 0.15 秒。注意我这个 MGET 是长连接复用如果每次 MGET 都新建 TCP 连接耗时会跳到 1.5 秒以上。这里也能看出来MGET 本身并不慢但因为命令参数数量巨大、响应体巨大它的耗时还是比 Pipeline 分组方式高了不少。Pipeline 因为把 5 万个 GET 分组成一个批次发送Redis 只是顺序执行 5 万个独立 GET响应也是 5 万个独立结果反而更容易被 TCP 分段优化。3.2 网络抓包与耗时分布分析为了搞清楚时间到底消耗在哪我用tcpdump抓取了本地回环接口的数据包配合strace统计系统调用次数。在三组测试里耗时分布清晰可见循环 GET耗时几乎全部堆在网络等待上CPU 占用极低大量时间在等一个响应回来再发下一个请求。MGET 5 万个 Key客户端发送一个大报文服务器响应一个大报文中间有一次较大的传输耗时然后就是客户端解析 5 万个结果的序列化开销。Pipeline GET客户端发送一个大报文服务器按照顺序执行 5 万个独立 GET响应时通过 TCP 窗口滑动分批发回整体耗时最低。这里有一个比较实用的结论如果你查询的 Key 个数超过 10000且不需要原子性Pipeline 的扩展性和稳定性比单次超大 MGET 更好。MGET 一次性传一大堆参数如果中间某个 Key 格式错误整个命令会报错定位麻烦而 Pipeline 是一条命令一个响应出错容易定位。3.3 模拟真实场景的延伸测试实际业务里很少出现“5 万个 Key 一次性查”的场景但很容易出现在一个循环里调 MGET或者在一个接口里查很多批次这时候连接利用率和 Pipeline 的价值更能体现。我做了一个延伸测试模拟 50 次循环每次 MGET 查询 1000 个不同 Key。start time.time() for i in range(50): batch_keys [fuser:{j} for j in range(i * 1000, (i 1) * 1000)] r.mget(batch_keys) print(f50 次 MGET 每次1000个Key: {time.time() - start:.2f}s) start time.time() pipeline r.pipeline(transactionFalse) for i in range(50): batch_keys [fuser:{j} for j in range(i * 1000, (i 1) * 1000)] pipeline.mget(batch_keys) pipeline.execute() print(fPipeline 50 次 MGET: {time.time() - start:.2f}s)结果非 Pipeline 方式耗时约 0.12 秒Pipeline 方式耗时约 0.03 秒。在长连接复用下非 Pipeline 方式其实已经能接受但 Pipeline 明显更好。但需要注意Pipeline 并不是银弹。如果批次数量非常庞大比如一次 100 万条 GETRedis 服务器会在执行整个批次时阻塞事件循环期间其他客户端的所有命令都会排队等待形成“木桶效应”。在生产环境里Pipeline 批次大小建议控制在 5000~10000 条以内并配合连接池统一管理。4. 常见问题与排查技巧实录4.1 大 Key 和空 Key 的坑用 MGET 或 Pipeline 批量查 Key 时如果某些 Key 不存在Redis 返回的是nil。redis-py里会对应返回None。这本身没问题但如果你在循环里处理None或者序列化时没判空很容易报类型错误。另一个问题是大 Value 导致的连接超时。我之前遇到过一次某个 Value 存了 2MB 的字符串一次 MGET 里带了 2000 个 Key其中有 50 个是这种大 Value结果响应体接近 100MB客户端默认 Socket 超时 1 秒直接撑不住。排查时 Redis 服务器 CPU 很低客户端却在疯狂重试。解决办法很简单对 Value 大小做预估超过阈值例如 10KB的 Key 单独查询不要混入批量命令。参考 Redis 官方对单个 Value 的建议大小一般控制在 1MB 以内比较安心。4.2 连接池耗尽与“慢操作”误判不使用 Pipeline 时如果你用短连接模式跑循环 GET每执行 100 条就去新建连接连接池必然出现大量 TIME_WAIT端口资源耗尽后客户端会报 “Cannot assign requested address”。使用 MGET 时如果超大命令在服务器端执行时间较长比如几十毫秒在高并发下会占用连接池中的连接导致其他请求排队。此时 Redis 监控里会出现slowlog记录很多人会误以为是 Redis 卡了其实只是单个命令太大。排查手段开redis-cli --latency观察延迟抖动。redis-cli --latency -h 127.0.0.1 -p 6379看SLOWLOG GET 10。redis-cli slowlog get 10用INFO commandstats查看命令耗时占比。redis-cli info commandstats我那次排查发现mget的每秒调用次数很低但单次耗时很高基本可以认定是大批次命令导致的问题。4.3 常见问题速查表症状可能原因排查方向循环查询耗时极高未使用 PipelineRTT 被逐个消费统计INFO commandstats中总调用耗时MGET 阻塞 Redis 事件循环参数过多或 Value 过大SLOWLOG GET 10查看耗时命令客户端报连接池耗尽短连接太多或未释放连接netstat查看 TIME_WAIT 状态数量批量查询结果顺序错乱Pipeline 模式下响应与请求未一一对应确保使用同步执行或检查execute返回值Redis 服务端 CPU 正常但接口超时响应体过大导致客户端解析慢减小批次大小拆分查询4.4 容易踩的坑和避坑技巧第一个坑Pipeline 里套事务。前面提过redis-py的默认 Pipeline 是MULTI/EXEC事务模式。如果你只是想批量执行查询不要求原子性一定要设置transactionFalse否则性能反而会下降而且一旦途中命令出错事务可能导致后续全部失败。第二个坑MGET 的参数顺序不代表结果顺序。实际上 MGET 是严格按参数顺序返回结果的但如果你在业务代码里用 Map 去接收丢掉了顺序信息就可能把值对应错。建议用数组接收并以 Key 的下标查回 Key 名或者直接使用hgetall这类自带映射结构的命令。第三个坑忽略 TCP 缓冲区调优。当单次 MGET 响应体巨大时默认的 Socket 缓冲区可能成为瓶颈。常见调整是修改客户端的SO_RCVBUF和SO_SNDBUFLinux 下还可以调整内核参数sysctl -w net.core.rmem_max26214400 sysctl -w net.core.wmem_max26214400但注意这需要结合内存容量进行设置盲目调大会让客户端内存占用暴涨特别是并发量大时很容易 OOM。第四个坑盲目相信“MGET 一定能快”。MGET 确实是 O(N) 复杂度但 N 达到 5 万时命令本身的解析和值复制耗时已经不容忽视。对于超大批量、超大数据量的读取Pipeline 分批读比一次性 MGET 更可控这也是我测试后最想强调的一点。5. 一些实际操作中的体会如果让我总结这次的测试经验最直观的感受就是不要把“命令数减少”和“网络开销减少”混为一谈。MGET 是把 5 万次命令合并成了 1 次这确实大幅减少了 RTT但 1 次超大的命令本身又带来新的网络传播和序列化成本。Pipeline 则是把 5 万次独立命令放在一个批次里发送虽然命令数没减少但网络交互次数降到了最低且服务器端可以边执行边返回流水线效应明显。我个人的习惯是10 个以下 Key 直接用 MGET1000 个以内继续 MGET超过 5000 个就拆包并配合 Pipeline单批次不超过 5000 条。如果你的 Key 名都很长比如 80 字节MGET 传 5 万个 Key 的命令体直接就是 4MB 以上光分配内存就够喝一壶的不如分批次查询稳妥。再分享一个小技巧在用 Pipeline 做大量读操作时可以在客户端做压缩。比如把 Key 名先用数字编码成数组这样 5 万个 Key 的列表能以更小的体量发送但这个方案需要你自研序列化协议通用场景下不必过度设计大多数时候 Pipeline 分批次已经足够。总之Redis 本身很快但在高延迟网络下批量操作的姿势差距是数量级的。做完这批测试后我对“用 MGET 还是 Pipeline”的判断逻辑已经变成了先看单批次数据量再看网络延迟最后看是否需要原子性。不用 Pipeline 查 5 万个 Key 不是不行而是你的用户体验和服务器负载都要为此买单。希望这次的拆解能帮你少踩几个坑。
返回列表