一、RTT 开销分析
1.1 一次命令的完整旅程
每次 Redis 命令都要经历「客户端发送 → 网络传输 → 服务端处理 → 网络返回 → 客户端解析」。其中服务端处理只有几十微秒,网络往返(RTT)才是大头。当客户端与服务端跨机房时,单次 RTT 可能高达几十毫秒。
# 同机房 RTT ~0.5ms,服务端处理 ~0.05ms
# 跨地域 RTT ~30ms,服务端处理仍只有 ~0.05ms
# 距离越远,RTT 开销越致命
1.2 N 次命令 = N 次 RTT
# 执行 100 条独立命令串行等待:总耗时 ≈ 100 × (RTT + 处理)
# 同机房 0.5ms RTT → ≈55ms;跨地域 30ms RTT → ≈3 秒
RTT 是批量的核心动机:把 N 次往返压缩成 1 次,总耗时从
N×(RTT+处理)变为RTT + N×处理。处理时间几乎不变,省掉的都是网络开销。
1.3 测量 RTT
# 观察网络延迟
redis-cli -h 10.0.0.1 -p 6379 --latency
# min: 0, max: 5, avg: 0.52 (1000 samples)
# -P 16 表示 pipeline 16,吞吐可提升数十倍
redis-benchmark -h 10.0.0.1 -p 6379 -n 100000 -c 50 -P 16
二、pipeline 原理与实现
2.1 原理
**pipeline(管道)**把一批命令一次性发给服务端,服务端顺序执行后一次性返回结果。客户端不需要等前一条返回再发下一条,而是在一个往返内完成整批命令。
# 普通模式:SET→回复→SET→回复...(N 次往返)
SET a 1
OK
SET b 2
OK
# pipeline 模式:一次性发送多条,一次性收回复
SET a 1
SET b 2
SET c 3
# 回复依次返回: OK OK OK
2.2 客户端实现
// Go 示例:go-redis Pipelined,一次往返执行全部
pipe := rdb.Pipeline()
pipe.Set(ctx, "a", 1, 0)
pipe.Set(ctx, "b", 2, 0)
pipe.Set(ctx, "c", 3, 0)
cmds, _ := pipe.Exec(ctx)
// Java 示例:Lettuce 异步批处理
RedisAsyncCommands<String, String> async = conn.async();
async.set("a", "1"); async.set("b", "2"); async.set("c", "3");
# redis-cli 也支持 --pipe 批量导入(每行一条命令)
cat commands.txt | redis-cli --pipe
2.3 pipeline 的吞吐提升
# 未使用 pipeline:约 5 万次/秒(RTT 受限);pipeline 16:约 200 万次/秒
redis-benchmark -n 100000 -c 50 -P 16
pipeline 提升的是吞吐而非单条延迟:单条命令延迟不变,但批量场景下单位时间处理的命令数大幅提升。适合批量写入、批量读取、缓存预热等场景。
三、Redis 事务 MULTI/EXEC
3.1 事务模型
Redis 事务通过 MULTI 开启、EXEC 执行、DISCARD 取消。与关系型数据库不同,Redis 事务没有回滚,命令在 EXEC 时批量顺序执行。
MULTI # 开启事务
SET balance:alice 100
DECRBY balance:alice 30
INCRBY balance:bob 30
EXEC # 顺序执行所有命令
# 返回: [OK, 85, 30]
MULTI
SET key1 value1
DISCARD # 取消事务,清空队列
# 返回 OK
3.2 与 pipeline 的关系
MULTI/EXEC 在协议层面同样把命令「攒起来一次发送」,所以事务天然具备 pipeline 的网络收益,且额外保证原子性:
| 对比项 | pipeline | MULTI/EXEC |
|---|---|---|
| 减少 RTT | 是 | 是 |
| 原子性 | 否(命令间可穿插其他客户端命令) | 是(EXEC 时整体执行) |
| 中间结果 | 不需要 | 不需要 |
| 适用 | 批量读写 | 需要原子的批量写 |
两者常被混为一谈,但语义不同:pipeline 只优化网络,事务额外保证原子。不需要原子性的批量读用 pipeline,需要原子性的批量写用事务。
四、WATCH 乐观锁
4.1 WATCH 的作用
WATCH 在事务前监控若干 key。如果 WATCH 之后、EXEC 之前这些 key 被其他客户端修改,EXEC 直接失败返回 nil,从而避免「读-改-写」竞争。
WATCH balance:alice # 监控余额
GET balance:alice # 100
MULTI
DECRBY balance:alice 30
INCRBY balance:bob 30
EXEC
# 若期间 alice 余额被改 → EXEC 返回 nil,需重试
# 若未被改 → 正常执行,返回 [OK, 85, 30]
4.2 乐观重试
# 应用层乐观重试(伪代码)
while true:
WATCH key
val = GET key
MULTI; 执行写逻辑; result = EXEC
if result == nil: continue # 冲突 → 重试
break # 成功
WATCH 是乐观锁:不提前加锁,靠版本校验发现冲突。适合「读多写少、冲突概率低」的场景;冲突率高时重试成本大,可换 Lua 脚本保证原子。
五、pipeline vs 事务
5.1 语义对比
| 维度 | pipeline | MULTI/EXEC | Lua 脚本 |
|---|---|---|---|
| 网络往返 | 1 次 | 1 次 | 1 次 |
| 原子性 | 无 | 有 | 有 |
| 条件逻辑 | 无 | 无 | 有 |
| 回滚 | 无 | 无 | 无 |
| 服务端计算 | 无 | 无 | 有 |
| 适用版本 | 所有 | 所有 | 2.6+ |
5.2 选型建议
# 纯批量读(如缓存预热、批量 MGET)→ pipeline
# 批量写但可容忍中间穿插 → pipeline
# 批量写且要求原子 → MULTI/EXEC 或 Lua
# 需要条件判断的原子操作 → Lua
# 需要事务内逻辑(如扣库存判断)→ Lua
5.3 反例
# 错误:把事务当 pipeline 用,只为省 RTT
MULTI
GET key1 # 不需要原子性,却付出事务的队列开销
GET key2
EXEC
# 正确:纯读用 pipeline 或 MGET
MGET key1 key2
事务的入队与 EXEC 解析有额外开销。纯读场景用
MGET/pipeline 更轻量;只有确实需要原子性时才用事务。
六、批量写优化案例
6.1 案例:批量初始化用户缓存
# 场景:导入 100 万用户缓存,原始做法 500 万次 SET × 每次 RTT,耗时极长
SET user:1:name tom
SET user:1:age 18
# ...
// 优化:pipeline 批量写,1 万条一次往返
pipe := rdb.Pipeline()
for i := 0; i < 10000; i++ {
pipe.HSet(ctx, fmt.Sprintf("user:%d", i), "name", names[i], "age", ages[i])
}
pipe.Exec(ctx)
6.2 案例:批量写对比数据
| 方式 | 耗时(10 万条 SET) | 说明 |
|---|---|---|
| 逐条 SET | ~55s | RTT 主导 |
| pipeline 100 | ~0.8s | 网络开销摊薄 |
| MULTI/EXEC | ~0.9s | 原子但略慢于纯 pipeline |
| Lua 批量 | ~0.7s | 服务端循环,需传参 |
6.3 pipeline 块大小的权衡
# 过大 → 客户端内存高、阻塞服务端太久;过小 → 收益不明显
# 经验值:单批 100~1000 条,视命令复杂度和带宽而定
批量写的关键参数是每批条数。太小的批次省不了 RTT,太大的一批会让其他客户端长时间得不到服务。生产常用 100~500 条一批,配合限速平滑写入。
七、Lua 脚本对比
7.1 Lua 的场景
Lua 脚本在服务端原子执行,同时具备「事务原子性 + 条件逻辑 + 服务端计算」,是 pipeline 与事务的进阶替代:
-- 原子扣库存:pipeline/事务无法在服务端做条件判断
local stock = tonumber(redis.call('GET', KEYS[1]))
local qty = tonumber(ARGV[1])
if stock == nil then return -1 end
if stock >= qty then
redis.call('DECRBY', KEYS[1], qty)
return stock - qty
else
return -2
end
EVAL "$(cat deduct.lua)" 1 stock:sku:001 5
7.2 选择矩阵
| 需求 | 最优方案 |
|---|---|
| 减少 RTT,不需要原子 | pipeline |
| 减少 RTT + 原子批量写 | MULTI/EXEC |
| 原子 + 条件判断 | Lua |
| 原子 + 复杂计算 | Lua |
| 高频重复调用 | EVALSHA / Functions |
# 高频 Lua 脚本务必用 EVALSHA 省去脚本体传输
SCRIPT LOAD "return redis.call('GET', KEYS[1])"
EVALSHA <sha1> 1 mykey
7.3 性能对比实测
# 同一批 1000 条 SET:pipeline 0.09ms/条,MULTI/EXEC 0.12ms/条,Lua 0.10ms/条
# 三者差距不大,决策应由语义需求驱动
性能上三者差距不大,决策应由语义驱动:要原子无条件判断选事务,要条件逻辑选 Lua,只要省 RTT 选 pipeline。不要为了「更快」而选错语义。
八、最佳实践
8.1 黄金法则
| 实践 | 说明 |
|---|---|
| 明确语义再选型 | 原子/条件/纯批量,三者对应事务/Lua/pipeline |
| 控制批量大小 | 单批 100~500 条,避免阻塞与内存暴涨 |
| 配合连接池 | pipeline 复用连接,避免频繁建连 |
| 关注超时 | 大批量命令设置合理读超时,避免悬挂 |
| 慎用无限批量 | 分批处理大任务,降低单次压力 |
8.2 组合使用
# 场景:批量写入 + 部分失败补偿
# 方案:pipeline 分批写 + 对失败批次用事务重试
// 分批 pipeline 通用写法:每批 500,Exec 后检查错误并补偿
for batch := 0; batch < total; batch += 500 {
pipe := rdb.Pipeline()
// 填充 500 条命令
cmds, _ := pipe.Exec(ctx)
}
8.3 监控验证
# 用 INFO stats 的 total_commands_processed 对比优化前后吞吐斜率
redis-cli info stats
# 用 SLOWLOG 观察大批量命令是否拖慢
redis-cli slowlog get 10
批量优化落地后要监控
total_commands_processed与服务端 CPU。若吞吐未明显提升,先检查 RTT 是否真的下降、批量是否够大。
九、注意事项与陷阱
9.1 常见陷阱
| 陷阱 | 后果 | 规避 |
|---|---|---|
| 一次性 pipeline 百万条 | 内存暴涨、阻塞其他客户端 | 分 100~500 条一批 |
| 事务内包含 WATCH 后不改的 key | 多余监控开销 | 只监控真正依赖的 key |
| 把事务当 pipeline 用 | 额外队列开销 | 纯读用 MGET/pipeline |
| 忽略命令失败 | 批量中部分失败不知情 | 检查 Exec 返回的每条结果 |
| 跨槽批量命令(集群) | CROSSSLOT 错误 | hash tag 收敛同槽 |
9.2 集群下的批量
# 集群中 pipeline 只能包含同槽 key
# 跨槽批量需按槽分组,或使用 hash tag
MGET {user:1}:name {user:1}:age
# 同一槽 → 允许
9.3 注意事项清单
# 1. pipeline 不保证原子,中途可被其他命令插入
# 2. MULTI/EXEC 无回滚,运行时错误不中断后续命令
# 3. Lua 脚本阻塞主线程,须保持短小
# 4. WATCH 冲突重试要有上限,防止活锁
# 5. 大批量命令避免在高峰期执行;集群中 pipeline 限同槽 key
最后一条铁律:批量优化的前提是数据分布与业务语义允许。为优化而破坏原子性或一致性,是最常见的过度优化。
结语
- 网络 RTT 是 Redis 命令延迟的大头,跨地域场景下处理时间占比几乎可忽略,这是批量的根本动机。
- pipeline 把 N 次往返压缩为 1 次,显著提升吞吐,但不保证原子性。
- MULTI/EXEC 事务在减少 RTT 的同时保证原子执行,但没有回滚机制。
- WATCH 是乐观锁,靠版本校验发现「读-改-写」竞争,冲突时 EXEC 返回 nil 需重试。
- pipeline 与事务的核心区别在原子性:纯批量读用 pipeline,原子批量写用事务。
- 批量写要控制每批条数(100~500),配合连接池与限速,避免内存暴涨与阻塞。
- Lua 脚本同时具备原子性、条件逻辑与服务端计算,需要条件判断的原子操作首选 Lua。
- 选型由语义驱动而非性能:明确「要不要原子、要不要条件」后再决定 pipeline、事务还是 Lua。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。