Транзакция, откат и гонка: собираем CAS-обновление
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Прошлая статья научила таблицу отказывать: уникальный индекс не пускает вторую такую же почту. Отказ — это хорошо, когда он приходит. Хуже случай, когда не пожаловался никто, а данные всё равно испортились: две вставки, из которых прошла одна, и две правки одной строки, из которых доехала одна. В этой статье два инструмента против этих двух бед. Первый — транзакция с откатом: всё или ничего. Второй — обновление с условием на версию, у которого второй запрос с той же версией меняет ноль строк. Ноль строк — не ошибка и не пустяк: это единственный способ узнать, что пока вы думали, строку правил кто-то ещё.
Регистрация редко пишет одну строку. Учётная запись едет в одну таблицу, профиль с прозвищем — в другую, и уникальный индекс есть у обеих. Значит, отказать может любая из двух вставок — в том числе вторая, когда первая уже прошла.
Прогноз: Аня регистрируется первой и занимает прозвище ann. Следом приходит Боря — почта у него своя, а прозвище он выбрал то же. Сколько строк окажется в каждой таблице после его попытки и чем кончится вторая, уже с прозвищем bob?
Боря получил отказ по прозвищу — и остался в таблице учётных записей. Строка есть, профиля к ней нет, войти по ней нельзя: это половина человека. Вторая попытка, где прозвище свободно, отказывает по почте — по его же собственной строке. Теперь Боря не может ни войти, ни зарегистрироваться заново.
Ошибки в коде при этом нет. Обе вставки честные, отказ пришёл по делу и узнаётся по тем же признакам, что и раньше, — коду 23505 и имени индекса. Беда в другом: две вставки были задуманы как одно действие, а выполнились как два — и между ними мир успел измениться.
Действие, которое писало в две таблицы и упало посередине, оставляет после себя не ошибку, а данные. Ошибку видно в журнале, данные — нет.
Лекарство известно и называется транзакцией: группа действий, которая или выполняется целиком, или не выполняется вовсе. Настоящая база умеет это сама — BEGIN, COMMIT и ROLLBACK, — но, как и в прошлый раз, сначала соберём механизм.
Механизм простой до неприличия. Каждое действие внутри транзакции кладёт в журнал функцию, которая его отменяет: вставке отвечает удаление той же строки, правке — возврат прежних значений. Бросило тело транзакции исключение — журнал проигрывается задом наперёд. Порядок тут обязателен: если одну строку правили дважды, отменять надо с последней правки, иначе первая отмена вернёт то, что вторая немедленно перезапишет.
Прогноз: те же три попытки, но регистрация обёрнута в транзакцию. Сколько строк останется в таблицах после отказа Бори и какой номер достанется его учётной записи, когда он наконец зарегистрируется?
После отказа в таблицах остались только строки Ани: откат убрал учётную запись, которую успела завести первая вставка. Третья попытка проходит целиком — и получает номер три, а не два.
Второй номер достался неудачной попытке и сгорел вместе с ней: откат вернул строку, но не счётчик номеров. Ровно так же ведёт себя IDENTITY у настоящей базы, и дырки в нумерации после неудачных вставок вы уже видели в прошлой статье.
Чего наш откат не делает — и это стоит понимать сразу. Он отменяет то, что уже записано, а настоящая база просто не показывает незафиксированное никому, кроме самой транзакции. Разница видна, как только рядом появится второй читатель: у нас он успеет увидеть полуфабрикат и уехать с ним, у базы — нет. Как это устроено на самом деле, разбирает статья про настоящий Postgres.
Откат в модели — это отмена сделанного. Откат в базе — это отказ показывать сделанное. Первое можно подсмотреть со стороны, второе нельзя.
Транзакция спасает от падения посередине. От соседа она не спасает — и вот второй сюжет, в котором опять никто ни на что не жалуется.
Обычная правка заметки — это чтение и запись: обработчик берёт строку, что-то в ней меняет и кладёт обратно. Между чтением и записью стоит поход в базу, то есть await, и на этом месте обработчик отпускает поток. Что бывает дальше, вы видели на проверке занятости перед вставкой: соседний запрос успевает ровно то же самое.
Прогноз: сначала два переименования одной заметки по очереди, потом два разом. Сколько правок насчитает строка в каждом случае?
Ни одна сторона об этом не узнает. Клиент, отправивший первую правку, получил успешный ответ и показал человеку сохранённый текст — а в базе лежит чужой. Ту же беду на уровне двух устройств разбирала часть V: пять подходов превратились в четыре, потому что оба устройства прибавили единицу к одному и тому же числу.
У потерянного обновления нет ни кода ошибки, ни строки в журнале. Единственное его проявление — недовольный человек, который уверен, что правку сохранял.
Починить это перепроверкой перед записью нельзя: перепроверка будет таким же снимком прошлого, как и первое чтение. Годится одно — положить проверку внутрь самой записи, туда, где между вопросом и ответом ничего не помещается.
Для этого строке нужна колонка, по которой видно, менялась ли она, — версия. Читая строку, клиент забирает версию вместе с данными; записывая, присылает её обратно, и запрос выглядит так: UPDATE notes SET title = $1, version = version + 1 WHERE id = $2 AND version = $3. Приём зовут сравнением с обменом (compare-and-swap, CAS) — тем же словом, что и атомарную операцию процессора, и по той же причине: проверка и запись неразделимы.
Ответом на такой запрос будет не строка, а число изменённых строк — rowCount у драйвера. Одна — записали. Ноль — не записали, и это весь разговор.
Прогноз: те же два одновременных переименования, но каждое несёт версию, которую прочитало. Что вернёт каждое и какое название останется в строке?
Первое обновление изменило одну строку, второе — ноль. В строке лежит название первого, версия выросла на единицу, и счётчик правок честно показывает одну: правка и была одна. Вторая не потерялась молча — её просто не случилось.
Работает это по той же причине, по которой работал уникальный индекс: вопрос задан тому, кто пишет, и в тот момент, когда он пишет. В модели условие и запись стоят вплотную, без await между ними; в базе это одна команда, и разорвать её нечем.
Версия — не номер правки для человека, а отпечаток строки, который клиент возвращает обратно, чтобы база могла сравнить.
У нуля изменённых строк есть неприятная особенность: это не исключение. Драйвер не бросает, try не срабатывает, и код, не смотрящий на rowCount, ведёт себя точно так же, как раньше, — отвечает «сохранено» и ничего не сохраняет. Проверка нуля обязательна, и забыть её легче, чем забыть catch.
Причин у нуля три, и запрос их не различает: строку кто-то поправил; строки нет вовсе; строка есть, но не ваша — если в условие зашито владение. Различить можно единственным способом: перечитать строку без условия на версию и посмотреть, что вернулось.
Что делать дальше, зависит от правки. Если её некому пересказать — счётчик просмотров, отметка «прочитано», — конфликт незачем нести наверх: перечитать, повторить с новой версией, и так несколько раз. Если правку делал человек и текст его, повтор недопустим: он затрёт чужой текст ровно так же, как это делало обновление без условия. Тогда наверх едет доменный код из того же списка, а клиент показывает «заметку успели поправить, обновите страницу».
Прогноз: две правки с одной и той же версией и одна — по несуществующему номеру строки; следом три одновременных просмотра, каждый с повторами. Что ответит каждая правка и сколько просмотров насчитает строка?
Первая правка прошла. Вторая получила ноль и, перечитав строку, отличила конфликт от пропажи: строка на месте, версия уже вторая. Третья получила тот же ноль и тем же путём выяснила, что строки нет вовсе, — это NOT_FOUND, а не конфликт.
Три просмотра разом уложились в одну, две и три попытки: каждый следующий натыкался на чужую свежую версию, перечитывал строку и пробовал снова. Насчитано три — не пропал ни один. Цена — лишние круги к базе, и на очень горячей строке их будет много: поэтому у повторов всегда есть предел, а за пределом — тот же доменный код.
Повтор годится там, где правку можно пересчитать заново. Текст, набранный человеком, пересчитать нельзя — его можно только затереть.
Модель собрана — посмотрим на настоящую базу. Прогонщик поднимает временный кластер PostgreSQL на свободном порту, заводит пустую базу и передаёт её адрес примеру; адрес печатается первой строкой и без пароля. Клиентов здесь два, и это важно: запросы одного клиента драйвер pg выстраивает в очередь, так что одновременности внутри одного соединения не бывает.
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();
база: postgres://postgres@127.0.0.1:<порт-базы>/app
оба открыли версию: 1 1
без условия, A: 1
без условия, B: 1
в строке: { title: 'список дел', version: 3 }
снова оба открыли версию: 1 1
с условием, A: 1
с условием, B: 0
в строке: { title: 'список покупок', version: 2 }Первая сцена повторяет потерянное обновление буква в букву. Оба клиента прочитали версию 1, оба записали, оба получили rowCount 1 — и в строке осталось название второго. Версия при этом выросла до трёх: база честно посчитала две правки, а увидеть можно только одну.
Вторая сцена — то же самое с условием. Первый UPDATE меняет одну строку, второй — ноль: его WHERE version = 1 больше не выполняется. Ни исключения, ни предупреждения; всё, что отличает удачу от неудачи, — это число.
Заметьте, чего база не сделала сама. Два UPDATE одной строки одновременно она не выполняет: второй ждёт, пока первый зафиксируется. Но ожидание от потерянного обновления не спасает, потому что беда случилась раньше — оба клиента прочитали строку до того, как хоть кто-то начал писать. База защищает запись, а не ваше представление о строке.
Разницу между «подождать» и «отказать» видно, если заглянуть в незакрытую транзакцию. Ниже клиент A начинает транзакцию и правит строку, не фиксируя её; клиент B тут же правит ту же строку, а о том, чем B занят, мы спрашиваем саму базу — по номеру его обслуживающего процесса в pg_stat_activity. Сразу под адресом базы прогон печатает уровень изоляции: read committed, умолчание Postgres, и всё дальнейшее верно именно для него.
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();
база: postgres://postgres@127.0.0.1:<порт-базы>/app
уровень изоляции: read committed
A взял номер: 1
пока A не зафиксировал, B: Lock
B получил номер: 2 | строк изменено: 1
A с условием version = 1 | строк изменено: 1
пока A не зафиксировал, B: Lock
B с условием version = 1 | строк изменено: 0
в строке: { seq: 2, version: 2 }Оба раза 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?
Три просмотра, а насчитан один
Счётчик просмотров заметки увеличивает обработчик: читает строку, ходит в базу, пишет прочитанное плюс один. Три одновременных просмотра насчитывают один. Почините счёт, не заводя колонку версии и не добавляя повторов.
Прочитанное значение устаревает на await. Значит, читать надо не заранее, а вплотную к записи — тогда между чтением и записью ничего не поместится.
Насчитано три. В SQL это выглядит ещё проще — читать отдельно вообще не нужно: update notes set views = views + 1 where id = $1. Одна команда, прибавка к тому значению, которое база видит в момент записи.
Версия с условием и повторами тоже дала бы три, но заплатила бы лишними кругами к базе. Прибавка дешевле, потому что спорить тут не о чем: «плюс один» от двух клиентов — это «плюс два» в любом порядке, и чья правка была первой, никого не интересует.
Строка вернулась, а номер другой
Отмена удаления написана в лоб: запомнить строку, удалить, при откате вставить обратно. Строка возвращается, но ссылки на неё из соседней таблицы после этого ведут в пустоту. Сделайте так, чтобы отмена возвращала строку тем же номером; читающий запрос alive не трогайте.
Вставка раздаёт номера сама и прежний номер не принимает — это мы видели ещё на сгоревшем номере в транзакции. Значит, удалять строку не надо вовсе: достаточно пометить её удалённой, и тогда отмена становится обычной правкой.
Номер остаётся прежним, ссылка из соседней таблицы продолжает вести на заметку. Плата за это — условие deleted !== true в каждом читающем запросе: пропустили в одном — и удалённая заметка всплыла в списке. Про то, как не забыть это условие ни в одном запросе, будет отдельная статья.
Такая помеченная строка называется надгробием, и в части V вы её уже встречали: устройство, которое надгробия не получит, оставит удалённую заметку у себя навсегда.
Отмена не в том порядке
Вот та же транзакция, только короче — в ней одни правки. Отмена написана в лоб и проигрывает журнал с начала, а строка после неё встаёт не туда: название осталось новым, папка вернулась старой. Почините отмену.
Журнал отмен — это стопка: последнее сделанное отменяется первым. Первая запись в журнале помнит строку до всех правок, вторая — строку после первой правки. Проиграв их по порядку, мы сначала вернём исходное состояние, а потом тут же затрём его промежуточным.
Теперь отмена сначала возвращает папку, а потом название — и строка оказывается такой, какой была до транзакции.
Настоящая база по журналу задом наперёд не ходит: незафиксированные версии строк она и так никому не показывала, а отмена для неё — пометка «эту транзакцию не считать». Наша модель пишет сразу и поэтому обязана уметь пятиться.