文章目录

在这里插入图片描述

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:252026-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$RedistListenerWorkTimer-* 等代码线索

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 everysec
  • no-appendfsync-on-rewrite no
  • auto-aof-rewrite-percentage 100
  • auto-aof-rewrite-min-size 64mb
  • 配置文件中 save 项是注释状态,但服务端日志显示运行过程中仍在做 BGSAVE

用途:

  • 验证 Redis 持久化策略
  • 评估是否需要改成 RDB-only 或放宽 AOF rewrite 策略

3. 当前目录材料得出的高价值结论

3.1 已被目录内证据直接支持的结论

  1. Boom!!! 不是可靠的 GC 停顿证据。
  2. catalina.out 中存在明确的故障窗口,约从 08:41:34.99508:41:44.410
  3. Redis 报错是 JedisConnectionException -> SocketTimeoutException: connect timed out
  4. 多份线程栈中,不止 Redis,HTTP/Nacos 等连接也卡在 socketConnect()
  5. Redis 服务端日志里的 BGSAVE 很快,约 100ms 量级,不支持“RDB 本身卡 1 分钟”的说法。
  6. 应用存在持续创建 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 邻居表溢出 的关键闭环证据


建议按下面顺序看:

  1. catalina.out
    先确定故障时间线和首次异常。
  2. thread2.log / thread3.log / thread6.log / thread7.log / thread8.log
    看故障现场线程统一卡在哪里。
  3. redis-1.log / redis-2.log / redis-3.log
    对齐 Redis 服务端行为,排除或确认持久化阻塞。
  4. redis-*.conf
    核对持久化和运行参数。
  5. 1610.hprof
    最后再做深入对象分析。

4. 补充材料

“系统层证据文件”

  • dmesg 关键输出
  • sysctl net.ipv4.neigh.default.gc_thresh*
  • ip neigh show | wc -l
  • ss -s
  • netstat -anp | grep 15002 | grep ESTABLISHED
  • free -h

  1. 应用侧证据catalina.out
  2. Redis 侧证据redis-*.logredis_monitor*.txtredis-*.conf
  3. JVM 现场证据thread*.txt/.log1610.hprof

一、故障表象:所有人第一眼都会怀疑 Redis

这次现场症状非常典型,也非常容易误导人:

现象 第一反应
系统不定时卡顿约 1 分钟 可能是 JVM Stop-The-World 或 Redis 卡死
Tomcat 进程还活着 不是进程崩溃,不是 OOM 直接退出
应用日志报 RedisConnectionFailureException 很像 Redis 不可用
线程栈里有 Jedis connect timed out 更像 Redis 端口无响应
现场反馈 redis-cli 也连不上 很容易进一步认定“就是 Redis 挂了”

但有经验的排障一般不会停在“最像根因的那个报错”上,因为分布式系统里,最早报错的组件,往往只是最先受害的组件,不一定是根因所在

这次真正有价值的做法,不是盯着 Redis 一路深挖,而是把证据拆成几层:

  1. 应用日志里发生了什么。
  2. 线程在卡哪里。
  3. Redis 自己在那一刻干了什么。
  4. 操作系统资源有没有到极限。
  5. 内核日志有没有给出更底层的异常信号。

只要这五层证据不能互相印证,就不能下结论。

二、第一轮怀疑:是 GC 吗?是 Redis 持久化吗?

1. Boom!!! 看起来像 GC 标记,但时间线先把它否掉了

catalina.out 里有几次非常扎眼的 Boom!!!

  • 08:24:08
  • 08:40:58
  • 08:56:23
  • 09: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 正常业务日志

中间只有几十毫秒,没有出现秒级甚至十几秒的时间断层。

这说明两件事:

  1. Boom!!! 不是一个可靠的 Full GC 证据。
  2. 如果系统真的发生了“卡一分钟”,真正的卡点不在这些 Boom!!! 本身。

相反,真正值得注意的是下面这一段:

08:41:34:995 最后一条连续正常日志
08:41:44:410 下一条日志
08:41:44:506 开始出现 RedisConnectionFailureException

这里中间大约有 9.4 秒 的空白,然后 Redis 超时报错开始密集出现。

这才是应该对齐线程栈和系统状态的真实故障窗口。

2. Redis 持久化很可疑,但日志不支持它是这次主因

目录里有三套 Redis:

  • redis-chk:7776
  • redis-mg:7777
  • redis-itx:7778

配置里确实有几个容易让人警觉的点:

  • appendonly yes
  • appendfsync everysec
  • no-appendfsync-on-rewrite no
  • auto-aof-rewrite-percentage 100
  • auto-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:2116: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-27953
  • Timer-27958
  • Timer-27963
  • Timer-28512

16:21:1116: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。它们未必是根因,但显然会放大连接建立异常带来的业务冲击。

这部分应该怎么治理

后续应优先回到代码做两件事:

  1. 搜索 new Timer(schedule(scheduleAtFixedRate(,核对是否存在重复创建后不销毁。
  2. 把分散的 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$RedistListenerWork
  • SimilarDocService$QueueListener
  • 大量 Timer-* 编号持续增长

建议后续专项治理:

  1. 梳理这些线程的创建入口。
  2. 确认是否存在重复启动、异常重建、无界重试。
  3. 用统一线程池代替零散 Timer
  4. 为关键 worker 增加生命周期日志和指标。

这会直接提升下一次故障的可观测性。

八、这次事故最值得记住的,不是调了哪个参数,而是排障方法

这次排障里,最有价值的其实不是那三条 sysctl,而是下面这套判断顺序:

  1. 先看时间线是否对得上。
    Boom!!! 很吓人,但前后只差几十毫秒,就不能把它当 GC 证据。

  2. 先分清“谁在报错”和“谁是根因”。
    Redis 先报错,不代表 Redis 先出问题。

  3. 线程栈比主观猜测更可靠。
    多协议统一卡在 socketConnect(),这一下就把视角从 Redis 拉到了系统层。

  4. 负证据同样重要。
    内存充足、Swap 为 0、端口未耗尽、BGSAVE 只要 100ms,这些都在帮助我们排除错误方向。

  5. 内核日志不要最后才看。
    neighbor table overflow 这种信息,往往比应用日志更接近根因。

对于线上故障来说,最危险的不是“不会排”,而是太早相信第一个看起来最像的解释

结语

回头看这次现场,最容易走偏的路径其实有两条:

  • 看到 Redis 超时,就一路把锅扣在 Redis 身上;
  • 看到 Boom!!! 和高编号 Timer-*,就直接写成 Full GC 或“2.8 万线程泄漏”。

这两条路都“像”,但都不够严谨。真正能站得住的结论,必须是日志、线程栈、系统指标、内核日志能够互相闭环

这次闭环后的结论是:

  • 主因:Linux 邻居表溢出,导致大量新连接卡在 socketConnect()
  • 表现:Redis 最先报超时,但不是唯一受影响组件。
  • 次级风险:Redis 持久化配置值得优化,Timer/Worker 创建模式也需要治理。
  • 方法论:跨层交叉验证,先证伪再下结论。

如果把这次复盘浓缩成一句话,那就是:

线上最像根因的那个报错,常常只是系统把问题“翻译”给你看的方式。真正的根因,往往埋在更低一层。

参考资料

在这里插入图片描述

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐