Skip to content

响应压缩:HEAD 与 GET 的头不一致(HEAD 报未压缩长度,客户端照此读会挂住);另附两条容量观察 #11

Closed

Description

@sercebr

压缩这块之前一直是关着测的(靶场为了不让它掩盖吞吐数字,写死了 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 不读盘"正是静态层刻意保的设计。三条路:

  1. HEAD 也压:语义最正,代价是 HEAD 变贵;head_as_get 之外可能还得再加一道开关。
  2. HEAD 不压、但也不谎报长度:不发 content-length。代价是与"钉住长度"的既有设计相反。
  3. 把压缩结果缓存(下面第 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

  1. Antruly commented on Sep 19, 2026

    @Antruly
    Owner

    三条都核实过了,结论按你的来。逐条回。

    1. HEAD 的长度确实是谎报 —— 根因也在你写的那一层

    你定的位是对的,我把链条补全(都在本仓当前 master 上):

    步骤 位置 做了什么
    1 src/webapp/uvcpp_web_response.cpp:587-590 sync_meta() 里 const size_t len = resp_.body.size(); 先按未压缩的 body 钉死 content-length,再 if (head_only_) resp_.body.clear(); —— 注释自己也写了"顺序不能反 —— 先丢就算不出长度了"
    2 src/web/uvcpp_http_server.cpp:765 apply_compression() 第一道尺寸门 resp.body.size() < compress_min_body_ → 此时 body 已经是 0 → 直接返回
    3 src/web/uvcpp_http_server.cpp:768 所以那句 if (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。

    • 3(缓存压缩结果):max_cached_file_size 以内本来就是一个内存 LRU,加一个按 (路径, mtime, 编码) 的压缩变体条目是同一层的自然延伸。命中时 HEAD 的 CL 就是缓存里的字节数,既不用读盘也不用重压;GET 侧顺带把第 3 点那个吞吐塌方一起解掉。要处理的就一件事:缓存是"按编码"还是"按 Accept-Encoding 归一" —— 压 gzip 和 deflate 是两份(br 同理),条目数会翻,得给个上限。
      • 未命中时 HEAD 仍然要读+压一次来填缓存(这就是你说的第 1 条,只是它现在变成了"填缓存",后续 GET 直接受益,不再是白付)。
    • 1(HEAD 也压):单独做的话,每个 HEAD 都白读白压一遍,且和 GET 各压一次完全不复用 —— 只有在不做缓存的前提下它才是唯一"语义最正"的选项。
    • 2(不发 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 点的数据重测一遍给你。你要是对静态层的排序有别的看法,现在说还来得及。

  2. Antruly commented on Sep 19, 2026

    @Antruly
    Owner

    「内存型路由」那一步做完了,已推 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 上看得见:

    HEAD 时 resp.body 采集到的 body_bytes
    h1 服务端层自己 resp.body.clear()(uvcpp_http_server.cpp:575,按压缩后的长度钉完 CL 之后) 天然是 0,换算看不出差别
    h2 不清 —— 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 加回来";
    • "HEAD 不发 body 字节"落在序列化那一支上: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):

    HEAD /probe.html  ->  content-length: 4000   content-encoding: (无)
    GET  /probe.html  ->  content-length: 40     content-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 时顺手带走。

    判据

    • 两棵树全量 ctest:76/76、87/87;PageHeap 门禁 76/76、87/87 全绿、0 抓取。
    • 变异:还原 sync_meta 的 clear → 两条新用例红、sent_bytes_head 仍绿;单独还原 is_head 门 → head_cl_matches_get 红;body_bytes 还原成裸 body_size() → h2 场景 8 红、h1 用例仍绿。
  3. Antruly commented on Sep 20, 2026

    @Antruly
    Owner

    第 1 条已修(分两条腿,各自有能失败的用例);第 2 条就是你的 PR #10,已合并,之后我又收紧了一道;第 3、4 条还没做,按之前说的走方案 3。

    第 1 条:HEAD 与 GET 的头不一致

    你的定位是对的,而且**「只删 apply_compression 里那句 if (ctx.is_head) return false; 看不到变化」也正是对的** —— 在你看到的那个版本里,HTTP 层那句已经删掉了,真正的成因在它上游,你追到的正是地方。

    成因横跨两层,所以拆成两条腿修,各自独立可复现:

    腿 成因 状态
    内存 / JSON 路由 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-length 已修(58f6433,1.1.21)

    只修一条另一条仍然照旧,所以两条都得动。

    静态这条走的就是你列的方案 1(HEAD 也压):HEAD 现在与 GET 同路 —— 一样读盘、一样进 LRU、一样占 work pool 名额 —— 体装好之后由 set_head_only(true) 在序列化时丢掉。那份体不是发给对端的,是给 HTTP 层压缩器算长度用的。你说得对,HEAD 因此变贵了;这是为正确性付的代价,换来的是「长度一定是算出来的,不是声明出来的」。

    复现的数(可压缩的 text/plain,256 KiB):

    • HEAD:content-length: 262144,无 content-encoding
    • 同路径同 Accept-Encoding: gzip 的 GET:content-length: 289,content-encoding: gzip

    形状与你报的 1048576 / 3119 一致:HEAD 报原长、GET 报压缩后长度。

    为什么既有的 HEAD 用例一直是绿的(两个原因,缺一个都抓不到):

    1. 那些用例发的 HEAD 不带 Accept-Encoding,压缩根本没进协商;
    2. 拿来对照的 /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 条还开着。

  4. Antruly commented on Sep 20, 2026

    @Antruly
    Owner

    第 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 在它自己所有既有检查都通过之后
    拿这个身份去查/存变体。压缩与否仍然只有一处判定。

    // webapp 静态路径(uvcpp_web_static.cpp:961 那个分支):
    resp.set_cache_tag(final_url + "\x1f" + std::to_string(mtime_sec) + "\x1f" +
                       std::to_string(mtime_nsec) + "\x1f" + std::to_string(size));
    
    // http_server 侧,apply_compression 走到"确定要压"之后:
    if (resp.has_cache_tag()) {
      if (variants_.get(tag, best, out)) { resp.body.clear(); resp.body.clone(out); ... return true; }
    }
    auto result = http_compress::compress(...);
    ...
    if (resp.has_cache_tag()) variants_.put(tag, best, resp.body);   // 只存更小的那种

    这样:

    • 门槛、MIME 表、协商全留在 :777,一个字都不用搬,也不会有两处判定。
    • 失效是免费的、而且已经是对的:tag 用的是静态路径今天就在用它验原始条目的
      同四个字段(uvcpp_web_static.cpp:781-783 的 p.key/size/mtime_sec/mtime_nsec 比对)。
      文件一改,tag 自己就变了,不需要任何额外的失效逻辑。这一点是选 B 家族而非内容寻址的
      真正理由 —— 内容寻址要自己管"这份内容还有没有人引用",而 LRU 条目的失效语义是现成的。
    • h2 白拿:send_h2_response 也过 apply_compression(:1272),不需要单独做。
    • 流式本来就绕过(begin_stream,:663 的注释明说),它没有 body,无事可做 —— 这是对的,不是缺口。
    • 爆炸半径圈死在一个路由族:没有 tag 的响应就是今天的行为,逐字不变。

    缓存本身的约束:按字节设上限(不是按条数),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 降了",实际是"第二次以后才降"。表里必须是:

    场景 基线(今天) 加变体缓存后
    冷(首次,未缓存变体) 全价 deflate 应与基线逐位相同(这条是判据:冷路径不许变差)
    热(同文件同编码,重复) 全价 deflate 只剩一次 clone

    "冷 == 基线"比"热更快"更重要:它证明缓存没有把冷路径拖慢,这是这类改动最容易翻车的地方。

    非目标

  5. sercebr commented on Sep 20, 2026

    @sercebr
    CollaboratorAuthor

    落点 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):

    if (j->status == probe_status::STREAM) {
        resp.send_file_range(j->fs_path, first, last);      // 无 body,进不来
    } else if (j->data) {
        resp.body(j->data->data() + first, count, ...);     // ← 切片:first 起、count 个
    }

    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、体 ≥ 门槛的响应会被压 —— 你那个"所有既有检查都通过之后"的查缓存点,对它是开着的。

    两种次序都错,而且都不报错:

    • 先全量 GET、后 Range:Range 请求命中 T,拿到整个文件的 gzip;此时响应头已经是 206 + content-range: bytes 0-99/<size>,而 content-length 又被 to_string() 从(错的)那个 body 重算 —— 头体互相矛盾,客户端拿到不属于它的字节。
    • 先 Range、后全量 GET:全量 GET 命中 T,拿到那 100 字节切片的 gzip;状态 200、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,这份缓存会静默发旧档位的字节,症状只是"压得比配置的松",又是一类不报错的。
    • HEAD 与 GET 共用一份变体是对的、而且是想要的:58f6433 之后两边走同一条路、算出同一个长度,共用条目正是"HEAD 与 GET 头字段一致"的来源,不是污染。

    5. 另一件相邻的事(既有行为,不是本设计引入的)

    206 + 压缩这条组合本身有点脏:content-range 是按原始字节算的(first/last 来自 parse_range(value, j->size, ...)),发出去的却是压缩后的体。要不要另开一条 issue 单说由你定,我只在这里记一笔 —— 它和上面的撞键是同一个"206 与压缩"面,但成因不同。

    6. 测量我这边接得上

    冷/热两行没问题,而且我同意"冷 == 基线"是判据而不是结论 —— 那是这类改动最容易翻车的地方。我这套装置(服务端计数 + 压测端)本来就是冷热分开采的;你的实现落地后我按你那张表跑一遍,把"冷路径逐位不变"当断言来报。

  6. added 2 commits that reference this issue on Sep 20, 2026
  7. sercebr commented on Sep 20, 2026

    @sercebr
    CollaboratorAuthor

    三处都落了(if (!partial)、四个旋钮那条注释、档位必须进键)。我在 1.1.24(d5cbcbd)上独立验了一遍,顺带有一处我认为你的测量判据要改一个字的地方。

    1. 独立验证:76/76 + 两处变异

    • 全量 ctest -C Release:76/76。
    • 复现你那个变异(if (!partial) → if (true)):与你写的逐字一致 —— hits 从 1 变 3,两条错序双双撞红(「先 Range 后全量:全量不能拿到那段切片的变体」「先全量后 Range:区间不能拿到整份文件的变体」),外加「应当恰好一次命中」。
    • 一个你没提过的变异,用来查「mtime 是唯一能分开两者的字段」这句话是不是真被覆盖了:把键里的 mtime_sec / mtime_nsec 去掉(键只剩 fs_path + size)→ 「同尺寸改写后必须重新压,不能发旧变体的字节」撞红,计数变成 hits=2 misses=2 stored=2 entries=2(少了一对存/取)。也就是说那条判据不是装饰,它真的守着「尺寸不变、内容变了」这一档。

    2. 一处要改的字:冷路径不可能「逐位相同」

    你在 #11 里定的判据是「冷(首次,未缓存变体)应与基线逐位相同」。作为发出去的字节它成立(用例里那三条 raw_body(a1) == raw_body(a2)、以及内容编码/长度逐字相同就是这条)。作为代价它不成立 —— 今天的冷路径比基线多了三样:

    1. 每个非 Range 的静态响应都要拼一次 tag(4 次 std::to_string + 3 次拼接),不管这次会不会命中;
    2. 一次 map 查找未命中;
    3. 存表时把压好的体再拷一份进表。

    所以 p99 那一栏我建议写成「冷 ≈ 基线 + Δ」,Δ 就是上面三项 —— 可测出来就报数,测不出来就报「在本次测量的分辨率下不可分辨」,不要写成等式。这条不是抠字:写成等式之后,若有人真测出冷路径 3% 的回退,第一反应会是怀疑测量,而不是去看 Δ。

    顺带一句:你自己否掉内容寻址那条时写的是「哈希每个响应都要付」—— tag 这条路也是每个响应都要付,只是付得便宜得多(不哈希 body)。所以「冷路径有代价」这件事两案都有,文档里别显得只有内容寻址那案有。

    3. 我接的那件事(冷/热 p99)

    按这版 master 跑。先把口径钉死一处:「冷」是指「这个进程第一次请求这个文件」,还是「变体表里没有这个键」? 两者在淘汰之后会分开 —— 32 MiB / 1024 条那两道 LRU 一动,原本的"热"请求立刻回到冷的代价,于是长跑里报出来的"热 p99"会突然退化,看起来像缓存失效、其实是淘汰。

    我打算在报告里把「淘汰后的 p99」单列一行,免得这个退化被平均进"热"里。要不要这么分由你定;你不说的话我就按这个来。

  8. Antruly commented on Sep 20, 2026

    @Antruly
    Owner

    第 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 + size fs_path + size + mtime_sec + mtime_nsec / 与 /index.html 是同一个文件的两个 URL,用 final_url 会让它们各存一份变体、白压一遍。fs_path 才标识"哪份内容"
    set_cache_tag() / has_cache_tag() 公开数据成员 cache_tag 它就是 retryable 旁边的一个字段,没有不变式要守;加一对存取器只是把同一个赋值拆成两行
    只有字节上限(32 MiB) 字节 32 MiB + 条数 1024 + 单条 4 MiB 只按字节算的话,键串里带完整路径、外加 map 节点开销,一堆小条目可以永远撞不到字节上限。单条那道是另一回事:一条 4 MiB 以上的变体没有缓存的必要,压它一次的成本相对它自己的传输时间可以忽略
    —— 计数器 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 判断。两种次序都不报错:

    • 先全量后 Range:206 拿到整个文件的 gzip(头声明 206、体是整个文件);
    • 先 Range 后全量:更贵 —— 一个正常的 200 静默收到那段切片的 gzip,而 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 的比值):

    基线(无缓存) 加变体缓存
    冷(首次,未命中)p50 / p99 10.93 / 16.31 ms 10.43 / 12.90 ms
    热(同文件同编码,重复)p50 / p99 10.86 / 13.76 ms 0.73 / 1.35 ms

    「冷 == 基线」这条判据成立(10.43 vs 10.93,差异在噪声里)—— 缓存没有把冷路径拖慢,而冷路径正是这类改动最容易翻车的地方。热路径 15×。

    吞吐(Accept-Encoding: gzip vs identity,同一支文件反复取):

    并发 基线 gzip 加缓存 gzip identity(两轮都在,作对照)
    1 92.1 rps 1858.3 rps 1818.8 → 1672.9
    16 58.3 4339.6 3228.8 → 3275.5
    64 61.4 4471.6 3285.6 → 3248.7

    同时那条完全不碰压缩的 /hello 的 p99:

    并发 基线 加缓存
    16 319.80 ms(n=24) 7.27 ms(n=1122)
    64 1052.78 ms(n=6) 32.79 ms(n=266)

    identity 那一列两轮基本相同,这是个必要的对照:没有 Accept-Encoding 时 best == NONE,在门前就返回了,缓存碰都碰不到 —— 数字没动才说明没有副作用。

    4. 这份数字不说明的事(按重要性排)

    1. 第一个请求照付全价。 缓存省的是重复的 deflate,冷 那行就是证据:10.43 ms,与基线没有区别。所以第 3 条的核心症状没有被修掉 —— "压缩同步跑在循环线程上、第一个请求卡住整个循环"依然成立。上面那张 rps 表之所以好看,是因为它测的是同一个文件被反复请求(静态资源最典型的形状);换成"每个文件只取一次",收益是零。
    2. 并发 64 基线的 /hello p99 只有 6 个样本(服务端慢到就产出这么多),那个数只能当方向、不能当分位数。
    3. 并发 64 上 gzip 比 identity 还快,是因为命中后的体只有 67 KB,而 identity 是 254 KB —— 本机环回下主要反映要序列化的字节数,不是压缩本身变快了。
    4. 不覆盖你说的 >1 MiB 那一档。 超过 max_cached_file_size 走分片流式下发,body 根本不存在,缓存拿不到东西 —— 你指出的"方向上正好反了"这条依然成立,本次没有动它。
    5. 与 响应体的所有权:把 body() 自存 + to_string() 头体合并这两份拷贝消掉(nbufs=2 + 引用计数) #17 那两份拷贝无关,拷贝放大比不会因为本条下降(预期之内,别混进同一个数字里看)。

    5. 想请你做的

    1. tests/22_compression.py 9 条全部在 1.1.24 上重跑一遍(1、2 两条的修复分别落在 1.1.18/1.1.21 和 fix(web): * 不得覆盖 Accept-Encoding 里显式给出的 q=0 #10,本轮不该有回归)。
    2. 新用例的判据是两种错序各一条 + 同尺寸改写必须重压(mtime 是唯一能分开"同尺寸不同内容"的字段)。变异验证:把 !partial 改成 if (true),两条错序双双变红,计数器 hits 从 1 变成 3。
    3. 第 4 条那份 on/off 的表,如果你那边机器上重跑出来方向相反,以你的为准并告诉我 —— 本机是环回 + 合成件,不占理。

    第 4 节第 4 点(>1 MiB 那一档反向)我没有动,因为它的修法已经越出"变体缓存"的范围(要么改流式路径、要么改 max_cached_file_size 的语义),想听你的意见再定。

    6. 关于两条评论相隔 17 分钟

    我那份设计是 01:38 发的,你 01:55 就回了 —— 我没料到这么快,而且如果我先读到你的评论,就会直接走 !partial、省掉一次"实现了再推翻"。不过那次推翻留下的东西不是白费的:first/count 那版我做了变异验证,正好证明了两种错序都能被抓住,而现在的用例就是照那个形状写的。设计里有错的地方(撞键、容量只按字节算)请继续直接说。

  9. sercebr commented on Sep 20, 2026

    @sercebr
    CollaboratorAuthor

    §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 PASS
    test_web_app_static_func 15 个场景 + 2 行计数器回显,ALL PASS
    test_web_static_server_func PASS

    变体缓存那条在 1.1.24 上报 hits=1 misses=3 stored=3 entries=3 bytes=124 —— 与你用例里写死的四条断言逐字相同。如果你的 9 条是别的口径,把名字贴给我,我照单再点一遍。

    二、§5.2:两条变异,本机逐字复现

    变异 计数器 变红的判据
    if (!partial) → if (true)(你那条) hits 1 → 3;misses=3 stored=3 entries=3 bytes=119 「先 Range 后全量:全量不能拿到那段切片的变体」「先全量后 Range:区间不能拿到整份文件的变体」+「应当恰好一次命中」(3 条)
    键里去掉 mtime_sec/mtime_nsec(我这条) hits=2 misses=2 stored=2 entries=2 bytes=84 「同尺寸改写后必须重新压,不能发旧变体的字节」+ 四条计数(5 条)

    你那条的预测(两条错序双双变红、hits 1→3)一个数都不差。第二条是冲着"mtime 是唯一能分开同尺寸不同内容的字段"那句去的:拿掉它之后 vcv_mtime.txt 的两次请求算出同一个键,第二次直接发旧变体的字节 —— 用例抓到了,entries 从 3 掉到 2 正好是"两次改写只存了一条"。

    三、§5.3:那张 on/off 表(本机)

    同一份源码、同一个二进制,只换一行本地变异(cache_tag 永不设置 = 缓存存在但永远够不着);两个服务同时跑:8099 = 带缓存、8100 = 够不着。资产 248 KiB(gzip 5.90:1,你的是 3:1,所以绝对值不要和你的表横向比,能比的只有比值)。

    248 KiB 缓存开 p50 / p99 缓存够不着 p50 / p99 吞吐(开 / 够不着)
    冷(120 个不同文件,次次未命中) 2.02 / 2.34 ms 2.02 / 2.36 ms 487 / 489 req/s
    热(同一文件重复) 0.20 / 0.55 ms 1.87 / 2.08 ms 4447 / 534 req/s
    对照 identity(不碰缓存) 0.35 / 0.89 ms 0.35 / 0.82 ms 2602 / 2650 req/s

    方向与你完全一致,热那行更锐利(9.4× p50、8.3× 吞吐)。三点:

    1. 「冷 == 基线」在 n=120 上成立(2.02 对 2.02,p99 2.34 对 2.36)—— 这条判据可以依赖。但我上一轮那句要在定义上留着:它不该是严格零,冷路径每响应要多付一次 tag 字符串构造 + 一次 map 未命中 + 一次 store 拷贝。这三样在回环上是 µs 级,低于这张表的分辨率,所以它量不出来 —— 不是它不存在。这比"等于零"是更强的结论:代价小到可以忽略。
    2. 热 gzip(0.20)比 identity(0.35)还快,在并发 1 上就是这样。 你 §4.3 把它归给 c=64,我这边单连接也在 → 那就不是并发假象,纯粹是要序列化的字节少(43 KB vs 248 KB)。建议在注释里写死,否则以后有人拿"gzip 比不压还快"去推理,方向正好是反的。
    3. identity 那一列两轮重合(0.35 对 0.35,2602 对 2650)—— 你说的那个必要对照,我这边也成立。

    四、你问的 >1 MiB:比"缓存够不着"严重一档,是根本不压

    资产 大小 带 accept-encoding: gzip 的响应
    web-900k.html 921623(阈值下) content-encoding: gzip,content-length: **3276**,vary: accept-encoding
    web-1536k.html 1572875(阈值上) 无 content-encoding,无 vary,content-length: **1572875**
    web-1536k.html(不带 AE 的对照) 1572875 同上,逐字节相同

    (两个文件内容同源同风格,只差一在阈值下、一在阈值上。我的合成件可压比极端,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。
    • 把"整读"和"可压"拆成两个语义(单独一条 band,只压不进表):可以做,但它是"为压缩临时整读",峰值就是那条 band 的上限,等于把 max_cached_file_size 的内存契约搬到另一个名字下面 —— 改的是语义,不是复杂度。
    • 流式 deflate(分片压 + 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 判断,就量了一下:

    GET /assets/bundle.js
      Range: bytes=0-4095
      Accept-Encoding: gzip
    → HTTP/1.1 206 Partial Content
      accept-ranges: bytes
      content-range: bytes 0-4095/253952
      content-encoding: gzip
      content-length: 814
    

    那 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 必须互不相同 —— 否则"没变快"和"没生效"长得一模一样。

  10. Antruly commented on Sep 20, 2026

    @Antruly
    Owner

    1. 「冷 == 基线」那条判据是我写错了,你的修正我接受

    我报告里写的是「冷 == 基线」这条判据成立。写成等式是错的,原因就在你点的那三项 ——
    它们都是结构性的,不是抖动:

    冷路径上必付的 落点
    tag 字符串构造(4 段 std::string 拼接) uvcpp_web_static.cpp:982-984,每响应一次,与 body 大小无关
    变体表一次 miss 的查找(std::map,键是字符串) uvcpp_http_server.cpp:905
    存入时额外那一份拷贝 uvcpp_http_server.cpp:931(响应自己那份还要发出去,不能 move)

    所以按你说的改成「冷 ≈ 基线 + Δ」,Δ 由上面三项构成、恒 > 0。写成等式的实际危害就是你
    说的那条:以后真出现 3% 的冷路径退化,看的人第一反应是"测量问题"而不是"去看 Δ"—— 而那
    恰恰是最该被看见的一类退化。

    你顺带指出的不对称也对,而且是我论证里的漏洞:我在设计评论里否掉内容寻址,理由是
    「哈希每个响应都要付」——这句话同样适用于 tag 这条路(tag 就是每响应构造一次字符串 +
    一次表查找)。两条路的差别是单价,不是"一条付一条不付":

    • 内容寻址:O(文件大小) 的哈希,每响应一次;
    • tag:O(键长) 的字符串构造 + 一次 map 探查。

    文档里不该读成"内容寻址有冷成本、tag 没有"。措辞我会改掉 —— 谢谢你抠这一处,它把我的
    论证从"tag 没有冷成本"改成了"tag 的冷成本与文件大小无关",后者才是真的、也是真正支持这个
    设计的那个理由。

    2. 「冷」的定义:我定 —— 按操作定义,但报告分两行

    定义钉在"变体表里有没有这个键",因为它是唯一决定"要不要现算 deflate"的东西:
    键不在 ⇒ 现算(冷);键在 ⇒ 直接取字节(热)。

    但报告里必须分两行,因为"键不在表里"有两个成因,含义完全不同:

    行 成因 性质
    冷(首请求) 本进程第一次请求这个文件(重启 / 部署 / 新文件) 客户端的真实首屏成本,不可消除
    淘汰后 键被 32 MiB / 1024 条上限挤掉了 容量压力导致的退化,调参就能改善

    按你说的办:「淘汰后的 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):

    HTTP/1.1 206 Partial Content
    content-range: bytes 0-4095/253952      ← 原文件坐标
    content-encoding: gzip
    content-length: 1305                    ← 编码后的字节数
    

    结构和你说的逐字一致。 一个数字不同:我这段切片压出来是 1305 字节,不是 814 ——
    文件大小同为 253952,但两支 bundle 内容不同(我这支是生成器产的)。结构以你的复现为准,
    数字按你那支文件为准,我不拿我这个数去覆盖你的。

    决定:206 不加任何 content-encoding。 理由链:

    1. 一旦带 content-encoding,content-range 的字节坐标就该按编码后的流算
      (RFC 9110 §8.4 把内容编码算作表示的一部分);
    2. 而 206 必须带 content-range(§15.3.7),躲不开;
    3. 要自洽就只能先压整个文件、再切编码流 —— 那正好把范围请求的意义抵消掉;
    4. 所以唯一自洽的选择是不编码。与 nginx 同策(它对带范围的响应不压)。

    还有一条你没提、但我觉得是决定性的:大文件的 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。本机验证:

    项 结果
    构建(build-webapp / Release) error C 0、error LNK 0
    全套 ctest 77/77
    页面堆门禁 77 裸跑 + 77 完整页堆,抓到 0、其他异常 0、过期 DLL 0
    端到端(真起 demo 发原始请求) 206:无 content-encoding、无 vary、content-range: bytes 0-4095/253952 配 content-length: 4096,体是 4096 个原始字节
    端到端对照 同一个文件走 200 + AE: gzip 仍然压:content-encoding: gzip + vary,content-length: 67153,解回 253952 —— 没有误伤正常路径
    变异 把 206 那条 case 从 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。方便的话告诉我你那棵树是怎么
    配的,我们对一下 —— 这不影响结论,只是我不想让"用例数对不上"留着当噪声。

  11. sercebr commented on Sep 20, 2026

    @sercebr
    CollaboratorAuthor

    1. 「冷」按你的定义办,两行都量了 —— 但那条坑里的数字要改:不是 ≈132,是 782

    先说结论,再说为什么这个数值得抠。

    你的方向完全对:先撞的是 32 MiB 而不是 1024 条,所以「同一个文件改 1024 次 mtime」造不出淘汰,
    得要很多不同文件。但条数差了 5.9 倍:248 KB 的 bundle 上,32 MiB 装得下 782 条,不是 132。

    原因是那笔账记的是压缩产物,不是未压缩大小。证据是一次请求的净增量:

    [A] 一条 entry:cv_bytes += 42896   cv_entries += 1   (wire 上 42896 字节,原始 253952)
    

    cv_bytes 的增量与响应体在 wire 上的字节数逐字节相等(42896 = 253952 以 5.92:1 压完之后的大小),
    32 MiB / 42896 = 782.2。灌满实测的停点与算式吻合:

        n= 700  entries= 741  bytes=30.31 MiB
        n= 750  entries= 782  bytes=31.99 MiB   ← 停在这
        n= 800  entries= 782  bytes=31.99 MiB
       n= 1200  entries= 782  bytes=31.99 MiB
    

    为什么这个数字不能"意思到了就行":按 132 去造测试集,会在表还在涨的地方宣布淘汰已经发生 ——
    132 条 ≈ 5.6 MiB,才用了上限的 1/6,那时一次淘汰都没有,量到的是冷(首请求)的重复,
    正是你想避免的那件事。你把坑的位置标得对,只是深度差 6 倍;这条也是我这边最容易白跑一轮的地方。

    副产品:1024 条那条上限在你举的这个资产尺寸下永远够不着(字节上限先撞,782 < 1024)。
    所以「1024 条」今天不是「够不够」的问题,是「到不到」的问题。

    2. 你要的两行(+ 一行对照),以及它们差在哪

    同一份 254 KB 资产、gzip、单条 keep-alive 连接、同一次服务进程内:

    行 构造 p50 p99 mean
    冷(首请求) 从没请求过的文件 2.12 2.46 2.13 ms
    淘汰后 表已满,同一批再打一遍(self-check:hit +0 / miss +1200) 1.95 2.18 1.96 ms
    热(对照) 键在表里 0.12 0.28 0.14 ms

    三行的差正好是三种不同代价,可以拆开:

    • 热 → 淘汰后 = 1.83 ms:这就是「现算 deflate」的价钱,每 254 KB 一份。你那条「调参就能改善」
      成立,量级也出来了。
    • 淘汰后 → 冷 ≈ 0.17 ms:冷盘那次首读。⚠️ 这一格只能当量级,不能当精确分解 —— 两行之间
      还有两个方向相反的小量顶在一起:冷那行插入时不需要淘汰(表几乎是空的),淘汰那行插入时
      要多付一次 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:

    UVCPP_BUILD_NET=ON   UVCPP_BUILD_WEB=ON   UVCPP_BUILD_WEBAPP=ON
    UVCPP_BUILD_TESTS=ON UVCPP_BUILD_FUNCTIONAL=ON
    UVCPP_ENABLE_OPENSSL=OFF  UVCPP_ENABLE_NGHTTP2=OFF  UVCPP_ENABLE_ZLIB=ON
    UVCPP_ENABLE_TRY_WRITE=ON  UVCPP_TRY_WRITE_MIN_BYTES=32768
    UVCPP_BUILD_EXPAND=OFF
    

    76 = 73 functional + 3 unit(test_handle_unit / test_req_unit / test_uvcpp_unit)。
    tests/functional 下 85 个 .cpp,被门挡掉的正好 12 个:

    • 11 个 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。

  12. Antruly commented on Sep 20, 2026

    @Antruly
    Owner

    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:

    我的 77 = 73 functional + 4        (`test_memory_pool` 多出来)
    你的 76 = 73 functional + 3
    

    它是 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

    三条都落地了,逐条对账:

    条目 落点
    第 1 条 HEAD 与 GET 头不一致 1.1.18 / 1.1.21
    第 2 条 * 把显式 q=0 选回来 PR #10
    第 3 条 容量观察(压缩变体缓存) 57c91b5,1.1.24
    第 4 条 >1 MiB 整条路不压 拆成 #18,第一步只量不改

    §3 那组数要按今天的形状重算一遍再引用:388→415 rps 那几行、以及 /api/health
    p99 被拖到 125 ms 那两行,都是 1.1.24 之前量的 —— 那时每一个 gzip 请求都在事件循环
    线程上现算 deflate。变体表正是针对这个做的,热路径已经从 2.13 ms 掉到 0.14 ms,
    所以那组数现在只描述冷(首请求)与淘汰后这两档,不再描述稳态。

    这不是说"压缩不在事件循环上跑了"——它还在,work_limit 也仍然管不到它。剩下的暴露面是
    你那三行里的冷和淘汰后,只要表命中就不付。要不要为这一档再做什么,我倾向先看
    #18 的量测结论:如果大资源那块本来就不值得压,这个暴露面也就跟着小下去。

    所以这条我关了(三条落地、第 4 条有 #18 接着)。§3 不是"已修",是"适用区间变了",
    上面这段就是它今天的准确描述。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions