流量没涨、Redis 的 CPU 和内存都很平稳,接口延迟却从几十毫秒涨到两秒多,日志里开始刷 Could not get a resource from the pool。重启应用立刻恢复,几个小时后复发。
这种形态基本可以排除 Redis 本身:服务端很闲,是客户端借不到连接。要么池太小,要么借出去没还。
现象:三种表现指向同一处#
| 现象 | 含义 |
|---|---|
Too many connections 或池等待超时 |
池里没有可用连接,borrow 失败 |
connected_clients 阶梯上涨、不回落 |
连接只增不减,先怀疑泄漏 |
blocked_clients 持续大于 0 |
有阻塞命令(BLPOP 等)长期占用连接 |
Redis 报 ERR max number of clients reached |
服务端 maxclients 被打满,新连接全拒 |
应用线程堆栈停在 borrowObject |
大量线程在等连接,典型的池饥饿 |
前两条最关键。connected_clients 是判断泄漏最直接的指标:正常应该跟着流量波动,泄漏则是一条只会往上爬的台阶。
还有一种没有报错的表现:延迟整体抬升、QPS 下降,日志干净。这往往是池已满、线程在排队,但等待超时设得足够长,所以还没抛异常。等到开始报错,说明排队已经严重到触及超时了。
池耗尽还常和线程池放大叠加:请求线程各自等连接,线程池被占满,健康检查、心跳这类本不需要 Redis 的请求也排不上队,服务看起来整体卡死。这时先看线程池活跃数,别只盯 Redis。
定位:先看服务端,再看应用侧#
# 客户端连接数与阻塞数
redis-cli info clients
# 拒绝连接统计:rejected_connections 增长说明服务端上限被打到
redis-cli info stats | grep -E 'rejected|total_connections'
# 每条连接的 addr / age / idle / cmd,重点找 idle 很大的
redis-cli client list | grep -oE 'addr=[^ ]+|age=[0-9]+|idle=[0-9]+|cmd=[^ ]+' \
| paste -d' ' - - - - | head -20
# 应用侧粗看:ESTAB 数量应接近 实例数 × 池上限
ss -tn state established '( dport = :6379 )' | wc -l
client list 里重点看三列:age(连接存活秒数)、idle(空闲秒数)、cmd(最后一次命令)。大量 idle 数百秒、cmd 停在 client 或 ping 的连接,通常就是借出后没有归还的。如果这些连接的 addr 集中在同一小段端口范围,说明它们来自同一个池,而不是散落的客户端。
应用侧要看的还有连接池的 active、idle、等待线程数。没有这些指标时,用上面的 ESTAB 数量粗看:明显大于「实例数 × 池上限」,说明有代码绕过了池,直接建连。
线程卡在哪也要看:Java 用 jstack,Python 用 py-spy dump,找停在 borrowObject、get_connection、acquire 上的线程。数量等于池上限,基本就是池饥饿。
用最小代码复现泄漏,比读业务代码快:
import redis
pool = redis.ConnectionPool(host="127.0.0.1", port=6379,
max_connections=4, socket_timeout=1)
for i in range(10):
conn = pool.get_connection("PING") # 手动借出
try:
conn.send_command("SET", f"k{i}", "v")
conn.read_response()
if i == 2:
raise RuntimeError("业务异常") # 异常路径提前跳出
finally:
pass # 忘了 pool.release(conn)
# 第 5 次开始抛 ConnectionError: Too many connections
把 pass 换成 pool.release(conn),或者干脆用客户端封装,问题立刻消失。
根因:五条泄漏路径#
借还不成对。 手动 get_connection() 之后,正常路径归还了,异常路径、提前 return、continue 都漏掉了。这是最常见的一条。
每个请求新建客户端。 在函数里 redis.Redis(host=...),每次调用都建一个连接池和一条连接,请求结束也不关。connected_clients 会跟 QPS 成正比,涨得飞快。
阻塞命令独占连接。 BLPOP、BRPOP、SUBSCRIBE 会一直占着连接不释放。如果它们从共享池里借连接,几个消费者就能把池吃光。
池上限小于真实并发。 池大小拍脑袋定成 10,实际并发 200,其余线程全在排队。加上等待超时设为无限,请求就堆在那里,表现为整体延迟上升而不是明确报错。
连接没有超时或健康检查。 网络设备静默断开空闲连接,池里留着死连接,借出来第一次用就失败,重试又去借同样的死连接。
还有一类隐蔽的:事务或管道开了没结束。MULTI 之后异常退出,连接上残留着未完成的事务状态,归还到池里继续被下一个请求借走,报错五花八门。这类问题在池里看不出来,只能靠统一封装避免。
池大小可以按这个公式估:并发 ≈ QPS × 平均持有时间(秒) × 1.5。算完再和 Redis 的 maxclients 对一遍,所有实例的池上限之和不要超过它的七成。
修复:统一入口,参数兜底#
先保证「借了一定还」,再谈池多大。
import redis
# 全进程一个池,参数按并发估算
pool = redis.ConnectionPool(
host="10.0.0.5", port=6379,
max_connections=64, # 上限要小于 maxclients / 实例数
socket_timeout=0.5, # 单次命令超时,避免线程被拖死
socket_connect_timeout=0.5,
health_check_interval=30, # 定期 PING,剔除死连接
retry_on_timeout=False, # 超时不要盲目重试,先确认幂等
)
client = redis.Redis(connection_pool=pool)
# 业务代码只用 client,由它负责归还
client.set("k", "v", ex=60)
确有需要手动借出时,用 try/finally 保证归还:
conn = pool.get_connection("PING")
try:
conn.send_command("PING")
conn.read_response()
finally:
pool.release(conn) # 异常路径同样会执行
再配三条约束:阻塞命令使用独立的小池,不占业务池;池的等待超时设成有限值(比如 200ms),借不到就快速失败并降级;服务端设置 timeout,主动踢掉长时间空闲的连接。
降级要提前设计。池满时返回缓存或默认值,比让请求挂两秒再失败体验更好,也能避免雪崩。关键是降级开关要有上限,别让所有请求都走降级。
Java 侧同理,Jedis 的 maxTotal、maxWaitMillis 与 testOnBorrow 要一起配;Lettuce 默认共享连接,模型不同,别把 Jedis 的经验直接套过去。
防复发#
- 埋点四项:池 active、idle、等待线程数、borrow 耗时。饱和率超过 80% 持续五分钟就告警。
- 对
connected_clients做基线告警,偏离基线 30% 就查,不要等报错。 - 定期采样
CLIENT LIST存档,出问题时能回溯是哪类连接在涨。 - 代码规范写死:业务代码只能通过统一封装的 client 访问 Redis,禁止裸调
get_connection。 - 压测时注入异常,专门验证 finally 分支和降级逻辑,而不是只跑正常路径。
- 把连接池上限写进部署配置的校验项:实例数 × 上限 <
maxclients× 0.7,启动时检查一次。
结论#
连接池耗尽的表象是「借不到连接」,根因几乎都在「借了没还」或「根本没用池」。
定位顺序:先 INFO clients 看总量趋势,再 CLIENT LIST 看 idle 与 cmd,最后回到应用侧核对池指标和借还代码。
修复三件事:统一走客户端封装、异常路径用 try/finally 归还、池大小按 QPS × 持有时间 估算并设有限等待。最后加一条 connected_clients 的基线告警,泄漏就不会拖到用户感知才被发现。
还有一个反向教训:重启应用只是把泄漏的计时器归零。如果重启后曲线依旧阶梯上涨,问题没修,只是被延后了。
最后一条成本提醒:池上限不是越大越好。连接数远超 Redis 单线程的处理能力,只是把排队从客户端搬到服务端,延迟反而更高。
完