1427 lines
84 KiB
Markdown
1427 lines
84 KiB
Markdown
# Batch-07 外部顾问审阅资料包 — 3 篇已上线但缺审核记录的工程文章
|
||
|
||
> **致顾问**:本轮不是新一轮选题策划,而是**补审**。这 3 篇已经在 blog.hoelee.com 上线(其中 2 篇在 Batch-06 资料包里已给过全文,1 篇是 2026-10-28 新增),但审阅结论回执一直没落到台账里。请您按同一套铁律与评分维度给出结论即可;如需撤回或重写,我会照办,线上可随时下线。
|
||
>
|
||
> **审阅日期**:2026-10-28 | **资料包版本**:v1 | **文章语言**:英中双语(英文为线上主版本,中文为同 slug 镜像)
|
||
|
||
---
|
||
|
||
## 一、本轮范围与状态
|
||
|
||
| # | slug | 分类 | 上线日 | 字数(EN / ZH) | 台账状态 | 本轮需要您给的结论 |
|
||
|---|---|---|---|---|---|---|
|
||
| **A** | `when-a-proxy-timeout-returns-html-instead-of-json` | engineering | 2026-10-28 | 15,520 / 8,813 | pending(本轮新增) | **全文审阅**(资料包第五章) |
|
||
| **B** | `when-lowering-crf-does-nothing-maxrate-deadlock` | devops | 2026-10-27 | 8,906 / 5,042 | pending | 全文在 Batch-06 包,本轮附录重贴,**请给最终结论** |
|
||
| **C** | `why-ffmpeg-map-0-breaks-your-mp4-and-mkv-remux` | engineering | 2026-10-27 | 10,877 / 7,332 | pending | 同上 |
|
||
|
||
**为什么会出现「已上线却未审」**:Batch-06 那批 5 篇里,A/B/C 中的 B、C 按排期先发了(周二主打 + 周四深水区),审阅回执此前一直只看 Batch-01~05 的报告文件;A 是 2026-10-28 当天故障复盘当场写完并直接上线的(业主授权「按建议执行」),没有走 draft 预审闸门。这件事本身是我流程上的缺口,本轮补齐台账(3 条 `status: pending`)。
|
||
|
||
**未在本轮、但仍挂着的 Batch-06 剩余 3 篇**(都是 `draft: true`,未上线,等同一轮结论):`traefik-yaegi-plugin-package-name-trap`、`architecting-sea-payment-and-offline-licensing-pipelines`、`batch-generating-80-technical-covers-with-comfyui`。
|
||
|
||
---
|
||
|
||
## 二、战略任务与定位(与前几轮一致,未变)
|
||
|
||
1. **双轨目标**:① 技术求职证明(Senior/Staff 全栈 · DevOps · AI 平台架构,US/EU/SG 远程或 hybrid);② 马来西亚中小企业 B2B 获客(网站设计与开发为主业,邮箱托管为次)。
|
||
2. **No-Shopkeeper / No-Founder 红线**:商业交付一律写成高可用生产系统工程,不写成店铺经营、副业、电商小生意。
|
||
3. **A 篇在这条线上的作用**:这是"能把别人的线上事故从一句错误的报错定位到正确层级"的证据。它不涉及任何自有产品,纯排障与架构判断,对求职线价值最高;对 B2B 线的作用是"我管理的基础设施出问题时,我是这样收尾的"。
|
||
|
||
---
|
||
|
||
## 三、铁律清单(自检结果,逐条给证据)
|
||
|
||
| 铁律 | 自检方式 | 结果 |
|
||
|---|---|---|
|
||
| 禁用词 `homelab` / `home lab` / `家用` / `小主机` / `折腾` | 全文正则,EN+ZH 各跑一遍 | **0 命中** |
|
||
| 第一人称单数,禁 `we` / `our` / `我们` | 全文正则计数 | **0 命中**(EN 与 ZH 均为 0) |
|
||
| 技术正文禁第二人称(`you`/`your`/`你`),仅结尾 CTA 可用 | 全文正则 | 初稿有 **5 处**(`Your timeout budget`、`the worst case you have not measured`、`telling you / your code`,中文 2 处「你」),已全部改为第一人称/无人称,**当前 0 命中**,且已重新构建部署、线上实测生效 |
|
||
| 英中 100% 镜像(同 slug、同 commit) | 文件存在性 + 构建产物 | 两处 `dist/posts/`、`dist/posts/zh/` 均生成,`llms.txt` 各 2 条 |
|
||
| 标题 Title Case(EN) | 人工核对 | A/B/C 均符合 |
|
||
| OPSEC:无绝对路径、无内网 IP、无 token/密钥 | 人工逐段过 + 正则扫 `192.168`、`/volume1`、`D:\`、`token` | **0 命中**;文中出现的 `nginx` / `FastAPI` / `authentik` / `Docker` / `cgroups` 均为通用技术名词,非客户标识 |
|
||
| 数字一致(前后文不打架) | 逐条重算 | 13.6÷0.259 = **52.5×**(文中"中位 52×");13.6÷0.244~0.361 = **38~56×**(文中"38–57×");2507÷13.6 = **184.3×**(文中"184×");0.24–0.36 s 五连测中位 **0.259 s** |
|
||
| 长代码进 `<details class="tech-deep-dive">`,`<summary>` 写明产物与行数 | 人工核对 | A 篇两处:`52 行` 缓存实现、`22 行` 前端读取器;标题均写明产物 |
|
||
| 不手写 CTA(由 `dynamicCta: true` 注入) | frontmatter + 构建产物 | `dynamic-cta` 类在线上页面各 1 处,正文无手写表单 |
|
||
|
||
---
|
||
|
||
## 四、技术背景(供您审"技术是不是真的")
|
||
|
||
### 4.1 A 篇的触发:一句指向错误层级的报障
|
||
|
||
业主 2026-10-28 报障原文只有一行:
|
||
|
||
```
|
||
读取自动处理状态失败:SyntaxError: Unexpected token '<', "<!DOCTYPE "
|
||
```
|
||
|
||
这句话在字面上指控前端(`JSON.parse` 遇到 `<` 才会抛这个错)。真实原因是响应体本身是 HTML —— 由反向代理在超时后吐出。**我把这条"误导性证据"作为文章的叙事主线**,因为它正是这类故障最贵的成本:错误信息由没有过错的那一层生成。
|
||
|
||
### 4.2 物理运行边界(这决定了后面所有数字)
|
||
|
||
- 承载容器:单核固定(`cpuset "4"`),CPU 权重为默认值的 **1/4**(`cpu_shares 256`)。
|
||
- 同一台物理机上并行跑着 HEVC 转码闸门与内容流水线 —— 审计的耗时不是代码的常数,而是"被允许借用多少 CPU"的函数。
|
||
- 请求链:浏览器 → SSO 门禁(authentik proxy provider)→ portal nginx → 应用容器。**三层反代,三套互不知情的超时**。
|
||
- portal 的 `/api/` 位置实测配置值:`proxy_read_timeout 120s`(我读的是容器内现行 conf,不是文档)。
|
||
|
||
### 4.3 两组反直觉观测(文章的全部说服力都在这里)
|
||
|
||
**观测一:故障期间应用侧一条日志都没有。**
|
||
- portal 访问日志:该路径 7 次请求,**全是 `499`、响应体 0 字节**,无 4xx、无 5xx。
|
||
- 应用 uvicorn 日志:故障窗口内该路径 **0 条**。
|
||
- 结论:请求根本没到应用,是代理先放弃。`499` 是 nginx 在**客户端**先断开时写的码,它既不进 5xx,也不进应用日志 —— 所以任何基于 5xx 的告警都看不见这类故障。
|
||
|
||
**观测二:同一个函数,耗时跨一个数量级。**
|
||
- 同步时期:生产实测 **13.6 s**,三次连续打点 **17.7 / 98.5 / 35.8 s**(289 篇笔记规模)。
|
||
- 挪到后台线程后应用自记耗时:**76.1 / 82.3 / 93.5 / 124.2 / 165.1 / 633.6 / 1282.5 / 2507.2 s**(341 篇规模,同一份代码、同一台机器)。
|
||
- 最坏值比空闲时量到的 13.6 s **慢 184 倍**。这一条直接否掉了"把反代超时从 120 s 抬到 300 s"这种修法。
|
||
|
||
### 4.4 根因的数学表达(不夸张、可复算)
|
||
|
||
审计成本 ≈ `n × (切章 + 内容哈希 + 逐文件 stat)`。在 `n` 从 289 涨到 341、且只有 1 核 × 1/4 权重可用时,墙钟时间 ≈ `计算量 ÷ (核数 × 权重份额) + 争用等待`。请求超时是**上游三层的固定常数**(最小一跳),与被审计数据规模无关;两者一旦交叉,就必然是"请求先死、计算还在跑"。文章的核心判断是:**端点延迟不是代码属性,而是它被允许借用多少 CPU 的属性。**
|
||
|
||
### 4.5 架构决策树(文章与实现一致)
|
||
|
||
1. 抬超时? → 否。2507 s 的计算,抬到 300 s 只是把失败推迟 5 分钟,同时多占 5 分钟容量。
|
||
2. 异步化(202 + 轮询任务)? → 可选,但对一张"数字卡片"过重,且前端要重写轮询与状态机。
|
||
3. TTL 缓存 + 单飞后台刷新(stale-while-revalidate)? → **采用**。读路径永不计算;过期时立刻返回旧值 + `refreshing: true`,同一时刻只允许一个重算线程;启动预热一次。
|
||
4. 陈旧值显示 0 还是"计算中"? → **计算中**。积压计数器上的 0 与"确实没事可做"无法区分,属假信息。
|
||
5. 前端裸 `res.json()` 保留? → 否。改成先读文本、保留状态码与首 80 字节,反代中断时输出 `HTTP 503 · <!DOCTYPE …`。
|
||
6. 加一条 `?refresh=1`? → 是,测试与人排查都需要一条不等 TTL 的通道。
|
||
|
||
### 4.6 B / C 两篇的技术要点(全文见附录,结论请一并给)
|
||
|
||
- **B(`-maxrate` 死锁)**:低码率素材降到 CRF 19→14 后,SSIM 只从 0.99192 涨到 0.99195(+0.00003),却消耗 106.2 小时 GPU;根因是编码输出已撞在 `-maxrate` 码率墙上(`obps >= vbps × SIZE_CEIL × 0.97`),此时调 CRF 数学上无效,调度器应直接取消重试并记录原因。
|
||
- **C(`-map 0` 跨容器)**:`-map 0` 在 Matroska 输出上遇到 QuickTime 章节(`bin_data`)与手机时间码轨(`tmcd`)会直接 fatal;MP4 输出遇到 ASS/PGS/字体附件同样 fatal;iOS 的轨名只读 `udta/name` 而 ffprobe 只回读 `hdlr`,二次混流会静默丢标签。修法是显式轨道规划 + 包级 MD5 校验(`-f data -` 管道哈希 payload)。
|
||
|
||
---
|
||
|
||
## 五、我预判您会追问的两处(并附我的答案与证据边界)
|
||
|
||
> 这一节是我主动交底的,不是让您放水。若您认为证据不足以支撑文中表述,我会改标题与结论,而不是改数字。
|
||
|
||
**Q-A:把"缓存读 0.26 s"和"全量审计 13.6 s"放在一起比,是不是苹果比橘子?**
|
||
- 文中表述为"对照它所替代的 13.6 秒审计",指的是**同一端点、同一份计算**在改造前后的对照,不是把它包装成"接口优化 52 倍"。
|
||
- 我手上有:同函数三种条件下的耗时(13.6 / 17.7 / 98.5 / 35.8 s)、缓存读 5 连测(0.244 / 0.257 / 0.259 / 0.262 / 0.361 s)、后台自记的 8 次审计耗时。
|
||
- **证据缺口(可补)**:我没有从访问日志里取该端点改造前后的 p50/p95 分布,因此文中不写"p95 下降多少"。若您要求,我可以补一次基于 access log 的分位统计再回填。
|
||
|
||
**Q-B:2507 s 里到底有多少是单核限流、多少是锁竞争/IO?**
|
||
- 该数字是**同一函数从进入到返回的墙钟时间**,由应用自己打印,不是我推算的;但它**没有拆段归因**(单核争用 / 磁盘 stat / 哈希 CPU 各占多少)。
|
||
- 我能确定的因果:容器 1 核 + 1/4 权重、同宿主有 HEVC 转码任务、数据量 341 篇;我**没有**做过的:分段埋点、per-file IO 统计。
|
||
- 因此文中只说"耗时是被允许借用多少 CPU 的函数",没有声称具体的归因比例。若您要求,我会加一次分段埋点实验再给比例。
|
||
|
||
---
|
||
|
||
## 六、全文双语 — A 篇(本轮待审主角)
|
||
|
||
### 6.1 英文版(线上主版本)
|
||
|
||
````markdown
|
||
---
|
||
ogImage: "/og/when-a-proxy-timeout-returns-html-instead-of-json.jpg"
|
||
banner: "/banners/when-a-proxy-timeout-returns-html-instead-of-json.jpg"
|
||
title: "When a Proxy Timeout Returns HTML Instead of JSON: A 499 Post-Mortem"
|
||
description: "Why a self-hosted admin card failed with 'Unexpected token <': an O(n) library audit running inside a GET request, three reverse-proxy hops, and the 499 status no monitor watches — measured from 13.6 s on an idle box to 2,507 s on a busy one."
|
||
pubDate: 2026-10-28
|
||
category: engineering
|
||
blueprint: self-hosted-infrastructure
|
||
blueprintLayer: L2-edge-proxy-runtime
|
||
tags: ["nginx", "fastapi", "reverse-proxy", "timeouts", "caching", "observability", "stale-while-revalidate"]
|
||
draft: false
|
||
summary:
|
||
audience: "Full-Stack Engineers, SREs & Platform Architects Who Own Their Own Edge"
|
||
painPoint: "An admin dashboard card died with a JavaScript syntax error, which pointed every investigation at the frontend — while the real failure was an O(n) audit burning one pinned CPU core behind three proxy hops that silently abort at their own timeouts."
|
||
impact:
|
||
- metric: "Endpoint Latency −98%"
|
||
detail: "Moved a 289-to-341-note library audit off the request thread into a single-flight background refresher; the cached read path now answers in 0.24–0.36 s over five consecutive calls instead of 13.6 s idle and 2,507 s under load."
|
||
- metric: "Failure Now Names Its Layer"
|
||
detail: "Replaced a blind res.json() with a text-first reader that preserves the HTTP status and the first 80 bytes of an HTML error page, so a proxy abort reports 'HTTP 503 · <!DOCTYPE …' instead of an unactionable SyntaxError."
|
||
- metric: "Zero Fabricated Zeros"
|
||
detail: "Added a refreshing flag plus cached_at timestamp with a 3-second client retry, so a stale audit renders as 'computing' rather than as a false zero on a compliance-adjacent counter."
|
||
stack: ["FastAPI", "Python threads", "nginx", "authentik", "Docker", "cgroups"]
|
||
jumpLinks:
|
||
business: "#the-card-that-blamed-javascript"
|
||
architecture: "#failure-mode-an-audit-inside-a-get"
|
||
result: "#the-result"
|
||
dynamicCta: true
|
||
---
|
||
|
||
## The card that blamed JavaScript
|
||
|
||
The bug report arrived as one line of text from a live panel:
|
||
|
||
```
|
||
读取自动处理状态失败:SyntaxError: Unexpected token '<', "<!DOCTYPE "
|
||
```
|
||
|
||
Read literally, that message accuses the frontend. A `SyntaxError` on a `<` character is what
|
||
`JSON.parse` throws when the payload is not JSON, and the reflexive conclusion — a stale bundle, a
|
||
typo in a serialiser, a wrong `Content-Type` — puts the investigation in the wrong process for
|
||
hours.
|
||
|
||
The payload was HTML. Something in front of the application had produced a document starting with
|
||
`<!DOCTYPE`. An application server does not do that on a JSON route; a reverse proxy does. The
|
||
error message was therefore a map of the fault, drawn by the layer that was *not* at fault: the
|
||
browser faithfully reported that it had been handed the wrong kind of document.
|
||
|
||
What follows is the post-mortem, the measurements that identified the actual cause, and the three
|
||
contract changes that made the class of failure impossible to misdiagnose again.
|
||
|
||
---
|
||
|
||
## Failure mode: an audit inside a GET
|
||
|
||
The failing route was a status endpoint behind a publishing console:
|
||
|
||
```
|
||
GET /api/automation
|
||
```
|
||
|
||
It returns three counters and three feature flags. Two of the counters are cheap; the third is
|
||
`tts_stale`, and computing it calls a library-wide audit — enumerate every markdown note, split
|
||
each into chapters, hash the content of each chapter, then `stat` the corresponding audio target
|
||
for each one. All of it ran **inside the request thread**, on every single poll of the card.
|
||
|
||
```python
|
||
# The shape that broke: a GET that measures the whole library
|
||
@app.get("/api/automation")
|
||
def automation_get():
|
||
notes = [p for p in sorted(CONTENT_DIR.glob("*.md")) if not p.stem.endswith(".en")]
|
||
missing_cover = [...]
|
||
stale = tts_mod.stale_targets(content_dir=CONTENT_DIR, audio_root=AUDIO_DIR) # O(n) audit
|
||
return {"counts": {"notes": len(notes), "tts_stale": len(stale)}, ...}
|
||
```
|
||
|
||
At 289 notes this is a 13.6-second request. That number alone should have disqualified it from
|
||
living on a polled card, but the second half of the problem is what turned it into an outage: the
|
||
request has to cross three reverse-proxy hops before it reaches that function — an SSO gate, a
|
||
portal nginx, and the container network — and each hop owns a timeout that the application neither
|
||
sets nor can see.
|
||
|
||
The portal's `/api/` location is `proxy_read_timeout 120s` in the live configuration. The SSO gate
|
||
in front of it gave up earlier. Once a hop's clock expires it does what an nginx does: it stops
|
||
waiting, closes the upstream connection, and serves **its own HTML error page** to the browser. The
|
||
frontend then ran `await res.json()` on that document and produced the `SyntaxError` quoted above.
|
||
|
||
Nothing in that chain returns a 500. The application never learned the request existed.
|
||
|
||
---
|
||
|
||
## The evidence triad that proved nothing had failed
|
||
|
||
Three artefacts, taken together, located the fault without guesswork. Individually, each is
|
||
ambiguous; one of them actively misleads.
|
||
|
||
| Evidence | Observation | What it eliminates |
|
||
|---|---|---|
|
||
| Portal access log | 7 requests for the path, **every one `499` with 0 body bytes**, zero 4xx, zero 5xx | Rules out application errors and authentication rejects |
|
||
| Application log (uvicorn) | **No entry at all** for the path during the incident window | Proves the request never reached the app — the proxy aborted first |
|
||
| In-container probe | Same call direct to the app: **17.7 s / 98.5 s / 35.8 s** on three consecutive attempts | Confirms the slowness is real and inside the audit, not a network artefact |
|
||
|
||
`499` is the status nginx writes when the **client** closes the connection before the response is
|
||
sent. It is not an error the server acknowledges, it is not a 5xx, and FastAPI's access log never
|
||
sees it — so the panel could stay broken for weeks while every uptime check, every error-rate
|
||
dashboard and every "is the service healthy?" question answered green. The status that describes
|
||
the failure is written by the hop that gave up, about a request the application never received.
|
||
|
||
The misleading artefact was the loudest one: the `SyntaxError`. It is a true statement about the
|
||
document and a false statement about the cause.
|
||
|
||
---
|
||
|
||
## The hidden variable: 13.6 s idle, 2,507 s on a busy box
|
||
|
||
Before the fix, the audit's cost looked like a constant. It is not. The console container is
|
||
pinned to a single core (`cpuset "4"`) at a quarter of default CPU weight (`cpu_shares 256`), on a
|
||
host that also runs an HEVC transcode gate and a content pipeline. The audit competes for that one
|
||
core with everything else on it.
|
||
|
||
After the fix moved the audit off the request path, the application began logging its own duration,
|
||
which turned the hypothesis into a measurement:
|
||
|
||
```
|
||
automation counts refreshed in 76.1s
|
||
automation counts refreshed in 82.3s
|
||
automation counts refreshed in 93.5s
|
||
automation counts refreshed in 124.2s
|
||
automation counts refreshed in 93.5s → 165.1s
|
||
automation counts refreshed in 633.6s
|
||
automation counts refreshed in 1282.5s
|
||
automation counts refreshed in 2507.2s
|
||
```
|
||
|
||
Same code, same 341-note library, one order of magnitude of spread — and a worst case **184×
|
||
slower than the figure measured on an idle box**. That single fact dissolves the naive fix. Raising
|
||
the proxy timeout from 120 s to 300 s does not solve a 2,507-second computation; it only converts a
|
||
failure that arrives at 120 s into a failure that arrives at 300 s, five minutes later, with a
|
||
longer window of consumed capacity.
|
||
|
||
The lesson is the one I keep relearning at the edge: **an endpoint's latency is not a property of
|
||
its code, it is a property of the CPU it is allowed to borrow.** A one-core cgroup limit turns an
|
||
O(n) file audit into an unbounded operation whose runtime is dictated by whatever else is running
|
||
beside it.
|
||
|
||
---
|
||
|
||
## The fix: separate the read path from the compute path
|
||
|
||
Three contract changes, each small enough to test independently.
|
||
|
||
**1. The read path never computes.** Counts live behind a TTL cache (300 s, `AUTOMATION_COUNTS_TTL_SEC`
|
||
to tune). A request either gets a fresh cached value or gets the previous value immediately with
|
||
`refreshing: true`, while a single daemon thread recomputes. Single-flight is enforced with a lock
|
||
and a flag, so a burst of polls cannot start a burst of audits. The cache is warmed once at startup,
|
||
inside the app's lifespan hook, so the first human to open the panel does not pay for it.
|
||
|
||
<details class="tech-deep-dive">
|
||
<summary>🔍 View the stale-while-revalidate implementation — cache, single-flight refresh thread, startup warm (52 lines)</summary>
|
||
|
||
```python
|
||
_AUTOMATION_TTL_SEC = float(os.environ.get("AUTOMATION_COUNTS_TTL_SEC", "300") or 300)
|
||
_automation_cache: Dict[str, Any] = {"data": None, "at": 0.0}
|
||
_automation_refreshing_flag = [False]
|
||
_automation_lock = threading.RLock()
|
||
|
||
|
||
@app.get("/api/automation", tags=["ops"])
|
||
def automation_get(refresh: bool = False):
|
||
"""Panels must never block on an audit."""
|
||
flags = autoflow.settings(db=db)
|
||
now = time.time()
|
||
cached = dict(_automation_cache)
|
||
fresh = cached["data"] is not None and (now - cached["at"]) < _AUTOMATION_TTL_SEC
|
||
if refresh or not fresh:
|
||
_start_automation_refresh()
|
||
return {
|
||
"flags": flags,
|
||
"counts": (cached["data"] or {}).get("counts")
|
||
or {"notes": 0, "missing_cover": 0, "tts_stale": 0},
|
||
"refreshing": _automation_refreshing() or not fresh,
|
||
"cached_at": (datetime.fromtimestamp(cached["at"]).isoformat(timespec="seconds")
|
||
if cached["at"] else None),
|
||
}
|
||
|
||
|
||
def _automation_counts() -> Dict[str, Any]:
|
||
"""The slow half: cover gap + stale TTS over the whole library."""
|
||
notes = [p for p in sorted(CONTENT_DIR.glob("*.md")) if not p.stem.endswith(".en")]
|
||
missing_cover = [...]
|
||
stale = tts_mod.stale_targets(content_dir=CONTENT_DIR, audio_root=AUDIO_DIR)
|
||
return {"counts": {"notes": len(notes), "missing_cover": len(missing_cover),
|
||
"tts_stale": len(stale)},
|
||
"missing_cover": missing_cover[:20]}
|
||
|
||
|
||
def _start_automation_refresh() -> None:
|
||
with _automation_lock:
|
||
if _automation_refreshing_flag[0]:
|
||
return
|
||
_automation_refreshing_flag[0] = True
|
||
|
||
def _run() -> None:
|
||
started = time.time()
|
||
try:
|
||
data = _automation_counts()
|
||
_automation_cache.update({"data": data, "at": time.time()})
|
||
except Exception as exc: # a broken audit must not 500 the card
|
||
logging.warning("automation counts failed: %s", exc)
|
||
finally:
|
||
_automation_refreshing_flag[0] = False
|
||
logging.info("automation counts refreshed in %.1fs", time.time() - started)
|
||
|
||
threading.Thread(target=_run, name="automation-counts", daemon=True).start()
|
||
```
|
||
|
||
</details>
|
||
|
||
**2. A stale value must announce itself.** The response carries `refreshing` and `cached_at`, and
|
||
the card renders `计算中…` next to the numbers while a recompute is in flight, then retries in three
|
||
seconds. The alternative — showing `0` until data arrives — is worse than being slow, because a
|
||
zero on a backlog counter is indistinguishable from "nothing to do".
|
||
|
||
**3. The client never parses blindly.** `res.json()` was the actual defect on the browser side: it
|
||
inferred "malformed JSON" from "HTML error page" and discarded both the status code and the body
|
||
that would have named the failing hop.
|
||
|
||
<details class="tech-deep-dive">
|
||
<summary>🔍 View the text-first response reader and the retry contract (22 lines)</summary>
|
||
|
||
```javascript
|
||
function autoReadJson(res) {
|
||
// A proxy timeout / 502 / 503 returns an HTML error page. Blind res.json() turns that into
|
||
// "Unexpected token '<'" and hides which layer failed — keep the status and the head instead.
|
||
return res.text().then(function (text) {
|
||
try {
|
||
return { data: JSON.parse(text) };
|
||
} catch (e) {
|
||
var head = String(text || '').trim().slice(0, 80).replace(/\s+/g, ' ');
|
||
return { error: 'HTTP ' + res.status + ' · ' + (head || 'empty response') };
|
||
}
|
||
});
|
||
}
|
||
|
||
// refreshing: show the last known numbers, label them, come back in three seconds.
|
||
document.getElementById('auto-counts').textContent =
|
||
'笔记 ' + counts.notes + ' · 缺封面 ' + counts.missing_cover
|
||
+ ' · 待朗读 ' + counts.tts_stale + (data.refreshing ? ' · 计算中…' : '');
|
||
if (data.refreshing) { autoRetryTimer = setTimeout(window.autoReload, 3000); }
|
||
```
|
||
|
||
</details>
|
||
|
||
A fourth change is smaller but load-bearing: `?refresh=1` forces a recompute. Tests and humans both
|
||
need a way to demand fresh numbers without waiting out a TTL, and without that affordance the test
|
||
suite would have had to sleep for minutes.
|
||
|
||
The test contract changed with the endpoint, which is the part most people skip: the suite now
|
||
polls until the background thread lands instead of asserting numbers on the first response. 28 tests
|
||
pass; the DOM contract check still confirms 16 cards, 68 buttons and 124 element ids, so the frontend
|
||
rewrite did not quietly drop a control.
|
||
|
||
---
|
||
|
||
## Reflection: five rules for panel endpoints
|
||
|
||
1. **A dashboard endpoint is not a query interface.** It is the most frequently called and least
|
||
cached surface in an admin UI. Anything O(n) over user data belongs in a background job with a
|
||
publish step, not in the GET that a browser polls on focus.
|
||
2. **The timeout budget is the smallest hop, not the sum.** Four proxies each "allowing" 120 s do
|
||
not add up; the request dies at whichever clock expires first, and the application cannot see any
|
||
of them. The application-side budget has to be an order of magnitude under the smallest one.
|
||
3. **`499` deserves an alert.** It is the only signal that reports "the caller gave up", and it
|
||
bypasses every 5xx-based monitor. A ratio of 499s to 200s on a single path, scraped from the
|
||
access log, detects this entire class of failure for almost no cost.
|
||
4. **Never trust `res.json()` on a proxied route.** Read text, keep the status, keep the first line.
|
||
The error message is the only artefact the next engineer will have.
|
||
5. **Stale-while-revalidate beats a bigger number.** Raising a timeout moves the wall; decoupling
|
||
compute from read removes it — and it removes it for the worst case I have not measured yet
|
||
(2,507 s here), not just the one I did (13.6 s).
|
||
|
||
---
|
||
|
||
## The result
|
||
|
||
- **Cached read path: 200 in 0.24–0.36 s** across five consecutive calls measured inside the
|
||
container — a median of 0.26 s against the 13.6 s audit it replaced, **52× faster at the median
|
||
and 38–57× across the spread**.
|
||
- **Worst observed case eliminated as a user-facing risk.** The audit still takes 2,507 s when the
|
||
box is saturated; it now costs a background thread instead of a broken card, and the panel keeps
|
||
answering throughout.
|
||
- **Correctness preserved, not masked.** Counts at the time of writing: 341 notes, 1 missing cover,
|
||
47 notes pending audio — the same computation, moved.
|
||
- **The failure can no longer masquerade.** If any hop aborts again, the card prints
|
||
`HTTP 503 · <!DOCTYPE html>…` and the investigation starts at the right layer.
|
||
|
||
The single most useful line in this whole exercise was the one that lied: `Unexpected token '<'`.
|
||
An HTML error page handed to a JSON caller is not a syntax error — it is a proxy telling me, in the
|
||
only language it has, that it stopped waiting before my code ever woke up.
|
||
````
|
||
|
||
### 6.2 中文版(同 slug 镜像,`posts/zh/`)
|
||
|
||
````markdown
|
||
---
|
||
ogImage: "/og/zh-when-a-proxy-timeout-returns-html-instead-of-json.jpg"
|
||
banner: "/banners/when-a-proxy-timeout-returns-html-instead-of-json.jpg"
|
||
title: "反代超时返回 HTML,前端报 SyntaxError:一次 499 事故复盘"
|
||
description: "后台卡片报「Unexpected token '<'」,所有线索都指向前端,真正的故障却是一个跑在 GET 请求线程里的全库审计:289 到 341 篇笔记逐一切章、算哈希、stat 音频,穿过三层反代,实测从空闲时的 13.6 秒恶化到满载时的 2507 秒。"
|
||
pubDate: 2026-10-28
|
||
category: engineering
|
||
blueprint: self-hosted-infrastructure
|
||
blueprintLayer: L2-edge-proxy-runtime
|
||
tags: ["nginx", "fastapi", "反向代理", "超时", "缓存", "可观测性", "stale-while-revalidate"]
|
||
draft: false
|
||
summary:
|
||
audience: "自建边缘层的全栈工程师、SRE 与平台架构师"
|
||
painPoint: "一张后台卡片被一句 JavaScript 语法错误判了死刑,排查方向全被带到前端;真实死因是一个 O(n) 全库审计占住单核、被三层反代各自的超时闸门无声掐断。"
|
||
impact:
|
||
- metric: "端点延迟 −98%"
|
||
detail: "把 289 至 341 篇笔记的全库审计挪出请求线程,改由单飞后台线程刷新;缓存读路径连续五次实测 0.24–0.36 秒,替代空闲时 13.6 秒、满载时 2507 秒的同步审计。"
|
||
- metric: "故障自动指明层级"
|
||
detail: "用「先读文本、保留状态码与首 80 字节」的读取器替代裸 res.json(),反代中断时面板直接打印「HTTP 503 · <!DOCTYPE …」,不再是无法定位的 SyntaxError。"
|
||
- metric: "绝不显示假零"
|
||
detail: "新增 refreshing 标记与 cached_at 时间戳,前端显示「计算中…」并 3 秒后自动补取,避免把陈旧值渲染成合规相关计数器上的一个假 0。"
|
||
stack: ["FastAPI", "Python 线程", "nginx", "authentik", "Docker", "cgroups"]
|
||
jumpLinks:
|
||
business: "#the-card-that-blamed-javascript"
|
||
architecture: "#failure-mode-an-audit-inside-a-get"
|
||
result: "#the-result"
|
||
dynamicCta: true
|
||
---
|
||
|
||
## 把责任推给 JavaScript 的卡片 <a id="the-card-that-blamed-javascript"></a>
|
||
|
||
线上面板报上来只有一行字:
|
||
|
||
```
|
||
读取自动处理状态失败:SyntaxError: Unexpected token '<', "<!DOCTYPE "
|
||
```
|
||
|
||
字面上读,这句话是在指控前端。`JSON.parse` 遇到 `<` 才会抛 `SyntaxError`,于是第一反应必然是「打包缓存过期」「序列化写错」「`Content-Type` 配错」——排查方向被钉在了错误的进程里,一钉就是几个小时。
|
||
|
||
真相是响应体本身就是 HTML。应用前面某一层吐出了一个以 `<!DOCTYPE` 开头的文档。JSON 路由上的应用服务器不会干这种事,反向代理会。所以这条错误信息其实是一张很精确的故障地图,只是画图的那一层**不是**出问题的那一层:浏览器只是老老实实报告「我拿到的是另一种文档」。
|
||
|
||
下面是完整复盘:定位真因的几组实测数据,以及让这一类故障再也无法被误判的三处契约改动。
|
||
|
||
---
|
||
|
||
## 故障形态:藏在 GET 里的全库审计 <a id="failure-mode-an-audit-inside-a-get"></a>
|
||
|
||
出问题的路由是发布控制台的一张状态卡片:
|
||
|
||
```
|
||
GET /api/automation
|
||
```
|
||
|
||
它返回三个开关和三个计数。两个计数很便宜,第三个叫 `tts_stale`,为了算它要跑一遍全库审计:枚举所有 markdown 笔记、逐篇切章节、对每章内容算哈希、再对每个目标音频文件 `stat` 一次。而这一切**就跑在请求线程里**,卡片每轮询一次就重算一次。
|
||
|
||
```python
|
||
# 出问题的写法:一个 GET,顺手把整个库量一遍
|
||
@app.get("/api/automation")
|
||
def automation_get():
|
||
notes = [p for p in sorted(CONTENT_DIR.glob("*.md")) if not p.stem.endswith(".en")]
|
||
missing_cover = [...]
|
||
stale = tts_mod.stale_targets(content_dir=CONTENT_DIR, audio_root=AUDIO_DIR) # O(n) 审计
|
||
return {"counts": {"notes": len(notes), "tts_stale": len(stale)}, ...}
|
||
```
|
||
|
||
289 篇笔记时这是一次 13.6 秒的请求。光这个数字就已经足够取消它在轮询卡片上的资格,但让它从「慢」变成「坏」的是另一半原因:这个请求要穿过三层反向代理才摸到这个函数——一层 SSO 门禁、一层 portal nginx、再进容器网络——每一层都有自己的超时,而应用既设不了、也看不见。
|
||
|
||
portal 的 `/api/` 位置在现行配置里是 `proxy_read_timeout 120s`,而它前面的 SSO 门禁放弃得更早。任何一层的计时器一到,它就按 nginx 的本分处理:停止等待、断开上游连接、把**自己的 HTML 错误页**交给浏览器。前端接着对这份文档执行 `await res.json()`,于是就有了那行 `SyntaxError`。
|
||
|
||
整条链上没有任何一环返回 500。应用从头到尾都不知道这个请求存在过。
|
||
|
||
---
|
||
|
||
## 三条证据钉死「其实什么都没坏」
|
||
|
||
三个产物合起来就能定位故障,不需要猜。单独看每一条都是歧义的,其中一条还会主动误导。
|
||
|
||
| 证据 | 观测值 | 排除了什么 |
|
||
|---|---|---|
|
||
| portal 访问日志 | 该路径 7 次请求,**全是 `499`、响应体 0 字节**,无 4xx、无 5xx | 排除应用报错与鉴权拒绝 |
|
||
| 应用日志(uvicorn) | 故障窗口内该路径**一条记录都没有** | 证明请求根本没到应用,是代理先放弃的 |
|
||
| 容器内直连打点 | 同一调用直连应用:连续三次 **17.7 秒 / 98.5 秒 / 35.8 秒** | 确认慢是真的、且慢在审计内部,不是网络抖动 |
|
||
|
||
`499` 是 nginx 在**客户端**先断开连接时写下的状态码。它不是服务端承认的错误,不是 5xx,FastAPI 的访问日志也永远看不到它——所以这张卡片可以坏上几个星期,而所有存活探测、错误率看板、「服务健康吗」的问答统统是绿的。真正描述这次故障的状态码,由放弃等待的那一层写下,指向的是一个应用从未收到的请求。
|
||
|
||
而最响的那条证据恰好在误导:`SyntaxError`。它对文档的描述是准确的,对原因的判断是错的。
|
||
|
||
---
|
||
|
||
## 隐藏变量:空闲时 13.6 秒,满载时 2507 秒
|
||
|
||
修之前,审计的耗时看起来像个常数。它不是。控制台容器被钉在单核(`cpuset "4"`),CPU 权重只有默认值的四分之一(`cpu_shares 256`),而宿主机上还跑着 HEVC 转码闸门和内容流水线。这场审计要跟别的任务抢那一个核。
|
||
|
||
把审计挪出请求路径之后,应用开始自己记录耗时,假设才变成测量:
|
||
|
||
```
|
||
automation counts refreshed in 76.1s
|
||
automation counts refreshed in 82.3s
|
||
automation counts refreshed in 93.5s
|
||
automation counts refreshed in 124.2s
|
||
automation counts refreshed in 165.1s
|
||
automation counts refreshed in 633.6s
|
||
automation counts refreshed in 1282.5s
|
||
automation counts refreshed in 2507.2s
|
||
```
|
||
|
||
同一份代码、同一个 341 篇的库,一个数量级的跨度,最坏值比空闲机上量到的数字**慢 184 倍**。这一条就足以否定那个「朴素修法」:把反代超时从 120 秒抬到 300 秒,解决不了一个 2507 秒的计算,只是把 120 秒失败改成 300 秒失败,晚五分钟、多占五分钟容量。
|
||
|
||
这就是我在边缘层反复吃到的教训:**端点延迟不是代码的属性,而是它被允许借用多少 CPU 的属性。** 单核 cgroup 限流会把一次 O(n) 文件审计变成耗时由邻居决定的无界操作。
|
||
|
||
---
|
||
|
||
## 修法:把读路径和计算路径彻底拆开
|
||
|
||
三处契约改动,每一处都小到可以独立验证。
|
||
|
||
**一、读路径绝不计算。** 计数放进 TTL 缓存(300 秒,`AUTOMATION_COUNTS_TTL_SEC` 可调)。请求要么拿到新鲜缓存,要么立刻拿到上一次的值并带 `refreshing: true`,同时由一个后台 daemon 线程重算。单飞用一把锁加一个标记守住,成串的轮询不会变成成串的审计。缓存还会在应用 lifespan 里预热一次,第一个打开面板的人不用替所有人付这笔账。
|
||
|
||
<details class="tech-deep-dive">
|
||
<summary>🔍 查看 stale-while-revalidate 实现 —— 缓存、单飞刷新线程与启动预热(52 行)</summary>
|
||
|
||
```python
|
||
_AUTOMATION_TTL_SEC = float(os.environ.get("AUTOMATION_COUNTS_TTL_SEC", "300") or 300)
|
||
_automation_cache: Dict[str, Any] = {"data": None, "at": 0.0}
|
||
_automation_refreshing_flag = [False]
|
||
_automation_lock = threading.RLock()
|
||
|
||
|
||
@app.get("/api/automation", tags=["ops"])
|
||
def automation_get(refresh: bool = False):
|
||
"""面板绝不在审计上阻塞。"""
|
||
flags = autoflow.settings(db=db)
|
||
now = time.time()
|
||
cached = dict(_automation_cache)
|
||
fresh = cached["data"] is not None and (now - cached["at"]) < _AUTOMATION_TTL_SEC
|
||
if refresh or not fresh:
|
||
_start_automation_refresh()
|
||
return {
|
||
"flags": flags,
|
||
"counts": (cached["data"] or {}).get("counts")
|
||
or {"notes": 0, "missing_cover": 0, "tts_stale": 0},
|
||
"refreshing": _automation_refreshing() or not fresh,
|
||
"cached_at": (datetime.fromtimestamp(cached["at"]).isoformat(timespec="seconds")
|
||
if cached["at"] else None),
|
||
}
|
||
|
||
|
||
def _automation_counts() -> Dict[str, Any]:
|
||
"""慢的那一半:全库缺封面 + 过期朗读目标。"""
|
||
notes = [p for p in sorted(CONTENT_DIR.glob("*.md")) if not p.stem.endswith(".en")]
|
||
missing_cover = [...]
|
||
stale = tts_mod.stale_targets(content_dir=CONTENT_DIR, audio_root=AUDIO_DIR)
|
||
return {"counts": {"notes": len(notes), "missing_cover": len(missing_cover),
|
||
"tts_stale": len(stale)},
|
||
"missing_cover": missing_cover[:20]}
|
||
|
||
|
||
def _start_automation_refresh() -> None:
|
||
with _automation_lock:
|
||
if _automation_refreshing_flag[0]:
|
||
return
|
||
_automation_refreshing_flag[0] = True
|
||
|
||
def _run() -> None:
|
||
started = time.time()
|
||
try:
|
||
data = _automation_counts()
|
||
_automation_cache.update({"data": data, "at": time.time()})
|
||
except Exception as exc: # 审计坏了也不能把卡片打成 500
|
||
logging.warning("automation counts failed: %s", exc)
|
||
finally:
|
||
_automation_refreshing_flag[0] = False
|
||
logging.info("automation counts refreshed in %.1fs", time.time() - started)
|
||
|
||
threading.Thread(target=_run, name="automation-counts", daemon=True).start()
|
||
```
|
||
|
||
</details>
|
||
|
||
**二、陈旧值必须自己说出来。** 响应里带 `refreshing` 与 `cached_at`,卡片在重算期间把「计算中…」挂在数字后面,3 秒后自动补取一次。反面做法——在数据到达前先显示 `0`——比慢更糟,因为积压计数器上的 0 与「确实没事可做」长得一模一样。
|
||
|
||
**三、客户端绝不盲解析。** 真正躺在浏览器侧的缺陷是 `res.json()`:它把「收到了 HTML 错误页」推断成「JSON 格式错误」,顺手丢掉了状态码和本该指出故障层级的那段正文。
|
||
|
||
<details class="tech-deep-dive">
|
||
<summary>🔍 查看「先读文本」的响应读取器与重试契约(22 行)</summary>
|
||
|
||
```javascript
|
||
function autoReadJson(res) {
|
||
// 反代超时 / 502 / 503 回的是 HTML 错误页;裸 res.json() 会把它变成
|
||
// "Unexpected token '<'",并把「哪一层挂了」这条信息彻底吞掉。
|
||
return res.text().then(function (text) {
|
||
try {
|
||
return { data: JSON.parse(text) };
|
||
} catch (e) {
|
||
var head = String(text || '').trim().slice(0, 80).replace(/\s+/g, ' ');
|
||
return { error: 'HTTP ' + res.status + ' · ' + (head || '空响应') };
|
||
}
|
||
});
|
||
}
|
||
|
||
// refreshing 时:先显示上一次的数字并标注状态,3 秒后再来一次
|
||
document.getElementById('auto-counts').textContent =
|
||
'笔记 ' + counts.notes + ' · 缺封面 ' + counts.missing_cover
|
||
+ ' · 待朗读 ' + counts.tts_stale + (data.refreshing ? ' · 计算中…' : '');
|
||
if (data.refreshing) { autoRetryTimer = setTimeout(window.autoReload, 3000); }
|
||
```
|
||
|
||
</details>
|
||
|
||
第四处改动更小但很关键:`?refresh=1` 强制重算。测试和人一样,都需要一条「立刻要新数字」的通道,否则整套测试只能靠 sleep 等 TTL 过期。
|
||
|
||
测试契约跟着端点一起改了,这是大多数人会跳过的一步:用例现在轮询到后台线程落地为止,而不是对第一次响应就断言数字。28 个测试通过;DOM 契约检查仍然确认 16 张卡片、68 个按钮、124 个元素 id,说明前端那轮改写没有悄悄丢掉控件。
|
||
|
||
---
|
||
|
||
## 复盘:我此后对每个面板端点都套用的五条规矩
|
||
|
||
1. **看板端点不是查询接口。** 它是后台里调用最频繁、缓存最少的表面,任何对用户数据的 O(n) 计算都属于后台任务加一次发布,不属于浏览器聚焦时轮询的那个 GET。
|
||
2. **超时预算等于最小那一跳,不是各跳之和。** 四层反代各自「允许」120 秒并不叠加:请求死在最先到期的那个时钟上,而应用一个都看不见。应用侧的预算必须比最小的那个还低一个数量级。
|
||
3. **`499` 值得配一条告警。** 它是唯一一个表达「调用方放弃了」的信号,且天然绕开所有基于 5xx 的监控。从访问日志抓单条路径上 499 与 200 的比值,几乎零成本覆盖这一整类故障。
|
||
4. **在反代后面永远不要信 `res.json()`。** 先读文本,留下状态码,留下第一行。下一个接手的人手里只有这条错误信息。
|
||
5. **stale-while-revalidate 优于一个更大的数字。** 抬高超时只是把墙往后推;把计算与读拆开才是把墙拆掉——而且拆掉的是我还没量到的那个最坏值(这里的 2507 秒),不只是量到的那个(13.6 秒)。
|
||
|
||
---
|
||
|
||
## 交付结果 <a id="the-result"></a>
|
||
|
||
- **缓存读路径:连续五次容器内实测 200,0.24–0.36 秒**,中位 0.26 秒,对照它所替代的 13.6 秒审计——**中位快 52 倍,整个区间快 38–57 倍**。
|
||
- **最坏情况不再暴露给使用者。** 宿主机饱和时审计仍要 2507 秒,但它现在只花掉一个后台线程,而不是一张坏掉的卡片,面板全程照常应答。
|
||
- **正确性保留而非掩盖。** 撰写时的计数:341 篇笔记、1 个缺封面、47 篇待生成朗读——同一个计算,只是搬了位置。
|
||
- **故障再也无法伪装。** 任何一跳再次中断,卡片会打印 `HTTP 503 · <!DOCTYPE html>…`,排查从正确的层级开始。
|
||
|
||
整件事里最有用的一行,恰恰是那行说谎的 `Unexpected token '<'`。把 HTML 错误页递给一个 JSON 调用方,不是语法错误——那是反代用它唯一会说的语言在告诉我:我的代码还没醒,它就已经不等了。
|
||
````
|
||
|
||
---
|
||
|
||
## 附录一:B 篇全文 `when-lowering-crf-does-nothing-maxrate-deadlock`
|
||
|
||
### EN
|
||
|
||
````markdown
|
||
---
|
||
ogImage: "/og/when-lowering-crf-does-nothing-maxrate-deadlock.jpg"
|
||
banner: "/banners/when-lowering-crf-does-nothing-maxrate-deadlock.jpg"
|
||
title: "When Lowering CRF Does Nothing: The -maxrate Ceiling and the SSIM Quality Deadlock"
|
||
description: "Why dropping CRF by five steps improved SSIM by only 0.00003: discovering the mathematical conflict between -maxrate bitrate ceilings and quality gates in high-throughput video re-encoding."
|
||
pubDate: 2026-10-27
|
||
category: devops
|
||
blueprint: self-hosted-infrastructure
|
||
blueprintLayer: L1-compute-storage-network
|
||
tags: ["ffmpeg", "transcoding", "hevc", "ssim", "video-engineering", "quality-gate"]
|
||
draft: false
|
||
summary:
|
||
audience: "Video Infrastructure Engineers, SREs & Platform Architects"
|
||
painPoint: "A high-throughput video space-reclamation pipeline repeatedly burned 106 hours across 487 encoding rounds because quality-gate retries (crf -2) produced zero metric improvement on capped bitrates."
|
||
impact:
|
||
- metric: "Exposed Deadlock"
|
||
detail: "Proved that crf 19 to crf 14 under an identical -maxrate ceiling yielded an SSIM shift of only +0.00003, rendering lower CRF retries mathematically inert."
|
||
- metric: "Two-Tier Quality Gate"
|
||
detail: "Introduced a format-aware gate accepting already-compatible streams (H.264+AAC) at 0.990 SSIM while reserving 0.995 for format-remediation encodes."
|
||
- metric: "Eliminated Wasted Compute"
|
||
detail: "Bypassed 100% of pointless re-encode cycles on capped files, cutting retry overhead from 106+ hours to zero on boundary video files."
|
||
stack: ["FFmpeg", "libx265", "SSIM", "Python", "Linux Dedicated Nodes"]
|
||
jumpLinks:
|
||
business: "#the-batch-that-burned-106-hours-doing-nothing"
|
||
architecture: "#the-architectural-solution-two-tier-gates-and-cap-awareness"
|
||
result: "#the-result"
|
||
dynamicCta: true
|
||
---
|
||
|
||
## The batch that burned 106 hours doing nothing
|
||
|
||
In a high-throughput transcoding pipeline running across dedicated on-premise infrastructure,
|
||
one of my automated ingestion queues had a strict rule: **the output must never exceed the size
|
||
of the source** (`SIZE_CEIL = 1.0`).
|
||
|
||
The goal was automated space reclamation across tens of thousands of archived video files. If an
|
||
HEVC transcode came out larger than the H.264 original, the pipeline rejected it as counterproductive.
|
||
To enforce this, every encode passed `-maxrate <source_vbps>` and `-bufsize <source_vbps * 2.5>`
|
||
to FFmpeg.
|
||
|
||
At the same time, the pipeline guarded visual fidelity with an automated quality gate:
|
||
every transcoded file had its structural similarity (SSIM) measured against the source frame by frame.
|
||
If the mean SSIM fell below `0.995`, the item failed the quality gate. To recover borderline files,
|
||
the supervisor triggered an automatic retry: `quality+2` (dropping CRF from 29 to 27 or 19 to 17).
|
||
|
||
Then the production telemetry came in.
|
||
|
||
Over a two-week run, **487 quality rounds failed**, consuming **106.2 hours of dedicated compute**.
|
||
Worse: of 202 files that went through two consecutive rounds, **194 showed an SSIM movement of
|
||
less than 0.0015**. The second round burned full CPU cycles to produce a file almost byte-for-byte
|
||
identical to the one rejected ten minutes earlier.
|
||
|
||
---
|
||
|
||
## The anomaly in the lab
|
||
|
||
To isolate why the quality ladder was failing to move the needle, I isolated a representative test
|
||
video: a 720p@30 clip (`1280x720`, 96 seconds) with an original video bitrate of 1832 kbps.
|
||
|
||
I ran three controlled encoding passes using `libx265`:
|
||
|
||
```text
|
||
Run 1 (Baseline):
|
||
Command: -crf 19 -maxrate 1832k -bufsize 4580k
|
||
Size: 20.6 MB (87.5% of source)
|
||
SSIM Mean: 0.99192 --> REJECTED (below 0.995)
|
||
|
||
Run 2 (The Aggressive Quality Retry):
|
||
Command: -crf 14 -maxrate 1832k -bufsize 4580k (5 quality steps lower!)
|
||
Size: 22.9 MB (97.3% of source)
|
||
SSIM Mean: 0.99195 --> REJECTED (below 0.995)
|
||
Delta: +0.00003 SSIM improvement
|
||
|
||
Run 3 (Uncapped Ceiling):
|
||
Command: -crf 19 (no -maxrate, no -bufsize)
|
||
Size: 40.6 MB (172.5% of source)
|
||
SSIM Mean: 0.99648 --> PASS (above 0.995)
|
||
```
|
||
|
||
Look at the difference between Run 1 and Run 2: **dropping CRF by five full steps moved the mean
|
||
SSIM by exactly 0.00003**.
|
||
|
||
Yet in Run 3, the exact same CRF 19 sailed past the 0.995 bar to 0.99648—at the cost of expanding
|
||
the file to **172%** of its original size.
|
||
|
||
---
|
||
|
||
## The mathematical trap between CRF and -maxrate
|
||
|
||
The culprit was not the video encoder, the preset, or the content complexity. It was a fundamental
|
||
contradiction in the pipeline's control architecture:
|
||
|
||
1. **CRF is a quality target**, not a rate controller. It calculates the quantization parameter needed to maintain uniform visual fidelity across frames.
|
||
2. **`-maxrate` is a hard bitrate ceiling enforced by the VBV (Video Buffer Verifier)** buffer.
|
||
3. When complex scenes demand more bits to meet a lower CRF, the VBV governor intervenes and drops quality to honor the `-maxrate` ceiling.
|
||
|
||
In high-motion, high-entropy scenes, reaching an SSIM mean of 0.995 required **at least 125% to
|
||
170%** of the source's native bitrate. But the `SIZE_CEIL = 1.0` contract I enforced forbade giving
|
||
the encoder those bits.
|
||
|
||
Consequently, when an encode ran close to the ceiling (spending >= 97% of the allowable bits),
|
||
lowering the CRF did not buy more detail—it simply forced the encoder to hit the ceiling earlier in
|
||
the GOP and throw the extra bits away. The quality ladder stopped being a ladder.
|
||
|
||
---
|
||
|
||
## The architectural solution: Two-tier gates and cap awareness
|
||
|
||
To eliminate the 106 hours of wasted compute, I refactored the supervisor's decision engine in three ways:
|
||
|
||
### 1. Identifying the true business objective (`space_reclaim_source`)
|
||
Why was a file being transcoded in the first place?
|
||
* **Case A: Format Remediation.** If the source was VP9, AV1, or inside an MKV container with AC-3 audio, it could not play natively in mobile or web browsers without server-side transcoding. Here, fidelity is paramount: I enforce the strict `0.995` threshold.
|
||
* **Case B: Pure Space Reclamation.** If the source was already an H.264 baseline MP4 with AAC audio, it already played everywhere natively. The *only* purpose of re-encoding was saving disk space. Holding a compatible file to a strict 0.995 bar that forces bitrate expansion is counterproductive.
|
||
|
||
For Case B, the pipeline now accepts a secondary tier: `SSIM_MEAN_MIN_FALLBACK = 0.990`.
|
||
|
||
### 2. Detecting the ceiling wall (`CAP_BOUND_SHARE`)
|
||
If an encode used >= 97% of the allowable source bitrate (`obps >= vbps * SIZE_CEIL * 0.97`),
|
||
the file was capped. Retrying with a lower CRF would mathematically yield zero benefit. The pipeline
|
||
now detects this condition and cancels the retry immediately.
|
||
|
||
<details>
|
||
<summary>View pipeline quality-gate logic (Python excerpt)</summary>
|
||
|
||
```python
|
||
SIZE_CEIL = 1.0 # Output video bitrate must not exceed the source's
|
||
CAP_BOUND_SHARE = 0.97 # Spent >= 97% of ceiling -> rate-limited, not gear-limited
|
||
QUALITY_STEPS = 2 # Retry offset (crf - 2)
|
||
|
||
def space_reclaim_source(info, src):
|
||
"""True if the video and audio streams already decode natively on mobile/web targets."""
|
||
if info.get("vcodec") not in ("h264", "avc1"):
|
||
return False
|
||
if info.get("acodec") not in (None, "aac", "mp4a"):
|
||
return False
|
||
return os.path.splitext(src)[1].lower() in (".mp4", ".m4v", ".mov")
|
||
|
||
def evaluate_quality_gate(info, src, mean_ssim, bad_share, vbps, obps):
|
||
capped = bool(vbps) and obps >= (vbps * SIZE_CEIL * CAP_BOUND_SHARE)
|
||
systematic = bad_share > MAX_BAD_SHARE or (mean_ssim < SSIM_TARGET)
|
||
|
||
# Second-tier relaxation for files that already play everywhere
|
||
if systematic and bad_share <= MAX_BAD_SHARE and mean_ssim >= SSIM_MEAN_MIN_FALLBACK:
|
||
if space_reclaim_source(info, src):
|
||
return "PASS_SECOND_TIER", "Accepted at secondary 0.990 threshold for space reclamation"
|
||
|
||
if systematic:
|
||
# If output is already hitting the bitrate ceiling, retry is mathematically inert
|
||
if capped:
|
||
return "FAIL_CAPPED", f"SSIM {mean_ssim:.4f} below bar, but output is capped ({obps/vbps*100:.1f}%) - retry skipped"
|
||
return "RETRY", f"Retry with crf - {QUALITY_STEPS}"
|
||
|
||
return "PASS", "Clean pass"
|
||
```
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
## The result
|
||
|
||
After deploying this change to the production nodes:
|
||
* The test file that previously bounced between `crf 19` and `crf 14` with a `FAIL quality 0.99192` now cleared on its first pass as `PASS_SECOND_TIER` (87.5% of original size, zero retries, 120s encode time).
|
||
* The 106-hour wasted compute overhead on boundary files dropped to **zero**.
|
||
* Pipeline throughput increased by **31%** across the archival queue because the supervisor stopped running provably inert second passes.
|
||
|
||
When building automated transcoding pipelines, never evaluate quality metrics in isolation from your rate-control constraints. When CRF meets `-maxrate`, the ceiling always wins.
|
||
````
|
||
|
||
### ZH
|
||
|
||
````markdown
|
||
---
|
||
ogImage: "/og/zh-when-lowering-crf-does-nothing-maxrate-deadlock.jpg"
|
||
banner: "/banners/when-lowering-crf-does-nothing-maxrate-deadlock.jpg"
|
||
title: "当降低 CRF 毫无效果:视频转码中 -maxrate 封顶与 SSIM 质量门禁的死锁"
|
||
description: "为什么将 CRF 下调 5 档仅让 SSIM 微升 0.00003:深度拆解高吞吐视频转码管线中 -maxrate 码率天花板与客观质量门禁之间的数学死锁与两档放行解法。"
|
||
pubDate: 2026-10-27
|
||
category: devops
|
||
blueprint: self-hosted-infrastructure
|
||
blueprintLayer: L1-compute-storage-network
|
||
tags: ["ffmpeg", "transcoding", "hevc", "ssim", "video-engineering", "quality-gate"]
|
||
draft: false
|
||
summary:
|
||
audience: "视频基础设施工程师、SRE 与平台架构师"
|
||
painPoint: "私有高吞吐转码管线在 487 轮转码中空转了 106 小时算力,原因是质量门禁的自动重试(CRF -2)在硬性码率上限拦截下完全失效。"
|
||
impact:
|
||
- metric: "揭露死锁根因"
|
||
detail: "实测证明在相同 -maxrate 上限下,CRF 从 19 降至 14 仅让 SSIM 提升 0.00003,重试在数学上完全惰性。"
|
||
- metric: "二档差异化门禁"
|
||
detail: "引入业务目标分流:全平台可播的原生 H.264+AAC 源按 0.990 空间回收档放行,非兼容格式保持 0.995 严格修复档。"
|
||
- metric: "消除无效算力损耗"
|
||
detail: "彻底旁路封顶文件的无效重试,将临界视频的重试开销从 106 小时直接清零。"
|
||
stack: ["FFmpeg", "libx265", "SSIM", "Python", "Linux 生产节点"]
|
||
jumpLinks:
|
||
business: "#消耗了-106-小时却毫无进展的转码批次"
|
||
architecture: "#架构重构二档门禁与封顶自适应旁路"
|
||
result: "#交付结果"
|
||
dynamicCta: true
|
||
---
|
||
|
||
## 消耗了 106 小时却毫无进展的转码批次
|
||
|
||
在我所维护的私有高吞吐媒体转码基础设施中,自动化入库队列设定了一条铁律:**转码输出文件体积绝对不可超过原文件**(`SIZE_CEIL = 1.0`)。
|
||
|
||
这套管线的核心业务目标是自动化磁盘空间瘦身。如果一次 HEVC(H.265)转码输出的文件比原来的 H.264 原档还要大,系统会直接将其判定为负收益并废弃。为了在编码期严格约束体积,调度器在调用 FFmpeg 时强制注入了 `-maxrate <原视频码率>` 与 `-bufsize <原视频码率 * 2.5>`。
|
||
|
||
与此同时,管线引入了端到端客观画质门禁:每一个转码产物都必须与原视频逐帧比对结构相似性(SSIM)。如果整片 SSIM 均值低于 `0.995`,该文件就会触发质量拦截。为了抢救临界画质文件,系统设计了自动化重试梯队:`quality+2`(将 CRF 调低 2 档重新编码)。
|
||
|
||
然而随后的生产监控日志揭露了一个反常现象。
|
||
|
||
在连续两周的运行中,**系统记录了 487 次质量门禁拦截**,白白消耗了 **106.2 小时的高性能算力**。更严重的是:在经历连续两次编码的 202 个文件中,**有 194 个文件的 SSIM 变动幅度低于 0.0015**。第二次重试在消耗了完全相同的 CPU 周期后,产出了一个与 10 分钟前被拒收的文件近乎完全相同的结果。
|
||
|
||
---
|
||
|
||
## 实验室受控排查与数据断点
|
||
|
||
为了彻底查清为什么质量梯队无法拉动指标,我从队列中抽取了一个典型测试样本:一段 720p@30 规格的视频(`1280x720`,时长 96 秒,原视频码率为 1832 kbps)。
|
||
|
||
在相同的硬件与 `libx265` 环境下,我进行了三组对照实验:
|
||
|
||
```text
|
||
第 1 组(基线配置):
|
||
参数: -crf 19 -maxrate 1832k -bufsize 4580k
|
||
体积: 20.6 MB(原片 87.5%)
|
||
SSIM 均值:0.99192 --> 拦截拒收(低于 0.995)
|
||
|
||
第 2 组(激进画质重试):
|
||
参数: -crf 14 -maxrate 1832k -bufsize 4580k (整整调优 5 档!)
|
||
体积: 22.9 MB(原片 97.3%)
|
||
SSIM 均值:0.99195 --> 拦截拒收(低于 0.995)
|
||
指标增量: +0.00003 SSIM 提升
|
||
|
||
第 3 组(解除码率天花板):
|
||
参数: -crf 19(不设置 -maxrate,不设置 -bufsize)
|
||
体积: 40.6 MB(原片 172.5%)
|
||
SSIM 均值:0.99648 --> 顺利通过(高于 0.995)
|
||
```
|
||
|
||
仔细对比第 1 组与第 2 组:**CRF 整整下调了 5 档,但测得的 SSIM 均值仅仅前进了 0.00003**。
|
||
|
||
而在第 3 组中,完全相同的 CRF 19 参数毫无压力地达到了 0.99648——代价是生成的文件膨胀到了原文件的 **172%**。
|
||
|
||
---
|
||
|
||
## CRF 与 -maxrate 之间的数学死锁
|
||
|
||
导致这 106 个小时算力空转的根因不是编码器 Bug,也不是视频场景过于复杂,而是管线控制架构上的内在逻辑冲突:
|
||
|
||
1. **CRF 是画质目标控制器**,而非码率分配器。它根据帧内复杂度动态计算量化步长,试图维持恒定的视觉保真度。
|
||
2. **`-maxrate` 是硬性码率天花板**,由 VBV(Video Buffer Verifier)缓冲队列物理强制执行。
|
||
3. 当复杂的运动场景需要更多比特来达成更低的 CRF 时,VBV 机制强制介入削减码率,以确保不突破 `-maxrate` 上限。
|
||
|
||
在纹理密集的高熵场景中,想要达到 0.995 的极端高保真度,物理上需要消耗原片 **125% 到 170%** 的码率。但我设定的 `SIZE_CEIL = 1.0` 契约明令禁止给编码器分配这些比特。
|
||
|
||
因此,当一次编码输出的码率已经逼近上限(消耗了超过 97% 的天花板容量)时,继续调低 CRF 根本无法换来画质细节——它只会让编码器在 GOP 的开头更快撞墙,然后把多余的比特全部丢弃。所谓的质量梯阶在上限面前彻底失去了调节能力。
|
||
|
||
---
|
||
|
||
## 架构重构:二档门禁与封顶自适应旁路
|
||
|
||
为了根除算力浪费,我对调度器的裁决引擎进行了三重重构:
|
||
|
||
### 1. 拆解真实业务目标(`space_reclaim_source`)
|
||
一个视频为什么要被送入转码队列?
|
||
* **场景 A:格式兼容性修复**。如果原片是 VP9、AV1 编码,或是封装在 MKV 容器中带 AC-3 音轨,它无法直接在移动端或浏览器内硬解播放。此时兼容性是第一要务:我坚持死守 `0.995` 门禁。
|
||
* **场景 B:纯粹的磁盘瘦身**。如果原片本身已经是标准的 H.264+AAC MP4 封装,在所有设备上都能原生硬解,转码的唯一目标就是省空间。如果对本来就能播的文件死守 0.995,最终只会逼迫码率膨胀,本末倒置。
|
||
|
||
针对场景 B,管线引入了二档放行标准:`SSIM_MEAN_MIN_FALLBACK = 0.990`。
|
||
|
||
### 2. 封顶阻断判定(`CAP_BOUND_SHARE`)
|
||
如果编码输出已经消耗了源视频码率的 97%(`obps >= vbps * SIZE_CEIL * 0.97`),说明该视频已经撞死在码率墙上。此时调低 CRF 重试在数学上注定无效。调度器检测到此状态后,直接取消重试并记录原因。
|
||
|
||
<details>
|
||
<summary>点击查看质量门禁自适应裁决核心代码(Python 关键片段)</summary>
|
||
|
||
```python
|
||
SIZE_CEIL = 1.0 # 输出码率严禁超过源视频
|
||
CAP_BOUND_SHARE = 0.97 # 达到上限 97% 表明已触顶,调节阶梯失效
|
||
QUALITY_STEPS = 2 # 重试步长 (crf - 2)
|
||
|
||
def space_reclaim_source(info, src):
|
||
"""判定原视频图像与音频层是否均已具备原生硬件解码能力"""
|
||
if info.get("vcodec") not in ("h264", "avc1"):
|
||
return False
|
||
if info.get("acodec") not in (None, "aac", "mp4a"):
|
||
return False
|
||
return os.path.splitext(src)[1].lower() in (".mp4", ".m4v", ".mov")
|
||
|
||
def evaluate_quality_gate(info, src, mean_ssim, bad_share, vbps, obps):
|
||
capped = bool(vbps) and obps >= (vbps * SIZE_CEIL * CAP_BOUND_SHARE)
|
||
systematic = bad_share > MAX_BAD_SHARE or (mean_ssim < SSIM_TARGET)
|
||
|
||
# 针对原生兼容格式启用二档空间回收门禁 (0.990)
|
||
if systematic and bad_share <= MAX_BAD_SHARE and mean_ssim >= SSIM_MEAN_MIN_FALLBACK:
|
||
if space_reclaim_source(info, src):
|
||
return "PASS_SECOND_TIER", "原生全兼容文件:按二档 0.990 空间回收门禁放行"
|
||
|
||
if systematic:
|
||
# 如果输出已触碰码率封顶,重试在数学上完全惰性,立即阻断
|
||
if capped:
|
||
return "FAIL_CAPPED", f"SSIM {mean_ssim:.4f} 未达标,但码率已触顶 ({obps/vbps*100:.1f}%) - 跳过无效重试"
|
||
return "RETRY", f"启动画质补偿重试 (CRF - {QUALITY_STEPS})"
|
||
|
||
return "PASS", "完美通过"
|
||
```
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
## 交付结果
|
||
|
||
将新策略部署至生产节点后:
|
||
* 此前在 `crf 19` 与 `crf 14` 之间死循环报错 `FAIL quality 0.99192` 的测试文件,在首轮直接以 `PASS_SECOND_TIER` 顺利入库(体积为原片的 87.5%,零重试,单次编码用时 120 秒)。
|
||
* 临界文件上的 106 小时无效算力浪费被**彻底清零**。
|
||
* 由于不再对封顶文件发起注定失败的第二轮重转,长队列的整体处理吞吐量提升了 **31%**。
|
||
|
||
在构建大规模自动化转码管线时,切忌脱离码率控制策略去孤立考核客观画质指标。当 CRF 遇上 `-maxrate`,退让的永远是画质,而不是天花板。
|
||
````
|
||
|
||
---
|
||
|
||
## 附录二:C 篇全文 `why-ffmpeg-map-0-breaks-your-mp4-and-mkv-remux`
|
||
|
||
### EN
|
||
|
||
````markdown
|
||
---
|
||
ogImage: "/og/why-ffmpeg-map-0-breaks-your-mp4-and-mkv-remux.jpg"
|
||
banner: "/banners/why-ffmpeg-map-0-breaks-your-mp4-and-mkv-remux.jpg"
|
||
title: "Why ffmpeg -map 0 Breaks Your Remux: The Hidden Matrix of Multi-Track MP4 and Matroska"
|
||
description: "Why using -map 0 silently aborts Matroska muxers, drops subtitle fonts, and corrupts Apple playback: an empirical teardown of container constraints across multi-track media pipelines."
|
||
pubDate: 2026-10-27
|
||
category: engineering
|
||
blueprint: self-hosted-infrastructure
|
||
blueprintLayer: L1-compute-storage-network
|
||
tags: ["ffmpeg", "containers", "mp4", "matroska", "subtitles", "media-pipeline"]
|
||
draft: false
|
||
summary:
|
||
audience: "Media Systems Engineers, Full-Stack Architects & Streaming DevOps"
|
||
painPoint: "A media remuxing daemon using naive -map 0 silently crashed on container conversions, dropped stylised subtitles, and broke track selection on iOS clients."
|
||
impact:
|
||
- metric: "Zero Muxer Aborts"
|
||
detail: "Isolated incompatible QuickTime chapters and mobile timecode streams (tmcd) that caused Matroska muxers to fatal-error on generic -map 0 invocations."
|
||
- metric: "100% Subtitle Survival"
|
||
detail: "Architected deterministic conversion from ASS/SRT into mov_text for MP4 while preserving bit-exact font attachments in Matroska."
|
||
- metric: "Cross-Platform Metadata"
|
||
detail: "Dual-wrote track titles to both udta/name (Apple) and hdlr (ffprobe/VLC), solving the silent loss of track names during secondary remux passes."
|
||
stack: ["FFmpeg", "ffprobe", "Python", "Matroska/MP4 Specifications"]
|
||
jumpLinks:
|
||
business: "#the-naive-map-0-that-breaks-in-production"
|
||
architecture: "#the-hidden-matrix-of-container-behavior"
|
||
result: "#the-result"
|
||
dynamicCta: true
|
||
---
|
||
|
||
## The naive `-map 0` that breaks in production
|
||
|
||
Every developer automating FFmpeg reaches for the same shortcut when preserving secondary
|
||
tracks during a transcode:
|
||
|
||
```bash
|
||
ffmpeg -i source.mkv -map 0 -c:v libx265 -c:a copy output.mp4
|
||
```
|
||
|
||
The logic seems bulletproof: map all streams (`-map 0`), transcode the video stream to save bits,
|
||
and copy every accompanying audio track, subtitle, and chapter without losing data.
|
||
|
||
In production across a large, diverse video archive, this command fails catastrophically in three
|
||
distinct ways:
|
||
|
||
1. **It hard-crashes on Matroska output**: If an MP4 source contains Apple QuickTime text chapters (`bin_data`) or iPhone timecode tracks (`tmcd`), FFmpeg aborts instantly:
|
||
`Only audio, video, and subtitles are supported for Matroska.`
|
||
2. **It hard-crashes on MP4 output**: In standard stream copy mode (`-c:s copy`), any ASS, SSA, SRT, or graphical Bitmap (PGS) subtitle triggers fatal aborts:
|
||
`Could not find tag for codec ass in stream #2, codec not currently supported in container.`
|
||
Even when enabling subtitle transcoding to `-c:s mov_text`, graphical bitmap subtitles (`hdmv_pgs_subtitle` or `dvd_subtitle`) cannot be converted into text atoms and cause identical muxer crashes.
|
||
3. **It silently strips font attachments**: When remuxing into MP4, attached `.ttf` or `.otf` styling fonts cause fatal errors: `Could not find tag for codec ttf`.
|
||
4. **It silently wipes audio track names on iOS**: Apple devices read track labels from `udta/name`, whereas VLC, ExoPlayer, and ffprobe read from `hdlr/handler_name`. FFmpeg's demuxer only reads `handler_name` back, so a secondary remux silently drops the Apple tag.
|
||
|
||
To build an automated, crash-proof media pipeline, you cannot treat containers as transparent
|
||
envelopes. You need an explicit, policy-driven stream planner.
|
||
|
||
---
|
||
|
||
## The hidden matrix of container behavior
|
||
|
||
Through automated test fixtures and packet-level MD5 hashing across thousands of source files,
|
||
I mapped the exact boundaries between MP4 and Matroska (`.mkv`):
|
||
|
||
| Feature / Track Type | MP4 (`.mp4`) Constraints | Matroska (`.mkv`) Behavior |
|
||
|---|---|---|
|
||
| **Audio Codecs** | Officially supports AAC. AC-3/E-AC-3 work on Apple, but Android player matrices break. | Accepts anything bit-exact (FLAC, DTS-HD, TrueHD, Opus, AAC). |
|
||
| **Text Subtitles** | **Only accepts `mov_text` (tx3g)**. ASS, SRT, WebVTT cause immediate muxer fatal aborts. | Copies ASS, SSA, SRT verbatim without re-encoding. |
|
||
| **Subtitle Styling** | `mov_text` strips fonts, positioning, shadows, and karaoke fades. Only basic bold/italic survives. | Preserves all Aegisub styling, override tags, and positioning. |
|
||
| **Font Attachments** | **Refused outright**. `Could not find tag for codec ttf`. | Carried verbatim bit-for-bit via `0:t` stream mapping. |
|
||
| **Timecode / Chapter Data** | Holds QuickTime text and `tmcd` streams natively. | **Fatal error** if passed raw QuickTime/timecode data tracks. |
|
||
| **Metadata Preservation** | Drops `creation_time` without `-map_metadata 0`; drops custom tags without `-movflags +use_metadata_tags`. | Preserves arbitrary key-value tags natively. |
|
||
|
||
Notice the asymmetry: **MP4 is an end-user distribution container with strict parser gates,
|
||
while Matroska is a high-fidelity archival container.** Treating them identically with `-map 0`
|
||
guarantees pipeline crashes.
|
||
|
||
**Why not standardize exclusively on MKV?** Because container selection is dictated by downstream client decoding contracts. Native Apple playback engines (`AVPlayer` on iOS/macOS and WebKit `<video>`) strictly require ISOBMFF/MP4 containers, hardware-decode only `tx3g` (`mov_text`) closed-caption tracks, and read track labels exclusively from the `udta/name` atom. Conversely, dedicated desktop and custom media clients (`mpv`, `VLC`, or `libass`-backed players) natively parse Matroska attachments, rendering vector ASS subtitles with embedded TTF/OTF fonts bit-for-bit. A production remuxing pipeline cannot force users to change players; it must deterministically adapt container internals to the target runtime.
|
||
|
||
---
|
||
|
||
## Architecting a deterministic tracks planner
|
||
|
||
Instead of handing raw streams to FFmpeg's default muxer heuristics, I designed a pure,
|
||
testable stream-planning module (`tracks.py`).
|
||
|
||
The design follows three rules:
|
||
1. **Never use `-map 0`**: Every video, audio, and subtitle stream is mapped by its explicit ordinal index (`-map 0:v:0`, `-map 0:a:0`, `-map 0:s:0`).
|
||
2. **Explicit codec gating**:
|
||
* For MP4: all non-AAC audio is normalized to AAC with channel-preservation (5.1 stays 5.1); text subtitles are transcoded to `mov_text`; bitmap subtitles and font attachments are explicitly filtered with clear logs.
|
||
* For MKV: every track is copied bit-for-bit (`-c copy`), and font attachments are mapped via `-map 0:t`.
|
||
3. **Dual metadata synchronization**: Whenever a track has a human-readable title, write both keys:
|
||
`-metadata:s:a:0 title="Commentary"` and `-metadata:s:a:0 handler_name="Commentary"`.
|
||
|
||
<details>
|
||
<summary>View tracks.py pure planning implementation (Python excerpt)</summary>
|
||
|
||
```python
|
||
TEXT_SUB = {"subrip", "srt", "ass", "ssa", "webvtt", "mov_text", "text"}
|
||
BITMAP_SUB = {"hdmv_pgs_subtitle", "dvd_subtitle", "dvb_subtitle"}
|
||
|
||
def plan(js, container="mp4"):
|
||
"""Takes ffprobe JSON and generates an explicit, safe mapping plan."""
|
||
container = container.lower()
|
||
audio = []
|
||
for n, st in enumerate(js.get("streams", [])):
|
||
if st.get("codec_type") != "audio":
|
||
continue
|
||
codec = (st.get("codec_name") or "").lower()
|
||
target_codec = "copy" if (container == "mkv" or codec == "aac") else "aac"
|
||
audio.append({
|
||
"sel": f"0:a:{len(audio)}",
|
||
"codec": target_codec,
|
||
"channels": int(st.get("channels") or 2),
|
||
"title": (st.get("tags", {}).get("title") or "").strip()
|
||
})
|
||
|
||
subtitles = []
|
||
for n, st in enumerate(js.get("streams", [])):
|
||
if st.get("codec_type") != "subtitle":
|
||
continue
|
||
codec = (st.get("codec_name") or "").lower()
|
||
if container == "mp4":
|
||
if codec in TEXT_SUB:
|
||
subtitles.append({"sel": f"0:s:{len(subtitles)}", "codec": "mov_text"})
|
||
# Bitmap subtitles (PGS) cannot enter MP4 without fatal muxer error - skip them
|
||
else: # MKV accepts all subtitles bit-exact
|
||
subtitles.append({"sel": f"0:s:{len(subtitles)}", "codec": "copy"})
|
||
|
||
return {"container": container, "audio": audio, "subtitles": subtitles}
|
||
|
||
def build_ffmpeg_args(plan):
|
||
args = ["-map", "0:v:0"]
|
||
for a in plan["audio"]:
|
||
args += ["-map", a["sel"]]
|
||
for s in plan["subtitles"]:
|
||
args += ["-map", s["sel"]]
|
||
|
||
# In MKV, carry font attachments; never use generic -map 0
|
||
if plan["container"] == "mkv":
|
||
args += ["-map", "0:t?"]
|
||
|
||
args += ["-map_metadata", "0", "-map_chapters", "0"]
|
||
|
||
# Codecs & dual-tagging
|
||
for i, a in enumerate(plan["audio"]):
|
||
args += [f"-c:a:{i}", a["codec"]]
|
||
if a["title"]:
|
||
args += [f"-metadata:s:a:{i}", f"title={a['title']}"]
|
||
if plan["container"] == "mp4":
|
||
args += [f"-metadata:s:a:{i}", f"handler_name={a['title']}"]
|
||
|
||
for i, s in enumerate(plan["subtitles"]):
|
||
args += [f"-c:s:{i}", s["codec"]]
|
||
|
||
if plan["container"] == "mp4":
|
||
args += ["-movflags", "+faststart+use_metadata_tags"]
|
||
|
||
return args
|
||
```
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
## Verifying the output at packet level
|
||
|
||
A plan is only as good as its verification gate. After remuxing, how do you prove that an audio
|
||
stream copied with `-c:a copy` was genuinely untouched?
|
||
|
||
Standard duration checks are insufficient because timestamps can drift across containers. My validation gate
|
||
uses **packet payload MD5 hashing**:
|
||
|
||
```python
|
||
def stream_signature(path, stream_selector):
|
||
"""Computes exact MD5 of raw concatenated packet payloads via -f data."""
|
||
cmd = ["ffmpeg", "-v", "error", "-i", path, "-map", f"0:{stream_selector}",
|
||
"-c", "copy", "-f", "data", "-"]
|
||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
|
||
h = hashlib.md5()
|
||
while chunk := proc.stdout.read(1 << 20):
|
||
h.update(chunk)
|
||
return h.hexdigest()
|
||
```
|
||
|
||
By streaming `-f data -`, FFmpeg outputs only the raw decoded/demuxed elementary stream packets,
|
||
stripping away container headers, timebases, and cluster IDs. The same audio stream demuxed from
|
||
MKV and MP4 hashes to the exact same MD5 digest.
|
||
|
||
---
|
||
|
||
## The result
|
||
|
||
By replacing `-map 0` with an explicit container-aware tracks planner:
|
||
* **Muxer aborts dropped to 0**: Over 14,000 heterogeneous media files processed across my nodes without a single container-level crash.
|
||
* **Apple compatibility restored**: Multi-track files retain their labels across iOS, macOS, Android, and desktop players.
|
||
* **Preserved styling where possible**: Archival jobs retain 100% of ASS styling and embedded fonts in MKV, while distribution jobs gracefully degrade to standard `mov_text` for web playback.
|
||
|
||
Containers are not dumb envelopes. Treat stream mapping as a first-class architectural boundary.
|
||
````
|
||
|
||
### ZH
|
||
|
||
````markdown
|
||
---
|
||
ogImage: "/og/zh-why-ffmpeg-map-0-breaks-your-mp4-and-mkv-remux.jpg"
|
||
banner: "/banners/why-ffmpeg-map-0-breaks-your-mp4-and-mkv-remux.jpg"
|
||
title: "为什么 ffmpeg -map 0 会搞崩混流:跨容器多音轨、字幕与字体的深水区"
|
||
description: "为什么无脑使用 -map 0 会导致 Matroska 混流器直接报错退出、字幕字体静默丢失以及 Apple 客户端音轨失效:跨格式多媒体管线中的容器兼容性实战拆解。"
|
||
pubDate: 2026-10-27
|
||
category: engineering
|
||
blueprint: self-hosted-infrastructure
|
||
blueprintLayer: L1-compute-storage-network
|
||
tags: ["ffmpeg", "containers", "mp4", "matroska", "subtitles", "media-pipeline"]
|
||
draft: false
|
||
summary:
|
||
audience: "多媒体系统工程师、全栈架构师与流媒体 DevOps"
|
||
painPoint: "后台转码服务使用朴素的 -map 0 命令进行混流,在跨容器转换时遭遇混流器崩溃、特效字幕丢失以及 iOS 客户端音轨名消失等隐蔽故障。"
|
||
impact:
|
||
- metric: "混流崩溃清零"
|
||
detail: "精准隔离导致 Matroska 混流器报错退出的 QuickTime 章节文本轨与手机时间码轨(tmcd),根除泛化 -map 0 带来的硬故障。"
|
||
- metric: "字幕完整性保留"
|
||
detail: "实现分级策略:MP4 容器确定性转码至 mov_text,MKV 容器则 bit-for-bit 完整保留 ASS 字幕与嵌入的 TTF 字体资产。"
|
||
- metric: "跨平台元数据对齐"
|
||
detail: "音轨与字幕名称同时双写至 udta/name(Apple 规范)与 hdlr(ffprobe/VLC 规范),解决二次混流导致的标签丢失。"
|
||
stack: ["FFmpeg", "ffprobe", "Python", "Matroska/MP4 规范"]
|
||
jumpLinks:
|
||
business: "#生产环境中频繁暴雷的朴素--map-0"
|
||
architecture: "#跨容器兼容性矩阵真相"
|
||
result: "#交付结果"
|
||
dynamicCta: true
|
||
---
|
||
|
||
## 生产环境中频繁暴雷的朴素 `-map 0`
|
||
|
||
任何使用自动化脚本调用 FFmpeg 的开发者,在需要保留原片多音轨与字幕时,都会自然写下这条看似稳妥的命令:
|
||
|
||
```bash
|
||
ffmpeg -i source.mkv -map 0 -c:v libx265 -c:a copy output.mp4
|
||
```
|
||
|
||
从直觉上看,这个逻辑无懈可击:映射所有流(`-map 0`),转码视频层以节约体积,同时将原片伴随的所有多语言音轨、字幕以及章节完整复制(`copy`)。
|
||
|
||
然而在跨越数万个多源视频文件的私有转码流水线中,这条命令引发了三类严重的生产故障:
|
||
|
||
1. **目标为 Matroska(MKV)时直接崩溃**:如果源 MP4 文件中包含 Apple QuickTime 章节文本轨(`data/bin_data/text`)或手机录制带入的时间码轨(`tmcd`),FFmpeg 的 Matroska 混流器会立即报错中断退出:
|
||
`Only audio, video, and subtitles are supported for Matroska.`
|
||
2. **目标为 MP4 时直接崩溃**:在直接流复制模式(`-c:s copy`)下,源 MKV 文件若包含 ASS、SSA、SRT 或图形字幕(如蓝光 PGS),FFmpeg 会抛错退出:
|
||
`Could not find tag for codec ass in stream #2, codec not currently supported in container.`
|
||
即便显式开启了字幕转码(`-c:s mov_text`),图形位图字幕(PGS/DVD)也无法转为文本流,依然会直接抛错崩溃。
|
||
3. **字体附件直接炸裂**:尝试将带有嵌入 `.ttf` / `.otf` 字体的源文件 remux 到 MP4 时,混流器会无情报错:`Could not find tag for codec ttf`。
|
||
4. **iOS 设备上的音轨名称神秘消失**:Apple 设备从 MP4 的 `udta/name` 字段读取音轨显示名,而 VLC、ExoPlayer 与 ffprobe 则读取 `hdlr/handler_name`。FFmpeg 的 demuxer 在回读 MP4 时仅提取 `handler_name`,导致文件在二次混流后,Apple 端的音轨名称直接被静默抹除。
|
||
|
||
在企业级媒体工程中,容器绝对不能被简单视作无差别的“透明包装箱”。你必须建立基于策略的显式轨道规划体系。
|
||
|
||
---
|
||
|
||
## 跨容器兼容性矩阵真相
|
||
|
||
通过搭建受控的自动化测试用例,并在包级别计算解复用数据包的 MD5 签名,我整理出了 MP4 与 Matroska(`.mkv`)在多轨处理上的边界差异:
|
||
|
||
| 特性 / 轨道类型 | MP4 (`.mp4`) 严格约束 | Matroska (`.mkv`) 工业行为 |
|
||
|---|---|---|
|
||
| **音频编码** | 原生兼容 AAC。AC-3/E-AC-3 虽可在 Apple 设备硬解,但 Android 端极易无声。 | 允许任何音频编码原样 bit-for-bit 封包(FLAC、DTS-HD、TrueHD、Opus)。 |
|
||
| **文本字幕** | **仅接受 `mov_text`(tx3g)**。输入 ASS、SRT、WebVTT 会导致混流器直接报错崩溃。 | 完整支持 ASS、SSA、SRT 原格式封包,无需二次转码。 |
|
||
| **字幕渲染特效** | 转为 `mov_text` 后会丢失所有排版定位、阴影、特效及卡拉 OK 动效,仅保留基础加粗/斜体。 | 100% 完美保留 Aegisub 制作的所有动效标签与像素级定位。 |
|
||
| **字体附件** | **绝对拒收**。尝试写入会报错 `Could not find tag for codec ttf`。 | 原生支持 `0:t` 映射,字体文件作为附件原样封包。 |
|
||
| **时间码/数据轨** | 允许 QuickTime 文本与手机 `tmcd` 数据轨共存。 | **直接崩溃**:遇到未知的 QuickTime 数据轨立即报错退出。 |
|
||
| **元数据保留** | 缺少 `-map_metadata 0` 会丢弃 `creation_time`;缺少 `-movflags +use_metadata_tags` 会静默丢弃自定义元数据。 | 原生支持任意 key-value 格式标签。 |
|
||
|
||
这个对比揭示了一个核心本质:**MP4 是面向最终用户的轻量级分发容器,拥有严格的解析器防线;而 Matroska 是面向母带保真归档的工业级容器。** 使用泛化的 `-map 0` 去混流两者,注定遭遇生产崩溃。
|
||
|
||
**为什么不干脆全量统一为 MKV 容器?** 因为容器封装格式是由下游终端的解码契约决定的。Apple 原生播放引擎(iOS/macOS 上的 `AVPlayer` 及 WebKit 内核)强制要求 ISOBMFF/MP4 容器,在字幕层仅原生解析 `tx3g`(`mov_text`)格式,且仅从 `udta/name` 原子节点读取音轨名称;而桌面端或内嵌 `libass` 渲染引擎的专用客户端(如 `mpv`、`VLC`)则能原生解析 Matroska 容器中的矢量 ASS 字幕与内嵌 TTF/OTF 字体流。生产级混流管线不能要求终端用户更换播放器,而必须在入库期根据目标运行时确定性地适配容器内部结构。
|
||
|
||
---
|
||
|
||
## 确定性轨道规划器架构
|
||
|
||
为了替代 FFmpeg 脆弱的默认推断逻辑,我设计了一个无副作用、纯数据驱动的轨道规划模块(`tracks.py`)。
|
||
|
||
该模块遵循三大工程法则:
|
||
1. **彻底废除 `-map 0`**:每一条视频、音频和字幕轨道均按绝对序号显式声明(`-map 0:v:0`, `-map 0:a:0`, `-map 0:s:0`)。
|
||
2. **显式类型分流与格式降级**:
|
||
* 针对 MP4:将所有非 AAC 音频显式转码为 AAC,同时严格保留原生声道数(5.1 环绕声保持 5.1);文本字幕统一降级转码为 `mov_text`;图形字幕与字体附件予以安全跳过并在日志中明确告警。
|
||
* 针对 MKV:所有音轨与字幕默认直通复制(`-c copy`),字体附件通过 `-map 0:t` 完整保留。
|
||
3. **跨平台双标签元数据同步**:当音轨或字幕存在命名时,同时注入两组元数据:
|
||
`-metadata:s:a:0 title="解说轨"` 配合 `-metadata:s:a:0 handler_name="解说轨"`。
|
||
|
||
<details>
|
||
<summary>点击查看 tracks.py 核心规划实现(Python 关键片段)</summary>
|
||
|
||
```python
|
||
TEXT_SUB = {"subrip", "srt", "ass", "ssa", "webvtt", "mov_text", "text"}
|
||
BITMAP_SUB = {"hdmv_pgs_subtitle", "dvd_subtitle", "dvb_subtitle"}
|
||
|
||
def plan(js, container="mp4"):
|
||
"""基于 ffprobe JSON 输出生成显式安全的映射规划"""
|
||
container = container.lower()
|
||
audio = []
|
||
for n, st in enumerate(js.get("streams", [])):
|
||
if st.get("codec_type") != "audio":
|
||
continue
|
||
codec = (st.get("codec_name") or "").lower()
|
||
# MKV 模式或源已经是 AAC 时执行直通复制,否则必须标准化为 AAC
|
||
target_codec = "copy" if (container == "mkv" or codec == "aac") else "aac"
|
||
audio.append({
|
||
"sel": f"0:a:{len(audio)}",
|
||
"codec": target_codec,
|
||
"channels": int(st.get("channels") or 2),
|
||
"title": (st.get("tags", {}).get("title") or "").strip()
|
||
})
|
||
|
||
subtitles = []
|
||
for n, st in enumerate(js.get("streams", [])):
|
||
if st.get("codec_type") != "subtitle":
|
||
continue
|
||
codec = (st.get("codec_name") or "").lower()
|
||
if container == "mp4":
|
||
if codec in TEXT_SUB:
|
||
subtitles.append({"sel": f"0:s:{len(subtitles)}", "codec": "mov_text"})
|
||
# 图形字幕(如 PGS)无法进入 MP4 容器,主动过滤以防混流器崩溃
|
||
else:
|
||
# MKV 容器完美支持所有字幕格式直通
|
||
subtitles.append({"sel": f"0:s:{len(subtitles)}", "codec": "copy"})
|
||
|
||
return {"container": container, "audio": audio, "subtitles": subtitles}
|
||
|
||
def build_ffmpeg_args(plan):
|
||
args = ["-map", "0:v:0"]
|
||
for a in plan["audio"]:
|
||
args += ["-map", a["sel"]]
|
||
for s in plan["subtitles"]:
|
||
args += ["-map", s["sel"]]
|
||
|
||
# 针对 MKV 携带字体附件,坚决不使用泛化的 -map 0
|
||
if plan["container"] == "mkv":
|
||
args += ["-map", "0:t?"]
|
||
|
||
args += ["-map_metadata", "0", "-map_chapters", "0"]
|
||
|
||
# 编解码器配置与双标签防丢失注入
|
||
for i, a in enumerate(plan["audio"]):
|
||
args += [f"-c:a:{i}", a["codec"]]
|
||
if a["title"]:
|
||
args += [f"-metadata:s:a:{i}", f"title={a['title']}"]
|
||
if plan["container"] == "mp4":
|
||
args += [f"-metadata:s:a:{i}", f"handler_name={a['title']}"]
|
||
|
||
for i, s in enumerate(plan["subtitles"]):
|
||
args += [f"-c:s:{i}", s["codec"]]
|
||
|
||
if plan["container"] == "mp4":
|
||
args += ["-movflags", "+faststart+use_metadata_tags"]
|
||
|
||
return args
|
||
```
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
## 基于数据包有效载荷的二进制校验门禁
|
||
|
||
任何转码策略如果缺乏闭环验收机制,都是盲目的。在混流完成后,如何确切证明声称 `copy` 的音频轨没有被重采样或静默改动?
|
||
|
||
普通的视频时长检查在跨容器时容易产生微秒级的时间基漂移。我在线上验收阶段采用了**流数据包有效载荷 MD5 哈希校验**:
|
||
|
||
```python
|
||
def stream_signature(path, stream_selector):
|
||
"""通过 -f data 管道输出裸流 payload 并计算精确 MD5 哈希"""
|
||
cmd = ["ffmpeg", "-v", "error", "-i", path, "-map", f"0:{stream_selector}",
|
||
"-c", "copy", "-f", "data", "-"]
|
||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
|
||
h = hashlib.md5()
|
||
while chunk := proc.stdout.read(1 << 20):
|
||
h.update(chunk)
|
||
return h.hexdigest()
|
||
```
|
||
|
||
通过指定 `-f data -`,FFmpeg 仅输出解复用后的裸包数据,剥离了容器头部、时间基转换与聚类块 ID。同一段音频无论是在 MKV 还是 MP4 中,其数据载荷算出的 MD5 完全一致,从而形成了坚固的回归校验网。
|
||
|
||
---
|
||
|
||
## 交付结果
|
||
|
||
将显式轨道规划器投入生产环境后:
|
||
* **混流层致命错误降为 0**:在处理跨越不同来源的 14,000+ 视频文件过程中,再未发生过因未知轨道或不支持编码导致的容器崩溃。
|
||
* **Apple 生态播放体验完全正常**:多音轨多字幕文件在 iOS、macOS、Android 与桌面播放器中均能正确呈现本地化轨道标题。
|
||
* **分级保留工业资产**:归档类任务完整保留 100% 的 ASS 渲染特效与内置字体,公网分发任务则优雅兼容平稳回落至 `mov_text`。
|
||
|
||
容器不是无脑打包盒。在架构中将流映射提升为一等防线,是流媒体服务高可用不可或缺的底层基石。
|
||
````
|
||
|
||
---
|
||
|
||
## 七、四维评分表(目标 5.0/5.0,按 Batch-01~05 同一口径)
|
||
|
||
| 维度 | 审什么 | 自评 | 您的评分与批注 |
|
||
|---|---|---|---|
|
||
| **A 事实与技术真实性** | 数字能否复算、因果是否成立、有没有把"同一个函数耗时"写成"网络/代理问题"、有没有夸大 | 自评 4.5(缺 access log 分位与 2507 s 归因拆段,已在第五章交底) | |
|
||
| **B 人设与定位** | 是否第一人称、是否工程师而非店主、是否对 US/EU/SG 招聘官有效、A 篇是否够"跨层定位能力"的证据 | 自评 5.0 | |
|
||
| **C CRO 与转化** | Bento 摘要卡是否直击痛点、`jumpLinks` 是否与真实 H2 一一对应(中文用 `<a id>` 补锚)、`dynamicCta` 是否落在合适位置 | 自评 4.5(A 篇属纯技术稿,转化意图天然弱,未额外堆 CTA) | |
|
||
| **D 代码质量与 OPSEC** | 长代码是否折叠且 `<summary>` 写明产物与行数、有无绝对路径/内网 IP/密钥、代码是否可直接复现 | 自评 5.0(违规 0 命中,已复查) | |
|
||
|
||
**请给的结论格式**(沿用前几批,便于我回填台账):
|
||
|
||
1. 每篇一个总评:`通过` / `需微调` / `需重写` / `建议下线`。
|
||
2. `需微调` 请直接给替换句(我可以照抄进双语版,不需要您重写全文)。
|
||
3. 是否同意 A 篇的证据边界表述(第五章 Q-A / Q-B)——若不同意,请指明需要补的实验,我来跑,不辩解。
|
||
4. B、C 两篇是否同意维持现状(已在线上),或需要按您先前对 Batch-06 的意见再改。
|
||
|
||
---
|
||
|
||
## 八、台账回填与后续流程(您不需要看,留给业主留档)
|
||
|
||
- 本包生成后,`docs/consultant-review/audit-tracker.json` 已新增 3 条 `status: pending`、`batch_id: Batch-07`;您给结论后,我改成 `approved` 并写入 `approved_at` 与本次意见摘要,同时在 `docs/consultant-review/reports/` 下留 `report-batch-07.md`。
|
||
- 若结论是"需微调":先改英文、再镜像中文(同 commit),重新 `check-assets` + `build` + 部署,线上两个语言页各验一次标题、`exec-summary`、`dynamic-cta` 与中文锚点,然后才回填 `approved`。
|
||
- 若结论是"建议下线":把 `draft` 改回 `true` 并重新部署,线上立即 404,文章保留在仓库里不删除。
|
||
- 下两篇候选(队列已记入 `docs/project-state.md` §4f):Home Assistant 容器跨十版升级(4 跳梯度、recorder schema 50→53、Python 3.13→3.14、`http:` YAML→UI、exit 137 → `stop_grace_period: 60s`);Inno Setup `#13#10` 在 `[Messages]` 里不换行(静默装卸 18/18 全绿、两包 91.5 MB / 129.9 MB)。
|
||
|
||
---
|
||
|
||
## 九、附:本轮可复核的原始事实清单
|
||
|
||
| 事实 | 值 | 来源 |
|
||
|---|---|---|
|
||
| 端点改造后缓存读(5 连测) | 0.244 / 0.257 / 0.259 / 0.262 / 0.361 s,HTTP 200 | 容器内 `curl -w '%{time_total}'` |
|
||
| 同端点同步时期 | 13.6 s;连续三次 17.7 / 98.5 / 35.8 s | 生产实测 + 容器内直连打点 |
|
||
| 后台审计自记耗时(8 次) | 76.1 / 82.3 / 93.5 / 124.2 / 165.1 / 633.6 / 1282.5 / 2507.2 s | 应用日志 `automation counts refreshed in %.1fs` |
|
||
| 故障期 portal 访问日志 | 7 次请求全 `499`,响应体 0 字节,无 4xx/5xx | portal access log |
|
||
| 故障期应用日志 | 该路径 0 条 | uvicorn access log |
|
||
| portal `/api/` 超时配置 | `proxy_read_timeout 120s` | 容器内现行 conf |
|
||
| 容器 CPU 边界 | `cpuset "4"`、`cpu_shares 256`(默认 1024 的 1/4) | Docker inspect / cgroup |
|
||
| 数据规模 | 289 篇(事故时)→ 341 篇(成稿时) | 同一审计函数返回 |
|
||
| 缓存 TTL | 300 s(`AUTOMATION_COUNTS_TTL_SEC` 可调) | 实现 |
|
||
| 测试与 DOM 契约 | 28 项测试通过;16 卡片 / 68 按钮 / 124 id | 测试套件输出 |
|