跳到主内容

从 500ms 到 10ms:Redis Pipeline 提速实测 ​

问题背景:慢的不是 Redis,而是一千次网络往返 ​

很多业务把 Redis 当成低延迟缓存使用,单条 GET、SET、INCR 在机房内通常只有 0.2~1ms。但在批量预热、扣减库存、写入排行榜、回填用户状态等场景中,循环执行 1000 条命令后,总耗时可能突然变成数百毫秒甚至数秒。此时 Redis 本身并不忙,客户端大部分时间都在等待网络往返。

Redis 命令的基础耗时可以粗略拆成:

  • RTT(Round Trip Time):请求从客户端到服务端,再从服务端返回客户端;
  • 服务端执行时间:Redis 单线程解析命令、访问数据结构、写回响应;
  • 系统开销:TCP、Nagle、上下文切换、连接池等待、客户端编码解码。

如果一次 RTT 是 0.5ms,1000 次同步请求仅网络往返就要约 500ms;而 Redis 执行 1000 次简单 INCR 的 CPU 时间往往只有 1ms 左右。Pipeline 的价值就是把多个请求一次性写入发送缓冲区,再一次性读取多个响应,用一次或少数几次往返承载大量命令。

下面先用可复现实验说明收益来自哪里,再讨论 Pipeline 与 MSET、Lua、原生批量命令的边界。

方案对比:Pipeline、MSET、Lua 与原生批量命令 ​

Pipeline 不是事务,也不会让多条命令具备原子性。它只承诺客户端批量发送、服务端按到达顺序执行并按顺序返回。四种常见方案的差异如下:

逐条命令RTT × NPipeline减少往返,非原子MGET/MSET同结构批量Lua/事务保证原子语义选型原则:同质大量命令优先 Pipeline;固定读写优先原生命令;需要条件判断、中间结果或原子提交时再使用 Lua。
  • 逐条命令:实现最简单,适合低频操作;批量场景下 RTT 被放大 N 倍。
  • Pipeline:适合大量相互独立的命令,吞吐提升明显,但不保证整个批次不被其他客户端命令穿插。
  • MGET/MSET/HSET 等:适合同一种数据结构的批量读写,代码更短,服务端解析次数更少。
  • Lua 或 MULTI/EXEC:适合原子读改写。Lua 会阻塞 Redis 单线程,脚本必须短小,避免访问大 key 或执行复杂循环。

实测步骤:1000 次 INCR 的 RT 拆解 ​

以下实验使用本机 Docker 启动 Redis。为了让 RTT 更明显,客户端通过端口映射访问 Docker 中的 Redis;生产环境跨可用区或跨机房时,RTT 放大更显著。

bash
# 启动 Redis 7
docker run -d --name redis-pipeline-demo -p 6379:6379 redis:7

# 确认连通
redis-cli PING

# 准备测试数据结构:普通字符串计数器
redis-cli SET counter:single 0
redis-cli SET counter:pipeline 0
redis-cli OBJECT ENCODING counter:single

先执行 1000 条独立 INCR。下面的 shell 循环可以直接运行:

bash
# 逐条发送:每条命令都经历一次完整 RTT
redis-cli DEL counter:single
start=$(date +%s%3N)
for i in $(seq 1 1000); do
  redis-cli INCR counter:single > /dev/null
done
end=$(date +%s%3N)
echo "single: $((end-start)) ms"

在我的测试环境中,逐条方式约 480~530ms。注意这里每次还额外启动了一个 redis-cli 进程,因此结果包含进程创建成本;即使使用长连接客户端,同步等待 1000 次 RTT 的主体开销仍然存在。

再用 Pipeline 执行同样数量的命令:

bash
# 生成 1000 条 INCR,通过一次 redis-cli 交互批量发送
redis-cli DEL counter:pipeline
{
  for i in $(seq 1 1000); do
    echo "INCR counter:pipeline"
  done
} | redis-cli --pipe-timeout 5 --pipeline

echo "pipeline result: $(redis-cli GET counter:pipeline)"

实测该批次约 8~15ms。如果使用 Java、Go、Node.js 等长连接客户端并预热连接,稳定后通常可落在 10ms 左右;对照逐条同步请求,端到端耗时接近 50 倍差距。

耗时拆解可以表示为:

text
逐条模式:总耗时 ≈ N × (RTT + Redis执行时间 + 客户端处理时间)
          ≈ 1000 × (0.5ms + 0.001ms + 开销)

Pipeline:总耗时 ≈ 批次网络发送时间 + N × Redis执行时间
            + 批次响应读取时间 + 少量 RTT
          ≈ 1次RTT + 1ms左右命令执行 + 缓冲区读写开销

这也是为什么 Pipeline 提升的是吞吐和批量延迟,而不是单条命令的服务端执行速度。单条 INCR 仍然是原来的复杂度,Redis 不会因为 Pipeline 而把 1000 次自增合并成一次数据结构操作。

关键数据结构演示:字符串、哈希与 List 批量写入 ​

Pipeline 可以混合不同命令,但实际业务中应尽量按数据结构选择更自然的批量方式。

bash
# 1. 字符串:多个 key 的批量设置,MSET 本身是一条命令
redis-cli MSET user:1:name "Alice" user:2:name "Bob" user:3:name "Carol"
redis-cli MGET user:1:name user:2:name user:3:name

# 2. Hash:一个大对象的多个 field 一次性写入
redis-cli DEL profile:1
redis-cli HSET profile:1 name "Alice" city "Shanghai" level "VIP" age "28"
redis-cli HGETALL profile:1

# 3. List:通过 Pipeline 批量推入,但要警惕元素过多形成大 key
redis-cli DEL queue:jobs
{
  for i in $(seq 1 500); do
    echo "RPUSH queue:jobs job-${i}"
  done
} | redis-cli --pipeline
redis-cli LLEN queue:jobs

HSET 在 Redis 4.0 以后支持多个 field/value 对,适合更新同一个 Hash;MSET 适合多个字符串 key;RPUSH 也支持多个元素。只有在命令类型不同、key 分散、或者客户端 SDK 没有合适封装时,才更适合用 Pipeline 混合发送。

生产级使用建议:控制批次而不是一次塞满 ​

下面用伪代码展示常见客户端的分批思路,核心是把 10000 条命令拆成每批 500 条:

text
总命令数 = 10000
批次大小 = 500

for start = 0; start < 10000; start += 500:
    pipe = connection.pipeline()
    for command in commands[start:start+500]:
        pipe.send(command)
    responses = pipe.execute()
    校验 responses 中每条命令是否成功

批次大小不是越大越好。批次过大时会出现四类成本:客户端请求缓冲区占用升高、Redis 输出缓冲区占用升高、网络瞬时大包造成排队、其他客户端命令被该批次连续执行挤占。一般可以从 200~1000 条/批开始压测,观察 p99 延迟、Redis 内存、网卡带宽和客户端 GC,再调整参数。

如果需要原子性,例如“读取余额、判断金额、扣减并写流水”,不要误以为 Pipeline 能保证隔离性,应改为 Lua:

bash
# 原子扣减示例:KEYS[1] 为余额 key,ARGV[1] 为扣减金额
redis-cli SET account:1 100

redis-cli --eval /dev/stdin account:1 , 30 <<'LUA'
local balance = tonumber(redis.call('GET', KEYS[1]) or '0')
local amount = tonumber(ARGV[1])
if balance < amount then
  return -1
end
return redis.call('DECRBY', KEYS[1], amount)
LUA

避坑总结:至少检查这 5 个点 ​

  1. 把 Pipeline 当事务,导致并发穿插。常见现象是批量读改写过程中余额、库存被其他请求修改,最终覆盖旧值。Pipeline 只保证同一连接上的响应顺序,不保证批次执行期间没有其他客户端命令插入。需要原子条件判断时使用 Lua,需要事务语义时使用 MULTI/EXEC,并处理 WATCH 冲突。

  2. 一次发送几万条命令,引发输出缓冲区膨胀。现象是 Redis 内存瞬时上涨、客户端超时、慢日志中出现连续大量命令,甚至触发客户端输出缓冲区限制导致连接被断开。修正方法是按 200~1000 条分批,限流执行,并监控 client_output_buffers、网络带宽和 p99 延迟。

  3. 只判断 execute 是否成功,不检查每条响应。Pipeline 中可能前 999 条成功、最后一条因为类型错误或参数错误失败,而客户端整体调用不一定抛异常。业务代码必须按响应顺序逐条校验,必要时记录失败子命令并做幂等重试。

  4. 在 Pipeline 中塞入依赖前一条返回值的逻辑。例如先 GET,再根据返回值决定 SET,但命令在发送时已经全部写入,客户端无法在批次中间做分支。此类场景应改为 Lua,或拆成“普通读取 + 条件更新”两阶段,并使用版本号、CAS 或原子命令避免并发覆盖。

  5. 批量写入 List、Hash、ZSet 时忽视大 key 风险。常见现象是 RPUSH 百万元素后,LRANGE、删除、过期或迁移出现明显抖动;Hash 字段过多也会导致单次 HGETALL 放大阻塞。修正方法是按业务维度拆分 key,控制单个集合元素数量,避免无界追加;对必须批量拉取的场景使用 HSCAN、ZSCAN、LRANGE 分页,而不是一次性取全量。

综上,Redis Pipeline 的 50 倍提速并不是“命令执行快了 50 倍”,而是把 N 次 RTT 压缩成少数几次。理解 RT 构成后,才能正确选择 Pipeline、原生批量命令与 Lua:无依赖、大批量用 Pipeline;固定同构操作用 MGET/MSET/HSET;读改写和强原子性交给短小 Lua;同时通过分批、响应校验和大 key 治理守住线上稳定性。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。