跳到主要内容

风控系统 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,触发告警

关键配置:

参数默认值说明
时间窗口500msA 和 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
BTC3~30 分钟3 个区块后
ETH / ERC2012~2.5 分钟12 个区块后
OMNI跟 BTC~30 分钟同 BTC
TRON / TRC2019~60 秒19 个区块后
BSC15~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 加实时评分引擎