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 耗时提升原子性
循环单条 GET52.3 ms1x每条独立
Pipeline(1 批 100 条)1.6 ms33x无
MGET(原生命令)1.2 ms44x单命令原子
Lua(100 次 redis.call)1.5 ms35x有

一句话破题: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)分不同批次:

批大小往返次数总耗时备注
1010052.1 ms退化成几乎单条
502011.3 ms收益已显现
100106.2 ms常用值
50022.4 ms接近最优
100011.9 ms最优,但内存峰值高
5000(5000 key)19.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 决策表

需求用什么理由
批量读同一类 keyMGET / HMGET1 条命令,开销最小
批量写互不依赖的 keyPipeline(分批 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/setN 次 RTTMGET 或 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 ×10010010058ms基线
Pipeline HGETALL ×10010012.1ms无明显问题
Lua 聚合 100 个 HGETALL112.6ms脚本复杂、阻塞更久
改存 JSON + MGET111.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 ×10052.3 ms68.4 ms1x无
Pipeline(1 批)1.63 ms2.41 ms32x无
MGET1.21 ms1.92 ms43x单命令原子
Lua(100 次 redis.call)1.48 ms2.63 ms35x有

7.3 写入场景(100 个 SKU 扣减)

方式平均耗时结果一致性
循环 DECRBY ×10055.1 ms逐条独立,中途失败部分生效
Pipeline ×1001.82 ms非原子,可能超卖
Pipeline + 100 个 EVALSHA3.41 ms每个 SKU 原子,整体不原子
单 Lua 脚本(同 slot)2.27 ms整体原子

注意第三行比第二行慢 87%——原子性是有价格的:每个 EVALSHA 都要走一次脚本查找与独立执行,比裸 DECRBY 重。

7.4 并发吞吐(32 线程)

方式key-ops/s相对单条
循环单条 GET6.4 万1x
Pipeline42 万6.6x
MGET55 万8.6x
Lua38 万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 星级对比

维度循环单条PipelineMGET 等原生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 Pipeline + Lua 批量操作:把 100 次网络往返压成 1 次
作者:jiangyi
地址:http://jiangyi.space/articles/2026/10/25/1792914144786.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消