- PVSM.RU - https://www.pvsm.ru -
Проблема — в часы пик не справляется сетевой интерфейс с передаваемым объёмом данных.
Из доступных вариантов решения был выбран сжатие хранимых данных
tl;dr: экономия памяти >100% и сети >50%. Речь пойдёт о плагине [1] для predis [2], который автоматически сжимает данные перед отправкой в redis.
Как известно, в redis используется текстовый протокол (binary safe) и данные хранятся в исходном виде. В нашем приложении в redis хранятся сериализованные php объекты и даже куски html кода, что очень хорошо подходит под саму концепцию сжатия — данные однородные и содержат много повторяющихся групп символов.
В процессе поиска решения было найдено обсуждение в группе [3] — разработчики не планируют добавлять сжатие в протокол… Значит будем делать сами.
Итак, концепция: если размер данных, переданных для сохранения в redis, больше N байт, то перед сохранением сжать данные с помощью gzip. При получении данных из redis проверить первые байты данных на наличие gzip заголовка и, если он найден, распаковать данные перед передачей в приложение.
Так как мы используем predis для работы с redis, то плагин и был написан для него.
Начнём с малого и напишем механизм для работы со сжатием — CompressorInterface
— методы для определения нужно ли сжимать, сжатия, определения нужно ли распаковать и самой распаковки. Конструктор класса будет принимать пороговое значение в байтах, начиная с которого включается сжатие. Этот интерфейс позволит реализовать вам любимый алгоритм сжатия самостоятельно, например ламповый WinRAR.
Логику проверки размера входных данных выносим в класс AbstractCompressor
, чтобы не дублировать её в каждой из реализаций.
abstract class AbstractCompressor implements CompressorInterface
{
const BYTE_CHARSET = 'US-ASCII';
protected $threshold;
public function __construct(int $threshold)
{
$this->threshold = $threshold;
}
public function shouldCompress($data): bool
{
if (!is_string($data)) {
return false;
}
return mb_strlen($data, self::BYTE_CHARSET) > $this->threshold;
}
}
Используем mb_strlen
для преодоления возможных проблем с mbstring.func_overload
и однобайтовую кодировку для предотвращения попытки автоматического определения кодировки из данных.
Делаем реализацию на основе gzencode [4] для сжатия, который имеет magic bytes равные x1fx8bx08"
(по ним мы будем понимать, что строку необходимо распаковать).
class GzipCompressor extends AbstractCompressor
{
public function compress(string $data): string
{
$compressed = @gzencode($data);
if ($compressed === false) {
throw new CompressorException('Compression failed');
}
return $compressed;
}
public function isCompressed($data): bool
{
if (!is_string($data)) {
return false;
}
return 0 === mb_strpos($data, "x1f" . "x8b" . "x08", 0, self::BYTE_CHARSET);
}
public function decompress(string $data): string
{
$decompressed = @gzdecode($data);
if ($decompressed === false) {
throw new CompressorException('Decompression failed');
}
return $decompressed;
}
}
Приятный бонус — если вы пользуетесь RedisDesktopManager [5], то он автоматически распаковывает gzip при просмотре. Я пытался посмотреть результат работы плагина в нём и, пока не узнал об этой особенности, считал что плагин не работает :)
В predis есть механизм Processor, который позволяет изменить аргументы команд до передачи в хранилище, его мы и будем использовать. К слову, на основе этого механизма в стандартной поставке predis есть префиксер, который позволяет динамически добавлять ко всем ключам некоторую строку.
class CompressProcessor implements ProcessorInterface
{
private $compressor;
public function __construct(CompressorInterface $compressor)
{
$this->compressor = $compressor;
}
public function process(CommandInterface $command)
{
if ($command instanceof CompressibleCommandInterface) {
$command->setCompressor($this->compressor);
if ($command instanceof ArgumentsCompressibleCommandInterface) {
$arguments = $command->compressArguments($command->getArguments());
$command->setRawArguments($arguments);
}
}
}
}
Процессор ищет команды, которые реализуют один из интерфейсов:
1. CompressibleCommandInterface
— показывает, что команда поддерживает сжатие и описывает метод для получения командой реализации CompressorInterface
.
2. ArgumentsCompressibleCommandInterface
— наследник первого интерфейса, показывает, что команда поддерживает сжатие аргументов.
Логика получилась странной, вам не кажется? Почему сжатие аргументов происходит явно и вызывается процессором, а логика по распаковке ответов нет? Взглянем на код создания команды, который использует predis (PredisProfileRedisProfile::createCommand()
):
public function createCommand($commandID, array $arguments = array())
{
// вырезаны проверки и поиск реализации команды
$command = new $commandClass();
$command->setArguments($arguments);
if (isset($this->processor)) {
$this->processor->process($command);
}
return $command;
}
Из-за этой логики у нас появилось несколько проблем.
Первая из них заключается в том, что процессор может повлиять на команду только после того, как она уже получила аргументы. Это не позволяет передать в неё какую-то внешнюю зависимость (GzipCompressor
в нашем случае, но это мог быть и какой-то другой механизм, который нужно инициализировать снаружи predis, например система шифрования или механизм для подписи данных). Из-за этого появился интерфейс с методом для сжатия аргументов.
Вторая проблема заключается в том, что процессор не может повлиять на обработку командой ответа сервера. Из-за этого логика по распаковке вынуждена находиться в CommandInterface::parseResponse()
, что не совсем корректно.
Две эти проблемы в совокупности привели к тому, что внутри команды хранится механизм для распаковки и сама логика распаковки не явная. Думаю процессор в predis должен быть поделён на два этапа — препроцессор (для трансформации аргументов до отправки на сервер) и постпроцессор (для трансформации ответа от сервера). Я поделился этими мыслями с разработчиками predis.
use CompressibleCommandTrait;
use CompressArgumentsHelperTrait;
public function compressArguments(array $arguments): array
{
$this->compressArgument($arguments, 1);
return $arguments;
}
use CompressibleCommandTrait;
public function parseResponse($data)
{
if (!$this->compressor->isCompressed($data)) {
return $data;
}
return $this->compressor->decompress($data);
}
О результатах включения плагина на графиках одного из инстансов кластера:
Как установить и начать использовать:
composer require b1rdex/predis-compressible
use B1rdexPredisCompressibleCompressProcessor;
use B1rdexPredisCompressibleCompressorGzipCompressor;
use B1rdexPredisCompressibleCommandStringGet;
use B1rdexPredisCompressibleCommandStringSet;
use B1rdexPredisCompressibleCommandStringSetExpire;
use B1rdexPredisCompressibleCommandStringSetPreserve;
use PredisClient;
use PredisConfigurationOptionsInterface;
use PredisProfileFactory;
use PredisProfileRedisProfile;
// strings with length > 2048 bytes will be compressed
$compressor = new GzipCompressor(2048);
$client = new Client([], [
'profile' => function (OptionsInterface $options) use ($compressor) {
$profile = Factory::getDefault();
if ($profile instanceof RedisProfile) {
$processor = new CompressProcessor($compressor);
$profile->setProcessor($processor);
$profile->defineCommand('SET', StringSet::class);
$profile->defineCommand('SETEX', StringSetExpire::class);
$profile->defineCommand('SETNX', StringSetPreserve::class);
$profile->defineCommand('GET', StringGet::class);
}
return $profile;
},
]);
Автор: Anatoly Pashin
Источник [6]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/redis/260551
Ссылки в тексте:
[1] плагине: https://github.com/b1rdex/predis-compressible
[2] predis: https://github.com/nrk/predis
[3] обсуждение в группе: https://groups.google.com/forum/#!topic/redis-db/65a4YZwwuJs
[4] gzencode: http://php.net/gzencode
[5] RedisDesktopManager: https://github.com/uglide/RedisDesktopManager/
[6] Источник: https://habrahabr.ru/post/331474/?utm_source=habrahabr&utm_medium=rss&utm_campaign=best
Нажмите здесь для печати.