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