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 |
三条纪律比具体数字重要:
- 总量要算总账:
实例数 × PoolSize + 运维预留 < maxclients。20 个实例 × 32 是 640,看起来离 10000 很远,但如果 Redis 前面还有代理、或者几个服务共用同一个实例、或者有连接泄漏,账很快就不是这么算了。 - 空闲连接被中间设备回收是最难查的一类:表现为偶发
read: connection reset by peer、i/o timeout,重启应用就好,过一阵又来。对策是把ConnMaxIdleTime设得比 NAT / 负载均衡的空闲超时小(常见 350s、900s 这种量级),服务端配tcp-keepalive 300帮忙探活,服务端timeout不要用默认的0(永不关闭空闲连接)。 - 超时要成链:上游请求的超时(ctx deadline)必须大于
PoolTimeout + ReadTimeout。否则出现的是最难解释的现象——上游早就返回超时了,那些 Goroutine 还堵在池里等连接,越积越多,最后是整个服务雪崩。
健康检查这件事我的做法是"轻量":不在每次借连接时 PING(RTT 直接翻倍),只在启动时 PING 一次做 fail-fast;运行期靠 DialTimeout + ReadTimeout 兜底。另外,CLIENT SETNAME 是投入产出比最高的一件事,它让 CLIENT LIST 从一屏天书变成可读的清单。
四、把两次告警压成一张表
| 现象 | 第一条命令 | 输出里盯住哪个字段 |
|---|---|---|
connection refused | ss -lntp | grep 6379 | 监听地址是 127.0.0.1 还是 * |
| protected mode 拒绝 | CONFIG GET bind protected-mode requirepass | bind 是否回环、有没有密码 |
max number of clients reached | INFO clients + INFO stats | connected_clients vs maxclients;rejected_connections |
| 写入报 OOM | INFO memory | used_memory vs maxmemory、maxmemory_policy、evicted_keys |
MISCONF ... unable to persist | INFO persistence | rdb_last_bgsave_status、aof_last_write_status |
| 实例莫名重启 | INFO server | uptime_in_seconds 是否远小于预期 |
| 请求变慢、池超时 | INFO clients | blocked_clients、client_recent_max_output_buffer |
| 谁占着连接 | CLIENT LIST | name、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 已经算好、只是没人看的事实。第一次告警我只花了几分钟,第二次花了一晚上,差别就在于第二次的现场全靠事后猜。