本文旨在为中高级工程师与技术负责人深度剖析金融风控系统中“自成交”(Wash Trade)这一典型作弊行为的识别与对抗技术。我们将从现象入手,回归到图论、统计学等计算机科学基础原理,层层深入到实时流计算、状态管理等工程实现细节,并最终探讨不同方案在延迟、吞吐、准确性上的权衡与架构演进路径。本文的目标不是概念普及,而是提供一套可落地、可演进的实战技术框架,适用于数字货币交易所、NFT 市场、股票交易等一切关注交易流动性与合规性的场景。
现象与问题背景
自成交(Wash Trade),俗称“刷量”,是一种市场操纵行为。其核心是交易者(或其控制的多个账户)同时扮演买方和卖方,与自身或关联方进行交易,以制造虚假的交易活跃度。这种行为在传统金融市场被严格禁止,但在新兴的、监管尚不完善的领域(如早期加密货币交易所、NFT 交易平台)则一度泛滥。
其主要动机包括:
- 制造虚假繁荣: 高的交易量能吸引真实的用户和投资者,提升项目或平台的市场排名和声誉。
- 操纵市场价格: 通过连续的、略微抬高价格的自成交,可以制造价格上涨的假象(Pump),吸引跟风盘后出货(Dump)。
- 获取平台激励: 某些平台会根据交易量提供手续费返佣、平台币奖励或参与 IEO/IDO 的资格,自成交成为低成本套利的方式。
从技术视角看,自成交可以分为几种典型模式:
- 直接自成交 (Direct Self-Trade): `账户 A` 向 `账户 A` 自己下单并成交。这是最原始的形式,大多数现代撮合引擎在设计之初就会通过 `buyer_id == seller_id` 的校验来直接禁止此类交易发生。
- 关联账户对倒 (Indirect Cross-Trade): `账户 A` 买入,`账户 B` 卖出。然而,`账户 A` 和 `账户 B` 背后由同一实体控制。这是最常见的形式。
- 环形交易 (Ring Trade): 更复杂的模式,如 `账户 A -> 账户 B -> 账户 C -> 账户 A`,形成一个资金或资产流动的闭环。这种模式更难被发现,尤其是在环路较长、交易时间分散的情况下。
我们的核心挑战在于,系统如何在每秒数万甚至数十万笔的成交洪流中,实时、准确地识别出哪些看似独立的账户实际上是“同伙”,并判定他们的交易行为是否构成自成交。这不仅是一个算法问题,更是一个高并发、低延迟的分布式系统工程问题。
关键原理拆解
要解决这个问题,我们不能只看单笔交易,必须回到更基础的计算机科学原理,建立账户之间的关联性,并从统计学角度发现行为模式的异常。这里,我们将以一位大学教授的视角,阐述两大核心原理:基于图论的身份关联 和 基于统计学的行为模式识别。
原理一:身份关联的图论抽象
问题的根源在于识破伪装。一个操盘手可以注册无限多的账户,但其行为总会留下蛛丝马迹。这些“马迹”就是我们识别关联的锚点,例如:
- 设备指纹 (Device ID): 注册或登录时使用的设备唯一标识。
- IP 地址 (IP Address): 登录、下单时使用的公网 IP。
- 钱包地址 (Wallet Address): 充提币时使用的链上地址。
- 实名信息 (KYC Info): 身份证、护照等(尽管可以被伪造或借用)。
在计算机科学中,处理“关系”问题的最有力工具就是图论。我们可以将整个用户生态系统抽象成一个巨大的图(Graph):
- 节点 (Nodes): 代表不同的实体,包括 `用户ID`、`设备ID`、`IP地址`、`钱包地址` 等。
- 边 (Edges): 代表这些实体间的关联关系。例如,`用户A` 在 `IP_1` 登录过,就在 `用户A` 和 `IP_1` 节点间建立一条边。
在这个模型下,“识别关联账户”这一问题,就转化为“在图中寻找连通分量(Connected Components)”的经典算法问题。所有在同一个连通分量中的用户ID,都可以被高度怀疑为由同一实体控制。而最高效解决动态连通性问题的算法,就是并查集(Union-Find / Disjoint Set Union)。
并查集数据结构的核心是维护一个森林(一组树),每个连通分量是一棵树。它提供两个关键操作:
- `find(p)`: 找到元素 `p` 所在集合的代表元(即树的根节点)。
- `union(p, q)`: 如果 `p` 和 `q` 不在同一集合,则合并它们所在的集合(即将一棵树嫁接到另一棵树上)。
通过路径压缩(Path Compression)和按秩/大小合并(Union by Rank/Size)的优化,并查集操作的均摊时间复杂度可以接近 O(α(n)),其中 α(n) 是反阿克曼函数,其增长极其缓慢,对于任何实际输入的 n,其值都可以看作是一个极小的常数(小于 5)。这使得它成为在线、实时构建用户关联图谱的理论最优解。
原理二:交易行为的统计学特征
仅仅识别出关联账户还不够,我们还需要判定其交易行为是否“异常”。一个高频量化交易团队也可能使用多个关联账户进行做市策略,这是合法行为。自成交的核心特征是“无效交易”,即没有真实的经济意图,仅仅是资产的左手倒右手。这种行为在统计学上会呈现出几个显著特征:
- 时间上的高度相关性: 在极短的时间窗口内(如毫秒级),关联账户之间发生方向相反、数量和价格相近的交易。
- 价格和数量的镜像性: 买单和卖单的价格几乎完全一致,数量也高度匹配,导致市场净头寸(Net Position)几乎为零。
- 经济上的非理性: 正常交易者追求盈利,而刷量者可能长期来看是亏损手续费的。如果一个账户对持续进行无利润甚至稳定亏损的交易,这是一个强烈的危险信号。
- 成交价格的偏离度: 成交价长期偏离市场中间价(Mid-Price),或者对市场价格的深度(Market Depth)毫无影响。
这些特征可以通过时间序列分析、滑动窗口统计等方法进行量化,从而将模糊的“行为模式”转化为具体的、可编程的规则或模型特征。
系统架构总览
一个工业级的自成交检测系统,必然是一个融合了实时流处理、离线批处理和图计算的复杂系统。其典型架构可以用以下文字描述的组件图来表示:
数据源层:
- 业务数据库 Binlog/CDC: 捕捉用户注册、登录、KYC 等低频但关键的身份信息变更。
- 消息队列 (Kafka):
- `trades` topic: 撮合引擎产生的实时成交记录,这是系统的核心数据流。数据格式如:`{trade_id, pair, price, quantity, buyer_id, seller_id, timestamp}`。
- `user_events` topic: 用户登录、下单等行为日志,包含 `user_id, ip, device_id` 等信息。
处理与计算层:
- 实时计算 (Flink / Spark Streaming):
- 身份关联作业 (Identity Graph Job): 消费 `user_events` 和数据库 CDC 数据,使用 Flink 的 `KeyedState` 结合并查集算法,实时维护用户ID与其关联标识(IP、设备等)的映射关系,并将最终的用户“族群ID”(即连通分量的根节点ID)存储在高速缓存中。
- 交易检测作业 (Wash Trade Detection Job): 消费 `trades` topic,对于每笔交易,实时查询买卖双方的族群ID。如果族群ID相同,则标记为高嫌疑自成交。同时,它还会在时间窗口内对交易对进行统计,以发现更复杂的模式。
- 离线计算 (Spark / Hive):
- 全量图构建与分析: 定期(如每日)将全量数据导入 HDFS 或数据湖,使用 Spark GraphX 或图数据库(如 Neo4j, TigerGraph)进行更复杂的图分析,例如检测长达数十个节点的交易环路。
- 模型训练: 基于历史数据训练机器学习模型,以识别更隐蔽的刷量模式,并将模型部署到实时系统中进行推理。
存储与服务层:
- 状态存储 (Redis / RocksDB): Flink 作业的状态后端,用于存储并查集的父指针表、IP 的近期活跃用户列表等需要低延迟读写的状态数据。Redis Cluster 是常见的选择。
- 数据湖/数仓 (HDFS / S3 / Hive): 存储全量的原始日志和中间计算结果,供离线分析和审计使用。
- 风险处置接口: 将检测到的风险事件(如 `wash_trade_alert`)推送到另一个 Kafka topic,下游的处置系统(如账户中心、风控后台)消费这些消息,执行冻结账户、禁止交易、发送告警等操作。
核心模块设计与实现
现在,让我们切换到极客工程师的视角,深入探讨两个最关键模块的实现细节和代码片段。
模块一:实时身份图谱构建 (Union-Find on Flink)
这个模块的目标是,当一个新的用户行为事件(如登录)到来时,能毫秒级地更新用户关联关系。我们使用 Flink 来实现,其状态化流处理能力是完美的选择。
关键设计:
- 我们将每个实体(`user_123`, `ip_1.2.3.4`, `dev_abc`)都看作是并查集中的一个元素。
- 使用 Flink 的 `KeyedState` (具体来说是 `MapState`) 来存储并查集的 `parent` 映射表。为了能让不同 key 的数据进行 `union`,我们不能简单地用 `userID` 或 `ip` 来做 `keyBy`。一个巧妙的方法是,始终将 `union` 操作路由到两个待合并元素中字典序较小的一个 key 所在的 Flink 实例上处理,但这会引入复杂性和数据倾斜。更实用的方法是,将并查集的状态集中存储在外部的 Redis 中,Flink 任务通过 Redis Client 进行交互。这里为了简化,我们展示一个概念性的 Flink 实现。
// 伪代码: 一个处理用户事件,更新并查集状态的 Flink ProcessFunction
// 实际生产中状态管理会更复杂,可能会用 Redis 或 RocksDBStateBackend
public class IdentityGraphBuilder extends ProcessFunction<UserEvent, Void> {
// 状态句柄: MapState<String, String> parent; (entity -> root_entity)
// 假设我们有一个外部服务 (e.g., Redis-based) 来实现全局并查集
private transient UnionFindService unionFindService;
@Override
public void open(Configuration parameters) {
unionFindService = new RedisBasedUnionFindService();
}
@Override
public void processElement(UserEvent event, Context ctx, Collector<Void> out) throws Exception {
String userId = "user_" + event.getUserId();
String ip = "ip_" + event.getIpAddress();
String deviceId = "device_" + event.getDeviceId();
// 确保每个实体都被初始化
unionFindService.add(userId);
unionFindService.add(ip);
unionFindService.add(deviceId);
// 将用户与其关联的 IP 和设备进行合并
// Union-Find 的幂等性保证了重复操作是安全的
unionFindService.union(userId, ip);
unionFindService.union(userId, deviceId);
}
}
// UnionFindService 接口可以是:
interface UnionFindService {
void add(String element);
void union(String p, String q);
String find(String p);
}
工程坑点:
- 超级节点 (Super Nodes): 像公共场所 WiFi 或移动网络出口网关这样的 IP,会关联成千上万个无辜的用户,形成一个巨大的连通分量,导致误判。解决方案: 维护一个“公共标识符”黑名单,或在图模型中对边的权重进行衰减。例如,一个 IP 关联的用户超过 100 个,就降低这个 IP 作为关联证据的权重。
- 状态爆炸: 随着时间推移,并查集的状态会无限增长。解决方案: 需要对旧的、不活跃的实体进行状态 TTL (Time-To-Live) 清理。例如,一个超过 30 天没有任何活动的 IP,可以从图中移除其关联关系。
模块二:实时交易对倒检测
这个模块消费撮合后的 `trades` 流,利用上游构建好的身份图谱,实时识别自成交。
关键设计:
- 消费 `trades` topic。
- 对于每笔交易 `T(buyer_id, seller_id)`,调用 `UnionFindService` 分别查询 `find(buyer_id)` 和 `find(seller_id)`,得到他们各自的族群ID `root_buyer` 和 `root_seller`。
- 核心规则: 如果 `root_buyer == root_seller`,这笔交易就是一次高置信度的关联账户对倒,直接产生告警。
- 扩展规则: 为了检测更复杂的模式,可以按交易对(如 `BTC_USDT`)进行 `keyBy`,并使用 Flink 的时间窗口(如 1 秒的滑动窗口)。在窗口内,缓存所有买卖盘,并寻找那些“完美抵消”的交易模式,即使它们的族群ID不同(可能是尚未发现关联的账户),也可以作为中等嫌疑的事件上报。
// 伪代码: Go 语言实现的流处理消费者核心逻辑
package main
import "fmt"
// 假设 UnionFindService 是一个 RPC 客户端
type UnionFindServiceClient struct { /* ... */ }
func (c *UnionFindServiceClient) Find(id string) (string, error) { /* ... */ return "root_" + id, nil }
type Trade struct {
BuyerID string
SellerID string
Pair string
Price float64
Quantity float64
}
func ProcessTrade(trade Trade, ufClient *UnionFindServiceClient) {
buyerRoot, err1 := ufClient.Find("user_" + trade.BuyerID)
sellerRoot, err2 := ufClient.Find("user_" + trade.SellerID)
if err1 != nil || err2 != nil {
// Log error, maybe retry or push to dead letter queue
return
}
// 核心检测逻辑
if buyerRoot == sellerRoot {
fmt.Printf(
"High Confidence Wash Trade Detected! Pair: %s, Buyer: %s, Seller: %s, RootID: %s\n",
trade.Pair, trade.BuyerID, trade.SellerID, buyerRoot,
)
// Trigger alert to Kafka/Nats/etc.
sendAlert(trade, "AssociatedAccounts")
}
// 这里可以加入更复杂的窗口逻辑
// e.g., cache trades in a local map with TTL and look for reversals
}
func main() {
// 模拟从 Kafka 消费消息
// kafkaConsumer := ...
// ufClient := ...
// for message := range kafkaConsumer.Messages() {
// var trade Trade
// json.Unmarshal(message.Value, &trade)
// go ProcessTrade(trade, ufClient)
// }
}
工程坑点:
- 延迟与抖动: 对 `UnionFindService` 的 RPC 调用会引入延迟。如果该服务有抖动,将直接影响整个流处理的吞吐和稳定性。解决方案: 使用本地缓存(如 Caffeine / Guava Cache)来缓存 `find()` 的结果,缓存时间可以设置得很短(如 1-5 秒),能极大缓解对外部服务的压力。
- 乱序与延迟事件: 网络原因可能导致 `trades` 事件晚于产生它的 `user_events` 到达。Flink 的 Watermark 机制是专门为解决这类问题设计的,必须正确配置,以确保在处理一笔交易时,其相关的身份关联信息已经被处理过。
性能优化与高可用设计
在讨论架构时,没有银弹,全是权衡(Trade-off)。
实时性 vs. 准确性 vs. 成本
- 亚秒级实时(Sub-second Real-time): 优点是能最快地发现并干预,甚至在清结算前阻止。缺点是架构复杂,对状态存储的 P99 延迟要求极高(个位数毫秒),并且只能执行相对简单的规则(如直接关联检测),因为复杂的图算法无法在如此短的时间内完成。成本最高。
- 准实时(Near Real-time, 1s ~ 1min): 这是工程上最常见的选择。使用 Flink/Spark Streaming,可以在秒级或分钟级窗口内完成关联检测和简单的统计。它平衡了实时性和计算复杂度,能覆盖 90% 以上的常见刷量场景。
- 离线批量(Offline Batch, hours/days): 优点是可以用全部数据进行深度分析,运行复杂的图算法(如 PageRank, Louvain 社区发现)和机器学习模型,发现长期、隐蔽的作弊团伙。缺点是无法及时干预。成本最低,通常作为实时系统的补充和金丝雀数据源。
误报 (False Positive) vs. 漏报 (False Negative)
这是风控系统永恒的矛盾。规则太严,可能会把正常的高频做市商标记为刷量(误报),影响核心客户体验和平台流动性;规则太松,则会放过大量作弊者(漏报),导致平台数据失真,甚至引来监管处罚。
对抗策略:
- 分级告警: 不要用简单的“是/否”来判断。系统应输出一个风险分数或风险等级。例如,直接关联账户对倒是“高危”,价格/时间/数量高度匹配的镜像交易是“中危”,统计特征异常是“低危”。
- 白名单机制: 对于已知的、合规的做市商账户,可以将其加入白名单,豁免某些特定的严格规则。但这需要严格的业务审批流程。
- 人工审核与反馈闭环: 高危事件自动处置,中低危事件进入人工审核队列。分析师的审核结果(确认是刷量/误报)应被反馈回系统,作为优化规则和训练模型的样本数据。
系统高可用
- 无单点故障: Kafka、Flink、Redis 等所有组件都必须是集群化部署。
- 状态的持久化: Flink 必须开启 Checkpointing,将状态定期快照到分布式文件系统(如 HDFS/S3)。当任务失败时,可以从上一个成功的 checkpoint 恢复状态,保证数据不丢失、计算不重复 (Exactly-once semantics)。
- 降级预案: 当身份关联服务(如 Redis)出现故障时,实时检测作业不能崩溃。它可以降级运行,暂时只执行不依赖身份图谱的检测规则(如窗口内的镜像交易检测),并发出系统告警。
架构演进与落地路径
对于一个从零开始构建自成交检测系统的团队,不可能一步到位实现上述的复杂架构。一个务实、分阶段的演进路径至关重要。
第一阶段:离线规则驱动 (Crawl)
- 目标: 验证业务价值,快速发现最明显的作弊行为。
- 实现: 不引入复杂的流处理框架。编写一个每日运行的 Spark 或 Hive SQL 脚本,从交易日志中筛选出 `buyer_id = seller_id` 的交易,以及共享同一IP或设备ID的账户在1小时内的对倒交易。
- 产出: 一份每日生成的 CSV 报告,交由运营或风控团队人工处理。
- 优点: 实现成本极低,周期短,能快速解决 20% 的问题。
第二阶段:准实时流式检测 (Walk)
- 目标: 建立近实时的发现和响应能力。
- 实现: 引入 Kafka 和 Flink。搭建上文所述的“实时身份图谱构建”和“实时交易对倒检测”两个核心作业。将并查集的状态存储在 Redis Cluster 中。检测到的高危事件直接推送到告警 topic。
- 产出: 准实时的自动化告警,可与处置系统联动,实现半自动或全自动处理。
- 优点: 检测时效性大大提升,覆盖大部分作弊场景,系统开始具备闭环能力。
第三阶段:图计算与 AI 增强 (Run)
- 目标: 对抗专业、隐蔽的作弊团伙,降低误报率。
- 实现:
- 将 Flink 计算出的身份图谱关系和交易数据每日导入到图数据库(如 Neo4j)或 Spark GraphX 中。
- 在图平台上运行社区发现、环路检测等算法,挖掘离线也难以发现的作弊网络。
- 抽取用户、交易的特征(如交易频率、盈利亏损率、对手方分散度等),训练异常检测模型(如孤立森林、LSTM Autoencoder)。
- 将训练好的模型打包,通过 FlinkML 或外部模型服务接口,在实时流中进行推理打分,作为规则引擎的补充。
- 产出: 一个多维度、基于分数、结合了规则、图分析和机器学习的立体化风控大脑。
- 优点: 检测能力和准确性达到业界领先水平,能有效对抗不断进化的作弊手段。
自成交检测是一场持续的攻防战。随着作弊手段的演进,风控系统也必须不断进化。从简单的规则匹配,到基于图的关联分析,再到机器学习的智能决策,这条技术演进之路,不仅是风控能力的提升,也是一个技术团队在数据处理、分布式系统和算法应用方面不断走向成熟的体现。
延伸阅读与相关资源
-
想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
交易系统整体解决方案。 -
如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
产品与服务
中关于交易系统搭建与定制开发的介绍。 -
需要针对现有架构做评估、重构或从零规划,可以通过
联系我们
和架构顾问沟通细节,获取定制化的技术方案建议。