关系型数据库模拟图:邻接表模型与递归CTE归纳总结
做反欺诈图谱的时候,我们经常要问一个问题:关系型数据库又不是专门的图数据库,怎么存图?怎么查图?
说实话,我刚开始做这个方向时也头大。银行的风控系统不可能为了图谱专门搭一套 Neo4j,数据都在 Oracle 和 MySQL 里躺着。那怎么办?用邻接表模型 + 递归 CTE,这套组合拳在反欺诈场景下特别实用。
1. 邻接表模型——最朴素的图存储方式
邻接表,说白了就是把图的每条边当成一行记录存到表里。举个例子,两个账户之间的转账关系,存成这样:
-- 边表结构示例
CREATE TABLE edge (
from_node VARCHAR(32) NOT NULL,
to_node VARCHAR(32) NOT NULL,
relation VARCHAR(20) DEFAULT 'transfer',
amount DECIMAL(18,2),
create_time TIMESTAMP,
PRIMARY KEY (from_node, to_node, create_time)
);
-- 插入两条边
INSERT INTO edge VALUES ('A', 'B', 'transfer', 5000, '2025-01-01 10:00:00');
INSERT INTO edge VALUES ('B', 'C', 'transfer', 3000, '2025-01-01 10:05:00');
为什么叫邻接表?因为每个节点通过这张表就能找到它的邻居——从 from_node 出发找到所有 to_node 就是出边,反过来就是入边。我在项目中常常把边表再加上方向字段和权重,用来区分资金流出和流入。
关键点:邻接表只存关系,不存节点属性。节点属性通常单独放一张节点表,用 JOIN 关联,这样边表才能保持轻量、查询快。
2. 递归CTE——在SQL中跑图遍历
存好了图,怎么遍历?SQL 标准里给了我们一个神器:WITH RECURSIVE。它允许你在一条 SQL 里定义递归查询,一层层往下走,直到没有新节点为止。
语法结构其实不复杂,核心就两部分:锚点成员(第一层)和递归成员(从上一层往下一层)。我习惯叫它「种子 + 生长」。
WITH RECURSIVE path AS (
-- 锚点:从A出发
SELECT from_node, to_node, 1 AS depth,
CAST(from_node || '->' || to_node AS TEXT) AS route
FROM edge
WHERE from_node = 'A'
UNION ALL
-- 递归:沿着边往下走
SELECT e.from_node, e.to_node, p.depth + 1,
p.route || '->' || e.to_node
FROM edge e
JOIN path p ON e.from_node = p.to_node
WHERE p.depth < 20 -- 防止死循环
)
SELECT * FROM path;
这段代码在反欺诈里非常常见——找从某账户出发,经过多少跳能到达哪些账户。我把 depth 限制在 20 层,因为真要出现 20 跳以上的资金链路,多半是有意构造的洗钱路径了。
小技巧:递归 CTE 默认会检测重复节点,但为了性能,我通常在递归成员里加一个 POSITION(e.to_node IN p.route) = 0 的条件,避免走回头路。不过要注意,有些数据库(比如 MySQL 8.0 以下版本)不识别 POSITION 写法,需要改用 NOT LIKE 或 INSTR。
3. 反欺诈场景下的典型应用
邻接表 + 递归 CTE 在反欺诈图谱里能做的事,远不止查个关联路径。我列几个用过的场景:
- 资金链路追踪——从疑似诈骗账户出发,递归找到所有收款方,看资金最终流向了哪几个归集账户。这在分析跑分平台时特别有效。
- 团伙关系挖掘——把共同设备、共用 IP 也当成边存进表,然后用递归 CTE 做连通体划分。我见过一个案子,靠这个挖出了一个 47 人的刷单团伙。
- 环检测——递归 CTE 走到某步发现回到了已访问节点,那就说明存在循环转账。一般用来识别自洗钱行为。
注意:递归 CTE 在 MySQL 8.0+ 和 PostgreSQL / SQL Server 里都支持,但 Oracle 的写法略有不同(需要用 CONNECT BY)。如果你公司还在用 MySQL 5.7,那就只能用存储过程模拟递归了。我曾经在一个生产项目里被这个坑过,临时改方案,差点延期。
4. 邻接表与其他图存储方式的对比
有些同学会问:为什么不把整个图存成 JSON 字段或者用属性图模型?我整理了一个对比表,大家看看就清楚了:
| 存储方式 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| 邻接表(边表) | 标准SQL支持,索引优化方便,数据量无上限 | 多跳查询写起来麻烦,深度递归性能下降 | 交易链路、设备关联、关系图谱 |
| JSON/数组字段 | 存储灵活,单行读取快 | 无法用索引查询关系,更新成本高 | 小规模图谱,只查不写的场景 |
| 属性图(专用数据库) | 原生图遍历,性能高 | 需要额外维护,和现有业务库不兼容 | 复杂图分析、实时推荐 |
看完这个表你应该能理解,为什么反欺诈图谱的离线分析环节,大家更喜欢用邻接表+递归CTE——它不需要额外引入组件,复用现有数据库的备份、容灾、权限体系,对风控工程师来说非常友好。
嗯,最后再说一点我自己的体会:递归 CTE 的写法看似固定,但实际调优空间很大。比如设置合理的深度上限、在递归成员里过滤无效路径、给 edge 表建组合索引(from_node, to_node),这些细节都能让查询从几分钟降到几秒。我在之前一个项目中,就是因为少建了一个索引,一次全链路追踪跑了 17 分钟,被业务方骂惨了。从那以后,我每次写递归 CTE 之前都先检查 explain。
5. 避坑指南
- 忘记设深度上限——递归 CTE 没有上限时可能跑进死循环,CPU 打满,数据库挂掉。我曾经在测试环境没加
WHERE depth < 10,结果递归了 3 万层,把开发库拖垮了。从那以后,我写的每个递归都会在锚点和递归成员里各检查一次 depth。 - 索引没覆盖递归条件——递归成员每次 JOIN 都需要快速定位
from_node = p.to_node,如果 edge 表只有主键没有其他索引,那每次递归都是全表扫描。我建议至少建一个(from_node, to_node, relation)的联合索引。 - UNION ALL 误写成 UNION——UNION 会去重,导致递归结果出错(可能漏掉重复路径上的新节点)。在递归 CTE 里请始终用 UNION ALL,除非你确信需要去重。
- 数据库兼容性——MySQL 8.0 和 PostgreSQL 的递归 CTE 写法几乎一样,但 SQL Server 需要把递归成员放在最后一行,Oracle 更推荐 CONNECT BY。如果你在跨数据库迁移项目,提前写个适配层,否则改起来很痛苦。
我个人习惯在递归 CTE 的最终查询时加上 ORDER BY depth, route,这样输出的路径清晰可读,业务分析人员一眼就能看出资金流向。如果数据量大,也可以先 SELECT DISTINCT 去重再排序,减少下游处理压力。