那一次我用Go panic做流程控制
摘要
一位Go工程师讲述了一次事件:内存数据存储因排序缓慢而超载,他们在排序函数内部实现了上下文取消,使用panic和recover进行非局部流程控制,类似于encoding/json处理错误的方式。
<p><a href="https://lobste.rs/s/arfalj/one_time_i_used_go_panics_for_flow_control">评论</a></p>
查看缓存全文
缓存时间: 2026/05/23 10:43
# 那次我用Go的panic做流程控制
来源:https://noncrab.net/posts/panic-as-flow-control/
主人公发现支撑我们支持工作的一个关键服务竟然对过载毫无抵抗力,以及我们如何修复这个问题。
我们工作的一部分支持基础设施是一个内存数据存储,允许我们根据各种维度查询待办的支持任务,比如工作类型、是否因某种原因被搁置等。它在功能上等同于SQL数据库中的单个表,有单一数据集、布尔过滤器和可配置排序。
在工作中,我们有一个内存数据存储,为部分支持基础设施提供支持。它有点类似于位图过滤再加事后过滤,因此任何使用sort/limit都会对整个结果集排序。关键点在于,结果集可能大到排序需要一两秒钟。
背景是,当时这个服务部署没有自动扩缩容,上游服务会重试失败的请求,有时会在相对较短的超时后重试。这很有趣。
所以有一天,这个服务承受了超出其处理能力的查询负载;由于缺乏弹性,它过载了,查询开始花费**比平时长得多**的时间(比如,长达一分钟,而正常情况下最多1-2秒)。不幸的是,因为这是个事故,有时人会 panic,我的一种猜测是内存变慢了。这当然很荒谬,但在时间压力下,事故大脑是非常真实的。
然而,正如前面所暗示的,这个服务只是过载了,所以我们不仅有略高于平均水平的负载,还有来自重试的**失败**负载。在Go服务中,大多数情况下我们会传递一个 [`context`](https://go.dev/doc/database/cancel-operations),这样当调用方放弃我们时,我们可以取消操作,短路并提前退出。
但是,当我们拿到CPU profile并查看时,绝大部分CPU时间都花在了查询的排序阶段。在Go中,所有 [`sort`](https://pkg.go.dev/sort) 函数都不支持取消(这很合理,因为通常你是在批量上下文中,或者排序的数量少到时间不重要)。那么,该怎么办呢?
通常,context取消会有叶子函数检查 [`错误`](https://pkg.go.dev/context#Cause),然后通过典型的 [`错误即值`](https://go.dev/doc/tutorial/handle-errors) 机制传播错误。然而,没有任何排序函数(例如 `slices.SortFunc` (https://pkg.go.dev/slices#SortFunc))接收 context 或者允许返回错误。
幸运的是,Go还有另一种非局部信号机制来处理错误(例如,如果你解引用了一个 `nil` 指针),即 [panics](https://go.dev/blog/defer-panic-and-recover)。这种机制通常不用于错误处理,因为非局部流程控制可能更难推理,但在一个狭窄定义的上下文中是有意义的。
例如,`encoding/json` 包就是这样做的,比如通过 `json.(*encodingState).error(...)` (https://github.com/golang/go/blob/go1.26.2/src/encoding/json/encode.go#L348-L350) 抛出 panic,并在顶级函数 `json.(*encodingState).marshal(...)` (https://github.com/golang/go/blob/go1.26.2/src/encoding/json/encode.go#L334-L342) 的范围内 recover。这样,任何客户端代码都不会看到非局部流程控制,工程师也不会遇到意外的 panic。
所以我们将代码从这样:
```go
func execute(ctx context.Context) (results, error) {
resultSet := query.filter(someTable)
slices.SortFunc(resultSet, func(a, b Row) int {
return query.compare(a, b)
})
}
```
改成了这样:
```go
type nonLocalCancellation struct {err error}
func execute(ctx context.Context) (results, error) {
resultSet := query.filter(someTable)
var sortErr error
defer func() {
// 参考:https://go.dev/blog/defer-panic-and-recover
if r := recover(); r != nil {
if c, ok := r.(nonLocalCancellation); ok {
sortErr = c.err
} else {
panic(r)
}
}
}()
slices.SortFunc(resultSet, func(a, b Row) int {
if ctx.Err() != nil {
panic(nonLocalCancellation{err: ctx.Err()})
}
return query.compare(a, b)
})
if sortErr != nil {
return nil, sortErr
}
return resultSet, nil
}
```
这虽然折腾了不少(这是一个丑陋问题的丑陋解决方案),但确实意味着如果调用方放弃了查询,我们就不会浪费时间为一个永远不会关心结果的请求排序。
相似文章
优化CPU密集型Go热路径的笔记
本文讨论了CPU密集型Go代码的性能优化技术,指出了泛型和接口抽象因无法内联而产生的局限性,并主张在热路径中使用代码复制。文章通过一个Brotli移植示例和深入基准测试进行了说明。
就用Go
一篇带有强烈观点的开发者文章倡导使用Go编程语言,强调其简洁的语法、强大的标准库、高效的并发模型以及单二进制部署,作为对过于复杂的现代技术栈的实用替代方案。
Go语言中的L1指令缓存集冲突、关联性与代码对齐
深入探讨L1指令缓存集冲突和代码对齐如何导致Go语言中出现意外的性能回退,以及调查过程。
Go 实验详解
本文介绍了 Go 语言中实验性功能的处理方式、生命周期以及近期实验示例。
调试挂起的Go程序的技巧
一份实用指南,涵盖了调试挂起的Go程序的三种方法:使用SIGQUIT打印堆栈跟踪、附加delve调试器以及保存核心转储供后续分析。