Skip to content

fix(webapp): 静态闸门只在真要读盘时取名额,缓存命中不再被 503 - #12

Merged
Antruly merged 1 commit into
Antruly:masterfrom
sercebr:fix/static-work-limit-cache-hit
Sep 19, 2026
Merged

Antruly merged 1 commit into
Antruly:masterfrom
sercebr:fix/static-work-limit-cache-hit

Conversation

@sercebr

@sercebr sercebr commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

这个 PR 修 #4。里面有一处需要你拍板的取舍(流式那一支要不要继续占名额),写在最后一节,我先按"最小改动"做了,改回去很容易。


症状与根因

uvcpp_web_static::serve() 在投递 job 之前就取工作池名额:

if (im->work_limit && !im->work_limit->acquire()) {
  ++im->rejected;  resp.service_unavailable();  resp.set_header("retry-after", "1");
  resp.end();  return;
}

而"这次要不要读盘"的判断在 run_job()(worker 线程)里、缓存查找之后:

for (size_t i = 0; i < j->probes.size(); ++i) { ... 命中就 j->data = p.data; return; }

两处顺序一颠倒,结果是:命中 LRU 缓存、一次磁盘读都不做的请求(也就是绝大多数请求)也要先抢名额 —— 抢不到就 503。

闸门想卡的是「并发的磁盘读」,它的计数单位却是「请求」。这两者在缓存未命中时重合、在命中时不重合。默认 work_limit = UV_THREADPOOL_SIZE × 4 = 16,所以并发一过 16 就开始大面积回绝本来零成本、本来能立刻返回的请求。

实测(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%)
48 19,322(61.6%) 43,962(0%)
64 14,239(72.3%) 43,784(0%)
100 8,304(83.3%) 43,980(0%)
200 4,401(91.8%) 43,751(0%)

并发 64 时被拒的 71.7% 里,99.97% 是缓存命中(同一 harness 加了计数确认)。所以这不只是"默认值保守",而是并发过 16 之后主动把服务打垮。


改动

把取名额的时机搬到 run_job() 里真正要读盘的那两句之前。取名额这件事本身没变,只有判定点挪了位置。

支路 改动前 改动后
缓存命中(只做了一次 stat) 占 不占
HEAD / 404 / 403 等不读盘的分支 占(没办法,那时只有这一种取法) 不占
整读进内存 占 占
超过 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 都不拿)。原来的

if (j->self->work_limit) j->self->work_limit->release();

就变成了无条件归还 —— 它会把别人的名额还掉,闸门自己成了漏洞。所以 job 加了 bool slot_held(构造函数里初始化为 false),归还侧看它:

if (j->slot_held) j->self->work_limit->release();

serve() 里 uv_queue_work 返回非 0 的那一支同理:任务从来没进过池子,而名额现在是在 worker 里才取的,所以那儿根本没有名额可还 —— 原来那句 release() 在今天会凭空还掉别人的名额,已删除。


验证

新增用例(tests/functional/web_app_worklimit_func.cpp)

static_integration 里加了一组互为对照的断言 —— 同一时刻、同一个被占住的名额,差别只在"这个 URL 有没有进过缓存":

冷文件 cold.txt(必然要读盘)  → 503 + Retry-After
热文件 hello.txt(已在缓存)   → 200 + 正常 body,且 rejected_count 仍为 1

只测其中一条分不出"命中绕开闸门"和"闸门整个失效"这两种截然不同的实现。

另外两处口径收紧(都是为了让判据真的成立):

  • 饱和请求从 /s/hello.txt 换成从没被请求过的 /s/cold.txt —— 否则这条断言的测的是"缓存会不会命中",不是"闸门会不会拒"。
  • "名额必须每次都还"那一组的 5 次连发改成 5 个不同的冷文件 —— 连发同一个的话,第 1 次读过之后后面全走命中,release 一次都不需要,用例会在"漏还名额"的实现上照样全绿。

变异验证(tests/tools/stream_mutation.py)

变异 结果
master 源码 + 新用例 3 条断言 FAIL(期望: 200 / 实际: 503;期望: 1 / 实际: 2 ×2)
本分支源码 + 新用例 全绿
S12 static_no_admission(不做准入) 抓住,红在 static_integration(503)
S13 slot_never_released(名额只拿不还) 抓住,红在 static_integration(连发 5 个冷文件)
S14 cache_hit_takes_slot(命中也要占名额 = 还原改动前行为) 抓住,红在 static_integration(饱和-命中)
S15 stream_not_gated(流式不占名额) 抓住,红在 stream_still_gated

S12/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.json
  • 本机环境:Windows 10 + MSVC(VS 18 Community),i7-14700K;全部流量走 loopback
  • 相关 issue:#4

Refs #4

`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
@Antruly
Antruly merged commit d703b4b into Antruly:master Sep 19, 2026
16 checks passed
Antruly added a commit that referenced this pull request Sep 19, 2026
#12 把取名额从 serve() 搬到了 run_job() 里真正要读盘的那两句之前,所以
"拿不到就当场 503、一个线程池任务都不投"不再成立:不读盘的分支(LRU 命中、
HEAD / 404 / 403)根本不占名额,而超限的请求已经进了线程池 —— 这道闸门
不是投递前的准入。

Co-Authored-By: Claude Code <noreply@anthropic.com>
Antruly added a commit that referenced this pull request Sep 19, 2026
Co-Authored-By: Claude Code <noreply@anthropic.com>
@Antruly

Antruly commented Sep 19, 2026

Copy link
Copy Markdown
Owner

已合入(d703b4b),谢谢 —— 问题本身核实为真(A/B 见下),落点我也认为是这一版里最合适的选择。

合入后 master 上全量重建 + ctest -C Release 76/76 绿,PageHeap 门禁 rc=0(76 个用例裸跑与完整页堆下都绿、过期 DLL 0 个);CI 两次推送(d703b4b、9c6a4cd)都 success。

本地复核

A/B(本机,同一棵树,只换源码)

  • master 源码 + 你新加的用例:static_integration 红,失败签名与你描述的一致 ——
    饱和-命中:… 期望: 200 实际: 503、回绝计数 期望: 1 实际: 2;
  • 整个 PR + 新用例:13 个分组全过([worklimit] ALL PASS)。

变异扫描(tests/tools/stream_mutation.py 全量跑):S1–S11 既有 11 条仍然抓住,
你新加的 4 条也都抓住、红的正是各自期望的那一组:

变异 红在
S12 static_no_admission static_integration(饱和)
S13 slot_never_released static_integration(连发 5 个冷文件)
S14 cache_hit_takes_slot static_integration(饱和-命中)
S15 stream_not_gated stream_still_gated

归还侧那处顺带修掉的真缺陷值得单独说一句:名额搬进 worker 之后"拿过名额"不再是
必走的路,原来那句无条件 release() 会还掉别人的名额;uv_queue_work 返回非 0
的那一支更是"从没取过却凭空归还"。job::slot_held 把这两处一起收住了 —— 这是整个
PR 里最容易漏掉的部分。计数归属也对:acquire_slot() 只在 worker 写 j->status,
rejected 仍只在 loop 线程加(finish_job()),release() 仍在 after_work 里同步
跑,唤醒回调的保证没变。

你末节问的那个取舍:保持现状,流式继续占名额

理由和你写的一样:被移出覆盖集合的只有"缓存命中"这一支,其余逐一对齐;流式那一支
永远是缓存未命中,对 #4 的现象零贡献。放宽是另一件事,test_stream_path_still_gated
与 S15 保持原样。

一处如实记下的后果(不阻塞)

判定点从 uv_queue_work 之前挪进了 worker,所以 work_limit 不再是一道"准入"闸:
并发尖峰里多出来的请求会先进 libuv 线程池的队列、再由 worker 回绝,master 是在投递前
就挡掉。被回绝的请求本身没变贵(一次 stat),变的是排队这件事 —— 线程池队列长度
不再受 work_limit 上界约束。要恢复投递前的准入得另加一道 loop 侧的快速判据,不在
这个 PR 的范围里。

两个维护者 follow-up(已经推上 master)

  1. doc/webapp-guide.md 里"静态文件:acquire() 拿不到就当场回 503 … 一个线程池任务
    都不投"这句在合入后不再成立,改掉了。
  2. stream_mutation.py 的跨行锚点在本机一条都匹配不上:工作区是 CRLF
    (Windows 上 core.autocrlf=true,本仓库没有 .gitattributes),而 S14/S15 是唯二
    的跨行锚点。匹配不上会被记成"未施加"、汇总里显示 --,退出码仍然是 0 ——
    也就是说这两条变异在本机一次都没真正跑过,直到我把锚点按目标文件的换行符归一。
    提这个不是说你的脚本有问题,是说"没施加"和"抓住了"在汇总里长得太像了:如果哪天
    你在自己机器上看到 --,值得当成错误而不是"跳过"。这个判据要不要在脚本里升级成
    失败,看你的意思。

另:S4 next_not_retained 的期望组(handler_runs_before_body)和实际红的组对不上
(红的是 on_end_then_response / slow_but_progressing_survives 那几个),这条改动
之前就是这样,与本次无关,先记在这里。

Antruly added a commit that referenced this pull request Oct 10, 2026
`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()` 查表),所以新链不是"看起来对"。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants