AI 爬虫在 429 和慢响应面前直接走人:限流阈值与 TTFB 排查复盘
适用读者:负责站点可用性与搜索可见性的后端/运维工程师,手上有 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_req、limit_conn、map、geo 都是内置模块。思路是先把 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_req 的 zone 参数必须是字面量,不能写变量。想把 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% 就告警的规则,这类事故下次会早两天被发现。
参考与延伸
- RFC 6585: Additional HTTP Status Codes —— 429 与
Retry-After的规范定义 - MDN: 429 Too Many Requests
- nginx: ngx_http_limit_req_module ——
burst、nodelay的官方说明 - nginx: ngx_http_map_module
关键词:HTTP 429, Retry-After, 限流策略, TTFB, AI爬虫, 抓取退避, 生成式引擎优化, AI优化AIO