Автоматическое сжатие хранимых данных в redis

в 2:59, , рубрики: gzip, php, predis, redis

Проблема — в часы пик не справляется сетевой интерфейс с передаваемым объёмом данных.
Из доступных вариантов решения был выбран сжатие хранимых данных
tl;dr: экономия памяти >100% и сети >50%. Речь пойдёт о плагине для predis, который автоматически сжимает данные перед отправкой в redis.

Как известно, в redis используется текстовый протокол (binary safe) и данные хранятся в исходном виде. В нашем приложении в redis хранятся сериализованные php объекты и даже куски html кода, что очень хорошо подходит под саму концепцию сжатия — данные однородные и содержат много повторяющихся групп символов.

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

Итак, концепция: если размер данных, переданных для сохранения в redis, больше N байт, то перед сохранением сжать данные с помощью gzip. При получении данных из redis проверить первые байты данных на наличие gzip заголовка и, если он найден, распаковать данные перед передачей в приложение.
Так как мы используем predis для работы с redis, то плагин и был написан для него.

Начнём с малого и напишем механизм для работы со сжатием — CompressorInterface — методы для определения нужно ли сжимать, сжатия, определения нужно ли распаковать и самой распаковки. Конструктор класса будет принимать пороговое значение в байтах, начиная с которого включается сжатие. Этот интерфейс позволит реализовать вам любимый алгоритм сжатия самостоятельно, например ламповый WinRAR.

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

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 для сжатия, который имеет magic bytes равные x1fx8bx08" (по ним мы будем понимать, что строку необходимо распаковать).

GzipCompressor

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, то он автоматически распаковывает 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.

Код типичной Set команды

use CompressibleCommandTrait;
use CompressArgumentsHelperTrait;

public function compressArguments(array $arguments): array
{
    $this->compressArgument($arguments, 1);

    return $arguments;
}
Код типичной Get команды

use CompressibleCommandTrait;

public function parseResponse($data)
{
    if (!$this->compressor->isCompressed($data)) {
        return $data;
    }

    return $this->compressor->decompress($data);
}

О результатах включения плагина на графиках одного из инстансов кластера:
Автоматическое сжатие хранимых данных в redis - 1
Автоматическое сжатие хранимых данных в redis - 2

Как установить и начать использовать:

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

Источник

Поделиться

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