AI 爬虫在 429 和慢响应面前直接走人:限流阈值与 TTFB 排查复盘

2026-09-18 10:10:24 19 次浏览
Nginx限流策略爬虫优化性能排查GEO

适用读者:负责站点可用性与搜索可见性的后端/运维工程师,手上有 Nginx 和 access log,需要在流量尖峰里区分"抓取"与"攻击"这两件很像的事。

大促第二天凌晨的那条曲线

大促第二天上午十点,我把前 48 小时的 access log 按分钟铺成曲线,爬虫类请求的成功率在凌晨 01:20 之后从 92% 掉到 41%,429 计数一根比一根高,商品列表的 TTFB 有一批落在 8 秒以上。

真正难受的地方不在当天。两周后运营来问:新上架的三十多个 SKU 在 AI 回答里为什么搜不到,反而还在推半年前下架的老款。翻日志才确认,那批页面上线后的头三天正好落在抓取成功率最低的区间里。

先看数据:第一版限流的参数表

大促前一周我们给站点加了 CDN 回源保护和 Nginx 限流,配置基本照网上的"高并发防护"模板抄。核心毛病是把所有 UA 塞进同一个桶:搜索爬虫、AI 爬虫、真实用户、几个 requests 采集脚本共用同一个 rate。

参数 第一版取值 爬虫侧实际感知 调整后取值 调整依据
limit_req zone + rate 单区 5r/s,所有 UA 共用 峰值下单个爬虫 IP 就能打满令牌桶,全站一起被限 human 20r/s、crawler 8r/s、slowapi 3r/s 按 UA 归一化分流,爬虫桶更小但互相独立
burst 0 令牌耗尽后第一个请求就直接 429 human 40、crawler 12、slowapi 6 允许短突发,不把正常波峰当攻击
nodelay 未开启 请求被塞进队列排队,TTFB 被动拉长而非快速失败 三类都开启 宁可快速 429,也不让连接挂在队列里
limit_req_status 503 爬虫把 503 当服务端故障,退避(Backoff)比 429 更激进 429 429 语义是"限速",503 语义是"我坏了"
Retry-After 未设置 爬虫只能按默认退避猜,猜错就直接放弃 5 至 120 秒,按 UA 分级 明确告知等待时长,减少无谓重试
limit_conn 未设置 单 IP 并发连接无上限,上游连接池被爬虫占满 perip 8 至 30 大促前先看单 IP 连接数分布
proxy_read_timeout 60s 慢接口占住 worker 连接,正常请求跟着排队 10 至 12s 商品列表 P99 修完后稳定在 1.5 秒内

第一版的 5r/s 是按"服务器能扛"倒推的,没考虑爬虫侧怎么理解这个数字。一个爬虫机房可能有几十个出口 IP,每个看起来只有 2 到 3 r/s,合起来已超过给人类用户预留的额度,burst=0 又让任何抖动都变成硬拒绝。

proxy_read_timeout 60s 是另一个隐藏成本。大促期间商品列表在缓存击穿时会跑 20 秒以上,Nginx 等满 60 秒,期间 worker 与上游连接都被占着,表面没报错,实际是可用并发被悄悄吃掉。顺带说一句,503 改回 429 之后,同一个爬虫的抓取间隔从 6 小时以上回落到 40 分钟。

一个请求进来之后,它有两条死路

限流和超时是两条独立路径,日志里却长得一样:都是"这次抓取没拿到内容"。把决策画出来,归因就清楚多了。

flowchart TD
    A[请求到达 Nginx] --> B{UA 归一化 map}
    B -->|搜索爬虫 / AI 爬虫| C[zone=crawler rate=8r/s]
    B -->|正常用户| D[zone=human rate=20r/s]
    B -->|空 UA / 脚本类 UA| E[zone=slowapi rate=3r/s]
    C --> F{令牌桶里还有令牌吗}
    D --> F
    E --> F
    F -->|有| G[转发到上游应用]
    F -->|没有| H[立刻返回 429 并带 Retry-After]
    G --> I[应用层查询与缓存耗时]
    I --> J{TTFB 是否低于 proxy_read_timeout}
    J -->|是| K[200 正常返回页面]
    J -->|否| L[Nginx 断开,爬虫记为超时失败]
    H --> M[爬虫降频并写入限速记忆]
    L --> M
    M --> N[下次抓取间隔被拉长到数小时甚至数天]

上半段是限流路径:429 由 Nginx 直接产生,上游收不到这个请求。下半段是超时路径:请求已打到数据库和缓存,返回值没人要了,连接、慢查询和 CPU 都被白白消耗。

两条路在 access log 里的指纹完全不同:限流产生的 429 里 $upstream_response_time 是短横线,超时那批有数值且贴着 proxy_read_timeout 上限。按这个特征一分,当天 429 有 87% 由 Nginx 产生。

原理剖析:爬虫的退避(Backoff)与限速记忆

收到 429、5xx 或连接超时后,爬虫的调度器不会原地重试,而是执行退避(Backoff):第一次失败等 1 分钟,第二次 2 分钟,第三次 4 分钟,封顶通常落在几小时。响应头带 Retry-After 时,规范的调度器会优先采信这个值,把它当作"下一次最早可以来的时间",而不是自己猜。

Retry-After 的语义在 RFC 6585 里写得很清楚,可以是秒数,也可以是 HTTP 日期。我们后来统一用秒数,因为跨时区解析日期出过差错。它只是建议值:给一个 120 秒的 Retry-After,不代表爬虫 120 秒后一定回来,只代表它不会更早来。

真正导致恢复慢的是限速记忆这一层。主流搜索爬虫会按"主机 + 目录"维护抓取配额,配额由历史响应质量决定:长期 200 且 TTFB 低就上调,持续 429 或超时就下调,而下调比上调快得多,记忆衰减窗口以天计。

修复生效的时间线因此被拉得很长:配置改完,429 从 38% 降到 2% 只用了几个小时;爬虫把配额加回来用了 6 天;新品被真正抓取并进入索引又等了 8 天。运营感受到的"两周没被索引",主要成本在后两段。所以小站被持续限流几周后,即使全线放开,抓取量也不会立刻回升,爬虫侧的限速记忆还在。

按 UA 做差异化限流:Nginx 配置落地

环境是 Nginx 1.24.0(stable),limit_reqlimit_connmapgeo 都是内置模块。思路是先把 UA 归一化成短标签,再让不同标签落到不同令牌桶。

# /etc/nginx/conf.d/crawler_ratelimit.conf
# 目标:把搜索爬虫、AI 爬虫、真实用户、脚本类流量分流到各自的令牌桶
# 环境:nginx 1.24.0,limit_req / limit_conn / map 均为内置模块

# 1) 定义限流共享内存区
# key 用 $binary_remote_addr,10m 大约能存 16 万个 IP 的令牌状态
limit_req_zone $binary_remote_addr zone=human:20m rate=20r/s;
# 爬虫桶单独给一份,rate 更低但互不干扰,避免爬虫挤掉人类用户的额度
limit_req_zone $binary_remote_addr zone=crawler:20m rate=8r/s;
# 列表页与搜索接口单独限,它们是最容易拖慢上游的一批 URI
limit_req_zone $binary_remote_addr zone=slowapi:10m rate=3r/s;
# 按单 IP 统计并发连接数,用于识别同机房多出口 IP 的情况
limit_conn_zone $binary_remote_addr zone=perip:10m;
# 说明:zone 参数只接受字面量,所以"按 UA 选桶"要在 location 层完成
# zone 大小按大促期间真实 IP 数量估,宁可多留一点共享内存

# 2) UA 归一化:长 UA 串映射成短标签,日志里也能直接 group by
map $http_user_agent $ua_class {
    default                              "human";
    # 搜索与 AI 抓取代理逐个匹配,命中即返回
    "~*Googlebot"                        "googlebot";
    "~*Google-Extended"                  "google_ai";
    "~*GPTBot"                           "ai_openai";
    "~*ClaudeBot"                        "ai_anthropic";
    "~*PerplexityBot"                    "ai_perplexity";
    "~*(bingbot|Bingbot)"                "bingbot";
    "~*Baiduspider"                      "baiduspider";
    # 空 UA 与明显的脚本类 UA 归到最严的一档
    "~*^(curl|python-requests|Scrapy)"   "suspicious";
    ""                                   "suspicious";
}

# 3) 按 UA 类别挑选 Retry-After,爬虫越"友好"等待时间越短
# 注意:limit_req 的 zone 不支持变量,分派要到 location 层做
map $ua_class $retry_after {
    default                                              "30";
    "~*(googlebot|bingbot|baiduspider)"                  "5";
    "~*(ai_openai|ai_anthropic|ai_perplexity|google_ai)" "10";
    "suspicious"                                         "120";
}
server {
    # 站点入口,https 与 http2 一起开,减少爬虫的握手次数
    listen 443 ssl http2;
    server_name shop.example.com;

    # 命中限流统一返回 429,语义上明确是"限速"不是"故障"
    limit_req_status 429;
    # 连接数超限同样返回 429,避免爬虫把 503 当成服务端错误
    limit_conn_status 429;

    # 每一类流量一个独立 location,令牌桶之间互不影响
    # 普通用户:burst 放宽,开 nodelay,宁可快速拒绝也不要排队拉长 TTFB
    location / {
        # 人类用户桶,rate 20r/s,允许 40 个突发请求
        limit_req  zone=human burst=40 nodelay;
        # 超出 burst 的请求立刻 429,不会进入等待队列
        limit_conn perip 30;
        proxy_pass http://app_upstream;
        # 慢接口修完后的 P99 在 1.5 秒内,12 秒留了足够余量
        proxy_read_timeout 12s;
    }

    # 商品列表与搜索:独立令牌桶,burst 收紧,保住上游连接池
    # 这条路径命中率最低、最容易打穿到数据库,限额单独收紧
    location /api/products {
        # 这一档 rate 最低,因为每次未命中都会打穿到数据库
        limit_req  zone=slowapi burst=6 nodelay;
        # 并发连接压到 8,给真正的用户请求留出连接池空间
        limit_conn perip 8;
        proxy_pass http://app_upstream;
        # 与列表页查询的服务端超时对齐,避免上游还在跑就被断开
        proxy_read_timeout 10s;
    }

    # 站点地图与商品 feed,给已知搜索爬虫留一条专门的通道
    # 这两个路径只服务爬虫,正常用户不会直接访问
    location /feed/ {
        # 走爬虫桶,与真实用户的额度完全隔离
        limit_req  zone=crawler burst=12 nodelay;
        # sitemap 与 feed 都是静态生成,超时可以给得比列表页宽
        proxy_pass http://app_upstream;
        # 这两个路径不会回源到数据库,10 秒足够
        proxy_read_timeout 10s;
    }

    # 429 统一由命名 location 生成:正文极短,不触发模板渲染
    # 上面三个 location 的限流结果都收敛到同一个处理入口
    error_page 429 = @rate_limited;
    # 命名 location 里的响应不会走应用,也不会被业务中间件改写
    location @rate_limited {
        # 把 map 里算好的等待秒数回给爬虫,减少无谓重试
        add_header Retry-After $retry_after always;
        # 禁止 CDN 缓存 429,否则一次限流会被放大成缓存层的 429
        add_header Cache-Control "no-store" always;
        # 纯文本响应,避免额外的模板与 gzip 开销
        default_type text/plain;
        return 429 "429 too many requests; retry-after=$retry_after\n";
    }
}

limit_reqzone 参数必须是字面量,不能写变量。想把 UA 分派和限流区绑在一起,只能拆 location,这也是上面把 /api/products/feed/ 单独列出来的原因。map 只做 UA 归一化和 Retry-After 取值。

429 响应必须显式 no-store。这点是被 CDN 坑出来的:第一版没加,CDN 把某个 IP 的 429 缓存了 60 秒,那个时间段内其他爬虫的请求也拿到缓存里的 429。按响应头里的 Age 字段能直接确认,改完之后这类问题再没复现。

排查流程:从日志筛选到修复验证

事故当天我们没有先动配置,而是花两小时把数据钉死。先归因再改,否则很容易在错误方向上优化。

flowchart LR
    S1[筛 access log:状态码 429 且 UA 归一化] --> S2[按分钟聚合 429 计数,定位起始时刻]
    S2 --> S3{429 是 Nginx 产生还是上游产生}
    S3 -->|upstream_time 为短横线| S4[命中 limit_req 或 limit_conn]
    S3 -->|upstream_time 有值且偏大| S5[上游自身限流或依赖超时]
    S4 --> S6[核对 zone/rate/burst/nodelay 与 Retry-After]
    S5 --> S7[按 URI 统计 P95,排出慢接口]
    S7 --> S8[慢查询 / 缓存命中率 / 连接池三项逐个排]
    S6 --> S9[改配置,先在 10% 流量灰度]
    S8 --> S9
    S9 --> S10[用同一脚本重跑,对比 429 占比与 TTFB 分位]
    S10 --> S11[观察抓取量与索引覆盖率的回升曲线]

第一步的筛选条件要落到可聚合的字段上。我们用 $remote_addr 加 UA 归一化标签,直接 awk 出三列就够,不需要上 ELK。当天 96 万条记录,本地脚本跑完约 40 秒。

第二步按分钟聚合,把"限流从哪一分钟开始"钉死。429 在 01:18 开始抬升,而 01:17 正好是商品列表缓存集体过期的时间点:缓存击穿让 TTFB 变长,慢请求占住 worker,其他请求开始排队并触发限流。

第三步区分 429 来源,用的是前面提的 $upstream_response_time 指纹,这一步做完才知道"改 Nginx"和"改应用"各自该承担多少。当天结论是 87% 的 429 来自 Nginx 限流,但触发限流的根因是上游变慢。

用标准库把 429 占比和 TTFB 分位算出来

依赖只有 Python 3.10 以上的标准库,没有 pandas 也没有 numpy。脚本读 Nginx combined 格式加两个追加字段,输出各爬虫的状态码分布与 TTFB 分位。

# 依赖:Python 3.10+,仅标准库(re / sys / gzip / argparse / collections)
# 运行:python log_report.py access.log
# 日志格式:Nginx combined,末尾追加 $request_time 与 $upstream_response_time

import re
import sys
import gzip
import argparse
from collections import defaultdict, Counter

# 正则里关心的字段用命名组,预编译避免逐行重复解析
ACCESS = re.compile(
    r'(?P<ip>\S+) \S+ \S+ \[(?P<ts>[^\]]+)\] "(?P<req>[^"]*)" '
    r'(?P<status>\d{3}) \S+ "[^"]*" "(?P<ua>[^"]*)" '
    r'(?P<rt>\S+) (?P<urt>\S+)'
)
# 先匹配搜索引擎,再匹配 AI 抓取代理,最后兜底为人类用户
# UA 片段到短标签的映射,顺序敏感,先命中先返回
UA_RULES = [
    ("googlebot", "Googlebot"),
    ("google-extended", "Google-Extended"),
    ("gptbot", "GPTBot"),
    ("claudebot", "ClaudeBot"),
    ("perplexitybot", "PerplexityBot"),
    ("bingbot", "Bingbot"),
    ("baiduspider", "Baiduspider"),
]


def classify(ua):
    # 输入是原始 UA 串,输出是统一的短标签
    # UA 统一转小写再比对,避免大小写导致的漏归类
    low = ua.lower()
    for frag, label in UA_RULES:
        if frag in low:
            return label
    # 空 UA 单独成一类,这批往往是最激进的采集脚本
    # 返回短标签而不是布尔值,方便日志里直接 group by
    return "human" if ua.strip() else "empty-ua"


def percentile(values, q):
    # 用最近秩法取分位,样本量小时比线性插值更保守
    if not values:
        return 0.0
    ordered = sorted(values)
    idx = int(round(q * (len(ordered) - 1)))
    return ordered[min(len(ordered) - 1, idx)]


def open_log(path):
    # 兼容 .gz 历史归档,大促后不必手动解压几十 GB
    if path.endswith(".gz"):
        return gzip.open(path, "rt", encoding="utf-8", errors="replace")
    # 普通文件按 utf-8 读,坏字节直接替换,不让脚本中断
    return open(path, encoding="utf-8", errors="replace")


def main():
    # 参数只有一个日志路径,保持脚本可以在巡检任务里直接调用
    ap = argparse.ArgumentParser()
    ap.add_argument("log")
    args = ap.parse_args()

    # 每个爬虫一份状态码计数器,用来算 429 占比
    status_by_bot = defaultdict(Counter)
    # 每个爬虫一份 TTFB 样本,最后统一算分位
    ttfb_by_bot = defaultdict(list)
    # 按分钟聚合 429,用来还原限流从哪一分钟开始
    bad_minute = Counter()

    # 逐行流式处理,单线程足够跑完千万行级别的日志
    for line in open_log(args.log):
        m = ACCESS.search(line)
        # 格式不匹配的行直接跳过,例如内部健康检查的日志
        if not m:
            continue
        bot = classify(m["ua"])
        status = m["status"]
        status_by_bot[bot][status] += 1
        # 命中缓存或压根没走上游时该字段是短横线,必须跳过
        if m["urt"] != "-":
            try:
                ttfb_by_bot[bot].append(float(m["urt"]))
            except ValueError:
                # 多段代理时该字段可能是逗号分隔,这里只取第一段
                pass
        if status == "429":
            bad_minute[m["ts"][:17]] += 1

    print(f'{"bot":<17}{"total":>9}{"429%":>8}{"P50":>8}{"P95":>8}{"P99":>8}')
    # 按请求量倒序,让占比大的爬虫排在最前面
    for bot, cnt in sorted(status_by_bot.items(), key=lambda kv: -sum(kv[1].values())):
        total = sum(cnt.values())
        ratio = cnt.get("429", 0) / total * 100
        samples = ttfb_by_bot.get(bot, [])
        print(
            f"{bot:<17}{total:>9}{ratio:>7.1f}%"
            f"{percentile(samples, 0.50):>8.2f}"
            f"{percentile(samples, 0.95):>8.2f}"
            f"{percentile(samples, 0.99):>8.2f}"
        )

    # 命中缓存的行不参与分位统计,否则 TTFB 会被严重低估
    # 峰值分钟用来对齐应用侧事件,例如缓存集体过期
    # 只打印前 5 个峰值分钟,够用来对齐应用侧事件
    print("\n429 峰值分钟 top5:")
    for ts, n in bad_minute.most_common(5):
        print(f"  {ts}  {n}")

    # 分位值保留两位小数,方便直接对比改动前后的数字
    # 生产环境可以按阈值返回非零退出码,接进每天的巡检任务
    sys.exit(0)


# 入口保持最小,调用 main 之前不读盘
# import 本模块做单测时不会产生副作用
if __name__ == "__main__":
    main()

脚本跑出来的第一版结果里,AI 爬虫那一行的 429 占比 51%、P95 8.6 秒;人类用户那一行只有 6% 和 3.2 秒。限流阈值对爬虫的伤害远大于对用户。

分位函数用最近秩法而不是线性插值,因为事故分析看的是"最差那批请求到底多慢",插值会把这个值抹平;排查早期只有几千条记录时,这个差别直接影响判断。

慢接口才是 TTFB 越过 8 秒的根因

问题最后落在一个具体的 URI 上:商品列表在缓存击穿时 P99 是 9.2 秒,平时只有 380 毫秒。查下来是三件事叠在一起。分页统计用了一条 count(*) 全表扫描,在 SKU 表 420 万行、大促期写放大的情况下从 120 毫秒涨到 4 秒以上;缓存过期时间全站统一设成 300 秒,热点 key 在同一秒集体失效;上游连接池上限 32,慢查询把连接占满后,其余请求都在排队等连接。

改动顺序是先易后难。缓存过期时间加正负 10% 的抖动,集体失效变成散点失效;count(*) 换成按分类聚合的计数表,写入时增量更新,查询退化成一次主键查;连接池从 32 提到 96。

改完之后商品列表 P95 从 3.2 秒降到 620 毫秒,P99 从 9.2 秒降到 1.4 秒。TTFB 的改善不是线性收益:爬虫的超时阈值通常在 5 到 10 秒之间,只要有一批请求落在线上面,那部分抓取就是纯损失,降到线下之后损失直接消失。整体命中率 71% 看着不差,但未命中的 29% 恰好集中在最热的分类上,头部 20 个 URI 只有 43%。

修复前后:抓取成功率与索引覆盖率对比

第 3 天中午完成全部改动,之后连续观察到第 14 天。表里的数字取自同一份 access log 和同一套索引查询脚本,口径没变过。

指标 修复前(大促第 2 天) 修复后(第 14 天) 变化
爬虫抓取成功率 41% 96% +55pp
全站 429 占比 38% 1.7% -36.3pp
AI 爬虫 429 占比 51% 2.4% -48.6pp
商品列表平均 TTFB 3.8s 0.62s -84%
商品列表 TTFB P95 8.6s 1.4s -84%
超时放弃请求占比 11.3% 0.3% -11pp
新品索引覆盖率(14 天) 9% 88% +79pp
单 IP 并发连接峰值 340 22 -94%

恢复曲线的三段尺度差得很远。429 占比从 38% 掉到 2% 用了 5 小时,几乎和配置生效同步;抓取成功率从 41% 回到 70% 用了 2 天;新品索引覆盖率从 9% 爬到 88% 用了 12 天,这一段才是真正的复利。

剩下 12% 的覆盖率一直没上来,是那批新品页面本身的问题:结构化数据里的价格字段和实际价格不一致,抓到了但没进索引,跟限流无关。所以只盯 429 占比的话,这次事故第 5 天就可以宣布结束。

两个常见误区与接下来会怎么变

第一个误区是把爬虫的 429 当攻击处理。我们第一天加了 IP 封禁,把一批 AI 爬虫整段拉黑,它们在退避之后换出口 IP 继续来,频率更低、更不可控。更稳的做法是保留抓取通道,用 Retry-After 给明确节奏,让对方自己降频。

第二个误区是认为"阈值定高一点就没问题"。阈值定高只是把 429 换成超时,请求照样失败,代价还更大:请求已打到数据库和缓存,资源白烧。

趋势上,AI 抓取流量的占比还会涨,特征比传统搜索爬虫更分散:出口 IP 多、单 IP 频率低、对 Retry-After 的遵循程度参差。按 IP 粗粒度限流会越来越容易误伤,按 UA 加路径的差异化策略是当下的可行解。

运维侧最该提前准备的是日志字段。把 $request_time$upstream_response_time、UA 归一化标签都打进 access log,成本几乎为零。需要长期盯的不是 429 这个数字,而是"爬虫抓取成功率"和"新品索引覆盖率"这一对组合指标:前者反映通道是否通畅,后者反映业务影响是否消退。把它们做成每天早上的例行巡检,再加一条 429 占比超 5% 就告警的规则,这类事故下次会早两天被发现。

参考与延伸

关键词:HTTP 429, Retry-After, 限流策略, TTFB, AI爬虫, 抓取退避, 生成式引擎优化, AI优化AIO

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。