Транзакции и ACID в Neo4j

Почему граф можно доверять деньгам: Neo4j — полноценная ACID-база, а не «эвентуально согласованное» хранилище.

ACID — гарантии транзакций: атомарность (всё или ничего), согласованность (правила целостности соблюдены), изоляция (параллельные транзакции не мешают), долговечность (зафиксированное переживёт сбой).

Вокруг слова «NoSQL» сложился миф: будто все нереляционные базы ради скорости и масштаба отказались от транзакций и согласованности, поэтому им «нельзя доверять важные данные». Для многих документных и key-value хранилищ это отчасти правда — они выбрали eventual consistency. Но Neo4j к этому лагерю не относится. Это полноценная ACID-СУБД, и именно поэтому на ней строят системы, где цена ошибки высока: банковские графы, антифрод, учёт прав доступа. В этом уроке разберём, что каждая буква ACID означает на практике графа и как пользоваться транзакциями из кода так, чтобы данные оставались целыми при любых сбоях.

Граф с настоящими транзакциями

Многие NoSQL-базы пожертвовали транзакциями ради масштаба. Neo4j — нет: это ACID-СУБД. Каждый запрос исполняется в транзакции, и либо все его изменения применяются вместе, либо ни одного. Это критично для графа: перевод между счетами создаёт связь и меняет два узла — все три действия должны случиться атомарно.

// перевод: либо обе стороны, либо ничего
MATCH (from:Account {id:'A'}), (to:Account {id:'B'})
WHERE from.balance >= 100
CREATE (from)-[:SENT {amount:100}]->(to)
SET from.balance = from.balance - 100,
    to.balance   = to.balance + 100

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

Разложим этот пример по буквам ACID, потому что он удобно демонстрирует все четыре. Атомарность (A): создание связи SENT и два изменения баланса либо случатся вместе, либо не случатся вовсе — нельзя списать у отправителя, но не зачислить получателю. Согласованность (C): условие WHERE from.balance >= 100 не даст уйти в минус, а уникальные ограничения и ваши правила остаются соблюдены до и после. Изоляция (I): если два перевода с одного счёта идут параллельно, блокировки выстроят их в очередь, и баланс не «прочитается дважды старым». Долговечность (D): как только сервер ответил «commit», деньги переведены навсегда, даже если питание выключат через миллисекунду. Эти четыре гарантии и есть причина, по которой графу можно доверять деньги.

Явные транзакции из драйвера

Несколько запросов можно объединить в одну транзакцию через драйвер: открыть, выполнить шаги, зафиксировать (commit) или откатить (rollback):

with driver.session() as session:
    tx = session.begin_transaction()
    try:
        tx.run("MATCH (a:Account {id:$a}) SET a.balance = a.balance - $amt", a='A', amt=100)
        tx.run("MATCH (b:Account {id:$b}) SET b.balance = b.balance + $amt", b='B', amt=100)
        tx.commit()
    except Exception:
        tx.rollback()
        raise

Изоляция и блокировки

Neo4j по умолчанию даёт уровень изоляции read-committed: транзакция видит только зафиксированные данные. При записи на изменяемые узлы и связи берутся блокировки, что упорядочивает конкурентные изменения и предотвращает гонки (включая ту, что в MERGE). Долгие транзакции держат блокировки дольше — ещё причина батчировать массовые операции.

Стоит знать, что у read-committed есть граница: он не защищает от «неповторяющегося чтения» — если в одной транзакции прочитать баланс дважды, между чтениями его могла изменить и зафиксировать другая транзакция. Поэтому критичные операции вроде нашего перевода полагаются не на повторное чтение, а на блокировку записи: когда транзакция меняет узел счёта, она держит на нём блокировку до commit, и параллельный перевод с того же счёта вынужден ждать. Так конкурентные изменения выстраиваются в строгий порядок и не затирают друг друга. Тот же механизм спасает MERGE от гонки: два одновременных MERGE по одному ключу не создадут дубль, потому что один дождётся другого.

Долговечность

Перед подтверждением транзакция пишется в журнал (transaction log / WAL). Даже если сервер упадёт сразу после commit, при перезапуске журнал доиграется и данные восстановятся. Именно это «D» в ACID отличает базу от кэша.

Тонкость, которую часто упускают: «commit вернул успех» и «изменения уже долетели до основного файла хранилища» — это не одно и то же. Гарантия долговечности держится на журнале. Сначала изменения надёжно пишутся в transaction log на диск, и только после этого commit считается успешным; применение к основному store может произойти чуть позже, лениво. Если в этот момент сервер упадёт, при старте Neo4j прочитает журнал и «доиграет» зафиксированные, но ещё не применённые транзакции (recovery). Поэтому, услышав от сервера «ок», вы можете быть уверены в сохранности данных, даже если железо умрёт миллисекундой позже. Тот же журнал — основа репликации в кластере: реплики получают и применяют именно поток этих записей.

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

Каждая транзакция накапливает изменения в памяти, при commit сначала пишет их в журнал транзакций (durability), затем применяет к хранилищу. Блокировки на узлы/связи обеспечивают изоляцию: пока транзакция держит блокировку на узле, другая ждёт. Если изменений слишком много, всё это давит на память — отсюда правило батчей. Откат просто отбрасывает накопленные изменения, не трогая хранилище.

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

  • Считать Neo4j «эвентуально согласованным». Нет — это строго ACID; на это можно опираться.
  • Гигантская транзакция. Миллионы изменений в одном commit бьют по памяти и держат блокировки — батчируйте.
  • Забыть rollback при ошибке. В явных транзакциях ловите исключение и откатывайте, иначе блокировки повиснут.
  • Долгие транзакции под нагрузкой. Они блокируют других; держите транзакции короткими.

Итоги

  • Neo4j — полноценная ACID-СУБД: изменения атомарны, целостность гарантирована.
  • Связь + изменение узлов происходят как единое целое — идеально для переводов и подобного.
  • Изоляция read-committed и блокировки упорядочивают конкуренцию; журнал даёт долговечность.
  • Держите транзакции короткими и батчируйте массовые изменения.
Проверьте себя
1. Что гарантирует атомарность (A в ACID) при графовом переводе денег?
AСкорость
BЛибо все изменения (связь + оба узла) применятся вместе, либо ни одного
CШифрование
DПараллельность
2. Чем Neo4j отличается от многих NoSQL-баз в плане транзакций?
AОн вообще без транзакций
BОн строго ACID, тогда как многие NoSQL жертвуют транзакциями ради масштаба
CОн только eventually consistent
DУ него нет долговечности
3. Почему массовые изменения лучше делать батчами?
AТак красивее
BОгромная транзакция давит на память и долго держит блокировки, мешая другим
CБатчи отключают ACID
DИначе данные не сохранятся