«1. Обзор

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

В этом руководстве мы узнаем, как эффективно считывать все доступные ключи Redis, соответствующие определенному шаблону.

2. Исследуйте коллекции

Давайте представим, что наше приложение использует Redis для хранения информации о мячах, используемых в разных видах спорта. Мы должны иметь возможность видеть информацию о каждом шаре, доступном в коллекции Redis. Для простоты ограничим наш набор данных только тремя мячами:

    Крикетный мяч весом 160 г Футбольный мяч весом 450 г Волейбольный мяч весом 270 г

Как обычно, сначала очистим основы, работая над наивным подходом к изучению коллекций Redis.

3. Наивный подход с использованием redis-cli

Прежде чем мы начнем писать код Java для изучения коллекций, мы должны четко представлять, как мы будем это делать, используя интерфейс redis-cli. Предположим, что наш экземпляр Redis доступен по адресу 127.0.0.1 через порт 6379, чтобы мы могли изучить каждый тип коллекции с помощью интерфейса командной строки.

3.1. Связанный список

Во-первых, давайте сохраним наш набор данных в связанном списке Redis с именем balls в формате sports-name_ball-weight с помощью команды rpush:

% redis-cli -h 127.0.0.1 -p 6379
127.0.0.1:6379> RPUSH balls "cricket_160"
(integer) 1
127.0.0.1:6379> RPUSH balls "football_450"
(integer) 2
127.0.0.1:6379> RPUSH balls "volleyball_270"
(integer) 3

Мы можем заметить, что успешная вставка в список выводит новую длину списка. Однако в большинстве случаев мы будем слепы к действиям по вставке данных. В результате мы можем узнать длину связанного списка с помощью команды llen:

127.0.0.1:6379> llen balls
(integer) 3

Когда мы уже знаем длину списка, удобно использовать команду lrange, чтобы легко получить весь набор данных:

127.0.0.1:6379> lrange balls 0 2
1) "cricket_160"
2) "football_450"
3) "volleyball_270"

3.2. Set

Далее давайте посмотрим, как мы можем исследовать набор данных, когда решим сохранить его в наборе Redis. Для этого нам сначала нужно заполнить наш набор данных в наборе Redis с именем balls, используя команду sadd:

127.0.0.1:6379> sadd balls "cricket_160" "football_450" "volleyball_270" "cricket_160"
(integer) 3

Упс! У нас было повторяющееся значение в нашей команде. Но поскольку мы добавляли значения в набор, нам не нужно беспокоиться о дубликатах. Конечно, мы можем увидеть количество добавленных элементов из выходного значения ответа.

Теперь мы можем использовать команду smembers, чтобы увидеть всех членов набора:

127.0.0.1:6379> smembers balls
1) "volleyball_270"
2) "cricket_160"
3) "football_450"

3.3. Хэш

Теперь давайте воспользуемся структурой хеш-данных Redis для хранения нашего набора данных в хеш-ключе с именем balls, где поле хэша — это название вида спорта, а значение поля — вес мяча. Мы можем сделать это с помощью команды hmset:

127.0.0.1:6379> hmset balls cricket 160 football 450 volleyball 270
OK

Чтобы просмотреть информацию, хранящуюся в нашем хеше, мы можем использовать команду hgetall:

127.0.0.1:6379> hgetall balls
1) "cricket"
2) "160"
3) "football"
4) "450"
5) "volleyball"
6) "270"

3.4. Отсортированный набор

В дополнение к уникальному значению члена, отсортированные наборы позволяют нам вести счет рядом с ними. Что ж, в нашем случае использования мы можем оставить название вида спорта в качестве значения члена и вес мяча в качестве счета. Давайте воспользуемся командой zadd для сохранения нашего набора данных:

127.0.0.1:6379> zadd balls 160 cricket 450 football 270 volleyball
(integer) 3

Теперь мы можем сначала использовать команду zcard, чтобы найти длину отсортированного набора, а затем команду zrange, чтобы исследовать полный набор:

127.0.0.1:6379> zcard balls
(integer) 3
127.0.0.1:6379> zrange balls 0 2
1) "cricket"
2) "volleyball"
3) "football"

~~ ~ 3,5. Строки

Мы также можем рассматривать обычные строки ключ-значение как поверхностный набор элементов. Давайте сначала заполним наш набор данных с помощью команды mset:

127.0.0.1:6379> mset balls:cricket 160 balls:football 450 balls:volleyball 270
OK

Мы должны отметить, что мы добавили префикс «balls:», чтобы мы могли отличить эти ключи от остальных ключей, которые могут лежать в нашем Redis. база данных. Кроме того, эта стратегия именования позволяет нам использовать команду keys для изучения нашего набора данных с помощью сопоставления шаблонов префиксов:

127.0.0.1:6379> keys balls*
1) "balls:cricket"
2) "balls:volleyball"
3) "balls:football"

4. Наивная реализация Java

Теперь, когда мы разработали базовую идею соответствующего Redis команды, которые мы можем использовать для изучения коллекций разных типов, пора нам запачкать руки кодом.

4.1. Зависимость Maven

В этом разделе мы будем использовать клиентскую библиотеку Jedis для Redis в нашей реализации:

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>3.2.0</version>
</dependency>

4.2. Клиент Redis

«Библиотека Jedis поставляется с одноименными методами Redis-CLI. Однако рекомендуется создать клиент Redis-оболочку, который будет внутренне вызывать вызовы функций Jedis.

Всякий раз, когда мы работаем с библиотекой Jedis, мы должны помнить, что отдельный экземпляр Jedis не является потокобезопасным. Поэтому, чтобы получить ресурс Jedis в нашем приложении, мы можем использовать JedisPool, который представляет собой потокобезопасный пул сетевых подключений.

И, поскольку мы не хотим, чтобы несколько экземпляров клиентов Redis перемещались в любой момент времени в течение жизненного цикла нашего приложения, мы должны создать наш класс RedisClient по принципу одноэлементного шаблона проектирования.

Во-первых, давайте создадим частный конструктор для нашего клиента, который будет внутренне инициализировать JedisPool при создании экземпляра класса RedisClient:

private static JedisPool jedisPool;

private RedisClient(String ip, int port) {
    try {
        if (jedisPool == null) {
            jedisPool = new JedisPool(new URI("http://" + ip + ":" + port));
        }
    } catch (URISyntaxException e) {
        log.error("Malformed server address", e);
    }
}

Далее нам нужна точка доступа к нашему одноэлементному клиенту. Итак, давайте создадим статический метод getInstance() для этой цели:

private static volatile RedisClient instance = null;

public static RedisClient getInstance(String ip, final int port) {
    if (instance == null) {
        synchronized (RedisClient.class) {
            if (instance == null) {
                instance = new RedisClient(ip, port);
            }
        }
    }
    return instance;
}

Наконец, давайте посмотрим, как мы можем создать метод-оболочку поверх метода lrange Jedis:

public List lrange(final String key, final long start, final long stop) {
    try (Jedis jedis = jedisPool.getResource()) {
        return jedis.lrange(key, start, stop);
    } catch (Exception ex) {
        log.error("Exception caught in lrange", ex);
    }
    return new LinkedList();
}

Конечно, мы можем следовать та же стратегия для создания остальных методов-оболочек, таких как lpush, hmset, hgetall, sadd, smembers, keys, zadd и zrange.

4.3. Анализ

Все команды Redis, которые мы можем использовать для изучения коллекции за один раз, естественно, в лучшем случае будут иметь временную сложность O(n).

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

Итак, мы должны отметить, что мы ограничиваем наш наивный подход, чтобы использовать его только в целях отладки.

5. Основы работы с итераторами

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

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

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

Хотя общее время чтения в обоих случаях будет сравнимым, тем не менее, второй подход лучше, так как дает нам передышку.

Давайте посмотрим, как мы можем использовать подход на основе итератора для изучения коллекций Redis.

6. Сканирование Redis

Redis предлагает несколько стратегий сканирования для чтения ключей из коллекций с использованием подхода на основе курсора, который, в принципе, похож на закладку страницы.

6.1. Стратегии сканирования

Мы можем просканировать все хранилище коллекций ключей и значений с помощью команды Scan. Однако, если мы хотим ограничить наш набор данных типами коллекций, то мы можем использовать один из вариантов:

    Sscan можно использовать для итерации по наборам через элементы, хранящиеся в отсортированном наборе

Мы должны отметить, что нам на самом деле не нужна стратегия сканирования на стороне сервера, специально разработанная для связанных списков. Это потому, что мы можем получить доступ к членам связанного списка через индексы, используя команду lindex или lrange. Кроме того, мы можем узнать количество элементов и использовать lrange в простом цикле для перебора всего списка небольшими порциями.

Воспользуемся командой SCAN для сканирования ключей строкового типа. Чтобы начать сканирование, нам нужно использовать значение курсора как «0», совпадающую строку шаблона как «мяч*»:

127.0.0.1:6379> mset balls:cricket 160 balls:football 450 balls:volleyball 270
OK
127.0.0.1:6379> SCAN 0 MATCH ball* COUNT 1
1) "2"
2) 1) "balls:cricket"
127.0.0.1:6379> SCAN 2 MATCH ball* COUNT 1
1) "3"
2) 1) "balls:volleyball"
127.0.0.1:6379> SCAN 3 MATCH ball* COUNT 1
1) "0"
2) 1) "balls:football"

«

«При каждом завершенном сканировании мы получаем следующее значение курсора, которое будет использоваться в последующей итерации. В конце концов, мы знаем, что просмотрели всю коллекцию, когда следующее значение курсора равно «0».

7. Сканирование с помощью Java

К настоящему моменту у нас достаточно понимания нашего подхода, чтобы мы могли приступить к его реализации на Java.

7.1. Стратегии сканирования

public ScanResult<String> scan(final String cursor, final ScanParams params);
public ScanResult<String> sscan(final String key, final String cursor, final ScanParams params);
public ScanResult<Map.Entry<String, String>> hscan(final String key, final String cursor,
  final ScanParams params);
public ScanResult<Tuple> zscan(final String key, final String cursor, final ScanParams params);

Если мы заглянем в основные функции сканирования, предлагаемые классом Jedis, мы обнаружим стратегии для сканирования различных типов коллекций:

public ScanParams match(final String pattern);
public ScanParams count(final Integer count);

Jedis требует два необязательных параметра, search-pattern и result-size, для эффективного управления сканированием — ScanParams делает это возможным. Для этой цели он использует методы match() и count(), которые в общих чертах основаны на шаблоне проектирования компоновщика: эти стратегии через интерфейс ScanStrategy:

public interface ScanStrategy<T> {
    ScanResult<T> scan(Jedis jedis, String cursor, ScanParams scanParams);
}

Сначала давайте поработаем над простейшей стратегией сканирования, которая не зависит от типа коллекции и считывает ключи, но не значения ключей:

public class Scan implements ScanStrategy<String> {
    public ScanResult<String> scan(Jedis jedis, String cursor, ScanParams scanParams) {
        return jedis.scan(cursor, scanParams);
    }
}

Далее давайте выберем стратегию hscan, предназначенную для чтения всех ключей полей и значений полей определенного ключа хеша:

public class Hscan implements ScanStrategy<Map.Entry<String, String>> {

    private String key;

    @Override
    public ScanResult<Entry<String, String>> scan(Jedis jedis, String cursor, ScanParams scanParams) {
        return jedis.hscan(key, cursor, scanParams);
    }
}

Наконец, давайте создадим стратегии для наборов и отсортированных наборов. Стратегия sscan может считывать все элементы набора, тогда как стратегия zscan может считывать элементы вместе с их оценками в виде кортежей:

public class Sscan implements ScanStrategy<String> {

    private String key;

    public ScanResult<String> scan(Jedis jedis, String cursor, ScanParams scanParams) {
        return jedis.sscan(key, cursor, scanParams);
    }
}

public class Zscan implements ScanStrategy<Tuple> {

    private String key;

    @Override
    public ScanResult<Tuple> scan(Jedis jedis, String cursor, ScanParams scanParams) {
        return jedis.zscan(key, cursor, scanParams);
    }
}

7.2. Итератор Redis

Далее давайте набросаем строительные блоки, необходимые для создания нашего класса RedisIterator:

    Курсор на основе строк Стратегия сканирования, такая как сканирование, sscan, hscan, zscan Заполнитель для параметров сканирования Доступ к JedisPool для получения ресурса Jedis

Теперь мы можем продолжить и определить эти члены в нашем классе RedisIterator:

private final JedisPool jedisPool;
private ScanParams scanParams;
private String cursor;
private ScanStrategy<T> strategy;

На нашем этапе все готово для определения специфичных для итератора функций для нашего итератора. Для этого наш класс RedisIterator должен реализовать интерфейс Iterator:

public class RedisIterator<T> implements Iterator<List<T>> {
}

Естественно, нам необходимо переопределить методы hasNext() и next(), унаследованные от интерфейса Iterator.

Во-первых, давайте возьмем самый простой плод — метод hasNext() — поскольку лежащая в его основе логика проста. Как только значение курсора становится равным «0», мы знаем, что закончили сканирование. Итак, давайте посмотрим, как мы можем реализовать это всего в одну строку:

@Override
public boolean hasNext() {
    return !"0".equals(cursor);
}

Далее, давайте поработаем над методом next(), который выполняет тяжелую работу по сканированию:

@Override
public List next() {
    if (cursor == null) {
        cursor = "0";
    }
    try (Jedis jedis = jedisPool.getResource()) {
        ScanResult scanResult = strategy.scan(jedis, cursor, scanParams);
        cursor = scanResult.getCursor();
        return scanResult.getResult();
    } catch (Exception ex) {
        log.error("Exception caught in next()", ex);
    }
    return new LinkedList();
}

Мы должны отметить, что ScanResult дает не только результаты сканирования, но и следующее значение курсора, необходимое для последующего сканирования.

Наконец, мы можем включить функциональность для создания нашего RedisIterator в классе RedisClient:

public RedisIterator iterator(int initialScanCount, String pattern, ScanStrategy strategy) {
    return new RedisIterator(jedisPool, initialScanCount, pattern, strategy);
}

7.3. Чтение с помощью итератора Redis

Поскольку мы разработали наш итератор Redis с помощью интерфейса Iterator, вполне интуитивно можно читать значения коллекции с помощью метода next(), если hasNext() возвращает true.

Для полноты и простоты мы сначала сохраним набор данных, относящийся к спортивным мячам, в хеше Redis. После этого мы будем использовать наш RedisClient для создания итератора с использованием стратегии сканирования Hscan. Давайте проверим нашу реализацию, увидев это в действии:

@Test
public void testHscanStrategy() {
    HashMap<String, String> hash = new HashMap<String, String>();
    hash.put("cricket", "160");
    hash.put("football", "450");
    hash.put("volleyball", "270");
    redisClient.hmset("balls", hash);

    Hscan scanStrategy = new Hscan("balls");
    int iterationCount = 2;
    RedisIterator iterator = redisClient.iterator(iterationCount, "*", scanStrategy);
    List<Map.Entry<String, String>> results = new LinkedList<Map.Entry<String, String>>();
    while (iterator.hasNext()) {
        results.addAll(iterator.next());
    }
    Assert.assertEquals(hash.size(), results.size());
}

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

8. Заключение

Мы начали это руководство с намерением узнать, как мы можем прочитать все совпадающие ключи в Redis.

Мы обнаружили, что Redis предлагает простой способ чтения ключей за один раз. Несмотря на простоту, мы обсудили, как это создает нагрузку на ресурсы и поэтому не подходит для производственных систем. Копнув глубже, мы узнали, что существует подход на основе итератора для сканирования совпадающих ключей Redis для нашего запроса на чтение.

Как всегда, полный исходный код реализации Java, используемой в этой статье, доступен на GitHub.