Инструменты пользователя

Инструменты сайта


development:database:sharding

Шардирование баз данных

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

Сложно сказано, однако, суть проста. Давайте посмотрим вначале на примерчик.

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

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

Предлагаю рассмотреть практический пример.

Постановка задачи

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

Необходимо хранить следующие данные:

  • название картинки
  • путь к картинке
  • ссылка на автора картинки
  • рейтинг картинки
  • дата публикации

Требуется организовать выборку картинок по автору.

В общем все просто и обыкновенно, но не забывайте, что у нас супер популярный ресурс.

"Классически" подход

Самым простым решением будет следующее.

1. Создаем базу данных `my_super_service`.

2. Создаем табличку `pictures`, со следующей структурой:

  • `photo_id` - идентификатор картинки, первичный, уникальный ключ (autoincrement)
  • `user_id` - ссылка на пользователя, можно сделать это поле индексом, т. к. выборки будут идти именно по этому полю
  • `title` - название картинки
  • `filename` - путь к файлу картинки
  • `rating` - рейтинг
  • `date` - дата публикации

3. Подключение к базе данных

PHP:
 
$connect = mysql_connect($server, $user, $password);
$mysql_select_db('my_super_service', $connect);

4. Получение записей

PHP:
 
$query = "SELECT *
  FROM `pictures`
  WHERE `user_id` = '" . $user_id . "'
  ORDER BY `date` DESC;";
 
$res = mysql_query($query, $connect);

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

Но мы помним, что у нас супер-пупер сервис и табличка `pictures` будет очень быстро расти. В какой-то момент сервер просто перестанет справляться со всеми запросами к базе данных. И тут на выручку может прийти шардирование.

Подход с применением шардирования баз данных

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

К примеру, мы уже не можем использовать `photo_id` из классического представления, так как мы объявили его autoincrement. Но баз то теперь может быть две, три или вообще сто и в каждой будет запись с одинаковым `photo_id` и в рамках нескольких таблиц он уже не будет уникальным. В данном случаи мы должны предусмотреть какой-то универсальный идентификатор картинки, который будет нести в себе и информацию о авторе картинки. Можно, на пример, использовать GUID.

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

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

Итак преобразуем процесс, у нас получится следующее:

1. Создаем начальную базу данных `my_super_service`, а можно создать сразу две `my_super_service0` и `my_super_service1`, для тестирования механизма шардирования.

2. Создаем табличку `pictures` в каждой базе данных, со следующей структурой:

  • `photo_id` - идентификатор картинки, уникальный ключ
  • `user_id` - ссылка на пользователя, индекс
  • `title` - название картинки
  • `filename` - путь к файлу картинки
  • `rating` - рейтинг
  • `date` - дата публикации

Как вы наверно обратили внимание в нашей табличке остался уникальный идентификатор `photo_id`. Надо понимать, что это поле должно быть уникальным в рамках всех баз данных, а не одной какой-то определенной. И еще раз напоминаю, так как шардирование мы решили проводить по полю `user_id`, и если у вас возникнет необходимость делать выборку по полю `photo_id` (что мы сейчас не рассмативаем), то наш идентификатор картинок должен быть комплексным и нести в себе информацию о идентификаторе пользователя.

3. Подключение к базе данных

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

Я предлагаю разработать скрипт, который будет работать по следующему алгоритму:

  1. получение строки запроса и хеша поля `user_id`
  2. определение базы данных содержащей необходимые данные
  3. подключение к базе данных и отправка запроса
  4. получение ответа
  5. закрытие соединения с базой данных

Далее мы рассмотрим одну из реализаций подобного скрипта. Условно назовем эту функцию sqlShard().

Для построения хеша поля `user_id` будем использовать функцию md5(). Вы конечно волны использовать любой другрой принцип построения хеша.

4. Получение записей

PHP:
 
$query = "SELECT *
  FROM `pictures`
  WHERE `user_id` = '" . $user_id . "'
  ORDER BY `date` DESC;";
 
$res = sqlShard($query, md5($user_id));

Скрипт подключения к базе данных

Наш скрипт на входе должен получить SQL запрос и некий хеш, на основании которого будет осуществлено подключение к нужно базе данных.

Давайте рассмотрим алгоритм поэтапно.

Распределение подключений к базе данных

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

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

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

PHP:
 
function codeGet($hesh, $length) {
  //так как один шестнадцатеричный разряд в двоичном формате имеет четыре разряда,
  //то для сокращения трудоемкости мы будем получать необходимое число шестнадцатеричный
  // разрядов для получения бинарного кода
  $data = substr($hesh, 32 - ceil($length / 4), ceil($length / 4));  
  $data = base_convert($data, 16, 2);
 
  //так как при конвертировании из одной системы в другую незначащие разряды (нули вначале) игнорируются,
  //а для нас они важны, то дополняем урезанные нули
  while(strlen($data) < ceil($length / 4) * 4) {
    $data = "0" . $data;
  }
 
  //возвращаем необходимое количество символов
  $code = substr($data, strlen($data) - $length, $length);
  return $code;
}

Получение настроек для подключения к базе данных

Простой

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

PHP:
 
$sql_config = array(
  '0' => array(
    'server' => 'server0',
    'name' => 'my_super_service0',
    'user' => 'user0',
    'pass' => 'pass0'
  ),
  '1' => array(
    'server' => 'server1',
    'name' => 'my_super_service0',
    'user' => 'user1',
    'pass' => 'pass1'
  )
);
 
$sql_code = codeGet($hesh, 1);
$sql_data = $sql_config[$sql_code];

Посложнее

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

PHP:
 
$sql_config = array(
  'length' => 2,
  '0' => array(
    '00' => array(
      'data' => array(
        'server' => 'server00',
        'name' => 'my_super_service00',
        'user' => 'user00',
        'pass' => 'pass00'
      )
    ),
    '10' => array(
      'data' => array(
        'server' => 'server10',
        'name' => 'my_super_service10',
        'user' => 'user10',
        'pass' => 'pass10'
      )
    )
  ),
  '1' => array(
    'data' => array(
      'server' => 'server1',
      'name' => 'my_super_service1',
      'user' => 'user1',
      'pass' => 'pass1'
     )
  )
);

Краткое пояснение. Поле 'length' - определяет максимальную глубину деления базы данных. Поле 'data' содержит настройки для подключения.

Для получения настроек напишем следующий алгоритм:

  • получаем первый / очередной разряд кода
  • проверяем подмассив на наличие поля 'data'
  • поля нет - на начало, поле есть - возвращаем данные
PHP:
 
function configGet($code) {
  $result = false;
 
  //текущий разряд
  $current_code = '';
 
  //временный массив - сюда на каждой итерации будем складывать рассматриваемый подмассив
  $tmp = $sql_config;
 
  for($i = $sql_config['length'] - 1; $i >= 0; $i--) {
    //получаем текущий разряд
    $current_code = substr($code, $i, 1) . $current_code;
 
    //получаем подмассив
    $tmp = $tmp[$current_code];
 
    //проверяем наличие поля 'data'
    if(isset($tmp['data'])) {
      $result = $tmp['data'];
      break;
    }
  }
 
  return $result;
}
 
$sql_code = codeGet($hesh, $sql_config['length']);
$sql_data = configGet($sql_code);

Подключение к базе и отправка запроса

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

PHP:
 
function sqlShard($query, $hesh) {
  //получаем данные для подключения
  $sql_code = codeGet($hesh, $sql_config['length']);
  $sql_data = configGet($sql_code);
 
  //подключаемся к базе данных
  $db_connect = mysql_connect($sql_data['server'], $sql_data['user'], $sql_data['pass']);
  $db_select = mysql_select_db('pictures');
 
  //отправляем заголовки (это уже как вам будет угодно)
  mysql_query("set names utf8", $db_connect);
  mysql_query("set character set utf8", $db_connect);
  mysql_query("set collation_connection='utf8_general_ci'", $db_connect);
 
  $result = mysql_query($query, $db_connect);
 
  mysql_close($db_connect);
 
  return $result;
}		

Резюме

В заключение я хочу дать несколько подсказок.

  • Будет здорово, если вы обернете представленный механизм в класс.
  • Скорее всего у вас возникнут проблемы с mysql_real_escape_string(), так как у вас не будет постоянного подключения к базе данных, и вряд ли вы будете давать доступ к базе данных анонимному пользователю без пароля. Можно однако использовать mysql_escape_string() или написать свою функцию экранирования спецсимволов.
  • По этой же причине возможно, возникнут проблемы с mysql_insert_id(). Тут вы можете сделать дополнительный выходной параметр, или возвращать значение в аргумент.
development/database/sharding.txt · Последнее изменение: 2015.09.19 05:29 — 127.0.0.1