Пошук уроків, статей та іншого контенту
Створите процедури з параметрами, керуванням транзакціями та викликом бізнес-операцій у базі даних.
Збережена процедура — це об’єкт PostgreSQL, який містить послідовність SQL-операцій і викликається командою CALL.
Процедури схожі на функції, але мають важливу відмінність:
функція викликається через SELECT, INSERT, UPDATE або іншу SQL-конструкцію;
процедура викликається через CALL;
процедура може керувати транзакціями за допомогою COMMIT і ROLLBACK;
процедура не зобов’язана повертати значення.
Процедури зручні для складних бізнес-операцій, які повинні виконуватися безпосередньо в базі даних:
переказ коштів між рахунками;
пакетна обробка замовлень;
зміна статусу пов’язаних записів;
аудит операцій;
виконання кількох узгоджених змін у таблицях.
Базовий синтаксис:
CREATE [OR REPLACE] PROCEDURE procedure_name(
[parameter_name parameter_type [IN | OUT | INOUT], ...]
)
LANGUAGE plpgsql
AS $$
BEGIN
-- SQL-операції
END;
$$;Параметри можуть мати такі режими:
IN — вхідний параметр. Це режим за замовчуванням;
OUT — вихідний параметр;
INOUT — параметр одночасно передається в процедуру і повертається з неї.
Виклик процедури:
CALL procedure_name(argument1, argument2);На відміну від функції, процедуру не можна викликати як частину виразу:
-- Так не можна:
SELECT procedure_name(10);Процедура може містити:
COMMIT;або:
ROLLBACK;Це дозволяє завершити поточну транзакцію і почати нову під час виконання процедури.
Однак є важливе обмеження: процедура з керуванням транзакціями повинна викликатися безпосередньо через CALL, не будучи частиною явного блоку транзакції.
Правильний виклик:
CALL process_operation();Неправильний виклик для процедури, яка містить COMMIT або ROLLBACK:
BEGIN;
CALL process_operation();
COMMIT;У такому випадку PostgreSQL повідомить, що транзакційні команди не можна виконувати всередині вже відкритого блоку транзакції.
Також не можна виконувати COMMIT або ROLLBACK усередині блоку EXCEPTION у PL/pgSQL. Обробник помилок створює підлеглу транзакцію, а завершувати таку транзакцію безпосередньо не можна.
Розглянемо бізнес-операцію, яка повинна:
перевірити вхідні параметри;
заблокувати два рахунки;
перевірити баланс рахунку-відправника;
зменшити баланс відправника;
збільшити баланс отримувача;
записати операцію переказу;
записати бухгалтерські проведення;
зафіксувати всі зміни однією транзакцією.
DROP TABLE IF EXISTS account_entries;
DROP TABLE IF EXISTS transfers;
DROP TABLE IF EXISTS accounts;
CREATE TABLE accounts (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
owner_name text NOT NULL,
balance numeric(12, 2) NOT NULL DEFAULT 0,
CHECK (balance >= 0)
);
CREATE TABLE transfers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
from_account_id integer NOT NULL REFERENCES accounts(id),
to_account_id integer NOT NULL REFERENCES accounts(id),
amount numeric(12, 2) NOT NULL CHECK (amount > 0),
reference text NOT NULL,
created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
CREATE TABLE account_entries (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
transfer_id bigint NOT NULL REFERENCES transfers(id),
account_id integer NOT NULL REFERENCES accounts(id),
amount numeric(12, 2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
INSERT INTO accounts (owner_name, balance)
VALUES
('Олена', 1000.00),
('Андрій', 250.00);У таблиці account_entries сума має знак:
від’ємна сума означає списання;
додатна сума означає зарахування.
CREATE OR REPLACE PROCEDURE transfer_money(
IN p_from_account_id integer,
IN p_to_account_id integer,
IN p_amount numeric(12, 2),
IN p_reference text
)
LANGUAGE plpgsql
AS $$
DECLARE
v_first_id integer;
v_first_balance numeric(12, 2);
v_second_id integer;
v_second_balance numeric(12, 2);
v_from_balance numeric(12, 2);
v_to_balance numeric(12, 2);
v_transfer_id bigint;
BEGIN
IF p_from_account_id IS NULL
OR p_to_account_id IS NULL
OR p_amount IS NULL
OR p_reference IS NULL THEN
RAISE EXCEPTION
USING
ERRCODE = '22023',
MESSAGE = 'Усі параметри переказу є обов’язковими';
END IF;
IF p_from_account_id = p_to_account_id THEN
RAISE EXCEPTION
USING
ERRCODE = '22023',
MESSAGE = 'Рахунок-відправник і рахунок-отримувач мають відрізнятися';
END IF;
IF p_amount <= 0 THEN
RAISE EXCEPTION
USING
ERRCODE = '22023',
MESSAGE = 'Сума переказу повинна бути більшою за нуль';
END IF;
IF btrim(p_reference) = '' THEN
RAISE EXCEPTION
USING
ERRCODE = '22023',
MESSAGE = 'Призначення переказу не може бути порожнім';
END IF;
/*
* Рахунки блокуються завжди в однаковому порядку.
* Це зменшує ризик взаємного блокування під час паралельних переказів.
*/
SELECT id, balance
INTO STRICT v_first_id, v_first_balance
FROM accounts
WHERE id = LEAST(p_from_account_id, p_to_account_id)
FOR UPDATE;
SELECT id, balance
INTO STRICT v_second_id, v_second_balance
FROM accounts
WHERE id = GREATEST(p_from_account_id, p_to_account_id)
FOR UPDATE;
IF p_from_account_id = v_first_id THEN
v_from_balance := v_first_balance;
v_to_balance := v_second_balance;
ELSE
v_from_balance := v_second_balance;
v_to_balance := v_first_balance;
END IF;
IF v_from_balance < p_amount THEN
RAISE EXCEPTION
USING
ERRCODE = 'P0001',
MESSAGE = format(
'Недостатньо коштів: доступно %s, потрібно %s',
v_from_balance,
p_amount
);
END IF;
UPDATE accounts
SET balance = balance - p_amount
WHERE id = p_from_account_id;
UPDATE accounts
SET balance = balance + p_amount
WHERE id = p_to_account_id;
INSERT INTO transfers (
from_account_id,
to_account_id,
amount,
reference
)
VALUES (
p_from_account_id,
p_to_account_id,
p_amount,
btrim(p_reference)
)
RETURNING id INTO v_transfer_id;
INSERT INTO account_entries (
transfer_id,
account_id,
amount
)
VALUES
(v_transfer_id, p_from_account_id, -p_amount),
(v_transfer_id, p_to_account_id, p_amount);
COMMIT;
END;
$$;Виклик потрібно виконувати без BEGIN:
CALL transfer_money(
1,
2,
125.50,
'Оплата рахунку INV-2026-001'
);Після успішного виклику зміни вже зафіксовані.
Перевірити баланси можна так:
SELECT id, owner_name, balance
FROM accounts
ORDER BY id;Очікуваний результат:
id | owner_name | balance
----+------------+---------
1 | Олена | 874.50
2 | Андрій | 375.50Перевірка журналу переказів:
SELECT
t.id,
t.from_account_id,
t.to_account_id,
t.amount,
t.reference,
t.created_at
FROM transfers AS t
ORDER BY t.id;Перевірка бухгалтерських проведень:
SELECT
ae.transfer_id,
ae.account_id,
ae.amount
FROM account_entries AS ae
ORDER BY ae.id;Для одного переказу сума проведень повинна дорівнювати нулю:
SELECT
transfer_id,
sum(amount) AS total
FROM account_entries
GROUP BY transfer_id;У процедурі змінюються кілька таблиць:
accounts;
transfers;
account_entries.
Якщо будь-яка операція досягне помилки, процедура не виконає COMMIT. Поточна транзакція буде перервана, а зміни не повинні залишитися частково застосованими.
Наприклад, якщо на рахунку недостатньо коштів:
CALL transfer_money(
1,
2,
100000.00,
'Невдалий переказ'
);Після помилки:
баланс не зменшиться;
баланс отримувача не збільшиться;
рядок у transfers не створиться;
проведення в account_entries не з’являться.
Для перевірки можна виконати:
SELECT id, owner_name, balance
FROM accounts
ORDER BY id;
SELECT count(*) AS transfer_count
FROM transfers;FOR UPDATEКоманда:
SELECT ...
FROM accounts
WHERE id = ...
FOR UPDATE;блокує вибраний рядок до завершення транзакції.
Це важливо для рахунків. Без блокування дві паралельні операції можуть одночасно прочитати один і той самий баланс:
перша операція бачить баланс 100;
друга операція також бачить баланс 100;
обидві перевіряють, що коштів достатньо;
обидві змінюють баланс.
FOR UPDATE змушує одну операцію зачекати, поки інша завершить роботу з рядком.
У прикладі рахунки блокуються в порядку їхніх ідентифікаторів:
WHERE id = LEAST(p_from_account_id, p_to_account_id)потім:
WHERE id = GREATEST(p_from_account_id, p_to_account_id)Єдиний порядок блокування зменшує імовірність deadlock, коли дві транзакції блокують одна одну.
OUT та INOUTПроцедура може повертати значення через параметри OUT або INOUT.
Наприклад:
CREATE OR REPLACE PROCEDURE calculate_total(
IN p_price numeric,
IN p_quantity integer,
OUT p_total numeric
)
LANGUAGE plpgsql
AS $$
BEGIN
p_total := p_price * p_quantity;
END;
$$;Виклик:
CALL calculate_total(19.99, 3, NULL);Результат буде повернутий у вигляді одного рядка з параметром p_total.
Параметри OUT не передаються як звичайні вхідні значення. Для них під час виклику передають позиційний заповнювач NULL.
Для процедури бізнес-операції зазвичай зручніше:
зберігати результат у таблиці;
повертати ідентифікатор через OUT;
або використовувати окрему функцію для обчислення значень.
Процедура з COMMIT у наведеному прикладі не повертає результат, оскільки її основна мета — виконати атомарну бізнес-операцію.
Для зміни тіла процедури використовується:
CREATE OR REPLACE PROCEDURE transfer_money(
IN p_from_account_id integer,
IN p_to_account_id integer,
IN p_amount numeric(12, 2),
IN p_reference text
)
LANGUAGE plpgsql
AS $$
BEGIN
-- нова реалізація
END;
$$;CREATE OR REPLACE PROCEDURE замінює визначення процедури, але не змінює її назву та сигнатуру.
Якщо потрібно змінити типи або кількість параметрів, це вже інша сигнатура. У такому випадку стару процедуру за потреби видаляють:
DROP PROCEDURE transfer_money(integer, integer, numeric, text);Типи параметрів у DROP PROCEDURE потрібно вказувати без назв параметрів.
Процедуру можна видалити так:
DROP PROCEDURE transfer_money(integer, integer, numeric, text);Якщо процедура може бути відсутньою:
DROP PROCEDURE IF EXISTS transfer_money(integer, integer, numeric, text);SELECTНеправильно:
SELECT transfer_money(1, 2, 100, 'Тест');Правильно:
CALL transfer_money(1, 2, 100, 'Тест');COMMITНеправильно:
BEGIN;
CALL transfer_money(1, 2, 100, 'Тест');
COMMIT;Якщо процедура містить COMMIT, її потрібно викликати без зовнішнього BEGIN.
Якщо одна процедура спочатку блокує рахунок 1, а інша спочатку блокує рахунок 2, паралельні виклики можуть створити deadlock.
Краще блокувати пов’язані рядки в детермінованому порядку, наприклад за ідентифікатором.
Перевірка балансу без FOR UPDATE не гарантує коректності під час одночасних операцій.
Небезпечно:
SELECT balance
FROM accounts
WHERE id = p_from_account_id;Для подальшої зміни балансу потрібно блокувати рядок:
SELECT balance
FROM accounts
WHERE id = p_from_account_id
FOR UPDATE;COMMIT усередині обробника помилкиТранзакційні команди не слід розміщувати всередині EXCEPTION:
BEGIN
-- операції
EXCEPTION
WHEN OTHERS THEN
ROLLBACK;
END;У PL/pgSQL обробка винятків використовує підлеглу транзакцію, тому така конструкція є некоректною для керування основною транзакцією. Краще дозволити помилці вийти з процедури або обробити її без COMMIT і ROLLBACK.
COMMIT виконається після помилкиЯкщо до COMMIT виникла помилка, виконання процедури припиняється. Команди після помилки не виконуються. Тому перевірки та всі критичні зміни потрібно розміщувати до явного COMMIT.
Збережена процедура створюється через CREATE PROCEDURE.
Викликається процедура командою CALL.
Процедура може мати параметри IN, OUT та INOUT.
Процедури можуть виконувати кілька пов’язаних бізнес-операцій у базі даних.
COMMIT і ROLLBACK дозволені в процедурах за умови прямого виклику через CALL.
Процедуру з керуванням транзакціями не можна викликати всередині явного блоку BEGIN ... COMMIT.
Для конкурентної зміни даних потрібно використовувати блокування, зокрема SELECT ... FOR UPDATE.
Пов’язані рядки бажано блокувати в однаковому порядку.
Успішна бізнес-операція повинна фіксувати всі зміни атомарно.