「回声」的点赞活动,被代理池刷走了奖品
「回声」做过一次内容创作活动:用户给喜欢的文章点赞,点赞数进入排行榜,排名靠前的作者可以获得流量券。为了防止刷量,后端给点赞接口加了一条规则:同一个 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 限流,不区分目标和动作。用户给不同内容点赞、评论、收藏的频率差异很大,共用一个阈值会互相挤占。
- 频控状态只存在单机内存。多实例部署时每个实例各算一份,实际放行量是阈值的实例数倍,需要共享存储或按实例分摊。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。