Транзакция, откат и гонка: собираем CAS-обновление

база≈ 16 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Прошлая статья научила таблицу отказывать: уникальный индекс не пускает вторую такую же почту. Отказ — это хорошо, когда он приходит. Хуже случай, когда не пожаловался никто, а данные всё равно испортились: две вставки, из которых прошла одна, и две правки одной строки, из которых доехала одна. В этой статье два инструмента против этих двух бед. Первый — транзакция с откатом: всё или ничего. Второй — обновление с условием на версию, у которого второй запрос с той же версией меняет ноль строк. Ноль строк — не ошибка и не пустяк: это единственный способ узнать, что пока вы думали, строку правил кто-то ещё.

Разделы темы:·

Регистрация редко пишет одну строку. Учётная запись едет в одну таблицу, профиль с прозвищем — в другую, и уникальный индекс есть у обеих. Значит, отказать может любая из двух вставок — в том числе вторая, когда первая уже прошла.

Прогноз: Аня регистрируется первой и занимает прозвище ann. Следом приходит Боря — почта у него своя, а прозвище он выбрал то же. Сколько строк окажется в каждой таблице после его попытки и чем кончится вторая, уже с прозвищем bob?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Боря получил отказ по прозвищу — и остался в таблице учётных записей. Строка есть, профиля к ней нет, войти по ней нельзя: это половина человека. Вторая попытка, где прозвище свободно, отказывает по почте — по его же собственной строке. Теперь Боря не может ни войти, ни зарегистрироваться заново.

Ошибки в коде при этом нет. Обе вставки честные, отказ пришёл по делу и узнаётся по тем же признакам, что и раньше, — коду 23505 и имени индекса. Беда в другом: две вставки были задуманы как одно действие, а выполнились как два — и между ними мир успел измениться.

Действие, которое писало в две таблицы и упало посередине, оставляет после себя не ошибку, а данные. Ошибку видно в журнале, данные — нет.

Лекарство известно и называется транзакцией: группа действий, которая или выполняется целиком, или не выполняется вовсе. Настоящая база умеет это сама — BEGIN, COMMIT и ROLLBACK, — но, как и в прошлый раз, сначала соберём механизм.

Механизм простой до неприличия. Каждое действие внутри транзакции кладёт в журнал функцию, которая его отменяет: вставке отвечает удаление той же строки, правке — возврат прежних значений. Бросило тело транзакции исключение — журнал проигрывается задом наперёд. Порядок тут обязателен: если одну строку правили дважды, отменять надо с последней правки, иначе первая отмена вернёт то, что вторая немедленно перезапишет.

Прогноз: те же три попытки, но регистрация обёрнута в транзакцию. Сколько строк останется в таблицах после отказа Бори и какой номер достанется его учётной записи, когда он наконец зарегистрируется?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

После отказа в таблицах остались только строки Ани: откат убрал учётную запись, которую успела завести первая вставка. Третья попытка проходит целиком — и получает номер три, а не два.

Второй номер достался неудачной попытке и сгорел вместе с ней: откат вернул строку, но не счётчик номеров. Ровно так же ведёт себя IDENTITY у настоящей базы, и дырки в нумерации после неудачных вставок вы уже видели в прошлой статье.

Чего наш откат не делает — и это стоит понимать сразу. Он отменяет то, что уже записано, а настоящая база просто не показывает незафиксированное никому, кроме самой транзакции. Разница видна, как только рядом появится второй читатель: у нас он успеет увидеть полуфабрикат и уехать с ним, у базы — нет. Как это устроено на самом деле, разбирает статья про настоящий Postgres.

Откат в модели — это отмена сделанного. Откат в базе — это отказ показывать сделанное. Первое можно подсмотреть со стороны, второе нельзя.

Транзакция спасает от падения посередине. От соседа она не спасает — и вот второй сюжет, в котором опять никто ни на что не жалуется.

Обычная правка заметки — это чтение и запись: обработчик берёт строку, что-то в ней меняет и кладёт обратно. Между чтением и записью стоит поход в базу, то есть await, и на этом месте обработчик отпускает поток. Что бывает дальше, вы видели на проверке занятости перед вставкой: соседний запрос успевает ровно то же самое.

Прогноз: сначала два переименования одной заметки по очереди, потом два разом. Сколько правок насчитает строка в каждом случае?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
По очереди — две правки, и счётчик показывает две. Разом — тоже две правки, обе ответили «записал», а счётчик показывает одну. Вторая запись затёрла первую целиком: она положила в строку то, что прочитала до правки соседа, вместе с его исчезнувшим названием.

Ни одна сторона об этом не узнает. Клиент, отправивший первую правку, получил успешный ответ и показал человеку сохранённый текст — а в базе лежит чужой. Ту же беду на уровне двух устройств разбирала часть V: пять подходов превратились в четыре, потому что оба устройства прибавили единицу к одному и тому же числу.

У потерянного обновления нет ни кода ошибки, ни строки в журнале. Единственное его проявление — недовольный человек, который уверен, что правку сохранял.

Починить это перепроверкой перед записью нельзя: перепроверка будет таким же снимком прошлого, как и первое чтение. Годится одно — положить проверку внутрь самой записи, туда, где между вопросом и ответом ничего не помещается.

Для этого строке нужна колонка, по которой видно, менялась ли она, — версия. Читая строку, клиент забирает версию вместе с данными; записывая, присылает её обратно, и запрос выглядит так: UPDATE notes SET title = $1, version = version + 1 WHERE id = $2 AND version = $3. Приём зовут сравнением с обменом (compare-and-swap, CAS) — тем же словом, что и атомарную операцию процессора, и по той же причине: проверка и запись неразделимы.

Ответом на такой запрос будет не строка, а число изменённых строк — rowCount у драйвера. Одна — записали. Ноль — не записали, и это весь разговор.

Прогноз: те же два одновременных переименования, но каждое несёт версию, которую прочитало. Что вернёт каждое и какое название останется в строке?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первое обновление изменило одну строку, второе — ноль. В строке лежит название первого, версия выросла на единицу, и счётчик правок честно показывает одну: правка и была одна. Вторая не потерялась молча — её просто не случилось.

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

Версия — не номер правки для человека, а отпечаток строки, который клиент возвращает обратно, чтобы база могла сравнить.

У нуля изменённых строк есть неприятная особенность: это не исключение. Драйвер не бросает, try не срабатывает, и код, не смотрящий на rowCount, ведёт себя точно так же, как раньше, — отвечает «сохранено» и ничего не сохраняет. Проверка нуля обязательна, и забыть её легче, чем забыть catch.

Причин у нуля три, и запрос их не различает: строку кто-то поправил; строки нет вовсе; строка есть, но не ваша — если в условие зашито владение. Различить можно единственным способом: перечитать строку без условия на версию и посмотреть, что вернулось.

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

Прогноз: две правки с одной и той же версией и одна — по несуществующему номеру строки; следом три одновременных просмотра, каждый с повторами. Что ответит каждая правка и сколько просмотров насчитает строка?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первая правка прошла. Вторая получила ноль и, перечитав строку, отличила конфликт от пропажи: строка на месте, версия уже вторая. Третья получила тот же ноль и тем же путём выяснила, что строки нет вовсе, — это NOT_FOUND, а не конфликт.

Три просмотра разом уложились в одну, две и три попытки: каждый следующий натыкался на чужую свежую версию, перечитывал строку и пробовал снова. Насчитано три — не пропал ни один. Цена — лишние круги к базе, и на очень горячей строке их будет много: поэтому у повторов всегда есть предел, а за пределом — тот же доменный код.

Повтор годится там, где правку можно пересчитать заново. Текст, набранный человеком, пересчитать нельзя — его можно только затереть.

Модель собрана — посмотрим на настоящую базу. Прогонщик поднимает временный кластер PostgreSQL на свободном порту, заводит пустую базу и передаёт её адрес примеру; адрес печатается первой строкой и без пароля. Клиентов здесь два, и это важно: запросы одного клиента драйвер pg выстраивает в очередь, так что одновременности внутри одного соединения не бывает.

прогон под базой PostgreSQL 18.4 · node 26.5.0 · 23 сентября 2026
Два соединения правят одну строку: сначала без условия на версию, потом с ним
import pg from 'pg';

// адрес базы — первой строкой и без пароля: видно, куда ушли запросы
const url = new URL(process.env.DATABASE_URL);
url.password = '';
console.log('база:', url.href);

// два клиента — два соединения: запросы одного клиента драйвер выстраивает в очередь,
// и никакой одновременности внутри него не бывает
const a = new pg.Client({ connectionString: process.env.DATABASE_URL });
const b = new pg.Client({ connectionString: process.env.DATABASE_URL });
await a.connect();
await b.connect();

await a.query(`CREATE TABLE notes (
  id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  title text NOT NULL,
  version integer NOT NULL DEFAULT 1
)`);
await a.query("INSERT INTO notes (title) VALUES ('черновик')");

const read = async (client) => (await client.query('SELECT title, version FROM notes WHERE id = 1')).rows[0];
const reset = () => a.query("UPDATE notes SET title = 'черновик', version = 1 WHERE id = 1");

// Сцена 1: оба открыли заметку, потом оба сохранили — запрос без условия на версию
let seenA = await read(a);
let seenB = await read(b);
console.log('оба открыли версию:', seenA.version, seenB.version);

const plain = (client, title) => client.query('UPDATE notes SET title = $1, version = version + 1 WHERE id = 1', [title]);
console.log('без условия, A:', (await plain(a, 'список покупок')).rowCount);
console.log('без условия, B:', (await plain(b, 'список дел')).rowCount);
console.log('в строке:', await read(a));

// Сцена 2: то же самое, но обновление несёт версию, которую клиент видел
await reset();
seenA = await read(a);
seenB = await read(b);
console.log('снова оба открыли версию:', seenA.version, seenB.version);

const cas = (client, title, version) =>
  client.query('UPDATE notes SET title = $1, version = version + 1 WHERE id = 1 AND version = $2', [title, version]);
console.log('с условием, A:', (await cas(a, 'список покупок', seenA.version)).rowCount);
console.log('с условием, B:', (await cas(b, 'список дел', seenB.version)).rowCount);
console.log('в строке:', await read(a));

await a.end();
await b.end();
node cas.mjs

Первая сцена повторяет потерянное обновление буква в букву. Оба клиента прочитали версию 1, оба записали, оба получили rowCount 1 — и в строке осталось название второго. Версия при этом выросла до трёх: база честно посчитала две правки, а увидеть можно только одну.

Вторая сцена — то же самое с условием. Первый UPDATE меняет одну строку, второй — ноль: его WHERE version = 1 больше не выполняется. Ни исключения, ни предупреждения; всё, что отличает удачу от неудачи, — это число.

Заметьте, чего база не сделала сама. Два UPDATE одной строки одновременно она не выполняет: второй ждёт, пока первый зафиксируется. Но ожидание от потерянного обновления не спасает, потому что беда случилась раньше — оба клиента прочитали строку до того, как хоть кто-то начал писать. База защищает запись, а не ваше представление о строке.

Разницу между «подождать» и «отказать» видно, если заглянуть в незакрытую транзакцию. Ниже клиент A начинает транзакцию и правит строку, не фиксируя её; клиент B тут же правит ту же строку, а о том, чем B занят, мы спрашиваем саму базу — по номеру его обслуживающего процесса в pg_stat_activity. Сразу под адресом базы прогон печатает уровень изоляции: read committed, умолчание Postgres, и всё дальнейшее верно именно для него.

прогон под базой PostgreSQL 18.4 · node 26.5.0 · 23 сентября 2026
Незакрытая транзакция держит строку: что получит второй клиент с прибавкой и что — с условием на версию
import pg from 'pg';

const url = new URL(process.env.DATABASE_URL);
url.password = '';
console.log('база:', url.href);

const a = new pg.Client({ connectionString: process.env.DATABASE_URL });
const b = new pg.Client({ connectionString: process.env.DATABASE_URL });
await a.connect();
await b.connect();
console.log('уровень изоляции:', (await a.query('SHOW transaction_isolation')).rows[0].transaction_isolation);

await a.query(`CREATE TABLE heads (
  user_id integer PRIMARY KEY,
  seq integer NOT NULL DEFAULT 0,
  version integer NOT NULL DEFAULT 1
)`);
await a.query('INSERT INTO heads (user_id) VALUES (1)');

// о том, чем занят второй клиент, спрашиваем саму базу — по номеру его обслуживающего процесса
const pidB = (await b.query('SELECT pg_backend_pid() AS pid')).rows[0].pid;
const stateB = async () => {
  for (let i = 0; i < 200; i += 1) {
    const { rows } = await a.query('SELECT wait_event_type FROM pg_stat_activity WHERE pid = $1', [pidB]);
    if (rows[0]?.wait_event_type === 'Lock') return rows[0].wait_event_type;
    await new Promise((resolve) => setTimeout(resolve, 25));
  }
  return 'блокировки не дождались';
};

// Сцена 1: номер очереди — прибавка на месте, внутри незакрытой транзакции A
await a.query('BEGIN');
console.log('A взял номер:', (await a.query('UPDATE heads SET seq = seq + 1 WHERE user_id = 1 RETURNING seq')).rows[0].seq);
const bumpB = b.query('UPDATE heads SET seq = seq + 1 WHERE user_id = 1 RETURNING seq');
console.log('пока A не зафиксировал, B:', await stateB());
await a.query('COMMIT');
const bumped = await bumpB;
console.log('B получил номер:', bumped.rows[0].seq, '| строк изменено:', bumped.rowCount);

// Сцена 2: то же ожидание, но обновление несёт условие на версию
await a.query('BEGIN');
console.log('A с условием version = 1 | строк изменено:', (await a.query('UPDATE heads SET version = version + 1 WHERE user_id = 1 AND version = 1')).rowCount);
const casB = b.query('UPDATE heads SET version = version + 1 WHERE user_id = 1 AND version = 1');
console.log('пока A не зафиксировал, B:', await stateB());
await a.query('COMMIT');
console.log('B с условием version = 1 | строк изменено:', (await casB).rowCount);
console.log('в строке:', (await a.query('SELECT seq, version FROM heads WHERE user_id = 1')).rows[0]);

await a.end();
await b.end();
node wait.mjs

Оба раза B честно ждал: wait_event_type говорит Lock. Дальше пути расходятся. Прибавка seq = seq + 1 после ожидания перечитывает строку и прибавляет к новому значению — номера идут 1 и 2, не потеряно ничего. Обновление с условием version = 1 после того же ожидания перепроверяет условие на новой строке, и оно уже ложно: ноль строк, повторяйте.

Отсюда правило, которое пригодится в главе про синхронизацию: там, где нужен сквозной номер без дыр, берут прибавку на месте и ожидание блокировки, а не версию. CAS в этом месте только заставлял бы повторять запрос: номер очереди сам по себе никакого конфликта не несёт, спорить о нём не о чем.

И то и другое держится на уровне изоляции. На repeatable read после того же ожидания оба запроса не возвращают ни ноль строк, ни номер: они падают с кодом 40001 и словами «could not serialize access due to concurrent update» — повторять приходится всю транзакцию целиком. Сам такой отказ — код и текст слово в слово — показан записанным прогоном в статье про настоящий Postgres; на этой паре запросов он проверен отдельным опытом на PostgreSQL 18.4.

Можно: SELECT … FOR UPDATE берёт строку под замок, и соседний запрос ждёт вашей фиксации — то самое ожидание, что и выше, только взятое заранее. Беда в том, что замок держится до конца транзакции, а между чтением и записью у веб-приложения стоит человек, который думает над текстом. Транзакция, открытая на время раздумий, — это замок на строке на минуту, на две и до закрытия вкладки.

Версия не держит ничего. Между двумя запросами база о вас не помнит вовсе, а конфликт обнаруживается в момент записи. Поэтому приём и зовут оптимистичным: он исходит из того, что конфликты редки, и платит только в те разы, когда ошибся. Пессимистичный замок платит всегда — и тем больше, чем дольше думает человек.

Разница между моделью и настоящей базой здесь не в приёме, а в том, кто его выполняет. Приём один и тот же: условие на прежнее значение, положенное внутрь самой записи.

  • Действие, которое пишет в две таблицы, может упасть посередине, и тогда после него остаются не ошибки, а данные: учётная запись без профиля, занявшая собой почту. Лекарство — транзакция: всё или ничего.
  • Откат в модели — журнал обратных действий, проигранный задом наперёд: вставке отвечает удаление, правке — возврат прежних значений. Порядок обязателен, иначе первая отмена вернёт то, что вторая немедленно перезапишет. Счётчик номеров откат не отматывает: номер неудачной попытки сгорает.
  • Транзакция не защищает от соседа. Чтение, await, запись прочитанного — это потерянное обновление: обе правки отвечают «сохранено», в строке остаётся одна, и ни кода ошибки, ни записи в журнале после этого нет.
  • Сравнение с обменом — условие на версию внутри самой записи: WHERE id = $1 AND version = $2 и version = version + 1 в SET. Проверка и запись неразделимы, поэтому между ними ничего не помещается — ни соседний запрос, ни ваш await.
  • Ноль изменённых строк — не исключение: драйвер молчит, и код, не смотрящий на rowCount, врёт человеку так же, как раньше. Причин у нуля три — поправили, нет вовсе, не ваша, — и различает их только перечитанная строка.
  • Конфликт, который можно пересчитать, повторяют с новой версией и с пределом попыток; конфликт над чужим текстом несут человеку доменным кодом. А сквозной номер без дыр версией не делают вовсе: там берут прибавку на месте и ожидание блокировки.

1/4Два обработчика прочитали одну строку, каждый поменял в ней своё поле и записал результат. Уникальных индексов на этих полях нет. Что будет в журнале базы?

2/4Запрос UPDATE notes SET title = $1, version = version + 1 WHERE id = $2 AND version = $3 изменил ноль строк. Что это значит?

3/4Почему в модели условие на версию и сама запись стоят вплотную, без await между ними?

4/4Номер изменения в синхронизации берут запросом update … set seq = seq + 1 … returning seq. Почему это не CAS?

Три просмотра, а насчитан один

Счётчик просмотров заметки увеличивает обработчик: читает строку, ходит в базу, пишет прочитанное плюс один. Три одновременных просмотра насчитывают один. Почините счёт, не заводя колонку версии и не добавляя повторов.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Прочитанное значение устаревает на await. Значит, читать надо не заранее, а вплотную к записи — тогда между чтением и записью ничего не поместится.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Насчитано три. В SQL это выглядит ещё проще — читать отдельно вообще не нужно: update notes set views = views + 1 where id = $1. Одна команда, прибавка к тому значению, которое база видит в момент записи.

Версия с условием и повторами тоже дала бы три, но заплатила бы лишними кругами к базе. Прибавка дешевле, потому что спорить тут не о чем: «плюс один» от двух клиентов — это «плюс два» в любом порядке, и чья правка была первой, никого не интересует.

Строка вернулась, а номер другой

Отмена удаления написана в лоб: запомнить строку, удалить, при откате вставить обратно. Строка возвращается, но ссылки на неё из соседней таблицы после этого ведут в пустоту. Сделайте так, чтобы отмена возвращала строку тем же номером; читающий запрос alive не трогайте.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Номер остаётся прежним, ссылка из соседней таблицы продолжает вести на заметку. Плата за это — условие deleted !== true в каждом читающем запросе: пропустили в одном — и удалённая заметка всплыла в списке. Про то, как не забыть это условие ни в одном запросе, будет отдельная статья.

Такая помеченная строка называется надгробием, и в части V вы её уже встречали: устройство, которое надгробия не получит, оставит удалённую заметку у себя навсегда.

Отмена не в том порядке

Вот та же транзакция, только короче — в ней одни правки. Отмена написана в лоб и проигрывает журнал с начала, а строка после неё встаёт не туда: название осталось новым, папка вернулась старой. Почините отмену.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь отмена сначала возвращает папку, а потом название — и строка оказывается такой, какой была до транзакции.

Настоящая база по журналу задом наперёд не ходит: незафиксированные версии строк она и так никому не показывала, а отмена для неё — пометка «эту транзакцию не считать». Наша модель пишет сразу и поэтому обязана уметь пятиться.