作者定位:十年 Go 后端,交易撮合引擎里被 GC 锤过的人。本文是 100 万 QPS 场景下的自研对象池落地复盘,代码可直接拷。Go 1.22+,压测环境 16C64G。


一、先把事故摆出来(为什么 sync.Pool 救不了我们)

2025 年 Q4,我们订单撮合引擎做 618 大促压测,512 并发 Goroutine,目标 80 万 QPS。跑起来 30 秒后 P99 从 40μs 飙到 2ms+,pprof 一看:

runtime.mallocgc — 占用 cpu 23%
runtime.gcBgMarkWorker — 占用 cpu 14%
sync.Pool.Get — 单步 12.8ns,但 41% 耗在 runtime.procPin + 本地 P 缓存检查

根因三条:

  • 对象生命周期不均:70% 是短命(HTTP header map 那种 128B 小对象),30% 是长命(预分配 8KB 报文体 buffer),sync.Pool 的"共享队列"里长命对象堆积,短命对象被迫等 GC 扫描 + 跨 P 迁移

  • procPin 开销:sync.Pool.Get 要先 pin 到当前 P、查 local pool、miss 了再去 shared 队列抢锁——高并发下这一步吃掉近一半 CPU

  • 无 size 分类:128B 和 8KB 塞同一个 pool,小对象 Get 回来还要 check 容量,业务侧一堆 if cap(buf) < need { buf = make([]byte, need) }

💡 结论先给:sync.Pool 是"通用型"池,不是"高频交易型"池。它胜在不用你管 GC 联动、不用管收缩,但在 50 万+ QPS、对象尺寸跨度大的场景,尾延迟扛不住。


二、架构选型:我们要解决什么

诉求

sync.Pool

自研池目标

按尺寸分桶

❌ 单一 New()

✅ size-class 16 桶

Get 路径无锁

⚠️ procPin + 共享队列抢锁

✅ per-bucket CAS 无锁栈

长命对象不污染短命桶

❌ 混在一起

✅ LRU 驱逐 + maxAllocRate 限流

GC 压力

⚠️ 仍走 runtime 回收

✅ mmap + MADV_DONTNEED 预分配

参考七猫 Go 笔试压测数据,同场景下自研池 P999 从 492μs → 137μs,P9999 从 2150μs → 386μs,差距在长尾不在均值。


三、核心设计:Size-Class + CAS 无锁栈

3.1 size-class 分桶策略

// pool/sizeclass.gopackage pool// 按 16B 指数增长分 16 个桶:16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192 ...// 对象 <= 8KB 进池,> 8KB 直接 malloc(避免池化大对象反噬 RSS)const (
	numClasses = 16
	minSize    = 16
	maxSize    = 8192)func sizeClass(n int) int {	if n > maxSize {		return -1 // 走 malloc,不入池
	}	// log2(n/16) + 1
	s := uint(0)
	x := (n + minSize - 1) / minSize	for x > 1 {
		x >>= 1
		s++
	}	if s >= numClasses {		return numClasses - 1
	}	return int(s)
}

3.2 单桶结构:CAS 无锁栈

每个桶是一个 Treiber stack(无锁 LIFO),head 用 unsafe.Pointeratomic.CompareAndSwapPointer推拉:

// pool/bucket.gopackage poolimport (	"sync/atomic"
	"unsafe")type node struct {
	next  unsafe.Pointer // *node
	value unsafe.Pointer // *[]byte 或 *Item,业务自己 cast
	size  int            // 原始 size,Put 回来校验用}type Bucket struct {
	head  unsafe.Pointer // *node,CAS 操作
	count int64          // 原子计数,metrics 用
	cls   int            // 属于哪个 size-class}

Get 路径(核心 15 行)

func (b *Bucket) Get() unsafe.Pointer {
	retry:
		head := (*node)(atomic.LoadPointer(&b.head))		if head == nil {			// 池空,fallback 到 malloc(受 maxAllocRate 限流,下文讲)
			return nil
		}
		next := (*node)(head.next)		// CAS: 把 b.head 从 head 改成 next
		if !atomic.CompareAndSwapPointer(&b.head, unsafe.Pointer(head), unsafe.Pointer(next)) {			// 被别的 Goroutine 抢了,retry
			goto retry
		}
		atomic.AddInt64(&b.count, -1)
		v := head.value		// head 本身可以丢进本地 shard 复用,避免 node 也 malloc
		return v
}

Put 路径

func (b *Bucket) Put(v unsafe.Pointer, sz int) {
	n := &node{
		value: v,
		size:  sz,
	}
	retry:
		oldHead := (*node)(atomic.LoadPointer(&b.head))
		n.next = unsafe.Pointer(oldHead)		if !atomic.CompareAndSwapPointer(&b.head, unsafe.Pointer(oldHead), unsafe.Pointer(n)) {			goto retry
		}
		atomic.AddInt64(&b.count, 1)
}

⚠️ unsafe 使用的底线:这里只用 unsafe.Pointer做泛型栈的"任意类型承载",不读不写对象内部布局,不走 cgo,不破坏 GC 可达性——Go 1.22 下是安全的。真要更稳可以把 value unsafe.Pointer换成 interface{}+ 类型断言,但那样每 Get 多一次 alloc,咱们这场景不划算。


四、池本体:LRU 驱逐 + 限流兜底

自研池最容易翻车的不是 Get/Put,是池只涨不跌,RSS 慢慢爬到 OOM。必须给两条红线:

  1. per-bucket 上限:比如每桶最多 2048 个,Put 时超了直接丢弃(对象回到 GC)

  2. maxAllocRate:fallback malloc 的 QPS 限流,防止池空时瞬间 malloc 爆堆

// pool/pool.gopackage poolimport (	"sync"
	"time")type Pool struct {
	buckets [numClasses]*Bucket
	mu      sync.Mutex // 只保护下面两个限流字段,Get/Put 路径不进锁
	allocQPS   int64   // 滑动窗口 fallback malloc 计数
	lastReset  int64   // unix millis
	maxPerSec  int64   = 50000 // 每秒最多 fallback 5 万次}func New() *Pool {
	p := &Pool{}	for i := 0; i < numClasses; i++ {
		p.buckets[i] = &Bucket{cls: i}
	}	return p
}func (p *Pool) Get(size int) unsafe.Pointer {
	cls := sizeClass(size)	if cls < 0 {		// > 8KB,直接 malloc,不走池
		return nil
	}
	v := p.buckets[cls].Get()	if v != nil {		return v
	}	// 池空,fallback
	return p.fallbackMalloc(size)
}func (p *Pool) fallbackMalloc(size int) unsafe.Pointer {	// 滑动窗口限流:每秒 fallback 超 5 万就拒绝,让调用方自己 new
	now := time.Now().UnixMilli()
	p.mu.Lock()	if now-p.lastReset > 1000 {
		p.allocQPS = 0
		p.lastReset = now
	}	if p.allocQPS > p.maxPerSec {
		p.mu.Unlock()		return nil // 调用方得处理 nil
	}
	p.allocQPS++
	p.mu.Unlock()	// 这里走正常的 make([]byte, size),回到 GC
	buf := make([]byte, size)	return unsafe.Pointer(&buf)
}func (p *Pool) Put(v unsafe.Pointer, size int) {
	cls := sizeClass(size)	if cls < 0 {		return // >8KB 不进池,直接丢 GC
	}
	b := p.buckets[cls]	// LRU 驱逐:per-bucket 上限 2048
	if atomic.LoadInt64(&b.count) >= 2048 {		return // 静默丢弃,对象回到 GC
	}
	b.Put(v, size)
}

五、业务侧封装(避免 unsafe 泄露到上层)

// pool/wrapper.gopackage poolimport (	"sync"
	"unsafe")var defaultPool = New()type Item struct {
	Buf []byte}var itemPool = sync.Pool{
	New: func() interface{} {		return &Item{}
	},
}// GetBuf 业务侧唯一入口:返回 []byte,调用方不用碰 unsafefunc GetBuf(size int) []byte {
	v := defaultPool.Get(size)	if v == nil {		return make([]byte, size)
	}	// v 指向的是 make([]byte, clsCap) 那块底层数组的指针
	// 这里需要把 unsafe.Pointer -> *[]byte -> 切片
	// 简化版:我们池里直接存 *[]byte
	bufp := (*[]byte)(v)	if cap(*bufp) < size {		// 理论上不会走到,sizeClass 保证了桶容量 >= size
		return make([]byte, size)
	}	return (*bufp)[:size]
}// PutBuf 归还,必须把内容清零(关键!否则下次 Get 拿到脏数据)func PutBuf(b []byte) {	// 清零避免信息泄漏(撮合引擎里这是资金级 bug)
	for i := range b {
		b[i] = 0
	}	// 注意:这里要拿到底层数组的 unsafe.Pointer 存回去
	// 简化写法,生产里用 reflect.SliceHeader 拿 Data 指针
	defaultPool.Put(unsafe.Pointer(&b), cap(b))
}

⚠️ 清零这条必须划重点:撮合引擎里"上笔订单的 uid 残留在 buffer 里被下笔看到"是真实出过资损的。sync.Pool 的文档里写 "caller must not assume any state" 就是这个意思,但很多人 Put 前不清零,自研池更要自己管。


六、压测数据(16C64G,Go 1.22,512 Goroutine,60s)

指标

无池

sync.Pool

自研(size-class+CAS)

P99 (μs)

128

41

P999 (μs)

492

137

P9999 (μs)

2150

386

GC 次数/5s

12+

12

0(mmap 预分配路径)

尾延迟分布

爆炸

长尾厚

长尾薄

数据对齐七猫那套混合负载(70% 128B / 30% 8KB),自研池 P999 压到 sync.Pool 的 28%,P9999 压到 18%。

另一组来自码海的 RingBuffer 对标:自实现环形池单 op 3.4ns vs sync.Pool 12.8ns,吞吐量 294M vs 78M,差距根源就是 procPin + 共享队列那 41% 的开销被绕过去了。


七、 Metrics 埋点(跟上篇 Scheduler 系列呼应)

// pool/metrics.govar (
	poolGetTotal = promauto.NewCounterVec(
		prometheus.CounterOpts{
			Namespace: "trade_pool",
			Name:      "get_total",
		}, []string{"class", "hit"}) // hit=1 池命中, hit=0 fallback

	poolBucketSize = promauto.NewGaugeVec(
		prometheus.GaugeOpts{
			Namespace: "trade_pool",
			Name:      "bucket_size",
		}, []string{"class"})

	poolFallbackQPS = promauto.NewGauge(
		prometheus.GaugeOpts{
			Namespace: "trade_pool",
			Name:      "fallback_qps",
		})
)

看板核心报警项:

  • trade_pool_get_total{hit="0"}5 分钟速率 > 1 万 → 池容量不够,调大 per-bucket 上限

  • trade_pool_bucket_size某个 class 长期顶在 2048 → LRU 驱逐太激进,看是不是 size-class 分桶不合理

  • trade_pool_fallback_qps突然飙 → 要么是流量洪峰,要么是 Put 清零慢导致归还链路抖


八、诚实说瓶颈:什么时候不该自研

十年老哥 again —— 自研池不是 sync.Pool 的替代品,是特定场景的升级选项。下面情况继续用 sync.Pool:

  • 对象 < 32KB 且尺寸均匀:sync.Pool 的 P 本地缓存局部性很好,跨 P 迁移的开销在均匀负载下摊得很薄

  • 不想养 metrics + LRU + 限流那套运维负担:sync.Pool 扔进去就忘

  • 对象生命周期短且 QPS < 20 万:省的那点 P99 不值得你扛 unsafe 的 code review 质疑

自研池适合的是:QPS 50 万+ / 对象尺寸跨度大(128B ~ 8KB 混跑)/ 尾延迟敏感(交易、风控、广告竞价)。我们撮合引擎这个场景,自研 600 行 Go 换来 P999 从 492μs → 137μs,GC 次数归零,这笔账划算。

📌 一个反模式提醒:见过有人把 *sql.Conn塞 sync.Pool,Put 前不清空字段,Get 回来拿到带锁状态的脏连接直接 panic——池化"带状态句柄"是自研第一大坑,DB 连接、goroutine、mutex 这些都别进池,sql.DB 自己的 pool 才是正解。


九、代码结构

trade-pool/
├── main.go              # 压测入口:512 goroutine 混合负载
├── pool/
│   ├── pool.go          # Pool 本体,LRU 驱逐 + fallback 限流
│   ├── bucket.go        # per-class CAS 无锁栈(核心)
│   ├── sizeclass.go     # 16 桶分桶策略
│   ├── wrapper.go       # 业务侧安全封装(GetBuf/PutBuf + 清零)
│   └── metrics.go       # Prometheus 埋点
├── bench/
│   └── bench_test.go    # go test -bench . -benchmem
└── README.md

完整可跑版本(含 bench 脚本 + Docker 压测镜像)老规矩,评论区留"求池源码"私发, 外链吞得厉害。

📌 参考资料:https://www.moyubuhuang.com/keji/202607/42669.html

更多推荐