假设你在维护一个电商自动化系统。运营同学突然问:“买过智能手表的人,他们关注的达人最近推荐了哪些配件?这些配件里,哪些又被相似用户反复购买?”
用户表、商品表、订单表、关注表、内容表都已经有了,数据也不缺。麻烦在于,这个问题不是要查某一行记录,而是要沿着“购买”“关注”“推荐”这些关系连续走好几步。用关系数据库当然也能写出查询,但 JOIN 会一层层叠起来;需求每多走一步,查询结构、执行成本和维护难度都会一起上涨。
图数据库换了一个出发点:不再只把关系藏在外键里,而是让关系和实体一样,成为可以命名、带属性、直接遍历的一等数据。这样一来,我们描述的不是几张彼此分开的表,而是一张能够回答“谁与谁怎样相连”的网络。

图数据库真正擅长的不是“把数据画成图”,而是处理以连接为核心的问题。若查询总是在问“从谁出发,沿什么关系,走几步,会到哪里”,图模型往往很自然;若查询只是按主键读取一条记录,图数据库通常不会凭空带来优势。
多数业务型图数据库采用属性图模型。你可以先把它理解成四种积木:节点、标签、关系和属性。它们各有分工,不能随意互换。
节点表示能够被单独识别的对象,例如用户、商品、订单、设备、账户和配送站。节点通常会有一个业务标识,但不要把数据库内部生成的元素编号当成长期业务主键,因为数据迁移、重新导入或跨环境同步后,内部编号可能改变。
CREATE (u:用户 {
用户编号: 'U-1001',
姓名: '林晓雨',
城市: '杭州',
注册时间: datetime('2026-08-01T09:30:00')
})这里的 u 只是当前查询中的变量名,:用户 是标签,花括号里才是节点属性。变量帮助我们在一条查询里指代这个节点,标签负责把同类节点组织起来,属性保存具体值。
一个节点可以拥有多个标签。例如某位用户既是 :用户,也是 :会员。标签适合表达稳定的类别,并经常参与约束、索引和模式匹配。像“积分为 830”这种经常变化的值,就更适合放在属性里,而不是不断添加和删除标签。
CREATE (u:用户:会员 {
用户编号: 'U-1002',
姓名: '周宁',
会员等级: '金卡'
})关系必须有起点、终点和类型,并且天然带有方向。(用户)-[:购买]->(商品) 与 (商品)-[:购买]->(用户) 表达的是两种不同的模型。即使业务上把“同事”理解为对称关系,存储时仍要选择方向;查询时可以忽略方向匹配,也可以显式写入两条关系,但团队必须统一规则。
关系还能保存自己的属性。购买时间、成交价和数量描述的是“这次购买”,不属于用户本身,也不属于商品本身,所以放在购买关系上最自然。
MATCH (u:用户 {用户编号: 'U-1001'})
MATCH (p:商品 {商品编号: 'P-2048'})
CREATE (u)-[:购买 {
购买时间: datetime(),
成交价: 699,
数量: 1
}]->(p)属性适合保存姓名、状态、时间、金额、权重等可比较或可过滤的值。属性很灵活,但灵活不等于无约束。用户编号、商品编号、关系发生时间等核心字段仍应统一命名、类型和是否必填,否则同一含义出现三四种拼写,后续查询会非常痛苦。

把原有数据表机械地一张张改成节点,通常只会得到一张难查的图。更稳妥的方法,是先收集系统必须回答的问题,再反推需要哪些节点和关系。
假设我们要实现“给用户推荐朋友买过、但自己还没买过的商品”,可以按下面的顺序建模。
先写出查询里的起点与终点。起点是目标用户,终点是候选商品,因此“用户”和“商品”适合成为节点。
再写出中间要经过的业务动作。我们需要从目标用户走到朋友,再从朋友走到商品,所以至少需要“朋友”和“购买”两类关系。
接着确认筛选条件放在哪里。用户编号属于用户,商品类别属于商品,购买时间与成交价属于购买行为,分别放在对应节点或关系上。
最后把高频问题写成模式,检查是否能沿着少量且明确的关系到达答案。如果每次查询都要扫描大量无关节点,说明标签、关系类型或入口索引还需要调整。
这是图建模里最常见的取舍。可以用三个问题快速判断:
比如“购买”一开始也许只需要连接用户与商品,此时带属性的关系很简洁。后来业务要求一笔订单包含多件商品,还要关联优惠券、收货地址和退款单,这时候把订单提升为节点会更清楚:
(用户)-[:提交]->(订单)-[:包含 {数量: 2}]->(商品)
(订单)-[:使用]->(优惠券)
(订单)-[:配送至]->(地址)如果所有用户都连接到同一个“热门”节点,这个节点可能拥有数百万条关系。只要查询从它出发且没有先限制关系类型、时间或范围,就会展开出巨大的中间结果。解决办法不是简单地“多加内存”,而是回到访问模式:能否按月份拆分事件节点,能否给关系类型分层,能否在遍历过程中尽早过滤,能否避免从超级节点向外全量扩散。
图模型的灵活性不会自动替你维护数据质量。重复用户、缺失业务主键、同义关系类型混用,都会让遍历结果悄悄膨胀。核心标识要配合唯一性约束,关系命名要有统一的方向和时态,写入还要考虑是否可能被重复执行。
Cypher 的可读性来自“模式匹配”。圆括号表示节点,方括号表示关系,箭头表示方向。我们先用一组很小的数据,把整个查询过程走一遍。
唯一性约束防止同一个业务对象被重复创建,也能为常见的精确定位提供基础。普通索引则帮助规划器从大量节点里尽快找到查询入口。
CREATE CONSTRAINT 用户编号唯一 IF NOT EXISTS
FOR (u:用户) REQUIRE u.用户编号 IS UNIQUE;
CREATE CONSTRAINT 商品编号唯一 IF NOT EXISTS
FOR (p:商品) REQUIRE p.商品编号 IS UNIQUE;
CREATE INDEX 商品类别索引 IF NOT EXISTS
FOR (p:商品) ON (p.类别);索引的重点是“找到从哪里开始走”,不是把关系遍历的每一步都变成索引查询。索引越多,写入时需要同步维护的结构也越多,所以不能看到属性就全部建立索引。先围绕真实查询测量,再保留能明显改善入口定位、过滤或排序的索引。

自动化流程很可能因为超时而重试。若每次重试都直接 CREATE,同一个用户、商品或购买事件可能被写入多次。MERGE 会先按给定模式匹配,找不到时再创建,但也要注意:MERGE 只保证它所描述的整个模式,不会猜测你的业务唯一键。稳妥做法是配合约束,并把唯一字段写清楚。
MERGE (u:用户 {用户编号: $用户编号})
ON CREATE SET u.注册时间 = datetime()
SET u.姓名 = $姓名,
u.城市 = $城市
MERGE (p:商品 {商品编号: $商品编号})
SET p.名称 = $商品名称,
p.类别 = $商品类别
MERGE (u)-[b:购买
参数没有直接拼进查询字符串,而是通过 $参数名 传入。这样既减少输入内容破坏查询结构的风险,也方便数据库复用执行计划。
现在把问题写成图案:目标用户连接朋友,朋友连接商品,同时排除目标用户已经购买的商品。
MATCH (我:用户 {用户编号: $用户编号})-[:朋友]->(朋友:用户)
MATCH (朋友)-[购买记录:购买]->(候选:商品)
WHERE 购买记录.购买时间 >= datetime() - duration('P90D')
AND NOT EXISTS {
MATCH (我)-[:购买]->(候选)
}
RETURN
这段查询读起来像一条路线,而不是多张表的连接清单。更重要的是,关系类型、方向、时间过滤和排除条件都写在对应位置,后来接手的人能看懂“为什么要这样走”。

层级、依赖链和多度人脉常常不知道确切深度,这时可以写可变长度模式。危险也在这里:关系越密,可能路径数增长得越快。不要在大图上随手写一个没有上限的全方向遍历。
MATCH path = (起点:服务 {服务编号: $服务编号})
-[:依赖于*1..4]->
(下游:服务)
WHERE all(r IN relationships(path) WHERE r.启用 = true)
RETURN DISTINCT 下游.服务编号, length(path) AS 依赖深度
ORDER BY 依赖深度这里明确限定只走 依赖于,方向从上游到下游,深度在 1 到 4 之间,并且遍历过程中要求关系已启用。条件越早排除无效路径,越能避免先生成庞大结果再过滤。
如果用户可能没有绑定设备,但我们仍想保留用户记录,就用 OPTIONAL MATCH。它类似可选模式:匹配不到时相关变量为 null,不会把整行用户丢掉。
MATCH (u:用户 {用户编号: $用户编号})
OPTIONAL MATCH (u)-[:绑定]->(d:设备)
RETURN u.姓名 AS 用户姓名,
collect(d.设备编号) AS 已绑定设备从仓库到配送站只经过两条道路,看起来比经过三条道路更短,但两条道路可能总共 38 公里,三条道路却只有 19 公里。于是我们要先说清楚“最短”指什么。
MATCH (起点:配送站 {站点编号: $起点编号}),
(终点:配送站 {站点编号: $终点编号})
MATCH path = shortestPath((起点)-[:道路*..12]->(终点))
RETURN [n IN nodes(path) | n.名称] AS 经过站点,
length(path) AS 道路段数上面的查询寻找跳数较少的路径,不会自动读取每条道路的“公里数”并求加权最小值。若业务目标是距离或耗时最低,就应该采用支持相应权重的路径算法,并保证权重非负、单位一致、缺失值有明确处理规则。
“最短路径”和“列出所有路径”不是同一件事。所有路径查询很容易因环路和分支产生巨大结果,尤其不能把无限深度的结果直接返回给接口。先限定起点、终点、关系类型、方向和最大深度,再决定究竟需要一条最优路径、若干候选路径,还是仅判断能否到达。
一次订单写入可能包含四步:创建订单、连接用户、连接商品、扣减库存。如果第三步失败而前两步已经提交,图里就会出现没有商品明细的订单。关系再丰富,也救不了不完整的业务事实。
把共同构成一次业务动作的写入放进同一事务,成功时整体提交,异常时整体回滚。使用 JavaScript 驱动时,托管写事务还能在适合重试的临时故障后重新执行事务函数,因此函数内部不要混入不可安全重复的外部副作用,例如先发短信再写数据库。
const session = driver.session({ database: 'neo4j' })
try {
const result = await session.executeWrite(async tx => {
const orderResult = await tx.run(`
MATCH (u:用户 {用户编号: $用户编号})
MATCH (p:商品 {商品编号: $商品编号})
MERGE (o:订单 {订单编号: $订单编号})
ON CREATE SET o.创建时间 = datetime(), o.状态 = '待支付'
MERGE (u)-[:提交]->(o)
MERGE (o)-[item:包含 {明细编号: $明细编号}]->(p)
SET item.数量 = $数量, item.成交价 = $成交价
RETURN o.订单编号 AS 订单编号
这段示例还藏着一个需要你补强的边界:如果库存不足,第二条查询可能返回零行,但事务函数未必自动抛错。生产代码要检查返回记录数,库存不足时主动抛出业务异常,让事务回滚。

事务解决的是一组操作如何原子地完成,约束解决的是数据能否保持合法,两者缺一不可。常见的保护包括:
图查询慢,常见原因不是“图数据库不够快”,而是查询从一个过宽的入口开始,沿过多关系展开,最后才过滤掉绝大多数结果。可以按这个顺序排查:
先用唯一键或高选择性的属性定位少量入口节点,避免从所有同类节点开始遍历。
在模式中写清关系类型和方向,不要用任意关系代替尚未想清楚的业务路径。
为可变长度路径设置合理上限,把时间、状态、租户等条件尽可能提前放进遍历过程。
使用执行计划观察入口是索引查找还是全量扫描、中间结果在哪一步突然变大,再针对瓶颈改模型或查询。
索引会占用存储,每次相关属性变化还需要更新。读多写少、入口定位频繁的属性通常值得索引;写入极密集而查询几乎不用的属性,建索引可能得不偿失。不要依赖“毫秒级”这类脱离数据规模与查询条件的承诺,应该用自己的数据、并发和热点分布做基准测试。
遍历命中十万条路径后,前端只显示十条,并不代表数据库只做了十条工作。尽量先聚合、排序和限制,再返回接口真正需要的字段;不要默认返回完整节点、完整路径和所有属性。对于分页,也要避免用越来越大的偏移量扫描前面所有结果,最好围绕稳定排序键设计游标。
普通业务表按用户编号分片后,一次查询往往只落到一个分片。图却可能从用户走到好友,再走到商品和品牌,几步之内跨越多个分区。每次跨区都要付出网络通信、协调和数据一致性的成本,所以“把节点均匀分散”不等于“查询也会均匀变快”。

增加内存、CPU 与更快存储,部署简单,也能让更多热点图数据留在缓存中。它适合尚未触及单机容量或吞吐上限的系统,但硬件存在上限,单机也不能独自提供故障容忍。
在集群中,同一个数据库可以拥有多个参与事务处理的主副本,并从中选出当前写入者。写入通常需要足够多的主副本确认后才完成,因此它换来的是故障容忍和数据可靠性,不应被误解为“加几个主副本,单个数据库的写吞吐就按倍数增加”。
读副本从主副本同步事务日志,能够分担只读查询。它很适合读多写少的系统,不过复制存在短暂延迟,刚写入的数据不一定立刻能在任意读副本看到。需要写后立即读取时,要把因果一致性要求纳入驱动会话与路由设计。
超大规模系统可以按租户、区域或相对独立的业务域拆分图。好的分区策略让大多数遍历停留在区内;坏的策略则让每个请求都跨区。设计前先统计真实路径:最常见的起点在哪里,通常走几跳,哪些关系最容易跨边界,跨区查询能否异步化或预先汇总。
社交推荐、反欺诈、权限关系、依赖分析、知识网络和物流路径看起来差别很大,但它们有共同点:答案取决于连接结构,而且连接会被连续查询。
以反欺诈为例,单个账户的金额可能完全正常,风险却藏在“多个账户共用设备”“短时间经过多层转账又回到关联账户”“不同申请人共享联系方式”这些组合关系里。图数据库能把这些关系留在同一个模型中,再用有边界的路径寻找可疑结构。
MATCH (申请:贷款申请)-[:使用设备]->(设备:设备)<-[:使用设备]-(其他申请:贷款申请)
WHERE 申请.申请编号 = $申请编号
AND 其他申请 <> 申请
OPTIONAL MATCH (其他申请)-[:填写号码]->(号码:电话号码)<-[:填写号码]-(更多申请:贷款申请)
RETURN count(DISTINCT 其他申请) AS 共用设备申请数,
如果系统主要是按键读取缓存、记录按时间追加的日志、对大批事实做列式聚合,或者业务几乎不查询多跳关系,键值数据库、时序数据库、关系数据库或分析型仓库可能更直接。图数据库不是 NoSQL 学习路线的“终点”,只是针对连接问题的一种工具。
一个小图演示很容易成功,真正上线要面对重复写入、热点、权限和故障。我们可以沿着下面这条线检查:
判断模型是否成熟,有一个很实用的标准:新同事看到业务问题后,能够在白板上画出与查询几乎一致的路径;工程师把这条路径写成查询时,不需要先猜测三套关系方向,也不会碰到没有边界的遍历。此时图模型才真正服务了业务,而不只是换了一种存储外观。
再想一个开放题:订单原本只是用户到商品的一条“购买”关系,后来需要关联多件商品、优惠券、地址和退款单。你会怎样调整模型?
图数据库的核心价值,是让关系从隐藏的连接条件变成可以命名、带属性、直接遍历的数据。节点表达实体,标签表达稳定类别,关系表达有方向的业务连接,属性补充具体值;真正的建模则从查询问题出发,反推路径、边界和约束。
写查询时,先用索引定位少量入口,再沿明确的关系类型和方向前进。多跳路径要限制深度并尽早过滤,最短跳数与最低权重也要分清。写入方面,约束、幂等和事务共同防止重复数据与半成品子图。扩展方面,主副本、读副本和业务分区分别解决不同问题,跨分区关系则是必须正视的成本。
当业务反复追问“谁通过什么关系与谁相连”,图数据库会让模型和查询更贴近问题本身;当业务只是简单键值读取或大批量聚合时,选择更专门的数据系统反而更轻松。会用图数据库,不只是会写一条路径查询,更是知道关系何时值得成为系统的主角。
最后再考虑缓存、内存、读副本与分区。硬件能放大好模型的能力,也会放大坏查询造成的浪费。