Redis Pipeline + Lua 批量操作:把 100 次网络往返压成 1 次
引言
线上商品详情页有个接口,P99 稳定在 60ms,CPU 5%,GC 正常,数据库慢查询为零。压测报告看了三天没找到瓶颈,最后抓了一次链路:
商品详情页(一个请求要展示 100 个 SKU 的实时价格和库存)
for (String skuId : skuIds) { // 100 次循环
String price = redis.get("sku:price:" + skuId);
String stock = redis.get("sku:stock:" + skuId);
}
// 200 次 Redis 调用,耗时 98ms,占接口总耗时 80%
单次 get 0.5ms,看着不多;乘 200 次就是 100ms。而 Redis 服务端真正处理这 200 条命令花的时间,加起来不到 1ms——99% 的时间都花在"客户端发出去、服务端处理完、再传回来"的往返路上。
这不是 Redis 慢,是调用方式错了。消除往返有三把工具,能力边界完全不同:
| 手段 | 核心作用 | 原子性 | 适用 |
|---|---|---|---|
| 原生命令(MGET/HMGET/MSET) | 一条命令拿多个 key | 单命令原子 | 简单批量读写,最优先 |
| Pipeline | 多条命令一次发送,往返压成 1 次 | 不保证原子 | 批量无依赖操作 |
| Lua 脚本 | 逻辑搬到服务端,一次往返原子执行 | 脚本内原子 | 读-判断-写 的组合逻辑 |
先给结论数(100 个 key、1KB value、跨机 RTT 0.4ms):
| 方式 | 100 key 耗时 | 提升 | 原子性 |
|---|---|---|---|
| 循环单条 GET | 52.3 ms | 1x | 每条独立 |
| Pipeline(1 批 100 条) | 1.6 ms | 33x | 无 |
| MGET(原生命令) | 1.2 ms | 44x | 单命令原子 |
| Lua(100 次 redis.call) | 1.5 ms | 35x | 有 |
一句话破题:Pipeline 省的是"往返次数",Lua 省的是"往返次数 + 客户端与服务器之间的逻辑来回";前者解决性能,后者解决原子性——不要拿 Lua 当"更快的 Pipeline",它在纯读写场景通常还比 MGET 慢一点。
一、先算一笔账:50ms 都花在哪
1.1 一次 get 的时间构成
客户端 Redis 服务端
│ │
│ ① 序列化 key + 写 socket │
├──────────────────── ② 网络 RTT 0.4ms ─────────────────────▶│
│ ③ 执行 0.01ms
│◀──────────────────── ④ 回传 1KB + RTT ─────────────────────┤
│ ⑤ 反序列化 0.02ms │
▼
单次总耗时 ≈ 0.5ms,其中真正"干活"的只有 0.01ms(2%)
跨可用区同机房 RTT 0.4ms 是常态。100 次循环 = 100 次串行往返 = 40ms 纯网络 + 10ms 客户端序列化/上下文切换,凑成那 50ms。
1.2 为什么并发也救不了它
有人会说"那我开多线程不就完了"。能缓解,但代价是连接数暴涨、Redis 单线程被打满、客户端线程池排队——而且业务逻辑常常要求"先查完才能算",天然串行。把 100 次往返变成 1 次,才是消掉这部分开销的正解。
1.3 三种消除手段的本质区别
【单条】 req1 → resp1 → req2 → resp2 → ... → req100 → resp100
100 次 RTT,100 次系统调用
【Pipeline】 req1,req2,...,req100 → 一次 TCP 发送
← resp1,resp2,...,resp100 (打包回传)
1 次 RTT,服务端仍是逐条执行
【Lua】 EVAL(script, keys, args) → 服务端把 100 步逻辑跑完 → 1 个结果
1 次 RTT,服务端"一个整体"执行,中间不插入其他客户端命令
关键差异:Pipeline 只是运输方式的优化,命令还是 100 条,中间可以被别的客户端插队;Lua 是执行方式的优化,整个脚本在 Redis 单线程里跑完,中间不会被打断。
二、Pipeline:把 100 个请求塞进一个 TCP 包
2.1 原理
Pipeline 没有魔法,就是客户端不等待响应连续写,最后一次性读回所有响应。服务端完全不知道你在用 Pipeline——它只是从 socket 里连续读到 100 条命令,逐条执行,逐条写回。
普通模式(同步):
write(get k1) → read() 阻塞 ─┐
write(get k2) → read() 阻塞 ├── 每次都要等一个 RTT
...
Pipeline:
write(get k1) ┐
write(get k2) ├─ 连续写入 socket buffer(不等响应)
... │
write(get k100)┘
read() → 一次读回 100 条响应(按发送顺序)
所以 Pipeline 的收益上限就是往返次数:命令越多、RTT 越大,收益越明显。同机 RTT 0.05ms 时,Pipeline 只快 3~5 倍;跨机房 RTT 5ms 时,能快近百倍。
2.2 三种客户端的写法
Jedis:
try (Jedis jedis = pool.getResource()) {
Pipeline p = jedis.pipelined();
for (String key : keys) {
p.get(key); // 只写不读,本地排队
}
List<Object> results = p.syncAndReturnAll(); // 一次性发送 + 接收
}
Spring Data Redis(最常用):
// 注意:callback 必须返回 null,结果由 executePipelined 统一收集
List<Object> results = stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> {
StringRedisConnection conn = (StringRedisConnection) connection;
for (String key : keys) {
conn.get(key);
}
return null;
});
// results 与 keys 顺序严格一一对应
Lettuce(Spring Boot 2.x+ 默认):
// Lettuce 的 Batch 是 Pipeline 的高层封装
io.lettuce.core.api.sync.RedisCommands<String, String> cmd = connection.sync();
cmd.setAutoFlushCommands(false);
for (String key : keys) {
cmd.get(key); // 攒在本地
}
cmd.flushCommands(); // 一次发出
// 结果需要异步/批处理方式回收,或直接用 Spring 的 executePipelined
2.3 必须知道的三个限制
限制一:Pipeline 不保证原子性。
客户端 A 的 pipeline: [DECR stock] [INCR sold]
时间线: A 发 DECR → 服务端执行 → 客户端 B 的 GET 插进来读到中间状态
→ A 的 INCR 才执行
服务端是逐条执行的,两条命令之间可以插入其他客户端的命令。想原子,用 MULTI/EXEC 或 Lua;MULTI/EXEC 里的命令也是排队执行、不能做条件判断,所以"读-判断-写"只能靠 Lua。
限制二:错误要逐条检查。
Pipeline 中某条命令失败(比如对 String 执行 INCR),不影响后续命令执行,错误只体现在结果列表对应位置上(Spring 会抛异常包装)。不能像单条调用那样一个 try-catch 兜住全部。
限制三:批越大,内存越危险。
发送端缓冲 10000 条命令、接收端缓冲 10000 条响应(每条 1KB 就是 10MB),乘以连接池里的并发连接数,很容易把客户端堆吃穿。必须分批。
2.4 批大小实测:不是越大越好
1000 个 key(1KB value)分不同批次:
| 批大小 | 往返次数 | 总耗时 | 备注 |
|---|---|---|---|
| 10 | 100 | 52.1 ms | 退化成几乎单条 |
| 50 | 20 | 11.3 ms | 收益已显现 |
| 100 | 10 | 6.2 ms | 常用值 |
| 500 | 2 | 2.4 ms | 接近最优 |
| 1000 | 1 | 1.9 ms | 最优,但内存峰值高 |
| 5000(5000 key) | 1 | 9.8 ms | 单批过大,反序列化与内存拷贝拖慢 |
生产建议:批大小 200~500。1000 条理论更快,但一次 1MB 的响应缓冲在 200 并发下就是 200MB,GC 压力不值得。另外要注意,批越大,单批失败的重试成本越高。
2.5 别忽略原生命令:MGET 常常比 Pipeline 更好
// 100 个 key 一次取回:1 条命令、1 次 RTT、服务端零额外开销
List<String> values = stringRedisTemplate.opsForValue().multiGet(keys);
MGET 是 Redis 原生多 key 命令,命令数就是 1 条,比 Pipeline 的 100 条更省服务端解析开销,实测还快 25%(1.2ms vs 1.6ms)。同类还有 HMGET、MSET、SADD(多成员)、LPUSH(多元素)、ZADD(多成员)。
判断顺序应该是:能用原生命令 → 用原生命令;不能 → Pipeline;需要原子逻辑 → Lua。
三、Lua:把逻辑搬到服务端
3.1 原理:为什么脚本是原子的
Redis 执行命令是单线程的,而 **EVAL 把整个脚本当成"一条命令"**来调度——脚本开始执行后,直到返回,不会插入任何其他客户端的命令。这就是 Lua 原子性的全部来源,不需要锁、不需要 WATCH。
【MULTI/EXEC】 【Lua】
MULTI EVAL script
GET stock ← 可以插队 ├─ GET stock
DECR stock ├─ 判断 stock >= qty
EXEC └─ DECR stock
(整体执行,不可插入)
读到的值可能在 EXEC 前已被改 读、判断、写在同一原子块内
→ 需要 WATCH 乐观锁 + 重试
3.2 EVAL / EVALSHA / SCRIPT LOAD
# 每次传脚本体:浪费带宽(脚本可能几 KB)
EVAL "return redis.call('GET', KEYS[1])" 1 sku:stock:1
# 先加载拿 SHA,之后只传 SHA(生产标准姿势)
SCRIPT LOAD "return redis.call('GET', KEYS[1])" # → "a1b2c3..."
EVALSHA a1b2c3... 1 sku:stock:1
Spring Data Redis 的 DefaultScriptExecutor 会自动"先 EVALSHA,报 NOSCRIPT 再 EVAL",所以用 DefaultRedisScript 声明脚本即可,不用自己管 SHA:
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setLocation(new ClassPathResource("lua/deduct_stock.lua"));
script.setResultType(Long.class);
Long result = stringRedisTemplate.execute(script,
List.of("sku:stock:1001"), "1"); // keys, args
注意:Redis 重启或 SCRIPT FLUSH 后脚本缓存会丢,客户端要能自动降级到 EVAL(Spring 已处理,手写 Jedis 要自己兜)。
3.3 第一个脚本:单 SKU 原子扣减
-- KEYS[1] = sku:stock:{skuId}
-- ARGV[1] = 扣减数量
-- 返回:>=0 表示扣减成功后的余量;-1 表示库存不足
local stock = tonumber(redis.call('GET', KEYS[1]) or '-1')
local qty = tonumber(ARGV[1])
if stock < 0 then return -2 end -- key 不存在
if stock < qty then return -1 end -- 库存不足,未做任何写
return redis.call('DECRBY', KEYS[1], qty)
这一个脚本同时解决了秒杀的三个问题:超卖(判断和扣减在同一原子块)、多一次往返(查库存和扣减合并)、并发重试(不需要 WATCH)。
3.4 Lua 的五个坑
坑一:脚本会阻塞整个 Redis。
脚本执行期间,Redis 单线程被占住,其他所有客户端排队。一个执行 50ms 的脚本,在 10 万 QPS 的实例上就是灾难。脚本内禁止大循环、禁止 KEYS *、禁止对百万元素做排序聚合——把 O(1)~O(10) 当上限。
# 脚本超时(默认 5s)后 Redis 只回 BUSY,且只能 SCRIPT KILL(前提是没写过数据)
lua-time-limit 5000
坑二:原子 ≠ 回滚。
Lua 脚本中途报错,已经执行的写不会回滚:
redis.call('DECRBY', KEYS[1], 1) -- 已执行
redis.call('INCR', KEYS[2]) -- 这里报错 → 前一条已生效,不会回滚
所以脚本必须"先全部校验,再全部写"(像 3.3 那样),关键位置用 redis.pcall 显式处理错误。别把 Lua 当数据库事务用。
坑三:Cluster 下多 key 必须同 slot。
EVAL script 2 sku:stock:1001 sku:stock:1002
→ CROSSSLOT Keys in request don't hash to the same slot
解决:hash tag 强制同槽
{sku:1001}:stock {sku:1001}:sold → 都按 "sku:1001" 算 slot
需要跨多个不同 SKU 的批量原子操作时,只能用 {batch:orderId} 这类公共 tag 把 key 聚到一槽——代价是热点集中(第六章实战二会算这笔账)。
坑四:返回值类型有坑。
Lua 的 table 转 RESP 时:数字变整数、字符串变 bulk、true 变 1、false 变 nil、nil 会截断数组。返回 {1, nil, 3} 到客户端会变成两元素数组。需要"空值"时用空字符串 '' 或 -1 占位。
坑五:脚本要尽量确定性。
老版本 Redis 靠"命令传播"复制脚本,脚本里用 TIME/RANDOMKEY/SRANDMEMBER 会导致主从数据不一致。Redis 5+ 默认效果复制(replicate effects)基本解决,但把随机逻辑放到客户端、把脚本保持为纯函数依然是团队规范。
四、两者结合的正确姿势
4.1 决策表
| 需求 | 用什么 | 理由 |
|---|---|---|
| 批量读同一类 key | MGET / HMGET | 1 条命令,开销最小 |
| 批量写互不依赖的 key | Pipeline(分批 200~500) | 1 次 RTT,无原子要求 |
| 读-判断-写(扣库存、领券、限流) | Lua | 必须原子 |
| 100 个独立原子操作 | Pipeline 包 EVALSHA | 每个脚本原子,整体 1 次 RTT |
| 批量原子(N 个 SKU 全成或全不成) | 单个 Lua 脚本(同 slot) | 整体原子,热点风险自担 |
| 需要事务回滚 | 数据库 | Redis 做不到 |
4.2 组合模式一:Pipeline 包多个 EVALSHA
最实用的组合——把"100 次单点原子操作"压成 1 次往返,同时保留每次操作的原子性:
DefaultRedisScript<Long> deduct = new DefaultRedisScript<>();
deduct.setLocation(new ClassPathResource("lua/deduct_stock.lua"));
deduct.setResultType(Long.class);
// 一个请求要扣 100 个 SKU,每个 SKU 独立成败(不是全成或全不成)
List<Object> results = stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (int i = 0; i < skuIds.size(); i++) {
byte[] key = ("sku:stock:" + skuIds.get(i)).getBytes();
byte[][] args = { String.valueOf(qty.get(i)).getBytes() };
connection.scriptingCommands().evalSha(
sha(deduct), ReturnType.INTEGER, 1, key, args); // 每条独立原子
}
return null;
});
// 逐个检查 results:-1 表示该 SKU 库存不足,其余 SKU 照常扣减
适合"允许部分成功"的场景(比如批量下单时每个商品独立校验)。注意:整体依然不是原子的,失败补偿要业务自己做。
4.3 组合模式二:Pipeline 读 + Lua 写
典型的"批量查详情 + 单点原子变更":
// 第一步:Pipeline 批量取 100 个 SKU 的价格/库存(1 次往返)
List<Object> values = stringRedisTemplate.executePipelined(...);
// 第二步:对需要变更的那一个 SKU,用 Lua 原子扣减(1 次往返)
Long remain = stringRedisTemplate.execute(deductScript, List.of(stockKey), qty);
总共 2 次 RTT 完成"批量展示 + 原子变更",比循环 200 次快两个数量级。
4.4 反模式清单
| 反模式 | 问题 | 正确做法 |
|---|---|---|
| 循环里单条 get/set | N 次 RTT | MGET 或 Pipeline |
| Pipeline 里放需要读结果的 if/else | 客户端拿不到中间结果 | 改用 Lua |
| 用 Pipeline 做"库存判断+扣减" | 非原子,必超卖 | Lua |
| 用 MULTI/EXEC 做条件逻辑 | 命令不能基于读结果分支 | Lua |
| Lua 里循环 10 万次 | 阻塞 Redis 数百毫秒 | 拆分 + 客户端分页 |
| Pipeline 一次塞 10 万条 | 客户端内存爆、超时 | 分批 200~500 |
| 每个请求都 EVAL 全量脚本体 | 带宽浪费 | EVALSHA / Spring 自动缓存 |
五、实战一:批量查询用户信息
场景:消息中心一次要渲染 100 个用户的头像、昵称、等级。用户信息存 Hash(user:profile:{userId}),字段多、单条 2~3KB。
5.1 方案对比
| 方案 | 命令数 | 往返 | 耗时 | 问题 |
|---|---|---|---|---|
| 循环 HGETALL ×100 | 100 | 100 | 58ms | 基线 |
| Pipeline HGETALL ×100 | 100 | 1 | 2.1ms | 无明显问题 |
| Lua 聚合 100 个 HGETALL | 1 | 1 | 2.6ms | 脚本复杂、阻塞更久 |
| 改存 JSON + MGET | 1 | 1 | 1.4ms | 需改存储模型 |
结论:批量读场景,最优解往往不是 Pipeline,而是改数据模型。 把"Hash 存字段"改成"String 存 JSON",批量查就能用 MGET,一条命令拿 100 个用户;同时 Pipeline 方案也完全够用,只是多 50% 开销。
5.2 代码(Pipeline 版,无需改存储)
public Map<String, Map<String, String>> batchGetProfiles(List<String> userIds) {
List<Object> raw = stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> {
StringRedisConnection conn = (StringRedisConnection) connection;
for (String uid : userIds) {
conn.hGetAll("user:profile:" + uid);
}
return null; // 必须返回 null
});
Map<String, Map<String, String>> result = new LinkedHashMap<>();
for (int i = 0; i < userIds.size(); i++) {
@SuppressWarnings("unchecked")
Map<String, String> profile = (Map<String, String>) raw.get(i);
if (profile != null && !profile.isEmpty()) {
result.put(userIds.get(i), profile); // 空 Hash 视为未命中
}
}
return result;
}
两个细节:① 结果顺序与发送顺序严格对应,可以用下标直接关联;② 未命中的 key 返回空 Map(不是 null),要按 isEmpty() 判断,否则会把"不存在"当成"存在但没字段"。
5.3 缓存穿透兜底
批量接口最容易踩的坑是"100 个 key 里 80 个不存在",全部打到 DB。规范做法:未命中的 key 回填空值占位(user:profile:{id} = "{}",TTL 60s),下一次批量查询就能命中,避免同一批请求反复穿透。
六、实战二:批量扣减库存
场景:下单时一次扣减购物车里 N 个 SKU 的库存,要求"要么全扣成功,要么一个都不扣"。
6.1 方案一:单脚本全量原子(同 slot)
-- KEYS[1..n] = sku:stock:{batch:orderId}:skuId (hash tag 保证同槽)
-- ARGV[1..n] = 每个 SKU 的扣减数量
-- 返回 {1, n} 成功;{-1, i} 第 i 个库存不足;{-2, 0} 参数错误
local n = #KEYS
if n == 0 or n ~= #ARGV then return {-2, 0} end
-- 第一遍:全部校验,任一不足就整体退出(此时还没写,天然"回滚")
for i = 1, n do
local stock = tonumber(redis.call('GET', KEYS[i]) or '-1')
if stock < 0 then return {-1, i} end
if stock < tonumber(ARGV[i]) then return {-1, i} end
end
-- 第二遍:全部扣减
for i = 1, n do
redis.call('DECRBY', KEYS[i], tonumber(ARGV[i]))
end
return {1, n}
Java 调用:
List<String> keys = skus.stream()
.map(s -> "sku:stock:{batch:" + orderId + "}:" + s) // 同 hash tag → 同 slot
.collect(Collectors.toList());
String[] args = qtys.stream().map(String::valueOf).toArray(String[]::new);
List<Long> r = stringRedisTemplate.execute(batchDeductScript, keys, (Object[]) args);
if (r.get(0) == -1) {
throw new BizException("库存不足: " + skus.get(r.get(1).intValue() - 1));
}
6.2 方案二:逐 SKU 原子 + 业务补偿(生产更常用)
方案一有个硬伤:所有 SKU 的库存 key 被 hash tag 聚到同一个 slot,热点集中,集群扩不散。真实大促里我们用的是:
① 逐个 SKU 执行单 key 扣减脚本(每个原子,可分散到不同 slot)
② 收集失败列表
③ 对已成功的 SKU 反向补偿(INCRBY 恢复)
④ 补偿失败进本地消息表,定时任务兜底
牺牲了"整体原子",换来水平扩展能力。这是分布式库存的常态:Redis 保证单点不超卖,跨点一致性交给业务补偿。
6.3 幂等:扣减脚本必须能防重
支付回调、网关重试都会导致同一订单重复扣减。在脚本里加一道订单去重:
-- KEYS[1] = sku:stock:{sku}:1001
-- KEYS[2] = order:deducted:{orderId} (同 slot 才能放一个脚本里)
-- ARGV[1] = qty, ARGV[2] = orderId
if redis.call('SISMEMBER', KEYS[2], ARGV[2]) == 1 then
return -3 -- 已扣过,幂等返回
end
local stock = tonumber(redis.call('GET', KEYS[1]) or '-1')
if stock < tonumber(ARGV[1]) then return -1 end
redis.call('DECRBY', KEYS[1], ARGV[1])
redis.call('SADD', KEYS[2], ARGV[2])
redis.call('EXPIRE', KEYS[2], 86400) -- 去重集合必须有过期时间
return 1
最后一行 EXPIRE 是血泪教训——没有 TTL 的去重 Set 会一年长成几百 MB 的大 key,阻塞集群(同系列《Redis 大 Key 排查》里踩过)。
七、性能对比:单条 vs Pipeline vs Lua
7.1 测试环境
| 项目 | 配置 |
|---|---|
| 服务端 | Redis 7.2,8 核 16G,单实例 |
| 客户端 | 同机房独立机器,RTT 0.4ms |
| 数据 | 100 个 key,value 1KB(String) |
| 工具 | JMH,10 轮预热 + 10 轮测量取中位数 |
| 并发 | 延迟测试单线程,吞吐测试 32 线程 |
7.2 单请求延迟(100 key)
| 方式 | 平均耗时 | P99 | 提升 | 原子性 |
|---|---|---|---|---|
| 循环单条 GET ×100 | 52.3 ms | 68.4 ms | 1x | 无 |
| Pipeline(1 批) | 1.63 ms | 2.41 ms | 32x | 无 |
| MGET | 1.21 ms | 1.92 ms | 43x | 单命令原子 |
| Lua(100 次 redis.call) | 1.48 ms | 2.63 ms | 35x | 有 |
7.3 写入场景(100 个 SKU 扣减)
| 方式 | 平均耗时 | 结果一致性 |
|---|---|---|
| 循环 DECRBY ×100 | 55.1 ms | 逐条独立,中途失败部分生效 |
| Pipeline ×100 | 1.82 ms | 非原子,可能超卖 |
| Pipeline + 100 个 EVALSHA | 3.41 ms | 每个 SKU 原子,整体不原子 |
| 单 Lua 脚本(同 slot) | 2.27 ms | 整体原子 |
注意第三行比第二行慢 87%——原子性是有价格的:每个 EVALSHA 都要走一次脚本查找与独立执行,比裸 DECRBY 重。
7.4 并发吞吐(32 线程)
| 方式 | key-ops/s | 相对单条 |
|---|---|---|
| 循环单条 GET | 6.4 万 | 1x |
| Pipeline | 42 万 | 6.6x |
| MGET | 55 万 | 8.6x |
| Lua | 38 万 | 5.9x |
这张表最值得玩味:并发下 Lua 比 Pipeline 慢 10%。原因是原子性的代价——一个脚本执行 100 步期间,Redis 单线程被独占,其他连接全部排队。Pipeline 的命令是逐条执行的,服务端可以在命令间隙服务其他客户端,并发度更高。
所以:"原子"和"高并发"在天平两端,Lua 脚本越重,越会拉低整个实例的吞吐。
7.5 纯单 SKU 秒杀场景
| 方案 | 吞吐 | 说明 |
|---|---|---|
| Lua 单 key 扣减 | 9.8 万次/s | 最推荐 |
| WATCH + MULTI/EXEC + 重试 | 3.1 万次/s | 冲突重试放大,越高并发越差 |
| 先 GET 再 DECR(无锁) | 理论高,实测超卖 | 不可用 |
WATCH 方案在热点 key 上会大量重试失败,实际吞吐只有 Lua 的三分之一——这也是秒杀不用乐观锁、全用 Lua 的原因。
八、总结
8.1 星级对比
| 维度 | 循环单条 | Pipeline | MGET 等原生 | Lua |
|---|---|---|---|---|
| 延迟(100 key) | ★ | ★★★★ | ★★★★★ | ★★★★ |
| 并发吞吐 | ★ | ★★★★ | ★★★★★ | ★★★★ |
| 原子性 | ✗ | ✗ | 单命令 | ★★★★★ |
| 代码复杂度(星多=简单) | ★★★★★ | ★★★★ | ★★★★★ | ★★ |
| 支持条件逻辑 | ✓(客户端) | ✗ | ✗ | ★★★★★ |
| Cluster 友好 | ★★★★★ | ★★★★★ | 需同 slot | ★★ |
| 服务端压力 | ★★★★★ | ★★★ | ★★★★★ | ★★★ |
| 出错处理 | 简单 | 逐条检查 | 简单 | 不回滚,需预校验 |
8.2 选型决策树
要批量访问 Redis,怎么选?
├── 只是多个 key 的同类读/写?
│ ├── 有原生命令(MGET/HMGET/MSET/SADD...) → 用它,最优
│ └── 没有 → Pipeline,分批 200~500
├── 需要"读-判断-写"?
│ ├── 单 key → 单 key Lua 脚本(秒杀、扣库存、限流)
│ └── 多 key
│ ├── 能接受热点集中 → 一个 Lua 脚本 + hash tag 同槽
│ └── 不能 → 逐 key Lua + Pipeline 批量发 + 业务补偿
├── 只要"少往返",不要原子?
│ └── Pipeline,别用 MULTI/EXEC,更别用 Lua
└── 需要回滚/强一致?
└── 不是 Redis 的活,回数据库
8.3 一句话
Redis 的批量优化其实只有一句话:把"客户端和服务端之间的对话次数"降到最少——MGET 把 100 条命令合成 1 条(最优,可惜只在同类读写时可用),Pipeline 把 100 次往返合成 1 次(运输优化,命令还是 100 条,中间可插队,不保证原子),Lua 把 100 步逻辑合成 1 个不可打断的执行单元(执行优化,换来原子性,代价是脚本期间整个实例为你排队);三者不是替代关系而是分工关系:能用原生命令就别上管道,能用管道就别写脚本,非要原子才动 Lua——而一旦用了 Lua,就要记住它给你的是"不被打断",不是"出错回滚",预校验才是脚本的正确写法。
8.4 关键数据
- 100 个 key、RTT 0.4ms:单条 52.3ms → Pipeline 1.6ms(33x)→ MGET 1.2ms(43x)→ Lua 1.5ms(35x)
- 32 并发吞吐:单条 6.4 万 → Pipeline 42 万 → MGET 55 万 → Lua 38 万 key-ops/s
- Lua 并发吞吐比 Pipeline 低 10% —— 原子性的价格
- 100 SKU 批量扣减:Lua 整体原子 2.27ms vs Pipeline 非原子 1.82ms,原子性溢价 25%
- 单 SKU 秒杀:Lua 9.8 万次/s vs WATCH 乐观锁 3.1 万次/s,3.1 倍
- Pipeline 甜点批大小:200~500;1000 条更快但内存峰值不可忽视
- 服务端真正处理 100 条 GET 的时间:< 1ms(所以 50ms 里 98% 是往返)
8.5 给团队的建议
| 项目状态 | 建议 |
|---|---|
| 代码里有 for 循环访问 Redis | 全量排查,这是最高性价比的性能优化点 |
| 缓存结构选型 | 批量读为主的数据优先存 JSON String(可用 MGET),少用 Hash |
| Pipeline 使用规范 | 统一封装工具方法,强制分批 200~500,禁止业务代码自己裸写 |
| Lua 使用规范 | 脚本入库评审:必须预校验、必须有执行时长上限、禁止大循环 |
| Cluster 环境 | 多 key 脚本必须过同 slot 检查,CI 里加 CROSSSLOT 用例 |
| 监控 | 加 redis_commands_duration_seconds 分位监控,Lua 慢脚本单独告警 |
| 幂等 | 所有扣减类脚本带订单去重集合,且集合必须有 TTL |
九、常见问题
9.1 Pipeline 能保证命令按顺序执行吗?
能保证发送顺序 = 服务端执行顺序 = 响应顺序,这是 Redis 协议的单连接特性决定的。但"按顺序执行"不等于"原子执行"——两条命令之间可以被其他客户端的命令插入。所以顺序敏感但不需要原子的场景(比如先写 A 再写 B、B 依赖 A 已存在)Pipeline 可用;需要"中间不被看见"的场景必须 Lua。
9.2 Lua 脚本里能不能调用其他 Redis 命令?有什么限制?
能,用 redis.call()(出错抛异常终止脚本)或 redis.pcall()(捕获错误继续)。限制主要有四:① Cluster 下所有 key 必须同 slot;② 脚本内不能执行会阻塞的命令(如 BLPOP);③ 不建议用随机/时间类命令(老版本主从会不一致);④ 脚本执行时间计入 Redis 单线程,长脚本会拖垮实例。另外 redis.call 中途报错时,之前执行的写不会回滚。
9.3 Pipeline 和 MULTI/EXEC 有什么区别?该用哪个?
Pipeline 是网络层优化(一次发送、一次接收),MULTI/EXEC 是事务语义(命令排队、EXEC 时连续执行、不被插入)。两者可以叠加(MULTI 里的命令也能 pipeline 发送)。但要注意:MULTI/EXEC 不能根据读结果做分支——你在 MULTI 里读到的值,无法用来决定后面执行什么,所以"判断库存再扣减"只能用 Lua。日常批量读写用 Pipeline,需要"一组命令不被打断"用 MULTI/EXEC,需要"看结果再决定"用 Lua。
9.4 用了 Pipeline,为什么有时候反而更慢?
三个常见原因:① 批太大(一次几千条),客户端序列化和响应缓冲成了瓶颈,实测 5000 条一批比 1000 条一批慢 5 倍;② 单条命令本身很重(如 HGETALL 大 Hash),Pipeline 只是省了 RTT,服务端耗时没变;③ 连接池配置不当,Pipeline 复用了错误的连接或触发了连接争用。排查顺序:先测批大小,再看单命令耗时(SLOWLOG),最后看连接池指标。
9.5 为什么说 Lua 不适合做"更快的批量读"?
因为批量读的瓶颈是网络往返和数据传输,而 Lua 并没有减少数据量——100 个 key 的 100KB 数据还是要传回来,脚本内 100 次 redis.call 也有解析开销,最后还要把 Lua table 转成 RESP。实测 Lua 1.48ms 还慢于 MGET 的 1.21ms。Lua 的价值在"服务端做判断和聚合"——比如 100 个 key 里只返回满足条件的 3 个,这时传输量骤降,Lua 才真正赢。
9.6 生产上最该防的是什么?
按事故频率排序:① Pipeline 没分批导致客户端 OOM;② Lua 脚本里有大循环导致 Redis 卡住、全站超时;③ 扣减脚本没有幂等导致重复扣减;④ 去重集合没有 TTL长成大 key;⑤ Cluster 下多 key 脚本报 CROSSSLOT 才发现。前四条都能靠代码规范 + 评审挡住,第五条建议在预发环境就用集群模式跑一遍。
互动话题:你们代码里还藏着多少"循环里访问 Redis"的写法?有没有被一个没分批的 Pipeline 或一个大循环 Lua 脚本坑过?评论区报一下你们优化前后的耗时对比,我挑几个典型场景做成下一篇的排查实战。
参考资料
- Redis 官方文档 · Pipelining:https://redis.io/docs/latest/develop/using-commands/pipelining/
- Redis 官方文档 · Scripting with Lua:https://redis.io/docs/latest/develop/programmability/eval-intro/
- Redis 官方文档 · Transactions(MULTI/EXEC/WATCH):https://redis.io/docs/latest/develop/using-commands/transactions/
- Redis 命令参考 · MGET / EVAL / EVALSHA:https://redis.io/docs/latest/commands/
- Spring Data Redis · Pipelines and Scripting:https://docs.spring.io/spring-data/redis/reference/redis/pipeline.html
- 本系列前作:《Redis 持久化 RDB vs AOF 怎么选:数据安全与性能的权衡》
- 本系列前作:《消息重复消费排查:幂等性缺失的血泪教训——钱多扣了一次》
标题:Redis Pipeline + Lua 批量操作:把 100 次网络往返压成 1 次
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/10/25/1792914144786.html
公众号:服务端技术精选
- 引言
- 一、先算一笔账:50ms 都花在哪
- 1.1 一次 get 的时间构成
- 1.2 为什么并发也救不了它
- 1.3 三种消除手段的本质区别
- 二、Pipeline:把 100 个请求塞进一个 TCP 包
- 2.1 原理
- 2.2 三种客户端的写法
- 2.3 必须知道的三个限制
- 2.4 批大小实测:不是越大越好
- 2.5 别忽略原生命令:MGET 常常比 Pipeline 更好
- 三、Lua:把逻辑搬到服务端
- 3.1 原理:为什么脚本是原子的
- 3.2 EVAL / EVALSHA / SCRIPT LOAD
- 3.3 第一个脚本:单 SKU 原子扣减
- 3.4 Lua 的五个坑
- 四、两者结合的正确姿势
- 4.1 决策表
- 4.2 组合模式一:Pipeline 包多个 EVALSHA
- 4.3 组合模式二:Pipeline 读 + Lua 写
- 4.4 反模式清单
- 五、实战一:批量查询用户信息
- 5.1 方案对比
- 5.2 代码(Pipeline 版,无需改存储)
- 5.3 缓存穿透兜底
- 六、实战二:批量扣减库存
- 6.1 方案一:单脚本全量原子(同 slot)
- 6.2 方案二:逐 SKU 原子 + 业务补偿(生产更常用)
- 6.3 幂等:扣减脚本必须能防重
- 七、性能对比:单条 vs Pipeline vs Lua
- 7.1 测试环境
- 7.2 单请求延迟(100 key)
- 7.3 写入场景(100 个 SKU 扣减)
- 7.4 并发吞吐(32 线程)
- 7.5 纯单 SKU 秒杀场景
- 八、总结
- 8.1 星级对比
- 8.2 选型决策树
- 8.3 一句话
- 8.4 关键数据
- 8.5 给团队的建议
- 九、常见问题
- 9.1 Pipeline 能保证命令按顺序执行吗?
- 9.2 Lua 脚本里能不能调用其他 Redis 命令?有什么限制?
- 9.3 Pipeline 和 MULTI/EXEC 有什么区别?该用哪个?
- 9.4 用了 Pipeline,为什么有时候反而更慢?
- 9.5 为什么说 Lua 不适合做"更快的批量读"?
- 9.6 生产上最该防的是什么?
- 参考资料
评论