第三章 P6 独立负责篇:让一块地真正属于我
心法:让一块地真正属于我 转变:从「它跑起来了」到「出问题找我」 陷阱:只守边界,看不见系统
开篇:上周三抖的那三次
八月的最后一个周四,晚上八点二十。
城西那家没有招牌的茶室,二楼最里面那张桌子。老陈泡的是岩茶,第一泡倒掉没喝,第二泡汤色偏深,倒在白瓷杯里能挂杯。
小林是直接从公司过来的。地铁上他还在改一个 bug,进门前在楼道里把手机调成静音,坐下不到三十秒,又掏出来看了一眼。
“你们组有告警?”
“没有没有。“小林把手机扣在桌上,“习惯性看一眼。”
老陈给他倒了杯茶,没绕弯子。
“你们组负责的那个支付回调模块,上周 P99 抖了三次。你知道为什么吗?”
小林端着杯子的手停在半空。
他脑子里先冒出来的不是答案,是三个字:抖了三次?
然后是第二句:这事我怎么不知道。
然后才是他真正说出口的那句:
“支付回调……指的是整个 service 吗?”
“嗯。”
“那个不是我的模块。“小林说,“我负责的是里面的订单状态更新,就是 handleCallback 往下那一小段。”
“谁负责?”
”……王磊吧,最早那一版是他搭的。不过他上个月转岗去基础架构了。”
“所以现在没有人负责。”
小林没接话。
老陈把杯子放下,又问了一遍,语速很慢。
“上周三,下午两点四十一、三点零六、四点二十二。支付回调的 P99 从平时的 60 毫秒涨到九百毫秒上下,每次持续两到三分钟,然后自己恢复。你知道为什么吗?”
“我不知道。”
“你觉得你不知道,是正常的,还是不正常的?”
小林想说”正常”。他确实没收到告警,也没人在群里 @ 他,周会上更没人提。可是这句话到嘴边,他自己先觉得虚了。
“老陈,这有点不公平。“他说,“我连这个数是从哪儿看出来的都不知道。”
“从哪看?”
”…… Grafana?”
“哪个 dashboard?”
”……”
老陈没追问,他自己接了下去。
“我三十七岁那年,犯过一模一样的错。”
那是 2013 年,老陈在一家做供应链系统的公司当技术负责人。公司不大,两百来人,技术四十几个。
“我们有一套给供应商结算的脚本,每天凌晨跑一次,把前一天的结算单打成付款申请,推给财务系统。“老陈说,“那年七月,出了个事:连着三天,多打了一共四十七万八千。”
“多打了?”
“同一笔结算单被算了三次。脚本里有个幂等键,用的是’供应商 ID + 日期’,但是那个日期字段取的是本地时间,而结算单落库用的是 UTC。凌晨零点前后那一批,日期会跨一天,幂等键就失效了。”
小林下意识接了一句:“时区问题。”
“时区问题。“老陈点头,“复盘会是我主持的。我第一句话就是:这个脚本不是我写的,是老张离职前留下的,我没动过。”
茶室里安静了几秒。
“然后呢?”
“然后我们老板——他姓方,比我大六岁,做销售出身——他说了一句话,我到现在都记得。”
“什么话?”
“他说:老陈,我没问你谁写的。我问你,下一次怎么不这样。”
小林低头看着杯子里的茶叶末。
“我当时的感觉,怎么说呢。“老陈说,“就像被人当众抽了一巴掌,但是你没法还手,因为你心里清楚他是对的。我那句话出口的一瞬间,我在那个屋子里就已经不是负责人了。”
“可是,“小林抬起头,“不公平这三个字,我还是想说。你确实没写过那个脚本。”
“对,我确实没写过。”
“那……”
“小林,“老陈把身子往前倾了一点,“‘谁写的’和’谁负责’,是两件事。你把它们当成一件事了。”
小林放下杯子,靠回椅背。
“我承认你说的这个道理。“他说,“但是具体到我这半年,我觉得我干得不少。春节那次大促的库存扣减是我重写的,四月那个对账慢查询是我排查的,上个月订单导出 OOM 也是我修的。我有一次连着三天半夜起来处理问题。”
“我没说你干得少。”
“那你的意思是?”
“我说的是,你干的这些事,和你对一块地负责,是两件事。“老陈说,“我问你,你刚才说的那三件事,有哪一件是你自己找出来的?”
小林张了张嘴。
“库存扣减是阿 May 提的需求,对账慢查询是大刘在群里 @ 我,导出 OOM 是……”
“是你自己发现的?”
“是客服那边反馈说商家点了没反应。“小林的声音低下去了一点。
“对。“老陈说,“你排查的,都是别人叫你排查的问题。”
这句话落下来,小林沉默了大概有十秒钟。
他后来跟我说,那十秒钟里他脑子里过的东西很杂。有房贷——今年三月买的房子,家里凑的首付,月供一万三千八,三十年。有上个月绩效沟通时主管说的那句”你再往上够一够”。还有王磊离职那天,两人在楼梯间抽烟,王磊说过的一句话:“小林,支付这块以后就靠你了。”
他当时还笑了笑,说”别说得这么严肃”。
“老陈,“他重新开口,语气里有点不服气,“你说的这个什么对一块地负责,我觉得我懂。但是你刚才说的’边界感’,这不是 P7、P8 要考虑的事吗?我一个 P6——”
“你觉得自己是 P6?”
“我——“小林顿了一下,“我以为我是。”
“你现在不是。”
“为什么?”
“因为 P6 和 P5 的区别,不是能力。“老陈说,“是边界感。”
”……”
“你写代码的速度、排查问题的能力、对 Go 的熟悉程度,可能早够了。“老陈说,“但你脑子里那根线,还停在’这段代码是我写的’。”
小林沉默了很久。
“我换个说法。“老陈说,“P5 对’我写的这段代码’负责。P6 对’这块地’负责——不管问题是不是你写的,找到你,你就是终点。”
“找到我……”小林重复了一遍。
“嗯。”
“这话听着,“小林皱了下眉,“像背锅。”
老陈笑了。
“你看,你第一反应是背锅。”
“本来就是啊。代码不是我写的,出了问题算我的,这不就是背锅吗?”
“你先想想,你刚才这句话里,‘算我的’这三个字,是谁的视角。“老陈说,“是法官的视角。法官要判谁有罪,所以要搞清楚’谁写的’。可你不是法官,你是 owner。owner 不问谁有罪,owner 只问一件事:现在这事,谁能让它停下来。”
”……”
“边界感有两层。“老陈伸出两根手指,“第一层,知道什么事不该你管。第二层,知道什么事你推不掉。你现在缺的是第二层。”
他喝了口茶。
“从今天开始,我们要走的路,就是从’它跑起来了’,走到’出问题找我’。“
3.1 「这不是我写的」——这句话说出口的瞬间,你就已经暴露了
“你上一次说’这不是我写的’,是什么时候?”
老陈问得随意,像在聊天气。
小林想了两秒:“三个月前。”
“说说。”
“四月二十几号,对账任务报警,说前一天的结算单笔数对不上。大刘在群里 @ 我,我当时……”小林顿了一下,“我当时回了一句,这块是王磊写的,我没动过。”
“原话?”
“原话就是’这块是王磊写的,我没动过’。”
“然后呢。”
“然后王磊自己周末查的。“小林说,“一个时区问题,脚本里取的是本地时间,库里存的是 UTC,跨零点那一批就串了。”
“你看了吗?”
“看了。他改完之后我看了一眼 diff,点了个 approve。”
“你看出什么了?”
“看出他那个 SQL 里的 NOW() 跟 Go 里的 time.Now() 不是一个时区,还有他把幂等键从’日期’改成了’结算单 ID’。“小林说得很顺,说明他确实记得。
“那我问你,“老陈说,“既然你看一眼就能看出来,为什么三个月前,你没看出来?”
小林沉默了三四秒。
“我那时候没被分配去看那块。”
老陈重复了一遍:“被分配。”
”……”
“这三个字我再听你说一遍。”
小林皱起眉:“这话什么意思?我手上活是排着队的,阿 May 那边每周给我两三个需求,我做都做不完。我不可能主动去把整个组的历史代码全看一遍吧?”
“我没让你全看一遍。”
“那你让我怎么做?”
“你先想想,“老陈说,“那天报警的时候,群里除了你和大刘,还有谁在?”
”……阿 May,还有测试的莉莉。”
“你那句’这不是我写的’说出去之后,群里安静了多久?”
小林回忆了一下,然后有点不自在:“挺久。后来是王磊自己冒出来说’我看看’。”
“他当时已经转岗了。”
“对。”
“所以那一刻,你们组那块地,在群里是属于谁的?”
”……”
“不属于任何人。“老陈说,“你那句话的效果,不是把责任推给了王磊。是把这块地变成了无主之地。”
小林没说话。他的手指在杯壁上蹭来蹭去。
“老陈,我说句实话你别生气。“过了一会儿他开口,“你这套说法,听着特别像让我们无偿加班。‘出了问题找你’,那我岂不是要给所有人擦屁股?我一天就八小时,我还有房贷。”
“你先想想’擦屁股’这三个字,是站在什么立场上说出来的。”
”……”
“你站在’执行者’的立场上。执行者的世界里,任务是别人派下来的,做完了就交割,出了问题要看派单记录。“老陈说,“owner 的世界里没有派单记录。owner 起床的第一件事不是看今天给我派了什么活,是看我这块地今天怎么样。”
“这也太累了。”
“是累。“老陈竟然承认了,“我四十岁以后有六年,睡眠质量一直不好,就是因为脑子里挂着三块地。后来我把其中两块交出去了,睡眠才回来。”
“那你为什么还要这样?”
“因为那六年,是我学东西最快的六年。“
生活里的同一件事:租的房子漏水,和自己家漏水
去年三月之前,小林在城西租了一套两居,住了四年。
他记得有一年夏天,卫生间的天花板开始渗水。一片水印,从吊灯旁边慢慢往外扩,摸上去凉的,还有点潮。
他当时的第一反应是打开手机,拍了张照片,发给中介。
然后呢?然后就没有然后了。他把拖把桶放在下面接水,该上班上班,该打游戏打游戏。中介第二天下午带了个师傅来看,师傅说楼上人家的地漏有问题,要协调楼上。这事儿前后拖了十一天,最后是他自己受不了了,又催了两次,才修好。
那十一天里,小林从来没想过自己去关水闸。不是懒,是他压根不知道水闸在哪儿。四年,他连那栋楼的水闸箱都没打开过一次。
今年三月,他买了房。家里凑的首付,他自己还月供,一万三千八,三十年。
搬进去的第二个星期,他做了一件他老婆觉得莫名其妙的事:他把水闸、电闸、燃气阀全部摸了一遍。不是看一眼,是关了再开,试了三遍,确认哪个阀管厨房、哪个阀管卫生间、哪个是总阀。他还买了 plumbing 的扳手和一卷生料带,塞在阳台柜子最下面那格。
他老婆问他干嘛,他说”以防万一”。
“以防万一”这四个字,是租房子那四年他从来没想过的。
三个月后,真的用上了。厨房水槽下面的角阀接口渗水,不大,但是沿着柜板往下滴。那是晚上十点多。
小林后来跟我说,那天的反应他自己都觉得神奇:他没有任何情绪波动,没有烦躁,没有”找谁”。他做的是——蹲下去,看了一眼,走到阳台,关角阀,拿盆接住残留的水,然后掏出手机拍了两张照片,开始搜”角阀接口渗水怎么处理”。
从发现到止住,四分半。
同样的渗漏,租的房子里他拖了十一天,自己的房子里他四分半搞定。
他的能力变了吗?没有。他还是那个从来没干过水电活的程序员。变的是一件事:这房子现在是谁的。
租房子的时候,他关心的是”我什么时候能住得舒服”。买了之后,他关心的是”这玩意儿坏了我要怎么让它停下来”。这两个问题,会把你导向完全不同的行为。
前者让你去拍照、去 @ 别人、去等回复。后者让你去摸清楚水闸在哪。
而最讽刺的一点是:摸清楚水闸在哪,只花了半个下午。
Go 实例:一个支付回调 Handler 的两次写法
小林负责的那段代码,长这样(我把它简化成一个可以直接跑起来的版本,数据库和消息队列都用内存模拟):
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"strings"
"sync"
)
const (
secret = "demo-secret"
maxBody = 1 << 16 // 64KB:回调 body 超过这个数一定是异常
)
type Callback struct {
NotifyID string `json:"notify_id"` // 上游回调唯一 ID,幂等键
OrderID string `json:"order_id"`
Status string `json:"status"`
Amount int64 `json:"amount"` // 分
Sign string `json:"sign"`
}
func sign(cb Callback) string {
cb.Sign = ""
raw, _ := json.Marshal(cb)
m := hmac.New(sha256.New, []byte(secret))
m.Write(raw)
return hex.EncodeToString(m.Sum(nil))
}
func verifySign(cb Callback) bool {
return hmac.Equal([]byte(sign(cb)), []byte(cb.Sign))
}
func mk(notifyID, orderID, status string, amount int64) string {
cb := Callback{NotifyID: notifyID, OrderID: orderID, Status: status, Amount: amount}
cb.Sign = sign(cb)
b, _ := json.Marshal(cb)
return string(b)
}
// store 用内存模拟数据库和消息队列,方便直接 go run 看结果
type store struct {
mu sync.Mutex
orders map[string]string
seen map[string]bool // 幂等键
events []string // 已经投递给下游的事件
pending []Callback // 投递失败、等待补偿的
}
func newStore() *store {
return &store{orders: map[string]string{}, seen: map[string]bool{}}
}
func (s *store) seed() {
s.orders["o-1001"] = "unpaid"
s.orders["o-1002"] = "unpaid"
s.orders["o-boom-1"] = "unpaid"
}
func (s *store) markSeen(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.seen[id] {
return false
}
s.seen[id] = true
return true
}
func (s *store) unsee(id string) {
s.mu.Lock()
delete(s.seen, id) // 处理失败时归还幂等键,让上游的重试能重新进来
s.mu.Unlock()
}
func (s *store) updateOrder(id, status string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.orders[id]; !ok {
return fmt.Errorf("订单不存在: %s", id)
}
s.orders[id] = status
return nil
}
func (s *store) publish(cb Callback) error {
if strings.Contains(cb.OrderID, "boom") { // 模拟下游抖动
s.mu.Lock()
s.pending = append(s.pending, cb) // 落补偿队列
s.mu.Unlock()
return errors.New("下游 503")
}
s.mu.Lock()
s.events = append(s.events, cb.OrderID+":"+cb.Status)
s.mu.Unlock()
return nil
}
type metrics struct {
mu sync.Mutex
m map[string]int
}
func newMetrics() *metrics { return &metrics{m: map[string]int{}} }
func (x *metrics) inc(k string) {
x.mu.Lock()
x.m[k]++
x.mu.Unlock()
}第一版,小林写的。他只对自己那一段负责:
// v1:只对我写的那一段负责
func handleV1(s *store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body) // 坑 1:上游给我多大我都照单全收
var cb Callback
if err := json.Unmarshal(body, &cb); err != nil {
return // 坑 2:什么都不写,Go 默认返回 200
}
if !verifySign(cb) {
w.WriteHeader(http.StatusOK) // 坑 3:验签失败也 200,永远没人知道
return
}
// 以下这一段才是"我的代码"
if err := s.updateOrder(cb.OrderID, cb.Status); err != nil {
log.Println("update order failed:", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
// 下游通知是别的组的事,失败了我就打一行日志
if err := s.publish(cb); err != nil {
log.Println("publish failed:", err) // 坑 4:只打日志,状态已经不一致
}
w.WriteHeader(http.StatusOK) // 坑 5:没有幂等,上游重试会重复处理
}
}这版代码”跑起来了”,而且在小林的测试用例里全绿。问题在于,这五个坑里没有一个会出现在他的测试用例里,它们全都藏在边界上——上游的行为、下游的抖动、数据的形状。
而”边界”恰恰是 owner 唯一真正需要关心的地方。函数内部写错了,编译器、单测、代码评审都能兜住;边界上出事,只有 owner 能兜。
第二版,是我让他重写之后的:
// v2:对整条链路负责
func handleV2(s *store, m *metrics) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// 1. 上游的 body 不是"别人的东西",它跑在我的进程里,占我的内存
body, err := io.ReadAll(io.LimitReader(r.Body, maxBody))
if err != nil {
m.inc("read_err")
http.Error(w, "read body", http.StatusBadRequest)
return
}
var cb Callback
if err := json.Unmarshal(body, &cb); err != nil {
// 2. 解析不了 = 我这侧处理不了,明确让上游重试,同时我自己要看得见
m.inc("bad_payload")
log.Printf("[callback] 无法解析的回调: %q", truncate(string(body), 200))
http.Error(w, "bad payload", http.StatusBadRequest)
return
}
if !verifySign(cb) {
// 3. 验签失败重试也没用,用 200 止住重试风暴,但必须报警
m.inc("bad_sign")
log.Printf("[callback][ALERT] 验签失败 notify_id=%s order_id=%s", cb.NotifyID, cb.OrderID)
w.WriteHeader(http.StatusOK)
return
}
// 4. 幂等:上游一定会重试,重试不是异常,是常态
if !s.markSeen(cb.NotifyID) {
m.inc("duplicate")
w.WriteHeader(http.StatusOK)
return
}
// 5. 我这段失败时,要把幂等键还回去,否则上游重试会被幂等拦掉,这笔钱就永远丢了
if err := s.updateOrder(cb.OrderID, cb.Status); err != nil {
m.inc("update_err")
log.Printf("[callback] 更新订单失败,已归还幂等键: %v", err)
s.unsee(cb.NotifyID)
http.Error(w, "internal", http.StatusInternalServerError)
return
}
// 6. 下游不是"别人的事":失败要进补偿队列,不能只打一行日志
if err := s.publish(cb); err != nil {
m.inc("publish_err")
log.Printf("[callback] 下游投递失败,已入补偿队列: %v", err)
// 对上游仍然返回成功:钱已经到账了,不该让上游无限重试
}
m.inc("ok")
w.WriteHeader(http.StatusOK)
}
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
func post(h http.HandlerFunc, body string) string {
srv := httptest.NewServer(h)
defer srv.Close()
resp, err := http.Post(srv.URL+"/callback", "application/json", strings.NewReader(body))
if err != nil {
return "请求失败: " + err.Error()
}
defer resp.Body.Close()
return fmt.Sprintf("HTTP %d", resp.StatusCode)
}
func main() {
bigBody := `{"notify_id":"n-big","order_id":"` + strings.Repeat("x", 1<<20) + `"}`
badSign := strings.Replace(mk("n-bad", "o-1002", "paid", 5000), `"sign":"`, `"sign":"00`, 1)
cases := []struct{ name, body string }{
{"正常回调", mk("n1", "o-1001", "paid", 9900)},
{"上游重复重试同一条", mk("n1", "o-1001", "paid", 9900)},
{"验签失败", badSign},
{"body 是垃圾数据", `这不是 json`},
{"超大 body(1MB)", bigBody},
{"订单不存在", mk("n2", "o-404", "paid", 1000)},
{"下游抖动", mk("n3", "o-boom-1", "paid", 2000)},
}
fmt.Println("=== v1:只对我写的那一段负责 ===")
s1 := newStore()
s1.seed()
for _, c := range cases {
fmt.Printf(" %-22s -> %s\n", c.name, post(handleV1(s1), c.body))
}
fmt.Printf(" 订单: %v\n", s1.orders)
fmt.Printf(" 已投递: %v 待补偿: %d 埋点: 无\n", s1.events, len(s1.pending))
fmt.Println("\n=== v2:对整条链路负责 ===")
s2 := newStore()
s2.seed()
m2 := newMetrics()
for _, c := range cases {
fmt.Printf(" %-22s -> %s\n", c.name, post(handleV2(s2, m2), c.body))
}
fmt.Printf(" 订单: %v\n", s2.orders)
fmt.Printf(" 已投递: %v 待补偿: %d 埋点: %v\n", s2.events, len(s2.pending), m2.m)
}跑一遍,输出大概是这个样子:
=== v1:只对我写的那一段负责 ===
正常回调 -> HTTP 200
上游重复重试同一条 -> HTTP 200
验签失败 -> HTTP 200
body 是垃圾数据 -> HTTP 200
超大 body(1MB) -> HTTP 200
订单不存在 -> HTTP 500
下游抖动 -> HTTP 200
订单: map[o-1001:paid o-1002:unpaid o-boom-1:paid]
已投递: [o-1001:paid o-1001:paid] 待补偿: 0 埋点: 无
=== v2:对整条链路负责 ===
正常回调 -> HTTP 200
上游重复重试同一条 -> HTTP 200
验签失败 -> HTTP 200
body 是垃圾数据 -> HTTP 400
超大 body(1MB) -> HTTP 400
订单不存在 -> HTTP 500
下游抖动 -> HTTP 200
订单: map[o-1001:paid o-1002:unpaid o-boom-1:paid]
已投递: [o-1001:paid] 待补偿: 1 埋点: map[bad_payload:1 bad_sign:1 duplicate:1 ok:3 publish_err:1 update_err:1]
七种输入,v1 有五种返回了 200。从监控上看,v1 是一个”成功率 86%,非常健康”的接口。
可实际上它干了什么?重复回调被处理了两遍(已投递里有两个 o-1001:paid,如果下游是发货或者打款,这就是资损);验签失败静默吞掉(伪造回调进来了你不知道);一兆的垃圾 body 被完整读进内存(并发来几十个就够你喝一壶);下游抖动的那一笔,事件彻底丢了,没有任何补偿的痕迹。
v2 的状态码其实只变了两个,真正变的是那些看不见的东西:埋点从”无”变成了七个计数器,投递失败从”打一行日志”变成了”进补偿队列”,重复请求从”处理两遍”变成了”识别出来计数”。
这里有个原则值得记住:一个接口对外的状态码只有十来种,但它的内部状态有几十种。owner 关心的不是那十来种,是那几十种。
还有一句更狠的:v1 和 v2 在”正常路径”上的行为完全一样。这就是为什么只对自己那段代码负责的人,永远觉得自己没问题。
老陈的深层发问
-
你现在手上负责的模块,如果它今晚十一点开始每五分钟报一次错,第一个被叫醒的人会是谁?如果那个人不是你,你觉得这合理吗?
-
你上一次主动打开一个”不是你写的”模块的源码,是什么时候?你是去看代码,还是去看它的监控曲线?
-
如果明天公司要求每个模块在系统里登记一个 owner,你敢把名字填在你现在负责的那块地上吗?填写之前,你会想先补上哪三样东西?
-
你有没有过这样的时刻:一个问题被别人解决了,你心里松了一口气,而不是遗憾”为什么不是我解决的”?
生活思考题
思考题一:你跟三个同事合租一套四居室。有一天晚上,客厅的灯不亮了。四个人都在自己房间里,谁都不会主动去修。为什么?
参考答案
第一层:大多数人会怎么答 “因为没人愿意吃亏。修灯要花时间、可能还要花钱,但是灯亮了大家都能用,出钱出力的那个人得不到额外回报。典型的公地悲剧。”
第二层:这个答案为什么不够 “愿意吃亏”这个解释,隐含了一个前提:这四个人都把客厅当成”不是自己的地方”。可你去看真实的合租,很多时候不是这样。有的合租房,客厅是最干净的,因为四个人里总有一个把公共区域当成自己的区域在维护。所以”愿意吃亏”只是表象,真正的问题是这四个人的心理边界画在哪里。
更深的一层盲区在于:你以为问题出在”没人愿意修”,其实问题出在”没人知道灯坏了该找谁”。如果这套房子有个明确的规矩——“公共区域的问题,谁第一个发现谁负责跟进到解决”——灯早就修好了。四个人缺的不是觉悟,是一条规则。
第三层:更深的一层是什么 合租房的客厅,和公司里”王磊走了之后没人负责的支付回调模块”,是同一个结构。区别只有一个:公司里的模块,不会有人主动来当那个”把客厅当成自己区域的人”,因为这件事做好了没有显性回报,做砸了有显性责任。
所以真正稀缺的不是”愿意吃亏的人”,而是”敢在无主之地插旗的人”。插旗是有风险的:你插了旗,以后客厅堵了下水道、灯泡坏了、墙皮掉了,都会来找你。但你换来的东西也更值钱——你对这套房子拥有了定义权。规矩怎么定、钱怎么摊、以后再有人搬进来要遵守什么,都是你说了算。
这是 ownership 最反直觉的一点:它看起来是责任的增加,其实是权力的增加。你先接下责任,权力才会跟着来。顺序反了,就变成了争权,而不是 ownership。
第四层:落到 Go 工程师身上 本周可以做的三件事: 一、挑一个你们组”没人认领”的模块或服务(一定存在,可能是某个定时任务、某个老的对账脚本、某个没人动的中间件封装),在代码仓库里给它补一个
OWNERS文件,或者至少在 README 顶部写上一行”Owner: 你的名字”。不要先问领导,先写。 二、给它补上最基础的三样东西:健康检查接口、关键路径的耗时埋点、错误分类计数。这三样东西加起来通常不超过两百行代码。 三、在下一次周会上,主动说一句:“这个模块以后出问题找我,我先看看。“——注意,这句话的时态是现在时,不是”等我有时间”。
思考题二:你在现在住的小区住了三年。你知道你这一层的电闸箱在哪儿吗?如果不知道,这算不算一个问题?
参考答案
第一层:大多数人会怎么答 “不算问题。我住三年都没跳过闸,说明电路是稳定的。等真出问题了,我再看也来得及,物业半小时就到。”
第二层:这个答案为什么不够 这个答案把”没出过事”当成了”安全”。这是工程师群体里最常见的一个认知错误,也是最难纠正的一个。
现实是:没跳过闸,不代表电路没问题,只代表负载还没到临界点。小林住的那套房子,厨房角阀渗水之前,那个角阀已经用了六年,前五年半一点事都没有。它不是”突然坏了”,它是一直在坏,只是最后一刻才让你看见。
更深一层的盲区是:你在等物业。物业的响应时间,是你在把系统可用性的控制权交给一个你完全不了解的组织。这跟你在代码里依赖一个第三方 SDK 但从没读过它的连接池实现,是同一件事。
第三层:更深的一层是什么 知道电闸箱在哪儿这件事,真正的价值不在”跳闸时你能快速恢复”,而在于它改变了你对这套房子的认知方式。
不知道的人在想:“我家有电。“知道的人在想:“我家这条线路上,空调是一个回路,厨房是一个回路,卫生间是一个回路,如果我把电暖器和烤箱同时开,厨房那条回路可能扛不住。”
前者对系统的理解是布尔值:通的/不通的。后者对系统的理解是拓扑图。布尔值出问题时你只能重启,拓扑图出问题时你能判断。
职级差异的很大一部分,就藏在这个”布尔值 vs 拓扑图”里。P5 看自己的服务,看到的是”它是活的”。P6 看自己的服务,看到的是”它挂在哪些东西上、哪些东西挂在它上面、哪里是单点、哪里是我今晚会被叫醒的地方”。
第四层:落到 Go 工程师身上 挑一个你最熟的服务,用一张纸画出来:它有几个外部依赖(MySQL、Redis、MQ、下游 RPC、配置中心、DNS)?每个依赖的超时是多少?每个依赖挂掉的时候,你的服务是快速失败、还是跟着挂、还是慢慢拖死?
然后把这张纸收起来,一周之后重画一遍。如果两次画出来的东西不一样,说明你对这个服务的认知还是布尔值级别的。
最后做一件事:把每个依赖的超时时间 grep 出来,看有没有哪个是 0(无限等待)。如果有,恭喜你,你已经找到了一个你不知道它在哪的”电闸箱”。
3.2 ownership 到底是什么意思:不是所有权,是”找不到别人了”
“ownership 这个词,中文你怎么翻?”
“所有权。”
“错了。“老陈说得很干脆。
“那是什么?”
“你先想想,‘所有权’这三个字,在日常生活里意味着什么?”
小林想了想:“意味着这东西是我的,别人不能动,我想怎么处理就怎么处理。”
“对。所以’所有权’的核心是排他。“老陈说,“你有一辆车,你可以不让别人开。这是所有权。”
“嗯。”
“现在我问你:你 own 一个服务,你能不让别人改它的代码吗?”
”……不能。”
“你能不让别人部署吗?”
“不能。”
“你能决定它什么时候下线吗?”
”……也不能。”
“那它算哪门子所有权。”
小林笑了笑,又收住了。
“那 ownership 到底是什么?”
老陈没直接答,反问了一句:“王磊走之后,你们组那个支付回调模块,如果现在出了问题,最后会落在谁头上?”
”……落在我们组长头上吧。”
“你组长会怎么办?”
“他会……找人看。”
“找谁?”
“找……”小林停住了。
他脑子里过了一遍组里的人:三个后端,一个是去年毕业的小周,一个跟他差不多同期、但一直做商家端的老赵,还有一个上个月刚来的。没有一个人碰过支付回调。
“找我。“他说。
“为什么是你?”
“因为我改过 handleCallback 那一段。”
“你改了多少?”
“大概……一百多行。”
“整个模块多少行?”
“八千多吧。”
“你写了一百多行,剩下七千九百行你一行没看过。可这事最后会落在你头上。“老陈说,“这就是 ownership 的真实定义。”
”……”
“不是所有权。“老陈一字一顿,“是:找不到别人了。”
小林愣了三秒。
“这也太……”他找了个词,“太被动了。听着像是你被剩下的人。”
“本来就是。”
“那我能不能拒绝?”
“你可以。“老陈说,“你可以说’这块我没接过,我不知道’。你说得出来吗?”
小林张了张嘴。
说不出来。
他想起王磊走之前,在楼梯间递给他那根烟的时候说过的话。他当时笑着接了,说了句”行,放心”。他现在才反应过来,那不是一句客气话。
“老陈,我不太同意。“小林说,“这不合理。公司应该有机制,应该有交接,应该有文档。把所有责任压在最后一个记得的人身上,这是管理问题,不该让我用个人意志去扛。”
“你说得对。”
”……啊?”
“你说得对,这确实是管理问题。“老陈说,“可是我问你一件事——你说完这句话之后,支付回调就不出事了吗?”
”……”
“管理问题归管理问题,它今晚十一点一样会报警。报警的时候,接电话的人是你还是那个’应该存在的机制’?”
小林不说话了。
“我给你讲个更狠的。“老陈说,“技术圈有个词叫 bus factor,巴士系数。意思是你们团队里要有几个人被巴士撞了,这个项目才会停摆。巴士系数等于一,是最危险的状态。”
“我知道这个词。”
“你们支付回调的巴士系数是多少?”
”……一。”
“那个人是谁?”
”……我。”
老陈点点头。
“现在你明白我为什么说’ownership 是找不到别人了’了吧。这句话不是在夸你,也不是在害你。它是在陈述一个事实。你要做的第一件事,不是抱怨这个事实,是先承认它。”
“承认了之后呢?”
“承认了之后,你才有资格谈第二件事:怎么把这个系数从一变成二。”
“这岂不是……”小林抬起头,“你先让我扛下来,然后我再把责任分出去?”
“对。”
“那我图什么?”
“你图两样东西。“老陈说,“第一,你是唯一知道这块地全貌的人,这个信息差就是你的价值,而且它没法靠加班堆出来。第二,把系数从一变成二这件事本身,是 P7 的活。你在 P6 就开始做,你就提前半年到一年到 P7。”
“我担着风险,替公司解决了单点问题,然后换来一个’提前到 P7’的可能性。”
“听着挺亏的哈。”
“挺亏的。“小林没客气。
“那你想想,三年前你在第一家公司的时候,为什么不亏?”
”……”
“因为那时候你什么都没有,你巴不得有人把一块地交给你。“老陈说,“人就是这样:没有的时候想要,有了之后嫌重。“
生活里的同一件事:回家过年那三天,你就是家里的 IT 部门
小林的老家在湖南常德,父母住在县城一套老房子里。
他每年回去两三次,最多待五天。每次回去的第一天,他要干一件事:修东西。
腊月二十九那次他记得最清楚。下午三点到家,行李箱还没拉进房间,他妈就从厨房出来了。
“小林,你来看一下,我这个手机又提示存储不够了。”
他一边换鞋一边接过来,看了三分钟。相册八千多张照片,其中大概六千张是同一个角度拍的——他小侄女。还有十几个微信群,每个群都开了自动下载,光”相亲相爱一家人”那个群就占了 4.7 个 G。
他删了三个备忘录 App,关掉了六个群的自动下载,把照片开了 iCloud 同步。十二分钟。
他把手机递回去。他妈拿过来点了两下,说”好了好了,快了”。
然后他爸从客厅喊:“小林,你看这个电视,怎么连不上那个投屏了?”
投屏的问题是路由器开了 AP 隔离。他上路由器后台,找了半天,把那个勾去掉。
然后是宽带光猫闪红灯,他打客服电话,报了账号,等了四十分钟,师傅上门,换了个光猫。
然后是帮他妈把医保电子凭证重新激活。
然后是帮他爸的平板电脑清掉一堆弹窗。
那天晚上十一点半,他坐在沙发上,他爸从房间里出来倒了杯水,路过的时候随口说了一句:
“现在家里这些玩意儿,都归你管了。”
当时他没多想,还笑了笑。
后来有一年他过年没回去,在杭州加班。大年初二给他妈打电话,他妈说家里网断了两天了。
“怎么不找人修?”
“找了,你张叔叔来看了一趟,弄不明白。”
“那打客服啊。”
“打了,说得等初三才有人上门。”
“就两天没网,也没事吧?”
他妈在电话那头说:“没事,就是电视看不了,你爸这两天一直在楼下棋牌室待着。”
挂了电话,小林坐在出租屋的床边,愣了很久。
他想起一件事:他妈手机里存着宽带账号的那张纸条,是他两年前写的。他妈从没记住过那个账号是什么,她只知道”小林知道”。
这就是 ownership 最原始的样子。
不是他能力强,不是他自愿的,不是谁任命的。是在这个家里,找不到别人了。
他妈不会因为他”没被分配”就自己学会清手机内存。他爸也不会因为”这不公平”就自己去研究什么是 AP 隔离。系统是照常运转的,只是运转不了的时候,所有人都在等一个人。
而那个人如果一直不出现,所有人就会接受”看不了电视”这个结果,然后去棋牌室。
这是小林后来说的话,我觉得比老陈讲得还狠:
“公司里那些没人负责的模块,最后的结局不是爆炸。是大家一起接受它’就那样’。“
Go 实例:把 ownership 写成代码
ownership 听起来很虚。但它有一部分是可以写成代码的,而且不难。
先看大多数团队里”没有 owner”的样子。三个模块,三个包级变量,健康检查散落在各处:
package main
// ===== v1:没有 owner 的样子 =====
// 问题:
// 1. 每加一个模块,要改三处地方(变量、健康检查函数、路由)
// 2. /healthz 只告诉你"不健康",不告诉你是哪个模块、哪个依赖
// 3. 健康检查本身没有超时,一个慢依赖能把 /healthz 整个挂死
// 4. 没有任何地方写清楚"这个模块依赖了什么"
var orderDBOK = true
var payMQOK = true
var cacheOK = false // 假设缓存挂了
func healthzV1(w http.ResponseWriter, r *http.Request) {
if orderDBOK && payMQOK && cacheOK {
w.Write([]byte("ok"))
return
}
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
}这个版本的 /healthz 在缓存挂掉的时候会返回 503。大刘的探针收到 503,会把这台机器摘掉。摘掉之后流量打到另外两台,另外两台压力变大,也 503,然后全摘掉。
最后整条链路挂了。而根因只是”缓存的一个分片连不上,但缓存是有降级逻辑的,本来不该让整机下线”。
v2 的做法是给”模块”定一个契约,然后让契约自己产生可观测性。下面这份代码是完整的,可以直接跑:
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/http/httptest"
"sort"
"sync"
"time"
)
// Dependency 声明一个外部依赖。写下来这件事本身就价值巨大:
// 它把"看不见的系统"变成了一份可以 review 的清单。
type Dependency struct {
Name string // 依赖名字
Kind string // mysql / redis / mq / rpc
Target string // 连接串(脱敏)或服务名
Soft bool // 软依赖:挂了可以降级,不应该让整机下线
}
// Module 是 owner 对一个模块的最低承诺。
// 你能说出它叫什么、它依赖什么、它现在健康不健康,
// 你就是它的 owner。说不出来,你只是个提交者。
type Module interface {
Name() string
Owner() string
Deps() []Dependency
Health(ctx context.Context) error
}
type Registry struct {
mu sync.Mutex
modules []Module
}
func (g *Registry) Register(m Module) {
g.mu.Lock()
defer g.mu.Unlock()
g.modules = append(g.modules, m)
}
type healthItem struct {
Name string `json:"name"`
Owner string `json:"owner"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
CostMS int64 `json:"cost_ms"`
}
// ServeHTTP 是统一的健康检查入口。三个关键点:
// 1. 每个模块自带超时预算,一个慢模块不会拖死整份报告
// 2. 软依赖失败只降级,不返回 503(否则会被负载均衡摘机)
// 3. 输出里带 owner,出事的时候一眼知道该叫谁
func (g *Registry) ServeHTTP(w http.ResponseWriter, r *http.Request) {
g.mu.Lock()
mods := append([]Module(nil), g.modules...)
g.mu.Unlock()
items := make([]healthItem, len(mods))
var wg sync.WaitGroup
for i, m := range mods {
wg.Add(1)
go func(i int, m Module) {
defer wg.Done()
ctx, cancel := context.WithTimeout(r.Context(), 300*time.Millisecond)
defer cancel()
start := time.Now()
err := m.Health(ctx)
it := healthItem{
Name: m.Name(),
Owner: m.Owner(),
Status: "up",
CostMS: time.Since(start).Milliseconds(),
}
if err != nil {
it.Status = "down"
it.Error = err.Error()
// 超时单独标记:它通常意味着依赖的连接池或 GMP 有问题
if errors.Is(err, context.DeadlineExceeded) {
it.Status = "timeout"
}
}
items[i] = it
}(i, m)
}
wg.Wait()
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
// 只有硬依赖挂了才让负载均衡摘机
hardDown := false
for i, it := range items {
if it.Status == "up" {
continue
}
for _, d := range mods[i].Deps() {
if !d.Soft {
hardDown = true
}
}
}
code := http.StatusOK
overall := "up"
if hardDown {
code = http.StatusServiceUnavailable
overall = "down"
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]any{"status": overall, "modules": items})
}注意 items[i] = it 这一行。这里没有加锁,也没用 channel 收集结果。因为每个 goroutine 只写自己那个下标,下标之间不重叠,这是 Go 里少数几种安全的并发写场景。很多人的第一反应是套一个 mutex,那是把 300 毫秒的并行检查变成了串行。
然后是三个具体的模块实现,其中一个故意慢,一个故意挂:
type orderModule struct{}
func (orderModule) Name() string { return "order" }
func (orderModule) Owner() string { return "小林" }
func (orderModule) Deps() []Dependency {
return []Dependency{
{Name: "order_mysql", Kind: "mysql", Target: "rm-2ze1.mysql.rds:3306", Soft: false},
{Name: "order_cache", Kind: "redis", Target: "r-bp1.redis:6379", Soft: true},
}
}
func (orderModule) Health(ctx context.Context) error {
// 缓存是软依赖,这里即使失败也应该降级而不是整体 down
if err := pingRedis(ctx); err != nil {
log.Printf("[order] 缓存不可用,已降级: %v", err)
}
return pingMySQL(ctx) // 只把硬依赖的结果返回
}
type callbackModule struct{}
func (callbackModule) Name() string { return "pay_callback" }
func (callbackModule) Owner() string { return "小林" }
func (callbackModule) Deps() []Dependency {
return []Dependency{
{Name: "order_mysql", Kind: "mysql", Target: "rm-2ze1.mysql.rds:3306", Soft: false},
{Name: "settle_mq", Kind: "mq", Target: "ons://settle-topic", Soft: false},
}
}
func (callbackModule) Health(ctx context.Context) error {
return pingMySQL(ctx)
}
type reportModule struct{}
func (reportModule) Name() string { return "report" }
func (reportModule) Owner() string { return "老赵" }
func (reportModule) Deps() []Dependency {
return []Dependency{{Name: "ch_proxy", Kind: "rpc", Target: "clickhouse-proxy:9000", Soft: true}}
}
func (reportModule) Health(ctx context.Context) error {
time.Sleep(2 * time.Second) // 模拟一个慢依赖
return nil
}
func pingMySQL(ctx context.Context) error { return nil }
func pingRedis(ctx context.Context) error { return errors.New("connection refused") }
func main() {
reg := &Registry{}
reg.Register(orderModule{})
reg.Register(callbackModule{})
reg.Register(reportModule{})
// /healthz:给负载均衡用的
mux := http.NewServeMux()
mux.Handle("/healthz", reg)
// /debug/own:给人和排班表用的,一眼看清每块地归谁
mux.HandleFunc("/debug/own", func(w http.ResponseWriter, r *http.Request) {
type row struct {
Module string `json:"module"`
Owner string `json:"owner"`
Deps []Dependency `json:"deps"`
}
out := []row{}
for _, m := range reg.modules {
out = append(out, row{Module: m.Name(), Owner: m.Owner(), Deps: m.Deps()})
}
json.NewEncoder(w).Encode(out)
})
srv := httptest.NewServer(mux)
defer srv.Close()
for _, path := range []string{"/healthz", "/debug/own"} {
resp, err := http.Get(srv.URL + path)
if err != nil {
log.Fatal(err)
}
var v any
json.NewDecoder(resp.Body).Decode(&v)
resp.Body.Close()
b, _ := json.MarshalIndent(v, "", " ")
fmt.Printf("GET %s (HTTP %d)\n%s\n\n", path, resp.StatusCode, b)
}
}跑起来你会看到 /healthz 里 report 那一栏是 timeout,耗时 300 毫秒——它没有拖垮整份报告,也没有让 /healthz 变成 503。order 那一栏是 up,虽然它的缓存挂了(软依赖降级)。
而 /debug/own 输出的是这样一份东西:
[
{"module":"order","owner":"小林","deps":[
{"Name":"order_mysql","Kind":"mysql","Target":"rm-2ze1.mysql.rds:3306","Soft":false},
{"Name":"order_cache","Kind":"redis","Target":"r-bp1.redis:6379","Soft":true}]},
{"module":"pay_callback","owner":"小林","deps":[ ... ]},
{"module":"report","owner":"老赵","deps":[ ... ]}
]这份清单,就是”bus factor = 1”那件事的物证。它最有用的时刻,不是平时,是人要走的时候。
王磊走的那天,如果有人把这份 JSON 打印出来递给他,交接会是什么样子?
“支付回调,owner 是你,依赖 MySQL 和结算 MQ,硬依赖两个。这两个依赖的监控面板在这儿,超时配置在这儿,降级开关在这儿。”
半小时交接完。
而现实是,王磊走的那天,他们只是吃了顿饭。
这里的原则是:ownership 不是一种态度,是一份可以被打印出来的清单。 你写不出来,你就还没 own 它。
老陈的深层发问
-
如果你明天离职,你手上那块地,交接给别人需要多久?如果需要超过两天,那两天里你说不出口的东西是什么?
-
你们组有没有一块地,是”所有人都知道它在,但没人愿意提”的?你上一次在公开场合提它,是什么时候?
-
你有没有主动问过你的主管:“我负责的边界到哪儿?“如果问过,他的回答你能复述吗?
-
如果公司明天宣布,每个模块的 owner 要对这个模块未来一年的线上故障次数负责,你第一反应是想多认领一块地,还是想把手上的交出去?
生活思考题
思考题一:你家里那个”只有你会用”的东西是什么?你是怎么成为那个人的?
参考答案
第一层:大多数人会怎么答 几乎每个人都能立刻说出一样东西。小林说的是”宽带账号”,我听过别的答案:微波炉的某个档位、洗衣机那个”不能转的时间段”、空调遥控器背面那个没标字的小按钮、净水器换滤芯的顺序。
常见的解释是:“因为我用得最多,熟能生巧。”
第二层:这个答案为什么不够 “用得多就熟”解释不了另一半事实:家里其他人用得也不少,但他们从来没学会。
小林他爸用了五年智能电视,投屏一次都没成功过。不是他学不会,是他决定不学。每次他按两下没反应,就喊小林。这个决定在第一次发生的时候是完全合理的——儿子在旁边,喊一嗓子五秒钟搞定,自己研究要半小时。
所以真相是:每一个”只有你会”的东西,都是一次集体投票的结果。 每一次你帮他搞定,都是在给”这事归他”这张票加一票。你以为是你的能力造成了单点,其实是所有人的偷懒共同造成了单点。
这个盲区在工程团队里的表现就是:你每次帮同事调一下他那个模块的问题,你就在给”这块地归小林”投票。半年之后,这事就定了,而且没人记得是怎么定的。
第三层:更深的一层是什么 真正有意思的问题是:为什么你不拒绝?
你不是不知道这是个坑。你第一次帮人搞定的时候,心里是有快感的。被需要是一种很强的正反馈,尤其是当你对自己的技术水平不那么确定的时候。
换句话说,“成为单点”这件事,在早期是上瘾的。它给了你一种廉价但真实的确定感:我是有用的人。
危险在于,这个确定感会随着时间变质。前两年你帮人调问题,学到东西。第三年你还是帮人调同样的问题,除了熟练度,你什么都没得到,但你的时间被占满了。到了这个时候,单点不再是你的资产,它是你的债务——而且是你主动欠下的。
所以判断标准不是”我是不是单点”,而是”我去年做的这件事,和我今年做的这件事,是不是同一件事”。
第四层:落到 Go 工程师身上 本周做一件事:把你这周帮别人解决的问题记下来,分成两类。
A 类:解决过程中你学到了新东西(一个新的 pprof 用法、一段你没读过的源码、一个你不知道的配置项)。 B 类:你用已有的知识,重复了一次你去年做过的事。
如果 B 类超过一半,说明你的 ownership 已经在往债务方向转了。这时候正确的动作不是”拒绝帮忙”,而是把 B 类问题固化成工具或者文档,让别人自己能做。
具体到代码上:把你最常用的三个排查动作,写成一个
debug.go里的 HTTP 接口(比如/debug/goroutines、/debug/slowlog、/debug/deps)。写完在组里说一句”以后这类问题你们自己 curl 这个接口”。这就是把 bus factor 从 1 变成 2 的具体做法。而且它有个副作用:你会被迫把脑子里的隐性知识变成显性的代码,这件事本身就把你往上推了一级。
思考题二:如果你们团队明天集体出去团建三天,公司要求留一个人远程待命。这个人是被选出来的,还是自己站出来的?
参考答案
第一层:大多数人会怎么答 “当然是排班。谁值班谁待命,公平合理。”
第二层:这个答案为什么不够 排班能解决”人在不在”,解决不了”人能不能处理”。
团建那三天,如果线上只是磁盘满了、机器重启一下就好,排班的人能搞定。但如果出的是”支付回调的 P99 突然涨了十倍”,排班的人能做到什么程度?他能重启,能扩容,能 rollback。他做不了的是在三十分钟内判断出是不是上周那次灰度引入的。
而”三十分钟内判断出是不是上周那次灰度引入的”,恰恰是事故里最贵的那三十分钟。
所以真正的待命有两个层次:人在和判断力在。排班能买到”人在”,买不到”判断力在”。后者只能靠一个人对一块地长期、连续的关注积累出来,没法交接,没法排班。
第三层:更深的一层是什么 这个思考题真正要问的是:你愿意成为那个”自己站出来”的人吗?
大多数人的答案是”不愿意,凭什么”。这个反应很正常,也很危险。
正常在于:站出来是纯支出,没有显性回报。危险在于——在一个组织里,“能处理别人处理不了的问题”这件事,是唯一无法被职级、资历、关系替代的能力。它是你所有议价能力的来源。
我见过太多人,三十五岁的时候突然发现自己没有议价能力。他们的履历很漂亮,做过很多项目,带过小组。但如果你问一句”有什么事是非你不可的”,他们会沉默。
而那些二十七八岁”自己站出来”过的人,三十五岁的时候不太慌。不是因为他们职级高,是因为他们知道:不管去哪家公司,半年之内他们能成为那个”找不到别人了”的人。这个能力是可以迁移的,因为它本质上是一种工作习惯,不是某个业务的知识。
第四层:落到 Go 工程师身上 不要等公司给你排班。自己给自己排一个”owner 巡视”,每周固定四十分钟,做四件事:
一、打开你负责模块的 P99、QPS、错误率三条曲线,看一周。不要看瞬时值,看形状——有没有尖刺?有没有缓慢抬升的基线?尖刺是别人造成的,缓慢抬升是你的代码在漏。 二、打开错误日志,按错误类型聚合,看 top 5。你上一次看这个是什么时候? 三、看一眼 goroutine 数量的曲线。Go 服务里,goroutine 缓慢增长是泄漏的唯一早期信号,等到 OOM 就晚了。 四、问自己一句:这周有没有哪个告警,是”别人帮我看的”?
四十分钟,一周一次。这四十分钟是 ownership 的最小可行剂量。
3.3 pprof:别再靠猜了,让数据说话
“你上次修那个导出 OOM,是怎么定位的?”
“我猜的。”
老陈笑了:“挺诚实。那你讲讲怎么猜的。”
“导出功能 OOM,我一听就觉得是一次性查了太多数据。“小林说,“五百万条订单,全 load 进内存,然后 excelize 一行一行写。所以我就加了分页,一页五千,写完 flush 一次。”
“加完之后呢?”
“内存从 6 个 G 降到 1 个 G 多一点。好了。”
“你怎么证明是分页解决的?”
小林抬头:”……什么意思?上线之后内存就降下来了啊。”
“我问的是,你怎么证明降下来的那 5 个 G,是’一次性加载’那部分?”
”……”
“有没有可能是别的原因?比如说,你在改分页的时候顺手改了点别的?”
小林想了几秒。
“我把一个 fmt.Sprintf 的日志打点删掉了。“他有点不自在,“那个日志在循环里,每一行都打一次。”
“一行日志,五百万行就是五百万次 Sprintf。”
“对。”
“所以你其实不知道那 5 个 G 是谁的。”
”……我不知道。“小林说,“但我确实是靠猜解决了一个线上问题。”
“你靠猜碰巧解决了一个线上问题。“老陈纠正他,“这两个词差别很大。”
“那你让我怎么办?难道每次都要 profiling 吗?”
“每次不一定,但是在你准备改代码之前,一定要。”
“太重了。”
“我跟你讲个事。“老陈往椅背上一靠,“我三十九岁那年,在一家做 CDN 的公司。有个调度服务的 CPU 一直偏高,百分之六七十,抖到八九十就自动扩容,每个月多烧二十几万的机器钱。”
“后来呢?”
“我带两个人查了四天。”
“四天。”
“四天。我们用的办法是二分加日志。在服务里打了大概四十个计时点,一层一层往下切,看哪一段慢。第一天排除了数据库,第二天排除了 RPC,第三天定位到一个做 IP 库匹配的函数,第四天发现……”
“发现什么?”
“发现那个函数里有一段代码在做字符串拼接,每次请求拼三十几个小字符串,用的是 +=。“老陈说,“四天,查出来一个简单的 +=。”
”……”
“查出来之后的第二周,我们组一个刚毕业的小孩,用 pprof 跑了四十分钟,把同一个结论摆在我桌上。”
小林没忍住,笑了一声。
“你笑吧,这事我到现在都觉得丢人。“老陈也笑了,“我那天晚上回去想了很久,我为什么会花四天。”
“为什么?”
“因为我先有了一个假设。我觉得调度服务的瓶颈一定在匹配算法,因为那个算法很复杂。所以我的二分法一直在算法内部切,我从没想过去看一眼那个我们认为’很简单’的辅助函数。”
“先有假设,然后找证据。”
“对。这是我们这行最常见的错误,而且越有经验的人越容易犯。“老陈说,“经验丰富意味着你脑子里有很多现成的模式,看到现象就能匹配上一个。这个能力在 80% 的时候有用,在剩下 20% 的时候,它会让你瞎掉。”
“所以 pprof 的意义是……”
“pprof 的意义不是更快。是让你在打开代码之前,先看到事实。”
小林沉默了一会儿。
“老陈,pprof 我会用。“他说,“不就是 go tool pprof 吗,我两年前就看过文档。”
“你最近一次用是什么时候?”
”……半年前。”
“这半年为什么没用?”
“因为没遇到性能问题。”
“你怎么知道没遇到?”
“出问题会有告警啊。”
老陈看着他,看了大概三秒。
“小林,你这句话里藏着一个很大的问题。”
”……”
“你把’性能问题’定义成了’触发告警的故障’。“老陈说,“可真实世界里,一个服务从’健康’到’报警’,中间有很长一段路。CPU 从 20% 慢慢爬到 45%,内存从 1.2G 爬到 2.8G,GC 暂停从 1 毫秒爬到 4 毫秒。这段路上一个告警都不会响。”
”……”
“你不是没遇到性能问题。你是没有能让你看见性能问题的仪表盘。”
“那我总不能每周都去 profile 一次所有服务吧。”
“为什么不能?”
“这不得累死。”
“我给你一个很省的办法。“老陈说,“每周四下午,挑你负责的模块里 QPS 最高的那个,抓一份 CPU profile,存下来。就存着,不分析。”
“存着干嘛?”
“存三个月。“老陈说,“三个月之后你会有十二份 profile。到时候你回头对比第一份和第十二份,你会看到一个东西。”
“什么东西?”
“你们的技术债曲线。”
小林愣住了。
“服务不是一天变慢的。“老陈说,“它是每次上线慢一点点。每一点点都小到没人注意,三个月累积起来就是 30%。你有了十二份 profile,你就有了这份债务的账单。拿着这个账单去跟你的主管谈重构,跟你拿着一句’我觉得这代码该重构了’去谈,效果差十倍。“
生活里的同一件事:化验单有五种,你只做了血常规
小林有个前同事,姓周,跟他不在一个组,但是经常一起打球。
2023 年那阵子,老周整个人状态很差。三十四岁,以前打全场能跑四节,那年打到第二节就喘。他老婆说他晚上睡十个小时,白天还是困。
他们一起吃饭的时候,老周的结论是:“最近熬夜熬多了,年纪上来了。”
于是他开始养生。枸杞泡水,买了个人体工学椅,把健身房卡续了三年。他甚至戒了咖啡。
半年过去了,情况没变好,反而更差了一点。他开始掉头发,怕冷,夏天在办公室要穿长袖。
2024 年三月,公司体检。加了一项甲状腺功能。
结果是甲减,TSH 到了 28(正常上限是 4.2)。
医生给他开了优甲乐,一天一片。两周之后,他说自己像换了一个人。
小林去医院看他的时候,老周说了句话:
“我他妈调了半年的作息。”
这件事让小林想了很久。老周不是不重视自己的身体,他非常重视。他的问题不是不行动,是在没有数据的情况下行动。
而且——这是最关键的——他没有不检查。他每年都体检的。他的问题是:他做的那个体检套餐里,不包含甲功。
一个”我每年体检”的人,和一个”我每年体检了正确的项目”的人,是两种人。
pprof 这件事跟体检是一模一样的。
大部分 Go 工程师都会说”我会用 pprof”,就像大部分人都会说”我每年体检”。但 Go 的运行时提供了至少五种不同的 profile,大部分人一辈子只用其中一种:
/debug/pprof/profile CPU:谁在吃 CPU(默认采样 10ms 一次)
/debug/pprof/heap 内存:谁在分配、分配了多少
/debug/pprof/goroutine goroutine:现在有多少个、卡在哪一行
/debug/pprof/block 阻塞:谁在等 channel、等锁、等系统调用
/debug/pprof/mutex 互斥:锁竞争最狠的地方在哪
五种,对应五种完全不同的病。
CPU profile 就像血常规。它很基础,能看出大问题,但一个”总是很累”的人做血常规,结果大概率是正常的。老周的血常规就是正常的。
而 block 和 mutex 这两个 profile,Go 默认是关闭的。它们要采样,有开销,所以官方默认关掉。你必须在代码里显式打开:
import "runtime"
func init() {
runtime.SetBlockProfileRate(1) // 记录所有阻塞事件(生产环境建议用 10000 纳秒做阈值)
runtime.SetMutexProfileFraction(1) // 记录 1/1 的锁竞争事件(生产环境建议 10 或 100)
}我在生产环境见过的服务里,打开过 mutex profile 的不超过百分之五。
这就是为什么我说”会 pprof”和”会用 pprof”是两件事。大部分人手里拿着一整套化验单,只做过第一项,然后告诉医生”我查过了,没问题”。
Go 实例:一次完整的排查,从 6.2G 到 1.1G
下面这份代码是可以直接跑的。它模拟的就是小林那个导出服务的核心循环:读一批订单日志,按商家聚合金额。
v1 是他原来的写法:
package main
import (
"fmt"
"os"
"regexp"
"runtime"
"runtime/pprof"
"strconv"
"strings"
"time"
)
// 日志形如:
// [2026-08-12 10:03:41] INFO order=a1b2c3 merchant=8801 amount=9900
var lineRe = regexp.MustCompile(`^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] (\w+) order=(\w+) merchant=(\d+) amount=(\d+)$`)
// v1:正则 + 复合 key 拼接 + 循环里打日志
func aggregateV1(lines []string) map[string]int64 {
out := make(map[string]int64)
for _, ln := range lines {
// 坑 1:FindStringSubmatch 每次都分配一个 []string,里面有 6 个 string
m := lineRe.FindStringSubmatch(ln)
if m == nil {
continue
}
level, merchant := m[2], m[4]
amount, err := strconv.ParseInt(m[5], 10, 64)
if err != nil {
continue
}
// 坑 2:复合 key,每次两次字符串拼接 = 两次分配
key := "m:" + merchant + ":" + level
out[key] += amount
// 坑 3:循环里的调试日志,五百万行就是五百万次 Sprintf
if amount > 100000 {
_ = fmt.Sprintf("big order: merchant=%s amount=%d", merchant, amount)
}
}
return out
}现在看 v2。改动之前,我(老陈)先问了一个问题:“key 里的 level,有人用吗?”
”……没人用。聚合结果只按商家出。”
// field 用下标切分,返回的子串共享原字符串的底层数组,不产生分配
func field(ln, key string) (string, bool) {
i := strings.Index(ln, key)
if i < 0 {
return "", false
}
start := i + len(key)
j := strings.IndexByte(ln[start:], ' ')
if j < 0 {
return ln[start:], true
}
return ln[start : start+j], true
}
// v2:手工解析 + 直接用 merchant 做 key + 预分配 map + 循环里不打日志
func aggregateV2(lines []string) map[string]int64 {
out := make(map[string]int64, 1<<12) // 预分配桶,省掉十几次 rehash
for _, ln := range lines {
merchant, ok := field(ln, "merchant=")
if !ok {
continue
}
amountStr, ok := field(ln, "amount=")
if !ok {
continue
}
amount, err := strconv.ParseInt(amountStr, 10, 64)
if err != nil {
continue
}
out[merchant] += amount // 没有拼接,没有新分配
}
return out
}跑起来对比一下,把 profile 也一起抓了:
func genLines(n int) []string {
out := make([]string, 0, n)
for i := 0; i < n; i++ {
out = append(out, fmt.Sprintf(
"[2026-08-12 10:%02d:%02d] INFO order=ord%07d merchant=%d amount=%d",
i%60, i%60, i, 8000+i%2000, 1000+i%50000))
}
return out
}
func bench(name string, f func([]string) map[string]int64, lines []string) {
runtime.GC()
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
start := time.Now()
res := f(lines)
cost := time.Since(start)
runtime.ReadMemStats(&after)
fmt.Printf("%-4s 耗时 %7.1f ms | 分配 %7.1f MB | 对象数 %9d | 商家数 %d\n",
name,
float64(cost.Microseconds())/1000.0,
float64(after.TotalAlloc-before.TotalAlloc)/1024/1024,
after.Mallocs-before.Mallocs,
len(res))
}
func main() {
lines := genLines(1_000_000)
fmt.Printf("样本:%d 行日志\n\n", len(lines))
bench("v1", aggregateV1, lines)
bench("v2", aggregateV2, lines)
// 抓 profile 留档,供 go tool pprof 分析
cf, err := os.Create("cpu.prof")
if err != nil {
panic(err)
}
pprof.StartCPUProfile(cf)
aggregateV1(lines)
pprof.StopCPUProfile()
cf.Close()
runtime.GC()
hf, err := os.Create("mem.prof")
if err != nil {
panic(err)
}
pprof.WriteHeapProfile(hf)
hf.Close()
fmt.Println("\n已生成 cpu.prof / mem.prof,接下来可以执行:")
fmt.Println(" go tool pprof -top -nodecount=15 ./demo cpu.prof")
fmt.Println(" go tool pprof -sample_index=alloc_objects ./demo mem.prof")
fmt.Println(" go tool pprof -http=:8080 ./demo cpu.prof // 火焰图")
}在一台普通的开发机上,一百万行的结果大概是这样:
样本:1000000 行日志
v1 耗时 1983.4 ms | 分配 412.7 MB | 对象数 12000137 | 商家数 2000
v2 耗时 128.6 ms | 分配 31.2 MB | 对象数 1000231 | 商家数 2000
十五倍。
现在来看,如果这个服务在跑五百万行,一百万行对应 4 亿字节的分配总量,五百万行就是 20 亿字节。这些对象绝大部分活不过一轮 GC(它们只是临时的 key 和 submatch 切片),但 GC 的成本不是看你最后剩多少,是看你分配了多少。这就是为什么 RSS 能到 6.2G:Go 的 GC 目标是把堆控制在活跃数据的两倍,但你每秒往里扔几百兆的垃圾,堆就得不停地扩张。
用 go tool pprof -top ./demo cpu.prof 看 v1,排名靠前的通常是这几行:
Showing nodes accounting for 1.72s, 86.4% of 1.99s total
flat flat% sum% cum cum%
0.62s 31.2% 31.2% 0.95s 47.7% regexp.(*machine).backtrack
0.33s 16.6% 47.7% 0.33s 16.6% runtime.mallocgc
0.24s 12.1% 59.8% 0.24s 12.1% runtime.memmove
0.21s 10.6% 70.4% 0.21s 10.6% runtime.slicebytetostring
0.18s 9.0% 79.4% 0.18s 9.0% runtime.mapassign_faststr
0.14s 7.0% 86.4% 0.14s 7.0% runtime.concatstrings
看到这个,你根本不需要打开业务代码就知道发生了什么。31% 在正则回溯,16% 在分配内存,10% 在 slicebytetostring(这是 string(b) 转换),7% 在 concatstrings(这是 + 号拼接)。全是运行时的开销,没有一个字节花在业务逻辑上。
这份 profile 最值钱的地方在于:它在你打开代码之前,就告诉了你该往哪儿看。
然后是排查的顺序,这个顺序比工具本身重要:
第一步,看 CPU profile 的 top 10。如果大头是 runtime.*,说明问题在分配,直接跳第三步。如果大头是你的业务函数,看它的调用栈往上三层,找出是谁在调它。
第二步,用 go tool pprof -peek 函数名 看这个函数的调用者和被调用者。很多时候慢的不是这个函数本身,是它被调了一千万次。
第三步,用 -sample_index=alloc_objects 看堆 profile。注意这里有个坑:默认的 inuse_space 看的是”现在还活着的对象”,而临时对象(最容易出问题的那批)在 GC 之后已经死了,你看不见。所以查内存分配问题,一定要看 alloc_objects 或 alloc_space。
第四步,改完之后不要相信自己的感觉,重新跑一遍 bench。数字会说话。
还有一个小林后来才学到的东西:CPU profile 的采样频率是每秒 100 次,也就是每 10 毫秒一次。任何执行时间小于 10 毫秒的函数,在 CPU profile 里都可能完全看不见,即使它被调用了一百万次。
这就是”pprof 说没问题,但服务就是慢”这个现象最常见的来源。这时候要看的不是 CPU profile,是 block profile 或者 mutex profile——你的 goroutine 不是在算,是在等。
老陈的深层发问
-
你负责的服务,现在的 P99 是多少?上周是多少?上个月是多少?如果你答不上来,你凭什么说它”没问题”?
-
你上一次改一段代码”为了性能”,是在看到数据之后,还是在感觉它慢之后?
-
如果让你现在给你的服务抓一份 CPU profile,你需要多久才能拿到?五分钟,还是半天,还是你根本不知道该登哪台机器?
-
你的服务打开过 block profile 和 mutex profile 吗?如果没打开过,你怎么知道你的 goroutine 不是在等锁?
生活思考题
思考题一:上个月电费突然多了三百块。你会怎么查?
参考答案
第一层:大多数人会怎么答 “先看是不是空调。夏天开空调最耗电,肯定是空调开多了。然后把温度调高两度,把不用的电器拔了,下个月再看。”
第二层:这个答案为什么不够 这个答案的问题不在于猜错了,而在于它不可证伪。
你把温度调高两度,下个月电费回到正常——你能证明是空调的问题吗?不能。可能是那个月天气凉快,可能是你出差了十天,可能是你女儿住校没回来。你什么都没证明,只是得到了一个”看起来解决了”的错觉。
而下个月如果电费还是三百多呢?你怎么办?把电视拔了?把冰箱温度调高?你会开始第二轮猜测,而且这一轮的猜测比第一轮更没有依据。
这就是”猜”的本质问题:它不是慢,是它没有终止条件。 你可以无限猜下去,每一次都觉得自己在接近真相,实际上你在原地打转。
第三层:更深的一层是什么 正确的做法,是电工的做法。
第一步,抄电表。拿到一个确切的总量:这个月用了 823 度,上个月 487 度,多了 336 度。 第二步,算日均。336 除以 30,等于每天多用 11.2 度。 第三步,分段。把每天的用电按小时拆开(现在智能电表都能看),看多出来的这 11.2 度集中在哪个时段。如果是凌晨两点到六点,那跟空调没关系,空调没人半夜开——大概率是热水器保温、或者某个设备在待机。 第四步,拉闸验证。在可疑时段把总闸关了,看电表还走不走。走,就是有人在偷电或者表有问题;不走,就逐个回路合闸,看哪个回路一合上电流就跳。
这四步走完,最多两个小时,一定找得到。
注意这四步的共同点:每一步都在缩小范围,而且每一步的结果都是可以观测的数字。 这就是 profiling 的全部方法论。它不聪明,它就是笨,但是它有终止条件。
把这个思路翻译到 Go 上: 抄电表 = 看服务的 CPU、内存、QPS 三条曲线的绝对值; 算日均 = 把峰值换算成单位请求的成本; 分段 = CPU profile 的 top 10,找出最大的那一块; 拉闸验证 = 改完之后重新压测,用数字说话。
第四层:落到 Go 工程师身上 本周做一件事:给你负责的服务装一个”电表”。
最简单的形式:在
/debug/metrics里暴露三个数值——进程启动至今的累计请求数、累计 CPU 时间(time.SysUsage或者proc/self/stat)、累计分配字节数(runtime.MemStats.TotalAlloc)。有了这三个数,你就能算出一个值:每个请求的平均成本(CPU 毫秒/请求,分配字节/请求)。
这个值的意义在于,它跟流量无关。QPS 涨了,你的 CPU 涨是正常的;但如果每请求成本涨了,那一定是你代码的问题。
把它画成一条曲线,每天看一眼。这就是你的电表。三个月之后,你会比组里任何人都更早知道”这个服务开始变慢了”。
思考题二:你买了一辆二手车,开了一个月,感觉”好像有点费油”。你怎么判断它是不是真的费油?
参考答案
第一层:大多数人会怎么答 “看仪表盘上的平均油耗啊。“或者”跟同款车比比,问问车友群里别人的油耗是多少。”
第二层:这个答案为什么不够 仪表盘上的平均油耗,是车厂用一个你不了解的算法算出来的,而且它的统计周期、加权方式你都不知道。你拿这个数字跟车友群里别人的数字比,比的不是车的油耗,是两个不可比的数字。
更根本的问题是:“感觉有点费油”这个判断,是在没有基线的情况下做出的。你买这辆车第一天,没记录过它的油耗。所以你现在没有”它以前是多少”这个参照物,只能跟外部比——而外部数据不可比。
第三层:更深的一层是什么 老司机怎么做的?加满油,跳枪,里程表清零或者记下当前里程,正常开,下次加油再加满跳枪,记下加了多少升,用升数除以里程差乘以一百。
这个动作叫建立基线。做两三次,你就知道这辆车在你手上的真实油耗,而且这个数字是可复现的、可比较的(跟你自己比)。
工程师群体里最常听到的一句话是”最近接口好像变慢了”。说这句话的人里,十个有九个拿不出上周的数据。他们跟那个说”我的二手车好像有点费油”的人,犯的是完全一样的错误:在对一个连续量做判断时,用的是离散的、模糊的记忆。
人的记忆有个特点:我们对”最近一次很慢的体验”记得特别牢,对”一百次正常的速度”完全没有印象。所以凭感觉判断性能,结果永远是”变慢了”。
第四层:落到 Go 工程师身上 具体做法,三件事:
一、给你负责的每个接口,记录一个”上周同一天的 P99”。不需要复杂的系统,一个定时任务,每周一早上把上周的 P99 算出来存到一张表里,够用。 二、写一个小工具,输入一个接口名,输出它最近八周的 P99 序列。当有人说”接口变慢了”的时候,第一件事不是去查代码,是跑这个工具。 三、把每一次上线和这条曲线对齐。Go 服务里最常见的性能退化来源就是上线。如果你能在曲线上标注出”8 月 12 号那次上线之后,P99 从 62ms 涨到 78ms”,排查范围立刻缩小到一个 commit。
这三件事加起来,代码量不超过三百行。它换来的是:当别人在群里说”接口好像变慢了”的时候,你能第一个说出”从上周三那次上线开始,涨了 25%,涨在这个函数里”。
这句话,就是 P6 和 P5 的分界线。
3.4 context 不只是第一个参数:取消、超时、值传递的三个坑
“context 是干嘛的?”
“传取消信号和超时。“小林说,“还有传值。”
“你刚才说了三件事,顺序是什么?”
“取消、超时、传值。”
“说反了。“老陈说,“官方包注释里确实写了可以传值,但传值是 context 最不重要的功能,重要到什么程度呢——重要到很多人认为它根本不该存在。”
“可我们项目里就用 ctx 传 userID。”
“我知道。“老陈说,“我猜你们还传了商户 ID,可能还有当前登录的手机号。”
”……对。”
“那我问你,你们传的这些值,有谁在五层以下的调用栈里用过?”
小林想了想:“日志里打。还有审计。”
“所以它们是’业务参数’,不是’上下文’。“老陈说,“你把业务参数塞进 context,跟把业务参数塞进全局变量,本质上是同一件事:你懒得改函数签名。”
“可是改签名要改一堆调用方。”
“所以你们选了一个更省事的方案,代价是:现在没人知道 handleCallback 到底依赖什么。要看它依赖什么,你得全局搜 ctx.Value。”
小林没说话。
“这事我们先放一放。“老陈说,“我问你一个更基础的:你们项目里,有没有人写过 context.Background(),写在一个 goroutine 里?”
”……”
“有吧。”
“有。“小林承认,“我自己也写过。”
“为什么?”
“因为……”小林想了一下,“因为如果不写,那个 goroutine 里要用的 ctx 可能已经被 cancel 了。比如一个 HTTP handler 已经返回了,但是我想在后台异步刷个缓存。”
“这个想法是对的。“老陈说,“但你写 context.Background(),等于把这个 goroutine 从整棵取消树里剪掉了。它不是’不会被上游取消’,它是永远不会被任何人取消。”
”……”
“你刚才说的场景,正确的写法是 context.WithoutCancel(),Go 1.21 加的。“老陈说,“它表达的是’我要脱离取消,但我保留值’。你写 Background,表达的是’我什么都不要了’。这两个东西差一个字,后果差很远。”
小林愣了一下:“我没想到这玩意儿还有专门的 API。”
“大部分人没想到。“老陈说,“所以大部分人的代码里,context.Background() 到处都是,而且每一处都是个定时炸弹。”
“老陈,我再问一句。“小林说,“WithValue 这个事,官方文档说的就是 request-scoped 的值可以传。userID 不是 request-scoped 吗?每个请求都不一样。”
“traceID 呢?”
“也是。”
“当前用户的会员等级呢?也是每个请求可能不一样。你放 ctx 里吗?”
”……不放。”
“为什么不放?”
“因为它是业务字段,要参与逻辑判断。”
“那 userID 不参与逻辑判断吗?”
小林张了张嘴,又闭上了。
“你看,你自己刚才说出来了。“老陈说,“判断标准从来不是’这个值每个请求一不一样’。判断标准是:这个值描述的是请求本身,还是描述业务对象。”
“traceID 描述的是请求本身。”
“对。traceID、requestID、客户端 IP、灰度标识、调用链深度——这些东西是’关于这次调用’的元数据。它们的特点是什么?”
”……跟业务无关?”
“跟业务无关,而且路径上的每一层都可能需要,但没有任何一层需要理解它。“老陈说,“日志中间件需要 traceID,但它不需要知道 traceID 是什么含义,它只需要打出来。这就是 context 值传递唯一正当的用途。”
“userID 呢?”
“userID 是业务对象。它参与鉴权、参与数据过滤、参与限流。它在某个具体的函数里被读取,然后被用在一个 if 里。“老陈说,“这种东西,就该在函数签名里。你把它塞进 ctx,等于把一个显式的依赖变成了隐式的。”
”……”
“我知道你在想什么。“老陈说,“你在想’那我要改二十个函数签名’。”
“对。”
“那你先想想,为什么你会需要 userID 穿二十层?”
”……”
“因为你的调用链太深了,或者你的中间层什么都干。“老陈说,“这是设计问题,不是 context 的问题。你想用 context 绕过设计问题,绕得过一次,绕不过两次。”
小林沉默了大概十秒。
“还有一个坑。“老陈说,“你们 ctx.Value 的 key 用的什么类型?”
“字符串。”
“比如 ctx.Value("user_id")。”
“对。”
“那我给你讲个真事。“老陈说,“2019 年,一家做电商的公司,支付链路里有个 bug:偶尔会有订单用错误的优惠价成交。查了两周。”
“原因?”
“两个不同的包,都往 ctx 里写了 ctx.Value("user_id")。一个是网关中间件写的,存的是登录用户 ID;一个是营销 SDK 写的,存的是优惠券归属人 ID。营销 SDK 那行代码在链路深处,它把网关的值覆盖了。”
”……”
“两边都是 string,编译不报错,运行不 panic,只是静默地取到了错的值。”
“那怎么避免?”
“key 用自定义类型,不用内置类型。”
// 错误:任何包都能用同样的字符串覆盖你的值
ctx = context.WithValue(ctx, "user_id", 1001)
// 正确:未导出的自定义类型,别的包构造不出同一个 key
type traceIDKey struct{}
ctx = context.WithValue(ctx, traceIDKey{}, "trace-9f3a1c")“就这?”
“就这。一行代码的事。“老陈说,“而且 staticcheck 有一条规则叫 SA1029,专门检查这个。你们 CI 里跑了吗?”
”……没跑。“
生活里的同一件事:餐厅退菜,和那道没人通知后厨的宫保鸡丁
去年十月,小林和几个朋友去一家湘菜馆。
六个人点了八个菜。等了大概二十五分钟,上了两个。
又等了十分钟,还是两个。
他们那桌有个朋友脾气急,叫了服务员:“那个宫保鸡丁不要了,催一下其他的。”
服务员特别客气:“好的好的,马上帮您催。”
然后她走了。
又过了二十分钟,菜陆续上来了。最后一道端上来的,就是那盘宫保鸡丁。
他们六个人看着那盘菜,都没动筷子。
小林后来跟我复盘这件事,说这里面藏着 context 的三个坑,一个都不少。
第一个坑,是断链。
服务员说”好的”,但她没去后厨。她去干嘛了?不知道。可能她转头去催了别的桌,忘了。也可能她以为”不要了”这种事不用特意通知后厨,反正菜炒好了端上去就是了。
在 Go 里,这叫 goroutine 里用了 context.Background()。上游说”我不要了”,这个信号传到了服务员这一层,然后就停了。后厨那边什么都不知道,继续炒,炒完了端上来,然后被端回去。炒这盘菜用的燃气、厨师的时间、那盘鸡肉,全浪费了。
第二个坑,是超时预算。
他们那顿饭的”预算”是什么?是朋友九点半要赶末班地铁。从上菜到九点半,一共五十分钟。
如果他们聪明一点,应该这么跟服务员说:“我们九点半要走。八个菜,你看着办,哪些来得及上、哪些来不及,现在就告诉我,来不及的我现在就退。”
这叫预算分配。
而现实中他们是怎么做的?他们给每一道菜都设了一个隐含的期望:“快点上”。八道菜,每道菜都是”快点上”。结果就是没有一道菜有明确的时间边界,最后所有菜一起超时。
Go 里对应的错误长这样:上游给我的 deadline 是 500 毫秒,我串行调三个下游,每个都 context.WithTimeout(ctx, 500*time.Millisecond)。看起来每个都不会超过 500 毫秒,实际上加起来可能 1500 毫秒。
第三个坑,是值传递。
小林他们那桌后来又发生了一件事。他们叫服务员加一碗米饭,服务员说”好的”,然后去隔壁桌送了一碗米饭。
为什么?因为她没记清楚是几号桌要的。
“加一碗米饭”这个请求,缺了”桌号”这个上下文。桌号是描述请求本身的元数据,不是业务对象。后厨做米饭不需要知道桌号,但是传菜的人需要。而且传菜的人拿到桌号之后,不需要理解桌号的含义,只需要知道”送到 3 号桌”。
这就是 traceID。
而”这道菜不要放辣”,是业务对象——它需要后厨理解,需要参与”做菜”这个逻辑。你不能把它贴在桌号旁边,你得写在单子上,交给厨师。
这就是函数参数。
Go 实例:一个 handler 调三个下游
下面这个程序可以直接跑。三个下游分别是库存(120ms)、价格(200ms)、风控(300ms),上游给的总预算是 400 毫秒。
v1 是大多数项目的真实写法:
package main
import (
"context"
"fmt"
"sync"
"time"
)
// 三个下游的真实耗时
var costs = map[string]time.Duration{
"库存": 120 * time.Millisecond,
"价格": 200 * time.Millisecond,
"风控": 300 * time.Millisecond,
}
func callDownstream(ctx context.Context, name string) (string, error) {
select {
case <-time.After(costs[name]):
return name + ":ok", nil
case <-ctx.Done():
return "", fmt.Errorf("%s 被取消: %w", name, ctx.Err())
}
}
// ===== v1:context 只是"第一个参数" =====
type Request struct {
UserID int64
ctx context.Context // 坑 1:把 ctx 存进结构体,这个结构体的生命周期就再也不清晰了
}
func handleV1(parent context.Context) ([]string, error) {
req := &Request{UserID: 1001, ctx: parent}
// 坑 2:异步任务用 Background,上游取消跟它彻底没关系
go func() {
time.Sleep(3 * time.Second)
fmt.Println(" [异步任务] 干完了(上游早就走了,纯白干)")
}()
// 坑 3:写死的 500ms,跟上游的 deadline 一点关系都没有
// 坑 4:cancel 被丢掉了,超时之前返回就会泄漏 timer
out := make([]string, 0, 3)
for _, name := range []string{"库存", "价格", "风控"} {
ctx, _ := context.WithTimeout(req.ctx, 500*time.Millisecond) // 注意这个 _
r, err := callDownstream(ctx, name)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, nil
}v2 是改过之后的:
// ===== v2:把 context 当成一条有预算的链路 =====
type traceKey struct{} // 未导出的自定义类型,别的包构造不出同一个 key
func handleV2(parent context.Context) ([]string, error) {
// 只放描述请求本身的元数据,业务字段走参数
parent = context.WithValue(parent, traceKey{}, "trace-9f3a1c")
// 1. 先看上游还剩多少时间
budget := 400 * time.Millisecond
if dl, ok := parent.Deadline(); ok {
budget = time.Until(dl)
}
// 2. 三个下游是并行的,所以每个都可以拿到几乎全部预算,
// 只需要留 10% 给自己做聚合和返回。
// (如果是串行,才需要把预算除以三)
per := budget * 90 / 100
fmt.Printf(" 上游预算 %v,并行调用,每个下游分到 %v\n", budget, per)
// 3. 异步任务:明确决定它跟不跟着上游取消。
// 这里选择"跟",所以直接传 parent。如果确实要脱离取消链,
// Go 1.21 起应该用 context.WithoutCancel(parent),而不是 Background()。
go func(ctx context.Context) {
select {
case <-time.After(3 * time.Second):
fmt.Println(" [异步任务] 干完了")
case <-ctx.Done():
fmt.Println(" [异步任务] 收到取消,停了,没白干")
}
}(parent)
names := []string{"库存", "价格", "风控"}
results := make([]string, len(names))
errs := make([]error, len(names))
var wg sync.WaitGroup
for i, name := range names {
wg.Add(1)
go func(i int, name string) {
defer wg.Done()
// 4. cancel 在这里 defer,而不是丢给 GC
ctx, cancel := context.WithTimeout(parent, per)
defer cancel()
results[i], errs[i] = callDownstream(ctx, name)
}(i, name)
}
wg.Wait()
for _, err := range errs {
if err != nil {
return nil, err
}
}
return results, nil
}
func main() {
fmt.Println("=== v1 ===")
func() {
ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond)
defer cancel()
start := time.Now()
res, err := handleV1(ctx)
fmt.Printf(" 结果=%v\n 错误=%v\n 耗时=%v\n", res, err, time.Since(start).Round(time.Millisecond))
}()
fmt.Println(" 等 3.2 秒,看那个异步任务会不会自己停……")
time.Sleep(3200 * time.Millisecond)
fmt.Println("\n=== v2 ===")
func() {
ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond)
defer cancel()
start := time.Now()
res, err := handleV2(ctx)
fmt.Printf(" 结果=%v\n 错误=%v\n 耗时=%v\n", res, err, time.Since(start).Round(time.Millisecond))
fmt.Println(" 等 3.2 秒,看这个异步任务会不会自己停……")
time.Sleep(3200 * time.Millisecond)
}()
}跑一下,输出大概是:
=== v1 ===
结果=[]
错误=风控 被取消: context deadline exceeded
耗时=400ms
等 3.2 秒,看那个异步任务会不会自己停……
[异步任务] 干完了(上游早就走了,纯白干)
=== v2 ===
上游预算 400ms,并行调用,每个下游分到 360ms
结果=[库存:ok 价格:ok 风控:ok]
错误=<nil>
耗时=300ms
等 3.2 秒,看这个异步任务会不会自己停……
[异步任务] 收到取消,停了,没白干
同样的上游预算,同样的三个下游,v1 失败,v2 成功。
v1 为什么失败?它串行调用,前两个用掉 320 毫秒,第三个还剩 80 毫秒,但它给第三个设的是 500 毫秒——这个 500 毫秒是假的,真正生效的是上游剩下的 80 毫秒。所以第三个在跑了 80 毫秒之后被掐断,这 80 毫秒的请求已经发到风控那边去了,白跑。
v2 为什么成功?因为三个下游没有依赖,可以并行。并行的时候,每个下游的超时应该等于”上游剩余时间减去我自己收尾需要的时间”,而不是”上游剩余时间除以三”。
这个区别,很多人搞不清楚,然后在并行场景下写 budget/3,白白把每个下游的超时砍到三分之一,导致正常请求被误杀。
规则是这样的:
- 串行调用:把预算按顺序分摊,前面的调用用完多少,后面的就剩多少。
- 并行调用:每个分支都能拿到接近全部的预算,减去聚合开销。
- 混合调用:先按串行阶段切大块,每个阶段内部再按并行规则分。
还有一个细节值得单独说:context.WithTimeout 返回的 cancel,如果你不调用它,这个 ctx 会一直挂到它的父节点被取消,或者它自己的 timer 到期。在循环里创建 ctx 而不 cancel,是最常见的泄漏源。Go 官方的 go vet 有一条 lostcancel 检查,专门抓这个。你们 CI 里跑了 go vet 吗?
跑了的话,上面 v1 里那个 ctx, _ := context.WithTimeout(...) 会被直接拦下来。
老陈的深层发问
-
你的服务里,所有 RPC 调用的超时时间加起来,超过上游给你的总预算了吗?你算过吗?
-
如果你负责的服务现在被上游取消(比如网关 500ms 超时),你手上有多少个 goroutine 是”收不到这个取消”的?
-
你在代码里写过多少次
context.Background()?每一次,你想清楚它意味着什么了吗? -
如果一个新人接手你的代码,他能不能只通过函数签名,就知道这个函数需要什么?还是他得去全局搜
ctx.Value?
生活思考题
思考题一:你在餐厅点了一桌菜,等了四十分钟一道都没上。你会怎么处理?
参考答案
第一层:大多数人会怎么答 “催。叫服务员过来问,‘我们的菜怎么还没上’,然后让他去催一下。”
第二层:这个答案为什么不够 “催”这个动作,在系统里的等价物是什么?是重试。
你去问”菜怎么还没上”,服务员去后厨问”3 号桌的菜好了没”,后厨说”马上”,服务员回来告诉你”马上”。三十秒之后你又催一次。整个过程中,你的菜不会因此快一秒,反而后厨被打断了两次。
这就是超时之后盲目重试的问题:它不解决根因,还消耗了处理能力。而在分布式系统里,它更危险——上游超时了,重试,下游本来就在超负荷,你又加了一份负载。经典的雪崩就是这么来的。
更根本的盲区在于:大多数人到了四十分钟这个点,脑子里只有”我还要等多久”,没有”我的边界在哪”。
第三层:更深的一层是什么 真正该问的问题不是”还要等多久”,是”我最多还等多久,以及到了那个点我做什么”。
一个有边界感的人会这么处理:四十分钟的时候,叫服务员,问一句:“现在有几个菜在做?剩下的十分钟能上齐吗?上不齐的话,帮我退掉做不完的那几个,我先吃能上的。”
这句话里包含了三个元素:当前状态(有几个在做)、剩余预算(十分钟)、降级方案(退掉做不完的)。
翻译成 Go,就是一个正确的超时处理路径:
ctx.Done()触发之后,你要做的不是把整个请求标记成失败,而是看哪些分支已经完成了、哪些可以降级、哪些必须失败。风控没跑完,这笔交易必须失败;推荐位没跑完,返回空列表就行了。大部分人写的代码,超时之后是”全部失败”。而正确的代码,超时之后是”部分成功 + 明确标记哪些是降级的”。这两者的可用性差距巨大。
第四层:落到 Go 工程师身上 检查你负责的接口,把它的返回值分成三类:
- 必须有,没有就报错(比如订单金额、库存状态)
- 可以有,没有就降级(比如推荐、优惠券提示、用户画像)
- 有了更好,没有也无所谓(比如埋点、统计)
然后给第二类和第三类各自单独派生一个 context,超时更短(比如主预算的 30%),并且失败时只降级不报错。
代码上长这样:
mainCtx, cancel := context.WithTimeout(r.Context(), 400*time.Millisecond) defer cancel() // 降级分支单独一个短超时 softCtx, softCancel := context.WithTimeout(r.Context(), 120*time.Millisecond) defer softCancel()做完这件事,你的接口在大促期间的可用性会明显不一样。因为大促时第一个崩的永远是那些”锦上添花”的下游。
思考题二:你打客服电话,客服说”我帮您转接”,转接之后你要重新报一遍手机号、订单号、问题描述。为什么?
参考答案
第一层:大多数人会怎么答 “因为系统没打通,两个客服看不到同一个工单。这是他们 IT 系统的问题。”
第二层:这个答案为什么不够 “系统没打通”是现象,不是原因。真正的原因是:转接这个动作,传递了通话,但没有传递上下文。
电话系统把你的语音流转给了另一个人,但”这个人是谁、他前面说了什么、工单号是多少”这些信息,是挂在第一个客服的会话上的,转接的时候没有一起过去。
所以第二个客服必须从零开始重建上下文,而重建的成本由你承担——你要把已经说过的话再说一遍。
有意思的是:客服自己也不想这样。他问你要手机号的时候,他也很烦。但他没有别的办法,因为他的系统里没有那个值。
第三层:更深的一层是什么 这件事在工程上的对应物,比你想的更普遍。
你有没有遇到过这样的代码:一个函数里起了个 goroutine 去做异步处理,异步处理里要打日志,然后你发现日志里没有 traceID。为什么没有?因为你在
go func()的时候,把 traceID 从一个局部变量里读出来打印了,而新起的 goroutine 拿不到调用栈上的 ctx。或者更常见:你把一个 ctx 传进了一个函数,那个函数内部用了
context.Background()开了一条新的链路,于是下游所有的日志、所有的 span、所有的超时,跟上游全断了。你在 Jaeger 上看调用链,会发现一条链断成两截,中间是空的。这跟客服转接是一模一样的:业务动作过去了,上下文没过去。
而最讽刺的地方在于,用户(或者调用方)是能感知到断链的。你打电话被要求重报手机号,你的体验是”这家公司不专业”。你的服务在调用链上断了一截,你的体验是”查问题查不明白”——但你不会因此去修它,因为不修也能跑。
第四层:落到 Go 工程师身上 三条具体的检查项:
一、全局搜一下你的代码里有多少处
context.Background()和context.TODO()。列出清单,每一处都问一句:“这里为什么不能从上游接一个 ctx 下来?” 答案如果是”因为这里是 main 函数 / 定时任务入口”,保留;如果是”因为不知道怎么传”,那就是 bug。二、检查所有
go func()。每一个 goroutine 的第一个参数都应该是 ctx,除非你有明确理由不要。没有 ctx 的 goroutine,在 Go 里就是一段你控制不了生命周期的代码。三、在你的日志库里加一个强制项:打日志必须传 ctx,从 ctx 里自动取 traceID 拼进日志行。这一步做完,你的调用链就再也不会断。
这三个检查加起来大概半天工作量。做完之后,你下次排查线上问题的时候,能少花两个小时。
3.5 并发不是并行:worker pool 与 errgroup 的正确姿势
“并发和并行有什么区别?”
“并发是同时应对很多事,并行是同时做很多事。“小林背得很顺,“Rob Pike 那个演讲里说的,concurrency is about dealing with lots of things at once,parallelism is about doing lots of things at once。”
“背得挺熟。“老陈说,“那我问你个具体的:你们那个批量发券的接口,一次来一万张券,你起多少个 goroutine?”
“每个券一个。”
“一百万张呢?”
”……”
“你说。”
“一百万个。”
“那你起吧。”
小林没接话。
“goroutine 便宜,这话没错。Go 1.4 之后初始栈是 2KB,一百万个就是两个 G 内存。所以内存不是问题,对吧?”
“对。”
“那我问你第二件事:GC 的时候,运行时要做什么?”
“标记。从 root 开始扫。”
“root 包括什么?”
“全局变量、栈、寄存器。”
“每个 goroutine 都有自己的栈。“老陈说,“一百万个 goroutine,GC 就要扫一百万个栈。”
”……”
“我给你个具体数字。“老陈说,“我去年帮一家公司看一个服务,四核八 G 的容器,平时 8000 个 goroutine,GC 的 STW 大概 0.1 毫秒。有一次他们改了个批量接口,goroutine 涨到 12 万,STW 变成了 3.4 毫秒。”
“3.4 毫秒也不算多。”
“对,单次不多。但这个服务的 P99 是 50 毫秒,3.4 毫秒的 STW 意味着每次 GC 有 7% 的请求要多等 3.4 毫秒。而且——“老陈顿了一下,“GC 变频繁了。goroutine 多了,堆上对象就多,堆增长快,GC 频率从 2 分钟一次变成 12 秒一次。”
”……”
“最要命的不是这个。“老陈说,“最要命的是你说的那句’每个券一个 goroutine’,打到了谁身上。”
小林反应过来了。
“打到券服务上。”
“一万张券,一万个并发请求。券服务的连接池是 50。”
”……”
“你的并发,是别人的并行。”
小林沉默了一会儿。
“老陈,你说的这些我都认。“他说,“但是我们项目里也用 worker pool 啊,上个月我还写了一个。”
“说说怎么写的。”
“起 N 个 worker,一个 jobs channel,一个 results channel,for range jobs。”
“N 是多少?”
”……”
“说。”
”……我写的是 runtime.NumCPU()。“小林说,“不对吗?”
“我没有说不对。“老陈说,“我问你,你那个 worker pool 里的任务是 CPU 密集的,还是 IO 密集的?”
“IO 密集,要调下游。”
“那 NumCPU 是怎么来的?”
”……我不知道,我看别人都这么写。”
“NumCPU 作为 worker 数,只在任务 100% 是 CPU 密集、且每个任务耗时均匀、且没有阻塞的情况下才是合理的。“老陈说,“你调下游,一次 20 毫秒,其中 19.9 毫秒在等网络。这时候 worker 数是 NumCPU,等于你在四核机器上只开了四个并发,下游能扛 200 并发,你只用了 2%。”
“那应该怎么定?”
“你先想想,这个数字应该由什么决定。”
”……下游能扛多少。”
“还有呢?”
“我们自己这边……内存?”
“对。worker 数是由下游的承载能力和我这侧的资源上限共同决定的,跟 CPU 核数关系不大,除非你的任务在算东西。”
“那具体给多少?”
“我给你个起点。“老陈说,“IO 密集型,取 min(下游允许的最大并发 × 0.8,我这侧单任务内存 × 并发数 < 可用内存的 30%)。然后压测,往上加,加到成功率开始掉或者 P99 开始涨,退回去两档。”
“压测。”
“压测。“老陈说,“这个词你听着是不是很重?”
”……有点。”
“大部分人对压测的抗拒,不是因为麻烦,是因为压测会给出一个数字,而那个数字可能会打脸。你不压测,你就可以一直觉得你的 worker 数设得挺合理。”
“还有个事。“老陈说,“你说的那个 worker pool,我猜你的 WaitGroup 是这么写的。”
“怎么写的?”
“var wg sync.WaitGroup,然后 for 里面 wg.Add(1),go func(){ defer wg.Done() ... }()。”
“对啊。”
“Add 在循环里,在 goroutine 外面,这没错。“老陈说,“但我见过太多人写成 go func(){ wg.Add(1); defer wg.Done() ... }()。”
“那会怎么样?”
“Wait 可能在任何一个 goroutine 开始之前就返回了。因为主 goroutine 跑到 Wait 的时候,计数还是 0。”
”……”
“这种 bug 特别讨厌,因为它大部分时候是正常的。goroutine 调度得快一点,Add 就赶在 Wait 前面了。一天跑一百万次,可能有一次出问题。”
“这种问题怎么查?”
“go test -race。“老陈说,“你们跑吗?”
”……CI 里没开。”
“为什么没开?”
“因为开了之后……”小林停住了。
“因为开了之后一片红。”
”……对。”
老陈笑了。
“你知道这说明什么吗?”
“说明我们有竞态。”
“说明你们一直有竞态,只是以前看不见。“老陈说,“竞态这个东西,不是你不测它就不存在。它只是在等一个时机——流量高峰、机器负载高、GC 时间长的那一瞬间——然后给你一个谁都复现不了的结果。”
“我们加上。”
“加上,然后你会看到一个很长的列表。你不要一次性全改。“老陈说,“按这个顺序改:先改所有 map 并发写,因为它会直接 fatal error,而且 recover 救不了。再改所有共享计数器。剩下的慢慢来。“
生活里的同一件事:装修时,水泥干不了就是干不了
小林装修那套房子,是半包,自己找的工人。
开工第一周,他干了一件后来被瓦工师傅嘲笑的事。
那天他去看进度,发现水电只改了一半,泥木还没进场。他急了——他算过工期,水电一周、泥木两周、油漆一周,加起来一个月,但中间还有等干的时间,总共得四十五天。
他问工长:“能不能多找几个人,各干各的,同步推进?”
工长是个安徽人,五十来岁,姓孙。孙工长没直接回他,而是带他走到卫生间门口,指着刚做好的防水。
“小林,你看这个。”
地面上一层黑色的防水涂料,看着是干的。
“这个要干几天?”
“不知道,两三天吧。”
“二十四小时。“孙工长说,“然后是闭水试验,四十八小时。这三天,我找一百个人来,也只能站在这儿看。”
他又带小林去看阳台的墙。
“这面墙昨天砌的。今天能不能贴砖?“他敲了敲,“不能。水泥没到强度,你贴上去,三个月之后砖会空鼓,然后全掉。”
“要等多久?”
“七天。”
小林后来跟我说,那七天他是真的烦躁。他每天早上起来第一件事就是想”今天是不是可以贴砖了”,然后答案是”不行”。
“当时我想的是,多花钱就能快。“他说,“我以为装修是一个可以用钱换时间的事情。”
不完全是。装修里面有两类等待:人的等待和材料的等待。
人的等待,是可以靠加人解决的。贴砖这个活,一个人一天贴 8 平米,两个人就是 16 平米,基本线性。
材料的等待,加多少人都没有用。水泥要七天就是七天。
孙工长后来跟他说了一句话,他记到现在:
“小林,工地上最贵的不是人工,是那些干不了活的时间。你要是能把所有要等的东西提前安排好,让你等人变成人等材料,你这房子能提前十天。”
这句话,讲的就是并发优化的本质。
先找出哪些等待是可以并行的,哪些是串行的关键路径,然后把资源堆到可并行的部分上。
堆到关键路径上,没有用。水泥干不了,你找一百个人来也只能看着。
而 goroutine 的问题在于:它太便宜了,便宜到你根本不会去想”这个等待是什么类型的等待”。你只会想”反正起个 goroutine 很便宜”。
于是你起了一万个 goroutine,去等一个连接池只有 50 的下游。那一万个 goroutine 里的 9950 个,站在那儿看水泥干。
Go 实例:从无限 fan-out 到有闸门的 worker pool
下面这个程序可以直接跑。它模拟的是小林那个批量发券的场景:一万个任务,下游最大并发 20,超过就返回 429。
package main
import (
"context"
"fmt"
"runtime"
"sync"
"time"
)
// downstream 模拟一个有并发上限的下游服务
type downstream struct {
mu sync.Mutex
current int
max int
peak int
}
func (d *downstream) call(ctx context.Context, id int) (string, error) {
d.mu.Lock()
d.current++
if d.current > d.peak {
d.peak = d.current
}
over := d.current > d.max
d.mu.Unlock()
defer func() {
d.mu.Lock()
d.current--
d.mu.Unlock()
}()
if over {
return "", fmt.Errorf("任务 %d: 429 下游限流", id)
}
time.Sleep(2 * time.Millisecond) // 模拟处理耗时
return fmt.Sprintf("task-%d", id), nil
}
// ===== v1:每个任务一个 goroutine =====
func runV1(ctx context.Context, ds *downstream, n int) (ok, failed int) {
var wg sync.WaitGroup
results := make([]string, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// 坑 1:Go 1.22 之前,这里的 i 是所有 goroutine 共享的同一个变量
// 坑 2:ok / failed 是共享变量,无锁自增,数据竞争
r, err := ds.call(ctx, i)
if err != nil {
failed++
return
}
results[i] = r
ok++
}()
}
wg.Wait()
return
}注意 v1 里那两个计数器。go run -race 跑这段,ok + failed 几乎一定小于 n,因为自增不是原子的,两个 goroutine 同时读到 0、都写回 1,就丢了一次。
还有一件事要说清楚:很多人的第一反应是把 results 从 slice 换成 map,写成 results[i] = r。千万别。Go 的 map 并发写是运行时直接 throw,不是 panic,recover 救不了:
fatal error: concurrent map writes
这个错误连堆栈都打不全,因为它发生在运行时的 hashWriting 检查里。你只能看到一堆 goroutine 的栈,然后进程没了。
v2 是有闸门的 worker pool:
// ===== v2:worker pool,并发度由下游承载能力决定 =====
type job struct{ id int }
type result struct {
id int
val string
err error
}
func runV2(ctx context.Context, ds *downstream, n, workers int) (ok, failed int) {
jobs := make(chan job)
res := make(chan result, workers)
// 1. 固定数量的 worker,数量由压测得出的下游承载能力决定
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs { // jobs 关闭时,range 自动退出,worker 自然收工
select {
case <-ctx.Done():
res <- result{id: j.id, err: ctx.Err()}
return
default:
}
v, err := ds.call(ctx, j.id)
res <- result{id: j.id, val: v, err: err}
}
}()
}
// 2. 生产任务。必须在单独的 goroutine 里,否则无缓冲 channel 会在第一个就死锁
go func() {
defer close(jobs)
for i := 0; i < n; i++ {
select {
case jobs <- job{id: i}:
case <-ctx.Done():
return
}
}
}()
// 3. 所有 worker 退出后再关结果 channel
go func() {
wg.Wait()
close(res)
}()
// 4. 单点汇聚,没有共享变量,不需要锁
results := make([]string, n)
for r := range res {
if r.err != nil {
failed++
continue
}
results[r.id] = r.val
ok++
}
return
}
func main() {
const n = 5000
fmt.Printf("任务数 %d,下游最大并发 20\n\n", n)
ds1 := &downstream{max: 20}
start := time.Now()
ok1, fail1 := runV1(context.Background(), ds1, n)
fmt.Printf("v1 无限 fan-out:成功 %-5d 失败 %-5d 下游并发峰值 %-5d 耗时 %v\n",
ok1, fail1, ds1.peak, time.Since(start).Round(time.Millisecond))
fmt.Printf(" ok+failed = %d(小于 %d,说明计数器有数据竞争)\n", ok1+fail1, n)
ds2 := &downstream{max: 20}
start = time.Now()
ok2, fail2 := runV2(context.Background(), ds2, n, 20)
fmt.Printf("v2 worker pool:成功 %-5d 失败 %-5d 下游并发峰值 %-5d 耗时 %v\n",
ok2, fail2, ds2.peak, time.Since(start).Round(time.Millisecond))
fmt.Printf("\n当前 goroutine 数: %d, GOMAXPROCS: %d\n", runtime.NumGoroutine(), runtime.GOMAXPROCS(0))
}跑起来的结果大概是:
任务数 5000,下游最大并发 20
v1 无限 fan-out:成功 231 失败 4752 下游并发峰值 2847 耗时 604ms
ok+failed = 4983(小于 5000,说明计数器有数据竞争)
v2 worker pool:成功 5000 失败 0 下游并发峰值 20 耗时 521ms
v1 有 95% 的任务被下游限流打回来了。它用了 2847 的并发去打一个只能扛 20 的下游,把自己打成了 429。
而 v2 的并发峰值稳定在 20,全部成功,而且总耗时还更短——因为 v1 那一堆 goroutine 里,绝大多数的时间花在了调度和 GC 上,不是花在干活上。
并发度不是越大越好,它有一个最优点,超过之后吞吐会下降。 这条曲线在排队论里有个名字,叫 Little’s Law 的推论,但你在工程上不需要记这个名字,你只需要记住:压测,画出来,找拐点。
如果你不想手写 worker pool,golang.org/x/sync/errgroup 是标准做法:
import "golang.org/x/sync/errgroup"
func runV3(ctx context.Context, ds *downstream, n int) (int, int, error) {
g, gctx := errgroup.WithContext(ctx)
g.SetLimit(20) // 并发上限,等价于 worker 数
results := make([]string, n)
var mu sync.Mutex
var failed int
for i := 0; i < n; i++ {
i := i // Go 1.22 之前必须写这一行
g.Go(func() error {
v, err := ds.call(gctx, i)
mu.Lock()
defer mu.Unlock()
if err != nil {
failed++
return nil // 单个任务失败不算失败,降级处理
}
results[i] = v
return nil
})
}
if err := g.Wait(); err != nil {
return 0, failed, err // 第一个返回的错误
}
return n - failed, failed, nil
}errgroup.WithContext 有两个行为必须记住,不然会踩坑:
第一,任何一个 g.Go 返回非 nil 错误,gctx 会立刻被 cancel,其他还在跑的任务会收到取消信号。这是”快速失败”,很多场景是对的,但批量处理场景通常不对——你不想因为第 3 个任务失败就放弃后面 9997 个。上面例子里我把错误吞掉返回 nil,就是为了绕过这个行为。
第二,gctx 在 g.Wait() 返回之前不能当成普通 ctx 用。它要么已经被业务取消,要么被 errgroup 自己 cancel 了。我见过有人在 g.Wait() 之后还拿 gctx 去调数据库,然后一脸茫然地收到 context canceled。
最后是 sync 包里的几个坑,我列出来,你对着查一下自己的代码:
sync.Mutex不能被复制。如果你的结构体里有 Mutex,然后用值接收者定义方法、或者结构体被值传递,锁就废了。go vet的copylocks检查能抓到,前提是你们跑了 vet。sync.Once里的函数如果 panic,Do会认为”已经执行过了”。之后再调Do,什么都不会发生,而且不报错。初始化失败之后静默地用一个半初始化状态,这种 bug 能查一天。sync.RWMutex的读锁里不能升级成写锁。先RLock()再Lock(),死锁,百分百。go vet抓不到,只能靠人。RWMutex的写锁会被连续的读锁饿死。Go 的 RWMutex 实现里,写锁来了之后新来的读锁会排队,但已经在排队的读锁会先执行完。如果读多到一定程度,写锁可能等很久。配置热更新这种场景要小心。sync.WaitGroup的Add必须在go语句之前。这个前面说过了。sync.Pool不是缓存。这个下一节单独讲。
老陈的深层发问
-
你的服务里,并发度最高的那个 fan-out 是多少?这个数字是怎么定下来的,你记得吗?是算出来的、压出来的,还是抄来的?
-
你上一次跑
go test -race是什么时候?如果现在跑,你猜会有几条告警?你敢猜一个数字吗? -
你的服务里有多少个
go func()?每一个,你都知道它在什么条件下会退出吗?有没有哪一个,是”进来就再也不出来”的? -
如果下游明天把并发上限从 200 降到 20,你的代码里需要改几个地方?改完之后你怎么验证改对了?
生活思考题
思考题一:公司食堂中午十二点有八百人吃饭。打饭窗口从 4 个增加到 8 个,排队时间只减少了 30%,远不到一半。为什么?
参考答案
第一层:大多数人会怎么答 “因为人的反应速度有上限。窗口多了,每个人的打饭时间没变,所以只能快一点点。而且八个人挤在取餐区,反而互相挡路。”
第二层:这个答案为什么不够 “人的反应速度”这个说法太模糊,它解释不了为什么是 30% 而不是 5% 或者 60%。
真正的原因是:打饭这个流程里,只有一部分是可并行的。
一个完整的打饭动作包括:走到窗口(不可并行,走道就那么宽)、选菜(可并行)、跟阿姨说要什么(可并行,但受限于阿姨的反应)、阿姨打菜(可并行)、刷卡(不可并行,如果你的食堂是出口统一刷卡)、找座位(不可并行,座位就那么多)。
窗口从 4 个变 8 个,加速的只有”选菜+说需求+打菜”这一段。走路、刷卡、找座这三段完全没变。
这就是 Amdahl 定律:一个系统的加速比,受限于它不可并行的那部分所占的比例。如果不可并行部分占 40%,你把可并行部分优化到无限快,总时间最多也只能降到原来的 40%。
第三层:更深的一层是什么 这个思考题真正要问的是:你知道你的”不可并行部分”是什么吗?
大部分工程师在优化性能的时候,第一反应是”加并发”。因为加并发是改动最小、最容易验证的动作——你改个数字,跑一下,看到 QPS 涨了,很有成就感。
但加并发的收益有硬上限,这个上限由你的串行部分决定。而串行部分通常不在你的代码里,在架构里。
比如一个批量接口:解析请求(串行,JSON 反序列化)、查用户权限(串行,一次 DB)、并发处理 N 个任务(可并行)、聚合结果(串行)、写审计日志(串行)。如果你把并发度从 10 加到 100,可能只快了 15%,因为剩下的 85% 全在串行段。
而这时候真正该做的是:审计日志改成异步、权限缓存、请求体流式解析。这些改动比”把 10 改成 100”难十倍,但收益也大十倍。
第四层:落到 Go 工程师身上 挑你负责的最慢的那个批量接口,做一次分解:把它的耗时拆成串行段和并行段,用
time.Since打点,算出串行段占比。如果串行段占比超过 50%,加并发是在浪费时间。这时候去做这三件事:
一、审计日志、埋点、统计上报——全部改成 channel 异步,或者直接在请求返回后再写(用
context.WithoutCancel派生的 ctx)。 二、权限、配置、字典数据——加本地缓存,用singleflight防止缓存击穿。 三、JSON 解析——如果请求体大,看看能不能只解析需要的字段,或者换成流式解析。做完再测。你会发现串行段从 50% 降到 15%,这时候加并发才真正有意义。
思考题二:你搬家,叫了五个朋友来帮忙。东西很多。你会让大家各自随便搬,还是分工?为什么?
参考答案
第一层:大多数人会怎么答 “分工。一个人搬书、一个人搬衣服、一个人搬大件家具、一个人打包、一个人开车。这样不会乱,也不会重复劳动。”
第二层:这个答案为什么不够 分工有一个隐含前提:每类东西的工作量差不多。
但搬家这件事,工作量极度不均衡。书可能有二十箱,衣服只有五箱,家具只有六件但是要拆、要两个人抬、还下不去楼梯。
所以纯分工的结果是:搬衣服的那个人半小时干完了,然后站着看别人干;搬家具的两个人累死,还卡在楼梯上,其他所有人都在等他们。
这就是 worker pool 里的长尾任务问题。如果你的任务是均匀的,worker pool 的效率接近 100%。如果任务耗时差异大(比如批量导出,有的商家有 5 条数据,有的有 50 万条),那么 worker pool 的最后阶段,会只剩一两个 worker 在干活,其他都在空转。
第三层:更深的一层是什么 老练的搬家是怎么做的?我认识一个干了十几年搬家的师傅,他的做法是这样的:
先不分工。所有人一起把大件家具搬下去——因为大件需要多人协作,而且它是关键路径(家具不搬走,其他东西没地方放)。
然后才开始分工,而且他会按箱子大小分配,不按类别。小箱子一个人一次抱两个,大箱子一次一个。谁手上的活干完了,过来拿下一箱。
这个做法有四个要素:关键路径优先、任务粒度打散、动态分配而不是静态分配、允许协作。
翻译成 Go:
- 关键路径优先 = 把大任务(耗时的、阻塞的)先丢进队列,或者单独一个高优先级队列。
- 任务粒度打散 = 不要一个任务处理一个商家的全部数据,要按页或按时间段切片,让每个任务的耗时接近。
- 动态分配 = 用 channel 分发任务(worker 干完主动取下一个),而不是预先把任务平均分给每个 worker。
- 允许协作 = 一个超大任务可以被拆成多个子任务,由多个 worker 同时处理。
其中”动态分配”这一条最关键,也最容易被忽略。很多人的 worker pool 是这么写的:预先把任务切片,worker 0 处理 0-999,worker 1 处理 1000-1999。这叫静态分配,它的吞吐取决于最慢的那个分片。改成 channel 动态取,吞吐能提升 20% 到 40%,改动只有几行。
第四层:落到 Go 工程师身上 三件事:
一、给你负责的批量任务加一个耗时分布埋点:记录每个任务的处理耗时,输出 P50、P90、P99、Max。如果 Max 是 P50 的十倍以上,说明你有长尾问题。 二、把静态分片改成 channel 动态分发。如果你的 worker pool 现在是
for w := 0; w < n; w++ { go handle(tasks[w*chunk:(w+1)*chunk]) },改成for j := range jobs。 三、对超长任务做二次拆分:在任务内部判断数据量,如果超过阈值(比如一万条),把它拆成十个子任务重新丢回队列。这一步要小心递归深度和重复拆分,加一个depth字段限制层数。做完这三件事,你的批量任务的总耗时通常会降 30% 以上,而且不用加机器。
3.6 内存逃逸:你的对象到底分配在哪,为什么这很重要
“Go 里一个对象,什么时候在栈上,什么时候在堆上?”
“编译器决定。“小林说,“逃逸分析,go build 的时候会做。如果一个对象的生命周期超出了函数范围,就分配到堆上。”
“说得挺好。那我问你,你怎么知道你的对象逃没逃?”
”……”
“有没有命令能看?”
“有,go build -gcflags=-m。”
“你用过吗?”
“看过一次。“小林说,“看不懂。”
“哪看不懂?”
“它打一堆东西出来,什么 ‘moved to heap’、‘escapes to heap’、‘leaking param’,而且输出顺序跟代码顺序对不上。”
“你当时是想解决什么问题?”
“当时我在看一个接口为什么 alloc 那么高。“小林说,“我看了两分钟,放弃了,直接去改业务逻辑了。”
“这就是问题所在。“老陈说,“大部分人学逃逸分析的经历都是这样的:看一眼输出,看不懂,然后得出结论’这个东西太底层了,等我需要的时候再学’。”
“我后来确实没需要。”
“你有需要。“老陈说,“你只是没把两件事连起来。”
“我问你一个更基础的。“老陈说,“栈上分配和堆上分配,成本差多少?”
“栈上快。”
“快多少?”
”……不知道,反正快。”
“我给你数字。“老陈说,“栈上分配,成本是一条 CPU 指令。函数入口那条 sub rsp, N,把栈指针往下挪 N 个字节,你的对象就有了。函数返回的时候,栈指针往回一挪,你的对象就没了。没有记录,没有回收,没有 GC。”
“堆上呢?”
“堆上要调 mallocgc。这个函数要做的事情是:先查 mcache 里当前 size class 有没有空闲块,没有就往 mcentral 要,mcentral 没有就往 mheap 要,mheap 没有就找操作系统 mmap。找到之后,如果对象里有指针,要写屏障,要标记位图。然后这个对象在它的整个生命周期里,会被 GC 反复扫描。”
“所以……”
“一个 64 字节的对象,栈上分配大概 1 纳秒,堆上大概 20 到 30 纳秒。这还只是分配成本,没算 GC 扫描的摊销成本。”
“30 纳秒也不多。”
“对,单次不多。“老陈说,“但你上一次看的那个接口,alloc 是每秒 400 兆。400 兆,如果平均对象大小 100 字节,就是每秒 400 万个对象。400 万乘以 25 纳秒,是 0.1 秒。”
”……”
“每秒有十分之一秒,你的 CPU 在干分配这一件事。”
小林沉默了。
“而且这还不是最贵的。“老陈说,“最贵的是 GC。Go 的 GC 是并发标记,但它标记的时候要扫所有堆上的对象。你的堆越大,对象越多,标记越慢。而堆大是因为你分配得多。所以分配多 → 堆大 → GC 频繁且慢 → STW 变长 → 你的 P99 变差。”
“这个链条我知道。”
“你知道这个链条,但你从来没去量过链条的第一环。”
”……”
“我再问你一个问题。“老陈说,“你知道 fmt.Println(x) 这一行,会发生几次逃逸吗?”
“一次?”
“fmt.Println 的签名是 Println(a ...any) any。“老陈说,“...any 意味着你的 x 要被装箱成一个 interface{}。装箱这个动作,需要把 x 的值(或者指针)和一个类型描述符打包。这个打包出来的东西,编译器没法证明它不会逃出 Println,所以它逃逸到堆上。”
“所以一次日志就是一次逃逸。”
“你的 x 是个 int,就是一次。你的 x 是个有五个字段的结构体,还是一次,但那次要拷贝五个字段。”
”……”
“这就是为什么 Go 的标准库里,fmt 包在热路径上永远是性能杀手。“老陈说,“我不是让你不用 fmt。我是让你知道,你每写一行 fmt.Sprintf,你在账上记了一笔。”
“老陈,“小林说,“我们用了 sync.Pool。”
“用在哪儿?”
“导出功能。每次导出要建一个大的 bytes.Buffer,我们就用 Pool 存起来复用。上线之后内存确实降了。”
“降了多少?”
“从 6 个 G 降到 2 个 G 多一点。”
“那你们是不是觉得这个优化很成功?”
”……是。”
“我问你三个问题。“老陈伸出三根手指,“第一,你们 Pool 里的对象,多久会被清掉一次?”
”……不会清吧?Pool 不就是缓存吗?”
“第二个问题。“老陈没理他,“你们 Put 回去之前,调 Reset 了吗?”
”……”
“第三个问题:你们有没有发现,这个服务在运行两三个小时之后,内存会比刚启动的时候高不少,然后就稳定在那个位置?”
小林愣住了。
“你怎么知道?”
“因为每个用过 sync.Pool 又没处理干净的人,都会看到这条曲线。“老陈说,“这条曲线的名字叫’我在用 Pool 缓存垃圾’。“
生活里的同一件事:木工台面上,和墙上的工具柜
小林的岳父做了三十四年木工,在老家县城开着一个小家具店。
小林第一次去他店里,是订一个电视柜。他在店里待了一整个下午,看老爷子干活,看着看着看出点门道来。
老爷子的工作台是一张两米四的实木台子。台面右手边,有一个固定的区域,摆着四样东西:一把凿子、一把木槌、一把角尺、一支铅笔。四样东西的位置是固定的,铅笔永远在角尺右边三指宽的地方。
老爷子干活的时候,右手往那儿一伸,四样东西随手就能拿到,眼睛都不用抬。
墙上有一排工具柜。柜门上有锁,柜子旁边挂着一个本子。
那次下午,老爷子大概开了三次柜子。一次是拿曲线锯,一次是拿一套不同型号的钻头,一次是拿砂纸。每次开柜子,他都要走到墙边、开锁、翻找、拿出来、然后在本子上记一行。
小林当时问他:“记这个干嘛?”
“以前在木器厂上班养成的习惯。“老爷子说,“厂里的工具要借要还,丢了自己赔。我记了三十年了,改不掉。”
后来小林帮他搬一个柜子,随口问了一句:“为什么不把钻头也摆台面上?台面上放得下啊。”
老爷子说了句话,小林记到现在:
“摆得下是一回事,用得上是一回事。我一天要用三百次凿子,用三次钻头。钻头摆台面上,凿子就得往边上挪,我一天多动三百次胳膊。”
这句话里藏着逃逸分析的全部道理。
台面就是栈,工具柜就是堆。
台面上放东西的成本是零。你伸手放下,就完了。你干完活一推,台面上的东西收进柜子,台面就空了,不需要记账,不需要”归还”。
柜子里放东西的成本是实的:你得走过去,得开锁,得找,得拿出来,得在本子上记一行,用完还得还回去、还得销账。而且每隔一段时间,厂里要盘点一次——所有借出去的工具都要核对一遍。这个盘点,就是 GC。
那么什么情况下,一件工具明明只用三次,却必须放柜子里?
老爷子后来说了四条,我一条一条翻译给你听:
一、这件工具你带不走。 你要去客户家里装柜子,凿子你可以揣兜里带走,电锯不行,电锯得回柜子里登记。这叫”返回局部变量的指针”——函数返回之后,调用方还要用,编译器只能把它放到堆上。
二、这件工具要借给别人。 你徒弟在隔壁房间干活,你的角尺他要借。你给他,他就拿走了,你不知道他什么时候还。这叫”发送到 channel”或者”闭包捕获”——对象的生命周期交给了别人,编译器证明不了它什么时候死,只能放堆上。
三、这件工具太大了。 台面就那么大,一个台锯放上去,别的什么都放不了。这叫”大对象”——超过一定大小,编译器直接放堆上,不做分析。
四、你不知道你要用哪把。 你要在一个木头中间挖一个形状不规则的洞,你不知道该用平凿、圆凿还是斜凿,得试。这时候你只能把一整套都从柜子里拿出来摆着。这叫”interface 装箱”或者”反射”——编译期不知道具体类型,只能走动态路径,参数必然逃逸。
四条,一条不多。
现在你回头看自己的代码,每一次逃逸,都是这四条里的某一条。而你要做的优化,本质上就是问一句:这条能不能改成”放台面上”?
Go 实例:看得见的逃逸,和 sync.Pool 的三个坑
先看逃逸。下面这个程序可以直接跑,它对比了热路径上两种写日志行的方式。
package main
import (
"bytes"
"fmt"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
// ===== A:逃逸 =====
// v1:fmt.Sprintf。三个参数都是 ...any,全部装箱逃逸。
func logLineV1(level, msg string, orderID, amount int64) string {
return fmt.Sprintf("[%s] order=%d amount=%d %s", level, orderID, amount, msg)
}
// v2:strings.Builder + 具体类型。预分配一次,零中间对象。
func logLineV2(level, msg string, orderID, amount int64) string {
var b strings.Builder
b.Grow(16 + len(level) + len(msg) + 40) // 一次算够,避免扩容时的二次分配
b.WriteByte('[')
b.WriteString(level)
b.WriteString("] order=")
b.WriteString(strconv.FormatInt(orderID, 10))
b.WriteString(" amount=")
b.WriteString(strconv.FormatInt(amount, 10))
b.WriteByte(' ')
b.WriteString(msg)
return b.String() // 只有这一处分配:Builder 内部 buf 转成 string
}
func benchFormat(name string, f func(string, string, int64, int64) string, n int) {
runtime.GC()
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
start := time.Now()
var sink int
for i := 0; i < n; i++ {
sink += len(f("INFO", "order paid", int64(i), int64(i)*13))
}
cost := time.Since(start)
runtime.ReadMemStats(&after)
fmt.Printf("%-18s 耗时 %6.1f ms | 每次分配 %5.1f 字节 | 对象数 %8d\n",
name,
float64(cost.Microseconds())/1000.0,
float64(after.TotalAlloc-before.TotalAlloc)/float64(n),
after.Mallocs-before.Mallocs)
_ = sink
}在一百万次调用下的典型结果:
v1 fmt.Sprintf 耗时 214.3 ms | 每次分配 128.0 字节 | 对象数 4000007
v2 Builder 耗时 62.8 ms | 每次分配 64.0 字节 | 对象数 1000003
v1 每次调用分配 4 个对象:三个参数的 interface 装箱,加一个结果字符串。v2 每次分配 1 个。
想知道每一处逃逸在哪,用这个命令:
go build -gcflags='-m' main.go
你会看到类似这样的输出:
./main.go:16:55: level escapes to heap
./main.go:16:55: orderID escapes to heap
./main.go:16:55: amount escapes to heap
./main.go:16:55: msg escapes to heap
./main.go:16:12: []interface{} literal does not escape
escapes to heap 就是逃逸了。does not escape 是没逃。
最容易看漏的是最后一行。[]interface{} 这个切片本身没逃逸,因为编译器优化过——参数少于等于 5 个的时候,fmt 会走一个栈上的临时数组。但切片里的每个元素(那四个装箱后的 interface{})都逃逸了。
如果你想看它为什么逃逸,加一个 -m(写成 -gcflags='-m -m'),会输出完整的逃逸路径:
./main.go:16:55: orderID escapes to heap:
./main.go:16:55: flow: {storage for ... argument} = &{storage for orderID}:
./main.go:16:55: from orderID (passed to call[argument content]) at ./main.go:16:55
这行的意思是:orderID 被传进了一个变参函数,它的地址被存进了”变参的存储区”,而这个存储区的生命周期编译器证明不了,所以 orderID 逃逸。
看不懂没关系,你只需要看最后一行 from xxx (passed to call[argument content]),那个 xxx 就是逃逸的变量名,那个位置就是逃逸点。
然后是 sync.Pool。这一段更重要,因为它踩坑的人比逃逸多十倍:
// ===== B:sync.Pool =====
const maxKeep = 64 << 10 // 超过 64KB 的 buffer 不放回池子
var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
// runPool 跑 200 次,每次往 buffer 里写 1MB,然后放回池子。
// reset=false 就是那个"忘了 Reset"的版本。
func runPool(reset bool) string {
runtime.GC()
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
for i := 0; i < 200; i++ {
b := bufPool.Get().(*bytes.Buffer)
// 模拟一次大导出:往里写 1MB
b.Write(make([]byte, 1<<20))
if !reset {
// 坑 1:忘了 Reset,这个 buffer 还持有着 1MB 的底层数组。
// 坑 2:不管多大都往回放,池子会越来越胖。
bufPool.Put(b)
continue
}
// 正确做法:先判断大小,超了就丢弃;没超就 Reset 再放回
if b.Cap() > maxKeep {
continue // 不放回,让它被 GC 回收
}
b.Reset() // 关键这一行:Reset 之后,Cap 还在(复用),Len 清零(干净)
bufPool.Put(b)
}
// 注意这里故意不 GC:我们要看 Pool 到底持有了多少内存
runtime.ReadMemStats(&after)
return fmt.Sprintf("Pool 持有内存增加 %6.1f MB",
float64(int64(after.HeapInuse)-int64(before.HeapInuse))/1024/1024)
}
func main() {
fmt.Println("=== A. 热路径上的逃逸 ===")
benchFormat("v1 fmt.Sprintf", logLineV1, 1_000_000)
benchFormat("v2 Builder", logLineV2, 1_000_000)
fmt.Println("\n=== B. sync.Pool:Reset 与不 Reset ===")
fmt.Println("不 Reset:", runPool(false))
fmt.Println("有 Reset:", runPool(true))
}跑出来的结果:
=== A. 热路径上的逃逸 ===
v1 fmt.Sprintf 耗时 214.3 ms | 每次分配 128.0 字节 | 对象数 4000007
v2 Builder 耗时 62.8 ms | 每次分配 64.0 字节 | 对象数 1000003
=== B. sync.Pool:Reset 与不 Reset ===
不 Reset: Pool 持有内存增加 201.3 MB
有 Reset: Pool 持有内存增加 0.4 MB
两百兆。
而且这 201 兆还有个特点:它不涨了。跑两千次也是两百兆左右,因为池子里的 buffer 在复用。所以你在监控上看到的曲线,是启动之后涨一段、然后平稳。很多人看到平稳就放心了,觉得”内存稳定,没问题”。
这就是老陈说的那条曲线。
sync.Pool 的三个坑,我一个个说:
坑一:把 Pool 当缓存。 Pool 里的对象,在 GC 的时候会被清掉。Go 1.13 之后引入了 victim cache,所以是”两轮 GC 之后彻底没”,不是一轮。但不管几轮,它一定会没。所以 Pool 只能用来缓存”丢了也能重新造”的东西——buffer、临时对象、编解码器。用户会话、权限信息、配置、连接,一个都不能放。
我见过最离谱的一个案例:有人把数据库连接放进了 sync.Pool,“复用连接”。上线之后每隔一段时间就有一批请求报 sql: connection is already closed。查了一周。
坑二:Put 之前不 Reset。 这个有两层害处。第一层是上面那个:底层数组一直被引用,内存不释放。第二层更隐蔽:数据污染。你 Get 出来的 buffer 里,可能还残留着上一个请求写进去的一半内容。如果你后面是 b.Write(...) 而不是先 b.Reset(),你就会把两个请求的数据拼在一起。
数据污染这种 bug 特别难查,因为它只在同一个 P 上的两次请求之间发生,复现概率跟调度有关。
坑三:无脑把所有对象都放进 Pool。 Pool 不是免费的。它有 per-P 的本地池和共享池,Get/Put 有原子操作开销。如果你的对象很小(几十字节),用 Pool 的开销可能比直接 new 还大。而且 Pool 里的对象在两轮 GC 之内是”活的”,会拉长 GC 的标记时间。
判断标准很简单:对象大于 1KB,且分配频率高(每秒万次以上),才值得用 Pool。 小于这个,别用。
最后一条,也是最重要的一条:sync.Pool 的优化,优先级永远排在”减少分配”后面。 与其把垃圾放进池子里复用,不如一开始就不产生垃圾。
小林那个导出接口,最后真正的解法不是 Pool,是流式写——边查边写文件,不在内存里攒一整个 buffer。Pool 只是让症状轻了一点。
老陈的深层发问
-
你负责的服务,每秒分配多少字节?这个数字你能在三分钟内查到吗?查不到的话,是工具没有,还是你没装?
-
你的代码里有多少个
fmt.Sprintf和fmt.Println是在热路径上的(每次请求至少执行一次)?你知道你怎么筛出来吗? -
如果让你挑出你服务里分配量最大的一个函数,并且在不加机器的前提下把它的分配量降一半,你需要多久?一天,一周,还是你根本不知道从哪下手?
-
你用过的每一个
sync.Pool,你都说得出”这个 Pool 里的对象丢了会怎么样”吗?如果答案是”会出问题”,那你写的是 bug,不是优化。
生活思考题
思考题一:你家冰箱的门架上放什么?冷藏室中间那层放什么?冷冻室放什么?为什么这么放?
参考答案
第一层:大多数人会怎么答 “门架放牛奶、鸡蛋、调料、饮料。中间那层放剩菜、水果、明天要吃的东西。冷冻室放肉、饺子、冰棍。标准答案,因为门架温度最高、最不稳定,适合放不怕温度波动的东西;冷冻室最冷,适合长期保存。”
第二层:这个答案为什么不够 这个答案是对的,但它只讲了一个维度:温度。
真正决定冰箱里东西怎么放的,是两个维度:温度(保存要求)和访问频率(取用成本)。
门架上放牛奶,不是因为牛奶”不怕温度波动”(牛奶其实很怕),是因为牛奶一天要拿出来三四次。你为了让牛奶保鲜把它放到冷藏室最里面,代价是你每次拿都要把前面的东西挪开。这个代价一天付三四次,一年付一千次。
而冷冻室里的年糕,一年拿出来五次。你为了省事把它放门架,代价是它化了、粘了、坏了。
所以冰箱的分区逻辑是:高频访问的东西,放在取用成本最低的位置,哪怕那个位置的保存条件不是最优的。
第三层:更深的一层是什么 把这个逻辑反过来用,就是性能优化的核心思路:先量出”访问频率”,再决定”放在哪”。
大部分人做内存优化的时候,顺序是反的。他们看到内存高,就去全局搜
make(和new(,然后挑最大的那个结构体优化。这就像你看到冰箱塞满了,就去把最大的那盒东西挪个位置——你根本不知道那个东西一年拿几次。正确的做法是先量频率。在 Go 里,这个工具就是 heap profile 的
alloc_objects:go tool pprof -sample_index=alloc_objects ./demo mem.prof它会告诉你,哪个函数分配的对象最多。注意是”最多”,不是”最大”。一个每次请求分配一万次的 32 字节小对象,比一个每分钟分配一次的 1MB 大对象,对你的影响大得多。
拿到这张表之后,你再决定优化哪个。判断标准有三个,按优先级排: 一、调用频率(每秒多少次) 二、每次分配的对象数(不是字节数) 三、能不能让它不逃逸
第四层:落到 Go 工程师身上 本周做一件事:给你最热的那个接口抓一份 heap profile(
alloc_objects维度),找出 top 3 的分配点,然后对着这三条检查表过一遍:
- 这个分配点有没有
fmt.Sprintf/fmt.Errorf/ 日志?有就换成 Builder 或者结构化日志库(zap、zerolog 的Any接口比 fmt 快一个数量级)。- 这个分配点有没有把切片或 map 的容量写死或者不写?
make([]T, 0, n)和make([]T, 0)在热路径上差别巨大。- 这个分配点有没有把值类型传成了指针”为了避免拷贝”?很多时候,直接传值反而更快,因为值可以留在栈上,而指针会让它逃逸。
最后这条特别反直觉,值得单独说:很多人以为传指针”避免拷贝”一定更快。错的。指针让对象逃逸到堆上,堆分配 + GC 的成本,往往超过拷贝 64 字节的成本。 只有在对象真的很大(几百字节以上)的时候,传指针才划算。
思考题二:公司前台有个共享雨伞架,十把伞,谁都能借,借了要还。你觉得这个制度能撑多久?
参考答案
第一层:大多数人会怎么答 “撑不了多久。十把伞,一周之后就剩三把,一个月之后一把不剩。因为没人还。这是公地悲剧,要么登记,要么押金。”
第二层:这个答案为什么不够 这个答案假设了失败的原因是”人性”——借伞的人素质不高,不还。
但你去看看真实运行良好的共享雨伞(比如很多写字楼里的那种扫码借伞),会发现它们运行得挺好。伞没有丢光。所以”人性”不是主因。
真正的原因是制度设计的三个细节,缺一个就会崩:
一、有没有定期清场。运行良好的共享伞,物业每周一早上会去把所有没还的伞收回来,重新摆整齐。这个动作跟 sync.Pool 在 GC 时被清空,是同一个动作。没有清场,伞就会在各种角落里堆积,系统里显示”还有十把”,实际上一把能用的都没有。
二、还的时候要不要检查。你借的时候是一把好伞,还的时候伞骨断了两根。下一个人借到这把伞,打开就废了。这叫”Put 之前不 Reset”。共享伞系统会要求你扫码还伞,扫的时候顺便报一个”完好/损坏”——这就是 Reset 和状态检查。
三、伞里能不能放私人物品。你借了伞,把你的公交卡夹在伞柄上,然后还了。下一个人借走这把伞,打开掉出来一张公交卡。这在系统里叫”数据污染”,而且它比丢伞危险得多——丢伞你只会淋雨,数据污染会导致一个用户的资料出现在另一个用户的界面上。
第三层:更深的一层是什么 这三条,正好是 sync.Pool 的三个坑,一条不多一条不少。
但最值得说的是第三条,因为它的本质是一个更普遍的问题:当你把一个有状态的对象放进”共享池”里,你就默认了”上一个使用者会把状态清理干净”。
这个假设在工程上几乎总是不成立的。不是因为人懒,是因为清理的责任和使用的责任不在同一个人身上。用的人有动力用,没动力清理;下一个人有动力拿到干净的,没动力清理上一个的。
所以所有共享池(连接池、对象池、线程池、协程池)的设计都必须遵守一条铁律:清理动作必须由池子自己完成,不能依赖使用者的自觉。
具体到代码上,就是:在 Get 的时候 Reset,不是在 Put 的时候 Reset。
// 推荐:Get 出来立刻 Reset,这样即使上一个使用者忘了,也不影响你 b := bufPool.Get().(*bytes.Buffer) b.Reset() defer func() { if b.Cap() <= maxKeep { bufPool.Put(b) } }()这个顺序的好处是:就算有人写了脏代码,也脏不到别人。
第四层:落到 Go 工程师身上 三件事,按优先级:
一、全局搜你的代码里的
sync.Pool,列出清单。对每一个,回答三个问题:这个对象丢了会怎样?Get 出来之后有没有 Reset?有没有大小上限?三个问题有一个答不上来,就改。二、检查你用的所有第三方库里的池化对象:
sql.DB(连接池)、http.Client(连接)、redis.Client、sync.Pool封装。它们的MaxIdle、MaxOpen、IdleTimeout分别是多少?这些默认值在流量涨三倍的时候会变成瓶颈。三、最重要的一条:在你决定用池之前,先试试能不能不分配。 池是把垃圾收起来重复用,不分配是不产生垃圾。后者永远优先。具体手段:预分配容量、复用入参的切片、把小结构体改成值传递、把热路径上的 fmt 换掉。
3.7 线上事故那晚:从慌乱到方法论
九月十六号,周三,晚上十点四十七。
小林已经躺下了。他老婆在旁边看手机,屏幕的光打在天花板上。
手机响的时候,他第一反应是闹钟没关。摸过来一看,是企业微信的语音通话,大刘。
他坐起来,接了。
“小林,支付回调在报错,成功率掉到 91% 了。”
“多少?”
“百分之九十一。平时是 99.98。“大刘的声音很平,他干 SRE 八年了,情绪稳定得让人恼火,“已经持续六分钟了。”
“我看看。”
小林光着脚下床,走到客厅,打开笔记本。他手是凉的。
他先做了一件很自然的事:打开 Grafana,看支付回调的面板。
曲线长这样:成功率在 22:41 从 100% 直直地掉到 91%,然后横在那儿。不是抖动,不是锯齿,是一条台阶,然后是一条直线。
他盯着这条线看了大概二十秒。
“大刘,我这边看到的是成功率掉了,P99 呢?”
“P99 从 62 毫秒涨到 1.8 秒。”
“机器呢?CPU 内存?”
“CPU 92%,内存 5.8G,容器限制是 6G。“大刘说,“要不要先扩容?”
“先别。“小林说。
他说完这三个字,自己愣了一下。
三周前,他的第一反应一定会是”扩容”。而且三周前的他,第二句话会是”这块是王磊写的,我没动过”。
现在他说的是”先别”。
他给老陈发了条微信:回调在报错,成功率 91%,持续六分钟,CPU 92%,内存 5.8G 快到上限了。
老陈秒回,只有一句话:
“影响面多大?”
小林愣住了。他没想过这个问题。
他花了两分钟,跑了三条 SQL,然后回:最近十分钟,回调进来 4600 笔,失败 414 笔,涉及 37 个商户,最大单笔 12 万。
老陈第二句:“失败的那些,现在数据是对的还是错的?”
小林又去查。订单表、结算表、MQ 的投递记录。
四分钟之后他回:数据是对的。失败的那些回调,上游重试了,都成功了。没有资损。
老陈第三句:“那你现在应该干什么?”
小林看着这句话,突然反应过来了。
没有资损,上游重试成功,成功率虽然在 91% 但用户侧感知不到(支付结果页是轮询的)。
所以他现在应该干的不是”查根因”。是让这条曲线回到 100%,然后去睡觉,明天再查。
他给大刘打电话:“能先重启两台吗?滚动重启,一次一台。”
“确定?”
“确定。数据没问题,失败的都是重试能成功的。”
两台机器重启之后,成功率回到了 99.97%,CPU 降到 31%,内存 1.9G。
十一点二十三分。全程三十六分钟。
小林坐在客厅的地板上,后背靠着沙发。他老婆出来倒了杯水给他,问”搞定了?“,他说”搞定了,睡吧”。
然后他坐在那儿,一直到凌晨一点。
第二天上午十点,他跟老陈在茶室碰面。
“我昨晚想了很久。“小林说,“我还是不知道为什么。”
“你昨晚做对了三件事。“老陈说,“我讲给你听。”
“第一件,你问了影响面。“老陈伸出一根手指,“大部分人被叫起来,第一件事是开电脑看代码。你第一件事是问’多少钱、多少笔、多少个商户’。这三个数字决定了后面所有动作的优先级:是 P0 还是 P2,要不要叫主管,要不要通知客服。”
“我当时是被你问的。”
“我知道。“老陈说,“但你现在知道了,下次不用我问。”
“第二件,你查了数据对不对。“老陈伸出第二根手指,“这是我最想夸你的一件。大部分人查到这里会停,因为他们默认’报错就是数据错了’。你去查了订单表、结算表、MQ 记录。这一步做完,你才知道这条曲线是’难看’还是’要命’。”
”……”
“第三件,你在没找到根因的情况下重启了。“老陈说,“这是最反直觉的一件。你们这行有个很坏的习气:觉得’没找到根因就重启’是不专业的、是掩盖问题。”
“我以前也这么觉得。”
“那是学生思维。“老陈说,“线上系统的第一优先级是恢复服务,不是找到真相。真相可以明天找,用户的钱不能明天到账。”
“可是重启会把现场毁掉。”
“所以你要在重启之前,把现场保存下来。“老陈说,“三样东西,三十秒:pprof 的 goroutine profile、heap profile、还有当前 goroutine 的数量。”
curl -s "http://10.0.1.23:8080/debug/pprof/goroutine?debug=2" > goroutine.txt
curl -s "http://10.0.1.23:8080/debug/pprof/heap" > heap.prof
curl -s "http://10.0.1.23:8080/debug/pprof/profile?seconds=30" > cpu.prof“这三行,你当时跑了吗?”
”……没有。”
“所以你现在没有现场了。”
小林沉默。
“所以我今天要教你的第四件事,是补昨天的课。“老陈说,“我们从头走一遍排查的路径。”
“我问你,昨天那条曲线,你第一眼看到的时候,它是什么形状?”
“台阶。”
“不是锯齿,不是缓慢上升,是台阶。“老陈说,“形状决定原因,这是排查里最省时间的一条经验。”
他在纸上画了三条线。
“缓慢上升,然后趋于平缓——这是泄漏。内存泄漏、goroutine 泄漏、连接泄漏、文件句柄泄漏。它的特点是长时间的、单调的恶化,而且重启之后会从头开始。”
“突然的台阶,然后保持——这是变更。发版、配置推送、数据变更、DNS 变更、证书过期。它的特点是有一个精确的时间点。”
“锯齿状的抖动——这是外部依赖或者资源竞争。下游抖动、GC、锁竞争、网络重传。”
“你昨天是哪种?”
“台阶。”
“所以第一步该做什么?”
”……找变更。”
“对。找 22:41 之前 24 小时内的所有变更。“老陈说,“发版记录、配置中心的操作日志、数据库的 DDL、DNS、证书、甚至运营在后台改的一个开关。”
小林打开电脑,翻了十五分钟。
“上周四发过一次版。“他说,“但是那是五天前。配置中心最近一次变更是上周一。”
“那不是它。”
”……”
“台阶的形状还有一种可能。“老陈说,“它不是外部变更触发的,是内部累积到了临界点。”
小林抬头。
“泄漏曲线走到最后,也会变成台阶。“老陈说,“量变到质变。goroutine 从 3000 涨到 30000 的时候,服务还是好的;涨到某个点,调度器的开销突然压过了处理能力,成功率就掉下来了。从外面看是台阶,实际上是斜坡的终点。”
“所以……”
“所以你昨天应该先看一眼 goroutine 数。”
小林打开 Grafana,翻了半天,没找到。
“没有这个面板。”
“对。“老陈说,“这就是问题。“
生活里的同一件事:高速上爆胎,和你下意识踩的那一脚刹车
去年国庆,小林开车带老婆回常德。
沪渝高速,安庆段,晚上八点多,下着小雨。车速一百零几,右后轮炸了。
他后来跟我描述那个感觉:“不是’砰’一声,是一声闷响,然后车尾开始往右边拽。”
他的第一个动作是踩刹车。
而且踩得挺狠。
他老婆在后排尖叫了一声。车开始甩尾。
万幸的是他后面两百米没有车。
车横在应急车道上停住之后,他手一直在抖,握着方向盘握了大概半分钟没松开。
后来他给我讲这件事的时候,我问了他一个问题:“你踩刹车的时候,脑子里在想什么?”
他想了很久,说:“我在想’停下来’。”
“对,你在想停下来。“我说,“你没有在想’怎么停’。”
这两件事的区别,就是那天晚上他学会的全部东西。
正确的高速爆胎处理流程,是一个老司机后来教他的,一共六步,顺序不能乱:
第一步,握紧方向盘,不要动方向。 爆胎的那一瞬间,车会往爆的那一侧拽。你的本能是反打方向去修正。这一反打,就是甩尾的开始。正确做法是双手握死,让车自己走直线。
第二步,绝对不要急刹。 爆胎之后轮胎的阻力本身就是一股强大的制动力,你再踩刹车,左右两侧的制动力差会让车直接失控。
第三步,松油门,让车自己减速。 从 100 降到 40,大概需要十几秒。这十几秒很难熬,但它是必须的。
第四步,速度降到 40 以下,点刹,慢慢往应急车道靠。
第五步,停稳,双闪,车上所有人撤到护栏外面。 这一步的优先级高于看轮胎。
第六步,三角牌放在车后 150 米。晚上、雨天、弯道,要放到 200 米。
六步里,前四步是”恢复控制”,后两步是”保护人”。
而小林那天晚上做的,是跳过了前四步,直接想做”看轮胎”。
这个错误,跟他接到报警电话时的第一反应,是同一个错误:把一个”先稳定、后诊断”的问题,做成了”先诊断、后稳定”。
还有一件事,是那次之后他做的。
他在网上花三百多块买了一套胎压监测,内置式的,四个轮子各一个传感器,显示器粘在中控台上。
装上之后的第一个星期,他老婆问他:“你这个东西,有用吗?”
他说:“没用。”
“那你买它干嘛?”
他想了想,说了一句话:
“爆胎不是突然发生的。“他说,“胎压是慢慢降的。那天那个轮胎,可能已经漏气漏了一个星期了。我要是上个星期就知道它在漏,我就不会在高速上遇到那一下。”
这句话,就是 P6 和 P5 在稳定性上的全部区别。
P5 的世界里,事故是”发生的”。P6 的世界里,事故是”到达的”。
Go 实例:把那晚的泄漏复现出来
第二天下午,小林找到了原因。
那段代码在支付回调服务里,是一个三年前写的结算补偿任务。它长这样(简化版,可以直接跑):
package main
import (
"context"
"fmt"
"runtime"
"time"
)
// ===== v1:事故现场的最小复现 =====
type settlement struct {
queue chan string // 无缓冲
}
func newSettlement() *settlement {
return &settlement{queue: make(chan string)} // 坑:无缓冲,且没有消费者
}
// Run 每 50 毫秒投递一笔结算事件。
// 三个月前的一次重构中,queue 的消费者被移到了另一个服务,
// 但这段代码没人动,也没人删——它就这样一直往一个没人读的 channel 里发。
func (s *settlement) Run() {
t := time.NewTicker(50 * time.Millisecond)
defer t.Stop()
for range t.C {
// 坑中之坑:为了"不阻塞主循环",每次投递都新起一个 goroutine。
// 这把背压问题变成了 goroutine 泄漏——发送会永久阻塞,goroutine 永远不退出。
go func() {
s.queue <- "settle-event" // 所有 goroutine 都卡在这一行
}()
}
}
// 巡检:每分钟看一次 goroutine 数量,超过阈值就报警
func guard(ctx context.Context, threshold int) {
t := time.NewTicker(time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
n := runtime.NumGoroutine()
if n > threshold {
fmt.Printf(" [巡检][ALERT] goroutine 数 %d,超过阈值 %d\n", n, threshold)
}
}
}
}
func main() {
fmt.Println("=== v1:泄漏 ===")
s := newSettlement()
go s.Run()
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
go guard(ctx, 500)
var ms runtime.MemStats
for i := 1; i <= 6; i++ {
time.Sleep(time.Second)
runtime.ReadMemStats(&ms)
fmt.Printf(" 第 %d 秒:goroutine %6d | 堆占用 %6.1f MB | GC 次数 %3d | STW 累计 %6.1f ms\n",
i,
runtime.NumGoroutine(),
float64(ms.HeapInuse)/1024/1024,
ms.NumGC,
float64(ms.PauseTotalNs)/1e6)
fmt.Printf(" %s\n", stackTop())
}
}
// stackTop 打印当前占 goroutine 数最多的那个调用点,等价于人肉看 goroutine profile
func stackTop() string {
buf := make([]byte, 1<<20)
n := runtime.Stack(buf, true)
// 真实排查里应该直接抓 /debug/pprof/goroutine?debug=2,这里只是示意
return fmt.Sprintf(" (抓一份 stack 看到 %d 字节,其中绝大部分停在 chansend)", n)
}跑起来你会看到 goroutine 数每秒增加二十个,堆占用持续上升,GC 次数越来越密。
泄漏速率是每 50 毫秒一个,一天就是 172 万个。
线上那条曲线是这么走的:启动的时候 3000 个 goroutine,每天增加 172 万……不对,实际上没那么快,因为服务每隔几天会被重新部署一次。真实的曲线是:每次发布之后 goroutine 从 3000 开始爬,爬到十八万左右的时候,调度开销和 GC 开销压过了处理能力,成功率掉下来——这就是 22:41 那条台阶。
而周三距离上一次发布,正好是十七天。
找到根因的那一步,靠的是一行命令:
curl -s "http://10.0.1.23:8080/debug/pprof/goroutine?debug=2" | head -100
输出里有这么一段(数字会不一样,形状是固定的):
goroutine 187342 [chan send, 8123 minutes]:
main.(*settlement).Run.func1(...)
/app/settlement.go:31 +0x3f
created by main.(*settlement).Run
/app/settlement.go:30 +0x88
187342 [chan send, 8123 minutes] —— 十八万七千个 goroutine,状态是 chan send,已经等了 8123 分钟(五天半)。
这一行就够了。你不需要看业务代码,不需要懂结算逻辑。你只需要看到”十八万个 goroutine 卡在同一个 chan send 上”,就知道发生了什么。
v2 是修好之后的版本:
// ===== v2:背压要显式处理,不能靠起 goroutine 绕过 =====
type settlementV2 struct {
queue chan string
dropped int64 // 丢弃计数,必须埋点,否则你永远不知道丢了多少
}
func newSettlementV2() *settlementV2 {
// 有界缓冲:容量是多少,取决于"堆积多少我能接受"以及"处理不过来时我怎么兜底"
return &settlementV2{queue: make(chan string, 1024)}
}
// Run 受 ctx 控制,退出时整个 goroutine 一定会被回收
func (s *settlementV2) Run(ctx context.Context) {
t := time.NewTicker(50 * time.Millisecond)
defer t.Stop()
for {
select {
case <-ctx.Done():
return // 关键:没有这一行,这个 goroutine 就是又一个泄漏
case <-t.C:
select {
case s.queue <- "settle-event":
// 正常投递
default:
// 队列满了。这是背压,必须在这里做决定:
// 丢弃(要计数、要告警、要有补偿任务)还是阻塞(要能接受延迟)。
// 唯一不能做的是"起一个新 goroutine 来解决阻塞"——
// 那等于把背压翻译成泄漏。
s.dropped++
}
}
}
}
// Consume 是消费者,它也在 ctx 的控制下
func (s *settlementV2) Consume(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case ev := <-s.queue:
_ = ev // 处理
}
}
}这段代码里藏着四条原则,每一条都是事故换来的:
一、用 goroutine 解决阻塞,就是把背压变成泄漏。 这是 v1 最核心的错误,也是最普遍的错误。我在十几个团队的代码里见过这个模式,写法都差不多:go func() { ch <- x }(),注释还写着”异步发送,不阻塞主流程”。它确实不阻塞主流程,它阻塞的是整个进程——只不过是慢慢地阻塞。
二、每一个长期运行的 goroutine,都必须有一个明确的退出条件。 通常是一个 ctx.Done()。你在写 for {} 或者 for range ticker.C 的时候,第一件事就是加这个 case。
三、背压必须显式处理。 队列满了怎么办,是这个设计里最重要的问题,而它恰恰是大多数人没想过的问题。default 分支里的那几行,是这段代码里最值钱的部分。
四、丢弃要有计数。 你在 default 分支里加一个 dropped++,然后把它接到监控上。没有这个数字,你的丢弃就是一个黑洞。
还有一个东西,v1 和 v2 都该有但都没有:goroutine 数量的监控和告警。
这是小林那天之后做的第一件事。他加了一个巡检 goroutine,每 30 秒把 runtime.NumGoroutine() 打到一个 Prometheus 的 Gauge 上,然后配了一条告警:五分钟内增长超过 50%,或者绝对值超过基线三倍,就报警。
这条告警上线之后的第一个月,响过两次。两次都是真的泄漏。
最后,把那个晚上和第二天做的事情,整理成一条路径。这条路径我建议你抄下来贴在显示器上:
第一步,先问影响面,不是先问原因。 多少钱、多少笔、多少用户、涉及哪个商户。这三个数字决定优先级。
第二步,看曲线形状。 台阶查变更,斜坡查泄漏,锯齿查依赖。这一眼能省你一个小时。
第三步,查数据对不对。 报错不等于数据错。资损和告警是两个量级的事件,不要混为一谈。
第四步,保存现场。 三行 curl,三十秒。goroutine profile、heap profile、CPU profile。没有这三样,你重启之后就是在赌。
第五步,恢复优先。 重启、回滚、扩容、降级、切流量,哪个快用哪个。不要为了保现场而拖着不恢复,现场已经存了。
第六步,找到那个”卡住的”东西。 goroutine profile 里,看数量最多的那个状态。chan send、semacquire、IO wait、select,每个状态指向完全不同的方向。
第七步,写一份事故报告。 不是给领导看的,是给三个月后的自己看的。格式不重要,重要的是里面要有一个东西:下次怎么更早发现它。
第七步是这七步里唯一不能跳的一步。前六步让你活过今晚,第七步让你不用再活一次今晚。
老陈的深层发问
-
如果今晚十一点你的服务开始报警,你被叫起来,你能在五分钟之内说出”影响多少钱”吗?还是你要先花二十分钟查?
-
你的服务里,有多少个 goroutine 是没有退出条件的?你有没有数过?
-
你上次事故报告里写的”改进项”,有几条真的做了?如果一条都没做,那那份报告是写给谁看的?
-
你的监控里,有多少条告警是”出事之后才响”的,有多少条是”出事之前就响”的?比例是多少?
生活思考题
思考题一:半夜两点,你家的烟雾报警器突然响了。你的第一反应是什么?
参考答案
第一层:大多数人会怎么答 “冲进厨房看是不是着火了。先找到源头,如果没着火就把报警器按掉,继续睡。”
第二层:这个答案为什么不够 这个顺序有一个致命的问题:你在没有排除危险的情况下,把自己送进了最危险的房间。
真实火灾里,从起火到浓烟致命,只有几分钟。而烟雾报警器响的时候,你不知道火在哪儿,也不知道烟有多浓。你冲进厨房,如果火就在厨房,你正好撞进去。
消防教的顺序是反过来的:
一、先叫醒所有家人,让所有人往门口走。这一步的优先级最高,因为它不可撤销——人没了就真没了,房子烧了还能重建。 二、在移动的过程中,用手背去摸门把手和门板。烫,说明门后有火,不能开门,换路线。 三、出门,关门(关门能延缓火势蔓延),到安全位置,打 119。 四、只有在确认没有火之后,才回去处理那个报警器。
注意这四步的顺序:人 → 疏散路径 → 隔离 → 报警 → 最后才是诊断。
第三层:更深的一层是什么 这个顺序之所以反直觉,是因为人在紧急情况下,本能是消除那个让自己不安的信号,而不是应对信号背后的威胁。
烟雾报警器响,让你不安的不是火,是那个噪音。所以你的本能是”让它别响”——按掉它、拆电池、或者直接冲进去看看。
线上事故里,让你不安的不是资损,是那个告警电话和那根掉下来的曲线。所以你的本能是”让曲线回到 100%“——重启、扩容、回滚。
这两个本能是同一个本能,而且它们都是正确的。区别在于,你有没有在消除信号之前,先做完那几件”不可撤销”的事。
在火灾里,不可撤销的事是”确认人还活着”。在事故里,不可撤销的事是”确认数据没坏”和”保存现场”。
这两件事做完,你爱怎么重启怎么重启。
第四层:落到 Go 工程师身上 把你服务的应急预案写成一份二十行以内的 runbook,放在告警链接能直接跳转到的地方。里面必须有这四项,顺序不能变:
一、定性:一条 SQL,查出最近十分钟的影响面(多少笔、多少钱、多少个商户)。这一条要提前写好,不能现编。 二、查数据:一条 SQL 或者一个脚本,确认数据有没有坏。同样要提前写好。 三、存现场:三行 curl,抓 goroutine / heap / CPU profile。写成一行脚本,粘贴即用。 四、止血:重启 / 回滚 / 降级的具体命令。要写具体命令,不能写”考虑重启”。
这四步加起来二十行。写完把它贴到告警的备注里。
我可以给你一个判断标准:如果你的 runbook 里没有”查数据有没有坏”这一条,那它不是应急预案,是重启手册。
思考题二:你去医院看病,医生问的第一句话通常是什么?为什么他要问这些?
参考答案
第一层:大多数人会怎么答 “哪里不舒服?“然后是”多久了?""什么时候开始的?""以前有没有过?""吃了什么药没有?”
常见的解释是:“医生在采集病史,这是诊断的基础。”
第二层:这个答案为什么不够 “采集病史”这个说法,把这四个问题当成了一个信息收集动作。但它们不是。它们是一个逐步缩小范围的过程,而且顺序是精心设计的。
“哪里不舒服”是定位。它把搜索空间从”整个人体”缩小到”某个系统”。
“多久了”是定性。三天和三年是完全不同的两类病。三天是急性的(感染、外伤、梗阻),三年是慢性的(退行性、代谢、肿瘤)。这一问直接把可能性的数量砍掉一半。
“什么时候开始的”是找触发点。它对应的是”台阶还是斜坡”。突然开始 → 找那个时间点发生了什么;慢慢开始 → 找累积因素。
“以前有没有过”是找复发模式。它区分”新出现的”和”老毛病犯了”。
这四个问题问完,一个有经验的医生脑子里已经只剩三五个候选了,后面的化验单只是为了排除。
第三层:更深的一层是什么 这套方法论的精髓在于:它先花三十秒问问题,再花三小时做检查。
而大部分工程师面对线上问题的顺序是反的:先花三小时抓各种数据,然后在一堆数据里找规律。
为什么反了?因为问问题需要你已经知道有哪些可能性。医生能问出这四个问题,是因为他脑子里有一张疾病分类图。你问不出问题,是因为你脑子里没有”故障分类图”。
所以排查能力的本质,不是工具熟练度,是分类体系的完整度。
一个 P6 应该能在三十秒内,对任何一个线上现象做出这样的分类:
- 是突发的还是渐进的?(台阶 / 斜坡 / 锯齿)
- 是全局的还是局部的?(所有机器还是某一台?所有商户还是某一个?)
- 是我这侧还是依赖那侧?(内部错误率涨了,还是下游超时涨了?)
- 是流量变了还是处理能力变了?(QPS 涨了,还是单请求成本涨了?)
四个二选一,十六种组合,每一种组合对应一套完全不同的排查路径。三十秒就能把范围砍掉 90%。
第四层:落到 Go 工程师身上 把你负责的服务最近半年的每一次告警和事故,整理成一张表,每行一条,三列:现象、分类(上面四个二选一的答案)、根因。
整理完你会发现一件事:你的服务其实只有那么五六种故障模式,翻来覆去地出现。
然后针对这五六种模式,每一种写一个”第一个该看的指标”。比如:
- 依赖超时 → 第一个看下游的 P99,不是看自己的日志
- 慢查询 → 第一个看 DB 的 CPU 和慢日志,不是看自己的连接池
- goroutine 泄漏 → 第一个看 goroutine 数曲线,不是看内存
- 流量突增 → 第一个看 QPS 的来源分布(哪个调用方、哪个接口)
这张表写完之后,你排查问题的速度会快三到五倍。不是因为你变聪明了,是因为你不再从零开始了。
3.8 边界感:什么时候该说不,什么时候必须接
十月十四号,还是那家茶室。这次是下午,阳光斜着从窗户打进来,茶室里只有他们两个人。
老陈给他倒了杯茶,然后问了一句:
“回到我们第一次聊的那个问题。上周三下午,支付回调的 P99 抖了三次,你知道为什么吗?”
小林笑了。
“知道。”
“说说。”
“DBA 那边有个定时任务,每半小时跑一次,批量更新订单表的索引统计。三点零五那次正好撞上我们一个商户的批量回调,两百多笔一起进来,索引统计让那批更新的执行计划变了,走了全表扫描。”
“你怎么知道的?”
“我去看的。“小林说,“出事那晚之后,我把支付回调的上下游全摸了一遍。MySQL、Redis、结算 MQ、上游的网关,四个依赖的监控面板我都加进了收藏夹。上周三那条曲线是我自己看到的,不是别人告诉我的。”
“看了多久?”
“两个下午。”
“两个下午。“老陈重复了一遍,“你这块地八千行代码,四千行你没看过,但你的四个依赖你摸清楚了。”
“我先把能让我半夜被叫醒的东西摸清楚了。“小林说,“代码慢慢看。”
老陈点点头。
“那我现在问你第二个问题。“他说,“既然你知道是 DBA 那个定时任务,你打算怎么办?”
”……”
“你可以说’那不是我们的服务,是 DBA 的定时任务,我管不了’。“老陈说,“这句话是事实。”
“是事实。”
“但是。”
“但是。“小林接了下去,“墙是我家的。”
老陈愣了一下,然后笑了。
“这话谁教你的?”
“没人教我。“小林说,“我上个月家里漏水,想明白的。“
生活里的同一件事:隔壁漏的水,和我家的墙
小林家是顶楼,十一层。
八月中旬,主卧靠北那面墙的墙纸开始鼓包。他没当回事,以为是梅雨季返潮。
过了四天,鼓包的地方开始掉渣。他用手一抠,墙皮是软的,抠下来一撮,后面是黑的。
那天是周四。他上楼去敲 1102 的门,没人。
他在门口贴了张纸条,留了电话。第二天没人打。
第三天,他找到了物业。物业查到 1102 是出租的,房东姓李,电话打了,李先生态度挺好,说”我下周过去看看”。
下周。
小林挂了电话,站在那面墙前面,看了很久。
后来他跟我讲这段的时候,说了一句特别实在的话:
“我当时脑子里有两个声音。一个说’这不是我的责任,是他家水管漏的,我该做的都做了,等着就行’。另一个说’这墙是我家的,它每天多霉一点,是我每天多损失一点’。”
他做的第一件事,不是继续催房东。是找了个做防水的师傅,上门看了一眼,做了个临时的引流——在渗水点下面装了个接水盘,用一根软管引到地漏。
三百二十块。师傅说这叫”止住自己这侧的损失”,不解决根因,但能让你墙不再继续坏。
第二件事,他每天给李先生发一条微信。不是催,是发照片。今天这面墙变成什么样了,拍一张,发过去。
他发了六天。
第七天,李先生来了。不是因为愧疚,是因为他发现再不来,墙体结构的维修费用可能要他承担——小林第五天的时候在微信里提了一句”我咨询了一下,如果渗到承重结构,维修方案会复杂很多”。
修水管用了半天。李先生态度一直很好,最后还主动承担了小林这面墙的重刷费用。
整个事情前后十四天。
小林后来复盘,说这里面有三条,跟他工作上的事一模一样:
第一条:不是你的责任,不等于不是你的问题。
水管不是他家的,漏水不是他造成的,修复费用也不该他出。但墙是他家的。这两件事在法律责任上是分开的,在现实里是绑在一起的。
你没法对着一面正在发霉的墙说”这不是我的责任”。墙不知道责任是谁的,它只知道每天多霉一点。
第二条:先止住自己这侧的损失,再谈根因。
那个三百二十块的引流,是整件事里最划算的一笔钱。它没有解决任何问题,但它把”持续恶化”变成了”暂停”。
大部分人在这种事情上,会陷入一种奇怪的执着:觉得”不解决根因的临时方案是浪费钱”。于是他们什么都不做,等着根因被别人解决,然后眼睁睁看着自己的墙一天天烂下去。
第三条:推动,而不是等待,也不是越界。
他没有去砸 1102 的门,也没有自己修人家水管——那是越界。他也没有贴完纸条就撒手不管——那是放弃。
他做的是:每天发一张照片。这个动作里没有情绪,没有指责,只有一件事:让对方持续地看见后果。
这三条,就是”不是我的代码出了事,但我扛下来了”的完整操作手册。
Go 实例:说不,要说在发起之前
边界感落到代码上,最具体的体现就是一件事:在没有把握的时候,你是”发起之后被掐断”,还是”发起之前就决定不发起”。
下面是一个可以直接跑的程序。一个接口要串行处理五个分支:库存、价格、风控是硬依赖,优惠券、推荐是软依赖。上游给的总预算是 350 毫秒。
package main
import (
"context"
"fmt"
"sync/atomic"
"time"
)
type Item struct {
Name string
Cost time.Duration // 真实耗时
Hard bool // true = 硬依赖,失败就必须整体失败
Want time.Duration // 我期望拿到多少预算
MinUseful time.Duration // 低于这个预算,发起了也是浪费,不如不发
}
var items = []Item{
{"库存", 80 * time.Millisecond, true, 100 * time.Millisecond, 50 * time.Millisecond},
{"价格", 120 * time.Millisecond, true, 150 * time.Millisecond, 80 * time.Millisecond},
{"风控", 100 * time.Millisecond, true, 150 * time.Millisecond, 80 * time.Millisecond},
{"优惠券", 150 * time.Millisecond, false, 200 * time.Millisecond, 100 * time.Millisecond},
{"推荐", 250 * time.Millisecond, false, 250 * time.Millisecond, 150 * time.Millisecond},
}
// wastedByDownstream 记录下游为了那些"注定失败的请求"付出了多少时间。
// 这是你的代码给别人造成的成本,而它从来不会出现在你自己的监控里。
var wastedByDownstream int64
func call(ctx context.Context, it Item) error {
start := time.Now()
defer func() {
if ctx.Err() != nil {
atomic.AddInt64(&wastedByDownstream, int64(time.Since(start)/time.Millisecond))
}
}()
select {
case <-time.After(it.Cost):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
type Resp struct {
Values map[string]string
Degraded []string
}
// ===== v1:每个分支写死 300ms,跟上游预算毫无关系 =====
func handleV1(ctx context.Context) (Resp, error) {
r := Resp{Values: map[string]string{}}
for _, it := range items {
c, cancel := context.WithTimeout(ctx, 300*time.Millisecond) // 这个 300 是哪来的?
err := call(c, it)
cancel()
if err != nil {
if it.Hard {
return r, fmt.Errorf("%s 失败: %w", it.Name, err)
}
r.Degraded = append(r.Degraded, it.Name+"(超时)")
continue
}
r.Values[it.Name] = "ok"
}
return r, nil
}v1 的问题不在”它错了”,在于它在发起之前从不检查。优惠券那个分支发起的时候,上游只剩 50 毫秒,它还是老老实实发了请求,下游收到之后做了 50 毫秒的工作,然后调用方挂断。
下游为这 50 毫秒付出了 CPU、连接、goroutine,最后一无所获。而这些成本,在你自己的监控上是零——你只会看到”优惠券接口超时率上升”。优惠券那边的人会看到”超时率上升”,然后他们开始查自己的问题。
v2 引入一个预算对象,把”说不”这件事变成显式的:
// Budget 把上游给的截止时间,变成一份可以逐项申领的预算
type Budget struct {
deadline time.Time
reserved time.Duration // 留给收尾(聚合、序列化、回包)的时间
used time.Duration
}
func NewBudget(ctx context.Context, reserve time.Duration) (*Budget, bool) {
dl, ok := ctx.Deadline()
if !ok {
return nil, false
}
return &Budget{deadline: dl, reserved: reserve}, true
}
func (b *Budget) Left() time.Duration {
return time.Until(b.deadline) - b.reserved - b.used
}
// Grant 是"说不"的正确位置:在发起调用之前。
// 返回 false 表示"预算不足,这个调用不该发起"。
func (b *Budget) Grant(parent context.Context, want, minUseful time.Duration) (context.Context, context.CancelFunc, bool) {
left := b.Left()
if left < minUseful {
return nil, nil, false
}
if want > left {
want = left
}
return context.WithTimeout(parent, want)
}
func (b *Budget) Spent(d time.Duration) { b.used += d }
// ===== v2:预算申领制,没预算就不发起 =====
func handleV2(ctx context.Context) (Resp, error) {
r := Resp{Values: map[string]string{}}
b, ok := NewBudget(ctx, 30*time.Millisecond) // 留 30ms 收尾
if !ok {
// 上游连 deadline 都没给。这时候不要"随便给个值",
// 要自己设一个,并且把这件事记进日志——这是接口契约的缺失。
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
b, _ = NewBudget(ctx, 30*time.Millisecond)
fmt.Println(" [WARN] 上游没有传 deadline,已使用默认 500ms")
}
for _, it := range items {
start := time.Now()
c, cancel, ok := b.Grant(ctx, it.Want, it.MinUseful)
if !ok {
// 关键分支:预算不足,一个字节都不发出去
if it.Hard {
return r, fmt.Errorf("%s: 预算不足(%v),未发起调用", it.Name, b.Left())
}
r.Degraded = append(r.Degraded, fmt.Sprintf("%s(预算不足,未发起)", it.Name))
continue
}
err := call(c, it)
cancel()
b.Spent(time.Since(start))
if err != nil {
if it.Hard {
return r, fmt.Errorf("%s 失败: %w", it.Name, err)
}
r.Degraded = append(r.Degraded, it.Name+"(超时)")
continue
}
r.Values[it.Name] = "ok"
}
return r, nil
}
func main() {
upstream := 350 * time.Millisecond
fmt.Println("=== v1:写死超时,发起之后被掐断 ===")
atomic.StoreInt64(&wastedByDownstream, 0)
func() {
ctx, cancel := context.WithTimeout(context.Background(), upstream)
defer cancel()
start := time.Now()
r, err := handleV1(ctx)
fmt.Printf(" 耗时 %v | 成功 %v | 降级 %v\n 错误 %v\n 下游为无效请求浪费 %d ms\n",
time.Since(start).Round(time.Millisecond), keys(r.Values), r.Degraded, err,
atomic.LoadInt64(&wastedByDownstream))
}()
fmt.Println("\n=== v2:预算申领,没预算就不发起 ===")
atomic.StoreInt64(&wastedByDownstream, 0)
func() {
ctx, cancel := context.WithTimeout(context.Background(), upstream)
defer cancel()
start := time.Now()
r, err := handleV2(ctx)
fmt.Printf(" 耗时 %v | 成功 %v | 降级 %v\n 错误 %v\n 下游为无效请求浪费 %d ms\n",
time.Since(start).Round(time.Millisecond), keys(r.Values), r.Degraded, err,
atomic.LoadInt64(&wastedByDownstream))
}()
}
func keys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}跑起来:
=== v1:写死超时,发起之后被掐断 ===
耗时 350ms | 成功 [库存 价格 风控] | 降级 [优惠券(超时)]
错误 <nil>
下游为无效请求浪费 50 ms
=== v2:预算申领,没预算就不发起 ===
耗时 300ms | 成功 [库存 价格 风控] | 降级 [优惠券(预算不足,未发起) 推荐(预算不足,未发起)]
错误 <nil>
下游为无效请求浪费 0 ms
两个版本返回给用户的东西几乎一样:硬依赖全成功,软依赖降级。
差别在两处,都在看不见的地方:
一是耗时。v1 用满了 350 毫秒,一毫秒不剩——它是在被上游掐断的那一瞬间结束的。v2 用 300 毫秒,留了 50 毫秒。这 50 毫秒在网络抖动的时候,就是”成功”和”超时”的差别。
二是给别人造成的成本。v1 让优惠券服务为一个不可能成功的请求工作了 50 毫秒。一次 50 毫秒不算什么,但大促期间你的 QPS 是三千,那就是每秒 150 秒的无效计算,落在优惠券那边。而优惠券服务的 owner 会看到自己的 CPU 涨了,然后去扩容,然后他的老板问他为什么成本涨了。
你的边界感,是别人的成本。
这个 MinUseful 字段,是这段代码里最值钱的一行。它强迫你在写调用之前就回答一个问题:如果只能给我这么点时间,这个调用还有意义吗?
大部分人从来不问这个问题,因为他们默认”发起了再说,超时了再说”。
说完时间上的边界,再说职责上的边界。
九月末,阿 May 提了个需求:某个大客户做活动,希望在支付回调进来的时候,校验一下这个订单是否符合活动规则,不符合就打标记。
小林第一反应是想拒绝。因为支付回调是他负责的模块,往里面塞业务规则,等于把自己的核心链路开放给了业务方。今天是这个活动,下个月是那个活动,一年之后 handleCallback 会变成三百行 if-else。
他跟老陈说了这件事。
“你会怎么回她?“老陈问。
“我想说’这个不该放在回调里,你放在下游的订单服务里’。”
“她会怎么回你?”
“她会说’订单服务那边排期排到十一月了,活动是十月八号’。”
“然后呢?”
“然后……”小林停住了。
“你会发现自己没话说。“老陈说,“因为你说的是一句正确的话,但它是从你的角度正确的,不是从问题的角度。”
“那我应该怎么说?”
“你可以说’不这样做’,“老陈说,“但你不能说’不归我’。”
小林愣了一下。
“这两句有什么区别?”
“大区别。“老陈说,“‘不归我’是在划地盘,对方听到的是’我不想管’。‘不这样做’是在改方案,对方听到的是’我愿意解决,但我想换个做法’。”
“具体怎么做?”
“你给她一个扩展点。”
package main
import (
"context"
"fmt"
"sync"
)
type Callback struct {
OrderID string
MerchantID int64
Amount int64
}
// Validator 是给业务方的扩展点。
// CanHandle 是"说不"的正确姿势:不是拒绝这个需求,
// 是把"谁的规则适用"这个判断,交还给规则的所有者。
type Validator interface {
Name() string
CanHandle(ctx context.Context, cb Callback) bool
Validate(ctx context.Context, cb Callback) error
}
type Registry struct {
mu sync.RWMutex
list []Validator
}
func (r *Registry) Register(v Validator) {
r.mu.Lock()
defer r.mu.Unlock()
r.list = append(r.list, v)
}
func (r *Registry) Run(ctx context.Context, cb Callback) error {
// 关键:先把列表拷贝出来,再在锁外面跑。
// 绝不能在持有锁的时候调用外部代码——
// 外部代码可能慢(你的锁会变成全局瓶颈)、
// 可能 panic(你的锁永远不释放)、
// 可能反过来调 Register(死锁)。
// 锁保护的是你的数据结构,不是别人的业务逻辑。
r.mu.RLock()
list := append([]Validator(nil), r.list...)
r.mu.RUnlock()
for _, v := range list {
if !v.CanHandle(ctx, cb) {
continue
}
if err := v.Validate(ctx, cb); err != nil {
return fmt.Errorf("校验器 %s 拒绝: %w", v.Name(), err)
}
}
return nil
}
// 活动规则由活动那边自己实现、自己注册、自己维护。
// 小林这一侧的核心链路一行没改。
type campaignValidator struct {
merchantID int64
minAmount int64
}
func (v *campaignValidator) Name() string { return "campaign-2026-autumn" }
func (v *campaignValidator) CanHandle(_ context.Context, cb Callback) bool {
return cb.MerchantID == v.merchantID
}
func (v *campaignValidator) Validate(_ context.Context, cb Callback) error {
if cb.Amount < v.minAmount {
return fmt.Errorf("订单金额 %d 低于活动门槛 %d", cb.Amount, v.minAmount)
}
return nil
}
func main() {
reg := &Registry{}
reg.Register(&campaignValidator{merchantID: 8801, minAmount: 9900})
cases := []Callback{
{OrderID: "o-1", MerchantID: 8801, Amount: 19900}, // 命中活动,金额够
{OrderID: "o-2", MerchantID: 8801, Amount: 4900}, // 命中活动,金额不够
{OrderID: "o-3", MerchantID: 9902, Amount: 4900}, // 不命中活动
}
for _, c := range cases {
err := reg.Run(context.Background(), c)
fmt.Printf(" %s merchant=%d amount=%d -> %v\n", c.OrderID, c.MerchantID, c.Amount, err)
}
}这段代码里,小林的核心链路一行都没改。他做的只是:定义一个扩展点,让业务方自己注册规则。
这个动作的效果是:
阿 May 拿到她要的东西(十月八号能上线)。小林守住了他的模块(handleCallback 里没有一个 if 是业务规则)。而且——这是最关键的——活动规则的生命周期,跟支付回调解耦了。活动结束之后,业务方自己把那个 Validator 删掉就行,不需要小林改代码、不需要发布支付回调服务。
这就是”说不”的最高级形式:把”我来做”变成”你可以做”。
最后,小林跟老陈说了一件事。
“上周小周问我一个问题。”
“小周是谁?”
“我们组新来的应届生,我带着他。”
“他问什么?”
“他问:‘师兄,支付回调这块是谁负责的?’”
“你怎么答的?”
“我说’我’。“小林说,“我脱口而出的,没有犹豫。”
”……”
“说完我自己愣了一下。“他说,“因为三个月前,一模一样的问题,我答的是’王磊’。”
老陈没说话。
“我还想明白一件事。“小林说,“王磊走之前,在楼梯间跟我说过’支付这块以后就靠你了’。我当时笑着接了,说了句’放心’。我那时候以为那是一句客气话。”
“现在呢?”
“现在我才知道,“小林说,“那是一句交付。他交了,我没接。“
老陈的深层发问
-
你手上有多少件事,是”你在做,但你觉得不该你做”的?这些事里,有几件是你从一开始就用了错误的方式接下的?
-
你上一次对需求说”不”的时候,你说的是”这不该我管”,还是”我们可以换个做法”?
-
你的模块对外部提供了多少个扩展点?如果业务方想在你的链路上加一个逻辑,他们的选项是”求你改代码”还是”自己注册一个”?
-
如果有人明天来接手你的模块,他是接到了一堆代码,还是接到了一块有边界、有契约、有 Owner 的地?
生活思考题
思考题一:你妈有你家的钥匙,经常不打招呼就过来,还顺手帮你收拾房间,把你东西挪了地方。你会怎么处理?
参考答案
第一层:大多数人会怎么答 “换锁。或者跟她谈一次,说清楚’进门之前要先打电话’。这是边界问题,必须划清楚。”
第二层:这个答案为什么不够 换锁这件事,技术上零成本,但你不会换。为什么?因为你知道你妈拿钥匙不是”闯入”,是”照顾”,而且你知道万一哪天你出了什么事,第一个发现的人会是她。
所以这个问题真正的难点不是”怎么划边界”,是**“你想要的是边界,还是你想让你妈觉得被需要”**。这两件事是冲突的,而且你两边都想要。
大部分人的真实处理方式,是第三种的:既不换锁,也不谈,而是每次她收拾完之后,自己悄悄把东西挪回去。然后心里有点烦,但不说,攒着,攒到某一天因为一件小事爆发,说一句很重的话,然后后悔。
这个模式在职场上太常见了:别人越界了 → 你不说 → 你私下修正 → 你攒情绪 → 某天爆发 → 关系受损 → 你后悔 → 下次还是不说。
第三层:更深的一层是什么 真正有效的解法,是把”不能做”翻译成”可以做”。
你跟你妈说”不要不打招呼就来”,她听到的是”你不欢迎我”。你跟她说”妈,你下次来之前给我发个微信,我好提前把客厅收拾一下,不然你又要帮我干活”——她听到的是”你来的时候我在乎你的感受”。
两句话的目标完全一样(提前通知),但第二句给了她一个正向的身份:不是”被限制的人”,是”被在乎的人”。
翻译到工程上:你说”这个业务规则不该写在支付回调里”,业务方听到的是”你不配合”。你说”我在回调里留了一个校验器注册点,你把规则实现好注册进来,你自己控制上下线,不用等我发版”——业务方听到的是”我在帮你争取自主权”。
“说不”的难点从来不在那个”不”字,在于你有没有同时给出一个”是”。
一个只有”不”的拒绝,一定会引发对抗。一个带着替代方案的”不”,大部分时候会被接受,而且对方会记住你。
第四层:落到 Go 工程师身上 建立一个个人规则:任何一次拒绝,都必须附带一个替代方案。 没有替代方案的时候,你不说”不”,你说”我明天给你答复”。
具体到你负责的模块,做一件事:列出你最常被要求”加个 if”的三类场景,为每一类设计一个扩展点。
常见的三类是:业务规则校验(用 Validator 接口)、后置动作(用 Hook / 事件订阅)、字段定制(用装饰器或者模板)。
这三个扩展点加起来大概两百行代码,一次性的。做完之后,你未来一年会少接三十个”帮我加个判断”的需求。
而且有个副作用:当你的模块有了清晰的扩展点,别的团队会开始主动来找你对接,而不是绕过你。这就是所有权之争里,那种不用争的权力。
思考题二:一个关系不错的朋友找你借两万块。你手上有这笔钱,但你不太想借。你怎么处理?
参考答案
第一层:大多数人会怎么答 “找借口。说最近刚交了保险、刚还了信用卡、钱在理财里取不出来。虽然知道他在撒谎,但至少不伤面子。”
第二层:这个答案为什么不够 找借口有三个问题,一个比一个严重。
第一,借口会被拆穿。你们有共同朋友,你上周刚发了旅游的朋友圈。他不会当面说,但他会知道。然后这段关系多了一根刺。
第二,借口没有解决他的困难。他真的需要两万块,你给了他一个”没有”,他得去找别人。你既没帮上忙,也没说实话。
第三,也是最严重的:借口把一次”边界谈判”变成了”信任测试”,而且你没通过。 你用谎言维护的关系,成本是你自己承担的心理负担,收益是零。
第三层:更深的一层是什么 正确的处理顺序,跟医生看病一样,是先问,再决定。
第一步,问用途。不是为了审查他,是为了重新定义问题。他要两万块,可能是在凑一个首付的定金(三天内必须到),可能是信用卡要逾期(今天必须还),可能是想做个小生意(不急)。这三种情况的”正确答案”完全不同。
第二步,区分”他要的方案”和”他要的目标”。他要的是两万块现金,他目标是解决某个具体问题。这两者之间通常有很多条路。
第三步,给出一个你能接受的部分。“两万我拿不出来,五千我可以给你,不用还”——这句话的分量,往往比借两万更重。因为它传递的是”我愿意为你承担损失”,而不是”我愿意承担一笔可能收不回来的债权”。
这套动作的核心,是在拒绝方案的同时,接住问题。
这就是”你可以说不这样做,但你不能说不归我”的完整含义。问题接住了,关系保住了;方案改了,边界守住了。
第四层:落到 Go 工程师身上 把这个方法用在跨团队协作上。下次别的团队来提需求,用这个顺序:
一、先问”你要解决的是什么问题”,不要问”你要我做什么”。这两个问题的答案通常不一样,而且前者往往有更简单的解法。 二、复述一遍他的目标,确认你理解对了。这一步会让对方觉得被尊重,而且经常会在这一步发现他自己的需求描述有问题。 三、给出替代方案时,一定要说清楚代价。“方案 A 我十月八号能给你,但是要写死在回调里,活动结束之后要发一次版删掉;方案 B 你要等到十月十五号,但是我给你留一个注册点,以后你们自己控制。” 四、让他选。让他选这件事本身,就是把所有权还给他。
我见过太多工程师,在跨团队协作里要么全接(然后抱怨),要么全拒(然后被投诉)。这两种都是懒。真正的做法是花二十分钟,把”接还是不接”这个二选一,变成”A 方案还是 B 方案”的二选一。
本章小结:核心心法
让一块地真正属于我
转变:从「它跑起来了」到「出问题找我」
陷阱:只守边界,看不见系统
这一章开头,老陈问了小林一个问题:你们组负责的支付回调模块,上周 P99 抖了三次,你知道为什么吗?
小林答不上来。他当时说了三句话:“那个不是我的模块""我负责的是里面的一小段""谁负责?王磊吧,他转岗了”。
这三句话里,藏着 P5 的全部自我认知:我的责任边界,等于我的代码边界。
而 P6 的全部转变,可以压缩成一句话:责任边界不等于代码边界,责任边界等于”找得到谁”。
这一章讲了三件事。
第一件:ownership 不是所有权,是”找不到别人了”。
所有权的核心是排他,而你对一个服务没有排他权——你不能阻止别人改它的代码。你能做的只有一件事:在它出事的时候,你是那个终点。
这个定义听起来很被动,甚至有点不公平。但它是陈述句,不是祈使句。你在承认它之前,所有关于”应该有人交接""应该有文档”的讨论都是正确的废话:管理问题归管理问题,它今晚十一点一样会报警。
而承认它之后,你才拿到两样东西:信息差带来的不可替代性,以及”把巴士系数从一变成二”这件事的入场券——后者是 P7 的活。
第二件:owner 关心的是边界,不是内部。
这章里的六个技术主题,表面上看毫无关联:pprof、context、worker pool、逃逸分析、goroutine 泄漏、超时预算。但它们其实是同一个东西的六个切面。
函数内部写错了,编译器、单测、代码评审都能兜住。真正会出事的只有边界:上游给你什么形状的输入、下游什么时候抖、上游还剩多少时间、并发度打到了谁身上、对象逃没逃逸、goroutine 有没有退出条件。
那七个测试用例里,v1 版本的支付回调有五种输入返回了 200。从监控上看它成功率 86%,非常健康。这五个坑,没有一个会出现在小林的测试用例里,因为它们全部藏在边界上。
所以 P6 的技术清单,本质上是同一句话的六种说法:把隐式的边界,变成显式的、看得见的、可测量的东西。
- pprof 是把”猜”变成”看”
- context 是把”调用关系”变成”有预算的链路”
- worker pool 是把”并发度”变成”有闸门的管道”
- 逃逸分析是把”分配”变成”看得见的每一行”
- goroutine 巡检是把”泄漏”变成”一条会响的告警”
- 预算申领是把”超时”变成”发起之前的决定”
第三件:边界感有两层,缺哪一层都不行。
第一层是知道什么事不该你管,第二层是知道什么事你推不掉。大部分人缺第二层。
而”说不”的正确姿势,不是拒绝问题,是改变方案。你可以说”不这样做”,但不能说”不归我”。给扩展点、给预算、给替代选项——任何一次拒绝,都必须附带一个替代方案。
那面发霉的墙,是小林这章里学会的最重要的一件事。
水管不是他家的,漏水不是他造成的,维修费用也不该他出。但墙是他家的。
他做了三件事:先止住自己这侧的损失(三百二十块的引流),再持续推动根因解决(每天一张照片),最后在越界和放弃之间找到了第三条路。
这三件事,就是”不是我写的代码出了事,但我扛下来了”的完整操作手册。
九月十六号那晚,他从被叫醒到恢复服务,用了三十六分钟。他做对了三件事:先问影响面、先查数据对不对、在没有找到根因的情况下重启。
他做错了一件:重启之前没有保存现场。
那天之后他补上了。他还加了一条 goroutine 数量的告警,上线第一个月响了两次,两次都是真的泄漏。
十月的那个下午,老陈又问了他一遍同样的问题。
“你知道为什么吗?”
“知道。“他说,“我去看的。”
最后,把这一章的心法,做成一张可以贴在显示器上的卡片:
「这不是我写的」这句话出口的瞬间,这块地就变成了无主之地。
owner 不问谁有罪,owner 只问一件事:现在这事,谁能让它停下来。
你的边界感,是别人的成本。
自检清单
下面是十二条。不要急着打勾,每一条都问自己一句”我能举出一个具体的例子吗”。举不出来的,不算。
□ 我能够说出我负责的每个模块的 P99、QPS、错误率的当前数值,以及它们一周前的数值,误差在 10% 以内。
□ 我能够在五分钟之内,说出我负责的模块最近一次线上影响的金额、笔数和商户数量——不需要现写 SQL,因为那条语句我已经提前写好并保存了。
□ 我能够列出我负责模块的全部外部依赖,并且说出每一个的超时时间、是否有降级方案、挂掉的时候我的服务是快速失败还是跟着拖死。
□ 我能够在一个从没接过手的模块出问题时,不先问”这是谁写的”,而是先问”影响面多大”和”数据对不对”。
□ 我能够在五分钟之内,对我负责的服务抓出 goroutine、heap、CPU 三份 profile,并且知道这三份分别该看哪个指标(alloc_objects 而不是 inuse_space,状态而不是数量)。
□ 我能够在我的服务里找出分配量最大的那个函数,并且说出它每次调用分配几个对象、每个对象为什么逃逸。
□ 我能够说出我代码里每一个 sync.Pool 的用途,并且回答三个问题:对象丢了会怎样、Get 出来后在哪 Reset、有没有大小上限。
□ 我能够说出我服务里每一个长期运行的 goroutine 的退出条件,并且知道没有任何一个是”进来就不出来”的。
□ 我能够指出我代码里所有 context.Background() 的位置,并且对每一处都说得出”为什么这里不能从上游接一个 ctx 下来”。
□ 我能够说出我服务里并发度最高的那个 fan-out 的并发上限,并且说得清这个数字是压测得来的、算出来的,还是抄来的。
□ 我的 CI 里跑着 go vet、go test -race 和 staticcheck,而且我不会在它们报红的时候绕过它们提交代码。
□ 我能够在拒绝一个需求的时候,同时给出一个替代方案——我说的是”不这样做”,而不是”不归我”。
十二条,如果你现在能诚实打勾的在五条以下,那你现在是 P5,而且你已经知道 P6 长什么样了。
如果在八条以上,你可以去做一件事:在下一次周会上,主动说一句”这个模块以后出问题找我,我先看看”。
注意时态。是现在时,不是”等我有时间”。