《互联网产品的服务端架构·交易、增长与平台化》6.3 防刷、风控与兜底

风控服务超时两秒,下单接口把所有异常都当成拒绝,用户看到“活动已结束”,库存其实还在。文章说明防刷为什么要同时看账号、设备、IP 和支付工具,风控结果为什么必须区分通过、拒绝和降级,并给出滑动窗口 Check 的 Go 实现、边界测试与真实运行输出。

风控超时两秒,活动看起来结束了

「刚好」的第三场活动做了更严格的防刷:下单前调用风控服务,检查用户、设备、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 邀请关系与归因 。

常见错误与边界

  • 只按账号限购。注册成本低、接码平台成熟,换账号就能继续领。
  • 把设备和支付工具一刀切。家庭共享设备、共用银行卡是正常场景,硬拒绝会误伤真实用户。
  • 风控结果只有通过与拒绝。系统错误被折叠成业务拒绝,一次超时变成全量拒绝。
  • 降级直接放行且不打点。故障窗口里的攻击无法追溯,也无法判断损失。
  • 降级直接拒绝且不解释。用户看到“活动结束”,不会重试,客服也无法解释。
  • 窗口边界口径不一致。起点算不算、同一秒算不算,函数、规则配置和文案必须统一。
  • 空维度被当成同一个值。所有未登录用户共享空用户键,会互相挤占限额。
  • 负数规则被当成不限制。脏配置会永久绕过风控,必须显式报错。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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