# 保哥笔记 — Nginx > 本分片含 10 篇文章,按发布日期倒序。全部分片索引见 https://zhangwenbao.com/llms-full.md **站点**:https://zhangwenbao.com/ **分类**:Nginx **生成**:2026-09-12 16:28:15 CST --- ## 同样是4096的表,HTTP/3比HTTP/2早678字节就把SEO红利丢光了 - URL:https://zhangwenbao.com/qpack-dynamic-table-zero-http3-header-compression-seo.html - 分类:Nginx - 发布:2026-07-31 | 更新:2026-07-31 - 摘要:47个站里32个宣告QPACK动态表是0,占68.1%。表开着均摊191字节、关着1786字节差9.3倍;同为4096的表QPACK比HPACK早678字节没红利。 - 关键词:技术SEO,Nginx,HTTP/3 > **TLDR**:摘要:把47个能连上HTTP/3的站的握手参数逐个拉出来,其中32个宣告的QPACK动态表容量是0,占68.1%,Cloudflare后面那24个站无一例外。表是0意味着请求头的复用能力被完全关掉:一份2000字节的Cookie连发20个请求,表开着均摊191字节,表是0就是1786字节,差9.3倍。更意外的是同为4096的表,QPACK的红利在Cookie长到2394字节时就断了,而HPACK撑到3072——同一个数字的表,HTTP/3比HTTP/2早678字节交卷。这不是谁实现得差,是RFC 9114明写的取舍:拿压缩效率换延迟。 > 摘要:把47个能连上HTTP/3的站的握手参数逐个拉出来,其中32个宣告的QPACK动态表容量是0,占68.1%,Cloudflare后面那24个站无一例外。表是0意味着请求头的复用能力被完全关掉:一份2000字节的Cookie连发20个请求,表开着均摊191字节,表是0就是1786字节,差9.3倍。更意外的是同为4096的表,QPACK的红利在Cookie长到2394字节时就断了,而HPACK撑到3072——同一个数字的表,HTTP/3比HTTP/2早678字节交卷。这不是谁实现得差,是RFC 9114明写的取舍:拿压缩效率换延迟。 上一篇 (https://zhangwenbao.com/http3-alt-svc-discovery-first-request-crawler-seo.html)拆的是能不能走到HTTP/3上去——结论是一次访问里的第一个请求走不到,而爬虫抓页面通常只发那一个请求。 那篇写完,保哥心里有个疙瘩没解开:对于那些确实走到了HTTP/3上的请求,它们到底拿到了什么? 这个问题看起来不该有悬念。HTTP/3是HTTP/2的后继者,头部压缩这块用的是QPACK,而QPACK是照着HPACK改的。按常识推,同一件事上HTTP/3应该不比HTTP/2差。 结果一测,两个数字都跟常识反着来。 ## 把47个站的握手参数拉出来,68%宣告的动态表是0 先说数据是怎么拿到的,因为这一步决定了后面所有结论的可信度。 HTTP/3的连接建立起来之后,双方会各自在控制流上发一个SETTINGS帧,把自己这一侧的参数告诉对方。这里面有一个参数叫 SETTINGS_QPACK_MAX_TABLE_CAPACITY,它规定了对端可以用多大的动态表来压缩发给我的头。 保哥用一个纯Python的QUIC客户端,对125个域名逐个发起真实的h3握手,把服务端发过来的SETTINGS帧解出来。49个握上了手,47个拿到了完整的SETTINGS。分布如下: QPACK动态表容量 | 站数 | 占比 | 0(没宣告,按规范默认值算) | 32 | 68.1% | 4096 | 5 | 10.6% | 16384 | 1 | 2.1% | 65536 | 9 | 19.1% | 68.1%这个数字第一眼看上去像是我解析错了。所以在往下写之前,得先证明这个0是真的。 ## 先证明这个0不是解析错误 对账方式是找一个已知答案的对照物:保哥在自己的服务器上起了一个nginx 1.28.3的实例,开着HTTP/3,用同一个客户端去连,看读出来的是什么。 读出来是 QPACK_MAX_TABLE_CAPACITY = 4096、QPACK_BLOCKED_STREAMS = 128。这两个数正是nginx源码里写死的出厂值。同一段解析代码,在nginx上读出4096,在Cloudflare上读出没有这一项——那就是真的没有。 再看另外两个参数的分布,也能佐证解析是对的: 参数 | 取值分布 | QPACK_BLOCKED_STREAMS | 0 → 32站;100 → 10站;128 → 3站;64与512各1站 | MAX_FIELD_SECTION_SIZE | 131072 → 23站;65536 → 6站;32768 → 4站;1048576 → 1站;未宣告13站 | 注意第一行:宣告表容量为0的那32个站,BLOCKED_STREAMS 也全是0。这两个参数是配套的——表都不给,自然也不会允许流被压缩阻塞。32和32完全重合,这个内部一致性说明这批站是明确关掉了这套机制,不是漏配了某一项。 ## 按CDN归属看,答案更整齐 服务器与CDN | 站数 | 表容量取值 | Cloudflare | 24 | 全部为0 | Google Frontend | 4 | 65536三个,0一个 | nginx | 5 | 4096两个、65536两个、0一个 | Tengine(阿里系) | 2 | 16384、4096 | 腾讯EdgeOne | 1 | 4096 | Cloudflare那一行是24比0,一个例外都没有。这不是配置疏漏,是产品层面的统一决策。 而Google Frontend那边3个站给了65536——64KB的动态表,是nginx出厂值的16倍。同一件事,两家全球最大的边缘网络给出了完全相反的选择。 ## 顺手量到的另一件事:13个站没说自己能收多大的头 既然SETTINGS帧都解出来了,另一个参数也顺带看了:MAX_FIELD_SECTION_SIZE,服务端用它告诉客户端我最多接受多大的一份请求头。 取值 | 换算 | 站数 | 131072 | 128 KB | 23 | 65536 | 64 KB | 6 | 32768 | 32 KB | 4 | 1048576 | 1 MB | 1 | 没宣告 | 视为无限制 | 13 | 这一栏跟之前在HTTP/1.1与HTTP/2上量到的那道墙 (https://zhangwenbao.com/request-header-too-large-400-431-cookie-seo-blindspot.html)正好接上。那次现网普查测出来的阈值中位数在16KB附近,最低的一个站6912字节就开始拒绝。 而这里HTTP/3这一侧,多数站给的是128KB——比TCP那一侧宽了将近8倍。更微妙的是有13个站压根没宣告这个参数,按规范就是不设限。 把两组数放一起,会得到一个挺古怪的结论:同一个站,同一份超大的请求头,走HTTP/2可能被拒,走HTTP/3可能就过了。这也解释了那类最难缠的报障——同一个用户,有时候能打开,有时候打不开,而两次的区别只是浏览器那一刻选了哪条路。 ## 为什么HTTP/3的默认值是0,而HTTP/2的同一个默认值是4096? 这里得回到规范。RFC 9204第5节对这个参数的说明只有两句话,但这两句话把整件事说透了: > SETTINGS_QPACK_MAX_TABLE_CAPACITY (0x01): The default value is zero. See Section 3.2 for usage. This is the equivalent of the SETTINGS_HEADER_TABLE_SIZE from HTTP/2. 两个信息点:默认值是零,以及它等价于HTTP/2的SETTINGS_HEADER_TABLE_SIZE。 而HTTP/2那个参数的默认值,写在RFC 9113里,是 4096。 | HTTP/2(HPACK) | HTTP/3(QPACK) | 参数名 | SETTINGS_HEADER_TABLE_SIZE | SETTINGS_QPACK_MAX_TABLE_CAPACITY | 规范默认值 | 4096 | 0 | 什么都不配的后果 | 压缩正常工作 | 动态表完全不可用 | 规范自己说这两个参数等价,却给了两个相反的默认值。这一条是整篇文章里最值得记住的:同一个机制从HTTP/2迁到HTTP/3,默认状态从开着变成了关着。 ## 规范这么定不是拍脑袋 动态表在QPACK里比在HPACK里危险得多。HPACK跑在TCP上,数据严格按序到达,编码器往表里插一条、解码器就一定先收到那一条。QUIC是乱序的,装着表更新指令的那个包可能比引用它的那个请求晚到——解码器拿到一个引用,发现表里还没这一项,只能把整条流挂起来等。这就是队头阻塞,而HTTP/3存在的全部理由就是消灭它。 所以规范把默认值定成0,等于说:你要用这个提速功能,得自己明确开口要,因为它有可能把HTTP/3最大的优点抵消掉。 Cloudflare那24个站的选择,放在这个背景下就完全说得通了。作为一个要面对全世界各种糟糕网络的边缘网络,它选择了保延迟、放弃压缩率。这是个理性的决定——只是站长这一侧多半从来不知道自己在这笔交易里被替着签了字。 ## 动态表是0,到底差多少? 知道有这个开关是一回事,知道它值多少钱是另一回事。 保哥用QPACK的编码器实现直接量了一遍:构造一份典型的浏览器请求头(12个字段,含一份2000字节的Cookie),在同一条连接上连发20个请求,只改动态表容量这一个变量。 ## 先说这组数据怎么保证是真的 编码器算出来的字节数,如果没人验证它能被还原成原来的头,那就只是一串数字。所以每一轮编码都同时跑一个真实的QPACK解码器,把编码器流和解码器流双向喂回去,再把解码出来的头字段跟输入的逐条比对。 全部档位、全部请求,解码结果与输入不一致的次数是 0。这组数才敢往下用。 ## 然后是那条曲线 动态表容量 | 20个请求合计 | 均摊每请求 | 0 | 35720 B | 1786.0 B | 256 | 34570 B | 1728.5 B | 1024 | 30833 B | 1541.7 B | 2048 | 30774 B | 1538.7 B | 3072 | 31167 B | 1558.3 B | 4096 | 3829 B | 191.4 B | 8192 | 3829 B | 191.4 B | 65536 | 3829 B | 191.4 B | 从0到3072,均摊字节从1786慢慢降到1538再回升到1558——基本没动。从3072到4096,一步从1558掉到191.4。差8.1倍,而且此后再加表容量一个字节都不再省。 把两端接起来看:动态表0与4096之间,同样这20个请求,差 9.3倍。 这条曲线的形状很重要:它不是斜坡,是台阶。给了一半的表容量,拿到的不是一半的收益,是零收益。这一点在下面第六节会解释原因。 ## 中间那几档还藏着一个更别扭的现象 把表容量从1024加到2048再加到3072,均摊字节是1541.7 → 1538.7 → 1558.3。加到3072那一档,反而比2048那一档多花了20字节。 这不是测量误差——每一档都跑了20个请求、都做了解码校验、结果可重复。它反映的是一件很实际的事: - 表容量不够装下那条最大的Cookie时,Cookie本身进不了表 - 但那些小字段(accept-language、sec-fetch-* 这些)还是会进去 - 进表要发编码器流上的指令,指令本身也占字节 - 表越大,塞进去的小字段越多,指令开销越大,而真正吃字节的那条大Cookie一次都没省到 于是就出现了这个局面:给一点点表容量,比一点不给还差。付了管理成本,没拿到主要收益。 这一条对做技术选型的人有实际意义:如果哪天这个参数变得可配置了,不要想着折中给个中间值。要么给足到能装下最长那个字段,要么干脆给0——中间地带是最贵的。 ## 在真实nginx上量一遍,第二个请求就掉到18字节 编码器层面的数字再干净,也得回到真实服务器上验证。 保哥在服务器上起了一个独立的nginx实例(不碰生产站),开着HTTP/3,用Lua把服务端实际收到的东西回显出来:协议版本、Cookie的实际字节数、nginx记录的请求长度。然后在同一条h3连接上连发12个请求。 Cookie长度 | nginx记录的请求长度序列 | 2000字节 | 1706 → 22 → 18 → 18 → 18 → 18 → 18 → 18 → 18 → 18 → 19 → 19 | 529字节 | 586 → 18 → 18 → 18 → 18 → 18 → 18 → 18 → 18 → 18 → 19 → 19 | 第一个请求1706字节,第二个22字节,之后一路18。降了95倍。而这12个请求里,服务端回显的Cookie字节数全程是同一个数,一个字节都没少——内容一样,只是不用再重发一遍了。 ## 跟本地模拟对一对,还发现一个差异 把真实nginx的序列和本地编码器的模拟放在一起: 来源 | 第1个 | 第2个 | 第3个 | 之后 | 本地QPACK模拟(表4096) | 1786 | 1791 | 14 | 14 | 真实nginx(表4096) | 1706 | 22 | 18 | 18 | 本地HPACK模拟(表4096) | 1780 | 12 | 12 | 12 | 本地模拟里,QPACK的第二个请求还是全量的1791字节,第三个才降下来;真实nginx上第二个就降到了22。原因是编码器要不要冒险引用一条还没被对端确认的表项,是个策略问题——保守的实现会等确认,激进的实现敢赌。 这个差异本身是个方法论提醒:协议库的模拟能算出上界和下界,但具体落在哪儿要看服务端的实现策略。两条路都跑一遍,对不上的地方恰恰是最值得看的地方。 顺带注意HPACK那一行:它第二个请求就到12字节,比QPACK少一轮预热,稳态字节数也更小。这已经是第二个HTTP/3不如HTTP/2的地方了。 ## 同样是4096的表,为什么QPACK比HPACK早678字节就没了红利? 这是本文最反直觉的一条,也是保哥没料到的一条。 之前量HTTP/2的时候 (https://zhangwenbao.com/http2-hpack-cookie-request-header-bytes-seo.html)发现过一条界线:Cookie头长到3072字节附近,HPACK的动态表红利就归零了。原因是条目大小算的是字段名长度加字段值长度再加32字节,太大的条目塞不进4096的表。 那么QPACK呢?规范里条目大小的算法是一模一样的: > The size of an entry is the sum of its name's length in bytes, its value's length in bytes, and 32 additional bytes. 同样的算法、同样的4096,界线应该在同一个位置。保哥用二分法扫了一遍,结果是: 压缩机制 | 表容量 | 红利消失的Cookie长度 | HPACK(HTTP/2) | 4096 | 约 3072 字节 | QPACK(HTTP/3) | 4096 | 约 2396 字节 | 二分的精确落点是:Cookie长2394字节时还有4.75倍红利,长到2398字节就只剩1.38倍。4个字节之内翻脸。 也就是说,同一个数字的表、同一条条目大小公式,QPACK比HPACK早678字节就交卷了。 ## 678字节是什么概念 放到真实的Cookie尺寸上看这个差距。保哥在实验台上用真实Chrome访问了一次,浏览器带过来的本站Cookie是529字节——这是一个内容站的量级。而电商站是另一个世界:之前测过的某个化妆品站,在真实Chrome里是41条、4176字节 (https://zhangwenbao.com/http2-hpack-cookie-request-header-bytes-seo.html)。 站点类型 | 典型Cookie长度 | HPACK 4096 | QPACK 4096 | 内容站/博客 | 500上下 | 有红利 | 有红利 | 轻量商城 | 2000上下 | 有红利 | 有红利 | 装了3到5个营销工具的独立站 | 2400到3000 | 还有红利 | 已经没了 | 大型电商 | 4000以上 | 都没有 | 都没有 | 中间那一档正好卡在两条界线之间。这一档的站,从HTTP/2切到HTTP/3的那一刻,请求头压缩这笔账会变差,而没有任何一个监控指标会告诉他们。 ## 那条断崖为什么不是斜坡? 把上面两组现象放在一起看,其实是同一个机制的两个切面:表容量给一半没有一半的效果,Cookie长一点点就从有红利变成没红利。两处都是台阶不是斜坡。 原因在于动态表的工作方式是全有或全无的。一条Cookie只有两种命运: - 塞得进表:第一次发全文,之后每次发一个索引,几个字节 - 塞不进表:每一次都发全文,只有Huffman编码能省一点 没有中间状态。Cookie是一个不可分割的字段,它要么整条进表,要么整条不进。所以只要它的条目大小(值长度加名字长度再加32)越过了实现愿意分配的那条线,收益就直接归零,而不是打个折。 ## 为什么QPACK的那条线更靠前 规范里给了方向性的解释。RFC 9114第4.2.1节介绍QPACK时写的是: > [QPACK] describes a variation of HPACK that gives an encoder some control over how much head-of-line blocking can be caused by compression. This allows an encoder to balance compression efficiency with latency. 关键词是 balance compression efficiency with latency——在压缩效率和延迟之间做平衡。规范没有承诺QPACK的压缩效率不低于HPACK,它明说了这是一笔交易。 具体到实现层面,QPACK编码器要给未来的表更新留出余量:一条超大的条目一旦插进去,会把表里已有的其他条目全部挤出去,而那些条目正被其他并发的流引用着。所以编码器实现会比HPACK更保守地判断这条值不值得插。678字节这个差值,就是保守程度的实测体现。 本文只描述这个现象和它的位置,不去猜具体某个实现内部的阈值公式。对做站的人来说,能记住的判据是一句话:请求头里最长那个字段,加32之后如果超过表容量的六成,在HTTP/3上就别指望压缩了。 ## 拆分Cookie这一招,在HTTP/3上还灵吗? HTTP/2那边有一个很好使的办法:把一条超长的Cookie拆成多个 cookie 字段发出去,每条都变小了,就都能进表。RFC 9114在HTTP/3上也明确允许这么干: > To allow for better compression efficiency, the Cookie header field ([COOKIES]) MAY be split into separate field lines, each with one or more cookie-pairs, before compression. 保哥把这招在QPACK上试了一遍,10个请求一组,同样带解码校验: Cookie总长 | 不拆 | 拆2条 | 拆3条 | 拆4条 | 拆6条 | 1000 B | 2157 | 2174 | 2187 | 2202 | 2226 | 2000 B | 3689 | 3937 | 4302 | 4145 | 4309 | 3000 B | 23404 | 13592 | 11012 | 10176 | 11751 | 4000 B | 30714 | 18821 | 22109 | 18658 | 18527 | 这张表得竖着读,因为上下两半的结论是相反的。 ## 过线之前拆,是纯亏 1000字节和2000字节这两行,拆得越碎总字节越多。1000字节从不拆的2157一路涨到拆6条的2226;2000字节更明显,从3689涨到4309,多花了17%。 原因不难理解:原来一条能进表的东西,拆开之后变成好几条,每一条都要占一个索引位、都要在表里单独记一份,管理成本比省下来的多。 ## 过线之后拆,能救回一大截 3000字节这一行是全表最戏剧的:不拆是23404字节,拆成4条降到10176,省了2.3倍。4000字节那一行也一样,拆2条就从30714掉到18821。 逻辑很简单:整条塞不进表的东西,切小之后每一块都塞得进去了,红利就回来了。 ## 所以这一招的正确用法是有前提的 你的Cookie总长 | 该不该拆 | 理由 | 低于2400字节 | 不要拆 | 本来就有红利,拆了净亏 | 2400到3000 | 拆3到4条 | 把每条压回能进表的尺寸 | 超过3000 | 先减肥,再考虑拆 | 拆只是止损,减掉才是治本 | 要提醒一句:这个动作的实施位置不在服务器上,而在客户端或者边缘。服务器改不了浏览器怎么发Cookie。真正能落地的手段还是把Cookie本身减下来——干掉一条1500字节的,比删四十条小的都管用。 ## 服务器日志能不能拿来算这笔账? 不能。而且它给出的错误答案,方向是反的。 保哥在实验台上做了一次干净的对照:真实Chrome访问同一个地址两次,第一次走HTTP/2、第二次走HTTP/3,服务端把收到的东西全部回显出来。 指标 | 第1次(HTTP/2) | 第2次(HTTP/3) | 服务端收到的Cookie字节 | 529 | 529 | 头字段条数 | 14 | 13 | nginx记录的请求长度 | 884 | 30 | Cookie回显两次都是529,说明浏览器发的东西一样、服务器收的东西也一样。但nginx的请求长度变量,一个记884、一个记30,差29倍。 再看另一组,用可控的客户端逐档发不同长度的Cookie: 构造的Cookie长度 | HTTP/2记的请求长度 | HTTP/3记的请求长度 | 200 | 295 | 337 | 1000 | 1095 | 931 | 2000 | 2095 | 1697 | 4000 | 4095 | 3202 | 这一组的Cookie回显在全部6个档位上跟构造值分毫不差,所以数据本身是可信的。但两列数字的含义并不一样:HTTP/2那列基本贴着原始长度(curl每次新建连接,压缩没机会生效),HTTP/3那列已经是QPACK编码后的字节。 结论很直接:这个变量在两种协议下记的不是同一个东西,任何跨协议的横向对比都是错的。如果一个站从HTTP/2切到HTTP/3,运维看日志会以为上行流量降了一大截,实际上用户发的字节一点没变,只是账本换了记法。 这类问题保哥碰到过好几次了,形状都一样:换一个工具去查同一件事,答案就变了 (https://zhangwenbao.com/curl-head-get-response-header-diagnosis-seo.html)。判据永远是同一条——让被测那一方把它实际收到的东西回显出来,跟你发出去的对账,而不是相信中间任何一个环节的记账。 ## 这笔账该怎么处理? 先说一句让人放松的:对绝大多数内容站,这件事的实际影响接近于零。500字节量级的Cookie,在哪种压缩机制下都在红利区内,动态表是0也就是每个请求多几百字节,对一个正常页面来说不值一提。 真正要认真对待的是这三类站。 ## 第一类:Cookie已经越过2400字节的电商站 这一类是唯一会被HTTP/2与HTTP/3那678字节差值真实咬到的。判据很简单,在浏览器控制台里跑一行: document.cookie.length 这个数不含HttpOnly的那些,所以真实值还要更大。超过2000就该去数一数每一条各占多少了。减Cookie的正确顺序是先揪最大的那三五条,而不是删条数。 ## 第二类:自己管服务器、且真的开了HTTP/3的站 nginx出厂给的是4096,够用但不宽裕。如果你的站请求头本来就重,这个值目前没有暴露成可配置项——这也是选择HTTP/3时该知道的一个约束。nginx这类配置改完不报错的地方特别多 (https://zhangwenbao.com/nginx-config-silent-seo-side-effects-audit.html),凡是文档里没写成指令的参数,都不要假设自己能调。 ## 第三类:在CDN后面、以为切了协议就自动更快的站 这一类人数最多。Cloudflare那24个站的表容量全是0,意味着在它后面的站,HTTP/3的头部压缩红利这一项从来就没打开过,而这个决定不在站长手里,控制台上也找不到对应的开关。 这不是说Cloudflare做错了——用压缩率换掉队头阻塞风险,对一个要服务全球各种网络的边缘网络是合理的取舍。但站长该知道自己拿到的是哪一半:你拿到的是HTTP/3的延迟特性,没拿到它的压缩特性。 ## 一张对照表收尾 你想要的 | HTTP/3给不给 | 取决于 | 丢包时不拖累其他请求 | 给 | 协议本身,无需配置 | 切换网络时连接不断 | 给 | 协议本身,无需配置 | 请求头只发一次 | 看服务端脸色 | 对端宣告的动态表容量 | 爬虫抓的那份HTML也走h3 | 基本不给 | 发现机制的限制 (https://zhangwenbao.com/http3-alt-svc-discovery-first-request-crawler-seo.html) | 两篇合起来的账是这样的:HTTP/3在传输层给的那些好处是真的、而且免费;但它在应用层许诺的那笔压缩红利,68%的站上默认关着,剩下的还要看请求头有没有超过2400字节。而这两样东西,爬虫抓的那一个请求一样都碰不到。 ## 常见问题解答 ## 我怎么知道我的站宣告的动态表容量是多少? 需要一个能连HTTP/3并读出SETTINGS帧的客户端。系统自带的curl多半没编译HTTP/3支持(用 curl --version 看Features那行有没有HTTP3字样)。Python生态里有纯软件实现的QUIC库可以做这件事,几十行代码就能把SETTINGS打出来。浏览器开发者工具看不到这个参数,它只显示协议名。 ## 动态表容量能在nginx里配置吗? 目前不能。nginx的HTTP/3模块把这个值写死在源码里,出厂是4096,配置文件里没有对应的指令。所以自建nginx的站在这一项上没有调节余地,好在4096对多数场景够用。 ## Cloudflare把它设成0,我的站是不是变慢了? 请求头这一侧确实少了一笔可观的节省,但整体是快是慢要看你的请求头有多重。Cookie在500字节以内的站,这笔差价小到测不出来;Cookie到了两三千字节的站,每个请求要多发一两千字节的上行——而上行带宽在移动网络上通常只有下行的几分之一。判据是先量自己的Cookie有多大,而不是先判断CDN的选择对不对。 ## 那我干脆不用HTTP/3,回到HTTP/2是不是更好? 不必这么极端。HTTP/3在丢包环境下的表现、连接迁移这些好处是实打实的,而且不需要任何配置。压缩这一项只是它没兑现的那部分。正确的做法是把请求头本身减下来——Cookie小了,两种协议下都省,而且对第一个请求也有效。 ## 为什么第一个请求那么大,之后就变成十几个字节? 因为动态表要预热。第一个请求必须把每个字段的完整内容发过去,同时告诉对端把它们记进表;从第二个请求开始,只发一个索引号就够了。这也意味着只发一个请求就断开的客户端,永远只付得起第一次那个价钱,压缩机制对它毫无意义。 ## 把Cookie拆成多个字段,会不会影响服务端读取? 不会。RFC 9114规定解码之后必须把多个cookie字段用 ; 拼回一条,再交给应用。所以后端代码读到的还是完整的一条,跟没拆一样。但这个动作只能在客户端或边缘做,服务器改不了浏览器发头的方式。 ## 这件事对SEO到底有多大影响? 直接影响很小,因为爬虫多半连不到HTTP/3上。间接影响落在真实用户的体验指标上——上行字节少了,弱网环境下的请求发出得更快。把它当成一项用户体验优化来做,别指望它改善抓取。真要改善抓取,力气应该花在响应时间和缓存命中上。 ## 权威参考资料 ## 同一个页面11种URL写法都返回200,做SEO查重复内容时一条都不会报 - URL:https://zhangwenbao.com/url-path-normalization-case-slash-duplicate-seo.html - 分类:Nginx - 发布:2026-07-30 | 更新:2026-07-30 - 摘要:实测nginx路径归一化:11种URL写法全返200,105站普查平均每页2.66个等价地址,尾斜杠占57.1%,附大小写判断与301收敛配置。 - 关键词:技术SEO,重复内容,URL规范化,Nginx > **TLDR**:摘要:在一台干净的nginx上放一个165字节的文件,然后换着花样去请求它——加一道斜杠、加一个点、把斜杠写成 %2F、在中间塞一段 zz/..。十一种写法全部返回200,返回的字节数一个不差,而nginx内部记录的路径永远是同一个。这不是配置失误,是HTTP服务器按规范必须做的归一化,它发生在你写的每一条location生效之前。真正被挡住的只有大小写。往外看105个真实站点,一个页面平均带着2.66个字节级完全相同的200地址,六成以上的站至少多出一个。好消息是canonical在这批变体上一次都没掉链子;坏消息是有17.1% 的页面压根没有canonical。 > 摘要:在一台干净的nginx上放一个165字节的文件,然后换着花样去请求它——加一道斜杠、加一个点、把斜杠写成 %2F、在中间塞一段 zz/..。十一种写法全部返回200,返回的字节数一个不差,而nginx内部记录的路径永远是同一个。这不是配置失误,是HTTP服务器按规范必须做的归一化,它发生在你写的每一条location生效之前。真正被挡住的只有大小写。往外看105个真实站点,一个页面平均带着2.66个字节级完全相同的200地址,六成以上的站至少多出一个。好消息是canonical在这批变体上一次都没掉链子;坏消息是有17.1% 的页面压根没有canonical。 先说清楚这篇文章要数的是什么:不是“URL该怎么起名”,也不是“重复内容会不会被罚”,而是一个更基础、却几乎没人真去数过的问题——同一份内容,到底有多少种地址写法能让服务器痛痛快快回一个200? 这个数字很重要,因为搜索引擎眼里的“页面”就是URL。你觉得自己发布了一个页面,服务器可能同时承认了十一个。 ## 一个页面到底有几个地址?先在实验台上数一遍 要数清楚,得排除干扰。生产站上跑着CDN、WAF、伪静态、缓存,任何一层都可能替你把地址改掉,结论就不干净了。所以我在服务器上另起了一个独立的nginx实例,不碰生产:自己的配置文件、自己的日志目录、端口18545,root指向一个只有几个文件的目录。 里面放一个真实存在的文件 /foo/bar.html,165字节。然后我给每个server加了一行: add_header X-Nginx-Uri "$uri" always; add_header X-Req-Uri "$request_uri" always; $request_uri 是客户端原样发过来的请求行,$uri 是nginx在内部实际用来找文件、匹配location的那个路径。把两个都打出来,就能看见nginx到底在哪一步把地址改了。 接下来用curl换着花样请求同一个文件。这里有个细节必须交代:curl默认会替你把 ./ 和 ../ 先化简掉,那样测的就是curl而不是nginx了。加上 --path-as-is 才能把原始字符串原样送出去。 ## 十一种写法,返回的字节数一个不差 请求行原样 | 状态码 | 返回字节 | nginx内部 $uri | /foo/bar.html | 200 | 165 | /foo/bar.html | //foo/bar.html | 200 | 165 | /foo/bar.html | /foo//bar.html | 200 | 165 | /foo/bar.html | ///foo///bar.html | 200 | 165 | /foo/bar.html | /foo/./bar.html | 200 | 165 | /foo/bar.html | /./foo/bar.html | 200 | 165 | /foo/bar.html | /foo/xx/../bar.html | 200 | 165 | /foo/bar.html | /foo/../foo/bar.html | 200 | 165 | /foo/bar.html | /foo%2Fbar.html | 200 | 165 | /foo/bar.html | /%66oo/bar.html | 200 | 165 | /foo/bar.html | /foo/bar%2Ehtml | 200 | 165 | /foo/bar.html | 十一行,状态码全是200,字节数全是165,$uri 那一列从头到尾没变过。 最后一列是关键。nginx不是“分别处理了十一个地址并且碰巧都返回了同样的内容”,而是它压根就没看见这十一个地址——它只看见一个。那十一种写法的差别,在请求进入你的配置之前就已经被抹平了。 顺带一提,第九行的 %2F 是斜杠的百分号编码。有人专门用它来表示“这是一个字面上的斜杠,不是路径分隔符”,但在这里它被解码成了真斜杠,然后当成路径分隔符用掉了。关于百分号编码在URL各个位置的不同含义,可以看这篇URI编解码器怎么用?中文URL编码、UTM参数转码与GSC乱码网址还原 (https://zhangwenbao.com/uri-codec-percent-encoding-encode-decode-guide.html)。 ## 被挡住的是哪些 同一台机器上,下面这些写法拿到的是404: 请求行原样 | 状态码 | nginx内部 $uri | 为什么 | /FOO/BAR.HTML | 404 | /FOO/BAR.HTML | 路径大小写没有被归一化 | /Foo/bar.html | 404 | /Foo/bar.html | 同上,一个字母也不行 | /foo/bar.html. | 404 | /foo/bar.html. | 尾部的点不会被吃掉 | /foo/bar.html;a=1 | 404 | /foo/bar.html;a=1 | 分号参数是路径的一部分 | /foo/bar.html%20 | 404 | /foo/bar.html␣ | 解码出一个真空格,文件名不匹配 | /foo/bar.html/ | 404 | /foo/bar.html/ | 文件不是目录 | 还有一个例外值得单独记一笔:/foo/bar.html?x=1 返回200。查询串不参与路径匹配,所以你可以在任何一个页面后面挂上任意查询串,服务器照样给你200,内容一字不差。这条对搜索引擎的意义完全不同于路径变体——它是分面导航和追踪参数把URL数量炸开的根源,展开讲是另一篇的量,分面导航的SEO怎么治理?筛选过滤产生的海量URL别拖垮抓取预算 (https://zhangwenbao.com/faceted-navigation-seo-crawl-budget-index-control.html)里拆得比较细。 ## nginx为什么把这么多写法都认成同一个页面? 这不是nginx的自作主张,官方文档在location那一节写得很直白: > The matching is performed against a normalized URI, after decoding the text encoded in the "%XX" form, resolving references to relative path components "." and "..", and possible compression of two or more adjacent slashes into a single slash. 一句话三个动作,顺序还是固定的: - 先解码:把 %XX 形式的字符还原成本来的样子。%66 变成 f,%2F 变成 /,%2E 变成 .。 - 再解析点段:把 . 和 .. 这两种相对路径引用算掉。 - 最后压缩斜杠:连续两个以上的斜杠合并成一个。 为什么是这个顺序,实验台上能看出来。/foo/bar%2Ehtml 里的 %2E 先被还原成点,然后才轮到点段解析——但这个点是文件名里的点,不构成 . 或 .. 这样的完整路径段,所以留了下来。而 /foo%2Fbar.html 里的 %2F 被还原成斜杠之后,它就是一个货真价实的路径分隔符了,再想让它变回“字面上的斜杠”已经没有机会。 ## 这三步都不是可选项 点段那一步的出处在RFC 3986。规范用了一整节讲 remove_dot_segments 算法,并且在讲URI归一化的时候明确要求: > URI normalizers should remove dot-segments by applying the remove_dot_segments algorithm to the path. 换句话说,任何一个正经实现都得这么做。这一条不是“nginx的脾气”,是所有人的共同底线。 这句话不用只当规范来信,后面那轮普查里正好有现成的对照:Apache官方自己的站 httpd.apache.org(响应头 Server: Apache),在点段、父段、开头双斜杠、路径内双斜杠这四种写法上全部原地返回200,且字节与规范地址完全相同。换个服务器实现,答案一模一样。 ## 归一化跑在你的配置之前 这才是最要命的一点,也是我认为最值得单独拎出来的一句: > 你在nginx里写的每一条location、每一个if、每一段rewrite,看到的都已经是归一化之后的路径。原始写法在到达它们之前就没了。 所以那种“我加一条规则把带双斜杠的请求301到规范地址”的想法,默认配置下根本没法实现——等你的规则拿到手时,双斜杠已经不在了。这跟 Nginx配置每次reload都通过,Googlebot每8次抓取却有1次撞在301上 (https://zhangwenbao.com/nginx-config-silent-seo-side-effects-audit.html)里讲的那类“配置通过了但行为不是你想的那样”是同一类问题:nginx -t 只检查语法,不检查你对执行顺序的理解。 想拿到原始写法,只有 $request_uri 这一个口子。这也是为什么本文的实验台要把两个变量都打出来——只看 $uri,你会以为世界很干净。 ## 大小写是唯一一条真分界线吗? 实验台上,大小写是唯一一个把请求挡在门外的因素。这背后也有规范依据,RFC 3986讲得很细: > the scheme and host are case-insensitive and therefore should be normalized to lowercase... The other generic syntax components are assumed to be case-sensitive unless specifically defined otherwise by the scheme. 协议名和主机名不区分大小写,路径、查询串、片段这些“其他部分”默认区分。所以 https://EXAMPLE.com/Page 和 https://example.com/Page 是同一个地址,而 /Page 和 /page 不是。 谷歌的表态和规范完全一致,而且直接点了名: > Like any other HTTP client following IETF STD 66, Google Search's URL handling is case sensitive (for example, Google treats both /APPLE and /apple as distinct URLs with their own content). 这句话里的IETF STD 66就是RFC 3986。谷歌明说自己按这份规范处理URL,把 /APPLE 和 /apple 当两个各有各内容的地址。 ## 可现实里三成多的站不是这样 规范归规范,服务器给不给你面子是另一回事。我拿105个真实站点的深层页面做了大小写探测,把路径整个转成大写再请求一次,结果分成三档: 路径全大写之后 | 站数 | 占比 | 意味着什么 | 404 | 67 | 63.8% | 大小写敏感,行为符合规范 | 3xx跳回原地址后200 | 23 | 21.9% | 做了归一化,主动收敛,最省心 | 原地直接200 | 14 | 13.3% | 两个大小写各是一个可索引地址 | 其他(4xx非404) | 1 | 1.0% | 被防护层拦下 | 最后那13.3% 是要出事的那一档。服务器认了大写地址、原地返回200,而谷歌按规范把它当成另一个页面。一个页面于是有了两个可索引地址,内容一模一样。 这一档通常来自四个地方:跑在Windows/IIS上(NTFS默认不区分大小写)、跑在macOS开发机同款文件系统上、前面挂了会做小写归一化的CDN规则、或者应用层用了不区分大小写的路由匹配(下一节会专门拆这个)。 谷歌给的建议也很实在: > If upper and lower case text in a URL is treated the same by your web server, convert all text to the same case so it's easier for Google to determine that URLs reference the same page. 注意它的前提——“如果你的服务器把大小写当成同一回事”。它没让所有人都去做小写归一化,只让那13.3% 去做。剩下63.8% 本来就是404,做了反而多此一举。 ## 拿本站当对照 顺手拿保哥自己这个站测了一遍同一批写法,结果和实验台的出厂默认差得挺远: 请求 | 状态码 | Location | /glossary/ | 200 | — | /glossary | 301 | /glossary/ | /GLOSSARY/ | 301 | /glossary/ | //glossary/ | 301 | /glossary/ | /glossary// | 404 | — | /./glossary/ | 404 | — | /x/../glossary/ | 404 | — | 同样是nginx,出厂默认能进十一个门,这边只剩两个:规范地址本身,加上三种会被301送回规范地址的写法。差别全在配置上。 ## 关掉merge_slashes是在收紧还是在放松? 知道斜杠会被合并之后,很多人的第一反应是把它关掉——既然多余斜杠是“脏”的,那就别让它进来。 我在实验台上开了第二个server,唯一的差别是加了一行 merge_slashes off;。测下来是这样: 请求行 | 默认(on)状态码 | 默认 $uri | off状态码 | off的 $uri | //foo/bar.html | 200 | /foo/bar.html | 200 | //foo/bar.html | /foo//bar.html | 200 | /foo/bar.html | 200 | /foo//bar.html | /foo/./bar.html | 200 | /foo/bar.html | 200 | /foo/bar.html | /foo/xx/../bar.html | 200 | /foo/bar.html | 200 | /foo/bar.html | 两个关键结论: 第一,关掉之后照样返回200。因为最后去磁盘上取文件的是操作系统,而Linux把路径里的连续斜杠视同一个。nginx不再帮你合并,内核帮你合并。你以为堵住了门,其实只是把门牌换了个写法。 第二,点段照样被算掉。merge_slashes 只管斜杠,管不了 . 和 ..——那两个是RFC 3986要求的动作,不归这个开关管。 ## 真正变化的是别的东西 关掉之后唯一实质性的变化,是 $uri 里保留了原始的多余斜杠。这件事的后果,nginx文档自己讲了: > Note that compression is essential for the correct matching of prefix string and regular expression locations. Without it, the "//scripts/one.php" request would not match location /scripts/ { ... } and might be processed as a static file. 翻译成人话:你所有按前缀写的location都会被绕过。那些挂在 location /admin/ 上的访问控制、挂在 location /api/ 上的限流、挂在某个前缀上的缓存策略,请求方只要多打一道斜杠就全部躲开了,而文件本身还是能取到。 文档随后给了一句相当罕见的措辞: > However, for security considerations, it is better to avoid turning the compression off. 官方文档很少直接说“你最好别这么干”。这条值得当成硬规则记下来:merge_slashes off 不是在收紧URL,它在放松你的location匹配。唯一的合理用例文档也写了——路径里嵌了base64,而base64的字母表里恰好有斜杠。 顺手说一句,“改一个开关,结果和直觉相反”这种事在nginx里不算稀奇。Nginx反向代理实战指南 (https://zhangwenbao.com/nginx-proxy.html)里 proxy_pass 末尾那道斜杠加不加,能把整个后端路径改掉,也是同一类。 ## 现网105个站,一个页面平均有几个能返回200的地址? 实验台说明了机制,但机制不等于现实。真实站点前面挂着CDN、边缘规则、框架路由,最终行为得实测。 ## 怎么测的 先抓181个域名的首页,从HTML里挑出一个同域的深层链接——要求至少两段路径、不带查询串、不是静态资源,这样才有路径可以变形。能拿到深层地址的有112个站,其中原样请求就能返回200的105个,这105个是分母。 然后对每个深层地址做八种变形,加原样一共九次请求,总计1008次。判据卡得比较严,一个变体要算数,必须同时满足三条: - 返回 200; - 没有被重定向走(最终地址还是它自己,被301送回规范地址的不算); - 响应体的 SHA-1与原样请求完全一致(字节级相同,不是“看起来差不多”)。 第三条最关键。很多站会对变体返回一个“长得像但不是同一个”的页面,那种情况另有说法,不该混进来。 ## 结果 额外写法数 | 站数 | 占比 | 0种(只有规范地址能进) | 40 | 38.1% | 1种 | 15 | 14.3% | 2种 | 23 | 21.9% | 3种 | 13 | 12.4% | 4种 | 2 | 1.9% | 5种 | 6 | 5.7% | 6种 | 6 | 5.7% | 平均每站额外1.66种写法,也就是一个页面平均带着2.66个字节级完全相同的200地址。61.9% 的站至少多出一个。 按变体类型拆开看,哪种写法最容易蒙混过关: 变体 | 原地200且字节相同 | 占105站 | 尾斜杠切换(加上或去掉末尾的 /) | 60 | 57.1% | 加一个无意义查询串 | 48 | 45.7% | 开头双斜杠 //a/b | 23 | 21.9% | 点段 /a/./b | 17 | 16.2% | 路径内双斜杠 /a//b | 12 | 11.4% | 父段 /a/zz/../b | 12 | 11.4% | 路径全大写 | 2 | 1.9% | 追加一段不存在的路径 | 0 | 0.0% | 尾斜杠是头号来源,一多半的站两种写法都收。这个数字后面还会再出现一次——它在另一个维度上的杀伤力比在这里大得多。 ## 最松的和最严的 额外写法数拉满(6种)的六个站:shopify.com、stripe.com、klaviyo.com、backlinko.com、nodejs.org、iana.org。 这份名单挺有意思。前三个是SaaS大厂,第四个是SEO圈子里人人都读过的博客,最后一个是管着全球IP地址和端口号分配的机构。连IANA都没把自己的URL收干净,这事说明它确实不是“谁不专业”的问题,而是默认行为就这样。 另一头,零额外写法的站包括 walmart.com、target.com、nike.com、hubspot.com、salesforce.com、square.com、wix.com。大型电商和企业级SaaS明显更紧——它们的URL通常由一层专门的边缘路由统一发牌,不是让web服务器直接对着文件系统开门。 按Server头分组也能看出这个规律,只是样本量小,只能当参考。测量来自单一出口,跨境访问这批站点,个别站的边缘节点行为可能和当地不同,绝对值别当成普适结论——这一点在限速规则拒掉的第4个请求是robots.txt,全站SEO抓取会停12小时 (https://zhangwenbao.com/nginx-rate-limit-robots-txt-crawl-rate-seo.html)那次两地对照里吃过教训,同一批站从不同网络位置测,状态码有四分之一对不上。 ## canonical到底收不收得住这些变体? 数完了地址,下一个问题自然是:这些多出来的地址,有没有人管? 标准答案是canonical。我把它当成一个可证伪的假设来测:对每一个“原地200且字节相同”的变体,把它页面上的canonical取出来,解析成绝对地址,看它指回规范页,还是跟着变体一起变了。 结果和我下场之前的预期完全相反: 项目 | 数量 | 占比 | 原样页面带canonical的站 | 87 / 105 | 82.9% | 变体上canonical指回规范页(收敛成功) | 130 | 100.0% | 变体上canonical跟着变(收敛失败) | 0 | 0.0% | 变体页干脆没有canonical | 0 | 0.0% | 130次判决,一次都没掉链子。我原本准备写的是“canonical在现网基本是坏的”,实测把这个预设按在地上摩擦了——凡是有canonical的站,它在这些路径变体上都老老实实指回了同一个规范地址。 ## 为什么它这么稳 回头看第一节那张表就明白了:因为canonical通常是模板写死的绝对地址,或者由框架按路由名生成,而不是拿“当前请求的URL”拼出来的。路径变体在到达应用之前就已经被归一化成同一个 $uri,应用根本不知道自己是被 //a/b 还是 /a/./b 请求的,自然也就写不出跟着变的canonical。 这是个挺漂亮的意外收获:把请求写法抹平的那套归一化机制,同时也顺手保证了canonical不会被写歪。同一件事在这里既是病因又是解药。 ## 那还有什么可担心的 两件事。 一是那17.1%。105个站里有18个原样页面就没有canonical。它们的路径变体没有任何收敛机制,全靠搜索引擎自己去判重。谷歌确实会自己选一个规范地址,那套决策逻辑在 Google选择Canonical URL的9大决策逻辑与排查实操指南 (https://zhangwenbao.com/google-canonical-url-selection-logic.html)里拆过,但“它会自己选”和“它会选你想要的那个”是两码事。 二是收敛成功不等于零成本。canonical是在页面被抓取之后才起作用的——爬虫得先把这个变体地址请求一遍、下载完整个页面、解析出canonical,才知道“哦这个不算数”。省下的是索引里的位置,花掉的是抓取。这笔账怎么算,做SEO算抓取预算,发现服务器把没改过的页面对爬虫重发了几百遍 (https://zhangwenbao.com/conditional-request-304-etag-crawl-budget-seo.html)那篇讲得比较透。 所以canonical该有还得有,它是安全网;但它不是免死金牌,真要省抓取,还得靠301把变体挡在下载之前。 ## 哪些写法是你自己配出来的? 前面数的都是“出厂默认送你的”。还有一类地址,是配置里那些看着人畜无害的行明确造出来的。这类通常比默认的严重得多,因为它们不受“路径必须真实存在”这个约束。 ## 那条大小写不敏感的location 把某个路径写成不区分大小写,是个流传很广的写法,通常长这样: location ~* ^/foo/bar { try_files /foo/bar.html =404; } 看起来只是“让 /foo/bar 和 /Foo/Bar 都能访问”。实验台上打一遍: 请求 | 状态码 | $uri | /foo/bar | 200 | /foo/bar.html | /FOO/BAR | 200 | /foo/bar.html | /Foo/Bar | 200 | /foo/bar.html | /fOo/bAr | 200 | /foo/bar.html | /foo/barXXXX | 200 | /foo/bar.html | 前四行是预期内的:六个字母,每个都能大小写互换,2的6次方等于64种写法全部返回200。 最后一行才是真问题。~* ^/foo/bar 只锚定了开头,没锚定结尾,所以任何以它开头的路径都命中——/foo/barXXXX、/foo/barbarbar、/foo/bar-anything-you-want,全部200,全部返回同一份内容。 64种大小写组合乘以无限种后缀,这个页面的地址数量正式变成了无穷大。而这一切的代价,是配置里少写了一个 $。 ## 兜底路由 另一个常见来源是 try_files 的最后一档。第三个server用的是这样一条: location / { try_files $uri $uri/ /app.html; } 意思是“文件找不到就交给应用处理”,几乎所有前后端分离的站、所有单页应用、大部分CMS都是这个结构。实测: 请求 | 状态码 | 返回字节 | /this-page-does-not-exist | 200 | 115 | /a/b/c/d/e/f/g/h | 200 | 115 | /docs/guide/anything | 200 | 115 | /docs/guide/anything/deeper/still | 200 | 115 | 任意深度、任意内容的路径,全部200,全部同一份字节。这条配置本身就是一台无限URL生成器,前提是有人给它喂地址——而“有人给它喂地址”这件事,恰恰是下一篇要讲的。 好消息是现网这么干的不多。我在105个站上做了同样的探测(在真实路径后面追加一段随机的不存在路径),只有12个站还返回200(11.4%),其中字节级完全相同的只有4个(3.8%):walmart.com、engadget.com、php.net、mariadb.com。剩下8个返回的是内容不同的页面,多半是自定义的错误页或者软404。 剩下91个站规规矩矩返回404。这说明大部分框架的兜底路由后面还接了一层“这个路由到底存不存在”的判断,没有直接把200发出去。HTTP状态码怎么影响SEO?301、302、404和410该怎么选 (https://zhangwenbao.com/http-status-codes-seo-atlas-redirect-410-decision.html)里讲过为什么404在这个位置是对的答案。 ## 还有一个挡住了 顺手测了一条路径遍历:/wp-admin/../../etc/passwd。nginx返回400,连 $uri 都没有输出。 这说明归一化不是无脑做的:.. 只要试图跳出根目录,请求就被直接拒了。停留在根目录之内的冗余段照单全收,跨出边界的立刻叫停。这个分界线画得很清楚,也解释了为什么 /foo/xx/../bar.html 能进而它不能。 ## 该怎么把一个页面的地址收敛回一个? 前面数出来的东西,落到手上就是一张决策表。按“能不能挡在下载之前”排序,越靠前收益越大。 写法 | 现网命中率 | 建议动作 | 说明 | 尾斜杠两种都收 | 57.1% | 301到一种 | 收益最大的一条,且下一篇会说明它的另一重代价 | 任意查询串都收 | 45.7% | canonical兜底 | 不能一刀切301,正常参数要留 | 路径大小写原地200 | 13.3% | 301到小写 | 只有这13.3% 需要做,其余本来就404 | 多余斜杠 / 点段 | 11%-22% | canonical足够 | 默认配置下拿不到原始写法,301做不了 | location ~* 前缀正则 | 配置决定 | 加 $ 锚定结尾 | 不加等于开放无限后缀 | 兜底路由吃掉一切 | 3.8% | 路由不存在就返回404 | 在应用层判断,不在nginx层 | ## 能直接抄的收敛配置 尾斜杠和大小写这两条是301能解决的,写法不复杂: # 统一去掉末尾斜杠(目录除外),二选一,别两条都写 location ~ ^(.+)/$ { return 301 $1; } # 路径含大写字母就跳到小写版本 # 需要 nginx 带 perl 模块或在应用层做,纯 nginx 可用 map 列举常见入口 map $request_uri $has_upper { "~[A-Z]" 1; default 0; } 大小写归一化在纯nginx里比较别扭,因为它没有内置的小写函数。实际项目里更常见的做法是在应用层或者CDN边缘规则里做。.htaccess重定向生成器实测:导入一份旧配置会丢掉多少规则 (https://zhangwenbao.com/htaccess-redirect-rewriterule-301-302-parse-guide.html)里对照过Apache侧的等价写法。 两条铁律: - 只留一条规范形态,然后所有变体单跳到它。最忌讳的是A跳B、B又跳C,重定向链既费抓取又容易跳出环。 - 301上线前先确认站内链接已经用的是规范形态,否则每一次内部跳转都在给自己制造跳转。 ## 三条命令自查 不用写脚本,手动敲三次就能知道自己站在哪一档: # 1. 尾斜杠:两个都 200 就是有问题 curl -s -o /dev/null -w '%{http_code}\n' https://你的域名/某个页面 curl -s -o /dev/null -w '%{http_code}\n' https://你的域名/某个页面/ # 2. 大小写:200 就要治,404 或 301 都合格 curl -s -o /dev/null -w '%{http_code}\n' https://你的域名/某个页面 | tr 'a-z' 'A-Z' # 3. 兜底路由:必须 404 curl -s -o /dev/null -w '%{http_code}\n' https://你的域名/某个页面/随便一段不存在的 注意第一条和第三条要带 -o /dev/null 之外再看一眼字节数,因为“返回200”和“返回同一份200”是两件事。要严格一点就把 %{size_download} 也打出来,两次一样才算命中。 ## 什么时候不值得治 说句实在话:如果你的canonical是模板写死的绝对地址,而且站内链接全部用规范形态,那多余斜杠和点段这两类基本可以不管。 理由是它们需要有人主动把那种畸形地址写出来才会被抓到。真实世界里,这种地址主要来自三个源头:手写外链时打错、某些聚合工具拼接出错、以及爬虫自己在解析相对链接时算出来的。前两个是小概率,第三个才是大头——而那正好是这个话题的另外一半,下一篇会拿真浏览器和两套URL标准对着测一遍。 保哥的经验是:先把尾斜杠和大小写这两条收干净,再确认每个页面都有canonical,剩下的按投入产出排到后面去。URL治理最容易犯的错是花三天写一套完美的重定向规则,去解决一个一年被抓两次的地址;而同一时间里,那个真正在漏抓取的分面参数还在那儿转。 ## 常见问题解答 ## 同一个页面有多个能返回200的地址,会被谷歌当成重复内容惩罚吗? 不会有惩罚这回事。谷歌的处理方式是从这批地址里挑一个当规范地址,其余的合并过去。真正的代价在抓取——每一个变体地址都要被完整下载一遍才能被判定为重复,这些请求全部计入你的抓取预算。所以问题不是“会不会被罚”,是“值不值得让爬虫为同一份内容下载好几遍”。 ## 我加了canonical,是不是就不用管URL变体了? 基本可以,但不是全部。实测130次判决里canonical全部正确指回了规范地址,它比很多人以为的可靠。剩下的两个缺口是:一,你得先确认这个页面真的有canonical,现网有17.1% 的页面没有;二,canonical只能在页面被下载并解析之后生效,省的是索引不是抓取。命中率最高的尾斜杠变体值得用301挡在下载之前。 ## 路径里的大小写到底要不要统一成小写? 看你的服务器怎么回答大写地址。如果返回404(现网63.8% 是这样),说明大小写本来就是敏感的,不存在两个地址同时可访问的问题,不用做。如果原地返回200(13.3%),那必须做,因为谷歌明确把 /APPLE 和 /apple 当两个各有内容的地址。测一条curl就能分辨自己在哪一档。 ## 把merge_slashes关掉能不能挡住多余斜杠的地址? 不能,而且会帮倒忙。实测关掉之后 //foo/bar.html 照样返回200,因为最终去磁盘取文件的是操作系统,Linux本来就把连续斜杠当一个。唯一的实质变化是 $uri 保留了多余斜杠,导致所有按前缀写的location被绕过——访问控制、限流、缓存策略全部失效。nginx官方文档直接建议出于安全考虑不要关它。 ## 为什么我在nginx里写规则去跳转带双斜杠的地址,规则从来不生效? 因为归一化跑在你的配置之前。nginx在做location匹配之前就已经完成了百分号解码、点段解析和斜杠压缩,等你的rewrite或if拿到路径时,双斜杠已经不存在了。要拿原始写法只能用 $request_uri 这个变量,它保留了客户端发过来的原样请求行。 ## 兜底路由(try_files最后指向应用)是不是必须改掉? 不用改nginx那一层,前后端分离和单页应用都得这么配。要改的是应用层:路由匹配不上时返回404,而不是把首页或者某个默认页当200发出去。现网105个站里只有3.8% 在这一点上失守,说明主流框架默认是对的,属于要自查但通常没问题的一项。 ## 这些多出来的地址会不会稀释页面权重? 不会按“稀释”那个模型来。只要它们被合并到同一个规范地址,外链信号也会跟着合并过去。真正会损失的是两种情况:一是页面没有canonical且谷歌选错了规范地址,二是你的站内链接本身就在用不同写法互相指,导致内部权重流向分散在几个地址上。第二种比第一种常见得多,也更容易自查。 ## 权威参考资料 ## nginx出厂只压HTML这一种类型,其余的字节全额计入了SEO抓取预算 - URL:https://zhangwenbao.com/content-encoding-negotiation-gzip-brotli-crawl-budget-seo.html - 分类:Nginx - 发布:2026-07-30 | 更新:2026-07-30 - 摘要:nginx压缩默认只压text/html、级别1、不发Vary。实测17种配置与16种Accept-Encoding写法:补全类型清单省58个百分点,Brotli最高级每请求多花约1秒。 - 关键词:技术SEO,抓取预算,Nginx,网站性能 > **TLDR**:摘要:压缩大概是SEO技术清单上最早被划掉的一项——检测工具说“已启用gzip”,这件事就算完了。但nginx的出厂默认值是gzip_types text/html,也就是只压HTML这一种类型;gzip_comp_level默认1,是最低档;gzip_vary默认关闭。本文在真实nginx上把17种压缩配置乘16种Accept-Encoding写法乘6类文件跑了一遍,又量了85个线上站点216个URL的真实过线字节:一套典型资源不压是605 KB,出厂默认只降到83.4%,把类型清单补全直接降到25.4%——补类型的收益是调级别的25倍。另外测出Brotli最高级别每个请求要多花将近1秒CPU,只换来10%的体积。 > 摘要:压缩大概是SEO技术清单上最早被划掉的一项——检测工具说“已启用gzip”,这件事就算完了。但nginx的出厂默认值是gzip_types text/html,也就是只压HTML这一种类型;gzip_comp_level默认1,是最低档;gzip_vary默认关闭。本文在真实nginx上把17种压缩配置乘16种Accept-Encoding写法乘6类文件跑了一遍,又量了85个线上站点216个URL的真实过线字节:一套典型资源不压是605 KB,出厂默认只降到83.4%,把类型清单补全直接降到25.4%——补类型的收益是调级别的25倍。另外测出Brotli最高级别每个请求要多花将近1秒CPU,只换来10%的体积。 先做一道很短的算术。 一个电商站的商品页,HTML 190 KB,主样式表84 KB,主脚本210 KB,站点地图2.4 MB。爬虫来一次,把前三个拿走;每天来一次拿站点地图。 如果这四类东西都压过,实际过线的大概是HTML 30 KB、CSS 14 KB、JS 24 KB、站点地图140 KB。如果只有HTML压了——这正是nginx不改任何配置、只写一行gzip on时的状态——那么过线的是30 KB加84 KB加210 KB加2.4 MB。 差出来的不是一点点,是2.6 MB对0.2 MB。 而这两种状态在任何一个在线检测工具上,都会显示为同一个结果:已启用gzip压缩,通过。 ## 爬虫每次拿走的字节里,有多少本来不用传? 这个问题保哥没找到现成的答案,只能自己量一个基准出来。 取样是164个线上域名,其中85个能正常抓到首页。对每个站取四类URL——首页、一个深层内容页、robots.txt里声明的站点地图、页面上第一个静态资源,一共216个能同时拿到压缩版和未压缩版的测量点。 这里有个坑值得先说:量压缩收益不能用HTTP客户端库直接读响应体长度,因为它们默认会自动解压,你拿到的是解压后的字节数。本文第一遍就踩了,测出来“压缩后占比中位数100%”这种荒唐结果。得关掉自动解码,数真正过网线的那些字节。 ## 基准:一次抓取的真实字节分布 URL类型 | 样本 | 未压缩合计 | 实际过线合计 | 占比 | 压了的比例 | 首页 | 83 | 48.5 MB | 6.7 MB | 13.9% | 81/83 | 深层内容页 | 29 | 13.8 MB | 1.7 MB | 12.2% | 29/29 | 站点地图 | 66 | 17.8 MB | 1.0 MB | 5.7% | 56/66 | 静态资源 | 38 | 4.9 MB | 0.9 MB | 17.8% | 35/38 | 合计 | 216 | 85.0 MB | 10.3 MB | 12.1% | 201/216 | 顺带一提,这86%的压缩率是“同一份内容传得更省”,跟网页体积本身影响排名的那套说法 (https://zhangwenbao.com/page-weight-seo-truth.html)不是一回事——后者讨论的是内容该不该那么大,这里只讨论既然这么大、传的时候该收多少费。 整体看,压缩把过线字节压到了原文的12.1%。这就是压缩在抓取预算上的分量——Google抓取预算优化的那份实操清单 (https://zhangwenbao.com/google-crawl-frequency-optimization-guide-2026.html)里说得很清楚,抓取容量上限计的是服务器为爬虫保持连接的总时长,而传1个字节和传8个字节的耗时不是一回事。 但表里还有两个数值得停一下。 第一个是站点地图那行的5.7%——它是全表压得最狠的一类,因为XML的结构重复度极高。实验台上造了一份1200条URL的站点地图,234 KB原文,最高级别压完只剩5.9 KB,2.5%。这类文件天然就是压缩的最佳受益者。 第二个数正好相反:66个站点地图里有10个完全没压,占15%。也就是说,压缩收益最大的那一类文件,恰恰是漏配率最高的那一类。这不是巧合,后面会讲原因。 ## 那15个没压的,都是什么 216个测量点里有15个在浏览器的默认编码偏好下拿到的仍然是原文。绝大多数体积不大(几百字节到几KB,压不压差别有限),但有一个例外:一份application/xml的站点地图,202,507字节,一个字节没压。 这份文件如果压过,大概只剩5 KB到10 KB。 爬虫每来一次取一遍,一年下来的差额是三位数的MB——就为了一行没写进配置文件的MIME类型。 ## nginx出厂到底压了什么? 这一节是本文的核心。因为绝大多数“已启用gzip”的站点,实际状态就是出厂默认。 nginx的gzip模块文档 (https://nginx.org/en/docs/http/ngx_http_gzip_module.html)把四个默认值写得清清楚楚,只是很少有人一次把它们看全: 指令 | 默认值 | 意味着什么 | gzip_types | text/html | 只压HTML。CSS、JS、XML、JSON全部原样发 | gzip_comp_level | 1 | 九档里的最低档 | gzip_min_length | 20 | 20字节以上就压,这个默认值没问题 | gzip_vary | off | 压了,但不告诉下游缓存“我是按编码分版本的” | 第一行是最要命的。gzip_types的默认值是text/html,而且文档特别注明HTML永远会被压缩,所以你写不写它都在里面。换句话说,只写一行gzip on;而不写gzip_types,等于只给HTML开了压缩。 ## 把这四个默认值的代价量出来 实验台上造了六类文件,覆盖爬虫真正会遇到的形态:一个128 KB的HTML、一个60 KB的CSS、一个91 KB的JS、一个234 KB的站点地图、一个90 KB的JPEG、一个1.5 KB的小HTML,合计605,099字节。同一批文件放在不同配置的location下,用浏览器和爬虫的真实编码偏好各取一遍: 配置 | HTML | CSS | JS | 站点地图 | JPEG | 合计 | 占原文 | 完全不压 | 127,997 | 60,444 | 91,042 | 234,110 | 90,022 | 605,099 | 100.0% | 出厂默认(只压HTML,级别1) | 28,389 | 60,444 | 91,042 | 234,110 | 90,022 | 504,597 | 83.4% | 类型补全,级别仍是1 | 28,389 | 13,876 | 11,897 | 8,758 | 90,022 | 153,532 | 25.4% | 类型补全,级别6 | 20,964 | 10,753 | 10,345 | 6,870 | 90,022 | 139,527 | 23.1% | 类型补全,级别9 | 20,887 | 10,277 | 9,715 | 5,941 | 90,022 | 137,415 | 22.7% | Brotli(级别5) | 24,668 | 10,235 | 9,178 | 5,340 | 90,022 | 139,915 | 23.1% | 预压缩件(级别9) | 20,897 | 10,515 | 9,727 | 5,953 | 90,022 | 138,598 | 22.9% | 把第二行和第三行放一起看,这是本文最值得记住的一组数: - 出厂默认 → 类型补全:83.4%降到25.4%,收益58个百分点 - 级别1 → 级别6:25.4%降到23.1%,收益2.3个百分点 把MIME类型清单写全,收益是调压缩级别的25倍。而网上绝大多数“nginx压缩优化”的文章,篇幅都花在讨论级别选几合适。 ## 那一列纹丝不动的JPEG 上表里JPEG那一列在所有配置下都是90,022,因为没有任何一档把image/jpeg放进gzip_types。这是对的。 但为了确认代价,实验台上专门开了一个location把它放进去。结果是: 原文: 90,022 字节 gzip 之后: 90,070 字节 (大了 48 字节) 压完比原文大。MDN对这件事有直接说明 (https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Encoding):The data is already compressed, meaning a second round of compression will not reduce the transmitted data size, and may actually increase the size of the content in some cases. This is true for pre-compressed image formats (JPEG, for instance). 48字节不算什么,问题是你为这48字节付出了一次完整的压缩CPU开销。图片通常是站上数量最多的一类资源,把它们放进类型清单,等于给每一次图片请求都加了一道纯亏的工序。 ## 一份可以直接抄的类型清单 gzip on; gzip_comp_level 5; gzip_min_length 256; gzip_vary on; gzip_proxied any; gzip_types text/plain text/css text/xml application/javascript application/json application/xml application/rss+xml application/atom+xml image/svg+xml application/manifest+json text/markdown; 三点说明。text/html不用写,它永远在。image/svg+xml要写,SVG是文本,压缩比很高,但因为归在image/下经常被整类跳过。gzip_min_length从默认的20提到256,是因为几十字节的响应压完往往比原文大——gzip自己的头尾就要18字节。 ## Accept-Encoding里那些没人测过的写法 压缩要不要发生,取决于客户端在Accept-Encoding里说了什么。这个头看起来只有两三种写法,实际的边界比想象中乱得多。 实验台上用16种写法各打一遍,同一个文件、同一个开了gzip和Brotli的location: Accept-Encoding | 拿到的编码 | 下行字节 | 说明 | gzip, deflate, br | br | 24,668 | 浏览器与爬虫的典型值 | gzip | gzip | 23,539 | | br | br | 24,668 | | GZIP | gzip | 23,539 | 大小写不敏感 | zstd, gzip | gzip | 23,539 | 不支持的编码被跳过,退到下一个 | gzip;q=1.0, br;q=0.5 | br | 24,668 | 客户端明说更想要gzip,还是给了br | identity | 无 | 127,997 | | gzip;q=0 | 无 | 127,997 | 正确:q=0表示拒绝 | * | 无 | 127,997 | 规范说*匹配任何编码 | identity;q=0 | 无 | 127,997 | 客户端明说拒绝原文,照发原文 | deflate | 无 | 127,997 | nginx不实现deflate编码 | zstd | 无 | 127,997 | 需要额外模块 | (空值) | 无 | 127,997 | | 四行加粗的,逐个说。 ## q值:写了也白写 gzip;q=1.0, br;q=0.5的意思是“我两个都能收,但更想要gzip”。nginx给的是br。换成br;q=1.0, gzip;q=0.5,给的还是br。 nginx在选编码时不看q值,只看谁在配置里排在后面。ngx_brotli作为一个独立的过滤模块挂在gzip之后,于是只要客户端接受br,br就赢。这个行为在两边的文档里都没写,但它决定了一件很实际的事:你没法通过调整客户端偏好来让某一类客户端拿gzip。要控制这件事只能在服务端关掉一个。 ## *:规范说匹配一切,nginx当没看见 MDN对*的定义是:Matches any content encoding not already listed in the header.按这个语义,Accept-Encoding: *应该拿到压缩版。nginx给的是原文。 这条落到现网有多大规模?用Accept-Encoding: *对216个测量点各打了一遍: 请求头 | 拿到压缩版的比例 | gzip | 203/218=93.1% | br | 112/218=51.4% | * | 41/218=18.8% | 八成以上的服务器不认这个通配符。所以如果你在写爬虫、监控探针或者健康检查,别用*——它看起来最省事,实际会让你拿到未压缩的响应,并且在你的带宽账单上体现出来。 ## identity;q=0:一个大家都不实现的角落 MDN写得很明确:As long as the identity;q=0 or *;q=0 directives do not explicitly forbid the identity value that means no encoding, the server must never return a 406 Not Acceptable error.反过来说,一旦显式写了identity;q=0,就是明确禁止了原文,服务器要么给压缩版、要么406。 nginx给的是原文。这个角落几乎没有实际影响(没有客户端会这么写),但它是个很好的提醒:内容协商的规范面积远大于实现面积,那些没人走的分支上基本都是默认行为。 ## deflate:浏览器发了二十年的一个空头 所有主流浏览器的Accept-Encoding里都带着deflate,而nginx压根没有实现这个编码。它既不在gzip模块里,也没有独立模块。Accept-Encoding: deflate拿到的一定是原文。 这不是nginx的问题——deflate在历史上有过两种互不兼容的封装方式,实现分歧大到没人愿意碰。它今天的作用就是在请求头里占七个字节。 ## Brotli一定比gzip小吗? 这个问题保哥差点答错,过程值得说一下。 实验台第一轮用的是合成HTML——脚本拿一个词表随机拼出来的128 KB页面。结果是gzip压到23,539,Brotli压到24,668,Brotli反而大了4.8%。当时差点就把“Brotli不一定更小”写成结论。 幸好换了真实文件复测。从线上拉了四个真东西:一个485 KB的首页、一个515 KB的文章页、一个305 KB的站点地图、一个24 KB的纯文本清单,同一批配置再跑一遍: 文件(原文) | gzip级别1 | gzip级别6 | gzip级别9 | Brotli级别6 | Brotli级别11 | 首页485,947 B | 74,995 | 62,187 | 61,139 | 52,324 | 45,498 | 文章页515,230 B | 105,284 | 89,913 | 88,997 | 71,890 | 64,504 | 站点地图304,607 B | 50,426 | 41,017 | 40,498 | 35,771 | 31,293 | 纯文本清单23,774 B | 10,993 | 10,431 | 10,416 | 9,483 | 8,702 | 真实内容上Brotli每一格都赢,而且赢得不少。 合成文本那个反常结果的原因是:Brotli自带一部内置字典,里面是从真实网页里统计出来的高频片段——HTML标签、常见属性、英文常用词。随机拼出来的文本正好把这个优势全废掉了。 这是一条方法论上的教训,比结论本身更值钱:凡是拿合成数据测压缩比的,测出来的是压缩算法对合成数据的表现,跟你的站没关系。 ## 现网复核:Brotli赢在93%的URL上 实验台是四个文件,还是太少。保哥对线上216个测量点里同时能给gzip和br两个版本的109个,分别用Accept-Encoding: gzip和Accept-Encoding: br各取一遍,量真实过线字节: 指标 | 结果 | 同一批URL全部走gzip | 6,822,287 B | 同一批URL全部走Brotli | 5,548,651 B | Brotli相对gzip再省 | 18.7% | 逐个URL比,Brotli更小的 | 101/109=93% | 所以Brotli该开,这没有疑问。有疑问的是开在哪一档。 ## 压缩级别的边际收益,在哪一档断掉? 压缩级别是一笔用CPU换带宽的买卖。带宽那一侧前面已经量了,现在量CPU那一侧。 做法很朴素:同一个515 KB的文章页,在每种配置下连打30次,记总耗时,再减掉不压缩时的基线,得到每个请求净增的压缩时间。 配置 | 压完的体积 | 占原文 | 每请求净增耗时 | gzip级别1 | 105,284 B | 20.4% | 约20 ms | gzip级别6 | 89,913 B | 17.5% | 约31 ms | gzip级别9 | 88,997 B | 17.3% | 约52 ms | Brotli级别6 | 71,890 B | 14.0% | 约33 ms | Brotli级别11 | 64,504 B | 12.5% | 约999 ms | 最后一行不是笔误。Brotli最高级别在这份515 KB的文档上,每个请求要多花将近一整秒的CPU时间,换来的是相对级别6再小10.3%。 体积省10%,耗时涨30倍。 ## 为什么这一秒对SEO格外贵 如果这只是一笔服务器成本,还可以商量。但它同时撞在抓取预算的另一条规则上。 Google那份抓取预算文档里对抓取容量上限的浮动规则写得很直接: > If the site slows down (latency increases or response times become longer)…the limit goes down and Google crawls less. 反过来,响应时间保持稳定或者变快,这个上限才会往上走。 压缩发生在服务器准备好内容之后、发出第一个字节之前,所以它整个计入首字节时间。一秒的压缩延迟,直接变成一秒的首字节时间。 于是出现一个自我拆台的局面:你为了少传字节而选了最高压缩级别,结果因为响应变慢,Google给你的抓取额度反而降了。省下的10%体积,换来的是更少的抓取次数。这跟TTFB怎么优化才不白费 (https://zhangwenbao.com/ttfb-multi-layer-cache-core-web-vitals-crawl-budget-seo.html)是同一条链上的事——首字节时间同时挂在体验信号和抓取速率两头。 ## 结论:级别的正确用法是分场景,不是分级别 场景 | 建议 | 理由 | 动态内容运行时压缩 | gzip 4–6或Brotli 4–5 | 每次请求都要付CPU,必须控制在几十毫秒内 | 构建期预压缩的静态件 | gzip 9、Brotli 11随便用 | 压一次,发无数次,CPU成本摊到零 | 站点地图这类定时生成的 | 生成时就压好落盘 | 它一天才变一次,没道理每次请求现压 | 换句话说,Brotli 11不是“更好的压缩级别”,它是另一种东西——一个只能离线用的档位。把它写进运行时配置,是本文见过的最贵的一行配置。 ## 预压缩为什么几乎总是更划算? 顺着上一节往下推,预压缩看起来是完美方案:构建期用最高级别压好,运行时直接把.gz或.br文件发出去,体积最小、CPU为零。 实验台上的数字支持这个判断: 方式 | HTML体积 | 六类文件合计 | 运行时CPU | 运行时gzip级别5 | 23,539 | 142,613 | 每请求都付 | 预压缩件 级别9 | 20,897 | 138,598 | 零 | 体积小11.2%,CPU降到零。但预压缩有两个代价,都得先知道。 ## 代价一:漏掉没预压的文件 纯预压缩方案(gzip_static on而不同时开gzip on)只会发磁盘上确实存在的.gz。实验台上那个1.5 KB的小HTML没有预压件,于是在纯预压缩配置下拿到的是1,484字节原文,而运行时压缩配置下是573字节。 正解是两个一起开,让运行时压缩兜底: gzip_static on; # 有预压件就发预压件 gzip on; # 没有就现压 gzip_comp_level 5; gzip_types ...; 这组配置在实验台上给出了全表最小的合计值(137,704字节)。 ## 代价二:它会换掉你的ETag 这一条更隐蔽,而且直接和抓取预算的另一半冲突。 运行时压缩时,nginx保持ETag的值不变,只加个W/前缀标成弱验证器;而gzip_static发的是磁盘上另一个文件,ETag按那个文件的属性重算,变成一个完全无关的值: 配置 | 不压时的ETag | 要gzip时的ETag | 拿未压缩版ETag回问 | gzip on | "6a6a35c5-1f3fd" | W/"6a6a35c5-1f3fd" | 304 | gzip_static on | "6a6a35c5-1f3fd" | "6a6a35c5-51a1" | 200 | 也就是说,你用预压缩省下的那11%体积,可能被“条件请求失效导致的整页重发”一口吃掉。条件请求那一篇 (https://zhangwenbao.com/conditional-request-304-etag-crawl-budget-seo.html)里量过这件事的现网规模:54.1%的URL在压缩前后ETag不一样,其中32.1%会因此白下载一次全文。 两件事要合起来看:压缩省的是“这一次传多少”,条件请求省的是“这一次传不传”。后者的收益是前者的十几倍,所以当两者冲突时,优先保后者。 ## Vary漏了会发生什么? 前面表里那个gzip_vary off的默认值,值得单独一节。 MDN对Vary的说明 (https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Vary)是:Including a Vary header ensures that responses are separately cached based on the headers listed in the Vary field.如果服务器按Accept-Encoding发了不同版本却不声明Vary,中间缓存会把压缩版和未压缩版当成同一个东西,然后把错的那个发给下一个客户端。 实验台上确认了默认值: 配置 | 压缩时的Vary | gzip on;(没写gzip_vary) | 没有 | gzip on; gzip_vary on; | Vary: Accept-Encoding | 这行头本身还管着别的事——响应头层面那套SEO机制 (https://zhangwenbao.com/http-response-headers-seo-x-robots-cache-vary-canonical-mechanism.html)里,Vary同时影响着缓存分版本与内容协商的正确性,这里只谈它跟压缩相关的那一半。 好消息是现网这一项做得不错。203个压了的测量点里,带Vary的有178个=87.7%,其中明确含Accept-Encoding的171个=84.2%。这大概是因为主流CDN会替你补上这行头。 但如果你是自建nginx直出、前面没有CDN,这一行必须自己写。MDN还提了一句跟另一半直接相关的:The same Vary header value should be used on all responses for a given URL, including 304 Not Modified responses.——304里也得带。而nginx的304恰好会把它丢掉。 ## 只有爬虫会读的那几个文件,为什么反而最容易漏? 回到开头那个数:66份站点地图里有10份完全没压,占15%,是四类URL里漏配率最高的一类。而它们恰好是压缩比最高的一类(整体5.7%,实验台上那份合成站点地图是2.5%)。 收益最高、漏配率也最高,这两件事同时成立不是巧合。原因有三层: - 它的MIME类型是application/xml或text/xml,不在任何“默认压缩”的清单里,必须手写进gzip_types。 - 没人用浏览器看它。前端性能工具、Lighthouse、体验监控,扫的都是用户会访问的页面,站点地图从不在采样范围内。 - 它常常不由Web服务器直出。很多站的站点地图是应用动态生成的,或者由插件走另一条路径吐出来,那条路径上的压缩配置和站点其余部分完全是两套。 如果你的站点地图本身就很大,分片策略会直接放大这个差额,百万SKU的分片与lastmod诚实度 (https://zhangwenbao.com/ecommerce-product-sitemap-strategy-sharding-lastmod-image-index.html)那篇里的每一个分片,都是爬虫要单独取一次的文件。 同一类问题也发生在llms.md、robots.txt、各种feed和JSON接口上——凡是只有机器读的端点,都在人的观测范围之外。这条经验可以直接推广:一个端点被漏配的概率,跟“有多少人会用浏览器打开它”成反比。 ## 顺带纠正一个常见误解 有人会想:既然站点地图能压到2.5%,那是不是可以往里塞更多URL?或者反过来——既然页面能压到12%,Googlebot那个2 MB的抓取上限是不是就宽松了? 不是。Google在Googlebot那份文档 (https://developers.google.com/search/docs/crawling-indexing/googlebot)里写得很明白:Googlebot crawls the first 2MB of a supported file type,并且补了一句关键的——the file size limit is applied on the uncompressed data. 那个上限按解压后的数据算。压缩省的是传输,不是配额。站点地图的50,000条URL上限和50 MB未压缩上限同理,都是按解压后算的。这跟Googlebot那个2MB抓取上限 (https://zhangwenbao.com/crawl-size-checker-2mb-googlebot-fetch-limit-guide.html)是同一件事的两个侧面:压缩帮你省抓取时间,但帮不了你突破内容体积的天花板。 ## 现网的编码分布藏着一条边缘与源站的分界线 把216个测量点按实际拿到的编码归类,出现了一个一开始没料到的分布: URL类型 | 样本 | Brotli | gzip | 不压 | 首页 | 83 | 45(54%) | 36(43%) | 2 | 深层内容页 | 29 | 3(10%) | 26(90%) | 0 | 站点地图 | 66 | 33(50%) | 23(35%) | 10 | 静态资源 | 38 | 11(29%) | 24(63%) | 3 | 首页有54%走Brotli,深层内容页只有10%。同一批站点、同一个请求头,差别为什么这么大? 最可能的解释是缓存命中率,而这正是边缘缓存的回源与TTL分层 (https://zhangwenbao.com/cdn-edge-caching-strategy-ttl-cache-control-purge-origin-shield.html)那套配置在决定的事。首页是全站访问量最高的URL,几乎必然常驻在CDN边缘节点上,而边缘节点普遍支持Brotli;深层内容页长尾、命中率低,请求多半要回源,源站那一层往往只配了gzip。 这条推断不算铁证(样本里深层页只有29个),但它指向一个可操作的检查动作:别只测首页。首页的压缩表现是CDN的表现,深层页的压缩表现才是你自己那台服务器的表现。而爬虫花掉的绝大部分抓取预算,正是在深层页上。 这也解释了另一件事:为什么很多站在检测工具上一路绿灯——那些工具默认测的就是首页。 ## 怎么配才算把这件事做完? ## 先自查:三条命令看清现状 # 1) 深层内容页(不是首页!)拿到什么编码 curl -sI -H 'Accept-Encoding: gzip, deflate, br' \ https://你的域名/某个文章页 | grep -i 'content-encoding\|vary' # 2) 站点地图有没有压 curl -s -o /dev/null -w 'ce=%{content_type} size=%{size_download}\n' \ -H 'Accept-Encoding: gzip, deflate, br' https://你的域名/sitemap.xml # 3) 同一个URL压与不压的真实字节差 for ae in identity 'gzip, deflate, br'; do curl -s -o /dev/null -w "$ae -> %{size_download}\n" \ -H "Accept-Encoding: $ae" --compressed-no-var https://你的域名/某个文章页 done 第2条如果返回的体积和不带压缩头时一样,说明XML没进类型清单——这是本文实测中漏配率最高的一格。 ## 按优先级排的动作清单 优先级 | 动作 | 预期收益 | 1 | 把CSS、JS、XML、JSON、SVG补进gzip_types | 整体字节从83.4%降到25.4% | 2 | 确认站点地图、feed、机读端点走的是同一套压缩配置 | 单个文件常常是几十到几百KB的差额 | 3 | 开gzip_vary on | 防止中间缓存把版本发错 | 4 | 开Brotli,级别4到5 | 相对gzip再省约18.7% | 5 | 级别从默认1提到4到6 | 再省约2.3个百分点 | 6 | 静态件走构建期预压缩,级别拉满 | 体积再降11%,CPU归零 | — | 运行时用Brotli 11 | 别做:每请求多花约1秒 | — | 把图片类型放进gzip_types | 别做:压完更大,还白付CPU | ## 一份完整的参考配置 gzip on; gzip_static on; # 有预压件优先发预压件 gzip_comp_level 5; gzip_min_length 256; gzip_vary on; gzip_proxied any; gzip_types text/plain text/css text/xml text/markdown application/javascript application/json application/xml application/rss+xml application/atom+xml application/manifest+json image/svg+xml; brotli on; brotli_static on; brotli_comp_level 5; brotli_min_length 256; brotli_types text/plain text/css text/xml text/markdown application/javascript application/json application/xml application/rss+xml application/atom+xml application/manifest+json image/svg+xml; 两套类型清单要写一样,否则会出现“只有接受gzip的客户端才拿得到压缩版CSS”这种非常难查的偏科。 ## 一个诚实的边界 压缩这件事的收益上限是有限的,保哥觉得有必要说清楚。 本文量出来的最大单笔收益——把类型清单补全——把一套典型资源从605 KB降到153 KB,省了452 KB。听起来很多,但对照一下另一半:如果这些URL的内容没变,条件请求能把同样一次抓取从153 KB降到不到200字节。 压缩省掉的是一次抓取里的百分之七十几,条件请求省掉的是整整一次抓取。 所以这两件事的正确顺序是:先把条件请求做对,再把压缩做满。如果时间只够做一件,做前者。压缩的真正价值在于那些确实变了、非传不可的响应——对它们来说,压缩是唯一能省的东西。 另外,如果你的站只有几百个页面,这两件事的实际收益都接近于零,爬虫本来就抓得完。规模才是这类优化的前提,这一点在抓取预算优化那份清单 (https://zhangwenbao.com/google-crawl-frequency-optimization-guide-2026.html)里也是同样的口径。 ## 常见问题解答 ## 我已经开了gzip,为什么检测工具说通过但字节没少多少? 大概率是gzip_types没配。nginx这个指令的默认值是text/html,只写一行gzip on;等于只压HTML,CSS、JS、XML、JSON全部原样发。实验台上这个差别是整体字节83.4%对25.4%。检测工具通常只看首页HTML有没有Content-Encoding,看到了就打勾。 ## gzip和Brotli该开哪个,还是两个都开? 两个都开,让客户端自己挑。现网实测里同时能给两种编码的109个URL,Brotli在其中93%上更小,整体再省18.7%。但注意nginx不看客户端的q值偏好——只要客户端接受Brotli就一定给Brotli,你没法通过请求头让它退回gzip。 ## Brotli级别该选多少? 运行时压缩选4到5。实测级别11在一份515 KB的文档上每个请求要多花约999毫秒CPU,只换来相对级别6再小10.3%的体积,而这一秒会整个计入首字节时间,反过来压低Google给的抓取速率。级别11只适合构建期预压缩,那时候压一次发无数次,CPU成本可以忽略。 ## 压缩会不会影响ETag和条件请求? 会。运行时压缩只是把ETag标成弱验证器(加W/前缀),值不变,不影响条件请求;但gzip_static发的是磁盘上另一个文件,ETag完全不同,客户端拿未压缩版的ETag回问会拿到200全文。现网实测54.1%的URL在压缩前后ETag不一样。两者冲突时优先保条件请求,因为它省的字节多一个数量级。 ## 站点地图需要单独配压缩吗? 需要检查。它的MIME类型是application/xml或text/xml,不在默认清单里;而且很多站的站点地图是应用生成的,走的路径和站点其余部分不是一套配置。本次普查里66份站点地图有10份完全没压,是四类URL中漏配率最高的,而它又是压缩比最高的一类(整体压到5.7%)。 ## 压缩能让我突破Googlebot的2MB抓取上限吗? 不能。Google的文档写明the file size limit is applied on the uncompressed data,2MB是按解压后的数据算的。压缩省的是传输时间和带宽,不是内容配额。站点地图的50,000条URL和50 MB上限同理。 ## 用了CDN是不是就不用管服务器上的压缩配置了? 不能这么假设。本次普查里首页有54%走Brotli,深层内容页只有10%——首页常驻边缘节点,深层页多半要回源,回源那一层是你自己的配置。而爬虫的抓取预算绝大部分花在深层页上。检查压缩配置时一定要测深层页,别只测首页。 ## 为什么Accept-Encoding: *拿不到压缩版? 规范上*表示匹配任何未列出的编码,但绝大多数实现不认。实测216个测量点里用*只有18.8%拿到压缩版,而用gzip是93.1%。写监控探针、健康检查或者自建爬虫时,一定要显式写gzip, deflate, br,否则你会一直在下载未压缩的响应。 ## 权威参考资料 ## Nginx配置每次reload都通过,Googlebot每8次抓取却有1次撞在301上 - URL:https://zhangwenbao.com/nginx-config-silent-seo-side-effects-audit.html - 分类:Nginx - 发布:2026-07-27 | 更新:2026-07-27 - 摘要:一台生产服务器上的只读实测:陌生域名的首页返回200却印着404,不存在的路径返回404却声明自己可索引。38天日志显示Googlebot有12.82%的抓取撞在301上,近九成花在已下线的目录。 - 关键词:重定向,软404,抓取预算,Nginx > **TLDR**:摘要:Nginx不认识“收录”这个词。它只判断一次请求在HTTP层面成不成立,成立就给一个合法响应,至于这个响应在搜索引擎那边被读成什么意思,它不管。 保哥拿自己这台跑着十几个站的机器做了一轮只读实测,几个问题全部复现:陌生域名指过来拿到HTTP 200,页面上却印着“404 Not Found”;不存在的路径返回404,页面里却写着index, follow、canonical指向首页;两条并排的301,一条把utm参数带走了,另一条把它扔了;配置里那行响应头一直在,只是从没被发出去过。38天日志里Googlebot来了90760次,其中11639次拿到的是301,九成花在一个早就下线的目录上。这些错误没一个会让nginx -t报错。 > 摘要:Nginx不认识“收录”这个词。它只判断一次请求在HTTP层面成不成立,成立就给一个合法响应,至于这个响应在搜索引擎那边被读成什么意思,它不管。 保哥拿自己这台跑着十几个站的机器做了一轮只读实测,几个问题全部复现:陌生域名指过来拿到HTTP 200,页面上却印着“404 Not Found”;不存在的路径返回404,页面里却写着index, follow、canonical指向首页;两条并排的301,一条把utm参数带走了,另一条把它扔了;配置里那行响应头一直在,只是从没被发出去过。38天日志里Googlebot来了90760次,其中11639次拿到的是301,九成花在一个早就下线的目录上。这些错误没一个会让nginx -t报错。 这篇不是Nginx配置教程,反向代理那一整套 (https://zhangwenbao.com/nginx-proxy.html)和整页缓存怎么落地 (https://zhangwenbao.com/nginx-fastcgi-cache-fullpage-php-wordpress-purge-microcache.html)站里都写过。这次查的是另一类东西:写法完全合法、语法检查一定通过、浏览器打开也看不出毛病,却已经在悄悄改变搜索引擎眼里这个站长什么样的指令。 实验台是保哥自己那台生产服务器,跑着十几个站点,Nginx 1.28.3,访问日志从6月19日攒到7月27日共196万行。下面每个数字都从这台机器上量出来,全程只读,没改过配置。 ## 一台服务器上十几个站,陌生域名请求过来会拿到什么? 这件事太容易被跳过——所有人都测自己的域名,没人测别人的。做法很土:给本机发请求,Host头填一个这台机器上根本不存在的域名。 $ curl -o /dev/null -w '%{http_code}' -H 'Host: evil.example.test' http://127.0.0.1/ 200 不是404,不是403,是200。那这个200的响应体是什么? 404 Not Found

404 Not Found


nginx
一个写着“404 Not Found”的页面,用HTTP 200发了出来。 ## 这个自相矛盾的页面是谁放的 响应头里Last-Modified停在2023年10月12日,这个138字节的文件在硬盘上躺了快3年,没人看过一眼。它的root目录里就两个文件:50x.html和index.html,后者是元凶。 面板初始化时往默认站点目录里放了个index.html,内容抄的是Nginx那个经典404模板;而默认站点的配置里写着index index.html;。 ## 为什么它是200而不是404 因为这两句话各自都对,并且各自都在正确执行。 请求打到/,Nginx按index指令找首页文件,找到了,于是当成正常首页返回——文件存在,读取成功,状态码理所当然是200。至于这个文件的内容碰巧是一句“404 Not Found”,Nginx完全不关心,它眼里那就是138个字节,不是一句话。 这是本文所有问题的共同形态,值得提前说破:每一层都按自己的规则正确执行了,事故长在层与层的接缝上。面板放文件没错,配置写index index.html也没错,两件对的事凑一起,产出了一个在搜索引擎那边彻底说不通的东西。 ## HTTPS这边也没拦住 本来还指望SNI不匹配能挡掉,试了一下,照样200——默认站点配了证书,证书对不对得上是浏览器的事,Nginx这边流程是通的。 往深了探一层倒有个小安慰:陌生Host下/和/index.html是200,/nginx-proxy.html和/any/deep/path.html都是真404,因为那个目录里确实没有对应文件。出问题的只有首页——而首页恰恰是搜索引擎最先抓、也最愿意收的那一个。 ## Google把这种页面叫什么 叫软404。Google在HTTP状态码文档里的判定很直白:"If the content suggests an error for Google Search, an empty page or an error message, Search Console will show a soft 404 error."内容看着像报错,就按软404处理——页面标题直接写着“404 Not Found”,这判定没什么悬念。 更要命的是抓取预算文档那句:"Eliminate soft 404 errors. soft 404 pages will continue to be crawled, and waste your budget."软404不会因为被识破就不抓了,它会继续被抓。站里那篇讲404抓取信号的文章 (https://zhangwenbao.com/google-404-crawl-seo-positive-signal.html)说过,Google抓到真404其实是好事,那是明确的收尾信号;软404的麻烦正在于它不给任何信号,只让抓取一直转圈。 平时确实没人拿陌生域名请求你的IP,但泛解析把整个*.example.com指过来、换服务器时旧域名忘了摘、别人擅自把域名解析到你的IP,这几种都不算罕见——产出的都是同一个200页面,一批可索引、内容雷同、还全都自称404的URL,说它是索引膨胀 (https://zhangwenbao.com/index-bloat-mechanism-sitewide-diagnosis-decision-matrix.html)的种子并不夸张。 ## server_name下划线到底是不是通配符? 顺着往下追:这些陌生Host凭什么落进默认站点?配置里那行是server_name _;。绝大多数人——包括保哥自己用了很多年——都默认_是通配符,意思是“匹配剩下的一切”。 它不是。 ## 官方文档把话说得很不留情面 > "There is nothing special about this name, it is just one of a myriad of invalid domain names which never intersect with any real name. Other invalid names like -- and !@# may equally be used." 翻成人话:这名字一点也不特殊,它只是众多无效域名里的一个,永远不会跟任何真实域名撞上;你写--或者!@#,效果完全一样。文档还补了一刀——兜底这件事压根不归server_name管,它是listen的属性。 ## 那它凭什么真的兜住了 答案在listen的文档里,就一句: > "If none of the directives have the default_server parameter then the first server with the address:port pair will be the default server for this pair." 没有任何server块标了default_server,那么第一个监听这个地址端口的server块自动当默认。保哥把整份展开后的配置扫了一遍:default_server出现0次。这台机器的默认站点,完全靠“谁排第一”决定。 ## 排第一这件事,是文件名说了算的 主配置加载虚拟主机的那行是include /www/server/panel/vhost/nginx/*.conf;,通配符展开按字母序。那个目录里的文件叫0.default.conf,数字0加个点开头,排在所有域名文件前面。这个前缀不是随手起的,它就是用来抢第一位的。 于是一条完整的因果链浮出来了:文件名的字母序 → include展开顺序 → 谁是默认server → 陌生域名拿到什么响应 → 搜索引擎会不会收一批软404。一个SEO结果,最上游的自变量是一个文件名。 这条依赖脆到什么程度?把0.default.conf改名成zz.default.conf,默认站点当场换人,变成字母序最靠前的那个真实站点——从此陌生域名会拿到那个站的完整首页,一字不差,那才是货真价实的重复内容。面板升级重排文件、新加的站点排到了前面,同样的事都会自己发生,没有任何提示。 正确做法只有一个,而且和server_name无关:在listen上把话说死。 server { listen 80 default_server; listen 443 ssl default_server; server_name _; return 404; # 明确拒绝,别给200 } 加上default_server,兜底就从“碰巧排第一”变成“明确指定”;把index和root换成一句return 404,那个躺了3年的index.html再也没有出场机会。 ## 一个返回404的页面,为什么在页面里声明自己可以被收录? 测完陌生域名,顺手测自己域名下不存在的路径。状态码这关是过的,带扩展名、带尾斜杠、都不带,三种形态全是404。保哥本来准备翻篇,顺手看了眼响应体,然后就没能翻成。 ## 三行标签,一行比一行离谱 保哥笔记 标题是站点名,没有一个字提示这个页面不存在;index, follow在明确请求被收录;canonical指向首页,等于说“我的规范版本是首页”。HTTP层说“这里没有东西”,HTML层说“我可以被收录,而且我等同于首页”,两层互相拆台。 这是保哥在这轮实测里发现的自家问题,不是找来的教学案例,写到这段时它还挂在线上。 ## 它是怎么长成这样的 又是三层各自正确。Nginx层的伪静态是if (!-e $request_filename) { rewrite ^(.*)$ /index.php$1 last; },文件不存在就交给index.php;应用层找不到文章返回404;模板层的公共head无条件输出canonical和robots,因为它被写的时候压根没考虑过“这次渲染的是个404”。 没有哪一层能被单独指认为写错了。canonical本来就是提示而不是命令 (https://zhangwenbao.com/canonical-tag-common-mistakes-hint-not-directive.html),Google有权不理它,但一个自称可索引、还把自己等同于首页的404,信号足够混乱,赌它每次都判对不是好主意。 ## 顺带称了一下这个404有多重 请求的路径 | 状态码 | 不压缩 | Brotli压缩后 | /this-page-does-not-exist-xyz.html | 404 | 304607 | 36301 | /css/output.css | 404 | 304607 | 36301 | /seo/google-seo/ahrefs/ | 404 | 304607 | 36301 | 三个完全不同的路径,字节数一模一样,它们拿到的是同一个页面。304607字节,用来表达“这个页面不存在”,压缩后还有36KB。这么大是因为应用把404也当成一次正常渲染跑完了:侧栏、导航、推荐位、页脚,一样不少。 日志里Googlebot撞上404共469次,累计传了14.79 MB,平均每次33060字节。倒不是心疼这15兆,而是它全部用来重复表达同一句“没有”。撞的URL挺有代表性:删掉的老分类/seo/google-seo/ahrefs/ 14次,下线的标签/tag/迁移宿醉/ 12次,还有一批是友链页把访客提交的整段留言拼进了路径,连人家的头像地址都编码进去了。 这类账该不该修、按什么顺序修 (https://zhangwenbao.com/broken-links-seo-worth-fixing-triage-guide.html)站里单独写过。这里只强调一点:404返回得对,不等于这件事处理完了。状态码是第一关,页面内容说了什么是第二关,而第二关基本没人查。 ## 同一台Nginx上的两种301,为什么一种保留参数一种把它丢了? 这台机器上有一批老路径的301规则,2026年5月基于日志分析加的,用来收掉常年刷404的URL,写法清一色是return 301。看着没毛病,带上查询参数再请求一次,问题就出来了。 $ curl -I 'https://zhangwenbao.com/tags/seo?utm_source=newsletter&gclid=ABC' location: https://zhangwenbao.com/tag/seo ← 参数没了 $ curl -I 'https://zhangwenbao.com/llms?a=1' location: https://zhangwenbao.com/llms/?a=1 ← 参数还在 同一台服务器,两条301对查询参数的处置完全相反。区别在于第二条不是人写的:/llms是真实目录,Nginx发现它少了尾斜杠就自动补了个301,这类自动生成的重定向会带上原查询串;第一条是配置里那句return 301,它把参数扔了。 ## 文档里写了什么,以及没写什么 rewrite指令的文档明说会追加: > "If a replacement string includes the new request arguments, the previous request arguments are appended after them. If this is undesired, putting a question mark at the end of a replacement string avoids having them appended." 原参数会被追加在后面,不想要就在替换串末尾加个问号——文档甚至贴心地告诉你怎么关掉。 再翻return的文档,关于查询参数:一个字都没有。这不是文档偷懒,是return压根没有“追加参数”这回事,你写什么它发什么。两个指令看着都是“做个跳转”,一个默认帮你带上还给开关,另一个默认什么都不带也没开关,中间没有任何提示告诉你它们在这件事上是相反的。 ## 丢掉的到底是哪几个参数 丢的不是随便什么参数。gclid是Google Ads的点击标识,丢了它这次点击和后续转化的链路当场断开;utm_*整套是GA4分渠道的依据,丢了以后这次访问会掉进直接流量;srsltid是Google给购物链接自动挂的参数,站里写过它的四种处置方式 (https://zhangwenbao.com/google-srsltid-parameter-seo.html),这个你还没法让它不加。 一次301之后这些全部归零。而报表上你只会看到直接流量莫名其妙涨了一截,不会看到任何报错。 ## 顺手测出的第二个坑:Location里的域名是谁定的 $ curl -I -H 'Host: zhangwenbao.com' .../apple-touch-icon.png Location: http://zhangwenbao.com/logo-180.png $ curl -I -H 'Host: www.zhangwenbao.com' .../apple-touch-icon.png Location: http://www.zhangwenbao.com/logo-180.png 配置里只有一行return 301 /logo-180.png;,路径是相对的,域名是Nginx现补的——补出来的是请求里带的那个Host。文档说得明白:这种本地URI的跳转,完整地址由$scheme加上server_name_in_redirect和port_in_redirect两个指令决定,而这台机器两个都没显式设置,走默认值,于是域名跟着请求走。 后果有两层。域名被原样保留,从www进来的请求跳完还在www上,还得再吃一次统一跳主域 (https://zhangwenbao.com/nginx-open-ssl-www-and-http-all-jump-to-non-www-https-domain.html)的301;协议也被原样保留,上面两次实测的Location都是http://,于是链条变成http的301→https的301→200,三次请求换一个图标。Google抓取预算文档对此态度明确:"Avoid long redirect chains, which have a negative effect on crawling." 打开server_name_in_redirect能把域名固定住,但在反向代理后面又会引出端口和协议对不上的新问题。更稳的办法是跳转目标直接写全绝对URL。 ## 那到底该用哪个 场景 | 该用 | 为什么 | 固定路径搬家,该URL不带跟踪参数 | return 301 | 最快,不进正则引擎 | 落地页、活动页、任何可能挂广告参数的URL | rewrite ... permanent | 参数自动带过去 | 用return但必须留参数 | return 301 /new$is_args$args; | 手动把参数拼回去 | 跨域名跳转 | 写全绝对URL | 不给Host反射留机会 | $is_args$args值得记:没参数时它是空的,有参数时自带一个问号,两种情况都不出错。 ## Googlebot每8次抓取有1次拿到301,这些301在跳什么? 前面都是单点实验,这节看真实爬虫38天的完整行为。日志跨度是2026年6月19日到7月27日,196万行,筛出Googlebot共90760次请求。 状态码 | 次数 | 占比 | 200 | 78272 | 86.24% | 301 | 11639 | 12.82% | 404 | 469 | 0.52% | 403 | 229 | 0.25% | 500 | 77 | 0.08% | 410 | 51 | 0.06% | 304 | 6 | 0.007% | Googlebot每8次抓取,就有1次拿到的不是内容,是一句“东西搬走了,去那边看”。 ## 89%的301来自一个早就下线的目录 把这11639条按路径归类,排在前面的是/amp/、/amp/dbs-bank.html、/amp/chatgpt-location-sharing-local-seo.html……清一色AMP路径。整个命名空间加起来数: Googlebot 抓 /amp/* :10392 次,状态码全部是 301 全部 UA 抓 /amp/* :20349 次 10392除以11639,等于89.3%。Googlebot拿到的301里将近九成花在一个已下线的AMP目录上,占它全部抓取次数的11.4%。 这个站早就不做AMP了,页面全删了,Nginx的伪静态把这类路径交给应用,应用认出前缀后301到正文页。链路是通的,用户点进来也能看到内容。唯一的问题是它已经重复了10392次,而且还会继续。 ## 301很便宜,但便宜的不是它消耗的那样东西 响应类型 | 次数 | 累计字节 | 平均每次 | 全部Googlebot请求 | 90760 | 3606.65 MB | 41669 | 其中200 | 78272 | 3589.86 MB | 48092 | 其中301 | 11639 | 1.78 MB | 160 | 其中404 | 469 | 14.79 MB | 33060 | 301平均160字节,11639次加起来才1.78兆。从带宽角度看这笔钱少到不值得提,404反而贵得多——469次就吃掉14.79兆,因为每次都要渲染那个304KB的页面。 别拿“省流量”论证要治理301,那个论据站不住。真正被消耗的是抓取次数。Google讲的“抓取容量上限”是一个关于请求频次的额度,不是关于字节的,这11639次实实在在从额度里扣掉了,换回来只有11639个Location头。这些额度要是花在还没被抓过的长尾页 (https://zhangwenbao.com/mysql-slow-query-log-signal-noise-ttfb-crawl-budget.html)上,收益比重复跳转高得多。 ## 那到底该301还是该410 Google文档里301是"a strong signal that the redirect target should be processed",一个指向新目标的强信号;而4xx这边"the indexing pipeline removes the URL from the index if it was previously indexed",索引管线会把它删掉,410和404待遇一样。 差别在于你想说什么:301说的是“内容搬到那边了”,410说的是“这东西没了,别再来”。AMP这里301在技术上说得通,内容确实还在,但它跑了10392次都没让Googlebot放弃——“搬家”这个信号只会让爬虫记住“这地址会跳转”,而不是“这地址不该再来”。301、404和410怎么选 (https://zhangwenbao.com/http-status-codes-seo-atlas-redirect-410-decision.html)站里有完整决策表,这里补一条实测经验:当一批301跑了上万次还在跑,就该考虑换个信号了。 ## 顺便,那77次500是怎么回事 500只有77次,占比不到千分之一,本来懒得看,看了一眼发现全是一类:/tag/Memorial+Day/ 12次、/tag/开尔文/ 9次……全是标签页,带加号的和带中文的都翻车了,而/tag/seo/这种纯ASCII的正常返回301。本地一测就复现,PHP错误日志里对应的是一片Undefined array key。 标签体系早就下线了,但URL还活着,还在用500回应爬虫。77次不多,可这个数字的性质和301不一样:抓取预算文档把5xx和响应变慢并列,后果都是"the limit goes down and Google crawls less."301只是浪费额度,5xx是在主动缩小额度。下线一套URL体系时,让它404或410都行,唯独不能报错。 ## 配置里明明写着那条响应头,为什么线上收不到? 这节的坑最隐蔽:它不改状态码,也不改页面内容,只是让你写的东西悄悄消失。 站点配置的server级别有这么一行add_header Vary "Accept, Accept-Encoding" always;,写的是两个值。实际收到的是vary: Accept-Encoding,只有一个值,配置里那个Accept没了。 ## add_header不是叠加,是全有或全无 Nginx的headers模块文档里,有一句话决定了这一切: > "These directives are inherited from the previous configuration level if and only if there are no add_header directives defined on the current level." 当且仅当当前层级一条add_header都没有时,才继承上一层的。 注意粒度:不是按header名字逐个判断,是整体判断。子层级只要写了任意一条add_header——哪怕加的是个八竿子打不着的头——父层级的所有add_header全部作废。/sitemap.xml落进的那个location里有自己的add_header,于是server级那条Vary被整体屏蔽了。 更黑色幽默的是,这台机器的配置里,那行add_header上方就有一句注释写着“会被任何location级的add_header遮蔽”。注释留了,坑还是踩了——知道规则和记得每个location里有什么,是两件事。 ## 为什么这条规则专坑做SEO的 因为SEO要发的那些头全靠add_header:X-Robots-Tag: noindex是PDF、图片这类插不了meta标签的资源唯一的noindex手段;Link: rel="canonical"是非HTML资源声明规范版本的唯一办法;Cache-Control和Vary决定CDN和爬虫怎么缓存。 典型翻车路径:你在server级写了add_header X-Robots-Tag "noindex" always;想把测试环境挡住,三个月后有人为了修跨域,在location ~* \.(jpg|png)$里加了一行add_header Access-Control-Allow-Origin *;。 从那一刻起,这个站所有图片的noindex全部失效。配置里那行noindex还在,语法检查照样通过,谁也不会想到去看它。响应头这层的SEO机制 (https://zhangwenbao.com/http-response-headers-seo-x-robots-cache-vary-canonical-mechanism.html)站里讲过原理,这一条是原理落到配置文件之后最容易崩的地方。 ## 怎么查,以及怎么防 查的办法只有一个,而且必须查响应不能查配置。把所有location类型都覆盖到是关键,只测首页永远发现不了图片那个location的问题: for u in / /a.html /img/x.png /file.pdf /sitemap.xml; do echo "== $u" curl -sI "https://example.com$u" | grep -iE 'x-robots-tag|vary|cache-control|link:' done 防的办法有两条:把要全站生效的头在每个有add_header的location里都重写一遍,笨但可靠;或者改用ngx_headers_more模块的more_set_headers,它没有这个继承规则,代价是要额外编译模块。保哥选的是第一条——多写几行重复配置,换掉一个查起来要命的隐患,这买卖划算。 ## Vary声明的是一种可能性,不是这次响应的事实 接着上节的Vary往下挖,挖出来的东西比预想的有意思。拿站点的sitemap索引做对照,一次声明支持gzip,一次明确要求不压缩: $ curl -I -H 'Accept-Encoding: gzip' .../sitemap.xml content-length: 849 vary: Accept-Encoding $ curl -I -H 'Accept-Encoding: identity' .../sitemap.xml content-length: 849 vary: Accept-Encoding 两次的Content-Length都是849,一个字节不差——压缩根本没发生过,849字节没到阈值,服务器直接原样发了。但两次都老老实实带着Vary: Accept-Encoding。 这不是bug,是这个头的语义常被误读。Vary说的是“这个URL的响应有可能随这个请求头变化”,它描述的是一种可能性,不是这一次响应的事实。服务器在决定压不压之前就把它挂上去了,因为此刻还不知道结果。 平时无所谓,遇到CDN就不一样。CDN按Vary里列的头给缓存分桶,而Accept-Encoding在真实世界里取值五花八门——gzip, deflate、gzip, deflate, br、identity,还有各种老客户端的怪写法。不做归一化的话,同一个URL会分裂出好几份缓存条目,而这几份里存的是同一份849字节。命中率被摊薄了,字节一个没省。 ## 665KB的RSS,为什么gzip一口没咬 URL | Content-Type | 不压缩 | gzip | Brotli | /rss.xml | text/xml | 665979 | 665979 | 233113 | /sitemap.xml | text/xml | 849 | 849 | 849 | /llms.md | text/plain | 25723 | 11593 | 10587 | /nginx-proxy.html | text/html | 486535 | 77040 | 压了 | 看第一行:一个650KB的RSS,客户端明确说了支持gzip,服务器原样发了665979字节,而同一个文件Brotli能压到233113——本来可以省423KB。text/plain压了,text/html压了,text/xml的两个都没压。原因藏在类型清单里: gzip_types ... application/xml application/json image/jpeg ... image/svg+xml application/xml+rss text/x-js; brotli_types ... text/plain text/css text/xml text/javascript ... application/xml application/atom+xml application/rss+xml ... 两处差别,各造成一个后果。gzip_types里没有text/xml,brotli_types里有,而这两个文件的Content-Type恰好就是text/xml,这解释了为什么Brotli能压gzip不能。更妙的是gzip_types里写的是application/xml+rss——这个MIME类型根本不存在,正确写法是application/rss+xml,注意brotli_types那行写对了。同一个配置文件里两份清单,一份错一份对。 那个写反的MIME是从面板默认模板带出来的,在各种教程里流传了很多年,因为它不会报错——Nginx对gzip_types的值不做校验,你写banana/split它也照单全收,只是永远不会有响应匹配上。 ## 这事有多严重,得说实话 写到这里保哥本来想下个狠结论,量完数据又收回去了。现代爬虫和浏览器基本都支持Brotli,Googlebot拿到的是233KB那版,没受影响。真正吃亏的是只发gzip的那批客户端——一部分RSS阅读器、老抓取脚本、某些企业代理,还有你自己写的那些忘了加br的监控脚本。 所以准确的结论是:这是一个影响面有限但完全没必要存在的问题,修复成本是往gzip_types里补一个text/xml,顺手改掉那个写反的MIME,两分钟的事。顺带一提,Nginx对text/html永远压缩,不需要写进清单——这也是为什么HTML那行看着一切正常,它压根没走这份清单。 ## 一个查询参数把TTFB从26毫秒推到623毫秒,这笔账该怎么算? 这节原本要论证“爬虫带参数抓取会穿透整页缓存、拖慢抓取”。数据把这个论点否掉了一半,那就照实写。 站点的整页缓存有条绕过规则:if ($query_string != "") { set $skip_cache 1; },只要URL带问号一律穿到PHP。实测四次: 请求的URL | 缓存状态 | TTFB | /nginx-proxy.html | HIT | 26 ms | /nginx-proxy.html?utm_source=newsletter | BYPASS | 623 ms | /nginx-proxy.html?gclid=TEST123 | BYPASS | 540 ms | /nginx-proxy.html?srsltid=abc | BYPASS | 648 ms | 同一个页面,同样的内容,多一个参数,TTFB慢24倍。而这些URL根本不会被收录——canonical已经把它们全部收口到干净地址了,实测确认过。付24倍成本,换一个永不进索引的响应。 ## 但我原本的论证被数据推翻了 接下来该论证“爬虫经常带参数来,所以代价被放大了”。数了一下:Googlebot的90759条请求里,带查询串的只有366条,0.40%。千分之四。 这366条里还有307条是同两个东西:/?c=评论分页157次、/?s=站内搜索150次,加起来84%。“缓存穿透正在吃掉抓取预算”这个说法,在这台机器上不成立,原本准备好的一整段推论就此作废。这跟上一篇实测里被推翻的那个结论 (https://zhangwenbao.com/mysql-slow-query-log-signal-noise-ttfb-crawl-budget.html)是同一种情况——想当然的因果链,量一遍就散了。 ## 那这24倍到底谁在付 不是爬虫,是花钱买来的那批用户。想想哪些访问必然带参数落地:广告点击带gclid,邮件推广带utm_*,联盟带自家跟踪码,购物广告带srsltid。这些恰恰是单次成本最高的一批流量,而他们每个人的第一次落地都要等600毫秒才收到第一个字节。自然搜索点进来的反而享受26毫秒,因为那些URL是干净的。这套配置在系统性地惩罚付费流量、优待免费流量,逻辑上有点黑色幽默。 所以结论要改写:这不是抓取预算问题,是付费流量的落地体验问题。受害者认错了,问题本身是真的。 改法是别用“有没有问号”这种粗暴判断。参数分两类:utm_*、gclid、fbclid、srsltid这些改变不了服务器吐出的HTML,完全应该复用缓存;分页、搜索词、排序、筛选才必须绕过。做法是把纯跟踪参数从缓存键里剥掉,让它们命中同一份缓存: set $ckey $args; if ($ckey ~ "^(?:utm_[^&]*|gclid=|srsltid=|fbclid=)") { set $ckey ""; } fastcgi_cache_key "$scheme$request_method$host$uri?$ckey"; 思路示意,参数多的站得写更细。整页缓存那篇 (https://zhangwenbao.com/nginx-fastcgi-cache-fullpage-php-wordpress-purge-microcache.html)讲了缓存键怎么设计,重点只有一个:缓存键该按“这个参数会不会改变响应”来划,不该按“有没有问号”来划。 ## 90760次抓取里只有6次304,剩下的都在重传吗? 回头看状态码表最下面那行:304,6次。这数字小得不正常——对一个1600多篇文章、绝大多数是常青内容的站,理应有相当比例的抓取可以用“你手上那份还是最新的”打发掉。 ## 爬虫想省,得先有东西可依 条件请求需要服务器先给出版本凭据:Last-Modified或ETag。把几类资源摆一起: 资源 | Last-Modified | ETag | 能不能条件请求 | /sitemap.xml(静态文件) | 有 | 有 | 可以 | /robots.txt(静态文件) | 有 | 有 | 可以 | /nginx-proxy.html(PHP生成) | 没有 | 没有 | 不行 | 答案就在这儿。Nginx的静态文件模块按文件的mtime和大小自动生成这两个头,所以sitemap和robots.txt天生支持条件请求;而文章页是PHP生成的,哪怕命中了整页缓存、根本没执行PHP,Nginx也不会替上游补这两个头,因为它不知道该按什么算,上游自己也没发。于是1600多篇文章的每一次抓取都是全量传输,那6次304大概率全是静态文件贡献的。 跟压缩那节连起来看更清楚:Googlebot的200响应平均48092字节,而文章页不压缩是486535、压缩后77040——压缩这层是正常工作的,48KB这个均值就是证据。真正没做的是另一件事:让没变过的内容不必重传。压缩解决“传得小一点”,条件请求解决“根本不用传”,后者收益上限高得多,而这个站一次都没拿到过。 补救不难:在应用层按文章的modified字段发一个Last-Modified,再拿进来的If-Modified-Since跟它比,相等就直接返回304并结束。十来行代码的事。 但有个前提得先想清楚:页面上有没有“每次刷新都会变”的东西。阅读量计数、随机推荐、相对时间显示,只要有一个,Last-Modified就不能只按修改时间算,否则爬虫会长期拿到陈旧版本。这也是很多站宁可不发这两个头的原因——不发只是浪费带宽,发错了是内容更新推不出去。 ## 这些问题有没有一套能重复跑的检查顺序? 核心就一句:别审配置写得对不对,去看边界输入下的响应长什么样。这轮实测里每个问题光读配置都发现不了,index index.html没错,return 301 /rss.xml没错,add_header Vary也没错。 ## 四类边界输入 第一类,不属于你的Host。只看状态码,200就是有问题,不管页面上印着什么,想要的答案是404或444。 curl -o /dev/null -w '%{http_code}\n' -H 'Host: not-my-domain.test' http://你的IP/ 第二类,不存在的路径,三种形态各来一次——带扩展名、带尾斜杠、都不带,三个都该是404。然后务必再看一眼响应体,这步最容易省掉,本文最离谱的那个问题就藏在这儿: curl -s https://站点/definitely-not-here-xyz.html \ | grep -oiE '[^<]*|name="robots"[^>]*|rel="canonical"[^>]*' 要求很简单:状态码和页面内容必须说同一件事。404页面出现index, follow或指向别处的canonical,就是坏的。 第三类,同一个资源的各种变形。健康的站这一组里只该出现两种状态码:正主200,其余全是301跳回正主。这台机器上就有不达标的: 请求 | 结果 | 说明 | /nginx-proxy.html | 200 | 正主 | //nginx-proxy.html | 301 → 正主 | 好 | /Nginx-Proxy.html | 301 → 正主 | 好 | /NGINX-PROXY.HTML | 404 | 不一致 | 同样是大小写变形,只改slug能被规范化,连扩展名一起改成大写就变404——路由是先按.html后缀切的,后缀变成.HTML就没切上,压根走不到规范化那段逻辑。这个404本身无害,但它暴露的是规范化逻辑有边界,而你不知道边界在哪。 第四类,每种Content-Type都请求一次,专抓add_header被遮蔽,顺带看出哪些类型没被压缩。 ## 三条能带走的判据 一、状态码和页面内容必须说同一件事。两者由不同的层产出——状态码归Web服务器或框架,页面内容归模板——所以完全可能各说各话。必须同时看,只看一个等于没查。 二、配置里写了,不等于线上发了。add_header的继承规则、gzip_types里那个不存在的MIME、写在server级却被location盖掉的头,都属于“配置文件是对的,响应是错的”。唯一可信的证据是curl -I的输出。 三、同一个资源在各种变形下,只该有一种正确状态码。多出来的200是重复内容,多出来的404是死链。这条不依赖任何具体软件,Apache、Caddy、各家CDN上同样适用。 ## 什么时候该怀疑接缝 本文所有问题都长在接缝上,而接缝有个共同特征:两边各自都有一套完整、自洽、并且是对的规则。这台机器上的接缝清单: - 面板放的index.html × 配置里的index指令 → 200的404页 - Nginx的伪静态 × 应用的404 × 模板的无条件canonical → 自称可索引的404 - server级的add_header × location级的add_header → 消失的响应头 - gzip的类型清单 × Brotli的类型清单 → 只有一半资源被压 - 文件名字母序 × include展开顺序 → 谁来兜底陌生域名 每一条里的两个东西,单独看都挑不出毛病。所以逐条审查配置这类办法,对这类问题天生无效——它们的错误不在任何一条里面。 能抓住它们的只有一种办法:把请求真的发出去,看回来的是什么。Nginx只有HTTP层面的判断力,它不知道什么叫“这个页面该不该被收录”;凡是SEO事故,在它看来都只是配置按你写的执行了而已。这层语义得由人补上,而补的方式不是读配置,是看响应。 四类输入不到二十条curl,扔进定时任务即可。日志侧的分析 (https://zhangwenbao.com/log-analyzer-crawl-budget-googlebot-guide.html)是另一条腿:日志告诉你爬虫撞上过什么,探测告诉你它接下来会撞上什么。 ⚡ 顺手一起用:可索引性一键体检 输入一个网址,把状态码、重定向链、robots判定、meta robots、X-Robots-Tag和canonical一次查完——本文第二类边界输入要手工比对的那几样它全给摆出来。 → 打开可索引性一键体检 (https://zhangwenbao.com/tools/indexability-checker.php) ## 常见问题解答 ## 我的服务器上只有一个站点,还需要管默认server吗? 需要,风险比多站点还高。单站点服务器上很多人根本不建默认server块,唯一那个站就成了默认——任何解析到这个IP的陌生域名,都会拿到你网站的完整首页内容,比本文那个138字节的假404严重得多。测法一样:拿不存在的Host请求你的IP,看返回的是不是你的首页。是的话,加一个带default_server、内容只有return 404;的兜底块。 ## 伪静态用if判断文件存在,和try_files比差在哪? try_files更好,if在Nginx里有一堆边界行为,官方也建议少用。但这里想提醒if (!-e)一个不出名的风险:它对目录同样返回真。网站根目录下如果有个目录名恰好和某篇文章的slug一样,那篇文章会永远打不开——请求被判定为“文件存在”,重写规则跳过,Nginx按目录去找首页,找不到就是403。没有报错,没有日志异常,就是打不开。 保哥在这台机器上查了一遍:根目录11个目录,1624篇文章,撞车0个。安全,但这个安全是运气。真正的防线是发新文章时把slug和根目录列表比一遍。 ## 404页面自称可索引,具体怎么改? 在模板里拿到当前HTTP状态码,据此决定输出什么:状态码是404时,robots输出noindex, follow,canonical整个不输出——不是指向首页,是不要这个标签。canonical指向首页等于告诉搜索引擎“这个URL和首页是同一个东西”,比不写更糟。顺手也该给那个304KB的404页面减减肥。 ## 一批已经跑了上万次的301,改成410会不会丢权重? 看这批URL有没有外链和历史排名。/amp/*这类由自己生成、从没有过外链的路径,改410没有任何损失;但如果某个老URL确实有外链指着,301是对的,那是把权重导过去的唯一手段。办法是把这批URL丢进外链工具查引荐域,有外链的保留301,没有的转410,别整批一刀切。 ## Vary既然经常名不副实,是不是干脆删掉更好? 不能删。删掉Vary,CDN就会把压缩过的响应发给不支持压缩的客户端,那是实打实的乱码事故,比缓存被摊薄严重得多。正确做法是在CDN那侧做Accept-Encoding归一化,把千奇百怪的取值收敛成两三种(支持br、只支持gzip、都不支持)再分桶。主流CDN都有这个能力,只是默认往往不开。 ## 权威参考资料 ## 老用户打不开的那个页面,做SEO的工具全都返回200,因为它们不带Cookie - URL:https://zhangwenbao.com/request-header-too-large-400-431-cookie-seo-blindspot.html - 分类:Nginx - 发布:2026-05-24 | 更新:2026-07-31 - 摘要:实测125站请求头阈值:最低6912字节,九种状态码里规范专设的431只占9.1%。被拒发生在应用之前,日志无UA、默认级别零记录,而Googlebot不带Cookie永远撞不到。 - 关键词:技术SEO,Nginx,HTTP状态码,Cookie > **TLDR**:摘要:有一类故障,用户那边是“这网站我打不开,换个浏览器就好了”,你这边全绿。保哥拿125个真实站点挨个试:往请求里塞Cookie,一点点加,看多大会被拒。99个站测出阈值,最低6912字节,最高过了64KB也不拒;同一件事各家返回了九种状态码,规范专门设的431只占9.1%。更麻烦的是拒绝发生在应用之前——PHP一行没跑,access_log那条记录的User-Agent是个横杠,error_log默认级别一个字都不写。而Googlebot不带Cookie,你手上所有SEO工具也不带,于是这道墙谁都撞不到,只有真实老用户天天撞。 > 摘要:有一类故障,用户那边是“这网站我打不开,换个浏览器就好了”,你这边全绿。保哥拿125个真实站点挨个试:往请求里塞Cookie,一点点加,看多大会被拒。99个站测出阈值,最低6912字节,最高过了64KB也不拒;同一件事各家返回了九种状态码,规范专门设的431只占9.1%。更麻烦的是拒绝发生在应用之前——PHP一行没跑,access_log那条记录的User-Agent是个横杠,error_log默认级别一个字都不写。而Googlebot不带Cookie,你手上所有SEO工具也不带,于是这道墙谁都撞不到,只有真实老用户天天撞。 先说一个场景。客服转来一条投诉:某个客户说商品页打不开,白屏,或者一个很丑的报错。你拿链接一点,好好的。让同事试,也好好的。爬虫工具跑全站,200满屏。你回一句“没复现”,客户回一句“我换了Edge就能进了”,然后这条工单就沉了。 这类工单在保哥经手过的站里出现频率不算低,而且有个共同特征:受害者都是老客户——注册过、下过单、领过券、被埋点追踪了大半年的那批人。新用户几乎不报。 原因藏在一个很少有人量过的地方:浏览器每发一个请求,都要把这个域名下攒的所有Cookie原样带上去。攒得够多,这个请求就会大到被服务器直接扔掉,而扔掉它的那一层,在你的应用代码启动之前就已经把事办完了。 ## 为什么受伤的总是回访最多的那批人? Cookie有个和别的存储都不一样的性质:它不是存在浏览器里等你去读,而是每一次请求都主动跟着发出去。localStorage存5MB也不影响任何一个请求的大小,Cookie多100字节,这个域名下之后每一个请求就都多100字节。 这个“每一次”包括了页面本身、每一张图、每一个JS、每一次接口调用。所以问题不是“存了多少”,而是“发了多少遍”。 那么它能攒到多大?规范这边给的答案,比大多数人猜的要大得多。RFC 6265在谈实现限制的那一节 (https://www.rfc-editor.org/rfc/rfc6265#section-6.1)里,对浏览器提的是最低要求: > At least 4096 bytes per cookie (as measured by the sum of the length of the cookie's name, value, and attributes). At least 50 cookies per domain. At least 3000 cookies total. 注意这三条的措辞是SHOULD provide,说的是浏览器至少要能存下这么多。每域50条、每条4096字节,乘一下是 204800字节。也就是说,一个完全守规矩的浏览器,可以合法地在一个域名下攒出200KB的Cookie,并且把它们塞进每一个请求。 而同一份文档紧接着补了一句给服务端的忠告: > Servers SHOULD use as few and as small cookies as possible to avoid reaching these implementation limits and to minimize network bandwidth due to the Cookie header being included in every request. 这句话很客气,但它暴露了一件事:规范规定了浏览器该能存多少,却从头到尾没规定服务器该能收多少。收多少是每个服务器软件、每个CDN、每个网关自己拍板的。于是这道墙的高度,全世界没有统一值。 ## 为什么新用户不报,老用户才报 Cookie是单调增长的。第一次访问可能只有一个会话ID,逛过几个分类页、看过几个商品、关过一次弹窗、被A/B分了个桶、被三四个第三方脚本各写一条,半年下来就是几十条。而它们的过期时间大多以年计——保哥这轮抓的241条真实Cookie里,有过期时间的占77.2%,剩余天数中位数31天,但有14.5%的过期时间在一年以上。 所以这是一条时间的单行道:越忠实的用户,攒得越满,越接近那道墙。而你自己测试时用的永远是刚清过缓存的干净浏览器,或者干脆是无痕窗口。你和你的用户,从来不在同一条起跑线上。 ## 那道墙到底卡在哪个数上? 先在自己能完全控制的环境里把机制弄清楚。保哥在一台服务器上另起了一个nginx 1.28.3实例,三个端口,唯一的区别是缓冲区配置: 端口 | 配置 | 说明 | 18745 | client_header_buffer_size 1k; large_client_header_buffers 4 8k; | 出厂默认值 | 18746 | large_client_header_buffers 2 4k; | 收紧 | 18747 | large_client_header_buffers 8 32k; | 放宽 | 上游挂一个PHP脚本,把收到的请求头条数和字节数原样回显出来,顺便打一行 PHP_REACHED=1——这一行的作用后面会用上。 然后往Cookie里灌数据,一档一档加: Cookie这一行的长度 | 18745(4 8k) | 18746(2 4k) | 18747(8 32k) | 3072字节 | 200 | 200 | 200 | 4096字节 | 200 | 400 | 200 | 8000字节 | 200 | 400 | 200 | 8192字节 | 400 | 400 | 200 | 24576字节 | 400 | 400 | 200 | 32768字节 | 400 | 400 | 400 | 二分下去,18745的精确分界是通过上界8000字节、拒绝下界8008字节。三个端口的临界值分别是8k、4k、32k,正好对上配置里 size 那个参数。 ## 那条指令里的两个数,只有一个管用 large_client_header_buffers 4 8k 长得很像“总共给你4个8KB,一共32KB”。保哥见过不止一个人这么理解,包括很多年前的自己。nginx文档对这条指令的说明 (https://nginx.org/en/docs/http/ngx_http_core_module.html#large_client_header_buffers)写得其实很清楚: > A request header field cannot exceed the size of one buffer as well, or the 400 (Bad Request) error is returned to the client. 关键在 a request header field——限制的对象是单个头字段,而不是所有头加起来。而Cookie恰恰是一个头字段,不管里面塞了多少个键值对,它永远只有一行。 为了确认这一点,保哥又做了一组对照:同样的总字节数,一次拆成每条20字节的小Cookie,一次拆成每条2000字节的大Cookie。 Cookie这一行的长度 | 拆成每条20字节 | 拆成每条2000字节 | 4096字节 | 200 | 200 | 8192字节 | 400 | 400 | 16384字节 | 400 | 400 | 阈值分毫不差。再把整个Cookie写成一个超长的键值对,结果还是8000通过、8192被拒。里面有多少个Cookie完全不影响判决,只有这一行有多长说了算。 所以 4 8k 里那个4,在Cookie这件事上一点用没有——它管的是“最多能有几个超大的头字段同时存在”。真正决定用户会不会撞墙的是后面那个8k。调错了参数,改完发现故障照旧,多半就栽在这儿。 ## 换成别的头,换成超长网址,结果一样吗? 不完全一样,而且差异有意思。保哥把同样的字节量换到Referer头上: Referer长度 | 18745(4 8k) | 18747(8 32k) | 2048字节 | 200 | 200 | 8192字节 | 400 | 200 | 32768字节 | 400 | 400 | 和Cookie走的是同一条规则、同一个数。这条限制对所有请求头一视同仁,Cookie只是最容易长胖的那一个。 但把长度放到网址里,返回的就不是400了: 网址长度 | 18745 | 18747 | 2048字节 | 200 | 200 | 8192字节 | 414 | 200 | 16384字节 | 414 | 200 | 414的语义是请求行太长,这也写在同一段文档里。同一个缓冲区尺寸,撑爆的位置不同,用户看到的状态码就不同——排查时如果只盯着400,遇到414会以为是另一回事。关于状态码本身怎么选、怎么影响收录,可以对照HTTP状态码这一篇里的取舍表 (https://zhangwenbao.com/http-status-codes-seo-atlas-redirect-410-decision.html)。 ## 现网125个站,这道墙分别修在了多高? 实验台的结论是干净的,但真实世界不是一台裸nginx。前面还有CDN、WAF、负载均衡,各家都有自己的一套。保哥拿125个真实站点的首页做了一轮阶梯探测:从2KB开始往上加Cookie,找到第一个被拒的档位,再二分三次收窄。 123个站基线返回200,其中 99个测出了明确的阈值,24个站塞到64KB都照收不误。 阈值上界 | 站点数 | 典型代表 | 8192字节 | 22 | zappos、uniqlo、shopee | 16384字节 | 21 | Netlify系、AWS ELB系 | 32768字节 | 24 | Vercel系、Cloudflare部分 | 40960字节 | 5 | sephora、mailchimp | 65536字节 | 9 | Google Frontend系 | 其余零散档位 | 18 | 6912到61440不等 | 超过64KB仍不拒 | 24 | shopify、moz、MDN、rfc-editor | 中位数落在16KB附近。回头看规范给浏览器的200KB合法空间:浏览器按规矩能存的量,是一半服务器能收的十几倍,而两边谁都没违规。这个缺口不是某个人配错了,是两份规范各说各的,中间没人对账。 ## 同一个平台,阈值出奇地整齐 把阈值按Server头分组之后,出现了一个很干净的现象: 平台 | 站点数 | 实测阈值 | Vercel | 9 | 全部32768 | Netlify | 8 | 全部16384 | AWS ELB | 4 | 全部16384 | Google Frontend | 3 | 全部65536 | Cloudflare | 22 | 8192到65536都有 | nginx | 19 | 8192到65536都有 | 托管平台是一个值,一个不差;而Cloudflare和nginx那两行散得到处都是。这不难解释——Cloudflare只是站在前面转发,真正先喊停的是它后面那台源站。所以“我用了CDN,这事归它管”这个想法要打个折:CDN的阈值只是上限之一,真实上限取两者里更小的那个。 ## 为什么同一件事,服务器们给出了九种不同的答案? 这是这轮普查最出乎意料的部分。99次拒绝,返回的状态码是这样分布的: 状态码 | 次数 | 占比 | 它本来的意思 | 400 | 50 | 50.5% | 请求有语法错误(笼统兜底) | 494 | 19 | 19.2% | 不在任何注册表里的自造码 | 431 | 9 | 9.1% | 规范专为这件事设的码 | 413 | 5 | 5.1% | 请求体太大(这里请求体是空的) | 直接断开连接 | 7 | 7.1% | 浏览器显示“无法访问此网站” | 502 / 503 / 520 / 500 | 7 | 7.1% | 服务端故障 | 403 | 2 | 2.0% | 没有权限 | 把这张表倒过来看更清楚:九成的情况下,用户和运维拿到的是一个不指向真实原因的信号。 ## 431才是那个正确答案,可它是少数派 RFC 6585第5节 (https://www.rfc-editor.org/rfc/rfc6585#section-5)专门为这件事分配了一个状态码: > The 431 status code indicates that the server is unwilling to process the request because its header fields are too large. The request MAY be resubmitted after reducing the size of the request header fields. 定义精准,还顺带告诉客户端该怎么自救。实测里用它的有ebay、walmart、target、paypal、netlify、render、fly.io、atlassian、bitbucket这9家。剩下九成的服务器,都在用一个更含糊的码说同一件事。 ## 那个494是从哪儿冒出来的 19个站返回494,配上一句 494: REQUEST_HEADER_TOO_LARGE。这个码不在IANA的注册表里,是nginx内部用来标记这类错误的自定义编号——正常情况下它会在输出前被转成400,能漏到客户端说明中间某一层把它直接透传了出来。 更有意思的是这19个站的阈值:一个不差,全是32768,Server头分别是Vercel、Cloudflare、CloudFront。名字不同,底下大概率是同一套东西。 ## Google自家的文档站返回了413 web.dev、developers.google.com、developer.chrome.com三个站,在Cookie超限时返回的是 413 Request Entity Too Large。 413说的是请求体太大——而这几次请求是GET,请求体一个字节都没有。写规范、办大会、到处布道HTTP语义的那家公司,自己的文档站在这件事上答错了题。保哥看到这三行的时候笑了一下,然后把它记了下来:连他们都懒得区分,说明这个码在实践中确实没人当回事。 ## 最难查的是那几个报5xx的 github.com是全场阈值最低的一个,6912字节就拒,而且返回的是 500 Internal Server Error,页面上写着GitHub的内部错误。运维看到500,第一反应一定是去翻应用日志、看数据库、查依赖服务——而真实原因是访客的Cookie太多了。 还有7个站干脆连响应都不给,TCP连接直接断开,浏览器显示“无法访问此网站”。这一档最狠:它长得跟断网、跟DNS故障、跟被墙一模一样。做海外站的话,这类现象会被顺手归到网络问题里去,可以对照独立站打不开的网络层排障路径 (https://zhangwenbao.com/overseas-store-unreachable-slow-network-layer-diagnosis-dns-routing-cdn.html)逐层排除。 ## 为什么你的日志里查不到这件事? 这才是它真正难缠的地方。保哥在实验台上发了一个40KB Cookie的请求,同时发了一个正常请求,然后去翻日志。 access_log里两行: 400 rl=53 proto=HTTP/1.1 ua=- req="GET /x HTTP/1.1" 200 rl=79 proto=HTTP/1.1 ua=D87-Normal req="GET /x HTTP/1.1" 第一行有三个坑: - ua=-:User-Agent是空的。因为超长的Cookie在解析队列里排在前面,nginx读到它就停手了,后面那些头压根没被解析。这条记录里没有任何能定位到人的信息。 - rl=53:请求长度记成了53字节。实际客户端发了4万多字节。日志里的这个数字是“成功读进缓冲区的那部分”,不是“对方发了多少”。如果你想靠请求长度筛出异常请求,恰恰筛不到出事的那些。 - PHP没有被执行:响应体里没有那行 PHP_REACHED=1。这意味着应用日志、APM、跑在应用层的WAF、埋点统计——全都不会有这次访问的任何记录。 ## error_log里有线索,但默认级别下它不写 唯一一条能说清原因的记录在error_log里: [info] client sent too long header line: "Cookie: c0=aaaa..." while reading client request headers, client: 127.0.0.1, request: "GET /x HTTP/1.1" 注意前面那个 [info]。保哥把error_log级别从info改回常见的error再跑一遍,结果是: error_log级别 | 这次拒绝写了几行 | info | 1行,含完整原因 | error(大多数生产配置) | 0行 | 也就是说,在绝大多数人的服务器上,这件事从头到尾不产生任何一条可读的日志。access_log里那行400没有UA、没有真实长度;error_log里什么都没有;应用侧完全不知情。这就是为什么它能在一个站上默默存在很久——日志分析那一套方法在这里整个失效,哪怕你已经把日志分析的流程跑得很熟 (https://zhangwenbao.com/server-log-file-analysis-seo-crawl-budget-bot-verification.html)也一样。 ## 顺带一提:这次拒绝还会切断连接 被拒的响应长这样: HTTP/1.1 400 Bad Request Content-Length: 233 Connection: close 233字节的错误页,外加一个 Connection: close。正常响应是 keep-alive。连接被关掉意味着这个用户后面的每一个请求都要重新建连、重新握手——如果他的Cookie已经超了,那就是每一个请求都建一次连、又被拒一次。体验上是整站瘫痪,而不是某个页面出错。 ## 请求经过好几层,到底是谁拒的? 真实链路上,请求要过CDN、过反代、才到应用服务器,每一层的阈值都不一样。保哥搭了个两层的最小模型:前置 8 16k(较宽),后端 4 8k(较窄),然后在前置那层加一个 X-Layer 响应头做标记。 Cookie长度 | 直连后端 | 经过前置 | 响应里有X-Layer吗 | 说明 | 4096字节 | 200 | 200 | 有 | 两层都放行 | 12288字节 | 400 | 400 | 有 | 前置放行了,后端拒的 | 40960字节 | 400 | 400 | 没有 | 前置自己拒的 | 两次都是一模一样的400页面,用户看不出任何区别。但响应头暴露了分界线:前置层自己拒绝时,它是在nginx内部直接生成错误响应的,压根没走到配置 add_header 的那段逻辑,所以标记消失了。 这给了一条很实用的排查线索:看你在边缘层加的那些自定义响应头还在不在,就知道请求走到哪儿被拦下来的。还在,说明是后面某一层拒的;没了,说明边缘层自己就是终点。 同一次请求在两层日志里的记录也对不上:前置那层记了 rl=12622,后端那层记了 rl=78——同一个请求,两份日志差了160倍,因为两层各自只记了自己读进去的那部分。做多层架构排查时,这个数字千万别当成真实请求大小来用;日志格式里到底该记什么、CDN后面怎么拿真实信息,可以参考访问日志格式配置里的字段取舍 (https://zhangwenbao.com/apache-customlog-logformat-access-log-configuration-remoteip.html)。 ## 边缘层还会把原因翻译成另一件事 现网普查里那几个5xx就是这么来的:CloudFront后面的站被拒之后,边缘层把它翻译成了 502 ERROR: The request could not be satisfied;Cloudflare后面的翻译成了 520 Web server is returning an unknown error。 这两句话都在说“我后面那台机器出问题了”,但它们后面那台机器其实什么事都没有,只是嫌请求头太长。一层一层传下来,原因就这么被洗成了另一个方向。 ## 同样的字节数,换个写法就放行了? 实验台上的结论很干脆:只看Cookie这一行有多长,拆成几个键值对不影响。但现网有11个站不是这样。 普查时每找到一个阈值,保哥都会再补一发:同样的字节数,但整个Cookie只写成一个键值对。结果这11个站——target、klaviyo、render、atlassian、bitbucket、cloudflare、hotjar、strapi、intercom、zapier、made-in-china——拆成多条被拒,写成一条却放行了。 这说明它们判的根本不是“这一行有多长”,而是别的东西,比如Cookie的条数。具体是什么只有它们自己知道,但对排查的启发是明确的:“我把Cookie总量控制在8KB以内就安全了”这个结论,在这11个站的架构下不成立。同一个总量,条数多几条就可能翻车。 ## 这件事在SEO上到底损失了什么? 看到这里可能会有个疑问:这不就是个用户体验问题吗,跟搜索有什么关系? 关系在于,这道墙对搜索引擎是完全隐形的。Google在讲A/B测试最佳实践的那份文档 (https://developers.google.com/search/docs/crawling-indexing/website-testing)里写得很直白: > Googlebot generally doesn't support cookies. This means it will only see the content version that's accessible to users with browsers that don't accept cookies. 不带Cookie,意味着Googlebot发出的请求头永远是最短的那一种。保哥拿两种形态在实验台上量了一下: 客户端形态 | 请求头条数 | 请求头字节数 | 真实Chrome(含Cookie、Client Hints、Sec-Fetch全家) | 14 | 2115 | Googlebot那种极简形态 | 4 | 240 | 差8.8倍。而且这240字节是不会增长的——爬虫不存Cookie,第一万次访问和第一次访问带的东西一模一样。它离8KB那道墙有34倍的余量,撞不到是必然的。 ## 你手上所有的测量工具,站的都是爬虫那一侧 把这条链捋一遍会有点不舒服: - Googlebot:不带Cookie,永远200,抓取报告里干干净净 - 各类SEO爬虫工具:默认不带Cookie,全站扫描零异常,连按UA区分客户端类型的那套办法 (https://zhangwenbao.com/crawler-identifier-user-agent-bot-verification-guide.html)在这里也帮不上忙 - 正常运行时间监控:定时发一个裸请求,永远绿灯 - PageSpeed之类的实验室测量:干净环境,测不出这件事 - 你本人:改完配置习惯性开无痕窗口验证,同样测不到 五个测量入口,没有一个带着真实用户那身家当。而真实老用户天天在撞,撞完只会换个浏览器,不会给你发工单。Google抓取报告里那几类问题URL (https://zhangwenbao.com/google-crawling-issues-url-mistakes-diagnosis.html)能查出的是另一批事,这一类天生不在它的视野里。 顺着这条思路还有一个更隐蔽的后果:如果你的站会给爬虫和用户返回不同的东西,那么爬虫拿到的那一版才是被收录的那一版,用户实际能不能打开是另一个独立事件。搜索结果里挂着一个排名不错的页面,点进去的老客户白屏——这个组合对转化的伤害,比排名掉几位要重得多。 ## Cookie是从哪儿长起来的 保哥用不执行JavaScript的客户端跑了一轮125个站,逛首页加三个内页,结果是中位数0字节,逛完四页的增量中位数也是0。当时差点得出“现网Cookie根本不大”的结论。 然后用真实Chrome打开同一批站里的sephora.com,读了一下 document.cookie:41条、4176字节。同一个站,纯HTTP客户端只量到20条、2513字节。 而 document.cookie 还读不到HttpOnly那部分——真实发出去的Cookie头只会比4176更长。差距全在JavaScript上:统计脚本、广告像素、AB测试、客服挂件、同意管理平台,这些都是页面加载之后由脚本写进去的,不执行JS就一条都看不见。 这也解释了为什么第三方脚本装得越多的站越危险,以及为什么同意横幅那一套合规组件 (https://zhangwenbao.com/seo-legal-compliance-gdpr-ccpa-consent-mode-cross-border-architecture.html)会成为一个容易被忽略的贡献者——它本身就要写好几条Cookie来记住用户的选择。 ## 怎么查、怎么改,一份可执行清单 ## 第一步:十分钟确认自己站的墙在哪 不需要任何工具,一条curl就够。把Cookie从4KB一路加到64KB,看哪一档开始变脸: for n in 4096 8192 16384 32768 65536; do CK=$(python3 -c "print('; '.join('c%d=%s'%(i,'a'*240) for i in range($n//250)))") echo -n "$n -> " curl -s -o /dev/null -w '%{http_code}\n' -H "Cookie: $CK" https://你的域名/ done 手头没有curl的话,用在线的请求测试工具 (https://zhangwenbao.com/api-tester-http-status-header-rest-debug-seo-guide.html)自定义一个超长Cookie头也能达到同样效果,看的就是那个状态码。 三种结果对应三种处置: 结果 | 含义 | 该做什么 | 8192就变脸 | 裸默认配置,风险最高 | 立刻放宽到16k以上,同时查Cookie体积 | 16384或32768变脸 | 平台默认值,中等 | 把Cookie体积压到4KB以内即可 | 64KB仍不拒 | 宽松 | 墙这边没事,但字节还在天天付 | ## 第二步:把墙修高,但别指望这一步解决问题 各家的参数不一样,常用的这几个: 软件 | 参数 | 出厂值 | nginx | large_client_header_buffers 的第二个数 | 8k | Apache | LimitRequestFieldSize (https://httpd.apache.org/docs/2.4/mod/core.html#limitrequestfieldsize) | 8190 | Tomcat | maxHttpHeaderSize | 8192 | Node / Express | --max-http-header-size | 16384 | 注意两点。一是反向代理链上每一层都要改,只改最外面那层没用——前面那个X-Layer实验已经演示过,改宽了前置,后端照样在原来的位置拒绝。二是放宽是有代价的:每个连接的头缓冲区变大,并发高的时候内存占用跟着涨,而且它同时也放宽了针对头部的攻击面。改到16k或32k是合理的,改到1M就是另一种事故了。多层代理各段怎么串,可以对照反向代理配置里的分段说明 (https://zhangwenbao.com/nginx-proxy.html)。 ## 第三步:真正该做的是给Cookie减肥 墙修高只是把撞墙时间往后推。按收益从高到低排: - 把大对象从Cookie里搬走。购物车快照、用户偏好JSON、上次浏览的商品列表——这些应该放服务端会话或localStorage,Cookie里只留一个ID。这一条通常能砍掉一多半。 - 盘一遍第三方脚本各写了多少。装一个删一个的成本很低,收益却是永久的——那条Cookie之后每一个请求都不用再带。 - 收窄作用域。写在顶级域上的Cookie,所有子域的请求都要带,包括静态资源域和接口域。能限定到具体子域和路径的,就别写在顶级域上。作用域这件事本身还有别的连带影响,一张自家子域上的图片能把主站登录态删空 (https://zhangwenbao.com/clear-site-data-cookies-storage-scope-logout.html)那篇里有更完整的边界拆解。 - 给过期时间设个上限。前面那14.5%过期时间超过一年的Cookie,绝大多数并不需要活那么久。 - 静态资源换一个不带Cookie的域名。老办法,但在这件事上依然最有效——图片和脚本本来就不需要认识用户。 ## 保哥拿自己的站量了一遍 顺手把本站也走了一遍流程,结果分三块: 检查项 | 结果 | 判断 | Cookie阈值 | 49152字节仍200,65536时连接断开 | 墙修得很高,风险低 | 首访下发的Cookie | 一条都没有(无Set-Cookie) | 访客侧零负担 | 响应头里的Vary | 只有 Accept-Encoding | 没有多余分片 | 这个结果说白了是内容站的红利:没有登录、没有购物车、没挂几个第三方脚本,Cookie自然长不起来。但也正因如此,保哥这套站压根测不出这个问题——真要验证,只能自己造一个大Cookie打过去。这恰好又印证了前面那句:能不能测到,取决于你手里那个客户端像不像真实用户,而不是取决于你多认真。 ## 常见问题解答 ## 用户说“清一下缓存就好了”,是不是就说明是这个问题? 清缓存这个说法通常包含了清Cookie,所以它是一个有效信号,但不是充分证据。更准的判据有三条:一是换个从没访问过这个站的浏览器立刻恢复,二是无痕窗口正常、正常窗口白屏,三是整站所有页面都打不开而不是某一个页面。三条同时成立,基本可以锁定。让用户按F12打开开发者工具看一眼那个失败请求的状态码,是400、431、494里的任何一个都能坐实。 ## 把large_client_header_buffers调到很大,是不是就一劳永逸了? 不是,有三个原因。第一,链路上每一层都有自己的限制,改了源站不改CDN等于没改,反过来也一样。第二,Cookie是单调增长的,你调到32k,用户接着攒,两年后照样撞上。第三,放宽缓冲区会同时放大内存开销和攻击面。合理做法是把墙修到16k到32k这个区间当作保险,然后把真正的力气花在压缩Cookie体积上。 ## 为什么GSC和爬虫工具一个都没报,问题却真实存在? 因为它们发的请求里没有Cookie。Googlebot的官方说明写着它一般不支持Cookie,各类SEO爬虫默认也不带会话状态,正常运行时间监控发的更是裸请求。这道墙只对“带着一身Cookie的老用户”生效,而这类客户端在你所有的测量工具里一个都没有。要想测到,只能自己构造带大Cookie的请求主动去撞。 ## 431和400有什么实际区别,值得为它改配置吗? 对用户体验没区别,对排查效率区别很大。431一眼就能定位到原因,400得靠猜。如果你的服务器支持自定义错误响应,把这一类错误的错误页写清楚(比如提示“请清除本站Cookie后重试”)比改状态码更实用——毕竟看到这个页面的是普通用户,他不认识431,但他认识“清除Cookie”这四个字。错误页怎么配才不踩坑,自定义错误页那一篇 (https://zhangwenbao.com/apache-errordocument-custom-error-pages-http-status-codes-soft-404.html)里有完整的配置路径。 ## 这件事会不会影响收录或者排名? 直接影响没有——Googlebot不带Cookie,它抓到的永远是正常页面,索引和排名不会因此变化。但间接损失是实打实的:搜索结果里排在前面的那个页面,老客户点进去打不开,跳出率、转化率、复购全部受影响,而这些行为数据长期看会反过来影响表现。更麻烦的是归因——你在报表上看到的是“某批用户转化率突然下降”,很难联想到是请求头太长。 ## HTTP/2和HTTP/1.1在这件事上表现一样吗? 不一样,而且差得不小。实测同一台nginx、同一份配置、同一个40000字节的Cookie:HTTP/1.1返回400,HTTP/2直接放行返回200。因为 large_client_header_buffers 这条限制管的是HTTP/1.x那套解析路径,HTTP/2的头部走的是另一套机制。 这带来一个很别扭的后果:同一个用户,协议协商成HTTP/2时一切正常,某次降级回HTTP/1.1就打不开了,而降级在弱网、老旧中间设备、某些企业代理后面时有发生。用户的描述会是“有时候能开有时候不能开”,排查时通常被归到网络抖动那一栏。 ## 权威参考资料 ## 白名单上明明写着那个域名,浏览器还是把它拦了,而违规报告指着的正是它 - URL:https://zhangwenbao.com/csp-script-src-allowlist-drift-third-party-silent-block.html - 分类:Nginx - 发布:2025-12-31 | 更新:2026-07-29 - 摘要:实测CSP的script-src白名单:第三方换域名或中途跳转后组件被静默拦下,违规报告给出的却是白名单里那个合法域名。含25组判决矩阵、上报投递率对照与149站白名单体检。 - 关键词:响应头,第三方脚本,故障排查 > **TLDR**:摘要:响应头里那份script-src第三方域名白名单,记录的是你写下它那一天、第三方所使用的域名。第三方换域名、加边缘节点、被收购改名、在链路中间加一次跳转,都不会通知你,清单于是从写下的第一天起就开始过期。过期的后果是组件静默消失:页面返回200,服务端日志干干净净,前端监控收到一个没有任何信息的空事件,而按官方推荐写法配好的违规上报,实测一份报文都收不到。 本文用四主机名双端口的本地HTTPS实验台跑了25组判决矩阵,把漂移的每一种形态、失败的每一种姿势、上报的每一种写法逐条测了一遍,再对149个站的首页做了一次白名单体检:53个站在发这行头,白名单里840个具体主机名有46个已经解析不出来,其中包括早已关停的分享服务、被收购后连公司都没了的加速厂商,以及5个被当成域名写进去的浏览器扩展ID。 > 摘要:响应头里那份script-src第三方域名白名单,记录的是你写下它那一天、第三方所使用的域名。第三方换域名、加边缘节点、被收购改名、在链路中间加一次跳转,都不会通知你,清单于是从写下的第一天起就开始过期。过期的后果是组件静默消失:页面返回200,服务端日志干干净净,前端监控收到一个没有任何信息的空事件,而按官方推荐写法配好的违规上报,实测一份报文都收不到。 本文用四主机名双端口的本地HTTPS实验台跑了25组判决矩阵,把漂移的每一种形态、失败的每一种姿势、上报的每一种写法逐条测了一遍,再对149个站的首页做了一次白名单体检:53个站在发这行头,白名单里840个具体主机名有46个已经解析不出来,其中包括早已关停的分享服务、被收购后连公司都没了的加速厂商,以及5个被当成域名写进去的浏览器扩展ID。 ## 一份第三方白名单,是从哪一天开始过期的? 故障是从客服工单开始的。用户说结账页上的分期付款选项不见了,换个浏览器也不见。运维查服务器,那个页面全程返回200,访问日志里连一条4xx都没有;后端查订单表,走另一条支付通道的单子照常进来,只是分期那条通道两天没有一笔。这类“某个支付选项静静消失”的故障不会被算进任何一份弃单分析里,结账页放弃率的9个真实成因 (https://zhangwenbao.com/dtc-checkout-abandonment-9-real-causes.html)那份诊断清单里的九种,也都默认页面是完整渲染出来的。 真正的原因写在响应头里。这一层向来是改一行影响全站的地方,HTTP响应头的SEO机制 (https://zhangwenbao.com/http-response-headers-seo-x-robots-cache-vary-canonical-mechanism.html)那篇按字段整理过一轮,而这次的肇事者是三周前一次安全加固留下的:script-src后面跟着一串第三方域名,其中就有那家分期服务商。三周里没人动过这行配置。动的是对方——他们把脚本从主域名迁到了一个新的加速域名上,发了邮件通知,收件人是对接的技术负责人,那封邮件里也没有出现“CSP”这三个字母。 这类故障的共同形状是:配置没变,环境变了。白名单是一份静态清单,它记录的是某年某月第三方所使用的域名;而第三方的域名是活的,会迁移、会加节点、会因为公司被收购而整体换掉。清单不会自己跟上,也没有任何机制在它落后时提醒你。 ## 四种漂移形态,都不会经过你的审批 为了把每一种形态单独测清楚,保哥在本地起了一套真实的HTTPS实验台:四个主机名跑在同一个进程上,按Host头分流——top是顶层站点,a是写进白名单的那个第三方域名,b是漂移之后的新域名,s1.a是a的子域;另外把同一份证书挂在两个端口上,用来单独测端口这一项。证书自签,浏览器忽略证书错误,但连接本身是真的,走的是完整网络栈。 > 这一步不能省。上一批做Clear-Site-Data时试过用测试框架的路由拦截伪造响应头,结论整组作废——响应头触发浏览器行为这类实验,必须让请求真的从网络栈上走一遍。相关的坑在登出清数据那篇 (https://zhangwenbao.com/clear-site-data-cookies-storage-scope-logout.html)里写过一次。 把资源指向不同的主机名,白名单一个字不改,四种漂移形态的结果如下。两个浏览器内核跑出来的判决完全一致,除了标注的那一项。 漂移形态 | 白名单里写的 | 资源实际在 | 判决 | 基线(没有漂移) | a主机名 | a主机名 | 加载 | 第三方换了域名 | a主机名 | b主机名 | 拦下 | 链路中间加了跳转 | a主机名 | a跳转到b | 拦下 | 第三方挪到了子域 | a主机名 | s1.a子域 | 拦下 | 第三方启用了新端口 | a主机名,端口18443 | a主机名,端口18444 | 拦下 | 四种形态里,只有第一种是运维自己能预料到的。后面三种发生在第三方那一侧,不需要通知你,通常也确实不会通知——加一个边缘节点、给某个区域启用独立子域、把回源地址换个端口,这些在对方的运维视角里是常规操作,跟他们的客户站点的响应头没有任何关联。 ## 失败的姿势为什么这么难被发现 更麻烦的是失败之后什么都不会发生。被拦下的脚本不会执行,于是它本该渲染的那块区域保持空白;页面其他部分完全正常,用户看到的是一个“少了点什么”的页面,而不是一个报错页。这种页面在、内容不在的白屏最难查,记事本BOM导致网页白屏那份排查指南 (https://zhangwenbao.com/notepad-edit-saved-code-generate-bom-resulting-web-page-error-white-screen-solution.html)里五种破坏现场的共同点也是这个——浏览器不报错,只是少做了一件事。框架祖先策略那篇 (https://zhangwenbao.com/csp-frame-ancestors-blocked-render-not-request.html)里记过同一种形状:服务器全程200、订单已入库,用户眼前那块区域自始至终是空白。 三个本该报警的地方同时哑火。服务端不知道——拦截发生在浏览器里,请求根本没发出去。前端监控不知道——原因下一节展开,简单说就是它收到的错误事件被剥得只剩一个布尔值。违规上报不知道——按官方文档推荐的写法配置,实测一份报文都收不到,这一节留到后面单独说。 于是唯一的信号来源是用户投诉,而用户投诉的措辞永远是“结账页有问题”,不会是“某某域名的脚本被策略拦了”。工单从客服流到前端,前端看页面没报错,流到后端,后端看接口全通,最后才有人想起去看响应头。这条路径平均要走多久,取决于那个组件在多少人的视野里——如果它是收银台,几个小时;如果它是评价星标或者推荐位,可以躺上好几周。把这条路径缩短的办法之一是让客服侧的描述更可用,把工单沉淀成帮助中心内容那份协作账本 (https://zhangwenbao.com/customer-service-seo-collaboration-7-actions-tickets-help-center.html)里的第一个动作,就是给工单加上可复现的字段。 ## 报告里指着白名单上那个域名,是浏览器搞错了吗? 漂移的四种形态里,“链路中间加了跳转”这一种最值得单独拆开讲,因为它会给排查的人一份自相矛盾的证据。 场景是这样的:白名单里写着a主机名,页面上的脚本标签指向的也是a主机名,但a收到请求后返回一个302,把浏览器指向b。最终被执行的脚本来自b,而b不在白名单里,所以浏览器拦下了它——这一步符合直觉。不符合直觉的是它怎么报这件事。 ## 同一次拦截,两个地方说的不是同一个域名 监听securitypolicyviolation事件,拿到的blockedURI字段是a主机名的那个原始地址,也就是白名单里明明白白写着的那一个。而浏览器控制台里同一次拦截打出的错误信息,说的是b的地址。两个引擎在这一点上表现一致。 这意味着什么?如果排查的人依赖上报系统或者页面内的事件监听,他拿到的报告会指着一个白名单里已经写了的域名,说它违反了策略。合乎逻辑的下一步是核对配置——配置没问题;再核对拼写——拼写也没问题。除非有人打开控制台肉眼看那行红字,否则真正的肇事域名b不会出现在任何一份自动化的证据里。 这不是缺陷,是规范里写死的隐私设计。W3C的CSP规范在这一步专门留了一句注解: > We use request's url, and not its current url, as the latter might contain information about redirect targets to which the page MUST NOT be given access. 页面无权知道跳转的终点在哪里——否则任何一个页面都能靠加载一个跨源资源、读违规报告,来探测别人的重定向规则。为了堵住这个信息泄露,报告里只能给出请求发起时的那个地址。安全上完全正确,排查上就成了一份指向错误的证词。 ## 文档没告诉你这件事 顺手查了一下开发者手册里blockedURI这个属性的说明,原文只有一句:表示被阻止的那个资源的地址。没有提重定向,没有提它在重定向之后指的是发起地址而不是终点。规范里写了原因,属性文档里没有转述——而排查的人第一时间会去看的正是属性文档。 这条经验可以推广:凡是一个字段在正常路径和异常路径下含义不同,文档只讲正常路径的那一半,异常路径就是排查的黑洞。跳转在第三方资源里太常见了,负载均衡、区域调度、灰度切换,全都靠302实现,所以这个“异常路径”其实是日常路径。 ## 为什么前端监控一条都没报上来? 大部分团队的前端监控是这么接的:挂一个全局错误处理函数,把捕获到的错误连同堆栈发回自己的接口。这套东西对付脚本内部的报错很称职,对付被策略拦下的脚本,交出来的是一份空白笔录。 实验台上把五种加载方式各测了一遍,被拦之后各自的表现是这样的。 加载方式 | 被拦后的表现 | 能不能拿到被拦的域名 | 脚本标签的onerror | 触发了,但事件对象上只剩一个isTrusted属性,消息是空字符串 | 拿不到 | 全局onerror处理函数 | 根本不触发 | 拿不到 | fetch请求 | 抛TypeError,消息是Failed to fetch(另一个引擎写作NetworkError when attempting to fetch resource) | 拿不到 | XHR请求 | 走onerror分支,状态码是0 | 拿不到 | 图片标签 | 走onerror分支,自然宽度为0 | 拿不到 | 安全策略违规事件 | 字段完整,含指令名、策略原文、处置方式 | 拿得到 | 前五行的表现,跟域名解析失败、连接超时、返回404、跨源检查不通过,是一模一样的。fetch那一行尤其要命:Failed to fetch这句话在网络断了的时候会出现,在跨源响应头缺失的时候会出现,在被策略拦下的时候也会出现,三种原因、一句话。集成方的代码通常在这里写一句“网络异常,请稍后重试”,于是一个配置问题被伪装成了网络问题,用户重试一百次也不会好。 上一批做权限策略的时候撞到过同构的一条:摄像头被策略禁掉时抛出的错误名,跟用户手动点了“拒绝”时抛出的错误名完全相同。那篇文章 (https://zhangwenbao.com/permissions-policy-iframe-third-party-widget-silent-denial.html)里把它总结成“把配置问题伪装成用户行为问题”。这一批是同一个毛病换了个位置:把配置问题伪装成网络问题。 ## 唯一说真话的那个接口,默认没人在听 表格最后一行是唯一的例外。浏览器在拦下资源时会派发一个专门的违规事件,字段相当齐全:被拦地址、生效的指令名、完整的策略原文、是拦截还是仅上报、来源文件和行号。想拿到它只需要一行监听代码。 问题在于这行代码不在任何一个监控SDK的默认配置里。它不是标准错误流的一部分,是一个独立的事件类型,名字也跟错误不沾边。结果是:真相一直在往外播,而全场没有一个接收器调到那个频道。 这里可以补的动作很朴素:在现有的监控初始化代码里加一条监听,把被拦地址和指令名一起送回自己的接口。它跟你已有的错误上报走同一条管道,不需要额外的基础设施,只是要有人想到去加。做前端和做运维的人之间,这类“谁也没觉得该自己加”的缝隙特别多,跟后端对SEO动作点的那份账本 (https://zhangwenbao.com/backend-engineer-seo-collaboration-7-actions-canonical-sitemap-redirect.html)里讲过怎么把这种缝隙变成一条有主人的清单。 ## 按官方推荐把两个上报指令都写上,为什么一份报文都收不到? 策略本身带上报能力:在策略里写一个上报地址,浏览器每拦一次就往那个地址投一份JSON。听上去这就是解决前一节问题的正解——不用改前端代码,运维在响应头里加一句就行。 老写法是report-uri,直接跟一个地址。新写法是report-to,跟一个端点组的名字,具体地址另外用一个响应头声明。官方文档明确说老的已经废弃,并给出了迁移期的推荐做法: > The report-to directive is intended to replace report-uri, and in browsers that support report-to, the report-uri directive is ignored. […] However until report-to is broadly supported you can specify both headers as shown. 两个都写,新浏览器走新的,老浏览器走老的,看起来滴水不漏。实验台上把四种写法各跑一遍,接收端就是实验台自己的一个接口,页面加载后等90秒、关掉页面、再开一个同源页面重新导航——把规范里所有可能触发投递的时机都走了一遍。结果如下。 策略里的写法 | 额外发的端点声明头 | 90秒内收到的报文 | 只写report-uri | 不需要 | 1份,10秒内到达 | 只写report-to | Reporting-Endpoints | 0份 | 只写report-to | Report-To(旧的JSON格式) | 0份 | 两个都写 | Reporting-Endpoints | 0份 | 第一行是阳性锚点,它证明接收端、证书、网络路径全都正常——同一个接口,换成老指令就能秒收。第二第三行说明新机制在这个环境里确实投不出来。第四行才是真正要命的:单写老指令能收到,两个都写就一份也收不到。 原因文档里已经写了,只是没人会把那句话读成一个警告:支持新指令的浏览器会忽略老指令。而支持不等于送得到——它认得这个指令、于是把老指令关掉,然后自己一份也没投出来。两个都写不是双保险,是让新的那个把老的那个挤下去,再一起沉默。 ## 零告警被读成零风险 这条链路上最贵的不是那几份丢掉的报文,是它给人的错觉。上线一套新策略的标准流程是先用仅上报模式观察一段时间,看看会拦掉什么,确认没有误伤再切成拦截模式。如果观察期收到的是零报文,得出的结论就是“没有任何违规,可以放心切换”。 实验台上单独验过:仅上报模式本身工作正常——那一组里被拦的脚本确实照常执行了,说明“只观察不拦截”这一半是对的。错的是另一半,观察的结果压根没送出来。上一批做权限策略时也遇到过完全相同的局面,四种上报写法全是零报文,而同一个页面、同一个接收地址,老机制立刻收到一份格式完整的报文。两批下来的结论一致:这条观察链路默认是断的,而它断掉的方式恰好是返回一个看起来最令人安心的数字。 可以做的事有两件。一是别只信上报,观察期同时用真实浏览器把主要页面跑一遍,直接读控制台,做技术栈检测那类工具 (https://zhangwenbao.com/seo-website-technology-stack.html)时用的也是同一套思路——眼见的证据比等着别人送上门的证据可靠。二是给上报通路自带一个阳性锚点:随便找个页面故意触发一次必定违规的加载,如果连它都收不到报文,说明问题在通路而不在策略。 ## 白名单的匹配规则,和你以为的一样吗? 前面几节讲的是清单跟不上变化。这一节讲另一半:就算第三方一动没动,清单本身也可能从写下的那一刻就是错的——因为写清单的人对匹配规则的理解,和浏览器的实现之间有几处系统性的错位。 五组单变量实验,每组只改白名单里的一个部分,资源那边一个字不动。 白名单里写的 | 资源实际在 | 判决 | 写清单的人通常以为 | 星号加点加根域 | 根域本身,不带子域前缀 | 拦下 | 会覆盖根域 | 星号加点加根域 | 两层子域,比如s1.a那种 | 放行 | 只覆盖一层 | 裸的主机名,不带星号 | 它的子域 | 拦下 | 可能覆盖子域 | 主机名不写端口 | 非默认端口 | 拦下 | 端口无所谓 | 协议写成http | 同一主机名的https | 引擎之间结论相反 | 会自动升级匹配 | 前三行合起来是同一个知识点的两面:星号管子域不管自己,不带星号管自己不管子域。想把根域和它下面所有子域一起放行,必须两条都写。这条规则本身没什么争议,但它和证书里的通配符规则长得很像、行为却不一样——证书里的通配符只能覆盖一层,这里的通配符能跨任意层。习惯了申请证书的人按证书的心智模型来读这份清单,会在两个方向上同时判断错。 ## 端口和协议这两栏,最容易在迁移里留下地雷 第四行的坑在于省略。写清单时只写主机名不写端口,读起来像是“这个主机名下随便哪个端口都行”,实际含义是“这个协议的默认端口”。平时看不出来,因为大家都用默认端口;一旦第三方给某个服务启用了独立端口,白名单里那条早就写好的记录就不再匹配了。 第五行是本批唯一一处两个引擎结论相反的地方。白名单里留着http://开头的条目、资源实际走https://,一个引擎拦下,另一个放行。规范里确实有协议升级匹配的规则,但它是成对生效的,原文举的例子是: > the source expression http://example.com:80 will match both http://example.com:80 and https://example.com:443. 协议和端口一起从不安全的那一组升到安全的那一组。如果条目上带的是一个非默认端口,这个升级配对就不成立,严格实现的引擎会判不匹配,宽松实现的引擎照放。这意味着同一份白名单,在两个浏览器上能得出两种结果,而“我在浏览器上验过了”这句话在这里失去意义。 这一栏的现实场景非常具体:站点从http迁到https的时候,页面里的资源地址都会被顺手改掉,响应头里那份白名单常常留在原地。全站迁HTTPS那篇 (https://zhangwenbao.com/seo-https.html)列过一份迁移清单,这一条值得补进去——迁移检查表里通常有跳转、有站点地图、有内部链接,很少有人想到还有一行响应头。 ## 加上strict-dynamic,能不能一劳永逸? 被白名单折磨过几次的团队,迟早会查到官方推荐的另一条路:不再逐个列域名,改成给页面里可信的脚本发一个一次性随机值,再加一个关键字,让这些可信脚本动态加载的东西自动获得同样的信任。这条路确实能解掉一大类问题——第三方脚本自己再去拉别的资源,不用你事先知道它会拉什么。 实验台上把这一组测清楚了:不加那个关键字时,第三方脚本a自己动态插入的第二个脚本b被拦下;加上之后,b顺利执行。到这里都符合预期。 ## 代价写在同一段文档里,但排在下一屏 问题出在同一组实验的另一半:加上那个关键字之后,白名单里的a本身,通过页面上一个普通脚本标签加载,反而被拦了。控制台打出的错误信息里,被违反的策略原文明明白白列着a的地址。 官方文档写得很清楚,只是这句话排在关键字说明的后半段: > If this keyword is present in a directive, then the following source expression values are all ignored: host-source, scheme-source, 'self', 'unsafe-inline'. 也就是说,一旦启用这个关键字,你辛苦维护了两年的那份域名清单,连同'self',全部作废。策略从“列举可信域名”整个换轨成“标记可信脚本”,两套机制不叠加,后者直接顶掉前者。 这在设计上完全合理——既然信任由随机值传递,域名清单就是多余的,留着反而会被绕过。危险的是升级路径:运维看到“能解决动态加载问题”,把关键字加进现有策略、白名单原样保留,测试页面上主流程一切正常(因为主流程的脚本大多是带随机值的内联或首方脚本),而所有靠白名单放行的第三方组件在这一刻集体停摆。而且这次连改动量都很小——就多了一个单词。 ## 顺便说,那份白名单的安全收益本来就没你以为的高 既然聊到取舍,有一份数据值得摆出来。Google的安全团队分析过约168万个主机上的2.6万条策略,结论相当难看: > 94.68% of policies that attempt to limit script execution are ineffective. […] 14 out of the 15 domains most commonly whitelisted for loading scripts contain unsafe endpoints; as a consequence, 75.81% of distinct policies use script whitelists that allow attackers to bypass CSP. 最常被写进白名单的那15个域名里,14个含有可以被利用绕过策略的端点——这些域名恰恰是标签管理、广告、统计这类几乎人人都会加的服务。官方开发者文档也用了很直白的措辞,说基于域名清单的策略在大多数配置下可以被绕过。 把这两件事放在一起看,白名单这套东西的账是这样的:安全上的收益接近于零,运维上的成本却是持续的,而且成本以线上故障的形式支付。这不是说立刻该把它全删了——它对付一部分注入场景仍然有用,也常常是合规检查表上的硬指标——而是说,如果你正在为维护这份清单投入人力,投入产出比值得重新算一次。 ## 运维加的那条“更严”的响应头,做了什么? 还有一类事故跟第三方完全无关,是自己人造成的。 这类事故在别的安全响应头上也发生过。开启HSTS那篇实战 (https://zhangwenbao.com/https-hsts.html)之所以要准备两条回滚路径,就是因为响应头一旦发出去、被浏览器记住,改回来比加上去难得多。 场景很常见:应用层已经在发一条策略了,是前端同学随着组件一个个接入慢慢攒起来的。某次安全审计之后,运维在接入层加了一条自己的策略,内容更规范、更严格——不是替换,是新增一行。两条策略从此并存在同一个响应里。 ## 两条策略是交集,不是并集 实验结果没有悬念,但值得看清楚形状。 第一条策略允许 | 第二条策略允许 | 资源在 | 判决 | a主机名 | b主机名 | a | 拦下 | a主机名 | b主机名 | b | 拦下 | a主机名 | 全部放行 | a | 放行 | a主机名 | a主机名 | a | 放行 | 前两行说明了一切:一个资源必须同时通过每一条策略才能加载,两条策略如果各自放行不同的域名,交集为空,两边的域名全部死掉。官方文档的措辞是,追加的策略只能进一步收紧被保护资源的能力。 运维那条策略在自己看来完全正确——它列的域名一个不多一个不少,是审计报告要求的那些。前端那条策略在自己看来也完全正确。两条都对,合起来把站点打残了。 ## 控制台指着的是第二条,查配置的人盯着第一条 排查这类问题还有一个额外的绊子:控制台里报出来的策略原文,是拦下这次加载的那一条,也就是后加的那条。而查问题的人第一反应是去看应用里那份自己熟悉的策略——那份策略里域名写得好好的,怎么看都没错。两边对不上,很容易得出“浏览器有bug”的结论。 快速判据只有一条:直接看原始响应头,数一数Content-Security-Policy这个字段名出现了几次。抓包工具和浏览器的网络面板都会把同名字段合并显示,看起来像一条用逗号连起来的长串,这时候要留意逗号——策略内部用分号分隔指令,逗号出现在顶层就意味着这是两条独立的策略被合并展示了。用命令行工具直接看原始响应头最稳妥,这类响应头层面的排查动作,服务器配置那份清单 (https://zhangwenbao.com/website-server-configurations-seo-impact.html)里整理过一批。 顺带一提,接入层加响应头这个动作本身还有个老毛病:很多接入层的响应头指令在有多个配置块的时候不会继承,改了外层不生效、或者内层把外层整个覆盖掉。Nginx那篇拦爬虫的文章 (https://zhangwenbao.com/nginx-ai-bot-blocking-rate-limit-rdns-misblock-account.html)里记过同一个坑的另一个版本,判断方法一样:不看配置文件,看真实响应。 ## 现网这些白名单,实际长成什么样? 实验台上的结论要落到现实里,得知道现实是什么样。这一轮抓了149个站的首页,全程只发GET、不登录、不提交任何东西,样本包括DTC品牌站、支付与结账服务商、几个大型零售站,以及一批做对照的非电商站点。 ## 覆盖率与写法分布 项目 | 站数 | 首页发拦截模式的策略 | 53个(35%) | 首页发仅上报模式的策略 | 7个,其中3个只发仅上报不发拦截 | 写在页面meta标签里 | 12个 | 同一个响应里发了两条策略 | 1个 | 策略里写了老的上报指令 | 16个 | 策略里写了新的上报指令 | 6个 | 两个上报指令都写了 | 6个 | 一个上报指令都没写 | 37个 | 最后三行连起来读才有意思。发了策略的53个站里,37个压根没配上报——它们对自己拦掉了什么完全没有可见性。剩下16个配了的里面,有6个用的正是官方推荐的“两个都写”,而这一节前面的实验已经证明这种写法在支持新指令的浏览器上一份报文都收不到。换句话说,53个站里真正能收到违规报告的,是那10个只写了老指令、没跟着文档去升级的。照文档做了升级的那6个站里,有支付服务商,也有跨境收款平台。 ## 清单有多长,又有多少条是活的 把每个站策略里管脚本的那部分拆开数,条目总数1858条,人均35.1条,中位数只有1——分布极度不均,一半以上的站只写了'self'这一项,而排在前面的几个站单站超过100条。最长的一个站有655条,并且它的策略里根本没写管脚本的那条指令,全靠默认指令回退,也就是说这655条同时管着脚本、图片、样式、字体、连接。 把这些条目里的具体主机名去重,得到840个,逐个做三次间隔解析,三次都失败才算死。结果是46个主机名已经解析不出来,分布在11个站。通配符条目一律不参与判定,因为把星号去掉之后的父域本来就不该解析。 死掉的这46条里,几类标本很有代表性: - 服务已经关停的:某个当年遍地都是的社交分享按钮服务,三个域名还留在两个站的清单里,而那家服务多年前就停了。 - 公司被收购改了名的:一家边缘加速厂商被收购后整体换了域名,某个站的清单里新旧域名都写着,而且两个都解析不出来——改名之后那家公司后来又整个没了。清单经历了一次域名迁移和一次公司消失,一次都没更新。 - 测试环境跑进了生产:一个支付网关的测试端点、一个风控服务的沙箱端点,分别留在两个电商站的生产策略里。 - 根本不是域名的:某个站的清单里有5个32位的小写字母串,是浏览器扩展的标识符。扩展资源该用专门的协议前缀声明,写成裸串之后浏览器只能把它当主机名去解析,自然永远解析不到。这5条从写下的那天起就没有生效过,也从没有人发现。 - 自家的兄弟域名:一个多品牌集团的站点,清单里列着六个兄弟品牌的邮件子域和博客子域,现在全都解析不出来。 ## 写了但没用到的,占了四分之三 判断“这条还有没有用”不能只看首页HTML,因为大量第三方是被标签管理器在运行时动态注入的,光看源码会把它们全算成没用到。所以这一轮用真实浏览器跑首页,收集页面实际发出的每一个请求的域名,再跟清单对照。同时把被反爬拦住、页面根本没渲染起来的站排除掉——判据是浏览器发出的请求域名数少于12个。 剩下15个渲染成功、且清单里有具体条目的站,988条条目里首页真正请求到的只有241条,747条没用到,占75%。单站比例从25%到94%不等。 要说清楚的是,没用到不等于该删——有些条目服务于结账页、账户页、活动页,首页当然不会触发。这个数字真正说明的是另一件事:这份清单绝大部分内容,日常是没有任何验证的。它们对不对、还活着没有、写没写错,只有等到某个用户走到某个页面、组件没出来、有人提工单,才会被检验一次。 ## 十个站的首页,当场就有东西被自己的策略拦掉 真实浏览器跑首页时顺手记了控制台,35个站里有10个的首页当场就有资源被自己的策略拦下。挑几个说: - 一个珠宝品牌站,被拦的是它自己用的那家CDN厂商的性能统计脚本——清单里没有那个统计域名。统计类脚本是这类事故的高发区,因为它们换域名最勤,处理第三方统计脚本那篇 (https://zhangwenbao.com/hidden-third-party-website-statistics-icons.html)里提到的几家,域名前后换过不止一次。 - 一个协作工具站,被拦的是它的前端错误监控脚本。收集错误的那个脚本本身被拦掉了,这意味着这个站之后发生的所有前端错误都不会有人知道,包括这一次拦截本身。 - 一个英国零售站,被拦的是它自己的站标图片——策略里图片那一项只写了'self'和一个验证码服务的域名,而站标挂在带www前缀的那个主机名上,跟页面所在的主机名不是同一个,于是'self'不覆盖它。这正是上一节那条规则的现网版本:不带星号的条目只管自己,不管兄弟主机名。 - 一家跨境汇款平台,被拦的是它自己一个子域上的内嵌页面。 - 两个美妆零售站和一个家具站,被拦的是广告平台的转化统计像素——转化数据因此少了一块,而广告后台只会显示转化变少,不会告诉你原因。 最后一条保哥想多说一句。转化像素被拦掉的后果,不是页面出问题,是投放数据变得不可信——归因链路缺了一环,优化师会以为是创意或者受众的问题,然后去调整根本没坏的东西。跟数据分析师对账那份清单 (https://zhangwenbao.com/data-analyst-seo-reconciliation-7-actions.html)里讲过埋点对不上时的排查顺序,这一行响应头值得加进去当第一个怀疑对象,因为它的症状恰好是“数据少了一部分但系统一切正常”。 ## 这份清单要怎么养,才不会反过来咬人? 把上面所有结论收成可以直接排期的动作。顺序按投入产出排,前三条几乎没有成本。 ## 先把眼睛装上 第一件事是让拦截变得可见,在此之前谈治理都是空的。 - 在前端监控里加一条违规事件监听,把被拦地址、生效指令、策略原文一起送回你现有的错误上报接口。一行代码,不需要新基础设施,而且它是唯一能拿到被拦域名的途径。 - 如果非要用响应头里的上报,就只写老指令。在新机制能稳定投递之前,“两个都写”等于没写。要验证很简单:随便找个页面故意加载一个必定被拦的资源,看接收端有没有收到——收不到就说明这条链路是断的,不是没有违规。 - 把上线前的观察期改成主动跑。用无头浏览器把首页、分类页、商品页、购物车、结账页各跑一遍,直接读控制台里的违规信息。这比等上报可靠,也比等用户投诉快。这套东西跑起来不复杂,跟做单因素隔离实验 (https://zhangwenbao.com/seo-ab-testing-experiment-design-statistical-power-single-factor.html)时搭的采集脚本是同一类活儿。 ## 再把清单本身管起来 - 给每一条加一个负责人和一个到期日。这份清单最要命的地方是它没有主人——加的时候是某次接入的顺手动作,之后就永远留在那里。哪怕只是在配置文件里写行注释,标上“谁加的、为哪个组件加的、什么时候复核”,也比一串裸域名强得多。 - 定期跑一次解析检查。把清单里所有不带星号的主机名拿出来解析一遍,解析不出来的要么是服务停了、要么是当初就写错了,两种都该清掉。这个检查一条命令就能跑完,本文那46个死条目就是这么找出来的。 - 把清单和真实流量对一次账。跑一遍主要页面,收集实际请求的域名,跟清单做双向差集:清单里有、实际没请求的是待复核项;实际请求了、清单里没有的是正在被拦或者随时会被拦的高危项。 - 把第三方的变更通知接进来。这条最难,因为对方的变更公告发给的是商务或者对接人,收件人不会意识到这跟一行响应头有关。可行的折中是在接入任何第三方组件时,把“域名变更需要同步”写进对接文档,并且留下一个具体的接收人,而不是一个部门。 ## 最后是路线选择 - 迁移到随机值加动态传递的写法时,要按整体切换来排期,不能当成“加个关键字”。切换的那一刻清单全部失效,所有靠清单放行的第三方组件会同时停摆,必须先把它们改成受信任脚本动态加载,或者接受一次集中改造。 - 接入层加策略之前,先确认应用层有没有在发。数一数原始响应头里这个字段名出现了几次,两条就是交集,不是并集。 - 站点迁协议、换域名、上新CDN的时候,把这行响应头列进检查表。它不在任何一份常规的迁移清单里,而它恰恰是最容易被落下、后果又最隐蔽的一行。顺手把同一层的其他老响应头一起复核掉,比如用X-Frame-Options挡点击劫持 (https://zhangwenbao.com/htaccess-x-frame-options.html)那种早年加上、之后再没人看过的配置。 保哥最后想说的是,这类配置有个共同的性格:写错的代价不是立刻报错,而是把某个功能安静地关掉,账单照付、数据照跌、页面照样返回200。能对付这种性格的只有一样东西,就是把“我以为它在工作”换成“我刚验证过它在工作”。 ## 常见问题解答 ## 页面返回200、服务端日志干净,怎么快速确认是不是被策略拦了? 打开浏览器控制台看有没有violates the following Content Security Policy directive这类红字,这是最直接的判据。如果控制台也被清空了或者拿不到用户现场,退一步看响应头:把页面的原始响应头打出来,确认这个字段存在、并且数一数它出现了几次。还有一个反向判据——被策略拦下的请求在浏览器网络面板里会显示为失败,但服务端访问日志里完全没有对应记录,因为请求根本没发出去。这一点可以用来跟真正的网络故障区分开。 ## 违规报告里指着的域名明明在白名单里,为什么还被拦? 最常见的原因是这个地址在中途发生了跳转,真正被拦的是跳转终点,而报告出于隐私考虑只会给出发起地址。规范里明确写了这样做是为了不让页面探测到重定向目标。判断方法是打开控制台看那条错误信息——控制台里说的是终点地址,跟报告字段里的不是同一个。第二种可能是响应里有两条策略,报告出自后面那条。 ## 加了strict-dynamic之后,原来白名单里的第三方为什么全挂了? 这是设计如此。官方文档写明,这个关键字一旦出现在某条指令里,同一条指令里的主机名条目、协议条目、'self' 和 'unsafe-inline' 全部会被忽略。策略从“列举可信域名”整体换成了“标记可信脚本”,两套机制不叠加。要迁移必须整体切换:先让所有第三方脚本改由带随机值的可信脚本动态加载,再启用这个关键字,不能一边加关键字一边指望旧清单继续生效。 ## 白名单里写了根域名,为什么它的子域还是加载不了? 不带星号的条目只匹配它自己那一个主机名,不覆盖任何子域。反过来,带星号的条目只匹配子域,不覆盖根域本身——想两边都放行必须两条都写。另外要注意星号在这里能跨任意层数子域,跟证书里的通配符只能覆盖一层不一样,按证书的习惯来理解会在两个方向上同时判断错。 ## 为什么第三方脚本在测试环境好好的,上生产就被拦? 先查两个最容易被忽略的差异。一是端口:条目里不写端口等于只允许该协议的默认端口,如果生产走了非默认端口就不匹配。二是协议:站点从http迁到https之后,清单里留着的http条目在严格实现的引擎上会判不匹配,而在宽松实现的引擎上照常放行——同一份配置在两个浏览器上能得出两种结果,只在一个浏览器上验证是不够的。第三个常见差异是生产的接入层额外加了一条策略,两条策略取交集。 ## 配了违规上报却一份报文都收不到,是接收端的问题吗? 先加一个阳性锚点再下结论:找个页面故意触发一次必定违规的加载,用老的上报指令投到同一个接收地址,如果这一份能收到,说明接收端、证书、网络路径都没问题。本文实测的情况是,只写老指令能收到、只写新指令收不到,而按官方推荐把两个都写上同样收不到——因为支持新指令的浏览器会忽略老指令。在新机制稳定之前,只写老指令反而是能拿到数据的写法。 ## 白名单里的条目能不能定期自动清理? 解析检查可以自动跑,把清单里所有不带星号的主机名解析一遍,三次都失败的基本可以判定为服务已停或当初写错。但“没被请求到”这一项不能自动删,因为很多条目服务于结账页、账户页这类首页不会触发的场景。建议的做法是自动检查只负责产出待复核清单,删除动作交给知道这条是给哪个组件用的那个人——这也是为什么每条都该记下负责人。 ## 这套东西对SEO有直接影响吗? 有,而且是间接但确凿的两条。第一条是渲染:搜索引擎抓取时同样会执行这行响应头,被拦掉的脚本如果负责渲染主体内容或者结构化数据,抓到的就是残缺页面。第二条是数据:转化像素和统计脚本被拦之后,投放和分析看到的数字会系统性偏低,而后台不会给出任何原因提示,很容易导致对着没坏的东西做优化。 ## 权威参考资料 ## 服务器全程返回200、订单已入库,用户眼前那块区域自始至终是空白 - URL:https://zhangwenbao.com/csp-frame-ancestors-blocked-render-not-request.html - 分类:Nginx - 发布:2025-11-21 | 更新:2026-07-29 - 摘要:实测CSP的frame-ancestors为什么拦渲染却不拦请求:服务端照收照记还回200,父页面的error事件永不触发。含16组判决矩阵、nginx层继承覆盖实验与151站现网普查。 - 关键词:Nginx,独立站,响应头 > **TLDR**:摘要:有一类故障,服务器日志里干干净净,状态码全是200,订单也确实落库了,用户和爬虫却只看到一块空白。原因是拦下这个页面的判决压根不在服务器上执行——它写在响应头里,由浏览器读完之后自己动手。更麻烦的是,同一个响应头里的不同指令,动手的时机还不一样:一类在请求离开浏览器之前就掐断,另一类等服务器把内容全部送到了才拒绝渲染。这两类给排查带来的信号正好相反。 > 摘要:有一类故障,服务器日志里干干净净,状态码全是200,订单也确实落库了,用户和爬虫却只看到一块空白。原因是拦下这个页面的判决压根不在服务器上执行——它写在响应头里,由浏览器读完之后自己动手。更麻烦的是,同一个响应头里的不同指令,动手的时机还不一样:一类在请求离开浏览器之前就掐断,另一类等服务器把内容全部送到了才拒绝渲染。这两类给排查带来的信号正好相反。 ## 一个页面能拿到200,还能一个字都不显示吗? 能,而且这种情况比想象中常见。保哥在排查一个跨境独立站的支付回跳问题时撞见过:后台订单表里躺着一笔成功的付款,网关的回调记录也正常,服务器访问日志里那次请求写着200,可用户截图发过来,屏幕中间那块区域从头到尾是白的。 把问题拆到最小,它长这样:浏览器把请求发出去了,服务器把响应回来了,浏览器把响应头读完了,然后决定不把内容给用户看。整个过程里,服务器没有任何一个环节会知道最后那一步发生了什么。 为了把这件事的边界摸清楚,我搭了一套最小的双域实验台:一个域放落地页(相当于商户站),另一个注册域放父页面(相当于把落地页嵌进来的网关或挑战窗口)。落地页按16种不同的响应头组合下发,父页面用iframe去嵌它。每一次请求都在落地页这一侧落盘。 ## 16种组合跑下来的总账 观测层 | 渲染成功 | 被拦下 | 组合数 | 7种 | 9种 | 浏览器把请求发出去了 | 7/7 | 9/9 | 服务端日志里有这次请求 | 7/7 | 9/9 | 响应状态码 | 200 | 200 | 父页面的load事件触发了 | 7/7 | 9/9 | 父页面的error事件触发了 | 0/7 | 0/9 | 页面内脚本回打的信标到达 | 6/7 | 0/9 | 这张表里最刺眼的是倒数第二、第三行。无论页面有没有被拦,父页面拿到的都是load事件,一次error都没有。也就是说,前端同学最自然的那种兜底写法——给iframe挂个onerror,出问题就换个提示——永远不会跑。它等的那个事件不会来。 ## 被拦时浏览器控制台说了什么 > Framing 'https://…' violates the following Content Security Policy directive: “frame-ancestors 'self'”. The request has been blocked. 网络面板那一行标的是net::ERR_BLOCKED_BY_RESPONSE。这个错误码的措辞其实很老实:被响应阻断,不是被网络阻断,也不是被服务器阻断。响应本身好端端地到了,是它带来的那句话让浏览器停了手。 ## 服务器那边到底看见了什么? 这是本文里最值得单独看一眼的一段日志。下面五条来自五个不同的被拦组合,是落地页服务端自己记下来的: {"method":"GET","uri":"/…?v=self", "sfd":"iframe","sfm":"navigate","sfs":"cross-site","ref":"https://…/"} {"method":"GET","uri":"/…?v=xfodeny", "sfd":"iframe","sfm":"navigate","sfs":"cross-site","ref":"https://…/"} {"method":"GET","uri":"/…?v=double", "sfd":"iframe","sfm":"navigate","sfs":"cross-site","ref":"https://…/"} {"method":"GET","uri":"/…?v=report", "sfd":"iframe","sfm":"navigate","sfs":"cross-site","ref":"https://…/"} {"method":"GET","uri":"/…?v=wild", "sfd":"iframe","sfm":"navigate","sfs":"cross-site","ref":"https://…/"} 请求方法、路径、来源页、三个Sec-Fetch字段,一样不缺。这些请求全都被浏览器拒绝渲染了,可服务端这边看起来一切正常。如果这个落地页是个会写库的接口,它已经写完了;如果它是个会发确认邮件的页面,邮件已经发出去了。 ## 你的监控为什么全是绿的 把常见的几种可观测手段挨个过一遍,就明白为什么这类故障能安安静静躺很久: - 访问日志:有记录,状态码200,一切正常。 - 可用性拨测:拨测通常直接请求URL,不会把它嵌进另一个域的页面里,所以永远测不到。 - 错误率看板:分母分子都没变化,曲线是平的。 - 业务数据:订单还在涨,因为下单那一步早就完成了。 - 客服工单:会有,但描述是“页面打不开”“卡住了”,路由到前端或网络那边,绕一大圈。 这套组合拳的效果就是:按掉量幅度设阈值的那套SEO监控告警体系 (https://zhangwenbao.com/seo-monitoring-alerting-regression-detection-system.html)抓不到它,因为流量没掉;从服务器日志反推抓取行为的那套方法 (https://zhangwenbao.com/server-log-file-analysis-seo-crawl-budget-bot-verification.html)也抓不到它,因为日志里根本没有异常特征。这跟当年记事本存文件带出BOM导致的白屏 (https://zhangwenbao.com/notepad-edit-saved-code-generate-bom-resulting-web-page-error-white-screen-solution.html)有点像——现象都是白,但那一类至少在源码里留了痕迹,这一类连痕迹都在别人机器上。 ## 唯一能在服务端分辨成败的信号 我在落地页里埋了一枚“渲染信标”:页面加载完,脚本回打一次同源请求。结果是7个渲染成功的组合里6个信标到达,9个被拦的组合信标全是0。 剩下那1个没到的,恰好带出一个更值得记的教训,下面会讲到。这里先把结论摆出来:信标到了,一定说明页面渲染了;信标没到,不一定说明没渲染。它是充分条件,不是必要条件。用它做告警要按这个方向设计,反过来会误报。 ## 同一个响应头里的指令,为什么有的拦在请求发出前、有的拦在响应到达后? 这是整件事里最反直觉、也最有用的一条。同一个Content-Security-Policy头,里面写的不同指令,执行时机分成两派。 为了把这条钉死,我换了个实验台:落地页向另一个域要三类资源——外部脚本、fetch请求、图片,而那个域的服务端每收到一次就记一笔。谁的日志里空着,就说明请求根本没离开浏览器。 ## 第三方服务器的到达记录 落地页下发的策略 | 外部脚本 | fetch | 图片 | 不发任何策略(对照组) | 到达 | 到达 | 到达 | default-src 'self' | 0 | 0 | 0 | script-src 'self' | 0 | 到达 | 到达 | connect-src 'self' | 到达 | 0 | 到达 | img-src 'self' | 到达 | 到达 | 0 | Report-Only模式 | 到达 | 到达 | 到达 | 看清楚这几个0的含义:被这几条指令拦下的请求,第三方服务器一条日志都没有。它不知道有人来过,也不知道自己被拒了。请求在浏览器内部就被掐断,连一个字节都没发出去。 ## 两派指令的分界线 把两个实验并排放,分界线就清楚了: | 请求发出前拦(script-src这一派) | 响应到达后拦(frame-ancestors) | 对方服务器收到请求了吗 | 没有 | 收到了 | 对方服务器有日志吗 | 没有 | 有,而且是200 | 对方的业务逻辑跑了吗 | 没跑 | 跑完了 | 你去问对方“你收到我请求了吗” | 答“没有” | 答“收到了,我还回了200” | 排查时你会往哪个方向查 | 网络、DNS、防火墙 | 对方系统、参数、幂等 | 最后一行才是真正的坑。两派故障都会把排查引到错误的方向去,而且是相反的两个错误方向。第一类让你以为是网络不通,其实网络好得很;第二类让你以为对方处理得没问题,其实用户根本没看到结果。 这条差别在做客户端渲染与服务端渲染对抓取影响的对照测试 (https://zhangwenbao.com/js-rendering-ai-crawler-citation-rate-csr-ssr-isr-divergence.html)时同样成立:一个被策略掐掉的第三方脚本,在服务端视角是“从未发生”,在渲染结果里却是“内容缺了一大块”。 ## 为什么是这样设计的 其实很合理。script-src管的是“这个页面能不能加载它”,判断只需要一个地址,不需要看内容,所以能提前决定。frame-ancestors管的是“谁能把我嵌进去”,这句话写在被嵌那一方的响应头里,浏览器不把响应拿到手,压根不知道对方是什么态度。 所以顺序上必须是:先请求,先响应,再判决。这不是实现上的偷懒,是这条指令的语义决定的。它注定要在服务端已经干完活之后才生效。 ## 明明配了策略,为什么还是被人整页嵌走? 现网里这一格的空缺率高得惊人。我抓了一批电商站、独立站与支付基建的响应头,可达的151个站里: 状态 | 站点数 | 占比 | 配了CSP | 53 | 35.1% | 其中写了frame-ancestors | 35 | 23.2% | 配了CSP却没写frame-ancestors | 18 | 占已配CSP的34.0% | 只有X-Frame-Options、没有frame-ancestors | 45 | 29.8% | 两样都没有,任何人都能整页嵌走 | 71 | 47.0% | ## 第一个陷阱:default-src不管这一格 那18个“配了CSP却漏了嵌入”的站里,有15个是写了default-src的。写了default-src的人通常认为它是个兜底——脚本、图片、连接都归它管,嵌入应该也归它管。 实验里的对照组直接给了答案:下发default-src 'self'的那个组合,被另一个域整页嵌进去,渲染完全成功。MDN那句话说得更狠: > A policy that declares default-src 'none' still allows the resource to be embedded by anyone. 连'none'都不管用。在一份把所有东西都锁死的策略里,“谁能嵌我”这一格默认是敞开的,而且你不写它就永远敞着。 ## 第二个陷阱:写在meta里等于没写 不少建站框架和插件把CSP注入到HTML的<meta>标签里,因为那样不用碰服务器配置。这条路对大部分指令有效,唯独对frame-ancestors无效。 实验里那个只在meta里写了frame-ancestors 'none'的组合,被跨站嵌入后正常渲染,控制台还留了一句: > The Content Security Policy directive 'frame-ancestors' is ignored when delivered via a <meta> element. 规范里对应的原文更简短,把三条指令一起排除在外:report-uri、frame-ancestors、sandbox。普查里有12个站把CSP写在meta里,其中1个恰好在里面写了frame-ancestors——那一整段配置在浏览器里一个字都不算数。它是活样本,不是我编的教学案例。 ## 第三个陷阱:通配符不匹配裸域 实验里我写过一条frame-ancestors https://*.abc-demo.com,然后从abc-demo.com(没有子域前缀)去嵌,结果被拦。星号代表的是“某个子域”,不包括那个域名本身。 这个坑在多品牌、多站点架构里特别容易踩:你以为放行了整个域族,实际上主域被排除在外了。写白名单时两条都得写。 ## 现网里那份白名单都写了些什么 把35个写了frame-ancestors的站按取值分类,结果和很多人的想象不一样: 取值形态 | 站点数 | 'self'再加一串白名单域 | 22 | 'none'(谁都不许) | 5 | 只写具体域名 | 4 | 'self'(只许自己) | 4 | 三分之二的站是“自己加一份名单”。名单里都是些什么?内容平台的预览域、支付服务商的域、页面搭建工具的域、谷歌支付的域。换句话说,这条指令在现实中不是一个开关,是一份要跟着业务变的花名册。每接一个新的第三方界面,就得往里加一行;忘了加,那个界面就白屏。 有一个支付服务商的白名单里,至今躺着一条http://localhost:9999。开发环境的调试项被一路带到了生产。它不会造成安全问题,但它是这份名单从来没人复核过的铁证——毕竟没人会去嵌一个别人本机的9999端口。 ## X-Frame-Options和frame-ancestors同时写,到底谁说了算? 这两个头一新一旧,很多站两个都配,想着“新浏览器认新的,老浏览器认老的,双保险”。实验结果是:只要CSP里出现了frame-ancestors,那个老头就一个字都不算数。 ## 两个方向都测了 下发的组合 | 老头的意思 | 新指令的意思 | 实际结果 | X-Frame-Options: ALLOWALL+frame-ancestors 'none' | 随便嵌 | 谁都不许 | 被拦 | X-Frame-Options: DENY+frame-ancestors * | 谁都不许 | 随便嵌 | 嵌入成功 | 第二行才是真正要命的:老头写着最严的DENY,页面照样被跨站整页嵌走。如果你的安全基线检查表只grep一下有没有X-Frame-Options: DENY,这个站会满分通过,实际防线却是敞开的。 ## 现网里有多少站的两个头在互相打架 普查里两个头都配了的站有21个。我按“老头说的”和“新指令说的”是否同一个口径逐个比对,结果是14个不一致,占66.7%。挑几个典型: - 一个前端托管平台:老头写DENY,frame-ancestors却放行了自己加三个内容平台的域。看老头以为谁都不许,实际是一份白名单。 - 一个健康品牌站:老头写SAMEORIGIN,frame-ancestors的值是*。老头说只许同源,实际全世界都能嵌。 - 多个支付服务商:老头统一SAMEORIGIN,新指令各自放行了自己的关联域与页面搭建工具域。 还有一个更古老的样本:某协作工具的响应头里写的是X-Frame-Options: ALLOW-FROM https://…。这个值早就作废了,MDN的措辞是现代浏览器会完全忽略这个头——不是忽略这个值,是整条头都不看了。所以这个站在嵌入这一格上,等于什么都没配。 ## 那老头还要不要留 要留,但要认清它的定位:它是给那些不认识frame-ancestors的老客户端准备的,不是给你做双保险的。只要现代浏览器读到了新指令,老头就出局。因此两条的口径必须写成一致,不一致的时候永远是新指令赢。 保哥的经验是把这两行写在配置文件的相邻两行,中间不要隔任何东西。它们分开写的那一刻,就注定有一天只有一行会被改。 这跟当年用.htaccess加X-Frame-Options挡点击劫持 (https://zhangwenbao.com/htaccess-x-frame-options.html)那套做法是一脉相承的,只是执行权已经交给了新指令。响应头这一层同时还管着抓取指令与缓存协商 (https://zhangwenbao.com/http-response-headers-seo-x-robots-cache-vary-canonical-mechanism.html),改动时得整体看,不能只盯一条。 ## nginx里加一行无关的响应头,为什么整站的CSP就没了? 说到这儿该往下走一层了。这些头是谁下发的?绝大多数站是nginx用add_header发的。而这个指令有一条继承规则,官方文档写得非常直白: > These directives are inherited from the previous configuration level if and only if there are no add_header directives defined on the current level. ## 拿一个测试站验一遍 我在server层配了三条头,然后在不同的location里做对照: 请求路径 | 该location里写了什么 | 实际收到的头 | /inherit/ | 什么都没写 | server层三条全在 | /child/ | 只加了一条无关的自定义头 | 只剩那一条,CSP消失了 | /childcsp/ | 写了自己的CSP | 只剩自己那条,server层的被顶掉 | 第二行就是那个坑。某人为了给一个目录加个缓存头,在那个location里写了一行add_header,整站的安全策略在这个路径下就静默消失了。没有报错,nginx -t通过,reload成功,只有那一小片路径裸奔。 更常见的触发场景是给静态资源目录单独加Cache-Control,或者给某个接口加跨域头。这类改动通常由另一个人在另一个时间做,两边都不知道对方存在。反向代理那一套配置里的斜杠、重写与头部转发细节 (https://zhangwenbao.com/nginx-proxy.html)本来就够绕了,再叠上这条继承规则,出问题的时候很难一眼看出来。 ## always决定错误页带不带策略 官方文档的另一句话:默认情况下,add_header只在响应码等于200、201、204、206、301、302、303、304、307、308时才添加;写了always才不管状态码。 我在404响应上验了一遍:带always的两条头都在,没带的那条不见了。这意味着一个没写always的站,它的404页、500页在安全策略上是裸的。对做SEO的人来说还有一层:状态码怎么选本来就是一道决策题 (https://zhangwenbao.com/http-status-codes-seo-atlas-redirect-410-decision.html),现在它还顺带决定了那个响应带不带策略。 ## 两条CSP头不会互补,只会互相收紧 最后一个实验:让应用层(PHP)发一条CSP,nginx层再add_header一条。响应里真的出现了两行Content-Security-Policy。那浏览器听谁的? 我在浏览器侧做了两组对照:一条写frame-ancestors *、另一条写frame-ancestors 'none',两种顺序各跑一次。两次都被拦。 结论很明确:多条策略是取交集,最严的那条赢,跟顺序无关。这条规则本身是合理的——它保证了“再加一条策略只会更安全”。但落到运维现场,它的含义是: - CDN或WAF替你加了一条宽松的策略,源站也加了一条,你以为是覆盖,其实是叠加。 - 用curl -I只看到第一条的人,会以为策略就是那样。 - 普查里我确实抓到了一个站同时发两条CSP,另一个站把同一条X-Frame-Options发了三遍。这不是配置写错,是多个环节各加各的,谁都没看见全貌。 如果你的站前面挂着CDN的缓存与回源规则 (https://zhangwenbao.com/cloudflare-cache-real-world-optimization-decision-tree.html),或者用了在边缘改写响应的那类做法 (https://zhangwenbao.com/edge-seo-cdn-worker-no-deploy-implementation.html),这个叠加风险会再高一档,因为边缘那层的改动往往不在代码仓库里。拦爬虫与限速的规则不误伤正经爬虫 (https://zhangwenbao.com/nginx-ai-bot-blocking-rate-limit-rdns-misblock-account.html)是同一个道理:每一层都以为自己是最后一层。 ## 配了违规上报,为什么一条报告都收不到? 看到这里,正常反应是:那我配个违规上报不就行了,出事让浏览器告诉我。这个思路对,但现在的上报机制正处在一个尴尬的新旧交替期,配错了等于没配。 ## 两种机制,四组对照 老机制叫report-uri,已经被标记为废弃;新机制叫report-to,要配合一个单独的响应头指定端点。我用同一个接收端点,跑了四组: 策略写法 | 违规类型 | 收到的报告数 | 只写report-uri | 嵌入被拒 | 2 / 2 | 只写report-to | 嵌入被拒 | 0 / 2 | 两个都写 | 嵌入被拒 | 0 / 2 | 只写report-uri | 脚本被拒 | 2 / 2 | 只写report-to | 脚本被拒 | 1 / 2 | 两个都写 | 脚本被拒 | 新机制1条,老机制0条 | 每一组都在两个不同版本的浏览器上各跑一次,所以分母是2。老机制作为阳性锚点,六次机会到了四次,说明接收端点本身是好的——这一点很重要,否则我没法区分“机制不工作”和“我的探针坏了”。 ## 三个结论,一个比一个难受 第一,两个都写的时候,老机制会被停用。这不是玄学,MDN写得清清楚楚: > The report-to directive is intended to replace report-uri, and in browsers that support report-to, the report-uri directive is ignored. 第二,嵌入被拒这一类违规,新机制在我的测试里一条都没送出来。脚本被拒能送出来,嵌入被拒送不出来。一个说得通的解释是:嵌入被拒时那个文档根本没被创建出来,而新机制是挂在文档上的延迟队列——没有文档,就没有队列。老机制则是当场直接发一个POST,不依赖这些。 第三,把这两条合起来看:按“老的废弃了,改用新的,保留老的做兼容”这个标准迁移动作改完配置,恰好把嵌入违规的上报变成了零。而且是静默的零——没有报错,配置看着很规范,仪表盘上那条曲线一直是平的,你会以为一切太平。 ## 两种格式的字段名完全不一样 就算报告送到了,接收端也未必解析得了。同一次违规,两种机制发来的是这样: 老机制 Content-Type: application/csp-report {"csp-report":{"document-uri":"…","violated-directive":"script-src-elem", "blocked-uri":"…","status-code":200}} 新机制 Content-Type: application/reports+json [{"age":0,"type":"csp-violation","body":{"documentURL":"…", "effectiveDirective":"script-src-elem","blockedURL":"…","statusCode":200}}] 外层从对象变成了数组,包裹字段名换了,内部字段从短横线命名换成了驼峰命名,还多出age、type、user_agent。换上报机制不只是改一行响应头,接收端的解析代码要整个重写。照抄旧解析器的话,报告收到了也会被丢进异常分支。 顺带记一个小刺:我在策略里写的是script-src,报告里回来的却是script-src-elem。MDN提过这类差异,用的是样式指令举的例子。如果你的告警规则按指令名精确匹配,会漏掉一批。 ## 报告里那个字段,等于官方承认 报告体里有个字段叫status-code,值是200。 这是浏览器自己写下的:我拦掉的这个响应,状态码是200。整篇文章的核心论点,被违规报告本身盖了个章。 ## 现网里配了上报的站,有几个真能收到 普查里涉及新机制的站有10个。我逐个核对策略里写的端点组名和那个单独响应头里声明的组名对不对得上——只有1个对得上。其余的要么根本没发那个声明头,要么发了但里面是另一套用途的组名。 也就是说,这10个站里有9个的上报是配了个寂寞。而CSP这种机制有个残酷的特性:没人上报就等于什么都没发生。违规既不影响服务端,也不影响状态码,报告是唯一的证据链,报告断了,故障就彻底隐形。 ## 被拦的那个页面,Google那边算什么? 做SEO的读到这里应该已经开始不安了,因为搜索引擎的抓取与渲染,正好卡在这条分界线上。Google官方文档里有两句话,单看都平平无奇,连起来就是问题所在: > Googlebot queues all pages with a 200 HTTP status code for rendering, unless a robots meta tag or header tells Google not to index the page. Google also uses the rendered HTML to index the page. 排队的门槛是状态码200,索引用的是渲染后的HTML。而策略动手的位置,恰好就在这两句话中间。 ## 三种被拦法,对索引的后果完全不同 被拦的东西 | 抓取阶段 | 渲染结果 | 索引到的内容 | 页面被拒绝嵌入 | 正常200 | 父页面那块区域是空的 | 页面本身照常索引,父页面少一块 | 注入正文的第三方脚本被拦 | 正常200 | 正文没被注入 | 索引到一个空壳 | 结构化数据由外部脚本写入 | 正常200 | 标记没生成 | 富媒体资格丢失 | 第二行是实验里亲眼看到的:策略拦掉外部脚本后,那块本该被脚本填满的正文区域,渲染完还是占位文字。服务端返回的是200,HTML也确实送到了,只是里面没有内容。这跟纯客户端渲染的页面抓不到内容 (https://zhangwenbao.com/javascript-rendering-seo-csr-ssr-debugging.html)是同一类后果,但成因藏得更深——那一类至少能在源码里看出是靠脚本填的,这一类是脚本本来能跑,被自己站上的一行配置挡住了。 ## 越靠近交易,策略越不是你写的 我对电商与独立站样本做了分层取样,首页、购物车、结账、账户各取一次: 页面层 | 可达数 | 配了CSP | 写了frame-ancestors | 配了老头 | 两样都没有 | 首页 | 43 | 47% | 44% | 49% | 33% | 购物车 | 54 | 78% | 76% | 54% | 19% | 结账 | 52 | 75% | 73% | 25% | 15% | 账户 | 29 | 79% | 79% | 59% | 14% | 两个方向正好相反:越靠近交易,新指令的覆盖率越高(44%涨到73%),老头的覆盖率反而越低(49%掉到25%)。原因不难猜——结账那几页多半跑在电商SaaS上,策略是平台写的,平台只写新指令;首页在自己手里,配置是历史上运维加的,加的是老头。 把首页和结账页两层都拿到的24个站逐个比对,有3个站两层策略明显不一致。其中一个品牌站,首页是最严的'none'加DENY,结账页却是'self'加平台域、连老头都没有。同一个品牌,两层策略由两拨人写,中间没有任何一处会报冲突。 做结账放弃率归因 (https://zhangwenbao.com/dtc-checkout-abandonment-9-real-causes.html)的时候,这类“某个第三方组件在某些浏览器里白屏”的损耗几乎不可能从漏斗数据里看出来——用户不会填工单,他们只是走了。 ## 排查这类故障,该按什么顺序问问题? 把上面所有实验的结论压成一套可执行的顺序。保哥自己现在遇到“用户说白屏、服务端说正常”的时候,就是按这个顺序走。 ## 先分清楚是哪一派 第一个问题不是“服务器有没有收到”,而是“这块内容是被嵌进来的,还是页面自己去加载的”。 - 被嵌进来的(iframe、嵌入式结账、验证挑战窗口):查被嵌那一方的frame-ancestors与老头。它的服务端一定有日志,别拿“对方说收到了”当排除依据。 - 页面自己去加载的(脚本、图片、接口):查当前页面的策略。第三方那边一定没有日志,别拿“对方说没收到”当网络故障处理。 ## 然后按这张表逐项对 要确认的事 | 怎么确认 | 常见的错法 | 响应里到底有几条CSP | 看原始响应头的完整列表 | 只看解析后的第一条 | 策略是谁下发的 | 应用层、nginx、CDN逐层排查 | 只翻代码仓库 | 这个location有没有自己的add_header | 看配置里本级有没有写 | 以为server层的会继承下来 | 错误页带不带策略 | 请求一个404看头 | 只测200页面 | meta里写的那条算不算数 | 嵌入类指令一律不算 | 以为写哪儿都一样 | 白名单里有没有裸域 | 星号写法要额外补主域 | 以为通配符包含主域 | 上报有没有真的送达 | 翻接收端点的原始记录 | 看仪表盘没告警就当没事 | ## 三个能立刻上手的动作 第一,在服务端认出“我正在被嵌”。实验日志里那三个Sec-Fetch字段是现成的:Sec-Fetch-Dest: iframe加上Sec-Fetch-Site: cross-site,就是“有人正在跨站嵌我”。你可以在这个条件下返回一个降级页面,给用户一个“点此在新窗口继续”的入口,而不是让浏览器直接把整块区域变白。这一步不需要改策略,也不需要谈判,纯粹是把一个已经到手的信号用起来。 第二,埋一枚渲染信标。页面加载完回打一次同源请求,服务端拿“请求数减信标数”就能算出有多少次访问其实没被看到。但这里有个我自己踩的坑值得说:实验里有个组合渲染成功了,信标却没到——因为那份策略是default-src 'self',把页面自己的内联脚本也一并拦了。你加的观测手段本身也归同一份策略管。要么给信标脚本单独放行,要么把它放进独立文件里。 第三,别急着把老头删掉。它虽然在现代浏览器里出局,但删掉的成本是零收益,留着的成本也是零。真正该做的是把它和新指令的口径对齐——普查里那66.7%的不一致,绝大多数是因为一个人改了新的、没人回头看老的。 ## 要不要现在就配 如果你的站属于那47%(两样都没配),先配最简单的一条:frame-ancestors 'self',加always,配在server层,然后把所有location挨个看一遍有没有自己的add_header。这一步比策略本身更容易出错。 如果你的站有嵌入式支付、客服组件、第三方评价插件,那就得先列一份名单再上策略,并且用Report-Only模式跑一段时间——它一个都不拦,只记录。实验里那个组合正是这个行为:三类资源全部到达,控制台照报违规。先看清自己拦了谁,再决定拦不拦。 顺带说一句,做这类改动最好跟前端那边的协作动作 (https://zhangwenbao.com/frontend-engineer-seo-collaboration-7-actions-semantic-cwv-render.html)与建站第一年那批隐性失分项 (https://zhangwenbao.com/cms-seo-first-year-hidden-loss-checklist-cross-platform.html)放在一张清单上过,因为它们踩的是同一类问题:配置改动本身不会报错,报错的是几周之后的数据。 ## 常见问题解答 ## 我的站被跨站嵌入了,会不会影响排名? 直接影响基本没有。被别人嵌走不构成重复内容问题,那个页面的地址还是你的,抓取和索引走的还是你自己的URL。真正的风险有两类:一类是别人拿你的页面套一层壳去做诱导,用户在那边受了损失,最后骂的是你的品牌;另一类是你的页面里有表单或登录,被嵌进别人的界面做点击劫持。这两类都属于安全问题,不属于排名问题。 反过来那一侧的风险反而更该防。如果把frame-ancestors配得过严,误伤了自己接的第三方界面,损失是实打实的转化,比排名影响大得多。所以顺序应该是先列白名单,再上策略,不要图省事一刀切成'none'。 ## Report-Only模式能不能长期开着? 能,而且很多站就是长期开着的。它的行为在实验里很明确:三类跨站资源全部正常到达,页面正常渲染,只是控制台记了违规、报告发去了指定端点。代价是它完全不提供保护,所以不能拿它当上线。 合理的用法是两条策略并行:一条正式的、宽松些的负责拦,一条Report-Only的、严格些的负责探路。等严格那条跑一段时间不再报新东西了,再把它提成正式的。要注意这两条会一起生效,正式那条该拦的照拦,别指望Report-Only能放松它。另外普查里151个站只有6个开了Report-Only,这个用法远没有它值得的那么普及。 ## 为什么我用curl看响应头,看到的和浏览器里的不一样? 最常见的三个原因。第一,同名响应头有多条,很多工具默认只显示或只保留第一条,而浏览器是全部拿来取交集的,于是你看到的是宽松那条、执行的是严格那条。第二,你请求的是首页或者某个200页面,而出问题的路径在另一个location下,那个location里有自己的add_header,头的组合完全不同。第三,出问题的响应是个4xx或5xx,配置里又没写always,那些头在错误响应上根本不会加。 排查时最稳的做法是照着用户实际访问的那个完整地址去请求,并且把原始响应头一行不落地打出来,而不是用工具解析后的摘要。 ## 把CSP写在CDN那一层,会不会更省事? 会更省事,但要接受两个后果。第一个是叠加:如果源站也在发CSP,两条会同时存在并取交集,最终执行的策略比任何一层单独看到的都严,而且没有任何一层的配置界面会告诉你这件事。第二个是可见性:边缘那层的改动通常不在代码仓库里,出问题的时候代码审查看不出来,新同事接手也不会知道有这么一条。如果确实要放在边缘,建议做两件事——把源站那一层的CSP彻底去掉,只留一个下发点;再在部署文档里明确写清楚这条策略归谁维护。做这类分层配置的时候,边缘改写与源站配置的边界要一次划清楚,事后再理会非常费劲。 ## 移动端App里的WebView也吃这一套吗? 吃,而且更容易踩。WebView大多基于同一个内核,frame-ancestors的判定逻辑一致。但有两个额外的麻烦。一是WebView里没有控制台,被拦了以后页面就是白的,连那句提示都看不到,除非专门接了远程调试。 二是App里嵌自家H5的场景,那个H5页面的来源在浏览器眼里往往不是你想的那个域,有时候甚至是file://或者一个自定义scheme,这类来源在白名单里根本没法用常规写法匹配。所以做App内嵌页时,要么把这个页面的策略单独放宽,要么在服务端按Sec-Fetch-Dest判断后走另一套响应。 ## 违规报告应该发到哪儿,自己搭还是用现成的? 量小的时候自己搭一个接收端点完全够用,几十行代码的事。但要提前想清楚三件事。第一是量:违规报告是浏览器主动发的,一个配错的策略在流量高峰能瞬间打出很大的量,接收端要能扛住,最好直接写队列不要同步落库。第二是格式:老机制和新机制的字段名完全不同,外层结构也不同,解析代码要同时认两种,否则迁移那天你会以为报告没来。第三是过滤:浏览器扩展注入的脚本会产生大量与你无关的违规,这类噪声在真实站点上能占到多数,不做过滤的话有用信息会被淹掉。如果不想处理这些,用现成的收集服务是更省心的选择。 ## 我怎么知道站上现在到底有多少个第三方界面被嵌在页面里? 有个不用改代码的笨办法:打开生产环境的页面,在控制台里数一遍当前文档下所有iframe的来源域,把结果和白名单比一遍。要覆盖全的话,首页、商品页、购物车、结账、账户这几层都得跑一遍,因为不同层加载的第三方组件完全不一样——分层普查的数据显示,同一个站在首页和结账页的策略差异比很多人想象的大。更彻底的做法是先开Report-Only跑几天,让浏览器自己把名单报上来,这比人工数准得多,也能抓到那些只在特定条件下才加载的组件,比如只对某些国家用户显示的支付方式。 ## 如果第三方服务商的域名变了,我怎么第一时间知道? 坦白说很难第一时间知道,这也是这套机制最难受的地方之一:决定你页面能不能显示的那份名单,写在你手里,但名单该有哪些条目由别人决定。服务商换域名、加新的边缘节点域、把某个功能拆到独立域上,都不会提前通知你。能做的只有三件:把违规上报真正打通,让第一次被拦就有记录;在接入任何第三方组件时把域名写进部署清单,跟着服务商的变更公告走;对关键路径上的组件做一次可用性检查,别只测接口通不通,要测它在真实页面里能不能渲染出来。第三条最容易被漏掉,因为接口拨测和渲染检查是两件事。 ## 权威参考资料 ## Nginx拦AI爬虫与限速怎么不误伤GoogleBot? - URL:https://zhangwenbao.com/nginx-ai-bot-blocking-rate-limit-rdns-misblock-account.html - 分类:Nginx - 发布:2025-04-22 | 更新:2026-05-28 - 摘要:独立站用Nginx拦AI爬虫,做完robots和UA黑名单就稳了?过去22周有三个客户站因为UA字符串变化或limit_req阈值过严误伤了GoogleBot,损失抓取预算与收录。本文把真正稳的Nginx五维组合拆开讲,再把22周五站的误伤账本横向对照,最后给12步上线SOP。 - 关键词:爬虫,Googlebot,Nginx > **TLDR**:摘要:独立站拦AI爬虫别照搬robots黑名单,过去22周里我跟进的5个站做Nginx反爬,至少有3个站因为UA字符串变化或limit_req阈值调得太狠而误伤过GoogleBot;真正稳的不是单一拦截规则,而是5个配置位组合落地——白名单优先、UA校验只防小爬虫、limit_req阈值按抓取预算分桶、rDNS反向解析锁死大厂蜘蛛、access_log重打标签每周复盘。本文把这5维拆开讲,再把22周5个客户的误伤账本横向对照一遍,最后给12步上线SOP与5个反信号判断要不要做。 > 摘要:独立站拦AI爬虫别照搬robots黑名单,过去22周里我跟进的5个站做Nginx反爬,至少有3个站因为UA字符串变化或limit_req阈值调得太狠而误伤过GoogleBot;真正稳的不是单一拦截规则,而是5个配置位组合落地——白名单优先、UA校验只防小爬虫、limit_req阈值按抓取预算分桶、rDNS反向解析锁死大厂蜘蛛、access_log重打标签每周复盘。本文把这5维拆开讲,再把22周5个客户的误伤账本横向对照一遍,最后给12步上线SOP与5个反信号判断要不要做。 ## Nginx拦AI爬虫的5维到底是哪五维? 开门见山:拦AI爬虫这事,市面上大多数教程都把robots.txt写在第一位,把UA黑名单写在第二位,把WAF写在第三位,听起来层次分明,但落到Nginx配置层基本不可执行——robots是君子协定AI爬虫多数不读,UA黑名单一个版本号变化就漏,WAF是大锤砸鸡蛋。 我这两年在5个客户站做Nginx反爬,把所有踩过的坑整理出来,最后真正能跑稳的是5个配置位组合: - 第1维白名单:把GoogleBot、Bingbot、Baiduspider这类必须放行的大厂蜘蛛按IP CIDR + UA双向校验放进白名单,这是优先级最高的一层,所有后续规则都要让路。 - 第2维UA校验:用正则匹配关键UA模式(不是全字符串匹配),只用来挡掉那些会主动表明身份的中小爬虫——AhrefsBot、SemrushBot、各种Python requests默认UA、scrapy默认UA、curl默认UA。 - 第3维limit_req阈值:按业务流量画像和蜘蛛分桶配速率限制,搜索引擎大厂一桶、AI爬虫一桶、普通用户一桶、未知爬虫一桶,每桶独立zone独立阈值,挡DDoS也挡乱爬。 - 第4维rDNS反向解析:对宣称自己是GoogleBot/Bingbot的请求做PTR反查 + 正向解析双闸验证,这是Google官方推荐的唯一能识别假UA的方法,假冒IP直接退化为未知爬虫桶限速。 - 第5维log归因:access_log自加 $bot_tag、$rdns_verified、$rate_bucket三个字段,每周用GoAccess或者Loki+Grafana看清楚谁被挡了、谁误伤了、谁漏过了,5维配置不归因等于盲拳。 5维之间不是并列关系,是一条流水线:请求进来先过白名单(命中直接放)、不命中走UA校验(命中坏UA直接403)、再走limit_req分桶限速、宣称大厂的额外做rDNS验证、最后所有动作都打进access_log。下面5个H2把每一维拆开讲清楚。 ## Nginx配置位1 — 白名单怎么写才不漏GoogleBot? 白名单是5维里优先级最高的一道,但也是被低估最严重的一道。很多客户站的Nginx配置里没有白名单,直接deny by UA把Googlebot也按“非常规流量”挡了,然后在GSC里看到爬取错误率飙升才反应过来。 白名单要满足两个条件才能真正不漏:第一是IP CIDR来源必须用Google/Bing官方发布的IP段,不是抄某个博客贴出来的列表;第二是要和UA做双向校验——光验IP不验UA容易把代理服务器误判,光验UA不验IP容易被假冒身份骗。 Google现在每天会更新googlebot.json(在developers.google.com路径下),Bing也维护一份类似的bingip2.json,国内Baiduspider没有官方JSON但PTR域名后缀稳定(.baidu.com与 .baidu.jp)。我的做法是写一个每天04:00跑的cron,把这两份JSON拉下来转成Nginx的geo模块加载格式: geo $is_google_ip { default 0; 66.249.64.0/19 1; 34.100.182.96/28 1; 34.101.50.144/28 1; 34.118.254.0/28 1; # ...动态生成 } map $http_user_agent $is_google_ua { default 0; ~*googlebot 1; ~*googlebot-image 1; ~*googlebot-video 1; ~*googleother 1; } map $is_google_ip$is_google_ua $google_verified { default 0; “11” 1; } 关键是最后那个map:只有IP和UA同时命中才算验证通过,单独一个命中算可疑。可疑请求走rDNS二次验证(第4维),通过就放行不通过降级到未知爬虫桶限速。 5个客户站22周里白名单维护频率的真实分布: 客户型 | 白名单刷新频率 | 触发事件 | DTC美妆 | 每周一次自动 | Google IP范围每月新增2段 | B2B SaaS | 每月一次手动 | Bing IP半年更新一次 | 外贸建材 | 每季度一次 | 主要靠PTR反查不依赖IP白名单 | 跨境母婴 | 每周一次自动 | Baidu与360 IP段变动较频繁 | Shopify服饰 | 不维护 | 站在Shopify上Nginx不可控 | 反直觉的一点:白名单不是写一次就完事,IP段每月都在变。我见过最离谱的一个站,三年前的白名单文件原封不动用到2025年,里面一半IP段Google已经退了改新段,导致新段被误挡近30%——GSC报“Server error 5xx”了半年没人定位到Nginx。规则=白名单必须自动化更新,手工维护的白名单 = 定时炸弹。 ## Nginx配置位2 — UA校验为什么会误杀新爬虫? UA校验是5维里看起来最简单、其实坑最深的一道。简单是因为一行 `if ($http_user_agent ~* “ahrefsbot|semrushbot”) { return 403; }` 就能跑,深坑是因为UA字符串是爬虫开发者随时可以改的,写死任何版本号都会被下一版升级绕过。 过去两年里AI爬虫UA变化历史,整理成一张表能看清问题: 爬虫 | 初版UA | 当前UA | 变化时间 | OpenAI GPTBot (https://platform.openai.com/docs/bots) | GPTBot/1.0 | GPTBot/1.2 | 2024-08 1.0升1.1,2025-02升1.2 | OpenAI ChatGPT-User | 无 | ChatGPT-User/2.0 | 2024-04新增 | Anthropic ClaudeBot | ClaudeBot | ClaudeBot/1.0 | 2024-07加版本号 | Anthropic Claude-User | 无 | Claude-User/1.0 | 2024-11新增 | Perplexity | PerplexityBot/1.0 | PerplexityBot/1.0 + Perplexity-User/1.0 | 2024-10拆两个 | Common Crawl (https://commoncrawl.org/big-picture/frequently-asked-questions/) | CCBot/2.0 | CCBot/2.0 | 不变 | Apple Applebot | Applebot/0.1 | Applebot-Extended | 2024-06拆AI训练专用 | 这张表的启示是:写死版本号的规则(比如 `~* “gptbot/1\.0”`)半年内100% 失效,AI训练用专用UA(如Applebot-Extended)和爬通用网页的UA拆开了你的旧规则盖不住新拆出来的那个。 我的写法是用宽松正则只匹配主品牌名,不匹配版本号: map $http_user_agent $bot_class { default “human”; ~*(googlebot|bingbot|baiduspider|yandexbot|duckduckbot) “search”; ~*(gptbot|chatgpt-user|claudebot|claude-user|perplexitybot|perplexity-user|applebot) “ai”; ~*(ahrefsbot|semrushbot|mj12bot|dotbot|petalbot|bytespider) “seo-tool”; ~*(python-requests|scrapy|curl|wget|go-http-client|java-http-client) “script”; } 分类完了后用 $bot_class走不同的limit_req zone(第3维),不要直接deny by class——直接deny的话每次新爬虫出现都要改配置reload,灰度成本高。把分类和限速解耦后只需要每季度更新一次正则就行。 反直觉的一点:UA校验在5维里是最弱的一道,因为它是爬虫开发者主动配合才有效。写正则的目的不是“挡AI爬虫”,而是“给请求打标签”——真正决定挡不挡是后面的limit_req阈值和rDNS反查。保哥见过一个客户在UA层deny了所有AI爬虫然后抱怨ChatGPT搜不到自己的内容,问他“那GPTBot你想不想让它进来”,他愣了——他根本没想过AI训练(数据集采集)和AI推理(实时搜索)是两个UA。规则=UA校验只用来打分类标签,不直接做拦截动作。 ## Nginx配置位3 — limit_req阈值怎么调才不误伤抓取预算? limit_req (https://nginx.org/en/docs/http/ngx_http_limit_req_module.html)是Nginx反爬的核心武器,但90% 的客户配置都犯一个共同错误:给所有请求一套阈值。一套阈值的结果是要么对GoogleBot太严(误伤抓取预算)、要么对乱爬太松(CPU跑满)。正确做法是按上一维 $bot_class分桶: limit_req_zone $binary_remote_addr zone=human:10m rate=100r/s; limit_req_zone $binary_remote_addr zone=search:10m rate=20r/s; limit_req_zone $binary_remote_addr zone=ai:10m rate=5r/s; limit_req_zone $binary_remote_addr zone=seo_tool:10m rate=1r/s; limit_req_zone $binary_remote_addr zone=script:10m rate=1r/m; map $bot_class $rate_zone { default “human”; “search” “search”; “ai” “ai”; “seo-tool” “seo_tool”; “script” “script”; } server { location / { limit_req zone=$rate_zone burst=20 nodelay; # ... } } 5个分桶的阈值不能随便定,要按业务画像反推。我给客户算阈值的公式是: 搜索引擎大厂阈值 = 总页面数 × 平均更新频率 ÷(24×3600)× 安全系数3倍。比如5000 SKU站平均每页每7天更新一次,那么理论抓取请求/秒 = 5000 ÷ (7×86400) ≈ 0.008,安全系数3倍是0.024 r/s,但GoogleBot实际抓取会有突发(重新抓某个分类下全部商品),所以最终阈值定在10-20 r/s留余地。 22周里5个客户站limit_req阈值演进表: 客户型 | 初版search桶 | 当前search桶 | 初版ai桶 | 当前ai桶 | 调整原因 | DTC美妆1k SKU | 10 r/s | 15 r/s | 2 r/s | 3 r/s | 初版误伤GoogleBot抓图,放宽 | B2B SaaS 500页 | 5 r/s | 10 r/s | 1 r/s | 2 r/s | 页少抓取频次本来低,初版反而过严 | 外贸建材200页 | 5 r/s | 5 r/s | 1 r/s | 0.5 r/s | 页极少AI桶可以更严 | 跨境母婴5k SKU | 20 r/s | 30 r/s | 5 r/s | 8 r/s | 大站GoogleBot突发达25 r/s必须放宽 | Shopify服饰800 SKU | 不可控 | 不可控 | 不可控 | 不可控 | Shopify平台层不开放Nginx | 反直觉的一点:很多SEO顾问会建议“抓取预算不够就加内容、加sitemap、加内链”,但我跟踪的5个站里有 2个站SEO排名上不去的根本原因不是关键词、不是内链,是limit_req阈值定得太严 GoogleBot抓不动——只调limit_req一项30天内GSC收录数从1200涨到2800。规则=做SEO收录排查时limit_req zone配置和access_log里503/429占比要列在第一项检查清单。 ## Nginx配置位4 — rDNS反查怎么真正落地不被伪造UA骗? rDNS反向解析是5维里技术门槛最高、但效果最稳的一道。原理是:自称GoogleBot的请求来源IP,对它做PTR反查应该解析到 .googlebot.com或 .google.com域名后缀,然后对那个域名做正向解析回来必须等于原IP——这是Google官方在Search Central文档 (https://developers.google.com/search/docs/crawling-indexing/verifying-googlebot)里写明的唯一识别真假GoogleBot的方法。 问题是Nginx原生不支持rDNS动态查询,直接在location里调DNS会阻塞worker进程。落地有3个方案: - 方案A — njs模块异步查询:用Nginx官方的JavaScript子集(njs)模块写rDNS查询逻辑,异步走resolver拿到结果。优点是原生Nginx不依赖外部服务,缺点是njs学习曲线高。 - 方案B — Lua + OpenResty:openresty自带lua-resty-dns库,几行代码搞定。优点是社区案例多,缺点是要换OpenResty不能用社区版Nginx。 - 方案C — 旁路cache验证:access_log里出现自称大厂UA的请求时,用一个Python sidecar异步做rDNS验证、把结果写进Redis缓存(TTL 24小时),下一次同IP来请求直接读缓存。优点是Nginx配置极简,缺点是首次访问没缓存时还是放行。 我的5个客户里3个用方案C、2个用方案B(OpenResty)。方案A听起来最纯但社区案例稀少调试痛苦,不推荐给非专职运维的团队。 方案C的实战配置示例: # Nginx主配置 map $bot_class$is_google_ua $needs_rdns { default 0; “search1” 1; “ai1” 1; } # log里多打一列needs_rdns log_format with_bot '$remote_addr - $remote_user [$time_local] ' '“$request” $status $body_bytes_sent ' '“$http_referer” “$http_user_agent” ' 'bot=$bot_class rdns=$needs_rdns zone=$rate_zone'; # Python sidecar每秒tail一次access_log # 对needs_rdns=1但还没缓存的IP做PTR + 正向解析双闸 # 验证不通过的IP直接加到Nginx的deny列表(include /etc/nginx/blacklist.conf;) # 验证通过的IP加到白名单geo表里 22周里5个站rDNS反查的真实成效统计: 客户型 | 22周总请求宣称大厂UA | rDNS验证通过 | 验证失败(假冒) | 失败IP来源Top 3 | DTC美妆 | 184万 | 168万 | 16万(8.6%) | .ovh.com / .digitalocean.com / 中国IDC | B2B SaaS | 52万 | 49万 | 3万(5.7%) | .amazonaws.com / .vultr.com / 中国IDC | 外贸建材 | 28万 | 26万 | 2万(7.1%) | .ovh.com / .contabo.com / .hetzner.de | 跨境母婴 | 396万 | 361万 | 35万(8.8%) | .ovh.com / .leaseweb.com / 中国IDC | 4个站平均有7-9% 的“自称GoogleBot/Bingbot”流量是假的,来源高度集中在OVH、DigitalOcean、AWS这几家便宜VPS——典型的小爬虫开发者租5美金/月机器跑脚本伪造UA。这部分流量如果不做rDNS反查全靠UA校验过滤,会全部按search桶走20 r/s阈值,单IP一天能抓170万次,相当于一个小型DDoS。 反直觉的一点:保哥早年也以为UA校验 + IP白名单已经够了,做完rDNS反查才发现假冒流量占比8% 在大站每月能多消耗30%-40% 的CPU。规则=rDNS反查在5维里是性价比最高的一道,做完前对自己的反爬体系不要有信心。日志分析与爬虫验证的深度方法 (https://zhangwenbao.com/server-log-file-analysis-seo-crawl-budget-bot-verification.html)另开一篇专讲,本文重点在Nginx配置层。 ## Nginx配置位5 — log归因怎么看清谁被挡了? log归因是5维里最容易被忽略、但回报最直接的一道。前4维都是“配置侧”的事情,log归因是“验证侧”的事情——没有归因等于盲拳,配完不知道效果。 5维落地后的log配置应该包含5个自定义字段:$bot_class(来自UA校验)、$rate_zone(来自分桶映射)、$google_verified(来自白名单)、$rdns_verified(来自旁路验证)、$req_status_at_limit(被limit_req拦下时的503/429标记)。 log_format reverse_proxy '$remote_addr [$time_local] ' '“$request” $status $body_bytes_sent ' '“$http_user_agent” ' 'bot=$bot_class zone=$rate_zone ' 'google=$google_verified rdns=$rdns_verified ' 'limit_status=$limit_req_status'; access_log /var/log/nginx/access.log reverse_proxy; 有了这套log后,每周复盘清单应该看4件事: - 第1件:被挡的合法蜘蛛。grep `limit_status=REJECTED google=1`,应该接近0;如果不是0说明search桶阈值太严,要调宽。 - 第2件:被放过的假爬虫。grep `bot=search rdns=0`,应该越少越好;如果占比大于2% 说明rDNS sidecar跑得不够频繁,要调短TTL。 - 第3件:漏伤的真用户。grep `bot=human limit_status=REJECTED`,应该接近0;如果不是0说明human桶阈值太严,要么调宽要么排查爬虫UA是不是有漏识别。 - 第4件:未知爬虫桶占比。grep `bot=script`,看趋势;上涨说明新爬虫品种在涌入要更新正则。 归因工具选哪一个看团队规模和预算: 方案 | 适合规模 | 实施成本 | 归因深度 | Excel + log切片 | < 1万PV/天 | 1小时/周 | 看主要趋势够 | GoAccess实时dashboard | 1-10万PV/天 | 30分钟搭建 | 看分桶分布够 | Loki + Grafana | > 10万PV/天 | 1-2天搭建 | 可下钻到单IP单UA | Cloudflare Logpush + BigQuery | 已用CF企业版 | 付费功能 | 跨节点跨周聚合 | 反直觉的一点:很多客户做完前4维配置后觉得“万事大吉”,3个月后流量突然下降才发现search桶里GoogleBot被误伤了30%。规则=log不归因,5维配置等于盲拳;每周复盘4件事是5维的最后一道闸。独立站Cloudflare缓存治理与回源率优化决策树 (https://zhangwenbao.com/cloudflare-cache-real-world-optimization-decision-tree.html)里有相关的log切片技巧可以借鉴。 ## 22周5站误伤账本横向对照:哪一类站点最容易误伤? 5维讲完了,下面把22周5个客户站的实战账本拉出来横向对照——同样的5维配置,在不同业务类型站上误伤率差别可以达到10倍。 客户型 | SKU/页量 | 初版误伤GoogleBot比例 | 调整后误伤比例 | SEO流量变化(4个月) | 主要踩坑维度 | DTC美妆 | 1000 SKU | 4.2% | 0.3% | + 18% | UA校验把GoogleBot-Image漏了 | B2B SaaS | 500页 | 1.1% | 0.2% | + 6% | limit_req对小站本就够松 | 外贸建材 | 200页 | 0.5% | 0.1% | 持平 | 页极少没什么爬取压力 | 跨境母婴 | 5000 SKU | 11.3% | 0.8% | + 31% | limit_req阈值定20 r/s太严 | Shopify服饰 | 800 SKU | 不可控 | 不可控 | + 2% | Shopify平台层Nginx不开放 | 横向看出几个规律: - 高SKU站(跨境母婴5000、DTC美妆1000)初版误伤最高,因为GoogleBot抓取突发量大、limit_req阈值容易卡。 - 低SKU站(外贸建材200、B2B SaaS 500)初版误伤就很低,因为本来抓取频次就低,5维配置主要价值在挡乱爬虫,不在保抓取预算。 - Shopify这类SaaS平台站根本不开放Nginx配置层,只能依赖Cloudflare或者Shopify自带的Bot Management,5维方法在这类站完全用不上。 - 调整后误伤比例普遍降到1% 以下,且SEO流量在4个月内有可观增长(高SKU站增长最显著)。 22周里把5个站分成4个阶段:第1-4周打access_log基线、第5-8周配置初版5维上线、第9-16周每周复盘调整、第17-22周稳定运行做横向对照。这个时间线在中等复杂度站点是合理的,简单站可以压缩到8-12周,超复杂多语言站可能需要30周以上。 ## 5个最容易踩的Nginx配置坑是什么? 5维配置过程中我前后踩过不止5个坑,挑出影响最大、最容易在交付时复发的5个列在下面: - 坑1 — deny指令写在location内部不生效。Nginx的ngx_http_access_module处理deny/allow的优先级是按context来的,写在server块里和写在location块里执行时机不同;如果location内部还有internal redirect(rewrite ^/ /index.php?$args last),deny会被绕过。规则=deny/allow写在server块顶部,不写location内部。 - 坑2 — limit_req zone名漏定义致全站不限速。zone必须在http块用limit_req_zone指令定义,然后在server/location里用limit_req zone=name引用;如果zone名拼错(比如定义的是 `ai_bot` 引用写成 `ai-bot`),Nginx不报错但直接不限速。规则=nginx -t后必须再跑一次reload + 看error_log里有没有zone not found警告。 - 坑3 — rDNS反查直接同步阻塞Nginx worker。Nginx默认的resolver是同步的,如果在location里用set + resolver做DNS查询,每个请求都会卡50-200ms,并发上来worker全部卡死。规则=rDNS必须走njs/lua异步模块,或者走旁路sidecar不写Nginx主路径。 - 坑4 — UA正则贪婪匹配把Mozilla全ban了。新手常写 `~* “bot”` 这种宽松正则,结果把所有包含 “bot” 的UA都按爬虫处理——比如 “Mozilla/5.0 (compatible; sneakbot/1.0)” 会命中,但更糟的是GoogleBot的UA字符串里就有 “GoogleBot” 也包含 “bot”,如果白名单的优先级没在UA校验之前GoogleBot也会被挡。规则=UA正则要精确到品牌名而不是单词 “bot”,且白名单必须先于UA校验执行。 - 坑5 — 白名单map顺序错把deny all顶进白名单。Nginx的map指令是按声明顺序匹配的,default值放在第一行还是最后一行结果不同;如果在map里写 `default 0; ~*googlebot 1; deny_all 1;` 这种顺序,deny_all会覆盖googlebot。规则=map里default永远放第一行,特殊匹配规则按品牌名分组排列。 这5个坑的共同特征是Nginx -t不报错、reload成功、表面看一切正常,问题在生产跑了1-2周才在access_log里冒头。规则=5维上线后必须做7天灰度(第9-15步SOP),不能直接全量。 ## GoogleBot、Bingbot、GPTBot、ClaudeBot、PerplexityBot真实UA与IP范围怎么校验? 白名单和UA正则要写对,第一步是把5大蜘蛛的真实UA、IP范围发布页、rDNS域名后缀整理清楚。下面这张表是我维护的对照清单,每季度刷新一次: 蜘蛛 | 主要UA模式 | IP范围来源 | rDNS后缀 | 注意陷阱 | GoogleBot | ~*googlebot | developers.google.com/googlebot.json | .googlebot.com / .google.com | GoogleBot-Image / GoogleBot-Video是独立UA,要分别匹配 | Bingbot | ~*(bingbot|msnbot) | www.bing.com的bingbot.json | .search.msn.com | BingPreview是另一个UA做手机预览不是搜索抓取 | OpenAI GPTBot | ~*gptbot | platform.openai.com gptbot文档列IP | 不公开PTR后缀,靠IP范围验证 | ChatGPT-User是实时搜索UA不是训练,要分桶 | Anthropic ClaudeBot | ~*(claudebot|claude-user|claude-searchbot) | 不公开IP范围只公开UA列表 | 不公开 | 3个UA用途不同:ClaudeBot训练Claude-User实时引用Claude-SearchBot搜索 | Perplexity | ~*(perplexitybot|perplexity-user) | 不公开IP范围 | 不公开 | PerplexityBot训练Perplexity-User实时回答 | Common Crawl | ~*ccbot | commoncrawl.org/big-picture列范围 | .commoncrawl.org | 非营利但抓取量大要单独限速 | 这张表里有几个高频陷阱要点出来: - 陷阱1 — Bingbot与Bingbot AI是不同UA。Bing在2024年拆出了独立的AI训练用UA,普通Bing搜索抓取走bingbot/2.0,AI训练走BingPreview或msnbot-news,规则要写两条。 - 陷阱2 — Anthropic不公开IP范围。ClaudeBot系列没有像Google那样的IP JSON,所以rDNS反查对Claude不可用,只能靠UA + Cloudflare AI Bots Management第三方验证。 - 陷阱3 — 通用网页抓取vs AI训练数据采集是不同UA。GoogleBot是抓页面给Google搜索用,Google-Extended是Google Gemini训练用(默认放行如果你robots.txt没禁),Applebot-Extended同理。规则=白名单只放搜索抓取那个UA,AI训练UA按业务策略决定是放是挡。 - 陷阱4 — IP范围发布页路径会变。Google 2023年把googlebot.json从google.com路径迁到developers.google.com,旧路径还能访问但不再更新;自动化拉取脚本要跟着改URL。 - 陷阱5 — rDNS后缀有多个变种。GoogleBot既可能解析到 .googlebot.com也可能到 .google.com(视IP段),正向解析回来必须等于原IP才算通过。规则=rDNS验证逻辑要支持多后缀匹配,写死单一后缀会漏。 我的客户22周里发现的“自称大厂”流量来源分布前面的表已经列过,这里补一个:除了真假GoogleBot之外,还有一类需要单独识别——伪装成普通用户的headless Chrome爬虫,UA里完全没有bot字样,靠UA校验完全过滤不了,只能靠JS Challenge(Cloudflare Bot Management或者自建Turnstile)做行为校验。这部分逻辑不在Nginx 5维范围内,拦AI爬虫该不该的三层选型框架 (https://zhangwenbao.com/block-ai-bots-robotstxt-waf.html)里有更细的分层方法。 ## 不同业务客户怎么选拦截组合?6类客户决策树 5维不是每个站都要全配,按业务类型选组合才划算。保哥按22周5个客户站+此前接触的20+客户案例整理出6类决策树: - 类1 — 纯展示官网(< 100页):只需要白名单 + UA校验2维,limit_req阈值随便定一个保底值就行,rDNS反查不必上,log归因每月看一次。理由是抓取压力本来就小,过度配置不划算。 - 类2 — 中小DTC(500-1000 SKU):5维全配但limit_req阈值给得宽松,rDNS走旁路方案C不挂主路径,log归因每周一次。理由是GoogleBot抓取频次中等,误伤代价中等。 - 类3 — 大DTC(5000+ SKU):5维全配且limit_req阈值要按蜘蛛分桶细分到至少6个zone(search、ai-train、ai-realtime、seo-tool、script、human),rDNS必须上方案A或B实时验证,log归因每天一次。理由是抓取预算是SEO收录的硬瓶颈,误伤代价巨大。 - 类4 — B2B资讯站(页中等量):白名单 + UA校验 + limit_req 3维即可,rDNS反查可以跳过,log归因每月一次。理由是流量来源以直接访问和内链为主,搜索流量占比不主导。 - 类5 — SaaS文档站(万级页):5维全配且要为GoogleBot-Image、GoogleBot-News等子UA单独留zone,rDNS必须上,log归因每周一次。理由是文档站搜索流量占比极高,且GoogleBot多种子UA同时活跃。 - 类6 — Shopify/Wix等SaaS平台站:5维全部不可控,转走平台层Bot Management(Shopify用Cloudflare、Wix用平台自带)+ Cloudflare企业版的AI Audit。理由是平台层Nginx不开放,配置层5维方法不适用。 决策树的逻辑入口是三个问题:第1问页面数(< 100 / 100-1000 / 1000-10000 / > 10000)、第2问平台(自有服务器vs SaaS平台)、第3问搜索流量占比(< 30% / 30-70% / > 70%)。三个答案组合出6类的归属。 反直觉的一点:很多顾问会建议“所有站都配5维”,但中小站做完5维的维护成本(每周复盘1小时 + 每月白名单同步1小时 + 每季度UA正则更新1小时)一年累计40+小时人力,对纯展示官网而言收益远低于成本。规则=5维是工具不是目标,按业务收益反推决定配几维。 ## 12步Nginx反爬上线SOP怎么走? 5维选定后落地按12步SOP走,每一步都有验收点不能跳: - 第1步 — access_log 1周打底。开启完整access_log记录现状抓取分布,不做任何拦截。验收=至少7×24小时数据。 - 第2步 — 识别现有蜘蛛UA分布。用awk+sort+uniq切出Top 50 UA,标记哪些是大厂、AI、SEO工具、脚本爬虫。验收=识别覆盖率 > 95%。 - 第3步 — 分桶5类。按UA分类对应到search/ai/seo-tool/script/human 5桶,剩余未识别归human桶(保守策略)。验收=5桶覆盖100% 流量。 - 第4步 — 白名单配置IP+UA双向。从google/bing官方JSON拉IP段配geo模块,map UA正则做双向校验。验收=nginx -t通过 + 测试GoogleBot UA模拟请求200。 - 第5步 — UA校验正则。第2维map配置,分类到 $bot_class。验收=测试用例覆盖5桶各至少3个UA样本。 - 第6步 — limit_req zone定义。第3维5个zone按业务画像反推阈值。验收=nginx -t通过 + ab压测各zone阈值符合预期。 - 第7步 — rDNS njs模块。第4维落地(方案A/B/C选其一)。验收=模拟ovh.com IP自称GoogleBot应被识别为假冒。 - 第8步 — log重打tag。第5维log_format注入5个自定义字段。验收=access_log新格式可读且GoAccess能解析。 - 第9步 — 灰度10% 流量。用split_clients或者Nginx upstream weight把10% 流量切到新配置,90% 留在旧配置做对照。验收=灰度组与对照组流量分布无显著差异。 - 第10步 — 观察7天误伤率。grep limit_status=REJECTED google=1 / bot=human limit_status=REJECTED两类异常。验收=两类异常占比均 < 1%。 - 第11步 — 全量上线。把灰度比例从10% 拉到100%,旧配置保留30天作回滚备份。验收=GSC抓取错误率7天内不升高。 - 第12步 — 每周复盘。按第5维log归因的4件事清单做每周1小时复盘,发现异常调阈值。验收=持续运行不少于8周。 12步走完整套流程在中等复杂度站点(1000-5000 SKU)大约需要5-7周,简单站可以压缩到3-4周,复杂站(多语言 + 多业务线)可能10+周。规则=SOP不能跳第9-10步灰度,直接全量上线一旦误伤GoogleBot需要2-4周才能恢复GSC收录信号。 ## 什么情况下别做Nginx拦截?5个反信号 5维方法有适用边界,下面5个反信号出现就不要做,配了反而是负面收益: - 反信号1 — 站点流量低于1000 UV/天。爬虫流量绝对值很小,5维带来的运维负担超过收益。建议=robots.txt + 简单UA黑名单足够。 - 反信号2 — 已经在用Cloudflare企业版的Bot Management。CF的Bot Management比Nginx 5维更细,且不消耗自有服务器资源;Nginx层再做5维等于重复劳动还可能产生规则冲突。建议=CF配为主,Nginx只做最低限度的回源保护,AI爬虫流量趋势可以横向看Cloudflare Radar (https://radar.cloudflare.com/)的公开数据印证。 - 反信号3 — 团队没有专职运维定期看log。5维上线后第12步每周复盘是关键,没人看log等于配了个定时炸弹——3个月后IP段变了、新爬虫出现了,全部默默失效。建议=按月外包给一个能看log的顾问,或者退回到反信号1的简化方案。 - 反信号4 — 站点正在主动加入AI数据共享。如果业务策略是AI时代主动让GPTBot/ClaudeBot抓取以换取AI回答里的引用,那5维里ai桶应该完全放行,不应该限速。建议=把5维改成“4维 + ai桶完全白名单”。 - 反信号5 — 站点是纯销售线索B2B流量来源已稳。流量不是KPI(销售线索是),SEO抓取量的小幅波动不影响销售管线,5维上线的投入回报比低。建议=robots.txt加GPTBot Disallow(如果不想被训练)+ Cloudflare免费版Bot Fight Mode已足够。 反信号判断的核心逻辑是:5维方法的真正价值在“保抓取预算 + 控未知爬虫资源 + 反假冒身份”三件事,如果业务上这三件事都不是关键瓶颈,那5维就是过度工程。 保哥见过最浪费的一个案例:一个日均200 UV的纯展示官网,团队花了3个月配完5维结果第4个月就因为没人维护IP段过期了GoogleBot被挡35%,最后干脆把5维全删了回到robots.txt + 简单黑名单。规则=先按反信号自检,过反信号闸再决定上5维。Apache .htaccess SEO 6层综合治理 (https://zhangwenbao.com/apache-htaccess-seo-6-layer-rewrite-cache-canonical-hsts.html)那篇里有Apache视角的反信号清单可以横向对照。 ## 常见问题解答 ## Nginx 5维拦AI爬虫和直接用robots.txt区别有多大? robots.txt是君子协定,所有AI爬虫里只有GoogleBot严格遵守,GPTBot/ClaudeBot/PerplexityBot在2024-2025年都有过被实测无视robots.txt的记录。Nginx 5维是物理层拦截,不依赖爬虫主动配合。两者关系:robots.txt表达意图(给守规矩的爬虫看),Nginx 5维强制执行(给不守规矩的爬虫挡)。规则=两者都要有,不是二选一。 ## 做完5维后Cloudflare还需要开Bot Management吗? 看用CF哪一档。CF免费版的Bot Fight Mode只挡基础爬虫,与Nginx 5维冲突小可以共存;CF Pro版的Super Bot Fight Mode与5维有功能重叠但CF是边缘节点先过滤、Nginx是回源前再过滤,纵深防御对大站有意义;CF企业版的Bot Management比Nginx 5维细很多,那Nginx层可以简化只保留白名单和limit_req兜底。规则=按CF档位决定Nginx 5维做几维。 ## rDNS反查会不会增加首次请求的延迟? 会,且不可忽略。直接同步rDNS在Nginx worker里查询,单次延迟50-200ms取决于DNS服务器响应。所以本文推荐方案C(旁路sidecar)——首次请求不做实时验证,先按UA校验放行,sidecar异步验证完写Redis,第二次请求开始才走完整rDNS路径。代价是首次请求可能放过1次假爬虫,但避免了主路径阻塞。规则=rDNS验证必须异步,主路径不能等同步DNS。 ## 5维上线后GSC抓取统计应该看哪些指标? 看4个指标:第1是抓取请求总数(应该稳定或略升)、第2是平均响应时间(应该不变或略降)、第3是抓取错误率(应该不升)、第4是按响应状态分布(5xx/429占比应该接近0)。前4周抓取请求总数可能小幅波动(GoogleBot适应新配置),4周后应该稳定。如果4周后抓取请求总数比上线前低20% 以上,大概率是limit_req阈值定得太严,要回去调宽。 ## 不同业务类型站做完5维SEO流量都能涨吗? 不一定。高SKU站(5000+ SKU)做完5维后4个月SEO流量平均涨20-30%,因为抓取预算之前被limit_req卡死;中等SKU站(500-1000 SKU)涨幅5-15%;低SKU站(< 200页)几乎不涨,因为抓取预算本来就不是瓶颈。规则=5维的SEO收益与站点规模正相关,小站做5维收益主要在挡乱爬虫节省CPU不在涨流量。 ## 5维方法在国内服务器(阿里云、腾讯云、宝塔面板)上有什么特殊要求? 3点特殊要求:第1是Baiduspider与360Spider的IP范围没有像Google那样的官方JSON,要靠PTR反查 .baidu.com / .so.com后缀验证;第2是宝塔面板的Nginx配置在vhost文件里改了之后宝塔会自动覆盖,必须用include /etc/nginx/conf.d/custom.conf的方式把5维配置写在vhost之外;第3是阿里云/腾讯云的安全组默认会限速一部分高频请求,做5维之前要确认云厂商安全组的限速规则不会与Nginx limit_req冲突。 ## 权威参考资料 ## DedeCMS目录禁PHP解析加固:nginx与Apache配置加文件权限 - URL:https://zhangwenbao.com/nginx-dedecms-php-deny-all.html - 分类:Nginx - 发布:2021-09-16 | 更新:2026-06-02 - 摘要:织梦被传webshell,多半是上传目录还能解析PHP。本文给出安全加固:用一段nginx的location正则deny all屏蔽uploads、templets、data等目录的PHP解析,附Apache等效写法、权限矩阵、open_basedir与disable_functions加固和应急清理流程。 - 关键词:宝塔面板,目录权限,DedeCMS安全,Nginx配置 > **TLDR**:摘要:织梦被传webshell,多半是上传目录还能解析PHP。本文给安全加固——用一段nginx的location正则deny all屏蔽uploads、templets、data等目录的PHP解析,附Apache等效配置、文件系统权限配合,再讲除了禁PHP还要做的加固、配置验证、被攻击后的应急、典型攻击案例复盘和与ModSecurity与Cloudflare WAF的分工。 > 摘要:织梦被传webshell,多半是上传目录还能解析PHP。本文给安全加固——用一段nginx的location正则deny all屏蔽uploads、templets、data等目录的PHP解析,附Apache等效配置、文件系统权限配合,再讲除了禁PHP还要做的加固、配置验证、被攻击后的应急、典型攻击案例复盘和与ModSecurity与Cloudflare WAF的分工。 2014 年保哥接手过一个三天两头被挂马的织梦 DedeCMS 站点,每次清理完没几天又被植入新马,最后排查下来根本原因不在 DedeCMS 自身的漏洞,而是攻击者通过编辑器上传图片接口把 木马.php.jpg 这种伪装文件丢到 /uploads/ 目录,再通过路径直接访问 uploads/xxx/shell.php 执行恶意代码。从那以后保哥养成了一个固定习惯:所有 DedeCMS 站点上线第一件事,就是把 uploads、templets、images、html 这类不需要执行 PHP 的目录在 nginx 层 deny 掉 PHP 解析。这一道防线挡住了 80% 以上的 PHP 后门攻击。本文记录这套方案的完整配置、攻击原理、相关安全加固的全套细节。 ## 为什么要禁止特定目录执行 PHP 织梦 DedeCMS 这类老 CMS 之所以挂马频繁,根本原因是"凡是能被上传文件的目录都可能被植入 PHP 后门"。攻击者常见手法有以下几种: - 编辑器漏洞上传。FCKEditor、UEditor、CKEditor 等富文本编辑器历史上有过若干上传校验绕过漏洞,攻击者通过构造特殊的 Content-Type、Magic Number 把 PHP 文件伪装成图片上传到 /uploads/。 - 文件管理器越权。后台文件管理器如果鉴权不严,攻击者拿到普通用户账号也能传 PHP 到任意目录。 - 会员投稿接口。开放注册的站点,会员投稿往往附带"上传图片"功能,逻辑漏洞同样可能被利用。 - 第三方插件。织梦插件生态早期审核宽松,部分插件自带的上传组件就是后门入口。 - 历史遗留。多年前被入侵留下的后门可能还潜伏在某个图片目录里,没被清干净。 这些攻击手法的共同特征是:把 PHP 代码丢到一个本不该执行 PHP 的目录。如果 web 服务器看到 /uploads/xxx.php 直接交给 PHP-FPM 解析,攻击就成功;如果在 nginx 层就拦截掉,deny all 返回 403,攻击就失败。这就是本文方案的核心思路:白名单思维——只在确实需要 PHP 解析的目录开放,其他目录一律禁止。 ## 宝塔面板 nginx 完整配置 登录宝塔面板 (https://zhangwenbao.com/bt-panel-upgrade-failed.html),进入对应站点的"配置文件"编辑界面(也可以直接 SSH 编辑 /www/server/panel/vhost/nginx/yourdomain.conf)。在 server { ... } 块内、紧挨着 root 行下面,加入以下配置: location ~* ^/(include|uploads|a|templets|skin|images|html|data|special)/.*\.(php|php5|php7|phtml|pht)$ { deny all; return 403; access_log off; log_not_found off; } 这段配置的几个关键细节: - ~* 是大小写不敏感的正则匹配,能拦住 .PHP、.Php 这类大小写变体。 - ^/(...)/ 限定只匹配以这些目录名开头的路径。如果你的站点这些目录有别名或软链,需要把别名也加进来。 - \.(php|php5|php7|phtml|pht)$ 覆盖 PHP 解析器可能识别的所有扩展名。.phtml 和 .pht 是历史上被绕过验证常用的扩展,强烈建议一并屏蔽。 - deny all + return 403 双保险,确保不会因为某些 nginx 版本的解析差异让请求漏过去。 - access_log off 减少日志噪音,因为这类请求一旦发生通常是攻击探测,记下来意义不大反而消耗磁盘。 保存配置后宝塔会自动 reload nginx,几秒钟内生效。如果是手动改的 conf 文件,记得 nginx -t 测试语法后 nginx -s reload。 ## 目录清单与责任分工 织梦 DedeCMS 默认目录结构里,建议禁 PHP 的目录及其用途: - /uploads/:用户上传的图片、附件、文档。100% 不应该执行 PHP。 - /templets/:前端模板文件目录。模板里只有 .htm、CSS、JS、图片,不需要 PHP 解析(DedeCMS 的模板渲染是在编译阶段完成的,渲染后的 HTML 输出文件在别的地方)。 - /skin/:后台皮肤资源(CSS、JS、图片)。 - /images/:站点公用图片资源。 - /html/:DedeCMS 静态生成的 HTML 文件目录(如果开启了静态生成)。 - /a/:DedeCMS 默认的文章静态生成根目录(取决于站点配置)。 - /include/:核心 PHP 文件,但对外不应该暴露访问——所有调用都通过其他入口文件 require_once 引入。所以禁止外部直接访问 /include/xxx.php 是合理的。 - /data/:缓存、备份、临时数据。绝对不能执行 PHP,因为这里有数据库备份文件、敏感配置缓存。 - /special/:专题页目录(如果用了专题功能)。 需要保留 PHP 执行的目录有:根目录入口文件(index.php、list.php、view.php、tag.php 等)、/dede/ 后台目录(建议改名)、/member/ 会员中心、/plus/ 扩展目录(如果在用)。 ## Apache 环境的等效配置 如果站点跑在 Apache 上,可以通过 .htaccess 实现同样效果。在 /uploads/、/templets/ 等需要禁 PHP 的目录里,分别放一份 .htaccess: <FilesMatch "\.(php|php5|php7|phtml|pht)$"> Order Deny,Allow Deny from all </FilesMatch> Apache 2.4 及以上推荐用新语法: <FilesMatch "\.(php|php5|php7|phtml|pht)$"> Require all denied </FilesMatch> Apache 的 .htaccess 方案有两个优势:一是粒度细,每个目录单独控制;二是修改不需要 reload web 服务。劣势是每次请求都会读 .htaccess 文件,性能不如 nginx 集中配置好。如果你的站点流量大且仍在用 Apache,建议把规则迁到主配置文件里减少 .htaccess 解析开销。 ## 文件系统权限配合 nginx 层的 PHP 解析屏蔽是第一道墙,文件系统权限是第二道。两道叠加才稳。织梦 DedeCMS 推荐的目录权限: - 网站根目录:755(所有者读写执行,其他用户读执行)。 - /data/:750(仅 owner + group 可访问,禁止 other 读取,避免 data/sqldata/*.txt 数据库备份被外网爬走)。 - /uploads/:755(PHP-FPM 进程要能写入上传文件)。 - /dede/:755 或 750(后台代码,建议改名为非默认 dede 后再设权限)。 - /include/:755。 - 所有 PHP 文件:644(owner 读写,其他读,禁止任何用户写——攻击者就算上传了 PHP 也无法覆盖现有文件)。 - 所有目录:755(保证 web 进程能进入)。 批量设置命令(在站点根目录执行): find . -type d -exec chmod 755 {} \; find . -type f -exec chmod 644 {} \; chmod -R 750 ./data/ chown -R www:www ./ 注意 owner 要是 web 服务用户(宝塔默认 www,CentOS 默认 nginx 或 apache)。如果上传写入失败,多半是 owner 错了,chown 修一下即可。 ## 加固清单:除了禁 PHP 还要做的 禁 PHP 是基础线,下面这几项加上去防御就比较完整了: - 后台目录改名。把 /dede/ 改成不规则的字符串如 /admin_a8x7k2/,再配合 IP 白名单访问,能挡住绝大多数自动化扫描。 - install.php 删除或断网。安装完成后 install/index.php.bak 文件务必删掉或加 deny all,历史上有大量站点是因为 install 文件没删被攻击者重装。 - plus 目录限制。/plus/ 下很多文件是历史漏洞重灾区(如 recommend.php、flink_add.php),不需要的功能直接删掉对应文件。 - 禁用 PHP 危险函数。在 php.ini 里 disable_functions 加上 exec, passthru, shell_exec, system, proc_open, popen, eval 等,即使有后门也无法执行命令。 - open_basedir 限制。把 PHP 进程能访问的文件路径限制在站点根目录内,攻击者无法跨站读 /etc/passwd。 - WAF 接入。宝塔自带的 nginx 防火墙、阿里云 WAF、Cloudflare WAF 任选其一,对常见攻击 payload 做规则拦截。 - 文件完整性监控。AIDE、tripwire 或宝塔的"文件监控"功能,对 PHP 文件做哈希校验,新增/修改文件立即告警。 - 定期备份。代码 + 数据库每周完整备份,备份保留至少 30 天滚动,避免被攻击后无干净版本可恢复。 ## 验证配置是否生效 配置完成后必须验证一遍是否真的生效。验证步骤: 第一步,SSH 登录服务器,在 /uploads/ 目录创建一个测试 PHP 文件: echo "<?php phpinfo(); ?>" > /www/wwwroot/yoursite/uploads/test_check.php 第二步,用浏览器访问 https://yoursite.com/uploads/test_check.php。如果返回 403 Forbidden,说明 nginx 配置生效了;如果返回 phpinfo() 页面,说明配置没生效,需要回查 nginx 配置和 reload 状态。 第三步,访问 /uploads/somepic.jpg 类正常图片,确保 nginx 没误伤——只有 .php 文件被屏蔽,其他文件类型应该正常返回。 第四步,测试完务必删除测试文件:rm /www/wwwroot/yoursite/uploads/test_check.php,避免留隐患。 第五步,把这个测试用例做成脚本,每次 nginx 配置变更后自动跑一次,避免下次有人改 nginx 时把这条规则注释掉了没人知道。 ## 被攻击后的应急处理 如果你接手的站点已经挂马了,光配 nginx 不够,得先清干净。保哥的应急清理流程: - 立刻断网。在防火墙层把 80/443 端口封掉,阻止攻击者持续访问后门。 - 全站文件备份。打 tar 包带走,留作取证。 - 找出后门文件。用 grep -rEn "(eval|base64_decode|gzinflate|str_rot13|assert)\(" /www/wwwroot/yoursite/ 扫描含可疑函数的 PHP 文件,逐个人工确认。 - 检查 webshell 特征。常见后门特征:单一文件含完整 PHP 入口 + 加密通信 + 文件管理 + 命令执行;用 find 按修改时间筛最近变更的文件 find /www/wwwroot/yoursite -name "*.php" -mtime -7。 - 对比官方代码。从 DedeCMS 官方下载同版本干净源码,diff 出所有被改动的文件。 - 清理数据库。后门也可能藏在数据库里(管理员表、文章正文 onload 注入),逐表 SELECT 高危关键字。 - 重置所有密码:管理员账号、数据库密码、SSH 密钥、宝塔面板密码。 - 部署本文配置。nginx deny PHP + 文件权限收紧 + WAF 上线。 - 恢复网络访问,但先内网测试 24 小时确认没有残留后门。 ## 其他 CMS 的等效配置参考 这套思路并非 DedeCMS 独占。其他主流 CMS 的"禁 PHP 目录清单"参考: - WordPress:wp-content/uploads/、wp-content/themes/*/assets/ 等需要禁 PHP。 - Typecho:usr/uploads/、usr/themes/*/assets/ 需要禁 PHP。 - Discuz (https://zhangwenbao.com/discuz-global-variables-details.html):data/attachment/、uc_server/data/、data/cache/ 需要禁 PHP。 - ECShop (https://zhangwenbao.com/ecshop-prompts-deprecated-preg_replace-to-report-incorrect-solutions.html) / ECTouch (https://zhangwenbao.com/ecshop-mobile-ectouch.html):images/、data/、themes/ 需要禁 PHP。 - 帝国 CMS:d/file/、d/txt/、d/js/、e/template/ 需要禁 PHP。 - Drupal / Joomla:sites/default/files/、images/、media/ 需要禁 PHP。 所有 CMS 的逻辑都一样:"上传目录、模板资源目录、缓存目录绝对不允许执行 PHP",这是通用安全准则。 ## 监控告警与持续运维 配置完成不代表万事大吉,建议长期开启以下监控: - nginx access.log 异常 PHP 请求统计。每天用 awk 统计 /uploads/.*\.php 类请求数量,突然飙升说明有人在扫描。 - 403/404 突增告警。如果一夜之间 403 数量从几十涨到几千,多半遇到了自动化攻击。 - 文件改动告警。inotify-tools 或宝塔的文件监控对 /uploads/ 目录做监听,新增 .php 文件立即告警。 - 登录日志。后台管理员登录、SSH 登录、宝塔面板登录,全部接入告警通道(邮件/钉钉/企业微信)。 - 季度安全审计。每季度跑一次 find / -newer /tmp/last_audit -type f 找出近期修改的所有文件,过一遍是否合规。 这些监控加上去,攻击发生时能在 5-30 分钟内发现并响应,远比"被挂马半年才发觉"强得多。 ## 典型攻击案例复盘 保哥这几年实战中处理过的几个典型 DedeCMS 挂马案例,每个都对应了不同的攻击向量,合并起来能帮你建立更完整的防守认知。 案例一:UEditor 类型校验绕过。某地方门户站,攻击者把一个 PHP 后门通过 UEditor 上传接口传到 /uploads/allimg/202309/shell.php。原因是 UEditor 早期版本只校验了 Content-Type 头,没校验文件实际内容,攻击者用 Content-Type: image/jpeg 但 body 里塞 PHP 代码就绕过了。这个案例的关键启示是:不能把上传文件的安全完全交给应用层,nginx 层 deny PHP 是兜底防线。 案例二:plus/recommend.php SQL 注入升级。一个旧 DedeCMS 5.7 站点,攻击者利用 plus/recommend.php 的 SQL 注入漏洞读出管理员账号密码,登录后台后通过"模板管理 → 文件管理"上传 PHP 木马到 /data/cache/ 目录。如果当时已经禁了 /data/ 的 PHP 解析,即使后台被攻入,木马也跑不起来——攻击链就被切断了。 案例三:第三方插件后门。一个使用某"高仿 SEO 插件"的 DedeCMS 站点,插件本身就带了一个隐藏的 webshell 路由 /include/dedeseo/back.php。这种"自带后门"的插件防不胜防,禁 include 目录的 PHP 解析能直接挡住这类攻击。当然 include 目录确实存在被引用的合法 PHP 文件,但这些文件正常使用应该是被 require_once 引入而不是直接 URL 访问,所以禁直接访问没有副作用。 案例四:data 目录敏感文件泄露。一个站点没有禁 /data/ 目录,攻击者直接访问 data/sqldata/dede_admin-id.txt 读到了管理员密码哈希。这个案例展示了 deny 不仅能防止 PHP 执行,对敏感文件的读取保护同样重要。建议把 /data/ 整个目录直接 deny all(不管什么扩展名),写法是: location ^~ /data/ { deny all; return 403; } 这样连 .txt、.sql 备份文件都读不到。 ## 与 ModSecurity / Cloudflare WAF 的分工 有些同学已经接入了 WAF(云 WAF 或 ModSecurity),是不是就不用配本文的 nginx deny 规则了?答案是不行,二者是不同层次的防御: - WAF:基于流量特征做规则匹配,拦截已知攻击 payload。优点是覆盖广,劣点是规则一旦过时新攻击方式会绕过;且 WAF 默认信任来自服务器内部的请求,如果攻击者已经传了文件到服务器,访问该文件不会经过 WAF(特别是 ModSecurity 装在反代层时)。 - nginx 目录禁 PHP:基于路径/扩展名做静态规则,无论攻击 payload 多新颖,只要 PHP 文件落在被禁目录就跑不起来。 - fail2ban:基于 access.log 模式封 IP,对暴力探测有效但对单次攻击不及时。 - SELinux / AppArmor:操作系统级别的强制访问控制,PHP 进程即使想读 /etc/passwd 也读不到。这层最底,配置最复杂,多数中小站没启用。 四层叠加才是完整防御。本文的 nginx 配置是其中性价比最高的一层——配置简单、零运维成本、对正常业务零影响、却能挡住绝大多数自动化攻击。建议在 WAF 之外一定要加上。 ## 自动化巡检脚本 保哥写了一个简单的 bash 脚本,每天 cron 跑一次,自动检查 nginx 配置和 uploads 目录是否还安全: #!/bin/bash SITE_ROOT="/www/wwwroot/yoursite.com" NGINX_CONF="/www/server/panel/vhost/nginx/yoursite.com.conf" # 1. 检查 nginx 配置是否还包含 deny 规则 if ! grep -q "deny all" "$NGINX_CONF" || ! grep -q "uploads.*\.php" "$NGINX_CONF"; then echo "[ALERT] nginx deny 规则缺失" fi # 2. 扫描 uploads 目录是否有 PHP 文件 PHP_FILES=$(find "$SITE_ROOT/uploads" -name "*.php" 2>/dev/null) if [ -n "$PHP_FILES" ]; then echo "[ALERT] uploads 目录发现 PHP 文件:" echo "$PHP_FILES" fi # 3. 检查近 24 小时被修改的 PHP 文件 RECENT=$(find "$SITE_ROOT" -name "*.php" -mtime -1 2>/dev/null) if [ -n "$RECENT" ]; then echo "[INFO] 近 24 小时修改的 PHP 文件:" echo "$RECENT" fi 把这个脚本放到 /root/daily_check.sh,加 chmod +x,再 crontab -e 里加一行 0 6 * * * /root/daily_check.sh | mail -s "DedeCMS 安全巡检" you@example.com,每天早 6 点出一份巡检报告。任何异常都能在当天发现。 ## 常见问题解答 Q1:配置后图片上传不影响吧? 不影响。这条规则只针对 .php、.phtml 等扩展名,.jpg、.png、.gif、.webp 等正常图片格式不会被拦截。/uploads/ 目录依然可以正常存放和访问图片资源。 Q2:站点本来就靠 /uploads/preview.php 这种文件提供预览功能怎么办? 如果有合法的 PHP 入口在被屏蔽的目录下,需要单独放行。可以在 nginx 配置里加一条更具体的 location = /uploads/preview.php { ...正常 PHP 解析配置... },= 是精确匹配优先级最高,会绕过通用 deny all 规则。但更推荐的做法是把这种 PHP 文件迁出 uploads 目录,从架构上避免开洞。 Q3:宝塔面板的"防跨站攻击"开关跟这个有什么关系? 不一样。宝塔的"防跨站攻击"是 open_basedir 限制,让 PHP 进程不能跨站读其他站的文件。本文方案是 nginx 层不让某些目录的 PHP 被执行,二者是互补关系,建议都开启。 Q4:deny all 和 return 403 选哪个? 都行。deny all 是 ngx_http_access_module 的指令,最终也是返回 403。return 403 是 ngx_http_rewrite_module 的指令,更直白。两个一起写不冲突,是为了在某些边缘情况下双保险。如果只能写一个,return 403 更现代化。 Q5:会不会因为这个配置导致网站访问慢? 不会。nginx 的 location 正则匹配在编译期完成,匹配开销极小,平均每请求增加几微秒。比起被植入后门后清理代价低 10000 倍。 Q6:DedeCMS 已经停止维护了,这套加固还有意义吗? 有意义而且更重要。停止维护意味着不再有官方安全补丁,新发现的漏洞没人修,被动防守压力更大。这种情况下做好"目录禁 PHP + 文件权限收紧 + WAF + 监控"四道防线,是延长老站安全寿命的关键。如果业务允许,建议规划 12-24 个月内迁移到 WordPress、Typecho 或自研静态站。 Q7:把这套配置放在 nginx server 块还是 location 块? 放在 server { ... } 块内、其他 location 之前。nginx 的 location 匹配顺序是:精确匹配 = 优先 → ^~ 前缀匹配 → 正则匹配(按出现顺序) → 普通前缀匹配。本文的 ~* 正则匹配,写在 server 块内即可,不需要嵌套到其他 location 里。 这套配置部署完,DedeCMS 站点的安全水位会明显提升一档。下一篇保哥会继续讲怎么用 fail2ban + nginx 联动自动封禁恶意 IP,敬请期待。 ## Nginx反向代理实战指南:proxy_pass末尾斜杠、proxy_redirect、sub_filter、WebSocket与upstream全场景配置 - URL:https://zhangwenbao.com/nginx-proxy.html - 分类:Nginx - 发布:2020-12-22 | 更新:2026-05-16 - 摘要:Nginx反向代理远不止网传那几行proxy_pass。本文逐一拆解proxy_pass末尾斜杠带不带URI的四种拼接差异、proxy_redirect改写Location头、sub_filter替换响应体的三大限制,再覆盖WebSocket、SSE流式、大文件上传、upstream调度算法等生产场景。 - 关键词:https,反向代理,Nginx > **TLDR**:摘要:Nginx反向代理远不止网传那几行proxy_pass。本文重点拆proxy_pass末尾斜杠带不带URI的关键差异、proxy_redirect处理后端发的301与302、sub_filter替换响应体绝对路径,再覆盖WebSocket、SSE流式与长轮询、大文件上传、upstream负载均衡、proxy_cache减少回源和HTTPS代理的特殊配置,附实战调试技巧。 > 摘要:Nginx反向代理远不止网传那几行proxy_pass。本文重点拆proxy_pass末尾斜杠带不带URI的关键差异、proxy_redirect处理后端发的301与302、sub_filter替换响应体绝对路径,再覆盖WebSocket、SSE流式与长轮询、大文件上传、upstream负载均衡、proxy_cache减少回源和HTTPS代理的特殊配置,附实战调试技巧。 Nginx 反向代理 (https://zhangwenbao.com/apache-proxy.html)是把内部服务藏在统一域名背后的核心手段,但实际配置里至少有十类常见场景:泛目录、子目录、整站、带斜杠与不带斜杠的 proxy_pass 行为差异、proxy_redirect 修正 location 头、sub_filter 替换响应体绝对路径、WebSocket 升级、流式输出、上传大文件、grpc 转发。本文按场景给出最小可工作的 nginx server 块配置,并讲清每条指令的边界条件——比如 proxy_pass 末尾斜杠的有无会影响整个请求路径的拼接逻辑,sub_filter 只对未压缩响应体生效等等。 ## 反向代理与正向代理的边界 先把概念厘清:正向代理面向客户端,客户端主动配置 proxy server 让它代为请求外网(公司内网用 squid 出网就是典型);反向代理面向服务器,客户端不知道实际服务在哪台机器,nginx 在前面伪装成统一入口(CDN、WAF、网关都是反向代理)。本文涉及的全部是反向代理。 反向代理的核心价值 (https://nginx.org/en/docs/http/ngx_http_proxy_module.html)是: - 统一入口:客户端只见到 example.com,背后可能是 Java 微服务、Python 服务、Go 网关多个进程并存。 - SSL 卸载:HTTPS 在 nginx 终结,后端走 HTTP 减少握手开销。 - 负载均衡:upstream 块加多台后端做轮询、加权、ip_hash。 - 缓存:proxy_cache 把热点内容缓存在 nginx 层。 - 请求改写:rewrite + proxy_pass 把 URL 形态映射到后端期望的形态。 ## proxy_pass 末尾斜杠的关键差异 这是 nginx 反向代理最容易翻车的地方,没有之一。 ## 规则简述 当 proxy_pass 后的 URL 带 URI(包括末尾斜杠 /),nginx 会把 location 匹配的部分从请求 URI 中替换掉;不带 URI 时,原始 URI 完整传递到后端。 ## 四种组合的实际行为 假设 location 是 /abc/,请求 /abc/foo/bar?x=1,对应四种 proxy_pass 写法的转发结果: proxy_pass 写法 | 转发到后端的 URI | proxy_pass http://backend; | /abc/foo/bar?x=1(完整传递) | proxy_pass http://backend/; | /foo/bar?x=1(去掉 /abc) | proxy_pass http://backend/api; | /api/foo/bar?x=1(替换 /abc 为 /api) | proxy_pass http://backend/api/; | /api/foo/bar?x=1(同上) | 注意:第三、第四种结果一样,但实现机制不同。第三种是 nginx 把 /abc/ 替换成 /api,然后拼上 /foo/bar,结果是 /api/foo/bar。第四种 nginx 把 /abc 替换成 /api/,再拼上 foo/bar,结果也是 /api/foo/bar。一致是因为运气好。 ## 规则失效的特殊场景 下面三种情况 proxy_pass 不能带 URI,nginx 会启动报错: - location 用了正则匹配(location ~ ^/api/) - location 内部用了 rewrite ... break - proxy_pass 后是变量(proxy_pass $upstream;) 这些场景下 nginx 没法做"替换 URI"动作,只能完整传递。如果你必须改写 URI,要在 location 里用 rewrite + proxy_pass 不带 URI 组合: location ~ ^/api/(.*)$ { rewrite ^/api/(.*)$ /v2/$1 break; proxy_pass http://backend; } ## 三种基础场景的最小配置 ## 整站反向代理 典型用途:example.com 反代到内部某台 IP 上的整站。 server { listen 80; server_name example.com; location / { proxy_pass http://192.168.1.10:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } 四个 proxy_set_header 是生产标配: - Host:让后端拿到原始请求 Host,不然看到的是 nginx 的 IP。 - X-Real-IP:客户端真实 IP,用于日志、风控、地域识别。 - X-Forwarded-For:经过的代理链,多级代理时按逗号拼接。 - X-Forwarded-Proto:让后端知道客户端是 HTTPS 还是 HTTP,防止后端生成的 URL 协议错。 ## 子目录反向代理(带前缀) 典型用途:example.com/api/* 反代到独立 API 服务,example.com/* 走前端。 server { listen 80; server_name example.com; location /api/ { proxy_pass http://192.168.1.20:3000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { proxy_pass http://192.168.1.10:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } } 注意 location /api/ 的 proxy_pass 末尾必须带 / —— 让 nginx 把 /api 前缀去掉再转发。如果不带 /,后端会收到 /api/users 而不是 /users,绝大多数后端会 404。 ## 泛目录(不带末尾斜杠的 location) 典型用途:example.com/abc 与 example.com/abc/foo 都转发到某后端。 server { listen 80; server_name example.com; location /abc { proxy_pass http://192.168.1.30/abc; } } 这种写法最大的坑是 example.com/abcd 也会命中 location /abc——nginx 的 location 前缀匹配不要求末尾斜杠对齐。如果要严格匹配 /abc/* 而不命中 /abcd,必须写 location /abc/。 ## proxy_redirect 处理后端发的 301/302 ## 问题描述 后端服务返回 302 跳转时,Location 头里带的是后端自己的 URL(比如 http://backend/login)。浏览器收到这个 Location 后会直接访问 backend,绕过 nginx。如果 backend 在内网,浏览器跳到内网 IP 立刻失败;如果 backend 在公网,跳过 nginx 等于绕过了 SSL 终结、WAF、统计。 ## 修复方案 用 proxy_redirect 把后端发的 Location 头改写成 nginx 路径: location /my/ { proxy_pass http://backend/; proxy_set_header Host $host; proxy_redirect http://backend/ http://$host/my/; } 这条 proxy_redirect 把 Location: http://backend/login 改写成 Location: http://example.com/my/login。三种语法形式: - proxy_redirect default; —— 默认行为,nginx 自动尝试做合理替换,对简单场景够用。 - proxy_redirect off; —— 完全不改写。 - proxy_redirect http://backend/ http://$host/my/; —— 显式指定改写规则。 ## HTTPS 终结场景的特别注意 如果 nginx 终结 HTTPS、后端是 HTTP,proxy_redirect 必须显式写: proxy_redirect http://backend/ https://$host/my/; 否则后端发的 http:// Location 头会让浏览器跳到 http 站,触发 mixed content 警告或跳转。 ## 相对路径的 Location 头 有些后端返回的 Location 是相对路径(Location: /login),这种情况 proxy_redirect 默认会处理:把 / 替换为 /my/。如果你显式写了规则但没覆盖相对路径,需要再加一行: proxy_redirect / /my/; ## 响应体内绝对路径的替换:sub_filter ## 问题:写死的 /public、/static、/api 很多老式 web 应用在模板里写死 <script src="/static/app.js">,反代到 example.com/my/ 时浏览器会请求 example.com/static/app.js 而不是 example.com/my/static/app.js。如果 example.com 根目录另有 /static/ 路径属于其它服务,请求就会落到错误的地方。 ## sub_filter 模块基础 nginx 编译时需要带 --with-http_sub_module,主流 Linux 发行版的 nginx-extras 包都带这个模块。检查方法:nginx -V 2>&1 | grep -o sub_module。 location /my/ { proxy_pass http://backend/; proxy_set_header Host $host; proxy_redirect / /my/; sub_filter 'href="/' 'href="/my/'; sub_filter 'src="/' 'src="/my/'; sub_filter 'action="/' 'action="/my/'; sub_filter_types text/html text/css application/javascript; sub_filter_once off; } ## 三个关键限制 使用 sub_filter 之前要清楚它做不到什么: - 不能处理压缩响应。如果后端发的是 gzip 压缩的响应体,nginx 看到的是字节流不是文本,sub_filter 默默失效。修复:在 location 里加 proxy_set_header Accept-Encoding ""; 强制后端返回未压缩响应。代价是 nginx 出口流量变大,可以让 nginx 自己重新 gzip。 - 只能处理静态生成的内容。JS 框架(React、Vue、Angular)在浏览器端动态拼出的 URL 不在响应体里,sub_filter 完全够不着。这种情况需要改前端代码(用 webpack publicPath)或者在前端打包时通过环境变量注入前缀。 - 多个 sub_filter 的限制。nginx 1.9.4 之前一个 location 内只允许一个 sub_filter。1.9.4 之后允许多个,但仍按顺序执行,前一条改变后的内容不会触发后一条匹配。 ## WebSocket 反向代理 (https://nginx.org/en/docs/http/websocket.html) WebSocket 需要 HTTP/1.1 与 Upgrade、Connection 头才能升级协议。默认配置下 nginx 把这两个头剥掉了,导致 WebSocket 无法建立。 map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { listen 80; server_name ws.example.com; location / { proxy_pass http://websocket_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } } 四个关键点: - proxy_http_version 1.1:WebSocket 必须 HTTP/1.1,nginx 默认对后端用 1.0。 - map 块定义 $connection_upgrade:WebSocket 升级请求 Connection 头是 upgrade,普通请求是 close,map 优雅处理两种情况。 - proxy_read_timeout/send_timeout:默认 60s,长连接 WebSocket 一闲置就被切。改成 3600s(1 小时)或更长。 - 不要 proxy_buffering:WebSocket 是流式,缓冲会导致消息延迟。建议加 proxy_buffering off;。 ## 流式响应(SSE / 长轮询) Server-Sent Events、GPT 类 streaming API、curl --raw 实时输出等场景,nginx 默认会把响应整段缓冲后再发给客户端,破坏流式效果。 location /stream/ { proxy_pass http://backend/; proxy_buffering off; proxy_request_buffering off; proxy_http_version 1.1; proxy_set_header Connection ''; chunked_transfer_encoding on; proxy_read_timeout 24h; } 关键四项:proxy_buffering off 关闭响应缓冲;proxy_request_buffering off 让客户端慢上传也立刻流向后端;Connection 设空字符串避免 Keep-Alive 头被改;chunked_transfer_encoding on 让 nginx 用 chunked 编码发送给客户端。 ## 上传大文件的代理 默认 nginx 限制 client_max_body_size 1MB,上传大文件直接 413。完整调优: http { client_max_body_size 500m; client_body_buffer_size 128k; client_body_timeout 600s; } server { location /upload/ { proxy_pass http://backend/; proxy_request_buffering off; proxy_http_version 1.1; proxy_send_timeout 600s; proxy_read_timeout 600s; } } proxy_request_buffering off 让 nginx 边收边转,不在 nginx 本地缓冲整个请求体,对大文件上传场景可以省掉 nginx 服务器磁盘 IO。 ## 负载均衡的 upstream 配置 upstream backend { least_conn; server 192.168.1.10:8080 weight=3 max_fails=3 fail_timeout=30s; server 192.168.1.11:8080 weight=2 max_fails=3 fail_timeout=30s; server 192.168.1.12:8080 backup; keepalive 32; } server { location / { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection ""; } } ## upstream 五种调度算法 - round_robin:默认轮询,按 weight 分配。 - least_conn:发送给当前连接数最少的后端,对连接耗时差异大的场景(流式、WebSocket)效果好。 - ip_hash:按客户端 IP 计算 hash 路由,同一 IP 总是去同一台后端。session sticky 的简易实现。 - hash $request_uri consistent:按请求 URI 一致性 hash,缓存场景常用。 - random two least_conn:随机选两台再用 least_conn 选其一,配合分布式系统的二选一最优策略。 ## keepalive 的关键性 upstream 块内的 keepalive 32; 让 nginx 与后端保持 32 个长连接复用,避免每次请求都新建 TCP。配合 server 块内的 proxy_http_version 1.1; proxy_set_header Connection ""; 才生效。我曾遇过没设 keepalive 的高并发站点,nginx 与后端之间每秒新建几千个 TCP 连接,把内核 nf_conntrack 表撑爆。 ## 缓存:proxy_cache 减少回源 proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=my_cache:100m max_size=10g inactive=60m use_temp_path=off; server { location / { proxy_pass http://backend; proxy_cache my_cache; proxy_cache_key "$scheme$request_method$host$request_uri"; proxy_cache_valid 200 304 10m; proxy_cache_valid 404 1m; proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; proxy_cache_lock on; add_header X-Cache-Status $upstream_cache_status; } } 关键点:proxy_cache_key 默认包含 host,多域名共用同一缓存目录时这个 key 防止串数据;proxy_cache_use_stale 让后端崩了时仍然返回过期缓存兜底;proxy_cache_lock 防止缓存击穿(同一 key 多个并发请求只放一个回源);X-Cache-Status 头返回 HIT/MISS/BYPASS/STALE 让你能在浏览器观察。 ## HTTPS 反向代理的特殊配置 server { listen 443 ssl http2; server_name example.com; ssl_certificate /etc/ssl/certs/example.com.pem; ssl_certificate_key /etc/ssl/private/example.com.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; ssl_prefer_server_ciphers off; ssl_session_cache shared:SSL:10m; location / { proxy_pass http://backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; } } server { listen 80; server_name example.com; return 301 https://$server_name$request_uri; } 常见错误是 X-Forwarded-Proto 没显式写 https,让后端误以为是 http 请求并生成 http 链接,浏览器加载时被 mixed content 拦截。 ## 实战调试技巧 ## 看真实转发出去的请求 临时在 location 里加 error_log /var/log/nginx/debug.log debug;,再 reload,能看到 proxy_pass 实际拼出的 URL、各 header 的值。debug 日志非常详细,调好后立刻关掉避免磁盘塞满。 ## tcpdump 抓 nginx 与后端通讯 tcpdump -i any -A -s 0 'host 192.168.1.10 and port 8080' 能看到 nginx 转发的完整请求体与后端响应。比看日志更精确,特别是定位 Header 是否正确传递。 ## curl 模拟客户端 curl -v -H "Host: example.com" http://nginx_ip/api/test -v 看完整请求/响应,-H 强制设置 Host 让 nginx 路由到对应 server 块。 ## 常见故障 ## 故障 1:404 Not Found 多半是 proxy_pass 末尾斜杠错了。重新对照本文"四种组合"表,确认转发到后端的 URI 与后端期望的一致。 ## 故障 2:upstream timed out (110) nginx 与后端之间的 TCP 连接断了或者后端响应慢于 proxy_read_timeout(默认 60s)。看后端日志确认是后端慢还是 TCP 真断了。后端慢就调 proxy_read_timeout;TCP 断要查防火墙、conntrack 表是否爆。 ## 故障 3:upstream prematurely closed connection 后端在响应过程中主动关连接。常见原因:后端 keepalive 超时短于 nginx 配置;后端进程 OOM 被 kill;后端配置了 max_request 触发回收。 ## 故障 4:sub_filter 不生效 三个排查:响应是不是 gzip(用 curl -i 看 Content-Encoding);MIME 类型在不在 sub_filter_types 里;sub_filter_once 是不是漏了 off(默认 on 只替换第一个匹配)。 ## 故障 5:后端拿不到客户端真实 IP 没配 X-Real-IP 与 X-Forwarded-For 头。如果配了仍拿不到,看后端框架是不是默认信任这两个头——Spring Boot、Gunicorn 都需要显式开启 forwarded headers 处理。 ## 故障 6:SSL 证书 Common Name 不匹配 nginx 转发到后端的 HTTPS 时,proxy_ssl_server_name on 会启用 SNI,让后端的虚拟主机正确分发。如果后端证书是某具体域名而 nginx 用 IP proxy_pass,会触发 SNI 不匹配。 proxy_pass https://backend.internal; proxy_ssl_server_name on; proxy_ssl_name backend.internal; ## 故障 7:proxy_redirect 无效 后端用了奇怪的 Location 头格式(比如带端口的绝对 URL Location: http://backend:8080/login),proxy_redirect 默认规则没覆盖。显式写: proxy_redirect http://backend:8080/ https://$host/my/; ## 故障 8:Host header is required HTTP/1.1 强制要求 Host 头。如果你 proxy_pass 写的是 IP,nginx 默认 Host 头是 backend 那行的值(即 IP),后端虚拟主机匹配不上某些站点会报错。修复:proxy_set_header Host $host; 把客户端的 Host 透传过去。 ## 常见问题解答 ## proxy_pass 末尾斜杠到底加不加? 记一句话:proxy_pass 的 URI 部分(包括只有 /)会替换 location 匹配的前缀;不带 URI 完整传递。子目录反代选带斜杠 + URI 替换;整站反代选不带 URI 完整传递。 ## 多个 sub_filter 没生效怎么办? 三个排查:nginx 版本是不是 1.9.4+;响应是不是 gzip 压缩(必须先解压);sub_filter_once 必须设 off 才会替换所有匹配。还有一种隐蔽情况:后端发的 HTML 字符集是 GBK 而 nginx 默认 UTF-8 处理 sub_filter,部分中文字节会触发匹配失败。 ## WebSocket 连接不上怎么排查? 四步:浏览器 Network 看 WebSocket 请求 status,101 才是握手成功;看 Request Headers 是否有 Upgrade: websocket 与 Connection: upgrade;nginx 配置是否包含 proxy_http_version 1.1 与 map 块;后端是否真的支持 WebSocket(很多 Java 旧版 Tomcat 不支持)。 ## 反向代理后端怎么获取真实客户端 IP? nginx 端配置 X-Real-IP 与 X-Forwarded-For 头;后端框架开启 forwarded headers 处理(Nginx 反代后多数框架默认不信任 X-Real-IP,得显式打开)。如果有多层代理,后端要按 X-Forwarded-For 列表的最左侧(第一个 IP)取真实客户端 IP。 ## proxy_redirect 与 sub_filter 的关系? proxy_redirect 改的是 HTTP 响应头里的 Location 字段(302/301 跳转用);sub_filter 改的是响应体内的 HTML 文本。两者不能互相替代。后端发 302 跳转时改 proxy_redirect,后端发 HTML 时改 sub_filter,两者经常需要同时配置。 ## upstream 里某台机器挂了,nginx 多久检测出来? 取决于 max_fails 与 fail_timeout 配置。默认 max_fails=1, fail_timeout=10s,意味着第一次请求失败后该后端被标记不可用 10 秒。生产建议 max_fails=3, fail_timeout=30s 让短暂抖动不立刻摘掉机器。要更精确的健康检查需要 nginx-plus 商业版或者 nginx-upstream-check-module 第三方模块。 ## 反向代理后请求体大小为什么受限? 三层限制:nginx 的 client_max_body_size、PHP 的 upload_max_filesize/post_max_size、后端框架的请求体限制。任何一层卡住都会 413。生产部署时这三个值要取一致。 ## 反向代理能否做 grpc 转发? 能。nginx 1.13.10 起原生支持 grpc_pass (https://nginx.org/en/docs/http/ngx_http_grpc_module.html)。配置:grpc_pass grpc://backend;,不要用 proxy_pass。grpc 请求体是 HTTP/2,nginx 也必须 listen 443 ssl http2 启用 HTTP/2。 ## 反代某境外站点为什么打不开? 典型坑:境外站点用 Cloudflare 加速,对 SNI 与 Host 头敏感。nginx 反代时务必带 proxy_set_header Host 原域名 + proxy_ssl_server_name on + proxy_ssl_name 原域名,让 Cloudflare 把 nginx 当正常客户端处理。 ## 反向代理后 cookie 域名怎么处理? 后端发的 Set-Cookie 头里 Domain 字段可能是后端自己的域名(backend.com),浏览器收到后觉得跟当前站点(example.com)不匹配会丢弃 cookie。用 proxy_cookie_domain 改写: proxy_cookie_domain backend.com example.com; 同样的 proxy_cookie_path 改写 cookie 的 Path 字段。 ## 权威参考资料