← назад к разделу

База данных, которая «теряет деньги при сбое» — это не база данных, а кэш с амбициями. Разберёмся с нуля: что такое транзакция, что гарантирует ACID и почему выбор уровня изоляции — это не теория, а практическое решение с последствиями.

Обязательно

Что такое транзакция и зачем она нужна

Без транзакций любой сбой посередине операции оставляет данные в сломанном состоянии. Представьте перевод денег:

UPDATE account SET balance = balance - 100 WHERE id = 1;
-- сервер падает прямо здесь
UPDATE account SET balance = balance + 100 WHERE id = 2;

Первая строка выполнилась, вторая — нет. Деньги исчезли.

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

BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;

Теперь при любом сбое PostgreSQL либо применит обе строки, либо откатит обе.

ACID — четыре гарантии транзакций

ACID — аббревиатура, описывающая, что именно гарантирует база данных.

Атомарность (Atomicity). Транзакция — неделимое действие. Если что-то пошло не так до COMMIT, все изменения откатываются автоматически.

PostgreSQL обеспечивает это через WAL (Write-Ahead Log) — журнал изменений. Каждая операция сначала записывается в журнал, потом применяется к данным. При падении сервера PostgreSQL читает журнал и заново проигрывает подряд все записанные в него изменения — не разбирая, чья транзакция успела зафиксироваться, а чья оборвалась.

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

читающий статус транзакции нашёл версию строки версия видна зафиксирована версия пропущена оборвана оборванная версия остаётся её убирает VACUUM

Оборванную транзакцию никто не откатывает: строки остаются в таблице, а читающий отсеивает их по статусу.

Согласованность (Consistency). После транзакции база остаётся в корректном состоянии — все ограничения соблюдены: NOT NULL, FOREIGN KEY, UNIQUE, CHECK.

INSERT INTO product (category_id, price, name) VALUES (99, 200, 'Тортик');
-- ERROR: insert or update on table "product" violates foreign key constraint
-- Вся транзакция откатывается

PostgreSQL проверяет только те ограничения, которые объявлены в схеме: какие данные считать «корректными» — решает архитектор, а не база.

Изоляция (Isolation). Параллельные транзакции не должны мешать друг другу. Это самая сложная буква — на практике существует несколько уровней изоляции с разными компромиссами между скоростью и строгостью. Разберём подробно ниже.

Долговечность (Durability). Если COMMIT вернул успех — изменения переживут любой сбой: падение сервера, отключение питания, перезагрузку.

Тот же WAL: на COMMIT журнал сбрасывается на диск (fsync), и только потом клиент получает ответ. Данные на диске — даже если страницы данных ещё не записаны.

Параметр synchronous_commit = off ускоряет коммиты тем, что не ждёт сброса журнала на диск: клиент получает ответ раньше, чем запись доехала. Потерять при сбое можно только хвост — порядка трёх интервалов wal_writer_delay, то есть около полусекунды при значении по умолчанию; всё, что старше, на диске. Это принципиально отличается от fsync = off, где теряется не хвост, а целостность: после аварийного выключения база может не подняться вовсе, потому что страницы данных и журнал разъехались. Первое уместно для метрик и журналов доступа, второе — только на стенде, который не жалко пересоздать.

Как PostgreSQL не мешает транзакциям читать друг у друга — MVCC

Наивный способ изолировать транзакции — блокировки: пока одна транзакция пишет, другая не читает. Это медленно.

PostgreSQL использует другой подход — MVCC (Multi-Version Concurrency Control). Каждая строка хранит несколько версий. UPDATE не меняет строку на месте — создаёт новую версию, а старая остаётся на диске. Транзакция, которая уже взяла свой снимок данных, продолжает видеть старую версию: новая для неё как будто ещё не появилась. Никаких блокировок чтения — каждая транзакция видит свой согласованный снимок данных. А вот когда она возьмёт следующий снимок и наконец увидит новое значение — решает уровень изоляции, и об этом вся вторая половина статьи.

Старые версии строк убирает фоновый процесс autovacuum, когда они становятся невидимыми всем активным транзакциям. Команда VACUUM — та же уборка, но запущенная руками; в списке процессов на сервере искать надо именно autovacuum. Версии на диске одни и те же для всех, а когда брать снимок, решает уровень изоляции, и в этом вся разница между уровнями.

T1 версии строки id=3 T2 price=150 READ COMMITTED — снимок на каждый запрос REPEATABLE READ — снимок на всю транзакцию BEGIN SELECT → 150 UPDATE price=200COMMITprice=200 SELECT → 200 тот же запрос в той же транзакции — другое значение BEGIN SELECT → 150снимок T1 зафиксирован UPDATE price=200COMMITprice=200 SELECT → 150 снимок держится до конца — новой версии T1 не видит

Обе половины ролика — один и тот же сценарий: T1 читает цену, параллельная T2 меняет её и коммитит, на диске остаются две версии строки. Разница только в том, откуда T1 читает второй раз. На Read Committed каждый запрос берёт свежий снимок и видит 200; на Repeatable Read снимок зафиксирован в начале транзакции, и T1 снова видит 150 — новая версия для неё просто не существует.

Снимок — это просто граница

Каждая версия строки помнит два номера: xmin — кто её создал, xmax — кто её заменил (ноль у живой). Если упростить, снимок — это граница: версия видна, когда xmin не больше границы, а xmax либо ноль, либо больше. Вся разница между уровнями — когда эту границу берут:

живой пример

import java.util.List;

public class SnapshotDemo {

    record RowVersion(long xmin, long xmax, int price) {}

    static Integer visible(List<RowVersion> row, long snapshot) {
        for (RowVersion v : row) {
            boolean born = v.xmin() <= snapshot;
            boolean dead = v.xmax() != 0 && v.xmax() <= snapshot;
            if (born && !dead) {
                return v.price();
            }
        }
        return null;
    }

    public static void main(String[] args) {
        long t1Start = 99;
        List<RowVersion> before = List.of(new RowVersion(50, 0, 150));
        List<RowVersion> after = List.of(
                new RowVersion(50, 150, 150),
                new RowVersion(150, 0, 200));

        System.out.println("T1, первый SELECT:        " + visible(before, t1Start));
        System.out.println("Read Committed, второй:   " + visible(after, 150));
        System.out.println("Repeatable Read, второй:  " + visible(after, t1Start));
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

package main

import "fmt"

type rowVersion struct {
	xmin, xmax uint64
	price      int
}

func visible(row []rowVersion, snapshot uint64) any {
	for _, v := range row {
		born := v.xmin <= snapshot
		dead := v.xmax != 0 && v.xmax <= snapshot
		if born && !dead {
			return v.price
		}
	}
	return nil
}

func main() {
	const t1Start = 99
	before := []rowVersion{{50, 0, 150}}
	after := []rowVersion{{50, 150, 150}, {150, 0, 200}}
	fmt.Println("T1, первый SELECT:       ", visible(before, t1Start))
	fmt.Println("Read Committed, второй:  ", visible(after, 150))
	fmt.Println("Repeatable Read, второй: ", visible(after, t1Start))
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

function visible(row, snapshot) {
  for (const v of row) {
    const born = v.xmin <= snapshot;
    const dead = v.xmax !== 0 && v.xmax <= snapshot;
    if (born && !dead) return v.price;
  }
  return null;
}

const t1Start = 99;
const before = [{ xmin: 50, xmax: 0, price: 150 }];
const after = [{ xmin: 50, xmax: 150, price: 150 }, { xmin: 150, xmax: 0, price: 200 }];

console.log('T1, первый SELECT:       ', visible(before, t1Start));
console.log('Read Committed, второй:  ', visible(after, 150));
console.log('Repeatable Read, второй: ', visible(after, t1Start));
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

from dataclasses import dataclass


@dataclass(frozen=True)
class RowVersion:
    xmin: int
    xmax: int
    price: int


def visible(row: list[RowVersion], snapshot: int) -> int | None:
    for v in row:
        born = v.xmin <= snapshot
        dead = v.xmax != 0 and v.xmax <= snapshot
        if born and not dead:
            return v.price
    return None


t1_start = 99
before = [RowVersion(50, 0, 150)]
after = [RowVersion(50, 150, 150), RowVersion(150, 0, 200)]
print("T1, первый SELECT:       ", visible(before, t1_start))
print("Read Committed, второй:  ", visible(after, 150))
print("Repeatable Read, второй: ", visible(after, t1_start))
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Read Committed берёт границу заново перед каждым запросом — номер 150 попадает внутрь, и виден 200. Repeatable Read держит исходную границу 99 — виден 150.

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

Четыре уровня изоляции

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

УровеньDirty ReadNon-Repeatable ReadPhantom ReadWrite Skew
Read Uncommittedв PG — нетдадада
Read Committed (по умолчанию)нетдадада
Repeatable Readнетнетнетда
Serializableнетнетнетнет

Аномалии в таблице — «сюрпризы», которые получает транзакция при параллельной работе. Разберём каждую.

Read Uncommitted — в PostgreSQL работает как Read Committed

Стандарт допускает на этом уровне чтение незафиксированных изменений (dirty read): транзакция видит данные, которые другая ещё не сохранила. PostgreSQL не делает этого ни на каком уровне — благодаря MVCC видны только зафиксированные версии, и Read Uncommitted здесь равен Read Committed.

Read Committed — уровень по умолчанию

Каждый запрос внутри транзакции видит данные, зафиксированные на момент начала этого запроса. Между запросами данные могут измениться.

Non-repeatable read — один и тот же запрос дважды возвращает разное: ровно то, что показывает первая половина ролика выше. Между двумя SELECT транзакции T1 соседняя транзакция успевает закоммитить свой UPDATE, и второй ответ уже другой.

Phantom read — один и тот же диапазон возвращает разное количество строк:

-- T1
BEGIN;
SELECT COUNT(*) FROM product WHERE category_id = 1;
-- → 3

-- T2 вставила новую строку и закоммитила

-- T1 продолжает
SELECT COUNT(*) FROM product WHERE category_id = 1;
-- → 4  ← в той же транзакции, больше строк
COMMIT;

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

Отсюда практический вывод. Запрос UPDATE account SET balance = balance - 100 WHERE id = 1 AND balance >= 100 безопасен на уровне по умолчанию: вторая транзакция дождётся первой, увидит уже уменьшенный остаток, пересчитает от него, а если денег не хватило — не изменит ни строки, и приложение получит «обновлено 0 строк». А связка «прочитал в приложении, посчитал в коде, записал результат» небезопасна ровно потому, что между чтением и записью нет ничего, что заставило бы перечитать: обе транзакции посчитают от старого значения, и вторая запись затрёт первую. Разбор способов лечения — в статье про уровни изоляции.

Когда подходит: большинство CRUD-сервисов с короткими транзакциями — прочитал одну строку, обновил, закоммитил. Если вся логика умещается в один запрос, аномалии не возникают.

Repeatable Read — снимок на всю транзакцию

Транзакция фиксирует снимок данных при первом запросе и видит его до конца — ровно это показывает вторая половина ролика выше. Non-repeatable read и phantom read исчезают.

Дополнительно: если две транзакции пытаются обновить одну строку, вторая получает ошибку could not serialize access due to concurrent update. Приложение обязано поймать её и повторить транзакцию.

Write skew — что Repeatable Read не ловит

Правило: суммарная цена товаров в категории «Сладости» не падает ниже 250. Сейчас сумма 50 + 70 + 150 = 270, запас всего 20. Две транзакции одновременно читают эту сумму, каждая видит запас 20 и каждая снижает цену своего товара на 20: конфет одна, мармелада другая. Каждая по отдельности правило соблюла, а вместе они увели сумму до 230. База конфликта не увидела, потому что следит за строками, а не за правилом: транзакции изменили разные строки, и по UPDATE им нечего было делить; общим у них было только чтение суммы, а чтение Repeatable Read не защищает. Это и есть write skew, и вот он по шагам:

-- T1: снижает цену конфет на 20
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT SUM(price) FROM product WHERE category_id = 1;
-- → 270, запас 20 — снизить на 20 можно
UPDATE product SET price = 130 WHERE id = 3;

-- T2 параллельно: снижает цену мармелада на 20
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT SUM(price) FROM product WHERE category_id = 1;
-- → 270 (T1 ещё не закоммитила, снимок T2 её не видит)
UPDATE product SET price = 50 WHERE id = 2;

-- T1 COMMIT → сумма: 50 + 70 + 130 = 250, правило соблюдено
-- T2 COMMIT → сумма: 50 + 50 + 130 = 230  ← нарушение!

Ловит такое только уровень, который следит за зависимостями между чтениями и записями, то есть Serializable.

Когда не брать. Снимок, который транзакция держит всё своё время, мешает уборке мусора: старые версии строк нельзя удалить, пока есть транзакция, которая имеет право их увидеть. Пока отчёт считается пять секунд, это незаметно. Транзакция на REPEATABLE READ, забытая открытой на час, за этот час не даст вычистить ни одной устаревшей версии по всей базе — таблицы пухнут, запросы по ним замедляются, и виноват в этом не тот, кто их пишет. Отсюда правило: чем выше уровень изоляции, тем короче должна быть транзакция. Про мусор и горизонт видимости — в статье VACUUM, autovacuum и bloat.

Когда подходит: аналитические запросы, отчёты за период, где нужен консистентный срез данных на момент старта.

Serializable — полная изоляция

Транзакции исполняются так, как если бы шли строго одна за другой, в каком-то порядке. Никаких аномалий, включая write skew.

PostgreSQL реализует это через SSI (Serializable Snapshot Isolation) — отслеживание зависимостей между транзакциями. Если PostgreSQL видит, что две параллельные транзакции образуют конфликт, одна из них откатывается:

-- Тот же пример, T1 закоммитилась первой:
-- T2 COMMIT → ERROR: could not serialize access due to read/write dependencies
--                    among transactions

T2 откатывается; на повторе она видит уже новые данные после T1 и решает правильно.

Цена: PostgreSQL не держит физические блокировки, но откатывает транзакции чаще. Приложение обязано уметь повторять транзакцию при ошибке SQLSTATE 40001 (serialization_failure). Read-only транзакции на Serializable дешевле пишущих, но не бесплатны: они тоже участвуют в отслеживании конфликтов и тоже могут получить откат. Полностью избавиться от этого позволяет SERIALIZABLE READ ONLY DEFERRABLE — такая транзакция подождёт заведомо безопасного снимка и дальше уже не откатится; за это платят ожиданием в начале.

И условие, без которого всё это не работает: гарантия держится, только если все конфликтующие транзакции идут на SERIALIZABLE. Уровень — свойство транзакции, а не таблицы; одна параллельная транзакция на READ COMMITTED, пишущая в те же строки, возвращает write skew в полном объёме, и сериализуемая транзакция об этом даже не узнает: отслеживать зависимости не с кем. Поэтому уровень поднимают не одному методу, а всем сценариям, которые трогают этот инвариант, — сервису, отчёту, ночному заданию и ручному скрипту поддержки. Именно на этом чаще всего и спотыкаются при внедрении.

Про цену стоит сказать конкретнее: платят предикатными блокировками. Они никого не задерживают — только помечают, что именно транзакция прочитала: строку, страницу или отношение целиком. По этим пометкам база и находит конфликты. Слотов под них ограниченное число (max_pred_locks_per_transaction, по умолчанию 64 на транзакцию), и когда пометок становится больше, база их укрупняет: вместо строк помечает страницу, вместо страниц — таблицу. С этого момента конфликтом считается любая запись в помеченную область, и откаты приходят туда, где реального пересечения не было. Признак беды характерный: число ошибок 40001 растёт от объёма данных, а не от нагрузки. Лечится увеличением параметра или сужением выборок, которые читает транзакция.

Когда подходит: денежные операции с инвариантами на нескольких строках, проверка уникальности перед вставкой, любые ситуации с write skew.

Точки сохранения: транзакция внутри транзакции

Вложенных транзакций в PostgreSQL нет, а откатить часть работы можно — точкой сохранения:

BEGIN;
INSERT INTO product (name, price, category_id) VALUES ('Тортик', 200, 1);
SAVEPOINT after_product;
INSERT INTO product (name, price, category_id) VALUES ('Тортик', 200, 1);  -- нарушили UNIQUE
ROLLBACK TO SAVEPOINT after_product;
-- первая вставка жива, транзакция продолжается
COMMIT;

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

Отсюда поведение @Transactional(propagation = NESTED) в Spring, которое регулярно удивляет: внутренний метод выполняется не в отдельной транзакции, а в точке сохранения внешней — его изменения можно откатить отдельно, но пережить откат внешней они не могут. Подробнее — в статье про @Transactional.

Отсюда поведение вложенного tx.Begin() в pgx, которое регулярно удивляет: на уже открытой транзакции он не начинает новую, а ставит точку сохранения — внутренний блок можно откатить отдельно, но пережить откат внешней транзакции он не может. Подробнее — в статье про транзакции в pgx.

У драйвера pg в Node вложенных транзакций нет вовсе: второй BEGIN в том же соединении PostgreSQL встречает предупреждением there is already a transaction in progress, а knex и Sequelize делают вложенный transaction() точкой сохранения — откатить отдельно можно, пережить откат внешней нельзя. Подробнее — в статье про транзакции в Node.

Отсюда поведение вложенного with conn.transaction(): в psycopg 3 и session.begin_nested() в SQLAlchemy: внутренний блок выполняется не в отдельной транзакции, а в точке сохранения внешней — его можно откатить отдельно, но пережить откат внешней он не может. Подробнее — в статье про сессию и транзакции в SQLAlchemy.

Цена у точек сохранения тоже есть, и натыкаются на неё в циклах. Каждая точка — это отдельная подтранзакция со своим номером, а в памяти процесса их кешируется 64. Как только у транзакции их становится больше, чужие запросы начинают ходить на диск, чтобы выяснить статус этих подтранзакций, и замедляется вся база, а не только виновник. Поэтому цикл «на каждую строку свой SAVEPOINT» на десятках тысяч строк — известный способ уронить производительность на ровном месте. И то же самое делает try/catch вокруг каждой итерации в коде: драйвер ставит точку сохранения сам, чтобы после ошибки транзакция могла продолжиться.

Когда какой уровень выбирать

ЗадачаУровень
CRUD: читаю одну строку, обновляю, коммичуRead Committed (по умолчанию)
Отчёт за период, нужен консистентный срезRepeatable Read
Перевод денег: строки известны заранееRead Committed + SELECT … FOR UPDATE
Инвариант на нескольких строках, которые заранее не назватьSerializable
Долгий аналитический запрос (read-only)Repeatable Read

Уровень поднимают снизу вверх: каждый следующий дороже. Критерий выбора простой, и он же отвечает на вечный вопрос «а как правильно переводить деньги». Если строки, которые надо защитить, известны заранее — счёт отправителя, счёт получателя, — берите обычный READ COMMITTED и явно заблокируйте их через FOR UPDATE, обязательно в одинаковом порядке во всех местах кода, иначе получите взаимную блокировку. Это дёшево, предсказуемо и видно в логах. SERIALIZABLE нужен там, где заблокировать нечего: правило проверяется по выборке строк, и опасна как раз строка, которой ещё нет, — «не больше пяти активных заказов на клиента», «сумма позиций не превышает лимит». Тогда база сама отследит конфликт и откатит одну из транзакций, а вы повторите её.

Дополнительно: при первом чтении можно пропустить

Глубже: ограничения целостности: чем база держит букву Cрасширенное

Согласованность в ACID это обещание, что транзакция переводит данные из одного правильного состояния в другое. Что такое «правильное», база знает только из ограничений, которые вы объявили, и в этом смысле буква C стоит на них.

NOT NULL и CHECK держат правило внутри строки: CHECK (quantity > 0), CHECK (status IN ('DRAFT', 'PAID')). UNIQUE держит правило между строками, и у него есть частичная форма: CREATE UNIQUE INDEX ... ON orders (customer_id) WHERE status = 'DRAFT' разрешает покупателю один черновик, но сколько угодно оплаченных заказов. FOREIGN KEY держит связи и говорит, что делать с потомками при удалении родителя: ON DELETE RESTRICT (умолчание: удалить нельзя, пока есть ссылки), CASCADE (удалить и их), SET NULL. CASCADE пишут осознанно: удаление покупателя, которое молча уносит его заказы, это потеря данных, а не удобство.

Две настройки для живой базы. DEFERRABLE INITIALLY DEFERRED откладывает проверку ключа до конца транзакции: две таблицы, ссылающиеся друг на друга, иначе не заполнить. NOT VALID при добавлении ограничения на большую таблицу: ограничение действует для новых строк сразу, а старые проверяются отдельной командой VALIDATE CONSTRAINT без долгой блокировки таблицы.

Спор «правило в базе или в приложении» решается просто: правило, нарушение которого портит данные навсегда, живёт в базе, потому что к таблице ходит не только это приложение, а ещё миграции, скрипты поддержки и следующий сервис. Правила, которые меняются по бизнесу раз в месяц («скидка не больше 30 %»), держат в коде. EXCLUDE из статьи про диапазоны той же природы: ограничение, которое не даёт двум записям пересечься.

Коротко

  • ACID — четыре гарантии транзакции: атомарность (всё или ничего), согласованность (ограничения соблюдены), изоляция (параллельные не мешают), долговечность (данные после COMMIT не теряются).
  • WAL обеспечивает атомарность и долговечность — журнал сбрасывается на диск до подтверждения клиенту.
  • MVCC — каждая транзакция видит согласованный снимок без блокировок чтения; dirty read PostgreSQL не показывает ни на одном уровне.
  • Read Committed (по умолчанию) — снимок на каждый запрос; допускает non-repeatable read и phantom read.
  • Repeatable Read — снимок на всю транзакцию; устраняет non-repeatable и phantom, но не write skew.
  • Serializable — полная изоляция, включая write skew; требует обработки ошибок сериализации в приложении.
  • Букву C держат ограничения: NOT NULL, CHECK, UNIQUE (в том числе частичный), FOREIGN KEY с явным ON DELETE; DEFERRABLE откладывает проверку до коммита, NOT VALID добавляет правило без долгой блокировки.
  • На READ COMMITTED запись ведёт себя не как чтение: UPDATE дожидается чужой транзакции, перечитывает новую версию строки и заново проверяет WHERE, — поэтому считать надо в самом UPDATE, а не в приложении.
  • SERIALIZABLE защищает, только если на нём идут все конфликтующие транзакции; платит предикатными блокировками, а при нехватке слотов они укрупняются до страницы и таблицы.
  • Точка сохранения — не вложенная транзакция: откатывает часть работы, но фиксируется только вместе с внешней; больше 64 подтранзакций замедляют весь сервер.

Что пощупать

Резерв товара в практикуме remodov/marketplace-system-go: транзакция через WithTx, чтение строки FOR UPDATE, чтобы двое не прочитали один остаток, и колонка version как оптимистичная блокировка для обычных правок.

Код: repository.go, service.go.

Сделаем сами

Ветка step-05-stock-and-locking.

Что почитать дальше