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

Реплікація

Покупець натискає «Оформити», браузер переходить на сторінку «Мої замовлення», і замовлення там немає. Запис пішов на мастер, а читання, щоб розвантажити його, на репліку, яка відставала на півсекунди. У лабораторній L7 такий клієнт під штучним лагом 500 мс не знайшов щойно створеного замовлення в 10 випадках із 10. Помилок немає: це звичайна асинхронна реплікація.

Буває гірше: мастер падає, репліку підвищують, і 10 із 30 замовлень, які покупцям уже підтвердили, зникають. Репліка не встигла їх отримати, а мастер, де вони лежали, більше не підніметься. А 21 жовтня 2018 року розрив мережі між датацентрами GitHub тривав 43 секунди, а деградація сервісу понад добу (розбір нижче).

Передумови. WAL і LSN: модуль 11. Знімки й ізоляція: модуль 10. Затримка мережі: модуль 2 курсу мереж; tc netem, яким ми її імітуємо: модуль 12 курсу мереж.

Копії бази тримають із трьох причин. Читання: звіти й каталог ідуть на репліки, а мастер лишається для запису. Відмовостійкість: коли мастер помирає, є кому його замінити. Географія: копія в іншому місті швидше відповідає тамтешнім користувачам і переживає втрату датацентру.

Копія не бекап: DROP TABLE докотиться до репліки так само слухняно, як усе інше (модуль 11 про PITR).

Модуль про схему з одним мастером (у документації PostgreSQL primary): запис приймає лише він, а репліки застосовують його зміни в тому ж порядку. Схему без мастера розібрано наприкінці.

Потокова реплікація PostgreSQL

Section titled “Потокова реплікація PostgreSQL”

Мастер і так пише кожну зміну у WAL (модуль 11). Потокова реплікація (streaming replication) віддає цей потік репліці. Процес walsender на мастері надсилає байти журналу в міру їх появи, walreceiver на репліці записує їх у власні сегменти, а процес відновлення застосовує записи до сторінок, як після збою. У режимі hot_standby репліка ще й відповідає на запити лише для читання.

WAL іде від мастера до репліки через чотири етапи: відправлено, записано, збережено, застосовано. Значення synchronous_commit задає, до якого етапу чекає COMMITмастерреплікамережа (тут tc netem)відправленоwalsender → мережаsent_lsnзаписаноwalreceiver, write()write_lsnзбереженоfsync на репліціflush_lsnзастосованоredo, видно запитамreplay_lsnremote_write: чекає до «записано»on: чекає до «збережено»remote_apply: чекає до «застосовано»
Чотири позиції в журналі, які показує pg_stat_replication, і те, до якої з них чекає COMMIT. Між «відправлено» і «записано» лежить мережа: саме тут у лабораторній стоїть tc netem.

Така реплікація фізична: репліка збігається з мастером байт у байт. Тому мажорна версія й архітектура мають збігатися, а копіюється весь кластер. Натомість копія точна, включно з ctid і версіями рядків.

Синхронна й асинхронна

Section titled “Синхронна й асинхронна”

Типово реплікація асинхронна: мастер підтверджує COMMIT, коли запис про коміт на його власному диску, і репліку не питає. Швидко, але те, що репліка не встигла отримати, зникає разом із загиблим мастером. Межа втрат дорівнює лагу в мить аварії.

Синхронна реплікація змушує COMMIT чекати репліку. Які репліки синхронні, задає synchronous_standby_names на мастері, а що вони мають підтвердити, synchronous_commit:

synchronous_commit COMMIT повертається, коли Гарантія
local запис збережено на диску мастера репліка не впливає
remote_write репліка передала запис своїй ОС переживе падіння PostgreSQL на репліці, але не її ОС
on (типове) репліка зберегла запис на диск (fsync) переживе й падіння ОС на репліці
remote_apply репліка застосувала запис читання на репліці його вже бачить

Останній режим лікує аномалії з наступного розділу для підтверджених транзакцій: лаг для них нульовий. Тепер ціна. PostgreSQL 18.6, два контейнери, pgbench -N з -s 10, 15 секунд на запуск. Затримку tc netem додано на шлях підтверджень від репліки до мастера, тож вона одностороння. Значення local дорівнює асинхронній реплікації. Числа це транзакції за секунду:

Затримка Клієнтів local remote_write on remote_apply
0 мс 1 1665 1487 1085 1102
0 мс 16 13880 12172 9290 8753
5 мс 1 1644 158 155 153
5 мс 16 13551 2547 2496 2412
20 мс 1 1794 47 46 46
20 мс 16 14231 750 737 722

Без затримки режими відрізняються fsync-ом на репліці: on повільніший за remote_write на чверть. З мережею різниця між трьома синхронними режимами губиться. Кожен COMMIT чекає затримку, тож один клієнт не зробить більше 1 / затримка комітів за секунду (20 мс дають 47). Шістнадцять клієнтів чекають паралельно і дають у 16 разів більше, але до local не дотягують. Тому між датацентрами синхронну реплікацію вмикають лише для транзакцій, яким вона потрібна (synchronous_commit задають і для сеансу чи транзакції).

Синхронність коштує ще й доступності. Якщо єдина синхронна репліка зникла, COMMIT на мастері висить, доки вона не повернеться або її не приберуть із synchronous_standby_names. Тому задають кілька синхронних вузлів і вимагають підтвердження від частини: ANY 1 (a, b).

Лаг і те, що бачить користувач

Section titled “Лаг і те, що бачить користувач”

Лаг міряють у байтах (sent_lsn - replay_lsn) і в часі (write_lag, flush_lag, replay_lag). Користувачу байти байдужі, а ось аномалії, які лаг вносить у видиме, ні.

Читання власних записів (read-your-writes): після COMMIT користувач має бачити свій запис. Якщо читання потрапило на репліку, яка його ще не застосувала, запис «зникає».

Покупець оформлює замовлення на мастері й за кілька мілісекунд відкриває «Мої замовлення» на репліці, яка ще не отримала WAL: замовлення зниклопокупецьмастерреплікачасPOST: оформити200 OK, LSN 0/5A1WAL доїхав, застосованоGET: мої замовленнязамовлення немаєВиправлення: GET несе LSN із відповіді й чекає, доки pg_last_wal_replay_lsn() на репліціне стане не меншим за 0/5A1, або йде до мастера.
Запис на мастері, читання за кілька мілісекунд на репліці з лагом. Виправлення в зеленій рамці: запам'ятати LSN запису й чекати його на репліці.

Монотонне читання (monotonic reads): хто побачив новіші дані, не має потім побачити старіші. Порушується, коли перше читання потрапило на репліку A, що вже застосувала запис, а друге на репліку B, що відстає.

Способи з цим жити, від простого до точного:

  • Читати своє з мастера. N секунд після запису користувача його читання йдуть на мастер, потім на репліку. N має бути більшим за реальний лаг, а «типовий лаг» під навантаженням зростає.
  • Чекати LSN. Відповідь на запис несе pg_current_wal_lsn(), узятий після коміту. Читання на репліці чекає, доки pg_last_wal_replay_lsn() не стане не меншим за нього. Якщо чекання затяглося, читає з мастера. Токен лежить у cookie, тож сервер стану не тримає. Саме це робить еталон L7.
  • Прив’язка до репліки. Сеанс користувача завжди потрапляє на ту саму репліку (за хешем ідентифікатора). Монотонність є, власні записи це не лікує.

Порівняння LSN — звичайний SQL, і його можна спробувати тут:

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

Результат: 1456576 і false: репліка відстає на стільки байт, тож запису з LSN 0/9832130 на ній ще немає.

Коли мастер падає, одну з реплік треба зробити новим. Це failover: заміна мастера іншим вузлом. У PostgreSQL репліку підвищує pg_promote(): вона виходить із режиму відновлення, відкриває нову timeline (гілку історії журналу) і приймає запис. Вона має лише те, що встигла отримати. При асинхронній реплікації підтверджене мастером, але не доставлене, втрачено. У L7 при лагу 2 с пропали 10 із 30 підтверджених замовлень, рівно останні десять.

Вручну перемикає людина, що вирішила: мастер мертвий. Автоматично перемикає система на кшталт Patroni. На кожному вузлі працює агент, і всі домовляються через розподілене сховище (DCS: etcd, Consul, ZooKeeper або Kubernetes API). Мастер той, хто тримає ключ мастера (leader key) з обмеженим часом життя. Якщо агент не оновив його за ttl (типово 30 с), ключ зникає, і репліки змагаються за нього.

maximum_lag_on_failover відсіює надто відсталих претендентів, synchronous_mode вмикає синхронну реплікацію, а use_pg_rewind (типово вимкнено) повертає старого мастера.

Split brain: старий мастер живий, але недосяжний для системи перемикання; репліку підвищено, і два вузли приймають запис у різні лінії часуклієнти 1клієнти 2Aстарий мастерBпідвищена реплікарозривOrchestrator чи Patroni не бачать Atimeline 1: …, 101, 102timeline 2: …, 101, 103історії розійшлися після 101фенсинг: зупинити A ДО підвищення Bповернення A: pg_rewind відкидає 102
Старий мастер живий, але система перемикання його не бачить. Обидва вузли приймають запис у різні історії. Зелені рамки показують, що цьому запобігає.

Якщо репліку підвищено, а старий мастер працює, мастерів два. Це split brain: клієнти, що потрапили на різні вузли, пишуть у різні історії, і злити їх без втрат не можна. Причина не в помилці адміністратора, а в хибному висновку «мастер мертвий»: розрив мережі, довга пауза процесу, перевантаження. У нашому Docker після pg_promote() на живій парі обидва вузли відповіли pg_is_in_recovery() = f і прийняли по замовленню.

Запобігає цьому фенсинг (fencing): гарантія, що старий мастер більше не пише, і діє вона ДО підвищення нового. Для цього вимикають його живлення (STONITH), відбирають мережу чи сховище. У L7 наївне перемикання провалюється саме з цієї причини.

Повернення старого мастера

Section titled “Повернення старого мастера”

Старий мастер має транзакції, яких немає в новій історії, тож потік WAL нового мастера він не прийме. Заново знімати pg_basebackup на терабайтній базі довго, тому є pg_rewind. Він знаходить точку, де історії розійшлися, і повертає блоки старого мастера до неї, беручи з нового лише змінені.

Вимоги: full_page_writes = on і або wal_log_hints = on, або контрольні суми даних, задані під час initdb. У PostgreSQL 18 контрольні суми ввімкнено типово (release notes), тож на свіжому кластері pg_rewind працює. Без них і без wal_log_hints не працює взагалі, і з’ясувати це після аварії пізно. Розбіжні транзакції pg_rewind відкидає: це ті самі 10 замовлень.

Мастер може прибрати сегмент WAL, який репліка ще не отримала (так GitLab у модулі 11 втратив реплікацію), і її доведеться будувати заново. Слот реплікації (replication slot) запам’ятовує позицію споживача й забороняє видаляти журнал далі.

Зворотний бік: слот, споживач якого помер, тримає WAL, поки не заповниться диск самого мастера. А якщо на репліці ввімкнено hot_standby_feedback, слот тримає ще й xmin, і VACUUM не чистить версії рядків, потрібні запитам репліки. Так само давня транзакція з модуля 10 зупиняла очищення, тільки тепер вона в чужому сеансі.

Межу ставить max_slot_wal_keep_size (типово без межі). Коли слот її перевищує, він стає lost, і репліку будують заново через pg_basebackup. У PostgreSQL 18 додано idle_replication_slot_timeout, що скасовує слот, який довго простоює. За pg_replication_slots стежать як за вільним місцем: непомітно слот ніхто не прибере.

Якщо VACUUM на мастері прибрав версії рядків, потрібні довгому запиту на репліці, застосування цього запису входить у конфлікт із відновленням (recovery conflict). Репліка чекає до max_standby_streaming_delay (типово 30 с), потім скасовує запит: canceling statement due to conflict with recovery. Вибирати доводиться між запитом і лагом.

hot_standby_feedback = on відсуває цю проблему на мастер: репліка повідомляє найстарший свій знімок, і VACUUM чекає. Ціна: мертві версії на мастері живуть, поки довгий запит триває на репліці. Конфліктів через блокування таблиць це не прибирає.

Логічна реплікація передає не байти сторінок, а зміни рядків за моделлю «публікація — підписка» (вимагає wal_level = logical). Підписник є повноцінною базою: може мати іншу мажорну версію, інші індекси й власні таблиці. Публікація може містити лише частину таблиць чи рядків. Тому її вибирають для оновлення мажорної версії майже без простою, збору даних із кількох баз в одну й віддачі частини даних.

Реплікується лише вміст таблиць. DDL не передається: ALTER TABLE на видавцеві треба повторити на підписнику вручну, інакше потік зупиниться. Послідовності не реплікуються: значення в таблиці переїжджає, а лічильник на підписнику лишається початковим. Великі об’єкти теж не реплікуються, а передавати можна лише таблиці, не представлення.

Реплікація без мастера

Section titled “Реплікація без мастера”

У системах Dynamo-стилю (детально в модулях 13 і 17) мастера немає. Запис іде на всі N копій і вважається підтвердженим, коли відповіли W із них. Читання опитує R. Якщо R + W > N, множини вузлів запису й читання перетинаються, і читання бачить принаймні одну копію останнього підтвердженого запису.

Відставші копії підтягують read repair (читач записує свіже значення назад на вузли, що відстали) і hinted handoff (вузол, що прийняв запис для недосяжного сусіда, зберігає підказку й віддає її, коли той повернеться). Кворум не скасовує суперечок при розриві мережі: що обирати тоді, розглядає CAP, детально в модулі 13.

Середовище те саме, що в L7: мастер, репліка й «застосунок» у labs/l07-replication/compose.yml, датасет small (Датасет). Від профілю replica кореневого файлу воно відрізняється двома речами: утиліта tc і NET_ADMIN є на обох вузлах (затримка потрібна на виході мастера), а «застосунок» живе в третьому контейнері, щоб його запити не стояли в тій самій черзі, що й WAL.

Terminal window
docker compose -f labs/l07-replication/compose.yml up -d --build --wait
docker compose -f labs/l07-replication/compose.yml exec postgres psql -U shop -d shop

Стан реплікації на мастері. Знімок знято під pgbench із затримкою 200 мс на шляху до репліки й recovery_min_apply_delay = 1s на репліці, щоб усі чотири позиції розійшлися:

SELECT sent_lsn, write_lsn, flush_lsn, replay_lsn, write_lag, flush_lag, replay_lag, sync_state
FROM pg_stat_replication;
sent_lsn | write_lsn | flush_lsn | replay_lsn | write_lag | flush_lag | replay_lag | sync_state
-----------+-----------+-----------+------------+-----------------+-----------------+-----------------+-----------
0/9832130 | 0/97EEF58 | 0/97EEF58 | 0/96CE770 | 00:00:00.200469 | 00:00:00.200469 | 00:00:00.999775 | async

Мережа дає 200 мс до запису, застосування відстає ще на секунду. Різниця sent_lsn - replay_lsn дорівнює 1 456 576 байт, стільки ж у пісочниці вище. У пісочниці реплік немає, тож pg_stat_replication там порожня. Затримку ставить скрипт лабораторної, який друкує команди tc:

Terminal window
./labs/l07-replication/netem.sh set postgres replica 200
./labs/l07-replication/netem.sh clear postgres

Покинутий слот. Репліку зупинено, pgbench -N на мастері (40 с, близько 9000 транзакцій за секунду) залишив у слоті 97 МБ WAL. Далі задано max_slot_wal_keep_size = '64MB' і записано таблицю з восьми мільйонів рядків. WAL тримає ще й wal_keep_size = 512MB, тому межа спрацювала лише після майже гігабайта:

SELECT slot_name, active, wal_status, invalidation_reason,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots;
slot_name | active | wal_status | invalidation_reason | retained
-----------+--------+------------+---------------------+----------
replica1 | f | unreserved | | 980 MB
replica1 | f | lost | wal_removed |

Другий рядок знято після CHECKPOINT. Репліка, яку запустили знову, пише: FATAL: could not start WAL streaming: ERROR: can no longer access replication slot "replica1", і її будують з нуля. Алерт ставлять на unreserved, а не на lost.

Синхронна репліка зупинена. На мастері synchronous_standby_names = 'walreceiver', репліку вимкнено, і звичайний INSERT висить; у pg_stat_activity він має wait_event_type = IPC і wait_event = SyncRep. Коли репліка повернулась, запит завершився сам.

Конфлікт на репліці. Запит на репліці під REPEATABLE READ читає таблицю й спить, а мастер видаляє всі 100 000 її рядків і виконує VACUUM. З max_standby_streaming_delay = '2s' репліка відповідає:

ERROR: canceling statement due to conflict with recovery
DETAIL: User query might have needed to see row versions that must be removed.

pg_stat_database_conflicts.confl_snapshot на репліці дорівнює 1. З hot_standby_feedback = on той самий VACUUM на мастері звітує 0 removed, 100000 remain, 100000 are dead but not yet removable, і запит очищення переживає. У тому запуску його все одно скасовано з іншою причиною: User was holding a relation lock for too long. Після видалення всіх рядків VACUUM обрізав порожні сторінки в кінці файлу, а для цього бере ACCESS EXCLUSIVE. Це блокування потрапляє в журнал і на репліці конфліктує із запитом, що читає таблицю; hot_standby_feedback тут не допомагає, допомагає параметр таблиці vacuum_truncate = off.

Логічна реплікація. Підписник — другий контейнер subscriber (профіль logical) зі схемою датасету; на мастері wal_level = logical:

CREATE PUBLICATION pub_delivered FOR TABLE orders WHERE (status = 'delivered');
-- на підписнику:
CREATE SUBSCRIPTION sub_delivered
CONNECTION 'host=postgres dbname=shop user=postgres password=postgres'
PUBLICATION pub_delivered;

Початкове копіювання перенесло 18 841 із 22 031 замовлення small: лише доставлені. Нове замовлення зі статусом delivered на підписнику з’явилося, зі статусом new ні. Послідовність orders_order_id_seq на мастері дійшла до 22 033, а на підписнику лишилась last_value = 1, is_called = false. Команда ALTER TABLE orders ADD COLUMN gift_wrap boolean, виконана лише на мастері, зупинила потік:

ERROR: logical replication target relation "public.orders" is missing replicated column: "gift_wrap"

Після такого самого ALTER TABLE на підписнику реплікація пішла далі. Якби підписник став мастером, лічильник довелося б виставляти через setval.

Перемикання з втратою. Скрипт incident.sh з L7 вмикає лаг 2 с, оформлює 20 замовлень, чекає, оформлює ще 10 і вбиває мастера (docker kill). Після pg_promote() на репліці є 20 замовлень: клієнтам підтверджено 30, втрачено 10. Нумерація на новому мастері не суцільна, бо послідовності журналюються наперед, тож дірки в номерах про втрату не свідчать.

Репліка отримує байти WAL, позицією слугує LSN, а після підвищення з’являється новий timeline. Логічну реплікацію вмикають окремо.

Розбір ґрунтується на постмортемі GitHub від 30 жовтня 2018 року (джерела). Усі часи UTC.

21 жовтня о 22:52 заміна оптичного обладнання на 100 Гбіт/с розірвала зв’язок між мережевим вузлом на східному узбережжі США й основним датацентром. Зв’язок повернувся за 43 секунди, і цього вистачило. Orchestrator (інструмент керування топологією MySQL, вузли якого домовляються за Raft), що працював в основному датацентрі, був відсунутий: вузли в датацентрі на західному узбережжі та в публічній хмарі на сході склали кворум і почали перемикати кластери. Мастерами стали сервери на заході, і застосунки писали туди.

Східні сервери мали короткий проміжок записів, які не встигли реплікуватися на захід, а західні майже 40 хвилин приймали записи застосунків. Один із найзавантаженіших кластерів мав 954 записи у вікні розбіжності. Записи були в обох датацентрах і в жодному не було всіх, тож повернути мастерів на схід безпечно було не можна. GitHub обрав цілісність замість доступності: призупинив вебхуки й збирання Pages, а бази відновлював із резервних копій, які робили раз на чотири години. Кластери сягали майже п’яти терабайтів, дані довго йшли з віддаленого сховища, а репліки наздоганяли мастерів за степеневим спадом, а не лінійно. О 11:12 наступного дня мастери повернулися на схід, о 16:24 репліки наздогнали, а о 23:03 розібрали черги: понад п’ять мільйонів подій вебхуків і 80 тисяч збирань Pages (близько 200 тисяч навантажень вебхуків протермінувалися). Деградація тривала 24 години 11 хвилин. За постмортемом, дані користувачів не втрачено, кілька секунд записів звіряли вручну.

Автоматика не зламалась, а виконала правила: кворум є, мастер зник, перемикаємо. Але мастер не зник: основний датацентр працював, а розрив тривав 43 секунди. Записи приймали обидва боки, тож це split brain, розтягнутий у часі, і історії довелося зводити відновленням із копій, а це години. Автоматичне перемикання між регіонами варто вмикати лише після відповіді, скільки записів ви готові втратити за кожну секунду розриву.

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

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

«Репліка — це бекап». Вона застосовує кожну зміну мастера, включно з DROP TABLE і помилковим UPDATE. Від помилки людини захищає PITR із модуля 11.

«Синхронна реплікація не втрачає даних». remote_write не переживе падіння ОС на репліці, а синхронна репліка, вилучена з synchronous_standby_names, мовчки робить реплікацію асинхронною.

«Лаг — це лише метрика». Це й дефект логіки: читання власних записів і монотонне читання порушуються без жодної помилки в журналах, а покупець не бачить свого замовлення.

«Мастер не відповідає, отже, він мертвий». Тоді split brain неминучий. Спершу фенсинг, потім pg_promote().

«Слот реплікації — безкоштовна страховка». Слот без споживача тримає WAL, і без max_slot_wal_keep_size та алерта диск мастера заповниться.

Перевір себе

1. Застосунок пише замовлення на мастера, а «Мої замовлення» читає з репліки. При лагу 300 мс покупець не бачить щойно оформленого замовлення. Що виправляє це, не відмовляючись від репліки?
2. Мастер працює з `synchronous_commit = remote_write` і одною синхронною реплікою. Який збій після підтвердження `COMMIT` не втрачає транзакцію?
3. Синхронна реплікація з однією синхронною реплікою. Репліку вимкнули. Що станеться з `INSERT` на мастері?
4. Автоматика визнала мастера мертвим і підвищила репліку, але мастер лише втратив мережу й далі приймає запис від частини клієнтів. Яка головна небезпека і що її запобігає?
5. Після аварії старий мастер має 10 закомічених замовлень, яких немає в новій timeline. Як повернути його репліку без повного копіювання?
6. Кластер Dynamo-стилю: N = 3, запис підтверджують W = 2 вузли, читання опитує R = 2. Що це дає?

L7. Реплікація і перемикання: створити лаг tc netem, написати клієнта з читанням власних записів, «вбити» мастера, підвищити репліку, з’ясувати втрачені замовлення й повернути старого мастера через pg_rewind.