风控超时两秒,活动看起来结束了
「刚好」的第三场活动做了更严格的防刷:下单前调用风控服务,检查用户、设备、IP 三个维度在过去十分钟内的行为。风控返回通过才继续扣库存,返回拒绝就提示“参与过于频繁”。
活动开始四分钟,风控服务的响应时间从三十毫秒涨到两秒,随后开始超时。下单接口的代码里写着:风控调用失败就返回错误。前端把所有错误统一展示成“活动已结束”。用户看到的是活动刚开始就结束,客服在十分钟内收到两百多条投诉,实际库存还剩四千多件。这些数字是案例假设值,用来说明故障形态,不是线上实测。
事后复盘,风控本身没有误判,问题出在调用方:它把“风控说不行”和“风控没说清楚”当成了同一种结果。前者是业务拒绝,后者是系统故障,两者需要的兜底完全不同。
防刷不能只看账号
只按账号限购,攻击者只需要多注册。只按设备限购,家庭共享设备会被误伤。只按 IP 限购,代理池和 NAT 会让规则同时失效和误伤。支付工具最接近资金侧,但涉及隐私和合规,不能无限收集。
| 维度 | 能挡住什么 | 典型信号 | 误伤风险 | 单用的问题 |
|---|---|---|---|---|
| 账号 | 批量注册、重复领取 | 注册时长、历史订单、退款率 | 新用户、低频用户 | 接码平台可以批量换号 |
| 设备 | 多开、模拟器、群控 | 设备指纹、安装列表、传感器 | 家庭共享设备、二手设备 | 改机工具可以伪造指纹 |
| IP | 机房流量、代理池 | 归属地、ASN、请求频率 | NAT、校园网、企业出口 | 代理池可以不断换 IP |
| 支付工具 | 换号换机后的资金归集 | 卡 BIN、支付账户、收货地址 | 家庭共用卡、代付 | 隐私敏感,不能过度收集 |
| 行为 | 脚本节奏、批量操作 | 请求间隔、点击轨迹、领取时间 | 无障碍工具、自动化测试 | 需要样本,冷启动不准 |
这些维度不是简单的“越多越好”。每加一个维度,都会增加误伤和合规成本。合理的做法是分层:账号和设备做实时硬限制,IP 做频率软限制,支付工具做事后关联分析,行为信号先打标再进人工审核。
风控结果也不应该只有“通过”和“拒绝”两种。至少要有四种:通过、拒绝、人工审核、系统错误。前三种是业务判断,第四种是系统状态。把系统错误折叠进拒绝,等于让一次依赖故障变成一次全量业务拒绝;折叠进通过,则等于在故障期间对攻击者完全敞开。
| 结果 | 含义 | 建议动作 | 用户文案 |
|---|---|---|---|
| 通过 | 规则内,可以继续 | 进入库存扣减 | 无 |
| 拒绝 | 明确命中规则 | 终止本次请求 | 参与过于频繁,请稍后再试 |
| 审核 | 信号可疑但不确定 | 进入延迟队列或人工审核 | 订单审核中,结果稍后通知 |
| 错误 | 风控不可用或输入异常 | 走降级规则,不直接判死 | 当前人数较多,请稍后再试 |
降级不是静默失败
风控服务不可用时,常见两种错误做法。第一种是 fail-closed:所有请求都拒绝,用户以为活动结束,库存卖不出去。第二种是 fail-open:所有请求都放行,脚本在故障窗口里把库存刷空,事后没有任何记录。
两种做法都把“系统故障”变成了“业务决策”,而且都没有告诉任何人。正确的降级应该同时满足三个条件:有明确的降级规则、有可观测的记录、有对用户的诚实文案。
降级规则可以是本机频控:风控不可用时,先用进程内的滑动窗口限制单设备和单 IP 的频率,再叠加活动本身的限购。它不如完整风控准确,但能把攻击成本从“零”抬到“需要换设备或换 IP”。也可以缩小放行比例,或者只让老用户、历史订单正常的用户先通过。
可观测性同样重要。每一次降级都要记录原因、规则版本、请求标识和最终决策,并且打点。否则事后无法判断故障期间放行了多少、拒绝了多少、有没有被利用。降级不是把异常吞掉,而是换一套规则继续工作,同时把“我降级了”这件事暴露出来。
用户文案要区分“业务拒绝”和“系统繁忙”。“参与过于频繁”是业务结论,用户知道自己被规则挡了;“当前人数较多,请稍后再试”是系统状态,用户知道可以重试。把系统故障写成“活动已结束”,用户不会重试,客服也无法解释。
失败场景:一次超时变成全量拒绝
回到开头。风控 RPC 的超时阈值是两秒,活动服务的调用代码是 if err != nil { return reject }。风控集群因为一次发布滚动重启,两个实例同时下线,客户端连接池里的请求开始超时。活动服务没有区分超时、连接拒绝和业务拒绝,统一走了拒绝分支。
前端拿到拒绝码后,按产品文案映射成“活动已结束”。用户不再重试,客服收到大量投诉,运营以为库存卖完了,差点提前下线活动。实际上库存还有四千多件,风控恢复后活动又正常卖了二十分钟。
第二个变种是输入异常。历史事件里有一条时间戳比当前事件还晚,风控函数返回时间倒退错误,调用方同样当成拒绝。单个脏数据导致一批用户被挡,日志里只有一行“risk rejected”,没有原因。
第三个变种是静默放行。风控超时后,调用方为了“不影响体验”直接返回通过,也没有打点。故障持续了十五分钟,脚本在这段时间里领走了两千多张券,事后对账才发现,但已经无法区分哪些是正常用户。
这三个变种的共同点是:调用方没有把“错误”当成第三种结果。只要风控函数的返回值只有 allow 和 reject,故障就一定会被折叠成其中一种,而两种折叠都会出事。
用滑动窗口 Check 收口多维规则
风控判断可以收成一个纯函数:输入历史事件、当前事件和规则,输出是否放行。历史事件的读取、规则的版本管理、结果的记录和降级策略,都由调用方负责。
// file: internal/activity/risk.go
// tier: L1
// verify: go test ./internal/activity/
package activity
import (
"errors"
"fmt"
)
var (
// ErrInvalidRule 表示风控规则出现负数。
ErrInvalidRule = errors.New("activity: invalid risk rule")
// ErrRiskClockBackwards 表示历史事件的时间晚于当前事件。
ErrRiskClockBackwards = errors.New("activity: risk clock backwards")
)
// Event 是一次待判定的行为事件。三个维度都可以为空,空维度不参与风控;
// At 是 Unix 秒。
type Event struct {
UserID string
DeviceID string
IP string
At int64
}
// Rule 是滑动窗口规则。WindowSeconds 是窗口长度;三个 Max 字段为 0 表示
// 该维度不限制。Max 是窗口内允许出现的总次数,包含本次请求。
type Rule struct {
WindowSeconds int64
MaxPerUser int
MaxPerDevice int
MaxPerIP int
}
// Check 判断事件 e 是否仍在规则允许范围内。窗口是闭区间
// [e.At-WindowSeconds, e.At],历史事件里晚于 e.At 的记录说明时间倒退,
// 直接报错。任一维度达到上限就返回 false;返回错误时调用方应走降级路径,
// 而不是把用户当成正常或异常。
func Check(history []Event, e Event, r Rule) (bool, error) {
if r.WindowSeconds < 0 || r.MaxPerUser < 0 || r.MaxPerDevice < 0 || r.MaxPerIP < 0 {
return false, fmt.Errorf("%w: %+v", ErrInvalidRule, r)
}
start := e.At - r.WindowSeconds
var user, device, ip int
for _, h := range history {
if h.At > e.At {
return false, fmt.Errorf("%w: history at %d > event at %d",
ErrRiskClockBackwards, h.At, e.At)
}
if h.At < start {
continue
}
if e.UserID != "" && h.UserID == e.UserID {
user++
}
if e.DeviceID != "" && h.DeviceID == e.DeviceID {
device++
}
if e.IP != "" && h.IP == e.IP {
ip++
}
}
switch {
case r.MaxPerUser > 0 && user >= r.MaxPerUser:
return false, nil
case r.MaxPerDevice > 0 && device >= r.MaxPerDevice:
return false, nil
case r.MaxPerIP > 0 && ip >= r.MaxPerIP:
return false, nil
}
return true, nil
}
实现里有几个设计决定:
- 窗口是闭区间
[e.At-WindowSeconds, e.At]。边界上的事件到底算不算,必须和运营口径一致;闭区间意味着“刚好在窗口起点”和“和当前同一秒”的历史都计入。 - 任一维度达到上限就拒绝。
Max是窗口内允许的总次数,包含本次请求;历史里已经有Max条,再放行这一条就会超限,所以判断用>=。 - 三个
Max为 0 表示该维度不限制。这样运营可以只配设备维度,也可以只配 IP 维度,不需要额外的开关字段。 - 当前事件的用户、设备或 IP 为空时,跳过对应维度。未登录用户没有用户 ID,匿名设备可能拿不到指纹,跳过比把所有空值当成同一个用户更安全。
- 历史事件的时间晚于当前事件,直接返回错误。静默忽略会让窗口计算悄悄漏掉事件,也会掩盖调用方的排序或时钟问题。
- 规则出现负数返回错误,而不是当成“不限制”。脏配置必须显式失败,否则一次负值会永久绕过限制。
函数返回 (bool, error) 而不是单个布尔值,就是为了保留第三种结果。调用方拿到 false, nil 是业务拒绝,拿到 false, err 是系统或输入异常,应该走降级路径,而不是直接拒绝用户。
测试:窗口边界、各维度与时间倒退
滑动窗口的错误集中在边界:窗口起点、当前时刻、上限刚好达到、空维度、时间倒退。测试要把这些行为固定下来。
// file: internal/activity/risk_test.go
// tier: L1
// verify: go test ./internal/activity/
package activity
import (
"errors"
"testing"
)
func TestCheckCountsInclusiveWindow(t *testing.T) {
r := Rule{WindowSeconds: 60, MaxPerUser: 2}
e := Event{UserID: "u1", At: 100}
history := []Event{
{UserID: "u1", At: 39},
{UserID: "u1", At: 40},
}
ok, err := Check(history, e, r)
if err != nil || !ok {
t.Fatalf("Check at start boundary = %v, %v; want true, nil", ok, err)
}
history = append(history, Event{UserID: "u1", At: 100})
ok, err = Check(history, e, r)
if err != nil || ok {
t.Fatalf("Check with two in-window events = %v, %v; want false, nil", ok, err)
}
}
func TestCheckRejectsEachDimension(t *testing.T) {
e := Event{UserID: "u1", DeviceID: "d1", IP: "10.0.0.1", At: 100}
cases := []struct {
name string
rule Rule
history []Event
}{
{"user", Rule{WindowSeconds: 60, MaxPerUser: 1}, []Event{{UserID: "u1", At: 90}}},
{"device", Rule{WindowSeconds: 60, MaxPerDevice: 1}, []Event{{DeviceID: "d1", At: 90}}},
{"ip", Rule{WindowSeconds: 60, MaxPerIP: 1}, []Event{{IP: "10.0.0.1", At: 90}}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
ok, err := Check(c.history, e, c.rule)
if err != nil || ok {
t.Fatalf("Check = %v, %v; want false, nil", ok, err)
}
})
}
}
func TestCheckZeroMeansUnlimited(t *testing.T) {
e := Event{UserID: "u1", DeviceID: "d1", IP: "10.0.0.1", At: 100}
history := []Event{
{UserID: "u1", DeviceID: "d1", IP: "10.0.0.1", At: 90},
{UserID: "u1", DeviceID: "d1", IP: "10.0.0.1", At: 91},
}
ok, err := Check(history, e, Rule{WindowSeconds: 60})
if err != nil || !ok {
t.Fatalf("Check unlimited = %v, %v; want true, nil", ok, err)
}
}
func TestCheckSkipsEmptyDimension(t *testing.T) {
e := Event{UserID: "", DeviceID: "d1", IP: "", At: 100}
history := []Event{
{UserID: "", At: 99},
{UserID: "", At: 98},
}
ok, err := Check(history, e, Rule{WindowSeconds: 60, MaxPerUser: 1, MaxPerIP: 1})
if err != nil || !ok {
t.Fatalf("Check empty dimensions = %v, %v; want true, nil", ok, err)
}
}
func TestCheckRejectsBackwardsHistory(t *testing.T) {
e := Event{UserID: "u1", At: 100}
history := []Event{{UserID: "u1", At: 101}}
if _, err := Check(history, e, Rule{WindowSeconds: 60, MaxPerUser: 2}); !errors.Is(err, ErrRiskClockBackwards) {
t.Fatalf("backwards history = %v, want ErrRiskClockBackwards", err)
}
}
func TestCheckRejectsInvalidRule(t *testing.T) {
e := Event{UserID: "u1", At: 100}
cases := []Rule{
{WindowSeconds: -1},
{WindowSeconds: 60, MaxPerUser: -1},
{WindowSeconds: 60, MaxPerDevice: -1},
{WindowSeconds: 60, MaxPerIP: -1},
}
for _, r := range cases {
if _, err := Check(nil, e, r); !errors.Is(err, ErrInvalidRule) {
t.Fatalf("Check(%+v) = %v, want ErrInvalidRule", r, err)
}
}
}
TestCheckCountsInclusiveWindow 用窗口六十秒、上限两次的规则,验证窗口起点的事件计入、窗口外的事件忽略,再补一条窗口内事件让计数达到上限并拒绝。TestCheckRejectsEachDimension 分别用用户、设备、IP 三个维度命中上限,确认任一维度都能独立拒绝。
TestCheckZeroMeansUnlimited 用全零规则和大量历史事件,确认仍然放行。TestCheckSkipsEmptyDimension 让当前事件的用户和 IP 为空,历史里塞满空用户事件,确认空维度不会被当成同一个用户。TestCheckRejectsBackwardsHistory 和 TestCheckRejectsInvalidRule 固定时间倒退与负规则的错误路径。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/activity/ -run 'TestCheck' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestCheckCountsInclusiveWindow
--- PASS: TestCheckCountsInclusiveWindow (0.00s)
=== RUN TestCheckRejectsEachDimension
=== RUN TestCheckRejectsEachDimension/user
=== RUN TestCheckRejectsEachDimension/device
=== RUN TestCheckRejectsEachDimension/ip
--- PASS: TestCheckRejectsEachDimension (0.00s)
--- PASS: TestCheckRejectsEachDimension/user (0.00s)
--- PASS: TestCheckRejectsEachDimension/device (0.00s)
--- PASS: TestCheckRejectsEachDimension/ip (0.00s)
=== RUN TestCheckZeroMeansUnlimited
--- PASS: TestCheckZeroMeansUnlimited (0.00s)
=== RUN TestCheckSkipsEmptyDimension
--- PASS: TestCheckSkipsEmptyDimension (0.00s)
=== RUN TestCheckRejectsBackwardsHistory
--- PASS: TestCheckRejectsBackwardsHistory (0.00s)
=== RUN TestCheckRejectsInvalidRule
--- PASS: TestCheckRejectsInvalidRule (0.00s)
PASS
ok webapp/internal/activity 0.229s
六个用例全部通过,其中维度用例包含三个子用例。它们验证的是规则判断;历史事件的存储、规则的下发、降级开关和用户文案,仍然需要风控平台与业务代码配合。
小结
防刷要同时看账号、设备、IP、支付工具和行为,每个维度都有盲区,组合使用才能提高攻击成本。风控结果至少要有通过、拒绝、审核、错误四种,错误不能折叠成拒绝或通过。
降级不是静默失败,而是换一套规则继续工作,同时记录原因、暴露指标、给用户诚实的文案。Check 用 (bool, error) 保留第三种结果,窗口边界、空维度和时间倒退都有明确行为。下一章从活动与秒杀转向增长的另一条线:邀请关系、归因与裂变。
阅读导航:上一节:6.2 秒杀链路与限流 · 下一节:7.1 邀请关系与归因 。
常见错误与边界
- 只按账号限购。注册成本低、接码平台成熟,换账号就能继续领。
- 把设备和支付工具一刀切。家庭共享设备、共用银行卡是正常场景,硬拒绝会误伤真实用户。
- 风控结果只有通过与拒绝。系统错误被折叠成业务拒绝,一次超时变成全量拒绝。
- 降级直接放行且不打点。故障窗口里的攻击无法追溯,也无法判断损失。
- 降级直接拒绝且不解释。用户看到“活动结束”,不会重试,客服也无法解释。
- 窗口边界口径不一致。起点算不算、同一秒算不算,函数、规则配置和文案必须统一。
- 空维度被当成同一个值。所有未登录用户共享空用户键,会互相挤占限额。
- 负数规则被当成不限制。脏配置会永久绕过风控,必须显式报错。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。