讓我們用 SQL 開(kāi)發(fā)一個(gè)圖形數(shù)據(jù)庫(kù)吧!
作者: 不剪發(fā)的Tony老師
畢業(yè)于北京航空航天大學(xué),十多年數(shù)據(jù)庫(kù)管理與開(kāi)發(fā)經(jīng)驗(yàn),目前在一家全球性的金融公司從事數(shù)據(jù)庫(kù)架構(gòu)設(shè)計(jì)。CSDN學(xué)院簽約講師以及GitChat專(zhuān)欄作者。csdn上的博客收藏于以下地址:https://tonydong.blog.csdn.net
文章目錄
設(shè)計(jì)表結(jié)構(gòu)
實(shí)現(xiàn)圖查詢(xún)
插入數(shù)據(jù)
圖的遍歷
最短距離
索引優(yōu)化
大家好!我是只談技術(shù)不剪發(fā)的 Tony 老師。
圖形數(shù)據(jù)庫(kù)(Graph Database)是 NoSQL 數(shù)據(jù)庫(kù)的一種,使用圖結(jié)構(gòu)來(lái)存儲(chǔ)、表示、處理和查詢(xún)數(shù)據(jù)。圖是節(jié)點(diǎn)(Node)或者頂點(diǎn)(Vertice)和連接(Link)或者邊緣(Edge)的集合。節(jié)點(diǎn)代表了一個(gè)實(shí)體(人、物體等),連接代表了兩個(gè)節(jié)點(diǎn)之間的聯(lián)系(好友、愛(ài)好等)。圖形數(shù)據(jù)庫(kù)非常適合社交關(guān)系分析、金融欺詐檢測(cè)、知識(shí)圖譜等領(lǐng)域,Neo4j 是著名的圖形數(shù)據(jù)庫(kù)。
除了使用專(zhuān)門(mén)的圖形數(shù)據(jù)庫(kù)之外,如今主流的關(guān)系型數(shù)據(jù)庫(kù)都提供了 JSON 文檔存儲(chǔ)功能以及通用表表達(dá)式(WITH 子句)的支持。我們完全可以基于這些功能實(shí)現(xiàn)一個(gè)簡(jiǎn)單的圖形數(shù)據(jù)庫(kù),同時(shí)可以使用 SQL 語(yǔ)句對(duì)圖形進(jìn)行操作和查詢(xún)。
如果你覺(jué)得這篇文章有用,歡迎關(guān)注??、評(píng)論??、點(diǎn)贊??!
設(shè)計(jì)表結(jié)構(gòu)
圖要由兩個(gè)結(jié)構(gòu)組成:節(jié)點(diǎn)和邊緣。
節(jié)點(diǎn)代表了一個(gè)實(shí)體對(duì)象,例如人、地點(diǎn)、事物等。節(jié)點(diǎn)可以擁有屬性,例如人的姓名、性別等。一個(gè)節(jié)點(diǎn)對(duì)應(yīng)了關(guān)系型數(shù)據(jù)庫(kù)中的一行數(shù)據(jù)或者文檔數(shù)據(jù)庫(kù)中的一個(gè)文檔。
邊緣代表了兩個(gè)對(duì)象之間的關(guān)系,例如某人住在某地。邊緣也可以擁有屬性,例如何時(shí)開(kāi)始住在某地。一個(gè)邊緣對(duì)應(yīng)了關(guān)系型數(shù)據(jù)庫(kù)中的一個(gè)外鍵記錄或者文檔數(shù)據(jù)庫(kù)中的一個(gè)鏈接 id。
基于圖的結(jié)構(gòu),我們可以在關(guān)系型數(shù)據(jù)庫(kù)中創(chuàng)建兩個(gè)表:node 和 edge。它們的 ERD 如下圖所示:
創(chuàng)建以上表結(jié)構(gòu)的 SQL 腳本如下(MySQL 語(yǔ)法):
-- MySQL
CREATE TABLE IF NOT EXISTS node (
node_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
properties JSON
);
CREATE TABLE IF NOT EXISTS edge (
edge_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
source_id BIGINT NOT NULL,
target_id BIGINT NOT NULL,
properties JSON,
FOREIGN KEY(source_id) REFERENCES node(node_id),
FOREIGN KEY(target_id) REFERENCES node(node_id)
);
CREATE INDEX idx_edge_source_id ON edge(source_id);
CREATE INDEX idx_edge_target_id ON edge(target_id);
對(duì)于節(jié)點(diǎn)表 node,我們創(chuàng)建了一個(gè)自增主鍵 node_id,以及一個(gè)存儲(chǔ)節(jié)點(diǎn)屬性的 JSON 字段 properties。對(duì)于邊緣表 edge,我們創(chuàng)建了一個(gè)自增主鍵 edge_id,表示關(guān)系起點(diǎn)的字段 source_id 和表示關(guān)系終點(diǎn)的字段 target_id,以及一個(gè)存儲(chǔ)關(guān)系屬性的 JSON 字段 properties。
同時(shí),為了數(shù)據(jù)操作和提高查詢(xún)性能,我們創(chuàng)建了兩個(gè)索引 idx_edge_source_id 和 idx_edge_target_id。
??完整的表結(jié)構(gòu)和查詢(xún)腳本可以從 GitHub 或者 CODE CHINA 下載。除了 MySQL 之外,我們還提供了 Oracle、Microsoft SQL Server、PostgreSQL 以及 SQLite 版本。
實(shí)現(xiàn)圖查詢(xún)
插入數(shù)據(jù)
接下來(lái)我們使用 SQL 語(yǔ)句插入一些測(cè)試數(shù)據(jù),首先插入幾個(gè)節(jié)點(diǎn):
INSERT INTO node(properties) VALUES('{"Label":"Person", "Name":"張三", "Age": 22}');
INSERT INTO node(properties) VALUES('{"Label":"Person", "Name":"李四", "Age": 30}');
INSERT INTO node(properties) VALUES('{"Label":"Person", "Name":"王五", "Age": 28}');
以上語(yǔ)句插入了 3 個(gè)節(jié)點(diǎn),它們的 Label 都是 Person,擁有姓名和年齡屬性。
然后我們?cè)俳⒁恍╆P(guān)系:
INSERT INTO edge(source_id, target_id, properties)
VALUES((SELECT node_id FROM node WHERE json_value(properties, '$.Name')='張三'),
(SELECT node_id FROM node WHERE json_value(properties, '$.Name')='李四'), '{"Label":"關(guān)注", "Degree": 80}');
INSERT INTO edge(source_id, target_id, properties)
VALUES((SELECT node_id FROM node WHERE json_value(properties, '$.Name')='李四'),
(SELECT node_id FROM node WHERE json_value(properties, '$.Name')='王五'), '{"Label":"關(guān)注", "Degree": 90}');
INSERT INTO edge(source_id, target_id, properties)
VALUES((SELECT node_id FROM node WHERE json_value(properties, '$.Name')='張三'),
(SELECT node_id FROM node WHERE json_value(properties, '$.Name')='王五'), '{"Label":"關(guān)注", "Degree": 60}');
其中,json_value 是 MySQL 提供的 JSON 函數(shù),用于提取 JSON 文檔中的元素。
以上測(cè)試數(shù)據(jù)建立的關(guān)系圖如下所示:
對(duì)于節(jié)點(diǎn)和邊緣的查詢(xún)和普通表類(lèi)似,例如:
SELECT properties FROM node WHERE json_value(properties, '$.Name')='張三';
properties |
--------------------------------------------+
{"Age": 22, "Name": "張三", "Label": "Person"}|
SELECT * FROM edge WHERE source_id = 1 AND target_id = 2;
edge_id|source_id|target_id|properties |
-------+---------+---------+-----------------------------+
1| 1| 2|{"Label": "關(guān)注", "Degree": 80}|
圖的遍歷
我們從節(jié)點(diǎn)“張三”出發(fā),遍歷所有的節(jié)點(diǎn):
WITH RECURSIVE traverse(id, relation, hops) AS (
SELECT node_id, cast(json_value(properties, '$.Name') AS CHAR(100)), 0
FROM node
WHERE json_value(properties, '$.Name')='張三'
UNION ALL
SELECT target_id, CONCAT(relation,'->',json_value(n.properties, '$.Name')), hops + 1
FROM edge e
JOIN traverse t ON e.source_id = t.id
JOIN node n ON e.target_id = n.node_id
)
SELECT id, relation, hops
FROM traverse;
id|relation |hops|
--+---------------+----+
1|張三 | 0|
2|張三->李四 | 1|
3|張三->王五 | 1|
3|張三->李四->王五| 2|
首先,我們定義了一個(gè)遞歸形式的通用表表達(dá)式 traverse,它由兩部分組成,使用 UNION ALL 進(jìn)行組合。第一個(gè) SELECT 語(yǔ)句返回了起點(diǎn)“張三”,第二個(gè) SELECT 語(yǔ)句引用了 traverse 自身,通過(guò)不停的迭代返回所有連接的節(jié)點(diǎn)。字段 hops 代表了節(jié)點(diǎn)之間的跳躍次數(shù)。最后的 SELECT 語(yǔ)句返回了全部的節(jié)點(diǎn)信息。
??關(guān)于通用表表達(dá)式的語(yǔ)法和使用案例,可以參考這篇文章和這篇文章。
從遍歷的結(jié)果可以看出,MySQL 默認(rèn)采用的是廣度優(yōu)先搜索方法。如果想要實(shí)現(xiàn)深度優(yōu)先搜索,可以使用 ORDER BY 排序:
WITH RECURSIVE traverse(id, relation, hops) AS (
SELECT node_id, cast(json_value(properties, '$.Name') AS CHAR(100)), 0
FROM node
WHERE json_value(properties, '$.Name')='張三'
UNION ALL
SELECT target_id, CONCAT(relation,'->',json_value(n.properties, '$.Name')), hops + 1
FROM edge e
JOIN traverse t ON e.source_id = t.id
JOIN node n ON e.target_id = n.node_id
)
SELECT id, relation, hops
FROM traverse
ORDER BY relation;
id|relation |hops|
--+---------------+----+
1|張三 | 0|
2|張三->李四 | 1|
3|張三->李四->王五| 2|
3|張三->王五 | 1|
最短距離
基于以上圖的遍歷,我們可以很容易地找出兩個(gè)節(jié)點(diǎn)之間的最短距離。例如,以下語(yǔ)句可以找出“張三”和“王五”之間的最短距離:
WITH RECURSIVE traverse(id, relation, hops) AS (
SELECT node_id, cast(json_value(properties, '$.Name') AS CHAR(100)), 0
FROM node
WHERE json_value(properties, '$.Name')='張三'
UNION ALL
SELECT target_id, CONCAT(relation,'->',json_value(n.properties, '$.Name')), hops + 1
FROM edge e
JOIN traverse t ON e.source_id = t.id
JOIN node n ON e.target_id = n.node_id
)
SELECT id, relation, hops
FROM traverse
JOIN node ON id = node_id
WHERE json_value(properties, '$.Name')='王五'
ORDER BY hops
LIMIT 1;
id|relation |hops|
--+-----------+----+
3|張三->王五 | 1|
索引優(yōu)化
我們使用 EXPLAIN 命令查看一下最短距離搜索語(yǔ)句的執(zhí)行計(jì)劃:
EXPLAIN
WITH RECURSIVE traverse(id, relation, hops) AS (
SELECT node_id, cast(json_value(properties, '$.Name') AS CHAR(100)), 0
FROM node
WHERE json_value(properties, '$.Name')='張三'
UNION ALL
SELECT target_id, CONCAT(relation,'->',json_value(n.properties, '$.Name')), hops + 1
FROM edge e
JOIN traverse t ON e.source_id = t.id
JOIN node n ON e.target_id = n.node_id
)
SELECT id, relation, hops
FROM traverse
JOIN node ON id = node_id
WHERE json_value(properties, '$.Name')='王五'
ORDER BY hops
LIMIT 1;
id|select_type|table |partitions|type
|possible_keys |key
|key_len|ref
|rows|filtered|Extra |
--+-----------+----------+----------+------+-------------------------------------+------------------+-------+----------------+----+--------+------------------------------------------+
1|PRIMARY |<derived2>| |ALL
| | |
| | 6| 100.0|Using temporary; Using
filesort |
1|PRIMARY |node | |ALL
|PRIMARY | |
| | 3| 50.0|Using where; Using join buffer (hash
join)|
2|DERIVED |node | |ALL
| | |
| | 3| 100.0|Using
where |
3|UNION |t
| |ALL |
| | | | 3| 100.0|Recursive;
Using where |
3|UNION |e |
|ref |idx_edge_source_id,idx_edge_target_id|idx_edge_source_id|8
|t.id | 1|
100.0| |
3|UNION
|n | |eq_ref|PRIMARY
|PRIMARY |8 |hrdb.e.target_id| 1|
100.0| |
由于 node 表的 properties 字段中的 Name 元素缺少合適的索引,查詢(xún)使用了大量的全表掃描,如果圖中的節(jié)點(diǎn)數(shù)量很多時(shí)性能比較差。對(duì)此,我們可以使用數(shù)據(jù)庫(kù)的函數(shù)索引為 JSON 文檔中的節(jié)點(diǎn)數(shù)據(jù)創(chuàng)建索引,從而提高訪問(wèn)性能。例如:
CREATE INDEX idx_node_name ON node ( (json_value(properties, '$.Name')) );
1
執(zhí)行以上命令創(chuàng)建索引之后,我們?cè)俅尾榭磮?zhí)行計(jì)劃:
id|select_type|table |partitions|type
|possible_keys |key
|key_len|ref
|rows|filtered|Extra |
--+-----------+----------+----------+------+-------------------------------------+-------------+-------+-----------------+----+--------+------------------------------------------+
1|PRIMARY |node | |ref
|PRIMARY,idx_node_name |idx_node_name|2051
|const | 1| 100.0|Using temporary; Using
filesort |
1|PRIMARY |<derived2>|
|ref |<auto_key0> |<auto_key0>
|9 |hrdb.node.node_id| 2|
100.0| |
2|DERIVED
|node | |ref |idx_node_name
|idx_node_name|2051 |const | 1|
100.0| |
3|UNION
|t | |ALL |
| | | | 2|
100.0|Recursive |
3|UNION
|e | |ALL
|idx_edge_source_id,idx_edge_target_id| |
| | 2| 50.0|Using where; Using join buffer (hash
join)|
3|UNION |n |
|eq_ref|PRIMARY |PRIMARY |8
|hrdb.e.target_id | 1|
100.0| |