创建日期: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. 先看关系型里长什么样
电商订单的经典六张表:
三件事值得注意:
order_items是一张纯粹的"中间表"——它自己不是业务实体,存在的唯一理由是"一个订单里有多个商品,且每个商品有数量/成交价"。 这是 ER 图和属性图差异最大的地方。categories自己指向自己(parent_id)——品类树。- 每一条连线都是外键:它在物理上只是"一个整数列",连接是查询时算出来的。
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 节的判据:要"走"就做关系 |
自测
- 五条翻译规则分别是什么?哪一条最容易被漏掉?
order_items这类中间表在图里变成了什么?为什么不需要给它建节点?- "商品颜色"和"品牌"分别该做成什么?依据是哪三问?
- 关系方向定错了能改吗?口诀是什么?
- 订单行上的
unitPrice为什么必须复制一份,不能去读p.price? - 为什么约束要在灌数据之前建?它和
MERGE是什么关系?
关联
- 上一篇:入门
- 下一篇:写入与约束——
MERGE的坑、约束能管到哪、幂等重灌 - 关系型那头的对照:MySQL 表设计与范式
- 系列入口:Neo4j 101
参考
- 实测环境: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