4. 血缘存储与索引

数据血缘一旦铺开,存储就成了绕不开的坎。关系型数据库存血缘?我试过,表结构越拆越碎,JOIN 到怀疑人生。后来我彻底转向了图数据库——血缘本身就是一张图,用图存图,天经地义。

但图数据库也不是随便选一个就能用。Neo4j 和 JanusGraph 我都踩过坑,今天把选型思路、存储结构、索引优化一并说清楚。

4.1 图数据库选型:Neo4j vs JanusGraph

先给结论:小团队、单机部署、快速验证,选 Neo4j;大规模、分布式、需要强一致性,选 JanusGraph。但实际落地远没这么简单。

对比维度 Neo4j JanusGraph
部署模式 单机 / 集群(Causal Cluster) 分布式(依赖 HBase / Cassandra / Bigtable)
查询语言 Cypher(声明式,易上手) Gremlin(函数式,灵活但陡峭)
事务支持 ACID,强一致 最终一致性(取决于后端存储)
存储上限 单机数十亿节点(需大内存) 理论上无上限(水平扩展)
索引能力 内置索引 + 全文索引 依赖外部索引(Elasticsearch / Solr)
运维成本 低,开箱即用 高,需维护 HBase + ES + ZK
社区生态 成熟,文档丰富 活跃,但中文资料少

我的建议:如果血缘数据量在 10 亿条边以内,且不需要跨机房部署,直接上 Neo4j。JanusGraph 虽然能扛更大规模,但运维复杂度会吃掉你一半的开发精力。

避坑指南:我曾经在一个项目里选了 JanusGraph,结果 HBase RegionServer 频繁宕机,排查了三天才发现是 GC 参数没调好。后来切回 Neo4j,两周就上线了。不是 JanusGraph 不好,而是你的团队有没有精力伺候它。

4.2 血缘关系存储结构

图数据库里,节点和边就是一切。血缘的核心模型其实很简单:表 / 字段是节点,ETL 流转是边。但细节藏在标签和属性里。

4.2.1 节点设计

我习惯把血缘节点分成三类:

  • 表节点:标签 Table,属性包括 database、schema、table_name、update_time
  • 字段节点:标签 Column,属性包括 column_name、data_type、description
  • ETL 任务节点:标签 ETLJob,属性包括 job_name、job_type、run_frequency

举个例子,一张订单表 ods.orders 的存储结构:

// 创建表节点
CREATE (t:Table {
  database: "ods",
  schema: "public",
  table_name: "orders",
  update_time: datetime("2024-01-15T08:00:00")
})

// 创建字段节点
CREATE (c1:Column {column_name: "order_id", data_type: "bigint"})
CREATE (c2:Column {column_name: "user_id", data_type: "bigint"})
CREATE (c3:Column {column_name: "amount", data_type: "decimal(10,2)"})

// 建立字段与表的从属关系
CREATE (c1)-[:BELONGS_TO]->(t)
CREATE (c2)-[:BELONGS_TO]->(t)
CREATE (c3)-[:BELONGS_TO]->(t)

4.2.2 边设计

边才是血缘的灵魂。我定义了四种核心关系:

关系类型 说明 示例
DERIVED_FROM 字段级血缘,表示目标字段来源于源字段 dw.dwd_order.amount → ods.orders.amount
PRODUCED_BY 表由某个 ETL 任务生成 dw.dwd_order → ETLJob:order_etl
CONSUMED_BY 表被某个 ETL 任务消费 ods.orders → ETLJob:order_etl
BELONGS_TO 字段属于某张表 Column:order_id → Table:orders

注意:边一定要带属性。我习惯在 DERIVED_FROM 边上加 transform_expression 字段,记录转换逻辑(比如 amount * 1.1)。这样回溯问题时,一眼就能看出数据是怎么变的。

4.3 索引优化策略

图数据库的索引,说白了就是用空间换时间。但索引不是越多越好,我见过有人给每个属性都建索引,结果写入慢得像蜗牛。

4.3.1 Neo4j 索引策略

Neo4j 支持两种索引:

  • B-tree 索引:适合精确匹配和范围查询。比如按 table_name 查表。
  • 全文索引:适合模糊搜索。比如搜字段描述里的关键词。

我的经验是:只给查询频率最高的属性建索引。比如:

// 创建 B-tree 索引(精确匹配)
CREATE INDEX table_name_index FOR (t:Table) ON (t.table_name)
CREATE INDEX column_name_index FOR (c:Column) ON (c.column_name)

// 创建全文索引(模糊搜索)
CREATE FULLTEXT INDEX column_desc_index FOR (c:Column) ON EACH [c.description]

小技巧:如果你经常按 database + schema + table_name 组合查询,可以建复合索引:CREATE INDEX table_composite_index FOR (t:Table) ON (t.database, t.schema, t.table_name)。这样查询时能走索引覆盖,不用回表。

4.3.2 JanusGraph 索引策略

JanusGraph 的索引分两种:

  • 图索引(Graph Index):全局查询,比如“找出所有名为 amount 的字段”。
  • 顶点中心索引(Vertex-centric Index):局部查询,比如“找出某张表的所有字段”。

我踩过的一个坑:JanusGraph 默认的图索引是后台构建的,数据量大时可能几个小时才生效。所以建索引时一定要指定 build 参数:

// 创建图索引(立即构建)
mgmt.buildIndex("byTableName", Vertex.class)
    .addKey(tableNameKey)
    .buildMixedIndex("search")
    .build()

// 创建顶点中心索引(加速遍历)
mgmt.buildEdgeIndex(etlJobLabel, "byTimestamp", Direction.BOTH, Order.DESC, timestampKey)

曾经我犯过的错:在 JanusGraph 里给所有属性都建了混合索引,结果 Elasticsearch 集群直接 OOM。后来我学乖了——只给用于过滤的属性建索引,用于排序的属性用顶点中心索引。

4.4 血缘存储架构总览

说了这么多,画张图帮你理清思路。下面这张 SVG 展示了从数据源到图存储的完整链路:

血缘存储与索引架构 数据源 MySQL / Hive / Kafka 血缘解析引擎 SQL Parser / 日志解析 图数据库 Neo4j / JanusGraph 索引引擎 B-tree / Elasticsearch 查询接口 Cypher / Gremlin / REST 血缘应用 影响分析 / 溯源 / 质量监控 说明: 1. 数据源经过解析引擎,提取表/字段/ETL关系,写入图数据库 2. 索引引擎加速查询,Neo4j 用 B-tree,JanusGraph 依赖 Elasticsearch 3. 查询接口统一对外暴露,应用层无需关心底层是 Neo4j 还是 JanusGraph

4.5 实战中的索引调优

理论说完了,聊聊我在实际项目中怎么调索引的。

4.5.1 慢查询分析

Neo4j 里用 PROFILE 看查询计划:

PROFILE MATCH (t:Table {table_name: "orders"}) RETURN t

// 输出示例:
// +-----------------+----------------+----------------+------+
// | Operator        | Estimated Rows | Rows           | Hits |
// +-----------------+----------------+----------------+------+
// | NodeIndexSeek   | 1              | 1              | 1    |
// +-----------------+----------------+----------------+------+

如果看到 NodeByLabelScan,说明没走索引,赶紧补上。

4.5.2 索引维护

我每周跑一次索引健康检查:

  • 检查索引大小是否异常增长
  • 检查索引命中率(低于 80% 的考虑删除)
  • 检查是否有重复索引

我的习惯:在 Neo4j 里用 :schema 命令查看所有索引和约束。如果发现某个索引从未被使用,果断删掉——它只会拖慢写入速度。

4.5.3 写入性能优化

批量写入血缘数据时,我踩过一个大坑:每插入一条边就提交一次事务,结果写入速度只有 200 条/秒。后来改成每 1000 条边提交一次,速度直接飙到 5000 条/秒。

// 批量写入示例(Python + Neo4j Driver)
with driver.session() as session:
    batch = []
    for edge in edges:
        batch.append(edge)
        if len(batch) >= 1000:
            session.run("""
                UNWIND $batch AS row
                MATCH (src:Column {column_name: row.src})
                MATCH (tgt:Column {column_name: row.tgt})
                CREATE (src)-[:DERIVED_FROM {transform: row.transform}]->(tgt)
            """, batch=batch)
            batch.clear()
    // 处理剩余数据
    if batch:
        session.run(...)

注意:批量写入时别忘了关掉自动索引。Neo4j 在批量插入时会自动维护索引,如果数据量太大,索引重建会拖垮性能。我一般先删索引,插完再重建。

好了,关于血缘存储和索引,该说的都说了。选型没有银弹,存储结构要贴合业务,索引优化要持续监控。记住一句话:图数据库不是万能药,用对了是利器,用错了是累赘。