4、实体关系建模:如何用表表示人与人之间的关联

做团伙识别,核心就是算人和人之间的关联。

你想想,如果每两个人之间的关系都存成一张表,那这表该怎么设计?

我最早做反欺诈团伙项目时,第一版表结构做得太简单——把两个人名直接扔在一条记录里,结果后来要查“谁和谁通过某个人认识”,SQL写得跟蜘蛛网似的。踩了这个坑之后,我才老老实实地把实体关系建模这件事想透。

4.1 实体与关系的两种表达

人与人之间的关联,本质上就是图里的“边”。

但表是二维的,图是多维的。

怎么用表来表示?归根结底两种思路:

表达方式 说明 适用场景
邻接表 一行记录一条关系,存两个实体ID + 关系属性 稀疏关系、关系属性多、团伙识别主场景
邻接矩阵 用二维表(人×人)表示是否有关联 稠密关系、全量计算但扩展性差

我个人的习惯,90% 的场景选邻接表。你想想看,团伙识别里一个人可能跟成百上千人有关联,但数据库里不是所有人都互相认识。邻接表存起来省空间,查起来也灵活。邻接矩阵更适合做图算法内部的中间结构,当存储的话,人数一多就爆了。

我的一点经验: 如果一个团伙里人数超过5000,邻接矩阵单表就超过2.5亿格,99%都是0。别这么干。

4.2 人物表:所有关系的起点

先得有“谁”,才能说“谁和谁”。

人物表一般叫 person,核心字段就两个:person_id 和 person_name。当然在实际项目中,还会带上身份证号、手机号、设备指纹等身份标识,但那是为了去重。我们这里只讨论建模最简结构。

我曾经在项目中遇到一个问题:同一个人的手机号在两张表里出现两个不同的 person_id,结果算出来的团伙分裂成了两半。所以建人物表时,最好提前用硬标识(身份证号、设备IMEI)做一次统一清洗。

4.3 关系表:边才是灵魂

关系表是团伙识别里最重要的一张表。我习惯叫它 relation。

最基本的字段结构是这样:

CREATE TABLE relation (
    relation_id    BIGINT PRIMARY KEY,
    from_person_id INT NOT NULL,
    to_person_id   INT NOT NULL,
    relation_type  VARCHAR(32) NOT NULL,   -- 关系类型:通话/转账/同址/同IP
    relation_time  DATETIME,               -- 关系发生时间(可选)
    weight         DECIMAL(10,4),          -- 关系权重(可选)
    create_time    DATETIME DEFAULT NOW()
);

这里要注意三点:

  • from_person_id 和 to_person_id 都引用 person.person_id,构成外键关系。
  • 关系是无向的还是带方向的?我建议统一存成有向的。比如A给B打了一个电话,就存 (A, B);如果B也回了一个,再存 (B, A)。这样查询时不需要额外判断方向。
  • relation_type 用字符串而不是数字枚举,因为团伙识别中关系类型会经常新增,字符串可读性强,维护起来也简单。
我曾经踩过的坑: 一开始没加 relation_type,把所有关系混在一张表里。后来要分别计算“通话次数”和“转账金额”,SQL里到处写 WHERE relation_type = 'call',但索引效率极差。一定要把类型字段单独列出来。

4.4 关系表的分表设计(可选)

当数据量很大时(比如每天几千万条通话记录),一张关系表会变成热表,影响所有查询。

我做的方案是按 relation_type 拆成多张子表:

  • relation_call(通话关系)
  • relation_transfer(转账关系)
  • relation_location(同址关系)

这样每张表的数据量可控,还能针对不同关系类型建不同的索引。缺点是做跨类型查询时要用UNION,但团伙识别里跨类型比例并不高,可以接受。

建表示例(通话关系):

CREATE TABLE relation_call (
    relation_id    BIGINT PRIMARY KEY,
    from_person_id INT NOT NULL,
    to_person_id   INT NOT NULL,
    call_count     INT DEFAULT 1,
    total_duration INT DEFAULT 0,
    first_call_time DATETIME,
    last_call_time  DATETIME,
    create_time    DATETIME DEFAULT NOW(),
    INDEX idx_from (from_person_id),
    INDEX idx_to (to_person_id)
);

注意这里把关系属性(次数、时长)直接作为字段,而不是用JSON或text存。理由很简单:要用SQL高效聚合。

4.5 一张图看懂实体关系建模

下面这张图,是我自己画的最简实体关系模型。你可以照着它建表。

实体关系建模核心结构 person 人物表 person_id (PK) person_name ...其他标识字段 relation 关系表 relation_id (PK) from_person_id (FK) to_person_id (FK) 关系子表 relation_call relation_transfer relation_location ⚠ 关键设计原则 1. 人物表保证 person_id 全局唯一(通过身份证/设备指纹去重) 2. 关系表用有向边,便于无向/有向转换;关系类型用字符串,便于扩展 示例数据(四人团伙) person: {1:'张三', 2:'李四', 3:'王五', 4:'赵六'} relation_call: (1,2,3次), (2,3,5次), (3,1,1次), (2,4,2次) relation_transfer: (1,2,5000元), (4,2,3000元) 通过关系表可以查出:张三↔李四↔王五 形成三角,赵六只连接李四

4.6 复杂关系场景的处理技巧

不是所有关系都像“A和B打过电话”这么简单。实际项目中,我遇到过这些麻烦事:

4.6.1 同一个人在不同关系中的身份不统一

比如张三用自己身份证实名手机号,但用他老婆的银行卡转账。这时候手机号关联到张三,银行卡关联到另一个人。怎么统一?我的做法:再建一张实体统一表,把各种标识映射到同一个 person_id。

CREATE TABLE entity_map (
    map_id       BIGINT PRIMARY KEY,
    person_id    INT NOT NULL,    -- 统一后的主ID
    source_type  VARCHAR(16),     -- 'mobile' / 'id_card' / 'device_id'
    source_value VARCHAR(128)     -- 具体的号码/设备码
);

然后写ETL时,先通过 entity_map 把新记录关联到已有的 person_id,没有就新建一个。

4.6.2 关系的时间窗口问题

团伙关系不是一成不变的。张三和李四去年是同事,今年没联系了,但去年他们一起做过一些事。如果你只存储最新关系,就丢失了历史。我的方案是用分区表,按月分区,查询时指定时间范围。

CREATE TABLE relation_call_part (
    relation_id BIGINT,
    from_person_id INT,
    to_person_id INT,
    call_count INT,
    relation_month DATE,
    PRIMARY KEY (relation_id, relation_month)
) PARTITION BY RANGE (TO_DAYS(relation_month)) (
    PARTITION p202401 VALUES LESS THAN (TO_DAYS('2024-02-01')),
    PARTITION p202402 VALUES LESS THAN (TO_DAYS('2024-03-01'))
);

这样查“2024年1月到3月谁和谁有通话”就只扫两个分区,速度很快。

4.6.3 关系的方向 vs 无向

团伙识别中,很多关系本质是无向的(同住一个地址)。但存储时还是存有向边,查询时用 UNION 或者 LEAST/GREATEST 做无向化处理。我习惯存两条有向边(A->B 和 B->A),因为这样可以复用同一种查询模式,而且能保留单向关系的可能性(比如A转账给B,但B没转回)。

我的一个习惯性操作: 建表时再加一个字段 is_directed TINYINT DEFAULT 1,明确这个关系类型是否有方向。无向关系在写入时自动补双向,但 is_directed 标记为0,查询时知道它是双向的。

4.7 关系表的索引策略

关系表最频繁的查询是:给定一个人,找出他所有关联的人。所以索引主要建在 from_person_id 和 to_person_id 上。我通常建两个独立索引:一个从 from 查,一个从 to 查。如果是按关系类型过滤,加上 relation_type 做复合索引。

CREATE INDEX idx_rel_from_type ON relation(from_person_id, relation_type);
CREATE INDEX idx_rel_to_type   ON relation(to_person_id, relation_type);

如果使用分区表,索引按照本地索引建就行,不用全局索引。MySQL分区表不支持全局索引(除了某些版本),用本地索引就好。

4.8 避坑经验总结

最后,把我在几个项目里掉过的坑总结成清单,希望能帮你省点时间。

  • 千万不要把关系属性存成逗号分隔的字符串——你会后悔的。每个属性一个字段,哪怕某些字段为空,也比解析字符串快100倍。
  • 注意处理自环(A和A自己有关系)。有些业务场景会出现一个人给自己打电话的记录,应当统一过滤掉,否则图算法会报错。
  • 关系表的主键我用自增ID, 不用业务组合键。因为关系表经常要删重写入,自增ID稳定且不受重复影响。
  • 如果关系表要支持亿级数据, 建议用TiDB或者ClickHouse这种分布式数据库,MySQL单表超过5000万条后,即使有索引也会越来越慢。

表结构设计好了,下一步才是填充数据和用SQL做特征衍生。但这一步如果没走稳,后面所有计算都会跟着跑偏。建模这件事,值得多花点时间想清楚。