xiaowei-system/skills/devops/go-backend-perf-storm-debug.../SKILL.md

7.1 KiB
Raw Blame History

name version description
go-backend-perf-storm-debugging 1.0.0 Use when Go 后端 CPU高/超时/异常。六坑http无超时/O(V²)/零值触发风暴/超时误报/缓存当数据源/Rust编译并行OOM。

Go 后端性能/风暴故障排查

适用:本地 Go 常驻服务zhiyid、gateway 类)出现 CPU 100%、请求超时、监控报"异常/API=unknown"。2026-09-06 zhiyid 生产实战提炼(完整实录见 references/2026-09-06-zhiyid-pagerank-storm.md

排查时序(先做这五步,别跳)

  1. 拉现状:进程/端口/health/最近 journalpgrep -fass -tlnpcurl healthjournalctl --since N min ago)。铁律:不许凭记忆/假设下结论。
  2. 时间线还原:把 journal 按时间排开,找"并发事件窗口"——往往不是单一原因,是几件事同时发生(如 distill 满载 llama + N 个并发 recall 同时打进来)。
  3. 区分瞬时 vs 持续负载过去后重测一次curl 手动触发同 API。瞬时恢复 = 拥塞;持续 = 有永久挂起点。别在风暴进行中下根因结论——先等它跑完一轮。
  4. 查四大坑(见下),逐个排除。
  5. 修复后必须实测验证 + journal 证据,不空口说"修好了"。

坑 1http.Client{} 无 Timeout → 下游半开 TCP 永久挂起

  • 症状:服务 CPU 不高但某 API 偶发 20s+ 不返回(直到客户端超时断开);服务端日志看不出错误。
  • 根因:&http.Client{} 默认无超时。下游bge-proxy→远端服务器TCP 半开时请求永久挂起goroutine 堆积。
  • 修复:&http.Client{Timeout: 8 * time.Second}(按正常延迟留余量:正常 21-150ms卡顿时 8s 快速失败让上层降级)。
  • 排查 grepgrep -n "http.Client{}" internal/——裸 Client{} 一律嫌疑。
  • 注意 import"time"

坑 2O(V²) 图/排序算法随数据量超线性膨胀

  • 症状:重启后某 full 任务单次 CPU 从 20-30s 涨到 3 分钟(节点 10k→17.6k,仅 +70% 数据却慢 6 倍)。
  • 根因PageRank 每目标节点遍历全部源节点找边匹配 → iterations × V²。
  • 识别:嵌套循环 for _, node := range nodes { for src, edges := range outEdges { for _, e := range edges { if e.target == node ... = O(V²) 全源匹配(伪装成按边遍历)。
  • 修复:贡献累加法 O(V+E)——先归一化每源节点 totalWt沿出边把 damping*rank[src]*e.weight/totalWt 累加到 contrib[target],循环外一次性赋新 ranks。17628 nodes3min → <1s。
  • 教训:任何"数据涨一点就慢很多倍"的性能退化,先怀疑 O(V²) 全源匹配,重写为两遍遍历(先算源贡献、再沿边累加)。

坑 3重启零值状态 + 多套独立触发器 → 连锁 full 风暴

  • 症状:重启后服务 CPU 100% 持续 10+ 分钟journal 显示同一任务consolidation full在 30s~5min 内重复触发多次,每次带 3min 的 PageRank。
  • 根因LastFiredAt 只存内存不持久化 → 重启后零值 → CanFire 立即通过;若存在两套独立触发循环(如 routes 30s ticker + executor 5min ticker且各自维护 LastFiredAt会各自 fire → N 次 full 并发叠加。
  • 修复两层:(a) 让 full 任务本身变快(坑 2→ 风暴窗口从分钟级变秒级,多跑也无感;(b) 根治:重启时给所有触发器 LastFiredAt=now防启动全 fire或持久化触发状态。
  • 验证:看"是否会收敛"(所有零值 trigger fire 完后 cooldown 生效即停),不只数"发生了几次"——设计行为 vs 无限循环的差别就在收敛性。
  • 排查:找全所有触发循环,别只看一套。

坑 4客户端超时 vs 服务端慢 的误报

  • 症状:监控报"服务异常/API=unknown"服务端其实健康——只是某时刻并发负载distill 满载 + 3 并发 recall把响应推到客户端 read timeout 之外,客户端判死。
  • 修复:客户端超时按"满载最坏延迟"配10s→20s不是按正常延迟配服务端给下游 http 加超时(坑 1防永久挂起放大。
  • 判断:服务端实测冷缓存 recall 7.6s < 新超时 20s → 不会再误报。

坑 5进程缓存当数据源 → 只扫到近期触碰,看不到全量

  • 症状后台任务decay/遗忘/批量扫描)"在跑"但永远处理不到老数据——重启后 scanned 只有个位数3而非全表量级2000+)。
  • 根因:函数遍历进程内 _local.memories 这类增量缓存(只在 commit/recall/search 时触碰填充),不是存储层全表。重启缓存空 → 只积累少量近期条目 → 到期老数据永远不在候选里,业务闭环静默断掉。
  • 识别:查数据源——for id, m := range _local.memories 这类缓存遍历 vs 真正查库;缓存有没有启动全量加载。
  • 修复:候选源升级为存储层全表扫描(安全版),缓存降级为 fallback(a) scan API 强制 limit 参数IPC 层钳制硬上限,如 5000(b) 跳过重量列1024 维 vector 全表序列化 ≈ 12MB+IPC JSON 必卡);(c) 真读业务字段(旧 scan 函数 last_recalled_at: String::new() 恒空串 → 下游判定失真——警惕"字段存在但恒空"的假读取);(d) 所有消费方各自带截断护栏merge 桶 500 / conflict 500 / sanity 1000防输入源变大后回归风暴(e) IPC 失败/空 → fallback 缓存遍历保可用。
  • 教训:"任务在跑" ≠ "任务覆盖全量"。验收批量任务先看 scanned 数量级对不对,再信 forgotten/processed 结果。
  • 附加陷阱scan 返回字段类型与消费方断言必须一致——Go map 里 recall_count 存 int64 但消费方 .(int) 断言 → 静默归 0。跨层传 map 时类型要对齐。

坑 6Rust sidecar 重编译并行 OOMcgroup 内)

  • 症状:cargo build 多路并行CARGO_BUILD_JOBS=4跑几分钟后被 SIGKILLexit -9无编译错误输出dmesg 显示 Memory cgroup out of memory: Killed process ... (rustc/coordinator)oom_score_adj=200。
  • 根因:多个 rustc 并行峰值内存lancedb 依赖单 crate RSS ~1GB+)叠加超 cgroup 限额15G 机器 free 2.2G + swap 满时尤甚)。
  • 修复:CARGO_BUILD_JOBS=1 cargo build --release 单线程重跑——被杀前的增量编译仍有效单线程续编即可lancedb 大依赖全量约 14min增量后 ~5min
  • 验证:编译中 ps -o pcpu,rss -C rustc 看单进程 RSS < 可用内存cargo 被 kill 后 pgrep -fa cargo|rustc 应无残留。
  • 教训:本机 Rust 重编译默认单线程;任何"cargo 无输出被杀"先查 dmesg OOM 再决定重跑参数。

部署纪律(改 Go 服务二进制)

备份 → systemctl --user stop → cp → start → health curl → 功能实测(手动触发 API + journal 证据)→ commit 信息带实测数据。生产 binary 先备份 .bak-YYYYMMDD-pre-xxxText file busy 时先 stop 再 cp。

关联

  • 织忆业务细节/触发器 cooldown 配置表/decay 验收 → zhiyi skill非 curator-managed需 adopt 才能改)
  • 本会话完整实录与时间线 → references/2026-09-06-zhiyid-pagerank-storm.md