基于PolarDB的图分析:通过表将数据快速导入到图

更新时间:2025-02-24 09:55:29

本文介绍在使用PolarDB PostgreSQLAGE插件时,针对大数据量快速导入数据的方法。该方法可以快速将图数据库中亿级以上的节点和边快速导入到数据库中,避免在插入边时进行查询带来的性能瓶颈。

背景

PolarDB PostgreSQL支持AGE插件进行图数据的存储管理和查询。

在进行图数据库数据的导入与导出时,通常需要将节点数据和边数据转换为CSV等表格式。然后,导入节点表,再通过边表在节点上构建边。但是在使用CypherMATCH...CREATE语句创建边时,需要查询对应的两侧节点,导致导入速度降低。针对千万级以上数据量的边进行导入时,其性能往往无法满足需求。

此处介绍了一种新的图数据导入方法。该方法通过直接生成和管理图上节点的唯一ID,省略MATCH步骤,直接写入边的记录。可以将图的导入效率提升两个数量级。这种导入方法包含以下步骤:

  1. 将图中的节点导入为数据库中的普通表。通常情况下,节点表上有一列(或多列的组合)作为其唯一性标识。

  2. 将图中的边导入为数据库中的普通表。边表上先是左侧节点的唯一性标识的列的值,然后是右侧节点的唯一性标识的列的值,最后是边自身的属性。

  3. 在节点表上添加唯一标识列,在边表上,通过JOIN或计算方式,添加起始点和终止点的唯一标识列。

  4. 通过唯一标识列将节点表导入到图。

  5. 通过唯一性标识将边表导入到图。

前提条件

支持的PolarDB PostgreSQL的版本如下:

PostgreSQL 14(内核小版本14.12.24.0及以上)。

说明

您可通过如下语句查看PolarDB PostgreSQL的内核小版本号:

SELECT version();

如需升级内核小版本,请参见升级版本

最佳实践

我们构建了一个简单的场景作为案例:

  • 包括两种类型的点,分别是v_userv_product

    • v_user:通过两列typeuid构成唯一标识,不同typeuid可能重复。

    • v_product:使用一列product_id作为唯一标识,但其是一列复杂字符串,无法转换为数值型id,需要人工创建标号。

  • 有一种类型的边,名为e_own。

  • 点和边的数据都已经导入到了名为xxx_raw的表中。

  • 将点和边都导入到名为Toys的图中。

image

导入前结构

  1. 创建插件。

    CREATE EXTENSION age;

    创建插件后,将其加入需要使用此插件的数据库或用户的搜索路径和预加载库中:

    ALTER DATABASE <dbname> SET search_path = "$user", public, ag_catalog;
    ALTER DATABASE <dbname> SET session_preload_libraries TO 'age';
  2. 创建图,包括两种节点类型和一种边类型(v代表vertex即节点, e代表edge即边)。

    ---创建图
    SELECT create_graph('toys');
    
    ---创建节点类型
    SELECT create_vlabel('toys','v_user');
    SELECT create_vlabel('toys','v_product');
    
    ---创建边类型
    SELECT create_elabel('toys','e_own');
  3. 创建节点的原始表,用于存放需导入的节点数据。

    CREATE TABLE public.v_user_raw(type text, uid text, name text, age integer);
    INSERT INTO v_user_raw VALUES
    ('A','U1', 'Alice', '33'),
    ('B','U1', 'Bob', '21');
    
    CREATE TABLE public.v_product_raw(product_id text, price double precision);
    INSERT INTO v_product_raw VALUES
    ('INAKLIDAS', '50'),
    ('ENKUCLKSD', '80'),
    ('IIUIHAKLS', '320'),
    ('SDVDSUHEE', '340');
  4. 创建边的原始表,用于存放需导入的边数据。

    CREATE TABLE public.e_own_raw(user_type text, user_uid text, product_id text, buy_price text);
    INSERT INTO e_own_raw VALUES
    ('A', 'U1', 'INAKLIDAS', '45'),
    ('B', 'U1', 'ENKUCLKSD', '70'),
    ('B', 'U1', 'INAKLIDAS', '50'),
    ('B', 'U1', 'SDVDSUHEE', '330');

创建唯一标识

  • 对于v_user节点,此处假设仅存在10种类型(AJ),因此可以采用uidtype的组合方式来创建唯一标识,并将该映射关系注册为函数。

    CREATE OR REPLACE FUNCTION v_user_idgen(type text, uid text) RETURNS bigint AS
    $$
    SELECT (ASCII(type) - ASCII('A')) + substring(uid, 2)::bigint * 10
    $$
    language SQL;

    然后在涉及的节点表和边表上添加标识列:

    ALTER TABLE v_user_raw ADD COLUMN _id bigint UNIQUE CHECK(_id < 281474976710656 AND _id>=0);
    UPDATE v_user_raw SET _id = v_user_idgen(type, uid);
    
    ALTER TABLE e_own_raw ADD COLUMN _fromid bigint CHECK(_fromid < 281474976710656 AND _fromid>=0);
    UPDATE e_own_raw SET _fromid = v_user_idgen(user_type, user_uid);
  • 对于v_product节点,由于其字符串无法直接转换为bigint类型的数值,因此采用PostgreSQLbigserial类型自动生成编号,并通过连接操作将对应的ID添加到边表中。然后对组成唯一标识的列建立索引以加速。

    说明

    如需对组成唯一标识的多列建立索引,则可使用CREATE INDEX ON v_product_raw(column1, column2,...);语法。

    ALTER TABLE v_product_raw ADD COLUMN _id bigserial UNIQUE CHECK(_id < 281474976710656 AND _id>=0);
    CREATE INDEX ON v_product_raw(product_id);
    
    ALTER TABLE e_own_raw ADD COLUMN _toid bigint CHECK(_toid < 281474976710656 AND _toid>=0);
    UPDATE e_own_raw SET _toid = v_product_raw._id FROM v_product_raw WHERE v_product_raw.product_id = e_own_raw.product_id;

以上两种方法的区别如下:

  • 将映射关系注册为函数,便于后续管理,可以继续通过此函数管理属性到节点id的映射,且速度更快。

  • 使用bigserial类型自动生成编号,则不要求原先的列可以转换为数值类型,但需要一次额外的JOIN操作。同时,后续无法使用简单函数计算出属性到节点id的映射,可能会无法进行特定的操作。

快速导入

  1. 创建辅助函数。辅助生成PolarDB PostgreSQL内部唯一id。

    CREATE OR REPLACE FUNCTION age_name_to_idx_start(graph_name text, kind_name text, label_name text)
    RETURNS bigint
    AS 'SELECT id::bigint<<48 FROM ag_catalog.ag_label WHERE kind = kind_name and name = label_name and graph = (SELECT graphid FROM ag_catalog.ag_graph WHERE name = graph_name)'
    language SQL IMMUTABLE STRICT PARALLEL SAFE;
    
    CREATE OR REPLACE FUNCTION age_name_to_seq(graph_name text, kind_name text, label_name text)
    RETURNS text
    AS 'SELECT graph_name || ''.'' || seq_name::text FROM ag_catalog.ag_label WHERE kind = kind_name and name = label_name and graph = (SELECT graphid FROM ag_catalog.ag_graph WHERE name = graph_name)'
    language SQL IMMUTABLE STRICT PARALLEL SAFE;
  2. 导入节点。

    1. 导入节点前,建议通过创建唯一约束,防止产生重复id。也可以省略此步骤。此索引也有助于后续查询,如您选择创建不必删除。

      CREATE UNIQUE INDEX ON toys.v_user(id);
      CREATE UNIQUE INDEX ON toys.v_product(id);
    2. 导入节点时,可以在_raw表中选择需要的列加入节点,而新增的_id等列可以不需要加入节点.

      INSERT INTO toys."v_user"
      SELECT (age_name_to_idx_start('toys', 'v', 'v_user') +  _id)::text::graphid,
      row_to_json((select x FROM (select type, uid, name, age) x))::text::agtype FROM v_user_raw;
      
      SELECT setval(age_name_to_seq('toys', 'v', 'v_user'), (SELECT max(_id) + 1 FROM v_user_raw));
      INSERT INTO toys."v_product"
      SELECT (age_name_to_idx_start('toys', 'v', 'v_product') + _id)::text::graphid,
      row_to_json((select x FROM (select product_id, price) x))::text::agtype FROM v_product_raw;
      
      SELECT setval(age_name_to_seq('toys', 'v', 'v_product'), (SELECT max(_id) + 1 FROM v_product_raw));
  3. 导入边。通过_fromid, _toid表生成边。可以不记录两侧节点的属性,只记录其自身的buy_price属性。

    INSERT INTO toys."e_own" 
    SELECT
    (age_name_to_idx_start('toys', 'e', 'e_own') + nextval(age_name_to_seq('toys', 'e', 'e_own')))::text::graphid,
    (age_name_to_idx_start('toys', 'v', 'v_user') + _fromid)::text::graphid,
    (age_name_to_idx_start('toys', 'v', 'v_product') + _toid)::text::graphid,
    row_to_json((select x FROM (select buy_price) x))::text::agtype
    FROM e_own_raw;

测试验证

  • 查询节点。

    • SELECT * FROM cypher('toys', $$
      MATCH (v:v_user)
      RETURN v
      $$) as (v agtype);

      返回结果如下:

                                                                  v                                                             
      --------------------------------------------------------------------------------------------------------------------------
       {"id": 844424930131978, "label": "v_user", "properties": {"age": 33, "uid": "U1", "name": "Alice", "type": "A"}}::vertex
       {"id": 844424930131979, "label": "v_user", "properties": {"age": 21, "uid": "U1", "name": "Bob", "type": "B"}}::vertex
      (2 rows)
    • SELECT * FROM cypher('toys', $$
      MATCH (v:v_product)
      RETURN v
      $$) as (v agtype);

      返回结果如下:

                                                               v                                                         
      -------------------------------------------------------------------------------------------------------------------
       {"id": 1125899906842625, "label": "v_product", "properties": {"price": "50", "product_id": "INAKLIDAS"}}::vertex
       {"id": 1125899906842626, "label": "v_product", "properties": {"price": "80", "product_id": "ENKUCLKSD"}}::vertex
       {"id": 1125899906842627, "label": "v_product", "properties": {"price": "320", "product_id": "IIUIHAKLS"}}::vertex
       {"id": 1125899906842628, "label": "v_product", "properties": {"price": "340", "product_id": "SDVDSUHEE"}}::vertex
      (4 rows)
  • 查询边。

    查找Alice购买各个产品时使用的折扣。

    SELECT buy, sell, sell::int - buy::int as discount FROM cypher('toys', $$
    MATCH (v1:v_user)-[e:e_own]-(v2:v_product)
    WHERE v1.name = 'Bob'
    RETURN e.buy_price, v2.price
    $$) as (buy agtype, sell agtype);

    返回结果如下:

      buy  | sell  | discount 
    -------+-------+----------
     "70"  | "80"  |       10
     "50"  | "50"  |        0
     "330" | "340" |       10
    (3 rows)

后续导入和更新

  • 在使用CypherCREATE语句创建过节点后,不能再使用当前快速导入方式。因为使用CREATE可能会破坏通过xxx_idgen函数或JOIN所生成唯一标识之间的对应关系。

  • 导入完成后,命名为xxx_raw的表和xxx_idgen的函数可根据需要进行删除,这不会影响图中的数据。如果后续仍希望采用此方式插入边,则可以保留这些表和函数。但需注意,在导入节点和边的过程中,应跳过已导入的节点和边,以避免创建重复的节点和边。

    • 使用当前导入方式继续插入一个节点。

      INSERT INTO v_user_raw VALUES
      ('A','U2', 'Alien', '99', v_user_idgen('A', 'U2'));
      INSERT INTO toys.v_user
      SELECT (age_name_to_idx_start('toys', 'v', 'v_user') +  _id)::text::graphid,
      row_to_json((select x FROM (select type, uid, name, age) x))::text::agtype FROM (SELECT (('A','U2', 'Alien', '97', v_user_idgen('A', 'U2'))::v_user_raw).*) raw;
      SELECT setval(age_name_to_seq('toys', 'v', 'v_user'), (SELECT max(_id) + 1 FROM v_user_raw));
      
      INSERT INTO v_product_raw(product_id, price) VALUES
      ('AIEEEEEEE', '999');
      INSERT INTO toys."v_product"
      SELECT (age_name_to_idx_start('toys', 'v', 'v_product') + _id)::text::graphid,
      row_to_json((select x FROM (select product_id, price) x))::text::agtype FROM v_product_raw WHERE product_id = 'AIEEEEEEE';
      SELECT setval(age_name_to_seq('toys', 'v', 'v_product'), (SELECT max(_id) + 1 FROM v_product_raw));
    • 使用当前导入方式继续插入一条边。需要先删除原有的边表的内容,再重新执行导入边步骤。

      DELETE FROM e_own_raw;
      
      INSERT INTO e_own_raw VALUES
      ('A', 'U2', 'AIEEEEEEE', '9999');
      UPDATE e_own_raw SET _fromid = v_user_idgen(user_type, user_uid);
      UPDATE e_own_raw SET _toid = v_product_raw._id
      FROM v_product_raw 
      WHERE v_product_raw.product_id = e_own_raw.product_id;
      
      INSERT INTO toys."e_own" 
      SELECT
      (age_name_to_idx_start('toys', 'e', 'e_own') + nextval(age_name_to_seq('toys', 'e', 'e_own')))::text::graphid,
      (age_name_to_idx_start('toys', 'v', 'v_user') + _fromid)::text::graphid,
      (age_name_to_idx_start('toys', 'v', 'v_product') + _toid)::text::graphid,
      row_to_json((select x FROM (select buy_price) x))::text::agtype
      FROM e_own_raw;

总结

通过表的方式,可以快速将图数据库外的节点和边导入到数据库内的图中,以便进行查询。相比原有的MATCH ... CREATE ...方式,通常实现性能两倍以上提升。

试用体验

您可以访问PolarDB免费试用页面,选择试用“云原生数据库PolarDB PostgreSQL版”,体验Ganos的图计算能力。

  • 本页导读 (1)
  • 背景
  • 前提条件
  • 最佳实践
  • 导入前结构
  • 创建唯一标识
  • 快速导入
  • 测试验证
  • 后续导入和更新
  • 总结
  • 试用体验