Redis Pipeline 为什么能提速:RTT、批处理与内存边界
从 Redis 的请求响应协议出发,解释 Pipeline 怎样减少网络往返和系统调用,以及它与批量命令、事务、Lua 的区别和使用边界。
假设一个接口要读取 500 个 Redis Key。循环调用 500 次 GET,代码很简单,耗时却不一定花在 Redis 查找数据上。客户端每发出一条命令,都要等请求到达 Redis、命令执行、回复再穿过网络回来,然后才能发送下一条。即使每条命令只执行几十微秒,500 次网络往返仍会依次累加。
Pipeline 改变的是这段等待方式:客户端连续发送多条命令,不逐条等待回复,Redis 按收到的顺序执行,再让客户端批量读取结果。它主要减少 Round Trip Time(RTT)和 Socket 读写次数,并没有创造新的执行线程,也没有给多条命令增加事务语义。
这篇文章只回答一个问题:Pipeline 为什么能提速,以及批次开得太大时,成本会转移到哪里。
一、单条请求为什么会被 RTT 限制
Redis 使用请求—响应协议。最普通的同步调用遵循下面的顺序:
客户端发送 GET user:1
客户端等待
Redis 读取、执行并返回结果
客户端收到结果
客户端发送 GET user:2
……
一次往返耗时可以粗略拆成:
单次调用耗时 ≈ 请求网络时间 + 排队时间 + 命令执行时间 + 回复网络时间
前后两段加起来就是通常所说的 RTT。若连续执行 N 条彼此独立的同步命令,总耗时大致包含 N 次 RTT:
Tserial ≈ N × RTT + 所有命令的执行与传输时间
这不是严格的性能公式,连接复用、TCP 缓冲、调度、负载和 Value 大小都会影响结果。它仍然揭示了主要问题:下一条命令必须等待上一条回复时,网络等待无法重叠。
即使客户端和 Redis 在同一台机器,调用也不是一段普通的内存函数。客户端进程要写 Socket,内核调度 Redis 进程读取并处理,Redis 再写回 Socket,客户端重新获得运行机会。Redis 的官方 Pipeline 文档也专门说明,本机回环网络依然包含系统调用和进程调度成本。
二、Pipeline 把多次等待合并成少量批次
使用 Pipeline 后,时序变成:
客户端发送 GET user:1
客户端发送 GET user:2
客户端发送 GET user:3
客户端开始读取回复
Redis 执行 GET user:1 -> reply 1
Redis 执行 GET user:2 -> reply 2
Redis 执行 GET user:3 -> reply 3
如果一批包含 B 条命令,N 条命令大致只需要 ceil(N / B) 轮批次往返:
Tpipeline ≈ ceil(N / B) × RTT + 所有命令的执行与传输时间
Pipeline 并没有减少命令数量。Redis 仍要解析和执行全部命令,网络也仍要传输全部请求与回复。它省掉的是逐条“发送—等待—再发送”造成的空档,同时让客户端和服务端有机会用更少的 read()、write() 系统调用处理更多数据。
图里的命令仍按顺序进入 Redis。Pipeline 提升的是一批独立命令的端到端吞吐,而不是让单条 GET 本身执行得更快。只发一条命令,或者后一条命令必须依赖前一条的结果时,Pipeline 没有可合并的等待。
三、回复顺序稳定,不代表整批具有原子性
Redis 会按照命令发送顺序返回 Pipeline 结果。客户端可以用第 i 个回复对应第 i 条命令,不需要在协议里给每条回复额外附一个请求 ID。
但顺序不能推导出原子性。假设客户端 A 通过 Pipeline 发送:
GET balance
SET balance 90
客户端 B 的命令可能在两条命令之间执行。A 先读到 100,B 把余额改成 80,A 又按旧结果写入 90,B 的更新就被覆盖了。Pipeline 只负责批量传输,没有把“读取—计算—写回”封装成不可插入的操作。
需要一组命令连续执行时,应评估 MULTI/EXEC;需要读取当前值后在服务端完成判断和写入时,通常应使用已有原子命令、条件写入、Lua 或 Redis Functions。Redis 的事务文档明确说明,事务中的命令会作为一个隔离单元连续执行,这项保证不属于普通 Pipeline。
Pipeline 也不是并行执行。Redis 可以一次收到许多命令,处理命令的核心路径仍遵循 Redis 自身的执行模型。一批里如果夹着一个耗时很长的命令,排在它后面的回复仍要等待。把慢命令放进 Pipeline,不会把它变快,反而可能一次制造更明显的排队。
四、批量命令、Pipeline、事务与 Lua 怎样区分
这几种方式都能减少客户端与 Redis 的交互,因此经常被混在一起。
| 机制 | 发送的 Redis 命令数 | 是否减少 RTT | 是否保证中间不插入其他客户端命令 | 能否使用前一步结果继续计算 |
|---|---|---|---|---|
MGET、MSET 等批量命令 |
1 | 是 | 单条命令执行期间不会插入 | 只支持命令本身定义的逻辑 |
| Pipeline | 多条 | 是 | 否 | 发送时通常还没有前序回复 |
MULTI/EXEC |
多条并排队后执行 | 可结合客户端 Pipeline | 是 | 事务队列中的后续命令不能直接读取前序回复后再组装 |
| Lua / Functions | 通常一次调用 | 是 | 脚本执行具有原子性 | 可以在服务端读取、判断和写入 |
如果要读取一批明确的 String Key,MGET 比发送多个 GET 更直接,也省去重复命令解析。若操作类型不同,例如同时读取 String、Hash 与 ZSet,或者每条命令参数不同,Pipeline 更灵活。
若下一步依赖上一步结果,例如“先取库存,判断大于零,再扣减”,不能先把所有命令盲目塞入 Pipeline。此时真正需要的是服务端原子操作,而不是单纯减少 RTT。
在 Redis Cluster 中还要考虑路由。一个 Pipeline 中的 Key 可能属于不同 Slot 和不同节点,Cluster 客户端通常需要先按节点分组,再分别发送子 Pipeline;具体能力取决于客户端实现。MGET 等单条多 Key 命令则通常要求相关 Key 位于同一个 Slot。Pipeline 不能绕过 Cluster 的 Slot 约束。
五、批次越大,回复缓冲区占用越多
Pipeline 的速度收益来自“不等回复继续发送”,这也意味着回复必须暂时放在某个地方。客户端还没开始或来不及读取时,Redis 会把待发送内容放进该连接的输出缓冲区。
假设一次 Pipeline 读取 10,000 个 Key,每个 Value 平均 20 KB,仅 Value 就可能产生约 200 MB 回复,协议字段和对象管理还会增加额外开销。若多个应用实例同时重建缓存,Redis 需要为多条连接保留回复,瞬时内存和网络压力会一起上升。
这正是“只读请求也可能带来内存峰值”的原因。Pipeline 不修改 Value,但它产生的待发送回复属于客户端连接内存。Redis 官方建议把大量命令拆成有限批次,读完一批结果再发送下一批;文档中的批次数量只是示例,不能当成适用于所有 Value 大小和并发量的固定配置。
站内的《只读请求为什么也会触发 Redis Key 淘汰》复盘了这条故障链:多个实例并发执行大 Pipeline,输出缓冲区增长,实例越过内存边界,随后触发数据集淘汰。那篇文章处理故障排查,本文只保留设计结论:批次要按回复字节数和并发连接数估算,不能只数命令条数。
需要重点观察的信号包括:
- 每批命令数、请求与回复总字节数、整批耗时;
- Pipeline 并发数以及客户端连接池等待;
- Redis 的客户端输出缓冲区和总客户端内存;
- 实例内存、网络吞吐、
evicted_keys与连接断开; - 单批中是否包含复杂度过高或返回结果过大的命令。
Redis 还支持通过客户端输出缓冲区限制和 maxmemory-clients 管理客户端连接内存,具体行为见官方的客户端处理文档。这些保护措施负责止损,不能代替应用控制批次和并发。
六、超时以后,不能假设整批都没执行
Pipeline 通常会一次返回与命令数量对应的回复数组,其中某一条命令失败,并不表示其他命令没有执行。应用必须检查每个回复,而不是只判断“本次 Pipeline 调用有没有抛异常”。
更麻烦的是连接中断:客户端可能已经把整批命令写入内核,Redis 执行了其中一部分或全部,但回复没有完整到达客户端。此时应用看到的是结果未知,而不是确定失败。若直接重试一批 INCR、LPUSH 或扣减命令,可能产生重复效果。
因此,Pipeline 适合两类操作:重复执行没有副作用的读取;或者本身具备幂等边界的写入。非幂等写入需要业务 ID、去重状态或原子脚本保证重试安全。即使不用 Pipeline,远程调用也存在相同的不确定性;Pipeline 只是让一次失败可能覆盖更多命令。
还要避免把返回顺序当成业务成功列表。下面这种处理是不完整的:
// 伪代码:具体类型与方法名取决于客户端
List<PipelineReply> replies = pipeline.execute(commands);
if (replies != null) {
markAllSucceeded();
}
更合理的做法是保存命令与业务对象的稳定对应关系,逐项解析回复,对可重试错误和永久错误分别处理。客户端若提供异步 Pipeline,还要把批次超时、连接关闭和调用取消的语义确认清楚。
七、怎样选择批次大小
没有一个通用的 pipelineSize = 1000。批次应在吞吐、单批延迟、内存峰值和失败范围之间取平衡。
可以从一个保守值开始,并同时记录命令数和回复字节数。逐步增大批次,观察吞吐是否仍然明显增长。如果吞吐已经接近平台期,而单批 P99、输出缓冲区或实例内存继续上升,就没有必要再扩大。
一个实用的控制方式是给批次设置双重边界:
达到最大命令数 -> 发送
预计请求或回复字节达到阈值 -> 提前发送
对于 Value 大小差异很大的读取,仅用命令数控制尤其危险。读取 500 个几十字节的计数器和读取 500 个大 JSON,连接内存与网络成本完全不同。可以根据历史分布估算回复大小,或把明显的大对象放到独立批次。
批次控制之外还要限制并发。10 个实例各发一批 1,000 条,与 1 个实例发同样一批,对 Redis 的瞬时压力不是一回事。缓存预热、全量同步和定时任务应加入并发上限与抖动,避免所有实例同时启动。
八、什么时候值得使用 Pipeline
Pipeline 适合下面这些场景:
- 一次需要执行许多彼此独立的小命令;
- 网络 RTT 在总耗时中占比较高;
- 没有合适的单条批量命令;
- 应用能够限制批次、并发,并逐条处理回复;
- 写操作能够安全重试,或者失败后可以逐项确认。
若只有少量命令,Pipeline 增加的代码复杂度可能没有收益。若结果之间有依赖,优先寻找原子命令、Lua 或 Functions。若单条命令本身很慢,应先处理数据结构、命令复杂度和 Big Key,而不是把更多慢命令塞进同一批。
判断 Pipeline 是否有效也不应只看客户端平均耗时。至少同时比较总吞吐、单批 P99、Redis CPU、网络吞吐、客户端内存和服务端输出缓冲区。真正理想的结果是:减少等待以后吞吐上升,而内存与尾延迟仍处于可控范围。
Pipeline 的机制很小:先连续发送,再批量读取。用好它需要记住两条边界。第一,它优化通信,不提供并行和原子性;第二,它把逐条 RTT 换成了批次内存、批次延迟和更大的失败范围。只要围绕回复大小和并发量设置上限,Pipeline 就能成为一种简单且稳定的吞吐优化手段。
参考资料
如果这篇文章对你有帮助