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

为了防恶意刷量与抗突发高并发全站接入了分布式限流与熔断降级(Sentinel / Redis 令牌桶),搜索收录与关键词排名却断崖式暴跌?排查伪造蜘蛛打爆限流白名单致真蜘蛛频报 429、熔断降级返回 200 状态码投毒快照成海量软 404、降级骨架屏致正文脱落沦为薄内容与 Redis 限流抖动 fail-closed 阻断我踩过的几个深坑

2026-11-7 / 0 评论 / 6 阅读
AI 摘要由 AI 生成

文章揭示了因引入分布式限流与熔断降级措施,导致搜索收录与关键词排名暴跌的问题。通过深入分析,发现是黑产伪造蜘蛛打爆限流白名单,导致真蜘蛛频繁遭遇429错误,进而影响了搜索引擎爬虫的抓取和排名。作者强调了设计时对爬虫白名单的依赖不足,以及伪造User-Agent带来的严重后果,并建议优化限流策略和增强对爬虫行为的识别。

上季度由于站点内容频繁被黑产爬虫与大模型清洗程序无节制抓取,白天业务高峰期内网数据库连接池数次被暴力拖垮。为了护住后端核心存储并压制突发流量峰值,我们在接入网关与核心微服务之间铺设了一套完整的分布式限流与熔断降级体系:外层依托 OpenResty + Redis 执行分布式滑动窗口令牌桶限流,应用层与微服务集群接入 Alibaba Sentinel 做自适应系统保护、慢调用比例熔断与热点参数隔离。

上线初期压测表现非常抢眼。面对模拟的数万 QPS 突发流量,网关精准砍掉异常 IP,下游 MySQL 和 Redis 负载平稳得像一条直线,恶意刷量行为直接锐减了 95% 以上。

内部庆功会还没开完,第三周周一早上翻看搜索平台数据时,所有人都傻眼了。

百度搜索资源平台上的“抓取频次”曲线直接从每天几十万次断崖下坠到不足两万次,“抓取异常”里 429(Too Many Requests)和 503(Service Unavailable)的报错柱状图红得刺眼。紧接着 Google Search Console 发来大面积警报:“已抓取 - 尚未编入索引”与“软 404”页面暴增数万条,核心行业词排名在短短几天内大面积滑出前五页,全站搜索自然流量跌去了近三分之二。

防御恶意攻击的防护盾,把正常输送搜索权重的搜索引擎蜘蛛挡在门外狠狠揍了一顿。顺着网关拦截日志、Sentinel 熔断上下文、爬虫抓取沙箱以及降级逻辑深挖了整整四天,我才把隐藏在限流熔断与搜索引擎爬虫交互里的致命暗礁挨个揪了出来。


坑一:仅凭 User-Agent 判定蜘蛛白名单,黑产伪造 UA 耗尽共享令牌桶致真蜘蛛频报 429

在设计限流规则时,为了避免误杀搜索引擎爬虫,网关层最初加了一段前置逻辑:只要请求头中的 User-Agent 命中百度、谷歌、必应等已知搜索引擎爬虫标识,就绕过常规的按客户端 IP 限流规则,走一条专用的“爬虫保活通道”。

-- 错误设计:仅依赖 User-Agent 字符串做爬虫放行或走共享大桶
local ua = ngx.var.http_user_agent or ""
local is_spider = false

if string.find(ua, "Baiduspider") or string.find(ua, "Googlebot") or string.find(ua, "bingbot") then
    is_spider = true
end

if is_spider then
    -- 分配到一个固定的爬虫全局共享令牌桶(容量 200 QPS)
    local allowed = limit_bucket:consume("spider_shared_bucket", 1)
    if not allowed then
        ngx.exit(429)
    end
else
    -- 普通用户按 IP 令牌桶限流
    limit_by_ip(client_ip)
end

这段看似贴心的代码,成了整场灾难的导火索。

爬虫对抗逻辑一旦公之于众或被黑产嗅探,黑产爬虫只要在请求头里带上 User-Agent: Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html),就能瞬间逃逸严格的 IP 限流规则,堂而皇之地挤进这个为蜘蛛预留的专用桶里。

结果不到两天,全网几十个分布式爬虫集群伪造蜘蛛 UA 疯狂扫站。高峰期伪造蜘蛛的并发直接把 spider_shared_bucket 抽得干干净净。

当真正的 Baiduspider 和 Googlebot 集群从官方机房节点发起常规抓取时,一头撞在空空如也的令牌桶上,网关当场无情甩出 HTTP 429 Too Many Requests:

2026-10-18T14:22:01+08:00 [warn] 28190#0: *4910291 [lua] rate_limit.lua:42: spider_shared_bucket exhausted, client: 220.181.108.89, request: "GET /article/k8s-pod-network.html HTTP/1.1", status: 429

真正的百度蜘蛛在连续几分钟内遭遇超过 30% 的 429 阻断。在搜索引擎爬虫的设计逻辑中,HTTP 429 与 503 属于强烈的后端负载过载信号。为了保护目标服务器不被压垮,抓取调度引擎会自动启动指数退避(Exponential Backoff)算法,瞬间将本站的抓取配额削减 70% 到 90%。蜘蛛来得越来越少,新内容无法收录,旧内容快照过期。

解决办法:双向 rDNS 解析反查 + IP 缓存白名单验证

防御伪造 UA 必须采用工业级反向 DNS 双向校验(Reverse DNS Lookup),绝对不能信任客户端请求头中的任何文本标识。

合规的爬虫 IP 必须满足反向域名解析(PTR 记录)指向官方域名,并且正向解析该域名得到的 IP 必须等于原请求 IP。由于 DNS 查询存在网络耗时,网关层必须结合本地 LRU 缓存与动态黑白名单机制:

-- 正确方案:基于 OpenResty 的双向 rDNS 校验与 LRU 缓存
local lrucache = require("resty.lrucache")
local verified_cache = lrucache.new(50000) -- 缓存验证结果 24 小时
local resolver = require("resty.dns.resolver")

local function verify_spider_ip(client_ip, ua)
    local is_spider_ua = string.find(ua, "Baiduspider") or string.find(ua, "Googlebot")
    if not is_spider_ua then
        return false
    end

    local cached = verified_cache:get(client_ip)
    if cached ~= nil then
        return cached == true
    end

    -- 执行反向 DNS 解析 (PTR)
    local ptr_domain = get_ptr_record(client_ip)
    local valid = false

    if string.find(ua, "Baiduspider") and ptr_domain and string.match(ptr_domain, "%.baidu%.(com|jp)$") then
        -- 进一步正向解析验证 IP 回指
        local forward_ips = resolve_forward(ptr_domain)
        for _, ip in ipairs(forward_ips) do
            if ip == client_ip then
                valid = true
                break
            end
        end
    elseif string.find(ua, "Googlebot") and ptr_domain and string.match(ptr_domain, "%.googlebot%.com$") then
        local forward_ips = resolve_forward(ptr_domain)
        for _, ip in ipairs(forward_ips) do
            if ip == client_ip then
                valid = true
                break
            end
        end
    end

    verified_cache:set(client_ip, valid, 86400)
    return valid
end

通过这一层校验,伪造蜘蛛 UA 的黑产 IP 被直接归类为恶意探测,打入极速限流黑洞;真正的官方爬虫则进入专享的弹性独立通道,彻底杜绝了真假爬虫在同一令牌桶内“抢食”导致的 429 惨剧。


坑二:熔断降级返回 HTTP 200 状态码与 JSON 错误包,搜索引擎入库形成海量“软 404”

当业务依赖的下游评论系统或关联推荐服务出现慢查、连接池耗尽时,Sentinel 的熔断机制(DegradeRule)会迅速触发,将对应资源熔断并跳入兜底降级逻辑(Fallback)。

原本这套降级方案是给 App 和前端 AJAX 异步请求准备的,统一返回格式标准:

// 错误设计:兜底降级方法默认返回 HTTP 200 与统一 JSON
@Component
public class GlobalUrlBlockHandler implements BlockExceptionHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, BlockException e) throws Exception {
        response.setStatus(HttpStatus.OK.value()); // 返回 200 OK
        response.setContentType("application/json;charset=utf-8");

        Map<String, Object> result = new HashMap<>();
        result.put("code", 5003);
        result.put("msg", "系统业务繁忙,部分服务已降级,请稍后刷新重试");

        response.getWriter().write(JSON.toJSONString(result));
    }
}

当微服务网关遇到页面级访问熔断时,这套逻辑不加区分地直接返回了 HTTP 200 状态码与上述 JSON 串。

在浏览器里,普通用户看到一串 JSON 可能会按 F5 刷新一下。但对搜索引擎爬虫来说,HTTP 200 意味着页面访问完好无损、内容正常交付。

结果那几天因为下游依赖抖动触发熔断的几千张文章页面,百度蜘蛛抓回去的全部是:

{"code": 5003, "msg": "系统业务繁忙,部分服务已降级,请稍后刷新重试"}

百度的内容快照解析系统立刻把原有的技术长文快照冲掉,换成了这串重复的错误提示文本。紧接着,算法检测到站内多达数千个不同 URL 的正文完全雷同,判定为大批量“软 404(Soft 404)”与低质采集页,直接把涉及的这批老文章从主索引库剥离并降权处理。

解决办法:严格规范 HTTP 状态码与标头语义

搜索引擎爬虫识别页面状态唯一遵循的是 HTTP 协议状态码,而不是响应体里的业务 JSON。

  1. 限流触发时:必须返回 HTTP 429 Too Many Requests,并强制附带 Retry-After 标头(例如 Retry-After: 120),明确通知蜘蛛 120 秒后再来。
  2. 熔断与系统过载时:必须返回 HTTP 503 Service Unavailable,同样携带 Retry-After: 300。
  3. 针对爬虫请求:禁止在报错场景下返回 200,宁可抛出标准 503,也绝不能让错误提示污染页面快照。
// 正确方案:区分客户端类型并按标准 HTTP 协议状态码响应
@Component
public class ProperSeoBlockExceptionHandler implements BlockExceptionHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, BlockException e) throws Exception {
        // 限流(FlowException)返回 429,熔断降级(DegradeException)返回 503
        if (e instanceof FlowException) {
            response.setStatus(429);
            response.setHeader("Retry-After", "120"); // 建议爬虫 2 分钟后重试
        } else {
            response.setStatus(503);
            response.setHeader("Retry-After", "300"); // 建议爬虫 5 分钟后重试
        }

        response.setHeader("X-Robots-Tag", "noindex, nofollow"); // 兜底防止被收录为错误快照
        response.setContentType("text/html;charset=utf-8");

        String html = "<!DOCTYPE html><html><head><meta name=\"robots\" content=\"noindex, nofollow\">"
                    + "<title>Service Temporarily Unavailable</title></head>"
                    + "<body><h1>503 Service Unavailable</h1><p>Server under high load, please retry later.</p></body></html>";
        response.getWriter().write(html);
    }
}

HTTP 503 会清晰明确地告知爬虫调度系统:“本页面内容暂不可用,但原有的快照与权重请继续保留,切勿删除现有索引”。


坑三:前端骨架屏(Skeleton Screen)异步降级,蜘蛛渲染沙箱抓取脱落正文沦为薄内容

另一个极其隐蔽的坑出现在 SSR 服务端渲染的“优雅降级”设计上。

为了在 Node.js SSR 渲染层高负载时不直接把白屏或者报错甩在用户脸上,前端架构师设计了一套“兜底骨架屏机制”:当后端聚合接口响应时间超过 800ms 时,Sentinel 自动熔断 SSR 服务端直出逻辑,改为直接向客户端输出一个空的 HTML 骨架外壳(包含骨架屏占位图与加载动效),并注入前端打包好的 JS Bundle,指望浏览器在本地通过客户端渲染(CSR)再异步调接口填充内容。

普通用户用 Chrome 访问时体验尚可,骨架屏闪烁两秒后,文章内容被 AJAX 补齐。

但这对搜索引擎爬虫无异于灾难。

百度爬虫(Baiduspider)出于计算资源与抓取效率的平衡考虑,在全网抓取过程中绝大多数页面走的是静态 HTML 解析管道,只有极少数权重极高的页面才会进入耗时漫长的动态渲染流水线(Web Rendering Service)。即使是 Googlebot,动态渲染也有着不可预测的排队延迟与 5 秒左右的严格执行超时。

当爬虫拿到这个骨架屏 HTML 时,它看到的全部内容只有:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="utf-8">
    <title>文章加载中... - 雷灵博客</title>
</head>
<body>
    <div id="root">
        <div class="skeleton-header"></div>
        <div class="skeleton-line-shimmer"></div>
        <div class="skeleton-line-shimmer"></div>
        <div class="skeleton-line-shimmer"></div>
    </div>
    <script src="/static/js/main.chunk.js"></script>
</body>
</html>

没有正文,没有标题,没有技术关键词,甚至没有语义化的内链与标签。

数千篇处于降级窗口期的文章被抓取后,在搜索引擎的文本特征分析模型里直接被判定为字数不足 100 字符的薄内容(Thin Content)。根据现代搜索引擎的低质内容清退规则,全站如果存在高比例的空骨架页面,域名整体质量分会被直接降档,导致那些原本没有被降级的正常页面也跟着连坐丢排名。

解决办法:爬虫通道坚守完整 SSR 与陈旧缓存降级(Stale-While-Revalidate)

在内容型站点中,对爬虫降级“骨架屏”无异于自杀。面对高并发压力,爬虫通道与用户通道必须在网关层执行差异化降级策略:

  1. 用户通道:允许降级为骨架屏或局部异步加载,保障交互可用性。
  2. 爬虫通道:严禁降级为骨架屏。当实时 SSR 出现性能瓶颈或熔断时,爬虫请求必须降级为读取边缘或本地 Redis 中缓存的陈旧静态 HTML 快照(Stale Cache),宁可提供 10 分钟前的完整静态内容,也绝不给爬虫输出空壳。
  3. 缓存击穿兜底:如果连陈旧缓存都没有,宁可直接响应标准的 503 状态码,促使爬虫稍后重访,也不输出骨架屏。
# Nginx / OpenResty 针对蜘蛛的陈旧缓存兜底配置
location /article/ {
    set $is_verified_spider 0;
    # 结合 Lua 校验得出的真实爬虫变量
    if ($http_user_agent ~* (Baiduspider|Googlebot)) {
        set $is_verified_spider 1;
    }

    # 爬虫开启 stale-while-revalidate 与 stale-if-error
    proxy_cache my_html_cache;
    proxy_cache_valid 200 10m;
    proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
    proxy_cache_background_update on;

    proxy_pass http://ssr_backend_cluster;
}

通过这套配置,当后端微服务发生限流或熔断时,Nginx 会默默调出上一次成功渲染的完整 HTML 吐给蜘蛛,不仅让爬虫毫发无损地读到正文,还能在毫秒级内完成响应,拉低抓取延时。


坑四:分布式限流中间件 Redis 抖动触发 fail-closed 阻断,且缺少 Retry-After 标头重创抓取调度

生产环境中不可预知的问题永远比想象的要多。

在我们的限流架构中,OpenResty 网关通过 Lua 脚本调用底层 Redis 集群执行滑动窗口令牌桶的计数。某天凌晨四点,底层 Redis 集群执行主从故障切换并伴随轻微跨可用区网络抖动,网关与 Redis 之间的连接池瞬间爆满,大量 Redis 请求超时。

原本在编写限流中间件逻辑时,负责的同学采用了经典的“安全优先”理念:

-- 错误设计:Redis 出现异常时默认 fail-closed(阻断放行)
local red = redis:new()
local ok, err = red:connect("10.0.1.50", 6379)
if not ok then
    ngx.log(ngx.ERR, "Redis 连接失败: ", err)
    -- 担心穿透打崩下游,直接抛出 500 或 503 阻断请求
    ngx.exit(ngx.HTTP_SERVICE_UNAVAILABLE)
end

这种“宁可阻断不可穿透”的 fail-closed 策略在防御黑客攻击时是正确的,但在面对搜索引擎蜘蛛时成了隐形杀手。

凌晨通常是各大搜索引擎集中调度批量索引更新的黄金窗口期。就在这短短的 30 秒 Redis 抖动期间,百度蜘蛛突发的批量抓取刚好撞上,网关瞬间批量抛出大面积的 503。

更致命的是,所有的 503 响应中完全没有返回 Retry-After 标头。

按照 RFC 7231 与主流搜索引擎的抓取规范:

  • 如果服务器返回 503 且带有 Retry-After: 300,爬虫调度器会把未抓取的任务挂起并推迟到 5 分钟后再次尝试,这被视为合规的运维维护行为。
  • 如果服务器频繁返回无 Retry-After 的 503,或者状态码在 200、500、503 之间剧烈震荡,爬虫调度器会将该站点标记为“不稳定站点(Unstable Host)”,系统级下调抓取线程上限。

解决办法:限流组件 fail-open 保护 + 降级本地内存限流与规范 Retry-After

针对高可用架构与 SEO 保护,网关限流组件必须引入双层兜底策略:

  1. fail-open(失败开放)原则:分布式限流属于保障性组件,绝不能成为全链路单点故障。当底层 Redis 集群宕机或连接超时时,限流层必须自动降级为“放行”或者“切换至本地单机进程内限流(如内存令牌桶)”,绝不能把内部中间件故障转变为全局对外阻断。
  2. 标头强制注入:无论何时只要网关抛出 429 或 503,哪怕底层发生未捕获异常,也必须确保注入 Retry-After 标头与 X-Robots-Tag: noindex, nofollow。
-- 正确方案:Redis 异常自动降级为本地内存限流,绝不直接 fail-closed
local limit_local = require("resty.limit.req")
local local_limiter = limit_local.new("my_limit_zone", 100, 50) -- 本地共享内存限流

local function acquire_token(client_ip, cost)
    local redis_client = get_redis_connection()
    if not redis_client then
        -- 核心要点:Redis 故障时降级为本地单机限流,保障服务活着
        ngx.log(ngx.WARN, "分布式限流不可用,自动降级为单机内存限流")
        local delay, err = local_limiter:incoming(client_ip, true)
        if not delay then
            if err == "rejected" then
                ngx.header["Retry-After"] = "60"
                ngx.header["X-Robots-Tag"] = "noindex, nofollow"
                ngx.exit(429)
            end
        end
        return true
    end

    -- 执行常规 Redis 分布式令牌桶...
end

通过双层兜底,即便整套 Redis 集群在夜间遭遇网络抖动或重启,网关依然能凭借单机内存限流平稳承接流量,既防止了后端被真实恶意流量打穿,又彻底杜绝了爬虫在夜间遭遇无序阻断的风险。


生产落地的四条自查清单

梳理完限流熔断与搜索引擎爬虫之间的冲突机制后,我们把这些教训整理成了一张生产巡检核对清单:

  1. 爬虫身份验证:绝对不以客户端明文 User-Agent 作为白名单唯一准入凭证,必须建立基于双向 rDNS 与 IP 缓存的核验通道,防止假蜘蛛霸占配额。
  2. HTTP 状态码语义严格隔离:发生限流时响应 429,发生熔断降级时响应 503,务必随响应头输出 Retry-After 提示,坚决禁止将错误提示用 200 状态码输出。
  3. 针对爬虫的降级策略禁用客户端骨架屏:爬虫通道下的内容页熔断,优先使用 Stale-While-Revalidate 陈旧缓存或直出静态 HTML,不可给爬虫吐出空白或骨架屏外壳。
  4. 限流基础组件遵循 fail-open 设计:限流系统自身的高可用故障,绝不能直接转化为对外的 503/500 熔断,应自动降级为单机内存限流或短暂放行,避免误伤爬虫调度信用。

评论一下?

OωO
取消