作者定位十年 Go 后端交易撮合引擎里被 GC 锤过的人。本文是 100 万 QPS 场景下的自研对象池落地复盘代码可直接拷。Go 1.22压测环境 16C64G。一、先把事故摆出来为什么 sync.Pool 救不了我们2025 年 Q4我们订单撮合引擎做 618 大促压测512 并发 Goroutine目标 80 万 QPS。跑起来 30 秒后 P99 从 40μs 飙到 2mspprof 一看runtime.mallocgc — 占用 cpu 23% runtime.gcBgMarkWorker — 占用 cpu 14% sync.Pool.Get — 单步 12.8ns但 41% 耗在 runtime.procPin 本地 P 缓存检查根因三条对象生命周期不均70% 是短命HTTP header map 那种 128B 小对象30% 是长命预分配 8KB 报文体 buffersync.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μsP9999 从 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避免池化大对象反噬 RSSconst ( 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无锁 LIFOhead 用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 // 原始 sizePut 回来校验用}type Bucket struct { head unsafe.Pointer // *nodeCAS 操作 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。必须给两条红线per-bucket 上限比如每桶最多 2048 个Put 时超了直接丢弃对象回到 GCmaxAllocRatefallback 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 前不清零自研池更要自己管。六、压测数据16C64GGo 1.22512 Goroutine60s指标无池sync.Pool自研(size-classCAS)P99 (μs)—12841P999 (μs)—492137P9999 (μs)—2150386GC 次数/5s12120mmap 预分配路径尾延迟分布爆炸长尾厚长尾薄数据对齐七猫那套混合负载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}) // hit1 池命中, hit0 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{hit0}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μsGC 次数归零这笔账划算。 一个反模式提醒见过有人把*sql.Conn塞 sync.PoolPut 前不清空字段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