☰
Redis批量查询优化:MGET与Pipeline性能对比及网络RTT影响解析
2026/9/28 8:38:24 网站建设 项目流程

在Redis的日常运维和开发里,MGET和Pipeline这两个词出现的频率相当高。我之前在一个内部系统里做过一次压力测试,单次请求通过MGET拉取5万个不连续的Key,结果接口耗时直接冲到了秒级,监控图表当场拉满红线。当时第一反应是Redis是不是出问题了,后来排查了半天才发现,问题出在客户端和Redis服务器之间的网络交互次数上,而不是Redis本身处理不过来。这篇文章我就完整拆解一下,如果不用 Pipeline,MGET 一次性查 5 万个 Key 到底会发生什么,顺便把背后的原理、参数权衡和排查技巧都摊开讲清楚。

先给结论:MGET 查 5 万个 Key,原本是一个原子命令,但如果不走 Pipeline 或连接池复用,性能差距能到几十倍甚至上百倍。这个实验非常适合正在做缓存架构设计、接口性能优化,或者刚接触 Redis 批量操作的开发者参考,能帮你彻底理解批量操作在网络层是怎么运转的。

1. 内容整体设计与思路拆解

1.1 这个实验到底在验证什么

我们常说的“MGET 一次性查 5 万个 Key”,表面看是单个命令请求 Redis 返回一堆数据,似乎是一条命令,性能一定很快。但实际上这里隐藏着最容易被忽略的变量:网络往返时间。

如果只是在单台本机、用短连接、逐条发送 5 万次 GET,那和 MGET 没什么关系。但如果把 5 万个不连续的 Key 拼进一个 MGET 命令里,同时通过支持多路复用的客户端连接发送给 Redis,Redis 处理这些 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)的真相

RTT(Round-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.2ms
Value 大小100 字节(固定字符串)
Key 总数50 万个
单次查询 Key 数5 万个
客户端并发线程数1(单线程顺序)
超时时间10s

测试脚本用 Python 的redis-py库,因为它的 Pipeline 机制比较直观,适合演示。

有一点要提前说明:redis-py的 Pipeline 默认是 transaction Mode(MULTI/EXEC),如果想纯粹测试 Pipeline 性能,需要显式设置transaction=False,否则每批命令会额外包裹事务指令,影响对真实性能的判断。

3. 实操过程与核心环节实现

3.1 基础测试代码与执行对比

先写一个最直接的脚本,分别测试三种模式:

import redis import time r = redis.Redis(host='127.0.0.1', port=6379, db=0) # 预置 50 万个 key for i in range(500000): r.set(f'user:{i}', 'x' * 100) keys = [f'user:{i}' for i in range(50000)] # 模式A: 循环 GET start = time.time() for k in keys: r.get(k) print(f'Loop GET 5万次: {time.time() - start:.2f}s') # 模式C: 单次 MGET start = time.time() r.mget(keys) print(f'MGET 5万个Key: {time.time() - start:.2f}s') # 模式B: Pipeline GET start = time.time() pipeline = r.pipeline(transaction=False) for k in keys: pipeline.get(k) pipeline.execute() print(f'Pipeline 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 = [f'user:{j}' for j in range(i * 1000, (i + 1) * 1000)] r.mget(batch_keys) print(f'50 次 MGET 每次1000个Key: {time.time() - start:.2f}s') start = time.time() pipeline = r.pipeline(transaction=False) for i in range(50): batch_keys = [f'user:{j}' for j in range(i * 1000, (i + 1) * 1000)] pipeline.mget(batch_keys) pipeline.execute() print(f'Pipeline 50 次 MGET: {time.time() - start:.2f}s')

结果:非 Pipeline 方式耗时约 0.12 秒,Pipeline 方式耗时约 0.03 秒。在长连接复用下,非 Pipeline 方式其实已经能接受,但 Pipeline 明显更好。

但需要注意,Pipeline 并不是银弹。如果批次数量非常庞大(比如一次 100 万条 GET),Redis 服务器会在执行整个批次时阻塞事件循环,期间其他客户端的所有命令都会排队等待,形成“木桶效应”。在生产环境里,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 常见问题速查表

症状可能原因排查方向
循环查询耗时极高未使用 Pipeline,RTT 被逐个消费统计INFO commandstats中总调用耗时
MGET 阻塞 Redis 事件循环参数过多或 Value 过大SLOWLOG GET 10查看耗时命令
客户端报连接池耗尽短连接太多或未释放连接netstat查看 TIME_WAIT 状态数量
批量查询结果顺序错乱Pipeline 模式下响应与请求未一一对应确保使用同步执行或检查execute返回值
Redis 服务端 CPU 正常但接口超时响应体过大导致客户端解析慢减小批次大小,拆分查询

4.4 容易踩的坑和避坑技巧

第一个坑:Pipeline 里套事务。前面提过,redis-py的默认 Pipeline 是MULTI/EXEC事务模式。如果你只是想批量执行查询,不要求原子性,一定要设置transaction=False,否则性能反而会下降,而且一旦途中命令出错,事务可能导致后续全部失败。

第二个坑:MGET 的参数顺序不代表结果顺序。实际上 MGET 是严格按参数顺序返回结果的,但如果你在业务代码里用 Map 去接收,丢掉了顺序信息,就可能把值对应错。建议用数组接收并以 Key 的下标查回 Key 名,或者直接使用hgetall这类自带映射结构的命令。

第三个坑:忽略 TCP 缓冲区调优。当单次 MGET 响应体巨大时,默认的 Socket 缓冲区可能成为瓶颈。常见调整是修改客户端的SO_RCVBUF和SO_SNDBUF,Linux 下还可以调整内核参数:

sysctl -w net.core.rmem_max=26214400 sysctl -w net.core.wmem_max=26214400

但注意,这需要结合内存容量进行设置,盲目调大会让客户端内存占用暴涨,特别是并发量大时很容易 OOM。

第四个坑:盲目相信“MGET 一定能快”。MGET 确实是 O(N) 复杂度,但 N 达到 5 万时,命令本身的解析和值复制耗时已经不容忽视。对于超大批量、超大数据量的读取,Pipeline 分批读比一次性 MGET 更可控,这也是我测试后最想强调的一点。

5. 一些实际操作中的体会

如果让我总结这次的测试经验,最直观的感受就是:不要把“命令数减少”和“网络开销减少”混为一谈。MGET 是把 5 万次命令合并成了 1 次,这确实大幅减少了 RTT;但 1 次超大的命令本身又带来新的网络传播和序列化成本。Pipeline 则是把 5 万次独立命令放在一个批次里发送,虽然命令数没减少,但网络交互次数降到了最低,且服务器端可以边执行边返回,流水线效应明显。

我个人的习惯是:10 个以下 Key 直接用 MGET,1000 个以内继续 MGET,超过 5000 个就拆包并配合 Pipeline,单批次不超过 5000 条。如果你的 Key 名都很长(比如 80 字节),MGET 传 5 万个 Key 的命令体直接就是 4MB 以上,光分配内存就够喝一壶的,不如分批次查询稳妥。

再分享一个小技巧:在用 Pipeline 做大量读操作时,可以在客户端做压缩。比如把 Key 名先用数字编码成数组,这样 5 万个 Key 的列表能以更小的体量发送,但这个方案需要你自研序列化协议,通用场景下不必过度设计,大多数时候 Pipeline 分批次已经足够。

总之,Redis 本身很快,但在高延迟网络下,批量操作的姿势差距是数量级的。做完这批测试后,我对“用 MGET 还是 Pipeline”的判断逻辑已经变成了:先看单批次数据量,再看网络延迟,最后看是否需要原子性。不用 Pipeline 查 5 万个 Key 不是不行,而是你的用户体验和服务器负载都要为此买单。希望这次的拆解能帮你少踩几个坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询