← назад к разделу

К property graph нужен язык запросов. В Neo4j это Cypher: если SQL описывает таблицы и JOIN, то Cypher описывает шаблон — какие узлы соединены какими рёбрами. Запрос выглядит как рисунок связи в тексте.

MATCH (a:Person {firstName:'Anna'}) -[:ЗНАКОМ_С*1..2]-> (x) RETURN x.firstName индекс :Person(firstName) Anna Boris Darya … Anna Boris Darya Vera Egor Anna 1. шаблон: знакомые Anna на один-два шага 2. индекс по метке находит стартовый узел — один поиск 3. шаг 1: по рёбрам к прямым знакомым 4. шаг 2: от них — дальше по рёбрам, индекс уже не нужен

Индекс по метке нужен один раз — он находит стартовый узел. Дальше Cypher идёт по рёбрам от узла к соседям, и звёздочка *1..2 — это ровно два таких шага. Поэтому цена запроса зависит от числа пройденных рёбер, а не от размера базы.

Обязательно

Шаблон связи: рисунок в скобках

Узел — в круглых скобках, ребро — в квадратных, направление — стрелкой:

(p:Person) -[:КУПИЛ]-> (t:Product)

Читается буквально: «узел p с меткой :Person купил узел t с меткой :Product». Буквы p и t — переменные, чтобы дальше на них сослаться.

MATCH и RETURN: найти и вернуть

Чтение начинается с MATCH (найти шаблон) и заканчивается RETURN (что вернуть). «Какие товары купила Anna» — запустите и поменяйте имя:

живой пример

MATCH (p:Person {firstName: 'Anna'}) -[:КУПИЛ]-> (t:Product)
RETURN t.title
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Фильтр пишут прямо в узле ({firstName: 'Anna'}) или отдельным условием через WHERE — как в SQL; ниже оба способа сразу. «Товары, которые покупают вместе с этим» — обход на два шага: от товара к его покупателям, от них — к другим их покупкам:

живой пример

MATCH (t:Product {title: 'Механическая клавиатура'}) <-[:КУПИЛ]- (:Person) -[:КУПИЛ]-> (other:Product)
WHERE other.title <> 'Механическая клавиатура'
RETURN other.title, count(*) AS вместе
ORDER BY вместе DESC LIMIT 5
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Это и есть «с этим товаром берут» из карточки магазина — два ребра и одна группировка, без единой таблицы связей.

Стрелка <-[:КУПИЛ]- развёрнута: от товара к покупателю связь идёт в обратную сторону. Направление можно и вовсе опустить (-[:КУПИЛ]-) — тогда обход идёт в обе стороны.

Путь переменной длины: обход на неизвестную глубину

Главная сила Cypher — обход, когда число шагов заранее неизвестно. Его задают звёздочкой, как в регулярных выражениях. «Знакомые Anna и знакомые её знакомых»:

живой пример

MATCH (a:Person {firstName: 'Anna'}) -[:ЗНАКОМ_С*1..2]-> (кто:Person)
RETURN кто.firstName, кто.lastName
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

* означает «одно или больше рёбер ЗНАКОМ_С» — то же самое, что *1.., просто короче. Глубину ограничивают: *1..2 — один-два шага (как здесь), *1..4 — до четырёх, *0.. — ноль или больше (ноль шагов означает, что в ответ попадёт и сам стартовый узел). Поменяйте *1..2 на *1..1 и запустите снова: в ответе останутся только прямые знакомые. Тот же обход без базы, в десяти строках:

живой пример

import java.util.*;

public class VarLengthPath {
    public static void main(String[] args) {
        Map<String, List<String>> edges = Map.of("Anna", List.of("Boris", "Darya"),
                "Boris", List.of("Vera"), "Darya", List.of("Egor"));
        List<String> layer = List.of("Anna");
        for (int hop = 1; hop <= 2; hop++) {
            List<String> next = new ArrayList<>();
            for (String node : layer) next.addAll(edges.getOrDefault(node, List.of()));
            System.out.println("шаг " + hop + ": " + next);
            layer = next;
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

package main

import (
	"fmt"
	"strings"
)

func main() {
	edges := map[string][]string{"Anna": {"Boris", "Darya"}, "Boris": {"Vera"}, "Darya": {"Egor"}}
	layer := []string{"Anna"}
	for hop := 1; hop <= 2; hop++ {
		var next []string
		for _, node := range layer {
			next = append(next, edges[node]...)
		}
		fmt.Printf("шаг %d: [%s]\n", hop, strings.Join(next, ", "))
		layer = next
	}
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

const edges = new Map([['Anna', ['Boris', 'Darya']], ['Boris', ['Vera']], ['Darya', ['Egor']]]);
let layer = ['Anna'];
for (let hop = 1; hop <= 2; hop++) {
  const next = layer.flatMap((node) => edges.get(node) ?? []);
  console.log(`шаг ${hop}: [${next.join(', ')}]`);
  layer = next;
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

edges = {"Anna": ["Boris", "Darya"], "Boris": ["Vera"], "Darya": ["Egor"]}
layer = ["Anna"]
for hop in (1, 2):
    nxt = [n for node in layer for n in edges.get(node, [])]
    print(f"шаг {hop}: [{', '.join(nxt)}]")
    layer = nxt
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

В реляционной базе тот же вопрос — громоздкий рекурсивный CTE, в Cypher — одна строка. Отдельно есть поиск кратчайшего пути, классическая «цепочка рукопожатий»:

живой пример

MATCH путь = shortestPath(
  (a:Person {firstName: 'Anna'}) -[:ЗНАКОМ_С*]- (b:Person {firstName: 'Grigory'})
)
RETURN [узел IN nodes(путь) | узел.firstName] AS цепочка
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

nodes(путь) разворачивает найденный путь в список узлов, а квадратные скобки — это выражение над списком, как map в обычном коде: из каждого узла берём имя. Ответ читается прямо: ["Anna", "Boris", "Vera", "Grigory"] — три рукопожатия.

CREATE и MERGE: создать и обновить

Ночная загрузка отработала дважды, и в графе теперь два узла одного и того же человека, а его связи разошлись между ними. Так бывает с CREATE: он всегда создаёт. MERGE сначала ищет и создаёт только если не нашёл, поэтому повторный запуск ничего не дублирует:

CREATE (p:Person {firstName: 'Maria', status: 'ACTIVE'})

MATCH (m:Person {firstName: 'Maria'}), (t:Product {title: 'Йога-коврик'})
MERGE (m) -[r:КУПИЛ]-> (t)
ON CREATE SET r.дата = '2026-09-17'

Кнопки «Запустить» под этими двумя примерами нет, и это не недосмотр: учебная песочница открыта только на чтение. Иначе первый же CREATE менял бы граф всем, кто читает статью следом.

MERGE не создаст дубликат, если связь уже есть — поэтому им грузят данные из внешних источников, где сущность приходит многократно. Но обратите внимание, как написан пример: свойство дата вынесено из шаблона в ON CREATE SET. Так и надо. MERGE ищет совпадение по всему, что перечислено внутри скобок, свойства включительно; напиши мы MERGE (m) -[:КУПИЛ {дата: '2026-09-17'}]-> (t), и назавтра, с другой датой, совпадения бы не нашлось — появилась бы вторая связь «купил». В шаблоне MERGE оставляют только то, что делает запись уникальной, а всё изменчивое дописывают через ON CREATE SET (при создании) и ON MATCH SET (при обновлении найденного).

WITH: конвейер из шагов

Один MATCH и один RETURN закрывают простые вопросы. Как только нужно посчитать промежуточный результат и продолжить от него, появляется WITH — он передаёт данные следующему шагу, как труба:

MATCH (c:Customer)-[:PLACED]->(o:Order)
WITH c, count(o) AS orders, sum(o.total) AS revenue
WHERE orders >= 3
MATCH (c)-[:PLACED]->(:Order)-[:CONTAINS]->(p:Product)
RETURN c.name, orders, revenue, collect(DISTINCT p.title)[0..5] AS examples
ORDER BY revenue DESC
LIMIT 20;

Читается сверху вниз: нашли покупателей с заказами, посчитали по каждому, отфильтровали по счёту, от оставшихся пошли дальше за товарами, собрали результат. Роль WITH здесь тройная — он группирует (всё, что не агрегат, становится ключом группировки), пробрасывает дальше только перечисленные переменные (остальные становятся недоступны) и позволяет WHERE после агрегата — это тот же HAVING из SQL.

MATCH покупатели и заказы пары клиент-заказ WITH счёт и сумма по клиенту одна строка на клиента WHERE оставить от трёх заказов только частые клиенты MATCH от клиента к товарам собранный список RETURN имя, счёт, товары

Запрос идёт сверху вниз, и WITH закрывает предыдущий шаг, поэтому дальше видно только перечисленное в нём, а WHERE фильтрует уже по посчитанному.

Три вещи, о которые спотыкаются. Переменную, которая нужна дальше, надо перечислить в WITH явно — иначе она «исчезнет». WITH с DISTINCT и LIMIT работает как отдельный шаг: WITH c ORDER BY c.created DESC LIMIT 100 сначала обрежет до ста, и дальше работа пойдёт только с ними — так ограничивают дорогие обходы. И WITH — единственное место, где можно переименовать выражение, чтобы потом им пользоваться.

Обновление и удаление: SET, REMOVE, DELETE

CREATE и MERGE создают, а менять и удалять — отдельные слова.

// изменить свойства
MATCH (c:Customer {email: $email})
SET c.status = 'ACTIVE', c.updatedAt = datetime();

// добавить метку и убрать свойство
MATCH (c:Customer {email: $email})
SET c:Verified
REMOVE c.verificationCode;

// сразу набором из параметра-словаря
MATCH (c:Customer {email: $email})
SET c += $props;          // += дописывает, = заменяет все свойства целиком

// удалить связь
MATCH (:Customer {email: $email})-[r:VIEWED]->(:Product {sku: $sku})
DELETE r;

// удалить узел вместе со всеми его связями
MATCH (p:Product {sku: $sku})
DETACH DELETE p;

Две ловушки. SET c = $props заменяет все свойства узла: то, чего нет в словаре, исчезнет; дописывает только SET c += $props. И DELETE на узле со связями падает с ошибкой «ещё есть связи» — узел удаляют либо DETACH DELETE (и тогда связи уходят молча, что стоит осознать), либо сначала удаляя связи явно.

Массовое обновление в одной транзакции упирается в память, поэтому для больших правок есть порционная форма:

MATCH (o:Order) WHERE o.total IS NULL
CALL { WITH o SET o.total = 0 } IN TRANSACTIONS OF 10000 ROWS;

OPTIONAL MATCH: сохранить тех, у кого связи нет

Обычный MATCH работает как внутреннее соединение: покупатель без заказов из выдачи исчезнет. OPTIONAL MATCH оставляет его, подставив null:

MATCH (c:Customer)
OPTIONAL MATCH (c)-[:PLACED]->(o:Order)
RETURN c.name, count(o) AS orders
ORDER BY orders;

Это и есть аналог левого соединения, и та же ловушка, что в SQL: условие на необязательную часть пишут внутри самого OPTIONAL MATCH (или в WITH … WHERE после него), а не в общем WHERE — иначе он превратится в обычный MATCH и отсечёт тех, кого должен был сохранить.

Где спотыкаются

  • Забывают про MERGE и плодят дубли. CREATE на повторной загрузке создаёт вторую копию узла или ребра. Для «создать, если ещё нет» — только MERGE по уникальному ключу с индексом или ограничением уникальности.
  • MATCH без якоря обходит весь граф. Нет узла с меткой и индексируемым свойством — Neo4j начинает с полного сканирования. Заземляйте запрос на конкретный узел, от которого пойдёт обход.
  • Пишут * без верхней границы. На плотном графе такой путь обойдёт пол-базы — ограничивайте глубину (*1..4), если это не полный обход по замыслу.
  • Ждут, что MERGE найдёт «хоть что-нибудь». Он работает по принципу «всё или ничего»: если хоть одна часть шаблона не нашлась, Neo4j создаёт шаблон целиком. MERGE (a:Person {name:'X'}) -[:ЗНАКОМ_С]-> (b:Person {name:'Y'}) при отсутствии одной только связи создаст ещё и вторых «X» и «Y» рядом с настоящими. Лечится разбиением: сначала MERGE на каждый узел отдельно, потом MERGE на связь между уже найденными.
  • Подставляют значения прямо в текст запроса. {firstName: 'Anna'} годится, пока вы играете в песочнице. В коде имя приходит снаружи, и его передают параметром — {firstName: $name}, а значение отдают драйверу отдельно. Так закрывается подстановка чужого кода в запрос, и заодно база перестаёт заново продумывать план для каждого нового имени: с параметрами текст запроса один и тот же, план берётся из кэша.
Дополнительно: при первом чтении можно пропустить

Глубже: кратчайший путь и граница обходарасширенное

Про кратчайший путь стоит добавить, чем он отличается от обычного обхода. Путь переменной длины (*1..5) находит все пути в этих границах — их может быть очень много, и именно поэтому такой запрос бывает дорогим. shortestPath((a)-[:KNOWS*]-(b)) ищет один кратчайший путь и останавливается, как только нашёл: он идёт поиском в ширину с двух концов и потому обходит на порядки меньше связей. Есть и allShortestPaths — все пути минимальной длины (их бывает несколько, если до цели есть равные маршруты), и это уже дороже.

Практическое правило: вопрос «связаны ли и как коротко» — это shortestPath, вопрос «какими вообще путями связаны» — путь переменной длины с обязательной верхней границей.

И про память: путь без верхней границы (* или *..) на связном графе — самый простой способ получить запрос, который выполняется минутами и падает по памяти. Cypher не останавливает такой обход сам: он честно собирает промежуточные результаты, пока не кончится куча, а сообщение будет про нехватку памяти, а не про запрос. Поэтому верхнюю границу ставят всегда, а для «дальних» вопросов берут shortestPath или алгоритмы из отдельной библиотеки графовых алгоритмов.

Глубже: EXPLAIN и PROFILE: посмотреть, как выполняетсярасширенное

Совет «заземляйте запрос на индекс» проверяется прямо в браузере или в оболочке:

PROFILE
MATCH (c:Customer {email: $email})-[:PLACED]->(o:Order)
RETURN o.id, o.total;

EXPLAIN показывает план, не выполняя запрос; PROFILE выполняет и показывает факт — сколько строк прошло через каждый шаг и сколько обращений к хранилищу (db hits) он сделал.

Читают план по двум признакам. Первый — как начался запрос: NodeIndexSeek означает, что индекс сработал; NodeByLabelScan — что база перебрала все узлы с меткой, и это чаще всего ответ на вопрос «почему медленно». Второй — где вырос объём: шаг, на котором строк стало в тысячи раз больше, и есть тот самый дорогой обход (обычно проход через узел с огромным числом связей или путь переменной длины без границы).

Практический порядок такой: сначала PROFILE на медленном запросе, потом взгляд на первую строку плана, потом — на шаг с максимальным db hits.

Коротко

  • Cypher описывает шаблон: узел в круглых скобках, ребро в квадратных, направление стрелкой.
  • Читают через MATCH … RETURN, фильтруют прямо в узле или через WHERE.
  • Стрелку разворачивают (<-[:КУПИЛ]-) или опускают — тогда обход идёт в обе стороны.
  • Звёздочка — путь переменной длины (*1..3): находит все пути в границах, и без верхней границы падает по памяти; shortestPath ищет один кратчайший и потому дёшев.
  • MERGE берёт существующее или создаёт; загрузка через CREATE плодит дубли. В шаблон MERGE кладут только то, что делает запись уникальной, изменчивое — в ON CREATE SET / ON MATCH SET; не нашлась часть шаблона — создастся шаблон целиком.
  • В коде значения передают параметрами ($name), а не вставляют в текст запроса: и безопаснее, и план запроса переиспользуется.
  • Запрос заземляют на узел с меткой и индексом: индекс работает один раз, дальше идут рёбра.
  • WITH — конвейер: группирует, пробрасывает переменные дальше и даёт WHERE после агрегата; без него запрос длиннее двух шагов не написать.
  • Менять и удалять — это SET (с +=, иначе свойства заменяются целиком), REMOVE, DELETE и DETACH DELETE; массовые правки идут порциями через CALL { … } IN TRANSACTIONS.
  • OPTIONAL MATCH — левое соединение с той же ловушкой про условие в общем WHERE; EXPLAIN и PROFILE показывают, начался ли запрос с NodeIndexSeek вместо NodeByLabelScan.

Что почитать дальше