Пошук уроків, статей та іншого контенту
Застосуєте транзакції для платежів, резервування ресурсів і пакетних операцій із безпечним конкурентним доступом.
BEGIN і COMMITУ простому прикладі транзакція часто виглядає так:
BEGIN;
UPDATE accounts
SET balance = balance - 100
WHERE id = 1;
UPDATE accounts
SET balance = balance + 100
WHERE id = 2;
COMMIT;У реальному застосунку потрібно додатково врахувати:
паралельні запити від різних користувачів;
повторне надсилання запиту через мережеву помилку;
часткове виконання пакетної операції;
блокування рядків;
виклики зовнішніх платіжних сервісів;
повторну доставку webhook-подій;
зависання процесу посеред транзакції.
Транзакція гарантує атомарність лише для операцій, які виконуються всередині PostgreSQL. Вона не може автоматично скасувати платіж у зовнішнього провайдера або повернути HTTP-запит у попередній стан.
Припустімо, є товар із кількістю доступних одиниць:
CREATE TABLE product_stock (
product_id text PRIMARY KEY,
available_quantity integer NOT NULL CHECK (available_quantity >= 0)
);
CREATE TABLE reservations (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id text NOT NULL,
product_id text NOT NULL REFERENCES product_stock(product_id),
quantity integer NOT NULL CHECK (quantity > 0),
created_at timestamptz NOT NULL DEFAULT now(),
-- Один товар можна зарезервувати для замовлення лише один раз.
UNIQUE (order_id, product_id)
);
INSERT INTO product_stock (product_id, available_quantity)
VALUES ('keyboard-1', 10);Наївна реалізація може спочатку прочитати залишок, а потім оновити його:
SELECT available_quantity
FROM product_stock
WHERE product_id = 'keyboard-1';Якщо два запити одночасно прочитають значення 1, обидва можуть вирішити, що товар доступний. У результаті кількість резервувань перевищить кількість товару.
Для захисту рядка використовується SELECT ... FOR UPDATE. Він блокує вибраний рядок до завершення транзакції.
BEGIN;
-- Блокуємо рядок запасу до COMMIT або ROLLBACK.
SELECT available_quantity
FROM product_stock
WHERE product_id = 'keyboard-1'
FOR UPDATE;
-- Після блокування цей рядок не може одночасно змінити інша транзакція.
UPDATE product_stock
SET available_quantity = available_quantity - 2
WHERE product_id = 'keyboard-1'
AND available_quantity >= 2;
-- У застосунку потрібно перевірити, що UPDATE змінив один рядок.
INSERT INTO reservations (order_id, product_id, quantity)
VALUES ('order-100', 'keyboard-1', 2);
COMMIT;Якщо умова available_quantity >= 2 не виконується, застосунок повинен виконати ROLLBACK, а користувачу повідомити, що товару недостатньо.
Мережевий клієнт може повторити той самий запит. Наприклад, сервер успішно створив резервування, але відповідь не дійшла до клієнта.
Без ідемпотентності повторний запит може двічі зменшити залишок. Для цього потрібно:
мати стабільний ідентифікатор операції або замовлення;
забезпечити унікальність резервування;
перевіряти, чи операція вже виконана;
змінювати залишок і створювати резервування в одній транзакції.
Функція нижче виконує ці кроки атомарно:
CREATE OR REPLACE FUNCTION reserve_product(
p_order_id text,
p_product_id text,
p_quantity integer
)
RETURNS text
LANGUAGE plpgsql
AS $$
DECLARE
v_available integer;
BEGIN
IF p_quantity <= 0 THEN
RAISE EXCEPTION 'Кількість повинна бути додатною';
END IF;
-- Повторний запит для того самого замовлення є безпечною операцією.
IF EXISTS (
SELECT 1
FROM reservations
WHERE order_id = p_order_id
AND product_id = p_product_id
) THEN
RETURN 'already_reserved';
END IF;
-- Блокуємо запас конкретного товару.
SELECT available_quantity
INTO v_available
FROM product_stock
WHERE product_id = p_product_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Товар "%" не знайдено', p_product_id;
END IF;
IF v_available < p_quantity THEN
RETURN 'not_enough_stock';
END IF;
UPDATE product_stock
SET available_quantity = available_quantity - p_quantity
WHERE product_id = p_product_id;
INSERT INTO reservations (order_id, product_id, quantity)
VALUES (p_order_id, p_product_id, p_quantity);
RETURN 'reserved';
END;
$$;
BEGIN;
SELECT reserve_product('order-100', 'keyboard-1', 2);
COMMIT;Перевірка наявності резервування перед блокуванням не є достатньою сама по собі: два паралельні запити можуть одночасно не побачити рядок. Унікальне обмеження UNIQUE (order_id, product_id) залишається остаточним захистом від дублювання.
У складнішій реалізації можна обробити конфлікт унікальності через INSERT ... ON CONFLICT. Важливо, щоб повторний запит не зменшував залишок повторно.
Платіж зазвичай включає дві різні системи:
локальну базу даних застосунку;
зовнішнього платіжного провайдера.
Не варто робити так:
BEGIN
змінити замовлення в PostgreSQL
викликати платіжний API
COMMITПричини:
HTTP-запит може тривати довго;
транзакція утримуватиме блокування під час очікування мережі;
процес може завершитися після платежу, але до COMMIT;
PostgreSQL не може скасувати вже виконану операцію у провайдера.
Замість цього застосунок зазвичай розділяє процес на локальні атомарні кроки:
у транзакції створює або оновлює платіж зі статусом pending;
передає провайдеру унікальний ключ ідемпотентності;
отримує результат або webhook;
у новій транзакції обробляє подію та змінює локальний стан.
Приклад схеми:
CREATE TABLE orders (
id text PRIMARY KEY,
status text NOT NULL CHECK (
status IN ('created', 'reserved', 'payment_pending', 'paid', 'cancelled')
)
);
CREATE TABLE payments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id text NOT NULL UNIQUE REFERENCES orders(id),
provider_payment_id text UNIQUE,
status text NOT NULL CHECK (
status IN ('pending', 'succeeded', 'failed')
),
amount_cents bigint NOT NULL CHECK (amount_cents > 0),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE payment_events (
event_id text PRIMARY KEY,
provider_payment_id text NOT NULL,
event_type text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);Створення локального платежу:
BEGIN;
-- Блокуємо замовлення, щоб паралельні операції не змінили його стан одночасно.
SELECT id
FROM orders
WHERE id = 'order-100'
FOR UPDATE;
INSERT INTO payments (
order_id,
provider_payment_id,
status,
amount_cents
)
VALUES (
'order-100',
NULL,
'pending',
15999
)
ON CONFLICT (order_id) DO NOTHING;
UPDATE orders
SET status = 'payment_pending'
WHERE id = 'order-100'
AND status IN ('created', 'reserved');
COMMIT;Після цього можна викликати платіжний API. Такий виклик не повинен утримувати транзакцію PostgreSQL.
Провайдер може повторно надіслати ту саму подію. Тому ідентифікатор події повинен бути унікальним.
CREATE OR REPLACE FUNCTION process_payment_event(
p_event_id text,
p_provider_payment_id text,
p_event_type text
)
RETURNS text
LANGUAGE plpgsql
AS $$
DECLARE
v_inserted integer;
BEGIN
-- Повторна доставка тієї самої події не повинна змінювати дані вдруге.
INSERT INTO payment_events (
event_id,
provider_payment_id,
event_type
)
VALUES (
p_event_id,
p_provider_payment_id,
p_event_type
)
ON CONFLICT (event_id) DO NOTHING;
GET DIAGNOSTICS v_inserted = ROW_COUNT;
IF v_inserted = 0 THEN
RETURN 'already_processed';
END IF;
IF p_event_type = 'payment_succeeded' THEN
-- Блокуємо платіж перед зміною його стану.
UPDATE payments
SET status = 'succeeded',
updated_at = now()
WHERE provider_payment_id = p_provider_payment_id
AND status = 'pending';
-- Стан замовлення змінюється в тій самій транзакції.
UPDATE orders o
SET status = 'paid'
FROM payments p
WHERE p.order_id = o.id
AND p.provider_payment_id = p_provider_payment_id
AND p.status = 'succeeded'
AND o.status = 'payment_pending';
RETURN 'payment_succeeded';
ELSIF p_event_type = 'payment_failed' THEN
UPDATE payments
SET status = 'failed',
updated_at = now()
WHERE provider_payment_id = p_provider_payment_id
AND status = 'pending';
UPDATE orders o
SET status = 'cancelled'
FROM payments p
WHERE p.order_id = o.id
AND p.provider_payment_id = p_provider_payment_id
AND p.status = 'failed'
AND o.status = 'payment_pending';
RETURN 'payment_failed';
ELSE
RETURN 'ignored_event_type';
END IF;
END;
$$;
BEGIN;
SELECT process_payment_event(
'evt-100',
'provider-payment-100',
'payment_succeeded'
);
COMMIT;Обробка події та зміна локального стану виконуються в одній транзакції. Якщо транзакція завершиться помилкою, вставка в payment_events також буде скасована, і подію можна буде безпечно обробити повторно.
У production-застосунку перед зміною платежу потрібно також перевіряти:
підпис webhook;
суму платежу;
валюту;
відповідність замовлення;
допустимий перехід між статусами.
SKIP LOCKEDПакетні працівники часто обробляють чергу паралельно. Два worker-процеси не повинні взяти одне й те саме завдання.
CREATE TABLE jobs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'processing', 'done', 'failed')),
attempts integer NOT NULL DEFAULT 0,
locked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO jobs (payload)
SELECT jsonb_build_object('number', value)
FROM generate_series(1, 10) AS value;Один worker може атомарно забрати пакет завдань:
BEGIN;
WITH selected_jobs AS (
SELECT id
FROM jobs
WHERE status = 'pending'
ORDER BY created_at, id
FOR UPDATE SKIP LOCKED
LIMIT 3
)
UPDATE jobs AS j
SET status = 'processing',
attempts = attempts + 1,
locked_at = now()
FROM selected_jobs
WHERE j.id = selected_jobs.id
RETURNING j.id, j.payload;
COMMIT;FOR UPDATE SKIP LOCKED працює так:
FOR UPDATE блокує вибрані рядки;
SKIP LOCKED пропускає рядки, які вже забрав інший worker;
різні workers отримують різні завдання;
worker не чекає на блокування чужої роботи.
У цьому прикладі завдання переходять у processing до COMMIT. Після цього їх можна обробляти поза транзакцією, не утримуючи блокування.
Якщо worker завершився аварійно, завдання можуть залишитися у стані processing. Потрібен механізм відновлення:
BEGIN;
UPDATE jobs
SET status = 'pending',
locked_at = NULL
WHERE status = 'processing'
AND locked_at < now() - interval '15 minutes';
COMMIT;Тривалість тайм-ауту потрібно вибирати з урахуванням реального часу обробки. Занадто короткий тайм-аут може призвести до повторної обробки ще активного завдання.
PostgreSQL за замовчуванням використовує READ COMMITTED. У цьому режимі кожна SQL-команда бачить дані, зафіксовані до початку цієї команди.
Для більшості операцій із явним FOR UPDATE цього достатньо. Проте іноді потрібно, щоб транзакція працювала з узгодженим знімком даних.
REPEATABLE READBEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT ...
FROM product_stock
WHERE product_id = 'keyboard-1';
-- Інші транзакції можуть змінити дані,
-- але ця транзакція продовжує бачити свій знімок.
COMMIT;У REPEATABLE READ PostgreSQL може завершити транзакцію помилкою serialization failure, якщо паралельна зміна несумісна з її знімком.
SERIALIZABLESERIALIZABLE змушує PostgreSQL гарантувати результат, еквівалентний послідовному виконанню транзакцій:
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- Критична бізнес-операція.
COMMIT;Це не означає, що всі транзакції завжди успішно завершаться. PostgreSQL може відхилити одну з них із помилкою serialization_failure. Застосунок повинен повторити всю транзакцію, а не лише останній SQL-запит.
Також потрібно повторювати транзакцію після помилки взаємного блокування deadlock_detected.
Типовий алгоритм:
почати транзакцію;
виконати всі її операції;
зробити COMMIT;
якщо отримано 40001 або 40P01, зачекати з невеликим випадковим інтервалом;
повторити всю транзакцію обмежену кількість разів.
Не можна повторно виконувати лише частину транзакції: попередні операції могли вже бути зафіксовані або мати інший результат.
Взаємне блокування виникає, коли транзакції беруть блокування в різному порядку:
Транзакція A: блокує товар 1, очікує товар 2
Транзакція B: блокує товар 2, очікує товар 1Жодна транзакція не може продовжити роботу, тому PostgreSQL примусово скасовує одну з них.
Щоб зменшити ризик deadlock:
завжди блокуйте таблиці та рядки в однаковому порядку;
для кількох товарів сортуйте ідентифікатори перед блокуванням;
не виконуйте довгі зовнішні операції всередині транзакції;
тримайте транзакції короткими;
обробляйте deadlock_detected повтором усієї транзакції.
Наприклад, для замовлення з кількома товарами потрібно спочатку визначити всі product_id, відсортувати їх і лише потім послідовно заблокувати рядки.
Після UPDATE або DELETE не слід припускати, що операція змінила дані. Потрібно перевіряти кількість змінених рядків.
Наприклад:
BEGIN;
UPDATE product_stock
SET available_quantity = available_quantity - 5
WHERE product_id = 'keyboard-1'
AND available_quantity >= 5
RETURNING product_id, available_quantity;
-- Якщо UPDATE не повернув рядок, товару недостатньо
-- або його не існує. У такому разі виконується ROLLBACK.
COMMIT;RETURNING зручний тим, що дозволяє одночасно:
виконати перевірку;
змінити значення;
отримати фактичний результат операції.
Транзакція повинна охоплювати всі локальні зміни, які мають бути атомарними:
BEGIN
перевірити замовлення
заблокувати залишок
зменшити залишок
створити резервування
змінити статус замовлення
COMMITАле вона не повинна охоплювати:
HTTP-запит до платіжного провайдера;
очікування користувацької дії;
довгу обробку файлу;
повільне виконання непов’язаної задачі.
Якщо будь-яка операція всередині транзакції не вдалася, застосунок повинен виконати ROLLBACK. З’єднання, на якому сталася помилка транзакції, не можна повторно використовувати для звичайних запитів до ROLLBACK.
SELECT available_quantity FROM product_stock WHERE product_id = 'keyboard-1';
-- Пізніше:
UPDATE product_stock SET available_quantity = available_quantity - 1;Між цими командами інша транзакція може змінити залишок. Використовуйте одну транзакцію та блокування або атомарний UPDATE з умовою.
Це утримує блокування під час мережевої операції та створює складні ситуації при обриві з’єднання. Зберігайте локальний стан окремо від виклику зовнішнього сервісу.
Повторний HTTP-запит або webhook не повинен повторно списувати гроші, зменшувати залишок чи створювати резервування.
Для цього використовуйте:
унікальні ключі операцій;
UNIQUE-обмеження;
ON CONFLICT;
таблицю вже оброблених подій.
Довга транзакція довше утримує блокування, збільшує кількість конфліктів і може затримувати очищення старих версій рядків.
Помилки 40001 і 40P01 не означають, що потрібно продовжити роботу в тій самій транзакції. Потрібно виконати ROLLBACK і повторити всю операцію.
SKIP LOCKED без відновленняЯкщо worker позначає завдання як processing, але завершується аварійно, потрібен процес повернення застарілих завдань у pending.
Конкурентне резервування виконуйте в транзакції з блокуванням рядка через SELECT ... FOR UPDATE.
Залишок і створення резервування повинні змінюватися атомарно.
Ідемпотентність забезпечується стабільними ключами, унікальними обмеженнями та перевіркою повторних операцій.
Виклик зовнішнього платіжного сервісу не слід виконувати всередині транзакції PostgreSQL.
Webhook-події обробляйте в окремих транзакціях і зберігайте їхні унікальні ідентифікатори.
Для паралельної пакетної обробки використовуйте FOR UPDATE SKIP LOCKED.
Усі транзакції потрібно робити короткими та брати блокування в узгодженому порядку.
Помилки серіалізації й deadlock потребують повтору всієї транзакції.