Таблица дежурных врачей: в любой момент минимум один врач должен быть на
дежурстве. Это ограничение никогда не выражается как CHECK на
одной строке — оно про несколько строк сразу, поэтому его проверяют в коде
перед тем, как снять с себя дежурство.
CREATE TABLE doctors (
id serial PRIMARY KEY,
name text NOT NULL,
on_call boolean NOT NULL DEFAULT true
);
INSERT INTO doctors (name, on_call) VALUES ('Иванов', true), ('Петров', true);
Иванов и Петров оба дежурят и оба сегодня решают уйти пораньше. Каждый сначала проверяет: если дежурит кто-то ещё, кроме меня — можно уходить.
T2 (Петров): SELECT count(*) WHERE on_call → 2
T1: UPDATE on_call = false WHERE name = 'Иванов', COMMIT
T2: UPDATE on_call = false WHERE name = 'Петров', COMMIT
Ни одна транзакция не переписывала строку, которую изменила другая: Иванов менял
только свою строку, Петров — только свою. Формального write-write конфликта нет,
поэтому механизм «первый коммит побеждает», на котором строится
SNAPSHOT ISOLATION, здесь молчит.
Write skew возникает, когда две транзакции читают одно и то же множество строк, принимают решение на основе прочитанного и пишут в непересекающиеся строки — но вместе их решения нарушают инвариант, который держится сразу на нескольких строках.
Почему REPEATABLE READ / SNAPSHOT ISOLATION не спасает
SNAPSHOT ISOLATION решает проблему потерянного обновления, потому что там конфликт —
это две записи в одну и ту же строку. Здесь запись идёт в разные строки,
и с точки зрения механизма проверки конфликтов (сравнения версий изменённой
строки) всё чисто: Иванов не трогал строку Петрова, Петров не трогал строку
Иванова.
| Уровень изоляции | Lost update | Write skew |
|---|---|---|
| READ COMMITTED | возможен | возможен |
| REPEATABLE READ / SNAPSHOT ISOLATION | исключён | возможен |
| SERIALIZABLE (истинная сериализуемость) | исключён | исключён |
Важная деталь: «SERIALIZABLE» в этой таблице — не просто название уровня из стандарта, а гарантия, что результат параллельного выполнения эквивалентен какому-то последовательному порядку транзакций. Именно эта гарантия ловит write skew, потому что ни один последовательный порядок (сначала Иванов, потом Петров, или наоборот) не приводит к нулю дежурных.
Как это ловит настоящий SERIALIZABLE
В PostgreSQL SERIALIZABLE реализован не блокировками, а
отслеживанием зависимостей чтения-записи между конкурентными транзакциями
(Serializable Snapshot Isolation, SSI). Та же последовательность действий
с этим уровнем изоляции:
-- сессия 1
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM doctors WHERE on_call = true; -- 2
UPDATE doctors SET on_call = false WHERE name = 'Иванов';
COMMIT; -- успешно
-- сессия 2 (началась до COMMIT сессии 1)
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM doctors WHERE on_call = true; -- тоже 2, свой снапшот
UPDATE doctors SET on_call = false WHERE name = 'Петров';
COMMIT;
ERROR: could not serialize access due to read/write dependencies among transactions
DETAIL: Reason code: Canceled on identification as a pivot, during commit attempt.
HINT: The transaction might succeed if retried.
SQLSTATE: 40001
Postgres находит цикл зависимостей: чтение T2 пересекается с записью T1 (и
наоборот), и коммитит только одну из транзакций. Ошибка приходит на
COMMIT, а не раньше — поэтому приложение обязано перехватывать
SQLSTATE 40001 и повторять транзакцию целиком, а не считать это
обычной ошибкой запроса.
При повторе Петров снова читает on_call, но теперь видит уже
закоммиченное состояние Иванова — count = 1 — и корректно
отказывается снимать с себя дежурство.
Где это всплывает в реальном коде
- Двойное бронирование переговорки. Обе транзакции проверяют пересечение с существующими бронями, не находят конфликта и вставляют новую запись — потому что запись, которую вставляет конкурент, ещё не видна в снапшоте.
- Общий лимит на паре счетов. Муж и жена одновременно снимают деньги с двух разных счетов, у которых общий овердрафт-лимит на семью; каждое списание по отдельности укладывается в лимит, вместе — нет.
- Резервирование последнего слота. Проверка «осталось ли место» через агрегат по одной таблице и запись в другую (или в новую строку) — частный случай той же схемы: чтение диапазона, запись вне диапазона.
Общий признак: в коде есть SELECT, который агрегирует или проверяет
несколько строк, и по его результату транзакция пишет лишь в часть этого
множества (или в новую строку) — а не во все прочитанные строки разом.
Что делать, если SERIALIZABLE для всей транзакции дорого
SSI в PostgreSQL — не бесплатный: он держит SIREAD-локи для отслеживания зависимостей и увеличивает число откатов под нагрузкой, а каждый откат — это полный повтор транзакции на стороне приложения. Поднимать уровень изоляции на весь сервис ради одного проблемного места обычно не стоит.
1. Заблокировать то, что читаете, а не только то, что пишете
SELECT ... FOR UPDATE над строками, которые участвуют в проверке
инварианта, превращает чтение в намерение записи — вторая транзакция будет
ждать первую, а не работать со своим независимым снапшотом.
BEGIN;
SELECT id FROM doctors WHERE on_call = true FOR UPDATE;
-- вторая параллельная транзакция с тем же условием будет ждать здесь
UPDATE doctors SET on_call = false WHERE name = 'Иванов';
COMMIT;
Ограничение: блокируются только строки, которые существовали на момент запроса.
Если инвариант может быть нарушен вставкой новой строки (как в примере с
переговорками), FOR UPDATE не поможет — там нужна явная блокировка
диапазона или сериализуемость.
2. Материализовать конфликт
Завести отдельную строку, которая физически представляет инвариант, и заставить
обе транзакции писать именно в неё — тогда write skew превращается в обычное
потерянное обновление, которое ловится уже на REPEATABLE READ.
CREATE TABLE duty_roster_lock (id int PRIMARY KEY, on_call_count int NOT NULL);
BEGIN;
UPDATE duty_roster_lock SET on_call_count = on_call_count - 1 WHERE id = 1
RETURNING on_call_count; -- если результат = 0, откатываем
UPDATE doctors SET on_call = false WHERE name = 'Иванов';
COMMIT;
Оба врача теперь пишут в одну и ту же строку-счётчик, поэтому вторая транзакция либо ждёт блокировку строки, либо получает конфликт версий при попытке закоммититься — в зависимости от уровня изоляции.
3. SERIALIZABLE точечно, с retry-логикой
Если инвариант дорого материализовать, можно поднять изоляцию только для этой конкретной транзакции, не трогая остальную систему, и обернуть вызов в повтор по коду ошибки:
for attempt in range(3):
try:
with connection.transaction(isolation_level='SERIALIZABLE'):
release_from_duty(doctor_id)
break
except SerializationFailure: # SQLSTATE 40001
continue
Ретраи должны быть идемпотентны по смыслу операции: транзакция при повторе видит уже актуальные данные и сама решает, применять изменение или нет — это не «повторить тот же UPDATE вслепую», а «выполнить всю бизнес-логику заново».
Не только PostgreSQL
Механизм зависит от СУБД. Postgres ловит write skew оптимистично через SSI —
транзакции выполняются параллельно, конфликт обнаруживается на коммите. SQL Server
на SERIALIZABLE действует пессимистично: захватывает блокировки
диапазона ключей (key-range locks) уже на чтении, поэтому вторая транзакция
физически ждёт первую вместо того, чтобы упасть на коммите. Итог одинаковый —
аномалия исключена, — но профиль поведения под нагрузкой разный: Postgres теряет
транзакции на откатах, SQL Server теряет пропускную способность на блокировках.
Чек-лист
- Есть ли в коде
SELECT, агрегирующий несколько строк, после которого транзакция пишет в строку, не входящую в этот же набор? - Держится ли на этих строках инвариант, который затрагивает больше одной строки одновременно (сумма, count, «хотя бы один», диапазон)?
- Проверили ли вы поведение именно при конкурентном выполнении двух транзакций, а не только последовательно в тестах?
- Если не готовы держать
SERIALIZABLE— заблокировали ли явно прочитанные строки (FOR UPDATE) или материализовали конфликт в отдельную строку? - Есть ли в приложении retry на
SQLSTATE 40001там, где используетсяSERIALIZABLE?
Вывод
Повышение уровня изоляции до REPEATABLE READ закрывает потерянное
обновление, но не защищает от инвариантов, которые распределены по нескольким
строкам. Единственная гарантия против write skew — настоящая сериализуемость,
и она либо стоит откатов и повторов (Postgres SSI), либо блокировок и ожидания
(пессимистичный SERIALIZABLE). Выбор конкретного способа — явная
блокировка, материализация конфликта или полный SERIALIZABLE —
зависит от того, сколько мест в системе реально держат многострочный инвариант,
а не от привычки «на всякий случай» ставить максимальный уровень изоляции всюду.
Если ваш код проверяет условие через SELECT, а потом пишет в другую
строку — задайте себе вопрос не «какой уровень изоляции у меня стоит», а «что
произойдёт, если две такие проверки выполнятся одновременно, каждая увидев
состояние до записи другой».