写Go也写了几年了,期间在错误处理上踩过的坑、翻过的车,比写业务逻辑还多。你随便去一个Go项目的代码评审现场,十有八九争论的焦点都在错误处理上:这个error该不该吞掉、那个panic要不要recover、Wrap的链是不是套得太深了。实际上,"golang异常处理"这个话题看起来简单,真正用好的团队真不多。我见过太多代码里全是裸的errors.New和挂着不回的错误日志,也见过生产环境因为一个panic把整个服务搞到雪崩的。
这篇文章不是教科书式的语法罗列,而是把我这些年积累的Go错误处理实战经验完整梳理一遍。从error的设计哲学,到panic/recover的底层机制,再到一套可以直接抄到项目里的错误处理方案,最后附上我踩过的坑和排查技巧。不管你是刚接触Go的初学者,还是已经在生产环境摸爬滚打的开发者,这篇文章都能给你一些真正能落地的思路。
1. 先想清楚:Go的错误处理到底在解决什么问题
很多人学Go的错误处理,第一反应是"怎么这么啰嗦",每个函数都要写if err != nil,烦死了。但你真去深挖一下,会发现这个设计背后是有非常清晰的逻辑的。
1.1 对比其他语言的异常机制,Go选择了一条不同的路
先拿Java、Python、C++这些语言来说,它们的异常处理是try-catch模式。函数内部抛一个异常,调用者用一个catch块接住,接不住就一路向上抛,直到某个地方兜底。这套机制看起来很优雅,但实际工程里会出现一个非常隐蔽的问题:异常边界变得模糊。
我举个例子,你写了一个函数,内部调了文件读取、网络请求、数据库查询三个动作。任何一个环节出错都会抛出异常。调用者接住异常之后,根本不知道具体是哪个环节出了问题,只能看异常消息去猜。更麻烦的是,有些人为了省事,直接在catch块里打个日志就算完事,错误被吞得一干二净,线上出了问题排查半天都不知道源头在哪。
Go的做法完全不同——错误就是一个普通的值。函数出错了,就把错误作为返回值交给你。你拿到这个值,想怎么处理就怎么处理。可以立刻返回、可以打日志、可以包装上下文、可以忽略(虽然我不建议)。这种把错误"值化"的设计有一个巨大的优点:错误的传递路径是完全显式的,代码读起来,哪条路径可能出错、出错之后怎么办,一目了然。
还有一点很多人没意识到,Go这样设计其实是被"逼"出来的。在Go语言诞生之前,我们团队用C++写后台服务,线上出现过一个特别典型的故障:某个模块在底层抛了一个异常,没有在合适的位置捕获,直接冒到最外层把整个服务进程干崩了。这种"隐式控制流"在分布式系统里的破坏力非常强——一个底层小故障能因为异常穿透引发大面积雪崩。Go的if err != nil虽然啰嗦,但它逼着你在每个可能出错的环节停下来思考,这个错误到底要不要处理、怎么处理。
1.2 三大件各自的分工:error、panic、recover
Go里关于异常处理的核心其实就三个东西:error接口、panic、recover。它们的定位是截然不同的,很多新手(甚至一些老手)容易搞混。
error:处理可预期的、常规的业务和系统错误。文件不存在、网络超时、参数不合法、数据库连接失败,这些都是常规错误,用error值来处理。panic:处理不可预期的、程序无法继续正常执行的异常。数组越界、空指针解引用、断言失败、map并发读写,这些是程序bug或者极端情况下的运行时问题。recover:是panic的"兜底网",用来在defer中拦截panic,阻止进程崩溃。
用一个生活化的类比来解释:开车出门,半路轮胎扎了钉子,这是可以预料的常规故障——靠边停车换备胎,相当于error;但如果你正在高速上飙车,突然发动机爆缸、方向盘失灵,这就是极端异常情况——panic;车上配备的气囊和安全带,就是recover,在你失控撞车的时候尽量保你一条命。
一个核心的工程原则是:能用error解决的问题,绝不要抛panic。panic应该是你最后的防线,而不是常规的错误传递手段。我在代码评审里看到有人用panic来结束业务流程、然后在入口处recover再转成error返回,这种做法非常糟糕,等于把Go的显式错误处理硬掰回Java那一套,而且性能上还有额外开销。后面我会详细讲为什么。
2. error的正确使用姿势:从零开始掌握错误处理的核心
error这个接口在Go里特别简单,内部就一个方法:
type error interface { Error() string }任何类型只要实现了Error() string方法,就能当作error使用。这是Go的"隐式接口"机制的典型体现。但接口越简单,用的时候就越见功力。
2.1 创建错误的几种方式,以及它们的适用场景
最基础的是errors.New,直接创建一个错误值:
var ErrUserNotFound = errors.New("user not found")注意,这里有个工程上的讲究:错误变量要用ErrXxx的命名规范定义成包级变量,不要每次调用都现场new一个。因为errors.New创建的error内部有一个指针字段(实际上是errorString结构体),每次new出来的错误值即使字符串相同,两个err ==比较也是false。如果定义成包级变量,就形成了单例,调用方可以用err == ErrUserNotFound做精确匹配。
不过errors.New只能给一段静态字符串,很多时候你需要带上下文信息,比如"查询用户ID xxx 失败"。这时候用fmt.Errorf:
userID := "u_10086" err := fmt.Errorf("query user %s failed: %w", userID, ErrUserNotFound)这里我用到了%w,后面会专门讲。%w之外的格式化动词就是普通字符串拼接,%s、%d都行。但有个细节很多人会踩:fmt.Errorf如果忘了写%w,而是写成%v或者%s去格式化error,那它返回的错误就只是一个新的errorString,和原始错误完全切断关系,链路信息丢失,你后面就只能拿err.Error()的文本去匹配,非常被动。
2.2 错误包装:%w与errors.Is/As的搭配使用
Go 1.13之前,错误包装是一件很痛苦的事。你只能自己定义结构体把原始错误包一层,不自量力地实现Error()方法,还要在代码里到处做类型断言。
Go 1.13引入的%w格式化动词,算是把这个问题优雅地解决了。用%w包装之后,你可以在任意一层用errors.Is判断错误链上是否出现过某个特定的错误,用errors.As把错误链上的某个错误提取出来转成具体类型。
// 底层返回一个哨兵错误 func findUser(id string) error { return fmt.Errorf("get user from db: %w", ErrUserNotFound) } // 中间层继续包装 func queryUserProfile(id string) error { if err := findUser(id); err != nil { return fmt.Errorf("query user profile: %w", err) } return nil } // 调用方判断 err := queryUserProfile("u_10086") if errors.Is(err, ErrUserNotFound) { // 这里是 ErrUserNotFound 语义,可以给用户提示"未找到该用户" fmt.Println("user not found in chain") }errors.Is的本质是沿着错误链逐层比较,不仅比较当前error,还会调用Unwrap()不断往深层走。所以你要做的是保证每一层包装都用%w,这样链条才不会断。
errors.As则更进一步,适用于需要提取错误链上某个具体自定义类型的场景:
type TimeoutError struct { Duration time.Duration Operation string } func (e *TimeoutError) Error() string { return fmt.Sprintf("operation %s timeout after %v", e.Operation, e.Duration) } func callRPC() error { return fmt.Errorf("call rpc: %w", &TimeoutError{Duration: 3 * time.Second, Operation: "get_user"}) } func main() { err := callRPC() var te *TimeoutError if errors.As(err, &te) { // 提取出了 TimeoutError,可以判断具体类型和字段 if te.Duration > 2*time.Second { fmt.Println("rpc timeout too long, need retry?") } } }errors.As有个很容易写错的点:第二个参数必须传入目标类型的指针的指针,也就是&te。原理很简单,因为errors.As要修改你传入的变量,让它指向错误链上匹配的那个值,所以需要传入变量的内存地址。
2.3 自定义错误类型的实践:把错误变成结构化数据
有时你需要的错误不只是"一段话",而是携带更多结构化信息。比如上层业务要根据错误码区分处理逻辑,或者要判断错误是否可重试。
一个典型的自定义错误长这样:
type MyError struct { Code int // 业务错误码 Message string // 给用户看的消息 Cause error // 底层原始错误 } func (e *MyError) Error() string { if e.Cause != nil { return fmt.Sprintf("code=%d message=%s cause=%v", e.Code, e.Message, e.Cause) } return fmt.Sprintf("code=%d message=%s", e.Code, e.Message) } func (e *MyError) Unwrap() error { return e.Cause } func (e *MyError) Is(target error) bool { // 可选:定义自己的Is逻辑,让 errors.Is 能用 if t, ok := target.(*MyError); ok { return e.Code == t.Code } return false }这里的关键是同时实现了Error()和Unwrap()。Error()让它可以作为error正常使用,Unwrap()让它可以被errors.Is和errors.As沿着链条逐层解包。Is()方法则是可选的高级玩法,当你觉得errors.Is默认的逐层比较规则还不够时,比如你只关心错误码是否相等、不关心具体实例,就可以自定义Is来覆盖默认行为。
从工程实践角度说,自定义错误类型最核心的价值在于:它把错误从"人读的字符串"升级成了"机器可读的结构化数据"。线上系统拿到这种错误可以直接做逻辑分支,而不是全靠正则去抠字符串,那个真的很痛苦。
3. panic与recover:运行时异常的捕获和恢复注意事项
panic的底层机制其实很直接:程序在某个位置触发了panic,当前goroutine开始执行延迟函数栈(defer stack)里的defer函数,如果这些defer函数里有recover,就能捕获panic让程序恢复正常;如果没有recover,整个goroutine就会崩掉,打印堆栈信息,程序退出(如果panic发生在main goroutine)或该goroutine退出。
3.1 panic什么时候会发生:别把panic当错误流程
写业务代码时,panic几乎都是这两类:一种是主动调用panic(),另一种是运行时自动触发的,比如索引越界、空指针、往已关闭的channel里发送数据。
举个空指针的典型例子:
var user *User = nil fmt.Println(user.Name) // 这里会触发 panic: runtime error这种纯属代码bug,正确做法是写代码时就做好nil判断,而不是指望recover兜底。我之前做过一个项目,有个同事在代码里大幅使用recover来捕获空指针异常,因为这样可以"不用在每个地方判断nil"。一开始看着还能跑,后来线上出问题复盘时发现,错误全被recover吞掉了,堆栈也丢了,根本定位不了问题出在哪。那次之后,我们在团队里立了一个规矩:panic和recover只用于守护进程级的不可恢复错误或者框架级的兜底,业务代码里禁止主动panic,也禁止为了偷懒用recover掩盖bug。
另外有两个极其隐蔽的panic触发场景,我单独拎出来说:
- map的并发读写。Go的map本身不是并发安全的,两个goroutine同时读写同一个map,运行时会直接检测到并触发
fatal error: concurrent map read and map write。注意这个还不太一样,它是fatal error,recover是拦不住的,进程会直接退出。解决方案是用带锁的map、sync.Map或者Go 1.9+的sync.Map。 - 向已关闭的channel写数据。往一个
close过的channel里send,会触发panic。不过向已关闭的channel里取数据是合法的(取完缓冲里的元素后得到零值),所以很多人下意识觉得没问题,实际写channel时踩坑。
3.2 defer的执行顺序:这决定了你的recover能不能用
defer语句永远在函数返回前执行,多个defer按**后进先出(LIFO)**的顺序执行。这个顺序背后有一套运行时的实现逻辑:每个goroutine上都有一个defer链表,新的defer会被挂到链头,执行时从链头开始取,所以自然就是后注册的先执行。
这个LIFO顺序在实际工程里有特别经典的用法——资源释放顺序。比如你一个函数里先打开了文件、又建立了网络连接,释放资源的defer一定要倒着写:
file, err := os.Open("data.txt") if err != nil { return err } defer file.Close() conn, err := net.Dial("tcp", "localhost:8080") if err != nil { return err } defer conn.Close()执行时先关conn再关file,这个顺序是符合直觉的:后建立的连接先释放。
但这里还有个更隐蔽的坑:defer是在函数返回时才执行的,而不是作用域结束时。很多人以为在for循环里的defer是每个循环迭代结束就执行,实际上不是——它们全部攒着,直到函数结束才一口气执行。如果for循环里有几万个迭代,每个迭代都defer关闭一个资源,那资源会全部堆积到函数尾部才释放。迭代量大的场景,要么不用defer,每次循环体尾部手动关闭;要么把循环体拆成独立的子函数,这样每个子函数自己的defer就能及时执行。
3.3 recover到底怎么用才靠谱
recover()这个函数只在defer函数里调用才有效,其他地方调用会直接返回nil。这是因为recover的底层实现依赖goroutine的panic状态,只有当当前goroutine正处于panicking状态、且运行在defer调用路径上时,recover才能捕获到panic。非defer路径上的recover是无效的。
最基本的用法:
func safeCall(fn func()) { defer func() { if r := recover(); r != nil { fmt.Println("recovered from panic:", r) } }() fn() // 如果fn内部panic,会被上面的defer捕获 }这里有个很多新手会犯的经典错误:直接在defer里写recover()的返回值判断,而不把recover()赋值给变量。
// 错误写法 defer func() { if recover() != nil { // 这样其实也能工作,但注意 recover 只有第一次调用有效 fmt.Println("panic!") } }()上面其实能用,但下面这种更隐蔽:
// 错误写法:在非defer的中间函数里recover func outer() { defer innerRecover() // innerRecover里调用recover,其实是无效的 panic("boom") } func innerRecover() { r := recover() fmt.Println("recovered?", r) }为什么这个无效?因为recover()只对当前函数栈帧上的panic有效。innerRecover是outer的defer直接调用的一个独立函数,panic触发时,Go运行时执行defer链,进入innerRecover这个新函数,此刻panic状态属于outer的函数调用上下文,recover在innerRecover里拿不到它。所以要想recover成功,recover()调用必须直接写在defer设置的那个函数体内,不能用中间函数包一层。
再有就是panic时recover返回的值是panic传入的参数。如果你panic("boom"),recover返回的是字符串"boom";如果你panic传入一个error,返回的就是那个error值。所以恢复之后你要判断类型:
if r := recover(); r != nil { if err, ok := r.(error); ok { // 将它作为error记录 log.Printf("panic recovered, err=%v", err) } else { log.Printf("panic recovered, raw=%v", r) } }还有一点:recover之后,发生panic的那个函数当前执行点的剩余代码不会继续执行,但程序会从defer链执行完毕后返回到调用方。什么意思呢?假如fn()里第2行panic,第3行和第4行的代码永远不会执行。你recover之后,safeCall自己可以继续往下走,但fn那个函数是回不去了。这点在写业务逻辑时要特别小心,不要以为recover住了程序就自动"回血"到出问题的现场继续跑——它只是让整个goroutine不崩,逻辑上该中断的还是中断了。
4. 实战:给你一个可以直接抄的错误处理方案
前面讲了理论,这部分给你一套相对完整的工程化方案。我把它贡献出来,也是希望你在自己项目里不用从零造轮子。
4.1 错误码与错误的统一管理:从源头规划
很多项目里的错误处理之所以乱,根源在于错误码的语义不统一。有的模块错误码用101、102这种无意义数字,有的直接用错误字符串,还有的干脆不设错误码,全靠看error message猜。我的做法是:用一个专门的包(比如叫errno或者code)统一管理全局错误码和错误信息。
package errno import "fmt" type Errno struct { Code int Message string } func (e Errno) Error() string { return fmt.Sprintf("code=%d, message=%s", e.Code, e.Message) } // 全局错误码统一定义 var ( Success = Errno{Code: 0, Message: "success"} ErrParamInvalid = Errno{Code: 40000, Message: "param is invalid"} ErrUnauthorized = Errno{Code: 40100, Message: "unauthorized"} ErrNotFound = Errno{Code: 40400, Message: "resource not found"} ErrInternalServer = Errno{Code: 50000, Message: "internal server error"} ErrTimeout = Errno{Code: 50400, Message: "request timeout"} )错误码规划有两条原则:一是分段,比如4xxxx表示客户端错误、5xxxx表示服务端错误,这样一眼能看出是调用方的问题还是服务的问题;二是预留区间,比如每个业务模块分到100个码位,中间留空余,方便后续扩展,不要在规划时把码全部塞满。实际项目里我用的是"模块号+错误序号"的方式,比如订单模块的码段在 50100-50199,库存模块在 50200-50299,定位问题的时候速度极快。
4.2 业务错误与系统错误的区分:别让底层错误裸奔到前端
有了统一错误码,接下来要做的核心是分层区分业务错误和系统错误。
- 业务错误:用户参数不合法、无权限、资源不存在、余额不足。这类错误是可以预期的,应该显式返回给调用方,让前端弹提示或做相应逻辑。
- 系统错误:DB连接失败、第三方接口超时、Redis不可用。这类错误不该直接把底层细节抛给用户,而是应该记录详细的错误堆栈,给用户一个通用的"系统繁忙"。
分层的关键在于翻译。在Controller/Handler层做一个错误映射函数,把内部自定义的系统错误翻译成用户可读的响应:
func TranslateError(err error) errno.Errno { // 如果是我们自己的业务错误码,直接返回 if e, ok := err.(errno.Errno); ok { return e } // 对底层系统错误分类 switch { case errors.Is(err, context.DeadlineExceeded): return errno.ErrTimeout case errors.Is(err, os.ErrNotExist): return errno.ErrNotFound default: return errno.ErrInternalServer } }这里的关键点是利用errors.Is做链式判断,而不是用err.Error()做字符串匹配。因为底层错误类型可能是数据库驱动、企业级框架自定义的,完全没法预知字符串长什么样,用链式比较才是最稳的。
4.3 API层的错误响应封装:统一格式,前后端不那么容易打架
线上API的错误响应格式应该统一,不要让这个接口返回{"error":"xxx"},那个接口返回{"code":1,"message":"yyy"}。我们项目里统一的响应格式类似:
{ "code": 40400, "message": "user not found", "data": null, "trace_id": "abc123" }封装成Go函数大致长这样:
type Response struct { Code int `json:"code"` Message string `json:"message"` Data interface{} `json:"data"` TraceID string `json:"trace_id"` } func SendError(c *gin.Context, httpStatus int, err error) { e := TranslateError(err) requestID := c.GetHeader("X-Request-ID") c.JSON(httpStatus, Response{ Code: e.Code, Message: e.Message, Data: nil, TraceID: requestID, }) }这里有两个细节值得注意。第一,httpStatus是真正的HTTP状态码,code是业务错误码,两者需要分开看。业务上凡是客户端引发的错误,HTTP状态码用4xx;而服务端异常统一用500。不过有些团队喜欢HTTP全用200、只在body里放业务code,这个属于API设计哲学之争,不展开。第二,TraceID真的很重要。有了trace id,线上用户报错时你拿着id去日志中心一查,整条链路的调用、错误上下文就全部出来了。没有trace id的话,用户光说"报错了",你根本不知道从哪查起。
4.4 goroutine里的错误处理:这是很多线上故障的温床
未来你写并发代码时,一定会遇到这个场景:开了一堆goroutine处理任务,其中某个goroutine里出错了,怎么把这个错误带回来?用channel来传错误是一个很经典的做法。
func worker(id int, errCh chan<- error) { defer func() { if r := recover(); r != nil { errCh <- fmt.Errorf("worker %d panic: %v", id, r) } }() // 模拟可能出错 if id == 2 { errCh <- fmt.Errorf("worker %d business error", id) return } errCh <- nil } func main() { const n = 5 errCh := make(chan error, n) for i := 0; i < n; i++ { go worker(i, errCh) } for i := 0; i < n; i++ { if err := <-errCh; err != nil { fmt.Println("receive error:", err) } } }这里有个必须记住的硬性规定:channel一定要有缓冲。如果channel没有缓冲,而goroutine所在逻辑在执行到写channel这一步时没有其他协程同步接收,就会阻塞;更严重的是,如果子goroutine里的panic还没被recover、又恰好卡在写channel上,整个goroutine就泄漏了。我建议统统用make(chan error, n),n是自认为最多可能产生的错误数。这样能保证goroutine在出错后第一时间写入错误,不至于因为channel阻塞导致整个任务无法收敛。
还有一种更细致的场景是errgroup,这是官方提供的一个包,专门用来管理一批goroutine的并发执行和错误汇聚。用errgroup.WithContext可以做到"任意一个goroutine出错,就取消整个任务组":
g, ctx := errgroup.WithContext(context.Background()) for _, task := range tasks { task := task g.Go(func() error { select { case <-ctx.Done(): return ctx.Err() default: return doTask(task) } }) } if err := g.Wait(); err != nil { fmt.Println("task group failed:", err) }errgroup的源码不长,但是设计很精妙。它的WithContext派生出的context会在任意一个goroutine返回非nil错误时自动cancel,其他还在跑的goroutine通过ctx.Done()感知并自行退出。这种"快速失败"的模型特别适合批量任务中一个失败就整体不需要继续的场景,比如某个数据同步任务,中间有一条失败,其他的同步取消也能接受,这样能避免大量无意义的资源浪费。
5. 那些年踩过的坑:常见问题与排查技巧
错误处理相关的线上事故,我见得太多了。这一节把最常见的问题和排查手段给你撸一遍,都是血泪教训。
5.1 常见panic场景速查:这些坑你迟早会遇到
| 场景 | panic信息 | 原因 | 解决 |
|---|---|---|---|
| 空指针解引用 | runtime error: invalid memory address or nil pointer dereference | 对nil对象访问字段或方法 | 使用前判断nil,或初始化时用new/make |
| 切片越界 | runtime error: index out of range [5] with length 3 | 访问超出切片长度的下标 | 访问前判断len(),用range尽量安全 |
| 向已关闭channel发送 | send on closed channel | 向close后的channel写数据 | 约定发送方才能关闭channel,避免双写 |
| map并发读写 | fatal error: concurrent map read and map write | 多个goroutine同时读写同一个map | 使用sync.Mutex、sync.RWMutex或sync.Map |
| 断言失败 | interface conversion: interface {} is string, not int | 类型断言时类型不匹配 | 使用v, ok := i.(int)的comma-ok形式 |
| 在nil map上写 | assignment to entry in nil map | 对未make的map执行赋值 | 使用make(map[K]V)初始化后再赋值 |
这里面我特别想diss一下"往nil map里写值"这个坑。很多人创建一个var m map[string]int,以为和切片一样可以直接用,结果一赋值就panic。原因是map是引用类型,必须经过make初始化才能写入。更阴的是从nil map读数据是不会panic的,只会返回零值,所以"读不出bug,写就炸"。
5.2 recover失效的几种情况:你以为你兜住了,其实没有
recover不是万能的,我整理了一下我在生产环境中实际验证过的失效场景:
- defer里的recover只能恢复当前goroutine的panic。如果你在main goroutine的defer里recover,但另一个子goroutinepanic了,那是完全捞不回来的,子goroutine会直接崩溃退出。每个goroutine必须自己负责自己的panic恢复。
os.Exit会绕过所有的defer。哪怕你的恢复代码写得再完美,只要哪一行代码调用了os.Exit(1),defer根本不会执行,recover自然也没机会跑。所以在服务框架里千万别用os.Exit来退出,要用return配合信号处理优雅退出。panic传了nil值。如果你写panic(nil),某些Go版本(Go 1.21之前)里recover()会返回nil,程序看似什么都没发生,实际上panic根本没被处理,运行时会在defer链执行完毕后依然崩溃。这个问题在Go 1.21的一个修复中改了,但老版本代码里如果真的有panic(nil)这种写法,很容易造成"recover失灵"的假象。- recover被再次panic覆盖。在defer函数里recover之后,又调用了
panic(...),新的panic会再次触发并导致程序崩溃。这在某些复杂的错误处理框架里容易发生,比如有人在defer里recover成功后,想把错误再抛给上层,直接panic(newErr),结果上层没有recover,进程照样崩。
5.3 排查panic的实用技巧:从net/http到吞掉堆栈信息
线上如果突然有panic,第一件事不是找代码,而是先拿堆栈。Go默认在panic时会打印完整的调用堆栈,前提是你没有在入口处用recover把堆栈吞掉。
这里有一个重要的建议:recover里不仅要记录错误信息,还要记录当时的调用堆栈。因为panic发生之后,如果你只recover不记录堆栈,你就永远失去了现场。这个坑我踩过很重的一次:某个服务频繁报错但业务不受影响,我就没有重视,等真的出现大问题的时候才发现,之前那些panic全是空指针,但堆栈全被吞了,往上翻日志只看到一行"recovered: nil pointer",连哪行代码触发的都不知道。
正确的姿势是记录debug.Stack():
defer func() { if r := recover(); r != nil { // 打印或者把堆栈缓存到日志平台 fmt.Printf("panic recovered: %v\n%s", r, debug.Stack()) } }()debug.Stack()返回的是调用当前goroutine的全部堆栈信息,包括触发panic的goroutine内的函数调用链。你把它和业务日志放在一起,排起查来会快非常多。另外就是,如果条件允许,建议配置一个上游的日志处理系统或者错误追踪平台,把panic信息统一发过去,这样不用登录每台机器翻日志。
再分享一个排查小技巧:当你拿到一段panic堆栈时,核心看两个地方。第一,panic发生的那一行代码在堆栈文件路径里有标注;第二,函数调用链从内到外。一般真正的问题是链条最内层的函数传入了非法参数,而错误返回地在外层。你去代码里定位时,先从最里层看起,一层层往外推,就能找到问题的根因。
还有个常被忽视的问题:panic后的资源泄漏。defer里如果有非recover的资源释放逻辑,panic触发时defer链还是会执行,释放逻辑也会执行,这个没问题。但如果你的是零时goroutine里的panic,没有任何defer去恢复它,goroutine会挂掉。如果它持有某个连接池里的连接、或者占有某个分布式锁,这些资源很可能得不到释放。所以设计并发任务时,每一个goroutine的入口处都要有recover+清理逻辑,不能让goroutine裸奔。
结尾
在Go里把错误处理做好,其实没有太多花哨技巧,更多是工程纪律。我个人的体会是,这几件事值得每支Go团队认真沉淀成规范:第一,哨兵错误统一定义成包级变量,禁止现场new裸错误;第二,所有跨层传递的错误一律用%w包装,并配合errors.Is/As做链式判断;第三,panic走框架级兜底,业务代码绝不主动panic,也绝不为了偷懒吞掉异常;第四,goroutine入口必带recover + debug.Stack(),多给线上留一条后路。
最后再分享一个小技巧:在评审别人的Go代码时,如果看到有人为了"简化代码"把一堆错误处理全部用recover吃掉,我建议你问他一句——如果这里panic了,你怎么知道是哪个请求、哪个参数导致的?答不上来的,趁早改掉。Go的错误处理就是这样,它不花哨,但它逼着你在每一个可能出错的地方做出选择,而这恰恰是它最可靠的地方。