Redis 连接被拒与连接池打满:两次线上告警的排查

connection refused 的五种可能(bind、protected-mode、maxclients、maxmemory、实例重启)与连接池打满的排查(connected_clients、blocked_clients、SLOWLOG、大 key)。

2026 年 5 月,我在同一个星期里被 Redis 告警了两次,但两次是两件完全不同的事。第一次是新服务上线后立刻报连不上:

dial tcp 10.0.0.12:6379: connect: connection refused

第二次是一周后,接口 p99 从 30ms 涨到 3s,日志里刷 redis: connection pool timeout,Goroutine 数一路上涨不回落,但 Redis 本身健康得很——PING 一秒回,CPU 不高。

第一次是网络与进程层的问题(谁在听、听在哪个地址上),第二次是客户端与命令层的问题(连接被谁占住、池子多小)。混在一起查会浪费很多时间,所以这篇按两条线分开记,最后给一张"现象 → 一条命令 → 看哪个字段"的对照表。

一、连不上:先分清是哪一种"连不上"

connection refused 的字面含义其实很窄:TCP 握手被明确拒绝,说明目标地址端口上没有进程在 listen,或者中间有防火墙在 REJECT。如果你看到的是别的报错,方向完全不同——所以在动手之前,先把错误消息看清楚:

应用看到的报错它真正的含义该往哪查
connect: connection refused没有进程在听,或被防火墙拒绝进程、监听地址、容器网络
DENIED Redis is running in protected mode ...Redis 在听,但拒绝非本机来源且没设密码bind / protected-mode / requirepass
ERR max number of clients reached在听,但连接数到顶maxclients、连接泄漏
OOM command not allowed when used memory > 'maxmemory'连接正常,写入被拒maxmemory、淘汰策略
MISCONF Redis is configured to save RDB snapshots ...连接正常,持久化失败后禁写磁盘、目录权限、BGSAVE
偶发 read: connection reset by peer / i/o timeout空闲连接被中间设备回收空闲超时、NAT、tcp-keepalive

我的这条 connection refused 属于第一类。第一条命令不是去应用机器上重新加载配置,而是到 Redis 那台机器上看端口到底听在哪儿:

ss -lntp | grep 6379
# LISTEN 0 511 127.0.0.1:6379 0.0.0.0:* users:(("redis-server",pid=1,fd=6))   ← 只听了回环
# LISTEN 0 511 *:6379         0.0.0.0:* users:(("redis-server",pid=1,fd=6))   ← 监听所有地址

输出里冒号前面的那一段就是全部答案。127.0.0.1:6379 表示只有本机能连,任何容器、任何别的机器都不可能连上,无论密码配得对不对。我这次是 compose 里写了 ports: - "127.0.0.1:6379:6379"——本机调试用得很舒服,但同一个 compose 网络里的应用容器走的是容器 IP,压根碰不到这个映射。

顺着这条线再确认三条:在应用所在的机器或容器里执行 redis-cli -h 10.0.0.12 -p 6379 PING(只在本机跑没有意义);在服务端用 redis-cli CONFIG GET bind protected-mode maxclients timeout tcp-keepalive 看清配置事实;容器场景再补一条 docker ps --format '{{.Names}}\t{{.Ports}}',确认端口映射确实只映到了回环。

Redis 7 的配置文件里 bind 127.0.0.1 -::1、protected-mode yes 是默认值,是在"本机自用"这个前提下最安全的组合;一旦要跨机器访问,三件事必须同时满足:bind 加上真实网卡地址(或 0.0.0.0)、网络/防火墙放行、以及设密码(开放监听又不设密码是把自己挂到公网上)。容器场景还有一个常见版本:Redis 容器和 app 容器不在同一个 docker network 里,宿主机能连、容器里 refused——用服务名 redis:6379 而不是宿主机 IP 就是从根源上避开这个问题。

maxclients 到顶也是一种"连不上",但报错消息不同(见上表),排查看这两个字段:

redis-cli INFO clients | grep -E 'connected_clients|maxclients'
redis-cli INFO stats   | grep -E 'rejected_connections|total_connections_received'

rejected_connections 只要不为 0,就说明历史上真的撞过上限,值得当成一个正式告警项。这里有个容易忽略的坑:Redis 启动时会按 ulimit -n 反推 maxclients,文件描述符不够就在日志里悄悄降级——You requested maxclients of 10000 requiring at least 10032 max file descriptors... maxclients has been reduced to 4064。systemd 托管的实例要在 unit 里显式写 LimitNOFILE=65535,容器要加 --ulimit nofile=65535:65535,否则你以为的上限和实际生效的上限不是同一个数。

maxmemory 满了导致写入被拒最容易被误判成"连接问题":应用把缓存写失败包成通用错误继续往下走,表现就是"Redis 偶尔抽风",但连接一直是好的。

redis-cli INFO memory | grep -E 'used_memory_human|maxmemory_human|maxmemory_policy|evicted_keys'

used_memory 顶到 maxmemory、策略是 noeviction 时,所有写入直接报 OOM。Redis 7 还多了一个 maxmemory-clients(默认 5%),用来限制所有客户端输出缓冲的总占用——它的意义在于:大 key 的输出加上读不动的慢客户端,能把内存吃满并触发客户端被断开,这条线索正好接上第二次告警。

实例重启是最安静的一种可能:应用侧表现为一段时间的 refused,然后自动恢复,于是没人报警。一条 redis-cli INFO server | grep uptime_in_seconds 就能确认——数值远小于预期就是重启过。接着查原因:journalctl -u redis-server、docker inspect --format '{{.State.OOMKilled}}' <容器>、dmesg -T | grep -i 'killed process'。在我遇到过的例子里,Redis 是被内核 OOM Killer 干掉的——它自己的 maxmemory 没配,于是内存一路涨到把整台机器拖下水,系统挑了个最大的进程下手。

二、池子打满:从服务端视角先看清客户端

第二次告警换了完全不同的入口。应用侧是"慢"和"池超时",但这类问题必须先去服务端看客户端长什么样:

$ redis-cli INFO clients
connected_clients:96
cluster_connections:0
maxclients:10000
client_recent_max_input_buffer:20480
client_recent_max_output_buffer:1052672
blocked_clients:12
tracking_clients:0
clients_in_timeout_table:4
字段说明异常信号
connected_clients当前客户端连接数接近 maxclients,或者正好等于"所有实例 pool size 之和"——说明池全被占满
blocked_clients正阻塞在 BLPOP/BRPOP/XREAD BLOCK 这类命令上长时间大于 0,说明连接被阻塞命令占着不放
client_recent_max_output_buffer单个客户端输出缓冲的峰值数值很大,说明有大 key 输出或者有读不动的客户端
clients_in_timeout_table被 timeout 配置纳入超时管理的连接配合服务端 timeout 一起看
maxclients连接数上限和 connected_clients 放在一起才有意义

我这次 blocked_clients 是 12,一直不归零——这就是入口。再看是谁占着连接:

redis-cli CLIENT LIST | head
# id=812 addr=10.0.0.31:52140 laddr=10.0.0.12:6379 fd=19 name=order-worker age=412 idle=0 cmd=brpop

name 这一栏最有用,前提是应用在建连时调了 CLIENT SETNAME。我们给每个服务都设了名字,于是 CLIENT LIST 一眼看出是哪个服务、哪个实例的连接被 brpop 拖着——没有这一步,面对几百行输出只能靠猜。接着找慢命令和阻塞源:

redis-cli SLOWLOG GET 20                    # 每条含时间戳、耗时(微秒)、命令参数、来源地址
redis-cli CONFIG GET slowlog-log-slower-than slowlog-max-len
redis-cli INFO commandstats                 # 每个命令的 calls 与 usec_per_call
redis-cli LATENCY DOCTOR                    # 有没有记录到延迟事件
redis-cli --latency -h 10.0.0.12            # 从客户端侧量 RTT,区分网络抖动与命令慢
redis-cli --bigkeys                         # 内部走 SCAN,反复执行安全
redis-cli MEMORY USAGE user:profile:8812 SAMPLES 0

slowlog-log-slower-than 的单位是微秒,默认 10000(10ms),我们把它临时调到 5000 才把那条罪魁抓出来。INFO commandstats 里的 usec_per_call 更适合找"系统性慢"——SLOWLOG 只记个案,而 usec_per_call 告诉你哪个命令平均下来就不便宜。

然后就是那几个老朋友,它们在"池子打满"里通常不是原因,而是让问题暴露得更快的东西:

  • KEYS *:O(N) 且全程阻塞单线程,生产上执行一次就能让所有请求超时。要用 SCAN 游标翻。
  • SMEMBERS / HGETALL / LRANGE key 0 -1:几万元素的集合上性质和 KEYS 一样,一次做完期间所有命令排队。改成 SSCAN / HSCAN / 分批 LRANGE。
  • DEL 大 key 是同步的:删一个几百万元素的集合会阻塞几十毫秒到几秒,用 UNLINK 交给后台线程回收。
  • 拿了连接却在等别的 IO:这是"池打满"最常见的代码层原因。连接池 checkout 之后去调 HTTP、去等一个 5 秒的 BLPOP、或者忘记 defer Close,池子再大也不够用。反过来,池子小加上阻塞命令会直接僵死:所有连接都在 brpop 上等,新请求永远拿不到连接。

三、连接池参数怎么定

Go 服务用的是 go-redis v9,这几个参数我现在有一份固定取值和理由:

参数含义我的取值与理由
PoolSize单实例最大连接数QPS × p99 命令耗时 × 1.5(Little's law 的粗算),不是越大越好
MinIdleConns保活的最小空闲连接取 PoolSize 的 1/4,避免突发流量时集中建连
ConnMaxIdleTime空闲多久后关闭连接30m,且必须小于中间设备的空闲回收时间
ConnMaxLifetime连接最长寿命30m ~ 1h,加随机抖动,避免所有连接同时重建
PoolTimeout从池里拿连接的最长等待1 ~ 2s,必须小于上游接口超时
DialTimeout建连超时500ms
ReadTimeout / WriteTimeout单次读写超时1s,按 p99 命令耗时的 3~5 倍定,不是拍脑袋写 3s

三条纪律比具体数字重要:

  1. 总量要算总账:实例数 × PoolSize + 运维预留 < maxclients。20 个实例 × 32 是 640,看起来离 10000 很远,但如果 Redis 前面还有代理、或者几个服务共用同一个实例、或者有连接泄漏,账很快就不是这么算了。
  2. 空闲连接被中间设备回收是最难查的一类:表现为偶发 read: connection reset by peer、i/o timeout,重启应用就好,过一阵又来。对策是把 ConnMaxIdleTime 设得比 NAT / 负载均衡的空闲超时小(常见 350s、900s 这种量级),服务端配 tcp-keepalive 300 帮忙探活,服务端 timeout 不要用默认的 0(永不关闭空闲连接)。
  3. 超时要成链:上游请求的超时(ctx deadline)必须大于 PoolTimeout + ReadTimeout。否则出现的是最难解释的现象——上游早就返回超时了,那些 Goroutine 还堵在池里等连接,越积越多,最后是整个服务雪崩。

健康检查这件事我的做法是"轻量":不在每次借连接时 PING(RTT 直接翻倍),只在启动时 PING 一次做 fail-fast;运行期靠 DialTimeout + ReadTimeout 兜底。另外,CLIENT SETNAME 是投入产出比最高的一件事,它让 CLIENT LIST 从一屏天书变成可读的清单。

四、把两次告警压成一张表

现象第一条命令输出里盯住哪个字段
connection refusedss -lntp | grep 6379监听地址是 127.0.0.1 还是 *
protected mode 拒绝CONFIG GET bind protected-mode requirepassbind 是否回环、有没有密码
max number of clients reachedINFO clients + INFO statsconnected_clients vs maxclients;rejected_connections
写入报 OOMINFO memoryused_memory vs maxmemory、maxmemory_policy、evicted_keys
MISCONF ... unable to persistINFO persistencerdb_last_bgsave_status、aof_last_write_status
实例莫名重启INFO serveruptime_in_seconds 是否远小于预期
请求变慢、池超时INFO clientsblocked_clients、client_recent_max_output_buffer
谁占着连接CLIENT LISTname、cmd、idle、age
哪条命令慢SLOWLOG GET 20 / INFO commandstats耗时(微秒)、usec_per_call
有没有大 key--bigkeys / MEMORY USAGE类型与元素数量

如果重来一次,我会在两次告警之前就做掉三件小事:上线前把 ss -lntp 的输出贴进部署记录(包括 bind 地址和端口映射),所有 Redis 客户端强制 CLIENT SETNAME 并把 PoolSize 写进配置中心(不是散在各个服务的代码里),以及把 rejected_connections、blocked_clients、evicted_keys 这三个数拉进监控——它们分别对应"连不上"、"被占住"、"内存不够"这三类事故,而且都是 Redis 已经算好、只是没人看的事实。第一次告警我只花了几分钟,第二次花了一晚上,差别就在于第二次的现场全靠事后猜。

返回博客列表