В августе 2017 года Mail.Ru Group провёл чемпионат для backend-разработчиков HighLoad Cup. Суть конкурса проста: используя любой стек технологий написать как можно более производительное приложение под ограниченные серверные ресурсы обрабатывающее заранее определённый набор запросов и упаковать его в docker контейнер. Естественно, в лидерах оказались решения на C/C++, Java, GoLang. Что удивительно, PHP стек оказался производительнее NodeJS и Python решений.
Условия конкурса можно разделить на 2 задачи: быстрый веб-сервер и высокопроизводительное хранилище данных, желательно в оперативке. Будет очень хорошо, если вы предварительно ознакомитесь с условиями конкурса и вариантами решения, в данном обзоре я их не упоминаю.
Бинарно-упакованные данные в RAM
Одним из наиболее интересных трюков мне показалось решение хранить базу данных в оперативке в бинарно-упакованном виде, читать байты по нужному смещению и декодировать. В код из комментария по ссылке закрались несколько ошибок, в результате которых не происходит загрузки данных в память, а так же смещение рассчитывается не правильно и получаются битые данные. Привожу исправленный мной вариант:
<?php
ini_set("memory_limit","256M");
define('BLOCK_LENGTH', 13);
echo 'memory: ' . intval(memory_get_usage() / (1024*1204)) . "\n";
// fill 11 heaps with zeros
// 1 heap stores 1M visits (1 visit is 13 bytes, 1M visits - 13MB)
$heaps = [
str_repeat(pack('LLLc', 0, 0, 0, 0), 1000000),
str_repeat(pack('LLLc', 0, 0, 0, 0), 1000000),
str_repeat(pack('LLLc', 0, 0, 0, 0), 1000000),
str_repeat(pack('LLLc', 0, 0, 0, 0), 1000000),
str_repeat(pack('LLLc', 0, 0, 0, 0), 1000000),
str_repeat(pack('LLLc', 0, 0, 0, 0), 1000000),
str_repeat(pack('LLLc', 0, 0, 0, 0), 1000000),
str_repeat(pack('LLLc', 0, 0, 0, 0), 1000000),
str_repeat(pack('LLLc', 0, 0, 0, 0), 1000000),
str_repeat(pack('LLLc', 0, 0, 0, 0), 1000000),
str_repeat(pack('LLLc', 0, 0, 0, 0), 1000000)
];
echo 'memory: ' . intval(memory_get_usage() / (1024*1204)) . "\n";
$i = 1;
while($visitsData = @file_get_contents("visits_$i.json")) {
$visitsData = json_decode($visitsData, true);
foreach ($visitsData['visits'] as $k => $row) {
$id = $row['id'];
$heapIndex = intval(floor($id / 1000000));
$startPosition = ($id - 1) * BLOCK_LENGTH;
$data = pack('LLLc',
$row['user'],
$row['location'],
$row['visited_at'],
$row['mark']
);
for ($t = 0; $t < strlen($data); $t++) {
$heaps[$heapIndex][$startPosition + $t] = $data[$t];
}
}
echo "$i\n";
$i++;
}
unset($visitsData);
gc_collect_cycles();
echo 'memory: ' . intval(memory_get_usage() / (1024*1204)) . "\n";
/**
* Get visitor by id
*
* @param string[] $heaps
* @param int $id
* @return array
*/
function read_from_heap($heaps, $id) {
$heapIndex = intval(floor($id / 1000000));
$startPosition = $id - $heapIndex * 1000000;
$data = substr($heaps[$heapIndex], ($startPosition-1) * BLOCK_LENGTH, BLOCK_LENGTH);
var_dump(bin2hex($data) );
return unpack('Luser/Llocation/Lvisited_at/cmark', $data);
}
var_dump( read_from_heap($heaps, 1) );
var_dump( read_from_heap($heaps, 2) );
var_dump( read_from_heap($heaps, 99999) );
var_dump( read_from_heap($heaps, 100000) );
var_dump( read_from_heap($heaps, 100001) );
Скорость чтения и декодирования одной записи из такого справочника без нагрузки варьируется от 0.000002 до 0.000012 секунды. Для сравнения, скорость чтения и декодирования из APCu (apcu_add + apcu_fetch) находится в пределах 0.000002 — 0.000004, однако, не гарантирует хранения данных т.к. является кэшем. А Memcached по TCP выдаёт вообще 0.000012 — 0.000037. Скорость работы подобного решения под нагрузкой — тема для отдельной статьи.
Данный способ хранения отлично подходит для массива данных с записями фиксированной длины и подряд идущими идентификаторами, иначе эффективность такой структуры будет падать. Главный профит подобного хранения — отсутствие оверхедов структур данных на хранения мета-информации, например ключей, а так же огромный оверхед на zval.
Хранение оригинального справочника визитов в PHP-массивах занимало бы в памяти от 2 790 Мб до 5 510 Мб, в зависимости от формата, а оптимизации с помощью SplFixedArray снижают потребление до 704 Мб. Бинарный же вариант хранения потребляет всего 116 Мб.
Оптимизация веб-сервера и сети
Не менее удивительной оказалась идея написания web-сервера, который обрабатывает только необходимый минимум заголовков для выполнения условий конкурса, игнорируя всё, что ни на что не влияет.
Другим интересным решением оказался тюнинг сети, а именно TCP_NODELAY, TCP_DEFER_ACCEPT, TCP_QUICKACK, SO_SNDBUF и SO_RCVBUF. Однако, это уже не про PHP, а, например, про nginx.
Материалов по конкурсу огромное количество и изучение их того стоит!
Ссылки по теме:
- Новый чемпионат для backend-разработчиков: HighLoad Cup
- Документация к первому, пилотному чемпионату highloadcup
- Телеграм-чат в котором до сих пор идут обсуждения
- По следам highloadcup: swoole vs workerman, splfixedarray vs array
- Как написать хорошее решение для Highload Cup
- Мои 5 копеек про Highload Cup 2017 или история 9го места
- Список репозиториев с решениями на github