Graph 3 — Cypher căn bản

13 thg 7, 2026 2 lượt xem
#sql
#query
#graph-database
#neo4j
#cypher

Graph 3 — Cypher căn bản

Bài mô hình hoá đã dựng xong property graph ngân hàng: node Customer, Account, quan hệ OWNS, SENT {amount, txnAt}. Nhưng một mô hình chỉ có giá trị khi ta hỏi được nó. Cypher là ngôn ngữ truy vấn của Neo4j — vai trò với đồ thị đúng như SQL với bảng. Bài này đưa bạn từ số 0 đến chỗ tự viết được các truy vấn thường ngày. Nếu bạn đã quen SQL, tin tốt là Cypher học rất nhanh: cùng tư duy khai báo (declarative — mô tả cái gì cần, không mô tả cách lấy), chỉ khác cách diễn đạt.

Cypher ra đời năm 2011 tại Neo4j, sau đó được mở chuẩn qua dự án openCypher. Từ năm 2024, phần lớn Cypher trở thành nền tảng của GQL (Graph Query Language) — chuẩn ISO/IEC 39075, ngôn ngữ đồ thị chuẩn quốc tế đầu tiên, ngang hàng với SQL. Nói cách khác, kỹ năng Cypher bạn học hôm nay đang hội tụ về một chuẩn ngành lâu dài, không phải cú pháp riêng của một hãng.

Lưu ý quan trọng: các block cypher trong bài không phải SQL Postgreskhông chạy được trên sandbox của Knowledge Base. Chúng minh hoạ cú pháp Neo4j. Chỉ block đánh dấu -- ▶ Chạy được (là SQL) mới thực thi được.

Triết lý cốt lõi: vẽ hình bằng ASCII

Ý tưởng thiên tài của Cypher: cú pháp trông giống hình vẽ của đồ thị. Node vẽ bằng dấu ngoặc tròn — trông như một vòng tròn — và relationship vẽ bằng mũi tên. Bạn "vẽ" mẫu (pattern) muốn tìm, engine đi khớp mẫu đó trong đồ thị.

  • Node: (n) — một node bất kỳ tên n; (c:Customer) — node có label Customer; (c:Customer {fullName: 'Nguyễn Văn A'}) — thêm điều kiện property inline.
  • Relationship: -[:OWNS]-> — cạnh có type OWNS, mũi tên chỉ hướng; -[r:SENT]- (không mũi tên) — bỏ qua hướng; -[r:SENT {amount: 5000000}]-> — cạnh có property.
  • Pattern: ghép lại thành đường đi. (a:Customer)-[:OWNS]->(acc:Account) đọc trực tiếp là "khách A sở hữu tài khoản acc".

Ví dụ một pattern đầy đủ, đọc gần như tiếng Anh:

(a:Customer {fullName: 'Nguyễn Văn A'})-[:OWNS]->(acc:Account)-[:SENT]->(other:Account)

Nghĩa: "khách tên A, sở hữu tài khoản acc, tài khoản đó đã gửi tiền tới other". Chính khả năng nhìn thấy hình dạng truy vấn làm Cypher dễ đọc hơn nhiều so với chuỗi JOIN lồng nhau trong SQL cho cùng câu hỏi.

MATCH và RETURN — xương sống của mọi truy vấn

Cặp mệnh đề cơ bản nhất: MATCH mô tả mẫu cần tìm, RETURN chọn cái gì trả về.

MATCH (c:Customer)
RETURN c.fullName, c.city

Đối chiếu SQL: MATCH (c:Customer) tương đương FROM customers c, còn RETURN c.fullName, c.city tương đương SELECT c.full_name, c.city. Cùng ý nghĩa "lấy tên và thành phố mọi khách hàng".

Sức mạnh lộ ra khi có quan hệ. Câu "liệt kê mọi tài khoản của khách tên A":

MATCH (c:Customer {fullName: 'Nguyễn Văn A'})-[:OWNS]->(acc:Account)
RETURN acc.accountNo, acc.balance

Trong SQL bạn phải viết FROM customers c JOIN accounts a ON a.customer_id = c.id WHERE c.full_name = 'Nguyễn Văn A'. Trong Cypher, phép JOIN biến mất — nó được diễn đạt bằng chính mũi tên -[:OWNS]->. Đây là khác biệt lớn nhất về trải nghiệm: quan hệ là công dân hạng nhất, không phải điều kiện ON phải viết lại mỗi lần.

Bảng đối chiếu Cypher ↔ SQL

Ý địnhCypherSQL
Nguồn dữ liệu / duyệtMATCH (c:Customer)FROM customers c
Nối quan hệ-[:OWNS]->(acc:Account)JOIN accounts acc ON ...
LọcWHERE c.city = 'Hà Nội'WHERE c.city = 'Hà Nội'
Chọn cột trả vềRETURN c.fullNameSELECT c.full_name
Sắp xếpORDER BY acc.balance DESCORDER BY acc.balance DESC
Giới hạn dòngLIMIT 10LIMIT 10
Bỏ qua dòng đầuSKIP 20OFFSET 20
Gộp nhómRETURN c.city, count(*) (ngầm)GROUP BY c.city
Loại trùngRETURN DISTINCT c.citySELECT DISTINCT c.city
Truyền kết quả tiếpWITH ...subquery / CTE
Ghép dữ liệu (get-or-create)MERGEINSERT ... ON CONFLICT

Ba khác biệt cần ghi nhớ ngay: (1) thứ tự đọc Cypher là MATCH → WHERE → RETURN (không phải SELECT trước như SQL); (2) không có GROUP BY — gộp nhóm là ngầm định, xem phần hàm gộp; (3) JOIN được thay bằng pattern.

WHERE — lọc pattern

WHERE trong Cypher đặt điều kiện lên node, relationship hoặc kết quả trung gian — về mặt ngữ nghĩa gần như WHERE trong SQL.

MATCH (c:Customer)-[:OWNS]->(acc:Account)
WHERE acc.balance > 1000000000 AND acc.currency = 'VND'
RETURN c.fullName, acc.accountNo, acc.balance
ORDER BY acc.balance DESC

"Khách nào có tài khoản VND số dư trên 1 tỷ". Ngoài so sánh thường, WHERE hỗ trợ:

  • Kiểm tra sự tồn tại của pattern: WHERE (c)-[:USES_DEVICE]->(:Device {flagged: true}) — chỉ giữ khách có dùng thiết bị bị gắn cờ. Đây là điều SQL phải diễn đạt bằng EXISTS (SELECT ...).
  • Phủ định pattern: WHERE NOT (c)-[:OWNS]->(:Account) — khách chưa có tài khoản nào.
  • Danh sách và chuỗi: WHERE acc.currency IN ['VND', 'USD'], WHERE c.fullName STARTS WITH 'Nguyễn', ... CONTAINS ..., ... ENDS WITH ....
  • Khoảng và null: WHERE acc.balance IS NOT NULL.

Điểm mạnh riêng: WHERE có thể lọc theo sự có mặt hoặc vắng mặt của một đường đi — thứ mà SQL diễn đạt vụng về. "Tài khoản chưa từng gửi tiền đi đâu" chỉ là WHERE NOT (acc)-[:SENT]->().

Hàm, alias và hàm gộp

Cypher có kho hàm phong phú: toUpper(), substring(), size(), coalesce(), hàm ngày date(), datetime(), duration(), hàm toán round(), abs()… Đặt alias bằng AS, giống SQL:

MATCH (acc:Account)
RETURN acc.currency AS loai_tien,
       count(*)      AS so_tk,
       sum(acc.balance) AS tong_so_du
ORDER BY tong_so_du DESC

Đây là chỗ Cypher khác SQL rõ nhất: không có GROUP BY. Khi bạn trộn cột thường (acc.currency) với hàm gộp (count, sum, avg, min, max, collect) trong RETURN, Cypher tự động gom nhóm theo các cột không-gộp. Ở trên, acc.currency là khoá gộp ngầm; count(*)sum(acc.balance) tính trên mỗi nhóm. Ít gõ hơn, nhưng phải nhớ quy tắc: cột non-aggregate trong RETURN chính là GROUP BY.

Một hàm gộp đặc biệt hữu ích là collect() — gom nhiều giá trị thành một danh sách (list), thứ SQL thuần không có tương đương gọn:

MATCH (c:Customer)-[:OWNS]->(acc:Account)
RETURN c.fullName AS khach,
       collect(acc.accountNo) AS danh_sach_tk,
       count(acc) AS so_tk

Trả về mỗi khách một dòng, kèm mảng số hiệu tài khoản của họ — rất tiện để dựng cấu trúc lồng nhau.

Đối chiếu chạy được: cùng một câu hỏi trên SQL

Để bạn thấy tương ứng cụ thể, đây là câu "liệt kê các giao dịch của một tài khoản, mới nhất trước" — viết bằng SQL và chạy được trên sandbox (Postgres, bảng transactions):

-- ▶ Chạy được
SELECT id, kind, amount, created_at
FROM transactions
WHERE account_id = 1
ORDER BY created_at DESC
LIMIT 20;

Câu Cypher tương đương trên đồ thị (nếu mô hình giao dịch là node Transaction, xem bài mô hình hoá) — chỉ minh hoạ, không chạy:

MATCH (acc:Account {id: 1})-[:ORIGINATED]->(t:Transaction)
RETURN t.id, t.kind, t.amount, t.txnAt
ORDER BY t.txnAt DESC
LIMIT 20

So sánh trực diện: FROM transactions WHERE account_id = 1 trở thành pattern (acc:Account {id: 1})-[:ORIGINATED]->(t:Transaction); phần ORDER BY ... LIMIT giống hệt nhau. Với truy vấn một cấp thế này, hai ngôn ngữ tương đương về sức mạnh. Đồ thị chỉ thắng đậm khi câu hỏi cần đi nhiều bước (multi-hop) — chủ đề của bài Cypher nâng cao.

WITH — pipeline giữa các phần

WITH là mệnh đề Cypher không có tương đương một-một trong SQL, và là chìa khoá để viết truy vấn phức tạp. Nó chốt kết quả trung gian rồi truyền sang phần tiếp theo — như một đường ống (pipeline) hoặc CTE nối tiếp. Cú pháp giống RETURN nhưng thay vì kết thúc, nó nối vào mệnh đề sau.

Dùng WITH khi cần lọc sau khi gộp (giống HAVING trong SQL):

MATCH (c:Customer)-[:OWNS]->(acc:Account)
WITH c, count(acc) AS so_tk
WHERE so_tk >= 3
RETURN c.fullName, so_tk
ORDER BY so_tk DESC

Ở đây WITH c, count(acc) AS so_tk gộp số tài khoản mỗi khách, rồi WHERE so_tk >= 3 lọc — vì WHERE sau WITH hoạt động trên kết quả đã gộp. SQL viết là GROUP BY ... HAVING COUNT(*) >= 3.

WITH cũng để nối nhiều bước duyệt, chuyền biến qua từng chặng, sắp xếp/giới hạn giữa chừng, hay chuẩn bị dữ liệu cho MERGE/CREATE. Quy tắc: mọi biến muốn dùng ở phần sau WITH phải được liệt kê trong WITH — cái gì không mang theo sẽ bị "quên".

Sơ đồ: MATCH khớp mẫu trên đồ thị

Engine nhận pattern (c)-[:OWNS]->(acc)-[:SENT]->(other) rồi quét đồ thị tìm mọi tổ hợp node/cạnh khớp hình dạng đó, gán vào các biến c, acc, other. Mỗi tổ hợp khớp là một dòng kết quả — đúng như mỗi tổ hợp hàng khớp JOIN là một dòng trong SQL.

CREATE, MERGE — tạo dữ liệu

Cho tới đây ta chỉ đọc. Cypher cũng là ngôn ngữ ghi (khác SQL, nơi DDL/DML tách riêng). CREATE tạo node và relationship mới:

CREATE (c:Customer {id: 1001, fullName: 'Trần Thị B', city: 'Hà Nội'})
CREATE (acc:Account {id: 5001, accountNo: '0110001', balance: 0, currency: 'VND'})
CREATE (c)-[:OWNS]->(acc)

Cẩn trọng: CREATE luôn tạo mới, không kiểm tra trùng. Chạy hai lần sẽ có hai khách trùng — thảm hoạ khi nạp dữ liệu lặp. Đây là lý do có MERGE.

MERGE là get-or-create: tìm pattern; nếu đã tồn tại thì dùng lại, nếu chưa thì tạo. Đây là mệnh đề quan trọng nhất khi nạp và đồng bộ dữ liệu — bảo đảm không sinh bản trùng khi migrate hay import theo lô:

MERGE (c:Customer {id: 1001})
  ON CREATE SET c.fullName = 'Trần Thị B', c.createdAt = date()
  ON MATCH  SET c.lastSeen = date()
MERGE (acc:Account {id: 5001})
MERGE (c)-[:OWNS]->(acc)

MERGE (c:Customer {id: 1001}) khớp theo id: có rồi thì lấy, chưa có thì tạo. ON CREATE SET chỉ chạy khi mới tạo, ON MATCH SET chỉ chạy khi đã tồn tại — cực tiện để phân biệt "khách mới" vs "khách cũ cập nhật lần gặp cuối". Tương đương gần nhất trong SQL là INSERT ... ON CONFLICT DO UPDATE (upsert).

Cạm bẫy MERGE phải nhớ: MERGE khớp trên toàn bộ property viết inline. MERGE (c:Customer {id: 1001, city: 'HN'}) sẽ tạo node mới nếu không có node nào khớp cả id lẫn city. Quy tắc an toàn: MERGE chỉ theo khoá định danh duy nhất (id, accountNo), rồi gán các property khác bằng SET. Muốn MERGE chạy đúng và nhanh, cột khoá đó nên có unique constraint — Cypher tạo bằng CREATE CONSTRAINT ... REQUIRE acc.accountNo IS UNIQUE (xem bài mô hình hoá về constraint/index).

SET, REMOVE, DELETE — cập nhật và xoá

SET cập nhật property hoặc thêm label cho node/relationship đã có:

MATCH (acc:Account {accountNo: '0110001'})
SET acc.balance = acc.balance + 50000000,
    acc.updatedAt = datetime()
SET acc:Frozen          // thêm label Frozen để đánh dấu phong toả

REMOVE làm ngược lại — bỏ một property hoặc bỏ một label:

MATCH (acc:Account:Frozen {accountNo: '0110001'})
REMOVE acc:Frozen        // gỡ phong toả
REMOVE acc.riskNote      // xoá một property

Phân biệt SET acc.x = null (property vẫn tồn tại, giá trị null) với REMOVE acc.x (property biến mất hẳn) — Neo4j coi hai thứ này khác nhau.

DELETE xoá node hoặc relationship. Nhưng có ràng buộc quan trọng: không xoá được node còn dính cạnh. Neo4j bảo vệ tính toàn vẹn — một Account còn quan hệ OWNS, SENT thì DELETE sẽ báo lỗi. Hai cách xử lý:

// Xoá đúng một cạnh
MATCH (a:Account)-[r:SENT]->(b:Account)
WHERE r.txnAt < date('2020-01-01')
DELETE r

// Xoá node CÙNG mọi cạnh dính vào nó: DETACH DELETE
MATCH (acc:Account {accountNo: '0110001'})
DETACH DELETE acc

DETACH DELETE gỡ toàn bộ cạnh liên quan rồi mới xoá node — dùng khi thật sự muốn loại một thực thể khỏi đồ thị. Hãy cẩn trọng: không có WHERE giới hạn, MATCH (n) DETACH DELETE n sẽ xoá sạch cả cơ sở dữ liệu.

ORDER BY, SKIP, LIMIT và parameter

Sắp xếp và phân trang giống SQL, chỉ khác OFFSET gọi là SKIP:

MATCH (c:Customer)-[:OWNS]->(acc:Account)
RETURN c.fullName, acc.balance
ORDER BY acc.balance DESC
SKIP 20 LIMIT 10           // lấy trang thứ 3, mỗi trang 10 dòng

Parameter ($tên) là thực hành bắt buộc trong sản xuất, đúng như bind parameter trong SQL. Thay vì nhét giá trị thẳng vào chuỗi truy vấn, dùng $:

MATCH (c:Customer {fullName: $ten})-[:OWNS]->(acc:Account)
WHERE acc.balance > $nguong
RETURN acc.accountNo, acc.balance

Kèm map tham số {ten: 'Nguyễn Văn A', nguong: 1000000000} khi gọi từ driver. Ba lợi ích, giống hệt lý do dùng bind parameter SQL:

  1. An toàn — chống injection: giá trị không bao giờ được ghép vào cú pháp, kẻ tấn công không chèn được Cypher độc hại.
  2. Tái dùng query plan — Neo4j cache kế hoạch thực thi theo cấu trúc truy vấn; cùng cấu trúc khác tham số dùng lại plan đã biên dịch, tránh parse/plan lại mỗi lần.
  3. Sạch và dễ đọc — tách logic khỏi dữ liệu, tránh lỗi escape dấu nháy.

Quy tắc thép: mọi giá trị đến từ người dùng hoặc thay đổi giữa các lần gọi đều phải là parameter, không bao giờ nối chuỗi.

Cấu trúc một truy vấn hoàn chỉnh

Ghép các mảnh lại, đây là hình dạng điển hình của một truy vấn phân tích — "top 5 khách có tổng tiền gửi đi lớn nhất trong quý, chỉ tính khách ở Hà Nội":

MATCH (c:Customer)-[:OWNS]->(acc:Account)-[s:SENT]->()
WHERE c.city = $thanh_pho
  AND s.txnAt >= datetime($tu_ngay)
WITH c, sum(s.amount) AS tong_gui
WHERE tong_gui > 0
RETURN c.fullName AS khach, tong_gui
ORDER BY tong_gui DESC
LIMIT 5

Đọc theo dòng chảy: MATCH vẽ đường khách → tài khoản → cạnh gửi tiền; WHERE lọc thành phố và mốc thời gian; WITH ... sum(...) gộp tổng tiền mỗi khách và chuyền tiếp; WHERE tong_gui > 0 là lọc-sau-gộp (HAVING); RETURN ... ORDER BY ... LIMIT chọn và cắt top 5. Toàn bộ đọc như một câu chuyện tuyến tính — đó là cảm giác Cypher hướng tới.

Use case thực tế

Bối cảnh. Đội vận hành dữ liệu NCB cần dựng nhanh một truy vấn phục vụ bộ phận chăm sóc khách hàng ưu tiên (priority banking): mỗi sáng liệt kê các khách VIP tại Hà Nội có tổng số dư mọi tài khoản trên 5 tỷ VND kèm số hiệu các tài khoản, để RM (relationship manager) chủ động liên hệ. Dữ liệu đã ở Neo4j sau khi migrate từ core theo mô hình: (:Customer)-[:OWNS]->(:Account).

Bước 1 — Truy vấn khai thác. Analyst viết bản đầu, gộp số dư theo khách:

MATCH (c:Customer)-[:OWNS]->(acc:Account {currency: 'VND'})
WHERE c.city = $thanh_pho
WITH c, sum(acc.balance) AS tong_so_du, collect(acc.accountNo) AS ds_tk
WHERE tong_so_du >= $nguong
RETURN c.fullName AS khach, tong_so_du, ds_tk
ORDER BY tong_so_du DESC

collect(acc.accountNo) gom mọi số hiệu tài khoản của mỗi khách thành một danh sách trên cùng dòng — thứ mà SQL thuần phải dùng string_agg hoặc mảng. Tham số {thanh_pho: 'Hà Nội', nguong: 5000000000} được truyền từ job, không ghép chuỗi.

Bước 2 — Đóng vào job có tham số. Truy vấn được lưu thành một câu Cypher cố định trong scheduler; mỗi ngày chạy với cùng cấu trúc nên Neo4j tái dùng query plan đã biên dịch. Thời gian trả về ổn định ~40ms trên tập ~50 nghìn khách Hà Nội, so với ~600ms nếu ghép giá trị vào chuỗi khiến plan bị biên dịch lại mỗi lần (đo trên môi trường staging của đội).

Bước 3 — Đánh dấu và cập nhật. Sau khi RM liên hệ, hệ thống gắn nhãn khách đã chăm sóc để hôm sau không lặp:

MERGE (c:Customer {id: $id})
  ON MATCH SET c:Contacted, c.lastContactedAt = date()

MERGE theo khoá id (có unique constraint) đảm bảo không tạo trùng dù job chạy lại; SET c:Contacted thêm label thay vì sửa cấu trúc.

Kết quả. Từ khi chuyển câu hỏi này sang Cypher có tham số, đội bỏ được một chuỗi 4 JOIN + subquery trong SQL báo cáo cũ, rút thời gian bảo trì truy vấn và loại lỗi trùng bản ghi khi job chạy lặp. Bài toán nhiều bước hơn — truy vết dòng tiền, phát hiện vòng — sẽ cần các mệnh đề nâng cao ở Cypher nâng cao và thuật toán ở bài thuật toán đồ thị.

Ghi nhớ

  • Cypher vẽ đồ thị bằng ASCII: node là (:Label {prop}), cạnh là -[:TYPE]->. Pattern (a)-[:OWNS]->(b) đọc trực tiếp như câu nói.
  • Bộ ba nền tảng: MATCH (mô tả mẫu, thay cho FROM+JOIN), WHERE (lọc), RETURN (chọn kết quả, thay cho SELECT). Quan hệ là công dân hạng nhất — không phải điều kiện ON.
  • Không có GROUP BY: trộn cột thường với hàm gộp (count, sum, collect…) trong RETURN sẽ tự gộp theo các cột không-gộp. WITH + WHERE đóng vai HAVING.
  • WITH là pipeline: chốt kết quả trung gian và chuyền sang phần sau — chìa khoá viết truy vấn nhiều tầng; biến nào không mang theo trong WITH sẽ mất.
  • CREATE luôn tạo mới; MERGE là get-or-create — dùng MERGE khi nạp/đồng bộ để tránh trùng, chỉ khớp theo khoá định danh duy nhất rồi SET phần còn lại. ON CREATE / ON MATCH phân biệt tạo mới vs cập nhật.
  • SET cập nhật property/thêm label; REMOVE gỡ; DELETE xoá cạnh; DETACH DELETE xoá node cùng mọi cạnh. Không xoá được node còn cạnh nếu không detach.
  • ORDER BY/LIMIT/SKIP như SQL (SKIP = OFFSET). Luôn dùng parameter $param cho giá trị động: an toàn (chống injection), tái dùng query plan, sạch code.
  • Cypher là nền tảng của chuẩn GQL (ISO 39075) — kỹ năng học được hội tụ về chuẩn ngành, nhưng không phải SQL và không chạy trên sandbox Postgres.

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