《互联网产品的服务端架构·内容、社交与实时互动》6.3 互动防刷与频控

从「回声」一次点赞活动被代理池刷穿的事故写起:说明「同一用户对同一目标」「同一 IP 多账号」「同一设备批量注册」三种频控维度分别拦什么、漏什么,给出 Rule、Throttle、Allow 与 AllowE 的滑动窗口 Go 实现、表驱动测试和真实运行输出,并整理频控键设计的常见错误与边界。

「回声」的点赞活动,被代理池刷走了奖品

「回声」做过一次内容创作活动:用户给喜欢的文章点赞,点赞数进入排行榜,排名靠前的作者可以获得流量券。为了防止刷量,后端给点赞接口加了一条规则:同一个 IP 每分钟最多点赞 60 次,超过就返回 429。

活动开始半小时,排行榜上出现了一篇普通文章,十分钟内涨了 4 万赞。风控同学查访问日志,发现这些点赞来自三千多个不同 IP,每个 IP 只点了十几次,完全没有触发限流。再往下查,账号注册时间集中在活动开始前一周,头像和昵称是批量生成的,设备指纹高度相似,但 IP 分散在多个云厂商的出口网段。

这次事故暴露的问题很直接:只按 IP 限流,防的是“单机高频”,防不了“代理池 + 批量账号”。攻击者只要把请求分散到足够多的 IP,单 IP 阈值就形同虚设。更糟的是,正常用户在公司或校园网共享出口,反而容易被这个阈值误伤。

频控不是“加一个计数器”这么简单。它要回答三个问题:键是什么、窗口多长、超过之后怎么办。键选错,后面的阈值和窗口再精细也没有意义。

频控键不是一个维度,而是一组维度

互动防刷的频控键至少要覆盖三层:用户与目标的关系、网络来源、设备与账号特征。每一层拦的东西不同,漏的东西也不同。把它们组合起来,才能既拦住批量行为,又不把正常用户挡在门外。

频控键拦截目标单独使用的漏洞建议窗口与动作
user_id + target_id + action同一用户反复点赞、取消、再点赞换账号即可绕过分钟级,超过后拒绝并提示
user_id + action单账号对所有内容的批量互动多账号轮换可绕过分钟级,超过后降级或验证
ip + action单出口的高频请求代理池、NAT 共享会误伤分钟级,仅作辅助信号
device_id + action同一设备批量注册或切换账号设备指纹可伪造小时级,超过后进人工审核
account_age + action新账号突击互动老账号被盗或养号天级,新账号阈值更严

最内层是 user_id + target_id + action。它解决的是“同一个人对同一篇文章反复点赞取消”,这类行为通常来自脚本或误触,频率不会太高,窗口可以短。它不能防多账号,因为每个账号都是独立的键。

中间层是 user_id + action。它限制一个账号在短时间内对所有内容的互动总量。这一层能拦住“一个账号给几百篇文章点赞”的脚本,但拦不住“几千个账号各点十几篇”。它也会误伤真实的重度用户,所以阈值要结合账号历史,不能一刀切。

外层是 ip + action 和 device_id + action。它们不直接决定是否放行,而是作为风险信号参与决策。单独用 IP 限流,就是这次事故的根因;单独用设备指纹,攻击者可以改参数;两者叠加,再结合账号年龄和内容分布,才能把“代理池 + 批量账号”的成本抬上去。

滑动窗口比固定分桶更难绕过

固定分桶的实现很常见:把时间按分钟切段,key 是 user:action:202610281930,每次请求对当前分钟的计数器加一。它在窗口边界有一个明显漏洞:攻击者在 19:29:59 打满 60 次,19:30:00 再打满 60 次,两秒内就完成了 120 次,而每一分钟看起来都合规。

滑动窗口保存每次放行的时间戳,按 now - window 淘汰过期记录,再判断窗口内数量。它没有固定边界,攻击者无法靠“卡点”获得双倍配额。代价是要为每个 key 保存一个时间戳列表,内存随请求量增长,需要配合淘汰和上限。

还有一种折中是“滑动日志 + 令牌桶”:用令牌桶控制平均速率,用滑动窗口控制突发。对点赞这种低频互动,滑动窗口足够;对评论、私信这种可能被用于骚扰的接口,还需要内容级和关系级的额外限制。

超过阈值之后,不是只有拒绝一种动作

频控命中后的动作应该分层。第一层是拒绝,返回剩余等待时间,让正常用户知道什么时候可以重试;第二层是降级,比如点赞先进入待确认状态,不立即计入公开计数;第三层是验证,要求完成滑块或短信验证;第四层是人工审核或封禁。

如果所有命中都直接返回 429,正常用户会因为一次误触被挡住,攻击者则能从响应里推断阈值。更好的做法是对内层键直接拒绝,对外层风险信号只做标记和降级,把“是否真人”的判断交给验证环节。

失败场景:只按 IP 限流,代理池轻松绕过

回到活动事故。攻击者的脚本逻辑很简单:准备三千个账号,每个账号绑定一个代理出口,循环给目标文章点赞。每个 IP 每分钟只发 10 次请求,远低于 60 的阈值;每个账号只点 20 次,也没有触发账号级限制,因为账号级限制根本没配。

真正应该拦住它的是三层组合。第一层 user_id + target_id + action 只能限制单账号重复,挡不住多账号;第二层 user_id + action 能限制单账号总量,但三千个账号轮流上,单账号仍然很低;第三层 device_id + action 才是关键,因为批量注册的账号往往共享设备指纹或模拟器特征,即使 IP 分散,设备维度仍会聚在一起。

事故的后果不只是排行榜失真。流量券发给了刷量作者,真实创作者觉得平台不公平;活动预算被快速消耗,后续无法追加;更严重的是,计数服务被异常流量打高,热度排序把这篇普通文章推到了首页,普通用户看到的是明显不匹配的内容。

修复时,团队没有简单调低 IP 阈值,而是把频控改成多键组合:内层键命中直接拒绝,设备键命中进入验证,IP 键只作为风险分。同时给新账号设置更严的互动配额,对短时间内点赞高度集中的内容触发人工复核。活动结束后复盘,真正的收益不是“挡住了多少请求”,而是“误伤率没有上升”。

用 Rule 和 Throttle 实现滑动窗口

频控器需要两个输入:规则和当前时间。规则包含窗口长度和窗口内最大放行次数;当前时间由调用方传入,而不是在函数内部取 time.Now(),这样测试可以精确控制边界,也方便在分布式环境里换成统一时钟。

下面的代码来自本书配套的 webapp-lab。AllowE 是核心方法,返回是否放行、剩余等待秒数和错误;Allow 是简化版本。每次放行都会记录时间戳,窗口按 now - WindowSeconds 淘汰,因此是滑动窗口而不是固定分桶。

// file: internal/counter/throttle.go
// tier: L1
// verify: go test ./internal/counter/

package counter

import (
	"fmt"
	"sync"
)

// Rule 是一个滑动窗口频控规则:WindowSeconds 秒内最多放行 Max 次。
type Rule struct {
	WindowSeconds int
	Max           int
}

func (r Rule) validate() error {
	if r.WindowSeconds <= 0 {
		return fmt.Errorf("counter: window seconds must be positive, got %d", r.WindowSeconds)
	}
	if r.Max < 0 {
		return fmt.Errorf("counter: max must not be negative, got %d", r.Max)
	}
	return nil
}

// Throttle 按 key 维护滑动窗口。固定分桶在窗口边界会放过两倍流量,
// 这里保存每次放行的时间戳,按 now-WindowSeconds 淘汰。
type Throttle struct {
	mu      sync.Mutex
	rule    Rule
	events  map[string][]int64
	lastNow int64
	seen    bool
}

// New 创建一个频控器并校验规则。
func New(rule Rule) (*Throttle, error) {
	if err := rule.validate(); err != nil {
		return nil, err
	}
	return &Throttle{rule: rule, events: make(map[string][]int64)}, nil
}

// AllowE 判断 key 在 now 时刻是否放行,并返回需要等待的秒数。
// 拒绝时等待秒数是窗口内最早一条仍需过期的事件释放后的时间。
// now 小于此前任意一次调用的 now 时返回 error,不修改任何状态。
func (t *Throttle) AllowE(key string, now int64) (bool, int, error) {
	if key == "" {
		return false, 0, fmt.Errorf("counter: empty throttle key")
	}
	t.mu.Lock()
	defer t.mu.Unlock()

	if t.seen && now < t.lastNow {
		return false, 0, fmt.Errorf("counter: clock moved backwards: %d < %d", now, t.lastNow)
	}
	t.seen, t.lastNow = true, now

	cutoff := now - int64(t.rule.WindowSeconds)
	events := t.events[key]
	i := 0
	for i < len(events) && events[i] <= cutoff {
		i++
	}
	events = events[i:]
	if len(events) == 0 {
		delete(t.events, key)
	} else {
		t.events[key] = events
	}

	if len(events) >= t.rule.Max {
		if t.rule.Max <= 0 {
			return false, t.rule.WindowSeconds, nil
		}
		need := len(events) - t.rule.Max + 1
		oldest := events[need-1]
		wait := t.rule.WindowSeconds - int(now-oldest)
		if wait < 1 {
			wait = 1
		}
		return false, wait, nil
	}

	t.events[key] = append(events, now)
	return true, 0, nil
}

// Allow 是 AllowE 的简化版本。空 key、时钟倒退或规则错误时返回 false 和 0;
// 需要错误原因时使用 AllowE。
func (t *Throttle) Allow(key string, now int64) (bool, int) {
	ok, wait, _ := t.AllowE(key, now)
	return ok, wait
}

等待秒数的计算值得单独说明。窗口内已经有 L 条记录、上限是 Max 时,要让新请求通过,必须等 L - Max + 1 条旧记录过期。取这第 L - Max + 1 条记录的时间戳,算出它还有多久离开窗口,就是需要等待的秒数。这样返回的不是一个固定值,而是“最早什么时候可以重试”。

时钟倒退会直接返回 error,并且不修改任何状态。分布式系统里,不同实例的时钟可能短暂不一致;如果允许 now 任意倒退,旧请求会被当成新请求,窗口记录也会乱序。把时钟单调性作为前置条件,比在窗口逻辑里做各种补偿更可靠。

Max 允许为 0,表示该键完全禁止放行,可用于紧急封禁;WindowSeconds 和 Max 为负数会在 New 阶段被拒绝。空 key 也会报错,因为空 key 会把所有请求聚到同一个窗口里。

测试:窗口边界、键隔离和时钟倒退

频控的测试必须覆盖四类行为:滑动窗口是否按时间淘汰、拒绝时等待秒数是否正确、不同 key 是否互不影响、非法输入是否报错。

// file: internal/counter/throttle_test.go
// tier: L1
// verify: go test ./internal/counter/

package counter

import "testing"

func newTestThrottle(t *testing.T, window, max int) *Throttle {
	t.Helper()
	th, err := New(Rule{WindowSeconds: window, Max: max})
	if err != nil {
		t.Fatalf("New = %v, want nil", err)
	}
	return th
}

func TestNewRejectsInvalidRules(t *testing.T) {
	cases := []Rule{
		{WindowSeconds: 0, Max: 1},
		{WindowSeconds: -1, Max: 1},
		{WindowSeconds: 10, Max: -1},
	}
	for _, rule := range cases {
		if _, err := New(rule); err == nil {
			t.Fatalf("New(%+v) = nil, want error", rule)
		}
	}
}

func TestAllowSlidingWindowAndWait(t *testing.T) {
	th := newTestThrottle(t, 10, 2)

	if ok, wait := th.Allow("user-1", 100); !ok || wait != 0 {
		t.Fatalf("first = %v, %d; want true, 0", ok, wait)
	}
	if ok, wait := th.Allow("user-1", 101); !ok || wait != 0 {
		t.Fatalf("second = %v, %d; want true, 0", ok, wait)
	}
	if ok, wait := th.Allow("user-1", 102); ok || wait != 8 {
		t.Fatalf("third = %v, %d; want false, 8", ok, wait)
	}
	if ok, wait := th.Allow("user-1", 109); ok || wait != 1 {
		t.Fatalf("at 109 = %v, %d; want false, 1", ok, wait)
	}
	if ok, wait := th.Allow("user-1", 110); !ok || wait != 0 {
		t.Fatalf("at boundary = %v, %d; want true, 0", ok, wait)
	}
}

func TestAllowKeysAreIndependent(t *testing.T) {
	th := newTestThrottle(t, 60, 1)

	if ok, _ := th.Allow("user-1", 10); !ok {
		t.Fatal("user-1 first request must pass")
	}
	if ok, wait := th.Allow("user-1", 11); ok || wait != 59 {
		t.Fatalf("user-1 second = %v, %d; want false, 59", ok, wait)
	}
	if ok, _ := th.Allow("user-2", 11); !ok {
		t.Fatal("user-2 must not share user-1's window")
	}
}

func TestAllowRejectedAttemptDoesNotExtendWindow(t *testing.T) {
	th := newTestThrottle(t, 10, 1)

	if ok, _ := th.Allow("user-1", 0); !ok {
		t.Fatal("first request must pass")
	}
	if ok, wait := th.Allow("user-1", 3); ok || wait != 7 {
		t.Fatalf("reject at 3 = %v, %d; want false, 7", ok, wait)
	}
	if ok, wait := th.Allow("user-1", 4); ok || wait != 6 {
		t.Fatalf("reject at 4 = %v, %d; want false, 6", ok, wait)
	}
}

func TestAllowZeroMaxAlwaysRejects(t *testing.T) {
	th := newTestThrottle(t, 30, 0)
	if ok, wait := th.Allow("user-1", 5); ok || wait != 30 {
		t.Fatalf("zero max = %v, %d; want false, 30", ok, wait)
	}
}

func TestAllowERejectsBackwardClock(t *testing.T) {
	th := newTestThrottle(t, 10, 2)

	if _, _, err := th.AllowE("user-1", 100); err != nil {
		t.Fatalf("first AllowE = %v, want nil", err)
	}
	if _, _, err := th.AllowE("user-1", 99); err == nil {
		t.Fatal("backward clock = nil, want error")
	}
	if ok, _ := th.Allow("user-1", 100); !ok {
		t.Fatal("first request at 100 must still be recorded after the rejected call")
	}
}

func TestAllowERejectsEmptyKey(t *testing.T) {
	th := newTestThrottle(t, 10, 1)
	if _, _, err := th.AllowE("", 1); err == nil {
		t.Fatal("empty key = nil, want error")
	}
}

TestAllowSlidingWindowAndWait 是最关键的一条。窗口 10 秒、上限 2 次,100 秒和 101 秒各放行一次,102 秒被拒绝并等待 8 秒;109 秒仍然被拒绝,等待 1 秒;110 秒时 100 秒的记录正好离开窗口,请求通过。这个用例同时验证了“边界是开区间”和“等待时间随 oldest 记录变化”。

TestAllowKeysAreIndependent 确认 user-2 不会共享 user-1 的窗口,这是多维度频控的基础。TestAllowRejectedAttemptDoesNotExtendWindow 确认被拒绝的请求不会被记录,否则攻击者每次重试都会把窗口往后推,正常用户永远等不到放行。

TestAllowERejectsBackwardClock 和 TestAllowERejectsEmptyKey 覆盖错误路径。前者还验证了报错之后状态没有被污染:紧接着在同一个时间点调用 Allow,第一次请求仍然能被记录。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/counter/ -run 'TestNew|TestAllow' -count=1 -v

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

=== RUN   TestNewRejectsInvalidRules
--- PASS: TestNewRejectsInvalidRules (0.00s)
=== RUN   TestAllowSlidingWindowAndWait
--- PASS: TestAllowSlidingWindowAndWait (0.00s)
=== RUN   TestAllowKeysAreIndependent
--- PASS: TestAllowKeysAreIndependent (0.00s)
=== RUN   TestAllowRejectedAttemptDoesNotExtendWindow
--- PASS: TestAllowRejectedAttemptDoesNotExtendWindow (0.00s)
=== RUN   TestAllowZeroMaxAlwaysRejects
--- PASS: TestAllowZeroMaxAlwaysRejects (0.00s)
=== RUN   TestAllowERejectsBackwardClock
--- PASS: TestAllowERejectsBackwardClock (0.00s)
=== RUN   TestAllowERejectsEmptyKey
--- PASS: TestAllowERejectsEmptyKey (0.00s)
PASS
ok  	webapp/internal/counter	0.364s

PASS 表示七个顶层用例全部通过,ok 行末尾是包路径和本次耗时。测试里用的是传入的整数时间,不依赖真实时钟,所以结果稳定;生产环境要把 now 换成统一的时间源,并注意多实例之间的窗口状态是否需要共享。

小结

频控键选错,阈值再精细也没有用。只按 IP 限流,防的是单机高频,防不了代理池;只按用户 ID 限流,防不了批量账号;只按设备限流,设备指纹可以被伪造。三层组合、分层处置,才能同时控制漏放和误伤。

滑动窗口比固定分桶更难绕过,因为它没有可以卡点的窗口边界。代价是要保存时间戳列表,并为每个 key 设置内存上限和淘汰策略。返回剩余等待秒数而不是固定 429,能让正常用户知道何时重试,也能减少无意义的轮询。

下一节进入评论系统:评论树如何分页、如何避免深分页拖垮数据库,以及楼中楼的排序和折叠该怎么做。

阅读导航:上一节:6.2 计数一致性:精确、近似与对账 · 下一节:7.1 评论树与分页 。

常见错误与边界

  • 只按 IP 做频控。代理池和 NAT 共享出口会让这个键既漏放又误伤,IP 只能作为辅助风险信号。
  • 用固定分桶实现窗口。攻击者在窗口边界两侧各打满一次,就能在很短时间内获得双倍配额。
  • 把被拒绝的请求也记入窗口。攻击者反复重试会不断延长窗口,正常用户反而永远等不到放行。
  • 所有频控命中都直接返回 429。内层键可以拒绝,外层风险信号应该降级或验证,否则误伤率会很高。
  • 在函数内部调用 time.Now()。测试无法控制边界,分布式环境也无法统一时钟;时间应该由调用方传入。
  • 允许 now 倒退。旧请求会被当成新请求,窗口记录乱序;应该显式报错并保持状态不变。
  • 只按用户 ID 限流,不区分目标和动作。用户给不同内容点赞、评论、收藏的频率差异很大,共用一个阈值会互相挤占。
  • 频控状态只存在单机内存。多实例部署时每个实例各算一份,实际放行量是阈值的实例数倍,需要共享存储或按实例分摊。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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