Горизонтальное масштабирование PHP-приложений, часть 2

В первой части данного цикла статей, мы обсуждали горизонтальное масштабирование прикладного уровня – масштабирование серверов для обработки PHP-кода с помощью балансировщика нагрузки и других средств. Мы рассмотрели совместное использование локальных данных, возможности оптимизации и азы балансировки нагрузки.

Однако в масштабировании нуждается не только прикладной уровень. Наше приложение в большой степени зависит от количества операций чтения/записи из/в нашу базу данных.

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

Оптимизация

Правильное индексирование вашей базы данных означает, что главные таблицы содержат только важные данные и ничего лишнего, а вся вторичная информация содержится в других таблицах (например, users_basic + users_additional + users_alt_contacts и так далее).

Этот прием известен, как шардинг (database sharding) – комплексная тема, требующая рассмотрения в отдельной статье. В этом случае, вы получите практически мгновенное исполнение запросов.

Все подобные приемы позволят вам обойти узкие места базы данных и ускорить её работу. Есть еще одна вещь, которая может помочь еще больше – кэширование запросов (query cache).

Как правило, серверы баз данных кэшируют результаты запросов и выдают готовые данные, если поступивший запрос ранее уже был выполнен. Это позволяет мгновенно отправлять данные клиенту (нашему приложению) из кэша, не дожидаясь повторной обработки запроса.

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

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

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

Группа серверов с пользовательскими аккаунтами не предполагает большого количества операций чтения/записи, по крайней мере, их не столько, сколько потребуют чаты или игровые функции, поэтому сюда мы можем выделить, к примеру, 5 серверов.

Для группы чатов, задействуем 10 серверов, так как одновременное взаимодействие миллионов пользователей в реальном времени в данном случае чрезвычайно важно. Но сердцем нашего приложения, разумеется, является сам игровой процесс, поэтому для этой группы стоит выделить наибольшее количество серверов – например, 20.

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

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

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

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

Репликация (MSR)

Репликация (Master-Slave Replication, MSR) это концепция построения современных, чаще всего встраиваемых, баз данных. Обратитесь к документации той или иной базы данных, чтобы узнать, поддерживается ли такая функция вашей БД.

MSR это процесс перенаправления всех операций записи с одного мастер-сервера (master) на несколько ведомых (slave). Ведомые сервера выполняют запрос и затем копируют данные с мастер-сервера.

Схема работы следующая:

  • Посетитель выполняет операцию записи в БД, например, меняет информацию в профиле;
  • Приложение отправляет запрос мастер-серверу БД;
  • Мастер-сервер выполняет запрос и перенаправляет его всем ведомым серверам;
  • Ведомые сервера выполняют запрос и теперь, одни и те же данные содержатся и на мастер- и на ведомых серверах;
  • Дальнейшее чтение выполняется на ведомых серверах.

Финальный шаг это самое важное в данной последовательности – операции чтения выполняются на ведомых серверах.

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

Функция MSR по умолчанию активна при установке современных версий MariaDB и MySQL.

Разделение операций чтения и записи

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

Когда необходимо разделить чтение и запись, вам необходимо создать отдельные подключения. Для записи, нам нужно подключиться к мастер-серверу, а для чтения – к одному из ведомых. Конечно, это можно сделать вручную.

При осуществлении операции записи, используется специальная мастер-конфигурация:

<?php
 $config = $this->getService('configProvider');
  $master = new PDO(
     'mysql:dbname=' . $config->db->master->name . ';host=' . $config->db->master->host,
     $config->db->master->user,
     $config->db->master->password
 );
 $statement = $master->prepare('ЗАПРОСЫ ДЛЯ ОБНОВЛЕНИЯ ЗАПИСЕЙ'); //...

Для чтения же, потребуется новое соединение, с другими значениями конфигурации:

<?php
 $config = $this->getService('configProvider');
  $slave = new PDO(
     'mysql:dbname='.$config->db->slave->name.';host='.$config->db->slave->host,
      $config->db->slave->user,
      $config->db->slave->password );
 $results = $slave->query('ЗАПРОСЫ ДЛЯ ОБНОВЛЕНИЯ ЗАПИСЕЙ');
//...

Если у нас несколько ведомых серверов и мы знаем их адреса, то можно добавить рандомизацию обращений к ним:

<?php
  $config = $this->getService('configProvider');
  //Берем случайный ведомый сервер из списка
  $slaveConfig = $config->db->slaves[array_rand($config->db->slaves)];
  $slave = new PDO(     'mysql:dbname='.$slaveConfig->name.';host='.$slaveConfig->host,      $slaveConfig->user,      $slaveConfig->password ); $results = $slave->query('ЗАПРОСЫ ДЛЯ ЧТЕНИЯ ЗАПИСЕЙ'); //...

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

В свою очередь для ведомых серверов нужен другой подход – создание функции рандомизации и вызова $this->getService(‘db’)->slave, который автоматически возвратит случайно выбранный из списка сервер.

В этом случае, вам не придется беспокоиться о ручном выборе, тем более, что в кластер могут добавляться новые slave-сервера – их нужно будет просто внести в конфигурационный файл и все будет работать.

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

Позаботьтесь о сохранении статистики использования CPU/RAM на ведомых машинах, чтобы убедиться в том, что класс управления чтением всегда выбирает наименее загруженный сервер. Этот абстрактный класс должен всегда выбирать для соединения рабочий и незагруженный сервер.

Ведь вам требуется, чтобы считывание из базы данных было полностью прозрачно для конечного пользователя, но, в то же время, администратор должен знать, с каким ведомым сервером возникла проблема:

<?php
  // ... some class, some connect() method
  $config = $this->getService('configProvider');
 $mailer = $this->getService('mailer');
 $validSlaves = $config->db->slaves;
 $bConnectionSuccessful = false;
 while (!empty($validSlaves) && !$bConnectionSuccessful) {
     $randomSlaveKey = array_rand($validSlaves);
     $randomSlave = $validSlaves[$randomSlaveKey];
     try {
         $slave = new PDO(
             'mysql:dbname='.$randomSlave->name.';host='.$randomSlave->host,
              $randomSlave->user,
              $randomSlave->password
         );
         $bConnectionSuccessful = true;
     } catch (PDOException $e) {
         unset($validSlaves[$randomSlaveKey]);
         $mailer->reportError( ... ); // Отправка email администратору, запись в мастер-лог и так далее
     } catch (Exception $e) {
         unset($validSlaves[$randomSlaveKey]);
         $mailer->reportError( ... ); // Отправка email администратору, запись в мастер-лог и так далее
     }
 }  if ($bConnectionSuccessful) {
     return $slave;
 }
   $mailer->reportError( ... ); // Уведомление о неудачной попытке подключения к ведомому серверу
 throw new Exception( ... ); // или уведомление о возникновении исключения с деталями в сообщении

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

Синхронизация задержек чтения/записи

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

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

Однако, независимо от качества вашей кластерной сети, скорости отдельных машин или размера запросов, нет настроек, способных корректно выполнить следующий код:

<?php
$db = $this->getService('db');
$master = $db->get('master');
$slave = $db->get('slave');
 
// текущее значение равно 1 
$master->exec('UPDATE `some_table` SET `value` += 1 WHERE `id` = 1'); // запуск
$slave->query('SELECT `value` FROM `some_table` WHERE `id` = 1'); // значение все еще равно 1

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

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

Но если это требуется, то лучше используйте приближение – если вы ожидаете увеличения значения на единицу, то считайте оригинальное значение со slave-сервера, а затем добавьте 1 к результату, после чего результат можно вывести пользователю. В свою очередь мастер-сервер сможет продолжать обновление записей в фоновом режиме.

Падение мастер-сервера

А что же случится, если мастер-сервер упадет? Приведет ли это к остановке всей системы? К счастью, нет. Имеются решения для мастер-серверов, позволяющие обойти их отказ.

Как правило, метод решения данной проблемы заключается в том, чтобы поставить на место отказавшего мастер-сервера один из slave-серверов. Однако, нужно внести некоторые изменения в архитектуру, чтобы задействовать эту возможность.

Процедура следующая:

  • Мастер-сервер отказывает и через него больше нельзя выполнять операции записи;
  • Скрипт определяет случай отказа мастер-сервера, сообщает об ошибке и выбирает случайный slave-сервер из списка доступных;
  • Выбранный ведомый сервер получает сигнал о том, что он становится мастер-сервером;
  • Все остальные slave-сервера получают сигнал, что мастер-сервер изменился;
  • Возможны небольшие потери данных, если основной мастер-сервер упал во время очередной операции записи и не успел отправить данные на slave-сервера, либо если ведомый сервер не имел достаточно времени, чтобы выполнить запрос до того, как стать мастер-сервером;
  • Когда мастер-сервер вновь доступен, то следует оставить все функции ведомого сервера и выполнять только задачи мастера – делать что-либо еще, просто не имеет смысла, потому что за упущенными запросами не угнаться.

В MySQL и MariaDB, вы можете переключить slave в master с помощью команды CHANGE MASTER TO, обратная процедура осуществляется командами STOP SLAVE и RESET MASTER. Для выяснения подробностей, обратитесь к документации.

Заключение

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

Перевод статьи «Horizontal Scaling of PHP Apps, Part 2» был подготовлен дружной командой проекта Сайтостроение от А до Я.