风控系统 V2 — 旁路实时对账
版本: V2.0 · 更新: 2026-03-06
定位: 纯旁路观测,业务代码零修改,覆盖资产对账与钱包对账。
一、设计原则
| 原则 | 说明 |
|---|---|
| 零侵入 | 业务代码不做任何改动,所有观测从 DB 层和链上切入 |
| 旁路架构 | 对账引擎独立部署,与业务服务完全解耦 |
| 总量优先 | 先保证总量对账准确,单笔精确对账作为后期迭代目标 |
| 不考虑熔断 | V2 只做发现和告警,熔断机制在 V3 实现 |
二、整体架构
┌─────────────────────────────────────────────────────┐
│ 业务系统(不动) │
│ 资产服务(A) 业务流水服务(B) 提现服务(C) │
└──────┬──────────────┬──────────────┬────────────────┘
│ 写 DB │ 写 DB │ 广播交易
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌──────────────┐
│ 资产 DB │ │ 流水 DB │ │ 链上节点 │
└────┬────┘ └────┬────┘ └──────┬───────┘
│ Binlog │ Binlog │ 链上事件
▼ ▼ ▼
┌──────────────────────────────────────────────┐
│ Canal / Maxwell (CDC) │
└──────────────────────┬───────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ 对账引擎(Golang) │
│ ┌──────────────┐ ┌──────────────────────┐ │
│ │ 时间窗口匹配 │ │ 汇总快照对账(兜底)│ │
│ └──────┬───────┘ └──────────┬───────────┘ │
│ │ │ │
│ ┌──────▼─────────────────────▼───────────┐ │
│ │ 悬挂队列(超时 → 告警) │ │
│ └────────────────────────────────────────┘ │
└──────────────────────┬───────────────────────┘
▼
告警系统(Slack / 钉钉 / 电话)
三、观测入口(四层)
3.1 Binlog CDC(核心)
原理: 模拟 MySQL Slave,实时消费 Binlog,获取所有表变更,业务完全无感知。
工具:
- Canal(阿里开源,推荐,生产验证充分)
- Debezium(Kafka 生态)
- Maxwell(轻量,JSON 输出)
前提条件(DB 层配置,无需改业务):
-- MySQL 配置文件加入(通常 replica 已开,无需额外操作)
[mysqld]
log_bin = ON
binlog_format = ROW -- 必须是 ROW 模式
binlog_row_image = FULL -- 记录完整行(before + after)
server_id = 1 -- 唯一 ID
Canal 消费到的事件格式:
{
"table": "user_assets",
"type": "UPDATE",
"ts": 1709710800123,
"gtid": "3e11fa47-71ca-11e1-9e33-c80aa9429562:23",
"data": {
"user_id": 10086,
"coin": "USDT",
"balance_before": "1000.000000",
"balance_after": "800.000000",
"updated_at": "2026-03-06 16:00:00.123"
}
}
GTID 作为逻辑时钟: 无需业务添加版本号,Binlog 的 GTID 本身是全局单调递增的,保证消费顺序。
3.2 DB 触发器(CDC 的降级方案)
当 Binlog 权限受限时,在 DB 层加触发器,业务代码不感知:
-- 在资产表加变更审计触发器
CREATE TRIGGER trg_asset_audit
AFTER UPDATE ON user_assets
FOR EACH ROW
INSERT INTO risk_asset_audit_log (
user_id, coin,
balance_before, balance_after,
delta,
changed_at,
server_id
) VALUES (
OLD.user_id, OLD.coin,
OLD.balance, NEW.balance,
NEW.balance - OLD.balance,
NOW(3),
@@server_id
);
-- 对账引擎只读 risk_asset_audit_log,不碰业务表
3.3 独立链上监控
完全独立于业务系统,自建节点或接入商业 API(QuickNode、Alchemy 等):
监控服务:
├── 订阅所有热钱包地址的链上事件
├── 维护 platform_known_txs 表(来源:提现服务的 Binlog)
└── 计算:chain_confirmed_balance - known_balance = 异常值
关键:known_broadcast 表
提现服务广播交易时会写 DB(Binlog 可以捕获 txid),将这些 txid 记录到 platform_known_txs。
链上余额减少但 txid 在 platform_known_txs 里 → 正常(已知操作)。
链上余额减少且 txid 不在 platform_known_txs 里 → 异常!
3.4 汇总快照(终极兜底)
每 30 秒跑一次,基于聚合索引,不全表扫描:
-- 有 (coin, balance) 联合索引的情况下,3T 数据 < 2 秒出结果
SELECT
coin,
SUM(balance) AS total_user_balance,
COUNT(user_id) AS user_count,
NOW(3) AS snapshot_at
FROM user_assets
GROUP BY coin;
对比链上余额,偏差超阈值 → 触发告警。
这一层保证即使 CDC 漏事件,总量层面一定能发现异常。
四、跨实例对账(A & B 数据关联)
4.1 核心问题
资产在 A 服务器,业务流水在 B 服务器,没有全局版本号,高并发下两边快照时间不对齐。
4.2 时间窗口匹配
匹配逻辑:
A 变动:user_id=10086, coin=USDT, Δ=-200, ts=16:00:00.123
B 流水:user_id=10086, coin=USDT, amount=200, type=withdraw, ts=16:00:00.189
满足:
① 同 user_id
② 同 coin
③ 金额吻合(|Δ_A| ≈ amount_B,允许手续费误差)
④ 时间差 < 阈值(默认 500ms,可调)
→ 匹配成功,记录为"已对账"
4.3 悬挂队列
A 变动进来 → 查找对应 B 流水
├── 找到 → 已对账,写入 reconcile_matched
└── 找不到 → 进入 reconcile_pending(悬挂队列)
↓
等待 60 秒(可配置)
↓
60s 内 B 流水到达 → 补充匹配,正常
60s 后仍无 B 流水 → 写入 reconcile_anomaly,触发告警
关键配置:
| 参数 | 默认值 | 说明 |
|---|---|---|
| 时间窗口 | 500ms | A 和 B 事件的最大时间差 |
| 悬挂超时 | 60s | 等待 B 流水的最长时间 |
| 金额误差 | 0.001% | 允许的手续费/精度偏差 |
4.4 误报缓解策略
同一用户、同一币种、同一时间窗口有多笔相同金额时,单笔匹配会乱序。
处理方案:单笔误报只触发 L1 告警(人工看),L2 告警依赖汇总快照的总量差额。
总量层面准确 > 单笔精确匹配。
五、钱包对账详解
5.1 余额分层模型
不能直接用"链上余额 = 用户余额总和",因为有大量中间状态。
有效余额 = chain_confirmed
+ known_broadcast ← 平台已广播但未上链(来自 Binlog)
+ platform_pending ← 平台已记账但未广播
用户资产 = Σ用户可用余额
+ Σ用户冻结余额
+ 运营资金(手续费归集等)
对账等式:
有效余额 = 用户资产 + 在途资金
| 状态 | 定义 | 来源 |
|---|---|---|
chain_confirmed | 链上 N 个确认的余额 | 独立节点轮询 |
known_broadcast | 平台广播但未确认的 txid | 提现服务 Binlog 捕获 txid |
platform_pending | 平台记账但未广播 | 提现服务 Binlog |
watch_mempool | 用户充值广播未确认 | 节点 mempool 监听(不参与对账等式) |
5.2 各公链确认数建议
| 公链 | 建议确认数 | 平均时间 | 进入 chain_confirmed |
|---|---|---|---|
| BTC | 3 | ~30 分钟 | 3 个区块后 |
| ETH / ERC20 | 12 | ~2.5 分钟 | 12 个区块后 |
| OMNI | 跟 BTC | ~30 分钟 | 同 BTC |
| TRON / TRC20 | 19 | ~60 秒 | 19 个区块后 |
| BSC | 15 | ~45 秒 | 15 个区块后 |
| 其他主流链 | 参考各链文档 | — | — |
5.3 BTC 慢上链处理(防误报)
BTC 提现流程:
1. 提现服务创建交易 → DB 写入 txid(Binlog 捕获)
2. 广播到 BTC 网络 → known_broadcast[txid] = 金额
3. 链上余额减少 → 对比 known_broadcast,找到匹配 → 正常
4. 等待 3 个区块确认 → 从 known_broadcast 移入 chain_confirmed
关键:
热钱包余额减少 但 known_broadcast 里有对应 txid → 正常,不告警
热钱包余额减少 且 known_broadcast 里没有 txid → 异常!立即告警
known_broadcast 的超时处理:
广播后超过 72 小时未确认(手续费过低被遗弃)→ 标记为 dropped
dropped 的 txid → 不再计入 known_broadcast → 触发余额重新核算
同时触发重广播流程(业务侧处理,风控仅告警)
5.4 多签钱包对账
多签钱包(M-of-N)余额纳入对账等式:
有效余额 = chain_confirmed(热)
+ chain_confirmed(冷/多签)
+ known_broadcast
+ platform_pending
多签余额查询:
→ 通过多签地址直接查链(多签地址是普通链上地址,可直接查)
→ 不依赖业务系统,完全独立
六、对账引擎数据表设计
-- 对账汇总快照(每 30 秒一条)
CREATE TABLE reconcile_snapshots (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
coin VARCHAR(20) NOT NULL,
total_user_balance DECIMAL(30,8) NOT NULL, -- DB 侧用户总余额
chain_confirmed DECIMAL(30,8) NOT NULL, -- 链上已确认
known_broadcast DECIMAL(30,8) NOT NULL, -- 已广播未确认
platform_pending DECIMAL(30,8) NOT NULL, -- 已记账未广播
delta DECIMAL(30,8) NOT NULL, -- 差额(应为 0)
snapshot_at DATETIME(3) NOT NULL,
INDEX idx_coin_time (coin, snapshot_at)
);
-- 单笔变动悬挂队列
CREATE TABLE reconcile_pending (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
source ENUM('asset','business') NOT NULL,
server_id INT NOT NULL,
user_id BIGINT NOT NULL,
coin VARCHAR(20) NOT NULL,
amount DECIMAL(30,8) NOT NULL,
event_ts DATETIME(3) NOT NULL,
raw_event JSON,
status ENUM('pending','matched','anomaly') DEFAULT 'pending',
matched_id BIGINT DEFAULT NULL,
timeout_at DATETIME(3) NOT NULL, -- event_ts + 60s
created_at DATETIME(3) DEFAULT NOW(3),
INDEX idx_status_timeout (status, timeout_at),
INDEX idx_user_coin_ts (user_id, coin, event_ts)
);
-- 异常记录
CREATE TABLE reconcile_anomaly (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
anomaly_type ENUM('unmatched_asset','unmatched_biz','chain_deficit','snapshot_mismatch') NOT NULL,
coin VARCHAR(20) NOT NULL,
user_id BIGINT DEFAULT NULL,
delta DECIMAL(30,8) DEFAULT NULL,
detail JSON,
detected_at DATETIME(3) DEFAULT NOW(3),
alert_sent TINYINT(1) DEFAULT 0,
resolved TINYINT(1) DEFAULT 0,
INDEX idx_type_time (anomaly_type, detected_at),
INDEX idx_resolved (resolved, detected_at)
);
-- 链上已知广播(防 BTC 误报)
CREATE TABLE platform_known_txs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
coin VARCHAR(20) NOT NULL,
txid VARCHAR(128) NOT NULL,
amount DECIMAL(30,8) NOT NULL,
wallet_addr VARCHAR(128) NOT NULL,
status ENUM('broadcast','confirmed','dropped') DEFAULT 'broadcast',
broadcast_at DATETIME(3) NOT NULL,
confirmed_at DATETIME(3) DEFAULT NULL,
timeout_at DATETIME(3) NOT NULL, -- broadcast_at + 72h
UNIQUE KEY uk_txid (coin, txid),
INDEX idx_status (status, timeout_at)
);
七、告警分级
V2 版本不含熔断,仅告警。
| 级别 | 触发条件 | 通知方式 |
|---|---|---|
| L1 提示 | 单笔悬挂超时(可能误报) | Slack / 钉钉 |
| L2 预警 | 汇总快照差额 > 小阈值(如 10 USDT) | Slack + 短信 |
| L3 告警 | 汇总快照差额 > 大阈值(如 1000 USDT) | 电话 + Slack + 短信 |
| L4 紧急 | 链上异常变动(known_txs 对不上) | 电话叫醒 + 所有渠道 |
阈值按币种分别配置,存储在 risk_alert_config 表,支持运行时修改无需重启。
八、部署建议
8.1 Canal 部署
# Canal Server 独立部署,不与业务服务混部
# 连接业务 MySQL 作为 Slave(只读权限)
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT
ON *.* TO 'canal'@'%' IDENTIFIED BY 'canal_password';
8.2 对账引擎部署
- 独立 Golang 服务,最小化依赖
- 只有只读权限访问业务 DB(防止误操作)
- 写入权限仅限于
risk_*相关表 - 推荐独立 MySQL 实例存储对账数据(与业务 DB 物理隔离)
8.3 监控指标
# 对账引擎自身的可观测性指标
reconcile_lag_seconds # CDC 消费延迟
reconcile_pending_count # 当前悬挂队列深度
reconcile_anomaly_rate # 异常率(过高说明配置需要调整)
snapshot_delta{coin} # 各币种实时差额
chain_query_latency # 链上查询延迟
九、已知局限与后续迭代
| 局限 | 影响 | V3 计划 |
|---|---|---|
| 单笔匹配依赖时间窗口,同用户同金额可能乱序 | 误报 L1 告警 | 业务加 correlation_id(需改代码) |
| 悬挂队列超时需要人工确认 | 响应慢 | V3 加熔断自动响应 |
| 多跳 AML 溯源 | 未覆盖 | V3 加图数据库追踪 |
| 行为风控(快进快出洗钱) | 未覆盖 | V3 加实时评分引擎 |