R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。

从排查 Go CAT Client 的错误中学到的

背景

一个月前,我们仓库的操作人员感觉系统的响应太慢,点击一个按钮需要等待十几秒才能出结果,于是给我们加了个前端监控的需求,也就是监控 API 的响应时间。我跟同事借用了漫威宇宙中的观察者 Uatu 的名字,给项目的前后端分别命名为 uatu-libuatu-service。后来项目经过几次修改,已经可以在前端监控 DNS、TCP、响应时间,还有前端的一些未被捕获的报错(包括 Vue 的报错,这里借用了 sentry 的部分代码)。

我们公司其实已经用上了一套监控平台,是美团开源的 CAT,于是我们也打算把数据接入到这儿,大概的思路是:前端收集监控信息,发送到我们自己写的后端,再通过后端转发到公司内网的 CAT。前后端交互没啥好说的,重点在后端转发到 CAT。CAT 的文档并没有特别的易懂,而且网上的 Go Client 并不支持 Transaction 的嵌套,因此我们决定对其加以修改。

Go CAT Client 的结构

最主要的文件如下:

  • agent.go:与 CAT 服务器连接、发送消息,属于底层
  • api.go:负责生成、启用、禁用 Agent
  • client.go:获取连接信息、生成 CAT 的基本数据类型
  • message.go:构造一条消息、对其编码、调用 Agent 发送消息

需要对其优化的点是:

  1. 之前的 Message 自身有 encodeSend 方法,也就是说,需要调用 msg.Send(msgBytes) 来发送数据,这显然是很不合理的;
  2. 不支持消息树,也就是不支持 Transaction 嵌套,我们需要加上这个功能。

第一次优化

我先实现了消息树功能,为 message.go 添加了 CommitMultiple 函数,用 map[int64][]*Message 来存储每次的消息树内容(由于 Go 是一个实例在跑,因此我需要一个 ID 来区分是哪个 Request)。至于树结构,我通过 Parent ID 构造了一个邻接表,然后做一次深度优先遍历,就可以生成 CAT 的数据格式并发送了。

第一次 Bug 的现象

线上多了好多 5xx 的错误,看 Log 发现是 concurrent map read and map write,由于我对 Go 了解不深,于是查资料,发现 Go 的 map 不是线程安全的……询问了某大佬后,大佬给我推荐了新版 Go 自带的 concurrent_map。但我对照着它的原理,以及网上的分析,发现我们这种需要大量写操作的业务并不适用,于是还是手动加锁吧,读的时候调用 RLock,写的时候调用 Lock

加了锁之后,5xx 的错误全部消失。

第二次优化

为了把发送相关的函数抽出来,我写了个 message_stage.go 用来存储和批量发送消息。我先把深度优先遍历啥的也都挪了进去,又把 Send 方法也挪到了里面。

第二次 Bug 的现象

在 Jenkins 上面 Build 了之后,发现并不能跑起来。Mesos 的记录表示:Docker 服务启动一段时间后会报 255 错误,然后重启。同事眼疾手快,趁 Docker 没崩掉的时候进去看了 Log,错误是 OOM(内存超限),我完全理解不了。

第二天,同事告诉我,我们好像有个叫 Grafana 的线上监控系统诶!我登录进去一看,几个国家的 CPU 都占到了 100%,内存使用也超过了 8 GB 的限制(之前基本只用 50 MB),这更疑惑了。我用 Go 的 pprof 调试了好久,除了发现 Go 在垃圾回收之后会不断占着内存、不还给操作系统以外(正常现象,因为我系统内存很足,Go 这样可以避免大量内存分配),并没发现有什么可能造成内存泄漏的地方。

后来,我在同事发过来的 Log 里面发现了:某一个 goroutine 中的调用堆栈有一大堆的 traverse 函数,但目前所有业务传过来的数据不可能多于两层,这说明我的深度优先遍历无限递归了!以一个当年 OI 选手的名义起誓,我写这种东西还是很轻松的,不可能出现这个问题,于是我暂时限制了递归层数,并把可能造成无限递归的数据打到了 Log 里面,发现 ID 是 0,Parent ID 也是 0……这特么成环了……

所以 CPU 疯转和 OOM 的原因是:由于成环了,程序无限递归,traverse 里面的变量又不多,所以可以很轻松的递归上千层,这耗尽了 CPU 的资源,也耗尽了有限的内存。

仔细一想,好像是因为线上环境的前端库还没更新,于是没有传这两个 Key 过来,我在后端 Struct 里面写的结构也没有声明 required,就被解析成 0 了。

在后端对旧数据做了一些兼容之后,可以跑起来了,CPU 和内存的占用也恢复了正常。

经验教训

  1. 要对语言足够熟悉,才不会踩语言的坑;
  2. 线上的 API 如果做了不兼容的升级,请务必要区分版本。
版权声明:除文章开头有特殊声明的情况外,所有文章均可在遵从 CC BY 4.0 协议的情况下转载。

这是我们共同度过的

第 1332 天