如何在香港服务器的 Windows 环境中配置 NUMA-aware 的 SQL 实例,避免跨内存节点延迟?

凌晨两点,我站在将军澳机房的过道里,手里捏着一杯便利店咖啡。空调送风从机柜侧板的孔洞里呼呼直吹,耳边是风扇和盘阵马达持续的白噪音。我们这个客户的业务是跨境电商,高峰期集中在北京时间白天(香港同区),晚高峰的 P99 延迟 时不时从 40ms 飙到 120ms。
我第一眼就怀疑是 NUMA 跨节点访问在捣鬼:两个插槽、NVMe 分布在不同根复杂(Root Complex),单实例 SQL 吃满了整机的 CPU 和内存,但 Buffer Pool 跨节点拿页,再叠加并行度带来的线程迁移,局部性全被打散。
这一晚我做的事很简单:把一台“大汉堡”拆成两个“半汉堡” —— 在同一台服务器上跑 两个 SQL 实例,每个实例只绑定一个 NUMA 节点,内存和 CPU 都“就近取材”,把跨节点延迟从源头掐掉。下面就是那一晚我从确认、设计到落地、验收的完整过程。
1. 现场硬件与版本信息(可对照你的环境做差异化调整)
| 项目 | 配置(本次实操) | 说明 |
|---|---|---|
| 机型 | HPE ProLiant DL360 Gen10 | 1U,双路 |
| CPU | 2× Intel Xeon Gold 6230R(26C/52T,2.1GHz) | 共 52C/104T |
| 内存 | 384GB(12×32GB DDR4-2933),两路均衡插条 | 确认条目均衡很重要 |
| 存储 | 4× U.2 NVMe(槽位 1–2 走 CPU0,3–4 走 CPU1) | 直连背板;对应不同根复杂 |
| 网络 | 2× 10GbE(各接不同 CPU 的 PCIe) | 便于做 RSS 与拓扑亲和 |
| OS | Windows Server 2022 Datacenter | 高性能电源策略 |
| SQL | SQL Server 2019/2022 Enterprise | 本文 T-SQL 兼容二者 |
| 机房 | 香港(将军澳) | 往返内地延迟稳定 |
小贴士:不确定 NVMe/网卡到底“贴”哪个 CPU?用厂商主板拓扑图 + coreinfo -n(Sysinternals)辅助判断。
2. 诊断与基线:先证明“病因”是跨节点
2.1 Windows/硬件层确认 NUMA 拓扑
# PowerShell:看 NUMA 节点与 CPU 组
Get-CimInstance Win32_NumaNode | Select NumaNodeNumber,Status
Get-CimInstance Win32_Processor | Select DeviceID,NumberOfCores,NumberOfLogicalProcessors,SocketDesignation
# Sysinternals(自行下载):映射 CPU 与 NUMA
coreinfo.exe -n # 展示每个逻辑处理器所在的 NUMA 节点
2.2 SQL 层确认节点与调度器分布
-- NUMA 节点、内存节点、在线调度器掩码
SELECT node_id, memory_node_id, processor_group, online_scheduler_mask, online_scheduler_count
FROM sys.dm_os_nodes
WHERE node_state_desc = 'ONLINE';
-- 每个节点上的 scheduler
SELECT scheduler_id, cpu_id, is_online, is_idle, parent_node_id, processor_group
FROM sys.dm_os_schedulers
WHERE status = 'VISIBLE ONLINE' AND is_online = 1;
-- 每个内存节点的本地/外来提交内存(单位 KB)
SELECT memory_node_id, foreign_committed_kb, foreign_committed_kb*1.0/NULLIF( (foreign_committed_kb+local_committed_kb),0 ) AS foreign_ratio,
local_committed_kb, target_kb
FROM sys.dm_os_memory_nodes
ORDER BY memory_node_id;
判定要点:
- 如果 foreign_committed_kb 比例偏高(>5% 甚至 10%),而你的实例吃满两个 NUMA,基本就能锁定“跨节点拿页”问题。
- sys.dm_os_wait_stats 里若 SOS_SCHEDULER_YIELD、CXCONSUMER/CXPACKET 与 CPU 高波动同时出现,也可能是并行度+跨节点联动。
本次现场基线(简化取数):
| 指标 | 节点0 | 节点1 | 备注 |
|---|---|---|---|
| local_committed_kb | 141,312,000 | 130,048,000 | 大约 270GB |
| foreign_committed_kb | 15,872,000 | 17,408,000 | 接近 11–12%(偏高) |
| 平峰 P95 / P99 | 22ms / 40ms | — | 应用侧 APM 统计 |
| 峰值 P95 / P99 | 60ms / 120ms | — | 争用期拉高 |
3. 方案设计:一机两实例,每实例固绑一个 NUMA
为什么不是单实例 + CPU/IO 亲和?
- 单实例把 Buffer Pool 混在一起,热点与冷页跨节点更难“干净”隔离。
- 两实例各自有独立的 Buffer Pool 与计划缓存,更容易做到局部性和内存稳定。
- 对开发/运维清晰:按业务维度分库分实例,也减少“同城互殴”。
设计要点
- 实例划分:MSSQLSERVER(默认实例)→ 绑定 NUMA 节点 0;SQLN1(命名实例)→ 绑定 NUMA 节点 1。
- CPU/进程亲和:用 NUMA 级别亲和而非逐核,避免 CPU 组(>64 逻辑核)复杂度。
- 内存:max server memory 对半分配并预留 OS/FS 缓冲与 Agent、备份进程。
- 存储:尽量让每个实例的 tempdb/日志/热点数据盘落在“近端” NVMe。
- 网络:给每个实例绑定独立 IP 与各自的 10GbE,做 RSS(Receive Side Scaling)亲和到对应 NUMA。
- 并行度:MAXDOP 不超过单个 NUMA 的物理核数(或略低),cost threshold for parallelism 合理抬高(例如 50–80 起)。
4. 落地步骤(可直接套用/改造)
4.1 BIOS/固件(只列关键项)
- 确认 NUMA Enabled;不要开启 SNC(Sub-NUMA Cluster),除非你计划“每路再劈两半”。
- 内存条两路均衡;电源策略设为 Performance;C-States 依业务酌情(低延迟场景可关)。
- 升级 NVMe/HBA/NIC 固件到厂商 LTS 版本。
4.2 Windows 电源与 NIC RSS
# 高性能电源方案
powercfg -setactive SCHEME_MIN
# 以两块网卡为例,把 NIC1 亲和到 NUMA0,NIC2 亲和到 NUMA1
# 注意 BaseProcessorNumber/MaxProcessorNumber 需要根据 CPU 组/核数调整
Get-NetAdapterRss -Name "NIC1","NIC2"
Set-NetAdapterRss -Name "NIC1" -BaseProcessorNumber 0 -MaxProcessorNumber 15 -BaseProcessorGroup 0
Set-NetAdapterRss -Name "NIC2" -BaseProcessorNumber 0 -MaxProcessorNumber 15 -BaseProcessorGroup 1
Restart-NetAdapter -Name "NIC1","NIC2"
经验:RSS 亲和虽然不直接改变 SQL 线程调度,但网络中断和数据路径贴近本地节点,避免额外跨节点抖动,尤其你有大量小包 RPC/结果集回传时很有用。
4.3 安装第二个 SQL 实例并分配端口/IP
SQL Server Configuration Manager → 为两个实例分别绑定 不同的专用 IP 与固定端口(譬如 10.10.10.11:1433 / 10.10.10.12:1435)。
Windows 防火墙放行对应端口,仅允许业务段访问。
4.4 为每个实例配置 NUMA 亲和
注意:我们使用“按 NUMA 节点”的亲和,避免在 >64 逻辑处理器的系统里折腾 CPU 组语法。
在默认实例(绑定节点 0)执行:
ALTER SERVER CONFIGURATION
SET PROCESS AFFINITY NUMANODE = (0); -- 只使用节点0
-- 可选:I/O 亲和,如果你的盘确实物理贴近节点0
ALTER SERVER CONFIGURATION
SET IO AFFINITY NUMANODE = (0);
在命名实例 SQLN1(绑定节点 1)执行:
ALTER SERVER CONFIGURATION
SET PROCESS AFFINITY NUMANODE = (1);
ALTER SERVER CONFIGURATION
SET IO AFFINITY NUMANODE = (1);
确认:
SELECT node_id, online_scheduler_mask, online_scheduler_count, processor_group
FROM sys.dm_os_nodes WHERE node_state_desc='ONLINE';
SELECT scheduler_id, parent_node_id, is_online
FROM sys.dm_os_schedulers
WHERE status='VISIBLE ONLINE'
ORDER BY parent_node_id, scheduler_id;
4.5 内存上限与 LPIM
容量规划示例(本机 384GB):
- 预留 OS/驱动/备份/监控:~32–48GB
- 剩余约 336–352GB → 两实例 各 160GB(保留回旋空间)
在两个实例分别执行:
EXEC sp_configure 'show advanced options', 1; RECONFIGURE;
EXEC sp_configure 'max server memory (MB)', 160000; RECONFIGURE;
启用 Lock Pages in Memory (LPIM):
把 SQL Server 服务账号加入本地安全策略 “锁定内存中的页”,重启服务后 sys.dm_os_process_memory 中 locked_page_allocations_kb 应该 > 0。
4.6 tempdb 与数据/日志的“就近”落盘
把槽位 1–2(属于 CPU0)的 NVMe 做成卷 E:,只给默认实例使用;槽位 3–4(CPU1)做 F: 给命名实例。
tempdb 文件数 ≈ 在线 CPU 数(每实例)/ 2,最大不超过 8–12;每个文件初始/增长相同,避免 GAM/SGAM 热点。
-- 以绑定节点0的实例为例
USE master;
ALTER DATABASE tempdb MODIFY FILE (NAME='tempdev', FILENAME='E:\MSSQL\TempDB\tempdb.mdf', SIZE=4096MB, FILEGROWTH=512MB);
-- 新增多个数据文件
ALTER DATABASE tempdb ADD FILE (NAME='tempdb2', FILENAME='E:\MSSQL\TempDB\tempdb2.ndf', SIZE=4096MB, FILEGROWTH=512MB);
-- 视 CPU 而定继续增加...
ALTER DATABASE tempdb MODIFY FILE (NAME='templog', FILENAME='E:\MSSQL\TempDB\templog.ldf', SIZE=2048MB, FILEGROWTH=256MB);
经验:如果你用的是阵列卡后端而非直连 NVMe,I/O 亲和的收益会弱一些,但仍建议在卷/控制器层面尽量“各玩各的”。
4.7 并行度与代价阈值
-- 每个实例分别设置
EXEC sp_configure 'show advanced options', 1; RECONFIGURE;
-- 单个 NUMA 有 26 物理核,这里取 20–24 更保守
EXEC sp_configure 'max degree of parallelism', 20; RECONFIGURE;
-- 抬高并行代价阈值,减少盲目并行
EXEC sp_configure 'cost threshold for parallelism', 70; RECONFIGURE;
4.8 连接路由与应用侧改造(关键但常被忽视)
把 订单库/结算库落到实例 A(节点0),商品/检索落到实例 B(节点1);
严禁跨实例 JOIN(必要时通过中间表/ETL 同步);
应用配置两个独立连接串,按业务域路由;
如果是微服务,直接在服务间解耦,减少跨域调用。
5. 验收与对比:用数据说话
5.1 SQL DMV:外来提交内存显著下降
改造后一小时采样(节选):
| 指标 | 节点0(实例A) | 节点1(实例B) | 备注 |
|---|---|---|---|
| local_committed_kb | 163,840,000 | 158,720,000 | 各自 160GB 左右 |
| foreign_committed_kb | 1,024,000 | 1,280,000 | <1%,基本消除了跨节点 |
| target_kb | ≈ 167,000,000 | ≈ 167,000,000 | 与 max server memory 接近 |
5.2 应用延迟(APM)与等待类型
| 时间窗 | P95 | P99 | 主要等待类型(Top 3) |
|---|---|---|---|
| 改造前(高峰) | 60ms | 120ms | CXCONSUMER、SOS_SCHEDULER_YIELD、PAGEIOLATCH_SH |
| 改造后(高峰) | 28ms | 52ms | CXCONSUMER、LATCH_SH、WRITELOG |
解释:PAGEIOLATCH_SH 大幅下降说明热页更“就地”命中,I/O 压力更均匀;SOS_SCHEDULER_YIELD 收敛,线程迁移减少。
5.3 额外观测(PerfMon 可选)
- SQLServer:Buffer Node(*)\Local node page lookups/sec ↑
- SQLServer:Buffer Node(*)\Remote node page lookups/sec ↓
- Process(sqlservr)\% Processor Time 两实例各自稳定在 35–60% 区间
- SQLServer:Databases(*)\Log Flushes/sec 两实例各自平稳,无共振
6. 这一路踩过的坑 & 解决过程
CPU 组(Processor Groups)
104 逻辑线程意味着 Windows 会拆成两个 CPU 组。起初我尝试用“逐核”亲和,结果写法一不留神就把两个组的编号混了。改法:改用 PROCESS AFFINITY NUMANODE=,SQL 自己处理组与调度器,稳定。
忘了给 OS 留内存
第一次把两实例各设 170GB,Server 备份窗口 + 防毒扫描时 OS 出现内存压力。改法:实例各 160GB,且将备份任务分时错峰,问题消失。
tempdb 文件增长不一致
之前把几个 ndf 的初始/增长配得不齐,监控里发现 GAM/SGAM 等待抖一下就高。改法:统一大小与增长,且提前预分配到足够空间。
网卡 RSS 没重启
Set-NetAdapterRss 之后忘了重启网卡,亲和未生效。改法:记得 Restart-NetAdapter,或维护窗口直接 Restart-Computer。
应用仍跨实例 JOIN
某个历史报表 SQL 没迁移,跨实例通过 Linked Server JOIN,立刻看见外来内存上升。改法:当天就把报表拆成两步(落地+汇总),并给研发加了 lint 规则。
7. 可复用的核查脚本(拿去即用)
7.1 一次性拉齐 NUMA 与调度器视图
;WITH N AS (
SELECT node_id, memory_node_id, processor_group, online_scheduler_count
FROM sys.dm_os_nodes WHERE node_state_desc='ONLINE'
),
S AS (
SELECT parent_node_id AS node_id, COUNT(*) AS schedulers
FROM sys.dm_os_schedulers
WHERE status='VISIBLE ONLINE' AND is_online=1
GROUP BY parent_node_id
),
M AS (
SELECT memory_node_id, local_committed_kb, foreign_committed_kb, target_kb
FROM sys.dm_os_memory_nodes
)
SELECT N.node_id, N.processor_group, N.online_scheduler_count, S.schedulers,
M.local_committed_kb, M.foreign_committed_kb,
CAST(100.0*foreign_committed_kb/NULLIF(local_committed_kb+foreign_committed_kb,0) AS DECIMAL(5,2)) AS foreign_pct,
M.target_kb
FROM N
LEFT JOIN S ON S.node_id = N.node_id
LEFT JOIN M ON M.memory_node_id = N.memory_node_id
ORDER BY N.node_id;
7.2 实例亲和与并行度统一设置(模板)
-- 选择性执行:把当前实例绑定到指定 NUMA 节点,并设置并行策略
DECLARE @NumaTarget INT = 0; -- 修改为 0 或 1
DECLARE @MaxDOP INT = 20; -- 按单节点物理核数或略低
DECLARE @CTP INT = 70; -- cost threshold
ALTER SERVER CONFIGURATION SET PROCESS AFFINITY NUMANODE = (@NumaTarget);
ALTER SERVER CONFIGURATION SET IO AFFINITY NUMANODE = (@NumaTarget);
EXEC sp_configure 'show advanced options', 1; RECONFIGURE;
EXEC sp_configure 'max degree of parallelism', @MaxDOP; RECONFIGURE;
EXEC sp_configure 'cost threshold for parallelism', @CTP; RECONFIGURE;
8. 什么时候不该“一机两实例”?
你需要单实例内做大量跨库 JOIN 或强一致事务;
内存总量过小,还要被切成两份;
存储/网络无法做到相对“就近”,两个实例反而互相抢;
你的瓶颈根本在 磁盘吞吐 或 执行计划/索引,NUMA 只会“锦上添花”。
9. 备选策略:仍坚持单实例?也能做到“更 NUMA-friendly”
只使用一个 NUMA 节点的 CPU(PROCESS AFFINITY NUMANODE=(0)),但把另一个节点内存给 OS/缓存或其它服务。
精细化 MAXDOP、用 Resource Governor 把某些报表型工作负载限在特定调度器组(避免打扰 TP)。
检查查询与索引,减少大范围 Hash Join/Sort 的跨节点搬运;对热点表用 分区+对齐(避免并行跨节点把大表扫两遍)。
结尾:把“不确定性”压到最低
那晚 4 点多走出机房,我把 APM 的趋势图截了个屏发到群里——晚高峰 P99 从 120ms 掉到 52ms,队友在群里刷了一排“牛”。
NUMA 并不是黑魔法,它只是要求你尊重“距离”:CPU 离内存、网卡、NVMe 的距离,线程离数据页的距离,实例离业务域的距离。
当你把这些距离一个个缩短,系统就会回到本该有的形态——稳定、可预期、好维护。
如果你也在香港的机房里被深夜的 P99 折腾,不妨把这套流程走一遍:确认拓扑 → 两实例固绑 → 内存与 I/O 就近 → 并行与网络收敛 → 应用分域路由 → DMV 验证。
等你看见 foreign_committed_kb 乖乖降到 1% 以内,心里那口气,也就顺了。