7.1 KiB
7.1 KiB
| 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)。
排查时序(先做这五步,别跳)
- 拉现状:进程/端口/health/最近 journal(
pgrep -fa、ss -tlnp、curl health、journalctl --since N min ago)。铁律:不许凭记忆/假设下结论。 - 时间线还原:把 journal 按时间排开,找"并发事件窗口"——往往不是单一原因,是几件事同时发生(如 distill 满载 llama + N 个并发 recall 同时打进来)。
- 区分瞬时 vs 持续:负载过去后重测一次(curl 手动触发同 API)。瞬时恢复 = 拥塞;持续 = 有永久挂起点。别在风暴进行中下根因结论——先等它跑完一轮。
- 查四大坑(见下),逐个排除。
- 修复后必须实测验证 + journal 证据,不空口说"修好了"。
坑 1:http.Client{} 无 Timeout → 下游半开 TCP 永久挂起
- 症状:服务 CPU 不高但某 API 偶发 20s+ 不返回(直到客户端超时断开);服务端日志看不出错误。
- 根因:
&http.Client{}默认无超时。下游(bge-proxy→远端服务器)TCP 半开时请求永久挂起,goroutine 堆积。 - 修复:
&http.Client{Timeout: 8 * time.Second}(按正常延迟留余量:正常 21-150ms,卡顿时 8s 快速失败让上层降级)。 - 排查 grep:
grep -n "http.Client{}" internal/——裸Client{}一律嫌疑。 - 注意 import:补
"time"。
坑 2:O(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 nodes:3min → <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 时类型要对齐。
坑 6:Rust sidecar 重编译并行 OOM(cgroup 内)
- 症状:
cargo build多路并行(CARGO_BUILD_JOBS=4)跑几分钟后被 SIGKILL(exit -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-xxx。Text file busy 时先 stop 再 cp。
关联
- 织忆业务细节/触发器 cooldown 配置表/decay 验收 →
zhiyiskill(非 curator-managed,需 adopt 才能改) - 本会话完整实录与时间线 → references/2026-09-06-zhiyid-pagerank-storm.md