- PVSM.RU - https://www.pvsm.ru -

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

Проблема — в часы пик не справляется сетевой интерфейс с передаваемым объёмом данных.
Из доступных вариантов решения был выбран сжатие хранимых данных
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, чтобы не дублировать её в каждой из реализаций.

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

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 [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.

Код типичной 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

Источник [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