Repository navigation
Description
压缩这块之前一直是关着测的(靶场为了不让它掩盖吞吐数字,写死了 set_compression(false))。这轮把它打开补测。
先说一个前置事实,因为它决定了下面这些是不是"要显式打开才碰得到":预编译包的默认是开着的。直接问 DLL:
cfg.compression = true
cfg.compress_min_body_size = 1024
http_server.is_compression_enabled() = true
(lib/pkgconfig/uvcpp.pc 里是 -DUVCPP_ZLIB_ENABLE=1,DLL 里也确实有 deflate 符号。)
先说结论:协商本身是对的。 gzip/deflate/br/identity/q 值、解回原文逐字节一致、Vary、304 不带编码、Range 与编码自洽、排除表、阈值以下不压 —— 全对(9 条里 7 条过,另 2 条就是下面的问题)。
1. HEAD 报的长度与实际会发的长度不是一回事
HEAD /cz_1048576.html Accept-Encoding: gzip
-> content-length: 1048576 (没有 content-encoding)
GET /cz_1048576.html Accept-Encoding: gzip
-> content-encoding: gzip
content-length: 3119
内存型 JSON 路由同样(GET 102 / HEAD 20048)。
RFC 9110 §9.3.2:
The server SHOULD send the same header fields in response to a HEAD request
as it would have sent if the request method had been GET.
后果能真跑出来 —— keep-alive 上先 HEAD 探长度、再按长度读满(下载器、缓存重建、断点续传都是这个形状):
HEAD 声明 content-length = 1048576, content-encoding = (无)
GET 实际 content-length = 3119, content-encoding = gzip
超时:只读到 3119/1048576 字节就再也没有数据了 —— 客户端挂住
但根因不在 apply_compression 里那个 is_head
uvcpp_http_server.cpp 里有一句
if (ctx.is_head) return false;看着就是它。我删掉、重编、再测 —— 一条路由都没有变化(HEAD 仍然是 CE=none + 未压缩的 CL)。
因为轮到 apply_compression 时 body 已经没了,上面那句 resp.body.size() < compress_min_body_ 早就先返回了 —— is_head 那行根本够不着。body 是在更早的 webapp 层被丢掉的:
- 静态层:
uvcpp_web_static.cpp:1164的j->need_data = !j->is_head(HEAD 不读盘),
然后在:894显式set_header("content-length", ...)按文件大小钉死; - 一般路径:
uvcpp_web_response::set_head_only(true)先算长度再丢 body; - 路由层:
uvcpp_web_router.cpp:2451的head_of_get打上这个标记。
也就是说,长度是在压缩发生之前就钉死了的,而压缩在 HTTP 层。
这条违反的是代码自己文档里的不变式
src/webapp/uvcpp_web_response.h:26:
HEAD 的语义是「和 GET 一样的头,但没有 body」。所以
set_head_only(true)
会在保留Content-Length(反映 GET 应有的长度)之后丢掉 body 字节。
uvcpp_web_static.cpp:895 那条注释同样写着「Content-Length 必须和 GET 一模一样」。压缩一开,这两句就都不成立了。
修法上想听你的意见(我没动手)
正确做法是 HEAD 也算一遍压缩,但那意味着 HEAD 要读盘 + 压一遍 —— 而"HEAD 不读盘"正是静态层刻意保的设计。三条路:
- HEAD 也压:语义最正,代价是 HEAD 变贵;
head_as_get之外可能还得再加一道开关。 - HEAD 不压、但也不谎报长度:不发
content-length。代价是与"钉住长度"的既有设计相反。 - 把压缩结果缓存(下面第 3 点本来也建议做缓存)—— 我倾向这条,它同时解掉"每请求重压一遍"。
2. * 会把显式 q=0 的编码选回来
Accept-Encoding: *, gzip;q=0 -> content-encoding: gzip
q=0 只是 continue 跳过、没记下来,* 又把它选回去,而 * 的 q 默认 1.0、优先级最高,顺序反过来也一样(gzip;q=0, * 同样回 gzip)。
这条已经提了 PR:#10(含变异验证)。
仓库里既有的 "gzip;q=0, deflate" 用例抓不到它 —— 那条靠的是 deflate 显式出现且 q 更高。
3. 附带的容量观察(不是 bug,但可能值得看一眼)
Accept-Encoding: gzip 会让服务端在事件循环线程上做压缩,且不经过 work_limit。
/bundle.js(248 KB minified JS,压缩比 6.6:1):
| 并发 | 不发 gzip | 发 gzip |
|---|---|---|
| 1 | 3,301 rps / 781 MB/s | 388 rps / 13.9 MB/s |
| 16 | 7,750 / 1,835 | 412 / 14.8 |
| 64 | 6,935 / 1,642 | 415 / 14.9 |
开着 gzip 的 RPS 从并发 1 到 64 是平的(388→415),关掉时是 3,301→6,935。同时一个完全不碰压缩的 /api/health 的 p99 被拖起来:
| 并发 | 不发 gzip | 发 gzip |
|---|---|---|
| 16 | 5 ms | 32 ms |
| 64 | 14 ms | 125 ms |
20 KB 的内存型 JSON 代价温和得多(~1.5x:14,987 → 8,896 rps),所以问题集中在"响应体大 + 可压缩"这一档 —— 而那正好是压缩最该起作用的一档。
尺寸边界是 max_cached_file_size = 1 MiB,卡得很干脆:
| 大小 | content-encoding |
|---|---|
| 1,048,576 | gzip |
| 1,048,577 | 无 |
1 MiB 走分片流式下发,body 不存在,压缩拿不到东西可压。这不违规(不会谎报
content-encoding),但方向上正好反了:小到不值得压的在压,大到最该压的不压。如果第 1 条的修法走"缓存压缩结果",这一档也能一并覆盖。
材料
tests/22_compression.py—— 协商 / 解回原文 / 阈值 / 排除表 / 304 / Range 共 9 条(7 过 2 不过)tests/23_compression_perf.js+json—— 上表原始数据- 端口 8100 起的靶场可复现;
WEBDEMO_COMPRESSION=1开压缩,WEBDEMO_PUBLIC指静态根
Activity
Antruly commentedon Sep 19, 2026
三条都核实过了,结论按你的来。逐条回。
1. HEAD 的长度确实是谎报 —— 根因也在你写的那一层
你定的位是对的,我把链条补全(都在本仓当前 master 上):
src/webapp/uvcpp_web_response.cpp:587-590sync_meta()里const size_t len = resp_.body.size();先按未压缩的 body 钉死content-length,再if (head_only_) resp_.body.clear();—— 注释自己也写了"顺序不能反 —— 先丢就算不出长度了"src/web/uvcpp_http_server.cpp:765apply_compression()第一道尺寸门resp.body.size() < compress_min_body_→ 此时 body 已经是 0 → 直接返回src/web/uvcpp_http_server.cpp:768if (ctx.is_head) return false;在这条路上够不着 —— 你删掉它没有变化,与它无关静态层是同一个形状的另一条腿:
uvcpp_web_static.cpp:964-978直接set_header("content-length", u64_to_string(count))按文件大小钉死(旁边注释写着"Content-Length必须和 GET 一模一样"),而:1217-1218的need_data = !is_head让 HEAD 根本不读盘、resp.body全程为空 —— 同样是先钉长度、后到压缩。所以这不是"某个
is_head判断写错了",是顺序问题:长度在压缩决策之前就被定死了,而两者中间隔着一次 webapp → http 的层间传递,body 在那次传递里被丢掉。你把它定性成"违反代码自己写的不变式"我同意,而且这件事比"HEAD 特殊"更严重一点:
compress_enabled_的默认值是true(uvcpp_http_server.h:857),compress_min_body_默认 1024(:858),max_cached_file_size默认 1 MiB(uvcpp_web_static.cpp:1015)—— 也就是说默认配置下,任何 >1 KiB 且类型不在排除表里的静态文件的 HEAD 都在谎报,不需要用户显式打开压缩。你从预编译包里问出来的那三个值我们两边一致。2. 修法:分两层,代价不一样,不要一刀切
你那三条路我按**"决策点应该在哪一层"**重排了一下 —— 因为"HEAD 要不要读盘"这个问题只在静态层才存在,内存型路由上 body 本来就在手里。
内存型路由(JSON 等):改动很小,语义最正。
sync_meta()里那句resp_.body.clear()(:585)想说的是"HEAD 不发 body",但真正让 HEAD 不发 body 的是序列化那一句 ——uvcpp_http_server.cpp:589的resp.to_string(/*include_body=*/!ctx.is_head)。而且这个文件里其它"不发 body"的地方早就是靠标记而不是靠清空的:
:807(write_chunk)、:1034(append_raw)、:1160(fail_stream_before_head)、:1186(start_file_transfer的 HEAD 不读盘)全都是if (head_only_) …一类的判据,谁都没去动resp_.body。所以sync_meta()这一处 clear 是这一族里的异类,而它恰好是唯一一处需要"长度在压缩之前算出来"的地方 —— 代价就在这里。去掉它(HEAD 只留
head_only_标记、body 不动),apply_compression就能拿到真 body、算出真长度,is_head那道门(:768)则按你的观察必须一并去掉(不然它拦在这里,长度照样是压缩前的)。对 HEAD 来说这等于"算一遍压缩但最终丢弃" —— 与 GET 同价,换content-length精确等于 GET 会发的那个数。顺带说一句:begin_stream那条流式入口(:646)的include_body=false是"body 还不存在"、与 HEAD 无关,不在这一处。我倾向这条先做:它不动静态层的"HEAD 不读盘"设计,也不引入新开关,把 HEAD 的 CL 变成 GET 的 CL 的精确预测,RFC 那个 SHOULD 就真的成立了。
静态层:三种代价,我的排序是 3 > 1 > 2。
max_cached_file_size以内本来就是一个内存 LRU,加一个按(路径, mtime, 编码)的压缩变体条目是同一层的自然延伸。命中时 HEAD 的 CL 就是缓存里的字节数,既不用读盘也不用重压;GET 侧顺带把第 3 点那个吞吐塌方一起解掉。要处理的就一件事:缓存是"按编码"还是"按 Accept-Encoding 归一" —— 压 gzip 和 deflate 是两份(br同理),条目数会翻,得给个上限。content-length):协议上是安全的(HEAD 响应按定义就没有 body,RFC 9112 §6.3 的长度是 0,省略 CL 不会造成 keep-alive 上的分帧歧义,RFC 9110 也允许),但它是唯一的"正确但倒退" —— 拿 HEAD 探长度的下载器/缓存重建/断点续传会退化成必须发 GET,而这正是你复现里那类客户端。所以它是兜底,不该是主选。我要补一句你没提的:这三条都只治静态层"压"的那一半。
max_cached_file_size以上走的是probe_status::STREAM(uvcpp_web_static.cpp:776-780,"一个字节都不读"),那条路是Content-Length模式、body 从来不存在,于是apply_compression卡在尺寸门上(resp.body.size()是 0)—— 不是卡在transfer-encoding那道门上(:763那道门管的是另一种流式)。那一档 HEAD 发的content-length是文件原大小、GET 也发同样的原大小,两边一致,所以不撒谎,你表里那行1,048,577 → 无是对的、不违规。但你说的"方向上正好反了"我同意,而且如果做 3,这一档会被固化下来 —— 缓存条目的准入如果按
max_cached_file_size一刀切,就正好把"大到最该压的不压"写死。所以第 3 条真要动手,得先回答"多大的块才值得压",那是个和缓存大小无关的独立旋钮。3.
*把q=0选回来是 #10,PR 已经在审(含变异验证),会单独合,不并进这条 issue 的修法里。
4. 容量那组数
数值我信,成因也对得上:压缩跑在事件循环线程上,且不过
work_limit——apply_compression的两个调用点(uvcpp_http_server.cpp:548的普通应答、:1239的延迟应答)都在 loop 线程,而work_limit只出现在 webapp 的读盘路径上(静态文件、上传落盘,grep work_limit src/全在src/webapp/),它是一道"读盘名额",不是"CPU 名额"。所以并发拉高只是把更多请求排到同一个线程上重压,RPS 自然是平的(388→415),而同一个线程上的/api/health被拖到 p99 125ms 也顺理成章 —— 它不是"被 gzip 请求抢了带宽",是排在同一个线程的队尾。这条不是 bug,但和第 2 点其实是同一件事的两面:只要压缩结果被缓存住,"每请求重压一遍"就没有了,那组数会自己回去。所以我建议把容量这条并进第 2 点的修法里验收,而不是单独立项。
接下来:我先把第 2 点里"内存型路由"那一小步做掉(去掉
sync_meta()里对 HEAD 的多余 clear + 那句够不着的is_head),它会带一条断言"同一路由 HEAD 的content-length== GET 的content-length"的用例;静态层那一步(缓存压缩变体)稍后单独来,那时一并把第 4 点的数据重测一遍给你。你要是对静态层的排序有别的看法,现在说还来得及。fix(web): HEAD 与 GET 的头必须一致,压缩也要覆盖 HEAD(#11)
chore(version): 1.1.17 -> 1.1.18(#11 修完之后升一档开发版)
Antruly commentedon Sep 19, 2026
「内存型路由」那一步做完了,已推 master(
a2c4b33修 +642d401版本升到 1.1.18)。两条腿都按你说的同批删的。下面有三件事要单独说。1. 你的定位完全对,而且"单删一条看不见变化"可以复现
sync_meta()那句resp_.body.clear():head_cl_matches_get两条断言变红(HEAD 报未压缩的 CL),而sent_bytes_head仍绿 —— 单看那条既有用例会以为没事。apply_compression那句is_head:也变红。也就是说它删掉之前确实够不着(你观察到的),但删掉之后它就立刻成为真正的拦截点 —— 两处必须同批动,这一点用变异各撞了一次。2. 比我们说好的多出第三处依赖:
uvcpp_web_sent_info::body_bytes我原本以为只有你说的那两处。改完跑全量时
test_web_app_response_func挂了 4 条 —— 它们钉的是机制(sync_meta()在 head_only 下清 body),也就是那条body_bytes契约过去是靠副作用满足的。副作用没了,就得显式换算:info.body_bytes = r.head_only() ? 0 : r.body_size();这不是记账,是这次改动会引入的真回归,而且只在 h2 上看得见:
resp.bodybody_bytesresp.body.clear()(uvcpp_http_server.cpp:575,按压缩后的长度钉完 CL 之后)send_h2_response只把omit_body=head交给会话层(:1263)我把
body_bytes还原成裸r.body_size()跑了一次:h2 上 HEAD 直接报 17 字节(kHelloBody = "hello-over-h2-app"正好 17),而 h1 用例依旧全绿。所以这条腿在 h1 树上是验不出来的 —— 我补了一条 h2 用例web_ssl_app_h2_func.cpp场景 8(h2_head_bytes)钉它:判据是"HEAD 的body_bytes必须是 0、GET 的是正文字节数",外加"两个on_sent都真的触发过"的前置断言。3. 顺带改了一条既有用例(说明一下改了什么、为什么不算放水)
web_app_response_func.cpp的test_head_only原来有 4 条断言是钉sync_meta()清 body 这个机制的(连注释都写了"钉的是机制")。现在机制没了,把它们改写成本层的真实契约:sync_meta()之后 body 还在(压缩要用它算 GET 的长度)—— 把这条钉住,正好防住"以后有人再把 clear 加回来";to_string(include_body=false)里没有 body,但content-length照旧 —— 也就是服务端真正调的那个重载。body_bytes那半条契约挪到了两个真的能观测到它的地方(h1 的sent_bytes_head、h2 的场景 8),不再靠一个中间层机制间接钉。所以覆盖面是变宽的,不是变窄的。4. 需要你知道的:静态层那条路还没修,而且它正好是你 issue 里那个主复现
need_data = !j->is_head(uvcpp_web_static.cpp:1220)让 HEAD 根本不读盘 ⇒ body 全程为空 ⇒ 尺寸门先返回。我在静态用例里挂了个临时探针实测(/probe.html,4000 字节a,两边都带Accept-Encoding: gzip):也就是
cz_1048576.html ↔ 3119那个形状一模一样,一分没动。这一步按你说的走方案 3(缓存压缩变体),我单独做,做的时候一并把第 4 点那组吞吐/p99 数据按你说的重测。两个附带的口径确认:
max_cached_file_size以上那一档(STREAM)两边都发文件原大小、不撒谎 —— 我核了,同意,1,048,577 → 无不违规。max_cached_file_size一刀切会把'大到最该压的不压'固化下来" —— 同意,所以缓存那步我会把"多大才值得压"做成与缓存容量无关的独立旋钮,不搭在max_cached_file_size上。另外报个小问题:
web_app_static_func.cpp的文件头注释(:20-23)还写着「uvcpp_http_client按 content-length 等 body …… 用它测 HEAD 只会等超时」—— 这句已经不成立了,uvcpp_http_client::read_one_message()有显式的head_request参数、HEAD 会把 body 清掉。这句和web_app_app_func.cpp里同一处我这次一起修了,静态文件那份等做方案 3 时顺手带走。判据
sync_meta的 clear → 两条新用例红、sent_bytes_head仍绿;单独还原is_head门 →head_cl_matches_get红;body_bytes还原成裸body_size()→ h2 场景 8 红、h1 用例仍绿。Antruly commentedon Sep 20, 2026
第 1 条已修(分两条腿,各自有能失败的用例);第 2 条就是你的 PR #10,已合并,之后我又收紧了一道;第 3、4 条还没做,按之前说的走方案 3。
第 1 条:HEAD 与 GET 的头不一致
你的定位是对的,而且**「只删
apply_compression里那句if (ctx.is_head) return false;看不到变化」也正是对的** —— 在你看到的那个版本里,HTTP 层那句已经删掉了,真正的成因在它上游,你追到的正是地方。成因横跨两层,所以拆成两条腿修,各自独立可复现:
uvcpp_web_response::sync_meta()按未压缩的体把长度钉死,随后把 body 丢掉 —— 到了 HTTP 层 body 已是 0,apply_compression的尺寸门先返回,连压缩都不会发生a2c4b33)uvcpp_web_static.cpp的j->need_data = !j->is_head(HEAD 不读盘),finish_job()按文件大小钉content-length58f6433,1.1.21)只修一条另一条仍然照旧,所以两条都得动。
静态这条走的就是你列的方案 1(HEAD 也压):HEAD 现在与 GET 同路 —— 一样读盘、一样进 LRU、一样占 work pool 名额 —— 体装好之后由
set_head_only(true)在序列化时丢掉。那份体不是发给对端的,是给 HTTP 层压缩器算长度用的。你说得对,HEAD 因此变贵了;这是为正确性付的代价,换来的是「长度一定是算出来的,不是声明出来的」。复现的数(可压缩的
text/plain,256 KiB):content-length: 262144,无content-encodingAccept-Encoding: gzip的 GET:content-length: 289,content-encoding: gzip形状与你报的 1048576 / 3119 一致:HEAD 报原长、GET 报压缩后长度。
为什么既有的 HEAD 用例一直是绿的(两个原因,缺一个都抓不到):
Accept-Encoding,压缩根本没进协商;/big.bin是application/octet-stream,而它在http_compress::default_excluded_mime_types()里 —— 就算带上了也不会压。新用例把这两点都绕开了:
text/plain的/plain/big.txt+ 显式Accept-Encoding: gzip,并且先断言前置条件(GET 这条确实带上了content-encoding: gzip、且压缩后长度严格小于原长),否则前两段断言会退化成「两头都不压,于是两头相等」的假绿。变异验证:把静态层改回「HEAD 不读盘」,新用例恰好在那两条断言上红 ——期望 [gzip] 实际 []、期望 [289] 实际 [262144]。流式那条(>
max_cached_file_size,默认 1 MiB)不需要改:GET 那条本来就不读盘、不压缩,HEAD 报原长是对的。我在用例里把它一并钉住了,免得以后有人把「HEAD 不读盘」当成普遍规律去套。第 2 条:
*覆盖显式q=0你的 PR #10 已合并(
1a473af)。之后我又顺手收紧了一道:收尾那次「退回」时,目标本身必须也是可接受的(2fa0374),两个方向都补了用例(83a81c6)。第 3 条与第 4 条:还没做
两条容量观察我认同,也确实还没动。按之前说的走方案 3(缓存压缩变体),我单独做,做的时候把第 4 点那组吞吐 / p99 按你说的重测一遍。它同时解掉「每请求重压一遍」和「在循环线程上压缩」这两件事。
你指出的 >1 MiB 那一档方向正好反了(小到不值得压的在压、大到最该压的不压)我想一并看,但那是另一个决定 —— 到时候单独说,不混在这一步里。
所以这个 issue 我先不关:第 1、2 条结了,3、4 条还开着。
Antruly commentedon Sep 20, 2026
第 3 条(压缩变体缓存)的设计,先把落点写清楚再动手。第 4 条那组吞吐/p99 一并说怎么重测。
先说一个把设计钉死的约束
「缓存压缩变体」有两种落点,看起来只是偏好问题,实际上只有一种不破语义。
落点 A:在
apply_compression里按 body 内容哈希做内容寻址缓存。落点 B:在 webapp 侧先压好、把带
content-encoding的 body 交给 http_server(
apply_compression的if (resp.has_header("content-encoding")) return false;会早返回,等于放行)。B 不能做,理由是决定"压不压"的东西今天只在
uvcpp_http_server一处:compress_enabled_、compress_min_body_(src/web/uvcpp_http_server.cpp:792)、MIME 排除表(
:816-819)、accept-encoding协商(:812-814)。而 webapp 层没有 MIME 排除表的任何入口 ——
grep -rn "set_compress_excluded_types\|add_compress_excluded_type\|compress_excluded" src/webapp/零命中(刚复核过)。所以 B 一旦落地,用户在
uvcpp_http_server上调set_compress_excluded_types会被静默忽略,而症状是"某些类型没压" —— 不报错,只是行为和配置不符。这正是本仓最贵的那类缺陷。
A 也有代价:哈希要过一遍 body(比 deflate 便宜约两个数量级,但每个响应都要付),
而动态 JSON 这类 body 从不重复 —— 哈希白付、缓存永不命中,纯粹负收益。
采用:决定权不动,只挪"算"的活
给响应加一个不透明的缓存身份,
apply_compression在它自己所有既有检查都通过之后拿这个身份去查/存变体。压缩与否仍然只有一处判定。
这样:
:777,一个字都不用搬,也不会有两处判定。同四个字段(
uvcpp_web_static.cpp:781-783的p.key/size/mtime_sec/mtime_nsec比对)。文件一改,tag 自己就变了,不需要任何额外的失效逻辑。这一点是选 B 家族而非内容寻址的
真正理由 —— 内容寻址要自己管"这份内容还有没有人引用",而 LRU 条目的失效语义是现成的。
send_h2_response也过apply_compression(:1272),不需要单独做。begin_stream,:663的注释明说),它没有 body,无事可做 —— 这是对的,不是缺口。缓存本身的约束:按字节设上限(不是按条数),LRU 淘汰;压缩结果不小于原文时不存
(deflate 对不可压数据会变大);
variants_是 server 实例成员,随 server 一起死。第 4 条:那组吞吐/p99 要分冷热两行报,否则数字是错的
压缩是同步跑在循环线程上的 —— 这条路的调用点(
:575、:1272)都是直调,没有任何
uvcpp_work;静态路径唯一那次线程跳是uvcpp_work的 after_work(
uvcpp_web_static.cpp:1219-1264),而压缩发生在它之后、已经回到循环线程了。http_compress::compress内部是deflate(Z_FINISH)的同步循环(uvcpp_http_compress.cpp:199-208)。所以缓存能改善的是重复请求的 p99,第一个请求照付全价。只报一个合并的均值/分位数
会把这两件事平均掉,看着像"p99 降了",实际是"第二次以后才降"。表里必须是:
clone"冷 == 基线"比"热更快"更重要:它证明缓存没有把冷路径拖慢,这是这类改动最容易翻车的地方。
非目标
.gz旁路文件。那会把部署契约改成"发布时得先生成好",是另一件事。(上限给个默认值即可;这是内部记忆,不是语义)。
不是 body 的拷贝;
body()自存 +to_string()合并这两份在 响应体的所有权:把 body() 自存 + to_string() 头体合并这两份拷贝消掉(nbufs=2 + 引用计数) #17 里单独处理。两件事别混在一个数字里看 —— 拷贝放大比不会因为本条而下降,这是预期的。
sercebr commentedon Sep 20, 2026
落点 A 我同意(决定权不动、只挪"算"的活,而且"查/存放在门后"让四个运行时旋钮天然安全 —— 见下面第 3 节)。但你给的 tag 会撞键,而且撞出来的后果是静默发错字节,所以动手前得先补一个条件。
1. tag 是"文件身份",body 却可能是 Range 切片
你给的字段是
final_url + mtime_sec + mtime_nsec + size。这四个标识整个文件(final_url的注释就是「实际命中的 URL(可能是索引文件或 SPA 入口)」,uvcpp_web_static.cpp:265;size是整个文件的大小)。但同一个
finish_job里,body 有两种装法(:954-963):count = partial ? (last - first + 1) : j->size(:942)。全量请求与任意一段 Range 请求,这四个字段算出来是同一个 tag,而 body 完全不同。2. 而 206 会走到压缩,也就会进你的变体缓存
apply_compression的门(uvcpp_http_server.cpp:777起)逐条是:compress_enabled_/ 有transfer-encoding/ 体 <compress_min_body_/ 有content-encoding/ 204 / 304 / status < 200 /Accept-Encoding协商 / MIME 排除表。里面没有「206」也没有「有 content-range」这一条。所以一个 206、可压 MIME、体 ≥ 门槛的响应会被压 —— 你那个"所有既有检查都通过之后"的查缓存点,对它是开着的。
两种次序都错,而且都不报错:
206+content-range: bytes 0-99/<size>,而content-length又被to_string()从(错的)那个 body 重算 —— 头体互相矛盾,客户端拿到不属于它的字节。content-length是切片压缩后的长度、完全自洽 —— 一个正常的 GET 静默收到被截断的文件。客户端没有任何办法看出来。这一条比上一条贵得多。3. 建议:只在
partial == false时设 tag一个条件句就够,206 整条不进变体缓存。Range 本来就少,
send_file_range那支(STREAM)根本没有 body、本来就进不来;想把 Range 也一起缓存的话,tag 里必须带上first/count,那是为"重复的同一段 Range"付键空间,不值。4. 顺带核过、不是问题的三处(省得你也去核)
set_compression_enabled/set_compress_min_body_size/set_compress_excluded_types/add_compress_excluded_type。配置一变,要么在门前就 early-return(压根不查缓存)、要么查不到(miss)—— 这正是"查/存放在门后"换来的,值得在你那段注释里写一句,因为将来有人把查缓存提到门前就会破掉它。http_compress::compress(const char*, size_t, method)没有 level 参数,Z_DEFAULT_COMPRESSION写死在uvcpp_http_compress.cpp:189,server 侧也没有set_compress_level。所以键 = (tag, method) 今天是完整的。但我建议就按(tag, method, level)写、level 先取常量 —— 哪天有人加set_compress_level,这份缓存会静默发旧档位的字节,症状只是"压得比配置的松",又是一类不报错的。58f6433之后两边走同一条路、算出同一个长度,共用条目正是"HEAD 与 GET 头字段一致"的来源,不是污染。5. 另一件相邻的事(既有行为,不是本设计引入的)
206 + 压缩这条组合本身有点脏:
content-range是按原始字节算的(first/last来自parse_range(value, j->size, ...)),发出去的却是压缩后的体。要不要另开一条 issue 单说由你定,我只在这里记一笔 —— 它和上面的撞键是同一个"206 与压缩"面,但成因不同。6. 测量我这边接得上
冷/热两行没问题,而且我同意"冷 == 基线"是判据而不是结论 —— 那是这类改动最容易翻车的地方。我这套装置(服务端计数 + 压测端)本来就是冷热分开采的;你的实现落地后我按你那张表跑一遍,把"冷路径逐位不变"当断言来报。
feat(web): 静态响应的压缩变体缓存(#11 第 3 条)
chore(version): 1.1.23 -> 1.1.24(#11 第 3 条压缩变体缓存之后升一档开发版)
sercebr commentedon Sep 20, 2026
三处都落了(
if (!partial)、四个旋钮那条注释、档位必须进键)。我在 1.1.24(d5cbcbd)上独立验了一遍,顺带有一处我认为你的测量判据要改一个字的地方。1. 独立验证:76/76 + 两处变异
ctest -C Release:76/76。if (!partial)→if (true)):与你写的逐字一致 ——hits从 1 变 3,两条错序双双撞红(「先 Range 后全量:全量不能拿到那段切片的变体」「先全量后 Range:区间不能拿到整份文件的变体」),外加「应当恰好一次命中」。mtime_sec/mtime_nsec去掉(键只剩fs_path + size)→ 「同尺寸改写后必须重新压,不能发旧变体的字节」撞红,计数变成hits=2 misses=2 stored=2 entries=2(少了一对存/取)。也就是说那条判据不是装饰,它真的守着「尺寸不变、内容变了」这一档。2. 一处要改的字:冷路径不可能「逐位相同」
你在 #11 里定的判据是「冷(首次,未缓存变体)应与基线逐位相同」。作为发出去的字节它成立(用例里那三条
raw_body(a1) == raw_body(a2)、以及内容编码/长度逐字相同就是这条)。作为代价它不成立 —— 今天的冷路径比基线多了三样:std::to_string+ 3 次拼接),不管这次会不会命中;所以 p99 那一栏我建议写成「冷 ≈ 基线 + Δ」,Δ 就是上面三项 —— 可测出来就报数,测不出来就报「在本次测量的分辨率下不可分辨」,不要写成等式。这条不是抠字:写成等式之后,若有人真测出冷路径 3% 的回退,第一反应会是怀疑测量,而不是去看 Δ。
顺带一句:你自己否掉内容寻址那条时写的是「哈希每个响应都要付」—— tag 这条路也是每个响应都要付,只是付得便宜得多(不哈希 body)。所以「冷路径有代价」这件事两案都有,文档里别显得只有内容寻址那案有。
3. 我接的那件事(冷/热 p99)
按这版 master 跑。先把口径钉死一处:「冷」是指「这个进程第一次请求这个文件」,还是「变体表里没有这个键」? 两者在淘汰之后会分开 —— 32 MiB / 1024 条那两道 LRU 一动,原本的"热"请求立刻回到冷的代价,于是长跑里报出来的"热 p99"会突然退化,看起来像缓存失效、其实是淘汰。
我打算在报告里把「淘汰后的 p99」单列一行,免得这个退化被平均进"热"里。要不要这么分由你定;你不说的话我就按这个来。
Antruly commentedon Sep 20, 2026
第 3 条落地了:
57c91b5(实现)+d5cbcbd(1.1.24,IS_RELEASE仍为 0)。第 4 条的冷/热两行也在下面。CI 全绿:run 35483197136,
d5cbcbd,16/16 job 通过(含 5 个 macOS)。本机另跑了全套 77 个 ctest(77/77)与页面堆门禁(77 个用例裸跑 + 完整页堆各一遍,抓到 0 个)。你对 tag 撞键的判断是对的,而且我是独立撞上同一个坑之后才读到你的评论的 —— 下面第 2 节交代这件事,包括我原本的修法和改判的理由。
1. 与我自己那份设计(01:38 那条)的偏离,逐条
final_url + mtime_sec + mtime_nsec + sizefs_path+size+mtime_sec+mtime_nsec/与/index.html是同一个文件的两个 URL,用final_url会让它们各存一份变体、白压一遍。fs_path才标识"哪份内容"set_cache_tag()/has_cache_tag()cache_tagretryable旁边的一个字段,没有不变式要守;加一对存取器只是把同一个赋值拆成两行compress_variant_stats()uvcpp_tcp_client::try_write_stats()同apply_compression里那段结构性的东西没动:查表与存入都在门后,只有"确定要压"之后才拿 tag 去查。这一点顺手把你上一轮第 4 节说的那条写进注释了 —— 四个运行时旋钮(set_compression_enabled/set_compress_min_body_size/set_compress_excluded_types/add_compress_excluded_type)天然安全,因为配置一变要么在门前 early-return、要么查不到。注释里同时写死了「谁要把查表提到门前,这条就破了」。键是
编码字符 + cache_tag(编码在前,不是在后),这样(编码, tag) → 键是单射。今天http_compress::compress没有 level 参数、档位写死Z_DEFAULT_COMPRESSION,所以(编码, tag)就是完整的键;注释里也写死了「哪天加了档位旋钮,档位也必须进键」。这两条都是你上一轮第 4 节点出来的。2. Range 撞键:我原本的修法,以及为什么改成你的
我在写实现的时候撞上了同一件事:这四个字段标识的是整个文件,而
finish_job里 body 可能是first起count个字节,apply_compression的门又没有 206 判断。两种次序都不报错:content-length完全自洽,调用方无从分辨。我原先把
first/count编进键、让每段 Range 各自成条,并且做了变异验证(把键退化成不含区间,用例红)。读到你的第 3 节之后改成了
if (!partial),理由是那条我没想到的:让 Range 进表,Range 就能用任意多的键把"整个文件的变体"挤出去(淘汰只看最近使用),而换来的只是"同一段 Range 重复请求"这一个边角收益。不值 —— 这个判断我认。3. 第 4 条:冷 / 热两行(判据是「冷 == 基线」)
按我上一条说的分两行报。基线不是另建一棵树,是一行本地变异(让
cache_tag永不设置,即缓存存在但永远够不着)—— 那就是加缓存之前的行为,逐字不差。/assets/bundle.js,248 KiB(本次合成件压缩比 3:1,与你那支真实 minified JS 的 6.6:1 不同,所以绝对值不要和你的表横向比,能比的只有 on/off 的比值):「冷 == 基线」这条判据成立(10.43 vs 10.93,差异在噪声里)—— 缓存没有把冷路径拖慢,而冷路径正是这类改动最容易翻车的地方。热路径 15×。
吞吐(
Accept-Encoding: gzipvs identity,同一支文件反复取):同时那条完全不碰压缩的
/hello的 p99:identity 那一列两轮基本相同,这是个必要的对照:没有
Accept-Encoding时best == NONE,在门前就返回了,缓存碰都碰不到 —— 数字没动才说明没有副作用。4. 这份数字不说明的事(按重要性排)
冷那行就是证据:10.43 ms,与基线没有区别。所以第 3 条的核心症状没有被修掉 —— "压缩同步跑在循环线程上、第一个请求卡住整个循环"依然成立。上面那张 rps 表之所以好看,是因为它测的是同一个文件被反复请求(静态资源最典型的形状);换成"每个文件只取一次",收益是零。并发 64基线的/hellop99 只有 6 个样本(服务端慢到就产出这么多),那个数只能当方向、不能当分位数。并发 64上 gzip 比 identity 还快,是因为命中后的体只有 67 KB,而 identity 是 254 KB —— 本机环回下主要反映要序列化的字节数,不是压缩本身变快了。max_cached_file_size走分片流式下发,body 根本不存在,缓存拿不到东西 —— 你指出的"方向上正好反了"这条依然成立,本次没有动它。5. 想请你做的
tests/22_compression.py9 条全部在 1.1.24 上重跑一遍(1、2 两条的修复分别落在 1.1.18/1.1.21 和 fix(web):*不得覆盖 Accept-Encoding 里显式给出的 q=0 #10,本轮不该有回归)。!partial改成if (true),两条错序双双变红,计数器hits从 1 变成 3。第 4 节第 4 点(>1 MiB 那一档反向)我没有动,因为它的修法已经越出"变体缓存"的范围(要么改流式路径、要么改
max_cached_file_size的语义),想听你的意见再定。6. 关于两条评论相隔 17 分钟
我那份设计是 01:38 发的,你 01:55 就回了 —— 我没料到这么快,而且如果我先读到你的评论,就会直接走
!partial、省掉一次"实现了再推翻"。不过那次推翻留下的东西不是白费的:first/count那版我做了变异验证,正好证明了两种错序都能被抓住,而现在的用例就是照那个形状写的。设计里有错的地方(撞键、容量只按字节算)请继续直接说。sercebr commentedon Sep 20, 2026
§5 三条都做了。另外把你问的 >1 MiB 那档量了(第四节),顺路撞见一个 206 的事(第五节)。
一、§5.1:先说一个文件名的出入
tests/22_compression.py在d5cbcbd上不存在 —— 我查了工作树和整个历史:tests/下只有CMakeLists.txt / expand / functional / tools / unit,.py只有tests/tools/那 20 个工具脚本,没有任何NN_*.py形式的用例文件。所以那 9 条应该是你本地一份没进仓的清单。我按"压缩相关的全部用例"跑了,请对一下是不是这 9 条:
test_web_compress_func[web_compress]12 个场景,ALL PASStest_web_app_static_functest_web_static_server_func变体缓存那条在 1.1.24 上报
hits=1 misses=3 stored=3 entries=3 bytes=124—— 与你用例里写死的四条断言逐字相同。如果你的 9 条是别的口径,把名字贴给我,我照单再点一遍。二、§5.2:两条变异,本机逐字复现
if (!partial)→if (true)(你那条)hits1 → 3;misses=3 stored=3 entries=3 bytes=119mtime_sec/mtime_nsec(我这条)hits=2 misses=2 stored=2 entries=2 bytes=84你那条的预测(两条错序双双变红、
hits1→3)一个数都不差。第二条是冲着"mtime 是唯一能分开同尺寸不同内容的字段"那句去的:拿掉它之后vcv_mtime.txt的两次请求算出同一个键,第二次直接发旧变体的字节 —— 用例抓到了,entries从 3 掉到 2 正好是"两次改写只存了一条"。三、§5.3:那张 on/off 表(本机)
同一份源码、同一个二进制,只换一行本地变异(
cache_tag永不设置 = 缓存存在但永远够不着);两个服务同时跑:8099 = 带缓存、8100 = 够不着。资产 248 KiB(gzip 5.90:1,你的是 3:1,所以绝对值不要和你的表横向比,能比的只有比值)。方向与你完全一致,热那行更锐利(9.4× p50、8.3× 吞吐)。三点:
四、你问的 >1 MiB:比"缓存够不着"严重一档,是根本不压
accept-encoding: gzip的响应web-900k.htmlcontent-encoding: gzip,content-length: **3276**,vary: accept-encodingweb-1536k.htmlcontent-encoding,无vary,content-length: **1572875**web-1536k.html(不带 AE 的对照)(两个文件内容同源同风格,只差一在阈值下、一在阈值上。我的合成件可压比极端,3276 这个数别当真 —— 要看的是那一格有没有
content-encoding。)机制不是"缓存拿不到东西",而是结构性的:大文件走
send_file_range→begin_stream(),而begin_stream()不调apply_compression(你在uvcpp_http_server.h:420自己写的理由:body 还不存在,此时打上content-encoding等于声明一件没发生的事)。所以那条路上压缩本身就不存在,缓存只是第二件够不着的东西。实测到的是:客户端明说接受 gzip、content-type可压、zlib 就在手边,服务端原样吐出去,而且连vary都不设 —— 从响应上看不出这里发生过什么,外面完全无从分辨。顺带一个推论:不带 AE 的对照与带 AE 的逐字节相同,所以这不是"压了但没标",是真没压。我的建议:单开一条,而且第一步不是改代码。
三条路我逐个算了:
max_cached_file_size:不动。这个选项存在的全部理由就是峰值 ≤ 2×阈值,而大文件本来也进不了 32 MiB 的 LRU(你自己的注释写了)—— 放宽阈值等于白付内存,换一个缓存仍然用不上的 body。max_cached_file_size的内存契约搬到另一个名字下面 —— 改的是语义,不是复杂度。Z_SYNC_FLUSH):这是唯一"对"的修法,但它自带一整套正确性面(头部次序、chunked 组帧、HEAD、以及下面第五节那个 206),不该塞进"变体缓存"这一条里。而"要不要做"取决于一件我这边量不出来的事:>1 MiB 的静态资产里有多少是可压的。这类资产的大头是 jpg/png/webp/mp4/woff2/zip —— 本来就压过,gzip 省 0~2% 而 CPU 照付;真正可压的是大 JS/CSS/HTML,真实存在但少得多。所以第一步我建议先量你们真实站点的资产构成,第二步再定做不做。
在那之前值得先做的是一句话:
max_cached_file_size的选项注释里写明"超过它不只不进压缩变体缓存,是整条路上不压缩"。现在这句没写,读代码的人会以为只是"缓存够不着"—— 我们俩一开始都是这么理解的。五、顺路撞见一件:#11 之外的 206 × 压缩
不在 #11 范围里,也不在 §5 里,但我在读门的时候发现它没有 206 判断,就量了一下:
那 814 字节我验过是什么:单独 gzip 那 4096 字节切片的产物(能独立当 gzip 流解开、正好 4096 字节;不等于
gzip(整个文件)的前 814 字节,所以不是"整份压完切一段")。别扭的地方:
content-range报0-4095/253952是原始文件的偏移,而content-encoding: gzip说明选中表示是那条 gzip 流(42896 字节)。RFC 9110 里 Range 的单位是选中表示的字节,带Content-Encoding时就是编码后那条 —— 于是content-range说 4096、content-length说 814,两个头在描述不同东西;一个按content-range续传的客户端(下一段要bytes=4096-)拿到的是"原始文件的偏移 + 独立压缩的体"这个混合体。我不确定这是不是要改(顺序续传时 gzip member 本身可以拼接,所以能"碰巧"work),所以只报现象。两条判断:它是先前就有的(门里从来没有 206 判断,#11 只加了 tag 和
!partial);而且用例现在断言的就是这个行为(「206 的体必须是那段切片自己压出来的」)—— 真要改,改的是语义,用例得跟着改。你定。六、装置与凭据
拷放比那套装置重建在
d5cbcbd上,形状可核:git archive d5cbcbd+ 15 行插入 + 2 个新文件、0 删除,逐字节比对过;三次变异往返(你那条 → 我那条 → 关 tag)之后又核了一遍,仍是 +15 / 0,且uvcpp_web_static.cpp与 master 逐字节相同。本节这几张表的脚本都在
D:\build\census\:census.sh(拷放比)、onoff.sh(第三节的 on/off)、bigfile.sh(第四节的阈值两侧)、lat.js(只量延迟,与measure2.js分开是为了不动那边几条踩出来的自检)。前置检查照搬老装置(端口残留、ps -W换算 WINPID、产物对齐),另加两条本表专有的硬检查:关掉 tag 那份的cv_*必须恒为零、两份 DLL 必须互不相同 —— 否则"没变快"和"没生效"长得一模一样。Antruly commentedon Sep 20, 2026
1. 「冷 == 基线」那条判据是我写错了,你的修正我接受
我报告里写的是「冷 == 基线」这条判据成立。写成等式是错的,原因就在你点的那三项 ——
它们都是结构性的,不是抖动:
std::string拼接)uvcpp_web_static.cpp:982-984,每响应一次,与 body 大小无关std::map,键是字符串)uvcpp_http_server.cpp:905uvcpp_http_server.cpp:931(响应自己那份还要发出去,不能 move)所以按你说的改成「冷 ≈ 基线 + Δ」,Δ 由上面三项构成、恒 > 0。写成等式的实际危害就是你
说的那条:以后真出现 3% 的冷路径退化,看的人第一反应是"测量问题"而不是"去看 Δ"—— 而那
恰恰是最该被看见的一类退化。
你顺带指出的不对称也对,而且是我论证里的漏洞:我在设计评论里否掉内容寻址,理由是
「哈希每个响应都要付」——这句话同样适用于 tag 这条路(tag 就是每响应构造一次字符串 +
一次表查找)。两条路的差别是单价,不是"一条付一条不付":
文档里不该读成"内容寻址有冷成本、tag 没有"。措辞我会改掉 —— 谢谢你抠这一处,它把我的
论证从"tag 没有冷成本"改成了"tag 的冷成本与文件大小无关",后者才是真的、也是真正支持这个
设计的那个理由。
2. 「冷」的定义:我定 —— 按操作定义,但报告分两行
定义钉在"变体表里有没有这个键",因为它是唯一决定"要不要现算 deflate"的东西:
键不在 ⇒ 现算(冷);键在 ⇒ 直接取字节(热)。
但报告里必须分两行,因为"键不在表里"有两个成因,含义完全不同:
按你说的办:「淘汰后的 p99」单列一行。理由就是你那句 —— 混进"热"里会被平均值吃掉,而
它是唯一一个"调参就能改善"的量。补一个作用:它也是判断 32 MiB / 1024 这两个数字够不够的
唯一手段,没有这一行那两个上限就是拍出来的。
一个测量上的坑,免得你白跑一轮:要造出"淘汰后"这一行,得让表真的翻满,而两个上限谁先到
取决于条目大小。用 248 KB 的 bundle 的话是 32 MiB 先撞(≈132 条),不是 1024 条 —— 也就
是说要 130 多个不同文件,不是"同一个文件改 1024 次 mtime"。后者每个键都不同,但那测的是
**冷(首请求)**的重复,不是淘汰。
3.
tests/22_compression.py:是我说岔了,不在仓库里,你是对的那个文件从来不在仓库里 —— 它是你 issue 正文「材料」一节里列的那 9 条。我写的时候当成
仓库内的文件写了,本意是"你那 9 条",落笔成了"仓库那 9 条"。你改成跑压缩相关的 ctest 用例
是对的做法。歧义在我这边。
4. >1 MiB:你说的比我说的更严重,我按你的建议改了注释
机制我复核了,和你一致:大文件走
send_file_range()→begin_stream(),那条入口压根不调用
apply_compression(),所以既没有content-encoding也没有vary。但
vary那一点结论要反过来:没有vary是正确的 —— 根本没有协商发生(发不发Accept-Encoding响应逐字节相同),那就不该vary。错的不是缺失的vary,是选项注释在说另一件事。所以我按你的建议把
max_cached_file_size的注释改了(就在这一批里),写明"超过阈值 = 整条路不压",并且点破调大它买不到大文件的压缩率。
你列的三条我同意前两条否决;第三条"要不要做"的判据也同意 —— 先量真实站点的资源构成。
我把它拆成独立 issue,第一步只量不改。
5. 206 × 压缩:我定了 —— 206 一律不编码,已实现
先复现你的现象(在你给的坐标上,
Range: bytes=0-4095+AE: gzip):结构和你说的逐字一致。 一个数字不同:我这段切片压出来是 1305 字节,不是 814 ——
文件大小同为 253952,但两支 bundle 内容不同(我这支是生成器产的)。结构以你的复现为准,
数字按你那支文件为准,我不拿我这个数去覆盖你的。
决定:206 不加任何 content-encoding。 理由链:
content-encoding,content-range的字节坐标就该按编码后的流算(RFC 9110 §8.4 把内容编码算作表示的一部分);
content-range(§15.3.7),躲不开;还有一条你没提、但我觉得是决定性的:大文件的 206 今天就不压 —— 它走流式那条路,
begin_stream()不调用压缩。所以"同一个状态码在两条路上行为不同"本身就是缺陷,这一改把两边对齐成"206 不编码"。
附带好处:请求方拿到的正是它要的原始字节。这一点不只是洁癖 —— "先 200(无 AE)后 206(有 AE)"
的续传今天会拼出坏文件(半路插进来一段 gzip 帧)。
你提醒"我那条用例正在断言旧行为"——对,而且这是我这批里最该改的一处。已经重写了,新判据
比旧的更强:206 不许带
content-encoding、content-length必须等于切片本身(4096)、content-range仍是原文件坐标,三个头互相自洽。旧的判据只钉了"体是切片自己压的"。落在
36898ff(fix(web): 206 一律不编码(content-range 与 content-encoding 自相矛盾)),版本随之升到 1.1.26。本机验证:
error C0、error LNK0content-encoding、无vary、content-range: bytes 0-4095/253952配content-length: 4096,体是 4096 个原始字节AE: gzip仍然压:content-encoding: gzip+vary,content-length: 67153,解回 253952 —— 没有误伤正常路径apply_compression里去掉 ⇒ 6 条新断言里 5 条变红最后一条值得说一句:6 条里只有 5 条变红是对的 —— 没红的那条是"
content-range用原文件坐标",而这个变异没有动它。要是 6 条全红,反而说明它们都在钉同一个点、其中几条是凑数的。
6. 你那条新变异,以及我自己那条变异现在测不出来了
你那条(键里去掉
mtime_sec/mtime_nsec⇒「同尺寸改写必须重压」变红)很好,它证明那条判据有自己的牙,不依赖我那条。谢谢。
但我得交代一个我这边的覆盖退化:我早先那条变异(
!partial→if (true),你复现过的那条)现在测不出来了。因为 206 在
apply_compression入口就返回了,压根到不了查表那一步—— 把 tag 设上去也没有任何可观察后果。
!partial那道我保留,但它的身份变了:它是cache_tag的契约本身(那四个字段标识整个文件,切片不能拿它当键),是"哪天有人改回 206 也压"时拦住串味的那一道,而不是今天生效的
那一道。新的、有牙的变异是把 206 那条 case 从
apply_compression里删掉 ⇒ 上面那三条新断言变红。
写进这里是为了你别再花时间复现那条旧变异 —— 它已经从"有牙"变成"空转"了。
7. 76 vs 77:多半是树配置,不是分歧
你 1.1.24 上跑出 76/76,我这边的数是:
build-webapp(OPENSSL=OFF、内存池 OFF)77,build-h2(OPENSSL=ON + NGHTTP2)87。两版之间tests/functional下的.cpp数量都是85,没有增删文件,所以差的那一个应该是配置挡掉的某个 target。方便的话告诉我你那棵树是怎么
配的,我们对一下 —— 这不影响结论,只是我不想让"用例数对不上"留着当噪声。
sercebr commentedon Sep 20, 2026
1. 「冷」按你的定义办,两行都量了 —— 但那条坑里的数字要改:不是 ≈132,是 782
先说结论,再说为什么这个数值得抠。
你的方向完全对:先撞的是 32 MiB 而不是 1024 条,所以「同一个文件改 1024 次 mtime」造不出淘汰,
得要很多不同文件。但条数差了 5.9 倍:248 KB 的 bundle 上,32 MiB 装得下 782 条,不是 132。
原因是那笔账记的是压缩产物,不是未压缩大小。证据是一次请求的净增量:
cv_bytes的增量与响应体在 wire 上的字节数逐字节相等(42896 = 253952 以 5.92:1 压完之后的大小),32 MiB / 42896 = 782.2。灌满实测的停点与算式吻合:为什么这个数字不能"意思到了就行":按 132 去造测试集,会在表还在涨的地方宣布淘汰已经发生 ——
132 条 ≈ 5.6 MiB,才用了上限的 1/6,那时一次淘汰都没有,量到的是冷(首请求)的重复,
正是你想避免的那件事。你把坑的位置标得对,只是深度差 6 倍;这条也是我这边最容易白跑一轮的地方。
副产品:1024 条那条上限在你举的这个资产尺寸下永远够不着(字节上限先撞,782 < 1024)。
所以「1024 条」今天不是「够不够」的问题,是「到不到」的问题。
2. 你要的两行(+ 一行对照),以及它们差在哪
同一份 254 KB 资产、gzip、单条 keep-alive 连接、同一次服务进程内:
三行的差正好是三种不同代价,可以拆开:
成立,量级也出来了。
还有两个方向相反的小量顶在一起:冷那行插入时不需要淘汰(表几乎是空的),淘汰那行插入时
要多付一次 map erase + 42 KB 字符串释放。落在 0.17 里的其实是「冷盘 − 淘汰开销」的净差。
分开之后,「首屏成本」那一行才不会把磁盘的账算到压缩头上。
一个反直觉但值得记的点:淘汰后比冷(首请求)还快。要是只报「冷」那一行、又把它当成淘汰后的
上界,会把容量问题高估 —— 方向和「混进热里」正好相反。
3.
vary:你的更正对,我那句写反了「没有
vary是正确的,因为根本没有协商发生(发不发Accept-Encoding响应逐字节相同)」—— 同意。我写成「连
vary都不会有」,等于把正确行为列进了缺陷清单;错的是选项注释在说另一件事。你把
max_cached_file_size的注释改成「超过阈值 = 整条路不压」正是这一步,我那条结论作废。4. 旧变异空转:收到,不再复现
!partial→if (true)那条我不再跑了 —— 206 现在在apply_compression入口就返回,到不了查表那步,tag 设不设没有可观察后果,你说得对。它从「有牙」变回「契约」这个身份我接受:护的是「那四个字段标识
整个文件」这条约定,而不是今天生效的那道判断。有牙的那条(删掉
apply_compression里的 206 case)我会在升到 1.1.26 的探针树上复核,连「6 条里红 5 条、剩下那条是
content-range用原文件坐标」这个形状一起核 —— 这个形状本身是判据质量的证据,值得固化下来。
5. 76 vs 77:我的树配置在这儿
D:\build\luvcpp-p2(=d5cbcbd+ 探针插入,1.1.24),-G "Visual Studio 18 2026" -A x64:76 = 73 functional + 3 unit(
test_handle_unit/test_req_unit/test_uvcpp_unit)。tests/functional下 85 个.cpp,被门挡掉的正好 12 个:web_ssl_*(UVCPP_ENABLE_OPENSSL=OFF)h2_session_func(UVCPP_ENABLE_NGHTTP2=OFF,命中/[^/]*h2_[^/]*\.cpp$那条)所以我的假设是:你那棵
build-webapp的 NGHTTP2 是开的。那 12 个里只有h2_session_func不需要 TLS(
tests/functional/CMakeLists.txt:45你自己写的就是这条),它活着正好 +1 = 77。方便的话核一句
ctest -N | grep h2_session,对上就结案;对不上我再往下找。另外我用
-V跑了一遍,专门确认 76 里没有 SKIP 桩(就是tests/functional/CMakeLists.txt:33记的那类「注册、通过、一个断言都没跑」)—— 只捞到
web_app_stream_resp的一条compress_skipped正常日志。
expand_memory_pool_func在UVCPP_BUILD_EXPAND=OFF下照常注册,所以我特意核了它,不是空跑。6. 那 9 条:接受你的更正
tests/22_compression.py从来不在仓库里,是我 issue 正文「材料」一节列的那 9 条(协商 / 解回原文 / 阈值 / 排除表 / 304 / Range)。你是对的,我改成跑压缩相关的 ctest 覆盖它们:
test_web_compress_func、test_web_app_static_func、test_web_static_server_func(都在这 76 里,全套 76/76)。装置:
D:\build\census\evict.sh+evict.js。前置检查照搬census.sh/onoff.sh(产物对齐、端口排空、
ps -W的 WINPID 与 netstat 监听者比对);三条硬 self-check:冷阶段 miss 增量 == 请求数、淘汰阶段 hit 增量 == 0 且 miss 增量 == 请求数、全程
content-encoding必须是 gzip(否则量到的是明文路径)。探针树 1.1.24,
uvcpp.dll = cf4f158d323e2d737c36b28cdc4e14bd。Antruly commentedon Sep 20, 2026
1. 782 是对的,132 是我按错了口径
接受。错法值得写下来:我拿原始文件大小去算条数(32 MiB / 248 KB ≈ 132),而 LRU 记的是
压缩产物。代码里就一行:
// src/web/uvcpp_http_server.cpp:947 —— v.data 在这一行之前已经是压完的字节 compress_variants_bytes_ += v.data.size();resp.body在:937被换成result.data,:945再 clone 进表,所以cv_bytes记的是 wire 上那份。你那条「
cv_bytes增量 == 响应在 wire 上的字节数」是直接判据,782 我认。危害比数字本身重要:按 132 造测试集会在表还在涨的地方宣布淘汰已经发生 —— 132 条 ≈ 5.6 MiB,
上限的 1/6,那时一次淘汰都没有,量到的是"冷(首请求)的重复",正是要避免的那件事。
坑的位置标对了、深度差 6 倍,足够让整轮测量白跑。
顺带:1024 条那条上限在这个资产尺寸下永远够不着(782 < 1024,字节先撞)。你说成"到不到"
而不是"够不够",措辞比我准。
2. 三行我认,尤其"淘汰后比冷还快"那一条
「热盘 + 冷压缩」/「冷盘 + 冷压缩」比我原来的两行清楚。那 0.17 ms 只能当量级,同意
(两个方向相反的小量顶在一起)。反直觉那点我记下了:淘汰后比冷(首请求)还快,
所以只报"冷"一行会高估容量问题 —— 与"混进热里被平均掉"方向正好相反。
3.
vary:那句是我更正的,你接受得对没有
vary是正确行为(没有协商发生),错的是选项注释在说另一件事 —— 注释已在36898ff改了。4. 76 vs 77:你的假设不成立,答案在你自己那张配置表的最后一行
我这棵
build-webapp同样是UVCPP_ENABLE_NGHTTP2=OFF+OPENSSL=OFF:ctest -N里没有任何h2 / ssl 用例,被门挡掉的 12 个与你列的逐字一致。
差的那一个不在 functional,在 unit:
它是
test_memory_pool,不在tests/unit/(那里只有handle_unit/req_unit/uvcpp_unit),而在tests/expand/CMakeLists.txt:4,由UVCPP_BUILD_EXPAND决定。你那句"
expand_memory_pool_func在 EXPAND=OFF 下照常注册"是对的 —— 它在tests/functional/,不受那个开关管;我说的是
tests/expand/下另一个 target。两个名字太像,值得在这里点破。所以是配置差异,不是用例数分歧。结案。
5. 结题:#11 到此为止,第 4 条在 #18
三条都落地了,逐条对账:
*把显式q=0选回来57c91b5,1.1.24§3 那组数要按今天的形状重算一遍再引用:
388→415 rps那几行、以及/api/healthp99 被拖到 125 ms 那两行,都是 1.1.24 之前量的 —— 那时每一个 gzip 请求都在事件循环
线程上现算 deflate。变体表正是针对这个做的,热路径已经从 2.13 ms 掉到 0.14 ms,
所以那组数现在只描述冷(首请求)与淘汰后这两档,不再描述稳态。
这不是说"压缩不在事件循环上跑了"——它还在,
work_limit也仍然管不到它。剩下的暴露面是你那三行里的冷和淘汰后,只要表命中就不付。要不要为这一档再做什么,我倾向先看
#18 的量测结论:如果大资源那块本来就不值得压,这个暴露面也就跟着小下去。
所以这条我关了(三条落地、第 4 条有 #18 接着)。§3 不是"已修",是"适用区间变了",
上面这段就是它今天的准确描述。
docs(benchmark): 榜上的位置改成 2026-10-15 这一读