Доставка обновлений из БД MySQL в приложение при помощи клиента репликации libslave

в 12:19, , рубрики: Без рубрики

Доставка обновлений из БД MySQL в приложение при помощи клиента репликации libslave

При написании любого достаточно крупного проекта всегда встают более-менее похожие проблемы. Одна из них — проблема скорости получения обновлений системы. Относительно легко можно наладить быстрое получение небольших обновлений. Довольно просто изредка получать обновления большого объема. Но что если надо быстро обновлять большой массив данных?

Для Таргета Mail.Ru, как и для всякой рекламной системы, быстрый учет изменений важен по следующим причинам:
• возможность быстрого отключения показа кампании, если рекламодатель остановил ее в интерфейсе или если у него кончились деньги, а значит, мы не будем показывать ее бесплатно;
• удобство для рекламодателя: он может поменять цену баннера в интерфейсе, и уже через несколько секунд его баннеры начнут показываться по новой стоимости;
• быстрое реагирование на изменение ситуации: изменение CTR, поступление новых данных для обучения математических моделей. Все это позволяет корректировать стратегию показа рекламы, чутко реагируя на внешние факторы.

В этой статье я расскажу об обновлении данных, лежащих в больших таблицах в БД MySQL, фокусируясь на скорости и консистентности — ведь не хотелось бы уже получить новый заведенный баннер, но при этом не получить данную рекламную кампанию.

Как мы могли бы это делать, используя стандартные средства MySQL? Можно было бы периодически перечитывать все таблицы целиком. Это самый неоптимальный вариант, ведь вместе с новыми данными будет перегоняться много старых, уже известных данных, гигантская нагрузка ляжет на сеть и на MySQL-сервер. Другой вариант — соответствующим образом подготовить схему хранения данных: ввести во все таблицы время последнего обновления и делать селекты за нужные промежутки времени. Правда, если таблиц много, и машин, где надо хранить копию данных, тоже много, то и селектов будет очень-очень много, а нагрузка на MySQL-сервер, опять-таки, получается большой. Кроме того, придется позаботиться о консистентности полученных обновлений.

Удобный способ решения проблемы предлагает нам libslave:
• данные приходят к нам из БД «сами» — нет необходимости поллить базу, если обновлений в данный момент нет;
• обновления приходят в том порядке, в котором выполнялись на мастере, нам видна вся история изменений;
• не надо специально готовить таблицы, вводить таймстемпы, ненужные с точки зрения бизнес-логики;
• видны границы транзакций — т. е. точки консистентности данных;
• низкая нагрузка на мастер: он не выполняет запросы, не нагружает процессор — он просто шлет файлы.

О том, как мы используем libslave для наших целей, и будет эта статья. Я вкратце расскажу, как устроена репликация данных в MySQL (думаю, все себе это хорошо представляют, но все же), как устроена сама libslave, как ею пользоваться, приведу результаты бенчмарков и некоторый сравнительный анализ существующих реализаций.

Устройство репликации

Мастер-база записывает каждый запрос, изменяющий данные или схему данных, в специальный файл — так называемый binary-log. Когда бинарный лог достигает определенного размера, запись переходит к следующему файлу. Существует специальный индекс этих бинарных логов, а также определенный набор команд для управления ими (например, для удаления старых бинлогов).

Слейв получает по сети эти файлы в сыром виде (что упрощает реализацию мастера) и применяет к своим данным. Слейв помнит позицию, до которой он дочитал бинлоги, и поэтому при перезапусках просит мастер продолжить отправлять ему логи начиная с нужной позиции.

Существует два режима репликации — STATEMENT и ROW. В первом режиме мастер записывает в бинлог исходные запросы, которые он выполнял для изменения данных (UPDATE/INSERT/DELETE/ALTER TABLE/...). На слейве все эти запросы выполняются так же, как они выполнялись бы на мастере.

В ROW-режиме, доступном начиная с MySQL версии 5.1, в бинлог пишутся не запросы, а уже измененные этими запросами данные (впрочем, запросы, изменяющие схемы данных (DDL), все равно пишутся как есть). Событие в ROW-режиме представляет собой:
• одну строку данных — для команд INSERT и DELETE. Соответственно, для INSERT пишется вставленная строка, для DELETE — удаленная
• две строки — BEFORE и AFTER — для UPDATE.

При каких-то массовых изменениях данных такие бинлоги получаются значительно больше, чем если бы мы записали сам запрос, но это частный случай. Для использования в своем демоне ROW-репликация нам очень удобна тем, что не надо уметь выполнять запросы и мы получаем сразу то, что нужно — измененные строки.

Кстати, совсем необязательно включать ROW-репликацию на своем самом-важном-мастер-сервере. Мастер-сервер может кормить несколько слейвов по обычной STATEMENT репликации (резервы, бэкапы), а часть из этих слейвов могут писать ROW-логи, и уже к ним будут обращаться за данными демона.

Как это работает у нас

Получение данных из базы происходит в два этапа:
1. начальная загрузка данных при старте демона
2. получение обновлений

Начальная загрузка данных, в принципе, может быть облегчена наличием дампов — демон может сбрасывать свое состояние на диск и писать позиции бинлога в тот же дамп. Тогда при старте можно загрузить данные в память из дампа и продолжить читать бинлог с последней позиции. Но когда дампа нет, надо что-то делать. И это что-то, разумеется, Select.

Выбрать данные надо так, чтобы в памяти они были консистентными. Для этого можно выбирать данные из всех таблиц в одной транзакции. Но после этого нам надо начать читать обновления по libslave и решить, с какой позиции мы будем читать. Взять текущую позицию после селектов нельзя, т. к. во время селектов в базу могли быть записаны новые данные, которые в селект не попали, но позицию бинлога подвинули. Брать позицию перед началом селектов тоже нельзя, т. к. от момента, когда мы возьмем текущую позицию, до момента начала селекта могут прийти новые данные. Начать транзакцию командой BEGIN, а потом получить текущую позицию бинлога, опять-таки, не выйдет — между ними нет никакой синхронизации, и если один клиент сделал BEGIN, то другие клиенты могли в этот время записать данные, а позиция бинлога, соответственно, могла сместиться.

Все эти размышления подводят нас к той мысли, что обеспечивать консистентность чтения будет частично задачей демона. Данные у нас в памяти устроены так, что если к нам приходит неконсистентный объект (в том плане, что мы можем обнаружить его неконсистентность — например, ему не хватает необходимых связей), то он будет просто выброшен из памяти демона; однако, если позже он придет консистентным, то будет вставлен в память. Если он придет консистентным два раза, то в памяти останется его последнее состояние. Если он был в памяти консистентным, а придет неконсистентным, то неконсистентное состояние мы не применим, и объект в памяти демона не изменится.

Исходя из всего этого, правильная, с нашей точки зрения, модель начальной загрузки выглядит так:
1. Получаем текущую позицию бинлога на мастере p1 — делаем это в произвольный момент времени.
2. Делаем все селекты.
3. Получаем новую текущую позицию бинлога на мастере p2.
4. С помощью libslave читаем все события между p1 и p2. При этом в демон могут прийти как новые объекты, которые образовались во время селекта, так и измененные старые, которые уже есть в памяти.
5. После этого у демона есть непротиворечивая копия данных, демон готов к работе — можем отвечать на запросы и принимать обновления с помощью libslave, начиная с позиции p2.

Я особо подчеркну, что, раз консистентность данных у нас поддерживается в демоне, то нам нет необходимости делать в п. 2 селекты в одной транзакции. Рассмотрим, для примера, такую сложную последовательность событий:
1. Получаем текущую позицию бинлога.
2. Начали читать таблицу кампаний, в которой есть кампания campaign1.
3. Создался новый баннер banner1 в состоянии 1 в существующей кампании campaign1.
4. Создалась новая кампания campaign2, которая не попадает в результат селекта.
5. Создался новый баннер banner2, который привязан к кампании campaign2.
6. Banner1 перешел в состояние 2.
7. Закончили читать таблицу кампаний, перешли к чтению таблицы баннеров, и в это чтение попадет banner1 в состоянии 2 и banner2.
8. Дочитали таблицу баннеров.
9. Создался новый баннер banner3 в кампании campaign2.
10. Получили новую текущую позицию бинлога p2.

И теперь посмотрим, что будет происходить в демоне:
1. Демон селектит все кампании и запоминает их в память (возможно, проверяя какие-то критерии — может быть, нам нужны в памяти не все кампании).
2. Демон перешел к селекту баннеров. Он прочитает banner1 сразу в состоянии 2 и запомнит его, привязав его к уже прочитанной кампании campaign1.
3. Он прочитает banner2 и откинет его, т. к. кампании campaign2 для него в памяти нет — она не попала в результаты селекта.
4. На этом селект закончился. Переходим к чтению изменений от позиции p1 до позиции p2.
5. Встречаем создание баннера banner1 в состоянии 1. Напомню, в памяти демона он уже есть в своем последнем состоянии 2, но, однако же, мы применим это обновление, и переведем баннер в состояние 1 — это не страшно, т. к. данные будут использоваться для работы только после того, как мы дочитаем до позиции p2, а до этой позиции мы получим изменения этого баннера еще раз.
6. Прочитали создание новой кампании campaign2 — запомнили ее.
7. Прочитали создание привязанного к ней баннера banner2 — теперь мы его запомним, т. к. для него есть соответствующая кампания, а данные консистентны.
8. Прочитали перевод banner1 в состояние 2 — применили, теперь и тут консистентно.
9. Прочитали создание banner3 в кампанию campaign2 — вставили в память.
10. Дошли до позиции p2, остановились — все данные загружены консистентно, можем отдавать их пользователю и читать обновления дальше в штатном режиме.

Заметим, что на этапе первоначальной выборки данных их неконсистентность не говорит о том, что ошибки присутствуют в базе — просто это такая особенность процесса загрузки данных. Такая неконсистентность будет исправлена далее демоном при дочитывании данных. А вот если после этого в них есть какие-то несоответствия — тогда уже да, тогда это база.

Код выглядит примерно так:

slave::MasterInfo sMasterInfo;	// заполняем опции коннекта к базе
    Slave sSlave(sMasterInfo);		// создаем объект для чтения данных
    // запоминаем последнюю позицию бинлога
    const slave::Slave::binlog_pos_t sInitialBinlogPos = sSlave.getLastBinlog();
    select();	// селектим данные из базы
    // получаем новую последнюю позицию бинлога — изменилась за время селекта
    const slave::Slave::binlog_pos_t sCurBinlogPos = sSlave.getLastBinlog();
    // теперь нам надо дочитать данные из слейва до этой позиции
    init_slave();	// здесь добавляются колбеки на таблицы, вызываются Slave::init и Slave::createDatabaseStructure
    sMasterInfo.master_log_name = sInitialBinlogPos.first;
    sMasterInfo.master_log_pos = sInitialBinlogPos.second;
    sSlave.setMasterInfo(sMasterInfo);
    sSlave.get_remote_binlog(CheckBinlogPos(sSlave, sCurBinlogPos));

Функтор CheckBinlogPos вызовет завершение чтения данных из бинлога по достижении позиции sCurBinlogPos. После этого происходит первичная подготовка данных для использования и запускается чтение данных из слейва с последней позиции уже без всяких функторов.

Устройство libslave

Рассмотрим подробнее, что такое libslave. Мое описание основано на наиболее популярной реализации. Ниже я сравню несколько форков и совершенно другую реализацию.

Libslave — это библиотека на C++, которая может быть использована в вашем приложении для получения обновлений из MySQL. Libslave не связана на уровне кодов с MySQL-сервером; она собирается и линкуется только с клиентом — libmysqlclient. Работоспособность библиотеки проверялась на мастерах версии от 5.1.23 до 5.5.34 (не на всех! Только на тех, что под руку попались).

Для работы нам нужен MySQL-сервер с включенной записью бинлогов в режиме ROW. Для этого у него в конфиге должны быть следующие строки:

[mysqld]
log-bin = mysqld-bin
server-id = 1
binlog-format = ROW

Пользователю, под которым будет ходить libslave, потребуются права доступа REPLICATION SLAVE и REPLICATION CLIENT, а также SELECT на те таблицы, которые он будет обрабатывать (на те, которые будут в бинлогах, но которые он будет пропускать, SELECT не нужен). Право SELECT нужно для получения схемы таблицы.

В libslave встроен микро-классик nanomysql::Connection для выполнения обычных SQL-запросов на сервере. В жизни мы его используем не только как часть libslave, но и как клиент для MySQL вообще (не хотелось использовать mysqlpp, mysql-connector и прочие штуки).

Основной класс называется Slave. Перед началом работы мы задаем пользовательские колбеки для событий от таблиц, за которыми будем следить. В колбек передается информация о событии в структуре RecordSet: его тип (Write/Update/Delete) и данные (вставленная/обновленная/удаленная запись, в случае Update — ее предыдущее состояние).

При инициализации библиотеки параметрами мастера происходят следующие проверки:
1. Проверяем возможность соединиться с сервером.
2. Делаем SELECT VERSION() — убеждаемся, что версия не меньше, чем 5.1.23.
3. Делаем SHOW GLOBAL VARIABLES LIKE 'binlog_format' — убеждаемся, что формат бинлогов ROW.
4. Читаем сохраненную позицию последнего прочитанного сообщения через пользовательскую функцию. Если пользовательская функция ничего не вернула (пустое имя бинлога, нулевая позиция), то читаем текущее положение бинлога в мастере через запрос SHOW MASTER STATUS.
5. Считываем структуру базы для таблиц, за которыми будем следить.
6. Генерируем slave_id — такой, чтобы не совпадал ни с одном из запросов SHOW SLAVE HOSTS.
7. Регистрируем слейв на мастере выполнением simple_command (COM_REGISTER_SLAVE).
8. Запрашиваем передачу дампа командой simple_command (COM_BINLOG_DUMP).
9. Запускаем цикл обработки входящих пакетов — их парсинг, вызов нужных колбеков, обработку ошибок.

Отдельно стоит упомянуть про пятый пункт — считывание структуры базы данных. В случае настоящего MySQL-slave, мы всегда знаем правильное устройство табличек, потому что начали с какого-то SQL-дампа и продолжили читать таблицы с соответствующей позиции бинлога, следуя всем DDL-statement. Libslave же в общем случае стартует с той позиции бинлога, которую ей предоставит пользователь (например, с той, на которой мы сохранились в прошлый раз, или с текущей позиции мастера). Прошлых знаний о структуре базы данных в общем случае у нее нет, поэтому схему таблицы она получает парсом вывода результатов запроса SHOW FULL COLUMNS FROM. И именно оттуда берется информация о том, какие поля, каких типов и в каком порядке парсить из бинлога. С этим может быть такая проблема: описание таблиц мы получаем текущие, а бинлоги можем начать читать предыдущие, когда таблица еще выглядела по-другому. В этом случае libslave, скорее всего, выйдет с ошибкой, что данные неконсистентны. Придется начинать читать с текущей позиции мастера.

Не страшно менять описание таблиц в процессе работы libslave. Она распознает запросы ALTER TABLE и CREATE TABLE, и сразу после их получения перечитывает структуры таблиц. Конечно, и тут возможны проблемы. Предположим, что мы быстро два раза поменяли структуру таблицы, между этими событиями записав туда какие-то данные. Если libslave получит запись о первом альтере, только когда уже будет завершен второй, то через SHOW FULL COLUMNS FROM получит сразу же второе состояние БД. Тогда событие на обновление таблицы, которое будет соответствовать еще первому описанию, имеет шансы остановить репликацию. Впрочем, на практике такое бывает крайне редко (у нас не было ни разу), и в случае чего лечится перезапуском демона с чистого листа.

С помощью libslave можно отслеживать границы транзакций. Несмотря на то, что, пока транзакция не завершится, ни одна ее запись не попадет в бинлог, различать транзакции все же может быть важно: если у вас есть какие-то два связанных изменения в разных таблицах, то вы можете не захотеть использовать только одну обновленную, пока не обновится и вторая. В бинлог не попадают события BEGIN — при начале транзакции идут сразу измененные строки, которые завершаются COMMIT'ом. Т.е. транзакции отслеживаются не по BEGIN/COMMIT, а по двум последовательным COMMIT'ам.

Если мастер исчез

Основной цикл работы libslave, вызываемый функцией get_remote_binlog, получает в качестве параметра пользовательский функтор, который проверяется перед чтением каждого нового пакета. Если функтор вернет true, то цикл завершится.

При возникновении любых ошибок происходит вывод ошибки в лог и цикл продолжается. В частности, если сервер перезагружают, то libslave будет пытаться переконнектиться с сервером до победного конца, после чего продолжит читать данные. Возможны и вечные залипы — например, если из-под мастера утащили логи, которые читает libslave, то libslave будет вечно «плакаться» в цикле, что нужных логов нет.

На случай обрыва сети соединение настраивается с таймаутом в 60 секунд. Если за 60 секунд не пришло ни одного нового пакета данных (что может быть и в случае, если просто нет обновлений), то будет произведен реконнект к базе данных, и чтение потока сообщений продолжится с последней прочитанной позиции.

В принципе, завершить цикл можно и раньше, не дожидаясь окончания таймаута. Предположим, что вы хотите иметь возможность быстро, но корректно завершить приложение по Ctrl+C, не дожидаясь возможных 60 секунд при разрыве сети или отсутствии обновлений. Тогда в обработчике сигнала достаточно выставить флаг, который заставит следующий вызов пользовательского функтора вернуть true, и вызвать функцию Slave::close, которая принудительно закроет сокет MySQL. Из-за этого вызов чтения пакета завершится с ошибкой, и при проверке ответа от пользовательского функтора произойдет выход из цикла.

Статистика
В библиотеке есть абстрактный класс ExtStateIface, которому из libslave передается различная информация: число реконнектов, время последнего эвента, статус соединения с БД. Этот же класс отвечает за сохранение и загрузку текущей позиции бинлога в какое-либо постоянное хранилище. Существует дефолтная реализация этого класса DefaultExtState, работающая через мьютекс (т. к. устанавливать статистику может slave в одном потоке, а читать ее — кто-то другой в другом). Грустная новость заключается в том, что правильная реализация этого класса необходима для корректной работы libslave, т. е. это не просто объект статистики — это объект, который может управлять работой библиотеки.

Бенчмарки

Бенчмарки проводились на двух комплектах машин.

Первый комплект представлял собой одну машину, на которой была установлена БД, и на ней же выполнялся тест. Конфигурация:
• CPU: Intel® Core(TM) i7-4770K CPU @ 3.50GHz
• mem: 32 GB 1666 MHz
• MB: Asus Z87M-PLUS
• HDD: SSD OCZ-VERTEX3
• OS Gentoo Linux, ядро 3.12.13-gentoo x86_64
Настройки БД были по умолчанию. Честно говоря, не думаю, что они имеют большое значения для мастера, который фактически просто «льет» файл по сети.

Второй комплект представлял собой две машины. Первая машина с БД:
• CPU: 2 x Intel® Xeon® CPU E5620 @ 2.40GHz
• mem: 7 x 8192 MB TS1GKR72V3N 800 MHz (1.2ns), 1 x 8192 MB Kingston 9965434-063.A00LF 800 MHz (1.2ns), 4 x Empty
• MB: ETegro Technologies ETRS370G3
• HDD: 14 x 300 GB HUS156030VLS600, 2 x 250 GB WDC WD2500BEVT-0
• PCI: LSI Logic / Symbios Logic SAS2116 PCI-Express Fusion-MPT SAS-2 [Meteor], Intel Corporation 82801JI (ICH10 Family) SATA AHCI Controller
• OS CentOS release 6.5 (Final) ядро 2.6.32-220.13.1.el6.x86_64

Машинка с тестом:
• CPU: 2 x Intel® Xeon® CPU E5-2620 0 @ 2.00GHz
• mem: 15 x 8192 MB Micron 36KSF1G72PZ-1G4M1 1333 MHz (0.8ns), 1 x 8192 MB Micron 36KSF1G72PZ-1G6M1 1333 MHz (0.8ns)
• MB: ETegro Technologies ETRS125G4
• HDD: 2 x 2000 GB Hitachi HUA72302, 2 x 250 GB ST250DM000-1BD14
• PCI: Intel Corporation C602 chipset 4-Port SATA Storage Control Unit, Intel Corporation C600/X79 series chipset 6-Port SATA AHCI Controller
• OS CentOS release 6.5 (Final) ядро 2.6.32-358.23.2.el6.x86_64

Сеть между машинами 1 Гбит/с.

Следует отметить, что в обоих тестах БД не обращалась к диску, т. е. бинлоги были закэшированы в памяти, и в передачу данных тест не упирался. Загрузка CPU во всех тестах была 100%. Это говорит о том, что упирались мы в саму библиотеку libslave, т. е. исследовали ее производительность.

Для теста были созданы две таблицы — маленькая и большая:

CREATE TABLE short_table (
    id int NOT NULL auto_increment,

    field1 int NOT NULL,
    field2 int NOT NULL,

    PRIMARY KEY (id)
);

CREATE TABLE long_table (
    id int NOT NULL auto_increment,

    field1 timestamp NOT NULL DEFAULT 0,
    field2 timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    field3 enum('a','b','c') NOT NULL DEFAULT 'a',
    field4 enum('a','b','c') NOT NULL DEFAULT 'a',

    field5 varchar(255) NOT NULL,
    field6 int NOT NULL,
    field7 int NOT NULL,
    field8 text NOT NULL,
    field9 set('a','b','c') NOT NULL DEFAULT 'a',
    field10 int unsigned NOT NULL DEFAULT 2,
    field11 double NOT NULL DEFAULT 1.0,
    field12 double NOT NULL DEFAULT 0.0,
    field13 int NOT NULL,
    field14 int NOT NULL,
    field15 int NOT NULL,
    field16 text NOT NULL,

    field17 varchar(255) NOT NULL,
    field18 varchar(255) NOT NULL,
    field19 enum('a','b','c') NOT NULL DEFAULT 'a',
    field20 int NOT NULL DEFAULT 10,

    field21 double NOT NULL,
    field22 double NOT NULL DEFAULT 1.0,
    field23 double NOT NULL DEFAULT 1.0,

    field24 double NOT NULL DEFAULT 1.0,

    field25 text NOT NULL DEFAULT "",

    PRIMARY KEY (id)
);

Каждая таблица содержала по одному миллиону записей. При вставке данных одно текстовое поле заполнялось короткой строкой. Все остальные строки были фактически пустыми, поля заполнялись значениями по умолчанию. Т.е. такой метод вставки позволял получить полноценные бинлоги, но большинство строковых полей были пустыми:
INSERT INTO short_table (field1, field2) values (1, 2);
INSERT INTO long_table (field5, field25) values («short_string», «another_short_string»);

Каждая из этих таблиц были сначала вставлена в БД, после чего полностью обновлена запросами:
UPDATE short_table SET field1 = 12;
UPDATE long_table SET field6 = 12;

Таким образом, удалось получить набор бинлогов типа INSERT и набор бинлогов типа UPDATE (которые раза в два больше, т. к. содержат помимо измененной строки ее предыдущее состояние). Перед каждой операцией запоминали позицию бинлога, т. е. получили таким образом 4 интервала бинлогов:
инсерты короткой таблицы (5429180 — 18977180 => 13548000)
апдейты короткой таблицы (18977180 — 45766831 => 26789651)
инсерты длинной таблицы (45768421 — 183563421 => 137795000)
апдейты длинной таблицы (183563421 — 461563664 => 278000243).

Тест был собран компилятором gcc-4.8.2 с флагами -O2 -fomit-frame-pointer -funroll-loops. Каждый тест прогонялся три раза, в качестве результата брались показатели третьего теста.

А теперь немного таблиц и графиков. Но сначала нотация:
• «без колбеков» означает, что мы попросил libslave прочитать определенный набор бинлогов, не навешивая никаких колбеков на таблицу, т. е. и записи RecordSet не создавались.
• «С бенчмарк-колбеками» означает, что были повешены колбеки, измеряющие посекундную производительность, стараясь минимально воздействовать на общее время выполнения теста (нужно для построения графиков). Больше они ничего не делали — вся работа на libslave была только в том, чтобы распарсить запись, создать объект(-ы) RecordSet и передать их в пользовательскую функцию по ссылке.
• «С lockfree-malloc» означает, что в тесте использовался аллокатор.

«Время 1» и «Время 2» — время выполнения теста для набора машин 1 и 2, соответственно.

Тест Время 1, сек. Время 2, сек.
Инсерты в маленькую таблицу без колбеков 00,0299 00,1595
Инсерты в маленькую таблицу с бенчмарк колбеками 02,4092 03,8958
Апдейты в маленькую таблицу без колбеков 00,0500 00,2336
Апдейты в маленькую таблицу с бенчмарк-колбеками 04,8499 07,4892
Инсерты в большую таблицу без колбеков 00,2627 01,1842
Инсерты в большую таблицу с бенчмарк-колбеками 20,2901 33,9604
Инсерты в большую таблицу с бенчмарк-колбеками с lockfree-malloc 19,0906 34,5743
Апдейты в большую таблицу без колбеков 00,6225 02,3860
Апдейты в большую таблицу с бенчмарк-колбеками 40,4330 70,7851
Апдейты в большую таблицу с бенчмарк-колбеками с lockfree-malloc 37,9637 68,3616
Инсерты и апдейты в обе таблицы без колбеков 00,9499 03,9179
Инсерты и апдейты в обе таблицы с бенчмарк-колбеками на короткую 08,0445 14,8126
Инсерты и апдейты в обе таблицы с бенчмарк-колбеками на длинную 62,8213 100,9520
Инсерты и апдейты в обе таблицы с бенчмарк-колбеками на обе 67,8092 118,3860
Инсерты и апдейты в обе таблицы с бенчмарк-колбеками на обе с lockfree-malloc 64,5951 113,3920

Ниже приведен график скорости чтения по последнему бенчмарку с обеих машин. График постепенно спадает: быстрее всего читаются маленькие инсерты, далее идут маленькие апдейты, далее — большие инсерты, и медленнее всего обрабатываются маленькие апдейты. Можно примерно представить скорость обработки каждого типа бинлогов.

Доставка обновлений из БД MySQL в приложение при помощи клиента репликации libslave

Я не исследовал в данном бенчмарке скорость обработки событий DELETE, но подозреваю, что она идентична скорости INSERT, поскольку в логе появляется сообщение такой же длины, что и при вставке.

Разные реализации

На данный момент мне известны только две реализации libslave. Одна из них — это уже упомянутая, в свое время ее открыла компания «Бегун», и об этом много где было написано (например, на OpenNet). Именно эта реализация используется в портах FreeBSD.

В Mail.Ru Group используется форк Бегуна, который я иногда подпиливаю. Часть изменений в ней была также внесена в бегунский форк. Из невнесенных: выпиливание неиспользуемого кода, уменьшение включения хедеров, больше тестов (тесты на BIGINT, на длинные SET'ы), проверка версии формата каждого бинлога, поддержка mysql-5.5, типа decimal (возвращается как double — разумеется, используется не в биллинге, а там, где достаточно примерного представления о балансах), поддержка битовых полей (позаимствована из форка, который сейчас практически в том же состоянии, что и мой).

Вторая реализация, которая мне известна — от вышеупомянутых Пианиста и Димарика. Что она из себя представляет архитектурно и в плане производительности, мне еще предстоит выяснить.

Примеры кода

Примеры кода есть и в самой библиотеке, но я дам несколько комментариев.

Файл types.h: через typedef'ы показывает маппинг между типами MySQL и типами C++. Можно заметить, что все строковые типы, включая BLOB, представляют собой просто std::string, а все целочисленные типы — беззнаковые. Т.е. даже если в определении таблицы написан просто int (не unsigned), то библиотека будет возвращать тип uint32_t.

В этом же файле содержатся две удобные функции по переводу типов DATE и DATETIME, предоставляемых libslave, в обычный time_t. Эти две функции являются внешними (не вызываются внутри libslave) по историческим причинам: изначально libslave возвращал странные закодированные числа для этих дат, и я не стал это менять.

Файл recordset.h содержит определение структуры RecordSet, представляющей собой одну запись бинлога. В ней содержится тип сообщения, его время, имя базы данных и таблицы, к которым он принадлежит, а также две строки — новая и предыдущая (для update).

Строка представляет собой ассоциативный массив из имени столбца в объект типа boost::any, который будет содержать в себе тип, описанный в types.h и соответствующий полю.

Основной объект Slave описан в файле Slave.h.

Простейший код для запуска чтения репликации выглядит так:

void callback(const slave::RecordSet& event) {

    switch (event.type_event) {
    case slave::RecordSet::Update: std::cout << "UPDATE"; break;
    case slave::RecordSet::Delete: std::cout << "DELETE"; break;
    case slave::RecordSet::Write:  std::cout << "INSERT"; break;
    default: break;
    }
}
    slave::MasterInfo masterinfo;

    masterinfo.host = host;
    masterinfo.port = port;
    masterinfo.user = user;
    masterinfo.password = password;

    slave::Slave slave(masterinfo);
    slave.setCallback(«database», «table», callback);
    slave.init();
    slave.createDatabaseStructure();
    slave.get_remote_binlog();

Пример сессии с тестовым клиентом test_client
Для наглядности рассмотрим небольшой пример сессии: создание пользователя, базы, таблицы, наполнение ее данными и соответствующий вывод test_client. То есть пример готового клиента репликации, содержащегося в исходных кодах libslave.
Вызов test_client выглядит так:
Usage: ./test_client -h <mysql host> -u <mysql user> -p <mysql password> -d <mysql database> <table name> <table name> …

Попробуем:
mysql -u root

mysql> create user 'slave_test_user'@'localhost';
Query OK, 0 rows affected (0.00 sec)

mysql> create database slave_test_db;
Query OK, 1 row affected (0.00 sec)

Запускаем клиент:

./test_client -h localhost -u slave_test_user -d slave_test_db
Reading binlogs...
Error in reading binlogs: mysql_query() failed: Access denied; you need (at least one of) the REPLICATION SLAVE privilege(s) for this operation : 1227 : [SHOW SLAVE HOSTS]

Видим в цикле появляющуюся ошибку о невозможности выполнить запрос. Ошибка повторяется в цикле, потому что get_remote_binlog вызывается в цикле, сам же он кидает исключение в ответ на ошибку.

Даем права:

mysql> grant REPLICATION SLAVE on *.* to 'slave_test_user'@'localhost';
Query OK, 0 rows affected (0.00 sec)

Повторяем запуск клиента:

./test_client -h localhost -u slave_test_user -d slave_test_db
Reading binlogs...
Error in reading binlogs: mysql_query() failed: Access denied; you need (at least one of) the SUPER,REPLICATION CLIENT privilege(s) for this operation : 1227 : [SHOW MASTER STATUS]

Прав все равно не хватает, на этот раз REPLICATION CLIENT. Выдаем.

mysql> grant REPLICATION CLIENT on *.* to 'slave_test_user'@'localhost';
Query OK, 0 rows affected (0.00 sec)
> ./test/test_client -h localhost -u slave_test_user -d slave_test_db
Creating client, setting callbacks...
Initializing client...
Reading database structure...
Reading binlogs...

Ага, делает вид, что читает поток обновлений. Но мы еще не дали ему на вход ни одну таблицу — без этого можно будет разве что дебаг-логи смотреть.

mysql> create table simple (id int auto_increment, word varchar(32), primary key (id));
Query OK, 0 rows affected (0.01 sec)

Добавляем в командную строку имя таблицы:

> ./test/test_client -h localhost -u slave_test_user -d slave_test_db simple
Creating client, setting callbacks...
Initializing client...
Reading database structure...
Error in initializing slave: mysql_query() failed: SELECT command denied to user 'slave_test_user'@'localhost' for table 'simple' : 1142 : [SHOW FULL COLUMNS FROM simple IN slave_test_db]

Вот и требование прав на SELECT — даем.

mysql> grant SELECT on slave_test_db.* to 'slave_test_user'@'localhost';
Query OK, 0 rows affected (0.00 sec)

Итоговые права выглядят так:

mysql> show grants for 'slave_test_user'@'localhost';
+-------------------------------------------------------------------------------------+
| Grants for slave_test_user@localhost                                                |
+-------------------------------------------------------------------------------------+
| GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'slave_test_user'@'localhost' |
| GRANT SELECT ON `slave_test_db`.* TO 'slave_test_user'@'localhost'                  |
+-------------------------------------------------------------------------------------+
2 rows in set (0.00 sec)

Повторяем попытку:

> ./test/test_client -h localhost -u slave_test_user -d slave_test_db simple
Creating client, setting callbacks...
Initializing client...
Reading database structure...
Reading binlogs...

Посмотрим теперь, как читаются данные и как клиент видит границы транзакций. В MySQL делаем:

mysql> insert into simple set word='aaa';
Query OK, 1 row affected (0.00 sec)

mysql> insert into simple (word) values ('bbb'), ('ccc');
Query OK, 2 rows affected (0.00 sec)
Records: 2  Duplicates: 0  Warnings: 0

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into simple set word='ddd';
Query OK, 1 row affected (0.00 sec)

mysql> insert into simple set word='eee';
Query OK, 1 row affected (0.00 sec)

mysql> commit;
Query OK, 0 rows affected (0.00 sec)

Клиент выдает:


> ./test/test_client -h localhost -u slave_test_user -d slave_test_db simple
Creating client, setting callbacks...
Initializing client...
Reading database structure...
Reading binlogs...
INSERT slave_test_db.simple
  id : int(11) -> 1
  word : varchar(32) -> 'aaa'
  @ts = 1394604531
  @server_id = 1

COMMIT @server_id = 1

INSERT slave_test_db.simple
  id : int(11) -> 2
  word : varchar(32) -> 'bbb'
  @ts = 1394604567
  @server_id = 1

INSERT slave_test_db.simple
  id : int(11) -> 3
  word : varchar(32) -> 'ccc'
  @ts = 1394604567
  @server_id = 1

COMMIT @server_id = 1

INSERT slave_test_db.simple
  id : int(11) -> 4
  word : varchar(32) -> 'ddd'
  @ts = 1394604643
  @server_id = 1

INSERT slave_test_db.simple
  id : int(11) -> 5
  word : varchar(32) -> 'eee'
  @ts = 1394604646
  @server_id = 1

COMMIT @server_id = 1

Т.е. становится понятно, как видеть границы транзакций, и можно заметить, что никакие данные из транзакции в лог не пишутся, пока не будет вызван COMMIT.

Заключение

В заключение хочу сказать, что libslave — это отличный способ организовать прием обновления данных, лежащих в БД в MySQL, в ваш демон. Мы его эксплуатируем и в хвост и в гриву, за исключением некоторых маленьких конфигурационных таблиц, которые по коду проще селектить целиком с определенной периодичностью.

Кстати, одной из причин публикации этой статьи стало то, что в русскоязычном интернете мне удалось найти лишь одну историю успеха — это доклад на Highload++ 2011 Александра «Пианиста» Панкова и Дмитрия «Димарика» Самирова «Нестандартное использование репликации Mysql». Каких-то других обсуждений libslave найти не получилось. Поэтому, если вам такие публикации известны, то прошу поделиться ссылками в комментариях.

И полезная ссылка на страницу с описанием формата бинлогов напоследок: dev.mysql.com/doc/internals/en/event-data-for-specific-event-types.html

Автор: vozbu

Источник

Поделиться

* - обязательные к заполнению поля