先定位资源,再谈优化
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 里的 lockSlow | sync.(*Mutex).Lock、runtime.usleep | 以为 CPU 不够 |
| 分配/GC | -benchmem、heap profile | runtime.mallocgc、GC 占比 | 以为数据库慢 |
| I/O 等待 | goroutine/block profile、trace | syscall.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 profile | lockSlow/usleep 高则转 3;mallocgc 高则转 4 |
| 3 | 采集 mutex/block profile | 找到具体锁和调用方,判断能否分片或移出热路径 |
| 4 | -benchmem 与 heap profile | 看 allocs/op、GC 占比和最大分配点 |
| 5 | goroutine/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 性能预算:把端到端延迟拆到每个调用 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。