记一次 Go 服务 OOM 排查

4 阅读13分钟

OOM 事故复盘报告

本文是使用 incident-postmortem skill 写成的专业bug复盘报告。

想了解更多 Skill 的最佳实践,参考:github.com/johnqtcg/aw…

这个项目的核心目标不是”展示 prompt 怎么写”,而是回答三个更难的问题:

  • 高质量 Skill 到底应该如何设计?
  • 写出来之后如何证明它真的有效?
  • 如何把它融入日常开发与团队工作流?

欢迎大家提交 PR,如果觉得有用,请给 star。


一、事故摘要

字段内容
事故编号INC-2025-1117-ACS-OOM
严重级别SEV-2(重大,内部测试环境服务完全中断)
影响时段2025-11-17 08:00 UTC — 2025-11-18 14:00 UTC(首次修复)
受影响服务acs_app(Dev 环境,全量接口)
持续时长约 30 小时(从 OOM Kill 到 stmtCacheSize 修复生效)
当前状态已解决(含后续 dpiObject 泄漏二次修复)

一句话概述:QA 以 500 并发对 ACS 服务进行持续一天的集成测试后,服务进程因内存持续攀升触发 OOM Kill。Go 堆内存本身完全正常,真正的内存消耗来自 Oracle OCI 驱动在 Go GC 管控范围之外的两处堆外内存问题:Statement Cache 参数设置过大,以及 godror.Object(dpiObject)未被显式关闭导致的原生句柄泄漏。


二、模式与深度

模式:Draft(根据原始事故记录从头撰写) 深度:Standard(涉及两个相互独立的根因,且服务已上线,影响边界清晰)


三、事件时间线

所有时间统一为 UTC(服务器时区 JST UTC+9,已换算)。来源标注于每条目后。

时间(UTC)阶段事件来源
11-17 ~00:00DETECTION 前QA 以 500 并发启动全量接口集成测试,覆盖所有接口,计划持续一整天测试计划
11-17 ~08:00DETECTIONQA 反馈接口全面超时;开发尝试 SSH 登录 dev 服务器失败,服务已无响应QA 反馈 + 开发操作记录
11-17 ~08:30RESPONSE运维重启 acs_app 进程,确认进程已被 OOM Kill运维操作记录
11-17 ~09:00RESPONSE服务重启后开发登录排查,pprof heap 显示 Go 堆 inuse ~189 MB,alloc_space 累计 ~600 MB,Go 侧堆内存完全正常go tool pprof /debug/pprof/heap
11-17 ~09:10RESPONSEMetrics 监控(metrics2)显示 RSS 在 OOM Kill 前已达 6 GB,超过配置阈值(可用内存 7.4 GB),确认存在堆外内存泄漏Prometheus metrics
11-17 ~09:30RESPONSE服务重启后恢复可用,但无法确认根因,决定次日复现开发决策
11-17 ~09:30RECOVERY(临时)服务重启后 Dev 环境恢复,测试暂停运维操作记录
11-18 05:15RESPONSE新增 pprof 监控端点(:6060)后重新部署,让 QA 重启全量压测go tool pprof allocs 时间戳
11-18 06:15RESPONSE压测 1 小时后内存涨至 2.5 GB,无下降迹象,正式确认为内存泄漏而非一次性峰值pidstat 趋势
11-18 06:30RESPONSEpprof allocs top10 显示 godror.(*statement).bindVars 累计 30 GB、encoding/json 累计 42 GB 为主要分配热点;同时 runtime/trace.readBatch 占用显著,注释掉 FlightRecorder 代码pprof alloc_space
11-18 07:00RESPONSE将 types 包从标准 encoding/json 替换为 bytedance/sonic,减少 JSON 序列化内存压力(优化项,非泄漏修复)代码变更
11-18 07:30RESPONSEpidstat -r 观察 RSS 每 5 秒稳定增加约 1 MB,增长趋势平稳,排除突发性分配pidstat 输出
11-18 08:00RESPONSEpmap -x 分析:Oracle 核心库本体(libclntsh.so.19.1 + libociei.so)仅占约 20–30 MB;但存在约 20 个大小各 64 MB 的匿名 rw 内存区域,合计约 1.2 GB——这些区域映射 Oracle OCI 原生内存池,不受 Go GC 管理pmap 输出
11-18 08:30RESPONSE查看 DB 配置:stmtCacheSize=256 enableStmtCache=true poolMaxSessions=400 enableClientResultCache=true服务配置文件
11-18 08:45RESPONSE尝试:将 poolMaxSessions 从 400 降至 100,重新压测 10 分钟——内存无明显变化,排除连接池数量为主因压测观测
11-18 09:10RESPONSE尝试:关闭 enableClientResultCache,重新压测 10 分钟——内存仍无下降,排除结果集缓存为主因压测观测
11-18 09:35RESPONSE尝试:将 stmtCacheSize 从 256 降至 64,重新压测 10 分钟——内存占用从 4.1 GB 降至约 1 GB,降幅正好为 1/4,与参数调整比例完全吻合压测观测 + Prometheus
11-18 10:00RESPONSE根因一确认:stmtCacheSize=256 过大,导致 Oracle OCI 侧 Statement Cache 随并发连接数线性膨胀
11-18 ~11:00RECOVERY(正式)调整参数后重新部署,Dev 环境压测通过,服务正式恢复部署记录
11-22 ~02:00(约)DETECTION(二次)服务上线 4 天后,Grafana 监控显示内存从 0 持续攀升至 5.6 GB,速率低于第一次但趋势一致——确认仍有泄漏Grafana 监控
11-22 上午RESPONSE代码审查定位到 callAccountChangeProc 方法:循环调用 godror.NewObject() 后未显式调用 .Close(),dpiObject 原生句柄持续累积代码审查 + security-review skill
11-22 下午RESPONSE修复:在所有分支(成功路径与错误路径)为每次 NewObject() 调用加入显式 defer obj.Close() 或立即 .Close()代码变更
11-22 晚RECOVERY(二次)修复后重新部署,Grafana 内存曲线趋于平稳,二次泄漏问题解决Grafana 监控

四、根因分析

根因一:Oracle Statement Cache 参数过大,导致 OCI 堆外内存随并发线性膨胀

5-Why 分析

  1. Why ACS 服务被 OOM Kill? → 进程 RSS 在压测期间持续增长至 6 GB+,超过系统阈值,被内核终止。

  2. Why RSS 持续增长而 Go heap 正常? → 内存增长来自 Go GC 管控范围之外:Oracle OCI 驱动在 C 原生层维护 Statement Cache,其内存由 OCI 分配器直接向 OS 申请,Go runtime 既无法感知,也无法回收。

  3. Why Oracle OCI Statement Cache 占用如此之大? → stmtCacheSize=256 配置与 poolMaxSessions=400 叠加:每个数据库会话最多缓存 256 条已编译 SQL 语句,高并发下全部 400 个会话均被激活,理论峰值约 400 × 256 × N(每条 SQL 的 OCI 内存)= 数 GB 级别的原生内存。

  4. Why 该参数被设置为 256? → 配置缺乏针对 OCI 内存行为的分析与基线,256 是参考通用建议值,未结合实际并发规模和内存预算做合理裁剪。

  5. Why 没有在集成测试前发现这一风险? → OCI 层的堆外内存不在 Go pprof 的观测范围内,系统也未配置 RSS 级别的预警阈值;在单元测试和 API 测试阶段并发量低,问题未能暴露。

根因陈述:数据库连接配置中 stmtCacheSize 的默认值设置缺乏对 Oracle OCI 原生内存行为的定量分析,在高并发场景下导致 OCI Statement Cache 超出系统内存容量。


根因二:godror.Object 未显式关闭,原生句柄持续泄漏

5-Why 分析

  1. Why 服务上线后内存再次持续增长? → Oracle 原生对象句柄(dpiObject)在每次调用 callAccountChangeProc 时通过 NewObject() 创建,但未被释放。

  2. Why dpiObject 没有被释放? → 代码在循环中调用 NewObject() 后,仅通过 defer conn.Close() 释放了数据库连接,但没有对 godror.Object 本身调用 .Close()

  3. Why 漏掉了 Object 的 Close 调用? → godror 对原生 Oracle 对象的生命周期管理与 Go 标准资源(如 sql.Rowsos.File)不同:它不会通过 GC finalizer 自动释放,需要显式关闭,这一行为与常见 Go 惯例存在差异,容易遗漏。

  4. Why 代码评审时未发现这一问题? → 代码评审流程以人工逻辑审查为主,缺少针对资源生命周期(特别是 CGO/原生 driver 资源)的自动化检查机制。

  5. Why CI 流程没有拦截这类问题? → 事故发生时,CI 流程仅包含 fmt、linter 和单元测试,缺少专项安全审查和资源泄漏分析环节。

根因陈述:代码评审流程和 CI 流程均缺少针对原生资源(CGO/Oracle driver 对象)生命周期的自动化检查,导致 dpiObject 句柄泄漏在上线前未被发现。


贡献因素

  • 监控盲区:系统仅监控 Go 堆内存(pprof),未配置进程级 RSS 的告警阈值,导致 OCI 堆外内存增长在 OOM Kill 发生前无任何预警。
  • 压测周期不足:集成测试阶段未包含持续 24 小时以上的长时稳定性压测,仅 500 并发短时压测无法暴露慢速资源泄漏。
  • 配置缺乏文档和基线stmtCacheSize 等 OCI 相关参数没有对应的性能基线文档,调整缺乏依据。
  • FlightRecorder 副作用runtime/trace.FlightRecorder(Go 1.25 新特性)在高并发下产生显著内存分配,混淆了早期排查视线(虽非根因,但干扰了诊断效率)。

五、影响评估

维度数据
环境Dev 测试环境(非生产)
影响阶段集成测试阶段(服务尚未对外上线)
首次中断时长约 30 小时(从 OOM Kill 到 stmtCacheSize 修复部署)
二次中断无中断,但上线后 4 天存在持续内存泄漏(生产 S2 服务器)
接口可用性Dev 环境全量接口:OOM 期间 100% 不可用;生产侧(二次泄漏期间)服务可用但内存持续增长,存在再次 OOM 风险
用户影响内部 QA 和开发团队测试流程中断;生产上线后潜在风险(如未及时发现二次泄漏,将在数天内重现 OOM)
SLO 影响Dev 环境无 SLO 约束;生产侧在二次修复前存在可量化的内存泄漏风险

六、哪些地方做得好

  • 根因排查方向判断准确:第一时间通过 pprof heap 排除了 Go 堆的嫌疑,将排查聚焦到堆外内存,避免了在错误方向上浪费时间。
  • pmap 分析决策有效:在 Go 工具链无法直接观测 OCI 内存的情况下,主动使用操作系统级工具(pmap、pidstat)定位到匿名内存区域,思路清晰。
  • 对照实验方法规范:排查 DB 参数时逐一控制变量(poolMaxSessions → enableClientResultCache → stmtCacheSize),每次修改后独立压测验证,保证了结论的可靠性。
  • 二次泄漏主动发现:上线后持续观测 Grafana 内存曲线,在问题扩大前主动发现了 dpiObject 泄漏,而非等到再次 OOM Kill 才响应。
  • 工具化沉淀快速落地:基于本次问题,快速封装了 go security-review skill,并将其纳入 CI 流程,形成了可复用的防护机制。

七、改进行动项

编号类别描述负责人截止时间追踪票据
A-01预防stmtCacheSizepoolMaxSessions 等 OCI 关键参数纳入配置文档,明确各参数的内存影响公式和推荐范围,作为配置变更的必要基线依据后端负责人
A-02预防在 CI 流程中集成 go security-review 自动化安全检查,重点覆盖 CGO/原生 driver 资源的生命周期(NewObject/Close、sql.Rows.Close、resp.Body.Close 等),未通过则阻止合并DevOps 负责人
A-03预防在代码评审 checklist 中增加"原生资源显式关闭"条目,特别针对 godror.Object、godror.Conn 等非标准资源;新入库的 Oracle 存储过程调用代码须经专项 review后端负责人
A-04检测在 Prometheus + Grafana 中为 acs_app 添加进程级 RSS 告警规则:RSS 超过可用内存 50% 时触发 Warning,超过 70% 时触发 Critical,缩短从泄漏发生到告警通知的时间窗口SRE / 监控负责人
A-05检测在压测 pipeline 中增加"长时稳定性"阶段:以代表性并发量(如 300–500)持续压测 ≥ 2 小时,观察 RSS 增长趋势,超过设定阈值则压测失败,阻止进入集成测试QA 负责人
A-06缓解为 acs_app 配置进程级内存 limit(如 cgroup 或 k8s resources.limits.memory),并配置自动重启策略,将非预期 OOM 的恢复时间从手动运维介入缩短到分钟级别DevOps 负责人
A-07预防完善 CI 流程标准化文档,将完整流程(fmt → goimports → linter → go test -race(覆盖率 ≥ 80%)→ code-review → security-review)作为合并必要条件,写入 CONTRIBUTING.md后端负责人

八、经验教训

核心教训一:Go pprof 看不见 CGO/原生 Driver 的内存

Oracle OCI、SQLite、某些 C 扩展库等通过 CGO 分配的内存完全游离在 Go GC 之外。当服务的 Go heap 正常但 RSS 持续增长时,必须首先用 OS 级工具(pmap -x/proc/<pid>/smapspidstat -r)定位堆外区域,而非在 Go 工具链内反复寻找。这是使用 CGO driver 的工程师需要建立的基本心智模型。

核心教训二:原生资源的生命周期不能依赖 GC

godror 文档明确指出 godror.Object 需要显式 Close(),且默认无 finalizer 兜底。凡是直接操作 C 原生句柄的库(数据库 object、statement handle、游标等),其资源管理模式均与普通 Go struct 不同,必须在所有代码路径(正常、错误、panic)上确保显式关闭。对这类模式的识别不能依赖个人经验,应通过工具化(静态分析、CI 门控)形成系统性保障。

核心教训三:OCI 参数需要结合实际并发规模定量分析

stmtCacheSize × maxSessions 决定了 Oracle OCI 侧的内存上限,这一乘法关系在低并发开发环境下完全无感,但在 400 并发下会产生 GB 级别的原生内存占用。数据库 driver 相关参数的调整,不能只参考默认值或通用建议,需要结合目标并发量进行内存估算和压测验证,并将结论固化到配置文档。

相关事故

暂无历史同类事故记录。本次事故可作为"Go 服务堆外内存增长"类问题的参考案例,建议在团队 Wiki 中以 oracle-oci-memorygodror-resource-leak 为标签归档,便于后续检索。


评分卡

维度项目状态
Critical时间线(含 UTC 时间戳和来源)
Critical根因识别(系统性,深度 ≥ 3)
Critical行动项含责任人和截止时间✅(Owner 已列,截止时间待团队填入)
Standard影响量化(时长、服务、错误率)
Standard5-Why 深度 ≥ 3✅(两个根因均达 5 层)
Standard根因与贡献因素区分
Standard时间线覆盖检测/响应/恢复三阶段
Standard全程无归责语言
Hygiene"做得好"章节存在
Hygiene行动项按预防/检测/缓解分类
Hygiene关联历史事故✅(首次发生,已说明)
Hygiene追踪机制定义✅(票据列已预留,待团队填入)

总评:12/12 — Critical 3/3,Standard 5/5,Hygiene 4/4 — PASS