使用免费AI模型生成的Go并发日志聚合器在生产环境偶发崩溃,根源是map并发写,最终靠Go race detector定位并用一把锁修复。
崩溃是间歇性的、只出现在生产环境、本地所有测试都捕捉不到。原来是一个免费编程模型帮我写的代码中存在数据竞争,修复方法只是一把锁。本文记录了从症状到根本原因的追踪过程,并展示 Go 竞态检测器是如何让隐藏的问题变得可见的。
我使用 MonkeyCode 的免费模型访问权限,为一个副业项目生成了一个小型并发日志聚合器,并在他们的免费服务器上预览。声明:本文是 MonkeyCode 产品推广的一部分。在单个请求下服务运行正常,所以我没有考虑并发问题就直接合并了代码。
第一次崩溃发生在第二天,日志中只有简短的 "fatal error: concurrent map writes"。进程重启了,健康检查恢复了,事故在几秒内就结束了。但第二天又发生了,然后又是两次,每次都在流量高峰时出现。
我的第一个假设是资源耗尽,因为免费服务器有适度的内存限制。我添加了指标,监控堆内存增长,但没有发现异常。崩溃日志指向一次 map 写入,但不确定是哪个 map 和哪个 goroutine。
突破出现在我用竞态检测器运行测试套件时。命令很简单:go test -race ./...。它立即报告了两个 goroutine 在没有同步的情况下对同一个 map 写入的竞态条件。原来 agent 的代码使用了普通的 map[string]int 来计数事件,每个请求处理器都并发地写入它。
以下是出问题代码的简化版本,以及修复方案。原始代码使用了裸露的 map:
var counts = map[string]int{}
func handle(event string) {
counts[event]++
}
修复方案是用 mutex 包装 map:
var (
mu sync.Mutex
counts = map[string]int{}
)
func handle(event string) {
mu.Lock()
defer mu.Unlock()
counts[event]++
}
为了让这个回归问题可复现,我添加了一个压力测试,发送一百个并发事件,然后检查最终计数。没有竞态检测器时,测试大多数时候会通过;加上 -race 后,它会一致地失败。
func TestConcurrentCounts(t *testing.T) {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
handle("event")
}()
}
wg.Wait()
if counts["event"] != 100 {
t.Fatalf("got %d, want 100", counts["event"])
}
}
教训是,单线程的心智模型是我们大多数人的默认模式,也是大多数生成代码的模型的默认模式。map 不是计数器;它是共享结构,需要加锁。竞态检测器是代价最低的学习方式,因为它把一个概率性的崩溃变成了确定性的报告。
这种方法有局限性。竞态检测器只在 Go 中有效,而且只在你的测试真正执行并发路径时才能发现问题。它找不到从未运行过的代码中的竞态,而且会明显拖慢测试套件。如果你的服务是单线程的,检测器会保持安静,但这与安全不是同一回事。
这次崩溃教会我把每个 agent 写的 map 都当作嫌疑对象,直到证明清白。如果你正在尝试免费模型访问和免费服务器,在加入部署之前先把 -race 加到测试命令中。它不花一分钱,却能找到那种只在负载下才出现的 bug 类别。