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. Установлен
Далее давайте посмотрим, как мы можем исследовать набор данных, когда решим сохранить его в наборе 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. Сортированный набор
В дополнение к уникальному значению члена, sorted-sets позволяет нам вести счет рядом с ними. Что ж, в нашем случае использования мы можем оставить название вида спорта в качестве значения члена и вес мяча в качестве счета. Давайте используем команду 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
по принципу шаблона проектирования singleton .
Во-первых, давайте создадим частный конструктор для нашего клиента, который будет внутренне инициализировать 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
можно использовать для перебора наборовHscan
помогает нам перебирать пары поле-значение в хеше.Zscan
позволяет выполнять итерацию по элементам, хранящимся в отсортированном наборе.
Мы должны отметить, что на самом деле нам не нужна стратегия сканирования на стороне сервера, специально разработанная для связанных списков . Это потому, что мы можем получить доступ к членам связанного списка через индексы, используя команду lindex
или lrange
. Кроме того, мы можем узнать количество элементов и использовать lrange
в простом цикле для перебора всего списка небольшими порциями.
Воспользуемся командой SCAN
для сканирования ключей строкового типа. Чтобы начать сканирование, нам нужно использовать значение курсора как «0» , сопоставив строку шаблона как «ball*»:
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. Стратегии сканирования
Если мы заглянем в основные функции сканирования, предлагаемые классом Jedis
, мы найдем стратегии для сканирования различных типов коллекций:
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
требует два необязательных параметра, шаблон поиска и размер результата, чтобы эффективно управлять сканированием — ScanParams
делает это возможным . Для этой цели он использует методы match()
и count()
, которые в общих чертах основаны на шаблоне проектирования компоновщика :
public ScanParams match(final String pattern);
public ScanParams count(final Integer count);
Теперь, когда мы получили базовые знания о подходе сканирования Jedis
, давайте смоделируем эти стратегии с помощью интерфейса 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
может считывать элементы вместе с их оценками в виде Tuple
s:
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
:
- Курсор на основе строки
- Стратегия сканирования, такая как
scan
, 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 .