生产观察截止:2026-09-14 13:38。 本报告描述该时间窗口,不代表此后的持续可用性。
关联变更: PR #673。
Executive summary
本次事件是高负载下暴露的搜索成本与容量保护问题。课程目录绕过已有 Meilisearch,重复执行跨表模糊查询;代表性 SQL 的 JIT 编译占执行时间约 92.8%。请求缺少贯穿数据库的取消边界,昂贵查询与普通业务竞争同一个小连接池;空闲标签页轮询完整隐私页面进一步增加流量。内核日志另证实应用曾因全局内存耗尽被杀死。紧急处置在不扩容的条件下关闭 JIT、复用 Meili、补齐索引并限制搜索并发与时长。恢复后 51 分钟内,61,238 次请求出现 6 次 5xx,占 0.0098%,主站没有再次重启;但课程 AI 摘要仍有少量 502(正常限流)。
Summary
1. 影响范围与统计口径
用户可见影响包括课程搜索变慢、课程详情及首页等页面间歇失败。网关样本中的失败还涉及全站搜索、隐私页面、通知和图片访问,说明故障影响已超出课程列表本身。
| 完整小时 |
全部请求 |
近似动态请求 |
课程目录请求 |
502 |
504 |
502+504/全部请求 |
| 09:00–09:59 |
157,019 |
49,905 |
5,060 |
4,259 |
370 |
2.948% |
| 10:00–10:59 |
155,832 |
52,630 |
5,629 |
6,706 |
712 |
4.760% |
| 11:00–11:59 |
66,007 |
26,260 |
1,661 |
23 |
0 |
0.0348% |
| 合计 |
378,858 |
128,795 |
12,350 |
10,988 |
1,082 |
3.186% |
口径说明:
- 历史网关分析读取日志尾部 128 MiB,覆盖 08 点的一部分至约 12:10;仅以上三个完整小时用于小时比较。
- “全部请求”包含静态资源;动态请求按路径排除资源类请求近似统计。课程目录指
/courses 与 /api/forum/courses,不含详情、评价和摘要接口。
- 错误比例是请求口径,不能直接解释为受影响用户比例或站点可用性 SLO;同一用户重试会产生多次请求。
- 10 点平均约 43.3 次全部请求/秒、14.6 次动态请求/秒、1.56 次课程目录请求/秒。
2. 运行环境
| 项目 |
故障诊断时确认的配置/状态 |
对事件的意义 |
| 主机资源 |
2 vCPU;系统可用总内存约 1.635 GiB;swap 约 2 GiB |
计算与内存余量有限 |
| 同机服务 |
主站、开发实例、PostgreSQL 16、Meilisearch 1.11、OpenResty |
各服务共享主机资源 |
| 主库应用连接池 |
最大 5、空闲 3、连接生命周期 300 秒 |
慢查询会占用普通业务所需连接 |
| PostgreSQL |
statement_timeout=0;未加载 pg_stat_statements |
缺少数据库级查询时长兜底及完整 SQL 聚合观测 |
| HTTP 服务 |
WriteTimeout=10s |
限制响应写入,不自动等价于业务查询取消 |
| 课程限流 |
每 IP 120 次/60 秒;无全局课程并发上限 |
能约束单来源,不能控制多来源总资源占用 |
| 资源保护 |
诊断时容器无硬 CPU/内存限额,应用未设置 GOMEMLIMIT |
缺少各服务之间的明确资源预算 |
以上为本事件期间的运行快照,不将其视为永久配置规范。
Timeline
| 时间(UTC+8) |
事件与证据 |
判读 |
| 08:56 |
聚合样本已出现单分钟 282 次 502 |
大量错误早于后续 OOM;不能把所有失败归于一次重启 |
| 09:00–10:59 |
两个完整小时共 312,851 次请求,10,965 次 502、1,082 次 504 |
存在持续一段时间的间歇性服务退化 |
| 10:24:54 |
内核记录全局 OOM,杀死主站 Go 进程;swap 几乎耗尽 |
确认一次资源耗尽导致的进程终止 |
| 10:24:57 |
主站容器重新启动 |
重启恢复进程,但未解决慢查询来源 |
| 10:24 整分钟 |
2,402 次请求中 616 次 502,占 25.65% |
与 OOM 同分钟出现显著错误峰值 |
| 12:09 |
仍有 79 次 502,网关记录上游提前关闭 |
重启后仍有故障,不能以存活状态判断恢复 |
| 诊断后、镜像切换前 |
确认 JIT 成本后设置主库新连接默认 jit=off;补建教师反查索引 |
先实施已验证、无需扩容的止血措施;具体执行分钟未完整留存 |
| 镜像切换前 |
使用新二进制原地回填 11,540 条课程,58 批,失败 0 |
避免清空线上搜索索引造成额外不可用 |
| 12:46:39 |
新主站容器启动 |
本次采用单实例替换,有启动过渡窗口 |
| 12:46:50 |
健康与业务探针通过,部署记录成功 |
索引、业务返回和实际二进制校验均完成 |
| 12:47–12:49 |
三个完整分钟共 4,143 次请求,无 502/503/504 |
支持初步恢复,不足以证明长期无故障 |
| 13:24 |
仓库补充索引刷新审查修正 c15d94d0 |
该后续修正尚未部署到本报告核验的生产实例 |
| 13:38 |
扩展核验覆盖恢复后 51 个完整分钟 |
主站健康、无再次重启,但发现少量残余错误 |
12:46 整分钟记录 13 次 502、183 次 503。这些错误与部署启动窗口重叠,应计入变更影响;没有逐请求追踪,不能断言每一条都由替换动作单独造成。
Root cause
3. 直接性能瓶颈:复杂关键词查询触发高成本 JIT
原课程搜索对名称、课号、拼音、别名、教学班及教师等字段组合 %keyword% 包含匹配,并通过 OR 与 EXISTS 跨表关联。列表先计算准确总数,再以相同复杂条件查询一页数据;LIMIT 20 并不意味着数据库只处理 20 行。
对生产数据使用自选关键词“高等数学”进行限时、只读验证,结果如下。测试未重放用户请求,也没有并发压测。
| 试验 |
执行时间 |
备注 |
相同 COUNT,jit=on |
1,622.560 ms |
关闭逐节点计时 |
相同 COUNT,jit=off |
31.803 ms |
执行计划形状与共享缓冲命中相同 |
另一次 jit=on,开启节点计时 |
961.844 ms |
其中 JIT 编译 892.162 ms,约 92.8% |
前两次查询均返回 69,执行阶段均为 1,281 次 shared buffer hit,没有数据块物理读取;JIT 试验生成 60 个函数,计划成本约 763,960,超过内联和优化阈值 500,000。该证据支持:此查询的大部分时间消耗在编译开销,而非读取磁盘数据。
PostgreSQL 官方说明,JIT 根据计划估算成本触发,短查询的编译开销可能超过收益,与本次计时结果一致。PostgreSQL 16:何时使用 JIT
1,622.560 ÷ 31.803 ≈ 51.0 是一次代表性 SQL 对照的墙钟时间比值。
4. 扫描放大与索引缺口
| 表 |
90 秒内顺序扫描读取行次数 |
course_offering |
4,426,080 |
course |
3,450,460 |
course_offering_instructor |
2,492,599 |
course_alias |
872,774 |
course_instructor |
645,724 |
course_review_stats |
463,937 |
posts |
29,260 |
topics |
8,600 |
| 图示八表合计 |
12,389,434 |
图示合计约 13.77 万次行读取/秒,其中六张课程相关表占 99.69%。seq_tup_read 反映顺序扫描累计读取的行次数,同一行可以被反复计算。
另有三项结构性开销得到源码及数据库元数据支持:
- 普通 B-tree 索引未解决这些包含匹配,实际执行计划存在多处顺序扫描。
- 教师关联索引名含
instructor,实际却落在 offering_id 上,缺少所需的 instructor_id 反查索引。该问题会增加部分关联查询成本,但不足以解释全部故障。
- 列表详情补齐已采用批量查询,并非逐卡片 N+1;然而仍有多轮数据库往返,SSR 还重复读取院系、学期、校区选项。
5. 故障扩散:共享连接池、超时与内存压力
故障期间课程链路没有将 HTTP 请求 context 贯穿到数据库查询。客户端离开或响应写入超时后,查询与等待缺少可靠的取消边界。五个主库连接被占用时,用户会话、普通页面和后台任务也会排队。
应用日志尾部 64 MiB 覆盖 10:00:40–12:10:01,其中记录 94,432 条慢 SQL。以下只代表进入慢日志的样本:
| 查询位置/用途 |
慢日志条数 |
样本中位数 |
样本 P95 |
最大值 |
| 课程总数 COUNT |
5,699 |
1.838 s |
14.564 s |
100.406 s |
| 课程分页查询 |
5,667 |
2.028 s |
12.873 s |
61.434 s |
| 按 ID 批量读取教师 |
5,629 |
1.738 s |
14.761 s |
66.557 s |
| 用户会话读取 |
3,058 |
1.598 s |
14.414 s |
69.760 s |
GORM 记录的是查询调用墙钟时间,可能包含连接等待、调度和执行等开销,不是纯 SQL CPU 时间。这些样本的 P95 也不能当作全量接口 P95。简单查询同步变慢,支持共享资源拥堵的判断,而非“每张表都需要增加索引”。
内核记录证实 10:24:54 发生 OOM:当时 swap 只剩约 240 KiB;被杀应用约有 314 MiB 常驻内存及约 1.44 GiB 换出页。10:24 同分钟网关错误包括连接重置 295 次、连接拒绝 14 次和上游提前关闭 307 次,共 616 次,与进程终止和重启相吻合。
以下图中,实线表示已观测或由源码确认的关系,虚线表示尚缺堆/goroutine 时序数据验证的资源演化推断:
flowchart TD
A[课程关键词跨表匹配与重复 COUNT] --> B[JIT 编译及查询占用共享资源]
C[流量与完整页面轮询] --> D[更多请求进入应用]
B --> E[数据库连接等待及业务延迟]
D --> E
F[请求取消未贯穿数据库] --> G[已失去响应价值的工作仍可继续]
E --> H[响应期限不足与上游断连]
H --> I[502 或 504]
E -.-> J[在途工作积压与内存压力]
G -.-> J
J -.-> K[已确认的全局 OOM]
K --> L[应用被杀与重启]
L --> I
证据边界: OOM 是事实,请求积压是合理的内存压力来源;但目前缺少故障前的堆剖析、goroutine 曲线和完整内存分配记录,不能确定全部内存增长来源,不能确认或排除独立内存泄漏。
6. 流量放大器:完整隐私页面轮询
历史尾部日志样本中,/privacy 被请求 60,221 次,占近似动态请求 40.46%。源码证实,空闲标签页每分钟拉取完整页面 payload,只为比较一个分析开关;页面可见性变化和恢复事件也会触发请求。该路径会构建完整页面布局,而不是返回廉价的布尔配置值。
这意味着同一用户打开多个标签页,即使没有主动操作,也会增加服务端工作。
7. 为什么已有保护未能阻止故障
| 既有机制 |
本次暴露的缺口 |
对应教训 |
| 已部署 Meili |
全站聚合搜索使用它,课程目录仍走复杂 SQL |
搜索基础设施存在,不代表所有入口已使用 |
| 每 IP 限流 |
多来源可同时正常访问;没有全局资源预算 |
滥用防护与容量保护需要分别设计 |
| HTTP 写超时 |
查询没有随请求及时取消 |
超时必须传播到实际消耗资源的操作 |
| 健康接口与进程重启 |
进程存活时,课程及普通查询仍可严重排队 |
健康验证需覆盖业务路径和共享依赖 |
| 功能与迁移测试 |
此次行为通过交付链进入生产,仍暴露真实 PG 计划/JIT 和容量问题 |
正确性证据之外,需要代表数据规模及负载下的性能证据 |
| 单实例替换 |
启动时产生可见错误窗口 |
必须记录发布影响,不能把它隐藏在恢复统计中 |
Guardrails
8. 已实施的处置及效果机制
以下为紧急部署版本 28ba5858 已完成的措施,均未增加主机资源。
| 措施 |
实际改变 |
解决的问题 |
| 关闭 JIT |
主库新连接默认关闭;应用连接也默认关闭,显式 DSN 可选择开启 |
移除已验证的高成本编译 |
| 复用 Meili |
关键词先取得课程候选 ID;补入班号及教师检索字段 |
将关键词匹配从复杂 SQL 扫描移至搜索引擎 |
| 保留数据库校验 |
PG 负责可见性、精确筛选、评分排序、总数和分页补齐 |
避免将过期索引直接当成权限与业务事实 |
| 候选完整性保护 |
上限 20,000 个 ID;索引允许额外一条以发现溢出 |
不静默截断匹配集后返回错误总数 |
| 失败时限制资源 |
已配置 Meili 若失败/结果不完整则返回 503,不回退昂贵 SQL |
避免搜索依赖故障再次压垮数据库 |
| 有界执行 |
每进程最多两条目录查询主体同时执行,查询链路四秒预算,列表与数据补齐读取携带 context |
控制连接竞争与无效积压;503 附 Retry-After: 2 |
| 索引与缓存 |
并发创建教师反查索引;筛选字典缓存五分钟 |
减少关联扫描与重复字典读取 |
| 移除页面轮询 |
配置在正常导航/刷新时更新 |
降低空闲标签页产生的后台请求 |
| 原地回填 |
不清空线上索引,更新 11,540 条文档,58 批,失败 0 |
减少迁移搜索入口时的不可用窗口 |
9. 恢复后的功能与性能验证
以下为部署后的源站单次探针,含 Meili、数据库筛选与返回组装;不是公网端到端 P95,也不是压测结果。
| 场景 |
HTTP |
源站耗时 |
| 中文关键词“高等数学” |
200 |
62.13 ms |
| 拼音首字母 |
200 |
15.04 ms |
| 无匹配结果 |
200 |
8.42 ms |
| 第二页/评分排序 |
200 |
58.79/58.42 ms |
| 仅有评价 |
200 |
19.63 ms |
| 院系/教师/学期筛选 |
200 |
57.18/63.05/58.85 ms |
| 教学班课号 |
200 |
11.83 ms |
| 无关键词目录 |
200 |
24.54 ms |
| 课程 SSR 页面 |
200 |
56.62 ms |
| 两条同时发出的不同关键词搜索 |
均为 200 |
17.80/64.47 ms |
检索语义已有变化。 同一关键词的旧 SQL 对照返回 69 条,新 Meili 候选返回 95 条,说明检索集合并不完全相同。新关键词匹配遵循 Meili 分词和容错规则;精确筛选、可见性和评分排序继续由数据库确认。当前验证通过不等于已经完成搜索相关性和准确率评估,短中文词、相近数字课号等仍应纳入专门验收。
10. 扩展观察:主要故障缓解,但不等于零错误
补充读取网关日志尾部 64 MiB,最早记录为 11:00:42,完整覆盖 12:47:00–13:38:00(右端不包含),共 51 分钟。
| 指标 |
结果 |
| 全部请求 |
61,238,平均约 20.0 次/秒 |
| 课程目录请求 |
1,598,平均约 0.52 次/秒 |
| HTTP 200 |
60,927 |
| 502/503/500/504 |
3/2/1/0 |
| 全部 5xx |
6,约 0.0098% |
| 主站重启次数 |
新容器自 12:46:39 启动后为 0 |
| 截止快照 |
主站 healthy;新连接 jit=off |
| 可用内存/已用 swap |
约 662 MiB/359 MiB |
残余错误分布:
- 13:02:43、13:16:36、13:30:24:课程 AI 摘要接口
/api/forum/courses/:id/summary 返回 502,网关均记录上游提前关闭。
- 13:27:36:同一类摘要接口返回一次 500。
- 12:58:58:课程 JSON 列表返回一次 503;13:28:29:课程页面返回一次 503。503 可以来自容量、超时或搜索可用性保护。
这些数据支持“原先广泛的资源拥堵已经明显缓解”。但恢复后平均流量低于 10 点高峰,目录请求速率也下降;因此不将前后错误率之差全部归因于修复,不宣称已证明高峰承载上限。摘要接口的少量失败应单独追踪,不能未经验证便沿用原课程搜索根因。
恢复窗口仍有 6,839 次 /privacy 请求。老标签页在刷新前继续执行旧脚本,因而移除轮询的效果不是发布瞬间全部兑现;也不能把剩余请求一概视为旧脚本。
11. 测试证据与代码/生产版本边界
紧急部署前完成的验证包括:
- 相关 Go 模型、课程服务、搜索服务、控制器、路由、数据库连接及课程/搜索 CLI 测试。
- PostgreSQL 16 的
TestSchemaMigratesOnPostgreSQL 与 TestSchemaUpgradeCreatesNewTablesOnPostgreSQL,以及 JIT 默认关闭测试。
- Meilisearch 1.11 实例验证,覆盖超过默认 1,000 条结果的完整候选、别名、拼音与班号;同时验证失败、取消、候选溢出、隐藏及删除课程过滤。
- 前端轮询回归、
pnpm build、API 契约 pnpm run check、五项仓库治理检查;正常 Git hook 执行 go vet、增量 golangci-lint 和前端类型检查。