glogcenter 是一个轻量的集中式日志中心(Go 写的,部署简单,开箱即用),我在项目里用它收日志,整体体验不错。不过真实用起来,先后踩到了两个问题:一个是内存持续增长,一个是集群模式下用户管理失效。两次都是翻源码定位到根因,各提了一个 PR——#92 和 #93,现在都已合并。这篇文章把两次排查的过程写下来。
问题一:内存持续增长,最后定位到 2 行代码
现象
长期运行时内存单调增长、不回落。怀疑方向很快收敛到 goroutine 泄漏(这个案例也关联了项目的 #70)。
读源码:泄漏藏在「关闭→重开」的生命周期里
glogcenter 的日志仓有一个闲置自动关闭机制(autoCloseWhenMaxIdle,默认闲置 300 秒):日志仓闲置后自动关闭,再次写入时重新打开、新建一套存储结构。问题在于——旧存储的消费协程 readyGo 从不退出。每次「关闭→重开」周期净泄漏一个 goroutine,连同 64 槽 channel 和整个存储结构体的引用一起挂在那里。按日分仓 + 断续写入是很常见的场景,每个闲置间隙就累积一个,长期运行自然持续增长。
根因:Go 里 break 退不出 for
真正的原因是一个经典的 Go 语义坑。readyGo 是一个 for-select 循环,里面有两处在收到 nil(Close 的退出信号)后写了 break——但在 Go 语义里,select 中的 break 只退出 select,不会退出外层的 for;而整个函数没有任何 return。于是每次 Close 之后,消费协程必然存活,结局分两种:
- 闲置关闭(常态):关闭时索引已建完,协程停驻在内层
<-s.storeChan上永久阻塞——通道永远不会被关闭(nil只在closing=true之后到达,判定分支不可达); - 关闭时仍有未建索引的日志:建索引因存储已关闭持续报错,协程热循环空转,还刷错误日志。
生产日志里有实锤:关闭LogDataStorage 之后,仍会出现一条 空闲等待接收日志——那就是泄漏的协程在关闭后重新停驻。
修复与验证
修复本身小到惊人:两处 break 改成 return,一共 2 行。热路径行为和「关闭后按需自动重开」的语义完全不变。
改之前先写测试复现:新增一个 goroutine 计数法测试,分两阶段——忙时立即关闭(覆盖 select 分支退出路径)、闲置自动关闭(覆盖内层阻塞接收这条生产路径)。修复前测试稳定失败(delta = +1,泄漏复现),修复后两个阶段都是 delta = +0。go vet 干净,-race 无新竞争。
→ PR #92(已合并)
问题二:集群模式下,用户管理静默失效
现象
开启集群模式(GLC_CLUSTER_MODE=true)后,一组「灵异现象」:
- 新建用户只在操作节点存在,其他节点无法登录;
- 改密不同步,各节点密码不一致;
- 删除用户不传播,被删用户在其他节点依旧可登录——这是权限控制失效,性质就严重了;
- 登录会话不共享,经负载均衡到其他节点的请求被拒。
关键发现:载荷全打到了「日志转发」接口
排查发现,用户管理、改密、删除、登录会话这些节点间转发的载荷,实际全部被投递到了日志转发接口 /glc/v1/log/transferAdd——载荷与日志模型字段毫无重叠、Text 为空,于是被静默丢弃,接口还返回 Ok。
根因:参数从没被用过
源码里最耐人寻味的一点:TransferGlc(uri, jsonlog) 的 uri 参数从未被使用——函数体里硬编码转发到 /v1/log/transferAdd。5 个调用方传入了 4 种 uri,5 个 transfer 控制器和路由全部就绪,唯独函数体没用 uri。日志转发「恰好」因为硬编码值等于 LogTransferAdd 而看起来一切正常,把这个缺陷掩盖得严严实实。
顺着读下去还发现两个连带 bug:
UserDelController的转发用错了常量——复制粘贴错误,删用户载荷会打到 transferLogin 上,替被删用户在各节点建立会话;- 登录转发控制器缺登录守卫:会话缓存仅在开启登录时初始化,登录关闭的节点被命中会因 nil 缓存 panic(登录控制器本身有守卫,转发版漏了)。
修复与验证
修复共 3 处 5 行:函数体改用 uri 参数(恢复全部 4 类传播)、删除转发常量改正、转发版补上与登录控制器一致的登录守卫。测试同样先行:三阶段回归测试,修复前都以正确原因失败,修复后全过,包括「删除用户后,伪集群节点实际收到 /glc/v1/sysuser/transferDel」这类端到端断言。
→ PR #93(已合并)
两次排查攒下的心得
- 现象先行,根因在后。「内存涨」和「用户不同步」表面毫无关系,最后一个落在 2 行控制流,一个落在一个从未被使用的参数上。不读源码,永远只能停留在重启大法。
- 最危险的是静默丢弃。返回 Ok 但什么都不做的接口,会把结构性缺陷掩护成「偶尔不生效」。
TransferGlc的 uri 参数被无视后,日志链路恰好正常,其余四条链路全军覆没却无人报警。 - TDD 是 PR 的通用语言。先写复现测试,让它在修复前以正确的原因失败,修复后通过——维护者审 PR 时不用信任你的描述,看测试就够了。
- 最小改动 + 显式兼容性说明。两个 PR 的代码改动分别只有 2 行和 5 行,其余篇幅都在说明根因、测试方法与兼容性。改动越小、论证越清楚,合并得越快。
- Go 的 for-select 里,
break退不出for。这个坑不新,但每次遇到还是值得写进 PR 描述里,帮后来的人省一次排查。
写在最后
给自己的建议(也送给遇到类似问题的你):遇到开源组件的异常,别急着绕过去——现象 → 最小复现 → 读源码 → 定位根因,这条路走通一次,下一次就快了。两个 PR 的完整描述都在: