О Redis

2019-09-01

Я давно и успешно пользуюсь Redis, и другим рекомендую. Но всё никак про неё не писал. Исправляюсь.

Redis — это почти буквально «редиска» (которая на самом деле "radish"). И СУБД. Поэтому «она».

редиска

Redis — это in-memory СУБД типа ключ-значение. То есть она хранит все данные в оперативной памяти, и данные представлены в виде множества значений, каждое из которых можно получить по ключу.

Хранить данные в памяти нужно в первую очередь для скорости. И такие СУБД в первую очередь используются для кэшей. Собственно, ближайшим «конкурентом» Redis является Memcached. В AWS managed инстансы их обоих даже создаются одним и тем же сервисом ElastiCache.

Раз уж это кэш, то его и можно использовать как кэш. Например, настроив Cachable в Spring. Тогда результаты вызова почти любого метода будут сохраняться в кэш, и повторые вызовы с теми же аргументами будут возвращать результаты из кэша, что, в данном случае, гораздо быстрее.

    // этот метод приходится вызывать довольно часто
    @Cacheable(cacheNames = ["entity-ids"], keyGenerator = "redisCacheKeyGenerator")
    override fun getEntityIds(
        entities: Collection<String>?,
        entityTypes: Collection<String>?,
        // и прочие другие фильтры...
    ): Collection<String> {
        // довольно сложный запрос в БД по редко меняющимся данным...
    }

Кэши в Spring управляются через CacheManager. Для кэша в Redis есть своя реализация RedisCacheManager. Время жизни записей в кэше — это, в данном случае, фича Redis. Для каждой записи можно задать время жизни, и она будет автоматически удалена через указанное время. Каждая запись кэша в данном случае будет связана с вызовом метода с определённым набором аргументов.

    @Bean
    open fun redisCacheManager(
        @Qualifier("cacheRedis") redis: RedisOperations<Any, Any>   // это подключение к Redis
    ): CacheManager {
        val manager = RedisCacheManager(redis)
        manager.cacheNames = cacheRedisNames().names    // имена кэшей ("entity-ids") можно задать явно
        manager.setUsePrefix(true)      // добавлять префикс к ключам, чтобы отличать их от других ключей в том же Redis
        manager.setDefaultExpiration(cacheExpiration)   // время жизни по умолчанию
        return manager
    }

Здесь я захотел нарисовать кастомную сериализацию ключей кэша. Потому что дефолтная реализация вроде как не включает имя метода, что может привести к ненужным коллизиям.

    @Bean
    open fun redisCacheKeyGenerator(): KeyGenerator {
        return KeyGenerator { target, method, params ->
            val sb = StringBuilder()
            sb.append(target.javaClass.name)    // добавляем имя класса
            sb.append(method.name)              // и имя метода
            for (obj in params) {
                sb.append(obj?.toString() ?: "")    // и все аргументы
            }
            sb.toString()
        }
    }

Для работы с Redis в Spring, по аналогии с другими БД, используется RedisTemplate, реализующий интерфейс RedisOperations. Чаще всего используется RedisTemplate<String, String>, то есть и ключи и значения являются строками. Но здесь у нас кэш, и значением может быть любой объект, поэтому RedisTemplate<Any, Any>.

    @Bean
    open fun cacheRedis(
        serializer: Jackson2JsonRedisSerializer<Any>    // сериализатор для значений
    ): RedisOperations<Any, Any> {
        val template = RedisTemplate<Any, Any>()
        template.connectionFactory = cacheRedisConnectionFactory()  // как мы будем подключаться к Redis
        template.keySerializer = StringRedisSerializer()    // ключи у нас таки строки, наш генератор создаёт строки
        template.hashKeySerializer = serializer     // про hash поговорим попозже
        template.valueSerializer = serializer       // для значений используем специальный сериализатор
        return template
    }

Значения, которые сохраняются в кэш, должны быть сериализованы. По умолчанию используется обычная сериализация Java, которая требует, чтобы объекты реализовывали Serializable. Но у нас всякие сущности вовсе не всегда так сериализуются, но зато они вполне представимы в JSON. Поэтому создаём кастомный сериализатор в JSON с помощью Jackson.

    @Bean
    open fun jackson2JsonRedisSerializer(
        @Qualifier("objectMapper") objectMapper: ObjectMapper   // есть у нас уже всесистемный Jackson, который умеет сериализовывать все нужные классы
    ): Jackson2JsonRedisSerializer<Any> {
        val jackson2JsonRedisSerializer = Jackson2JsonRedisSerializer(Any::class.java)  // а это сериализатор, использующий Jackson
        val mapper = objectMapper.copy()
        mapper.disable(DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES)   // не падать, если при чтении встретилось свойство объекта, которое мы игнорируем
        mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)   // не падать, если при чтении встретилось неизвестное свойство объекта
        mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL)  // не пишем null значения, типа место экономим  
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY)   // писать все свойства объектов, а не только публичные
        mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL)    // добавлять в JSON информацию о конкретном Java типе, чтобы потом прочитать этот же тип
        jackson2JsonRedisSerializer.setObjectMapper(mapper)
        return jackson2JsonRedisSerializer
    }

Для подключения к Redis обычно явно указывают хост, порт и номер БД. Но существует также полуофициальный стандарт на URL вида "redis://". Вот его «парсинг» я и реализовал.

@Bean
    open fun cacheRedisConnectionFactory(): RedisConnectionFactory {
        log.info("Cache Redis: $cacheUrl")
        if (cacheUrl.scheme != "redis") {
            throw IllegalArgumentException("$cacheUrl must be redis:// url")
        }

        val jedis = JedisConnectionFactory()    // Java клиент к Redis называется Jedis
        jedis.hostName = cacheUrl.host
        jedis.port = if (cacheUrl.port == -1) 6379 else cacheUrl.port   // порт по умолчанию 6379
        jedis.database = cacheUrl.path?.split('/')?.getOrNull(1)?.toIntOrNull() ?: 0    // БД по умолчаню 0
        return jedis
    }

К Redis серверу подключаются по TCP, порт по умолчанию 6379. По этому TCP подключению клиент (синхронно) посылает команды и получает ответы. Так что тут тоже нужен пул коннекций. В Jedis пул есть.

При подключении можно указать номер БД. На самом деле в Redis может одновременно храниться несколько независимых наборов ключей. Они и называются базами данных и нумеруются с нуля. Обычно это редко используется, и по умолчанию вся работа происходит с набором ключей номер ноль. Но их там, на самом деле, создаётся с десяток (настраивается в конфигурации сервера). Для разных наборов данных можно выбрать разные номера БД, чтобы не мучаться с изобретением непересекающихся префиксов ключей.

Redis как кэш вполне быстр. Но только если ваш кэш находится в той же локальной сети или датацентре. Если же приложение у вас здесь, а кэш за океаном, всё становится значительно грустнее. Всё правильно, против физики не попрёшь. Round-trip по сети требует время. И, в случае большой сетевой задержки, реально может быть быстрее сходить в SQL базу, и получить много данных за один запрос, чем много раз опрашивать кэш. Кэш — это много round-trip, всегда.

кэш

Memcached — это прям тупо-тупо ключ и какое угодно значение. Для Memcached значение — это просто набор байт. А вот в Redis значения бывают некоторых разных интересных типов. И это прикольно.

Базовый тип данных в Redis называют String. На самом деле это любой набор байт. Длиной до 512 мегабайт. Можно непосредственно записывать и получать String по ключу. Можно инкрементировать и декрементировать значения, если в строке записано десятичное число, обычными ASCII цифрами, типа "123". А можно даже манипулировать отдельными битами этой «строки», что, как уверяет документация, позволяет хранить в Redis фильтры Блума.

В Redis есть списки (List). Под одним ключом можно хранить не одну строку, а список строк. Можно добавлять элементы в начало, конец, в произвольную позицию в списке. Можно читать элементы в начале, конце, в произвольной позиции в списке. Можно читать срез списка. Можно обрезать список. Для всего этого есть операции.

В Redis есть множества (Set). Это как списки, но они неупорядочены и хранят только уникальные значения. Можно находить пересечения, объединения и разность множеств. То есть реально взять и прочитать несколько ключей и пересечь прочитанные множества, и даже сохранить результат в другой ключ. Всё одной операцией.

Redis — единственная известная мне БД, где в документации указана сложность каждой операции. Пересечение множеств — это O(N*M), где N — это количество элементов в самом маленьком множестве, а M — количество пересекаемых множеств. Логично.

Получение значения по ключу (GET) — это O(1). Правильно, у нас же key-value БД, это вроде как большая хэш таблица. Получение одной записи должно быть константным, иначе это плохая хэш таблица.

А вот перечисление всех ключей (KEYS) — это уже O(N). Что тоже логично, нужно же перебрать все имеющиеся ключи. Но это также значит, что если вам нужно выбрать несколько связанных значений, нельзя искать их, перебирая ключи.

Решение: использовать Set. Заведите отдельную запись, с отдельным известным ключом, типа Set, которая будет «каталогом» ваших связанных записей. Когда добавляете запись, делайте ещё SADD в запись-каталог. Да, при записи будет на одну операцию больше. Зато потом, чтобы прочитать весь набор нужных записей, вы читаете (SMEMBERS) ваш «каталог», а потом читаете каждую нужную запись. Уже нет нужды перебирать все ключи в БД.

Собственно, такое комбинирование, создание дополнительные записей, ссылающихся на другие записи для их быстрого поиска, — это типичный подход при хранении сложных структур данных в key-value СУБД. Больше разных промежуточных ключей, чтобы искать другие ключи более эффективно. Ведь получить значение по ключу — это очень быстро.

баланс силы

В Redis есть хэши (Hash). Если само key-value хранилище является мапой (ассоциативным массивом, словарём, хэш таблицей) ключей в значение, но в Redis само значение тоже может быть мапой.
Получается мапа мапы. В принципе, если у вас только плоские объекты, и часто нужно получать или изменять только часть их свойств, то вполне можно хранить их в хэшах Redis, безо всякой дополнительной сериализации.

В Redis есть упорядоченные множества (Sorted set). Они ведут себя как обычные множества, хранят только уникальные значения, но дополнительно упорядочены по score. Score — это вещественное число, связанное с каждым сохранённым значением. В Sorted set удобно хранить, например, облако тегов с весами.

А я использовал Sorted set чтобы хранить последние уникальные детекции. Допустим у нас есть какие-то сенсоры, которые детектируют, когда мимо них проносят некоторые теги. Каждому сенсору назначим ключ и заведём запись типа Sorted set в Redis. В качестве значения множества будем указывать обнаруженный тег. А в качестве score — таймстамп (число секунд с начала эпохи). Теперь одной операцией ZREVRANGEBYSCORE можно получить все теги, обнаруженные данным сенсором за последние пять, десять, пятнадцать минут. Это часто именно то, что нужно показывать в так называемых real-time данных. При этом размер записи в Redis определяется лишь количеством уникальных тегов, но не тем, как долго этот сенсор работает. Весьма удобно.

В Redis есть и парочка других, более экзотических типов данных. Но я вам про них ничего не скажу, ибо не использовал.

Хоть Redis и хранит все данные в памяти, периодически он скидывает снапшоты на диск. Так что, при корректных шатдаунах, он вполне персистентен и будет надёжно хранить ваши данные.

Redis поддерживает master-slave репликацию. Для надёжности и для масштабирования по чтению. Когда вы подымаете Redis в ElastiCache, обычно и создаётся сразу парочка слейвов.

Redis может переназначать мастера, если оригинальный мастер сдох. Этим high availability занимается отдельная штука под названием Sentinel.

Ещё существует и Redis Cluster. Там есть и шарды, то есть можно хранить наборы данных, которые не помещаются в память одной машины.

Хоть оно вроде возможно, тем не менее я не слышал, чтобы Redis использовали как средство хранения конфигурации в распределённых системах, вместо ZooKeeper или Consul. Не знаю, почему.

А ещё в Redis есть Pub/Sub. Публикация-подписка. Поначалу я думал, что это возможность подписаться на изменения каких-нибудь ключей, и получать уведомления, когда значения ключей меняются. Оказалось, нет. Это просто возможность одному множеству клиентов, подключенных к серверу, подписываться на сообщения в некоемом топике. А другому множеству клиентов публиковать сообщения в топик. Произвольные бинарные сообщения. Просто ещё одна фича Redis, совершенно ортогональная нашим ключам-значениям.

Не то, чтобы это был полноценный брокер сообщений. Тут нет многих возможностей, предоставляемых более полноценными очередями сообщений. Но, если у вас уже есть Redis, и вам просто нужно что-то передавать из одного компонента в другой, можно воспользоваться и этим Pub/Sub, нечего плодить ещё сущностей.

А ещё в Redis есть зачатки транзакций.

А ещё в Redis можно выполнять на серверной стороне код на Lua. Становится понятно, откуда у Tarantool ноги растут.

лого

Пользуйтесь Redis. Он простой, быстрый и предсказуемый. Его можно использовать как просто внешний сетевой кэш, как быстрое хранилище сложных оперативных данных, как простой брокер для обмена сообщениями, и как много чего ещё.