Repository navigation
fix(webapp): 静态闸门只在真要读盘时取名额,缓存命中不再被 503 - #12
Conversation
`serve()` 在投递 job 之前就 `work_limit->acquire()`,而"要不要读盘"这个
判断在 worker 里、缓存查找之后。于是命中 LRU 缓存、一次磁盘读都不需要的
请求(也就是绝大多数请求)也要先抢名额,抢不到就 503 —— 闸门卡的是"并发
的磁盘读",计数单位却是"请求"。
把取名额的时机搬到 `run_job()` 里真正要读盘的那两句之前:
- 缓存命中(只做了一次 stat)→ 不取
- HEAD / 404 / 403 等不读盘的分支 → 不取(改动前也不该取,只是那时
没有别的取法)
- 整读进内存 → 取
- 超过 `max_cached_file_size` 走分片流式 → 取(覆盖集合与改动前对齐)
取名额这件事本身没变,只有判定点挪了位置;503 的产出位置随之从 `serve()`
的同步回绝变成 `finish_job()` 里的 `probe_status::REJECTED` 分支,响应内容
(503 + `Retry-After: 1`)逐字不变。
因为"拿过名额"不再是必走的路,归还侧需要凭据:`job::slot_held`。无条件
`release()` 会把别人的名额还掉,闸门自己就成了漏洞。`serve()` 里 `rc != 0`
那一支同理 —— 名额在 worker 里才取,那里根本没有名额可还。
实测(`tests/05_cliff.js`,`/index.html`,keep-alive,每档 5 秒):
| 并发 | master | 修复后 |
|-----:|--------:|--------:|
| 16 | 44,113 (0%) | 43,708 (0%) |
| 24 | 42,248 (**12.8%**) | 44,030 (0%) |
| 32 | 26,871 (**45.1%**) | 44,167 (0%) |
| 64 | 14,239 (**72.3%**) | 43,784 (0%) |
| 200 | 4,401 (**91.8%**) | 43,751 (0%) |
`WEBDEMO_WORK_LIMIT=1`(最严的闸门)下同样全程 ~44k / 0% 503,因为命中
缓存从不触碰闸门。
测试:`web_app_worklimit_func` 新增两组互为对照的断言(同一时刻、同一个被
占住的名额:冷文件必须 503、热文件必须 200),以及 `stream_still_gated`。
变异验证(`tests/tools/stream_mutation.py`)新增 S14/S15,S12/S13 锚点随
代码搬动更新;master 源码 + 新用例 → 3 条断言 FAIL,本分支 → 全绿,两端
既有分组均无回归。
Refs Antruly#4
#12 把取名额从 serve() 搬到了 run_job() 里真正要读盘的那两句之前,所以 "拿不到就当场 503、一个线程池任务都不投"不再成立:不读盘的分支(LRU 命中、 HEAD / 404 / 403)根本不占名额,而超限的请求已经进了线程池 —— 这道闸门 不是投递前的准入。 Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
|
已合入( 合入后 master 上全量重建 + 本地复核A/B(本机,同一棵树,只换源码)
变异扫描(
归还侧那处顺带修掉的真缺陷值得单独说一句:名额搬进 worker 之后"拿过名额"不再是 你末节问的那个取舍:保持现状,流式继续占名额理由和你写的一样:被移出覆盖集合的只有"缓存命中"这一支,其余逐一对齐;流式那一支 一处如实记下的后果(不阻塞)判定点从 两个维护者 follow-up(已经推上 master)
另: |
`doc/benchmark-rig.md` 的根因分析那一节末尾留着这么一句:Windows 上要拿到 USO 的 效果**只有绕开 `uv_udp_send` 一条路**,而那背离了"只用 libuv 的 I/O 原语"的初衷, 所以**要么不做,要么做进 libuv 自己** —— "不是本库该私下绕过去的"。 `5b04cce`(1.5.2)**正是**在本库里做了这件事:`UVCPP_ENABLE_UDP_GSO`,Windows + QUIC 上默认 ON,`src/quic/uvcpp_quic_session.cpp` 里 `setup_gso()` 拿 `uv_fileno()` 设 `UDP_SEND_MSG_SIZE`、把一批等长数据报折成一次 `WSASendTo`。而 `benchmark-rig.md` 自 `49b6535` 之后再没被改过,`quic-guide.md §1.2` 又是后来才写的 —— 两处之间没有任何指针。**今天读这一节的人会得出"USO 没做"的结论**,而 `doc/quic-guide.md` §1.2 正好在讲它做成了什么、量到多少、坑在哪。 改法(只补指针,**不删原文**):在那一段后面接一句"这是当时的分析结论,不是现在的 决定",写明 1.5.2 选了哪条路、实测倍数(1.33–1.35× 墙钟、线上数据报数在噪声内不变)、 以及链到 `quic-guide.md §1.2` 的锚点。原文留着是**有意**的:它是"先定根因、再谈做 不做"的现场,改成事后叙述就看不出取舍被推翻过 —— 这句话写进去了。 判据:`check_docs` / `check_doc_lines` / `check_ci_layout` / `check_doc_versions` 全 rc=0;`check_docs` 判据 2 会连 `#12-windows-上的发送侧-udp-gsouso152` 这个锚点 一起核(`check_docs.py:338` 对 `anchors_of()` 查表),所以新链不是"看起来对"。
这个 PR 修 #4。里面有一处需要你拍板的取舍(流式那一支要不要继续占名额),写在最后一节,我先按"最小改动"做了,改回去很容易。
症状与根因
uvcpp_web_static::serve()在投递 job 之前就取工作池名额:而"这次要不要读盘"的判断在
run_job()(worker 线程)里、缓存查找之后:两处顺序一颠倒,结果是:命中 LRU 缓存、一次磁盘读都不做的请求(也就是绝大多数请求)也要先抢名额 —— 抢不到就 503。
闸门想卡的是「并发的磁盘读」,它的计数单位却是「请求」。这两者在缓存未命中时重合、在命中时不重合。默认
work_limit=UV_THREADPOOL_SIZE × 4= 16,所以并发一过 16 就开始大面积回绝本来零成本、本来能立刻返回的请求。实测(
tests/05_cliff.js,/index.html,keep-alive,每档 5 秒)并发 64 时被拒的 71.7% 里,99.97% 是缓存命中(同一 harness 加了计数确认)。所以这不只是"默认值保守",而是并发过 16 之后主动把服务打垮。
改动
把取名额的时机搬到
run_job()里真正要读盘的那两句之前。取名额这件事本身没变,只有判定点挪了位置。stat)max_cached_file_size,走分片流式新增
Impl::acquire_slot(job*):没设闸门直接放行;抢不到就把j->status写成新的probe_status::REJECTED并返回 false。503 的产出位置随之从
serve()的同步回绝变成finish_job()里的一个分支,响应内容逐字不变(503 +Retry-After: 1)。rejected计数仍然只在 loop 线程上加(acquire_slot只写j->status,不碰共享计数)。一个必须一起改的地方:归还侧的凭据
名额搬进 worker 之后,"拿过名额"不再是必走的路(命中缓存 / HEAD / 404 都不拿)。原来的
就变成了无条件归还 —— 它会把别人的名额还掉,闸门自己成了漏洞。所以
job加了bool slot_held(构造函数里初始化为false),归还侧看它:serve()里uv_queue_work返回非 0 的那一支同理:任务从来没进过池子,而名额现在是在 worker 里才取的,所以那儿根本没有名额可还 —— 原来那句release()在今天会凭空还掉别人的名额,已删除。验证
新增用例(
tests/functional/web_app_worklimit_func.cpp)static_integration里加了一组互为对照的断言 —— 同一时刻、同一个被占住的名额,差别只在"这个 URL 有没有进过缓存":只测其中一条分不出"命中绕开闸门"和"闸门整个失效"这两种截然不同的实现。
另外两处口径收紧(都是为了让判据真的成立):
/s/hello.txt换成从没被请求过的/s/cold.txt—— 否则这条断言的测的是"缓存会不会命中",不是"闸门会不会拒"。release一次都不需要,用例会在"漏还名额"的实现上照样全绿。变异验证(
tests/tools/stream_mutation.py)期望: 200 / 实际: 503;期望: 1 / 实际: 2×2)static_no_admission(不做准入)static_integration(503)slot_never_released(名额只拿不还)static_integration(连发 5 个冷文件)cache_hit_takes_slot(命中也要占名额 = 还原改动前行为)static_integration(饱和-命中)stream_not_gated(流式不占名额)stream_still_gatedS12/S13 的锚点随代码搬动做了更新。两端既有分组全部通过(
accounting、unlimited、default_limit、does_not_queue、所有wakeup_*)→ 无回归。完整 webapp 功能套件:21/21 通过。
最严闸门下
WEBDEMO_WORK_LIMIT=1(一个名额)时,修复后的构建在并发 8→200 全程仍是 ~44k RPS / 0% 503 —— 因为命中缓存从不触碰闸门。这条是这次改动最直接的效果证据。max_cached_file_size以上的文件走resp.send_file_range()分片流式下发。这一支在 master 上就是占名额的,我保持了占用(run_job()里 STREAM 分支之前acquire_slot)。理由:覆盖集合里被移出去的成员因此只有缓存命中这一支,其余与改动前逐一对齐。放宽闸门是另一件事,得单独讨论 —— 而且流式文件永远是缓存未命中(
probe_status里STREAM与OK互斥),所以它对 #4 报告的那个现象没有任何贡献。如果你想让流式也放开(比如认为"分片下发不占线程池"或另有考虑),改动是删掉
run_job()里 STREAM 分支前面那一行if (!acquire_slot(j)) return;,同时把test_stream_path_still_gated和变异 S15 一起反向 —— 用例是钉住当前语义的,不一起改会红。这一条我没有替你选。材料
05_cliff_pr4_master.json/05_cliff_pr4_fix.json/05_cliff_pr4_fix_limit1.jsonRefs #4