Пошук уроків, статей та іншого контенту
Спроєктуйте збереження версій записів і визначте правила їх створення, читання та відновлення.
Версіювання даних — це збереження кількох станів одного запису в часі. На відміну від звичайного аудиту, де зберігається інформація про факт зміни, версіювання дає змогу:
переглянути попередній стан запису;
відновити запис до певної версії;
визначити, хто і коли змінив дані;
зберегти історію без перезаписування старих значень.
Наприклад, для статті можна зберігати всі редакції її заголовка, тексту та статусу публікації.
Важливо відразу визначити правило:
Відновлення старої версії не видаляє нові версії, а створює ще одну версію з відновленими даними.
Тоді історія залишається послідовною і не втрачає інформацію про те, що запис спочатку змінювали, а потім відновлювали.
Один із практичних підходів — розділити дані на дві таблиці:
articles — поточний стан запису;
article_versions — незмінна історія всіх версій.
Таблиця версій зберігає повну копію даних, а не лише відмінності між версіями. Такий підхід називають збереженням повних знімків (snapshot).
Переваги повних знімків:
будь-яку версію можна прочитати одним запитом;
відновлення не потребує послідовного застосування багатьох змін;
пошкодження однієї версії не руйнує всі наступні версії;
структура запитів залишається простою.
Недолік — більший обсяг даних. Для більшості бізнес-сутностей це прийнятний компроміс.
CREATE TABLE articles (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
status text NOT NULL CHECK (status IN ('draft', 'published')),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE article_versions (
article_id bigint NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
version_no integer NOT NULL CHECK (version_no > 0),
title text NOT NULL,
body text NOT NULL,
status text NOT NULL CHECK (status IN ('draft', 'published')),
created_at timestamptz NOT NULL DEFAULT now(),
created_by text NOT NULL DEFAULT current_user,
PRIMARY KEY (article_id, version_no)
);version_no нумерує версії окремо для кожної статті. Наприклад, різні статті можуть одночасно мати версії 1, 2 і 3.
Первинний ключ (article_id, version_no) не дозволяє створити дві версії з однаковим номером для однієї статті.
Перед реалізацією потрібно зафіксувати правила:
Початкове створення статті створює версію 1.
Кожна зміна версіюваних полів створює наступну версію.
Старі версії не оновлюються і не видаляються.
Зміна полів, які не належать до вмісту версії, не повинна створювати нову версію.
Усі зміни та створення версії виконуються в одній транзакції.
Відновлення старої версії створює нову поточну версію.
Номер версії є послідовним лише в межах конкретного запису.
У прикладі updated_at змінюватиметься разом із поточним записом, але не зберігатиметься як окрема версіонована властивість. Час створення версії зберігається в article_versions.created_at.
Створювати версії можна в коді застосунку, але це створює ризик пропустити окремий шлях зміни даних. Наприклад, один сервіс може правильно записувати історію, а адміністративний SQL-запит — ні.
Тригер PostgreSQL дає змогу централізувати це правило на рівні бази даних.
CREATE OR REPLACE FUNCTION save_article_version()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
next_version integer;
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO article_versions (
article_id,
version_no,
title,
body,
status
)
VALUES (
NEW.id,
1,
NEW.title,
NEW.body,
NEW.status
);
RETURN NEW;
END IF;
SELECT COALESCE(MAX(version_no), 0) + 1
INTO next_version
FROM article_versions
WHERE article_id = NEW.id;
INSERT INTO article_versions (
article_id,
version_no,
title,
body,
status
)
VALUES (
NEW.id,
next_version,
NEW.title,
NEW.body,
NEW.status
);
RETURN NEW;
END;
$$;
CREATE TRIGGER articles_create_initial_version
AFTER INSERT ON articles
FOR EACH ROW
EXECUTE FUNCTION save_article_version();
CREATE TRIGGER articles_create_new_version
AFTER UPDATE OF title, body, status ON articles
FOR EACH ROW
WHEN (OLD IS DISTINCT FROM NEW)
EXECUTE FUNCTION save_article_version();Тригер оновлення запускається лише для змін title, body або status. Умова OLD IS DISTINCT FROM NEW додатково перевіряє, що значення справді змінилися.
Наприклад, таке оновлення не створить нову версію:
UPDATE articles
SET title = title
WHERE id = 1;Натомість зміна заголовка створить нову версію:
UPDATE articles
SET title = 'Оновлений заголовок',
updated_at = now()
WHERE id = 1;Оскільки PostgreSQL блокує рядок під час його оновлення, два одночасні оновлення тієї самої статті не виконуватимуться паралельно над одним і тим самим рядком. Це дає змогу безпечно визначати наступний номер версії через MAX(version_no) + 1 у цьому сценарії.
Поточний стан потрібно читати з основної таблиці:
SELECT id, title, body, status, updated_at
FROM articles
WHERE id = 1;Таблиця версій потрібна для читання історії, а не для заміни поточного стану.
Щоб отримати всі версії статті:
SELECT
version_no,
title,
body,
status,
created_at,
created_by
FROM article_versions
WHERE article_id = 1
ORDER BY version_no DESC;Найновіша версія буде першою через ORDER BY version_no DESC.
Щоб отримати конкретну версію:
SELECT
article_id,
version_no,
title,
body,
status,
created_at,
created_by
FROM article_versions
WHERE article_id = 1
AND version_no = 2;Версію потрібно ідентифікувати парою значень:
article_id;
version_no.
Номер 2 сам по собі не є глобальним ідентифікатором версії.
Відновлення виконується як звичайне оновлення поточного запису значеннями з історичної версії:
BEGIN;
UPDATE articles AS current_article
SET
title = version.title,
body = version.body,
status = version.status,
updated_at = now()
FROM article_versions AS version
WHERE current_article.id = version.article_id
AND current_article.id = 1
AND version.version_no = 2;
COMMIT;Тригер створить нову версію з відновленими значеннями. Наприклад:
створено версію 1;
статтю змінено — створено версію 2;
статтю змінено ще раз — створено версію 3;
відновлено версію 1 — створено версію 4.
Версія 1 при цьому залишається в історії, а версія 4 показує факт відновлення.
Перед відновленням бажано перевірити, що потрібна версія існує:
SELECT 1
FROM article_versions
WHERE article_id = 1
AND version_no = 2;Якщо запит не повернув рядок, операцію відновлення виконувати не слід.
Відновлення має складатися з одного атомарного оновлення. Не варто окремо:
прочитати версію;
завершити транзакцію;
пізніше оновити поточний запис.
Між цими операціями інший запит може змінити статтю. Надійніший варіант — виконати читання та оновлення в одній транзакції.
Якщо застосунок спочатку показує користувачу версію для підтвердження, під час самого відновлення потрібно ще раз перевірити, що версія існує. За потреби можна також заблокувати поточний рядок:
BEGIN;
SELECT id
FROM articles
WHERE id = 1
FOR UPDATE;
UPDATE articles AS current_article
SET
title = version.title,
body = version.body,
status = version.status,
updated_at = now()
FROM article_versions AS version
WHERE current_article.id = version.article_id
AND current_article.id = 1
AND version.version_no = 2;
COMMIT;FOR UPDATE не дозволяє іншій транзакції одночасно змінити цей рядок до завершення поточної транзакції.
До версії потрібно включати всі поля, необхідні для повного відновлення запису. Для статті це можуть бути:
заголовок;
текст;
статус;
ідентифікатори пов’язаних значень, якщо вони є частиною стану статті.
Окремо корисно зберігати метадані версії:
час створення;
користувача або сервіс, який виконав зміну;
причину зміни, якщо її передає бізнес-логіка;
тип операції, наприклад update або restore.
У базовому прикладі created_by використовує current_user PostgreSQL. У реальному застосунку database-користувач може бути спільним для всіх запитів, тому ідентифікатор користувача застосунку часто передають окремим значенням у межах транзакції.
Є два поширені способи зберігати версії.
Кожна версія містить усі версіоновані поля.
Переваги:
просте читання;
просте відновлення;
незалежність версій одна від одної.
Недоліки:
повторюються незмінні дані;
історія займає більше місця.
Кожна версія містить лише поля, які змінилися.
Переваги:
менший обсяг даних;
зручно аналізувати окремі зміни.
Недоліки:
для отримання стану потрібно послідовно застосувати зміни;
відновлення складніше;
пошкодження або пропуск однієї зміни може вплинути на наступні.
Для задачі надійного читання та відновлення повні знімки зазвичай є простішим і безпечнішим вибором.
Якщо структура запису часто змінюється або версіюються різні типи сутностей, знімок можна зберігати в jsonb:
CREATE TABLE entity_versions (
entity_type text NOT NULL,
entity_id bigint NOT NULL,
version_no integer NOT NULL,
snapshot jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
created_by text NOT NULL DEFAULT current_user,
PRIMARY KEY (entity_type, entity_id, version_no)
);Такий підхід гнучкіший, але має компроміси:
PostgreSQL не перевіряє структуру JSON на рівні типів таблиці;
складніше гарантувати наявність обов’язкових полів;
для відновлення потрібне перетворення JSON у поля поточної таблиці;
запити до окремих властивостей можуть бути складнішими.
Для стабільної структури сутності окремі типізовані колонки в таблиці версій зазвичай зрозуміліші.
Історія версій може швидко зростати. Перед впровадженням потрібно визначити:
чи зберігати всі версії безстроково;
чи потрібен термін зберігання;
чи можна видаляти історію разом із поточним записом;
чи містять версії персональні або конфіденційні дані.
Якщо історія потрібна для юридичного аудиту, автоматичне видалення версій може бути неприйнятним. Якщо це лише історія редагування, можна ввести політику зберігання, наприклад залишати останні N версій.
Видалення поточного запису через ON DELETE CASCADE у наведеній схемі видалить і його версії. Це підходить не для кожної системи. Якщо історія повинна залишатися після видалення сутності, потрібно використовувати іншу політику: м’яке видалення або окреме збереження історичних даних.
Не слід змінювати рядок у article_versions, щоб виконати відновлення. Це знищує історію.
Правильно:
UPDATE articles
SET title = ...,
body = ...,
status = ...
WHERE id = ...;Неправильно:
UPDATE article_versions
SET title = ...
WHERE article_id = ...
AND version_no = ...;Відновлення не повинно видаляти версії, створені після вибраної версії. Інакше буде втрачено інформацію про подальші зміни.
Якщо фоновий процес часто змінює updated_at, це поле не повинно запускати версіювання вмісту. Тригери потрібно прив’язувати лише до полів, які справді є частиною бізнес-версії.
Оновлення поточного запису та запис історії мають бути однією атомарною операцією. Якщо вони виконуються окремо, може виникнути поточний стан без відповідної версії або версія без успішного оновлення.
Не слід генерувати version_no у застосунку через попередній запит на кшталт SELECT MAX(version_no), а потім вставляти результат окремим запитом. Два паралельні процеси можуть отримати однаковий номер.
Нумерацію потрібно виконувати в межах операції, яка синхронізована з оновленням поточного рядка, або використовувати інший механізм серіалізації.
Для версіювання записів у PostgreSQL:
зберігайте поточний стан окремо від історії;
для надійного відновлення зберігайте повні знімки версій;
нумеруйте версії окремо для кожного запису;
робіть історичні версії незмінними;
створюйте версію лише після зміни значущих полів;
виконуйте оновлення та запис версії в одній транзакції;
відновлюйте стару версію через оновлення поточного запису;
не видаляйте історичні версії під час відновлення;
заздалегідь визначте правила зберігання, видалення та аудиту версій.