Реплікація
Навіщо це
Section titled “Навіщо це”Покупець натискає «Оформити», браузер переходить на сторінку «Мої замовлення», і замовлення там немає. Запис пішов на мастер, а читання, щоб розвантажити його, на репліку, яка відставала на півсекунди. У лабораторній L7 такий клієнт під штучним лагом 500 мс не знайшов щойно створеного замовлення в 10 випадках із 10. Помилок немає: це звичайна асинхронна реплікація.
Буває гірше: мастер падає, репліку підвищують, і 10 із 30 замовлень, які покупцям уже підтвердили, зникають. Репліка не встигла їх отримати, а мастер, де вони лежали, більше не підніметься. А 21 жовтня 2018 року розрив мережі між датацентрами GitHub тривав 43 секунди, а деградація сервісу понад добу (розбір нижче).
Передумови. WAL і LSN: модуль 11. Знімки й ізоляція: модуль 10. Затримка мережі: модуль 2 курсу мереж; tc netem, яким ми її імітуємо: модуль 12 курсу мереж.
Навіщо копії
Section titled “Навіщо копії”Копії бази тримають із трьох причин. Читання: звіти й каталог ідуть на репліки, а мастер лишається для запису. Відмовостійкість: коли мастер помирає, є кому його замінити. Географія: копія в іншому місті швидше відповідає тамтешнім користувачам і переживає втрату датацентру.
Копія не бекап: DROP TABLE докотиться до репліки так само слухняно, як усе інше (модуль 11 про PITR).
Модуль про схему з одним мастером (у документації PostgreSQL primary): запис приймає лише він, а репліки застосовують його зміни в тому ж порядку. Схему без мастера розібрано наприкінці.
Потокова реплікація PostgreSQL
Section titled “Потокова реплікація PostgreSQL”Мастер і так пише кожну зміну у WAL (модуль 11). Потокова реплікація (streaming replication) віддає цей потік репліці. Процес walsender на мастері надсилає байти журналу в міру їх появи, walreceiver на репліці записує їх у власні сегменти, а процес відновлення застосовує записи до сторінок, як після збою. У режимі hot_standby репліка ще й відповідає на запити лише для читання.
Така реплікація фізична: репліка збігається з мастером байт у байт. Тому мажорна версія й архітектура мають збігатися, а копіюється весь кластер. Натомість копія точна, включно з 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 користувач має бачити свій запис. Якщо читання потрапило на репліку, яка його ще не застосувала, запис «зникає».
Монотонне читання (monotonic reads): хто побачив новіші дані, не має потім побачити старіші. Порушується, коли перше читання потрапило на репліку A, що вже застосувала запис, а друге на репліку B, що відстає.
Способи з цим жити, від простого до точного:
- Читати своє з мастера. N секунд після запису користувача його читання йдуть на мастер, потім на репліку. N має бути більшим за реальний лаг, а «типовий лаг» під навантаженням зростає.
- Чекати LSN. Відповідь на запис несе
pg_current_wal_lsn(), узятий після коміту. Читання на репліці чекає, докиpg_last_wal_replay_lsn()не стане не меншим за нього. Якщо чекання затяглося, читає з мастера. Токен лежить у cookie, тож сервер стану не тримає. Саме це робить еталон L7. - Прив’язка до репліки. Сеанс користувача завжди потрапляє на ту саму репліку (за хешем ідентифікатора). Монотонність є, власні записи це не лікує.
Порівняння LSN — звичайний SQL, і його можна спробувати тут:
Результат: 1456576 і false: репліка відстає на стільки байт, тож запису з LSN 0/9832130 на ній ще немає.
Перемикання
Section titled “Перемикання”Коли мастер падає, одну з реплік треба зробити новим. Це 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 і фенсинг
Section titled “Split brain і фенсинг”Якщо репліку підвищено, а старий мастер працює, мастерів два. Це 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 замовлень.
Слот реплікації
Section titled “Слот реплікації”Мастер може прибрати сегмент 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 стежать як за вільним місцем: непомітно слот ніхто не прибере.
Конфлікти на репліці
Section titled “Конфлікти на репліці”Якщо VACUUM на мастері прибрав версії рядків, потрібні довгому запиту на репліці, застосування цього запису входить у конфлікт із відновленням (recovery conflict). Репліка чекає до max_standby_streaming_delay (типово 30 с), потім скасовує запит: canceling statement due to conflict with recovery. Вибирати доводиться між запитом і лагом.
hot_standby_feedback = on відсуває цю проблему на мастер: репліка повідомляє найстарший свій знімок, і VACUUM чекає. Ціна: мертві версії на мастері живуть, поки довгий запит триває на репліці. Конфліктів через блокування таблиць це не прибирає.
Логічна реплікація
Section titled “Логічна реплікація”Логічна реплікація передає не байти сторінок, а зміни рядків за моделлю «публікація — підписка» (вимагає wal_level = logical). Підписник є повноцінною базою: може мати іншу мажорну версію, інші індекси й власні таблиці. Публікація може містити лише частину таблиць чи рядків. Тому її вибирають для оновлення мажорної версії майже без простою, збору даних із кількох баз в одну й віддачі частини даних.
Реплікується лише вміст таблиць. DDL не передається: ALTER TABLE на видавцеві треба повторити на підписнику вручну, інакше потік зупиниться. Послідовності не реплікуються: значення в таблиці переїжджає, а лічильник на підписнику лишається початковим. Великі об’єкти теж не реплікуються, а передавати можна лише таблиці, не представлення.
Реплікація без мастера
Section titled “Реплікація без мастера”У системах Dynamo-стилю (детально в модулях 13 і 17) мастера немає. Запис іде на всі N копій і вважається підтвердженим, коли відповіли W із них. Читання опитує R. Якщо R + W > N, множини вузлів запису й читання перетинаються, і читання бачить принаймні одну копію останнього підтвердженого запису.
Відставші копії підтягують read repair (читач записує свіже значення назад на вузли, що відстали) і hinted handoff (вузол, що прийняв запис для недосяжного сусіда, зберігає підказку й віддає її, коли той повернеться). Кворум не скасовує суперечок при розриві мережі: що обирати тоді, розглядає CAP, детально в модулі 13.
Як це насправді
Section titled “Як це насправді”Середовище те саме, що в L7: мастер, репліка й «застосунок» у labs/l07-replication/compose.yml, датасет small (Датасет). Від профілю replica кореневого файлу воно відрізняється двома речами: утиліта tc і NET_ADMIN є на обох вузлах (затримка потрібна на виході мастера), а «застосунок» живе в третьому контейнері, щоб його запити не стояли в тій самій черзі, що й WAL.
docker compose -f labs/l07-replication/compose.yml up -d --build --waitdocker 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_stateFROM 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:
./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 retainedFROM 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 recoveryDETAIL: 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. Нумерація на новому мастері не суцільна, бо послідовності журналюються наперед, тож дірки в номерах про втрату не свідчать.
Реплікація в MySQL
Section titled “Реплікація в MySQL”Репліка отримує байти WAL, позицією слугує LSN, а після підвищення з’являється новий timeline. Логічну реплікацію вмикають окремо.
Реплікує двійковий журнал (binlog) логічних подій. У MySQL 8.4 log_bin увімкнено, binlog_format = ROW (у журналі повні образи змінених рядків), а gtid_mode вимкнено: позицію репліки задають файлом і зсувом (SHOW BINARY LOG STATUS), різними на кожному вузлі. Після перемикання репліка не знає, де продовжувати в журналі нового мастера, тому в експлуатації вмикають GTID: кожна транзакція має глобальний ідентифікатор, і репліка каже, які вже має. Синхронність дає втулок напівсинхронної реплікації (rpl_semi_sync_source), вимкнений за замовчуванням; після rpl_semi_sync_source_timeout (10 с) мастер переходить в асинхронний режим. Типові значення змінних перевірено на MySQL 8.4.11 у Docker, перехід на асинхронний режим описано за документацією.
Розбір: GitHub, жовтень 2018
Section titled “Розбір: GitHub, жовтень 2018”Розбір ґрунтується на постмортемі 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 та алерта диск мастера заповниться.
Перевір себе
Лабораторна
Section titled “Лабораторна”L7. Реплікація і перемикання: створити лаг tc netem, написати клієнта з читанням власних записів, «вбити» мастера, підвищити репліку, з’ясувати втрачені замовлення й повернути старого мастера через pg_rewind.
Джерела
Section titled “Джерела”- PostgreSQL 18, документація: High Availability, Load Balancing, and Replication, WAL і
synchronous_commit, логічна реплікація,pg_rewind, release notes 18. - MySQL 8.4 Reference Manual, глава «Replication».
- Patroni, Dynamic Configuration Settings.
- GitHub, October 21 post-incident analysis, 30 жовтня 2018.
- M. Kleppmann, Designing Data-Intensive Applications, розділ 5 «Replication».
- G. DeCandia et al., Dynamo: Amazon’s Highly Available Key-value Store, SOSP 2007.