Выборка случайной записи из таблицы с 700*10^6 строк

в 14:54, , рубрики: mysql, mysql performance, php, sql, Разработка веб-сайтов

Многие ли из нас сталкивались на практике с этим модным словом "Big Data", работая в заурядных компаниях веб-разработчиками? Скорее вы, как и мы, разрабатываете каждый день одинаковые сайты на одинаковых CMS, часто даже не задумываясь об их производительности.

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

Это рассказ о том, как пара амбициозных веб-разработчиков впервые столкнулась с задачей обработки "больших данных".

image

Итак, что же хочет заказчик

Есть конечный набор логинов. Необходимо каждому новому пользователю генерировать случайный логин из этого набора. Логин должен быть уникальным для каждого пользователя, он формируется по шаблону XX999999, где X — буква английского алфавита, 9 — цифра от 0 до 9.

Задача была решена на уже существующем сайте, который работает на Apache (PHP 5.6) и СУБД MySQL.

Масштабы катастрофы

Для начала нужно было написать алгоритм генерации логинов и оценить масштабы катастрофы.
Сам алгоритм генерации очень прост.

$alphabet = range('A', 'Z');
$alphabetLength = count($alphabet);
for ($i = 0; $i < $alphabetLength; $i++) {
    for ($j = 0; $j < $alphabetLength; $j++) {
        $arLogins = [];
        for ($k = 0; $k < 1000000; $k++) {
            $k = strval($k);
            $arLogins[] = '("' . $alphabet[$i] . $alphabet[$j] . str_pad($k, 6, '0', STR_PAD_LEFT) . '")';
        }
        // insert 1 000 000 by single query
        $strSql = "INSERT INTO logins VALUES " . implode(',', $arLogins);
        $DB->Query($strSql);
    }
}

Оказалось, что на деле получится около 700 миллионов логинов. Стало быть, вариант генерировать их “на лету” тут не пройдет.

Алгоритм генерации “на лету”

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

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

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

value
AA000000
AA000001
AA000002
...

А потом вспомнили, что логин нужно выбрать случайный.

Первые шаги

Конечно, первое, что решили испробовать, это всем известный ORDER BY RAND() LIMIT 1. Результат заставил себя ждать, с сервером можно было попрощаться на неопределенное время. Наличие индекса оказалось совершенно бесполезно в этом случае.

SELECT `value` FROM `logins` ORDER BY RAND() LIMIT 1;

Что делать?

Пришло время узнать у гугла ответ на вопрос “Что делать?”.

Первое, что предлагает гугл — способы оптимизации с ORDER BY, но это сразу не для нас, так как они круты и производительны, только если у вас в базе несколько тысяч записей. Нашлось несколько способов оптимизации с JOIN и подзапросами, они не сработали по той же причине.

Все эти способы очевидно предназначены для того случая, когда речь идет об оптимизации времени выполнения запроса с 500 мс до 50 мс, в нашем же случае речь шла скорее о том, чтобы за 10 минут, пока выполняется запрос, не уронить сервер.

Однако все это было честно испробовано, stackoverflow проштудирован от корки до корки, но так и не дождались, пока выполнится запрос, так что о приросте производительности судить не можем :)

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

SELECT MAX(`id`) FROM `logins`;
SELECT `value` FROM `logins` WHERE `id` = <random id>;

Отличный вариант, работать должен идеально быстро. Однако у нас нет возможности добавить каждой записи целочисленный id, таблица и так уже перешагнула за 20 Гб, а ресурсы сервера не резиновые. Да и даже если бы такая возможность была, логины должны быть уникальными, а значит, когда мы отдаем пользователю очередной логин, из таблицы его нужно сразу удалить. Что будет, если наткнемся на уже несуществующий логин? Возвращаемся к варианту с огромным количеством циклов.

Следующий испробованный вариант — случайный OFFSET и LIMIT 1. Значение OFFSET генерирует PHP-сервер и подставляет его в запрос. Казалось бы, на стороне MySQL никакой сортировки и рандомизации нет, однако сам OFFSET не так прост. В случае генерации большого значения для смещения MySQL сначала переберет все строки до смещения и только потом отдаст нужную. Немного лучше сортировки, но в общем случае ждать результата можно очень долго. Да и выбор количества записей не такая уж быстрая операция.

SELECT COUNT(*) FROM `logins`;
SELECT `value` FROM `logins` OFFSET 123456789 LIMIT 1;

Новый подход

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

В голову приходят все те же два варианта — на стороне БД и на стороне PHP.

“Ну теперь то мы можем сделать случайную сортировку в MySQL, пользователь же не будет ждать” — подумали мы. Создали пустую копию таблицы, запускаем запрос:

INSERT INTO `new_logins` (SELECT * FROM `logins` ORDER BY RAND());

Но не тут то было. Чтобы отсортировать все записи в случайном порядке, MySQL выгрузит их все в оперативную память и только после этого отсортирует, а как мы уже выяснили выше — ресурсы сервера ограничены. Да и базу положить часов на 8 на рабочем сервере таким запросом не хотелось бы.
Тогда попробуем сортировку на PHP — set_time_limit(0) + консоль и дело в шляпе, пусть себе выполняется. Алгоритм генерации подразумевал вставку 1 миллиона записей за 1 запрос к БД.

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

Господин NoSQL

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

Если у кого-то был опыт использования NoSQL-хранилищ для соизмеримого количества данных, поделитесь в комментариях.

Свет в конце тоннеля

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

А что если вернуться к варианту с рандомизацией значения на стороне PHP-сервера? У нас есть поле varchar(8) и PRIMARY индекс. Мы не можем сгенерировать случайный логин и выбрать его из базы из-за вероятности попадания в “дыру” (уже удаленный логин) и последующего зацикливания, однако мы можем сравнивать строки. Почему бы не сгенерировать случайный логин и не выбрать те, которые больше него, добавив при этом LIMIT 1? Индекс здесь отлично поможет ускорить выборку. Пробуем — на этот раз результат не заставил себя ждать, меньше, чем за секунду, получаем нужную запись. Осталось только исключить один крайний случай — если логин, который сгенерировал PHP-сервер, последний в таблице. Тогда получим пустой результат и выберем из таблицы первый по порядку логин одним дополнительным запросом.

function generateRandomLogin()
{
    $alphabet = range('A', 'Z');

    $firstLetter = $alphabet[mt_rand(0, count($alphabet) - 1)];
    $secondLetter = $alphabet[mt_rand(0, count($alphabet) - 1)];
    $number = mt_rand(0, 999999);

    return $firstLetter . $secondLetter . $number;
}

function createLogin()
{
    $randomLogin = generateRandomLogin();

    $newLogin = $DB->Query('SELECT * FROM `logins` WHERE value > "' . $randomLogin . '" LIMIT 1')->Fetch();
    if ($newLogin) {
        // if login was found delete it from database
        $DB->Query('DELETE FROM `logins` WHERE `value`="' . $newLogin['value'] . '"');
        return $newLogin['value'];
    }

    // if login was last in table, select first
    $newLogin = $DB->Query('SELECT * FROM `logins` LIMIT 1')->Fetch();
    if (!$newLogin) {
        throw new Exception('All logins are already used');
    }
    $DB->Query('DELETE FROM `logins` WHERE `value`="' . $newLogin['value'] . '"');
    return $newLogin['value'];
}

Заключение

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

Автор: Максим

Источник

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


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js