64 核裸金属上宝塔面板比 16 核 VPS 还慢:NUMA 跨节点访存、php-fpm 按内存算 max_children、nginx worker 绑核三层实测

先说结论,省得你看到一半:宝塔面板出厂的那套 nginx.conf、php-fpm.conf、my.cnf,是按 1C2G ~ 4C8G 的 VPS 写的默认值。原封不动搬到 64 核 / 512G 的裸金属上,性能不是持平,是下降。

测试机:2×Xeon Gold 6338(64 物理核 / 128 线程),512G DDR4,2×NVMe RAID1,双口 25G,CentOS 7.9 + 宝塔 8.x,PHP 8.1 + OPcache。

同一份业务代码(一个 Redis 读 + 模板渲染的 PHP 页面),三组数据:

  • 宝塔默认配置:4200 QPS,P99 47ms,vmstat cs 380000/s
  • 只改 php-fpm:7100 QPS,P99 19ms,cs 140000/s
  • 再改 NUMA 绑定 + nginx worker 模型:11800 QPS,P99 8.7ms,cs 62000/s

2.8 倍,没有换一台机器,没有改一行业务代码。下面逐层拆。

一、先承认这台机器有两个 NUMA 节点,而不是一个大 CPU

宝塔装完,很多人第一件事就是进面板调参数,但从没看过这台机器的内存拓扑。裸金属最常见的坑就在这。

lscpu | grep -Ei "socket|core|numa|thread"
numactl -H
numastat -p $(pgrep -o nginx)

典型输出里你会看到 node0 / node1 各 256G,node distance 是 10 和 21。21 意味着跨节点访存的延迟是本地的大约 1.5~2.2 倍(视微架构而定,Xeon 上一般 80ns vs 140ns)。

再看一个更致命的:

cat /proc/sys/kernel/numa_balancing
# 1

numastat -p $(pgrep -o nginx) | head -5
# Per-node process memory usage (in MBs) for PID 12345 (nginx)
#                            Node 0          Node 1           Total
# Huge                         0.00            0.00            0.00
# Heap                         8.12          218.44          226.56

看到了吗,一个 worker 进程 96% 的堆内存落在远端节点上。而 numa_balancing 会周期性地扫页表做自动迁移,在 nginx 这种短生命周期、高并发的进程上,迁移收益远小于它带来的 TLB shootdown 抖动——P99 那种几毫秒到几十毫秒的毛刺,八成是它干的。

第一步就是把它关掉,并且让大页老实点:

# /etc/sysctl.d/99-numa.conf
kernel.numa_balancing = 0
vm.zone_reclaim_mode = 0

# 关闭透明大页(MySQL 和 PHP 在 THP 下会有内存碎片和延迟抖动)
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag

顺手说一句,我们交付轻云互联的裸金属机器时,BIOS 层直接把 NUMA balancing 和 THP 关掉了,面板装上之前这两项就已经是关的,省得后面查半天。

二、nginx:worker_processes auto + 无节点亲和,等于一半请求在吃远端内存

宝塔默认的 /www/server/nginx/conf/nginx.conf,events 段大概长这样:

events {
    worker_connections 1024;
    multi_accept on;
    use epoll;
}

1024 个连接上限,在 25G 网卡上就是个笑话。更关键的是——宝塔的 nginx.conf 里根本没有 worker_processes 这一行,用的是编译默认值或者 auto。在 128 线程的机器上 auto 就是 128 个 worker。

128 个 worker 意味着什么?每个 worker 都有自己的 epoll 实例、连接池、accept 队列。内核在 128 个核上调度它们,进程首次触页落在哪个 NUMA 节点完全随机,于是上一节 numastat 看到的那个 96% 远端就成了常态。

修法:worker 数按物理核算,内存 interleave,队列对齐

# /www/server/nginx/conf/nginx.conf
user  www www;
worker_processes  64;               # 物理核数,不是 128 线程数
worker_rlimit_nofile 1048576;
worker_shutdown_timeout 10s;

events {
    worker_connections 65535;
    multi_accept on;
    use epoll;
    accept_mutex off;               # 配合 reuseport,交给内核做负载均衡
}

http {
    access_log off;                 # 高并发静态站,别让磁盘先崩
    ...
}

站点的 vhost 配置里,listen 必须加 reuseport,否则 64 个 worker 会在同一个 listen fd 上抢 accept:

server {
    listen 80 reuseport backlog=65535;
    listen 443 ssl http2 reuseport backlog=65535;
    ...
}

然后是最关键的一步——用 numactl 让 nginx 的内存页在两个节点上交错分配,而不是让它随便挑:

# 手动启动一次,验证效果
numactl --interleave=all /www/server/nginx/sbin/nginx -t
numactl --interleave=all /www/server/nginx/sbin/nginx

# 验证
numastat -p $(pgrep -o nginx)
# Heap 应该在 Node0 和 Node1 上各占一半左右

但宝塔是走 /etc/init.d/nginx 启停的,重启后面板一操作又回去了。所以用 systemd override 把它钉死:

mkdir -p /etc/systemd/system/nginx.service.d
cat > /etc/systemd/system/nginx.service.d/override.conf <<'EOF'
[Service]
LimitNOFILE=1048576
LimitNPROC=65535
ExecStartPre=/usr/bin/numactl --interleave=all /www/server/nginx/sbin/nginx -t
ExecStart=/usr/bin/numactl --interleave=all /www/server/nginx/sbin/nginx
ExecReload=/usr/bin/numactl --interleave=all /www/server/nginx/sbin/nginx -s reload
EOF
systemctl daemon-reload
systemctl restart nginx

还有一步容易漏的:网卡多队列数要和 worker 数对齐。默认 25G 网卡可能只开了 8 个 combined 队列,64 个 worker 抢 8 个 RX 队列,RSS 哈希再均匀也没用。

ethtool -l eth0                       # 看当前 combined 数
ethtool -L eth0 combined 32           # 一般开到核数的一半到核数即可
cat /proc/interrupts | grep eth0 | wc -l

对齐之后,accept 的软中断分发和 worker 的 epoll 唤醒基本在同一个节点内,跨节点 IPI 会明显减少。

三、php-fpm:宝塔按内存算出来的 max_children 是颗定时炸弹

这是我觉得宝塔在裸金属上最坑的一处。宝塔面板的「性能调整」页面,那个推荐值是按内存大小算的,算法大致是「可用内存 × 系数 ÷ 单个 PHP 进程预估内存」。512G 的机器,它能给你算出四位数甚至五位数的 max_children。

我们实测那台机器上,宝塔自动写下的是:

pm = dynamic
pm.max_children = 3200
pm.start_servers = 640
pm.min_spare_servers = 320
pm.max_spare_servers = 1280

看起来很美。跑起来是这样:

vmstat 1
# r   b   swpd   free   buff  cache   si   so    bi    bo   in    cs us sy id wa
# 78   0      0 4123456   ...     0    0     0     0 128k  381240 41 38 21  0
#                                                       ^^^^^^
# cs(上下文切换)38 万/秒,sy(系统态 CPU)38%

38% 的 CPU 被烧在了进程调度上。PHP-FPM 的 dynamic 模式在请求量波动时会疯狂 fork/reap 子进程,每个子进程都有自己的 opcache 共享内存映射和 TLB,进程数一多,L1/L2 缓存彻底失效。你花大价钱买的 64 个物理核,一半在干「把寄存器从 A 进程换到 B 进程」这件事。

修法:static 模式 + 按物理核算子进程 + OPcache 拉满

# /www/server/php/81/etc/php-fpm.d/www.conf

; 别用 dynamic,裸金属上 CPU 是最不缺的资源,缺的是 cache 局部性
pm = static
pm.max_children = 128          ; 物理核数 × 2

; 每个请求处理完立刻回收,避免内存碎片长期累积
pm.max_requests = 2000

; unix socket 比 TCP 快,但 backlog 是内核参数在管
listen = /tmp/php-cgi-81.sock
listen.backlog = 65535
listen.owner = www
listen.group = www
listen.mode = 0660

; 这块宝塔默认给得很保守
request_terminate_timeout = 100s
rlimit_files = 1048576
rlimit_core = 0

同时把 backlog 拉起来,否则 65535 只是一个愿望:

# /etc/sysctl.d/99-bt-metal.conf
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.ipv4.tcp_syncookies = 1

OPcache 这块,宝塔默认给的是 opcache.memory_consumption=128、opcache.max_accelerated_files=4000。在裸金属上直接吃掉 1~2G 内存毫无压力:

# 在 www.conf 里直接覆盖,或者改 php.ini
php_admin_value[opcache.memory_consumption] = 2048
php_admin_value[opcache.interned_strings_buffer] = 128
php_admin_value[opcache.max_accelerated_files] = 65536
php_admin_value[opcache.jit] = tracing
php_admin_value[opcache.jit_buffer_size] = 256M
php_admin_value[opcache.validate_timestamps] = 0

validate_timestamps=0 在生产环境是必须的,代价是改代码要 reload php-fpm。宝塔的「防跨站攻击(open_basedir)」如果你不需要,也在站点配置里关掉——它每次请求都要走一遍路径前缀匹配,高并发下是实打实的开销。

还有一处宝塔的坑:/etc/init.d/php-fpm-81 这类 init 脚本里写死了 ulimit -SHn 65535。如果你走 systemd 托管,limits.conf 是不生效的,得写 override:

[Service]
LimitNOFILE=1048576
LimitNPROC=65535

四、被忽略的宝塔面板自己:单进程 Tornado + SQLite 监控库

机器调好了,还有个东西在偷偷吃资源。

宝塔面板的 BTPanel 服务是 单进程 Python Tornado。开了「监控」功能之后,它每 30 秒采集一次系统指标,写进 SQLite:

ls -lh /www/server/panel/data/system.db
# 站点多一点、跑几个月之后,这个文件能涨到几个 G

sqlite3 /www/server/panel/data/system.db "PRAGMA page_count; PRAGMA page_size;"
# 算一下实际占用,再乘以写放大

strace -c -p $(pgrep -f BTPanel) -f -e trace=write,fsync 2>&1 | head -20

SQLite 在 WAL 模式下,监控数据每 30 秒一次写入,单次事务不大,但 站点数上到几百之后,采集本身要遍历 /proc 下几千个进程,单线程 Python 走 psutil,一次采集能吃掉大半个月核心,而且卡住的时候整个面板都点不动。

处理办法很直接:

  • 生产机器上直接关掉面板的「监控」开关,把数据交给 Prometheus + node_exporter 去做
  • 如果确实要用面板看流量,把采集周期从 30s 调到 300s
  • 定期 VACUUM 一次 system.db,或者直接 archive 掉历史表
# 定期清理监控历史(保留 7 天)
sqlite3 /www/server/panel/data/system.db "
DELETE FROM system_stat WHERE add_time < strftime('%s','now') - 7*86400;
VACUUM;"

另外,宝塔的 /etc/init.d/bt 管理的是面板主进程,任务队列 BT-Task 是另一个进程。计划任务多的时候,这两个进程加上 cron,会形成一小撮常驻的 CPU 尖刺。在 64 核机器上不算什么,但如果你在面板里看的是「整体使用率」,这些尖刺会掩盖真实业务负载。

五、一份可以直接抄的裸金属 + 宝塔调优清单

按执行顺序列一遍,都是上面验证过有效的东西:

1. 内核层

# /etc/sysctl.d/99-bt-metal.conf
kernel.numa_balancing = 0
vm.zone_reclaim_mode = 0
vm.swappiness = 1
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_tw_reuse = 1
net.ipv4.ip_local_port_range = 10000 65000
fs.file-max = 2097152
fs.inotify.max_user_watches = 1048576

sysctl -p /etc/sysctl.d/99-bt-metal.conf

2. 网卡层

ethtool -L eth0 combined 32
ethtool -G eth0 rx 4096 tx 4096
ethtool -K eth0 gro on gso on tso on
# 中断亲和,把每个 RX 队列钉到对应 NUMA 节点的核上
for i in $(grep eth0 /proc/interrupts | awk -F: '{print $1}'); do
  echo $(($i % 64)) > /proc/irq/$i/smp_affinity_list
done

3. nginx 层

  • worker_processes = 物理核数,不是线程数
  • worker_connections 65535,worker_rlimit_nofile 必需
  • listen 加 reuseport backlog=65535
  • 由 numactl --interleave=all 启动,且用 systemd override 钉住
  • 静态资源站点关掉 access_log,或者 buffer 写日志

4. php-fpm 层

  • pm = static,max_children = 物理核数 × 2(IO 密集可以再上调)
  • pm.max_requests = 2000
  • unix socket + listen.backlog = 65535
  • OPcache 给到 2G,validate_timestamps=0
  • 检查 init 脚本或 systemd 的 ulimit,别被 65535 卡住

5. 面板层

  • 关掉「监控」开关,换 Prometheus
  • 定期 VACUUM system.db
  • 面板本身别暴露公网,502 个站也别指望它做运维入口

最后说个反直觉的点:裸金属不是「更大的 VPS」。VPS 上你感受不到 NUMA,因为宿主给你的通常就是一个节点内的一小块资源;裸金属给了你完整的拓扑,也把「谁来管这个拓扑」的责任交回给了你。宝塔面板做的是「让所有人都能开箱即用」,它的默认值必须向下兼容一台 2G 内存的小鸡,所以它永远不会替你做上面这些事。

这套参数在我们手上几十台双路裸金属上跑了大半年,没有出现过因为上下文切换导致的长尾。真正要盯的不是峰值 QPS,是 vmstat 里的 cs 和 sy——这两个数降下来,P99 自然就听话了。