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

    Проблема — в часы пик не справляется сетевой интерфейс с передаваемым объёмом данных.
    Из доступных вариантов решения был выбран сжатие хранимых данных
    tl;dr: экономия памяти >50% и сети >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 равные \x1f\x8b\x08" (по ним мы будем понимать, что строку необходимо распаковать).
    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 (\Predis\Profile\RedisProfile::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);
    }

    О результатах включения плагина на графиках одного из инстансов кластера:



    Как установить и начать использовать:
    composer require b1rdex/predis-compressible

    use B1rdex\PredisCompressible\CompressProcessor;
    use B1rdex\PredisCompressible\Compressor\GzipCompressor;
    use B1rdex\PredisCompressible\Command\StringGet;
    use B1rdex\PredisCompressible\Command\StringSet;
    use B1rdex\PredisCompressible\Command\StringSetExpire;
    use B1rdex\PredisCompressible\Command\StringSetPreserve;
    use Predis\Client;
    use Predis\Configuration\OptionsInterface;
    use Predis\Profile\Factory;
    use Predis\Profile\RedisProfile;
    
    // 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;
        },
    ]);

    Upd: ссылка на плагин на GitHub.
    Метки:
    Поделиться публикацией
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 14
    • +2
      Экономия >100%? Это как, оно ещё какие-то буферы памяти создаёт?
      • –1
        Посмотрите на график в конце поста. Было 5ГБ, стало 2ГБ → в 2.5 раза меньше.
        • +2
          я бы назвал это экономией в 60% (было 5ГБ, сэкономили 3ГБ = 60%). Но может у меня что-то не то с терминологией.
          • 0
            У вас 100 рублей. Чашка капучино стоит 100 рублей, но сегодня скидка 100%. Вопрос: Сколько вам будет стоить чашка капучино? Варианты ответов: а) 0 рублей б) 50 рублей в) 42 рубля.
            • 0
              Понял, спасибо.
              • 0
                Скидка 100% от цены?
                То есть 100% от 100 рублей — это 100 рублей, то есть кофе обойдется мне даром.
                А вот если скидка больше 100%, то мне за кофе еще и доплатить должны
          • +1
            Сюда бы еще график CPU приложить
            • +1
              На нём изменения в пределах статистической погрешности, меньше 1%.
              • 0
                а на машине с компрессором?
                • 0
                  О ней и речь. На сам redis по процессору это не влияет вообще никак.
                  • +1
                    статистически бесплатный гзип?
              • 0
                Недавно была аналогичная ситуация. Как ни странно, но упала не только загрузка памяти, но и ЦПУ, хотя прогнозировалось наоборот.
                • 0
                  это значит реализация работы с хранилищем ужасная.

                  я на скорую руку писал на nodejs простейший сервис, отдающий по http распакованные строчки из пайпа на вход приложения (stdin), тот в свою очередь передавался от упаковщика gzip, распаковывающего гигабайтовые текстовые файлы (что то типа логов). Так вот, в приложении не было ничего лишнего, собираем строки в массив, по запросу массив отдаем в виде одной строки с разделителем (знаю можно с оптимизировать по памяти, собирая строку сразу), так вот получившееся приложение нагружало процессор в четыре раза больше, чем собственно gzip (запросов в секунду было считанные десятки). Я почти уверен, что реализация http сервера и асинхронного чтения строк из файла на nodejs уже несет в себе большое количество оверхеда.
              • 0
                -

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.