## 现象 默认配置下静态文件服务在**并发 17** 就开始大面积返回 `503`,且**命中 LRU 缓存的请求也照拒**。并发越高,成功 RPS 越低 —— 并发 200 时只剩 4,531,比单连接(16,113)还差。 | 并发 | 成功 RPS | 总 RPS | 503 占比 | |---:|---:|---:|---:| | 16 | 43,294 | 43,294 | 0% | | 24 | 41,562 | 54,866 | 24.2% | | 64 | 13,524 | 54,094 | 75.0% | | 200 | 4,531 | 57,027 | 92.1% | 对照组:同样并发打纯内存路由(不碰磁盘、不碰静态服务)**400 并发以内零 503**,稳定 78,000~79,600 RPS。 悬崖正好落在**并发 17**,也就是默认的 `work_limit`(由 `UV_THREADPOOL_SIZE=4` 推导为 `4×4 = 16`)。 ## 根因 `uvcpp_web_static::serve()` 在**投递 worker 任务之前**就抢工作池名额: ```cpp // src/webapp/uvcpp_web_static.cpp:1143 if (im->work_limit && !im->work_limit->acquire()) { ++im->rejected; ... resp.service_unavailable(); resp.set_header("retry-after", "1"); resp.end(); return; } Impl::job* j = new Impl::job(); ... if (im->cache_enabled()) im->collect_probes(url, j->probes); // ← 缓存查找在这之后 ``` 而**缓存查找发生在 worker 里**(`run_job`),也就是拿到名额之后。于是:**一个命中内存 LRU 缓存、根本不需要工作线程的请求,仍然必须先抢到一个线程池名额,抢不到就 503。** 服务端自己的计数器把这一点坐实了(并发 100、6 秒): ``` 缓存命中 56,503 次 / 未命中 16 次 ← 缓存工作得很好 闸门拒绝 296,715 次 ← 但 84% 的请求在进入缓存前就被挡了 ``` 旁边那段注释解释了"为什么放在 dotfile 判定之后、`new job` 之前",理由只覆盖了"别为 403/404 白占名额",没有覆盖"这个请求可能根本不需要名额"。 ## 影响 - 任何用 `serve_static()` 的服务,默认配置下并发一过 16 就雪崩;**LRU 缓存带来的收益被闸门整体抵消**。 - 这不是"过载保护",而是"过载后服务质量反而下降":成功 RPS 随并发单调递减到比单连接还低。 - 调大 `UV_THREADPOOL_SIZE` 有效(32 时 503 归零,成功 RPS 39,660),但那是把线程池一起调大 —— 治标。 ## 建议 **让缓存命中绕开闸门。** 缓存是进程内 LRU,命中时一个线程池任务都不需要,不该消耗名额。可行的形状: 1. 在 `acquire()` 之前做一次同步的缓存查找,命中则就地发响应; 2. 或者把 `acquire()` 下移到 worker 内部——只在"确实要读盘"那一支才取名额(但那样请求已经排队了,准入就失去意义)。 第 1 条更符合这里"准入而非排队"的设计意图,也与文件里既有的"能同步回绝就同步回绝"的思路一致(dotfile 判定就是这么做的)。 顺带:如果短期不改,至少在文档里点明"静态文件的并发上限默认是 16,且缓存命中也算",否则很难从现象反推到这个配置。 ## 复现 `REPORT.md` §5.2 与 §5.3,脚本 `tests/05_cliff.js`(并发悬崖)与 `tests/04_worklimit.js`(工作池/线程池调优曲线)。环境:libuvcpp 1.1.0 预编译包,Windows 10 / i7-14700K。
Activity
sercebr commentedon Sep 19, 2026
在 master(1.1.7)上复现确认 —— 并且补了一组关键对照
原报告是在 1.1.0 预编译包上测的。把仓库拉下来编了一份 master(本地构建:
UVCPP_BUILD_WEB/WEBAPP=ON,其余关),同样的客户端、同样的用例,结论一致:对照(同一并发区间打纯内存路由
/api/data):16 / 64 / 200 并发分别是 61,004 / 71,668 / 70,351 RPS,零 503。悬崖照旧卡在并发 17,也就是默认
work_limit(UV_THREADPOOL_SIZE=4→4×4 = 16)。关键对照:把闸门关掉之后,吞吐反而回到高位
同一个 harness(keep-alive 连接),只把
work_limit设成 0(不限):关掉闸门之后 8 到 200 并发全程稳定在 ~43,500 RPS、零 503 —— 而且线程池仍然只有 4 个线程,说明线程池并不是瓶颈。
(顺带一条方法论提醒:第一次做这组对照时我用的是"每个请求新建连接"的另一个客户端,量出来闸门关掉反而只有 8k RPS。那是客户端建连开销,不是服务端。换回同一个 keep-alive 客户端才是可比的 —— 凡是要写进结论的数字,两边的测量方式必须一致。)
结论:默认值本身在制造故障
默认配置下,并发一过 16,成功吞吐从 43k 掉到 7.5k(并发 100),而唯一的"收益"是拒绝得快。这不是"用可用性换安全"的保守取舍 —— 被拒绝的那些请求里 99.97% 本来能命中内存 LRU 缓存,服务端做一次 stat 就能给出响应。
uvcpp_web_static.cpp:1143那个work_limit->acquire()是在投递之前做的准入;旁边注释解释的位置理由是"别为 403/404 白占名额",但没有覆盖"这个请求可能只需要一次 stat"。想请作者拍个板
修法有几条路,取舍不同,我不确定哪个符合你原本的设计意图,所以先不动手。列出来供选:
A. 把
work_limit的默认值放大(或不给静态服务设默认闸门)最小改动,实测有效(关掉就是 43k 稳定)。代价是过载时不再"快速失败",而是把任务堆到线程池队列上 —— 但实测线程池 4 线程也没被打满,说明这个担心在当前负载下不成立。
B. 让"只需 stat"的路径不占闸门名额
我原以为缓存命中完全不碰线程池,读了源码发现不是:
cache_probe存的是 mtime/size 快照,worker 里要stat一次来验证新鲜度。所以这条不是"绕开闸门",而是"闸门计的是请求数,而它想限制的是并发的文件读" —— 语义上应该按 IO 计数而不是按请求计数。改动比 A 大,但更贴合闸门本来的目的。C. 保持现状,只把"静态并发上限默认 16、且缓存命中也算"写进文档
至少让使用者能从现象反推到这个配置。零风险,但问题还在。
我倾向 A(立刻可用)或 B(语义正确)。你选一条我就把它做成 PR 并附上前后对照 —— 本机的构建与测量脚本都在,验证是现成的。
(另外提一句:
set_work_limit()的文档说"传 0 = 不限",实测确实如此,这点没问题。)Antruly commentedon Sep 19, 2026
确实, 最初开发webapp模块的时候, 就在考虑这个静态文件服务的问题, 毕竟是属于io操作, 这需要一整套的静态文件读取机制,且不能破坏异步io这个底层框架, 也考虑过这个libuv 的底层 工作线程默认是4个, 如果高负荷运转,工作线程占满的时候, 新的请求进来,目前这个阶段的处理方法是返回503, 当然只是这个版本先实现静态服务文件的访问, 后续肯定是要想办法解决这个问题的, 目前在开发 http2 这个框架, 后续我想好思路解决一下这个问题, 或是你看看有没有什么好的点子解决静态文件io密集任务的访问, 建立缓存的确是一个必要的操作, 但需要管理好这个缓存并不容易, 让我考虑一下这个问题的解决办法吧, 非常感谢你提出的问题, 都是非常实际的问题!
sercebr commentedon Sep 19, 2026
你问有没有好思路 —— 我把自己测到的和读代码看到的整理一下,有几条,按代价从小到大。都没做过原型,属于讨论稿。
先把"错位"说清楚
work_limit名字和注释里的定位是工作池在途上限,也就是"别让线程池上堆太多活"。但它现在的计数单位是请求(在投递之前acquire()),而它真正想保护的东西是并发的那份文件读。这两者在"缓存未命中"时重合,在"命中"时不重合 —— 而命中是绝大多数。我实测的对照(同一 harness、keep-alive):
关掉之后 8→200 并发全程稳定在 ~43.5k、零 503,而且线程池仍然只有 4 个线程。
这个数字是关键的:它说明瓶颈根本不在线程池。粗算一下也对得上 —— 43.5k RPS 里绝大多数是缓存命中,工作线程上只做一次
stat(几微秒),4 个线程的占用率是个位数百分比。所以闸门在 16 这里拒掉的,是线程池完全吃得下的请求。三条路,按代价排
① 让闸门计"文件读",而不是"请求"(我认为最贴合它本来的目的)
现在的次序是「
acquire()→ 投递 → worker 里 stat + 判缓存」。改成把acquire()挪到 worker 里只在真的要读盘那一支:缓存命中路径完全不碰闸门,而未命中路径的保护一点没少(内存面的大头就是那份文件内容)。你注释里担心的"堆着不投只是把线程池队列换成自己的队列、内存照爆"—— 这里堆在池队列上的只是
stat任务,每个几十字节;真正吃内存的读仍然被闸门卡着。所以那个担心在改法 ① 下不成立。代价:准入发生在任务已经在池队列里之后,"准入"这个语义弱化了一点。换来的是命中路径的 503 消失。
② 让缓存命中变成"纯内存"(更长线、但最干净)
命中之所以还要一次
stat,是因为要验新鲜度。如果改成用uv_fs_event/uv_fs_poll盯着每个已缓存的文件,文件一变就失效缓存项,那么命中就是一次纯内存的查表 + 一次写 —— 一个工作线程都不需要,连池都不碰。缓存条目默认上限 256,也就是最多 256 个 watcher,量级上可接受。代价是要处理"watcher 建/拆与 LRU 淘汰同步"这套状态机,比 ① 复杂不少。
顺带一提,做了 ② 之后,
max_cached_file_size(默认 1 MiB)这个上限的意义也会变 —— 现在它同时是"缓存内存预算"和"要不要走流式"的分界,拆开之后可以各管各的。③ 退一步的两件事(不解决根因,但便宜)
一个可以直接当验收标准的数字
改完之后,缓存命中路径的吞吐应当接近"关掉闸门"那条曲线(8→200 并发 ~43.5k、零 503),而未命中路径仍然受闸门保护(把闸门调到 1 时,第二个并发未命中请求应当照样 503)。
前半句我现在就能测(
tests/05_cliff.js和04_worklimit.js都在),后半句要等改动落地。无论最后选哪条,有一句文档建议补上
现在的行为是"静态并发上限默认 16,且缓存命中也算"。这句话在文档里找不到,而现象上(并发一过 16 就大面积 503、但 RPS 又不像过载)非常难反推到这个配置。先把它写出来,就算代码这轮不动,也能省掉下一个人的半天。
要做原型的话我可以按 ① 写一版(本地有构建和前后对照的脚本,改完能直接量出来)。你定方向就行。
sercebr commentedon Sep 19, 2026
方案 ① 我做了一版原型,本地把数字量出来了。只是本地实验,没推 PR、没跑全量用例、也没做并发正确性审查 —— 先把曲线贴上来,供你判断这条方向值不值得走。
改了什么
一句话:
acquire()从serve()挪到run_job()里真的要读盘那一支之前;名额的归还跟着一个slot_held标志走(因为现在有"没拿过"的路径了)。曲线:并发不再崩塌
同一 harness(keep-alive,5 秒/档,打
/index.html):8→200 并发全程 42–44k、零 503,正好贴着"把闸门整个关掉"那条曲线的天花板(~43.5k)。也就是说缓存命中路径已经跑满,而闸门并没有消失。
闸门的语义变化(这条最需要你拍板)
同一个配置(
work_limit=1)、同一个已缓存的文件、同一个客户端 50 并发:serve())反过来,闸门仍然在保护它该保护的东西:冷缓存下 50 个线程同起跑抢一个还没缓存的文件,原型照样会拒(实测 12 次 503)—— 因为那一次读确实在进行。窗口只有约 1 ms,缓存一热就没人需要名额了。
改完之后闸门的覆盖集合变成这样,这是它和今天最大的差别,请重点看最后一行:
stat,不读盘 ← 这正是要修的uvcpp_web_file的滑动窗口自己封顶要问你的就是那行 STREAM:它也在读盘,但它的内存已经有别的机制封顶了。闸门再卡一层,只会把大文件下载也变成 503(而且大文件读取本来就慢,窗口比小文件长得多)。所以我倾向"不覆盖它",但那是我基于"闸门=内存保护"的理解推的 —— 如果它原本的意图还包括"别让线程池上堆太多 IO",那 STREAM 就该一起算。
这个原型没做的事
web_app_static_func那批得跑过才算数);slot_held那条路径的并发正确性没审(取/还在不同线程上,但原有的 acquire/release 也是这个形状,所以风险应该不高);acquire失败时j->status走哪个响应"以外的收尾细节。要是方向定了,我可以把它做成能合的样子(补齐用例 + 全量跑一遍)。要是你另有想法,这份就当个数据点 —— 结论至少有一条是稳的:现在这个闸门在缓存命中路径上收的是"过路费",不是"保护费"。
fix(webapp): 静态闸门只在真要读盘时取名额,缓存命中不再被 503 (#12)
Antruly commentedon Sep 19, 2026
修好了,合入
d703b4b(#12,作者 @sercebr)—— 落点就是你列的方案 ①:acquire()从serve()挪进run_job(),只在"确实要读盘"那一支之前取名额,归还跟着
job::slot_held走。你提的两条验收标准
前半句(缓存命中路径的吞吐接近"关掉闸门"那条曲线) —— 作者在 PR 里量了,
/index.html、keep-alive、每档 5 秒:16→200 并发全程 43.7k~44.2k、503 占比 0%,与关掉闸门那条曲线同高。
后半句(未命中路径仍然受保护) —— 有一条专门的用例钉着:
test_stream_path_still_gated(流式那一支永远是缓存未命中,仍然占名额),变异
S15 stream_not_gated能抓住它。流式要不要继续占名额那处取舍我这边也按"保持现状"定了(它对 #4 报告的现象零贡献,放宽闸门是另一件事)。
本机复核:两棵树
error C/error LNK为 0,ctest -C Release76/76 与 87/87,PageHeap 门禁 rc=0,CI 对
d703b4b、9c6a4cd两跑都是 success。PR 那边的复核细节写在#12 (comment)
一处如实记下的后果
判定点从
uv_queue_work之前挪到 worker 之内,所以work_limit不再是一道"投递前的准入"闸:并发尖峰时多出来的请求会先进 libuv 线程池的队列、再由 worker 回绝。
被回绝的请求本身没有变贵(只做一次
stat),变的是排队这件事 —— 线程池队列长度不再受
work_limit上界约束。要恢复"投递前的准入"得另加一道 loop 侧的快速判据,本 PR 不做。这一条关掉,另一条不关
这个 issue 报的具体缺陷(命中缓存的请求被闸门回 503)到此为止。你回的那段里更长的题目
—— 静态文件缓存本身怎么管(按 IO 计数而不是按请求计数、用
uv_fs_event/uv_fs_poll让命中变成纯内存、短 TTL)—— 是另一件事,值得单开一个 issue 慢慢定方向,别混在"修 BUG"里。
docs(benchmark): 榜上的位置改成 2026-10-15 这一读