侧边栏壁纸
  • 累计撰写 213 篇文章
  • 累计收到 2 条评论

为了给全站加速把路由与重定向下沉到 Edge 边缘中间件,搜索引擎收录却断崖式暴跌?排查 Cloudflare Workers 并发打爆 CPU、流式分块截断与爬虫地域 302 误判我踩过的几个深坑

2026-10-19 / 0 评论 / 9 阅读
AI 摘要由 AI 生成

为加速全站,将路由与重定向下沉至Edge边缘中间件,导致搜索引擎收录量断崖式下跌。原因在于Cloudflare Workers并发打爆CPU、流式分块截断与爬虫地域302误判。通过分析日志与重放测试,发现Edge中间件代码对爬虫造成严重影响,如强制302重定向和多语言重定向死锁,需遵循搜索引擎规范,避免对爬虫执行基于IP的强制重定向。

上个季度我们对整个技术社区站点的接入层做了一次大手术。起因是源站服务器的 TTFB(首字节到达时间)在高峰期经常飙到 400ms 以上,遇上跨国访问或者跨运营商跨省请求,网络链路抖动严重。

为了把全球和跨地域访问延迟压到 50ms 以内,架构组决定把原来挂在源站 Nginx 上的 URL 重写、多语言路由、301 跳转、防盗链以及反作弊过滤,统一迁移到 CDN 边缘计算层。我们选了 Cloudflare Workers 配合边缘键值存储(KV),后来部分子系统也挂了 Next.js 的 Edge Middleware。

刚上线那一周,监控面板的数据漂亮极了:全球平均 TTFB 直接压到了 35ms,静态资源边缘缓存命中率飙升到 92%,源站 CPU 负载肉眼可见地下跌了一大半。

好日子只过了两周。第三周周一开例会,SEO 运营同事发出了警报:Google Search Console 里的有效索引量直接跳水了百分之三十五,国内百度站长后台更惨,抓取频次从每天两万多次断崖下跌到不足八百次,抓取异常诊断列表里密密麻麻全是 504 网关超时、302 循环重定向以及快照内容为空的报错。

当时我心里咯噔一下。普通用户通过浏览器点开页面明明秒开,为什么搜索引擎爬虫抓取却大面积暴毙?经过连续三个通宵翻看 Cloudflare 日志、分析爬虫真实请求与重放测试,我才发现边缘中间件那套看似行云流水的无服务器代码,几乎把搜索引擎爬虫最脆弱的神经挨个挑了一遍。

边缘根据地域 IP 强行 302 重定向,爬虫深陷多语言重定向死锁

排查发现的第一个灾难,出在边缘多语言自动跳转逻辑上。

为了给不同国家和地区的用户提供对应的语言版本,我们在 Worker 脚本的最开头写了一段基于客户端 IP 的分流逻辑:

// 错误示范:未经爬虫识别就按地理位置强行重定向
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const country = request.cf?.country || 'US';

    // 如果访问根路径或公共文档,未携带语言前缀时按国家分流
    if (!url.pathname.startsWith('/zh-cn') && !url.pathname.startsWith('/en-us')) {
      if (country === 'CN' || country === 'HK' || country === 'TW') {
        url.pathname = `/zh-cn${url.pathname}`;
        return Response.redirect(url.toString(), 302);
      } else {
        url.pathname = `/en-us${url.pathname}`;
        return Response.redirect(url.toString(), 302);
      }
    }

    return fetch(request);
  }
};

这段逻辑对真人浏览非常丝滑:国内用户直接跳中文版,欧美用户直接跳英文版。

但搜索引擎爬虫根本不是这么工作的:

  1. Googlebot 的抓取节点绝大多数位于美国加州和弗吉尼亚机房,IP 属地自然是 US。当 Googlebot 尝试抓取站内的中文技术文档 /zh-cn/posts/docker-guide 时,内链推荐列表或者跨页引用有时候给的是无前缀规范 URL /posts/docker-guide,边缘一看到美国 IP,直接一脚 302 把 Googlebot 踹到了 /en-us/posts/docker-guide。
  2. 更有杀伤力的是页面内的 Canonical 标签:我们在中文版 HTML 里配置了标准地址 <link rel="canonical" href="https://example.com/zh-cn/posts/docker-guide" />。Googlebot 读取到这个规范标签后,试图回溯验证目标地址,结果再次被边缘根据 IP 判定强制 302 回调,陷入多跳重定向循环,最终判定该页面存在重定向错误而直接丢弃收录。
  3. 百度爬虫 Baiduspider 同样遭殃。部分国内多线 BGP 节点或通过海外代理抓取的探针,被识别为非大陆 IP,直接拉到了英文版;而英文版里又没有针对百度的移动自适应适配,导致移动索引直接判定为软 404。

彻底解决这个问题必须推翻按 IP 强跳的设计:

  • 严格遵循 Google 和百度官方规范:禁止对搜索引擎爬虫执行基于 IP 的强制重定向。
  • 根路径 / 保持中立,提供语言选择或默认主版本,并通过 hreflang 标签明确语言对映射,让爬虫按链接关系自主抓取各版本,而不是靠边缘拦截。
  • 如果业务非要保留跳转,必须在边缘层建立白名单:检查请求头中的 User-Agent,并通过 rDNS(反向 DNS 解析与正向双向验证)确认是合法搜索引擎蜘蛛后,直接放行直达源站,绝不触发任何地域重定向。
// 修正方案:识别合法蜘蛛并豁免重定向
const BOT_UA_REGEX = /(Googlebot|Baiduspider|bingbot|Sogou web spider|Bytespider)/i;

function isSearchBot(userAgent: string): boolean {
  return BOT_UA_REGEX.test(userAgent);
}

// 在路由分流前优先放行蜘蛛
if (isSearchBot(request.headers.get('User-Agent') || '')) {
  return fetch(request);
}

爬虫突发高并发打爆 Worker 运行配额,边缘批量抛出 1101 与 504

第二个大坑出在边缘函数的高并发执行瓶颈上。

普通用户访问网站,请求分布在全天相对均匀,边缘 Worker 执行一次耗时 10-15ms,无论 CPU 时间还是 KV 查询次数都在安全阈值内。

但各大搜索引擎的爬虫调度策略完全不同。一旦爬虫发现站内更新了大量链接,或者定时拉取全站更新时,抓取队列会在几秒钟内涌入几十甚至上百个并发请求。

当时为了在边缘实现更精细的防盗链与 URL 规范化,我们给每个请求挂载了几个重操作:

  1. 检查 Cloudflare KV 里存储的 5000 多条旧 URL 映射字典(做 301 重定向规则匹配)。
  2. 调用 Web Crypto API 计算签名校验防盗链 token。
  3. 如果未命中边缘缓存,通过 fetch(origin) 回源拉取,然后再通过正则修剪请求头。

当 Baiduspider 连续用 40 个并发连接密集抓取分页列表时,问题彻底爆发了:

  • Cloudflare Workers 免费或基础付费计划对单次请求的 CPU 耗时限制非常严苛(普通套餐仅 50ms CPU time)。高并发下 CPU 时间片被密集正则匹配和加密计算耗尽,部分请求直接超时,抛出 Error 1101: Worker threw exception。
  • 更要命的是,KV 存储在瞬时高并发读取同一命名空间时,遇到了突发连接限制,排队延迟飙到上百毫秒,最终引发源站与 CDN 边缘断链,给爬虫返回 504 Gateway Timeout。

搜索引擎爬虫对服务器错误极度敏感。如果一个站点在几分钟内连续返回数十个 500、504 或 1101,爬虫调度器会立即启动“过载保护熔断机制”,认为你的服务器已经濒临崩溃,从而主动压低抓取频次(Crawl Rate),甚至在未来数周内大幅削减抓取配额。

解决这一步的动作很直接:

  • 边缘计算逻辑必须极限轻量化。把耗费 CPU 的复杂正则、加解密计算从边缘主链路剔除。
  • 对 KV 的重定向规则查询增加一级进程内内存缓存(利用全局变量 Map 缓存热点规则),避免每次请求都走远程 KV 子请求。
  • 明确设置源站回源超时与兜底逻辑,超时直接降级返回,绝不允许未捕获异常导致边缘直接吐出 1101 错误页。
// 内存级缓存热点重定向字典,避免频繁消耗 KV 子请求配额
const memoryRedirectCache = new Map<string, string>();

async function getRedirectUrl(path: string, env: Env): Promise<string | null> {
  if (memoryRedirectCache.has(path)) {
    return memoryRedirectCache.get(path)!;
  }

  // 仅在内存未命中时异步查 KV
  const target = await env.REDIRECT_KV.get(path);
  if (target) {
    // 限制内存缓存上限防爆内存
    if (memoryRedirectCache.size > 2000) memoryRedirectCache.clear();
    memoryRedirectCache.set(path, target);
  }
  return target;
}

HTMLRewriter 流式修改响应体,分块传输(Chunked)截断导致快照空白

第三个坑最为隐蔽,整整卡了我们一天才抓到元凶。

当时我们想在边缘动态给页面注入 canonical 链接以及针对百度的特定元标签,同时不想让源站重新打包部署。Cloudflare Workers 提供的 HTMLRewriter 堪称神器,支持流式解析 HTML 并注入 DOM:

// 隐藏极深的分块传输缺陷
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const response = await fetch(request);

    // 如果不是 HTML 内容直接返回
    const contentType = response.headers.get('content-type') || '';
    if (!contentType.includes('text/html')) {
      return response;
    }

    // 动态注入 Canonical 与版权标识
    return new HTMLRewriter()
      .on('head', {
        element(el) {
          const canonical = `https://example.com${new URL(request.url).pathname}`;
          el.append(`<link rel="canonical" href="${canonical}" />`, { html: true });
        }
      })
      .transform(response);
  }
};

用 Chrome 和 Edge 打开,页面渲染完好,检查源代码也能看到注入的标签。

但当我们通过命令行抓取模拟百度蜘蛛请求时,怪事发生了:

curl -A "Baiduspider+(+http://www.baidu.com/search/spider.htm)" \
     -H "Accept-Encoding: gzip" \
     -i https://example.com/posts/architecture-review

返回的 HTTP 响应头里没有 Content-Length,而是标明了 Transfer-Encoding: chunked。这在现代网络栈里很常见,但关键在于:

  • 源站输出的是经过 Gzip 压缩的内容,边缘 Worker 解压后用 HTMLRewriter 改写,随后以分块传输流式输出。
  • 国内部分老旧爬虫节点在解析带有动态重写、分块慢速输出的 HTTP/1.1 流式响应时,网络连接提前关闭,或者缓冲区未能等待后续数据块,导致爬虫索引分析器拿到的只截断在 <head> 前半截,正文部分全是空白!
  • 在百度站长平台调取“抓取诊断”生成的快照文本,居然只有两百字节,正文彻底成了空气。页面被搜索引擎系统当成没有实际价值的空白页,自然无法入库。

修复手段很明确:
针对搜索引擎爬虫,必须彻底绕过流式动态修改;如果确实需要注入标签,必须由源站在渲染服务端(SSR/SSG)统一闭环处理,或者在边缘等待完整响应体装配完毕后,计算精准的 Content-Length 头并一次性返回,不让爬虫承担流式分块截断的风险。

// 对爬虫直接返回源站完整响应,避免分块流式传输引发截断
if (isSearchBot(request.headers.get('User-Agent') || '')) {
  return fetch(request);
}

边缘缓存键剥离 Query 参数,分页与筛选页沦为批量重复内容

第四个深坑出现在缓存策略优化上。

上线初期,架构组为了尽可能提升边缘节点的缓存命中率,写了一条缓存键规整化规则:
“很多用户点击链接会自带诸如 utm_source、fbclid、spm 等追踪参数,这些参数会导致相同页面缓存碎片化。因此在构建边缘缓存 Cache Key 时,把 URL 里的 Query 参数全部剔除。”

开发人员写出的规整代码类似这样:

// 致命的缓存键参数清洗逻辑
const cacheUrl = new URL(request.url);
// 简单粗暴地直接抹掉了所有查询参数
cacheUrl.search = '';

const cacheKey = new Request(cacheUrl.toString(), request);
const cache = caches.default;
let response = await cache.match(cacheKey);

if (!response) {
  response = await fetch(request);
  // 缓存响应
  ctx.waitUntil(cache.put(cacheKey, response.clone()));
}

这个看似聪明的举措直接引爆了一场灾难:

  • 我们的列表页和标签聚合页是通过查询参数控制分页的:/category/backend?page=1、/category/backend?page=2、/category/backend?page=3。
  • 当爬虫顺着第一页的翻页链接去抓取第二页、第三页时,由于边缘把 cacheUrl.search 直接置空,请求在边缘全部命中了第一页的缓存!
  • 爬虫连续抓取了 50 个分页,返回的 HTML 标题、文章摘要和链接列表竟然一模一样。
  • 搜索引擎算法立刻触发了“重复内容惩罚”,判定全站存在数以千计的近重复与镜像页面,不仅后续深层文章链接没有被发现抓取,整个聚合栏目的收录和权重还被全面降级。

缓存键参数清洗必须非常谨慎:
绝对不能无脑置空 search。必须维护一份明确的“可丢弃营销追踪参数白名单”(如 utm_*、ref、from),而业务关键参数(如 page、sort、filter、tag)必须完整保留在 Cache Key 中。连带着还有一个细节:对挑出来的保留参数必须按字母顺序重新排序(Query Normalization),防止参数顺序颠倒导致的重复缓存。

// 规范化缓存键:仅清洗营销追踪参数,严格保留业务分页与排序参数
const IGNORED_PARAMS = new Set(['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid']);

function normalizeUrlForCache(url: URL): string {
  const cleanUrl = new URL(url.toString());
  const params = new URLSearchParams();

  // 排序并过滤参数
  Array.from(url.searchParams.entries())
    .sort(([a], [b]) => a.localeCompare(b))
    .forEach(([key, value]) => {
      if (!IGNORED_PARAMS.has(key.toLowerCase())) {
        params.append(key, value);
      }
    });

  cleanUrl.search = params.toString();
  return cleanUrl.toString();
}

边缘内部 Fetch 伪装重写,与规范地址 Canonical 形成幽灵内耗

最后一个坑,出在边缘内部路由重写(Rewrite)与搜索引擎规范化机制的冲突上。

我们有一个旧版归档路径 /v1/archive/article/:id,上线边缘后,团队希望能把这个旧路径映射到新的 REST 规范路径 /articles/:id。

当时为了让浏览器地址栏不变,避免产生一次 301 延迟,开发同事在 Worker 内部做了一次伪装重写:

// 边缘悄悄 Rewrite,不给客户端发 301
if (url.pathname.startsWith('/v1/archive/article/')) {
  const id = url.pathname.replace('/v1/archive/article/', '');
  url.pathname = `/articles/${id}`;
  // 内部 fetch 新地址,但对外返回时的地址依然是旧的
  return fetch(new Request(url.toString(), request));
}

在前端看来,用户访问旧地址,内容直接出来了,体验挑不出毛病。

但在搜索引擎看来,这是极其混乱的信号:

  1. 爬虫抓取的是旧地址 https://example.com/v1/archive/article/1024,HTTP 状态码是 200 OK。
  2. 可源站吐出来的 HTML 正文里,规范标签 Canonical 明确写着:<link rel="canonical" href="https://example.com/articles/1024" />。
  3. 页面内的所有导航、作者链接和面包屑指向的也是新地址。
  4. 爬虫既没收到 301 永久重定向指示权重转移,又面对两个内容完全一样但 URL 不同的 200 页面。百度收录库最终陷入了分裂:一部分收录了旧 URL,一部分收录了新 URL,两边都在竞争同一个关键词排名,权重相互抵消,双双掉出搜索结果首页。

网站架构里对于资源地址的演进,永远要遵循一条铁律:
该 301 永久重定向的地方,绝不要在边缘搞内部静默 Rewrite。
明确向爬虫返回 301 状态码,告诉搜索引擎旧地址已经彻底失效,权重全量继承给新地址。这种规范化的网络交互,远比自作聪明的边缘内部代理健康得多。

// 正确处理:确凿的 301 永久重定向告知权重转移
if (url.pathname.startsWith('/v1/archive/article/')) {
  const id = url.pathname.replace('/v1/archive/article/', '');
  url.pathname = `/articles/${id}`;
  return Response.redirect(url.toString(), 301);
}

边缘架构上线后的排查与防护清单

踩平了上面几个深坑后,我们在边缘接入层重新梳理出了一套上线准则:

  1. 蜘蛛流量优先旁路保护:对 Googlebot、Baiduspider、Bingbot 等主流搜索引擎蜘蛛,在边缘识别后开辟独立处理链路,豁免所有的地域检测、强制跳转以及流式 DOM 篡改,确保爬虫获取的内容与源站输出一致。
  2. 轻量化计算与超时兜底:边缘 Worker 只做路由分发与安全拦截,坚决移除非必要的计算与密集加解密;凡是涉及外部存储(KV、D1、外部 API)的操作,统统加上毫秒级超时截断与异常捕获。
  3. 缓存键按字段精准清洗:严禁直接清空 URL Query,只剔除已知营销广告追踪参数,保留分页、分类、筛选等核心参数并做字典序排序。
  4. 规范 301 权重继承:对废弃和迁移路径,统一返回带缓存的 301 重定向响应,不在边缘做悄无声息的内部代理重写。
  5. 部署前的爬虫模拟回归:每次发布边缘 Worker 脚本前,除了用常规浏览器验证,必须用 curl 伪造各大爬虫的 User-Agent 抽检关键页面,确认 HTTP 状态码、响应头(Content-Length、Vary、Canonical)以及快照正文完整无缺。

这套规则跑通以后,网站的抓取频次在第二周开始回升,受影响的收录量也在一个月内逐步回到了原有水平,边缘计算应有的首字节速度红利才算真正落到了实处。

评论一下?

OωO
取消