故障排查 · 2026 年 11 月 11 日

Redis 连接池耗尽:一次连接泄漏的完整复盘

接口 P99 涨到秒级,日志出现 ConnectionError。Redis 很闲,客户端却借不到连接。从连接数曲线到异常路径的 finally,讲清泄漏怎么找、怎么修。

流量没涨、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。

定位:先看服务端,再看应用侧#

bash
# 客户端连接数与阻塞数
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 上的线程。数量等于池上限,基本就是池饥饿。

用最小代码复现泄漏,比读业务代码快:

python
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 对一遍,所有实例的池上限之和不要超过它的七成。

修复:统一入口,参数兜底#

先保证「借了一定还」,再谈池多大。

python
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 保证归还:

python
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 单线程的处理能力,只是把排队从客户端搬到服务端,延迟反而更高。

完

#Redis#排障#连接池
回到顶部 ↑