Linux - 一次把 Redis 当成元凶的排障:Tomcat 没死、Redis 超时,真正问题却是 Linux 邻居表溢出
文章目录

Pre 前置知识
Linux - ARP Cache:从 ip neigh 到交换机转发,一次讲透主机路由表、ARP 缓存与 MAC 表
概述
故障摘要:应用不定时卡顿约 1 分钟,Tomcat 进程始终存活,业务日志大量报
RedisConnectionFailureException,现场甚至反馈“连redis-cli也偶发连不上”。如果只看表象,很容易把根因归到 Redis 或 JVM Full GC 上。但沿着应用日志、线程栈、Redis 日志、系统指标和内核日志一路交叉验证后,真正闭环的结论是:主因不是 Redis 崩溃,也不是 Full GC,而是 Linux 邻居表(ARP/neighbor cache)溢出,导致大量新建连接统一卡在socketConnect();Redis 只是最先报错的受害者。同时,线程栈还暴露出另一个不能忽略的隐患:业务代码存在高频创建
Timer的强烈信号,这会放大系统复杂度和后续排障成本,但它不是这次“卡一分钟”的主证据。
采集信息
用途:对本次 “Tomcat 存活但业务间歇性卡顿、Redis 超时、最终定位到 Linux 邻居表溢出” 的排障材料
1. 原始证据
1.1 应用主日志
| 文件 | 大小 | 时间范围/说明 | 价值 |
|---|---|---|---|
catalina.out |
246 MB | 2026-03-25 07:48:25 到 2026-03-25 16:39:25 |
主业务时间线,包含 Boom!!!、RedisConnectionFailureException、故障窗口前后日志 |
核心用途:
- 对齐故障时间线
- 验证
Boom!!!是否对应长时间停顿 - 找到 Redis 报错首次出现的时间点
1.2 Redis 服务端日志
| 文件 | 大小 | 起止特征 | 价值 |
|---|---|---|---|
redis-1 .log |
333 KB | 2026-03-23 启动,持续到 2026-03-26 13:51 | 7776 实例服务端行为,含 BGSAVE、AOF 相关记录 |
redis-2.log |
336 KB | 2026-03-23 启动,持续到 2026-03-26 13:51 | 7777 实例服务端行为 |
redis-3.log |
304 KB | 2026-03-23 启动,持续到 2026-03-26 13:51 | 7778 实例服务端行为 |
核心用途:
- 验证 Redis 在故障窗口是否出现长时间阻塞
- 核对
BGSAVE/BGREWRITEAOF实际耗时 - 判断 Redis 持久化是否是主因还是风险项
1.3 Redis Monitor 抓包
| 文件 | 大小 | 说明 | 价值 |
|---|---|---|---|
redis_monitor.txt |
66 KB | 一份 Redis monitor 输出 | 看命令流量和命令形态 |
redis_monitor7778.txt |
558 KB | 7778 实例的 monitor 输出 |
验证 monitor 期间 Redis 是否持续处理请求 |
核心用途:
- 看 Redis 是否仍能处理命令
- 观察是否有异常大命令、阻塞性命令、热点 key
1.4 Java 线程栈
通过arthas导出的线程堆栈
| 文件 | 大小 | 抓取时间 | 价值 |
|---|---|---|---|
thread1.txt |
1.1 MB | 2026-03-25 16:21:11 |
正常/对照现场 |
thread2.log |
1.2 MB | 2026-03-25 16:21:34 |
故障现场之一 |
thread3.log |
1.2 MB | 2026-03-25 16:21:45 |
故障现场之一 |
thread4.log |
1.1 MB | 2026-03-25 16:21:58 |
对照现场 |
thread5.log |
1.2 MB | 2026-03-25 16:22:14 |
对照现场 |
thread6.log |
1.2 MB | 2026-03-25 16:22:25 |
故障现场之一 |
thread7.log |
1.3 MB | 2026-03-25 16:22:38 |
故障现场之一 |
thread8.log |
1.3 MB | 2026-03-25 16:22:42 |
故障现场之一 |
thread9.txt |
1.1 MB | 2026-03-25 16:56:08 |
后续对照现场 |
核心用途:
- 证明大量线程统一卡在
java.net.PlainSocketImpl.socketConnect - 区分 Redis 单点异常还是多协议统一异常
- 暴露
OfflineSelfCheckCreate$RedistListenerWork、Timer-*等代码线索
1.5 堆转储
| 文件 | 大小 | 说明 | 价值 |
|---|---|---|---|
1610.hprof |
713 MB | Java heap dump | 后续做 MAT/HeapHero 分析,确认对象分布、线程相关对象、Timer/任务对象、缓存占用等 |
说明:
- 这份文件目前还没有展开分析。
- 如果后面要继续追
Timer创建来源、对象滞留、连接池对象堆积,这个文件价值很高。
2. 配置文件
| 文件 | 大小 | 说明 |
|---|---|---|
redis-1.conf |
61 KB | 7776 Redis 配置 |
redis-2.conf |
61 KB | 7777 Redis 配置 |
redis-3.conf |
61 KB | 7778 Redis 配置 |
配置层面已经确认的关键信息:
- 三个实例都启用了
appendonly yes appendfsync everysecno-appendfsync-on-rewrite noauto-aof-rewrite-percentage 100auto-aof-rewrite-min-size 64mb- 配置文件中
save项是注释状态,但服务端日志显示运行过程中仍在做BGSAVE
用途:
- 验证 Redis 持久化策略
- 评估是否需要改成 RDB-only 或放宽 AOF rewrite 策略
3. 当前目录材料得出的高价值结论
3.1 已被目录内证据直接支持的结论
Boom!!!不是可靠的 GC 停顿证据。catalina.out中存在明确的故障窗口,约从08:41:34.995到08:41:44.410。- Redis 报错是
JedisConnectionException -> SocketTimeoutException: connect timed out。 - 多份线程栈中,不止 Redis,HTTP/Nacos 等连接也卡在
socketConnect()。 - Redis 服务端日志里的
BGSAVE很快,约 100ms 量级,不支持“RDB 本身卡 1 分钟”的说法。 - 应用存在持续创建
Timer的强烈信号,线程名编号从27953增长到28512。
3.2 不在目录里、但已在排障过程中得到的关键信息
这些信息目前没有单独保存成文件,只存在于终端会话里:
dmesg中出现:
neighbor: arp_cache: neighbor table overflow!
ss -s显示:
TCP: 6988 (estab 5745, timewait 1165)
free -h显示内存充足、Swap 0B- Java 线程数约
1460 netstat -anp | grep 15002 | grep ESTABLISHED | wc -l得到约4568
这部分是最终把问题指向 Linux 邻居表溢出 的关键闭环证据
建议按下面顺序看:
catalina.out
先确定故障时间线和首次异常。thread2.log/thread3.log/thread6.log/thread7.log/thread8.log
看故障现场线程统一卡在哪里。redis-1.log/redis-2.log/redis-3.log
对齐 Redis 服务端行为,排除或确认持久化阻塞。redis-*.conf
核对持久化和运行参数。1610.hprof
最后再做深入对象分析。
4. 补充材料
“系统层证据文件”
dmesg关键输出sysctl net.ipv4.neigh.default.gc_thresh*ip neigh show | wc -lss -snetstat -anp | grep 15002 | grep ESTABLISHEDfree -h
- 应用侧证据:
catalina.out - Redis 侧证据:
redis-*.log、redis_monitor*.txt、redis-*.conf - JVM 现场证据:
thread*.txt/.log、1610.hprof
一、故障表象:所有人第一眼都会怀疑 Redis
这次现场症状非常典型,也非常容易误导人:
| 现象 | 第一反应 |
|---|---|
| 系统不定时卡顿约 1 分钟 | 可能是 JVM Stop-The-World 或 Redis 卡死 |
| Tomcat 进程还活着 | 不是进程崩溃,不是 OOM 直接退出 |
应用日志报 RedisConnectionFailureException |
很像 Redis 不可用 |
线程栈里有 Jedis connect timed out |
更像 Redis 端口无响应 |
现场反馈 redis-cli 也连不上 |
很容易进一步认定“就是 Redis 挂了” |
但有经验的排障一般不会停在“最像根因的那个报错”上,因为分布式系统里,最早报错的组件,往往只是最先受害的组件,不一定是根因所在。
这次真正有价值的做法,不是盯着 Redis 一路深挖,而是把证据拆成几层:
- 应用日志里发生了什么。
- 线程在卡哪里。
- Redis 自己在那一刻干了什么。
- 操作系统资源有没有到极限。
- 内核日志有没有给出更底层的异常信号。
只要这五层证据不能互相印证,就不能下结论。
二、第一轮怀疑:是 GC 吗?是 Redis 持久化吗?
1. Boom!!! 看起来像 GC 标记,但时间线先把它否掉了
catalina.out 里有几次非常扎眼的 Boom!!!:
08:24:0808:40:5808:56:2309:01:03
如果这是某个 Full GC 监控输出,那确实很像“长时间 STW”。但把前后日志拉出来看,很快就能发现不对。
例如第一次:
08:24:08:583 正常业务日志
Boom!!!
08:24:08:635 正常业务日志
第二次连续 8 个 Boom!!!:
08:40:58:593 正常业务日志
Boom!!! x8
08:40:58:659 正常业务日志
中间只有几十毫秒,没有出现秒级甚至十几秒的时间断层。
这说明两件事:
Boom!!!不是一个可靠的 Full GC 证据。- 如果系统真的发生了“卡一分钟”,真正的卡点不在这些
Boom!!!本身。
相反,真正值得注意的是下面这一段:
08:41:34:995 最后一条连续正常日志
08:41:44:410 下一条日志
08:41:44:506 开始出现 RedisConnectionFailureException
这里中间大约有 9.4 秒 的空白,然后 Redis 超时报错开始密集出现。
这才是应该对齐线程栈和系统状态的真实故障窗口。
2. Redis 持久化很可疑,但日志不支持它是这次主因
目录里有三套 Redis:
redis-chk:7776redis-mg:7777redis-itx:7778
配置里确实有几个容易让人警觉的点:
appendonly yesappendfsync everysecno-appendfsync-on-rewrite noauto-aof-rewrite-percentage 100auto-aof-rewrite-min-size 64mb
如果只看配置,确实可以怀疑 AOF rewrite 带来的磁盘抖动。而且从日志名看,这还是 Redis 7 的 multi-part AOF,例如:
appendonly-itx.aof.1.base.rdb
appendonly-itx.aof.1.incr.aof
这说明环境并不老旧,已经是 Redis 7 之后的 AOF 机制。
问题在于:怀疑归怀疑,日志必须能对上故障时刻。
把 Redis 日志按故障时间对齐后,看到的是这样的记录:
08:41:03 Background saving started
08:41:03 DB saved on disk
08:41:03 Fork CoW for RDB: current 0 MB
08:41:03 Background saving terminated with success
三套实例都类似,耗时基本都在 100ms 量级。
这意味着:
- RDB
BGSAVE存在,但非常快。 - fork 的 CoW 开销很小。
- 它和“卡 1 分钟”这个故障表现不匹配。
后续日志里也能看到 AOF rewrite,但同样完成得很快,并没有形成分钟级阻塞。
因此,Redis 持久化配置是一个值得优化的风险点,但从现有证据看,它不是这次故障的主根因。
这一步很重要,因为很多排障文章会在这里直接下结论:“AOF rewrite 导致 Redis 卡死”。这次不能这么写,因为证据并不支持。
三、真正的突破口:线程栈告诉我们,卡住的不是 Redis,而是 connect()
目录里有 9 份线程栈,从 16:21 到 16:56 分多次抓取。把线程栈按“卡在什么位置”聚类后,信息突然清楚了。
在多份故障现场线程栈里,出现了大量这样的调用链:
java.net.PlainSocketImpl.socketConnect(Native Method)
而且卡住的并不只有 Jedis。同时卡住的还包括:
- Redis 连接:
redis.clients.jedis.BinaryJedis.connect - Nacos HTTP:
sun.net.www.http.HttpClient.openServer - Kafka 相关线程
- 其他 HTTP 客户端连接
也就是说,这不是“只有 Redis 连不上”,而是新建网络连接这件事本身出了问题。
从现场统计看,几份线程栈中有大量线程卡在 socketConnect():
| 线程栈 | Jedis connect 卡住数量 | HTTP openServer 卡住数量 |
|---|---|---|
thread2.log |
34 | 6 |
thread3.log |
34 | 12 |
thread6.log |
36 | 10 |
thread7.log |
35 | 2 |
thread8.log |
37 | 10 |
这类现象通常指向的就不再是 Redis 应用层,而是更底层的问题,例如:
- 网络栈异常
- 邻居表/ARP 解析异常
- 监听队列或连接建立链路异常
- 极端情况下的系统资源瓶颈
它至少说明一件事:
Redis 报错只是症状之一,真正的问题覆盖了不止一种协议和中间件。
四、系统资源继续证伪:不是内存,不是 Swap,也不是端口耗尽
第二轮系统级检查的结果也很关键。
1. 内存和 Swap 很健康
现场输出显示:
Mem: 124Gi total, 32Gi used, 43Gi free, 48Gi buff/cache
Swap: 15Gi total, 0B used
这基本可以排除:
- 因内存不足导致 Redis 被 swap 拖慢
- 因 Java 进程挤占内存导致全机 I/O 抖动
- 因 OOM 导致子进程或服务异常
2. 端口没有耗尽
ss -s 结果:
TCP: 6988 (estab 5745, timewait 1165)
本地临时端口范围:
32768 60999
这意味着可用临时端口还有两万多,TIME_WAIT 虽然不低,但远没到“端口耗尽”的程度。
3. 线程数高,但没碰上限
现场 Java 线程数约:
Threads: 1460
系统线程上限:
1020494
1460 个线程对于一个大型 Java 进程已经偏多,但它不是“卡一分钟”的直接解释,更不是系统级 hard limit 命中。
换句话说,第二轮证伪之后,排障范围进一步收缩成一句话:
应用层看到的是 Redis 连接超时,线程层看到的是多协议统一卡在
socketConnect(),系统资源层又没有给出“内存、端口、线程数用尽”的证据。
这时就应该把视角再往下压一层,看内核日志。
五、决定性的信号:neighbor table overflow
真正把问题闭环的是 dmesg 里的这条内核日志,或者看 /var/log/messages:
neighbor: arp_cache: neighbor table overflow!
这条信息比任何 Redis 超时都更接近根因。
1. 这条日志到底是什么意思
Linux 内核维护一张邻居表,IPv4 场景下通常就是 ARP 缓存。它的职责很简单:把 IP 地址解析为二层可达的 MAC 地址。
如果一台机器要和大量“直接连接的对端”通信,邻居项就会不断增长。当邻居表达到上限时,新邻居无法正常分配,连接建立过程就会异常,表现出来就是:
- 新 TCP 连接建立慢
connect()超时- 某些服务最先报“对端不可用”
- 过一阵子随着旧项回收,又自动恢复
这和本次故障的表象几乎完全一致。
2. 为什么这次很像它
Linux 内核文档里,邻居表这三个阈值的默认值通常是:
| 参数 | 含义 | 常见默认值 |
|---|---|---|
gc_thresh1 |
低水位,低于它时 GC 不积极 | 128 |
gc_thresh2 |
超过它后开始更激进回收 | 512 |
gc_thresh3 |
非永久邻居项硬上限 | 1024 |
而现场又给出了另一个关键信息:
netstat -anp | grep 15002 | grep ESTABLISHED | wc -l
4568
这代表 15002 端口上存在 4568 条已建立连接。连接数本身并不等于邻居项数量,但在“大量终端在线、且终端分布在大量直接可达对端”这一业务场景里,邻居表溢出的可能性就非常高了。
更重要的是,内核已经直接打印了 neighbor table overflow。这不是推测,这是现场证据。
3. 为什么 Redis 看起来最像元凶
因为 Redis 往往是最敏感的那个受害者:
- 业务线程需要频繁从连接池借连接
- 新建连接时直接走
connect() - 一旦
connect()变慢,Jedis 很快就报connect timed out - 业务日志因此最先被 Redis 错误刷屏
但线程栈已经证明,同一时间卡住的不只是 Redis,还包括 HTTP 和 Kafka 相关连接。
所以更准确的描述应该是:
Redis 不是故障根因,而是最先把内核层连接建立异常暴露出来的组件。
六、一个容易被误读的信号:Timer-27953 不等于“活着 27953 个线程”
这次排障里还有一个很容易写错的地方,就是 Timer 线程。
线程栈里出现了很多名字像这样的线程:
Timer-27953Timer-27958Timer-27963Timer-28512
从 16:21:11 到 16:56:08,最大编号从 27953 增长到 28512,差值 559,约 16 个/分钟。这说明:应用在持续创建新的 Timer 实例或等价调度对象。
但要注意一个专业上的细节:
- 线程名里的编号,表示创建序号,不等于当前活着的线程数。
- 实际线程栈里以
Timer-开头、当前仍可见的活跃线程,大约只有十几条。 - 所以“有 28000+ 个 Timer 活线程”这个说法是不严谨的。
真正严谨的写法应该是:
线程栈显示应用存在持续创建
Timer的强烈信号,编号在 35 分钟内增长了 559;这提示代码中可能存在重复调度、生命周期管理不当或定时器对象频繁创建的问题,但仅凭线程名编号,不能直接把它等同于实时活跃线程数。
与此同时,线程栈里还稳定出现了 32 个:
OfflineSelfCheckCreate$RedistListenerWork.run(OfflineSelfCheckCreate.java:307)
并且在故障现场,这些线程里有一部分正卡在:
RedisClient.rPop -> JedisPool.getResource -> BinaryJedis.connect
这说明业务代码层面,确实存在一组长期运行、持续拉 Redis 的 worker。它们未必是根因,但显然会放大连接建立异常带来的业务冲击。
这部分应该怎么治理
后续应优先回到代码做两件事:
- 搜索
new Timer(、schedule(、scheduleAtFixedRate(,核对是否存在重复创建后不销毁。 - 把分散的
Timer改成统一的ScheduledExecutorService,并把生命周期托管给 Spring 或容器。
这属于后续治理项,不是这次一分钟卡顿的主闭环证据,但不能忽视。
七、修复优先级:先止血,再治理
基于现有证据,我会把修复顺序排成下面这样。
1. 第一优先级:扩大邻居表阈值
如果这台机器确实承担了大量直接连接终端,优先止血手段就是调大邻居表阈值:
sysctl -w net.ipv4.neigh.default.gc_thresh1=16384
sysctl -w net.ipv4.neigh.default.gc_thresh2=32768
sysctl -w net.ipv4.neigh.default.gc_thresh3=65536
再落到持久配置里。
这组值不是银弹,但对于 124GiB 内存、且存在数千终端在线的机器,通常比默认值合理得多。
调完后要持续观察三类指标:
ip neigh show | wc -l
ss -s
dmesg -T | grep -i "neighbor table overflow"
如果告警消失、卡顿消失,根因就基本坐实。
2. 第二优先级:检查连接模型,而不是只盯 Redis
因为线程栈里同时出现了 Redis、HTTP、Kafka 连接建立阻塞,所以还要检查:
- 是否存在大量短连接反复重建
- 是否有不必要的主动探测/轮询
- 连接池参数是否过小,导致故障时雪崩式重连
- 是否有同机自连、代理转发、端口复用设计不合理的问题
这一步比“把 Redis 参数全改一遍”更重要,因为根因在系统连接建立层。
3. 第三优先级:把 Redis 持久化从“风险点”优化掉
虽然 Redis 持久化不是这次主因,但它仍然是值得收敛的运维风险。
如果 Redis 在这里主要承担缓存角色,可以评估:
- 是否切换为 RDB-only
- 是否关闭 AOF
- 如果保留 AOF,至少评估
no-appendfsync-on-rewrite yes - 调整
auto-aof-rewrite-min-size和 rewrite 触发频率
这里的原则很简单:
缓存类 Redis 不必为了“理论上的更高可靠性”承担不必要的磁盘放大和运维复杂度。
但这一步要基于业务数据恢复模型来决定,不能机械照搬。
4. 第四优先级:回到代码治理 Timer 和 Worker
这次线程栈留下了很清楚的代码线索:
OfflineSelfCheckCreate$RedistListenerWorkSimilarDocService$QueueListener- 大量
Timer-*编号持续增长
建议后续专项治理:
- 梳理这些线程的创建入口。
- 确认是否存在重复启动、异常重建、无界重试。
- 用统一线程池代替零散
Timer。 - 为关键 worker 增加生命周期日志和指标。
这会直接提升下一次故障的可观测性。
八、这次事故最值得记住的,不是调了哪个参数,而是排障方法
这次排障里,最有价值的其实不是那三条 sysctl,而是下面这套判断顺序:
-
先看时间线是否对得上。
Boom!!!很吓人,但前后只差几十毫秒,就不能把它当 GC 证据。 -
先分清“谁在报错”和“谁是根因”。
Redis 先报错,不代表 Redis 先出问题。 -
线程栈比主观猜测更可靠。
多协议统一卡在socketConnect(),这一下就把视角从 Redis 拉到了系统层。 -
负证据同样重要。
内存充足、Swap 为 0、端口未耗尽、BGSAVE 只要 100ms,这些都在帮助我们排除错误方向。 -
内核日志不要最后才看。
neighbor table overflow这种信息,往往比应用日志更接近根因。
对于线上故障来说,最危险的不是“不会排”,而是太早相信第一个看起来最像的解释。
结语
回头看这次现场,最容易走偏的路径其实有两条:
- 看到 Redis 超时,就一路把锅扣在 Redis 身上;
- 看到
Boom!!!和高编号Timer-*,就直接写成 Full GC 或“2.8 万线程泄漏”。
这两条路都“像”,但都不够严谨。真正能站得住的结论,必须是日志、线程栈、系统指标、内核日志能够互相闭环。
这次闭环后的结论是:
- 主因:Linux 邻居表溢出,导致大量新连接卡在
socketConnect()。 - 表现:Redis 最先报超时,但不是唯一受影响组件。
- 次级风险:Redis 持久化配置值得优化,Timer/Worker 创建模式也需要治理。
- 方法论:跨层交叉验证,先证伪再下结论。
如果把这次复盘浓缩成一句话,那就是:
线上最像根因的那个报错,常常只是系统把问题“翻译”给你看的方式。真正的根因,往往埋在更低一层。
参考资料
-
Redis 官方文档:Persistence
https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/ -
Linux Kernel 官方文档:
ip-sysctl中关于neigh/default/gc_thresh1~3
https://www.kernel.org/doc/html/v6.5/networking/ip-sysctl.html

更多推荐




所有评论(0)