第二章 P5 独立篇:让一块地归我负责
心法:让一块地归我负责 转变:从「我做完了」到「它跑起来了」 陷阱:只守自己的一亩三分地
开篇:凌晨两点十七分
两周后,还是城西那家没有招牌的茶室。
老陈这次没带他那套盖碗,就用一个玻璃杯泡了点绿茶,茶叶飘在上面。他把杯子推到小林面前,问了一句:
“你第一次被半夜叫起来,是什么时候?”
小林张嘴就想答,然后停住了。
不是想不起来。是想起来得太快了。
“2023 年 4 月 11 号。“他说,“凌晨两点十七分。”
“记得这么清楚。”
“我记得特别清楚。“小林说,“因为那天我手机开的静音,大刘打到第四遍我才接到。他第一句话是’小林,你那个导出功能,把机器搞挂了’。”
老陈没接话,把手里的杯子放下,示意他继续。
那是小林入职现在这家公司的第四个月。公司做商家订单管理的 SaaS,商家在后台能看到自己的订单列表,能筛选、能看详情。小林当时被分在交易组,负责订单查询这一块。
需求的来源,是一次周一早上的周会。
阿 May 是那块业务的产品经理,三十岁出头,说话很快,很少铺垫。她那天翻着笔记本,翻到快结束的时候抬头说了一句:
“对了,还有个小的。好几个商家反馈,订单列表能不能导出 Excel?他们财务要对账,现在是一页一页复制的。”
然后她就翻过去了。
小林记得自己当时的第一反应。不是”这个需求要怎么做”,是”就这?”
他的原话是:“这个简单,我这两天做一下。”
他跟老陈复述这句话的时候,自己都有点不好意思。
“老陈,我跟你说实话,“他说,“我当时心里是有点不屑的。我那时候已经写过支付回调、写过对账任务、写过退款的状态机,我觉得这种’查数据写个文件’的活,应该是实习生干的。”
“所以你觉得自己被低估了。“老陈说。
“对。“小林没否认,“我觉得这是在我手上浪费时间。”
“然后呢。”
“然后我两天就写完了。中间还等了一天联调,实际上写代码大概一天半。”
“用了什么?”
“excelize。查库,遍历,往 sheet 里写,写完了保存到临时目录,返回一个下载链接给前端。“小林说,“总共两百来行。”
“测了吗?”
“测了。“小林说,“我们测试环境有四百多条订单,导出来格式对、字段对、中文不乱码,我还特意发给阿 May 看了一眼,她说没问题。MR 我也提了,组里另外一个同事 review 的,他只留了一条评论,说某个字段名拼错了。”
“改了吗?”
“改了。然后就上线了。4 月 6 号,周四,跟着版本走。”
老陈”嗯”了一声,端起杯子。
“上线之后呢?“他问。
“上线之后风平浪静。“小林说,“周四到周日,一共导出了六百多次,没有一条报错。我还特意看了一眼监控,内存平稳,耗时 P99 是 180 毫秒。我心里还挺得意的,觉得这事儿就这么完了。”
“然后 11 号凌晨两点十七分。”
“然后 11 号凌晨两点十七分。”
大刘是公司的 SRE,三十五岁,瘦高个,说话从来不带情绪,但每句话都像在陈述一份事故报告。
他那天晚上说的是这样一段话,小林一个字都没忘:
“小林,你家那个导出接口,把订单服务的内存吃满了,容器 OOM,被 k8s 杀了三次,现在商家那边五分钟查不了订单。你先起来看一下,我已经把那个商家的大促群里的消息压住了,压不了太久。”
小林当时整个人还是懵的。他坐起来,第一句话是:
“不可能吧,我测过的。”
大刘说:“你测的是多少条?”
“四百多条。”
“这次是十一万条。“大刘说,“一个连锁餐饮的商家,三年的订单,财务月底对账,一次性全导。”
小林后来在监控上看到那条曲线。凌晨 1 点 58 分开始,服务的内存从 320MB 一路往上爬,两分钟涨到 3.8G,容器的 limit 是 4G,然后是一条断崖。重启,再爬,再断崖。前后三次。
问题是,那个导出功能和订单查询,跑在同一个进程里。
也就是说,导出把内存吃满的时候,那个商家门店里正在查订单的收银员,全都查不了。凌晨两点,正是几家夜宵店在跑单的时间。
“你那个功能是干什么的来着?“老陈这时候插了一句。
小林愣了一下:”……导出 Excel。”
“它挂的时候,挂的是谁?”
”……订单查询。”
“所以一个’导出 Excel’的功能,把订单查询搞挂了。“老陈说,“你当时意识到这件事了吗?”
“我当时没意识到。“小林说,“我是第二天复盘会上才反应过来的。当时我只想着一件事:怎么让它别再导了。”
他当时的处理很狼狈。
他没有做开关。他那个功能上线的时候没有任何可以关掉的旋钮,既没有配置,也没有 feature flag,连个能改的数据库字段都没有。他想了半天,最后给大刘出的主意是:
“要不你在网关上把那个 URL 加个黑名单吧。”
大刘在电话那头沉默了两秒钟,说:“行。”
然后是两个小时的清理。临时目录 /tmp/export 里堆了八百多个文件,有的几十 KB,有的两百多 MB——那些是被 OOM 打断的半成品。磁盘使用率 87%。大刘一边删一边问:“这些文件有清理逻辑吗?”
小林说:”……没有。”
“生成的文件什么时候过期?”
”……没想过。”
电话那头又沉默了一下。大刘没骂他,一句重话都没说。他只是把文件删完,把服务拉起来,看了眼监控,说了句”行了,睡吧”。
小林挂了电话,坐在床边,看了很久的天花板。
他老婆被吵醒了,迷迷糊糊问了一句:“怎么了?”
“没事,公司的事。“他说。
他那天晚上没再睡着。不是因为害怕被骂,是因为他想不通。他真的想不通:四百条和十一万条,中间差的是数量,凭什么四百条能通过,十一万条就挂了?
第二天上午十点的复盘会。
阿 May 也在。她没等多人齐,开口第一句就是:“这个商家是我们今年签的标杆客户,他们的财务现在还在等那个文件。”
小林说了技术方案,说了内存,说了流式写入,说了要分页。
阿 May 听完,问了他一个问题。这个问题他记到现在。
她问的是:“那四百条的时候能跑,十一万条就挂了,这个临界点在哪?你能告诉我,最多能导多少条吗?”
小林答不上来。
他不是没想过,是他压根就没觉得这是个问题。在他脑子里,这个功能的”正确性”是二元的:能导出来,或者导不出来。他没有”多少条”这个维度。
“老陈,“小林说到这,抬头看着老陈,“我跟你说实话,第二天复盘的时候,我心里其实是有委屈的。”
“委屈什么?”
“我觉得这不公平。测试环境就那么点数据,我怎么测得出十一万条的问题?”
老陈看着他,看了大概三四秒。
“你刚说的这句话,“老陈说,“就是我想跟你聊的整件事。”
“哪句话?”
“‘测试环境就那么点数据’。“老陈把这句话重复了一遍,“你再想想,测试环境只有四百条数据这件事,是谁的问题?”
小林张了张嘴。
他本来想说”那不是我要管的事”。话到嘴边,他咽回去了。
因为他忽然意识到,如果说这句话,那他等于在承认:他写了两百行代码,但他根本不知道这段代码运行的那个世界里,有多少数据、有多少人、有多少种用法。
老陈没等他回答,往后靠了靠。
“你刚才讲了二十分钟,“老陈说,“我数了一下,你说了六次’我做完了’,说了三次’我测过了’,说了两次’MR 提了’。”
”……”
“你没有一句话,是在说它跑起来之后会发生什么。”
小林没说话。
“我再给你换个说法。“老陈说,“你写的那两百行代码,交出去的那一刻,你觉得这事儿结束了。但对你那个商家、对大刘、对那天夜里在收银台上点’查询’的人,这事儿是刚开始。”
“我知道你要说什么了。“小林说。
“你说说。”
“‘做完’和’跑起来’不是一回事。“小林说,”……但这个道理是不是有点太鸡汤了?”
老陈笑了。
“是挺鸡汤的。“他说,“所以我不想跟你讲道理。我想跟你讲代码。”
“代码?”
“你回去把你那个导出功能的代码找出来。“老陈说,“如果还在的话。我猜还在,你们那个仓库应该没拆。”
“在。我上个月还动过它。”
“那就好。“老陈把茶杯里的茶叶吹开,“我们一行一行看。“
一、「我做完了」和「它跑起来了」之间,隔着一整个真实世界
老陈没有讲道理,他让小林把代码投到屏幕上
他们在小林公司楼下的一家咖啡馆,找了个靠墙的位置。小林把笔记本打开,从仓库里翻出那个文件。
文件叫 order_export.go,两百三十七行。
“你自己先读一遍,“老陈说,“读到你觉得不对的地方就停。”
小林往下滚了两屏,鼠标停住了。
”……这里。“他说。
老陈凑过去看。
那是第 41 行:
rows, _ := db.Query(query, merchantID, start, end)“我记得我当时写这个下划线的时候,脑子里想的是什么,“小林说,“我想的是’这个查询不会失败’。”
“为什么不会失败?”
“因为前面已经查过一次了,参数都一样。”
“前面那次查的是什么?”
”……查的是有没有权限。”
老陈点点头,没说话,示意他继续往下读。
小林又滚了一段。停在 68 行。
“这里也有问题。“他说,“rows.Close() 写在 defer 里了,但是这个循环跑完之前都不会执行。十一万行,连接一直占着。”
“你们连接池多大?”
”……二十。”
“那也就是说,“老陈说,“五个商家同时导出,订单查询那边就可能开始排队了。”
小林没接话。他往下滚。
第 92 行,是那个 for 循环。
for i, r := range all {
f.SetCellValue("订单", fmt.Sprintf("A%d", i+2), r.OrderNo)
f.SetCellValue("订单", fmt.Sprintf("B%d", i+2), r.Buyer)
f.SetCellValue("订单", fmt.Sprintf("C%d", i+2), r.Amount)
// ...一共八列
}“这个我知道了。“小林说,“十一万行乘以八列,八十八万次调用。”
“八十八万次调用,每次调用里面干了什么,你知道吗?”
小林想了一下。”……得去解析单元格坐标?”
“要解析坐标字符串,要查这个 sheet 在不在,要查这个单元格有没有样式,要处理合并单元格的判断,还要加锁。“老陈说,“你当时跑四百条的时候,三千两百次调用,几十毫秒跑完了,什么感觉都没有。它变成十一万行,就不是一个东西了。”
“我当时确实没想到。”
“你不用自责’没想到’,“老陈说,“我要你看见的是另一件事。”
“什么事?”
“这四十一行、六十八行、九十二行,是三个不同的问题,“老陈说,“但它们是同一个原因。”
小林看着他。
“你写代码的时候,脑子里有一个画面,“老陈说,“那个画面里,有四百条数据,有一个用户,有一次成功的执行。你所有的判断,都是对着那个画面做的。”
”……”
“而它真正跑的地方,有十一万条数据,有几十个商家,有重复点击,有网络抖动,有磁盘写满,有人在你不知道的时候连点五次。”
老陈把身子往后靠了靠。
“我跟你说过,我不想给你讲道理。那我就问你一个具体的问题:你写代码的时候,脑子里的那个画面,是从哪来的?”
小林想了很久。
“从测试环境。“他说。
“对。“老陈说,“你脑子里那个’真实世界’,是你自己造的。而你造它的时候,用的材料是你最熟悉的、最舒服的那部分数据。”
小林有点不服气。他确实有点不服气。
“但是老陈,“他说,“我不可能凭空知道线上有十一万条。我要是能知道,我不就提前处理了吗?”
“你能知道。“老陈说。
“我怎么知道?”
“你有没有去看过 orders 这张表,按 merchant_id 分组,最大的那个商家有多少条?”
小林愣住了。
”……我可以说没有吗。”
“你可以说没有。“老陈说,“这条 SQL 你写起来要多久?”
“三十秒。”
“三十秒。“老陈重复了一遍,“你没有花这三十秒,然后你花了两个通宵去收拾。”
小林沉默了很久。
“我明白你的意思了。“他最后说,“但我还是觉得有点冤。就算我知道了最大是十一万,我也不知道该怎么处理啊。”
老陈笑了。
“这就对了。“他说,“你现在问的是’怎么做’,这是技术问题,我一会儿跟你讲。但你刚才那一整段,从’不公平’到’我怎么知道’,全是’做完’这个视角里的话。”
“那’跑起来’的视角里,该说什么?”
老陈想了一下。
“我给你举个例子。“他说,“你装修过房子吗?“
老陈的卫生间:做完,和三年不漏
“我 2008 年装修第一套房子。“老陈说,“九十平,两室一厅,东四环。那会儿我还在写代码,白天上班,周末跑建材市场。”
“卫生间防水,是我盯得最紧的一项。”
他说,防水这东西的施工流程其实很死:先把地面清理干净,刷第一遍防水涂料,等干,刷第二遍,十字交叉方向,再等干,然后往墙面上翻三十公分,淋浴区翻一米八。做完之后要做闭水试验——把地漏堵上,放水放到三公分高,放四十八个小时,然后去楼下看天花板。
“我那会儿觉得,我盯得够紧了。“老陈说。
他确实盯了。他请了三天假,专门在现场看着。刷了几遍,翻边多高,他都拿尺子量了。
问题出在闭水试验。
“第三天,装修队长给我打电话,说’陈哥,放水放了一天一夜了,下去看过了,没事,今天能贴砖了’。”
老陈说他当时在现场,看了看水面,确实没少。他下楼敲了门,楼下那家没人。
“然后呢?“小林问。
“然后我就让他放水贴砖了。“老陈说,“我连四十八小时都没等满,等了二十六个小时。我连楼下那家的天花板都没看到,我在门口敲了三下,没人开门,我就上来了。”
“楼下没人开门”——这句话在小林脑子里停了一下。
“住进去第十一个月,“老陈说,“楼下上来敲门。”
那天晚上八点多,一个五十来岁的男人站在门口,说:“您好,我家主卧天花板渗水,您家是不是卫生间漏水?”
老陈上去看。主卧那面墙,正对着他家卫生间,天花板上一片黄色的水渍,有一巴掌大,边缘已经发霉,长出黑色的毛。
“后来呢?”
“后来砸了。“老陈说,“卫生间的地砖、墙砖全砸掉,砸到找平层,重新做防水,重新做闭水试验,重新贴砖。我们一家三口在酒店住了十二天。”
“多少钱?”
“砸和重做,一万四。酒店十二天,六千多。楼下那家的天花板,重刷加赔偿,八千。加起来两万三。“老陈说,“那是 2008 年,两万三。”
“工人没赔吗?”
“没有。“老陈说,“人家会说,我防水做了啊,闭水试验也做了啊,二十六个小时没漏啊。”
小林笑了,然后笑不出来了。
“老陈你讲这个是什么意思?“他问。
“我想让你看一件事。“老陈说,“防水这件事,最难的地方在哪?”
“在于……它藏在瓷砖下面?”
“在于**‘做完’和’不漏’之间,隔着十一个月**。”
老陈伸出手指,在桌上敲了敲。
“你想想这个时间差。防水刷完的那一刻,你没法验证它对不对。你只有两个选择:要么你就等十一个月,要么你在闭水试验上做手脚——把四十八小时缩成二十六小时,把’去楼下看’变成’在门口敲三下’。”
“我自己就是这么干的。“他说,“我不是被工人骗了,我是自己骗自己。因为我太想让它’做完了’。”
小林坐在那儿,没说话。
“你那个导出功能,和这个卫生间,是一模一样的事。“老陈说。
“你写完了,测了四百条,跑了五天没出问题。你不愿意再等了,也不愿意再往下挖一层——就像我不愿意再多等二十二个小时,不愿意再跑一趟去敲楼下那家的门。”
“然后十一个月之后,或者五天之后,水就下来了。”
“只不过你家漏的是水,你家楼下住着人。“老陈说,“你那个服务漏的是内存,你家楼下住着大刘。“
老陈母亲的菜地
“还有一个。“老陈说。
“我妈在老家院子里种菜。前年我回去,帮她把那块地翻了一遍,种了西红柿、黄瓜、辣椒、小葱。折腾了一整天,腰都直不起来。”
“我种完拍了张照片,发朋友圈,配文是’春种一粒粟’。”
“然后呢?”
“然后我第二天就回北京了。“老陈说,“两个月之后我再回去,那块地里,草比西红柿高。”
“西红柿死了吗?”
“没死,还活着,就是被草欺住了,结了两个果子,鹌鹑蛋那么大。“老陈说,“我妈没说什么,她自己后来把草拔了。”
“我想说的是,“他继续,“我那天干的活,从’任务’的角度看,完成度是百分之百。地翻了,坑挖了,苗栽了,水浇了。我甚至比大部分人干得都认真。”
“但是从’这块地’的角度看,我干的是最没用的那部分。”
“为什么是最没用的?”
“因为翻地、播种,是所有环节里最容易的。“老陈说,“难的是后面那两个月——是每天都要去看一眼,是草刚冒头就拔掉,是西红柿长到三十公分要搭架子,是发现叶子发黄要判断是缺水还是缺肥还是病了。”
“这些活,没有人给你派。”
“这些活,也没有一天是’做完’的。”
小林听到这,忽然想起一件事。
“我上个季度做那个退款状态机的时候,“他说,“上线之后我盯了三周的监控,每天早上第一件事就是看昨天的失败率。”
“为什么盯?”
“因为我不放心。退款那玩意儿出错是要赔钱的。”
“那导出那个功能,你盯了吗?”
小林停住了。
”……导出出错不赔钱。“他说。
“对。“老陈说,“这就是我要的那个答案。”
他往前倾了倾身子。
“你盯退款,不是因为你责任心强。是因为你怕担责。你是被’后果’驱动的,不是被’这块地’驱动的。”
“那两个有什么区别?”
“区别大了。“老陈说,“被后果驱动的人,只盯’会出大事的地方’。其他地方他一概不管,直到出事。被这块地驱动的人,他不管这块地上种的是西红柿还是小葱,他都每天去看一眼。”
“因为你不知道西红柿和杂草哪个长得快。“
从「一次性」到「流式」:那两百行代码该怎么改
“行,道理我不讲了。“老陈说,“你把代码改给我看。”
小林打开了编辑器。
“先说你原来那个版本的毛病。“老陈说,“我给你列一下,你看看漏了什么。”
小林一边想一边说:
“第一,一次性把十一万行全捞进内存。第二,rows 的 Close 在 defer 里,长查询占着连接。第三,八十八万次 SetCellValue,每次都要重新解析坐标。第四,没有行数上限。第五,文件生成完不清理。第六,文件名用时间戳,同一个商家连续点两次不会覆盖,会生成一堆。”
“还有一个你没说。“老陈说。
小林想了半天,摇头。
“你的 sheet 名叫’订单’,“老陈说,“如果有一天产品让你把 sheet 名改成商家自定义的名字,商家名字里带个星号或者冒号,会怎么样?”
”……会报错。”
“会报错。“老陈说,“这不是重点,先放着。你现在先按这六条改。”
下面是小林改完的第一版核心逻辑,我把它整理了一下(注释是复盘时加的):
package export
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/xuri/excelize/v2"
)
const (
maxRows = 100_000 // 硬上限:宁可明确拒绝,也不要 OOM
pageSize = 2_000 // 每页两千行,内存里始终只有一页
exportDir = "/tmp/export"
)
// ErrTooManyRows 是一个"哨兵错误",调用方可以用 errors.Is 判断
var ErrTooManyRows = errors.New("导出行数超过上限")
type OrderRow struct {
ID int64
OrderNo string
Buyer string
Amount int64 // 分,不用 float64 表示金额
Status int
CreatedAt time.Time
}
// ExportOrders 第二版:先数行、再分页、边查边写、写满上限就拒绝
func ExportOrders(ctx context.Context, db *sql.DB, merchantID int64, start, end time.Time) (string, error) {
// 1. 先问一句"到底有多少行"。三十秒的 SQL,能省两个通宵。
var total int
if err := db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM orders
WHERE merchant_id = ? AND created_at BETWEEN ? AND ?`,
merchantID, start, end,
).Scan(&total); err != nil {
return "", fmt.Errorf("统计导出行数失败: %w", err)
}
if total > maxRows {
// 2. 明确拒绝,并且把"多少行 / 上限多少行"说清楚,让调用方能直接提示用户
return "", fmt.Errorf("%w: 共 %d 行,上限 %d 行,请缩小时间范围",
ErrTooManyRows, total, maxRows)
}
f := excelize.NewFile()
const sheet = "订单"
f.SetSheetName("Sheet1", sheet)
// 3. 流式写入:内容直接落盘,不攒在内存里
sw, err := f.NewStreamWriter(sheet)
if err != nil {
return "", fmt.Errorf("创建流式写入器失败: %w", err)
}
if err := sw.SetRow("A1", []any{
"订单号", "买家", "金额(元)", "状态", "下单时间",
}); err != nil {
return "", fmt.Errorf("写入表头失败: %w", err)
}
line := 2
var lastID int64
for {
// 4. 游标分页而不是 OFFSET:OFFSET 越大,数据库要扫过的行越多,越导越慢
page, err := queryPage(ctx, db, merchantID, start, end, lastID, pageSize)
if err != nil {
return "", err
}
if len(page) == 0 {
break
}
for _, r := range page {
cell, _ := excelize.CoordinatesToCellName(1, line)
// 5. 一次写一行,而不是一格一格写
if err := sw.SetRow(cell, []any{
r.OrderNo,
r.Buyer,
fmt.Sprintf("%.2f", float64(r.Amount)/100),
statusText(r.Status),
r.CreatedAt.Format("2006-01-02 15:04:05"),
}); err != nil {
return "", fmt.Errorf("写入第 %d 行失败: %w", line, err)
}
lastID = r.ID
line++
}
// 6. 每页之间给 context 一次取消的机会(用户关了页面、网关超时了)
if err := ctx.Err(); err != nil {
return "", err
}
}
if err := sw.Flush(); err != nil {
return "", fmt.Errorf("刷新流式缓冲区失败: %w", err)
}
if err := os.MkdirAll(exportDir, 0o755); err != nil {
return "", fmt.Errorf("创建导出目录失败: %w", err)
}
name := fmt.Sprintf("%d_%s.xlsx", merchantID, time.Now().Format("20060102150405"))
path := filepath.Join(exportDir, name)
if err := f.SaveAs(path); err != nil {
return "", fmt.Errorf("保存导出文件失败: %w", err)
}
return path, nil
}
// queryPage 按主键游标取一页。注意 rows.Close() 是立刻 defer 的,
// 因为这个函数返回很快,不会长时间占着连接。
func queryPage(ctx context.Context, db *sql.DB, merchantID int64,
start, end time.Time, afterID int64, limit int) ([]OrderRow, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, order_no, buyer, amount, status, created_at
FROM orders
WHERE merchant_id = ? AND created_at BETWEEN ? AND ? AND id > ?
ORDER BY id ASC LIMIT ?`,
merchantID, start, end, afterID, limit,
)
if err != nil {
return nil, fmt.Errorf("查询订单分页失败: %w", err)
}
defer rows.Close()
var out []OrderRow
for rows.Next() {
var r OrderRow
if err := rows.Scan(&r.ID, &r.OrderNo, &r.Buyer,
&r.Amount, &r.Status, &r.CreatedAt); err != nil {
return nil, fmt.Errorf("扫描订单行失败: %w", err)
}
out = append(out, r)
}
// rows.Err() 必须检查:循环正常结束不代表没有出错(比如连接中途断了)
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("遍历订单行失败: %w", err)
}
return out, nil
}
func statusText(s int) string {
switch s {
case 1:
return "待付款"
case 2:
return "已付款"
case 3:
return "已发货"
case 4:
return "已完成"
case 5:
return "已退款"
default:
return "未知"
}
}“改完之后,“老陈说,“你猜效果怎么样?”
“我后来测了。“小林说,“同样十一万行,内存峰值从 3.8G 降到 62MB,耗时从 92 秒降到 14 秒。”
“为什么快了六倍?”
“一个是流式写入,少了很多重复的坐标解析和样式查找。另一个是游标分页,OFFSET 80000 这种查询在 MySQL 上要扫过八万行,改成 id > ? 之后走主键,快很多。”
“对。“老陈说,“但我更想让你注意一件事。”
“哪件?”
“这段代码里,最值钱的不是流式写入,也不是游标分页。“老陈说,“是开头那个 COUNT(*)。”
小林看着屏幕。
“三十秒的 SQL,换来的是’我知道我要面对什么’。“老陈说,“你原来的代码里没有这一行,所以你是在盲干。你后来的所有优化,都是建立在’我知道最多十一万行’这个前提上的。”
“这就叫 ownership 吗?”
“这是 ownership 的第一步。“老陈说,“先搞清楚你这块地有多大、土质怎么样、去年种过什么。 大部分人连这一步都没做,就开始写代码了。“
老陈的深层发问
老陈合上笔记本,问了小林四个问题。他没有给答案。
第一问:你最近一次说”我做完了”,是在什么时候?你说完这句话之后,接下来发生的事,你是第一个知道的,还是最后一个知道的?
第二问:你负责的东西,明天如果全部下线,会有几个人来找你?他们找你,是因为用得顺手,还是因为他们的工作停了?这两种’被需要’,你想要哪一种?
第三问:你能立刻说出一个数字吗——你负责的那个接口,线上真实的 P99 是多少?最大的一次入参有多大?如果说不出来,你是在’负责’它,还是在’写过’它?
第四问:如果明天你要把手上的东西交接给小周,你需要几天?这几天里,有多少内容是只在你脑子里、从没落到纸上的?
小林听完之后,沉默了大概半分钟。
“第三个我答不上来。“他说。
“我知道。“老陈说,“所以我问了。“
生活思考题
思考题一
你租过房子吗?
回想一下:租房的时候,厨房水龙头滴水,你是怎么处理的?如果是你自己家的房子,水龙头滴水,你又是怎么处理的?
这两种情况下,你的反应速度、处理方式、以及处理完之后你会不会惦记着这件事,有什么不一样?
这个”不一样”,和老陈说的 ownership,是什么关系?
参考答案
大多数人会怎么答
大部分人的第一反应是:租房的时候我会拖一拖,可能先拿个盆接着,或者发微信给房东,等房东来处理;自己家的我肯定马上修,或者马上买个新的换上。
再往后想一层,有人会说:因为租房是别人的东西,自己家是自己的东西,自己的东西当然更上心。
这个答案是对的,但它停在了最表面。
这个答案为什么不够
“别人的东西不上心,自己的东西上心”——这个说法有一个隐含前提:上心与否,取决于产权。
但你仔细想想,你在公司里写的代码,产权是公司的,不是你的。如果”上心”真的完全由产权决定,那所有打工人都应该一样地不上心。可事实是,同一个团队里,有的人写出来的东西住了三年不漏水,有的人写出来的东西第十一个月就漏。
更关键的漏洞是:这个答案里没有”时间”。
租房的时候你拖,是因为你可能只住一年,漏水的后果大概率不会落在你头上;自己家你要住三十年,漏水的后果百分之百是你自己扛。真正决定你行为的,不是产权,是”你会不会在这里待到后果发生的那一天”。
更深的一层是什么
ownership 这个词,字面意思是”物主身份”,但它在工程师语境里的真实含义,是**“我对这件事的后果,负有时间上的连带责任”**。
小林那个导出功能,他心里给它的预期寿命是”两天”。所以他所有的判断,都是对着”两天”做的。而它真正的寿命是”至少三年”,因为它会一直挂在商家后台上。
这两者之间的差距,就是事故。
再往深一层:租房的人和买房的人,还有一个更隐蔽的区别——租房的人不知道这房子之前发生过什么。他不知道上一个租客把什么堵进了下水道,不知道墙里那根管子是哪年换的,不知道卫生间防水做了几遍。
所以租房的人只能被动反应:漏了才修。
而买房的人会主动做一件事:搞清楚它的历史。开发商是谁、物业是哪家、这栋楼有没有沉降记录、上次大修是什么时候。
这个动作对应到工程师身上,就是那个 COUNT(*),就是去问”最大的商家有多少条订单”,就是去看过去三个月的监控曲线,就是去翻这个模块上一任 owner 留下的 issue。
ownership 不是”出问题我扛”,是”我在出问题之前,把这块地的历史搞清楚”。
落到 Go 工程师身上
给你一个可以今天就做的具体动作:
挑一个你”负责”的接口,去查四件事,写进一个文档里,哪怕只有你自己看:
- 过去 30 天,它的 QPS 峰值是多少、P99 是多少、错误率是多少。
- 它的入参,线上出现过的最大值是什么(比如一次最多传了多少个 ID、时间跨度最长是多少天)。
- 它依赖的下游有哪些,每个下游的超时时间是多少。如果它下游有三个服务,每个超时 3 秒,那你这个接口在最坏情况下要多久?
- 它上一次出故障是什么时候,原因是记在哪的。
这四件事,不会超过两个小时。
这两个小时,是你从”写过这段代码”变成”这块地归我管”的分界线。
思考题二
你有没有过这样的经历——一件事你花了很大力气做完了,过了一段时间发现,要么白做了,要么根本没人用?
回想一下那件事。从它开始到它被废弃,中间有没有出现过某个信号,是你当时看到过、但忽略了的?
那个信号是什么形状的?
参考答案
大多数人会怎么答
大部分人会想起某件事:花了一个月做的一个后台功能,上线之后没人点;或者写了一套很优雅的抽象,结果业务方向变了,全删了。
然后总结出来的教训通常是:“需求没对齐”、“应该先跟产品确认”、“应该做个 MVP 先验证”。
这些答案都对,而且都是标准答案。
这个答案为什么不够
“需求没对齐”这个说法,把责任推给了”沟通”这个抽象的词。它能让你心里好受一点,但它不能让你下次做得更好,因为”多沟通”是不可执行的。
而且它还有一个更致命的盲区:它假设只要你问了,别人就会告诉你。
真实情况是,阿 May 也不知道那个商家有十一万条订单。大刘也不知道那个导出会 OOM。产品经理、运维、甚至商家自己,都不知道。
“问清楚需求”这件事的能力上限,是所有参与者认知的总和。
所以真正的问题不是”为什么我没想到”,而是——
更深的一层是什么
小林那次事故里,最刺人的一句话是阿 May 问的:“那最多能导多少条?”
注意这个问题的形状:它不是在问”能不能导”,它是在问”边界在哪”。
我们大部分人在做事的时候,脑子里装的是”这条路径能不能走通”。而 owner 脑子里装的是”这条路的边界在哪,边界之外是什么”。
这是一个完全不同的思维方式。
你走路能不能走通,只要迈腿就知道。你要知道一条路的边界,你得走到头,撞上去。
而”白做了”这件事,本质上就是:你从来没去找过边界,所以你不知道自己早就已经在边界外面了。
回到那个被忽略的信号。它通常长这样:
- 有人说了一句”这个应该没人会用吧”,然后没人反驳。
- 你在做的时候,发现有一半的时间花在”把数据结构对齐”上,而不是花在业务逻辑上。
- 你提 MR 的时候,review 的人只留了拼写错误的评论。
- 上线之后,你发现自己不太想去看监控。
最后这一条最准。
你不太想去看监控,是因为你心里知道,那个数字不会好看,或者你根本不知道什么算好看。 这个”不想看”,就是信号本身。
落到 Go 工程师身上
给你一个具体的判断方法,叫”上线前的三句话”:
在你提 MR 之前,用三句话把这件事写下来,写在 MR 描述里:
- 上线之后,我会在哪里看到它? (哪个监控面板、哪条日志、哪个指标。如果你答不出来,说明你没有观测手段,先补这个。)
- 什么数字出现,说明它坏了? (不是”报错了”,是具体的:错误率超过 1%?P99 超过 2 秒?生成的文件数比请求数少?)
- 它坏了之后,我第一件事做什么? (关掉哪个开关、回滚哪个版本、删掉哪个目录。如果你答不出来,说明你没给自己留退路。)
这三句话,写下来大概五分钟。
这五分钟,能把”白做了”变成”做完了,而且我知道它现在是死是活”。
二、错误处理:你是不是也在到处写 if err != nil { return err } 然后假装它不存在
「我没丢啊,我 return 了」
老陈翻着小林改完的代码,翻到 queryPage 那里,手指点了一下。
“fmt.Errorf(xxx: %w, err),“他说,“你这儿为什么用 %w 不用 %v?”
“因为 %w 能保留错误链,上层可以用 errors.Is 判断。”
“你什么时候知道的这个?”
小林想了想。”……去年看一篇博客。”
“那你知道为什么吗?”
“知道啊,%w 会把原始错误包进去,形成一条链,errors.Is 会沿着链往上找。”
“那你给我举个例子,什么时候需要沿着链往上找。”
小林想了三秒钟。
“比如……判断是不是 sql.ErrNoRows。”
“还有呢?”
”……判断是不是超时?”
“还有呢?”
小林卡住了。
老陈没继续追问,他把笔记本转过来,指着另一处。
“我问你一个更实际的。“他说,“你当时那个导出的第一版,第 41 行是 rows, _ := db.Query(...)。这个下划线,你在整个项目里写了多少次?”
小林没说话。他打开 IDE,搜了一下。
”……一百四十七处。“他说。
“你知道这一百四十七处里,有多少是真的不可能失败的吗?”
”……”
“一个都没有。“老陈说,“因为没有什么是不会失败的。你写那个下划线的时候,真正的意思不是’这个不会失败’。”
“那是什么意思?”
“是**‘如果它失败了,我不知道该怎么办,所以我假装它没发生’**。”
小林有点急了。
“但是老陈,“他说,“你不可能每一个错误都处理啊。我要是每处都写,我的代码一半都是 if err != nil,根本看不了。”
“谁让你每处都写了?”
“那不处理怎么办?”
“你先想想,“老陈说,“‘处理’这个词,在你脑子里是什么意思?”
小林愣了一下。
”……打印日志?或者返回给上层?”
“所以对你来说,处理错误 = 打日志 + 往上抛。“老陈说,“那我问你,抛给谁?”
“抛给调用我的人。”
“调用你的人再抛给谁?”
”……再往上。”
“抛到最后一层呢?”
小林不说话了。
“最后一层是你的 HTTP handler。“老陈说,“handler 拿到一个 error,它要把它变成一个 HTTP 状态码和一个错误信息,返回给用户。这个时候,它需要知道:这个错误是用户的问题还是我们的问题?能不能重试?要不要告警?”
“可是它拿到的 error,“老陈说,“是一个从数据库驱动里冒出来的、没有任何上下文的字符串。它能怎么办?它只能返回 500。”
“所以线上就出现了一个很有意思的现象,“他继续说,“商家传了一个不存在的订单号,我们返回 500。我们的告警响了,大刘半夜爬起来,查了半天发现是用户传错了参数。”
小林听到这,脸有点热。
这事儿他干过。不止一次。
一张写着「肝功能异常」的体检报告
“我给你讲个体检的事。“老陈说。
“2019 年,我第一次做比较全的体检。报告出来之后,有一项是’肝功能异常’。”
“就五个字?”
“就五个字。“老陈说,“底下附了一行小字:‘建议复查’。”
他说他当时拿着那张纸,站在医院走廊里,脑子是空的。
他不知道哪一项异常。不知道高了多少。不知道严不严重。不知道是昨天喝酒喝的还是肝坏了。
“那一个礼拜,我什么都没干。“老陈说,“我请了两天假,跑了三家医院,挂了两个专家号,做了一次 B 超、一次乙肝五项、一次自免肝抗体。”
“结果呢?”
“结果是体检前一天晚上,我跟我一个老同学吃饭,喝了四两白酒。“老陈说,“谷丙转氨酶 78,参考值是 9 到 50。”
”……”
“我现在想起来还觉得荒唐。“老陈说,“那一个礼拜,如果我的报告上写的是——”
他拿过一张餐巾纸,在上面写:
谷丙转氨酶(ALT):78 U/L,参考范围 9-50 U/L,轻度升高。 常见非病理性原因:检查前 48 小时内饮酒、熬夜、剧烈运动、服用解热镇痛药。 建议:排除上述因素后,两周内复查。若两周后仍高于 50,建议消化内科就诊。
“如果报告上是这么写的,“老陈把纸推给小林,“我用得着请两天假吗?我用得着跑三家医院吗?我用得着那一个礼拜睡不着觉吗?”
小林看着那张餐巾纸,忽然明白他要说什么了。
“‘肝功能异常’就是一个丢失了上下文的错误。”
“对。“老陈说,“它是一个’值’,但它是一个没法做决策的值。”
“拿到它的人(我)有三个选择:一是把它原样上报(我老婆、我妈),让恐慌扩散;二是忽略它(假装没看见);三是自己花一个礼拜去把上下文找回来。”
“而这三个选择,“老陈说,“正好对应你在代码里的三种写法。”
他在纸上又写了三行:
1. return err -> 原样上报,恐慌扩散到最上层
2. _ = doSomething() -> 假装没看见
3. 打一堆日志,然后人肉去翻 -> 自己花一个礼拜找上下文
“正确的做法只有一种,“老陈说,“在错误发生的那个位置,把它当时知道的一切,都写进错误里。”
他顿了一下。
“我再给你讲个更狠的。”
“我们家厨房装了个烟雾报警器。装在厨房门口,炒菜的时候经常响。”
“我媳妇儿的处理方式,是拿个塑料袋把它套上。”
老陈笑了笑。
“套了三个月。有一天油锅真的起火了——那天我在书房,她在客厅,报警器没响,因为上面套着塑料袋。等我们闻到味儿跑过去,锅已经烧穿了,抽油烟机的罩子熏得全黑。”
“后来呢?”
“后来报警器是好的,我们拆下来测过。“老陈说,“它一直在正常工作。是我们让它闭嘴了。”
“我的 _ = db.Query(...),“小林说,“就是那个塑料袋。”
“对。“老陈说,“而且更糟的是,你不是套了一个,你套了一百四十七个。”
“你炒菜的时候它响,你觉得烦。你不知道的是,它响的那 147 次里,有 146 次是油烟,有 1 次是真的。”
“而你为了那 146 次的清净,把那 1 次也捂死了。“
错误处理:从「假装不存在」到「让上层能做决策」
“行了,看代码。“老陈说。
小林把他当时那段代码复原了一下,是这么写的:
// ===== 第一版:小林当时的写法 =====
package order
import "database/sql"
type Repo struct{ db *sql.DB }
// FindByNo 的问题:原始错误裸着往上冒。
// 上层拿到的是 sql.ErrNoRows 这种数据库驱动的错误,
// 既不知道是哪个订单,也没法判断该返回 404 还是 500。
func (r *Repo) FindByNo(orderNo string) (*Order, error) {
var o Order
err := r.db.QueryRow(
`SELECT id, order_no, amount, status FROM orders WHERE order_no = ?`,
orderNo,
).Scan(&o.ID, &o.OrderNo, &o.Amount, &o.Status)
if err != nil {
return nil, err // 上下文:零
}
return &o, nil
}
// GetOrder 的中间层:什么都不做,原样透传。
// 这一层存在的唯一价值,就是让错误多穿一层衣服。
func (s *Service) GetOrder(orderNo string) (*Order, error) {
o, err := s.repo.FindByNo(orderNo)
if err != nil {
log.Printf("[ERROR] get order failed: %v", err) // 每层都打,一个错误日志里出现 N 次
return nil, err
}
if o.Status == StatusClosed {
return nil, errors.New("订单已关闭") // 用字符串新建错误,上层无法判断类型
}
return o, nil
}“这个写法有个特别典型的特征,“老陈说,“我给它起了个名字,叫**‘错误搬运工’**。”
“什么意思?”
“每一层都干三件事:打一行日志、原样返回、假装自己处理过了。“老陈说,“你打开你们项目的日志系统搜一个错误,能搜出五条一模一样的,来自五个不同的文件。你根本分不清这是一个错误穿了五层,还是五个错误。”
“那第二版呢?”
“第二版是我见过最多的’改良’。“老陈说,“加了上下文,但用错了格式化动词。”
// ===== 第二版:加了上下文,但把链弄断了 =====
func (s *Service) GetOrderV2(orderNo string) (*Order, error) {
o, err := s.repo.FindByNo(orderNo)
if err != nil {
// 坑 1:用了 %v 而不是 %w。
// 字符串被拼进新错误,原始错误对象丢失,errors.Is 永远返回 false。
return nil, fmt.Errorf("查询订单 %s 失败: %v", orderNo, err)
}
return o, nil
}
// 调用方这样判断,永远进不去:
if errors.Is(err, sql.ErrNoRows) { // false!链在 %v 那里断了
return http.StatusNotFound
}“这个坑有多隐蔽?“老陈说,“隐蔽到它能过 code review,能过测试,能上线跑半年,直到有一天有人写了 errors.Is(err, sql.ErrNoRows),发现不生效,然后他以为是 errors.Is 的问题。”
“那正确的怎么写?”
“你先看三条原则。“老陈伸出手指数。
“第一,领域层定义自己的错误,不要让基础设施的错误泄露出去。 你的 service 层不该出现 database/sql,你的 handler 更不该出现。”
“第二,每一层往上抛的时候,用 %w 把你这一层知道的东西包进去。 你这一层知道订单号,就包订单号;知道商家 ID,就包商家 ID。”
“第三,只在边界记一次日志。 日志不是撒胡椒面,是最后那个能看清全局的人,负责记那一次。”
下面是按这三条改完的版本:
package order
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
)
// ============================================================
// 1. 领域错误:这一层不 import database/sql,也不 import net/http
// ============================================================
var (
// ErrOrderNotFound / ErrOrderClosed 是"哨兵错误",
// 用 errors.Is 判断,调用方不需要比较字符串。
ErrOrderNotFound = errors.New("订单不存在")
ErrOrderClosed = errors.New("订单已关闭")
ErrOrderFrozen = errors.New("订单已被风控冻结")
)
// OrderError 是"带上下文的错误类型",用 errors.As 取出。
// 它实现了 Unwrap,所以即使被 %w 包了十层,errors.As 依然能捞出来。
type OrderError struct {
Op string // 出错的动作:"query" / "export" / "refund"
OrderNo string
Merchant int64
Retryable bool // 能不能重试:网络抖动能重试,余额不足不能
Err error // 底层原因
}
func (e *OrderError) Error() string {
return fmt.Sprintf("订单 %s(商家 %d) %s 失败: %v",
e.OrderNo, e.Merchant, e.Op, e.Err)
}
func (e *OrderError) Unwrap() error { return e.Err }
// ============================================================
// 2. 仓储层:把基础设施错误"翻译"成领域错误
// ============================================================
func (r *Repo) FindByNo(ctx context.Context, orderNo string) (*Order, error) {
var o Order
err := r.db.QueryRowContext(ctx,
`SELECT id, order_no, amount, status, merchant_id
FROM orders WHERE order_no = ?`, orderNo,
).Scan(&o.ID, &o.OrderNo, &o.Amount, &o.Status, &o.Merchant)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// 翻译。上层再也不用知道 sql.ErrNoRows 的存在。
return nil, &OrderError{
Op: "query", OrderNo: orderNo, Retryable: false,
Err: ErrOrderNotFound,
}
}
// 其他错误:保留原始错误,同时补上这一层知道的信息。
// ctx.Err() 能告诉我们是超时还是被取消。
retryable := !errors.Is(err, context.Canceled)
return nil, &OrderError{
Op: "query", OrderNo: orderNo, Retryable: retryable,
Err: fmt.Errorf("db query: %w", err), // %w:保留链
}
}
return &o, nil
}
// ============================================================
// 3. 服务层:不再打日志,不再裸透传,只做该做的事
// ============================================================
func (s *Service) GetOrder(ctx context.Context, orderNo string) (*Order, error) {
o, err := s.repo.FindByNo(ctx, orderNo)
if err != nil {
// 这一层知道商家维度,可以补一层上下文。
// 注意:不打日志。日志留给最外层。
return nil, fmt.Errorf("svc.GetOrder: %w", err)
}
if o.Status == StatusClosed {
// 业务规则错误,同样用领域错误表达
return nil, &OrderError{
Op: "check", OrderNo: orderNo, Merchant: o.Merchant,
Retryable: false, Err: ErrOrderClosed,
}
}
return o, nil
}
// ============================================================
// 4. 边界层:这里是唯一记日志的地方,也是唯一做"翻译给用户"的地方
// ============================================================
func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
orderNo := r.URL.Query().Get("order_no")
o, err := h.svc.GetOrder(r.Context(), orderNo)
if err == nil {
writeJSON(w, http.StatusOK, o)
return
}
var oe *OrderError
switch {
case errors.Is(err, ErrOrderNotFound):
// 用户传错了参数:这是"用户的错",不是故障。不打 error 日志,不告警。
writeJSON(w, http.StatusNotFound, msg("订单不存在"))
case errors.Is(err, ErrOrderClosed):
writeJSON(w, http.StatusConflict, msg("订单已关闭,无法操作"))
case errors.As(err, &oe):
// 能拿到结构化上下文:日志里带上订单号和商家,可观测性直接拉满
if oe.Retryable {
h.log.Warn("order op retryable",
"op", oe.Op, "order", oe.OrderNo, "merchant", oe.Merchant, "err", err)
writeJSON(w, http.StatusServiceUnavailable, msg("服务繁忙,请稍后重试"))
} else {
h.log.Error("order op failed",
"op", oe.Op, "order", oe.OrderNo, "merchant", oe.Merchant, "err", err)
writeJSON(w, http.StatusInternalServerError, msg("服务内部错误"))
}
default:
// 兜底:未知错误一律 500,并且记日志。永远不要让用户看到原始 error 字符串。
h.log.Error("unhandled error", "err", err)
writeJSON(w, http.StatusInternalServerError, msg("服务内部错误"))
}
}“最后再补一个你一定会遇到的场景。“老陈说,“defer 里的错误。”
// 你写过多少次这个?
func saveFile(path string, data []byte) error {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("创建文件失败: %w", err)
}
defer f.Close() // Close 的错误去哪了?
_, err = f.Write(data)
return err // 而且:如果 Write 出错,文件是空的,但函数返回之前它还在磁盘上
}
// 正确的写法:用命名返回值 + errors.Join(Go 1.20+)
func saveFileOK(path string, data []byte) (err error) {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("创建文件 %s 失败: %w", path, err)
}
defer func() {
// Close 的错误不能丢(比如数据还留在页缓存里没刷盘),
// 也不能覆盖 Write 的错误,所以用 Join 把两个都留下来。
if cerr := f.Close(); cerr != nil {
err = errors.Join(err, fmt.Errorf("关闭文件 %s 失败: %w", path, cerr))
}
if err != nil {
// 写入失败就别留半个文件,不然你那个 /tmp/export 里又是一堆垃圾
_ = os.Remove(path)
}
}()
if _, err = f.Write(data); err != nil {
return fmt.Errorf("写入文件 %s 失败: %w", path, err)
}
return nil
}小林看着最后那个 os.Remove,沉默了很久。
“我那个 /tmp/export 里的八百多个文件,“他说,“就是这么来的。”
“对。“老陈说,“你那个事故里,一半的文件是因为进程被 OOM 杀掉来不及清理,另一半是因为出错的路径上没有清理。”
“你处理了’成功’那条路,没处理’失败’那条路。”
“而’失败’那条路,才是真实世界里人走得最多的那条。“
老陈的深层发问
第一问:你最近一周写的错误分支里,有多少是”原样往上抛”的?你有没有想过,你那个 return err 抛出去的,到底是”信息”还是”麻烦”?
第二问:如果线上现在有一个错误在告警,你能不能在三分钟内说出:它影响的是哪些用户、能不能重试、要不要人工介入?如果说不出来,那是错误设计的问题,还是你根本没设计过?
第三问:你的项目里有没有一处 _ = xxx()?那一刻你在想什么?如果把它补上,你打算怎么补?如果你还是打算留着,你愿意为它写一个注释,说明”为什么这里可以忽略”吗?
第四问:你打日志的时候,是在帮未来的自己,还是在撇清责任——“我记过了,出事别找我”?这两种日志,长得很像,但它们不一样。你能分出来吗?
生活思考题
思考题一
你有没有过这样的经历:别人跟你说了一件事,你听完完全不知道该怎么反应,因为你不知道这件事有多严重?
比如朋友发来一句”我出事了”,或者医生说”有点问题,再观察观察”,或者领导说”你那个方案,我们再看看”。
那一刻你是什么感受?你做了什么?
这件事,和你的错误设计,有什么关系?
参考答案
大多数人会怎么答
大部分人的第一反应是”我会焦虑”,然后会说”我会追问清楚”,或者”我会先假设最坏的情况”。
有人会总结出一个沟通原则:说话要说清楚,要给别人足够的上下文。
这个答案为什么不够
这个答案把问题归到了”表达能力”上。
但你想想,那个跟你说”我出事了”的朋友,他不是表达能力差。他是在那个时刻,他自己也不知道严不严重,或者他知道的严重程度和你想知道的”严重程度”不是一个东西。
医生跟你说”有点问题,再观察观察”,他不是不会说话。他是在信息不足的情况下,选择了一个对他自己风险最小的表达——说了不吓着你,不说又不担责。
这里真正的结构是:信息的生产者,和信息的使用者,关心的事情不一样。
医生关心的是”我有没有漏诊的风险”,你关心的是”我明天要不要请假”。
更深的一层是什么
把这件事映射到代码上:
你的 repo 层是”医生”,你的 handler 层是”病人”。
repo 层抛出一个 sql.ErrNoRows,它关心的是”我把数据库返回的东西如实告诉你了,我没撒谎”。
handler 层收到这个错误,它关心的是”我该返回 404 还是 500”。
这两件事之间,隔着一整套翻译工作。而大部分人(包括小林)默认这个翻译是自动的。它不是。
这带来一个反直觉的结论:
一个错误的价值,不在于它有多准确,而在于它能不能让拿到它的人立刻做出决策。
sql.ErrNoRows 是准确的,但它不能让 handler 做出决策。ErrOrderNotFound 不一定更”准确”,但它能让 handler 立刻返回 404。
再往深一层:
你有没有注意过,我们最受不了的不是坏消息,是”不知道该怎么反应的消息”。
“这个功能挂了”——坏消息,但你能反应:去修。 “这个功能的 P99 有点高”——你没法反应。多高算高?跟什么比?要不要现在处理?
所以”说清楚”这三个字,真正的含义不是”多说几句”,而是——
在你说出这条信息的时候,把”接收方接下来该做什么”也一并给他。
一句”我出事了”,如果变成”我出事了,你今晚八点能来一趟医院吗”,你的焦虑会瞬间消失,因为你有了动作。
一个 ErrOrderNotFound,如果带上”这是用户传错了参数,不需要告警,返回 404”,handler 就有了动作。
落到 Go 工程师身上
给你一个今天就能做的检查,五分钟:
打开你最近写的一个模块,找出所有 return err 的地方,对每一个问自己三个问题:
- 拿到这个错误的人,能判断它是”用户的错”还是”我们的错”吗? 如果不能,在这一层补一个领域错误。
- 这个错误信息里,有没有包含”定位问题需要的最小信息”? 订单号、用户 ID、商家 ID、操作名。少一个,半夜排查就多花十分钟。
- 这一层是不是既打了日志又往上抛了? 如果是,把日志删掉。只在你能看见全局的那一层记一次。
再给你一个判断标准:
如果你写的错误信息,需要你去翻日志才能知道发生了什么,那这个错误信息就是失败的。
好的错误信息应该长这样:
订单 EXP20230411008891(商家 10247) export 失败: 查询订单分页失败: db query: dial tcp 10.0.3.17:3306: i/o timeout
看到这一行,你不用翻任何日志,就能知道:哪个订单、哪个商家、哪个动作、哪一层、什么原因。
这就是那张写着”谷丙转氨酶 78,参考值 9-50”的体检报告。
思考题二
你家里有没有什么东西,是”一直在报警但你习惯了”的?
烟雾报警器、汽车的某个故障灯、冰箱的异响、手机的低电量提醒、体检报告上年年偏高但你从来没管过的那个箭头。
你是怎么让它们闭嘴的?
参考答案
大多数人会怎么答
大部分人会举几个例子:汽车胎压灯亮了半年没去查、手机天天提示存储不足、体检报告上尿酸年年偏高。
然后总结:人会对持续存在的警告脱敏,这是正常现象,重要的是要定期处理。
这个答案为什么不够
“定期处理”这个建议,暗含一个假设:这些警告是”可以攒着处理”的。
但你仔细看这几个例子,它们其实分成两类:
- 汽车的胎压灯:它亮着,说明现在就有一个轮胎气压不对。它是”状态的报警”。
- 体检报告上的尿酸偏高:它高着,说明你的身体正在发生某种变化。它是”趋势的报警”。
这两类的处理方式完全不同,但我们的大脑用同一套机制对待它们:习惯了就不响了。
更深的盲区在这里:
你让它们闭嘴的方式,不是”处理了问题”,而是”降低了自己的敏感度”。
这个动作在心理学上叫”习惯化”,它是有生理基础的——你的大脑会自动过滤掉持续的、无变化的刺激,这是大脑节能的方式。
这意味着:你和报警器的斗争,从一开始就不对等。你的大脑天生站在”关掉它”这一边。
更深的一层是什么
把这个结构搬到代码上:
_ = db.Query(...) 就是那个塑料袋。
但更有意思的是另外两种”闭嘴方式”:
第一种是”调高阈值”。 报警从”P99 > 500ms”改成”P99 > 2s”,告警立刻安静了。问题没解决,你只是改了”什么叫问题”。
第二种是”把报警转到不常看的渠道”。 从短信改成邮件,从邮件改成群消息,从群消息改成一个没人订阅的日报。
这两种方式,比 _ = err 隐蔽一百倍,因为它们看起来像”优化”。
而它们共同的本质是:
我们在优化”自己的感受”,而不是在优化”系统的状态”。
这才是真正要命的地方。因为一个工程师的职业价值,恰恰在于他能在系统还”感觉良好”的时候就发现问题。
一旦你开始优化自己的感受,你就从”owner”退化成了”使用者”。
使用者的目标是什么?是”别烦我”。 owner 的目标是什么?是”这块地今年收成怎么样”。
落到 Go 工程师身上
给你一个具体的动作,这个动作有点反直觉,但很有用:
连续一周,每天花五分钟,去看一个你平时不看的报警渠道。
比如:你们的错误日志里 error 级别的 top 10、慢查询日志的前 20 条、k8s 的重启记录。
不要试图修,就看。看一周。
你会看到三种东西:
- 一直在响但没人管的——这就是你家的烟雾报警器。你要做的不是关掉它,是问一句”它为什么一直在响”。
- 偶尔响一下,但你从来没看过的——这是最危险的,因为它可能是真问题,只是还没到爆发的量级。
- 你根本看不懂的——这说明这个报警是给”理想中的运维”看的,不是给你的。要么改,要么删。
然后,从第一类中挑一个,把它查清楚,用一页文档写下来:它为什么一直在响,它是真问题还是噪音,如果是噪音,为什么当初会加这个报警。
这一页文档,是你从”写代码的人”变成”管这块地的人”的第一份作业。
因为owner 不是那个写代码最快的人,是那个最清楚”这块地上现在有几处不对劲”的人。
三、goroutine 泄漏:你启动的那些 goroutine,后来都去哪了
「后来我把它改成异步了」
“我还没说完。“老陈说,“你那个导出,第二天之后怎么改的?”
小林翻出另一个文件。
“改成异步了。“他说,“前端点一下,我返回一个 taskID,后台起一个 goroutine 跑,前端每两秒轮询一次状态,跑完了给下载链接。”
“为什么改成异步?”
“因为同步的话,十一万行要十几秒,网关 60 秒超时倒是不至于,但前端一直转圈,体验不好。阿 May 说要加个进度条。”
“所以你起了 goroutine。“老陈说,“我问你一个问题。”
“您说。”
“那些 goroutine,后来都去哪了?”
小林看着他,没反应过来。
“什么叫去哪了?”
“就是你起的那个 goroutine,它跑完了之后去哪了?没跑完的呢?用户点完就把页面关了的呢?”
”……跑完了就结束了啊。”
“你怎么知道它结束了?”
”……”
老陈把笔记本转过来,指着屏幕。
“你起的那 200 多个 goroutine,在那天晚上服务被 OOM 杀掉之前,全都在。“他说,“你知道它们手里攥着什么吗?“
「goroutine 很轻啊」
小林这个时候是有点不服的。
“老陈,goroutine 才几 KB 栈,“他说,“我们服务上万个 goroutine 都正常跑。多两百个应该不至于吧。”
“goroutine 本身是轻的。“老陈说,“问题是它手里攥着的东西不轻。”
他拿过那张餐巾纸,在背面画了个小人。
“你想象一个 goroutine 是一个快递员。“他说,“快递员本人体重五十公斤,不重。但他背上背着一个包裹,手里还牵着一根绳,绳子另一头拴着一辆他正在卸货的货车。”
“你起了一百个快递员,他们都没回来。你算的是一百乘以五十公斤,觉得没事。”
“但你没算那一百辆货车。”
小林没说话。
“你那个导出 goroutine 手里攥着什么,我给你数一下。“老陈伸出手指。
“第一,一个数据库连接。你们连接池二十个,两百个 goroutine 排队等着拿连接,剩下的业务请求也在排队。”
“第二,一个 f,就是那个 excelize 的 File 对象。你在 goroutine 里创建的,还没 SaveAs,它整个在堆上。十一万行,这是多少?”
”……几百 MB。”
“对。第三,“老陈说,“一个没有读者、或者没有写者的 channel。你那个代码里有没有 channel?”
小林翻了一下。“有,起了一个 out chan string,用来把结果传回去。”
“缓冲多大?”
”……没设。”
“无缓冲。“老陈点点头,“那我问你,如果调用方超时走了,没人读这个 channel,那个 goroutine 会怎么样?”
小林想了一下,脸色变了。
”……会永远卡在 out <- path 那一行。”
“永远。“老陈说,“不是卡一会儿,是到进程死为止。”
“而它攥着的那几百 MB,GC 回收不了。因为从 GC 的角度看,这个 goroutine 还活着,它引用的东西全是可达的。”
“你以为它是垃圾,GC 以为它还在忙。“
楼道里那盏常亮的灯
“我给你讲个楼道灯的事。“老陈说。
“我以前住的那个小区,是九十年代末的楼,六层,没电梯。楼道灯是声控的,灯泡是那种白炽灯泡,四十瓦。”
“声控开关这东西有个特点:它坏了的时候,不是不亮,是一直亮。”
他说,大概 2013 年或者 2014 年,他们那栋楼四楼的那盏灯,常亮了很长一段时间。
“有多长?”
“我不确定。半年?大半年?“老陈说,“我记得的是,我每天上下班经过四楼,那盏灯都亮着。我从没停下来看过它一眼。”
“没人管吗?”
“没人管。“老陈说,“你想想那个场景。你下班回家,走到四楼,灯是亮的,你的反应是什么?”
”……没反应。”
“对,没反应。你会觉得’哦,灯亮着’,然后就上楼了。你甚至会有点高兴——今天不用跺脚了。”
老陈说,那栋楼一年多以后做了一次公共电费的公示。他们那个门洞,那个月的电费比旁边门洞多了两百三十多块。
“然后才有人去查。“他说,“查出来是四楼那盏灯。四十瓦,一天二十四小时,大半年。”
“多少钱?”
“算下来一百多块。“老陈说,“钱不多。”
“我真正想说的不是钱。“他继续,“我想说的是那半年里,每一个经过那盏灯的人,都看见它了。”
“我也看见了。我看见了不下两百次。”
“但我从没觉得它是’我的事’。”
小林听到这,低下头看着自己的手。
“你那个服务上,现在有几盏这样的灯?“老陈问。
”……”
“你不用现在回答我。“老陈说,“但我要你注意一件事。”
“那盏灯最可怕的地方,不是它一直亮着。”
“是它一直亮着,而且你看习惯了,以至于它熄灭的那天你都不会发现。”
“什么叫熄灭?”
“服务重启。“老陈说,“你们的服务每次发版重启一次,所有泄漏的 goroutine 全部归零,内存曲线掉下来,一切正常。”
“所以你在监控上看内存,看到的是一条锯齿:慢慢涨,重启,掉下来,再慢慢涨。”
“只要你发版够勤,你可能永远看不到它涨到顶的样子。”
“直到有一天,你们两周没发版。”
小林猛地抬起头。
那天是 4 月 11 号。他们的上一个版本是 3 月 28 号。
两周。
从「起了不管」到「有始有终」
“行,我们还是看代码。“老陈说。
小林把他那个异步版本的核心逻辑复原了一下:
// ===== 第一版:小林的异步导出(问题版本)=====
package export
import (
"log"
"sync"
"time"
)
// 全局 task 表:并发读写,没有加锁。
// 在 Go 里这不是"数据竞争警告",这是 fatal error: concurrent map writes,进程直接崩。
var tasks = map[string]*Task{}
type Task struct {
ID string
Status string // 多个 goroutine 同时读写,而且没有同步
Path string
}
var wg sync.WaitGroup // 只 Add 过,从没 Wait 过,等于没加
func SubmitExport(merchantID int64, start, end time.Time) string {
id := fmt.Sprintf("%d-%d", merchantID, time.Now().UnixNano())
tasks[id] = &Task{ID: id, Status: "running"} // 坑 1:无锁写 map
wg.Add(1)
go func() {
defer wg.Done()
path, err := ExportOrders(merchantID, start, end)
if err != nil {
log.Println("export failed:", err)
return // 坑 2:出错直接 return,Status 永远是 "running",前端一直轮询下去
}
tasks[id].Status = "done" // 坑 3:无锁写
tasks[id].Path = path
// 坑 4:任务永远留在 map 里,没人删 -> 内存里的 tasks 表无限增长
}()
return id
}“我给你数一下这里面的问题。“老陈说,“你自己看能数出几个。”
小林看着屏幕,一个一个数。
“第一,全局 map 没加锁,并发读写会崩。第二,出错的时候状态不更新,前端会一直轮询。第三,task 永不删除,内存泄漏。第四,wg 加了没用。”
“还有三个。“老陈说。
”……”
“第五,没有任何取消机制。用户点完导出,发现要等十几秒,把页面关了。那个 goroutine 不知道,它还在那跑,跑到 SaveAs 结束,然后往一个没人读的 channel 里写,然后卡死。”
“第六,没有并发上限。用户连点五次,就起五个 goroutine。如果有一百个商家同时导出呢?”
“第七,“老陈说,“没有去重。同一个商家连点五次,生成五个一模一样的文件。你那个 /tmp/export 里的八百多个文件,有一半是这么来的。”
小林闭上眼睛。
“这七个问题,“老陈说,“你猜它们的根因是什么?”
“是我没考虑并发?”
“不是。“老陈说,“根因是——”
“你把 go 这个关键字,理解成了’让它快点’,而不是’把一份责任交出去’。”
“go func(){} 这五个字符,写起来最省事。但它真正的含义是:我在创建一个生命周期,而这个生命周期的结束条件,我必须自己负责定义。”
“你定义了它什么时候开始,没定义它什么时候结束。”
“这就是泄漏。”
第二版:加了 channel,制造了另一种泄漏
“还有一种是更高级的错误。“老陈说,“我看过很多人’修’完上面的问题,写成这样:”
// ===== 第二版:加了 channel,反而更隐蔽 =====
func ExportWithChan(merchantID int64) <-chan string {
out := make(chan string) // 坑:无缓冲
go func() {
path, err := doExport(merchantID)
if err != nil {
return // 坑 A:直接 return,out 永远没人写,接收方永久阻塞
}
out <- path // 坑 B:如果接收方已经超时走了,这里永久阻塞
}()
return out
}
// 调用方
func handleExport(w http.ResponseWriter, r *http.Request) {
select {
case path := <-ExportWithChan(1):
writeJSON(w, 200, path)
case <-time.After(3 * time.Second):
// 你以为你超时返回了,就没事了。
// 但那边那个 goroutine 还卡在 out <- path 上,它手里还攥着几百 MB。
writeJSON(w, 504, "timeout")
}
}“这个坑隐蔽在哪?“老陈说,“隐蔽在它看起来很 Go。”
“用了 channel,用了 select,用了超时。这段代码能过 review,能过测试,能在低压力下跑半年不出问题。”
“然后某天流量上来一点,超时变多了,goroutine 数就一路上去了。”
第三版:有取消、有边界、有关闭、有去重
“下面是正确的写法。“老陈说,“你看完之后,告诉我哪几行是关键的。”
package export
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
)
const (
StateRunning int32 = 0
StateDone int32 = 1
StateFailed int32 = 2
)
const (
maxConcurrent = 4 // 同时最多 4 个导出在跑
taskTimeout = 5 * time.Minute // 单个任务硬超时
)
type ExportTask struct {
ID string
State int32 // 用 atomic 读写,不用锁
Path string
ErrMsg string
Done chan struct{} // 用 close 广播,而不是发值
}
// ExportManager 管住所有导出任务的生命周期
type ExportManager struct {
mu sync.Mutex
tasks map[string]*ExportTask
byKey map[string]string // 幂等键 -> taskID,用于防重复点击
sem chan struct{} // 有界并发的信号量
}
func NewExportManager() *ExportManager {
return &ExportManager{
tasks: make(map[string]*ExportTask),
byKey: make(map[string]string),
sem: make(chan struct{}, maxConcurrent),
}
}
// Submit 提交一个导出任务。
// key 是幂等键(比如 "merchant:10247"),同一个 key 重复提交只会跑一个。
func (m *ExportManager) Submit(ctx context.Context, key string,
run func(ctx context.Context) (string, error)) (string, bool, error) {
m.mu.Lock()
// 1. 去重:已经在跑的任务,直接返回原来的 taskID
if id, ok := m.byKey[key]; ok {
if t, ok := m.tasks[id]; ok && atomic.LoadInt32(&t.State) == StateRunning {
m.mu.Unlock()
return id, false, nil // false 表示"没有新建,复用了已有的"
}
}
t := &ExportTask{
ID: fmt.Sprintf("%s-%d", key, time.Now().UnixNano()),
Done: make(chan struct{}),
}
atomic.StoreInt32(&t.State, StateRunning)
m.tasks[t.ID] = t
m.byKey[key] = t.ID
m.mu.Unlock()
go func() {
// 2. 信号量:先抢名额,抢不到就等;等服务被取消就直接退出。
// 这一步保证内存和数据库连接永远不会被导出任务吃光。
select {
case m.sem <- struct{}{}:
defer func() { <-m.sem }() // 释放名额
case <-ctx.Done():
m.finish(t, "", ctx.Err())
return
}
// 3. 任务自己的超时。注意这里用的是独立 context:
// 用户关了页面,不该让已经跑了 90% 的任务白跑;
// 但也不能让它无限跑下去。
taskCtx, cancel := context.WithTimeout(context.Background(), taskTimeout)
defer cancel()
path, err := run(taskCtx)
m.finish(t, path, err)
}()
return t.ID, true, nil
}
func (m *ExportManager) finish(t *ExportTask, path string, err error) {
if err != nil {
atomic.StoreInt32(&t.State, StateFailed)
t.ErrMsg = err.Error()
} else {
atomic.StoreInt32(&t.State, StateDone)
t.Path = path
}
// 4. close 而不是 send:所有等待者同时被唤醒,且不会有"漏发"的问题
close(t.Done)
}
// Wait 等待任务完成。调用方(HTTP 请求)取消时立即返回,
// 但任务本身继续跑——这是设计选择,不是 bug。
func (m *ExportManager) Wait(ctx context.Context, id string) (*ExportTask, error) {
m.mu.Lock()
t, ok := m.tasks[id]
m.mu.Unlock()
if !ok {
return nil, fmt.Errorf("任务 %s 不存在", id)
}
select {
case <-t.Done:
return t, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
// GC 定期清理已结束的任务,避免 tasks 表无限增长
func (m *ExportManager) GC(olderThan time.Duration) {
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now()
for id, t := range m.tasks {
s := atomic.LoadInt32(&t.State)
if s == StateRunning {
continue
}
if now.Sub(t.finishedAt) > olderThan {
delete(m.tasks, id)
for k, v := range m.byKey {
if v == id {
delete(m.byKey, k)
}
}
}
}
}“我看出来了。“小林说,“关键的是那个 select。”
“哪个 select?”
“case m.sem <- struct{}{} 和 case <-ctx.Done() 那个。“小林说,“goroutine 在等资源的时候,也在听取消信号。”
“对。“老陈说,“这是 goroutine 能不能’被杀掉’的唯一方式。”
“我再问你,为什么 Done 用 close 不用 send?”
小林想了想。“因为……如果有十个人在等这个任务,send 只能唤醒一个。”
“对,而且更重要的,“老陈说,“send 这件事本身可能阻塞。close 永远不阻塞。”
“close(t.Done) 这一行,是这段代码的’句号’。你起的每一个 goroutine,都要有一个句号。”
怎么证明你没有泄漏
“最后给你一个东西。“老陈说,“你怎么证明你的代码没有 goroutine 泄漏?”
“跑 pprof 看?”
“那是事后。我说的是事前——在测试里就拦住它。”
package export
import (
"context"
"runtime"
"testing"
"time"
)
// 方法一:最朴素,运行时对比 goroutine 数量
func TestSubmitNoLeak(t *testing.T) {
// 先让运行时稳定下来(前一轮测试的 goroutine 可能还在退出)
time.Sleep(50 * time.Millisecond)
before := runtime.NumGoroutine()
m := NewExportManager()
ctx, cancel := context.WithCancel(context.Background())
// 提交一个会一直阻塞、直到 ctx 被取消的任务
_, _, err := m.Submit(ctx, "m:1", func(ctx context.Context) (string, error) {
<-ctx.Done()
return "", ctx.Err()
})
if err != nil {
t.Fatal(err)
}
cancel() // 现在触发取消
// 给 goroutine 一点时间退出。生产代码里不该靠 sleep,测试里可以。
time.Sleep(200 * time.Millisecond)
after := runtime.NumGoroutine()
if after > before+1 {
t.Fatalf("疑似 goroutine 泄漏: before=%d after=%d", before, after)
}
}
// 方法二:用 goleak,在整个包的测试结束时统一检查,更严格也更省事
// go get go.uber.org/goleak
//
// func TestMain(m *testing.M) {
// goleak.VerifyTestMain(m) // 任何没退出的 goroutine 都会让测试失败
// }“这个测试有一个前提,“老陈说,“你的 goroutine 必须能被取消。”
“如果你的 goroutine 里没有任何一个 select { case <-ctx.Done(): },那这个测试永远通过不了,因为根本取消不掉。”
“所以这个测试真正测的不是’有没有泄漏’,“小林说,“是’你的 goroutine 有没有退出机制’。”
老陈笑了。
“你看,你自己其实知道。“
老陈的深层发问
第一问:你最近一次写 go func() { ... }() 的时候,有没有同时写下它在什么条件下会退出?如果写不出来,你能不能说清楚,你凭什么认为它会退出?
第二问:你负责的服务,如果连续两周不发版,内存曲线会是什么形状?你见过它的形状吗,还是你只见过重启之后的样子?
第三问:你有没有过这样的时刻——你知道某个地方”应该”有问题,但你没去查,因为”它现在没出事”?那个地方现在还在吗?
第四问:goroutine 退出需要有人通知它。你生活里有没有一件事,是你在等一个永远不会来的通知,所以一直没结束的?
生活思考题
思考题一
你有没有过”交代给别人一件事,但从来没有问过结果”的经历?
比如让装修师傅顺便改个插座、让朋友帮你带个东西、给某个部门发了一封邮件、在群里 @ 了一个人。
那件事最后是怎么收场的?你是什么时候想起来的?
参考答案
大多数人会怎么答
大部分人会举出具体的例子,然后总结出两个教训:一是”要跟进”,二是”要找靠谱的人”。
也有人会说:这种事最后往往是不了了之,想起来的时候已经晚了,或者干脆就忘了。
这个答案为什么不够
“要跟进”这三个字,听起来是解决方案,实际上是把责任推给了你的记忆力。
你仔细想想,你为什么没跟进?
不是因为你懒。是因为**“跟进”这件事,没有一个触发点**。
你不会在周三下午三点忽然想起”哦,我应该问一下那个插座”。你会想起它,是在某个特定的时刻——你搬进新家,第一次想在那个位置插吸尘器的时候。
也就是说:没跟进的事,只有在它”暴露”的时候才会被想起。
而它暴露的时候,往往已经晚了。
更深一层的盲区是:
我们以为”交代了”就等于”委托了”,但委托和责任是两回事。
你把一件事交代给装修师傅,责任的主体从”我要改插座”变成了”我要确认插座改好了”。责任没有转移,只是换了个形式。
更深的一层是什么
这件事在代码里的对应物,精确得可怕:
go func() { ... }() 就是”交代了一件事”。
没有 ctx、没有 Done channel、没有 WaitGroup 的 goroutine,就是”交代了但没有任何回收机制的事”。
而 goroutine 泄漏的可怕之处,和”忘了问结果的插座”完全一样:
它不会立刻爆,它会在你最不期望的时候暴露。
更精确地说,这两件事共享同一个结构:
发起一个异步动作
↓
没有定义"完成"的判定标准
↓
没有定义"完成"的通知方式
↓
没有定义"没完成"的处理方式
↓
资源被无限期占用,直到系统崩溃 / 直到你搬进新家
这四步里,绝大部分人只做了第一步。
为什么会这样?
因为第一步是”做事”,后面三步是”管理”。而”做事”有即时反馈,“管理”没有。
你写下 go func(){} 的那一刻,代码就跑起来了,你有成就感。
你定义一个 Done channel,什么都不会发生,你没有任何成就感。
所以这不是能力问题,是反馈结构问题。
落到 Go 工程师身上
给你一个可以立刻用的习惯,我叫它”goroutine 四问”。
每次你准备写 go 这个关键字之前,停下来,在心里回答四个问题:
- 它什么时候结束? (明确的终止条件,不能是”跑完就结束了”——如果它卡在 IO 上呢?)
- 谁来通知它结束? (ctx?关闭的 channel?还是它自己跑完?)
- 它结束的时候,谁会知道? (Done channel?WaitGroup?还是没人知道?)
- 它不结束的时候,会不会拖垮别人? (它攥着连接吗?攥着大 buffer 吗?能不能限制同时存在的数量?)
这四个问题,回答一遍大概十秒钟。
这十秒钟,能帮你省掉一个通宵。
再给你一个具体的检查动作:
打开你的服务,找一天流量最低的时间,看两个数字:goroutine 数量和内存占用。然后看发版记录,算一下距离上次发版多久了。
如果 goroutine 数随”距上次发版的时间”单调增长,你就有泄漏。 不需要 pprof,这一眼就能看出来。
思考题二
你家里有没有什么东西,是”一直开着但你习惯了”的?
楼道里常亮的灯、一直滴水的龙头、常年待机的机顶盒、冰箱里过期的酱料、开了但从来不看的会员自动续费。
你是怎么”处理”它们的?是真的处理了,还是只是让自己不再注意到它们?
参考答案
大多数人会怎么答
大部分人会承认:大部分是”习惯了”,少数是真的修了。
然后会总结:人会对持续存在的干扰脱敏,这是本能,解决办法是”定期检视”。
这个答案为什么不够
“定期检视”这个建议有个致命的问题:它假设你的”定期”比问题的”积累速度”更快。
但真正的问题是:你根本不知道积累速度是多少。
楼道灯一天 0.96 度电,你一个月看一次电表,能看出来吗?如果那栋楼还有别的用电,你看不出来。
这就引出一个更根本的盲区:
我们对”渐变”是天然免疫的。
人类感知系统的设计,是为了检测”变化”,不是为了检测”状态”。你的眼睛能在黑暗中看见一个移动的光点,但看不见一个缓慢变亮的背景。
这就是为什么内存泄漏能潜伏半年,为什么技术债能积累到不可收拾,为什么你的身体能”突然”查出一个大问题。
不是突然的,只是你感知不到渐变。
更深的一层是什么
这里有一个非常反直觉的洞察:
“习惯了”不是一个心理缺陷,它是一个认知机制。而这个机制,是可以被利用的——利用它的人是你自己,受害的也是你自己。
什么意思?
你有没有注意过,你对”坏状态”的脱敏,是选择性的?
- 楼道灯亮着,你脱敏了。
- 但如果你家上个月电费是 80,这个月是 310,你立刻就敏了。
也就是说:你不是对”状态”脱敏,你是对”没有参照物的状态”脱敏。
一旦有了参照物——旁边的门洞、上个月的电费单、另一个服务的 goroutine 数——你立刻就能看见问题。
这就给出了一个非常具体的解法:
不要试图”更细心”,要给自己造参照物。
落到 Go 工程师身上
给你三个具体的动作,按成本从低到高:
一、给每个你自己负责的服务,做一个”基线快照”。
不是告警,是快照。找一个业务低峰的时间(比如周二下午三点),把这几个数字抄下来,写在服务 README 的最上面:
- goroutine 数量
- 内存 RSS
- 常驻堆对象数
- 连接池使用率
- 过去一小时的错误数
每个月更新一次,并且保留历史。
三个月之后你会拥有一张表。这张表比任何告警都值钱,因为它能告诉你”渐变”的速度。
二、把”同比”和”环比”引入你的日常。
大部分人的监控看的是绝对值:“goroutine 2000,正常吗?”
owner 看的是相对值:“goroutine 2000,上周同期是 800,涨了 1.5 倍,为什么?”
绝对值让你安心,相对值让你警觉。
三、最重要的一条:给你的代码找一个”参照物”。
这条最反直觉,但最有效:
定期去看别人的代码,尤其是比你写得好的代码。
不是为了学技巧,是为了重新校准你的”正常值”。
你看自己的代码,永远觉得”还行”,因为你和一个东西待久了,你会把它当背景。
你去读一段 Go 标准库的代码,或者读一个你尊重的开源项目的代码,你会忽然意识到:哦,原来错误处理可以是这个样子的,原来一个函数可以这么短。
人的”正常值”是被最近接触的东西定义的。你接触什么,你就以什么为标准。
这就是那盏楼道灯的解法:不是让你每天都盯着它看,是让你定期去看一眼”不亮的灯”长什么样。
四、接口是抽象的,但过早抽象比不抽象更糟
Kevin 只问了一句话
小林在改那个导出功能的时候,还干了一件事。
他把导出逻辑抽成了一个接口。
“因为我想着,“他跟老陈解释,“以后肯定要支持 CSV 的,说不定还要支持 PDF。所以我先抽个 Exporter 接口出来,现在只有 Excel 一个实现,以后加别的很方便。”
老陈”嗯”了一声。
“MR 是谁 review 的?”
“Kevin。”
老陈笑了。“他说什么了?”
小林的表情有点微妙。
“他就在那个文件上留了一条评论。“小林说,“就一句:‘这个接口有几种实现?’”
“你怎么回的?”
“我回’现在一种,后面会加’。“小林说,“然后他把那条评论 resolve 了,没再说别的。”
“你当时心里怎么想的?”
小林沉默了两秒。
“我觉得他在装。“他说。
老陈没笑。
“说实话,没事。“老陈说,“我们接着往下聊。你给我看一下那个接口。”
小林把代码翻出来:
type Exporter interface {
Export(orders []*Order) ([]byte, error)
SetHeader(header []string)
SetStyle(style *Style)
Name() string
MimeType() string
FileExt() string
}“六个方法。“老陈数了一下。
“对。”
“现在有几种实现?”
“一种。”
“那这六个方法,是谁定义的?”
”……我定义的。”
“你根据什么定义的?”
小林张了张嘴。
“根据 Excel 能做的事。“他说。
“也就是说,“老陈说,“你先有了一个 Excel 的实现,然后照着它的样子,反过来定义了一个接口,然后让这个实现去满足这个接口。”
”……对。”
“那你告诉我,这个接口带来了什么?”
小林想了很久。
”……以后扩展方便。”
“你把这句话再念一遍。”
“以后扩展方便。”
“以后。“老陈重复了一遍,“你为了一个’以后’,付出了什么,你知道吗?”
小林摇头。
“我给你数。”
老陈伸出手,一根一根数。
“第一,你写了这个接口,六个方法。每加一种格式,你要实现六个方法,不管你用不用得上。你那个 CSV 实现,真的需要 SetStyle 吗?”
”……不需要。”
“第二,你要写测试。你要给这个接口写一个 mock,mock 里要实现六个方法,其中五个是空实现。”
“第三,“老陈说,“也是最贵的一条——这个接口的签名,是你照着 Excel 猜的。”
“等 CSV 真的来的时候,你会发现你的猜测错了一半。”
“比如什么?”
“Export(orders []*Order) ([]byte, error)。“老陈说,“CSV 需要一次性拿到所有订单吗?不需要,CSV 是一行一行写文本的,它天生就是流式的。但你的接口强迫它先等所有数据到齐。”
“也就是说,你这个’为了以后方便’的接口,等’以后’真的来了,反而成了障碍。”
小林坐在那儿,一句话都说不出来。
那台九合一切菜器,和十三个没穿线的网线面板
“我给你讲两个我自己交过的学费。“老陈说。
“第一个是切菜器。”
“2016 年双十一,我在京东上买了个厨房神器。九合一多功能切菜器,一百九十九块。”
“它能干嘛?”
“能切片、切丝、切条、切块、切花片、磨蓉、打蛋、榨汁、还有一个我没搞明白是干嘛的。“老陈说,“九个刀头,一个塑料盒子装着,还送了一个专用的刷子。”
“用了几次?”
“三次。“老陈说,“第一次是拆箱那天,我切了一整个土豆,试了五个刀头。第二次是周末,切了根黄瓜。第三次是我妈来,我给她演示了一下。”
“然后就没用过了?”
“然后就没用过了。“老陈说,“我给你算一笔账。”
“每用一次,你要做几件事:从橱柜最里面那层把盒子搬出来,找到你要的那个刀头,装上,切,然后把这玩意儿拆成七个零件,一个一个洗,洗完晾干,装回盒子里,放回橱柜最里面那层。”
“总共多久?”
“切两分钟,洗十分钟,收两分钟。“老陈说,“而我用刀切,切五分钟,洗十秒钟。”
“它现在在哪?”
“还在橱柜最里面那层。“老陈说,“前年我搬家,我媳妇儿要扔,我说别扔,万一用得上。”
老陈自己也笑了。
“你猜那九个刀头,我拆了几个?”
”……”
“一个。切片那个。“老陈说,“另外八个到现在还是塑封的。”
“我付了一百九十九块,买了一个切片器,外加八个我没拆过的塑料片,和一个我每打开一次就会感到愧疚的盒子。”
小林笑了。
“第二个,“老陈说,“是我家装修的时候,预留的网线面板。”
“我那会儿想得特别远。我说,现在都无线了,但万一以后不行呢?网速越来越快,无线哪有有线稳定。所以我在每个房间都留了网口,客厅两个,主卧两个,次卧两个,书房四个,餐厅一个,玄关一个,还有两个给有线电视。”
“一共多少个?”
“十四个面板。“老陈说,“材料和人工,两千八。”
“用到几个?”
“一个。“老陈说,“客厅那个,接路由器。”
”……”
“最气的是后面这件事。“老陈说,“住了大概三年,我想在书房接个有线,因为书房信号不好。我把面板拆开,想接根网线。”
“里面没线。”
“里面是空的。“老陈说,“装修队只穿了客厅那一根,其他十三个面板,就是在墙上钉了个塑料盒子,后面是一段空管。”
“你找他们了吗?”
“找了,人家说,您当时只让我们穿客厅那根啊,别的都是’预留’。”
“预留。”
老陈把这两个字又念了一遍。
“小林,你那个 Exporter 接口,就是那十三个网线面板。”
“我……”
“你不是在’预留扩展’,“老陈说,“你是在花两千八百块,买一段空管。”
“而且更糟的是,“他继续,“三年之后你真的需要在书房上网的时候,你拆开面板,发现里面没线。你不但白花了钱,你还误以为自己已经准备好了。”
“‘没预留’的人,至少知道自己要重新开槽。‘假预留’的人,会一直以为自己只要接根线就行。“
「不要设计接口,去发现接口」
“我们看代码。“老陈说。
“Go 的社区里有一句话,是 Rob Pike 说的,“他说,“Don’t design with interfaces, discover them.”
“不要’用’接口来设计,要’发现’接口。”
“什么意思?“小林问。
“意思是:接口不是你提前规划出来的,是你在写了两三个具体实现之后,从它们身上’长’出来的。”
“你先写 Excel 导出,再写 CSV 导出,写到一半你会发现,这两段代码里有一段是一模一样的——都是’按页取数据,然后把一行写出去’。”
“这一模一样的那一段,才是接口。”
“而不是你脑补的’导出器应该长什么样’。”
第一版:小林的”预留式”接口
// ===== 第一版:为了"以后",提前抽一个大接口 =====
package export
// Exporter 是"我猜的"接口:六个方法,照着 Excel 的样子定出来的。
type Exporter interface {
Export(orders []*Order) ([]byte, error) // 强迫所有实现都等全量数据到齐
SetHeader(header []string)
SetStyle(style *Style) // CSV 根本不需要,但被迫要实现
Name() string
MimeType() string
FileExt() string
}
type ExcelExporter struct { /* ... */ }
func (e *ExcelExporter) Export(orders []*Order) ([]byte, error) { /* ... */ }
func (e *ExcelExporter) SetHeader(h []string) { /* ... */ }
func (e *ExcelExporter) SetStyle(s *Style) { /* ... */ }
func (e *ExcelExporter) Name() string { return "excel" }
func (e *ExcelExporter) MimeType() string { return "application/vnd..." }
func (e *ExcelExporter) FileExt() string { return ".xlsx" }
// 噩梦从这里开始:为了"以后"提前写的 CSV 实现,从上线到删掉,一次都没被调用过。
type CSVExporter struct { /* ... */ }
func (c *CSVExporter) Export(orders []*Order) ([]byte, error) { /* 流式写,但被迫先收全量 */ }
func (c *CSVExporter) SetHeader(h []string) { /* ... */ }
func (c *CSVExporter) SetStyle(s *Style) { /* 空实现,但必须写 */ }
func (c *CSVExporter) Name() string { return "csv" }
func (c *CSVExporter) MimeType() string { return "text/csv" }
func (c *CSVExporter) FileExt() string { return ".csv" }
// 测试的时候,你还要给这个接口写 mock,六个方法,五个空实现。
type mockExporter struct{ called int }
func (m *mockExporter) Export(o []*Order) ([]byte, error) { m.called++; return nil, nil }
func (m *mockExporter) SetHeader(h []string) {}
func (m *mockExporter) SetStyle(s *Style) {}
func (m *mockExporter) Name() string { return "mock" }
func (m *mockExporter) MimeType() string { return "" }
func (m *mockExporter) FileExt() string { return "" }“你看这段代码,“老陈说,“一共五十多行,其中有多少行是’有用’的?”
”……十行?”
“Excel 那个实现里的十行。“老陈说,“剩下四十行,全是为了’以后’付的利息。”
第二版:先用具体类型,别急着抽象
“正确的第一步,是先不要接口。“老陈说。
// ===== 第二版:一个具体类型就够了 =====
package export
// ExcelWriter 就是一个具体类型。没有接口,没有 mock,没有"以后"。
// 它现在的职责只有一个:把一页一页的订单,流式写进一个 xlsx 文件。
type ExcelWriter struct {
sw *excelize.StreamWriter
f *excelize.File
}
func NewExcelWriter() (*ExcelWriter, error) {
f := excelize.NewFile()
f.SetSheetName("Sheet1", "订单")
sw, err := f.NewStreamWriter("订单")
if err != nil {
return nil, fmt.Errorf("创建流式写入器失败: %w", err)
}
return &ExcelWriter{f: f, sw: sw}, nil
}
func (w *ExcelWriter) WriteHeader(cols []string) error {
cells := make([]any, len(cols))
for i, c := range cols {
cells[i] = c
}
return w.sw.SetRow("A1", cells)
}
func (w *ExcelWriter) WriteRow(line int, o *Order) error {
cell, err := excelize.CoordinatesToCellName(1, line)
if err != nil {
return err
}
return w.sw.SetRow(cell, []any{
o.OrderNo, o.Buyer,
fmt.Sprintf("%.2f", float64(o.Amount)/100),
statusText(o.Status),
o.CreatedAt.Format("2006-01-02 15:04:05"),
})
}
// Close 落盘。注意:Close 里做清理,失败就删半成品,不留垃圾。
func (w *ExcelWriter) Close(path string) (err error) {
defer func() {
if err != nil {
_ = os.Remove(path)
}
}()
if err = w.sw.Flush(); err != nil {
return fmt.Errorf("flush 失败: %w", err)
}
if err = w.f.SaveAs(path); err != nil {
return fmt.Errorf("保存失败: %w", err)
}
return nil
}“这段代码里一个 interface 都没有。“老陈说,“它好不好测?”
”……好测。生成一个文件,读回来,断言内容。”
“对。“老陈说,“你不需要 mock 一个 Excel 写入器,你只需要一个临时目录。”
func TestExcelWriter(t *testing.T) {
dir := t.TempDir() // 测试结束自动清理,不会留垃圾
path := filepath.Join(dir, "out.xlsx")
w, err := NewExcelWriter()
if err != nil {
t.Fatal(err)
}
if err := w.WriteHeader([]string{"订单号", "买家"}); err != nil {
t.Fatal(err)
}
if err := w.WriteRow(2, &Order{
OrderNo: "E001",
Buyer: "张三",
Amount: 1990,
Status: 2,
CreatedAt: time.Date(2023, 4, 11, 2, 17, 0, 0, time.Local),
}); err != nil {
t.Fatal(err)
}
if err := w.Close(path); err != nil {
t.Fatal(err)
}
// 读回来断言
f, err := excelize.OpenFile(path)
if err != nil {
t.Fatal(err)
}
got, err := f.GetCellValue("订单", "A2")
if err != nil {
t.Fatal(err)
}
if got != "E001" {
t.Fatalf("A2 = %q, want E001", got)
}
}第三版:CSV 真的来了,接口才长出来
“现在,“老陈说,“半年之后,阿 May 真的来跟你说’要支持 CSV’。”
“这时候你怎么办?”
”……写第二个实现。”
“对。写完第二个实现之后,你回头看,会发现这两段代码里有一样的东西。”
// ===== 第三版:接口是从两个实现里"长"出来的 =====
// 关键 1:接口定义在【使用方】这一侧,不是实现方那一侧。
// 使用方(dumpOrders)只需要"写一行"和"收尾"两件事,那接口就只有两个方法。
type RowWriter interface {
WriteRow(cells []any) error
Flush() error
}
// 关键 2:这个函数是"消费方",它接受接口。
// 它不关心传入的是 xlsx 还是 csv,还是测试里的一根"假管子"。
func dumpOrders(ctx context.Context, fetch func(ctx context.Context, afterID int64,
limit int) ([]*Order, error), w RowWriter, cols []string) error {
if err := w.WriteRow(cols); err != nil {
return fmt.Errorf("写入表头失败: %w", err)
}
line := 2
var afterID int64
for {
page, err := fetch(ctx, afterID, 2000)
if err != nil {
return err
}
if len(page) == 0 {
break
}
for _, o := range page {
if err := w.WriteRow([]any{
o.OrderNo, o.Buyer,
fmt.Sprintf("%.2f", float64(o.Amount)/100),
statusText(o.Status),
o.CreatedAt.Format("2006-01-02 15:04:05"),
}); err != nil {
return fmt.Errorf("写入第 %d 行失败: %w", line, err)
}
afterID = o.ID
line++
}
if err := ctx.Err(); err != nil {
return err
}
}
return w.Flush()
}
// 关键 3:两个实现,各自只做自己擅长的事。
type excelRowWriter struct{ /* ... */ }
func (w *excelRowWriter) WriteRow(cells []any) error { /* 转坐标、写 sheet */ }
func (w *excelRowWriter) Flush() error { /* flush + SaveAs */ }
type csvRowWriter struct {
w *csv.Writer // 标准库 encoding/csv
f *os.File
buf *bufio.Writer
}
func (w *csvRowWriter) WriteRow(cells []any) error {
strs := make([]string, len(cells))
for i, c := range cells {
strs[i] = fmt.Sprint(c)
}
return w.w.Write(strs)
}
func (w *csvRowWriter) Flush() error {
w.w.Flush()
if err := w.w.Error(); err != nil { // csv.Writer 的错误是攒着的,必须检查
return err
}
return w.f.Close()
}“你看这个接口。“老陈说,“两个方法。”
“而之前那个是六个。”
“为什么变少了?”
“因为……这个是照着’用得到’定的,之前那个是照着’能想到’定的。“小林说。
“对。“老陈说,“这就是那句话的意思:接口越大,抽象越弱。”
“六个方法的接口,能容纳的实现少;两个方法的接口,能容纳的实现多。”
“我再问你,“老陈说,“如果现在要加第三种格式,比如 PDF,你要改什么?”
小林看着代码。
”……什么都不用改。dumpOrders 不用动,只要写一个新的 pdfRowWriter。”
“对。这才是’扩展方便’。”
“你之前那个接口,加一种格式要动六个地方;这个接口,加一种格式只动一个地方。”
“而它们的区别,只在于你是提前猜的,还是事后发现的。“
什么时候才该抽象
“那我以后怎么判断?“小林问,“什么时候该抽接口,什么时候不该?”
“我给你三个信号。“老陈说,“三个都不满足,就别抽。”
“信号一:你手里已经有第二种实现,或者第二种实现就在这个迭代里。”
“不是’以后可能有’,是’这个季度要’。”
“信号二:你需要隔离一个外部依赖来写测试。”
“比如你要测订单服务,但它要连数据库、要调支付网关。这时候你抽一个接口,把数据库和网关挡在外面,这是值得的。”
“但注意,“老陈强调,“你要抽的是’那个外部依赖’,不是’你的整个服务’。”
“信号三:你要打破一个循环依赖,或者要切开一个包。”
“这个不常见,但一旦出现,接口几乎是唯一解。”
“除了这三种,“老陈说,“剩下的’为了以后’、‘为了优雅’、‘万一要改呢’,全部是空管。”
“还有一条更狠的,“他补充,“Go 的标准库里,io.Writer 只有一个方法。”
type Writer interface {
Write(p []byte) (n int, err error)
}“一个方法。“老陈说,“它支撑了文件、网络、压缩、加密、哈希、HTTP 响应、日志、buffer,所有这些东西。”
“你觉得你能想出一个比它更好的抽象吗?”
小林摇头。
“那你凭什么觉得,你能提前想出一个比’照着 Excel 抄六个方法’更好的抽象?“
老陈的深层发问
第一问:你手上现在有几个只有一个实现的接口?它们每一个,都对应着一段你当时以为会发生的未来。那个未来发生了吗?
第二问:你写抽象的时候,是为了让未来的改动更容易,还是为了让现在的自己看起来更像一个’会设计的人’?这两种动机,你能分清吗?
第三问:如果把你写的接口全部删掉,用具体类型重写,你的代码会变长还是变短?如果变短了,那这些接口在做什么?
第四问:你有没有过这样的经历——你提前做了一件”以后会用”的事,结果以后真要用的时候,发现当时做的不对,反而要推倒重来?那一刻你是什么感受?
生活思考题
思考题一
你家里有没有买过”多功能”的东西?
多功能料理机、带十一个插孔的插线板、能当充电宝又能当暖手宝又能当手电筒的东西、买了三年只用过一次的烤箱。
你买它的时候,脑子里想的是什么?你现在用它吗?
参考答案
大多数人会怎么答
大部分人会承认买过,然后嘲笑一下自己:“买了烤箱,用了两次,现在放杂物。”
总结出来的教训通常是:“不要买不需要的东西”、“要克制消费冲动”。
这个答案为什么不够
这个答案只解释了”买错了”,没解释”为什么买的时候觉得对”。
而且它是错的——你买的时候的判断,在你当时的信息条件下,很可能是理性的。
你想:一个烤箱 300 块,一个空气炸锅 400 块,一个九合一切菜器 199 块。如果它们都能用,那”多功能”确实更划算。
你的判断在数学上没错。错的是你漏算了一项成本,而这项成本,是所有”多功能”产品说明书上都不会写的:
切换成本。
烤箱的使用成本,不只是”买烤箱的 300 块”,还有:把它从橱柜里搬出来的力气、预热要等的十分钟、用完要擦的内胆、晾干要占的地方。
多功能产品的真正成本,不是价格,是每一次使用时的摩擦力总和。
而这个摩擦力,在”买”的那一刻,你是看不见的。因为你在想象中使用它的场景里,摩擦力是零。
更深的一层是什么
这里有一个残酷的对称性:
你在”省钱”上得到的收益,会在”使用”上成倍地还回去。
插线板十一个孔,你省了买三个插线板的钱,但你每次插插头都要找一圈孔位。 烤箱你省了买面包机的钱,但你每次烤东西都要预热十分钟。
而技术上的”过早抽象”,是这个结构的完美对应物:
- 你”省”下的:将来重构的时间。
- 你付出的:现在每写一行代码,都要先想”这个要不要放进接口”;每次改实现,都要同步改接口和 mock;每个新人读这段代码,都要多理解一层他永远用不到的间接性。
收益在遥远的未来,成本在每天的当下。
这就是为什么人会持续地犯这个错误——不是因为蠢,是因为成本是每天小额扣款的,收益是一次性想象的。
再往深一层:
还有一个更隐蔽的问题,就是老陈讲的那个”空管”。
多功能产品的第二个代价,是它让你误以为自己已经拥有了那个能力。
你买了带绞肉功能的料理机,你就不会去学怎么用刀剁肉馅。
你抽了一个 Exporter 接口,你就不会去想”CSV 到底需要什么样的写入方式”。
“预留”这个词最阴险的地方,是它把”还没做”伪装成了”已经准备好”。
落到 Go 工程师身上
给你三条可以立刻用的规则:
一、“三次法则”。
同一个逻辑,第一次写,直接写。第二次遇到,复制粘贴(是的,复制粘贴)。第三次再遇到,才抽。
这不是懒。这是用真实的重复,来证明抽象的必要性。两次重复可能是巧合,三次重复一定是模式。
二、接口定义在使用方的包里,不定义在实现方的包里。
这条是 Go 特有的,也是最容易被忽略的。
// 错:在实现方的包里定义接口
package excel
type ExcelWriter interface { ... } // 谁会用这个?没人。
// 对:在使用方的包里定义接口,而且只声明"我需要的那部分"
package export
type RowWriter interface {
WriteRow([]any) error
Flush() error
}这条规则有个巨大的好处:它强迫你从”我需要什么”出发,而不是从”你能提供什么”出发。
三、接受接口,返回结构体。(Accept interfaces, return structs)
// 对:参数要接口,返回值要具体类型
func NewExcelWriter() *ExcelWriter // 返回具体类型
func dumpOrders(w RowWriter) error // 接受接口
// 错:返回接口
func NewExcelWriter() RowWriter // 调用方被剥夺了能力为什么?返回接口是在剥夺调用方的能力。 你返回一个 *ExcelWriter,调用方既能当 RowWriter 用,也能调用它特有的方法。你返回一个 RowWriter,调用方就只有两个方法了。
四、最后一条,也是最好用的一条:
下次你想加一个接口的时候,先不要加。在代码里留一行注释:
// TODO: 如果这里出现第二种实现(CSV/PDF),把 WriteRow/Flush 抽成 RowWriter 接口。
// 2023-04-11 小林:现在只有 Excel 一种,先不抽象。这行注释,成本是零,收益是:三个月后不管是你还是别人看到这里,都能立刻知道”这里有一个待定的决策,以及为什么当时没做”。
这行注释,就是那个”面板后面真的穿好了线”的网线口。
思考题二
你有没有过这样的经历:你花了很长时间”做准备”,结果真正开始做的时候,发现准备的方向全错了?
比如为了出国准备了两年英语,结果去了个说中文的地方;为了转岗学了大半年某个技术,结果转岗之后根本用不上;为了一个可能的机会,拒绝了另一个确定的机会。
那个”准备”,是浪费了吗?
参考答案
大多数人会怎么答
大部分人会分成两派。
一派说”不浪费,学到的东西总会有用”。另一派说”浪费了,方向错了就是白费”。
然后通常会有一个折中的结论:“准备是必要的,但方向要对。”
这个答案为什么不够
“方向要对”这四个字,等于没说。因为如果你能预知方向,你就不需要准备了,你直接做就行了。
这个答案的盲区在于:它假设”准备”和”方向”是可以分离的两件事。
实际上它们不能。
你仔细回想一下你那些”准备错了”的经历,会发现一个共同点:
你准备的那个东西,是你当时能看到的最具体的东西。
准备英语,因为英语是”可准备的”——有单词表、有考试、有分数。 学某个框架,因为框架是”可准备的”——有文档、有 demo、有教程。
而你真正需要的那个东西(在那个环境里活下来的能力、在那个岗位上的判断力),恰恰是不可准备的,所以你不会去准备它。
这就是”准备错方向”的真正机制:
不是你看错了未来,是你选择了”可准备的东西”,而不是”该准备的东西”。
更深的一层是什么
把这个机制搬到代码上,它精确对应”过早抽象”这个行为。
你抽 Exporter 接口,是因为”抽接口”是可准备的。它有明确的产出(一个 interface 定义),有明确的标准(方法签名看起来干不干净),有明确的完成感(MR 合并了)。
而”等 CSV 真的来了再说”是不可准备的。它没有产出,你今天什么都没写,你没有完成感。
所以,“过早抽象”的本质,不是设计能力的问题,是你在用”可准备的东西”,来缓解”不可准备的未来”带来的焦虑。
这才是它最难改的地方——它不是一个技术错误,它是一个情绪安抚机制。
再往深一层,有一个更难受的洞察:
那些”准备错了”的经历,其实不完全是浪费。但它们值钱的地方,不在你准备的那个东西上。
你准备两年英语,最后去了说中文的地方。你真学到的不是英语,是”我能坚持做一件看不到回报的事两年”。
你学了大半年没用上的框架,你真学到的不是那个框架,是”我知道自己学新东西的速度大概是多少”。
准备的价值,从来不在准备的物件上,而在你对自己认知的更新上。
落到 Go 工程师身上
给你一个具体的判断方法,用来决定”要不要现在就做这个提前量”:
问自己一个问题:如果我现在不做它,将来真要做的时候,成本会高多少?
分三种情况:
情况 A:将来做的成本 ≈ 现在做的成本。
比如抽接口。现在抽和三个月后抽,工作量差不多,甚至三个月后抽更准(因为那时候你有两种实现了)。
这种情况,永远不要现在做。 这就是”空管”。
情况 B:将来做的成本 >> 现在做的成本。
比如数据库表的字段设计。你现在把 amount 定义成 float64,将来要改成 int64(分),要改所有读写的地方,还要做数据迁移,还要处理迁移期间的兼容。
这种情况,现在就要想清楚。 这不是过早抽象,这是”不可逆决策”。
情况 C:你现在根本不知道将来会不会需要。
这种情况,答案是:什么都不做,但留一个”钩子”。
钩子是什么?就是那行注释,就是那个 context.Context 参数,就是那个你没抽但把函数拆得很干净的结构。
钩子的成本是零,但它让”将来做”的成本保持接近”现在做”的成本。
最后给你一条最实用的:
下次你想做”提前量”的时候,先花五分钟,把它归到 A、B、C 中的哪一类。
如果是 A,删掉。 如果是 B,认真做,并且写清楚为什么。 如果是 C,留个钩子,然后去做别的。
这个分类,五分钟。
这五分钟能帮你把”准备”从一种焦虑驱动的冲动,变成一个可以讨论的决策。
五、可测试性:写不出来测试的代码,说明设计有问题
「我测了,就是没进 CI」
“下一个问题。“老陈说,“你这个导出,有测试吗?”
“有。“小林说,“我本地测的。”
“在 CI 里跑吗?”
”……不跑。”
“为什么不跑?”
“因为它要连数据库。“小林说,“我们 CI 环境没有 MySQL。”
老陈点点头,然后又问了一句:
“那你本地是怎么测的?”
“我本地起了个 MySQL,灌了点数据,然后手动跑了几次。”
“所以你的测试,是一个需要人工操作的、依赖特定环境的、跑完不会留下任何记录的过程。”
”……你可以这么说。”
“那我问你,三个月之后,有人改了这个文件的某一行,他怎么知道他改坏了?”
小林沉默了。
“他不会知道。“老陈替他回答,“他要看你有没有留注释,或者他要知道’哦这里要连数据库手动测’。”
“老陈,“小林这时候有点急了,“我觉得这个要求有点高。业务逻辑和数据库耦合,这不是很正常吗?哪个业务代码不连数据库?”
“你说得对。“老陈说,“业务逻辑和数据访问确实要打交道。”
“那——”
“但我要你记住一句话。“老陈说。
他停了一下。
“写不出来测试的代码,不是测试的问题,是设计的问题。”
“这话我听过。“小林说,“但我一直觉得是站着说话不腰疼。”
“我给你讲个事。“
被封进吊顶里的燃气热水器
“还是 2008 年那套房。“老陈说,“我刚才讲了卫生间防水,现在讲热水器。”
“那套房子的卫生间很小,大概四个平方。燃气热水器要挂在墙上,又要接冷水进、热水出、燃气进、排烟管,管子特别多,露在外面很难看。”
“我那会儿的设计思路是:把热水器藏进吊顶里。”
“吊顶做成整片的铝扣板,只在热水器正下方留一个检修口。”
“检修口多大?”
“三十乘三十。“老陈说,“标准的检修口尺寸。”
“装的时候非常顺利。“他说,“热水器的师傅两小时就装完了,吊顶的师傅半小时封好,最后剩一个三十乘三十的小方口,扣上一块板,严丝合缝。我站在卫生间里看,干净、整洁、一点管子都露不出来。”
“我当时特别满意。”
“出问题是什么时候?”
“2016 年,第八年。“老陈说,“先是水温不稳,后来是点不着火,最后是彻底不出热水。”
“我找了厂家的师傅来看。师傅搬了个梯子,爬上去,把检修口那块板扣下来,伸头进去看了一眼,然后下来了。”
“他说:‘您这个我修不了。’”
“为什么?”
“他说,检修口太小,他的手能伸进去,但肩膀进不去,看不见里面的接头。要换那个点火器,得把整个热水器拆下来。要拆下来,得先把它从吊顶里拿出来。”
“怎么拿?”
“拆吊顶。“老陈说,“从检修口往两边拆,拆掉大概三分之一的铝扣板,才够把热水器斜着掏出来。”
“多少钱?”
“热水器本身换个点火器,配件加工时,四百二。“老陈说,“拆吊顶、装回去,人工费八百。”
“加起来一千二,其中八百是为了’够得着’。”
“而那个检修口,“老陈说,“如果我当时让它大十公分,从三十乘三十变成四十乘四十——”
“会怎么样?”
“铝扣板是按片卖的,多切一刀不要钱。装的时候多花五分钟。“老陈说,“成本是零。”
“八年后,这一刀值八百块。”
小林坐在那儿,半天没说话。
“你现在明白我的意思了吗?“老陈问。
”……我的代码,就是把热水器封进吊顶里。”
“对。“老陈说,“你写代码的时候,只考虑了一条路径:‘正常跑起来’那条路径。”
“你没有考虑第二条路径:‘我要检查它、我要修它、我要确认我没改坏它’这条路径。”
“测试不是给你的代码加一层保险。“他说,“测试是你的代码上那个检修口。”
“你写不出测试,不是因为你不会写测试,是因为你在设计的时候,根本没有给’检修’这件事留位置。“
从「连着数据库才能测」到「纯函数 + 边界 IO」
“看代码。“老陈说。
“你给我写一个最简单的东西:商家的日报表,统计某一天的总订单数、总金额、退款数。你会怎么写?”
小林想了一下,在编辑器里敲了起来。
第一版:典型的”不可测”写法
// ===== 第一版:隐式的、全局的、不可替换的依赖 =====
package report
import (
"time"
"database/sql"
)
var (
db *sql.DB // 全局 DB
cfg *Config // 全局配置
)
// BuildDailyReport 的三个依赖,全是隐式的:
// 1. time.Now() -> 直接调系统时钟,测试无法固定时间
// 2. db -> 全局变量,测试无法替换
// 3. sendEmail -> 直接发邮件,测试会真的发一封出去
// 要测这个函数,你必须:起一个真实的 MySQL、改系统时间、起一个 SMTP 服务器。
// 这就是为什么它"没法进 CI"。
func BuildDailyReport(merchantID int64) (*Report, error) {
now := time.Now() // 坑 1:时钟是隐式的全局状态
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
var total int
var amount int64
err := db.QueryRow( // 坑 2:全局 db
`SELECT COUNT(*), COALESCE(SUM(amount),0) FROM orders
WHERE merchant_id=? AND created_at>=? AND created_at<?`,
merchantID, day, day.AddDate(0, 0, 1),
).Scan(&total, &amount)
if err != nil {
return nil, err
}
var refunds int
err = db.QueryRow(
`SELECT COUNT(*) FROM orders
WHERE merchant_id=? AND status=5 AND updated_at>=? AND updated_at<?`,
merchantID, day, day.AddDate(0, 0, 1),
).Scan(&refunds)
if err != nil {
return nil, err
}
r := &Report{
MerchantID: merchantID,
Day: day,
Total: total,
Amount: amount,
Refunds: refunds,
Rate: float64(refunds) / float64(total), // 坑:total 为 0 时除零 -> NaN
}
// 坑 3:直接发邮件。测试跑一次,商家收到一封邮件。
if err := sendEmail(cfg.SMTPHost, cfg.ReportTo, r.String()); err != nil {
return nil, err
}
return r, nil
}“你自己看,“老陈说,“这个函数有几个依赖?”
“三个。时钟、数据库、邮件。”
“这三个依赖,有几个是显式写在函数签名上的?”
”……零个。”
“对。“老陈说,“一个函数的依赖,如果不是写在它的签名上的,那它就是不可替换的。不可替换,就不可测。”
“那我给它们都抽成接口?”
小林立刻动手,写了一个:
第二版:用力过猛的 mock 大礼包
// ===== 第二版:把所有东西都抽象掉,结果更糟 =====
type DB interface {
QueryRow(query string, args ...any) *sql.Row
Query(query string, args ...any) (*sql.Rows, error)
Exec(query string, args ...any) (sql.Result, error)
Begin() (*sql.Tx, error)
BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error)
Close() error
Ping() error
Stats() sql.DBStats
// ...三十个方法
}
type Mailer interface {
Send(to string, subject, body string) error
}
type Clock interface {
Now() time.Time
Since(t time.Time) time.Duration
Until(t time.Time) time.Duration
}
type ConfigProvider interface { /* ... */ }
// 然后你的测试长这样:四十行 mock,五行断言。
type mockDB struct{ /* ... */ }
func (m *mockDB) QueryRow(q string, a ...any) *sql.Row { /* 怎么构造一个 *sql.Row?构造不了! */ }“停。“老陈说。
“怎么了?”
“我问你一个问题。“老陈指着那个 DB 接口,“你打算怎么在测试里造一个 *sql.Row?”
小林停住了。
*sql.Row 是 database/sql 里的具体结构体,它的字段全是私有的,你没有数据库就构造不出一个带数据的 *sql.Row。
”……构造不了。”
“所以你这个三十个方法的接口,是假的。“老陈说,“它看起来很’可测’,实际上你一个用例都写不出来。”
“这是第二种过度设计。“他说,“第一种是’提前抽接口’,第二种是’为了测试而抽象,但抽象错了层次’。”
“那正确的层次是什么?”
“你再想想,“老陈说,“你真正要测的是什么?”
小林想了很久。
“我要测的是……退款率算得对不对。”
“对!“老陈说,“你要测的是逻辑,不是 IO。”
“查询数据库是 IO,发邮件是 IO。这些不需要在单元测试里测,它们在集成测试里测。”
“你要测的,是’给我这三个数,退款率是不是等于退款数除以订单数’。”
第三版:把逻辑抽成纯函数,把 IO 推到边界
“看这个。“老陈说。
// ===== 第三版:小接口、纯函数、IO 在边界 =====
package report
import (
"context"
"time"
)
// 1. 只抽象"你真的要在测试里替换的那一个能力"。
// 注意这个接口只有一个方法,而且它返回的是领域对象,不是 *sql.Rows。
// 这就绕开了"构造不了 *sql.Row"的问题。
type DailySource interface {
SumByDay(ctx context.Context, merchantID int64, day time.Time) (DaySummary, error)
}
type DaySummary struct {
Orders int
Amount int64 // 分
Refunds int
}
type Report struct {
MerchantID int64
Day time.Time
Orders int
Amount int64
Refunds int
RefundRate float64
}
// 2. 核心逻辑:纯函数。没有 IO,没有时钟,没有全局变量。
// 给它输入,它给你输出。同样的输入永远给同样的输出。
func BuildReport(merchantID int64, day time.Time, s DaySummary) Report {
r := Report{
MerchantID: merchantID,
Day: day,
Orders: s.Orders,
Amount: s.Amount,
Refunds: s.Refunds,
}
// 边界情况在这里显式处理,而不是让它悄悄变成 NaN
if s.Orders == 0 {
r.RefundRate = 0
return r
}
r.RefundRate = float64(s.Refunds) / float64(s.Orders)
return r
}
// 3. IO 全部推到外面这一层。这一层几乎不含逻辑,所以它的测试可以是集成测试。
func BuildDailyReport(ctx context.Context, src DailySource, merchantID int64, day time.Time) (Report, error) {
s, err := src.SumByDay(ctx, merchantID, day)
if err != nil {
return Report{}, err
}
return BuildReport(merchantID, day, s), nil
}“现在你写测试。“老陈说。
package report
import (
"testing"
"time"
)
// 表驱动测试:每一个 case 就是一个"输入 -> 期望输出"的对照。
// 加一个新 case,只需要加一行。
func TestBuildReport(t *testing.T) {
day := time.Date(2023, 4, 11, 0, 0, 0, 0, time.UTC)
cases := []struct {
name string
summary DaySummary
want float64
}{
{name: "正常", summary: DaySummary{Orders: 100, Refunds: 3, Amount: 199000}, want: 0.03},
{name: "无退款", summary: DaySummary{Orders: 50, Refunds: 0, Amount: 99000}, want: 0},
{name: "全退款", summary: DaySummary{Orders: 10, Refunds: 10, Amount: 5000}, want: 1},
// 这一个 case 就是第一版里那个 NaN bug
{name: "零订单", summary: DaySummary{Orders: 0, Refunds: 0, Amount: 0}, want: 0},
{name: "退款数异常大于订单数", summary: DaySummary{Orders: 5, Refunds: 9}, want: 1.8},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := BuildReport(10247, day, c.summary)
if got.RefundRate != c.want {
t.Fatalf("RefundRate = %v, want %v", got.RefundRate, c.want)
}
})
}
}
// 内存里的 fake:不用 mock 框架,不用 gomock,就是一个 struct。
type fakeSource struct {
byDay map[time.Time]DaySummary
err error
}
func (f *fakeSource) SumByDay(ctx context.Context, merchantID int64, day time.Time) (DaySummary, error) {
if f.err != nil {
return DaySummary{}, f.err
}
return f.byDay[day], nil
}
func TestBuildDailyReport_Error(t *testing.T) {
src := &fakeSource{err: errors.New("db down")}
if _, err := BuildDailyReport(context.Background(), src, 1, time.Now()); err == nil {
t.Fatal("期望返回错误,但得到了 nil")
}
}“这个测试,“老陈说,“跑一次多久?”
”……几毫秒。”
“需要数据库吗?”
“不需要。”
“需要改系统时间吗?”
“不需要。”
“会发出邮件吗?”
“不会。”
“能在 CI 里跑吗?”
”……能。”
老陈往后一靠。
“你看,“他说,“我一个 mock 框架都没用,一个接口只有一个方法,测试文件比你的 mock 礼包短了三十倍,覆盖率还更高。”
“因为我测的是逻辑,你测的是调用。”
“这两者的区别是什么?”
小林想了一下。”……测逻辑,改实现不会让测试挂。测调用,改实现测试就挂。”
“对。“老陈说,“如果你的重构会让测试挂,那你写的不是测试,是第二个实现。”
怎么测那些”真的有 IO”的部分
“那数据库查询怎么办?“小林问,“我真的要验证 SQL 是对的。”
“这部分有两个办法。“老陈说。
“第一个,用真的数据库,但是内存里的。”
// 用 SQLite 内存库测真实的 SQL 语法(注意:它不能验证 MySQL 专有语法,
// 但能抓住 90% 的低级错误,比如字段名写错、参数个数不对)
func TestRepoSumByDay(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:") // github.com/mattn/go-sqlite3
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`CREATE TABLE orders (
id INTEGER PRIMARY KEY,
merchant_id INTEGER,
amount INTEGER,
status INTEGER,
created_at DATETIME
)`); err != nil {
t.Fatal(err)
}
// 造三条数据,跨两天
seed := []struct {
merchant int64
amount int64
status int
day string
}{
{10247, 1990, 2, "2023-04-11 09:00:00"},
{10247, 2990, 5, "2023-04-11 15:00:00"},
{10247, 3990, 2, "2023-04-12 09:00:00"}, // 第二天,不该被统计
}
for _, s := range seed {
if _, err := db.Exec(`INSERT INTO orders (merchant_id, amount, status, created_at)
VALUES (?,?,?,?)`, s.merchant, s.amount, s.status, s.day); err != nil {
t.Fatal(err)
}
}
repo := &Repo{db: db}
got, err := repo.SumByDay(context.Background(), 10247,
time.Date(2023, 4, 11, 0, 0, 0, 0, time.UTC))
if err != nil {
t.Fatal(err)
}
if got.Orders != 2 { // 只统计 4-11 那两条
t.Fatalf("Orders = %d, want 2", got.Orders)
}
if got.Refunds != 1 {
t.Fatalf("Refunds = %d, want 1", got.Refunds)
}
if got.Amount != 4980 {
t.Fatalf("Amount = %d, want 4980", got.Amount)
}
}“第二个办法,测 HTTP 层,用标准库的 httptest。这个是我最喜欢的,因为它测的是’用户真正会用到的那个入口’。”
func TestGetOrderHandler(t *testing.T) {
// 一个假的 service,只实现 handler 需要的那一个方法
svc := &fakeOrderService{
orders: map[string]*Order{
"E001": {OrderNo: "E001", Amount: 1990, Status: 2},
},
}
h := &Handler{svc: svc}
cases := []struct {
name string
query string
want int
wantBody string
}{
{"正常", "?order_no=E001", http.StatusOK, "E001"},
{"订单不存在", "?order_no=E999", http.StatusNotFound, ""},
{"缺少参数", "", http.StatusBadRequest, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/order"+c.query, nil)
rec := httptest.NewRecorder() // 不需要真的起端口
h.GetOrder(rec, req)
if rec.Code != c.want {
t.Fatalf("code = %d, want %d, body=%s", rec.Code, c.want, rec.Body.String())
}
if c.wantBody != "" && !strings.Contains(rec.Body.String(), c.wantBody) {
t.Fatalf("body = %s, want contains %s", rec.Body.String(), c.wantBody)
}
})
}
}“这段代码里,“老陈说,“httptest.NewRecorder() 是标准库给你的检修口。它不用起端口,不用开防火墙,就是一个实现了 http.ResponseWriter 的结构体。”
“标准库里到处都是这种检修口:接口、注入点、可替换的实现。它不是碰巧长成那样的,它是被设计成那样的。”
“你读标准库的时候,不要只看它怎么用。你要看它为什么这么设计。“
老陈的深层发问
第一问:你上一次改别人的代码,改完之后你怎么确认你没改坏?如果你的答案是’我看了两遍’,那你的信心来自哪里?
第二问:你的项目里,测试覆盖率是多少?这个数字,是’被覆盖的代码行数’,还是’被验证过的行为’?这两者差多远,你心里有数吗?
第三问:如果让你现在给你负责的模块补测试,你第一个想补的是哪一块?为什么是那一块——是因为它最容易坏,还是因为它最好写?
第四问:你有没有写过那种’为了让覆盖率达标’的测试?写完那晚你睡得好吗?
生活思考题
思考题一
你家里有没有一个地方,是”装的时候很顺,但要修就得大动干戈”的?
封在吊顶里的热水器、被柜子挡住的电闸箱、塞在角落最里面的路由器、需要把整个抽屉拉出来才能拔的插头。
你当时为什么那么装?现在每次要动它的时候,你是什么心情?
参考答案
大多数人会怎么答
大部分人会说出好几个例子,然后总结:“装修的时候应该多留检修口”、“要考虑后期维护”。
也有人会自嘲:“装的时候只想着好看,没想过会坏。”
这个答案为什么不够
“要考虑后期维护”这句话,和”写代码要考虑可测性”一样,是对的,但不可执行。
因为你装修的那个时刻,你的注意力全在”能不能装进去”和”装完好不好看”上。你脑子里根本没有”它会坏”这个选项。
这不是疏忽,这是人的认知结构:我们对”还没发生过的故障”,缺乏想象力。
你买第一套房的时候,你不会想到热水器会坏,因为你从来没经历过热水器坏。
更深一层的盲区是:
“留检修口”这件事,在装修的那个时刻,是纯粹的”成本”,没有任何”收益”。
它不让你家更好看(相反,它让你家更难看)。它不让你家更好用。它唯一的作用,是在未来某个不确定的时刻,帮你省一笔不确定的钱。
人不会为了不确定的未来,付出确定的当下的代价。 除非有人逼你,或者你被坑过。
更深的一层是什么
这里有一个非常有意思的反转:
那些”留了检修口”的人,不是因为他们更有远见,是因为他们被坑过。
老陈那个热水器,如果他 2008 年之前就经历过一次”拆吊顶”,他一定会留一个四十乘四十的口。
也就是说,“可维护性”这个能力,几乎无法通过学习获得,只能通过受伤获得。
这就是为什么”代码可测性”这件事,讲了二十年还是讲不通——因为它违反了人的本能。
而这个本能是可以被绕过的。绕过的办法不是”提醒自己要想远一点”,而是:
把”未来”拉近,让它变成”现在”。
具体怎么做?
你装修的时候,不要问自己”这个会不会坏”。要问自己一个更有画面感的问题:
“如果三年后的某天晚上,这个东西坏了,我会站在哪里,手里拿着什么,我要先搬开什么才能碰到它?”
你看,这个问题不一样。它不是在问概率,它是在放电影。
一旦你开始放电影,你就会看见那个检修口太小、手伸不进去。你就会看见那个插头在柜子最里面,你得先把三箱纸巾搬出来。
人不会为概率行动,但会为画面行动。
落到 Go 工程师身上
给你一个可以直接用的习惯,我叫它”三个月问题”:
在你提 MR 之前,用三分钟,在脑子里放一段三个月的电影:
“三个月后的某天晚上十点,这个模块出问题了。报警响了,不是我值班,是小周值班。他打开这个目录,他看到的第一个文件是什么?他能不能在五分钟内找到入口?他能不能在本地跑一个测试,复现这个问题?”
这三分钟,会让你做出完全不一样的设计决策。
你会开始写 README,不是因为规范要求,是因为你”看见”了小周在翻你的目录。 你会把函数拆小,不是因为”单一职责”,是因为你”看见”了小周在断点。 你会把依赖写进函数签名,是因为你”看见”了小周在问”这个 db 是从哪来的”。
再给你一条更硬的规则:
一个函数的可测性,看它的依赖是不是都在签名上。
打开你最近写的一个函数,数一数它有几个”隐式依赖”:
- 直接调用
time.Now()吗?—— 这个函数的输出依赖当前时间,你没法重现它的行为。改成传进来,或者传一个func() time.Time。 - 用了全局变量吗?(全局 db、全局 config、全局 logger)—— 你没法在测试里换掉它。
- 直接调用
os.Getenv吗?—— 测试依赖环境变量,CI 上和本地可能不一样。 - 直接发 HTTP 请求/发邮件/写文件吗?—— 测试会真的发出去。
这四个里只要有一个,这个函数就不可单元测试。
而修它们的成本,通常只是”多一个参数”。
这多出来的一个参数,就是那个四十乘四十的检修口。装的时候多五分钟,八年之后省八百块。
思考题二
你会做饭吗?
回想一下:你第一次照着菜谱做一道菜,和后来你”不用看菜谱”做同一道菜,这两者之间发生了什么?
是什么让你从”照着做”变成了”会做”?
参考答案
大多数人会怎么答
大部分人会说:“做多了就会了”、“失败几次就知道火候了”。
也有人会说得更具体一点:“尝过咸淡才知道放多少盐”、“炒糊过才知道火要多大”。
这个答案为什么不够
“做多了就会了”这个说法,掩盖了一个非常关键的区别:
做了三十次的人,不一定比做了五次的人更会做。
你身边一定有这样的人:做了十年饭,手艺和十年前一模一样。
为什么?因为他每一次都在”照着做”,而不是在”调整”。
这里真正的机制是:从”照着做”到”会做”,中间必须经历一个东西——反馈回路的闭合。
什么是反馈回路?就是”我做了一个动作 → 我立刻知道这个动作产生了什么影响 → 我下次调整”。
做菜天然有这个回路:你放了一勺盐,五分钟后你尝一口,咸了,下次少放。这个回路的延迟是五分钟。
写代码也有这个回路,但它的延迟要长得多:
你写了一个接口,三个月后有人要用它,才发现它设计错了。延迟:三个月。
所以”做菜十年还是不会做”和”写代码五年还是老样子”,是同一个问题:反馈回路太长,或者根本没闭合。
更深的一层是什么
这就是为什么”测试”这件事,表面上是在做质量保证,实际上是在做一件更重要的事:
测试是把反馈回路的延迟,从三个月压缩到三秒。
你想:
- 没有测试:你改了一行,要等别人用、等线上出问题,你才知道改坏了。延迟:几天到几个月。
- 有测试:你改了一行,
go test ./...,两秒钟之后你看到红色的 FAIL。延迟:两秒。
这两秒钟,就是你会做菜和不会做菜的区别。
再深一层:
菜谱和测试,还有一个惊人的相似点——
好的菜谱,写的是”到什么状态”,不是”做多久”。
“炒至微微焦黄”,不是”炒三分钟”。 “盐适量,尝一下”,不是”放两克盐”。
因为每个灶台的火不一样,每家的锅不一样,当天的湿度不一样。
好的测试也是这样:它验证的是”行为”,不是”实现”。
// 坏测试:验证实现(改一下实现就挂)
if mock.queryCount != 3 {
t.Fatal("查询次数不对")
}
// 好测试:验证行为(怎么实现都行,结果对就行)
if got.RefundRate != 0.03 {
t.Fatalf("RefundRate = %v, want 0.03", got.RefundRate)
}前者测的是”你按菜谱炒了三分钟”,后者测的是”菜熟了”。
落到 Go 工程师身上
给你三个具体的动作:
一、给自己建一个”两秒反馈”。
挑你最常改的那个包,配好一个命令,让它能在两秒内跑完:
go test ./report/... -count=1 -v 2>&1 | head -50如果你的测试需要五分钟才能跑完,你就会不跑它。如果你不跑它,它就等于不存在。
跑不完的测试,不如没有测试。
二、每修一个 bug,先写一个会失败的测试。
这条是最重要的。
不是”修完之后补个测试”,是先写一个能复现这个 bug 的测试,看着它 FAIL,然后去修,然后看着它 PASS。
这个流程的价值不在于测试本身,在于它强迫你先理解 bug,再动手。
而更重要的是:这个测试会永久留在那里,它就是这个 bug 的墓碑。三年后有人又写出同样的 bug,这个测试会在两秒内告诉他。
三、用一个”三个月之后”的标准,检验你的测试好不好。
写完一个测试,问自己:
“如果三个月后我把这个函数的内部实现完全重写,但保持行为不变,这个测试会不会挂?”
如果会挂——说明你在测实现,不是在测行为。这个测试会在重构的时候变成你的负担,最后你会删掉它。
如果不会挂——说明你测的是行为。这个测试会陪你三年。
判断一个测试好不好,不看覆盖率,看它能不能活过一次重构。
六、当阿 May 说”就加个小功能”的时候,你该问什么
「我问了会显得我事儿多」
“我们回到最开始。“老陈说。
“什么时候?”
“周会。“老陈说,“阿 May 说’还有个小的,订单列表能不能导出 Excel’。”
“然后你说’这个简单,我这两天做一下’。”
小林沉默了一下。
”……对。”
“我问你,“老陈说,“从她说完那句话,到你开始写代码,中间隔了多久?”
”……没有隔。我当天下午就开始了。”
“你问过她什么吗?”
”……没有。”
“一个字都没问?”
“一个字都没问。”
老陈把茶杯端起来,没喝,又放下了。
“为什么没问?”
小林想了一下,说了一句实话。
“因为她那么忙。“他说,“周会上十几个人,她翻到那页就用了三十秒。我如果举手说’等等,我想问几个问题’,我觉得……”
“你觉得什么?”
“我觉得会显得我事儿多。“小林说,“而且那是个小功能。我一个做了两年的人,连个导出都要问,说不过去。”
老陈点点头。
“你觉得阿 May 会怎么想?”
“她会觉得……这人怎么这么麻烦。”
“我给你另一个版本。“老陈说。
“那天晚上两点十七分,大刘给你打电话。第二天上午十点,复盘会。阿 May 问你’最多能导多少条’。”
“你答不上来。”
“你想想那一刻,阿 May 心里在想什么?“老陈说,“她不是要骂你。她在想的是:‘我上周要是多花五分钟跟他说清楚就好了。’”
”……”
“你觉得她是宁愿在周会上多花五分钟回答你三个问题,还是宁愿在复盘会上花一小时,还要跟客户道歉?”
小林没说话。
“我来告诉你,‘就加个小功能’这句话,你应该问什么。“老陈说。
他伸出手,开始数。
“第一问:谁用?”
“你当时知道是用给商家财务的吗?”
”……我知道是商家用的。”
“你知道是财务用吗?“老陈说,“商家老板和商家财务,是完全不同的两种用户。老板看一眼就关了,财务要拿去做账,要跟银行流水对。”
“第二问:多久用一次?”
“你这个功能上线到出事,一共五天,导出了六百多次。“老陈说,“听起来不多,对吧?但你没问的是,这个次数跟什么有关。”
“跟什么有关?”
“跟月份。“老陈说,“财务对账是月底的事。你上线是 4 月 6 号,出事是 4 月 11 号。你知道 4 月 11 号是什么日子吗?”
”……”
“是那家连锁餐饮上一个季度的账,压到 4 月中旬才对的。“老陈说,“你那六百多次,大部分是零星试用。真正的高峰,是每个月最后三天。”
“第三问:最大会到多少?”
“这一问,就是阿 May 后来问你的那一问。“老陈说,“只不过她问晚了五天,而且是在复盘会上问的。”
“第四问:失败了会怎么样?”
“你当时想过吗?”
”……想过,我觉得失败了就是导出失败,用户再点一次。”
“那我问你,“老陈说,“如果它是一个对账文件,导出失败了,财务会怎么办?”
”……再点一次。”
“再点一次,失败了呢?”
”……”
“她会打你们客服电话。“老陈说,“然后客服会找阿 May,阿 May 会找你。这个链条上每多一环,你的错误就要多付一个人的时间。”
“第五问:怎么知道它坏了?“老陈说,“你有给它加监控吗?”
”……没有。”
“所以那天晚上,唯一知道它坏了的人,是那个商家的财务,和大刘。“老陈说,“你,是最后一个知道的。”
老陈停了一下。
“这五问,“他说,“加起来大概五分钟。”
“你没花这五分钟。“
「随便,你点吧」
“我请你吃过饭吗?“老陈忽然问。
“啊?”
“我讲个吃饭的事。“老陈说。
“前年,我跟三个老同事吃饭。其中一个姓王,我们从第一家公司的同事,二十年了。”
“到了饭桌上,服务员问点菜,老王说:‘随便,你点吧,我都行。’”
“我就点了。“老陈说,“四个人,我点了四个菜一个汤:水煮牛肉、干煸四季豆、香菜拌牛肉、酸菜鱼,加一个番茄蛋汤。”
“菜上来之后,我才发现,“老陈说,“老王不吃辣,也不吃香菜。”
“四个菜里,水煮牛肉他一口没动,香菜拌牛肉他一口没动,干煸四季豆有干辣椒他挑了半天,酸菜鱼他喝了点汤。”
“最后他吃了小半碗米饭和两碗番茄蛋汤。”
“那顿饭多少钱?”
“四百二。“老陈说,“剩了半桌。”
“你没问他吗?”
“我问了啊。“老陈说,“他说’随便,你点吧,我都行’。”
老陈笑了笑。
“问题就在这句话上。“他说,“‘随便’不是一个需求。‘随便’是’我把定义权交给你,但我把验收权留在我这儿’。”
“你想想这个结构有多不公平:他不做选择,但他有意见。”
小林笑了。
“而且更糟的是,“老陈继续,“他不是故意的。 他是真觉得’我都行’。”
“为什么?“小林问。
“因为’我不吃辣”我不吃香菜’这件事,在他脑子里是默认设置,不是需求。”
“他觉得这是常识,你应该知道。他不会主动说,因为说出来显得矫情。”
“越是长期存在的约束,越不会被说出来。因为它对说的人来说是背景,不是信息。”
老陈往前倾了倾身子。
“现在你回到周会上。”
“阿 May 说’就加个小功能’。”
“她为什么没说’这个商家有十一万条订单’?“老陈说,“不是因为她藏着。是因为她也不知道。她不知道有十一万条,她甚至不知道’订单条数’是个会影响技术方案的东西。”
“对她来说,一个商家有多少订单,是背景,不是需求。”
“所以你不能等她说。你要问。”
“而你要问的,恰恰是那些’对她来说是背景’的东西。”
“我再给你举个更狠的。“老陈说。
“我媳妇儿让我下班路上’顺便买杯咖啡’。”
“我就买了。买回来,她说:‘我要的是冰的。’”
“第二天我又买,买了冰的。她说:‘我要的是燕麦奶的。’”
“第三天我买了冰的燕麦奶。她说:‘今天冷,我想喝热的。’”
老陈摊了摊手。
“第四天我学乖了。我说:‘冰的还是热的?奶还是燕麦奶?大杯中杯?要不要加浓缩?’”
“她说:‘你怎么这么多问题。’”
“但那杯咖啡,她喝完了。“
幂等:用户点了五次,就真的该生成五个文件吗
“我们讲最后那个问题。“老陈说,“你那个 /tmp/export 里的八百多个文件。”
“一半是失败留下的半成品,“他说,“另一半是用户重复点击。”
“用户为什么重复点击?”
“因为……点了没反应?”
“对。“老陈说,“你那个导出要十几秒,前端转圈,用户不知道有没有点上,就又点了一下。点了五次。”
“这就是我说的第四问的答案:失败了会怎么样?“老陈说,“答案是:会引发更多的失败。”
“一个慢的功能,会诱导用户重复提交;重复提交让它更慢;更慢让用户点更多次。这是个正反馈。”
“那我让前端把按钮置灰就行了?”
“你觉得够吗?“老陈问。
小林想了一下。”……不够。刷新页面就能绕过。”
“还有呢?”
”……开两个标签页。”
“还有?”
”……直接调 API。”
“对。“老陈说,“任何只在客户端做的防护,都只是提示,不是约束。”
“真正的约束必须在服务端,而且必须是幂等的。”
第一版:只靠前端,等于没有
// ===== 第一版:服务端完全无保护 =====
// 前端做了:点击后置灰按钮,3 秒后恢复。
// 能绕过的方式:刷新、多标签页、curl、手机端和 PC 端同时点、
// 网速慢导致 3 秒后按钮恢复了但请求还没回来。
func (h *Handler) CreateExport(w http.ResponseWriter, r *http.Request) {
var req struct {
MerchantID int64 `json:"merchant_id"`
Start string `json:"start"`
End string `json:"end"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, msg("参数错误"))
return
}
// 每次调用都新建一个任务。点五次 -> 五个 goroutine -> 五个文件。
id, err := h.mgr.Submit(r.Context(), req.MerchantID, req.Start, req.End)
if err != nil {
writeJSON(w, http.StatusInternalServerError, msg("提交失败"))
return
}
writeJSON(w, http.StatusOK, map[string]any{"task_id": id})
}第二版:内存去重(能用,但不可靠)
// ===== 第二版:进程内去重 =====
// 比第一版好,但有两个致命问题:
// 1. 服务重启,map 清空,去重失效
// 2. 多副本部署时,请求打到不同实例,去重失效
type Manager struct {
mu sync.Mutex
inFlight map[string]string // key -> taskID
}
func (m *Manager) Submit(key string) (string, bool) {
m.mu.Lock()
defer m.mu.Unlock()
if id, ok := m.inFlight[key]; ok {
return id, false // 复用
}
// ...新建
return id, true
}“这个能解决 90% 的问题。“老陈说,“但你要知道它的边界在哪——内存状态在多副本下是不共享的。”
“你们部署了几个副本?”
”……四个。”
“那你的去重,实际上只在 25% 的请求上生效。“老陈说,“用户点五次,请求被负载均衡分到四个实例上,能起四个任务。”
第三版:用数据库的唯一索引做幂等
“正确的做法,是把’不重复’这件事,交给一个所有实例共享的、有原子性保证的地方。“老陈说。
// ===== 第三版:用数据库唯一索引做幂等 =====
package export
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"time"
)
// 表结构(MySQL):
//
// CREATE TABLE export_task (
// id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
// idempotent_key VARCHAR(64) NOT NULL COMMENT '幂等键',
// task_id VARCHAR(64) NOT NULL,
// state TINYINT NOT NULL DEFAULT 0 COMMENT '0运行中 1成功 2失败',
// file_path VARCHAR(255) NOT NULL DEFAULT '',
// err_msg VARCHAR(512) NOT NULL DEFAULT '',
// created_at DATETIME NOT NULL,
// finished_at DATETIME DEFAULT NULL,
// PRIMARY KEY (id),
// UNIQUE KEY uk_idem (idempotent_key) -- 关键:靠它保证原子性
// ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
type Store struct{ db *sql.DB }
// TryCreate 原子地"抢"一个幂等键。
//
// 返回 (taskID, created, err):
// created=true -> 我抢到了,调用方应该真的去执行导出
// created=false -> 别人已经在跑了(或者已经跑完了),返回他的 taskID
//
// 关键在于:这里的"检查 + 写入"是一个原子的数据库操作,
// 不依赖应用层的锁,所以四个实例同时来抢,也只会有一个成功。
func (s *Store) TryCreate(ctx context.Context, key, taskID string) (string, bool, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO export_task (idempotent_key, task_id, state, created_at)
VALUES (?, ?, 0, NOW())
ON DUPLICATE KEY UPDATE task_id = task_id`, // 已存在时"什么都不改"
key, taskID)
if err != nil {
return "", false, fmt.Errorf("创建导出任务失败: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return "", false, fmt.Errorf("读取 RowsAffected 失败: %w", err)
}
// MySQL 语义:1 表示新插入成功;0 表示命中了重复键且值没变。
if n == 1 {
return taskID, true, nil
}
// 别人先抢到了:把他的 taskID 查出来返回给调用方
var existing string
if err := s.db.QueryRowContext(ctx,
`SELECT task_id FROM export_task WHERE idempotent_key = ?`, key,
).Scan(&existing); err != nil {
return "", false, fmt.Errorf("查询已有导出任务失败: %w", err)
}
return existing, false, nil
}
// ============================================================
// 幂等键的设计,是这件事里最难的部分
// ============================================================
// 幂等键的粒度,决定了它的行为。太粗和太细都是错的:
//
// 太粗:key = "merchant:10247"
// -> 用户改了时间范围,还是拿到上一次的文件。错。
//
// 太细:key = "merchant:10247:1704067200000000000"(含纳秒时间戳)
// -> 每次点击都是新 key,等于完全没有幂等。错。
//
// 正确:包含"真正影响结果的参数" + "一个合理的时间窗口"
func IdempotencyKey(merchantID int64, start, end, now time.Time) string {
// 参数摘要:时间范围不同 -> 文件不同 -> 不该复用
// 时间窗口:30 秒内算"同一次点击",超过 30 秒算"新的一次请求"
window := now.Unix() / 30
raw := fmt.Sprintf("export|%d|%s|%s|%d",
merchantID,
start.Format("20060102"),
end.Format("20060102"),
window,
)
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:16]) // 32 个 hex 字符,够放进 VARCHAR(64)
}
// ============================================================
// Handler:现在连点十次也只会跑一个
// ============================================================
func (h *Handler) CreateExport(w http.ResponseWriter, r *http.Request) {
var req struct {
MerchantID int64 `json:"merchant_id"`
Start string `json:"start"`
End string `json:"end"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, msg("参数错误"))
return
}
start, err := time.ParseInLocation("2006-01-02", req.Start, h.loc)
if err != nil {
writeJSON(w, http.StatusBadRequest, msg("开始日期格式错误"))
return
}
end, err := time.ParseInLocation("2006-01-02", req.End, h.loc)
if err != nil {
writeJSON(w, http.StatusBadRequest, msg("结束日期格式错误"))
return
}
// 边界:结束时间要包含当天最后一秒
end = end.AddDate(0, 0, 1).Add(-time.Second)
key := IdempotencyKey(req.MerchantID, start, end, time.Now())
taskID := fmt.Sprintf("%s-%d", key[:8], time.Now().UnixNano())
existingID, created, err := h.store.TryCreate(r.Context(), key, taskID)
if err != nil {
h.log.Error("create export task failed", "err", err)
writeJSON(w, http.StatusInternalServerError, msg("提交失败"))
return
}
if created {
// 只有抢到的那个实例,才真的起 goroutine 去跑
go h.runExport(existingID, req.MerchantID, start, end)
}
// 不管是不是自己跑的,都返回同一个 taskID 给前端。
// 前端拿着这个 ID 轮询,用户点十次也是同一个结果。
writeJSON(w, http.StatusOK, map[string]any{"task_id": existingID})
}“你看这段代码里最值钱的一行是什么?“老陈问。
小林看了半天。
“是那个 UNIQUE KEY uk_idem?”
“是。“老陈说,“那一行 DDL,比上面这一百行 Go 代码都值钱。”
“为什么?“小林说,“那不就是个索引吗?”
“因为它把’不重复’这个约束,从应用层的逻辑,变成了数据层的事实。”
“应用层去重,你是在’试图’不重复。数据层约束,你是’不可能’重复。”
“‘试图’会失败,‘不可能’不会。”
“你写一百行 Go 代码去判断’有没有在跑’,中间有一万个地方可能出错:锁没加、map 并发崩、服务重启清空、多副本不共享。”
“而一个唯一索引,它由数据库保证。你的四个实例、八个实例、一百个实例,全都绕不过它。”
老陈停了一下。
“我再告诉你一句,“他说,“幂等这件事,最好的实现方式永远是’找一个已经保证幂等的基础设施’,而不是’自己写一段幂等逻辑’。”
“数据库的唯一索引是一个。Redis 的 SETNX 是一个。对象存储的’同名覆盖’是一个。”
“你要做的,是认出它们。“
老陈的深层发问
第一问:你上一次接到需求的时候,问了几个问题?如果是一个都没有,那你是把”理解需求”这件事,外包给了谁?
第二问:你有没有过这样的时刻——你做完了一个功能,需求方看了一眼说”我不是这个意思”?那一刻你怎么反应的?你心里想的是”你没说清楚”,还是”我没问清楚”?
第三问:你负责的功能里,有几个是”用户会重复触发”的?它们每一个,在服务端都有幂等保护吗?如果没有,它们背后的那个”不重复”的假设,是谁在替你承担?
第四问:如果阿 May 明天离职,接替她的人来问你”订单导出这个功能是怎么设计的”,你能说清楚吗?还是你要说”这个你去问阿 May”?
生活思考题
思考题一
你有没有说过”随便”?有没有人对你说过”随便”?
回想最近一次。那次的结果怎么样?
如果你是对别人说”随便”的那个人——你真的随便吗?你心里有没有一个”但其实我不想要”的清单?
参考答案
大多数人会怎么答
大部分人会承认说过,然后分成两派:
说”随便”的人会说:“我确实没什么特别的偏好,主要是想让大家都开心。” 听到”随便”的人会说:“我最怕别人说随便,因为说了随便的人往往最有意见。”
这个观察很准,但大部分人停在了”这人好难伺候”的结论上。
这个答案为什么不够
先说”说随便的人”。
“我没什么特别偏好”这句话,几乎百分之百是假的。
你一定有不吃的东西。香菜、内脏、很辣的、甜的咸的混在一起的、某种你小时候吃伤过的。
这些东西你不会主动说,因为它对你来说是默认设置。
这里有一个关键的认知陷阱:人无法区分”我的偏好”和”世界的默认状态”。
你觉得”不吃香菜”是”正常”,吃香菜才是”偏好”。所以你不说。
而在听的人那边,他觉得”大部分人吃香菜”,所以他不问。
两个人都觉得自己的假设是”默认值”,于是一个不说,一个不问。
再说”听随便的人”。
“说了随便的人最有意见”这个观察是对的,但它给出的建议通常是错的——大部分人的应对是”那我就多问几句”。
多问几句有用,但不够。因为你问出来的,只是对方”当时能想起来的”约束。
你问”有什么不吃的吗”,对方说”我不要辣的”。然后菜上来了,是甜的,他还是不高兴。因为”不吃甜的”这件事,他在你问的那个瞬间没想起来。
更深的一层是什么
“随便”这个词的本质,不是”我没需求”,是**“我的需求在我意识到它之前不存在”**。
这句话听着玄,但它有一个非常实用的推论:
你不能靠”问”来获取需求。你要靠”给选项”来激发需求。
“你有什么不吃的吗?” —— 这是一个开放问题,对方要在脑子里搜索一遍所有食物,他搜不全,所以他说”没有”。
“咱们吃川菜还是粤菜?” —— 这是一个封闭问题,对方只需要做一次比较。而在做这次比较的时候,他脑子里会瞬间冒出很多东西:
- “川菜啊……上次吃辣的胃疼了。”
- “粤菜好像有点清淡,但我这两天正好想清淡点。”
你看,需求是在”选择”的过程中浮现的,不是在”回忆”的过程中浮现的。
这个洞察,是产品经理最值钱的技能之一,也是工程师最缺的技能之一。
再往深一层,还有一层更狠的:
“随便”除了是”我没说”,有时候还是”我不敢说”或”我不想负责”。
一桌人吃饭,说”随便”的人,往往是不想承担”点错了”的责任。
“你点的,不好吃也怪你。”
这是一种责任转移。而责任转移,是所有协作问题的根源。
落到 Go 工程师身上
把上面这三层,直接翻译成三条可执行的动作:
一、永远不要问”有什么要注意的吗”,要问选择题。
错误:这个功能有什么特殊要求吗?
正确:这个导出,是给财务对账用的(所以要完整、要精确、要能追溯),
还是给老板看一眼的(所以要快、要好看、要能筛选)?
这两种我们做出来完全不一样。
注意第二种问法的结构:它给出了两个具体的、互斥的、会导致不同技术决策的选项。
这就是”川菜还是粤菜”。
二、需求评审会上,你要问的是”背景”,不是”需求”。
“背景”就是那些对阿 May 来说是”默认设置”的东西:
- 这个功能,最后一次有人提是什么时候?(越久没被提,说明需求量越小,或者说明它一直被忍着)
- 现在用户是怎么解决这个问题的?(你说”一页一页复制”,那就是复制了多久?如果忍了两年,说明没那么急,你可以慢慢做;如果是上周刚开始的,说明有新变量)
- 这个功能上线之后,谁会第一个发现它坏了?(这个问题能帮你找到真正的使用方)
三、最重要的一条:把”随便”当成一次责任划分的机会。
当有人说”随便,你定”的时候,你要做的不是接过来,而是:
“行,那我来定。但我要先说清楚我按什么定的,你看有没有问题。如果后面要改,我们得重新过一遍工期。”
这听起来很生硬,但它做了一件极重要的事:
它把一次”责任转移”,变成了一次”责任确认”。
对方得到了知情权和否决权,你得到了决策权。双方都清楚边界在哪。
这比”好嘞我来做”然后做完被挑一堆毛病,要好一万倍。
思考题二
你有没有重复做过同一件事,而你自己当时并不知道?
重复付款、重复提交表单、重复发同一条消息、下载了两次同一个文件、买了两样一模一样的东西。
你是怎么发现的?发现的时候是什么感受?
参考答案
大多数人会怎么答
大部分人会举出例子:双十一付了两次款、填表的时候点了两次提交、网速慢的时候重复刷新。
然后总结:“所以要有防重复机制”、“要等一下再点”。
这个答案为什么不够
这个答案把”重复”归因为用户的操作失误。
但你仔细想想:用户为什么会重复点击?
不是因为他手抖。是因为系统没有给他一个明确的”已经收到了”的信号。
按钮按下去了,没有变化。转圈?没有转圈。变灰?没有变灰。什么都没有。
那一刻用户脑子里发生的是:
“我点到了吗?” → “好像没有” → “再点一次。”
重复点击不是用户的错,是系统没有完成一次完整的”交互闭环”。
更深一层的盲区在这里:
我们把”防重复”当成了一个技术问题(加个幂等键),但它首先是一个体验问题(告诉用户你收到了)。
这两者的成本完全不同,效果也完全不同。
更狠的一层是:
系统的”没反应”,往往不是因为它在忙,而是因为它的设计里根本没有”回应”这一步。
你写 POST /export 的时候,你想的是”收到请求 → 起个任务 → 返回 taskID”。
你没想过的是:从用户按下按钮,到你的服务处理完,中间有网络传输、负载均衡、网关,这些地方都可能吃掉几十到几百毫秒。
而用户的耐心阈值,是 100 毫秒。
超过 100 毫秒没有反馈,用户就会认为”没点上”。
这就是为什么幂等不是”可选优化”,而是”必选项”。
更深的一层是什么
这里有一个更本质的洞察,关于”边界”:
所有的重复问题,本质上都是”同一个动作,被两个不同的系统数了两次”。
你点了一次。你的浏览器认为是一次。你又点了一次,浏览器认为是第二次。 但从”业务”的角度看,这是同一个意图(“我要导出这个文件”),应该只产生一个结果。
“操作”和”意图”是两个不同的东西。
幂等的本质,就是:把”意图”识别出来,而不是数”操作”的次数。
这就是为什么幂等键的设计那么难——你要判断的是”这两次点击,是不是同一个意图”。
- 用户点两次,间隔 0.5 秒 → 同一个意图。
- 用户点两次,间隔 5 分钟 → 可能是两个意图(他改了时间范围又点了一次)。
- 用户改了时间范围又点 → 一定是两个意图(结果不一样)。
幂等键的设计,就是你对”什么算同一个意图”的定义。
而这个定义,你没法从技术上推导出来,你只能从业务上问出来。
这就是为什么”你不问需求,就做不对幂等”。
落到 Go 工程师身上
给你三条具体的规则:
一、任何写操作(POST/PUT/DELETE),默认都要有幂等设计。
不是”如果会重复就加”,是”默认都加,除非你能证明它不会被重复触发”。
能被重复触发的路径,比你想的多得多:
- 用户连点(最常见)
- 前端超时重试(你们的 HTTP 客户端有没有 retry?)
- 消息队列的重投(at-least-once 语义)
- 网关的超时重试(很多网关默认会重试幂等性未知的 GET,有些连 POST 都重试)
- 用户的手机和电脑同时操作
二、幂等键应该由调用方生成,还是服务端生成?
答案是:看你能不能信任调用方。
- 内部服务之间:调用方生成(比如上游传一个
X-Request-Id),服务端直接用作幂等键。这样上游重试时带同一个 ID,天然幂等。 - 面向用户的接口:服务端根据业务参数生成。你永远不能信任客户端传来的幂等键,因为他可以每点一次生成一个新的。
三、最重要的一条:幂等不是”拒绝第二次”,是”返回第一次的结果”。
// 错的:第二次请求返回一个错误
if exists {
return errors.New("请勿重复提交") // 用户体验很差,而且上游重试会一直失败
}
// 对的:第二次请求返回第一次的结果
if !created {
return existingTaskID, nil // 前端拿着同一个 ID 去轮询,一切正常
}这个区别非常关键:“拒绝”会让重试变成失败,“返回同样的结果”会让重试变成无操作。
前者你没有解决重复问题,你只是把重复的表现从”生成了五个文件”变成了”用户看到五个错误提示”。
七、大刘半夜给你打电话,是在替你承担你没承担的
「他是 SRE,那不是他的工作吗」
“你跟大刘熟吗?“老陈问。
”……不太熟。“小林说,“就是工作关系。平时在群里说话,偶尔一起吃饭。”
“他那天晚上,骂你了吗?”
“没有。“小林说,“一句重话都没有。”
“你们复盘会的时候,他说你了吗?”
“也没有。”
“事故报告是谁写的?”
”……他写的。”
老陈点点头。
“我问你,“他说,“那天晚上从两点十七分到大概四点,这一个多小时,大刘做了几件事?”
小林开始数。
“他给我打了电话。他判断了影响面。他把那个商家的客户群压住了。他删了临时文件。他拉起了服务。他确认了监控恢复。他还……”
“等等。“老陈打断他,“你把’他给我打了电话’放第一个。”
”……”
“在他给你打电话之前,他做了几件事?”
小林愣住了。
他从来没想过这个问题。
“我给你列一下。“老陈说。
“第一,凌晨两点十几分,告警响了。他是在睡梦里被叫起来的——跟你一样。”
“第二,他从告警判断出是哪个服务。这一步需要什么?需要他知道这个服务的部署、它的指标、它的 Owner 是谁。”
“第三,他判断影响面:OOM 了,重启三次,商家现在查不了订单。这一步他看了几个面板?”
“第四,他决定:是先重启止损,还是先保留现场排查。他选择了先止损。 这意味着他放弃了查出根因的机会,选择了让业务先恢复。”
“第五,他给你打电话。”
“第六,在等你起来的这段时间,他去压客户群。他要跟一个正在生气的客户解释,话怎么说,他要想。”
“第七,删文件。八百多个,他一个一个判断哪些能删。”
“第八,拉服务,看监控,确认恢复。”
“第九,第二天上午十点,复盘会。”
“第十,事故报告。”
老陈停下来。
“这十件事里,“他说,“有几件是你做的?”
”……一件。“小林说,“第七件,删文件,我后来帮他一起删了。”
“对。你做了十分之一。”
小林低着头,没说话。
过了好一会儿,他抬起头。
“老陈,我跟你说实话。“他说,“我那天心里其实有一句话,我一直没说出来。”
“你说。”
“我觉得那是他的工作。“小林说,“他是 SRE,运维是他负责的。我把功能写好了,上线了,剩下的运维是他的事。”
老陈看着他,看了很久。
“这句话,“老陈说,“我要你记住。”
“因为五年之后,你会为这句话感到羞耻。”
“而现在,“他继续,“我不想骂你。我想让你看一件事。”
“那天夜里,“老陈说,“大刘不是在帮你。他是在替你活着。”
“什么意思?”
“意思是:你的代码,运行在生产环境上的每一秒,都有人在替你兜底。”
“内存涨了,是监控在替你看着。磁盘满了,是有人在替你清理。服务挂了,是有人在半夜爬起来重启。”
“你写的那个’我做完了’,是建立在一整群人’替你没做完’的基础上的。”
“而你,“老陈说,“你不但没意识到这一点,你还觉得’那是他们的工作’。”
小林的脸发烫。
“我再说一遍,“老陈的语气缓和了一点,“我不是在道德上批评你。我是要你明白一个事实。”
“ownership 的边界,不是’我的代码’,是’我的代码造成的所有后果’。”
“而那些后果,在你睡觉的时候,是由别人承担的。”
“你要做的,不是感谢他们。“老陈说,“是减少他们替你承担的量。“
门卫老张,和三点钟那场没下的雨
“我给你讲个门卫的事。“老陈说。
“我住了十来年的那个小区,有个门卫,姓张。六十多岁,个子不高,说话带口音。”
“我跟他其实不熟。就是每天早上出门点个头,晚上回来他抬一下杆。”
“有一年,大概是 2017 年秋天,我有一次凌晨三点多才回家。公司在赶一个版本,我在公司待到两点半,打车回去。”
“到小区门口的时候,我看见老张在。”
“他在干嘛?”
“我当时以为他在值班室里坐着。“老陈说,“结果我走进去,值班室没人。灯是亮的,门开着,人不在。”
“然后我听见地下车库那边有动静。”
“我下去看,“老陈说,“老张拿着一个手电筒,在地下车库里,一辆车一辆车地照。”
“他照什么?”
“他照车窗。“老陈说,“他拿手电筒往每一辆车的驾驶位玻璃里照,看窗户有没有关。”
“我说:‘张师傅,您这是干嘛呢?’”
“他说:‘看看有没有没关窗的。’”
“我说:‘这都三点了。’”
“他说:‘天气预报说三点有雨。’”
老陈停了一下。
“我问他:‘那今天下了吗?’”
“他说:‘还没。’”
“我又问:‘您每天都这么查吗?’”
“他说:‘不是,就下雨天。’”
“我说:‘那今天您怎么三点就出来了?’”
“他说:‘预报说三点有雨,我早点来,赶在下雨之前。’”
老陈喝了口茶。
“那天晚上,雨最后下没下,我不记得了。“他说,“但我记得我从车库走回电梯的那段路上,心里特别不是滋味。”
“为什么?”
“因为那天晚上我们小区有大概四百户人家,“老陈说,“其中有三百九十九户,第二天早上起来,完全不知道前一天夜里有人拿着手电筒,一辆车一辆车地照过他们的车窗。”
“我就是那三百九十九户之一。”
“我甚至不知道老张叫什么——我知道他姓张,是因为他的工牌上写着。”
小林没说话。
“大刘就是老张。“老陈说。
“你想想你们公司的监控体系。告警规则是谁配的?阈值是谁调的?半夜谁在 on-call?磁盘满了谁去清?”
“你写代码的时候,心里的那个’真实世界’,有他们吗?”
”……没有。”
“你现在明白我为什么问你’你跟大刘熟吗’了吗?”
小林点点头。
“我再给你讲一个更近的。“老陈说。
“去年我帮一家公司做顾问,他们有个服务,每天凌晨三点会有一个内存尖峰,持续五分钟,然后自己恢复。”
“这个尖峰持续了多久?”
“一年零四个月。“老陈说,“监控上看得很清楚,一条五分钟的尖刺,每天凌晨三点整。”
“没人管吗?”
“没人管。“老陈说,“因为它自己恢复了,没有触发告警——告警阈值是内存超过 4G 持续 10 分钟,它涨到 3.9G,持续 5 分钟,下去。”
“后来呢?”
“后来有一天,他们的云厂商做了一次宿主机迁移,那个服务所在的机器内存少了 1G。”
“那天凌晨三点,它涨到 3.9G,没下去。”
“服务挂了,影响了大概二十分钟。“老陈说,“我去做复盘的时候,问了他们一个问题。”
“什么问题?”
“‘这个尖峰,是谁先发现的?’”
“答案是:没人’发现’。它一直在那儿,所有人在监控上都看得见,但没有人’看见’它。”
“就像老张那盏手电筒,“老陈说,“它照过每一辆车,但没有一辆车知道。“
让别人少替你承担一点:超时、限流、可观测、能退出
“好,还是看代码。“老陈说。
“你要做的四件事:“他说,“给它超时,给它上限,给它眼睛,给它退路。”
“这四件事,对应大刘那天夜里做的十件事里的八件。”
第一件事:超时
// ===== 改之前:没有超时,没有优雅退出,没有连接池上限 =====
func main() {
db, _ := sql.Open("mysql", dsn) // 连接池用默认值:无上限
http.HandleFunc("/export", handleExport)
http.ListenAndServe(":8080", nil) // 没有 ReadTimeout/WriteTimeout,没有 Shutdown
}
func handleExport(w http.ResponseWriter, r *http.Request) {
// 坑 1:用了不带 ctx 的 Query,请求被客户端取消后,这条 SQL 还在数据库里跑
rows, _ := db.Query(`SELECT ... FROM orders WHERE merchant_id = ?`, merchantID)
// 坑 2:没有任何日志。出事了你怎么知道是谁导的、导了多久、失败在哪一步?
// 坑 3:没有任何指标。大刘的告警规则怎么配?
// 坑 4:没有并发上限。一百个商家同时导出,一百个查询同时打在数据库上。
_ = rows
}“这段代码里的每一个坑,“老陈说,“最后都由大刘承担了。”
“没有超时 → 请求堆积 → 连接耗尽 → 服务无响应 → 大刘半夜起来重启。” “没有上限 → 内存涨满 → OOM → 大刘半夜起来重启。” “没有日志 → 排查靠猜 → 大刘半夜多花一小时。” “没有指标 → 告警配不出来 → 大刘根本不知道它坏了,直到客户打电话。”
“你省下的每一行代码,都是大刘半夜的一次起身。”
下面是改完之后的样子:
// ===== 改之后:超时、上限、观测、退路 =====
package main
import (
"context"
"database/sql"
"errors"
"log/slog" // Go 1.21+ 标准库结构化日志
"net/http"
"os"
"os/signal"
"syscall"
"time"
_ "github.com/go-sql-driver/mysql"
"golang.org/x/time/rate"
)
func main() {
// ---- 1. 观测:结构化日志。出事了能按 merchant / task_id 检索 ----
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
db, err := sql.Open("mysql", dsn)
if err != nil {
slog.Error("open db failed", "err", err)
os.Exit(1)
}
// ---- 2. 上限:连接池参数是你的"资源声明"。
// 不设这些值,等于告诉数据库"我想开多少开多少"。
db.SetMaxOpenConns(50) // 最多 50 条连接
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(time.Hour) // 连接最长活一小时,防止被防火墙悄悄断掉
db.SetConnMaxIdleTime(10 * time.Minute)
mgr := export.NewManager(4) // 同时最多 4 个导出任务
h := &Handler{db: db, mgr: mgr}
mux := http.NewServeMux()
// Go 1.22+ 的路由支持方法和通配;中间件显式包裹,别藏起来
mux.Handle("POST /export", rateLimit(http.HandlerFunc(h.CreateExport)))
mux.Handle("GET /export/{id}", http.HandlerFunc(h.QueryExport))
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second, // 防慢速攻击:读请求头最多 5 秒
ReadTimeout: 30 * time.Second, // 读完整请求体最多 30 秒
WriteTimeout: 300 * time.Second,// 导出是长任务,放宽,但绝不能是 0
IdleTimeout: 60 * time.Second,
// 注意:不要用 r.Context() 作为长任务的 ctx。
// 请求结束后 r.Context() 会被取消,你的后台任务会跟着死。
}
// ---- 3. 退路:优雅退出 ----
go func() {
slog.Info("server starting", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server failed", "err", err)
os.Exit(1)
}
}()
// 收到 SIGTERM(k8s 发版时会发):不再接新请求,等手上的活干完再走。
// 这一步能让你每次发版时,少产生几十个"半成品文件"。
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
sig := <-quit
slog.Info("收到退出信号,开始优雅退出", "signal", sig.String())
// 给退出一个硬上限,不能无限等
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
slog.Error("http shutdown failed", "err", err)
}
// 等所有在跑的导出 goroutine 结束(或超时)
if err := mgr.WaitAll(ctx); err != nil {
slog.Error("等待导出任务结束超时,强制退出", "err", err)
} else {
slog.Info("所有导出任务已结束")
}
if err := db.Close(); err != nil {
slog.Error("db close failed", "err", err)
}
slog.Info("bye")
}
// ---- 4. 限流:令牌桶。每秒 2 个请求,桶容量 5(允许短暂突发)----
var exportLimiter = rate.NewLimiter(rate.Limit(2), 5)
func rateLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !exportLimiter.Allow() {
// 429 + Retry-After:告诉客户端"等一下再来",而不是让它在那儿转圈
w.Header().Set("Retry-After", "1")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"msg":"导出请求过多,请稍后再试"}`))
return
}
next.ServeHTTP(w, r)
})
}“然后是 handler 内部。“老陈说。
func (h *Handler) CreateExport(w http.ResponseWriter, r *http.Request) {
start := time.Now()
var req ExportReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, msg("参数错误"))
return
}
// 请求级硬超时:任何一个环节卡住,30 秒后必然返回,不会无限等。
// 这一行,能挡掉大刘夜里 80% 的电话。
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
// 观测:不管成功失败,都留一条记录,带上足够的上下文。
// 这一条日志,能让大刘在两分钟内判断出"是企业客户还是普通客户在导"。
defer func() {
slog.Info("export request",
"merchant", req.MerchantID,
"start", req.Start,
"end", req.End,
"cost_ms", time.Since(start).Milliseconds(),
)
// 指标:延迟 + 次数 + 错误数。大刘的告警规则就配在这三个上面。
h.metrics.ExportRequests.Inc()
h.metrics.ExportLatency.Observe(time.Since(start).Seconds())
}()
// 关键:后台任务要用一个"不会被请求取消"的 ctx,但要带自己的超时。
// 用户关了页面,任务继续跑;但跑超过 5 分钟,一定停。
taskCtx := context.WithoutCancel(ctx) // Go 1.21+
taskID, created, err := h.mgr.Submit(taskCtx, req)
if err != nil {
h.metrics.ExportErrors.Inc() // 错误计数,配告警用
slog.Error("export submit failed", "merchant", req.MerchantID, "err", err)
writeJSON(w, http.StatusInternalServerError, msg("提交失败,请稍后重试"))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"task_id": taskID,
"reused": !created, // 前端可以据此提示"已有任务在进行中"
})
}“最后,“老陈说,“我给你一个标准,用来判断一个功能’够不够格上线’。”
“四个黄金信号。”
他伸出手指数。
“流量(Traffic):有多少人在用?你在指标里能看到吗?”
“延迟(Latency):慢的时候有多慢?P99 是多少?”
“错误(Errors):失败了多少?失败在哪一步?”
“饱和度(Saturation):它还剩多少余量?内存用了多少?连接用了多少?goroutine 有多少?”
“这四个数字,“老陈说,“就是大刘的’天气预报’。”
“你给他这四个数字,他能在下雨之前,把手电筒打开。”
“你不给他,他只能在雨下下来之后,去一辆一辆车地敲窗户。“
老陈的深层发问
第一问:你上一次主动去看运维的监控面板,是什么时候?你上一次问大刘’最近我这块有什么异常’,是什么时候?
第二问:你的服务上线之后,有没有人因为它半夜起来过?你知道是谁吗?你跟他说过话吗?
第三问:如果把你负责的所有功能全部下线,大刘的 on-call 电话会减少多少通?如果答案是’零’,那这一年你做的这些事,价值在哪里?
第四问:你有没有做过一件’没人要求、没人知道、但让系统更稳一点’的事?如果做过,你还记得是什么吗?如果没做过,为什么?
第五问:如果明天你离职,接替你的人能不能在一天之内搞清楚你这块地现在是什么状态?如果不能,那这段时间里,是谁在替你记着?
生活思考题
思考题一
你生活里有没有一个”老张”?
一个你知道他在,但你从来没认真想过他在做什么的人。小区的门卫、公司的保洁、每天给你送外卖的同一个人、你妈、你伴侣。
挑一个,具体地想:他昨天的这个时间,在做什么?
参考答案
大多数人会怎么答
大部分人会想到几个人,然后有点感慨,说”确实应该多感激身边的人”。
也有人会很诚实地承认:“我想不出来,我确实没注意过。”
这个答案为什么不够
“要多感恩”这个结论,方向是对的,但它有一个问题:它把这件事变成了一个情绪问题,而情绪是会过去的。
你今天感动一下,明天该怎么样还怎么样。
而且它还有一个更隐蔽的陷阱:“感恩”这个动作,暗含了一个等级关系——我在上,他在下,我赐予感激。
但老张那天夜里做的事,不是在”服务”老陈。他是在做一件他自己的专业判断:天气预报说三点有雨,我必须在下雨之前把车库查完。
他有他的专业,只是这份专业的受众看不见。
更深一层的盲区:
我们之所以看不见他们,不是因为我们冷漠,是因为”正常运转”这件事,天然是隐形的。
你的心脏跳了三十年,你从来没注意过它。它出问题的那一天,你才会第一次感受到它的存在。
所有的”基础设施”都是这样:网络、电力、自来水、父母的身体、伴侣的情绪劳动、SRE 的 on-call。
它们在的时候,你感知不到。它们不在的时候,一切都完了。
这就是为什么”ownership”这件事,不只是对自己的代码负责,还包括——
更深的一层是什么
看见”隐形的工作”,是一种可以训练的能力,而它的入口是”反事实想象”。
什么叫反事实想象?就是:
不要问”他在做什么”,要问”如果他不做,会发生什么”。
你不问”门卫在干嘛”,你问”如果小区没有门卫,一个月之后这里会变成什么样”。 你不问”大刘在干嘛”,你问”如果公司没有 SRE,我们的服务能撑多久”。 你不问”保洁阿姨在干嘛”,你问”如果一周没人打扫,这层楼会变成什么样”。
一旦你开始做这个思想实验,那些隐形的东西会瞬间变得清晰可见。
这就是为什么很多公司在做” Chaos Engineering”(混沌工程)——故意把某个依赖打挂,看看会怎么样。
它的本质不是测试系统,是让隐形的东西显形。
再往深一层,有一个更难受的洞察:
别人的”隐形工作”,往往正是你的”显性失误”的兜底。
你没设超时 → 大刘半夜起来重启。 你没做限流 → 大刘半夜扩容。 你没写日志 → 大刘半夜猜。 你没留退路 → 大刘半夜背锅。
你的每一个”没做”,都是别人的一个”不得不做”。
而可怕的是,这个转化是单向的:他替你扛了,你就更不知道自己没做。
这就是 P4 和 P5 之间最隐蔽的那道墙:在 P4,你的失误由别人承担;到 P5,你要开始承担自己的失误。
落到 Go 工程师身上
给你三个具体动作:
一、去找大刘(或者你们的 SRE / 值班同学)聊一次,问一个问题:
“过去三个月,我负责的东西,给你添过几次麻烦?”
只问这一个问题,然后不要辩解,不要解释,就听着。
这个问题会让你非常不舒服。但这个不舒服,是你能拿到的最便宜的反馈。
二、给你负责的每个功能,补上”四个黄金信号”,并且自己去配一条告警。
不是让 SRE 配,是自己配。配的过程中你会想清楚一件事:什么情况下,我希望半夜被人叫起来?
这个问题的答案,会彻底改变你对”完成”的定义。
三、最有价值的一条:建立一份”这块地的履历表”。
一个 markdown 文件,放在你负责的目录下,内容包括:
- 它是什么,给谁用
- 它依赖什么(上下游、超时、配额)
- 它历史上出过什么事故,怎么修的
- 现在已知的隐患(那些”一直在响但你习惯了”的报警)
- 出问题的时候,第一件事做什么
这份文档,是你能给下一个 owner 最好的礼物,也是你能给大刘最好的礼物。
更重要的是:写这份文档的过程,会强迫你去问、去查、去承认自己不知道的东西。
思考题二
你有没有过”一直在那儿,但没人管,直到它突然爆了”的经历?
家里某个一直响的异响、身体某个一直有的小毛病、一段一直有问题的关系、公司某个一直在告警的服务。
它最后是怎么爆的?爆之前,有没有信号?
参考答案
大多数人会怎么答
大部分人会举出例子,然后说:“其实早就有信号了,只是没当回事。”
然后总结:“小问题要及时处理,不要拖成大问题。”
这个答案为什么不够
“要及时处理”这个建议,和”要多感恩”一样,正确但不可执行。
关键在于:你当时为什么没处理?
不是因为你懒。是因为你没有能力判断”它值不值得现在处理”。
你想想那个场景:
一个告警,一周响三次,每次自己就恢复了。 你的待办列表里有二十件事。 这个告警排第几?
它一定排在最后。因为排在它前面的每一件事,都有一个明确的”不做会怎样”,而它没有。
这就是”一直有信号但没人管”的真正机制:
不是信号不够强,是信号的”后果不明确”。
一个 P0 故障,你立刻处理,因为你知道不处理的后果是”被开除”。 一个”内存有点高”,你不处理,因为你不知道不处理的后果是什么。
更深一层:
那些”一直在那儿”的东西,之所以能一直存在,恰恰是因为它们没有造成过可见的后果。
这是幸存者偏差的反面:它们没爆,所以看起来”没事”;而它们之所以还没爆,可能只是因为条件还没凑齐。
老陈讲的那个”每天凌晨三点的内存尖峰”就是典型的例子:它存在了一年零四个月,一直”没事”,直到云厂商做了一次宿主机迁移,条件凑齐了,它爆了。
更深的一层是什么
这里有一个非常反直觉的洞察:
“还没出事”不是”没事”的证据,它只是”条件还没凑齐”的说明。
而”条件凑齐”这件事,往往不是你控制的——它是外部环境的变化。
- 你的服务内存一直 3.9G,没事。直到运维把 limit 从 4G 调到 3.8G。
- 你的接口一直 P99 800ms,没事。直到上游把超时从 2s 改成 500ms。
- 你的 SQL 一直全表扫,没事。直到数据从十万涨到一千万。
- 你的代码一直有一个 goroutine 泄漏,没事。直到你们两周没发版。
你的系统不是一个静态的东西,它是一组”暂时成立的假设”。
而 P5 这一级,最重要的能力之一,就是:知道自己依赖哪些假设。
这就是这一章最核心的那句话的另一面:
“做完”是任务视角,“跑起来”是责任视角。
而”跑起来”的真正含义是:你得知道,它现在是靠什么在”跑着”的,那些东西什么时候会变。
落到 Go 工程师身上
给你一个具体的方法,叫”假设清单”:
给你负责的模块,列一张表,写下来”它现在能正常跑,是依赖于哪些假设”。
比如你那个导出:
| 假设 | 现在的实际情况 | 什么情况下会破 |
|---|---|---|
| 单个商家订单数 < 10 万 | 最大 11.2 万 | 大商家续约 / 数据只增不删 |
| 同时在导出的任务 < 5 个 | 峰值 3 个 | 月底对账高峰 |
| 磁盘剩余 > 5G | 剩 87% 用量 | 文件不清理就会满 |
| MySQL 查询 < 5 秒 | P99 1.2 秒 | 数据量翻倍、索引失效 |
| 用户不会连点 | —— | 慢的时候一定会连点 |
| 至少每两周发一次版 | 平均 10 天 | 封版期、假期 |
这张表,列一遍大概半小时。
而这半小时的价值在于:它把你从”它现在没问题”,变成了”它现在靠这几条撑着”。
前者让你安心,后者让你警觉。
再给你一条配套的动作:
这张表里,挑出”最脆弱的那一条”,给它配一个监控。
不是给它配告警(还没到那个程度),是配一个能看见的数字。
比如”单个商家订单数”,就做一个每天跑一次的统计,把 top 10 输出出来。
让它从”我不知道”,变成”我知道它在涨,速度是每个月 8%”。
这个数字,三个月之后会救你一次。
八、你的代码三个月后你自己还看得懂吗
小周问了一句话,小林答不上来
周一下午,小林在给小周讲代码。
小周是组里的实习生,去年九月来的,小林带他。那天小周在看订单模块的一个文件,看到一半,抬起头问了一句:
“林哥,calcWindow 这个函数,为什么这里要减 8 小时?”
小林转过椅子,凑过去看。
func calcWindow(t time.Time) (time.Time, time.Time) {
start := t.Add(-8 * time.Hour).Truncate(24 * time.Hour)
end := start.Add(24 * time.Hour)
return start, end
}小林盯着这四行,看了大概十秒钟。
”……这个。“他说,“应该是时区的问题。”
“什么时区的问题?”
“就是……我们数据库里存的是 UTC,但业务上要按北京时间算天。”
“那就是 UTC 减八小时?“小周问,“那为什么不用 time.LoadLocation?”
”……”
小林说不出话来。
因为他也不知道为什么不用 LoadLocation。他只记得当时这么写完,跑通了,就没再动过。
“我看看 git log。“他说。
他查了一下,这段代码的提交日期是 2022 年 11 月 3 日。提交信息是:fix: 修复日报时间窗口错误。
就这一行。没有正文。
他点开那次 MR,只有他自己 review 的,他自己批准的。
“林哥,“小周又问,“那这个 Truncate 是什么意思?”
”……把时间截断到天的开始。”
“哦。“小周点点头,“那如果是夏令时呢?”
“中国没有夏令时。”
“哦对。“小周说,“那这个函数有没有测试?”
小林打开那个目录,看了一眼。
没有。
那天晚上,小林坐在工位上,把那段代码看了二十分钟。
他最后确认了两件事。第一,这段代码是对的——Add(-8h) 之后 Truncate(24h),在这个特定场景下确实能得到”北京时间当天零点对应的 UTC 时间”。第二,他完全不记得自己当时是怎么想出来的。
他花了二十分钟,才重新理解了一段他自己写的、十一行的代码。
那个写着”书房-杂物”的箱子
“我 2020 年搬过一次家。“老陈说。
“从东四环搬到望京。东西不多,找了个小的搬家公司,一辆厢货,两个师傅,一天搬完。”
“我打包打了三天,一共打了二十七个箱子。每个箱子上我都用马克笔写了标签:‘厨房-碗’、‘卧室-冬装’、‘书房-书’。”
“有一个箱子,“老陈说,“我写的是’书房-杂物’。”
“这个箱子我封好之后,一直到 2023 年,三年,我都没打开过。”
“为什么?”
“因为它里面装的是’我觉得可能有用,但不知道放哪’的东西。“老陈说,“你搬家的时候一定有这样的东西——它不属于任何一类,但你又舍不得扔,于是你单独给它一个箱子。”
“2023 年我整理储藏室,把它打开了。”
“里面有什么?”
“我给你数一下。“老陈说。
“一个坏了的路由器,2014 年的,天线断了一根。”
“三根线。一根 HDMI,一根 VGA,还有一根我到现在不知道是什么接口。”
“一个装螺丝钉的塑料袋,里面大概二十几颗螺丝,长短不一,有几个还带着膨胀管。”
“一张 2015 年的电费缴费单。”
“一个手机充电器,Micro-USB 的。”
“还有一本 2011 年的会议手册,某个技术大会的,扉页上有我自己的签名。”
老陈说到这,停了一下。
“我拿着那个装螺丝钉的塑料袋,站在储藏室里,看了大概五分钟。”
“看什么?”
“我在想,我为什么会把这袋螺丝钉留下来。”
“想出来了吗?”
“没有。“老陈说,“到现在都没想出来。”
“但我想明白了一件事。“他说,“我留它的时候,一定有一个理由。”
“那个理由在当时一定是充分的。可能我当时想的是’这是拆书架时多出来的,万一以后要装回去呢’。可能我想的是’这几颗螺丝质量很好,扔了可惜’。”
“但我没有把这个理由,写在那个塑料袋上。”
“于是三年之后,我手里拿着一袋螺丝钉,我既不敢扔,也不知道怎么用。它占用了我的储藏室,占用了我五分钟,占用了我到现在还在想它的脑细胞。”
“你猜我最后怎么处理它的?”
”……扔了?”
“我放回去了。“老陈笑了,“我又放回储藏室了。因为我想,万一呢。”
小林也笑了。
“你知道你的代码里有多少个这样的塑料袋吗?“老陈问。
”……”
“我给你举几个你自己写的。”
老陈把笔记本转过来。
“// TODO: 临时方案,等阿May confirm 后改成配置。”
“这是小林你在 2021 年写的。阿 May 去年就离职了。”
“// 这里减 8 是因为时区,不要动。”
“不要动。你告诉了别人’不要动’,但你没告诉他’为什么不要动’,也没告诉他’什么情况下可以动’。”
“所以小周看到了,他不敢动,但他也不知道为什么不能动。他只能来问你。”
“而你,“老陈说,“你花了二十分钟才想起来。”
“你写的每一行注释,都是在给未来的某个人留一张纸条。而大部分时候,未来的那个人就是你自己。”
“问题是,“他继续,“你在写那张纸条的时候,你以为未来的自己还记得当时的上下文。”
“他不记得。”
“他连自己为什么留下一袋螺丝钉都想不起来。“
可读性:从”能跑”到”能被人读懂”
“我们看代码。“老陈说。
“我把你那个文件里的几个地方摘出来,“他说,“你看看。”
改之前
// ===== 改之前:每一个都能跑,但没有一个是"能读懂"的 =====
package report
import (
"strings"
"time"
)
// 问题 1:四个 bool 参数。调用方写成 ProcessOrder(o, true, false, true, false),
// 读代码的人必须回到定义处才能看懂,而且极易传错顺序(编译器不会报错)。
func ProcessOrder(o *Order, isVip, isNew, fromApp, force bool) (int, error) {
// 问题 2:嵌套五层。读到这里,你的脑子里要同时记着四个条件。
if o != nil {
if o.Status == 2 {
if isVip {
if !force {
// 问题 3:魔术数字。2 是什么?8 是什么?86400 是什么?
if o.Amount > 100000 {
// 问题 4:字符串循环拼接,O(n^2)
s := ""
for _, i := range o.Items {
s += i.Name + ","
}
// 问题 5:注释在翻译代码,不是在解释原因
// 遍历 items,拼接名称
o.Remark = s
return 8, nil
}
}
}
}
}
// 问题 6:用 24*time.Hour 表示"一天"。
// 在夏令时切换的日子(虽然中国没有,但你的代码可能在别的市场跑),
// 一天可能是 23 或 25 小时。
deadline := time.Now().Add(24 * time.Hour)
_ = deadline
// 问题 7:Parse 出来的是 UTC,但你当本地时间用,差 8 小时
t, _ := time.Parse("2006-01-02", "2023-04-11")
_ = t
// 问题 8:Format 的 layout 写成别的语言的格式。
// Go 的参考时间是 2006-01-02 15:04:05,写成 "YYYY-MM-dd" 会原样输出。
_ = time.Now().Format("YYYY-MM-dd HH:mm:ss")
// 问题 9:Split 再取下标,不如 Cut 清晰
parts := strings.Split(o.Remark, ":")
if len(parts) > 1 {
_ = parts[1]
}
return 0, nil
}“你把这段代码读完,“老陈说,“需要多久?”
”……两分钟?”
“两分钟,读四十行。“老陈说,“而它做的事,说出来只要一句话。”
“那为什么写的时候不觉得?”
“因为写的时候,你脑子里有全部上下文。”
“你知道 2 是已付款,你知道 100000 是一千元,你知道那四个 bool 分别是什么。”
“而读的时候,这些上下文全部消失了。这就是’能跑’和’能读懂’的全部差距。”
改之后
// ===== 改之后 =====
package report
import (
"context"
"fmt"
"strings"
"time"
)
// ---- 1. 用明确的类型替代裸 bool ----
type Channel string
const (
ChannelApp Channel = "app"
ChannelWeb Channel = "web"
ChannelMiniApp Channel = "miniprogram"
)
type CustomerKind string
const (
CustomerVip CustomerKind = "vip"
CustomerNew CustomerKind = "new"
CustomerNormal CustomerKind = "normal"
)
// ProcessOptions 把"一堆开关"变成一个有名字的结构体。
// 调用方现在写的是 ProcessOrder(ctx, o, ProcessOptions{Channel: ChannelApp, Customer: CustomerVip}),
// 不用回定义处也能读懂,而且加字段不会破坏已有调用。
type ProcessOptions struct {
Channel Channel
Customer CustomerKind
// Force 跳过金额校验。仅在后台人工补单时使用,
// 因为财务偶尔要处理历史遗留的异常订单。
Force bool
}
// ---- 2. 状态用常量,不要用数字 ----
type OrderStatus int
const (
StatusPending OrderStatus = iota + 1 // 待付款
StatusPaid // 已付款
StatusShipped // 已发货
StatusDone // 已完成
StatusRefunded // 已退款
StatusClosed // 已关闭
)
// String 让状态在日志和错误信息里可读。
// 这一个方法,能省掉你未来无数次"8 是什么"的排查。
func (s OrderStatus) String() string {
switch s {
case StatusPending:
return "待付款"
case StatusPaid:
return "已付款"
case StatusShipped:
return "已发货"
case StatusDone:
return "已完成"
case StatusRefunded:
return "已退款"
case StatusClosed:
return "已关闭"
default:
return fmt.Sprintf("未知状态(%d)", int(s))
}
}
// ---- 3. 金额用 int64(分),常量带上单位 ----
// vipLargeOrderThresholdFen 大额订单阈值:1000 元 = 100000 分。
// 用"分"而不是 float64,避免 0.1+0.2 != 0.3 这类问题。
const vipLargeOrderThresholdFen = 1000_00
// ---- 4. 时区只加载一次,放在包级别 ----
// 注意:time.LoadLocation 会读文件系统(或内嵌的 tzdata),有 IO 开销。
// 不要在热路径里每次都调用它。
// (如果部署环境没有 tzdata,需要 import _ "time/tzdata" 把时区数据编进二进制)
var beijingLoc = mustLoadLocation("Asia/Shanghai")
func mustLoadLocation(name string) *time.Location {
loc, err := time.LoadLocation(name)
if err != nil {
// 这是启动期的"不可恢复错误",直接 panic 是对的:
// 时区错了,后面所有时间计算都是错的,还不如起不来。
panic(fmt.Sprintf("加载时区 %s 失败: %v", name, err))
}
return loc
}
// ---- 5. 主流程:早返回替代嵌套 ----
var (
ErrOrderNil = errors.New("订单为空")
ErrOrderNotPaid = errors.New("订单未付款")
ErrOrderNeedReview = errors.New("大额订单需要人工复核")
)
func ProcessOrder(ctx context.Context, o *Order, opt ProcessOptions) (ResultCode, error) {
// 卫语句(guard clause):把"不满足条件"的情况一个个先挡掉。
// 这样主流程永远在最外层,读起来是从上到下的一条直线,没有缩进地狱。
if o == nil {
return 0, ErrOrderNil
}
if o.Status != StatusPaid {
return 0, fmt.Errorf("%w: 当前状态 %s", ErrOrderNotPaid, o.Status)
}
if opt.Customer == CustomerVip && o.Amount > vipLargeOrderThresholdFen && !opt.Force {
// 注释写"为什么",不写"做什么"。
// "为什么"是这个 if 存在的理由,它永远变不了;
// "做什么"代码本身就写了,注释只会和代码脱节。
return 0, fmt.Errorf("%w: 金额 %d 分超过 VIP 大额阈值 %d 分",
ErrOrderNeedReview, o.Amount, vipLargeOrderThresholdFen)
}
o.Remark = joinItemNames(o.Items)
if err := ctx.Err(); err != nil {
return 0, err
}
return ResultOK, nil
}
// joinItemNames 一个小函数,一个职责。
// 它的名字就是它的文档,不需要注释。
func joinItemNames(items []Item) string {
// 预分配容量,避免 append 时反复扩容
names := make([]string, 0, len(items))
for _, it := range items {
if it.Name == "" {
continue
}
names = append(names, it.Name)
}
return strings.Join(names, ",")
}
// ---- 6. 标准库的正确用法:几个高频坑 ----
func timePitfalls() {
now := time.Now()
// 坑 A:用 Add(24*time.Hour) 表示"明天"
_ = now.Add(24 * time.Hour) // 错:夏令时的日子,一天不是 24 小时
_ = now.AddDate(0, 0, 1) // 对:按日历加减
// 坑 B:Parse 出来是 UTC,当本地时间用
_, _ = time.Parse("2006-01-02", "2023-04-11") // 错:得到 UTC 零点
_, _ = time.ParseInLocation("2006-01-02", "2023-04-11", beijingLoc) // 对
// 坑 C:Format 的 layout 写错
_ = now.Format("YYYY-MM-dd HH:mm:ss") // 错:Go 的参考时间是 2006-01-02 15:04:05
_ = now.Format("2006-01-02 15:04:05") // 对
// 坑 D:用 Unix 时间戳算"当天零点"
_ = time.Unix(now.Unix()/86400*86400, 0) // 错:按 UTC 对齐,东八区会差 8 小时
y, m, d := now.Date() // 对:用日历字段重新构造
_ = time.Date(y, m, d, 0, 0, 0, 0, now.Location())
// 坑 E:用 == 比较时间
t1, _ := time.ParseInLocation("2006-01-02", "2023-04-11", beijingLoc)
t2 := time.Date(2023, 4, 11, 0, 0, 0, 0, time.UTC)
_ = t1 == t2 // 错:== 会连 location 和单调时钟一起比较,同一时刻也可能 false
_ = t1.Equal(t2) // 对:只比较时刻
// 坑 F:Split 之后再取下标
s := "order_no:E001"
if parts := strings.Split(s, ":"); len(parts) > 1 {
_ = parts[1]
}
// 对:strings.Cut(Go 1.18+)语义更清晰,且只分配一次
if key, val, ok := strings.Cut(s, ":"); ok {
_, _ = key, val
}
}
// ---- 7. JSON 编码:别用 map[string]any ----
// 错:用 map 承载结构化数据,字段类型全靠断言,错一个就 panic
func badDecode(data []byte) (float64, error) {
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
return 0, err
}
return m["amount"].(float64), nil // float64 表示金额会丢精度
}
// 对:定义结构体,金额用 int64(分),时间用 time.Time
type OrderDTO struct {
OrderNo string `json:"order_no"`
Amount int64 `json:"amount"` // 单位:分
CreatedAt time.Time `json:"created_at"`
}
func goodDecode(r io.Reader) (OrderDTO, error) {
var dto OrderDTO
dec := json.NewDecoder(r)
// 禁止未知字段:能挡掉"前端把 order_no 写成 orderNo"这类静默失效的 bug。
// 没有这一行,字段名拼错时你拿到的是零值,而不是一个错误。
dec.DisallowUnknownFields()
if err := dec.Decode(&dto); err != nil {
return dto, fmt.Errorf("解析订单失败: %w", err)
}
// 检查后面还有没有多余内容,防止传进来两个 JSON 对象
if err := dec.Decode(&struct{}{}); err != io.EOF {
return dto, errors.New("请求体包含多个 JSON 对象")
}
return dto, nil
}“你读完这段代码,“老陈说,“需要多久?”
”……一分钟。”
“而它做的事,是刚才那四十行的三倍。”
“为什么?”
“因为这段代码里,没有一个字符需要你去猜。”
老陈伸出手指数。
“第一,名字即文档。 ProcessOptions{Channel: ChannelApp} 比 ProcessOrder(o, true, false, true, false) 多了十一个字符,省了三十秒。”
“第二,常量即解释。 vipLargeOrderThresholdFen 比 100000 多了二十个字符,省了一次’这他妈是多少’的停顿。”
“第三,注释讲为什么。 你写// 遍历 items,拼接名称,是在把 Go 翻译成中文,这是浪费。你写// 仅在后台人工补单时使用,因为财务偶尔要处理历史遗留的异常订单,这是在告诉未来的那个人:这个 if 存在的理由是什么,什么情况下可以删掉它。”
“第四,卫语句。 主流程是一条直线,不是一座金字塔。人的短期记忆能同时装下大概七个东西,每多一层嵌套,你就多占一个。”
“第五,“老陈说,“小函数。 joinItemNames 只有十行,读完不需要记住任何上下文。”
“我给你一个判断标准,“他说,“一个函数,如果你需要滚动屏幕才能读完,它大概率太长了。”
“为什么是屏幕?“小林问。
“因为人的眼睛不能在滚动的同时保持上下文。“老陈说,“滚一次,你就丢一部分。滚三次,你在读第一行的时候,已经忘了它了。”
“那多少行合适?”
“我见过一个说法,是三十行。“老陈说,“但我不喜欢给数字。我给你一个更好的标准:”
“一个函数应该只有一层抽象。”
“什么叫一层抽象?ProcessOrder 这一层,讲的是业务规则:空、没付款、大额要复核。它不该去关心’名字怎么拼接’。名字怎么拼接是 joinItemNames 那一层的事。”
“你在一层里关心两件事,读者就要在两件事之间来回切换。切换是有成本的。“
老陈的深层发问
第一问:你现在能不能打开你三个月前写的一个文件,在不看 git log、不看需求文档的前提下,说清楚每一个 if 是为了挡住什么情况?
第二问:你写的注释里,有多少是在解释”为什么”,有多少是在把代码翻译成中文?如果你把所有”翻译型注释”删掉,你的代码会变得难读多少?
第三问:如果小周明天要接手你手上的东西,你最不想让他看到哪个文件?为什么是那个文件?
第四问:你有没有写过那种”我知道这样不好,但先这样”的代码?那行 TODO 现在还在吗?它旁边的那件事,后来做了吗?
第五问:你的代码里,有多少知识是只存在于你脑子里的?如果你明天出车祸住院一个月,那些知识会怎么样?
生活思考题
思考题一
你有没有翻到过自己很久以前写的东西——日记、朋友圈、博客、备忘录、给别人的留言——然后完全看不懂自己在说什么?
挑一条,仔细想想:你当时写它的时候,脑子里有什么是”默认大家都知道(包括未来的自己)“的?
参考答案
大多数人会怎么答
大部分人会笑一笑,举出几个例子:“2016 年发的一条朋友圈,我现在完全不知道在说什么。”
然后总结:“所以要写清楚,要给未来的自己留点线索。”
这个答案为什么不够
“要写清楚”这四个字,还是不可执行。因为——
你当时也觉得你写清楚了。
这是这个现象最诡异的地方:你在写那条朋友圈的时候,你百分之百确信未来的自己能看懂。你现在看不懂,不是因为你当时敷衍,是因为你和未来的自己,共享的上下文比你以为的少得多。
这里真正的机制,心理学上有个名字,叫**“知识的诅咒”(Curse of Knowledge)**:
一旦你知道了一件事,你就无法想象”不知道它”是什么感觉。
你写代码的时候,你知道这个 2 是已付款,因为你已经在这个模块里待了三周。你没法想象”不知道 2 是什么”的状态,所以你不会觉得需要给它命名。
更深一层的盲区:
我们以为”上下文”是存在代码里的,其实大部分上下文是存在”做这件事的那段时间”里的。
你写那段代码的时候,你刚跟阿 May 吵完一架,你知道她对”金额”的定义改了三次;你知道数据库里有一批历史脏数据,所以那个 if 必须那么写;你知道上游那个服务下周要重构,所以你现在不能动这个字段。
这些,一条都进不了代码。
而三个月后,你只记得代码,不记得那场架、那批脏数据、那次重构。
更深的一层是什么
最狠的洞察在这里:
代码是”结论”,不是”推理过程”。
你写下的每一行代码,都是一次决策的结果。而代码里只保存了结果,没有保存决策。
这是所有软件的根本困境:我们交付的是结论,但维护需要的是推理。
这就是为什么”注释要写为什么”这条建议,被说了一万遍,还是有人不当回事——因为”为什么”这部分,在你写代码的那一刻,是”显而易见的背景”,不是”需要记录的信息”。
而破解它的办法,不是”提醒自己多写注释”,而是换一个视角:
不要把自己当成”写代码的人”,把自己当成”三个月后接手这段代码的人”。
这个视角切换,会让你做出完全不同的决策:
- 你会给常量起名字,不是因为”规范”,是因为那个人不知道
2是什么。 - 你会把四个 bool 换成结构体,不是因为”优雅”,是因为那个人要翻到定义处才能看懂调用。
- 你会写那句”为什么”,不是因为”要写注释”,是因为那个人不知道你跟阿 May 吵过那一架。
“三个月后的自己”不是你自己,是一个陌生人。你要给他写代码。
落到 Go 工程师身上
给你三条具体的规则:
一、“三问注释法”。
每次你要写注释的时候,问自己:
- 这行注释,是在说”代码做了什么”吗?—— 如果是,删掉。代码本身就说了,注释只会过期。
- 这行注释,是在说”为什么这么做”吗?—— 如果是,留下。这是唯一不会过期的注释。
- 这行注释,是在说”什么情况下可以删掉这段代码”吗?—— 如果是,这是最有价值的注释。
第三条特别重要。你写 // 临时方案,等阿May confirm 后改成配置,这是一个没有截止条件的 TODO。
如果你写成 // 临时方案:硬编码白名单,等配置中心上线后(预计 2021-Q2)删除。联系人:阿 May,那它就是有截止条件的。三年后有人看到它,立刻知道”这件事已经过期了,可以删”。
二、提交信息的格式,比你想的重要一百倍。
今天开始,你的提交信息至少写三行:
修复日报时间窗口计算错误
问题:日报用 UTC 自然日切分,导致北京时间 0-8 点的订单被算到前一天。
原因:Parse 出来的时间是 UTC,但业务按北京时间算"天"。
改法:改用 ParseInLocation + time.Date 按日历构造零点。
验证:补了一个单测,覆盖 2023-04-11 00:30 和 2023-04-11 09:30 两个时间点。
这四段,大概三十秒。
三个月后,当你(或者小周)对着 git blame 想弄明白某个奇怪的写法时,这三十秒能省下二十分钟。
三、最有用的一条:写完代码,隔一天再读一遍。
不是当天。是睡一觉之后。
因为当天你脑子里还有全部上下文,你读什么都觉得顺。
睡一觉起来,那些上下文开始消退,你开始接近”一个陌生人在读这段代码”的状态。
这个”隔一天再读”,是你能做的最便宜、最有效的代码审查。
思考题二
你有没有整理过衣柜或者储藏室?
回想一下:你把一件东西拿在手里,犹豫”扔还是不扔”的那几秒钟,你脑子里在想什么?
最后让你做出决定的,是什么?
参考答案
大多数人会怎么答
大部分人会承认:“大部分时候是’万一用得上’,所以又放回去了。”
也有人会说:“我现在学乖了,用’一年没碰过就扔’的规则。”
这个答案为什么不够
“一年没碰过就扔”是个好规则,但它只解决了”已经囤了的东西”,没解决”为什么会囤”。
而且它有个巨大的漏洞:有些东西你一年没碰,但它确实是必要的。 比如灭火器、急救包、备用钥匙、往年凭证。
所以”扔不扔”这个问题,真正的难点不在”使用频率”,在——
你不知道”万一”的概率有多大。
你想想那个决策过程:
你手里拿着一根 VGA 线。 你想:“这玩意儿我现在用不上。” 你又想:“但万一哪天要接投影仪呢?” 你算了算:一根线占的地方很小,扔了之后要用的成本很高(跑一趟电脑城)。 结论:留着。
这个推理,在单件物品上是完全正确的。
问题出在:你有四百件这样的东西,每一件单独看都”应该留”。
这就是”囤积”的真正机制:
每一个”留着”的决策,在局部都是理性的;但四百个理性的局部决策,加起来是一个灾难性的全局结果。
更深一层:
“留着”的成本是延迟的、分散的、不可见的;“扔了”的成本是即时的、集中的、可见的。
你留一根线,今天的成本是零。明天的成本也是零。它的成本在三年后——你搬家的时候,它占了你十分钟和你的一箱空间。
而你扔了它,如果明天就要用,成本立刻发生,而且很痛。
所以大脑的系统性偏好,永远是”留着”。
更深的一层是什么
把这个结构搬到代码上,你会看到一个毛骨悚然的对应:
技术债就是储藏室。
- 每一行”先这样吧”的代码,单看都是理性的:改它要花两小时,不改今天的成本是零。
- 每一段”以后再优化”的逻辑,单看都是合理的:现在没出问题。
- 每一个”万一以后要支持”的接口,单看都是划算的:现在抽出来比以后重构便宜。
然后三年之后,你打开这个模块,发现它塞满了你自己都不记得为什么留着的东西。
而这里有一个更难受的洞察:
储藏室里的东西,至少还是”实物”——你打开箱子能看见它。
代码里的”杂物”,大部分是隐形的。
你看不见那个”只有一个实现的接口”,看不见那个”从没被执行过的分支”,看不见那个”为了兼容三年前的一个客户而保留的字段”。
它们在你的代码里,像暗物质一样存在:你知道代码库很大,但你不知道大在哪。
落到 Go 工程师身上
给你三个具体的动作:
一、给”留着”设一个代价。
下次你想写”先这样吧”的时候,不要只是写个 TODO。写清楚:
// TODO(小林, 2026-08): 这里硬编码了白名单。
// 阻塞原因:配置中心的 Go SDK 还没发布(预计 2026-Q4)。
// 如果 2027-Q1 还在,直接删掉这段,改成读配置。这个”截止日期”,就是给”留着”设的代价。它把一个无限期的债务,变成了一个有到期日的债务。
有到期日的债,会被还。没有到期日的债,不会。
二、建立”半年一清理”的机制。
不是等到搬家才整理储藏室。
给你负责的模块,每半年做一次”三件事”:
- 跑一遍覆盖率,找出那些从没被执行过的分支,问一句”它还存在吗”。
- 搜一遍 TODO 和 FIXME,把过期的删掉,把还有效的加上截止日期。
- 找出所有只有一个实现的接口,问一句”它当初是为了什么加的”。
这三件事,加起来大概一个下午。
这一个下午,是你和你的代码库之间的一次”换季”。
三、最重要的一条:学会区分”收藏”和”备用”。
这是整理师近藤麻理惠那个说法的真正内核(虽然她只说了”心动”):
“收藏”是”它本身让我有拥有的愉悦”。 “备用”是”我在为一个可能不会发生的未来付费”。
对应到代码:
- 一个你写过的最优雅的抽象,是收藏。 它没有用,但它让你开心,它让你在 review 的时候被夸。这是真实的价值。
- 一个”以后可能支持 CSV”的接口,是备用。 它不让你开心,它只让你在想起来的时候焦虑。
收藏可以留,备用要定期清。
而区分它们的办法只有一个,很土但很有效:
把它拿在手里(或者打开那个文件),问自己:“如果它明天消失了,我会松一口气,还是心疼?”
松一口气,说明它是债。心疼,说明它是你的。
本章小结:核心心法
咖啡馆外面的天已经黑了。
小林看了一眼手机,晚上八点四十。跟上次一模一样的时刻,他自己都没察觉。
服务员过来加了两次水,第二次的时候欲言又止,大概是要打烊了。老陈没理会,把笔记本合上,推到一边。
“我给你总结一下今天。“老陈说,“三句话。”
“您说。”
“第一句:P5 这一级的全部任务,是把一句话从’我做完了’,改成’它跑起来了’。”
老陈伸出一根手指。
“你注意这两个词的区别。‘我做完了’的主语是’我’,时间点是你提交 MR 的那一刻。‘它跑起来了’的主语是’它’,时间点是永恒的现在进行时——它今天跑着,明天跑着,你度假的时候跑着,你离职三年之后可能还在跑着。”
“你改变不了这个时间尺度。“老陈说,“你只能改变自己对着它的态度。”
“第二句呢?”
“第二句:ownership 的边界,不是你的代码,是你的代码造成的所有后果。”
老陈伸出第二根手指。
“你写的功能,谁在用?用的时候会碰到什么?失败了谁会知道?半夜谁会被叫起来?磁盘满了谁去清?这些全在你的’地’里面。”
“而你今天一整天的辩解,从’测试环境就那么点数据’,到’那是 SRE 的工作’,全部都是在缩小这块地的边界。”
“你每缩小一次,就有一个人替你多扛一点。”
小林没说话。
“我问你,“老陈说,“你今天听完这些,心里最不舒服的是哪一段?”
小林想了很久。
“是门卫老张。“他说。
“为什么?”
“因为……”小林停了一下,“因为我发现我从来没想过,我们公司的监控是谁配的。”
“你知道现在是谁配的吗?”
”……不知道。”
“你可以明天去问。“老陈说,“就问这一句:‘咱们服务的告警规则,是谁维护的?’”
“我问这个干嘛?”
“不干嘛。“老陈说,“你问了,那块地就大了一寸。”
老陈伸出第三根手指。
“第三句:P5 最大的陷阱,是只守自己的一亩三分地。”
“什么意思?“小林说,“我今天是缩小了边界,但你之前说的是’让一块地归我负责’,那不就是守好自己的地吗?”
“这两件事不一样。“老陈说。
“我给你讲个事。”
“我老家那边,以前是农村。我们村有两户人家,一家姓李,一家姓王,两家的地挨着。”
“地挨着的地方,有一条排水沟。”
“这条沟,两家人都不管。”
“为什么?“小林问。
“因为谁都觉得那是对方的。“老陈说,“老李说,这沟在老王那边的地头,该老王清。老王说,这沟排的是老李家的水,该老李清。”
“吵了多少年?”
“我小时候就在吵。“老陈说,“有一年夏天,连着下了三天大雨。”
“那三天,沟里堵满了杂草和泥。水排不出去,往两边漫。”
“老李家的玉米淹了半亩,老王家的也淹了半亩。”
“一亩地,那年颗粒无收。“老陈说,“而那条沟,清一次只要半天。”
“后来呢?”
“后来村里出面,重新划了地界,把那条沟划成’公共的’,两家轮流清。“老陈说,“但那是一年之后的事了。那一年的两家人,都少收了半亩。”
老陈看着小林。
“你现在写的每一行代码旁边,都有这样一条沟。”
“比如什么?”
“你和上游之间的接口,谁定义错误码?你和下游之间的超时,谁设?你的日志格式,跟别的服务一致吗?你的服务挂了,会不会把隔壁服务拖死?”
“这些问题,“老陈说,“没有一个是’你的职责’,也沒有一个是’别人的职责’。它们全部在边界上。”
“而边界上的东西,谁先看见,就是谁的。”
“你今天跟我讲了一整天’不是我的问题’,“老陈说,“我要你记住:在职场里,‘边界上的事’从来不是负担,是机会。它是唯一一种没人跟你抢的东西。”
“因为大部分人,都在守自己那一亩三分地。”
小林坐在那儿,消化着这句话。
过了一会儿,他抬起头。
“老陈,我问你一个事。“他说。
“你说。”
“你今天说的这些——错误处理、goroutine、接口、测试、幂等、监控、可读性——这些我都能学。我有信心三个月之内把它们都补上。”
“我知道你能。“老陈说。
“但你说的那个’ownership’,“小林说,“那个东西要怎么学?”
老陈沉默了一下。
然后他说了一句话。
“你现在回答不了,说明你还没到那一步。”
“什么意思?”
“意思是,“老陈把茶杯里最后一点水喝了,“ownership 不是一个技能,它是一个身份认同。”
“你不是’学会了’ownership,你是’决定成为’一个 owner。”
“而这个决定,不是今天做的,也不是听完我讲这些就能做的。”
“那什么时候做?”
“在你第一次主动去做一件’没人要求你做’的事,并且做完了也不说出去的时候。”
老陈站起来,把外套搭在肩上。
“那一天你自己会知道。”
小林走出咖啡馆,风一吹,打了个冷战。
他站在路边等车,掏出手机。
有三条微信。一条是他老婆问”几点回来”,一条是他妈发的一个养生文章的链接,还有一条是公司的群消息。
他往下滑,滑到大刘的头像。
上一次聊天记录是 2024 年 11 月,他问大刘一个测试环境的权限问题,大刘回了个”已开”。
他点开输入框。
他本来想打”刘哥,上次那个导出的事,谢谢你”。
他打了一半,删掉了。
因为这句话,是在让自己好受,不是在为对方做什么。
他又打了一行。
刘哥,订单导出那个功能,我这周加了监控指标和文件清理,告警阈值我按现在的量配的。你有空帮我看一眼合不合适?
发完之后,他把手机塞回兜里。
车来了。他上了车,找了个靠窗的位置。
手机震了一下。
你配的?
我配的。
过了几秒。
行。那我今天睡个好觉。
小林看着这条消息,忽然笑了。
他想起三年前那个凌晨,大刘在电话那头说的那句”行了,睡吧”。
那天晚上,他没睡着。
今天晚上,大刘能睡着了。
这就是”做完”和”跑起来”之间,那一点点距离。
而他花了三年,才走了这么一点。
自检清单
下面是 P5 这一级的自检清单。请诚实打勾——这张清单没有别人会看,你骗自己没有意义。
如果你有超过三条打不了勾,说明你手上那块地,你只是在”种”,还没有在”管”。
关于「跑起来」
- □ 我能够说出我负责的每一个接口的线上真实数据:QPS 峰值、P99、错误率,以及出现过入参最大值
- □ 我能够说出我负责的功能”最多能处理多少”,并且在这个上限被突破时,系统有明确的行为(拒绝、排队、还是降级)
- □ 我能够给我负责的功能配出至少三个指标(流量、延迟、错误),并且知道它们异常时的第一处理动作
- □ 我能够说出我负责的功能上一次出故障是什么时候、原因是什么、当时怎么修的
- □ 我能够在功能上线之前,就写好”上线后我在哪里看到它、什么数字说明它坏了、坏了我第一件事做什么”这三句话
关于错误处理
- □ 我能够给我负责的模块定义一套领域错误,并且让
database/sql、net/http这类基础设施错误不泄露到业务层 - □ 我能够解释我写的每一个
fmt.Errorf为什么用%w而不是%v,以及调用方会怎么用errors.Is/errors.As - □ 我能够说出我项目里每一个
_ = xxx()的理由,或者把它们全部补上 - □ 我能够做到”只在边界记一次日志”,并且日志带上足够的上下文(哪个用户、哪个操作、哪一层、什么原因)
关于并发与资源
- □ 我在写
go func() { ... }()的时候,能够同时回答:它什么时候结束、谁通知它、它结束谁会知道、它不结束会不会拖垮别人 - □ 我能够给异步任务设并发上限,并且说明这个上限是怎么算出来的
- □ 我能够给我的服务配好连接池参数(最大连接数、最长生命周期),并且说出每一个值背后的理由
- □ 我能够在测试里检测 goroutine 泄漏(用
runtime.NumGoroutine或 goleak),并且这个测试在 CI 里跑
关于设计与可测性
- □ 我能够说出我项目里每一个接口存在的原因,并且删掉那些”只有一个实现且没有测试需求”的接口
- □ 我能够做到”接口定义在使用方那一侧”,并且接口的规模不超过我需要的最小集合
- □ 我能够给我负责的核心逻辑写表驱动测试,并且这些测试能够活过一次内部重构(即:测行为,不测实现)
- □ 我能够不用 mock 框架,只用标准库和少量 fake 完成单元测试
关于需求与协作
- □ 我能够在接到需求的当天,问出”谁用、多久一次、最大多少、失败会怎样、怎么知道坏了”这五个问题
- □ 我能够给我负责的每一个写操作设计幂等键,并且说清楚”什么算同一个意图”
- □ 我能够与我的 SRE / 运维至少聊过一次,问过”过去三个月我这块给你添过什么麻烦”
- □ 我能够写出一份”这块地的履历表”:它是什么、依赖什么、出过什么事、有什么已知隐患、出问题第一件事做什么
关于可读性
- □ 我能够打开我三个月前写的任意一个文件,不看 git log 就说清楚每一个
if是为了挡住什么 - □ 我写的注释里,“为什么”多于”做了什么”,并且每一条 TODO 都有责任人和截止日期
- □ 我能够保证我写的函数不需要滚动屏幕就能读完,或者能够说明它为什么不能更短
- □ 我能够对标准库的高频坑(时间格式与时区、字符串拼接、
json解码、金额表示)说出正确的用法和错误用法的后果
关于这一级的本质
- □ 我能够说出”我做完了”和”它跑起来了”的区别,并且举出我自己的一个真实例子
- □ 我能够举出一个我主动去做、没人要求、做完也没特意说的事
- □ 我能够说出我这块地的边界在哪,以及边界上有哪些”没人管的排水沟”
下一章预告:P6 独立负责篇——从一块地到一个系统。
当小林真的开始管一块地,他很快就发现:这块地不是孤立的。
它的上游在变,它的下游在涨,它脚下的土壤(Go 运行时、GC、GMP)他一无所知。
而老陈会问他的第一个问题是:“你这块地,今年收成怎么样?你说得出来吗?”
那将是另一个层级的问题:不是”怎么做对”,而是”怎么知道它对不对”。