第六章 P9 影响篇:让别人因我的存在而不同
心法:让别人因我的存在而不同 转变:从「我负责」到「我让组织能负责」 陷阱:仍沉浸在具体事务里不放手
开篇:小赵提离职那天,小林发现自己画不出图
十一月十二号,周四,下午三点二十。
“富春”会议室的白板还剩最后一小块空地。小林站在那儿,手里的白板笔帽还没拔,正准备把最下面那一层画完。
白板上是履约平台的三层架构。最上面一层是业务接入层,八个方块;中间一层是能力层,五个方块;最下面一层是他刚写完的”调度”两个字,后面跟着一串字:仓匹配、运力匹配、时效承诺、异常重调度。
这是他花了两个礼拜画的图。上一次他这么认真地画一张图,是两年前那次没过的评审。
手机在桌上震了一下。
他没看。他把笔帽拔了,转过身,说:“最下面这一层,我要重点讲一下。”
手机又震了一下。
桌上四个人,阿 May 抬头看了他一眼。
小林把手机翻过来,屏幕朝下。“没事,继续。”
他说到”异常重调度”这个方块的第三句话,桌上的手机自己亮了。是 HRBP 小郑发来的第二条。
林哥,赵鹏的离职申请刚才提到 OA 上了,流程会走你这儿。你要不要先跟他聊聊?
小林看着那行字,看了大概三秒。
“小林?“阿 May 说。
“哦。“他把手机扣下去,“我说到哪了?”
“异常重调度。“大刘说。
“对。“小林拿起笔,“异常重调度这块……”
他讲到一半,停了。笔尖在白板上点了一下,留了个蓝点。
“不好意思,我出去接个电话。”
楼梯间在十七楼东头,消防门一推开有股风。小林走到窗边,拨过去。
“林哥。“小赵的声音还是那样,有点快,有点闷。
“我刚看到流程。“小林说,“怎么回事?”
“嗯。”
“是哪个环节不舒服?钱?还是职级?你今年没评上 P8,这事我知道,我可以再——”
“林哥。“小赵打断了他,“不是钱。”
楼梯间里风声很大。小林把手机换到另一只耳朵。
“那是什么?”
“我算了一下。“小赵说,“我到你组里三年零四个月。这三年我写了大概六万行代码,其中我自己的设计……我想了想,不到五千行。”
“什么意思?”
“调度器那个状态机,是你在白板上画完给我的。仓匹配的权重算法,是你在会上定的目标函数,我实现。上个月那个异常重调度的方案,是你在周会上说的那三条,我只是把它写成了 Go。”
小林没说话。
“我不是说这样不好。“小赵说,“林哥你比我强,你定的方案基本都是对的,跟着你干这三年我学了很多。但是上个月有一天,我自己在那儿改一个东西,改到一半我停住了。”
“改什么?”
“那个仓库产能的缓存,五分钟过期那个。我发现我改不动,因为我不确定改了之后,你在会上定的那个目标函数还成不成立。”
“那个你可以问我啊。”
“我知道。“小赵说,“我那天晚上九点给你发消息,你十一点回的我。林哥,你回得很对,我一听就懂了。”
他停了一下。
“问题就在这儿。”
挂了电话,小林在楼梯间站了大概五分钟。
后来他跟老陈复述这一段的时候,说的是:“我当时脑子里过的不是’他为什么要走’,是’他走了会怎样’。”
他在备忘录里开始列。
赵鹏手上:
1. 履约调度器(fulfill-scheduler)—— 他写的,但状态机是我画的
2. 仓匹配算法(warehouse-match)—— 他写的,权重是我定的
3. 仓储对接协议 —— 他从零谈的,跟仓储那边三个系统
4. 履约对账 —— 他维护,只有他知道那几个补偿逻辑
5. 值班表上他占 1/3 的排班
6. 今年两个事故,他是第一个到场的
列到第 4 条的时候,他停了。
履约对账那几个补偿逻辑,他知道是”历史形成的”,是三年来十几个 case 攒出来的。他大概能说出其中三四个的来由。剩下的,他不知道。
列到第 6 条的时候,他把手机放下了。
那天晚上他到家快十点。他老婆在客厅给女儿冲奶粉,一岁两个月的小人儿在围栏里站着,扶着栏杆看他。
“吃饭了吗?”
“吃了。”
“小赵要走了。“他说。
“哪个小赵?”
“就那个,去年年会坐我们那桌的,戴眼镜那个。”
他老婆想了一下。“哦,那个话不多的。他为什么要走?”
“不知道。“小林坐在沙发上,“我今天跟他聊了,他说不是钱。”
“那是什么?”
小林看着天花板,看了很久。
“他说他写的代码里,自己的想法不到十分之一。”
他老婆把奶瓶递给他,去卫生间拿毛巾。出来的时候,小林还保持着同一个姿势。
“那你打算怎么办?”
“我想先把他留住。”
“留住了呢?”
“留住再说。“小林说,“他手上那块,现在没人接得住。对账那边要是出问题,是要赔钱的。”
他老婆没接话。她把毛巾搭在沙发背上,蹲下去擦地上的一小块奶渍。
“我这个月绩效下来了。“她说,“涨了六百。”
“挺好的。”
“嗯。”
“房贷下个月开始按新的利率算。“小林说,“我算了一下,一个月少三百二。”
“哦。”
“我明天……”他停了一下,“我明天想给赵鹏打个电话,跟他聊聊职级的事。P8 的名额我再去要一次。”
他老婆擦完了,站起来。
“你先别打电话。”
“为什么?”
“你今天下午到现在,一共说了几次’我’?”
小林愣住了。
“我没别的意思。“他老婆把毛巾拿走了,“我就是觉得,你现在这个状态,明天不管跟他说什么,他都不会留下来的。”
周六上午十点,老陈家的阳台上。
老陈在侍弄他那几盆三角梅,剪刀咔哒咔哒响。小林坐在塑料凳上,把他这三天的事从头讲了一遍:OA 流程、楼梯间的电话、那份清单、他老婆那句”你说了几次我”。
老陈一直在剪枝。等他讲完,老陈把剪刀放下,用手指把一小截掉在盆土上的枝条捡起来,扔进脚边的桶里。
“你先说说,你是怎么想的。”
“我觉得我得把他留住。“小林说,“不是因为感情,是因为那块业务现在离了他要出问题。”
“嗯。”
“我打算这么做:第一,P8 的名额我去跟王总要,今年没给他明年给;第二,对账那块我让小周接过来,我带着;第三,仓储对接那块,我让赵鹏写文档,写详细一点。”
“第三点,他愿意写吗?”
”……不好说。”
老陈在围裙上擦了擦手。
“小林,我问你个问题。”
“您说。”
“你请三个月假,你那块业务会怎么样?“
6.1 你请三个月假,你那块业务会怎样?
小林当时第一个反应是:这老头在开玩笑。
“三个月?不可能,我哪有三个月假。”
“假设。”
“假设也没有。“小林说,“去年我最长的假是七天,去的时候每天还要看群。”
“我问的不是你能不能请,是请了会怎样。“老陈把喷壶拿起来,“你想三十秒,再回答。“
那三十秒
后来小林跟我说,那三十秒是他这两年最难熬的三十秒。不是因为问题难,是因为他发现自己脑子里闪过的第一个画面,是那张对账表的补偿逻辑。
“会出问题。“他说。
“哪一块?”
“对账。“小林说,“那块有一堆 case 是历史攒下来的,十几个分支,赵鹏脑子里有一张表。我大概知道一半。”
“还有呢?”
“调度器。改状态机的时候,有几个地方只有他改过。不是不能改,是改起来要重新看一遍,一次大概两天。”
“还有呢?”
“值班。他占三分之一,他走了排班要重新排,剩下的人一个月多两次。”
老陈一边喷水一边听着,等他说完。
“所以答案是’会出问题’。”
“对。”
老陈把喷壶放下。
“那你现在还在 P8。“
小林的反驳
“我不同意。“小林说。
“哦?”
“您这个问题本身就不公平。“他把凳子往前挪了挪,“任何一个八个人的团队,把负责人抽走三个月,都会受影响。这不叫’我还在 P8’,这叫’团队还没长大’。”
“嗯,这个想法很常见。”
“而且,“小林说,“我这一年做的事,跟去年不一样了。去年我自己写代码、自己定方案。今年我评审了他们所有的 PR,我带着做了三次大的架构决策,我把小周从 P5 带到了能独立负责一个模块。这些难道不算产出?”
“算。”
“那您的意思是?”
老陈没直接回答,他问了个别的。
“你上周一共评审了几个 PR?”
”……二十三个。”
“这二十三个里面,有几个是因为你看了,才没出事的?”
小林张了张嘴。
“你不用给我一个精确的数。“老陈说,“你给个大概。”
“我不知道。“小林说,“这个没法统计。评审的时候我没法知道,如果我不看,会不会出事。”
“对。”
“但是……”小林停了一下,“就算只有一两个,那也算吧?”
“算。“老陈说,“我换个问法。这二十三个 PR 里,有几个是那个人自己发现的问题,写完自己改了,然后才提给你的?”
这次小林沉默得久一点。
”……我也不知道。”
“你看。“老陈说,“这两个数,你一个都说不上来。“
组织好像不需要我了?
小林站起来,走到阳台边上,往下看了一眼。老陈家在六楼,下面是小区的那条环形路,有个老大爷在遛狗。
“那我就当您那个问题的答案是’照常转’好了。“他说。
“好,你说。”
“如果我说,我请三个月假,业务照常转,一点问题没有。“小林转过身,“那按您的逻辑,我是不是就到 P9 了?”
“按我的逻辑,是。”
“那您不觉得这很荒谬吗?“小林声音大了一点,“业务照常转,那说明组织不需要我啊。那我这个人的价值在哪里?我拿这份工资的理由是什么?我每天加班到十点,年底拿了个 A,然后我证明了自己的方式是——我可以消失三个月,没人发现?”
老陈看着他,没说话。
“我今年三十。“小林说,“我房贷还有二十二年,我女儿一岁两个月。我每天晚上回去她都睡了。我这么拼,是为了让自己变得可有可无吗?”
阳台上安静了几秒钟。三角梅的叶子被风吹得翻了一下。
老陈说:“你先坐。”
小林坐下了。
“你这个反应,比前一个答案诚实。“老陈说,“前一个答案是’会出问题’,那是你在用工作证明自己的位置。这一个答案是’那我算什么’,这才是真的。”
“那您说,我算什么?”
“我给你讲个事。“
楼下那家开了二十年的面馆
老陈家小区门口有一排底商,最东头是家面馆,招牌是红底白字”老周面馆”,招牌边上那块”二十年老店”的小牌子是十年前挂上去的,褪色褪得厉害。
老周今年六十出头。这家店开了二十年,前十四年是他和他老婆两个人干,他煮面,他老婆端面收钱。六年前他儿子从外地回来,接了灶台。
“现在老周不煮面了。“老陈说,“他每天早上七点来,把汤底吊上,然后就搬个凳子坐门口。熟客进来他抬头点头,‘老三样?’,‘今天多加辣?‘。钱是他老婆收,面是他儿子煮。”
“我去吃过多少次了。“老陈说,“我特意观察过,他儿子煮的面,跟他煮的,我没吃出区别。”
“去年冬天,老周腰间盘突出了。医生说要躺一个月。”
“那一个月,我天天从那儿过。面馆一天没关。早上七点灯亮,晚上九点灯灭,门口那个塑料凳空着。”
小林听着。
“一个月之后老周回来了。“老陈说,“还是那个凳子,还是那个姿势。他瘦了大概十斤。”
“我问他:‘周老板,歇得怎么样?’”
“他说了一句话。“老陈停了一下,“他说:‘陈老师,我躺在床上的第二十天,我儿子给我打电话,问我要不要进一批新的辣椒面。我躺在床上一动不能动,我还得管这个事。我当时就想,我这二十年到底干了些什么。’”
小林没说话。
“小林,你说那一个月,老周是高兴还是难受?”
”……都有吧。“小林说,“店没关,是他教得好。但他说’我这二十年到底干了些什么’,这话是真的难受。”
“对。”
“所以这两个答案你都不喜欢,是吧。“老陈说。
“是。”
“因为这两个答案,指的都是同一件事。”
老陈把喷壶挂回墙上的钉子。
“你还在用’我做了什么’来衡量自己。”
“说得通的是’会出问题’,你松了口气,因为组织离不开你。说不通的是’照常转’,你慌了,因为组织不需要你。这两个反应,都是把你自己当成那个产出。”
“那我不这么想,我该怎么想?”
“我三十九岁那年,做过一年的技术负责人,管四十几个人。“老陈说,“那年我特别忙,忙到什么程度——我一年写了两万行代码。”
“然后呢?”
“然后那年我们组的离职率是百分之三十一。”
老陈笑了笑,是那种不怎么好看的笑。
“我当时觉得是那拨人不行。“他说,“过了好几年我才反应过来。那一年我写了两万行代码,意味着那一年我把四十几个人里最能干的那些人,能自己决定的事,全给决定了。“
从这一节开始,换一个坐标系
“我还是不太舒服。“小林说。
“我知道。”
“我总觉得自己像个骗子。“他说,“我现在的周报上写的是什么?‘推进履约平台三期’、‘完成三次架构评审’、‘带教小周’。这些话我写出来,我自己都不信。”
“那你以前写什么?”
“以前写’完成仓匹配算法重构,P99 从 800ms 降到 120ms’。“小林说,“那时候我写完周报,我知道我这一周干了什么。现在我不知道。”
老陈点点头。
“我给你一个东西,你先拿着,不一定现在能接受。”
“您说。”
“衡量 P9 的,不是他本人多能打。是他不在场的时候,这块业务转不转得下去,转得好不好。”
“转得下去我懂。转得好怎么算?”
“这就要看你这三年的本事,有多少长在了别人身上。”
老陈伸出三根手指。
“你的产出,是这三样东西:你培养的人,你建立的标准,你塑造的判断力。”
“标准我理解,人我也理解。“小林说,“判断力怎么算?那个东西……长在别人身上,我看不见。”
“对,你看不见。“老陈说,“所以你恐慌。”
小林又沉默了。
“我给您交个底。“他说,“我昨天晚上想了一晚上。我想明白一件事,但是我没想明白怎么解。”
“什么事?”
“我这十年攒的东西,到今年,一样都用不上了。“小林说,“我会写 Go,我会排查问题,我能三天看完一个陌生系统,我能在出事故的时候十分钟定位。这些东西,今年我用得越来越少。评审的时候我说’这里要加个超时’,然后别人去加。我做架构决策,然后别人去实现。”
“我这些技能,全变成了’由别人使用’的东西。”
“那’我’是什么?”
老陈看着他。阳台外面那条环形路上,那个遛狗的老大爷转了一圈,又走回去了。
“你现在回答不了这个问题,说明你还没到那一步。“老陈说。
这句话是老陈的口头禅。小林从前听过很多次,每次听了都不服气。
这次他没有不服气。他点了点头。
Go 实例:一个 retry 包,和”让别人写也不错”
那次谈话之后大概两周,组里出了一次不大不小的事故。
事情是这样的:仓储那边的接口有点不稳定,偶尔超时。有个 P6 的同事写了个重试。
// 第一版:看上去很自然的写法
package retry
import (
"time"
)
// Retry 重试执行 fn,最多 n 次
func Retry(n int, fn func() error) error {
var err error
for i := 0; i < n; i++ {
if err = fn(); err == nil {
return nil
}
}
return err
}调用的地方长这样:
err := retry.Retry(5, func() error {
return warehouseClient.DeductStock(ctx, sku, qty)
})
if err != nil {
return err
}评审的时候小林看了一眼,说”加个 sleep 吧”,对方加了个 time.Sleep(100 * time.Millisecond)。上线了。
出事是在大促的第二天下午两点十分。仓储的接口抖了大概四十秒,那一瞬间有六千多个请求涌进去,每个请求 retry 五次,中间 sleep 一百毫秒。
四个下游服务被自己人打挂了。更麻烦的是,DeductStock 这个接口不是幂等的。重试了五次,同一笔库存被扣了三次。
事故复盘会上,小林问那个同事:“你当时为什么传 5?”
“我看别的地方也是 5。”
“为什么 sleep 一百毫秒?”
“您说加个 sleep,我就加了。”
“你知道 DeductStock 不幂等吗?”
”……不知道。”
散会以后小林坐在那儿想了很久。
他一开始的结论是”这个人不仔细”。后来他把那个 Retry 函数重新看了一遍,改了想法。
问题不在他不小心。问题在于,这个 API 让”危险的用法”和”安全的用法”一样容易。
- 传 5 和传 3,写起来一样容易,代价差十倍。
- 加 sleep 和不加 sleep,写起来一样容易。
- 幂等的接口和不幂等的接口,塞进同一个
fn,完全看不出来。
这不是一个能靠”下次注意”解决的问题。只要这个 API 长这样,下一个 P6 还是会写错,只是不一定这次这么倒霉。
小林带着小赵(那会儿小赵还没走)重写了一版:
// 第二版:让"安全的写法"成为默认,"危险的写法"必须显式声明
package retry
import (
"context"
"errors"
"math/rand"
"time"
)
// Policy 描述重试策略。零值不可用,必须通过 NewPolicy 构造。
type Policy struct {
maxAttempts int
base time.Duration
max time.Duration
// 只有返回 true 的错误才会被重试。
// 这是必填项:想不清楚就默认不重试,这是安全的方向。
shouldRetry func(err error) bool
}
type Option func(*Policy)
// NewPolicy 构造一个重试策略。
// shouldRetry 是必填参数,强迫调用方在写这行代码的时候
// 停下来想一想:这个操作失败了,能重来吗?
func NewPolicy(shouldRetry func(err error) bool, opts ...Option) Policy {
p := Policy{
maxAttempts: 3, // 保守默认值
base: 200 * time.Millisecond,
max: 2 * time.Second,
shouldRetry: shouldRetry,
}
for _, opt := range opts {
opt(&p)
}
return p
}
// WithMaxAttempts 显式提高重试次数。上限 5,超过会被钳制。
func WithMaxAttempts(n int) Option {
return func(p *Policy) {
if n < 1 {
n = 1
}
if n > 5 {
n = 5
}
p.maxAttempts = n
}
}
func WithBaseBackoff(d time.Duration) Option {
return func(p *Policy) { p.base = d }
}
// Do 执行 fn。ctx 取消时立即返回,不再重试。
func (p Policy) Do(ctx context.Context, fn func(context.Context) error) error {
var err error
attempts := 0
for {
if err = fn(ctx); err == nil {
return nil
}
if !p.shouldRetry(err) {
return err
}
attempts++
if attempts >= p.maxAttempts {
return err
}
d := p.backoff(attempts)
select {
case <-ctx.Done():
// 关键:ctx 取消后要返回原始错误和 ctx 错误,
// 让调用方能判断到底发生了什么。
return errors.Join(err, ctx.Err())
case <-time.After(d):
}
}
}
// backoff 指数退避 + 全抖动。
// 不加抖动的重试,会让所有调用方在同一时刻重试,形成尖峰。
func (p Policy) backoff(attempt int) time.Duration {
d := p.base << attempt
if d > p.max || d <= 0 { // 位移溢出保护
d = p.max
}
// 全抖动:在 [0, d] 之间随机,把重试打散
return time.Duration(rand.Int63n(int64(d)))
}调用方现在必须这么写:
// 调用方被迫回答一个问题:这个错误能重试吗?
policy := retry.NewPolicy(func(err error) bool {
// 只有明确的可重试错误才重试。库存扣减这种非幂等操作,
// 宁可失败也不要重试。
return errors.Is(err, warehouse.ErrTimeout) ||
errors.Is(err, warehouse.ErrTemporaryUnavailable)
})
err := policy.Do(ctx, func(ctx context.Context) error {
return warehouseClient.DeductStock(ctx, sku, qty)
})这版上线之后,小林在组里做了一件事:他写了一行 CI 检查,禁止仓库里再出现裸的 retry.Retry(。
后来他跟我说过一个细节。改完之后第三周,他看新人的 PR,看到一个 P5 的同事写的:
policy := retry.NewPolicy(func(err error) bool {
return errors.Is(err, io.ErrUnexpectedEOF)
}, retry.WithMaxAttempts(2))那段代码写得不算漂亮,注释也啰嗦。但是小林说他看完,心里那块石头落下去了一半。
因为那个人在写 shouldRetry 的时候,被迫思考了一次”这个操作能重来吗”。这个问题,第一版的 API 里根本不存在。
“这就是区别。“老陈后来说,“你以前是那个在评审里说’这里要加超时’的人。你现在是那个设计 API 的人,让下一个 P5 在键盘前自己问自己:这里能重试吗?”
“你的品味如果不能变成别人键盘上的默认值,那它就只是你的私人爱好。“
老陈的追问
(这一节的问题,老陈没有给答案。他只是把它们写在了一张便签上,贴在小林的笔记本里。)
-
你手上有多少”你知道,但没有第二个人知道”的东西?如果明天你出车祸住院两个月,这几样东西里,有几样会变成事故?
-
你带的团队里,有谁现在能坐到你的位置上,把这个季度扛下来?如果一个人都没有,你过去两年到底是在带人,还是在用人?
-
你上一次因为”别人做得比你好”而高兴,是什么时候?
-
如果你现在的职级明天被降一级,工资不变,工作内容不变,你会比现在更轻松,还是更难受?
-
你请三个月假,回来以后,你希望团队跟你说”你可算回来了”,还是”这三个月我们上线了 X、Y、Z”?你敢诚实地回答吗?
生活思考题
题一:为什么你出差一周回来,第一天比上班还累?
大多数人会这么答: 因为事情堆着了嘛。你不在的时候,别人不敢拍板,等你回来一起问你。第一天当然要把这一周的账一起还。
这个答案为什么不够: 它把原因全都推给了”你不在”这个事实。但同样是出差一周,有的人回来第一天是空的,有的人回来第一天要开六个会。这两个人之间的差别,不是运气,是他们在走之前做了什么。
更深一点问:那些”等你回来问你”的事,是真的需要你的判断,还是因为他们知道你反正会拍板,所以懒得自己决定?
更深的一层是什么: “等你回来”这件事,是有激励的。对提问的人来说,问你的成本是零,自己拍板的成本是可能出错。任何一个理性人,在一个”随时可以问老板”的环境里,都会选择问。
所以问题不在他们,问题在于你从来没让”自己决定”这件事变得比”问你”更容易。更深地说:你可能还从”被问”里头获得了一点东西。被需要的感觉是会上瘾的。你嘴上说烦,但你每次回答的时候,心里有一小块是舒服的。
这一层最狠的地方在于:你不是没能力放手,你是在用”他们还需要我”来确认自己的价值。
落到 Go 工程师身上: 下次出差前,别只是发个”我下周不在,急事打电话”。做三件事:
第一,把你手上”只有你能拍板”的事情列一个清单,每一条后面写清楚,这个决定需要的信息是什么,判断标准是什么。然后当着他们的面,把其中三条的拍板权交出去,明确说”这三类,你们定,不用问我,回来也不用汇报”。
第二,把你常用的那些判断写进代码里,别只写在脑子里。超时该设多少、重试要不要加、错误要不要 wrap,这些你评审时说了一百遍的话,写成一条 lint 规则,或者一个默认封装。你不在的那一周,这条规则会替你说。
第三,回来第一天,不要问”这一周有什么事”。问”这一周你们自己定了哪几件事,定了什么”。你问什么,他们下次就会准备什么。
题二:为什么很多人退休以后,一年之内身体就垮了?
大多数人会这么答: 因为一闲下来就没有规律了,也不动脑子了,人闲则生病。
这个答案为什么不够: 这话听起来有道理,但它没法解释另一件事:同样是退休,有人退了以后去钓鱼、去旅游、去带孙子,越活越精神。差别不在”闲不闲”,在别的地方。
更深的一层是什么: 很多人在退休前三十年,一直在用一个东西支撑自己:角色。
“我是某某单位的某某”。这个角色每天给他发任务,给他评价标准,给他社会位置。他不需要回答”我是谁”这个问题,因为每天早上闹钟响的时候就有人替他回答了。
退休那天,这个外部答案消失了。他必须第一次自己回答”我是谁”。
有些人答得上来。他们有爱好,有持续了二十年的朋友,有一些跟工作无关的身份(我是那个在小区里会修水管的人,我是桥牌桌上最讨厌的对手)。
有些人答不上来。于是他们只剩下身体。一个只剩下身体的人,会把所有注意力放到身体上,一点点不舒服都会被放大。
技术人的版本更狠一点:你三十年的身份是”我是一个能写代码的人”。如果这个身份在某个年纪失效了,你还剩什么?
落到 Go 工程师身上: 这个问题在你三十岁的时候问,比在五十五岁的时候问,成本低一百倍。
具体做一件事:找一个跟你的职业完全无关、但你能靠它被人记住的身份。不是”兴趣爱好”,是你在某个小圈子里被需要的方式。比如你在你们业主群里是那个能看懂物业合同条款的人,你在孩子的家长群里是那个会组织活动的人,你在某个开源项目的 issue 区是那个会认真回答新手问题的人。
这件事听起来跟 Go 没关系,其实关系很大。因为所有那些让你在技术上能影响别人的动作(写文档、做分享、回答别人的问题、把标准固化成工具),本质上都是在训练同一个能力:不通过”我亲自做”来产出价值。
顺带说一句:你现在就开始练,等你真到五十五岁,你不会垮。
6.2 从「我负责」到「我让组织能负责」:产出形式的根本转变
第二个周六,小林带了一页纸去。
他把上一周的工作全列了出来,按时间顺序。评审了三十一个 PR,参加了九场会,做了两个架构决策,跟仓储那边谈了一次接口,写了两份文档,帮小周调了一个并发的 bug,面试了四个人。
“我列完了。“他把纸推过去,“但我越列越虚。”
“虚在哪儿?”
“这上面每一件事,我都能被替代。“小林说,“评审 PR,Kevin 也能评审。做架构决策,王总拍也行。调 bug,小周自己调一调也能调出来。面试,HR 筛完让谁来都一样。”
“嗯。”
“我去年不是这样。“他说,“去年我写完那个仓匹配算法,我知道那一行行代码是我写的,跑了半年 P99 从 800 掉到 120。那个东西,谁也拿不走。”
老陈把纸拿起来看了看,又放下。
“你这上面写的,都是过程。”
“过程?”
“你这上面每一条,都是’你花的时间’,没有一条是’你留下的东西’。“老陈说,“评审三十一个 PR,那这三十一个 PR 合进去之后,这个团队写 PR 的水平变了吗?”
”……”
“你帮小周调了一个并发的 bug。“老陈往下指,“那小周下次遇到并发问题,能自己调吗?”
“这个我知道。“小林说,“他现在能调一部分。上次那个 channel 泄漏,我提示了两句他自己就找到了。”
“那这一条就不算白干。“老陈说,“你看,你自己其实知道。“
小林第一次试着定义自己的产出
“那我重新写。“小林把纸拿回来,翻到背面。
他写了大概十分钟。中间划掉了三次。
我这一年留下的东西(?):
1. 小周能独立负责履约对账了(以前是我负责)
2. 组里的 PR 模板改过一版,现在提 PR 必须写"影响范围"和"回滚方式"
3. 履约的 SLO 上次定的,大刘那边用了
4. 去年 618 之后那套压测流程,别的组也在用
写完他自己盯着看。
“第四条我不确定。“他说,“压测那套,隔壁组是 Kevin 推的,我给了脚本。”
“那你说,那套流程现在还在用吗?”
“在。他们上个月还加了两个场景。”
“那就算。“老陈说,“你给了一条线,他们在上面接了东西。这就是。”
小林把纸放下。
“我写出来这四条,还是虚。“他说,“因为这四条,我在年底述职的时候,一条都说不出口。”
“为什么说不出口?”
“因为它们听起来都……不像是我的功劳。“小林说,“‘小周能独立了’,那是小周自己努力。‘PR 模板改了一版’,这个谁都能改。‘SLO 用了’,那是大刘用得好。我念这四条,评委会觉得我这一年啥也没干。”
“所以你述职的时候念什么?”
“念……”小林想了想,“念我推进了什么项目,解决了什么问题,扛了几次事故。”
“那不还是 P8 的述职吗?”
小林没说话。
老陈说:“你这个困惑是对的,而且不是你一个人的问题。全公司所有的 P8 升 P9,卡就卡在这一句上:我不知道我该拿什么去述职。”
“那您说该拿什么去?”
“我给你三个东西,你自己验证。”
老陈竖起三根手指。
“第一个,是你不在场的时候,这块业务运转的数据。”
“什么数据?”
“事故数、需求平均交付周期、值班群里一天响几次、需求排队的长度。“老陈说,“这些数据你今年和去年比,是变好了还是变差了?”
“变好了。“小林说,“去年值班群一天大概响十几次,现在三四次。需求交付周期,去年平均九天,现在五天半。”
“这两个数,是你亲自干活干出来的,还是别的什么带来的?”
”……是流程带来的。“小林说,“我们做了发布卡点,做了灰度,需求拆得细了。”
“那你述职的时候,念这个。”
“第二个呢?”
“第二个,是你带的人,能力基线的变化。”
老陈说:“这个必须能点名。不能说’团队整体提升了’,那句话等于没说。你要能说出:去年这时候小周只能做我给他的活儿,今年他能自己接需求、自己拆、自己定排期。去年赵鹏只能实现我画的图,今年他能自己画。”
“今年。“小林重复了一遍,“赵鹏要走。”
“我知道,先听我说完。”
“第三个是什么?”
“第三个,是你推动过的机制,在你不再推之后,还在不在。”
小林抬起头。
“这一条最狠。“老陈说,“前两条别人还能帮你作证。第三条只有时间能作证。”
“什么叫机制?”
“你组里现在有多少’规矩’?”
小林想了想。“提 PR 要写影响范围。改数据库要过 DBA。大促前要压测。上线要走灰度。故障要写复盘。”
“这些里面,有几条是你不在的时候,还会被执行的?”
”……大部分会吧。”
“为什么?”
“因为……”小林停住了。
他突然意识到自己答不上来。提 PR 要写影响范围,是因为他每次看 PR 都会问一句。大促前压测,是因为他去年在会上说了一次。故障写复盘,是因为他上次带头写了一份。
“我明白了。“他说,“这些不是机制。”
“这些是什么?”
“这些是……我在。“
一阵风和一个机制的区别
“你别太沮丧。“老陈说,“百分之八十的团队负责人,一辈子都在干这件事。他们把’我每天都在盯’当成了机制。”
“那机制到底是什么?”
“我给你个判据,特别土,但是管用。”
老陈说:“机制的特征是,推动它的那个人走了,它还在。”
“一阵风的东西叫运动,不叫机制。”
“运动长什么样?“老陈伸出手比划,“你开个会,说’从今天起我们要重视代码质量’。头两周大家都在改,第三周开始有人不改了,第五周你自己也忘了。年底一看,Sonar 的告警数跟去年一样。”
“机制长什么样?”
“机制是:这条要求变成了一行 CI 检查,红了就合不进去。你走不走,它都红。”
“那要是有人绕过呢?”
“那就说明你这个机制还不够硬。“老陈说,“机制不是一锤子买卖,它是你一遍遍加固出来的。第一版有人绕过,你就加第二道。加到第三道,绕过的成本比遵守的成本高了,它就活了。”
小林在本子上记了一句。
“我插一句。“他说,“您说的这个我认,但是有个问题。”
“你说。”
“如果机制都变成了硬约束,那人是不是就变成机器了?“小林说,“我们组写代码,如果每一条都靠 lint 卡,那还要脑子干什么?”
“这个问题问得好。“老陈说,“你再想想,lint 卡住的那些东西,是需要脑子的吗?”
”……不需要。”
“对。“老陈说,“超时时间设多少、错误要不要 wrap、ctx 要不要传,这些从来就不需要脑子,需要的是记性。你把记性的活儿交给机器,脑子才能腾出来干脑子该干的事。”
“那脑子该干什么?”
“判断什么时候该违反这条规则。”
老陈说:“所有真正的机制,都留了一个口子。”
“什么口子?”
“让人能在想清楚了以后,明确地、留痕地违反它。”
他举了个例子。他们组的 lint 规则禁止在一个包里直接 new 另一个服务的 HTTP client,必须走统一的工厂。但是有个同事有个正当理由要绕过去。他没删规则,他在代码里加了一行注释:
//nolint:svcclient // 原因:该服务不在服务发现里,需要直连内网 IP。
// 责任人:赵鹏,2026-03-11,预计 2026-06 前迁移到注册中心。
client := &http.Client{Timeout: 2 * time.Second}“看到区别了吗?“老陈说,“//nolint 不是作弊,它是一次声明。他声明了:我知道规则,我选择违反,我给出理由,我留下名字和日期。”
“那这条规则的作用是什么?”
“让’违反’这件事变得昂贵。“老陈说,“他得想清楚理由,得写下来,得署自己的名。而’遵守’是免费的。”
“这就够了?”
“这就够了。“老陈说,“你不可能让所有人永远做对。你只能让’做对’比’做错’省力,让’做错’必须签字画押。“
老陈的私教
老陈四十八岁那年体检,查出来中重度脂肪肝,外加腰间盘膨出。医生说完,补了一句:“你再这么坐下去,六十岁之前要坐轮椅。”
他回来就找了个健身房,请了个私教。
“那个教练姓吴,二十六岁,一米七八,一百四十斤。“老陈说,“我第一次去,看他在卧推,推一百公斤,推了八个。”
“你自己练吗?“老陈问小吴。
“练啊,下班练。”
后来的三个月,老陈每周去三次,一次没见小吴在上课时间自己练过。老陈做深蹲,他在后面扶着;老陈做硬拉,他蹲在侧面数数:“三、四,腰别塌,对,再来两个。”
有次老陈忍不住了:“你上课的时候自己练练呗,你也要保持状态。”
小吴笑了:“陈哥,我上课的时候自己练,那这节课是谁的?”
三个月之后老陈去复查,脂肪肝从中重度变成轻度,体重掉了十一斤。他挺高兴,续了课。
后来他跟前台聊天,随口问了一句:“你们这儿哪个教练续课率最高?”
“小吴啊。”
“为什么?”
“他学员出效果的比较多吧。“前台说,“而且他请假的时候,学员也不用停。”
这句话老陈记住了。
“他请假的时候学员不用停?“老陈问,“那怎么办?”
“他有本子啊。“前台说,“每个学员这个礼拜练什么、几组几个、哪里有伤要注意,他全写在一个本子上。他一请假,本子放前台,随便哪个教练拿起来就能带。”
老陈后来专门找小吴要了那个本子看。
“我看了三页。“老陈说,“那三页上写的东西,比他那块胸肌值钱一百倍。”
“写了什么?”
“不是’卧推三组十二个’那种。“老陈说,“他写的是:‘陈哥,肩关节活动度不够,第三周之前不要做杠铃卧推,先用哑铃。他右膝旧伤,深蹲蹲到九十度就停。他容易放弃,第三组和第四组之间一定要跟他说一句话,什么都行,别让他停在那儿发呆。’”
小林听着。
“小林,你说小吴的产出是什么?”
”……那个本子?”
“一半。“老陈说,“本子只是载体。他的产出是:他不在的时候,那三十个学员的课还能不能照常上。”
“那他每次上课的时候扶着、数数、喊’再来两个’,算什么?”
“算过程。”
老陈停了一下。
“我后来想明白一件事。小吴那个本子上的内容,全是他’在场’的时候攒下来的。他不盯着看,他就写不出’陈哥容易放弃’这一条。”
“所以’在场’不是没用?”
“在场有用,但在场不是产出。“老陈说,“在场是采集。产出是你采集完之后,留下来的那个东西。”
“您这么说我就舒服一点了。“小林说。
“先别舒服。“老陈说,“你再想想:小吴要是只写了个本子,自己从来不出现,学员会续课吗?”
”……不会。”
“对。“老陈说,“所以这两头都得有。你得到场,但你到场的目的,是为了有一天可以不到场。“
Go 实例:把一条规范,从文档变成 CI 上的红灯
小林回去之后干的第一件事,不是写规范,是翻旧账。
他在组里的故障库里搜”超时”和”goroutine”,翻出过去两年的记录,找到了三次事故,根因都指向同一件事:有人在有 ctx 的函数里用了 context.Background()。
最典型的一次是去年四月。一个同事写了个异步刷新缓存的逻辑:
// 第一版:看起来完全无害
func (s *OrderService) GetOrder(ctx context.Context, id string) (*Order, error) {
order, err := s.repo.Load(ctx, id)
if err != nil {
return nil, err
}
// 异步刷新一下缓存里的统计信息,失败不影响主流程
go func() {
// 用 Background,因为这个 goroutine 的生命周期跟请求无关
bgCtx := context.Background()
if err := s.stats.Refresh(bgCtx, id); err != nil {
log.Printf("refresh stats failed: %v", err)
}
}()
return order, nil
}这段代码的注释写得还挺有道理。写它的人当时想的是:主请求返回了,ctx 就被取消了,我要是还用这个 ctx,异步任务就被取消了,所以我要用 Background。
这个推理本身没错。错的是他没往下想第二步。
出事是在一个促销日的下午,stats.Refresh 会去查一次统计库。那天统计库慢了,Refresh 卡在三秒以上。而 Background 是不带超时的。
三秒钟里,进来了大概两万个请求。两万个 goroutine 全部挂在那里,一个都不退出。
P99 从 120 毫秒涨到 4.2 秒,然后整台机器的内存从 40% 涨到 91%,GC 频率翻了六倍,最后服务被打爆。
复盘会上小林问:“为什么不用带超时的 ctx?”
“我当时觉得异步任务不需要超时。”
“为什么不需要?”
”……我以为它很快。”
这个”我以为它很快”,是这两年小林听过最多次的一句话。
那次会议之后,小林在组的 wiki 上写了一条规范:
【规范】禁止在接收 ctx 参数的函数体内使用 context.Background() / context.TODO()。
如果确实需要一个脱离请求生命周期的 context,必须使用 context.WithoutCancel(ctx)
配合 context.WithTimeout,并写明理由。
然后他做了一件事:在接下来两个月的所有 PR 里,人工检查这一条。
两个月里他抓到了四次。
四次,听起来还行。直到他做了一道算术题:那两个月组里一共提了大概四百个 PR,他一个一个看,抓到四个。
问题是:他漏了多少?
他不敢想。而且更现实的是,两个月之后他忙起来了,这件事就没人管了。规范还在 wiki 上,一个字没改,但它是死的。
这就是老陈说的”一阵风”。
第三个月,小林带着组里一个 P6 写了条 linter。
// Package ctxcheck 检查:在有 ctx context.Context 参数的函数体内,
// 禁止直接调用 context.Background() / context.TODO()。
//
// 这些调用会切断调用链的取消信号和 trace 传播,
// 是 goroutine 泄漏和全链路超时失效的常见来源。
package ctxcheck
import (
"go/ast"
"go/types"
"strings"
"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/passes/inspect"
"golang.org/x/tools/go/ast/inspector"
)
var Analyzer = &analysis.Analyzer{
Name: "ctxcheck",
Doc: "禁止在有 ctx 参数的函数体内使用 context.Background()/context.TODO()",
Requires: []*analysis.Analyzer{inspect.Analyzer},
Run: run,
}
func run(pass *analysis.Pass) (interface{}, error) {
insp := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
// 只看函数声明。闭包里的情况留给下一版,先把主要场景覆盖住。
nodeFilter := []ast.Node{(*ast.FuncDecl)(nil)}
insp.Preorder(nodeFilter, func(n ast.Node) {
fn := n.(*ast.FuncDecl)
if fn.Body == nil {
return
}
// 只检查"自己带着 ctx"的函数:你都有 ctx 了,为什么还要新建一个?
if !hasContextParam(pass, fn.Type) {
return
}
ast.Inspect(fn.Body, func(node ast.Node) bool {
call, ok := node.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
ident, ok := sel.X.(*ast.Ident)
if !ok {
return true
}
if ident.Name != "context" {
return true
}
if sel.Sel.Name != "Background" && sel.Sel.Name != "TODO" {
return true
}
// 确认这个 context 真的是标准库的 context 包,
// 避免误伤同名的本地包。
if !isStdContext(pass, ident) {
return true
}
pass.Reportf(call.Pos(),
"函数 %s 已有 ctx 参数,不应再调用 context.%s();"+
"这会切断取消信号与 trace 传播。"+
"如需脱离请求生命周期,请用 context.WithoutCancel(ctx) 并显式设置超时。",
fn.Name.Name, sel.Sel.Name)
return true
})
})
return nil, nil
}
func hasContextParam(pass *analysis.Pass, ft *ast.FuncType) bool {
if ft.Params == nil {
return false
}
for _, field := range ft.Params.List {
typ := pass.TypesInfo.TypeOf(field.Type)
if typ == nil {
continue
}
// 用字符串判断不严谨,这里用 types.Type 的底层类型比较。
if named, ok := typ.(*types.Named); ok {
obj := named.Obj()
if obj != nil && obj.Pkg() != nil &&
obj.Pkg().Path() == "context" && obj.Name() == "Context" {
return true
}
}
}
return false
}
func isStdContext(pass *analysis.Pass, ident *ast.Ident) bool {
obj, ok := pass.TypesInfo.Uses[ident]
if !ok {
return false
}
pkg, ok := obj.(*types.PkgName)
if !ok {
return false
}
return pkg.Imported().Path() == "context"
}配套的测试,用的是 analysistest(这是关键,没有测试的 linter 三个月就没人敢改了):
package ctxcheck_test
import (
"testing"
"golang.org/x/tools/go/analysis/analysistest"
"example.com/lint/ctxcheck"
)
func TestAnalyzer(t *testing.T) {
// testdata/src/a/a.go 里用 // want "..." 标注期望的报错位置
analysistest.Run(t, analysistest.TestData(), ctxcheck.Analyzer, "a")
}// testdata/src/a/a.go
package a
import (
"context"
"time"
)
func Bad(ctx context.Context) { // 有 ctx 参数
go func() {
_ = context.Background() // want "已有 ctx 参数,不应再调用 context.Background"
_ = time.Second
}()
}
func AlsoBad(ctx context.Context) error {
c := context.TODO() // want "已有 ctx 参数,不应再调用 context.TODO"
<-c.Done()
return nil
}
// 这个不该报错:函数本身没有 ctx,它必须自己造一个根 context。
func Good() {
_ = context.Background()
}
// 这才是推荐写法:先切断取消关系,再显式给超时。
func Recommended(ctx context.Context) {
detached := context.WithoutCancel(ctx)
bg, cancel := context.WithTimeout(detached, 3*time.Second)
defer cancel()
_ = bg
}接进 CI 之后,第一次全量跑,扫出来三十七处。
三十七处。
小林盯着那个数字看了很久。他两个月人工只抓到四个。
那三十七处里,有三处是真的会在大促的时候出事的。剩下三十四处可能一辈子都不会出问题,但它们都是隐患,而且它们全都是”看起来完全无害”的。
后来他把这条规则并进了 golangci-lint,并且在 .golangci.yml 里写死了:
linters:
enable:
- ctxcheck
issues:
# 允许显式豁免,但必须写原因和责任人
exclude-rules:
- linters: [ctxcheck]
source: "^//nolint:ctxcheck \\(\\S+\\) " # 要求写明责任人最后一步,是他写了个小脚本,每周一早上统计一次 //nolint:ctxcheck 的数量,发到组里的群里。
这个数字从一开始的 5,降到三个月后的 1。
老陈后来看到了那个群,问他:“你为什么要把豁免的数量也发出去?”
“因为豁免会烂掉。“小林说,“当时说’预计六月前迁移’,到了八月还没迁。如果你不盯着豁免本身,豁免就会变成新的后门。”
老陈笑了:“你看,你自己其实知道。”
这一节背后的原则:文档是给人看的,工具是给组织用的。
你写在 wiki 上的那条规范,只在你记得它的那些天里有效。你想要它在你休假、你离职、你调岗之后还有效,你就必须给它一个不依赖人的载体。
而 linter 只是载体之一。它真正做的事情是:把”你脑子里的判断”,变成”任何人按下回车键时都会遇到的一句话”。
老陈的追问
-
你组里的每一条”规矩”,有几条是写在工具里的,有几条是写在你脸上的?
-
你去年推动的事情里,有哪一件是”你今年一次都没再提,但它还在发生”的?如果一件都没有,你去年到底是在建能力,还是在救火?
-
如果你明天调去另一个部门,你给现在这个组留下的东西里,哪一样会在半年后还有人提起来?
-
你有没有统计过,你一周里花在”重复说同一句话”上的时间有多少?如果把这些话里的一半变成工具,你省下来的时间去干什么?你敢不敢说实话?
-
你的团队里,有没有一个人,是你”不敢”让他做某件事的?为什么不敢?这个不敢,有多少是保护,有多少是控制?
生活思考题
题一:为什么你写完的文档,三个月后连你自己都不看了?
大多数人会这么答: 因为忙嘛。写完就扔那儿了,后面项目一紧,谁还记得去看。而且文档这东西,写完就过时。
这个答案为什么不够: “忙”是现象,不是原因。你忙的时候照样会去看监控、去看 runbook、去看别人给你发的消息。同样是文字,有的你天天看,有的一次都不看。
所以真正的问题不是”你为什么不看文档”,是”什么样的文档会被反复打开”。
更深的一层是什么: 文档有两种。
第一种是陈述性文档:它记录某个时刻的状态。“本系统采用三层架构,分别是……”。这种文档在写完的第二天就开始过期,三个月后它的价值只剩历史价值。你当然不看。
第二种是指令性文档:它会在某个具体时刻被拿出来执行。“线上出现 P0,第一步看这个盘,第二步执行这条命令,第三步通知这三个人”。这种文档你会反复打开,因为你在某个时刻需要它。
绝大部分团队写的文档是第一种,但大家期待它的效果是第二种。
更深一层:文档的价值,取决于它被打开的那个时刻是不是”你正需要它”。而决定这一点的,不是文档写得好不好,是它有没有被嵌进某个流程里。runbook 会被打开,是因为事故处理流程里写着”先查 runbook”。新人手册会被打开,是因为入职流程里写着”第一周读完这一页,找你的 mentor 签字”。
一份没有被任何流程引用过的文档,从写出来的那一刻起就是死的。
落到 Go 工程师身上: 下次你要写文档之前,先做一件事:写下”谁、在什么时刻、会因为什么原因打开它”。
如果你答不上来,别写。你去写个工具、写个脚本、写条 lint 规则,比写文档有用。
如果你答得上来,那就顺手多做一步:把打开它的那个动作,钉进流程里。比如:
- 你写了个接口设计规范,就在 PR 模板里加一行 checkbox:“接口是否符合 xxx 规范(链接)”。
- 你写了个事故处理手册,就在告警 payload 里带上那个链接,让大刘半夜被叫醒的时候,手机上第一眼就是它。
- 你写了个新人上手指南,就把它做成
make onboarding里的第一步,跑完自动化脚本最后打印一句”接下来读这一页(链接)”。
文档不是写完就完了。文档是”你要在流程里给它留一个入口”。
题二:为什么很多”一把手工程”,一把手一走就黄?
大多数人会这么答: 因为下面的人没动力呗。一把手在的时候有压力,一把手一走,谁还费那个劲。或者说,因为一把手用的是行政权力,不是真正的共识。
这个答案为什么不够: 这些话都对,但它们是现象层面的。而且它们暗含一个很偷懒的结论:“下面的人不行”。
你只要把责任推到”下面的人”身上,你就永远不用改自己的做法了。
更深的一层是什么: 一把手工程之所以黄,通常不是因为人没动力,是因为这个项目从一开始就没有改变任何人的日常动作。
它改变的是”汇报线”,不是”工作流”。
举个例子。某公司推”研发效能提升”,一把手挂帅,每周开一次会,每个部门报指标。这个项目在一把手走之前,数据一直很好看。
为什么好看?因为每周要报。
一把手走了,会不开了,指标没人在报了。三个月之后,所有人都回到原来的做法。因为这个项目从头到尾,没有在任何一个工程师的日常动作里留下痕迹。它只在一把手的那张会上留下了痕迹。
真正活下来的项目长什么样?它一定改变了某个你每天都会做的事:
- 提 PR 的时候,CI 里多了一条检查。
- 起服务的时候,脚手架里默认带上了 trace。
- 值班的时候,告警里多了一个链接。
- 排需求的时候,看板上多了一列。
这些东西不依赖谁在开会,它就在你的工具链里。
更深一层:一把手工程最容易犯的错,是把”重视”当成了”投入”。 开会、站台、讲话、发排名,这些都是”重视”,它们传递的是信号。而”投入”是把资源变成工具、变成接口、变成默认配置。
信号会消失,工具不会。
落到 Go 工程师身上: 你现在手上推的那个事,先问自己一个很土的问题:如果明天我不再提它,它靠什么活下去?
如果答案是”靠大家已经习惯了”,那你就得验证一下:这个习惯是长在脑子里的,还是长在工具里的?长在脑子里的习惯,撑不过两个季度的人员变动。
具体到 Go 项目里,可以落地的动作是:
第一,任何”我们以后都要 XXX”的要求,落地时同时问一句”这个 XXX 能不能变成 go generate 出来的一段代码,或者一个模板仓库里的默认文件”。能变成默认值的,绝不写进文档。
第二,你做的任何内部工具,做完后数一下:有多少人是”主动来用”的,有多少人是”因为 CI 卡住了不得不用”的。真正活下来的规范,最后都会走到第二种。
第三,给自己定个规矩:任何一个你推动的事情,在推了三个月之后,你故意停一个月,看看它的曲线塌不塌。塌了,说明你在做运动,不是在建机制。这个测试很疼,但是它是唯一诚实的测试。
6.3 你是在培养人,还是在制造依赖
小林挽留小赵,挽留了整整三周。
第一周他找了王总,要 P8 的名额。王总说今年的盘子已经定了,明年第一季度一定给。他说服王总在 OA 上写了个”特批”的备注。
第二周他给小赵开出了涨薪,涨了百分之二十二。这是他能批的上限。
第三周小赵说:“林哥,你别这样。”
“我哪样?”
“你现在这样,我反而更想走了。“
那个周三下午的对话
“小林,我给你讲个细节。“老陈说。
那次是周三下午,在老陈家楼下的一个小公园里。十一月底的天,四点多就暗了,长椅上凉。老陈特意带了坐垫。
“他说’你别这样’,你当时怎么回的?”
“我说我这是为你好。”
“然后呢?”
“然后他说……”小林停了一下,“他说,‘林哥,你不是在为我好。你是在为你自己好。’”
老陈点点头。
“我当时挺生气的。“小林说,“我心想我为了给你要名额,跟王总磨了三次,我给你涨薪涨到我权限上限,你说我是为自己好?”
“你当时没怼回去?”
“没有。“小林说,“我回家想了一晚上。第二天我想明白了,他说得对。”
“哪儿对?”
“我要留住他,不是因为他有更好的发展。“小林说,“是因为对账那块我没人接。”
“嗯。”
“我那天晚上想明白一件事。“小林说,“我这两年对他做的所有’培养’,都有一个共同点:都是在让我自己更安全。“
小林的三招,和老陈的三句话
小林把他这三周做的事,一条条跟老陈对了。
第一招:交接文档。
“我让赵鹏写交接文档,写得详细一点。他写了,我看了,写得不行。”
“哪儿不行?”
“他写了大概二十页,全是代码结构、函数签名、字段含义。“小林说,“我要的不是这个。我要的是那十几个补偿逻辑,每一个是怎么来的,什么情况下会触发,改了会有什么后果。”
“那你怎么说的?”
“我说’这个不行,你重写,要写清楚为什么’。”
“他说什么?”
“他说’林哥,那些我写不出来。你让我讲一遍我讲得出来,写下来我不知道从哪儿下笔’。“小林说,“我当时觉得他在推脱。”
老陈笑了。
“你知道为什么他写不出来吗?”
”……为什么?”
“因为那些东西,是他跟着你学会的。“老陈说,“他跟你一起开过十次会,一起处理过六个 case,他知道什么情况下你会说’这个要加个补偿’。他把这套东西学成了肌肉记忆,不是学成了知识。”
“肌肉记忆写不下来。”
“对。“老陈说,“这恰恰说明你教得没错。但也说明另一件事:他学的这套判断,绑定在’你在场’这个前提上。”
第二招:让小周接。
“我让小周接手对账,我带着他。“小林说,“结果第一周,我俩一起看代码,看了三个下午。看到第二个下午我就烦了。”
“为什么烦?”
“他问的问题太基础了。“小林说,“他问我’这个 status 为什么有 7 这个值,文档里只写了 6 个’。我说这个值是我三年前加的,当时有个 case 没地方放。他又问’那什么情况下会是 7’。我说你先别管这个。”
“第三个下午呢?”
“第三个下午我说’你先看着,我去开个会’。“小林说,“然后那一整个下午,我自己去把那几个补偿逻辑回滚的方案写出来了。”
老陈看着他。
“你听出问题了吗?”
“我听出来了。“小林说,“我嘴上说’我带他’,实际上是我自己干得快,我着急。”
“再深一层。”
”……”
“你不是着急。“老陈说,“你是不敢让他慢。”
第三招:自己接手。
第三周,小林自己把对账那块接了。他花了两个通宵,把那十几个补偿分支一个一个看了一遍,在 Confluence 上写了一篇八千字的文档,配了二十一张图。
写完那天是周四凌晨三点。他合上电脑,坐在书房里,突然特别想给小赵发条消息,说”你看,我也能干”。
“这条消息你发了吗?”
“没发。“小林说,“我坐在那儿想了两个小时,我想明白了一件事:我这两周做的所有事,都是在证明这件事离了我不行。”
“你是在证明给他看,还是在证明给自己看?”
小林沉默了很久。
“给自己看。“
老陈讲了一件丢人的事
“我给你讲个我自己的事。“老陈说。
“我四十二岁那年,带过一个姑娘,姓孙。我到现在都觉得,那是我这辈子带过的最聪明的一个人。”
“她有多聪明?”
“我给她讲一遍,她能想到第三步。“老陈说,“我那时候做支付,有次遇到一个对账不平的问题,我在会上说了个思路,散会她就来找我,说’陈老师你那个思路有个漏洞,如果跨天的话这个差额会累计’。”
“我想了一晚上,她说得对。”
“后来呢?”
“后来我把她当成了我的……怎么说呢,“老陈想了想,“当成我的外接硬盘。”
“所有难的活儿都给她。所有我拿不准的,先问她。所有重要的会我都带上她,让她帮我记、帮我补。”
“那挺好的啊。”
“好个屁。“老陈说。
公园里有个老大爷在遛鸟,鸟笼子挂在树枝上。
“有一次,公司有个外派的机会,去新加坡,一年。我当时是她老板,我不同意。”
“为什么不同意?”
“我给 HR 的理由是’她现在手上的项目走不开’。“老陈说,“真实原因是,她走了我怎么办。”
“后来呢?”
“后来公司硬调的,HR 直接找她谈的,没经过我。“老陈说,“她去了。”
“然后呢?”
“然后她在新加坡待了一年,回来直接升了两级。三年之后,她是另一家公司的技术总监。”
老陈停了一下。
“小林,你知道这件事最让我难受的是什么吗?”
“什么?”
“她不是在我这儿变强的。她是在离开我之后,才变强的。”
小林抬起头。
“我带了她两年半。“老陈说,“那两年半,我一直在用她,我从来没让她自己做过一次完整的决策。所有的判断都是我做的,她只是我的校验器。”
“我那时候还觉得自己挺会带人的。”
公园里暗下来了。老大爷把鸟笼取下来,提着走了。
“从那以后我立了个规矩。“老陈说,“任何人跟了我一年以上,如果他还离不开我,那是我的失败,不是他的能力问题。“
小林的反驳
“我不同意。“小林说。
“你说。”
“您这个规矩太绝对了。“他说,“有些事是真的需要能力门槛的。对账那块,十几个补偿分支,那是我三年攒的 case。让一个 P6 去改,他连为什么要改都不知道,他改错了要赔钱。这不是我舍不得放,这是业务不允许。”
“嗯,这个想法很常见。”
“而且您刚才说的孙姐,她能升两级,是因为她本来就强。“小林说,“不是您耽误了她两年半,是她自己底子好,早晚的事。”
“你说的后半句,有一半是对的。”
“哪一半?”
“她底子确实好。“老陈说,“但’早晚的事’这一半不对。”
“为什么?”
“因为她在我手底下那两年半,升了一级。“老陈说,“离开我之后,一年升一级,升了两级。”
小林不说话了。
“你说的业务不允许,我认。“老陈说,“赔钱的事确实不能让新人瞎改。但是小林,你再想想:”
“他没到这个程度,是因为他不行,还是因为你从来没有让他到过?”
”……”
“我问你个具体的。“老陈说,“这三年里,赵鹏有没有自己做过一次完整的、从需求到上线的、你完全没参与的决策?”
小林想了很久。
”……有一次。“他说,“前年双十一之前,仓储那边接口要改版,是他谈的。他谈完来跟我说了一下,我觉得没问题,就让他去了。”
“那次怎么样?”
“挺好的。他谈了个灰度方案,先切 5% 的流量进去,跑了一周没问题再全切。我当时还觉得这小子挺稳。”
“你参与了多少?”
“我就听了一次汇报。”
“那你为什么不早让他多做这种事?”
小林没回答。
“因为这种事少。“老陈替他说了,“因为大多数时候,你自己干只要两小时,给他讲清楚要两天,他干完你还得 review 一遍,加起来三天。”
“对。“小林说,“我就是这么想的。”
“这个账是对的。“老陈说,“但是小林,这个账只在做一次的时候是对的。”
“什么意思?”
“你这一次省了两天半,你就得在下一次、再下一次,永远花这两个小时。“老陈说,“你从来没有把那两天半花出去,所以你永远省不掉那两个小时。”
“这就是你自己给自己挖的坑。“老陈说,“你不是在培养人,你是在制造依赖。而且你制造依赖的手法,看起来特别像负责任。“
菜市场卖鱼的老张
老陈家小区西门外有个菜市场,早上六点到九点最热闹。
最里面那排有个鱼摊,摊主姓张,五十来岁,杀鱼是那一排里最快的。一条草鱼,敲、刮、开膛、去腮、切块,一分四十秒。
“我观察他好几年了。“老陈说。
老张有个儿子,二十出头,也在摊子上。但老张从来不让他杀鱼。
“为什么?”
“慢。“老陈说,“那孩子杀一条鱼要五六分钟,而且杀得难看,鳞片刮不干净,鱼胆容易破。”
“有一次我听见老张骂他:‘你看看你杀的这个,谁要买?’”
“那孩子就在旁边干别的:捞鱼、装袋、收钱、找零、给客人刮鱼鳞。”
“刮鱼鳞也不算简单活儿。”
“是不算。“老陈说,“但那不是杀鱼。”
“去年夏天,老张住了半个月的院。“老陈说,“腰间盘,老毛病,这次是急性发作。”
“那半个月,我去买鱼,那个摊子关着。隔壁卖肉的说,他儿子守着摊子,但是不杀鱼。”
“不杀鱼?”
“不杀。“老陈说,“客人挑了鱼,他捞出来,装袋,说’我们老板不在,杀不了’。客人问’你不是他儿子吗’,他说’我不会’。”
“那半个月,那个摊子的营业额我后来听人说了,掉了大概七成。”
小林听着。
“老张出院回来第一天,我去看了一眼。“老陈说,“他站在那儿杀鱼,还是一分四十秒,杀完一条就骂一句。”
“骂什么?”
“骂他儿子’废物’。”
公园里彻底暗了。路灯亮了。
“小林,你说那孩子是废物吗?”
”……不是。”
“那他是什么?”
“他是……他爸从来没让他杀过。”
“对。“老陈站起来,拍了拍裤子上的灰,“你让他杀了五年,他杀了六万条鱼,他现在能杀一分四十秒。你不让他杀,他到三十岁还是不会。”
“这个道理谁都懂。“老陈说,“难的是,老张每天早上五点半到,六点开始排队,七点半到九点是高峰。那一个半钟头里,他让儿子杀,他就少卖三十条鱼。”
“三十条鱼,一条赚八块,二百四十块。”
“你每天都要在’今天的二百四十块’和’三年后有人能替我’之间选一次。绝大多数人,每天都会选那二百四十块。“
Go 实例:交出去的不是实现,是契约
小林开始改变做法,是从一件很小的事开始的。
十二月初,履约要做一件不大不小的事:把对账的持久化从一张大表拆成按月分表。这件事涉及两个模块:小林写的调度器要读,小赵写的对账服务要写。
以前的做法是:小林把接口定义好,把实现也写个大概,然后让小赵填空。
第一版他三天就写完了,长这样:
// 第一版:把实现的所有能力,原封不动倒给调用方
package storage
import "context"
type OrderRepo interface {
Save(ctx context.Context, o *Order) error
SaveBatch(ctx context.Context, os []*Order) error
Update(ctx context.Context, o *Order) error
UpdateStatus(ctx context.Context, id string, s Status) error
UpdateFields(ctx context.Context, id string, fields map[string]interface{}) error
Get(ctx context.Context, id string) (*Order, error)
GetByUserID(ctx context.Context, uid string) ([]*Order, error)
List(ctx context.Context, offset, limit int) ([]*Order, error)
ListByStatus(ctx context.Context, s Status, offset, limit int) ([]*Order, error)
CountByStatus(ctx context.Context, s Status) (int64, error)
Delete(ctx context.Context, id string) error
BeginTx(ctx context.Context) (Tx, error)
WithTx(ctx context.Context, tx Tx) OrderRepo
}这个接口,小林自己看着还挺满意。十二个方法,覆盖全面。
问题在他把这段推给小赵的第二天就暴露了。
小赵在群里问:“WithTx 返回的这个 OrderRepo,它里面的 Get 是在事务里还是不在?”
小林说:“在。”
小赵又问:“那我如果先 BeginTx,再 WithTx,再 Save,然后 tx.Commit(),中间 Save 失败了,我要不要 Rollback?”
小林说:“要。”
“那 Save 内部会不会自己 Rollback?”
“不会。”
“那如果我在 WithTx 之后的 repo 上又调了一次 BeginTx 呢?”
小林看着这几行,突然意识到一件事:这个接口把存储的所有复杂度都暴露给了调用方。 小赵为了实现分表,必须理解这十二个方法在一个”按月分表”的世界里分别是什么意思。
更糟的是,这十二个方法里,调度器真正用到的只有两个:Load 和 Save。
剩下十个,是”因为实现里有,所以接口里也就有了”。
这是典型的提供方视角接口:我有什么,就给什么。
小林把它推了重写:
// 第二版:消费方定义的最小接口 + 契约测试 + 免费假实现
package fulfill
import (
"context"
"errors"
"time"
)
// OrderID 用独立类型,避免跟 string 混淆。
type OrderID string
// ErrNotFound 是契约的一部分:所有实现都必须在找不到时返回它。
var ErrNotFound = errors.New("order not found")
type Order struct {
ID OrderID
Status Status
Amount int64 // 单位:分
CreatedAt time.Time
UpdatedAt time.Time
}
// OrderStore 是调度器(消费方)需要的全部能力,只有两个方法。
// 注意:这里没有事务、没有批量、没有分页 —— 因为调度器不需要。
// 需要批量的对账服务,自己在其上组合,不要污染这个接口。
type OrderStore interface {
Load(ctx context.Context, id OrderID) (Order, error)
Save(ctx context.Context, o Order) error
}写到这里,小林做了一件以前从没做过的事:他没有去写实现。他写了一套契约测试,这套测试不针对任何具体实现,它针对的是”任何声称自己是 OrderStore 的东西”。
// contract_test.go
package fulfill_test
import (
"context"
"errors"
"sync"
"testing"
"time"
"example.com/fulfill"
)
// OrderStoreContract 是一套行为约定。
// 任何 OrderStore 的实现(MySQL 分表版、内存版、mock 版)
// 都必须通过这一套测试,才算"遵守了契约"。
//
// 用法:在你的实现包里写一句
// func TestContract(t *testing.T) { fulfill_test.OrderStoreContract(t, newMyStore) }
func OrderStoreContract(t *testing.T, newStore func(t *testing.T) fulfill.OrderStore) {
t.Helper()
t.Run("加载不存在的订单返回 ErrNotFound", func(t *testing.T) {
s := newStore(t)
_, err := s.Load(context.Background(), "no-such-order")
if !errors.Is(err, fulfill.ErrNotFound) {
t.Fatalf("期望 ErrNotFound,实际 %v", err)
}
})
t.Run("存进去的能原样读出来", func(t *testing.T) {
s := newStore(t)
now := time.Now().Truncate(time.Second) // 很多存储会截断时间精度
o := fulfill.Order{
ID: "ORD-1",
Status: fulfill.StatusCreated,
Amount: 1999,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.Save(context.Background(), o); err != nil {
t.Fatalf("Save 失败: %v", err)
}
got, err := s.Load(context.Background(), "ORD-1")
if err != nil {
t.Fatalf("Load 失败: %v", err)
}
if got.ID != o.ID || got.Status != o.Status || got.Amount != o.Amount {
t.Fatalf("读出来的跟写进去的不一致:\n 写: %+v\n 读: %+v", o, got)
}
})
t.Run("重复 Save 同一个 ID 是幂等的", func(t *testing.T) {
s := newStore(t)
o := fulfill.Order{ID: "ORD-2", Status: fulfill.StatusCreated}
_ = s.Save(context.Background(), o)
o.Status = fulfill.StatusShipped
if err := s.Save(context.Background(), o); err != nil {
t.Fatalf("第二次 Save 失败: %v", err)
}
got, _ := s.Load(context.Background(), "ORD-2")
if got.Status != fulfill.StatusShipped {
t.Fatalf("第二次 Save 没有生效,状态仍是 %v", got.Status)
}
})
t.Run("ctx 取消后不得写入", func(t *testing.T) {
s := newStore(t)
ctx, cancel := context.WithCancel(context.Background())
cancel() // 立刻取消
err := s.Save(ctx, fulfill.Order{ID: "ORD-3"})
if err == nil {
t.Fatal("ctx 已取消,Save 竟然成功了")
}
// 顺便验证:失败的写入不能留下半条数据
if _, err := s.Load(context.Background(), "ORD-3"); !errors.Is(err, fulfill.ErrNotFound) {
t.Fatalf("失败的写入留下了脏数据: %v", err)
}
})
t.Run("并发写同一个 ID 不能崩", func(t *testing.T) {
s := newStore(t)
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
_ = s.Save(context.Background(), fulfill.Order{
ID: "ORD-C",
Status: fulfill.Status(i % 4),
})
}(i)
}
wg.Wait()
})
}最后,他给了一个免费的假实现,让调度器这边的开发完全不用等小赵的分表版本:
// fake.go
package fulfill
import (
"context"
"sync"
)
// FakeOrderStore 是给依赖方开发用的内存实现。
// 它故意做得跟真实现一样严格:一样会返回 ErrNotFound,
// 一样在 ctx 取消时报错。假的太宽松,会让你的代码在真环境里崩掉。
type FakeOrderStore struct {
mu sync.RWMutex
orders map[OrderID]Order
}
func NewFakeOrderStore() *FakeOrderStore {
return &FakeOrderStore{orders: make(map[OrderID]Order)}
}
func (f *FakeOrderStore) Load(ctx context.Context, id OrderID) (Order, error) {
if err := ctx.Err(); err != nil {
return Order{}, err
}
f.mu.RLock()
defer f.mu.RUnlock()
o, ok := f.orders[id]
if !ok {
return Order{}, ErrNotFound
}
return o, nil
}
func (f *FakeOrderStore) Save(ctx context.Context, o Order) error {
if err := ctx.Err(); err != nil {
return err
}
f.mu.Lock()
defer f.mu.Unlock()
f.orders[o.ID] = o
return nil
}
// Seed 只在测试里用,方便造数据。
func (f *FakeOrderStore) Seed(orders ...Order) {
f.mu.Lock()
defer f.mu.Unlock()
for _, o := range orders {
f.orders[o.ID] = o
}
}然后他把这三样东西打包扔给小赵,说了三句话:
OrderStore这个接口,是调度器需要的全部。你别改它,你只要能实现它。OrderStoreContract这套测试,你的分表实现必须跑通。跑通了就算你做对了,不用问我。FakeOrderStore你拿去,我这边开发不等你。
小赵那天晚上十一点多给他发了条消息:
林哥,这个契约测试里的”并发写同一个 ID”,你确定要卡这一条?分表实现的话,并发写同一条要跨月路由,我得想一下。
小林说,他看到这条消息的时候,愣了一下。
因为这是三年来,小赵第一次提出了一个他没考虑到的问题。
“以前他问我,都是’这个字段用什么类型’、‘这个分支要不要加’。“小林跟老陈说,“这次他问的是’这条契约本身合理吗’。”
“你怎么回的?”
“我说’你定’。”
“就俩字?”
“就俩字。“小林说,“我当时手放在键盘上,打了三十多个字,全删了,最后就发了’你定’。”
老陈笑了。
“那两个字,比你这两年跟他说过的所有话加起来都值钱。”
这一节背后的原则:授权的技术形态,是契约,不是实现。
你给别人一个实现,他只能照着改,他永远是在你的思路里打转。你给别人一个契约(接口 + 契约测试 + 假实现),他可以在契约内部自由发挥,而你不用在场。
三件套里,最容易被忽略的是假实现。没有假实现,“我等你”就是唯一选项,依赖就产生了。有了假实现,两条线可以并行,这才是真正的解耦。
顺便说一句:这一套东西,老陈当年没做过。他说他四十岁之前,都是自己把实现写完,然后让别人”看看有什么问题”。
老陈的追问
-
你带的每一个人,你都能说出他”今年比去年多了哪一项能力”吗?说不出来,说明你今年只是在用他,不是在带他。
-
你上一次让别人做一件”你知道他会做错,但错了代价你可承受”的事,是什么时候?如果答案是”没有”,你是在保护业务,还是在保护你自己不出错?
-
你的团队里,有没有人因为你而更强,同时也因为你而更离不开你?这两件事是不是同一个人?如果是,你打算怎么办?
-
如果你带的人明天集体提离职,你觉得他们是”终于解脱了”,还是”对不起林哥”?你怎么知道你的判断是准的?
-
你敢不敢让一个你认为”还不行”的人,去做一件你认为”很重要”的事,然后在旁边一句话不说?
生活思考题
题一:为什么一个家里,最累的那个人往往是”最放心不下”的那个人?
大多数人会这么答: 因为别人都不靠谱呗。他能干,他眼睛里有活,别人看不见、看见了也做不好,最后只能他自己来。
这个答案为什么不够: 这个答案把一个循环当成了原因。你观察一下就会发现,这种家庭里常常有个诡异现象:不是”别人不行所以他累”,而是”他什么都干了,所以别人越来越不行”。
这两句话的因果方向是反的,但是当事人永远认为是前者。
更深的一层是什么: “放心不下”这个状态,是有回报的。
第一层回报是效率:我自己干五分钟,教他要半小时,还得返工。这个账每次都划算。
第二层回报是控制感:事情按我的方式做完,我知道它是好的。
第三层回报最深,也最难承认:被需要的感觉。如果你什么都不干了,你就得面对一个问题:“这个家没我转得下去吗?”
第三层回报是这三家里最隐蔽的,因为它会带来痛苦。你会抱怨,你会累,你会在深夜发一条朋友圈。但抱怨本身就是回报的一部分:它证明你还重要。
所以”放心不下”不是一种性格缺陷,它是一个稳定的、有收益的行为模式。你不可能靠”想开点”改掉它,你只能靠改变收益结构来改。
落到 Go 工程师身上: 这个模式在团队里长什么样?长这样:你会把”最难的活”留给自己,理由是”这个他们做不了”。你会下意识地不把某些知识写下来,因为写下来你就不是唯一懂的人了。你会在别人做的时候忍不住插手,因为”我看不下去了”。
能落地的三件事:
第一,给自己定一个硬指标:每一个季度,你必须交出一件”你现在做的、且你认为别人做不了”的事。 交出去的判据不是”他做完了”,是”他做完之后你不需要 review”。
第二,把你的默认反应改掉。别人来问你的时候,你的第一句话不要是答案,改成”你先说说你打算怎么做”。这句话很烦人,但它会逼对方先自己想一遍。你会惊讶地发现,一半的问题在他开口的第三句就自己解决了。
第三,给自己一个很土的度量:每天记一笔”今天有几次,我做了本可以不做的事”。这个数你连记两周,会刺痛你。
题二:为什么很多手艺,是”偷学”来的,而不是”教”出来的?
大多数人会这么答: 因为师傅留一手呗。教会徒弟饿死师傅,所以师傅不认真教,徒弟只能自己偷看。
这个答案为什么不够: “留一手”确实存在,但它解释不了一个现象:很多师傅是真心想教的,手把手教,一点没藏,徒弟还是学不会。你看那些非遗传承的纪录片,老艺人恨不得把心掏出来,年轻人照样学不出来。
更深的一层是什么: 手艺里有两种知识。
第一种是可陈述的:步骤、配方、参数、工具。这部分能写进书里,能被”教”。
第二种是判断:这一步到什么程度算”够”了?这个面团今天为什么比昨天黏?这一刀下去多重算”刚好”?
第二种知识,师傅自己都说不清楚。你问他”什么时候算好了”,他说”你看着差不多就行了”。他不是藏,他是真的表述不出来,因为他的判断是基于成千上万次试错形成的模式识别,不是规则。
这种知识唯一的传递方式是:让学习者在有反馈的环境里,自己试足够多次。
而”教”这个动作,恰恰会减少他试的次数。你把每一步都告诉他了,他就不需要自己判断了。他按你说的做,做对了,但他不知道为什么对,也不知道差一点会怎样。
所以真正的学,发生在师傅不在旁边的时候。
落到 Go 工程师身上: 这一条对我们打击很大,因为我们这个行业特别爱”教”:技术分享、code review、mentor 制度、内部分享会。这些有没有用?有用,但它们传递的是第一种知识。
判断力(这个接口该不该拆、这个超时该设多少、这个功能该不该做)是第二种。
能做的具体动作:
第一,在 code review 里,把”你应该这样写”改成”你试过另一种写法吗?两种都跑一下 benchmark 给我看”。你要的是他自己得出一个结论,而不是接受你的结论。
第二,创造”低风险的失败”。判断力必须在失败里长出来,但生产环境的失败太贵。所以你要造一个便宜的失败场:一个内部的小项目、一次不影响线上的重构、一个”故意搞坏再修”的演练。让他在那儿摔。
第三,最狠的一条:在关键决策上,让对方先说结论,你后说。 顺序一换,他就必须自己判断。如果你先说,他后面所有的思考都会变成”给老板的结论找理由”。
对了,还有个副作用,你要有心理准备:他们自己得出的结论,有时候跟你不一样,而且有时候他们是对的。
6.4 技术规范为什么会变成形式主义(以及如何不让它变成)
一月初,阿 May 在周会上放了一张图。
图上是一张饼图,最大的那一块写着”INTERNAL_ERROR 82.4%”。
“这是去年十二月份,客服工单系统里所有的报错码分布。“阿 May 说,“一共一万三千七百条。八成的工单,客服看到的都是’系统内部错误’四个字。”
“然后呢?”
“然后客服只能把工单转给技术,让技术去查日志。“阿 May 说,“我算了一下,这类工单的平均处理时长是 47 分钟,其中 41 分钟花在’找这是哪个服务的哪个错误’上。”
小林坐在那儿,脸有点发烫。
因为去年六月,他推过一条规范:所有对外暴露的错误,必须带业务错误码。
他还记得那次周会。他在白板上写了三行:
1. 所有 error 必须用 %w 包装,保留调用链
2. 所有对外的错误必须实现 Coder 接口,返回业务错误码
3. 禁止 errors.New / fmt.Errorf 裸错误直接返回给调用方
当时所有人都点头。他还专门写了篇 wiki,配了四个例子。
“这个规范我去年六月推过。“小林说。
“我知道。“阿 May 说,“所以我想问,为什么十二月还是这样。“
小林回去翻了代码
那天晚上他翻了大概两个小时。他翻出来的东西,比那张饼图更让他难受。
第一种,纯凑数的 wrap:
if err != nil {
return fmt.Errorf("%w", err)
}这种写法能通过 lint,能通过 review,它”包”了,但它一个字的信息都没加。
第二种,格式化字符串堆料:
return fmt.Errorf("fulfill service schedule order %s failed: get warehouse %d capacity error: %w",
orderID, warehouseID, err)看起来挺专业,但它的错误码还是 INTERNAL_ERROR,因为写它的人压根没实现 Coder。
第三种,最省事的:
return apierr.New(apierr.CodeInternal, err.Error())这一行小林看了很久。它完全符合规范:有错误码,有 wrap。但它的错误码永远是 CodeInternal。
规范的所有字面要求,它都满足了。规范的意图,它一条都没满足。
第二天他把这些发给老陈看。
“你说这是人的问题还是规范的问题?“他问。
“你说呢?”
“我觉得是人的问题。“小林说,“我规范写得挺清楚的,三条,还配了例子。他们敷衍。”
老陈没说话。
”……不对,是吧。”
“你自己再想想。”
小林盯着那三行规范看了三分钟。
“我这条规范,只说了’要做什么’。“他说。
“还有呢?”
“我没说做完有什么好处。”
“还有呢?”
”……我写的时候,我自己都没试过按这条规范写一天代码。“小林说,“我给你举个例子。我要把一个错误从最底层一路传上来,三层:dao、service、handler。按我这条规范,我要在 dao 里定义错误码,在 service 里 wrap 一次,在 handler 里再判断一次类型。”
“一共几行?”
“大概七行。“小林说,“如果不按规范来,一行 return err。”
“那你觉得他们会选哪个?“
老陈装修房子那次
老陈五年前装过一次房子,自己找的工队,自己盯。
“我在门口贴了一张纸。”
“写的什么?”
“‘进入工地必须戴安全帽。每天收工前必须清扫。垃圾必须装袋。‘“老陈说,“我还买了八个安全帽,挂在墙上。”
“第一天,工人都戴着。”
“第三天,我发现有两个人没戴。我说了一句,戴上了。”
“第七天,我去的时候,八个人里五个没戴,地上的水泥灰有半指厚,墙角堆着一堆没装袋的碎砖。”
“您发火了吗?”
“发了。“老陈说,“我说’我贴在门口的话你们没看见吗’。工长说了一句话,我到现在都记得。”
“什么话?”
“他说:‘陈老师,安全帽在墙上挂着,进门的时候手上拎着工具,谁会放下工具去拿帽子?’”
小林笑了。
“后来呢?”
“后来我换了个做法。“老陈说,“我把安全帽从墙上取下来,八个帽子全挂在门把手上。”
“门把手?”
“对。“老陈说,“你推门,帽子就在手上晃。你要进门,你就得把它拿下来。拿都拿下来了,顺手就戴上了。”
“那清扫呢?”
“我把扫把和簸箕从杂物间拿出来,靠在门框边上。“老陈说,“出门必经。顺便我买了二十个编织袋,就堆在门后面。”
“有用吗?”
“有用。“老陈说,“后来一直到完工,我一次没提醒过。”
“为什么有用?”
“因为我没改人,我改了他们走的那条路。”
老陈停了一下。
“小林,你想想我第一次那个做法错在哪儿。”
“您把规矩贴在墙上了。”
“贴在墙上,意味着遵守规矩要多走两步。“老陈说,“而’不戴帽子’是零成本的。”
“你让他每天做一百次这样的选择,他一百次都会选省事的那一个。不是因为他人品差,是因为人就是这么运行的。”
“任何一条要求,如果遵守它比违反它更费力,它早晚变成墙上的一张纸。“
小林的三版规范
小林后来把组里的错误处理规范改了三版。他跟我说,这三版改完,他才真正明白”规范”这两个字是什么意思。
第一版:写在 wiki 上的三条要求。
结果你已经看到了。执行率,按他自己的统计,大概百分之三十五。而且那百分之三十五里,相当一部分是第三种的”敷衍合规”。
第二版:加 lint 检查。
他用的是 wrapcheck 加自己写的一条规则,检查所有返回给 handler 的错误是否实现了 Coder。
CI 一开,红了四百多个地方。
组里炸了两天。然后出现了两个新现象:
一是有人开始在 PR 里批量加 //nolint:apierr,一天加了七十多个。
二是那段纯凑数的 fmt.Errorf("%w", err) 出现了,因为它能过 lint。
“这个我懂。“老陈说,“你给了他一道墙,他就去找门。”
“那怎么办?再堵一个门?”
“你堵不完的。“老陈说,“你堵到第三道门的时候,你会发现你们组一半的时间在跟自己的工具打架。”
第三版:改默认路径。
小林做了一件不一样的事。他没有再加规则,他写了个包。
他先数了一下:组里最常见的错误场景,其实就那么几类。参数不对、查不到、状态不对、没权限、下游超时、下游限流。
然后他写了个 apierr 包,核心设计原则只有一条:让正确的写法比错误的写法更短、更顺手。
// Package apierr 统一业务错误的表达。
//
// 设计目标不是"功能强大",而是"让正确的写法比错误的写法更短"。
// 错误写法: return fmt.Errorf("order %s not found", id) —— 31 个字符,无错误码
// 正确写法: return apierr.NotFound("order", id) —— 30 个字符,带错误码
package apierr
import (
"errors"
"fmt"
"net/http"
"strings"
)
// Code 是业务错误码。对外暴露,会出现在客服工单和 App 提示里。
type Code string
const (
CodeInternal Code = "INTERNAL_ERROR"
CodeInvalidArg Code = "INVALID_ARGUMENT"
CodeNotFound Code = "NOT_FOUND"
CodeConflict Code = "CONFLICT"
CodePermission Code = "PERMISSION_DENIED"
CodeUpstreamUnavail Code = "UPSTREAM_UNAVAILABLE"
CodeRateLimited Code = "RATE_LIMITED"
)
// Error 是统一的错误类型。
type Error struct {
Code Code // 对外错误码
Msg string // 给用户/客服看的、安全的描述
Op string // 出错的操作,用于定位,例如 "fulfill.GetOrder"
Detail string // 内部细节,只进日志,不外传
Retryable bool
err error // 原始错误,保留 errors.Is/As 能力
}
func (e *Error) Error() string {
var b strings.Builder
if e.Op != "" {
b.WriteString(e.Op)
b.WriteString(": ")
}
b.WriteString(string(e.Code))
if e.Msg != "" {
b.WriteString(": ")
b.WriteString(e.Msg)
}
if e.err != nil {
b.WriteString(": ")
b.WriteString(e.err.Error())
}
return b.String()
}
func (e *Error) Unwrap() error { return e.err }
// HTTPStatus 让 HTTP 层不需要写 switch。
func (e *Error) HTTPStatus() int {
switch e.Code {
case CodeInvalidArg:
return http.StatusBadRequest
case CodeNotFound:
return http.StatusNotFound
case CodeConflict:
return http.StatusConflict
case CodePermission:
return http.StatusForbidden
case CodeRateLimited:
return http.StatusTooManyRequests
case CodeUpstreamUnavail:
return http.StatusBadGateway
default:
return http.StatusInternalServerError
}
}
// ClientSafe 返回可以放心返回给前端/客服的内容。
// 内部 Detail 绝不能出现在这里,防止泄漏实现细节。
func (e *Error) ClientSafe() map[string]any {
m := map[string]any{"code": string(e.Code)}
if e.Msg != "" {
m["message"] = e.Msg
}
m["retryable"] = e.Retryable
return m
}
// ---------- 下面是关键:把最常见的场景做成一行函数 ----------
func New(code Code, msg string) *Error {
return &Error{Code: code, Msg: msg}
}
func NotFound(kind string, id any) *Error {
return &Error{
Code: CodeNotFound,
Msg: fmt.Sprintf("%s %v 不存在", kind, id),
}
}
func InvalidArg(field, why string) *Error {
return &Error{
Code: CodeInvalidArg,
Msg: fmt.Sprintf("参数 %s 不合法: %s", field, why),
}
}
func Conflict(op string, why string) *Error {
return &Error{Code: CodeConflict, Msg: op + " 冲突: " + why}
}
// Wrap 把一个下游错误包成带码的错误。
// 它会自动保留 errors.Is / errors.As 的能力,
// 并且自动判断可重试性 —— 这件事以前全靠人记。
func Wrap(err error, code Code, msg string) *Error {
if err == nil {
return nil
}
// 已经是 *Error 了,就不要重复包一层,否则错误码会被覆盖。
var ae *Error
if errors.As(err, &ae) {
return ae
}
return &Error{
Code: code,
Msg: msg,
Retryable: isRetryable(code, err),
err: err,
}
}
func isRetryable(code Code, err error) bool {
if code == CodeRateLimited || code == CodeUpstreamUnavail {
return true
}
return errors.Is(err, context.DeadlineExceeded)
}(上面的 isRetryable 引用了 context,实际代码里补上 import "context" 即可。写出来是为了说明一件事:可重试性这种”以前靠人记”的判断,现在被塞进了构造函数里。)
然后他改了框架里的统一错误处理:
// handler 层不再需要写任何 switch,也不需要判断错误类型
func writeError(w http.ResponseWriter, err error) {
var ae *apierr.Error
if !errors.As(err, &ae) {
// 走到了这里,说明有人返回了裸错误。
// 不打日志不行,打了又容易刷屏,所以按分钟聚合。
log.Printf("[apierr] 未包装的错误: %v", err)
ae = apierr.New(apierr.CodeInternal, "服务开小差了,请稍后重试")
}
if ae.HTTPStatus() >= 500 {
// 5xx 才打完整日志,4xx 是用户的锅,不打扰值班同学
log.Printf("[apierr] %s | %s", ae.Op, ae.Error())
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(ae.HTTPStatus())
_ = json.NewEncoder(w).Encode(map[string]any{"error": ae.ClientSafe()})
}他还做了一个小东西:一个每周跑一次的脚本,扫一遍代码里所有 apierr.New(apierr.CodeInternal 的调用,按人统计,发到组里的群里。
这个数字一开始是每周新增 23 个。
三个月之后,是每周 2 个。
而客服工单里 INTERNAL_ERROR 的占比,从 82.4% 掉到了 19.7%。
阿 May 后来在会上说了一句话,小林记到现在。她说:“你们那个改动之后,我们客服那边最直观的感受是,‘我终于知道这个单子该转给谁了’。“
小林自己总结的三条
“我后来想了想,规范变成形式主义,就三个原因。“小林跟老陈说。
“说说。”
“第一,规范只说了’要做什么’,没说’做完有什么好处’。“他说,“我六月推那条规范的时候,我跟大家说的是’这样能提升可观测性’。可观测性对他们来说是什么?他们写代码的时候看不到任何变化。”
“后来我改了说法。我说:‘用了这个包之后,值班同学半夜被叫起来,第一眼就能看到是下游哪个服务挂了,不用翻二十分钟的日志。’”
“这个说法他们听进去了。因为值班表上有他们。”
“第二呢?”
“第二,遵守比违反更费力。“小林说,“这个是致命的。我第一版规范,写对要七行,写错要一行。我第三版做完,写对比写错还省事。”
“你确定写对比写错省事?”
“确定。“小林说,“apierr.NotFound("order", id) 比 fmt.Errorf("order %s not found", id) 还短一个字符。我数过。”
老陈笑了。
“第三呢?”
“第三,检查的人会疲劳。“小林说,“我盯了两个月,我盯不动了。第三个月我忙,就没盯,然后它就烂了。”
“所以检查必须自动化。”
“对。“小林说,“但我加了一句:自动化的检查不能只有’红’和’绿’。你还得有一个数字,能看到趋势。”
“为什么?”
“因为’绿’不代表好。“小林说,“我第二版加 lint 之后,CI 是绿的。绿得一塌糊涂。因为它绿的原因,是大家都在写 fmt.Errorf("%w", err)。”
“那个每周发的数字,才是真的指标。”
老陈点点头。
“你现在说的话,跟半年前不一样了。”
“哪儿不一样?”
“半年前你会说’我推了一条规范’。“老陈说,“你现在说的是’我改了他们写代码时最省力的那条路’。“
老陈的追问
-
你推过的所有规范里,有几条你现在能说出”它给遵守它的人省了什么事”?如果一条都说不出来,那你推的不是规范,是你自己的审美偏好。
-
你们团队的 lint 配置里,有多少条
//nolint?这个数字在过去半年是涨还是跌?你上一次看这个数字是什么时候? -
你有没有一条规范,是你自己经常违反的?你违反它的时候,心里想的是什么?那个想法,你的团队里每个人都会有。
-
你上一次问”这条规范给你添麻烦了吗”,是什么时候?你还记得对方的回答吗?
-
如果明天取消你们组所有的规范,有多少人会松一口气,有多少人会不习惯?这两个数字的比例,说明了什么?
生活思考题
题一:为什么小区里的垃圾分类,最后都变成混装?
大多数人会这么答: 因为大家素质不够,嫌麻烦。还有一部分人会说,因为清运车最后也是混在一起拉走的,分了白分。
这个答案为什么不够: “素质”是个万能解释,它什么都能解释,也什么都不解释。而”清运车混装”这条,在大部分城市其实是违规的,也不构成居民不分的主要理由。
真正的原因,得回到那个扔垃圾的动作本身去看。
更深的一层是什么: 一次”正确分类”的动作,需要同时满足五个条件:
- 你得知道这个东西属于哪一类(湿垃圾还是干垃圾?大骨头是干垃圾,小骨头是湿垃圾,你确定吗?)
- 你家得有两个桶,且两个桶不能放太远。
- 垃圾桶得有地方放,厨房本来就小。
- 你得在扔的那一刻想起来这件事,而你扔垃圾的时候通常在赶时间。
- 扔的时候,那个投放点得是开着的(很多小区定时定点)。
这五条里,任何一条不满足,这次分类就失败了。而”不分”只需要满足零条。
一个需要五个条件才能成功的动作,和一个需要零个条件就能完成的动作,放在一起竞争,前者永远输。
更深一点说:这五个条件里,最难的是第一条——知识。垃圾分类要求每个居民记住几百种物品的分类,这是一个巨大的认知负担,而它带来的回报(环境变好)是极其遥远、极其分散的。人的大脑不会为这种回报付费。
那些真正做成的社区,不是靠宣传,是靠把这个动作的难度降下来。比如:把定时投放改成全天,把两个桶换成一个踏板开的双格桶放在厨房水槽下面,在桶盖上印最常见的二十种东西。
落到 Go 工程师身上: 你推的每一条技术规范,都去数一下:遵守它需要同时满足几个条件?
如果需要”记住规则 + 手动写代码 + 记得在 PR 里说明 + 通过 review”,那就是四个条件。你要做的是把它降到零到一个条件。
具体到 Go:
- 需要”记住规则”的,变成 lint 或者
go vet的检查。 - 需要”手动写代码”的,变成
go generate或者脚手架模板里默认就有的文件。 - 需要”记得在 PR 里说明”的,变成 PR 模板里的必填项,或者干脆变成一条能自动生成的统计。
- 需要”通过 review”的,是唯一应该保留的一个条件 —— 因为那是需要人脑的部分。
一句判断标准:如果你的一条规范,需要一个人”每次都想起它”才能执行,那这条规范已经死了,只是还没埋。
题二:为什么公司里那些”价值观考核”,最后都变成了互相打高分?
大多数人会这么答: 因为是形式主义呗,大家都不当真,走个过场。而且打低分得罪人,谁也不想做坏人。
这个答案为什么不够: 它解释了”为什么分数高”,没解释”为什么一开始大家是认真的,后来变成这样”。
任何一个新的考核制度推出来的前两轮,大家都是认真的。第六轮开始变成走过场。中间发生了什么?
更深的一层是什么: 考核这件事,有一个隐藏的属性:它一旦跟利益挂钩,它测量的就不再是那个东西本身了。
这是个著名的规律(古德哈特定律):当一个指标变成目标,它就不再是好指标。
开始的几轮,大家按真实表现打分。然后有一个人发现:我给小王打低分,小王下个季度会给我打低分,我的奖金会受影响。而给小王打高分,对我来说成本是零,收益是小王也给我打高分。
这个博弈只需要发生两次,就传染开了。第三次,所有人都在打高分。因为任何一个人单方面”认真打分”,他的收益是负的。
这不是道德问题,这是机制设计问题。 你把一群理性人放进一个”诚实有惩罚,作弊有收益”的规则里,他们会集体作弊。换一批人也是一样。
所以真正的问题不是”怎么让大家认真起来”,而是”这个规则有没有让诚实变成最优策略”。
落到 Go 工程师身上: 这一条看起来跟技术没关系,其实关系极大,因为技术团队里的”考核”无处不在:
- 你统计代码行数,就会有人把一行拆成三行。
- 你考核单测覆盖率,就会有人写一堆没有断言的测试。
- 你考核”事故数”,就会有人把事故降级成”问题”,不记录。
- 你考核”需求交付周期”,就会有人把大需求拆成五个小需求。
- 你奖励”主动发现隐患”,就会有人的隐患数量突然变多。
这些不是人品问题。你在奖励什么,你就会得到什么。
具体到 Go 团队,能做的三件事:
第一,任何你要考核的指标,先自己试着作弊一次。你马上就能找到漏洞。找不到漏洞,说明你没认真找。
第二,永远用一组指标,不用单一指标。覆盖率配上”这条测试删掉之后,CI 会不会绿”(变异测试的思想)。事故数配上”故障平均恢复时长”,这样降级事故就没意义了,因为恢复时长照记。
第三,最有意思的一条:把”检查是否作弊”这件事本身,做成随机的、不定期的抽查。你不可能每次都查,你也别承诺每次都查。不定期抽查的威慑力,比百分百检查加上一个已知的绕法,要高得多。
最后加一句:你自己推的东西,你也要能被这套规则检验。你要是给自己留了后门,那这个后门一个月之内所有人都会知道。
6.5 平台化:什么时候该做平台,什么时候该忍住
二月中旬,王总给了小林一个季度的窗口。
“履约这块你做完之后,能不能把别的业务线也接进来?“王总说,“现在是三条线各写各的:商城、社区团购、企业购。三条线都有履约,三套代码,三个人维护。”
“我正想说这事。“小林说。
“多久?”
“我估一下。“小林说,“三个月出原型,半年接第一条线,一年接完。”
王总点点头:“行,你做。需要人跟我说。“
小林的草稿
小林回到工位,画了一版架构图。核心是一个通用履约编排引擎。
他的想法是:三条业务线的履约流程虽然不一样,但拆开看都是一堆”节点”和”条件”。所以做一套引擎,把节点抽象出来,业务线用配置把这些节点串起来。以后接第四条线,就不用写 Go 代码了,配一下就行。
他画完,自己觉得挺漂亮。他还给这个引擎起了个名字,叫 FlowForge。
他把图发给 Kevin 看。Kevin 回了三个字:“我支持。”
Kevin 还加了一句:“这个东西做出来,行业里是有同类产品的。做好了能对外讲。”
那句话让小林有点飘。
周末他拿着图去找老陈。
老陈把图看了大概两分钟,问了个问题。
“你现在有几条业务线?”
“三条。”
“这三条里,重复的东西是什么?”
”……”小林张了张嘴,“履约流程大体是一样的,都是下单、分配、出库、回传。”
“我问的不是流程。“老陈说,“我问的是代码里重复的东西。”
“这个我没数过。”
老陈把图推回来。
“你回去数。一条一条数,数清楚。三条业务线的仓库里,到底哪几段代码是一模一样的。“
小林数了一周
他真的去数了。他写了个脚本,把三个仓库的 Go 文件做了个粗略的重复块检测,然后人工过了一遍。
结果他自己都愣了。
三条业务线代码重复情况(按代码块统计)
真正一行不差重复的:
- 下游 HTTP 调用的超时/重试/熔断封装 3/3
- 幂等键的生成与校验(Redis + Lua) 3/3
- 全链路 trace 的注入与透传 3/3
- 配置热更新的 watcher 3/3
- 对账任务的调度框架(定时 + 分片 + 断点) 3/3
- 错误码到 HTTP 状态码的映射 3/3
看着像重复,实际每行都不一样:
- 订单状态机:三条线的状态数分别是 7、11、9
- 仓匹配规则:商城按省市区,团购按自提点,企业购按合同仓
- 时效承诺计算:三条线的口径完全不同
- 异常补偿:各有各的历史 case
他把这张表发给老陈。
“我看明白了。“他在消息里写,“重复的是横切的东西,不重复的是业务流程本身。”
老陈回了一句:“那你打算做哪个?”
“我打算先做上面那一半。”
“不做引擎了?”
”……不甘心。“小林说。
老陈投过的那个酸菜鱼
“我给你讲个我赔钱的事。“老陈说。
“您还赔过钱?”
“我投过的项目,十个里死了七个,很正常。“老陈说,“这个是死得最可惜的一个。”
二零一九年,老陈投了一个做酸菜鱼的小连锁。老板姓方,厨师出身,在杭州开了三家店。
“他那个鱼,是真好吃。“老陈说,“我第一次去吃,是朋友带我去的,在一条小巷子里,店面不到四十平,六张桌子,晚上七点去要等位。”
“三家店,一年能做多少?”
“三家店加起来,一年流水大概九百万,净利一百三十多万。“老陈说,“方老板当时想开第四家、第五家,来找我聊,说要建中央厨房。”
“他的理由是什么?”
“三个:统一口味、降低厨师依赖、提高扩张速度。“老陈说,“这三条听起来都对。”
“中央厨房要多少钱?”
“他算的是三百八十万。“老陈说,“厂房、冷链车、设备、还有一个研发厨房。他自己出两百万,找我们这些老股东凑了一百八十万。”
“然后呢?”
“然后死了。“老陈说,“中央厨房建好是二零二零年四月。正好赶上那一年餐饮最难的时候。但这不是主因。”
“主因是什么?”
“主因是:他三家店的客人,本来就不是冲着’统一的酸菜鱼’去的。”
老陈说:“他那三家店,三个厨师,口味其实是有差别的。文一路那家偏酸,教工路那家偏辣,滨江那家鱼片切得薄。我后来才知道,很多老客是认店的。文一路那家搬走的老客,不会去教工路。”
“中央厨房一上,三家店统一了。统一之后,老客吃了一口就说’味道不对了’。”
“掉了多少?”
“半年之内,三家店流水掉了百分之四十一。“老陈说,“方老板后来把中央厨房关了,又让厨师自己调味,恢复了七成。但那三百八十万已经花掉了。”
“我心疼钱吗?心疼。“老陈说,“但我更心疼另一件事。”
“什么事?”
“方老板是个好厨师。“老陈说,“他这辈子最值钱的东西,是他手上的那点分寸。他花了三百八十万,去做一件让自己最值钱的东西失效的事。”
小林听着。
“小林,你那个 FlowForge,跟方老板的中央厨房,有什么不一样?”
”……”
“你想想,三条业务线现在跑得好好的。你做一个引擎把它们统一起来,统一之后,谁受益?”
“统一了以后,接新业务线快。”
“你有第四条业务线吗?”
“没有。”
“王总说了要开第四条吗?”
”……没有。”
“那你在为谁做这个引擎?”
小林沉默了很久。
“为我明年的述职。“
小林的反驳,和三次法则
“我还是想争一句。“小林说。
“你说。”
“方老板那个是产品问题,我这个是工程问题。“他说,“三条业务线各写各的,一年之后就是三个烟囱。到时候想拉通都拉不动。这个我在上一家公司见过,最后花了两年重写。”
“嗯,这个想法很常见,而且你说的那个结局是真的。”
“那您的意思是忍着?”
“不是忍着。“老陈说,“是等第三个出现。”
“什么意思?”
“我给你个东西,叫三次法则。“老陈说,“第一次做,直接做。第二次做类似的事,忍住,复制。第三次再遇到,就抽。”
“为什么是第三次?”
“因为第一次的时候,你不知道它是不是偶然。第二次的时候,你知道它重复了一次,但你不知道重复的是哪一部分——你这时候抽,抽出来的往往是错的那一半。”
“第三次你才看得清。“老陈说,“第三次的时候,你已经见过了三种不同的情况,你知道哪些是它们共有的,哪些是各自特有的。这时候你抽出来的东西,才是真正稳定的那部分。”
“那第三次之前呢?”
“复制。“老陈说,“第二次的时候,你就老老实实把代码复制一份,改一改。这件事会让你难受,但这个难受是有用的。它是你为’抽对’这件事付出的信息成本。”
“那我这个case呢?“小林说,“我这已经是第三次了,三条业务线。”
“对,所以你该抽。“老陈说,“但你抽的不是业务流程,是那六行横切的东西。”
“这两者的区别是什么?”
“你自己那张表已经告诉你了。“老陈说,“你的表里,上面那一半,三条线是一模一样的。下面那一半,三条线是各自不同的。”
“上面那一半,重复的是成本。下面那一半,差异是价值。”
小林盯着这张表看了很久。
“我明白了。“他说,“业务流程的’重复’是表象,横切能力的’重复’才是成本。”
“再说一遍,大声点,说给你自己听。”
“平台化的单位是横切关注点,不是业务流程。“
Go 实例:一个死掉的编排引擎,和一条活下来的 middleware 链
小林第一版确实写了那个引擎。他写了大概两周,然后自己把它删了。
他给我看过那版代码的核心,我把它精简了一下放在这儿。你可以看看它一开始长什么样:
// 第一版(已废弃):通用履约编排引擎
package flowforge
import "context"
// Node 是流程里的一个节点。
type Node interface {
// ID 全局唯一,配置里用它来引用
ID() string
// Exec 执行节点,返回下一个要执行的节点 ID
Exec(ctx context.Context, input Input) (nextNodeID string, err error)
}
// Input 是节点之间传递的数据。用 map 是因为"通用"。
type Input map[string]any
// Flow 描述一个流程:节点集合 + 跳转条件。
type Flow struct {
Nodes map[string]Node
Edges map[string][]Edge
StartID string
}
// Edge 是条件跳转。Cond 用字符串表达式表示,运行时求值。
type Edge struct {
To string
Cond string // 例如 `input.status == "PAID" && input.warehouse_id != ""`
}
func (f *Flow) Run(ctx context.Context, input Input) error {
cur := f.StartID
steps := 0
for cur != "" {
if steps > 100 { // 防死循环
return ErrTooManySteps
}
steps++
node, ok := f.Nodes[cur]
if !ok {
return ErrUnknownNode(cur)
}
next, err := node.Exec(ctx, input)
if err != nil {
return err
}
cur = f.pickEdge(cur, input, next)
}
return nil
}这段代码本身写得不算差。问题在它落地之后。
小林给我讲过一个具体的下午。那天线上出了个问题:某个订单卡在”待分配”状态超过六小时。他、小周、还有社区团购那边的两个人,四个人查了三个半小时。
他们查了:
- YAML 配置里那条流的定义(六百多行)
Cond表达式里那个字符串(运行时求值,打断点打不到)- 每个 Node 的 Go 实现(散落在四个包里)
- Redis 里存的那个
Inputmap(里面的 key 是运行时动态塞进去的,谁塞的要看代码)
“三个半小时。“小林说,“如果它是普通的 Go 代码,我三十秒就能找到那个 if。”
他删掉引擎的那天,在 git 的 commit message 里写了一句话:
这个引擎解决了一个我们还没有的问题,同时制造了一个我们解决不了的问题。
第二版他做了完全相反的东西。他没有抽象业务流程,他把那六行横切的能力做成了 middleware 链:
// 第二版:只抽横切关注点,业务代码还是普通 Go 代码
package fulfillkit
import (
"context"
"time"
)
// Handler 是业务处理函数。业务方写的东西就是这么简单。
type Handler[Req, Resp any] func(ctx context.Context, req Req) (Resp, error)
// Middleware 包装一个 Handler。
// 注意签名的对称性:Middleware 拿到的和返回的都是同一个 Handler 类型,
// 所以它们可以任意顺序自由组合。
type Middleware[Req, Resp any] func(Handler[Req, Resp]) Handler[Req, Resp]
// Chain 把多个 Middleware 串起来,越靠前越外层。
// 这里刻意从后往前包,让调用方写的时候顺序符合直觉。
func Chain[Req, Resp any](h Handler[Req, Resp], mws ...Middleware[Req, Resp]) Handler[Req, Resp] {
for i := len(mws) - 1; i >= 0; i-- {
if mws[i] == nil {
continue
}
h = mws[i](h)
}
return h
}
// ---------- 下面是那六个真正重复的东西 ----------
// WithTimeout 统一超时。每个业务线的超时策略不同,但"必须有超时"是共同的。
func WithTimeout[Req, Resp any](d time.Duration) Middleware[Req, Resp] {
return func(next Handler[Req, Resp]) Handler[Req, Resp] {
return func(ctx context.Context, req Req) (Resp, error) {
ctx, cancel := context.WithTimeout(ctx, d)
defer cancel()
return next(ctx, req)
}
}
}
// WithIdempotency 幂等。key 从请求里提取,由业务方告诉我们在哪儿。
type IdemKeyer[Req any] func(Req) string
func WithIdempotency[Req, Resp any](store IdemStore, keyer IdemKeyer[Req], ttl time.Duration) Middleware[Req, Resp] {
return func(next Handler[Req, Resp]) Handler[Req, Resp] {
return func(ctx context.Context, req Req) (Resp, error) {
key := keyer(req)
if key == "" {
return next(ctx, req) // 没有幂等键就不做幂等,不强制
}
ok, err := store.Acquire(ctx, key, ttl)
if err != nil {
// 幂等组件挂了不能阻断业务,降级放行并打点。
// 这一条是踩过坑的:早期版本这里直接 return err,
// Redis 抖一下整个下单全挂。
metrics.Incr("idempotency.store_error")
return next(ctx, req)
}
if !ok {
var zero Resp
return zero, ErrDuplicateRequest
}
return next(ctx, req)
}
}
}
// WithRetry 重试。可重试与否由 apierr 决定,业务方不用自己判断。
func WithRetry[Req, Resp any](policy RetryPolicy) Middleware[Req, Resp] {
return func(next Handler[Req, Resp]) Handler[Req, Resp] {
return func(ctx context.Context, req Req) (Resp, error) {
return policy.Do(ctx, func(ctx context.Context) error {
_, err := next(ctx, req)
return err
})
}
}
}业务方的代码就变成这样:
// 社区团购的履约处理。所有业务代码就是这一个函数,
// 没有配置,没有 DSL,没有节点注册。
func handleGroupBuyFulfill(ctx context.Context, req FulfillReq) (FulfillResp, error) {
// 这里就是普通的、能打断点、能读、能被 IDE 跳转的 Go 代码
if req.PickupPointID == "" {
return FulfillResp{}, apierr.InvalidArg("pickup_point_id", "自提点不能为空")
}
warehouse, err := matchPickupPoint(ctx, req)
if err != nil {
return FulfillResp{}, apierr.Wrap(err, apierr.CodeUpstreamUnavail, "匹配自提点失败")
}
// ... 业务流程,按团购自己的逻辑写,不用迁就任何引擎
return FulfillResp{WarehouseID: warehouse.ID}, nil
}
// 装配:横切能力在这里统一挂上,一行一个,顺序清晰
var handler = fulfillkit.Chain(
handleGroupBuyFulfill,
fulfillkit.WithTimeout(3*time.Second),
fulfillkit.WithIdempotency(idemStore, func(r FulfillReq) string { return r.OrderID }, 24*time.Hour),
fulfillkit.WithRetry(retryPolicy),
fulfillkit.WithTracing("groupbuy.fulfill"),
fulfillkit.WithMetrics("groupbuy.fulfill"),
)这个版本上线之后,发生了一件小林没预料到的事。
四月份,企业购那条线的人主动来找他,问能不能用这个 Chain。
小林说可以,那个包里没有一行代码跟商城、团购、企业购有关。
企业购的同事接进去之后,自己加了两个 middleware:一个做合同仓的白名单校验,一个做发票信息的预填。这两个东西小林完全没想过。
“我当时心里有点复杂。“小林跟老陈说。
“复杂在哪儿?”
“我做了个东西,别人在上面加了我不认识的功能。“他说,“一半是高兴,一半是……觉得自己没用了。”
老陈笑了。
“这就是平台和框架的区别。“老陈说,“框架是你规定别人怎么写,平台是别人在你上面长东西。”
“你那个 FlowForge,是框架。你删掉它是对的。”
“那我做的这个 Chain,是平台?”
“一半。“老陈说,“它现在只是个库。什么时候三条业务线都在用,而且有人在用它做你没想过的事,它就是平台了。”
“那我该做什么?”
“什么都不做。“老陈说,“平台不是你建起来的,是你让它长起来的。你要做的,是别去挡它。“
老陈的追问
-
你现在想做的那个”平台”,是为第几条业务线做的?那条业务线现在存在吗?如果它不存在,你是在做平台,还是在给自己找事做?
-
你手上有没有一个”你三年前做的平台”,现在还在被使用?如果它已经死了,你还记得它当年是怎么立项的吗?
-
你的团队里,有没有人在用你的东西做你没设想过的事?如果一件都没有,说明你做的是框架,不是平台。你接受这个结果吗?
-
你能不能说出,你的平台”不做什么”?说不出来的平台,最后都会变成一个什么都想装的怪物。
-
如果公司明天砍掉你正在做的平台项目,你损失的到底是什么?是那套代码,还是那个能对外讲的故事?
生活思考题
题一:为什么很多”通用框架”,最后只剩下最初那一个项目在用?
大多数人会这么答: 因为通用的东西性能差、用着别扭,而且每个业务都有自己的特殊情况,通用框架兜不住。
这个答案为什么不够: 这个答案是事后总结。那些框架在立项的时候,也是信誓旦旦说”我们调研了所有业务线”的。问题出在调研这个动作本身。
更深的一层是什么: 你去调研三条业务线,你问的是”你们需要什么”。
而你得到的答案,永远是他们各自已经实现的东西的最大公约数的补集。换句话说,你听到的不是需求,是”他们现在的做法”。
这个信息有没有用?有。但它有一个致命偏差:它描述的是过去,不是未来。
业务会变。你在做框架的那半年里,那三条业务线也在往前走。等你做出来,他们已经不需要半年前那个东西了。于是他们会说”我们这边的情况比较特殊”。
更深的偏差在于:“通用”这个词诱惑力太大了。 做一个能解决三个问题的东西,和解决一个能覆盖三个场景的东西,听起来差不多,做起来差十倍。前者是复用,后者是抽象。抽象需要先见过足够多的样本,而三个样本远远不够。
真正通用的东西长什么样?你看 SQL、看 HTTP、看 Unix 的管道。它们的共同点是:它们都只定义了一个极小的、稳定的契约,然后允许上面长出无限的东西。 SQL 没规定你存什么,HTTP 没规定你的 body 长什么样,管道没规定你传什么文本。
一句话:通用性不来自”覆盖了多少场景”,而来自”放弃了对多少东西的控制”。
落到 Go 工程师身上: 判断你要不要抽,有个很土的办法:看那个东西的变化频率。
- 下游调用的超时策略:半年变一次。抽。
- 幂等键的规则:一年变一次。抽。
- 业务流程的状态机:一个月变两次。不抽。
- 补偿逻辑:跟着事故走,随时变。绝对不抽。
你抽的东西,变化频率必须显著低于使用它的业务。这个关系一旦反过来,你的框架就会变成瓶颈:业务线改一点东西,要来求你改框架。
还有个更实用的检验:你抽完之后,写一段”最小使用示例”。如果这段示例超过三十行,或者需要读一份文档才能看懂,那你的抽象层次选错了。
Go 在这件事上有个天然优势:它的类型系统不鼓励深继承。当你发现你需要一个 BaseFulfillService 的时候,那基本就是抽象错了的信号。
题二:为什么很多人买了”多功能”厨房电器,最后只用它其中一个功能?
大多数人会这么答: 因为其他功能做得不好呗。样样通样样松,不如买个专业的。
这个答案为什么不够: 它假设所有多功能产品都做不好。但现实中有的多功能产品是成功的:一台手机取代了相机、手电筒、MP3、导航仪。为什么手机行,而”带烧烤功能的微波炉”不行?
更深的一层是什么: 区别不在于”做了几个功能”,在于这些功能共享的是不是同一套核心能力。
手机取代的那些东西,共享的是同一套:一块屏幕 + 一个处理器 + 一个操作系统 + 网络。每加一个功能,边际成本极低,因为核心能力是现成的。
微波炉加烧烤功能,共享的只有”一个箱子”和”一块控制面板”。加热原理完全不同,要加一整套加热管、温控、耐高温内胆。边际成本几乎是重新做一台机器。
所以判断一个”多功能”产品行不行,看的不是功能数量,是:第 N 个功能,是靠复用已有的核心能力实现的,还是靠新增一套独立系统实现的?
更有趣的是:功能太多还会带来一个隐性成本,叫选择成本。你面对一台有十二个按钮的机器,每次用都要想一下按哪个。这个”想一下”很便宜,但它每次都发生。一年两百次,你就烦了。最后你只会用那个你闭着眼都能按的按钮。
落到 Go 工程师身上: 这条直接对应一种非常常见的设计冲动:给接口加方法。
你写了个 OrderStore,只有 Load 和 Save。然后有人说”我需要批量查”,你加了个 List。又有人说”我需要按状态统计”,你加了个 CountByStatus。半年后这个接口有十二个方法,而每个实现者都要实现这十二个。
你以为你在”增强复用”,其实你在制造一台十二按钮的微波炉。
具体判断方法:每加一个方法,问一句”这个方法是靠已有的方法组合出来的吗?”
GetByUserID不能用Load组合出来(要索引)。加,但加在另一个小接口上。SaveBatch能用Save组合出来(循环 + 可选的事务)。不加,写个 helper 函数。Exists能用Load组合出来。不加。
Go 的接口应该是小的、按需组合的。一个 12 方法的接口,应该拆成 4 个 3 方法的接口,让实现者只实现它需要的那几个。这是 Go 里最被低估的一条设计准则,也是”平台化”这件事在代码层面最小的映射。
最后一句:你自己也检查一下你给团队提供的那些”工具包”。如果一个包有 40 个导出函数,其中 30 个只有一个人用过,那它已经是一台十二按钮的微波炉了。
6.6 开源与影响力:不是作秀,是把自己放进更大的坐标系
Kevin 那句”做好了能对外讲”,在小林心里留了根刺。
三月底,他把那个被自己删掉的 FlowForge 重新看了一遍,想了想,还是放弃了。但他盯上了另一个东西:他们组里那个按 key 限流的组件。
这个组件是去年做社区团购的时候写的。当时团购那边搞秒杀,一个自提点一秒钟进来几千个请求,要按自提点 ID 限流。小林写了个 keylimiter,一百来行,用了快一年,没出过事。
他跟老陈说想把它开源出去。
“为什么?“老陈问。
“提升影响力。“小林说,“而且对公司技术品牌有好处,招聘的时候也能用。”
“还有呢?”
“还有……王总上次说,希望我们今年在行业里有点声音。”
“这些都是好处。“老陈说,“我问的是理由。”
“这不一样吗?”
“不一样。“老陈说,“好处是你得到什么,理由是你需要什么。”
小林愣了一下。
“我问你个问题。“老陈说,“你上一次因为一个陌生人的一句话,改了自己的设计,是什么时候?”
”……”
“想不起来?”
“想不起来。“小林说,“我们那个限流器,这一年就我一个人改过。大刘提过一次,说统计不准,我改了。”
“那就是没有。“
老陈家的辣酱
“我给你讲个我老婆的事。“老陈说。
老陈老婆每年秋天做一次辣酱。辣椒是从老家带来的那种小尖椒,配上蒜、姜、豆豉,用菜籽油炒,装进玻璃瓶里。
“做了十几年了。“老陈说,“我们家每年吃两瓶,剩下五六瓶,都送人。”
“送给谁?”
“邻居、朋友、我那几个老同事。“老陈说,“有一年,有个邻居吃完跟我说:‘陈老师,你这个辣酱可以拿去卖了。’”
“我老婆听了挺高兴。”
“第二年她做得更认真了,还专门买了好看的瓶子,贴了标签。”
“结果呢?”
“结果那年反馈特别多。“老陈笑了,“我给你数数。”
“三楼的老王说,太咸了。他老婆血压高,吃不了那么咸。”
“一楼的老李说,瓶子不好开,他手上有风湿,拧不动那个铁盖。”
“还有个同事说,这个辣酱开瓶之后放冰箱,两个星期上面就长一层白毛。”
“我老婆听完,气了一个星期。“老陈说,“她说’我送你们吃,你们还挑三拣四’。”
“后来呢?”
“后来她还是改了。“老陈说,“第二年盐少放了三分之一,换了那种一按就弹开的盖子,还把每瓶的量从 500 克改成 300 克,两星期吃完。”
“改完之后呢?”
“改完之后,那年送出去的辣酱,回购率是零差评。“老陈说,“而且有个事她没想到:她那个 300 克的小瓶装,现在我们还自己在用。因为以前那个大瓶,我们自己家也是放到长毛。”
老陈停了一下。
“小林,你想想,她那个’太咸’的问题,为什么十几年都没发现?”
“因为……你们家口味重。”
“对。“老陈说,“我们家一家三口都吃咸。在我们家的坐标系里,那个咸度是’正好’。”
“你把一罐辣酱送出家门的那一刻,你才知道它在别人家的饭桌上是什么水平。”
“那跟开源有什么关系?”
“你那个限流器,这一年没出过事。“老陈说,“但你有没有想过,它可能只是’在你家的饭桌上没出过事’?“
小林的反驳
“我不同意。“小林说。
“你说。”
“您这个比喻有个漏洞。“他说,“辣酱送出去,别人会吃。我一个内部限流器开源出去,没人用。”
“你怎么知道没人用?”
“我在 GitHub 上搜过类似的。“小林说,“按 key 限流的 Go 库,光是 star 过千的就有四五个。我这个开源出去,一年能有二十个 star 就不错了。”
“所以呢?”
“所以这不是’送辣酱’,这是’自己掏钱印了一堆名片发路人’。“小林说,“丢人。”
老陈看着他。
“小林,你刚才这句话里,有个词我听着很有意思。”
“哪个词?”
“丢人。”
”……”
“你怕的不是没人用。“老陈说,“你怕的是别人知道没人用。”
小林没说话。
“我问你:你现在在公司里,别人怎么评价你那个限流器?”
”……都说好用。”
“谁说的?”
“小周用过,说挺好。大刘说比之前那个省内存。”
“他们凭什么说好用?“老陈说,“小周是你带的,大刘是同事。他们俩说好用,你就信了。”
“你是站在一个所有评委都跟你熟的坐标系里,拿了一辈子的高分。”
老陈往前坐了坐。
“开源这件事最大的价值,不是输出,是输入。“他说,“你在公司里待了六年,你所有的技术方案,评价者都是认识你的人。认识你的人会给你留面子。”
“GitHub 上那个给你提 issue 的德国人,不认识你。他不会给你留面子。”
“没人用,不是丢人。没人用,是你在唯一一个不说客气话的市场里,拿到了一个诚实的报价。”
“那这个报价我怎么知道值不值?”
“你看 star 数你永远看不出来。“老陈说,“你看三件事:”
“第一,有没有人在 issue 里问了一个你没想到的问题。”
“第二,有没有人提 PR,哪怕最后没合。”
“第三,你自己团队的同事,在写新代码的时候,是不是更愿意用这个包的公开版本,而不是复制一份。”
“第三条为什么重要?”
“因为第三条说明,开源这个动作,反过来改进了你们内部的用法。“
Go 实例:一次接口最小化的手术
小林最后还是开源了。他跟我说,开源的过程比他想的难十倍,而且难的地方完全不在写代码。
他把内部版本的 keylimiter 打开,第一段就卡住了:
// 第一版:内部版本,2025 年写的
package keylimiter
import (
"sync"
"time"
"git.internal.com/platform/config" // 内部配置中心
"git.internal.com/platform/metrics" // 内部埋点
"git.internal.com/platform/logger" // 内部日志
)
type KeyLimiter struct {
mu sync.Mutex
limiters map[string]*bucket
rate int
burst int
}
func New(name string) *KeyLimiter {
// 直接读内部配置中心。这意味着:
// 1. 任何使用方都必须连上配置中心,单测跑不起来
// 2. 想改 rate 只能改配置,代码里没法传
// 3. 本地起服务,配置中心连不上就 panic
rate := config.GetInt("limiter." + name + ".rate")
burst := config.GetInt("limiter." + name + ".burst")
if rate <= 0 {
panic("limiter rate not configured: " + name) // 启动即崩
}
return &KeyLimiter{
limiters: make(map[string]*bucket),
rate: rate,
burst: burst,
}
}
func (l *KeyLimiter) Allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
b, ok := l.limiters[key]
if !ok {
b = newBucket(l.rate, l.burst)
l.limiters[key] = b
}
ok = b.allow(time.Now())
// 每条判定都埋点。内部用没问题,
// 开源出去等于强迫所有使用方接受我们的埋点体系。
metrics.Incr("limiter."+key, map[string]string{"allowed": fmt.Sprint(ok)})
// 内存里 map 无限增长,靠一个定时任务清理(下面这个函数没人调用过)
return ok
}他盯着这段代码看了十分钟,然后列了六个问题:
1. 依赖三个内部包 —— 外部没人能用
2. New() 里 panic —— 使用方连启动都启动不了
3. 全局一把锁 —— key 多的时候锁竞争严重
4. map 无限增长 —— 内存泄漏(那个清理函数从来没被调用过)
5. 强制埋点 —— 把我们的可观测体系塞给别人
6. 没有 example,没有 benchmark,没有版本
“这六条里,最让我汗颜的是第 4 条。“小林跟我说,“那个清理函数是我写的,我写完就忘了接。它在我自己家的饭桌上躺了一年,没人发现。”
开源版本他重写了一遍:
// 第二版:keylimiter v0.1.0
//
// 按 key 做独立令牌桶限流,分片加锁减少竞争,带惰性过期回收。
// 设计约束(写在了 README 最上面):
// - 零外部依赖,只用标准库
// - 不 panic,所有错误可恢复
// - 不做埋点,暴露 Stats() 让使用方自己接
// - v0.x 阶段 API 可能变,v1.0 之后保证向后兼容
package keylimiter
import (
"sync"
"sync/atomic"
"time"
)
// Limiter 按 key 限流。零值是无效的,必须用 New 构造。
// 它是并发安全的,可以被多个 goroutine 共享。
type Limiter struct {
rate float64 // 每秒补充的令牌数
burst int // 桶容量
ttl time.Duration // key 空闲多久后回收
shards []*shard
shardMask uint64
}
type shard struct {
mu sync.Mutex
buckets map[string]*bucket
lastSweep time.Time
}
type bucket struct {
tokens float64
last time.Time // 上次访问时间,用于惰性回收
}
// Option 配置可选项。所有可选项都有保守的默认值,
// 这样"最简单的用法"就是"安全的用法"。
type Option func(*Limiter)
// WithTTL 设置 key 的空闲回收时间,默认 10 分钟。
func WithTTL(d time.Duration) Option {
return func(l *Limiter) { l.ttl = d }
}
// WithShards 设置分片数,必须是 2 的幂。默认 64。
// 分片是为了把全局锁拆开:key 越多,收益越大。
func WithShards(n int) Option {
return func(l *Limiter) {
if n <= 0 || n&(n-1) != 0 {
return // 不是 2 的幂就忽略,用默认值。不 panic,也不报错。
}
l.shardMask = uint64(n - 1)
l.shards = make([]*shard, n)
for i := range l.shards {
l.shards[i] = &shard{buckets: make(map[string]*bucket)}
}
}
}
// New 创建一个限流器。rate 是每秒令牌数,burst 是桶容量。
// rate 必须为正;不合法时返回 error,绝不 panic。
func New(rate float64, burst int, opts ...Option) (*Limiter, error) {
if rate <= 0 {
return nil, ErrInvalidRate
}
if burst <= 0 {
burst = int(rate) // 一个合理猜测:默认允许一秒的量突发
if burst < 1 {
burst = 1
}
}
l := &Limiter{
rate: rate,
burst: burst,
ttl: 10 * time.Minute,
shardMask: 63,
shards: make([]*shard, 64),
}
for i := range l.shards {
l.shards[i] = &shard{buckets: make(map[string]*bucket)}
}
for _, opt := range opts {
opt(l)
}
return l, nil
}
// Allow 判断是否允许 key 的这次请求通过。
func (l *Limiter) Allow(key string) bool {
return l.allow(key, time.Now(), 1)
}
// AllowN 一次取 n 个令牌,用于批量请求按量限流。
func (l *Limiter) AllowN(key string, n int) bool {
return l.allow(key, time.Now(), n)
}
func (l *Limiter) allow(key string, now time.Time, n int) bool {
s := l.shards[l.shardOf(key)]
s.mu.Lock()
defer s.mu.Unlock()
b, ok := s.buckets[key]
if !ok {
b = &bucket{tokens: float64(l.burst), last: now}
s.buckets[key] = b
}
// 先按时间补充令牌。注意这里用 key 自己的 last,
// 而不是全局时间,避免时钟回拨导致令牌被扣成负数。
elapsed := now.Sub(b.last).Seconds()
if elapsed > 0 {
b.tokens += elapsed * l.rate
if b.tokens > float64(l.burst) {
b.tokens = float64(l.burst)
}
}
b.last = now
if b.tokens < float64(n) {
return false
}
b.tokens -= float64(n)
// 惰性回收:每 30 秒扫一次这个分片,清掉过期的 key。
// 不做全局 sweep,避免一次长停顿。
if now.Sub(s.lastSweep) > 30*time.Second {
s.lastSweep = now
go s.sweep(l.ttl) // 放到后台,不阻塞本次调用
}
return true
}
func (l *Limiter) shardOf(key string) uint64 {
// FNV-1a,够快,分布也够均匀
var h uint64 = 14695981039346656037
for i := 0; i < len(key); i++ {
h ^= uint64(key[i])
h *= 1099511628211
}
return h & l.shardMask
}
func (s *shard) sweep(ttl time.Duration) {
// 用原子标记避免并发 sweep 叠加
if !atomic.CompareAndSwapInt32(&s.sweeping, 0, 1) {
return
}
defer atomic.StoreInt32(&s.sweeping, 0)
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
for k, b := range s.buckets {
if now.Sub(b.last) > ttl {
delete(s.buckets, k)
}
}
}
// Stats 暴露内部状态,让使用方自己接埋点。
// 我们不替使用方决定怎么观测 —— 这是"不替别人做决定"的最小实践。
func (l *Limiter) Stats() Stats {
var total int
for _, s := range l.shards {
s.mu.Lock()
total += len(s.buckets)
s.mu.Unlock()
}
return Stats{Keys: total}
}
type Stats struct {
Keys int
}(shard.sweeping 字段在上面的结构体里加上 sweeping int32 即可,注意它必须跟 mu 一起保护或者在锁外原子操作,我在这里用的是原子操作。)
然后他补了 Example 测试。这一步是开源逼出来的,因为内部版本从来没人要求他写这个:
func ExampleLimiter_Allow() {
l, err := keylimiter.New(10, 20) // 每秒 10 个,桶容量 20
if err != nil {
log.Fatal(err)
}
// 前 20 个都能过(桶里一开始是满的)
passed := 0
for i := 0; i < 25; i++ {
if l.Allow("pickup-point-8823") {
passed++
}
}
fmt.Println(passed)
// Output: 20
}
func ExampleLimiter_AllowN() {
l, _ := keylimiter.New(100, 100)
// 一次批量请求要取 50 个令牌
fmt.Println(l.AllowN("batch-job", 50))
fmt.Println(l.AllowN("batch-job", 60)) // 桶里只剩 50,不够
// Output:
// true
// false
}开源出去两个月后的数据:
star: 34
fork: 3
issue: 7(其中 2 个是我自己提的)
PR: 1(没合)
下载的团队: 至少 4 个(从 issue 里看出来的)
七个 issue 里,有一条改变了他的设计。
那是一个德国人提的,用很简单的英语写的。大意是:你们的 bucket.last 用的是 time.Now(),在容器里如果发生时钟回拨(NTP 校时),elapsed 会变成负数,令牌会被扣没,导致一段时间内所有请求都被拒。
底下还有个人补充了一句:“K8s 节点上这个事不常见,但一旦发生,恢复时间是分钟级的。”
小林说他看完这个 issue,后背是凉的。
“我在公司里跑了一年多,一次没遇到。“他说,“因为我们的服务器时钟是对齐的。但一个陌生人,在一个我没想到的环境里,二十分钟就找到了我一个跑了三年的隐患。”
他后来把 time.Now() 换成了 time.Since 配单调时钟(runtime 的 nanotime 语义),并且加了个测试模拟时钟回拨。
“这个改动,你们公司内部用得上吗?”
“用得上。“小林说,“这个问题在公司内部也存在,只是概率低。”
“所以开源的第一个受益人是谁?”
“是我们自己。”
这一节背后的原则:开源是一次强制的接口最小化手术,而手术的第一个病人是你自己的团队。
你要把一个东西交给陌生人,你就必须回答一系列你从来不需要回答的问题:它的边界在哪儿?什么情况下它会坏?别人不用你的配置中心、你的日志、你的埋点,它还能跑吗?
这些问题的答案,在你内部用的时候全部是”无所谓”。它们之所以是”无所谓”,不是因为不重要,是因为你的团队替你承担了这些耦合的成本。
老陈的追问
-
你所有的技术方案,评价者里有没有一个不认识你的人?如果全是同事、下属、老板,你凭什么相信他们的好评?
-
你上一次在技术社区里发一个东西(issue、PR、文章),是什么时候?如果你三年没发过,你凭什么说自己在”技术圈”里?
-
如果你的代码明天被放到 GitHub 上,你最不想被人看到的是哪一段?那一段你为什么还没改?
-
你对外讲的东西,有多少是”我们做了什么”,有多少是”我们踩了什么坑”?前者是广告,后者才是技术影响力。你想被记住哪一种?
-
你有没有能力用三句话,向一个不认识你的同行,说清楚你们团队解决了一个什么别人也会遇到的问题?说不出来,说明你还没想过这件事对别人的意义。
生活思考题
题一:为什么很多人发了朋友圈之后,会反复去看有多少个赞?
大多数人会这么答: 虚荣呗。想被认可,想刷存在感。
这个答案为什么不够: “虚荣”是个标签,贴上去就完了,你什么都没理解。而且它解释不了一个现象:有些人在工作里非常理性,也照样会去看赞。他们不是不知道这没意义,他们知道,但还是会看。
更深的一层是什么: 点赞数之所以让人上瘾,不是因为它代表”被认可”,是因为它把一个模糊的东西变成了数字。
“朋友们觉得我过得好不好”,这个问题你本来是没法回答的。它是模糊的、主观的、不可比的。点赞数把它变成了一个具体的、可比较的、能追踪的数字。
人的大脑有个弱点:只要一个东西被量化了,我们就会开始为这个数字 optimize,而忘了它原来要测的是什么。
于是你会为了点赞数去挑照片、挑发布时间、写更”容易获赞”的文案。你做的事情有没有变好?可能有。你过的日子有没有变好?不一定。
更深的那一层:你之所以需要这个数字,是因为你没有一个内部标准。 如果你自己很清楚”我今年过得怎么样”,你就不需要三十七个赞来告诉你。
落到 Go 工程师身上: 这一条对技术人的杀伤力特别大,因为我们天然喜欢量化。
- 你统计 star 数,就会开始写”容易获 star”的项目(脚手架、awesome 列表、面试题合集),而不是解决真问题的项目。
- 你统计文章阅读量,就会开始写”入门教程”,而不是写你真正踩过的那个坑。
- 你统计分享会的评分,就会开始加段子,减少干货。
- 你统计团队的代码量,就会有人开始刷。
怎么办?不是说量化不好,是说你要给自己配一组内部标准,跟外部数字对冲。
具体的三个内部标准,我建议每一个做技术的人都定下来:
- 今年我解决了几个”别人也会遇到”的问题? 判据:能不能写下三句话,让一个同行看懂你解决了什么。
- 今年我有没有改变过自己的一个技术判断? 判据:有没有一个你年初坚信、年尾放弃的观点。一个都没有,说明你这一年没学到东西。
- 今年有没有一个不认识我的人,因为我的东西省了时间? 判据:一条 issue、一封邮件、一次转述都算。
这三个数,一个都不好刷。
题二:为什么自家孩子的问题,别人一眼就看出来了,你却看不见?
大多数人会这么答: 当局者迷旁观者清呗。而且自己看自己孩子带着滤镜,怎么都好看。
这个答案为什么不够: “滤镜”这个词暗示问题是”我们看不见缺点”。但很多时候,问题是我们根本不认为那是缺点。
举个具体的:有个朋友的孩子,吃饭的时候一定要看平板。他爸妈觉得这没什么,“能吃进去就行”。直到有次去别人家做客,那个孩子坐在餐桌前,没有平板,一口饭都不吃。他爸妈才发现这不是习惯问题,是个问题。
这个例子里,父母看不见,不是因为滤镜,是因为在他们家的环境里,那个行为从来没有造成过任何后果。没有后果,就没有信息。
更深的一层是什么: 任何一个系统,在它自己的环境里运行久了,都会产生一个现象:环境和系统互相适配,最后变得无法互相检验。
你的代码在你的基础设施上跑了三年,它和你的监控、你的部署、你的网络、你的时区、你的数据量,已经互相磨合到了一个稳定状态。所有可能导致问题的边缘情况,都被环境悄悄消化掉了。
这不代表你的代码是对的。这只代表你的代码和你的环境是一对。
换一个环境,那些被消化掉的问题会一起冒出来。
这就是为什么”外人一眼就能看出来”。外人不是更聪明,外人是在不同的环境里跑过你的东西。
落到 Go 工程师身上: 这条对应一个非常具体、非常便宜的动作:把你的代码扔到一个你不熟悉的环境里跑一次。
具体到 Go:
- 你的服务一直在 amd64 的 Linux 容器里跑,试一次 arm64。你会惊讶地发现有多少地方对字长和字节序有隐含假设。
- 你的服务时钟一直是对齐的,试一次在代码里注入时钟回拨(
time.Now()的封装里加个 offset)。看看你的限流器、你的缓存过期、你的定时任务会怎样。 - 你的数据量一直是十万级,造一份千万级的数据跑一次。你会发现那个
for循环里的append有多贵。 - 你的依赖一直很快,用
toxiproxy给它加 500 毫秒延迟跑一次。你会发现你的超时设得有多么乐观。 - 你一直在同一个 k8s 集群里,把你的包
go build到一个完全没有内部依赖的干净环境里跑一次。你会发现你以为”独立的”那个模块,偷偷引入了多少内部耦合。
这些动作都不难,加起来可能就是两天。但它们能提供给你的信息量,超过你内部一年的稳定运行。
最后一句:开源只是”换环境”这个动作最彻底的一种形式。 你不一定非要开源,但你一定要定期把你的东西,扔到一个没有人会给你留面子的地方去。
6.7 判断力的传帮带:怎么教那些”教不会”的东西
四月初,小赵的离职日期定了:四月三十号。
小林做了个安排:让小周接对账。四月份一个月,小赵做,小周在旁边看;五月开始,小周做,小赵远程答疑问(小赵答应了,人走了之后一个月内有问题还可以问他)。
四月十二号下午,小周提了个 PR。
那个 PR 是给对账任务加了个本地缓存,缓存”商户 ID 到结算规则”的映射,大概三万条。代码里他用了 sync.Map。
小林看到那一行的时候,手指已经放到键盘上了。他打了一行字:
这里别用 sync.Map,用 map + RWMutex。
然后他删掉了。
他改成:
这两个方案你都写一下,跑个 benchmark,把结果贴到 PR 里。
小周在工位上转过来看他,表情有点懵。
“林哥,你直接告诉我用哪个不行吗?”
“不行。“小林说,“你跑完再提。“
那一下午,和三十秒
小周跑了大概三个小时。
第一版 benchmark 结果他贴在 PR 里:sync.Map 比 map+RWMutex 快 3.2 倍。
小林看了一眼,问了一句:“你这个 benchmark 的读写比是多少?”
“啊?”
“你读了多少次,写了多少次?”
“我写的循环里,读一百次,写一次。”
“为什么是一百比一?”
“我看网上那些对比文章都是这么写的。”
小林没评价,又问:“我们这个缓存,实际是读多还是写多?”
小周愣在那儿。
“我去查一下。”
他查了大概四十分钟。回来的时候表情跟之前不一样了。
“林哥,我们这个是写多。“他说,“三万条规则,每天凌晨全量刷新一次,白天只有读。但是刷新的那一下,是三万次写。”
“那平均下来呢?”
“平均下来读远多于写。“小周说,“但是刷新那三分钟里,全是写。”
“那你的结论变了没有?”
“变了。“小周说,“我重新跑了三组。”
第二版 benchmark 结果:
场景 sync.Map map+RWMutex 分片 map(64)
读多写少 100:1 18.4 ns/op 61.2 ns/op 9.7 ns/op
读写均衡 10:1 42.1 ns/op 68.5 ns/op 15.3 ns/op
全写(刷新场景) 312 ns/op 89.6 ns/op 23.8 ns/op
“全写那个场景下,sync.Map 比 map+RWMutex 慢 3.5 倍。“小周说,“而我们现在的代码,正好是那个场景。”
“那你的 PR 要改吗?”
“要改。“小周说,“而且我觉得应该改成分片 map,全写场景下比 RWMutex 还快 3.7 倍。”
“行。“小林说,“改完再提。”
小周转过身走了两步,又回过头。
“林哥,我有个问题。”
“你说。”
“你一开始就知道答案是分片 map 吧?”
“我知道 sync.Map 在写多的场景下不行。“小林说,“分片 map 我没想过。”
“那你为什么不说?”
小林想了一下。
“因为我要是说了,你就只会记住’这里用分片 map’。“他说,“你跑这一下午,你记住的是’** benchmark 的读写比会决定结论**’。”
“这句话,我三十秒就能告诉你。但你三十秒听到的东西,跟你跑三小时知道的东西,不是一个东西。”
小周点点头,走了。
老陈的三个问题
“你做得对。“老陈说。
“我知道我做对了。“小林说,“但我心里还是有个声音在说:我花了一下午,直接告诉他只要三十秒。这一下午我本来能干三件事。”
“这个账你怎么算的?”
“一下午等于三十秒乘以三件事。”
“那我换个算法。“老陈说,“你这一下午,买的是’小周下次遇到并发选型的时候,不用来问你’。”
“一次就够了吗?”
“当然不够。“老陈说,“但是你想想,你以前的方式:你三十秒给一个答案,他记住了,下次遇到别的选型,他还是来问你。你要花多少个三十秒?”
”……一辈子。”
“对。“老陈说,“三十秒给他一个结论,一下午给他一个判断。这两个的差别,不是效率差别,是复利差别。”
“那我具体该怎么做?我总不能每次都说’你自己去查’吧,那也太装了。”
老陈伸出三根手指。
“第一,给场景,不给答案。”
“你把问题重新描述一遍,描述成一个可以被实验回答的问题。‘用 sync.Map 还是 RWMutex’不是个好问题,‘在读写比 100:1、10:1、全写这三种场景下,哪个更快’才是。”
“第二,给反馈,不给评价。”
“他跑完了,你别说’对了’或者’不对’。你要问’你试过 X 吗’、‘这个数在 Y 的情况下会怎样’。让他自己往下走一层。”
“第三,闭嘴。”
“这条最难。“老陈说,“他做到百分之八十的时候,你会特别想补那最后百分之二十。你不能补。你补了,那百分之二十就永远是你的。”
“那他做错了呢?”
“错在什么时候是允许的?“老陈反问。
”……代价小的时候?”
“对。“老陈说,“所以你还得做一件事:设计低风险的失败。“
老陈岳母的霉干菜焖肉
老陈的岳母八十一,绍兴人,做了一辈子饭。
她有道菜是霉干菜焖肉,老陈老婆从嫁过去就开始学,学了二十多年,做不出来。
“不是难吃。“老陈说,“能吃,但是不是那个味道。”
“差在哪儿?”
“说不上来。“老陈说,“我老婆每次问她妈,老太太就说三句话:肉要选五花,霉干菜要泡够,火候要到。”
“这三句我都背下来了。“老陈说,“我照着做,还是不对。”
“去年过年,我们回绍兴。年二十九那天,我老婆在厨房做这道菜,她妈坐在旁边的小板凳上看着。”
“我老婆做到一半,问:‘妈,现在是不是该加水了?’”
“老太太说什么?”
“老太太说:‘你自己尝一口。’”
“我老婆愣了。她说’妈我还没做好呢,尝什么’。”
“老太太说:‘你不尝,你永远不知道现在是什么味道。’”
老陈说,那天他老婆做了三件事:
第一,她尝了一口,说”有点淡”。加了一小勺盐。
第二,过了十分钟,她又尝了一口,说”现在有点咸了,但是肉还没烂”。她加了半碗水。
第三,起锅前她尝了第三口,说”这个味道……跟我妈做的不一样,但是能吃”。
“那天晚上,那盘肉吃光了。”
“老太太说什么了吗?”
“她吃了一口,说了一句话。“老陈说,“她说:‘你现在知道该怎么做了。’”
“就这一句?”
“就这一句。”
老陈停了一下。
“小林,你想想,这二十多年,老太太一直在’教’,我老婆一直在’学’。为什么二十多年没学会,二十九那天一下午就学会了?”
”……因为她自己尝了。”
“对。“老陈说,“老太太教了二十年的’火候’,是结论。那天她让我老婆做的事,是把判断权交出去。”
“火候这个东西,你能教吗?”
“不能。“小林说,“因为你不知道别人的锅、别人的火、别人的肉是多少。”
“对。“老陈说,“所有’教不会’的东西,都有一个共同特征:它依赖的上下文,是学习者自己的,不是你的。“
小林的反驳
“我又要反驳了。“小林说。
“你说。”
“您这个逻辑,推到极端就是:所有事都让他们自己试。“他说,“但是有些事,试一次的代价是公司赔三十万。”
“对账那块就是。三年前有个补偿逻辑写错了,多赔了十几万,我背了个 C。”
“这种事,我能让小周去试吗?”
“不能。“老陈说,“所以我说要设计低风险的失败,不是让他们去真实环境里失败。”
“怎么设计?”
“我给你四个具体的做法,你听听有没有用。”
“第一,把真实事故变成沙盘。”
老陈说:“你三年前赔了十几万那个 case,你还记得当时的数据吗?”
“记得一部分。”
“你把它做成一个练习:给小周一个数据库快照,一份当时的日志,让他自己查,查出来之后跟他说’你查对了,现在告诉我如果是你,你会怎么处理’。”
“他处理错了呢?”
“错就错了。“老陈说,“他在沙盘里错一次,赔的是零。他在生产环境里错一次,赔的是十几万。你要的就是让他先在沙盘里错够。”
“第二,让他做”副驾驶”。”
“就是我在旁边看着?”
“不是看着。“老陈说,“是你做,他预判。你处理一个线上问题的时候,让他在旁边,每一步之前先说’我猜你下一步要干什么’。”
“这个好在哪儿?”
“好在他会暴露自己的判断路径。“老陈说,“他说’我猜你要先重启’,你说’不,我先看下 QPS’。这一句话,比你讲一小时排查方法论都有用。”
“第三,让判断被写下来,然后被检验。”
老陈说:“任何一次重要的技术决策,让做决定的那个人写三行:我选了 A,我放弃 B 和 C,我认为 A 会在什么情况下失效。”
“半年之后,回来看这三行。”
“为什么要半年?”
“因为半年足够让一些假设不成立了。“老陈说,“判断力这个东西,没有反馈就长不出来。而普通的反馈周期太长,你必须人为地给它装一个’回看’的机制。”
“第四,你自己要当众犯一次错,并且当众改正。”
这条小林没想到。
“为什么?”
“因为你要让他们知道,判断错了是可以的。“老陈说,“一个从来不犯错的 leader,会培养出一群不敢做判断的人。”
“他们不是不敢,他们是觉得’反正林哥都是对的,我判断了也没意义’。”
“你上次当着他们的面承认自己判断错了,是什么时候?”
小林想了很久。
”……想不起来。“
Go 实例:把一次判断,外化成一个可复现的过程
小林后来把那次 sync.Map 的事,做成了一个东西,放在组里的仓库里。
他说他做这个东西的灵感,来自老陈那句”让判断被写下来,然后被检验”。
他建了个目录,叫 decisions/。每一份文件是一张”决策卡片”,长这样:
// decisions/0007-concurrent-map-selection_test.go
//
// 决策卡片 0007:并发 map 的选型
// 决策人:周XX(2026-04-12)
// 复核人:林XX
// 状态:生效中
// 下次复核:2026-10-12
//
// 【问题】对账任务的"商户ID -> 结算规则"缓存(约 3 万条),
// 用什么并发结构?
//
// 【结论】使用分片 map(64 分片 + 每片一把 RWMutex)。
// 不要使用 sync.Map。
//
// 【失效条件】(满足任意一条,本卡片必须复核)
// 1. 缓存条目数降到 1000 以下 —— 分片开销不划算
// 2. 刷新方式从"全量覆盖"改成"增量更新" —— 写压力下降,结论可能反转
// 3. Go 运行时对 sync.Map 的实现有重大改动
// 4. 出现新的、更合适的第三方库
//
// 【怎么验证】go test -bench=BenchmarkMapChoice -benchtime=3s ./decisions/
package decisions
import (
"fmt"
"strconv"
"sync"
"testing"
)
// ---------- 三种候选实现 ----------
type shardedMap struct {
shards []*shardT
mask uint64
}
type shardT struct {
mu sync.RWMutex
m map[string]int
}
func newSharded(n int) *shardedMap {
s := &shardedMap{mask: uint64(n - 1), shards: make([]*shardT, n)}
for i := range s.shards {
s.shards[i] = &shardT{m: make(map[string]int)}
}
return s
}
func (s *shardedMap) shardOf(k string) uint64 {
var h uint64 = 14695981039346656037
for i := 0; i < len(k); i++ {
h ^= uint64(k[i])
h *= 1099511628211
}
return h & s.mask
}
func (s *shardedMap) Load(k string) (int, bool) {
sh := s.shards[s.shardOf(k)]
sh.mu.RLock()
defer sh.mu.RUnlock()
v, ok := sh.m[k]
return v, ok
}
func (s *shardedMap) Store(k string, v int) {
sh := s.shards[s.shardOf(k)]
sh.mu.Lock()
defer sh.mu.Unlock()
sh.m[k] = v
}
// ---------- benchmark:三种读写比 ----------
// ratios 是这个决策的核心变量。
// 注意:结论完全依赖这个变量。脱离读写比谈"谁更快"是没有意义的,
// 这正是这张卡片存在的理由。
var ratios = []struct {
name string
readEvery int // 每 readEvery 次读,插入 1 次写
}{
{"read_heavy_100_1", 100},
{"balanced_10_1", 10},
{"write_heavy_1_1", 1},
}
const keySpace = 30000 // 跟线上缓存条目数保持一致
func BenchmarkMapChoice(b *testing.B) {
for _, r := range ratios {
b.Run(r.name, func(b *testing.B) {
b.Run("sync.Map", func(b *testing.B) {
var m sync.Map
for i := 0; i < keySpace; i++ {
m.Store(strconv.Itoa(i), i)
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
k := strconv.Itoa(i % keySpace)
if i%r.readEvery == 0 {
m.Store(k, i)
} else {
_, _ = m.Load(k)
}
i++
}
})
})
b.Run("map+RWMutex", func(b *testing.B) {
var mu sync.RWMutex
m := make(map[string]int, keySpace)
for i := 0; i < keySpace; i++ {
m[strconv.Itoa(i)] = i
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
k := strconv.Itoa(i % keySpace)
if i%r.readEvery == 0 {
mu.Lock()
m[k] = i
mu.Unlock()
} else {
mu.RLock()
_, _ = m[k]
mu.RUnlock()
}
i++
}
})
})
b.Run("sharded64", func(b *testing.B) {
s := newSharded(64)
for i := 0; i < keySpace; i++ {
s.Store(strconv.Itoa(i), i)
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
k := strconv.Itoa(i % keySpace)
if i%r.readEvery == 0 {
s.Store(k, i)
} else {
_, _ = s.Load(k)
}
i++
}
})
})
})
}
}
// TestPrintDecisionTable 不是测试,是个"报告生成器"。
// 跑 go test -run TestPrintDecisionTable -v 会把三种场景的对比打出来,
// 直接粘进决策卡片的【本次复核结果】里。
// 这样做的原因是:让"复核"这个动作的成本低到接近零。
func TestPrintDecisionTable(t *testing.T) {
if testing.Short() {
t.Skip("需要跑 benchmark,-short 模式跳过")
}
res := testing.Benchmark(func(b *testing.B) {
// 复用 BenchmarkMapChoice 的逻辑,收集结果
BenchmarkMapChoice(b)
})
fmt.Println("场景 ns/op 分配")
fmt.Println(res.String())
for _, m := range res.MemString() {
fmt.Println(m)
}
}这张卡片上线之后,小林做了三件事:
第一,他把”写决策卡片”变成了组里的规矩:任何涉及性能、并发、存储选型的技术决策,都要写一张。 不是所有决策,只是这三类。
第二,他在日历上设了个半年的提醒,专门用来复核这些卡片。
第三,他做了件他自己都没想到的事:他把决策权交给了小周,署名也写小周。
“我当时手痒。“小林说,“那个 benchmark 我想自己写,我知道怎么写更漂亮。但我忍住了。”
“忍得难受吗?”
“难受。“小林说,“他第一版跑出来的结果,读多写少那个场景下数据差了一位数,因为他 b.ResetTimer() 放错位置了。我看了一眼,特别想指出来。”
“你忍住了?”
“忍了两天。“小林说,“第三天他自己发现了,跑来问我’为什么这个数这么奇怪’。”
“你说了吗?”
“我说’你看看 ResetTimer 那一行的位置’。”
老陈笑了。
“你看,你自己其实知道。“
老陈的追问
-
你带的团队里,有几次技术决策是别人做的、署别人的名、你只复核?如果这个数是零,你的团队在做判断这件事上是零。
-
你上一次在同事面前说”这个我不懂,你讲给我听”,是什么时候?你还记得当时的感受吗?
-
你有没有一个机制,能让你在半年后回看自己今天的判断?如果没有,你的判断力靠什么长?
-
你带的人做错了事,你的第一反应是”我早就说过”,还是”我们一起看看当时哪个环节没想到”?这两种反应,会培养出两种完全不同的团队。
-
如果明天你失忆了,你的团队还能做技术决策吗?他们依据的是什么?是你留在代码里的东西,还是他们自己脑子里的东西?
生活思考题
题一:为什么你爸妈讲了一百遍的道理,你还是得自己撞一次才信?
大多数人会这么答:
因为他们讲的是抽象的,你自己撞的是具体的。抽象的没有痛感,具体的有。
这个答案为什么不够: 它只说了”为什么第一遍没用”,没说”为什么撞了那一次就有用”。
你想想:你撞的那次,你并没有获得任何新信息。你妈说的”别跟那种人合伙”,你撞了之后,你说的话跟她一模一样。信息是一样的,为什么效果差这么多?
更深的一层是什么: 因为你妈给你的是结论,而你自己撞出来的,是一套可检索的记忆结构。
结论是孤立的。它存在你脑子里,像一张没有索引的纸条。你遇到事情的时候,你根本想不起来去调它。
你自己撞出来的那个东西不一样。它是跟具体的场景、具体的情绪、具体的后果绑在一起的。你记得那个下午,记得那个人的表情,记得你银行卡里少掉的那个数字。下次遇到类似的场景,这一整套记忆会自动弹出来。
它不是被”调用”的,是被”触发”的。
更深一层:你妈讲的那个道理,你其实从来没”不信”过。你信,但你的信是二手的。二手的知识没有行动力。
所以传帮带的本质不是传递信息,是制造经历。
落到 Go 工程师身上: 你回想一下你自己是怎么学会”加超时”的。
也许有人跟你说过一百遍。但你真正学会的那一刻,大概率是你自己被一个没设超时的调用坑过一次:那个调用了下游一个 hang 住的服务,你的 goroutine 全挂住了,你的服务内存飙到 90%,大刘半夜把你叫起来。
从那天起,你写任何网络调用都会加超时。不是因为你记住了规定,是因为你的身体记住了那个凌晨三点。
所以,当你想让别人学会一件事的时候,问自己:我怎么让他”撞”一次,同时撞的代价是我能承受的?
具体到 Go 团队,几个能落地的做法:
- 把真实事故的脱敏数据做成一个练习。“这是当时的 pprof,你看看问题在哪儿。” 这比讲一小时 pprof 有用。
- 代码评审时,别给结论。给一个能验证结论的实验:“这两个写法你都写一遍,跑个 benchmark,结果贴上来。”
- 在新人入职的第一周,让他故意把一个服务搞崩一次(在测试环境),然后自己修。这一次的记忆,能顶半年。
- 复盘会上,让当事人讲”我当时是怎么想的”,而不是让领导讲”他错在哪儿”。前者能触发,后者只能评判。
题二:为什么驾校教练教不会你”这个车距能不能过去”?
大多数人会这么答: 因为这个要靠经验,练多了就有了。教练只能教基本操作,感觉这东西教不了。
这个答案为什么不够: “练多了就有了”这句话,是废话。它没告诉你练什么、怎么练、练多久。而且它解释不了一个现象:有的人练了一百个小时还是停不好车,有的人二十个小时就很溜。
更深的一层是什么: 教练教不会你的那个东西,本质上是一个模式识别问题:你需要在极短的时间内,综合车位长度、车速、方向盘角度、后视镜里的画面,做出一个判断。
模式识别能力的获取,有一个必要条件:必须有即时、准确、无损失的反馈。
你在驾校练倒车入库,教练在旁边说”打死”、“回半圈”、“停”。这个反馈是即时的,也是准确的,但它替代了你的判断。你是在执行指令,不是在做判断。你练了一百次”执行指令”,所以你拿到驾照之后,在没有教练喊的时候就不会了。
真正有效的练习长什么样?是你在空的停车场里,自己倒,倒歪了,下车看一眼,记住”刚才那个角度差了大概这么多”,上车再倒一次。
这个过程里,反馈是你自己获取的(下车看),代价是零(空停车场),而且你自己做了一次完整的判断。
所以教练真正有用的三个动作是:
- 把你带到一个安全的、空的、没有后果的场地。
- 让你自己试,他不说话。
- 你卡住超过三分钟,他才说一句,而且说的是”你下车看看左后轮离马路牙子还有多远”,不是”往左打半圈”。
落到 Go 工程师身上: 这一条对”带人”这件事的启示非常直接,而且反直觉:
大部分 mentor 的问题是反馈太多、太快、太精确。
你看着新人写代码,他写了三行,你指出两个问题。他改了,你再指出一个。他很感激你,他觉得学到了很多。
但他学到的是”林哥会告诉我哪里不对”。他没有学到”我自己怎么发现问题”。
具体能做的三件事:
第一,把你的反馈延迟一小时。看到问题了,先不评论,等他提了 PR 再说(或者等一小时)。你会惊讶地发现,一半的问题他自己就发现了。
第二,反馈的形式从”这里错了”改成”这里的结果跟你想的一样吗”。前者是评价,后者是让他自己去获取反馈。
第三,最狠的一条:给他一个”空停车场”。 具体说,就是一个不影响线上、没有 deadline、不会被 review 打回的小项目。明确告诉他:“这个东西上线不了也没关系,你随便试。” 你会看到他在那儿成长的速率,是正常项目里的三倍。
最后加一句:这个”空停车场”的成本,是你自己的焦虑。你得忍住不去问他”搞得怎么样了”。
6.8 你最大的产出,是那些你没参与的决策
四月三十号,小赵走了。
他最后一天下班的时候,小林送他到电梯口。电梯来了,小赵进去,回头说了句”林哥,谢了”。
小林说”到了新地方好好干”。
门关上。
两个月之后
六月底的季度复盘会。王总翻着数据,点到履约那一块。
“事故数,去年同期 7 次,今年 2 次。“王总说,“需求平均交付周期,去年 9 天,今年 5.2 天。值班群里一天平均响 3.4 次,去年是 11 次。”
“这块现在挺稳的。“他说。
小林点点头。
王总合上笔记本,随口说了一句:
“小林你现在在这儿,好像没什么事干了?”
会场上有人笑了。
小林也笑了。
那天晚上他在公司楼下的便利店门口站了十分钟,买了罐啤酒,坐在马路牙子上喝完了。
他给老陈打电话,把这句话复述了一遍。
“我知道他是随口说的。“小林说,“但是……”
“但是你心里难受。”
“嗯。”
“难受在哪儿?”
“我这一年,从去年十月到现在,九个月。“小林说,“我把调度器重构成了中间件,我推了错误处理规范,我做了那个限流器,我写了那条 lint,我让小周接了对账,我把赵鹏送走了。”
“然后我老板说,我好像没什么事干了。”
老陈在电话那头没说话。
“老陈,我问你个事。“小林说,“你说我这九个月,到底该算成功还是算失败?“
老陈的停车位
“我给你讲个我自己的事。“老陈说。
老陈原来住在一个老小区,九十年代末的房子,八十多户。他三十五岁那年住进去,第二年进了业委会,干过一届,两年。
“那个小区有个老大难问题:停车位。”
“多少个车位?”
“三十二个车位,八十六户。“老陈说,“而且车位的位置不一样,有的一开门就是楼梯口,有的要走八十米。”
“原来是怎么分的?”
“原来是谁先占谁停。“老陈说,“这就会有矛盾。下班晚的人回来没地方,就停在别人门口,堵着人家的车。三天两头吵架。”
“我在业委会那两年,主要就干了这一件事。“老陈说,“我们定了个规矩。”
“什么规矩?”
“三十二条:固定车位按入住时间轮候,五年重排一次。重排的时候,位置按轮候顺序自己挑。”
“就这么简单?”
“就这么简单,一共两句话。“老陈说,“但是为了这两句话,我们开了十一次会。”
“吵什么?”
“吵’入住时间’怎么算。买房的时间还是搬进来的时间?装修了一年没住算不算?租客算不算?二手房业主的时间是重新算还是继承?”
“最后怎么定的?”
“以房产证上的登记日期为准,租客不参与固定车位分配,二手房业主继承原业主的排队位置。”
老陈说,这条规矩通过那天,是二零一一年三月。
“我二零一八年搬走的。“老陈说,“去年我回去过一次,找老同事办事。”
“你看了车位?”
“我特意绕过去看了一眼。“老陈说,“车位还是那三十二个,划的线重新刷过,边上立了个牌子,写着那两句话。”
“我问门口的保安:‘这个规矩是谁定的?’”
“保安怎么说?”
“保安说:‘这个规矩一直有的。’”
老陈在电话里笑了一下。
“小林,那条规矩我定的,我清楚得很。那一届业委会五个人,其中三个已经搬走了,一个去世了。现在那个小区里,可能一个知道这件事的人都没有。”
“那您觉得亏吗?”
“我第一次回去看的时候,心里是有点不是滋味的。“老陈说,“我在那儿站了一会儿,有个新搬来的业主停完车下来,看了一眼那个牌子,掏手机拍了一张。”
“他拍那个牌子干什么?”
“我猜是发到业主群里问’我现在排到什么时候’。“老陈说,“他拍的时候,我就站在三米外。他没看我一眼。”
”……”
“我在那儿站了大概两分钟,突然就不难受了。”
“为什么?”
“因为我想明白一件事。“老陈说,“那条规矩,正在替我干我八年前想干的事。而且它干得比我在的时候还好。”
小林在马路牙子上坐着,没说话。
“小林,你刚才问我,你这一年算成功还是算失败。“老陈说,“我问你个别的:”
“这九个月里,有多少个技术决策,是你没参与,但是按你的想法做出来的?“
小林数了三天
这个问题小林数了三天。他列了个清单,最后留下三条。
第一条:五月十九号,新来的实习生写的一个服务。
那个实习生 P4,来了三周,写了个简单的库存查询服务。小林没 review 他的代码,是组里一个 P6 看的。
小林后来看了一眼那个服务,发现它的 HTTP client 有超时、有重试预算、有 trace 注入、有熔断。
这些东西,他去年十月推的那套 svcclient 包里全有,脚手架默认就生成好了。
“他自己知道为什么要这么写吗?“老陈问。
“不知道。“小林说,“他大概连 svcclient 是什么都不清楚,他就是照着脚手架填了业务逻辑。”
“那这算你的产出吗?”
”……算吧。”
“为什么算?”
“因为如果我不做那个包,他写出来的第一行代码就是 http.DefaultClient。“小林说,“那玩意儿没有超时。”
第二条:六月四号,小周在评审别的组的代码。
那天有个跨部门的评审,是营销那边的服务要调履约的接口。小周代表履约去。
会上小周提了个意见:你们这个接口,错误码不能返回 INTERNAL_ERROR,得给具体的业务码,我们这边要用来判断要不要重试。
营销那边的人说”我们一直是这么返回的”。
小周说了一句:“这个是我们组的规范,你们不按这个来,我们这边做不了重试策略。”
“你当时在场吗?”
“我不在。“小林说,“我是在会议纪要里看到的。”
“小周说这话的时候,有没有提到你?”
“没有。”
“那这算你的产出吗?”
小林想了很久。
“算。“他说,“因为两年前,这句话是我在会上说的。现在是小周在说。”
“而且他说得比我当年说得更好。“小林说,“我当年说的是’这个不符合规范’,他说的是’你们不按这个来,我们这边做不了重试策略’。”
“他给的是后果,不是规矩。”
第三条:四月,企业购那条线加的两个 middleware。
合同仓白名单校验、发票信息预填。这两个小林完全没想过,也没参与。
“这条算吗?“老陈问,“你连想都没想过。”
“算。“小林说,“因为那两个 middleware 能挂上去,是因为我那个 Chain 的签名是对称的。”
“如果签名不对称呢?”
“那他们就得改我的包,或者自己 fork 一份。”
“他们没有 fork。“老陈说,“这就是你的产出。你定的那个形状,让别人能在里面长东西,而且长出来的东西你不认识。“
小林的那一关
列完这三条,小林还是过不去。
“老陈,我把话说透吧。“他说,“您那个停车位的故事,我听完第一反应是:太惨了。”
“惨在哪儿?”
“干了事,没人知道。“小林说,“八十六户人家,每天有三十二户在用一个你定的规矩,没一个人知道是你。”
“我要是这么干一辈子,我的简历上写什么?”
老陈在电话那头停了几秒。
“小林,我问你个问题,你别急着答。”
“您说。”
“你是想让这件事’是你的’,还是想让它’是对的’?”
“这俩有冲突吗?”
“有时候有。“老陈说,“你把名字刻上去,它就更容易被反对。你把名字拿掉,它更容易活下来。”
“停车位那条规矩,我当年要是坚持叫’老陈轮候法’,它第二年就被推翻了。”
“为什么?”
“因为一叫名字,它就变成了’我的主张’,别人反对它不需要理由,只需要反对’这个人’。“老陈说,“名字一拿掉,它就变成了一个’中性的东西’。反对它,你得说清楚它哪儿不好。”
“所以它活了八年。”
小林没说话。
“我再说得直白一点。“老陈说,“你现在的难受,来源是你把**‘我的功劳’和’我的产出’当成了一回事**。”
“它们不一样吗?”
“功劳是记在你名下的,产出是留在这个世界上的。“老陈说,“功劳需要别人知道,产出不需要。”
“那我要是一辈子都不让人知道,我怎么升职?”
“你升职靠的是别人相信你干过这些事。“老陈说,“不是靠别人知道每一个细节。”
“区别在于:你在述职的时候,你能不能说出三件’只有你能推动的、并且已经验证有效的事’。你说得出来,就够了。至于那条 lint 规则的第一行是谁敲的,没人关心,包括评委。”
小林在马路牙子上坐着,把啤酒罐捏扁了。
“我还有个问题。”
“你说。”
“如果我做的所有事,最后都变成了’不需要我’的东西。“小林说,“那下一个问题是:我接下来干什么?”
老陈笑了。
“这个问题问得好。”
“这说明你已经过了那一关了。“
Go 实例:你不需要说服三十个人,你只需要改一条路
小林去年十月推的那个 svcclient 包,是这九个月里他花的力气最大、也最不被人记得的一件事。
起因是九月的一次事故。
那次是营销那边的一个服务,调用用户中心的一个接口。用户中心慢了,营销这边没有超时,三个小时里堆了四万多个挂起的 goroutine,最后整条链路雪崩。
复盘的时候小林问了一句:“为什么没设超时?”
那个人说:“我看了我们组的 client 示例,上面写了 Timeout: 3 * time.Second。我抄的时候不知道为什么删掉了,可能是清理代码的时候顺手删的。”
小林回去翻了那个”client 示例”。
// 第一版:一个"示例文件",放在 docs/ 目录下
package main
import (
"net/http"
"time"
)
// 正确的 HTTP client 写法(请大家照着抄)
func newClient() *http.Client {
return &http.Client{
Timeout: 3 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
}这个文件写在 wiki 上,也在仓库里放了一份。写得很清楚,还有注释。
问题是:抄一遍,就走样一次。
小林统计了一下(他写了个脚本扫全公司的 Go 仓库),结果:
全公司 Go 服务: 47 个
直接使用 http.DefaultClient: 11 个
&http.Client{...} 字面量: 63 处
其中设置了 Timeout 的: 31 处(49%)
同时设置了 Timeout 和连接池的: 8 处(13%)
而这 63 处里,有二十多处是”抄自那份示例,然后被改过”。
“改的人不一定是故意的。“小林说,“有的是为了调一个特殊场景,把 Timeout 调成了 0(无限)。调完忘了改回来。”
第二版他做了两件事。
第一件:提供一个包,让正确的写法成为唯一容易的写法。
// Package svcclient 是全公司所有服务间 HTTP 调用的唯一入口。
//
// 设计原则:
// 1. 默认值必须是安全的(有超时、有连接池、有熔断、有 trace)
// 2. 不提供"关掉超时"的选项,只提供"调大超时"的选项
// 3. 使用方拿不到 *http.Client,从物理上杜绝"拿去随便改"
package svcclient
import (
"net/http"
"time"
)
// Client 是内部服务调用客户端。
// 它不是 *http.Client —— 这是故意的。使用方无法把它
// 拆开改里面的字段,所以默认值不可能被破坏。
type Client struct {
http *http.Client
svc string // 被调服务名,用于埋点和 trace
timeout time.Duration
breaker Breaker
retry RetryPolicy
onMetrics func(Result)
}
type Option func(*Client)
// New 创建一个客户端。svc 是被调服务名,必填。
func New(svc string, opts ...Option) *Client {
c := &Client{
svc: svc,
timeout: 2 * time.Second, // 默认值。想改可以改,但必须显式改。
http: &http.Client{
Transport: &http.Transport{
MaxIdleConns: 200,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
// 关键:Dialer 层也要超时,否则 DNS 慢或者握手慢
// 会绕过 Client.Timeout 的某些路径
DialContext: (&net.Dialer{
Timeout: 1 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 1 * time.Second,
ResponseHeaderTimeout: 2 * time.Second,
ExpectContinueTimeout: 500 * time.Millisecond,
},
},
}
for _, opt := range opts {
opt(c)
}
// 最后再设一遍,保证 Option 改过之后 Timeout 仍然是有限值
if c.timeout <= 0 {
c.timeout = 2 * time.Second
}
c.http.Timeout = c.timeout
return c
}
// WithTimeout 调大超时。注意:不允许设成 0 或负数。
// 如果你真的需要一个超过 30 秒的调用,那它大概率不应该是同步 HTTP。
func WithTimeout(d time.Duration) Option {
return func(c *Client) {
if d > 0 && d <= 30*time.Second {
c.timeout = d
}
}
}
// Do 执行一次请求。trace、埋点、熔断、重试都在这里统一处理,
// 使用方不需要(也做不到)逐一手写。
func (c *Client) Do(ctx context.Context, req *Request) (*Response, error) {
// 1. 熔断:下游已经挂了就快速失败,别堆积
if !c.breaker.Allow() {
return nil, ErrCircuitOpen
}
// 2. 注入 trace:这一条以前全靠人记得写
ctx, span := tracer.Start(ctx, "http.client."+c.svc)
defer span.End()
start := time.Now()
var lastErr error
var resp *Response
// 3. 重试:只有明确可重试的错误才重试,且受重试预算限制
err := c.retry.Do(ctx, func(ctx context.Context) error {
httpReq, err := c.buildRequest(ctx, req)
if err != nil {
return err // 构造失败不重试
}
r, err := c.http.Do(httpReq)
if err != nil {
lastErr = err
return classify(err) // 把网络错误分类,决定要不要重试
}
defer r.Body.Close()
resp, lastErr = c.decode(r)
return lastErr
})
c.breaker.Record(err == nil)
c.report(Result{Svc: c.svc, Err: err, Cost: time.Since(start)})
if err != nil {
return nil, err
}
return resp, nil
}第二件:加一条 lint,从物理上堵住绕路。
// 第二版的配套规则:禁止裸用 http.DefaultClient 和 &http.Client{}
package nosvcclient
import (
"go/ast"
"go/types"
"golang.org/x/tools/go/analysis"
)
var Analyzer = &analysis.Analyzer{
Name: "nosvcclient",
Doc: "禁止直接使用 http.DefaultClient / http.Get / &http.Client{},必须使用 svcclient.New",
Run: run,
}
func run(pass *analysis.Pass) (interface{}, error) {
for _, f := range pass.Files {
ast.Inspect(f, func(n ast.Node) bool {
switch node := n.(type) {
// 命中 http.Get / http.Post / http.Head 这类包级函数
case *ast.CallExpr:
if sel, ok := node.Fun.(*ast.SelectorExpr); ok {
if isStdHTTP(pass, sel.X) {
switch sel.Sel.Name {
case "Get", "Post", "PostForm", "Head":
pass.Reportf(node.Pos(),
"禁止使用 http.%s:它没有超时,也没有 trace。"+
"请使用 svcclient.New(%q)",
sel.Sel.Name, pass.Pkg.Name())
}
}
}
// 命中 &http.Client{...} 和 http.Client{...}
case *ast.CompositeLit:
if sel, ok := node.Type.(*ast.SelectorExpr); ok {
if isStdHTTP(pass, sel.X) && sel.Sel.Name == "Client" {
pass.Reportf(node.Pos(),
"禁止直接构造 http.Client:请统一使用 svcclient.New,它会带上超时、连接池、熔断和 trace")
}
}
// 命中 http.DefaultClient
case *ast.SelectorExpr:
if isStdHTTP(pass, node.X) && node.Sel.Name == "DefaultClient" {
pass.Reportf(node.Pos(), "禁止使用 http.DefaultClient:它没有超时")
}
}
return true
})
}
return nil, nil
}
func isStdHTTP(pass *analysis.Pass, expr ast.Expr) bool {
ident, ok := expr.(*ast.Ident)
if !ok {
return false
}
obj, ok := pass.TypesInfo.Uses[ident]
if !ok {
return false
}
pkg, ok := obj.(*types.PkgName)
if !ok {
return false
}
return pkg.Imported().Path() == "net/http"
}半年之后的数据:
推之前(2025-09) 推之后(2026-06)
裸用 DefaultClient 的服务 11 个 0 个
设置了超时的 HTTP 调用 49% 100%
带 trace 的跨服务调用 34% 100%
超时类导致的 P2 以上故障 9 次/年 1 次/年
最后那个数字,是运维大刘给他的。大刘在会上说了一句:“这半年我半夜被叫起来的次数,比前年少了一大半。”
散会之后大刘拍了拍他肩膀,说了句”谢了啊小林”。
“就这一句。“小林跟老陈说,“我推这个包推了半年,中间被人骂过三次,说’你们这个包太重了’、‘我就想发个请求为什么要引你们这个’。半年之后,大刘跟我说了句’谢了’。”
“然后呢?”
“然后就没了。”
“没人给你发奖?”
“没有。”
老陈笑了。
“小林,你有没有想过,你那个 svcclient 包,全公司四十七个服务在用,一天大概跑几十亿次调用。这几十亿次调用里,每一次都带着超时、带着 trace、带着熔断。”
“这几十亿次里,有一次是你参与的吗?”
”……一次都没有。”
“这就是你的产出。“
老陈的追问
-
你去年做的所有事情里,有哪一件,是现在正在替你干活的?如果一件都没有,你去年是在产出,还是在消耗?
-
你团队里有哪条规矩,是”大家都在遵守,但没人记得是谁定的”?如果一条都没有,说明你所有的规矩都还挂在你的名字上,那么它们也都会跟着你一起走。
-
你有没有做过一件事,是为了让它”活下去”,而主动把你的名字从上面拿掉的?如果没有,你有没有想过,你坚持署名,可能正在缩短它的寿命?
-
如果明天你离开这家公司,一年之后回来,你能在这个地方看到几件”你的东西”?它们是以什么形式存在的?代码?文档?还是别人的习惯?
-
你做的这些”不需要你”的事,做完之后,你能不能清楚地说出”我接下来要干什么”?说不出来,说明你只是完成了任务,没有完成转型。
生活思考题
题一:为什么很多老小区的”规矩”,没人说得清是谁定的,但一直在执行?
大多数人会这么答: 因为大家习惯了嘛。习惯成自然,时间一长就成了规矩。
这个答案为什么不够: “习惯”是个结果,不是原因。你想想,同一个小区里,有的规矩活了二十年,有的规矩三个月就没人提了。它们都曾经是”习惯”。
更深的一层是什么: 一条规矩能不能活下来,取决于它有没有解决一个持续存在的问题,以及它有没有一个不依赖人的执行机制。
停车位轮候那条规矩,它解决的问题(车位不够)二十年后还在。更重要的是,它有一个自执行的机制:每个人都能看见排队顺序,谁插队所有人都会知道。它不需要一个”监督者”。
而那些三个月就死掉的规矩,通常长这样:“请大家自觉不要把垃圾放在楼道”。它没有执行机制,它依赖每个人的自觉,而自觉是最先消失的东西。
更深一层:一条规矩的寿命,跟它”被想起来的频率”成正比,跟它”需要被想起来的频率”成反比。
什么意思?停车位那条规矩,你每天都在用(停车的时候看一眼顺序),所以你每天都想起它。但你不需要专门想起它,因为那个牌子就在那儿,它就长在你的必经之路上。
而”不要乱放垃圾”这条,你需要专门想起它,且没有东西提醒你。
落到 Go 工程师身上: 这条对应一个特别具体的判断:你定下的东西,在别人的日常路径上吗?
- 放在 wiki 里的规范:不在。别人要专门去打开 wiki。
- 放在 PR 模板里的一句话:在。每次提 PR 都会看到。
- 做成 lint 规则:在,而且强制。
- 做成脚手架默认生成的代码:最在。因为它是”本来就有的东西”,别人根本意识不到那是一条规矩。
排序是:默认生成 > 强制检查 > 提醒 > 文档。
所以下次你要推一件事,先问自己在第几层。如果你只能做到第四层(文档),那你要诚实一点:你推的不是一个机制,你只是表达了你的想法。
还有一个反直觉的点:最好的规矩,是让人感觉不到它存在的规矩。 你的 svcclient 包,最好的状态是新人用了半年都不知道有个包叫这个名字,他以为 HTTP 调用本来就带超时。到那一天,你的规矩就成了。
题二:为什么你搬进”干净”的出租屋,会比搬进”脏”的出租屋,更自觉地保持卫生?
大多数人会这么答: 因为环境会影响人呗。干净的屋子你不好意思弄脏,脏的屋子反正已经脏了,破罐子破摔。
这个答案为什么不够: “不好意思”是个道德解释,它假设人是靠道德约束的。但真正的情况更机械,也更可靠。
更深的一层是什么: 这个现象有个名字,叫破窗效应。但破窗效应背后的机制,比”环境影响人”要具体得多。
一个干净的环境,给你的信号是:“这里有人管”。
这个信号会改变你的一次成本计算。你丢一个外卖盒在地上,在一个一尘不染的客厅里,这个动作非常显眼,你自己都觉得别扭。而在一个已经有三个外卖盒的客厅里,你丢第四个的成本几乎为零,因为没人(包括你自己)能分辨出来。
所以决定你行为的,不是你的道德水平,是”你的行为在这个环境里的可见度”。
更深的那一层:这个机制反过来也成立。你是那个”第一个丢外卖盒的人”,你就改变了这个环境对所有人的信号。你不是弄脏了一个客厅,你是宣布了这里没人管。
这也是为什么”第一次”这么重要。第一次违规被放过了,第二次就是零成本的。
落到 Go 工程师身上: 这条在代码上的映射极其直接:仓库的”初始状态”,就是那个客厅。
一个新同事进你的项目,他写的第一行代码长什么样,取决于他 clone 下来看到的那个代码长什么样。
- 他看到仓库根目录有一个
CONTRIBUTING.md,写着”所有错误必须带码” —— 这是文档,弱信号。 - 他 clone 下来,
go test ./...全绿,但没有一条测试检查错误码 —— 这是强信号,它在说”其实没人管这个”。 - 他看到仓库里有三十处
//nolint—— 这是更强的信号,它在说”规矩是可以商量的”。
具体该做的三件事:
第一,把你的仓库的”第一天”做好。一个新项目初始化的那一刻,是你唯一一次机会让所有人都从同一个干净状态开始。脚手架里默认带上 lint 配置、测试骨架、错误码体系、trace 注入。这些东西后来的成本,是第一天的一百倍。
第二,对第一次违规零容忍。不是惩罚那个人,是修复那个东西。第一次有人绕过你的 lint,你要做的不是批评他,是把那个绕过堵上,或者把规则改合理。你要让”绕过”这件事的成本保持在高位。
第三,最实在的一条:定期做一次”整屋保洁”。每个季度花两天,把仓库里累积的 //nolint、TODO、被注释掉的代码、没人用的导出函数清理一遍。这个动作本身不产生功能价值,但它维护了那个信号:“这里有人管”。
顺带一句:这一条也是为什么”技术债”难还。技术债累积的不是代码,是那个信号。当你还债的时候,你还掉的第一样东西,不是那三千行烂代码,是”这里有人管”这个信号。
6.9 当你不再是技术最强的那个人
七月,组里来了个新人。
姓孟,三十二岁,从一家做云原生的公司过来的,职级 P7。简历上有一条小林看了两遍:他是某个开源可观测项目的 committer。
小林面试他的时候问了个问题:“你为什么从那边过来?”
小孟说:“我想做业务。我们那个项目,做到后来全是跟 k8s 打交道,我看不到用户。”
这个回答小林挺满意。
那次评审
小孟来了第三周,小林让他看一遍履约的调度器,主要是熟悉代码。
第四周的评审会上,小孟提了个 PR,说是”练手”。
那个 PR 改的是调度器里的”规则匹配”那一块。小林三年前写的,大概八百行,用了大量的 switch 加 map[string]RuleFunc,因为不同业务线的规则类型不一样。
小孟用泛型重写了一遍。
// 小林的三年前版本(节选)
func (e *Engine) match(ctx context.Context, ruleType string, raw json.RawMessage, input Input) (bool, error) {
switch ruleType {
case "warehouse_whitelist":
var r WarehouseWhitelistRule
if err := json.Unmarshal(raw, &r); err != nil {
return false, err
}
return e.matchWhitelist(ctx, r, input)
case "time_window":
var r TimeWindowRule
if err := json.Unmarshal(raw, &r); err != nil {
return false, err
}
return e.matchTimeWindow(ctx, r, input)
case "sku_category":
// ... 还有 9 个 case,每个都是同样的四行
default:
return false, fmt.Errorf("unknown rule type: %s", ruleType)
}
}// 小孟的版本(节选)
type Rule[T any] interface {
Type() string
Match(ctx context.Context, spec T, input Input) (bool, error)
}
// 注册一次,之后新增规则类型不需要再改 Engine 的任何代码
func RegisterRule[T any](r Rule[T]) {
var zero T
rules[r.Type()] = ruleEntry{
parse: func(raw json.RawMessage) (any, error) {
var spec T
err := json.Unmarshal(raw, &spec)
return spec, err
},
match: func(ctx context.Context, spec any, in Input) (bool, error) {
return r.Match(ctx, spec.(T), in)
},
}
_ = zero
}新版本:三百一十行,比原来少了六成。而且他跑了个 benchmark,P99 从原来的 41 微秒降到 19 微秒,因为省掉了中间那层 any 装箱。
评审会上,小林看了大概二十分钟。
他说了三句话。
第一句:“泛型这个东西,我们组里其他人看得懂吗?”
小孟说:“可以加注释。”
第二句:“这个规则一共就十一种,为了十一种写一套泛型注册机制,是不是过度设计?”
小孟说:“现在是十一种,去年是八种,前年是五种。”
第三句:“这个改造的收益是什么?P99 降了 22 微秒,我们整个调度的 P99 是 42 毫秒,这 22 微秒占比不到千分之一。”
小孟没说话了。
那次评审没通过。
散会之后,小周留下来,跟小林说了一句:“林哥,我觉得他那个方案挺好的。”
“好在哪儿?”
“新增规则的时候不用改 Engine 了。“小周说,“上个月我加那个’企业购合同仓’的规则,我改了四个文件。”
“我知道。“小林说。
老陈问的那个问题
周末小林去找老陈,把这事从头讲了一遍。
他讲得挺详细,讲了那三句话,讲了 benchmark,讲了小周那句”他那个方案挺好的”。
讲完,老陈问了个问题:
“你在那二十分钟里,脑子里在干什么?”
“我在看他的代码。”
“还有呢?”
“我在看有没有 bug。”
“还有呢?”
小林停住了。
”……我在想怎么反驳他。”
老陈点点头。
“什么时候开始的?”
“从他打开第一页就开始了。“小林说,“我看到他用泛型,我第一个念头是’这玩意儿在 Go 1.18 才有的,我不是不会用,我是觉得没必要’。”
“然后呢?”
“然后我开始找他的问题。我找到了三个:泛型让新人看不懂、注册机制是过度设计、性能收益不显著。”
“这三条你找到花了多久?”
”……前两条是我在他讲到第二页的时候就想好的。”
老陈笑了。
“小林,我给你说个事,你别不高兴。”
“您说。”
“你那三条,没有一条是关于’这个方案好不好’的。它们全是关于’我能不能找到理由不采用它’的。”
小林没说话。
“我问你个问题。“老陈说,“你上一次在技术上被别人说服,是什么时候?”
”……”
“想不起来?”
”……三年前。“小林说,“三年前 Kevin 说服过我一次,关于要不要上 MQ。”
“三年。”
“三年。“小林重复了一遍。
“这三年里,你们组做了多少次技术决策?”
”……几百次。”
“这几百次里,有几次是别人说服你的?”
“零次。”
老陈把茶杯放下。
“那你现在知道问题在哪儿了吗?“
老陈赔掉的六百万
“我给你讲个我赔钱的事。第二个。“老陈说。
“我四十三岁当的 CTO。“他说,“从那之后,我大概三年没正经写过代码。”
“什么叫没正经写过?”
“就是那种,一个需求从零到上线,我全程自己写。“老陈说,“我写过一些脚本,改过一些配置,评审过无数代码。但是我没有从头写过一个完整的、上线的东西。”
“那三年我过得挺充实的。“他说,“每天开会,看架构,做技术选型,跟投资人讲技术故事。我觉得我的技术判断力在变强,因为我看到的东西更多了。”
“第四年,我们要做一个数据平台。选型的时候有两个方案:一个是当时很火的一个新型数据库,一个是老牌的 MPP 方案。”
“我选了那个新的。”
“为什么?”
“因为我看了一堆评测文章,看了三个会议的分享,它在一堆 benchmark 里都好。“老陈说,“我还跟他们团队的人聊过两次,人都挺好。”
“然后呢?”
“然后我们上了。上了大概四个月,问题全出来了。“老陈说,“它的运维工具链特别不完善,备份恢复要自己写脚本。它的 SQL 兼容性有坑,我们一半的查询要改写。它的社区很小,遇到问题只能自己看源码。”
“最要命的是它的内存管理。我们的查询量一大,它会 OOM,而且 OOM 之后不是重启就好了,它的 WAL 有几率损坏。”
“最后怎么样?”
“最后我们花了七个月迁回去了。“老陈说,“七个工程师七个月,加上云资源、加上业务受损,我算过一笔账,六百多万。”
“那您的判断错在哪儿?”
“我错在,我那三年里获得的所有信息,都是二手的。”
老陈说:“我看的是评测文章,那些文章的作者是在一个精心配置的环境里跑的。我听的是会议分享,那些分享是厂商自己做的。我跟他们团队聊,他们当然说自己好。”
“我没有一个渠道能告诉我:这个东西,日常用起来到底有多难受。”
“这个渠道是什么?”
“是我自己用它写一次完整的东西。“老陈说,“你真正用一个数据库,你会在第三天遇到第一个坑,第七天遇到第二个。这些坑永远进不了评测文章,因为它们太琐碎了。但正是这些琐碎的东西,决定了一个技术选型在半年之后是活的还是死的。”
“一个从来不写代码的技术负责人,他的判断力会在两年内失效,而他自己完全不知道。”
“因为所有反馈给他的信息,都是被人处理过的。“
小林的反驳
“我又要反驳了。“小林说。
“你说。”
“您刚才说的我认,但是我做不到。“他说,“我现在一周五个会,评审三十个 PR,做两次跨部门对齐,带八个人,还要跟王总对齐目标。我哪有时间从头写一个完整的东西?”
“你有多少时间?”
”……周末?”
“周末你要陪女儿。”
“对。“小林说,“而且我周末就算写了,我写的东西也进不了生产。我不熟现在的代码了,我写出来的东西要别人给我 review。”
“所以呢?”
“所以我觉得这个建议不现实。“小林说,“我现在这个级别,我自己写代码,是公司资源的浪费。”
“嗯。“老陈说,“你说的这些都是真的。”
“那您的意思是?”
“我改一下我的说法。“老陈说,“你不需要写代码,你需要保持痛感。”
“痛感?”
“你还记得上一次你被一个工具折磨得想骂人,是什么时候吗?”
小林想了想。“上个月。我们那个 CI,一个 Go 服务跑一次完整的流水线要十四分钟,我改一行代码要等十四分钟。”
“那你有没有去改它?”
“没有。“小林说,“我提了个 issue 给平台组,他们说下个季度排期。”
“你看。“老陈说,“你知道痛,但你没承受这个痛。”
“什么意思?”
“你提了个 issue,这个痛就转移走了。“老陈说,“转移到平台组那边了。你失去了这个痛。”
“所以呢?我要自己去改 CI?”
“不一定要改 CI。“老陈说,“你要做的,是在你的生活里保留一件’你会亲自承受后果’的技术活动。”
“比如?”
“三选一,随便哪个都行。”
老陈伸出三根手指。
“第一,你自己维护一个真实的小东西。“他说,“一个你自己花钱买的服务器上跑的东西,一个你自己用的工具,一个开源项目。它必须有真实用户,哪怕只有三个。”
“为什么必须有真实用户?”
“因为只有真实用户会告诉你,你做的那个东西到底有多难用。“老陈说,“你的同事不会。同事会照顾你的面子。”
“第二,你定期去做一次’陌生环境’的代码。“老陈说,“每个季度,你挑一个你完全不熟的模块,自己从头写一个小功能,走完整个流程:clone、起环境、写、调不通、看日志、骂人、调通。”
“这件事的价值不在产出,在于你会重新遇到那些你以为已经被工具解决掉的麻烦。”
“第三,你身边要有一个技术比你强的人,并且你要让他知道,他可以说服你。”
“这条最难。“老陈说,“因为这是唯一一条,需要你承认自己不是最强的。“
老陈的父亲
“我爸是木匠。“老陈说。
“我小时候,他做家具,我就在旁边看。他做的是那种老式的榫卯家具,不用一根钉子。”
“他五十岁那年,手不行了。“老陈说,“不是大病,就是手抖。他说他拿凿子的时候,准头差了。”
“那怎么办?”
“他不再做家具了。“老陈说,“他改做’放样’。”
“什么叫放样?”
“就是画样子、定尺寸。“老陈说,“一件家具,哪里开榫,榫头多大,卯眼多深,木头怎么下,这些他画在一块木板上。然后他带的那几个徒弟照着做。”
“他六十多岁还在做这个。”
“那他难受吗?”
“难受。“老陈说,“我有一次回家,看他一个人坐在那儿,手上拿着一块木头,反复看。我问他看什么,他说这块料有点翘,‘我想想怎么下锯’。”
“我说爸你现在又不用自己锯了。”
“他说了一句话,我记到现在。”
“什么话?”
“他说:‘我要是连木头今天什么脾气都摸不准了,我还凭什么给他们放样?’”
老陈停了一下。
“他那时候有个规矩。“老陈说,“每一批料进来,他都要自己锯一个榫头,就一个,做完扔掉。”
“为什么?”
“我那时候问他,他说:‘我不锯,我就不知道这块料今天的脾气。我的样子画得再准,料不对,做出来就是歪的。’”
“一个榫头,锯一次要多久?”
“十几分钟。“老陈说,“他一礼拜锯一次,锯了十几年。”
小林听着。
“小林,你说他锯的那个榫头,有什么用?”
”……不进任何一件家具。”
“对。“老陈说,“它被扔掉了。但是我爸那十几年画的所有样子,没有一个是错的。”
“这就是你要保留的那个东西。”
“不是为了产出,是为了校准。“
Go 实例:为扩展留出空间,是谦逊的技术形态
小林回去之后,把小孟那个 PR 重新看了一遍。
他这次看了整整一个下午,中间把小周叫过来一起看。
看到第三遍的时候,他发现了两件事。
第一件:小孟说得对。新增规则类型确实不用改 Engine 了。而小林自己那套 switch,每次加规则要改四个文件,去年加过三次,其中有一次改漏了一个地方,上线之后发现规则没生效,回滚了。
第二件:他发现他那个 Chain 的设计,有一个他从来没意识到的局限。
因为 Middleware[Req, Resp] 是泛型参数化的,所以你没法写一个”对所有 Handler 都适用”的 middleware。
比如你想写一个”记录所有 handler 的入参出参”的审计中间件,你写不出来,因为你不知道 Req 是什么类型。
组里现在的做法是:每个业务线自己写一份审计中间件。三份代码,九成一样。
这个局限是小孟那次评审之后,小林在洗澡的时候想明白的。
他后来跟小孟一起做了第二版。这版的改动很小,但很关键:
// 第三版:在泛型之外,留一个类型擦除的通道
package fulfillkit
import (
"context"
"fmt"
"sort"
"sync"
)
// Handler 保持不变,业务方的主力路径还是类型安全的泛型。
type Handler[Req, Resp any] func(ctx context.Context, req Req) (Resp, error)
type Middleware[Req, Resp any] func(Handler[Req, Resp]) Handler[Req, Resp]
// AnyMiddleware 是"跨类型通用中间件"的通道。
// 它牺牲一点类型安全(用 any),换来"写一次,所有业务线都能用"。
//
// 为什么需要它:审计、权限、限流这类中间件,跟具体业务无关,
// 泛型参数化之后反而写不出来。这是泛型的固有代价。
type AnyMiddleware func(ctx context.Context, name string, call func(context.Context) error) error
// ---------- 注册中心:这是这一版真正新增的东西 ----------
var (
registryMu sync.RWMutex
mwRegistry = map[string]AnyMiddleware{}
pluginOrder []string
)
// RegisterMiddleware 注册一个全局中间件。
// 这是给"别人"用的口子 —— 小林自己没有注册过任何一个。
func RegisterMiddleware(name string, mw AnyMiddleware) error {
if name == "" {
return fmt.Errorf("middleware 名不能为空")
}
registryMu.Lock()
defer registryMu.Unlock()
if _, exists := mwRegistry[name]; exists {
return fmt.Errorf("middleware %q 重复注册", name)
}
mwRegistry[name] = mw
pluginOrder = append(pluginOrder, name)
return nil
}
// MustRegisterMiddleware 供 init() 里使用,重名直接 panic。
// 这种"启动即失败"的设计是故意的:配置错误应该在第一秒暴露,
// 而不是等到线上某个请求走错位了才发现。
func MustRegisterMiddleware(name string, mw AnyMiddleware) {
if err := RegisterMiddleware(name, mw); err != nil {
panic(err)
}
}
// Registered 返回当前所有已注册中间件,按注册顺序。
// 暴露出来是为了让人能在启动日志里看到全貌,也方便写自检测试。
func Registered() []string {
registryMu.RLock()
defer registryMu.RUnlock()
out := make([]string, len(pluginOrder))
copy(out, pluginOrder)
sort.Strings(out) // 排序保证输出稳定,日志 diff 才不会乱跳
return out
}
// applyAny 在调用前后依次执行所有注册的全局中间件。
// 注意洋葱模型的实现:递归,而不是循环,这样"先注册的在外层"是自然的。
func applyAny(ctx context.Context, name string, idx int, call func(context.Context) error) error {
registryMu.RLock()
n := len(pluginOrder)
registryMu.RUnlock()
if idx >= n {
return call(ctx)
}
registryMu.RLock()
mw := mwRegistry[pluginOrder[idx]]
registryMu.RUnlock()
return mw(ctx, name, func(ctx context.Context) error {
return applyAny(ctx, name, idx+1, call)
})
}
// WrapAny 把任意业务函数包上所有全局中间件。
func WrapAny(name string, call func(context.Context) error) error {
return applyAny(context.Background(), name, 0, call)
}然后,小林做了一件他自己觉得最重要的事:他自己一个中间件都没注册。
他只在文档里写了一页,标题是《怎么给履约加一个全局中间件》,里面有三个例子。
三个月后,注册中心里有了五个中间件:
audit-log (小孟写的,审计日志)
contract-check (企业购那条线的人写的,合同仓校验)
gray-header (小周写的,灰度标透传)
slow-log (大刘写的,慢调用采样)
tenant-isolate (阿 May 那边提的需求,小周实现的)
这五个里,有三个的业务背景小林完全不了解。
“你有没有一种’失控’的感觉?“老陈问。
“有。“小林说,“我第一次看到 tenant-isolate 那个名字的时候,我不知道它是干什么的。我去问小周,小周说了十分钟,我说我还是没听懂。”
“然后呢?”
“然后我说’行,你定’。”
老陈笑了。
“你现在说’你定’说得挺顺口的。”
“半年前我说这两个字,要在键盘上删三十个字。“小林说,“现在我两秒就发出去了。”
“那这五个中间件的代码,你看过吗?”
“看过两个。“小林说,“另外三个我没看。”
“你不担心他们写错?”
“担心。“小林说,“但是我算了一下:我要是想看完这五个,我每周要花三个小时。三个小时我能干什么?”
“能干什么?”
“能去写我那个开源限流器。“小林说,“那个东西现在有三十几个 star,上周有人提了个 issue,说我那个 sweep 在高并发下有竞态。”
“你要修吗?”
“要修。“小林说,“我周末修。”
老陈笑了。
“你看,你自己其实知道。”
这一节背后的原则:你最强的那一刻,不是你把所有事都想到了,是你承认自己想不到,然后留了个口。
一个 P9 的技术影响力,最后长什么样?不长在他写了多少代码,长在他定的那个形状里,能长出多少他没想到的东西。
老陈的追问
-
你身边有没有一个技术比你强的人?如果有,他上一次当面反驳你,是什么时候?如果没有,你为什么允许这种事发生?
-
你上一次在技术上被别人说服,是什么时候?如果超过一年,你靠什么校准自己的判断?靠文章吗?
-
你现在还在写代码吗?如果你完全不写了,你凭什么相信你对”这个东西好不好用”的判断?
-
你的团队里,有没有一个人,是你在某件事上会去请教他的?有的话,你上一次请教他是什么时候?没有的话,你打算招一个吗?
-
如果你明天招进来一个人,技术全面超过你,你的第一反应是高兴还是紧张?诚实地回答,然后想想这个反应意味着什么。
生活思考题
题一:为什么很多顶级球队的主教练,自己当年只是个普通球员?
大多数人会这么答: 因为会踢的不一定会教呗。球员需要身体和技术,教练需要战术和眼光,这是两种不同的能力。
这个答案为什么不够: 它只说了”是两种不同的能力”,没说清楚这两种能力到底是什么,也没说清楚为什么”顶级球员反而当不了顶级教练”这个现象这么普遍。
你想想,那些顶级球员,他们对足球的理解是顶级的。为什么他们反而不如一些二流球员当教练当得好?
更深的一层是什么: 顶级球员的问题,不是”不懂”,是**“没法把不懂的人的状态还原出来”**。
一个天赋极高的球员,很多动作对他来说是本能。他不需要思考”这个球我该用脚内侧还是外侧”,他的身体自己知道。这意味着他从来没有经历过那个”不会”的阶段。
而一个普通球员不一样。他当年为了让一个动作变成本能,经历过完整的”不会、别扭、犯错、调整、内化”的过程。他记得每个阶段是什么感觉,所以他知道学员卡在哪儿,也知道该怎么把学员从那个地方推过去。
更深一层:教一个东西的能力,跟”你自己能做到”弱相关,跟”你记得你做不到的时候是什么样”强相关。
顶级球员还有一个劣势:他的权威太强了。他示范一次,学员看到的是一个完美的、不可企及的动作。这个示范本身是有害的,因为它不提供任何”路径信息”。
普通球员示范的时候,会示范”错误动作长什么样”,因为他自己当年就是那么错的。
落到 Go 工程师身上: 这条对带人这件事的启示非常直接:你不需要是最强的那个,你需要是那个”还记得自己不会的时候”的人。
具体做三件事:
第一,把你自己学一个新东西的过程记录下来。你学 Rust 也好,学分布式一致性也好,把你第一周、第一个月的困惑原样记下来。这些困惑,一年后你带新人的时候,是最宝贵的教材,因为你已经忘了。
第二,示范的时候,先示范错的。你在给团队讲一个设计的时候,先讲”我一开始是这么想的,这个想法错在哪儿”。这比一上来给一个完美方案有用十倍,因为完美方案不提供路径。
第三,也是最重要的:别在你最擅长的事情上去衡量你带的人。 你写了十年 Go,你看一个 P5 的代码,你会觉得处处是问题。但你要比较的不是”他跟你差多少”,是”他跟三个月前的他自己差多少”。
题二:为什么很多人当了领导之后,说的话越来越没人反驳?
大多数人会这么答: 因为下属怕得罪领导呗。而且领导掌握着绩效,谁敢跟老板对着干。
这个答案为什么不够: 它假设所有领导都是”容不下反对意见”的。但现实里,很多领导是真心想听反对意见的,他们开会时会说”大家畅所欲言”,会点名问”有没有不同看法”,甚至会设”唱反调”的角色。
然后还是没人说。
更深的一层是什么: 这个现象的机制,比”怕得罪”要精妙,也更难对抗。它有三层。
第一层是信息不对称。 你知道的比你下属多(你开了更多的会,看了更多的材料),所以你提出的方案,在信息的完整性上通常比他们临时想到的要好。他们反驳你,需要付出巨大的认知成本,而且大概率还是被你用他们不知道的信息驳倒。几次之后,他们就学会了”先听你说完”。
第二层是时机成本。 你说完一个想法,会议室里沉默三秒。在这三秒里,每个人都在算:我提出反对,要花多少时间组织语言?会不会打断节奏?会不会显得我没听懂?三秒之后,话题就过去了。
第三层最深,也最难解决:你的热情本身是一种压力。 你带着一个兴奋的想法进会议室,你说的时候眼睛是亮的。这时候站出来说”我觉得这个不行”的人,他不只是在反对一个方案,他在给你泼冷水。这需要的勇气,比理性讨论大得多。
这三层的共同点是:它们都不需要领导做任何事,它们会自动发生。
所以”鼓励大家提意见”是没用的,因为你在对抗的不是恐惧,是结构。
落到 Go 工程师身上: 怎么破?几个具体、可执行的做法:
第一,把你的方案先发出来,再开会。提前 24 小时把文档发出去,明确说”我不参加第一次讨论”。让他们在没有你的场合里先吵一轮。这是成本最低、效果最好的一招,因为它同时解决了信息不对称(他们有时间看材料)和热情压力(你不在场)。
第二,开会的时候,最后一个说。顺序改成:先让职级最低的人说,然后依次往上,你最后说。这个顺序一改,前面所有人的发言成本都下降了。
第三,给”反对”一个正式的、低风险的出口。比如设一个轮值的”红队”角色,这个人的任务就是找问题,而且这个角色在评价上是加分的。再比如,在决策文档里固定一栏”我们可能错在哪儿”,由不同的人轮流填。
第四,最狠的一招:你定期主动认一次错。 不是私下认,是在会上,当着所有人的面,说”我上次那个判断是错的,我当时漏了 X”。这件事做一次,你之后收到的反对意见,会比之前一年内收到的都多。
最后一句:这一条对所有级别的人都成立,不只是领导。你在 code review 里提意见的时候,你的热情对 PR 作者也是压力。试试把”这里应该这样改”改成”我没太看懂,你为什么选择这样写”。
本章小结:核心心法
十二月二十号,小林的年度述职前一天晚上,他又去了老陈家。
老陈在包饺子,白菜猪肉的。小林洗手,帮着擀皮。
“我明天述职。“小林说。
“讲什么?”
“我还没想好。”
老陈把馅料盆往他那边推了推。
“我问你个问题。”
“您说。”
“你请三个月假,你那块业务会怎样?”
小林擀皮的手停了一下。
那是十三个月前,在同一个阳台上,老陈问过他一模一样的问题。当时他答的是”会出问题”,然后老陈说”那你现在还在 P8”。
“我想好了。“他说。
“说。”
“会转。“小林说,“但是我不能说’一点问题没有’,那是吹牛。”
“具体点。”
“对账那块,小周接了七个月,出过一次问题,他自己修的,第二天我才知道。”
“调度器,小孟在做泛型那版重构,我一个月看一次进度。”
“值班,现在是五个人的排班,我占了二十分之一,跟其他人一样。”
“需求交付周期,现在是 4.8 天,我是上个月才知道的,因为我不再每周盯那个看板了。”
老陈包完一个饺子,放到盖帘上。
“那如果三个月里出了大事呢?”
“他们知道怎么找我。“小林说,“但是这三个月里,一次都没找过。”
“你试过?”
“试过一个月。“小林说,“十月份我请了十天假,带我老婆和孩子回了趟她老家。我把手机上的值班群设成免打扰,每天晚上看一次。”
“找你了吗?”
“第三天的时候,小周给我打了电话。“小林说,“问我一个事,是仓储那边要改接口,问我要不要接。”
“你怎么说的?”
“我说’你跟阿 May 定,定完了跟我说一声’。”
“然后呢?”
“然后他们定了。定完给我发了条消息,说他们选了方案 B。“小林说,“我看了那条消息,我一开始想说’为什么不是 A’,后来我把字删了,回了个’收到’。”
“你忍住了?”
“忍住了。“小林说,“忍得挺难受。”
老陈笑了。
“你现在能回答我那句话了吗?”
“哪句?”
“衡量 P9 的不是他本人多能打,是他不在场的时候,这块业务转不转得下去,转得好不好。”
小林把擀面杖放下。
“我能回答了。“他说,“但是我想加一句。”
“你说。”
“这十三个月我最难受的,不是’他们不需要我了’。“小林说,“是我发现,我过去十年攒的所有本事,确实一样都用不上了。”
“我会写 Go,我调得动并发,我能十分钟定位一个线上问题。这些东西,我今年一年,一样都没用上。”
“我第一次意识到这件事那天,我在书房坐了一晚上。”
“那你现在还难受吗?”
“还难受。“小林说,“但是不太一样了。”
“哪儿不一样?”
“我以前觉得,那些本事是我的。“小林说,“现在我觉得,那些本事是我用来换东西的。”
“换什么?”
“换小周现在能自己接对账。换那条 lint 规则。换那个四十七个服务都在用的包。“小林说,“我那些本事,我用了十年,现在它们变成别的东西了。”
“我今年三十一,我手上的手艺还值钱。但是我知道,它正在贬值。”
“那你打算怎么办?”
“趁它还没贬完,多换点东西出来。”
老陈停下手,看了他一眼。
“你今年述职,就讲这个。”
“讲这个能过吗?”
“我不知道能不能过。“老陈说,“但是我知道,这是你今年真正干的事。“
心法
让别人因我的存在而不同。
这句话的重点,不在于”不同”,在于”别人”。
P4 到 P8,你的产出全部挂在自己身上:你写的代码、你做的方案、你扛的事故、你定的决策。你的价值,可以直接从你本人的劳动里读出来。
P9 是第一个拐点:你的产出开始挂在别人身上。
你培养的人,他做出的判断里有你的痕迹,但署的是他的名。 你建立的标准,它在你不在场的时候替你说话,而且说得比在的时候更好使。 你塑造的判断力,它长在别人的脑子里,你看不见,也没法统计。
这就是身份危机的来源。你第一次发现,你手上那些用了十年的工具,全部变成了”由别人使用”的东西。你会恐慌,你会觉得自己像个骗子,你会怀疑自己凭什么拿那份工资。
这个恐慌是真实的,也是必要的。不经历这个恐慌的人,一辈子会卡在”我很能打”这个舒适区里,把团队带成一个只有他自己能转的单点。
而穿过这个恐慌的人,会得到一个很不一样的东西:
你不再是一根柱子。你是一张网的一个结点。
柱子抽走,房子会塌。结点摘掉,网还在,只是形状变了一点。
所以这一级最重要的三个转变是:
第一,衡量标准从”我做了什么”变成”我不在时发生了什么”。 你请三个月假,回来之后你希望听到的是”这三个月我们做了什么”,不是”你可算回来了”。
第二,工作对象从”事”变成”路径”。 你不再亲自把事情做对,你做的是让”做对”成为别人最省力的那条路。规范、工具、脚手架、默认配置,这些东西不领工资、不睡觉、不会离职。
第三,从”我有答案”变成”我能让答案长出来”。 你最强的技术时刻,不是你把所有情况都想到了,是你承认自己想不到,然后留了个口。
最后说一句不那么好听的。
这一级最难的地方,不是技术,不是方法论,是放弃功劳。
你做的那些真正有价值的事,最后都会变成”没人知道是谁干的”。就像老陈定的那条停车位规矩,八十六户人家用了八年,没一个人知道是他。
你可以选择把名字刻在上面,让它第二年就被推翻。
你也可以选择把它留在那儿,让它在你不认识的人手里继续活着。
「让别人因我的存在而不同」这句话,本来就不需要别人知道那个”我”是谁。
自检清单
这一级的判断标准,全都是”可验证”的。下面每一条,你都要能举出具体的例子、说出具体的人名或数字。说不出来,就不算。
□ 我能够说清楚:如果我明天开始请三个月假,我负责的这块业务会具体发生什么。我不需要猜,因为我有过去三个月的数据(事故数、交付周期、值班响应次数)支撑我的判断。
□ 我能够点名说出我团队里每一个人在过去一年里新增的能力,并且能说出这个能力是在哪个具体的事情上被验证过的。
□ 我能够列出至少三条”我推动过、且我今年一次都没再提、但它仍在发生”的机制。如果一条都列不出来,我去年是在救火,不是在建能力。
□ 我能够列出至少三个我没有参与、但按我设定的标准做出来的技术决策。这三条里,至少要有一条是我事先没想到的用法。
□ 我团队里至少有一条规范,是”所有人都在遵守,但没人记得是谁定的”。
□ 我团队的技术规范里,遵守它的成本低于违反它的成本。我知道每一条规范落在哪一层:默认生成的、强制检查的、提醒的、还是只写在文档里的。
□ 我手上的内部工具/包,至少有一个被我不认识的人用在了我没设想过的场景里。如果全部只在我自己的团队里用,我做的是框架,不是平台。
□ 我能够说出至少一个”因为陌生人的一句话而改了自己的设计”的例子。这个例子可以来自开源 issue、社区讨论、或者跨部门的反馈。
□ 我在过去半年里,至少有一次当众承认自己的判断是错的,并且说清楚了当时漏掉了什么。
□ 我身边至少有一个人,在某一个具体的技术领域比我强,而且我今年至少请教过他一次。
□ 我到现在还在写代码,或者在做一件”我会亲自承受后果”的技术活动。我知道这件事不是为了产出,是为了校准。
□ 我能够用三句话,向一个不认识我的同行说清楚:我这支团队解决了一个什么别人也会遇到的问题。
□ 我能够说出我明年打算交给别人的三件事,以及如果这三件事交出去之后我被边缘化了我该怎么办。
最后
老陈那天包完饺子,煮了一锅。小林吃的时候,老陈说了句话。
“你明年可能过不了。”
“我知道。“小林说,“我今年也觉得自己够呛。”
“我不是说你不够格。“老陈说,“我是说,这一级的评审,评委看的不是你今年做了什么。”
“那看什么?”
“看你不在的时候,留下来了什么。“老陈说,“而这个东西,至少要三年才能看出来。”
“那我今年白干了?”
“不白干。“老陈夹了个饺子,“你今年种的那些东西,都不是今年收的。”
小林没再问。他把那盘饺子吃完了。
出门的时候,老陈送到电梯口,说了最后一句:
“小林,你记住一件事。”
“什么?”
“你明年述职的时候,别讲你有多忙。”
“讲什么?”
“讲那些你没参与的决定。”
电梯门关上。