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.visitsON 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 по ключу требует ограничения уникальности, иначе при гонке возможны дубли.