第一章 P4 执行篇:把交代的事做对

心法:把交代的事做对 转变:从「学会」到「交付」 陷阱:等指令,只做被要求的事


开篇:把你第一年写的代码找出来

周六下午两点,城西,一家连招牌都没有的茶室。

老陈约小林在这里见面。小林是通过一个前同事辗转找到老陈的,据说这人做过 CTO,现在做技术顾问,帮几家公司看组织和人。前同事的原话是:“他说话挺扎人的,但你听完会想很久。”

五十多岁。头发花白了一半,穿一件洗得发软的灰色 polo 衫,面前摆着一只盖碗,茶叶泡得很浓。他给小林倒了一杯,没问小林现在做什么、在哪家公司、带几个人,也没寒暄。

他说的第一句话是:“你先说说,你觉得你现在卡在哪?”

小林愣了一下。他准备了一晚上的自我介绍,全卡在喉咙里。

“我……”他斟酌了一下,“我觉得我技术不差。goroutine、channel 这些我熟,线上问题我也能查,pprof 我也会用。但是这几年评级一直上不去,去年又没过。我也不知道差在哪。”

老陈点点头,没有接话,慢悠悠地喝了一口茶。

“你觉得是技术的问题?“老陈问。

“我一开始觉得是。后来觉得可能不完全是。”

“为什么后来觉得不是了?”

“因为跟我一起进来的几个人,技术明显不如我,有两个已经升上去了。“小林说这话的时候有点不自在,他知道自己这么讲显得小心眼,“我不是说他们不行,他们……就是,嗯,做事比较稳。”

老陈笑了:“稳。这个词很有意思。”

他又给小林续了点茶。

“我给你留个作业。“老陈说,“回去,把你入职第一年写的代码找出来。”

小林没反应过来:“啊?”

“第一年,你刚工作那一年,写的任何一个项目都行。找出来,自己完整读一遍。”

“那玩意儿有什么好读的……”小林干笑了一声,“我那时候连 context 都不会用。”

“就是不会用才要读。“老陈把盖碗的盖子轻轻扣上,“下周还是这个时间,带着你读完的感受来。别的不用准备。”

小林有点摸不着头脑。他本来以为老陈会给他讲讲职级体系、讲讲怎么包装晋升材料、讲讲怎么跟老板谈。结果让他回去看自己的黑历史?

“老陈,说实话,“小林还是没忍住,“我现在是卡在往上走,看五年前的东西……有用吗?”

老陈看着他,看了大概三秒钟。不是那种审视的眼神,是那种”我已经听过这句话三百遍”的眼神。

“你刚说,你技术不差,但升不上去。“老陈说,“我有我的判断,但我不想直接告诉你。你自己看一遍,看到的东西比我说的有用。”

“行吧。“小林把茶一口喝了,“我回去找找。”


小林花了三天才找到。

他 2021 年 3 月入职的第一家公司,是一家做本地生活的创业公司,六十来号人,做商家优惠券的。他在那儿待了不到两年,公司 2022 年底被收购,GitLab 账号在 2023 年初被统一注销了。

他先翻了旧手机的相册,没找到。又翻了网盘,只有几张团建照片。最后想起自己有一台 2019 年买的 ThinkPad,换下来之后一直塞在衣柜最上层。

那台笔记本的电池早鼓包了,插上电源还能开机。硬盘里有一个叫 work 的文件夹,最后一次修改是 2021 年 11 月 14 日。里面是一个 Go 项目,目录名叫 coupon。

小林把整个目录拷出来,用 Goland 打开。

然后他坐在那儿,一动不动地读了四十分钟。

他知道自己当年写得不好。但他没想到,是那种程度。

一个 handler.go 文件,一千八百行,从 HTTP 路由解析一直写到 SQL 拼装,中间还夹了一段硬编码的商户白名单,白名单下面有一行注释:// TODO: 临时方案,等阿May confirm 后改成配置。那个阿 May 就是当年的产品经理,现在还在他微信里。

他记得那个 TODO。他记得自己写完那行的时候想的是”下周就改”。

下周是五年前的下周。

有一个函数叫 DoSomething()。真的叫这个名字。参数列表里有四个 bool:func DoSomething(isVip bool, isNew bool, fromApp bool, force bool) (int, error)。

他自己看着这个函数签名,头皮发麻。这四个 bool 到底是哪四个,他一个都想不起来了。

有一个地方,他把一个 error 吞掉了:

rows, _ := db.Query(sql)

下面直接开始遍历。

有一整个文件叫 util.go,里面塞了二十七个函数,从时间格式化到发送短信到生成一个六位随机数字。文件名叫 util。

还有一个 test 目录,里面只有一个文件,user_test.go,内容是:

func TestGetUser(t *testing.T) {
    u, err := GetUser(1)
    if err != nil {
        t.Fatal(err)
    }
    fmt.Println(u)
}

一个断言都没有。就是打印出来自己用眼睛看。

小林靠在椅背上,长长地出了一口气。

他老婆在客厅喊他:“你笑什么呢?在里面待了一个小时了。”

“我没笑。“他说。

他确实没笑。他脸上发烫。

那种感觉很难形容。不是羞耻——好吧,有一部分是羞耻。更多的是一种后知后觉的惊恐:这些代码,他当年是很有信心地交给别人的。 他记得自己当年跟阿 May 说”这个需求我做完了,测过了,可以提测了”。他真的觉得”做完了”。

而他现在,五年之后,能在里面一眼看出二十个问题。

那他现在写的代码,五年后的自己看,会是什么感觉?

这个念头冒出来的时候,小林的后背有点凉。

他打开微信,想给老陈发条消息说”找到了”,打了一半又删掉。

他最后发的是:“老陈,我找到了。我说不清楚,下周当面说吧。”


一周后,还是那个茶室。

老陈给他倒上茶:“读完了?”

“读完了。“小林把笔记本推过去一点,“我拷出来了。你要看吗?”

“我不看。“老陈说,“你先说说,你读完是什么感觉。”

小林沉默了几秒。

“惨不忍睹。“他说,“真的,我现在带实习生小周,他写的代码我都要批评半天。我看了一圈,我当年比他差远了。”

“具体说说,差在哪。”

“太多了。“小林扳着手指头,“命名烂,一个函数一千多行,错误全吞掉,没有测试,硬编码一堆,注释全是 TODO,还他妈有一个函数叫 DoSomething……”

他说到一半停住了。

老陈等他。

“等等。“小林抬起头,“我突然想到一个事。”

“你说。”

“我当年那个组,加上我四个人。我的 leader 是个工作了三年的,他比我大两岁。他从来没跟我说过’你这个函数太长了’,也没说过’你要写测试’。他只会在我提测之后跑一遍,然后说’这里不对,改一下’。”

“所以呢?”

“所以……”小林有点激动,又有点不知道该怎么说,“所以这些东西,从来没有人教过我。我都是后来自己踩坑踩出来的。命名规范是我第二年换了个公司,看别人的代码才学会的。写测试是我有一次改坏了线上,被骂了一顿才开始写的。”

老陈点点头:“嗯。”

“所以你让我回来看这个,是想告诉我什么?“小林问,“是想说我基础差?”

“你觉得呢?”

“我觉得不只是。“小林慢慢地说,“我觉得你是想说……我有些东西,是硬扛过来的,不是学过来的。”

老陈把茶碗放下,看了小林一眼。

“继续。”

“硬扛过来的人……”小林皱着眉,努力把这个模模糊糊的念头说清楚,“硬扛过来的人,他知道自己做错了,但他不知道’正确的做法’叫什么名字。他能改掉一个具体的错,但他脑子里没有一个系统。所以他下一次还会犯别的错,再扛一次。”

“然后呢?”

“然后……”小林停住了,“然后我觉得,我到现在还是这样。”

老陈没有说话。他给各自的杯子续上茶,茶水落到杯子里,声音很清脆。

“你知道我最怕什么样的工程师吗?“老陈开口了。

“什么样的?”

“不是写得差的。写得差不要紧,教一教就好了。“老陈说,“我最怕的是那种——他做了五年,把’没人教’这件事,硬是活成了’不需要教’。”

小林端着茶杯,没喝。

“你说你技术不差,但升不上去。“老陈看着他,“我不跟你争这个。我只问你一件事:你带小周的时候,你有没有教过他,一个函数该写多长?”

小林想了想。

”……说过一次。他写了个三百行的函数,我说拆一下。”

“你怎么说的?”

“我说,太长了,拆一下。”

“然后呢?”

“然后他拆了,拆成四个函数,每个七八十行。”

老陈笑了:“你看。”

小林明白了。他教小周的方式,和当年他 leader 教他的方式,一模一样。


1.1 「写完」和「交付」之间,到底隔着什么

对话讨论

“我先问你一个最基本的问题。“老陈说,“你当年跟阿 May 说’这个需求我做完了’,你说的’做完’,是什么意思?”

“就是……代码写完了,本地跑通了,提测了。”

“本地跑通是什么意思?”

“就是我自己点了几个按钮,能出结果。”

老陈从旁边拿过一张餐巾纸,撕成两半,把一半推给小林。

“你在纸上写一个东西:‘做完’的定义。别想太久,三十秒。”

小林写了六个字:代码写完能跑。

老陈看了一眼,没评价。他问:“你们公司现在,一个需求从你说’做完’到上线,中间还要经过多少环节?”

“提测、QA 回归、产品验收、灰度、全量。”

“这几个环节加起来,平均多久?”

“顺利的话三五天吧。要是有问题就不好说了。”

“你上一个需求,QA 给你提了几个 bug?”

小林愣了一下。他仔细想了想。

”……七个。“他有点不好意思,“不过大部分是边界情况,不是主流程。”

“七个。“老陈重复了一遍,“那你说的’做完’,和最后上线的那个东西,中间差了七个 bug。”

“可是那些 bug 都是很边缘的……”

“我问你,“老陈打断他,“你写代码的时候,有没有想过那些边界情况?”

“想到了一些。”

“想到了一些。那剩下的那些呢?是你没想到,还是想到了但觉得不会出问题?”

小林沉默了。

说实话,是后者居多。有些情况他知道存在,但他脑子里有个声音说”这个应该不会有人这么操作”,然后他就没处理。

“我给你一个定义,你听听看。“老陈说,“写完,是代码在你机器上跑出了你想要的结果。交付,是这段代码离开你之后,别人不需要再回来问你任何问题。”

“这个标准也太高了吧。“小林脱口而出。

“高吗?”

“你要是这么说,那我五年前那个优惠券服务,一次都没交付过。”

老陈点点头:“对。”

小林有点不服气:“但是……阿 May 也没跟我说要处理那些情况啊。她的需求文档里就写了’用户使用优惠券时校验有效期’,我校验了。”

“嗯。“老陈不置可否,“那我问你,一张优惠券,什么叫’在有效期内’?”

“就是当前时间在生效时间和失效时间之间。”

“当前时间是哪个时区的时间?”

”……北京时间。”

“你的服务器用的是什么时区?”

小林卡住了。他记得那台机器,Docker 容器里默认 UTC。

“UTC。“他说。

“所以你的优惠券,提前八个小时生效,也提前八个小时失效。”

小林不说话了。

“还有,“老陈继续,“优惠券有哪些状态?”

“未生效、生效中、已使用、已过期……”

“还有呢?”

“已作废吧。”

“还有呢?”

”……”小林想了几秒,“被风控冻结?”

“你们有这个状态吗?”

”……没有。”

“那我问你下一个。“老陈说,“一张优惠券,用户核销到一半,服务挂了。这时候他再点一次,会怎么样?”

“会……再核销一次?”

“两张券核销一张,还是核销两张?”

小林没答上来。他当年根本没想过这个问题。

“我不是在批评你五年前写错了。“老陈说,“我是在让你看清楚一件事:你当年以为’需求写得很清楚所以不用多想’,其实是你在等别人把边界给你划好。”

“可那会儿我就是个新人啊。”

“是。新人这样很正常。“老陈说,“但你现在不是了。”

“我现在……”小林想反驳,又咽了回去。他想起上个月他给小周派活,说的是”这个接口加个参数,跟之前那个一样”。小周做完了,他 review 的时候发现没加参数校验,他当时心里想的是”这都不会?”

他从来没跟小周说过要加校验。

“我大概明白你的意思了。“小林慢慢地说,“你的意思是,我当年在等别人把活定义清楚,现在我也在……”

“你先想想。“老陈说,“我在等你自己说出来。”

小林想了很久。窗外面有人在遛狗,一只金毛,跑得很快。

“我觉得……”他终于开口,“我觉得我现在的’做完’,跟五年前其实是一样的。只不过我现在的 bug 少了,因为我知道的坑多了。但我从’写完’到’交付’的那段距离,我一直以为它是 QA 的事、是产品的事、是运维的事。”

“从来不是你的事。”

“对。从来不是我的事。”

老陈点点头:“这句话你要记住。比你今天听到的其他所有话都重要。”

“那到底怎么才算交付?“小林问,“你说’别人不需要再回来问你’,这太抽象了。有没有一个具体的标准?”

“有。“老陈说,“但我先不给你。你回去想一周,自己列一个清单。下周带过来。”

“又是作业。”

“你要是嫌麻烦,现在就可以走。“老陈笑着说,“我又没收你钱。”

小林也笑了:“行,我列。“


生活化案例:给你爸妈装路由器

去年过年,小林回老家。他爸妈家的宽带到期了,换了个运营商,装宽带的师傅上门,装了四十分钟,测速 100Mbps,一切正常,签了个字走了。

当天晚上,他妈打电话给他——用的是流量,不是 WiFi。

“网怎么连不上啊?”

小林问了一圈才发现:新路由器的 WiFi 名字变了,密码也变了。他妈的手机连不上,电视连不上,家里的扫地机器人连不上,他爸那个天天听戏的小音箱也连不上。

师傅干的活,从技术上说,完成得很好。光猫接好了,路由器配好了,网速达标了。

但这个活,交付了吗?

没有。因为使用它的人,第二天早上起来,生活被打乱了。

小林那天晚上花了两个小时,远程教他妈怎么连新 WiFi,教到一半他妈说”算了,你来的时候再弄吧”,然后他妈用流量看了一个月的抖音。

第二年春节小林回去,发现他妈的手机还连着邻居家的网。

这个事他一直记得,因为太典型了。

一个任务的”技术完成点”和”价值交付点”,经常不在同一个地方。 宽带师傅的完成点是”测速达标”,但他妈的价值交付点是”我能正常用上家里的所有设备”。

这两点之间隔着什么?

隔着:新 WiFi 名和密码贴在路由器上;电视、扫地机、音箱一个一个重新连一遍;教会他妈怎么连;走之前确认他妈自己连一次能连上;留个电话说有问题随时打。

这些事情,没有一件需要高超的技术。但少了任何一件,这个活就不算交付。

小林后来反思,他写代码也是这样。

他做完一个需求,测通了主流程,提测。然后 QA 提 bug,产品提需求变更,运维上线的时候发现配置文件没改,大刘半夜打电话问他这个日志为什么打这么猛。

每一件都是”小事”。

但这些小事的全部,就是”写完”和”交付”之间的距离。

宽带师傅有没有能力把这些小事做了?有。他只是没觉得那是他的事。

小林当年写优惠券的时候,有没有能力把时区、状态、幂等这些问题想清楚?

有。他只是没觉得那是他的事。

“这不是我的事”这五个字,是 P4 和更高职级之间,最贵的一道墙。


Go 技术实例:从”能跑一次”到”能交付给别人”

我们来看一段真实的演进。需求是这样的:

运营需要定期清理一批已经过期的优惠券,把它们的状态改成”已失效”,并且记录下清理了多少张。

这是个很常见的小需求。小林当年写出来的第一个版本,大概长这样:

package main
 
import (
	"database/sql"
	"fmt"
	"time"
 
	_ "github.com/go-sql-driver/mysql"
)
 
func main() {
	db, err := sql.Open("mysql", "root:123456@tcp(127.0.0.1:3306)/coupon")
	if err != nil {
		fmt.Println(err)
		return
	}
 
	now := time.Now().Format("2006-01-02 15:04:05")
	sqlStr := fmt.Sprintf(
		"UPDATE coupons SET status = 'expired' WHERE end_time < '%s' AND status = 'unused'",
		now,
	)
 
	res, err := db.Exec(sqlStr)
	if err != nil {
		fmt.Println(err)
		return
	}
 
	n, _ := res.RowsAffected()
	fmt.Println("清理了", n, "张")
}

这段代码有错吗?没有。它会跑,会输出”清理了 128 张”。

但它是一次性的。它交付不了。具体问题在哪:

第一,它会跑,但你不知道它跑了什么。 一万张和十万张,输出都是一行数字。运营问你”清了哪些”,你答不上来。

第二,失败了没有痕迹。 fmt.Println 打到标准输出,一关终端就没了。半夜三点跑的定时任务,早上来什么都没留下。

第三,它无法预演。 你不敢在生产上直接跑,因为你不知道会改多少数据。所以你只能先在本地跑一次看数字,再在生产跑一次。这两次之间数据可能已经变了。

第四,退出码永远是 0。 就算中间出了错,main 也是正常结束。监控系统看不出来它是成功还是失败。

第五,密码写死在代码里。 这个不用解释。

我们来看改进版。这段代码的目标不是”更优雅”,而是”能交给别人”:

package main
 
import (
	"context"
	"database/sql"
	"errors"
	"flag"
	"fmt"
	"log/slog"
	"os"
	"time"
 
	_ "github.com/go-sql-driver/mysql"
)
 
// 退出码:让外部(cron、K8s Job、监控)能判断这次执行的真实结果。
const (
	exitOK      = 0
	exitFailure = 1
)
 
func main() {
	// 所有环境差异都通过 flag 或环境变量注入,不在代码里写死。
	var (
		dsn     = flag.String("dsn", os.Getenv("COUPON_DSN"), "database source name")
		batch   = flag.Int("batch", 1000, "每张批次处理的数量")
		dryRun  = flag.Bool("dry-run", false, "只统计不修改,用于上线前预演")
		timeout = flag.Duration("timeout", 5*time.Minute, "整体超时时间")
	)
	flag.Parse()
 
	logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
 
	if *dsn == "" {
		logger.Error("missing dsn", slog.String("hint", "use -dsn or env COUPON_DSN"))
		os.Exit(exitFailure)
	}
 
	db, err := sql.Open("mysql", *dsn)
	if err != nil {
		logger.Error("open db failed", slog.String("err", err.Error()))
		os.Exit(exitFailure)
	}
	defer db.Close()
 
	ctx, cancel := context.WithTimeout(context.Background(), *timeout)
	defer cancel()
 
	total, err := expireCoupons(ctx, db, logger, *batch, *dryRun)
	if err != nil {
		logger.Error("expire coupons failed",
			slog.String("err", err.Error()),
			slog.Int("processed", total),
		)
		os.Exit(exitFailure)
	}
 
	logger.Info("expire coupons done",
		slog.Int("total", total),
		slog.Bool("dry_run", *dryRun),
	)
}
 
// expireCoupons 分批把已过期且未使用的优惠券置为 expired。
// 分批的原因:一次性 UPDATE 十万行会锁住大量记录,拖慢线上主库。
// dryRun 为 true 时只统计不写入,返回预估影响行数。
func expireCoupons(ctx context.Context, db *sql.DB, logger *slog.Logger, batch int, dryRun bool) (int, error) {
	const q = `SELECT id FROM coupons WHERE end_time < ? AND status = 'unused' ORDER BY id LIMIT ?`
 
	total := 0
	for {
		select {
		case <-ctx.Done():
			// 已经处理的部分是幂等的,下次接着做即可,所以这里返回已处理数量。
			return total, ctx.Err()
		default:
		}
 
		ids, err := selectExpiredIDs(ctx, db, q, time.Now(), batch)
		if err != nil {
			return total, err
		}
		if len(ids) == 0 {
			return total, nil
		}
 
		if !dryRun {
			if err := markExpired(ctx, db, ids); err != nil {
				return total, err
			}
		}
 
		total += len(ids)
		// 记录每一批的 id 区间,出问题时能精确定位到哪一批。
		logger.Info("batch processed",
			slog.Int("size", len(ids)),
			slog.Int64("first_id", ids[0]),
			slog.Int64("last_id", ids[len(ids)-1]),
			slog.Bool("dry_run", dryRun),
		)
	}
}
 
func selectExpiredIDs(ctx context.Context, db *sql.DB, query string, now time.Time, limit int) ([]int64, error) {
	// 传 time.Time 而不是格式化好的字符串:让驱动处理时区,避免代码里出现隐式时区假设。
	rows, err := db.QueryContext(ctx, query, now, limit)
	if err != nil {
		return nil, fmt.Errorf("select expired ids: %w", err)
	}
	defer rows.Close()
 
	var ids []int64
	for rows.Next() {
		var id int64
		if err := rows.Scan(&id); err != nil {
			return nil, fmt.Errorf("scan id: %w", err)
		}
		ids = append(ids, id)
	}
	// rows.Err() 必须检查:连接中断等错误只在遍历结束后才暴露。
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate rows: %w", err)
	}
	return ids, nil
}
 
func markExpired(ctx context.Context, db *sql.DB, ids []int64) error {
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback()
 
	stmt, err := tx.PrepareContext(ctx, `UPDATE coupons SET status = 'expired' WHERE id = ? AND status = 'unused'`)
	if err != nil {
		return fmt.Errorf("prepare: %w", err)
	}
	defer stmt.Close()
 
	for _, id := range ids {
		if _, err := stmt.ExecContext(ctx, id); err != nil {
			return fmt.Errorf("update id %d: %w", id, err)
		}
	}
 
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit: %w", err)
	}
	return nil
}
 
var errNothing = errors.New("nothing to do")

这段代码比第一个版本长了三倍。但它是可以交付的,因为:

  • 它可以预演。 上线前先 -dry-run 跑一次,看清楚会动多少数据,再决定要不要真跑。这一个小设计,能挡掉一半的生产事故。
  • 它留下了痕迹。 每一批都打了结构化日志,谁都能去看它干了什么。
  • 它会失败得明明白白。 退出码区分成功失败,监控系统能接住。
  • 它认得清边界。 超时、批次、幂等(重复跑不会造成错误,因为 WHERE status = 'unused'),这些都是”想清楚了”的证据。
  • 它不藏着东西。 DSN 从 flag 或环境变量进,没有人会在代码 review 里看到数据库密码。

你注意到了吗?这些改进里,没有一项需要更高的技术水平。第一个版本和第二个版本,用的是同一套 API。

差别只有一个:写代码的时候,你脑子里装的是”我自己跑一次”,还是”别人要拿去用”。

这就是”写完”和”交付”的全部距离。


老陈的深层发问

  1. 你上一次说”我做完了”之后,有没有人因为你的这个”做完”,多加了一小时班?那个人是谁,你还记得吗?

  2. 如果明天你来不了公司,你现在手里正在做的那件事,别人接手需要问你几个问题才能继续?那几个问题是什么?

  3. 你现在做的这件事,如果做到一半老板说”砍了不做了”,你能说出你到目前为止创造的价值吗?还是只能说”我写了三千行代码”?

  4. 你有没有过这种时候:明明想到了一个可能有问题的地方,但心里有个声音说”应该不会吧”,然后就跳过去了?最近一次是什么时候,后来怎么样了?

  5. 你是在等别人把活定义清楚,还是在主动定义活?这两个状态,你今天各占了多长时间?


生活思考题

题一:你帮朋友搬家,把东西都搬上楼了,堆在客厅。朋友说”谢谢啊”。这时候你觉得自己做完了吗?如果你觉得没做完,那还差什么?

参考答案

大多数人会这么答:搬上楼就算完了。我都累成那样了,剩下的他自己收拾呗。

这个答案为什么不够:你想想搬家的目的是什么。搬家不是为了”把东西从 A 点运到 B 点”,是为了”明天早上他能在新家正常生活”。东西堆在客厅,他今晚睡哪儿?明天早上找不着牙刷,找不着充电线,找不着第二天要穿的衬衫。你确实完成了物理位移,但你没完成那件事本身。更关键的是,你走之后,他不可能再打电话让你回来——他只会自己一边收拾一边在心里嘀咕”下次不找他了”。这个代价你永远看不见。

更深的一层:判断一件事有没有做完,看的不是”我做了什么动作”,而是”对方的状态有没有变成他想要的样子”。动作清单是给自己看的,状态变化是给别人看的。绝大多数职场上的”他活干得不行”,都不是因为动作没做,而是因为他停在了动作结束的地方。而且——最容易被忽略的一点——没做完的部分,成本不是消失,是转移给了别人。你少花的那一小时,是他多花的一小时,而且他还得先花时间搞清楚你到底做到哪儿了。

落到 Go 工程师身上:下次你说”写完了”之前,问自己三个问题。第一,这段代码现在部署上去,运维需要配置什么?我写清楚了吗?第二,它跑起来之后,出问题了怎么发现?有日志吗,有指标吗?第三,接手我这段代码的人,看到函数签名能不能看懂我在干什么?这三个问题回答不了,你就是那个把东西堆在客厅就走的人。具体到行动:写完代码之后,打开你的 PR 描述,把”部署步骤""验证方式""回滚方案”三段写上去。写完这三段,你会发现你漏了什么。

题二:你去一家餐厅吃饭,菜上齐了,你也吃完了。服务员问你”怎么样”,你说”挺好的”。但你心里其实觉得有点咸。你会说吗?

参考答案

大多数人会这么答:不会说。都吃完了,说了也没用,还显得事儿多。下次不来了就是了。

这个答案为什么不够:你以为你的沉默没有成本,其实成本全在别人身上。厨师今天做咸了,明天还会做咸,后天还会。他甚至不知道自己咸了。你”下次不来了”,对他来说只是一个模糊的客流下降,他归因到天气、地段、竞品,唯独不会归因到盐。你看,你的沉默,把一个可以修复的具体问题,变成了一个永远无法定位的模糊结果。而且更微妙的是——你其实也没真的”下次不来”。你大概率还会来,因为近、因为方便,然后每次都在心里减一分,直到有一天你说”这家店不行了”,但你完全说不清它是什么时候开始不行的。

更深的一层:反馈的成本是不对称的。你给出反馈的成本,是一次有点尴尬的对话,五分钟。你不给反馈的成本,是这个问题持续存在,直到某一天以更大的形式爆发。所有”其实我觉得有点问题但我没说”的累积,最后都会变成一个谁都解释不清的结果。职场里那些”突然被边缘化""突然没晋升""突然被裁”,很少是真的突然,大多是很久以前就开始的、从没被说出口的”有点咸”。

落到 Go 工程师身上:代码评审的时候,你有没有过”这里看着有点别扭,但我也说不清哪里不对,算了 approve 吧”?这个时刻,就是你没说”有点咸”的那个时刻。你不需要给出一个完美的替代方案才有资格提问题——“我看着这里有点绕,你能不能给我讲讲”就是完全合格的反馈,而且它往往能引出对方自己都没意识到的问题。具体行动:给自己定一个规则,每个 PR 至少留一条”我觉得可能可以更好”的评论,哪怕很轻微。你会发现,三个月之后,团队里说”我不太确定但是……”的人变多了,那才是评审真正开始起作用的标志。


1.2 代码是写给人看的,机器只是顺便执行

对话讨论

“我们接着看你那段代码。“老陈把笔记本往自己那边转了转,“这个 DoSomething,你能给我讲讲吗?”

小林凑过去看了一眼,脸又热了。

“这个……”他试图回忆,“这个是判断一个用户能不能领券的逻辑。”

“四个 bool 分别是什么意思?”

“isVip 是是不是会员,isNew 是是不是新用户,fromApp 是……是不是从 App 过来的,force 是……”

他停住了。

“force 是什么?“老陈问。

“我不记得了。”

“你不记得了。“老陈点点头,“这是你五年前写的代码,你不记得 force 是什么意思。那我问你,你三个月前写的代码,你能记得吗?”

”……大部分能。”

“一年前的呢?”

小林没说话。他想起上个月为了改一个 bug,花了整整一个下午去读自己半年前写的一个函数,最后发现那个函数根本没被调用,是段死代码。他在那儿读了四十分钟一段没人用的代码。

“我给你讲个我自己的事。“老陈说,“我三十一岁那年,在一家做 ERP 的公司。我写了一个模块,三千多行,自认为写得特别漂亮,用了好多设计模式。两年后公司让我回去改那个模块的一个小需求。”

“然后呢?”

“然后我打开那个文件,看了十分钟,关掉了。”

“关掉了?”

“关掉了。我说这个模块我改不了,得重写。”

小林笑了:“那您后来重写了?”

“重写了。花了三周。那三周我天天加班,我老婆当时怀着老二,我连产检都没陪去过。“老陈说,“我当时觉得我是在偿还技术债。很多年后我才想明白,那三周不是技术债,那是我写给两年后自己的一封信,而我没看懂。”

小林不笑了。

“你知道最讽刺的是什么吗?“老陈说,“我当年写那个模块的时候,觉得它’特别漂亮’。我觉得设计模式用得多,就是好代码。我完全没想过一件事——两年后的我,会不会骂现在的我。”

“那您觉得,什么叫好代码?”

“我不告诉你。“老陈说,“我让你自己看。你现在把你那个 DoSomething 翻译成人话,念给我听。”

小林看着屏幕,念:“处理优惠券,参数是:是不是会员,是不是新用户,是不是从 App 来,是否强制。”

“念完了什么感觉?”

“念完了……我还是不知道它要干嘛。”

“对。“老陈说,“一段代码如果你念出来听不懂,它就有问题。 这不是文学修辞,这是可执行的检查方法。”

“那注释呢?我当年也写了注释的。”

“你写的什么注释?”

小林翻了翻:”……// 判断是否是新用户。就写在 if isNew { 上面。”

老陈笑了:“这个注释的价值是什么?”

“呃……说明这一行在干嘛。”

“我看见 if isNew,我已经知道它在判断是不是新用户了。你的注释告诉我的是我已经知道的事。“老陈说,“那注释应该写什么?”

“写……为什么?”

“你先想想,举你自己的例子。”

小林想了一会儿。

“我上个月改过一段代码,“他说,“有个地方是 time.Sleep(200 * time.Millisecond)。我看不懂为什么要睡 200 毫秒,问了大刘,大刘说下游那个老系统有个 bug,连续请求间隔太短会丢数据。”

“然后你怎么办的?”

“我加了一行注释,写”下游老系统有 bug,见 Jira XXX”。”

“你看,你自己其实知道。“老陈说,“注释不写’这行在干什么’,那玩意儿代码本身就写着。注释写的是代码里看不出来的东西:为什么这么写,为什么不那么写,这里有什么坑,这里的历史是什么。”

“可是……”小林有点犹豫,“我有时候觉得,如果代码写得够清楚,就不需要注释了。有些文章是这么说的。”

“这话对一半。“老陈说,“写得清楚确实不需要注释来解释’是什么’。但是’为什么’,代码表达不了。”

他指了指屏幕上那行 // TODO: 临时方案,等阿May confirm 后改成配置。

“你这个 TODO 倒是个好注释。”

“啊?”

“它告诉你三件事:这是临时的,为什么是临时的,改的时候要找谁。“老陈说,“问题是你没改。”

小林被噎住了。

“我再问你一个。“老陈把笔记本转回来,“你那个 handler.go,一千八百行。你自己写的。你当时写的时候,有没有哪一刻觉得’这个有点长了’?”

“有。“小林毫不犹豫,“写到八百行的时候我就觉得不对了。”

“那你为什么没拆?”

“因为……”小林想了想,“因为拆起来麻烦。要改好多地方,而且当时赶着提测。”

“你看。“老陈往后靠了靠,“你不是不知道,你是选择了不去做。这就是我说的——你脑子里的系统是残缺的。你知道’太长不好’,但你不知道’应该多长’,不知道’怎么拆’,不知道’拆的标准是什么’。所以你每次都要靠意志力去决策,而意志力在赶进度的时候,永远会输。”

“那标准是什么?”

“我先问你,你觉得一个函数应该多长?”

“我以前听说是……不超过一屏?”

“一屏是多少行?你的屏幕和我的屏幕一样吗?”

“那……五十行?”

“为什么是五十?”

“因为……”小林答不上来了。他意识到自己从来没有认真想过这个问题,他只是记住了一个数字。

“我给你一个我自己的标准,你参考。“老陈说,“一个函数应该短到你能给它起一个准确的名字。”

“什么意思?”

“你起名字的时候,如果发现自己要写 ProcessCouponAndSendNotificationAndLog,那就说明这个函数干了三件事。如果你只能叫它 DoSomething,那就说明你根本不知道它干了什么。”

小林看着屏幕上那个 DoSomething,沉默了。

“名字起不出来,不是起名字的问题,是你对这件事的理解还没到能命名的程度。“


生活化案例:我妈的红烧肉菜谱

小林去年让他妈把红烧肉的做法写下来。

他妈做了三十年红烧肉,做出来那个味道,他在外面任何一家饭店都没吃到过。他想着趁过年回家,把做法记下来,以后自己能做。

他妈写了,写得挺认真,一张纸:

  1. 五花肉切块,焯水
  2. 锅里放油,炒糖色
  3. 下肉翻炒,加料酒、生抽、老抽
  4. 加水,放盐少许,糖适量
  5. 大火烧开,小火炖四十分钟
  6. 收汁,出锅

小林照着做了一遍。

做出来是一锅又黑又咸的焦炭。

他打电话给他妈:“妈,你那个菜谱不对吧?”

他妈说:“怎么不对了,我就是这样做的呀。”

他妈后来专门来他这儿住了一周,站旁边看他做。看到第四步,他妈说:“停。”

“你那个糖色炒过了。”

“菜谱上写的炒糖色啊。”

“炒到什么程度?”

”……没写。”

他妈说:“炒到大泡变小泡,颜色像枣红色,闻到一点焦糖味,就该下肉了。你炒到冒烟了,能不苦吗。”

然后加水那步,他妈说:“水要没过肉一个指节。”

“没过肉多少?”

“一个指节。“他妈伸出手指比给他看,“你那是刚盖住。”

盐少许,是多少?他妈说:“先放小半勺,炖到二十分钟的时候尝一下汤,比你想吃的味道淡一点,就对了。因为收汁之后会变咸。”

小林当时就愣在那儿了。

“盐少许”这三个字,对他妈来说是非常精确的信息。 因为她脑子里有一整套完整的上下文:什么锅、多大火、多少肉、收多久汁、最后会浓缩多少。这些上下文她用了三十年,已经压缩成了本能,她根本意识不到自己知道这些。

所以她写出来的菜谱,是”给她自己看的”。她看得懂,所以她以为别人也看得懂。

这个场景,跟代码是一模一样的。

你写 DoSomething(isVip, isNew, fromApp, force) 的时候,你脑子里有完整的上下文:这是运营给会员发券的逻辑,force 是给客服手动补发用的后门。你脑子里清清楚楚。

所以你写完之后,你觉得这段代码很清楚。

但三个月后的你,或者接手的同事,他脑子里没有那些上下文。他看到的只有四个 bool,和一个叫”做点什么”的函数名。

代码不是写给机器的。机器只需要字节码,你把变量名全改成 a、b、c 它也照样跑。代码是写给下一个读它的人的——而那个人,八成就是你自己。

小林后来明白了,他妈那个菜谱真正缺的不是”少许”改成”3 克”,而是缺一套”怎么判断”的标准:糖色炒到什么样子算好、汤尝起来应该什么味道。

代码里也一样。好的命名和注释,不是把 isNew 改成 isNewUser(虽然这也是进步),而是把”判断标准”和”决策依据”写出来。

那锅焦炭他最后吃完了,配了两碗米饭。


Go 技术实例:从 DoSomething 到能读懂的代码

我们把小林当年那段代码复原一下。它的实际职责是:判断一个用户能不能领取某张优惠券。

第一个版本(他当年的写法):

package coupon
 
// DoSomething 判断用户能不能领券
func DoSomething(isVip, isNew, fromApp, force bool) (int, error) {
	if force {
		return 1, nil
	}
 
	if !isNew && !isVip {
		return 0, nil
	}
 
	if fromApp {
		if isNew {
			return 1, nil
		}
	}
 
	if isVip {
		return 1, nil
	}
 
	return 0, nil
}
 
// 调用处,在 handler.go 的第 1037 行
func (h *Handler) Claim(c *gin.Context) {
	// ... 前面 1000 行 ...
 
	isVip := h.checkVip(uid)
	isNew := h.checkNew(uid)
	fromApp := c.GetHeader("X-Client") == "app"
	force := c.Query("force") == "1"
 
	ok, err := DoSomething(isVip, isNew, fromApp, force)
	if err != nil {
		c.JSON(500, err)
		return
	}
	if ok == 1 {
		// 发券
	}
}

这段代码的问题,不只是命名。

第一,四个 bool 是四个”不透明的信息通道”。 调用方写 DoSomething(true, false, true, false),读代码的人必须跳到定义处一个一个数。更糟的是,如果哪天有人把两个类型相同的参数写反了,编译器不会报错——DoSomething(isNew, isVip, ...) 照样能编译通过。这是最容易引起线上事故的一类 bug,因为它完全静默。

第二,返回 int 表达”能不能”,丢失了”为什么不能”。 用户点了领券,弹出来一个”领取失败”,客服问为什么,你查不出来。是名额没了?是用户不符合资格?还是这个券已经下架了?0 只告诉你”不行”。

第三,那个 force 是最危险的。 它是一扇没有任何标签的后门。任何人只要在 URL 上加 ?force=1 就能绕过所有规则。五年前小林写它的时候,是因为客服需要一个手动补发的入口。但五年后的今天,这段代码如果还在跑,它就是一个可以被任何人利用的漏洞——而没人记得它为什么存在。

第四,业务规则散落在 if 里,无法回答”为什么”。 if !isNew && !isVip { return 0 } 表达的是”非会员且非新用户不能领”。但这是谁定的规则?是运营定死的,还是当时的临时策略?看不出来。

我们来看改进版。注意,改进的方向不是”更短”,而是”让读代码的人不用猜”:

package coupon
 
import (
	"errors"
	"fmt"
	"time"
)
 
// RejectReason 说明用户为什么不能领券。
// 用具体类型而不是 bool/int:调用方无法忽略它,也无法传错顺序。
type RejectReason string
 
const (
	RejectNone          RejectReason = ""
	RejectNotTarget     RejectReason = "not_target"      // 不是目标人群
	RejectClaimedByUser RejectReason = "already_claimed" // 已经领过了
	RejectSoldOut       RejectReason = "sold_out"        // 发完了
	RejectOffline       RejectReason = "coupon_offline"  // 券已下架
)
 
func (r RejectReason) Error() string { return string(r) }
 
// UserProfile 把"判断所需的输入"显式化。
// 关键点:用领域概念(IsVip / RegisteredAt)而不是调用方的实现细节(fromApp)。
type UserProfile struct {
	UserID       int64
	IsVip        bool
	RegisteredAt time.Time
}
 
// CouponRule 券的领取规则,从数据库的配置反序列化而来,不写死在代码里。
type CouponRule struct {
	CouponID     int64
	Status       string // "online" | "offline"
	Total        int
	Claimed      int
	VipOnly      bool
	NewUserOnly  bool
	NewUserDays  int // 注册 N 天内算新用户
	AllowAppOnly bool
}
 
// ClientType 让调用方没法传错:传的是类型,不是裸 bool。
type ClientType string
 
const (
	ClientApp     ClientType = "app"
	ClientH5      ClientType = "h5"
	ClientMiniPro ClientType = "miniprogram"
)
 
// CheckClaimable 判断一个用户能不能领这张券。
//
// 返回 nil 表示可以领;否则返回具体的 RejectReason。
//
// 这里刻意不提供 force 参数。绕过规则的唯一途径是客服后台走独立的
// Compensate 接口,那个接口有独立的权限校验和操作审计。
// 历史教训:早期版本在 HTTP query 里放了一个 force=1,任何人都能绕过
// 全部规则,2021 年被人刷走过一批券。不要再加回来。
func CheckClaimable(u UserProfile, r CouponRule, src ClientType, now time.Time) error {
	if r.Status != "online" {
		return RejectOffline
	}
 
	if r.Claimed >= r.Total {
		return RejectSoldOut
	}
 
	if !matchAudience(u, r, now) {
		return RejectNotTarget
	}
 
	if r.AllowAppOnly && src != ClientApp {
		return RejectNotTarget
	}
 
	return nil
}
 
// matchAudience 判断用户是否属于目标人群。
// 抽成独立函数的原因:这是运营改动最频繁的逻辑,隔离出来便于单独测试和替换。
func matchAudience(u UserProfile, r CouponRule, now time.Time) bool {
	if r.VipOnly && !u.IsVip {
		return false
	}
 
	if r.NewUserOnly {
		// "新用户"的判定标准来自运营配置(NewUserDays),不是拍脑袋的常量。
		// 曾经这里写死为注册 7 天内,改一次需求就要发一次版。
		if now.Sub(u.RegisteredAt) > time.Duration(r.NewUserDays)*24*time.Hour {
			return false
		}
	}
 
	return true
}
 
// ClaimResult 领券结果,带上后续要展示给用户的文案依据。
type ClaimResult struct {
	CouponID int64
	Code     string
}
 
// Claim 执行领券。注意它接收的是 CheckClaimable 已经通过的结果。
// 分成"校验"和"执行"两步,是为了让校验逻辑可以被单独复用和测试。
func Claim(u UserProfile, r CouponRule, src ClientType, now time.Time) (ClaimResult, error) {
	if err := CheckClaimable(u, r, src, now); err != nil {
		return ClaimResult{}, fmt.Errorf("claim coupon %d for user %d: %w", r.CouponID, u.UserID, err)
	}
 
	// 真正扣减库存的地方必须由数据库保证原子性,这里的 r.Claimed 只是预判断。
	code, err := generateCode(u.UserID, r.CouponID)
	if err != nil {
		return ClaimResult{}, fmt.Errorf("generate code: %w", err)
	}
 
	return ClaimResult{CouponID: r.CouponID, Code: code}, nil
}
 
func generateCode(userID, couponID int64) (string, error) {
	if userID <= 0 || couponID <= 0 {
		return "", errors.New("invalid user or coupon id")
	}
	return fmt.Sprintf("%d-%d", couponID, userID), nil
}

改动之后,几个实质性的提升:

  • 调用方不可能传错参数。 CheckClaimable(u, r, src, now) 四个参数类型各不相同,写反了编译不过。这是”用类型系统当护栏”,比任何规范文档都管用。
  • 拒绝原因可读、可上报、可统计。 RejectSoldOut 和 RejectNotTarget 是两个不同的东西,你可以统计它们的比例,然后去问运营”是不是发太少了”。
  • force 后门被删掉了,并且留下了一条注释说明为什么不能加回来。 这是注释最有价值的一种写法:它记录的是一个”千万不要做的事”和背后的事故。这种信息,代码本身永远表达不了。
  • now 作为参数传入。 这让整个函数可以被确定性地测试——测试的时候你可以传任意时间,不用 time.Now(),也不用改系统时钟。
  • 业务规则数据化。 VipOnly、NewUserDays 从配置来,运营改规则不用发版。

再看一眼那个 DoSomething。它和 CheckClaimable 之间,差的技术水平其实不大。差的只是:写的时候,脑子里装的是”我要让机器执行这个逻辑”,还是”我要让下一个人读懂这个逻辑”。


老陈的深层发问

  1. 你现在手上有多少个函数,是你自己三个月前写的、现在打开要愣三秒才能想起它在干什么的?

  2. 如果你明天离职,交接文档只能写三句话给接手你核心模块的人,你会写哪三句?如果写不出来,说明什么?

  3. 你有没有在代码里留下过”临时方案”?它现在还在吗?它是真的临时,还是你当时给自己找的借口?

  4. 你写注释的时候,是在解释”这行代码在做什么”,还是在记录”我为什么这么决定”?这两者的比例是多少?

  5. 你上一次因为看不懂别人的代码而去问别人,是什么时候?你问的时候,是觉得自己笨,还是觉得对方写得烂?这个第一反应说明了什么?


生活思考题

题一:你的衣柜塞满了,但你每天早上还是觉得”没衣服穿”。为什么?

参考答案

大多数人会这么答:因为衣服不够,该买新的了。

这个答案为什么不够:你去数数,你衣柜里的衣服,绝对比你一年能穿过来的次数多。问题从来不是数量,是你找不到。所有衣服都塞在里面,按什么排的?按买的时间顺序,或者按塞得进去的顺序。你要找一件”面试能穿的、不太正式的、蓝色的衬衫”,你的检索路径是什么?是一件一件翻。翻到第三十件的时候,你已经烦躁了,随便抓一件穿上出门。然后你在公司一整天都不自在。你看,你不是没衣服,你是没有一套检索系统。买新衣服不能解决这个问题——新衣服塞进去三个月,也会变成那第三十件。

更深的一层:信息的价值 = 信息本身的价值 × 它被找到的概率。一件你永远找不到的完美衬衫,价值是零。而且这里有个反直觉的地方:往系统里加东西,会降低系统的可用性。你每买一件衣服,你找其他衣服的成本就高一点。这就是为什么很多人整理完衣柜会扔掉一半——扔掉之后他不是”衣服变少了”,是”每件衣服的可见度翻倍了”。代码里也是这样:你每加一个函数、一个包、一个配置项,都在增加系统的检索成本。一个两万行的 util 包,它的实际价值接近零,因为没人找得到里面的东西。

落到 Go 工程师身上:下次你要往项目里加一个新函数,先问自己:它会放在哪里?三个月后有人要找它,他会在哪个目录下找?如果你答不上来,说明你需要的是一个新包,而不是往 util.go 里塞第二十八个函数。具体行动:打开你项目里最大的那个文件,看它里面有多少个函数。超过二十个,就把它拆开——按”谁来找它”来拆,不是按”它属于什么类型”来拆。比如 coupon/rule.go 和 coupon/claim.go,比 model/ 和 service/ 有用得多。

题二:你去医院看病,医生两分钟就把你打发走了,你是什么感觉?如果医生花了二十分钟,把你的病因、用药原理、为什么会得这个病,一条一条讲清楚呢?

参考答案

大多数人会这么答:第二种医生好啊。耐心,负责,医德好。

这个答案为什么不够:你只说对了一半,而且是比较浅的那一半。往深一层想:这两分钟和二十分钟的差别,表面上是态度,实质上是医生有没有把你当成这个决策的参与者。两分钟那个医生,他的模型是”我给你一个结论,你照做”。二十分钟那个,他的模型是”我让你理解,然后我们一起决定”。这两者的差别,在你离开诊室之后才真正显现:第一种,你回家第三天药没吃完就停了,因为你不舒服的症状消失了,你不知道还要继续吃;第二种,你会吃完,因为你懂了为什么。所以那个二十分钟,不是花在”服务态度”上,是花在确保你的治疗方案能被正确执行上。

更深的一层:这里藏着一个很多人一辈子没想明白的道理。沟通的成本,要么在前面付,要么在后面付,而且后面付的通常是前面的三到五倍。 两分钟省下来的十八分钟,会以”患者没遵医嘱导致复发”的形式,在两周后加倍还回来。所有”我现在没时间解释,你先照做”的沟通,都在预支未来的时间。而且这笔债的利息特别高——因为对方在执行过程中一旦不理解,他不会停下来问你,他会自己猜,然后猜错,然后你要花更多时间去纠正一个错误的结果,而不是一开始就讲清楚。

落到 Go 工程师身上:你写的 PR 描述,是”两分钟版”还是”二十分钟版”?“修复了领券的 bug”是两分钟版。“这个 bug 的根因是并发下库存判断和扣减不是原子操作,压测 500 并发时出现超发 3 张;修复方式是把判断下沉到数据库的 UPDATE … WHERE stock > 0,靠行锁保证;已补充并发测试;回滚方式是把这行改回去”是二十分钟版。前者评审的人要花二十分钟自己看代码搞懂你在干嘛,后者他三分钟就能给出有效意见。你省的那点写 PR 描述的时间,是从五个评审人身上各偷了二十分钟。具体行动:从下一个 PR 开始,强制写四段:背景(为什么改)、方案(改了什么)、验证(你怎么确认它有效)、风险(什么情况下会出问题,怎么回滚)。写完这四段,你会发现有相当一部分 PR,你自己就发现不该提。


1.3 测试不是负担,是你唯一的安全网

对话讨论

“我问你个事。“老陈说,“你那个 user_test.go,里面就一个 TestGetUser,连个 if 都没有,就 fmt.Println(u)。你写它的时候,心里是怎么想的?”

小林苦笑:“因为组里有要求,每个包要有测试文件。”

“覆盖率要求?”

“好像是……20%?反正很低。”

“所以你写那个测试,是为了满足一个数字。”

“对。“小林承认得挺痛快,“我当时觉得写测试纯属浪费时间。我自己点一遍不就知道对不对了吗?”

“你现在还这么觉得吗?”

“现在……”小林想了想,“现在我写测试。但说实话,有时候赶进度我还是会跳过。而且我写测试的顺序是,先把功能写完,跑通了,再补测试。”

“为什么是那个顺序?”

“因为先写测试的话,我得先想清楚接口长什么样,太慢了。”

老陈点点头:“这个理由很诚实。我年轻的时候也是这么想的。”

“您也?”

“我三十四岁那年,带一个团队做一个支付系统。“老陈说,“我们没写测试。上线三个月,出了一次事故,重复扣款,涉及两百多笔,金额不大,但性质很恶劣。”

“后来呢?”

“后来我们通宵了两天,一笔一笔人工核对退款。第三天早上,我们老大把我们叫到会议室,没骂我们。他就问了一句:‘你们怎么确认它是好的?’”

“我们谁都没答上来。因为我们确实是’点了几个按钮,看着没问题’就上了。”

“那您从那之后就开始写测试了?”

“没有。“老陈笑了,“我当时补了一堆测试,补了两个月,补到覆盖率 70%。然后就没再管了。”

“为什么?”

“因为那些测试是我’补’的。“老陈说,“你知道补出来的测试是什么样吗?我知道代码会输出什么,我就断言什么。代码里有个 bug 返回了 0,我的测试就断言 assert.Equal(0, result)。这种测试全绿,但它保护不了任何东西。”

小林愣了一下。他发现自己现在的项目里,就有不少这种测试。

“那您是什么时候真正想明白的?”

“三年后。我换了一家公司,遇到一个比我小六岁的同事。“老陈说,“他写代码的方式跟我不一样。他拿到需求,第一件事是打开 _test.go,把用例名字先写下来。”

“用例名字?”

“对。TestClaimVipUser、TestClaimNewUser、TestClaimSoldOut、TestClaimCouponOffline。他先写这四个函数名,函数体是空的,跑一遍,四个 skip。然后他才开始写实现。”

“这不就是 TDD 吗?”

“是 TDD。但我当时觉得他是在作秀。“老陈说,“直到有一次我改了他的一个函数,改完跑测试,红了三个。我去看那三个用例的名字,看完我就知道我改坏了什么,甚至知道他当初为什么那么写。”

“那一刻什么感觉?”

“我觉得……”老陈斟酌了一下,“我觉得他在跟我说话。”

小林没听懂:“啊?”

“他不在公司了,他去了别的城市。但我读他写的测试,就像他在旁边跟我解释:这里有个边界,你注意一下;这里我当初犹豫过,最后选了这个方案,原因是这个。“老陈说,“测试不是验证代码对不对的东西。测试是一个人把他当时的思考过程,固化成了可执行的形式。”

小林沉默了一会儿。

“可是写测试真的很花时间。“他说,“我有时候写完一个功能,再写测试要翻倍的时间。”

“你说的是’翻倍’,具体是多少?”

”……半天的活变成一天吧。”

“那我问你,你上次改完代码,QA 给你提了七个 bug,你修那七个 bug 花了多久?”

“一天半。”

“加上改完之后的回归测试呢?”

“又是一天。”

“所以你为了省半天,花了两天半。“老陈说,“这个账,你现在算得清吗?”

小林不说话了。

“我知道你要说什么。“老陈看着他,“你要说’不是每次都会出 bug 的’。对,不是每次。但你知道你不知道哪次会出。这就是保险的逻辑——你买保险,不是因为你确定自己会出事,是因为你不确定自己不会出事。”

“那……”小林还是有点犹豫,“测试到底要写到什么程度?我总觉得,全写一遍太夸张了。”

“你先想想,我等会儿问你。”

“哦。“小林想了想,“我觉得,核心逻辑要写,边界要写。”

“什么叫核心逻辑?”

“就是……业务规则的部分。”

“你刚才说的那个优惠券的领域逻辑,算核心吗?”

“算。”

“那你上一节写的 CheckClaimable,你给它写测试了吗?”

小林没答上来——因为那是他几分钟前才在老陈指导下写出来的。

“你看。“老陈说,“你’知道’要写,但你不会’自动去做’。这又是同一个问题。”

“那怎么办?”

“我给你一个能立刻用的规则。“老陈说,“每当你想写一个 if,就在测试里加一个用例。”

“就这么简单?”

“就这么简单。你写一个 if,就说明那里有两种可能。两种可能,两个用例。你写三个 if 嵌套,最多八种情况,你至少要把那几个有意义的写出来。”

小林在心里默算了一下他最近写的一个函数——五个 if。

“那我得写十几个用例。“他说。

“那说明你那个函数有十几种行为。你自己知道它有十几种行为吗?”

小林答不上来。

“这就是测试的第一个价值,也是最被低估的一个价值。“老陈说,“它不验证你的代码对不对,它逼你想清楚你的代码有多少种可能。“


生活化案例:装修时的打压测试

小林 2023 年装修过一次房子。

水电改造那天他请了半天假去现场。师傅把水管接好,走的是顶,横平竖直,看着挺漂亮。师傅说,好了,明天可以封槽贴砖了。

小林说,不做打压吗?

师傅说,做啊,等会儿。

然后师傅拿了个手摇的打压泵接在水管上,摇到 0.8 兆帕,说,等半小时,看表掉不掉。

小林就在那儿站着等了半小时。

表没掉。

师傅说,行,没问题。

小林当时的感觉是:这半小时白等了。房子没有因为这半小时变大,也没有变好看。他甚至在心里嘀咕,是不是师傅在磨洋工。

半年后,他楼下的邻居上来敲门,说卫生间天花板在渗水。

不是他家,是他隔壁单元同一楼层的一户。那家装修找的是路边游击队,没做打压。后来查出来是墙里一个接头没拧紧,慢慢渗。

那家砸了卫生间的墙、砸了地砖、砸了刚装好的浴室柜,重新做防水,然后还要赔楼下天花板的修复。差不多四万块,加一个月没法住。

小林后来跟人聊起这事,才发现打压测试这个动作,性质很特殊。

它产生不了任何”看得见的价值”。 做完之后,你的房子不会因此多一平米,也不会因此更漂亮。水管接得好的话,打压和不打压,结果一模一样。

但它的价值在于:它把”未来某个时刻才会暴露的问题”提前到了”现在就可以修复”的时刻。

没封槽的时候,水管漏了,师傅拧一下就好,五分钟。

封了槽、贴了砖、装了橱柜之后,水管漏了,你要砸掉一整面墙。

同样是”拧一下”,成本差了两百倍。

而这里最要命的一点是:你无法通过”看起来好不好”来判断要不要做打压测试。 水管接得漂漂亮亮的,它可能漏;接得歪歪扭扭的,它可能不漏。视觉检查给不了你任何信息。

代码也是这样。

你写完一个函数,自己点了几下,看着对了。这个”看着对了”,跟”水管走线看着挺漂亮”是同一件事——它只能验证主流程,验证不了那些藏在墙里的接头。

测试就是打压测试。

它无聊、它不产出可见价值、它做完了你甚至会觉得浪费了时间。

但它是你在”封槽”之前,唯一能知道墙里有没有漏的机会。

而且——这是小林后来才想明白的——打压测试最好的时机,不是接完水管之后,是你决定怎么走线的时候。 因为你一想到”这个接头将来要打压”,你就会把它放在将来能修到的地方,而不是塞进承重墙里。

这就是为什么先写测试的人,写出来的代码结构往往更好:不是因为他们更聪明,是因为他们从一开始就在想”这个东西我怎么验”。


Go 技术实例:表驱动测试,以及它真正在解决什么

我们接着上一节的 CheckClaimable 来写测试。先看大多数人(包括五年前的小林)会怎么写:

package coupon
 
import (
	"fmt"
	"testing"
	"time"
)
 
// 【错】 常见写法一:打印出来自己看
func TestCheckClaimable_v1(t *testing.T) {
	u := UserProfile{UserID: 1, IsVip: true, RegisteredAt: time.Now()}
	r := CouponRule{CouponID: 1, Status: "online", Total: 100, Claimed: 0}
 
	err := CheckClaimable(u, r, ClientApp, time.Now())
	fmt.Println(err)
}

这个测试永远通过。它唯一的用途是”跑一下看看”,价值接近零。

// 【错】 常见写法二:把所有断言堆在一个函数里
func TestCheckClaimable_v2(t *testing.T) {
	now := time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC)
 
	// 正常会员
	err := CheckClaimable(
		UserProfile{UserID: 1, IsVip: true, RegisteredAt: now.AddDate(0, 0, -30)},
		CouponRule{CouponID: 1, Status: "online", Total: 100, Claimed: 0, VipOnly: true},
		ClientApp, now)
	if err != nil {
		t.Error("vip should be able to claim")
	}
 
	// 非会员
	err = CheckClaimable(
		UserProfile{UserID: 2, IsVip: false, RegisteredAt: now.AddDate(0, 0, -30)},
		CouponRule{CouponID: 1, Status: "online", Total: 100, Claimed: 0, VipOnly: true},
		ClientApp, now)
	if err != RejectNotTarget {
		t.Errorf("non-vip should be rejected, got %v", err)
	}
 
	// ... 后面还有十几个,复制粘贴,改几个数字
}

这个版本会失败时会告诉你”第 28 行错了”,但你得自己去数那是第几个用例。当用例超过十个,这个文件就没人愿意维护了。而且每加一个用例,你都要复制一大段。这是绝大多数测试文件最终烂掉的原因——不是因为没写,是因为加用例的边际成本太高。

来看表驱动写法:

package coupon
 
import (
	"testing"
	"time"
)
 
func TestCheckClaimable(t *testing.T) {
	// now 固定成一个常量时间。这是最关键的一步:
	// 整个测试变成确定性的,任何时候跑、在任何时区跑,结果都一样。
	now := time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC)
 
	cases := []struct {
		name string        // 用例名,会显示在 go test -run / -v 的输出里
		user UserProfile
		rule CouponRule
		src  ClientType
		want error         // 期望的拒绝原因;nil 表示期望可以领取
	}{
		{
			name: "会员可以领会员专享券",
			user: UserProfile{UserID: 1, IsVip: true, RegisteredAt: now.AddDate(0, 0, -30)},
			rule: CouponRule{CouponID: 1, Status: "online", Total: 100, Claimed: 0, VipOnly: true},
			src:  ClientApp,
			want: nil,
		},
		{
			name: "非会员不能领会员专享券",
			user: UserProfile{UserID: 2, IsVip: false, RegisteredAt: now.AddDate(0, 0, -30)},
			rule: CouponRule{CouponID: 1, Status: "online", Total: 100, Claimed: 0, VipOnly: true},
			src:  ClientApp,
			want: RejectNotTarget,
		},
		{
			name: "券已下架,任何人都不能领",
			user: UserProfile{UserID: 1, IsVip: true, RegisteredAt: now.AddDate(0, 0, -30)},
			rule: CouponRule{CouponID: 1, Status: "offline", Total: 100, Claimed: 0},
			src:  ClientApp,
			want: RejectOffline,
		},
		{
			name: "库存刚好发完",
			user: UserProfile{UserID: 3, RegisteredAt: now.AddDate(0, 0, -1)},
			rule: CouponRule{CouponID: 1, Status: "online", Total: 100, Claimed: 100},
			src:  ClientApp,
			want: RejectSoldOut,
		},
		{
			name: "新用户在窗口期最后一天",
			user: UserProfile{UserID: 4, RegisteredAt: now.AddDate(0, 0, -7)},
			rule: CouponRule{CouponID: 1, Status: "online", Total: 100, Claimed: 0, NewUserOnly: true, NewUserDays: 7},
			src:  ClientApp,
			want: nil, // 注册满 7 天整,仍算新用户
		},
		{
			name: "新用户超窗口期一小时",
			user: UserProfile{UserID: 5, RegisteredAt: now.AddDate(0, 0, -7).Add(-time.Hour)},
			rule: CouponRule{CouponID: 1, Status: "online", Total: 100, Claimed: 0, NewUserOnly: true, NewUserDays: 7},
			src:  ClientApp,
			want: RejectNotTarget,
		},
		{
			name: "仅限 App 领取,H5 来源被拒",
			user: UserProfile{UserID: 6, RegisteredAt: now.AddDate(0, 0, -1)},
			rule: CouponRule{CouponID: 1, Status: "online", Total: 100, Claimed: 0, AllowAppOnly: true},
			src:  ClientH5,
			want: RejectNotTarget,
		},
		{
			name: "非会员但券无限制,可以领",
			user: UserProfile{UserID: 7, IsVip: false, RegisteredAt: now.AddDate(0, 0, -300)},
			rule: CouponRule{CouponID: 1, Status: "online", Total: 100, Claimed: 0},
			src:  ClientApp,
			want: nil,
		},
	}
 
	for _, tc := range cases {
		// t.Run 给每个用例一个独立的子测试。
		// 好处:失败时能精确定位到具体是哪个业务场景,而且可以单独跑 go test -run 'TestCheckClaimable/库存刚好发完'。
		t.Run(tc.name, func(t *testing.T) {
			got := CheckClaimable(tc.user, tc.rule, tc.src, now)
 
			// 用 == 比较 error 值,因为 RejectReason 是预定义的可比较类型。
			// 如果错误是 fmt.Errorf 包装出来的,这里要换成 errors.Is。
			if got != tc.want {
				t.Fatalf("CheckClaimable() = %v, want %v", got, tc.want)
			}
		})
	}
}

跑一下:

$ go test -v ./coupon/
=== RUN   TestCheckClaimable
=== RUN   TestCheckClaimable/会员可以领会员专享券
=== RUN   TestCheckClaimable/非会员不能领会员专享券
=== RUN   TestCheckClaimable/券已下架,_任何人都不能领
=== RUN   TestCheckClaimable/库存刚好发完
=== RUN   TestCheckClaimable/新用户在窗口期最后一天
=== RUN   TestCheckClaimable/新用户超窗口期一小时
=== RUN   TestCheckClaimable/仅限_App_领取,_H5_来源被拒
=== RUN   TestCheckClaimable/非会员但券无限制,_可以领
--- PASS: TestCheckClaimable (0.00s)
    --- PASS: TestCheckClaimable/会员可以领会员专享券 (0.00s)
    ...

这段输出,本身就是一份业务规则文档。新来的同事跑一遍 go test -v,就知道领券有哪八种情况。

表驱动解决的核心问题不是”少写代码”。它解决的是三件更本质的事:

第一,加一个用例的边际成本降到三行。 从”复制二十行改三个数字”降到”写一个结构体字面量”。这个成本差异,决定了半年后你的测试文件是八个用例还是八十个。

第二,它逼你把边界想清楚。 注意上面那两个用例:“新用户在窗口期最后一天”和”新用户超窗口期一小时”。如果你不是用表的形式写,你大概率不会去想”刚好 7 天整算不算”。而恰恰是这种边界,线上出错最多。

第三,它让 bug 可复现。 线上出了一次”有个人说他明明是新用户却领不了”,你怎么办?你不需要去猜,你只需要把他的 RegisteredAt 和当时的 now 填进表里,加一个用例,跑一下。红的就是它。修完之后,这个用例永远留在那里,防止它再回来。

最后一点:注意 now 是固定值。这是表驱动测试能不能长期活下去的关键。如果你的测试里用 time.Now(),那么今天能过的测试,下个月可能就过不了——而那时你根本不知道是代码坏了还是时间变了。

永远不要在测试里依赖”现在”。 时间、随机数、外部服务、全局配置,这四样东西进测试,这个测试就已经死了,只是还没埋。


老陈的深层发问

  1. 你项目里的测试覆盖率是多少?那百分之多少里面,有多少个测试是”即使代码写错了它也会过”的?

  2. 你上一次靠测试发现了一个自己没想到的 bug,是什么时候?如果从来没有,你的测试在保护什么?

  3. 如果明天老板说”从今天起不许写测试了,全部时间用来写功能”,你会松一口气,还是会焦虑?这个反应说明了什么?

  4. 你有没有过这种时候:改了一行代码,跑测试全绿,上线就炸了?那次你漏掉的用例是什么?你后来补了吗?

  5. 你写测试是为了让覆盖率达标,还是为了让自己敢改代码?上一次你”敢改”一个别人的核心模块,是因为什么?


生活思考题

题一:你买车之后第一年,保险到期了,一年没出过险。续保的时候你是什么心情?

参考答案

大多数人会这么答:心疼。一万多块钱,一年啥事没有,感觉白交了。要不就买个交强险算了,商业险少买点。

这个答案为什么不够:你把保险当成了”消费”,所以它必须有回报才划算。但保险的本质不是消费,是风险转移——你花一万块,把”万一出事要赔五十万”这个不确定性,转移给了保险公司。你”白交”的这一万块,买到的东西是你这一年开车的时候不用想着那件事。这个东西看不见,但它真实存在。而且这里有个特别反直觉的地方:保险性价比最高的那一年,恰恰是什么都没发生的那一年。 因为一旦发生了,说明你的风险已经兑现了,那个价格就不再是”保费”,而是”赔付”,你会被重新定价,甚至被拒保。所以”白交”不是浪费,是你赢了一局你看不见的赌局。

更深的一层:人对”没有发生的坏事”天然没有感知。你的大脑只对已经发生的事件记账,对避免掉的事件不记账。这导致一个系统性的误判:凡是”防患于未然”的投入,都会被事后评估为浪费。 备份没用上,所以备份是浪费的;演练没出事,所以演练是浪费的;测试没抓到 bug,所以测试是浪费的。这个逻辑如果成立,那消防队应该解散——因为大部分城市大部分日子都没着火。而判断这类投入的正确方式,不是看”它有没有产生收益”,而是问”如果它没有,最坏情况我承受得起吗”。承受不起,那这个钱就不是可选项。

落到 Go 工程师身上:你现在的项目,有多少”保险”?数据库备份、灰度发布、限流降级、监控告警、回归测试——这些东西平时全是成本,出事那天全是救命的。给你一个具体的判断方法:问自己一个问题,如果现在线上出了一次数据错误,我要多久能发现,多久能恢复? 答不上来的部分,就是你没买的保险。行动上,别想着一次性补全,先做一件投入最小的事:给你负责的核心接口加一个”错误率超过 1% 就告警”的规则。这个东西可能一年都不响一次,但它响的那次,会让你觉得这一年都值了。

题二:你学开车的时候,教练让你倒车入库,对三个点、打两圈方向盘。你当时觉得这玩意儿有用吗?现在倒车入库,你还会去对那三个点吗?

参考答案

大多数人会这么答:当时觉得有用,现在早忘了,全靠感觉。

这个答案为什么不够:你说”靠感觉”,但你的感觉是什么?是你开了五年车之后,脑子里形成的一个无法言说的模型:车长多少、后轮在哪、方向盘打多少度车尾会往哪边摆多少。这个模型非常准——你能一把进库,误差不超过十公分。但你说不出来。这就是问题所在:这个模型无法传递、无法检验、无法在异常情况下被调用。你换个车,车长差三十公分,你的”感觉”立刻失灵,你要重新适应半个月。而如果你当初记住的是”后视镜下沿盖住库角线时向右打死”,这个规则是可以迁移的——你只需要把它微调一下。感觉是结果,规则是原因。你只保留了结果,把原因扔了。

更深的一层:这里藏着一个关于”学习”的真相。真正的掌握,不是”我能做对”,而是”我知道我为什么做对”。 这两者在顺境下看起来完全一样,只有在环境变化时才会分出高下。而环境一定会变——换个车、换个库、换个项目、换个团队。那些”靠感觉”的人,每次环境变化都要从零开始积累;而那些”知道为什么”的人,只需要调整参数。这就是为什么有些人十年经验是一年经验用了十次,而有些人三年就能带团队。差别不在努力程度,在于他有没有把每一次成功,都转化成一条可复述的规则。

落到 Go 工程师身上:你调过一次 Go 服务的 GC 参数,把 GOGC 从 100 调到 400,P99 从 800ms 降到 120ms,你很高兴。但你能说清楚为什么吗?GOGC 调大意味着堆可以长更大才触发 GC,GC 次数减少,但每次 GC 的停顿变长、峰值内存变高。你这次赢了,是因为你的服务是内存换延迟划算的场景,还是只是运气好?下次换个服务,你是不是又要试一遍?具体行动:从今天开始,每解决一个”凭感觉调对了”的问题,花十分钟把它写成一段话:我观察到了什么、我改了什么、为什么会有效、什么情况下会失效。写下来,放在 PR 描述里,或者放在项目的 docs/tuning.md 里。一年之后,那个文件就是别人愿意花大价钱请你去的原因——因为你的经验是可传递的,而大多数人的不是。


1.4 怎么问问题,才不会让人想躲着你

对话讨论

“我插一个问题。“老陈说,“你带小周,他多久问你一次问题?”

“一天好几次吧。“小林说,“有时候一上午三四次。”

“你烦吗?”

小林犹豫了一下:”……有点。”

“为什么烦?”

“因为大部分问题,他自己看一眼就知道。“小林说,“比如他问我’这个接口返回 404 是不是路径错了’,我说你去看路由注册。他’哦’一声就走了。”

“所以他问的不是问题,是要你去帮他看。”

“对!“小林一拍大腿,“就是这个感觉。他不是在问,他是在……推。把问题推给我。”

老陈笑了:“那我问你,你当年问你 leader 的问题,是什么样的?”

小林沉默了。

“我不记得了。“他说。

“我帮你回忆一下。“老陈说,“你在那个 coupon 项目里,有一个文件叫 debug.log,我看到了。你在里面写过一行:‘为什么这里会 panic,问了王哥,王哥说不知道’。”

”……您连这个都看到了。”

“你写了日期,2021 年 6 月 3 号。“老陈说,“那天你问了什么问题?”

小林闭着眼睛想。那天下午,他记得自己卡了三个多小时,最后实在没办法,拍了拍旁边工位的王哥。

“我说’王哥,我这有个地方 panic 了,你帮我看看’。“他说,“然后我把屏幕转给他。”

“他怎么回答的?”

“他说不知道,让我加日志看。”

“然后你写下了’王哥说不知道’。”

“我当时觉得他敷衍我。”

老陈看着他:“你现在觉得呢?”

小林没说话。过了好一会儿。

“我现在觉得……”他慢慢地说,“我那个问题,确实没法回答。”

“为什么没法回答?”

“因为我只说了’panic 了’。哪个 panic?在哪一行?什么输入?我复现了吗?我什么都没说。“小林说,“王哥要回答我,得先花二十分钟把我的代码读一遍,然后花十分钟复现。他当时正在赶自己的东西。”

“你看,你自己其实知道。“老陈说。

“可是……”小林还是有点拧巴,“那我当时是真的不知道该怎么问啊。我又不是故意的。”

“我没说你故意。“老陈说,“我说的是:没有人教过你怎么提问,所以你用了最省力的那种问法,然后你为此付出了代价——你以为别人不愿意帮你。”

小林想起一件事。他去年带小周的时候,有一次小周问他问题,他没回,过了半小时小周自己解决了。他当时心里还挺高兴,觉得”这不就自己解决了吗”。

现在他意识到,小周可能也会在心里记一笔”林哥不愿意帮我”。

“那到底该怎么问?“他问。

“我先问你,你觉得提问的目的是什么?”

“得到答案?”

老陈摇头。

小林想了想:”……得到思路?”

“再想想。”

”……”小林卡住了。

“我给你一个说法,你看看对不对。“老陈说,“提问的目的,是让对方用最低的成本,把他脑子里的信息转移到你脑子里。”

“最低成本……”

“对。你要替对方算这笔账。他回答你的问题要花多少时间?这里面有几个部分:理解你的问题、定位相关代码、回忆他的经验、组织语言。”

“然后呢?”

“然后你看这四个部分,哪一个你能提前帮他做掉?”

小林琢磨了一下:“理解问题……和定位代码?”

“对。“老陈说,“如果你问’这个接口 404 了怎么办’,对方要从第一秒开始理解。如果你问’我在 router.go 第 47 行注册了 /api/v1/claim,但请求 /api/v1/claim 返回 404,我用 curl 直接打本机 8080 也是 404,日志里没有进 handler’,对方十秒钟就能给你一个方向。”

“这两个问题的差别……”小林喃喃道,“不是措辞,是我自己先做了多少工作。”

“你现在明白小周的问题在哪了?”

“明白了。他问我之前,什么都没做。”

“也不完全是什么都没做。“老陈说,“我猜他是做过一点的——他至少打开了那个文件。但他没做完。”

“什么叫没做完?”

“他做到’我觉得这里有问题’就停了,没有做到’我把问题缩小到了具体哪一步’。“老陈说,“这中间差的那一段,恰恰是最有价值的。因为当你把问题缩小到具体某一步的时候,你经常自己就找到答案了。”

小林想起一个数据。他上个月统计过自己问别人的问题,大概十五个,其中七个他在描述的过程中自己想通了。

“我有一个问题。“小林说,“如果我把问题描述得很清楚,花的时间比直接问还多呢?有时候我卡了三小时,问别人三分钟就解决了。”

“三小时和三分钟,你选哪个?”

“三分钟啊。”

“所以你要问的是,怎么判断该卡多久?”

“对。”

“我给你一个我自己的标准。“老陈说,“半小时。”

“半小时?”

“半小时之内,你必须做三件事:第一,把问题描述清楚,写下来;第二,至少试过两种你猜的方向;第三,把你试过的东西记下来,包括失败的。这三件事做完还卡着,就问。”

“如果三件事做不完呢?”

“那就说明你还没开始真正解决问题,你只是在原地转。“老陈说,“而且你注意——这三件事做完之后,你写下来的那段描述,直接就是你要发给别人的问题。它一分钱不浪费。”

小林在心里默默记下了这个。

“还有最后一个。“老陈说,“这个最重要。”

“您说。”

“问完之后,回去告诉对方结果。”

“啊?”

“你问了王哥,他给了你一个方向。后来你解决了。你回去告诉他了吗?”

小林想了想。没有。他当时解决了就过去了,王哥可能到现在都不知道那个 panic 最后是怎么修的。

“为什么要告诉他?“小林问。

“三个理由。“老陈伸出手指数,“第一,他给你的方向如果错了,他需要知道,否则他下次还会给别人同样的错误方向。第二,他如果对了,他需要知道自己是对的——人是需要这个反馈的,这决定他下次还愿不愿意帮你。第三,也是最重要的:你们之间会建立起一个’我帮你是有回响的’的信任。”

“信任……”

“你以为那些’人缘好、一问就有人答’的人,是因为他们长得好看?“老陈说,“是因为大家知道,问他问题他会认真答,回答了他会给你反馈。这个循环转起来,他获取信息的速度是你的五倍。”

小林想起他们组里一个叫婷婷的姑娘。她技术一般,但她每次问完问题,隔天一定会发条消息:“昨天那个问题,按你说的改了,好了,谢谢!“有时候还会补一句”我另外发现那个地方还有个坑”。

她入职比小林晚一年,现在已经是小组长了。

小林一直觉得是因为她会来事儿。

现在他不这么想了。


生活化案例:4S 店里的两次修车

小林有辆开了六年的车。前年冬天,右前轮那边开始有异响。

第一次去 4S 店,他跟接待说:“过减速带的时候,右前轮有异响。”

接待开了个单子,师傅把车开出去试了一圈,回来跟他说:“没试出来啊,正常。”

小林有点不高兴:“明明有。”

师傅说:“我再试一圈。“又开出去一圈,还是没有。

最后师傅说:“要不您把车放这儿,我们慢慢查?”

小林没放,他要用车。车开回来之后,那个响声还在,而且他开始怀疑是不是自己听错了。

这事拖了一个多月。

第二次去,是年后。这回他学乖了,去之前做了点功课。

他拿手机录了一段视频——让他老婆开车,他坐副驾,把手机伸到窗外对着右前轮拍。他老婆开了三趟减速带,录了三段。

然后他写了一张纸给师傅:

  • 声音:咔哒一声,不是连续的,是一下
  • 条件:时速 30 到 40,过减速带,不踩刹车的时候响;踩着刹车过,不响
  • 冷车明显,开十几分钟之后变轻
  • 平路上完全没有,只有过减速带或者过坑的时候
  • 大概从去年 11 月开始

师傅看完那张纸,又看了视频,直接说:“平衡杆胶套。”

然后把车举起来,二十分钟,换了个胶套。零件四十,工时四十。

一共八十块。

小林当时站在旁边看着,心里想的是:同一个师傅,同一个问题,为什么上一次他查不出来?

不是师傅不行。上一次师傅开着车在 4S 店门口那种平整的路面转了两圈,时速二三十,还时不时踩脚刹车——那些条件全都不满足,车当然不响。

师傅不是不愿意修,是他没有足够的信息去复现。

小林第一次去的时候,他脑子里装着一个完整的现象,但他只说出了六个字:“右前轮有异响。”

这六个字,把所有的关键信息都丢了:什么声音、什么条件、什么时候开始的、什么情况下不响。

而他做那张纸,花了多久?录视频二十分钟,写纸五分钟。

第一次省下的这二十五分钟,让他多跑了一趟 4S 店,多担了一个多月的心,还差点儿让他以为自己幻听。

这个事小林后来越想越觉得,它揭示的东西比”描述要详细”要多得多。

你观察到的现象,和你描述出来的信息,中间有一个巨大的漏斗。

你脑子里看到的是整头大象,你说出来的可能只有”一个长条状会动的东西”。

而且这个漏斗是单向的——信息一旦丢了,对方不可能凭空补回来。他能做的只有两件事:要么猜,要么问你二十个问题。

大多数有经验的人,遇到这种描述,会选择第三条路:放弃。

他不会说”我不帮你”,他会说”这个我得看看”,然后就没有然后了。

你以为他忙,其实他是算了一下账,发现回答你的成本太高了。

这就是为什么有些人问问题,别人抢着答;有些人问问题,别人看到消息就假装没看见。

差别不在关系,在于你替对方省了多少事。


Go 技术实例:最小可复现示例

技术上的提问,有个专门的词,叫 MRE,Minimal Reproducible Example,最小可复现示例。

我们看一个真实的对比。假设小林在写优惠券核销的时候,遇到了一个并发问题。

【错】 常见的提问方式:

大佬们,我这段代码用 goroutine 并发核销优惠券,跑的时候总是少几张,是不是 map 并发写的问题?我加了锁还是不行,急,在线等。

(下面贴了 200 行业务代码,含数据库操作、Redis 调用、日志埋点)

这段提问里,对方要做的事情是:读 200 行他不熟悉的代码、理解你的业务、猜你说的”少几张”是什么意思、然后自己搭环境复现。

成本:一到两个小时。而且大概率他复现不出来——因为你的代码依赖数据库和 Redis。

结果:没人回。

【对】 会有人回答的提问方式:

先把问题从业务里剥离出来,写成一个谁都能跑的小程序:

package main
 
import (
	"fmt"
	"sync"
)
 
// 最小复现:把业务逻辑剥离后,问题只剩这几行。
// 现象:期望 total == 10000,实际每次都小于 10000,且不固定。
func main() {
	var wg sync.WaitGroup
	total := 0
 
	for i := 0; i < 10000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			total++ // 这里是非原子操作:读-改-写三步,并发下会互相覆盖
		}()
	}
 
	wg.Wait()
	fmt.Println("total =", total) // 输出:total = 9837(每次不一样)
}

然后这样提问:

环境:go version go1.21.5 windows/amd64
代码:(上面这段,直接 go run 就能跑)
期望:total == 10000
实际:total = 9837,每次不一样
我试过的:

  1. 用 sync.Mutex 包住 total++,结果正确了
  2. 但我在真实代码里加了锁,还是会少,所以我怀疑不只是这一个问题
    我的疑问:加 Mutex 之后理论上应该正确,真实代码里还是少,可能是哪个环节没覆盖到?

这个提问,对方花三十秒就能看懂,花一分钟就能跑一遍,然后给你一个精确的答案。

而且——这件事最神奇的地方在于:你在写这个 MRE 的过程中,八成自己就找到答案了。

因为你要写 MRE,就必须把 200 行业务代码删到 20 行。删的过程中,你会被迫做一个判断:这行代码跟这个 bug 有关吗?

当你删到最后,你做的事情本质上是二分查找。你每次删掉一半,跑一次,看 bug 还在不在。

绝大多数 bug,在你删到第三轮的时候就自己暴露了。

这就是为什么老陈说”半小时规则”里一定要包含”至少试过两种方向”。逼自己构造 MRE,本身就是一种强力的调试手段。

再看一个更贴近 Go 日常的例子:读错误信息。

很多人的提问是”这个报错什么意思”,然后贴一张截了一半的图。而 Go 的错误信息里,其实已经把答案写在脸上了。比如这个:

panic: assignment to entry in nil map

goroutine 1 [running]:
main.buildIndex(...)
        D:/code/coupon/index.go:23 +0x4f
main.main()
        D:/code/coupon/main.go:11 +0x25
exit status 2

这四行里有多少信息?

  • assignment to entry in nil map:往一个 nil map 里写值。原因已经告诉你了:那个 map 没初始化。
  • index.go:23:精确到行。
  • main.go:11:调用链,告诉你 main 在第 11 行调用了 buildIndex。
  • goroutine 1 [running]:哪个 goroutine。

读懂这个,你根本不需要问任何人。你需要做的只是打开 index.go 第 23 行,看看那个 map 是在哪声明的,然后往上找它有没有被 make。

// 【错】 触发 panic 的写法
func buildIndex(coupons []Coupon) map[int64]Coupon {
	var m map[int64]Coupon // 只声明,没有 make,m 是 nil
	for _, c := range coupons {
		m[c.ID] = c // index.go:23 —— panic 在这里
	}
	return m
}
// 【对】 正确写法:make 出来,或者带容量提示
func buildIndex(coupons []Coupon) map[int64]Coupon {
	// 预分配容量:避免 map 扩容时的多次 rehash,
	// 数据量大的时候(十万级)能省下可观的分配开销。
	m := make(map[int64]Coupon, len(coupons))
	for _, c := range coupons {
		m[c.ID] = c
	}
	return m
}

这里顺便说一句:这个错误,go vet 抓不到,编译器也不报错。它是那种”跑起来才知道”的错误。

所以有一个习惯非常值钱:提交代码之前,跑一遍 gofmt、go vet、go build、go test。 四行命令,三十秒。

gofmt -l .          # 列出所有没格式化的文件(不直接改,先看)
gofmt -w .          # 真正格式化
go vet ./...        # 静态检查:能抓到 printf 格式串不匹配、无用的赋值、
                    # 复制了带锁的结构体、忘记检查的 error 等一整类问题
go build ./...      # 确认能编译
go test ./...       # 跑测试

go vet 抓到的很多问题,是那种你自己读十遍代码都看不出来的。比如:

// go vet 会报:fmt.Printf format %d has arg s of wrong type string
fmt.Printf("user id: %d\n", "abc")
 
// go vet 会报:assignment copies lock value
// 这个错非常隐蔽:复制一个带 mutex 的结构体,锁就失效了
type Cache struct {
	mu   sync.Mutex
	data map[string]string
}
func (c Cache) Snapshot() Cache { return c } // ← vet 报警

这四行命令,就是你在”问别人”之前,最该自己先做的那一步。老陈说的”至少试过两种方向”,其中一种就是这个。


老陈的深层发问

  1. 你微信里有多少个”问完之后就没再回音”的对话?对方当时花了多少时间回你,你知道吗?

  2. 你最近一次问别人问题,在问之前,你自己为这个问题工作了多久?如果不到半小时,你是在提问,还是在转移?

  3. 如果小周明天开始,每个问题都按”现象、条件、我试过的、我的猜测”四段来问,你会更愿意教他,还是觉得他事儿多?你的答案暴露了什么?

  4. 你有多久没有主动去问一个”蠢问题”了?你从什么时候开始,觉得问蠢问题会丢人?

  5. 你上一次被人问问题,你认真回答了吗?如果你的下属现在卡住了但他没来问你,你觉得是为什么?


生活思考题

题一:你去一个陌生城市旅游,在一个路口迷路了。你会怎么问路?

参考答案

大多数人会这么答:找个人问”XX 怎么走”。

这个答案为什么不够:你试过就知道,这么问得到的答案质量极不稳定。有人会热情地给你指一个方向,你走了十分钟发现不对;有人说”不太清楚”,然后走了;有人给你指了一个他自己也不太确定的方向,但因为他指得很肯定,你就信了。问题出在哪?出在你问的是一个开放问题,你把”搞清楚你在哪、你要去哪、怎么走最合适”这三件事,全部推给了对方。而对方脑子里只有”从这儿往前走”这一条路径——他不知道你走了多久、你累不累、你赶不赶时间、你能不能爬楼梯。

更深的一层:高手的问法是给出自己的判断,让对方确认或纠正。“我看看地图,我要去 XX,我现在应该在这个位置,如果往东走两个路口再往北,是不是就到了?“这个问题,对方只需要回答”对”或者”不对,往东走三个路口”。为什么这样问质量高?因为你已经完成了 90% 的工作,对方只需要做最后 10% 的校验。而且人的心理很有意思——纠正一个具体的错误,比从零构造一个正确答案容易得多,所以对方会更愿意回答,也会回答得更准确。还有一点:你给出自己的判断,等于给了对方一个”修正的锚”。如果他完全不知道,他会说”我不确定”;如果你没给锚,他可能随便指一个方向来尽快结束这次对话。

落到 Go 工程师身上:把你所有的提问,从”怎么办”改成”我打算这么做,你觉得对吗”。不要问”这个并发问题怎么解决”,问”我打算用 channel 把结果收集回来,然后主 goroutine 统一写入,你觉得这个思路有没有坑”。前者对方要从零给你设计,后者他只需要扫一遍你的方案找漏洞——后者他三十秒就能回你,而且给的答案质量高得多。具体行动:下次提问之前,先在编辑器里把你打算怎么做的方案写出来(哪怕就三行),然后连同方案一起发出去。你会发现回复率至少翻倍,而且你经常在写方案的过程中自己就想通了。

题二:你朋友跟你吐槽工作上的事,说了二十分钟。你给了他三个建议,他每个都回”但是……”。你什么感觉?下一次他再来吐槽,你还会认真听吗?

参考答案

大多数人会这么答:不会了。我明明在帮他想办法,他一句都听不进去,那我还说什么。下次敷衍一下算了。

这个答案为什么不够:你把”给建议”当成了帮助本身。但你回想一下那二十分钟,他真的在要建议吗?他第一句话可能是”我今天气死了”,或者”你说这事儿是不是过分”。他在要的是确认,不是方案。你给的三个建议,每一个都在传递一个隐含信息:“你目前的处理是错的,你应该按我说的做。“所以他才会本能地防御,才会对每个建议都说”但是”——他不是在反驳你的方案,他是在保护自己”我已经尽力了”这个感觉。这个循环里没有坏人,你们俩都很真诚,但你们在做两件完全不同的事:他在寻求共鸣,你在解决问题。而 mismatch 的代价,是你们两个人都觉得对方不理解自己。

更深的一层:所有的沟通,在传递信息之前,先要传递一件事——“我看见你了”。这件事没做完,后面的信息全都会被对方的防御系统挡掉。这不是什么心灵鸡汤,是有非常明确的机制:一个人在感觉自己被否定的时候,他的注意力会全部转向”如何证明我没错”,这时他处理新信息的能力接近于零。所以你会发现,越是在对方情绪上头的时候给建议,效果越差,而且会反向强化他原来的做法——因为他为了证明自己没错,会更加坚定。这就是为什么很多技术讨论会变成吵架:双方都觉得自己在讲道理,实际上双方都在防守。

落到 Go 工程师身上:代码评审的时候,“这里应该用 errgroup”和”我看了下这里,感觉并发的写法有点复杂,是有什么我没注意到的考虑吗”,是同一件事的两种说法。前者触发防御,后者触发解释。而后者能拿到的信息多得多——你可能会听到”哦,这里是因为下游不支持并发,我试过 errgroup,被限流了”,这个信息你用前一种问法永远拿不到,因为对方会在心里把你的评论归为”他又在显摆”然后直接关掉。具体行动:给你的评论加一个前缀。把”这里错了”改成”我没看懂,能讲讲吗”,把”应该用 X”改成”如果用 X 会怎么样,你考虑过吗”。这不是虚伪的圆滑,是因为你真的不知道对方为什么那么写,而且你真的想知道。


1.5 代码评审:别人批评你的代码时,你在防御什么

对话讨论

“我们再聊聊 code review。“老陈说,“你们公司的 PR,平均多久有人看?”

“快的半天,慢的两天。”

“你提的 PR,被要求改的多吗?”

“还行吧,一般一两条评论。“小林说,“有时候 Kevin 会在我 PR 下面留很长一段,我看着就来气。”

“来气?”

“嗯。“小林承认,“他有时候说话挺冲的。有一次他直接在我 PR 上写’这个写法有问题,应该重构’。就这一句,没有解释。”

“你当时怎么处理的?”

“我回了一句’这里是因为历史原因,先这样’。”

“然后呢?”

“然后他没再回,PR 就过了。”

老陈点点头:“你觉得你赢了?”

“我……”小林笑了笑,“算是吧。”

“我问你个事。“老陈说,“那个 PR,最后上线了吗?”

“上了。”

“出问题了吗?”

小林的表情有点僵。

“两个月后,那个接口超时,大刘半夜把我叫起来了。“他说,“查了三个小时,最后发现就是 Kevin 说的那个地方。”

“你看。“老陈说。

“可是他当时说清楚点我肯定就改了啊。“小林有点委屈,“他只说’有问题’,我怎么知道他说的是哪个问题。”

“那你为什么不问他?”

“因为……”小林停住了。

他为什么没问?他心里清楚。他当时的第一反应是”你凭什么说我的代码有问题”,第二个反应是”我不问,显得我没听懂”。然后他选了第三个:用一句”历史原因”把话题堵死。

“我猜你当时的感觉是,“老陈说,“他批评的不是你的代码,是你。”

小林抬起头。

”……对。”

“这个感觉是对的还是错的?”

“我理性的部分知道他是在说代码。但我当时的感觉就是……”小林比划了一下,“就是被人当众扇了一巴掌。”

“这个反应很正常,每个人都这样。“老陈说,“我四十二岁那年,写了一个自认为特别好的方案,发给我们当时的 CTO 看。他回了一封邮件,就一句话:‘这个方案的假设不成立。’”

“您怎么回的?”

“我没回。“老陈说,“我把那封邮件放了一周没看。第二周我打开,回了一封两千字的邮件,逐条论证我的假设成立。”

“他怎么回的?”

“他把我的邮件打印出来,在我工位上放了一张纸,上面用红笔圈了三个地方,旁边写着’这三个数据来源是去年的’。”

小林笑了:“那您确实错了。”

“我错了。但我花了一周时间,写了一封两千字的邮件,就为了证明我没错。“老陈说,“那一周,我什么正事都没干。”

“您后来跟他聊过这事吗?”

“聊过。他退休那年,我们一起吃了顿饭。“老陈说,“我问他,你当时为什么不直接告诉我那三个数据来源有问题。”

“他说什么?”

“他说:‘我说过。第一封邮件里我只写了一句话,是因为我想让你自己去找。你要是来问我,我十分钟就告诉你了。’”

小林沉默了。

“所以你看,“老陈说,“防御的代价,全是你自己付的。 他没损失什么,他该说的说了。我损失了一周,外加一次当面被打脸。”

“那您现在呢?现在有人批评您的代码,您什么反应?”

“还是有反应。“老陈说,“三十年了,这个反应一点没减弱。”

“那怎么办?”

“区别在于,“老陈说,“我现在能在那个反应冒出来之后,给自己三秒钟。”

“三秒钟干什么?”

“问自己一句话:他说的这个,如果我是对的,我怕什么?”

小林愣住了。

“你想想。“老陈说,“如果他说的对,那你改了,代码变好了,你学到东西了,你怕什么?如果他说的错,那你解释清楚,他学到东西了,你怕什么?”

“两种情况都不亏啊。“小林说。

“对,两种情况都不亏。那你防御什么?”

小林想了很久。

“我防御的……”他慢慢地说,“好像是’我不如他’这件事。”

“嗯。”

“就是我不想在别人面前显得我不行。”

“这个东西,具体是什么?”

“是……”小林斟酌着,“是我不想承认,有人比我懂得多。”

老陈点点头:“这句话说出来,比憋着舒服多了吧。”

”……是舒服点。“小林笑了,“说出来好像也没那么可怕。”

“我再问你一个更狠的。“老陈说,“你带小周,你给他评审的时候,你是在帮他,还是在证明你比他强?”

小林张了张嘴。

“我……我觉得是帮他。”

“举你最近一次的例子。”

小林想起上周给小周评审的一段代码。他留了九条评论。九条。

其中有一条是”这个变量名不太好”,一条是”这里可以简化”,还有一条他现在想起来,纯粹是因为小周用了一种他没见过的写法,他就留了句”为什么这么写”。

“九条。“老陈说,“你觉得小周看完是什么感觉?”

”……他可能觉得我事儿多。”

“他会改几条?”

“全改。”

“他会记住几条?”

小林不说话了。

“我跟你说个数据。“老陈说,“我做过一个小实验。我让几个团队统计过,一个 PR 上的评论数量,和这些评论被真正落实的比例,是负相关的。”

“评论越多,落实得越少?”

“对。超过七条评论,人的大脑就开始筛选了。他会挑最容易改的改掉——改个变量名、加个注释——然后把难的那些标记为已解决,实际上没动。”

“那怎么办?”

“你先想想,你留九条评论的时候,你的目的是什么?”

“让代码变好。”

“九条里,有几条是真正让代码变好的?”

小林老实回答:”……三四条吧。”

“那另外五六条是什么?”

“是我顺手写的。”

“你为什么不把那五六条删掉?”

“因为……”小林想了想,“因为如果我只在 PR 上留三条评论,我觉得我没有认真评审。”

老陈笑了。

“你看,这就是问题。“他说,“你把评审当成了考试,你要证明你批得够狠。 但评审的目的是让代码变好,不是让你展示你有多严格。”

“那我该留几条?”

“我给你一个规则。“老陈说,“一个 PR,最多留三条必须改的,其他的用’nit’开头,明确告诉对方可以不改。”

“nit?”

“nitpick,吹毛求疵的意思。业界通用写法。你写 nit: 这个变量名可以再短一点,不过不改也行。”

“这样对方不就不会有压力了吗?”

“对。而且更重要的是,“老陈说,“你这么写的时候,你自己也被迫分了类。 你留每条评论的时候必须问自己:这条是’不改会出问题’,还是’改了会更好’?”

小林在心里过了一遍上周那九条。

有三条是”不改会出问题”。六条是”改了会更好”。

如果他当时标了 nit,小周会先改那三条,然后有余力再看那六条。而不是被九条压着,随手改几个交差。

“我还有一个问题。“小林说,“如果评审的人说的确实不对呢?”

“那你就要顶回去。”

“啊?”

“你要顶回去,但是要带着理由。“老陈说,“‘我这么写是因为 X,我考虑过 Y,但是 Z’。这种回复,评审的人欢迎得很。”

“为什么?”

“因为他最怕的不是你顶他,他最怕的是你沉默。“老陈说,“你沉默,他不知道你是同意了、没看见、还是心里不服。这三种情况,后续的处理方式完全不一样。”

小林想起自己给 Kevin 回的那句”历史原因,先这样”。

在 Kevin 眼里,那句话可能就是沉默。


生活化案例:那条有点腥的鱼

小林学会做清蒸鱼,是三年前的事。

他专门研究过。鱼要现杀,刀口要斜,姜丝要铺在鱼身下面而不是上面,水开之后上锅,八分钟,多一秒都不行,出锅倒掉蒸鱼的水——那个水腥,然后重新淋热油、浇蒸鱼豉油。

他练了七八次,觉得自己可以出师了。

那年他丈母娘过生日,他主动请缨,做了一桌子菜,清蒸鱼是主菜。

他早上六点去菜市场,挑了一条一斤八两的鲈鱼,活的,让摊主当场杀。回家处理了半小时,每一片姜都切得一样厚。上锅的时候他掐着表。

菜上桌,大家都在夸。他丈母娘说”小伙子还会做饭,不错”。

他挺高兴的。

然后他小舅子夹了一筷子鱼,嚼了嚼,说:“这鱼有点腥啊。”

就这一句。

小林后来跟他老婆说,他当时脑子里”嗡”的一下。他后来回想,那一瞬间他脑子里冒出来的念头,顺序是这样的:

第一,“你懂个屁。” 第二,“我六点就去买鱼了你知道个屁。” 第三,“你是不是故意的。”

(他和他小舅子关系一般。)

他当时没说话,笑了一下,说”可能今天这条鱼不太新鲜”。

然后那顿饭他吃得没什么滋味。他一直在心里复盘:是蒸的时间长了?是蒸鱼的水没倒干净?还是豉油浇早了?

那天晚上他跟他老婆说:“你弟是不是针对我?”

他老婆说:“他就是那么个人,说话不过脑子。”

“那他也不能……”

“等一下,“他老婆说,“鱼是不是真的有点腥?”

小林卡住了。

”……可能吧。“他说,“我今天那个锅,水好像没完全开我就上去了。”

“那你明天再蒸一条?”

他第二天真的又蒸了一条。这回水开了才上锅,八分钟整,出锅把水倒得干干净净。

一点都不腥。

这件事小林后来越想越清楚。他小舅子那句话,价值其实非常高——它是唯一一个真实的反馈。

那天桌上其他人的”不错""挺好""小伙子真行”,全都是客气。只有那句”有点腥”,是真话。

而他为了防御那句真话,付出了什么?

他花了一整晚的心情,花了一次跟他老婆的争执,还在心里给他小舅子记了一笔账,这笔账记到现在。

而那句真话带给他的东西,他第二天才拿到。

如果当时他能跳过那三个念头,直接问一句”哪个部位腥”——那顿饭的后半程,他会吃得开心得多。

代码评审,是同一件事的职场版本。

你花三天写的 PR,是你的那条鱼。你知道它哪里下了功夫,你知道你为了那个奇怪的 if 挣扎了多久。所以当有人说”这里有问题”的时候,你听到的不是”这里有问题”,你听到的是”你三天白干了”和”你不行”。

但对方说的,真的只是”有点腥”。

而且还有一层更深的:那些说”不错""挺好""LGTM”的人,大多数根本没看你的代码。真正看了的人,才会说”有点腥”。

所以,你应该巴不得有人在下面留一条刺眼的评论。 那意味着这个世界上,有人真的读了你的东西。


Go 技术实例:一段会被打回的 HTTP 调用代码

我们来看一段非常典型的、会被评审反复挑出来的 Go 代码。需求是:批量查询几个商户的优惠券列表。

【错】 第一版(会被打回):

package coupon
 
import (
	"encoding/json"
	"io/ioutil"
	"net/http"
)
 
type MerchantCoupons struct {
	MerchantID int64    `json:"merchant_id"`
	Coupons    []Coupon `json:"coupons"`
}
 
// FetchAll 批量拉取所有商户的券
func FetchAll(ids []int64) ([]MerchantCoupons, error) {
	var result []MerchantCoupons
 
	for _, id := range ids {
		resp, err := http.Get("http://coupon-api/merchant/" + string(rune(id)) + "/coupons")
		if err != nil {
			return nil, err
		}
		defer resp.Body.Close() // ← 问题一
 
		body, _ := ioutil.ReadAll(resp.Body) // ← 问题二
 
		var mc MerchantCoupons
		json.Unmarshal(body, &mc) // ← 问题三
		result = append(result, mc)
	}
 
	return result, nil
}

这段代码的每一个问题,都是代码评审里会被反复挑出来的经典项:

问题一:defer 写在循环里。 defer 的执行时机是函数返回时,不是循环结束时。如果 ids 有 5000 个元素,这个函数会在返回前攒下 5000 个未关闭的连接,直接把文件描述符耗尽。线上的表现是:跑了一段时间之后,机器上所有网络请求都开始报 too many open files。

问题二:ioutil.ReadAll 的 error 被丢弃。 读一半网络断了,你会拿到一个残缺的 body,然后继续往下走。

问题三:json.Unmarshal 的 error 被丢弃。 这是最危险的一个。如果下游返回的不是 JSON(比如网关返回了一个 HTML 的 502 页面),mc 会是零值,然后你把一个空的 MerchantCoupons 追加进结果里。调用方拿到的数据是”这个商户有零张券”,而不是”这个商户查询失败了”。 这类 bug 能在线上安静地趴半年。

问题四:string(rune(id)) 是错的。 这段代码的作者想的是把 int64 转成字符串,他搜到 string() 就用了。但 string(rune(123)) 得到的是 Unicode 码点 123 对应的字符,也就是 {。URL 会变成 /merchant/{/coupons。

问题五:没有超时。 http.Get 用的是 http.DefaultClient,它没有超时。下游挂了,你这个请求会一直挂着,直到操作系统的 TCP 超时(可能是两分钟)。五百个商户串行调用,每次挂两分钟,你这个接口就彻底死了。

问题六:没有 context。 调用方取消请求了,你这边不知道,还在继续跑。

【对】 改进版:

package coupon
 
import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strconv"
	"time"
)
 
type MerchantCoupons struct {
	MerchantID int64    `json:"merchant_id"`
	Coupons    []Coupon `json:"coupons"`
}
 
// 包级别的 client,复用连接池。
// 不要在每次请求时 new 一个 http.Client——那样每次都要重新建 TCP 连接、
// 重新做 TLS 握手,性能极差,而且会积累大量 TIME_WAIT 状态的连接。
var couponClient = &http.Client{
	Timeout: 3 * time.Second, // 兜底超时:防止下游挂死拖垮上游
	Transport: &http.Transport{
		MaxIdleConnsPerHost: 100,
	},
}
 
// fetchOne 拉取单个商户的券。
// 拆成独立函数的原因:让 defer resp.Body.Close() 的作用域限制在单次请求内,
// 而不是累积到整个批量调用结束。
func fetchOne(ctx context.Context, id int64) (MerchantCoupons, error) {
	var mc MerchantCoupons
 
	// strconv.FormatInt,不是 string(rune(id))
	url := "http://coupon-api/merchant/" + strconv.FormatInt(id, 10) + "/coupons"
 
	// 用 NewRequestWithContext,让上游取消能传递到这里
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return mc, fmt.Errorf("build request for merchant %d: %w", id, err)
	}
 
	resp, err := couponClient.Do(req)
	if err != nil {
		return mc, fmt.Errorf("request merchant %d: %w", id, err)
	}
	// 这里的 defer 在函数返回时执行,也就是每次请求结束就关闭。
	defer resp.Body.Close()
 
	// 非 2xx 必须显式处理:http.Client 不会因为 404/500 返回 error
	if resp.StatusCode != http.StatusOK {
		return mc, fmt.Errorf("merchant %d: unexpected status %d", id, resp.StatusCode)
	}
 
	// LimitReader 是防御性措施:防止下游返回超大响应把内存打爆。
	// 10MB 对券列表来说绰绰有余,超了说明下游出问题了。
	body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
	if err != nil {
		// 读 body 的错误必须处理:读到一半断连,body 是残缺的
		return mc, fmt.Errorf("read body of merchant %d: %w", id, err)
	}
 
	if err := json.Unmarshal(body, &mc); err != nil {
		// 反序列化错误必须处理,否则拿到的是零值,会被当成"有零张券"
		return mc, fmt.Errorf("decode body of merchant %d: %w", id, err)
	}
 
	mc.MerchantID = id // 用请求时的 id 回填,不信任响应里的值
	return mc, nil
}
 
// FetchAll 批量拉取。单个商户失败不影响整体,失败信息随结果一起返回。
func FetchAll(ctx context.Context, ids []int64) ([]MerchantCoupons, []error) {
	result := make([]MerchantCoupons, 0, len(ids))
	var errs []error
 
	for _, id := range ids {
		// 每处理一个,检查一次上游是否已取消
		select {
		case <-ctx.Done():
			errs = append(errs, ctx.Err())
			return result, errs
		default:
		}
 
		mc, err := fetchOne(ctx, id)
		if err != nil {
			// 收集错误继续跑:一个商户的券查不到,不应该让整批失败。
			// 这是产品决策,需要跟阿 May 确认,不能自己拍。
			errs = append(errs, err)
			continue
		}
		result = append(result, mc)
	}
 
	return result, errs
}

这段代码会经得起评审,不是因为它更长,而是因为它把上面那六个问题全部回答了。

但这里我想说一件更重要的事。

如果你在评审里看到第一版代码,你会怎么留评论?

【错】 不好的留法(六条评论,每条一行):

  • “defer 放循环里了”
  • “error 没处理”
  • “string(rune()) 是错的”
  • “没超时”
  • “没 context”
  • “ioutil 废弃了”

这六条评论,对方会改,但他不知道为什么要改。下次他写另一段代码,还会犯同样的错。

【对】 好的留法:

挑出最重要的两条(不超时会拖垮整个服务、string(rune()) 是错的),写清楚后果;剩下四条标成 nit,或者干脆只在最重要的那条下面讲清楚原理,然后说”这个循环里还有几个类似的问题(defer 位置、error 检查),你一起看一下,改完我再过一遍”。

一条讲透的评论,胜过六条正确的评论。

评审的价值不在于你标出了多少问题,而在于对方看完之后,下次写这段代码的时候会不会不一样。


老陈的深层发问

  1. 你上一次在 PR 上留评论,有多少条是你真正读完代码之后写的,有多少条是你扫一眼顺手留的?

  2. 如果从明天起,你所有的 PR 都不允许别人留评论(直接合并),你会松一口气还是会慌?这个反应说明你把评审当成了什么?

  3. 你有没有过这种时候:评审的人提了一个你觉得不对的意见,你想反驳,但最后算了,改了?那次你放弃的是什么?

  4. 你现在手上有没有一个 PR,是被打回过三次以上的?你还记得每次打回的理由吗?如果记不清,说明什么?

  5. 如果你带的实习生小周,有一天在你的代码里挑出了一个你没注意到的严重问题,你的第一反应是高兴还是尴尬?


生活思考题

题一:你花了三个周末,自己动手给阳台打了一个柜子。打好之后你挺满意,发了个朋友圈。你爸来了看了一眼,说”这个隔板有点薄,放重东西会弯”。你什么反应?

参考答案

大多数人会这么答:心里不爽。我心血之作,你不夸就算了,上来就挑毛病。

这个答案为什么不够:你仔细想想你不爽的来源——你爸说的是”隔板薄”,你听到的是”你打的柜子不行”。这两件事的距离,比你想象的大得多。但这里有个更值得想的点:为什么你爸会这么说? 他没说”柜子丑”,没说”颜色不搭”,没说”你浪费时间”。他精准地指出了”隔板薄会弯”。这说明什么?说明他真的看了,而且他有判断依据——他可能年轻时打过柜子,见过隔板弯掉的样子。换句话说,他是在用他的经验保护你。你防御掉的,恰好是最有价值的那一类反馈:来自有经验的人的、具体的、可执行的。而那些说”不错啊""真厉害”的朋友圈点赞,你从中得到了什么?什么都没有。

更深的一层:人对”自己投入过的东西”的防御强度,和投入量成正比,和客观质量无关。你花三小时做的东西,别人说不好,你无所谓;你花三个周末做的,别人说不好,你血压上来了。这个机制在演化上说得通——投入越大,被否定意味着损失越大,所以要誓死保卫。但它有个致命的副作用:它让你无法从自己投入最多的事情里学到东西。 你投入越少的事,你越能听进去反馈;你投入越多的事,你越听不进去。而这恰恰搞反了——投入多的事情,改进带来的收益才最大。这是很多人成长停滞的隐藏机制:不是他们不学习,而是他们在最该学习的那些事上,关闭了接收通道。

落到 Go 工程师身上:给你自己设一个物理动作,用来打断防御反应。收到让你不舒服的评审意见时,强制等十五分钟再回复,并且回复的第一句话必须是复述对方的观点:“你的意思是 X,对吗?“这个动作有两个作用。第一,十五分钟足够让你的肾上腺素降下来。第二,复述一遍,你经常会发现对方说的和你以为的完全是两回事——你以为他在说你菜,其实他在说你漏了一个边界。具体行动:在你的编辑器旁边贴一张便签,写”复述,再回应”。这个笨办法,比任何心理建设都管用。

题二:你在朋友圈发了一张照片,有个朋友评论”这张构图有点问题,人太靠边了”。你下次拍照,会注意构图吗?

参考答案

大多数人会这么答:不会。我又不是摄影师,随便拍拍,他事儿真多。

这个答案为什么不够:你把”专业”当成了门槛,觉得我不是干这个的,所以我不必在意质量。但你想想,你真的不在意吗?你发朋友圈之前,是不是从十张里挑了最好的一张?你挑的标准是什么?大概率是”好看”,但你说不清什么叫好看。所以你挑的其实是”不丑”——脸没糊、没闭眼、光线不太暗。这就是你的全部标准。而那个朋友的评论,其实是在给你一个具体的、可操作的标准:把人放在三分线上。这个标准你只要知道一次,以后每次都管用。你拒绝它,理由是”我又不是摄影师”,但你付出的代价是:你以后拍每一张照片,都还在用”不丑”这个标准。

更深的一层:这里藏着一个特别普遍的自我设限——用”我不专业”来豁免自己不用改进。这个豁免的代价是巨大的,因为它适用的范围远超你的想象。你不是厨师,所以菜咸一点没关系;你不是作家,所以文档写得乱一点没关系;你不是设计师,所以界面丑一点没关系。这些”没关系”累积起来,就是别人对你这个人的整体印象——“他做的东西,凑合能用”。而”凑合能用”的人,在职场上的定价,和”超出预期”的人,差的不止一倍。而且最讽刺的是:专业和业余的差别,往往不在天赋,而在于业余的人拒绝接受反馈。 那些”随便玩玩”然后玩成专业的人,无一例外,都是那些在别人给反馈时会追问”那应该怎么弄”的人。

落到 Go 工程师身上:你有没有对自己说过”我又不是搞底层的,能跑就行”?这句话听起来很谦虚,实际上是一个精心构造的免责声明——它让你在代码质量上不用对自己负责。而 Go 这门语言,恰恰是一门”下限很低、上限很高”的语言:你写得很丑,它也能跑;但那些真正优雅的 Go 代码,读起来像散文。具体行动:找一段你觉得”能跑就行”的代码,花一个小时,用你见过的所有好标准去改它——命名、错误处理、注释、测试。改完之后,把它和你一个月前写的代码放在一起看。你会立刻知道,你之前省下的那个小时,值不值。


1.6 你的 git 提交记录,就是你的职业履历

对话讨论

“我们再回到那个 coupon 目录。“老陈说,“你看过它的 git log 吗?”

“看过一点。”

“什么感觉?”

小林想了想:“没什么感觉。就是一堆’fix’、‘update’、‘改’。”

“具体点。”

“我给你念几条。“小林在终端里敲了命令,念出来,“‘update’、‘fix bug’、‘fix bug again’、‘真的修好了’、‘合并’、‘add’……”

他念到一半停住了。

“‘真的修好了’。“老陈重复了一遍。

”……是我写的。”

“那天是什么情况?”

小林记得。那天是 2021 年 9 月,一个 bug 改了三次都没改对,他第三次提的时候,在 commit message 里写了”真的修好了”。他当时觉得很幽默。

“你现在觉得幽默吗?”

“现在觉得……挺丢人的。”

“丢人什么?”

“丢人的是我那时候觉得这很幽默。“小林说,“我把公司的代码仓库当成了我的朋友圈。”

老陈点点头:“我给你看样东西。”

他把自己的笔记本转过来,屏幕上是一个 git log。

commit 7f3a9c2 2024-11-03 14:22
    feat(order): 支持部分退款,保留原订单金额快照

    背景:客服反馈,用户在退款时希望看到原订单金额,而不是当前金额,
    因为运营可能已经调整过价格。

    实现:在 refunds 表增加 original_amount 字段,退款时从订单快照读取,
    不从 orders 表实时查询。

    影响:需要执行 migration 20241103_add_original_amount.sql
    回滚:revert 本提交 + 执行 down 脚本,字段可空,老数据为 NULL 时前端兜底显示 0

commit 2b81e05 2024-11-03 11:07
    refactor(order): 把退款金额计算从 handler 抽到 domain 层

    纯重构,无行为变更。抽出来的原因是这个函数下周要被部分退款复用,
    现在它和 HTTP 层耦合在一起没法测。
    已补充单元测试,覆盖率从 41% 到 78%。

commit 9c4d1a7 2024-11-02 16:45
    fix(order): 修复并发退款导致的金额超退

    根因:判断可退金额和实际扣减是两步操作,中间没有加锁,
    压测 500 并发时出现超退。

    修复:把判断下沉到 UPDATE ... WHERE refundable >= ?,靠行锁保证原子性。

    验证:补充 TestRefundConcurrent,500 并发下不再超退(修复前平均超退 2.3 笔)

“这是谁写的?“小林问。

“我去年带的一个小孩,二十七岁。“老陈说,“这是他一个需求的三次提交。”

小林看着屏幕,没说话。

他想起自己昨天刚提的一个 PR,里面有十一个 commit,标题分别是:“wip”、“wip2”、“改”、“fix”、“继续改”、“test”、“ok”……

最后他在合并的时候,点了 “Squash and merge”,把十一个压成了一个,标题叫”优化退款逻辑”。

“你看出差别了吗?“老陈问。

“看出来了。“小林说,“他的每个提交,都能单独看懂。”

“再具体点。”

“他的提交……”小林琢磨着,“他写清楚了三件事:为什么要改、改成了什么、怎么验证。而且他把重构和功能改动分开了。”

“为什么要分开?”

“因为……重构不改变行为,功能改动改变行为。如果混在一起,出问题的时候没法定位。”

“对。“老陈说,“还有一个更实际的原因:重构的提交,评审的人只需要看结构;功能改动的提交,评审的人需要看逻辑。 你把两百行重构和二十行逻辑改混在一起提交,评审的人要花一小时。你分开,他先扫两眼重构那个,然后认真看二十行。”

小林想起自己给小周评审的时候,最怕的就是那种”我顺手重构了一下”的 PR。两百行改动,真正需要看的可能就十行,但你要在两百里找出那十行。

“我问你个事。“老陈说,“你昨天那个 PR,squash 之后叫’优化退款逻辑’。半年之后,有人想回滚你的改动,他怎么知道你改了什么?”

“他可以看 diff。”

“diff 有四百行。”

”……”

“我再问你,“老陈说,“半年之后你自己还记得吗?”

小林不说话了。

“我跟你说一个我亲身经历的事。“老陈说,“我三十九岁那年,接手了一个系统。那个系统的原作者离职了,什么文档都没有。我第一天上班,做的第一件事是看 git log。”

“看到了什么?”

“四千三百个提交。其中大概三千个,标题是’修改’或者’update’。”

“那您怎么办的?”

“我花了两周,把那个系统的主要逻辑读了一遍。“老陈说,“如果那个人的提交信息写得好,我可能两天就够了。”

“可是写提交信息也要时间啊。”

“你觉得那个原作者,写’update’省下来的时间,去哪了?”

小林答不上来。

“我告诉你去哪了。“老陈说,“他省下来的时间,变成了他自己的时间。然后他离职了,那笔账就转给了我。”

“这就是我说的——没做完的工作,成本不会消失,只会转移。”

“那……”小林有点犹豫,“可是有时候我一天要提十几个 commit,每个都写那么详细,太累了吧。”

“你觉得那个小孩一天提几个?”

“看他这个频率……一天两三个?”

“对。他一天就两三个提交。“老陈说,“因为他在写每个提交之前,都想清楚了这个提交要干什么。 你一天十几个,是因为你写的时候没想清楚,写一步提一步,靠 git 当撤销键。”

小林被戳中了。他确实经常用 git 当撤销键——写到一半,先 commit 一下,怕丢了。

“那我该怎么办?”

“两条。“老陈伸出手,“第一,提交之前,问自己一句话:如果这个提交单独被人看到,他能看懂吗? 看不懂就重写。第二,提交之前,看一遍 diff。”

“看 diff?”

“对。git diff --staged。你要亲眼看一遍你要提交的东西。”

“这也太……”

“你知道我看过多少次因为没看 diff 而提交进去的东西吗?“老陈说,“调试用的 fmt.Println、写死的测试账号、临时注释掉的一段代码、.env 文件、一个 200MB 的日志文件。”

“我提交过一次 .env。“小林小声说。

“里面有什么?”

”……数据库密码。”

“后来呢?”

“被大刘发现了,改了密码。”

老陈笑了:“大刘是个好人。“


生活化案例:你手机里的八千张照片

小林手机里存了八千多张照片。

从 2018 年到现在,全是 IMG_20230412_143822.jpg 这种名字。中间换过两次手机,每次都是一键迁移,连目录结构都没变过。

他老婆不一样。他老婆每年年末,会做一件他以前觉得很没必要的事:挑出那一年的照片,挑一百张左右,按时间排好,做成一本相册。每一张下面,她会手写一行字。

“2023.4.12,第一次带你去海边,你怕水,站在浅滩上不肯往前。” “2024.1.28,你爸第一次抱她,手抖得不行。” “2025.6.1,你在幼儿园演了一棵树,全程没动,特别敬业。”

小林以前觉得这事挺矫情的。照片不都在手机里吗,想看随时看。

去年他爸生病住院,他想找一张”他爸第一次抱他女儿”的照片,做成相框放病房里。

他翻了两个小时。

八千张照片,他知道大概的时间——2024 年春节前后。他从 2024 年 1 月开始翻。翻到第三天他放弃了,中间还翻出来一堆截图、外卖订单、快递单号、拍了没看的文档。

最后是他老婆从相册里抽出来给他的。十秒钟。

那张照片下面写着:“2024.1.28,你爸第一次抱她,手抖得不行。”

他爸现在还在,病也好了。那张相框放在他家电视柜上。

但小林后来想明白了一件事。

他的八千张照片,和他老婆的一百张照片,差别不在数量。

差别在于:他的是存档,他老婆的是叙事。

存档的意思是”东西都在,但我找不着,也想不起来”。叙事的意思是”我知道每一张为什么在这儿”。

而他付出的成本其实更高——八千张照片要占 40GB 存储,要备份,换手机要迁移,iCloud 每个月要交钱。他为了”以后想看随时看”,付了八年的钱,然后在真正想看的那一天,翻了两个小时没找到。

git 仓库是一模一样的东西。

你的每一次提交,就是一张照片。

update、fix、改、wip——这些是 IMG_20230412_143822.jpg。

fix(order): 修复并发退款导致的金额超退,下面写着根因、修复方式、验证方法——这是相册里那张照片底下的一行字。

而且 git 比相册更残酷。

相册至少还是按时间排的。你的提交历史,连时间线都是乱的。

因为你 rebase 过、merge 过、cherry-pick 过、squash 过。你以为你在整理,其实你在洗牌。

小林后来养成了一个习惯,他每次提交之前会想一句话:半年之后有个人(很可能就是我自己)在半夜两点要回滚我的改动,他看到这一行,能不能知道我在干嘛。

这个习惯花了他多少时间?每个提交多一分钟。

一天五个提交,五分钟。

一个月一百分钟。

而他一年里,为了搞清楚”这段代码为什么这么写”,花掉的时间,远远不止二十个小时。


Go 技术实例:提交历史、工作流,和几个必须配好的东西

先看一个真实的新手提交历史,这是小林当年的:

$ git log --oneline -15
a3f9c21 (HEAD -> feature/refund) ok
7b2e45a 改
d81c033 fix
5a9b7f2 真的修好了
c4e8a10 fix bug again
e2d6b93 fix bug
9f1a2c4 wip
b7c3d58 add

再看一段改好的:

$ git log --oneline -15
7f3a9c2 (HEAD -> feature/partial-refund) feat(order): 支持部分退款,保留原订单金额快照
2b81e05 refactor(order): 把退款金额计算从 handler 抽到 domain 层
9c4d1a7 fix(order): 修复并发退款导致的金额超退
c81f6b0 test(order): 补充退款金额计算的边界用例

提交信息的格式,业界最通用的是 Conventional Commits:

<type>(<scope>): <subject>

<body>

<footer>
  • type:feat(新功能)、fix(修 bug)、refactor(重构,无行为变更)、test(只动测试)、docs(只动文档)、chore(构建、依赖、工具)、perf(性能优化)
  • scope:影响的模块,比如 order、coupon、api
  • subject:一句话说清改了什么,不超过 50 个字符,祈使句,结尾不加句号
  • body:说清”为什么”,不说”做了什么”(做了什么在 diff 里)
  • footer:关联的需求单号、破坏性变更说明(比如 BREAKING CHANGE:)

为什么要这么麻烦?因为它是可以被机器解析的。配上一个工具,你能自动生成 CHANGELOG,自动决定版本号(fix 升 patch,feat 升 minor,BREAKING CHANGE 升 major)。

把”一大坨提交”拆成”几个干净的提交”,用的是交互式 rebase:

# 把最近 5 个提交拿出来重新编排
git rebase -i HEAD~5

编辑器里会显示:

pick 9f1a2c4 wip
pick e2d6b93 fix bug
pick c4e8a10 fix bug again
pick 5a9b7f2 真的修好了
pick a3f9c21 ok

把中间的 pick 改成 s(squash,合并到上一个)或者 r(reword,改写提交信息):

pick 9f1a2c4 wip
s e2d6b93 fix bug
s c4e8a10 fix bug again
s 5a9b7f2 真的修好了
s a3f9c21 ok

保存后,git 会让你写一个新的提交信息。这时候按格式重写:

fix(order): 修复并发退款导致的金额超退

根因:判断可退金额和实际扣减是两步操作,中间没有加锁,
压测 500 并发时出现超退。

修复:把判断下沉到 UPDATE ... WHERE refundable >= ?,靠行锁保证原子性。

验证:补充 TestRefundConcurrent,500 并发下不再超退(修复前平均超退 2.3 笔)

一条铁律:git rebase -i 只对你自己还没推送到公共分支的提交用。 已经推到远端、别人可能基于它工作的提交,绝对不要 rebase。那会改写历史,导致别人的本地仓库和远端对不上。这条规矩违反了,你会被整个团队记恨。

几个 Go 项目里必须配好的东西。

第一,.gitignore。Go 项目最基础的:

# 编译产物
/bin/
*.exe
*.dll
*.so
*.dylib
 
# 测试产生的覆盖率文件
*.out
coverage.txt
 
# 依赖目录(国内很多团队选择提交 vendor,二选一,别两个都提交)
/vendor/
 
# 本地环境变量,绝对不能提交
.env
.env.local
*.pem
*.key
 
# 编辑器
.idea/
.vscode/
 
# 系统垃圾文件
.DS_Store
Thumbs.db

注意最后那两条。.DS_Store 是 macOS 生成的,Windows 的 Thumbs.db 同理。它们被提交进仓库,是评审时最尴尬的东西之一。

第二,go.mod 和 go.sum 必须提交。这是铁律。有人会因为 merge 冲突把它删掉重新生成,那是错的——go.sum 是依赖的校验和,删掉等于放弃了供应链安全。有团队图省事用 GOFLAGS=-mod=mod 甚至 GONOSUMDB,那是把门拆了。

第三,配一个 Makefile,把常用命令固化下来。这东西的价值不是少打字,而是让团队所有人用同一套命令:

.PHONY: fmt vet lint test build ci
 
# 格式化:列出需要修改的文件,并直接改写
fmt:
	go fmt ./...
 
# 静态检查
vet:
	go vet ./...
 
# 测试,带竞态检测和数据竞争检查
test:
	go test -race -cover ./...
 
# 构建
build:
	go build -o bin/app ./cmd/app
 
# CI 里跑的完整检查,本地提交前也可以跑一遍
# 这一条能挡掉 80% 的低级错误
ci: fmt vet test build

然后每次提交之前:

make ci

第四,用一个 pre-commit hook,把检查自动化。放在 .git/hooks/pre-commit(或者用 pre-commit 框架管理,让团队共享):

#!/usr/bin/env bash
set -e
 
# 1. 检查有没有未格式化的文件
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
    echo "以下文件没有 gofmt,请先运行 gofmt -w ."
    echo "$unformatted"
    exit 1
fi
 
# 2. 静态检查
go vet ./... || exit 1
 
# 3. 检查是否误提交了大文件和敏感文件
# 这一条救过很多人
if git diff --cached --name-only | grep -E '\.env$|.*\.pem$|.*\.key$'; then
    echo "检测到敏感文件,请从暂存区移除:git reset HEAD <file>"
    exit 1
fi
 
# 4. 检查提交信息里有没有调试残留
if git diff --cached | grep -nE '^\+.*fmt\.Println'; then
    echo "检测到新增的 fmt.Println,确认是要提交的吗?"
    echo "如果确认,用 git commit --no-verify 跳过"
    exit 1
fi

这个脚本,四段加起来不到四十行。它能挡掉的东西:格式问题、vet 能抓的 bug、误提交的密钥、调试用的 Println。

小林当年提交进去的那个数据库密码,如果有第三条,就不会发生。


老陈的深层发问

  1. 打开你最近一个项目的 git log,往前翻一百条。你能从这些提交信息里,还原出这半年团队都做了什么吗?

  2. 如果你现在要离职交接,你让你的 git 提交记录来代替你做交接,接手的人能看懂多少?

  3. 你有多少次提交,是”写完一步,先提交了再说”?你在用 git 做什么——版本管理,还是撤销键?

  4. 你上一次通过看别人的提交历史,学到某个东西,是什么时候?如果从来没有,是因为别人的提交写得不好,还是你从来不看?

  5. 如果把你的提交历史打印出来,贴在你家的墙上,你愿意让你爸妈、你孩子、你未来的老板看到吗?


生活思考题

题一:你搬了三次家,每次都有一堆纸箱没拆,直接塞进储物间。第四次搬家的时候,你是什么感觉?

参考答案

大多数人会这么答:烦。不知道里面是什么,扔了怕有用,搬了又费劲。最后全搬了,然后继续塞进新家的储物间。

这个答案为什么不够:你以为你在做”保留”这个动作,其实你在做”拖延决策”。那箱东西里有什么?你不知道。为什么不知道?因为三次搬家前你都有机会打开看一眼、决定扔还是留,你每次都选了”下次再说”。而”下次”永远不会来,因为你每次面对它的时候,都处于搬家最忙乱的时刻。所以这个箱子的真实状态是:你为它付了三次搬运费、三次仓储空间,但你对它的决策质量,和第一次见到它时一模一样——零。 这就是拖延的真实成本:它不是”免费地推迟”,它每次重复都在收费,而且你什么都没换来。

更深的一层:未整理的东西,会随着时间从资产变成负债。刚搬完的时候,那箱东西是”我的物品”;三次搬家之后,它变成了”我必须处理但不知道怎么处理的问题”。这个转变是悄悄发生的,中间没有一刻你能明确指出”就是这一天它变成负债的”。代码里的 TODO、临时方案、没拆的模块,全是这个模式。它们在写下的那一刻是合理的,然后每过一天,理解它们的成本就高一点,直到有一天,没人敢动了——不是因为它复杂,是因为没人记得它为什么在那儿。而这东西最坑的地方在于:处理它的最佳时机永远是”当时”,而”当时”你永远觉得没时间。

落到 Go 工程师身上:打开你的项目,搜一下 TODO、FIXME、HACK、XXX。数一下有多少个。然后按时间排序,看看最老的那个是什么时候写的。我猜你会发现有几个超过两年,而且写它的人已经离职了。给你一个可执行的规则:每个 sprint 的最后半天,不许写新代码,专门用来处理技术债务。 不是”有空再说”,是排进日程的硬性时间。如果你们团队没有这个习惯,你自己先开始——你自己的 TODO 你先清。具体行动:这周找五个最老的 TODO,处理掉三个,删掉两个(如果两年都没人做,说明它不重要,直接删)。你会惊讶于删掉之后的轻松感。

题二:你的微信聊天记录,从十年前到现在的都在。你想找一条三年前朋友推荐你的一本书的名字,你找得到吗?

参考答案

大多数人会这么答:找不到。微信那搜索太烂了。而且三年前的事,我记得他说过,但就不记得在哪儿了。

这个答案为什么不够:你把责任推给了工具。但你想想,你当时为什么不顺手记一下?他推荐的时候,你心里想的是”回头看看”,然后就没有回头。这件事的本质不是搜索功能差,是你在接收信息的那一刻,没有给它一个”未来的家”。你接收了,但没归档,所以它沉进了信息的海洋。而这里有个特别残酷的统计:你接收的信息,如果没有在 24 小时内被处理(记下来、用起来、或者明确丢弃),它基本就永久丢失了。 不是存在与否的问题,是可达性的问题。它在你的硬盘上,但和不存在没有区别。

更深的一层:很多人把”收藏”当成了”记住”。看到好文章,点收藏;看到好方案,截图存相册;听到好建议,心里说”记住了”。这些动作给你一种虚假的安全感——你的大脑在收藏的那一刻分泌了一点多巴胺,让你觉得这件事已经处理了。但实际上你只是把信息从一个地方挪到了另一个地方,中间没有经过任何加工。而加工才是关键:一条信息只有被你用自己的话复述过、和你已有的知识连起来过,它才会变成你的。 收藏夹越满的人,往往学到的越少,因为他把”收藏”这个低成本动作,替代了”消化”这个高成本动作。

落到 Go 工程师身上:你有没有一个自己的知识库?不是收藏夹,是你自己写的东西。你调过一次 GC 参数、你排查过一次内存泄漏、你踩过一个 sync.Map 的坑——你写下来了吗?给你一个最小可行的方案:在你的项目仓库里建一个 docs/notes/ 目录,每解决一个值得记的问题,写一个 md 文件,文件名带日期和关键词,正文只写三段:现象、根因、结论。不用长,三百字就够。一年之后这个目录会有五十个文件,那时候它就是你的护城河——因为当组里有人遇到类似问题时,你是那个”好像见过”的人。而”好像见过”和”我记下来了”之间,差的就是那三百字。


1.7 第一份工作真正该学的东西(不是技术)

对话讨论

“聊了六节了,我们往回拉一下。“老陈说,“我问你个大的:你第一份工作那两年,你学到的最有价值的东西是什么?”

小林想了很久。

“Go 吧。“他说,“我在那儿从零学的 Go。我入职的时候只会写点 Python 脚本。”

“Go 是你在那儿学的吗?”

“是啊。”

“你是怎么学的?”

“自己看教程,然后照着写。“小林说,“第一周我抄了一遍《Go 入门指南》,把里面的例子全跑了一遍。第二个月我开始写业务代码。”

“所以 Go 是你自己学的,公司只是给了你一个用的地方。”

”……对。”

老陈点点头:“那我再问:你在那儿,有没有哪个瞬间,是你觉得’哦,原来工作是这样’的?”

小林想了很久。

“有一个。“他说,“我入职第四个月,做第一个真正的需求。是给商家后台加一个导出功能,导出券的核销记录。”

“然后呢?”

“我做了三天,做完了,提测。阿 May 验收的时候说,导出的 Excel 打开是乱码。”

“什么乱码?”

“中文乱码。因为 Excel 默认用 GBK 打开 CSV,我写的是 UTF-8。“小林说,“我当时想的解决办法是,在代码里转码成 GBK。”

“后来呢?”

“阿 May 说不用,她说商家都用 WPS,WPS 能识别 UTF-8。“小林说,“我当时特别不服气,我觉得我在技术上是对的——GBK 确实兼容性更好。”

“然后你怎么做的?”

“我按她说的没改。“小林说,“我当时心里想的是’反正出问题了别找我’。”

“后来出问题了吗?”

“没有。”

“所以她是对的。”

“她是对的。“小林说,“但当时我觉得她是在外行指导内行。”

老陈笑了:“这个感觉,你现在还有吗?”

”……偶尔还有。”

“我跟你说个我的事。“老陈说,“我二十六岁那年,第一份工作,做财务软件。有一次财务那边提了个需求,说是报表要能按’季度’汇总。我说这个简单,就是三个月一组。”

“不对吗?”

“财务说不对。她们公司的财年是从 4 月开始的,所以她们说的’第一季度’是 4、5、6 月。”

“哦……”

“我当时第一反应是:你们为什么不按自然季度?“老陈说,“我甚至在会上说了这句话。”

“然后呢?”

“然后我们组长把我拉到一边,说了一句话,我这辈子都记得。”

“什么话?”

“他说:‘我们不是来教他们怎么做财务的。’”

小林愣了一下。

“我第一份工作,前半年我以为我学的是技术。“老陈说,“后半年我才发现,我学的全是别的东西。”

“哪些东西?”

“第一件,“老陈伸出手指,“学会’我做的东西是给谁用的’。 我写报表,用的人是财务,不是我。财务脑子里的’季度’和我脑子里的,是两个东西。我按我的理解做出来,对她来说就是错的——不管技术上多正确。”

“第二件?”

“学会在信息不全的情况下做决定。 学校里的题,条件是给全的。工作里没有。你永远在缺信息的情况下做事。你要学会判断:这个信息我必须等到,那个信息我可以先假设。”

“第三件?”

“学会’够好’。“老陈说,“我当时花了两周做一个查询优化,把响应时间从 3 秒优化到 0.8 秒。我特别得意。我们组长问我:‘有人抱怨过 3 秒吗?’”

”……没有?”

“没有。因为那是个月报,一个月跑一次。“老陈说,“我花两周,优化了一个一个月跑一次的报表,从 3 秒到 0.8 秒。你觉得这值吗?”

小林笑了:“不值。”

“但那时候我觉得特别值。“老陈说,“因为那是我唯一能证明自己’技术好’的方式。”

小林听到这句话,沉默了。

他想起自己去年花了一周,把他们内部的一个小工具从 gin 换成了Go 1.22 的原生路由。没人要求,没人受益,他就是想试试。

“我也有过。“他说。

“我知道。“老陈说,“每个人都干过。”

“那第一份工作到底该学什么?“小林问,“您说了三件,还有吗?”

“还有最后一件,也是最重要的一件。“老陈说,“学会被人信任。”

“信任?”

“你刚入职,没人信任你。你的 leader 给你的活,都是最小的、最安全的。为什么?不是因为歧视新人,是因为他不知道你会不会把事情搞砸。”

“然后呢?”

“然后你做了一件小事,做完得很好,准时,没出岔子。他会再给你一件稍微大一点的。“老陈说,“这个循环转起来,半年之后他给你的活就不一样了。”

“如果没转起来呢?”

“那你做两年,做的事和第一天一样大。“老陈说,“我见过太多这样的。三年了,还在做’给这个接口加个字段’。他以为公司不给他机会,其实是他从来没让人放心过。”

小林想起自己第一份工作那两年。他确实感觉到,第二年下半年开始,leader 给他的活明显不一样了——从”改个 bug”变成”这个模块你来”。

他当时以为是因为自己技术变好了。

现在他不确定了。

“我说个更扎心的。“老陈说,“你有没有想过,为什么有些人技术一般,但升得快?”

“因为他们会来事儿。”

“我换个说法你听听。“老陈说,“因为他们让 leader 觉得’交给他的事,我不用再想一遍’。”

小林不说话了。

“这七件事——哦不,四件事,“老陈纠正自己,“用的人是谁、信息不全下做决定、够好就行、让人信任。这四件事,没有一件写在你的技术教程里。”

“但是……”小林有点不甘心,“这些也不是能教的啊。”

“是能教的。“老陈说,“只是没人教。”

“为什么没人教?”

“因为教的人自己也不知道自己在教什么。“老陈说,“你当年的 leader,他给你派活的时候,他脑子里在算:这个活给小林,会不会出问题?他会算这个,但他不知道他在算这个。所以他也不会告诉你:‘我这次给你一个稍大一点的活,是因为上次那个你做得干净。’”

“他从没跟我说过。”

“对。所以你以为你的成长是随机的。“老陈说,“你以为你是运气好,其实是你每次都做对了某些事情,只是没人给你点破,所以你也不知道自己是怎么做对的。”

小林想起规范前面说的那句”从错到对”。

“所以我回看五年前的自己,“他慢慢地说,“不是要我去学那些技术。”

“对。”

“是要我看清楚,哪些东西我当时是蒙对的,哪些是蒙错的。”

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


生活化案例:驾校教的和上路要的,是两回事

小林 2017 年考的驾照。

他练了三十多个小时。倒库练了十二小时,侧方停车八小时,半坡起步六小时。教练在副驾,喊得最多的一句话是:“看那个点!后视镜下沿盖住那条线,向右打死!”

他考科目二考了两次。第一次挂在半坡起步,熄火了。

拿到驾照那天他特别高兴。

然后他买了一辆车,上路了。

第一周他就发现,驾校教的东西,百分之八十用不上。

他会倒库,但他小区的停车位有倒车影像和雷达,他从来没用过”后视镜下沿盖住线”那个技巧。

他会半坡起步,但他住的地方是平原,城区最高的坡是立交桥的引桥。

而真正要命的东西,驾校一样都没教。

比如:怎么判断前面那辆出租车会不会突然靠边停。答案是看他有没有开始减速、有没有打转向灯、副驾有没有人。

比如:怎么判断路口那个骑电动车的大爷会不会闯红灯。答案是别看他,看他的车头——车头开始偏了,就是他要动了。

比如:在高速上,怎么判断右侧那辆大货车是不是要变道。答案是看他的前轮。

比如:下暴雨的时候,怎么判断前面那段积水的深浅。答案是看旁边车的轮子淹到哪儿。

这些东西,没有一条可以考试。因为它们没有标准答案,全是概率和模式。

而驾校为什么教倒库?因为倒库可以考试。它有明确的点位、明确的标准、明确的通过线。

所以驾校教的是可考核的部分,而不是重要的部分。

这两者之间,有巨大的重叠,但也有巨大的空隙。

小林上路的第一个月,剐蹭了两次。都不是因为倒库没倒好,都是因为判断错了别人的意图。

而这两次剐蹭,驾校一点责任都没有——它教的东西他都学会了,而且考过了。

第一份工作,就是驾校。

学校和教程教你写 Go,教你 goroutine、channel、interface、GC 原理。为什么教这些?因为它们可考核。它有明确的对错,能出成面试题,能写成教程。

而工作里真正重要的那些东西——怎么判断这个需求背后真正的意图、怎么在需求说不清楚的时候推进、怎么知道这件事该做到什么程度、怎么让人放心把事情交给你——这些全是”上路之后”的东西。

没有人能给你出一道题,考你会不会判断那个骑电动车的大爷。

但你的职业生涯里,每天都有几十个”大爷”要判断。

小林后来开车开了九年,零事故(剐蹭那两次是头一个月)。他现在开车几乎不思考,该让就让,该超就超。

有人问他”你开车怎么这么稳”,他答不上来。

他答不上来,因为他脑子里的那些规则——看车头、看前轮、看别人的副驾——早就压缩成了本能。

就像他妈的”盐少许”。

这也是为什么这套东西没法教,只能靠你自己去撞几次。

但”撞”和”撞明白”之间,差一个动作:事后复盘。

小林剐蹭那两次之后,他花了时间想:我当时看到了什么?我以为他要干什么?他实际干了什么?我漏掉了什么信号?

第二次剐蹭之后,他养成了那个”看车头”的习惯。

如果他不复盘,他剐蹭二十次也一样。

工作里也是。

你第一份工作踩的每一个坑,都是一个”大爷”。你可以剐蹭过去,然后继续剐蹭;也可以花十分钟想清楚,然后这条规则跟你一辈子。


Go 技术实例:从”搜答案”到”看源码”

这一节的技术实例,不是某个具体的 API,而是一个更根本的动作:当你在 Go 里遇到一个说不清的问题时,去看标准库的源码。

这是第一份工作最值得养成、但最少人养成的习惯,因为没人教。

来看一个真实的场景。小林在做优惠券核销的时候,需要把 HTTP 响应体读出来,先记录日志,再解析。

【错】 错误写法:

package main
 
import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strings"
)
 
type Result struct {
	Code int    `json:"code"`
	Msg  string `json:"msg"`
}
 
func handle(resp *http.Response) error {
	// 第一次读:为了打日志
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return err
	}
	fmt.Println("raw response:", string(body))
 
	// 第二次读:为了解析
	var r Result
	if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
		return err
	}
	fmt.Printf("result: %+v\n", r)
	return nil
}
 
func main() {
	resp := &http.Response{
		Body: io.NopCloser(strings.NewReader(`{"code":0,"msg":"ok"}`)),
	}
	if err := handle(resp); err != nil {
		fmt.Println("err:", err)
	}
}

这段代码跑起来,r 是零值,Decode 没有报错,但什么都没解析出来。

小林当年的做法是:去搜”golang http body 读两次”,看三篇博客,抄一个”用 ioutil.ReadAll 读出来然后 json.Unmarshal(bytes)“的答案,跑通了,过去了。

他不知道为什么。下次遇到 io.Reader 的其他场景,还会再踩一次。

正确的做法,是打开标准库看一眼。

go doc io.Reader

输出:

package io // import "io"

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

    Reader is the interface that wraps the basic Read method.

    Read reads up to len(p) bytes into p. It returns the number of bytes read
    (0 <= n <= len(p)) and any error encountered. Even if Read returns n <
    len(p), it may use all of p as scratch space during the call. ...
    When Read encounters an error or end-of-file condition after successfully
    reading n > 0 bytes, it returns the number of bytes read. It may return the
    (non-nil) error from the same call or return the error (and n == 0) from a
    subsequent call. An instance of this general case is that a Reader
    returning a non-zero number of bytes at the end of the input stream may
    return either err == EOF or err == nil. The next Read should return 0, EOF.

关键在最后一句:The next Read should return 0, EOF.

io.Reader 是一个流,不是一块数据。它有一个内部的位置指针,读过的位置就过去了。第一次 ReadAll 把它读到了末尾,第二次再读,返回 0, EOF。

这就是为什么第二次解析出来是空的——json.NewDecoder 读的是同一个流,而它已经到底了。

json.Decoder.Decode 在读到 EOF 且缓冲区为空时,返回 io.EOF——但上面的代码里,错误被 json.NewDecoder(...).Decode(&r) 吃掉了?不,它返回了 io.EOF,代码返回了 err。实际上这段代码会打印 err: EOF。

(这个细节值得说一句:如果你当时没检查这个 err,你就会得到一个静默的空对象,然后花两天时间查为什么解析不出来。 这就是为什么”必须检查每一个 error”不是洁癖,是生存技能。)

【对】 正确写法:读一次,复用数据。

package main
 
import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strings"
)
 
type Result struct {
	Code int    `json:"code"`
	Msg  string `json:"msg"`
}
 
func handle(resp *http.Response) error {
	// 只读一次,存到内存里
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return fmt.Errorf("read body: %w", err)
	}
 
	// 打日志用的是这份内存副本
	fmt.Println("raw response:", string(body))
 
	// 解析用的也是它
	var r Result
	if err := json.Unmarshal(body, &r); err != nil {
		return fmt.Errorf("decode body: %w", err)
	}
	fmt.Printf("result: %+v\n", r)
	return nil
}
 
// 如果确实需要"一边转发一边记录",用 TeeReader:
// 它把读到的数据同时写进另一个 Writer,不消耗流本身。
func handleWithTee(resp *http.Response) error {
	var logged bytes.Buffer
 
	// TeeReader 返回的还是 io.Reader,但它会把读过的数据顺手写进 logged
	tee := io.TeeReader(resp.Body, &logged)
 
	var r Result
	if err := json.NewDecoder(tee).Decode(&r); err != nil {
		return fmt.Errorf("decode body: %w", err)
	}
 
	fmt.Println("raw response:", logged.String())
	fmt.Printf("result: %+v\n", r)
	return nil
}
 
func main() {
	raw := `{"code":0,"msg":"ok"}`
 
	resp1 := &http.Response{Body: io.NopCloser(strings.NewReader(raw))}
	if err := handle(resp1); err != nil {
		fmt.Println("err:", err)
	}
 
	resp2 := &http.Response{Body: io.NopCloser(strings.NewReader(raw))}
	if err := handleWithTee(resp2); err != nil {
		fmt.Println("err:", err)
	}
}

这个例子本身不复杂。真正有价值的是这三行命令:

go doc io.Reader              # 看接口定义和文档
go doc io.TeeReader           # 看这个函数的签名和说明
go doc -src io.TeeReader      # 直接看它的源码实现

第三行你会看到:

func TeeReader(r Reader, w Writer) Reader {
	return &teeReader{r, w}
}
 
type teeReader struct {
	r Reader
	w Writer
}
 
func (t *teeReader) Read(p []byte) (n int, err error) {
	n, err = t.r.Read(p)
	if n > 0 {
		if n, err := t.w.Write(p[:n]); err != nil {
			return n, err
		}
	}
	return
}

八行代码。

你读完这八行,你对 TeeReader 的理解,和那个抄博客的人,是两个层次。而且你会顺带明白一件事:它不缓冲,它是边读边写。 所以如果 w.Write 慢,整个读就慢。

这就是”看源码”和”搜答案”的区别。

搜答案给你一条鱼,看源码给你一张网。而且 Go 的标准库源码,是全世界可读性最高的工业级代码之一——它有完整的注释、清晰的命名、几乎没有历史包袱。你花半小时读 net/http,比刷十篇”Go 面试八股文”有用得多。

第一份工作,最该养成的技术习惯,就是这个:先 go doc,再搜,最后才问人。

这个习惯的价值不在当下。它的价值在三年之后——那时候你遇到一个没人写过博客的问题,而你已经有了自己找答案的能力。

而那些只会搜的人,会在那个时刻卡住。


老陈的深层发问

  1. 你第一份工作里,有没有一件事是你当时觉得”这不公平”,现在回头看觉得对方是对的?如果一件都没有,说明什么?

  2. 你现在做的这件事,用的人是谁?你上一次跟他聊过是什么时候?你知道他最头疼的是什么吗?

  3. 你上一次做到”够好就停”,而不是”做到我满意”是什么时候?你怎么判断的?

  4. 你的 leader 现在把活交给你的时候,他会不会再说一遍”这个要注意 X”?如果会说,说明什么?

  5. 如果你现在回到第一份工作,带着现在的脑子,你会做的第一件不一样的事是什么?


生活思考题

题一:你第一次一个人去一个陌生城市出差,下了飞机,你会先做什么?

参考答案

大多数人会这么答:打车去酒店,或者看导航找地铁。

这个答案为什么不够:这个答案只解决了”到达”这个动作,没解决”这次出差”这件事。真正有经验的人,下飞机之后做的第一件事是搞清楚三样东西:第一,明天要去的那个地方,从酒店过去要多久,早高峰是什么时候;第二,这个城市里谁是我这次要打交道的人,他们的作息是什么,我什么时候能找到他们;第三,如果这次谈崩了,我的备选方案是什么,我需要多留一天还是当天就能走。这三件事定下来,你今晚才能睡踏实。而这三件事,没有一件是”导航”能解决的。你看,新手和高手的差别不在于会不会用工具,在于他脑子里装的是”下一个动作”还是”这次出差的完整目标”。

更深的一层:新手永远在优化”到达”,高手永远在优化”不确定性”。因为新手对整件事的理解是线性的:出发、到达、办事、回来。而高手知道,这条线上真正决定成败的,是几个不确定性最大的节点:对方会不会临时变卦、明天会不会下暴雨、那个技术方案是不是真的可行。所以他会在出发前就把这些节点的风险降低——多打一个电话确认、多留半天缓冲、多准备一套说辞。这就是为什么同样一件事,新手做出来总是”差不多”,高手做出来总是”稳”。不是能力差,是他在你还没开始动的时候,就已经把可能出问题的地方想了一遍。

落到 Go 工程师身上:你接到一个需求的时候,脑子里有几个节点?大部分人的节点是:写代码、提测、上线。三个。而完整的节点是:需求背后的目标是什么、这个目标有没有别的更简单的方式达成、我这个方案依赖哪些外部系统、外部系统挂了怎么办、上线之后怎么验证它真的生效了、出问题了怎么快速回滚、如果效果不好我怎么知道。七个。具体行动:下次接到需求,在动手之前,把后面这四个节点写进你的 PR 描述的第一段,发给你的 leader 看一眼。他会惊讶的——因为绝大多数人从来不写这些,而这几句话,恰恰是 P5 和 P4 的分界线。

题二:你学了三个月吉他,能弹《小星星》,也能弹几首简单流行歌的分解和弦。你朋友聚会,有人把吉他递给你,让你来一首。你敢接吗?

参考答案

大多数人会这么答:不敢。我就会那么几首,而且弹得磕磕绊绊,丢人。

这个答案为什么不够:你以为别人期待的是”好听”。但你回想一下,你在聚会上听到别人弹吉他,你期待的是什么?不是他弹得跟 CD 一样——如果他弹得跟 CD 一样,你反而会觉得距离感。你期待的是”有人愿意来这一下”,是那个氛围。你拒绝的时候,你把标准定在了”专业水平”,而实际的标准是”参与”。这个错位的后果是什么?是你永远不弹,所以你永远停在”会几首”的水平。而那些弹得很烂但敢接的人,一年之后已经能在聚会上带全场合唱了。你不是因为会才做,你是因为做才会。 这句话被说滥了,但它的机制很少有人讲清楚:能力的增长,来自”做”之后的反馈回路,而这个回路的启动键,是一次不完美的表现。

更深的一层:拒绝一次表现机会的真实成本,不是”错过了一次表演”,是错过了一次接收反馈的机会。你在自己家里练十遍,你不知道哪里有问题——你听不出来。你在人前弹一遍,哪怕弹砸了,你会得到三个非常具体的信息:哪个和弦卡了、哪里节奏不稳、哪句大家跟着唱了。这三个信息,你练一百遍都拿不到。所有”等我准备好了再上”的人,都陷在同一个死循环里:因为不上,所以拿不到反馈;因为没反馈,所以永远准备不好。而打破这个循环的唯一方式,就是在没准备好的时候上一次。

落到 Go 工程师身上:你有没有一个想做但一直没开始的项目?一个你觉得”等我学会了 X 再做”的东西?那个东西,就是你手里的吉他。给你一个具体的破局方法:把”我要做一个完整的 X”改成”我要做一个能跑起来但很丑的 X”。 不是”我要写一个配置中心”,是”我要写一个能读取本地 yaml 并热加载的五十行程序”。不是”我要学懂分布式一致性”,是”我要用三个 goroutine 模拟一次网络分区,看看会出什么问题”。具体行动:这周末花四个小时,做一个你一直想做但觉得”还没准备好”的小东西,做完之后不管多丑,发给你信任的一个人看。他那句”这里有问题”,比你看十篇教程值钱。


1.8 从「学得会」到「做得出来」的那道坎

对话讨论

“你那个作业做完了吗?“老陈问,“‘做完’的定义。”

小林从包里掏出一张纸,摊开。

老陈接过去看。上面是手写的,字迹挺潦草:

做完 = 交付
1. 代码写完,能跑
2. 边界情况都考虑过了(时区、并发、空值、超长、超量、重复)
3. 有测试,能覆盖主要分支和边界
4. 错误有明确的原因,不是笼统的"失败"
5. 有日志,出问题能查
6. 没有硬编码的配置
7. 没有遗留的调试代码
8. 提交信息写清楚了为什么
9. PR 描述写清楚了背景、方案、验证、回滚
10. 评审意见都处理了,没处理的说明了原因
11. 告诉了需要用的人(QA、运维、产品)怎么用
12. 上线之后确认过它是真的在工作

老陈看了很久。

“这是你写的?”

“写了三遍。“小林说,“第一遍我写了六条,都是技术相关的。第二遍我加了 4 到 8。第三遍才想到 9 到 12。”

“为什么 9 到 12 最后才想到?”

“因为……”小林想了想,“因为前八条发生在’我的电脑上’,后四条发生在’别人的世界里’。”

老陈把纸放下,看着他。

“这句话,你自己想出来的?”

“刚想出来的。”

“你记住这句话。“老陈说,“P4 和 P5 之间,就是从’我的电脑上’走到’别人的世界里’。”

小林看着那张纸,忽然有个感觉:这十二条,没有一条是他不会的。

他全会。

他甚至可以给别人讲这十二条。

但他从来没有一次,十二条全做到过。

“我有个问题。“他说,“这十二条我都会,我也知道它们对。但我为什么做不到?”

“你觉得呢?”

“因为……麻烦?”

“具体点。”

“因为赶时间。“小林说,“每次都是’先上线,后面再补’。”

“然后补了吗?”

”……没有。”

“为什么没有?”

“因为上线之后就有新需求了。”

老陈点点头:“我问你个事。你上个月有没有哪一次,是完整地做到了这十二条的?”

小林仔细想了想。

“有一次。“他说,“上上个月,那个对账的功能。因为那个东西涉及钱,我不敢马虎。”

“那次花了多久?”

“正常一周的活,我做了十天。”

“出问题了吗?”

“没有。上线到现在一次问题都没有。“小林说,“而且前天运营还问我要了个新需求,我两天就做完了,因为代码结构清楚。”

“你看。“老陈说,“你以为你多花了三天。其实你省了。”

小林愣住了。

“那个对账功能,如果按你平时的做法,一周上线,然后会怎么样?”

“会……提测一堆 bug,改几天,上线之后可能还有问题。”

“加起来几天?”

“十天到两周吧。”

“所以你没有多花三天。“老陈说,“你是把’后面必然要花的那些时间’,提前花掉了。而提前花和后面花,效果完全不一样——提前花,你是在设计阶段就把问题消掉了;后面花,你是在救火。”

“救火的体验还特别差。“小林补充了一句。

“对,而且救火的时候你会焦虑,焦虑的时候会犯更多的错。”

小林想起那次半夜被大刘叫起来。

“那我为什么别的时候做不到?“他问,“我知道这个道理啊。”

“你不是做不到。“老陈说,“你是不相信。”

“不相信什么?”

“不相信’提前做’真的比’后面补’划算。”

小林想反驳,又咽回去了。

因为老陈说对了。他每次选择”先上线”的时候,心里都有一个声音:“这次应该不会出问题。”

“我给你讲最后一件我自己的事。“老陈说,“我四十五岁那年,做一个项目。我当时已经是 CTO 了,按理说不用写代码,但那个项目的核心模块我自己写了。”

“为什么自己写?”

“因为赶。因为我觉得我自己写最快。“老陈说,“我写了两周,写完了,测试简单跑了一下,就上了。”

“然后呢?”

“上线第三天,出了一次事故。数据错乱,涉及三万多条记录。我们花了四天修复,其中两天是在写脚本清洗数据。”

“您当时什么感觉?”

“我当时第一反应是:运气不好。“老陈说,“我觉得是因为那个数据太特殊了,谁也想不到。”

“后来呢?”

“后来我去做复盘,我让我们团队的一个小孩,把这次事故和我之前十次’我亲自写的、没写测试就上线的’项目,做了个对比。”

“结果呢?”

“十一次里,出了四次事故。“老陈说,“而团队里那些按流程写的、有测试的项目,十一个里出了零次。”

”……”

“我看着那个数字,坐了很久。“老陈说,“我以前一直觉得,我水平高,所以我可以不按流程来。那个数字告诉我:流程不是给水平低的人准备的,流程是给’会误判自己水平’的人准备的。”

小林沉默了。

“我讲这些不是要让你内疚。“老陈说,“我是要让你看清楚一件事——从’学得会’到’做得出来’,中间隔的不是知识,是习惯。”

“知识我够了吗?”

“你够了。你五年前就够了一半,现在全够了。“老陈说,“你缺的是:在没有人看着你的时候,在赶时间的时候,在心里有个声音说’这次算了’的时候,你还能不能按那十二条做。”

“这叫……”

“这叫纪律。”

“可是纪律听起来好累。”

“累吗?“老陈笑了,“你刷牙累吗?”

“不累。”

“为什么?”

“因为习惯了。”

“对。“老陈说,“纪律的最高境界,是不需要纪律。 你把它变成习惯,它就不消耗意志力了。”

“那怎么变成习惯?”

“一次只改一条。”

“就一条?”

“就一条。“老陈说,“你那十二条,你挑一条,最容易做到的,坚持一个月。做到了,再加一条。”

“挑哪一条?”

“你先想想。”

小林看着那张纸,一条一条往下看。

“我挑第七条。“他说,“没有遗留的调试代码。”

“为什么是这条?”

“因为……这条最简单。每次提交之前看一遍 diff 就行。”

“那就它。“老陈说,“一个月。”

“一个月之后呢?”

“一个月之后,你带着结果来。我们聊下一章。”

“下一章是什么?”

“P5。“老陈说,“让一块地归我负责。”

小林把那张纸折起来,放进包里。

他抬头看了看窗外。天快黑了,茶室里只剩下他们两个人。

“老陈。“他忽然说。

“嗯?”

“我这五年,是不是白过了?”

老陈想了想。

“没有。“他说,“但是你欠了债。”

“什么债?”

“这十二条,你五年前就该会的。“老陈说,“你没会,所以你花了五年,用别的方式补上了——靠踩坑、靠加班、靠半夜被叫起来。这些就是利息。”

“那本金呢?”

“本金还在。“老陈站起来,开始收拾茶具,“你现在补,还来得及。再过五年,本金就要开始滚了。“


生活化案例:会做三道菜,不等于能做出一桌年夜饭

小林是个还不错的家常菜厨师。

他会做糖醋排骨,那个汁的比例他调了十几次,现在闭着眼都能调对。他会做清蒸鱼,前面说过,练过七八次。他会做蒜蓉粉丝虾,这是他的拿手菜,朋友来家里必点。

去年除夕,他爸妈、他丈人丈母娘、他小舅子一家,一共九个人来他家过年。

他老婆说,今年你来做吧。

他说,行。

他提前一周列了菜单:八个菜,一个汤。八个菜里有三个是他拿手的,五个是他”会做但没怎么做过”的。

除夕那天下午三点,他进厨房。

五点半,他老婆进来问他”还有多久”,他说”快了”。

六点半,他丈母娘进来问要不要帮忙,他说”不用不用”。

七点,第一道菜上桌。

七点半,上了三道菜。

八点,全部上齐。

结果:糖醋排骨是凉的,因为他四点半就做好了,放在那儿等别的菜。清蒸鱼蒸老了,因为他为了腾出炒锅,提前十分钟就上锅了。炒青菜出水了,因为锅不够热他就下了菜——他急着腾灶眼。汤忘了。

他老婆后来跟他说:“你今天做的菜,每道都不如平时。”

他当时特别委屈:我忙了五个小时。

后来他复盘,发现问题根本不在”每一道菜怎么做”上。

每一道菜他都会。他的问题是:他不会”同时做八道菜”。

这是两种完全不同的能力。

做一道菜需要的能力:刀工、火候、调味、时间。

做一桌菜需要的能力:排程、资源调度、并行、降级、兜底。

具体说:

  • 排程:哪道菜先做、哪道后做。凉菜先做,热菜后做;能复热的先做,不能复热的最后做。
  • 资源调度:家里只有两个灶眼,一个炒锅一个汤锅。八道菜里五道要炒,你怎么办?答案是:汤用汤锅,两道菜用烤箱,一道菜改成凉菜。
  • 并行:等水开的那三分钟,你能切完下一道菜的配料吗?
  • 降级:时间不够了,哪道菜可以砍掉?哪道菜可以改成半成品?
  • 兜底:万一某个菜失败了,你有没有一个五分钟能端上来的备选?

这五样东西,在他学”怎么做糖醋排骨”的时候,一样都没学到。

而它们恰恰是决定那顿饭成败的东西。

从”会做一道菜”到”能出一桌菜”,中间隔的不是更多的菜谱,是另一套完全不同的能力。

小林写代码也是这样。

他会写 goroutine,会写 channel,会用 sync 包,会调 GC 参数,会用 pprof。

这些是”一道菜”。

而”交付一个功能”是”一桌菜”。它需要:

  • 排程:这个需求拆成几步?哪步先做?哪步依赖别人?
  • 资源调度:你的时间、QA 的时间、运维的时间,怎么安排?
  • 并行:等 QA 回归的那两天,你能做什么?
  • 降级:时间不够了,砍哪部分?砍了之后核心目标还在吗?
  • 兜底:上线失败了怎么办?回滚方案是什么?

学校的教程、网上的课程、技术书籍,教的全是”一道菜”。

因为”一桌菜”没法教。它依赖于具体的场景、具体的人、具体的时间压力。

所以这道坎,只能自己过。

但你可以让它过得快一点——方法是:不要等到有人让你做一桌菜的时候,才第一次做。

小林后来学乖了。今年端午,他做了一次六个人的饭。中秋,做了八个人的。

他现在敢接十个人的局了。

不是因为他的糖醋排骨变好吃了,是因为他学会了排程。


Go 技术实例:从”一个能跑的 main.go”到”一个能被接手的项目”

这一节的技术实例,是项目结构的演进。这是”学得会”和”做得出来”在代码上最直观的差别。

大部分自学 Go 的人,写完的东西长这样:

myapp/
├── main.go          # 800 行,所有东西都在里面
├── go.mod
└── go.sum

main.go 里面从上到下:配置常量、数据库连接、HTTP handler、业务逻辑、工具函数、main 函数。

它能跑。它甚至跑得很好。

但它”交付”不了,因为第二个人接手的时候,他要读完 800 行才能动手改。

来看一个真实的项目骨架,这是”能被接手”的样子:

coupon-service/
├── cmd/
│   └── server/
│       └── main.go           # 只做一件事:组装依赖、启动、优雅退出
├── internal/
│   ├── config/
│   │   └── config.go         # 配置加载,全部从环境变量/配置文件来
│   ├── domain/               # 领域层:纯业务规则,不依赖 HTTP、不依赖数据库
│   │   ├── coupon.go
│   │   ├── claim.go
│   │   └── claim_test.go
│   ├── repo/                 # 数据访问层:SQL 都在这里
│   │   └── coupon_repo.go
│   └── transport/
│       └── http/
│           ├── handler.go    # 只做参数解析和响应封装
│           └── router.go
├── pkg/                      # 可以被外部引用的公共包(可选,谨慎添加)
├── migrations/
│   └── 20241103_add_original_amount.sql
├── deploy/
│   └── Dockerfile
├── docs/
│   └── notes/                # 排查记录、调优记录
├── Makefile
├── README.md
├── .gitignore
└── go.mod

关键不在目录名字,在这几个决策背后的理由:

第一,cmd/server/main.go 里不要写业务逻辑。 它只做三件事:读配置、组装依赖、注册信号处理和优雅退出。

package main
 
import (
	"context"
	"errors"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
 
	"coupon-service/internal/config"
	"coupon-service/internal/repo"
	"coupon-service/internal/transport/httpapi"
)
 
func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
 
	cfg, err := config.Load() // 配置全部来自环境变量,代码里没有一个常量
	if err != nil {
		logger.Error("load config failed", slog.String("err", err.Error()))
		os.Exit(1)
	}
 
	// 依赖在这里显式组装。谁能一眼看清"这个服务依赖什么",
	// 谁就能在出问题时第一时间定位。
	couponRepo, err := repo.NewCouponRepo(cfg.DB)
	if err != nil {
		logger.Error("init repo failed", slog.String("err", err.Error()))
		os.Exit(1)
	}
	defer couponRepo.Close()
 
	srv := &http.Server{
		Addr:         cfg.Addr,
		Handler:      httpapi.NewRouter(couponRepo, logger),
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
		// 注意 IdleTimeout:不加的话,空闲连接会一直占着 fd
		IdleTimeout: 60 * time.Second,
	}
 
	// 在独立的 goroutine 里启动,主 goroutine 去做优雅退出
	go func() {
		logger.Info("server starting", slog.String("addr", cfg.Addr))
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			logger.Error("server failed", slog.String("err", err.Error()))
			os.Exit(1)
		}
	}()
 
	// 优雅退出:收到 SIGTERM 后,先停止接收新请求,
	// 等正在处理的请求做完(最多 15 秒),再退出。
	// K8s 每次滚动发布都会发 SIGTERM,没有这段逻辑的请求会被硬切断,
	// 用户看到的就是"发布的时候总有几个请求失败"。
	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit
 
	logger.Info("shutting down")
	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()
	if err := srv.Shutdown(ctx); err != nil {
		logger.Error("shutdown failed", slog.String("err", err.Error()))
		os.Exit(1)
	}
	logger.Info("bye")
}

这个 main.go 里,没有任何业务逻辑。它唯一的作用是”接线”。

为什么不把业务逻辑写进去? 因为 main 函数不可测试。任何写在 main 里的逻辑,都没法写单元测试,你只能靠手动点。而”能被测”是”能交付”的前提。

第二,internal/domain 里不要出现 HTTP 和 SQL。

package domain
 
import (
	"errors"
	"time"
)
 
// Coupon 领域对象。它是纯粹的业务概念,
// 不认识 JSON,不认识 SQL,不认识 Redis。
type Coupon struct {
	ID       int64
	Status   Status
	StartAt  time.Time
	EndAt    time.Time
	Total    int
	Claimed  int
}
 
type Status string
 
const (
	StatusOnline  Status = "online"
	StatusOffline Status = "offline"
)
 
// ErrNotClaimable 用一个带原因的错误,而不是 bool。
// 调用方(HTTP handler)拿到它之后,可以映射成不同的响应码和文案。
var ErrNotClaimable = errors.New("coupon not claimable")
 
// IsActive 判断券在给定时刻是否有效。
// now 作为参数传入:整个函数是纯函数,可测、可重现。
func (c Coupon) IsActive(now time.Time) bool {
	return c.Status == StatusOnline &&
		!now.Before(c.StartAt) &&
		now.Before(c.EndAt)
}
 
// CanClaim 判断还能不能领。
// 注意这里只做判断,不做扣减——判断和扣减是两个关注点,
// 真正的库存扣减必须由数据库的原子操作保证。
func (c Coupon) CanClaim(now time.Time) error {
	if !c.IsActive(now) {
		return ErrNotClaimable
	}
	if c.Claimed >= c.Total {
		return ErrNotClaimable
	}
	return nil
}

这个包可以被任何东西调用:HTTP handler、定时任务、命令行工具、单元测试。它不依赖任何外部设施。

第三,repo 层把 SQL 全关在一个地方。

package repo
 
import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"time"
 
	"coupon-service/internal/domain"
)
 
type CouponRepo struct {
	db *sql.DB
}
 
func NewCouponRepo(cfg DBConfig) (*CouponRepo, error) {
	db, err := sql.Open("mysql", cfg.DSN)
	if err != nil {
		return nil, fmt.Errorf("open db: %w", err)
	}
 
	// 连接池参数必须显式设置。Go 的默认值在生产上几乎一定不够:
	// 默认 MaxOpenConns 是 0(无限制),高并发下会打爆数据库连接数。
	db.SetMaxOpenConns(cfg.MaxOpen)
	db.SetMaxIdleConns(cfg.MaxIdle)
	db.SetConnMaxLifetime(time.Hour)
 
	if err := db.Ping(); err != nil {
		return nil, fmt.Errorf("ping db: %w", err)
	}
	return &CouponRepo{db: db}, nil
}
 
func (r *CouponRepo) Close() error { return r.db.Close() }
 
// ClaimAtomically 原子地领取一张券。
//
// 关键点:判断和扣减在一条 SQL 里完成,靠行锁保证原子性。
// 这是防止超发的标准做法——先 SELECT 再 UPDATE 的写法在并发下必然超发。
// UPDATE 返回的影响行数为 0,说明库存没了或者条件不满足。
func (r *CouponRepo) ClaimAtomically(ctx context.Context, couponID, userID int64, now time.Time) (bool, error) {
	const q = `
		UPDATE coupons
		SET claimed = claimed + 1
		WHERE id = ?
		  AND status = 'online'
		  AND start_at <= ?
		  AND end_at > ?
		  AND claimed < total
	`
 
	res, err := r.db.ExecContext(ctx, q, couponID, now, now)
	if err != nil {
		return false, fmt.Errorf("claim coupon %d: %w", couponID, err)
	}
 
	n, err := res.RowsAffected()
	if err != nil {
		return false, fmt.Errorf("rows affected: %w", err)
	}
	if n == 0 {
		// 影响 0 行:可能是发完了,也可能是券不可领。
		// 如果需要区分,再查一次拿具体原因(这一步在多数场景下可以省)。
		return false, nil
	}
	return true, nil
}
 
func (r *CouponRepo) Get(ctx context.Context, id int64) (domain.Coupon, error) {
	var c domain.Coupon
	err := r.db.QueryRowContext(ctx,
		`SELECT id, status, start_at, end_at, total, claimed FROM coupons WHERE id = ?`,
		id,
	).Scan(&c.ID, &c.Status, &c.StartAt, &c.EndAt, &c.Total, &c.Claimed)
	if errors.Is(err, sql.ErrNoRows) {
		return c, fmt.Errorf("coupon %d: %w", id, ErrNotFound)
	}
	if err != nil {
		return c, fmt.Errorf("get coupon %d: %w", id, err)
	}
	return c, nil
}
 
var ErrNotFound = errors.New("not found")

第四,transport/http 层只做协议转换。

package httpapi
 
import (
	"errors"
	"log/slog"
	"net/http"
	"strconv"
	"time"
 
	"coupon-service/internal/domain"
)
 
type Handler struct {
	repo   Claimer
	logger *slog.Logger
}
 
// Claimer 是 Handler 需要的依赖,定义在使用方这边。
// 这是 Go 的经典做法:接口由消费者定义,而不是由实现者定义。
// 好处:测试的时候随便塞一个 fake 进去,不用起数据库。
type Claimer interface {
	ClaimAtomically(ctx context.Context, couponID, userID int64, now time.Time) (bool, error)
}
 
func (h *Handler) Claim(w http.ResponseWriter, r *http.Request) {
	// 1. 解析并校验输入
	couponID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"msg": "invalid coupon id"})
		return
	}
	userID, ok := userIDFrom(r.Context())
	if !ok {
		writeJSON(w, http.StatusUnauthorized, map[string]string{"msg": "not logged in"})
		return
	}
 
	// 2. 调领域/仓储
	ok, err = h.repo.ClaimAtomically(r.Context(), couponID, userID, time.Now())
	if err != nil {
		// 3. 错误分类:domain 层的语义错误 → 400;其他 → 500
		if errors.Is(err, domain.ErrNotClaimable) {
			writeJSON(w, http.StatusConflict, map[string]string{"msg": "券已领完"})
			return
		}
		// 内部错误记录日志,但不把细节暴露给客户端
		h.logger.Error("claim failed",
			slog.Int64("coupon_id", couponID),
			slog.Int64("user_id", userID),
			slog.String("err", err.Error()),
		)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"msg": "服务开小差了"})
		return
	}
	if !ok {
		writeJSON(w, http.StatusConflict, map[string]string{"msg": "券已领完"})
		return
	}
 
	writeJSON(w, http.StatusOK, map[string]string{"msg": "ok"})
}
 
func writeJSON(w http.ResponseWriter, code int, body any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(code)
	// 生产上应该用 json.NewEncoder(w).Encode(body) 并检查 err
	_ = body
}

这个结构,每一层都可以被单独测试和替换。

从”800 行的 main.go”到这个结构,用到的 Go 知识,没有一个是新的。接口、struct、包、错误处理——你在第一周就学完了。

差的不是知识,是”我知道它应该长这样”。

而这个”应该”,恰恰是没人教的那一块。


老陈的深层发问

  1. 你现在能不能说出,你过去一年做的所有事情里,有哪一件是你从头到尾、完整地、按你自己认可的标准交付的?如果一件都没有,那你的时间去了哪里?

  2. 你那张”做完的定义”上,有多少条是你已经做到超过三次的?有多少条是你一次都没做到的?后者里,你打算先做哪一条?

  3. 如果你现在开始,把每一件事都做到你自己写的那十二条,你的产出会变成现在的几分之几?你愿意接受这个数字吗?

  4. 你有没有过这种时刻:明知道这样做会欠债,但还是选择了”先上线”?那一刻你脑子里那个说服自己的理由,是什么?

  5. 五年之后,如果有一个 28 岁的年轻人,翻出你今天写的代码,他会看到什么?他会不会像你今天看你五年前的代码一样,脸发烫?


生活思考题

题一:你健身卡办了两年,去了不到二十次。每次不去,你都有一个理由。把那些理由列出来,它们有什么共同点?

参考答案

大多数人会这么答:工作忙、太累了、今天下雨、明天再去。共同点是——都是借口。

这个答案为什么不够:你把”借口”当成了道德问题,好像只要意志力够强就能克服。但你想想,这二十次里,你有没有哪次是”今天特别想去但还是没去”?没有。每一次你不去的时候,你当时是真的觉得不该去。你的每一次决策,在当时看来都是理性的:今天加班到九点,去了健身房就睡太晚,明天起不来,恶性循环。所以问题从来不是”意志力不够”,问题是你每次都在临时决策。而临时决策,永远会被当下最紧迫的事赢走。因为当下最紧迫的事(加班、累、困)是确定的、即时的、有强烈感受的;而健身的收益(三个月后的体形)是模糊的、延迟的、没有感受的。让一个模糊的远期收益,去对抗一个确定的即时痛苦,每一次都会输。

更深的一层:解决这个问题的唯一办法,是取消决策。不是”我决定每周去三次”,而是”周一三五晚上七点,我的日历是锁死的,那时候我没有别的选项”。这句话的力量在于:当你把它变成日程,你每一次面对的就不再是”去还是不去”,而是”我要不要取消一个已经排好的事”——而取消一个已排定的事,心理成本高得多。这就是为什么所有”自律”的体系,本质都不是增强意志力,而是减少决策次数。把需要意志力的事,变成不需要决策的例行公事。

落到 Go 工程师身上:你有多少”我知道该做但一直没做”的事?写测试、写文档、清理 TODO、看源码、复盘事故。这些事的共同点是:它们都需要你在当下主动决策。 所以它们永远排在”写新功能”后面。给你一个具体的方法:把这些事变成流程的一部分,而不是独立的决策。不要说”我要抽时间写测试”,要说”我的 PR 模板里有一栏叫’测试’,不填就提交不了”。不要说”我要记得复盘”,要说”每次线上事故,24 小时内必须有一个文档,这是发版流程的一环”。具体行动:挑一件你一直想做但没做的事,这周给它造一个”不这么做就走不下去”的机制——一个 CI 检查、一个 PR 模板字段、一个日历提醒。机制比决心管用十倍。

题二:你会骑自行车、会开车、会坐地铁。从你家到公司,五公里。你选哪种?为什么?

参考答案

大多数人会这么答:看情况。赶时间就开车,不赶就骑车或者地铁。

这个答案为什么不够:这个答案听起来很合理,但它隐瞒了一个事实——你每次都在重新决策。而重新决策是有成本的,这个成本你感觉不到,因为它太细小了。你每天早上要在脑子里过一遍:今天几点起、要不要洗头、天气怎么样、车限号吗、地铁挤不挤。这些判断每一个都很快,但它们消耗的是同一种资源:你一天里有限的决策能量。这就是为什么很多人一天没干什么大事却觉得累——他做了两百个小决策。而那些高效的人,他们的秘密不是决策快,是他们把大量决策变成了默认项。乔布斯穿一样的衣服,不是因为他有审美偏执,是因为他不想每天早上为穿什么花一秒钟。

更深的一层:默认项还有第二个价值,比省能量更重要:它让你有稳定的基线。 如果你每天都走同一条路、同一个时间,你就会非常精确地知道”这段路要二十分钟”。有了这个精确基线,你才能判断”今天堵了”是异常,才能提前十分钟出门。而如果你每天都换路线、换方式,你对这段路的认知永远是模糊的,你永远在给自己留”以防万一”的冗余。这个模糊性会扩散——你对通勤时间模糊,你对”这个需求要多久”也就模糊。而”做一件事需要多久”的估算能力,是工程师最值钱也最稀缺的能力之一。

落到 Go 工程师身上:你有稳定的工作节奏吗?还是每天都被打断、被重新安排?给你一个具体的判断方法:回想你过去四周,你有没有过连续三天、每天有三个小时不被打断的时间? 如果没有,你其实一直处在”救火模式”,你的产出质量必然是打折的,而且你不会意识到——你会以为是自己的能力问题。具体行动:跟你的 leader 谈一次,要一段”不排需求”的时间,哪怕每周半天。用这半天做一件事:把你负责的模块,写一个 README,写清楚它的职责、依赖、已知问题。这个 README 半年之后会变成你最常打开的文件,而且它是你能给团队留下的、最便宜也最有用的东西。


本章小结:核心心法

天完全黑了。

老陈把茶具收进一个布袋子里,动作很慢,很熟练。小林帮着把桌上的杯子摞起来。

“我最后问你一个问题。“老陈说。

“您说。”

“今天这七个多小时,你觉得自己学到了什么?”

小林想了一下。

“我学到了……我五年前欠的债,现在还没还完。“他说,“但是我还发现一个事。”

“什么?”

“我发现我那个时候不是不努力。“小林说,“我挺努力的。我天天看教程到十二点,我周末也在家写代码。”

“那问题在哪?”

“问题在于,我努力的方向,和’把事做对’的方向,是两条线。“小林说,“我以为我在往上走,其实我在原地转圈。”

老陈把布袋子的绳系上,抬头看了他一眼。

“这句话值今天这一下午。“他说。

小林笑了笑,没接话。他脑子里在转那个念头:五年,一千八百多天,他有多少天是在”原地转圈”?

“我给你总结一下今天聊的东西。“老陈说,“就三句话。”

“您说。”

“第一句:P4 的全部任务,是把一件被交代清楚的事,做到别人不需要再回来找你。”

老陈伸出一根手指。

“你注意这个定义里的关键词——‘被交代清楚’。P4 不需要你自己定义问题,那不是你这个级别的事。你要做的是:当别人把问题定义好了交给你,你能完整地、干净地、不留尾巴地做出来。”

“听起来简单。“小林说。

“听起来简单,做到的人不到三成。“老陈说,“大部分人做到 80% 就交出去了,剩下的 20% 让别人去擦屁股。而那 20%,往往就是时区、并发、边界、日志、文档——所有’不酷’的东西。”

“第二句呢?”

“第二句:把事做对,不是能力问题,是纪律问题。”

老陈伸出第二根手指。

“你今天列的十二条,没有一条需要高深的技术。全都是’我知道,但我没做’。所以 P4 这一级,拼的不是谁聪明,是谁能忍住’这次算了’那个念头。”

“那第三句呢?”

老陈伸出第三根手指。

“第三句:你今天补的每一条,都是在还五年前的债;你今天欠的每一条,五年后都要加倍还。”

小林把这张纸又掏出来看了一眼。

“这十二条,我做不到全部。“他说。

“我不要求你全部做到。“老陈说,“我要求你做到一条,然后一直做。”

“第七条。”

“第七条。”

老陈把布袋背在肩上,往门口走。走到门口他停了一下,回头。

“还有一件事,我本来不打算今天说。”

“什么?”

“你今天看你自己五年前的代码,你是带着羞耻看的。“老陈说,“这个羞耻感,你要小心。”

“小心什么?”

“小心两种用法。“老陈说,“第一种,你用羞耻来惩罚自己,觉得自己这五年白活了。这种用法会让你躺平。第二种,你用羞耻来嘲笑五年前的自己,觉得’我当时怎么这么蠢’。这种用法会让你傲慢——因为你现在写的代码,五年后一样会被嘲笑。”

“那该怎么用?”

“第三种。“老陈说,“你看着那段代码,问自己一个问题:当时的我,缺的是什么?不是缺努力,是缺什么?”

小林在心里问了这个问题。

答案是:缺一个人告诉我,什么是对的。

老陈像是看穿了他在想什么。

“你现在知道了。“他说,“所以你欠的那个债,有一部分是能还的——去当那个告诉小周’什么是对的’的人。”

“可是我自己也没完全做到啊。”

“那就一起做。“老陈推开门,“你跟小周约定,你俩一起改第七条。一个月之后,你俩一起复盘。”

门开了,外面是一条没什么人的巷子。路灯是暖黄色的,照着地上的落叶。

小林走出去,风一吹,他打了个冷战。

他掏出手机,看了一眼。晚上八点四十。有三条微信,一条是他老婆问”几点回来”,一条是组里的群消息,还有一条是小周的:

“林哥,我那个 PR 你看了吗?”

小林看着那条消息,站住了。

他想起自己昨天看到那个 PR,扫了一眼,觉得改动挺多,就放那了。

他打了一行字,删掉,又打了一行。

最后发出去的是:

“看了,等我十分钟,我给你留几条评论。”

发完之后他站在那儿,忽然有点想笑。

五年前,他也是那个发”王哥,我这有个地方 panic 了”的人。


自检清单

下面是这一级的自检清单。请诚实打勾——你骗自己没有意义,这张清单没有别人会看。

如果你有超过三条打不了勾,说明 P4 这一级的地基还有洞。往上盖楼之前,先把洞补上。

关于「交付」

  • □ 我能够说清楚”做完”和”交付”的区别,并且能列出我自己认可的交付标准(不少于八条)
  • □ 我能够在我最近完成的三个任务里,至少举出一个”我主动想到了需求方没提到的边界情况”的具体例子
  • □ 我能够在我说”做完了”之后,不需要再被人追问”那 X 情况你处理了吗”
  • □ 我能够独立完成一次上线,包括配置变更、验证方式、回滚方案,并且提前把它们写下来

关于代码

  • □ 我能够写出让人不需要猜的代码:函数名能准确描述它做的事,参数类型不会让人传错顺序
  • □ 我能够解释我写的每一个 if 是为了处理什么情况,并且能在测试里找到对应的用例
  • □ 我能够用表驱动的方式写测试,覆盖正常路径、边界值和至少一种异常情况
  • □ 我能够在提交之前跑完 gofmt、go vet、go test,并且养成习惯而不是靠提醒

关于协作

  • □ 我能够在提问之前,先把问题描述成”现象、条件、我试过的、我的猜测”四段
  • □ 我能够在收到让我不舒服的评审意见时,先复述对方的观点再回应,而不是立刻反驳
  • □ 我能够写出一条合格的提交信息:一句话说清改了什么,正文说清为什么,以及怎么验证
  • □ 我能够在给别人的 PR 留评论时,区分”必须改”和”可以不改”,并且明确标注出来

关于这一级的本质

  • □ 我能够说出,我做的东西是给谁用的,以及那个人最头疼的是什么
  • □ 我能够举出一个例子,是我主动把一件事做到”够好”就停手,而不是做到自己满意

下一章预告:P5 独立篇——让一块地归我负责。

从”把交代的事做对”,到”没人交代,我自己知道该做什么”。

那时候,问题不再是”怎么做”,而是”做什么”。

而小林会发现,他欠的债,从这一章开始,要连本带利地还了。