company_banner

Доставка обновлений из БД 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


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



    Я не исследовал в данном бенчмарке скорость обработки событий 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 -u -p -d dev.mysql.com/doc/internals/en/event-data-for-specific-event-types.html
    • +53
    • 16,5k
    • 4
    Mail.ru Group
    1822,00
    Строим Интернет
    Поделиться публикацией

    Комментарии 4

      +1
      Спасибо, интересно.

      А какой метод вы посоветуете для «быстрого получения небольших обновлений»?
        +1
        Какой каверзный вопрос. Попробую ответить.

        Однозначно могу сказать, что libslave подойдет в большинстве случаев, когда требуется получать обновления из базы MySQL, т.е. и в случае быстрого получения небольших обновлений. Единственное, что приходит в голову, когда получать все обновления не хочется — это в случае, когда данные меняются существенно быстрее, чем допустимый лаг для их потребителя, и объем изменений сравним с объемом самих данных. Например, если бесконечным потоком в таблицу льются поправки к каким-то коэффициентам, которые при этом медленно меняют сами коэффициенты, то выгоднее переселектить всю таблицу, чем получать все эти незначительные изменения.

        Однако в случае, если таблица небольшого размера, и ее селект существенно быстрее того временного лага, который допустим для ее обновления в читателе, мы используем регулярный переселект всей таблицы, поскольку это проще в реализации — не надо продумывать логику, как обновить данные (т.е. поддержать операции insert/update/delete), надо просто скачать новые и выкинуть старые. Переселекчивать при этом можно раз в минуту, раз в секунду, чаще — сколько надо. Мы так поступаем с таблицами, которые содержат какие-то настройки. Этот случай частично подпадает под «быстрое получение небольших обновлений», хотя и в случае сильных изменений небольших таблиц работает не хуже.

        Другой случай «быстрого получения небольших обновлений» — небольшие изменения больших таблиц. Понятно, что регулярный переселект тут не особо поможет, а libslave поможет, но вы спрашиваете, вероятно, про альтернативное решение без libslave, поскольку я упоминал, что сделать это «относительно легко». Поскольку решение, на мой взгляд, во многом зависит от конкретной задачи, попробую пофантазировать: пусть у нас есть один писатель, который пишет данные в таблицу, таблица большая, целиком находится в памяти большого количества демонов (т.е. БД используется ими как минимум для холодного старта), но обновляется по чуть-чуть, и нам хочется простого решения, как обновлять. Думаю, что самое простое по объему реализации, что я бы сделал, это вместе с записью данных в БД рассылал бы с писателя UDP пакеты с обновлениями всем зарегистрированным читателям. Поскольку обновлений немного, применять их должно быть просто, нагрузка будет небольшая во всех отношениях, пакеты не будут теряться, а обновления будут происходить синхронно на всех читателях. Это довольно упрощенное описание, но, по-моему, сделать такую рабочую модель можно.

        С ростом количества изменений могут начаться проблемы несинхронности применения обновлений демонами — где-то какая-то задержка, кто-то лагнул, и мы уже не можем рассчитывать, что большой поток обновлений можно рассылать всем демонам одинаково. Приходится принимать во внимание состояние каждого из них, трекать, что кому посылать. И тут уже (хотя на самом деле даже раньше) удобно использовать libslave, поскольку там все эти проблемы решены, и каждый читатель получит свой поток изменений независимо от состояния других читателей и скорости, с которой он способен их применять.

        Надеюсь, что мне удалось ответить на ваш вопрос.
        0
        Спасибо.

        Прошу прощения за «каверзную» формулировку :) Я подразумеваю случай, когда таблицы большие, общий объем изменений в них тоже немаленький, а потребителю требуется лишь небольшая часть из них. То есть случай, когда гонять ради этого по сети весь бинлог — однозначно overkill и чревато проблемами. Тут нужна модель publish-subscribe, как вы и заметили. Примеры — таблица с балансами, таблица с личными сообщениями пользователей и т.д. В «большой тройке» есть средства для этого на уровне СУБД; и я хотел узнать, что есть в MySQL. А если нет, то как это все-таки реализуют, если нужно.
          0
          Для фильтрации на стороне мастера одной базы вам поможет:
          --binlog-ignore-db

          Для фильтрации таблиц — сделайте промежуточный mysql-slave, в нём поставьте опции
          --replicate-ignore-table

          и уже со slave высасывайте libslave обновления

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

        Самое читаемое