Graph 2 — Mô hình hoá đồ thị

13 thg 7, 2026 2 lượt xem
#sql
#data-modeling
#property-graph
#graph-database
#neo4j

Graph 2 — Mô hình hoá đồ thị

Bài tổng quan đã đặt vấn đề: có những câu hỏi ngân hàng — "hai tài khoản này cách nhau mấy bước chuyển tiền?", "vòng tiền khép kín nào đi qua 5 tài khoản rồi quay về?", "bao nhiêu khách hàng dùng chung một thiết bị?" — mà mô hình quan hệ trả lời rất đắt vì phải JOIN đệ quy. Đồ thị giải quyết gọn, nhưng chỉ khi mô hình dữ liệu được thiết kế đúng. Bài này là bài quan trọng nhất của series: nếu mô hình sai, mọi Cypher viết sau đều gượng ép và mọi thuật toán chạy sau đều cho kết quả vô nghĩa.

Ta dùng property graph — mô hình dữ liệu của Neo4j — gồm ba thành phần: node (đỉnh, thực thể), relationship (cạnh, quan hệ có hướng) và property (cặp key–value gắn trên cả node lẫn relationship). Ngoài ra node có label (nhãn phân loại) và relationship có type (loại). Toàn bộ nghệ thuật mô hình hoá là quyết định: một mảnh dữ liệu nên là node, là relationship, hay là property?

Nguyên tắc cốt lõi: danh từ vs động từ

Quy tắc khởi đầu đơn giản và hiệu quả một cách đáng ngạc nhiên:

  • Danh từ → node. Thực thể có bản sắc riêng, tồn tại độc lập, có thể được nhiều thứ khác trỏ tới: Customer, Account, Transaction, Merchant, Device, Address, Phone.
  • Động từ / quan hệ → relationship. Cách hai thực thể liên kết với nhau: OWNS (khách sở hữu tài khoản), SENT / RECEIVED (tài khoản gửi/nhận tiền), USES_DEVICE (khách dùng thiết bị), SHARES_ADDRESS (cùng địa chỉ).
  • Tính từ / thuộc tính mô tả → property. Dữ liệu bổ nghĩa cho một node hoặc relationship nhưng không tồn tại độc lập: balance, currency của Account; amount, timestamp của một lần chuyển tiền.

Quy ước đặt tên trong cộng đồng Neo4j: label viết PascalCase (Customer), relationship type viết UPPER_SNAKE_CASE (USES_DEVICE), property viết camelCase (accountNo, createdAt). Nhất quán tên gọi quan trọng hơn việc chọn quy ước nào.

Ba câu hỏi để phân loại

Khi phân vân một mảnh dữ liệu nên là gì, tự hỏi:

  1. Có thứ khác cần trỏ tới nó không? Nếu nhiều node cần liên kết tới cùng một giá trị (nhiều khách cùng một địa chỉ, nhiều tài khoản cùng một thiết bị), giá trị đó nên là node — để chia sẻ và duyệt được. Nếu chỉ một node dùng, để là property.
  2. Ta có cần đi (traverse) qua nó không? Nếu câu hỏi nghiệp vụ là "tìm các khách hàng chung địa chỉ", address phải là node để đi từ Customer → Address → Customer. Nếu chỉ để hiển thị, để làm property.
  3. Nó mô tả một liên kết hay một thực thể? amount mô tả một lần gửi tiền (liên kết giữa hai account) → property của relationship. balance mô tả trạng thái của account → property của node.

Ví dụ kinh điển: địa chỉ. Nếu chỉ để in ra hồ sơ khách, address là property của Customer. Nhưng trong bài toán gian lận, "nhiều khách khai cùng địa chỉ" là tín hiệu đáng ngờ — lúc đó Address phải là node riêng, để (:Customer)-[:LIVES_AT]->(:Address)<-[:LIVES_AT]-(:Customer) trở thành một pattern duyệt được. Cùng một trường dữ liệu, mô hình khác nhau tuỳ câu hỏi bạn cần trả lời — đây là tư duy query-first mà ta sẽ quay lại ở cuối bài.

Hướng và property của relationship

Mọi relationship trong property graph đều có hướng (directed): luôn có node nguồn và node đích. Tuy vậy, Neo4j lưu quan hệ theo cả hai chiều ở tầng vật lý, nên khi truy vấn bạn có thể đi ngược hướng mà không mất hiệu năng — điều đó cho phép mô hình theo hướng ngữ nghĩa tự nhiên nhất rồi truy vấn theo chiều nào cũng được.

Chọn hướng theo ngữ nghĩa: (:Customer)-[:OWNS]->(:Account) đọc xuôi là "khách sở hữu tài khoản". Với chuyển tiền, hướng chính là dòng tiền: (:Account)-[:SENT]->(:Account). Đừng tạo cả SENT lẫn RECEIVED giữa cùng cặp account cho cùng một giao dịch — đó là dữ liệu trùng; một quan hệ SENT có hướng đã đủ, muốn "tài khoản nào nhận" chỉ cần đi ngược chiều.

Relationship mang property là điểm mạnh của property graph mà mô hình quan hệ không có tương đương trực tiếp. Một lần chuyển tiền không chỉ là "có liên kết" mà còn có amount, timestamp, channel, currency:

// Mô tả (không phải SQL chạy được)
(a:Account {accountNo: '011...'})
  -[:SENT {amount: 50000000, currency: 'VND', txnAt: datetime('2026-06-30T09:15:00'), channel: 'IB'}]->
(b:Account {accountNo: '021...'})

Property trên relationship trả lời được ngay các câu hỏi như "tổng tiền A gửi cho B trong tháng", "cạnh nào có số tiền vừa dưới ngưỡng báo cáo 300 triệu" — mà không cần bảng trung gian.

Label và phân loại node

Label là nhãn gắn cho node để phân loại và đánh index. Một node có thể mang nhiều label cùng lúc, dùng để biểu diễn phân loại chồng lấn:

  • (:Account:Savings) — tài khoản tiết kiệm; (:Account:Current) — tài khoản thanh toán. Truy vấn MATCH (a:Account) bắt cả hai; MATCH (a:Savings) chỉ bắt loại tiết kiệm.
  • (:Customer:HighRisk) — đánh dấu khách rủi ро cao mà không cần đổi cấu trúc.
  • (:Account:Frozen) — tài khoản bị phong toả, gắn/gỡ label linh hoạt.

Label khác property ở chỗ: Neo4j đánh index và tối ưu duyệt theo label rất hiệu quả, còn lọc theo property là quét. Vì vậy giá trị phân loại có số lượng hữu hạn và hay dùng để lọc nên cân nhắc làm label (status, type), còn giá trị mô tả tự do (balance, openedAt) để làm property.

Cảnh báo: đừng lạm dụng nhiều label để mã hoá dữ liệu động. Đừng tạo label Balance_50M — đó là property. Label là loại, property là giá trị.

Chuyển từ mô hình quan hệ sang đồ thị

Phần lớn dữ liệu ngân hàng đang nằm trong CSDL quan hệ (xem kiến trúc PostgreSQL). Có một bộ quy tắc chuyển đổi khá cơ học từ mô hình ER (Entity–Relationship) sang property graph:

Trong mô hình quan hệTrong property graph
Một dòng của bảng thực thể (customers, accounts)Một node, label = tên bảng số ít
Cột thường của dòng đóProperty của node
Khoá ngoại (FK) 1–N (accounts.customer_id → customers.id)Một relationship (:OWNS) từ node cha tới node con
Bảng nối (junction table) cho quan hệ N–NMột relationship; các cột phụ của bảng nối → property của relationship
Bảng nối có bản sắc riêng (nhiều FK, cần nối tiếp)Một node trung gian, không phải relationship

Ví dụ cụ thể. Trong CSDL quan hệ của sandbox ta có customers, accounts, transactions với accounts.customer_id → customers.idtransactions.account_id → accounts.id. Chuyển sang đồ thị:

  • Mỗi dòng customers → node (:Customer {id, fullName, city, createdAt}).
  • Mỗi dòng accounts → node (:Account {id, accountNo, balance, currency}).
  • FK accounts.customer_id → quan hệ (:Customer)-[:OWNS]->(:Account).
  • transactions — chỗ này cần quyết định, xem phần sau.

Một cách để hình dung số dòng trong bảng nguồn trước khi migrate — ví dụ đếm khách theo thành phố để ước lượng số node Customer:

-- ▶ Chạy được
SELECT city, COUNT(*) AS num_customers
FROM customers
GROUP BY city
ORDER BY num_customers DESC;

Việc chuyển FK thành relationship là chỗ đồ thị "trả nợ" cho quan hệ: cái mà SQL phải diễn đạt bằng JOIN ... ON mỗi lần truy vấn thì đồ thị hiện thực hoá sẵn thành cạnh vật lý, duyệt bằng con trỏ thay vì tra index. Đó là lý do đường đi nhiều bước (multi-hop) nhanh hơn hẳn.

Sơ đồ: từ ER sang property graph

Điểm mấu chốt trong sơ đồ: bảng transactions không nhất thiết thành node. Nếu giao dịch chỉ là "A chuyển cho B", nó có thể thành cạnh SENT mang property amount, txnAt — gọn hơn và duyệt dòng tiền tự nhiên hơn.

Mô hình hoá giao dịch: node hay relationship?

Đây là quyết định thiết kế quan trọng nhất trong đồ thị ngân hàng, và câu trả lời là "tuỳ" — nhưng có tiêu chí rõ ràng.

Giao dịch là relationship khi nó thuần tuý nối hai bên và ta chủ yếu quan tâm dòng tiền:

// Mô tả — giao dịch như một cạnh
(a:Account)-[:SENT {amount, currency, txnAt}]->(b:Account)

Ưu điểm: mô hình gọn, ít node, duyệt chuỗi chuyển tiền A→B→C→… rất tự nhiên và nhanh. Phù hợp bài toán truy vết dòng tiền, phát hiện vòng khép kín (cycle).

Giao dịch là node khi nó là một thực thể có nhiều bên tham gia và nhiều thuộc tính cần liên kết tiếp:

// Mô tả — giao dịch như một node bậc trung (reified)
(a:Account)-[:ORIGINATED]->(t:Transaction {amount, txnAt, status})-[:CREDITED]->(b:Account),
(t)-[:AT_MERCHANT]->(m:Merchant),
(t)-[:VIA_DEVICE]->(d:Device)

Ưu điểm: giao dịch giờ nối được với Merchant (điểm bán), Device (thiết bị thực hiện), Location, Channel… — những thứ mà mô hình cạnh không gắn được vì cạnh chỉ nối đúng hai node. Kỹ thuật "biến quan hệ nhiều bên thành node trung gian" gọi là reification.

Tiêu chí quyết định:

Yếu tốNghiêng về relationshipNghiêng về node
Số bên tham giaĐúng 2 (gửi → nhận)Nhiều (bên gửi, bên nhận, merchant, device)
Cần nối thêm thực thể khácKhôngCó (merchant, thiết bị, thẻ)
Loại giao dịchChuyển tiền P2PMua hàng, thanh toán qua POS/thẻ
Ưu tiênDuyệt dòng tiền cực nhanhPhân tích ngữ cảnh giàu chiều

Thực tế trong một đồ thị chống gian lận, ta thường dùng cả hai: giao dịch chuyển tiền nội bộ dạng cạnh SENT để truy vết dòng tiền nhanh, còn giao dịch qua thẻ/POS dạng node Transaction nối tới MerchantDevice. Không có "một mô hình đúng cho mọi thứ".

Các anti-pattern cần tránh

1. Super node (node siêu bậc). Một node có quá nhiều quan hệ (hàng trăm nghìn tới hàng triệu cạnh) — ví dụ node Bank mà mọi Account đều BELONGS_TO, hay node Merchant "Ví điện tử X" mà hàng triệu giao dịch trỏ tới. Khi duyệt qua super node, engine phải quét khối lượng cạnh khổng lồ, giết hiệu năng. Cách xử lý: chia nhỏ (phân theo chi nhánh, theo tháng), hoặc dùng property lọc trên cạnh, hoặc mô hình lại để tránh phải duyệt qua super node. Trong ngân hàng, các tài khoản trung gian (tài khoản tổng, tài khoản treo) rất dễ trở thành super node — cần chú ý khi thiết kế.

2. Node phình to (nhồi property). Nhồi hàng chục property, hay cả một mảng JSON lớn, vào một node. Node nặng làm chậm cả những truy vấn không cần các property đó (Neo4j đọc cả node). Nếu một cụm property thường được truy vấn cùng nhau và tách rời phần còn lại, cân nhắc tách thành node liên kết.

3. Nhầm lẫn cấp độ: dữ liệu thành label / label thành property. Tạo label động kiểu Account_VND, Account_USD (currency là property); hoặc ngược lại, để trạng thái hay-lọc như frozen thành property khi nó xứng đáng làm label để index. Ranh giới: loại → label, giá trị → property.

4. Sao chép nguyên xi lược đồ quan hệ. Biến mọi bảng — kể cả bảng nối thuần tuý — thành node, giữ nguyên khoá ngoại thành property thay vì thành cạnh. Kết quả là một "đồ thị" mà vẫn phải join thủ công bằng property — mất hết lợi thế. Bảng nối N–N không có dữ liệu riêng nên thành relationship, không phải node.

5. Trùng lặp cạnh và property vô nghĩa. Tạo cả SENT lẫn RECEIVED cho cùng một giao dịch, hoặc gắn createdAt lên mọi node "cho chắc" dù không bao giờ truy vấn. Mỗi property và mỗi cạnh đều tốn lưu trữ và làm node nặng hơn.

Query-first: mô hình theo mẫu truy vấn

Nguyên tắc bao trùm: mô hình đồ thị nên được lái bởi các câu hỏi bạn sẽ hỏi, không phải bởi cấu trúc dữ liệu nguồn. Đây là khác biệt tư duy lớn nhất so với thiết kế CSDL quan hệ (vốn hướng chuẩn hoá — normalization — để tránh trùng lặp).

Quy trình query-first thực dụng:

  1. Liệt kê các câu hỏi nghiệp vụ trước tiên: "hai tài khoản cách nhau mấy bước?", "khách nào chung thiết bị với khách đã bị gắn cờ?", "có vòng tiền nào quay về điểm xuất phát trong 24 giờ?".
  2. Vẽ pattern Cypher mà mỗi câu hỏi cần đi qua: (:Customer)-[:USES_DEVICE]->(:Device)<-[:USES_DEVICE]-(:Customer).
  3. Từ các pattern đó suy ra node, label, relationship type, và property nào phải nằm trên cạnh nào để pattern chạy được.
  4. Chấp nhận một chút phi chuẩn hoá (denormalization) nếu nó làm câu hỏi hay-hỏi chạy nhanh: property lặp lại trên cả node lẫn cạnh là bình thường trong đồ thị.

Schema-optional: tiến hoá lược đồ

Neo4j là schema-optional (không bắt buộc lược đồ trước): bạn có thể thêm node mới, property mới, loại quan hệ mới bất cứ lúc nào mà không cần ALTER TABLE, không migration đau đớn. Điều này khiến việc tiến hoá mô hình rất dễ — bắt đầu nhỏ với Customer, Account, SENT, rồi khi bài toán gian lận cần, thêm DeviceUSES_DEVICE, sau đó thêm Merchant và reify giao dịch thẻ thành node — tất cả trên đồ thị đang chạy.

Đổi lại tính linh hoạt là kỷ luật do con người giữ: không có ràng buộc bảng nào ngăn bạn tạo SENT với amount là chuỗi thay vì số, hay gõ nhầm relationship type SENDT. Neo4j có cơ chế constraint (ràng buộc tính duy nhất, tồn tại của property) và index để siết lại những chỗ quan trọng — nên dùng cho các bất biến cốt lõi (mỗi AccountaccountNo duy nhất). Ta sẽ dùng đến chúng ở bài Cypher cơ bảnnâng cao.

Use case thực tế

Bối cảnh. Phòng phòng chống gian lận của NCB nghi ngờ một nhóm tài khoản "mule" (tài khoản trung gian rửa tiền): các tài khoản mở bởi những khách hàng khác nhau nhưng thực chất do cùng một nhóm điều khiển. Dấu hiệu điều tra viên muốn tìm: các khách hàng dùng chung thiết bị đăng nhậpgiữa họ có dòng tiền chuyển vòng.

Mô hình hoá query-first. Câu hỏi dẫn tới hai pattern:

  • Chung thiết bị: (:Customer)-[:USES_DEVICE]->(:Device)<-[:USES_DEVICE]-(:Customer).
  • Dòng tiền: (:Account)-[:SENT]->(:Account), nối với chủ tài khoản qua (:Customer)-[:OWNS]->(:Account).

Từ đó chốt mô hình: 4 loại node Customer, Account, Device, và (cho giao dịch thẻ) Transaction; các quan hệ OWNS, SENT {amount, txnAt}, USES_DEVICE {firstSeen, lastSeen}.

Nạp dữ liệu (minh hoạ số lượng). Migrate từ CSDL lõi: 2,4 triệu node Customer, 3,1 triệu Account, ~180 triệu cạnh SENT (giao dịch 12 tháng), 0,9 triệu Device, ~6,5 triệu cạnh USES_DEVICE. Cạnh USES_DEVICE lấy từ log đăng nhập app; property firstSeen/lastSeen giúp lọc thiết bị dùng chung trong cùng khung thời gian — quan trọng vì thiết bị bán lại không phải gian lận.

Kết quả mô hình đúng. Truy vấn "tìm cụm ≥3 khách hàng chung 1 thiết bị, và giữa các tài khoản của họ có ≥1 cạnh SENT trong 7 ngày" chạy trong vài giây — điều bất khả thi thực tế nếu diễn đạt bằng JOIN đệ quy trên bảng transactions 180 triệu dòng cộng bảng log đăng nhập. Điều tra viên khoanh được 34 cụm đáng ngờ; sau rà soát thủ công, 11 cụm được nâng thành hồ sơ điều tra. Chi tiết thuật toán phát hiện cộng đồng và vòng tiền để ở bài thuật toán đồ thịứng dụng fraud & AML.

Bài học mô hình. Nếu ban đầu để device là property của Customer (như trong hồ sơ khách), pattern "chung thiết bị" không thể duyệt được — phải quét toàn bảng so khớp chuỗi. Chính việc nâng Device từ property thành node (theo query-first) mở khoá cả bài toán. Đây là minh chứng rõ nhất cho nguyên tắc của bài này.

Ghi nhớ

  • Danh từ → node, động từ/quan hệ → relationship, tính từ mô tả → property. Đây là quy tắc khởi đầu, đủ đúng cho 80% trường hợp.
  • Ba câu hỏi phân loại: có thứ khác trỏ tới nó không? / có cần duyệt qua nó không? / nó mô tả liên kết hay thực thể? Cần chia sẻ và duyệt → node; chỉ mô tả một thứ → property.
  • Relationship có hướng và mang property (SENT {amount, txnAt}) — điểm mạnh riêng của property graph, tránh phải tạo bảng trung gian.
  • Label = loại (index tốt, dùng lọc, cho phép nhiều label/node); property = giá trị. Đừng mã hoá giá trị thành label động.
  • Chuyển ER→đồ thị: bảng→node, cột→property, khoá ngoại→relationship, bảng nối N–N→relationship. Đừng bê nguyên FK thành property.
  • Giao dịch là relationship khi chỉ nối 2 bên và cần truy vết dòng tiền nhanh; là node (reify) khi cần nối merchant/device/nhiều bên. Thực tế thường dùng cả hai.
  • Tránh anti-pattern: super node (quá nhiều cạnh), node phình to (nhồi property), nhầm cấp label/property, và sao chép nguyên xi lược đồ quan hệ.
  • Query-first: mô hình theo câu hỏi sẽ hỏi, không theo cấu trúc nguồn; chấp nhận phi chuẩn hoá có chủ đích. Neo4j schema-optional giúp tiến hoá mô hình dễ — nhưng cần constraint/index cho các bất biến cốt lõi.

Bài viết liên quan

Cách index hoạt động (B-Tree), đọc EXPLAIN, seq scan vs index scan và mẫu tối ưu truy vấn.

13 thg 7, 2026 4

Khoá, ràng buộc, quan hệ và chuẩn hoá 1NF/2NF/3NF — thiết kế lược đồ đúng từ đầu.

13 thg 7, 2026 4

Kết nhiều bảng đúng cách: các loại JOIN, bẫy thường gặp và phép hợp tập.

13 thg 7, 2026 3

Truy vấn lồng, CTE (WITH), CTE đệ quy và hàm cửa sổ — vũ khí cho phân tích nâng cao.

13 thg 7, 2026 3