讓我們用 SQL 開發(fā)一個圖形數(shù)據(jù)庫吧!

作者: 不剪發(fā)的Tony老師
畢業(yè)于北京航空航天大學(xué),十多年數(shù)據(jù)庫管理與開發(fā)經(jīng)驗,目前在一家全球性的金融公司從事數(shù)據(jù)庫架構(gòu)設(shè)計。CSDN學(xué)院簽約講師以及GitChat專欄作者。csdn上的博客收藏于以下地址:https://tonydong.blog.csdn.net



文章目錄

        設(shè)計表結(jié)構(gòu)
        實現(xiàn)圖查詢
            插入數(shù)據(jù)
            圖的遍歷
            最短距離
        索引優(yōu)化

大家好!我是只談技術(shù)不剪發(fā)的 Tony 老師。

圖形數(shù)據(jù)庫(Graph Database)是 NoSQL 數(shù)據(jù)庫的一種,使用圖結(jié)構(gòu)來存儲、表示、處理和查詢數(shù)據(jù)。圖是節(jié)點(Node)或者頂點(Vertice)和連接(Link)或者邊緣(Edge)的集合。節(jié)點代表了一個實體(人、物體等),連接代表了兩個節(jié)點之間的聯(lián)系(好友、愛好等)。圖形數(shù)據(jù)庫非常適合社交關(guān)系分析、金融欺詐檢測、知識圖譜等領(lǐng)域,Neo4j 是著名的圖形數(shù)據(jù)庫。

除了使用專門的圖形數(shù)據(jù)庫之外,如今主流的關(guān)系型數(shù)據(jù)庫都提供了 JSON 文檔存儲功能以及通用表表達式(WITH 子句)的支持。我們完全可以基于這些功能實現(xiàn)一個簡單的圖形數(shù)據(jù)庫,同時可以使用 SQL 語句對圖形進行操作和查詢。

如果你覺得這篇文章有用,歡迎關(guān)注??、評論??、點贊??!
設(shè)計表結(jié)構(gòu)

圖要由兩個結(jié)構(gòu)組成:節(jié)點和邊緣。

節(jié)點代表了一個實體對象,例如人、地點、事物等。節(jié)點可以擁有屬性,例如人的姓名、性別等。一個節(jié)點對應(yīng)了關(guān)系型數(shù)據(jù)庫中的一行數(shù)據(jù)或者文檔數(shù)據(jù)庫中的一個文檔。

邊緣代表了兩個對象之間的關(guān)系,例如某人住在某地。邊緣也可以擁有屬性,例如何時開始住在某地。一個邊緣對應(yīng)了關(guān)系型數(shù)據(jù)庫中的一個外鍵記錄或者文檔數(shù)據(jù)庫中的一個鏈接 id。

基于圖的結(jié)構(gòu),我們可以在關(guān)系型數(shù)據(jù)庫中創(chuàng)建兩個表:node 和 edge。它們的 ERD 如下圖所示:















 

創(chuàng)建以上表結(jié)構(gòu)的 SQL 腳本如下(MySQL 語法):

-- 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);

對于節(jié)點表 node,我們創(chuàng)建了一個自增主鍵 node_id,以及一個存儲節(jié)點屬性的 JSON 字段 properties。對于邊緣表 edge,我們創(chuàng)建了一個自增主鍵 edge_id,表示關(guān)系起點的字段 source_id 和表示關(guān)系終點的字段 target_id,以及一個存儲關(guān)系屬性的 JSON 字段 properties。

同時,為了數(shù)據(jù)操作和提高查詢性能,我們創(chuàng)建了兩個索引 idx_edge_source_id 和 idx_edge_target_id。

    ??完整的表結(jié)構(gòu)和查詢腳本可以從 GitHub 或者 CODE CHINA 下載。除了 MySQL 之外,我們還提供了 Oracle、Microsoft SQL Server、PostgreSQL 以及 SQLite 版本。

實現(xiàn)圖查詢
插入數(shù)據(jù)

接下來我們使用 SQL 語句插入一些測試數(shù)據(jù),首先插入幾個節(jié)點:

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}');

 

以上語句插入了 3 個節(jié)點,它們的 Label 都是 Person,擁有姓名和年齡屬性。

然后我們再建立一些關(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 文檔中的元素。

以上測試數(shù)據(jù)建立的關(guān)系圖如下所示:

 





























對于節(jié)點和邊緣的查詢和普通表類似,例如:

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é)點“張三”出發(fā),遍歷所有的節(jié)點:

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|

 
 

首先,我們定義了一個遞歸形式的通用表表達式 traverse,它由兩部分組成,使用 UNION ALL 進行組合。第一個 SELECT 語句返回了起點“張三”,第二個 SELECT 語句引用了 traverse 自身,通過不停的迭代返回所有連接的節(jié)點。字段 hops 代表了節(jié)點之間的跳躍次數(shù)。最后的 SELECT 語句返回了全部的節(jié)點信息。

    ??關(guān)于通用表表達式的語法和使用案例,可以參考這篇文章和這篇文章。

從遍歷的結(jié)果可以看出,MySQL 默認采用的是廣度優(yōu)先搜索方法。如果想要實現(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|

 

最短距離

基于以上圖的遍歷,我們可以很容易地找出兩個節(jié)點之間的最短距離。例如,以下語句可以找出“張三”和“王五”之間的最短距離:

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 命令查看一下最短距離搜索語句的執(zhí)行計劃:

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 元素缺少合適的索引,查詢使用了大量的全表掃描,如果圖中的節(jié)點數(shù)量很多時性能比較差。對此,我們可以使用數(shù)據(jù)庫的函數(shù)索引為 JSON 文檔中的節(jié)點數(shù)據(jù)創(chuàng)建索引,從而提高訪問性能。例如:

CREATE INDEX idx_node_name ON node ( (json_value(properties, '$.Name')) );

    1

執(zhí)行以上命令創(chuàng)建索引之后,我們再次查看執(zhí)行計劃:

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|                                          |