一条 SQL 慢了两周,最后查出两个根因:一个是 TCP 内存,一个是 4G 的 Metastore
关键词:MetadataFetchFailedException、Spark shuffle 连接关闭、DataNode 1004 端口、CLOSE_WAIT 堆积、TCP out of memory、dfs.datanode.max.transfer.threads、sysctl tcp_mem、Hive Metastore JVM 调优、Kyuubi SQL 变慢
目录
- 一、先说结论
- [二、第一层:Kyuubi 说 SQL 失败了](#二、第一层:Kyuubi 说 SQL 失败了 "#%E4%BA%8C%E7%AC%AC%E4%B8%80%E5%B1%82kyuubi-%E8%AF%B4-sql-%E5%A4%B1%E8%B4%A5%E4%BA%86")
- [三、第二层:Spark 说 shuffle 拿不到数据](#三、第二层:Spark 说 shuffle 拿不到数据 "#%E4%B8%89%E7%AC%AC%E4%BA%8C%E5%B1%82spark-%E8%AF%B4-shuffle-%E6%8B%BF%E4%B8%8D%E5%88%B0%E6%95%B0%E6%8D%AE")
- 四、第三层:所有线索指向同一台机器
- [五、第四层:那台机器的 1004 端口](#五、第四层:那台机器的 1004 端口 "#%E4%BA%94%E7%AC%AC%E5%9B%9B%E5%B1%82%E9%82%A3%E5%8F%B0%E6%9C%BA%E5%99%A8%E7%9A%84-1004-%E7%AB%AF%E5%8F%A3")
- 六、第五层:线程栈里的真相
- [七、第六层:内核日志中的 TCP out of memory](#七、第六层:内核日志中的 TCP out of memory "#%E4%B8%83%E7%AC%AC%E5%85%AD%E5%B1%82%E5%86%85%E6%A0%B8%E6%97%A5%E5%BF%97%E4%B8%AD%E7%9A%84-tcp-out-of-memory")
- [八、一条被忽略的副线:Metastore 只有 4G](#八、一条被忽略的副线:Metastore 只有 4G "#%E5%85%AB%E4%B8%80%E6%9D%A1%E8%A2%AB%E5%BF%BD%E7%95%A5%E7%9A%84%E5%89%AF%E7%BA%BFmetastore-%E5%8F%AA%E6%9C%89-4g")
- 九、第一次处理:回滚参数,无效
- [十、转向内核:9 项 sysctl,缓解了](#十、转向内核:9 项 sysctl,缓解了 "#%E5%8D%81%E8%BD%AC%E5%90%91%E5%86%85%E6%A0%B89-%E9%A1%B9-sysctl%E7%BC%93%E8%A7%A3%E4%BA%86")
- [十一、另一条线:Metastore 4G → 32G → 64G](#十一、另一条线:Metastore 4G → 32G → 64G "#%E5%8D%81%E4%B8%80%E5%8F%A6%E4%B8%80%E6%9D%A1%E7%BA%BFmetastore-4g--32g--64g")
- 十二、完整因果链
- 十三、复盘
一、先说结论
业务反馈:Kyuubi 上的 SQL 突然变慢,部分直接失败,任务频繁重试。
查了两周,最后发现问题不是"一个根因",而是两个独立的瓶颈,恰好同时发作:
sql
阶段一:SQL 准备阶段 → Hive Metastore,JVM 只有 4G
阶段二:SQL 执行阶段 → 某台 DataNode,TCP 内存耗尽
两者表现完全一样:
sql
SQL 慢 / 超时 / 失败
但修法完全不同:
| 线 | 现象锚点 | 真正的解法 |
|---|---|---|
| A. 网络栈 | 1004 端口 9677 个 CLOSE_WAIT、TCP: out of memory |
批量调整 9 项内核参数 |
| B. 元数据 | Metastore 连接数 49--51、Kyuubi 会话 37--45 | JVM 从 4G 扩到 32G,再到 64G |
而中间有个很容易让人误判的插曲:
发现
dfs.datanode.max.transfer.threads被调到 40960,回滚到 8192 并重启------问题依然存在。
这次失败的回滚,反而是整个排查的转折点。
二、第一层:Kyuubi 说 SQL 失败了
业务侧的报错很直接:
yaml
Status: failed
org.apache.spark.shuffle.MetadataFetchFailedException:
Missing an output location for shuffle 1033 partition 5420
同时还有一类:
csharp
Caused by: java.io.IOException:
Connection from dn-node-07/131.88.69.78:7337 closed
业务层面看到的是:
| 现象 | 频率 |
|---|---|
| SQL 执行耗时显著增加 | 高 |
| SQL 长时间无响应 | 中 |
| SQL 直接失败 | 中 |
| 任务频繁重试 | 高 |
| executor lost | 中 |
第一反应通常是"Kyuubi 出问题了"。
但要注意:Kyuubi 只是 SQL 入口,它不做计算,只负责把 SQL 交给 Spark 引擎。
它报的错,多半是别人的错。
三、第二层:Spark 说 shuffle 拿不到数据
MetadataFetchFailedException 的含义很明确:
reduce task 去拉 shuffle 数据,但找不到某个 map output 的位置。
常见原因有八种:
kotlin
① 生成 shuffle 数据的 executor 异常退出
② executor 所在节点不可用
③ Spark shuffle service 异常
④ 本地 shuffle 文件丢失
⑤ 节点磁盘异常或磁盘满
⑥ 网络连接异常
⑦ NodeManager 异常
⑧ external shuffle service 不稳定
八种可能,第一次看根本没法定位。
但第二条报错给了关键线索:
csharp
Connection from dn-node-07/131.88.69.78:7337 closed
7337 是 shuffle 端口,而这台机器的名字,在报错里出现了不止一次。
所以不是"某个 executor 随机挂了",而是同一台机器上的 shuffle 服务不稳定。
四、第三层:所有线索指向同一台机器
把失败任务的 executor 分布拉出来,发现高度集中在一台节点上。
这台机器同时承担三个角色:
DataNode ------ 存数据
NodeManager ------ 跑 YARN 容器
RegionServer ------ 跑 HBase
再加上 NodeManager 内部的 Spark External Shuffle Service------一台机器上实际承载四类流量。
这是典型的混布节点。
混布本身没问题,但意味着:
这几个角色共享同一套 CPU、内存、磁盘、文件描述符、TCP 缓冲、网络带宽。 任何一方把资源吃光,其他人一起遭殃。
而且这台节点的 RegionServer 还在持续向同机的 DataNode 读 block------数据从本机磁盘出去,再从本机网卡走一趟,等于自己打自己。
于是去那台机器上看看。
五、第四层:那台机器的 1004 端口
第一个命令,数连接:
bash
netstat -anp | grep 1004 | wc -l
结果:
yaml
9000+
1004 是 HDFS DataNode 的数据传输端口。
继续看连接状态分布:
scss
9677 CLOSE-WAIT
259 ESTAB
1 LISTEN
121 TIME-WAIT
5.1 9677 个 CLOSE_WAIT 意味着什么
CLOSE_WAIT 的含义非常明确:
对端已经发了 FIN(我收到了关闭请求),但本机进程还没调
close()。
也就是说:
objectivec
客户端已经走了
↓
DataNode 应该关掉 socket
↓
但它没关
↓
连接挂在 CLOSE_WAIT
9677 个------意味着近万个连接,客户端都走了,DataNode 还没处理完关闭动作。
5.2 这会带来什么连锁反应
| 层级 | 影响 |
|---|---|
| socket 资源 | 每个连接占一个 fd,fd 被吃光 |
| 内存 | 每个 socket 有读写缓冲,占用内核内存 |
| 新连接 | fd 满了、缓冲没了 → 新连接建不上 |
| TCP 内存 | 大量 socket 缓冲累积 → 耗尽 |
而且要注意一点:
CLOSE_WAIT 是"被动等待",不是"正在传输"。 这些连接不干活,但依然占着资源。
这就是"假连接"------它们不产生吞吐,只消耗资源。
六、第五层:线程栈里的真相
光看连接数,只能知道"堵了",但不知道"为什么堵"。
抓线程栈:
arduino
jcmd 34176 Thread.print > datanode_stack.txt
6.1 第一类:正在发数据的线程(RUNNABLE)
less
"DataXceiver for client DFSClient_attempt_... at /10.0.x.x:31888
[Sending block BP-...:blk_6600780846_...]"
java.lang.Thread.State: RUNNABLE
at sun.nio.ch.FileDispatcherImpl.size0(Native Method)
at org.apache.hadoop.net.SocketOutputStream.transferToFully(SocketOutputStream.java:226)
at org.apache.hadoop.hdfs.server.datanode.BlockSender.sendPacket(BlockSender.java:616)
at org.apache.hadoop.hdfs.server.datanode.DataXceiver.readBlock(DataXceiver.java:611)
这些线程在正常干活 ------ 把 block 数据通过 socket 发出去。
6.2 第二类:卡在 SelectorPool 的线程(BLOCKED)
php
"DataXceiver for client DFSClient_hb_rs_... at /10.0.x.x:35470
[Sending block ...]"
java.lang.Thread.State: BLOCKED (on object monitor)
at java.util.concurrent.ConcurrentHashMap.computeIfAbsent(...)
- waiting to lock <0x...> (a ConcurrentHashMap$Node)
at org.apache.hadoop.net.SocketIOWithTimeout$SelectorPool.get(SocketIOWithTimeout.java:391)
at org.apache.hadoop.net.SocketIOWithTimeout.waitForIO(SocketIOWithTimeout.java:244)
at org.apache.hadoop.net.SocketOutputStream.waitForWritable(SocketOutputStream.java:171)
这一类的状态是 BLOCKED,不是 WAITING。
它们卡在两个地方:
arduino
waitForWritable ← 等 socket 可写(对端收不动)
SelectorPool.get ← 抢 SelectorPool 里的 selector(锁竞争)
翻译成人话:
它们在等"对面能收下",而对面收不动;同时几百个线程在抢同一把 selector 锁。
6.3 一个容易误判的地方
DataXceiver.readBlock 出现在栈里,本身不是异常------正常节点读 block 也会有这条栈。
真正异常的是它伴随的状态:BLOCKED (on object monitor) + waiting to lock。
看线程栈不能只看"调用了什么方法",要看"线程处于什么状态"。
RUNNABLE在发数据是正常的;BLOCKED在抢锁才是问题。
6.4 客户端来源很杂
线程名里有两种 DFSClient:
DFSClient_attempt_... ← Spark / YARN 任务
DFSClient_hb_rs_... ← HBase RegionServer
这台 DataNode 同时在服务 Spark 和 HBase。
所以它一卡,两边一起受影响------这正是混布带来的放大效应。
七、第六层:内核日志中的 TCP out of memory
再往下一层,看内核:
perl
dmesg -T | grep -i "tcp"
大量出现:
sql
TCP: out of memory -- consider tuning tcp_mem
7.1 这条日志意味着什么
socket 缓冲区占的是内核内存,不受 JVM 堆限制。
当 socket 数量太多、或者每个 socket 缓冲太大时:
内核 TCP 内存限制(tcp_mem)被打满
↓
无法为新连接分配缓冲
↓
新连接建立失败 / 已有连接异常关闭
这是操作系统级的资源枯竭。
7.2 为什么它能直接导致 Spark 失败
perl
TCP 内存耗尽
↓
新的 socket 分配不到缓冲
↓
Shuffle 连接(7337)建立失败或被重置
↓
MetadataFetchFailedException
↓
Kyuubi SQL 失败
主链路在这里闭合了:从 SQL 报错,一路追到内核。
八、一条被忽略的副线:Metastore 只有 4G
但如果问题到这里就"查清了",后面还会反复。
因为在同一时期的 Spark Driver 日志里,还有一组很不起眼的数据。
8.1 数据本身
arduino
Spark Driver 频繁连接 thrift://metastore-host:[REDACTED_PORT_5]
Metastore 当前连接数 : 49 ~ 51
Kyuubi opening sessions : 37 ~ 45
而查下去发现:
这台 Hive Metastore 的 JVM 堆,只有 4G。
8.2 4G 够不够
单看"4G 堆"好像还行------很多服务 4G 都能跑。
但 Metastore 的负载模型和普通服务不一样:
| 因素 | 说明 |
|---|---|
| 每个 Thrift 连接 | 占用线程栈 + 请求缓冲 |
| 每个元数据请求 | 拉 DBS / TBLS / PARTITIONS / PARTITION_KEY_VALS |
| 分区裁剪 | 一次可能加载上万分区对象 |
| 并发 | 40+ 会话同时打,对象无法快速释放 |
一句话:Metastore 的堆压力,跟"分区数 × 并发会话数"直接相关。
8.3 它影响的是哪个阶段
这一点很关键------Metastore 慢,不会影响数据读写,它影响的是"准备阶段" :
sql
SQL 解析
↓
元数据获取(表、列、分区)
↓
分区裁剪
↓
生成执行计划
↓
(以上都在 Metastore 侧完成)
↓
提交 Spark 作业 → 执行阶段(这里才是 DataNode / shuffle)
所以两条线的表现都是"SQL 慢",但一个慢在"开始之前",一个慢在"跑起来之后"。
九、第一次处理:回滚参数,无效
在主链路排查时,发现了一处配置改动:
xml
<property>
<name>dfs.datanode.max.transfer.threads</name>
<value>40960</value>
<!-- 原值 <value>8192</value> -->
</property>
从 8192 调到了 40960,翻了 5 倍。
9.1 为什么它看起来就是元凶
max.transfer.threads 控制 DataNode 允许的最大传输线程数(DataXceiver 上限)。
它本质是"闸门宽度",不是"处理能力"。
arduino
底层能力 = min(磁盘 IO, 网络带宽, TCP 内存, fd 数量, CPU)
闸门宽度 = max.transfer.threads
底层已经是瓶颈时,把闸门开大,只会让更多线程涌进来一起堵住。
按这个推理,改回 8192 应该就缓解了。
9.2 我们改了,也重启了
xml
<value>8192</value>
重启完成,DataNode 起来了。
然后再抓一次线程栈------
bash
BLOCKED
waiting to lock
SocketIOWithTimeout$SelectorPool
SocketOutputStream.waitForWritable
DataXceiver.readBlock
一模一样。
参数回滚了,重启了,线程还是卡在同一个地方。
9.3 这次失败的回滚,价值很大
因为它排除了一整条假设:
arduino
如果根因是"线程上限太高"
↓
回滚到 8192、重启、线程池重建
↓
应该恢复
↓
但事实是没有
结论:40960 不是根因,它只是一个"放大器"。
真正的瓶颈在更下层------内核的 TCP 缓冲、连接队列和回收能力。
继续在 DataNode 参数上折腾,是浪费时间。
十、转向内核:9 项 sysctl,缓解了
真正的处理发生在 6 月初。
判断是:高并发连接下,TCP 缓冲 / 队列 / 回收能力不足。
于是对整个 TDP 主机组做了一次批量内核参数调整。
10.1 先备份
css
ansible all -m shell -a "cp /etc/sysctl.conf /etc/sysctl.conf_20260602"
10.2 用 lineinfile 保证幂等
(regexp 命中就覆盖,没有就追加------可重复执行,不会写重复行)
ini
ansible all -m lineinfile -a "path=/etc/sysctl.conf regexp='^net.ipv4.tcp_mem\s*=' line='net.ipv4.tcp_mem = 2097152 3145728 4194304' state=present"
ansible all -m lineinfile -a "path=/etc/sysctl.conf regexp='^net.ipv4.tcp_rmem\s*=' line='net.ipv4.tcp_rmem = 4096 87380 16777216' state=present"
ansible all -m lineinfile -a "path=/etc/sysctl.conf regexp='^net.ipv4.tcp_wmem\s*=' line='net.ipv4.tcp_wmem = 4096 65536 16777216' state=present"
ansible all -m lineinfile -a "path=/etc/sysctl.conf regexp='^fs.file-max\s*=' line='fs.file-max = 2097152' state=present"
ansible all -m lineinfile -a "path=/etc/sysctl.conf regexp='^net.ipv4.tcp_max_orphans\s*=' line='net.ipv4.tcp_max_orphans = 262144' state=present"
ansible all -m lineinfile -a "path=/etc/sysctl.conf regexp='^net.ipv4.tcp_fin_timeout\s*=' line='net.ipv4.tcp_fin_timeout = 15' state=present"
ansible all -m lineinfile -a "path=/etc/sysctl.conf regexp='^net.ipv4.tcp_tw_reuse\s*=' line='net.ipv4.tcp_tw_reuse = 1' state=present"
ansible all -m lineinfile -a "path=/etc/sysctl.conf regexp='^net.core.somaxconn\s*=' line='net.core.somaxconn = 32768' state=present"
ansible all -m lineinfile -a "path=/etc/sysctl.conf regexp='^net.core.netdev_max_backlog\s*=' line='net.core.netdev_max_backlog = 16384' state=present"
10.3 生效并核验
css
ansible all -m shell -a "sysctl -p /etc/sysctl.conf"
ansible all -m shell -a "sysctl net.ipv4.tcp_mem net.ipv4.tcp_rmem net.ipv4.tcp_wmem fs.file-max net.ipv4.tcp_max_orphans net.ipv4.tcp_fin_timeout net.ipv4.tcp_tw_reuse net.core.somaxconn net.core.netdev_max_backlog"
10.4 每一项在解决什么
| 参数 | 值 | 解决什么 |
|---|---|---|
net.ipv4.tcp_mem |
2G / 3G / 4G(按页) | 提高内核 TCP 内存总上限,消除 TCP: out of memory |
net.ipv4.tcp_rmem |
4096 / 87380 / 16M | 放大接收缓冲上限,应对慢客户端 |
net.ipv4.tcp_wmem |
4096 / 65536 / 16M | 放大发送缓冲上限,缓解 waitForWritable 阻塞 |
fs.file-max |
2097152 | 提高系统级 fd 上限 |
net.ipv4.tcp_max_orphans |
262144 | 放宽半断开连接上限,避免被内核丢弃 |
net.ipv4.tcp_fin_timeout |
15 | 缩短 FIN_WAIT_2 停留时间,加快回收 |
net.ipv4.tcp_tw_reuse |
1 | 允许出方向复用 TIME_WAIT 连接 |
net.core.somaxconn |
32768 | 加大 accept 队列,扛住瞬时建连 |
net.core.netdev_max_backlog |
16384 | 加大网卡收包队列,避免丢包 |
10.5 结果
调整之后,症状明显缓解。
这里要强调一件事:
这次生效的不是"应用参数",是"内核参数"。
对比一下两次处理:
| 处理 | 层级 | 效果 |
|---|---|---|
| 40960 → 8192 | 应用(DataNode) | 无效 |
| 9 项 sysctl | 内核(TCP 栈) | 有效 |
同一个人、同一个问题,把参数从"应用层"挪到"内核层",结果完全不同。
十一、另一条线:Metastore 4G → 32G → 64G
网络栈这条线缓解之后,剩下的"慢"还在。
原因就是第八章那条副线:Metastore 一直是 4G。
11.1 第一次扩容:4G → 32G
把 Metastore 的 JVM 堆从 4G 提到 32G ------8 倍。
这一步解决的是:
- 大批量元数据请求时不再频繁 Full GC
- 分区裁剪能装下更多分区对象
- 40+ 并发会话下,对象分配不再紧张
11.2 第二次扩容:32G → 64G
后来又提到 64G。
为什么 32G 还不够?
因为它增长的不是"量",是"复杂度":
sql
表数量 ↑
分区数量 ↑
并发会话数 ↑
每条 SQL 涉及的分区集合 ↑
这些都直接反映为 Metastore 堆里的对象数量。
元数据服务的堆,不是按"请求数"算的,是按"元数据规模 × 并发"算的。
11.3 两次扩容加起来是 16 倍
4G → 32G → 64G
8x 2x 合计 16x
一个跑了两年的 Metastore,配置从 4G 涨到 64G------这本身就说明了一件事:
元数据服务的容量,需要跟数据规模一起规划,而不是装完就不动了。
十二、完整因果链
现在可以把两条线拼在一起:
sql
业务现象:SQL 慢 / 失败 / 重试
├─ 【线 B:准备阶段】
│ Hive Metastore JVM = 4G(严重偏小)
│ ↓
│ 40+ 并发会话 × 大分区表元数据请求
│ ↓
│ GC 频繁 / 对象分配失败 / 响应变慢
│ ↓
│ SQL 解析、元数据获取、分区裁剪、计划生成变慢
│
└─ 【线 A:执行阶段】
节点混布 DataNode + NodeManager + RegionServer
↓
磁盘 93%--96%,block 读取慢
↓
DataXceiver 发送 block 时下游收不动
↓
线程 BLOCKED 在 waitForWritable + SelectorPool 锁
↓
socket 不释放 → CLOSE_WAIT 累积到 9677
↓
内核 TCP 内存耗尽:"TCP: out of memory"
↓
同节点 Shuffle Service(7337)建连失败
↓
MetadataFetchFailedException
↓
SQL 失败
两条线在业务侧合流:
"Kyuubi 上的 SQL 又慢又爱失败"
这就是为什么一开始会"查不清楚"------因为它们是两个独立的问题。
十三、复盘
13.1 七个判断
① "SQL 慢"要分阶段定位,不是分组件定位
一条 SQL 至少两个阶段:
准备阶段:解析 → 元数据 → 分区裁剪 → 计划生成 (Metastore)
执行阶段:调度 → 读数据 → shuffle → 计算 (YARN / HDFS / 节点)
先判断它慢在哪一段,再去查那个段的组件。
判断方法:看 SQL 卡住时,Driver 日志在打什么------是在连 Metastore,还是在等 shuffle。
② 一个现象,可能是两个根因
如果一开始就认定"只有一个根因",要么会漏掉 Metastore,要么会漏掉 TCP。
排查到中期没有收敛,就该怀疑"是不是有两件事"。
③ 一次无效的回滚,比一次成功的修复更有信息量
把 40960 改回 8192、重启、问题依旧------这一步排除了一整条假设。
排查的本质不是"找到对的",是"排除错的"。 一个能被证伪的猜测,就是一个有价值的实验。
④ 同样的问题,换个层级就解决了
| 处理 | 层级 | 效果 |
|---|---|---|
| max.transfer.threads 8192 | 应用 | 无效 |
| 9 项 sysctl | 内核 | 有效 |
如果卡在应用层反复调参,说明瓶颈可能不在应用层。
判断信号:应用参数改完没效果,但内核日志里有告警。
⑤ CLOSE_WAIT 是"应用没关",不是"网络有问题"
它明确指向:对端已经关了,本机进程没有调 close()。
通常是应用层线程卡住,不是网络层故障。
⑥ 线程栈要看状态,不只看方法名
DataXceiver.readBlock 出现很正常;异常的是它伴随 BLOCKED。
方法名告诉你"它在做什么",状态告诉你"它卡在哪"。
⑦ 元数据服务的堆,要跟数据规模一起规划
4G → 32G → 64G,16 倍增长。
绝大多数"装完就不管"的 Metastore,都是在数据长到一定规模后才暴露容量问题。
判断标准:Metastore 堆大小,应该跟"分区总数 × 并发会话数"匹配,不是跟"平时 QPS"匹配。
13.2 排查 Checklist
执行阶段(DataNode / 网络)
| 检查项 | 命令 |
|---|---|
| DataNode 端口连接数 | `netstat -anp |
| 连接状态分布 | `ss -ant |
| CLOSE_WAIT 数量 | `ss -ant state close-wait |
| 线程栈 + 状态统计 | jcmd <pid> Thread.print,`grep "Thread.State" |
| 内核 TCP 告警 | `dmesg -T |
| TCP 内存上限 | cat /proc/sys/net/ipv4/tcp_mem |
| fd 使用 | `lsof -p |
| max.transfer.threads | hdfs getconf -confKey dfs.datanode.max.transfer.threads |
| 磁盘水位 | df -h /data* |
准备阶段(Metastore)
| 检查项 | 命令 |
|---|---|
| 堆大小与使用 | jcmd <pid> GC.heap_info、jstat -gcutil <pid> 1000 20 |
| 连接数 | `ss -antp |
| 元数据请求并发 | Kyuubi opening sessions、Driver 侧 Metastore 连接数 |
| 分区规模 | SELECT COUNT(*) FROM PARTITIONS; |
| 慢查询 | Metasotore 后端 MySQL 慢日志 |
最后一句
这两周最反直觉的地方是:
我们一直在找"那个根因",但它有两个。
一个在"SQL 开始之前",一个在"SQL 跑起来之后"。
而最接近真相的一刻,不是找到了什么,是否定了什么------
把
max.transfer.threads改回 8192,重启,再抓一次栈,发现一模一样。
那一刻有点泄气。但也正是那一次,"DataNode 参数"这条线被彻底排除,方向才转向内核。
排查里最贵的动作,不是找到一个能解释现象的理论, 而是愿意花时间去验证它------哪怕结果可能是"我又错了"。