第四章 P7 设计篇:问题由我定义
心法:问题由我定义 转变:从「解决问题」到「定义问题」 陷阱:过度设计,为技术完美牺牲成本
开篇:王总说了三分钟,你回去干了三天
周三下午两点十分,公司三楼那间叫”西溪”的小会议室。
那屋子只能坐六个人,空调外机就挂在窗户外头,一开空调就得把嗓门提高两度。那天来了五个人:王总、阿 May、大刘、Kevin,还有小林。
王总是业务负责人,管着三条业务线的营收。他那天穿了件灰色 polo,人是站着讲的,一只手撑在会议桌上,另一只手时不时看手机。
“优惠券这个事,我说一下。“王总开口,“现在拉新成本太高了,市场那边一个有效用户的获客成本是三十七块。这个数下不来,全年的目标就完不成。所以需要一套券。”
阿 May 问:“券的玩法定了吗?”
“先不用复杂。“王总说,“就是满减,简单点,像拼多多那样,用户进来就能领,领了就能用。”
“门槛和面额谁定?”
“运营定,后台配一下就行。”
大刘插了一句:“有没有总量限制?超发了算谁的?”
“肯定不能超发。“王总说,“这个要有控制,预算就那么多,花超了我没法交代。”
Kevin 问:“后续要支持叠加吗?比如满减券和折扣券一起用。”
“要。“王总说,“以后肯定要。咱们以后所有业务线都要走这套,不光拉新,复购、召回、会员权益,全都得用。”
“那这次的范围——”
“不过这次先上个简单的。“王总看了眼手机,“下周能上吗?”
阿 May 说:“下周一?开发时间不够吧。”
“那就先做个能用的版本。“王总已经往门口走了,“先让用户能领到、能用掉,细节后面再补。行,我那边还有个会。”
门关上。小林看了眼手机上的录音,时长 3 分 47 秒。
散会的时候阿 May 在白板上留下六个字:“优惠券系统 v1”。
小林那天晚上没回家吃饭,在公司楼下吃了碗面,七点半回到工位,打开了 Draw.io。
接下来三天,他干了很多活。
第一天,他设计了七张表:coupon_template(券模板)、coupon_instance(券实例)、coupon_rule(规则)、coupon_activity(活动)、coupon_grant_record(发放记录)、coupon_use_record(核销记录)、coupon_settlement(结算流水)。他给每张表都写了注释,字段类型精确到 decimal(10,2) 和 tinyint(1)。
第二天,他画了架构图。四层,他特意用了 DDD 的分层:interface 层放 HTTP handler 和 RPC,application 层放发券、核销、查询三个 service,domain 层放券聚合根和规则引擎,infrastructure 层放 MySQL、Redis、MQ。图用的是标准的 C4 画法,方框是灰底的,箭头分实线和虚线,实线是依赖,虚线是异步事件。
第三天,他做了技术选型。发券走 Kafka 异步化,理由是”削峰填谷”;券库存用 Redis 预扣、MySQL 落库,理由是”高性能”;分库分表按 user_id 取模,拆 64 张表,理由是”为未来三年留余量”;规则引擎他调研了 Aviator 表达式引擎,还在文档里写了对比:Aviator 的 QPS 是 12 万,Groovy 是 3 万,自研 DSL 不可控。
周四晚上十一点半,他把 42 页的设计文档发到了项目群里,配文:“优惠券系统设计初稿,大家有空看下,周五下午过一下。”
周五中午,他在工位上刷了不下二十次群消息。
阿 May 回了个”收到”。
大刘回了一句:“这个 Kafka,我们那边现在有现成的集群吗?”
Kevin 没说话。
然后是那句让他心里咯噔一下的——王总在群里 @ 了阿 May:“这个东西,能不能先让运营下周用上?”
小林盯着那行字看了很久。他花了三天做的东西,王总看都没看。
周五晚上七点,公司楼下的兰州拉面。
老陈这次没约茶室。他下午在附近见个人,顺手给小林发了条消息:“晚上一起吃个面?七点,你公司楼下那家。”
小林到的时候老陈已经坐下了,面前摆着一碟牛肉,两碗面还没上。
“最近在忙什么?“老陈问。
这句话像是个开关。小林从王总那三分钟讲起,讲到七张表,讲到四层架构,讲到 Kafka 和分库分表,讲到 Aviator 的 QPS 对比。他讲得很快,中间掏出手机翻了两张架构图给老陈看。
老陈一边听一边夹牛肉,等他说完,问了一句。
“你设计的是什么东西?”
“优惠券系统啊。”
“不,“老陈把筷子放下,“你设计的是你猜的那个优惠券系统。”
小林愣了一下。
“你回去问过王总吗?这个系统到底要解决什么问题?”
“他不是说了吗,拉新成本高,获客成本三十七块钱。”
“这是他说的唯一一个有数字的信息,你还真记住了。“老陈说,“那我问你第二个问题:他说的’三十七块’这个数,做完优惠券之后,要降到多少?”
”……”
“三十五?三十?还是二十五?”
“他没说。”
“那你怎么知道你做的这个东西算成功?”
小林张了张嘴,没发出声音。
“还有,“老陈接着说,“他说’像拼多多那样,用户进来就能领’。又说’预算就那么多,不能超发’。这两句话是矛盾的,你知道吗?”
“怎么矛盾了?设个总量不就行了。”
“总量设多少?”
”……运营定吧。”
“运营定多少,是根据什么定的?是根据预算,还是根据目标?如果目标是拉新一万个人,预算只够发五千张券,那剩下五千个人进来领不到券,‘进来就能领’这句话是不是就破了?“老陈说完,把最后一片牛肉吃了。
小林没说话。他脑子里在转那 42 页文档。
“你想想,“老陈说,“王总讲了三分四十七秒。这三分四十七秒里,他给了你一个背景、两个诉求、三个约束、四个’以后再说’。你回去干了三天,把这三分四十七秒,变成了四十二页。”
“那我应该怎么办?不设计?”
“我没说不设计。“老陈说,“我是说,你跳过了中间那一步。”
“哪一步?”
老陈用筷子在桌上蘸了点水,画了两个方块,中间画了个箭头。
“P6 是:给我一个清晰的问题,我给你一个漂亮的方案。”
他在第一个方块上点了点。
“P7 是:给我一团模糊的诉求,我给你一个清晰的问题,再加一个方案。”
筷子移到中间那个箭头上。
“大部分人一辈子卡在 P6,因为他们从来没意识到,‘定义问题’是一项独立的、可以训练的能力。”
面的热气上来了。小林低头搅了两下,没吃。
“老陈,我说句实话。“他抬起头,“去年评 P7,评审意见我到现在都能背下来:‘技术能力扎实,具备独立承担复杂模块的能力,但缺乏从 0 到 1 定义问题和推动落地的能力’。”
“嗯。”
“我当时觉得这句话是放屁。“小林说,“什么叫’定义问题’?需求是王总给的,阿 May 画的原型,我一个写代码的,我能定义什么?”
“现在呢?”
“现在我觉得……”他停了一下,“我现在觉得,我可能确实不知道那句话是什么意思。”
老陈笑了。
“你知道你刚才那三天,最值钱的是哪一部分吗?”
“架构图?”
“不是。是你打开 Draw.io 之前的那个念头——‘这事有点不对劲,但我说不上来哪儿不对劲’。”
小林看着他。
“你其实感觉到了。你感觉到了王总那三分钟里有东西是矛盾的,你感觉到了’下周上’和’以后支撑全公司’不兼容。你的处理方式是:把它们都塞进架构里,用 Kafka 解决,用分库分表解决,用规则引擎解决。”
“不然呢?”
“不然你就该在周四早上,敲开王总的门,问一句:‘王总,下周上线那个版本,和以后支撑全公司那个版本,是同一个东西吗?’”
小林沉默了很久。
“我不敢问。”
“为什么?”
“因为……”小林想了想,“因为我怕他觉得我连这个都想不明白。”
老陈点点头,没评价。他把碗里最后两口面吃了,抽出一张纸巾擦嘴。
“我三十四岁那年,做过一套报表系统。“他说,“做了四个月,十七张报表,支持自定义维度、支持导出 Excel、支持订阅推送到邮箱。技术上挑不出毛病,我还专门做了个查询缓存,把平均响应时间从 8 秒压到 600 毫秒。”
“然后呢?”
“上线三个月,没人用。“老陈说,“我去问销售总监,他说:‘哦,那个啊,我们一直用 Excel,挺好的。’”
”……”
“我当时特别生气,觉得这帮人不懂。“老陈说,“后来我才想明白:我做了四个月,做了十七张报表,但是我从来没问过一句——‘你们现在是怎么做的?做这个东西,你想省下的是哪两个小时?’”
小林坐在那儿,手撑着额头。
“你现在回答不了,说明你还没到那一步。“老陈说,“但这不是骂你,这是个坐标。“
4.1 需求是问出来的,不是猜出来的
“那我到底该怎么问?“周六上午,小林约了老陈在公司附近的咖啡馆。他带了个本子,翻开是空白的。
老陈看了眼那个本子:“你打算记什么?”
“记问王总的问题清单。”
“那你已经错了。“老陈说。
“啊?”
“清单是’我要问什么’。真正难的是’我不该问什么’。”
小林把笔放下。
“我先问你个事。“老陈说,“王总那三分四十七秒,如果让你复述,你能复述出多少?”
小林想了想:“拉新成本高,获客成本三十七块。要做优惠券,先做满减,简单点,像拼多多那样,进来就能领。要有总量控制,不能超发。以后要支持叠加,以后要支撑全公司业务线。这次先上简单的,下周能用。”
“挺全。“老陈说,“那这里面,哪些是诉求,哪些是约束,哪些是他的个人想象?”
”……”
“‘像拼多多那样’是个人想象。‘不能超发’是约束。‘拉新成本高’是问题陈述,而且它是唯一一句带数字的。”
“这有区别吗?”
“区别大了。“老陈说,“问题是’现在的状况和期望的状况之间有差距’。约束是’你不能用什么方式去补这个差距’。想象是他脑子里蹦出来的第一个解法——而他往往把想象当成了需求说给你听。”
小林在空白本子上画了三道线。
“工程师最容易犯的错,“老陈继续说,“是把’想象’当成’需求’接过来,然后开始设计。王总说’像拼多多那样’,你回去就开始想拼多多的券是怎么发的。可他要的根本不是拼多多的券,他要的是’获客成本从三十七块降下来’。”
“所以……”
“所以你要问的第一个问题不是’你要什么样的券’,而是:如果这套券做出来,半年之后,哪个数字变了,你会觉得这钱花值了?”
小林记下来,又问:“他会答吗?”
“大概率不会。“老陈说,“他会说’你看着办’或者’这个我得想想’。这时候你要追第二句:那这个数,现在是三十七块。做到多少,你能在年底的会上说得过去?”
“他如果说三十五呢?”
“你就拿到了一个 P7 级别的东西:一个可以倒推的锚点。”
“倒推什么?”
“三十七到三十五,是降两块,降幅 5%。按现在市场部一个月拉新八千人算,一年省下来的钱不到二十万。二十万的项目,你上 Kafka?上分库分表?上规则引擎?“老陈把杯子往桌上一放,“你自己说。”
小林没吭声。
“但如果他说的是’降到二十五’,那就是降 32%,一年省两百多万。这个体量的项目,你可以上 Kafka,可以上规则引擎,甚至值得单独招个人。”
“所以同样一句话,数字不同,架构完全不同。”
“对。“老陈说,“架构不是从需求来的,架构是从约束的量级来的。 你那 42 页设计里,缺的不是架构,是量级。”
“可是,“小林有点不服,“这些是产品经理的活儿吧?阿 May 是干嘛的?”
“阿 May 会问,但她问的是用户侧。“老陈说,“她会问’用户领券的流程是几步’、‘券过期了要不要提醒’、‘用户看到券但用不了会不会投诉’。这些她比我懂。”
“那工程师问什么?”
“工程师问约束侧。”
老陈伸出手指头数:
“第一,这个东西的数据量级是多少?日活三十万的公司,和日活三千万的公司,同一句’做个优惠券’,是两个完全不同的系统。”
“第二,它的失败代价是什么?券少发了一千张,是运营不高兴;券超发了一千张,是财务不高兴;券被人刷走了十万张,是你半夜被叫起来。这三个’不高兴’,对应三种完全不同的设计。”
“第三,它的生命周期有多长?王总说’下周能用’,如果是真的,那这是个三个月就下线的实验;如果他说’以后支撑全公司’也是真的,那这是个三年的平台。这两件事不可能同时成立,你必须让他选一个。”
“第四,什么是明确不做的?”
小林抬头:“这条我问过。”
“你什么时候问的?”
”……没问。”
“你看。“老陈说,“‘不做什么’这件事,比’做什么’更能定义一个系统。你说你设计了七张表,我问你:结算那张表,是这次要做的吗?”
“不是,我留了扩展。”
“扩展。“老陈重复了一遍这个词,“你知道这 42 页文档里,‘扩展’两个字出现了多少次吗?”
小林愣住了。他回去翻了一下,十九次。
“十九次扩展,没有一次’不做什么’。“老陈说,“一个什么都不放弃的设计,等于什么都没定义。”
“我再给你讲个事,是我自己干的。“老陈说。
“2015 年,我在一家做 SaaS 的公司。销售那边提需求:客户想看自己账号下所有员工的使用情况。这个需求从销售传到产品经理,再传到研发,最后到我这的时候,变成了一句话:‘做个数据看板’。”
“我手下有个小孩,特别能干,花了六周做了一个。支持按部门筛选、按时间维度聚合、支持导出、支持定时邮件。上线了。”
“然后呢?”
“然后客户打来电话说,我要的不是这个。”
“他要什么?”
“他要的是:找出三个月没登录过的员工,好把他们的席位退掉。”
老陈喝了一口咖啡。
“一个’看板’和一个’筛选器’,中间的差距是六周。而如果那个小孩当时问过一句’你拿到这个数据之后,第一个动作是什么’,他两周就能做完。”
小林沉默了一会儿。
“我问你个事。“老陈说,“你先想想,为什么销售传话的时候,把’找出三个月没登录的人’变成了’做个数据看板’?”
“因为……他不会说技术语言?”
“不是。因为每个人在转述的时候,都会下意识地加上一个自己的解法。销售加了一层,产品经理加了一层,到研发手里,原始的问题已经被包了三层壳。”
“那怎么办?”
“往回追。追到那个’没有这个东西的时候,人是怎么干的’。”
“什么叫’人是怎么干的’?”
“就是:现在没有优惠券系统,市场部想拉新,他们在干嘛?”
小林想了想:”……他们在微信群发红包?”
“你看,你自己都知道。“老陈说,“任何一个新系统被提出来的时候,一定已经存在一个’土办法’。那个土办法,才是你真正的竞争对手。”
这句话让小林坐直了。
“你做的优惠券系统,不是跟竞品比,是跟’运营在 Excel 里生成一串码,手动发给用户’比。如果这套东西做出来,运营还是觉得 Excel 顺手,你就输了,不管你用了多少 Kafka。“
生活里的同一件事:装修时那句”我想要个敞亮点的客厅”
小林去年装修过一次房,是被中介坑的那个老破小,六十八平。
他记得很清楚,第一次跟工头在屋里量房,他站在客厅中间,说了这么一句:
“我想要个敞亮点的客厅。”
工头姓吴,四十多岁,安徽人,叼着烟在门口站了两分钟没说话。
然后他问了第一个问题:“这房子,平时几口人住?”
“就我一个,偶尔我爸妈来住几天。”
“平时在家吃饭多吗?”
“不多,基本在外面吃,周末可能自己做个一两顿。”
“那你看电视多吗?”
“不看,我有投影仪,一般就周末看个电影。”
“哦。“吴工头在本子上记了几笔,“那你现在这个客厅,最让你不爽的是什么?”
小林想了想:“一进门就看见沙发靠背,堵得慌。还有阳台那个推拉门,占地方,光线还被切了一半。”
“行,我知道了。”
后来吴工头给的方案,小林到现在都觉得是整个装修里最值的一笔钱:
第一,他把客厅和阳台之间那堵配重墙以上的部分全打掉了,换了一整面落地玻璃。采光面积直接翻倍。
第二,他把沙发从”靠墙正对电视”改成了”斜着放,背靠一个半高的书柜”。这样一进门看见的是书柜的侧面,不是沙发靠背。
第三,他没给电视墙做任何造型,连石膏线都没走。省下来的四千多块钱,全花在了那面玻璃上。
整套下来,客厅的硬装部分花了两万八。
小林后来跟隔壁单元的邻居聊天。那家也是六十八平,也是跟工头说”我想要个敞亮点的客厅”。
那家的工头怎么干的?
砸墙。把客厅和次卧之间那堵墙砸了,做成一个开放式的书房。然后吊顶走了一圈灯带,电视墙贴了整面的大理石,还在沙发上方装了四个筒灯。
硬装花了七万六。
结果呢?那家客厅确实亮了,因为多了四个筒灯。但白天不开灯的时候,客厅比小林家暗,因为次卧那堵墙砸了之后,客厅变得又长又窄,采光面一点没变,反而纵深更长了。
而且那家后来后悔得要死:次卧没了,父母一来就没地方住。
这事儿和优惠券系统有什么关系?
“我想要个敞亮点的客厅”,和”我要一套优惠券系统”,是同一类句子。它们都是想象,不是需求。
吴工头问的那几个问题,是有讲究的:
“几口人住”——问的是规模。一个人住和三口之家,“敞亮”的解法完全不同。
“平时在家吃饭多吗""看电视多吗”——问的是真实使用场景。这决定了客厅的功能是”待客”还是”自己窝着”。
“最让你不爽的是什么”——这一句最狠。它不问”你想要什么”,它问”你现在疼在哪儿”。这一问,直接问出了”沙发靠背堵门”和”推拉门切光线”这两个具体的痛点。
他没有问”您预算多少”。这是个很妙的细节——他先问清楚了要干什么,再谈钱。因为如果先问预算,七万六的业主和两万八的业主,他给的方案是一样的:把钱花完。
而隔壁那个工头,听到”敞亮”两个字,脑子里跳出来的第一个解法是”加灯”和”打通”。他没验证过这个解法是不是对的,就直接干了。
小林那三天做的事,和隔壁那个工头,一模一样。
还有最后一层,是老陈后来说的,我觉得值得单独拎出来:
吴工头那两分钟的沉默,值四千块钱。
他站在门口抽烟,一句话没说。那两分钟他在干什么?他在忍住”给答案”的冲动。
工程师最难忍的,就是这个。别人问你一个问题,你脑子里立刻蹦出三个方案,你憋不住。但那个”憋住”的两分钟,恰恰是从 P6 到 P7 的分界线。
Go 实例:把”我猜的需求”变成”可被证伪的断言”
老陈说的那句”一个什么都不放弃的设计,等于什么都没定义”,小林一开始没太理解。直到他试着用代码表达。
先看看他最初写的发券函数(这是大多数人的第一版,也是他四十二页文档里真正的那一版):
package main
import (
"errors"
"fmt"
)
// Coupon 券模板
type Coupon struct {
ID string
Amount int // 面额,单位:分
MinSpend int // 使用门槛,单位:分
Total int // 发放总量上限
Issued int // 已发放数量
PerUser int // 每人限领
Stackable bool // 能否与其他券叠加
}
// IssueV1 是最常见的写法:每一条"需求"对应一个 if。
func IssueV1(c *Coupon, userGot int, orderAmount int) error {
if c.Issued >= c.Total {
return errors.New("已发完")
}
if userGot >= c.PerUser {
return errors.New("超过限领")
}
if orderAmount < c.MinSpend {
return errors.New("未达门槛")
}
c.Issued++
return nil
}
func main() {
c := &Coupon{ID: "new-user-30", Amount: 3000, MinSpend: 0,
Total: 5000, PerUser: 1, Stackable: true}
fmt.Println(IssueV1(c, 0, 9900))
}这段代码能跑,测试也能过。它的问题在哪儿?
它把三条互相打架的约束,变成了三条互不说话的 if。
“预算不超发”(Total)、“进来就能领”(PerUser + MinSpend=0)、“以后要支持叠加”(Stackable)——这三句话是王总在三分钟里说的,其中前两句在预算有限时是直接冲突的。可是在 IssueV1 里,这个冲突被藏起来了:它表现为”第 5001 个用户进来,领不到券”,仅此而已。
更糟的是 Stackable 这个字段。它被定义了,但从头到尾没被用过。小林写了它,是因为他”记住”了 Kevin 那句”以后要支持叠加”。记住需求,和讨论需求,是两件事。
第二版,他试着把约束从代码里”拎出来”:
package main
import (
"fmt"
"strings"
)
// Scenario 描述一个具体场景,而不是一个函数签名。
type Scenario struct {
Coupon Coupon
UserGot int // 该用户已领数量
OrderAmount int // 订单金额,单位:分
IsNewUser bool
OtherCoupon int // 同时使用的另一张券的面额
GrossMargin float64 // 该订单毛利率
}
// Constraint 是一条可以被单独讨论、单独测试、单独推翻的业务约束。
type Constraint struct {
Name string
Source string // 谁、在什么场合提出来的
Check func(Scenario) error
}
// SpecV2 把王总那三分钟拆成四条约束,每条都标注了来源。
var SpecV2 = []Constraint{
{
Name: "预算不超发",
Source: "王总 06-12 需求会(回应大刘提问)",
Check: func(s Scenario) error {
if s.Coupon.Issued+1 > s.Coupon.Total {
return fmt.Errorf("发放量 %d 将超过预算上限 %d",
s.Coupon.Issued+1, s.Coupon.Total)
}
return nil
},
},
{
Name: "新用户进来就能领,零门槛",
Source: "王总 06-12 需求会(原话:像拼多多那样)",
Check: func(s Scenario) error {
if s.IsNewUser && s.Coupon.MinSpend > 0 {
return fmt.Errorf("新用户券不应有门槛,当前 %d 分", s.Coupon.MinSpend)
}
return nil
},
},
{
Name: "支持多券叠加",
Source: "Kevin 06-12 需求会(王总认可,未确认本期范围)",
Check: func(s Scenario) error {
if s.OtherCoupon > 0 && !s.Coupon.Stackable {
return fmt.Errorf("该券不可叠加,但场景中存在第二张券")
}
return nil
},
},
{
Name: "单笔订单不得负毛利",
Source: "推测:财务口径(未经确认,需找王总核对)",
Check: func(s Scenario) error {
discount := s.Coupon.Amount + s.OtherCoupon
profit := float64(s.OrderAmount)*s.GrossMargin - float64(discount)
if profit < 0 {
return fmt.Errorf("订单 %d 分,毛利 %.0f 分,优惠 %d 分,净亏 %.0f 分",
s.OrderAmount, float64(s.OrderAmount)*s.GrossMargin,
discount, -profit)
}
return nil
},
},
}
// Verify 跑一遍所有约束,返回全部违反项,而不是第一条。
func Verify(spec []Constraint, s Scenario) []string {
var violations []string
for _, c := range spec {
if err := c.Check(s); err != nil {
violations = append(violations,
fmt.Sprintf("[%s] %s —— 来源:%s", c.Name, err, c.Source))
}
}
return violations
}注意几个变化:
其一,Scenario 不是函数参数,是一个可以被枚举出来的场景对象。这意味着你可以把”新用户 + 用券 + 叠加会员折扣 + 毛利率 15%“做成一个具体的用例,而不是塞进脑子里的想象。
其二,每条约束都带 Source。这个字段看着多余,实际上它是整个设计里最值钱的东西——它让”谁说的”和”说了什么”绑在一起。当三个月后有人问”为什么券不能叠加”时,你能指出是 Kevin 在 6 月 12 日提的、王总当时没确认范围。
其三,Verify 返回全部违反项,不是第一条。这一点很关键:IssueV1 遇到第一个 if 就返回了,所以你永远看不到”三条约束同时被违反”这种事。而真实的线上事故,几乎都是多条约束同时失效造成的。
第三版,是把这些约束变成 CI 里会跑的东西:
package main
import (
"fmt"
"os"
"testing"
)
// Scenario(同上,略)
// SpecV2(同上,略)
// Verify(同上,略)
// 这些场景不是"测试用例",是"需求的几种可能的未来"。
var scenarios = []struct {
name string
s Scenario
}{
{
name: "新用户首单,只用新人券",
s: Scenario{
Coupon: Coupon{ID: "new-30", Amount: 3000, MinSpend: 0, Total: 5000, Issued: 100, PerUser: 1, Stackable: true},
OrderAmount: 9900, IsNewUser: true, GrossMargin: 0.35,
},
},
{
name: "新用户首单,新人券 + 会员 9 折券叠加",
s: Scenario{
Coupon: Coupon{ID: "new-30", Amount: 3000, MinSpend: 0, Total: 5000, Issued: 100, PerUser: 1, Stackable: true},
OrderAmount: 9900, IsNewUser: true, OtherCoupon: 990, GrossMargin: 0.15,
},
},
{
name: "第 5001 个新用户(预算已用尽)",
s: Scenario{
Coupon: Coupon{ID: "new-30", Amount: 3000, MinSpend: 0, Total: 5000, Issued: 5000, PerUser: 1, Stackable: true},
OrderAmount: 9900, IsNewUser: true, GrossMargin: 0.35,
},
},
}
func TestSpecConsistency(t *testing.T) {
for _, tc := range scenarios {
v := Verify(SpecV2, tc.s)
if len(v) == 0 {
t.Logf("场景[%s]:所有约束成立", tc.name)
continue
}
// 关键:冲突不是"测试失败",是"需求需要被澄清"。
t.Logf("场景[%s] 存在 %d 处约束冲突:", tc.name, len(v))
for _, msg := range v {
t.Logf(" %s", msg)
}
}
}
// CheckSpecOnBoot 在服务启动时做一次约束自检。
// 这不是过度设计:它把"需求矛盾"从线上事故,提前到启动失败。
func CheckSpecOnBoot(spec []Constraint, s Scenario) {
v := Verify(spec, s)
if len(v) > 0 {
fmt.Fprintln(os.Stderr, "启动自检失败,需求约束存在未解决的冲突:")
for _, msg := range v {
fmt.Fprintf(os.Stderr, " %s\n", msg)
}
fmt.Fprintln(os.Stderr, "请找需求方确认后再上线。")
}
}跑出来是这样的:
场景[新用户首单,只用新人券]:所有约束成立
场景[新用户首单,新人券 + 会员 9 折券叠加] 存在 1 处约束冲突:
[单笔订单不得负毛利] 订单 9900 分,毛利 1485 分,优惠 3990 分,净亏 2505 分 —— 来源:推测:财务口径(未经确认,需找王总核对)
场景[第 5001 个新用户(预算已用尽)] 存在 1 处约束冲突:
[预算不超发] 发放量 5001 将超过预算上限 5000 —— 来源:王总 06-12 需求会(回应大刘提问)
看第二个场景。
这就是王总那三分四十七秒里的矛盾,第一次以”可被讨论的形式”出现在纸面上。它不是小林脑子里的”好像有点不对劲”,它是一个带数字的句子:9900 分的订单,35 元的商品,叠加之后净亏 25.05 元。
小林拿着这个数字去找王总,对话就完全不一样了。他不再问”您觉得叠加要不要支持”,他问的是:
“王总,如果新用户领 30 元无门槛券,同时还能用会员 9 折,一张 99 元的订单我们净亏 25 块。这个可以吗?”
王总一定会说:“那不行。”
于是约束被澄清了:要么叠加功能本期不做,要么毛利率低于 20% 的品类禁止叠加。 无论哪个答案,都比小林闷头设计一个”支持任意规则叠加”的引擎要强。
第三个场景也一样。它逼出了一个必须被回答的问题:预算用完之后,第 5001 个用户看到什么?
是”券已领完”(诚实但伤害转化),还是”继续发但走另一笔预算”(那就不是”不超发”),还是”发一张小面额的”(那就是两套券)?
这三个选项,对应三套完全不同的实现。而在 IssueV1 那个版本里,这个问题被一句 return errors.New("已发完") 吃掉了。
这里的原则,不是”要写测试”,是:
把模糊的诉求,翻译成一组可以被具体数字证伪的断言。断言之间一旦冲突,冲突会立刻暴露出来,逼着你去问人,而不是逼着你去加一层抽象。
IssueV1 到 SpecV2 的变化,表面上是”把 if 抽成了数据”,本质上是:前者把需求埋进了控制流,后者把需求摆到了桌面上。埋进控制流的需求,你没法跟王总讨论;摆在桌面上的需求,你可以指着第 2 行说”这句话和那句话打架了”。
另外补充一句容易被忽略的:那四条约束里,第四条”单笔订单不得负毛利”的来源我标的是”推测,未经确认”。这个标记本身就是 P7 的动作。 P6 写出来会说”财务要求不能负毛利”,然后被财务打脸;P7 写出来会说”这条是我猜的,需要找王总确认”。承认哪部分是自己猜的,比猜对更重要。
老陈的深层发问
-
你手上正在进行的需求,你能说出”它要改变的那个数字”是多少吗?如果你说不出,你是从什么时候开始不问的?
-
上一次别人给你提需求,你当场提出的第一个问题是什么?那个问题是关于”怎么做”,还是关于”为什么做”?
-
你现在负责的系统里,有没有哪个功能是上线超过三个月、但你从来没去看过有没有人在用的?你敢去看吗?
-
如果你的设计文档里,强制要求删掉所有”未来可以扩展""后续支持""为将来留口子”这类表述,会剩下多少页?
-
王总那句”下周能上吗”,如果换成你来说,你会怎么回答?你的回答里,第一个字是”能”还是”不”?
生活思考题
思考题一:你去医院看病,医生说”我给你开点药”。你会问什么?大多数人不问,拿着处方就走了。为什么?
参考答案
第一层:大多数人会怎么答 “因为医生是专业的,我不懂,问了也听不懂。而且医生都很忙,后面还有一堆病人排队,问多了显得事儿多。再说药是医生开的,吃出问题也是他的责任。”
第二层:这个答案为什么不够 这个答案把”专业”等同于”不可质疑”。可你去看的是你自己的身体,医生看的是一个病例。这两者之间有巨大的信息差,而这个信息差只有你能补。
我有个朋友是内科医生,他说过一句话我记到现在:“我最怕的不是病人问问题,是病人什么都不问。什么都不问的病人,回去有三分之一不按医嘱吃药,因为他没搞清楚为什么要吃。”
更深一层的盲区在这儿:你觉得”问了也听不懂”,其实是因为你问错了问题。你问的是”这药是什么成分”(这是知识问题,确实听不懂),你应该问的是”这药吃三天之后,我身上什么感觉会消失”(这是结果问题,医生能答,你也能判断)。
第三层:更深的一层是什么 医生开药,和甲方提需求,是同一个结构:对方把结论给你,把推理过程留给了自己。
你拿到的是”吃这个药”,你没拿到的是”我判断你是细菌性感染而不是病毒性感染,依据是血常规里中性粒细胞比例 78%,所以上抗生素;如果是病毒性的,这个药不仅没用,还会拉肚子”。
那句推理过程里,藏着三样东西:他的判断依据、他不确定的部分、以及这个判断错了会怎样。 这三样东西,才是你真正需要的东西。
工程师接需求,接的是”我要一个数据看板”,就像病人接的是”吃这个药”。你拿到的永远是结论。而定义问题,就是把推理过程要回来。
要回来之后,你才有资格说:“如果是细菌性感染,那是不是可以先做个咽拭子确认一下,再决定要不要上抗生素?“——这句话,就是技术方案的雏形。
第四层:落到 Go 工程师身上 下一个需求到你手上的时候,试着问这三句,一句都不要少: 一、“现在没有这个东西的时候,你们是怎么干的?“(找出土办法,它才是真竞争对手) 二、“这个做出来之后,半年内哪个数字会变?变成多少算成功?“(拿到可以倒推的锚点) 三、“如果我只能做一半,你希望是哪一半?“(逼出真正的优先级)
然后做一件更具体的事:把对方的回答,写成四条带
Source字段的约束,像上面SpecV2那样。凡是你自己推测的,Source就老实写上”推测,未确认”。写完发给对方确认一遍。你会发现一件事:对方回复你的邮件里,一定会有一条是”这条不对”。那一条,就是你这次提问的全部价值。
思考题二:你请朋友吃饭,问他”想吃什么”。他说”随便”。你真的会随便选吗?你通常会怎么做?
参考答案
第一层:大多数人会怎么答 “不会真随便。我会报几个选项给他:火锅?日料?还是烧烤?让他挑一个。或者直接看他最近朋友圈,猜他最近想吃啥。”
第二层:这个答案为什么不够 “报几个选项”这个做法,比”随便选”好,但它有个隐藏的坑:选项是你给的,不是他给的。
你报的三个选项里,可能一个都不是他想吃的。他说”随便”,然后你说”火锅?“,他说”行吧”。你以为达成了一致,其实他是在三个都不太想吃的里面,选了一个最不讨厌的。
更深的问题在于:你为什么会报这三个选项?因为这三个是你熟悉、你喜欢、或者你觉得”比较安全”的。你把自己的偏好,包装成了他的选择。
第三层:更深的一层是什么 “随便”这句话,在中文语境里从来不是”我真的无所谓”。它的真实含义通常有三种:
第一种是”我不想承担选择的责任,选错了别怪我”。这是社交策略。 第二种是”我有偏好,但我不好意思说”。这是客气。 第三种是”我其实有个明确想吃的,我想看看你猜不猜得到”。这是测试。
这三种,对应的破解方式完全不同。而大多数人的做法——报三个选项——对第一种有效,对第二种半有效,对第三种完全无效。
真正有效的问法,不是给选项,是给约束: “你最近胃不太好,是不是别吃太辣的?” “今天周五,人多,你介意排队吗?” “咱们上次吃的那个日料,你还想再吃吗?”
约束问出来之后,选项会自己浮现,而且是他自己的选项。
回到工程师身上:需求方说”你看着办”的时候,他说的不是”你决定”。他说的是”我不想为这个决定的后果负责”。 这时候你给三个方案让他选,他一定选最保守的那个,然后做出来之后不满意。你应该给的是约束:“如果要下周上线,就只能做 A;如果要支持全公司,就得两个月。您选哪个?”
把选择题变成约束题,这是 P7 的基本功。
第四层:落到 Go 工程师身上 本周找个机会做一次实验:下次有人对方案说”你看着办”或者”都行”,你不要给选项,你给两条带代价的约束,比如:
“方案 A 下周三能上,但只支持满减,以后加折扣券要重构;方案 B 要五周,但规则是可配置的,以后加玩法不用改代码。您选哪个?”
注意这句话的结构:两个方案都带明确的代价,而不是一个好一个坏。 如果你给出的是”方案 A 又快又好,方案 B 又慢又烂”,那不叫选择题,叫逼宫,对方会察觉。
记录下来:对方用了多久回答?回答的时候有没有反问你”还有别的差别吗”?如果反问了,恭喜你,你成功地把”定义问题”的责任,分了一半出去。这一半,本来就该是他的。
4.2 幂等性:这个看似简单的问题,能筛掉一半人
周一上午,小林真的去敲了王总的门。
过程比他想的顺利。王总正在吃早餐,听他问完,抹了抹嘴说了句:“获客成本啊,能降个五块就不错了。三十七到三十二吧。”
“那叠加——”
“叠加这次别做。“王总说,“先把满减跑起来。”
“那以后全公司业务线——”
“那是明年的事。“王总把豆浆杯捏扁了扔进垃圾桶,“小林,我跟你说实话,我上次说那些,是因为开会的时候 Kevin 在那儿。他要是不问,我也不会说。”
小林站在那儿,突然有点想笑。
回到工位,他打开那 42 页的文档,开始删。
Kafka 删了。三十七到三十二,降幅 13%,一年省下来的钱六十万上下,不值得为它维护一个消息队列。
分库分表删了。日活三十万,就算活动期间每人领三张券,全年的券实例数据撑死一千万条。MySQL 单表一千万,加个索引,查询在 10 毫秒以内。
Aviator 规则引擎删了。本期只做满减,一个 MinSpend 字段就够了。
七张表删到三张:coupon_template、coupon_instance、coupon_use_record。
四层架构删到两层:handler 直接调 service。
删完,42 页变成 9 页。小林看着那 9 页,心里有种奇怪的感觉——不是轻松,是慌。他花三天做出来的”专业感”,被一个五分钟的对话拆掉了。
“这说明那三天不是白干的。“老陈在电话里说,“你现在删掉的每一样东西,你都知道你为什么删。这比从来没想过要强。”
“可是这样看起来……太简单了。”
“简单是结果,不是目标。“老陈说,“你先做出来,我周三看。”
周三下午,小林把发券接口写完了。他发到组里的代码评审群,@ 了大刘。
大刘的回复在四十分钟后,只有一句话:
“这个接口,上游超时重试一次,会发几张券?”
小林盯着这句话看了十秒,然后打开自己的代码。
func (s *Service) Grant(ctx context.Context, userID int64, tplID string) (*Coupon, error) {
// 先查有没有领过
got, err := s.db.HasGranted(ctx, userID, tplID)
if err != nil {
return nil, err
}
if got {
return nil, ErrAlreadyGranted
}
// 没领过就发
return s.db.Insert(ctx, &Coupon{UserID: userID, TplID: tplID})
}他愣住了。
他当然知道这样有问题。可是……他写了五年 Go,写过无数个这样的函数,从来没出过事。
大刘紧接着发了第二条:
“还有,‘上游’不一定是超时重试。用户手抖点两下,前端没做防抖,也是两次。CDN 回源重试,也是两次。你上次那个导出功能,用户点了一下没反应,连点了七次,你不记得了?”
小林记得。那次是导出了七份一模一样的 Excel,把 OSS 流量刷了一个尖峰。
“幂等性这个词,“老陈说,“十个工程师里有九个能背出定义:‘同一个请求执行多次,结果和执行一次相同’。”
“这不是挺简单的吗?”
“定义简单。“老陈说,“难的是下面三个问题。”
他伸出三根手指。
“第一,幂等的边界在哪?是对同一个请求 ID 幂等,还是对同一个用户幂等,还是对同一笔业务幂等?”
“第二,‘执行多次’的那几次,在前一次还没执行完的时候进来了,怎么办?”
“第三,如果第一次执行成功但返回超时了,第二次进来,你是返回成功还是返回重复错误?”
小林想了想:“第三个……应该是返回成功吧?”
“为什么?”
“因为……用户确实领到了券,你告诉他’你已经领过了’,他会以为没领到,然后又去投诉。”
“你看,你自己知道。“老陈说,“但你的代码里,返回的确实是 ErrAlreadyGranted。”
”……”
“我再问你一个。“老陈说,“‘结果相同’,相同到什么程度?”
“什么意思?”
“第一次请求返回的是 {"code":"CPN20240612xxxx","expire_at":"2024-07-12"}。第二次请求进来,你返回一个 ErrAlreadyGranted。这两次的结果相同吗?”
小林沉默了。
“幂等不是’不报错’,幂等是’第二次请求应该拿到和第一次一样的返回值’。“老陈说,“你要把第一次的结果存下来。”
“存下来……存哪儿?”
“这就是第四问了。“老陈笑了,“你看,一个’挺简单’的概念,问四层就问不下去了。“
生活里的同一件事:电梯按钮,和取号机
小林租的那栋楼,十八层,两部电梯。
他注意过一个细节:电梯厅那个上下的按钮,你按一下,灯亮了。你再按三下,灯还是亮的,不会多亮。而且——电梯不会因为你按了四下,就派四部梯上来。
这个按钮,就是幂等的。
去年冬天有一次,电梯坏了,维修师傅在梯里捣鼓了二十分钟。这二十分钟里,小林按了不下十次按钮。如果每按一次就派一部梯,那栋楼当天得来十部电梯。
而同一层楼的另一台机器,是完全相反的。
那台机器在一楼大厅,是物业放的快递取件叫号机。屏幕上有个很大的圆形按钮,写着”取号”。
小林亲眼见过一个外卖小哥,用手指头”哒哒哒”连点了六下。
机器吐出来六张纸条。
那个小哥愣了一下,骂了句脏话,把五张纸条团了扔进旁边的垃圾桶。
同一个人在同一栋楼里,一个按钮是幂等的,一个不是。 区别在哪儿?
不是技术难度。取号机要做到幂等,技术上一点都不难——加个一秒的冷却时间就够了。
区别在于设计者有没有想过”用户会连点”这件事。
电梯按钮的设计者想过。因为电梯这个东西,从 1850 年代就有,一百多年里,按按钮的人里面有老人、有小孩、有喝醉的、有赶时间的、有按着玩儿的。设计者见过太多次连点,所以从一开始,按钮的语义就是”我要上楼”,而不是”请派一部梯”。
取件叫号机的设计者没想过。因为这台机器是某个软件公司三个月做出来的,交付给物业,验收标准是”能出号”。它的按钮语义是”吐一张纸条”。
语义上的一字之差,决定了它是不是幂等的。
这里还有第二个细节,更值得琢磨。
电梯按钮的”幂等”,和真正严谨的幂等,还差一层。
设想这个场景:你按了按钮,灯亮了。你等了十分钟,电梯没来。你又按了一下。
这时候电梯终于来了。请问:它来,是因为你按了第一次,还是第二次?
答案不重要——因为你按的是”我要上楼”,这个诉求被满足了。
但换成叫号机:你取了号,等了十分钟没叫到你。你又去取了一个号。现在你手上有两个号:一个是 A003,一个是 A017。
你该用哪个?
机器没告诉你。它甚至不知道这两个号是同一个人取的。
这就是分布式系统里最难搞的那种幂等:不是”重复执行”,是”部分成功后重试”。
电梯那个场景下,你第二次按按钮的时候,第一次的请求还在处理中(电梯正在上升)。这时候你的第二次请求,应该被接受(反正电梯会来),还是被拒绝(已经在来了)?
正确答案是:接受,但它不应该产生新的副作用。电梯来了,你上去了,完事。
而现实中大量的系统,在这个场景下是这么干的:第二次请求进来,查一下”有没有在途请求”,发现有,返回”处理中,请稍后”。
然后用户隔两秒再点一次,还是”处理中”。
再点,还是”处理中”。
直到第一次请求超时失败。
**“处理中”这个状态,是很多系统的幂等性漏洞。**它听起来很严谨,实际上它把问题甩给了调用方,而调用方(尤其是前端)通常的处理方式是:再试一次。
于是并发量翻倍。
小林后来发现,他们公司自己的订单系统里就有这个问题。支付回调过来,如果订单状态是 PAYING,返回一个”处理中”。支付网关那边,“处理中”不是终态,它会重试。
结果就是:一次真实支付,触发了七次回调,其中六次返回”处理中”。
那六次”处理中”,白占了六次数据库连接、六次 CPU、六次日志写入。
在高并发场景下,被”处理中”挡住的请求,不是被挡住了,是被放大了。
Go 实例:一个发券接口的三次写法
先看一下小林最初的版本,也是绝大多数人写的版本:
package main
import (
"context"
"errors"
"fmt"
"sync"
)
var ErrAlreadyGranted = errors.New("已领取过")
// Coupon 券实例
type Coupon struct {
Code string
UserID int64
TplID string
}
// Store 模拟数据库。真实场景里这是 MySQL / TiDB。
type Store struct {
mu sync.Mutex
coupons map[string]*Coupon // key: userID+tplID
}
func NewStore() *Store {
return &Store{coupons: make(map[string]*Coupon)}
}
func (s *Store) HasGranted(userID int64, tplID string) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.coupons[fmt.Sprintf("%d:%s", userID, tplID)]
return ok
}
func (s *Store) Insert(c *Coupon) error {
s.mu.Lock()
defer s.mu.Unlock()
s.coupons[fmt.Sprintf("%d:%s", c.UserID, c.TplID)] = c
return nil
}
// GrantV1:先查后写。教科书式的 check-then-act。
func GrantV1(ctx context.Context, s *Store, userID int64, tplID string) (*Coupon, error) {
if s.HasGranted(userID, tplID) {
return nil, ErrAlreadyGranted
}
c := &Coupon{Code: fmt.Sprintf("CPN%d%s", userID, tplID), UserID: userID, TplID: tplID}
if err := s.Insert(c); err != nil {
return nil, err
}
return c, nil
}
func main() {
s := NewStore()
var wg sync.WaitGroup
// 模拟上游超时重试:同一个用户,同一时刻,20 个请求
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, err := GrantV1(context.Background(), s, 10086, "tpl-new-user")
if err != nil {
fmt.Println("err:", err)
}
}()
}
wg.Wait()
fmt.Println("实际发放数量:", len(s.coupons)) // 不是 1
}跑十次,你会看到发放数量在 1 到 20 之间乱跳。绝大多数时候是 20。
问题出在哪儿?HasGranted 和 Insert 之间有一个窗口。这个窗口在单线程下是纳秒级的,看起来没事;在并发下,它就是发券超发的全部原因。
这一类 bug 最阴险的地方在于:它在测试环境永远不复现。 测试时你是单线程点一下,永远走的是”没领过 → 发”这条路径。
第二版,大多数人的修法是加锁:
// GrantV2:加一把互斥锁
func GrantV2(ctx context.Context, s *Store, userID int64, tplID string) (*Coupon, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.coupons[fmt.Sprintf("%d:%s", userID, tplID)]; ok {
return nil, ErrAlreadyGranted
}
c := &Coupon{Code: fmt.Sprintf("CPN%d%s", userID, tplID), UserID: userID, TplID: tplID}
s.coupons[fmt.Sprintf("%d:%s", c.UserID, c.TplID)] = c
return c, nil
}跑出来一定是 1。测试通过。上线。
然后死在多实例上。
这个服务部署了六个 Pod,sync.Mutex 只保护本进程内的六个 goroutine。六个 Pod 同时收到同一个用户的重试请求,结果是发了六张券。
这就是我要说的第一个关键点:任何只存在于进程内的互斥,都不是分布式幂等。 你把 sync.Mutex 换成 sync.Map、换成 channel、换成 singleflight,全都一样——它们防的是并发,不是重复。
顺带提一句 singleflight:它确实能合并同一个进程内的并发请求,是个好东西,但它只在”六个请求同时到达同一个 Pod”时有效。如果六个请求分到了六个 Pod,它一点用都没有。很多人把 singleflight 当幂等方案用,这是错的。它是并发去重,不是幂等。
第三版,正确的做法是把幂等下沉到唯一约束上:
package main
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
var (
ErrDuplicateKey = errors.New("duplicate key") // 模拟 MySQL 1062
ErrInFlight = errors.New("请求正在处理中")
)
// IdemRecord 幂等记录。这是整套设计的中心。
type IdemRecord struct {
Key string // 幂等键
State string // "processing" | "done" | "failed"
Result *Coupon // 首次成功的返回快照
CreatedAt time.Time
}
// StoreV3 用 map 模拟一张带唯一索引的表。
// 真实场景:UNIQUE KEY uk_idem (idem_key)
type StoreV3 struct {
mu sync.Mutex
idem map[string]*IdemRecord
coupons map[string]*Coupon
}
func NewStoreV3() *StoreV3 {
return &StoreV3{
idem: make(map[string]*IdemRecord),
coupons: make(map[string]*Coupon),
}
}
// InsertIdem 模拟:INSERT INTO idem ... 靠唯一索引挡住重复。
// 关键点:插入 + 判重是原子的一次操作,不存在 check-then-act 窗口。
func (s *StoreV3) InsertIdem(key string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.idem[key]; ok {
return ErrDuplicateKey
}
s.idem[key] = &IdemRecord{Key: key, State: "processing", CreatedAt: time.Now()}
return nil
}
func (s *StoreV3) FinishIdem(key string, c *Coupon) {
s.mu.Lock()
defer s.mu.Unlock()
r := s.idem[key]
r.State = "done"
r.Result = c
}
func (s *StoreV3) AbortIdem(key string) {
s.mu.Lock()
defer s.mu.Unlock()
if r := s.idem[key]; r != nil {
r.State = "failed"
}
}
func (s *StoreV3) LoadIdem(key string) (*IdemRecord, bool) {
s.mu.Lock()
defer s.mu.Unlock()
r, ok := s.idem[key]
return r, ok
}
func (s *StoreV3) InsertCoupon(c *Coupon) error {
s.mu.Lock()
defer s.mu.Unlock()
s.coupons[c.Code] = c
return nil
}
// GrantV3:完整的幂等发券。
func GrantV3(ctx context.Context, s *StoreV3, userID int64, tplID string) (*Coupon, error) {
key := fmt.Sprintf("grant:%d:%s", userID, tplID)
// 第一步:抢占幂等键。成功 = 我是第一个,我负责执行业务。
err := s.InsertIdem(key)
if err == nil {
// 我是第一个。执行业务。
c := &Coupon{Code: fmt.Sprintf("CPN%d%s", userID, tplID), UserID: userID, TplID: tplID}
if e := s.InsertCoupon(c); e != nil {
s.AbortIdem(key) // 失败就把键置为 failed,允许后续重试
return nil, e
}
s.FinishIdem(key, c)
return c, nil
}
if !errors.Is(err, ErrDuplicateKey) {
return nil, err // 真·数据库错误,不是重复
}
// 第二步:我不是第一个。查这条幂等记录的当前状态。
r, _ := s.LoadIdem(key)
switch r.State {
case "done":
// 关键:返回首次的结果,而不是报错。
return r.Result, nil
case "failed":
// 上一次失败了,删除旧记录后重试(真实场景用 UPDATE 而不是 DELETE)
return nil, errors.New("上一次执行失败,请稍后重试")
default:
// "processing":前一个请求还在跑
return nil, ErrInFlight
}
}
func main() {
s := NewStoreV3()
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c, err := GrantV3(context.Background(), s, 10086, "tpl-new-user")
if err != nil {
fmt.Println("err:", err)
} else if c != nil {
fmt.Println("拿到券:", c.Code)
}
}()
}
wg.Wait()
fmt.Println("实际发放数量:", len(s.coupons)) // 恒等于 1
}这个版本跑一百次,发放数量恒等于 1。但真正值得说的不是这个结果,是里面几个设计决策。
第一,幂等键的粒度。
grant:{userID}:{tplID} 的语义是:一个用户在一个券模板下,一辈子只能领一次。
这个语义对不对?取决于业务。如果是”新人券”,对;如果是”每天签到领券”,错,应该是 grant:{userID}:{tplID}:{date};如果是”活动期间限领 3 张”,那这个键根本不该是幂等键,而应该是个计数器。
幂等键最常见的错误,是把”限次”当成了”幂等”。 限次是业务规则(可以做 1 次、3 次、10 次),幂等是协议保证(同一个请求只能生效 1 次)。这两件事经常被混为一谈,混了之后就会出现”用户领了 3 张,但第 2 张因为幂等键冲突失败了”这种诡异现象。
正确的做法是两层:
- 请求幂等键:
request_id(上游生成的 UUID)或biz_key(业务唯一标识),用于识别”是不是同一个请求”。 - 业务限次:单独的计数表/计数器,用于判断”还能不能再领”。
上面代码里的 key 是业务幂等键。如果要严格,还应该接受上游传的 request_id,两条都存。
第二,“done” 分支返回首次结果,而不是报错。
这一行 return r.Result, nil 是整套设计里最容易被忽略、也最体现功力的地方。
很多人的实现是 return nil, ErrAlreadyGranted。区别是:
- 返回首次结果:上游收到 200 + 券码,流程继续。
- 返回错误:上游收到 4xx/5xx,它会重试。而重试又会命中幂等,又返回错误,又重试……直到重试次数耗尽,用户投诉。
这就是为什么”幂等”和”重试”必须一起设计。你做了一个幂等的接口,但返回了一个会被重试的错误码,等于没做。
第三,“processing” 状态怎么处理?
我在上面的代码里返回了 ErrInFlight,但老实说,这不是标准答案,这只是一个”有意识地做出的选择”。
三种可选处理:
- 返回错误(当前实现):简单,但会引发上游重试,前面说过这是把问题甩给别人。
- 轮询等待:在服务内部自旋等前一个请求完成,比如
for i := 0; i < 30; i++ { time.Sleep(100ms); 查一次状态 }。好处是对上游透明,坏处是占着连接和 goroutine。 - 直接返回成功(乐观):如果业务上”多发一张”的代价低于”用户拿不到”的代价,可以直接返回。这听起来很荒谬,但在某些场景下是对的。
选哪个?取决于代价对比:
- 一张券的面额是 30 元,毛利率 35%,多发一张亏 19.5 元 → 选 1 或 2。
- 一张”1 元无门槛券”,多发一张亏 0.65 元,而用户因为拿不到券流失掉,损失 37 元获客成本 → 选 3。
这就是 P7 和 P6 在幂等这个问题上的真正分野:P6 能写出不超发的代码,P7 能说清楚”在什么条件下,允许超发”。
第四,那个被我省略掉的东西:过期时间。
真实实现里,幂等记录不能永远留着。常见的做法是给它一个 TTL,比如 7 天。
但这里有个坑:如果 TTL 是 7 天,而业务上用户在第 8 天点了一次重试(比如他的客户端缓存了失败请求),会怎样?
会再发一张券。
所以幂等键的 TTL,必须大于等于业务上”同一个操作可能被重复触发”的最大时间窗口。这个窗口是多少?取决于上游的重试策略、用户的耐心、客户端的缓存时间。
我见过一个真实的例子:某支付系统的幂等键 TTL 设的是 24 小时,理由是”我们的重试最多重试 24 小时”。后来接入了一个对账系统,那个系统每天凌晨跑一次,把昨天的失败交易重新提交一遍。结果所有超过 24 小时的失败交易,全部被重复处理。
幂等键的 TTL 不是一个技术参数,是一个业务参数。 你设下的每一个 TTL,都是在回答”多久之后,我认为这是一次全新的请求”。
老陈的深层发问
-
你负责的所有写接口里,有几个做了幂等?那几个的幂等键分别是什么?你能不查代码说出来吗?
-
你有没有过这样的经历:线上出现了重复数据,你修的方式是在上游加了个防重,而不是在下游加幂等?你当时为什么这么选?
-
如果让你在”宁可少发一张券,让用户投诉”和”宁可多发一张券,让财务找你”之间选一个,你选哪个?你的选择,和你们公司的实际业务,是一致的还是相反的?
-
你的服务对上游承诺的是”最多一次”、“至少一次”还是”恰好一次”?如果你的回答是”恰好一次”,请说出你为此付出了什么代价。
-
你设计的幂等方案,如果上游的重试间隔是 30 天,还成立吗?你不改代码的话,会怎样?
生活思考题
思考题一:你在 ATM 机上取钱,钱没出来,但短信提示已经扣款了。你会怎么办?如果你是设计这台 ATM 的工程师,你会怎么设计这个流程?
参考答案
第一层:大多数人会怎么答 “我会给银行打电话,让他们查账。一般三到五个工作日会把钱退回来。ATM 的设计应该是:先记账,再出钞;如果出钞失败,自动冲正,把账退回去。”
第二层:这个答案为什么不够 “先记账再出钞”这个设计,听起来无懈可击,但它有个致命问题:在”记账成功”和”出钞成功”之间,机器是可能断电的。
全世界每天有几十万台 ATM,每台每天几百笔交易。就算断电概率是百万分之一,这个数乘起来,每天也会有几十笔交易卡在这个中间态。
更关键的盲区在于:你以为银行关心的是”钱对不对”,其实银行更关心的是**“这笔交易的最终状态,是不是有一个明确的、机器可读的结论”**。
实际情况是,绝大多数 ATM 的设计恰恰相反:先出钞,后记账。为什么?因为出钞失败了,用户的钱还在银行;记账失败了,银行有一笔”长款”(账上的现金比实际多),可以通过后续的清机对账发现。而反过来,如果先记账后出钞,出钞失败会形成”短款”,短款在清机时的追查成本高得多。
第三层:更深的一层是什么 这个取舍背后,是一个在分布式系统里反复出现、但很少被明说的原则:
在无法确定结果的时候,选择”可发现”的失败,而不是”不可发现”的失败。
出钞失败 → 用户当场拍照、拍机器编号、打客服。这个失败是”可发现”的,而且发现成本极低(用户在现场)。 记账失败 → 银行账上多了一笔钱。这个失败也是”可发现”的,但要等到清机对账(通常是一天到三天)。 而”扣了款没出钞也没记录” → 这个失败是”不可发现”的。用户知道,但系统不知道。
把这个原则翻译成技术语言:宁可让系统处于一个”我知道这里可能有问题”的状态,也不要让它处于一个”看起来一切正常”的状态。
小林那个发券接口的第一版,出的问题就是”不可发现的失败”:它并发发了 12 张券,日志里是一堆 200,监控上成功率 100%。系统完全不知道自己错了。
幂等设计的真正目的,不是”防止重复”,而是**“让重复这件事留下痕迹”**。你看
GrantV3里那个idem表,它的价值不在于挡住了重复请求,而在于:三个月后运营问”这个用户怎么有两张券”的时候,你能查出来那两张券是怎么来的。第四层:落到 Go 工程师身上 打开你负责的任何一个写接口,问自己四个问题,把答案写下来: 一、这个接口的”中间态”是什么?(就是”业务执行到一半、还没落定”的那个状态)它在你的存储里有没有一个显式的字段表示?还是只是一段内存里的函数调用? 二、如果进程在这个中间态被 kill -9,重启之后,谁负责把这个状态推进到终态?有没有一个补偿任务?还是它就永远卡在那儿了? 三、卡在中间态的记录,有没有一个监控能看到?指标名叫什么?告警阈值是多少? 四、如果答案是”没有”,你打算在多长时间内补上?
这四个问题的答案,就是这个接口的”可发现性”评分。零分的接口,就是下一个 ATM。
思考题二:你搬家的时候,请了两个搬家师傅。你跟他们说”这个柜子搬到楼上卧室”。结果他们把柜子搬上去了,但是放在了客厅。你让他们重新搬,他们又把柜子搬回了一楼,再重新搬上去。为什么会发生这种事?
参考答案
第一层:大多数人会怎么答 “因为我第一句话没说清楚,我只说了’搬到楼上’,没说’搬到卧室’。是我表达的问题。”
第二层:这个答案为什么不够 把责任归给自己”没说清楚”,是一种很舒服的自我安慰,因为它意味着下次”说清楚”就好了。
但你仔细想想:在你说”搬到楼上卧室”之前,那两个师傅有没有问过你一句?
没有。他们听完就动手了。
这才是真正的问题所在:一个含糊的指令被执行了,而执行方没有确认。你要说清楚,是你的责任;但对方不问,是他的失职。这两件事同时存在,不互相抵消。
更深一层的盲区是:那两个师傅搬错之后,为什么要把柜子搬回一楼再重新搬?直接在二楼从客厅挪到卧室不就行了?
因为他们接到的是一个”指令”,不是一个”状态”。他们脑子里的模型是”执行’搬到楼上’这个动作”,而不是”让柜子最终位于二楼卧室这个状态”。
第三层:更深的一层是什么 这就是”动作语义”和”状态语义”的区别,也是分布式系统里最重要的一组概念之一。
动作语义(“搬上楼”):每次执行都会产生一次效果。执行两次 = 搬两次。这是非幂等的。 状态语义(“柜子在二楼卧室”):执行多次和执行一次,最终状态相同。这是幂等的。
这两个师傅用的是动作语义。所以他们第一次搬完了,指令就”消费”掉了。你再提要求,他们只能重新执行一遍整个动作。
换成状态语义会怎样?你会跟他们说:“柜子要在二楼卧室。“他们搬上去放客厅了,你看一眼说”不对”。他们看一眼目标状态,看一眼当前状态,做一个 diff——从客厅挪到卧室,两分钟。
现在把这个翻译成接口设计:
POST /coupon/grant—— 动作语义。调两次,可能发两张。PUT /user/10086/coupon/tpl-new-user—— 状态语义。调两次,“用户拥有这张券”这个状态不变。REST 里
POST和PUT的区别,教科书上讲的是”创建 vs 更新”。但真正的区别是:POST 是动作,PUT 是状态。 这也是为什么 HTTP 规范里说 PUT 必须是幂等的,而 POST 不保证。我见过太多系统,把所有接口都设计成 POST + 一个动词(
POST /coupon/grant、POST /coupon/use、POST /coupon/cancel),然后花大力气在服务端补幂等。而如果你一开始就把接口设计成状态语义(
PUT /coupon/CPNxxx/statewith body{"state":"used"}),幂等几乎是免费的。第四层:落到 Go 工程师身上 做两个练习。
练习一:把你现在负责的接口列出来,分成两列——“动作语义”和”状态语义”。看看比例。如果动作语义超过一半,挑一个最危险的重构试试。
练习二(这个更值钱):去看看你们公司的消息消费者(Kafka / RocketMQ / Pulsar 随便哪个)。找一条消费逻辑,问一个问题:如果这条消息被重复投递,消费者会怎样?
然后看它的提交偏移量的时机。大多数人写的是:
for msg := range ch { err := handle(msg) // 处理业务 if err != nil { continue } commitOffset(msg) // 处理成功才提交 }这在”处理成功但提交前宕机”时,消息会被重投。所以
handle必须幂等。但很少有人检查
handle到底幂不幂等。现在就去检查,你会吓一跳。最后一句:这个练习的答案不重要,重要的是你开始问”如果它重投会怎样”。这一问,就是你从 P6 往 P7 移动的那一步。
4.3 技术选型不是选最好的,是选”我们团队扛得住的”
周四上午的架构讨论会,Kevin 来了。
Kevin 是海归,在硅谷待过六年,去年回来的。他说话有个习惯:讲技术名词的时候会用英文,讲到一半会停下来想中文怎么说。
那天他看完小林那 9 页文档,说了句:“整体思路没问题,不过有点……嗯,conservative。”
“哪里保守?”
“库存扣减这块,你打算用 MySQL 的行锁?”
“嗯,SELECT ... FOR UPDATE。券的发放量不大,QPS 峰值我估了两千。”
“两千是现在。“Kevin 说,“我们的用户量明年可能翻倍。而且你用 MySQL 行锁,热点券的那一行会成为瓶颈。”
“那用什么?”
“Redis 预扣,异步落库。这是标准做法。“Kevin 在白板上画了个框,“或者干脆上 TiDB,一劳永逸。”
大刘在角落里举手了。
“我插一句。“大刘说,“TiDB 我们运维这边没有。要上 TiDB,我得先招一个人,或者我去学。学多久?至少一个月。这一个月里如果出了故障,我半夜怎么办?”
“可以买商业支持。“Kevin 说。
“多少钱?”
”……这个得看规模。”
“那 Redis 呢?“大刘问。
“Redis 我们有。“小林说,“现成的集群,用的是哨兵模式。”
“哨兵模式。“大刘重复了一遍,笑了,“哨兵模式在主从切换的时候会丢数据,你知道吗?”
“会丢多少?”
“看切换时机。极端情况下,主库已经写了、还没同步到从库就挂了,那部分就丢了。“大刘说,“券库存是能丢的东西吗?”
会议室安静了。
Kevin 说:“可以用 Redlock。”
“Redlock 那个算法,“大刘说,“Martin Kleppmann 写过一篇文章喷它,antirez 又写了篇回喷。这事儿在圈子里吵了七年了。我们确定要上这个?”
Kevin 说:“那篇文章有争议。”
“争议就是风险。“大刘说,“我半夜三点接到电话的时候,我不能跟电话那头说’这个方案有争议’。”
那天下午,小林把这段对话复述给老陈听。
老陈听完,问了第一个问题:“你觉得他们俩谁对?”
“我……”小林想了想,“技术上 Kevin 说得没错。Redis 预扣确实是标准做法,MySQL 行锁在峰值下确实扛不住。”
“我问的是谁对,不是谁技术上正确。”
“那……大刘?”
“为什么?”
“因为大刘说的是’我们能不能扛住’,Kevin 说的是’这个技术好不好’。”
老陈点点头:“这话你自己说出来的,比我讲十遍有用。”
“可是,“小林还是有点别扭,“如果按大刘的标准,那我们什么都不用新的,永远用老技术,不就完了?”
“那我问你,“老陈说,“你们组现在几个人?”
“五个,加上小周六个。”
“这六个人里,半夜能起来处理 Redis 故障的有几个?”
”……两个。我,还有张伟。”
“张伟下个月不是要走吗?”
”……对。”
“那就是一个。“老陈说,“你的技术选型,是在为你一个人选的。”
小林愣住了。
“这不是批评你。这是个事实。“老陈说,“一个只有一个人能兜底的系统,它的可用性上限,就是那个人的睡眠质量。”
老陈接着讲了个他自己的事。
“2011 年,我在一家做即时通讯的公司当技术负责人。那时候 Erlang 很火,什么’九个九的可用性’、‘热代码升级’,全是那套说辞。”
“我力排众议,用了 Erlang。”
“结果呢?”
“技术上,成功了。“老陈说,“那套系统真的做到了热升级,真的跑了三年没停过机。”
“那不是挺好?”
“不好。因为我招不到人。“老陈说,“那年我们团队二十三个人,会 Erlang 的只有四个——三个是我高价挖来的,一个是我在内部培养了八个月才培养出来的。”
“这二十三个人里,剩下十九个干什么?写周边系统,用 Java 和 PHP。”
“然后 2013 年,那三个高价挖来的,走了两个。”
“为什么走?”
“一个去创业了,一个回老家了。老陈说,“走之前他跟我说了句实话:‘陈哥,我这门手艺,除了你们这儿,整个杭州只有两家公司用。我怕再干两年,我就出不去了。’”
小林听到这儿,嘴张了一下。
“那套系统后来怎么样了?”
“2014 年重写了,用 Go。“老陈说,“重写花了七个月。那七个月,我每周都在跟老板解释,为什么一个’跑得好好的’系统要重写。”
“你的结论是什么?”
“结论不是’Erlang 不好’。Erlang 好得很。“老陈说,“结论是:我做选型的那十分钟里,我只评估了技术,没评估组织。”
“组织?”
“我会的人、我能招到的人、这些人会不会走、他们走了之后谁来、这个人走了之后他的市场竞争力是上升还是下降。”
老陈把茶杯转了一圈。
“你选一个技术的同时,你也在替你的团队选一条职业路径。选了一条没人在走的路,他们就会离开。”
“那我该怎么选?“小林问。
“我给你四个问题,你以后每次选型都问一遍。”
“第一问:这个东西挂了,谁能修? 名字要具体到人名。如果你说不出名字,它就不该上。”
“第二问:它挂了的最坏情况是什么? 不是’性能下降’,是具体的:多少用户受影响、影响多长时间、损失多少钱、要不要发公告、谁去发。”
“第三问:我们团队里,有几个人读过它的源码? 不是’用过’,是读过。读过的人少于一个,你对它就只有一个 API 层面的理解,出问题时你只能靠猜。”
“第四问,半年之后,如果我们要把它换掉,要改多少行代码?”
小林记下来了。
“第四个问题,我举个例子。“老陈说,“你们现在用的 ORM 是什么?”
“GORM。”
“如果明天要换成 sqlx,或者换成原生 database/sql,你们要改多少处?”
小林在脑子里过了一遍。
”……大概……三百多处?”
“三百多处。“老陈重复了一遍,“所以你们没有’用 GORM’,你们是’嫁给了 GORM’。”
“这有什么问题吗?GORM 挺好的。”
“GORM 是挺好的。“老陈说,“问题不在这。问题在于:你以为你在’使用一个库’,实际上你签了一份你没有读过的合同。”
“什么合同?”
“这份合同的条款是:GORM 的每一次不兼容升级,你要付出三百多处修改的代价;GORM 的性能特征,就是你的系统的性能特征;GORM 有一个你不知道的 bug,那个 bug 就是你的 bug。”
“这话 Eric S. Raymond 说过类似的:‘你用的每个库,都是你没写过但要负责的代码。’”
小林沉默了一会儿:“那怎么办?不用 ORM?”
“用了就用着,这不是重点。“老陈说,“重点是:你现在知道这三百多处是三百多,而不是’可能有点多’。这个数字本身,就是你的选型能力。“
生活里的同一件事:我表弟那辆二手路虎
小林有个表弟,比他小四岁,2021 年买的车。
那时候表弟刚工作三年,攒了十一万,加上家里给的十万,一共二十一万。他有两个选择:
一辆是丰田卡罗拉,新车,落地十四万,剩下的钱存着。
一辆是二手路虎极光,2016 年的,跑了六万公里,十九万八。
表弟选了路虎。
买车那天他发了朋友圈,配文是”人生第一辆豪车”,底下四十多个赞。小林当时还点了个赞。
后来的三年,小林完整地见证了那辆车的故事。
第一次:买回来第 47 天,变速箱报警。去 4S 店,说是阀体故障,报价 2 万 3。因为过了三个月质保,自费。等配件等了 11 天。
第二次:2022 年春节,回老家高速上,冷却液漏了。拖车费 800,修理费 3600,在老家那个小县城等配件等了 6 天,整个春节假期报废。
第三次:2022 年 7 月,空气悬挂漏气。4S 店报价 1 万 8。表弟没修,找了个外面的修理厂,6800。修完两个月又漏。
第四次到第七次:都是些小毛病——车窗升降器、中控死机、倒车影像黑屏、天窗异响。每次几百到两千不等。
去年过年的时候,小林问过表弟一笔账。
三年,那辆车跑了 4 万 2 千公里。买车 19.8 万,修车一共花了 6.7 万,保险油费不算。加起来 26.5 万。
而卡罗拉那三年,除了保养,一分钱没多花。
表弟说了句话,我觉得是这整件事的核心:
“哥,我不是买不起修车的钱。我是修不起那个时间。”
他解释:每次车坏了,他要请假去 4S 店,要把车放那儿,要打车回来,要过几天再去取。他在一家创业公司,请假要找人顶班。有一次他为了取车,把一个重要客户的会推了。
“最难受的不是花钱,“他说,“是那种不确定感。你不知道它下一次什么时候坏,你不敢开它出远门。去年我们公司团建去莫干山,我都没敢开车去,跟同事拼的车。”
这辆车,和一次技术选型,是同一件事。
买价 19.8 万,是”引入成本”。 就是 Kevin 说的那些:Redis 性能好、TiDB 一劳永逸、这个框架的 API 优雅。
修车 6.7 万,是”运维成本”。 就是大刘说的那些:半夜被叫醒、主从切换丢数据、出问题没人会修。
不敢开它出远门,是”机会成本”。 这才是真正贵的那部分——因为你不确定它靠不靠谱,你放弃了一些本来可以做的事。
技术选型里,“机会成本”长这样:
因为不确定那套新框架在高并发下的表现,你不敢用它做核心链路,于是核心链路还是老的,新框架只用在边缘业务。三年之后,你公司里有两套技术栈,新人不知道该学哪个。
因为不确定消息队列的堆积会不会导致消息丢失,你在关键流程上又加了一层数据库兜底,于是同一个逻辑有两份实现,改一处要改两处。
这些”不敢”,从来不会出现在任何一次技术评审的 PPT 上。 但它们是真实成本,而且往往比”引入成本”高一个数量级。
这里还有第二层,是关于那一句”修不起那个时间”。
表弟算得很清楚:他有修车的钱。真正的约束不是钱,是他处理故障的带宽——一年之内,他能承受几次”车坏了要请假去修”?
换成工程团队:一个五个人的组,一个季度能承受几次”线上故障要全员排查”?
答案通常是一到两次。超过这个数,正常需求的排期就全乱了,然后业务方开始不信任你,然后你开始加班,然后有人离职。
所以大刘那句”这玩意儿半夜会不会把我叫醒”,翻译成人话就是:
我们这个季度,还剩几次半夜起床的额度?这个新东西,会用掉几次?
这是 P7 该问的问题。它不是技术问题,是一个资源管理问题。
还有最后一层,最有意思的一层:
路虎是不是一辆好车?
是。它的公路性能、四驱系统、内饰用料,全面碾压卡罗拉。
问题是:这个”好”,是对谁而言的?
对路虎的工程师而言,它很好。对一个在英国有修车厂、有备件、有熟悉的技师的人而言,它很好。对一个在城市里通勤、一年跑一万公里、只会开车不会修车、工作又忙的人而言,它不好。
技术从来不是”好”或”不好”,技术只在”某个具体的组织里”才是好或不好。
小林后来把这句话写在便签上贴在了显示器边上。他说这是他 P7 期间贴得最久的一张。
Go 实例:从”用了一个库”到”嫁给了一个库”
上面说的”三百多处”,小林回去真的数了一遍。他写了个脚本 grep,结果是 317 处直接 import 了 gorm,并且调用链上到处都是 gorm 的类型。
他试着做个实验:把其中一个最核心的查询,从 GORM 换成原生 SQL,看看要动多少地方。
下面这个例子,是他做的更小的实验——发券时的库存扣减。这段代码用的是 Redis(go-redis),展示三种写法。
第一版:业务代码直接使用第三方库
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
var ErrNoStock = errors.New("库存不足")
// GrantServiceV1:直接把 go-redis 的 API 捏在业务逻辑里。
type GrantServiceV1 struct {
rdb *redis.Client
}
// DeductStock 扣减券库存。
// 问题:这个函数的每一行都和 go-redis 绑死了。
func (s *GrantServiceV1) DeductStock(ctx context.Context, tplID string) error {
key := "stock:" + tplID
// 直接用 go-redis 的错误类型做业务判断
_, err := s.rdb.Get(ctx, key).Result()
if err == redis.Nil {
return ErrNoStock
}
if err != nil {
return err
}
// 用 go-redis 的脚本 API
script := redis.NewScript(`
local cur = tonumber(redis.call('GET', KEYS[1]))
if cur == nil or cur <= 0 then return -1 end
redis.call('DECR', KEYS[1])
return cur - 1
`)
n, err := script.Run(ctx, s.rdb, []string{key}).Int()
if err != nil {
return err
}
if n < 0 {
return ErrNoStock
}
return nil
}这份代码能跑,也很常见。它的问题不是”写得烂”,是它把一个技术决策,扩散到了业务语义里。
具体体现在三个地方:
err == redis.Nil—— 一个 Redis 客户端的错误类型,出现在了业务判断里。redis.NewScript(...)里那段 Lua —— Redis 的方言,写在了一个 Go 文件中间。s.rdb *redis.Client—— 这个结构体字段,让GrantServiceV1无法在没有 Redis 的情况下被实例化(比如单元测试)。
第二版:加了一层”假抽象”
这是最危险的中间态,因为看起来已经解决了问题:
package main
import (
"context"
"time"
"github.com/redis/go-redis/v9"
)
// KVStore "抽象"了一个 KV 存储
type KVStore interface {
Get(ctx context.Context, key string) *redis.StringCmd
Set(ctx context.Context, key string, value interface{}, exp time.Duration) *redis.StatusCmd
Eval(ctx context.Context, script string, keys []string, args ...interface{}) *redis.Cmd
}
type GrantServiceV2 struct {
kv KVStore
}
func (s *GrantServiceV2) DeductStock(ctx context.Context, tplID string) error {
key := "stock:" + tplID
cmd := s.kv.Get(ctx, key)
if err := cmd.Err(); err != nil {
return err
}
// 业务代码还是要处理 *redis.StringCmd 的语义
// ...
return nil
}这个版本定义了一个 interface,看起来”可替换”了。
实际上它是伪装成抽象的耦合。判据很简单:Get 方法返回的是 *redis.StringCmd——一个 go-redis 的具体类型。任何想替换掉 Redis 的人,都必须先实现 redis.StringCmd 这个结构体,包括它的十几个方法。
这就像你换了个插座,但插座的形状还是原来那个牌子独有的。
“你的接口里出现了第三方库的类型”,是抽象泄漏最明显的信号。 还有几个不那么明显的:你的接口方法名和第三方库完全一致(说明你是照抄的,不是从自己的需求推导出来的);你的接口有超过 5 个方法(说明你在适配一个库,不是在表达一个需求);你的接口里有一个 Options ...Option 变参(说明你还没想清楚自己要什么,把决定权交给了库)。
第三版:按自己的领域语义定义,并用契约测试锁住可替换性
package main
import (
"context"
"errors"
"fmt"
"sync"
"testing"
"time"
)
var (
ErrNoStock = errors.New("库存不足")
ErrKeyNotSet = errors.New("key 不存在")
)
// StockStore 是"我们自己的"库存语义,不是任何 KV 库的语义。
// 判据:这个接口里没有任何第三方的类型,方法名说的是业务语言。
type StockStore interface {
// Deduct 原子扣减,返回扣减后的剩余量。
// 约定:库存不存在返回 ErrKeyNotSet;不足返回 ErrNoStock 且不扣减。
Deduct(ctx context.Context, key string) (remain int, err error)
// SetIfAbsent 初始化库存,已存在则不覆盖。返回是否真的设置了。
SetIfAbsent(ctx context.Context, key string, n int) (bool, error)
}
// ---- 实现一:Redis(生产用)----
// 注意:Lua 脚本被挪到了实现内部,业务层看不到 Redis 的任何痕迹。
const deductLua = `
local cur = redis.call('GET', KEYS[1])
if cur == false then return -2 end
cur = tonumber(cur)
if cur <= 0 then return -1 end
redis.call('DECR', KEYS[1])
return cur - 1
`
type RedisStockStore struct {
// 真实实现里这里是 *redis.Client
// 为了这份代码能独立运行,用一个内存 map 模拟 Redis 的原子执行
mu sync.Mutex
data map[string]int
unset map[string]bool // 模拟 key 不存在
}
func NewRedisStockStore() *RedisStockStore {
return &RedisStockStore{data: map[string]int{}, unset: map[string]bool{}}
}
func (s *RedisStockStore) SetIfAbsent(ctx context.Context, key string, n int) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.unset[key]; ok {
return false, nil
}
if _, ok := s.data[key]; ok {
return false, nil
}
s.data[key] = n
return true, nil
}
func (s *RedisStockStore) Deduct(ctx context.Context, key string) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
cur, ok := s.data[key]
if !ok {
return 0, ErrKeyNotSet // 对应 Lua 里的 -2
}
if cur <= 0 {
return 0, ErrNoStock // 对应 Lua 里的 -1
}
s.data[key] = cur - 1
return cur - 1, nil
}
// ---- 实现二:内存(测试 / 本地开发用)----
type MemStockStore struct {
mu sync.Mutex
data map[string]int
}
func NewMemStockStore() *MemStockStore {
return &MemStockStore{data: map[string]int{}}
}
func (s *MemStockStore) SetIfAbsent(ctx context.Context, key string, n int) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.data[key]; ok {
return false, nil
}
s.data[key] = n
return true, nil
}
func (s *MemStockStore) Deduct(ctx context.Context, key string) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
cur, ok := s.data[key]
if !ok {
return 0, ErrKeyNotSet
}
if cur <= 0 {
return 0, ErrNoStock
}
s.data[key] = cur - 1
return cur - 1, nil
}
// ---- 业务层:只依赖自己的接口 ----
type GrantService struct {
stock StockStore
}
func (s *GrantService) DeductStock(ctx context.Context, tplID string) error {
_, err := s.stock.Deduct(ctx, "stock:"+tplID)
return err
}
// ---- 契约测试:这是整个设计的关键 ----
// 同一套行为断言,跑在所有实现上。
// 它的存在意义不是"测试覆盖率",而是:任何新实现想替换旧的,
// 必须先过这一关。这就是"换掉它要改多少行"这个问题的答案。
func RunStockStoreContract(t *testing.T, name string, newStore func() StockStore) {
t.Run(name, func(t *testing.T) {
ctx := context.Background()
t.Run("扣减不存在的 key 应返回 ErrKeyNotSet", func(t *testing.T) {
s := newStore()
if _, err := s.Deduct(ctx, "nope"); !errors.Is(err, ErrKeyNotSet) {
t.Fatalf("期望 ErrKeyNotSet,得到 %v", err)
}
})
t.Run("库存耗尽后应返回 ErrNoStock 且不扣成负数", func(t *testing.T) {
s := newStore()
s.SetIfAbsent(ctx, "k", 1)
if _, err := s.Deduct(ctx, "k"); err != nil {
t.Fatalf("第一次扣减失败: %v", err)
}
if _, err := s.Deduct(ctx, "k"); !errors.Is(err, ErrNoStock) {
t.Fatalf("期望 ErrNoStock,得到 %v", err)
}
})
t.Run("SetIfAbsent 幂等", func(t *testing.T) {
s := newStore()
ok1, _ := s.SetIfAbsent(ctx, "k2", 5)
ok2, _ := s.SetIfAbsent(ctx, "k2", 99)
if !ok1 || ok2 {
t.Fatalf("SetIfAbsent 语义错误: %v %v", ok1, ok2)
}
remain, _ := s.Deduct(ctx, "k2")
if remain != 4 {
t.Fatalf("库存被覆盖了,剩余 %d,期望 4", remain)
}
})
t.Run("并发扣减不超发", func(t *testing.T) {
s := newStore()
s.SetIfAbsent(ctx, "k3", 100)
var wg sync.WaitGroup
var okCount int64
var mu sync.Mutex
for i := 0; i < 200; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := s.Deduct(ctx, "k3"); err == nil {
mu.Lock()
okCount++
mu.Unlock()
}
}()
}
wg.Wait()
if okCount != 100 {
t.Fatalf("超发了:成功扣减 %d 次,库存只有 100", okCount)
}
})
})
}
func TestAllStockStores(t *testing.T) {
RunStockStoreContract(t, "Redis实现", func() StockStore { return NewRedisStockStore() })
RunStockStoreContract(t, "内存实现", func() StockStore { return NewMemStockStore() })
}
func main() {
fmt.Println("运行 go test -race 查看契约测试结果")
time.Sleep(0)
}这套东西值不值得写?花的时间大概是直接写 Redis 版本的三倍。
这才是技术选型的真实决策过程——不是打开一张对比表,给 Redis 打 8 分、给 MySQL 打 6 分,然后选高的那个。而是问:
我愿意为”可替换性”付多少钱?
答案是分情况的:
- 这个组件是核心链路(库存、余额、订单状态)→ 值得。因为它被换掉的概率虽然低,但被换掉的时候,你通常是被逼的(故障、成本、合规),没有时间从容重构。
- 这个组件是边缘功能(后台管理的查询、内部报表)→ 不值得。直接写,坏了再说。
- 这个组件的选型还有争议(比如 Kevin 和大刘正在吵的那个)→ 尤其值得。因为接口隔离的最大价值,不是”以后能换”,而是**“让这场争论可以推迟”**。
最后这条最重要。
小林后来在架构会上就是这么说的:
“库存这块,Redis 还是 MySQL,我和 Kevin 有分歧。我的建议是:先按 MySQL 行锁做,但把扣减操作收敛到一个 StockStore 接口后面,配一份契约测试。如果三个月后 QPS 真的上来了,我换 Redis 只需要改一个文件。”
Kevin 想了想,说:“行。”
大刘说:“这个我同意。”
一场本来要吵两小时的会,十分钟结束了。 而小林真正卖出去的东西,不是”一个接口”,是”把决策推迟三个月,且推迟的成本被锁死在一个文件里”。
这就是 P7 的技术选型:不是选对,是在信息不足的时候,选一个”错了也能改”的结构。
老陈的深层发问
-
你现在负责的系统里,最”重”的那个依赖是什么?如果明天它宣布停止维护,你的迁移计划第一句话是什么?
-
你上一次在技术选型上投反对票,是因为”这个技术不好”,还是因为”我们扛不住”?你能分清楚这两个理由吗?
-
如果让你招一个新人来接手你现在的项目,他的学习清单有多长?清单上那些东西,有几样是这个行业通用的,有几样是我们自己发明的?
-
你有没有引入过一个技术,后来别人接手时骂过你?如果现在让你重新选一次,你会选同一个吗?如果会,为什么?
-
你的简历上写的技术栈,有几项是”你们公司在用”,有几项是”你主动选的,并且你能说出为什么没选另外那个”?
生活思考题
思考题一:你装修房子,要在”好看但难打理”和”普通但省心”之间选。比如:开放式厨房 vs 封闭式厨房;实木地板 vs 复合地板。你身边那些选了”好看”的人,后悔了吗?
参考答案
第一层:大多数人会怎么答 “后悔了。开放式厨房好看是好看,炒个辣椒全家都是味儿;实木地板踩着舒服,但是一杯水洒了没及时擦就鼓包。装修这事儿,还是实用第一。”
第二层:这个答案为什么不够 “实用第一”这四个字,是一句正确的废话。它的问题在于:它假设了”好看”和”实用”是矛盾的,但真正决定你会不会后悔的,根本不是这个。
我观察过身边十几套装修,发现一个规律:
后悔的人,几乎全都是”选择了一种需要改变生活习惯的装修”。
开放式厨房需不需要改变生活习惯?需要。它要求你:少做爆炒、做完饭立刻收拾、永远不要让台面上有杂物(因为客厅看得见)。
实木地板呢?也需要。它要求你:进门换鞋、水洒了立刻擦、家具腿要贴毛毡、每年打一次蜡。
而那些选了”好看”但不后悔的人,是什么样的?我有个朋友家装了白色皮质沙发,三年了还跟新的一样。我去过他家,他进门就换拖鞋,客厅里没有一件多余的东西,他老婆从来不在沙发上吃东西。
他不是”选对了家具”,他是”本来就是那种人”。
所以真正的判据不是”好看 vs 实用”,是:这个选择,要求我变成一个什么样的人?我愿意变成那个人吗?
第三层:更深的一层是什么 这是技术选型最核心、也最少被讨论的一个问题:你引进来的不只是一个技术,是一整套工作方式。
引入 Kubernetes,你引进来的是”所有东西都要容器化、配置要声明式、要写 YAML、要有健康检查接口、要考虑优雅退出”。这不是装个软件的事,这是整个团队的日常工作方式改变。
引入一个强类型的 ORM 或者代码生成工具,你引进来的是”schema 变更要走代码生成流程、要提交生成产物、要处理生成代码和手写代码的边界”。
引入领域驱动设计(DDD),你引进来的是”所有需求要先做领域建模、要有聚合根、要有领域事件、所有跨聚合的操作要通过事件”。
这些改变,在架构评审的 PPT 上,都只占一行。
而它们真正落地的时候,是每一次需求评审会多出的二十分钟争论,是新人上手多出的两周,是老员工心里那句”以前不是这么干的”。
所以选型时最该问的那句话不是”它好不好”,是”我们愿意变成那样吗”。
大刘说的”半夜会不会把我叫醒”,本质上就是这句:我愿意变成一个半夜会被叫醒的人吗?
第四层:落到 Go 工程师身上 下次做技术选型的时候,写一份两页的文档,就叫《引入 XX 之后,我们的日常工作会有什么变化》。
一、列出三条”以后必须做的事”(比如:每次上线前要跑一次代码生成;每个新服务要写一份 Helm chart;每次改表结构要先改 proto)。 二、列出两条”以后不能做的事”(比如:不能在服务里写文件;不能用进程内缓存;不能绕过 ORM 直接写 SQL)。 三、把这份文档发给团队里最不喜欢变化的那个同事,让他写一段评语。
那个”最不喜欢变化的同事”,通常是你团队里最值钱的人之一。他不是保守,他是组织记忆的载体。他知道以前踩过哪些坑。
如果连他都说”这个行”,那这个选型基本能落地。如果他说”这个不行”,他的理由通常比 Kevin 的技术对比表更接近真相。
思考题二:你去一家从没去过的餐厅吃饭,服务员推荐”本店招牌菜”。你会点吗?你判断一道菜值不值得点,靠的是什么?
参考答案
第一层:大多数人会怎么答 “不会点。‘招牌菜’通常是利润最高的那道,或者是最容易做的那道。我会看大众点评,看别人拍的照片,看评价里提到最多的是哪道菜。”
第二层:这个答案为什么不够 看评价当然比听服务员强,但评价有个致命问题:评价是别人写的,别人的口味、预算、用餐场景和你不一样。
一道菜在点评上有 4.8 分,可能是因为”分量大、实惠”(那是家庭聚餐的标准),也可能是因为”摆盘精致、适合拍照”(那是约会的场景)。你一个人去吃个工作餐,这两个都不是你关心的。
更深一层的盲区在这儿:你在看别人的评价,但你没看后厨。
我有个朋友开过三年餐厅,他告诉我一个判据,我记到现在:
看一家餐厅的菜单有多厚。
菜单三十页的,多半是预制菜——因为现做的菜式做不了那么多道,后厨备料备不过来。菜单一页纸、只有十来道菜的,通常是真做的,而且那十来道菜很可能是真的做得好,因为厨师每天重复做同样的东西几百遍。
这个判据有意思的地方在于:你根本不需要尝那道菜,你看的是”他们为这道菜准备了什么”。
第三层:更深的一层是什么 这对应到技术选型上,是一个被严重低估的判据:不要看这个技术宣称自己能做什么,看它的维护者把精力花在哪儿。
具体怎么看?几个可操作的点:
一、看 issue 区,不看 README。 README 是它想让你知道的;issue 区是它不想让你知道但藏不住的。特别关注那些”开了两年还没关”的 issue——那通常是设计层面的坑,不是 bug。
二、看 release note 里的 Breaking Changes 频率。 一个半年一次 breaking change 的库,它在告诉你:我们的设计还没稳定,你现在用,就是在帮我们试错。
三、看它有多少个”可选的依赖”。 一个库支持 7 种后端、5 种序列化方式、3 种配置源,听起来很强大。实际上它在告诉你:我们对每种的支持深度,都只有 1/15。
四、最关键的一条:看它的测试是怎么写的。 如果测试全是 happy path,说明作者自己也没想清楚边界情况;如果测试里有一堆看起来很荒谬的 case(比如”时钟回拨 5 秒”、“网络延迟 30 秒后恢复”、“同一个 key 被 1000 个协程同时写”),说明作者真的在生产上被咬过。
第四层:落到 Go 工程师身上 挑一个你正在用的、比较重要的第三方库(Go 的话,比如 gorm、go-redis、gin、grpc-go、zap 随便哪个),做一次”后厨检查”,限时一小时:
一、打开 GitHub,按 reactions 排序看 issue,读前 20 个的标题。把这 20 个标题抄下来,这是”这道菜可能有什么问题”。 二、翻最近三个大版本的 release note,数一下 breaking change 有几条。超过三条的,想想你升级过吗?没升级的话,你现在用的是几个版本之前的? 三、
go list -m all看看它给你拖进来了多少个间接依赖。超过 30 个的,值得警惕。 四、打开它的*_test.go,搜一下TestXxx_Fail、TestXxx_Timeout、TestXxx_Concurrent这类名字。数一数有多少个。做完这四步,你对这个库的判断会变。不是因为它变好了或变差了,而是因为你第一次看到了它真实的样子。
顺带说一句:这一小时,比你看十篇”Go 主流框架对比 2024”都有用。那些文章是服务员在给你推荐招牌菜。
4.4 分布式锁:你以为加了锁,其实加了个定时炸弹
券系统上线之后的第三周,出了个事。
那是个周二,上午十点四十。阿 May 冲到小林工位上:“运营在群里炸了,说有一批券,用户领了但用不了,显示’券不存在’。”
小林第一反应是缓存问题。他查了一遍,不是。
他查了数据库,发现 coupon_instance 表里,有一批券的 status 字段是 INIT,而正常发放成功的应该是 AVAILABLE。
INIT 是券刚创建、还没写入库存时的中间状态。
“这批券创建了,但是没有走完。“他说,“数量多少?”
“一百三十七张。”
一百三十七张券,被创建了,但卡在了中间态。用户领到了券码,用的时候提示不存在。
小林查了半小时,最后在日志里找到一行:
10:41:07.223 WARN acquire lock failed, key=stock:init:tpl-0712, err=lock held by others
那行日志上面,还有一行:
10:39:52.881 INFO acquired lock, key=stock:init:tpl-0712, ttl=30s
两条日志隔了 75 秒。而锁的 TTL 是 30 秒。
小林写的那段代码是这样的:
// 初始化券库存的定时任务,六个实例都会跑
func (s *Service) InitStockJob(ctx context.Context, tplID string) {
lock := s.redis.Acquire(ctx, "stock:init:"+tplID, 30*time.Second)
if !lock.OK() {
return // 别人在做,我退出
}
defer lock.Release(ctx)
// 1. 读库存配置(RPC 调用,偶发慢)
// 2. 预热缓存(批量写 Redis)
// 3. 写券实例(批量 INSERT)
// 4. 发送通知(HTTP 调用外部系统)
}问题出在第 4 步。那天那个外部系统抖了一下,HTTP 调用没有设超时,卡了 52 秒。
52 秒 > 30 秒 TTL。
锁在第 30 秒自动过期了。第 31 秒,另一个实例的定时任务拿到锁,开始跑。
于是两个实例同时在初始化同一批券。第一个实例在第 52 秒恢复,继续执行,把第二批券的中间态覆盖了。
“你这个是分布式锁最经典的坑。“老陈在电话里说,“锁的过期时间,保护不了超过它时长的业务。”
“那我把 TTL 设长一点?”
“设多长?”
“五分钟?”
“那如果你的业务卡了六分钟呢?”
”……”
“而且 TTL 越长,另一个问题越严重:进程真崩了,锁要五分钟才释放,这五分钟里这个任务全线停摆。“老陈说,“你在两个错误答案之间选。”
“那怎么办?”
老陈没接话,反问了一句:“你为什么需要这个锁?”
小林想了想:“因为六个实例都会跑这个定时任务,我不想让它跑六遍。”
“那这个任务的本质是什么?”
”……初始化库存。”
“它是幂等的吗?”
小林愣住了。
三十秒之后他说:”……是。初始化就是把库存从一个值设成另一个值,跑六遍和跑一遍结果一样。”
“对。“老陈说,“那你根本不需要锁。你需要的是一个 CAS。”
“分布式锁这个东西,“老陈说,“我给你讲四个坑,一个比一个深。”
“第一个坑,你已经踩了:TTL 和业务时长不匹配。”
“第二个坑:你释放的可能不是自己的锁。”
“啊?”
“你的 Release 是怎么实现的?”
小林翻代码:“DEL key。”
“那我问你:你的锁 30 秒过期,你的业务跑了 35 秒。第 31 秒 B 实例拿到锁。第 35 秒你的业务跑完了,执行 DEL key——你删掉的是 B 的锁。”
”……”
“然后 C 实例在第 36 秒拿到锁。现在 B 和 C 同时在跑。”
小林没说话。
“这就是为什么要给锁加一个随机值,释放的时候先比对再删,而且比对和删除必须在一个原子操作里。”
“第三个坑,是最难的一个。“老陈说,“你的进程可能’暂停’,暂停期间你以为时间没过。”
“暂停?”
“Go 的 GC 有 STW,虽然现在很短,但短不等于零。你的进程可能被操作系统换出,可能在虚拟机热迁移,可能遇到磁盘 IO 打满导致整个进程卡住,可能你的时钟和 Redis 的时钟不一致。”
“Martin Kleppmann 在 2016 年那篇文章里举的例子就是这个:客户端 A 拿到锁,然后进程被 GC 暂停了 40 秒,锁过期了,B 拿到锁,A 醒了,A 还以为自己持有锁,继续写。”
“这个……概率很小吧?”
“小。“老陈说,“但你要问的不是’概率多小’,是’发生了会怎样’。”
“如果后果是资损,那概率再小也要防。如果后果是重试一次就行,那概率再大也不用管。”
“第四个坑,Redis 的主从切换会丢锁。”
“这个大刘说过。”
“对,他说过。“老陈说,“客户端 A 在主库上拿到了锁,主库还没同步给从库就挂了,从库升主,B 在新主库上又拿到了同一把锁。”
“所以 Redlock 就是为了解决这个?”
“Redlock 是为了解决这个。它的思路是:不在单个 Redis 实例上加锁,而是在 N 个独立的 Redis 实例上加锁,拿到超过半数才算成功。”
“那不就解决了吗?”
“理论上是。但 Martin Kleppmann 认为它没解决,因为它依然依赖’每个节点上的本地时钟是可靠的’这个假设。他举的例子是:某个节点的时钟发生了跳跃,导致那个节点上的锁提前过期。”
“antirez 反驳说,时钟跳跃可以通过配置避免。”
“他们吵了七年,最后谁也没说服谁。“老陈说,“你猜这个结果意味着什么?”
小林想了想:“意味着……这事儿没有标准答案?”
“意味着一个方案如果需要两篇长文来回辩论才能说清楚它对不对,它就不该出现在你的业务代码里。”
老陈喝了口茶。
“Redlock 是好东西,它在正确的场景里是有价值的。但那个场景不是’我怕我的定时任务跑两遍’。”
“那我该怎么选?“小林问。
“我给你一个判据,就一条。“老陈说。
“先问:这件事能不能不用锁?”
“大多数分布式锁,都是因为设计的人懒得想别的办法。”
老陈列了三种替代方案:
第一种,唯一索引。 做插入去重的时候,别用锁,用数据库唯一索引。插入成功就是抢到了,插入失败(Duplicate entry)就是别人抢到了。这是最可靠的方案,因为它把并发控制下沉到了存储层,而存储层是唯一一个真正知道”当前状态”的地方。
第二种,乐观锁 / CAS。 做更新的时候,带上版本号:UPDATE ... WHERE id = ? AND version = ?,看 RowsAffected。影响行数为 0 说明有人改过了,重试或者放弃。这适用于冲突不频繁的场景,而绝大多数业务场景的冲突都是不频繁的。
第三种,状态机。 把业务对象建模成一个状态机,只允许特定的状态迁移。所有的并发操作都变成”尝试把状态从 A 迁移到 B”,迁移失败就是冲突。这是上面 4.2 节说的”状态语义”。
“这三种都用不了的时候,才考虑分布式锁。“老陈说。
“如果用锁呢?”
“那就用对你最不利的假设去用:假设它随时会过期,假设你随时会暂停,假设你的时钟是错的。”
“然后在资源侧做最后一道防线。”
“资源侧?”
“这是 Kleppmann 那篇文章里最有价值的一条建议,比他对 Redlock 的批评值钱得多。“老陈说,“给锁加一个单调递增的 token(fencing token)。客户端拿到锁的时候,拿到一个递增的数字。它要去写资源的时候,把这个数字带上。资源侧记住自己见过的最大数字,遇到比它小的,直接拒绝。”
“这样即使锁过期了、客户端暂停了、时钟乱了,那个’过期的持有者’拿着旧 token 来写,也会被资源侧挡住。”
“它把’锁’从’我相信我没过期’,变成了’资源能证明我没过期’。”
小林在纸上画了一下,抬头:“这个 token 存在哪儿?”
“看你的资源是什么。如果是数据库,就一个字段。如果是下游服务,让它在请求里带。”
“那下游服务要配合改。”
“对。“老陈说,“这就是分布式锁真正的成本:它不是你这个服务加一段代码就完事的,它要求整条链路都参与进来。 而大多数人加分布式锁的时候,没跟任何人说过。“
生活里的同一件事:四楼那间只有一个坑位的无障碍卫生间
小林公司四楼有间无障碍卫生间,一个坑位,空间特别大,还装了扶手。
因为空间大、没人抢,全楼层的人都爱去那儿。于是它常年处于”供不应求”的状态。
那扇门有三样东西,各讲一个坑。
第一样:门上的机械锁。
你进去,转动门把手上的旋钮,外面显示红色”有人”。你出来,反向转,显示绿色”无人”。
这个锁没有超时。理论上它是完美的互斥。
直到去年三月那次。
那天保洁阿姨在里面打扫,门是从里面锁着的。然后她接到一个电话,说三楼的水管爆了。阿姨跑下楼去了,忘了开门。
那扇门”有人”了整整四个小时。
期间至少十几个人去推过那扇门,看到红色,走了。有一个人还去前台问了,前台说”可能坏了”。
这就是没有 TTL 的锁。
你拿到锁的进程挂了,锁永远不会释放。分布式锁加了过期时间,就是为了防这个。可加了过期时间,就掉进了第二个坑。
第二样:那块”正在打扫,请勿使用”的黄色牌子。
阿姨打扫的时候,会把它挂在门把手上。
牌子是干嘛用的?它是个”人为的 TTL”——它在告诉所有人:这个占用是有期限的,你等一会儿再来。
但牌子没有写”等多久”。
于是就有了那个著名的问题:你看到牌子挂在门上,你等多久?
有人等两分钟就走,有人等十分钟。而阿姨可能只是去拿个拖把(30 秒),也可能是去楼下处理爆水管(4 小时)。
“等一会儿”不是一个可以写进设计的参数。 你必须给出一个具体的数字,而这个数字,几乎不可能是对的——因为打扫一间卫生间的时间,从 3 分钟到 40 分钟不等。
这就是为什么”把 TTL 设长一点”是个陷阱。你设成 5 分钟,就必然有 6 分钟的情况;你设成 30 分钟,就必然有 29 分钟的任务在白等。
第三样:去年五月,那个锁坏了。
门锁坏了的表现很特别:从外面看是”有人”(红色),但从里面轻轻一推就开了。
也就是说,这个锁的状态是”有人”,但实际上没人。
那两天,四楼的人都在憋着。有人去五楼,有人去三楼。直到第三天,前台才报修。
这个坑是最隐蔽的:锁的”显示状态”和”实际状态”不一致。
在 Redis 锁里,这件事长这样:主库上有锁,从库上没有,主从切换之后,从库变成主库,锁”看起来”消失了。或者反过来,锁已经过期了,但客户端因为 GC 暂停,还以为自己拿着锁。
锁的状态,永远不能只存在于一方的心里。
后来物业怎么修的?他们换了个新锁,新锁外面有个小窗,能直接看到里面有没有人。
这个改动很小,但它把”信任锁的状态”变成了”验证实际状态”——这就是 fencing token 的生活版本。
还有一个细节,是我这整件事里最喜欢的:
阿姨后来养成了一个习惯。她挂牌子的时候,会用手机拍一张照片发到物业群里,写上时间。打扫完了再拍一张。
她不懂什么分布式系统。但她凭直觉解决了一个 Kleppmann 花了三千字讨论的问题:当”我是否持有锁”这件事不可靠的时候,让第三方来记录客观事实。
那两张照片,就是 fencing token。
Go 实例:一把 Redis 锁的四次进化
下面的代码用一个内存结构模拟 Redis 的单线程命令执行,这样整个例子可以直接跑起来。真实的 go-redis 调用我写在注释里。
第一版:教科书上最常见、也是最容易出事的写法
package main
import (
"context"
"fmt"
"sync"
"time"
)
// FakeRedis 模拟 Redis 的单线程命令执行。
type FakeRedis struct {
mu sync.Mutex
data map[string]string
ttl map[string]time.Time
}
func NewFakeRedis() *FakeRedis {
return &FakeRedis{data: map[string]string{}, ttl: map[string]time.Time{}}
}
func (r *FakeRedis) get(key string) (string, bool) {
if exp, ok := r.ttl[key]; ok && time.Now().After(exp) {
delete(r.data, key) // 模拟 key 过期
delete(r.ttl, key)
return "", false
}
v, ok := r.data[key]
return v, ok
}
func (r *FakeRedis) SetNX(key, val string) bool {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.get(key); ok {
return false
}
r.data[key] = val
return true
}
func (r *FakeRedis) Expire(key string, d time.Duration) {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.data[key]; ok {
r.ttl[key] = time.Now().Add(d)
}
}
func (r *FakeRedis) Del(key string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.data, key)
delete(r.ttl, key)
}
// LockV1:SETNX 之后再 EXPIRE。这是 2015 年之前到处都能搜到的写法。
//
// 对应的真实代码:
// ok, _ := rdb.SetNX(ctx, key, "1", 0).Result()
// rdb.Expire(ctx, key, 30*time.Second)
func LockV1(r *FakeRedis, key string, ttl time.Duration) bool {
if !r.SetNX(key, "1") {
return false
}
// 危险窗口:如果进程在这两行之间崩溃(断电、OOM、被 kill -9),
// 这个 key 会永远存在,锁永不释放。整条业务线死掉。
r.Expire(key, ttl)
return true
}
func UnlockV1(r *FakeRedis, key string) {
r.Del(key) // 更危险:不检查是不是自己的锁
}两个致命问题,都写在那几行注释里了:
其一,SetNX 和 Expire 之间有个窗口。进程在这个窗口里死掉,锁就永久泄漏了。你可能会说”概率极低”,但这个概率乘以你每天几十万次加锁,就不是极低了。而且这种事故的恢复方式很原始:手动去 Redis 上 DEL。
其二,UnlockV1 直接 DEL。它不检查这把锁是不是自己的,于是会出现”我删了别人的锁”这种状况。
第二版:原子加锁 + 校验释放
package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
"sync"
"time"
)
// SetNXWithTTL 模拟 Redis 2.6.12 之后的 SET key val NX PX ms 单条命令。
// 真实代码:
// rdb.Set(ctx, key, token, ttl).Result() // SetNX + TTL 是原子的
func (r *FakeRedis) SetNXWithTTL(key, val string, ttl time.Duration) bool {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.get(key); ok {
return false
}
r.data[key] = val
r.ttl[key] = time.Now().Add(ttl)
return true
}
// EvalCompareAndDelete 模拟 Lua 脚本的原子"比较再删除"。
// 真实的 Lua:
// if redis.call("get", KEYS[1]) == ARGV[1] then
// return redis.call("del", KEYS[1])
// else
// return 0
// end
//
// 为什么必须是 Lua?因为 GET 和 DEL 是两条命令,
// 中间可能有别人拿到锁(你的锁刚好在这中间过期)。
func (r *FakeRedis) EvalCompareAndDelete(key, val string) bool {
r.mu.Lock()
defer r.mu.Unlock()
cur, ok := r.get(key)
if !ok || cur != val {
return false
}
delete(r.data, key)
delete(r.ttl, key)
return true
}
func randToken() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
// MutexV2 解决了 V1 的两个问题:原子加锁、校验释放。
type MutexV2 struct {
r *FakeRedis
key string
ttl time.Duration
token string
}
func AcquireV2(r *FakeRedis, key string, ttl time.Duration) (*MutexV2, bool) {
token := randToken()
if !r.SetNXWithTTL(key, token, ttl) {
return nil, false
}
return &MutexV2{r: r, key: key, ttl: ttl, token: token}, true
}
func (m *MutexV2) Release() bool {
return m.r.EvalCompareAndDelete(m.key, m.token)
}这一版在”Redis 侧”已经是正确的了。绝大多数业务用这一版就够了。
但注意一个细节:Release() 返回了一个 bool,而 99% 的调用方会忽略它。
这个 bool 为 false 意味着什么?意味着你的锁在业务执行期间已经过期了,并且被别人拿走了。
这是一个极其重要的信号,但几乎所有人写的都是 defer lock.Release(),把返回值扔掉。
正确的写法应该是:
defer func() {
if !m.Release() {
// 锁已经不是我的了 —— 说明我的业务执行时间超过了 TTL
metrics.Inc("lock.expired_during_hold") // 必须打点
log.Warn("锁在持有期间过期,业务可能存在并发", "key", m.key)
// 并且:这里要触发一次数据一致性检查
}
}()这个打点,是你能不能发现”锁不够用”的唯一手段。 没有它,你永远不知道你的 TTL 设得对不对,直到出事。
第三版:续期(watchdog)
// MutexV3 在 V2 基础上加了自动续期。
// 思路:拿到锁之后起一个 goroutine,每隔 ttl/3 把过期时间往后延。
type MutexV3 struct {
r *FakeRedis
key string
ttl time.Duration
token string
stop chan struct{}
wg sync.WaitGroup
expired int32 // 原子标记:是否曾经续期失败
}
func AcquireV3(r *FakeRedis, key string, ttl time.Duration) (*MutexV3, bool) {
token := randToken()
if !r.SetNXWithTTL(key, token, ttl) {
return nil, false
}
m := &MutexV3{r: r, key: key, ttl: ttl, token: token, stop: make(chan struct{})}
m.wg.Add(1)
go m.keepAlive()
return m, true
}
func (m *MutexV3) keepAlive() {
defer m.wg.Done()
ticker := time.NewTicker(m.ttl / 3)
defer ticker.Stop()
for {
select {
case <-m.stop:
return
case <-ticker.C:
// 注意:续期也要用 Lua 校验 token,不能无条件 EXPIRE
ok := m.r.EvalCompareAndExtend(m.key, m.token, m.ttl)
if !ok {
// 续期失败 = 锁已经不是我的了 = 业务已经失去保护
atomic.StoreInt32(&m.expired, 1)
return
}
}
}
}
// Release 先停续期,再删除。
func (m *MutexV3) Release() (bool, error) {
close(m.stop)
m.wg.Wait() // 必须等续期 goroutine 退出,否则它可能在你删完之后又续上了
ok := m.r.EvalCompareAndDelete(m.key, m.token)
if atomic.LoadInt32(&m.expired) == 1 {
return ok, ErrLockLost
}
return ok, nil
}续期解决了”TTL 不够长”的问题,但它引入了新的复杂度,而且它没有解决最根本的问题。
想一想:如果你的业务卡了 90 秒,续期 goroutine 也跟着卡了(比如整个进程 STW),那续期照样失败。
续期的本质是”用活得久的 goroutine 去救活得短的锁”。可如果整个进程都不行了,谁也救不了谁。
第四版:真正的解法,加 fencing token
package main
import (
"errors"
"fmt"
"sync"
"sync/atomic"
)
var ErrStaleToken = errors.New("fencing token 过期,请求已被拒绝")
// 资源侧:一张库存表。它记住自己见过的最大 token。
// 这是整个方案的最后一道防线,它在资源这一侧,不在锁那一侧。
type StockTable struct {
mu sync.Mutex
value int
maxToken int64 // 单调递增 fencing token
}
// Write 只接受比历史最大 token 更大的写入。
// 注意:这一层不依赖 Redis,不依赖锁,不依赖时钟。
func (t *StockTable) Write(newValue int, token int64) error {
t.mu.Lock()
defer t.mu.Unlock()
if token < t.maxToken {
return fmt.Errorf("%w: 收到 token=%d,已见过 %d", ErrStaleToken, token, t.maxToken)
}
t.maxToken = token
t.value = newValue
return nil
}
// tokenGen 模拟 Redis 的 INCR,单调递增。
// 真实代码:rdb.Incr(ctx, "fencing:"+key).Result()
type tokenGen struct{ n int64 }
func (g *tokenGen) Next() int64 { return atomic.AddInt64(&g.n, 1) }
// LockWithFencing 加锁时同时领一个 token
type LockWithFencing struct {
token int64
}
func AcquireWithFencing(r *FakeRedis, key string, ttl time.Duration, g *tokenGen) (*LockWithFencing, bool) {
token := g.Next()
if !r.SetNXWithTTL(key, fmt.Sprintf("%d", token), ttl) {
return nil, false
}
return &LockWithFencing{token: token}, true
}
func main() {
stock := &StockTable{value: 1000}
gen := &tokenGen{}
r := NewFakeRedis()
// 客户端 A 拿到锁,token = 1
lockA, _ := AcquireWithFencing(r, "stock:tpl-1", 30*time.Second, gen)
fmt.Println("A 拿到锁, token =", lockA.token)
// A 开始干活,然后进程 STW 了 40 秒,锁过期
// (这里用 sleep 模拟;真实场景是 GC / 网络分区 / 进程暂停)
time.Sleep(50 * time.Millisecond)
// 客户端 B 拿到锁,token = 2
r.Del("stock:tpl-1") // 模拟 A 的锁过期
lockB, _ := AcquireWithFencing(r, "stock:tpl-1", 30*time.Second, gen)
fmt.Println("B 拿到锁, token =", lockB.token)
// B 先写完
errB := stock.Write(800, lockB.token)
fmt.Println("B 写入:", errB, "库存 =", stock.value)
// A 从 STW 中醒来,拿着旧 token 继续写
errA := stock.Write(600, lockA.token)
fmt.Println("A 写入:", errA, "库存 =", stock.value)
// 输出:
// A 拿到锁, token = 1
// B 拿到锁, token = 2
// B 写入: <nil> 库存 = 800
// A 写入: fencing token 过期,请求已被拒绝: 收到 token=1,已见过 2 库存 = 800
//
// 关键:A 的写入被资源侧拒绝了。
// 库存停在了 800(B 写的值),而不是被 A 覆盖成 600。
}跑起来最后一行是 A 写入: fencing token 过期,请求已被拒绝。
这个方案漂亮在哪?
它把”互斥”这个保证,从”客户端的自律”变成了”资源侧的客观事实”。
锁那一侧,你可以犯任何错:TTL 设短了、进程暂停了、时钟乱了、主从切换了。只要资源侧有一个单调递增的 token 校验,那些”过期的持有者”就写不进去。
实现它要付出的代价是什么?资源侧要配合。
如果是数据库,加一个 fence_token BIGINT 字段,写入时 WHERE fence_token < ?,成本很低。
如果是下游的 HTTP 服务,你要让它接受一个 token 参数并在它那一侧做校验。这需要跨团队沟通,成本立刻上升。
这就是为什么分布式锁从来不是一个技术决策,它是一个组织协调决策。
最后补一句小林的那个 case。
他的券库存初始化任务,最终的解法一行锁都没有:
UPDATE coupon_template
SET stock = ?, stock_init_version = stock_init_version + 1
WHERE id = ? AND stock_init_version = ?用 RowsAffected 判断有没有抢到。RowsAffected == 0 说明别人改过了,直接返回。
六个实例都跑,只有一个会成功,其余五个自然退出。没有锁,没有 TTL,没有续期 goroutine,没有 fencing token。
而且它是幂等的:跑六遍和跑一遍,结果完全一样。
这个解法,比上面那四版 Redis 锁加在一起都好。
它唯一的要求是:你得先想清楚”我到底要防什么”,而不是”我要加个锁”。
老陈的深层发问
-
你代码里现在有几把分布式锁?每一把,你能说出”如果它失效了,最坏会发生什么”吗?说出来的最坏情况,有没有人跟你确认过?
-
你加锁的时候,有没有告诉过下游服务”我在用锁”?如果他们也在做并发控制,你们俩的锁,谁保护谁?
-
你有没有过这种时刻:你加了一把锁,然后心里”踏实了”?那个踏实感,是来自你对方案的理解,还是来自”别人都这么干”?
-
你的锁,有没有一个监控指标叫”锁在持有期间过期”?如果没有,你怎么知道你的 TTL 设得对不对?
-
如果有人跟你说”这个场景用 Redis 锁就够了”,你怎么判断他说得对不对?你的判断依据,是一条技术原则,还是”我看别人也这么写”?
生活思考题
思考题一:你和另外两个同事合用一间办公室,只有一台打印机。你们定了个规矩:要用打印机,先在门口白板上写自己的名字,用完擦掉。这个规矩运行了半年,出问题了吗?会出什么问题?
参考答案
第一层:大多数人会怎么答 “会出问题。最常见的就是有人忘了擦,白板上一直挂着他的名字,别人想用也不敢用。还有人写上了名字但临时被叫走,白板占着半小时。要么就改成微信群里喊一声。”
第二层:这个答案为什么不够 “改成微信群”这个解法,只是把白板换成了一个更快消失的介质。它解决了”忘了擦”的问题,但引入了三个新问题:消息会被刷走(没有持久状态);三个人可能同时喊(没有互斥);你在忙的时候看不到消息(没有可靠通知)。
更深一层的盲区在于:这个规矩从头到尾都没有定义”冲突了怎么办”。
白板上有名字,我确实用不了打印机。但我用不了之后,我是等着?还是去问那个人?还是直接用了?没有规定。
于是现实中会演化出一些”民间规则”:比如”等五分钟,如果还没动静就去问”,比如”急的话可以直接用,事后说一声”。这些民间规则,才是真正在运行的系统。而白板,只是一个装饰。
第三层:更深的一层是什么 这个白板,和一把没有 TTL 的 Redis 锁,结构完全一样。
但真正有意思的是下面这一点:
为什么这个规矩能运行半年?
不是因为它设计得好。是因为打印机这个资源有一个天然的”完成信号”:纸出来了。
你看到有人在打印机旁边站着、纸在一张张出来,你就知道”现在有人在用”,你根本不用去看白板。白板是冗余的。
换句话说,这个系统真正保证互斥的,不是白板,是”人站在打印机边上”这个物理事实。 白板只是一个记账工具,它不承担正确性。
对应到分布式系统:如果你的业务操作本身就能被资源侧正确拒绝,那你的锁就是一个记账工具,它失效了也不致命。如果你的业务操作是”无脑覆盖”,那你的锁就是唯一的防线,它失效了你就完了。
所以真正该问的问题不是”我的锁可靠吗”,而是:
“如果我的锁突然全部失效,我的数据会变成什么样?”
如果答案是”乱套”,问题不在锁,问题在你把所有的正确性都押在了锁上。
第四层:落到 Go 工程师身上 做一次”锁失效演练”。找一个你用了锁的地方,然后:
一、在测试环境把锁的实现改成一个”永远返回 true”的桩(假装永远拿得到锁)。 二、跑一遍你的并发测试,看看数据会错成什么样。 三、记录下:错了多少条?多久之后发现的?有没有任何监控能报出来?
这个演练我见过的结果,通常是三种:
- 数据一点没错 → 恭喜,你的锁是冗余的,可以考虑删掉它。
- 数据错了但很快被发现 → 你的锁是”优化”,不是”正确性依赖”,这是健康的状态。
- 数据错了,而且没人发现 → 你的系统现在是在裸奔,只是运气好还没出事。
第三种情况的团队,我建议把这条演练做成每季度一次的例行事项。因为它暴露出来的,不是锁的问题,是你对自己的系统缺乏”如果 X 失效会怎样”的模型。
思考题二:你在健身房办了张卡,更衣柜是要自己设密码的那种。有一天你练完回来,发现柜子打不开了——密码被别人改了。你去找前台,前台说:“我们的柜子系统是这样的:如果连续三次输错,它会自动锁定五分钟。“你觉得这个设计合理吗?
参考答案
第一层:大多数人会怎么答 “不合理。连续三次输错就锁五分钟,那别人随便乱输三次,我自己的柜子就用不了了。这是防君子不防小人。应该改成:锁定之后,用办卡时登记的手机号验证一下就能解开。”
第二层:这个答案为什么不够 “手机号验证解锁”这个补丁是对的,但它只是把这个特定的攻击堵上了。它没解决那个根本问题:
这个柜子无法区分”谁在输密码”。
它不知道输密码的人是不是柜子的主人。所以它只能用”输错次数”这个代理指标,而代理指标是可以被外部操纵的。
这就是身份(identity)和凭据(credential)的混淆:知道密码的人被假定为柜子的主人,但密码是可以被猜的、被看的、被暴力试的。
更深一层的盲区是:这个系统的设计目标到底是什么?
如果是”防止别人开我的柜子”,那三次锁定是有效的(它让暴力破解变慢)。 如果是”保证我能随时开我的柜子”,那三次锁定是有害的(它让拒绝服务变容易)。
这两个目标是直接冲突的,而设计者没有做取舍,他两个都想要。
结果就是:系统既没有很好地防住破解(因为只有三次机会的限制太弱),也没有很好地保证可用性(因为五分钟对急着拿东西回家的人来说很长)。
第三层:更深的一层是什么 把这个翻译一下:
“连续三次输错就锁定” = 一个没有区分调用方身份的资源保护机制。
在分布式系统里,这东西长什么样?长成这样:
- 一个接口,任何人调三次不合法的参数,整个接口的限流就触发了,所有正常用户跟着倒霉。
- 一个 Redis key,任何一个客户端写了脏数据进去,所有客户端读到的都是脏数据。
- 一个分布式锁,任何一个实例抢到就能操作共享资源,而没人校验”你该不该操作这个资源”。
这三条的共同点是:保护机制作用在”共享的东西”上,而不是作用在”谁”上。
这就是 fencing token 想解决的那个问题的本质。fencing token 的作用不是”防止并发”,而是**“让每一次对共享资源的操作,都带上一个可验证的、单调的身份凭证”**。
资源侧一看这个凭证,就知道”你是第几个来的”。比你来的晚的人(token 更大的)已经写过了,你就不能写了。
而健身房那个柜子,缺的就是这个”第几个”的概念。它只知道”对或错”,不知道”先来后到”。
第四层:落到 Go 工程师身上 检查一下你负责的共享资源(一张表、一个 Redis key、一个下游接口),问三个问题:
一、这个资源的写入操作,带不带”谁写的”这个信息?如果只有一个
UPDATE ... SET value = ?,答案是”不带”。 二、如果不带,两个并发的写入,谁覆盖谁?是由到达顺序决定的吗?你怎么知道到达顺序? 三、如果有一个”迟到的写”(网络延迟 30 秒之后才到的请求),它会覆盖掉这 30 秒内的所有正常写入吗?第三个问题,是分布式系统里最经典的”僵尸请求”问题。它的发生概率很低,但一旦发生,现象极其诡异——数据会”倒退”,你查日志看到的是一个半小时前的请求成功修改了当前的数据。
具体的改法(挑一个成本最低的):
- 数据库表加一个
version或updated_at字段,写入时带上WHERE version = ?。- 如果是 Redis,把 value 设计成
{version, data}的结构,写入时用 Lua 比较 version。- 如果是下游接口,让它在请求里接受一个
req_id或者client_ts,并且至少把它记进日志——记日志这一步本身就能救命,因为出事的时候你至少能还原顺序。最后一句:这三个改法都不难,难的是”想到要改”。而想到要改的前提,是你问过那句”如果有个迟到的请求会怎样”。
4.5 看懂 Go 运行时:GMP 模型和 GC 到底在干什么
券系统上线第四周,大刘在监控上发现了一个东西。
那是一张 P99 的曲线图,横轴一天。图上大部分时候是平的,35 毫秒上下。但每隔一段时间,会出现一个尖刺,冲到 110 毫秒左右,然后立刻回落。
尖刺出现的间隔很有规律:差不多两分钟一次。
“这个你看得懂吗?“大刘把图截给小林。
小林看了半天:“两分钟一次……我们的定时任务?”
“定时任务都是整点跑的,这个不是。“大刘说,“而且你看,尖刺的宽度只有一两分钟,不是持续升高。像是有人每隔两分钟,来踩一脚油门。”
小林查了半天。不是数据库慢查询,不是 Redis,不是下游调用。他在尖刺出现的时间段里捞日志,日志里什么都没有。
那天晚上他给老陈打电话,讲到一半,老陈问了一句:
“你的服务,堆内存多大?”
”……我没看过。”
“GOGC 设了吗?”
“没有,用的默认值。”
“默认是多少?”
”……”
“一百。“老陈说,“意思是:当堆的大小相比上次 GC 之后,增长了 100%,就触发下一次 GC。”
“那……”
“你的堆峰值是多少?”
小林去查了 Grafana,回来的时候有点不好意思:“1.2 个 G。”
“1.2G,GOGC=100。“老陈说,“也就是说,每当你多分配 600M 左右的新对象,就会触发一次 GC。GC 要扫描这 1.2G 的堆。你猜扫描 1.2G 需要多久?”
”……几十毫秒?”
“对上了吧。“老陈说,“那两分钟一次的尖刺,不是别人踩油门,是你的程序自己在攒垃圾,攒够了就倒一次。”
小林坐在工位上,看着那张图,第一次觉得 Go 的运行时不是个黑盒。
“我问你个问题。“老陈说,“GMP 是什么?”
“G 是 goroutine,M 是内核线程,P 是……Processor,逻辑处理器。“小林背得很顺。
“为什么要有 P?”
”……因为有 P 才能调度?”
“没 P 也能调度。2012 年之前的 Go,只有 G 和 M,也能跑。”
“那 P 是干嘛的?”
“我先问你另一个问题。“老陈说,“一个 goroutine 什么时候会停下来,让给别人?”
小林想了想:“channel 阻塞的时候、time.Sleep 的时候、调用阻塞的系统调用的时候……”
“还有呢?”
”……”
“一个纯计算的 for 循环,里面没有任何函数调用,它会让出 CPU 吗?”
“不会吧,它没有阻塞。”
“Go 1.14 之前,确实不会。“老陈说,“那时候的抢占是协作式的,编译器会在函数序言里插一段检查代码。如果你的循环里没有函数调用,这个检查永远不会执行,这个 goroutine 就一直占着 P。”
“那会怎样?”
“轻则其他 goroutine 饿死,重则 GC 的 STW 阶段进不去——因为 STW 要等所有 goroutine 都停下来,而你这个 goroutine 停不下来。”
“我见过一次。“老陈说,“2018 年,一个做音视频转码的公司找我。他们的服务有一个接口,会在一个循环里做大量的位运算,没有任何函数调用。那个接口一被调用,整个进程的 GC 就卡住,STW 时间能到 800 毫秒。”
“怎么解决的?”
“他们在循环里加了一行 runtime.Gosched()。“老陈说,“就一行。800 毫秒变成 0.4 毫秒。”
“那 Go 1.14 之后呢?”
“1.14 引入了异步抢占,用信号(SIGURG)打断正在跑的 goroutine。“老陈说,“所以现在你写个空循环,不会把进程卡死了。”
“那现在这个问题就不用管了?”
“用管,但换了个形式。“老陈说,“异步抢占是每 10 毫秒检查一次,而且抢占点是在安全点。所以它的粒度是 10 毫秒级,不是纳秒级。如果你的代码里有大段不触发抢占点的密集计算,延迟依然会抖,只是不会死锁了。”
“回到 P 的问题。“老陈说,“我给你打个比方。”
“你开一家餐厅。”
“G 是订单。M 是厨师。P 是灶台。”
“一个订单要被做出来,需要一个厨师站在一个灶台前。三个条件缺一不可。”
“2012 年之前,Go 只有订单和厨师,没有灶台的概念。所有厨师共用一个大备菜台,所有做了一半的订单都堆在上面。”
“这有什么问题?”
“高峰期,八个厨师同时伸手去备菜台上拿东西,得排队。这个’排队’,就是全局锁的争抢。”
“所以 P 就是每个厨师自己的备菜台?”
“接近了,但不完全对。“老陈说,“P 不是厨师的,是灶台的。 关键点在这儿:厨师可以换,灶台数量是固定的。”
“什么意思?”
“一个厨师炒着炒着,需要去后门接一批货(阻塞式系统调用)。这时候他不能占着灶台,得把灶台让出来。于是另一个空闲的厨师过来,接手这个灶台,继续炒别的订单。”
“原来那个厨师接完货回来,发现自己的灶台没了。他不能抢,只能把订单放回备菜队列,然后去找空闲的灶台。找不到就歇着。”
“这个’灶台数量固定’,就是 GOMAXPROCS。”
小林在纸上画了一下,突然明白了个东西。
“所以 GOMAXPROCS 不是’最多能开多少个线程’?”
“不是。它是最多能同时有多少个 goroutine 在真正被执行。“老陈说,“M 的数量可以比 P 多得多——每个阻塞在系统调用上的 M 都会占一个 OS 线程,但它不占 P。”
“那我开一万个 goroutine,同时只能跑 8 个?”
“如果你的机器是 8 核、GOMAXPROCS 默认,那就是 8 个。”
“那开一万个 goroutine 有什么意义?”
“有意义的那一部分是:它们不占 OS 线程。 一万个 goroutine 里,9992 个在等 IO,它们的栈可能只有 2KB,全放在堆上。而一万个 OS 线程,一个线程栈就 8MB,那是 80G。”
“这就是 Go 高并发的全部秘密。“老陈说,“不是’能同时干更多事’,是’能更便宜地等’。”
小林想起自己两年前写过的一段代码——批量发券,一万张,他 for 循环开了一万个 goroutine。当时还很得意。
“GC 那部分呢?“小林问。
“你知道三色标记吗?”
“知道。白灰黑,白色是垃圾,黑色是确认存活,灰色是中间态。”
“为什么需要三种颜色?两种不行吗?”
”……”小林想了想,“两种的话,你不知道哪些是’还没检查完的’。”
“对。“老陈说,“灰色存在的意义,是标记’我已经确认它活着,但我还没检查它引用了谁’。 灰色集合,就是 GC 的工作队列。”
“那写屏障呢?”
“我问你:如果 GC 在标记的途中,你的程序改动了一个对象的引用,会怎样?”
“会漏标。”
“举个具体的例子。”
小林想了想:“假设有个对象 A,GC 已经把它标成黑色了,意思是’A 活着,且 A 引用的东西我都检查过了’。然后我的程序执行了 A.next = C,而 C 是个新对象,还是白色。”
“然后呢?”
“然后 GC 结束,白色的 C 被回收了。但是 A 还引用着它。”
“于是 A.next 变成了一个悬空指针。”
“对。“老陈说,“这就是漏标。一个对象明明活着,却被当成垃圾收走了。这在带 GC 的语言里是致命错误,比内存泄漏严重一万倍。”
“写屏障就是防这个的?”
“写屏障的作用是:在’程序的写操作’和’GC 的标记操作’之间,加一个契约。 具体来说,Dijkstra 风格的写屏障做的是:当你执行 A.next = C 的时候,如果 C 是白色的,就把 C 标成灰色(或者把 A 重新标成灰色)。”
“这样 C 就不会被漏掉了。”
“代价是什么?”
”……每次写指针都要多执行几条指令。”
“对。写屏障不是免费的,它是用’程序运行时的一点开销’,换’GC 不用长时间 STW’这个大便宜。”
老陈说,Go 1.8 之前用的是 Dijkstra 写屏障(插入屏障),需要在标记结束后重新扫描一遍栈,那次扫描是要 STW 的,大概几百毫秒。
Go 1.8 引入了混合写屏障(hybrid write barrier),把 Dijkstra 的插入屏障和 Yuasa 的删除屏障合在一起。效果是:栈上的对象在 GC 开始时就全部标黑,标记过程中新分配的对象也标黑,于是标记结束之后不需要再重新扫描栈。
“那一次改动,把 Go 的 STW 从百毫秒级,压到了亚毫秒级。”
“现在 Go 的 GC 有几次 STW?”
“两次。“老陈说,“一次是标记开始(开启写屏障),一次是标记结束(关闭写屏障)。两次加起来,99.9% 的情况下在 1 毫秒以内。”
“那我那 110 毫秒的尖刺是哪来的?”
“STW 只有 1 毫秒,剩下 109 毫秒是什么?”
小林想不出来。
“两样东西。“老陈伸出两根手指。
“第一,标记辅助(mark assist)。当你的 goroutine 分配内存的速度,超过了 GC 标记的速度,运行时会强制这个 goroutine 停下来,去帮忙做标记工作。你分配得越快,你被拉去做苦力的概率越高。”
“第二,并发标记的 CPU 占用。GC 的标记工作默认会占用 25% 的 CPU(这个数叫 gcBackgroundUtilization)。你的服务在尖刺期间,可用的 CPU 少了四分之一。”
“这两个加起来,就是那 110 毫秒。“
生活里的同一件事:营业中的餐厅,怎么知道哪张桌是空的
我有个朋友在杭州开过一家小馆子,二十张桌子,做家常菜。
他跟我讲过一件事,我觉得比任何一篇讲 GC 的文章都清楚。
他说,最难的不是做菜,是翻台。
中午十一点半到一点,是两个小时的高峰。这期间,桌子的状态一直在变:有人坐下,有人吃完走了,有人吃完了还坐着聊天,有人占着座位去隔壁买烟了。
你要在营业的同时,判断出”哪张桌可以收了”。
这个过程,和 GC 是一模一样的。我一层一层说。
第一层:为什么不能”全部停下来数一遍”?
最笨的办法是:到某个时刻,喊一嗓子”所有人别动”,然后你挨桌数一遍,哪桌有人哪桌没人,数完再喊”大家继续”。
这是 STW(Stop The World)。
这个办法在 200 平米的店里行得通吗?行不通。因为高峰期你停一分钟,门口排队的人就走一半。而且你喊完”继续”,三秒钟之内又有人吃完走了,你的统计立刻过期。
所以餐厅的做法是:不停业盘点。
第二层:三色标记,就是三色的小旗子。
我朋友的办法是这样的。他给每张桌子准备了三种颜色的牌子:
- 白牌:还没看过的桌。
- 灰牌:确认有人,但还没问过他们”你们这一桌还有没有人要来、还要不要加菜”。
- 黑牌:确认有人,并且已经问过了。
开始盘点的时候,他先把门口那一批正在排队的、以及吧台那边正在点单的客人(这相当于”根对象”:栈上的变量、全局变量)对应的桌子,都插上灰牌。
然后他一遍一遍地走:
拿起一张灰牌的桌,走过去问一句”还有人要来吗”。问明白了,看看这桌上的人有没有跟别的桌的人是一起的(比如”我们是隔壁那桌的朋友”)。如果有,把隔壁那桌也插上灰牌。
问完这一桌,把灰牌换成黑牌。
一直走到没有任何灰牌为止。
这时候,所有还插着白牌的桌子,就是没人了,可以收。
这个过程,就是三色标记。灰色集合 = 你手上待处理的任务队列。
第三层:写屏障,那张”换桌登记本”。
现在问题来了。
你正在盘点的过程中,3 号桌的客人吃完了,起身挪到了 7 号桌(7 号桌刚被收走,还是白牌),跟那边的人一起喝啤酒。
你没看见。
你继续盘,盘到 7 号桌,看它是白牌,“没人”,于是你把它收了,把酒杯全撤了。
3 号桌那几个人回来,发现自己没地方坐了。
这就是漏标。
怎么办?我朋友的解决办法是:在吧台放一个本子,任何人换桌,必须来吧台登记一句”我从 3 号挪到了 7 号”。
登记之后,你在盘点的过程中,每次扫完一圈,就翻一遍那个本子。凡是本子上记过的”目标桌”,如果它是白牌,就改成灰牌——因为你知道有人挪过去了。
这个本子,就是写屏障。
写屏障的本质,就是在”程序修改引用”这个动作上,加一个额外的、很便宜的记录动作。这样 GC 在标记的时候,就能知道”这段时间内发生了哪些引用变更”。
注意那个”很便宜”。我朋友没有要求换桌的人填一张表,他只要求喊一嗓子。如果要求填表,客人就不换了——写屏障的开销必须足够小,否则程序就不写指针了(比喻意义上的)。
第四层:STW 的两次,是”喊两嗓子”。
不停业盘点,也不是完全不停。
第一次要停:在开始盘点之前,你要喊一嗓子”大家先别换桌啊”,然后把当前所有”正在排队/正在点单”的客人对应的桌子,统一插上灰牌。
这一嗓子必须喊,因为如果不喊,你插灰牌的这段时间里,有人换桌了,你就漏了。
这个停顿很短,因为你只需要处理门口那十几个人,不需要处理二十张桌子。
这对应 GC 的第一次 STW:扫描栈和全局变量,把根对象标灰。
第二次要停:所有灰牌都处理完之后,你要再喊一嗓子”再别动了啊”,然后翻最后一遍换桌登记本,把本子上新增的记录处理掉。
处理完,确认没有灰牌了,再喊”大家继续”。
这对应 GC 的第二次 STW:结束标记,处理写屏障缓冲里剩下的记录。
我朋友说,这两嗓子,每次大概十秒钟。
十秒钟,对一家餐厅是可以接受的。而 Go 把这两嗓子压缩到了亚毫秒级。
第五层:标记辅助,那句”老板你这桌我帮你端”。
这一层是我最喜欢的,因为它解释了小林那个 110 毫秒的尖刺。
我朋友店里有个规矩:忙的时候,如果某个服务员手上的单子太多、上菜速度跟不上后厨出菜的速度,那么这个服务员必须放下手上的活,去后厨帮忙端盘子。
注意这个逻辑:不是”闲的人去帮忙”,是”制造问题的人去帮忙”。
为什么?因为如果你一直在开新台、在接新单,后厨的盘子就会越堆越多。你要想继续开新台,就得先帮着把堆积清掉。
这就是 mark assist。
你的 goroutine 分配对象(开新台)的速度太快,GC 标记(后厨出菜)跟不上,运行时就强制你的 goroutine 停下来,去帮忙做标记(端盘子)。你分配得越快,你被抓去端盘子的时间越长。
所以小林那个接口,尖刺不是 GC “造成”的,是他的代码”触发”的。他的代码在短时间内分配了大量对象,GC 被逼着跑,然后 GC 回过头来抓他的 goroutine 去帮忙。
Go 实例:从”一万张券一万 goroutine”到看得见的运行时
先看小林最早写的批量发券(这段是他两年前写的,他一直觉得写得挺好):
package main
import (
"context"
"fmt"
"runtime"
"sync"
"time"
)
type Coupon struct {
Code string
UserID int64
Rules map[string]string // 每张券挂一份规则快照
Log []string
}
// GrantBatchV1:一万个用户,开一万个 goroutine。
func GrantBatchV1(ctx context.Context, userIDs []int64, tpl map[string]string) []*Coupon {
out := make([]*Coupon, 0, len(userIDs))
var mu sync.Mutex
var wg sync.WaitGroup
for _, uid := range userIDs {
wg.Add(1)
go func(uid int64) {
defer wg.Done()
// 每个 goroutine 都复制一份模板,并且建一个新的 map 和 slice
c := &Coupon{
Code: fmt.Sprintf("CPN%d", uid),
UserID: uid,
Rules: make(map[string]string, len(tpl)),
Log: make([]string, 0, 8),
}
for k, v := range tpl {
c.Rules[k] = v
}
c.Log = append(c.Log, "created")
// 模拟一次 DB 写入
time.Sleep(2 * time.Millisecond)
mu.Lock()
out = append(out, c)
mu.Unlock()
}(uid)
}
wg.Wait()
return out
}
func main() {
runtime.GOMAXPROCS(8)
ids := make([]int64, 10000)
for i := range ids {
ids[i] = int64(i)
}
tpl := map[string]string{"type": "full_reduce", "min": "9900", "amt": "3000"}
start := time.Now()
got := GrantBatchV1(context.Background(), ids, tpl)
fmt.Printf("V1: 发放 %d 张,耗时 %v\n", len(got), time.Since(start))
}这段代码的三个问题:
问题一,没有并发上限。 一万个 goroutine 同时向数据库发请求,DB 连接池瞬间被打满,大部分 goroutine 在等连接。这不会让程序变快,只会让它变慢,并且把数据库拖垮。
问题二,每个 goroutine 都复制了一份 tpl map。 一万个 map,每个三个 entry。这些 map 全是垃圾——函数一返回就没人要了。这是典型的”分配风暴”。这些分配会推高堆增长,从而更早触发 GC,从而触发 mark assist。
问题三,mu.Lock() 保护一个 append。 一万个 goroutine 抢一把锁,锁竞争本身的开销就不可忽略。
第二版,控制并发 + 消除分配:
package main
import (
"context"
"fmt"
"runtime"
"sync"
"time"
)
// CouponV2:Rules 改成共享的只读指针,不再逐券复制。
// 这是最重要的一处改动:模板在一万张券之间共享,只读,不需要锁。
type CouponV2 struct {
Code string
UserID int64
Rules *RuleSet // 共享,只读
}
type RuleSet struct {
Type string
Min int
Amt int
}
// GrantBatchV2:worker pool + 共享模板 + 预分配结果
func GrantBatchV2(ctx context.Context, userIDs []int64, rules *RuleSet, workers int) ([]CouponV2, error) {
// 关键 1:结果用索引写入,不需要锁。
// 每个 worker 只写自己负责的那些下标,无竞争。
out := make([]CouponV2, len(userIDs))
jobs := make(chan int, workers) // 关键 2:带缓冲的 channel 天然就是并发闸门
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for idx := range jobs {
uid := userIDs[idx]
// 关键 3:值类型直接写入切片,不 new,不逃逸
out[idx] = CouponV2{
Code: fmt.Sprintf("CPN%d", uid),
UserID: uid,
Rules: rules, // 共享指针,零分配
}
// 模拟 DB 写入
time.Sleep(2 * time.Millisecond)
}
}()
}
for i := range userIDs {
jobs <- i
}
close(jobs)
wg.Wait()
return out, nil
}
func main() {
runtime.GOMAXPROCS(8)
ids := make([]int64, 10000)
for i := range ids {
ids[i] = int64(i)
}
rules := &RuleSet{Type: "full_reduce", Min: 9900, Amt: 3000}
// 并发数的选择:不是 CPU 核数,是"下游能承受多少"。
// DB 连接池 50,这里就设 40,留 10 给其他业务。
start := time.Now()
got, _ := GrantBatchV2(context.Background(), ids, rules, 40)
fmt.Printf("V2: 发放 %d 张,耗时 %v\n", len(got), time.Since(start))
}三个改动,各对应一个原理:
改动一:out[idx] 按下标写入,不用锁。
这个技巧值得记住:如果每个 goroutine 写的结果是”位置确定”的,就不要用共享的 append + mutex,用预分配切片 + 下标。 前者是”抢着往一个筐里放”,后者是”每人一个格子”。这个改动通常能把锁竞争的开销降到零。
改动二:jobs 是个带缓冲的 channel,这就是并发闸门。
很多人的 worker pool 写法是:开 N 个 goroutine,然后往一个无缓冲 channel 里塞任务。这没错,但缓冲大小为 workers 的 channel 有个额外好处:它会让生产者”提前”把任务准备好,减少 worker 空等。
更重要的是:这个数字 N,你必须有依据。 不是 CPU 核数,不是”感觉差不多”,而是”下游的承受能力”。数据库连接池 50,你设 40;下游接口的限流是 100 QPS,你设 8。
这个数字从哪来,是能不能做性能优化决策的分水岭。 P6 会说”我调了一下,设成 50 最快”;P7 会说”设成 40,因为连接池是 50,留 10 个给其他业务,避免批量任务把在线业务饿死”。
改动三:Rules 共享指针,零分配。
一万个 map 变成一份共享的只读结构。这是从”分配 1 万个对象”到”分配 1 个对象”的变化。
注意这里有个前提:它必须是只读的。 如果有任何一个地方会改它,这个改动就引入了 data race。所以这个共享结构,应该在类型上就表达出不可变——Go 没有 immutable 关键字,但你可以用”不导出字段 + 只提供 getter”来逼近,或者写个注释说清楚。
第三版,是把它变得可观测。因为”我改好了”这件事,需要证据:
package main
import (
"fmt"
"os"
"runtime"
"runtime/debug"
"time"
)
// GCStats 采集一次操作前后的 GC 与调度指标。
// 这段代码的价值:它把"我感觉快了"变成"GC 次数从 37 次降到 2 次"。
type GCStats struct {
NumGC uint32
PauseTotalNs uint64
HeapAlloc uint64
TotalAlloc uint64
NumGoroutine int
Wall time.Duration
}
func Sample(fn func()) GCStats {
runtime.GC() // 先做一次完整 GC,让起点干净(仅用于基准测量)
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
t0 := time.Now()
fn()
wall := time.Since(t0)
runtime.ReadMemStats(&after)
return GCStats{
NumGC: after.NumGC - before.NumGC,
PauseTotalNs: after.PauseTotalNs - before.PauseTotalNs,
HeapAlloc: after.HeapAlloc,
TotalAlloc: after.TotalAlloc - before.TotalAlloc,
NumGoroutine: runtime.NumGoroutine(),
Wall: wall,
}
}
func (s GCStats) Report(name string) {
fmt.Printf(
"%-6s 耗时=%-10v GC次数=%-4d STW总计=%-8v 累计分配=%-8s 堆=%-8s goroutine=%d\n",
name, s.Wall, s.NumGC,
time.Duration(s.PauseTotalNs),
humanBytes(s.TotalAlloc),
humanBytes(s.HeapAlloc),
s.NumGoroutine,
)
}
func humanBytes(b uint64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%dB", b)
}
div, exp := uint64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f%cB", float64(b)/float64(div), "KMGTPE"[exp])
}
// SetMemoryLimitSafe 展示 Go 1.19+ 的 GOMEMLIMIT 用法。
// 它取代了老派的 "ballast"(分配一大块内存把堆撑起来,从而少触发 GC)。
func SetMemoryLimitSafe(limitBytes int64) {
if limitBytes <= 0 {
return
}
debug.SetMemoryLimit(limitBytes)
}
func main() {
runtime.GOMAXPROCS(8)
ids := make([]int64, 10000)
for i := range ids {
ids[i] = int64(i)
}
tpl := map[string]string{"type": "full_reduce", "min": "9900", "amt": "3000"}
rules := &RuleSet{Type: "full_reduce", Min: 9900, Amt: 3000}
// 关键:把 GC 追踪输出到 stderr
// 真实排查时,用的是环境变量 GODEBUG=gctrace=1,不用改代码。
debug.SetGCPercent(100)
_ = os.Stderr
Sample(func() { GrantBatchV1(nil, ids, tpl) }).Report("V1")
Sample(func() { GrantBatchV2(nil, ids, rules, 40) }).Report("V2")
// 输出示例(8 核机器):
// V1 耗时=1.83s GC次数=37 STW总计=41ms 累计分配=148.2MB 堆=6.1MB goroutine=2
// V2 耗时=512ms GC次数=2 STW总计=1.2ms 累计分配=3.4MB 堆=2.8MB goroutine=41
}看那个”累计分配”一栏:148MB vs 3.4MB,差了 43 倍。GC 次数 37 vs 2。
这一栏才是性能优化真正要盯的数字。 不是耗时(耗时受 IO 影响,波动大),不是 CPU(CPU 高不一定是问题),而是”总共分配了多少字节”。
原因很简单:GC 的压力,正比于你的分配速率,而不是你的堆大小。
这句话是本节最重要的一句。它解释了很多反直觉的现象:
- 一个堆只有 100MB 的服务,GC 可能比一个堆 4GB 的服务更频繁——只要它分配得快。
sync.Pool能生效,不是因为它”省了内存”,是因为它”减少了分配次数”。- 设置
GOGC=200能减少 GC 次数,但会让堆峰值翻倍,而且每次 GC 要扫的对象更多,单次 STW 可能反而变长。
所以小林那个 1.2G 的堆、两分钟一次的尖刺,正确的排查路径是:
GODEBUG=gctrace=1看 GC 日志,确认尖刺时间和 GC 时间对得上。- 看
TotalAlloc的增长速率,找出是哪个接口在疯狂分配。 - 上 pprof 的
alloc_space(注意:是alloc_space,不是inuse_space),定位分配热点。 - 改分配,而不是改 GOGC。
GOGC 和 GOMEMLIMIT 是最后的手段,不是第一选择。 它们就像退烧药:能让你舒服,但不治病。
这里补一个 1.19 之后的重要变化,因为很多人还在用过时的招:
老派的做法叫 ballast:在服务启动时分配一大块内存(比如 512MB 的 byte slice)并且永不释放,人为把堆撑大,这样 GOGC=100 触发的间隔就变长了。
这个技巧有效,但它有个问题:那 512MB 是真实占用的物理内存。 在容器里跑,它会计入你的 memory limit,可能触发 OOMKill。
Go 1.19 的 GOMEMLIMIT(以及对应的 debug.SetMemoryLimit)是官方的替代方案:你告诉运行时”我最多能用这么多内存”,运行时会自己调节 GC 的频率,在内存和 CPU 之间做权衡。
// 容器内存 limit 是 2GB,留 20% 余量给栈、线程、非 Go 内存
debug.SetMemoryLimit(1600 * 1024 * 1024)这是 P7 该知道的东西,不是因为它难,而是因为它是”你能不能做出正确的性能优化决策”的前提。
小林后来总结过一句话,我觉得说得挺好:
“以前我调性能,是改参数看效果。现在我得先知道,那个参数背后的机器是怎么转的。不然我就是在瞎拧旋钮。“
老陈的深层发问
-
你负责的服务,堆内存峰值是多少?GC 频率是多少?这两个数,你能不查监控说出来吗?
-
你上一次做性能优化,是改了分配,还是改了参数?如果是改参数,你知道你在”治标”吗?
-
你代码里有没有哪一段,是靠”Go 的 GC 会帮我收”才成立的?如果 GC 突然变慢十倍,那段代码会怎样?
-
你设置
GOMAXPROCS、GOGC、连接池大小、worker 数量的时候,每一个数字的依据是什么?有几个数字是”抄的”或者”试出来的”? -
如果有人问你”为什么 Go 能做到百万级并发”,你的回答里,会不会出现”goroutine 很轻量”这五个字?如果出现,你能接着说出第二句吗?
生活思考题
思考题一:你家的冰箱,多久清理一次?你清理的时候,是全部拿出来重新放,还是哪儿有空位塞哪儿?这两种方式,哪一种更接近 Go 的 GC?
参考答案
第一层:大多数人会怎么答 “我大概一个月清一次。清理的时候会全部拿出来,过期的扔掉,剩下的重新归类放好。这肯定更接近 GC 啊——GC 就是把没用的东西找出来扔掉,然后整理剩下的。”
第二层:这个答案为什么不够 “全部拿出来重新放”这种方式,对应的其实是**标记-整理(mark-compact)**算法,比如 Java 的某些 GC 收集器会做的事。
Go 的 GC 不做整理。 这是很多人不知道的一点。
Go 的堆是分代的吗?不是。Go 的 GC 会移动对象吗?不会。它只做标记和清除(mark & sweep),清掉的对象留下的空洞,会被放进一个空闲链表,等着被复用。
所以 Go 的堆,用你冰箱的比喻,是**“哪儿有空位塞哪儿”**——更准确地说,是”把空出来的格子记在一个本子上,下次买菜回来先查本子”。
这个设计有个直接的后果,值得每个 Go 工程师知道:Go 会有内存碎片问题,但它用一个叫 size class 的机制把这个问题控制住了。 堆上的对象被分成大约 70 种大小规格,同样规格的对象放在一起分配。这样”空洞”的大小总是能被复用。
更深一层的盲区是这个:“一个月清一次”这个频率,你是怎么定的?
我猜你不是按日历定的,你是按”冰箱满了”定的。菜塞不进去了,你就清理一次。
这就是
GOGC=100的真实含义——它不是时间驱动的,是增长驱动的。堆涨了一倍,清一次。第三层:更深的一层是什么 现在有意思的地方来了。
你冰箱满了才清理,意味着什么?意味着你清理的那一天,会比较累——因为要扔的东西多。
而如果你每天顺手扔掉一样过期的东西,虽然你每天都要花十秒钟,但你永远不会有一天累得不想动。
这就是 GC 里最核心的一个权衡:吞吐量 vs 延迟。
- 攒着一起清 = 吞吐量高(你总共花的时间少),但单次停顿长(累的那一天)。
- 每天清一点 = 单次停顿短,但总开销大(每天都要想一下)。
Go 选的是后者,而且做得非常激进:并发标记 + 混合写屏障 + 亚毫秒 STW。它宁可多花一些 CPU(后台标记占 25%),也要把单次停顿压到最低。
为什么?因为 Go 的主要场景是在线服务。在线服务关心的是 P99,不是平均耗时。 一个平均 100ms 但 P99 是 2s 的接口,比一个平均 150ms 但 P99 是 180ms 的接口差得多。
这里还有一层,是很少有人讲的:“顺手扔”这个动作,也是有成本的,而且它可能被强制执行在你身上。
设想:你正在做饭(业务 goroutine 在跑),你妈在旁边清理冰箱(GC 在标记)。你一边做饭一边往冰箱里塞新买的菜(分配对象)。你塞得越快,你妈越跟不上,最后她会把你手上的菜接过去,说”你自己放,自己放”。
这就是 mark assist。 制造垃圾的人,被强制去帮忙清理垃圾。
第四层:落到 Go 工程师身上 三件具体的事,按性价比排序:
一、在你的服务里加一个 GC 指标看板,三个图:GC 次数(rate)、STW 的 P99(
go_gc_duration_seconds的 quantile)、堆大小。没有这个看板,你说的所有”性能优化”都是拍脑袋。二、跑一次
go test -bench . -benchmem,看输出里那两列:B/op(每次操作分配多少字节)和allocs/op(每次操作分配多少次)。这两个数,比 ns/op 更能预测你的服务在线上的 GC 压力。三、对你最热的那个接口做一次 alloc 分析:
go test -bench BenchmarkYourHotPath -memprofile mem.out go tool pprof -sample_index=alloc_space -http=:8080 mem.out注意那个
-sample_index=alloc_space。大部分人默认看的是inuse_space(当前还活着多少),而你应该看alloc_space(总共分配过多少)。GC 关心的是 alloc_space。做完这三步,你大概率会看到一个让你意外的结果:分配最多的那一行代码,通常不是你以为的那一行。
思考题二:你在游乐场排队玩项目。队伍排了 200 米,但项目的入口只有 4 个闸机。工作人员一直在广播:“请提前准备好二维码。“为什么广播这句话有用?它真的能让队伍变快吗?
参考答案
第一层:大多数人会怎么答 “有用。因为如果每个人到了闸机口才掏手机找二维码,闸机那边就要堵住。提前准备好,每个人过闸机的时间就从 10 秒变成 2 秒,4 个闸机的吞吐量翻五倍。”
第二层:这个答案为什么不够 这个答案是对的,但它只解释了”为什么有效”,没回答”有没有代价”。
“提前准备二维码”这个动作,不是免费的。它要求你在排队的 200 米里,就打开手机、点开 App、找到那个码、把亮度调高。
而真实情况是:大部分人不会提前准备。 他们会排到还有十米的时候才开始找。
于是会出现一个很有意思的波形:闸机口附近那十米,所有人都在低头掏手机,队伍走得特别慢;而 200 米开外的地方,队伍走得正常。
“提前准备”这个优化,把成本从”闸机口”转移到了”队伍中”,但并没有消除它。 它只是让成本错峰了。
更深一层的盲区是:工作人员为什么广播这一句,而不是去增加闸机?
因为增加闸机的成本很高(要买设备、要改造、要多招人),而广播的成本是零。
这是一个典型的”在不改变瓶颈的情况下缓解瓶颈”的做法。 它有用,但它有上限——当队伍排到 500 米的时候,光靠广播就不够了。
第三层:更深的一层是什么 这个场景里,真正值得琢磨的是”闸机”这个东西的存在。
为什么是 4 个闸机,不是 40 个?为什么不是不要闸机?
因为闸机保护的不是入口,是项目本身的容量。过山车一次只能坐 24 个人,你开 40 个闸机,只是让 200 个人更快地挤到站台前面等着,一点用都没有。
闸机的数量,是由下游的容量决定的,不是由上游的队伍长度决定的。
这句话,是本节所有内容里最值钱的一句。
回到小林那个批量发券:他开一万个 goroutine,相当于”看到队伍 200 米长,就开了 200 个闸机”。结果是什么?不是更快,是把数据库(过山车)挤爆了,然后所有人都得等。
而正确的做法,是去问那个数字:数据库一次能承受多少并发写入? 连接池 50,那就是 50 个闸机。多一个都是浪费,而且是危险的——因为批量任务会饿死在线业务。
现在把这三样对应起来:
游乐场 Go 运行时 业务系统 游客 goroutine (G) 请求 闸机 P(逻辑处理器) 并发额度 / 信号量 过山车座位 CPU 核 数据库连接 / 下游容量 看中间那一列:
GOMAXPROCS是”闸机数量”,CPU 核数是”过山车座位”。 这俩不一样。你可以设
GOMAXPROCS=1000(开 1000 个闸机),但你的机器只有 8 核(过山车只有 8 个座位)。结果是:992 个 goroutine 挤在”已就绪”队列里,等着被调度,每个都要排队。它们占用的内存是真的,占的调度开销是真的,但吞吐一点没提高。反过来,你也可以设
GOMAXPROCS=1。这时候你的程序变成单线程的,但不会出错——只是慢。第四层:落到 Go 工程师身上 打开你的服务,找出所有”并发度”相关的数字,列一张表:
GOMAXPROCS(如果是默认,写”默认=核数”)- worker pool 的 goroutine 数
- 数据库连接池的 MaxOpenConns / MaxIdleConns
- Redis 连接池大小
- 每个下游 HTTP/RPC 客户端的连接池或限流值
- 消息队列的消费者并发数
然后,对着每一个数字,写下它的依据。
依据必须是下游的某个容量,不能是”感觉”、“默认”、“之前的人设的”。比如:
- worker pool = 40,因为 DB 连接池 50,留 10 给在线业务。
- Redis 连接池 = 20,因为 Redis 单线程,超过 20 个并发也只是排队。
- 消费者并发 = 分区数,因为 Kafka 一个分区只能被一个消费者消费,多了会空转。
这张表填完,你会发现:至少有一半的数字,你说不出依据。
那些说不出依据的数字,就是你系统的隐藏瓶颈——或者隐藏的炸弹。 它们没出事,只是因为流量还没到那个量级。
最后一句:这张表,是”你能不能做性能优化决策”的资格证。没有它,你调的每一个参数都是赌博。
4.6 架构评审:Kevin 问的那句话,为什么把你问住了
七月中旬,券系统要上 v2 了。
这次不是”先上个简单的”。运营那边用了一个月,效果不错,获客成本从 37 降到了 33.5。王总在月度会上提了一嘴,结果第二天,另外两条业务线都找上门来:会员业务要做权益券,内容业务要做付费课程的抵扣券。
于是 v2 的需求变成:一套券系统,支撑三条业务线,券的玩法各不相同,将来可能还有第四条。
小林这次学乖了。他先找阿 May 对了一上午,又去找了王总两次,最后写成了一份 11 页的设计文档。文档的第一页不是架构图,是一张表:
| 业务线 | 券的形态 | 发放方 | 核销场景 | 预算归属 | 上线时间 |
|---|---|---|---|---|---|
| 增长(原) | 满减券,用户主动领 | 系统自动 | 下单页 | 市场部 | 已上线 |
| 会员 | 权益券,系统自动发 | 定时任务 | 下单页 + 会员页 | 会员部 | 8/15 |
| 内容 | 课程抵扣券,运营手动发 | 运营后台 | 课程支付页 | 内容部 | 8/30 |
他还在第二页写了四条明确”不做”的:
- 本期不做券的叠加。
- 本期不做券的转赠。
- 本期不做券的分佣(分销)。
- 本期不做实时预算看板(用 T+1 的离线报表代替)。
文档提前三天发到了群里。
评审会定在周三下午两点,参会的人有:Kevin、大刘、阿 May、王总(来了一半就走了),还有会员和内容两条线的两个后端。
小林讲了二十分钟。他这次没从架构图讲起,他从那张业务表讲起,讲了三个业务线的差异,讲了四条”不做”,然后才讲结构。
讲完之后,会议室安静了几秒。
Kevin 说:“整体 OK。”
小林心里松了半口气。
然后 Kevin 在白板上写了一个词:
Hypothesis
“我问你一个问题。“Kevin 说,“你这个设计,建立在几个假设之上?”
“假设?”
“就是你默认它成立、但你没有验证过的东西。”
小林想了想:“我……我假设三条业务线的券,数据结构是能统一的。”
“这是一个。还有吗?”
”……我假设核销的量不会太大。”
“多大算大?”
“QPS 五百以内。”
“你怎么知道的?”
”……现在是一百二。”
Kevin 把这两个记在白板上,然后问了那句把小林问住的话:
“这两个假设里,哪一个如果错了,你这套设计就整个废掉?”
小林张了张嘴。
他脑子里飞速转了一遍。数据结构统一——如果会员的权益券根本不是”券”的形态(比如它是”一年内免运费”,没有面额,没有门槛,不能核销),那他的 coupon_instance 表就装不下它。
而核销 QPS——如果超了,他可以加机器、加缓存,不会”整个废掉”。
“第一个。“他说。
“对。“Kevin 说,“那我问你第三个问题:如果这个假设是错的,你什么时候能知道?”
”……上线的时候?”
“上线的时候发现三条业务线的数据装不进同一张表,你要怎么办?”
会议室又安静了几秒。
“我……我可以在 coupon_instance 里加一个 ext 字段,放 JSON。”
Kevin 笑了笑,没说话。
大刘在角落里举手:“我插一句。你这个 ext 字段,是不是就是潘多拉的盒子?三个月之后,那个 JSON 里会有二十七个 key,每个业务线放一半,谁都不知道哪几个还在用。”
“我不是那个意思……”
“我知道你不是那个意思。“大刘说,“但结果会是这样。我在这行十二年,ext 字段我见过不下二十个,结局都一样。”
Kevin 等大刘说完,才接着说:
“小林,我刚才问你的那三个问题,不是在为难你。“他说,“我是在问:你这十一页文档里,有没有一个地方,写着’我最没把握的是 X’。”
“没有。”
“我看出来了。“Kevin 说,“你的文档写得很完整,架构、时序、表结构、接口定义、回滚方案,全都有。但通篇读下来,我不知道你最担心的是什么。”
“一份让评审者看不出’作者担心什么’的设计文档,有两种可能。“Kevin 说,“一种是你真的什么都想到了。另一种是你还没找到那个真正的问题。”
“你觉得你是哪种?”
那天晚上小林把这段对话讲给老陈听,讲完加了一句:“我当时特别想反驳他,但是我说不出话来。”
“你为什么想反驳?”
“因为我觉得我准备得很充分。11 页,提前三天发,我还写了四条’不做’。”
“你确实准备得充分。“老陈说,“但你准备的是’答案’,Kevin 问的是’你没有把握的部分’。”
“这俩不一样吗?”
“完全不一样。“老陈说,“你把评审当成考试了。”
“什么意思?”
“考试是什么?是你提前知道考点,把答案背熟,然后进场,把你会的东西展示出来,让考官给你打个分。”
“Kevin 问你’你最担心什么’的时候,你的第一反应是’我不能说,说了他会觉得我没想清楚’。这是考生的心态。”
小林没吭声。他知道老陈说对了。
“评审不是考试。“老陈说,“评审是——“他想了个词,“是采购。”
“采购?”
“你带着十一页文档进去,你的目标不应该是’让大家通过’。你的目标应该是:从这六个人的脑子里,带走三个你自己想不到的东西。”
“通过,是评审的最低标准,不是目标。”
小林在纸上写了”采购”两个字。
“‘通过’这个事,我见过太多反例了。“老陈说,“2016 年,我做过一个评审,全场无异议,所有人都说’挺好’,四十分钟结束。那是当年过得最顺的一次评审。”
“结果呢?”
“那个系统上线四个月就重写了。“老陈说,“因为全场没有人真正读过那三十页文档。大家都在看你讲得流不流畅。”
“所以一个’顺利通过’的评审,可能是一个失败的评审。”
“对。“老陈说,“一个有价值的评审,开完会你应该是不舒服的。 如果你的方案被四个人夸了一顿、提了三个无关痛痒的建议,那说明他们没认真看,或者你没给他们能下嘴的地方。”
“什么叫’能下嘴的地方’?”
“就是你主动把你的软肋亮出来。“老陈说,“比如你在文档第一页写:‘我最没把握的是券模型的统一性,具体是第 3 节。如果这一条错了,方案要推倒重来。请重点看这里。’”
“这样写……不怕被人觉得不行吗?”
“你觉得 Kevin 是那种人吗?”
小林想了想:“不是。”
“大刘呢?”
”……也不是。”
“那就对了。“老陈说,“你怕的不是 Kevin,你怕的是’承认自己没把握’这件事本身。”
“我再给你讲个更狠的。“老陈说。
“我三十九岁那年,去一家公司做技术顾问。他们有个架构评审,我去旁听。”
“评审的是一个支付网关的重构,主讲的是个三十出头的小伙子,讲得很好。讲到一半,他们 CTO——一个五十多岁的、话很少的人——举手问了一句:”
“‘你这个方案里,哪一个决定是最难改的?’”
“主讲的人愣了一下,然后开始讲数据库的选型。”
“CTO 摇头,说:‘我问的不是’哪个技术最难换’。我问的是:你这个方案落地半年之后,如果有人跟你说’我们要改一个需求’,有没有哪个需求,会让你说’这个改不了,因为我们当初是这么设计的’?’”
“主讲的人想了很久,最后说:‘业务方如果要求一笔支付拆成两笔子订单分别结算,我们改不了。’”
“CTO 说:‘好。那我们今天就讨论这一件事:这个需求,半年内出现的概率有多大?’”
“后来呢?”
“后来他们花了一个小时,只讨论这一件事。最后那个小伙子改了订单模型,多花了十天。”
“那个需求出现了吗?”
“出现了。四个月之后。“老陈说,“那十天,省了三个月的返工。”
小林坐在那儿,手托着下巴。
“我现在问你,“老陈说,“你的券系统 v2 里,哪一个决定是最难改的?”
小林想了很久。
”……券模型的统一。”
“具体点。”
“我把三条业务线的券,放在了同一张 coupon_instance 表里,用 biz_type 区分。如果将来会员的权益券不是’券’,而是’资格’,那它就装不进来。”
“对。“老陈说,“那你现在告诉我:这个决定,可逆吗?”
”……不可逆。或者说,改的代价很大。”
“有多大?”
“三张表要拆,所有查询要改,缓存要改……两个月?”
“两个月。“老陈重复,“那我问你:你愿不愿意为了’可能不发生的事’,现在多花十天?”
“我不知道。”
“这就是 P7 要做的判断。“老陈说,“它不是一个技术判断,是一个概率判断,而且这个概率,你永远算不准。”
“那怎么办?”
“三个办法。”
“第一,降低不可逆性。能不能让’券’和’资格’从一开始就是两个东西,中间用一个薄薄的适配层连起来?”
“第二,延迟决策。能不能先只上增长和会员两条线,内容那条线晚一个月,等你看清会员的权益券长什么样再定?”
“第三,买保险。如果就是要在今天决定,那就把’如果错了怎么迁移’写清楚,包括迁移脚本、双写方案、时间窗口。这个方案不一定要执行,但写它的过程会告诉你这个决定有多危险。”
小林把这三条理了一下,突然问:“老陈,如果一个决定,我用这三个办法都处理不了呢?”
“那就说明它不是一个技术决定。“老陈说,“它是一个业务决定,你该把它交回去。”
“交回给谁?”
“交给能承担后果的人。“
生活里的同一件事:那份被同学改了三处的川西自驾行程
2023 年国庆,小林规划了一次川西自驾。
八天,成都出发,走小环线。他做了份很详细的行程表,存在腾讯文档里:每天几点出发、开多少公里、走哪条路、住哪个县城、海拔多少、附近有什么吃的。
他做这份表花了三个晚上。地图上每段路都用测距工具量过,酒店是逐个比价订的,还标了每段的预计耗时。
出发前十天,他把链接发到了大学同学群里,附了一句:“大家帮忙看看有没有问题。”
群里四个人,回了三句。
第一句,来自一个在成都待过五年的同学,老杨:
“你第四天,丹巴到色达。你写的是 280 公里,4 小时。这段我 2019 年开过,实际要 8 个小时往上。原因有两个:一是要翻垭口,二是中间有差不多 60 公里在修路,是碎石路面,时速上不了 30。”
小林第一反应是想反驳——他确实在地图上量过,也查过攻略。
但他忍住了,回了句:“那我这一天怎么安排?”
老杨说:“要么在丹巴多住一晚,要么把色达那天砍掉。八天走小环线本来就紧,你中间还加了两个支线。”
第二句,来自另一个同学:
“你第六天住的那个地方,海拔 4200。你们四个人都是第一次上高原吧?我建议你别在那儿住,往下降到 3200 左右的县城。高原反应这事儿,不是’身体素质好就没问题’,是无规律的。我们那次四个人里,平时跑马拉松的那个反应最厉害。”
第三句,来自一个女同学:
“你每天的行程,最早是七点出发,最晚是九点半到酒店。中间除了吃饭没有任何机动时间。你们四个人里有会晕车的吗?有要上厕所的频次跟别人不一样的吗?有拍照会拍很久的吗?八天,一次机动时间都不留,第二天就会有人开始不高兴。”
小林最后改了三处,都是照这三句改的。
改完之后的实际行程证明,这三句救了整个假期。
老杨说的那段路,他们开了 7 小时 40 分(不是 8 小时,因为路况比 2019 年好了一些,但确实远不是 4 小时)。如果按原计划,他们会在天黑之后还在山路上。
海拔那件事:他们同行的一个人,在 3600 的地方就已经开始头疼了。
机动时间那件事:第四天,他们在路上遇到一片特别好看的草甸,停了整整两个小时。
这件事和架构评审,是同一件事。我把它拆开讲。
第一,那三句话的共同特点是什么?
它们都不是”你这个方案不好”。
它们是**“你没去过那里,我去过”**。
小林查了三个晚上的攻略,攻略告诉他的是”距离 280 公里”、“海拔 4200”。而老杨告诉他的是”那 60 公里是碎石路”,那个女同学告诉他的是”第二天就会有人不高兴”。
信息,和经历过之后的信息,不是同一种东西。
架构评审的价值,全部来自这个差值。Kevin 在硅谷见过券系统怎么做,大刘半夜被叫起来过几十次,他们脑子里有你不具备的数据。
所以评审会的第一个问题是:人对不对。
一场评审,最怕的不是有人提尖锐问题,是所有评审者都是同一个背景的人。五个人全懂 Go,全懂高并发,但没人懂财务、没人懂运维、没人真的用过这个产品——这样的评审,会通过得很顺利,然后在线上出事。
小林那次评审,真正有价值的两个问题,一个来自 Kevin(假设是否成立),一个来自大刘(ext 字段的下场)。这两个人的背景完全不同,这是那场会最值钱的地方。
第二,小林那个”想反驳”的瞬间,是最关键的。
看到老杨那句”实际要 8 小时”,他第一反应是”我查过攻略的”。
这个反应本身没有错,它是人的本能。但评审的价值,全部产生在”忍住这个反应”之后的那一句话上。
忍住了,问”那怎么改”,你就拿到了老杨脑子里那 7 小时 40 分的真实数据。
没忍住,回一句”我查过,攻略上写的是 4 小时”,老杨就不会再说话了。他不会跟你争——因为他的信息是从经历里来的,没法用一句攻略推翻,而他又没必要说服你。
评审会上最贵的一句话是”我查过”。 它让你保住了面子,丢掉了信息。
第三,那份行程表缺的不是细节,是”我最没把握的地方”。
小林那份表,详细到每段路的公里数。但通篇读下来,你看不出他自己担心什么。
如果他第一页写的是:
“这份行程我最没把握的三个地方:1)丹巴到色达那段的实际耗时,我只查到攻略数据;2)高海拔住宿,我们四个都是第一次上高原;3)行程太满,没有机动时间。请重点帮我看这三处。”
那三个同学会怎么回?
他们会直接回答这三个问题。他们不需要从头到尾读那份表,不需要自己去发现”哦这段路可能有问题”。
你给了他们一个下嘴的地方。
回到 Kevin 那句”我不知道你最担心的是什么”——他要的就是这个。
第四,也是我最想说的一点:那三个同学,不是最”厉害”的人。
小林那个群里,有人是大厂的技术专家,有人自己开了公司。那三句话,没有一句是他们说的。
说话的三个人,一个是”在成都待过五年”,一个是”上过高原”,一个是”跟人一起旅过游”。
他们在那三个具体的问题上,是经验最丰富的人。
架构评审最容易犯的错,就是只请”级别最高的人”。级别高的人视野好,但他可能三年没碰过线上运维了,可能从来没跟财务对过账。
真正的评审名单,应该是按**“这个方案会踩到哪些坑,谁踩过”**来列的,不是按职级列的。
小林后来做 v2 的第二次评审,名单里多加了一个人:财务部门的一个会计,姓吴。
老吴只提了一个问题:“你们这个券,用户用了之后,这个钱算谁的成本?是记在市场部的预算,还是记在毛利里冲减?”
全场没人答得上来。
那个问题最后成了 v2 里最大的一次改动——他们加了一张 coupon_settlement 表,专门记券的成本归属。
而那张表,小林在 v1 的时候就设计过,后来删掉了(“本期不做”)。
它在 v2 被加了回来,而且是被一个会计加回来的。
Go 实例:把评审会上的”假设”写成会报警的代码
Kevin 那句”你这两个假设,哪个错了整个设计就废了”,小林回去想了很久。他最后做了一件事:把假设从文档里,搬到代码里。
先看大多数人的做法——假设只存在于脑子里和 PPT 里:
package main
import (
"context"
"fmt"
"time"
)
// CouponInstance 券实例表对应的结构。
// 问题:这里有几个"没写在代码里"的假设:
// 1. 三条业务线的券,结构是一样的(用 biz_type 区分)
// 2. 一个用户持有的券不会超过 50 张(所以列表接口不分页)
// 3. 券模板总数不超过 1000(所以模板全量缓存在内存)
// 4. Amount 一定能装下面额(单位:分)
// 这些假设写在了设计文档第 7 页,代码里一行痕迹都没有。
type CouponInstance struct {
ID int64
BizType string
UserID int64
Amount int64
MinSpend int64
Status string
ExpireAt time.Time
}
// ListUserCoupons 返回用户的券列表。注意:没有分页。
func ListUserCoupons(ctx context.Context, userID int64) ([]*CouponInstance, error) {
// 假设:不超过 50 张。如果哪天变成了 5000 张,
// 这个函数会静默地变慢、变占内存,直到某天 OOM。
return queryAll(ctx, userID)
}
func queryAll(ctx context.Context, userID int64) ([]*CouponInstance, error) {
return nil, nil
}这些假设的麻烦在哪儿?
它们被违反的时候,系统不会报错,只会慢慢变差。
“一个用户不超过 50 张券”这个假设被打破的时候,发生的不是 500 错误,而是:接口从 20ms 变成 300ms,内存从 2KB/请求 变成 200KB/请求,然后某天流量高峰,Pod 开始 OOM 重启。
你查的时候会看到:OOM、GC 频繁、慢查询。没有一条指向”那个假设错了”。
第二版,稍微好一点:把假设写成注释。
// ListUserCoupons 返回用户的券列表。
// NOTE: 假设单用户持券数 < 50(见设计文档 v2 第 7 页)。
// 如果业务上开始发"券包"或者"批量发放",这个假设会失效,
// 届时改成分页接口。
func ListUserCoupons(ctx context.Context, userID int64) ([]*CouponInstance, error) {
return queryAll(ctx, userID)
}这比没有强,但它有个致命缺陷:注释不会报警。
文档里那句话,写完三个月之后,写它的人可能已经调岗了。新来的人看这个函数,看到的是”不分页的列表接口”,他不知道这背后有个假设。
第三版,把假设变成会说话的代码:
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
"sync"
"time"
)
// Assumption 是一条设计假设。
// 它和 4.1 节的 Constraint 是同一个思路:把"想当然"变成"可检查"。
type Assumption struct {
ID string // 唯一标识,用于监控和告警
Text string // 人话描述,写给半年后的自己看
Check func() (bool, string)
Severity string // "fatal" | "warn"
Owner string // 谁在盯这条
Reviewed time.Time // 上次复核时间,过期会提醒
}
// AssumptionSet 一组假设,可以自检,可以暴露给监控系统。
type AssumptionSet struct {
mu sync.Mutex
items []Assumption
broken map[string]string // 已违反的假设及原因
alert func(id, text, detail string)
}
func NewAssumptionSet(alert func(string, string, string)) *AssumptionSet {
return &AssumptionSet{
broken: map[string]string{},
alert: alert,
}
}
func (s *AssumptionSet) Add(a Assumption) { s.items = append(s.items, a) }
// Verify 跑一遍所有假设。
// 关键设计:违反假设不会 panic,不会拒绝服务。
// 它的作用是"让假设被违反这件事,变成一个能被看见的信号"。
func (s *AssumptionSet) Verify() {
for _, a := range s.items {
ok, detail := a.Check()
s.mu.Lock()
if ok {
delete(s.broken, a.ID)
s.mu.Unlock()
continue
}
already, existed := s.broken[a.ID]
s.broken[a.ID] = detail
s.mu.Unlock()
// 只在"第一次违反"或"原因变化"时告警,避免告警轰炸
if !existed || already != detail {
if s.alert != nil {
s.alert(a.ID, a.Text, detail)
}
if a.Severity == "fatal" {
log.Printf("[FATAL] 假设 %s 被违反:%s —— %s(负责人:%s)",
a.ID, a.Text, detail, a.Owner)
} else {
log.Printf("[WARN] 假设 %s 被违反:%s —— %s", a.ID, a.Text, detail)
}
}
}
}
// StartPeriodicCheck 启动定时检查。
// 频率不用太高,几分钟一次足够——假设的变化通常是缓慢的。
func (s *AssumptionSet) StartPeriodicCheck(d time.Duration) {
go func() {
t := time.NewTicker(d)
defer t.Stop()
for range t.C {
s.Verify()
}
}()
}
// ---- 业务侧:把 Kevin 问出来的那两条,写成假设 ----
var (
// 这两个是可变的计数器,真实场景来自数据库 COUNT(*)
maxCouponsPerUser int64
templateCount int64
distinctBizShapes int64
)
// SetupAssumptions 声明券系统的设计假设。
// 这份列表,就是架构评审的第二页。
func SetupAssumptions(alert func(string, string, string)) *AssumptionSet {
s := NewAssumptionSet(alert)
s.Add(Assumption{
ID: "ASM-001",
Text: "单用户持券数 < 50,列表接口可以不分页",
Severity: "warn",
Owner: "小林",
Reviewed: time.Date(2024, 7, 15, 0, 0, 0, 0, time.Local),
Check: func() (bool, string) {
n := atomic.LoadInt64(&maxCouponsPerUser)
if n > 50 {
return false, fmt.Sprintf("当前最大持券数 %d,已超过 50,分页改造需启动", n)
}
if n > 25 {
// 提前预警:一半的时候就要说,不要等到破了才说
return false, fmt.Sprintf("当前最大持券数 %d,已达阈值一半,请评估趋势", n)
}
return true, ""
},
})
s.Add(Assumption{
ID: "ASM-002",
Text: "三条业务线的券,数据结构是同构的(Kevin 评审重点)",
Severity: "fatal",
Owner: "小林",
Reviewed: time.Date(2024, 7, 15, 0, 0, 0, 0, time.Local),
Check: func() (bool, string) {
shapes := atomic.LoadInt64(&distinctBizShapes)
if shapes > 1 {
return false, fmt.Sprintf("检测到 %d 种异构券形态,coupon_instance 表需要拆分", shapes)
}
return true, ""
},
})
s.Add(Assumption{
ID: "ASM-003",
Text: "券模板总数 < 1000,可全量缓存到内存",
Severity: "warn",
Owner: "小林",
Reviewed: time.Date(2024, 7, 15, 0, 0, 0, 0, time.Local),
Check: func() (bool, string) {
n := atomic.LoadInt64(&templateCount)
if n > 1000 {
return false, fmt.Sprintf("模板数 %d 超过 1000,全量缓存将占用过多内存", n)
}
return true, ""
},
})
return s
}
// CheckOnBoot 启动自检。fatal 级别的假设在启动时就被违反,直接拒绝启动。
// 这一条看起来激进,实际上极其有用:
// 它把"上线四个月后才发现模型错了",变成"部署时就不让你上"。
func CheckOnBoot(s *AssumptionSet) error {
for _, a := range s.items {
if a.Severity != "fatal" {
continue
}
ok, detail := a.Check()
if !ok {
return fmt.Errorf("启动自检失败:假设 %s 已被违反(%s)。"+
"这是一个不可逆的设计假设,请在架构层面解决后再上线。"+
"负责人:%s", a.ID, detail, a.Owner)
}
}
return nil
}
func main() {
// 模拟线上状态:出现了第二种券形态
atomic.StoreInt64(&distinctBizShapes, 2)
atomic.StoreInt64(&maxCouponsPerUser, 63)
atomic.StoreInt64(&templateCount, 420)
alert := func(id, text, detail string) {
// 真实场景:推送到告警平台 / 打 metrics
fmt.Fprintf(os.Stderr, "ALERT [%s] %s -> %s\n", id, text, detail)
}
s := SetupAssumptions(alert)
if err := CheckOnBoot(s); err != nil {
fmt.Fprintln(os.Stderr, "服务拒绝启动:", err)
return
}
s.StartPeriodicCheck(5 * time.Minute)
}跑一下,输出是:
服务拒绝启动: 启动自检失败:假设 ASM-002 已被违反(检测到 2 种异构券形态,coupon_instance 表需要拆分)。这是一个不可逆的设计假设,请在架构层面解决后再上线。负责人:小林
这一整套东西的价值,不在代码本身,在三个副作用上:
副作用一,它把”假设”从一份会过期的文档,变成了一份会说话的清单。
新来的人接手这个服务,他不需要去翻设计文档(很可能已经找不到了)。他只要打开 SetupAssumptions,三条假设,每条一行人话,谁负责,什么时候复核的,一目了然。
副作用二,它给了你一个”什么时候该改设计”的客观信号。
这是最值钱的。没有这个东西的时候,架构重构的时机全凭感觉——要么拖到出事,要么过早重构浪费时间。
有了它,你会在”最大持券数达到 25”的时候收到一条 warn。那时候系统还完全正常,但你已经知道该动了。
副作用三,它改变了评审会的性质。
小林做 v2 第二次评审的时候,他做的第一件事不是讲架构,是打开这个文件,投屏,说:
“这是这套设计成立的前提。三条。第一条和第三条错了,我们能改,代价是两周。第二条错了,整个模型要推倒,代价是两个月。我今天要重点讲第二条。”
那场会的四十分钟,有三十分钟在讨论第二条。
Kevin 会后说了一句话:“这是我今年参加过的最高效的一次评审。”
最后补一个容易被滥用提醒:不要把这个东西做成”处处是假设”。
我见过有人写了六十多条假设,结果每条都在告警,最后没人看了。
判据是:只写”错了会让设计推倒重来”的那几条。三条到五条。如果超过十条,说明你的设计里有太多”想当然”,那本身就是个问题。
还有一个细节:Reviewed 字段。它的作用是——假设不是永恒的。业务变了,假设就该重新评估。我给每条假设设一个复核周期(比如一个季度),过期了就在周会上提醒一次。
这一条是跟 Kevin 学的。他说:“一个一年没人看过的假设,不是假设,是迷信。“
老陈的深层发问
-
你最近做的一个设计,你最没把握的是哪一部分?如果现在让你把它写在文档第一页,你敢写吗?不敢的话,你在怕什么?
-
你的设计文档,评审者是提前一天以上拿到的,还是会上第一次看到?如果是后者,你觉得他们有多少概率真的看懂了?
-
上一次评审,有人给你提了一个你没想过的问题。你当时的第一反应是想回答,还是想解释?那个反应,说明了什么?
-
你的方案里,有没有一个决定是”半年后改起来会非常痛苦”的?如果有,你知道它是什么吗?你告诉过谁?
-
如果让你列一个评审名单,你会按什么标准选人?你敢不敢请一个”不懂技术但懂业务”的人来?
生活思考题
思考题一:你买完房,拿到钥匙那天,开发商会给你一张”房屋验收单”,上面有几十项:门窗、水电、墙面、防水、排水。你会一项一项验吗?还是签个字就走?那些一项一项验的人,最后发现了什么?
参考答案
第一层:大多数人会怎么答 “大部分人签字就走了,因为不懂,也不知道怎么验。那些认真验的人,一般都找了验房师,能查出空鼓、渗水、门窗密封不严、地漏堵塞这些问题。修起来不贵,但不验的话住进去就麻烦了。”
第二层:这个答案为什么不够 “能查出空鼓和渗水”只是表面。真正有意思的是:验房师查出的问题里,哪一类最值钱?
不是空鼓,不是渗水。是**“这个东西在图纸上和实物不一样”**。
我一个做验房的朋友说过:他最关注的从来不是质量瑕疵(那些都能修,而且开发商一般会修),他关注的是三类:
一、不可逆的:承重墙的位置、管道井的位置、层高、梁的位置。这些一旦成型,你花多少钱都改不了。 二、影响后续所有装修的:比如你打算装中央空调,但开发商预留的机位不够;比如你打算改开放式厨房,但烟道的位置决定了改不了。 三、会影响邻居或公共部分的:比如你家的防水没做好,漏到楼下,那是你的责任,而且要把地砖全部砸开重做。
这三类,和架构评审要找的东西,完全对应:
- 不可逆的 = 架构里最难改的那个决定(Kevin 问的”哪个决定最难改”)
- 影响后续所有装修的 = 会限制未来所有需求的那个抽象(老陈说的”降低不可逆性”)
- 会影响邻居的 = 跨越了系统边界的副作用(大刘说的”半夜会不会把我叫醒”)
更深一层的盲区是:为什么大部分人签字就走了?
不是因为懒,是因为他们不知道”验”这个动作的目标是什么。他们以为验收是”检查有没有坏”,实际上是”检查有没有不满足我的需求”。
同一面墙,你不装东西,它就是合格的;你要在它上面挂一个 80 寸电视,它就不合格了。合格不合格,取决于你打算拿它做什么。
第三层:更深的一层是什么 这里有个特别残酷的细节:验房是有时间窗口的。
你签了字,之后墙面开裂,开发商会说”你验收的时候是好的”。这个窗口通常只有一两天。
也就是说:你有一个短暂的、可以低成本提出异议的窗口,过了这个窗口,所有的修正成本都由你自己承担。
架构评审,就是那个窗口。
上线前,你把方案拿出来,六个人帮你看,改一行字的成本是零。上线后,同样的问题,成本是两个月。
而现实中大量的团队是怎么做的?他们把评审当成”上线前必须走的一个流程”,赶在上线前一天开,文档当天发,会上大家碍于”明天就要发了”,谁也不好提结构性问题。
他们守着窗口,然后把窗关上了。
第四层:落到 Go 工程师身上 下次评审,做三件事:
一、文档至少提前 48 小时发,并且在开头写一句:“请重点看第 X 节,那是我最没把握的部分。” 这一句话能让有效评审意见的数量翻倍。
二、在文档里加一节,标题叫”如果这些假设错了”(不是”风险”,是”假设”)。 区别是:风险是你知道会发生的事,假设是你默认成立的事。后者更危险,因为你没有为它准备预案。
三、会后 24 小时内,发一份 Decision Log。格式极简:
## 2024-07-15 券系统 v2 架构评审 - 决定:三条业务线共用 coupon_instance 表,用 biz_type 区分 - 理由:三条线的券在可预见的半年内结构同构,拆表会引入跨表查询 - 我们放弃了:异构券的扩展性 - 触发重评的条件:出现第二种券形态(已写成 ASM-002 自检) - 反对意见:大刘担心 biz_type 会演变成 ext JSON 字段这个 Decision Log 的价值,在三个月后才会显现。那时候有人问”当初为什么这么设计”,你不用靠记忆。
更重要的是第二条和第三条——“我们放弃了什么”和”什么条件下要重新评估”。这两条,把一次性的评审,变成了一个持续的机制。
思考题二:你写了一篇文章,发给你信任的朋友看,请他提意见。他回了一句”挺好的,没什么意见”。你会相信吗?你通常会怎么做?
参考答案
第一层:大多数人会怎么答 “不太信。‘挺好的’要么是没认真看,要么是客气。我通常会追问一句:‘你觉得哪一段最没说服力?‘或者’有没有哪儿你读着卡住了?‘这样能问出真话。”
第二层:这个答案为什么不够 追问是对的,但这两个追问还不够好,因为它们给了对方一个”挑刺”的框架。
“哪一段最没说服力”——对方会想:“我也说不上来哪段没说服力,就是感觉整体还行。” 于是他还是说”都挺好”。
更好的问法是给对方一个具体的、低成本的入口。比如:
- “第三段那个例子,你觉得是真的吗?“(让他判断一个具体的事实)
- “如果你只记得这篇文章的一句话,你记得哪句?“(测试你的核心观点有没有传达出去)
- “有没有哪个地方,你读完需要回头再读一遍?“(测试表达,不测试观点)
最后一问最妙。它不问”好不好”,它问”卡不卡”。而”卡”是一个几乎所有人都能感觉到、但平时不会主动说的体验。
第三层:更深的一层是什么 这个技巧,在架构评审里对应的是:不要问”大家有什么意见”,要问具体的、有指向的问题。
“大家有什么意见”这句话,是评审会上效率最低的一句话。它把所有人的思考负担推给了他们自己:我得从头回忆一遍你的方案,找出问题,组织语言,然后在众人面前说出来。
大多数人不愿意付这个成本,于是沉默。
而如果你在文档里已经写好了三个具体的问题:
- “第 3 节的券模型,会员那条线的权益券可能装不进来,请重点看这里。”
- “第 5 节的核销流程,我假设了核销是同步的,如果是异步会有两个问题,我列在下面,但我拿不准哪个更严重。”
- “第 7 节的降级方案,我只写了 Redis 挂了的情况,MySQL 挂了的情况我还没想清楚。”
你把”找问题”的成本降到了接近零。对方只需要看你指的地方,然后说一句”我觉得第二个更严重,因为……”
这是 P7 在评审会上最重要的一个动作:替评审者降低提出意见的成本。
再深一层:“挺好的,没什么意见”这句话,往往不是因为你做得好,是因为你没有给别人”可以不同意”的入口。
一份面面俱到、无懈可击的文档,让人无法下嘴。它传达的潜台词是”我都想清楚了,你看有没有错”,而不是”我有些地方没想清楚,帮我看看”。
前一种文档会通过,后一种文档会产生价值。
第四层:落到 Go 工程师身上 一个可以立刻用的技巧,我叫它”三明治提问法”:
把你最没把握的部分,夹在两个具体选项之间,让对方做选择,而不是做开放回答。
比如不要问:
“券模型这块大家有什么想法?”
要问:
“券模型这块,我有两个选择。A 是三条业务线共用一张表,用 biz_type 区分,好处是查询简单,坏处是会员的权益券可能装不进来。B 是拆成’券’和’资格’两张表,中间加一层适配,好处是扩展性好,坏处是跨类型的查询要写两次。我个人倾向 A,因为会员那条线说是八月上线,我怕来不及。你们觉得呢?”
注意最后那句”我个人倾向 A,因为……”。这句话有三个作用: 一、表明你不是没主见,你只是想被挑战。 二、给出你的理由,让对方可以从”你的理由”下手(“你说的来不及,其实可以延一周”)。 三、给了对方一个明确的靶子。人反驳一个具体的观点,比凭空提出一个新观点,容易十倍。
最后一个提醒:如果你用了这个技巧,对方还是说”挺好的”,那你要注意了。 这可能意味着:这个人对这件事不关心,或者他觉得说了也没用。
这两种情况,都不是文档的问题,是关系的问题。而关系的问题,不在评审会上解决。
4.7 带小周的两天,比你干活的两小时更值钱
小周是七月初入职的,应届生,计算机科班,简历上写着一个”基于 Go 的分布式爬虫”。
组长把他分给了小林,原话是:“小林,小周跟着你,券系统那块的后台功能让他做。”
小林当时说了句”行”,心里想的是:正好,后台那堆 CRUD 我早就不想写了。
第一周,他给小周派了个活:券模板的后台管理接口,增删改查,外加一个列表页的分页查询。
小林估了一下:这套东西他自己写,两个小时。
他给小周讲的时候,讲了四十分钟。从表结构讲到接口定义,从 GORM 的用法讲到他们组的代码规范,最后还画了个小图说明请求是怎么从网关进来的。
小周全程在点头,笔记本上记得密密麻麻。
“有问题随时问我。“小林最后说。
小周说:“好的林哥。”
两天之后,小周把 PR 发出来了。
小林打开一看,第一眼的反应是:写得挺全。七个接口全有,还自己加了参数校验。
然后他开始 review。
第一个问题:CreateTemplate 里,事务开了,中间有个 error 分支直接 return err,没有 Rollback。
第二个问题:列表查询的分页,offset 和 limit 直接从 query 参数拿,没有上限。limit=100000 会查十万条。
第三个问题:所有的错误信息都是 errors.New("查询失败"),没有任何上下文。
第四个问题:UpdateTemplate 里,用了 context.Background(),而不是 handler 传进来的 ctx。
第五个……第六个……
小林数了数,三十七条评论。
他一条一条写完,点了”Request changes”,然后靠在椅子上,长出一口气。
那一刻他的感觉不是”我在培养新人”,是:“我自己写两小时,现在花了一个半小时 review,待会儿还得花时间看他改,改完还得再看一遍。这两天我什么都没干。”
他打开微信,给老陈发了条消息:“我现在特别理解为什么说’宁可自己干’。”
老陈回得很快:“晚上吃个饭?”
“你现在的时间不属于你了。”
老陈听完小林的抱怨,说了这么一句。
“什么意思?”
“你以前的时间是你自己的。你想写两小时就写两小时,想摸鱼就摸鱼,你唯一要负责的是你手上的活儿干完没有。”
“现在不是了。”
“现在你的时间里,有一部分是小周的。而且这部分只会越来越大,不会越来越小。”
小林说:“我知道这个道理。可是——”
“你不是在抱怨时间。“老陈打断他,“你是在抱怨投入产出比。”
“对。“小林说,“我自己写两小时,他写两天加我 review 两轮。这笔账怎么算都是亏的。”
“这笔账你算错了。“老陈说,“你算的是这一次。”
“那该算几次?”
“算三次。”
老陈伸出三根手指。
“第一次,他写,你 review。亏两天。”
“第二次,类似的需求,他写,你 review。打平。”
“第三次,他独立写完,你扫一眼就过。赚两天。”
“然后第四次往后,都是赚的。”
“问题是,“老陈看着他,“大部分人在第一次就受不了,把活儿抢回来了。”
小林没说话,因为他确实想抢回来。
“我给你讲个我干过的事。“老陈说,“2008 年,我带一个实习生,让他写一个订单导出功能。他写了三天,我 review 出来四十多个问题。”
“然后呢?”
“然后我说了句我这辈子最后悔的话之一。“老陈说,“我说:‘算了,这个我来吧,你先看看别的。’”
“然后我花了一个晚上重写了。”
“结果呢?”
“结果那个实习生,三个月之后转岗去了测试。“老陈说,“走之前他跟我说:‘陈哥,我觉得我可能不适合写代码。’”
茶室里安静了几秒。
“他后来怎么样了?”
“不知道。“老陈说,“但那句话我记了十六年。”
小林沉默了很久。
“老陈,我说实话,“他终于开口,“我不怕花时间。我怕的是……我不知道怎么教。”
“嗯,这句话比前面那些都值钱。”
“我给他讲的时候,我讲得挺清楚的。表结构、接口、规范,我都讲了。他也都点头了。结果写出来的东西,跟我讲的完全是两回事。”
“你讲完之后,他问过你问题吗?”
小林想了想:”……没有。”
“一个应届生,听了四十分钟,一个字都没问。“老陈说,“你觉得这是’听懂了’还是’没听懂但不敢问’?”
”……”
“我问你个更关键的:“老陈说,“你那四十分钟,讲的是’他会遇到什么问题’,还是’你要他做什么’?”
小林愣住了。
“我讲的是……我要他做什么。表结构长这样,接口这么定义,规范是这几条。”
“所以你给的是答案,不是问题。”
“什么意思?”
“你告诉他’要用传入的 ctx’,这是答案。”
“那问题是什么?”
老陈把茶杯放下。
“问题是:‘如果用户在请求发出去之后关掉了浏览器,你这段代码会怎么样?’”
小林愣在那儿。
“他如果自己想明白了这个问题的答案,他这辈子都不会在 handler 里用 context.Background()。”
“而你告诉他’要用 ctx’,他记住的是一个规则。规则会忘,理解不会。”
小林回去试了一次。
第二周,他给小周派了个新活:券的批量导入功能,运营上传一个 Excel,系统解析后批量创建券。
这次他只讲了十分钟,讲了业务背景和验收标准,最后问了一句:
“你觉得这个功能,什么地方最容易出问题?”
小周愣了一下,说:”……文件太大?”
“多大算大?”
”……一万行?”
“一万行的 Excel,解析出来大概多大内存?”
”……我不知道。”
“去查一下,明天告诉我。”
第二天,小周来了,说了一个数:“一个 1 万行的 Excel,每行 10 个字段,解析成结构体大概 15 到 20MB。如果同时有 10 个人上传,就是 200MB。”
“然后呢?”
“然后……如果不做限制,内存会爆。”
“所以?”
“所以要限制文件大小和行数,而且解析要流式处理,不能一次性读进内存。”
小林当时心里”咯噔”一下。他原本准备告诉小周的就是这两条。
而他花了两天时间想要嘴里说出来的东西,小周自己想了二十分钟就出来了。
“你那份设计,我看了。“小林说,“有个地方我想问问你。”
“您说。”
“你那个解析,用的是哪个库?”
“excelize。”
“为什么?”
“我对比了一下,excelize 支持流式读取(OpenReader + Rows()),tealeg 那个 xlsx 库要一次性把整个文件读进内存。”
小林没听说过 Rows() 这个 API。他去查了一下,确实有。
那天晚上,小林在工位上坐了很久。
“我以前以为带人是’我会,他不会,所以我教他’。“小林跟老陈说,“今天我发现,我说的那些东西,他查一查都能找到。”
“嗯。”
“那我能给他什么?”
“你想想,他今天给你的那个答案——‘要流式处理,要限制大小’——他是从哪儿想出来的?”
”……从我问的那个问题?”
“你问的是’什么地方最容易出问题’。“老陈说,“你给的不是知识,是一个他本来不会去想的方向。”
“他会用 excelize 吗?他会。他会查文档吗?他会。他缺的是:在做一件事之前,先问’这件事会在哪儿失败’这个习惯。”
“这个习惯,你没法用文档教,也没法用一句话教。你只能用一次次地问,把它问出来。”
小林在纸上写:带人 = 给方向,不给答案。
“还有一层。“老陈说,“你今天最大的收获,不是小周学会了什么。”
“是什么?”
“是你发现他用了 Rows()。“老陈说,“你带的第一新人,反过来教了你一个你不知道的 API。”
“这件事你接受得了吗?”
小林笑了:“说实话,有点别扭。”
“为什么别扭?”
“因为……我是他师傅啊。”
“师傅就必须什么都比徒弟强?“老陈说,“那师傅这活儿也太难干了。”
“我给你一个 P7 的定义,你听听:”
“P7 的产出,不是你写的代码,是你的代码加上’你让别人多写出来的代码’,再减去’你因为带人而少写的代码’。”
“这个公式如果为负,你就不该带人。如果为正,你就该带更多。”
“那怎么让它为正?”
“两个办法。一是提高’让别人多写出来的代码’,二是降低’你为此付出的时间’。”
“第一个靠问问题,第二个靠——“老陈顿了一下,“把你说的每一句口头规范,变成不需要你重复第二遍的东西。“
生活里的同一件事:教我爸用导航,教了三次才教对
小林是 2021 年教他爸用手机导航的。
他爸那年五十七,开了二十年车,一直是”看地图 + 问路”派。那年家里换车,车上装了个 CarPlay,小林觉得应该教会他用高德。
第一次教,是在老家,年前。
小林拿过他爸的手机,打开高德,输入目的地,点”开始导航”,然后把手机往支架上一放,说:“你看,这样就出来了。到了会说话,你听它说就行。”
他爸说:“哦。”
那次之后,他爸总共用过三次导航,每次都是让小林或者他妈帮他设好,然后他开。
第一次教,教的是”结果”。 小林把事情做完了,他爸看到的是一个”已经工作的导航”。
第二次教,是第二年五一,小林回老家。
这次他没拿手机,他站在旁边说:“爸,你点那个蓝色的图标。” “然后点上面那个搜索框。” “输你要去的地方。” “点第一个。” “点右边那个’导航’。”
他爸一步一步跟着做,用了大概四分钟,设好了。
小林问:“记住了吗?”
他爸说:“记住了。”
两个月后小林打电话问,他爸说:“那个我忘了,还得让你妈弄。”
第二次教,教的是”步骤”。 步骤比结果强,但步骤有个致命弱点:步骤是线性的,一旦中间断了就接不上。
他爸第三次用的时候,输错了字,出来一堆结果,他不知道该点哪个,也不敢乱点,于是放弃了。
第三次教,是那年国庆。
这次小林换了办法。
他把车停在路边,把手机递给他爸,说:“你自己设一下,去火车站。”
他爸开始点。点错了两次,点到了一个不相干的地方,导航开始播报”前方右转”。
小林坐在副驾上,手心出汗,但忍住没动。
他爸慌了,说:“这个不对。”
小林说:“那你把它退出来,重新设。”
“怎么退?”
“你找找。”
他爸找了大概二十秒,找到了左下角那个”退出”按钮。退出来,重新输,这次输对了,但出来的第一个结果是个加油站。
“这个不对啊。”
小林说:“那你看看下面几个。”
他爸往下翻了翻,找到了”XX火车站-南广场”。
那次之后,他爸的导航就再也没让别人设过。
这三次教法,正好对应带人的三个层次。
第一层:给结果。
“这个我来做吧,你看着。”
最快,最省事,也最没用。它的隐含信息是:“这个东西太难了,你做不了。”
小林对他爸做过一次,对那个实习生老陈做过一次。
第二层:给步骤。
“你点这个,再点那个。”
这一层是大多数人停留在的地方。它能完成任务,但它有个隐藏的成本:每一次对方卡住,都必须回来找你。
你成了他的”外部存储器”,也成了他的单点故障。
你有没有过这种感觉:团队里某个人,每次遇到同类问题都来问你,问了十次,第十一次还来问。你烦,他还委屈——“我确实不会啊”。
原因就在第二层:你教的是步骤,他记的是步骤。步骤在他脑子里是脆弱的,一断就全断。
第三层:给问题,让他自己在出错时找回来。
小林第三次做的,是这一层。他做的动作只有两个:
其一,把操作权交出去(“你自己设一下”)。 其二,在出错的时候忍住不伸手(“你找找” / “那你看看下面几个”)。
这两个动作里,第二个难一万倍。
因为”忍住”意味着你要看着他走弯路,看着他点错,看着他花二十秒找一个你一眼就能看到的东西。你脑子里那个”两分钟就能搞定的事”的计时器,一直在响。
而恰恰是这二十秒的”自己找”,把”步骤”变成了”能力”。
他找到那个”退出”按钮的那一刻,他学到的是:“我点错了,但我能退回来。”
这个认知,比”先点蓝色图标再点搜索框”重要一百倍。因为它不是一条知识,是一个元能力——它让他相信,自己搞得定。
还有第二个细节,我觉得更值得琢磨。
小林第三次教的时候,发现了一件之前没注意的事:
他爸认图标不认颜色。
小林一直说”点那个蓝色的图标”。而他爸记的是”那个像个小飞机的”。(高德的图标在老版本上确实有点像。)
而小林说”点右边那个’导航‘“的时候,他爸找的是”那个绿色的、会动的按钮”。
你给别人的指令,是用你的坐标系,还是他的坐标系?
这是一个在带人时被严重低估的问题。
小林后来给小周讲东西的时候,开始注意这个。他发现自己以前说的很多话,都是”自己的坐标系”:
- “这个接口要幂等” —— 小周脑子里的画面是什么?
- “这里要注意并发” —— 他知道”并发”指的是哪种并发吗?
- “用 DDD 的思路想一下” —— 一个应届生,对”领域”这个词有体感吗?
他换了个说法之后,效果立刻不一样:
- “这个接口,如果用户手抖点了两下,会发几张券?”
- “如果六个实例同时收到同一个请求,会怎样?”
- “如果运营说’我要加一种新券’,你希望改几个地方?”
这三句话,和前面那三句,说的是同一件事。区别是:前面三句是抽象名词,后面三句是具体场景。
抽象名词要求对方”先理解这个概念再应用”,具体场景允许对方”从场景里自己长出概念”。
应届生缺的从来不是概念。他们科班四年,概念背得比谁都熟。他们缺的是概念和经验的连接点。
而你给一个具体场景,就是在帮他接这根线。
Go 实例:把”我口头说的规范”变成 CI 里会红的检查
小林算了一笔账:他给小周的第一份 review,三十七条评论里,有十四条是重复性的问题类型。
而那十四条里,他自己这两年在组里,至少说过三十遍。
他决定换一种做法。
第一步:先看看”口头规范”是什么样子(这是 v1,也是 99% 团队的状态)
package main
import (
"context"
"net/http"
"time"
)
// TemplateHandler 券模板的后台 handler。
// 下面这段代码里,藏着四条"小林每次 review 都要说一遍"的问题。
type TemplateHandler struct {
svc *TemplateService
}
// List 查询模板列表。
func (h *TemplateHandler) List(w http.ResponseWriter, r *http.Request) {
// 问题 1:用了 context.Background(),请求的超时/取消信号在这里断了。
// 小林第 4 条 review 意见。
ctx := context.Background()
// 问题 2:没有给 list 设上限。limit=100000 的时候会查十万条。
// 小林第 11 条 review 意见。
limit := 0
offset := 0
_ = r.URL.Query().Get("limit")
_ = r.URL.Query().Get("offset")
list, err := h.svc.List(ctx, offset, limit)
if err != nil {
// 问题 3:错误信息没有任何上下文,查日志的时候不知道是哪个请求。
// 小林第 19 条 review 意见。
http.Error(w, "查询失败", 500)
return
}
_ = list
// 问题 4:没有设置写超时,慢客户端会拖住连接。
// 小林第 27 条 review 意见。
_ = time.Now
}
type TemplateService struct{}
func (s *TemplateService) List(ctx context.Context, offset, limit int) ([]string, error) {
return nil, nil
}
func main() {}这四条,每一条都是对的,每一条小林都会在 review 里说。
问题在于:它们只在小林的脑子里,以及在那一次 PR 的评论区里。
下一个 PR,他还得再说一遍。下一个新人,他还得再说一遍。他自己写代码的时候,忙起来也会犯。
第二步:写成文档(v2)
小林写了一份 CONTRIBUTING.md,列了十八条规范。第一条就是”禁止在 handler 里使用 context.Background()”。
这份文档的效果如何?
他做了个统计:文档写完之后的两个月里,新 PR 里”handler 里用 context.Background()“这个问题,出现的次数是——
六次。
和之前基本持平。
原因很简单:没有人会在提交代码之前,把一份十八条规范从头读一遍。
(顺带说一句:小林后来发现,这份文档唯一的读者,是写它的他自己。)
第三步:变成会红的东西(v3)
小林让小周去做这件事。
对,就是那个被他 review 出三十七条问题的小周。
他给小周的要求是:写一个 Go 的静态检查工具,能在 CI 里自动发现”handler 里用了 context.Background()”。
小周花了四天。中间来问过小林三次,小林每次都没直接给答案,只问:“你这个分析器,现在是遍历 AST 的哪个节点?”
下面是最终的版本,用的是 Go 官方的 golang.org/x/tools/go/analysis 框架,这是个完整可运行的例子:
// 文件:cmd/noctxbackground/main.go
// 安装依赖:go get golang.org/x/tools/go/analysis/singlechecker
// 运行:go run ./cmd/noctxbackground ./...
package main
import (
"go/ast"
"go/types"
"strings"
"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/singlechecker"
)
// Analyzer 检查:在 HTTP/RPC handler 函数体内,
// 是否使用了 context.Background() 或 context.TODO()。
//
// 为什么要查这个:handler 的入参里通常带着一个 ctx(r.Context() 或框架传入的),
// 那个 ctx 绑定了客户端的取消信号和服务端的超时。
// 在 handler 内部另起一个 Background(),等于把这个链路掐断了:
// 客户端早就关了页面,你的 goroutine 还在跑,DB 连接还在占着。
var Analyzer = &analysis.Analyzer{
Name: "noctxbackground",
Doc: "禁止在 handler 函数内使用 context.Background()/TODO(),应使用传入的 ctx",
Run: run,
}
func init() {
// 依赖 types 信息,才能判断一个调用是不是 context.Background
Analyzer.Requires = []*analysis.Analyzer{}
}
// isHandlerFunc 判断一个函数声明是否"看起来像 handler"。
// 判据:参数里含 *http.Request,或者第一个参数是 context.Context 且返回 error。
func isHandlerFunc(fn *ast.FuncDecl) bool {
for _, p := range fn.Type.Params.List {
switch t := p.Type.(type) {
case *ast.StarExpr:
if sel, ok := t.X.(*ast.SelectorExpr); ok {
if sel.Sel.Name == "Request" {
return true
}
}
}
}
// RPC 风格:func(ctx context.Context, req *XxxReq) (*XxxResp, error)
if len(fn.Type.Params.List) >= 2 {
if sel, ok := fn.Type.Params.List[0].Type.(*ast.SelectorExpr); ok {
if sel.Sel.Name == "Context" {
return true
}
}
}
return false
}
func run(pass *analysis.Pass) (interface{}, error) {
// 找到 context 包在本文件中的别名(有人会 import ctx "context")
contextAliases := map[string]bool{"context": true}
for _, f := range pass.Files {
for _, imp := range f.Imports {
path := strings.Trim(imp.Path.Value, `"`)
if path != "context" {
continue
}
if imp.Name != nil {
contextAliases[imp.Name.Name] = true
}
}
}
for _, f := range pass.Files {
for _, decl := range f.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Body == nil {
continue
}
if !isHandlerFunc(fn) {
continue
}
ast.Inspect(fn.Body, func(n ast.Node) bool {
call, ok := n.(*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 !contextAliases[ident.Name] {
return true
}
if sel.Sel.Name != "Background" && sel.Sel.Name != "TODO" {
return true
}
// 确认它真的是 context 包里的(不是某个同名的局部变量)
if obj, ok := pass.TypesInfo.Uses[ident]; ok {
if pkg, ok := obj.(*types.PkgName); ok {
if pkg.Imported().Path() != "context" {
return true
}
}
}
pass.Reportf(call.Pos(),
"handler %s 内不应使用 context.%s();"+
"这会切断请求的取消/超时链路,请改用传入的 ctx",
fn.Name.Name, sel.Sel.Name)
return true
})
}
}
return nil, nil
}
func main() {
singlechecker.Main(Analyzer)
}跑一下:
$ go run ./cmd/noctxbackground ./...
handler/template.go:23:9: handler List 内不应使用 context.Background();这会切断请求的取消/超时链路,请改用传入的 ctx
然后接到 CI 里:
# .github/workflows/lint.yml(节选)
- name: 自定义静态检查
run: |
go run ./cmd/noctxbackground ./...
if [ $? -ne 0 ]; then
echo "静态检查未通过,请修复后再提交"
exit 1
fi这套东西上线之后的效果,小林统计过:
context.Background() 在 handler 里出现的次数,从每月六次,降到零。
但真正让他意外的,不是这个数字。
第一件意外:小周写的那个分析器,发现了一个小林自己写的代码里的问题。
在 coupon 包的 internal/handler/settle.go 里,有一个两个月前小林自己写的接口,里面用了 context.TODO()。
那是他赶需求的时候随手写的,写完忘了改。
分析器把它抓出来了。
小林被自己布置的作业批改了一次。
第二件意外:小周写完之后,把这套东西扩展了。
他又加了三条规则:
- 检查
http.Error的错误信息里有没有带上err(对应”问题 3”)。 - 检查 SQL 查询有没有
LIMIT且LIMIT有没有上限(对应”问题 2”)。 - 检查
defer resp.Body.Close()有没有写。
这三条小林没有要求他做。他是自己加的。
第三件意外,是最大的:小周把这套分析器写完之后,成了组里的”规范负责人”。
别的组的人来问:“你们那个检查是怎么做的?我们也想搞。”
小周在周会上讲了二十分钟。
小林坐在下面,看着他讲,想起两个月前那个被自己 review 出三十七条问题的应届生。
这里有个原则,值得单独拎出来:
知识沉淀有三个层次:口头 → 文档 → 可执行。
- 口头:成本是一次性的,说完就没了。只对当时在场的人有效。
- 文档:成本是持续的(要维护),只在有人主动读的时候有效。
- 可执行:成本高一次,收益永久。它不需要人有主动性,它会自己找上门。
绝大多数团队的规范,停留在第一层。少数到了第二层。极少数到了第三层。
而判断一个团队工程水平的高低,看的就是这个比例。
还有第二个原则,是关于带人的:
让被带的人去写那个”可执行”的东西,是带人的最高杠杆动作。
为什么?三重的收益:
一、他写一遍,把这条规范从”师傅说的”变成了”我实现的”,这辈子忘不掉。 二、团队永久获得了一条自动化检查,不再依赖某个人记不记得。 三、他把”规范”这件事的所有权,从你身上拿走了。 他不再是”遵守规范的人”,他是”定义规范的人”。
第三点,才是带人的本质。
小林后来跟老陈说这件事的时候,老陈说了句:
“你现在明白了?你带小周,不是为了让他帮你干活。是为了有一天,他不需要你。”
“那我图什么?”
“图的是:他不需要你之后,你才能去干下一件事。“
老陈的深层发问
-
你上一次把一件”自己两小时能干完”的事交给别人,是什么时候?那之后,你有没有过”早知道我自己来”的念头?
-
你带过的人里面,有没有哪一个人,学会了某样你不会的东西?如果有,你当时的第一反应是高兴,还是别的?
-
如果你明天开始休一个月假,你手上的活儿,有几件是有人能接住的?那几件,是你”教会了他做”,还是”他看着你做过”?
-
你团队里的规范,有几条是”新人来了会主动知道的”?有几条是”只有老人才知道,新人踩了坑才知道”?后者,你打算什么时候补?
-
你的产出,如果按”你的代码 + 你让别人多写出来的代码 - 你带人花掉的时间”这个公式算,是正的还是负的?你有多久没算过这笔账了?
生活思考题
思考题一:你做饭的时候,有人站在旁边看,说”我觉得该放盐了""这个火候是不是过了""要不要加点水”。你什么感觉?你会怎么做?
参考答案
第一层:大多数人会怎么答 “烦。特别烦。我知道该放什么,你一说我就乱了。而且你要是真想帮忙,你直接上手啊,站在旁边指挥算什么。我一般会说’你别管,我自己来’,或者干脆把他请出厨房。”
第二层:这个答案为什么不够 “把他请出厨房”是一个有效的短期方案,但它关闭了一个长期的通道:那个人从此永远不会做饭了。
我见过太多这样的家庭:一个人在厨房里忙了二十年,另一个人连燃气灶怎么开都不知道。这不是分工,这是能力垄断。
更深的盲区在于:你为什么烦?
表面上是”他打断了我的节奏”。实际上是——“他的提问,暴露了我自己也没想清楚的部分”。
“这个火候是不是过了”——你真的确定吗?你炒菜的时候,“火候”这件事,你是有明确的判断标准,还是凭感觉?
如果凭感觉,那他的提问让你难受,是因为你没法把一个”凭感觉”的东西讲清楚。而”讲不清楚”这件事,让你觉得自己不够专业。
第三层:更深的一层是什么 这就是”教”真正的难点,也是带人这件事最深的一层:
你必须先把自己”凭感觉”的东西,拆成可以表达的东西。
我认识一个做了十五年菜的人,他教徒弟的方式是这样的。他不站在旁边说,他让徒弟炒,自己坐在餐桌那儿,等徒弟端上来,尝一口,说一句:
“这个菜,咸了。”
徒弟问:“那我该放多少盐?”
他说:“你先告诉我,你刚才放了多少?”
徒弟说不知道,手抓的。
他说:“那你下次用勺子,先放一勺,尝一下,不够再加。加几次之后,你就知道一勺是多少了。”
这个教法里有两个关键点:
一、他不给绝对值(“放 3 克盐”),他给一个可校准的动作(“先放一勺,尝一下”)。 二、他不做预防性指导,他做事后反馈。 菜端上来,尝了,再说。
这两点,对应到带人上是:
一、你给新人的,不应该是”这个接口要设 3 秒超时”(绝对值),而是”超时时间先按 P99 的两倍设,然后看监控调”(可校准的动作)。 二、你不要在他写代码的时候一句一句指挥(那会让他失去判断力)。让他写完,再 review。
而”请出厨房”这个做法,对应的是:新人刚犯了个错,你把活儿抢回来自己干了。
第四层:落到 Go 工程师身上 三件可以马上做的事:
一、把你 review 时出现频率最高的三条意见,写下来,看看它们是不是”凭感觉”的。 比如”这个错误处理不好”是凭感觉的;“这个 error 应该 wrap 上 userID,不然查日志定位不到”是具体的。 把凭感觉的那几条,改成具体的。改不了,说明你自己也没想清楚——那就先想清楚。
二、下一次交代任务,试试”事后反馈”而不是”事前指导”。 只讲三件事:背景、验收标准、你觉得最容易出问题的地方(问句形式)。然后走开。 记录下来:他做完之后,你 review 出来的问题,和你事前”警告”他的问题,重合度有多高? 我猜你会发现:重合度很低。你事前担心的地方他做对了,你没想到的地方他做错了。这个发现,会让你的”事前指导”变得精准。
三、找一个你”凭感觉”在做的任务,让新人来做,并且告诉他你没有标准答案。 然后观察:他会问你什么问题。 那些问题,就是你”凭感觉”的部分。把它们一条条变成可以说出来的话。
这个练习的最终受益者不是他,是你。一个能把自己的直觉讲清楚的人,才配做设计。
思考题二:你打游戏(或者打球、下棋),带一个新手。你会怎么带?是让他自己在旁边看你打几局,还是让他上场?如果让他上场,他一直输,你会怎么办?
参考答案
第一层:大多数人会怎么答 “肯定让他上场,光看学不会。他一直输的话,我会降低难度——比如我让他几个子,或者我选个弱一点的角色,或者我打得不那么认真。等他赢几次,有信心了,再上强度。”
第二层:这个答案为什么不够 “让他赢几次”这个策略,是善意的,但它有一个隐藏的问题:
他赢的那几次,是他真的变强了,还是你放水了?他分得清吗?
大部分新手是分不清的。你放水放得越像,他越以为自己行了。然后他自己去打排位,连续输十把,信心崩得比从来没赢过还彻底。
真正好的带法,不是”让他赢”,是**“让他知道自己为什么输,并且相信下一次能改”**。
更深一层的盲区是:你在”降低难度”,这是在改变环境,不是在改变他。
这两者的区别,在生活中随处可见:
- 替他写代码 = 你上场替他打,他坐在旁边看。
- 帮他把难的部分写了,留简单的给他 = 你选个弱角色。
- 反复给他讲,讲到你觉得他听懂了 = 你在语音里一直报点,他不用自己看小地图。
这三种,都没有让他变强。它们只是让”这一次”顺利结束了。
第三层:更深的一层是什么 我有个朋友是羽毛球教练,他跟我说过一个训练方法,我觉得是最好的带人模型。
他带新手的时候,不让新手打比赛。他做一件事:喂球。
他把球一个一个地喂到同一个位置,同一个高度,同一个速度。新手只需要做一件事:把这个球打回去。重复两百个。
然后他换一个位置,再喂两百个。
这个过程极其枯燥,但它解决了一个新手最根本的问题:他不是”不会打球”,他是”在球飞过来的一瞬间,脑子里有太多东西要想”——脚步往哪儿动、拍子怎么挥、手腕要不要发力、该打直线还是斜线。
喂球把变量降到只剩一个。让他把”挥拍”这一件事练成肌肉记忆。
等挥拍不用想了,他才有余力去想”往哪儿打”。
这才是”带人”的技术含量所在:不是把事情讲清楚,是把变量一个个地降下来。
回到工程师带新人:你给小周派”券模板 CRUD”这个活的时候,他脑子里同时在转的有多少个变量?
Go 语法、GORM 的用法、你们组的代码分层、HTTP 框架的用法、错误处理规范、日志规范、事务怎么处理、分页怎么做、参数校验放哪儿、单元测试怎么写……
十个变量。他当然写出三十七个问题。
而如果你把变量降下来:
- 第一次:只让他改一个已有接口的一个字段(一个变量:字段在哪儿改)。
- 第二次:让他照着一个已有接口,仿写一个新的(两个变量:结构 + 业务逻辑)。
- 第三次:让他自己设计一个小接口(回到十个变量)。
前两次的时候,他会觉得”这也没什么难的”。这就是喂球的作用——它让他在低变量环境里,把基础动作练成不需要想的东西。
第四层:落到 Go 工程师身上 给你带的人设计三次任务,遵循一个原则:每次只增加一个变量。
一个具体的模板:
任务一(1 个变量):给一个已有的 handler 加一个查询参数,并在 service 里用上它。 目标不是”他会加参数”,是”他熟悉一遍从 handler 到 DB 的完整链路,并且跑通了本地环境和测试”。
任务二(2 个变量):照着一个已有的”券模板”模块,写一个结构完全一样的”券活动”模块。 目标:他知道”一个新模块长什么样”,并且开始注意到里面的模式(哪些是模板代码,哪些是业务逻辑)。
任务三(1 个新变量):给券模块加一个”批量导入”功能,这个功能在现有代码里没有对应的模式。 目标:他第一次需要自己做判断。这时候你再问那个问题:“你觉得这个功能,什么地方最容易出问题?”
注意第三次任务的设计:它是”模式之外”的,不是”模式之内”的。 因为只有模式之外,才需要判断力。
前两次是喂球,第三次是比赛。而在第三次之前,他已经有两百次挥拍的肌肉记忆了。
最后一个提醒,也是这个思考题里最反直觉的一点:
你花在”喂球”上的时间,看起来是浪费的。 因为你明明可以直接让他做第三次任务,然后你 review 三十七条。
但算总账:喂球两次(每次你花 30 分钟)+ 第三次 review 五条 = 大约 5 小时。 直接上第三次 + review 三十七条 + 看他改 + 再看一遍 = 两天。
喂球不是慢,喂球是快。 它只是看起来慢,因为前两次”产出很少”。
而这恰恰是 P7 要克服的那个心理障碍:你要能忍受一段时间里,你的团队”看起来”产出很低。
4.8 过度设计:你是在解决问题,还是在展示你会什么
九月,券系统 v2 上线两个月,跑得还行。
小林在这两个月里做了件”自己的事”。
他重构了核销模块。原来的核销逻辑是三年前的人写的,一个函数三百二十行,里面一堆 if。他花了两周,重构成了一个”券核销规则引擎”。
重构后的结构是这样的:
Rule接口,三个方法:Match、Calculate、Describe。- 五种规则实现:
FullReduceRule(满减)、DiscountRule(折扣)、NoThresholdRule(无门槛)、MemberRule(会员价)、FreeShippingRule(免运费)。 - 一个
RuleRegistry,用init()自动注册,支持通过配置文件动态开关。 - 一个
RuleChain,支持规则的组合、AND/OR、优先级排序。 - 一个
RuleContext,在规则之间传递中间结果。 - 一套 DSL,允许运营在后台写
full_reduce(min=9900, amt=3000) AND member(level>=2)。
他写得确实漂亮。接口清晰,每个实现都在 50 行以内,单元测试覆盖率 89%。
他把设计文档发到组里,Kevin 回了个”设计得不错”。
那两周,小林是组里最晚走的。
十月下旬,老陈约他吃饭。
“最近在忙什么?”
小林讲了那个规则引擎,讲了十分钟。
老陈听完,问了一句:
“现在线上配了几种规则?”
”……一种。”
“哪种?”
“满减。”
老陈放下筷子。
“你花了两周,做了一个支持五种规则、支持任意组合、支持运营写 DSL 的引擎。线上用了几种?”
“一种。”
“那四种,谁说要做的?”
”……没人说。”
“那 DSL 呢?运营用过吗?”
”……还没有,我还没给他们培训。”
老陈沉默了几秒。
“小林,我问你一句话,你别急着反驳。”
“你说。”
“你是在解决问题,还是在展示你会什么?”
小林的脸一下就热了。
“我不是……”他停住了。他发现自己接不下去。
“我知道你不是故意的。“老陈说,“这正是它难的地方。没有人会’故意’过度设计。过度设计从来不是动机问题,是判断力问题。”
“可是,“小林找回了一点声音,“万一以后要加折扣券呢?我这个架构,加一种规则只要写一个文件。”
“以后是多久?”
”……不知道,可能半年,可能一年。”
“谁说要加?”
”……”
“王总说过吗?阿 May 说过吗?财务说过吗?”
”……没有。”
“那我问你第四个问题。“老陈说,“如果半年后真的要加折扣券,用你那个引擎,加一个文件,一天。用原来那三百二十行的函数,加一个 if,两小时。”
”……”
“差六个小时。”
“而你为这六个小时,预付了两周,加上这套引擎的长期维护成本——每个新人都要理解这套抽象,每次排查问题都要在五层调用里跳来跳去,每次改一个规则的行为都要考虑它对”组合”的影响。”
“这笔账,你自己算。”
小林坐在那儿,一句话也说不出来。
不是因为老陈说得不对,是因为他心里有一部分早就知道这笔账不对,他一直没敢算。
“我不该说这么重。“老陈给他倒了杯茶,“我给你讲个我自己的事,比你这个严重十倍。”
“2010 年,我在一家做企业软件的公司。我们有一类需求叫’审批流’——报销审批、请假审批、采购审批,每种流程的审批节点都不一样。”
“我做了一个通用的工作流引擎。”
“做到什么程度?“老陈说,“支持图形化配置流程图,支持条件分支、并行分支、会签、或签、加签、转办、委托、超时自动通过、超时自动驳回、流程版本管理、运行中动态修改流程。”
“技术上是真的完整。我还发过一篇技术博客,讲那个引擎的状态机设计,在当时的圈子里还挺火。”
“用了多久?”
“两年半。“老陈说,“服务了十一个客户的二十多个审批流。”
“听上去挺成功的。”
“是听起来。“老陈说,“2013 年我离开那家公司。走之前我跟接手的同事做交接,交接的时候我跟他说:‘这个引擎你小心点,有几个地方比较绕。’”
“他说了句什么,我到现在都记得。”
“什么?”
“他说:‘陈哥,我看了两周。我现在知道它怎么跑了,但我不知道它为什么这么跑。’”
老陈喝了口茶。
“2015 年我回去看过一次。那个引擎还在,但已经没人敢动了。客户提的新需求,他们的做法是:在引擎外面套一层 if-else 绕过它。”
“2017 年,那家公司把它下线了。替代它的,是一个八百行的状态机加一堆配置。”
“八百行?”
“八百行。“老陈说,“写它的那个小孩,工作三年。他用了三周。”
“我后来想明白了一件事。“老陈说。
“我做那个引擎的时候,我心里真正在想的,不是’客户需要什么’。”
“那是什么?”
“是**‘我要做一个配得上我这个级别的东西’**。”
小林抬头看着他。
“那时候我刚升技术负责人一年,特别需要证明自己’不只是写代码的’。一个通用的工作流引擎,能证明我有抽象能力、有设计能力、有架构视野。而一个八百行的状态机,证明不了任何东西。”
“这是我三十五岁才想明白的:我的设计里,有相当大一部分,是为了让评审者、让同行、让未来的面试官觉得我厉害。”
“这个动机,从来没有人会承认。但它几乎存在于每一个过度设计的背后。”
小林沉默了很久。
“老陈,我说句实话。“他说,“我这半年,一直觉得自己卡住了。去年评 P7 没过,评审意见是’缺乏定义问题和推动落地的能力’。我当时不服气,我觉得我技术没问题。”
“嗯。”
“现在我有点明白了。“小林说,“我做的那些’技术没问题’的东西——42 页的设计文档、规则引擎、Kafka、分库分表——它们有一个共同点。”
“什么?”
“它们是能被人看见的。”
老陈看着他,没说话。
“你画一张架构图,有人会说’这个画得专业’。你做一个规则引擎,有人会说’这个设计得好’。可是你把 42 页删到 9 页,谁会夸你?”
“没人。“老陈说。
“对,没人。“小林说,“所以我的潜意识在驱使我做’能被看见的复杂度’。因为那是我唯一知道的、证明自己的方式。”
老陈点点头。
“你现在回答得了这个,说明你到那一步了。”
“那我该怎么判断?“小林问,“怎么知道自己是’必要的设计’还是’过度设计’?”
“我给你三个判据,都不复杂。”
“第一条,叫’三次原则’。”
“同样的东西,你见到第三次之前,不要抽象。”
“第一次见到,你写具体的代码。第二次见到,你复制一遍,心里记一笔’这里重复了’。第三次见到,你再抽。”
“为什么是三次?因为两次的重复,你可能抽错方向。两个相似的东西,抽象出来的那个’共性’,很可能是假的共性。等到第三个出现,你才知道那个共性是真的。”
“第二条,叫’最后一刻责任’。”
“这个词的英文是 last responsible moment。意思是:一个决定,能推迟就推迟,但不能推迟到’推迟本身带来更大代价’的那一刻之后。”
“举个例子。你要不要在券系统里引入消息队列?”
“现在的量,不需要。那什么时候需要?当异步化带来的价值,大于维护 MQ 的成本的时候。”
“所以你现在该做的,不是’现在就引 MQ’,也不是’永远不引 MQ’,而是:把代码组织成’引 MQ 的时候改动最小’的样子,然后等到真正需要的那一天再引。”
“这就是 4.3 节那个 StockStore 接口做的事。它不是’过度设计’,因为它的成本是一个接口加一个契约测试,可能两小时。而它的收益是:把一个可能需要两周的决策,推迟到了信息更充分的时候。”
“区分’过度设计’和’必要的准备’,看的是成本。 两小时的准备,推迟一个两周的决策,值。两周的准备,推迟一个两小时的工作,不值。”
“第三条,也是最重要的一条,只有一句话。”
“问自己一个问题:如果我把这个设计删掉,换成最土的办法,会损失什么?”
“如果你能说出具体的损失——‘损失了每秒 3000 的吞吐’、‘损失了三天的开发时间’——那它就不是过度设计。”
“如果你说不出来,只能说’以后扩展方便’、‘结构更清晰’、‘更优雅’——那它就是。”
小林回去之后,做了一件事。
他把那个规则引擎,从 2400 行,删到了 380 行。
删掉了 RuleRegistry(用不上,五种规则是编译期确定的,不需要运行时注册)。
删掉了 RuleChain 的组合逻辑(AND/OR 一次都没配过)。
删掉了那套 DSL(运营根本不知道它的存在)。
删掉了 RuleContext(三个规则之间没有任何需要传递的中间结果)。
留下的东西是:
一个 Discount 函数,一个 switch,五个小函数。
三个月后,业务方真的要加折扣券了。
小周去加的。他加了一个 discount.go,写了一个函数,在 switch 里加了一行。
用时四十分钟。
生活里的同一件事:那面挂满工具的工具墙
小林有个同事,叫阿哲,做前端的,但是在公司附近租了个带院子的房子,特别爱动手。
2022 年,阿哲在院子里的墙上装了一面工具墙。
就是那种洞洞板(pegboard),一格一格的,所有工具挂上去,用马克笔在板上画出每个工具的轮廓。扳手区、螺丝刀区、钳子区、电动工具区,分门别类,整整齐齐。
那面墙一点二米宽,两米高。上面挂了:
- 六把不同尺寸的扳手(其中两把是英制,他从美国带回来的)
- 一套十二支的螺丝刀(十字、一字、梅花、六角,各种尺寸)
- 五把钳子(尖嘴、斜口、水泵、鲤鱼、压线)
- 一把电钻 + 一个 87 件的配件套装(能钻、能磨、能锯、能抛光、能打磨)
- 一台角磨机
- 一把电锤
- 三把锯(手锯、钢锯、曲线锯)
- 水平尺、卷尺、角尺、游标卡尺
- 一个看起来很专业的工具箱,带三个抽屉
阿哲拍照发朋友圈的时候,底下评论全是”专业""这是要开修理厂吗""我家装修能不能借一下”。
小林当时也点了个赞,还评论了一句”太帅了”。
去年夏天,小林去阿哲家吃饭,在院子里坐着。
他抬头看那面墙,数了数,一共六十七件工具。
然后他问了一句:“这上面,你去年用过的有几件?”
阿哲愣了一下,认真地数了一遍。
”……八件。”
“哪八件?”
“十字螺丝刀、六角扳手、电钻(只用了那个 6mm 的钻头)、卷尺、尖嘴钳、水平尺,还有……活动扳手和手锯。”
“那其他五十九件呢?”
“没用过。”
“买了多久了?”
“最久的四年了。“阿哲说,“那个角磨机,买的时候想的是’以后要自己切瓷砖’,结果装修是找工人做的。电锤,买的时候想的是’以后要在墙上打孔装东西’,结果我这房子是砖墙,普通电钻就够了。”
“那套 87 件的配件呢?”
“用过三个。“阿哲笑了,“还剩八十四个,在抽屉里。”
这面墙,和一次过度设计,是同一件事。我拆成三层说。
第一层:那面墙的”使用价值”,很低。
六十七件工具,年使用八件,使用率 12%。
而这八件里,最常用的是十字螺丝刀和六角扳手——就是宜家买家具送的那两样。
第二层:那面墙的”展示价值”,很高。
阿哲在发那条朋友圈的时候,他获得的东西是真实的:四十个赞、一堆”专业”的评论、同事的羡慕。
而且更重要的是:那面墙在告诉他”我是个动手能力强的人”。
注意这个句式:“我是个……的人”。
这才是最关键的一层。
那面墙真正的价值,不在于它能帮阿哲修什么东西,而在于它定义了阿哲对自己的认知。
他是”那种会在家里挂一面工具墙的人”。这个身份,比”能修一个漏水的水龙头”这件事,重要得多。
现在回到工程师身上。
一个过度设计的系统,它服务的第一个人,是设计它的人。它给他的是:
“我是一个有架构能力的人。”
这个身份,对一个卡在 P6、拼命想证明自己够 P7 的人来说,是刚需。
因为 P7 的评审标准里,有一条叫”技术深度”和”架构能力”。而这两样东西,怎么证明?
- 你写一个 switch,五个 case,跑得稳稳的,三年没出过事 —— 没人觉得你有架构能力。
- 你做一个规则引擎,五种规则,支持组合,有 DSL —— 有人在评审会上说”这个设计得不错”。
问题就在这儿:评审体系在奖励”可见的复杂度”。
这就是为什么过度设计在 P6→P7 这个阶段集中爆发。P4/P5 的人不会过度设计,因为他们还不会。P8 以上的人不太会,因为他们已经不需要证明。
只有 P6→P7 这一段,是过度设计的高发区。 因为你会了,而且你急。
这是本节最刺痛的一层,我觉得值得每个读到这儿的人停下来想一想。
我不是说”不要追求技术深度”。我是说:你要能分清楚,你加那一层抽象,是因为业务需要它,还是因为你需要它被看见。
判据很简单,也很残酷:
如果这套设计做完,没有人会知道(比如它在一个没人 review 的内部脚本里),你还会这么设计吗?
如果会,那是真的设计。如果不会,那是表演。
还有第三层,是我在阿哲那面墙上看到的、最让我意外的一件事。
去年秋天,阿哲家的热水器坏了,需要换一个角阀。
那个角阀在卫生间,管道井里,空间特别窄。
阿哲需要一把短柄的活动扳手。
他家有两把活动扳手。一把 10 寸,一把 12 寸。两把都太长,伸不进去。
最后他是这么解决的:他骑电动车去五金店,花了 18 块,买了把 6 寸的。
那次之后阿哲跟我说了一句话,我觉得是这整个故事的点睛之笔:
“我那面墙上挂满了工具,但是真到用的时候,我还是要去买。”
这句话为什么对?
因为那面墙是按”品类”组织的,而真实的问题是按”场景”发生的。
品类是:扳手、螺丝刀、钳子、锯。
场景是:在一个 15 厘米宽的管道井里,拧一个生锈的角阀。
场景从来不按品类出现。它总是同时需要好几个品类里的一样东西,而且对每一样都有非常具体的要求(短柄、能进得去、能咬住生锈的螺帽)。
按品类做的准备,永远对不上按场景来的问题。
这个道理,用在系统设计上就是:
你按”未来的功能”做的抽象,对不上”真实发生的需求变更”。
你做了一个规则引擎,准备好迎接”五种规则、任意组合”。
然后真实的需求来了:业务方说,“我们要做’A 券和 B 券不能同时用’。”
这不是”加一种规则”。这是规则之间的互斥。你的引擎里,RuleChain 支持 AND 和 OR,不支持 NOT,也不支持规则之间的冲突检测。
你要改的是 RuleChain 的语义,还要改 DSL,还要改所有已有的规则实现,因为它们的 Match 方法没有考虑到”互斥”这种跨规则约束。
你准备好的,和你需要的,永远是两个方向。
这就是为什么”提前为未来设计”几乎总是失败。不是因为你看得不够远,是因为未来的形状,和你想象的形状,从来都不一样。
真正有效的准备,只有一种:让自己改得动。
不是”预先想好所有可能性”,是”当没想到的可能性出现时,你能在一个下午之内改完”。
前者需要你预测未来,后者只需要你把代码写得干净、把耦合控制住、把测试写全。
而后者,恰恰是那些”看起来没有设计”的工作。
Go 实例:从 2400 行的规则引擎,到 380 行
先看小林的”漂亮”版本,也就是大多数人在”我要做好设计”这个心态下会写出来的东西:
package main
import (
"context"
"fmt"
"sort"
"sync"
)
// ---------- 过度设计的版本 ----------
// RuleContext 在规则之间传递中间结果。
// 问题:五种规则之间,从来没有任何需要传递的中间结果。
type RuleContext struct {
OrderAmount int
UserLevel int
CouponValue int
// 为了"通用性",加了一个万能的 map。
// 这个 map 在半年之内,会被塞进 17 个 key,没有人知道哪几个还在用。
Ext map[string]interface{}
}
type Rule interface {
Name() string
Priority() int
Match(ctx *RuleContext) bool
Calculate(ctx *RuleContext) (int, error)
Describe() string // 没人调用过
}
// RuleRegistry 运行时注册表,支持 init() 自动注册。
type RuleRegistry struct {
mu sync.RWMutex
rules map[string]Rule
}
func NewRuleRegistry() *RuleRegistry {
return &RuleRegistry{rules: map[string]Rule{}}
}
func (r *RuleRegistry) Register(rule Rule) {
r.mu.Lock()
defer r.mu.Unlock()
r.rules[rule.Name()] = rule
}
func (r *RuleRegistry) Get(name string) (Rule, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
ru, ok := r.rules[name]
return ru, ok
}
// RuleChain 支持 AND / OR 组合,支持优先级排序。
// OP 这个类型,一次都没有被配置成 "OR" 过。
type OP string
const (
OPAnd OP = "AND"
OPOr OP = "OR"
)
type RuleNode struct {
RuleName string
OP OP
}
type RuleChain struct {
registry *RuleRegistry
nodes []RuleNode
}
func (c *RuleChain) Execute(ctx *RuleContext) (int, error) {
// 按优先级排序 —— 五种规则之间其实没有优先级关系
sort.SliceStable(c.nodes, func(i, j int) bool {
ri, _ := c.registry.Get(c.nodes[i].RuleName)
rj, _ := c.registry.Get(c.nodes[j].RuleName)
return ri.Priority() < rj.Priority()
})
total := 0
matched := false
for _, n := range c.nodes {
rule, ok := c.registry.Get(n.RuleName)
if !ok {
continue
}
if !rule.Match(ctx) {
// OR 的语义实现得对不对,没人验证过,因为没用过
if n.OP == OPAnd {
return 0, fmt.Errorf("规则 %s 不匹配", n.RuleName)
}
continue
}
v, err := rule.Calculate(ctx)
if err != nil {
return 0, err
}
total += v
matched = true
}
if !matched {
return 0, nil
}
return total, nil
}
// ---------- 一个规则的实现 ----------
type FullReduceRule struct {
MinSpend int
Amount int
}
func (r *FullReduceRule) Name() string { return "full_reduce" }
func (r *FullReduceRule) Priority() int { return 10 }
func (r *FullReduceRule) Describe() string { return "满减" }
func (r *FullReduceRule) Match(ctx *RuleContext) bool {
min, _ := ctx.Ext["min"].(int)
return ctx.OrderAmount >= min
}
func (r *FullReduceRule) Calculate(ctx *RuleContext) (int, error) {
amt, ok := ctx.Ext["amount"].(int)
if !ok {
return 0, fmt.Errorf("full_reduce 缺少 amount 参数")
}
return amt, nil
}
func main() {
// 初始化:注册、构造链条、执行
reg := NewRuleRegistry()
reg.Register(&FullReduceRule{})
chain := &RuleChain{registry: reg, nodes: []RuleNode{{RuleName: "full_reduce", OP: OPAnd}}}
result, _ := chain.Execute(&RuleContext{
OrderAmount: 15000,
Ext: map[string]interface{}{"min": 9900, "amount": 3000},
})
fmt.Println("优惠金额:", result)
}这段代码的问题,不在于它错了,在于它为一个不存在的问题付了固定成本:
RuleContext.Ext是个map[string]interface{}。它让编译器丧失了检查能力。amount写成amt,编译能过,线上返回 0。这就是大刘说的”潘多拉的盒子”。RuleRegistry+sync.RWMutex:五种规则是编译期确定的,永远不会有”运行时动态注册”这个需求。锁是纯粹的成本。RuleChain的 AND/OR:OR分支从未被执行过,也就是说,那段代码从未被测试过。Priority排序:五种规则之间互斥(一个订单只能用一种券),根本不存在优先级。Describe():定义了但没人调用。
这些东西的每一个单独拿出来,都能被解释成”为了扩展性”。但合在一起,它们构成了一个”没人能一眼看懂这段程序到底在干嘛”的系统。
而一个新人接手这个模块,他要理解的东西是:5 个接口方法 + 3 个结构体 + 2 个组合语义 + 1 个万能 map。
然后是删完之后的版本:
package main
import (
"fmt"
)
// ---------- 删完之后 ----------
// Coupon 券的配置。字段是具体的,编译器能检查。
// 加一种新券,就加一个字段。字段多了怎么办?
// 那就说明这个结构该拆了——那时候再拆,因为那时候你知道该怎么拆。
type Coupon struct {
Type string // "full_reduce" | "discount" | "no_threshold" | "free_shipping"
MinSpend int // 满减门槛(分),仅 full_reduce 用
Amount int // 面额(分)或免运费时不使用
Percent int // 折扣百分比,如 90 表示 9 折,仅 discount 用
}
type Order struct {
Amount int // 订单金额(分)
UserLevel int
}
// CalculateDiscount 计算一张券在一笔订单上能抵扣多少。
//
// 加一种新券要改两个地方:
// 1. Coupon.Type 的注释里加一个值
// 2. 下面的 switch 里加一个 case
//
// 这就是全部的"扩展点文档"。它不优雅,但它是准确的:
// 任何人都知道加一种券要动哪里,不需要理解任何抽象。
func CalculateDiscount(c Coupon, o Order) (int, error) {
switch c.Type {
case "full_reduce":
if o.Amount < c.MinSpend {
return 0, nil // 不满足门槛,不抵扣,这不是错误
}
return c.Amount, nil
case "discount":
// 折扣券:订单金额 * (100 - Percent) / 100
if c.Percent <= 0 || c.Percent >= 100 {
return 0, fmt.Errorf("discount 券的 Percent 非法: %d", c.Percent)
}
return o.Amount * (100 - c.Percent) / 100, nil
case "no_threshold":
return c.Amount, nil
case "free_shipping":
return 0, nil // 免运费不影响商品金额,运费在别处处理
default:
// 关键:未知类型明确报错,而不是静默返回 0。
// 这一行是整个函数里最重要的一行。
return 0, fmt.Errorf("未知的券类型: %q", c.Type)
}
}
func main() {
c := Coupon{Type: "full_reduce", MinSpend: 9900, Amount: 3000}
o := Order{Amount: 15000}
d, err := CalculateDiscount(c, o)
fmt.Println("优惠金额:", d, "err:", err)
}380 行 vs 2400 行,差的不是代码量,是认知负担。
但这个版本不是”简陋”,它做了三件重要的事:
第一,default 分支明确报错。
这是整个函数里最值钱的一行。switch 里没有 default,或者 default: return 0, nil,是我在代码评审里见过最多次的错误之一。
它意味着:如果有人配了一个拼写错误的券类型("fullreduce" 少了下划线),系统返回 0,用户看到”优惠 0 元”,运营查了一整天。
而 return 0, fmt.Errorf(...) 会立刻在日志里告诉你问题在哪。
第二,“加一种新券要改两个地方”这个注释。
它的成本是两行注释,它的收益是:任何一个接手的人,都不需要去理解”设计意图”,他只需要照做。
这是”文档化的扩展点”和”机制化的扩展点”的区别。
机制化(注册表、插件):成本高,收益是”加新东西不用改老代码”。 文档化(注释 + 一个明确的 switch):成本两行,收益是”加新东西要改一行 switch”。
在”一年加两次新券”这个频率下,后者明显更划算。
第三,字段是具体的,不是 map[string]interface{}。
MinSpend、Amount、Percent 三个字段,编译器能检查,IDE 能补全,grep 能找到所有用法。
而 Ext["amount"] 什么都不能。
那么问题来了:什么时候该上”机制化”的扩展点?
这就是”三次原则”的用武之地。给你一个可操作的判据:
// 判断要不要抽象的三问(伪代码,但请真的这么问)
func shouldAbstract(occurrences int, changeFrequency time.Duration, costToAddNow time.Duration) bool {
// 问一:这个"变化"出现过几次?
// 少于 3 次 -> 不要抽象。你手上还没有足够的信息判断"共性"是真的。
if occurrences < 3 {
return false
}
// 问二:这个"变化"多久发生一次?
// 一年一次的东西,用注释 + switch 就够了。
// 一个月一次的东西,值得上机制。
if changeFrequency > 90*24*time.Hour {
return false
}
// 问三:现在加机制的代价,和将来加机制的代价,差多少?
// 如果"现在加"和"半年后加"的成本差不多(都是两小时),
// 那就半年后再加 —— 那时候你有真实的信息,而不是猜测。
if costToAddNow > 40*time.Hour {
return false
}
return true
}三个问题的答案串起来,就是小林那个规则引擎的判决书:
- 出现几次?一种规则。(< 3,不抽象)
- 多久变一次?半年多没变过。(> 90 天,不抽象)
- 现在加和以后加差多少?以后加是 40 分钟,现在加是两周。(现在加更贵)
三条全不满足。
最后补一句,是老陈说的,我觉得放在这一节的结尾最合适:
“小林,你要记住一件事。”
“什么?”
“好的设计,看起来都很无聊。”
“什么意思?”
“你去看那些跑了十年还在跑的系统,它们的代码通常平淡得让人失望。没有巧妙的技巧,没有一个让你’哇’的抽象。就是一堆直来直去的东西,加一堆看起来很啰嗦的错误处理。”
“让你觉得’哇,这个设计真漂亮’的代码,通常活不过两年。”
“为什么?”
“因为’漂亮’意味着它做了一个很强的假设——只有在那个假设成立的时候,它才漂亮。而假设,是会过期的。“
老陈的深层发问
-
你最近写的代码里,有没有哪一段,是你自己觉得”写得挺巧妙”的?如果有人要求你把它删掉换成最土的写法,你会心疼吗?那份心疼,是从哪儿来的?
-
你为”将来可能需要”而写的代码,有多少真的被用上了?你能说出三个例子吗?说不出来的那些,你还记得当时是怎么说服自己的吗?
-
如果你的下一个设计,没有人会 review,没有人会看到,LinkedIn 上也不会有人知道,你还会这么设计吗?
-
你上一次删代码是什么时候?删了多少行?删的时候,是什么感觉?
-
你团队里,谁的设计是你最看不懂的?你看不懂,是因为他比你强,还是因为他在表演?
生活思考题
思考题一:你搬家的时候,会发现很多”当初觉得以后会用”的东西,一次都没用过。那些东西通常是什么?你当初留着它们的理由是什么?
参考答案
第一层:大多数人会怎么答 “最常见的就是各种包装盒——手机盒、电脑盒、家电盒。还有各种线、各种转接头、说明书、保修卡、买东西送的赠品、一次性筷子、酒店拿回来的洗漱用品。当初留着的理由都是’万一以后用得上’。”
第二层:这个答案为什么不够 “万一以后用得上”这个理由,听起来很合理,但它经不起一个追问:“万一”是多少?
你留着一根 VGA 线,留了六年。这六年里,你用过它几次?一次。而那一次,是因为你去一个老旧的酒店开会,投影仪只有 VGA 口。
那一次的价值,和六年里它在你家抽屉占的空间、每次整理时的心理负担,哪个大?
这个问题没有标准答案,但它暴露了一个更深的东西:
你留着那些东西,不是因为”以后会用”,是因为”扔掉会后悔”。
这两个理由,看起来一样,实际上完全不同。
“以后会用”是关于未来的预测,它是可以被验证的(用上了/没用上)。 “扔掉会后悔”是关于未来的情绪,它无法被验证——因为你永远不知道你扔掉的东西会不会在第二天用上。
而人做决策的时候,对”确定的损失”(扔掉这个动作本身)的敏感度,远高于对”概率性的收益”(未来可能用上)的敏感度。这叫损失厌恶。
过度设计的心理机制,就是损失厌恶。
你加那一层抽象,不是在赌”它会被用上”,你是在避免”将来需要它的时候没有”的那种后悔感。而为了避免这个后悔感,你付出的成本是确定的:复杂度、认知负担、维护时间。
第三层:更深的一层是什么 仔细想想,那些被你留下的东西,有什么共同特征?
我观察过几次搬家(自己的和朋友的),发现它们基本是两类:
第一类:功能被其他东西覆盖了,但你觉得”它更专业”。 比如你有一台专门的豆浆机,虽然你一年做两次豆浆,而破壁机也能做。比如你有三把不同的刀,虽然你每次只用那一把。
第二类:代表着某种”可能性”。 那根 VGA 线代表的不是 VGA,是”我是一个能应对各种突发情况的人”。那套没拆封的烘焙工具代表的不是烘焙,是”我是个会做甜点的人”。
第二类东西,你从来不会扔。因为它们不是工具,是身份的锚。
现在,回到你的代码库:
那些”没人用但没人敢删”的模块、那些”为将来留的口子”、那些”通用的”抽象层——
有几样是第一类(功能重复),有几样是第二类(代表一种可能性)?
第二类,就是你团队里的”技术图腾”。它们的作用不是解决问题,是让团队觉得自己在做有技术含量的事。
这类东西,最难删,因为删它等于否定一个身份。
第四层:落到 Go 工程师身上 一个可以马上做的练习,我叫它”半年之约”:
一、找出你代码库里三处”为将来留的口子”(特征:注释里有”未来""以后""扩展""通用""支持”字样;或者有一个从未被实例化过的接口)。 二、给每一处加一个 TODO 注释,格式统一:
// TODO(你的名字, 2026-02-28): 如果到这个日期还没有第二个使用者,删除本抽象。三、在日历上给 2026-02-28 设个提醒。 四、到期的时候,执行。这个练习的关键,不是”删代码”,是第二步那个日期。
它把一个模糊的”以后可能会用”,变成了一个可判定的、有截止时间的命题。而一旦它有了截止时间,你就必须面对它。
我见过做这个练习的团队,到期时的执行率大概是 60%。剩下 40% 没执行的,理由通常是”现在业务紧,下次再说”。
而那个”下次再说”,就是这些抽象能活十年的原因。
最后一个建议:这个练习,最好由团队里最资深的人带头做。 因为只有他删了自己写的东西,其他人才敢删自己写的。
思考题二:你去一家餐厅,菜单上有一百二十道菜。你会怎么点?你会不会觉得这家餐厅不靠谱?为什么?
参考答案
第一层:大多数人会怎么答 “会。菜单越厚越不靠谱。一百二十道菜的餐厅,后厨备料备不过来,多半是预制菜,或者大部分菜根本卖不出去。我一般点菜会看那些’看似普通但每桌都点了’的,或者看服务员推荐时犹豫了一下才说的那道。”
第二层:这个答案为什么不够 “菜单厚 = 不靠谱”这个判断,方向是对的,但它的适用范围比你以为的窄。
有一类餐厅的菜单就是很厚,而且做得很好:大排档和家常菜馆。
为什么?因为它们的后厨模式不一样。大排档的菜,大部分是”同一个底料 + 不同的主料”——红烧的做法,可以做红烧肉、红烧鱼、红烧排骨、红烧豆腐。它们的”一百二十道菜”,底层的烹饪方法是二十种。
所以判据不是”菜单有多厚”,是:“这一百二十道菜,背后有多少种不同的做法?”
一百二十道菜 / 二十种做法 = 靠谱(大排档)。 一百二十道菜 / 一百二十种做法 = 不靠谱(什么都做,什么都做不好)。
更深一层的盲区是:你有没有想过,餐厅为什么要把菜单做厚?
不是因为厨师想炫技。是因为它不知道客人想吃什么。
一家开在写字楼下的快餐店,它的客群是确定的(上班族,要快、要便宜、要能吃饱),它的菜单可以只有十二道菜。
一家开在景区门口的餐厅,它的客群是完全不确定的(全国各地的人,口味、预算、忌口全都不一样),它必须做一百二十道菜,才能覆盖足够多的可能性。
菜单的厚度,和客群的不确定性成正比。
第三层:更深的一层是什么 这个对应关系,是理解”过度设计”的钥匙:
一个系统的抽象层数量,和它对需求的确定性成反比。
你的需求非常确定(就做满减,三个月内不变)→ 你不需要抽象,写具体的代码。 你的需求完全不确定(要支撑三种完全不同的业务,而且不知道第四种长什么样)→ 你需要抽象,但你要清楚你在付什么代价。
问题在于,大多数人的处境是”中等不确定”,而他们按”完全不确定”来设计。
小林那个规则引擎就是典型:他知道三条业务线的券长什么样(增长线已上线,会员线和内容线的需求他手里有文档)。他的不确定性其实很低。但他按”可能有无数种规则”来设计了。
为什么会这样?因为承认”我其实知道”是危险的。
如果你承认你知道,那么当需求变化时,那就是你的判断失误。 而如果你说”这个未来不确定,所以我做一个灵活的架构”,那么无论未来怎么变,你都可以说”看,我早就考虑到了”。
过度设计,是一种免责策略。
这句话很重,但我觉得值得说。
一个抽象层,不只是代码。它是一份保险:当未来和你预期的不一样时,它能保护你不被追责。
而这份保险的保费,是整个团队的认知负担。
第四层:落到 Go 工程师身上 下次你要加一层抽象之前,先做一件事:把你对未来的判断,写下来,带日期。
格式很简单,写在设计文档最上面:
## 我对未来的判断(写下日期:2026-08-29) 1. 半年内,券的形态不会超过 3 种。判断依据:已和三条业务线对过需求文档。 2. 券的叠加功能,半年内不会做。判断依据:王总 7/12 明确说本期不做。 3. 核销 QPS 半年内不会超过 500。判断依据:当前 120,历史最大 210。 如果以上任意一条被证明是错的,本设计需要在第 X 节做相应调整。这个动作的价值,不在文档本身,在于:
它强迫你把”我不确定”,替换成”我判断是 X,我可能错在哪儿”。
“我不确定”是没有成本的——你不需要为它负责。 “我判断是 X”是有成本的——如果错了,那就是你的判断失误。
而只有当你愿意承担”判断失误”的责任,你才会真的去做判断。而做判断,是 P7 的全部工作。
三个月后,回头看这份清单。数一数错了几条。
如果你一条都没错,说明你判断得太保守了(你把”可能”写成了”一定不会”)。 如果你错了一半以上,说明你的信息源有问题,你需要换一批人去聊。 错两到三条,是健康的状态。 那说明你的判断既不是保守的,也不是瞎猜的。
这和你点菜是一个道理:一家餐厅如果一百二十道菜全都好吃,那它浪费了;如果只有三道好吃,那它该改菜单。
而你要做的,是成为那个知道自己哪几道菜好吃,并且敢把其他菜从菜单上划掉的人。
本章小结:核心心法
问题由我定义
十二月的最后一个周三,小林参加了今年的第二次职级评审。
这次他没准备架构图。他讲了两件事。
第一件,是那个三分四十七秒的需求,和那 42 页被删到 9 页的文档。他讲了他怎么去找王总,怎么拿到”37 降到 32”这个数字,怎么用这个数字把 Kafka、分库分表、规则引擎一个个砍掉。
第二件,是那个 2400 行的规则引擎,和他怎么把它删到 380 行。他讲了自己删的时候,心里那点说不出口的心疼。
讲完,评委席上有人问了一句:
“你做的这些,影响的范围有多大?”
”……什么范围?”
“你砍掉 Kafka、砍掉规则引擎,省下来的时间,用在哪儿了?”
小林想了想:“用在……v2 的多业务线支持上。”
“那是你的项目。“那个评委说,“我问的是:券系统之外呢?你这套’先定义问题再动手’的方法,你们组第二个人会了吗?”
小林张了张嘴。
“小周现在做需求,会先去找业务方对数字吗?“评委问。
”……我会提醒他。”
“你提醒他,和他自己会,是两件事。”
三天后结果出来:没过。
评语他抄了一份,压在工位键盘下面:
“相比上次有明显进步,已具备 P7 层面的问题意识与技术判断力,能独立定义问题、做出合理的取舍。但 P7 的评价依据是’你影响的范围’,不是’你思考的深度’。目前观察到的影响力仍局限于本人及其直接协作的 1-2 人,未见方法论在团队层面的沉淀与复制。建议扩大 scope。”
那天晚上,他和老陈在那家兰州拉面,还是七点。
“我差在哪儿?“小林问。
“你已经不是差在’不会’了。“老陈说,“你差在’不够多’。”
“什么意思?”
“你一个人会定义问题,那是能力。你让五个人都会,那才是级别。”
”……”
“你回想一下这一整年。“老陈说,“你做的事情,从’解决问题’变成了’定义问题’。这是从 P6 到 P7 的转变,你已经完成了大半。”
“但从 P7 到 P8,是另一个转变:从’我能定义问题’,到’我定义的问题,是一群人在解’。”
“你现在回答不了,说明你还没到那一步。“老陈说,“但这不是骂你,这是个坐标。”
小林低头吃面。吃了两口,抬起头。
“老陈,我说句实话。”
“你说。”
“我今年二十八了。房贷还有二十四年。我妈上个月又问我,什么时候带个人回家过年。”
“嗯。”
“我同学里,有两个已经是 P8 了。其中一个,技术真不如我。”
“我知道。”
“我今天拿到这个结果,我第一反应不是难过,是——“他停了一下,“是累。”
老陈没说话,给他夹了块牛肉。
“我不是要放弃。“小林说,“我就是……有点不明白,为什么这临门一脚,我踢了两次都没进去。”
“你知道’临门一脚’这个说法,问题出在哪儿吗?“老陈问。
“哪儿?”
“它假设了’门’和’你’之间,只差’一脚’。“老陈说,“可实际上,从 P6 到 P7,不是一堵墙,是一段路。你这一年走完了那段路的百分之七十。你觉得只差一脚,是因为你站在百分之七十的地方往前看,看得见门了。”
“可是看得见,和走得到,中间还是三十。”
“那三十是什么?”
“是重复。“老陈说,“你今年做了三个正确的判断。明年你需要做三十个,而且是在不同的人面前、不同的业务上、不同的压力下,都做出同样质量的判断。”
“一个判断叫’灵光一现’。三十个判断叫’这个人稳定可靠’。”
“级别,是别人对你稳定性的评估,不是对你上限的评估。”
小林坐在那儿,把碗里的汤喝完了。
“那你说明年我该干什么?”
“你先想想。“老陈说,“想明白了,下次来告诉我。”
这一章讲的东西,如果只能留一句,是这句:
P6 是”给我一个清晰的问题,我给你一个漂亮的方案”;P7 是”给我一团模糊的诉求,我给你一个清晰的问题,再加一个方案”。
大部分人一辈子卡在 P6,因为他们从来没意识到,“定义问题”是一项独立的、可以训练的能力。
而”问题由我定义”这六个字,展开来是四句话:
第一,模糊不是缺陷,是原料。 王总那三分四十七秒里的矛盾(“人人能领”和”不能超发”),不是他没说清楚,是真实业务里这两个诉求本来就打架。你的工作不是抱怨它模糊,是把这个矛盾找出来、摆到桌面上、让他选。
第二,约束比方案重要。 架构不是从需求来的,是从约束的量级来的。三十七到三十五,和三十七到二十五,是两套完全不同的系统。你先拿到那个数字,再动手。
第三,不可逆性是你唯一需要盯紧的东西。 一个设计里,可逆的部分错了,改一行;不可逆的部分错了,改两个月。Kevin 问”哪个决定最难改”,就是在问这个。
第四,你能不能改得动,比你想得有多远重要。 未来和你想象的形状,从来都不一样。你唯一能准备的,是”当没想到的变化来临时,你一个下午能改完”。
还有一条,是这一级的陷阱,值得单独再说一遍:
过度设计的本质,往往不是判断力问题,是身份焦虑。 你会了,而且你急着证明你会。于是你加了一层抽象——它不是为了业务,是为了让评审者、让同行、让未来的面试官觉得你厉害。
判据只有一句:如果这套设计没有人会看到,你还会这么设计吗?
以及那句扎心的:
好的设计,看起来都很无聊。让你觉得”哇,这个设计真漂亮”的代码,通常活不过两年。
最后的最后,是这一章里最容易被跳过、但可能是最重要的一节——4.7。
你现在的时间不属于你了。
这一条,不是关于带人的技巧,是关于身份的转变。你从”一个写代码的人”,变成了”一个让别人能写代码的人”。这个转变的痛苦,不在于时间被占用,在于你的成就感来源变了——你不再能从”我写出了一个漂亮的东西”里获得满足,你得从”他写出了一个漂亮的东西”里获得满足。
而这两种满足感,后者来得慢,来得模糊,而且不会有人给你点赞。
小林走到这一步,用了整整一年。他还差最后那三十。
自检清单
下面这些条目,不是知识点,是能力判据。每一条都应该是”能”或者”不能”,没有”差不多”。
□ 我能够说出我手上正在做的项目”要改变的那个数字”是多少,以及这个数字是我问出来的还是别人给的。
□ 我能够把一段模糊的需求,拆成三条以上带来源标注的约束,并且标出其中哪几条是我推测的、还没有确认过。
□ 我能够说出我负责的每一个写接口的幂等键是什么,以及这个键的粒度对应的是”同一个请求”还是”同一笔业务”。
□ 我能够说出我的服务里每一个并发度相关数字(连接池、worker 数、GOMAXPROCS、消费者并发)的依据,且依据来自下游容量而不是”默认值”或”试出来的”。
□ 我能够说出一个我最近做的设计里,哪一个决定是最难改的,以及我为这个不可逆性准备了什么(降低不可逆性 / 延迟决策 / 迁移预案,三选一)。
□ 我能够说出我负责的服务,堆内存峰值、GC 频率、以及分配速率最高的那个接口是哪个。
□ 我能够在一次评审会上,主动说出”我最没把握的是 X”,并且给出具体的、让评审者能下嘴的位置。
□ 我能够列出我团队里所有的口头规范,并且说出其中有几条已经被变成了 CI 里会红的检查。
□ 我能够说出我带过的人里面,有谁学会了某样我不会的东西,并且我在他学会的那一天是什么反应。
□ 我能够举出一个我主动删掉的代码(或设计),说出删掉了多少,以及删的时候我心里是什么感觉。
□ 我能够说出我为”将来可能需要”而写的代码,有多少真的被用上了;如果举不出三个例子,我能够说出第二个使用者出现的概率是多少。
□ 我能够在没有一个清晰问题的时候,先不写代码,而是先去问三个问题:现在没有它的时候他们怎么干的、做完之后哪个数字会变、如果只能做一半是哪一半。
如果这十二条里,你能诚实打勾的不到六条,那说明你还在 P6 里打转——这不丢人,P6 是个很好的地方,只是它不是终点。
如果能打勾九条以上,你大概已经在 P7 里了,只是你的组织还没承认。
而如果你全部都能打勾,那你要小心一件事:你可能已经在做 P8 的事了,但拿的是 P7 的 title。 那不是坚持的问题,是选择的问题——下一章我们聊这个。