上一篇 下一篇 分享链接 返回 返回顶部

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

发布人:Minchunlin 发布时间:2025-09-08 10:35 阅读量:724


那天凌晨两点,在香港机房,我们一条 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 不可怕,可怕的是不去对齐。

目录结构
全文