01 · 开篇:一次查不出的 P0 事故

“你以为你在读内存。其实你在走一段总线。“


事故现场

凌晨 3:14,小林的手机炸了。

告警:stream-compute-07 P99 延迟 900ms(阈值 50ms),持续 40s 后自动恢复。

小林爬起来打开 Grafana。这是他写的流式计算服务,Go 1.21,8 台机器,每台 64 核 256G,处理上游 Kafka 的实时事件流。日常 QPS 12 万,P99 稳定在 8ms。

他看了监控面板:

指标异常?
CPU 使用率62%,正常
内存使用率71%,正常
GC 暂停最大 1.2ms,正常
Goroutine 数量3.4 万,正常
网络吞吐正常
磁盘 IO几乎为零

全都正常。

更诡异的是:故障持续 40 秒后自己恢复了,一周内出现了三次,每次都是不同机器,每次都是 30~60 秒。

小林查了一周:

  • 翻 Go 代码,看有没有锁竞争 → 没有
  • 打 pprof,go tool pprof -http 看了 CPU profile、heap profile、block profile → 全是平的
  • 看 Kafka 消费延迟 → 没有堆积
  • 怀疑是上游数据突增 → 查了 QPS,平稳
  • 怀疑是混部机器的邻居吵闹 → 运维说这批机器是独占的

一筹莫展。

周四下午,一个快退休的老架构师路过他工位,扫了一眼监控,说了一句:

“你这批机器是不是双路的?试试 numactl --interleave=all 绑一下,或者在 BIOS 里把 Node Interleaving 打开。”

小林半信半疑地试了。

一周没再出现。

小林懵了。他完全不知道发生了什么。


第一层追问:NUMA 是什么

小林找到了老陈。

小林:陈哥,NUMA 到底是啥?我听说过这词,但一直以为是个跟普通应用开发无关的东西。

老陈:先别问 NUMA 是什么。我问你个更基本的问题——你的 Go 程序里,make([]byte, 1024) 这行代码执行完,这 1024 字节在哪?

小林:在堆上啊。

老陈:“堆”是什么?

小林:……就是进程地址空间里那块动态分配的区域?

老陈:“地址空间”又是什么?它是真实存在的东西吗,还是只是一个抽象?

小林:……应该是抽象吧,背后是物理内存。

老陈:那我问你:地址 0x7f8e4c000000 这个虚拟地址,对应的物理内存在哪根内存条上?

小林:……我从来没想过这个问题。我以为内存是”均匀地”可用的。

老陈:这就是你这一周查不出来的原因。


正解拆解:内存不是一整块

1. 你以为的内存模型

大多数程序员脑子里的内存是这样的:

┌────────────────────────────┐
│      一整块内存 256 GB      │
│   任何 CPU 核心访问它      │
│   速度都是一样的           │
└────────────────────────────┘
     ↑
   所有 CPU 核心

这个模型在 2005 年之前基本成立。现在完全错误。

2. 真实的内存模型(双路服务器)

小林的机器是双路 Xeon,两颗 CPU,每颗 32 核,每颗 CPU 直连 128GB 内存:

   ┌──────────────┐                    ┌──────────────┐
   │   CPU Node 0 │                    │   CPU Node 1 │
   │   32 核      │                    │   32 核      │
   │  L1/L2/L3    │                    │  L1/L2/L3    │
   └──────┬───────┘                    └───────┬──────┘
          │ 内存控制器                          │ 内存控制器
          │                                    │
   ┌──────▼───────┐                    ┌───────▼──────┐
   │  本地内存     │                    │   本地内存    │
   │  128 GB      │                    │   128 GB     │
   └──────────────┘                    └──────────────┘
          │                                    │
          └──────────── QPI / UPI ─────────────┘
               (跨节点互连,带宽有限,延迟高)

关键点:

  • Node 0 的 CPU 访问 Node 0 的内存:走本地内存控制器,延迟约 80~100 ns
  • Node 0 的 CPU 访问 Node 1 的内存:要走 QPI/UPI 总线,绕到对面 CPU 的内存控制器,延迟约 140~180 ns

差了将近一倍。

3. 为什么会出事故

Linux 默认的内存分配策略是 local allocation(本地优先):

进程首次触碰(touch)一个虚拟页时,内核在当前正在运行的那个 CPU 所属的节点上分配物理页。

现在看小林的服务是怎么跑的:

阶段一:服务启动,一个 goroutine 在主线程上初始化了一个巨大的查找表(比如 40GB 的用户特征缓存)。这个表被分配在 Node 0 上。

阶段二:启动 2000 个 worker goroutine 处理数据流。

阶段三:Go 调度器把这 2000 个 goroutine 分散到 64 个 OS 线程上,跑在 64 个核上。其中一半的核属于 Node 1。

阶段四:Node 1 上的 goroutine 要访问那张 40GB 的表 → 每一次访问都要跨 QPI 总线。

阶段五:QPI 总线带宽被打满 → 所有跨节点访问排队 → 延迟暴涨到 900ms。

为什么 40 秒后自己恢复? 因为 Linux 内核有个叫 NUMA Balancing 的机制:内核会周期性(默认每秒若干次)地把”被远端 CPU 频繁访问的页”迁移到访问者所在的节点。迁移完,延迟就降下来了。

所以: 故障是”周期性的性能悬崖 + 内核自愈”,监控上看 CPU 内存全正常,因为瓶颈在总线上,而总线利用率不在常规监控里。


第二层追问:为什么 Go 特别容易踩这个坑

小林:那 C++ 或者 Java 的服务不也一样吗?

老陈:一样,但 Go 有三点让它更容易踩,而且踩得更隐蔽。你说说看?

小林:……goroutine 太轻量,容易起几千个,然后到处跑?

老陈:对,这是第一点。继续。

小林:Go 的内存分配器会预先向 OS 申请大块内存自己管理,所以……分配行为跟 OS 的交互模式不太一样?

老陈:很好,这是第二点。还有第三点,跟 GC 有关。

小林:GC 会遍历整个堆?

老陈:对。想想看:一个 40GB 的堆,GC 的标记阶段要遍历所有对象。如果这些对象分布在两个 NUMA 节点上,而 GC 的工作线程只有 8 个——这意味着什么?

小林:……意味着一定有一半的 GC 线程在做跨节点访问。

老陈:不止。更麻烦的是:Go 的 GC workers 跟你的业务 goroutine 抢的是同一批 P(Processor),而 P 是被调度器动态分配的。你根本控制不了 GC 线程跑在哪个节点上。 所以 NUMA 对 Go 的影响,是叠加在 GC 停顿之上的”隐性放大器”。

Go 的三个放大效应

因素说明
① goroutine 极度轻量一个服务几千个 goroutine 是常态,它们在 64 个核上被调度器来回搬运。一个 goroutine 在 Node 0 分配了对象,下一毫秒可能就被调度到 Node 1 的核上执行
② Go 自己管内存Go runtime 用 mmap 一次性向 OS 要大块(heap arena,64MB 起),之后自己切分。这意味着物理页的 NUMA 归属在 mmap 那一刻就定了,后面 runtime 内部的分配完全不知道 NUMA 的存在
③ GC 全堆扫描标记阶段要遍历整个堆。堆跨节点 = 必然有跨节点访问。而且这部分开销不体现在你的业务代码里

老陈:这就是为什么我说”Go 程序员特别需要懂底层”。Go 帮你屏蔽了太多东西,屏蔽得你连自己踩了坑都不知道。


动手实验一:亲眼看到内存的层次

在讲更深入的内容前,我们先用一个能跑的程序,把”内存访问速度不均匀”这件事测出来。

实验 1:测量不同大小数据集的访问延迟

package main
 
import (
	"fmt"
	"math/rand"
	"time"
	"unsafe"
)
 
// 构造一个"指针追逐"链表:每个节点指向下一个随机节点
// 这样 CPU 的预取器(prefetcher)无法预测下一次访问,强制真实走内存
type node struct {
	next *node
	pad  [56]byte // 补齐到 64 字节 = 一个缓存行,避免多个节点挤在一行
}
 
func buildChaseList(n int) *node {
	nodes := make([]node, n)
	// 生成随机排列,打乱访问顺序
	perm := rand.Perm(n)
	for i := 0; i < n-1; i++ {
		nodes[perm[i]].next = &nodes[perm[i+1]]
	}
	return &nodes[perm[0]]
}
 
func chase(start *node, iterations int) time.Duration {
	cur := start
	// 防止编译器把整个循环优化掉
	var sink uintptr
 
	t0 := time.Now()
	for i := 0; i < iterations; i++ {
		cur = cur.next
		if cur == nil {
			cur = start
		}
		sink += uintptr(unsafe.Pointer(cur))
	}
	elapsed := time.Since(t0)
 
	// 用 sink 做点无意义的事,阻止编译器消除循环
	if sink == 0 {
		fmt.Println("impossible")
	}
	return elapsed
}
 
func main() {
	fmt.Printf("%-14s %-14s %-12s %s\n", "数据集大小", "节点数", "总耗时", "平均每次访问")
 
	// 从 16KB(能装进 L1)一路测到 512MB(远超 L3,必走主存)
	for _, shift := range []uint{10, 12, 14, 16, 18, 20, 22, 24, 25} {
		n := 1 << shift
		head := buildChaseList(n)
 
		iterations := 2_000_000
		elapsed := chase(head, iterations)
 
		sizeKB := float64(n*64) / 1024
		sizeStr := fmt.Sprintf("%.0f KB", sizeKB)
		if sizeKB >= 1024 {
			sizeStr = fmt.Sprintf("%.0f MB", sizeKB/1024)
		}
 
		nsPerAccess := float64(elapsed.Nanoseconds()) / float64(iterations)
 
		fmt.Printf("%-14s %-14d %-12v %.2f ns\n",
			sizeStr, n, elapsed.Round(time.Millisecond), nsPerAccess)
	}
}

典型输出(Intel Xeon 双路)

数据集大小        节点数          总耗时        平均每次访问
64 KB          1024          6ms           3.01 ns     ← L1 命中
256 KB         4096          7ms           3.52 ns     ← L2 命中
1 MB           16384         12ms          6.11 ns     ← L3 命中
4 MB           65536         28ms          14.2 ns     ← L3 边缘
16 MB          262144        52ms          26.3 ns     ← 主存,本地节点
64 MB          1048576       61ms          30.5 ns     ← 主存,本地节点
256 MB         4194304       68ms          34.2 ns     ← 开始跨节点
1 GB           16777216      89ms          44.6 ns     ← 跨节点比例上升
2 GB           33554432      103ms         51.7 ns     ← 大量跨节点

这张表在说什么

   延迟 (ns)
   60 │                                              ● 51.7  ← 跨 NUMA 节点
   50 │                                        ● 44.6
   40 │                                  ● 34.2
   30 │                    ● 26.3  ● 30.5
   20 │              ● 14.2
   10 │        ● 6.11
    5 │  ● 3.01  ● 3.52
    0 └────┬────┬────┬────┬────┬────┬────┬────┬────┬────
        64K  256K  1M   4M   16M  64M  256M  1G   2G
         └── L1 ─┘└L2─┘└─L3──┘└── 本地主存 ──┘└ 远端 ─┘

从 L1 到远端内存,延迟差了 17 倍。

小林的服务,那张 40GB 的表,一半的访问落在最右边那个区间。

实验 2:验证 NUMA 效应是否存在

如果机器上能装 numactl,可以做对照实验:

# 查看 NUMA 拓扑
numactl --hardware
# 输出示例:
# available: 2 nodes (0-1)
# node 0 cpus: 0 1 2 ... 31
# node 0 size: 128000 MB
# node 1 cpus: 32 33 ... 63
# node 1 size: 128000 MB
# node distances:
# node   0   1
#   0:  10  21      ← 21/10 = 2.1,跨节点代价是本地的 2.1 倍
#   1:  21  10
 
# 实验 A:内存绑定在 Node 0,进程跑在 Node 0(本地访问)
numactl --cpunodebind=0 --membind=0 ./chasetest
 
# 实验 B:内存绑定在 Node 1,进程跑在 Node 0(跨节点访问)
numactl --cpunodebind=0 --membind=1 ./chasetest

对比两次的平均访问延迟,实验 B 通常会高出 40%~90%。

实验 3:在 Go 里观察 goroutine 的迁移

package main
 
import (
	"fmt"
	"os"
	"runtime"
	"sync"
	"syscall"
	"time"
)
 
// getCPU 返回当前线程正在哪个 CPU 核上运行
// Linux 的 sched_getcpu 是 vDSO 调用,非常快,不会陷入内核
func getCPU() (int, error) {
	return syscall.Gettid(), nil // 简化版,实际用 sched_getcpu
}
 
func main() {
	// 使用 runtime.LockOSThread + syscall 观察迁移
	// 这里我们用一个更直接的办法:启动 N 个 goroutine,
	// 每个周期性报告自己所在的 CPU
 
	var wg sync.WaitGroup
	cpuChanges := make(chan [2]int, 10000)
 
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			last := -1
			for j := 0; j < 5000; j++ {
				// 用汇编或 cgo 调用 sched_getcpu
				cpu := currentCPU()
				if last != -1 && cpu != last {
					cpuChanges <- [2]int{last, cpu}
				}
				last = cpu
				// 制造一点工作,防止被调度器闲置
				_ = make([]byte, 1024)
				time.Sleep(time.Microsecond)
			}
		}(i)
	}
 
	go func() {
		wg.Wait()
		close(cpuChanges)
	}()
 
	crossNode := 0
	total := 0
	for ch := range cpuChanges {
		total++
		if ch[0]/32 != ch[1]/32 { // 假设每 32 核一个 NUMA 节点
			crossNode++
		}
	}
 
	fmt.Fprintf(os.Stderr, "总迁移次数: %d, 跨节点迁移: %d (%.1f%%)\n",
		total, crossNode, float64(crossNode)/float64(total)*100)
	fmt.Fprintf(os.Stderr, "GOMAXPROCS = %d, NumCPU = %d\n",
		runtime.GOMAXPROCS(0), runtime.NumCPU())
}

这个实验的意义:让你亲眼看到,goroutine 在 64 个核之间被搬运的频率有多高。每一次跨节点迁移,都意味着这个 goroutine 接下来访问的数据可能全在远端。


第三层追问:更深的问题

老陈:现在你知道 NUMA 了。我问你一个更狠的问题——为什么要有 NUMA?把所有 CPU 都接到同一块内存上不就行了?

小林:……带宽不够?

老陈:对,但”不够”是怎么个不够法?给你数字。

小林:……DDR4-3200 单通道带宽大概 25GB/s?

老陈:双路服务器,每颗 CPU 有 6 个内存通道,6 × 25 = 150 GB/s 每颗。两颗就是 300 GB/s 的本地带宽。而 QPI/UPI 总线带宽呢?

小林:……大概 40 GB/s?

老陈:UPI 3.0 是 10.4 GT/s,双向大约 40~60 GB/s。你算算:本地带宽 300 GB/s,跨节点带宽 50 GB/s。差 6 倍。

小林:所以 NUMA 的本质是……用”不均匀”换”总量”?

老陈:就是这个。NUMA = Non-Uniform Memory Access,“非一致内存访问”。它是一场交易——牺牲访问的均匀性,换取整体带宽的可扩展性。 你不可能在不牺牲带宽的前提下,让 128 个核都等速访问 256GB 内存。总线宽度、信号完整性、功耗,全是物理约束。

小林:所以这是个物理问题,不是软件问题。

老陈:对。计算机系统里所有看起来”不合理”的设计,往底下追三层,都会追到一个物理约束上。 记住这句话,这本书后面你会反复验证它。


更深层的发问(这些没有标准答案)

问题 A:如果让你设计一台 512 核的机器,你怎么组织内存?

已知物理约束:

  • 单颗 CPU 能直连的内存通道数有上限(功耗和引脚数限制)
  • 跨节点互连的带宽增长远慢于核数增长
  • 访问延迟随跳数增加

你需要权衡:

  1. 分几个 NUMA 节点?节点多了本地性好但跨节点更频繁,节点少了带宽不够
  2. 用什么拓扑?全互连 / 环形 / 网格 / 超立方体?
  3. 要不要做硬件级的缓存一致性(ccNUMA)?代价是什么?

老陈的提示:去看看 AMD EPYC 的 Infinity Fabric 和 Intel 的 Mesh 架构,它们给出了两种不同的答案。再想想:为什么现在的趋势是 CXL(Compute Express Link)——它想把”内存”从”CPU 私有的东西”变成”网络上可共享的资源”。这跟分布式系统里的”共享存储”是不是有点像?

问题 B:为什么云计算厂商要卖”绑核”的实例?

AWS、阿里云都有”CPU 绑核”选项,甚至能让你看到 NUMA 拓扑。为什么?

老陈的提示:虚拟化场景下,一个 VM 的 vCPU 可能被调度到宿主机任意物理核上。如果一个 32 vCPU 的 VM 横跨两个 NUMA 节点,而客户跑的是数据库(对延迟极度敏感),会怎样?云厂商提供绑核,本质上是让客户为”确定性”付钱。这引出一个更大的问题:在多租户环境里,“性能隔离”和”资源利用率”是一对永恒的矛盾。

问题 C:Go 的 runtime 应该关心 NUMA 吗?

目前 Go runtime 完全不感知 NUMA。这是一个设计选择还是设计缺陷?

老陈的提示:想想两边。支持 NUMA 的话,runtime 要维护 per-node 的 mcache/mcentral,内存分配要考虑当前 goroutine 在哪个节点,GC 要根据节点划分工作——复杂度会爆炸。而且 Go 的主要战场是容器化的微服务,容器通常被 cgroup 限制在少数几个核上,NUMA 效应不明显。

但反过来:Go 现在越来越多地用在数据库、中间件、AI 基础设施上(TiDB、etcd、KubeEdge 都是 Go 写的),这些场景 NUMA 是真实痛点。

有意思的是,Java 的 HotSpot 从 JDK 14 开始有了 NUMA-aware 的 GC(Shenandoah、ZGC)。Go 会不会跟进?如果让你给 Go runtime 提一个 NUMA 支持的设计提案,你会怎么写?


思考题

题 1 ·【理解层】

小林的服务里,那张 40GB 的查找表如果改成”每个 NUMA 节点各存一份完整副本”,延迟问题就解决了。但这样做有什么代价?在什么条件下这个方案是可行的,什么条件下是灾难?

参考答案

收益:每个节点的 goroutine 都访问本地副本,跨节点访问归零,延迟从 ~150ns 降到 ~85ns,带宽压力从 50GB/s 上限提到 300GB/s。

代价分四项:

  1. 内存占用翻倍:40GB × 2 = 80GB。如果这个服务还有其他大对象,可能直接 OOM。
  2. 一致性维护成本:这个表是只读的吗?如果是只读的(比如用户特征快照,每天更新一次),那太好了,两份副本互不影响,更新时双写即可。如果表是可变的,麻烦就大了——你要么引入跨节点的缓存一致性协议(这本质上是在用户态重新实现硬件 ccNUMA,性能会很糟),要么接受短暂的不一致。
  3. 更新的放大效应:原方案更新一次写 40GB;新方案要么写两次(80GB),要么用”先写 A 再写 B”的双缓冲,这段时间内存峰值是 120GB。
  4. GC 压力:Go 的 GC 要遍历所有存活对象。堆从 40GB 涨到 80GB,标记阶段的工作量翻倍。

可行的条件:

  • 数据是只读或极少更新的(特征表、模型权重、配置快照、路由表)
  • 内存预算充足(副本数 × 单份大小 + GC headroom ≤ 总内存的 60%)
  • 访问模式是读密集(读写比 > 100:1)

灾难的条件:

  • 数据频繁写 → 一致性成本超过延迟收益
  • 内存已经吃紧 → 直接 OOM
  • 单份数据超过单节点内存 → 根本放不下(这时只能分片)

更优的第三种方案:分片 + 本地亲和。不要复制,而是把表按 key 哈希分成 N 片,每片固定绑定到一个节点,然后用调度亲和性让处理某个 key 范围的 goroutine 尽量跑在对应节点的核上。这样内存不翻倍,跨节点访问也大幅减少。这是 TiDB 和很多数据库做的事情。

更深一层:注意”复制”和”分片”是分布式系统的两个基本手段,它们在单机多 NUMA 节点这个尺度上同样适用。这说明 NUMA 机器本质上就是一个小型分布式系统——只不过它的”网络”是 QPI 总线,“网络分区”是节点故障。这个视角,到第 5 章讲 Raft 的时候会非常有用。

题 2 ·【应用层】

你负责一个 Go 写的 Redis 替代方案(内存 KV 存储),部署在双路 64 核机器上,数据量 100GB。请设计一套方案,让这台机器的实际吞吐接近理论上限。列出你要做的具体决策,并说明每个决策的代价。

参考答案

核心原则:让数据跟着计算走,或者让计算跟着数据走。二选一,不能各做一半。

方案设计:

决策 1 · 分片,而不是复制

  • 把 100GB 按 key 分成 64 片(每片约 1.6GB),用一致性哈希或简单的 hash(key) % 64
  • 每片绑定到一个 NUMA 节点。Node 0 存片 031,Node 1 存片 3263
  • 代价:如果 key 分布倾斜,可能出现节点间负载不均。需要监控每片的大小和热度,必要时做动态迁移(迁移本身就是跨节点拷贝,很贵)

决策 2 · 线程绑核(CPU affinity)

  • 启动 64 个 worker 线程(不是 goroutine),每个用 syscall.Syscall(SYS_SCHED_SETAFFINITY, ...) 绑死到一个核
  • 每个 worker 只处理自己那一片的 key 请求
  • 代价:Go 里绑核需要用 runtime.LockOSThread() + syscall,比较别扭;而且绑死后调度器无法做负载均衡,如果某个 key 特别热,那个核会打满而其他核空闲

决策 3 · 网络层也要感知 NUMA

  • 网卡通常挂在某个 NUMA 节点上(PCIe 插槽决定的)。cat /sys/class/net/eth0/device/numa_node 可以看到
  • 让收包的中断(IRQ)亲和到同节点的核:echo <mask> > /proc/irq/<irq>/smp_affinity
  • 用 SO_REUSEPORT 让每个 worker 有自己的 listening socket,由内核按四元组哈希分发
  • 代价:配置复杂,且机器迁移/网卡更换时要重新调

决策 4 · 内存分配本地化

  • 每个 worker 用自己的内存池,池子的内存用 numactl 或 mbind() 系统调用绑定到本地节点
  • 对象生命周期尽量在 worker 内部闭环:worker A 分配的对象,不要让 worker B 去释放(否则跨节点 free 很慢)
  • 代价:需要在 worker 之间做对象归还机制(比如 lock-free 的归还队列),复杂度上升

决策 5 · GC 的应对

  • 100GB 堆的 GC 是灾难。必须用 sync.Pool + 对象复用把堆压力降到最低
  • 考虑调大 GOGC(比如 400),用内存换 GC 频率
  • 终极方案:GOGC=off + 手动 runtime.GC() 在低峰期触发
  • 代价:OOM 风险,需要精确的内存预算

决策 6 · 验证手段(很多人漏掉这个)

  • 用 perf stat -e node-loads,node-load-misses 直接测量跨节点访问次数
  • 用 numastat 看每个节点的内存分配和跨节点命中
  • 压测时对比”绑核前后”的 P99,确认优化真的生效
  • 代价:需要 perf 权限,容器里可能拿不到

一句话总结:NUMA 优化的本质是消除不确定性——让每一份数据、每一个计算单元、每一次网络收包,都有确定的物理归属。这跟分布式系统里”让计算靠近数据”是同一个思路,只是尺度不同。

题 3 ·【创造层】

假设你就是那个”跨域访问延迟只有本地 1.2 倍”的理想硬件架构师(比如 CXL 成熟之后),NUMA 问题基本消失了。这时,题 2 里的那些优化还有价值吗?哪些会失效,哪些依然重要?

参考答案

这是一个很好的”反过来想”的问题。

会失效的(NUMA 特有的):

  • 决策 1(分片)中的”按节点分片”——不再需要为了 NUMA 而分片。但分片本身可能还有价值,见下。
  • 决策 2(绑核)中的 NUMA 考虑——不再需要为了让 CPU 靠近内存而绑核。
  • 决策 4(内存本地化)——整个消失。

依然重要的(与 NUMA 无关,而是更本质的东西):

  1. 缓存局部性,比 NUMA 更根本。 即使跨节点延迟降到 1.2 倍,L1 和主存之间那 17 倍的差距依然存在(就是实验 1 那条曲线)。绑核的价值不只在 NUMA,还在让数据和 CPU 缓存保持亲和——一个线程固定在同一个核上,它的 L1/L2 里缓存的数据才有效。这个价值不会因为 CXL 而消失。

  2. 分片带来的并行性,比分片带来的 NUMA 亲和更重要。 题 2 里把数据分 64 片,真正的收益是消除了全局锁——每个 worker 操作自己的片,不需要加锁。这个收益跟 NUMA 一点关系都没有。很多人在做 NUMA 优化时,以为自己优化的是内存,实际优化的是锁竞争。 这是一个非常常见的误判。

  3. 网络中断亲和的价值转移到其他维度。 NUMA 消失后,IRQ 亲和依然重要,但理由变了:不再是”让网卡靠近内存”,而是”避免跨核中断带来的缓存失效和上下文切换”。

  4. GC 压力是独立的难题。 100GB 堆的 GC 问题跟 NUMA 无关。CXL 甚至可能让情况更糟——因为内存池化后,访问延迟的方差变大,GC 的暂停时间变得更不可预测。

更深的一层思考:

这个问题真正的价值在于让你意识到:我们平时做的很多”性能优化”,实际上是几个不同问题被同一个手段顺带解决了。

当你把”分片”这个手段拿出来,它同时解决了:

  • NUMA 亲和(内存距离)
  • 锁竞争(并行度)
  • 缓存局部性(数据复用)

那么当其中一个问题消失时,这个手段是否还有价值,取决于其他两个问题是否还在。

这就是为什么”知道为什么”比”知道怎么做”重要得多。 你记住了”要分片”,但不知道分片到底在解决什么,那么当环境变化时(NUMA 消失了),你既不知道该不该继续分片,也不知道该换成什么。

给自己的检验标准:你做的每一个优化决策,能不能说出”如果我把它去掉,具体哪一项指标会恶化多少”?说不出来,说明你在抄方案,不在做设计。


小结:这一节你应该带走的三句话

  1. 内存不是均匀的。 从 L1 缓存到远端 NUMA 节点,延迟差 17 倍。你写的每一行代码,都在跟这条曲线打交道。

  2. 所有奇怪的性能问题,往下追三层都会追到物理约束。 NUMA 的本质是”用访问均匀性换总带宽”,因为总线带宽增长跟不上核数增长。

  3. NUMA 机器就是一个小型分布式系统。 “让计算靠近数据”、“分片 vs 复制”、“一致性 vs 可用性”——这些分布式系统的核心命题,在单机内部全部存在。理解了这一点,第 5 章的 Raft 对你来说会变得非常自然。


下一节

02 · 程序是怎么跑起来的

我们要把视角从”内存”拉到”整条链路”:从你敲下 go build 的那一刻,到 CPU 真正执行第一条指令,中间到底发生了什么。

老陈的预告:你知道 go build 出来的那个二进制文件,第一个被 CPU 执行的字节在哪里吗?不在 main 函数里,甚至不在 Go 的代码里。 想明白了这件事,你对”程序”这个词的理解会变一次。