chundev
日期:2026-04-20 技術棧:Neo4j 5.26 Community + Cypher
一個「找中間人(middleman)」的圖查詢在 2 個節點錨點下要跑 27.6 秒,使用者卡到罵人。PROFILE 找出三個經典反模式:關係型別寫在 WHERE 不在 pattern、無向關係 -[]- 導致雙向展開、未剪枝的 Cartesian product。重寫後 249 毫秒(110× faster)、DbHits 從 121M 降到 149K(810× fewer)、記憶體 19.7MB → 150KB。結果完全等價。
在一個以 Neo4j 儲存通訊圖譜的分析系統上,有個「中間人關聯」功能:給定兩個「錨點節點」(例如兩台手機上的使用者帳號),找出在他們的對話/通話紀錄中,同時跟雙方都有通訊的第三方。這是典型的 2-hop middleman pattern:
(ownerA) -[:PARTICIPATES_IN]-> (conversationA) <-[:PARTICIPATES_IN]- (middleman) -[:PARTICIPATES_IN]-> (conversationB) <-[:PARTICIPATES_IN]- (ownerB)
使用者選 2 個錨點 → 頁面要等 25–28 秒才渲染完,嚴重影響工作流程。
圖規模不大:3,143 個 Party 節點、2,876 個 Conversation、263 個 Call、約 1.6 萬條相關關係。照理說不該慢。
PROFILENeo4j 有兩個查詢計畫工具:
EXPLAIN — 只顯示計畫,不實際執行PROFILE — 實際跑,並回傳每個運算子消耗的 DbHits、Rows、MemoryPROFILE
MATCH ...
DbHits 是關鍵指標,不是 wall-clock time。 DbHits 代表「儲存引擎被存取的次數」,完全由資料模型與查詢決定,不受網路、記憶體、負載等外部因素影響。一個 1 億 DbHits 的查詢無論在哪台機器都會慢。
在 cypher-shell 裡跑 PROFILE,結尾會有一段簡潔的摘要:
Time: 27616
DbHits: 121605235
Rows: 1
Memory (Bytes): 19783944
UNWIND $exhibitIds AS exhibitId
MATCH (e:Anchor {pg_id: exhibitId})
MATCH (owner:Party)-[r]->(i)-[:FROM_ANCHOR]->(e)
WHERE (i:Conversation OR i:Call)
AND (type(r) = 'OWNER_PARTICIPATES_IN' OR type(r) = 'OWNER_PARTICIPATES_IN_CALL')
WITH collect(DISTINCT {owner: owner, anchor: e}) AS targetConfigs,
collect(DISTINCT e) AS targetAnchors
// 問題 1:Cartesian product 沒剪枝
UNWIND targetConfigs AS configA
UNWIND targetConfigs AS configB
WITH configA.owner AS ownerA, configB.owner AS ownerB, ...
WHERE elementId(ownerA) < elementId(ownerB)
// 問題 2:無向 -[]- + 問題 3:型別寫在 WHERE
MATCH path = (ownerA)-[r1]-(i1)<-[r2]-(middleman:Party)-[r3]->(i2)-[r4]-(ownerB)
WHERE i1 <> i2
AND type(r1) IN ['OWNER_PARTICIPATES_IN', 'PARTICIPATES_IN', ...]
AND type(r2) IN ['OWNER_PARTICIPATES_IN', 'PARTICIPATES_IN', ...]
AND type(r3) IN ['OWNER_PARTICIPATES_IN', 'PARTICIPATES_IN', ...]
AND type(r4) IN ['OWNER_PARTICIPATES_IN', 'PARTICIPATES_IN', ...]
// 問題 4:i1/i2 已經被 pattern match,再重跑一次 FROM_ANCHOR 檢查
MATCH (i1)-[:FROM_ANCHOR]->(anchorA)
MATCH (i2)-[:FROM_ANCHOR]->(anchorB)
// 問題 5:LIMIT 放在爆炸之後
WITH ownerA, ownerB, middleman, ...
LIMIT 20
PROFILE 結果:Time: 27616ms, DbHits: 121,605,235, Memory: 19.7 MB
1. 關係型別寫在 WHERE 不在 pattern
// ❌ 不走索引
MATCH (a)-[r]->(b)
WHERE type(r) IN ['TYPE_A', 'TYPE_B']
// ✅ 走 relationship type index
MATCH (a)-[r:TYPE_A|TYPE_B]->(b)
Neo4j 會把 pattern 裡的 [:TYPE] 編譯成直接走 relationship type lookup;寫在 WHERE 裡它必須先拉出所有關係、再逐一比對 type(r)。這直接反映在 DbHits 上 — 一個實測案例(來自 Neo4j 社群)顯示同樣語意下 inline type 29 DbHits vs WHERE filter 66 DbHits。放大到這個 query 的 4 條關係、數千次展開,差異是百萬級 DbHits。
2. 無向關係 -[]- 讓引擎雙向展開
// ❌ 引擎每個 r1 都要試兩個方向
MATCH (ownerA)-[r1]-(i1)
// ✅ 明確方向 — 若資料只有單向,另一向直接被剪掉
MATCH (ownerA)-[r1]->(i1)
如果你的資料模型裡 Party 永遠是 PARTICIPATES_IN 的 source、Conversation 永遠是 target,那 -[]- 是純粹浪費 — 引擎會展開「沒有邊的反方向」並立刻丟棄,但那個動作會計入 DbHits。
3. 未剪枝的 Cartesian product
UNWIND owners AS ownerA
UNWIND owners AS ownerB // O(N²) 對
N 個 owner 產生 N² 個 pair。雖然後面有 elementId(ownerA) < elementId(ownerB) 砍半,但仍是二次複雜度。更好的做法是先用預先算好的「有交集」關係(本例用一個 COLLIDED_WITH 關係,事先 ETL 算好誰跟誰有交集)先剪枝:
UNWIND owners AS ownerA
OPTIONAL MATCH (ownerA)-[collision:COLLIDED_WITH]-(ownerB:Party)
WHERE ownerB IN owners AND elementId(ownerA) < elementId(ownerB)
// 這段接下來的 owner pair 數量 = 實際有交集的 pair,通常 << N²
4. 重複 MATCH 已知的關係
// i1 來自 pattern 的一部分,再 MATCH 一次 FROM_ANCHOR 等於讀了兩次
MATCH (i1)-[:FROM_ANCHOR]->(anchorA)
替代:把 anchor 對應關係事先 collect 成 map,後面用 i2 IN exB.interactions 這種 list 包含檢查取代 MATCH:
UNWIND targetAnchors AS ex
MATCH (iall)-[:FROM_ANCHOR]->(ex)
WITH ex, collect(DISTINCT iall) AS interactions
WITH collect({anchor: ex, interactions: interactions}) AS perEx
// 之後用 WHERE i2 IN perEx[B].interactions 取代重新 MATCH FROM_ANCHOR
5. LIMIT 放在爆炸之後才生效
LIMIT 20 如果放在一個已經 explode 到 100,000 row 的 MATCH 後面,那個 100,000 row 還是得先算出來。應該盡量把 LIMIT 推到能限制擴散的位置,例如在 middleman 展開之後、aggregation 之前。
與其從 (ownerA, ownerB) 兩個端點出發找中間的人,改從中間的 interaction 出發:
對於每個 anchor A 的 interaction
i1,找出同時也參與了 anchor B 某個 interactioni2的 Party。這個 Party 就是 middleman 候選。
這把問題從「O(owners²) × 4-hop 展開」改成「O(interactions_A) × 1-hop 展開到 middleman → 1-hop 驗證 i2 ∈ interactions_B」。
UNWIND $exhibitIds AS exhibitId
MATCH (e:Anchor {pg_id: exhibitId})
WITH collect(DISTINCT e) AS targetAnchors
// 1) 每個 anchor 的 owners(OWNER_* 嚴格定義)
UNWIND targetAnchors AS ex
MATCH (owner:Party)-[:OWNER_PARTICIPATES_IN|OWNER_PARTICIPATES_IN_CALL]->(io)-[:FROM_ANCHOR]->(ex)
WHERE io:Conversation OR io:Call
WITH targetAnchors, ex, collect(DISTINCT owner) AS owners
// 2) 每個 anchor 的所有 interactions(預先 collect 成 list)
UNWIND targetAnchors AS ex2
WITH targetAnchors, ex, owners, ex2 WHERE ex = ex2
MATCH (iall)-[:FROM_ANCHOR]->(ex2)
WHERE iall:Conversation OR iall:Call
WITH targetAnchors, ex, owners, collect(DISTINCT iall) AS interactions
WITH targetAnchors, collect({ex: ex, owners: owners, interactions: interactions}) AS perEx
// 3) anchor 兩兩配對(elementId 排序去重)
UNWIND perEx AS exA
UNWIND perEx AS exB
WITH exA, exB WHERE elementId(exA.ex) < elementId(exB.ex)
// 4) middleman 橋接:pivot on i1,inline type + 指定方向
UNWIND exA.interactions AS i1
MATCH (i1)<-[r2:PARTICIPATES_IN|PARTICIPATES_IN_CALL|OWNER_PARTICIPATES_IN|OWNER_PARTICIPATES_IN_CALL]-(middleman:Party)
-[r3:PARTICIPATES_IN|PARTICIPATES_IN_CALL|OWNER_PARTICIPATES_IN|OWNER_PARTICIPATES_IN_CALL]->(i2)
WHERE i2 IN exB.interactions AND i1 <> i2
// 5) 關聯回 owners(直接用 list 包含檢查,不再 MATCH FROM_ANCHOR)
MATCH (ownerA:Party)-[r1:OWNER_PARTICIPATES_IN|OWNER_PARTICIPATES_IN_CALL|PARTICIPATES_IN|PARTICIPATES_IN_CALL]->(i1)
WHERE ownerA IN exA.owners AND ownerA <> middleman
MATCH (ownerB:Party)-[r4:OWNER_PARTICIPATES_IN|OWNER_PARTICIPATES_IN_CALL|PARTICIPATES_IN|PARTICIPATES_IN_CALL]->(i2)
WHERE ownerB IN exB.owners AND ownerB <> middleman
AND elementId(ownerA) < elementId(ownerB)
WITH ownerA, ownerB, middleman, i1, i2, r1, r2, r3, r4
LIMIT 20
// ... 之後的 aggregation 到 graph 元素
| 指標 | Before | After | 改善倍數 |
|---|---|---|---|
| 執行時間 | 27,616 ms | 249 ms | 110× |
| DbHits | 121,605,235 | 149,163 | 815× |
| Memory | 19,783,944 B | 150,504 B | 131× |
| 結果 cardinality | 12 triples / 11 middlemen | 12 / 11(完全等價) | — |
前端 elapsedMs 從 25,000+ ms 降到 300–600 ms 區間(含網路與 tRPC 序列化)。
同個 codebase 有第二支查詢「找兩個 anchor 之間的共同對話」(類似 social collision)。原本以為可以複用同樣的優化套路,但 PROFILE 下去只省了 15%(449ms → 383ms)。
原因:這支查詢早就用了 COLLIDED_WITH 預剪枝 owner pair,熱點沒爆炸 — 時間主要花在 aggregation 和 serialization。同樣的反模式、不同的瓶頸結構,優化效果完全不同級別。
這帶出一個重要教訓:先 PROFILE、再下手。不要套公式。看 DbHits 在哪個 operator 最高,針對那個 operator 優化。
-[]- — 無向關係強迫引擎雙向展開,每次多算的都是 DbHits。COLLIDED_WITH、SIMILAR_TO、SAME_GROUP 這類 ETL 階段就算好的索引邊。LIMIT 位置決定爆炸的大小 — 推到越接近擴散源越好。IN list — Cypher 的 list 檢查比走圖查詢便宜得多。