第五章 P8 负责篇:为结果兜底,不为方案辩护

心法:为结果兜底,不为方案辩护 转变:从「做对事」到「做对的事」 陷阱:什么都想亲自上手,放不下手


开篇:王总给了你一个数字,你给了他一份架构图

三月十号,周一上午十点,公司二楼那间叫”钱塘”的会议室。

这屋子的投影仪有个毛病,接上 HDMI 之后要等大概七八秒才亮,所以每次开会,第一个发言人总会有一段尴尬的沉默。那天是王总先开的口,他没用投影,直接把笔记本转过来,屏幕对着桌上五个人。

屏幕上是一张 Excel,就三行。

去年 618 履约超时率    8.9%
今年日常履约超时率     4.1%
今年 618 目标          3.0%

“履约超时”的定义写在表下面一行小字:用户下单到仓库出库,超过 48 小时。

“我说明一下这个数为什么重要。“王总把笔记本往前推了推,“去年 618 之后,客服那边统计过一次。一个超时订单,平均会产生 1.7 条客服工单,其中 0.4 条会升级。升级的那部分,平均处理成本是三十一块钱。再加上补偿券、二次配送、还有退款——一个超时订单的综合成本,我们算下来是十四块六。”

他用手指敲了敲表格。

“去年大促期间,超时订单一共十一万三千单。乘一下,一百六十五万。这还不算最难算的那部分——有多少人因为等太久,下次不来买了。”

阿 May 接了一句:“客服那边的口径是,超时订单里,退款率是正常订单的四点二倍。”

“对。“王总说,“所以这个 3.0%,不是我拍的。是运营、客服、财务坐在一起,对着去年的账算出来的。降到 3.0%,今年大促这一块能省下一百万出头。”

他环视了一圈。

“这个事,谁来做?”

桌上有五个人。阿 May 是产品,大刘是 SRE,Kevin 是架构师,小林,还有一个是刚调过来的数据分析师。

王总的目光落在小林身上。

“小林,你来牵头。”

小林愣了一下。他上周刚拿到第二次职级评审的结果——没过。评语他还压在键盘下面,那句”建议扩大 scope”他每天抬头都能看见。

“我?”

“你。“王总说,“优惠券那套你做得挺好,虽然过程有点绕。这次给你三个月,六一八之前上线。人你可以挑,组里三个,小周算一个。”

“那……目标是?”

“8.9 降到 3.0。“王总把笔记本合上了,“具体怎么做,你定。”

门关上的时候,阿 May 拍了下小林的肩膀:“加油啊,林工。”

小林点点头,脑子已经在转了。


那天晚上小林没回家,在公司楼下吃了碗面,八点回到工位,打开了 Draw.io。

他已经很久没有这种感觉了——一种纯粹的、从后脑勺往上升的兴奋。上一次有这种感觉,还是三年前他第一次用 Go 写出一个完整的 gRPC 服务的时候。

接下来三周,他干了很多活。

第一周,他画现状。履约链路一共有十一个环节:下单、支付回调、风控、订单拆分、库存预占、仓匹配、仓接单、拣货、打包、交接快递、出库回传。他画了一张横向的泳道图,每个环节标了耗时 P50 和 P99,标了负责的团队,标了是同步调用还是异步消息。这张图他改了七版。

第二周,他设计方案。核心是三件事:

第一,统一履约状态机。现在订单状态散落在四个服务里,每个服务都有自己的一套 status 字段, eleven 个环节里有一半的状态是靠定时任务扫出来的。他设计了一个中心化的 FSM,十一个状态、二十三条迁移边、每条边带超时和补偿动作。

第二,智能分仓调度。现在的仓匹配规则是硬编码的一棵决策树,两百多行 if-else,按”省市区 + SKU 库存 + 仓类型”三级匹配。他要换成一套带权重的调度算法,输入是仓的实时产能、SKU 分布、快递时效、历史履约时长,输出是仓的排序。

第三,全链路异步化。他把六个同步 RPC 改成了基于 MQ 的事件驱动,理由是”削峰填谷、故障隔离”。

第三周,他写了文档。五十八页,包括状态机迁移图、调度算法的目标函数、MQ 的 topic 划分和分区策略、灰度方案、回滚方案、压测计划。

他还特意在文档最后一页放了一张甘特图:四个人,两个月,最后一周留给压测。

三月二十四号周二下午两点,评审会。还是”钱塘”,还是那台要等七八秒的投影仪。

小林讲了四十分钟。讲到状态机的时候,Kevin 问了一句:“状态迁移的幂等怎么做?“他答上来了。讲到调度算法的时候,大刘问了一句:“仓的实时产能数据从哪来?仓储那边有接口吗?“他说”这个后面要去对接”。讲到异步化的时候,阿 May 问:“改成异步之后,用户看到的’预计发货时间’会不会不准?“他说”会有一点延迟,但影响很小”。

讲完,会议室安静了几秒。

王总一直没说话,他在笔记本上敲东西。这时候他抬起头。

“我问个问题。”

“您说。”

“这套东西全部上线以后,8.9 降到多少?”

小林张了张嘴。

“这个……得看上线之后的实际数据。”

“你现在能不能估?”

“状态机统一之后,状态不一致导致的卡单应该会少很多,但是具体能降多少……”他停了一下,“得看数据。”

“那仓匹配那块呢?”

“调度算法理论上能提升时效,但这个也要看——”

“看数据。“王总点点头,“好。那我再问一个:这套东西,多少人多少时间?”

“四个人,两个月。”

“八个人月。“王总在纸上算了一下,“三月十号到今天,十四天。你剩八十四天,六一八是六月十八号。八个人月,减掉你前期的设计,加上压测和灰度,大概刚好卡在六月初上线。”

“对。”

“也就是说,我要投八个人月,加两个月的窗口期,加一次大促级别的上线风险,换一个’上线之后看数据’。”

小林没说话。

“小林,“王总把笔放下,“我不是在否定你的方案。说实话这套设计,从技术上看是这几年我们履约这块最好的一次。”

“但是?”

“但是你没有告诉我,我花八个人月,能买到几个点。“王总说,“8.9 到 3.0,差 5.9 个点。你这八个人月,是买五个点,还是买零点五个点?”

”……”

“如果你告诉我,你这套东西能买三个点,剩下两点九我去找老赵要。那我们今天就能定。”

“如果你告诉我,你这八个人月只能买零点五个点,那我们今天就该讨论另外一件事——那五点四个点,到底在谁手里。”

他站起来,把笔记本收进包里。

“我不要方案。我要结果。3.0 这个数,你兜。”

门关上。

会议室里剩下四个人。大刘先开的口,他说:“其实王总这个问法,我听着挺有道理的。”

阿 May 说:“你别说了。”

小林坐在那儿,看着投影仪上那张十一环节的泳道图。屏幕上那七八秒的蓝底还没灭。

他坐了大概两个小时。期间手机震了三次,他一次没看。


晚上七点半,公司楼下的兰州拉面。

老陈已经到了,面前摆着一碟牛肉。小林坐下的时候,老陈看了他一眼。

“脸不太好。”

“嗯。”

面来了。小林吃了两口,把筷子放下,从王总那个 Excel 讲起,讲到十一个环节,讲到状态机,讲到二十三条迁移边,讲到调度算法的目标函数,讲到八个人月,讲到那句”我要结果”。

他讲得比三周前那次慢。中间有一次停下来,喝了口汤,又接着讲。

老陈一直没插话。等他说完,老陈问了一句:

“你先想想,你这一个月,做的是什么事?”

“做方案啊。”

“不。“老陈说,“你这一个月,做的是一个答案。”

“有什么区别?”

“你有没有想过,王总给你的是一道题,题目是’8.9 到 3.0’。你回去做了一个月的答案。到了交卷那天,你发现——”

老陈停了一下。

“——你从来没读过题。”

小林抬起头。

“我读了题啊。题目不就是履约超时率太高吗?”

“8.9 太高。“老陈说,“然后呢?”

“然后我分析了链路,找到了瓶颈。”

“你怎么找到的?”

“我画了泳道图,每个环节标了 P50 和 P99。”

“标完之后呢?哪个环节最慢?”

”……仓匹配。P99 是 1.8 秒。”

“1.8 秒。“老陈点点头,“所以你去优化 1.8 秒。”

“对。”

“小林,“老陈把筷子放下,“8.9% 的超时订单,超时的定义是什么?”

“下单到出库超过 48 小时。”

“四十八小时。“老陈说,“等于十七万两千八百秒。”

”……”

“你花了一个月,去优化一个一点八秒的环节。”

小林愣在那儿。

“我不是说 1.8 秒不重要。“老陈说,“我是说,你要先搞清楚,那四十八小时,是被谁吃掉的。”

“王总没给我数据。”

“你问过吗?”

”……”

“小林,我给你讲个事。“老陈给他夹了块牛肉,“去年你们公司年会,我认识的你们运维那边一个人,喝多了跟我说,他们每年大促最怕的不是系统挂,是仓库爆。他说大促第二天,华东那个仓,货堆到过道上,拣货的师傅要踩着箱子走。”

“这个我知道,仓储那边是有压力。”

“你知道,但你没把它算进去。“老陈说,“你知道和算进去,是两件事。”

“你的意思是,我应该先去拆那个 8.9%?”

“我应该问你:8.9% 这个数,你拆过吗?”

“没有。”

“那从明天开始,你别做方案了。”

“那我做什么?”

“去拆那个数。“


5.1 你的方案很优雅,但业务没涨——然后呢?

拆数字,不是做方案

第二天上午,小林去找了数据分析师。

分析师叫小郑,去年从一家电商公司跳过来,说话很快。小林说明来意之后,小郑看了他一眼。

“你要 618 的超时订单明细?”

“对,去年 618 的,全量。”

“十一万三千条?”

“对。”

“你要干嘛?”

“我想知道,这些订单为什么超时。”

小郑笑了:“这个我们有现成的口径啊,‘超时’就是超时了,还能为什么。”

“我的意思是——“小林想了想,“一个超时订单,它在四十八小时里,时间是怎么被花掉的?”

小郑停了两秒。

“这个……得看有没有埋点。”

结果是:没有。

履约链路有日志,但日志是”环节开始”和”环节结束”两种事件,打在不同的服务里,格式不统一,时间字段有的是秒有的是毫秒,还有两个服务用的是本地时区,另外,链路 ID 在第六个环节之后会断——因为中间过了一次 MQ,而 MQ 那一跳没有把 trace ID 传下去。

小林花了四天,写了八百行一次性脚本,把去年 618 那十一万三千单捞了出来,用订单号硬 join 了七个数据源,最后有 62% 的订单能完整还原链路。

第五天下午,他拿到了第一版拆解。

他把结果发给了老陈,然后打电话过去。

“拆出来了。”

“说。”

“去年 618,超时订单十一万三千二百单,占大盘 8.9%。能完整还原链路的六万九千八百单,我按’卡在哪个环节最久’归因——”

“等一下。“老陈打断,“‘卡在哪个环节最久’,这个归因口径是谁定的?”

“我定的。”

“那我问你,一个订单,在仓匹配等了两小时,在仓储等了五十小时。你归因给谁?”

”……仓储。”

“那如果你只优化仓匹配,这个订单还是超时。“老陈说,“你现在的归因口径,会让仓匹配那一块看起来很无辜。”

小林在那头沉默了几秒。

“你的意思是,我应该看’瓶颈’而不是’最久’?”

“我在问你:你到底想知道什么?”

“我想知道……如果我把某个环节优化掉,8.9 会变成多少。”

“那你该量的是什么?”

小林想了很久。

“该量的是——每个环节,有多少订单是被它单独卡住的。就是,如果不是因为它,这个订单不会超时。”

“对。“老陈说,“这两个口径差别大了。一个叫’耗时占比’,一个叫’归因占比’。前者告诉你时间花在哪了,后者告诉你钱花在哪了。”

“做技术的人一辈子都在看前者。”


第六天,小林换了口径重跑。

新口径的逻辑是:对每个超时订单,把链路上的等待环节按耗时排序,从最长的开始,逐个”假设消除”(把它的耗时置为 0),看订单是否还超时。一旦不超时了,就归因给最后被消除的那个环节。如果全部消除后仍然超时,归到”多因素”。

结果出来的时候,小林盯着屏幕看了很久。

去年 618 超时订单归因(样本 69812 / 总量 113200,覆盖率 61.7%)

  归因环节                     单量      占超时比    折算大盘
  ─────────────────────────────────────────────────────────
  1. 仓储拣货/打包排队          24180     34.6%      3.08pp
  2. 缺货等待补货              18740     26.8%      2.39pp
  3. 风控人工复审                9210     13.2%      1.17pp
  4. 地址异常/联系不上用户        5380      7.7%      0.69pp
  5. 系统卡单(状态机不一致)      2760      4.0%      0.35pp
  6. 仓匹配决策耗时              1450      2.1%      0.19pp
  7. 多因素叠加                  8092     11.6%      1.03pp
  ─────────────────────────────────────────────────────────
  合计                          69812    100.0%      8.90pp

他把自己的那五十八页文档翻出来,对着这张表看。

状态机统一,对应第 5 项,0.35 个百分点。

仓匹配调度算法,对应第 6 项,0.19 个百分点。

加上异步化可能带来的边际收益,满打满算,0.6 个百分点。

他要的是 5.9。

那天晚上,他在工位上坐到十一点半。中间小周过来说了句”林哥我先走了”,他嗯了一声。

十一点四十,他给老陈发了条微信:“拆完了。”

老陈秒回:“面馆?”

“面馆。“


老陈的故事:一张两千块的检查单

面还是那家。小林坐下,把手机推过去,给老陈看那张表。

老陈看了大概一分钟。

“你这一个月做的东西,值零点六个点。”

“零点六。“小林说,“我熬了两个通宵写的状态机。”

“我知道。”

“二十三条迁移边,我一条一条对过。”

“我知道。”

“老陈,我不是想抱怨。“小林说,“我是真的想不明白。我做的这个东西,技术上没有错啊。状态散在四个服务里,靠定时任务扫,这本来就是错的。仓匹配两百多行 if-else,加一个仓就要改一次代码,这本来就是错的。我把错的改对了,为什么它不值钱?”

老陈没回答。他在吃面。

吃了几口,他放下碗。

“我给你讲个我自己身上的事。”

“三年前,我体检,报告上有一项指标偏高,具体是什么我就不说了,反正是个跟肝有关的酶。正常值上限四十,我六十二。”

“我吓了一跳,挂了个专家号。专家是个五十多岁的女大夫,看了报告,又开了四个检查:彩超、CT、还有一个什么弹性成像,加上复查的血。一共两千一百多块,约检查约了三天,做检查跑了两趟医院。”

“最后结果出来了:轻度脂肪肝。”

“她说:少吃点,多运动,半年后复查。”

老陈笑了笑。

“我那天从医院出来,在停车场坐了十分钟。我算了一下:两千一百块,三天假,两趟医院,买回来一句话——少吃点多运动。”

“这话我妈十年前就跟我说过。”

小林没笑。他知道老陈还有下文。

“第二年我又去体检,还是那家医院,还是那一项,这回是五十八。我换了个大夫,是个三十出头的男医生。”

“他看了报告,抬头问我第一个问题是:‘你这个指标,去年是多少?’”

“我说六十二。”

“他说:‘哦,降了。那去年到今年,你生活上有什么变化?’”

“我说我去年下半年开始每周游泳两次。”

“他又问了三个问题:你喝酒吗,一周几次,最近一次喝酒是什么时候。”

“我说我一周喝一两次,上次是体检前四天,喝了大概三两白酒。”

“他笑了一下,说:那这个数你不用管。你体检前一周别喝酒,明年再来。”

“就这样?”

“就这样。挂号费二十五块。”

老陈把碗端起来喝了口汤。

“小林,这两个大夫,区别在哪儿?”

“第一个是过度检查,第二个是有经验。”

“不是。“老陈说,“第一个大夫问的是’你的指标是多少’,第二个大夫问的是’你这个数是怎么变成这样的’。”

“第一个在量状态,第二个在量变化。”

“你要判断一个东西是不是问题,光看它现在的数值没用。你得知道它是怎么变成这个数的。六十二和五十八,单看都是’偏高’,但一个是往上的,一个是往下的,那是完全两件事。”

“所以你那个 8.9,“老陈说,“它本身没有意义。有意义的是:日常是 4.1,大促是 8.9。中间那四点八个点,是大促这个变量加上去的。”

“而这四点八里面,有多少是仓库爆仓,有多少是缺货,有多少是你们系统的问题——这个才是题。”

小林低头看着那张表。

“我明白了。“他说,“我这一个月,是在给一个我根本没看清的东西做手术。”

“不是没看清。“老陈说,“是你根本没看。”

”……”

“你从三月十号拿到题目,到三月二十四号交卷,中间十四天。这十四天里,你去找过老赵吗?”

“没有。”

“去找过供应链的人吗?”

“没有。”

“去找过风控吗?”

”……没有。”

“你找了谁?”

小林想了想。

“找了 Kevin 两次,问状态机的幂等。找了大刘一次,问 MQ 的分区。”

“你找的都是技术的人。“老陈说,“你问的都是’怎么做’的问题。”

“你知道这叫什么吗?”

“叫什么?”

“在自己会的赛道上跑步。”

小林没说话。

“你现在最想干的事是什么?“老陈问。

”……我想回去改状态机。”

“为什么?”

“因为那个我会。”

老陈点点头。

“嗯。这事跟你儿子考试一个道理——不对,你还没儿子。“老陈改了口,“这事跟考试一个道理:卷子发下来,你先挑会做的题做。做完了,心里踏实,觉得今天没白来。至于不会的那几道,你告诉自己’我回头再看’。”

“但实际上,分数在那几道上。“

Go 实例:你的错误类型,决定了你能拆多少个点

那张归因表,小林是用八百行一次性脚本跑出来的。脚本跑完他就删了——因为那是”考古”,不是”度量”。

真正重要的问题是:下一次大促,我能不能在第二天早上就拿到这张表?

答案不在数据团队那儿,在他的代码里。因为这张表能不能自动出来,完全取决于一件事:他的系统里,一次失败有没有携带”归因信息”。

来看这段演化。

第一版:绝大多数团队的写法

package main
 
import "errors"
 
// 履约失败就是一个失败,没有别的
var ErrFulfillFailed = errors.New("履约失败")
 
func fulfillV1(orderID string) error {
	if !riskPass(orderID) {
		return ErrFulfillFailed
	}
	if !stockEnough(orderID) {
		return ErrFulfillFailed
	}
	if !warehouseAccept(orderID) {
		return ErrFulfillFailed
	}
	return nil
}
 
func main() {
	var failed int
	for i := 0; i < 1000; i++ {
		if err := fulfillV1(fmt.Sprintf("SO%06d", i)); err != nil {
			failed++
		}
	}
	fmt.Printf("失败率 %.1f%%\n", float64(failed)/10)
}
 
func riskPass(id string) bool      { return id[len(id)-1] != '7' }
func stockEnough(id string) bool   { return id[len(id)-1] != '3' }
func warehouseAccept(id string) bool { return id[len(id)-1] != '1' && id[len(id)-1] != '9' }

跑出来是”失败率 40.0%”。

然后呢?没有然后了。你想知道这 40% 里风控占多少、库存占多少、仓储占多少,你只能去翻日志,用 grep 去猜。而日志里的东西,取决于当时写代码的人心情好不好。

问题在哪儿?

问题不在”没打日志”。很多团队会补一句 log.Errorf("order %s failed", orderID),然后接着写 return ErrFulfillFailed。

那还是一样的。日志是给人看的字符串,字符串不能被聚合、不能被计算、不能在第二天早上自动变成一张饼图。

真正的问题是:你把一次失败,压缩成了一个比特。

一个 bool 里塞不下一个归因。一个 errors.New("履约失败") 里也塞不下。

第二版:让错误携带归因

package main
 
import (
	"errors"
	"fmt"
	"sort"
	"sync"
	"time"
)
 
// Stage 是链路上的环节,回答"卡在哪"
type Stage string
 
const (
	StageRisk      Stage = "风控"
	StageStock     Stage = "库存"
	StageWarehouse Stage = "仓储"
	StageSystem    Stage = "系统"
)
 
// Blocker 是归因标签,回答"卡在谁身上"。
// 注意这个粒度的选择:它必须能对应到一个团队的 KPI,
// 否则你拆出来也没人能认领。
type Blocker string
 
const (
	BlockerRiskManual  Blocker = "风控人工复审"
	BlockerOutOfStock  Blocker = "缺货等待补货"
	BlockerWHQueue     Blocker = "仓储排队"
	BlockerStateStuck  Blocker = "状态机卡单"
	BlockerMultiFactor Blocker = "多因素叠加"
)
 
// FulfillError 是"可被聚合的失败"。
// 它不是一个字符串,它是一条数据。
type FulfillError struct {
	OrderID   string
	Stage     Stage
	Blocker   Blocker
	Waited    time.Duration // 在这个环节等了多久
	Retryable bool          // 能不能自动重试,还是必须人/别的部门介入
	Err       error         // 底层原因,用 %w 保留链
}
 
func (e *FulfillError) Error() string {
	return fmt.Sprintf("订单 %s 卡在[%s/%s],已等待 %v", e.OrderID, e.Stage, e.Blocker, e.Waited)
}
 
func (e *FulfillError) Unwrap() error { return e.Err }
 
// fail 是唯一的失败出口。强制调用方说明"卡在谁身上"。
func fail(orderID string, stage Stage, blocker Blocker, waited time.Duration, retryable bool, cause error) error {
	return &FulfillError{
		OrderID:   orderID,
		Stage:     stage,
		Blocker:   blocker,
		Waited:    waited,
		Retryable: retryable,
		Err:       cause,
	}
}

现在调用方长这样:

// Hop 是链路上一次等待。真实系统里它由每个环节的计时器产出。
type Hop struct {
	Stage   Stage
	Blocker Blocker
	Dur     time.Duration
}
 
// fulfillV2 不再"遇到错就返回",而是把整条链路的等待采集下来。
// 因为一个订单超时,几乎从来不是某一个环节单独造成的,
// 你必须拿到完整的耗时分布,才能做归因。
func fulfillV2(orderID string) (hops []Hop, err error) {
	if d := riskWait(orderID); d > 0 {
		hops = append(hops, Hop{StageRisk, BlockerRiskManual, d})
	}
	if d := stockWait(orderID); d > 0 {
		hops = append(hops, Hop{StageStock, BlockerOutOfStock, d})
	}
	if d := warehouseWait(orderID); d > 0 {
		hops = append(hops, Hop{StageWarehouse, BlockerWHQueue, d})
	}
	if stuck(orderID) {
		hops = append(hops, Hop{StageSystem, BlockerStateStuck, 6 * time.Hour})
	}
	return hops, nil
}

关键在下面这个归因函数——它实现的正是小林第六天重跑时用的那个口径:

// attribute 回答一个问题:如果只能消除一个环节,消除哪个能让这个订单不超时?
//
// 做法:按耗时从长到短逐个"假设消除",一旦总耗时降到 SLA 以内,
// 就把这个订单归因给最后被消除的那个环节。
// 如果全部消除仍然超时,归到"多因素叠加"。
//
// 这个口径和"耗时占比"完全不同:
// 一个订单在仓匹配等 2 小时、在仓储等 50 小时,
// 按耗时占比,仓匹配占 3.8%,看起来无关紧要;
// 但按归因口径,消除仓匹配的 2 小时后它仍然超时,
// 所以仓匹配对这个订单的超时"不负责任"——
// 你优化它,一分钱也拿不回来。
func attribute(hops []Hop, sla time.Duration) (Blocker, bool) {
	var total time.Duration
	for _, h := range hops {
		total += h.Dur
	}
	if total <= sla {
		return "", false // 没超时
	}
 
	sorted := make([]Hop, len(hops))
	copy(sorted, hops)
	sort.Slice(sorted, func(i, j int) bool { return sorted[i].Dur > sorted[j].Dur })
 
	remaining := total
	for _, h := range sorted {
		remaining -= h.Dur
		if remaining <= sla {
			return h.Blocker, true
		}
	}
	return BlockerMultiFactor, true
}

最后把它接到聚合器上,让这张表每天自己长出来:

// Attributor 是一个常驻进程里的聚合器。
// 它的产出不是日志,是一张每天自动更新的表。
type Attributor struct {
	mu     sync.Mutex
	total  int
	counts map[Blocker]int
}
 
func NewAttributor() *Attributor {
	return &Attributor{counts: make(map[Blocker]int)}
}
 
func (a *Attributor) Observe(hops []Hop, sla time.Duration) {
	a.mu.Lock()
	defer a.mu.Unlock()
	a.total++
	if b, ok := attribute(hops, sla); ok {
		a.counts[b]++
	}
}
 
type Bucket struct {
	Blocker Blocker
	Count   int
	Share   float64 // 占超时订单的比例
	Points  float64 // 折算成大盘的百分点
}
 
func (a *Attributor) Report(totalOrders int) []Bucket {
	a.mu.Lock()
	defer a.mu.Unlock()
	out := make([]Bucket, 0, len(a.counts))
	for b, c := range a.counts {
		out = append(out, Bucket{
			Blocker: b,
			Count:   c,
			Share:   float64(c) / float64(a.total) * 100,
			Points:  float64(c) / float64(totalOrders) * 100,
		})
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count })
	return out
}

跑起来:

func main() {
	const sla = 48 * time.Hour
	a := NewAttributor()
	const n = 10000
 
	for i := 0; i < n; i++ {
		hops, _ := fulfillV2(fmt.Sprintf("SO%06d", i))
		a.Observe(hops, sla)
	}
 
	fmt.Printf("总订单 %d,大盘超时率 %.2f%%\n", n, float64(a.total)/float64(n)*100)
	for _, b := range a.Report(n) {
		fmt.Printf("  %-14s %6d 单  占超时 %5.1f%%  折算大盘 %.2fpp\n",
			b.Blocker, b.Count, b.Share, b.Points)
	}
}
 
// 下面这几个是模拟数据,真实系统里换成对下游的调用
func riskWait(id string) time.Duration {
	if id[len(id)-1] == '7' {
		return 14 * time.Hour
	}
	return 0
}
func stockWait(id string) time.Duration {
	if id[len(id)-1] == '3' {
		return 60 * time.Hour
	}
	return 0
}
func warehouseWait(id string) time.Duration {
	switch id[len(id)-1] {
	case '1', '9':
		return 52 * time.Hour
	default:
		return 4 * time.Hour // 正常履约也要等仓
	}
}
func stuck(id string) bool { return id[len(id)-1] == '5' }

输出(数字随模拟分布变化):

总订单 10000,大盘超时率 30.00%
  缺货等待补货     1000 单  占超时 33.3%  折算大盘 10.00pp
  仓储排队         1000 单  占超时 33.3%  折算大盘 10.00pp
  状态机卡单        582 单  占超时 19.4%  折算大盘  5.82pp
  风控人工复审      418 单  占超时 13.9%  折算大盘  4.18pp

这段代码里,真正值钱的不是语法,是三个决定。

第一个决定:把 Blocker 的粒度停在”谁的 KPI”上。

我见过很多人做归因,会把标签打得特别细:warehouse_queue_east_china_bin_overflow、stock_wait_purchase_order_delay。

打完发现没人认领。因为”华东仓 Bin 溢出”不是任何一个团队的 KPI,它是一个现象。你真正需要的是”归属于仓储团队”这个粒度,然后让仓储团队自己去拆。

归因的终点不是”找到原因”,是”找到能改它的人”。

第二个决定:归因口径写成了函数,而不是写在文档里。

小林第一次跑出来的那张表是错的,因为他的口径是”耗时占比”。这个错误他自己发现不了——它看起来很合理。

但一旦你把它写成一个函数,它就有了一个可以被质疑的形状。Kevin 可以 review 它,小郑可以挑战它,三个月后有人可以推翻它。

写在文档里的口径会腐烂,写在代码里的口径会被质疑。

第三个决定:让这张表每天自己长出来,而不是出事后再考古。

小林花了四天写脚本、五天捞数据,最后只有 61.7% 的订单能还原。因为他是在”考古”。

考古的成本,是你在为过去买单;度量的成本,是你在为未来投资。而这两者的代码量,其实差不了多少。

顺带说一句 Go 本身的事:这里用的 Unwrap() 加 %w,是 Go 1.13 之后给你的归因基础设施。很多人以为 errors.Is / errors.As 只是”判断错误类型”的语法糖,其实不是。

// 有了 Unwrap,归因可以从任意深度捞出来
var fe *FulfillError
if errors.As(err, &fe) {
	a.Record(fe.Blocker) // 不管包了多少层 fmt.Errorf,归因信息不丢
}

它是 Go 在问你一个问题:你的错误,是给人类读的一句话,还是给机器读的一条数据?

如果你回答”一句话”,那你的系统永远只能告诉你”失败了 8.9%“,然后你得自己去猜那 8.9% 是什么。


老陈的追问

那天晚上,面吃到一半,老陈问了小林四个问题。他一个都没给答案。

一、你手上现在正在做的那个项目,它要改变的那个数字是多少?这个数字,是从别人给你的任务里抄下来的,还是你自己算过一遍?

如果你算过,你能说出它去年是多少、今年日常是多少、峰值是多少吗?如果这三样你说不全,你拿到的不是一个目标,是一句口号。

二、你们团队去年的全部技术产出里,有多少是可以直接翻译成一个业务数字的?剩下的那些,你敢不敢在年终总结里把它们删掉?

注意,我问的不是”有没有价值”。稳定性、可维护性当然有价值。我问的是:你能不能说出来,那个价值值多少钱。说不出来的部分,在别人的预算表上,就是零。

三、如果你的方案全部上线,业务指标一动不动,你会怎么解释?

你想好那句话了吗?“架构更合理了”、“可维护性提升了”、“为未来打好了基础”——这三句话,你自己说出口的时候信不信?

如果你不信,那说明你心里知道,你在用过程代替结果。

四、过去一年,你有没有主动砍掉过一个你已经做了很久的项目?

不是被别人砍的,是你自己砍的。如果你一个都举不出来,那你要想一件事:你砍不下去,是因为它真的有价值,还是因为你已经为它投入太多了?


生活思考题

题一:你家的水电费,为什么总是降不下来?

你上个月收到水电费账单,比去年同期高了 40%。你想降下来。

你的第一反应是什么?大多数人会说:以后随手关灯、洗澡快一点、空调温度调高一度。

请停下来想一想:这个反应,和你拿到一个技术指标之后的反应,是不是一模一样?

参考答案

第一层:大多数人会怎么答

多数人会从”我能控制的行为”入手:关灯、缩短洗澡时间、空调从 24 度调到 26 度、拔掉不用的插头。这些都没错,做总比不做好。

第二层:这个答案为什么不够

因为它假设了”我知道钱花在哪儿”。

而实际上,绝大多数人根本不知道。你去问一个家庭:“你们家上个月的电,空调占多少、冰箱占多少、热水器占多少、待机占多少?“十个人里九个答不上来。

于是就出现了一个荒谬的局面:你在用”随手关灯”这种每次省 0.0002 度的行为,去对抗一个每月多花 80 度的账单。

更要命的是,那 40% 的增长,很可能根本不是用量增加造成的——可能是你家热水器换了恒温模式,可能是冰箱门封条老化了,可能是去年冬天比今年冷,也可能纯粹是电价阶梯跳档了。

你连涨在哪儿都不知道,你怎么降?

第三层:更深的一层是什么

真正有效的做法是拿一个电力监测插座(几十块钱那种),把它插在你怀疑的几台电器上,测一周。

我有个朋友真干过。他家夏天电费涨了 60%,他一直以为是空调。测完发现:空调用量同比只涨了 8%,真正的大头是他去年买的那台”一级能效”的储水式热水器——它的保温模式是默认常开的,一年三百六十五天、一天二十四小时,只要水温掉两度就自动加热。

那台热水器,每天什么都不干,耗 2.7 度电。一个月 81 度。

他花了 15 分钟,把保温模式关了,改成用之前半小时手动开。

电费第二个月就回来了。

这个故事的真正含义不是”热水器费电”。是:那个让数字变坏的东西,几乎从来不在你以为的地方。

它之所以不在你以为的地方,恰恰是因为——你以为的地方,是你熟悉的地方。

空调你熟悉,你知道它费电,你天天看见它。热水器在阳台角落,你一年看不了两眼。

第四层:落到 Go 工程师身上

下次拿到一个技术指标(P99 太高、错误率太高、成本太高),在动手之前,先做一件事:

把它拆开,拆到”每一份都属于某一个具体的东西”为止。

具体到 Go 工程师,是三个动作:

  1. 给你的错误/失败加上可聚合的标签,而不是一段给人看的字符串。用 errors.As 能捞出来的那种。这是本节那段代码做的事。

  2. 把你的耗时拆到”归因”而不是”占比”。 go tool pprof 给你的是占比,-http 那张火焰图也是占比。真正有用的是你自己写的那层”假设消除”逻辑。

  3. 最重要的一个动作:去问三个你从来不问的人。 你的接口慢,别只问你的团队,去问你的上游、你的下游、还有那个用你接口的业务方。去年那 40% 的电费,答案不在空调上,在阳台角落里。


题二:为什么你修了三次的水龙头还在滴水?

你家厨房的水龙头滴水,你修了三次。第一次换了个垫圈,好了两周;第二次缠了生料带,好了一个月;第三次你换了个新的阀芯,三个月后又开始滴。

你有没有想过,问题可能不在水龙头上?

参考答案

第一层:大多数人会怎么答

多数人会觉得是”配件质量不好”,于是去买更贵的阀芯、更贵的龙头,甚至干脆换个牌子。

第二层:这个答案为什么不够

因为你在解决”现象”,没有解决”为什么这个现象反复出现”。

一个阀芯的正常寿命是五到八年。你的阀芯三个月就坏了,这不是质量问题,这是有东西在加速它的损坏。

第三层:更深的一层是什么

我认识的一个水电师傅跟我说过一句话:水龙头反复坏,八成是水压的问题。

小区二次供水的压力如果长期偏高(超过 0.4MPa),阀芯的密封面会被持续冲击,垫圈会加速老化。你换什么牌子的阀芯都没用,因为你在对抗的是一个系统级的外力。

真正的解法是:在进水口装一个减压阀,一百二十块钱,二十分钟。装完之后,那个龙头再没坏过。

这个故事的真正含义是:一个反复出现的问题,它的根因几乎永远在”你的边界之外”。

为什么?因为如果它在你的边界之内,你第一次就修好了。

它能反复出现,恰恰说明你每次修掉的只是它的一部分。

第四层:落到 Go 工程师身上

这条对 Go 工程师特别扎心,因为我们的职业习惯就是”把边界划清楚”。

我们喜欢说”这不是我的服务的问题”、“上游传进来的数据就是这样”、“下游的 SLA 我控制不了”。

这些话每一句都对。但正因为每一句都对,你就永远修不好那个水龙头。

具体到行动:

  1. 凡是修过两次以上的问题,第三次动手前,先画一张”边界图”。 把你的服务画在中间,把上下游画在两边,然后问自己:这个问题,有没有可能是从外面传进来的力造成的?

  2. 去看那些”不是你的”指标。 你的服务 P99 突然变差,别先看自己的火焰图,先看上游的请求量分布——是不是突然多了一批”一个用户带 500 个 SKU”的请求?Go 服务里这种事太常见了:一个 for 循环里做了 N 次 RPC,上游给个大 N,你的 P99 就飞了。你的代码一行没错,但你就是慢了。

  3. 最重要的一条:在组里立一个规矩——“修第三次的问题,必须由另一个人来看。” 你自己看,你看到的永远是你上次看到的那一块。

小林那个 8.9%,他一开始看的是”仓匹配慢了 1.8 秒”。他修了三次状态机,每次都觉得自己修对了。

而那个真正的水压,在他从来没去过的那个仓库里。


5.2 从「怎么做」到「做不做」:scope 才是 P8 的核心动作

”我做不到”这三个字该怎么说

三月三十号,周五晚上,还是那家面馆。

小林把那张归因表推给老陈的时候,老陈没看表,先看他。

“你脸色比上次还差。”

“我想明白了。“小林说,“我这一个月做的东西,值零点六个点。”

“嗯。”

“那 3.0,我做不到。”

老陈夹了块牛肉,没接话。

“我打算下周去找王总,跟他说这个事。“小林说,“我把归因表给他看,告诉他,我最多做到 8.3。”

“然后呢?”

“然后……”小林停了一下,“然后看他怎么说吧。他要是坚持 3.0,那我就说,这活儿不该我一个人干。”

“不该你一个人干。“老陈重复了一遍,“那你打算让谁干?”

“仓储、供应链、风控。”

“他们听你的吗?”

”……”

“小林,“老陈放下筷子,“你说的这两句话,在王总耳朵里是两个完全不同的信号。”

“哪两句?”

“第一句,‘我做不到,我最多做到 8.3’。这句话的意思是:这件事我不管了。”

“第二句,‘我做不到 3.0,但我知道那 5.3 个点在谁手里,我来盯’。这句话的意思是:这件事我接着管,只是管法不一样。”

“你觉得王总要的是哪一句?”

小林没说话。

“我再问你一个更狠的。“老陈说,“如果你去跟王总说’我做不到’,王总说’行,那这个事我交给别人’——你什么反应?”

“那我就……”

“你会松一口气,还是会不甘心?”

小林想了很久。

“会不甘心。“他说。

“为什么?”

“因为……”他停住了,“我不知道。可能因为这是我第一个真正被点名的项目吧。上次评审,评委说我 scope 不够大。这次王总给了我一个线上业务的目标。”

“所以这是你的机会。”

“是。”

“那你为什么要去说’我做不到’?”

小林抬起头。

“不对。“他说,“我不是想说’我做不到’,我是想说——”

“你想说什么?”

“我想说,按现在的做法,做不到。”

老陈笑了。

“你看,你自己其实知道。”

“那我下周怎么跟王总说?”

“我不管你怎么说。“老陈说,“我只提醒你一件事:你下周走进那个会议室,你带的不是一份方案,是一个选择题。”

“选择题?”

“你带三个选项进去。每个选项,写清楚三件事:做到几个点、花多少人月、有什么风险。”

“然后让王总选。”

“那他要是选那个我根本做不到的呢?”

“那你就当场告诉他,做不到,并且告诉他差在哪儿。“老陈说,“P8 不是’什么都能做到’的人。P8 是’能说清楚什么做不到、以及为什么’的人。”

“这俩有什么区别?”

“区别大了。“老陈说,“第一种人,老板不敢把事情给他——因为他答应了之后,你不知道他能不能交。第二种人,老板敢把事情给他——因为他答应了,就是能交;他说不能交,那确实是不能交。”

“可预测性,是 P8 最值钱的品质。不是能力上限,是可预测性。“


王总选了第二个

四月三号,周一上午,小林在”钱塘”会议室讲了他人生第一份”没有架构图”的方案。

十五分钟,十一页 PPT。

第一页,去年 618 的归因表。

第二页到第四页,三个选项:

方案 A:只做技术优化
  内容:状态机统一 + 仓匹配调度算法 + 链路异步化
  预期收益:8.9% → 8.3%(降 0.6 个点,年化省约 17 万)
  投入:4 人 × 2 月
  风险:中(大促前一个月上线,有变更风险)

方案 B:技术优化 + 跨部门协同(推荐)
  内容:A 的全部 + 仓储晚班产能 + Top200 SKU 前置备货 + 风控白名单
  预期收益:8.9% → 3.4%(降 5.5 个点,年化省约 156 万)
  投入:4 人 × 2 月 + 约每周 10 小时的跨部门协调
  风险:高(依赖三个不受我管辖的部门)
  需要王总支持:在双周会上给三个部门各 5 分钟

方案 C:全量重构 + 供应链深度改造
  内容:履约链路重写 + 供应链预测补货系统
  预期收益:8.9% → 2.5%(理论值,无历史数据支撑)
  投入:12 人 × 4 月
  风险:极高(大促前不可能完成)

第五页到第九页,是归因的方法、数据的来源、置信度、以及”哪些数是估的”。

第十页,一页纸写清楚了三个部门各自需要做什么、以及”如果不配合,我的方案能兜到几个点”。

第十一页,空白。标题写着:需要您决定的事。

讲了十一分钟。

王总全程没看手机。

讲完之后,他翻回第二页,看了大概半分钟。

然后他问了小林一个问题。

“B 和 C 之间,你为什么推荐 B 不选 C?C 的效果更好啊。”

小林早有准备。

“因为 C 做不到。“他说,“供应链的预测补货,我上周找孙姐聊过。她那边现在连 SKU 级别的日销量预测都没有,历史数据只存了三个月。要做预测,得先花两个月做数据基建。两个月之后是五月十号,离大促三十九天,来不及。”

“所以 2.5 那个数是?”

“是我根据行业经验估的。“小林说,“没有依据,所以我标了’理论值’。”

王总点点头,又翻到第四页。

“B 里这个 3.4,你是怎么算的?”

“技术部分 0.6 个点,我有归因数据支撑。仓储加晚班,老赵他们去年双十一试过一次,华东仓的日处理量提升 34%,我按保守的 25% 折算,对应 1.9 个点。Top200 SKU 前置备货,这个数我没有历史数据,是按缺货订单里 Top200 SKU 的占比估的,1.7 个点,置信度低。风控白名单 0.7 个点,孙姐——不是,风控那边的老吴给的数。”

“加一下。”

“0.6 + 1.9 + 1.7 + 0.7 = 4.9。”

“那你的 3.4 是降 5.5 个点。”

“对不上。“小林说,“因为我这个加法是错的。”

王总抬起头。

“哪里错?”

“这几件事会互相抢。“小林说,“仓储加了晚班,缺货的订单就能更快消化,这两块的收益有重叠。风控放了白名单,订单更快进仓,会挤占仓储的产能。三个动作单独做各有收益,一起做,收益会打折。”

“我按 0.85 的折扣系数算,4.9 × 0.85 = 4.17。再加上归因样本只覆盖了 61.7%,我按乐观情况外推,最后取 5.5。”

“这个数我不敢保证。”

王总又看了他一会儿。

“3.5 行不行?”

“我按 3.4 报,做好了是 3.4,做不好我提前告诉您。“小林说,“我不按 3.5 报,因为如果我按 3.5 报,我做出 3.4,那我就得花一个小时跟您解释为什么差了零点一。”

“那一个小时,您和我都不该花在上面。”

会议室安静了几秒钟。

王总笑了一下。

“B。“他说,“仓储、供应链、风控,下周二的双周会,我给你十五分钟。“


老陈的房子:哪些钱不能省

那天晚上小林给老陈打电话,讲了会上的经过。讲到”3.5 行不行”那一段的时候,老陈在电话那头笑了。

“这句问得好。”

“我当时腿都软了。”

“你答得对。“老陈说,“你刚才说的那个道理,我给你讲个事。”

“我买过两套房。第一套是 2005 年,第二套是 2016 年。”

“第一套我什么都不懂,装修公司给了一份报价,二十六万。我当时想,房子都买了,装修不能太寒碜。所以我跟设计师说:材料用好一点的。”

“设计师很高兴。他给我换了进口的地板、进口的乳胶漆、品牌的厨柜、品牌的卫浴、品牌的灯具。每一项都加钱。加到最后是三十四万。”

“超了八万。”

“住进去第一年,地板起拱。第二年,卫生间渗水,楼下找上来了。第三年,主卧的窗户开始漏风,冬天窗台上能积一层水。”

“第四年我实在受不了,全部砸了重做。”

“第二次装修是 2016 年,那套房子一百二十平,我又找了个设计师,报价三十八万。这次我说了一句话:三十八万可以,但是这笔钱怎么分,我来定。”

“我把预算表重新排了一遍。”

“防水,从六千加到两万二。我说卫生间墙面防水要做到一米八,地面做两遍,做完放水泡四十八小时,我去楼下看。”

“水电,从两万加到四万五。全部换掉开发商预埋的线管,电线用国标国标阻燃,水管走顶不走地。走顶贵三千块,但漏了能看见,走地漏了你得砸地板。”

“窗户,从八千加到三万二。断桥铝,三层中空,进口五金件。”

“加完了,超支五万七。”

“这五万七从哪儿省?我从后面砍。”

“地板,从进口三层实木改成国产强化,一平米便宜两百二。厨柜,从品牌定制改成板材厂做柜体加品牌门板,省了一万八。灯具,从品牌店改成网上买灯体加飞利浦光源,造型一模一样,省了一万一。沙发,从两万四的改成六千的。”

“设计师跟我急了,他说陈哥你这是本末倒置,地板天天踩,沙发天天坐,你省钱省在这些地方,以后住着不舒服。”

“我说,你听我说完。”

“地板我不满意,三年后换一次,一万二,一个周末,家具搬出去再搬进来。沙发不喜欢,随时换,旧的挂闲鱼还能卖两千。”

“防水做砸了,我要砸掉卫生间,重做,楼下邻居的吊顶也要赔。水电出问题,我要砸墙。窗户漏风,我要拆窗框,外墙要补,冬天不能施工。”

“老陈说到这儿停了一下。

“小林,你知道这叫什么吗?”

“叫……好钢用在刀刃上?”

“不。“老陈说,“这叫——不可逆的地方花大钱,可逆的地方花小钱。”

“装修这件事,最怕的不是花钱,是花了钱之后改不动。”

“我把第二套房的预算表拿出来给你看,你会发现一个规律:我加钱的地方,全都是’一旦装完就看不见、出问题就要砸’的地方。我砍钱的地方,全都是’看得见、不喜欢随时能换’的地方。”

“这个规律放到技术决策上,一模一样。”

“哦?“小林说。

“你想想,“老陈说,“一个系统里,哪些东西是不可逆的?”

“数据库的分库分表键。“小林脱口而出,“选错了要重新洗数据。”

“还有呢?”

“接口的数据格式。一旦有外部调用方在用了,就改不动了。”

“还有呢?”

”……消息队列的消息格式。因为老的消费者可能永远在。”

“还有呢?”

“技术栈?选了 Go 就很难换成 Java。”

“对,但那个太粗了。“老陈说,“我给你一个更实用的判据:”

“不可逆的东西有一个共同特征——它有’别人’。”

“数据库分片键一旦定了,所有下游的查询都要按它来。接口格式一旦发布,所有调用方都要适配。消息格式一旦上线,所有消费者都要兼容。”

“不可逆性,本质上是’有多少别人依赖它’。”

“所以反过来,可逆的东西是:只有你自己依赖它的东西。函数内部的实现、模块的划分、算法的选择(只要接口没变)、甚至你的调度算法用什么排序——这些只有你一个人知道,你半夜爬起来改了,谁也不知道。”

“那又怎么样?“小林问。

“怎么样?“老陈说,“这意味着一件事:”

“你在可逆的地方,应该快。快到不假思索。”

“你的调度算法,你想到了一个更好的排序策略,你该不该做?该做。为什么?因为它可逆,错了改回来,成本是一小时。”

“你的系统要不要从单体拆成微服务?这个你得慎重。因为它不可逆——拆了之后,跨服务的事务、监控、排障、部署,全都变了。而且它会影响至少三个团队。”

“P7 和 P8 在这里的分界线是:P7 在所有地方都追求最优解;P8 只在不可逆的地方追求最优解,在可逆的地方追求最快。”

“因为 P8 知道,时间是有机会成本的。你在一个可逆的地方多花三天做到最优,那三天本来可以用来在不可逆的地方多想一遍。”

小林在电话那头沉默了一会儿。

“老陈,我那个五十八页的文档……”

“你那五十八页里,有多少是在设计可逆的东西?”

”……大部分。”

“状态机呢?”

“状态机……不可逆。“小林说,“因为状态字段要写到订单表里,而且有下游在读。”

“所以状态机那部分,你该花的时间不是二十分钟,是二十个小时。“老陈说,“你花了多久?”

”……两天。”

“而你的调度算法,你花了多久?”

“一个半星期。”

“你看。“


ADR:把”取舍”变成一份能被人质疑的文件

四月七号,小林在项目 Wiki 上建了一个目录,叫 decisions。

他写了第一篇 ADR。

ADR 是 Architecture Decision Record,架构决策记录。他之前听过这个词,但一直觉得是”大公司才搞的形式主义”。

这次他写完之后,明白了一件事:ADR 不是文档,是一份”我愿意为这个决定负责”的声明。

# ADR-001:大促履约链路,采用"绞杀者模式"增量替换,不做全量重构

- 状态:已接受
- 日期:2026-04-07
- 决策人:小林
- 参与讨论:Kevin、大刘、阿 May

## 背景

履约链路当前实现为一个单体的 fulfill-service(约 4.7 万行 Go 代码),
包含状态流转、仓匹配、库存预占、超时补偿四个职责,共享同一个 MySQL 实例。

618 目标:履约超时率从 8.9% 降至 3.0%(王总 3/10 立项会)。
归因数据显示(见 ADR-000),技术侧可贡献的收益上限为 0.6pp,
其中仓匹配 0.19pp、状态机卡单 0.35pp、异步化边际收益 0.06pp。

## 约束

1. 上线窗口:必须在 2026-06-10 前完成灰度,留 8 天观察期。
2. 人力:4 人 × 2 月,其中 1 人为应届(小周)。
3. 大促期间(6/18-6/20)禁止任何非紧急上线。
4. 不能影响日常履约:日常超时率 4.1% 不能有回退。

## 可选方案

### 方案 A:全量重写 fulfill-service
- 收益:彻底解决可维护性问题,长期收益最大
- 成本:4.7 万行重写 + 数据迁移,估算 12 人月
- 风险:6/10 前无法完成;即使完成,无灰度期;一旦出错,回滚需要停服
- 结论:否决

### 方案 B:只改仓匹配调度算法,其他不动
- 收益:0.19pp
- 成本:1.5 人月
- 风险:低
- 结论:不足以实现目标,但可作为保底

### 方案 C:绞杀者模式,按接口维度增量替换
- 做法:保留 fulfill-service 作为"壳",在它前面加一层路由,
        按接口 + 用户尾号灰度,逐个接口切到新的 fulfillment-core 服务
- 收益:状态机卡单 0.35pp + 仓匹配 0.19pp + 异步化 0.06pp = 0.6pp
- 成本:4 人月(含 0.8 人月用于搭建灰度与对账基础设施)
- 风险:中。新增一层路由会引入新的故障点;需要双写对账

## 决策

采用方案 C。

关键取舍:我们放弃"代码整洁度"这个目标,换取"可控的上线风险"。
fulfill-service 在 618 之后仍会存在,且会同时存在新旧两套实现。
这是我们有意识地接受的代价。

我们额外接受的两个代价:
1. 618 期间,新接口的排障路径比原来长一层(路由 → core)。
   缓解:路由层打点必须包含 decision_id,且新旧两条路径共用同一个 trace。
2. 双写期间,数据库写入量翻倍。
   缓解:影子写入走独立的连接池,且可一键关闭(见 ADR-003)。

## 我们可能错在哪儿(失效条件)

写这一节,是为了让三个月后的自己(或接手的人)知道
"什么时候应该推翻这个决定",而不是等到出事。

1. 如果到 5/20,灰度比例仍低于 30%,说明增量替换的速度跟不上,
   此时应回退到方案 B,只保 0.19pp。
   判据:看灰度看板的 rollout_percent 曲线,不看开发的主观感受。

2. 如果到 5/31,对账差异率(新旧结果不一致的比例)高于 0.5%,
   说明新旧实现对业务语义的理解存在分歧,此时应暂停灰度,
   先解决差异,而不是继续放量。
   判据:对账任务的 diff_rate 指标,见 ADR-004。

3. 如果 618 之后,fulfill-service 仍有超过 60% 的流量,
   说明绞杀失败了。此时不应继续绞杀,而应该重新评估
   "这个系统是否真的需要被替换"。
   判据:6/25 的流量分布快照。

## 回滚方式

灰度路由配置是一个 KV 项,改配置后 30 秒内生效。
任何时候把 rollout_percent 置 0,全部流量回到旧路径,
无需发布、无需重启、无需数据迁移。

小林写完,把链接发给了 Kevin,附了一句话:“帮我看看’我们可能错在哪儿’那一节。”

Kevin 二十分钟后回复了三条:

  1. 方案 A 的 12 人月是哪来的?我没看到估算过程。如果这是拍的,那你的”否决”就没有依据。
  2. 失效条件第 2 条,0.5% 这个阈值怎么定的?为什么不是 0.1%?
  3. 你说”双写走独立连接池”,但如果新实现的写入比旧的慢,连接池打满之后会不会反过来影响主路径?

小林盯着这三条看了很久。

他发现自己没法回答第二条。

那个 0.5%,是他拍的。

Go 实例:让业务不知道你在重构

ADR-001 里那个”绞杀者模式”,落到代码上是什么样?

先看大多数人的第一反应。

第一版:一个开关解决一切

package main
 
// 全局开关,发版的时候改成 true
const useNewScheduler = false
 
type Order struct {
	ID  string
	SKU string
	City string
	Qty int
}
 
type Decision struct {
	Warehouse string
	ETAHours  int
	Reason    string
}
 
func handleOrderV1(o Order) Decision {
	if useNewScheduler {
		return newScheduler(o)
	}
	return legacyScheduler(o)
}
 
func legacyScheduler(o Order) Decision {
	// 两百多行 if-else,加一个仓要改一次代码
	return Decision{Warehouse: "WH-EAST-01", ETAHours: 48, Reason: "legacy-rule-7"}
}
 
func newScheduler(o Order) Decision {
	// 小林写的新调度器
	return Decision{Warehouse: "WH-EAST-02", ETAHours: 36, Reason: "capacity-weighted"}
}

这版代码能跑,而且在很多公司真的就这么上线了。

它有五个问题,从轻到重:

一、开关是二值的。 要么 0%,要么 100%。中间没有过渡,你唯一的选择就是”赌一把”。

二、你没有证据。 新调度器算出来的仓,到底对不对?你只能等。等到什么?等到客诉。等到大促第二天,用户说”我明明在上海,为什么从武汉发货”。

三、新实现 panic,整个请求挂。 一个你还没验证过的代码路径,被放在了主链路上。

四、新实现慢,主链路跟着慢。 新调度器如果调用了三个新接口,每个 200ms,你原来的仓匹配是 1.8 秒,现在变成 2.4 秒。

五、你没法只给一部分订单开。 想只给华东的订单开?想只给非预售的订单开?做不到。

第二版:影子比对 + 稳定分桶灰度

package main
 
import (
	"context"
	"fmt"
	"hash/fnv"
	"sort"
	"sync"
	"time"
)
 
// ---- 灰度路由 ----
 
// Router 按订单维度做稳定分桶。
//
// 为什么用 hash 而不是随机数?因为一个订单在它的生命周期里会被处理很多次
// (下单时算一次 ETA,支付后算一次,改地址再算一次)。
// 如果用随机数,同一个订单这次走新实现、下次走旧实现,
// 用户看到的"预计发货时间"就会来回跳——
// 这不是技术事故,这是产品事故,用户会截图发帖。
//
// salt 一旦上线就不能改。改了 salt,所有订单的分桶都会重排,
// 等于你在没有任何准备的情况下做了一次全量切换。
type Router struct {
	mu      sync.RWMutex
	percent int // 0-100
	salt    string
}
 
func (r *Router) bucketOf(orderID string) int {
	h := fnv.New32a()
	h.Write([]byte(r.salt))
	h.Write([]byte(orderID))
	return int(h.Sum32() % 100)
}
 
func (r *Router) UseNew(orderID string) bool {
	r.mu.RLock()
	defer r.mu.RUnlock()
	return r.bucketOf(orderID) < r.percent
}
 
// SetPercent 是运行时可调的,不需要发版。
// 这一条比什么都重要:出事了,你要能在 30 秒内把流量切回去,
// 而不是"改代码、提 MR、等 CI、走发布流程"。
func (r *Router) SetPercent(p int) {
	r.mu.Lock()
	defer r.mu.Unlock()
	if p < 0 {
		p = 0
	}
	if p > 100 {
		p = 100
	}
	r.percent = p
}
 
// ---- 差异采集 ----
 
type DiffKind string
 
const (
	DiffNone      DiffKind = ""
	DiffWarehouse DiffKind = "仓不一致"
	DiffETA       DiffKind = "时效不一致"
	DiffError     DiffKind = "新实现报错"
	DiffPanic     DiffKind = "新实现 panic"
	DiffTimeout   DiffKind = "新实现超时"
)
 
type Differ struct {
	mu      sync.Mutex
	total   int
	diffs   int
	byKind  map[DiffKind]int
	samples []string // 只留少量样本,够排查就行
}
 
func NewDiffer() *Differ {
	return &Differ{byKind: make(map[DiffKind]int)}
}
 
func (d *Differ) record(kind DiffKind, orderID string, old, neu Decision) {
	d.mu.Lock()
	defer d.mu.Unlock()
	d.total++
	if kind == DiffNone {
		return
	}
	d.diffs++
	d.byKind[kind]++
	if len(d.samples) < 20 {
		d.samples = append(d.samples, fmt.Sprintf("%s: %s  old=%+v new=%+v", kind, orderID, old, neu))
	}
}
 
func (d *Differ) DiffRate() float64 {
	d.mu.Lock()
	defer d.mu.Unlock()
	if d.total == 0 {
		return 0
	}
	return float64(d.diffs) / float64(d.total) * 100
}
 
func (d *Differ) Snapshot() string {
	d.mu.Lock()
	defer d.mu.Unlock()
	var b []byte
	b = append(b, fmt.Sprintf("影子比对:样本 %d,差异 %d,差异率 %.2f%%\n", d.total, d.diffs, DiffRateOf(d.diffs, d.total))...)
	kinds := make([]DiffKind, 0, len(d.byKind))
	for k := range d.byKind {
		kinds = append(kinds, k)
	}
	sort.Slice(kinds, func(i, j int) bool { return d.byKind[kinds[i]] > d.byKind[kinds[j]] })
	for _, k := range kinds {
		b = append(b, fmt.Sprintf("  %-16s %d\n", k, d.byKind[k])...)
	}
	return string(b)
}
 
func DiffRateOf(diffs, total int) float64 {
	if total == 0 {
		return 0
	}
	return float64(diffs) / float64(total) * 100
}
 
// ---- 影子执行器 ----
 
// ShadowRunner 在主链路之外跑新实现。
// 它有三条铁律:不影响主链路结果、不影响主链路耗时、不影响主链路稳定性。
type ShadowRunner struct {
	differ   *Differ
	sem      chan struct{} // 并发上限,防止影子流量把新服务打垮
	timeout  time.Duration
	sampleRate float64 // 影子也要采样,别 100% 都跑
}
 
func NewShadowRunner(d *Differ, maxParallel int, timeout time.Duration, sampleRate float64) *ShadowRunner {
	return &ShadowRunner{
		differ:     d,
		sem:        make(chan struct{}, maxParallel),
		timeout:    timeout,
		sampleRate: sampleRate,
	}
}
 
func (s *ShadowRunner) shouldRun(orderID string) bool {
	h := fnv.New32a()
	h.Write([]byte(orderID))
	return int(h.Sum32()%1000) < int(s.sampleRate*1000)
}
 
// Go 在后台比对。注意这里做对了三件事:
//   1. recover —— 新实现 panic 只记录,不传播
//   2. 超时 —— 用 context 掐死,不拖主链路
//   3. 并发上限 —— sem 满了就放弃这次影子调用,宁可少采一个样
func (s *ShadowRunner) Go(ctx context.Context, orderID string, primary Decision, fn func(context.Context) (Decision, error)) {
	if !s.shouldRun(orderID) {
		return
	}
	select {
	case s.sem <- struct{}{}:
	default:
		return // 并发满了,放弃,这是正确的选择
	}
 
	go func() {
		defer func() {
			<-s.sem
			if r := recover(); r != nil {
				s.differ.record(DiffPanic, orderID, primary, Decision{})
			}
		}()
 
		sctx, cancel := context.WithTimeout(context.Background(), s.timeout)
		defer cancel()
 
		got, err := fn(sctx)
		switch {
		case err != nil:
			if sctx.Err() == context.DeadlineExceeded {
				s.differ.record(DiffTimeout, orderID, primary, got)
			} else {
				s.differ.record(DiffError, orderID, primary, got)
			}
		case got.Warehouse != primary.Warehouse:
			s.differ.record(DiffWarehouse, orderID, primary, got)
		case abs(got.ETAHours-primary.ETAHours) > 6:
			s.differ.record(DiffETA, orderID, primary, got)
		default:
			s.differ.record(DiffNone, orderID, primary, got)
		}
	}()
}
 
func abs(a int) int {
	if a < 0 {
		return -a
	}
	return a
}
 
// ---- 主流程 ----
 
// handleOrderV2 的关键设计:
//   - 灰度外的订单:主路径返回旧结果,同时影子跑新实现(只看不采信)
//   - 灰度内的订单:采信新结果,但新实现出任何问题都自动降级回旧结果
//
// 这意味着:即使新实现全是 bug,用户的体验也不会比原来更差。
// 你为一个还没验证过的东西,付的代价是零。
func handleOrderV2(ctx context.Context, r *Router, s *ShadowRunner, o Order) Decision {
	legacy := legacyScheduler(o)
 
	if !r.UseNew(o.ID) {
		s.Go(ctx, o.ID, legacy, func(c context.Context) (Decision, error) {
			return newScheduler(c, o)
		})
		return legacy
	}
 
	got, err := newScheduler(ctx, o)
	if err != nil {
		// 降级:新实现挂了,走老路,同时记一笔
		s.differ.record(DiffError, o.ID, legacy, got)
		return legacy
	}
	return got
}

跑起来:

func main() {
	router := &Router{salt: "fulfill-2026-618", percent: 10}
	differ := NewDiffer()
	shadow := NewShadowRunner(differ, 32, 50*time.Millisecond, 0.3)
 
	for i := 0; i < 20000; i++ {
		o := Order{
			ID:   fmt.Sprintf("SO%08d", i),
			SKU:  "SKU-1001",
			City: "上海",
			Qty:  1,
		}
		_ = handleOrderV2(context.Background(), router, shadow, o)
	}
 
	time.Sleep(300 * time.Millisecond) // 等影子 goroutine 跑完
 
	fmt.Println(differ.Snapshot())
	fmt.Println("—— 差异样本(前 5 条)——")
	for i, s := range differ.samples {
		if i >= 5 {
			break
		}
		fmt.Println("  " + s)
	}
 
	// 出事了:30 秒内切回去,不用发版
	router.SetPercent(0)
}
 
func legacyScheduler(o Order) Decision {
	return Decision{Warehouse: "WH-EAST-01", ETAHours: 48, Reason: "legacy-rule-7"}
}
 
// 模拟新实现:绝大多数订单结果一致,
// 剩下的差异分成三类——优化、报错、时效分歧。
// 真实系统里,这三类的比例就是你的放量判据。
func newScheduler(ctx context.Context, o Order) (Decision, error) {
	tail := o.ID[len(o.ID)-2:]
	switch tail {
	case "13": // 约 1%:新实现认为发武汉更快,这是优化,不是 bug
		return Decision{Warehouse: "WH-WEST-04", ETAHours: 30, Reason: "express-time-optimal"}, nil
	case "77": // 约 1%:新实现报错,这一类必须逐条看,不放量
		return Decision{}, fmt.Errorf("仓产能数据缺失: %s", o.ID)
	case "42": // 约 1%:仓一致,但时效估算差了很多
		return Decision{Warehouse: "WH-EAST-01", ETAHours: 30, Reason: "capacity-weighted"}, nil
	default:
		return Decision{Warehouse: "WH-EAST-01", ETAHours: 48, Reason: "capacity-weighted"}, nil
	}
}

跑出来:

影子比对:样本 5116,差异 161,差异率 3.15%
  仓不一致              94
  新实现报错            35
  时效不一致            32

—— 差异样本(前 5 条)——
  仓不一致: SO00000013  old={WH-EAST-01 48 legacy-rule-7} new={WH-WEST-04 30 express-time-optimal}
  新实现报错: SO00000077  old={WH-EAST-01 48 legacy-rule-7} new={WH 0 }
  时效不一致: SO00000042  old={WH-EAST-01 48 legacy-rule-7} new={WH-EAST-01 30 capacity-weighted}
  仓不一致: SO00000213  old={WH-EAST-01 48 legacy-rule-7} new={WH-WEST-04 30 express-time-optimal}
  新实现报错: SO00000377  old={WH-EAST-01 48 legacy-rule-7} new={WH 0 }

这段代码里,值钱的不是 goroutine,是四个判断。

第一个判断:灰度的单位不是百分比,是”决策”。

percent: 10 看起来是”10% 的流量”,但真正重要的是它背后那句:同一个订单,永远走同一条路。

这是很多灰度方案里最容易翻车的地方。你用随机数分桶,一个订单这次走新、下次走旧,用户刷新一下页面,预计发货时间从 48 小时变成 36 小时,再刷新又变回去。技术上没有任何错误,但用户会以为你的系统在抽风。

第二个判断:影子路径必须和主路径做资源隔离。

超时(context)、并发上限(sem)、panic 恢复(recover),三样缺一不可。

我见过太多次这种事故:新实现做了个影子调用,结果下游扛不住,连接打满,把主路径也拖垮了。你做实验,结果把生产环境搞挂了。

影子跑的第一原则不是”跑”,是”就算我全错,也不能影响到你”。

第三个判断:影子比对的目的不是找 bug,是找”理解的裂缝”。

这一条最重要,也最容易被误解。

小林第一周跑影子比对,差异率是 4.7%,远超 ADR 里那个 0.5% 的阈值。他慌了,拉了三个人看了一整天。

看完发现:4.7% 里,真正的 bug 只有 0.3%。剩下的 4.4%,分成两类:

  • 2.1%:新实现比旧实现更优。 旧规则里有个硬编码的”偏远地区默认发华东仓”,新实现按快递时效算,发现发武汉仓更快。旧的”错”,新的”对”。
  • 2.3%:两边都合理,但业务规则从来没被写清楚。 比如”同一用户同一天下的两单,是否必须合并到同一个仓”。旧实现里,因为分单是按时间顺序处理的,所以碰巧合并了;新实现是并行调度的,可能分到两个仓。这两种行为都没有文档,没有人规定过。

这 2.3% 才是影子比对最值钱的东西。

它不是在告诉你”你的代码有 bug”,它是在告诉你:这套业务规则,从来没有被任何一个人完整地定义过。它活在两个地方——旧代码的行为里,和运营同学的脑子里。而这两个地方,已经不一样了。

小林后来拿着这 2.3% 去找阿 May 和运营,开了一次两小时的会,把十一条规则白纸黑字定了下来。

那两小时,比他写四千行调度算法都值钱。

第四个判断:ADR 里那个”0.5%“,他最后改了。

Kevin 问”为什么不是 0.1%“,小林答不上来。他后来想了三天,把阈值改成了这样:

2. 差异率判据(2026-04-10 修订,原为单一阈值 0.5%,Kevin 指出无依据):

   - DiffError / DiffPanic / DiffTimeout 三类:阈值 0.05%。
     理由:这三类是"新实现不可用",不是"新实现有分歧"。
     任何一例都要人工看,不看不放量。

   - DiffWarehouse 且新 ETA 更长:阈值 0.3%。
     理由:这类差异会直接伤害用户体验,宁可保守。

   - DiffWarehouse 且新 ETA 更短:不设阈值,只做记录。
     理由:这是优化,不是风险。

   - DiffETA 单独出现:阈值 5%。
     理由:ETA 是估算值,本来就有误差,只要方向一致即可。

   Kevin 的质疑让我意识到:我原来那个 0.5% 是在保护我不犯错的,
   不是在保护业务的。分类之后才发现,我真正需要盯的只有 0.05%。

一个数字被拆开之后,你才发现你原来有多怕。

P7 设一个保守的阈值,是为了”出事了我没有责任”。P8 设一组分类阈值,是为了”我知道我在防什么”。

前者是免责,后者是控制。


老陈的追问

一、过去三个月,你砍掉过什么?

我说的是主动砍。不是被别人砍、不是因为时间不够、不是因为需求变了。是你自己看着它,说”这个不该做”,然后把它删了。

如果你举不出来,那你要想一件事:你手上的每一件事,都是谁决定要做的?如果答案一直是”需求就是这么提的”,那你做的不是决策,是执行。

二、你最近做的一个决定,它的”失效条件”是什么?

不是”风险是什么”——风险是你想得出的坏事。失效条件是:出现什么信号,你就承认自己错了。

你能说出来吗?如果你说不出来,那这个决定你其实没有做,你只是碰巧站在了那个位置上。

三、你有没有为一件”你知道做不完”的事,争取过时间?

或者是反过来:你有没有答应过一件”你知道做不完”的事,然后在最后两周拼命赶工,最后交出一个你自己都不满意的东西?

如果有,那你要问自己:你当时为什么不说?

是因为你觉得说了会显得不行?还是因为你心里其实觉得”也许能赶上”?

四、如果明天有人接手你现在负责的所有事情,你能交出去什么?

不是代码。代码在 Git 上。

我问的是:那件只有你知道的、一旦你离开就会消失的东西,是什么?它现在在哪儿?


生活思考题

题一:为什么你搬家的时候,总有三分之一的东西是到了新家才发现根本用不上?

你搬过家吗?

打包的时候,你觉得每一样都要带走。到了新家拆包,你会发现有一整箱东西——可能是旧杂志、可能是几件两年没穿的外套、可能是三只只有一个能用的充电器——你拆开看了一眼,又原封不动地放回了箱子里,然后那个箱子在阳台上放了半年。

为什么打包的时候,你看不出来?

参考答案

第一层:大多数人会怎么答

多数人会说:因为打包的时候没时间细看,搬家那天太急了,捡着什么装什么。

或者会说:因为”万一以后用得上”。

第二层:这个答案为什么不够

“没时间”这个解释,经不起追问。搬家这件事,你提前一周就知道日期,你有七天时间,你为什么没时间?

真相是:你不是没时间判断,你是回避判断。

为什么回避?因为判断是痛苦的。

拿起一件两年没穿的外套,你要做一次判断:我还会穿吗?这个判断的答案是”大概不会”。而一旦你说出”大概不会”,你就必须处理它——扔掉、捐掉、挂闲鱼。这三件事都比”装进箱子”麻烦十倍。

所以你的大脑给了你一个更省力的选项:“万一用得上呢”。

“万一用得上”这五个字,是一句免责声明。它把”我现在不想决定”包装成了”我在为未来考虑”。

第三层:更深的一层是什么

搬家公司的人跟我说过一个规律:一个三口之家,搬一次家,平均会运走 40% 的”死重”——到了新家一年之内绝对不会碰的东西。

而他们见过真正搬得干净的人,都有一个共同点:他们在打包之前,先做了一次”反向打包”。

什么叫反向打包?就是先不装东西,先站在新家的户型图前面,一个房间一个房间地想:这个房间里,我每天会做的事是什么?做这些事需要哪些东西?

然后把这些需要的东西,列一张清单。

打包的时候,只装清单上的。

这两种方式的区别,是”从存量里筛选”和”从需求出发”的区别。

前者你面对的是一屋子已有的东西,你的默认动作是”留下”,你需要为每一件”扔掉”找到理由。这个心理成本极高。

后者你面对的是一张空白清单,你的默认动作是”不装”,你需要为每一件”装上”找到理由。这个心理成本极低。

而这两种方式,最后装箱的数量,能差出一倍。

第四层:落到 Go 工程师身上

这个道理,在技术上的对应物叫:零基预算(zero-based budgeting)。

大多数团队做技术规划,用的是”存量筛选法”:把去年的项目列出来,砍掉明显不行的,剩下的延续。所以你会发现,你团队里那些谁也说不清价值的项目,能活三年。它们就像那件两年没穿的外套——没有人为它的存在辩护过,但也没有人为它的消失负责。

具体到 Go 工程师的季度规划,做一次反向打包:

  1. 先列”这个季度必须改变的三个业务数字”,从业务方那里拿,不要自己编。

  2. 然后问:为了改变这三个数字,我们需要什么能力? 只列能力,不列项目。

  3. 最后才做匹配:现有系统里,哪些部分在提供这些能力?哪些不是?

  4. 那个”不是”的部分,就是你要处理的东西。注意,处理不等于删除——它可能还需要维护,但你要给它定一个明确的”下线条件”。

还有一个更狠的做法,我建议你每季度做一次:

把你负责的所有”定期任务”(定时任务、巡检脚本、报表、对账 job)列一张表,对每个问一句:上一次有人因为它的输出而改变了一个决定,是什么时候?

如果答案是”想不起来”,那它就是你阳台上那个箱子。


题二:为什么那么多人办了健身卡,却坚持不下来?

健身房有个公开的秘密:年卡的续卡率大概在 20% 到 30% 之间。也就是说,十个办卡的人里,七个在第二年不续了。

而且更讽刺的是:这七个人里,大部分在办卡的时候,是真心打算去的。

他们为什么不去了?

参考答案

第一层:大多数人会怎么答

“没时间”、“太累了”、“意志力不够”、“工作太忙”。

第二层:这个答案为什么不够

这些解释有个共同问题:它们把原因归结为”人的品质”,而人的品质是不变的。

但事实是:同样一个人,能在双十一前连续三周每天加班到十二点,能为了一个游戏副本连续肝两个通宵,能在装修的时候跑十几趟建材市场。他不是没有意志力,他的意志力只是没有投给健身房。

所以真正的问题不是”他为什么坚持不下来”,是——

“他在办卡的那一刻,买的到底是什么?”

第三层:更深的一层是什么

他在办卡的那一刻,买的是一个身份,不是一个计划。

“我是一个会健身的人。”

这个身份让他当场获得了满足感。刷卡、拍照、发朋友圈,这个动作带来的多巴胺,和跑五公里带来的多巴胺,是同一种。而前者便宜得多。

健身房非常清楚这一点。所以他们的销售话术里,永远在强化身份,永远在回避计划:

  • “您看着就是经常锻炼的”(身份)
  • “我们这儿很多您这样的白领”(身份)
  • “年卡算下来一天才八块钱”(降低决策痛感,不谈计划)

从来没有人问过你:“你下周几来?几点?练什么?跟谁一起?”

而真正能坚持下来的那两三个人,他们做的事情和销售说的完全相反。他们不是”打算去健身”,他们是——

“每周二、周四早上七点,我先跑三公里,然后做二十分钟力量。”

注意这个句式里有什么:有具体的时间、有具体的地点、有具体的动作、有可验证的完成标准。

它不是愿望,它是日程。

更深的一层是:这个计划里,藏着一个”放弃的成本”。

周二早上七点,你的闹钟响了。你有两个选择:去,或者不去。如果你没有计划,不去是零成本的——你甚至不会认为自己”放弃”了什么,因为你从来没说过你要去。

但如果你的计划是”周二早上七点跑三公里”,不去,你的大脑会记一笔账。这一笔账不会让你立刻爬起来,但它会累积。累积到第三周,你会有一种说不清的烦躁,那种烦躁会推着你出门。

计划的价值不在于它让你做得更好,在于它让”不做”这件事,开始有成本了。

第四层:落到 Go 工程师身上

这条对技术人杀伤力极大,因为我们特别擅长做”没有成本的计划”。

你看看这些话你有没有说过:

  • “这个季度我们要治理技术债。”
  • “后面要补一下监控。”
  • “这块代码有机会重构一下。”
  • “我们要提升系统的可观测性。”

这些全是身份,没有一个是计划。

“我们要提升可观测性”——这不是计划,这是”我是一个重视可观测性的工程师”。

真正的计划长这样:

到 5 月 31 日,履约链路十一个环节里,至少九个环节能在 Grafana 上看到 P50/P99/P999, 且每个环节的 P99 告警阈值由大刘确认过。 完成判据:6 月 1 日的压测中,任一环节 P99 超过阈值,值班同学在 3 分钟内收到告警。 负责人:小周,我每周三下午四点跟他过一次进度。 如果到 5/20 只完成六个环节,砍掉最不重要的两个,保七个,并在周报里说明。

区别在哪儿?

前者没法失败,因为它没有终点。后者会失败,因为它有。

而正因为后者会失败,它才有价值——因为到了 5 月 20 日,你会真的面对一次取舍。

给你一个可执行的动作,下次写季度规划的时候用:

把每一条技术规划,改写成一句话,这句话必须包含:一个日期、一个数字、一个判据、一个人名。

改不出来的,删掉。

你会发现,你那个”三十条技术规划”的列表,最后只剩下五条。

那五条,才是你这个季度真正会做的事。


5.3 技术债务:怎么让王总同意给你三个月还债(量化说服的艺术)

你申请的从来不是”时间”,是”收入”

四月中旬,小林撞上了那堵墙。

事情是这样的:方案 B 里有一项”仓匹配规则支持按仓类型过滤”,阿 May 评估的时候估了三天,给小周做。

小周做了九天。

第九天晚上,小周来找小林,一脸疲惫。

“林哥,我改完了,但是我不敢发。”

“为什么?”

“我改了六个地方。仓匹配那个函数里改了两处,然后有个定时任务也用了同样的规则,还有个运营后台的’试算’接口也调用了它。我改完之后发现,试算接口出来的结果和线上的不一致。”

“然后呢?”

“然后我发现,试算接口用的是另一份规则——是去年有人复制过去改了一版的。”

小林沉默了一会儿。

“测试呢?”

“测试环境的用例全过了。但是我问了大刘,他说测试环境只有一个仓,所以那条新规则在测试环境根本没被触发过。”

那天晚上小林把 fulfill-service 的代码拉下来数了一遍。

四万七千行。四个职责:状态流转、仓匹配、库存预占、超时补偿。共享一张 fulfill_order 表,三十二个字段,其中十一个字段的注释是”待确认”或者”历史遗留”。

他数了一下 SELECT * 出现了 217 次。

他数了一下带 // TODO: 这块后面要重构 的注释,有 63 处,最早的一处是 2019 年。

他数了一下加一个仓需要改的地方:6 处,分布在 4 个文件里。

然后他打开了 Git log,把过去 12 个月涉及这个服务的所有提交捞出来,按需求归类,然后去翻了每个需求的估算工期和实际工期。

做完这些,是四月十七号凌晨一点半。

他给老陈发了条消息:“睡了吗?”

老陈秒回:“没。”

“我发现了点东西。”

“说。”

“fulfill-service 这块,过去一年,我们团队在它上面花了 487 个人天。涉及它的需求,平均实际工期是估算的 2.7 倍。不涉及它的,是 1.2 倍。”

“差值呢?”

“我粗算了一下,因为耦合而多花的时间,大概 180 人天。”

“折成钱。”

小林算了一下。他们团队一个人天的综合成本,按人力部门给的口径是两千五。

“45 万。”

“还有呢?”

“还有故障。过去 12 个月,这块代码引发的线上故障 5 次,其中 2 次在大促期间。按大刘的记录,平均每次故障的直接损失(客诉补偿 + 人工处理)是 3 万 8,加间接损失算 8 万。5 次就是 40 万。”

“还有呢?”

”……没了。”

“你漏了最贵的那一项。“老陈说。

“什么?”

“你因为改不动而没做的那些事。”

小林愣住了。

“过去一年,有没有哪个需求,是因为’这个改起来太麻烦’而缩水了、或者干脆砍了?”

小林想了一会儿,想起了阿 May 那个”预约配送”。

去年九月,阿 May 提了个需求:用户下单的时候可以选”希望哪天收货”,系统按这个时间去排仓。完整的方案是支持用户选具体日期和时间段。

评审的时候,小林说:“完整版要动仓匹配的核心逻辑,两周。要不我们做个简化版,只能选工作日或者周末?”

阿 May 说:“简化版的效果会差很多。”

最后还是做了简化版。上线之后数据平平,用户选”工作日”和”周末”的比例是 51:49,等于没选。

小林在手机屏幕上打了一行字:“预约配送,砍成了简化版。”

“还有吗?”

”……’同城急送’,因为要接新的配送商,需要动状态机,评估完是五周,砍了。”

“还有吗?”

“‘商家自发货’。这个要改库存预占的逻辑,不敢动,一直搁着。”

“三个。“老陈说,“你去把这三个需求,如果做成了,一年能带来多少,问出来。”

“问谁?”

“问阿 May,问王总。”


四月二十号,小林拿到了三个数。

  • 预约配送(完整版):阿 May 给的数是,能提升大家电品类的转化率 0.8 个百分点,年化 GMV 约 320 万。
  • 同城急送:王总给的数,去年做过一次小范围试点,试点城市的客单价提升 12%,如果这个能力全量,年化增量约 180 万。
  • 商家自发货:这个阿 May 说不清楚,只说”运营提了两年了,每次都被排到后面”。小林把它标成了”无法量化”。

然后他做了那张表。

他把表发给老陈,老陈打了个电话过来。

“你这回准备怎么跟王总说?”

“我打算申请三个月,把这个服务重构了。”

“你拿不到的。”

“为什么?”

“因为现在离大促五十八天。你去跟王总说’我要三个月’,王总只会听到一件事——这个人要在大促之前动核心链路。”

“那我怎么说?”

“你先想想:王总为什么会给你时间?”

“因为……不还债,以后会更慢?”

“那是你的理由,不是他的。“老陈说,“王总给你时间,只有一种可能:他相信这笔投入,能在他关心的时间窗口里,换回更多的时间。”

“他关心的时间窗口是多久?”

“他今年的 KPI。也就是到十二月。”

“所以……”

“所以你要算一笔账给他看:我从四月到十二月,投 8 周 × 20% 的产能,能换回多少产能。”

“如果这笔账是正的,他会给你。”

“如果这笔账是负的,你不该要。”

小林在电话那头沉默了一会儿。

“老陈,我有个问题。”

“你说。”

“为什么是 20%,不是三个月?”

“因为三个月是一个承诺,20% 是一个税率。”

“什么意思?”

“你说’给我三个月’,你是在要一笔钱。你要的时候,对方的第一反应是拒绝,因为这笔钱要从他口袋里出。”

“你说’每周给我 20%‘,你是在交税。税率这种东西,是可以谈的——他可以说 15%,你可以还价 18%。但没有人会把税率谈到零,因为没有人相信一个数可以既是零又是对的。”

“申请时间,你是在请求;谈判税率,你是在交易。“


老陈的房贷:利率不是唯一的数字

四月二十二号,小林去了趟老陈那儿,在他家楼下的咖啡馆。

老陈那天带了个旧笔记本,翻开一页,上面是他自己手写的几行字。

“我给你讲个我自己的蠢事。”

“2010 年,我买第一套房,贷款 118 万,三十年,利率 5.88%。”

“2013 年,我手上攒了三十万。那年理财产品的收益率大概是 4.2% 到 4.8%。”

“我算了一笔账:提前还贷,等于无风险拿到 5.88% 的年化收益。买理财,只有 4.5%。差 1.4 个点。”

“而且提前还贷之后,我每个月的月供能少一千八。”

“这账太清楚了,所以我去还了。”

“还完之后呢?”

“还完之后第三个月,我有个朋友拉我入股一个项目,需要二十万。”

“我拿不出来。”

“那笔钱在房子里。它变成了砖头。”

“我最后借了二十万,年息 9%。”

老陈合上笔记本。

“小林,我那年犯的错误是什么?”

”……你不该提前还贷?”

“不是。“老陈说,“我犯的错误是:我只算了利率,没算流动性。”

“5.88% 这个数字,在我把它还进去的那一刻,就锁死了。它不再是’我的钱’,它变成了’银行少给我的利息’。而’银行少给我的利息’不能拿出来应急。”

“我为了省每年 1.4 个点的利差,付了 9% 的借款利息。”

“三年白干。”

小林听着。

“这个跟技术债有什么关系?”

“关系大了。“老陈说,“技术债这个词,是程序员给自己找的比喻。但如果你真的拿债务来算,你会发现一个规律:”

“所有人谈债务的时候,只谈利率。没有人谈本金,没有人谈期限,更没有人谈流动性。”

“你说的’我要三个月重构’,那是本金加利息一次性付清。这在绝大多数情况下是错的,因为:”

“第一,你几乎没有一次性付清的能力——你的本金(三个月 × 4 人)太大了。”

“第二,你把所有的流动性都押进去了。还债期间,任何新需求你都做不了。”

“第三——这一条最要命——你还的这笔债,在你还完之后,可能已经不是你以为的那笔债了。”

“什么意思?”

“你三个月后重构完了,你猜怎么着?业务变了。你重构的那个’仓匹配’,可能下个季度就要接新的配送商,规则全变。你花了三个月,把一笔债从’欠十万’还到了’欠两万’,结果你又欠了一笔新的八万。”

“所以正确做法是什么?”

“分期,而且是小额分期。“老陈说,“每周还一点,边还边看。你还的过程中,如果发现这笔债的利率突然降了(比如某个模块其实没人动了),你就停在那儿,把额度转去还另一笔。”

“这就是为什么我跟你说 20% 而不是三个月。”

“哦,“小林说,“我明白了。”

“你明白了一半。“老陈说,“还有一半更重要。”

“哪一半?”

“利率是会变的,但你感觉不到。”

老陈从口袋里掏出手机,翻出一条短信给他看。是一条银行的信用卡账单提醒。

“我上个月刷了两万三,还了最低还款。”

“银行收我’手续费’ 0.6% 一个月。”

“0.6% 一个月,你听着是不是特别便宜?”

”……一年 7.2%?”

“不是。“老陈说,“年化 18.25%。”

“因为手续费是按原始本金算的,不是按剩余本金算的。你还了一半,手续费还是按两万三算。”

“小林,这个 0.6%,是我这一年见过的最贵的数字。”

“因为它小到我不在乎。”

“技术债的利息,就是那个 0.6%。”

“它不是一次性的大故障——大故障你记得住,会有人追责,会复盘,会立项。”

“它是:改一个需求,本来两天,因为要理解那段代码,多花半天。”

“它是:新同事问你’这个字段是什么意思’,你说’我也不知道,别动它’。”

“它是:每次发版前,你要多花四十分钟回归一遍,因为你不确信。”

“它是:评审的时候有人说’这块最好别动,上次谁动过一次出事了’。”

“每一次都很小。小到你不会把它记在任何地方。”

“但它每个月都来。”

“而且它是按原始本金算的——你欠得越多,你每个月付的绝对额就越大,哪怕你已经在还了。“


那场谈话:18%

四月二十四号,周四下午,小林去了王总办公室。

他没带 PPT,带了一页纸。

“王总,我想跟你要一样东西。”

“说。”

“我要我们团队每周 20% 的产能,从现在到十二月底,一共 32 周。”

王总抬起头。

“用来干嘛?”

“还债。“小林说,“fulfill-service 那个服务。”

“现在?大促前?”

“不是现在动它。“小林说,“是现在开始,每周花一天,做绞杀者式的增量替换。大促期间我们不碰它,只跑影子比对,不切流量。”

“你要我给你 32 周。“王总说,“我先问一句:这 20%,会让我少拿到什么?”

小林把第二页纸推过去。

如果每周抽走 20% 产能(团队 4 人 × 1 天 = 每周 4 人天),
32 周共 128 人天。

这 128 人天,本来可以做的东西(按当前排期):
  1. 券系统的 v2 多业务线支持         45 人天    —— 建议延后到明年 Q1
  2. 履约后台的导出功能优化           18 人天    —— 建议砍掉,改成每周手工导一次
  3. 订单详情页改版                   30 人天    —— 建议缩减到 12 人天,只做核心字段
  4. 一些零散的运营小需求             35 人天    —— 建议攒两周做一次

王总看着这张表,看了大概半分钟。

“第一条,券系统 v2,为什么要延后?那是你们自己提的。”

“因为它的收益是长期的,而今年的大促在六月。“小林说,“如果您不同意,那我把第三条砍到零,保第一条。”

“你倒是敢砍。”

“这三条我都跟阿 May 对过了。“小林说,“她说券系统 v2 可以延后,她同意了。”

王总哼了一声。

“然后呢?我给了你 128 人天,我能拿回什么?”

小林翻到第三页。

投入:128 人天(约 32 万)

回报(保守估计,只算能算的):
  1. 交付税下降
     现状:涉及 fulfill-service 的需求,实际工期 / 估算工期 = 2.7
     目标:降到 1.5(不追求 1.0,因为这不是一个新系统)
     量化:过去 12 个月,团队在该服务上花 487 人天;
           按 2.7 → 1.5 折算,一年可省约 217 人天 ≈ 54 万
           保守打五折计入:27 万

  2. 故障税下降
     现状:过去 12 个月,该服务引发线上故障 5 次,平均直接+间接损失 8 万
     目标:绞杀者模式下,新增代码全部隔离;老代码不再改动
     量化:预计故障次数降到 2 次,年省 24 万
           保守打五折计入:12 万

  3. 解锁三个被卡住的需求
     预约配送(完整版)    年化 GMV  320 万    —— 阿 May 提供
     同城急送(全量)      年化增量  180 万    —— 王总 4/20 提供
     商家自发货            无法量化,但运营已提两年
     注:这两项需要额外的开发资源,不在本次 128 人天内。
         还债本身不产生 GMV,它只是让这两件事从"做不了"变成"能做"。

  合计可量化回报(12 个月内):39 万
  投入:32 万
  净收益:+7 万(第一年基本打平)
  第二年:投入 0,回报 39 万

王总看到这里,手指在”第二年”那行停了一下。

“第一年打平。“他说。

“对。”

“那你这个账,等于在跟我说:今年我白干,明年我躺着赚。”

“是。”

“你凭什么保证明年能躺着赚?”

“我不保证。“小林说,“我只能保证一件事:这 128 人天花完之后,fulfill-service 里会有一份’每个接口的迁移状态表’,您可以随时看。”

“如果到十二月底,迁移完成度低于 60%,说明我估错了,您明年不用再给我这个预算。”

王总靠在椅背上,看了他很久。

“15%。”

“18%。”

王总笑了一下。

“你小子还敢还价。”

“券系统 v2 那 45 人天,我可以让阿 May 再压到 38。“小林说,“多出来的 7 人天,我要 3%。”

“你跟阿 May 说过了?”

“还没有。我今晚跟她说。”

王总摇摇头,笑了。

“行。18%。但是——”

“您说。”

“你每周要给我看一个数。“王总说,“一个数就够,别给我看进度表,我看不懂。”

“什么数?”

“你自己定。”

小林想了两秒。

“已迁迁移接口数除以总接口数。现在 0%,年底目标 70%。”

“行。“王总说,“每周五下班前,发我微信。“

Go 实例:让”还债进度”变成一个每周能报的数字

王总要的那个”每周一个数”,怎么做出来?

大部分团队的做法是:在 Wiki 上维护一张表,人工更新。这张表活不过六周,因为总有一周你会忘记更新,一旦断了两次,它就再也不会被更新了。

一个需要人主动维护的数字,最后一定会变成一个谎。

所以它必须是代码算出来的。

第一版:开个分支,重构三个月

package main
 
import (
	"fmt"
	"net/http"
	"strings"
)
 
// 重构分支里常见的写法:在一个长期不合并的分支上,
// 把老实现整体替换掉,然后祈祷合回来的那天一切顺利。
func handleLegacyOrModern(w http.ResponseWriter, r *http.Request, refactorDone bool) {
	if refactorDone {
		// 三个月后的新世界
		fmt.Fprintln(w, "modern")
		return
	}
	// 三个月前的旧世界
	if strings.HasPrefix(r.URL.Path, "/fulfill/match") {
		fmt.Fprintln(w, "legacy-match")
		return
	}
	fmt.Fprintln(w, "legacy-other")
}

这版的致命问题不是”分支会冲突”。分支冲突是可以解决的。

致命问题是:你无法回答”还了多少”。

三个月里,你的进度只有两个状态:0% 和 100%。第二周的时候,你没法告诉王总”我还了 8%“;第八周的时候,你也没法告诉他”我卡住了”。

于是出现了最坏的一种情况:你在第八周发现这条路走不通,但你不敢说,因为你已经投了八周。你只能硬着头皮往下做,然后在第十二周交出一个半成品。

没有中间态的进度,就没有中途纠偏的可能。

第二版:按接口的迁移状态机 + 加权进度 + 债务利息排序

package main
 
import (
	"bytes"
	"fmt"
	"hash/fnv"
	"net/http"
	"sort"
	"sync"
	"time"
)
 
// ---- 迁移状态机 ----
 
type Phase int
 
const (
	PhaseLegacy   Phase = iota // 全量老实现
	PhaseShadow               // 老实现主路径 + 新实现全量影子比对
	PhaseCanary               // 小比例灰度新实现
	PhasePrimary              // 新实现为主,失败降级回老实现
	PhaseDone                 // 老实现已下线
)
 
func (p Phase) String() string {
	switch p {
	case PhaseLegacy:
		return "未开始"
	case PhaseShadow:
		return "影子比对"
	case PhaseCanary:
		return "灰度中"
	case PhasePrimary:
		return "新实现为主"
	case PhaseDone:
		return "已完成"
	}
	return "未知"
}
 
// phaseWeight 是"这条路由还掉了百分之多少的债"。
//
// 注意这里的关键:进度不是布尔值。
// 一条处在"影子比对"阶段的路由,虽然一行流量都没切,
// 但它已经完成了"验证新旧语义是否一致"这件最难的活,
// 这部分工作量是实打实的,应该计入进度。
// 如果只统计"已上线",那么前三个月你的进度条会一直是 0——
// 然后你的预算会在第三个月被砍掉。
var phaseWeight = map[Phase]float64{
	PhaseLegacy:   0.0,
	PhaseShadow:   0.25,
	PhaseCanary:   0.55,
	PhasePrimary:  0.85,
	PhaseDone:     1.0,
}
 
type Route struct {
	Pattern       string
	Phase         Phase
	CanaryPercent int
	Modern        http.Handler
	Legacy        http.Handler
}
 
// ---- 绞杀者路由 ----
 
type Strangler struct {
	mu     sync.RWMutex
	routes map[string]*Route
	stat   *MigrateStat
}
 
type MigrateStat struct {
	mu            sync.Mutex
	fallbackCount map[string]int // 新实现失败、降级回老实现的次数
	canaryServed  map[string]int
}
 
func NewStrangler() *Strangler {
	return &Strangler{
		routes: make(map[string]*Route),
		stat:   &MigrateStat{fallbackCount: map[string]int{}, canaryServed: map[string]int{}},
	}
}
 
func (s *Strangler) Register(rt *Route) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.routes[rt.Pattern] = rt
}
 
// Advance 把一条路由推到下一个阶段。
// 这是唯一的迁移入口——所有状态变更都必须经过它,
// 这样你才能在里面加校验(比如"没做过影子比对,不允许进灰度")。
func (s *Strangler) Advance(pattern string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	rt, ok := s.routes[pattern]
	if !ok {
		return fmt.Errorf("未注册的接口: %s", pattern)
	}
	switch rt.Phase {
	case PhaseLegacy:
		if rt.Modern == nil {
			return fmt.Errorf("%s 还没有新实现,不能进影子比对阶段", pattern)
		}
		rt.Phase = PhaseShadow
	case PhaseShadow:
		rt.CanaryPercent = 5
		rt.Phase = PhaseCanary
	case PhaseCanary:
		switch {
		case rt.CanaryPercent < 50:
			rt.CanaryPercent = 50
		default:
			rt.Phase = PhasePrimary
		}
	case PhasePrimary:
		rt.Phase = PhaseDone
	case PhaseDone:
		return fmt.Errorf("%s 已经完成", pattern)
	}
	return nil
}
 
// Rollback 往回退一步。迁移不是单行道。
// 能够回退,你才敢往前走。
func (s *Strangler) Rollback(pattern string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if rt, ok := s.routes[pattern]; ok && rt.Phase > PhaseLegacy {
		rt.Phase--
		rt.CanaryPercent = 0
	}
}
 
// recorder 把响应先缓冲起来,为了实现"新实现失败就降级回老实现"。
type recorder struct {
	code int
	body bytes.Buffer
	hdr  http.Header
}
 
func (rec *recorder) Header() http.Header {
	if rec.hdr == nil {
		rec.hdr = http.Header{}
	}
	return rec.hdr
}
func (rec *recorder) Write(b []byte) (int, error) { return rec.body.Write(b) }
func (rec *recorder) WriteHeader(c int)           { rec.code = c }
 
func sameBucket(key string, percent int) bool {
	h := fnv.New32a()
	h.Write([]byte(key))
	return int(h.Sum32()%100) < percent
}
 
func (s *Strangler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	s.mu.RLock()
	rt, ok := s.routes[r.URL.Path]
	phase, pct := PhaseLegacy, 0
	if ok {
		phase, pct = rt.Phase, rt.CanaryPercent
	}
	s.mu.RUnlock()
 
	if !ok {
		http.NotFound(w, r)
		return
	}
 
	switch phase {
	case PhaseLegacy, PhaseShadow:
		// 主路径永远是老实现。新实现跑在影子 goroutine 里,只看不采信。
		// 好处:你可以提前三个月把新实现放上去跑,
		// 而业务方完全不知道你在重构。
		if phase == PhaseShadow && rt.Modern != nil {
			go func() {
				rec := &recorder{}
				defer func() { _ = recover() }() // 影子 panic 与主链路无关
				rt.Modern.ServeHTTP(rec, r.Clone(r.Context()))
			}()
		}
		rt.Legacy.ServeHTTP(w, r)
 
	case PhaseCanary:
		if sameBucket(r.Header.Get("X-Order-Id"), pct) {
			rt.Modern.ServeHTTP(w, r)
			return
		}
		rt.Legacy.ServeHTTP(w, r)
 
	case PhasePrimary:
		// 新实现为主,但它失败(5xx)就静默降级回老实现。
		// 用户完全感知不到,但降级次数会被记下来,
		// 降级率一旦异常,这条路由会被自动回退。
		rec := &recorder{}
		rt.Modern.ServeHTTP(rec, r)
		if rec.code >= 500 {
			s.stat.mu.Lock()
			s.stat.fallbackCount[rt.Pattern]++
			s.stat.mu.Unlock()
			rt.Legacy.ServeHTTP(w, r)
			return
		}
		for k, v := range rec.hdr {
			w.Header()[k] = v
		}
		w.WriteHeader(rec.code)
		_, _ = w.Write(rec.body.Bytes())
 
	case PhaseDone:
		rt.Modern.ServeHTTP(w, r)
	}
}

这是进度计算——王总要的那个数:

// ---- 进度:一个数字,加一个日期 ----
 
type WeeklySnapshot struct {
	Week    int
	Weighted float64 // 当周的加权完成度
}
 
type Progress struct {
	Total       int
	ByPhase     map[Phase]int
	Weighted    float64 // 0-1
	Velocity    float64 // 最近 4 周,平均每周推进多少
	ETC         time.Time
	BlockedList []string // 处于"灰度中"超过 3 周还没推进的路由
}
 
func (s *Strangler) Progress(history []WeeklySnapshot) Progress {
	s.mu.RLock()
	defer s.mu.RUnlock()
 
	p := Progress{Total: len(s.routes), ByPhase: map[Phase]int{}}
	for _, rt := range s.routes {
		p.ByPhase[rt.Phase]++
		p.Weighted += phaseWeight[rt.Phase]
	}
	if p.Total > 0 {
		p.Weighted /= float64(p.Total)
	}
 
	// 速度用最近四周的移动平均。
	// 为什么不用总平均?因为刚开始那几周你在搭基础设施,速度接近 0,
	// 用总平均会永远低估你现在的速度,让你看起来要干到明年。
	n := len(history)
	if n >= 2 {
		from := n - 4
		if from < 0 {
			from = 0
		}
		first, last := history[from], history[n-1]
		weeks := n - 1 - from
		if weeks > 0 {
			p.Velocity = (last.Weighted - first.Weighted) / float64(weeks)
		}
	}
	if p.Velocity > 0 {
		remaining := (1.0 - p.Weighted) / p.Velocity
		p.ETC = time.Now().AddDate(0, 0, int(remaining*7))
	}
	return p
}
 
func (p Progress) String() string {
	etc := "无法推算(当前速度为 0)"
	if p.Velocity > 0 {
		etc = p.ETC.Format("2006-01-02")
	}
	return fmt.Sprintf(
		"迁移进度 %.1f%%(%d 个接口)  预计完成 %s\n"+
			"  已完成 %d  新实现为主 %d  灰度中 %d  影子比对 %d  未开始 %d",
		p.Weighted*100, p.Total, etc,
		p.ByPhase[PhaseDone], p.ByPhase[PhasePrimary], p.ByPhase[PhaseCanary],
		p.ByPhase[PhaseShadow], p.ByPhase[PhaseLegacy],
	)
}

最后是优先级——先还哪笔债?

// ---- 债务账本:先还哪一笔 ----
 
// DebtEntry 记录一块代码的"年化利息"。
//
// 注意这里没有"代码质量"这个字段。
// 这是刻意的:技术债的优先级,不应该按"代码有多烂"排,
// 应该按"它每年吃掉我们多少小时"排。
// 一块写得极烂但三年没人碰的代码,它的利息是零——
// 你重构它,一分钱都拿不回来。
type DebtEntry struct {
	Route               string
	ChangesPerYear      int     // 过去 12 个月被改动的次数
	ExtraHoursPerChange float64 // 每次因为耦合多花的小时数
}
 
// AnnualInterestHours 是这块代码每年吃掉的时间。
// 这就是它的利率,单位是"小时/年"。
func (e DebtEntry) AnnualInterestHours() float64 {
	return float64(e.ChangesPerYear) * e.ExtraHoursPerChange
}
 
type DebtLedger struct {
	entries map[string]DebtEntry
}
 
// Priority 按年化利息降序,过滤掉已经迁移完的。
func (l *DebtLedger) Priority(done map[string]bool) []DebtEntry {
	out := make([]DebtEntry, 0, len(l.entries))
	for _, e := range l.entries {
		if done[e.Route] {
			continue
		}
		out = append(out, e)
	}
	sort.Slice(out, func(i, j int) bool {
		return out[i].AnnualInterestHours() > out[j].AnnualInterestHours()
	})
	return out
}
 
func (l *DebtLedger) TotalInterestHours() float64 {
	var t float64
	for _, e := range l.entries {
		t += e.AnnualInterestHours()
	}
	return t
}

跑起来:

func main() {
	s := NewStrangler()
	ledger := &DebtLedger{entries: map[string]DebtEntry{}}
 
	// 模拟 112 个接口
	for i := 0; i < 112; i++ {
		pattern := fmt.Sprintf("/fulfill/api/v1/handler%03d", i)
		s.Register(&Route{
			Pattern: pattern,
			Phase:   PhaseLegacy,
			Legacy:  static("legacy:" + pattern),
			Modern:  static("modern:" + pattern),
		})
		ledger.entries[pattern] = DebtEntry{
			Route:               pattern,
			ChangesPerYear:      1 + i%12,   // 有的接口一年改 12 次,有的一年改 1 次
			ExtraHoursPerChange: 1.5 + float64(i%7),
		}
	}
 
	// 按债务利息排序,取前 12 个优先迁移
	priority := ledger.Priority(map[string]bool{})
	fmt.Printf("债务账本:共 %d 个接口,年化利息总计 %.0f 小时\n\n",
		len(ledger.entries), ledger.TotalInterestHours())
 
	fmt.Println("年化利息最高的 8 个接口(优先迁移):")
	for i, e := range priority {
		if i >= 8 {
			break
		}
		fmt.Printf("  %-34s 年改 %2d 次 × 每次多花 %.1fh = %.0f h/年\n",
			e.Route, e.ChangesPerYear, e.ExtraHoursPerChange, e.AnnualInterestHours())
	}
 
	// 前四周:只做影子比对,进度几乎看不到
	history := []WeeklySnapshot{}
	for week := 1; week <= 4; week++ {
		for i, e := range priority {
			if i >= 3*week {
				break
			}
			_ = s.Advance(e.Route) // 每周推三条:影子 → 灰度 → 放量
		}
		p := s.Progress(history)
		history = append(history, WeeklySnapshot{Week: week, Weighted: p.Weighted})
	}
 
	fmt.Println()
	fmt.Println(s.Progress(history).String())
}
 
func static(body string) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, body)
	})
}

输出:

债务账本:共 112 个接口,年化利息总计 3184 小时

年化利息最高的 8 个接口(优先迁移):
  /fulfill/api/v1/handler083  年改 12 次 × 每次多花 7.5h = 90 h/年
  /fulfill/api/v1/handler034  年改 11 次 × 每次多花 7.5h = 82 h/年
  /fulfill/api/v1/handler047  年改 12 次 × 每次多花 6.5h = 78 h/年
  /fulfill/api/v1/handler069  年改 10 次 × 每次多花 7.5h = 75 h/年
  /fulfill/api/v1/handler082  年改 11 次 × 每次多花 6.5h = 72 h/年
  /fulfill/api/v1/handler020  年改  9 次 × 每次多花 7.5h = 68 h/年
  /fulfill/api/v1/handler104  年改  9 次 × 每次多花 7.5h = 68 h/年
  /fulfill/api/v1/handler011  年改 12 次 × 每次多花 5.5h = 66 h/年

迁移进度 5.9%(112 个接口)  预计完成 2027-09-12
  已完成 0  新实现为主 3  灰度中 6  影子比对 3  未开始 100

注意最后这两行。

“已完成 0”,但”迁移进度 5.9%”。

这就是加权进度的意义:十二条路由已经动了,其中三条已经切到以新实现为主、六条在灰度、三条在影子比对。真金白银的工作量已经投进去了,但如果你的进度口径是”已下线接口数 / 总接口数”,那么你汇报给王总的数字是——

零。

连续四周汇报零,第五周你的预算就没了。

这段代码里,有四个决定是 P8 才会做的。

第一个决定:进度不是布尔值,是加权的。

上面那个 5.9%,来自十二条已经动过的路由。其中没有一条”完成”,但每一条都投了真的工作量。

你不是在汇报进度,你是在维护”他愿意继续等”这件事。而”愿意继续等”,取决于他能不能看见东西在动。

第二个决定:进度必须有速度,有速度就有日期。

“迁移进度 5.9%“这个数字,王总看不懂。他不知道 5.9% 是快还是慢。

但”预计完成 2027-09-12”,他一秒就懂了——因为太晚了。

有了这个日期,你们才能开始真正的对话:要么加人,要么缩范围,要么接受这个日期。

一个没有日期的进度条,是一个没有人负责的进度条。

顺带说一句,那个速度用的是最近四周的移动平均,不是总平均。为什么?

因为前四周你在搭灰度、对账、影子比对的基建,加权进度几乎是平的。如果用总平均,你第八周的速度会被前四周拖累,算出来的 ETC 会荒谬地晚,然后你就失去了可信度。

第三个决定:先还哪笔债,不看代码烂不烂,看利息高不高。

DebtEntry 里没有”圈复杂度”、“重复代码率”、“测试覆盖率”。这些指标的问题是:它们衡量的是债务的”难看程度”,不是债务的”成本”。

一块 2019 年写的、没人敢碰的、两万行的代码,如果过去一年只被改动过一次,那它的年化利息是 6.5 小时。你花三个月重构它,年化回报是 6.5 小时。

而 handler011 一年改 12 次、每次多花 5.5 小时,年化利息 66 小时。你花一周迁移它,年化回报 66 小时。

技术债的优先级 = 改动频率 × 每次的额外成本。

这个公式还有第二个用处:它告诉你什么时候不该还债。

一个冷模块的债,永远不该还。你要做的是给它加个”结冰”标记,然后在它旁边写清楚”这块代码没人懂,改之前先找我”。

第四个决定:Advance 和 Rollback 是唯一的入口。

为什么要有 Advance 这个函数,而不是直接改 rt.Phase?

因为迁移状态机上的每一次跃迁,都应该有前置条件。你不能在没做过影子比对的情况下进灰度;你不能在降级率异常的情况下继续放量。

把这些校验放在一个地方,你就有了一个”迁移的规则”。而这个规则,是团队里所有人共享的——包括半年后接手的人,包括小周。

P8 造的东西,是让 P5 也能安全地做危险操作。


老陈的追问

一、你手上最想重构的那块代码,过去 12 个月被改动过几次?

如果你答不上来,那说明你想重构它的理由,可能不是”它拖慢了我们”,而是”它让我看着难受”。

这两个理由都对,但只有一个能拿到预算。

二、如果明天有人给你三个月,让你把最想重构的东西重构完,你敢接吗?

我换个问法:三个月后,你拿什么证明这三个月花得值?

如果你想到的是”代码更整洁了”、“新人上手更快了”,那你这三个月换来的东西,在别人的预算表上等于零。

三、你有没有算过,你团队的”交付税”是多少?

就是那个数字:一个需求的实际工期除以估算工期。

不算不知道。算了之后你可能会发现,你团队一半的时间不是在写新东西,是在和旧东西搏斗。而这件事,你的老板完全不知道。

四、你说”这块代码太烂了”的时候,你说的是它的质量,还是你的感受?

一块代码让你难受,和一块代码让团队变慢,是两件事。

前者是你的审美,后者是业务的成本。分不清这两件事的人,一辈子都在申请预算,一辈子都在被拒绝。


生活思考题

题一:为什么明知道外卖不健康,你还是会点?

你知道外卖油大、盐多、食材来源不明。你也知道做饭更健康、更省钱。

但你还是点。

而且你点的时候,心里是有愧疚的。点完之后看着那个餐盒,还会想”明天开始自己做饭”。

这个”明天开始”,为什么几乎永远不会来?

参考答案

第一层:大多数人会怎么答

“懒”、“累”、“下班太晚了”、“做饭洗碗太麻烦”。

第二层:这个答案为什么不够

我们来算一笔账。

点一份外卖,均价 28 块。从下单到吃上,25 分钟。吃完要收拾餐盒,5 分钟。

自己做饭,两人份的食材成本大概 18 块,人均 9 块。做一顿饭,从洗菜到出锅,40 分钟。吃完洗碗,10 分钟。

所以账是这样的:

钱时间
外卖28 元30 分钟
做饭9 元50 分钟

你用 20 分钟,换了 19 块钱。

你的时薪如果是 60 块,那这 20 分钟值 20 块。你自己做饭,是亏的。

那你为什么还有愧疚感?

因为你在用两套不同的标准算同一件事:

  • 算钱的时候,你用的标准是”食材成本”——9 块 vs 28 块,差三倍,你亏了。
  • 算时间的时候,你用的标准是”这件事累不累”——做饭累,点外卖不累,点外卖赢了。

你从来没有把这两样东西放到同一个单位里比过。

第三层:更深的一层是什么

真正的问题在于:“健康”这个收益,你不会算。

因为它是延迟的、概率性的、且不可见的。

外卖吃多了会怎样?大概率什么都不会怎样。可能在十年后体检报告上多两个箭头,可能什么都不发生。

你的大脑对这种”远期概率性损失”的估值,接近于零。这不是意志力问题,这是人类大脑的硬件设计——我们这套系统进化出来是为了应对”今天有没有饭吃”,不是为了应对”十年后会不会三高”。

于是就出现了一个非常荒谬的局面:

你在为一个你感受不到的收益(十年后的健康),对抗一个你每天都能感受到的成本(今天的 20 分钟和一顿饭的口味)。

这场仗你打不赢。不是因为你弱,是因为战场选错了。

那些真的自己做饭的人,他们不是意志力比你强。他们是换了一个战场——

他们做饭的理由不是”健康”,是”我今天想做那道菜”、“我老婆/老公喜欢吃这个”、“这是我一天里唯一不看手机的时间”。

他们把一场对抗远期损失的苦役,换成了一场即时获得奖励的活动。

第四层:落到 Go 工程师身上

这就是技术债申请预算之所以难的真正原因。

你跟王总说”我们要还技术债”,你说的是”十年后的健康”。

王总听到的是”今天的 20 分钟”。

你不是输在道理上,你是输在收益的时间尺度上。

具体到行动,三条:

  1. 永远不要用”技术债”这个词去申请资源。 这个词在业务方耳朵里的意思是”你们自己搞出来的麻烦,现在要我们买单”。换成具体的:“这个服务上,我们一年多花 217 个人天。”

  2. 把远期收益拆成近期可验证的里程碑。 不要说”一年后交付效率提升”,要说”到 6 月 30 日,handler011 这个接口的改动,从 9 天降到 3 天”。这个你能验证,他也能验证。

  3. 最重要的一条:找一个”即时奖励”挂在还债这件事上。

    小林那 18% 的产能,他给自己团队找的即时奖励是什么?

    是那个每周五发给王总的数字。

    你可能觉得这很功利。但事实是:小周和大刘之所以愿意每周花一天做迁移(而不是写新功能,写新功能多爽),就是因为那个数字每周都在涨,而且王总每周都会回一个”收到”。

    人在看不到进度的事情上,坚持不过六周。

    所以如果你要带团队做一件长期的事,第一件事不是定计划,是设计一个每周都会变化的、能被别人看见的数字。

    这个数字本身就是奖励。


题二:为什么你家的杂物越整理越多?

你有没有这种经历:周末花了一下午整理衣柜或者书房,扔掉两大袋东西,收拾完看着清爽多了。

然后三个月后,它又满了。

而且你隐约觉得,扔掉的那些东西里,有一两样你现在需要。

为什么”整理”这件事,永远是一次性的?

参考答案

第一层:大多数人会怎么答

“东西太多”、“收纳空间不够”、“家里人乱放”。

于是解决方案是:买更多的收纳盒、换更大的房子、贴标签、定规矩”用完放回原处”。

第二层:这个答案为什么不够

这些方案有个共同假设:问题在于”放的地方不够好”。

但真正的规律是:一个系统的混乱程度,和它的容量无关,和它的”流入速度 / 流出速度”有关。

你想想你家的快递包裹:每周进来几个?

一个三口之家,一周收到的包裹大概 5 到 8 个。一个月 25 个,一年 300 个。

而你一年扔掉多少东西?如果你一年整理两次,每次扔两大袋,一袋按 15 件算,一年 60 件。

进 300,出 60。

你整理得再好,你也是在往一个漏水的池子里加水。

收纳盒解决的是”怎么放得更整齐”,它完全不解决”进得比出得快”。

第三层:更深的一层是什么

整理术(不管是近藤麻理惠还是别的什么)教你的是:一次性地做一次正确的分类决策。

但这个思路有一个致命的漏洞:它假设你的决策是永久有效的。

而实际上,一件东西”有没有用”,是随时间变化的。

你 2019 年买的那个显示器支架,当时有用(你用笔记本电脑外接显示器)。2021 年你换了工作,公司配了显示器,支架没用了。但它在柜子里,你不知道它没用了,因为你没有定期重新评估它的机制。

所以真正的解法不是”整理”,是**“建立流出机制”**。

日本有一种做法叫”30 天箱子”:拿一个箱子,把你不确定的东西放进去,封上,写上日期。30 天后,如果你没打开过这个箱子,整箱扔掉,不打开看。

这个机制的精妙之处在于:它把”判断一件东西有没有用”这个心理成本极高的动作,变成了”30 天后我有没有想起它”这个成本为零的动作。

你不需要做判断,时间会替你做。

第四层:落到 Go 工程师身上

这条在技术上的对应物,叫自动下线(automated deprecation)。

大多数团队的代码库、接口、定时任务、数据表,都只有”流入”没有”流出”。

一个接口上线了,就永远在那儿。你不知道还有没有人调用它,所以你不敢删。于是不敢删 → 它一直在 → 它一直在,新人就会用它 → 用它的人更多了 → 更不敢删。

这就是你家的衣柜。

具体到 Go 工程实践,三个可落地的机制:

  1. 给每个对外接口加”最后一次被调用”的时间戳。 这个不需要复杂的基础设施,一个带 TTL 的 Redis key 就够了:
// 在中间件里给每个路由记一笔"今天有人调用"
func touchLastUsed(route string) {
    key := fmt.Sprintf("api:lastused:%s", route)
    // TTL 90 天:如果 90 天没人调用,这个 key 会自己消失,
    // key 消失 = 这个接口 90 天没人用 = 候选下线对象
    _ = rdb.Set(ctx, key, time.Now().Unix(), 90*24*time.Hour).Err()
}

然后写一个每周跑的脚本,列出所有 key 已消失的路由。

这个机制的威力不在技术,在心理:它让”删掉它”这个动作,从”我要做一个判断”变成了”系统告诉我它可以删了”。

  1. 给数据表、字段、配置项的注释里加”下线条件”。 格式固定:

    -- deprecated_candidate
    -- 下线条件:商家自发货功能全量后 30 天(预计 2026-09-01)
    -- 负责人:小林
    -- 如果到 2026-10-01 这条注释还在,请直接联系负责人确认
    warehouse_type TINYINT COMMENT '历史字段,见 ADR-007'

    关键在最后那句”如果到某天这条注释还在”。它是一个会自动过期的承诺。

  2. 最重要的一条:给”删代码”这件事,在你的团队里建立正反馈。

    小林后来在组里搞了个规矩:每周五下午的周会上,有五分钟叫”本周删掉了什么”。

    第一次没人说话。第二次小周说他删了一个没人用的 util 函数,两百行。小林当场说了一句”这个删得好,这个函数的注释我看了一年都没看懂”。

    从那之后,这五分钟就成了组里最热闹的五分钟。

    因为”我写了多少代码”从来不被表扬,而”我删了多少代码”被表扬了。

    而一个团队的代码库会不会腐烂,就看这一件事:删代码的人,是被当成英雄,还是被当成不干正事的人。


5.4 没有管辖权时,怎么让三个部门配合你(跨部门推动的真实逻辑)

老赵看了他一眼,说”这个我说了不算”

四月二十八号,周二上午十点,小林去了趟仓库。

那是公司在郊区租的一个仓,开车四十分钟。老赵是仓储负责人,五十岁上下,说话带点口音,办公室在仓门口一间隔出来的板房里,桌上放着一台老式显示器,屏幕上是 WMS 系统的界面。

小林坐下,寒暄了两句,然后进入正题。

“赵哥,我这次来,是想跟您聊聊 618 的事。”

“嗯。”

“今年我们的目标,是把履约超时率从 8.9% 降到 3.4%。”

“那是你们的目标。”

小林顿了一下。

“我拆了一下,8.9 里面,仓储环节占了 3.1 个点,是最大的一块。”

“3.1 个点。“老赵重复了一遍,“多少单?”

“去年大促期间,三万八千单左右。”

“三万八千单。“老赵靠在椅背上,“小林,你知道我去年 618 是什么情况吗?”

”……”

“我那个仓,设计日处理量是四万单。去年 618 峰值那天,进来六万二。我的人从早上六点干到第二天凌晨两点,拣货的师傅在过道上跑,打包的台上堆到天花板。”

“我知道。”

“你不知道。“老赵说,“你不知道那天我有两个人累到在洗手间吐了。你不知道我第三天有三个人请假不来了。你不知道我为了凑人手,临时从劳务市场招了四十个人,培训一天就上岗,出错率是平时的五倍。”

小林没说话。

“现在你跟我说,你要我降 3.1 个点。“老赵说,“我要怎么做?招人?加设备?那钱谁出?”

“这个……我们可以一起算一下。”

“今年预算批下来了吗?”

“没有。”

“那我们算什么?”

小林坐了大概二十分钟,然后开车回去了。

回去的路上他给老陈打电话,讲了这段对话。

讲完,老陈问了一句:

“你去找老赵,你说的是’帮我’,还是’给你’?”

”……什么意思?”

“你从头到尾,说的话里,主语是谁?”

小林想了想。

“我说的是’我们的目标是 8.9 降到 3.4’。”

“‘我们’。“老陈说,“这个词在跨部门沟通里,是最没用的一个词。”

“为什么?”

“因为老赵心里清楚,‘我们’这个词的真实含义是:你来干活,我去邀功。”

”……”

“小林,你告诉我,老赵今年的 KPI 是什么?”

“我不知道。”

“你连他的 KPI 都不知道,你去跟他谈什么?”

“那我去问?”

“你去问,他也不会告诉你。“老陈说,“但是你能猜。”

“怎么猜?”

“你想想,一个仓储负责人的成本结构是什么。”

“人力、场地、设备。”

“对。人力是大头。而人力的成本,取决于什么?”

“取决于……订单量?”

“取决于订单量的波动。”

老陈停了一下。

“老赵最痛苦的事情,不是’订单多’。订单多他可以招人。他最痛苦的是’订单忽多忽少’——大促这三天进六万单,大促过后回落到两万。他要按六万的峰值配人,那剩下三百六十天他都在养闲人;他按两万配人,那三天他就会爆。”

“所以呢?”

“所以你给他的方案,如果只是在大促三天让他’再多干一点’,那是在往他的痛点上踩。”

“那我该给他什么?”

“我哪知道。“老陈说,“我又没见过老赵。”

”……”

“但我可以告诉你一个判据。”

“什么判据?”

“你要让他的峰值变平,而不是让他的峰值变高。”

“小林,你手里有一个别人都没有的东西。”

“什么?”

“你能看见未来三天的订单。”

小林握着方向盘,愣了两秒钟。


老陈的故事:六楼的老太和一楼的老张

那天晚上,小林去了老陈家。老陈做了几个菜,开了一瓶黄酒。

“我给你讲个事。“老陈倒了杯酒,“我爸妈住那个小区,九十年代的房子,六层,没电梯。”

“四年前,楼里要装电梯。”

“六楼住个老太太,七十九,膝盖不行,一年下不了几次楼。她儿子是这事的牵头人,愿意出大头。”

“一楼住个老张,五十四五,开个小五金店。他不同意。”

“理由是什么?”

“三条:第一,电梯井挡采光,他家客厅本来就朝北;第二,施工要挖地基,他怕影响房子结构;第三,他觉得装了电梯,六楼涨三十万,他一楼一分不涨,凭什么。”

“这三条都很实在。”

“对,都很实在。“老陈说,“他们僵持了两年。中间社区来调解过三次,没成。六楼老太太的儿子上门吵过一次,老张把门关了。”

“后来呢?”

“后来换了届业委会,新主任是个退休的中学教务主任,姓周。”

“周主任做了什么?”

“他第一件事,是去找了设计师,把方案改了。”

“原来的方案是砖混井道,宽两米二,贴着他家客厅窗户。改成了钢结构加玻璃幕墙,宽一米四,挪到了楼梯口那一侧,离他家窗户还有一米八。”

“他找人做了个日照分析,说采光影响从原来的挡 41% 降到 12%。”

“第二件事,他改了分摊方案。一楼不分摊建设费,也不分摊以后的电费维保费。”

“老张答应了吗?”

“没有。”

“啊?”

“没答应。“老陈夹了口菜,“周主任把这两件事做完,又去找老张,老张说’我再考虑考虑’,然后就没下文了。”

“那最后是怎么办成的?”

老陈笑了。

“周主任第三件事,跟电梯没关系。”

“什么事?”

“老张家卫生间漏水。”

”……”

“他家楼上,二楼那户,水管老化,往下渗。老张找了二楼三次,二楼说’我看了,不是我家的问题,是主管道’。物业说主管道归全体业主,要走维修基金,走流程要三个月。”

“老张家卫生间天花板那块,已经长毛了。”

“周主任知道这件事之后,干了什么?他自己掏钱找了个师傅,去二楼看了一次。师傅看明白之后说不是主管道,是二楼自己那截支管。”

“周主任把这个结论,加上师傅拍的照片,打印了一份,拿给二楼。二楼没话说,第二周就修了。”

“老张那块天花板,重新刷了一遍。”

“签字那天,老张说了句话。”

“什么话?”

老陈放下酒杯。

“他说:‘周主任,不是我同意装电梯。是我欠你个人情。’”

小林听着,没说话。

“小林,“老陈说,“你觉得这个故事里,周主任做对了什么?”

“他解决了老张真正的问题。”

“对,但不止。”

“还有呢?”

“他从来没跟老张讲过’电梯对大家好’这句话。”

“一次都没有。”

“前面两年,六楼的儿子、社区的人,跟老张讲了一百遍’装电梯对整栋楼都好’、‘这是惠民工程’、‘你也为老人家想想’。”

“这些话,每一句都在告诉老张一件事:你的利益不重要。”

“周主任一句都没说。他做的前两件事(改方案、改分摊),是在告诉老张:‘你的顾虑我听到了,我改了’。”

“但真正让老张签字的,是第三件事。”

“第三件事的性质不一样。它不是在解决老张对电梯的顾虑,它是在解决老张自己的生活。”

“小林,这里面的区别,是这件事的关键。”

“什么区别?”

“前两件事,是在’我们这件事’里给老张让利。第三件事,是在’老张的事’里帮他。”

“前者是谈判。后者是关系。”

“谈判能让对方’不反对’,关系才能让对方’愿意’。”

“这个道理放到公司里,就是——”

“你去推动一个跨部门的事,你能做成’对方不反对’已经很不容易了。但’不反对’和’主动’之间,差的是十倍的效率。”

“而让你从’不反对’走到’主动’的,从来不是你把方案讲得多清楚,是你有没有帮他解决过一件跟他无关的事。”

小林沉默了一会儿。

“老陈。”

“嗯。”

“我连老赵的 KPI 都不知道,我怎么帮他解决他自己的事?”

“你不知道,是因为你没问过。”

“我问了他会说吗?”

“你没问过,你怎么知道他不会说?“


那张看板

五月的第一个周五,小林做了一件事。

他写了一个定时任务,每天凌晨三点跑一次,把前一天的履约数据按归因口径拆开,生成一张表,自动发到一个群里的机器人。

那个群里有七个人:他、阿 May、大刘、老赵、供应链的孙姐、风控的老吴,还有一个他没想到的人——王总。

第一周五,机器人发了第一条消息。

【履约归因日报】5/2(周四)

大盘超时率  4.3%(日常基线)
归因拆解:
  仓储排队        1.62pp   →  归因部门:仓储(老赵)
  缺货等待补货    1.13pp   →  归因部门:供应链(孙姐)
  风控人工复审    0.58pp   →  归因部门:风控(老吴)
  系统卡单        0.31pp   →  归因部门:履约技术(小林)
  其他            0.66pp

环比昨日:持平
数据来源:fulfill-attribution(自动更新,无需人工维护)
口径说明:见 ADR-000 第三节

没有人说话。

第二周五,又发了一条。这次超时率涨到了 5.1%,因为有个小促。

还是没有人说话。

第三周的周二,老赵给小林打了个电话。

“小林,你那个数,怎么算的?”

“赵哥,哪个数?”

“仓储排队那个。1.62 个点。”

“哦,是这样——“小林讲了一遍归因口径。

讲完,老赵沉默了几秒钟。

“你这个数,比我自己的系统还准。”

“啊?”

“我这边看的是’仓内平均处理时长’。你这个数看的是’因为仓内排队而导致超时的订单占大盘的比例’。这两个不是一回事。”

“对。”

“我上个月有三天,平均处理时长是正常的,但是超时单特别多。“老赵说,“我一直以为是你们系统的问题。”

“按你这个口径,是不是因为——”

“是因为分布。“小林说,“您看的是均值,均值掩盖了尾部。那三天里,大部分订单还是正常的,但是有一批 SKU 集中爆单,那批 SKU 的拣货路径特别长,堵在了一起。”

”……”

“赵哥,我把那三天的明细捞出来了,您要不要看一下?”

“你发我。”

那天下午,小林把一份 Excel 发给了老赵。里面有那三天里,超时订单的 SKU 分布、拣货路径长度、以及每个时段的仓内积压量。

老赵第二天回了个消息:“这个东西能不能每天给我?”

“可以。”

“那你加上我那个 WMS 的仓内积压数据,我给你开个只读账号。”


五月的第三个周五,小林在群里发了一条不一样的消息。

【618 产能预排】基于当前订单预测(置信度:中)

预测峰值日:6/18,预计单量 6.8 万

按当前产能模型(老赵 5/12 提供),华东仓设计日处理 4.0 万,
预计缺口 2.8 万单/日。

缺口消化方案(三个选项):

  A. 峰值日加班:需临时工 45 人,成本约 3.4 万,风险:熟练度低,出错率 +400%
  B. 前置履约(推荐):6/15-6/17 提前发出预售订单,
     预计可前置 1.9 万单,峰值日缺口降至 0.9 万,
     需临时工 15 人,成本约 1.1 万
  C. 分流至武汉仓:可分流 1.2 万单,但平均时效 +8 小时,
     预计新增超时 0.4pp

推荐 B。B 方案需要:
  - 供应链(孙姐):6/12 前提供预售订单清单及 SKU 备货确认
  - 履约技术(我):6/10 前上线"预售订单提前下发"开关
  - 仓储(老赵):6/15-6/17 每天增加两个晚班时段

三个选项的成本和收益我都列在上面了。如果周三之前没人反对,我按 B 推进。

发完之后,小林关了电脑回家。

第二天早上九点,他打开手机,看到三条消息。

孙姐:“预售清单我今天拉,明天给你。”

老赵:“晚班我安排,但是你那个预售订单要分批下发,一次全下来我接不住。”

老吴(风控):“我没意见,不过预售订单的风控规则要单独配,你找我的人对一下。”

小林盯着这三条消息看了很久。

他给老陈发了条微信:“成了。”

老陈回复:“你做了什么?”

“我发了一条群消息。”

“就这一条?”

“就这一条。”

老陈回了一个字:“嗯。”

然后过了两分钟,又发来一条:

“你知道你做成这件事,是因为什么吗?”

“因为我算得准?”

“不是。”

“那是因为什么?”

“因为你让三个人看见了同一件事。”

“在此之前,老赵看见的是’仓爆了’,孙姐看见的是’缺货了’,老吴看见的是’有风险订单’。他们每个人看见的都是自己的那一块,而且都觉得是别人的问题。”

“你做了一件事:把这三个人的痛苦,放到了同一张表上,还给了他们一个共同的单位——百分点。”

“当老赵在这张表上看见’缺货占了 1.13 个点’的时候,他第一次意识到:他的爆仓,有一部分是孙姐造成的。”

“当孙姐看见’排队占 1.62 个点’的时候,她也第一次意识到:她补货补慢了,代价是老赵的人要通宵。”

“你没有推动任何事。你只是让所有人都看见了同一件事。”

小林看着这段话,想了一会儿。

“老陈,那我前面那三周,那个每天发的日报,是在干嘛?”

“你在等。”

“等什么?”

“等他们自己发现那张表有用。”

“你如果第一天就把那个’618 产能预排’发出去,谁会理你?”

”……没人。”

“对。因为那时候你在他们眼里,是’那个来要我干活的小林’。”

“三周的日报发出去之后,你在他们眼里变成了什么?”

“变成了……’那个发数据的’。”

“不。“老陈说,“你变成了一个每天都在出现的东西。”

“这个区别很大。‘那个来要我干活的小林’是一个请求,请求可以被拒绝。‘每天都在出现的那个表’是一个事实,事实不可以被拒绝,只能被使用。”

“老赵给你打电话那天,他不是被你说服的。他是已经被那张表改变了看法,然后来找你确认。”

“你从头到尾没有说服过任何人。“


Go 实例:把口头约定变成会红的 CI

跨部门协作的真正成本,不在会议室里,在接口上。

小林和老赵的 WMS 之间,有一个接口:履约系统查询仓的实时产能。这个接口是去年做的,当时是”口头约定 + 一个 Wiki 文档”。

Wiki 文档的最后更新时间是去年八月。

第一版:手写客户端 + 靠拉群排查

package main
 
import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"time"
)
 
// 仓储 WMS 的产能查询接口。
// 字段含义写在 Wiki 上(最后更新:去年八月)。
type WMSCapacityClient struct {
	baseURL string
	cli     *http.Client
}
 
func (c *WMSCapacityClient) Query(warehouseID string) (map[string]interface{}, error) {
	resp, err := c.cli.Get(c.baseURL + "/api/capacity?wh=" + warehouseID)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
 
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}
 
	// 用 map 接,因为"不知道对方会返回什么"
	var m map[string]interface{}
	if err := json.Unmarshal(body, &m); err != nil {
		return nil, err
	}
	return m, nil
}
 
func main() {
	c := &WMSCapacityClient{
		baseURL: "http://wms.internal",
		cli:     &http.Client{Timeout: 2 * time.Second},
	}
	m, _ := c.Query("WH-EAST-01")
 
	// 用的时候才知道字段在不在
	if v, ok := m["capacity"].(float64); ok {
		fmt.Println("产能:", v)
	} else {
		fmt.Println("字段没了") // 去年十月发生过一次
	}
}

这段代码的问题不是”用 map 不优雅”。

问题是:当你和另一个团队的协作出问题时,你们只能在出事之后、在群里、靠人肉对齐。

去年十月出过一次事:WMS 那边把 capacity 改成了 available_capacity,忘了通知。履约这边 ok 为 false,走到 else 分支,然后产能被当成 0 处理,于是一大批订单被判定为”仓爆了”,改派到了武汉仓。

从上线到被发现,中间隔了十九个小时。

而发现的方式是老赵打电话过来问:“你们今天为什么给我发了三千单到武汉?”

第二版:契约文件 + CI 里会红的兼容性检查

// capacity_client.go —— 履约侧(消费方)的代码
package main
 
import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"sort"
	"strings"
	"time"
)
 
// ---- 契约定义 ----
 
// JSONType 用 JSON 的原始形态判断类型。
// 为什么不用 reflect?因为我们要检查的是"线上真实返回的字节",
// 不是我们本地定义的 struct。两边脱钩才是重点。
type JSONType string
 
const (
	TypeNumber JSONType = "number"
	TypeString JSONType = "string"
	TypeBool   JSONType = "bool"
	TypeObject JSONType = "object"
	TypeArray  JSONType = "array"
	TypeNull   JSONType = "null"
	TypeUnknown JSONType = "unknown"
)
 
// Field 是契约里的一个字段。
// Required 是双向的:
//   - 对提供方:Required 字段不能删,不能改类型
//   - 对消费方:Required 字段不能假设它不存在
type Field struct {
	Name     string   `json:"name"`
	Type     JSONType `json:"type"`
	Required bool     `json:"required"`
	Owner    string   `json:"owner"` // 这个字段的语义责任人是谁
}
 
// SLO 是写在契约里的服务水平。
// 它存在的意义是:把"我们口头说过 P99 一百毫秒"变成
// 一个可以被机器检查的东西。
type SLO struct {
	P99Millis    int     `json:"p99_ms"`
	Availability float64 `json:"availability"`
	MaxQPS       int     `json:"max_qps"`
	Contact      string  `json:"contact"` // 违约了找谁
}
 
// Contract 是跨团队的接口契约。
// 这个文件放在一个独立的 contracts 仓库里,
// 提供方和消费方都要在 CI 里跑它。
type Contract struct {
	Service  string  `json:"service"`
	Endpoint string  `json:"endpoint"`
	Fields   []Field `json:"fields"`
	SLO      SLO     `json:"slo"`
}
 
// ---- 兼容性检查 ----
 
type Violation struct {
	Severity string // "FATAL" 或 "WARN"
	Field    string
	Message  string
}
 
func (v Violation) String() string {
	return fmt.Sprintf("[%s] %s: %s", v.Severity, v.Field, v.Message)
}
 
// inferTypes 从真实的响应字节里推断每个字段的类型。
func inferTypes(body []byte) (map[string]JSONType, error) {
	var raw map[string]json.RawMessage
	if err := json.Unmarshal(body, &raw); err != nil {
		return nil, err
	}
	out := make(map[string]JSONType, len(raw))
	for k, v := range raw {
		out[k] = typeOf(v)
	}
	return out, nil
}
 
func typeOf(raw json.RawMessage) JSONType {
	s := strings.TrimSpace(string(raw))
	switch {
	case len(s) == 0:
		return TypeUnknown
	case s[0] == '"':
		return TypeString
	case s[0] == '{':
		return TypeObject
	case s[0] == '[':
		return TypeArray
	case s == "true" || s == "false":
		return TypeBool
	case s == "null":
		return TypeNull
	default:
		return TypeNumber
	}
}
 
// CheckCompat 比较契约和线上真实响应。
//
// 判定规则(这部分是最值得和对方坐下来吵一架的):
//   - 契约里的 required 字段消失了          → FATAL,CI 必须红
//   - 契约里的字段类型变了                  → FATAL
//   - 响应里出现了契约中没有的字段          → WARN,提示更新契约
//   - 契约里的字段还在,但值是 null         → WARN
//
// 为什么"新增字段"只是 WARN 而不是 FATAL?
// 因为如果新增字段也红,对方每次发版都会被你的 CI 拦住,
// 三周之后他们就会想办法绕开这个检查——
// 一个会被绕开的检查,比没有检查更糟。
func CheckCompat(c Contract, body []byte) ([]Violation, error) {
	actual, err := inferTypes(body)
	if err != nil {
		return nil, err
	}
 
	var vs []Violation
	seen := make(map[string]bool, len(actual))
 
	for _, f := range c.Fields {
		seen[f.Name] = true
		got, ok := actual[f.Name]
		if !ok {
			if f.Required {
				vs = append(vs, Violation{"FATAL", f.Name,
					fmt.Sprintf("契约要求该字段存在(责任人:%s),但响应中已消失", f.Owner)})
			}
			continue
		}
		if got == TypeNull {
			vs = append(vs, Violation{"WARN", f.Name, "字段存在但值为 null"})
			continue
		}
		if got != f.Type {
			vs = append(vs, Violation{"FATAL", f.Name,
				fmt.Sprintf("类型变更:契约 %s,实际 %s(责任人:%s)", f.Type, got, f.Owner)})
		}
	}
 
	names := make([]string, 0, len(actual))
	for k := range actual {
		names = append(names, k)
	}
	sort.Strings(names)
	for _, n := range names {
		if !seen[n] {
			vs = append(vs, Violation{"WARN", n,
				fmt.Sprintf("响应中的新字段,契约未收录,请更新 %s 的契约文件", c.Service)})
		}
	}
	return vs, nil
}
 
// ---- 消费方:启动时的自检 ----
 
// CapacityClient 是真正使用的客户端。
// 关键设计:它在初始化的时候,拿契约去对一次真实响应。
// 对不上就 panic——不是"打个日志继续跑",是启动失败。
//
// 这一点很反直觉,但它是对的:
// 一个字段没了,你"打个日志继续跑",
// 那十九个小时的静默错误就会重演。
// 宁可启动不了,也不能带着错的配置跑。
type CapacityClient struct {
	baseURL string
	cli     *http.Client
	// capacity 是我们唯一关心的字段,直接从响应里取,不用 map
	capacityKey string
}
 
func NewCapacityClient(baseURL string, c Contract) (*CapacityClient, error) {
	cli := &http.Client{Timeout: 2 * time.Second}
	resp, err := cli.Get(baseURL + c.Endpoint)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
 
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}
 
	vs, err := CheckCompat(c, body)
	if err != nil {
		return nil, err
	}
	for _, v := range vs {
		if v.Severity == "FATAL" {
			return nil, fmt.Errorf("契约校验失败,拒绝启动:%s", v.String())
		}
		fmt.Println("契约告警:", v.String())
	}
 
	return &CapacityClient{baseURL: baseURL, cli: cli, capacityKey: c.Fields[0].Name}, nil
}
 
type Capacity struct {
	WarehouseID       string `json:"warehouse_id"`
	AvailableCapacity int    `json:"available_capacity"`
	UpdatedAt         int64  `json:"updated_at"`
}
 
func (c *CapacityClient) Query(warehouseID string) (*Capacity, error) {
	resp, err := c.cli.Get(c.baseURL + "/api/capacity?wh=" + warehouseID)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	var out Capacity
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, err
	}
	return &out, nil
}

这是 CI 里跑的那个测试——它跑在两个团队的流水线里:

// contract_test.go
package main
 
import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"os"
	"testing"
	"time"
)
 
// goldens 是对方提供的"真实响应样本"。
// 关键点:这个文件由提供方维护,消费方只读。
// 提供方每次要改响应结构,必须先改这个文件,
// 改了之后,消费方的 CI 会红——这就是"提前十九小时"的机制。
const goldenPath = "testdata/wms_capacity.golden.json"
 
func loadContract(t *testing.T) Contract {
	t.Helper()
	b, err := os.ReadFile("contracts/wms_capacity.json")
	if err != nil {
		t.Fatalf("读契约失败:%v", err)
	}
	var c Contract
	if err := json.Unmarshal(b, &c); err != nil {
		t.Fatalf("解析契约失败:%v", err)
	}
	return c
}
 
// TestProviderHonorsContract 由提供方(WMS 团队)跑。
// 它验证:我的服务返回的东西,符不符合我承诺过的契约。
func TestProviderHonorsContract(t *testing.T) {
	c := loadContract(t)
	golden, err := os.ReadFile(goldenPath)
	if err != nil {
		t.Fatalf("读 golden 文件失败:%v(这个文件应该由 WMS 团队维护)", err)
	}
 
	vs, err := CheckCompat(c, golden)
	if err != nil {
		t.Fatalf("golden 文件不是合法 JSON:%v", err)
	}
	for _, v := range vs {
		if v.Severity == "FATAL" {
			t.Errorf("违反契约:%s", v.String())
		}
	}
 
	// SLO 也在这里被验证:如果契约里写了 P99 100ms,
	// 那就得有一个基准测试真的去测这个数
	if c.SLO.P99Millis <= 0 || c.SLO.P99Millis > 1000 {
		t.Errorf("SLO 的 P99 值不合理:%d ms", c.SLO.P99Millis)
	}
}
 
// TestConsumerSurvivesGolden 由消费方(履约团队)跑。
// 它验证:拿到对方的 golden 响应,我的客户端能不能正常启动并解析。
// 这个测试的价值在于:对方改了结构,我这边第一时间知道,
// 而不是等到十九个小时后老赵打电话。
func TestConsumerSurvivesGolden(t *testing.T) {
	c := loadContract(t)
	golden, err := os.ReadFile(goldenPath)
	if err != nil {
		t.Fatalf("读 golden 文件失败:%v", err)
	}
 
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write(golden)
	}))
	defer srv.Close()
 
	cli, err := NewCapacityClient(srv.URL, c)
	if err != nil {
		t.Fatalf("客户端启动失败(契约不兼容):%v", err)
	}
 
	got, err := cli.Query("WH-EAST-01")
	if err != nil {
		t.Fatalf("查询失败:%v", err)
	}
	if got.AvailableCapacity <= 0 {
		t.Errorf("产能解析异常:%+v", got)
	}
}
 
// TestGoldenIsFresh 是这套机制里最不起眼、但最重要的一条。
// 它检查 golden 文件的更新时间——超过 90 天没人动,就告警。
//
// 为什么需要这个?因为契约文件的死法不是"被删掉",
// 是"没人再看它了"。一个三个月没更新过的契约,
// 和一份写在 Wiki 上的文档,没有任何区别。
func TestGoldenIsFresh(t *testing.T) {
	info, err := os.Stat(goldenPath)
	if err != nil {
		t.Fatalf("golden 文件不存在:%v", err)
	}
	if age := time.Since(info.ModTime()); age > 90*24*time.Hour {
		t.Errorf("golden 文件已 %d 天未更新,契约可能已失效", int(age.Hours()/24))
	}
}

这两条测试跑起来,如果对方把 available_capacity 改成了 capacity 且类型从数字变成了字符串,输出是这样的:

== 正常响应 ==
  无违反

== 对方改了字段名之后的响应 ==
  [FATAL] available_capacity: 契约要求该字段存在(责任人:老赵/WMS),但响应中已消失
  [WARN]  capacity: 响应中的新字段,契约未收录,请更新 wms 的契约文件

注意那行 FATAL 里的”责任人:老赵/WMS”。

这五个字,是这段代码的全部意义。

去年十月那次事故之后,拉群吵的四十分钟,本质上是在吵”这是谁的责任”。而契约文件把责任写进了代码里——不是为了让谁背锅,是为了让下一个改这个字段的人,在动手之前就知道自己在改什么。

这套东西里,值钱的不是测试,是三个制度设计。

第一个:契约文件由提供方维护,消费方只读。

这一条决定了责任归属。

如果契约由消费方维护,那就是”我在记录你做了什么”,这是一份观察报告。如果由提供方维护,那就是”我在承诺我会做什么”,这是一份合同。

同样一份 JSON,谁写,决定了它是什么。

第二个:新增字段只告警,删除和改类型才红。

这条看起来是技术细节,其实是政治判断。

如果你的检查太严,对方每次发版都被你拦,三周之内他们一定会想办法绕开——最常见的绕开方式是”先发版,契约测试下次再修”。

一个会被绕开的检查,比没有检查更糟。因为它给了你一种虚假的安全感。

判据是:这个检查的红,必须让对方觉得”它帮我挡住了一个真会出事的问题”,而不是”它又来烦我了”。

第三个:TestGoldenIsFresh。

这个测试看起来很傻——它检查的是”这个文件多久没被改过”。

但它是整套机制的生命线。

契约这种东西的死法,从来不是被人删掉,是被人忘掉。一个三个月没有被更新过的契约文件,跟一份写在 Wiki 上的文档没有任何区别。

你要防的不是”契约错了”,是”契约没人看了”。

还有一个更根本的东西,值得单独说:

这套机制真正解决的是什么问题?

表面上看,它解决的是”接口不兼容”的问题。

但它真正解决的,是**“谁有资格定义’正常‘“这个问题**。

去年十月那次事故,事后拉了个群,吵了四十分钟。老赵那边说”我们改了字段,在群里说过”;小林这边说”我们没看到”;老赵说”你们应该看群”;小林说”群消息两百条一天”。

这四十分钟里,双方在争的根本不是技术问题,是:“什么叫通知到了?”

而契约文件一旦存在,这个问题就消失了。因为”通知”这个动作,从”我在群里说了”变成了”我改了 golden 文件,CI 会告诉你”。

跨部门协作里,绝大部分精力不是花在做事上,是花在定义”什么叫做好了”上。

你的工作不是让对方答应你,是让”答应”这件事有一个不需要再讨论的定义。

老陈的追问

一、你要推动的这件事,对方的 KPI 是什么?如果你答不上来,你去过他那儿几次?

注意,我问的不是”你问过没有”,是”你去过几次”。

KPI 这种东西,很少有人会直接告诉你。但你去他办公室三次,看看他桌上摊着什么、他接电话时在说什么、他在为什么事发火——你就知道了。

二、过去半年,你有没有帮一个别的部门的人,解决过一件和你完全无关的事?

如果有,你现在去找他帮忙,成功率会高十倍。

如果没有,那你手上其实没有任何跨部门的资源,你有的只是”同事关系”。而”同事关系”在做一件难事的时候,约等于零。

三、你上次跨部门推动失败,你的结论是什么?

如果你的结论是”对方不配合”、“对方格局小”、“对方只顾自己部门利益”——那你要注意了。

这个结论有一个特点:它百分之百正确,而且百分之百没用。

它在保护你不用反思,同时也在保证你下次还会失败。

四、如果明天你负责的这件事,需要你求三个人帮忙,但你手上没有任何可以交换的东西,你会怎么办?

想清楚这个问题。因为这一幕一定会发生。


生活思考题

题一:为什么小区里最难达成共识的事,是修屋顶?

你住在一个老小区,顶楼漏水。要修屋顶,需要整栋楼分摊费用。

顶楼说:我家漏水,我要修。 五楼说:跟我没关系,我不出钱。 一楼说:我连屋顶长什么样都不知道,凭什么出钱。

业委会开了三次会,没谈成。最后屋顶没修,顶楼那家自己掏钱做了个局部防水,第二年又漏了。

为什么这种事这么难?明明修好了对整栋楼都有好处。

参考答案

第一层:大多数人会怎么答

“因为大家自私”、“因为没人愿意吃亏”、“因为缺乏公共意识”。

于是解决方案是:加强宣传、开更多的会、让社区介入、让党员带头。

第二层:这个答案为什么不够

我们来算一笔账,假设修屋顶要 6 万块,十二户分摊,一户 5000。

对顶楼来说:不修,家里的墙纸每年要重贴一次(1200 元),天花板长霉,房子如果要卖,中介说”这个状况要少卖 8 万”。修了,一次性付 5000。

顶楼的收益是巨大的。

对一楼来说:不修,什么损失都没有。修了,付 5000。

一楼的收益是零。

所以你跟一楼说”修屋顶对整栋楼都有好处”,这句话是对的,但对一楼来说,这个”好处”是——我这栋楼的整体价值提升了。

而这个提升,在他卖房之前,他一分钱都拿不到。

更关键的是,就算他明天卖房,房价涨的那部分里,有多少是”屋顶修好了”贡献的?他算不出来。

第三层:更深的一层是什么

这个问题的真正结构,不是”有人自私”,是:收益的分布和成本的分布不匹配。

修屋顶这件事,成本是平均分摊的(每户 5000),收益是极度集中的(顶楼拿走 95%)。

只要成本均摊 + 收益集中,这件事就永远谈不成。跟人品无关。

你换十二个活雷锋住进来,还是谈不成。因为每家都在想同一件事:我出了 5000,我拿回什么?

那么现实世界里,这种事最后是怎么解决的?三种:

第一种:改变收益分布——让顶楼多出。 顶楼出 3 万,剩下 3 万十一户分,一户 2700。这个方案能谈成,因为 2700 这个数字,在大多数人的”算了不值得为这个吵架”阈值以下。

第二种:改变成本分布——让一楼不出。 按楼层递减分摊:顶楼 1.2 万,十一楼 9000,……,一楼 0。这个方案也能谈成,但它有个副作用:一楼以后在别的事上也不出钱了,你们楼的公共事务会长期分裂成两派。

第三种:捆绑——把修屋顶和别的事绑在一起。 比如屋顶修的同时,顺便把外墙也刷了、把楼道灯全换成 LED 的、把单元门换成可视门禁。这时候一楼开始算了:单元门我也用,楼道灯我也用,那我出的这 5000 里,有一部分是我自己要的。

这三种方案,都不是在解决”谁自私”,都是在重新设计收益和成本的分布。

第四层:落到 Go 工程师身上

跨部门推动的事,99% 的难度都在这个结构上。

你做的技术中台、通用组件、统一规范、数据治理——它们全都是”成本由各部门分摊,收益由你(或你的老板)集中拿走”。

所以它们推不动。不是因为别人坏,是因为结构如此。

具体到行动,三条:

  1. 在动手之前,先画一张”成本-收益分布表”。 列出这件事涉及的所有团队,对每个团队写两个数:他要付出什么(人天/风险/学习成本),他能拿回什么(且必须可量化)。

    如果你发现有三行是”付出明确、收益为零”,那这件事你推不动,除非你改变结构。

  2. 永远不要试图用”对公司有好处”去说服一个部门。 这句话在组织结构里等于零,因为没有人因为”对公司有好处”而被考核。

    你要找的是:这件事能不能让他的某个具体指标变好看。 如果找不到,你要么改方案,要么放弃。

  3. 最重要的一条:学会”捆绑”。

    老陈那个电梯故事里,周主任做的第三件事就是捆绑——他把”装电梯”和”老张家的漏水”绑在了一起。

    在技术上,捆绑的典型做法是:你要推一个对别人没好处的事,就把它塞进一个对别人有大好处的事里。

    小林后来推”履约链路统一埋点”,各个部门都不积极——因为埋点对他自己没用。他怎么做的?他把埋点和”归因日报”绑在了一起:接入统一埋点的部门,第二天就能在日报里看到自己的归因数据;不接的,报表上那一栏是空的。

    两周之内,三个部门全接了。

    没有一个人是被”说服”的。他们只是不想自己的那一栏是空的。


题二:为什么你在外面吃饭,点菜总是别人点?

几个人聚餐,服务员把菜单递过来,大家推让一圈,最后总有一个人接过去点。

这个人大概率是固定的。下次还是他。

为什么是他?为什么不是你?

参考答案

第一层:大多数人会怎么答

“因为他会点”、“因为他熟悉这家店”、“因为我不好意思”。

第二层:这个答案为什么不够

你有没有注意到,那个点菜的人,几乎从不问大家”你们想吃什么”。

他接过菜单,扫一眼,然后开始报菜名:来个这个、再来个这个、这个不要、这个换一下。

而其他人呢?其他人全程没有一句反对。

哪怕其中一个人其实不吃辣,哪怕有人最近在忌口。他们不会说。最多在菜上来之后说一句”这个我不吃的啊”,然后自己扒拉米饭。

所以”点菜”这件事的真实结构是什么?

它不是”谁来选菜”,它是——谁来承担”这顿饭点得好不好”的责任。

你不想点,不是因为你不会点,是因为你不想在菜点得不好的时候,承担那三秒钟的尴尬。

而那个点菜的人,他承担了这个责任。作为回报,他获得了一种看不见的东西:在这顿饭上,他的意见是默认选项。

第三层:更深的一层是什么

这个结构里,藏着一个关于”权力”的真相:

在一个协作系统里,权力不是被授予的,是被承担的。

没有人任命那个点菜的人。他只是每次都先开口,于是所有人都默认了。

而如果他某次不在,你会发现那顿饭特别难开局——大家推让半天,最后随便点几个,吃得也不开心。

这时候你才意识到:他不是在”服务”大家,他是在”主持”这顿饭。

更深的一层是:那个从不点菜的人,他付出了什么代价?

表面上,他什么都没付出——他不用承担点砸了的风险,他还能挑剔。

但实际上,他付出了一个巨大的代价:在这顿饭上,他的偏好永远排在最后。

因为点菜的那个人,是按自己的理解在点的。他会照顾大多数人的口味,但他照顾的是”他理解的大多数人”。

那个不点菜的人,如果他不说,没人知道他不吃辣。

沉默的成本,不是在这一顿饭上结算的,是在一百顿饭上累积的。

第四层:落到 Go 工程师身上

这条对技术人特别重要,因为我们这个群体里,“不点菜”的人特别多。

典型的表现:

  • 评审会上,你发现了问题,但你没说,会后私聊主讲人。
  • 需求讨论时,你觉得这个方案不对,但你没提,你回去按做不了的方式做了一半再来反馈。
  • 跨部门的会上,你有意见,但你等别人先说。
  • 老板问”谁愿意负责这件事”,你低着头。

你的理由通常是:“我不想显得太激进”、“我再想想”、“别人才是专业的”、“这个场合不适合”。

这些理由每一个都很合理。但它们的真实含义是同一个:我不想承担”说错了”的风险。

具体到行动,只有一条,但它很难:

下次开会,第一个开口。

不是第一个发言(第一个发言往往只是破冰),是第一个给出具体判断。

“我觉得这个方案有三个问题,第三个是致命的。”

“這個我做不了,原因是 X。如果要做,需要 Y。”

“我不同意。我的理由是……”

你会紧张,你会说错,你会在某一次很丢脸。这是必然的。

但你要知道:在一个十个人的组织里,第一个给出判断的人,自动获得了定义讨论框架的位置。 后面所有人说的话,都是在回应你——要么是支持,要么是反驳。

而那个等到第八个才开口的人,他说的每一句话,都是在别人的框架里打转。

这就是为什么有些人技术不如你,但在每次会议上听起来比你有分量。

不是他懂得多,是他先开口。

小林在五月那个周五,在群里发了那条”如果周三之前没人反对,我按 B 推进”。

那就是他第一次点菜。


5.5 SLO 不是运维的事:怎么给”稳定性”定一个业务听得懂的价

大刘说”99.9% 吧”,小林说”凭什么”

五月二十号,周三下午,小林和大刘在茶水间吵了一架。

起因是压测。第一轮换完之后,大刘拿着数据来找小林。

“你这个 SLO,得定一下。”

“多少?”

“99.9% 吧,行业惯例。“大刘说,“我们现在是 99.87%,加两台机器应该能到。”

“凭什么是 99.9%?”

大刘愣了一下。

“什么叫凭什么?大家都用这个数。”

“大家是谁?”

“各家互联网公司不都这么说吗,‘三个九’。”

“大刘,“小林把杯子放下,“我问你一个问题。99.9% 一个月允许挂多久?”

”……”

“四十三点八分钟。“小林说,“99.95% 是二十一点九分钟,99.99% 是四点四分钟。”

“这三个数,差的是三十九分钟。这三十九分钟,值多少钱?”

大刘没说话。

“大促那天,我们的峰值是六万八千单一天。客单价按去年算是一百八十块。也就是说,每分钟的 GMV 是八千五百块。”

“四十三分钟,是三十六万。”

大刘吹了声口哨。

“但这是错的。“小林说,“因为用户不会因为你挂了四十分钟就不买了,他会刷新、会等一会儿再来。真实的损失要打折,我按三折算,是十一万。”

“那也不少。”

“是不少。但你要算另一边。“小林说,“要从 99.9% 做到 99.99%,我们要做什么?”

“双活?”

“双活是最低配。双活 + 跨机房的库存同步 + 全链路的降级开关 + 每个依赖的熔断 + 每月一次的故障演练。大刘,你估一下,这套东西要多少人月?”

大刘想了想。

“至少……六个人月?”

“我算的是八个。“小林说,“八个人员,加上双活的机器成本,一年多出来大概七十万。”

“而它换来的是:把每年的故障时长,从五百二十六分钟降到五十三分钟。省下四百七十三分钟。”

“四百七十三分钟,按大促的 GMV 密度算,是四百万;按日常的密度算(日常每分钟 GMV 是一千二),是五十七万。”

“也就是说,我们花七十万,去省一个介于五十七万和四百万之间的数,而这个数我们根本算不准。”

大刘沉默了。

“那你的意思是,三个九就够了?”

“不是。“小林说,“我的意思是,99.9% 这个数,不应该是大刘定的,也不应该是我定的,应该是算出来的。而这个算法,得王总认。”

“而且——“他停了一下,“我算到这里的时候,发现我把一件事搞错了。”

“什么?”

“我把一年当成了一个整体。”

大刘看着他。

“一年五百二十六分钟的故障预算,如果是均匀分布的,那没关系。但如果是集中在大促那三天呢?”

“大促三天挂四十分钟,和平时挂四十分钟,是一个概念吗?”

”……不是一个概念。”

“对。“小林说,“所以我改了。”

他把手机拿出来,翻出一张表。

履约系统 SLO(分时段,v2)

  日常窗口(非大促)
    可用性目标      99.90%
    月度故障预算    43.8 分钟
    月度预算价值    约 11 万(按三折折算)
    达成成本        当前架构即可满足(99.87% → 99.90%,需 2 台机器)

  大促窗口(6/17 20:00 - 6/21 24:00,共 100 小时)
    可用性目标      99.99%
    窗口故障预算    0.6 分钟(36 秒)
    窗口预算价值    无法用 GMV 折算——挂 36 秒和挂 36 分钟,
                    在舆论和商家侧的影响不是 60 倍关系,
                    而是 0 和 1 的关系
    达成成本        双活(8 人月 + 年 70 万)

  结论:日常保持 99.90%,大促窗口投入双活。
        省下的预算:约 60 万/年(相比全年 99.99%)

大刘看完,沉默了大概十秒钟。

“这个’0 和 1 的关系’,是我想的那个意思吗?”

“是。”

“你跟王总说过这个?”

“还没。”

“你应该说。“大刘说,“我跟运维这么多年,最怕的就是老板问我’我们几个九’。我答’三个九’,他说’为什么不是四个九’,我没法回答。”

“因为说实话,我自己也不知道为什么是三个九。”


那天晚上,小林把这张表发给老陈。

老陈回了个电话。

“你这个分时段,是跟谁学的?”

“我自己想的。”

“想得对。“老陈说,“但你只做对了一半。”

“哪一半?”

“你分了时间,但没有分接口。”

“什么意思?”

“你那个 99.99%,是加在整个’履约系统’上的。那你告诉我,‘查询物流详情’这个接口,挂了会怎样?”

“用户看不到物流。”

“然后呢?”

“然后……他会刷新。”

“对。那’提交订单’这个接口,挂了会怎样?”

”……单下不了。”

“一单是一百八十块。“老陈说,“一个物流查询挂了,损失是零。一个下单挂了,损失是一百八十块乘以你挂的那段时间的单量。”

“这两个接口,凭什么用同一个 SLO?”

小林愣住了。

“老陈,您的意思是——”

“我的意思是,‘履约系统的可用性’是一个没有意义的指标。”

“真正有意义的是:‘下单接口的可用性’、‘支付回调的可用性’、‘仓匹配的可用性’、‘物流查询的可用性’。”

“这四个接口的 SLO,应该是四个不同的数。”

“而且——“老陈停了一下,“这四个数之间的关系,比这四个数本身更重要。”

“什么意思?”

“你想想:如果仓匹配挂了,下单要不要继续?”

“要。“小林说,“可以先把单接下来,仓匹配异步做。”

“对。那如果仓匹配挂了,你让下单也挂了,那你就把一个’可以延迟的损失’变成了一个’立即发生的损失’。”

“这就是降级设计。而降级设计的本质,是给不同的失败,定不同的价。”

“老陈,我有个问题。”

“你说。”

“您刚才说的这些,跟 P8 有什么关系?我怎么听着像是架构师该干的活?”

老陈在电话那头笑了一声。

“小林,你以为 SLO 是个技术指标?”

“不然呢?”

“SLO 是一份合同。”

“合同的一方是你,另一方是业务。合同的内容是:我承诺在一段时间内,把不可用控制在某个范围内;作为交换,我要得到什么。”

“你定 99.99%,你就是在跟业务签一份’我要花七十万,换一个我们谁都算不清的收益’的合同。”

“这种合同,签得下来吗?”

”……”

“而一个 P7 和一个 P8 的区别在这儿:P7 会把 SLO 当成一个技术目标去达成;P8 会把 SLO 当成一份合同去谈判。”

“谈判的意思是:它不是越高越好。它是在成本和收益之间找一个点,然后让双方都同意这个点。”

“而这个点,只有你能算出来。因为全世界只有你,既懂技术上的成本,又愿意去问业务那个数。“


老陈的保险:两个数字,哪个更值钱

“我给你讲个我买保险的事。“老陈说。

“我四十五岁那年,有个保险业务员找上我,推荐一款重疾险。”

“保额五十万,年缴一万八,缴二十年。”

“我说太贵了。”

“他说了一句话——你猜他说什么?”

“健康无价?”

“对,一字不差。“老陈笑了,“他说’陈哥,健康是无价的’。”

“然后我给他算了一笔账。”

“我四十五岁,买到六十五岁。按当时的行业经验表,一个四十五岁男性,未来二十年罹患重大疾病的概率,大概是百分之十六。”

“期望赔付 = 五十万 × 16% = 八万。”

“而我交的保费 = 一万八 × 20 = 三十六万。”

“我花三十六万,买一个期望值八万的东西。”

“这买卖,怎么看都是亏的。”

“那你买了吗?“小林问。

“买了。”

“为什么?”

“因为我在算完之后,问了自己第二个问题。”

“什么问题?”

“如果我真的得了,五十万够不够?”

老陈停了一下。

“我算了一下:当时我们家一年的开销是三十万。如果我得病,我不能工作,我老婆要请半年假照顾我。治疗费、康复费、加上两年的收入中断,大概要一百二十万。”

“五十万,不够。”

“所以问题就变了。它不是’这笔买卖划算不划算’,它是——‘我能承受的最坏情况是什么,而我有没有能力为它兜底’。”

“期望值那个算法是对的,但它是保险公司的算法。保险公司卖一万份保单,它看的是期望值。”

“我只有一条命。我不看期望值,我看尾部。”

“哦。“小林说。

“但你以为这就完了?“老陈说,“没有。我最后买的不是五十万保额。”

“我买的是三十万保额,年缴九千六。”

“省下来那八千四,我拿去做了两件事:每年一次全面体检(三千),加上一张健身房的年卡和一个私教课包(五千四)。”

“为什么?”

“因为我把’风险’这个东西拆开了。”

“风险 = 发生的概率 × 发生后的损失。”

“降低风险,有两条路:降低概率,或者降低损失。”

“买保险,是在降低损失——真出事了,保险公司赔你五十万。”

“体检和健身,是在降低概率——把那 16% 往下降。”

“我算了一下:我买五十万保额,多花八千四一年,换来的是’发生之后多赔二十万’。”

“我把这八千四花在体检和健身上的时候,换来的是’发生的概率从 16% 降到 12%’。”

“哪个更划算?”

小林在心里算了一下。

“降概率……期望赔付少了 4% × 50 万 = 两万。多赔那二十万,期望值是 16% × 20 万 = 三万二。”

“对,从期望值看,买保险更划算。”

“但我还是选了降概率。”

“为什么?”

“因为降低概率这件事,有额外的收益。“老陈说,“体检不只是为了防那一种病,它能查出别的东西。健身不只是降低重疾概率,它让我每天精神状态好,让我能熬得住那些年高强度的工作。”

“这些收益,不在那张保险合同的账上,但它们在我的人生账上。”

“小林,这个道理放到稳定性上,一模一样。”

“稳定性也有两条路。”

“第一条是降低故障概率:加机器、上双活、做灰度、做演练、上混沌工程。这条路的成本是线性的,而且越往后越贵——从三个九到四个九,成本翻三倍;从四个九到五个九,再翻十倍。”

“第二条是降低单次故障的损失:快速回滚、降级开关、熔断、错误预算驱动自动降级、故障时的流量切换。这条路的成本很低,而且它的收益随着系统规模增长。”

“大部分团队,百分之百的稳定性预算都花在第一条路上。”

“为什么?”

“因为第一条路是’建设’,有项目、有排期、有产出、能写进年终总结。第二条路是’兜底’,它平时看不见,出事的时候才有用,而且做它的那个人的绩效往往不好写。”

“但你算一下就知道:一个能把回滚时间从 15 分钟降到 90 秒的机制,它的价值等于——”

“等于把一年的故障时长减少百分之九十。“小林说。

“对。而且成本是两个人周。”

“而双活,八个人月,换来的是把故障时长减少百分之九十中的一小部分。”

“老陈,我明白了。“小林说,“但这有个问题。”

“什么问题?”

“降低损失这条路,老板看不见啊。你做了降级开关,平时它就在那儿躺着,什么也不干。谁会为这个给你打绩效?”

老陈沉默了两秒钟。

“小林,你问到点子上了。”

“这个问题,是 P8 这一级最难受的问题之一。”

“你做的事情里,有一部分是这样的:它做得越好,越没人看得见。”

“降级开关做得好,它永远不会被触发。故障演练做得好,真的出事时大家觉得’还好,有惊无险’。容量规划做得好,大促那天什么也没发生,然后有人会说’今年这个大促好像没什么量啊’。”

“稳定性的悖论是:它成功的样子,和什么都没做的样子,一模一样。”

“那怎么办?”

“两条。”

“第一条,你要学会在风平浪静的时候,主动把风险展示出来。不是炫耀,是让业务方看见’如果没有这个机制,昨天那次小故障会变成什么样’。”

“第二条,更重要的一条——”

老陈停了一下。

“你要自己知道你值多少钱。”

“别人看不见,那是别人的事。你自己不能因为别人看不见,就觉得它不重要。”

“因为到了 P8 这一级,你做的很多事情,别人真的看不见。你要是从’被看见’里面找成就感,你会做不下去。“


Go 实例:把 SLO 从文档里的数字,变成代码里的变量

那段”分时段 SLO”的表格,写完就贴在 Wiki 上了。

但小林很快发现一个问题:大促窗口那 36 秒的预算,没有人知道还剩多少。

它的意思是:在 6 月 17 日 20:00 到 6 月 21 日 24:00 这一百个小时里,系统允许的总失败时间是 36 秒。

而到了 6 月 18 日下午,如果已经花掉了 20 秒,那剩下的一天半里,系统应该做什么?

什么都不做,还是自动变保守?

第一版:固定重试

package main
 
import (
	"errors"
	"fmt"
	"time"
)
 
// 调用下游,失败就重试三次
func callWithRetry(fn func() error) error {
	var err error
	for i := 0; i < 3; i++ {
		if err = fn(); err == nil {
			return nil
		}
		time.Sleep(100 * time.Millisecond)
	}
	return err
}
 
func main() {
	downstreamFailRate := 0.8 // 模拟下游挂了,80% 的请求失败
	var sent int
	start := time.Now()
	for i := 0; i < 1000; i++ {
		_ = callWithRetry(func() error {
			sent++
			if i%100 < 80 {
				return errors.New("下游 500")
			}
			return nil
		})
	}
	fmt.Printf("1000 次业务调用,实际向下游发出 %d 次请求,耗时 %v\n",
		sent, time.Since(start).Round(time.Second))
}

跑出来:1000 次业务调用,实际向下游发出 3000 次请求,耗时 4 分 20 秒。

1000 次业务调用,发出了 3000 次请求,而且业务侧耗时超过四分钟。

真实线上是什么样?下游挂了 → 上游重试 × 3 → 下游压力 × 3 → 下游彻底崩溃 → 更多上游超时 → 更多重试。

这就是雪崩。而它的起点,是三行看起来非常善良的代码。

另外两个更隐蔽的问题:

  • 固定 100ms 退避,一千个请求会在同一时刻重试,形成重试风暴。真实的做法是退避 + 抖动。
  • 它对”不可重试的错误”(比如参数错误)也重试三次,白白浪费。

第二版:预算驱动的自适应降级

package main
 
import (
	"context"
	"errors"
	"fmt"
	"math/rand"
	"sync"
	"time"
)
 
// ---- 错误预算:把 SLO 变成代码里的一个变量 ----
 
// Mode 是系统在预算压力下的运行模式。
//
// 这个设计的出发点是:
// 一个"SLO 99.9%"的承诺,本质上是"我允许自己在这段时间内,
// 有千分之一的时间是不可用的"。
// 那么这段"可用的失败额度",就是一种资源——
// 它可以被花掉,也应该可以被查询。
//
// 一旦代码能查询"我还剩多少额度",它就能自己做决策:
// 额度充足 → 激进(多试几次,多调几个非关键接口)
// 额度见底 → 保守(不重试,跳过非关键调用,走兜底)
type Mode int
 
const (
	ModeFull         Mode = iota // 额度充足,全功能
	ModeConservative             // 额度过半,跳过非关键调用
	ModeEssential                // 额度见底,只做主流程
	ModeShed                     // 额度耗尽,开始丢弃非关键请求
)
 
func (m Mode) String() string {
	switch m {
	case ModeFull:
		return "全功能"
	case ModeConservative:
		return "保守"
	case ModeEssential:
		return "极简"
	case ModeShed:
		return "限流"
	}
	return "未知"
}
 
// MaxAttempts 是不同模式下的重试次数。
// 注意这里的反直觉之处:额度越少,重试次数越少。
// 大多数人写重试的时候,逻辑是"失败了就多试几次";
// 而正确的逻辑是"系统越不健康,越不应该给它加压"。
func (m Mode) MaxAttempts() int {
	switch m {
	case ModeFull:
		return 3
	case ModeConservative:
		return 2
	case ModeEssential:
		return 1
	default:
		return 1
	}
}
 
// slot 是滑动窗口里的一格。
// 为什么用滑动窗口而不是全局计数?
// 因为全局计数不会"忘记"——三个月前的一次故障,
// 会让你今天的预算永远是零。
type slot struct {
	stamp  int64 // 这一格属于哪个时间片
	total  int64
	failed int64
}
 
// Clock 是一个可注入的时钟。
//
// 这一条不只是为了写测试(虽然它确实让下面这个演示变得可能)。
// 更重要的是:任何依赖"经过了多少时间"的逻辑,
// 如果你直接调用 time.Now(),它就没法被单元测试覆盖。
// 你只能靠 sleep 去测,而 sleep 会让你的测试又慢又不稳定。
type Clock struct {
	mu  sync.Mutex
	cur time.Time
}
 
func NewClock(start time.Time) *Clock { return &Clock{cur: start} }
 
func (c *Clock) Now() time.Time {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.cur
}
 
func (c *Clock) Advance(d time.Duration) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.cur = c.cur.Add(d)
}
 
// Budget 是错误预算的运行时实现。
type Budget struct {
	mu      sync.Mutex
	allowed float64 // 允许的失败率,比如 0.001 表示 99.9%
	slots   []slot
	slotDur time.Duration
	now     func() time.Time // 注入的时钟
}
 
func NewBudget(window time.Duration, allowedFailRate float64, slotCount int, now func() time.Time) *Budget {
	return &Budget{
		allowed: allowedFailRate,
		slots:   make([]slot, slotCount),
		slotDur: window / time.Duration(slotCount),
		now:     now,
	}
}
 
func (b *Budget) Observe(ok bool) {
	cur := b.now().UnixNano() / int64(b.slotDur)
 
	b.mu.Lock()
	defer b.mu.Unlock()
 
	i := int(cur) % len(b.slots)
	s := &b.slots[i]
	if s.stamp != cur {
		// 时间片滚过去了,这一格归零复用
		s.stamp, s.total, s.failed = cur, 0, 0
	}
	s.total++
	if !ok {
		s.failed++
	}
}
 
// FailRate 统计当前窗口内的实际失败率。
func (b *Budget) FailRate() float64 {
	b.mu.Lock()
	defer b.mu.Unlock()
 
	cur := b.now().UnixNano() / int64(b.slotDur)
 
	var total, failed int64
	for i := range b.slots {
		s := &b.slots[i]
		if cur-s.stamp < int64(len(b.slots)) { // 只统计窗口内的格子
			total += s.total
			failed += s.failed
		}
	}
	if total == 0 {
		return 0
	}
	return float64(failed) / float64(total)
}
 
// Consumed 返回预算消耗比例。
// 0 表示额度完全没动,1 表示额度已经花光。
func (b *Budget) Consumed() float64 {
	rate := b.FailRate()
	if rate <= 0 {
		return 0
	}
	c := rate / b.allowed
	if c > 1 {
		c = 1
	}
	return c
}
 
func (b *Budget) Mode() Mode {
	c := b.Consumed()
	switch {
	case c < 0.5:
		return ModeFull
	case c < 0.8:
		return ModeConservative
	case c < 1.0:
		return ModeEssential
	default:
		return ModeShed
	}
}
 
// ---- 客户端:重试和降级都受预算约束 ----
 
var (
	ErrDegraded = errors.New("已降级:非关键调用被跳过")
	ErrShed     = errors.New("已丢弃:错误预算耗尽")
)
 
// isRetryable 判断错误值不值得重试。
// 这一条是必需的:对"参数错误"重试三次,
// 是在用三倍的资源,去重复一个必然会失败的动作。
func isRetryable(err error) bool {
	return !errors.Is(err, context.Canceled) &&
		!errors.Is(err, context.DeadlineExceeded)
}
 
// jitter 在退避时间上加抖动。
// 没有抖动的重试,会让所有上游在同一个瞬间一起重试,
// 这就是重试风暴的来源。
func jitter(base time.Duration) time.Duration {
	return base/2 + time.Duration(rand.Int63n(int64(base/2)))
}
 
type Client struct {
	budget   *Budget
	sleep    func(time.Duration) // 注入的"等待",真实环境里是 time.Sleep
	mu       sync.Mutex
	lastMode Mode
	degraded int64 // 被降级的次数,要打点,要能告警
}
 
// Do 执行一次调用。critical 表示这是不是主流程必需的。
//
// 这是整个设计的核心:
// 以前,"这个调用要不要做"是在写代码的时候决定的;
// 现在,"这个调用要不要做"是在运行时根据预算决定的。
// 你把一个静态决策,变成了一个动态决策。
func (c *Client) Do(ctx context.Context, critical bool, fn func(context.Context) error) error {
	mode := c.budget.Mode()
	c.mu.Lock()
	c.lastMode = mode
	c.mu.Unlock()
 
	switch mode {
	case ModeShed:
		if !critical {
			c.mu.Lock()
			c.degraded++
			c.mu.Unlock()
			return ErrShed
		}
	case ModeEssential, ModeConservative:
		if !critical {
			c.mu.Lock()
			c.degraded++
			c.mu.Unlock()
			return ErrDegraded
		}
	}
 
	backoff := 20 * time.Millisecond
	maxAttempt := mode.MaxAttempts()
 
	var err error
	for attempt := 0; attempt < maxAttempt; attempt++ {
		err = fn(ctx)
		if err == nil {
			c.budget.Observe(true)
			return nil
		}
		if !isRetryable(err) {
			break
		}
		// 关键的一行:预算快花光了,就别再给下游加压了
		if c.budget.Consumed() > 0.9 {
			break
		}
		select {
		case <-ctx.Done():
			c.budget.Observe(false)
			return ctx.Err()
		default:
			wait := jitter(backoff)
			c.sleep(wait) // 不直接 time.Sleep,等待也可以被注入
			backoff *= 2
		}
	}
 
	c.budget.Observe(false)
	return err
}
 
func (c *Client) DegradedCount() int64 {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.degraded
}

跑起来,模拟下游从健康到崩溃再到恢复:

func main() {
	rand.Seed(42)
 
	// 逻辑时钟从 6/18 20:00 开始。
	// 时钟由模拟推进:每轮业务调用推进 1 秒逻辑时间,
	// 重试的等待时间也推进逻辑时间——
	// 这样"窗口"和"重试开销"处在同一个时间尺度上。
	clk := NewClock(time.Date(2026, 6, 18, 20, 0, 0, 0, time.Local))
 
	// 1 分钟窗口,允许 1% 失败率(这是大促窗口的紧张版 SLO)
	budget := NewBudget(time.Minute, 0.01, 60, clk.Now)
	cli := &Client{
		budget: budget,
		sleep:  clk.Advance, // 真实环境里换成 time.Sleep
	}
 
	// 模拟:前 60 秒健康,60-150 秒下游 60% 故障,之后恢复到 0.2%
	downstreamFailRate := func(t int) float64 {
		switch {
		case t < 60:
			return 0.0
		case t < 150:
			return 0.6
		default:
			return 0.002
		}
	}
 
	const (
		totalSeconds  = 300
		callsPerSec   = 20
		criticalCalls = 6 // 前 6 个是关键调用(下单),其余是非关键(推荐、画像)
	)
 
	var sent, ok, degraded int64
	for sec := 0; sec < totalSeconds; sec++ {
		for i := 0; i < callsPerSec; i++ {
			critical := i < criticalCalls
			err := cli.Do(context.Background(), critical, func(ctx context.Context) error {
				sent++
				if rand.Float64() < downstreamFailRate(sec) {
					return errors.New("下游 500")
				}
				return nil
			})
			switch {
			case err == nil:
				ok++
			case errors.Is(err, ErrDegraded) || errors.Is(err, ErrShed):
				degraded++
			}
		}
		if sec%20 == 0 {
			fmt.Printf("t=%-6v 下游失败率 %4.1f%%  预算消耗 %6.1f%%  模式=%-4s  累计降级 %d\n",
				time.Duration(sec)*time.Second, downstreamFailRate(sec)*100,
				budget.Consumed()*100, budget.Mode(), cli.DegradedCount())
		}
		clk.Advance(time.Second)
	}
 
	total := totalSeconds * callsPerSec
	fmt.Printf("\n业务调用 %d 次,成功 %d 次,主动降级 %d 次\n", total, ok, degraded)
	fmt.Printf("向下游实际发出请求 %d 次\n", sent)
	fmt.Printf("如果沿用固定三次重试,至少会发出 %d 次\n", total*3)
}

跑出来:

t=0s     下游失败率  0.0%  预算消耗    0.0%  模式=全功能  累计降级 0
t=20s    下游失败率  0.0%  预算消耗    0.0%  模式=全功能  累计降级 0
t=40s    下游失败率  0.0%  预算消耗    0.0%  模式=全功能  累计降级 0
t=1m0s   下游失败率 60.0%  预算消耗   50.0%  模式=保守    累计降级 0
t=1m20s  下游失败率 60.0%  预算消耗  100.0%  模式=限流    累计降级 280
t=1m40s  下游失败率 60.0%  预算消耗  100.0%  模式=限流    累计降级 560
t=2m0s   下游失败率 60.0%  预算消耗  100.0%  模式=限流    累计降级 840
t=2m20s  下游失败率 60.0%  预算消耗  100.0%  模式=限流    累计降级 1120
t=2m40s  下游失败率  0.2%  预算消耗  100.0%  模式=限流    累计降级 1400
t=3m0s   下游失败率  0.2%  预算消耗  100.0%  模式=限流    累计降级 1680
t=3m20s  下游失败率  0.2%  预算消耗  100.0%  模式=限流    累计降级 1960
t=3m40s  下游失败率  0.2%  预算消耗   18.9%  模式=全功能  累计降级 2072
t=4m0s   下游失败率  0.2%  预算消耗    0.0%  模式=全功能  累计降级 2072
t=4m20s  下游失败率  0.2%  预算消耗    0.0%  模式=全功能  累计降级 2072

业务调用 6000 次,成功 3591 次,主动降级 2072 次
向下游实际发出请求 3959 次
如果沿用固定三次重试,至少会发出 18000 次

看最后三行。

向下游实际发出 3959 次请求,而不是 18000 次。

在下游 60% 故障的那九十秒里,这套机制把发往下游的压力砍到了原来的 22%。

这就是”降低单次故障损失”这条路的具体样子。

它不花八个人月做双活,它花一个下午写两百行代码,换来的效果是:当你的下游崩溃时,你不会成为压垮它的最后一根稻草。

再看 t=3m20s 到 t=3m40s 那两行。

预算消耗从 100% 掉到 18.9%,模式自动从”限流”退回”全功能”。

这个恢复是自动的,不需要任何人操作。 这正是滑动窗口的价值:它会忘记。

如果用全局计数,这次故障会让你在接下来的一整天里,永远处于”预算耗尽”状态——然后你的系统会一直降级,直到有人手动去重置它。

而现实里,那个”有人手动去重置”的动作,通常发生在有人发现系统行为不对的几小时之后。

一个会自动恢复的机制,和一个需要人工恢复的机制,是两代东西。

这段代码里有三个判断,值得单独说。

第一个判断:重试次数随预算下降而减少,不是上升。

这一条是反直觉的。

大多数工程师写重试的时候,心里的模型是”我失败了,我再试试”。这个模型里,重试是”我的努力”。

但在系统层面,重试是你给下游施加的压力。

下游之所以失败,大概率是因为它已经扛不住了。这时候你再给它三倍压力,是在加速它的死亡。

在分布式系统里,重试不是努力,是施压。

第二个判断:非关键调用在压力下被跳过,这个决策放在调用方。

看 Do(ctx, critical, fn) 这个签名。critical 这个参数,是调用方传进来的。

这意味着:每一个写下这行代码的人,都必须思考一次”这个调用,是主流程必需的吗?”

这个问题,在过去是从来没有人问的。大家写代码的时候,默认所有调用都是必需的。

而一旦你在签名里加了这个参数,这个问题就被强制问了一次。

P8 造的机制,会强迫 P5 做正确的思考。

第三个判断:降级次数是一个指标,不是一个日志。

c.degraded 是被记录的,它应该被打点到监控系统,应该有告警。

为什么?因为”降级”这件事,如果静默发生,它会变成一种慢性病。

你上线了降级机制,某天它开始降级了,没人知道。三个月后你发现,你的推荐系统有一半的请求在走兜底,而没有任何人注意到。

降级的可见性,比降级本身更重要。

一个你在用的降级开关,如果它触发了而你不知道,那它不是降级,那是故障。

老陈的追问

一、你负责的服务的 SLO 是多少?这个数是谁定的?

如果是你定的,你是怎么定的?如果是别人定的,你有没有问过为什么?

如果你从来没问过,那你维护的不是 SLO,是一个符号。

二、你负责的服务里,有哪几个接口是不允许挂的?有哪几个是挂了也没关系的?

这两个清单,你能在五分钟之内写出来吗?

写不出来,说明你没有做过降级设计——你只是把所有的东西都当成同样重要,然后祈祷它们都不出事。

三、你上一次故障,从”用户开始受影响”到”影响消除”,中间多久?这个时间,你能砍掉一半吗?

如果能,需要做什么?如果那件事需要两个人周,你做过吗?

如果你既没做过,也没打算做,那你为稳定性花的每一分钱,都是在买心理安慰。

四、过去一年,你有没有主动关掉过一个你自己的服务,就为了看会发生什么?

注意,我问的不是”有没有做过故障演练”。我问的是你敢不敢关掉你自己负责的东西。

大部分演练是在一个没人用的环境里、按剧本走的。那不是演练,那是表演。


生活思考题

题一:为什么你买的延保服务,几乎从来用不上?

你买过延保吗?手机延保、家电延保、汽车延保。

买的时候,销售会说:“多花两百块,保三年,主板坏了免费换。”

你买了。三年后,你发现两百块白花了——手机一次都没坏。

为什么?是商家在骗你吗?

参考答案

第一层:大多数人会怎么答

“他们算过概率的,肯定不亏”、“这是智商税”、“以后再也不买了”。

第二层:这个答案为什么不够

商家当然算过概率。但问题是:他们算的不是你以为的那个概率。

延保的定价逻辑是:预期赔付成本 + 渠道分成 + 运营成本 + 利润。

一台三千块的手机,延保卖两百。其中渠道(卖你延保的那个店员)拿走可能就有六十,运营和利润拿走八十。真正留给”赔付”的钱,只有六十块。

也就是说,商家认为”这台手机三年内主板损坏”的期望赔付,低于六十块。按主板维修成本八百块算,他们判断的损坏概率低于 7.5%。

这个判断通常是对的。

所以你”白花”了两百块,不是因为被骗,是因为你买的东西,真实价值只有六十块,而你付了两百。

第三层:更深的一层是什么

那为什么这么多人买?

因为你在买延保的那个瞬间,你的大脑里发生的事情不是”计算概率”,是——

“我刚花了三千块,如果它坏了,我会非常难受。我不想有这个难受。”

延保真正卖给你的东西,不是”坏了有人赔”,是**“你现在可以不用想这件事了”**。

这个东西有个名字,叫决策后焦虑的缓解。

你注意看延保的销售时机:它永远在你刚刚做完一个大额支出的那一刻被提出来。因为那一刻你的损失厌恶最强。

延保是一门”在焦虑最高点定价”的生意。

更深的一层是:这个东西不是全无价值。

它对两类人是有价值的:

  • 一类是”修不起”的人——他的现金流撑不住一次意外支出,那么他用确定的小钱换不确定的大钱,是理性的。
  • 一类是”想不清楚”的人——他确实需要一个机制,让他买完大件之后能睡得着觉。

它的价值不在于”会不会用上”,在于”买它的那个人,需不需要它来让自己不再想这件事”。

第四层:落到 Go 工程师身上

这一条,直接戳在技术人最隐蔽的一个习惯上:我们特别擅长为”以防万一”花钱。

你数数你的系统里有多少这些东西:

  • “以防万一”加了缓存,但没有失效策略
  • “以防万一”做了双写,但从来没有比对
  • “以防万一”上了消息队列,但消费者从来没挂过
  • “以防万一”留了一个配置开关,但没人知道它现在开着还是关着
  • “以防万一”多申请了三台机器

这些”以防万一”,和那个延保,是一模一样的结构:你在焦虑最高点做的决策,而那个焦虑来自于上一次故障。

具体的行动,很简单也很难:

把你系统里所有”以防万一”的东西列一张表,对每个写三列:它防的是什么、这个东西发生的概率是多少、发生了损失多少钱。

你会发现两件事:

  1. 一半以上的”以防万一”,你写不出概率。 因为你从来没算过,你只是记得上次出事的时候很难受。

  2. 写得出来概率的那些里面,有几个的损失金额小得可笑。 比如那个”以防万一”的双写,防的是”主库挂了”,概率是每年 0.3 次,损失是……二十分钟的写入失败。而你为它付出的代价,是双写的运维复杂度乘以三百六十五天。

列完这张表,你会砍掉三分之一的东西。

而砍掉它们,比加上它们难得多——因为加上它们的时候,你在缓解焦虑;砍掉它们的时候,你要重新面对焦虑。


题二:你家那个灭火器,还能用吗?

装修的时候,或者搬进新家的时候,你可能买过灭火器。

它可能放在厨房柜子里,或者阳台角落。

它现在还能用吗?

参考答案

第一层:大多数人会怎么答

“应该能用吧,又没用过。”

“不知道,应该没过期。”

第二层:这个答案为什么不够

灭火器上有两样东西:一个压力表,一个出厂日期。

压力表的指针必须在绿色区域。如果在红色区域,这个灭火器喷不出来,或者喷两秒就没了。

而压力是会掉的。一个全新的干粉灭火器,放着不动,三到五年压力就会降到临界值以下。

出厂日期则决定了它的报废年限——干粉灭火器通常是十年,水基型是六年。

所以一个买了五年、从未被检查过的灭火器,有相当大的概率是不能用或者不好用的。

而它恰恰是在”从来没用过”的情况下,才更可能出问题——因为它从来没用过,所以从来没有人发现它有问题。

第三层:更深的一层是什么

这个现象有个专门的名字,在可靠性工程里叫**“冷备份失效”**(cold standby failure)。

它的结构是这样的:

一个系统里,主路径天天在跑,所有问题都会暴露。而备份路径(或者兜底机制、降级开关、灾备环境)从来不跑,所以:

  • 它的配置可能早就过期了(主路径改了十次,它一次没跟着改)
  • 它的依赖可能早就不可用了(它调用的某个接口,上个月刚下线)
  • 它的容量可能早就不够了(它是在三年前的流量规模上做的容量规划)
  • 甚至操作它的人,可能已经离职了

于是在那个你真正需要它的时刻,你按下去,什么也没发生。

这是可靠性工程里最残酷的一条规律:

一个从未被验证过的兜底机制,等于没有兜底机制。而它比”没有”更糟——因为它给了你一种虚假的安全感,让你在主路径上少做了本来该做的加固。

真正专业的组织怎么处理这件事?

消防演习。

注意,消防演习不是”让大家知道灭火器在哪”(虽然这也重要)。消防演习真正的目的是:让那个从来不被使用的机制,定期被使用一次。

它强制你走出那一步:把灭火器拿起来,拔掉保险销,按下去。

你会发现有些灭火器压力不够,有些出口被堆了杂物,有些安全出口的门是锁着的。

这些发现,才是演习的全部价值。而它们只有在”真的做一次”的时候才会出现。

第四层:落到 Go 工程师身上

这条在技术上有个非常响亮的名字:混沌工程(Chaos Engineering)。

但我建议你先别管这个名字,先做三件不需要任何工具的事:

第一件:每季度做一次”降级开关演练”。

把你系统里的每个降级开关,在生产环境、低峰期、真的拉一次。

你会发现的典型问题:

  • 有一个开关,代码里判断的是 if config.Degrade,但配置中心里那个 key 的名字是 degrade_switch,从来没生效过。
  • 有一个降级逻辑,依赖一个兜底数据源,而那个数据源三个月前被下线了。
  • 有一个降级路径,它会去查一个 Redis,而那个 Redis 的连接池参数还是两年前的,早就不够了。

第二件:把”容量规划”从一年一次变成每月一次,并且每次都要真的压到目标值。

小林在 618 之前的第三次全链路压测里,发现华东仓的 WMS 接口在 3200 QPS 的时候开始大量超时——而他们的目标是 5000 QPS。

这个发现如果发生在 6 月 18 日,就是那 36 秒的预算。

第三件,也是最重要的一件:把演练做成一件”平常事”,而不是一件”大事”。

一个需要提前两周发通知、拉十个人开会、写应急预案、报备给上级的演练,一年最多做两次,而且每次都会变成一场表演——因为所有人都知道今天要演练,所有人都准备好了。

真正有效的演练是这样的:它小到不需要报备,频繁到没人当回事。

每周三下午三点,随机挑一台机器,把它从负载均衡里摘掉。不需要通知任何人。如果有人因此被影响了,那是你的系统有问题,不是演练有问题。

演练的价值不在于”发现问题”,在于”降低做这件事的门槛”。

最后留一句,是老陈那次说的:

“稳定性的悖论是:它成功的样子,和什么都没做的样子,一模一样。”

“而你要做的,是让它偶尔失败一次——在你能承受的时间和地点。“


5.6 放不下手:你还在写核心代码,说明你没在干 P8 的活

那十天,他写了两千三百行

五月二十号到五月三十号,是小林这一年上半年里,写代码最多的一段时间。

他记了个账,因为后来老陈问起的时候,他要说得清楚:

  • 二十号,周二,写到凌晨一点四十。完成了仓匹配调度算法的核心打分函数,四百二十行。
  • 二十一号,周三,写到两点半。完成了产能数据的本地缓存和预热逻辑,三百一十行。
  • 二十二号,周四,写到一点。修了三个并发问题(其中一个是他自己前一天引入的)。
  • 二十三号,周五,写到三点十分。重写了优先级队列,因为他觉得之前那个”不够优雅”。
  • 二十四号,周六,在家写到十一点。
  • 二十五号,周日,下午去公司,写到十二点。
  • 二十六号,周一,写到两点。
  • 二十七号,周二,写到一点半。
  • 二十八号,周三,写到两点二十。
  • 二十九号,周四,写到一点。

十天,两千三百行。

他自己知道这十天里发生了什么别的:

二十二号,周四上午,和老赵约好的产能对齐会。他迟到了四十分钟,因为他前一晚写到一点,早上没听见闹钟。老赵在会议室等了他四十分钟,一句话没说,会议压缩到十五分钟结束。

二十二号,周四下午,阿 May 在工位上拦住他:“小林,预售订单提前下发那个开关,什么时候给我?我要配运营后台。”

“这两天。”

“你上周也是这么说的。”

二十二号,周四晚上,王总在微信上问他:“这周的周报?”

他看到消息的时候是十一点半,正在改一个 channel 的缓冲大小。他回了句”明天给您”,然后把手机扣在了桌上。

第二天他忘了。

二十三号,周五下午四点,王总自己来找的他。

“周报。”

”……对不起,我下午给您。”

“你最近在忙什么?”

“在……赶调度算法。”

王总看了他一眼,没说什么,转身走了。

二十七号,周二,小周敲他工位。

“林哥,我那个 PR 你能看一下吗?挂了六天了。”

“我今天晚上看。”

那天晚上他写到一点半。

二十八号,周三早上,小周又来。

“林哥……”

“我今天一定看。“小林抬起头,眼睛里全是血丝,“你先去忙别的。”

小周”哦”了一声,走了。

后来小林才知道,那天上午小周在茶水间跟人说了一句话:“我不知道林哥现在在干什么,我手上四个 PR 都卡着。”

二十九号,周四,孙姐在群里 @ 他三次。预售清单他要的东西,他一直没回。

三十号,周五上午的周会。阿 May 当着所有人的面说:

“我再确认一次,预售下发开关,到底什么时候能给我?”

“下周。”

“下周是几号?”

”……周二。”

“好,周二。“阿 May 在本子上记了一笔,“如果周二没有,我要在周会上重新排我的计划了。”

那天中午,小林收到了老陈的微信:“晚上七点?”

“好。”

七点二十,小林到了面馆。他迟到了二十分钟,因为他下午睡过头了。


老陈已经坐在那儿了。面前摆着一碟牛肉,两碗面还没上。

小林坐下,没说话。

老陈看了他一会儿。

“你这周在干什么?”

“写代码。”

“写什么?”

“调度算法。”

“写了多少?”

“两千三百行。”

老陈点点头。面来了。他给小林推过去一碗,自己开始吃。

小林吃了两口,放下筷子。

老陈也没催他。他就那么等着。

大概过了一分钟,也许两分钟。

“老陈。“小林开口了。

“嗯。”

“我累了。”

“我知道。”

“我不是身体累。“小林说,“我是……”

他停住了。

老陈没接话。

又过了一会儿。

“我给你讲讲这十天我干了什么吧。“小林说。

然后他讲了。从那个打分函数讲起,讲到产能缓存,讲到优先级队列为什么重写,讲到那个 channel 的缓冲大小为什么改了四次。

他讲得很细,细到一个变量名字为什么要改。

老陈一直在听,中间夹了两块牛肉。

讲完之后,小林拿起杯子喝了口水。

“讲完了?“老陈问。

“讲完了。”

“那我问你一个问题。”

“您问。”

“这十天里,王总问你要的那个周报,你交了吗?”

小林的手停在半空中。

”……”

“老赵那个会,你迟到了多久?”

“四十分钟。”

“小周的 PR,你看了几个?”

”……”

“孙姐在群里 @ 你几次?”

“三次。”

“你回了几次?”

”……零次。”

老陈把筷子放下。

“小林,你在逃避。”


小林抬起头。

那一瞬间,他脸上的表情变了。

“我逃避?”

“嗯。”

“老陈,“他把杯子往桌上一放,声音大了起来,“我这十天,每天写到两点。我写了两千三百行代码,其中有一半是别人写不了的。你说我逃避?”

“是。”

“那你说我该干什么?“小林的声音又高了一度,“我去跟老赵开会?他会听我的吗?我去跟孙姐要清单?她拖了我三周了。我去给王总写周报?周报上我写什么?写’我这周在协调’?”

“我告诉我自己这周在协调,我怎么证明我协调出了什么东西?”

“我写代码,一天写四百行,晚上两点关电脑的时候,我知道我今天干了什么。”

“我去开会,开了一下午,回来我什么都说不出来。”

老陈看着他,没说话。

“老陈,我说句实话你别生气。”

“你说。”

“你是站着说话不腰疼。“小林说,“你现在是顾问,是投资人。你不用交周报,你不用被人催,你不用在评审会上被人问’你影响的范围有多大’。”

“我今年二十八。我房贷还有二十四年。我妈上个月又问我什么时候带人回家。”

“我同学里有俩都是 P8 了,其中一个写 Go 还没我写得好。”

“我唯一能证明我值这个钱的东西,就是我能写代码。”

“你要我把它交出去?”

“我交出去,我是什么?”

他一口气说完,胸口在起伏。

老陈还是没说话。他又夹了一块牛肉,慢慢嚼完。

面馆里有点吵,隔壁桌两个人在讨论装修。

过了大概半分钟。

“我给你讲个我自己的事。“老陈说。

小林低着头,没吭声。

“我三十七岁那年,第一次做 CTO。”

“那年十一月,一个星期四,凌晨两点十七分,我们的主库挂了。主库挂了之后,从库没顶上来,因为当时的主从切换是半自动的,需要人工确认一步。”

“值班的同学打我电话。我从床上跳起来,开车去了公司。”

“到公司是两点五十。我进办公室的时候,我手下那个 P6——他姓王,我叫他小王——已经在工位上了。还有两个运维也在。”

“我坐下来,打开电脑,把手放在键盘上。”

“然后我就那么坐着。”

”……”

“坐了多久?”

“一个半小时。”

“一个半小时,我一个字都没写。”

老陈笑了笑。

“你知道那一个半小时里我在干嘛吗?”

“干嘛?”

“我在想,我该敲什么命令。”

“我脑子里过了十几种可能:是磁盘满了?是慢查询把连接打满了?是某个定时任务在做大事?是主从延迟导致的?”

“每一种可能,都对应一串命令。我不知道该先敲哪一串。”

“因为我不知道现在的情况。我不知道小王他们已经查到哪一步了,我不知道谁在做什么,我不知道哪些命令已经跑过了。”

“所以我只能坐着。”

“那后来呢?”

“后来是小王修好的。”

“他怎么修的?”

“他凌晨三点十分的时候,转过头来问我一句:‘陈哥,我们的 binlog 保留几天?’”

“我说七天。”

“他说’好’,然后转回去,敲了大概二十分钟,四点半左右,服务恢复了。”

老陈停下来,喝了口汤。

“小林,你知道那天晚上,最让我难受的是什么吗?”

”……是什么?”

“不是我没修好。”

“是第二天我复盘的时候,小王跟我说了一句话。”

“他说:‘陈哥,你来了之后,我就不太敢动了。’”

“我说为什么。”

“他说:‘因为你坐在那儿,我不知道该听你的还是该继续查我的。我想问你要不要切主库,但是我不确定你是不是已经在想这个了,我又怕我问了你,你以为我没主意。’”

“小林,那天晚上,我本来可以三点十分就走的。”

“我留下来那一个半小时,我没有帮上任何忙。”

“我做的事情只有一件:我让那个真正能修好它的人,多犹豫了九十分钟。”


小林抬起头。

”……”

“你现在做的事情,跟我那天晚上做的是同一件。“老陈说。

“什么意思?”

“你这十天,写了两千三百行代码。这两千三百行,小周能写吗?”

“写不了。”

“为什么?”

“他不懂那个调度逻辑,他连 context 都传不明白。”

“对。“老陈说,“他连 context 都传不明白。而这十天里,他本来有机会学会。”

”……”

“你替他做了,他就不会了。这不是他的损失,这是你替他做的这个动作的必然结果。”

“你每替他改一次代码,他就少一次机会去犯那个错。而他没犯过那个错,他就永远学不会。”

“小林,你知道最狠的一点是什么吗?”

“什么?”

“三年后,他还是’连 context 都传不明白’。而你到那时候会说:‘你看,我说吧,他不行。’”

小林坐在那儿,一动不动。

“老陈。”

“嗯。”

“我不是不想放手。“小林的声音低了下去,“我是……”

“你是什么?”

“我不知道放给谁。”

“你试过吗?”

”……”

“小周手上那四个 PR,你看了几个?”

”……零个。”

“那你连试都没试过。“老陈说,“你说’我不知道放给谁’,但事实上你连一个 PR 都没看过,你不知道他现在能做什么、不能做什么。”

“你那个’放不下手’,不是建立在判断上的。”

“是建立在恐惧上的。”

小林沉默了很久。久到老陈把一碟牛肉都吃完了。

“老陈。”

“嗯。”

“我今天说那些话,你别往心里去。”

“什么话?”

“站着说话不腰疼那句。”

老陈笑了。

“我不会往心里去。”

“为什么?”

“因为你说得对。“老陈说,“我现在确实不用交周报。”

”……”

“但是我三十七岁那年交过。我四十一岁那年,我被人从 CTO 的位置上拿掉过。”

“小林,我跟你讲这些,不是要让你觉得’老陈都经历过所以他说得对’。”

“我是要告诉你一件事——”

老陈把身子往前倾了一点。

“你现在疼的地方,就是 P7 和 P8 的分界线。”

“你疼,是因为你正在失去一个旧的身份,而新的身份还没有长出来。”

“旧的身份是:我是一个能写出别人写不出的代码的人。这个身份你用了五年,它给了你所有的安全感。”

“新的身份是:我是一个能对一个结果负责的人。这个身份你还没有任何证据。”

“你怕的不是’放不下手’,你怕的是——放手之后,如果结果没达成,你拿什么证明自己。”

小林的眼眶红了。

他把头低下去,看着那碗已经坨了的面。

“那我怎么办?”

“我哪知道。“老陈说。

”……”

“但我可以给你一个判据,你每天早上到公司的时候问自己一句。”

“什么判据?”

“‘我今天要做的事情里,有没有一件是我最害怕做的那件?’”

“如果没有,那你今天就是在逃避。”

“因为你手上一定有一件事,是你最不想碰的——可能是给王总打电话,可能是去找老赵说一个坏消息,可能是承认一个判断错了,可能是把一件事交给小周然后看着他做砸。”

“那件事,才是你今天该做的第一件事。”

“而代码,是你最容易做到的那件事。它给你成就感,给你确定性,给你’我今天干了活’的证据。”

“它也是你最好的藏身之处。“


老陈的儿子:扶着后座跑了两个星期

“我再给你讲个事。“老陈说。

“我儿子学骑车,是七岁。”

“那种带辅助轮的童车他骑了两年,那年夏天我把辅助轮拆了。”

“拆完之后我扶着他后座,跟着跑。”

“跑了多久?”

“两个星期。”

“每天下班回来,在小区里的那条路上,来回跑。他骑,我在后面弯腰扶着后座,跟着跑。”

“两个星期之后,我的腰不行了。我那会儿三十四岁,弯腰跑五十米就喘,腰疼得晚上要贴膏药。”

“我老婆看了两天,有一天晚上她说:你放手试试。”

“我说不行,会摔。”

“她说:你扶着,他永远学不会。”

“我说我知道,但是摔了怎么办。”

“她说了一句话,我到现在都记得。”

“什么话?”

“她说:‘他摔一次,比你说一百遍”要平衡”管用。’”

老陈笑了笑。

“第三天,我放手了。”

“他歪歪扭扭骑了大概二十米,然后摔了。膝盖擦破一块皮。”

“他坐在地上,我老婆跑过去。我站在那儿没动。”

“然后他自己爬起来了。”

“爬起来之后,他又骑了五十米。”

“那五十米,比我扶着的这整整两个星期,走得都稳。”

老陈停了一下。

“小林,你知道我后来想明白什么了吗?”

“什么?”

“我扶着的那两个星期,不是保护。是延迟。”

“他其实早就会了。他只是在等我放手。”

“因为只要我的手在后座上,他就不必真的去学平衡——他摔倒的时候有我接着,他歪的时候有我扶着。他知道他不会真的摔,所以他的身体从来没有真正学会那件事。”

“我的手,是他不敢倒下的理由,也是他不敢前进的理由。”

小林听着,没说话。

“还有一层,更难听的。”

“你说。”

“我扶着跑了两个星期,腰疼得晚上贴膏药。”

“我一直以为我是为了他。”

“后来我才想明白:我是受不了自己站在一边看着。”

“看他歪歪扭扭,看他快要摔倒,我在旁边站着,那种感觉我扛不住。”

“我扶着他,不是因为我担心他摔倒,是因为我不能忍受’我在旁边看着他摔倒’这件事。”

“我那两个星期,是在用我的腰,换我心里的安心。”

老陈把最后一口汤喝了。

“你那两千三百行代码,跟我那两个星期的腰,是一个东西。”

五月三十一号,周六上午九点,小林到了公司。

办公室没人。他把小周那四个 PR 从头到尾看了一遍,看到十一点半。

看完之后他做了一件计划外的事:他打开自己那十天写的打分函数,从第一行读到最后一行。

读完他坐在那儿,后背有点凉。

他自己写的那个函数,他也快看不懂了。

不是看不懂语法。是看不懂自己当时的判断。


第一版:他五月二十三号重写后的样子

package main
 
import (
	"fmt"
	"sort"
)
 
type Warehouse struct {
	ID         string
	Capacity   int     // 仓的日处理产能
	DistanceKM float64 // 到收货地址的距离
	LoadRate   float64 // 当前负载率
}
 
// score 是仓匹配打分函数。
func score(w Warehouse) float64 {
	return 0.5*float64(w.Capacity)/1000 +
		0.3*(1-w.LoadRate)*100 -
		0.2*w.DistanceKM
}
 
func pick(warehouses []Warehouse) Warehouse {
	sort.Slice(warehouses, func(i, j int) bool {
		return score(warehouses[i]) > score(warehouses[j])
	})
	return warehouses[0]
}
 
func main() {
	ws := []Warehouse{
		{ID: "CN-EAST-01", Capacity: 8000, DistanceKM: 12, LoadRate: 0.7},
		{ID: "CN-EAST-02", Capacity: 8000, DistanceKM: 12, LoadRate: 0.7},
		{ID: "CN-NORTH-07", Capacity: 3000, DistanceKM: 260, LoadRate: 0.2},
	}
	fmt.Println("第一次选中:", pick(ws).ID)
 
	// 同一批仓,只是数据库这次返回的顺序不同
	ws2 := []Warehouse{ws[1], ws[0], ws[2]}
	fmt.Println("第二次选中:", pick(ws2).ID)
}

跑出来:

第一次选中: CN-EAST-01
第二次选中: CN-EAST-02

同一个订单,同样三个仓,只是数据库返回的顺序变了,选中的仓就不一样。

这就是这个函数埋得最深的那颗雷。

小林盯着这两行输出看了很久。他想起五月十九号那天,阿 May 问过他一句:“为什么同一单刷新两次,看到的预计发货仓不一样?“他当时说”缓存问题”。

不是缓存问题。

他把这个函数从头拆了一遍,找出五个问题:

第一,0.5、0.3、0.2 这三个权重,是一句业务判断。 它的意思是”我们认为产能比距离重要,但距离也不能完全不管”。这句判断藏在表达式里,没有名字,没有人会去质疑它。王总不会问”那个 0.5 是干嘛的”,因为王总根本看不到它。

第二,/1000 这个常数是拍脑袋的。 他写这行的时候,脑子里想的是”八千算大仓”。等公司开了五百产能的前置仓,这一项就变成 0.25,几乎不起作用。归一化应该跟着”这一次参与比较的仓”走,不是跟着”我写这行代码那天的仓”走。

第三,LoadRate 到底是 0 到 1,还是 0 到 100? 仓储那边有个 OpenAPI 返回的 load_rate 是百分比。如果哪天有人接了那个接口又忘了除以一百,(1-70)*100 等于负六千九。它不会报错,不会告警,它只会安静地选错一个仓,然后让某个用户的订单在华北仓躺三天。

第四,sort.Slice 是不稳定排序。 同分的两个仓谁在前面,取决于切片里的初始顺序,而初始顺序取决于数据库返回顺序,而数据库返回顺序取决于查询计划。这一条链路上的任何一环变了,结果就变了。

第五,也是让他后背发凉的那一条:这个函数里没有一行解释”为什么”。

小周打开这个文件,每一行都看得懂。但他不知道 0.5 能不能改,不知道 1000 是什么意思,不知道 LoadRate 是哪个口径,不知道同分了该怎么办。

他能看懂每一行,但他无法安全地改任何一个字。

于是他只有两个选择:要么来问小林,要么不动它。


第二版:一个月后,他把知识搬出了自己的脑子

package main
 
import (
	"errors"
	"fmt"
	"sort"
)
 
// ---- 一、把权重从"我脑子里"搬到"能被质疑的地方" ----
 
// Weights 是打分的三个权重。
//
// 为什么要单独拎出来?因为它们不是代码的细节,是业务判断:
// "我们认为仓的产能比距离重要,但距离也不能完全不管。"
// 一个藏在表达式里的 0.5,没人会去质疑它;
// 一个叫 WeightCapacity 的字段,周会上会有人问"为什么是 0.5"。
// 从"没人问"到"有人问",就是从"只有我知道"到"团队知道"。
type Weights struct {
	Capacity float64
	Load     float64
	Distance float64
}
 
// Validate 校验权重。
//
// 这一条是"放手"的技术前提:
// 我不在你旁边看着,所以我必须把"什么是对的"写成代码,让代码替我看着。
func (w Weights) Validate() error {
	if w.Capacity < 0 || w.Load < 0 || w.Distance < 0 {
		return errors.New("权重不能为负(如果你想要负向权重,请改成正向维度后取反)")
	}
	sum := w.Capacity + w.Load + w.Distance
	if sum <= 0 {
		return errors.New("权重之和必须大于 0")
	}
	if sum > 100 {
		return fmt.Errorf("权重之和 %v 超出合理范围(0, 100],你是不是把百分比直接填进来了", sum)
	}
	return nil
}
 
// ---- 二、把"只有踩过才知道的坑"变成启动就会失败的代码 ----
 
// Warehouse 是参与打分的仓。
type Warehouse struct {
	ID         string
	Capacity   int
	DistanceKM float64
	LoadRate   float64
}
 
// Validate 在边界上拦住脏数据。
//
// LoadRate 这一条是真踩过的坑:仓储侧的 OpenAPI 返回的 load_rate
// 是百分比(0~100),我们这边一直按 0~1 用。
// 错误表现不是报错,是"安静地选错仓"——这比报错糟一百倍,
// 因为它不触发任何告警,只能等用户在客服那边炸。
//
// 跨系统的口径不一致,必须在边界上拦住,
// 不能指望每个调用方都记得。人记不住,代码记得住。
func (w Warehouse) Validate() error {
	if w.ID == "" {
		return errors.New("仓 ID 不能为空")
	}
	if w.Capacity < 0 {
		return fmt.Errorf("仓 %s 的产能为负:%d", w.ID, w.Capacity)
	}
	if w.LoadRate < 0 || w.LoadRate > 1 {
		return fmt.Errorf("仓 %s 的负载率 %v 超出 [0,1];注意仓储侧接口返回的是百分比,需要除以 100",
			w.ID, w.LoadRate)
	}
	if w.DistanceKM < 0 {
		return fmt.Errorf("仓 %s 的距离为负:%v", w.ID, w.DistanceKM)
	}
	return nil
}
 
// ---- 三、归一化跟着候选集走,不再有魔法常数 ----
 
// normalize 用候选集内的极值,把一列值映射到 [0,1]。
//
// 八千产能的大仓和五百产能的前置仓放在一起比,
// 正确的做法是看它们在这批候选里的相对位置,
// 而不是各自除以一个写死的常数。
func normalize(values []float64) []float64 {
	if len(values) == 0 {
		return nil
	}
	min, max := values[0], values[0]
	for _, v := range values {
		if v < min {
			min = v
		}
		if v > max {
			max = v
		}
	}
	out := make([]float64, len(values))
	if max-min < 1e-9 {
		// 全一样,没有区分度,一律给中间值,避免除以零
		for i := range out {
			out[i] = 0.5
		}
		return out
	}
	for i, v := range values {
		out[i] = (v - min) / (max - min)
	}
	return out
}
 
// ---- 四、让结果可以被解释,而不是让人来猜 ----
 
// Reason 是打分的可解释分解。
//
// 为什么要返回这个,而不是一个 float64?
// 因为放手之后,出问题的一定不是你。半夜两点被叫起来的是小周或者大刘,
// 他们没有你脑子里那套直觉,他们需要的是能直接读的一句话。
// 一个只返回数字的打分函数,把全部排查成本转嫁给了未来那个人。
type Reason struct {
	WarehouseID string
	Total       float64
	Capacity    float64
	Load        float64
	Distance    float64
	TieBreak    string
}
 
func (r Reason) String() string {
	return fmt.Sprintf("%s 总分=%.4f(产能 %.3f + 负载 %.3f + 距离 %.3f;决胜:%s)",
		r.WarehouseID, r.Total, r.Capacity, r.Load, r.Distance, r.TieBreak)
}
 
// Rank 给一组仓打分并排序。
//
// 三个变化:
// 1. 权重外置且可校验,别人能改,改错了会在 CI 上红。
// 2. 归一化跟着候选集走,不再有 /1000 这种拍脑袋常数。
// 3. 排序稳定,并且同分时用一个与输入顺序无关的决胜规则,
//    保证"同一单永远选同一个仓"。
func Rank(ws []Warehouse, w Weights) ([]Reason, error) {
	if err := w.Validate(); err != nil {
		return nil, err
	}
	if len(ws) == 0 {
		return nil, errors.New("候选仓为空")
	}
 
	caps := make([]float64, len(ws))
	loads := make([]float64, len(ws))
	dists := make([]float64, len(ws))
	for i, c := range ws {
		if err := c.Validate(); err != nil {
			return nil, err // 边界拦住,不让脏数据静默地影响结果
		}
		caps[i] = float64(c.Capacity)
		loads[i] = c.LoadRate
		dists[i] = c.DistanceKM
	}
 
	nCap, nLoad, nDist := normalize(caps), normalize(loads), normalize(dists)
 
	reasons := make([]Reason, len(ws))
	for i, c := range ws {
		// 负载和距离都是越小越好,所以用 (1 - 归一化值)
		reasons[i] = Reason{
			WarehouseID: c.ID,
			Capacity:    w.Capacity * nCap[i],
			Load:        w.Load * (1 - nLoad[i]),
			Distance:    w.Distance * (1 - nDist[i]),
		}
		reasons[i].Total = reasons[i].Capacity + reasons[i].Load + reasons[i].Distance
	}
 
	sort.SliceStable(reasons, func(i, j int) bool {
		if reasons[i].Total != reasons[j].Total {
			return reasons[i].Total > reasons[j].Total
		}
		// 决胜规则必须是输入无关的:仓 ID 是稳定的,切片顺序不是
		return reasons[i].WarehouseID < reasons[j].WarehouseID
	})
 
	for i := range reasons {
		if i > 0 && reasons[i].Total == reasons[i-1].Total {
			reasons[i].TieBreak = "总分相同,按仓 ID 字典序(保证同一单结果稳定)"
		} else {
			reasons[i].TieBreak = "总分胜出"
		}
	}
	return reasons, nil
}
 
func main() {
	// 场景一:有人想把距离调成负权重(他以为"负数表示更看重距离")
	_, err := Rank([]Warehouse{{ID: "A", Capacity: 100, LoadRate: 0.5, DistanceKM: 10}},
		Weights{Capacity: 0.5, Load: 0.3, Distance: -0.2})
	fmt.Println("场景一:", err)
 
	// 场景二:有人接了仓储侧接口,忘了 load_rate 是百分比
	_, err = Rank([]Warehouse{{ID: "CN-EAST-02", Capacity: 8000, LoadRate: 70, DistanceKM: 12}},
		Weights{Capacity: 0.5, Load: 0.3, Distance: 0.2})
	fmt.Println("场景二:", err)
 
	// 场景三:正常打分
	rs, err := Rank([]Warehouse{
		{ID: "CN-EAST-01", Capacity: 8000, DistanceKM: 12, LoadRate: 0.7},
		{ID: "CN-EAST-02", Capacity: 8000, DistanceKM: 12, LoadRate: 0.7},
		{ID: "CN-NORTH-07", Capacity: 3000, DistanceKM: 260, LoadRate: 0.2},
	}, Weights{Capacity: 0.5, Load: 0.3, Distance: 0.2})
	if err != nil {
		panic(err)
	}
	fmt.Println("场景三:")
	for _, r := range rs {
		fmt.Println("  ", r.String())
	}
}

跑出来:

场景一: 权重不能为负(如果你想要负向权重,请改成正向维度后取反)
场景二: 仓 CN-EAST-02 的负载率 70 超出 [0,1];注意仓储侧接口返回的是百分比,需要除以 100
场景三:
    CN-EAST-01 总分=0.7000(产能 0.500 + 负载 0.000 + 距离 0.200;决胜:总分胜出)
    CN-EAST-02 总分=0.7000(产能 0.500 + 负载 0.000 + 距离 0.200;决胜:总分相同,按仓 ID 字典序(保证同一单结果稳定))
    CN-NORTH-07 总分=0.3000(产能 0.000 + 负载 0.300 + 距离 0.000;决胜:总分胜出)

他后来又补了一个测试,只有十二行,锁住那条最贵的性质:

func TestRankStableAcrossInputOrder(t *testing.T) {
	ws := []Warehouse{
		{ID: "CN-EAST-01", Capacity: 8000, DistanceKM: 12, LoadRate: 0.7},
		{ID: "CN-EAST-02", Capacity: 8000, DistanceKM: 12, LoadRate: 0.7},
	}
	first, _ := Rank(ws, Weights{Capacity: 0.5, Load: 0.3, Distance: 0.2})
	reversed := []Warehouse{ws[1], ws[0]}
	second, _ := Rank(reversed, Weights{Capacity: 0.5, Load: 0.3, Distance: 0.2})
	if first[0].WarehouseID != second[0].WarehouseID {
		t.Fatalf("同一批仓换了个顺序,选中结果就变了:%s vs %s",
			first[0].WarehouseID, second[0].WarehouseID)
	}
}

小林改完这些,是六月四号,周三。

改完那天晚上他跟老陈说了一句话:“我发现我那十天写的两千三百行里,真正值钱的不是那两千三百行。”

“是什么?“老陈问。

“是我脑子里那些没写下来的东西。“小林说,“我知道 LoadRate 是 0 到 1,我知道那个 1000 是拍的,我知道同分会抖。这三件事,我一件都没写下来。小周看我的代码,他看得到我写了什么,看不到我知道什么。”

“所以他不问我他就没法改。”

“对。”

“那现在呢?”

“现在这三件事都在代码里了。“小林说,“而且是以会失败的形式在代码里。他填错负载率,启动就报错,报错信息里写清楚’仓储侧是百分比’。他把权重填成负的,CI 就红。他想改归一化逻辑,那个测试会拦他。”

老陈点点头。

“小林,你再往前推一步。”

“怎么推?”

“你放手的唯一前提,不是找到一个靠谱的人。”

“是什么?”

“是把你的判断,变成不依赖你这个人的东西。”

小林愣了一下。

“你想想,你之前为什么放不下手?你怕的是小周做砸。但小周为什么会做砸?不是因为他笨,是因为他手里没有你脑子里的那些东西。”

“你把那些东西搬出来了,他就砸不了那么狠。”

“而他砸不了那么狠,你就敢放手。”

“所以’放不下手’看着是个信任问题,其实是个工程问题。”

小林坐在那儿,半天没说话。

“老陈。”

“嗯。”

“我这两天一直在想一件事。”

“你说。”

“如果这些约束我早两个月写,小周是不是早两个月就能接这个活了?”

“你说呢。”

”……是。”

“所以你那十天,不是’多写了两千三百行’,是’晚了两个月’。“


老陈的追问

一、你现在手上,有哪件事是”只有你能做”的?

先别急着答。把这句话换一种说法再问一遍:这件事是只有你能做,还是只有你做过?

这两个说法之间的那段距离,就是你和 P8 之间的距离。

二、如果你从明天开始休一个月假,你负责的东西里,第一个出问题的是哪个?

说出来。然后问自己第二个问题:出问题之后,是第几天被人发现的?

如果是第三十天,那说明这一个月里没有人真正用它。如果是第三天,那说明有人在用,但那个人发现的时候,他第一个反应是给你打电话还是自己看?

三、你带的那个人,这个季度学会了什么他上季度不会的东西?

你说得出来吗?

如果你说不出来,那这件事是他的损失,还是你的?

四、你上一次在代码评审里,忍住没自己动手改、而是问了一个问题,是什么时候?

想不起来,说明你一直在用”改代码”代替”带人”。

改代码一次三分钟,带人一次三十分钟。你每天都在选三分钟的那个。

五、如果你今天离职,交接文档你会写几页?那几页里,有几页是别人能在你走之后验证对错的?

写不出来的那些,是你这一年真正的工作量。


生活思考题

题一:为什么你妈做了一辈子饭,却教不会你做她那道菜?

你想学你妈那道红烧肉。你站在旁边看了三遍,她还边做边讲。

回家你自己做,做出来不是一个味儿。

是她藏了什么没说吗?

参考答案

第一层:大多数人会怎么答

“中餐就是没有量化,全靠手感。”

“我妈不怎么会教,她说不清楚。”

“火候这个东西,得自己练。”

第二层:这个答案为什么不够

“手感”这两个字,是这个问题里最危险的部分。它把一种可以被拆解的知识,包装成了一种天赋。

你妈说”油热了就把肉下锅”。她脑子里的判据是什么?

不是温度。是她把木筷子插进油里,筷子边上开始冒小泡。

你妈说”炒到变色”。她的判据是什么?

不是颜色。是肉块表面从粉红变成灰白,并且锅底开始有一层薄薄的焦渣,闻到一点点焦香。

这些全是外部可观测的判据。它们一直在那儿,只是她从来没有说出来过。

因为她说了二十年了,这些判据早就自动化了。自动化到她自己都感觉不到自己在使用它们。她以为她在告诉你全部,她确实在告诉她所知道的全部——她不知道自己知道的那部分,才是真正有用的那部分。

这就是为什么”看了三遍”没用。你看到的是结果,你要的是判据。

第三层:更深的一层是什么

知识传递有三种方式,效率差得很远:

写下来,最弱。示范一遍,中等。让对方独自做一次、做砸、然后自己找到出路,最强。

而人性有个特别坏的习惯:你越着急,你越会选最弱的那种。

因为写下来最快,示范次之,让对方做砸最慢——你得忍着,得看着他把肉炒老,得看着他倒掉重来。

所以你会说”给我吧我来”。你会抢过铲子。你抢得越熟练,他学得越慢。

更深的一层是:“我妈不怎么会教”这句话,其实是你在替自己开脱。 把责任放在”她不会教”上,你就不用面对那个更难的问题——我有没有问过”妈,你怎么知道油热了”?

没有人会主动把自己不知道自己知道的东西说出来。那些东西只能被问题挖出来。

第四层:落到 Go 工程师身上

这一条直接戳在技术文档最常见的病上。

你打开你们团队的 Wiki,数一数有多少句这样的话:

  • “注意这里的性能”
  • “这个字段有并发问题,改的时候小心”
  • “这个配置不要随便动”
  • “老逻辑,不知道为什么这么写,别碰”

这四句话,和你妈说的”油热了”,是一模一样的结构。

它们都是内部感受,不是外部判据。

一个具体的行动,下周就能做:

打开你负责的那个服务,找出三处”只有你知道”的地方。

对每一处,写三条东西:

  1. 触发条件:什么情况下会出问题。要具体到数字,不要写”高并发下”。写”QPS 超过 3000 且缓存命中率低于 60% 的时候”。
  2. 外部判据:我怎么知道它正在发生。看哪个监控、哪个日志、哪行报错。
  3. 如果你非要改,你会看到什么:给后来人一个可以安全试错的路标。

你会发现写这三条,比写那个功能本身还难。

因为它要求你把自己自动化了五年的东西,重新拆开。

再往前一步:把其中至少一条,写成会失败的代码。

Validate()、启动自检、CI 检查、一条 golden test。

写进 Wiki 的知识,靠人的自觉生效;写进代码的知识,靠编译器生效。

最后一个判据:

如果你休假三天回来,发现有人改坏了你的东西,那不是他的问题。那是你有一份知识还放在脑子里,没有搬出来。


题二:为什么你教爸妈用手机,教了三遍他们还是不会?

你过年回家,教你怎么打车、怎么挂号、怎么视频通话。

你很有耐心。你教了三遍。

过完年你回北京,第三天,你爸打来电话:“那个打车的东西,怎么弄?”

为什么三遍不够?

参考答案

第一层:大多数人会怎么答

“他们年纪大了,记不住。”

“他们根本不想学,就想让我帮他们弄。”

“老年人就是这样,得反复教。”

第二层:这个答案为什么不够

记性是一个原因,但它是最小的那个原因。

你回忆一下你教的过程。你说:“点这个绿色的图标。“然后呢?

然后你多半做了一件事:你伸手把手机拿过来了。

你说”给我”,然后你三下五除二点完了,把手机递回去,说”好了,就这样”。

他看到的是一串你看不见速度快到看不清的手指运动,和一个已经完成的结果。

他学到的不是”怎么做”,是”喊你来就行了”。

而你之所以会抢过手机,理由特别充分:快。你三十秒能弄完,让他自己弄要五分钟,中间还可能点错、可能弹出一个广告、可能误触充值。

还有一层更隐蔽的:他在你旁边的时候,不敢乱按。

他的手指悬在屏幕上,等你下一条指令。他怕点错,怕弄坏,怕你叹气。

你站在旁边,他就停止了学习。

这一条听着耳熟吗?老陈扶着自行车后座跑了两个星期,是同一个结构。

第三层:更深的一层是什么

真正的难点不是”传递信息”,是让对方在没有你的情况下,独立完成一次,并且承受那次失败。

你爸第三次打电话来问你怎么打车,如果你忍住,说”爸,你自己点一下试试,点错了没关系,点错了也不会扣钱”,然后挂掉电话——他会在那儿折腾十分钟,可能点进一个广告,可能定位偏了,可能最后还是没叫到车。

但那十分钟里发生的事情,和你教三遍那三十分钟里发生的事情,完全不一样。

那十分钟里,是他在建立自己的判据。

更深的那一层是:你之所以忍不住抢过手机,不是因为他慢。

是因为你不能忍受”看着他卡住”这件事。

看着他手指悬在屏幕上、看着他点错、看着他困惑,那种感觉你自己扛不住。你抢过手机,是在用你的三十秒,换你自己心里那十秒钟的舒服。

你不是在帮他。你是在缓解你自己。

第四层:落到 Go 工程师身上

这条在技术上有个特别体面的说法,叫”知识传递”和”梯队建设”。

落到地上,是三件很具体的事:

第一件:在代码评审里,把”我改”换成”我问”。

小林后来给自己定了个规矩:看到小周的 PR 里有问题,他不允许自己直接在评论里写”这里应该改成 X”。

他只能写问题。

“这个 map 会被几个 goroutine 同时写?”

“如果 Capacity 传进来是 0,这里会算出什么?”

“这个 for 循环里如果 ctx 被取消了,会发生什么?”

这个规矩让他很难受。写一个”改成 X”三十秒,写一个好问题要两分钟,而且他得忍住不去看小周什么时候才回复。

但三个月之后,小周的 PR 里开始自己写”这里加了锁,因为会被三个 goroutine 写”。

没有人教他加锁。是他自己想明白的。

第二件:给”让他做砸”留一个安全的位置。

你不能把一个线上主流程直接交给一个新人,然后说”摔一次就学会了”。那是甩锅,不是培养。

安全的做法是有梯度的:先让他做一个有测试覆盖的、可回滚的、出问题影响范围可控的模块。真出问题了,回滚,然后一起复盘。

关键在最后一步:复盘的时候,不许说”我早就知道这里会出问题”。

这句话一出口,他学到的就不是技术,是”以后别碰”。

第三件,也是最难的一件:分清楚”我来做更快”和”我来做更好”。

“我来做更快”是事实,几乎永远成立。

“我来做更好”通常是假的。它真正的意思是”我来做,我更舒服”。

小林后来在工位上贴了张便签,上面写着一句话,是老陈那天在面馆说的:

“你的手在他后座上,他就永远学不会平衡。”

“而你的手在那儿,不是因为他需要,是因为你需要。“


5.7 兜底的那一刻:出事以后,你不能说”这不是我写的”

六月八号,他把仓匹配交给了小周

六月八号,周一上午十点。小林把小周叫到三楼那间只能坐四个人的洽谈室。

小周进来的时候抱着电脑,一脸忐忑。他大概是以为要被训那四个 PR 的事。

“林哥,我那几个 PR——”

“你的 PR 我上周末看了。“小林说,“写得比我第一年写得好。”

小周愣在那儿。

“我找你是有个别的事。“小林把电脑合上,“仓匹配这块,从今天开始,你来做。”

洽谈室安静了大概十秒钟。

”……林哥,你是在生气吗?”

“我生什么气?”

“你是不是觉得我上次在茶水间说那句话,是在告状?“小周的声音小了下去,“我不是那个意思,我就是不知道你在忙什么——”

“你没说错。“小林说,“你那句话传到我这儿的时候,我第一反应是不服气。后来我想了两天,发现你说的是事实。”

“哦。”

“所以现在有两件事。”

小林伸了两根手指。

“第一件,这块东西交给你,是真的交给你。不是我写代码你来提 PR,也不是我写一半你补注释。是你需要的时候来问我,我不问你就不出现。”

“第二件,“他停了一下,“我会怕。”

小周抬起头。

“我会怕你做砸。而且我大概率会忍得很辛苦。“小林说,“所以我把话说在前面:如果你哪天看到我打开那个文件开始改代码,你可以直接问我一句:‘林哥,你是在帮我,还是在缓解你自己?’”

小周没忍住,笑了一声。

“我不敢问。”

“你敢。”


那天上午他们谈了四十分钟。小林讲了四件事,是他前一个周末想清楚的:

第一件,边界。

“产能数据对接、权重调整、候选集过滤,这三块你随便改,改完自己发版。归一化算法和决胜规则,改之前跟我说一声,我不拦你,但我要知道。还有一块——“他指了指屏幕上那个函数,“LoadRate 的校验,别动。”

“为什么?”

“因为那条报错信息里的每一个字,都是踩出来的。”

第二件,判据。

“你怎么知道你改对了?”

”……跑测试。”

“测试过了,上线了,然后呢?”

”……看监控?”

“看哪个监控?“小林说,“我给你三个数:灰度仓的超时率、被分到新仓的单量占比、rank 这个接口的 P99。这三个数任何一个动了超过百分之十,你先别查日志,你先想想你改了什么。”

第三件,回滚。

“我把灰度开关做成了按仓粒度。任意一个仓出问题,你能在三十秒之内把它摘出来,不需要发版。”

“这个我知道。”

“你知道,但你没用过。“小林说,“这周之内,你找一个低峰时段,真的摘一次。摘之前不用告诉我。”

第四件,兜底人。

小周犹豫了很久才问:“林哥,要是我做砸了呢?”

“那就做砸了。”

”……”

“我说真的。“小林说,“做砸了,我来接。这是我的第四件事。”

“你不怕我搞出一个大事故?”

“怕。“小林说,“但我怕的不是事故,是出了事故的时候,我什么都不知道。”

“所以你答应我一件事:出事的第一时间,你打给我。不管几点。”

“不管多晚?”

“不管多晚。“小林说,“你凌晨三点打给我,我最多骂你两句。你凌晨三点自己扛到早上七点再告诉我,我会真的生气。“


六月十三号,凌晨两点四十

灰度放到了百分之十二。

两点四十一分,小林的手机响了。是值班的监控告警电话,不是小周。

他坐起来的时候,脑子还没醒,手已经在摸电脑。

告警内容三条:

履约超时率(灰度分组)   4.1% → 5.2%
CN-PRE-003 仓单量占比    0.3% → 17.8%
rank 接口 P99            38ms → 41ms

CN-PRE-003 是六月一号刚开的一个前置仓,在常州。

小林盯着那三行看了大概二十秒,然后他做了那天晚上第一个动作——

他打开了 IDE。

光标在闪。他的手放在键盘上。

然后他停住了。


他后来跟老陈描述那一刻,说的是这样一段话:

“我坐在那儿,手在键盘上,脑子里已经有解法了。我知道大概是归一化那边的问题,我知道该看哪个函数,我估计二十分钟能定位,四十分钟能改完,一小时能发版。”

“我什么都知道。”

“然后我想起你那天说的那句话——‘我今天要做的事情里,有没有一件是我最害怕做的那件?’”

“我那时候最害怕做的事情,不是改代码。”

“是给王总打电话,告诉他我把事搞砸了。”

小林把电脑合上了。

他拿起手机,先给小周打了电话。

两点五十二分。小周接得很快,他还没睡。

“林哥,我看到了,我在查。”

“你说说是怎么回事。”

“我加了冷启动加权,新仓前两周产能分给加成——”

“然后呢?”

“然后……常州那个仓,它没接实时产能接口。“小周的声音开始发抖,“它返回的 load_rate 是 0。”

“零。”

“我以为是没有负载,我以为是好事。”

小林闭了下眼睛。

常州那个前置仓,六月一号开,六月十号才完成系统对接。在那之前,它的 load_rate 字段一直返回 0。

不是百分比的问题,不是口径的问题。是”这个字段还不存在”和”这个字段的值是零”,在接口上长得一模一样。

而 Validate() 拦不住它。因为 0 是一个完全合法的负载率。

“林哥,对不起,我——”

“你现在能改吗?”

“能,我把没接产能的仓从候选集里排除掉,加个 HasRealtimeCapacity 的标记。”

“多久?”

“二十分钟。发版再加十分钟。”

“好,你去改。“小林说,“我干另外一件事。”

“什么事?”

“我去告诉王总。”

电话那头沉默了。

“林哥,是不是应该等修好了再说?修好了就不用——”

“小周。”

“嗯。”

“你说的这个’修好了就不用说’,就是我们这个级别最贵的一个念头。”


三点零二分,小林拨了王总的电话。

响了六声。

他后来算过,那六声大概有十二秒。他在那十二秒里想了三件事:王总明天上午有会,王总睡觉早,王总说过”有事随时找我”但谁都知道那句话不能当真。

第七声的时候,王总接了。

“喂。”

“王总,是我,小林。”

”……”电话那头有翻身的动静,“说。”

“履约灰度出了问题。灰度分组的超时率从四点一涨到了五点二,原因是常州那个前置仓没接实时产能接口,返回的 load_rate 是 0,被我们的调度算法当成了最优仓,一批华东的单被分过去了。”

“影响多少单?”

“灰度百分之十二,从今天下午两点开始,受影响的大概两千三百单。其中已经出库的一千一百单,剩下的一千二百单在仓里。”

“现在什么状态?”

“小周在改,二十分钟改完,半小时之内能发版。灰度开关我已经摘了,新增的单不会再进去。”

王总没说话。

“还有一件事。“小林说,“在仓里那两千三百单,我明天上午拉一个清单,交给客服那边,主动给用户发一张补偿券。这笔钱从我们项目的预算里出,不走客服的账。”

电话那头安静了几秒钟。

“你知道那要多少钱吗?”

“二十块一张,两千三百单,四万六。”

”……”

“王总。“小林说,“这个事是我的问题。是我决定的灰度节奏,是我决定的交接方式。小周改的代码,但他不知道常州那个仓没接接口,而我应该知道。”

“嗯。”

“所以这笔钱我来申请,我来跟。”

又安静了几秒。

“小林。”

“在。”

“你凌晨三点给我打电话,就为了说这个?”

“就为了说这个。”

”……”

“我知道可以明天说。“小林说,“但我觉得如果明天说,您会问我一句’你什么时候知道的’。”

电话那头,王总笑了一声。很轻,但是在笑。

“你处理。“王总说,“钱的事明天上午我们过一下。”

“好。”

“还有。”

“您说。”

“下次再出这种事,别等到两点四十。”

”……明白。”

电话挂了。

小林坐在床边,看着手机屏幕暗下去。

他后来跟老陈说,那一刻他心里的感觉,不是”被原谅了”。

是”被授权了”。

这两个东西长得一模一样,但完全是两回事。


三点四十一分,小周发版。三点四十五分,灰度超时率回到 4.3%。四点零二分,回到 4.1%。

小林四点二十睡的。

第二天上午九点半,他带着那个清单去了客服。清单上两千三百零八单,每一单后面跟着用户 ID、下单时间、预计超时时长、建议补偿金额。

客服那边的孙姐看了一眼,抬头说:“你这是自己做的?”

“是我自己做的。”

“以前这种事都是我们来催你们。“孙姐把清单接过去,“这次反过来。”

“以后都反过来。“小林说。


六月十八号,大促。

六月二十二号,周五,复盘会。数字出来了:

去年 618 履约超时率    8.9%
今年 618 履约超时率    3.4%
目标                   3.0%

差零点四个点。

会上王总没说什么,只问了一句:“零点四个点,差在哪儿?”

小林说:“差在常州和无锡那两个前置仓。它们六月份才开,产能数据的对接是六月十号才完成的。我们在大促前十天才拿到它们的真实数据,来不及把它们的权重调准。”

“如果给你明年一年呢?”

“这两个仓成熟之后,能贡献零点六个点。”

“那明年目标定多少?”

阿 May 在旁边说了一句:“二点五?”

王总看了小林一眼。

小林说:“二点五我能兜。但我要一个东西。”

“说。”

“所有新开的仓,产能接口必须先接好再上线。这条要写进仓储那边的上线流程里,不是我们的流程。”

王总转过头,看向会议室角落里仓储那边的老赵。

老赵沉默了三秒,说:“可以。“


老陈的岳母:凌晨三点,那个能拍板的人

七月五号,周六晚上,还是那家面馆。

小林把六月十三号那天晚上的事,从头到尾讲了一遍。讲到那六声电话响的时候,他停了一下。

“我那时候,手心全是汗。”

“我知道。“老陈说,“你讲第二遍了。”

“我讲过吗?”

“上次打电话的时候讲过一遍。”

小林笑了。

讲完之后,老陈没评价,先讲了个别的。

“我岳母八十一,一个人在绍兴老家住。”

“去年十一月,一个周四,凌晨三点二十,我老婆接到电话。是隔壁邻居打来的,说我岳母起夜摔了一跤,坐在地上起不来。”

“我老婆当时就慌了。她第一反应是’我明天早上最早的航班回去’。”

“我说你先别急。”

老陈伸出三根手指。

“我做了三件事。”

“第一件,我让那个邻居去敲卫生间的门——我岳母是在卫生间门口摔的,门是往外开的,她坐在地上,门就开不了。我让邻居从窗户看一眼,确认她是坐着还是躺着,能不能动,有没有出血。”

“第二件,我给绍兴市中心医院打了个电话,问骨科急诊今晚有没有值班。”

“第三件,我在手机上看了一眼航班,最早的六点五十,到杭州七点五十,再打车回绍兴,九点半。”

“三件事干完,一共花了七分钟。”

“然后我跟我老婆说:现在打 120,去绍兴市中心医院,不要等我们回去。”

小林听着。

“后来呢?”

“后来是股骨颈骨折,当天上午做的手术。“老陈说,“医生说送得及时,再晚六个小时,就要换关节了。”

“那您那七分钟……”

“小林,你听我说完。“老陈把筷子放下,“我那天晚上做的那三件事,没有一件是’出事之后’做的。”

“什么意思?”

“我怎么知道要问’能不能动、有没有出血’?因为我半年前带她去体检的时候,医生跟我讲过,老年人摔跤,第一件事是判断意识状态和有没有活动性出血。”

“我怎么知道绍兴市中心医院?因为我去年春节回去的时候,专门问过我小舅子,老城区哪个医院骨科最强,急诊到几点。”

“我怎么知道航班?不是那天晚上查的,是我心里一直有数。”

老陈看着他。

“兜底的人和平常人的区别,不在于出事那一刻他有多勇敢。”

“在于出事之前,他把多少’不知道’变成了’知道’。”


小林没说话。

“你想想那天晚上你干了什么。“老陈说。

“我把灰度开关摘了,我让小周改代码,我给王总打了电话,我第二天做了补偿清单。”

“这四件事,有哪一件是你在凌晨两点四十临时想出来的?”

”……没有。”

“灰度开关按仓粒度,是五月二十号做的。”

“影响单量的估算,是因为你手机上有那个看板的快捷方式,你三十秒就能拉出来。”

“补偿标准二十块一张,是你四月份跟客服对过口径的,你记得那个’一个超时订单综合成本十四块六’。”

“给王总打电话,是——”

“是我最害怕做的事。“小林说。

“对。“老陈说,“这是唯一一件真正的’那一刻’发生的。”

“而它之所以能发生,是因为前面那三件你都提前做好了。”

“小林,兜底不是一个动作。兜底是一整套提前做完的功课,加上最后那一下——你肯不肯在凌晨三点按下拨号键。”

“那一下,是全部。那一下之前的,是你能不能按下它的本钱。”

小林喝了口汤。

“老陈。”

“嗯。”

“我那天挂了电话之后,坐在床边,有一个特别奇怪的感觉。”

“什么感觉?”

“我不后悔。”

“你以前会后悔?”

“我以前出了事,第一反应是’我怎么这么蠢’,然后开始想怎么把这件事盖过去,或者至少说得轻一点。“小林说,“那天我什么都没想盖。我就是想把这件事说清楚。”

“说完之后呢?”

“说完了我就踏实了。”

老陈点点头。

“你知道这说明什么吗?”

“说明什么?”

“说明你那天晚上,第一次不是在对结果负责。”

”……那我在对什么负责?”

“你在对你自己负责。”

“小林,你之前所有的难受,都来自于你想让结果好看。你想让王总觉得你行,你想让评审过,你想让你妈觉得你出息了。”

“那天凌晨三点,你放弃了让结果好看。你接受了一个事实:这件事已经搞砸了,而且是我搞砸的。”

“你接受它的那一刻,你才第一次真正站到了那个位置上。”

“因为兜底这个词,本来就预设了一件事——”

老陈停了一下。

“东西已经掉在地上了。”

“你要做的,不是证明它没掉,是把它捡起来。”


小林坐在那儿。

面馆里那桌讨论装修的两个人已经走了,换了一对情侣在吃炒面。

过了很久。

“老陈。”

“嗯。”

“三月份您问我’你这一个月,做的是什么事’,我说’做方案’。您说不是,说我在做’一个答案’。”

“我记得。”

“那天我一直没想明白,做个答案有什么错。”

“现在呢?”

“现在我觉得,问题不在于答案是好是坏。“小林说,“问题在于,我在做答案的时候,是不需要承担后果的。”

“方案做出来了,别人看了,说好或者不好,那是别人的判断。我不必为它负责,因为它还没发生。”

“而结果——”

“而结果是已经发生了的东西。“老陈接了上去,“它没法被修改,只能被承担。”

“对。”

“小林,你现在说出这句话,用了三个半月。”

“我笨。”

“你不是笨。“老陈说,“你是不敢。”

“这两个东西不一样。笨是学不会,不敢是学会了但不想承认。”

“你三月份就知道了。你只是要用一次凌晨三点的电话,来让自己相信自己是知道的。“

Go 实例:兜底不是一个 if err != nil

六月十三号那次事故之后,小林把下单链路那段代码从头翻了一遍。

那段代码是他前年写的。一百二十行,四个步骤,每个步骤下面三行。

他看完之后在会上说了一句话:“这段代码里,没有一个字是关于’如果失败了怎么办’的。”

第一版:把错误往上抛,然后祈祷

package main
 
import (
	"context"
	"errors"
	"fmt"
	"log"
)
 
type OrderReq struct {
	UserID string
	SKUID  string
	Addr   string
}
 
// 四个下游。为了能跑起来,这里用函数模拟。
func reserveStock(ctx context.Context, req OrderReq) error {
	return nil // 假设成功
}
 
func riskCheck(ctx context.Context, req OrderReq) error {
	return nil
}
 
func matchWarehouse(ctx context.Context, req OrderReq) (string, error) {
	// 六月十三号凌晨,这里返回了错误
	return "", errors.New("仓储侧 capacity 接口 503")
}
 
func createOrder(ctx context.Context, req OrderReq, warehouse string) (string, error) {
	return "ORD-20260613-0001", nil
}
 
// PlaceOrder 是下单主流程。
//
// 这段代码看着非常"规范":每一步都检查错误,都打了日志,
// 都用了 %w 包装。很多团队的代码规范就写到这儿为止。
func PlaceOrder(ctx context.Context, req OrderReq) (string, error) {
	if err := reserveStock(ctx, req); err != nil {
		log.Printf("库存预占失败: %v", err)
		return "", fmt.Errorf("库存预占失败: %w", err)
	}
 
	if err := riskCheck(ctx, req); err != nil {
		log.Printf("风控失败: %v", err)
		return "", fmt.Errorf("风控失败: %w", err)
	}
 
	w, err := matchWarehouse(ctx, req)
	if err != nil {
		log.Printf("仓匹配失败: %v", err)
		return "", fmt.Errorf("仓匹配失败: %w", err)
	}
 
	id, err := createOrder(ctx, req, w)
	if err != nil {
		log.Printf("创建订单失败: %v", err)
		return "", fmt.Errorf("创建订单失败: %w", err)
	}
	return id, nil
}
 
func main() {
	id, err := PlaceOrder(context.Background(), OrderReq{UserID: "u-1", SKUID: "sku-1"})
	if err != nil {
		log.Printf("下单失败: %v", err)
		return
	}
	fmt.Println("下单成功:", id)
}

小林在会上把这段代码投出来,问了三个问题。

第一个问题:库存预占成功了,仓匹配失败了,那份库存谁来释放?

没人答得上来。

后来他们翻了日志,那段代码从上线到那天,运行了一年零七个月。中间仓匹配失败过两千多次,每一次都有一份库存被占了没有释放,直到十五分钟后定时任务扫到超时才解掉。

两千多次,谁都没发现。因为它不报错,它只是让某件商品在某个仓里”看起来少了一件”。

第二个问题:仓匹配失败了,用户一定要看到”下单失败”吗?

这个问题是阿 May 回答的。她说:“当然不一定要。仓匹配挂了,你按省会给个默认仓,订单照下,最多发货慢半天。”

“那为什么不这么做?”

“因为……”阿 May 想了一下,“因为你们代码里没这么写啊。”

第三个问题:这次失败,我们要赔多少钱?

这个问题没人答得上来。

不是不知道赔多少,是代码里没有一个地方能回答这个问题。它散落在客服的口径表里、财务的账上、运营的补偿规则里。


第二版:每一步都必须自己回答”我挂了怎么办”

小林重写用了四天。核心的改动不是加了什么算法,是强迫每个步骤在声明的时候,必须写三件事。

package main
 
import (
	"context"
	"errors"
	"fmt"
	"time"
)
 
// ---- 一、先定义"结论",而不是只定义"错误" ----
 
// Outcome 是一次链路执行的最终结论。
//
// 它不是 error。
// error 只回答"有没有出错",而这里要回答的是"出错之后,用户拿到了什么"。
// 这两个问题之间的距离,就是这一段代码的主人处于哪一层。
type Outcome struct {
	OrderID     string
	WarehouseID string
	Degraded    []string // 被降级/跳过的步骤,要打点,要告警
	Compensate  bool     // 是否需要进入补偿流程(发券、客服跟进)
	UserMessage string   // 直接给用户看的话,不是给开发者看的
}
 
// Failure 是链路失败时对外暴露的形态。
//
// 注意它和 Outcome 的区别:
// Outcome 是"成了,但成了个什么样子";
// Failure 是"没成,以及我打算让对方知道多少"。
type Failure struct {
	Stage       string        // 挂在哪一步
	Reason      string        // 内部原因,进日志,不出去
	UserMessage string        // 用户看得懂的话
	RetryAfter  time.Duration // 多久之后可以重试;0 表示"别试了"
	Compensate  bool          // 已经产生的副作用要不要走补偿
}
 
func (f *Failure) Error() string {
	return fmt.Sprintf("%s 失败: %s", f.Stage, f.Reason)
}
 
// ---- 二、每个步骤必须声明三件事 ----
 
// State 是链路执行过程中共享的状态。
type State struct {
	Req         OrderReq
	UserID      string
	SKUID       string
	WarehouseID string
	OrderID     string
	reserved    bool // 库存是否已预占,回滚的时候要看它
}
 
type OrderReq struct {
	UserID string
	SKUID  string
	Addr   string
}
 
// Step 是链路上的一个步骤。
//
// 这里的关键不是代码结构,是它强制回答的三个问题:
//
//  1. Critical:它挂了,这个订单还能不能成立?
//  2. Fallback:它挂了,用什么顶上?
//  3. Compensate:它成了但后面挂了,怎么把它撤回来?
//
// 以前这三件事在写代码的人脑子里,或者在 wiki 的应急预案里。
// 现在它们必须被写出来。写不出来,Code Review 过不了。
// 不是编译器要求的,是团队要求的一一而这就是"工程标准"和"个人能力"的分界线。
type Step struct {
	Name       string
	Critical   bool
	Run        func(ctx context.Context, s *State) error
	Fallback   func(ctx context.Context, s *State) error
	Compensate func(ctx context.Context, s *State)
}
 
// ---- 三、流水线:显式地执行"降级"和"回滚" ----
 
type Pipeline struct {
	steps []Step
}
 
func (p *Pipeline) Run(ctx context.Context, req OrderReq) (Outcome, *Failure) {
	s := &State{Req: req, UserID: req.UserID, SKUID: req.SKUID}
	out := Outcome{}
	done := make([]*Step, 0, len(p.steps))
 
	for i := range p.steps {
		step := &p.steps[i]
 
		err := step.Run(ctx, s)
		if err == nil {
			done = append(done, step)
			continue
		}
 
		if step.Critical {
			// 关键步骤挂了:整条链路不成立,逆序回滚已经成功的步骤。
			// 这一条解决了"库存预占成功但仓匹配失败"的问题:
			// 回滚不是一个补丁,它是这个结构自带的。
			p.rollback(ctx, s, done)
			return Outcome{}, &Failure{
				Stage:       step.Name,
				Reason:      err.Error(),
				UserMessage: "订单创建失败,请稍后重试",
				RetryAfter:  30 * time.Second,
				Compensate:  true,
			}
		}
 
		// 非关键步骤挂了:先试兜底。
		if step.Fallback != nil {
			if ferr := step.Fallback(ctx, s); ferr == nil {
				out.Degraded = append(out.Degraded, step.Name)
				continue
			}
		}
 
		// 兜底也没成。这里必须显式决策:
		// 是让订单继续(标记要补偿),还是整个失败。
		// 最糟糕的写法是"记个日志然后继续"——
		// 那样你既不知道它发生过,也没法追溯它造成了什么。
		out.Degraded = append(out.Degraded, step.Name+"(兜底失败)")
		out.Compensate = true
	}
 
	out.OrderID = s.OrderID
	out.WarehouseID = s.WarehouseID
	if len(out.Degraded) > 0 {
		out.UserMessage = "下单成功,预计发货时间可能延迟 12 小时"
	}
	return out, nil
}
 
// rollback 逆序执行补偿。
//
// 逆序是必须的:先占了库存又创建了订单,回滚的时候要先撤订单再放库存。
// 顺序反了,会出现"库存已释放但订单还在"的中间态。
func (p *Pipeline) rollback(ctx context.Context, s *State, done []*Step) {
	for i := len(done) - 1; i >= 0; i-- {
		if done[i].Compensate != nil {
			done[i].Compensate(ctx, s)
		}
	}
}
 
// ---- 四、把四个步骤写出来 ----
 
func NewPlaceOrderPipeline(broken bool) *Pipeline {
	return &Pipeline{steps: []Step{
		{
			Name:     "库存预占",
			Critical: true,
			Run: func(ctx context.Context, s *State) error {
				s.reserved = true
				return nil
			},
			Compensate: func(ctx context.Context, s *State) {
				if s.reserved {
					s.reserved = false
					fmt.Println("  [回滚] 释放库存", s.SKUID)
				}
			},
		},
		{
			Name:     "风控",
			Critical: true,
			Run: func(ctx context.Context, s *State) error {
				return nil
			},
			// 风控没有副作用,不需要补偿。
			// 这一条"留空"本身就是一个信息:
			// 它告诉后来的人,我们确认过这一步是无副作用的。
			Compensate: nil,
		},
		{
			// 仓匹配:非关键。挂了用默认仓顶上。
			//
			// 这个 Critical: false 不是技术判断,是业务判断。
			// 它意味着"我们接受用默认仓带来的半天延迟"。
			// 而要让这个判断成立,必须有人去问过王总和阿 May。
			// 换句话说:写下这一行 false,逼着你去开一次会。
			Name:     "仓匹配",
			Critical: false,
			Run: func(ctx context.Context, s *State) error {
				if broken {
					return errors.New("仓储侧 capacity 接口 503")
				}
				s.WarehouseID = "CN-EAST-01"
				return nil
			},
			Fallback: func(ctx context.Context, s *State) error {
				// 按省份给一个默认仓。这个兜底数据必须是本地的、
				// 不依赖任何下游的,否则它自己也会挂。
				s.WarehouseID = "CN-DEFAULT-SH"
				fmt.Println("  [降级] 仓匹配不可用,使用默认仓", s.WarehouseID)
				return nil
			},
		},
		{
			Name:     "创建订单",
			Critical: true,
			Run: func(ctx context.Context, s *State) error {
				if s.WarehouseID == "" {
					return errors.New("仓 ID 为空,拒绝创建订单")
				}
				s.OrderID = "ORD-" + time.Now().Format("150405")
				return nil
			},
			Compensate: func(ctx context.Context, s *State) {
				if s.OrderID != "" {
					fmt.Println("  [回滚] 取消订单", s.OrderID)
					s.OrderID = ""
				}
			},
		},
	}}
}
 
func main() {
	fmt.Println("场景一:一切正常")
	out, fail := NewPlaceOrderPipeline(false).Run(context.Background(),
		OrderReq{UserID: "u-1", SKUID: "sku-1"})
	printResult(out, fail)
 
	fmt.Println("\n场景二:仓匹配挂了(六月十三号凌晨发生的事)")
	out, fail = NewPlaceOrderPipeline(true).Run(context.Background(),
		OrderReq{UserID: "u-1", SKUID: "sku-1"})
	printResult(out, fail)
 
	fmt.Println("\n场景三:创建订单挂了,前面的库存要能收回来")
	p := NewPlaceOrderPipeline(false)
	p.steps[3].Run = func(ctx context.Context, s *State) error {
		return errors.New("订单库主库连接超时")
	}
	out, fail = p.Run(context.Background(), OrderReq{UserID: "u-1", SKUID: "sku-1"})
	printResult(out, fail)
}
 
func printResult(out Outcome, fail *Failure) {
	if fail != nil {
		fmt.Printf("  失败:阶段=%s 用户看到=%q %v秒后可重试 需要补偿=%v\n",
			fail.Stage, fail.UserMessage, int(fail.RetryAfter.Seconds()), fail.Compensate)
		return
	}
	fmt.Printf("  成功:订单=%s 仓=%s 降级=%v 需要补偿=%v\n",
		out.OrderID, out.WarehouseID, out.Degraded, out.Compensate)
	if out.UserMessage != "" {
		fmt.Println("  用户看到:", out.UserMessage)
	}
}

跑出来:

场景一:一切正常
  成功:订单=ORD-030412 仓=CN-EAST-01 降级=[] 需要补偿=false

场景二:仓匹配挂了(六月十三号凌晨发生的事)
  [降级] 仓匹配不可用,使用默认仓 CN-DEFAULT-SH
  成功:订单=ORD-030412 仓=CN-DEFAULT-SH 降级=[仓匹配] 需要补偿=false
  用户看到: 下单成功,预计发货时间可能延迟 12 小时

场景三:创建订单挂了,前面的库存要能收回来
  [回滚] 释放库存 sku-1
  失败:阶段=创建订单 用户看到="订单创建失败,请稍后重试" 30秒后可重试 需要补偿=true

小林后来在组里讲这段代码,讲了四十分钟。他最后说的那段话,小周记下来了:

“第二版比第一版多了大概一百二十行。这一百二十行里,没有一行是’业务逻辑’。”

“它们全在回答同一个问题:如果这一步失败了,怎么办。”

“而这个问题在第一版里,没有人在代码里回答过。它在运维的应急预案里,在 Wiki 的某一页上,在老员工的脑子里。”

“一个没有写进代码的兜底,等于一个从来没被演练过的灭火器。”

“还有一件事。”

他指着屏幕上那行 Critical: false。

“这个 false,是我这个季度做过的最难的一个决定。”

“因为它不是一个技术问题。它是在说:仓匹配挂了,我们接受发货慢半天,也要让订单成立。”

“这句话以前没人说过。没有。”

“我为了写这个 false,去找了王总,去找了阿 May,去找了客服的孙姐。我问他们:如果仓匹配挂了,订单还要不要下。”

“他们三个给了三个不同的答案。我们吵了两次会。”

“所以这个 false 的价值,不在这一行代码。在于它逼出了那两次会。”

“以前我们系统里所有的东西都同等重要,因为没有人愿意承担’判定它不重要’的责任。”

“而这种’全都重要’,翻译过来就是:我们对这个系统的可靠性没有任何设计。“


老陈的追问

一、你负责的东西现在挂了,第一个发现的是谁?

是你,是监控,还是用户?

如果是用户,那你这两年在干的事情,需要重新想一遍。

二、你系统里的那些兜底路径,上一次真的被走过,是什么时候?

想不起来,说明它们大概率是坏的。

而一个坏的兜底,比没有兜底更贵——因为它让你在主路径上少做了本来该做的加固。

三、你上一次主动向上汇报一个”还没有人知道的坏消息”,是什么时候?

如果你的答案是”没有”,那只有两种可能:你没出过事,或者你一直在等它自己好。

这两种,哪一种更可怕?

四、你答应过别人的事情里,有几件是你当时就说清楚了”如果我做不到,会怎样”的?

一件的都说不出来,说明你一直在用”我尽量”代替承诺。

五、如果明年这个目标完不成,公司会损失多少钱?

这个数你知道,你的老板知道吗?

如果你的老板不知道,那说明他其实没那么在乎这个数。

那你在拼命的,到底是什么?


生活思考题

题一:为什么你在医院排队两小时不生气,外卖晚十分钟就给差评?

你去医院,挂号,排队,等叫号。

两小时。看病两分钟。

你出来的时候,很少会想”我要投诉这个效率”。

但你点了个外卖,超时十分钟,你会打开 App 给个差评,甚至打电话骂骑手。

为什么?

参考答案

第一层:大多数人会怎么答

“看病是刚需,外卖是消费。”

“医院没办法,大家都排队。”

“生病的时候人比较能忍。”

第二层:这个答案为什么不够

刚需不是主因。

你回忆一下医院那个场景:挂号单上印着你的号,候诊区的屏幕上跳着”当前叫到 47 号,请 48 号准备”。你是 62 号。

你知道自己在队列里的位置。你能算出还要等多久。

而外卖超时的那十分钟,你知道什么?

你什么都不知道。你不知道骑手到哪了,不知道商家出餐了没有,不知道他是摔了、迷路了、还是已经站在你楼下找不到门牌。

同样是十分钟,有进度条的十分钟和没有进度条的十分钟,是两种完全不同的痛苦。

你注意看医院为这件事花了多少成本:分诊台、叫号系统、两块大屏、广播。

它不是在让你等得更短,它在让你等得更”确定”。

第三层:更深的一层是什么

人不怕等待,怕的是不确定的等待。

而再往下挖一层,是更狠的一条:

人不怕坏结果,怕的是不知道这个结果算好还是算坏。

你在医院等了两小时,最后医生说”没事,回去多喝水”,你不生气。

你在医院等了两小时,最后医生说”这个我们这儿看不了,你去省城吧”,你会崩溃。

让你崩溃的不是那两小时。是你突然不知道自己面对的是什么了。

这一条在系统上有个非常直接的对应:

一个返回 500 Internal Server Error 的接口,和一个返回 {"code":"DEGRADED","message":"仓匹配降级,预计发货延迟 12 小时","retry_after":0} 的接口,对下游、对用户、对半夜被叫起来的那个人,是两种完全不同的东西。

前者的意思是”我不知道发生了什么”。

后者的意思是”我知道发生了什么,我知道它影响多大,我知道它什么时候好”。

第四层:落到 Go 工程师身上

小林在六月十三号之后,给自己定了三条规矩。都不难,但都需要事先写进代码:

第一条:每一个对外的失败,必须带三个字段。

发生了什么、对我(调用方/用户)的影响是什么、什么时候可以恢复。

落到 Go 上,就是那个 Failure 结构里的 UserMessage 和 RetryAfter。

RetryAfter 这个字段尤其重要。它让”重试”从一个盲目的动作变成一个有约定的动作。下游看到 Retry-After: 30,它就知道 30 秒内别来烦我。

第二条:降级必须让用户看得见。

你默默地在后台走了兜底路径,用户什么都不知道,然后在第三天收到货的时候生气。这比当时就告诉他”可能延迟 12 小时”要糟得多。

阿 May 在三月份评审会上问小林的那句”改成异步之后,用户看到的’预计发货时间’会不会不准”,问的就是这件事。

她在问的不是技术问题。她在问:你的降级,用户承不承担得起。

第三条:事故期间,沟通的频率比内容更重要。

每十五分钟一条,哪怕内容是”还在查,没有新进展”。

这一条听着反直觉,但它解决的是那个”不知道自己在队列哪个位置”的焦虑。你每十五分钟发一条,就是在告诉所有人:我还在这儿,我还知道这事没完。

一个具体的判据,下周就能自查:

如果你的系统现在挂了,你的用户、你的上游、你的老板,能不能在三十秒内知道三件事——发生了什么、对我的影响是什么、什么时候好?

如果有一件他们得靠猜,或者得跑来问你,那你缺的不是稳定性,是兜底。


题二:为什么你借出去的钱,最容易要不回来的,往往是最亲的人那笔?

你借过钱给朋友吗?

大概率借过。也大概率有一笔,到现在还没还,而且你不太好意思提。

你有没有注意过一个规律:越是关系近的人,那笔钱越难回来。

这是人的问题,还是钱的问题?

参考答案

第一层:大多数人会怎么答

“亲戚朋友之间不能有借贷关系。”

“钱是照妖镜,一试就试出人心。”

“我以后再也不借了。”

第二层:这个答案为什么不够

把锅甩给”人心”,是最省事的一种解释,也是最没用的一种。

你回忆一下你借钱的那一次,转账前后一共说了几句话。

“手头方便吗,借我两万。”

“行,转你了。”

“谢了哥们,过阵子还你。”

“不急。”

四句话。这四句话里,什么都没有约定。

什么时候还?不知道。还不上了怎么办?没说。要不要利息?提都没提。

你为什么不说?

因为你怕伤感情。你觉得谈这些,显得你不信任他,显得你小气,显得你们的关系”只值两万块”。

但你想清楚一件事:这些没说出口的东西,将来一定会有人承担。

而承担它的那个人,大概率是你。

缺契约不是”少了一张纸”。缺契约是少了一次把最坏情况说出口的机会。

第三层:更深的一层是什么

更深的一层特别扎人:

你回避那次谈话,不是为了保护他,是为了保护你自己那一刻的体面。

说”不还也没事”是豪爽的,说”如果三个月还不上,我们要怎么算”是难堪的。

你选了豪爽。

而难堪不会消失,它只是被推迟了。它会在半年后的某个晚上,在你翻账单的时候,回来找你,而且那时候的难堪会比当初大十倍——因为那时候你不光要谈钱,还要谈”你为什么不说”。

兜底的悖论就在这里:你越早把最坏的情况说清楚,你真正需要兜底的时刻就越少。

因为对方知道了边界,他会在边界内努力。

而模糊的善意,会催生依赖。你把”我不着急”说出口的那一刻,这笔钱在他心里的优先级就降了一档。不是他人品差,是你亲手告诉他的。

第四层:落到 Go 工程师身上

P8 最容易掉进去的一个坑,就是把”兜底”理解成”所有事我都扛”。

不是。

兜底是有边界的。而那个边界,必须在出事之前就画出来。

落到具体动作上,是四件事:

第一件:承接任何需求的时候,先写”失效条件”和”我们不做什么”。

小林在 5.2 里写的那份 ADR,最后一节叫”我们可能错在哪儿”。那一节的价值不在技术,在于它提前说出了”如果是这种情况,那我这套方案不管用”。

写下来,就意味着到时候你不用再解释一次。

第二件:每一次上线、每一次灰度,都要事先说清楚三件事。

回滚点在哪、谁来拍板关开关、关了之后业务上会变成什么样。

这三件事要发到群里,要让阿 May、大刘、王总都看见。

说在前面,那叫方案;说在后面,那叫甩锅。

第三件:跨部门协作的时候,把”如果对方没按时交付,我怎么办”提前说出口。

小林在四月份跟老赵谈产能数据的时候,最后加了一句:“如果五月十号之前数据接不进来,我就用历史均值顶上,误差大概在百分之十五。”

他后来跟老陈说,那是他第一次在跨部门会上说这种话,说完手心是凉的。

老陈问他:“老赵什么反应?”

“他说’你早说啊,那我给你排前面一点’。”

第四件,也是最日常的一件:下一次你答应任何事情之前,加一句话。

“如果到 X 日我没做到,那么我会做 Y。”

你会发现一件很可怕的事:有一半的承诺,你说不出这句话。

说不出来的那些,就是你现在正在”兜底”的那些。

而你之所以说不出来,不是因为你不负责。

是因为你从来没想过,你兜的那个底,边界在哪儿。


本章小结:核心心法

这一章从三月十号那间叫”钱塘”的会议室开始,到七月五号那家面馆结束。中间将近四个月,小林只学会了一件事。

为结果兜底,不为方案辩护。

这句话听起来像一句口号,其实它是一整套操作系统,而且它的启动条件非常明确——

当你不再能独立完成一件事的时候,它才会启动。

P7 的成就感来自”这个东西是我做的”。这句话里最关键的词是”我做的”,它意味着这件事在你的控制范围内,你的产出等于你的能力乘以你的时间。这是一个封闭系统,你可以一个人把它跑通。

P8 的成就感必须换成一句完全不同的话:“这个数是我兜的。”

这句话里最关键的词是”兜的”。它意味着这个数不由你一个人决定,你只是那个最后为它负责的人。中间有老赵的产能数据、有仓储那边的接口、有大刘的限流、有阿 May 的开关、有小周写的代码。他们任何一个人掉链子,那个数就掉下去了。

而掉下去的时候,你不能说”这不是我写的”。

这就是为什么这一章里,小林所有的痛苦都集中在一个点上:他一直在试图保住那个封闭系统。

五月的那十天,两千三百行代码,是他最后一次尝试。他每天写到两点,关电脑的时候知道今天干了什么。那种确定感,是他五年工程师生涯里唯一的安全感来源。

老陈没有让他放弃技术。老陈只问了他一句:

“你这十天里,王总问你要的那个周报,你交了吗?”


这一章的七个动作,是同一件事的七个切面:

拆数字,是承认你还没读过题。

砍 scope,是承认你的资源不够,而”不够”这件事必须你自己说出口。

量化技术债,是承认”我想做”和”该做”是两回事,而区分它们需要钱这个共同单位。

跨部门推动,是承认你没有管辖权,你能交换的只有利益。

给稳定性定价,是承认”不出事”不是一种美德,它是一种用预算买来的商品。

放手,是承认你的能力上限就是团队的上限,除非你不再做那个瓶颈。

兜底,是承认你一定会做砸,而你唯一能提前准备的是:做砸的时候,你站在什么位置上。

这七个动作里,前六个都是”认清现实”。

第七个是唯一一个动作:你在认清现实之后,还肯不肯站在那儿。


兜底有三个层次。

第一层在出事之前。 老陈岳母摔跤那个凌晨,他花了七分钟做三件事,没有一件是事发之后才做的。他知道要问”能不能动、有没有出血”,因为他半年前体检时问过医生;他知道绍兴哪家医院骨科急诊最强,因为他去年春节问过小舅子。兜底的水平,等于你在事情没发生的时候,把多少”不知道”变成了”知道”。

第二层在出事那一刻。 小林凌晨三点零二分拨出去的那个电话。那七声响铃里的十二秒,是他这一章里最贵的十二秒。那一刻他放弃了让结果好看,他接受了一个事实:这件事已经搞砸了,而且是我搞砸的。

第三层在出事之后。 不辩护。

方案是你在信息不完全的时候做的最优猜测,它一定会错。你为它辩护,就是在拿自己的信用去为一个你知道不完美的东西续命。

而结果是已经发生的事实。事实不需要辩护,它只需要被承担,然后被改善。

小林后来的 3.4% 没有达标。他走进王总办公室说的第一句话不是”我们已经尽力了”,是”差 0.4 个点,差在哪儿,明年怎么补”。


最后回到那个最疼的地方。

老陈在面馆里说:

“你的手在他后座上,他就永远学不会平衡。而你的手在那儿,不是因为他需要,是因为你需要。”

小林用三个月才承认这句话。

他怕的不是小周做砸。他怕的是放手之后,如果结果没达成,他拿什么证明自己——他房贷还有二十四年,他妈上个月又问了一次什么时候带人回家,他同学里有两个已经是 P8 了,其中一个写 Go 还没他写得好。

他唯一握得住的证据,是代码。

而 P8 要的,恰恰是让你交出唯一的证据,去换一种你还拿不出证据的东西。

这就是这一级的价。

旧的身份是”我能写出别人写不出的代码”,你用了五年,它给了你所有的安全感。新的身份是”我能对一个结果负责”,你还没有任何证据。

中间那段什么都不是的日子,才是 P8。


自检清单

下面十二条,每一条都请诚实回答。答不上来的那些,就是你离 P8 真正的距离。

  • 我能够把一个业务目标拆成一张表。 不是拆成模块,是拆成点。比如”超时率从 8.9% 降到 3.0%“,这 5.9 个点里,每个点归谁、他现在为什么做不到、做到需要什么、什么时候能看出来。拆不出来,说明你还没读过题。

  • 我能够在没有管辖权的情况下,让两个以上部门的同事为我做事。 并且我能说出他们每个人图的是什么。如果我只能说”这是公司的事”,说明我还没有推动能力,我只有汇报能力。

  • 我能够把技术债换算成钱。 用一句话说清楚:还这笔债,投多少人月,换回什么,什么时候能看出来。说不出来,说明我申请的其实不是资源,是心情。

  • 我能够写一份 ADR,里面有两节不是套话。 一节叫”我们可能错在哪儿”,一节叫”什么情况下这套方案要被推翻”。写不出这两节,说明我不是在做决策,我是在做记录。

  • 我能够在评审会上说”这个我做不到”,同时给出至少两个替代方案和各自的代价。 如果我说完”做不到”就结束了,那不叫取舍,那叫推诿。

  • 我能够为我的系统定义 SLO,并且说清三件事。 这个数是怎么来的、业务认不认、超了之后谁会疼。三件里缺一件,这个 SLO 就是个符号。

  • 我能够说出我负责的系统里,哪几个接口是不允许挂的,哪几个是挂了也没关系的。 并且这个清单和代码里的 Critical 标记是一致的。如果两者对不上,说明我的降级设计只存在于我的脑子里。

  • 我能够在三十秒内说出一个正在发生的故障的影响面。 多少用户、多少单、多少钱、预计多久恢复。四个数缺一个,说明我没有为这件事提前准备过。

  • 我能够在出事之后,不等人问,主动把坏消息说给该知道的人。 并且说清楚我打算怎么办、需要谁配合、要花多少钱。如果我总是等它自己好,我不是在兜底,我是在赌。

  • 我能够让一个我带的人,独立完成一件我做只要两小时、他做要两天的事。 全程不抢过键盘,只在他说”我做完了”之后提问。做不到,说明我还在用改代码代替带人。

  • 我能够把我脑子里”只有我知道”的东西,搬三条出来,变成会失败的代码。 校验、启动自检、CI 检查、一条 golden test。一条都搬不出来,说明我的不可替代性不是因为我强,是因为我懒得写。

  • 我能够说出明年这个目标完不成,公司会损失多少钱。 如果我说不出来,那我要么从来没问过,要么这个目标本来就没那么重要。


最后留一句,是老陈七月五号那天晚上说的:

“P8 不是你做得更多了,是你能承受的结果更多了。”

“你以前只能承受’这段代码是我写的’这个结果。现在你要能承受’这个数挂在我名字上’。而这两个东西之间,隔的不是能力,是你肯不肯在凌晨三点按下那个拨号键。”