第 33 章

RESP 协议、Pipeline 与连接池调优

第33章 RESP 协议、Pipeline 与连接池调优

深入理解 Redis 的通信协议是优化客户端性能的基础。RESP(Redis Serialization Protocol)设计极为精简,解析开销极低。本章从协议格式入手,剖析 Pipeline 的网络层原理,再到连接池各参数对吞吐量与延迟的实际影响,给出可量化的调优方法论。


33.1 RESP2 协议格式

RESP 以 \r\n 作为行分隔符,第一个字节标识类型,设计目标是"人类可读 + 机器易解析"。

33.1.1 五种基本数据类型

Simple String(简单字符串) — 前缀 +

+OK\r\n
+PONG\r\n

用于无需二进制安全的短字符串响应(如命令执行成功的确认)。

Error(错误) — 前缀 -

-ERR unknown command 'SETX'\r\n
-WRONGTYPE Operation against a key holding the wrong kind of value\r\n
-MOVED 3999 127.0.0.1:6381\r\n

错误消息首个单词约定为错误类型(ERR、WRONGTYPE、MOVED、ASK、NOSCRIPT 等),客户端可据此做精细处理。

Integer(整数) — 前缀 :

:1000\r\n
:0\r\n
:-1\r\n

用于计数命令(INCR、LLEN、SCARD)和布尔响应(SETNX 返回 0/1)。

Bulk String(批量字符串) — 前缀 $

$5\r\nhello\r\n      ← 5字节的"hello"
$0\r\n\r\n           ← 空字符串
$-1\r\n              ← nil(key不存在)

二进制安全,长度精确声明,可以存储任意字节序列(包含 \r\n)。

Array(数组) — 前缀 *

*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n
← 等价于 SET key value 命令

*-1\r\n              ← nil array(某些命令的空响应)
*0\r\n               ← 空数组

33.1.2 完整命令交互示例

客户端发送 SET name Alice

*3\r\n
$3\r\n
SET\r\n
$4\r\n
name\r\n
$5\r\n
Alice\r\n

服务端回复:

+OK\r\n

客户端发送 HGETALL user:1001(多字段 Hash):

*2\r\n$7\r\nHGETALL\r\n$9\r\nuser:1001\r\n

服务端回复(交替返回 field/value):

*4\r\n
$4\r\nname\r\n
$5\r\nAlice\r\n
$3\r\nage\r\n
$2\r\n25\r\n

33.2 RESP3:Redis 6+ 的扩展协议

Redis 6.0 引入 RESP3,需要客户端在连接时发送 HELLO 3 命令切换。RESP3 保持向后兼容,核心改进是增加了更丰富的类型,减少了客户端解析的歧义。

33.2.1 新增类型

Map(字典) — 前缀 %(替代交替 field/value 的 Array):

%2\r\n
$4\r\nname\r\n$5\r\nAlice\r\n
$3\r\nage\r\n$2\r\n25\r\n

HGETALL 在 RESP3 下直接返回 Map,客户端无需手动做奇偶下标配对。

Set(集合) — 前缀 ~

~3\r\n$3\r\nfoo\r\n$3\r\nbar\r\n$3\r\nbaz\r\n

SMEMBERS 返回 Set 类型,客户端可直接构造 Set/HashSet。

Double(浮点数) — 前缀 ,

,3.14159\r\n
,inf\r\n
,-inf\r\n

Boolean — 前缀 #

#t\r\n    ← true
#f\r\n    ← false

Blob Error — 前缀 !(支持二进制安全的错误消息):

!21\r\nSYNTAX invalid syntax\r\n

Push(服务端主动推送) — 前缀 >

>3\r\n
$7\r\nmessage\r\n
$4\r\nnews\r\n
$12\r\nHello World!\r\n

用于 Pub/Sub 消息、客户端缓存失效通知(Client-side caching)等服务端主动推送场景,客户端无需轮询。

33.2.2 切换到 RESP3

# redis-py 自动使用 RESP3(若服务端支持)
r = redis.Redis(protocol=3)

# 手动验证
info = r.execute_command('HELLO')
print(info['proto'])  # 3
// ioredis 暂无原生 RESP3,可手动发送 HELLO
const result = await redis.call('HELLO', '3');

33.3 inline 命令格式

Redis 还支持简单的 inline 命令格式(无 RESP 前缀),可直接用 telnet/netcat 输入:

$ telnet localhost 6379
> PING
+PONG

> SET key value
+OK

> GET key
$5
value

inline 格式不支持二进制安全,不适合生产环境,但对调试和协议学习非常有价值。


33.4 Pipeline 深度剖析

33.4.1 网络往返模型

无 Pipeline 时,每个命令需要完整的"发送→等待→接收"循环:

Client                    Server
  |--- SET key1 val1 --->|
  |<-------- OK ----------|   RTT1
  |--- SET key2 val2 --->|
  |<-------- OK ----------|   RTT2
  ...
  总时间 = N × RTT

Pipeline 将 N 个命令合并为一次 TCP Write,服务端逐条执行后批量回复:

Client                    Server
  |--- [cmd1,cmd2,...cmdN] --->|
  |                            | 逐条执行
  |<--- [r1,r2,...,rN] --------|   1 × RTT + 执行时间
  总时间 ≈ RTT + N × 命令平均耗时

量化对比(跨机房 RTT=5ms,命令执行时间=0.1ms):

方式 100个SET 总耗时
无 Pipeline 100 × 5.1ms = 510ms
Pipeline(100个一批) 5ms + 100×0.1ms = 15ms
Pipeline(10个一批 × 10次) 10×5ms + 100×0.1ms = 60ms

33.4.2 Pipeline 与 MULTI/EXEC 对比

维度 Pipeline MULTI/EXEC
网络往返 1次(全部命令) 2次(发命令 + EXEC)
原子性 无(命令可被插入) 有(原子执行)
命令错误处理 各命令独立返回错误 EXEC 时整体返回,运行时错误不回滚
服务端内存 发完即执行,无排队 命令排队在内存中(QUEUED)
条件逻辑 不支持(固定命令集) 不支持(需配合 WATCH 实现 CAS)
适用场景 批量写入、批量读取 需要原子操作的多命令事务

33.4.3 最优批次大小

批次过小:RTT 节省效果不明显。批次过大有三个隐患:

  1. 服务端输出缓冲区压力:Redis 将所有命令的结果暂存在输出缓冲区,直到 TCP ACK 后才清除。client-output-buffer-limit normal 256mb 64mb 60 是默认限制,超限会强制断开客户端。
  2. 客户端内存压力:需要在内存中保存整批响应,解析时间线性增加。
  3. 响应时间(P99 延迟)升高:Pipeline 是阻塞式的,批次越大,等待时间越长。

实验数据(本地回环,Intel Xeon 3GHz):

批次大小 QPS(SET) P99 延迟
1(无 Pipeline) 90,000 0.8ms
10 450,000 0.2ms
100 980,000 0.5ms
1000 1,200,000 3.2ms
10000 1,250,000 28ms

结论:100–500 个命令/批 是大多数场景的最佳平衡点。

33.4.4 Cluster 下的 Pipeline

Cluster 将 16384 个 slot 分散到多个节点,Pipeline 中不同 key 可能落在不同节点。

解决方案1:Hash Tag(强制同 slot):

# {user:1001} 是 hash tag,决定 slot 分配
r.set('{user:1001}:profile', 'data')
r.set('{user:1001}:sessions', 'data')
# 两个 key 保证在同一 slot,可在 Pipeline 中混用

解决方案2:客户端按节点分组(大多数高级客户端自动处理):

// go-redis ClusterClient 的 Pipelined 自动分组
_, err := clusterClient.Pipelined(ctx, func(pipe redis.Pipeliner) error {
    for i := 0; i < 1000; i++ {
        pipe.Set(ctx, fmt.Sprintf("key:%d", i), "val", time.Hour)
    }
    return nil
})
// 内部:按 slot 分组后,向各节点并行发送各自的 Pipeline

33.5 连接池调优

33.5.1 连接池参数全景

参数 含义 调优方向
maxTotal / PoolSize 最大连接数(含借出+空闲) 根据峰值 QPS 和命令延迟计算
maxIdle 最大空闲连接数 低峰期保留,避免重建连接
minIdle 最小空闲连接数 提前预热,避免突发时等待
maxWait 获取连接的等待超时 与业务 SLA 对齐
connectTimeout TCP 握手超时 一般 2–5s
socketTimeout 命令读写超时 一般 1–3s
testOnBorrow 借用前 PING 检测 长连接场景开启
testWhileIdle 后台定期检测空闲连接 推荐开启,减少 testOnBorrow 开销
evictionInterval 后台检测间隔 30s 为常见值

33.5.2 maxTotal 计算公式

理论最优连接数 = 峰值 QPS × 平均命令延迟(ms) / 1000 × 安全系数

峰值 QPS = 100,000 req/s
平均命令延迟 = 1ms(本机)
安全系数 = 2(应对波动)

最优连接数 = 100,000 × 0.001 × 2 = 200

跨机房延迟高(10ms),则相同 QPS 需要更多连接:

100,000 × 0.01 × 2 = 2000

这揭示了一个重要事实:降低命令延迟(使用 Pipeline、优化慢查询)比单纯增加连接数更有效

33.5.3 连接数与 Redis 内存消耗

Redis 服务端每个客户端连接消耗约 20–100KB 内存(取决于输入/输出缓冲区分配):

INFO memory
used_memory_human: 1.5G
used_memory_overhead: 200MB  ← 包含客户端缓冲区

INFO clients
connected_clients: 500
client_longest_output_list: 0
client_biggest_input_buf: 0

连接数过多的另一个问题:Redis 使用单线程事件循环处理连接事件,连接数过多会增加 epoll 轮询开销,影响命令处理延迟。生产上单个 Redis 实例的连接数建议不超过 5000,高并发场景应考虑连接代理(Twemproxy、KeyDB 或 Redis Cluster 分片)。

33.5.4 连接泄漏排查

症状connected_clients 持续升高,业务报 获取连接超时,但应用层看起来没有异常。

排查步骤

# 1. 确认连接数异常
redis-cli -h localhost INFO clients
# connected_clients: 3000(超过预期)

# 2. 查看每个客户端的状态
redis-cli -h localhost CLIENT LIST
# id=1234 addr=10.0.0.5:52341 fd=8 name= age=3600 idle=3600 flags=N db=0 cmd=NULL
# age=3600 idle=3600 意味着这个连接1小时未活动

# 3. 杀死僵尸连接
redis-cli -h localhost CLIENT KILL ID 1234

# 4. 批量杀死超过30分钟空闲的连接
redis-cli CLIENT LIST | awk -F' ' '{for(i=1;i<=NF;i++){if($i~/^idle=/){split($i,a,"=");if(a[2]>1800)print $0}}}' \
  | grep -oP 'id=\K[0-9]+' | xargs -I{} redis-cli CLIENT KILL ID {}

根本解决:确保所有客户端使用 try-with-resources 或等价模式;在连接池上设置 removeAbandonedTimeoutlogAbandoned=true(Commons Pool2)以自动回收泄漏连接并打印调用栈。

33.5.5 testOnBorrow vs testWhileIdle

testOnBorrow:每次借用前发送一次 PING,确保连接存活。

testWhileIdle:后台 eviction 线程定期(每 timeBetweenEvictionRunsMillis)对空闲连接进行 PING 检测,不可用则销毁。

推荐配置(生产):

config.setTestOnBorrow(false);         // 不在借用时检测(减少延迟)
config.setTestWhileIdle(true);         // 后台检测
config.setTimeBetweenEvictionRuns(Duration.ofSeconds(30));
config.setMinEvictableIdleTime(Duration.ofMinutes(2)); // 空闲2分钟销毁
config.setNumTestsPerEvictionRun(5);   // 每次检测5个连接

同时在业务侧做重试逻辑,捕获 JedisConnectionException 时重新获取连接执行一次。


33.6 网络层优化补充

33.6.1 TCP Nagle 算法

Redis 默认禁用 Nagle 算法(tcp-backlog 511 + tcp-keepalive 300)。Nagle 算法会将多个小数据包合并后再发送,与 Redis 的即时响应需求相悖。客户端侧也应设置 TCP_NODELAY(大多数客户端默认已设置)。

33.6.2 Unix Socket vs TCP

同机器上运行的 Redis 可以使用 Unix Domain Socket 通信,绕过 TCP 协议栈:

# redis.conf
unixsocket /var/run/redis/redis.sock
unixsocketperm 770
r = redis.Redis(unix_socket_path='/var/run/redis/redis.sock')

Unix Socket 延迟通常比 localhost TCP 低 30–50%(本地回环约 0.05ms vs 0.03ms),适用于 Redis 与应用部署在同机器的场景。

33.6.3 连接池预热

在服务启动阶段主动初始化 minIdle 个连接,避免流量突发时大量并发建连:

// Spring Boot ApplicationReadyEvent
@EventListener(ApplicationReadyEvent.class)
public void warmUpPool() {
    List<Jedis> connections = new ArrayList<>();
    try {
        for (int i = 0; i < minIdle; i++) {
            connections.add(pool.getResource());
        }
    } finally {
        connections.forEach(Jedis::close);
    }
}
// Go: 预热连接
func warmUp(rdb *redis.Client, count int) {
    var wg sync.WaitGroup
    for i := 0; i < count; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            rdb.Ping(context.Background())
        }()
    }
    wg.Wait()
}
本章评分
4.7  / 5  (3 评分)

💬 留言讨论