Перейти до вмісту

Журнал, відновлення, бекапи

У модулі 1 сервер убили сигналом SIGKILL, і закомічений рядок вижив, хоча його сторінка могла ще не дійти до файлу даних. Вижив він тому, що перед COMMIT зміна вже лежала в журналі, а після перезапуску сервер її повторив.

Від помилки людини це не захищає. У лабораторній L6 хтось виконує DROP TABLE order_items, репліка слухняно повторює команду, бо читає той самий журнал, а нічна копія pg_dump зроблена о третій ночі й не містить замовлень за день. Журнал, який архівують і поєднують із фізичною копією, повертає базу на мить перед помилкою: у L6 після відновлення на місці всі 30 замовлень, створених між бекапом і аварією.

Передумови. Сторінка даних і buffer pool: модуль 7. Версії рядків і pg_xact: модуль 10. Кеш сторінок ядра й журналювання файлових систем: модулі 13 і 15 курсу «Операційні системи». Виміри на розмірі small (Датасет), крім тих, де вказано інше.

Чому сторінки не пишуть одразу

Section titled “Чому сторінки не пишуть одразу”

UPDATE inventory змінює кілька байтів сторінки на 8 КіБ, що лежить у buffer pool. Писати її у файл при кожному COMMIT дорого. Одна покупка на «Крамниці» змінює пʼять сторінок: рядки в orders, order_items, inventory і записи в індексах двох перших таблиць. Вони розкидані по різних файлах, а випадковий запис найповільніший.

Тому зміну спершу дописують у кінець журналу, WAL (write-ahead log: журнал, у який пишуть наперед). Це послідовний запис, а файли даних оновлюють пізніше, у фоні й великими порціями. Кожен запис має адресу, LSN (log sequence number): позицію в журналі в байтах.

Правило одне: спершу журнал. Змінену сторінку не можна скинути у файл, доки в журналі на диску немає запису про її останню зміну. А COMMIT повертає успіх лише після того, як на диск дійшов запис про коміт. Так само працює журналювання файлових систем, але ті журналюють метадані, а WAL містить усі зміни даних.

Після збою сторінка у файлі може бути застарілою, але журнал містить усе, щоб її наздогнати: PostgreSQL читає його й повторює зміни (redo). Питання в тому, з якого місця. Від початку журналу старт тривав би годинами, бо журнал росте весь час.

Рішення просте: час від часу скидати на диск усі змінені сторінки й запам’ятовувати, що все до цього місця вже у файлах. Це контрольна точка (checkpoint): вона скидає змінені сторінки з buffer pool, пише в журнал спеціальний запис, а в pg_control адресу, з якої починати відновлення, точку REDO. Журнал до неї для відновлення вже не потрібен, і сегменти можна переробляти, якщо їх не тримає архів чи репліка.

Запускає її час (checkpoint_timeout, типово 5 хвилин) або обсяг журналу (max_wal_size, типово 1 ГБ, у нашому docker-compose.yml 4 ГБ; межа м’яка). Скидати всі сторінки одразу означало б зупинити диск, тому checkpoint_completion_target (типово 0.9) розтягує запис на 90 % інтервалу. Кожну точку відмічає рядок у лозі (log_checkpoints типово ввімкнено): checkpoint complete: wrote 342 buffers (1.0%), … distance=2836 kB, … redo lsn=0/5476FD8.

Рідкі точки дають менше повних образів (про них далі), але довше відновлення. Якщо в pg_stat_checkpointer num_requested переважає num_timed, точки викликає max_wal_size, і його варто збільшити.

SELECT checkpoint_lsn, redo_lsn, redo_wal_file, timeline_id FROM pg_control_checkpoint();
SELECT num_timed, num_requested, buffers_written FROM pg_stat_checkpointer;

Як відбувається відновлення

Section titled “Як відбувається відновлення”

PostgreSQL читає pg_control, бере з нього точку REDO останньої контрольної точки й проходить журнал до кінця дійсних записів. Кінець видно за повідомленням invalid record length … got 0. Потім сервер робить контрольну точку й приймає з’єднання.

Для кожного запису він дивиться на сторінку, якої той стосується. Якщо LSN сторінки менший за LSN запису, запис застосовується, інакше пропускається, бо зміна вже там. Образ сторінки записується безумовно. Завдяки перевірці LSN повтор ідемпотентний: збій посеред відновлення нічого не ламає, наступний запуск просто почне знову.

Вісь LSN: контрольна точка, записи WAL, збій і три кроки відновленняLSN, байти журналузмінені сторінки вжеу файлах данихredo: записи, якітреба повторитиобірваний хвістCOMMITконтрольна точкаREDO = 0/35A02818збій (SIGKILL)кінець журналу 0/406247D01. pg_control каже,де точка REDO2. redo кожного запису,якщо LSN сторінки менший3. кінець журналу:контрольна точка й запуск
Вісь LSN після збою. Зліва від точки REDO сторінки вже на диску, від неї до кінця журналу зміни повторюють за записами WAL. Числа з прогону під pgbench.

Класичний алгоритм ARIES (Mohan та співавтори, 1992) має три проходи: аналіз, повтор (redo) і відкат (undo) незавершених транзакцій. Останнього PostgreSQL не виконує. Рядки перерваної транзакції лежать у таблиці, але їхній статус у pg_xact так і не став «закомічено», тож вона вважається відкоченою, а її версії невидимі (модуль 10); прибере їх VACUUM. Відкочувати нічого, бо MVCC не затирає старих версій. InnoDB змінює рядок на місці, тому після повтору відкочує незавершені транзакції за undo log.

Запис «вставити рядок у сторінку 281» не можна накласти на torn page (частково записану сторінку). Вона виходить, коли живлення зникає посередині запису 8 КіБ: початок уже новий, кінець ще старий.

Тому перша зміна кожної сторінки після контрольної точки потрапляє в журнал цілою, разом із 8 КіБ вмісту. Це повний образ сторінки (full_page_writes, типово on). Якщо сторінку розірвало, redo перезаписує її образом, а потім накладає дрібніші записи.

Ціну видно одразу: та сама покупка з розділу «Як це насправді», виконана відразу після CHECKPOINT, зайняла 71 728 байтів замість 808: у десяти її записах були образи сторінок, зокрема FPI_FOR_HINT, які пишуться, коли змінюється лише «підказка» стану рядка (у PostgreSQL 18 контрольні суми сторінок увімкнено типово, і підказки журналюються). Масово: після CHECKPOINT двічі поспіль оновлено по 1000 випадкових замовлень.

Запуск WAL Образів сторінок (wal_fpi)
перший після контрольної точки 2547 кБ 320
другий, без нової контрольної точки 245 кБ 16
перший, full_page_writes = off 168 кБ 0

Тож після кожної контрольної точки запис зростає. Параметр вимикають лише там, де сховище гарантує атомарність запису сторінки (документація називає ZFS); wal_compression стискає образи ціною процесора.

fsync: що означає «записано»

Section titled “fsync: що означає «записано»”

Виклик write() нічого не записує на диск: дані лягають у кеш сторінок ядра (модуль 13 ОС). Щоб COMMIT був довговічним, PostgreSQL просить ядро дочекатися диска викликом fsync() чи fdatasync(). Який саме, вирішує wal_sync_method (на Linux типово fdatasync). pg_test_fsync у нашому контейнері (Docker Desktop, диск віртуальний) виміряв близько 400 мкс на запис 8 КіБ. У пісочниці PGlite fsync узагалі вимкнено: її дані одноразові.

Клієнти, що комітять одночасно, не чекають кожен свого fdatasync: хто дійшов до скидання першим, скидає журнал до найпізнішого LSN і заразом підтверджує всіх, чиї записи туди влізли. Це груповий коміт (group commit): через нього вісім клієнтів у вимірі нижче дали втричі більше транзакцій за секунду, ніж один.

Усе це працює, лише якщо система під базою чесна. Диск із кешем без захисту від втрати живлення може відповісти «готово» раніше, ніж дані стали постійними. Випадок, коли fsync чесно повідомив про помилку, а система його проігнорувала, розібрано нижче (fsyncgate).

З off COMMIT не чекає на fdatasync, а walwriter скидає журнал не пізніше ніж за три wal_writer_delay (типово 200 мс). Якщо сервер упаде в цьому вікні, закомічені транзакції зникнуть, але база лишиться консистентною, просто трохи давнішою. Параметр можна змінити для одного сеансу чи транзакції (SET LOCAL) і лишити on для платежів.

Вимір pgbench (-s 10, Docker Desktop, 15 с на запуск, по три запуски):

Клієнтів on, транзакцій/с off, транзакцій/с
1 544–1344 2419–2610
8 4001–5617 13 072–13 649

Розкид у on великий, бо швидкість fdatasync на віртуальному диску плаває. Що втрачається, перевірено так: pgbench 5 с, одразу pkill -9 postgres, перезапуск, підрахунок рядків у pgbench_history.

Режим Підтверджено клієнтам Після збою Втрачено
off 74 916 / 76 857 / 70 874 74 042 / 76 643 / 70 532 874 / 214 / 342
on 26 909 / 28 173 / 26 619 ті самі 0

Після збою суми abalance, tbalance і bbalance збіглися: база консистентна, але клієнт отримав підтвердження транзакцій, яких більше немає. Для журналу подій це прийнятно, для платежів ні.

Від помилки людини чи втрати диска рятує копія. Найпростіша — логічна: pg_dump читає базу звичайними запитами й пише SQL-скрипт або архів, з якого pg_restore відтворює таблиці, дані й індекси.

Щоб копія була консистентною під час роботи, він відкриває одну транзакцію на знімку: сервер-лог показує SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY. Усі таблиці читаються з одного знімка (модуль 10), тож замовлення не потраплять у дамп без позицій. Формат -Fc стиснутий: shop розміру small (33 МБ у базі) дав 4 326 160 байтів. Формат -Fd дозволяє паралельні -j; ролі лежать поза дампом (pg_dumpall --globals-only).

Логічний бекап відновлює стан на початок дампа, а не на довільну мить, і перебудовує всі індекси, тож на великій базі триває годинами. Зате його можна завантажити в іншу мажорну версію й витягти одну таблицю. Новіший pg_dump читає старий сервер, а старіший відмовляється працювати з новішим (це знадобиться в розборі).

Фізичний бекап і архів WAL

Section titled “Фізичний бекап і архів WAL”

Фізичний бекап копіює файли каталогу даних. База під час копіювання працює, тож файли відповідають різним моментам, і виглядає це як стан після збою. Тому pg_basebackup забирає разом із файлами журнал від початку копіювання, а backup_label вказує, з якої точки redo відновить узгодженість. База 56 МБ дала копію 73 МБ (додалися сегменти WAL), pg_verifybackup перевіряє її за backup_manifest.

Одна копія відновлює базу лише на свій момент. Далі потрібне безперервне архівування WAL: кожен завершений сегмент копіюється в сховище, яке живе довше за сервер.

archive_mode = on
archive_command = 'test ! -f /archive/%f && cp %p /archive/%f'
archive_timeout = 10

%p замінюється адресою сегмента, %f іменем. Команда мусить повертати нуль лише тоді, коли файл справді збережено: після нуля сервер вважає сегмент архівованим і може його переробити. Якщо команда падає, сегменти накопичуються в pg_wal і заповнюють диск, тож за цим стежать через pg_stat_archiver. З PostgreSQL 15 замість команди можна підключити бібліотеку (archive_library).

Сегмент архівується, коли заповниться, а на тихій базі це години, і недописаний хвіст загине разом із сервером. Тому є archive_timeout, який примусово закриває сегмент (у лабораторній 10 с). Безперервну передачу журналу розбирає модуль 12.

Відновлення на момент часу

Section titled “Відновлення на момент часу”

Відновлення на момент часу (PITR) розгортає базовий бекап у новому каталозі й проганяє над ним архівований журнал до мети. Потрібні файл recovery.signal у каталозі даних і параметри в postgresql.conf:

restore_command = 'cp /archive/%f %p'
recovery_target_time = '2026-09-30 22:32:02.891236+00'
recovery_target_inclusive = off
recovery_target_action = promote

restore_command дістає сегмент з архіву. Мета одна з чотирьох: recovery_target_time, _lsn, _xid або _name (мітка від pg_create_restore_point()). Мета мусить лежати після завершення базового бекапу.

Базовий бекап і архів WAL: відновлення зупиняється перед комітом DROP TABLE і відкриває timeline 2архів WAL (restore_command)часбазовий бекаппочаток 0/5000028…05…06…07COMMIT DROP TABLE0/070197B0, 22:32:02.891recovery_target_timerecovery_target_inclusive = offredo з архівуtimeline 2: нова історія після відновленняtimeline 1: після DROP
Базовий бекап і сегменти 05–07 архіву з прогону L6. Відновлення читає їх по черзі й зупиняється перед комітом DROP TABLE, а нові зміни йдуть на timeline 2.

Лог відновленого сервера з лабораторної:

LOG: starting point-in-time recovery to 2026-09-30 22:32:02.891236+00
LOG: recovery stopping before commit of transaction 866, time 2026-09-30 22:32:02.891236+00
LOG: selected new timeline ID: 2
LOG: archive recovery complete

Помиляються здебільшого в трьох параметрах. recovery_target_inclusive типово on: транзакція, закомічена рівно в мить мети, застосовується. Якщо взяти час коміту DROP TABLE з pg_waldump, то з on команда виконається, а з off зупинка настане перед нею.

recovery_target_action типово pause: сервер дійде до мети й лишиться у відновленні, доступним для читання, доки не викличуть pg_wal_replay_resume() або не зададуть promote. Мета за межами архіву закінчується помилкою recovery ended before configured recovery target was reached. Те саме станеться, якщо мета дорівнює часу останнього коміту в архіві, бо після нього не було запису, який показав би, що мету перейдено. З PostgreSQL 13 це помилка; раніше сервер мовчки завершував відновлення на кінці архіву.

Після відновлення сервер відкриває нову timeline: гілку історії журналу з наступним номером. Номер входить в імʼя сегментів (00000002…), тож нові записи не затирають старих сегментів timeline 1.

Інкрементальні бекапи PostgreSQL 17

Section titled “Інкрементальні бекапи PostgreSQL 17”

Повна копія щоразу переписує те, що не змінилось. У PostgreSQL 17 з’явилися інкрементальні бекапи (release notes 17): pg_basebackup --incremental=<backup_manifest попередньої копії> забирає лише змінені блоки, а pg_combinebackup збирає з повної й інкрементальних копій повноцінний каталог. Сервер має вести підсумки змін журналу: summarize_wal = on.

На нашій базі повна копія без WAL важила 57 МБ. Після оновлення близько двадцяти замовлень і п’ятдесяти рядків inventory інкрементальна зайняла 11 МБ, а найбільший її файл, inventory, 40 КіБ. pg_combinebackup зібрав з двох копій каталог розміром 57 МБ. Відновити інкрементальну копію без усього ланцюжка не можна.

RPO, RTO і бекап, який ніхто не відновлював

Section titled “RPO, RTO і бекап, який ніхто не відновлював”

RPO (recovery point objective) визначає, скільки даних можна втратити, виміряно часом, RTO (recovery time objective) — скільки сервіс може бути недоступним. Економіку вибору цих цифр розібрано в модулі 13 курсу хмар, а що дають керовані бази з PITR — у модулі 7. Для PostgreSQL:

Схема RPO RTO
нічний pg_dump до доби завантаження дампа й побудова індексів
базова копія і архів WAL archive_timeout, секунди копія плюс повтор журналу
те саме і синхронна репліка близько нуля для збою сервера секунди, але DROP TABLE реплікується теж

RTO PITR складається з копіювання бази й повтору журналу. У L6 відновлення з копії 73 МБ і трьох сегментів займало 8–10 с; 172 МБ журналу після kill -9 повторилися за 0,29 с на гарячому кеші. Для бази в терабайти числа інші, тому їх вимірюють, а не оцінюють.

Копія існує, лише якщо її відновлювали. Перевірка — повний цикл: розгорнути копію на чистій машині, прогнати журнал до мети, виконати контрольні запити (кількість замовлень, найсвіжіший запис) і записати, скільки це тривало. pg_verifybackup цього не замінює: він бачить цілі файли, а не те, що сервер на них запуститься.

Журнал для PostgreSQL один потік байтів, а LSN друкують як два шістнадцяткові числа: 0/AE013218. У заголовку кожної сторінки записано LSN її останньої зміни. Різниця двох LSN дорівнює кількості байтів журналу між ними. Потік ріжеться на сегменти по 16 МБ (wal_segment_size) у $PGDATA/pg_wal. Ім’я сегмента містить номер timeline і номер сегмента: 0000000100000000000000AE це timeline 1, сегмент AE. За LSN його дає pg_walfile_name().

Що саме пишеться, показує pg_waldump. Покупка з датасету: замовлення, позиція, зменшення залишку, усе в одному запиті, тобто в одній транзакції.

WITH o AS (
INSERT INTO orders (customer_id, pickup_point_id, status, placed_at, updated_at, total_amount)
VALUES (2, 1556, 'new', now(), now(), 499.00) RETURNING order_id
), i AS (
INSERT INTO order_items (order_id, line_no, product_id, quantity, unit_price)
SELECT order_id, 1, 12, 1, 499.00 FROM o
)
UPDATE inventory SET quantity = quantity - 1, updated_at = now() WHERE product_id = 12;

pg_current_wal_lsn() до й після показав діапазон від 0/AE013218 до 0/AE013540: транзакція зайняла 808 байтів. pg_waldump -s 0/AE013218 -e 0/AE013540 0000000100000000000000AE у контейнері дає такі записи (поле prev прибрано, імена таблиць дописано за relfilenode):

Heap len 85 lsn 0/AE013218 HOT_UPDATE inventory
Heap len 108 lsn 0/AE013270 INSERT orders
Btree len 64 lsn 0/AE0132E0 INSERT_LEAF orders_pkey
Heap len 62 lsn 0/AE013320 INSERT order_items
Btree len 72 lsn 0/AE0133F0 INSERT_LEAF order_items_pkey
Heap len 54 lsn 0/AE013438 LOCK xmax customers (ще три: pickup_points, orders, products)
Transaction len 34 lsn 0/AE013518 COMMIT 2026-09-30 22:42:54.549899 UTC

П’ять перших записів описують зміни сторінок. Записи LOCK належать перевіркам зовнішніх ключів: рядок-ціль блокується в режимі FOR KEY SHARE, блокування живе в xmax і тому змінює сторінку. Порядок записів це порядок виконання, а не порядок у тексті: UPDATE з головної частини пішов першим. Запис COMMIT і є комітом: статус у pg_xact (модуль 10) можна відновити з нього.

Розмір транзакції за цими LSN можна порахувати в пісочниці. Її pg_current_wal_lsn() не росте навіть після INSERT тисячі рядків (PGlite 0.5.8), тому числа з журналу взято з Docker.

СпробуйPostgreSQLCtrl+Enter — виконати

Відновлення після збою під навантаженням

Section titled “Відновлення після збою під навантаженням”

Тут checkpoint_timeout = 30min, щоб від останньої точки накопичилось багато журналу:

Terminal window
docker compose exec postgres pgbench -U postgres -c 8 -j 4 -T 20 bench
docker compose kill -s KILL postgres
docker compose --profile postgres up -d --wait
docker compose logs --no-log-prefix postgres
LOG: database system was not properly shut down; automatic recovery in progress
LOG: redo starts at 0/35A02818
LOG: invalid record length at 0/40625C70: expected at least 24, got 0
LOG: redo done at 0/406247D0 system usage: CPU: user: 0.22 s, system: 0.06 s, elapsed: 0.29 s
LOG: checkpoint starting: end-of-recovery immediate wait
LOG: database system is ready to accept connections

Перед збоєм від точки REDO набралось 172 МБ журналу, повтор зайняв 0,29 с, усі 91 617 транзакцій pgbench_history були на місці.

Джерело: постмортем GitLab, опублікований 10 лютого 2017 року.

Увечері 31 січня навантаження на базу GitLab.com різко зросло. Близько 23:00 UTC реплікація на вторинний сервер відстала й зупинилась: основний сервер прибрав сегменти WAL, яких репліка ще не отримала, а архівування WAL не було. Інженери взялися заново синхронізувати репліку через pg_basebackup, і близько 23:30 UTC видалення каталогу даних виконали на основному сервері замість вторинного: зникло близько 300 ГБ.

Жоден із способів резервування не дав свіжої копії. За постмортемом, pg_dump працював з PostgreSQL 9.2, а база була 9.6, і копій не виходило. Повідомлення про помилки відхиляв одержувач через відсутній підпис DMARC, у кошику S3 нічого не було. Знімки LVM робили раз на добу, знімків дисків Azure для серверів баз не ввімкнули, реплікація зламалась. Врятував знімок LVM, який інженер зробив вручну близько шести годин до аварії для іншої мети. Копіювання зайняло близько 18 годин, бо диски Azure працювали зі швидкістю близько 60 Мбіт/с, і сервіс повернувся 1 лютого близько 18:00 UTC. Втрачено зміни між 17:20 і 00:00 UTC: щонайменше 5000 проєктів, 5000 коментарів і близько 700 акаунтів.

GitLab підсумував, що з п’яти способів резервування й реплікації жоден не працював надійно або не був налаштований. Що з цього стосується модуля:

  • Архіву WAL не було. З ним аварія звелася б до PITR на мить перед помилкою: RPO у секунди, а не шість годин.
  • Єдину копію, що спрацювала, зробили випадково, а про збої pg_dump ніхто не дізнався.
  • Час відновлення визначила пропускна здатність диска: RTO виміряли під час аварії, а не до неї.

28 березня 2018 року Крейг Рінгер (Craig Ringer) написав у pgsql-hackers, що поведінка PostgreSQL при помилці fsync() небезпечна й за певних умов втрачає дані. Механіка така. Коли фонове записування брудних сторінок ядром закінчується помилкою вводу-виводу, Linux позначав ці сторінки чистими, а про помилку повідомляв лише одному виклику. Контрольна точка PostgreSQL, побачивши помилку, залишала роботу на наступну спробу. Повторний fsync() на тому самому файлі вдавався, бо помилку вже «спожито», контрольну точку вважали завершеною, а старий журнал переробляли. Зміни, яких на диску ніколи не було, зникали без сліду.

PostgreSQL змінив поведінку так, що після помилки fsync() сервер падає з PANIC і відновлюється з журналу, який ще містить ці зміни. Зміна потрапила в реліз 11.2 від 14 лютого 2019 року разом з параметром data_sync_retry (типово off): його вмикають, лише якщо певні, що ядро не скидає брудні буфери. За вікі PostgreSQL, схожий захист додали InnoDB і WiredTiger, а Linux змінив облік помилок запису у версіях 4.13–4.16.

Гарантія «скинуто на диск» залежить від усіх шарів під базою, зокрема від того, як ядро повідомляє про помилки.

Типові помилки розуміння

Section titled “Типові помилки розуміння”

«Реплікація це бекап». Репліка повторює той самий журнал, зокрема DROP TABLE і DELETE без WHERE. Від помилки людини захищає лише копія з архівом WAL.

«COMMIT повернув успіх, отже сторінки на диску». На диск дійшов журнал. Сторінки даних записуються пізніше контрольною точкою, а після збою відновлюються з журналу.

«Контрольна точка потрібна для довговічності». Довговічність дає fdatasync журналу при коміті. Контрольна точка лише обмежує довжину відновлення й дає змогу переробляти сегменти.

«З synchronous_commit = off база може зіпсуватись». Зіпсувати її може fsync = off. off забирає хвіст журналу, але база лишається консистентною.

«PITR відкотить мою базу». Відновлення створює новий сервер із копії й архіву на новій timeline. Стару базу воно не чіпає.

Перевір себе

1. Сервер убито `SIGKILL` через п’ять хвилин після останньої контрольної точки. Що робить PostgreSQL під час старту?
2. Чому одна й та сама покупка після `CHECKPOINT` займає в журналі 71 728 байтів, а наступна 808?
3. Застосунок пише журнал подій із `synchronous_commit = off`, сервер падає. Що правда про базу після перезапуску?
4. Ви відновлюєте базу на мить перед `DROP TABLE`, узявши з `pg_waldump` час його коміту, і лишили `recovery_target_inclusive` типовим. Що отримаєте?
5. Відновлений сервер дійшов до `recovery_target_time`, але `pg_is_in_recovery()` дає `t` і записувати не можна. Чому?

L6. Відновлення на момент часу: у «Крамниці» налаштовано бекап і архів WAL, хтось видаляє order_items. Треба знайти мить аварії, налаштувати restore_command і мету відновлення й підняти сервер на новій timeline, не втративши жодного замовлення до аварії.