《后端架构设计·高性能与高可用》1.2 瓶颈定位:CPU、内存、I/O、锁

用一个单锁计数与 64 分片锁的可比基准,真实运行 go test -bench -benchmem -cpuprofile -mutexprofile 并用 go tool pprof -top 读取输出,说明如何从 profile 区分锁竞争、分配和 I/O 等待,最后给出 arch-lab 订单服务的最小排查顺序。

先定位资源,再谈优化

1.1 解决了“怎么读延迟”。接下来的问题是:延迟高时,时间到底花在哪。没有 profile 的优化通常从最熟悉的代码开始改,改完再测,运气好快一点,运气不好只是把瓶颈挪到下一层。更糟的是把锁竞争误判成 CPU 太慢,于是加机器,结果锁还是同一把,QPS 几乎不动。

Go 运行时已经把四类证据准备好了:CPU profile 看时间花在哪些函数,内存 profile 看分配来自哪里,mutex/block profile 看谁在等锁,goroutine/trace 看谁在等 I/O。它们回答的问题不同,不能拿一个 profile 解释所有现象。

本节用 arch-lab 的一个高频计数器做可比实验:同一份工作量,单锁和 64 分片锁各跑一遍,先看基准数字,再看 profile 到底指出了什么。

四类瓶颈在 profile 里长什么样

先给一张对照表,后面再用真实输出验证:

现象首选证据典型符号容易误判成
CPU 真忙CPU profile业务函数自身 flat 高以为要加缓存
锁竞争mutex profile、CPU 里的 lockSlowsync.(*Mutex).Lock、runtime.usleep以为 CPU 不够
分配/GC-benchmem、heap profileruntime.mallocgc、GC 占比以为数据库慢
I/O 等待goroutine/block profile、tracesyscall.Syscall、runtime.gopark以为线程池太小

CPU profile 默认每 10ms 采样一次,只记录“正在 CPU 上运行”的栈。等锁、等网络、等磁盘的 goroutine 不在 CPU 上,不会出现在 flat 里;它们的等待时间要去 mutex、block 或 trace 里找。把 CPU profile 当成“耗时火焰图”是第一个常见错误。

可比基准:单锁与分片锁

计数器是订单服务里最常见的共享结构:按状态、按 SKU、按渠道统计。单锁实现用一把 sync.Mutex 保护一个 map;分片实现把 key 哈希到 64 个独立 map,每个 map 一把锁。两者接口一致,工作量一致,唯一变量是锁的粒度。

哈希函数手写 FNV-1a,避免在热路径上通过 hash.Hash32 接口产生额外分配。这一点很重要:如果分片版本每次 Inc 都分配一个哈希对象,基准测到的就不是锁的差异。

// file: internal/platform/lockbench/counter.go
// tier: L1
// verify: go test ./internal/platform/lockbench/ -count=1
package lockbench

import "sync"

type Counter interface {
	Inc(key string) int64
	Value(key string) int64
}

// SingleLockCounter serializes every key on one mutex.
type SingleLockCounter struct {
	mu sync.Mutex
	m  map[string]int64
}

func NewSingleLockCounter() *SingleLockCounter {
	return &SingleLockCounter{m: make(map[string]int64)}
}

func (c *SingleLockCounter) Inc(key string) int64 {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.m[key]++
	return c.m[key]
}

func (c *SingleLockCounter) Value(key string) int64 {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.m[key]
}

// ShardedCounter splits keys over independent mutexes. A single hot key
// still lands on one shard and cannot be helped by adding shards.
type ShardedCounter struct {
	shards []counterShard
}

type counterShard struct {
	mu sync.Mutex
	m  map[string]int64
}

func NewShardedCounter(shards int) *ShardedCounter {
	if shards < 1 {
		shards = 1
	}
	c := &ShardedCounter{shards: make([]counterShard, shards)}
	for i := range c.shards {
		c.shards[i].m = make(map[string]int64)
	}
	return c
}

func hashKey(key string) uint32 {
	const (
		offset32 = 2166136261
		prime32  = 16777619
	)
	h := uint32(offset32)
	for i := 0; i < len(key); i++ {
		h ^= uint32(key[i])
		h *= prime32
	}
	return h
}

func (c *ShardedCounter) shard(key string) *counterShard {
	return &c.shards[int(hashKey(key))%len(c.shards)]
}

func (c *ShardedCounter) Inc(key string) int64 {
	s := c.shard(key)
	s.mu.Lock()
	defer s.mu.Unlock()
	s.m[key]++
	return s.m[key]
}

func (c *ShardedCounter) Value(key string) int64 {
	s := c.shard(key)
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.m[key]
}

基准必须同时覆盖“热点 key”和“分散 key”。热 key 场景下,64 个分片里只有一个被使用,分片不但没有收益,还多了一次哈希计算;分散 key 场景下,64 把锁才能真正并行。只跑一个场景,很容易得出相反的结论。

// file: internal/platform/lockbench/counter_test.go
// tier: L1
// verify: go test ./internal/platform/lockbench/ -count=1 -bench . -benchmem
package lockbench

import (
	"fmt"
	"sync"
	"testing"
)

func TestCountersAgree(t *testing.T) {
	const workers, perWorker, keys = 16, 1000, 64
	implementations := map[string]func() Counter{
		"single-lock": func() Counter { return NewSingleLockCounter() },
		"sharded":     func() Counter { return NewShardedCounter(64) },
	}
	for name, newCounter := range implementations {
		t.Run(name, func(t *testing.T) {
			c := newCounter()
			var wg sync.WaitGroup
			for w := 0; w < workers; w++ {
				wg.Add(1)
				go func(w int) {
					defer wg.Done()
					for i := 0; i < perWorker; i++ {
						c.Inc(fmt.Sprintf("order:%d", (w+i)%keys))
					}
				}(w)
			}
			wg.Wait()
			var got int64
			for i := 0; i < keys; i++ {
				got += c.Value(fmt.Sprintf("order:%d", i))
			}
			if want := int64(workers * perWorker); got != want {
				t.Fatalf("sum = %d, want %d", got, want)
			}
		})
	}
}

func benchKeys() []string {
	keys := make([]string, 64)
	for i := range keys {
		keys[i] = fmt.Sprintf("order:%d", i)
	}
	return keys
}

func BenchmarkCounter(b *testing.B) {
	b.Run("single-lock/hot-key", func(b *testing.B) {
		c := NewSingleLockCounter()
		c.Inc("order:hot")
		b.ReportAllocs()
		b.ResetTimer()
		b.RunParallel(func(pb *testing.PB) {
			for pb.Next() {
				c.Inc("order:hot")
			}
		})
	})
	b.Run("sharded-64/hot-key", func(b *testing.B) {
		c := NewShardedCounter(64)
		c.Inc("order:hot")
		b.ReportAllocs()
		b.ResetTimer()
		b.RunParallel(func(pb *testing.PB) {
			for pb.Next() {
				c.Inc("order:hot")
			}
		})
	})
	b.Run("single-lock/64-keys", func(b *testing.B) {
		keys := benchKeys()
		c := NewSingleLockCounter()
		for _, k := range keys {
			c.Inc(k)
		}
		b.ReportAllocs()
		b.ResetTimer()
		b.RunParallel(func(pb *testing.PB) {
			i := 0
			for pb.Next() {
				c.Inc(keys[i&(len(keys)-1)])
				i++
			}
		})
	})
	b.Run("sharded-64/64-keys", func(b *testing.B) {
		keys := benchKeys()
		c := NewShardedCounter(64)
		for _, k := range keys {
			c.Inc(k)
		}
		b.ReportAllocs()
		b.ResetTimer()
		b.RunParallel(func(pb *testing.PB) {
			i := 0
			for pb.Next() {
				c.Inc(keys[i&(len(keys)-1)])
				i++
			}
		})
	})
}

b.RunParallel 使用 GOMAXPROCS 个 goroutine 并发调用,模拟多核竞争;b.ResetTimer 之前先写入一次 key,避免把 map 首次插入的分配算进测量。-benchmem 会给出 B/op 和 allocs/op,这两个数字是判断“锁问题还是分配问题”的第一道分叉。

真实基准:先看数字

环境:macOS 26.3、Apple M1 Pro(10 核)、Go 1.26.0。先跑 5 次短基准,命令和完整输出在 .local/reports/be-arch/be-arch-perf-01-2-lock-bench.txt。下面按组列出 5 次运行的 ns/op;四组的 B/op 和 allocs/op 都是 0,为排版省略重复列。

$ go test -c -o lockbench.test ./internal/platform/lockbench/
$ ./lockbench.test -test.run '^$' -test.bench '^BenchmarkCounter$' \
    -test.benchmem -test.benchtime=300ms -test.count=5
BenchmarkCounter/single-lock/hot-key-10    106.4  123.0  123.1  125.8  120.4 ns/op
BenchmarkCounter/sharded-64/hot-key-10     132.4  134.6  133.2  136.2  131.6 ns/op
BenchmarkCounter/single-lock/64-keys-10    161.6  157.2  162.6  157.9  158.4 ns/op
BenchmarkCounter/sharded-64/64-keys-10      61.21  61.78  60.38  60.74  62.97 ns/op

四组结果都是 0 B/op、0 allocs/op,说明测量没有混入分配。分散 key 场景下,分片锁从约 159ns/op 降到约 61ns/op,吞吐约为单锁的 2.6 倍;热 key 场景下,分片锁反而从约 120ns/op 升到约 134ns/op,因为哈希和取模是纯开销,所有 goroutine 仍在抢同一把锁。

这两个结果必须一起写。只写“分片锁快 2.6 倍”会误导读者把所有锁都分片;只写“分片锁更慢”又会忽略真正的并行收益。结论取决于 key 的分布,而 key 的分布只能从业务现场测,不能从“通常”推断。

从 CPU profile 看锁竞争

固定 1000 万次迭代,对单锁和分片锁分别采集 CPU 与 mutex profile,这样两组样本的工作量完全一致。命令如下,输出在 .local/reports/be-arch/be-arch-perf-01-2-pprof-single-fixed-cpu.txt 和 ...-sharded-fixed-cpu.txt:

$ ./lockbench.test -test.run '^$' -test.bench 'BenchmarkCounter/single-lock/64-keys$' \
    -test.benchtime=10000000x -test.count=3 \
    -test.cpuprofile=cpu-single-fixed.out -test.mutexprofile=mutex-single-fixed.out
$ go tool pprof -top -nodecount=12 lockbench.test cpu-single-fixed.out

单锁的 CPU profile 顶部节选如下(完整 top 见报告):

      flat  flat%   sum%        cum   cum%
     8.51s 39.38% 39.38%      8.51s 39.38%  runtime.usleep
     6.84s 31.65% 71.03%      6.84s 31.65%  runtime.pthread_cond_wait
     4.34s 20.08% 91.12%      4.34s 20.08%  runtime.pthread_cond_signal
     0.53s  2.45% 93.57%      6.16s 28.51%  internal/sync.(*Mutex).lockSlow
     0.10s  0.46% 95.88%      7.22s 33.41%  internal/sync.(*Mutex).Unlock (inline)
     0.05s  0.23% 96.58%      8.83s 40.86%  runtime.lock2
     0.04s  0.19% 96.76%     13.57s 62.80%  archlab/internal/platform/lockbench.(*SingleLockCounter).Inc

usleep、pthread_cond_wait、pthread_cond_signal 合计超过 90% 的 flat 样本,它们不是业务代码,而是运行时在锁竞争时自旋、休眠和唤醒。lockSlow 的 cum 是 6.16s,SingleLockCounter.Inc 的 cum 是 13.57s,说明大部分 CPU 时间被吸收在“等锁—唤醒—再等锁”的循环里。

分片锁的同一份输出(节选):

      flat  flat%   sum%        cum   cum%
     9.55s 74.20% 74.20%      9.55s 74.20%  runtime.usleep
     0.77s  5.98% 80.19%      4.35s 33.80%  internal/sync.(*Mutex).Unlock (inline)
     0.67s  5.21% 85.39%      1.29s 10.02%  internal/sync.(*Mutex).Lock (inline)
     0.53s  4.12% 89.51%      0.53s  4.12%  runtime.pthread_cond_wait
     0.12s  0.93% 93.71%      0.62s  4.82%  internal/sync.(*Mutex).lockSlow
     0.10s  0.78% 95.34%      0.62s  4.82%  internal/runtime/maps.(*Map).getWithoutKeySmallFastStr
     0.10s  0.78% 96.11%      0.26s  2.02%  runtime.mapassign_faststr

lockSlow 从 28.51% 降到 4.82%,map 相关函数开始出现在顶部,说明时间从“抢锁”回到了“做事”。usleep 仍然很高,但总样本从 21.61s 降到 12.87s,绝对等待时间大幅减少。这里看 flat% 会被总样本变化误导,必须同时看 cum 秒数和总样本。

从 mutex profile 看等待时间

CPU profile 的 lockSlow 只是“在锁代码里烧掉的 CPU”,真正的阻塞时间要看 mutex profile。它是 delay profile,单位是等待时长,不是 CPU 时间。同样的 1000 万次迭代,顶部节选:

$ go tool pprof -top -nodecount=10 lockbench.test mutex-single-fixed.out
      flat  flat%   sum%        cum   cum%
    18.14s 65.57% 65.57%     21.46s 77.55%  sync.(*Mutex).Unlock
     9.53s 34.43% 100%       9.53s 34.43%  runtime.unlock (inline)
         0     0% 100%     25.16s 90.90%  archlab/internal/platform/lockbench.(*SingleLockCounter).Inc
         0     0% 100%      3.69s 13.35%  internal/sync.(*Mutex).Lock (inline)

分片锁:

      flat  flat%   sum%        cum   cum%
 1248.29ms 95.93% 95.93%  1282.98ms 98.60%  sync.(*Mutex).Unlock
   52.94ms  4.07% 100%    52.94ms  4.07%  runtime.unlock (inline)
         0     0% 100%  1297.74ms 99.73%  archlab/internal/platform/lockbench.(*ShardedCounter).Inc
         0     0% 100%    14.75ms  1.13%  internal/sync.(*Mutex).Lock (inline)

同样 3000 万次 Inc,单锁累计等待约 27.67 秒,分片锁约 1.30 秒,相差约 21 倍。lockSlow 从 3.69 秒降到 14.75 毫秒,说明单锁版本的 goroutine 大部分时间在排队,分片版本几乎没有排队。

读 mutex profile 时要注意:它统计的是采样到的阻塞事件,默认采样率不是 100%,绝对值受采样影响,但同一进程、同一采样率下的相对比较仍然有效。不要把它和 CPU profile 的秒数相加,两者不是同一维度的资源。

内存与 I/O:别让 profile 缺席

内存问题首先看 -benchmem。本次四组都是 0 B/op、0 allocs/op,说明锁实验没有被分配污染。如果 allocs/op 很高,下一步用 -memprofile 看 alloc_space 或 inuse_space:前者回答“谁分配得最多”,后者回答“谁占着内存不放”。订单服务里常见的是每次请求构造一个大 map 或拼接 JSON,分配量随 QPS 线性增长,最后表现为 GC 周期变短、P99 抖动,而不是某个函数 CPU 高。

I/O 等待不会出现在 CPU profile 的 flat 里。定位方法是 goroutine profile 或 trace:

$ go tool pprof -top http://127.0.0.1:6060/debug/pprof/goroutine
$ go tool trace -http=127.0.0.1:8080 trace.out

goroutine profile 看到大量 goroutine 停在 syscall.Syscall、net.(*netFD).Read 或数据库驱动的 readLoop,说明在等网络或数据库;停在 chan receive、sync.(*Mutex).Lock,则分别是通道和锁。trace 能看到单个请求在哪个阶段被调度出去、等了多久,适合确认“CPU 不满但延迟高”的现场。

对 arch-lab 的订单服务,最小排查顺序是:

步骤动作判定
1固定负载重放,记录成功/失败/超时三本账确认是延迟、错误还是超时
2采集 30 秒 CPU profilelockSlow/usleep 高则转 3;mallocgc 高则转 4
3采集 mutex/block profile找到具体锁和调用方,判断能否分片或移出热路径
4-benchmem 与 heap profile看 allocs/op、GC 占比和最大分配点
5goroutine/trace看 I/O、通道、连接池等待
6最后才看数据库慢查询和网络前三步已排除应用内阻塞

顺序不能反。先查数据库慢查询,往往会漏掉应用内的一把全局锁;而一把全局锁的修复成本通常远低于加索引或拆库。

常见错误与边界

第一,只跑分散 key 的基准。热 key 场景下分片锁更慢,如果业务里恰好有一个爆款 SKU 或全局状态计数,分片不会救你,反而多一层哈希开销。

第二,把 mutex profile 的 delay 当成 CPU 时间。它是等待时长,不能与 CPU profile 相加,也不能直接换算成“需要多少核”。

第三,忽略分配。锁基准必须是 0 allocs/op 才可比;如果分片版本每次哈希都分配,测到的是分配器而不是锁。

第四,用 CPU 使用率判断瓶颈。CPU 不高但延迟高,通常是锁、I/O 或连接池;CPU 很高但 QPS 不涨,才更像计算密集或自旋。

第五,只在开发机跑一次就下结论。本节数字是本机单次/少数次运行,用于展示方法,不是生产容量结论;生产必须在目标机型、目标负载和真实数据规模上重复。

小结

瓶颈定位的顺序是:先用三本账确认症状,再用 CPU profile 区分“真忙”和“等锁”,用 mutex profile 量化等待,用 -benchmem/heap profile 检查分配,用 goroutine/trace 检查 I/O。单锁与 64 分片锁的可比基准显示:分散 key 时 159ns/op 降到 61ns/op,等待时间从 27.67 秒降到 1.30 秒;热 key 时分片反而从约 120ns/op 升到约 134ns/op。分片不是默认答案,key 分布才是。下一节把这些读数变成预算:端到端延迟怎样拆到每个调用,超时和重试又该预留多少。

阅读导航:上一节:1.1 吞吐、延迟与尾部延迟 · 下一节:1.3 性能预算:把端到端延迟拆到每个调用 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查