Класична дірка виглядає невинно: код читає баланс, віднімає в PHP сто гривень і записує результат. Дві паралельні транзакції прочитали 500, обидві порахували 400, обидві записали 400 замість очікуваних 300. Це lost update, і жоден рівень ізоляції з тих, які реально вмикають у продакшені, його не помічає: з погляду бази це два законні UPDATE з константою в правому боці. PostgreSQL на READ COMMITTED пропустить обидва, MySQL на REPEATABLE READ теж, бо другий UPDATE після зняття замка просто перезапише рядок своїм значенням. База не знає, що ваше 400 було похідним від прочитаного 500, поки ви їй про це не скажете.
Найдешевший спосіб сказати це - перенести обчислення в сам запит: UPDATE wallets SET balance = balance - 100 WHERE user_id = 42 AND balance >= 100. Читання й запис відбуваються всередині однієї операції під внутрішнім замком рядка, а умова в WHERE робить перевірку достатності коштів частиною тієї ж атомарної дії. Відповіддю стає кількість оновлених рядків: нуль означає, що грошей не вистачило. Там, де логіка зводиться до арифметики над однією колонкою, решта механізмів просто зайва.
Коли між читанням і записом мусить бути код, беруть SELECT ... FOR UPDATE. Замок на рядок ставиться в момент SELECT і тримається до COMMIT або ROLLBACK, паралельні транзакції зупиняються на тому ж SELECT і продовжують уже з актуальним значенням. Тут ховається деталь, яку легко проґавити: на READ COMMITTED PostgreSQL після зняття чужого замка перечитує рядок і повторно перевіряє умову WHERE (механізм EvalPlanQual), тож рядок, який перестав відповідати умові, з результату зникне, і код має бути готовий отримати порожню вибірку там, де щойно щось було. Ціна песимістичного підходу вимірюється тривалістю транзакції: FOR UPDATE плюс виклик платіжного шлюзу всередині того самого BEGIN означає, що всі інші клієнти чекають рівно стільки, скільки відповідає зовнішній сервіс. Пом'якшують це NOWAIT (впасти одразу) і SKIP LOCKED (пропустити зайняте), обидва доступні в PostgreSQL і в MySQL 8.0.
Оптимістичне блокування закриває іншу задачу: конфлікт рідкісний, а пауза між читанням і записом велика, бо в ній сидить людина з відкритою формою. Тримати транзакцію відкритою всі ці хвилини неможливо, тому в таблицю додають version, віддають її клієнтові разом із даними й повертають назад у WHERE: UPDATE ... SET version = version + 1 WHERE id = ? AND version = ?. Якщо оновлено нуль рядків, хтось випередив, і застосунок вирішує, що робити: перечитати й повторити автоматично для машинних операцій або показати користувачеві конфлікт для ручного редагування. Механізм повністю тримається на перевірці affected rows; без неї він перетворюється на декорацію, яка мовчки нічого не робить. Той самий принцип працює і без окремої колонки, якщо в WHERE підставити старе значення поля, що змінюється, але version надійніша, бо не залежить від того, скільки полів у формі.
Унікальні обмеження стоять окремо від обох підходів, бо захищають від іншого класу помилок. Блокування й версії працюють, поки всі пишуть через ваш код і в правильному порядку; обмеження працює завжди - при імпорті CSV, при ручному запиті в консолі, при другому сервісі, який дописали через рік. Перевірка «спочатку SELECT, чи вільний email, потім INSERT» лишає вікно в мілісекунди, і FOR UPDATE його не закриває, бо блокувати неіснуючий рядок нема як. Тому унікальний індекс ставлять завжди, а помилку 23505 у PostgreSQL чи 1062 у MySQL ловлять і перетворюють на нормальну бізнес-відповідь замість п'ятисотки. PostgreSQL дає ще два інструменти, яких немає в MySQL: частковий унікальний індекс з WHERE deleted_at IS NULL для м'яко видалених записів і exclusion constraint для заборони перетину інтервалів. У MySQL ті самі інваріанти або зводяться до звичайного UNIQUE над дискретними слотами, або лишаються на совісті застосунку.
-- 0. Найдешевше: уся логіка в одному UPDATE, читання й запис атомарні
UPDATE wallets SET balance = balance - 100
WHERE user_id = 42 AND balance >= 100; -- 0 рядків = не вистачило коштів
-- 1. Песимістично: замок на рядок живе до COMMIT, решта чекає на SELECT
BEGIN;
SELECT balance FROM wallets WHERE user_id = 42 FOR UPDATE;
-- тут можна рахувати складну логіку, але не ходити в зовнішні API
UPDATE wallets SET balance = :new_balance WHERE user_id = 42;
COMMIT;
-- не стояти в черзі: помилка одразу або пропустити зайняті рядки
SELECT id FROM wallets WHERE user_id = 42 FOR UPDATE NOWAIT;
SELECT id FROM jobs WHERE status = 'queued'
ORDER BY id LIMIT 10 FOR UPDATE SKIP LOCKED; -- PostgreSQL 9.5+, MySQL 8.0+
-- 2. Оптимістично: версія в WHERE, рішення за кількістю оновлених рядків
UPDATE documents
SET title = :title, version = version + 1
WHERE id = 7 AND version = :version_the_user_saw;
-- 0 рядків = документ змінили, поки була відкрита форма: перечитати й показати конфлікт
-- 3. Обмеження: остання лінія, тримає навіть коли логіку обійшли
ALTER TABLE bookings ADD CONSTRAINT bookings_slot_unique UNIQUE (room_id, slot_start);
-- часткова унікальність і перетини діапазонів: лише PostgreSQL
CREATE UNIQUE INDEX users_email_active ON users (lower(email)) WHERE deleted_at IS NULL;
ALTER TABLE bookings ADD CONSTRAINT bookings_no_overlap
EXCLUDE USING gist (room_id WITH =, during WITH &&); -- потрібне btree_gist
-- ідемпотентна вставка замість «спочатку перевір, чи вже є»
INSERT INTO bookings (room_id, slot_start, user_id)
VALUES (5, '2026-03-01 10:00', 42)
ON CONFLICT (room_id, slot_start) DO NOTHING; -- MySQL: ON DUPLICATE KEY UPDATE
Назвіть три рівні одним рухом: атомарний UPDATE з умовою там, де достатньо арифметики; version там, де між читанням і записом сидить людина з відкритою формою; унікальний індекс завжди, бо це єдине, що переживе баг у коді, імпорт даних і другий сервіс, який пише в ту саму таблицю.