第七章 P10 布局篇:选择解决哪些问题

心法:选择解决哪些问题 转变:从「解决问题」到「押注什么方向」 陷阱:用过去的成功经验判断未来


开篇:那场会上,他们吵了一百分钟

三月十四号,周四,下午两点。

望京 SOHO,T2 座 27 层。会议室叫”长江”,玻璃墙,一圈灰色的椅子,桌上摆了七瓶水,只有三瓶被拧开过。

林一鸣的公司在这一层。不是什么大公司,做工业视觉质检的,B+ 轮,一百六十多号人,技术四十出头。CTO 姓周,八一年的,老陈做他们顾问做了两年。

小林是被老陈带进去的。

“你就坐最后一排。“进电梯的时候老陈说,“一句话都别说。”

“我以什么身份去?”

“我助理。”

”……我看着像助理吗?”

老陈按了 27。电梯门快关上的时候他才开口。

“你今天的任务不是说话。是听。听完了,晚上我请你吃饭,我问你一个问题。”

“什么问题?”

“现在不告诉你。告诉你了你就带着答案去听了。”


会议室里坐了九个人。长桌尽头是投影幕,上面写着三行字:

明年技术投入方向(预算 4200 万,HC 38 个)

A. 质检算法平台 2.0(延续)
B. 质检大模型(新增)
C. 质检数据标准与开放平台(新增)

小林扫了一眼,心里大概有数了。这就是个资源分配的会。他参加过太多次这种会了——三选一,或者 A 拿大头、B 拿小头,最后吵的是比例。

他掏出手机,准备记会议纪要。

老陈在旁边用指节敲了一下他手背。

小林抬头。老陈看着投影幕,没看他。


前面二十分钟是数据。周 CTO 讲现状,讲客户,讲交付周期,讲毛利率。小林记了半页。

第二十分钟,评分开始。

第一个发言的是算法负责人,姓徐,四十上下,说话很快。

“A 我不说了,这是我们的命根子。现在四十一个客户,三十八个跑在 1.0 上,算法迭代一次要两周,客户投诉最多的就是误检率。2.0 我做完了架构设计,主要是把特征工程和模型解耦,两年,二十个人,我觉得不够,我要二十五个。”

“二十五个。“有人重复了一遍。

“B 我反对。“说话的是 COO,女的,姓张,“大模型这个词我们去年提了不下五十次,投资人问了不下十次。我上周跟两个客户吃饭,其中一个是汽车主机厂,人家采购总监直接问我:你们有没有大模型的能力。我说有。其实我们没有。”

“所以我们做?”

“所以我建议 B 拿一千五百万,先做一个能演示的。”

“能演示的和能交付的是两回事。“徐说的。

“我知道。“张 COO 说,“但如果明年我们还是没有,后年我们连投标资格都没有。”

第三个发言的是个年轻人,三十出头,做数据平台的,叫郑维。他讲得很慢,讲了两分钟就被打断了三次。

“C 是什么?“张 COO 问他,“说人话。”

“就是把我们这四十一个客户积累的质检数据,用一套统一的标准开放出去。“郑维说,“客户可以把自己的缺陷样本上传,用我们的标准打标,然后——”

“然后我们的数据就免费给别人了?”

“不是免费,是——”

“那这东西怎么收钱?”

郑维卡住了。他看向周 CTO。周 CTO 没说话。

小林在最后一排听着,心里替郑维着急。这个问题太好答了——你先免费,把标准铺下去,等行业都用你的标注格式了,你的工具链、你的模型、你的服务就都成了默认选择。这不就是 Go 社区那套玩法吗,先有标准库,后有生态。

他差点就举手了。

老陈又敲了他手背一下。


后面七十分钟,小林记满了两页。

他记下来的是这些:

  • 徐说 2.0 要用 Rust 重写推理引擎,因为 Go 的 GC 抖动在边缘设备上扛不住。郑维说 Rust 招不到人,四十个 HC 里有六个 Rust 名额,招半年招不满。
  • 张 COO 说 B 至少要能对外发布,所以要在六月底之前出 demo。徐说六月底出 demo 意味着用开源模型套壳,套壳的东西客户现场一跑就穿帮。
  • 有人提到他们最大的竞品上个月刚拿了 C 轮,传言在做一个”质检数据联盟”。张 COO 说这是好事,说明方向对了。周 CTO 说这也说明我们再不动就没位置了。
  • 中间有十五分钟在吵边缘设备的型号,NVIDIA 的 Jetson 还是昇腾。大刘式的问题:这玩意儿坏了谁修。
  • 最后十五分钟,周 CTO 说了一句话,会议室安静了几秒。他说:“我们今年到底是卖算法,还是卖数据,还是卖标准?这个问题如果不回答,多少钱分下去都是白分。”

然后就散了。没有结论。周 CTO 说下周再议。

小林合上本子的时候,看了一眼时间:三点四十七。

一百零七分钟。


出电梯的时候,小林有种说不出的憋屈。

“这什么会啊。“他说,“吵了一百分钟,最后一句结论都没有。B 到底做不做?Rust 招不招人?演示六月底出不出得来?一个都没定。”

老陈走在前面,没回头。

“而且郑维那个 C,明显是好东西啊。“小林跟上,“他只是没答上来’怎么收钱’。这个我都能替他答。标准先行,生态后置,这是常识。”

老陈在马路边站住了,回头看他。

“你觉得今天这个会,在吵什么?”

“技术路线啊。“小林说,“A 还是 B 还是 C,Rust 还是 Go,Jetson 还是昇腾,六月底还是九月底。”

“还有呢?”

“还有钱和人。“小林说,“二十五个 HC 还是十五个,一千五百万还是三千万。”

“还有呢。”

小林想了一下。“竞争。他们怕那个拿了 C 轮的竞品。”

老陈点点头,往前走了。

走了十几米,他说了一句。

“你记了三页纸,全是这些。但是今天这个会上,从头到尾,只有一句话是真正在被讨论的。”

“哪句?”

“周 CTO 最后那句。“老陈说,“我们到底卖算法、卖数据、还是卖标准。”

“对啊,他说了啊。”

“他说了,但没人接。“老陈说,“他问完这句,会议室安静了三秒,然后张 COO 说’这个问题太大了我们下次专门开一个会’,然后就有人去吵 Jetson 了。”

”……”

“小林。“老陈看着他,“你今天看到一个 P9 的会。”

“什么意思?”

“P9 的会,吵的是’怎么把这三分钱分好’。P10 的会,吵的是’这公司相信世界会变成什么样’。今天这个会,两件事都在吵,但只有三个人知道真正在吵第二件。”

“哪三个?”

老陈伸出三根手指。

“周 CTO。张 COO。还有坐在角落里那个,全程没说话的,戴眼镜那个。”

“哪个?”

“董事长。姓吴。他一句话没说,但他从头听到尾。他散会的时候,是第一个站起来走的。”

小林愣了一下。“他为什么先走?”

“因为他已经听完了。“老陈说,“他今天来,就是想看看这几个人吵什么。他听出来了:这公司里有两拨人,一拨相信护城河是’算法更准’,一拨相信护城河是’数据更多’。周 CTO 自己站在第三边,他相信是’标准’。”

“那他为什么不拍板?”

老陈笑了。

“你先想想。“


7.1 那场战略会上,他们争的不是技术方案

晚上七点,老陈带小林去了望京一家做鲁菜的馆子。二楼靠窗,能看见楼下的车流。

菜上齐之前,老陈把手机扣在桌上。

“今天那个会,你觉得哪一段最浪费时间?”

小林想都没想。“吵 Jetson 和昇腾那十五分钟。那玩意儿根本不该在这个会上讨论,那是硬件选型,让工程团队出个评测报告不就完了。”

“还有呢?”

“还有那个边缘设备坏了谁修,也是。”

“那十五分钟值多少钱?”

“啊?”

“算一下。“老陈说,“今天会上九个人,平均年薪算八十万,一小时成本大概四百块。加上周 CTO、张 COO、董事长三个人,这一屋子人一小时的人力成本大概是一千五。吵了十五分钟,三百七十五块。”

小林笑了。“那还好。”

“那你觉得他们真正在吵的那件事——我们卖什么——如果吵错了,值多少钱?”

小林笑不出来了。

“一百六十号人。“老陈说,“两年的方向。B+ 轮融的四点二个亿,现在账上还剩二点六个亿。如果方向选错,这二点六个亿烧完需要二十八个月。二十八个月后,如果没钱了,这家公司就不存在了。”

”……”

“所以我问你,“老陈说,“一个会上,九个人花十五分钟吵一件价值三百七十五块的事,花零分钟吵一件价值二点六个亿的事。你觉得这个会的问题出在哪?”

小林沉默了几秒。

“没人接周 CTO 那句话。”

“为什么没人接?”

“因为……不好答?”

“不。“老陈夹了块海参,“因为答了就要押注。”


“你想想那个场景。“老陈说,“如果徐站起来说’我认为我们卖算法’,那他就得接着说’所以 B 一分钱都不该给’。他敢吗?他不敢,因为张 COO 会记这个。如果张 COO 说’我们卖数据’,那她就得承认她去年跟四十一个客户吹的算法能力是虚的。她也不敢。”

“郑维呢?”

“郑维最不敢。“老陈说,“他是三个人里职级最低的。他要是说’我们该卖标准’,他就得同时说服 CTO、COO 和董事长。而他连’怎么收钱’都答不上来。”

“所以他们就吵 Jetson。”

“所以他们就吵 Jetson。“老陈重复了一遍,“吵 Jetson 是安全的。吵 Jetson 谁都不得罪。吵 Jetson 还能显得自己很专业、很关心落地。”

小林没说话。他想起自己参加过的那些评审会、周会、季度规划会。

“我们组也是这样吗?”

“你说呢?”

”……好像是。“小林说,“上次那个履约服务的重构,我们吵了两个礼拜的接口设计。但是’这个服务三年后还要不要存在’这件事,没人问过。”

“对。”

“可是接口设计也很重要啊。”

“当然重要。“老陈说,“我不是说瓷砖颜色不重要。我是说——”

他拿筷子蘸了点茶水,在桌上画了个方框。

“这是我去年装修的房子。”


“装修那事儿,我前前后后搞了五个月。“老陈说,“我老婆跟我吵得最凶的有三次。”

“第一次是瓷砖。她要那种哑光的浅灰色,我要亮面的。我们俩去建材市场跑了四趟,最后选了一种,两千八一平,全屋铺下来十一万。吵了大概三个周末。”

“第二次是厨房的柜门把手。她要那种隐藏式的,我要明装的小圆点。这事吵了两个晚上。最后我让了。”

“第三次是灯带。我说不要,她说要。吵了一次,最后没装。”

“这三个加起来,我大概花了四十个小时,还有五六顿饭是在吵架的状态下吃的。”

小林笑了。“这个我熟。”

“你知道这三个月里,我花在承重墙上的时间是多少吗?”

”……”

“四十分钟。”

老陈说,那四十分钟是跟设计师在工地上。设计师指着图纸说,这堵墙是承重墙,中间能不能开个洞,把厨房和餐厅打通。

“我说能开吗。设计师说能,但是要走结构加固,加一根钢梁,报价两万八,还要报物业、报建委,大概两个月批下来。”

“我说那算了。”

“四十分钟结束。”


“三个月后我住进去。“老陈说,“我后悔了两件事。”

“第一件,主卧的插座。我在床头两边各留了一个五孔插座,没有 Type-C。现在我跟我老婆两个人,每晚要抢那个充电头。这个我每天都要后悔一次。”

“第二件,我当初没开那个洞。住进去才发现,厨房和餐厅之间那堵墙,让整个东边这一半的房子采光全废了。冬天下午三点,客厅就得开灯。”

“而我要是当初花两万八、等两个月,把那堵墙开个两米宽的洞,加根钢梁,现在这房子会是完全不同的样子。”

小林听着。

“你知道最操蛋的是什么吗?“老陈说,“那个洞口的位置,是这房子里唯一一处我没法改的地方。”

“瓷砖不喜欢,十年后可以砸了重铺,两万块。” “柜门把手不喜欢,淘宝上有的是,三十块钱自己换。” “灯带没装,现在有那种磁吸的,贴上去就行。”

“唯独那堵墙。我要想现在开这个洞,得把整个家搬空,把家具全拆,把地板撬了,工期四十天,报价十五万。我做不到。我这辈子大概就住在这儿了,我就这么住着。”


“小林,装修这件事里有一个特别残酷的比例。”

老陈用筷子在桌上比划。

“一次装修,你有几百个决策要做。这几百个决策里,真正不可逆的,不超过五个。”

“承重墙能不能动。” “下水主管的位置能不能移。” “强弱电箱装在哪。” “地暖还是暖气片。” “层高够不够做新风。”

“这五个,每个都不超过四十分钟就能决定。加起来三个半小时。”

“而剩下的几百个决策——瓷砖、把手、灯带、踢脚线、门套、色号、窗帘、五金件——这些占了你五个月里百分之九十五的时间和百分之百的吵架次数。它们全部可以改。最贵的改一次也就两三万。”

“你知道这意味着什么吗?”

小林看着桌上那个茶水画的方框,慢慢被蒸发掉。

“意味着我把精力放错了地方。”

“不止。“老陈说,“意味着我们对’什么不可逆’这件事,天生没有感觉。”

“人对可逆的决策特别敏感,因为它有反馈。你挑了瓷砖,铺上去,你能看见,你马上知道好不好看。你的大脑会给你奖赏或者惩罚。”

“而不可逆的决策,做完之后什么都不会发生。你选了不开那个洞,房子照样住,日子照样过,你不会有任何不适。直到三年后的某个冬天下午,你坐在客厅里,觉得光线有点暗,你去开灯。”

“你不会想到那四十分钟。”


“所以回到今天那个会。“老陈说。

“Jetson 还是昇腾,这是瓷砖。选错了,明年换个型号,最多死掉几十万和两个月。”

“Rust 还是 Go,这也是瓷砖,只不过贵一点。选错了,最多两百万和半年。”

“六月底出不出 demo,这是柜门把手。”

“而’我们卖算法、卖数据、还是卖标准’——”

老陈停了一下。

“这是那堵墙。“


Go 实例:一个只有一个方法的接口,和一个有六个方法的接口

“你写过接口吧。“老陈说。

“写过啊。”

“那你说说,什么叫好接口?”

小林想了想。“高内聚低耦合?职责单一?依赖倒置?”

“背得挺熟。“老陈说,“那我问你个具体的。你们履约那块,检测一个仓库能不能接单,那个判断逻辑,你抽接口了吗?”

“抽了。”

“几个方法?”

”……”小林想了一下,“应该是六个。”

“为什么是六个?”

“因为它要查仓库产能、查运力、查时效、查黑名单、查天气、查例外规则。”

“所以你把这六件事都塞进一个接口里了。”

“对啊,它们都是判断的一部分啊。”

老陈笑了。“来,我给你看个东西。”


他把手机推过来,屏幕上是一段 Go 代码。

// 常见写法:把"一整套能力"抽成一个接口
package fulfillment
 
import "context"
 
// WarehouseChecker 检查一个仓库能否接单。
// 注意这个接口的方法数量。
type WarehouseChecker interface {
	CheckCapacity(ctx context.Context, warehouseID string, sku string) (int, error)
	CheckShipping(ctx context.Context, warehouseID string, zone string) (bool, error)
	CheckSLA(ctx context.Context, warehouseID string, zone string) (time.Duration, error)
	CheckBlacklist(ctx context.Context, warehouseID string, userID string) (bool, error)
	CheckWeather(ctx context.Context, warehouseID string) (WeatherRisk, error)
	CheckException(ctx context.Context, warehouseID string, sku string) (*Exception, error)
}

“这个接口有什么问题?“老陈问。

小林看了半天。“有点胖?”

“胖是表象。“老陈说,“我问你,这个接口现在有几个实现?”

”……一个。”

“从抽出来到现在多久了?”

“一年半。”

“一年半,一个实现。“老陈说,“那这六个方法,一年半里被改动过几次?”

小林想了想。“产能那块加了预占,改过两次。时效那块改过一次。天气那块……换过供应商,改过一次。”

“每次改动,你要动几个地方?”

“接口、实现、调用方,还有测试里的 mock。“小林说,“mock 最麻烦,stub 有六个方法,我每次都要改。”

“对。“老陈说,“这就是代价。你为了’未来可能有很多种检查方式’这个假设,付了一年半的税。而这个未来,到现在都没有来。”


“那应该怎么设计?“小林问。

“你先别问怎么设计。“老陈说,“你先回答我一个问题:这个接口,是给谁用的?”

“给调用方啊。”

“哪个调用方?”

“就……下单服务。”

“只有它?”

”……”小林想了想,“对,只有它。”

“那这个接口存在的意义是什么?”

小林愣住了。

他张了张嘴,又闭上。

“我明白了。“他说,“这个接口根本不该存在。”

“你说得比我想的还狠。“老陈笑了,“我本来只想说它该窄一点。”


老陈把手机拿回去,划了几下,又推过来。

package fulfillment
 
import "context"
 
// 第一次重构:把"一整套能力"拆成"一个判断"。
//
// 关键变化不是方法变少了,是语义变了:
// 旧接口描述的是"我能查什么"(能力清单)
// 新接口描述的是"我要什么结论"(问题定义)
type Checker interface {
	// Check 判断一个履约请求能否被接单。
	//
	// 契约(实现必须遵守,这不是建议):
	//   1. ctx 被取消或超时后,必须在 100ms 内返回;
	//   2. 返回的 error 必须能被 errors.Is 判定为本包导出的哨兵错误之一;
	//   3. 返回的 Decision 必须完整:不允许"既不说行也不说不行"。
	Check(ctx context.Context, req Request) (Decision, error)
}
 
type Request struct {
	WarehouseID string
	SKUs        []SKUQty
	Zone        string
	UserID      string
}
 
type Decision struct {
	Accept    bool
	Reason    Reason // 枚举,不是字符串
	Capacity  int
	Estimated time.Duration
}

“这是一个方法。“老陈说,“现在你来告诉我,这个改动换来了什么。”

小林看着代码。

“加字段不用改接口了。Request 里加个 PromoCode,调用方不用动。”

“对,这是第一条。还有呢?”

“mock 好写了,一个方法。”

“第二条。还有?”

”……”小林盯着那个注释,“契约写进注释里了。”

“再说具体点。”

“就是……我没有规定实现必须怎么算,我只规定了实现必须满足什么。“小林说,“以前那个接口,我把六个检查步骤写死在接口里了。任何一个实现者,都必须有这六步。如果有个仓库根本不看天气,它也得实现一个返回空的 CheckWeather。”

“对。“老陈说,“这个叫什么,你知道吗?”

“什么?”

“接口越宽,实现者的自由度越小。”


老陈把手机收回去,慢慢地说了一段话。

“小林,接口这个东西,很多人以为它是’抽象’,是’解耦’,是’设计’。这些都对,但都太技术了。”

“从更高一层看,接口是一个契约,而这个契约规定了权力的分配。”

“接口里写什么,等于你说了算。实现者只能照着做。” “接口里不写什么,等于实现者说了算。”

“io.Reader 只有一个方法:”

type Reader interface {
	Read(p []byte) (n int, err error)
}

“就这一个方法,让整个 Go 生态长出来了。文件、网络、buffer、压缩、加密、管道、hash——全世界任何人写出来的任何东西,只要能 Read,就能插进任何接受 io.Reader 的地方。”

“为什么?因为这个方法太窄了,窄到几乎没有约束。它只说了一件事:给我字节。它没说字节从哪来、怎么来、缓冲多大、要不要关闭。”

“反过来,如果一个接口有十二个方法,那全世界只有你自己能实现它。那不叫生态,那叫绑定。”


“我再给你看一个真的。“老陈又划了几下。

// Go 1.16 的 io/fs.FS
type FS interface {
	Open(name string) (File, error)
}

“一个方法。“老陈说,“就凭这一个方法,现在 Go 生态里有 embed.FS、有内存文件系统、有 zip 文件系统、有 S3 文件系统、有 git 文件系统、有用于测试的 mapfs。全都是别人写的,官方一个都没写。”

“你知道 io/fs 这个包,Russ Cox 他们讨论了多少年吗?”

“多少年?”

“从 2009 年 Go 开源就有提案。十一年。”

“十一年就为了定一个方法?”

“就为了定这一个方法。“老陈说,“因为这个接口一旦进了标准库,就永远不能改。加一个方法,全世界所有实现了 FS 的代码全部编译失败。所以他们宁可十一年不定。”

“十一年里他们干嘛?”

“等。“老陈说,“等生态自己长出足够多的实现,等看到足够多的真实用例,等到他们有把握说’这一个方法就够了’。”


小林看着窗外。

“所以……接口设计的本质不是’抽象得好不好’。”

“是什么?”

“是你愿意放弃多少控制权。”

老陈点点头。“这句话你可以记一辈子。”

“接口越窄,你放弃的控制权越多,别人能长出来的东西越多。” “接口越宽,你抓得越紧,生态越小,但你觉得越安全。”

“大部分人选后者。因为前者需要你忍受一个很长的、看不见回报的等待期。”


“再回到那六个方法的接口。“老陈说,“你知道最贵的成本不是改 mock 那几次吗?”

“那是什么?”

“是它堵死了一条路。”

“你想想,你们这套履约检查,现在只有一个实现。但假如明年公司要做一个’开放平台’,让第三方仓库接进来呢?”

“第三方仓库哪有产能数据、运力数据、天气数据?他可能只有一个’能不能接’的答案。按你那个六方法接口,他一个都实现不了。你要为他再设计一套接口,然后你的调用方要写 if。”

“而按一个方法的 Checker,第三方只要实现一件事:给我请求,我还你结论。”

“小林,这个后果,在你写下那六个方法的那一天,就已经决定了。你只是不知道。”


小林沉默了很久。

“老陈,我一直以为接口设计是技术问题。”

“它不是。”

“那它是什么?”

“是你对未来下的一次注。“老陈说,“你写下六方法接口,等于你下了注:未来所有实现者都会有这六步。你写下一方法接口,等于你下了相反的注:未来会有一大堆我根本想象不到的实现方式。”

“这两个注,哪个对?”

“你不知道。“老陈说,“你只能选一个,然后等三年。”

“三年后如果我发现押错了呢?”

“那就改。“老陈说,“但是你猜改六方法接口的代价,和改一方法接口的代价,哪个大?”

“六方法。”

“大多少?”

小林想了想他改那个 stub 时的烦躁。

“大概……十倍吧。”

“对。这就是那堵承重墙。“

老陈的追问

那天晚上临走,老陈说了五个问题,一个都没回答。

  1. 你手上有多少个接口,抽出来超过一年,实现数量还是一个?你为这些接口付了多少税?算过吗?

  2. 你团队里那些”定义了但从来没人实现过第二个”的抽象,如果现在全部删掉,会死多少人?如果答案是”没人会死”,那它们当时为什么被设计出来?

  3. 你上一次删掉一个自己设计的接口,是什么时候?如果你从来没删过,说明什么——说明你每次都押对了,还是说明你从来没回头看过?

  4. 你现在负责的系统里,有哪一处是”三年后不可能改”的?你说得出来吗?如果说不出来,你是不知道它在哪,还是你根本没找过?

  5. 如果你明天接手一个一百六十人的公司,你能不能在四十分钟内,指出那堵承重墙在哪?

生活思考题

题一:为什么装修房子时,人们会为瓷砖颜色吵架,却不会为承重墙吵架?

大多数人会怎么答

因为承重墙是技术问题,外行不懂,听设计师的就行了。瓷砖是审美问题,人人都有发言权,所以容易吵。

另一个常见答案:因为承重墙是”能不能做”的问题,瓷砖是”好不好看”的问题,前者有标准答案,后者没有。

这个答案为什么不够

这个答案把责任推给了”专业分工”,但它解释不了一个现象:那些真正不可逆的决策,往往也有人懂,也有人能吵,但就是没人吵。

比如地暖还是暖气片,这不是专业问题,这是生活方式问题,夫妻俩谁都能发表意见。但大部分家庭在这件事上花的讨论时间,不到挑沙发的一半。

再比如强弱电箱的位置。这东西一旦封进墙里,你这辈子都改不了。你去任何一个装修论坛看,讨论电箱位置的帖子,数量不到讨论乳胶漆色号的百分之一。

所以真正的原因不是”懂不懂”。

更深的一层

人对决策的投入程度,不取决于这个决策的重要性,取决于这个决策能带来多少即时反馈。

挑瓷砖:你站在一百块样板前面,你老婆拿起来一块,你觉得难看,你说出来,她反驳,你们有来有回。这个过程中你的大脑在持续获得反馈——判断、表达、被认同或被反驳。这是有快感的。

承重墙:设计师说不能开洞,你说哦。全程四十秒,没有任何反馈。你甚至不会感觉自己做了一个决策。

更残酷的一点在于:不可逆的决策,做对和做错在当下的体验几乎完全一样。

你选了开洞:房子照样住,你会多花两万八和两个月,住进去之后采光很好,你大部分时间不会意识到这是因为那个洞。

你选了不开洞:房子照样住,你省了两万八和两个月,住进去之后采光一般,你也大部分时间不会意识到这是因为没有洞。

两个选择在”住进去的那一天”给你们的体验,差别极小。差别要累积三年、五年、十年,才会慢慢变成”我住着舒服”和”我住着别扭”之间的鸿沟。

人的大脑是一个短反馈回路的机器。它天然地把精力分配给那些马上能看见结果的事。而这个世界最重要的那些决策,恰恰是反馈最慢的那些。

这就是为什么需要”级别”这个东西。P8 之所以是 P8,不是因为他比你聪明,是因为他被训练出了一套能力:能在没有反馈的事情上持续投入注意力。

落到 Go 工程师身上

具体做三件事:

第一,给你的决策做一次”可逆性分级”。打开你最近一个季度做的技术决策清单,逐条问:这个决定,一年后改,成本是多少?答案是”改不起”的,全部标红。你现在数一数,标红的有几条,你在它们上面花了多少时间?

第二,把可逆性写进你的设计文档模板。你们的设计文档里,有没有一栏叫”这个决定多久之后无法更改”?如果没有,加一栏。这一栏会逼着评审的人把注意力从”这个方案好不好”转到”这个方案错了能不能回来”。

第三,对不可逆的东西,把讨论时间强制拉长,对可逆的东西,把讨论时间强制压缩。具体的做法:评审会上,如果一个决策是可逆的,设一个硬性的时间盒,十五分钟,吵不完就抛硬币。如果一个决策不可逆,不许当场拍板,强制进入”隔夜再议”。

你会惊讶于这个简单规则带来的变化:大部分会议会缩短一半,而那几个真正重要的决定,终于开始有人认真对待了。

题二:为什么很多公司三年换一次战略方向,却从来不承认上一次是错的?

大多数人会怎么答

因为老板要面子。因为承认错误会影响权威。因为 KPI 压力,新官上任三把火。

这个答案为什么不够

“老板要面子”这个解释太省事了,它让你不用再往下想。

现实是,很多换方向的决策,恰恰是同一个人、同一个团队做的。去年说”我们做平台”,今年说”我们做 AI”,明年说”我们做 AI 应用”。这些话是同一张嘴说的。他不是不知道自己说过什么,他是真的觉得这次不一样。

真正的问题不是”为什么不肯承认错”,而是:他根本没有一个标准来判断自己错了。

更深的一层

一个战略决策的对错,需要三到五年才能验证。但一个高管在一个岗位上的平均任期,在中国互联网行业是二十八个月。

这意味着什么?

意味着绝大多数做战略决策的人,永远不会看到自己决策的验证结果。

他种下一棵树,两年半后他调岗了、跳槽了、或者公司把那条线砍了。他从来不会收到那封信。

一个从来收不到反馈的系统,是不会学习的。它只会重复它上一次的动作,然后给这个动作编一个新的理由。

这就是为什么”复盘”在大部分公司里会变成形式主义。复盘的前提是你知道结果。而战略决策的结果,需要的时间比人的任期还长。

更深的一层是:一个没有反馈的人,会用什么来代替反馈?

会用”叙事”。

他会给自己讲一个故事:我们当初做平台是对的,只是市场变了;我们当时判断没错,是执行出了问题;方向是对的,节奏没踩好。

这些故事不一定是假的。但它们有一个共同特点:它们都是不可证伪的。

“市场变了”——怎么验证?“执行问题”——怎么衡量?“节奏没踩好”——早多久算好?

一个不可证伪的解释,它的功能不是解释世界,是保护自我。

落到 Go 工程师身上

你现在还不到定战略的位置,但你可以从今天开始做一件 P10 才会做的事:

给自己的每一个重要判断,写一张”验证卡”。

格式很简单,三行:

判断:我认为 X 会在 Y 之前发生 / 我认为 A 方案比 B 方案好,因为 ______
验证时间:____ 年 ____ 月(必须是一个具体到月的日期)
证伪条件:出现 ______ ,说明我错了

去年你是不是觉得”我们该把那个服务拆了”?写下它,写上你觉得三个月后会出现什么,然后到那天去看。

这个动作看起来很小,但它是你和那些”永远正确的人”之间唯一的区别。

还可以做第二件事:去读三年前你们团队的周会记录或者季度规划文档。看看当时说要做的事,有几件真的做了,有几件做成了。你会有一个非常不舒服的发现:当时的判断,你现在已经完全想不起来为什么那么做了。

这个”想不起来”,就是你成长的起点。

7.2 从「解决问题」到「选择解决哪些问题」

急诊室里的那块牌子

过了两周,小林又去找老陈。

这次不是约饭,是他自己去的老陈家。老陈那天在收拾院子——他在昌平租了个带院子的平房,周末过去。

小林到的时候,老陈正蹲在地上剪月季的枯枝。

“来得正好。“老陈说,“帮我扶一下这个梯子。”

小林扶着梯子,看他爬上去锯一根横着的枯枝。锯到一半,老陈在梯子上喊:“上个月那个会,你有答案了吗?”

“哪个答案?”

“那家公司该卖什么。”

”……”小林想了一下,“我倾向于卖标准。就是郑维说的那个。”

“为什么?”

“因为算法会趋同,数据会枯竭,只有标准能锁住客户。“小林说,“这话我上次就想说了,你没让我说。”

老陈从梯子上下来,拍了拍手上的灰。

“你这是标准答案。”

“什么意思?”

“这是任何一个读过平台战略的书的人,都能背出来的答案。“老陈说,“算法会商品化、数据是飞轮、标准是护城河。三句话,教科书里都有。”

“那不对吗?”

“对不对我不知道。“老陈说,“我只知道,如果你今天坐在周 CTO 那个位置上,用这三句话去说服董事会,你会被问死。”

“被问什么?”

“被问’你凭什么认为这个行业会形成标准’,被问’如果形成不了标准,你这四千两百万烧完了怎么办’,被问’现在做标准,还是三年后做,为什么是现在’。”

老陈把锯子挂回墙上。

“小林,你说的那句话,是对’这个问题’的回答。”

“那 P10 回答的是什么问题?”

“是这个问题值不值得被回答。”


老陈洗了手,从屋里拿了两瓶啤酒出来。四月的下午,院子里有风。

“我给你讲个地方。“老陈说,“协和医院急诊科。”

“你生病了?”

“去年冬天,我岳母半夜胸口疼。“老陈说,“我开车送她去的,凌晨一点四十。”

急诊大厅里坐了三十几个人。

“我当时特别震惊。“老陈说,“我以为急诊是那种推着担架冲进来、医生跑着过来的场面。不是。是一屋子人坐在塑料椅子上,有的抱着肚子,有的捂着脸,有个小孩在哭,有个老大爷在打呼噜。”

“我们在那儿坐了大概二十分钟,才有个护士过来。”

“她手里拿个小本子,挨个问。问到我岳母的时候,问了五个问题:哪里疼,多久了,有没有吃过药,有没有过心梗,血压多少。”

“然后她从一个盒子里拿出一条绿色的腕带,套在我岳母手上。”

“绿色是什么意思?“小林问。

“我当时也不知道。后来我才知道,协和的急诊分四级。红的是濒危,立即;橙的是危重,十分钟内;黄的是急症,三十分钟内;绿的是非急症,可以等。”

“我岳母是绿的。”


“那天晚上我在那个大厅里坐了三个小时。“老陈说。

“三点半的时候,推进来一个男的,四十多岁,被人架着进来的,脸色发白,手捂着胸口。护士过去问了三个问题,从盒子里拿了一条橙色的腕带。”

“那个男的后来被推进去了,不到五分钟。”

“而大厅里那些抱着肚子的、捂着脸的,继续坐着。有个老太太从一点排到四点,没人管。”

“我当时心里很不舒服。“老陈说,“我觉得这不公平。我们先来的。”

“后来呢?”

“后来我岳母四点半看上了,做了心电图,不是心脏的问题,是胃食管反流。医生开了药,我们五点二十到家。”

“那个橙色的男的呢?”

“我不知道。“老陈说,“他可能是心梗,可能不是。”


老陈喝了一口酒。

“小林,那天晚上那个大厅里,最关键的人是谁?”

”……拿腕带的那个护士?”

“对。”

“可是她连医生都不是。“小林说,“她就是个护士,她问了五个问题,套了个带子。”

“她决定了那三十几个人里,谁先看,谁后看,谁可能看不上。”

“那也得医生水平高啊。”

“我告诉你一个数字。“老陈说,“协和急诊科,医生的水平在国内是顶级的,这个你同意吧?”

“同意。”

“但是在一个心梗病人身上,从发病到血管打通,黄金时间是一百二十分钟。超过这个时间,心肌开始不可逆地坏死。”

“如果那个护士给他套了绿色——”

“他死。“老陈说,“不是可能死,是很大概率死。协和最好的心内科医生,最好的导管室,全套最先进的设备,全都在那儿。但他死了。”

“因为他根本没走到医生面前。”


“这件事我想了很久。“老陈说。

“从小到大,我们接受的全部教育,都是关于’怎么把一件事做对’。”

“考试:怎么把题解出来。” “工作:怎么把需求实现了。” “晋升:怎么把 KPI 完成了。”

“所有的训练,全部集中在执行这个环节。”

“但是在那个急诊大厅里,真正决定生死的那一步,不在执行环节。它在前面,在护士拿腕带的那三十秒里。”

“而这一步,没有任何人为此训练过她,除了那套四级分类标准。”

“小林,急诊医学里最伟大的发明,不是任何一种手术,不是任何一种药,是这块腕带。”

“它把’谁先活’这个不可能回答的问题,变成了一个可以回答的问题。”


小林听着,忽然想起一件事。

“我们公司去年那次大促,是不是也是这样?”

“说说看。”

“去年双十一前三天,我们同时出了三件事。“小林说,“一个是订单服务的 P99 从 200ms 涨到 3 秒,一个是履约对账每天差两百多单,还有一个是新上的优惠券系统漏发,一天大概六千张。”

“然后呢?”

“我们整个组四个人,加上大刘,五天五夜,全扑在订单服务的 P99 上。”

“为什么是那个?”

“因为……那个最吓人?“小林说,“P99 三秒意味着下单转圈,转圈意味着用户流失,那是直接的钱。”

“那优惠券呢?”

“优惠券后来自己好了。“小林说,“其实是配置错了,第三天发现的,一共漏发了大概两万张,赔了四十多万。”

“对账呢?”

“对账……”小林停了一下,“对账那个问题,一直到十二月才彻底查清楚。是仓那边回传的签收状态有延迟,我们的补偿逻辑没覆盖。累计差了六千多单,财务年底核销的时候发现账对不上,折腾了半个月。”

“损失多少?”

”……财务说,大概一百六十多万。”

“比优惠券多。”

“多。”

“比 P99 呢?”

小林没说话。


“你们四个人五天五夜,解决了一个 P99 的问题。“老陈说,“这个 P99 的问题,造成的损失是多少?”

“我们后来算过,按那三天转化率下降的差值算,大概……十二万。”

“十二万。“老陈重复了一遍,“四个人五天,六十个工时,解决了一个十二万的问题。同时放着一百六十万的问题不管,因为它不叫。”

“它不叫。“小林重复了一句。

“对账那个问题不报警。“老陈说,“它只是每天差两百单,安静地差,一天两百,三天六百,一个月六千。它不会有人半夜给你打电话。”

“所以没人理它。”

“所以我们永远在解决那些叫得最响的问题。”

老陈把空瓶子放在地上。

“小林,你现在可以回答我了吧:P9 和 P10 的区别是什么?”

小林想了一会儿。

“P9 是解决问题。”

“具体一点。”

“P9 是面对一堆问题,判断哪个先解决,并且带着组织把它解决掉。”

“那 P10?”

“P10 是……”小林停住了。

他看着院墙根那堆剪下来的枯枝。

“P10 是决定,这一堆问题里,哪些根本不该出现在这个清单上。”

老陈看了他一眼。

“再说。”

“就是……”小林慢慢地说,“对账那个问题,为什么会存在?因为我们和仓储之间没有一套统一的单据状态标准。如果我们三年前定过这个标准,这个问题根本不会有。”

“对账问题是一个症状。真正的病是’没有标准’。”

“P9 在治病。“小林说,“P10 在决定,这病该不该由我们来治,还是该让整个行业都不再生这个病。”

老陈没说话。

他站起来,走到院墙边,把那堆枯枝抱起来,扔到角落里。

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


Go 实例:一个签名,和它背后的二十年

“我再给你看个东西。“老陈回屋拿出笔记本,摆在石桌上。

屏幕上是一段 Go 代码。

package httpclient
 
import (
	"bytes"
	"net/http"
	"time"
)
 
// 第一版(2021 年,我们内部一个 HTTP 客户端库的真实代码,几乎一模一样)
type Client struct {
	Timeout time.Duration
	Retry   int
}
 
func (c *Client) Do(method, url string, body []byte) (*http.Response, error) {
	var lastErr error
	for i := 0; i <= c.Retry; i++ {
		req, err := http.NewRequest(method, url, bytes.NewReader(body))
		if err != nil {
			return nil, err
		}
		http.DefaultClient.Timeout = c.Timeout // 注意这行
		resp, err := http.DefaultClient.Do(req)
		if err == nil {
			return resp, nil
		}
		lastErr = err
	}
	return nil, lastErr
}

“你看出几个问题?“老陈问。

小林看了一会儿,笑了。“这谁写的……”

“我带的第一个徒弟。”

“哦。“小林咳嗽一声,“那我……说得直接点?”

“你直接说。”

“第一,改了全局的 DefaultClient.Timeout,这是共享状态,并发下会互相踩。”

“第二,没有 context,调用方没法取消。”

“第三,重试是把 body 反复用同一个 Reader 读,第二次就是空的。”

“第四,重试没有任何退避,也没有对 5xx 和 4xx 做区分,POST 请求重试可能重复下单。”

“第五,返回的 Response 没关 Body,会漏连接。”

“说完了?”

“说完了。”

“全对。“老陈说,“你现在是 P6 水平的问题清单,一条不落。”


“那我换个问法。“老陈说,“这个包,现在被公司里多少个服务引用?”

”……”

“四十三个。“老陈说,“从 2021 年到现在,三年。这四十三个服务里,有十九个是别的部门写的,我们已经管不着了。”

“现在我想给这个 Do 加一个 context 参数。”

小林张了张嘴。

“你加不了。“他说。

“为什么加不了?”

“因为你一改签名,四十三个服务的编译全挂。”

“对。“老陈说,“所以我有两条路。”

“第一条,硬改。发个通知,让四十三个服务一周内改完。你猜结果?”

“有人会忘了改,有人会觉得凭什么,有人已经在忙别的事了。”

“对。而且最要命的是,这四十三个服务里,有六个是季度末封版的,改不了。”

“第二条路呢?”

“加一个新方法。”

// 第二版:加一个带 context 的方法,旧方法保留
func (c *Client) DoContext(ctx context.Context, method, url string, body []byte) (*http.Response, error) {
	// 新实现
}
 
// Deprecated: 请使用 DoContext。本方法将在 v2.0 移除。
func (c *Client) Do(method, url string, body []byte) (*http.Response, error) {
	return c.DoContext(context.Background(), method, url, body)
}

“这不就解决了吗?“小林说。

“解决了吗?”

”……”

“小林,你告诉我,现在一个新同事第一次用这个包,他会用哪个?”

“看 IDE 补全,他先看到 Do。”

“对。IDE 会给他补全 Do,因为按字母序 Do 在 DoContext 前面。他用了。三个月后他写了一段需要取消的调用,他才发现有 DoContext。”

“而且,你猜这四十三个服务里,现在有几个迁到 DoContext 了?”

”……”

“十一个。三年,十一个。”

“剩下三十二个,永远都不会迁了。因为它们的代码已经稳定了,没人愿意为了一个’更好的 API’去动一段正在赚钱的代码。”

“所以这个包里,会永远有两个 Do。”


“这不是我一个人的问题。“老陈说,“你去看看标准库。”

// net/http 包里,这些函数都是"历史遗留"
resp, err := http.Get(url)                    // 没有 ctx
resp, err := http.Head(url)                   // 没有 ctx
resp, err := http.Post(url, ct, body)         // 没有 ctx
resp, err := http.PostForm(url, values)       // 没有 ctx
 
// 后来补上的
req, _ := http.NewRequestWithContext(ctx, method, url, body)
resp, err := http.DefaultClient.Do(req)

“http.Get 为什么不带 ctx?”

“因为 Go 1.0 的时候还没有 context。“小林说。

“对,context 是 Go 1.7 才进标准库的,2016 年。而 http.Get 是 2012 年 Go 1.0 就有的。”

“那他们为什么不删掉 http.Get?”

“因为删了,全世界几千万行代码全部编译失败。”

老陈关掉笔记本。

“小林,Go 团队是这个世界上纪律性最强的工程团队之一。他们有世界上最好的工程师,有充裕的时间,有完整的向后兼容理论。但即使是他们,在 2012 年写下 http.Get 的那一天,也被锁死了。”

“他们锁了多久?”

“到今天,十三年。还会继续锁下去,可能锁到 Go 3.0,也可能永远。”


“你知道这意味着什么吗?“老陈说。

“意味着写代码的时候要小心?”

“不止。”

“意味着——你今天写下的每一个对外暴露的签名,都是一张没有到期日的欠条。”

“你写下 func (c *Client) Do(method, url string, body []byte) 的那三十秒里,你其实是在对全世界说:”

“‘我保证,在可预见的未来里,一个 HTTP 请求只需要这三个信息。’”

“这个保证,在 2021 年看起来天经地义。”

“但它错了。因为这个世界后来多了一个东西叫链路追踪,多了一个东西叫超时预算,多了一个东西叫灰度标。这三样东西,没有一样能塞进那三个参数里。”


“那正确写法是什么?“小林问。

“你现在问我正确写法,说明你已经知道答案了。”

“我想听你说。”

老陈把笔记本又打开。

package httpclient
 
import (
	"context"
	"errors"
	"io"
	"net/http"
	"time"
)
 
// 第三版:为"未来一定会变的东西"留出位置。
//
// 设计原则(这不是审美,这是三条硬约束):
//   1. ctx 必须是第一个参数,永远。
//   2. 请求用一个结构体承载,加字段不破坏兼容。
//   3. 行为用一个 Option 列表控制,不暴露字段。
type Client struct {
	base    *http.Client
	baseURL string
	retry   RetryPolicy
	before  []Hook
}
 
type Option func(*Client)
 
func WithTimeout(d time.Duration) Option { /* ... */ }
func WithRetry(p RetryPolicy) Option     { /* ... */ }
func WithHook(h Hook) Option             { /* ... */ }
 
func New(baseURL string, opts ...Option) *Client { /* ... */ }
 
type Request struct {
	Method  string
	Path    string
	Body    io.Reader // 注意:不是 []byte
	Header  http.Header
	TraceID string // 后来加的,没人需要改调用点
	Stage   string // 灰度标,也是后来加的
}
 
// Do 执行请求。
// 契约:ctx 取消后必须尽快返回;4xx 不重试;5xx 按 RetryPolicy 重试。
func (c *Client) Do(ctx context.Context, req *Request, opts ...CallOption) (*http.Response, error) {
	if ctx == nil {
		return nil, errors.New("httpclient: nil context")
	}
	// ...
	return nil, nil
}

“看出来了?“老陈说。

“Request 是结构体,以后加 TraceID、加灰度标、加租户 ID、加任何东西,调用方一个字都不用改。”

“对。”

“Option 是可变参数,以后加新的配置方式,New 的签名也不变。”

“对。”

“Body 是 io.Reader 不是 []byte。”

“这个你也说说,为什么?”

小林想了想。“因为 []byte 意味着你必须把整个 body 读进内存。如果要发一个两 GB 的文件,内存就炸了。”

“还有呢?”

“还有……可以流式?”

“对。io.Reader 允许实现者决定数据从哪来。可以是内存里的字节,可以是文件,可以是一个正在被计算的流,可以是一个加密管道。”

“这个和刚才那个 Checker 接口是一个道理吧?“小林说。

“是一个道理。“老陈说,“给实现者留自由。”


“但是,“老陈话锋一转,“你别以为这样就完美了。”

“还有什么问题?”

“这个设计有一个巨大的代价,你知道是什么吗?”

小林看着那段代码。

”……Option 太多会失控?”

“那是一个。但最大的代价是——”

老陈指着 Request 结构体。

“你放弃了对请求内容的控制。”

“什么意思?”

“当你把请求定义成一个开放的结构体,世界上的任何人都可以往里塞东西。三年后,这个结构体里会有二十七个字段,其中八个是某个业务线’临时’加的,四个已经没人用了但没人敢删,还有两个字段的含义是互相矛盾的。”

“你打开一个调用点,看到 Request{Method: "POST", Path: "/x", Stage: "gray", TenantID: "t1", NoRetry: true, ForceV2: true},你完全不知道这个调用在干什么。”

“而如果你当初定义的是 Do(ctx, method, url string, body []byte),调用点永远是清楚的,就三个东西。”

”……”

“所以你看,“老陈说,“窄的好处是清楚,宽的代价是混乱。宽的收益是可扩展,窄的代价是锁死。”

“这两个,你选哪个?”

”……我不知道。”

“这就对了。“老陈说,“你不知道,是因为这个问题没有正确答案。”

“它只有一个:你押哪个未来。”

“押’未来会有很多我没想到参数’,你就用 Request 加 Option。” “押’未来的调用方需要一眼看懂’,你就用平铺的参数。”

“选错了呢?”

“选错了,你会在三年后的某一天,坐在工位上,看着那段代码,想起今天下午。”

“然后你什么也做不了。“

老陈的追问

  1. 你现在维护的、对外暴露的 API 里,有哪一个是你三年前写的?如果让你现在改它的签名,需要通知多少个人?这个数字,你写它的时候算过吗?

  2. 你自己写过的代码里,有没有一处是因为”当时觉得没人会这么用”而简化了设计,后来被打脸的?

  3. 你现在负责的系统里,有多少个问题是”因为它不报警所以一直没人管”?如果让你不看监控系统,只凭直觉说出前三个,你说得出来吗?

  4. 如果你今年只能做一件事,你会做哪件?现在,把你脑子里冒出来的第一个答案划掉——那是叫得最响的那个。第二个是什么?

  5. 你上一次主动去定义”我们该解决什么问题”,是什么时候?如果答案是”没有过”,那你到目前为止,一直在别人的清单上挑活。

生活思考题

题一:为什么医院要分那么多科室,而病人永远不知道自己该挂哪个?

大多数人会怎么答

因为医学太复杂了,专业分工越来越细,这是进步的表现。病人不懂,所以导诊台才有存在的必要。

这个答案为什么不够

这个答案默认了”科室划分是客观的”。但它不是。

你知道精神科和神经内科的分界线在哪吗?你知道口腔科和耳鼻喉科的重叠地带归谁吗?你知道”眩晕”这个症状,在心内科、神经内科、耳鼻喉科、骨科(颈椎)、精神科都能看吗?

科室的划分不是从人体里长出来的,是被人划出来的。

而且不同国家划得不一样。日本有”心疗内科”,美国有”家庭医学科”(primary care),中国有大内科。同一个病人,在这三个国家会被分到完全不同的地方。

更深的一层

科室划分的背后,是一件事:你用什么维度去切这个世界。

按器官切:心内科、呼吸科、消化科。 按人群切:儿科、妇产科、老年科。 按手段切:外科、内科、介入科、放疗科。 按病程切:急诊、门诊、康复科、临终关怀。

每一种切法都是自洽的,每一种切法都会漏掉一些东西。而你一旦选了一种切法,所有人的思维方式就被锁死了。

一个医生在医学院学了五年”按器官切”,他这辈子看病人,第一反应都是”哪个器官坏了”。让他切换到”按病程切”,他需要重新学一遍。

这就是为什么”全科医生”这个东西这么难推。它不是一个岗位,它是一种完全不同的本体论。

最深的那一层是:定义维度的人,掌握了这个领域里最大的权力,而且这个权力是隐形的。

他不决定治什么病。他决定”什么算一种病”。

一旦”抑郁症”被定义成一种病,就有了一个科室、一批医生、一堆药、一个市场。 一旦”亚健康”没有被定义成病,它就永远只能停留在保健品柜台。

落到 Go 工程师身上

你写代码的时候,你也在不停地”分科”。

你定义一个 package,是在切这个系统。 你定义一个 struct,是在切这个领域。 你定义一个 error 的类型体系,是在切”什么算失败”。

这三件事,都比你以为的重要得多。

具体做一件事:打开你最核心的那个包,把它导出的所有类型列出来。

然后问自己:如果一个完全不了解我们业务的 Go 工程师,只看这些类型的名字和字段,他能不能说出我们这个系统认为”世界上有什么”?

如果他的理解是错的——比如你有一个 Order 类型,但没有 Refund 类型,退款逻辑藏在 Order.Status 的某个枚举值里——那你就在告诉全世界:在我们的世界观里,退款不是一种独立的东西,它是订单的一个状态。

这个判断,在你写下那行代码的那天生效,在你离职那天还在生效。

第二件事:去看一个你敬佩的开源库的包结构。 比如 net/http、database/sql、go.uber.org/zap。

不要看它怎么实现的,看它怎么切的。然后问:它为什么这样切?如果换一种切法会怎样?

这个练习没有正确答案,但做十次之后,你的”切世界”的手感会完全不一样。

题二:为什么很多人在工作中特别忙,但年底述职的时候说不出自己做了什么?

大多数人会怎么答

因为做的是琐碎的事,没有亮点。因为不会包装,做了十分说不出三分。因为领导看不见。

这个答案为什么不够

“不会包装”这个解释,掩盖了一个更根本的问题。

你去问那些”忙了一整年说不出来”的人,让他随便讲一件他今年做的事。他会讲得出来,而且往往讲得挺细:那个接口怎么设计的,那个 bug 怎么查的,那个迁移怎么做的。

他不是没做事,他是做的事情之间没有一条线。

更深的一层

“忙”和”有产出”是两件不同的事。

忙,是你的时间被填满了。 产出,是你的时间被投在了同一件事上。

一个人可以在一年内做五十件事,每件都做得不错,年底一件都说不出来——因为这五十件事分属于五十个不同的问题。

另一个人可以在一年内做十件事,看起来比前面那个人清闲得多,但因为这十件事全部指向同一个目标,他可以用三分钟讲清楚他今年干了什么。

区别不在于做了多少,在于你今年做的事情,是不是被一条主线串起来的。

而这条主线,大部分人是没有的。因为大部分人的工作内容,是”别人给什么就做什么”。

需求从产品来,工单从运维来,故障从监控来。你是一个响应者。响应者的时间永远是被填满的,因为这个世界产生问题的速度,永远大于你解决问题的速度。

这是一条铁律。你不可能靠”做得更快”来赢。

更深的一层是:如果你不接受这条铁律,你会一直试图用”提高效率”来解决”时间不够”的问题,而这是一个无底洞。

你学会了用 vim,省了五分钟。 你学会了写更好的 shell 脚本,省了半小时。 你用上了 AI 辅助编程,省了两小时。

然后你的待办清单,在一天之内又被填满了。因为清单的长度不取决于你的速度,取决于入口的带宽。

唯一的解法,是减少入口。

不是让自己做得更快,是让一部分问题根本不进你的清单。

落到 Go 工程师身上

三件具体的事:

第一,每周五下午,花二十分钟做一件事:把你这一周做的事列出来,然后给每一件标上一个”它服务于哪个目标”。

标不上的,就是没有主线的。连续做四周,你会看到一张很扎眼的图。

第二,主动去定义你下个季度只做三件事,然后公开说出来。说给你的leader听,说给你的合作方听。

这个动作的意义不在于”聚焦”,在于它会强迫你拒绝。当你公开说了”这个季度我只做这三件事”,第四件事来的时候,你就有了一个可以说出口的理由,而不用每次都现场解释。

第三,每个季度,挑一件你清单上”叫得最响”的事,把它从清单上划掉,不做。

然后观察三周。

大部分时候,你会发现:那件事不做,天也没塌。它会自己消失,或者被别人解决,或者它本来就不重要。

少数时候,它会炸。而那次爆炸,会是你今年学到的最贵的一课——你终于知道,什么叫”真正重要的事”。

7.3 三到五年:P10 的时间尺度和你不一样

那条路上的树

五一前,小林请了一天假,跟老陈回了趟老陈的老家,河北保定下面的一个县。

老陈去给父母上坟。小林是跟着去的,路上老陈说了句:“我爸是个木匠。”

“我知道。“小林说,“你上次提过。”

“我爸做了四十七年家具。“老陈说,“从十六岁学徒,做到六十三岁。死在肺癌上,六十五。”

车窗外是四月的华北平原,两边是刚返青的麦地。

“他这辈子最得意的事,不是打了多少家具。”

“那是什么?”

“七五年,他参与过一次种树。”


“那年他二十九岁,在生产队。县里下来一个任务,说要把县城到公社那条十公里的路,两边都种上树。”

“队里分了两千米,从砖窑到岔路口那段。”

“我爸说,当时有两种树苗可选。”

“一种是加拿大杨,速生的,长得快,三年就能有巴掌粗,五年就能遮阴。队里大部分人都选这个,因为眼见得着。”

“另一种是法国梧桐,慢。第一年长不了三十公分,前三年基本看不出变化。苗还贵,一棵贵八分钱。”

“我爸他们那个组,五个人,吵了一下午。最后组长拍板:一半一半。前一千米种杨树,后一千米种梧桐。”

“为什么这么分?”

“因为组长也不知道哪个对。“老陈说,“他说,先种着,十年后看。”


车下了高速,拐上一条县道。老陈放慢了车速。

“你往两边看。”

小林看过去。

路两边是树,很粗,树冠在路中间合拢,整条路像一条绿色的隧道。

“这是……”

“砖窑到岔路口。“老陈说,“就是我爸种的那一半。”

“这一段是?”

“梧桐。”

小林摇下车窗。树皮是那种斑驳的灰绿色,一片一片剥落。树干要两个人才抱得过来。

“那些杨树呢?“小林问。

老陈往前指了指。

前面大概两百米,路的尽头,是一片杨树。明显细一圈,树干笔直,叶子小。

“杨树还在?”

“这不是原来的。“老陈说,“原来的杨树,八六年就砍了。”

“才十一年?”

“十一年。“老陈说,“八五年一场大风,刮倒了十几棵。剩下的开始飞絮,一飞半个月,路两边的人家都不敢开窗。而且速生杨木质松,容易空心,从里面烂,看着好好的,一刮风就断。”

“八六年全砍了,又种了一批。那批到零三年,又砍了。”

“现在这个是第三批。”


老陈把车停在路边,两个人下来。

“我爸是九七年死的。“老陈站在梧桐树下,“他死的时候,这批梧桐种下去二十二年。”

“他见过它们长成这样吗?”

“见过。“老陈说,“他晚年每天骑个二八大杠,从城里骑到这儿,在树下坐一会儿,再骑回去。”

“他怎么说?”

“他没说过什么。“老陈说,“他不是那种会说话的人。”

“但是我记得一件事。我九二年考上大学,走之前,他骑车带我到这儿,指着那排梧桐跟我说了一句话。”

“什么话?”

“他说:‘这树不是给我长的。’”

老陈抬头看了看树冠。四月的阳光从叶子缝里漏下来。

“那年我十九岁,我觉得这话挺酸的,一个快死的老头子说的话。”

“现在我五十三,我站在这下面,我才知道他说的是一句特别实在的话。”


“小林,你算一下这个时间。”

“七五年种的树。” “八零年,看不出变化。” “八五年,刚有个人影那么高。” “九零年,能遮一点了。” “九五年,成荫。” “到今天,五十一年。”

“我爸做这件事的时候二十九岁。他死的时候六十五,看不到五十年后的样子。”

“他知不知道这树要五十年才成气候?”

“他知道。“老陈说,“他是个木匠,他懂木头。他知道梧桐慢。”

“那他为什么还种?”

老陈看了小林一眼。

“你先想想。”


回到车里,小林一路没怎么说话。

快到县城的时候,他忽然开口。

“我知道他为什么种了。”

“说。”

“因为他是个木匠。“小林说,“木匠这行,一辈子在跟时间打交道。他做一张八仙桌,用的料要放三年才能开料;他给人家打一套家具,漆要上七遍,一遍阴干七天。他这辈子做的每一件事,都是做完之后很久才有结果的那种事。”

“他已经习惯了。”

“对。“老陈说,“他不需要看见结果。他只需要知道这件事是对的。”

“可是……”小林顿了一下,“这不是有点亏吗?”

“哪里亏?”

“他种了树,他自己只看到了第二十二年。剩下的二十九年,是别人在乘凉。”

老陈笑了。

“小林,你反过来想。”

“他二十九岁那年,站在那条土路上,手里拿着两捆树苗。一捆三年成荫,一捆五十年成荫。”

“他选了那个慢的。”

“他这辈子剩下的三十六年里,每次骑车经过这条路,他都会看到那排树。第一年看不出变化,第三年看不出,第五年还是看不出。他会怀疑自己是不是种错了。”

“然后到了第十年,他忽然发现,这条路有一段开始有阴凉了。”

“老陈看着挡风玻璃外面,“你说,他这三十六年里,是亏了还是赚了?”

小林想了很久。

“赚了。”

“为什么?”

“因为那条路一直陪着他。“小林说,“他不是等到五十年才拿到回报的。他每年都能看见那棵树长了一点。他这三十六年,是一直有东西可看的。”

老陈没说话,过了一会儿才说:

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


“小林,我给你讲这个,是因为我要告诉你一个数字。”

“什么数字?”

“中国互联网公司里,一个技术总监在一个岗位上的平均任职时间,是二十八个月。”

“两年零四个月。”

“一个 P9 的平均任期是三十四个月。”

“一个 P10 的平均任期是四十七个月,不到四年。”

老陈转过一个弯。

“而一个技术方向从投入到看到结果,需要多久?”

“我不知道。”

“我给你几个真实的。”

“Kubernetes 从 Borg 立项到成为事实标准,九年。” “Go 语言从 2007 年第一个编译器到 2015 年真正被大规模采用,八年。” “AWS 从 2006 年上线 S3 到云计算被普遍接受,八年。” “你们公司那套履约系统,从立项到稳定运行,多久?”

”……三年。“小林说。

“那是个中型系统,二十几个人。”

“一个技术战略呢?”

“五年。“小林说。

“至少五年。”


“所以你看这个矛盾。“老陈说。

“一个 P10 要押的注,需要五年才能验证。而他自己,平均只能在这个位置上待四年。”

“这意味着什么?”

小林愣了一下。

“意味着他看不到结果。”

“对。“老陈说,“这意味着,P10 这个级别的全部工作,本质上都是在做一件自己看不到结果的事。”

“这就是这一级最反人性的地方。”

“P4 到 P8,你的工作全部有即时反馈。写完代码能跑,跑完有日志,上线有监控,季度有绩效。你做的每一件事,最快几分钟、最慢三个月,一定有一个结果回到你身上。”

“P9 开始,反馈变慢。你培养一个人,要看一年。你建一个平台,要看两年。”

“P10,反馈消失了。”

“你种下一棵树,你大概率不会看到它成荫。”


“所以大部分人做不了这一级。“老陈说,“不是因为不够聪明,不是因为技术不行。”

“是因为受不了没有反馈。”

“人在没有反馈的状态下,会做三件事,一件比一件糟糕。”

“第一件,他会开始频繁地换方向。因为没有反馈,他不知道现在这条路对不对,所以他每六个月就想换一条。这在公司里表现为’战略摇摆’。”

“第二件,他会开始做那些有即时反馈的小事。他会去 review 代码,会去管具体的需求,会去参加那些他本不该参加的评审会。因为只有在那儿,他才能感觉到’我今天干了点什么’。这在公司里表现为’高层下沉’。”

“第三件,最糟的。”

“他会开始给自己编一个已经成功的叙事。”

“他会跟别人说,我们这个方向是对的。他会找各种数据来证明。他会开始相信自己的 PR。因为在没有真实反馈的情况下,自我叙事是唯一能让他不崩溃的东西。”

“这三件事,任何一个组织的高层在出现的时候,都会腐蚀整个组织。”

“而它们全部源于同一个东西:人受不了没有反馈。“

Go 实例:一个版本号,和它背着的八千个仓库

“说回技术。“老陈说,“你知道 Go 是怎么处理’改一个你不能改的东西’这件事的吗?”

“用 GODEBUG?“小林说,“我见过,但是没深究过。”

“我给你看一个真的。”

他掏出手机,翻出一段代码。

package main
 
import (
	"fmt"
	"math/rand"
)
 
func main() {
	// Go 1.20 之前:全局 rand 是确定性的,每次运行都输出同一个数
	// Go 1.20 之后:全局 rand 自动随机播种
	fmt.Println(rand.Intn(100))
}

“这个变化,看起来很小吧?“老陈说。

“很小。就是默认有没有种子。”

“你知道 Go 团队为了这个’很小’的变化,花了多少个版本吗?”

”……”

“三个。”


“过程是这样的。”

“Go 1.20,他们加了一个 GODEBUG 开关:randautoseed=0。默认值是 1,也就是自动播种。行为变了。”

“结果是什么?”

“结果是全世界的测试炸了。”

“不是 Go 自己的测试。是全世界所有用 rand.Intn 写测试的公司,他们的测试突然开始随机失败了。有些是测试数据生成,有些是打乱顺序,有些是模拟。”

“Go 团队收到了几十个 issue。”

“然后他们做了什么?”

“Go 1.21,他们把默认值改回去了。randautoseed=1 恢复成旧行为。”

“等等,“小林说,“那不是回滚了吗?”

“是回滚了。“老陈说,“一个全世界最讲究工程纪律的团队,为一个’每次运行输出不同的随机数’的改动,正式回滚了一次。”

“那最后怎么解决的?”

“Go 1.22。他们换了个思路:不全局改,而是让 rand.Seed 变成一个 no-op(空操作),同时全局源自动播种,但通过 GODEBUG 保留了完整的降级路径,并且把这个变更写进了兼容性文档的第一条。”

“前后三个版本,两年。”

“为了一行代码。”


“你知道这件事给我最大的触动是什么吗?“老陈说。

“是他们很谨慎?”

“不是。”

老陈把手机收起来。

“是他们承认了一件事:改一个已经存在于世界上的行为,成本是无限的。”

“你想想这个链条。”

“Go 团队写下 rand.Intn 的那天是 2009 年。他们当时有没有想过’全局随机数要不要有默认种子’这个问题?”

“可能想过。”

“他们可能还讨论过。然后他们做了一个决定:让它确定性播种,这样测试可复现。”

“这个决定在 2009 年是对的。2009 年,Go 还在实验室里,用户是几十个 Google 工程师。测试可复现比什么都重要。”

“然后十五年过去,这个决定的受益者变成了几百万人。”

“而其中有一个人,在 2023 年某天下午,写了一个用 rand 生成测试数据的工具,跑了三年,一直很稳。”

“然后 Go 1.20 出来了。”

“他那天下午花了四个小时,在查一个根本不是他造成的问题。”


“我再说个更狠的。“老陈说。

“math/rand 这个包里,有一个函数叫 rand.Read。它在 Go 1.24 被 deprecated 了,推荐用 crypto/rand。”

“你知道 deprecated 是什么意思吗?”

“就是标记废弃,以后会删。”

“什么时候删?”

”……”

“不知道。“老陈说,“官方没说。可能五年,可能十年,可能永远不删。”

“因为一旦删掉,全世界几万个仓库会编译失败。”

“所以’废弃’这个词,在标准库里真正的含义是:”

“‘我们知道这是个错误,但我们已经没有权利修正它了,我们只能告诉后来的人别再用。’”


小林听着,忽然觉得后背有点凉。

“老陈,那我们自己写库的时候呢?”

“你们现在怎么发版本的?”

”……发 tag。v1.2.0、v1.3.0 这样。”

“有没有过 breaking change?”

“有啊。“小林说,“去年我们把一个包的 New 函数参数改了,发了个 v1.8.0,然后在群里通知了一声。”

“通知了一声。”

“嗯,还写了个迁移文档。”

“小林。“老陈说,“我给你看个东西,你自己判断。”

// 错误做法:在 v1 内部做不兼容变更
//
// module github.com/ourcorp/kit
 
// v1.7.0
func New(addr string, timeout time.Duration) (*Client, error)
 
// v1.8.0 —— 参数顺序变了,还加了第三个参数
func New(timeout time.Duration, addr string, opts ...Option) (*Client, error)

“如果你在 v1.8.0 里这么改,“老陈说,“你的用户执行 go get -u 的时候会发生什么?”

“编译失败。”

“他什么时候会发现?”

”……他下次构建的时候。”

“他下次构建是什么时候?“老陈问,“是他正在赶一个上线的时候?是他半年没碰这个项目,突然要修一个 bug 的时候?还是他的 CI 在一个周五的晚上跑的时候?”

“你不知道。”

“这就是问题:你把一个你造成的问题,在一个你完全不知道的时间点,扔给了一个你完全不认识的人。”

“而且他没有选择。他不能说’我不升级’,因为他可能依赖你另一个 bugfix。”


“那正确做法是?“小林问。

“Go 的答案很笨,但很有效。”

// 正确做法:用 module path 承载不兼容变更
//
// go.mod(v1 分支)
module github.com/ourcorp/kit
 
// go.mod(v2 分支)
module github.com/ourcorp/kit/v2

“就这一个区别。“老陈说,“在 module path 后面加 /v2。”

“这有什么用?”

“用处大了。”

“第一,v1 的用户执行 go get -u,永远不会拿到 v2。他们的代码永远能编译。”

“第二,想用 v2 的人,必须显式地改 import 路径:

import "github.com/ourcorp/kit/v2"

这意味着他知道自己在做一次迁移,他是有准备的。”

“第三,也是最关键的:v1 和 v2 可以在同一个二进制里共存。”

package main
 
import (
	v1 "github.com/ourcorp/kit"
	v2 "github.com/ourcorp/kit/v2"
)
 
func main() {
	c1, _ := v1.New("addr", time.Second)  // 老代码,不动
	c2, _ := v2.New(time.Second, "addr")  // 新代码,用新 API
	_, _ = c1, c2
}

“一个服务里,旧代码用 v1,新代码用 v2,你可以一点点迁,迁一年都行。”

”……”小林看着这个例子,“这个设计好笨。”

“笨得要死。“老陈说,“Russ Cox 写这个提案的时候,社区吵翻了。大家都说太丑了,为什么版本号要在 import 路径里,别的语言都不这样。”

“他怎么回的?”

“他说了一句话,我记到现在。”

“他说:‘我们不是在优化优雅,我们是在优化一个十年后还要能编译的世界。’”


“我给你总结一下这个版本号背后的三条原则。“老陈说。

“第一条,向后兼容不是一个技术选择,是一个承诺,而承诺是有成本的。”

“你承诺兼容 v1,意味着你要永远维护 v1,哪怕它设计得很烂。你要为它的 bug 打补丁,要为它的安全漏洞发版本,要在社区里回答关于它的问题。这个成本,你要在写下 v1 的那天就算清楚。”

“第二条,版本号是你和用户之间的契约,不是你的内部标记。”

“v1.8.0 里的 8 说的是’我加了一些功能,你不用改代码’。如果你在 v1.8.0 里做了一个需要用户改代码的变更,你就是在骗人。而骗人的代价,是用户再也不信你的版本号。”

“第三条,最反直觉的一条:在你还没想清楚之前,不要发 v1。”

“见过太多库了,v0.1 就开始被几百个项目依赖,作者还在天天改 API。”

“v0.x 这个前缀的全部意义就是:告诉全世界’我随时会改,别依赖我’。它是你唯一的免责条款。”

“一旦你发出 v1.0.0,你就失去了这个权利。”


“小林,我再问你一个事。”

“你说。”

“Go 1.0 是 2012 年 3 月发布的。到现在十四年,Go 的版本号到哪了?”

“1.24。”

“对。还是 1.x。“老陈说,“十四年,没有 2.0。”

“为什么?”

“因为 Go 1.0 发布的时候,他们做了一个承诺:Go 1 兼容性保证。所有符合 Go 1 规范的代码,在这个大版本的生命周期内,永远可以编译运行。”

“十四年前写的代码,今天还能编译。”

“你知道这个承诺的代价是什么吗?”

小林摇头。

“是他们永远不能修复任何一个’错误的设计’。”

“math/rand 的全局播种,他们知道是问题,改不了。” “http.Get 没有 ctx,他们知道是缺陷,加不了。” “context.Context 到处传,他们知道很难看,动不了。” “nil 可以赋值给接口然后 panic,他们知道这是坑,填不了。”

“每保留一个这样的东西,都是在还 2012 年那笔债的利息。”

“而这个利息,会一直付到 Go 2.0 出现,或者永远。”


老陈看着车窗外。

“所以你现在明白,为什么 P10 做一个决定要那么久了吗?”

“因为他知道,“小林说,“他今天签的字,可能要还十年的利息。”

“不止十年。“老陈说,“有些决定,是永远的。”

“你定一个协议,十年后它在几万台机器上跑。” “你定一个数据格式,二十年后还有人在解析它。” “你招一个人,他这辈子都带着你给他的这段经历。”

“小林,这就是为什么到这一级,人变慢了。”

“不是因为他变笨了,不是因为他变保守了。”

“是因为他终于知道,签字这件事是有重量的。“

老陈的追问

  1. 你做过的最长的一个技术决策,是哪一件?它现在还在生效吗?如果还在,它已经活了多久?

  2. 你现在手上有没有一个”我知道它是错的,但已经改不动了”的设计?它是怎么变成这样的?如果回到你写下它的那一天,你会怎么做?

  3. 你的团队有没有一份”我们承诺什么”的文档?不是给领导看的规划,是给用户(内部或外部)的兼容性承诺。如果没有,你的用户凭什么敢依赖你?

  4. 你有没有为自己设过一条”三年后要验证”的判断?如果有,到日子了去看过吗?如果从来没有,那你所有的学习都只发生在”事后解释”的层面,而不是”事前预测”的层面。

  5. 如果让你做一件事,你知道它要五年才有结果,而你这辈子可能看不到,你还会做吗?你的答案是什么不重要,重要的是——你能不能诚实地回答,而不是给出一个你觉得应该给的答案。

生活思考题

题一:为什么很多人健身,总是在第三周放弃?

大多数人会怎么答

因为没有毅力。因为太累了。因为没有找到乐趣。因为目标定太高了。

这个答案为什么不够

“没有毅力”这个解释,等于什么都没说。它只是把现象换了个名字。

真正值得问的是:为什么是第三周,不是第一周,也不是第十周?

第一周是最难的。肌肉酸痛,动作不标准,去健身房还要换衣服。按”毅力”理论,第一周应该放弃最多。

但现实是,第一周的放弃率并不高。第三周才是高峰。

更深的一层

因为第三周,是反馈消失的那一周。

第一周:你第一次做完一组卧推,第二天整个胸都是酸的。这是强反馈。你的身体在告诉你”发生了点什么”。

第二周:酸痛减轻了,但你能明显感觉到力量涨了。上周推 20 公斤只能做 8 个,这周能做 12 个。这还是反馈,而且是很好的反馈。

第三周:酸痛没了。力量还在涨,但涨得慢了,一周可能只多做一个。更重要的是——镜子里的你,一点没变。

你连续去了二十一天,你照镜子,你和二十一天前一模一样。

这就是崩溃点。

人放弃一件事,从来不是在最难的时候,是在”付出了但看不见变化”的时候。

最难的那一周,反而坚持得下去,因为痛苦本身就是反馈。 最舒服的那一周,反而坚持不下去,因为什么都没有发生。

最深的一层

所有的”长期主义”之所以难,不是因为它需要坚持,是因为它需要在一段负反馈期里保持理性。

什么叫负反馈期?就是你明明在做事,但世界给你的所有信号都在说”你在做无用功”。

健身的第三周到第八周。 学一门语言的第三个月到第八个月。 写一个库的第一个用户出现之前。 带一个团队,从你开始培养到他第一次独立做对判断,中间那九个月。 押一个技术方向,从立项到它第一次产生商业价值,中间那三年。

这些时间段里,你唯一的支撑不是反馈,是你对这件事的判断。

而判断这个东西,是没有快感的。

落到 Go 工程师身上

第一,学会识别”反馈空窗期”,并且提前跟自己约定好它有多长。

你要开始一件长期的事之前,先问:这件事的第一次真实反馈,会在什么时候出现?

学 Go 的运行时原理:第一次真实反馈,可能是”你第一次用 pprof 解决了一个别人解决不了的问题”,大概六个月。 做一个内部库:第一次真实反馈,是”第一个你不认识的人来问你问题”,大概一年。 培养一个人:第一次真实反馈,是”他在你不在场时做了一个你认同的判断”,大概九个月。

把这几个数字写下来。到了第三周你想放弃的时候,你看一眼:我这才第三周,我的反馈在第六个月。我不是没有进展,我是还没到。

第二,在没有外部反馈的时候,自己造一个粗糙的、但真实的反馈信号。

不是”打卡”,打卡是假的。是真的信号。

比如你在学 Go 的调度器,别只记笔记。每周写一篇短文,发到你们团队的内部 wiki 上,或者发到网上。第一篇只有三个人看,第二篇有人问了一个问题,第三篇有人指出你写错了一个地方。

那个”有人指出你写错了”,就是真实的反馈。它证明你在这个方向上已经走得足够深,深到有人愿意纠正你。

第三,最狠的一条:主动去做一件你知道三年才有结果的事,并且不告诉任何人。

不告诉任何人,意味着你拿不到任何社会性的反馈——没有点赞,没有认可,没有”哇你好自律”。

这件事的真正作用是:它会帮你区分两件事。

你做一件事,是因为你相信它是对的,还是因为你需要别人看见你在做?

这两者的区别,就是 P9 和 P10 的区别。

题二:为什么很多老字号活不下去,而它隔壁那家开了两年的网红店却在排队?

大多数人会怎么答

因为老字号不思进取,不做营销,产品老化。网红店会包装,懂年轻人。

这个答案为什么不够

这个答案听起来很有道理,但它解释不了一个事实:大部分网红店,两年后也死了。

你回忆一下三年前你排过的队。那家店现在还在吗?

网红店的死亡率,比老字号高得多。老字号之所以你还能看到它,恰恰因为它活下来了——而那些和它同时代、同样不思进取的店,早就死了,你根本没见过。

这是一个典型的幸存者偏差。你拿”活下来的老字号”去比”当红的网红店”,你比较的是两个被筛选过的样本。

更深的一层

真正的问题是:在一个长周期里,什么东西在衰减,什么东西在累积?

网红店累积的是注意力。 老字号累积的是信任。

这两种东西的时间曲线完全不一样。

注意力是一条脉冲:拉起来很快,衰减也很快。它的半衰期是三个月到一年。而且它有一个特点:你需要持续地投入才能维持它。你停止营销,注意力立刻归零。

信任是一条缓慢上升的曲线:前五年几乎看不见,五年后开始复利。而且它的特点相反:你停止投入,它还会持续很久。

一家开了三十年的店,你一年不做任何营销,明年它还在。因为”我小时候我爸带我来过”这件事,活在几万个人的记忆里。

最深的一层:这两种曲线,决定了两种完全不同的活法。

做注意力生意的人,必须永远在找下一个热点。他的焦虑是结构性的,不是因为他贪心,是因为他的资产每天在贬值。

做信任生意的人,可以十年做一件事。他的耐心也是结构性的,不是因为他淡泊,是因为他的资产每天都在增值。

而最残酷的地方在于:这两种活法,在前三年看起来完全一样,甚至前三年注意力生意会赢。

开一家网红店,三个月回本。 开一家老字号,三年才开始有回头客。

第三年的时候,网红店老板换了第三辆车,老字号老板还在为房租发愁。

第五年,网红店老板在找下一个项目。 第十年,老字号老板的儿子接了班。 第二十年,那条街上只有他一家。

落到 Go 工程师身上

你现在就可以做一次诚实的选择:你过去三年的积累,哪些是注意力,哪些是信任?

注意力型的积累:

  • 学会了某个当下很火的框架
  • 在某个大会上做了一次分享
  • 简历上多了一个大厂的名字
  • 追上了某个技术热点

信任型的积累:

  • 有一个你维护了三年以上、别人真的在用的东西
  • 有五个人,遇到某一类问题会第一时间想到来找你
  • 有一个你比 99% 的人懂得深的窄领域
  • 有一份你在某个问题上的判断记录,时间证明了它对过

这两种都不坏。但你必须知道自己在积累哪一种,以及它的半衰期是多少。

具体做一件事:列出你过去三年做过的事,给每一件标注”它的价值在三年后还剩多少”。

如果大部分是 0,那你这三年是在做脉冲生意。这不一定是错的——二十几岁做脉冲生意是合理的,因为你需要快速建立市场价值。

但你必须知道一件事:脉冲生意是有尽头的。 因为你的体力、你的新鲜感、你追热点的速度,全部在三十五岁之后开始下降。

而信任生意,恰恰是从三十五岁才开始有复利。

真正聪明的人,是在做脉冲生意的同时,每天拿出一点点时间,种一棵梧桐树。

7.4 老陈押错的那一次

二〇一九年四月十二日,星期五

五月末的一个晚上,小林在老陈家吃饭。

老陈做了三个菜,拌了个黄瓜。吃到一半,老陈把酒杯放下了。

“我该给你讲那件事了。”

“什么事?”

“押错的那一次。”

小林放下筷子。


“二〇一六年三月,我在一家公司做 CTO。”

“公司叫恒信,做企业软件的,主要是给物流公司和制造企业做内部系统。那年大概四百人,年营收三个多亿,不上市,也没打算上市。老板姓赵,六二年的。”

“我一五年去的,去的时候他跟我说了一句话。”

“什么话?”

“他说:‘我们现在是卖人头的,我想变成卖产品的。’”

老陈倒了杯酒。

“这句话你听着是不是特别耳熟。”

“嗯。”

“现在每个做项目的公司老板都会说这句话。“老陈说,“但一六年那时候,说这话的人还不多。赵总说了,而且他是认真的。他给了我四千八百万,三年,让我建一个团队。”

“做什么?”

“做 PaaS。”


“你别笑。“老陈说,“一六年做 PaaS 不是笑话,是非常正经的一个判断。”

“那年发生的事我给你数一下。”

“一五年七月,Kubernetes 1.0 发布。” “一五年,Docker 的融资到了 D 轮,估值十亿美元。” “一六年,国内做容器创业的公司,一个月能冒出来三家。” “一六年,我们那些客户——物流公司、制造企业——开始有人问:你们的系统能不能上云?”

“我当时做了三个判断。”

老陈伸出三根手指。

“第一,容器会赢。不是虚拟机,不是物理机,是容器。这个判断在二〇一六年不是共识,是有风险的。”

“第二,Kubernetes 会成为标准。不是 Swarm,不是 Mesos,是 K8s。这个判断在二〇一六年更冒险,因为那时候 Swarm 用起来比 K8s 简单十倍。”

“第三,我们的客户不会自己运维 K8s。他们是做物流的、做注塑的、做汽车零部件的,他们的 IT 部门最多五个人。他们需要一个’开箱即用’的东西。”

“所以你要做一个——”

“做一个’行业云操作系统’。基于 K8s,把上面那一层——服务治理、配置中心、发布系统、监控、日志、灰度——全部做好,做成一个盒子,卖给我们的客户。”

“客户买回去,他们的开发只要写业务代码,剩下的全是我们管。”

“一年一千五百万,四千八百万卖三年,然后每年收运维费。”


“小林,你猜这三个判断,我错了几个?”

”……”

“一个都没错。“老陈说。

“全对。”

“二〇一九年回头看,这三个判断,每一个都是对的。容器赢了。K8s 成了标准。客户确实不会自己运维 K8s——他们甚至连托管 K8s 都用不好,他们后来买的是更低层的东西。”

“那你为什么会输?”

老陈沉默了一会儿。

“因为我多做了第四个判断,而第四个判断是错的。”

“第四个?”

“第四个判断是:我们应该靠它卖钱。”


“我把这话说清楚。“老陈说。

“判断一、二、三,说的是’世界会变成什么样’。这些判断,只要我不动手,它们也会发生,我只是提前知道了。”

“判断四说的是’我们应该在世界的哪个位置上收钱’。这个判断,不是关于世界的,是关于我们自己的。”

“而我自己,恰恰是我最看不清的那个变量。”

“二〇一六年我做了一个决定:从公司四百人里,抽二十三个人,成立一个独立部门,就叫’云平台部’。”

“我抽的是哪些人?”

“我给你念几个。”

“刘志远,我们当时最好的分布式系统工程师,写了公司的消息中间件,跑了五年没出过大事。” “陈立,最好的运维,公司所有线上事故的最终防线。” “周涛,写调度系统的,我一三年招进来的应届生,自己带大的。” “还有十四个,全是各个业务线上的第一名。”

“我把最好的人,全抽走了。”


“然后呢?“小林问。

“然后就是三年。“老陈说,“我给你讲几个时间点。”

“二〇一七年九月。 平台第一版上线,能跑了。我们拿一个客户做试点,一家做汽车内饰的,江苏的。跑了三个月,客户很满意。赵总很高兴,在年会上讲了半小时。”

“二〇一八年三月。 这是第一个信号。”

“我们组里一个架构师,叫吴迪,比我小六岁,是个很清醒的人。他有天下午来找我,说了一句话。”

“什么话?”

“他说:‘陈总,云厂商的托管 K8s 一出来,我们卖的这些东西,就只剩一层皮了。’”

“你怎么回的?”

老陈喝了一口酒。

“我说:‘他们做的是通用能力,我们做的是行业能力。’”

“这话错了吗?”

“这话没错。“老陈说,“到今天这话都没错。通用能力之上确实有行业能力的空间。”

“但是——”

“但是这句话有一个致命的前提:行业能力的价值,要等到通用能力普及之后才会显现。”

“你想啊,一八年三月,客户连 K8s 是什么都不知道。你去跟他讲’我们这个平台能帮你做服务治理’,他听不懂,因为他还没有服务。”

“行业能力这个市场,要到什么时候才存在?”

“二〇二一年。二〇二二年。”

“而我没有这个时间。”


“二〇一八年六月。 第二个信号。”

“阿里云的 ACK、腾讯云的 TKE,还有华为云,三家在同一年把托管 K8s 做成熟了。价格降到我们成本的三分之一。”

“那年六月,我们丢了一个单子。江苏一个客户,本来谈好了,三百八十万。最后他们选了阿里云。”

“理由?”

“理由是他们老板在阿里云的会上听了一句话:‘你们不用自己建平台。’”

“然后呢?”

“然后我做了一个决定。“老陈说,“我把吴迪找来,我说,我们把重心转到行业能力上,不做通用平台了。”

“这不是对了吗?”

“方向对了。但你知道我做了什么吗?”

小林摇头。

“我保留了原来的平台代码,然后在它上面加了一层行业能力。”

“我没有删掉任何东西。”


“二〇一八年十月。 第三个信号。这个信号不是技术上的。”

“那天我去参加一个客户的季度沟通会。客户是我们最大的一家,做第三方物流的,年合同八百万,合作六年了。”

“他们的 IT 总监姓孙,跟我年纪差不多。会上他跟我说了一句话。”

“他说:‘陈总,我们那个运单查询的问题,提了八个月了。’”

“我当时愣住了。”

“我说:‘什么问题?’”

“他说:‘就是我们司机端查运单,跨省的时候要七八秒。这个我提过好几次。’”

“我回去查了。他确实提过。二〇一八年二月提了一次,五月提了一次,七月又提了一次。”

“三次。我这边的对接人,换了两个。第一个抽去云平台部了,第二个是新来的,不知道前因后果。”

“小林,你知道我当时是什么感觉吗?”

”……”

“我那天晚上没睡着。我算了一笔账。”

“八百万一年的客户,六年,加起来四千八百万。跟我那个项目的预算一样多。”

“而这个客户,因为一个七八秒的查询问题,跟我提了三次,我一次都没接住。”

“因为我们最好的人,全在那个从来没赚过一分钱的项目上。”


“那你就该停了啊。“小林说。

“对。“老陈说,“我现在回头看,二〇一八年十月那天,我就该停。”

“为什么没停?”

老陈看着桌上的酒杯。

“因为到那时候,我已经投了三千两百万,二十三个月。”

“我跟你讲我当时的真实想法,一点都不好听。”

“我不是在想’这件事该不该继续’。”

“我在想——‘如果我停了,我怎么跟赵总交代这三千两百万。’”

”……”

“你看,“老陈笑了,“我到那时候,脑子里想的已经不是’这事对不对’,是’我的脸往哪放’。”

“而且我给自己找了个特别好的理由。我说,再给一年,到二〇一九年六月,如果行业能力这条路跑不通,我就停。”

“‘再给一年’这四个字,是我这辈子说过的最贵的四个字。”


“二〇一九年三月,赵总找我谈话。”

“他很客气。他就问了一个问题:‘老陈,那个平台,今年能签多少?’”

“我说:‘目标一千两百万。’”

“他说:‘去年呢?’”

“我说:‘六百四十万。’”

“他停了一会儿,说:‘三年了,总共签了多少?’”

“我说:‘一千三百万。’”

“他又停了一会儿。”

“他说:‘我们投了多少?’”

“我说:‘四千七百六十万。’”

“他说:‘哦。’”

“就一个’哦’字。”

“然后他说:‘老陈,我不是怪你。我是想问你,这个事情,还要多久才回本?’”

“我当时说了个数。”

“多少?”

“我说:‘再给我两年。’”


“二〇一九年四月十二日,星期五。”

“赵总做了决定。他没让我参与。他直接在管理层会上宣布的。”

“云平台部解散。二十三个人,十九个转岗或者离职。”

“剩下的平台代码,交给运维部维护,只维护,不开发。”

“那天下午三点,我在会议室里跟那二十三个人讲话。”

“你知道我讲了什么吗?”

小林摇头。

“我讲了二十分钟。我讲了公司战略调整,讲了行业变化,讲了感谢大家的付出,讲了公司会给大家 N+1,讲了转岗机会。”

“我讲得很流畅,一句都没卡。”

“因为那些话我在脑子里排练过很多次了。”

“然后我说了句’谢谢大家’,准备走。”

“这时候,最后一排有个人举手。”

“叫李响。二十六岁。一六年七月入职的应届生,那年大四,是我去他们学校校招的时候招进来的。”

“他说:‘陈总,我能不能问一个问题。’”

“我说:‘你说。’”

“他说:‘陈总,那我这三年,算什么?’”


老陈停住了。

他拿起酒瓶,给自己倒了一杯。手很稳。

“我当时站在那儿。“他说。

“我站了可能有十秒。”

“我心里过了三个答案。”

“第一个:‘你这三年做的东西没有白费,技术是通用的。‘——这是骗人的,因为我们那个平台是自研的,外面没人用,简历上写’自研 PaaS’,HR 看不懂,猎头不认。”

“第二个:‘你还年轻,三年不算什么。‘——这也是骗人的。二十六到二十九,这三年是这辈子涨得最快的三年。”

“第三个:‘是我的问题,我押错了。‘——这个不是骗人的,但我说不出口。”

“因为我一说出这句话,就等于承认:我这三年,把你们二十三个人最好的三年,赔进了一个我自己也没想清楚的东西里。”

“我最后说了什么?”

“我说:‘李响,你这个我没法回答你。’”

“然后我就走了。”


“后来呢?“小林问。

“后来我一九年年底离开了恒信。赵总没赶我,是我自己走的。他给了我很体面的条件,还说可以随时回来。”

“那十九个人呢?”

“我二〇二一年做过一件事。“老陈说,“我把那十九个人的名字列出来,一个个去查他们的近况。”

“查得到吗?”

“大部分查得到。领英、脉脉、老同事。”

“我给你说三个数字。”

“第一个:十九个人里,有六个,在之后的两年里,没有找到和原来同级别的工作。”

“第二个:这六个人里,有四个人降了薪。”

“第三个:这六个人里,有三个人的简历上,那三年的经历现在是不写的。”

“不写?”

“不写。“老陈说,“因为没法解释。你跟面试官说’我做了三年自研 PaaS’,面试官问’基于什么’,你说’基于 K8s 但是我们自己包了一层’,面试官说’那你为什么不用原生的’。你答不上来。”

“所以他们把那三年从简历上删掉了。就当那三年没活过。”


“李响呢?”

老陈沉默了很久。

“李响去了杭州一家做电商的小公司,做后端。降薪百分之三十。”

“我二〇二二年见过他一次。”

“在哪儿?”

“在杭州,一个技术沙龙。他坐在最后一排,我也坐在最后一排。”

“散场的时候我们在门口碰上了。”

“他跟我打招呼,叫了我一声’陈总’。”

“我说’别叫陈总’。”

“我们站那儿聊了大概十分钟。他聊他现在做的事,聊得挺好的,他说他现在在做订单,说那个系统比我们当年的平台简单多了,但每天都有人用。”

“他说:‘陈总,我现在写的东西,每天有几万个人在用。’”

“我说:‘那挺好的。’”

“他说:‘嗯。’”

“然后他要走了。走了两步,他回头。”

“他说什么了?”

老陈把酒喝完。

“他说:‘陈总,我当年那个问题,你现在有答案了吗?’”


院子里有风。四月底的北京,晚上还有点凉。

“我到现在也没有。“老陈说。

小林不知道该说什么。

“老陈,那件事……也不全是你的问题。”

“你说得对,不全是我。“老陈说,“但我要跟你说一件更操蛋的事。”

“二〇二一年,我在一个会上碰到孙总,就是那个丢掉的八百万客户,第三方物流那个。”

“我们聊起来了。他跟我说了一件事。”

“什么事?”

“他说:‘陈总,我们当年换供应商,不是因为你们平台不行。’”

“那是因为什么?”

“他说:‘是因为那一年多,你们没有人管我们了。’”

“他说,二〇一七年下半年到二〇一八年底,他们提了七十多个需求,我们接住的不到二十个。他当时跟他们老板说了句话:‘恒信的心已经不在我们这儿了。’”

“他说:‘我们要的不是你们的平台,我们要的是你们那个人。’”

老陈看着窗外。

“小林,你知道这件事让我最难受的地方在哪吗?”

“在哪?”

“我一直以为我输在技术上。”

“我以为我输给了云厂商的技术,输给了托管 K8s 的成熟度,输给了我把行业能力做晚了。”

“结果不是。”

“结果是我把二十三个最好的人抽走的那天,我就已经输了。剩下那三年,只是在等一个通知。”


“老陈,我有个问题。“小林说。

“你说。”

“如果回到二〇一六年三月,站在同样的信息面前,你会怎么做?”

老陈想了很久。

“你是不是想听我说’我会更谨慎’或者’我会先做小范围试点’?”

“不是吗?”

“不是。“老陈说,“我会做一件完全不一样的事。”

“什么事?”

“我会把那二十三个人,一个都不抽走。”

“我会留五个人,做一件事:把公司现有的交付流程,从’人肉部署’改成’容器化部署’。就这一件事。不做平台,不卖产品,就是让我们自己的交付成本降下来。”

“一年省下来的钱,大概八百万。”

“然后呢?”

“然后到二〇一九年,等托管 K8s 成熟了,我用省下来的两千四百万,加上我这三年对客户真实痛点的理解,去做行业能力。”

“那时候,我已经有三年的一手数据,我知道客户真的需要什么。”

“而那时候,云厂商已经把通用能力的价格打到零了,行业能力才刚开始有人要钱。”

“老陈,这就是正确做法吧?”

“不。“老陈说,“这是我现在能想到的做法。”

“有什么区别?”

“区别大了。“老陈说,“二〇一六年三月的我,想不到这个。”

“因为二〇一六年三月,我看着 Docker 的融资新闻,看着 K8s 1.0 的 release note,看着那些创业公司一个接一个冒出来。我脑子里有一个声音一直在说:”

“‘再不进场就来不及了。’”

“小林,你记住这个声音。”

“所有押错注的人,在押的那一刻,听到的都不是’我会赚多少’,是’再不进场就来不及了’。’”


“最后跟你说个数据。“老陈说。

“恒信那件事,总账是多少?”

“我二〇二〇年算过一次。”

“直接投入:四千七百六十万。” “丢掉的客户:两个,年合同一千一百万,续约率按历史算,三年损失三千三百万。” “二十三个人三年的机会成本:按他们如果不做这个项目能在公司创造的价值算,我估的是两千万。” “我自己的三年:这个没法算。”

“加起来,大概一个亿。”

“而我最难受的不是这一个亿。”

“是那一千零九十五天。”

“二十三个人,三年,一千零九十五天。这些天数,是还不回来的。”

“钱可以再赚,公司可以再开,代码可以重写。”

“一个人二十六岁到二十九岁的那一千零九十五天,你赔不起,也没法赔。“


Go 实例:十二个方法的接口,和一个实现

“我再给你看一段代码。“老陈说,“这段比前面那六个方法的 Checker 要价更高,因为它要的是三年。”

“这是恒信那个自研 PaaS 里的真实代码,脱敏过。我把它放出来,是因为它是’押错了的抽象’最典型的样子。”

// 这是 2016 年写的。当时我们认为"未来会支持多种调度后端":
// K8s、Mesos、Swarm,甚至物理机。所以我们抽了一个完整的抽象层。
 
package scheduler
 
import (
	"context"
	"io"
)
 
// Backend 表示一个调度后端。
// 注意:这个接口有 12 个方法。
type Backend interface {
	Create(ctx context.Context, spec *AppSpec) (*App, error)
	Start(ctx context.Context, id string) error
	Stop(ctx context.Context, id string) error
	Destroy(ctx context.Context, id string) error
	Scale(ctx context.Context, id string, replicas int) error
	Update(ctx context.Context, id string, spec *AppSpec) error
	Inspect(ctx context.Context, id string) (*AppStatus, error)
	List(ctx context.Context, filter Filter) ([]*App, error)
	Exec(ctx context.Context, id string, cmd []string) (*ExecStream, error)
	Logs(ctx context.Context, id string, opts LogOptions) (io.ReadCloser, error)
	Stats(ctx context.Context, id string) (<-chan *Metrics, error)
	Health(ctx context.Context) error
}
 
// 我们实现了几个?
// 到 2019 年解散为止:一个。只有 K8sBackend。

“这十二个方法里,“老陈说,“真正被用到的有几个?”

”……”

“七个。Exec 从来没被用过,Stats 我们后来用了 Prometheus,没走这个接口,Health 写完了没人调。”

“那剩下的九个,每一个都要写 mock,每一个都要写测试,每一个都要在文档里解释。”

“而这个世界,只需要一个实现。”


“那正确做法是什么?“小林问。

“Go 的标准库自己给了答案。”

package scheduler
 
import "context"
 
// 正确做法:不要提前抽接口。
//
// 第一步:先写具体实现,只用 struct。
type K8sScheduler struct {
	client *k8s.Client
	ns     string
}
 
func NewK8s(client *k8s.Client, ns string) *K8sScheduler {
	return &K8sScheduler{client: client, ns: ns}
}
 
func (s *K8sScheduler) Deploy(ctx context.Context, spec *AppSpec) (*App, error) {
	// 直接调 k8s,没有中间层
}
 
func (s *K8sScheduler) Scale(ctx context.Context, id string, n int) error {
	// ...
}

“就这样?“小林说,“那如果以后真的要支持 Mesos 呢?”

“等第二个实现真的出现的时候,你再抽。”

“那时候抽不是要改很多调用方吗?”

“对。“老陈说,“但是你想清楚一件事:”

“‘第二个实现真的出现’这件事,发生的概率是多少?”

“在我们那个项目里,是零。三年,零。”

“而’抽接口的代价’,是百分之百确定的,从第一天就开始收。”

“你在用一件百分之百会发生的成本,去对冲一件可能永远不会发生的事。”


“我给你一个判断标准。“老陈说,“Go 社区有一句话,是 Rob Pike 说的:”

“‘The bigger the interface, the weaker the abstraction.’”

“接口越大,抽象越弱。”

“我自己用的更实用的版本是这一句:”

“在你写下任何一个接口之前,先说出第二个实现的名字。”

“说不出来,就别写。”

“老陈,那 io.Reader 呢?你写它的时候,第二个实现叫什么?”

“文件、网络连接、内存 buffer、管道。“老陈说,“说出来了吧?”

“所以它不是提前抽象,它是在已知存在多种实现的情况下做的抽象。”

“这才是区别:不是’要不要为将来做准备’,是’你说的那个将来,有没有具体的名字’。“

老陈的追问

  1. 你现在手上有没有一件事,是你在用”再给三个月”来拖延的?三个月前,你是不是也说过同样的话?

  2. 你有没有过这样的经历:一个决定已经明显错了,但你继续投入,理由不是”它还有救”,而是”我已经投了这么多”?如果有,现在还有吗?

  3. 如果你今年负责的那个项目明天被砍掉,你的团队里会不会有人问出”那我这几个月算什么”?如果会,你打算怎么回答?现在就想,别等到那天。

  4. 你上一次主动叫停一个自己发起的项目,是什么时候?如果你从来没叫停过任何一件自己发起的事,你是在证明自己永远正确,还是在证明你从不回头看?

  5. 你现在正在做的这件事,如果三年后失败了,谁会为你付代价?把他们的名字写出来。如果你写不出来,说明你还没想过这件事。

生活思考题

题一:为什么很多人明知道一段关系/一份工作已经不对了,还是拖着不走?

大多数人会怎么答

因为沉没成本。已经投入了这么多年,不甘心。

这个答案为什么不够

“沉没成本”是个好词,但它太干净了。它把一个极其混乱的心理过程,包装成了一个经济学概念。

真实的”不甘心”里面,至少混着三种完全不同的东西,而它们的解法完全不一样。

更深的一层:三种”不甘心”

第一种是对钱的:我在这上面花了五年。

第二种是对叙事的:我一直是那个”做什么都能成”的人。承认这件事错了,等于承认我不是那个人了。

第三种,最深的,是对他人的:如果我停了,那些跟着我干的人怎么办?我怎么面对他们?

大部分人以为自己卡在第一种。其实他们卡在第二和第三种。

第一种最好解,因为钱是可以计算的。五年,二十万,算清楚,认了。

第二种最难,因为它不是关于一件事,是关于你是谁。你不是在止损,你是在改写自传。

第三种最痛,因为它不是关于你,是关于别人为你付的代价。

最深的一层

这三种不甘心里,只有第三种是合理的。

前两种本质上是自私的——你在用已经花掉的钱和已经建立起来的自我形象,去换未来的钱和时间。这笔账是算得清的,理性上每个人都懂,只是做起来难。

但第三种不是自私,是责任。它是真实的道德负担。你确实欠那些人一个交代。

而恰恰是这一种,最容易被前两种伪装。

你会对自己说:“我不能停,停了团队怎么办。”

但真相可能是:你停了,团队三个月后都会找到新位置。而你不停,他们会在一个注定失败的事情上再耗一年。

你以为你在保护他们,其实你在保护”我是一个负责任的人”这个形象。

这个区别,只有你自己知道。而它决定了你会拖三个月,还是拖三年。

落到 Go 工程师身上

第一,给每一个你发起的项目,设一个”叫停条款”。

在立项文档的最后,加一段:

我们在什么情况下会停止这件事:
  1. 到 ____ 年 ____ 月,如果 ____ 指标没有达到 ____,停止。
  2. 如果出现 ____(外部信号),重新评估。
  3. 如果团队中有超过 ____ 人主动提出质疑,召开一次专门的复盘。

关键不是这些条件本身,是它们是你在清醒的时候写下的。

人在这个项目已经被投入了两年、情绪已经卷进去之后,是不可能客观地判断该不该停的。唯一的机会,是在你还没投入的时候,让那个清醒版本的你,替未来的你做一个约定。

第二,每个季度,问自己一个残酷的问题:如果我今天才知道这个项目,我还会启动它吗?

注意这个问题的措辞:不是”它现在进展如何”,不是”我们投入了多少”,是”如果今天才知道”。

这个问题的妙处在于,它自动地把沉没成本从等式里剔除了。

第三,学会区分”坚持”和”不肯认输”。

判断标准只有一个:你还在获得新信息吗?

如果这个月你知道的东西,和上个月一模一样,那你不是在坚持,你是在原地耗。

如果这个月你依然在学到新的、能改变你判断的东西,那就是坚持。

老陈那个项目,二〇一八年三月之后,吴迪已经把最关键的信息告诉他了。从那天起,他每个月知道的东西,和上个月是一样的。

真正的失败,不是二〇一九年四月十二日那天。是二〇一八年三月,吴迪说那句话的那个下午。

题二:为什么很多”为将来做准备”的设计,最后成了最大的负担?

大多数人会怎么答

因为过度设计。YAGNI,You Ain’t Gonna Need It。不要为假想的需求写代码。

这个答案为什么不够

YAGNI 这句话太好用了,好用到它变成了一个万能的理由,可以用来反对任何你不喜欢的抽象。

但现实是:有些为将来做的准备是对的。 Go 的 io.Reader 就是为将来做的准备,它是整个生态的基石。context.Context 也是为将来做的准备,虽然很多人讨厌它,但它确实解决了跨服务取消这个真实问题。

所以问题不是”要不要为将来做准备”,是——

你为将来做的这个准备,押的是什么?

更深的一层

任何抽象,都是一次押注。你押的是:

“未来会存在多种这种东西的实现。”

如果你押对了——比如你押”未来会有多种数据源”,所以你抽了一个 Store 接口——那么这个抽象会给你带来巨大的回报。

如果你押错了——未来只有一种实现——那么这个抽象不会给你带来任何回报,但它会持续地收你的税:

  • 每次改动要多改一个文件
  • 每次读代码要多跳一层
  • 每个新人要多理解一个概念
  • 每次调试要多看一次调用栈

而最操蛋的是:这个税,是你自己给自己加的,而且没人会替你取消它。

为什么没人取消?因为”删掉一个抽象”这件事,在任何团队里都比”加一个抽象”难十倍。加抽象的时候,你说”为了将来”,大家说”有道理”。删抽象的时候,你说”其实我们用不上”,大家说”万一以后要用呢”。

最深的一层:抽象的成本不是线性的,是指数的。

一个抽象,如果只有一层,成本大概是 10%。 两个抽象叠在一起,不是 20%,是 30%——因为你要理解它们的组合。 三个抽象叠在一起,是 60%。 四个以上,这个系统就只有设计它的人能改了。

而那个设计它的人,两年后大概率已经不在了。

落到 Go 工程师身上

老陈那个自研 PaaS 里的 Backend 接口,是这一节最好的例子:十二个方法,七个被用到,一个实现,三年。

我在这里再补三条可以立刻用的判断:

第一条,给每一个接口做一次”第二实现点名”。 在你写下任何一个接口之前,先说出第二个实现的名字。说不出来,就别写。这不是保守,这是把”押注”变成一个可以验证的动作。

第二条,把”删抽象”变成一件和”加抽象”一样容易的事。 具体做法:每一个为了”将来的多样性”而存在的抽象,都要在代码里留一行注释,写清楚”我们预计第二个实现会在 ____ 出现”。到了那个日期,如果没有出现,任何人都有权删掉它,不需要征求原作者同意。

第三条,学会区分”坚持”和”不肯认输”。 判断标准只有一个:你还在获得新信息吗?

如果这个月你知道的东西,和上个月一模一样,那你不是在坚持,你是在原地耗。

如果这个月你依然在学到新的、能改变你判断的东西,那就是坚持。

老陈那个项目,二〇一八年三月之后,吴迪已经把最关键的信息告诉他了。从那天起,他每个月知道的东西,和上个月是一样的。

真正的失败,不是二〇一九年四月十二日那天。是二〇一八年三月,吴迪说那句话的那个下午。

7.5 真趋势还是泡沫:怎么判断(以及为什么大部分人判断不了)

楼下那两家店

六月中,小林公司楼下的底商换了招牌。

原来那家卖煎饼的走了,新开了一家”手打柠檬茶”。开业那天请了三个穿着玩偶服的人在门口站着,排队的人绕了半圈。

小林中午下去买饭,看见那队,拍了张照片发给老陈。

老陈回了一句:三个月后你再拍一张。

小林真拍了。

三个月后,那家柠檬茶店,门口站着的还是三个人——是店员。排队的人一个都没有。

又过了一个月,店关了。变成了”重庆小面”。

“你怎么知道的?“小林问老陈。

“我不知道。“老陈说,“我只是知道去哪儿看。”


“你那天发给我的照片里,有几个信息。“老陈说。

“第一,那三个穿玩偶服的人,是花钱雇的。一个两百,三个六百,加上服装租赁,一天的开业成本在一千二以上。”

“第二,店的位置。你们楼下那个位置,是写字楼底商的中段,不是入口。写字楼底商的黄金位是入口第一家和第二家,因为那是所有人必经的地方。中段的位置,只有’专程来’的人才会走到。”

“第三,也是最重要的:那张照片里排队的人,你看看他们手里拿着什么。”

小林翻出照片。

”……手机。“他说,“都在举着手机。”

“对。“老陈说,“都在拍照。”

“一家店开业,如果排队的人都在拍照,那他们是来打卡的,不是来喝茶的。”

“打卡的人,一辈子只会来一次。”


“我教你一个判断方法。“老陈说。

“这个方法是我做早期投资那几年总结的,说起来就三个问题。”

“第一个问题:谁在买单?”

“是你自己掏钱,还是别人替你掏?”

“手打柠檬茶,二十五块一杯。掏钱的是二十几岁的白领,掏的是自己的钱。”

“而当年那些倒掉的 O2O,上门洗车的、上门按摩的,掏钱的是投资人。用户洗一次车九块九,成本四十。”

“这两件事的区别在哪?”

“在于掏自己钱的人,会算账。”

“一个人会不会连续三十天,每天花二十五块买一杯柠檬茶?大部分人不会。他最多一周买两次。”

“而一个’用户’会不会连续三十天用九块九的上门洗车?会。因为那不是市场价,那是补贴。补贴一停,他就消失了。”

“所以第一个判断标准是:这个东西的价格,是不是一个真实的市场价格?”

“如果不是,你在看的不是需求,是补贴制造出来的幻象。”


“第二个问题:多久买一次?”

“这个说的是频次。”

“我问你,你们楼下现在还在的店,是哪几家?”

小林想了想。“便利店。兰州拉面。水果店。还有一家烟酒店。”

“对。这四家,全是高频。”

“便利店,一天两次。” “兰州拉面,一周三次。” “水果店,一周一次。” “烟酒店——那个不是高频,但是成瘾。”

“而那些死掉的:手打柠檬茶、脆皮五花肉、芝士奶盖、某网红炸鸡——全是低频。”

“低频生意不是不能做,但低频生意有一个致命特点:它必须靠拉新活着。”

“因为一个人一年只来两次,你要维持流水,就得不停地找到新的’第一次来的人’。而找新人的成本,永远在涨。”

“高频生意不一样。高频生意靠的是回头客,回头客的获取成本是零。”

“技术也一样。”

“怎么讲?“小林问。

“你想想,一个技术出来,你怎么判断它是真需求还是泡沫?”

“看有多少人在用?”

“不。看同一个人用多少次。”

“一个公司上了 Kubernetes,用了三年,还在用,还在投入,还在招人。这叫真需求。”

“一个公司上了区块链,做了个 POC,发了篇通稿,然后就没有然后了。这叫泡沫。”

“判断标准不是’有多少公司在做’,是’做过的公司里,有多少还在做’。”


“第三个问题:不用了会怎样?”

老陈说,“这是三个问题里最狠的一个。”

“你不用手打柠檬茶,会怎样?”

”……不会怎样。”

“对。你今天不喝,明天不喝,你活得一模一样。”

“那便利店呢?”

“我们楼下那个便利店,晚上十一点关门。“小林说,“我有次加班到十一点半,走到那儿发现关了,我那天特别烦躁。”

“为什么烦躁?”

“因为……我本来打算买瓶水和一包饼干当宵夜的。”

“所以你不便利,会难受。“老陈说,“那个难受的程度,就是这门生意的价值。”

“‘不用了会怎样’这个问题的答案,如果是’不会怎样’,那这个东西就是可有可无的。可有可无的东西,在成本压力下会第一个被砍掉。”

“反过来,如果答案是’会很难受’、‘会做不下去’、‘会赔钱’,那这个东西就是刚需。”

“刚需的判定标准只有一个:它的缺失会造成可衡量的损失。”


“我把这三个问题,套到一个技术上试试。“老陈说。

“你想想 Kubernetes。”

“谁在买单:企业自己在买,付的是人力和云账单。真实价格。” “多久买一次:每天。你上了 K8s,你的每一次发布都在用它。” “不用了会怎样:回到人肉部署,发布一次两小时,回滚靠祈祷。”

“三条全过。”

“那区块链呢?”

“谁在买单:二〇一七年到一八年,大部分是试点预算,是’创新经费’。” “多久买一次:POC 做完,就不用了。” “不用了会怎样:不会怎样。原来怎么跑,现在还怎么跑。”

“三条全不过。”

“所以二〇一八年我跟一个朋友说,别投区块链了。他不听。他现在还跟我提这事。”

“那大模型呢?“小林问。

老陈笑了。

“你问我?”

“你觉得呢?”

“我觉得——“小林想了想,“谁在买单这条,现在是投资人在买。”

“对。”

“多久买一次这条,普通人用大模型……一周几次?”

“对。”

“不用了会怎样这条……”小林停了一下,“我不用大模型,我也能写代码。”

“你看,你自己其实知道。“老陈说,“我不是说大模型是泡沫。我是说:按这三个问题,你现在还判断不了。”

“那什么时候能判断?”

“等它有一个’不用的代价’。“老陈说,“等到有一天,某个公司不用大模型,它在成本上或者速度上被竞争对手甩开。那一天,它才从’好东西’变成’刚需’。”

“那一天可能已经在了,也可能还要三年。”

“而在那一天到来之前,所有关于它的判断,都是押注,不是分析。”


“不过,“老陈话锋一转,“我刚才说的这三问,有个巨大的漏洞。”

“什么漏洞?”

“它只能判断已经发生的事。”

“一家店开了两年还在,你能判断。一个技术上了三年还在用,你能判断。”

“但你要做的决定,恰恰是关于还没发生的。”

“二〇一六年我做 PaaS 的时候,我会这三问吗?我会。”

“谁在买单:企业,真实价格。过。” “多久买一次:每天。过。” “不用了会怎样:回到人肉部署,很痛苦。过。”

“三条全过。我照样赔了一个亿。”

“为什么?”

小林想了一下。

“因为……你判断的是’这个需求存在’,但你没法判断’这个需求会由谁来满足’。”

老陈看着他,看了好几秒。

“你再说一遍。”

“需求是真的,“小林说,“但’由你来满足’这件事,不是真的。”

“需求存在,不等于这个需求会变成你的生意。”

“对。“老陈说,“这是我这三问最大的盲区,也是所有’技术趋势判断’最深的那个坑。”

“大部分人判断的是’这事会不会发生’,而真正决定输赢的是’这事发生了,钱归谁’。”

“容器会发生。判断对了。钱归了云厂商。” “移动支付会发生。判断对了。钱归了两个 App。” “短视频会发生。判断对了。钱归了那两三家。”

“每一个趋势,都有几百个人判断对了,然后只有两三个人赚到钱。”

“剩下的人,全部死在同一件事上:他们以为判断对趋势就够了。”


“那怎么判断’钱归谁’?“小林问。

“这个问题,比前面三个问题难十倍。“老陈说,“我没有完整答案,我只有三个观察。”

“第一个观察:钱会归那个’不可替代’的人,而不是’最先做’的人。”

“容器的替代品很多,K8s 只是其中一个。但 AWS 上跑着几百万个集群,这些集群里的数据、配置、监控、人的习惯——这些东西不能搬。”

“不可替代性不是来自技术更好,是来自迁移成本更高。”

“第二个观察:钱会归那个’离客户最近’的人。”

“我们当年做 PaaS,客户是物流公司。但我们和物流公司之间,隔着一层:他们的 IT 部门。”

“而阿里云不用隔。阿里云的客户,就是那个 IT 部门。”

“谁离掏钱的那个人最近,谁定价。”

“第三个观察,最难受的一个:”

“钱会归那个’能等’的人。”

“我们三年烧了四千八百万,因为我们要靠它活着。”

“阿里云不需要。阿里云做 K8s,可以是十年不赚钱的战略投入,因为它有别的东西在赚钱。”

“一个能等十年的人,和一个只能等三年的人,打的是一场不公平的仗。”

“所以你判断一个方向值不值得押,你先问:这个赛道里,有没有一个玩家,他不需要靠这个赚钱?”

“如果有,你要非常小心。“


Go 实例:为不确定的未来留出位置,但不押注

“好,说回代码。“老陈说,“我刚才讲的这三问,在技术上有个对应的东西。”

“什么东西?”

“你怎么为一个’你还不确定会不会发生’的未来写代码。”

“这是 P10 天天要面对的问题,也是最容易做错的地方。”

“大部分人只有两种做法。”

“第一种,完全不管未来。等它发生了再说。” “第二种,为每一个’可能的未来’都做好准备。”

“第一种会被变化打死,第二种会被自己打死。”

“你见过第二种的代码吗?”

“见过。“小林苦笑,“我们那套优惠券系统里,有个券类型叫’未来可能支持的券’,写了两年,从来没用过。”

“对,就是那个。“老陈说,“我给你看一段。”


package coupon
 
import "context"
 
// 典型错误:把"不确定的未来"写死在调用路径上。
//
// 写这段代码的背景:
// 2019 年,消息中间件要从 RabbitMQ 迁到 Kafka,但迁移没做完。
// 于是作者写了一个"双写 + 双读"的兼容层。
// 五年后(2024),这段兼容层还在。
 
type Publisher struct {
	rabbit *RabbitClient
	kafka  *KafkaClient
	cfg    Config
}
 
func (p *Publisher) Publish(ctx context.Context, topic string, body []byte) error {
	// 问题一:每一条发布路径上,都有这个 if
	if p.cfg.UseKafka {
		if p.cfg.DualWrite {
			// 双写:两边都要成功才算成功
			if err := p.rabbit.Publish(topic, body); err != nil {
				return err
			}
		}
		return p.kafka.Publish(ctx, topic, body)
	}
	return p.rabbit.Publish(topic, body)
}
 
// 还有十几个这样的 if,散落在:
// - 消费端
// - 延迟队列
// - 死信处理
// - 监控上报
// - 本地测试脚手架

“这段代码有什么问题?“老陈问。

“丑。“小林说。

“丑是结果。我问的是代价。”

小林想了想。

“每加一个新特性,我要写两遍。”

“对。还有呢?”

“测试要跑两套。”

“还有?”

”……新人看不懂。他不知道 UseKafka 现在是 true 还是 false,他只能去翻配置,然后发现不同环境的配置还不一样。”

“还有最要命的一条,你没说。“老陈说。

“什么?”

“这段代码永远不会被删掉。”

“为什么?”

“因为删它,需要有人回答一个问题:‘迁移做完了吗?’”

“而这个问题,没有人能回答。因为迁移做完了 98%,剩下的 2% 是一些没人记得的老 topic。”

“没人敢为那 2% 负责,所以这段代码会一直活着。”

“它现在多大了?”

“五年。“老陈说,“五年里,它至少让每个接触过它的人,多花了三十分钟。”


“那正确的做法是什么?“小林问。

“你要先想清楚一件事:你面对的’不确定’,是哪一种?”

“有两种完全不同的不确定。”

“第一种:我知道未来会有几种,但我不知道是哪一种。”

“比如:消息中间件会在 Kafka 和 Pulsar 之间选一个,但我不知道是哪个。”

“第二种:我不知道未来会是什么样,可能是我现在完全想不到的东西。”

老陈说,“这两种不确定,解法完全不一样。”

“第一种,用接口解决。”

package messaging
 
import "context"
 
// 面对第一种不确定:未来是几种已知可能之一。
// 解法:定义一个最小的接口,把"选哪个"推迟到部署时。
 
type Broker interface {
	// Publish 发布一条消息。
	// 契约:
	//   1. 返回 nil 表示该消息已被持久化(不是"已接收");
	//   2. ctx 取消后必须尽快返回,且保证不丢已确认的消息;
	//   3. 返回的 error 必须能被 errors.As 转为 *BrokerError。
	Publish(ctx context.Context, topic string, msg Message) error
}
 
type Message struct {
	Key  string
	Body []byte
}

“注意这个接口只有一个方法。”

“为什么只有一个?“小林问。

“因为你每多定义一个方法,就等于赌了一把’未来所有的 Broker 都有这个能力’。”

“你定义 Consume,你就赌了’未来所有 Broker 都是拉模型’。Pulsar 是推模型,它就实现不了。”

“你定义 Ack,你就赌了’未来所有 Broker 都需要显式 ack’。”

“在你还不知道未来是什么的时候,你的接口要窄到只描述’你必须做到的事’,不要描述’你最好还能做什么’。”


“那第二种不确定呢?“小林问,“就是完全想不到的那种。”

“那种无解。“老陈说。

“无解?”

“对,无解。你不可能为一个你想不到的未来做设计。”

“那怎么办?”

“答案是:不要设计。要让’改’这件事变得便宜。”

老陈把笔记本推过来。

package messaging
 
import (
	"context"
	"fmt"
	"sync"
)
 
// 面对第二种不确定:未来是未知的。
// 解法不是"预留扩展点",是"降低切换成本"。
//
// 具体做法:注册表 + 一个开关。
 
var (
	mu       sync.RWMutex
	registry = map[string]Factory{}
	active   = "noop"
)
 
type Factory func(cfg map[string]any) (Broker, error)
 
// Register 由各个实现包在自己的 init 里调用。
// 关键:核心包不 import 任何具体实现。
func Register(name string, f Factory) {
	mu.Lock()
	defer mu.Unlock()
	registry[name] = f
}
 
// Use 在启动阶段调用一次,之后不允许变更。
func Use(name string, cfg map[string]any) (Broker, error) {
	mu.RLock()
	f, ok := registry[name]
	mu.RUnlock()
	if !ok {
		return nil, fmt.Errorf("messaging: unknown broker %q", name)
	}
	return f(cfg)
}

“这个有什么用?“小林问。

“用处是:messaging 这个包,不 import kafka,不 import pulsar,不 import 任何东西。”

“具体的实现在自己的包里 init 注册:”

// 在 kafka 包里
package kafka
 
import "yourcorp/messaging"
 
func init() {
	messaging.Register("kafka", func(cfg map[string]any) (messaging.Broker, error) {
		// ...
		return nil, nil
	})
}

“然后谁用,谁 import:”

package main
 
import (
	"yourcorp/messaging"
	_ "yourcorp/messaging/kafka" // 只为了注册
	_ "yourcorp/messaging/pulsar"
)
 
func main() {
	// 具体用哪个,由配置决定,不由代码决定
	b, err := messaging.Use(config.BrokerName, config.BrokerCfg)
	if err != nil {
		panic(err)
	}
	_ = b
}

“这就是 database/sql 的做法。“老陈说,“你去看看 database/sql:”

import (
	"database/sql"
	_ "github.com/go-sql-driver/mysql"
)
 
db, _ := sql.Open("mysql", dsn)

“sql 包不认识 mysql。mysql 包在 init 里把自己注册进去。你换 postgres,只改 import 和字符串。”

“这个设计的价值不在于’支持多种数据库’,在于’换数据库的成本,从改十几个文件,降到了改一行字符串’。”


“小林,这个东西有个名字,叫期权。”

“金融里的期权是什么意思?”

“就是你花一小笔钱,买一个’在未来某个时间,以某个价格买某个东西’的权利。”

“你不是必须买。你只是有权利买。”

“如果这个东西涨了,你行权,赚了。” “如果这个东西没涨,你不行权,你只损失了那一小笔期权费。”

“技术上的’期权’,就是:你花一点点成本,保持一个’未来能快速切换’的权利,但你不做任何具体投入。”

“Registry 模式,就是一个期权。你付出的成本是一个 init 函数和一个 map。你换来的是:当某一天你决定从 Kafka 换到 Pulsar 的时候,你改一行。”

“而那个 if cfg.UseKafka,是一个期货。”

“什么是期货?”

“就是你必须交割。你已经把两边的代码都写了,你已经付了两边的钱,不管最后走哪条路,另一条路的钱都已经花掉了。”


“我再给你一个更实用的。“老陈说。

“除了 registry,还有第二个期权,叫可随时拆掉的开关。”

package recommend
 
import "context"
 
// 第三个期权:低成本试探。
//
// 场景:你想试试一个新的推荐算法,但不确定它是否真的更好。
// 错误做法:开个分支,写两套代码,然后长期维护。
// 正确做法:一个明确的、带日期的、可删除的开关。
 
type Switch struct {
	// ShadowMode 下,新算法会真实执行,但结果只记录不返回。
	// 这是关键:它让"试探"这件事的风险降到零。
	ShadowMode bool
	// Ratio 是新算法生效的流量比例,0 到 100。
	Ratio int
	// Owner 必须填一个人名,不是团队名。
	Owner string
	// ExpiresAt 是这个开关的到期日。
	// 到期后 CI 会报错,逼着 Owner 要么转正要么删掉。
	ExpiresAt string
}
 
func Recommend(ctx context.Context, sw Switch, userID string, legacy, modern Algo) []Item {
	if sw.ShadowMode {
		// 影子流量:新算法跑一遍,结果打到日志,不返回给用户
		go func() {
			res := modern.Recommend(ctx, userID)
			logShadow(userID, res)
		}()
		return legacy.Recommend(ctx, userID)
	}
 
	if hash(userID)%100 < sw.Ratio {
		return modern.Recommend(ctx, userID)
	}
	return legacy.Recommend(ctx, userID)
}
 
func logShadow(userID string, items []Item) {}
func hash(s string) uint32                 { return 0 }
 
type Algo interface {
	Recommend(ctx context.Context, userID string) []Item
}
type Item struct{}

“这个 Switch 里,最关键的是哪一行?“老陈问。

”……ExpiresAt?”

“对。”

“为什么?”

“因为没有到期日的开关,会变成永久代码。“老陈说,“我见过一个公司,代码里有四百多个 feature flag,其中最老的一个是二〇一四年加的,加它的那个人二〇一六年就离职了。”

“没人敢删,因为没人知道删了会发生什么。”

“所以每一个开关,必须有四个字段:ShadowMode、Ratio、Owner、ExpiresAt。”

“Owner 必须是一个具体的人名,不是团队名。 团队名意味着没人负责。”

“ExpiresAt 必须配合 CI 检查。 到期了,CI 直接变红,你不处理就合不进去。”

“这三个东西加在一起,才叫’低成本试探’。少了任何一个,它就会变成’永久的临时代码’。”


“老陈,我明白期权的意思了。“小林说,“但是有个问题。”

“你说。”

“你刚才说的 registry、开关,都是技术上的。但真正难判断的不是技术上要不要留口子。”

“那是什么?”

“是这个方向要不要押。“小林说,“比如我们公司那个’质检大模型’。技术上都留口子,那 B 到底做不做?”

老陈笑了。

“你看,你绕回来了。”

“我给你一个答案,但这个答案可能让你不舒服。”

“你说。”

“你不需要在今天就决定 B 做不做。”

“啊?”

“你需要做的,是花尽可能小的钱,买一个’三个月后能做出更好判断’的权利。”

“具体怎么做?”

“一千五百万做 B,是期货。”

“而期权是什么?是:”

“花六十万,三个月,做一件只看不做的事。”

“让五个人,用三个月,把业界的开源模型跑一遍,跑在你们自己真实的、四十一个客户的历史数据上。不看能不能演示,只看一件事:它在你们最难的那个客户、最难的那个缺陷类型上,误检率是多少。”

“三个月后,你会拿到一个数字。”

“这个数字可能是 3%,可能是 30%。”

“如果是 3%,你就知道:这个东西有价值,值得投。” “如果是 30%,你就知道:这个东西现在不行,投了也白投。”

“而无论哪个结果,你只花了六十万。”

“这就是 P10 真正做的事:不是押注,是不断地买期权,直到有一天,信息足够充分了,才真正下注。”

“那要是别人先下注了呢?”

“会有人先下注。“老陈说,“而且其中一些人会赢。”

“这个你接受不了吗?”

小林想了很久。

”……接受不了。”

“为什么?”

“因为’错过’这件事,比’做错’更让人难受。“小林说,“做错了,你知道是自己判断的问题。错过了,你什么都没做,你连错都没得错。”

老陈点点头。

“这就是 FOMO。”

“而我要告诉你一个特别反直觉的事:”

“在 P10 这个层级,‘错过’的代价,几乎永远小于’做错’的代价。”

“为什么?”

“因为错过一次,你还活着,你还有下一次。” “而做错一次大的,你可能就不在了。”

“一个亿,三年,二十三个人。”

“小林,恒信那件事之后,公司还活着,但那个’再赌一把’的机会,我们没有了。”

“钱是可以再融的,但一家公司的’信用额度’是有限的。你错一次大的,你的容错空间就被压缩一次。错两次,你就只能做保守的事了。“

老陈的追问

  1. 你现在手上正在投入时间或资源的那件事,是”期货”还是”期权”?如果是期货,你能不能把它改造成期权?代价是多少?

  2. 你的代码里有多少个没有到期日的开关?它们的 Owner 是谁?如果你删掉其中一个,会发生什么——你真的知道吗,还是你不敢查?

  3. 你过去三年追过的技术热点,有几个现在还在你的日常工作中?追它们花的时间加起来是多少?如果这些时间全部用来做一件事,那件事会是什么?

  4. 如果让你判断”我现在的公司,三年后靠什么赚钱”,你的答案需要多少信息?你现在手上有多少?缺的那部分,你打算怎么拿到——还是你其实根本没打算去拿?

  5. 你有没有过”我早就知道这件事会成,但我没动手”的经历?如果有,阻碍你动手的是什么?是判断不够,还是你不愿意为一个判断承担后果?

生活思考题

题一:为什么很多人在股市里”看对了却没赚到钱”?

大多数人会怎么答

因为拿不住。涨了一点就卖,跌了就割。因为心态不好。因为人性贪婪。

这个答案为什么不够

“心态”这个解释,是所有投资问题里最没用的一个。因为它不可操作。你不能通过”调整心态”来改变行为。

真实的原因比这具体得多。

更深的一层

大部分人在股市里的行为模式,是这样的:

  • 用 10% 的仓位,买了一个自己非常有信心的股票
  • 用 90% 的仓位,买了几个自己半信半疑的股票

为什么?因为”非常有信心”这件事,往往来自”我已经研究得很深了”,而研究得越深,越知道风险在哪。所以你反而不敢重仓。

而那几个半信半疑的,是听别人说的,你不知道风险在哪,所以你不怕。

结果就是:在你最有判断力的地方,你投入得最少。在你最没判断力的地方,你投入得最多。

更深的一层:赚钱不取决于你看对多少次,取决于你看对的时候投了多少。

一个人判断准确率 40%,但每次看对的时候都重仓,看错的时候都轻仓——他会赚很多钱。

一个人判断准确率 70%,但每次都是均匀下注——他大概率不赚不赔,扣掉手续费还亏。

这个结构,在技术上完全一样。

落到 Go 工程师身上

第一,分清”研究”和”下注”。

研究是免费的,你可以同时研究十个方向,代价只是时间。 下注是有成本的,你只能下有限的几个。

大部分人的问题不是研究得不够,是把研究当成了下注——我看了三十篇关于 eBPF 的文章,我觉得我已经”投入”了。其实你只是看了三十篇文章。

真正的下注是什么?是你在生产环境里用它解决了真的问题,是你在团队里推着别人一起用,是你把它写进了你的年度目标。

第二,建立一个”信心-投入”对照表。

每个季度,列出你正在投入的事情,给每一件标两个分数:

  • 你的判断信心(1-10)
  • 你实际投入的比例(%)

然后看这两列是不是匹配的。

大部分人会看到一个非常难看的表格:信心 9 的事,投入 5%;信心 4 的事,投入 40%。

为什么会这样?因为信心 9 的事,往往是难的、慢的、没人给你鼓掌的。而信心 4 的事,往往是别人都在做的。

第三,最狠的一条:每年挑一件你信心最高的事,把投入翻倍。

不是加一点,是翻倍。

你会发现一个特别有意思的现象:当你真的把投入翻倍之后,你对这件事的判断也会变。因为你终于开始看到之前看不到的细节了。

判断力和投入,是互相喂养的。你投入得少,你永远只能得到肤浅的判断。

题二:为什么很多人”知道”熬夜不好,但就是改不掉?

大多数人会怎么答

因为自控力差。因为手机太好玩了。因为工作压力大,只有晚上是自己的时间。

这个答案为什么不够

“只有晚上是自己的时间”这个答案,其实已经很接近真相了,但大部分人说完这句就停了,没有往下想。

往下想一步就是:为什么白天不是你的时间?

更深的一层

熬夜这件事,本质上不是健康问题,是主权问题。

白天八小时,你在为别人做事。你的时间被会议、需求、消息切成碎片,你没有任何一段时间是完整的、由你自己支配的。

到了晚上十一点,世界终于安静了。这段时间没有任何人会找你,没有任何事需要你响应。

你舍不得睡,不是因为你不想睡,是因为你舍不得这段”属于自己”的时间。

你刷手机不是目的,你是在享受”我想刷就刷”这个权利。

所以所有”戒手机""早睡打卡”的方法,对这类人全部无效。因为你拿走他的手机,他还是会找别的事做——看剧、打游戏、发呆。他不是在玩手机,他是在守卫主权。

最深的一层

这意味着:你不可能通过”行为管理”来解决一个”主权缺失”的问题。

唯一的解法,是在白天夺回一小块主权。

落到 Go 工程师身上

这件事和 P10 有什么关系?关系很大。

P10 面对的核心困境,和熬夜的人面对的是同一个:你的时间不由你支配。

一个 P9,一天的工作时间大概是这样:

  • 会议:4 小时
  • 消息和邮件:2 小时
  • 被动响应(被拉去救火、被问问题):2 小时
  • 自己想做的事:0 到 1 小时

一个 P10,理论上应该反过来:大部分时间在自己想的事上。

但现实是,很多做到高管的人,日程比 P9 还满。他们从早八点到晚九点,全部是别人安排的会。

他们会用两种方式应对:

  • 一种是认命,把自己变成一台会议机器
  • 一种是熬夜,晚上十一点开始做真正重要的思考

第二种人,就是我们说的”工作狂”。而他们中的大部分人,会在五到十年内耗尽。

真正有效的做法,是在日程上”划地”。

具体三件事:

第一,在你的日历上,每周固定划出两个九十分钟的块,标为”不可预约”。 不加说明,不解释。

第一周会有人来抢。第二周还会有人抢。第三周,大部分人就习惯了。

而那两个九十分钟,是你一周里唯一真正属于你的时间。

第二,在这九十分钟里,做一件看起来”没有产出”的事:想问题。

不带电脑,或者带电脑但不联网。就一张纸,写:我现在面对的最重要的那个问题是什么?我缺什么信息?

你会发现,一件在会议上两小时吵不出结论的事,你一个人想九十分钟,会有答案。

因为大部分”吵不出结论”的会议,不是因为分歧,是因为没有人有完整的、连续的时间去想。

第三,每个季度,用掉你的休假,去做一件和你的工作完全无关、但需要连续时间的事。

爬山、钓鱼、装修、陪孩子做一个项目。

这件事的功能不是休息,是校准。它让你记起”连续的时间”是什么手感。

一个长期处于碎片状态的人,会慢慢丧失长时间思考的能力,而他不会意识到自己丧失了——因为他已经习惯了。

7.6 定义问题域:决定什么值得被解决

内科还是外科

七月的一个周六,小林陪他妈去体检。

他妈五十六,退休前在纺织厂做过十几年质检。体检中心在北四环,八层,早上七点半就排满了人。

小林在走廊里等的时候,注意到一个事。

走廊尽头是分诊台。一个大妈坐那儿,面前立着一块牌子,写着:

咨询分诊:
头晕、头痛         → 请先测血压
胸闷、心悸         → 心内科
反复发热           → 呼吸内科 / 感染科
关节疼痛           → 骨科 / 风湿免疫科
消瘦、乏力         → 请先挂内分泌科

一个大爷过来,说:“我头晕。”

护士头都没抬:“测血压了吗?”

“没有。”

“那边。”

大爷去了。

第二个,一个中年女的:“我头晕。”

护士还是那句:“测血压了吗?”

“测了,正常。”

“多久了?”

“半个多月了。”

“看东西转吗?”

“有点。”

“耳鼻喉科。”

”……我头晕为什么要看耳朵?”

护士抬起头看了她一眼:“你躺下或者翻身的时候晕不晕?”

“晕。”

“耳鼻喉科。“护士说,“不是脑袋的问题,是耳朵里管平衡的那个东西。”


小林在旁边听着,忽然觉得有点不对劲。

他掏出手机,搜了一下”头晕挂什么科”。

搜出来的第一条,是一个健康科普号的文章,标题叫《头晕到底该挂哪个科?一次给你讲清楚》。

文章里列了一张表:

头晕 + 血压高        → 心内科
头晕 + 转头时加重    → 骨科(颈椎)
头晕 + 天旋地转      → 耳鼻喉科
头晕 + 手脚麻木      → 神经内科
头晕 + 心慌、失眠    → 精神心理科
头晕 + 面色苍白      → 血液科

七个科室。

“妈。“小林回头,“你上次头晕,挂的什么科?”

“神经内科。“他妈说,“做了个核磁,八百多,什么也没查出来。”

“后来呢?”

“后来我自己好了。”


那天晚上小林跟老陈吃饭,把这事说了。

“我在想一个事。“小林说,“一个人头晕,他可能要去七个不同的科室。他自己完全不知道该去哪个。”

“对。”

“而医院是按器官分的科。心、肺、肝、肾、脑、骨。”

“对。”

“可是人是按症状来的。头晕、乏力、消瘦、失眠。”

“对。“老陈说,“你知道你在说什么吗?”

“我在说……医院的分科方式有问题?”

“不。“老陈说,“你在说一件大得多的事。”

“你在说:划分问题的方式,决定了哪些问题能被看见。”


“小林,我给你举个例子。“老陈说。

“慢性疲劳综合征。这个词你听过吧?”

“听过。就是老觉得累。”

“对。这个病,在很长一段时间里,是不存在的。”

“什么意思?”

“你去查,世界卫生组织把它列为疾病,是一九六九年。在美国 CDC 承认它,是一九八八年。在很多国家,它到现在都不是一个病。”

“为什么?”

“因为它不符合任何一种分类方式。”

“它不是心脏的问题,不是肺的问题,不是肝的问题,不是内分泌的问题,不是精神的问题。你按器官切,它无处安放。”

“而一个无处安放的东西,在医疗系统里是什么下场?”

”……”

“是’你没病’。”

“一个人说’我特别累,累了一年’,医生给他做了全套检查,全部正常,医生说’你没病,回去休息吧’。”

“他确实难受。但系统里没有他的位置。”

“这就是’分类’最残酷的地方:分不进去的东西,等于不存在。”


“我再给你举个反过来的例子。“老陈说。

“产后抑郁。”

“这个你听过。”

“这个病成为’一个病’,是很晚的事。而在它成为病之前,那些妈妈们,她们难受,但她们连’我得了什么’都说不出来。”

“后来它被定义了。”

“一旦被定义,发生了什么?”

小林想了想。“有了科室,有了医生,有了药。”

“还有呢?”

”……有了药厂的市场。”

“对。“老陈说,“一旦一件事被定义成’一个病’,围绕它的一整套产业立刻长出来。”

“医生、科室、诊断标准、量表、药物、医保编码、患者组织、科普文章。”

“而在它被定义之前,这些东西一个都不存在。”

“所以,定义问题域这件事,真正的权力在于:它决定了什么算一个问题。”

“它不解决问题。” “它甚至不判断问题的轻重。” “它只做一件事:告诉所有人,这个世界上有这些格子,你的东西要往格子里放。”


“那技术上也一样?“小林问。

“一模一样。“老陈说,“而且比医院还明显。”

“你想想,‘可观测性’这个词。”

“在二〇一七年之前,我们有什么?”

“监控。“小林说,“日志、指标、告警。”

“对。三样东西,三个领域,三套工具,三个团队。”

“搞日志的用 ELK,搞指标的用 Prometheus,搞告警的用 PagerDuty。这三个东西互相不通。”

“后来有人说了个词:Observability。可观测性。”

“这个词一出来,发生了什么?”

”……OpenTelemetry?”

“对,OpenTelemetry。一个试图把日志、指标、链路追踪统一起来的标准。”

“而这个东西在’可观测性’这个词被发明之前,是没人做的。因为没人觉得这三件事是一件事。”

“你看,“老陈说,“明明是同样的三样东西,同样是那些工程师,同样的技术水平。但是在’可观测性’这个概念出现之前,没有人想到要做 OpenTelemetry。”

“因为这个’问题’不存在。”

“它不存在,不是因为它不重要,是因为它没有被定义成一个问题。”


“老陈,我有点明白了。“小林说,“所以 P10 做的事,是定义这个格子。”

“对。”

“那怎么定义?”

老陈笑了。

“这个问题太大了。我只能给你一个我自己用的笨办法。”

“什么办法?”

“看那些’分不进去’的东西。”

“什么意思?”

“任何一个分类体系,都会产生一批’放不进去’的东西。医院里的’头晕’,技术里的’分布式追踪’——它既不是日志,也不是指标。”

“大部分人看到这些’分不进去’的东西,反应是把它硬塞进最近的那个格子。”

“‘头晕?那大概是神经内科吧。’” “‘分布式追踪?那就当它是日志的一种吧。’”

“而 P10 看到这些’分不进去’的东西,反应是:”

“‘会不会是我的格子划错了?’”


“我给你讲个真的。“老陈说。

“二〇〇三年前后,Google 内部有个问题。”

“他们的系统越来越大,一个请求要经过几十个服务。出事了,怎么查?”

“当时的’格子’是什么?”

“监控和日志。”

“监控:CPU、内存、QPS、错误率,这些是聚合的,按机器、按服务。” “日志:一条一条的文本,按行。”

“一个请求跨越三十个服务,出事了,你有什么?”

“你有三十台机器的 CPU 曲线,和一千万行日志。”

“这两样东西,没有一个能回答’这个请求在哪一步慢了’。”

“这个问题,放不进任何格子。”

“大部分公司怎么处理?他们把’查一次跨服务问题’这件事,变成了一个需要三个高级工程师、花两天时间的手工活。”

“而 Google 有个人——后来是几个人——他们说:这不是日志,不是指标,是第三种东西。”

“他们给它起了个名字,叫 tracing。”

“然后 Dapper 出来了。然后 Twitter 的 Zipkin 出来了。然后 OpenTracing、OpenCensus、OpenTelemetry 出来了。”

“今天全世界任何一个做微服务的人,都默认’链路追踪’是一个独立的东西。它在一个格子里。而这个格子,是二十年前那几个人划出来的。”


“老陈,“小林说,“你说的这些,都是特别大的事。我在我司,怎么可能有机会定义什么格子?”

“你错了。“老陈说。

“啊?”

“你每天都在定义格子,只是你不知道。”

“我举个例子。你们公司有没有’错误码’?”

“有啊。我们有一套,大概两百个。”

“谁定的?”

”……最早是框架组的老张定的,后来各个业务自己加。”

“现在有多少人用?”

“全公司都在用。”

“小林。“老陈说,“那套错误码,就是你们公司对’什么会出错’这个问题的完整定义。”

“你们公司认为,这个世界上的失败,有且只有这两百种。”

”……”

“你现在打开那个错误码表,看一眼。”

小林掏出手机,翻到内部的错误码文档。

“看完了?“老陈问。

“看完了。”

“有没有一个错误码,是’履约超时’?”

“有。”

“有没有一个是’用户取消了’?”

”……没有。”

“用户取消一个订单,在你们的系统里,是个什么状态?”

“是……订单状态变成已取消。”

“它不是错误。”

“对。“老陈说,“所以在你们公司的世界观里,‘用户改变主意’不是一种失败。”

“你们把它归类成了’流程的一部分’。”

“但你想想,从履约服务的角度看,一个订单被取消了,它已经做了仓匹配、已经占了产能、已经通知了仓库,这些全部要回滚。”

“这件事,和一个’因为仓满了导致失败’,在系统层面,代价是一样的。”

“而你们的系统,只把后者叫失败。”

“这意味着什么?”

小林慢慢地说。

“意味着……所有关于’取消’的处理逻辑,都没有被当成’失败处理’来写。”

“所以它没有重试,没有补偿,没有告警,没有监控。”

“对。”

“所以那六千单对账差异——”

“你自己去看看,那六千单里,有多少是取消单。”


小林当天晚上回去查了。

第二天他给老陈打电话。

“百分之六十二。”

“多少?”

“六千一百四十七单差异里,三千八百多单是取消单。“小林说,“全部是’取消之后仓那边已经扣减了,我们这边回滚了,但中间有一次网络抖动,回滚消息没到’。”

“这个问题存在多久了?”

“我查了 git log,相关的代码三年前写的。”

“三年。”

“三年。”

老陈在电话那头沉默了一下。

“小林,“他说,“你现在明白什么叫’定义问题域’了吗?”

“明白了。“小林说,“三年前那个写错误码的人,他做了一个决定:取消不是失败。”

“他做这个决定的时候,大概花了十秒钟。”

“而他不知道的是,他这十秒钟,决定了三年后你们要赔一百六十万。”

“而且,“老陈说,“他现在可能还觉得这个决定是对的。”

“因为从业务的角度,取消确实不是错误。用户有权取消。”

“这才是这件事最操蛋的地方。”

“他没错。他只是站在一个不同的格子里。“


Go 实例:你定义的错误,就是你的世界观

“来,我说代码。“老陈说。

“你写过错误处理吧。”

“写过。”

“那你看看这段。”

package warehouse
 
import (
	"context"
	"fmt"
)
 
// 常见写法:错误就是一串字符串
func Reserve(ctx context.Context, sku string, n int) error {
	stock, err := queryStock(ctx, sku)
	if err != nil {
		return fmt.Errorf("query stock failed: %w", err)
	}
	if stock < n {
		return fmt.Errorf("stock not enough: sku=%s, need=%d, have=%d", sku, n, stock)
	}
	if err := deduct(ctx, sku, n); err != nil {
		return fmt.Errorf("deduct failed: %w", err)
	}
	return nil
}
 
func queryStock(ctx context.Context, sku string) (int, error) { return 0, nil }
func deduct(ctx context.Context, sku string, n int) error     { return nil }

“这个有什么问题?“老陈问。

“用了 %w,可以包装。”

“对,这是 Go 1.13 之后大家都会的了。那我问你,调用方怎么处理这三个错误?”

”……”

“你说不出来,因为没法处理。“老陈说。

“如果调用方想’库存不足时给用户一个提示,数据库挂了时降级,扣减失败时重试’,他怎么写?”

小林看着那段代码。

”……用 strings.Contains?”

“对。“老陈说,“我给你写出来,你自己看看有多恶心。”

// 调用方:被迫做字符串匹配
err := warehouse.Reserve(ctx, sku, n)
if err != nil {
	switch {
	case strings.Contains(err.Error(), "stock not enough"):
		return userFacingError("库存不足")
	case strings.Contains(err.Error(), "query stock failed"):
		// 降级:当作有货,让流程继续
		return nil
	case strings.Contains(err.Error(), "deduct failed"):
		// 重试
		return retryLater()
	}
	return err
}

“这段代码有多脆弱?“老陈说。

“改一个单词就断了。“小林说。

“对。你把 stock not enough 改成 insufficient stock,调用方的判断立刻失效,而且编译不会报错,测试可能也不报错。”

“要等到线上用户看到’服务器内部错误’而不是’库存不足’的时候,你才会发现。”

“而那时候,你已经不知道是哪次提交改的了。”


“正确做法是什么?“小林问。

老陈把手机推过来。

package warehouse
 
import (
	"context"
	"errors"
	"fmt"
	"time"
)
 
// 第一层:定义这个领域的"失败本体论"。
//
// 这几行代码,是这个包对外宣告的世界观:
// 在这个领域里,失败只有下面这几类。
// 这不是技术细节,这是"我们如何看待这个业务"的声明。
 
var (
	// ErrNotFound 表示仓库中根本不存在这个 SKU。
	// 这是"查不到",不是"出错"。调用方应当按空结果处理。
	ErrNotFound = errors.New("warehouse: sku not found")
 
	// ErrInsufficient 表示库存不足。
	// 这是业务结果,不是系统故障。调用方应当给用户明确提示。
	ErrInsufficient = errors.New("warehouse: insufficient stock")
 
	// ErrUnavailable 表示仓库服务不可用(网络、超时、限流)。
	// 这是暂时的,调用方可以重试。
	ErrUnavailable = errors.New("warehouse: service unavailable")
)
 
// InsufficientError 携带具体信息,供调用方构造提示文案。
// 注意:这是一个结构体,不是字符串。
type InsufficientError struct {
	SKU   string
	Need  int
	Have  int
}
 
func (e *InsufficientError) Error() string {
	return fmt.Sprintf("warehouse: sku=%s need=%d have=%d", e.SKU, e.Need, e.Have)
}
 
// Unwrap 让 errors.Is 能够穿透这一层,匹配到 ErrInsufficient。
// 这一行是整套设计的枢纽,很多人会漏掉。
func (e *InsufficientError) Unwrap() error { return ErrInsufficient }
 
// RetryAfter 返回建议的重试等待时间。
// 注意:这个能力是"错误"对象自己提供的,不是调用方猜的。
func (e *RetryableError) RetryAfter() time.Duration { return e.after }
 
type RetryableError struct {
	after time.Duration
	cause error
}
 
func (e *RetryableError) Error() string {
	return fmt.Sprintf("warehouse: retry after %s: %v", e.after, e.cause)
}
func (e *RetryableError) Unwrap() error { return e.cause }

“看完了?“老陈问。

“看完了。”

“现在调用方怎么写?”

package order
 
import (
	"context"
	"errors"
 
	"yourcorp/warehouse"
)
 
func Place(ctx context.Context, sku string, n int) error {
	err := warehouse.Reserve(ctx, sku, n)
	if err == nil {
		return nil
	}
 
	// 第一层:判定"是哪一类失败",不关心具体文案
	switch {
	case errors.Is(err, warehouse.ErrNotFound):
		// 当作空结果处理,不是一个错误
		return nil
 
	case errors.Is(err, warehouse.ErrInsufficient):
		// 第二层:取出结构化信息
		var ins *warehouse.InsufficientError
		if errors.As(err, &ins) {
			return userFacingError("库存不足,当前仅剩 %d 件", ins.Have)
		}
		return userFacingError("库存不足")
 
	case errors.Is(err, warehouse.ErrUnavailable):
		// 第三层:错误对象自己告诉我们该等多久
		var re *warehouse.RetryableError
		if errors.As(err, &re) {
			return retryLater(re.RetryAfter())
		}
		return retryLater(time.Second)
 
	default:
		// 未知错误:不猜,直接上抛,但要留下上下文
		return err
	}
}
 
func userFacingError(format string, args ...any) error { return nil }
func retryLater(d time.Duration) error                 { return nil }

“现在,“老陈说,“我把 ErrInsufficient 的错误文案全改了,调用方的代码会坏吗?”

“不会。”

“我把 InsufficientError 加一个字段呢?”

“不会。”

“我加一个新的错误类型呢?”

”……不会,但是 default 分支会兜住它。”

“对。“老陈说,“你把一个’靠猜’的系统,变成了一个’靠契约’的系统。”


“但是,“老陈说,“我想让你看的不是这个。”

“那是什么?”

“你再看一遍那三个哨兵错误。”

var (
	ErrNotFound      = errors.New("warehouse: sku not found")
	ErrInsufficient  = errors.New("warehouse: insufficient stock")
	ErrUnavailable   = errors.New("warehouse: service unavailable")
)

“这三行,就是这个包对’什么会出错’这个问题的全部回答。”

“它在说:在我们这个领域里,失败有三种。”

“查不到、不够、不可用。”

“就这三种。”

“而小林,你知道这意味着什么吗?”

小林看着那三行,看了很久。

“意味着……’取消’不在里面。”

“对。”

“意味着你们公司的世界观里,没有’取消’这个失败。”

“意味着三年后那三千八百单。”


“我再给你看一个真的。“老陈说。

“net 包。”

// net.Error 从 Go 1.0 开始就是这个样子
type Error interface {
	error
	Timeout() bool   // 这个错误是超时吗?
	Temporary() bool // 这个错误是暂时的吗?
}

“这个接口,定义了整个 Go 世界里’网络错误’的本体论。”

“它说:网络错误只有两个维度。超时,还是暂时。”

“就这两个。”

“小林,你想想,这个定义在二〇一二年是不是对的?”

”……二〇一二年应该是对的。”

“对。二〇一二年,一个网络请求失败,原因基本就两类:要么是超时,要么是暂时性的(连接被重置、地址暂时不可达)。”

“然后到了二〇一五年、二〇一六年,微服务起来了。”

“一个请求失败了,你想知道的新东西是什么?”

”……是不是可重试的?是不是限流?是不是对端主动拒绝?要不要换一个节点?”

“对。而这些,net.Error 里一个都没有。”

“所以 Go 团队怎么办?”

“他们加了一个:”

// Go 1.13,errors 包
// 还有 os 包里这两个
os.ErrDeadlineExceeded // 超时
os.ErrClosed           // 已关闭

“然后到了 Go 1.18 之后,你可以用 errors.Is(err, os.ErrDeadlineExceeded) 来判断超时。”

“二十多年,就为了在网络错误的分类里,加清楚’超时’这一件事。”

“为什么这么难?”

“因为 Timeout() bool 这个方法,已经被全世界几万个包实现了。你不可能给它加参数,不可能改返回类型,不可能再加一个方法。”

“你只能另起炉灶,用一个新的机制,然后等全世界慢慢迁过来。”


“老陈,“小林说,“我有点懂了。”

“你说说。”

“定义错误类型,不是技术活。”

“是什么?”

“是宣布你这个世界里有什么东西。“小林说,“你定义了三种失败,以后所有人就只能在这三种里选。第四种出现的时候,他们要么硬塞进前三种,要么自己搞一套。”

“对。”

“而且,你定义得越早,改的成本越高。”

“对。“老陈说,“这就是为什么 P10 在定义一个问题域的时候,会花那么久。”

“多久?”

“我给你一个数字。“老陈说,“OpenTelemetry 这个项目,从 OpenTracing 和 OpenCensus 两个标准合并开始,到第一个稳定版本发布,用了多久?”

”……”

“两年半。而那只是’把两个已有的标准合并起来’。”

“如果要从零定义一个全新的领域呢?”

“五年。”

“而在这五年里,你每天都要面对一件事:“老陈说。

“什么事?”

“你不知道自己定义对了没有。”

“因为一个分类体系对不对,不看它自不自洽,看它能不能容纳’未来那些你还没见过的东西’。”

“而未来的东西,要到未来才知道。”

“所以这一层的人,做的所有事,本质上都是:在信息不全的情况下,给这个世界划格子,然后等十年,看看有没有人往里放东西。“

老陈的追问

  1. 你负责的系统里,有没有一类”失败”,是你们的世界观里根本不承认的?它现在以什么形式存在着——作为一个特殊的状态值,还是被忽略掉了?

  2. 你的包导出的错误类型(或者错误码),能不能让一个陌生人在不看文档的情况下,说出你们这个领域”认为会发生哪几种失败”?如果不能,你们的世界观是含糊的。

  3. 你有没有遇到过”这个东西放不进任何一个现有分类”的情况?你当时是怎么处理的?硬塞进最近的那个格子,还是停下来想过”是不是格子划错了”?

  4. 你现在维护的这套分类(错误码、状态机、事件类型),是三年前定的。这三年的业务变化,有没有产生过”分不进去”的新东西?如果有,你们是怎么把它塞进去的?

  5. 如果让你现在重新设计你们的核心领域模型,你会改哪里?如果你答”不用改”,你是在说这个模型完美,还是在说你从来没回头审视过它?

生活思考题

题一:为什么很多公司的”中台”做不下去?

大多数人会怎么答

因为太重了,不够敏捷。因为业务需求变化太快,中台跟不上。因为组织墙,各业务线不愿意用。

这个答案为什么不够

这些答案都是”执行层面”的。但中台这件事,从二〇一五年被提出来到二〇二〇年前后集体退潮,活下来的中台有一个共同点,死掉的也有一个共同点。

这个共同点和”技术能力”关系不大。

更深的一层

中台的本质,是一次分类。

你在说:这些业务(电商、外卖、酒旅、出行)虽然看起来不一样,但它们底下有一些东西是同一类的。用户、订单、支付、库存、营销。

所以你要把这些”同一类”的东西抽出来,做成共享的。

这个判断对不对?

在某些公司是对的,在某些公司是错的。

区别在哪?

在于你抽出来的那个”共同的东西”,是不是真的共同。

电商的”订单”,和外卖的”订单”,和出行的”订单”——它们是同一个东西吗?

表面上是。都是”一个人要买一个东西,付了钱,等履约”。

但拆开看:

  • 电商的订单,履约周期是三天,中间可以取消、可以退款、可以拆单
  • 外卖的订单,履约周期是四十分钟,超时就是事故,取消的代价是已经做出来的饭
  • 出行的订单,履约周期是二十分钟,但它是双向匹配的,而且价格是动态的

你把它们抽成一个”订单中心”,你就必须定义一个”订单”到底有什么字段、什么状态。

而这个定义,必然是三者中最小的那个交集,或者是最复杂的那个并集。

取交集:三个业务都觉得不够用,每个业务都要在中台之上再包一层。中台就变成了一个”又一层”。 取并集:中台变成一个谁都看不懂的怪物,改一个字段要协调三个业务线。

最深的一层

真正的判断标准是:

这些业务之间的”共同部分”,是稳定的,还是在变化的?

如果共同部分是稳定的——比如用户身份认证、支付通道、消息推送——那中台会成功。因为这部分十年不变,抽出来大家都省事。

如果共同部分是不稳定的——比如订单、营销、库存——那中台会死。因为这部分恰恰是各业务竞争的主战场,是变化最快的地方。

你永远不应该把”变化最快的东西”抽出来共享。因为共享意味着变更要协商,而协商在高速变化的领域里,是最慢的动作。

落到 Go 工程师身上

第一,在你抽出任何一层”共享”之前,先测一下它的变化速度。

具体方法:把你要抽的那个东西,过去十二个月的变更记录拉出来,数一下。

  • 一个月变一次以上的:不要抽。它是战场,抽了就死。
  • 一个季度变一次的:谨慎。可以抽,但必须留扩展点。
  • 半年以上才变一次的:放心抽。这是基础设施。

这个判断,比任何架构讨论都管用。

第二,警惕”命名上的一致”。

两个业务都叫”订单”,不代表它们是同一个东西。两个业务都叫”用户”,可能一个是 C 端消费者,一个是 B 端商家。

判断两个东西是不是”同一类”,不要看它们叫什么,看它们的状态机是不是一样。

状态机一样,就是一类。状态机不一样,就是两类,不管名字多像。

第三,如果你一定要做一个跨业务的抽象,先做”最薄的那一层”,并且只做一种能力。

不要做”订单中台”,做”订单号生成服务”。

订单号生成这个东西,所有业务都需要,而且十年不变。它足够小,小到没有任何业务会来跟你争它的定义。

当你有十几个这样的小东西之后,你会发现:你已经有了一个中台,而且是一个活的。

题二:为什么给孩子报兴趣班,最后大多数都半途而废?

大多数人会怎么答

因为孩子没兴趣。因为报太多了,精力分散。因为家长焦虑,跟风报的。

这个答案为什么不够

“孩子没兴趣”这个说法,掩盖了一个事实:任何一项技能,都必然要经过一段”没兴趣”的时期。

钢琴的前两年,是枯燥的。游泳的前半年,是难受的(冷水、呛水)。任何一项运动的前一年,都是挨打的。

一个从来没经历过”枯燥期”的兴趣,那不叫兴趣,那叫娱乐。

所以”没兴趣”不是放弃的原因,是放弃的说辞。

更深的一层

真正的原因,藏在家长报班时的那个决策里。

大部分家长报班的决策过程是这样的:

  • 听说钢琴能开发智力 → 报
  • 看邻居家孩子游泳游得好 → 报
  • 老师说这孩子有画画天赋 → 报
  • 现在编程很火 → 报

这四件事有一个共同点:它们都是从”这个东西好不好”出发的,而不是从”我的孩子是谁”出发的。

这是一个根本性的错误。

你不是在选”哪个兴趣班好”,你是在选”我的孩子和哪一项活动之间,能建立起一个长期的关系”。

而后者,需要你知道你的孩子是什么样的人。

最深的一层

这件事的难点在于:你没有办法在孩子六岁的时候,知道他十二岁会是什么样。

六岁那个坐不住的孩子,可能十二岁变成了一个特别专注的人。 六岁那个特别乖的孩子,可能十四岁开始需要一个出口。

你在用一个极其不完整的信息,做一个影响六年的决定。

这就是 P10 面对的那种决策。

区别只在于:一个六岁的孩子,你错了可以改;一个公司的五年战略,你错了改不了。

落到 Go 工程师身上

第一,区分”选什么”和”怎么选”。

大部分人在这件事上花的时间,90% 在”选什么”上,10% 在”怎么选”上。而真正重要的恰恰是后者。

什么叫”怎么选”?

不是”我要学 Rust 还是学 Go”,是:

  • 我在一年后,用什么标准来判断我这个选择是对的?
  • 我怎么在三个月内,用最小的成本,获得关于这个选择的关键信息?
  • 如果这个选择错了,我的退出成本是多少?

第二,给每一个”长期投入”设一个”止损点”和”加仓点”。

止损点:出现什么情况,我就停。 加仓点:出现什么情况,我就加倍投入。

学一门新技术:

  • 止损点:三个月后,我还没能用它做出一个真实的东西,或者我做的过程中持续地感到痛苦 → 停
  • 加仓点:我用它做出了一个东西,而且有人(任何人)用了,而且给了反馈 → 加倍

这两个点,必须在你开始之前就定好,写下来。

因为在过程中,你永远有理由不停:已经学了两个月了,现在放弃太可惜。

第三,最重要的一条:允许放弃,但要求复盘。

半途而废是不是坏事?不一定。

真正的坏事是”无声无息地放弃”——你不再做那件事了,但你从不承认自己放弃了,你只是不再提它。

正确的做法是:每一次放弃,都写一份五十字的复盘。

我放弃了:____
我放弃的原因:____
我当初的判断是:____
我漏掉的信息是:____

这五十个字的作用,不是为了下次不放弃。

是为了让你在第十次放弃的时候,能看出来:你每次放弃的原因,是不是同一个。

如果是,那你面对的就不是”选择”的问题了。

7.7 生态:为什么免费的东西能成为最深的护城河

老陈的那个塑料袋

八月,老陈要去一趟欧洲。走之前小林去他家,看见他在收拾行李。

沙发上摊着一个透明的塑料袋,里面全是转接头。

“我数数啊。“老陈倒出来,“这个是英标的,三个方脚,香港、英国、马来西亚用。这个是欧标的,两个圆脚,德法意西荷比奥。这个是美标的,一圆一扁。这个是意标的,三个脚排成一条线,意大利和智利用。这个是瑞士的,比欧标细一号。还有这个是印度的,南非也用,三个圆脚,但它和英标不通用。”

“七个。”

“你带七个转接头?”

“我每次都带七个。“老陈说,“我二十年前第一次出国,带了两个,在米兰的酒店里,手机充不上电,我在大堂坐到凌晨一点,等一个中国团的导游下来,跟她借。”

“那次之后我就买齐了。”

“这七个花多少钱?”

“加起来大概三百二。”

“你用了多少年?”

“二十年。”


“小林,你有没有想过一个事。“老陈把转接头一个个装回去。

“这七个转接头,全世界每年要卖多少个?”

”……很多吧。”

“我查过一次,一个做旅行转换插头的厂家,年销量两千万个。单价二十五到六十。”

“一年几个亿,就这一家公司。”

“而这些钱,本质上是在给谁交税?”

小林愣了一下。

“给……定标准的人?”

“对。“老陈说,“给那个在一九〇几年、或者一九四几年,坐在一间屋子里,决定’我们国家的插座应该是这个形状’的那个人。”

“他做了一个决定。”

“这个决定之后的一百年里,这个国家里所有生产电器的人,都要按他的形状来做插头。所有装修房子的人,都要按他的形状来装插座。所有来这个国家的人,都要买一个转接头。”

“他没有收任何人的钱,但他让所有人在接下来的一百年里,每做一件相关的事,都要想起他一次。”

“这就是标准。”


“我给你讲个更狠的。“老陈说。

“QWERTY。”

“键盘布局?”

“对。你现在用的键盘,左上角的六个字母是 Q-W-E-R-T-Y。”

“这个布局是谁设计的?”

”……”

“一八七三年,克里斯托弗·肖尔斯,一个美国人,做打字机的。”

“他为什么这样排?”

“有一个流传很广的说法:是为了降低打字速度。”

“因为早期的机械打字机,如果相邻的键被快速连按,连杆会卡住。所以肖尔斯把常用的字母组合,分散到了不同的手指上。”

“这个说法有争议,有人说是为了便于演示,有人说是因为当时电报员的习惯。但不管哪个版本,有一件事是确定的:”

“QWERTY 不是最优的。”

“一九三六年,有个叫德沃夏克的人,发明了一种新布局,叫 Dvorak。他做了实验,证明 Dvorak 布局能让打字速度提高百分之二十到四十,手指移动距离减少百分之六十。”

“这个布局今天还在,Windows 和 macOS 里都能选。”

“有谁在用?”

”……没人。“小林说。

“对,几乎没人。”

“一百五十年了。人类登上了月球,造出了核武器,发明了互联网,把算力做到了指甲盖大小。”

“但是全世界的键盘,还是那个一八七三年为了降低打字速度而设计的布局。”

“为什么?”

“因为换键盘的成本,不是换一个键盘的钱。”

“是全世界几十亿人,要重新学一遍打字。”

“是所有的键盘厂商,要重新开模。” “是所有的输入法,要重新做一套。” “是所有的培训教材,要重写。” “是所有已经形成肌肉记忆的人,要经历一段比现在慢得多的时期。”

“这个成本,大到没有任何一个人、任何一家公司,能够承担。”

“所以一个在一八七三年做出的、可能还是错的决定,锁死了一百五十年。”


“老陈,这个和我们有关系吗?“小林说,“我们又不是定标准的。”

“你确定?”

“我们公司就是做工业质检的,我们定不了什么标准。”

“小林,我问你,“老陈说,“你们给客户交付的时候,那个接口的字段,是谁定的?”

”……我们定的。”

“客户的数据格式呢?”

“我们定义的。”

“缺陷的标签体系呢?”

“也是我们的。”

老陈看着他。

“你现在告诉我,你们定了多少东西?”

小林不说话了。

“我今天早上那家公司,“老陈说,“郑维说的那个 C 方案——质检数据标准。他说不清楚怎么收钱,但他其实已经有一半答案了。”

“什么答案?”

“收钱这件事,对于标准来说,是最不重要的。”

“重要的是:一旦这个行业接受了你的标注格式,那么”

“所有做质检算法的人,都要读你的格式。” “所有做质检设备的人,都要输出你的格式。” “所有做质检服务的人,都要用你的工具链。” “所有新入行的工程师,学的第一套标签体系,是你的。”

“到那个时候,你要不要收钱,是你说了算。”

“而在此之前,你收不到钱。因为在此之前,你手上什么都没有。”

“生态这件事的全部难点,就在这儿:你必须在很长一段时间里,提供巨大的价值,而一分钱都不收。”


“我给你讲个真的。“老陈说。

“Go 语言。”

“Go 是 Google 做的。你知道 Google 在 Go 上花多少钱?”

“不知道。”

“我给你一个粗略的数。Go 团队在巅峰时期大概三十几个人,全是顶级工程师。按硅谷的薪酬算,一年的人力成本就是一千五百万美元往上。加上基础设施、会议、社区运营。”

“从二〇〇九年开源到今天,十六年。”

“至少三亿美元。而 Go 一分钱都不收。”

“Google 图什么?”

“图三件事。”

“第一,它让 Google 自己的工程师更 productive。Google 内部有几万 Go 工程师,效率提升哪怕 5%,那也是几十亿美元。”

“第二,它塑造了整个云原生生态的形状。Kubernetes、Docker、Prometheus、etcd、Terraform——这个世界的整个基础设施层,是 Go 写的。而这一层的标准,很大程度上是 Google 在定。”

“第三,最隐性的一件:它让 Google 在招人的时候,拥有了一个巨大的、免费的、训练有素的人才池。”

“这就是生态真正的回报方式:不是钱,是位置和人。”


“那我们小公司怎么学?“小林问。

“你学不了 Google 的规模,但你能学那个结构。”

“什么结构?”

“三层。”

老陈在桌上比划。

“第一层,免费提供一个真实的、有用的东西。” “第二层,让足够多的人依赖它。” “第三层,从’依赖’里长出来的东西,才是生意。”

“我给你讲个小的,讲我投过的公司。”

“二〇一八年我投过一家,做 API 网关的,八个人。”

“他们做了什么?”

“他们开源了一个 Go 写的插件 SDK,特别小,就一个接口,三个方法。BSD 协议,随便用。”

“这个 SDK 有什么特别的?”

“特别的地方在于,它是跨实现的。你按这个 SDK 写的插件,可以用在 Kong 上,可以用在 APISIX 上,可以用在 Envoy 上,也能用在他们自己的网关上。”

“他们图什么?”

“他们图的是:让全世界写网关插件的人,都养成一种习惯。”

“这个习惯是什么?”

“是’我写一个插件,第一步是 import 他们的 SDK’。”

“两年后,有四百多个第三方插件用了这个 SDK,其中三十几个是公司内部的核心插件。”

“然后他们在 SDK 里,加了一个企业版才有的能力:插件的热更新和灰度。”

“免费的 SDK 里没有,企业版里才有。”

“那些公司怎么办?”

”……买。”

“对。因为他们的三十几个核心插件,全部依赖这个 SDK。重写要三个月。而买企业版,八万一年。”

“你看这个链条:他们给了两年的免费,换来的是’重写成本高于购买成本’这个局面。而这个局面,就是护城河。”

“老陈,那他们成功了吗?”

“死了。“老陈说。

”……”

“二〇二一年死的。”

“为什么?”

“因为他们的 SDK 太好用了,好用到所有主流的网关都内置了兼容层。”

“你写的插件,不用他们的 SDK,直接在 Kong 上跑,Kong 自己就认。”

“他们辛辛苦苦建的那个’依赖’,被上游一口吞了。”

老陈笑了。

“小林,这就是生态最操蛋的地方。”

“生态不保护建设者。生态只保护位置。”

“你在生态里是一个’必经之地’,你就活着。你在生态里是一个’可选插件’,你随时会被上游收编。”

“那怎么判断自己是不是’必经之地’?”

“问一个问题。”

“什么问题?”

“如果明天你消失了,你的用户需要多久才能找到一个替代品,并且迁过去?”

“如果是三天,你在卖功能。” “如果是三个月,你在卖产品。” “如果是三年,你在卖生态。”

“如果是零天——”

“如果是零天,说明你从来就没存在过。“


Go 实例:免费的东西,背后最贵的东西

“我再跟你说一件事。“老陈说。

“你知道一个开源库,最贵的部分是什么吗?”

“是写代码?”

“不是。”

“是维护?”

“接近了,但还不准确。”

老陈把电脑打开。

“是**‘你不能改’这件事本身**。”

“我给你看一段真实的代码,这是 Go 标准库里 math/rand 在 Go 1.20 前后经历的变化。”

package main
 
import (
	"fmt"
	"math/rand"
)
 
func main() {
	// Go 1.19 及之前:每次运行输出相同的序列
	// Go 1.20:自动随机播种,每次不同
	// Go 1.21:回滚,恢复确定性
	// Go 1.22+:重新变成随机,但提供了完整的降级路径
	fmt.Println(rand.Intn(100))
}

“你看这四个版本。“老陈说。

“二〇二三年前后,Go 团队就为了这一行代码,来回折腾了三个版本。”

“为什么这么难?”

“因为全世界有几百万行代码,依赖’rand.Intn 每次运行结果相同’这个行为。”

“这些人里,有人用它生成测试数据,有人用它做随机抽样,有人用它做游戏。”

“你改变这个行为,他们的测试就炸了。”

“而他们中的大部分人,根本不会去看 Go 的 release note。”

“他们只会发现:我什么都没改,升级了 Go 版本,我的 CI 红了。”


“我给你看看他们最后是怎么解决的。“老陈说。

“这是 Go 生态里处理’必须改但又不能改’的标准做法,我总结成四步。”

“第一步:用 GODEBUG 提供一个逃生舱。”

// Go 1.20 引入的开关。
// 用户在启动时设置环境变量,可以退回旧行为:
//
//   GODEBUG=randautoseed=0 go test ./...
//
// 或者在代码里写死(不推荐,但紧急时可用):
//
//   //go:debug randautoseed=0
//   package main

“这一步的意义不是技术上的,是政治上的。”

“它在对全世界说:‘我知道我要改,我给你一个不改的方法,你自己决定。’”

“这把’强制变更’变成了’可选变更’。”

“第二步:在文档里明确写出兼容期。”

// Deprecated: As of Go 1.20, the global random number generator is
// automatically seeded. Programs that need reproducible sequences
// should use rand.New(rand.NewSource(seed)) instead.
//
// The old deterministic behavior can be restored with
// GODEBUG=randautoseed=0, which will be supported for at least
// two more major releases.

“注意最后那一句:‘至少再支持两个大版本’。”

“这句话不是客套,是合同。”

“它告诉用户:你有两年的时间迁。两年后我就可以不管你了。”

“第三步:提供一条明确的迁移路径,而不是只说’别用了’。”

package main
 
import (
	"fmt"
	"math/rand"
)
 
// 旧写法(依赖全局源,行为受 Go 版本影响)
func old() int {
	return rand.Intn(100)
}
 
// 新写法(显式创建源,行为永远确定,与 Go 版本无关)
func newDeterministic() int {
	r := rand.New(rand.NewSource(42))
	return r.Intn(100)
}
 
// 新写法(需要真随机)
func newRandom() int {
	return rand.IntN(100) // Go 1.22 新增,使用 crypto 级别播种的全局源
}
 
func main() {
	fmt.Println(old(), newDeterministic(), newRandom())
}

“注意这个 newDeterministic。“老陈说,“它的价值在于:用户不需要理解 GODEBUG,不需要理解播种,他只需要知道’这样写,行为永远是确定的’。”

“好的迁移路径,是一条不需要理解原理就能走的路。”

“第四步,也是大部分人漏掉的一步:提供一个发现机制。”

“你怎么让全世界那些不知道自己依赖了旧行为的人,知道自己依赖了?”

“Go 的做法是:GODEBUG 的每一次生效,都会在运行时记录一条 trace,并且有一个 go vet 的 pass 会扫描代码。”

“更狠的一招是:他们在 Go 1.21 的 release note 里,把这件事放在了最开头的兼容性章节,而不是放在 math/rand 的变更列表里。”

“因为他们知道:绝大多数人不会去读 math/rand 的变更,但所有人都会扫一眼’兼容性’那一节。”


“我把这四步,套到你自己维护的库上。“老陈说。

“假设你要废弃一个函数。大部分人怎么做?”

// 大部分人的做法:加一行注释,然后就没了
// Deprecated: use DoContext instead.
func (c *Client) Do(method, url string, body []byte) (*http.Response, error) {
	return c.DoContext(context.Background(), method, url, body)
}

“这有什么问题?“老陈问。

”……不知道什么时候会删。”

“对,这是第一个问题。”

“第二个问题:没人知道该怎么迁。DoContext 的参数是什么?行为有什么不一样?”

“第三个问题,也是最严重的:没有任何机制,让使用者在编译期或者运行期,知道自己用了废弃的 API。”

“他用了,他不知道。三年后你删了,他升级,编译失败,他在群里骂你。”

“正确的做法是什么?”

package httpclient
 
import (
	"context"
	"log/slog"
	"net/http"
	"os"
	"sync/atomic"
)
 
// 正确做法:一个"会说话"的废弃
 
// deprecationNotices 记录本次进程内触发过的废弃调用。
// 它的价值在于:它让"有多少人在用废弃 API"这件事,变成了一个可观测的数字。
var deprecationNotices sync.Map
 
// Do 执行请求。
//
// Deprecated: 自 v1.9.0 起废弃,请使用 DoContext。
//
//   - 迁移方式:在第一个参数传入 context.Context,其余参数不变。
//   - 移除时间:不早于 v2.2.0(也就是说,你至少有 3 个 minor 版本的时间)。
//   - 紧急回退:设置环境变量 GOCLIENT_LEGACY_DO=silent 可关闭告警。
//
// 本方法在 v1.x 生命周期内保证行为不变。
func (c *Client) Do(method, url string, body []byte) (*http.Response, error) {
	reportDeprecation("Do", "DoContext", "v2.2.0")
	return c.DoContext(context.Background(), method, url, body)
}
 
// reportDeprecation 做三件事:
//   1. 记录一次(可以被监控系统采集)
//   2. 在测试环境下直接 panic,逼测试作者处理
//   3. 在生产环境下只打一次日志,避免刷屏
func reportDeprecation(oldName, newName, removeIn string) {
	_, loaded := deprecationNotices.LoadOrStore(oldName, struct{}{})
	if loaded {
		return // 每个进程只报一次
	}
 
	if os.Getenv("GOCLIENT_LEGACY_DO") == "silent" {
		return
	}
 
	msg := "deprecated API used: " + oldName + " -> use " + newName + " (removal no earlier than " + removeIn + ")"
 
	// 关键设计:测试环境下升级为硬失败
	if os.Getenv("GO_ENV") == "test" {
		panic(msg)
	}
 
	slog.Warn(msg, "api", oldName, "replacement", newName)
}

“看明白了吗?“老陈说。

“这个 reportDeprecation 做了三件事。”

“第一,可观测。你能在监控系统里看到’有多少个服务在用废弃 API’。这个数字,是你决定什么时候真正删除它的唯一依据。”

“第二,测试环境硬失败。这是最狠的一招。它让’升级’这件事必须由使用者主动完成,而不是等他哪天想起来。”

“第三,生产环境只报一次。因为生产环境刷日志是另外一种灾难。”

“而最重要的,是上面那段注释里的三行:”

//   - 迁移方式:在第一个参数传入 context.Context,其余参数不变。
//   - 移除时间:不早于 v2.2.0。
//   - 紧急回退:设置环境变量 GOCLIENT_LEGACY_DO=silent 可关闭告警。

“这三行,就是你对用户做出的承诺。”

“迁移方式:告诉他怎么走。” “移除时间:告诉他还有多久。” “紧急回退:告诉他如果来不及,有路可退。”

“少了任何一行,这个 Deprecated 就是不负责任的。”


“小林,我现在说这件事的本质。“老陈说。

“一个开源库,或者一个内部平台,它真正的产品不是代码。”

“是什么?”

“是信任。”

“用户信任你,才会把他的生产系统建在你上面。”

“而信任是怎么建立的?”

“不是靠你的代码写得好。不是靠你的文档写得全。不是靠你回 issue 快。”

“是靠你在每一次’你可以不管,但你管了’的时刻,累积起来的。”

“你可以直接删掉那个废弃的 API,你没删,你给了三个版本的过渡期。” “你可以说’这是你们的问题’,你没说,你写了一个迁移工具。” “你可以说’这是预期行为’,你没说,你承认那是个 bug 并修了。”

“每一次这样的时刻,你的账户里就多一笔钱。”

“而这条账户,是你唯一的护城河。”

“因为代码会被抄,功能会被追,性能会被超过。”

“唯一抄不走的,是一群人相信’你会对他们负责’这件事。”

“而这件事,需要多久?”

“三年。”

“为什么是三年?”

“因为人的遗忘周期是三年。三年前你做过的所有承诺,如果到现在你还守着,那么从这个时刻起,人们才开始真的相信你。”

“前三年,你是在证明。三年之后,你才拥有。“

老陈的追问

  1. 你维护的东西(库、平台、服务),如果明天消失了,用户需要多久才能迁走?你能说出这个数字吗?是从哪里得来的——是猜的,还是测过的?

  2. 你有”废弃 API”的清单吗?清单上每一条,有没有写清楚”什么时候删”和”怎么迁”?如果没写,你打算什么时候写?

  3. 你有没有统计过:你维护的这个东西,有多少”你不认识的人”在用?如果全部是你团队内部在用,你做的是工具,不是生态。

  4. 你上一次为了”不破坏用户”而放弃一个技术上更好的方案,是什么时候?如果从来没有过,你可能在做一个技术作品,而不是一个产品。

  5. 你的用户里,有没有人因为信任你,而做了一个他自己也知道有风险的依赖?如果有,你打算怎么对得起这份信任?

生活思考题

题一:为什么有些免费的公园,比收费的游乐场更让人流连忘返?

大多数人会怎么答

因为免费。因为自然风景比人工设施好。因为没有商业化,更纯粹。

这个答案为什么不够

“免费”是最表层的解释。真正免费的东西太多了,大部分免费的东西没人去。

而”没有商业化”这个解释,只看了一半。

更深的一层

一个让人流连忘返的公园,它提供的不是”免费的空间”,是**“可预期的空间”**。

你今天去,那棵树在那儿。 你十年后去,那棵树还在那儿,只是粗了一圈。 你带着孩子去,你指着那棵树说:我小时候在这棵树下玩过。

这条时间线,是任何收费游乐场都给不了的。

一个游乐场,三年后设备就换了。主题变了,票价变了,甚至名字都变了。你去第二次,你找不到第一次的那个东西。

而公园最值钱的资产,恰恰是它变化极慢。

最深的一层

人对一个地方的感情,不取决于它有多好,取决于它承载了多少”我自己”的东西。

一个公园里,有你第一次约会的那条长椅,有你孩子学会走路的那片草地,有你父亲生前每天早上打太极的那个角落。

这些东西不是公园给你的,是你自己放进去的。

而公园做对了唯一一件事:它没有把你放进去的东西拿走。

一个收费游乐场,每年都在更新,每年都在拿走你去年的记忆。它让你每次都是新的,也就让你每次都是陌生的。

这就是”生态”和”产品”的本质区别。

产品的价值,来自它给你的东西。 生态的价值,来自你在里面放的东西。

落到 Go 工程师身上

第一,你做的东西,有没有一个”用户能往里放东西”的地方?

一个库,如果只能被调用,那是产品。 一个库,如果可以被扩展——有插件、有钩子、有接口让你注入——那是生态的雏形。

具体到 Go:你有没有导出一个用户可以实现的接口?你有没有一个用户可以注册的地方?

第二,你做的东西,变化速度是多少?

这是一个反直觉的指标:对基础设施来说,变化慢不是缺点,是优点。

Go 的十六年,只发了一个大版本。这不是保守,这是它最大的卖点。

你维护一个内部库,如果你每个月都在发不兼容的更新,你是在告诉用户:“别依赖我,我随时会变。”

而用户的反应一定是:我不依赖你,我自己写一份。

第三,去做一件”三年不变”的事。

你手上有没有一个东西,是三年前你做的,现在还在,而且还是那个样子?

如果有的话,去看看它的用户——他们有没有在上面长出你没想到的东西?

如果有,那就是生态。你只是种了一棵树,别人在树下摆了茶摊。

题二:为什么很多”平台”最后变成了”另一个需求方”?

大多数人会怎么答

因为平台脱离业务。因为平台团队不懂一线。因为 KPI 导向,平台要做出东西来证明自己。

这个答案为什么不够

这些答案都在说”平台做错了什么”,但没说”平台为什么会变成这样”。

一个平台团队里的人,和一线业务团队里的人,是同一批学校毕业的,智商差不多,同样努力。为什么进平台三年之后,他们就”脱离业务”了?

更深的一层

因为平台的激励结构是反的。

一线业务团队的激励是什么?是业务指标。订单量、转化率、故障数。这些指标有一个特点:它们由用户的真实行为决定。你做得好不好,用户会用脚投票。

平台团队的激励是什么?

“接入了多少个业务方。” “支撑了多少 QPS。” “节省了多少人日。”

这三个指标有什么共同点?

它们全部是平台自己能决定的,不需要业务方真的满意。

“接入了多少个业务方”——业务方被 KPI 逼着接入了,接完就不用了,算不算接入?算。 “支撑了多少 QPS”——业务方没得选,只能走你的平台,这算不算支撑?算。 “节省了多少人日”——这个数字是平台自己估的,谁来验证?

当一个团队的 KPI 全部由自己能控制的变量构成时,这个团队就必然脱离用户。

这不是道德问题,是结构性必然。

最深的一层

而真正致命的一步在后面:

平台发现自己推不动业务方的时候,它会做一件事——去要权力。

它会去跟管理层说:“接入平台应该是一个强制要求。”

一旦这个要求被批准,平台就从一个”服务提供者”,变成了一个”规则制定者”。

而规则制定者,是不需要用户满意的。

这就是为什么很多平台最后变成了”另一个需求方”:它开始对业务方提要求了,而不是业务方对它提要求了。

落到 Go 工程师身上

第一,给你的平台(或者你负责的内部工具)设一个”反向指标”。

不要只统计”多少人在用”。统计:

  • 有多少人主动在用(不是被强制的)
  • 有没有人在非工作时间用过(说明它真的顺手)
  • 有没有人给你提过改进建议(提建议意味着在乎)
  • 有没有人绕过你的平台,自己做了一套(这是最痛的一个指标,但最真实)

第二个指标尤其重要。一个只有上班时间有人用的工具,和一个周末也有人打开的工具,是两种完全不同的东西。

第二,每年做一次”取消强制”。

选一个季度,宣布:接入平台不再是强制的,业务方可以自己选。

然后看有多少业务方留下来。

这个数字,是你这个平台真实价值的唯一衡量。

大部分平台做不了这个实验。而做不了这个实验的平台,它的价值是存疑的。

第三,最狠的一条:平台团队里,必须有人”背着业务的指标”。

不是”支撑业务的指标”,是业务的指标本身。

平台团队的某个人,他的年终奖要跟某一个业务线的 GMV 挂钩。

这一条如果做不到,这个平台永远会脱离业务。因为没有共同的损失,就没有共同的目标。

7.8 接班人:你真正的作品是什么

一把刨子,和一本手册

九月中,老陈回了趟保定。

这次他带上了小林。他说他要去看看他爸的徒弟。

“我爸带过七个徒弟。“路上的时候老陈说,“最后还在做这行的,两个。”

“那两个现在怎么样?”

“一个在县城开了个家具厂,二十几个人。一个在天津,做古建修复。”

“我们去哪个?”

“县城那个。他叫王德发,我爸叫他小发子。比我大七岁,今年六十。”


家具厂在县城边上,一个院子,两排厂房。

王德发在车间里。六十岁的人,背有点驼,手上全是老茧,指甲缝里是黑的。

看见老陈,他愣了一下,然后笑了。

“小陈?”

“王哥。”

“你怎么来了。”

“来看看你。”

王德发在围裙上擦了擦手,把老陈往里让。

车间里摆着七八件半成品,是那种中式家具,榫卯结构,没有一根钉子。空气里是木头的味道,还有刨花的味道。

“这是小林。“老陈说,“我朋友的儿子,写程序的。”

王德发点点头,给小林倒了杯水。搪瓷缸子,上面印着”劳动模范”。

“我爸走的时候,“老陈说,“留下两样东西。我想看看。”

王德发停了一下。

“刨子在老屋杂物间。“他说,“我每年擦一次。”

“手册呢?”

“在楼上。”


手册是拿出来的。

那是一本线装的本子,封面是牛皮纸,已经发脆了。上面用毛笔写着四个字:《木工匠心》。

翻开,里面是毛笔画的图。

有各种榫卯的画法,燕尾榫、明榫、暗榫、楔钉榫。每一张图旁边有小字,写着尺寸和要点。

“这全是我爸画的?“老陈问。

“不全是。“王德发说,“前十几年是他画的,后来他手抖了,画不了了,就我接着画。再后来我眼睛也不行了,就我儿子画。”

“你儿子?”

“我儿子在厂里。“王德发往窗外指了指,“在跟客户谈。”

“我孙子也在。”

老陈翻着那本册子。

小林凑过去看。他看不懂榫卯,但他看得懂那些字。

有一页上写着:

燕尾榫,头大尾小,形如燕尾。
要点:
一、头尾比例七三,过小则不牢,过大则易裂。
二、榫头不可满,留一线。留一线者,可入胶,可透气,热胀冷缩有余地。
三、开榫必先试,试三次再落锤。
四、冬月木性收敛,留线宜宽半分。
   夏月木性舒张,留线宜窄半分。

“这个’留一线’,“老陈说,“你教过你儿子吗?”

“教过。”

“他做到了吗?”

王德发笑了。“头两年做不到。他嫌麻烦,做满,结果那年夏天有一批柜子,抽屉全卡住了。”

“后来呢?”

“后来他自己拆了重做。”

“你没告诉他?”

“我说过。“王德发说,“他听不进去。这种事,得自己撞。”


“我爸的那把刨子,“老陈说,“你看了吗?”

“看了。“王德发说,“我上个月刚擦过。”

“怎么样?”

“刃磨得很薄。“王德发说,“他手劲大,推刨子推了一辈子,那把刨子的刃口,被他磨成了他自己的角度。”

“别人用不了?”

“用不了。“王德发说,“我试过。我拿着推,推不动,一推就啃木头。”

“他给我做过一把新的,按我的手劲。那把我用了三十年。”

老陈点点头。

“所以那把刨子,现在就是个念想。”

“是个念想。“王德发说,“但它没用了。”


从车间出来,老陈在院子里站着。

“小林,你看出什么了吗?”

小林想了一下。

“那把刨子,是你爸最个人的东西。它按他的手做的,只有他能用。”

“然后呢?”

“所以他一走,刨子就死了。”

“对。“老陈说,“那把刨子是我爸这辈子最好的工具,也是唯一一个随他一起死掉的东西。”

“因为它是为他一个人设计的。”

“而那本册子——”

“那本册子不是为你爸设计的。“小林说,“它是为所有不认识他的人设计的。”

“对。“老陈说,“我爸画那本册子的时候,他不知道将来谁会用它。他不知道四十年后,会有一个他不认识的年轻人——王德发的孙子——翻到’冬月留线宜宽半分’那一页。”

“他甚至不知道他画这些的时候,他儿子会不会做这行。”

“但他画了。”


“小林,“老陈说,“这就是接班人这件事的全部答案。”

“什么东西?”

“你的接班人,不是那个’接你的班’的人。”

“那是谁?”

“是你的作品离开你之后,还能被谁使用。”

“一把按你手劲做的刨子,你的儿子都用不了。” “一本按’任何人’写成的册子,四十年后还有人在翻。”

“区别在哪?”

“在于你写的时候,脑子里有没有那个’不认识你的人’。”


“我给你讲个技术的。“老陈说。

“二〇〇八年,我在一家公司,带过一个人,比我小八岁,叫赵明。”

“赵明是我一手带起来的。我从他应届开始带,带了六年。”

“我教他怎么设计系统,怎么做技术选型,怎么在会上说话,怎么写文档。六年,我把我会的东西,一样一样地教给他。”

“二〇一四年他走的时候,已经是那边的技术负责人了。”

“我挺高兴的。我觉得这是我的作品。”

“然后呢?”

“然后他走了三年之后,我去看他在的公司,我发现他们那套系统,跟我当年教他的,一点都不像。”

“为什么?”

“因为那三年里,行业变了。微服务起来了,容器起来了。他面对的问题,跟我当年面对的,完全不一样。”

“他把我的东西,全改了。”

老陈笑了。

“我当时心里特别不是滋味。我带了他六年,他把我教的东西全扔了。”

“后来呢?”

“后来有一次,我们俩喝酒,我跟他说了这事。”

“他怎么说?”

“他说了一句话。”

“什么话?”

“他说:‘陈哥,你教我的不是那套系统。你教我的是,什么东西该扔。’”


老陈看着院子里的那两排厂房。

“小林,我那天晚上想了很久。”

“我这辈子教过很多人。有些人在用我教的东西,有些人早就不用了。”

“但真正让我觉得’没白教’的,是那些把我教的东西改造过的人。”

“因为那意味着,我给他们的不是一把刨子,是一本册子。”

“册子的意思是:你可以改,你可以加,你可以在上面写你自己的东西。”

“而一个只会复制你的人,他其实什么都没得到。他只是租用了你的经验几年,到期了就还回来了。”


“所以判断你是不是有一个接班人,有三个标准。“老陈说。

“第一个:他做过的判断里,有没有你没教过他的?”

“如果他的每一个判断,都能从你这儿找到出处,那你没有接班人,你有一个复印机。”

“第二个:他有没有改过你定下的东西?”

“如果三年过去,你定下的所有东西一个字没动,那不是他尊重你,那是他不敢。”

“第三个,最狠的:”

“他改过的那些东西里,有没有改得比你好,让你沉默的?”

“如果有,恭喜你。你有一个真正的接班人。”

“因为那一刻,你终于知道:你不重要了。”

“而’你不重要了’这件事,就是这一级唯一的成就。“

Go 实例:留下一把尺子,而不是一把钥匙

“我给你看个东西。“回到北京,老陈把笔记本打开。

“这是我二〇二一年帮一家公司做的一个东西。他们有个 Go 的缓存库,内部有四个不同的实现。”

“四个实现?”

“对。最早是本地内存,后来加了 Redis,再后来加了多级缓存,还有一个是为了测试用的空实现。”

“问题在哪?”

“问题在于:这四个实现,行为全都不一样。”

“我给你列几条:”

实现 A(内存):TTL 到期后,Get 返回 nil, nil
实现 B(Redis):TTL 到期后,Get 返回 nil, ErrNotFound
实现 C(多级):本地过期但 Redis 没过期时,Get 返回 Redis 的值
实现 D(空实现):Get 永远返回 nil, ErrNotFound

实现 A:Delete 一个不存在的 key,返回 nil
实现 B:Delete 一个不存在的 key,返回 nil
实现 C:Delete 一个不存在的 key,返回 ErrNotFound(因为 Redis 的 DEL 返回 0)
实现 D:Delete 返回 nil

“你看出问题了吗?“老陈问。

“同一个接口,四个实现,行为不一致。”

“对。而调用方不知道。他写了一套代码,跑在本地缓存上没问题,一上 Redis 就炸。”

“他们怎么解决的?”

“写了一百多页的文档,告诉所有人’每个实现的差异’。”

“有用吗?”

“没用。没人看。”


“后来我给他们做了一个东西。“老陈说。

“我不改他们的实现。我只给他们加了一个包。”

“什么包?”

“一个契约测试包。”

package cachetest
 
import (
	"context"
	"errors"
	"testing"
	"time"
)
 
// 这是给"未来的实现者"写的尺子。
//
// 设计意图:
// 我们不测任何一个具体实现。我们测的是"任何声称自己是 cache.Cache 的东西,
// 必须满足哪些行为"。
//
// 任何新实现,只要在它的测试里调用 Suite,就能知道自己合格不合格。
 
// Factory 由被测方提供,返回一个干净的、空的新实例。
type Factory func(t *testing.T) Cache
 
// Cache 是我们要求的接口(这个接口定义在 cache 包,这里重复声明只为自包含)。
type Cache interface {
	Get(ctx context.Context, key string) ([]byte, error)
	Set(ctx context.Context, key string, val []byte, ttl time.Duration) error
	Delete(ctx context.Context, key string) error
}
 
// ErrNotFound 是所有实现都必须返回的哨兵错误。
var ErrNotFound = errors.New("cache: not found")
 
// Suite 是完整的契约测试。
// 使用方式(在各个实现的测试里):
//
//   func TestRedisCache(t *testing.T) {
//       cachetest.Suite(t, func(t *testing.T) cachetest.Cache {
//           return newTestRedisCache(t)
//       })
//   }
func Suite(t *testing.T, newCache Factory) {
	t.Helper()
 
	// 下面每一条,都是一条"规矩"。
	// 这些规矩不是我发明的,是从过去三年里四个实现各自踩过的坑里提炼出来的。
 
	t.Run("Get 一个不存在的 key 必须返回 ErrNotFound", func(t *testing.T) {
		c := newCache(t)
		_, err := c.Get(context.Background(), "no-such-key")
		if !errors.Is(err, ErrNotFound) {
			t.Fatalf("期望 ErrNotFound,实际: %v", err)
		}
	})
 
	t.Run("TTL 过期后必须返回 ErrNotFound,不能返回 nil,nil", func(t *testing.T) {
		c := newCache(t)
		ctx := context.Background()
		if err := c.Set(ctx, "k", []byte("v"), 100*time.Millisecond); err != nil {
			t.Fatal(err)
		}
		// 立刻读,应该还在
		if _, err := c.Get(ctx, "k"); err != nil {
			t.Fatalf("刚写入就读不到: %v", err)
		}
		// 等过期
		time.Sleep(200 * time.Millisecond)
		if _, err := c.Get(ctx, "k"); !errors.Is(err, ErrNotFound) {
			t.Fatalf("过期后应返回 ErrNotFound,实际: %v", err)
		}
	})
 
	t.Run("Delete 一个不存在的 key 必须返回 nil(幂等)", func(t *testing.T) {
		c := newCache(t)
		if err := c.Delete(context.Background(), "no-such-key"); err != nil {
			t.Fatalf("Delete 必须是幂等的,实际返回: %v", err)
		}
	})
 
	t.Run("Set 之后再 Get 必须拿到相同的值", func(t *testing.T) {
		c := newCache(t)
		ctx := context.Background()
		want := []byte("hello")
		if err := c.Set(ctx, "k", want, time.Minute); err != nil {
			t.Fatal(err)
		}
		got, err := c.Get(ctx, "k")
		if err != nil {
			t.Fatal(err)
		}
		if string(got) != string(want) {
			t.Fatalf("期望 %q,实际 %q", want, got)
		}
	})
 
	t.Run("ctx 取消后必须尽快返回,不能阻塞", func(t *testing.T) {
		c := newCache(t)
		ctx, cancel := context.WithCancel(context.Background())
		cancel()
 
		done := make(chan error, 1)
		go func() {
			_, err := c.Get(ctx, "k")
			done <- err
		}()
 
		select {
		case <-done:
			// 好
		case <-time.After(100 * time.Millisecond):
			t.Fatal("ctx 已取消,但 Get 超过 100ms 仍未返回")
		}
	})
 
	t.Run("Delete 后 Get 必须返回 ErrNotFound", func(t *testing.T) {
		c := newCache(t)
		ctx := context.Background()
		if err := c.Set(ctx, "k", []byte("v"), time.Minute); err != nil {
			t.Fatal(err)
		}
		if err := c.Delete(ctx, "k"); err != nil {
			t.Fatal(err)
		}
		if _, err := c.Get(ctx, "k"); !errors.Is(err, ErrNotFound) {
			t.Fatalf("Delete 之后应返回 ErrNotFound,实际: %v", err)
		}
	})
}

“看完了?“老陈问。

“看完了。“小林说,“这不就是一套测试吗?”

“不。“老陈说,“这不是测试。”

“那是什么?”

“这是我把我脑子里那套’什么是对的’的判断,外化成了一个可执行的东西。”


“你想想,在没有这个 Suite 之前,那些’规矩’在哪儿?”

“在文档里。“小林说。

“不对。”

“在我脑子里?”

“对。“老陈说,“在四个不同人的脑子里,而且各不相同。”

“写 Redis 那个人,他脑子里是’找不到就返回 ErrNotFound’。写本地内存那个人,他脑子里是’找不到就返回 nil,跟 Go 的 map 一样’。”

“他们谁错了?”

”……都没错。”

“对,都没错。他们各自的实现,在自己的上下文里,都是合理的。”

“问题是:当他们两个人的代码,被同一个调用方使用的时候,‘都对’变成了’系统出错’。”

“而这个 Suite 做了什么?”

小林看着那段代码。

“它把’什么是对的’这件事,从四个人的脑子里,搬到了一个所有人都能看见的地方。”

“对。”

“而且它不是文档。“老陈说,“文档你可以不看,Suite 你不看就过不了。”


“更重要的是第三点。“老陈说。

“哪一点?”

“它在你离开之后,还能工作。”

“你想想三年后。你离职了。有个新来的小伙子,要加一个第五种实现,比如基于 Memcached。”

“他会做什么?”

“他会打开那四个实现的源码,看它们怎么写的。”

“然后呢?”

“然后他会挑一个最像的,抄。”

“他抄的那个,可能恰好是行为最奇怪的那个。“老陈说,“因为他不知道哪个是对的。”

“而有了这个 Suite 呢?”

“他写完之后,跑一遍 Suite。”

“如果 Suite 红了,他会看到一条具体的信息:”

--- FAIL: TestMemcachedCache/TTL_过期后必须返回_ErrNotFound,_不能返回_nil,nil (0.20s)
    suite_test.go:71: 过期后应返回 ErrNotFound,实际: <nil>

“他不需要认识我,不需要知道这条规矩是三年前哪次事故留下的,不需要知道我是谁。”

“他只需要知道:这条规矩存在,而且它是红的。”


“小林,我把这件事说到底。“老陈说。

“一个人留在这个世界上的东西,有三种形态。”

“第一种:你做的东西。”

“你写的系统,你写的代码,你做的方案。这些东西的寿命是三到五年。三年后,你的系统会被重写,你的代码会被删掉,你的方案会被更好的方案取代。”

“这不是悲剧,这是正常的新陈代谢。”

“第二种:你教给别人的东西。”

“你带出来的人,你教过的判断,你说过的话。这些东西的寿命是十到二十年。”

“但它们有一个问题:它们会在传递中变形。”

“你教给 A,A 教给 B,B 教给 C。到了 C 那一代,你原来的东西还剩多少?”

“如果你只是’说过’那句话,三代之后,你说的话变成了’听说以前有个前辈说过’。”

“第三种:你写下来的、可执行的、不依赖你在场的东西。”

“一本册子。一套 Suite。一条规矩。一个 CI 上的红灯。”

“这种东西的寿命是——”

“多久?”

“取决于它有没有被推翻。”

“《木工匠心》那本册子,到今天四十年。它还在,不是因为大家尊重它,是因为它上面写的那些东西,到今天还是对的。”

“‘冬月留线宜宽半分’——这个规律,四千年前的工匠不知道,四百年前的工匠不知道,你师傅不知道,今天你也不知道。但那本册子里写着,所以你知道。”

“这就是传承真正的形态:不是记住一个人,是拥有一把他不在场也能用的尺子。”


“老陈,我问一句。“小林说。

“你说。”

“那把尺子,如果有一天过时了呢?”

老陈笑了。

“好问题。”

“你想想,那本册子里写的,‘冬月木性收敛,留线宜宽半分’。”

“如果有一天,木头全换成了合成材料,不再热胀冷缩了呢?”

“那这一条就废了。”

“那怎么办?”

“那就废了。“老陈说,“册子里的每一条,都可以被划掉。”

“但是——”

“但是什么?”

“但是划掉它的那个人,必须知道自己在划什么。”

“他必须先读懂’冬月留线宜宽半分’说的是什么,知道它背后的物理原因,然后才能负责任地划掉它。”

“而如果那本册子不存在呢?”

“那他就根本不知道有这条规矩。他会在夏天留宽,冬天留窄,然后看着柜子开裂,从头再来一遍。”

“小林,这就是传承的本质。”

“传承不是让后人遵守你的规矩。传承是让后人知道,你曾经面对过什么问题,以及你是怎么想的。”

“他可以同意,可以推翻,可以改进。”

“但他不能假装你没存在过。“

老陈的追问

  1. 如果你明天离职,你团队里有没有一个人,能接住你现在所有的工作?如果有,你是什么时候开始准备他的?如果现在开始,需要多久?

  2. 你带过的人里,有没有人改过你定下的东西,而且改得比你原来的好?如果从来没有,你的威信是不是太重了?

  3. 你的团队里,有没有一条”规矩”,是以一个可执行的、不依赖你在场的形式存在的?如果全部只活在你和几个人的口头传承里,你离开的那天,它们会一起消失。

  4. 你写过的文档里,有多少是”三个月后连你自己都不看”的?这些文档,和那些真正被反复查阅的文档,区别在哪?

  5. 你有没有一个”你做过、但你不为它署名”的东西?如果一件也没有,你现在的全部产出,都是挂在你名下的——而这意味着,它们也会随着你的名字一起消失。

生活思考题

题一:为什么很多老手艺传不下去,不是因为没人学,而是因为”教不会”?

大多数人会怎么答

因为年轻人不愿意吃苦。因为赚不到钱。因为工业化替代了手工。

这个答案为什么不够

“年轻人不愿意吃苦”这个说法,每年都有人讲,讲了至少一百年。但每一代都有年轻人跑去学那些又苦又不赚钱的东西——学做琴、学制刀、学修复古籍、学传统建筑。

所以”没人学”不是真的。真的困难在后面。

更深的一层

手艺的传承,有一个致命的断点:它依赖”师徒共处同一时空”。

一个木匠艺人,他的手艺全部在他的手上。他推刨子的角度,他敲榫头的力度,他看一块木头的眼光。

这些东西,是怎么传给徒弟的?

不是靠”说”,是靠看。

徒弟站在旁边看三年,看师父怎么下第一刀,看师父在什么时候停手,看师父皱眉的那一刻。三年之后,他上手,师父在旁边看,看他第一刀下在哪,然后说”重来”。

这个过程需要什么?需要两个人,在同一个地方,待足够长的时间。

而现代的工作方式,恰恰在摧毁这个前提。

一个徒弟,一年换三个项目,每个项目跟着不同的师父。他看不到任何一个完整的三年。

更深的一层:手艺的核心,不是”怎么做”,是”什么时候停”。

一个能教的部分,是”怎么做”。这个可以写成册子,可以拍成视频,可以做成 SOP。

一个教不了的部分,是”什么时候停”。

“木料推到什么程度算平?” “榫头敲到什么程度算到位?” “代码重构到什么程度算够了?” “一个设计,加多少抽象算过度?”

这些问题的答案,全部是”到一个程度就停”,而”这个程度”没有刻度。

这就是手艺的全部秘密:它不是一套动作,是一套”停止判断”。

落到 Go 工程师身上

第一,把你的”停止判断”说出来,哪怕说不准确。

你 review 代码的时候,你说”这个抽象太重了”。新人不理解什么叫”太重”。

你能不能给它一个粗糙的刻度?

比如:“一个包里,如果一个接口的唯一实现只有一个,而且存在超过一年,那它就是太重了。”

这个刻度不精确,但它给了新人一个起点。他可以先按这个刻度做,做两年之后,他会自己修正这个刻度。

而修正,就是传承。

第二,做一次”师徒共处”的尝试。

找一个具体的人,用三个月,每周固定两小时,不看代码,只做一件事:把你在做的判断,说出来。

你在做一个技术决策的时候,把他叫上,把你脑子里过的那几个选项说出来,包括那些你一秒钟就否掉的。

大部分人以为”教”是把好的东西教出去。不是。

教,是把你脑子里那些”被否定掉的选项”展示出来。

因为新人看得见你的结论,但看不见你否掉了什么。而你的水平,恰恰体现在你否掉了什么。

第三,写一个”反例集”。

不是写最佳实践,写”我们踩过的十个坑,以及当时为什么踩进去”。

每一条包含三行:

现象:____
当时我们以为的原因:____
真正的原因:____

这个东西的价值,比任何”最佳实践”都高。因为最佳实践是抽象的规则,而踩坑记录是具体的场景。

人对抽象规则的记忆力很差,但对故事的记忆力很好。

题二:为什么很多人给自己孩子取名,会避开自己名字里的字?

大多数人会怎么答

因为想让孩子有自己的身份,不想让他活在父母的影子下。因为怕重名。因为时代变了,那些字不好听了。

这个答案为什么不够

“想让孩子有自己的身份”这个说法,太温情了,而且它假设了一个前提:父母是有意识地、主动地选择了”不传承”。

但你去问大部分人,他们会说:没想过这个问题,就是觉得不合适。

这个”没想过”,才是值得追问的。

更深的一层

这个名字里的字,为什么”不合适”?

因为它承载了太多你对自己的评价。

你叫”建国”,你觉得这个名字土,因为你觉得它所代表的那个时代、那套价值,已经不属于你了。 你叫”伟""强""静""芳”,你觉得这些字太普通,因为它们代表的是一个匮乏年代里最朴素的愿望。

你不是在给孩子取名,你是在和自己的来历做一次切割。

而这件事之所以发生,是因为你对自己名字所代表的那个世界,有一种复杂的感受:既是来处,又想摆脱。

最深的一层

这件事揭示了一个特别残酷的规律:

一个人最想传给下一代的,往往不是他拥有得最多的东西,而是他觉得自己最缺的东西。

一个吃过苦的父亲,最想给孩子的是”不吃苦”。 一个没读过书的母亲,最想给孩子的是”读书”。 一个在社会上吃过亏的人,最想教孩子的是”世故”。

而这些”最想给的”,恰恰是接收者最不想要的。

因为接收者没有那个匮乏。你给的药,治的不是他的病。

这就是为什么大部分”传承”会失败——传承者传的是自己的遗憾,而不是自己的经验。

落到 Go 工程师身上

这一条,对任何一个想”培养人”的人都适用。

第一,检查一下你教给别人的东西,是”我当年是怎么做的”,还是”我希望我当年有人教我”。

这两者看起来很像,其实完全不同。

“我当年是怎么做的”,是一手经验,有场景、有代价、有上下文。 “我希望我当年有人教我”,是你遗憾的投射,它没有场景,也不考虑对方是不是面对同样的问题。

你去听听你带的人,你最常说的那句话是什么。

如果是”这个你要小心”,那你在传经验。 如果是”这个你一定要学会”,那你可能是在传遗憾。

第二,问一句:对方缺这个吗?

你花了很长时间,教会一个新人怎么写设计文档。他学会了,写得很规范。

但也许他真正缺的,是”敢在会上说出自己的判断”。

而你教他写文档,是因为你当年就是因为不会写文档吃了亏。

你在补自己的洞,而不是在填他的坑。

第三,最有效的一件事:不要教,去问。

你想让他学会一个东西,不要告诉他答案。问他:

“这个方案,你觉得最大的风险在哪?”

他答错了,你说:“嗯,这个想法很常见。那你有没有想过……”

他答对了,你说:“你看,你自己其实知道。”

这三句话,是这本书里出现频率最高的三句。因为它们不是话术,是一个关于传承的完整方法论:

传承不是把我的东西给你,是帮你长出你自己的东西。

7.9 回望:从 P4 到 P10,每一次跃迁都是一次放弃

那个被删掉的目录

十月中旬,小林请了两天年假。

第一天他哪儿也没去,在家翻自己的 git 提交记录。

起因是那天上午他收到一条消息:他们公司最早的那一套订单系统,下个月要下线了。那套系统里,有他二〇二一年写的代码。

他想看看那段代码长什么样。

git log --author=linym --since=2021-01-01 --until=2021-12-31 --oneline | wc -l

出来的数字是 1247。

他盯着这个数字看了一会儿。

二〇二一年,他提交了 1247 次。他记得那年他刚转岗去做履约,二十七岁,每天写代码写到十点多,觉得自己特别能打。

他随便点开几个提交。


他先看到的是 fulfill-importer。

那是二〇二一年三月写的,一个批量导入工具。他记得很清楚,那一个月他写得特别投入。用了一个 goroutine pool,用 channel 做任务分发,加了优雅退出,加了断点续传,八百多行。

他当时特别得意。他在周会上讲了这个设计,讲了二十分钟。Kevin 还夸了他一句。

git log --oneline -- fulfill-importer/

最后一条提交是 2021 年 4 月 17 日。

他去看现在的仓库。

ls fulfill-importer/

No such file or directory。

他查了一下删除记录。二〇二三年六月,一个他不认识的同事删的。提交信息写着:“清理历史遗留,已由 scheduler v2 替代”。

他看着那行字,坐了很久。


他继续翻。

warehouse-reconcile —— 在,现在是别人在维护。 capacity-cache —— 在,被改得面目全非。 coupon-validator —— 在,但是 deprecated 了。 order-splitter —— 不在了。

然后他想起一个东西。

二〇二一年十一月,他写过一个文档。当时是为了给自己记笔记,写在 docs/troubleshooting.md 里,内容是”履约线上问题排查的十四个常见 case”。

他写那个文档的时候,花了大概三个小时。他当时觉得这是个”顺手的事”。

他打开那个文件。

git log --oneline -- docs/troubleshooting.md

三十一次提交。

最后一次是上个月,一个他完全不认识的 ID。

他点进去看。文件现在有两百多行,内容已经完全不是他写的那些了,但文件名还在,第一行还留着他当年写的那句话:

出事的时候,先别急着改代码。先把时间线拉出来。


小林关掉电脑,在他家那个小阳台上坐了一个下午。

他老婆下班回来,看见他坐在那儿。

“你今天怎么了?”

“我翻了一下我刚工作那年的代码。”

“然后呢?”

“我最得意的那八百行,被删了。“小林说,“我随手写的那个文档,现在还有人在改。”

他老婆把包放下,去厨房洗了手。

“这不挺好的吗?”

“哪儿好?”

“说明你现在比那时候强了啊。“她说,“你那时候觉得好的东西,现在看不上了,那不是进步吗?”

小林没说话。

“你在心疼什么?“他老婆问。

小林想了很久。

“我不是心疼那个代码。“他说,“我是在想,我这四年,到底留下了什么。“


老陈的那张纸

第二天,小林去找老陈。

他把这事说了。老陈听完,从抽屉里拿出一张 A4 纸,放在桌上。

纸上是一张表,手写的。

P4   把交代的事做对
P5   让一块地归我负责
P6   让一块地真正属于我
P7   问题由我定义
P8   为结果兜底
P9   让别人因我而不同
P10  选择解决哪些问题

“这七个,你都记得吧。”

“记得。”

“你有没有想过,从 P4 到 P10,一共六次跃迁,每一次都给你加了一样东西?”

“加了什么?”

老陈笑了。“这是所有人都会这么问的问题。”

“那我应该问什么?”

“你应该问:每一次跃迁,你失去了什么?”


老陈把纸翻过来,背面是空白的。他拿笔在上面写。

“P4 到 P5:你失去了’有人给我兜底’。”

“P4 的时候,你的任务是被安排好的,需求是别人定的,方案是别人设计的,你只需要把它做对。做错了,你的组长会兜。”

“P5 开始,你有了自己一块地。这块地是你的,出了问题也是你的。”

“你得到的是 ownership,你失去的是’出事的时候有人替你扛’。”

“P5 到 P6:你失去了’只对自己的代码负责’。”

“P5 的时候,你的代码是自己写的,你自己跑通,自己提测。”

“P6 开始,你要对这块地的’运行状态’负责。半夜被叫起来。这个不是你的代码,但它是你的地。”

“你得到的是’真正属于我’,你失去的是’我写完就完事了’。”

“P6 到 P7:你失去了亲自动手。”

老陈停了一下。

“这一条,是卡死的人最多的一条。”

“P6 的时候,你是最能打的那个。线上出事,你十分钟定位,二十分钟修复。所有人都服你。”

“P7 开始,你要设计东西给别人做。你自己做,两小时。教给别人做,他做三天,做得还没你好。”

“你得到的是’问题由我定义’,你失去的是’我自己做’这个选项。”

“而最难受的是:你知道你自己做会更快,但你不能做。”

“P7 到 P8:你失去了’方案对就够了’。”

“P7 的时候,你的方案在评审会上过了,你很爽。有人提出异议,你把他说服了,你更爽。”

“P8 开始,你要为结果兜底。方案是对的,但业务没做起来,那是你的问题。方案是对的,但成本超了三倍,那是你的问题。方案是对的,但团队因为这个方案离职了两个人,那还是你的问题。”

“你得到的是’为结果负责’,你失去的是’我可以说我对了’。”

“P8 到 P9:你失去了功劳。”

“P8 的时候,你的名字挂在那个系统上。那个系统成了,是你的功劳。”

“P9 开始,你做的事,最好的结果就是’没人知道是谁干的’。”

“你定了一条规范,三年后所有人都在遵守,没人记得是你定的。” “你培养了一个人,他现在独当一面,履历上写的是他的名字。”

“你得到的是’让别人因我而不同’,你失去的是’这是我的作品’。”


老陈写完这五条,停了。

“那 P9 到 P10 呢?“小林问。

老陈把笔放下。

“这一条,我没法用一句话写完。”

“为什么?”

“因为前面五条,失去的都是’有价值的东西’。而 P9 到 P10,你失去的是你唯一剩下的那个东西。”

“什么东西?”

老陈看着他。

“正确。”


“小林,你想想,从 P4 到 P9,你一直在追求一件事。”

“什么事?”

“把事做对。”

“P4 把代码写对。” “P5 把自己那块地管对。” “P6 把线上问题查对。” “P7 把方案设计对。” “P8 把结果兜对。” “P9 把人培养对。”

“每一次升级,你都在变得’更对’。你判断的准确率在提高,你踩的坑在减少,你说的话越来越准。”

“这个正反馈,支撑了你十年。”

“然后到了 P10,它断了。”

“为什么?”

“因为 P10 做的每一个决定,都不存在’对’这个状态。”

“你押一个方向,三年后它成了,你不知道是因为你判断对了,还是因为运气好,还是因为竞争对手自己摔了。”

“你押一个方向,三年后它失败了,你也不知道是因为判断错了,还是因为执行出了问题,还是因为时机早了两年。”

“你永远拿不到那个反馈。”

“你花二十年,把自己训练成了一个’能把事做对’的人。”

“然后你升到这一级,你发现这个能力,用不上了。”


“那这一级靠什么?“小林问。

“靠一个东西。”

“什么?”

“在没有正确答案的情况下,还能继续做决定,并且晚上睡得着。”

老陈说,“这不是勇敢,也不是自信。这两个词都太轻了。”

“这是一种非常具体的、可以训练的能力,它的名字叫:与不确定性共处。”

“我给你拆开讲。”

“第一,你要能区分’我不知道’和’我还没到知道的时候’。”

“大部分人把这两件事混在一起,于是要么过早下结论,要么永远不下结论。”

“第二,你要能把一个判断,和你的自我价值分开。”

“你的判断错了,不等于你这个人错了。这句话每个人都会说,但真正做到的人极少。”

“我做恒信那件事之后,大概有两年的时间,我觉得自己是个废物。后来我慢慢想明白:我押错了,这只是一个判断失误,它不构成对我的判决。”

“第三,最难的一条:”

“你要能在所有人都看着你、等你给一个方向的时候,说出’我还不知道,我们再等三个月’。”

“这一条最难,因为它要你同时承受两件事:别人的失望,和你自己的不确定。”


小林看着那张纸。

“老陈,你说这六级,每一次都是一次放弃。”

“对。”

“那如果我不想放弃呢?”

“那你就停在原来的那一层。”

”……”

“小林,这不是批评。“老陈说,“我见过很多人,他们在 P7 停了一辈子,过得很开心。”

“他们在自己的领域里是最强的,所有人都尊重他们,他们不用处理那些让人头疼的人和事。”

“这是完全合理的人生选择。”

“但是你要知道代价。”

“什么代价?”

“你停下的那一层,会慢慢变成你的天花板,然后变成你的牢笼。”

“为什么?”

“因为世界在往前走。“老陈说,“你在 P7 停十年,十年后,那些当年比你年轻的人,都到了 P8、P9。”

“那时候你面对的是什么?”

“是那些你曾经看不上的人,在给你安排活。”

“而那时候你已经四十岁了,你的学习能力、你的体力、你的家庭负担,全都不允许你再从头来一遍。”

“真正残酷的不是’停在 P7’,是’你在 P7 停了十年,然后发现这个世界已经不要 P7 了’。“


Go 实例:同一件事,七个层级看到的不是同一个东西

“最后,我给你看一段代码。“老陈说。

“这是我用一个例子串起来的。这个例子特别小,小到家每个人都会写。”

“从一个对象存储,下载一个文件。”

“就这么一件事。你看看,从 P4 到 P10,七个人写出来的东西,会有什么不一样。”


P4:能跑就行。

package main
 
import (
	"fmt"
	"io"
	"net/http"
)
 
// P4 版本:功能正确,能跑
func Download(url string) ([]byte, error) {
	resp, err := http.Get(url)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	return io.ReadAll(resp.Body)
}
 
func main() {
	b, _ := Download("https://oss.example.com/a.txt")
	fmt.Println(len(b))
}

“这段代码有没有错?”

“有。“小林说,“没有超时,body 全读进内存,没有 context。”

“对。但是——“老陈说,“这段代码在 P4 那个阶段,是完全合格的。”

“因为它解决了它被要求解决的问题。而且它简单,简单到任何人一眼就看懂。”

“P4 的全部要求,就是’交代的事做对’。这段代码做到了。”


P6:真正属于我的那块地,要考虑运行状态。

package main
 
import (
	"context"
	"fmt"
	"io"
	"net/http"
	"time"
)
 
// P6 版本:这块地是我的,我要为它的运行状态负责
func Download(ctx context.Context, url string, w io.Writer) (int64, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return 0, fmt.Errorf("build request: %w", err)
	}
 
	// 超时不是"加一个 timeout 参数"这么简单。
	// 这里用 context 而不是 client.Timeout,是因为调用方需要能取消,
	// 而且这个超时要能和上游的超时预算串起来。
	ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
	defer cancel()
	req = req.WithContext(ctx)
 
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return 0, fmt.Errorf("do request: %w", err)
	}
	defer resp.Body.Close()
 
	if resp.StatusCode != http.StatusOK {
		return 0, fmt.Errorf("unexpected status: %d", resp.StatusCode)
	}
 
	// 关键变化:不读进内存,直接流式写入。
	// 一个 2GB 的文件,用 io.ReadAll 会让进程 RSS 涨 2GB。
	n, err := io.Copy(w, resp.Body)
	if err != nil {
		return n, fmt.Errorf("copy body: %w", err)
	}
	return n, nil
}
 
// createFile 只是一个 demo 用的辅助函数,让上面这段代码可以独立跑起来。
func createFile(name string) (*os.File, error) {
	return os.Create(name)
}
 
func main() {
	f, _ := createFile("out.txt")
	defer f.Close()
	n, err := Download(context.Background(), "https://oss.example.com/a.txt", f)
	fmt.Println(n, err)
}

“P6 加的是什么?“老陈问。

“超时、context、流式、错误处理。”

“对。这些都是运行状态相关的事。”

“而 P6 加这些,不是因为他’懂更多’,是因为他的职责变了。”

“P5 的时候,这个函数跑挂了,别人会来修。” “P6 的时候,这个函数半夜挂了,是他在被叫醒。”

“所以他必须考虑:它会不会卡死?会不会把内存吃光?会不会因为网络抖动一直挂着?”

“你的代码长什么样,取决于你要为它承担什么。”


P8:为结果兜底。

package main
 
import (
	"context"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"math/rand"
	"net/http"
	"os"
	"time"
)
 
// P8 版本:我要为结果兜底,所以我不能只关心"代码对不对"
 
var (
	ErrNotFound   = errors.New("oss: object not found")
	ErrUnavailable = errors.New("oss: service unavailable")
)
 
type Downloader struct {
	client   *http.Client
	logger   *slog.Logger
	maxRetry int
	// 熔断:连续失败到一定程度,直接快速失败,不再打下游。
	// 这一行的价值,在一次真实的下游故障里,是几百万请求量的差别。
	breaker *Breaker
}
 
type Options struct {
	MaxRetry int
	Timeout  time.Duration
}
 
func NewDownloader(o Options) *Downloader {
	// ...
	return &Downloader{}
}
 
func (d *Downloader) Download(ctx context.Context, url string, w io.Writer) (n int64, err error) {
	// 可观测:这个函数每一次被调用,都要留下三个数字:
	// 耗时、大小、结果。没有这三个数字,线上出事我只能猜。
	start := time.Now()
	defer func() {
		d.logger.Info("oss.download",
			"url", url,
			"bytes", n,
			"cost_ms", time.Since(start).Milliseconds(),
			"err", err,
		)
		recordHistogram("oss.download.cost", time.Since(start).Seconds())
	}()
 
	if !d.breaker.Allow() {
		return 0, ErrUnavailable
	}
 
	for attempt := 0; attempt <= d.maxRetry; attempt++ {
		n, err = d.once(ctx, url, w)
		if err == nil {
			d.breaker.Success()
			return n, nil
		}
 
		// 关键判断:什么样的错误值得重试?
		// 4xx 不重试(重试也没用),5xx 和网络错误才重试。
		// 这个判断,是 P8 和 P6 最大的区别:P6 关心"出错了怎么办",
		// P8 关心"这个错误的代价是多少,值不值得重试"。
		if !isRetryable(err) {
			d.breaker.Failure()
			return n, err
		}
 
		// 退避 + 抖动。抖动不是可选项:
		// 一千个客户端同时重试,会造成下游的第二次雪崩。
		backoff := time.Duration(1<<attempt) * 100 * time.Millisecond
		jitter := time.Duration(rand.Int63n(int64(backoff / 2)))
		select {
		case <-ctx.Done():
			return n, ctx.Err()
		case <-time.After(backoff + jitter):
		}
	}
	d.breaker.Failure()
	return n, err
}
 
func (d *Downloader) once(ctx context.Context, url string, w io.Writer) (int64, error) {
	// 单次尝试的实现
	return 0, nil
}
 
func isRetryable(err error) bool { return true }
func recordHistogram(name string, v float64) {}
 
type Breaker struct{}
func (b *Breaker) Allow() bool    { return true }
func (b *Breaker) Success()       {}
func (b *Breaker) Failure()       {}
 
// createFile 同上,只是为了让这段 demo 能跑起来。
func createFile(name string) (*os.File, error) { return os.Create(name) }
 
func main() {
	d := NewDownloader(Options{MaxRetry: 3, Timeout: 30 * time.Second})
	f, _ := createFile("out.txt")
	defer f.Close()
	n, err := d.Download(context.Background(), "https://oss.example.com/a.txt", f)
	fmt.Println(n, err)
}

“P8 加了什么?“老陈问。

“重试、退避、熔断、可观测。”

“对。但你注意,这些全都不是’功能’。”

“这些全部是’如果它失败了,代价有多大’这个问题的答案。”

“为什么要有熔断?因为下游挂了的时候,如果你不熔断,你的线程池会被打满,然后你自己的服务也挂了。一个组件的问题,变成整个系统的问题。”

“为什么要有抖动?因为一千个客户端同时重试,会把一个正在恢复的下游,直接打死第二次。”

“为什么要有可观测?因为出事的时候,你没有第二次机会去’加个日志看看’。”

“小林,P8 的每一行’多余’的代码,都对应着一次真实的、赔过钱的教训。”


P9:让别人因我而不同。

// P9 版本:这个包要给全公司几十个人用,我一次都不想在场
 
// Package oss 提供对象存储的下载能力。
//
// 使用方式(推荐):
//
//	d := oss.NewDownloader(oss.Options{MaxRetry: 3})
//	n, err := d.Download(ctx, "oss://bucket/a.txt", w)
//
// 稳定性承诺:
//   - v1.x 内保证向后兼容,任何不兼容变更会发 v2。
//   - 所有导出的错误都是哨兵错误,可用 errors.Is 判定。
//   - 所有实现必须满足 oss/ossconformance.Suite。
package oss
 
// ... 实现略 ...
 
// 关键:P9 会在同一个仓库里,额外提供三个东西:
//
//   1. oss/ossconformance —— 契约测试套件
//      任何新实现(包括测试用的 mock)跑一遍就知道合不合规
//
//   2. example_test.go —— 可运行的示例
//      go doc 能直接看到,用户不用翻 README
//
//   3. MIGRATION.md —— 从旧版本迁移的逐步指南
//      每一条都写清楚"删了什么、换成什么、为什么"

“P9 加的是什么?“老陈说。

“加的全是’别人能不能用’的东西。“小林说。

“对。“老陈说,“P8 关心的是’它能不能扛住’,P9 关心的是’别人能不能自己用起来,而且用不错’。”

“契约测试、示例、迁移指南——这三样东西,没有一样是给机器看的。它们是给人看的。”

“P9 的本质,是把你的能力,从’我会’变成’别人也会’。”


P10:选择解决哪些问题。

老陈停住了。

“P10 的代码呢?“小林问。

“P10 没有代码。”

”……”

“小林,P10 面对的,不是’这段代码怎么写’。”

“是这四个问题:”

“第一,这个能力,三年后还需要存在吗?”

“如果对象存储的 SDK 明年就内置了断点续传和熔断,你这个包的价值在哪里?”

“第二,这个能力,应该由我们来实现吗?”

“如果云厂商免费提供,我们花三个月做这个,是投资还是浪费?”

“第三,这个能力,是’我们的问题’还是’行业的问题’?”

“如果只是我们的问题,做一个内部包就够了。” “如果是行业的问题,也许应该开源,也许应该去参与标准制定——而那条路,是五年。”

“第四,也是最狠的一个:如果我们不做这件事,会怎样?”

“如果答案是’不会怎样’,那它根本不该出现在清单上。”


“老陈,“小林说,“这四个问题,没有一个有标准答案。”

“对。”

“那怎么判断?”

“不判断。“老陈说,“押。”

“押错了呢?”

“那就赔。”

“赔得起吗?”

“看你在哪一层。“老陈说,“P4 押错,赔一个下午。P6 押错,赔一个通宵。P8 押错,赔一个季度。P9 押错,赔一年。”

“P10 押错,赔三年,和别人的三年。”


小林看着那张纸。

“所以这本书,讲的全是’怎么少赔一点’?”

“不。“老陈说,“这本书讲的是——”

“你在哪一层,就要有那一层的赔法。”

“P4 用一下午去赔一个判断失误,这叫学习。” “P8 用一个季度去赔一个判断失误,这叫交学费。” “P10 用三年去赔一个判断失误,这叫……”

“叫什么?”

老陈沉默了一下。

“这叫你终于坐在了那个位置上。“

老陈的追问

这是全书最后一组追问。我建议你找个安静的地方,一个人待着的时候看。

  1. 你现在在哪一层?不要看你去年评了什么,看你最近一次重大失误,你赔的是什么。

  2. 你上一次”放弃”一件事,是什么时候?你放弃的是什么,你得到了什么?如果你说不出放弃过什么,那你这几年可能只是在同一层里变熟练了。

  3. 你身边有没有一个人,比你高两级?如果有,你有没有观察过他怕什么?如果观察过,你看到了什么?

  4. 你今年二十几岁或三十几岁。你打算在现在这一层待多久?这个”待多久”是你选的,还是你没得选?

  5. 如果你这辈子到不了 P10,你会怎么过这一生?这个问题没有正确答案,但一个从来没想过它的人,和一个想过并且接受了的人,他们的下半辈子会非常不一样。

  6. 最后一个:你有没有一件”你知道要五年才有结果,但你现在已经在做”的事?如果有,你很幸运。如果没有,你打算从哪天开始?

生活思考题

题一:为什么很多人到了中年,会突然开始做一件年轻时完全看不上的事?

大多数人会怎么答

因为中年危机。因为玩不动了。因为开始追求稳定,追求健康,追求内心平静。

这个答案为什么不够

“中年危机”这个词,是一个筐,什么都能往里装。它解释了一切,也就什么都没解释。

真正值得问的是:为什么是那些事?

三十五岁的人开始跑步、开始喝茶、开始养花、开始钓鱼、开始读历史、开始回老家修房子。

这些事有什么共同点?

更深的一层

这些事有三个共同特征:

第一,它们的反馈很慢。 跑步三个月才看得见变化,养花一年才开花,读历史十年才看出脉络。

第二,它们不依赖别人。 你跑步,不需要团队配合。你养花,花不会跟你开会。你钓鱼,鱼不给你写周报。

第三,它们的评价标准在你自己。 你跑五公里,你没有输给谁,你只是跑了五公里。

这三个特征,对应的是中年人最缺的三样东西:

反馈很慢 —— 对应的是”我做的事看不到意义”。 不依赖别人 —— 对应的是”我的时间全被别人支配”。 标准在自己 —— 对应的是”我的一切都被别人评价”。

最深的一层

所以”中年危机”这个词是错的。

它不是危机,是一次价值观的主动切换。

一个人在三十岁之前,活在”外部记分牌”上:职级、薪资、房子、别人的评价。这个记分牌是有效的,因为它给你方向,给你动力。

但到三十五岁左右,大部分人会发现一件事:这个记分牌上的分,你已经加不动了。

不是因为你不够努力,是因为加分的边际成本在急剧上升。从 P7 到 P8,你要付出的,是从 P5 到 P6 的三倍。而从 P9 到 P10,那已经不是努力的问题了。

这时候,人会做两件事中的一件:

一种是加倍努力,试图在这个已经加不动分的记分牌上继续加分。这条路通向抑郁、 burnout、和四十岁的体检报告。

另一种是换一个记分牌。

换一个慢的、自己的、不依赖别人的记分牌。

跑步、养花、读历史、修老家的房子。

这就是全部真相:那些中年人不是在逃避,他们是在自救。

落到 Go 工程师身上

第一,在三十岁之前,就开始建第二个记分牌。

不要等到三十五岁被撞一下才想起来。

第二个记分牌应该是什么样的?三个标准:慢的、自己的、不依赖别人评价的。

具体可以是什么?

  • 一个你维护了五年以上的开源项目(哪怕只有二十个 star)
  • 一项你能说出”我练了三年”的技能(跑步、乐器、做饭、木工)
  • 一段你知道”五年后才能看到结果”的投入(教一个人、种一片东西、写一本册子)

第二,定期检查你的”第一个记分牌”的边际成本。

每一年问自己一次:我今年往上升一级,需要付出的代价比去年多了多少?

如果答案是”多了很多,而且收益在变少”,那你就是在一条边际递减的曲线上狂奔。

这时候你有两个选择:接受它,或者换一条曲线。

这两个选择都合理。但你必须是有意识地选,而不是稀里糊涂地跑。

第三,最实用的一条:现在就去学一样”没有用”的东西。

什么叫没有用?就是它不会出现在你的简历上,不会让你的薪资涨一分钱,不会被任何人评价。

学做菜。学修自行车。学认鸟。学一样乐器,但不考级,不表演。

这件事的功能不是”陶冶情操”。

它的功能是:在你的生活里,保留一块不属于任何人的地方。

题二:为什么很多人退休以后,才去做自己真正想做的事?

大多数人会怎么答

因为之前没时间。因为要赚钱养家。因为年轻时要奋斗。

这个答案为什么不够

“之前没时间”这个解释,撑不住一个简单的事实:很多退休的人,他们做的那件事,需要的投入并不大。

一个退休的人去学画画,一周六小时。他在职的时候,一周六小时是拿得出来的——他只是没有拿。

所以真正的问题不是”没时间”,是——

为什么在职的时候,他不敢拿这六小时?

更深的一层

因为在职的时候,你的时间是有”审计”的。

不是别人在审计你,是你在审计你自己。

你在心里有一个账本,你在不停地算:这两个小时我用来学画画,和用来加班,哪个”更值”?

而只要你开始算这个账,答案永远是加班更值。因为加班的收益是可预期的、可衡量的、能换钱的。而画画的收益是未知的、不可衡量的、可能永远为零的。

一个永远用”可衡量”的标准去衡量一切的人,永远不会去做那些”不可衡量”的事。

而那些”不可衡量”的事,恰恰是那些定义了你是谁的事。

最深的一层

退休这个事件,真正的意义不是”有时间了”。

是那个账本被撕掉了。

退休的第一天,你早上七点醒来,你第一次发现:这两个小时,没有任何”应该”。

没有应该去开的会,没有应该回的消息,没有应该交的活。

而这个”没有应该”的状态,对大部分人是恐怖的。这就是为什么很多人退休后第一年会生病——不是身体,是那个账本没了,人找不到坐标。

能顺利度过退休第一年的人,往往是那些在退休之前,就已经有过”没有应该”的时刻的人。

落到 Go 工程师身上

第一,每周给自己两个小时”没有应该”的时间。

不是”休息”,休息是有目的的(为了恢复精力)。

是”没有应该”——这两个小时,你做任何事都不需要向自己交代”这有什么用”。

大部分人做这件事的时候会非常焦虑。前几次,他会忍不住去想”我是不是应该利用这两个小时学点什么”。

这个焦虑本身就是答案:它说明你已经很久没有”没有目的”地活过了。

第二,区分”想做的事”和”想做成的事”。

“想做成的事”是有目标的:我要跑一次全马,我要写一个开源项目,我要出一本书。

“想做的事”是没有目标的:我想画画,我想看鸟,我想在阳台上种点东西。

这两者都需要。但大部分人只有前者。

只有”想做成的事”的人,他的人生是一条接一条的 KPI。他永远在赶路,永远在下一个目标。而退休的那天,他所有的目标同时失效。

第三,现在就去做一件你打算”退休后再做”的事,每周两小时。

不要等。因为等不到的概率,比你以为的高得多。

我给你一个数字:中国男性的平均预期寿命是七十四岁。而一个程序员能保持高强度工作的年限,乐观估计到五十岁。

从五十岁到七十四岁,二十四年。

听起来很长。但你要扣掉前五年(适应期、带孙子、身体开始出问题的那几年),扣掉最后五年(大概率在和慢性病相处)。

中间那十四年,是你真正”退休后”的时间。而那十四年里,你六十岁以后的部分,体力和精力会明显下降。

你真正能”做自己想做的事”的时间,可能只有八到十年。

而你打算做的事,需要多久?

学一门乐器到能自娱自乐:三年。 写好一本册子:两年。 把一个院子养成你想要的样子:五年。

你把这三个加起来,已经超过十年了。

所以,“退休后再做”这句话,对大部分人来说,真正的意思是”永远不做”。

本章小结:核心心法

心法

选择解决哪些问题。

这句话的重点,不在”解决问题”,在”选择”。

P4 到 P9,你的全部训练都指向一件事:把一件事做对。你花了十年,把自己从一个会写 for 循环的人,变成一个能带着三十个人做成一个系统的人。这十年里,你每一次升级,都在变得”更对”——判断更准,坑更少,说的话越来越有人听。

然后到了 P10,这个正反馈断了。

因为这一级面对的所有问题,都不存在”对”这个状态。

你押一个方向,三年后成了,你不知道是因为你判断对了,还是因为运气好,还是因为竞争对手自己摔了。你押一个方向,三年后失败了,你也不知道是判断错了、执行出了问题、还是时机早了两年。

你花二十年训练出来的那个核心能力——“把事做对”——在这一级,用不上了。

这就是这一级最沉重的地方,也是大部分人一辈子到不了这里的原因。不是因为不够聪明,不是因为技术不行,是因为受不了没有反馈。

人在没有反馈的状态下会做三件事,一件比一件糟糕:频繁换方向(战略摇摆)、下沉去做有即时反馈的小事(高层救火)、给自己编一个已经成功的叙事(自我欺骗)。而这三件事,任何一件都会腐蚀整个组织。

所以 P10 这一级的核心,不是判断力,不是远见,不是勇气。

是在没有正确答案、没有反馈、所有人都在看着你的情况下,还能继续做决定,并且晚上睡得着。


这一级的人,脑子里装的是这六件事。

第一,时间尺度。 P8 想这个季度,P9 想这一年,P10 想三到五年。而这个尺度的残酷之处在于:一个技术方向从投入到看到结果需要五年,而一个 P10 在一个岗位上的平均任期是四十七个月。你种下的树,你大概率看不到它成荫。

第二,判断力大于能力。 到这一层,执行力已经不是瓶颈了。你的组织里有的是能执行的人,而且他们比你执行得好。你的价值,只剩下那一个:判断什么值得做。

第三,与不确定性共处。 所有重大决策,都是在信息不全时做出的。等你看清了,机会也没了。这句话每个人都会说,但真正做到的人极少——因为真正做到,意味着你要能区分”我不知道”和”我还没到知道的时候”,意味着你要能把一个判断和你的自我价值分开,意味着你要能在所有人都等你给方向的时候,说出”我还不知道,我们再等三个月”。

第四,代价意识。 一次押注,等于几十个人的几年,加几千万预算。错了不是”改回来”那么简单。钱可以再融,但一家公司的信用额度是有限的,你错一次大的,容错空间就被压缩一次。错两次,你就只能做保守的事了。

第五,接班人与传承。 你真正的作品,不是你做的系统(它会三五年后被重写),不是你教给别人的东西(它会在传递中变形),而是你写下来的、可执行的、不依赖你在场的东西——一本册子,一套契约测试,一条 CI 上的红灯。传承不是让后人遵守你的规矩,是让后人知道你曾经面对过什么问题,以及你是怎么想的。他可以同意,可以推翻,但他不能假装你没存在过。

第六,失败的权利。 P10 必须有押错的空间。如果一个组织不允许高层犯错,就不会有人做长期决策。 因为长期决策天生就是高风险的,一个”不允许失败”的组织,会自动筛选出只做短期安全事情的人。


最后,回到这本书的起点。

P4 到 P10,六次跃迁,表面上你每一次都得到了一样东西:一块地、一个团队、一个平台、一个组织、一个方向。

但你真正经历的,是六次放弃。

P4 到 P5,你放弃了”有人给我兜底”。 P5 到 P6,你放弃了”只对自己的代码负责”。 P6 到 P7,你放弃了亲自动手。 P7 到 P8,你放弃了”方案对就够了”。 P8 到 P9,你放弃了功劳。 P9 到 P10,你放弃了正确。

每放弃一样,你就往前走一级。

而到了最后一级,你已经把”对”这件事也放弃了。你剩下的,只有一个东西:

在所有人都不知道答案的时候,你敢不敢说”我押这个”,然后在接下来的三年里,每天醒来都面对这个决定,并且不逃走。

这就是 P10。

它不是一种能力,是一种处境。


自检清单

这一级的条目,大部分你今天是做不到的。这套清单的用途不是让你现在就打勾,是让你知道这条路还有多远,以及你此刻站在哪一段。

□ 我能够说清楚:我押过的注里,哪一个已经验证成功了,哪一个是失败的。我说得出具体的时间、金额、人数,而不是”我当年做过一个项目”。

□ 我手上至少有一件”我知道要五年才有结果,而我现在已经在做”的事。如果没有,我目前所有的投入都还在五年以内的尺度里。

□ 我能够列出我做的决策中,哪些是不可逆的,以及我在每一个上面花了多少时间。如果这个比例是颠倒的(可逆的花了 95% 的时间),我知道问题在哪。

□ 我能够说出我所在行业里,未来三到五年会发生的一件事,并且说出我这个判断的证伪条件。说不出证伪条件的判断,不是判断,是愿望。

□ 我在过去两年里,至少有一次主动叫停过一件自己发起的事。如果从来没有,我是一个”永不认错”的人,而这在更高层级是致命的。

□ 我能够说出:如果我现在负责的这件事失败了,谁会替我付代价。我能说出他们的名字,而不只是”团队”。

□ 我做过的抽象里,至少有一个是”存在超过一年、但只有一个实现”的。我知道它每年的维护成本是多少,并且我做过一次删掉它的尝试(成功或失败都算)。

□ 我维护的对外接口/协议/数据格式里,有明确的兼容性承诺文档,写清楚了”什么情况下会破坏兼容”和”提前多久通知”。

□ 我手上有一件东西,是”我做过、但我不为它署名”的。如果一件也没有,我目前的全部产出都挂在我的名下,而这意味着它们会随我一起消失。

□ 我带过的人里,至少有一个改过我定下的东西,而且改得比我原来的好,让我沉默过一次。

□ 我至少留下过一个”不依赖我在场也能工作”的东西:一份被反复查阅的文档、一套契约测试、一条 CI 上的红线、一份反例集。

□ 我能够在一个所有人都看着我、等我给方向的时候,说出”我还不知道,我们再等三个月”,并且承受得住随之而来的失望。

□ 我知道我这一级最怕什么。我能用一句话说出来。如果说不出来,说明我还没有真正面对过它。


全书后记:老陈最后的话

十一月初,北京下了第一场雪。

不大,落地就化了。老陈那个院子的租约到期了,他在收拾东西。

小林去帮忙。东西不多,两个箱子,一个装书,一个装衣服。剩下的家具,他说送房东。

“你搬哪儿去?“小林问。

“深圳。“老陈说,“有个项目,做工业软件的,他们老板请我去做两年顾问。”

“两年后呢?”

“两年后再说。”


东西装完了,两个人在屋里坐着。桌上有一瓶酒,两个杯子。

小林从包里掏出一个本子。

牛皮纸封面,线装的,上面用毛笔写着四个字:《木工匠心》。

“这个……”老陈愣了一下。

“你上次给我看的照片,我在孔夫子旧书网上找到了。一九八三年版的,一百二。“小林说,“王师傅那本是手抄的,这是印的。”

老陈接过去,翻了两页。

“你还真买了。”

“我翻了一下。“小林说,“‘冬月木性收敛,留线宜宽半分’。这句我看不懂,但我知道它是个好东西。”

老陈没说话,继续翻。


“老陈,我有个问题。“小林说。

“你说。”

“这本书,从我 P4 那年认识你,到现在,四年了。”

“嗯。”

“如果让你说一句话,作为这本书的最后一句,你会说什么?”

老陈把本子合上,放在桌上。

他倒了两杯酒。

“我先跟你说个数字。“他说。

“我今年五十三。我押过的注,一共有六个。”

“成了两个,平了两个,错了两个。”

小林没说话。

“那两个平的,就是那种——做了,没赚也没赔,三年之后安安静静地关掉了,没人记得。”

“那两个错的,加起来是七千多万,四十几个人最好的三四年。”

“其中一个就是恒信那次。”


“那你后悔吗?”

老陈端起酒杯,没喝。

“我不后悔。”

“为什么?”

“因为那两个错的,是我自己选的。“老陈说,“不是别人让我做的,不是被逼的,是我站在那儿,看着所有信息,我自己说’我押这个’。”

“这个’我自己选的’,值很多钱。”

他把酒杯放下。

“但是小林,我要跟你说一件比后悔难得多的事。”

“什么事?”

“如果让我回到四十岁,站在同样的信息面前,我大概率还是会押同样的注。”

”……”

“这才是最操蛋的地方。”

老陈看着桌上那本册子。

“你不是不知道会错。你是知道可能会错,还是得押。因为不押,就什么都不会发生。”

“那怎么知道该押哪个?”

“你不知道。”

“这就是这一级的全部?”

“这就是这一级的全部。”


小林沉默了很久。

“老陈,我还有一件事想不明白。”

“你说。”

“你爸做了一辈子家具。“小林说,“你说过,他自己的家里,用的是最差的那一套。”

“对。”

“因为他做的好的,全卖出去了。剩下的边角料,歪的、裂的、尺寸不对的,他留着自己用。”

“对。”

“我一直在想这件事。“小林说,“你觉得,他是成功了,还是失败了?”

老陈笑了。

“我小时候觉得他是失败。”

“现在呢?”

“现在我不这么想了。”

老陈站起来,走到窗边。外面是化了一半的雪。

“我现在想的是:他这辈子做的所有好东西,都在别人家里。”

“他每天早上七点开工,晚上七点收工,做了四十七年。他做的每一件好东西,做完就送走了。”

“而他自己的家里,永远是那些歪的、裂的、剩下料做的。”

“他每天坐在自己做的那张歪桌子旁边吃饭,吃了四十年。”


老陈转过身。

“小林,这就是我们这行的样子。”

“你做的所有好东西,最后都不在你手里。”

“你写的系统,五年后被人重写。” “你定的规范,三年后没人记得是你定的。” “你带出来的人,两年后去了别的公司。” “你押对的方向,三年后大家觉得本来就该这样。”

“你唯一能留下的,是那些你自己看不见的东西。”

“就像你那个 troubleshooting 文档。你二〇二一年随手写的,你现在连里面写的什么都忘了。但是上个月,有个你不认识的人,改了它的第三十一次。”

“他不知道你是谁。他不需要知道。”

“这就是这件事最好的结果。”


小林端起酒杯,喝了一口。

“老陈,我最后问一个问题。”

“你问。”

“你说 P10 最怕的是什么?”

老陈走回桌边,坐下。

他想了大概半分钟。

“我告诉你 P10 最怕什么。”

“不是怕错。错是可以承受的,赔了就是赔了。”

“也不是怕失败。失败是有结果的,有结果就有下一步。”

“最怕的是:你做的所有决定,都要等十年才能知道对错,而十年之后,做出这个决定的你已经不在那儿了。”

“你拿不到那个反馈。”

“你这一辈子,都拿不到。”


老陈把杯子里的酒喝完。

“我二〇一六年押那个注的时候,四十三岁。”

“我现在五十三。按理说,十年了,该有结果了。”

“结果是什么?”

“结果是:我做对的那部分,我当年就知道了——容器赢了,K8s 成了标准,这些在三四年内就清晰了。”

“而我做错的那部分——‘我们该不该靠它卖钱’——我到今天也不能说我知道答案。”

“因为如果当年我们多撑两年呢?” “因为如果云厂商晚三年成熟呢?” “因为如果我们一开始就只做行业能力呢?”

“这些’如果’,永远不会有答案。”

“所以我会带着这个问题进棺材。”

“我做了一个决定,赔了一个亿,搭进去二十三个人最好的三年,而到死我也不知道那个决定本身是对是错。”

“小林,这就是这一级的真相。”

“不是孤独,不是压力,不是责任。”

“是你永远拿不到那个’我做对了’的确认。”


屋里安静了很久。

老陈从那个装衣服的箱子里,拿出一个旧本子。黑色的皮面,边角磨白了。

他翻到最后一页,推给小林。

那一页上,只有一行字,是用圆珠笔写的,字迹很重,划破了纸:

2019.4.12
"陈总,那我这三年算什么?"

小林看着那行字。

“你还留着。”

“我一直留着。“老陈说。

“为什么?”

“因为我还欠一个答案。”

老陈把本子收回去。

“我每年四月十二号,会想起这件事。不是想那个项目,是想起李响站在最后一排举手的那个样子。”

“他二十六岁。他问的是一个二十六岁的人该问的问题。”

“而我不知道答案。”


“小林。“老陈说。

“嗯。”

“你要是有一天,坐到了能押注的那个位置上。”

“我记住一件事就行。什么?”

老陈看着他。

“你押的不是钱,不是技术,是别人的人生。”

“你在会上说’再给我一年’的时候,你能不能想到,你的这一年,是某个人的二十六岁到二十七岁。”

“你在会上说’这个方向我们再看看’的时候,你能不能想到,有人在等你的这句话,等三个月。”

“你签的那个字,落到下面,是几十个人,接下来三年里的一千零九十五天。”

“这些天数,是还不回来的。”


小林走出那个院子的时候,天已经黑了。

北京的初冬,街上的人不多。他走到路口,等红灯。

手机震了一下。是老陈发来的。

小林,我忘了跟你说一件事。

小林回:什么?

你明年述职的时候,别讲你今年做成什么。

那讲什么?

那边过了一会儿才回。

讲你今年,决定不做什么。

红灯变了。小林过了马路。

他一边走,一边在备忘录里新建了一页。

验证卡

判断:我们那个对账问题,根因不是补偿逻辑,
      是我们和仓储之间没有统一的单据状态标准。
      如果我们不定这个标准,三年后这个问题还会再来一次。

验证时间:2029 年 11 月

证伪条件:如果三年后,行业内出现了一个通用的、
          大家都接受的仓储单据状态标准,
          说明我押错了 —— 这件事不该由我们来做。

他写完之后,把手机放进口袋。

风吹过来,有点冷。

他把外套的拉链拉到顶,往地铁站走。

前面的路口在修路,围了一圈蓝色的挡板,中间留了一条一人宽的小道。

小林走进去的时候,看见挡板上有四个字,用红漆喷的:

前方施工