01 · 开篇:一次查不出的 P0 事故
“你以为你在读内存。其实你在走一段总线。“
事故现场
凌晨 3:14,小林的手机炸了。
告警:
stream-compute-07P99 延迟 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 能直连的内存通道数有上限(功耗和引脚数限制)
- 跨节点互连的带宽增长远慢于核数增长
- 访问延迟随跳数增加
你需要权衡:
- 分几个 NUMA 节点?节点多了本地性好但跨节点更频繁,节点少了带宽不够
- 用什么拓扑?全互连 / 环形 / 网格 / 超立方体?
- 要不要做硬件级的缓存一致性(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。
代价分四项:
- 内存占用翻倍:40GB × 2 = 80GB。如果这个服务还有其他大对象,可能直接 OOM。
- 一致性维护成本:这个表是只读的吗?如果是只读的(比如用户特征快照,每天更新一次),那太好了,两份副本互不影响,更新时双写即可。如果表是可变的,麻烦就大了——你要么引入跨节点的缓存一致性协议(这本质上是在用户态重新实现硬件 ccNUMA,性能会很糟),要么接受短暂的不一致。
- 更新的放大效应:原方案更新一次写 40GB;新方案要么写两次(80GB),要么用”先写 A 再写 B”的双缓冲,这段时间内存峰值是 120GB。
- 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 存片 0
31,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 无关,而是更本质的东西):
-
缓存局部性,比 NUMA 更根本。 即使跨节点延迟降到 1.2 倍,L1 和主存之间那 17 倍的差距依然存在(就是实验 1 那条曲线)。绑核的价值不只在 NUMA,还在让数据和 CPU 缓存保持亲和——一个线程固定在同一个核上,它的 L1/L2 里缓存的数据才有效。这个价值不会因为 CXL 而消失。
-
分片带来的并行性,比分片带来的 NUMA 亲和更重要。 题 2 里把数据分 64 片,真正的收益是消除了全局锁——每个 worker 操作自己的片,不需要加锁。这个收益跟 NUMA 一点关系都没有。很多人在做 NUMA 优化时,以为自己优化的是内存,实际优化的是锁竞争。 这是一个非常常见的误判。
-
网络中断亲和的价值转移到其他维度。 NUMA 消失后,IRQ 亲和依然重要,但理由变了:不再是”让网卡靠近内存”,而是”避免跨核中断带来的缓存失效和上下文切换”。
-
GC 压力是独立的难题。 100GB 堆的 GC 问题跟 NUMA 无关。CXL 甚至可能让情况更糟——因为内存池化后,访问延迟的方差变大,GC 的暂停时间变得更不可预测。
更深的一层思考:
这个问题真正的价值在于让你意识到:我们平时做的很多”性能优化”,实际上是几个不同问题被同一个手段顺带解决了。
当你把”分片”这个手段拿出来,它同时解决了:
- NUMA 亲和(内存距离)
- 锁竞争(并行度)
- 缓存局部性(数据复用)
那么当其中一个问题消失时,这个手段是否还有价值,取决于其他两个问题是否还在。
这就是为什么”知道为什么”比”知道怎么做”重要得多。 你记住了”要分片”,但不知道分片到底在解决什么,那么当环境变化时(NUMA 消失了),你既不知道该不该继续分片,也不知道该换成什么。
给自己的检验标准:你做的每一个优化决策,能不能说出”如果我把它去掉,具体哪一项指标会恶化多少”?说不出来,说明你在抄方案,不在做设计。
小结:这一节你应该带走的三句话
-
内存不是均匀的。 从 L1 缓存到远端 NUMA 节点,延迟差 17 倍。你写的每一行代码,都在跟这条曲线打交道。
-
所有奇怪的性能问题,往下追三层都会追到物理约束。 NUMA 的本质是”用访问均匀性换总带宽”,因为总线带宽增长跟不上核数增长。
-
NUMA 机器就是一个小型分布式系统。 “让计算靠近数据”、“分片 vs 复制”、“一致性 vs 可用性”——这些分布式系统的核心命题,在单机内部全部存在。理解了这一点,第 5 章的 Raft 对你来说会变得非常自然。
下一节
我们要把视角从”内存”拉到”整条链路”:从你敲下 go build 的那一刻,到 CPU 真正执行第一条指令,中间到底发生了什么。
老陈的预告:你知道
go build出来的那个二进制文件,第一个被 CPU 执行的字节在哪里吗?不在main函数里,甚至不在 Go 的代码里。 想明白了这件事,你对”程序”这个词的理解会变一次。