Каждый запрос, который мы выполняли в разделе, шёл в транзакции. Мы этого не замечали, потому что транзакция начиналась и заканчивалась сама. В прошлом уроке драйвер тоже делал это за нас. Теперь разберёмся, что транзакция в Neo4j обещает, как управлять ею вручную и чем она защищает данные, когда в базу пишут несколько клиентов сразу.
Всё или ничего
Представь перевод книги в клубной библиотеке: Аня отдала книгу, Борис её получил. Это две записи. Если между ними что-то сломалось, книга не должна исчезнуть или раздвоиться. Транзакция собирает записи в пакет, который либо проходит целиком, либо не проходит совсем.
Neo4j даёт четыре гарантии ACID, как PostgreSQL из урока про транзакции:
- атомарность: транзакция применяется целиком или никак;
- согласованность: после транзакции соблюдены все ограничения, например уникальность;
- изоляция: незафиксированные изменения не видны другим;
- долговечность: зафиксированное пишется в журнал транзакций и переживёт падение сервера.
Теория уровней изоляции подробно разобрана в уроке про изоляцию.
Один запрос, одна транзакция
В cypher-shell и в песочнице каждый запрос без особых команд выполняется в своей автоматической транзакции. Даже если запрос создаёт сто узлов, а на сто первом падает, не останется ни одного. Проверим: зададим уникальность имени и попробуем создать сразу двух участников, второй из которых уже есть.
CREATE CONSTRAINT person_name FOR (p:Person) REQUIRE p.name IS UNIQUE;
0 rows
Added 1 constraint
UNWIND ['Кира', 'Аня'] AS name
CREATE (:Person {name: name});
22N80: data exception - index entry conflict. Index entry conflict: Node(0) already exists with label `Person` and property `name` = 'Аня'.
22N79: data exception - property uniqueness constraint violated. Property uniqueness constraint violated: Node(0) already exists with label `Person` and property `name` = 'Аня'.
MATCH (p:Person {name: 'Кира'})
RETURN count(p) AS kira;
+------+
| kira |
+------+
| 0 |
+------+
1 row
Кира не создана, хотя в списке она шла первой: ошибка на Ане отменила весь запрос.
Явная транзакция: :begin, :commit, :rollback
Если транзакция должна охватить несколько запросов, её открывают вручную. В cypher-shell для этого есть команды :begin, :commit и :rollback. Пока транзакция открыта, приглашение меняется: вместо neo4j@neo4j> видно neo4j@neo4j#.
:begin
MATCH (a:Person {name: 'Аня'}) SET a.born = 1995;
MATCH (a:Person {name: 'Аня'}) RETURN a.born;
:rollback
MATCH (a:Person {name: 'Аня'}) RETURN a.born;
neo4j@neo4j> :begin
neo4j@neo4j# MATCH (a:Person {name: 'Аня'}) SET a.born = 1995;
0 rows
Set 1 property
neo4j@neo4j# MATCH (a:Person {name: 'Аня'}) RETURN a.born;
+--------+
| a.born |
+--------+
| 1995 |
+--------+
1 row
neo4j@neo4j# :rollback
neo4j@neo4j> MATCH (a:Person {name: 'Аня'}) RETURN a.born;
+--------+
| a.born |
+--------+
| 1994 |
+--------+
1 row
Внутри транзакции изменение видно, после :rollback его нет, будто и не было. А :commit фиксирует всё сделанное:
:begin
CREATE (:Person {name: 'Кира', born: 1996});
MATCH (k:Person {name: 'Кира'}), (a:Person {name: 'Аня'}) CREATE (k)-[:KNOWS {since: 2024}]->(a);
:commit
MATCH (k:Person {name: 'Кира'})-[r:KNOWS]->(a) RETURN k.name, r.since, a.name;
neo4j@neo4j> :begin
neo4j@neo4j# CREATE (:Person {name: 'Кира', born: 1996});
0 rows
Created 1 node, set 2 properties, added 1 label
neo4j@neo4j# MATCH (k:Person {name: 'Кира'}), (a:Person {name: 'Аня'}) CREATE (k)-[:KNOWS {since: 2024}]->(a);
0 rows
Created 1 relationship, set 1 property
neo4j@neo4j# :commit
neo4j@neo4j> MATCH (k:Person {name: 'Кира'})-[r:KNOWS]->(a) RETURN k.name, r.since, a.name;
+---------------------------+
| k.name | r.since | a.name |
+---------------------------+
| "Кира" | 2024 | "Аня" |
+---------------------------+
1 row
Ошибка внутри явной транзакции
Что будет, если в середине открытой транзакции запрос упадёт? cypher-shell откатит транзакцию целиком, вместе с тем, что успели сделать до ошибки:
:begin
CREATE (:Person {name: 'Лев'});
CREATE (:Person {name: 'Аня'});
MATCH (p:Person {name: 'Лев'}) RETURN count(p) AS lev;
:commit
neo4j@neo4j> :begin
neo4j@neo4j# CREATE (:Person {name: 'Лев'});
0 rows
Created 1 node, set 1 property, added 1 label
neo4j@neo4j# CREATE (:Person {name: 'Аня'});
An error occurred while in an open transaction. The transaction will be rolled back and terminated. Error: Node(0) already exists with label `Person` and property `name` = 'Аня'
neo4j@neo4j> MATCH (p:Person {name: 'Лев'}) RETURN count(p) AS lev;
+-----+
| lev |
+-----+
| 0 |
+-----+
1 row
neo4j@neo4j> :commit
There is no open transaction to commit
Сообщение пришло в старом формате ошибок: так cypher-shell пишет об ошибке внутри открытой транзакции. После него транзакции больше нет: Лев не создан, приглашение снова >, а :commit честно отвечает, что фиксировать нечего.
Когда пишут двое
Изоляция по умолчанию в Neo4j называется read committed: транзакция видит только зафиксированные изменения других, а чтение не мешает записи. При записи Neo4j берёт блокировку на узел или связь, и вторая транзакция, которая хочет изменить тот же узел, ждёт, пока первая закончится. Если две транзакции ждут друг друга по кругу, база замечает взаимную блокировку и прерывает одну из них временной ошибкой. Драйверы из прошлого урока в функциях execute_write повторяют такие транзакции сами.
Простой счётчик SET n.visits = n.visits + 1 безопасен: документация Neo4j обещает, что для такой записи база сама берёт блокировку до чтения свойства. А вот если значение прочитано заранее, через WITH n.visits AS v, и только потом записано, параллельные клиенты могут затереть приращения друг друга. Песочница однопользовательская, поэтому эти эффекты в ней не воспроизвести, их видно только на настоящем сервере с несколькими клиентами.
Попробуй сам
Песочница поддерживает команды транзакций. Запрос открывает транзакцию, переносит отзыв о «Мастере и Маргарите» от Ани к Зое и показывает состояние внутри транзакции.
Транзакция осталась открытой: приглашение с решёткой. Выполни :rollback и запроси читателей книги снова, потом повтори всё и закончи :commit.
Проверь себя
Задание
Дальше
Одна база на одном сервере держит транзакции честно. А что, если сервер упадёт или запросов станет слишком много? В следующем уроке поговорим о масштабировании Neo4j: кластерах, репликах и о том, почему граф так трудно делить между серверами.
домашка