Раздел 34 · MySQL с нуля

Репликация и что дальше

junior~30 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В прошлом уроке мы снимали резервную копию. У неё два слабых места. Восстановление идёт долго, а всё, что продали после снятия копии, пропадает. Кинотеатр с сотней касс так рисковать не может. Сегодня разбираем, как держать рядом второй сервер, который в реальном времени повторяет каждое изменение первого. А в конце посмотрим, куда двигаться после этого раздела.

Диктант

Учитель читает текст вслух, ученик записывает. Учитель не ждёт, пока ученик допишет: он читает дальше, а ученик догоняет. Если ученик отвлёкся, он отстанет на пару предложений, но потом наверстает. Если ученик заболел и пропустил урок, он попросит тетрадь и спишет с того места, где остановился.

Репликация устроена так же. Источник читает вслух: записывает каждое изменение в журнал. Реплика записывает под диктовку: забирает журнал и выполняет те же изменения у себя.

Зачем это нужно:

  • Запасной сервер. Источник сгорел, реплика становится новым источником за минуты, а не за часы восстановления из дампа.
  • Чтение отдельно. Тяжёлые отчёты аналитика идут на реплику и не мешают кассам продавать билеты.
  • Копия на другой площадке. Реплика в другом дата-центре переживёт пожар в первом.

Бинарный журнал

Тетрадь учителя в MySQL называется бинарный журнал. Каждая зафиксированная транзакция, которая что-то изменила, попадает туда. В MySQL 8.4 журнал включён по умолчанию, и в песочнице тоже:

SELECT @@server_id, @@log_bin;
+-------------+-----------+
| @@server_id | @@log_bin |
+-------------+-----------+
|           1 |         1 |
+-------------+-----------+
1 row in set (0.00 sec)
SHOW VARIABLES LIKE 'log_bin';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| log_bin       | ON    |
+---------------+-------+
1 row in set (0.00 sec)

Это тот самый журнал, из-за которого в уроке про процедуры функция без DETERMINISTIC не создавалась с ошибкой 1418. Реплика повторяет изменения у себя, и серверу важно, чтобы функция дала там тот же результат.

server_id это номер сервера. У каждого участника репликации он свой: по нему реплика отличает чужие изменения от своих.

GTID: номер каждой транзакции

Как ученику понять, с какого места продолжать? Можно запомнить «страница 2, строка 730», то есть имя файла журнала и позицию в нём. Так MySQL и работал много лет, и при смене источника позиции приходилось пересчитывать руками.

Современный способ GTID: каждая транзакция получает номер вида uuid-сервера:порядковый-номер. Реплика помнит набор номеров, которые уже выполнила, и при подключении говорит источнику: «у меня есть 1-7, присылай остальное».

Как это настраивают

Дальше реальная настройка двух серверов MySQL 8.4 в Docker: источник bs-mysql-source и реплика. В песочнице один сервер, поэтому здесь только выводы настоящих серверов. Пароли в примерах учебные, придумай свои.

Шаг 1. На источнике проверяем номер сервера, GTID и журнал:

SELECT @@server_id, @@gtid_mode, @@log_bin;
+-------------+-------------+-----------+
| @@server_id | @@gtid_mode | @@log_bin |
+-------------+-------------+-----------+
|           1 | ON          |         1 |
+-------------+-------------+-----------+
1 row in set (0.00 sec)

gtid_mode по умолчанию выключен, его включают при запуске сервера параметрами --gtid-mode=ON --enforce-gtid-consistency=ON.

Шаг 2. Заводим пользователя, под которым реплика будет забирать журнал. Ему нужно одно право, и по принципу наименьших привилегий больше не даём:

CREATE USER 'repl'@'%' IDENTIFIED BY 'fake-repl-pass';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';
Query OK, 0 rows affected (0.02 sec)

Query OK, 0 rows affected (0.01 sec)

Право до сих пор называется по-старому, REPLICATION SLAVE, хотя команды уже переименовали.

Шаг 3. Смотрим, где сейчас журнал:

SHOW BINARY LOG STATUS\G
*************************** 1. row ***************************
             File: binlog.000002
         Position: 730
     Binlog_Do_DB: 
 Binlog_Ignore_DB: 
Executed_Gtid_Set: 567b7be2-cfa0-11f1-8831-a6f99e24b4bb:1-7
1 row in set (0.00 sec)

Файл binlog.000002, позиция 730, на источнике выполнено семь транзакций с номерами 1-7.

Шаг 4. На реплике у сервера свой номер, и он открыт только на чтение:

SELECT @@server_id, @@read_only;
+-------------+-------------+
| @@server_id | @@read_only |
+-------------+-------------+
|           2 |           1 |
+-------------+-------------+
1 row in set (0.00 sec)

Шаг 5. Говорим реплике, откуда брать журнал, и запускаем её:

CHANGE REPLICATION SOURCE TO SOURCE_HOST = 'bs-mysql-source', SOURCE_USER = 'repl', SOURCE_PASSWORD = 'fake-repl-pass', SOURCE_AUTO_POSITION = 1, GET_SOURCE_PUBLIC_KEY = 1;
START REPLICA;
Query OK, 0 rows affected, 2 warnings (0.03 sec)

Query OK, 0 rows affected (0.03 sec)

SOURCE_AUTO_POSITION = 1 значит «найди место по GTID», файл и позицию указывать не нужно. Два предупреждения про безопасность: пароль ушёл без шифрования TLS (заметка 1759), и он сохранён в служебной таблице реплики (заметка 1760). В учебном стенде с этим можно жить, в рабочей сети включают TLS.

Шаг 6. На источнике создаём базу и таблицу:

CREATE DATABASE kino;
CREATE TABLE kino.halls (id INT PRIMARY KEY, name VARCHAR(30) NOT NULL, seats SMALLINT UNSIGNED NOT NULL);
INSERT INTO kino.halls VALUES (1, 'Большой', 120), (2, 'Малый', 40), (3, 'VIP', 12);
Query OK, 1 row affected (0.00 sec)

Query OK, 0 rows affected (0.03 sec)

Query OK, 3 rows affected (0.01 sec)
Records: 3  Duplicates: 0  Warnings: 0

Шаг 7. На реплике смотрим состояние и данные:

SHOW REPLICA STATUS\G
*************************** 1. row ***************************
             Replica_IO_State: Waiting for source to send event
                  Source_Host: bs-mysql-source
                  Source_User: repl
                  Source_Port: 3306
                Connect_Retry: 60
              Source_Log_File: binlog.000002
          Read_Source_Log_Pos: 1510
               Relay_Log_File: 52997f6a8393-relay-bin.000003
                Relay_Log_Pos: 1721
        Relay_Source_Log_File: binlog.000002
           Replica_IO_Running: Yes
          Replica_SQL_Running: Yes
              Replicate_Do_DB: 
          Replicate_Ignore_DB: 
           Replicate_Do_Table: 
       Replicate_Ignore_Table: 
      Replicate_Wild_Do_Table: 
  Replicate_Wild_Ignore_Table: 
                   Last_Errno: 0
                   Last_Error: 
                 Skip_Counter: 0
          Exec_Source_Log_Pos: 1510
              Relay_Log_Space: 2983470
              Until_Condition: None
               Until_Log_File: 
                Until_Log_Pos: 0
           Source_SSL_Allowed: No
           Source_SSL_CA_File: 
           Source_SSL_CA_Path: 
              Source_SSL_Cert: 
            Source_SSL_Cipher: 
               Source_SSL_Key: 
        Seconds_Behind_Source: 0
Source_SSL_Verify_Server_Cert: No
                Last_IO_Errno: 0
                Last_IO_Error: 
               Last_SQL_Errno: 0
               Last_SQL_Error: 
  Replicate_Ignore_Server_Ids: 
             Source_Server_Id: 1
                  Source_UUID: 567b7be2-cfa0-11f1-8831-a6f99e24b4bb
             Source_Info_File: mysql.slave_master_info
                    SQL_Delay: 0
          SQL_Remaining_Delay: NULL
    Replica_SQL_Running_State: Replica has read all relay log; waiting for more updates
           Source_Retry_Count: 10
                  Source_Bind: 
      Last_IO_Error_Timestamp: 
     Last_SQL_Error_Timestamp: 
               Source_SSL_Crl: 
           Source_SSL_Crlpath: 
           Retrieved_Gtid_Set: 567b7be2-cfa0-11f1-8831-a6f99e24b4bb:1-10
            Executed_Gtid_Set: 567b7be2-cfa0-11f1-8831-a6f99e24b4bb:1-10,
56a052f4-cfa0-11f1-a43a-3ad2b020cdc2:1-5
                Auto_Position: 1
         Replicate_Rewrite_DB: 
                 Channel_Name: 
           Source_TLS_Version: 
       Source_public_key_path: 
        Get_Source_public_key: 1
            Network_Namespace: 
1 row in set (0.00 sec)

Полей много, смотри на пять:

  • Replica_IO_Running: Yes поток, который забирает журнал с источника, работает.
  • Replica_SQL_Running: Yes поток, который выполняет полученное, тоже работает. Если любой из двух No, причина в Last_IO_Error или Last_SQL_Error.
  • Seconds_Behind_Source: 0 реплика не отстаёт.
  • Retrieved_Gtid_Set транзакции 1-10 источника получены. Семь было до запуска, три добавил шаг 6.
  • Executed_Gtid_Set они же выполнены. Вторая строка с другим uuid это транзакции, которые случились на самой реплике.

И строки на месте:

SELECT * FROM kino.halls;
+----+----------------+-------+
| id | name           | seats |
+----+----------------+-------+
|  1 | Большой        |   120 |
|  2 | Малый          |    40 |
|  3 | VIP            |    12 |
+----+----------------+-------+
3 rows in set (0.00 sec)

Асинхронно, значит с отставанием

По умолчанию репликация асинхронная, как в диктанте: источник фиксирует транзакцию и сразу отвечает кассе, не дожидаясь реплики. Отсюда два следствия.

Первое: реплика отстаёт. Обычно на доли секунды, но под тяжёлой нагрузкой или при долгом ALTER TABLE отставание вырастает до минут. Его показывает Seconds_Behind_Source. Если зритель купил билет, а приложение тут же читает список его билетов с реплики, свежего билета там может ещё не быть. Поэтому то, что пользователь только что записал, читают с источника.

Второе: если источник сгорит, последние транзакции, которые не успели уйти, потеряются. Для кинотеатра это пара билетов за последнюю секунду. Для банка неприемлемо, там включают полусинхронную репликацию, где источник ждёт подтверждения хотя бы от одной реплики.

read_only и super_read_only

Реплику нельзя менять руками: строка, добавленная только на реплике, разойдётся с источником, и следующая транзакция с тем же ключом сломает репликацию. Для этого на реплике был read_only = 1. Проверим, как он держит удар. Под root на реплике:

INSERT INTO kino.halls VALUES (4, 'Новый', 10);
Query OK, 1 row affected (0.01 sec)

Прошло. read_only не останавливает пользователей с административными правами, а root именно такой. Чтобы запретить запись всем, включают super_read_only:

SET GLOBAL super_read_only = ON;
INSERT INTO kino.halls VALUES (5, 'Ещё', 10);
Query OK, 0 rows affected (0.00 sec)

ERROR 1290 (HY000): The MySQL server is running with the --super-read-only option so it cannot execute this statement

Теперь отказ. Сама репликация при этом работает: свои потоки реплика пускает. Зал номер 4 остался лишним на реплике, и в настоящей системе его пришлось бы убирать.

Реплика не резервная копия

Последнее и самое важное. Реплика повторяет всё, включая ошибки. Если администратор выполнит DROP TABLE tickets на источнике, через долю секунды таблицы не станет и на реплике. Поэтому резервные копии нужны и при живой репликации: реплика спасает от сгоревшего диска, копия от неверной команды.

Попробуй сам

Песочница не умеет запускать второй сервер, зато в ней можно посмотреть на параметры, с которых начинается любая настройка репликации.

Найди в выводе sql_mode режимы STRICT_TRANS_TABLES и ONLY_FULL_GROUP_BY, которые встречались в разделе. Потом подними у себя два контейнера MySQL по шагам из урока и посмотри SHOW REPLICA STATUS\G после вставки на источнике.

Проверь себя

Задание

Что дальше

Раздел закончен. Ты умеешь поднять MySQL, спроектировать таблицы с ключами и ограничениями, писать запросы с JOIN и группировкой, менять строки в транзакциях, ускорять запросы индексами, раздавать права и снимать копии. Куда двигаться дальше:

Возьми любой свой проект на MySQL и пройдись по нему глазами этого раздела: где не хватает индекса, где две команды стоило склеить транзакцией, у кого лишние права и когда последний раз проверяли восстановление из копии.

домашка

Домашка