背景和简介
PolarDB PostgreSQL兼容版可以使用AGE插件进行图数据的存储,管理,和查询。
在将图数据库中的数据进行导入和导出时,我们通常需要先将节点数据和边数据转换csv等表格格式,再先导入节点表,再通过边表在节点上构建边。
但使用cypher的MATCH...CREATE
语句创建边时,需要查询对应的两侧节点,造成导入速度降低。针对千万级以上数据量的边进行导入时,往往性能无法满足需求。
本文介绍了一种新的图数据导入方式。通过直接生成和管理图上节点的唯一id,跳过MATCH
步骤,直接写入边的记录。可以将图的导入效率提升两个数量级。这种导入方法包含以下步骤:
- 将图中的节点导入为数据库中的普通表。通常,节点表上有一列(或多列的组合)作为其唯一性标识。
- 将图中的边导入为数据库中的普通表。边表上先是左侧节点的唯一性标识的列的值,然后是右侧节点的唯一性标识的列的值,最后是边自身的属性。
- 在节点表上添加唯一标识列,在边表上,通过join或计算的方式,添加起始点和终止点的唯一标识列。
- 通过唯一标识列将节点表导入到图
- 通过唯一标识将边表导入到图
作为例子,我们构造一个简单的场景:
- 有两种类型的点,分别是v_user和v_product。
- v_user通过两列type和uid构成唯一标识,不同type下uid可能重复
- v_product使用一列product_id作为唯一标识,但其是一列复杂字符串,无法转换为数值型id,需要人工创建标号
- 有一种类型的边,名为e_own。
- 点和边的数据都已经导入到了名为xxx_raw的表中。
- 需要将点和边都导入到名为toys的图中
导入前结构
- 插件创建
数据库中需要预先创建age
插件
create extension age;
在每个会话执行时,需要设置search_path并执行SQL来加载扩展:
SET search_path = ag_catalog, "$user", public; select * from get_cypher_keywords() limit 0;
如不想在每个会话中设置search_path,可对数据库进行该项操作
ALTER DATABASE xxx SET search_path = ag_catalog, "$user", public;
- 创建图,包括两种节点类型和一种边类型(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');
- 创建节点的原始表,代表要导入的节点。
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');
- 创建边的原始表,代表要导入的边。
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个type(A-J),则可以使用uid+type组合的方式创建唯一标识,这里将此映射关系注册为函数
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类型的数值,因此使用PostgreSQL的bigserial对其自动添加编号,并且通过join的方式对边表添加对应的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的映射,同时速度更快;而使用自增id的话,不要求原先的列可以转换为数值类型,但需要额外的一次join操作,同时后续无法使用简单的函数计算出属性到节点id的映射,可能会无法进行特定的操作。
快速导入
创建辅助函数
先创建下列函数,辅助生成pg内部的唯一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;
导入节点
导入节点前,最好通过创建唯一约束,防止产生重复的id。但也可以省略此步骤。此索引对后续查询也有益处,使用后不必删除。
CREATE UNIQUE INDEX ON toys.v_user(id); CREATE UNIQUE INDEX ON toys.v_product(id);
导入节点时,我们可以在_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));
导入边
通过_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)
后续导入和更新
- 在导入完成后,命名为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;
- 在使用了
CREATE
的cypher语法创建过节点后,则不能再使用本文介绍的快速导入方式,因为使用CREATE
可能会破坏通过xxx_idgen函数或JOIN所生成唯一标识之间的对应关系。
总结
本文介绍了通过表格的方式,快速将图数据库外的节点和边,导入到数据库内的图中,以便查询的方式。相比原有的MATCH ... CREATE ...
方式,性能通常有两倍以上的提升。
试用体验
欢迎访问PolarDB免费试用页面,选择试用“云原生数据库PolarDB PostgreSQL版”,体验PolarDB的图计算能力