一、真实场景:线上Redis阻塞3秒
周一早上10点,接到告警:某业务接口的P99延迟从50ms飙升到3.2秒。排查发现,刚上线的定时任务使用了KEYS *+循环DEL删除10万个key。Redis单线程处理这条命令时,其他请求全部排队等待。
这是典型的“把Redis当关系型数据库用”的坑。本文会完整展示如何用管道、Lua脚本等方式高效批量操作,以及如何通过慢查询日志、LATENCY命令诊断问题。
二、方案对比:四种批量操作方式
环境:Redis 7.0.14(单机,2核4G),客户端压测机器同网段,使用redis-benchmark和自写PHP脚本(PHP 8.3)。测试场景:批量写入10万条string key(key:test_1~100000,value随机16字节),分别测试总耗时、服务端CPU占用、网络包数。
| 方案 | 命令数 | 总耗时 | QPS | CPU占用 | 网络包数 |
|---|---|---|---|---|---|
| 单条SET | 100,000 | 8.2s | 12,195 | 85% | 100,000+ACK |
| 管道(Pipeline) | 100,000 | 0.43s | 232,558 | 92% | 1个TCP包(批量) |
| 事务(MULTI/EXEC) | 100,001 | 8.5s | 11,764 | 84% | 100,001+ACK |
| Lua脚本 | 1 | 0.38s | 263,158 | 90% | 2个TCP包(EVAL+结果) |
结论:管道和Lua脚本均能将10万次写入控制在0.4秒以内,QPS提升20倍。事务反而更慢,因为每个命令仍需单独发送。下文给出具体实现。
三、代码实现与核心原理
1. 管道(Pipeline)—— 最常用方案
管道将多个命令打包成一次网络请求发送,Redis服务器依次执行后一次性回包。适合没有依赖关系的批量操作。
# 使用 redis-cli --pipe 模式,从stdin读命令,按Redis RESP协议一次发送
# 生成10万条SET命令
for i in $(seq 1 100000); do
echo "SET test_$i $(openssl rand -hex 16)"
done | redis-cli --pipe -h 127.0.0.1 -p 6379
# 输出:All data transferred. Waiting for the last reply...
# Last reply received from server.
# errors: 0, replies: 100000
原理:--pipe模式会分批发送(默认16KB),并等待响应。也可在编程语言中使用pipeline对象。
PHP示例(Predis 2.2):
<?php
require 'vendor/autoload.php';
use Predis\Client;
$client = new Client([
'scheme' => 'tcp',
'host' => '127.0.0.1',
'port' => 6379,
]);
$pipeline = $client->pipeline();
for ($i = 1; $i <= 100000; $i++) {
$pipeline->set("test_$i", bin2hex(random_bytes(16)));
}
$replies = $pipeline->execute(); // 一次性获取全部响应
echo "done, count: " . count($replies);
2. Lua脚本 —— 原子性+性能最优
Lua脚本在服务端执行,减少网络往返,且是原子操作。适合需要条件判断或依赖中间结果的场景。
-- batch_set.lua
local prefix = ARGV[1]
local count = tonumber(ARGV[2])
for i = 1, count do
redis.call('SET', prefix .. '_' .. i, redis.sha1hex(i))
end
return count
# 加载脚本,得到SHA
SCRIPT_LOAD=$(redis-cli SCRIPT LOAD "$(cat batch_set.lua)")
echo $SCRIPT_LOAD
# 使用EVALSHA执行
redis-cli EVALSHA $SCRIPT_LOAD 0 test_batch 100000
# 返回:(integer) 100000
注意:EVALSHA如果脚本未缓存会报错,可先用EVAL或者SCRIPT EXISTS检查。
3. 批量删除——千万不要用KEYS
获取10万key用SCAN代替KEYS,然后用管道删除。
# 正确做法:SCAN + 批量PIPELINE DEL
# 生成测试key
for i in $(seq 1 100000); do redis-cli SET test_del_$i val; done
# 删除全部test_del_* key
redis-cli --scan --pattern 'test_del_*' | redis-cli --pipe -d DEL
# 注意:--pipe模式默认读取标准输入,每行是一条命令。上述管道将SCAN输出的每一行作为key传给DEL命令。
# 但更严谨做法是用xargs:
redis-cli --scan --pattern 'test_del_*' | xargs -L 1000 redis-cli DEL
# 或者直接用redis-cli的--pipe,但需构造DEL命令
redis-cli --scan --pattern 'test_del_*' | sed 's/^/DEL /' | redis-cli --pipe
避坑:--pipe模式要求每行都是完整Redis命令,用sed构造DEL key1,redis-cli自动转换成RESP协议。
4. 慢查询分析 —— 定位延迟
配置慢查询阈值:
# 设置慢于200微秒的命令记录
redis-cli CONFIG SET slowlog-log-slower-than 200
redis-cli CONFIG SET slowlog-max-len 1000
# 查看最近10条慢查询
redis-cli SLOWLOG GET 10
# 输出示例:
# 1) 1) (integer) 14 # 唯一ID
# 2) (integer) 1709359000 # 时间戳
# 3) (integer) 12034 # 微秒耗时
# 4) 1) "KEYS" # 命令
# 2) "*"
# 5) "127.0.0.1:54321" # 客户端地址
# 6) "user-agent=..."
分析:KEYS *耗时12ms,应立刻用SCAN替代。结合MONITOR(调试用,生产慎用)可实时观察。
四、调优实战:内存、连接池与大key
1. 内存优化
使用INFO MEMORY查看内存碎片率:
redis-cli INFO MEMORY | grep -E 'used_memory|mem_fragmentation_ratio'
# used_memory: 209715200
# used_memory_rss: 314572800
# mem_fragmentation_ratio: 1.5
# 碎片率超过1.5说明碎片严重,可执行 MEMORY PURGE(需要高版本支持)或重启。
配置maxmemory-policy allkeys-lru控制淘汰策略,避免OOM。
2. 连接池配置
以PHP Predis为例,避免每次请求创建新连接:
<?php
$client = new Predis\Client([
'scheme' => 'tcp',
'host' => '127.0.0.1',
'port' => 6379,
], [
'connections' => [
'tcp' => 'Predis\Connection\StreamConnection',
],
'parameters' => [
'async_connect' => false,
'tcp_nodelay' => true, // 禁用Nagle算法,降低延迟
],
// 连接池:设置最小空闲连接数和最大连接数
'pool' => [
'min_connections' => 5,
'max_connections' => 20,
],
]);
// 注意:Predis原生不支持连接池,这里使用自定义池或重新连接。建议使用phpredis扩展+连接池。
// phpredis 6.0支持连接池:
// $redis = new Redis();
// $redis->pconnect('127.0.0.1', 6379, 2.5); // 持久连接
// 生产中使用长连接+RESP3协议
3. 大key处理
大key(如hash with 10万fields)会导致阻塞。使用--bigkeys扫描:
redis-cli --bigkeys
# 输出示例:
# Biggest string found 'test_1' has 1024 bytes
# Biggest set found 'myset' has 50002 members
[00:00.02] [00:00.02] Scanning the entire keyspace...
[00:00.03] [00:00.03] Biggest set found 'myset' has 50002 members
...
# 处理大key:分片、压缩或更换数据结构。
# 删除大key用UNLINK(异步非阻塞)代替DEL
redis-cli UNLINK myset
# 返回(integer) 1,后台线程回收内存
五、避坑指南
- 坑1:管道内存爆炸。
一次性向管道写入数百万条命令,客户端内存先爆(因为要缓存所有响应)。
解决:使用流式管道,分批发送(例如每1000条一批)。或用redis-cli --pipe的默认分批机制,但注意max-output-buffer配置。 - 坑2:Lua脚本中不要用随机命令。
如SPOP、RANDOMKEY、SRANDMEMBER,因为主从复制时,脚本在从库重放会得到不同结果,导致数据不一致。严格来说,Lua脚本必须具有确定性。 - 坑3:事务隔离性误解。
WATCH+MULTI只能保证CAS乐观锁,但事务内的命令不会提前执行,其他客户端仍可修改中间key。不要用它做性能优化,应使用管道或Lua。 - 坑4:
--pipe模式遇到错误不会停止。
如果某条命令语法错误,redis-cli --pipe会继续发送,最后报告错误数。但你很难定位是哪条命令出错。因此生成命令流时必须严格校验。
六、总结与行动清单
快速排查线上Redis性能问题时,按以下顺序检查:
SLOWLOG GET 50查看延迟命令redis-cli --bigkeys找大keyINFO COMMANDSTATS看命令调用频率- 检查连接数:
INFO CLIENTS - 内存碎片:
INFO MEMORY | grep frag
优化时牢记:批量操作用管道或Lua;删除用SCAN+UNLINK;慢查询阈值设为200微秒。