关键词:反刷、设备指纹、限流、实时风控、图谱识别、规则引擎、模型推理、熔断降级、Outbox、可观测性、多活容灾
很多团队第一次做反欺诈,都是从“加个验证码”或者“封几个 IP”开始。但在真实生产环境里,黑产对抗早已不是单点对单点的博弈,而是一场围绕成本、时延、误杀率、可绕过性和运营响应速度的系统对抗。
真正有效的反欺诈体系,不是某个算法模型,也不是某个 WAF 规则,而是一套横跨接入面、信号面、决策面、执行面和治理面的纵深防御系统。它既要在毫秒级请求链路上完成快速判定,也要在分钟级、小时级的近线和离线链路上持续修正策略;既要能在大促、秒杀、补贴活动这种高并发场景下扛住攻击洪峰,也要在误杀正常用户时具备可回滚、可解释、可申诉的工程能力。
本文不讨论空泛概念,而是从一个典型互联网业务的真实攻击面出发,给出一套可落地、可扩展、可演进的生产级反欺诈架构。
一、为什么大多数反欺诈系统一上线就失效
1.1 黑产对抗已经进入“工业化流水线”阶段
很多文章还停留在“恶意 IP 高频访问”“脚本重复提交”的阶段,但现实中的黑产早已具备成熟分工:
| 环节 |
黑产能力 |
对业务的影响 |
| 流量获取 |
住宅代理、机房代理、僵尸网络、模拟器集群 |
单纯按 IP 封禁几乎无效 |
| 账号获取 |
接码平台、撞库、批量注册、养号池 |
攻击身份越来越像“正常用户” |
| 设备环境 |
真机农场、改机框架、Xposed、Frida、自动化控制 |
设备维度识别难度大幅提高 |
| 行为模拟 |
轨迹录制回放、随机化交互、异步人机协同 |
传统验证码和前端校验被绕过 |
| 业务套利 |
新人补贴、红包裂变、秒杀抢券、内容搬运、接口撞库 |
损失不只是机器资源,而是直接业务亏损 |
这意味着反欺诈的核心难点已经从“拦截非法请求”,升级为“在尽量不打扰正常用户的前提下,快速识别伪装成正常用户的异常行为”。
1.2 单点能力为什么必然失效
团队常见的几个误区如下:
- 以为上了 WAF 就能解决业务欺诈。
- 以为有设备指纹就能稳定识别黑产。
- 以为风控模型分数高就能直接拦截。
- 以为限流阈值调大调小就能扛住活动流量。
这些手段都重要,但都只能解决局部问题:
- WAF 更擅长拦截通用 Web 攻击和异常流量形态,不擅长理解补贴套利、撞库登录、薅券下单这种业务语义。
- 设备指纹更适合做稳定标识和风险聚类,但面对真机农场、硬件改写和隐私对抗时会出现漂移。
- 模型推理更擅长从复杂特征中识别异常模式,但在线路径上受时延、特征完整度、样本漂移和可解释性约束。
- 限流更适合削峰和稳定系统,但不能判断“这个请求该不该放”。
因此,生产级反欺诈系统必须把“识别”和“承载”两件事同时做好:
- 识别层解决“谁可疑、为什么可疑、风险有多高”。
- 承载层解决“在攻击洪峰下系统能否稳定、降级是否有序、策略发布是否可控”。
1.3 一个真实业务视角下的反欺诈目标
以“新用户首单补贴”场景为例,请求链路表面上只是一次注册、登录、领券、下单、支付,但风控视角看到的是另一套状态机:
流量进入
-> 来源环境是否可信
-> 账号是否真实
-> 设备是否复用
-> 行为轨迹是否异常
-> 是否命中历史黑名单
-> 是否与已知团伙存在关联
-> 当前活动是否处于攻击高峰
-> 最终决策:放行 / 挑战 / 限制 / 审核 / 拒绝
所以反欺诈系统的目标从来不是“把坏人全拦住”,而是:
- 把明显恶意流量尽可能前置拦截,减少核心业务消耗。
- 把高风险行为在关键节点挑战或限制,控制业务损失。
- 把复杂团伙和慢性欺诈沉淀到近线、离线链路中持续识别。
- 把误判成本控制在业务可接受范围内。
- 在大流量与强对抗下保持系统本身不被拖垮。
二、生产级反欺诈体系的总体架构
2.1 五层架构:接入面、信号面、决策面、执行面、治理面
比“六层防线”更适合工程落地的表达方式,是按系统职责切分为五个平面:
┌──────────────────────────┐
│ 治理面 │
│ 策略发布 审计 观测 复盘 │
│ 回滚 灰度 申诉 容量管理 │
└────────────┬─────────────┘
│
┌───────────────────────────────────────▼───────────────────────────────────────┐
│ 决策面 │
│ 规则引擎 + 实时特征 + 模型推理 + 图谱关联 + 决策编排 + 风险解释 │
└───────────────┬───────────────────────────────────────────────┬───────────────┘
│ │
┌───────────────▼──────────────┐ ┌──────────────▼──────────────┐
│ 信号面 │ │ 执行面 │
│ 设备指纹 行为轨迹 IP信誉画像 │ │ 放行 挑战 限频 限额 审核 拒绝 │
│ 账号画像 实时特征 图谱关系 │ │ 黑名单 白名单 Case流 转人工 │
└───────────────┬──────────────┘ └──────────────┬──────────────┘
│ │
└───────────────────────┬───────────────────────┘
│
┌────────────▼────────────┐
│ 接入面 │
│ CDN WAF Gateway 限流 │
│ 签名校验 熔断 降级 │
└─────────────────────────┘
这个划分的价值在于:
- 接入面负责低成本拦截和保护核心资源。
- 信号面负责采集、清洗和组织风险证据。
- 决策面负责把证据转成可执行决策。
- 执行面负责把决策落实到具体业务动作。
- 治理面负责策略生命周期、系统稳定性和效果闭环。
2.2 关键设计原则
一套能长期活下来的反欺诈架构,通常都遵守以下原则:
原则一:快慢分层
不是所有判断都必须在主链路完成。
- 毫秒级在线链路负责高置信、低开销的快速拦截和挑战。
- 秒级近线链路负责实时聚合特征、分群和关联分析。
- 分钟级或小时级离线链路负责团伙挖掘、策略回溯和模型迭代。
原则二:证据先于结论
风险分数不是结论,风险证据才是系统的资产。
生产中一定要能回答:
- 为什么这个用户被拦截。
- 命中了哪些规则。
- 模型分数来自哪些特征。
- 是否存在设备共用、账号复用、IP 聚集、行为异常。
- 是否可以复放当时的决策上下文。
原则三:策略与执行解耦
不要把风控规则硬编码在业务服务里。
正确做法是:
- 业务服务只上报上下文并接收风险决策。
- 风控平台独立维护规则、阈值、模型、名单和实验配置。
- 决策结果通过明确的契约返回给业务。
原则四:对抗系统自身也要可防御
黑产不只攻击业务,也会攻击风控系统本身:
- 放大高成本特征查询,把在线特征服务打爆。
- 构造特殊参数,让模型推理超时。
- 大量命中验证码或挑战逻辑,拖垮三方依赖。
- 通过误报噪音污染样本和规则效果。
所以风控系统自己也必须有超时、隔离、熔断、缓存、降级和回滚能力。
2.3 一条典型在线决策链路
以“登录 + 领券 + 下单”三段式链路为例,在线决策建议分成三次检查:
| 业务节点 |
决策目标 |
决策时延预算 |
| 登录前 |
识别撞库、代理、设备异常、短信轰炸 |
10ms 到 20ms |
| 领券前 |
识别批量注册、设备复用、活动套利 |
20ms 到 50ms |
| 下单前 |
识别团伙套利、地址复用、支付风险 |
30ms 到 80ms |
不要把所有风控动作都堆到“下单前”。越晚决策,业务资源浪费越多,用户体验波动也越大。
三、业务案例:补贴套利场景下的纵深防御设计
为了让抽象架构更具可落地性,下面用一个常见场景贯穿全文:
3.1 场景定义
业务推出“新用户首单立减 30 元”活动,黑产通过以下流程套利:
- 用接码平台批量注册账号。
- 通过模拟器或真机群控制造“新设备”。
- 利用代理池不断切换出口 IP。
- 领取优惠券后快速下单。
- 使用相似收货地址、相同支付工具或相同设备群完成套利。
3.2 防控目标
系统要回答四个问题:
- 这是不是一个真实用户。
- 这是不是一个真实的新用户。
- 这次领券和下单是否具有团伙套利特征。
- 在高并发活动时,如何既控制损失又不影响大量正常用户。
3.3 风控策略分层
| 节点 |
快速判断 |
深度判断 |
动作 |
| 注册 |
IP、设备、手机号、行为轨迹 |
接码识别、设备聚类 |
验证码、注册限频、拒绝 |
| 登录 |
账号画像、IP 信誉、密码错误模式 |
撞库模式识别 |
限频、短信二验、冻结 |
| 领券 |
新人资格、设备复用、账号关联 |
团伙关系、历史套利路径 |
挑战、降额、拦截 |
| 下单 |
地址/支付/设备关联、短时突增 |
图谱团伙、模型分数 |
审核、拒单、补贴回收 |
这个表背后的工程含义是:同一套风控平台,不同业务节点的阈值、特征和动作完全不同。反欺诈不是一个接口,而是一套嵌入业务关键状态转换点的控制系统。
四、接入面:把明显恶意请求挡在核心资源之外
4.1 接入面解决的不是“正确判断”,而是“低成本过滤”
接入面最重要的目标不是百分之百识别恶意,而是以最小成本完成三件事:
- 丢掉明显异常的垃圾流量。
- 限制突发攻击对下游资源的冲击。
- 尽量在业务服务之前完成拦截。
因此接入面更关注:
- 每个请求的处理成本是否足够低。
- 热路径是否无阻塞。
- 规则是否足够简单稳定。
- 是否能在大促时快速调阈值和灰度。
4.2 接入层能力矩阵
| 能力 |
目标 |
推荐位置 |
| CDN 基础防护 |
大流量清洗、地域隔离、静态资源防刷 |
CDN / 边缘节点 |
| WAF |
通用攻击识别、恶意 UA、路径异常 |
边缘网关 |
| API 网关签名验证 |
防重放、防篡改、客户端校验 |
Gateway |
| 限流 |
防暴力请求、削峰、热点隔离 |
Gateway / Sidecar |
| 熔断降级 |
保护风控依赖、自身过载保护 |
Gateway / App |
4.3 生产级限流不只是“一个令牌桶”
活动场景下,很多系统的问题不是没限流,而是限流策略太单一。实际工程里建议至少同时具备四类配额:
- 全局配额:保护整体系统。
- 接口配额:保护高价值接口,如登录、发码、领券、下单。
- 主体配额:按账号、设备、手机号、IP、会话分别限频。
- 组合配额:按
设备 + 账号、IP + URI、手机号 + 场景 组合限频。
如果只按 IP 限流,住宅代理池会轻易绕过。
如果只按账号限流,批量注册账号会轻易绕过。
如果只按设备限流,设备指纹漂移或伪造后也容易绕过。
4.4 OpenResty + Redis 的生产级组合限流示例
下面这个示例演示的是基于 OpenResty 和 Redis 的“多维主体 + 配额分层 + 失败降级”的网关实现思路:
-- /etc/nginx/lua/fraud_guard_rate_limit.lua
local redis = require "resty.redis"
local cjson = require "cjson.safe"
local function fail_open(reason)
ngx.log(ngx.WARN, "rate limit degraded, reason=", reason)
ngx.header["X-Fraud-Guard-Degraded"] = "1"
return
end
local red = redis:new()
red:set_timeouts(30, 30, 30)
local ok, err = red:connect(os.getenv("REDIS_HOST", "127.0.0.1"), 6379)
if not ok then
fail_open(err)
return
end
local route = ngx.var.uri
local ip = ngx.var.remote_addr or "unknown"
local account = ngx.var.http_x_user_id or "anonymous"
local device = ngx.var.http_x_device_id or "unknown-device"
local policies = {
["/api/auth/login"] = {
{"ip", 20, 60},
{"account", 10, 60},
{"device", 15, 60},
{"account_device", 8, 60}
},
["/api/coupon/claim"] = {
{"account", 5, 300},
{"device", 5, 300},
{"ip", 30, 60},
{"account_device", 3, 300}
}
}
local route_policies = policies[route]
if not route_policies then
return
end
local values = {
ip = ip,
account = account,
device = device,
account_device = account .. ":" .. device
}
local now = ngx.time()
for _, policy in ipairs(route_policies) do
local dim = policy[1]
local threshold = policy[2]
local window = policy[3]
local slot = math.floor(now / window)
local key = "fraud:rl:" .. route .. ":" .. dim .. ":" .. values[dim] .. ":" .. slot
local count, incr_err = red:incr(key)
if not count then
fail_open(incr_err)
return
end
if count == 1 then
red:expire(key, window + 2)
end
if count > threshold then
ngx.status = 429
ngx.header["Content-Type"] = "application/json"
ngx.say(cjson.encode({
code = "RATE_LIMITED",
message = "request blocked by fraud gateway",
dimension = dim,
windowSeconds = window
}))
return ngx.exit(429)
end
end
red:set_keepalive(60000, 100)
这个示例有几个生产要点:
- Redis 异常时优先
fail-open,避免风控组件把主站直接带崩。
- 路由级策略独立配置,便于灰度和快速调参。
- 限流主体不只看 IP,而是多个身份维度组合。
- 对关键接口建议返回结构化错误码,方便上层识别是否进入挑战流程。
4.5 接入面的常见误区
误区一:把重计算放在网关层
网关层最忌讳做复杂特征查询、数据库访问或高成本模型调用。
网关层只适合轻计算和轻缓存,复杂判断应交给后续决策层。
误区二:所有拒绝都返回同一错误
从安全角度看,不需要把具体策略暴露给攻击者;但从业务和排障角度看,内部必须区分:
- 网关限流
- 签名失败
- 风险挑战
- 业务拒绝
- 下游超时降级
对外可以统一文案,对内不能失去原因。
误区三:高峰期临时人工改配置没有审计
活动高峰经常发生“阈值谁改的、什么时候改的、为什么改的”完全不可追踪。
反欺诈配置必须和业务配置一样进入治理体系,具备发布单、审批、审计和回滚。
五、信号面:把“可疑”转成可计算的风险证据
5.1 信号面是整个反欺诈系统的地基
决策质量上限,取决于信号质量上限。
如果输入信号质量差,规则再多、模型再复杂,也只是放大噪音。
信号面通常包含五大类证据:
- 网络与环境信号:IP、ASN、地域、代理类型、TLS 指纹、UA。
- 设备与终端信号:设备标识、系统版本、硬件参数、Root/Jailbreak、模拟器特征。
- 账号与主体信号:注册时长、登录频次、历史订单、支付工具、地址簇。
- 行为与过程信号:点击轨迹、停留时长、页面切换、输入节奏、操作顺序。
- 关联与团伙信号:设备共用、地址复用、支付聚集、图谱邻居风险。
5.2 设备指纹的正确定位
设备指纹不是“唯一设备 ID 生成器”,而是“设备相似性与稳定性识别器”。
生产中需要接受三个现实:
- 设备指纹可能漂移。
- 设备指纹可能被伪造。
- 设备指纹在隐私约束下不能无限采集。
所以更可靠的做法不是依赖单一指纹值,而是构建多层设备画像:
- 强标识:业务自有 Device ID、安装实例 ID、受信硬件标识。
- 弱标识:屏幕参数、时区、字体、图形能力、系统属性组合。
- 环境证据:代理、自动化框架痕迹、模拟器信号、Hook 痕迹。
- 行为证据:触控轨迹、停留节奏、交互顺序、异常操作路径。
5.3 服务端指纹聚合设计
前端采集只是入口,最终可靠性取决于服务端如何聚合、清洗和版本化。
推荐的数据模型:
public record DeviceSignalCommand(
String deviceId,
String installId,
String accountId,
String ip,
String userAgent,
String osType,
String osVersion,
String appVersion,
boolean rooted,
boolean emulator,
boolean hooked,
Map<String, String> attributes,
Instant eventTime
) {}
对应的服务端处理重点不是“直接打分”,而是先做四件事:
- 规范化:去除脏值、统一字段格式、补全默认值。
- 版本化:不同 SDK 版本、不同采集粒度必须能并存。
- 去重:短时间内重复上报不能无限放大权重。
- 聚合:沉淀成可供在线查询的主体画像。
5.4 生产级设备画像聚合代码示例
下面的示例演示的是一个在线设备画像聚合服务。重点不在“算分有多聪明”,而在于输入校验、幂等、超时控制和多存储写入的一致性边界。
package com.example.risk.device;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
@Service
public class DeviceProfileService {
private static final Duration DUPLICATE_WINDOW = Duration.ofMinutes(5);
private final DeviceEventRepository deviceEventRepository;
private final DeviceProfileRepository deviceProfileRepository;
private final IdempotencyService idempotencyService;
private final RiskFeatureCache riskFeatureCache;
private final OutboxService outboxService;
private final Clock clock;
public DeviceProfileService(DeviceEventRepository deviceEventRepository,
DeviceProfileRepository deviceProfileRepository,
IdempotencyService idempotencyService,
RiskFeatureCache riskFeatureCache,
OutboxService outboxService,
Clock clock) {
this.deviceEventRepository = deviceEventRepository;
this.deviceProfileRepository = deviceProfileRepository;
this.idempotencyService = idempotencyService;
this.riskFeatureCache = riskFeatureCache;
this.outboxService = outboxService;
this.clock = clock;
}
@Transactional
public void ingest(@Valid DeviceSignalCommand command, @NotBlank String requestId) {
if (!idempotencyService.tryAcquire("device-signal:" + requestId, DUPLICATE_WINDOW)) {
return;
}
Instant now = Instant.now(clock);
NormalizedDeviceSignal normalized = normalize(command, now);
deviceEventRepository.save(DeviceEvent.from(normalized));
DeviceProfile profile = deviceProfileRepository.findByDeviceId(normalized.deviceId())
.orElseGet(() -> DeviceProfile.create(normalized.deviceId(), now));
profile.bindAccount(normalized.accountId(), now);
profile.bindIp(normalized.ip(), now);
profile.updateEnvironment(normalized.rooted(), normalized.emulator(), normalized.hooked(), now);
profile.mergeAttributes(normalized.attributes(), now);
profile.refreshLastSeen(now);
deviceProfileRepository.save(profile);
riskFeatureCache.put(profile.toSnapshot());
outboxService.append("risk.device.profile.updated", Map.of(
"deviceId", profile.getDeviceId(),
"riskTags", profile.getRiskTags(),
"lastSeenAt", profile.getLastSeenAt().toString()
));
}
private NormalizedDeviceSignal normalize(DeviceSignalCommand command, Instant now) {
Map<String, String> attributes = new HashMap<>();
if (command.attributes() != null) {
command.attributes().forEach((k, v) -> {
if (k != null && v != null && !k.isBlank() && !v.isBlank()) {
attributes.put(k.trim().toLowerCase(), v.trim());
}
});
}
return new NormalizedDeviceSignal(
require(command.deviceId(), "deviceId"),
defaultValue(command.installId(), "unknown-install"),
defaultValue(command.accountId(), "anonymous"),
defaultValue(command.ip(), "0.0.0.0"),
defaultValue(command.userAgent(), ""),
defaultValue(command.osType(), "unknown"),
defaultValue(command.osVersion(), "unknown"),
defaultValue(command.appVersion(), "unknown"),
command.rooted(),
command.emulator(),
command.hooked(),
attributes,
Objects.requireNonNullElse(command.eventTime(), now)
);
}
private String require(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
}
return value.trim();
}
private String defaultValue(String value, String defaultValue) {
return value == null || value.isBlank() ? defaultValue : value.trim();
}
}
这个实现有几个重要边界:
- 请求幂等窗口避免 SDK 重试造成数据放大。
- 明细事件和聚合画像分离,既能追溯也便于在线查询。
- 通过 Outbox 把画像变更异步广播到下游特征系统,避免分布式事务。
- 在线缓存写入只作为加速层,不承载最终一致性责任。
5.5 行为信号比静态信号更难伪装
很多攻击者可以伪装设备、切换 IP、批量注册账号,但更难持续稳定地伪装行为过程。
因此在关键链路上,建议采集并利用:
- 页面进入到点击关键按钮的耗时。
- 输入手机号、验证码、地址的节奏。
- 鼠标移动或触控轨迹的突变特征。
- 页面跳转顺序是否符合常规路径。
- 是否存在“注册后立即领券、领券后立即下单”的异常短链路。
这些行为信号不一定都要在线强校验,但一定值得进入近线特征和模型训练集。
六、决策面:规则、特征、模型与图谱如何协同工作
6.1 决策面不是单引擎,而是多引擎编排
生产中的风控决策很少是“跑一个模型取分数”这么简单。
更合理的结构通常是:
请求上下文
-> 主体画像装载
-> 实时特征查询
-> 黑白名单判断
-> 规则引擎快速命中
-> 模型推理补充判断
-> 图谱/团伙关联增强
-> 决策编排器输出动作
其中:
- 规则引擎负责高可解释、强约束的策略。
- 模型负责识别复杂模式和非线性关系。
- 图谱负责团伙、共用、跨主体关联。
- 编排器负责融合不同证据并输出业务动作。
6.2 在线决策时延预算设计
时延预算决定了你能在主链路上做多少事。
一个经验性的预算如下:
| 组件 |
建议 P99 预算 |
| 主体画像缓存查询 |
2ms 到 5ms |
| 实时特征聚合查询 |
5ms 到 15ms |
| 规则引擎执行 |
1ms 到 3ms |
| 模型推理 |
5ms 到 20ms |
| 图谱近线增强 |
5ms 到 15ms |
| 决策编排与返回 |
1ms 到 3ms |
如果你在线链路给自己的预算是 30ms,却让每个依赖都按 30ms 超时,那系统一定会在高峰期雪崩。
6.3 决策契约必须标准化
风控平台和业务系统之间,一定要通过明确契约交互,而不是返回一个模糊的“riskScore=87”。
推荐返回结构:
public record RiskDecision(
String requestId,
String sceneCode,
DecisionAction action,
int score,
String strategyVersion,
String modelVersion,
java.util.List<String> hitRules,
java.util.List<String> riskTags,
String explanation,
long ttlSeconds
) {}
其中 action 至少应覆盖:
PASS
CHALLENGE
REVIEW
REJECT
LIMIT
DOWNGRADE
这样业务系统才能根据动作做差异化处理,而不是自己猜测分数阈值。
6.4 规则引擎:负责“明确业务约束”
以下几类判断非常适合放在规则层:
- 同一设备 10 分钟内注册多个账号。
- 同一手机号在多个账号间快速切换。
- 同一收货地址短时关联大量“新用户首单”。
- 接码特征手机号在活动开始后集中注册。
- 设备、账号、地址、支付工具中任意两项同时复用。
规则层的优点是解释性强、发布快、能快速兜底。
缺点是对复杂行为模式不够敏感,规则数量多了也容易冲突。
6.5 模型推理:负责“从复杂特征中识别异常模式”
模型更适合处理:
- 特征组合复杂,难以穷举规则。
- 风险边界不是绝对二元,而是概率型判断。
- 行为模式随活动和攻击方式快速变化。
但模型在线化时要注意三件事:
- 训练和在线特征定义必须严格一致。
- 模型分数不能脱离业务动作独立存在。
- 模型超时或不可用时必须有回退策略。
6.6 生产级决策编排代码示例
下面的代码重点演示:并行装载上下文、严格超时、降级回退、证据归因,而不是追求复杂算法本身。
package com.example.risk.engine;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
@Service
public class RiskDecisionService {
private final ProfileService profileService;
private final FeatureService featureService;
private final RuleEngine ruleEngine;
private final ModelGateway modelGateway;
private final GraphRiskService graphRiskService;
private final Executor riskExecutor;
public RiskDecisionService(ProfileService profileService,
FeatureService featureService,
RuleEngine ruleEngine,
ModelGateway modelGateway,
GraphRiskService graphRiskService,
Executor riskExecutor) {
this.profileService = profileService;
this.featureService = featureService;
this.ruleEngine = ruleEngine;
this.modelGateway = modelGateway;
this.graphRiskService = graphRiskService;
this.riskExecutor = riskExecutor;
}
public RiskDecision evaluate(RiskRequest request) {
CompletableFuture<ProfileSnapshot> profileFuture = CompletableFuture
.supplyAsync(() -> profileService.load(request.subjectKey()), riskExecutor)
.completeOnTimeout(ProfileSnapshot.empty(), 5, TimeUnit.MILLISECONDS);
CompletableFuture<FeatureBundle> featureFuture = CompletableFuture
.supplyAsync(() -> featureService.load(request), riskExecutor)
.completeOnTimeout(FeatureBundle.empty(), 12, TimeUnit.MILLISECONDS);
CompletableFuture<GraphSignal> graphFuture = CompletableFuture
.supplyAsync(() -> graphRiskService.query(request), riskExecutor)
.completeOnTimeout(GraphSignal.unknown(), 10, TimeUnit.MILLISECONDS);
ProfileSnapshot profile = profileFuture.join();
FeatureBundle features = featureFuture.join();
GraphSignal graphSignal = graphFuture.join();
RuleResult ruleResult = ruleEngine.execute(request, profile, features, graphSignal);
ModelResult modelResult;
if (ruleResult.shouldShortCircuitReject()) {
modelResult = ModelResult.skipped("short-circuit-by-rule");
} else {
modelResult = modelGateway.predict(request, profile, features, graphSignal)
.orTimeout(20, TimeUnit.MILLISECONDS)
.exceptionally(ex -> ModelResult.fallback("model-timeout"))
.join();
}
return merge(request, ruleResult, modelResult, graphSignal);
}
private RiskDecision merge(RiskRequest request,
RuleResult ruleResult,
ModelResult modelResult,
GraphSignal graphSignal) {
List<String> tags = new ArrayList<>(ruleResult.hitRules());
tags.addAll(modelResult.riskTags());
tags.addAll(graphSignal.tags());
int score = Math.min(100,
ruleResult.baseScore() + modelResult.scoreContribution() + graphSignal.scoreContribution());
DecisionAction action;
if (ruleResult.shouldShortCircuitReject()) {
action = DecisionAction.REJECT;
} else if (score >= 85) {
action = DecisionAction.REJECT;
} else if (score >= 70) {
action = DecisionAction.REVIEW;
} else if (score >= 55) {
action = DecisionAction.CHALLENGE;
} else {
action = DecisionAction.PASS;
}
return new RiskDecision(
request.requestId(),
request.sceneCode(),
action,
score,
ruleResult.strategyVersion(),
modelResult.modelVersion(),
ruleResult.hitRules(),
tags,
buildExplanation(ruleResult, modelResult, graphSignal),
Duration.ofMinutes(5).toSeconds()
);
}
private String buildExplanation(RuleResult ruleResult, ModelResult modelResult, GraphSignal graphSignal) {
return "rules=" + ruleResult.hitRules()
+ ", model=" + modelResult.reason()
+ ", graph=" + graphSignal.summary();
}
}
这段代码的工程价值在于:
- 明确了依赖并行装载,避免串行查询拉高时延。
- 每个依赖都有独立超时和兜底对象,不把单点异常放大成链路雪崩。
- 对“高置信规则命中”支持短路拒绝,节省模型成本。
- 最终决策带有策略版本、模型版本和解释字段,便于复盘。
6.7 决策面最容易踩的坑
坑一:规则、模型、图谱互相打架
没有统一编排层时,常见情况是:
- 规则引擎建议挑战。
- 模型建议放行。
- 图谱建议审核。
最后业务方无法理解“到底听谁的”。
所以一定要有统一决策优先级和合并逻辑。
坑二:在线特征依赖太多
一个请求如果要同步查 Redis、ES、HBase、图数据库、模型服务、画像服务,系统在高峰期必然脆弱。
在线路径只保留高价值、低时延特征,其他特征尽量预聚合。
坑三:没有决策快照
出问题时只剩一个“score=82”,无法知道当时命中了哪些规则、用的是什么版本、看到的画像是什么。
没有快照,就没有可证明的风控体系。
七、实时特征:高并发下如何稳定产出“最新风险上下文”
7.1 实时特征是在线判断的燃料
很多欺诈行为的价值就在“短时间内的集中爆发”。
比如:
- 同一设备 3 分钟注册 5 个账号。
- 同一地址 10 分钟内出现 8 个首单。
- 同一支付工具 30 分钟内绑定多个新人账号。
- 同一 IP 段在活动开始后 1 分钟内发起大量领券请求。
这些都属于强时间敏感特征,不能只靠离线数仓第二天再算。
7.2 实时特征链路的推荐设计
业务事件
-> Kafka
-> Flink 实时清洗与聚合
-> 在线特征存储 Redis / HBase / ClickHouse
-> 决策服务按主体拉取
关键点不在于用了什么中间件,而在于:
- 事件模型是否统一。
- 时间语义是否明确。
- 去重和乱序是否处理。
- 热 key 和热点主体是否被妥善治理。
7.3 风控事件模型
不要把每个业务接口的日志各写各的。
建议统一成标准风险事件模型:
{
"eventId": "evt_202607020001",
"sceneCode": "coupon_claim",
"subjectType": "account",
"subjectId": "u_10001",
"deviceId": "d_8899",
"ip": "203.0.113.10",
"orderId": "o_50001",
"addressHash": "addr_xxx",
"paymentToken": "pay_xxx",
"eventTime": "2026-07-02T11:30:01Z",
"attributes": {
"couponId": "new_user_30",
"result": "SUCCESS"
}
}
事件模型统一之后,才能在实时链路里复用清洗、窗口、聚合和下游订阅能力。
7.4 Flink 实时特征计算示例
下面这个示例演示“同一设备 10 分钟内成功领券账号数”的基于 Flink 的近实时聚合思路:
DataStream<RiskEvent> source = env
.fromSource(kafkaSource, WatermarkStrategy
.<RiskEvent>forBoundedOutOfOrderness(Duration.ofSeconds(5))
.withTimestampAssigner((event, timestamp) -> event.eventTimeMillis()), "risk-events");
source.filter(event -> "coupon_claim".equals(event.sceneCode()))
.filter(event -> "SUCCESS".equals(event.attributes().get("result")))
.keyBy(RiskEvent::deviceId)
.window(SlidingEventTimeWindows.of(Time.minutes(10), Time.minutes(1)))
.aggregate(new DistinctAccountCountAgg(), new DeviceCouponWindowFunction())
.addSink(new RedisFeatureSink("risk:feature:device:coupon-account-count"));
工程上要注意的不是这几行 API,而是以下细节:
- 事件时间而不是处理时间,否则乱序和重放会污染特征。
- 去重逻辑必须基于业务唯一事件 ID。
- 滑窗步长不能太细,否则状态量会膨胀。
- 写在线特征存储时要控制 TTL,避免历史噪音长期残留。
7.5 热点主体与热 Key 治理
高并发反欺诈链路很容易出现热点主体:
- 热门活动券 ID
- 热门商品 ID
- 同一爆炸性 IP 段
- 同一高频设备集群
如果在线特征全部堆在单个 Redis Key 上,活动开始时会直接形成热点争用。
解决思路包括:
- Key 哈希打散,再在读取时做逻辑聚合。
- 按时间片拆 Key,缩短单 Key 生命周期。
- 对极热点主体做本地缓存和单飞控制。
- 在 Flink 侧提前聚合,减少在线读写频率。
7.6 特征系统的稳定性边界
特征系统不是数据库报表系统,不能追求“查什么都能临时算”。
在线特征必须满足:
- 查询模式可预测。
- 数据结构固定。
- 读取延迟稳定。
- 缺失时有默认值。
一旦在线特征服务变成临时查询引擎,风控主链路的稳定性基本就不可控了。
八、图谱与团伙识别:为什么高价值欺诈最终都要做关联分析
8.1 规则能看到点,图谱能看到网
如果系统只看单个账号、单个设备、单个订单,很多团伙行为并不显眼。
但一旦把账号、设备、地址、支付工具、手机号、IP、收货人这些实体连接成图,很多“看似正常”的节点会显露出异常结构:
- 多账号共用少量设备。
- 多设备共用少量地址。
- 多账号共用少量支付工具。
- 多个看似独立的主体通过中间节点形成高密度社群。
这就是图谱在反欺诈中的核心价值。
8.2 图模型设计
常见实体和关系可以这样建模:
| 顶点 |
说明 |
| Account |
账号 |
| Device |
设备 |
| Phone |
手机号 |
| Address |
收货地址 |
| Payment |
支付工具 |
| IP |
网络出口 |
| Order |
订单 |
| 边 |
说明 |
| ACCOUNT_USE_DEVICE |
账号使用设备 |
| ACCOUNT_BIND_PHONE |
账号绑定手机号 |
| ORDER_USE_PAYMENT |
订单使用支付工具 |
| ORDER_TO_ADDRESS |
订单配送到地址 |
| ACCOUNT_LOGIN_FROM_IP |
账号从 IP 登录 |
图谱不是为了把所有查询都搬到图数据库,而是为了补齐传统 KV 和关系模型看不到的“关联性证据”。
8.3 近线图查询场景
以下几类判断很适合近线图查询:
- 设备 2 跳邻居中是否存在大量已处罚账号。
- 当前账号与历史黑样本社群的重叠度。
- 收货地址是否连接多个“新人首单”账号。
- 支付工具是否与高风险设备群共现。
8.4 图谱查询示例
下面给出一个适合近线风控增强的思路示例:
MATCH (a:Account)-[:ACCOUNT_USE_DEVICE]->(d:Device)<-[:ACCOUNT_USE_DEVICE]-(peer:Account)
WHERE id(a) == "u_10001"
RETURN peer.account_id, peer.risk_level, peer.punish_count
LIMIT 50;
这类查询不一定适合每次在线请求都实时执行,但非常适合:
- 对高风险请求做二次增强。
- 对活动期间重点主体做近线轮询。
- 对疑似团伙做夜间批量扫描。
8.5 图谱能力接入在线决策的正确方式
很多团队一上来就想让在线主链路实时查图数据库,最后要么时延爆炸,要么查询不可控。
更稳妥的方式是:
- 离线或近线把团伙风险结果沉淀成标签。
- 在线决策只查询已经预计算好的团伙标签或近线缓存。
- 对高风险请求再触发更深一层的图查询或人工审核。
图谱的强项是挖关联,不是替代所有在线存储。
九、执行面:风控动作如何真正落到业务上
9.1 决策不落地,就只是分析系统
很多风控平台做了很多分数和标签,业务侧却只接了一个“拒绝”开关。这种体系效果通常很差,因为风控动作本身应该是分层的。
推荐的动作集合:
| 动作 |
适用场景 |
说明 |
| PASS |
正常用户 |
直接放行 |
| CHALLENGE |
中风险 |
验证码、短信二验、人脸、行为校验 |
| LIMIT |
中高风险 |
限频、限额、限设备、限活动资格 |
| REVIEW |
高风险但需保守处理 |
进入人工审核或异步复核 |
| REJECT |
高置信恶意 |
直接拒绝 |
| DOWNGRADE |
系统异常或高峰保护 |
暂停高成本能力,保留基础防控 |
9.2 风控动作与业务状态机对齐
反欺诈不是独立宇宙,必须和业务状态机配合。
以补贴订单为例:
待注册 -> 已注册 -> 已登录 -> 已领券 -> 待下单 -> 待支付 -> 已支付 -> 已履约
风控动作的关键不是“在某一步拒绝”,而是“如何影响状态转换”:
- 登录挑战失败,不允许进入已登录态。
- 领券命中限制,不发放补贴资格。
- 下单命中审核,订单进入
RISK_REVIEW 中间态。
- 支付后近线识别为团伙,可进入补贴冻结或售后审查流程。
如果风控不理解业务状态机,就会出现:
- 已发券后再拒单,用户体验差且业务损失已发生。
- 已支付后才发现异常,但退款、履约、库存、客服已连锁触发。
9.3 订单风控落地的生产级代码示例
下面示例重点演示“业务状态机 + 风控动作 + 事务边界 + Outbox 事件”如何配合:
package com.example.order.app;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class SubsidyOrderApplicationService {
private final RiskFacade riskFacade;
private final OrderRepository orderRepository;
private final CouponService couponService;
private final OutboxService outboxService;
public SubsidyOrderApplicationService(RiskFacade riskFacade,
OrderRepository orderRepository,
CouponService couponService,
OutboxService outboxService) {
this.riskFacade = riskFacade;
this.orderRepository = orderRepository;
this.couponService = couponService;
this.outboxService = outboxService;
}
@Transactional
public SubmitOrderResult submit(SubmitOrderCommand command) {
RiskDecision decision = riskFacade.evaluateOrder(command);
if (decision.action() == DecisionAction.REJECT) {
return SubmitOrderResult.rejected(decision);
}
if (decision.action() == DecisionAction.CHALLENGE) {
return SubmitOrderResult.challenge(decision);
}
Order order = Order.create(command.accountId(), command.deviceId(), command.amount());
if (decision.action() == DecisionAction.REVIEW) {
order.markRiskReview(decision.score(), decision.explanation());
} else {
couponService.lockCoupon(command.couponId(), command.accountId());
order.markSubmitted();
}
orderRepository.save(order);
outboxService.append("order.risk.decision.created", java.util.Map.of(
"orderId", order.getOrderId(),
"accountId", command.accountId(),
"action", decision.action().name(),
"score", decision.score(),
"strategyVersion", decision.strategyVersion()
));
return SubmitOrderResult.accepted(order.getOrderId(), decision);
}
}
这段代码体现了几个非常关键的工程点:
- 风控决策先于核心业务状态推进。
- 审核态是显式中间态,而不是失败态伪装。
- Outbox 把风控决策事件可靠发往下游审计、客服、运营系统。
- 补贴资源锁定和订单提交只在明确可继续的状态下发生。
9.4 为什么 Outbox 在反欺诈场景里尤其重要
风控动作通常会影响多个系统:
- 订单系统
- 营销系统
- 用户系统
- 审核系统
- 客服系统
- 数据平台
如果业务事务提交了,但风控事件没发出去,后续就会出现:
- 订单被打上审核态,但审核平台没有收到任务。
- 用户被限权,但运营后台没有记录。
- 补贴被回收,但审计链路无法追溯。
因此,风控事件推荐和订单、券、账户等业务变更一起以本地事务写入 Outbox,再由异步投递器可靠发送。
十、治理面:没有治理,反欺诈系统会先被自己拖垮
10.1 治理面决定的是“系统能否长期演进”
很多团队前期做风控时,注意力都集中在识别命中率上,但真正影响长期成败的往往是治理能力:
- 策略能不能灰度发布。
- 调整阈值能不能快速回滚。
- 模型版本能不能追踪。
- 决策结果能不能复放。
- 误杀用户能不能申诉和恢复。
- 大促前有没有容量演练和故障预案。
10.2 策略中心的基本能力
策略中心建议具备以下能力:
| 能力 |
作用 |
| 版本管理 |
记录每次规则、阈值、名单和编排变更 |
| 灰度发布 |
按流量、租户、渠道、地区、用户群逐步放量 |
| 审批与审计 |
高风险策略变更必须可追踪 |
| 一键回滚 |
误杀或大面积波动时快速恢复 |
| 实验对照 |
比较新旧策略效果 |
| 配置快照 |
保留任意时刻的可复现配置 |
10.3 决策日志不是普通业务日志
决策日志建议至少包含:
- 请求 ID
- 场景编码
- 主体标识
- 输入特征摘要
- 命中规则
- 模型版本与分数
- 决策动作
- 触发耗时
- 策略版本
- 是否降级
这些日志要兼顾两种用途:
- 在线排障和可观测。
- 离线复盘和模型训练。
所以不建议只打一行自由文本日志,而应输出结构化审计事件。
10.4 风控系统自身的熔断与降级
风控平台也会故障,必须提前定义降级顺序。
推荐从“最贵”往“最便宜”逐层降:
- 关闭非关键图谱增强。
- 关闭高成本模型推理,回退规则 + 基础画像。
- 保留核心黑白名单和限流能力。
- 只对高价值接口保留挑战。
- 极端情况下进入业务保护模式,例如暂停高风险活动资格发放。
降级不是拍脑袋现场决定,而是预定义策略。
10.5 误杀治理
反欺诈系统最怕两种事故:
- 大面积漏拦,直接造成资金损失。
- 大面积误杀,导致正常用户投诉、流失和公关事故。
因此误杀治理不能只是客服兜底,而应进入系统设计:
- 被拦截原因可解释。
- 审核态可人工放行。
- 名单可快速修正。
- 策略回滚可分钟级生效。
- 用户申诉后能形成反向训练样本。
十一、高并发与可扩展设计:反欺诈系统如何扛住大促
11.1 高并发下最先出问题的不是模型,而是依赖链
活动高峰时,风控系统最容易被打爆的点通常包括:
- 画像缓存被热点 key 打穿。
- 实时特征查询扇出过多。
- 模型服务线程池耗尽。
- 验证码三方服务超时。
- 决策日志写入阻塞主链路。
- 名单和规则配置未本地缓存,中心服务抖动即全站受影响。
所以架构上要优先做“减依赖、控扇出、可降级、可本地化”。
11.2 典型并发治理手段
本地缓存 + 短 TTL
对于热点策略、白名单、基础画像,建议在网关和决策服务本地做短 TTL 缓存,减少中心依赖。
单飞控制
同一主体的高成本特征查询或图谱增强,应采用 single-flight,避免击穿下游。
隔离线程池
模型推理、图谱查询、验证码校验、审计投递应放到不同隔离池,避免相互拖垮。
背压与限流
风险引擎自身也需要限流。否则大流量攻击会让风控系统先于业务核心崩溃。
异步化
对不影响当前动作的能力,例如详细审计、离线训练样本写入、画像扩展更新,尽量异步化。
11.3 容量评估不能只看 QPS
风控系统容量评估至少要看四个维度:
- 请求 QPS
- 主体基数
- 特征查询扇出数
- 规则与模型执行成本
同样是 2 万 QPS:
- 如果每次只查本地缓存,系统可能很轻松。
- 如果每次要查 5 个 Redis、1 个图服务、1 个模型服务,系统压力完全不是一个量级。
11.4 延迟预算建议
在需要兼顾业务体验的在线链路里,可以采用如下预算:
| 环节 |
预算 |
| 网关接入防护 |
1ms 到 5ms |
| 基础画像加载 |
2ms 到 5ms |
| 实时特征 |
5ms 到 15ms |
| 规则引擎 |
1ms 到 3ms |
| 模型推理 |
5ms 到 20ms |
| 编排与动作输出 |
1ms 到 3ms |
如果总预算控制在 30ms 到 50ms,已经足以覆盖大多数登录、领券、下单场景。
11.5 多租户与多业务扩展
成熟的反欺诈平台不会只服务一个业务域。
当你把它平台化后,必须解决:
- 不同业务场景策略隔离。
- 不同租户数据隔离。
- 特征字典与事件模型统一。
- 动作集可扩展。
- 模型和规则版本按业务独立发布。
平台化的关键不是“所有业务共用一套规则”,而是“共用底座、分治策略”。
十二、生产避坑:这些问题几乎每个团队都会踩
12.1 只关注识别率,不关注误杀率
识别率高不代表系统好。如果误杀大量正常用户,业务方最终一定会绕开风控系统。
12.2 所有策略都追求在线实时
并非所有分析都要在主链路完成。把离线能力硬塞进在线路径,是很多系统不稳定的根源。
12.3 把风控系统做成“黑箱”
没有规则命中、模型原因、版本快照和证据链,业务和运营最终不会信任你的决策。
12.4 只做技术拦截,不做业务回收
即便拦截了部分攻击,如果已发放的券、已进入的订单、已触发的履约没有回收和补偿机制,损失仍然存在。
12.5 没有跨部门协同闭环
反欺诈不是纯技术系统,它至少涉及:
- 研发
- 算法
- 风控运营
- 客服
- 商业/营销
- 审计/合规
没有流程协同,技术命中后也难形成业务闭环。
十三、从 0 到 1 的落地路径:不要一开始就做“全家桶”
13.1 第一阶段:先做接入保护与关键节点挑战
适合初创或刚起步团队:
- 网关限流
- 基础 WAF
- 登录/发码/领券/下单关键接口挑战
- 黑白名单
- 结构化风控日志
这一阶段先解决明显攻击和资源保护问题。
13.2 第二阶段:补齐主体画像与实时特征
当业务进入活动运营期后,建议补齐:
- 设备画像
- 账号画像
- 实时特征聚合
- 策略中心
- 决策编排层
这一阶段开始真正形成“风险证据体系”。
13.3 第三阶段:引入模型与图谱
当攻击者开始规模化绕过规则时,再引入:
- 模型推理服务
- 图谱团伙识别
- 近线增强
- 误杀申诉闭环
这时的重点是提升复杂模式识别能力,而不是一开始就追求“AI 化”。
13.4 第四阶段:平台化与多活容灾
当系统开始承载多个业务域时,再考虑:
- 多租户平台化
- 同城双活或异地多活
- 策略灰度平台
- 统一审计与复盘中心
- 统一样本与评测平台
这时反欺诈系统才真正从“项目”演变成“基础设施”。
十四、上线前检查清单
14.1 架构检查
- 在线决策路径是否有明确时延预算。
- 高成本依赖是否都有超时、熔断和降级。
- 风控主链路是否避免分布式事务。
- 关键策略是否已支持灰度和回滚。
- 是否存在单点热点 key、单机线程池或单实例配置中心风险。
14.2 数据检查
- 风险事件模型是否统一。
- 事件是否具备唯一 ID,支持去重。
- 设备、账号、地址、支付工具等主体键是否规范化。
- 实时特征 TTL 和窗口是否经过校准。
- 决策日志是否可复现证据链。
14.3 业务检查
- 风控动作是否映射到明确业务状态。
- 误杀后是否有人工放行和用户申诉流程。
- 审核态是否有 SLA 和超时处理机制。
- 补贴回收、订单取消、权益冻结是否有补偿方案。
14.4 压测与演练检查
- 是否做过活动高峰压测。
- 是否演练过模型服务超时、图谱服务不可用、Redis 热点和配置中心抖动。
- 是否验证过降级后业务仍可继续运行。
- 是否做过策略误发后的快速回滚演练。
十五、结语:反欺诈的本质是一套持续对抗的控制系统
反欺诈从来不是“加几个规则”这么简单,也不是“上个模型”就能解决。
它本质上是一套持续对抗、持续学习、持续治理的工程控制系统。
一套真正有生命力的反欺诈体系,至少要同时做到四件事:
- 在接入面以足够低的成本挡住明显恶意流量。
- 在信号面沉淀高质量、可复用、可回溯的风险证据。
- 在决策面把规则、特征、模型、图谱组织成稳定的在线判定系统。
- 在治理面保证系统可灰度、可回滚、可解释、可复盘、可扩展。
当团队把反欺诈从“点状能力”升级为“纵深防御体系”之后,系统才真正具备和黑产长期对抗的资格。
从工程视角看,最重要的不是追求某个单点能力有多先进,而是让整条链路在高并发、强对抗、持续演进的现实环境中稳定工作。这,才是生产级反欺诈架构真正的分水岭。