如何在香港服务器的 CentOS 7 上,把 Spark 跑成“懂 NUMA 的集群调度器”

那天凌晨两点,在香港机房,我们一条 10 节点的 Spark 小集群,刚把 ETL 作业从 2 小时压到 1 小时 20 分,老板挺满意;可换了批宽表 join 的活儿,延迟又飙回去了。看着 Grafana 上 CPU 忙到 90%+、GC 抖得像心电图,我脑子里闪过四个字:NUMA 不亲和。
我把耳机摘了,蹲在地上用 numactl --hardware 看拓扑,果然——双路、两个 NUMA 节点,内存访问跨节点比例高得离谱。那一刻我决定把这套 Spark 跑成“懂 NUMA”的。下面是我整晚到天亮的全部动作和复盘,希望你也能少走些弯路。
1. 现场与环境清单(真机参数,不是纸上谈兵)
| 项 | 参数 |
|---|---|
| 机房/地区 | 香港荃湾,单机架,双上联 |
| 节点数 | 10 台(8 worker + 2 master/utility) |
| 单机硬件 | 2× Intel Xeon Gold 6130(16C/32T ×2),192 GB DDR4-2666 |
| 本地盘 | 2× 1.92 TB NVMe(分属不同 PCIe 槽) |
| 网卡 | 2× 10 GbE(mlx5,单机双口链路聚合) |
| OS | CentOS 7.9(3.10.0-1160 内核) |
| Java | OpenJDK 1.8u3xx(Server VM) |
| Spark | 3.3.x(Standalone;另有一套 YARN 方案见下) |
| 关键包 | numactl, tuned, irqbalance, ethtool |
| 监控 | Prometheus + Grafana + Node Exporter + JMX Exporter |
备注:CentOS 7 的 cgroups 是 v1,后面关于 CPU 集/亲和都按 v1 玩法写。
2. 先认清 NUMA 拓扑(别想当然)
# 看 CPU/核心/插槽/NUMA 映射
lscpu -e=CPU,CORE,SOCKET,NODE | sort -n | column -t
# 看 NUMA 硬件信息与带宽延迟(大概情况)
numactl --hardware
# 动态统计:本地/远程内存访问情况
numastat -m 1 10
目标:确认每台机器 2 个 NUMA 节点(node0/node1),每节点 16 物理核(32 线程),并记录每个节点上的 CPU 列表和本地 NVMe、NIC 分布。
示例记录(一台 worker):
| 资源 | node0 | node1 |
|---|---|---|
| CPU 列表(逻辑) | 0-15, 32-47 | 16-31, 48-63 |
| 本地 NVMe | nvme0(/sys/class/nvme/nvme0/device/numa_node=0) |
nvme1(…/numa_node=1) |
| 本地 NIC 队列 | ens3f0(队列 0-7) | ens3f1(队列 0-7) |
3. OS 侧准备:把地基打稳
3.1 透明大页 & 自动 NUMA 平衡
Spark/JVM 大多不喜欢透明大页(THP),而我们要手工做 NUMA 亲和,内核的自动 NUMA 迁移也别掺和。
# 临时(立刻生效)
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo 0 > /proc/sys/kernel/numa_balancing
# 持久(rc.local 或 sysctl)
cat >/etc/rc.d/rc.local <<'EOF'
#!/bin/bash
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag
echo 0 > /proc/sys/kernel/numa_balancing
EOF
chmod +x /etc/rc.d/rc.local
3.2 tuned 性能档
yum -y install tuned
systemctl enable --now tuned
tuned-adm profile throughput-performance
# 若对延迟更苛刻可试 latency-performance(注意频率提升带来的散热/功耗)
3.3 IRQ 亲和:让 NIC 中断各管各的
网卡的多队列中断默认可能集中在 node0 上,跨节点抖动会让网络和 CPU 都不开心。按 NUMA 把中断绑好:
# 把 ens3f0 的中断绑到 node0 的 CPU,ens3f1 绑到 node1
# RHEL 带的脚本找不到?就用这个小工具
cat >/usr/local/sbin/set_irq_affinity.sh <<'EOF'
#!/usr/bin/env bash
IFACE="$1"; CPULIST="$2"
for i in $(grep $IFACE /proc/interrupts | awk -F: '{print $1}'); do
echo $CPULIST > /proc/irq/$i/smp_affinity_list
done
EOF
chmod +x /usr/local/sbin/set_irq_affinity.sh
/usr/local/sbin/set_irq_affinity.sh ens3f0 0-15,32-47
/usr/local/sbin/set_irq_affinity.sh ens3f1 16-31,48-63
3.4 本地盘挂载对齐
把每个 NUMA 节点的 NVMe 分别挂到不同目录,后面给对应的 executor 用:
# 确认 NUMA 归属
cat /sys/class/nvme/nvme0/device/numa_node # => 0
cat /sys/class/nvme/nvme1/device/numa_node # => 1
mkfs.xfs /dev/nvme0n1
mkfs.xfs /dev/nvme1n1
mkdir -p /data/node0 /data/node1
echo '/dev/nvme0n1 /data/node0 xfs noatime,nodiratime 0 0' >> /etc/fstab
echo '/dev/nvme1n1 /data/node1 xfs noatime,nodiratime 0 0' >> /etc/fstab
mount -a
4. 设计 Spark 执行器布局:一 NUMA 节点 = 一个 executor
双路 32C(物理核)机器,每个 NUMA 节点起一个 executor,各自用本地内存、本地 NVMe、本地网卡队列——最大限度减少跨节点访问。
公式建议:
- executor.cores ≈ NUMA节点的物理核数 × 0.8(给系统/GC/Shuffle 预留一点余量)
- executor.memory ≈ (节点内存/2) × 0.85(再给 OS PageCache 留 10–15%)
- executor.memoryOverhead 至少 10–15%(含 off-heap、Netty、压缩等)
举例(单机 192 GB):
| 项 | 值 |
|---|---|
| 每节点可分配内存 | 96 GB |
| executor.memory | 82 GB(向下取整) |
| executor.memoryOverhead | 12 GB |
| executor.cores | 13(16×0.8≈12.8,向上到 13) |
| 每机 executor 数 | 2(node0、node1 各 1 个) |
5. “NUMA-aware Executor Wrapper”:强制绑定 CPU & 内存
Spark 本身不直接管 NUMA,我们写个小 wrapper,把 executor 的 Java 进程前置上 numactl:
# /opt/numa-java/java(伪装成 java 的 wrapper)
mkdir -p /opt/numa-java/bin
cat >/opt/numa-java/bin/java <<'EOF'
#!/usr/bin/env bash
# 读取环境变量指定 NUMA 节点与 CPU 列表
NUMA_NODE="${SPARK_NUMA_NODE:=0}"
CPU_LIST="${SPARK_CPU_LIST:-}"
MEMBIND=$NUMA_NODE
CMD_JAVA="/usr/lib/jvm/java-1.8.0/bin/java"
if [[ -n "$CPU_LIST" ]]; then
exec numactl --cpunodebind=$NUMA_NODE --membind=$MEMBIND --physcpubind="$CPU_LIST" "$CMD_JAVA" "$@"
else
exec numactl --cpunodebind=$NUMA_NODE --membind=$MEMBIND "$CMD_JAVA" "$@"
fi
EOF
chmod +x /opt/numa-java/bin/java
思路:在启动 executor 前设置环境变量 SPARK_NUMA_NODE、SPARK_CPU_LIST,wrapper 会把真实 java 前面套上 numactl 并绑定到对应 NUMA。
把 Spark 的 executor 指到这个“假的 JAVA_HOME”:
# spark-env.sh
export SPARK_WORKER_CORES=26 # 为两个 executor 总和预留(13+13)
export SPARK_WORKER_MEMORY=176g # 82g*2 + overhead 留富余
export SPARK_DAEMON_JAVA_OPTS="-XX:+AlwaysPreTouch"
# 关键:仅 executor 用这个 JAVA(driver 仍用系统 JAVA)
export SPARK_EXECUTOR_JAVA_HOME=/opt/numa-java
按 NUMA 节点拉起两个 executor:
在 Standalone 模式下最简单的办法是启动两个 worker 进程,各自绑到不同 NUMA。示例 systemd 单元:
# /etc/systemd/system/spark-worker-node0.service
[Unit]
Description=Spark Worker (NUMA node0)
After=network.target
[Service]
User=spark
Environment=SPARK_NUMA_NODE=0
Environment=SPARK_CPU_LIST=0-15,32-47
Environment=JAVA_HOME=/usr/lib/jvm/java-1.8.0
Environment=SPARK_EXECUTOR_JAVA_HOME=/opt/numa-java
Environment=SPARK_WORKER_DIR=/var/lib/spark/node0
Environment=SPARK_LOCAL_DIRS=/data/node0/spark_local
ExecStart=/opt/spark/sbin/start-slave.sh spark://master:7077
Restart=always
LimitNOFILE=100000
[Install]
WantedBy=multi-user.target
# /etc/systemd/system/spark-worker-node1.service
[Unit]
Description=Spark Worker (NUMA node1)
After=network.target
[Service]
User=spark
Environment=SPARK_NUMA_NODE=1
Environment=SPARK_CPU_LIST=16-31,48-63
Environment=JAVA_HOME=/usr/lib/jvm/java-1.8.0
Environment=SPARK_EXECUTOR_JAVA_HOME=/opt/numa-java
Environment=SPARK_WORKER_DIR=/var/lib/spark/node1
Environment=SPARK_LOCAL_DIRS=/data/node1/spark_local
ExecStart=/opt/spark/sbin/start-slave.sh spark://master:7077
Restart=always
LimitNOFILE=100000
[Install]
WantedBy=multi-user.target
这样每台机器两个 worker,各自的 SPARK_LOCAL_DIRS 指向本 NUMA 的 NVMe,executor 进程也被 wrapper 绑定在对应内存和 CPU 上。
6. Spark 侧配置(spark-defaults.conf)
# 资源与并发
spark.executor.instances 2
spark.executor.cores 13
spark.executor.memory 82g
spark.executor.memoryOverhead 12g
spark.default.parallelism 2x(num-executors * cores) # 按作业特性再微调
# GC 与 NUMA
spark.executor.extraJavaOptions -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:+UseNUMA -XX:+AlwaysPreTouch -XX:InitiatingHeapOccupancyPercent=35 -XX:+ParallelRefProcEnabled
spark.driver.extraJavaOptions -XX:+UseG1GC -XX:+AlwaysPreTouch
# Shuffle/本地化
spark.local.dir /data/node0/spark_local,/data/node1/spark_local
spark.shuffle.service.enabled true
spark.sql.shuffle.partitions 400 # 依数据量调
spark.shuffle.file.buffer 1m
spark.reducer.maxSizeInFlight 96m
spark.shuffle.io.preferDirectBufs true
# 网络与序列化
spark.serializer org.apache.spark.serializer.KryoSerializer
spark.rpc.message.maxSize 256
spark.network.timeout 800s
# 指标
spark.metrics.conf.*.sink.jmx.class org.apache.spark.metrics.sink.JmxSink
spark.eventLog.enabled true
-XX:+UseNUMA 让 JVM 按 NUMA 分区管理堆;-XX:+AlwaysPreTouch 预触碰减少缺页;G1 在大堆+多核上更稳。
7. YARN 场景怎么做(可选)
如果你跑在 Hadoop YARN 上,推荐CPU 集 + numactl 前缀的组合:
开启 cgroups CPU 集:
<!-- yarn-site.xml -->
<property>
<name>yarn.nodemanager.resource.cpu-vcores</name><value>52</value>
</property>
<property>
<name>yarn.nodemanager.linux-container-executor.cgroups.hierarchy</name><value>/sys/fs/cgroup</value>
</property>
<property>
<name>yarn.nodemanager.linux-container-executor.cgroups.mount</name><value>true</value>
</property>
<property>
<name>yarn.nodemanager.container-executor.class</name><value>org.apache.hadoop.yarn.server.nodemanager.LinuxContainerExecutor</value>
</property>
<property>
<name>yarn.nodemanager.linux-container-executor.group</name><value>hadoop</value>
</property>
在 NodeManager 的 container-executor 启动脚本里注入 numactl:根据 cgroup 分到的 CPU 列表,映射到对应 NUMA 节点,给容器前缀 numactl --membind=<node>。
这需要一点脚本功底和变更流程,生产改前请在预发充分回归。
Spark 提交时使用:
--conf spark.executor.extraJavaOptions="-XX:+UseNUMA -XX:+AlwaysPreTouch" \
--conf spark.executor.instances=2 \
--conf spark.executor.cores=13 \
--conf spark.executor.memory=82g
8. 校准 Shuffle 与 IO 的“在地化”
本地目录按 NUMA 分开:前面我们已经把 spark.local.dir 拆到 /data/node0 与 /data/node1。
确认 NVMe 的 NUMA 归属:cat /sys/block/nvme0n1/device/numa_node。尽量让绑定在 node0 的 executor 只使用 /data/node0。
Netty/堆外缓冲:spark.shuffle.io.preferDirectBufs=true,并相应放大 memoryOverhead。
磁盘调度:XFS + noatime,对顺序写更友好;NVMe 通常不需要额外调度器优化。
9. 验证:别靠感觉,一定要有数
9.1 微基准(SQL Join+Agg)
我们选了 1.2 TB 的 Parquet 宽表(80 列)、1:5 的维度 join,再做 3 列聚合:
SELECT a.k, SUM(a.v1), AVG(a.v2)
FROM fact a JOIN dim b ON a.id = b.id
GROUP BY a.k;
对比结果(单次作业,取 3 次中位数):
| 指标 | 优化前(默认) | 优化后(NUMA-aware) | 变化 |
|---|---|---|---|
| 总耗时 | 64m 20s | 41m 55s | -34.8% |
| Executor 跨节点读比例 | 27% | 5% | -81% |
| p95 GC 暂停 | 210ms | 120ms | -43% |
| Shuffle spill(磁盘) | 1.8 TB | 1.2 TB | -33% |
| 网络重传率 | 0.42% | 0.18% | -57% |
9.2 系统侧指标
- numastat 的 numa_miss/foreign 大幅下降;
- NIC 两个口的中断/吞吐更对称;
- NVMe 的写放大减小,温度曲线更平滑。
10. 常见坑位 & 现场解决
忘了关 THP:GC 抖动、缺页中断飙升。
处理:立刻 echo never > .../enabled,并写入 rc.local 持久化。
自动 NUMA(numa_balancing)没关:页迁移反复横跳。
处理:echo 0 > /proc/sys/kernel/numa_balancing;必要时加到 sysctl.conf。
memoryOverhead 太小:Netty/压缩挤爆 off-heap,executor 被 OOM killer 清掉。
处理:把 spark.executor.memoryOverhead 提到 10–15%(大作业甚至 20%),并观察 RSS。
NVMe/网卡不在本地节点:白做亲和,IO 还是跨节点。
处理:用 lspci -vv、/sys/.../numa_node 对位,必要时换槽或固定到另一个节点的 executor 使用。
G1 + 大堆 + 高并发:部分版本的 JDK 有罕见长暂停。
处理:降 MaxGCPauseMillis 期望值,并观察 GC 日志;极端场景试 CMS(JDK8)验证对比。
Standalone 两个 worker 端口冲突:默认目录/端口相撞。
处理:为 node0/node1 分别设 SPARK_WORKER_DIR、SPARK_LOCAL_DIRS、日志目录;必要时改 spark.worker.port。
irqbalance 抢回中断:你手动绑了,irqbalance 又改回去了。
处理:要么停掉 irqbalance,要么用它的 --banirq/配置文件排除特定中断。
11. 回滚与灰度
先在 1 台机器开启 NUMA 绑定,A/B 对比 24 小时;
如指标异常(比如 p99 延迟反而上升),先撤掉 wrapper 恢复默认 Java,再逐项排查(THP、numa_balancing、NVMe/IRQ 亲和)。
别一次性动 10 台,渐进式推进。
12. 一份可复用的提交脚本(示例)
#!/usr/bin/env bash
APP_JAR=/opt/jobs/warehouse-etl.jar
MAIN_CLASS=com.company.jobs.WideJoinAgg
MASTER_URL=spark://master:7077
/opt/spark/bin/spark-submit \
--class $MAIN_CLASS \
--master $MASTER_URL \
--conf spark.executor.instances=2 \
--conf spark.executor.cores=13 \
--conf spark.executor.memory=82g \
--conf spark.executor.memoryOverhead=12g \
--conf spark.executor.extraJavaOptions="-XX:+UseG1GC -XX:+UseNUMA -XX:+AlwaysPreTouch -XX:InitiatingHeapOccupancyPercent=35" \
--conf spark.local.dir="/data/node0/spark_local,/data/node1/spark_local" \
--conf spark.sql.shuffle.partitions=400 \
--conf spark.shuffle.io.preferDirectBufs=true \
$APP_JAR \
--date $(date -d "yesterday" +%F)
13. 结语:从“风声鹤唳”到“有条不紊”
天快亮的时候,雨小了。回放监控,那条折磨了我们几周的宽表作业终于稳定在 40 分钟上下。机柜前的风还是热的,但曲线“安静”了下来:跨节点内存访问像被关了闸,网卡中断乖乖各回各家,GC 不再乱跳。
NUMA 这件事,说起来概念不复杂,“一节点一执行器、本地内存本地 IO、中断与 CPU 对齐”,但要在生产里跑顺,需要你真的进到机房、看每块卡插在哪个槽、盯日志、盯指标、一步步把“默认方便”替换成“明确选择”。
如果你也在香港、在 CentOS 7 的老兵器上跑 Spark,试试把它调成“懂 NUMA”的家伙。它不会让每一个作业都飞起来,但它会让你的曲线更稳、瓶颈更可解释,最重要的是——这一次,性能提升来自你对机器的理解,而不是运气。祝你也能在清晨走出机房时,带着一组漂亮的对比表,而不是满脑子的问号。
附:排查清单(到机房就按这个走)
- numactl --hardware、lscpu -e 把 CPU/NUMA/NVMe/NIC 对齐表做出来
- 关闭 THP、自动 NUMA;启用 tuned-adm throughput-performance
- NIC 中断亲和按 NUMA 绑定;确认不被 irqbalance 抢回
- NVMe 挂载为 /data/node{0,1} 并确认 numa_node 值
- Standalone 两个 worker(或 YARN cgroup+wrapper),一 NUMA 一 executor
- JVM:-XX:+UseNUMA -XX:+AlwaysPreTouch -XX:+UseG1GC
- spark.executor.memoryOverhead ≥ 10–15%,观察 RSS 与 OOM
- 用 numastat、GC 日志、NVMe/NIC 指标验证优化方向
有问题,先保守回滚,再逐项复盘。你会发现,NUMA 不可怕,可怕的是不去对齐。