做运维这几年,最怕的不是故障本身,而是故障现象摆在你面前、所有常规手段都指向“没问题”的时刻。前阵子我就栽在一个Redis连接失败的问题上,从第一条报错到定位根因,前后花了整整三天。这三天里,我换过连接池参数、调过内核keepalive、查过防火墙会话表,甚至一度怀疑是客户端SDK的Bug,最后发现“元凶”居然是一个被启动参数覆盖掉的配置项。写这篇文章,是想把这三天踩过的坑、验证过的思路和最终的配置检查方法完整分享出来。尤其是那些遇到过“服务重启后正常、过一小时又连不上”这类诡异现象的同学,这篇文章的排查路径可以直接套用。
1. 故障现场:应用报错“连接被重置”,Redis 却活得好好的
1.1 错误日志里的三类典型异常
这次出问题的是三台应用服务器加一套 Redis 主从,客户端用的是 Jedis。最开始只有凌晨的任务报错,后来白天业务高峰期也开始出现。报错消息大致有三类:
redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pooljava.net.SocketException: Connection resetStackExchange.Redis.RedisConnectionException: No connection is available to service this operation
当时第一反应是网络问题,因为从监控面板看,Redis 进程的 CPU、内存都正常,主从也没有发生切换,慢查询数量也没有明显增加。唯一让人不安的是,这类异常的出现很有规律:应用重启后会清静一段时间,然后过 50 到 60 分钟,第一批报错准会冒出来。起初我以为是负载均衡或者防火墙干的,于是开始在网络链路上下工夫,结果网络侧什么可疑的地方都没找到。
这里想多说一句,很多 Redis 连接问题之所以难排查,不是报错信息有多复杂,而是它表现得像“网络抖了一下”。尤其是Connection reset这种错误,放在谁身上都会先怀疑交换机、防火墙或云安全组。恰恰是这种直觉,让你很容易跳过对 Redis 自身配置的检查,先去折腾一堆和根因无关的东西。
1.2 初步验证“三条全过”,反而更懵
按照常规排查套路,我在应用服务器上依次做了三件事:
- 用
telnet <redis_ip> 6379检查端口,返回 Connected,说明 TCP 层可达。 - 用
redis-cli -a '<password>' ping,返回 PONG,说明 Redis 进程活着,密码也没问题。 - 用
redis-cli -a '<password>' info server查看运行状态,发现connected_clients不高,远没有到maxclients上限。
三条全过,反而让人更懵。再加上 Redis Desktop Manager、Another Redis Desktop Manager 这类图形工具都能正常连接,我一度觉得问题不在 Redis,而在业务代码或连接池配置。后来复盘时才想明白,这个判断做得太早了。图形工具大多每次执行完命令就释放底层连接,或者空闲重连策略和业务长连接完全不同,它们“正常”恰恰容易掩盖真相。如果当时能多看一眼CONFIG GET timeout,整个排查可能十分钟就结束了。
2. 三天排查链路:从连接池到内核参数,到底错在哪一层
2.1 客户端连接池成了第一怀疑对象
因为报错集中在“从连接池拿不到连接”和“连接被重置”,大部分人进来都会先检查连接池配置。我也没有例外,依次做了三件事:
- 把
testOnBorrow从false改成了true,让连接在借出之前先发一个 PING,坏连接当场暴露。 - 把
maxTotal从 8 调大到 50,担心是并发连接数不够导致获取不到资源。 - 调小
minEvictableIdleTimeMillis,让空闲时间过久的连接被连接池主动淘汰。
改完之后,异常确实减少了一些,但没有根治。现在复盘,原因很简单:testOnBorrow只能让客户端不把坏连接借出去,并不能阻止 Redis 服务端在空闲后把连接关掉。它相当于给一个漏水的桶加了检查员,看起来漏出去的水少了,可桶底那个漏水点依然在。更要命的是,testOnBorrow在高并发下会增加一次额外 RTT,对性能有影响,不适合长期开着。
2.2 防火墙会话老化、LB空闲超时和内核 keepalive 都被“冤枉”了
既然客户端连接池只是表象,我开始往网络层挖。这种“每隔一段时间连不上”的现象,太像中间网络设备把空闲会话清掉了。于是我查了应用服务器和 Redis 之间的防火墙老化时间、交换机会话表、负载均衡的空闲超时配置,还把服务器内核参数改了一遍:
net.ipv4.tcp_keepalive_time = 120 net.ipv4.tcp_keepalive_intvl = 30 net.ipv4.tcp_keepalive_probes = 3改完之后等待业务低峰期观察,问题照旧。这说明网络层设备并没有主动丢包,TCP 连接不是被中间设备“掐断”的。这步排查虽然没解决问题,但排除掉了网络层的重大嫌疑,让我后面能更果断地回过头查 Redis 本身。
现在回看,当时还忽略了一个关键点:如果是网络设备会话老化,那么故障间隔时间应该和网络设备配置高度相关,通常不会精确到 60 分钟这样规整的数值。Redis 服务端恰好有一个timeout配置,功能和“空闲超过 N 秒就断开”完全吻合,但当时因为看过一遍redis.conf里写的是 0,就把它排除了。这个盲区直接导致后面多折腾了一天。
2.3 规律浮现:应用重启后满血,50分钟后再次倒下
排查到第二天晚上,我把所有异常事件的触发时间点拉出来,发现一个规律:每次应用重启后,大约 50 到 60 分钟,第一批连接失败一定会出现。如果是防火墙会话老化,老化时间应该由网络设备决定,不会和“60分钟”这么精确的数字绑定。我当时隐隐觉得,Redis 服务端一定存在一个“到点就断”的机制。
第三天上班,我直接在应用服务器上执行了:
redis-cli -h <redis_ip> -a '<password>' CONFIG GET timeout返回结果是60。再查tcp-keepalive,返回300。看到这两个值的瞬间,整个问题的链条一下就完整了。之前所有“重启后好转、过一小时复发”的现象,都能用这个配置解释:服务端在连接空闲 60 秒后主动断开,客户端却不知道,继续拿着旧连接去发命令,于是报错。
3. 真凶落网:启动参数里的--timeout 60覆盖了配置文件
3.1 只看 redis.conf 不看运行时配置,是这次事故的最大盲区
这里有个很讽刺的插曲:第二天我就打开过/etc/redis/redis.conf,里面写的清清楚楚是timeout 0,所以我直接把这个配置项从怀疑列表里划掉了。现在看,这是最不该犯的错误。Redis 配置其实有三个层面:
- 配置文件里的默认值或显式值;
- 启动命令里的
--配置名 值,优先级高于配置文件; - 运行时
CONFIG SET修改的临时值,优先级最高,但不会写回文件。
我们这台 Redis 由 systemd 管理,用systemctl cat redis查看实际启动命令后才发现,ExecStart里写的是/usr/bin/redis-server /etc/redis/redis.conf --supervised systemd --daemonize no --timeout 60。也就是说,虽然配置文件里是timeout 0,但启动命令额外加了一个--timeout 60,把配置文件的默认值覆盖掉了。
为什么会在启动命令里多出这个参数?大概率是之前有同事为了“让 Redis 自动回收空闲连接、减少连接数占用”,在 systemd unit 里临时加的参数,既没有同步到 redis.conf,也没有留任何注释。这种隐蔽性极强的问题,靠肉眼检查配置文件是发现不了的,必须用CONFIG GET或者查看进程启动参数才能看到真实值。
3.2 timeout、tcp-keepalive 和客户端连接池的“三方博弈”
timeout的单位是秒,官方说明是:客户端空闲 N 秒后,服务端主动关闭连接,默认 0 表示不启用。线上设置成 60,意味着所有连接只要 60 秒内没有命令,就会被 Redis 主动断开。这个机制本身并不奇葩,很多高连接数场景确实想通过它回收闲置连接。真正的问题是,它和客户端连接池的行为完全冲突了。
客户端连接池并不会立刻感知服务端已经断开。以 Java 应用为例,连接池里的空闲连接会保留很长时间,默认的驱逐策略在一些版本里甚至接近“永久保留”。于是链路就变成了这样:
- 应用从连接池取出一条“看起来正常”的连接。
- 这条连接距离上次使用已经超过 60 秒,Redis 服务端早就把它关了。
- 应用拿着这条连接发命令,可读事件触发 IO 异常,表现为
Connection reset或Read timed out。
这就能解释为什么重启应用能立刻恢复:重启后连接池重建,所有连接都是新的。但过 50 到 60 分钟,最早一批连接又一次进入空闲超时区,故障重现。不是精确到60秒,是因为业务访问 Redis 的节奏并不均匀,多条连接的空闲时间存在差异,整体上就表现为“一小时后集中报错”。
至于tcp-keepalive 300,它原本的作用是让内核每隔 300 秒发送 TCP 探测报文,确认对端是否存活。但问题在于timeout 60已经把连接关闭了,内核的 keepalive 根本没有机会介入,探测包也不会发出去,中间设备自然看不到这个连接有活动。这两个参数放在一起,等于一个在拆连接,另一个使劲想保活,最后保活方输得干干净净。
3.3 为什么阻塞命令和图形工具都没暴露问题
这次被两个“正常现象”干扰了很久:线上采用 BRPOP 进行消息处理的模块一直没报错,Redis Desktop Manager 也一直连得稳稳的,所以有一段时间我甚至怀疑是不是不同语言客户端对 Redis 的处理方式不同。实际上,问题纯粹出在连接的空闲状态上。
Redis 文档里写得很清楚:通过阻塞命令(如 BRPOP)或订阅命令(SUBSCRIBE)建立的连接,不受timeout限制。因为这类连接即使没有数据往来,也处于“正在被 Redis 跟踪”的状态,服务端不会因为空闲把它们断掉。所以凡是用了 BRPOP 的模块,哪怕业务上看起来没什么流量,连接也一直健在。这给排查制造了巨大的噪音。
而 Redis Desktop Manager 这类图形工具呢,通常执行完命令就会释放连接,或者空闲重连策略很短,每次新建连接都发生在服务端断开之前。它不代表业务长连接的真实行为。想明白这两点,你就不会再被“某些工具连得上、某些连不上”的表象带偏了。
4. 修复与验证:让配置在“文件、启动参数、运行时”三层完全一致
4.1 临时止血与彻底修复:三个地方必须一起改
确认根因之后,修复本身不难,但要注意三个层面缺一不可:
- 运行时热修改:先执行
CONFIG SET timeout 0,让当前所有连接立刻不再因为空闲被断开。注意这个修改不会写入配置文件,一重启就丢了,但可以当作应急止血。 - 配置文件确认:打开
/etc/redis/redis.conf,确认timeout 0或者直接注释掉这行,因为默认就是 0。只在这里改没有用,启动参数的优先级更高。 - 启动参数清理:编辑 systemd unit 或 override.conf,去掉
ExecStart后面的--timeout 60。如果保留了一个调优文件,建议在文件头部加注释,说明参数谁加的、为什么加、什么时候加。
改完后执行:
systemctl daemon-reload systemctl restart redis重启后再一次执行CONFIG GET timeout,确认返回的是0。同时用ps -ef | grep redis-server查看进程启动命令行,确保已经看不到--timeout字样。双重确认,才算真正改干净。
4.2 用一条 raw socket 脚本复现整个故障
为了验证修复效果,我写了一个最底层的测试脚本,绕开所有客户端 SDK,直接用 TCP 连接观察 Redis 行为。这样做的好处是不受任何连接池、自动重连机制干扰,能直接看到服务端到底有没有主动断开空闲连接。
import socket import time s = socket.create_connection(("<redis_ip>", 6379), timeout=5) s.sendall(b"PING\r\n") print("first response:", s.recv(1024)) time.sleep(61) try: s.sendall(b"PING\r\n") print("second response:", s.recv(1024)) except Exception as exc: print("second send/recv failed:", type(exc).__name__, exc) finally: s.close()在修复前跑这个脚本,第二次 PING 大概率会触发ConnectionResetError或TimeoutError。修复后跑,61 秒后连接依然活着,第二次能正常收到+PONG响应。这个脚本不依赖第三方库,适合直接拿到现场验证问题,也可以放进测试环境的回归用例里。
4.3 客户端连接池参数也需要配合调整
如果你不想把服务端timeout设成 0,那一定要让客户端连接池的空闲淘汰时间小于服务端的timeout。比如服务端timeout 60,客户端连接池就要保证空闲连接在 30 秒左右被回收,并通过testWhileIdle定期检查连接健康状态。这样才能在服务端关掉连接之前主动放弃,而不是等它变成一条坏连接。
在我的案例里,最终选择把服务端timeout设回默认的 0,让连接保持长期存活,依赖tcp-keepalive和应用层心跳清理异常连接。Redis 本身是很轻量的内存服务,空闲连接的成本主要是文件描述符和少量内存,没必要为了省资源去设置一个很短的timeout。如果连接数已经接近maxclients,优先排查有没有连接泄漏,而不是用粗暴的空闲断开来收拾局面。
5. 除了 timeout,还有哪些 Redis 配置会伪装成“连接失败”
5.1 maxclients 与 tcp-backlog:连接数到顶时的表现
maxclients默认是 10000,一般业务很难触达。可一旦应用存在连接泄漏,客户端数量会逐步逼近上限。表现出来就是新连接建立时直接被拒绝,客户端报Cannot connect或max number of clients reached,而已经建立的连接不受影响。这看起来和 timeout 问题很像,但看监控就能区分:connected_clients会持续走高,CONFIG GET maxclients能看到实际限制。
另一个容易忽略的是tcp-backlog。Redis 只负责接收内核已完成握手的连接,如果应用层 accept 不过来,连接请求就会在内核队列里堆积。当并发连接非常大时,tcp-backlog太小会导致客户端连接超时,Redis 日志里却不一定有明显报错。遇到这种情况,除了看 Redis 的tcp-backlog配置,还要把系统级参数net.core.somaxconn一起调大,两边保持一致才有效。
5.2 bind、protected-mode 与 ACL:权限类配置的典型坑
如果是新环境部署,连接失败更多是bind和protected-mode组合出来的问题。Redis 默认只监听127.0.0.1,直接远程连接必然失败;如果只写了bind 0.0.0.0但没关掉protected-mode,非本机访问还是会被拒绝,报错通常是DENIED Redis is running in protected mode。这个错误其实很友好,会直接告诉你原因。怕就怕某些云镜像把 protected-mode 关了,但bind设置错误,造成“一部分机器能连、一部分不能连”的诡异现象。
ACL 配置也要注意。Redis 6.0 之后可以通过 ACL 定义默认用户的权限和访问模式。这类问题最容易出现在版本升级后,比如客户端连接串里明明带了密码,但新配置里默认用户是nopass,或者反过来默认用户设置了requirepass而客户端没传密码。遇到权限类连接错误时,建议同时看ACL LIST和CONFIG GET requirepass,不要只看一处。
5.3 顺手能做的配置审计:把“三天排查”压缩到十分钟
经过这次事故,我给自己列了一个 Redis 连接问题的快速检查清单,分享出来,遇到类似问题可以按顺序过一遍:
CONFIG GET timeout:有没有配置过短的主动断开时间。CONFIG GET tcp-keepalive:是否配置了合理的心跳间隔。CONFIG GET maxclients和CLIENT LIST:连接数是否接近上限,各连接的空闲时间IDLE是否异常。CONFIG GET protected-mode、CONFIG GET bind、ACL LIST:监听地址、保护模式、权限配置是否匹配业务形态。systemctl cat redis或ps -ef | grep redis-server:启动参数有没有悄悄覆盖配置文件。
最后分享一个小技巧:我后来在配置管理系统里给所有 Redis 节点加了一个定时巡检脚本,每天拉一次CONFIG GET timeout和tcp-keepalive,与预期值做比对,出现偏差立即告警。别小看这一步,它能把这类“查三天”的隐性问题,消灭在真正影响业务之前。这次之后我也养成了一个习惯,凡是排查连接类故障,永远是先查运行时配置,再查文件配置,最后查启动参数。三个层面只要有一层不一致,再老的司机都容易翻车。