《互联网产品的服务端架构·规模化与架构演进》7.1 反爬与接口防刷

「回声」的价格接口被竞品爬虫高频抓取,团队先按 IP 封禁,误伤了合作方共享出口的三千名员工;又改成全量验证码,正常转化明显下滑。本节把单点封禁拆成多信号加权评分,给出可解释的评分、阈值与处置动作实现,并复盘一次误封引发合作方投诉的完整过程。

价格接口被爬空的那个下午

「回声」是一个二手交易社区。运营同学每天上午会看竞品的价格页,某天发现对方的新品价格、库存和「回声」几乎逐分钟同步。排查访问日志后,价格接口在凌晨两点到五点之间稳定出现每天 40 万次请求(案例假设值),来源是两千多个 IP,UA 全部是同一版浏览器,请求间隔精确到毫秒。正常的用户流量在这个时段只有三万多。

第一版处置很直接:把请求量最高的 200 个 IP 拉黑。效果只维持了半天。第二天爬虫换了一批 IP,量又回来了;更糟的是,客服收到合作方投诉:对方有三千名员工通过同一个出口访问「回声」,出口 IP 在黑名单里,整个公司都打不开商品页。

第二版改成“所有价格接口都弹验证码”。爬虫确实慢了下来,但正常用户的比价转化率也掉了一截,移动端尤其明显,因为验证码在小屏幕上本来就难通过。团队这时才意识到,反爬不是“封得越狠越安全”,而是在有限误伤下提高攻击成本。

这一节把问题拆成三层:看哪些信号、怎么加权、不同分数怎么处置。最后落成一个可以单独测试的纯函数,而不是散落在网关配置里的 if 分支。

单 IP 封禁为什么总在赔钱

单 IP 封禁的问题不在于判断错,而在于它把“一个网络出口”当成了“一个人”。家庭宽带、校园网、企业 NAT、运营商级 NAT 都会让成百上千的真实用户共享一个出口 IP。爬虫也早就知道这一点,会主动使用代理池、云主机和移动网络轮换地址。

更合理的做法是同时观察多个弱信号,让它们互相印证。单个信号都不可靠,但组合起来可以显著区分“偶发的高频用户”和“持续自动化的采集器”。

信号权重(案例假设值)说明主要误伤风险
单 IP 每分钟请求数超过 12030最直观的频率信号企业 NAT、运营商共享出口
同一 UA 覆盖超过 50 个账号25自动化工具常复用 UA网吧、公共终端
请求间隔标准差小于 20ms20机器节奏比人稳定前端轮询、监控探针
无 Cookie 且直接访问详情接口15跳过正常页面路径隐私模式、API 合作方
账号注册时间小于 1 小时15新号批量注册真实新用户
命中已知数据中心网段10云主机出口企业自建代理
白名单商户或合作方-40显式抵扣白名单被滥用

这些权重是案例假设值,不是行业标准。真实权重应该来自自己的误报样本和漏报样本:把过去一个月被投诉误封的请求、确认的爬虫请求各抽几百条,分别算每个信号的命中率,再决定权重。没有样本时,先用保守权重加人工复核,不要直接上生产。

频率阈值要按接口分开

价格接口、登录接口、搜索接口的正常频率完全不同。拿同一个“每分钟 120 次”套所有接口,要么误伤搜索,要么放过爬虫。阈值至少要按“接口 + 是否登录 + 客户端类型”三个维度分组,并且在配置中心里能单独调整。

分组越细,配置越多,也越容易写出互相矛盾的规则。一个实用的折中是:公共只读接口一组,登录后写接口一组,高风险接口(登录、改绑手机、下单)单独一组。每组只保留一个频率阈值和一个验证码阈值,超过就升级处置。

阈值不是拍脑袋的数字

阈值应该由两条曲线决定:正常用户的分数分布和攻击流量的分数分布。把最近一周的请求按分数画 histogram,找到两条曲线交叉的位置,再把 challenge 阈值放在交叉点附近,block 阈值放在攻击流量明显聚集的位置。这样阈值有依据,也能在业务变化后重新校准。

如果没有分布数据,至少要做“影子模式”:新规则先只记录不拦截,观察一周内会被 challenge 和 block 的请求里,有多少来自真实成交用户。影子模式能避免上线当天才发现误伤。

challenge 比 block 更常用

block 是不可逆的拒绝,challenge 是让用户证明自己是人。对价格接口这类读请求,challenge 的成本远低于误封:真实用户点一下就能继续,爬虫则要付出验证码识别或代理切换的成本。只有在分数很高、且请求明显不携带任何真实用户特征时,才使用 block。

处置动作还应该有时效。一次 challenge 通过后,给这个会话一个短期的信任窗口,例如 30 分钟;否则用户每翻一页都要验证,体验会迅速恶化。信任窗口要绑定会话或设备,而不是只绑 IP,否则又会回到共享出口的问题。

误伤要有申诉入口

再好的评分也会误伤。误伤发生后,用户需要一个能说清楚“我是谁、我在做什么”的入口,而不是只能反复刷新。申诉入口至少要做到三件事:记录被处置时的分数和命中信号;允许用户提交账号或合作方标识;处理结果能反向进入规则评估。

如果申诉只能人工看日志,成本会很高。更好的做法是把申诉和评分解释绑在一起:客服看到的是“该请求命中:频率 30、无 Cookie 15、新账号 15,总分 60,触发 challenge”,而不是一句“系统判定异常”。可解释性直接决定申诉能不能在几分钟内处理完。

规则上线要能灰度

反爬规则一旦误伤,影响的是真实用户,所以不能像普通业务逻辑那样一次性全量发布。规则应该支持按接口、按流量比例、按用户分桶灰度。先对 1% 的流量启用新权重,观察 challenge 率、通过率和投诉量,再逐步扩大。

灰度期间要同时保留旧规则,并能随时切回。回滚不应该依赖重新发版,而应该是一个配置开关。规则版本号要写进请求日志,否则事故复盘时无法区分“这次请求到底用了哪套权重”。

采集合规的边界

反爬需要采集 IP、UA、设备指纹和行为序列,这些数据本身就可能构成个人信息。采集范围应该遵循最小化:只保留判断所需的字段,不额外记录完整请求体和无关的浏览历史;保留期要明确,过期删除;对外解释时要说明用途。

设备指纹尤其要谨慎。它可能跨会话稳定标识一个用户,接近个人信息的效果。如果产品使用了设备指纹,应该评估是否需要告知和取得同意,并提供关闭路径。反爬的有效性不能成为无限采集的理由。

还有一个容易被忽略的边界:内部员工和合作方的访问。它们通常来自固定出口、使用脚本或批量工具,天然带有高频特征。规则上线前应该先把这类流量识别出来,登记为白名单或独立配额,而不是等投诉发生后再补。

白名单本身也要有生命周期,不能一次登记永久有效。

一次 /24 封禁带来的连锁投诉

事故发生在第二版规则上线后的周一。风控同学看到一个 C 段地址在十分钟内请求了八万次价格接口,直接把整个 /24 加入黑名单。从数据上看,这个网段确实贡献了绝大部分异常请求。

问题是,这个 /24 属于一家合作企业的办公网出口。对方有三千名员工,其中一部分负责在「回声」上做批量上架和价格维护。封禁生效后,对方的运营后台全部返回 403,商品价格停止更新,持续了四个小时。按照双方的服务协议,这属于可用性事故,团队不仅要写复盘,还赔了一笔服务抵扣。

更尴尬的是,真正的爬虫并没有被完全挡住。它把流量切到了另外几个网段,总量只下降了不到三成。也就是说,这次封禁付出了确定的误伤代价,却没有换来确定的安全收益。

复盘时团队承认了三个错误。第一,把网段当成身份,忽略了 NAT 和合作方出口。第二,没有为高频但合法的合作方保留白名单或独立配额。第三,处置动作只有“封”和“不封”两档,没有中间态。

修正方案是把 /24 封禁降级为“该网段内单 IP 的频率信号照常计分,但禁止直接 block”,同时给合作方登记独立配额。对确认的爬虫网段,也不是永久封禁,而是提高 challenge 概率并缩短信任窗口。这样即使误判,影响也局限在“多验证几次”,而不是“整家公司打不开”。

把评分写成可解释的纯函数

配套代码 internal/guard/antiautomation.go 只做三件事:把信号加权求和、把分数映射成处置动作、在阈值配置写反时选择保守结果。它不访问 Redis、不查数据库、不发送挑战,因为这些副作用会让“同一个请求为什么得到这个分数”难以复现。

// file: internal/guard/antiautomation.go
// tier: L1
// verify: go test ./internal/guard/

package guard

// Signal 是反爬评分中的一个观察信号。Name 用于日志和申诉解释,
// Weight 是该信号对总分的贡献,允许为负值,例如白名单抵扣。
type Signal struct {
	Name   string
	Weight int
}

// Score 汇总所有信号的权重。
// 总分最低为 0:负分只用于抵消其他信号,不应该让一次请求
// 因为“负得太多”而获得额外的通行能力。
func Score(signals []Signal) int {
	total := 0
	for _, s := range signals {
		total += s.Weight
	}
	if total < 0 {
		return 0
	}
	return total
}

// Decide 根据分数和两个阈值给出处置动作:pass、challenge 或 block。
// thresholds[0] 是 challenge 的下限,thresholds[1] 是 block 的下限。
// 两个阈值必须严格递增;配置写反时返回 challenge 而不是放行,
// 避免一次错误的配置把防护直接关掉。
func Decide(score int, thresholds [2]int) string {
	challenge, block := thresholds[0], thresholds[1]
	if block <= challenge {
		return "challenge"
	}
	switch {
	case score >= block:
		return "block"
	case score >= challenge:
		return "challenge"
	default:
		return "pass"
	}
}

Score 把负分截断到 0,而不是允许总分为负。负权重的用途是白名单抵扣,例如合作方请求带签名,减 40 分;如果总分已经是 0,再减也没有意义。截断让分数保持在一个稳定区间,阈值不会因为负分而失去解释力。

Decide 把阈值放在参数里,而不是写成包级常量。这样同一套逻辑可以服务不同接口:价格接口用 {40, 80},登录接口用 {20, 60},测试里也能直接构造边界。阈值来自配置,函数本身保持无状态。

阈值写反时返回 challenge 是一个有意的选择。配置写反通常意味着发布或解析出了问题,此时放行等于关闭防护,直接 block 又可能把全部流量拒之门外。challenge 是唯一“既不放过也不打死”的兜底动作,配合告警可以让问题尽快被发现。

代码没有包含“信号采集”。频率、UA、Cookie、账号年龄都应该在网关或风控服务里算好,再以 Signal 的形式传进来。采集层可能有缓存、滑动窗口和异步统计,这些都不适合放进一个需要快速单测的函数里。

代码也没有实现“信任窗口”。一次 challenge 通过后如何免验证,属于会话状态管理,应该由网关的 token 或 cookie 承担。把状态塞进评分函数,会让纯函数变成有状态服务,测试和回滚都会变难。

测试:分数、阈值与坏配置

反爬代码最怕两个问题:分数算错但没人发现,以及配置写反后静默放行。下面的测试把求和、边界、负分和坏阈值都固定下来。

// file: internal/guard/antiautomation_test.go
// tier: L1
// verify: go test ./internal/guard/

package guard

import "testing"

func TestScore(t *testing.T) {
	tests := []struct {
		name    string
		signals []Signal
		want    int
	}{
		{"empty", nil, 0},
		{"sum", []Signal{{"ip", 30}, {"ua", 20}, {"interval", 15}}, 65},
		{"negative floor", []Signal{{"allowlist", -50}, {"ua", 10}}, 0},
	}
	for _, tt := range tests {
		if got := Score(tt.signals); got != tt.want {
			t.Errorf("%s: Score() = %d, want %d", tt.name, got, tt.want)
		}
	}
}

func TestDecide(t *testing.T) {
	th := [2]int{40, 80}
	tests := []struct {
		score int
		want  string
	}{
		{0, "pass"},
		{39, "pass"},
		{40, "challenge"},
		{79, "challenge"},
		{80, "block"},
		{200, "block"},
	}
	for _, tt := range tests {
		if got := Decide(tt.score, th); got != tt.want {
			t.Errorf("Decide(%d) = %q, want %q", tt.score, got, tt.want)
		}
	}
}

func TestDecideInvalidThresholds(t *testing.T) {
	for _, th := range [][2]int{{80, 40}, {50, 50}, {-1, -2}} {
		if got := Decide(100, th); got != "challenge" {
			t.Errorf("Decide(100, %v) = %q, want challenge", th, got)
		}
	}
}

TestScore 覆盖了空信号、正常求和和负分截断。空信号返回 0 是有意的:没有任何证据时不应该凭空加分。负分截断则保证白名单抵扣不会制造一个“负分请求”,让后续阈值判断变得反直觉。

TestDecide 把 challenge 和 block 的边界各测了两次:刚好等于阈值、刚好低于阈值。阈值类代码最常见的错误是 > 和 >= 写反,导致某个分数永远落在错误档位。表驱动测试让这个边界一眼可见。

TestDecideInvalidThresholds 覆盖了三种坏配置:写反、相等和双负值。它们都应该返回 challenge,而不是 pass 或 block。这个测试的价值不在于证明 challenge 是唯一正确答案,而在于把“坏配置必须保守”这个约定固定下来,防止后来的人改成默认放行。

测试没有覆盖信号采集和真实流量分布。那部分应该用影子模式和离线评估验证,而不是在单元测试里断言“误报率低于 1%”,因为小样本下这个断言本身就不稳定。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/guard/ -run 'TestScore|TestDecide' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestScore
--- PASS: TestScore (0.00s)
=== RUN   TestDecide
--- PASS: TestDecide (0.00s)
=== RUN   TestDecideInvalidThresholds
--- PASS: TestDecideInvalidThresholds (0.00s)
PASS
ok  	webapp/internal/guard	0.322s

三个用例全部通过,退出码为 0。-run 只筛选了评分和处置相关的用例,同包的内容分级与脱敏测试没有执行;需要全量确认时去掉 -run 再跑一次。

小结

反爬的核心不是找到一种“绝对可靠”的封禁手段,而是用多个弱信号把攻击成本抬高,同时把误伤控制在可申诉、可恢复的范围内。单 IP 封禁之所以危险,是因为它把网络出口当成了用户身份;多信号加权并不完美,但它至少能解释“为什么拦”,也能在误判时只降级而不打死。

阈值应该来自分布和影子模式,而不是拍脑袋;处置动作应该优先 challenge,而不是直接 block;误伤必须有申诉入口,并且申诉结果要能反向校准权重。下一节讨论另一个更敏感的问题:内容分级、年龄与处置动作如何一起设计。

阅读导航:上一节:目录与学习路径 · 下一节:7.2 内容安全与未成年人保护 。

常见错误与边界

  • 把 IP 当用户身份。NAT、校园网和合作方出口会让成百上千人共享地址,按 IP 直接 block 很容易造成群体误伤。
  • 只用一个信号做决策。频率、UA、Cookie、账号年龄都不可靠,组合使用并保留负权重抵扣,比单点规则稳定。
  • 阈值长期不校准。业务节奏、客户端版本和攻击手法都会变,阈值应该定期用真实分布重新评估。
  • 只做 block 不做 challenge。对读接口而言,challenge 的误伤成本远低于拒绝;block 应留给分数极高且无真实用户特征的请求。
  • 没有申诉和解释。用户被拦后只能刷新,客服也看不到命中原因,误伤会直接变成投诉和流失。
  • 配置写反时默认放行。阈值必须严格递增,坏配置应该走保守分支并告警,而不是悄悄关闭防护。
  • 忽略白名单滥用。合作方白名单要绑定签名、配额和到期时间,否则会成为爬虫绕过评分的最短路径。
  • 用单元测试证明误报率。误报率需要影子模式和真实样本评估,单元测试只适合锁住边界与确定性。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查