一个批量接口慢在哪:不是 Redis 慢,是网络慢
八月下旬优化一个批量查询接口。它要一次读 200 个商品的库存,我一开始的写法很直白:
public Map<String, Integer> batchGetStock(List<String> skuIds) {
Map<String, Integer> result = new HashMap<>(skuIds.size());
for (String skuId : skuIds) {
String val = stringRedisTemplate.opsForValue().get("stock:" + skuId);
result.put(skuId, val == null ? 0 : Integer.parseInt(val));
}
return result;
}
200 次循环,接口耗时 42ms。我觉得 Redis 一次 GET 也就几十微秒,200 次不该这么慢。加了耗时埋点之后发现,Redis 命令本身的执行时间加起来才 1.6ms,剩下 40ms 全在等网络上。
算一下往返开销
用 redis-cli --latency 测一下客户端到 Redis 的往返延迟:
$ redis-cli -h 10.20.1.31 --latency -i 5
min: 0, max: 3, avg: 0.19 (milliseconds)
平均 0.19ms,看着很小。但这是单次往返(RTT)。200 次串行调用就是:
200 × 0.19ms = 38ms
加上序列化、连接获取这些,42ms 就对上了。这里的关键认知是:Redis 的单次命令执行是微秒级的,但网络往返是百微秒级的,两者差一个数量级。批量操作时瓶颈几乎总在网络而不是 Redis 本身。
用 tcpdump 抓包验证过,200 次 GET 对应 200 个请求包 + 200 个响应包:
$ tcpdump -i eth0 -nn -tt 'port 6379 and host 10.20.1.31' -c 400 | head -20
1598162401.118342 IP 10.20.1.12.48210 > 10.20.1.31.6379: Flags [P.], seq 1:43, length 42
1598162401.118531 IP 10.20.1.31.6379 > 10.20.1.12.48210: Flags [P.], seq 1:7, length 6
1598162401.118744 IP 10.20.1.12.48210 > 10.20.1.31.6379: Flags [P.], seq 43:86, length 43
...
相邻两个请求的时间差稳定在 190 微秒左右,就是等前一个响应回来才发下一个。
方案一:MGET 解决读批量
读的场景有现成的批量命令。200 个 key 一次 MGET 拿回来:
public Map<String, Integer> batchGetStock(List<String> skuIds) {
List<String> keys = skuIds.stream()
.map(s -> "stock:" + s)
.collect(Collectors.toList());
List<String> values = stringRedisTemplate.opsForValue().multiGet(keys);
Map<String, Integer> result = new HashMap<>(skuIds.size());
for (int i = 0; i < skuIds.size(); i++) {
String v = values.get(i);
result.put(skuIds.get(i), v == null ? 0 : Integer.parseInt(v));
}
return result;
}
耗时从 42ms 降到 0.8ms。一次往返,快 50 倍。
但 MGET 有两个限制:key 数量别太大(我们一次最多 500 个,超过就切批,避免单次请求包过大阻塞其他命令);所有 key 必须在同一个 Redis 实例上(集群模式下如果 key 分散在不同 slot,multiGet 在 Lettuce 里会自动按 slot 分组发多次,效果打折)。
方案二:Pipeline 解决写批量
写操作没有 MSET 那么通用(MSET 只能设字符串,且无法带过期时间、无法做自增),这时候用 pipeline。
pipeline 的原理:把多条命令攒在客户端缓冲区,一次性打包发给 Redis,Redis 依次执行后把所有响应一次性返回。从 200 次往返变成 1 次往返。
public void batchSetStock(List<StockItem> items) {
stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (StockItem item : items) {
byte[] key = ("stock:" + item.getSkuId()).getBytes(StandardCharsets.UTF_8);
byte[] val = String.valueOf(item.getStock()).getBytes(StandardCharsets.UTF_8);
connection.setEx(key, 1800, val); // SET key val EX 1800
}
return null; // 返回值会被忽略
});
}
200 次 SETEX,从 44ms 降到 1.1ms。
如果要拿每条命令的返回结果:
List<Object> results = stringRedisTemplate.executePipelined(
(RedisCallback<Object>) connection -> {
for (String skuId : skuIds) {
connection.incrBy(("stock:" + skuId).getBytes(), -1);
}
return null;
});
// results 里按顺序放着每条 INCRBY 的返回值
pipeline 的几个注意点:
- pipeline 不保证原子性。它只是批量传输,中间可能穿插别的客户端的命令。需要原子性就得用事务或者 Lua。
- 攒的命令别太多。一次塞 10 万条命令,客户端缓冲区和 Redis 的输入缓冲区都会涨,而且这条大命令会阻塞 Redis 处理其他请求。我们按 500 条切批。
- 结果是一次性返回的,会占用客户端内存。批量查询结果大时(比如 pipeline 里塞 5000 个 HGETALL),小心 OOM。
实测不同批大小的耗时(200 条命令,本地网络 0.19ms RTT):
| 方式 | 耗时 | 往返次数 |
|---|---|---|
| 逐条执行 | 42ms | 200 |
| pipeline(一批 200) | 1.1ms | 1 |
| pipeline(切批,每批 50) | 1.6ms | 4 |
| pipeline(一批 5000) | 38ms | 1(但 Redis 阻塞 30ms) |
最后一行是重点:批太大反而慢,因为 Redis 单线程,执行这 5000 条命令期间其他客户端全在排队。pipeline 是把往返成本转移成了 Redis 的处理时间,批太大就变成阻塞了。
方案三:Lua 脚本保证原子性
前面两种方案都不保证原子性。有个场景必须要原子:限量秒杀的库存扣减,要先判断库存够不够,够才扣。写成两条命令就出问题了:
// 错误:GET 和 DECRBY 之间可能被别的线程插进来,导致超卖
int stock = Integer.parseInt(redis.get("stock:" + skuId));
if (stock >= num) {
redis.decrBy("stock:" + skuId, num); // 中间这一刻库存可能已经被别人扣光了
return true;
}
return false;
压测 500 并发抢 1000 件库存,最后卖出 1043 件,超卖 43 件。
用 Lua 脚本解决。Redis 执行 Lua 脚本是单线程原子的,脚本执行期间不会有其他命令插进来:
-- deduct_stock.lua
-- KEYS[1]: 库存 key
-- ARGV[1]: 扣减数量
-- ARGV[2]: 兜底的过期时间,防止库存 key 永不过期
local key = KEYS[1]
local num = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local stock = tonumber(redis.call('GET', key))
if stock == nil then
return -1 -- key 不存在,说明没初始化
end
if stock < num then
return -2 -- 库存不足
end
redis.call('DECRBY', key, num)
redis.call('EXPIRE', key, ttl)
return stock - num -- 返回扣减后的剩余库存
Java 这边用 RedisScript + execute:
@Configuration
public class LuaScriptConfig {
@Bean
public DefaultRedisScript<Long> deductStockScript() {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptSource(new ClassPathResourceScriptSource(
new ClassPathResource("lua/deduct_stock.lua")));
script.setResultType(Long.class);
return script;
}
}
@Service
public class StockService {
@Autowired private StringRedisTemplate redis;
@Autowired private DefaultRedisScript<Long> deductStockScript;
public boolean deduct(String skuId, int num) {
List<String> keys = Collections.singletonList("stock:" + skuId);
Long ret = redis.execute(deductStockScript, keys,
String.valueOf(num), "1800");
if (ret == null) return false;
if (ret == -1L) { log.warn("库存未初始化, skuId={}", skuId); return false; }
if (ret == -2L) { return false; } // 库存不足
if (ret == 0L) { /* 触发售罄事件,更新 DB */ }
return true;
}
}
同样的压测,超卖为 0,QPS 还能到 21000(单 key,因为 Lua 脚本在 Redis 内部执行,只有一次网络往返)。
脚本缓存:别每次都传脚本全文
这是我踩的一个坑。一开始我用 DefaultRedisScript 配 ClassPathResourceScriptSource,以为 Spring 会帮忙缓存。后来抓包发现每次调用都在传完整的脚本文本——几百字节的 Lua 代码,每次都发一遍。
原因是 Redis 的脚本缓存机制:EVAL 命令要把脚本全文发过去,Redis 计算 SHA1 后缓存;EVALSHA 只发 SHA1 摘要,Redis 用摘要去缓存里找。如果缓存里没有,返回 NOSCRIPT 错误,客户端再退化成 EVAL。
Lettuce(Spring Boot 2.3 默认的 Redis 客户端)的 RedisScriptingCommands 确实做了优化:优先发 EVALSHA,收到 NOSCRIPT 才发 EVAL。用 redis-cli monitor 看下实际执行的命令:
$ redis-cli -h 10.20.1.31 monitor
1598163001.112233 [0 10.20.1.12:48210] "EVALSHA" "a4f2c8b1e9d3..." "1" "stock:10086" "2" "1800"
1598163001.112401 [0 10.20.1.12:48210] "EVALSHA" "a4f2c8b1e9d3..." "1" "stock:10087" "1" "1800"
1598163001.112588 [0 10.20.1.12:48210] "EVALSHA" "a4f2c8b1e9d3..." "1" "stock:10088" "3" "1800"
确实走的是 EVALSHA。但 JDK 8 + Lettuce 5.3 的组合有个 bug:如果 Redis 重启或者执行了 SCRIPT FLUSH,缓存全丢,客户端会收到 NOSCRIPT 并退化成 EVAL,这个退化是不可见的,只有并发高的那一下会慢。我们压测时看到过 TP99 突然从 3ms 跳到 90ms,就是 Redis 主备切换后脚本缓存丢失导致的。
解决办法是在应用启动时主动加载脚本:
@PostConstruct
public void preloadScripts() {
// 用 SCRIPT LOAD 把脚本灌进 Redis 缓存,返回 SHA1
String sha = redis.execute((RedisCallback<String>) connection ->
connection.scriptLoad(deductStockScript.getScriptAsString().getBytes()));
log.info("库存扣减脚本已预加载, sha1={}", sha);
}
另外确认一下脚本确实在缓存里:
redis> SCRIPT EXISTS a4f2c8b1e9d3f6a8b2c5d7e9f1a3b5c7d8e9f0a1
1) (integer) 1
redis> SCRIPT FLUSH # 清空脚本缓存(慎用,会影响所有客户端)
redis> SCRIPT KILL # 杀死正在执行的脚本(脚本没写操作时才管用)
Lua 脚本的几条硬规矩
这些是踩完坑之后总结的:
- 脚本不能用随机数和系统时间。Redis 要求脚本必须是确定性的,否则主从复制会因为执行结果不一致而数据错乱。Redis 5.0 之后默认开启 replication 的 effects replication(只复制写命令),这个限制松动了一些,但别依赖它。
- 脚本不能执行太慢。Lua 脚本在 Redis 里是单线程执行的,一个跑 500ms 的脚本会让所有其他客户端等 500ms。我们给 Lua 里加了个循环上限,超过 1000 次迭代就返回错误。
- KEYS 和 ARGV 要分开传。
KEYS用于 Redis 集群做 slot 路由,写死在脚本里或者放错位置,集群模式下会报CROSSSLOT Keys in request don't hash to the same slot。 - 脚本里不要做耗时的
KEYS操作。见过有人在 Lua 里写redis.call('KEYS', 'user:*'),几十万 key 直接把 Redis 卡死。要遍历用SCAN,或者干脆别在脚本里做。
三种方案怎么选
| 场景 | 方案 | 是否原子 |
|---|---|---|
| 批量读同一类型 key | MGET / HMGET | 否(单条命令本身是原子执行) |
| 批量写,不要求原子 | pipeline | 否 |
| 多步操作要原子 | Lua 脚本 | 是 |
| 要原子 + 要回滚 | MULTI/EXEC 事务 | 是(但弱,不支持条件回滚) |
补充说明一下 Redis 的事务:它的 MULTI/EXEC 不支持条件判断,也不支持回滚(某条命令失败,后面的照样执行,只有语法错误才整体不执行)。所以真要做"检查再执行"这种原子逻辑,只能用 Lua。这也是为什么 Lua 在 Redis 里地位这么重要。
留个问题
关于《Redis 管道与 Lua 脚本:批量操作的性能优化》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。