跳到主要内容

创建日期:2026-10-15 | 最近更新:2026-10-15 版本基线:Neo4j 5.26.4 community。本系列用本机 Docker 实例实测(docker exec neo4j-lab cypher-shell ...),文中输出均为真实查询结果,脚本与日志留档在 source/neo4j-lab/。

Neo4j 101 建模:把电商 ER 图翻译成属性图

一句话:图建模没有「范式」可背,只有一条主判据——这段信息会不会被别的实体指向?需不需要沿它遍历? 会,就做成节点或关系;不会,就老老实实做成属性。这篇拿一张真实的电商 ER 图,逐条翻译过去。

本篇是 Neo4j 101 系列 第 1 篇。承接上一篇,我们在这一篇里把系列用的电商图真正建起来。

1. 先看关系型里长什么样​

电商订单的经典六张表:

三件事值得注意:

  1. order_items 是一张纯粹的"中间表"——它自己不是业务实体,存在的唯一理由是"一个订单里有多个商品,且每个商品有数量/成交价"。 这是 ER 图和属性图差异最大的地方。
  2. categories 自己指向自己(parent_id)——品类树。
  3. 每一条连线都是外键:它在物理上只是"一个整数列",连接是查询时算出来的。

2. 翻译规则五条​

#从到例子
①实体表标签(+ 属性)users → (:User {userId, name, city})
②外键关系,方向 = 外键指向products.brand_id → (:Product)-[:MADE_BY]→(:Brand)
③中间表关系上的属性(不是节点!)order_items → (:Order)-[:CONTAINS {qty, unitPrice}]→(:Product)
④自指层级 / 树自指关系categories.parent_id → (:Category)-[:SUBCATEGORY_OF]→(:Category)
⑤一对多里"多"那一侧的重属性放到关系上,不是实体属性评分、加入时间、数量、成交价

第 ③ 条是重中之重。看这张图:

order_items 整张表消失了,它变成了边上的几个属性。验证一下——关系上的属性和节点上的属性,读起来一样直接:

MATCH (o:Order {orderId:'o1'})-[c:CONTAINS]->(p:Product)
RETURN o.orderId AS 订单, p.name AS 商品, c.qty AS 数量,
c.unitPrice AS 成交单价, p.price AS 当前价;

真实输出:

+---------------------------------------------------+
| 订单 | 商品 | 数量 | 成交单价 | 当前价 |
+---------------------------------------------------+
| "o1" | "小米 65W 氮化镓充电器" | 2 | 129 | 129 |
| "o1" | "iPhone 15 Pro 256G 黑色" | 1 | 8999 | 8999 |
+---------------------------------------------------+

c.qty 和 p.price 写在同一个 RETURN 里,中间没有任何 JOIN。在关系型里,这需要 orders JOIN order_items JOIN products 三张表。

3. 什么该做节点,什么该做关系​

这是图建模里最常卡住的地方。三个问题按顺序问下去:

拿两个例子走一遍:

  • 品牌:会。商品指向它、店铺也指向它、还要按它做排行 → 节点 (:Brand)。
  • 商品颜色:不会。没有别的实体指向"黑色",也不用按它做多跳遍历 → 属性 p.color。

注意最后一个分支。"照着表结构一对一搬"是新手最常犯的错—— 关系型里因为不能有嵌套,你会被迫建一堆小表;图里不需要。 看到 (:Product {skuId, name, price}) 旁边还挂着一个 (:Price {...}) 节点,基本就是这个病。

4. 方向怎么定​

关系必须带方向,但查询时可以忽略方向(-[:X]- 两头都不写箭头)。 方向定得对不对,影响的不是"能不能查到",而是反着查要不要多付一次代价。

口诀很简单:用动词短语读一遍,读得通就对了。

写法读作通不通
(:User)-[:PLACED]→(:Order)用户 下了 订单✅
(:Order)-[:PLACED]→(:User)订单 下了 用户❌
(:Order)-[:CONTAINS]→(:Product)订单 包含 商品✅
(:Product)-[:MADE_BY]→(:Brand)商品 由…制造✅(被动语态也行,读得顺就可以)

已发布的方向改不了(Neo4j 的关系方向是不可变的),所以定方向这一步值得多想十秒。 真定错了,只能把关系删了重建。

5. 属性 vs 关系:同一份信息,两种存法​

这是和关系型思维差异最大的一处。假设要做商品评分:

存法长什么样什么时候该用
做成关系(:User)-[:RATED {score:5, at}]→(:Product)要沿它遍历("和我口味像的人还评了什么")、要按人聚合
做成属性(:Product {avgScore: 4.6, ratingCount: 12})只读展示、不参与多跳;且能接受它是快照

对比一下:

在 SQL 里你根本不会纠结这件事——评分天然就是一张 ratings 表,一行一条,聚合用 AVG()。 图里把聚合结果直接放节点属性上也能跑,但你再也没法顺藤摸瓜走下去了。

判据一句话:要"走"就做关系,只"看"就做属性。

6. 反规范化:订单行上的成交价​

回到第 2 节那两条 CONTAINS 边。注意 c.unitPrice 和 p.price 那一对:

| "o1" | "iPhone 15 Pro 256G 黑色" | 1 | 8999 | 8999 |
↑ ↑
成交单价 当前价

这份数据里两列数值一样,只是因为我们的商品从没调过价。但它们是两个不同的东西:

  • c.unitPrice:下单那一刻的成交价快照。写完就冻住。
  • p.price:商品此刻的标价。随时会变。

这是图建模里最值钱的一条经验,也是关系型里同样的道理: 订单行上的价格必须复制一份冻在订单里,绝不能留个外键去读 products.price。 你在 SQL 里也不会这么干——order_items.unit_price 本来就是要存快照的。 到了图里,这个"快照"自然就落在了关系属性上,而不是某张表里。

7. 落地:把这个图建起来​

第一步永远是约束,不是数据:

CREATE CONSTRAINT user_id IF NOT EXISTS FOR (u:User) REQUIRE u.userId IS UNIQUE;
CREATE CONSTRAINT brand_name IF NOT EXISTS FOR (b:Brand) REQUIRE b.name IS UNIQUE;
CREATE CONSTRAINT category_nm IF NOT EXISTS FOR (c:Category) REQUIRE c.name IS UNIQUE;
CREATE CONSTRAINT store_id IF NOT EXISTS FOR (s:Store) REQUIRE s.storeId IS UNIQUE;
CREATE CONSTRAINT product_sku IF NOT EXISTS FOR (p:Product) REQUIRE p.skuId IS UNIQUE;
CREATE CONSTRAINT order_id IF NOT EXISTS FOR (o:Order) REQUIRE o.orderId IS UNIQUE;
CREATE CONSTRAINT promo_id IF NOT EXISTS FOR (pr:Promotion) REQUIRE pr.promoId IS UNIQUE;

为什么约束要在数据前面:它同时也是索引,是 MERGE 保持幂等的前提。篇 2 会把这件事讲透。

然后是实体(规则 ①):

UNWIND [
{userId:'u1', name:'张伟', city:'北京', level:'普通', regDate: date('2026-01-05')},
{userId:'u2', name:'李娜', city:'上海', level:'PLUS', regDate: date('2026-02-11')},
{userId:'u3', name:'王芳', city:'北京', level:'普通', regDate: date('2026-03-02')},
{userId:'u4', name:'刘强', city:'广州', level:'PLUS', regDate: date('2026-03-19')}
] AS row
MERGE (u:User {userId: row.userId})
ON CREATE SET u.name = row.name, u.city = row.city, u.level = row.level, u.regDate = row.regDate;

UNWIND [...] AS row 把一行一个对象的列表摊成多行,后面所有语句对每一行各执行一次。 这是 Cypher 里批量写数据的标准姿势,细节在篇 5。

接着外键(规则 ②),顺带把品牌和品类一起 MERGE 出来:

UNWIND [
{skuId:'SKU-001', name:'iPhone 15 Pro 256G 黑色', price:8999, brand:'Apple', category:'手机'},
{skuId:'SKU-002', name:'iPhone 15 128G 蓝色', price:5999, brand:'Apple', category:'手机'},
{skuId:'SKU-003', name:'华为 Mate 60 Pro 512G', price:6999, brand:'华为', category:'手机'},
{skuId:'SKU-004', name:'小米 14 256G 白色', price:3999, brand:'小米', category:'手机'},
{skuId:'SKU-005', name:'MacBook Air M3 13 英寸', price:9499, brand:'Apple', category:'笔记本'},
{skuId:'SKU-006', name:'小米 65W 氮化镓充电器', price:129, brand:'小米', category:'配件'}
] AS row
MERGE (p:Product {skuId: row.skuId})
ON CREATE SET p.name = row.name, p.price = row.price
MERGE (b:Brand {name: row.brand})
MERGE (c:Category {name: row.category})
MERGE (p)-[:MADE_BY]->(b)
MERGE (p)-[:BELONGS_TO]->(c);

MERGE (b:Brand {name: row.brand}) 这一句很有意思:三个 Apple 商品的 brand 都是 'Apple', 但它只会建出一个 (:Brand) 节点——因为 MERGE 是"有就复用,没有才建"。这也是幂等的来源。

自指树(规则 ④):

UNWIND ['手机','笔记本','配件'] AS leaf
MATCH (l:Category {name: leaf}), (root:Category {name:'数码'})
MERGE (l)-[:SUBCATEGORY_OF]->(root);

最后是中间表(规则 ③)——订单和订单行:

UNWIND [
{orderId:'o1', userId:'u1', paidAt:'2026-04-02T20:15:00', amount:9257, status:'paid'}
] AS row
MATCH (u:User {userId: row.userId})
MERGE (o:Order {orderId: row.orderId})
ON CREATE SET o.status = row.status, o.amount = row.amount,
o.paidAt = datetime(row.paidAt)
MERGE (u)-[pl:PLACED]->(o)
ON CREATE SET pl.at = o.paidAt;

UNWIND [
{orderId:'o1', skuId:'SKU-001', qty:1},
{orderId:'o1', skuId:'SKU-006', qty:2}
] AS row
MATCH (o:Order {orderId: row.orderId}), (p:Product {skuId: row.skuId})
MERGE (o)-[c:CONTAINS]->(p)
ON CREATE SET c.qty = row.qty, c.unitPrice = p.price;

建完看一眼形状:

CALL db.schema.visualization() YIELD nodes, relationships
RETURN size(nodes) AS 标签数, size(relationships) AS 关系类型数;

真实输出:

+-------------+
| 标签数 | 关系类型数 |
+-------------+
| 7 | 8 |
+-------------+

8. 反模式清单​

反模式为什么不对改成
把整个 JSON 塞进一个属性查不了、索引不了、更新要整体重写拆成属性或子节点
用属性值当关系类型:(a)-[:REL {type:'BUY'}]->(b)关系类型才是"边上的索引",放属性里等于放弃了它关系类型就叫 :BUY
建"万能 :Thing 节点"所有查询都要靠属性过滤,索引全部失效用多标签:(:User:VIP)
照着表结构一对一搬会建出一堆没有业务含义的"小表节点",多跳查询反而更难写回到第 3 节的三问
把所有东西都做成属性以后想"沿着它走"时走不了,只能改模型重灌数据第 5 节的判据:要"走"就做关系

自测​

  1. 五条翻译规则分别是什么?哪一条最容易被漏掉?
  2. order_items 这类中间表在图里变成了什么?为什么不需要给它建节点?
  3. "商品颜色"和"品牌"分别该做成什么?依据是哪三问?
  4. 关系方向定错了能改吗?口诀是什么?
  5. 订单行上的 unitPrice 为什么必须复制一份,不能去读 p.price?
  6. 为什么约束要在灌数据之前建?它和 MERGE 是什么关系?

关联​

参考​

  • 实测环境:Neo4j 5.26.4 community(neo4j:5.26.4-community)
  • 脚本与真实输出:source/neo4j-lab/(cypher/00-schema.cypher、cypher/01-load.cypher、out/01-load.log)
  • Neo4j 官方文档:Data modeling(建模指南)、Cypher Manual — MERGE