关系型数据库模拟图:邻接表模型与递归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 在反欺诈图谱里能做的事,远不止查个关联路径。我列几个用过的场景:

  1. 资金链路追踪——从疑似诈骗账户出发,递归找到所有收款方,看资金最终流向了哪几个归集账户。这在分析跑分平台时特别有效。
  2. 团伙关系挖掘——把共同设备、共用 IP 也当成边存进表,然后用递归 CTE 做连通体划分。我见过一个案子,靠这个挖出了一个 47 人的刷单团伙。
  3. 环检测——递归 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。

邻接表模型 + 递归CTE 反欺诈图谱流程 边表 (edge) from_node, to_node, relation 节点表 (node) WITH RECURSIVE path 锚点(seed) → 递归 → 终止条件 SQL引擎读取 资金链路追踪 (递归输出路径) 团伙关系挖掘 (连通体划分) 环检测 (重复节点判断) 输出:可疑路径 / 团伙标签 / 循环标志 接入风控规则引擎 / 模型特征 注意:depth 上限、索引、重复检测是递归CTE性能的三大关键点 我习惯在 edge 表上建 (from_node, to_node, relation) 联合索引

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 去重再排序,减少下游处理压力。