MERGE: идемпотентность и «создать, если нет»

Как писать загрузки, которые можно безопасно повторять: MERGE вместо CREATE.

MERGE — команда Cypher «найди такой паттерн, а если его нет — создай»: она делает операцию идемпотентной, исключая дубли.

Проблема, которую решает MERGE

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

MERGE (p:Person {name:'Алиса'})
RETURN p

Выполните этот запрос хоть десять раз — Алиса в базе будет ровно одна. Это и есть идемпотентность.

Что такое идемпотентность и почему она бесценна

Операция идемпотентна, если повторный её запуск не меняет результат по сравнению с однократным. Нажать кнопку лифта дважды — идемпотентно: лифт всё равно приедет один раз. Долить воды в стакан — нет: каждый раз воды становится больше. CREATE — как доливание воды, MERGE — как кнопка лифта. В мире данных это свойство решает целый класс боли. ETL-процесс упал на середине и его перезапустили? С MERGE ничего страшного: уже загруженные узлы найдутся, недогруженные создадутся. Сообщение из очереди доставилось дважды (а в распределённых системах это норма)? Обработчик на MERGE не наплодит дублей. Можно безбоязненно повторять — а значит, можно строить надёжные пайплайны.

ON CREATE и ON MATCH

Часто нужно «если создаём — проставь время создания, если нашли — обнови время визита». Для этого у MERGE есть две ветки:

MERGE (p:Person {name:'Алиса'})
ON CREATE SET p.created = timestamp(), p.visits = 1
ON MATCH  SET p.visits = p.visits + 1
RETURN p.name, p.visits

ON CREATE срабатывает только когда узел создан впервые, ON MATCH — когда найден существующий. Это рабочая лошадка для upsert-логики (вставить или обновить). Слово upsert — это сращение update и insert: «обнови, если есть, иначе вставь». В реляционных базах для этого городят громоздкие конструкции вроде INSERT ... ON CONFLICT DO UPDATE; в Cypher это читается почти как обычный русский текст.

Типичный практический сценарий — счётчик просмотров профиля. При каждом заходе на страницу мы хотим: если пользователя ещё нет — завести его и поставить счётчик в 1; если есть — увеличить счётчик. Ровно это и делает запрос выше. Ещё пример — импорт фильмов из внешнего каталога, где ON CREATE ставит дату первого появления, а ON MATCH обновляет рейтинг и кассовые сборы, не трогая дату. Разделяя «что делать при первом появлении» и «что делать при обновлении», вы держите неизменяемые и изменяемые поля под раздельным контролем.

MERGE для связей: осторожно с якорем

Тут кроется главная тонкость. Сравните:

// Хорошо: узлы найдены заранее, MERGE только связь
MATCH (a:Person {name:'Алиса'}), (b:Person {name:'Боб'})
MERGE (a)-[:ЗНАЕТ]->(b)

Здесь Алиса и Боб уже найдены через MATCH, и MERGE добавит связь, только если её ещё нет. А вот опасный вариант:

// Опасно: MERGE целого пути
MERGE (a:Person {name:'Алиса'})-[:ЗНАЕТ]->(b:Person {name:'Боб'})

MERGE проверяет на существование весь паттерн целиком. Если такого пути нет, он создаст и узлы, и связь — даже если Алиса уже была отдельно, появится новая. Поэтому правило: сначала MERGE-им узлы по отдельности, потом MERGE-им связь между ними.

Разберём, как именно рождается дубль, потому что это неочевидно. Допустим, в базе уже есть Алиса (без связи ЗНАЕТ к Бобу) и есть Боб. Выполняем опасный MERGE (a:Person {name:'Алиса'})-[:ЗНАЕТ]->(b:Person {name:'Боб'}). Cypher ищет в графе целый путь: Алиса → ЗНАЕТ → Боб. Такого пути нет (связи-то не было). Раз весь паттерн не найден — MERGE создаёт его целиком, со всеми узлами. В итоге появляются новые узлы-двойники Алисы и Боба, соединённые связью, а старые одинокие Алиса и Боб остаются болтаться рядом. Безопасная же версия сперва двумя отдельными MERGE гарантирует, что узлы существуют и переиспользуются, и лишь затем MERGE-ит между ними ребро:

MERGE (a:Person {name:'Алиса'})
MERGE (b:Person {name:'Боб'})
MERGE (a)-[:ЗНАЕТ]->(b)

Здесь каждый MERGE отвечает ровно за одну сущность, и ни одна из них не задвоится. Запомните это как мантру: узлы порознь, связь — после.

Как работает под капотом

MERGE — это поиск плюс условная запись в одной операции. Чтобы поиск был быстрым и корректным, на ключевое свойство (например, :Person(name)) должно стоять ограничение уникальности. Без него MERGE при конкурентных запросах может всё же создать дубль: два параллельных MERGE одновременно «не нашли» узел и оба создали. Ограничение уникальности и блокировки решают эту гонку. Поэтому связка «MERGE + unique constraint» — стандартная практика.

Разложим гонку по шагам, чтобы понять, откуда берётся дубль даже у «безопасного» MERGE. Два сервера приложения одновременно обрабатывают регистрацию пользователя с одним и тем же email. Поток A выполняет фазу поиска: узла нет. В этот же момент поток B тоже выполняет фазу поиска: узла нет (A ещё не успел записать). Теперь оба переходят к фазе создания — и оба создают узел. Получилось два пользователя с одинаковым email. Здесь нет ошибки в коде запроса: проблема в том, что между «поискал» и «создал» есть зазор, в который вклинился сосед. Ограничение уникальности закрывает этот зазор на уровне движка: оно вешает блокировку на ключ, так что второй поток вынужден дождаться первого и затем увидеть уже созданный узел (сработает ветка ON MATCH). Без ограничения MERGE защищает от дублей лишь при последовательных запусках, но не под конкурентной нагрузкой — а в проде нагрузка именно конкурентная.

Внутренне без подходящего индекса фаза поиска MERGE — это полный перебор узлов с нужной меткой, что на большой базе медленно. Индекс (а ограничение уникальности его создаёт автоматически) превращает поиск в точечный прыжок. Так что unique constraint даёт сразу две выгоды: корректность под параллельной нагрузкой и скорость.

Частые ошибки

  • MERGE-ить весь путь сразу. Это создаёт лишние узлы. MERGE узлы по отдельности, затем связь.
  • MERGE без ограничения уникальности. При параллельной нагрузке возможны дубли; всегда ставьте unique constraint на ключ.
  • Класть в MERGE-паттерн изменяемое свойство. MERGE (p:Person {name:'Алиса', visits:5}) будет искать узел именно с visits:5 — лучше MERGE по ключу, а изменяемое ставить в ON MATCH/ON CREATE.

Итоги

  • MERGE = «найти или создать»; делает загрузки идемпотентными.
  • ON CREATE / ON MATCH задают разные действия для новой и найденной записи (upsert).
  • Связи: MERGE узлы по отдельности, потом MERGE связь — иначе наплодите дубли.
  • MERGE по ключу требует ограничения уникальности, иначе при гонке возможны дубли.
Проверьте себя
1. В чём главное отличие MERGE от CREATE?
AMERGE быстрее
BMERGE сначала ищет паттерн и создаёт только при отсутствии — он идемпотентен
CMERGE удаляет дубли
DMERGE работает только со связями
2. Когда сработает ветка ON CREATE в MERGE?
AКаждый раз
BТолько когда узел создаётся впервые (не был найден)
CТолько при удалении
DНикогда
3. Почему MERGE по ключу стоит сопровождать ограничением уникальности?
AДля красоты
BБез него при параллельных MERGE возможна гонка и появление дублей
CИначе MERGE не работает вовсе
DЧтобы ускорить CREATE