Пошук уроків, статей та іншого контенту
Порівняєте підходи до конкурентних змін і реалізуєте перевірку версії та блокування рядка.
Коли кілька транзакцій одночасно змінюють один рядок, без додаткового контролю можуть виникнути помилки:
одна зміна перезапише іншу;
дві транзакції використають застаріле значення;
залишок товару стане від’ємним;
гроші будуть списані двічі;
користувач отримає результат, який уже не відповідає поточним даним.
PostgreSQL використовує MVCC — багатоверсійність рядків. Читання зазвичай не блокує запис, а запис не блокує звичайне читання. Це добре для продуктивності, але під час конкурентних змін потрібно явно обрати стратегію:
оптимістичне блокування — не блокуємо рядок заздалегідь, а перевіряємо, чи не змінив його хтось інший;
песимістичне блокування — одразу блокуємо рядок і не дозволяємо іншим транзакціям змінювати його до завершення поточної транзакції.
Створимо таблицю товарів із полем version. Це поле зберігатиме номер версії рядка.
DROP TABLE IF EXISTS products;
CREATE TABLE products (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
stock integer NOT NULL CHECK (stock >= 0),
price numeric(10, 2) NOT NULL CHECK (price >= 0),
version integer NOT NULL DEFAULT 1
);
INSERT INTO products (name, stock, price)
VALUES ('Mechanical Keyboard', 10, 99.99);
SELECT *
FROM products;Початковий рядок матиме приблизно такий вигляд:
id | name | stock | price | version
----+---------------------+-------+-------+---------
1 | Mechanical Keyboard | 10 | 99.99 | 1Поле version потрібно збільшувати щоразу, коли змінюється рядок.
Оптимістичне блокування підходить, коли конфлікти трапляються рідко. Транзакція:
читає рядок разом із його версією;
виконує обчислення;
оновлює рядок лише за умови, що версія залишилася незмінною;
збільшує версію;
перевіряє кількість оновлених рядків.
Ключова частина запиту:
WHERE id = $1
AND version = $2Якщо інша транзакція вже змінила рядок, поточна версія більше не збігатиметься, тому UPDATE не змінить жодного рядка.
Спочатку обидві транзакції читають товар:
SELECT id, stock, price, version
FROM products
WHERE id = 1;Нехай обидві транзакції отримали:
stock = 10
version = 1Перша транзакція змінює ціну:
BEGIN;
UPDATE products
SET price = 89.99,
version = version + 1
WHERE id = 1
AND version = 1;
COMMIT;Після цього рядок матиме:
stock = 10
version = 2Друга транзакція, яка досі має застарілу версію 1, виконує:
BEGIN;
UPDATE products
SET stock = stock - 1,
version = version + 1
WHERE id = 1
AND version = 1;Результат:
UPDATE 0Це означає, що рядок уже змінився після читання. Транзакція не повинна вважати операцію успішною:
ROLLBACK;У прикладному коді UPDATE 0 зазвичай перетворюється на помилку конфлікту версій. Клієнт може перечитати актуальні дані, повторити обчислення або повідомити користувача про конфлікт.
Наступний блок можна виконати як одну транзакцію:
BEGIN;
-- Отримуємо поточну версію товару
SELECT id, stock, price, version
FROM products
WHERE id = 1;
-- Змінюємо рядок лише тоді, коли version дорівнює очікуваному значенню
UPDATE products
SET price = 94.99,
version = version + 1
WHERE id = 1
AND version = 2;
-- Перевіряємо, що команда повернула UPDATE 1
COMMIT;Якщо версія вже не дорівнює 2, команда поверне UPDATE 0, і транзакцію потрібно скасувати:
ROLLBACK;Версію можна використовувати разом із перевіркою бізнес-умови:
BEGIN;
UPDATE products
SET stock = stock - 1,
version = version + 1
WHERE id = 1
AND version = 2
AND stock > 0;
-- Успіх можливий лише за умови UPDATE 1
COMMIT;Тут UPDATE 0 може означати одну з двох причин:
версія рядка вже змінилася;
товару більше немає на складі.
Якщо застосунку потрібно розрізняти ці ситуації, спочатку можна перечитати рядок або використовувати RETURNING.
BEGIN;
UPDATE products
SET stock = stock - 1,
version = version + 1
WHERE id = 1
AND version = 2
AND stock > 0
RETURNING id, stock, version;
COMMIT;Якщо RETURNING не повернув рядок, операція не виконалася.
Переваги:
рядок не утримується заблокованим під час тривалих обчислень;
транзакції менше очікують одна на одну;
добре підходить для форм редагування та API;
конфлікт можна обробити на рівні бізнес-логіки.
Недоліки:
клієнт повинен уміти обробляти конфлікти;
під час частих конфліктів доводиться повторювати операції;
усі зміни мають коректно оновлювати поле версії;
просте оновлення без перевірки версії обходить цей захист.
Песимістичне блокування застосовують, коли конфлікт імовірний або операція має бути виконана послідовно.
Для блокування рядка використовується:
SELECT ...
FROM ...
WHERE ...
FOR UPDATE;Такий запит:
знаходить рядок;
блокує його для змін;
утримує блокування до завершення транзакції;
не дозволяє іншій транзакції змінити або також заблокувати цей рядок.
Блокування діє лише всередині транзакції. Якщо виконати SELECT ... FOR UPDATE без BEGIN, транзакція в режимі автокоміту завершиться одразу після запиту, і блокування практично не допоможе для наступної команди.
BEGIN;
SELECT id, stock, price
FROM products
WHERE id = 1
FOR UPDATE;
UPDATE products
SET stock = stock - 1,
version = version + 1
WHERE id = 1
AND stock > 0;
COMMIT;Після SELECT ... FOR UPDATE інша транзакція, яка намагається змінити цей рядок, чекатиме завершення першої транзакції.
У першій сесії:
BEGIN;
SELECT id, stock
FROM products
WHERE id = 1
FOR UPDATE;
-- Рядок заблоковано до COMMIT або ROLLBACK
UPDATE products
SET stock = stock - 1,
version = version + 1
WHERE id = 1;
COMMIT;Поки перша транзакція не завершилася, у другій сесії:
BEGIN;
SELECT id, stock
FROM products
WHERE id = 1
FOR UPDATE;Другий SELECT чекатиме, доки перша транзакція не виконає COMMIT або ROLLBACK. Після цього друга транзакція отримає актуальний стан рядка.
Блокування саме по собі не гарантує, що операцію можна виконати. Після отримання блокування потрібно перевірити бізнес-умови:
BEGIN;
SELECT stock
FROM products
WHERE id = 1
FOR UPDATE;
-- Якщо stock = 0, потрібно скасувати операцію
UPDATE products
SET stock = stock - 1,
version = version + 1
WHERE id = 1;
COMMIT;Безпечніше одразу додати умову до UPDATE і перевірити результат:
BEGIN;
SELECT id, stock
FROM products
WHERE id = 1
FOR UPDATE;
UPDATE products
SET stock = stock - 1,
version = version + 1
WHERE id = 1
AND stock > 0
RETURNING id, stock, version;
COMMIT;Якщо рядок не повернувся, товару немає на складі. У такому разі замість COMMIT потрібно виконати ROLLBACK.
PostgreSQL підтримує кілька режимів блокування рядків. Для типових змін найчастіше потрібен FOR UPDATE.
-- Блокування рядків, які планується змінити
SELECT *
FROM products
WHERE id = 1
FOR UPDATE;
-- Блокування для оновлення окремих полів
SELECT *
FROM products
WHERE id = 1
FOR NO KEY UPDATE;
-- Блокування для видалення
SELECT *
FROM products
WHERE id = 1
FOR UPDATE;FOR UPDATE є зрозумілим вибором, коли транзакція збирається змінити або видалити отримані рядки. Конкретний режим слід обирати відповідно до типу конкурентної операції.
NOWAIT і SKIP LOCKEDNOWAITЗа замовчуванням FOR UPDATE чекає, якщо рядок уже заблокований. NOWAIT змінює цю поведінку: PostgreSQL одразу повертає помилку.
BEGIN;
SELECT id, stock
FROM products
WHERE id = 1
FOR UPDATE NOWAIT;
COMMIT;Це корисно, коли застосунок не хоче чекати й має відразу повідомити, що ресурс зайнятий.
SKIP LOCKEDSKIP LOCKED пропускає вже заблоковані рядки:
BEGIN;
SELECT id, name, stock
FROM products
WHERE stock > 0
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1;
COMMIT;Такий режим підходить для обробки черг, коли кілька працівників мають брати різні доступні записи. Для звичайного редагування конкретного рядка SKIP LOCKED зазвичай не потрібен, оскільки пропуск потрібного рядка може приховати факт, що він тимчасово заблокований.
конфлікти трапляються рідко;
транзакція не повинна довго утримувати блокування;
користувач редагує дані через форму;
можна безпечно повторити операцію;
потрібна явна інформація про конфлікт версій.
Типовий шаблон:
UPDATE products
SET price = $new_price,
version = version + 1
WHERE id = $id
AND version = $expected_version;конфлікти трапляються часто;
операція складається з кількох послідовних кроків;
результат залежить від актуального стану рядка;
очікування іншої транзакції прийнятніше за повтор операції;
потрібно серіалізувати зміни конкретного ресурсу.
Типовий шаблон:
BEGIN;
SELECT *
FROM products
WHERE id = $id
FOR UPDATE;
-- Перевірка стану та зміна рядка
COMMIT;Песимістичне блокування має сенс лише в межах однієї транзакції:
BEGIN;
SELECT *
FROM products
WHERE id = 1
FOR UPDATE;
UPDATE products
SET stock = stock - 1
WHERE id = 1;
COMMIT;Якщо між читанням і зміною виконати COMMIT, блокування буде знято, і інша транзакція зможе змінити рядок.
Оптимістичне блокування також часто використовують у транзакції, особливо коли одна бізнес-операція змінює кілька таблиць:
BEGIN;
UPDATE products
SET stock = stock - 1,
version = version + 1
WHERE id = 1
AND version = 3
AND stock > 0
RETURNING id, stock, version;
-- Якщо UPDATE 0, потрібно виконати ROLLBACK
COMMIT;Небезпечний варіант:
UPDATE products
SET price = 79.99
WHERE id = 1;Якщо застосунок працює за оптимістичною схемою, такий запит може перезаписати зміни іншого клієнта. Потрібно додати очікувану версію:
UPDATE products
SET price = 79.99,
version = version + 1
WHERE id = 1
AND version = 3;UPDATE 0UPDATE 0 не означає успішне виконання. Це сигнал, що умови WHERE не виконалися. Потрібно:
скасувати транзакцію;
перечитати актуальні дані;
повторити операцію або показати конфлікт користувачу.
Довга транзакція довго утримує блокування. Це змушує інші транзакції чекати й може призвести до накопичення запитів.
Не слід утримувати транзакцію під час:
очікування введення користувача;
мережевих запитів;
тривалих обчислень, які не потребують блокування;
роботи з файлами або зовнішніми сервісами.
Якщо одна транзакція спочатку блокує товар 1, а потім товар 2, а інша — спочатку товар 2, а потім товар 1, може виникнути взаємне блокування.
Найпростіший захист — блокувати кілька рядків у стабільному порядку:
BEGIN;
SELECT id, stock
FROM products
WHERE id IN (1, 2)
ORDER BY id
FOR UPDATE;
-- Подальша робота з рядками
COMMIT;PostgreSQL виявляє deadlock і скасовує одну з транзакцій. Застосунок має бути готовим повторити всю операцію після такої помилки.
SKIP LOCKED для звичайного редагуванняSKIP LOCKED не повідомляє, що потрібний рядок зайнятий, а просто пропускає його. Це може призвести до неправильного результату, якщо застосунок очікує змінити саме конкретний запис.
Оптимістичне блокування виявляє конфлікт після спроби зміни.
Песимістичне блокування не допускає конкурентної зміни до завершення транзакції.
Оптимістичний підхід використовує поле версії та перевірку кількості оновлених рядків.
Песимістичний підхід використовує SELECT ... FOR UPDATE.
Обидва підходи потребують правильної транзакційної межі.
Успішне виконання SQL-запиту ще не означає успішне виконання бізнес-операції: потрібно перевіряти його результат.
Оптимістичне блокування підходить для рідкісних конфліктів. Його основа — умовне оновлення:
WHERE id = $id
AND version = $expected_versionЯкщо запит оновив нуль рядків, дані були змінені кимось іншим.
Песимістичне блокування підходить для операцій, які потрібно виконати послідовно. Рядок блокується всередині транзакції:
SELECT *
FROM products
WHERE id = $id
FOR UPDATE;Після блокування транзакція читає актуальні дані, перевіряє умови та виконує зміни. Незалежно від обраного підходу, потрібно завершувати транзакції, перевіряти результати UPDATE і не утримувати блокування довше, ніж потрібно.