Nginx作为最流行的开源Web服务器之一,其日志模块承担着访问记录、错误追踪、流量分析等重要职责。在中小规模场景下,日志写入的开销几乎可以忽略,但当你把Nginx部署到一台几十核甚至上百核的多路服务器上时,事情就变得复杂了。这类服务器普遍采用NUMA(非统一内存访问)架构,CPU被划分为多个节点,每个节点拥有自己的本地内存。如果Nginx的worker进程在节点0上运行,而日志缓冲区却分配在节点1的内存上,每次写日志都要跨节点访问内存,延迟会显著增加。这就是所谓的NUMA远端访问问题,也是很多高并发场景下日志写入吞吐上不去的隐形原因。本文将系统讲解如何针对Nginx日志写入做NUMA亲和性优化。

一、理解NUMA架构与日志写入的关联
NUMA架构的核心思想是让每个CPU插槽(Socket)管理一块本地内存,CPU访问本地内存的速度远快于访问其他节点上的远端内存。以一台双路服务器为例,节点0包含CPU 0-31和一部分内存,节点1包含CPU 32-63和另一部分内存。CPU访问本节点内存大约需要100纳秒左右,而跨节点访问则可能达到200纳秒以上,延迟翻倍还会占用跨节点互连总线带宽。
Nginx的日志写入链路涉及多个内存操作:首先worker进程在处理请求时会将日志内容格式化到用户态缓冲区,然后通过write系统调用写入文件。内核侧的Page Cache页分配、文件缓冲块的分配,都会遵循当前进程所在NUMA节点的内存策略。如果进程频繁在节点之间迁移,缓冲区会散落在不同节点上,造成大量远端内存访问。此外,如果日志文件所在的磁盘控制器挂在某一个节点的PCIe通道上,跨节点提交IO也会带来额外开销。
你可以通过以下命令查看系统的NUMA拓扑,确认节点数量以及每个节点包含的CPU:
# 查看NUMA节点信息
numactl --hardware
# 查看每个节点的内存使用情况
numastat -m
# 查看Nginx进程分布在哪些节点上
numastat -p $(pidof nginx | awk '{print $1}')如果发现worker进程的内存大部分标记为其他节点的远端访问,就说明存在明显的NUMA不亲和问题,值得做针对性优化。
二、通过进程绑定实现CPU与内存亲和
最直接的优化手段是让Nginx的worker进程固定运行在特定的NUMA节点上,这样内存分配器会优先从本地节点拿内存。Nginx原生提供了worker_cpu_affinity指令,可以将worker与CPU核心一一绑定。假设服务器有2个NUMA节点、每个节点16个核心,开了8个worker,可以这样配置:
worker_processes 8;
# 将前4个worker绑定到节点0的CPU,后4个绑定到节点1
worker_cpu_affinity 00000000000000000000000000000001 00000000000000000000000000000010
00000000000000000000000000000100 00000000000000000000000000001000
00000000000000010000000000000000 00000000000000000000000000100000
00000000000000001000000000000000 00000000000000010000000000000000;绑定的核心原则是让每个节点的CPU上都有worker在跑,负载大致均衡,避免所有worker都挤在同一个节点上导致另一个节点的内存和IO资源闲置。需要注意的是,如果开启了Hyper-Threading,建议只绑定物理核心,逻辑核心留给软中断等内核任务。
除了配置文件内的绑定,还可以在启动层面用numactl指定内存策略。例如强制所有内存都从节点0分配:
# 使用numactl启动Nginx,内存优先从本地节点分配 numactl --cpunodebind=0 --membind=0 nginx # 或者更推荐的方式:优先本地,不足时允许跨节点 numactl --preferred=0 nginx
--membind是严格绑定,本地内存不足时会直接分配失败;--preferred则是尽力而为,更稳妥。对于日志这种持续写入的场景,只要保证了进程在节点上的稳定运行,大部分内存自然落在本地,因此--preferred通常已经够用。优化后再次执行numastat观察,如果远端访问计数占比降到百分之十以下,说明亲和性已经比较理想。
三、日志盘IO路径与文件系统的NUMA优化
进程绑定了节点只是第一步,IO路径同样要考虑亲和性。每个物理设备的PCIe控制器挂载在特定NUMA节点下,跨节点提交磁盘IO会增加延迟并消耗互连带宽。可以用下面的命令确认日志盘挂在哪个节点:
# 查看磁盘的NUMA亲和节点 cat /sys/block/sdb/device/numa_node # 如果输出-1,说明内核没有给出明确归属,可以手动设置 echo 0 > /sys/block/sdb/device/numa_node
拿到归属信息后,规划原则很朴素:尽量让写入日志盘的worker运行在与该盘同节点的CPU上。如果条件允许,可以为不同的NUMA节点各配一块日志盘,并通过Nginx多实例或者按节点划分日志文件的方式,让每个节点写自己本地的盘,彻底消除跨节点IO。
文件系统层面的优化同样重要。日志是典型的顺序追加写入,建议使用ext4或xfs并将挂载选项调整为适合日志的模式:
# xfs针对顺序大块写入的挂载参数 mount -t xfs -o noatime,nodiratime,logbsize=256k /dev/sdb1 /var/log/nginx
同时调整内核的脏页回写参数,让日志数据以较大的批量异步落盘,减少write系统调用的阻塞时间:
# 增大脏页比例和回写间隔,提升写入吞吐 sysctl -w vm.dirty_ratio=20 sysctl -w vm.dirty_background_ratio=10 sysctl -w vm.dirty_expire_centisecs=3000
另一个值得考虑的方案是缓冲日志。Nginx的access_log指令支持buffer参数,在用户态先攒够一批再写,配合flush参数控制最长落盘间隔:
# 64KB缓冲区,最多缓存30秒 access_log /var/log/nginx/access.log main buffer=64k flush=30s;
缓冲机制大幅减少了write系统调用的次数,也就减少了跨NUMA内存拷贝的发生频率,是性价比非常高的优化项。
四、效果验证与常见误区
优化完成后需要用数据说话。推荐使用sysbench或者wrk压测工具对比优化前后的表现,重点关注两个指标:一是numastat输出中的numa_miss和numa_foreign计数是否明显下降,二是Nginx每秒处理的请求数与日志写入延迟的变化。一个简单的对比脚本如下:
# 压测前记录NUMA统计基线 numastat > /tmp/before.txt # 执行压测 wrk -t8 -c1000 -d60s http://127.0.0.1/ # 压测后对比 numastat > /tmp/after.txt diff /tmp/before.txt /tmp/after.txt
实践中常见的误区有几个需要提醒。第一,盲目开启numactl --interleave=all把内存均匀打散到所有节点,这对日志写入这种高频率内存操作是负优化,应优先保证本地分配。第二,过度绑定CPU反而伤害性能,比如把所有worker绑到同一个节点的少数核心上,会造成本地内存耗尽和其他节点资源浪费。第三,如果系统同时开启了irqbalance服务,网卡中断可能被调度到任意节点,最好手动将网卡中断亲和到与worker相同的节点,保证请求接收和日志写入都在本地完成。NUMA优化不是孤立操作,只有把进程、内存、IO三者统一规划到同一节点,日志写入的性能提升才能完全释放出来。