Всем привет, меня зовут Михаил, я работаю Java/Kotlin разработчиком в ОТП Банке.
Все мы знаем про Redis. Часто используем его для in-memory кэша. В основном это ключ и значение. Но что, если можно пойти дальше и, как в том фильме, раскрыть все возможности Redis на 100%?
Сегодня я хочу поговорить о Sorted Set в Redis. Что это за зверь и как его использовать.
Буду показывать на примере таблицы лидеров. Это можно использовать для разных задач, но я выбрал именно эту.
Будем замерять время, ресурсы при нагрузках - все, как мы любим.
Присаживайтесь, будет интересно!
Какую проблему мы сегодня решаем?
Давайте представим, что в нашем приложении есть топ пользователей с самым большим балансом(рейтингом, сейчас неважно). Его нужно показывать всем, кто заходит в приложение. При этом баланс может часто меняться.
Класс сущности:
Класс сущности
@Getter @Setter @Entity @Builder @NoArgsConstructor @AllArgsConstructor @Table(name = "users", schema = "public") public class User implements Serializable { @Id @Column(name = "id") private UUID id; @Column(name = "name", nullable = false) private String name; @Column(name = "email", nullable = false, unique = true) private String email; @Column(name = "balance") private int balance; }
И есть простой код:
public interface UserRepository extends JpaRepository<User, Long> { List<User> findTop10ByOrderByBalanceDesc(); }
Давайте посмотрим, сколько у нас записей в бд:
SELECT COUNT(*) FROM users -- 1 017 087
На текущий момент у нас чуть больше 1 млн записей.
Также у нас имеется следующий индекс
CREATE INDEX idx_users_balance ON users(balance);
Вот с этими данными мы сейчас и будем работать.
Первый тест
Я буду использовать для замера времени и ресурсов либу, которую когда-то описывал тут - https://habr.com/ru/articles/1035238/ можно использовать все, что вашей душе угодно.
По ресурсам у меня apple m4 и выделено 4 гб на хип.
Все тесты подключены сейчас к нашей базе данных. Сначала я буду тестировать обычные SELECT:
@Test public void checkPostgresTimeOneRequest() { LoadTestReport loadTestReport = RunnableChecker.run(userService::findTop10); System.out.println(loadTestReport.toString()); } Total duration: 135 ms
Метод выполнился за 135 мс. Думаю, кто-то уже готов закрыть статью. Но не спешите - самое интересное впереди!
Теперь, когда мы поняли, что наш код уже работает быстро, давайте добавим нагрузки.В качестве requestCount используются виртуальные потоки для имитации одновременной нагрузки:
@Test public void checkPostgresTimeRps() { LoadTestReport loadTestReport = RunnableChecker.run( RunnableTesting.builder() .requestCount(Кол-во запросов) .task(userService::findTop10) .build() ); System.out.println(loadTestReport.toString()); }
Кол-во запросов | Время ответа мс | Memory |
5 000 | 679 ms ≈ 0,67 секунды | 158 MB |
10 000 | 1239 ms ≈ 1,2 секунды | 331 MB |
50 000 | 4277 ms ≈ 4,2 секунды | 1547 MB |
100 000 | 6301 ms ≈ 6,3 секунды | 2827 MB |
Как мы видим, с поиском Postgres справляется отлично, но что если мы хотим взять данные из середины? Или мы будем часто вставлять в базу значения
Второй тест
Теперь давайте доставать данные из середины, а не первые 10:
@Query(value = """ SELECT * FROM users ORDER BY balance DESC OFFSET :offset LIMIT :limit """, nativeQuery = true) List<User> findPageByBalanceDesc(@Param("offset") long offset, @Param("limit") int limit);
private static final long MIDDLE_OFFSET = 500_000L; private static final int PAGE_SIZE = 10; public List<User> findFromMiddle() { return userRepository.findPageByBalanceDesc(MIDDLE_OFFSET, PAGE_SIZE); }
@Test public void checkPostgresGetMiddleTime() { LoadTestReport loadTestReport = RunnableChecker.run( RunnableTesting.builder() .requestCount(Кол-во запросов) .task(userService::findFromMiddle) .build() ); System.out.println(loadTestReport.toString()); }
Давайте замерять:
Кол-во запросов | Время ответа | Heap |
1 000 | 22737 ms ≈ 22 секунды | 83 MB |
5 000 | 121552 ms ≈ 2 минуты | 203 MB |
10 000 | 262514 ms ≈ 4,3 минуты | 329 MB |
50 000 | 1509625 ms ≈ 25 минут | 1418 MB |
Здесь уже все гораздо хуже, конечно, тут еще проблема в том, что мы используем не keyset пагинация, а offset, но сейчас немного опустим это.
Но и поиск из середины будем честны выполняется не часто.
Третий тест
Теперь давайте более реальную картину, у нас много пользователей и они постоянно меняют свои балансы, также создаются новые пользователи:
@Modifying @Transactional @Query(value = """ UPDATE users SET balance = :balance WHERE id = ( SELECT id FROM users TABLESAMPLE SYSTEM (1) LIMIT 1 ) """, nativeQuery = true) int updateRandomBalance(@Param("balance") int balance);
public List<User> findRandomPage() { long offset = ThreadLocalRandom.current().nextLong(MIN_RANDOM_OFFSET, MAX_RANDOM_OFFSET + 1); return userRepository.findPageByBalanceDesc(offset, PAGE_SIZE); } public void updateRandomBalance() { int balance = ThreadLocalRandom.current().nextInt(1, 10_001); userRepository.updateRandomBalance(balance); } public void insertUser() { userRepository.save(buildUser()); } public User buildUser() { Random random = new Random(); int number = random.nextInt(10000) + 1; return User.builder() .id(UUID.randomUUID()) .email(UUID.randomUUID() + "@gmail.com") .balance(number) .name("Легенда") .build(); }
И теперь я запущу тест, который будет делать 10 операций чтения из рандомной страницы + 1 вставку/обновления:
@Test public void checkPostgresRandomPageWithWrites() { AtomicInteger step = new AtomicInteger(); LoadTestReport loadTestReport = RunnableChecker.run( RunnableTesting.builder() .requestCount(Кол-во запросов) .task(() -> battle( step, userService::findRandomPage, userService::insertUser, userService::updateRandomBalance )) .build() ); System.out.println(loadTestReport.toString()); } protected void battle(AtomicInteger step, Runnable read, Runnable insert, Runnable update) { if (step.getAndIncrement() % 11 == 10) { if (ThreadLocalRandom.current().nextBoolean()) { insert.run(); } else { update.run(); } } else { read.run(); } }
Кол-во запросов | Время ответа мс | Memory |
1 100 | 13339 ms ≈ 13,3 секунд | 88 MB |
5 500 | 75199 ms ≈ 1,25 минуты | 267 MB |
11 000 | 196320 ms ≈ 3,2 минуты | 336 MB |
55 000 | 2183521 ms ≈ 36 минут | 2318 MB |
Конечно, здесь все зависит от рандома, какая будет выбрана страница, какой глубины, но и я не стал доставать страницы после половины offset.
На настоящем проекте никогда не узнаешь, как им будут пользоваться
Выход на сцену redis sorted set
Sorted Set в Redis - это как обычный Set, но у каждого элемента есть числовой вес (score). Элементы всегда хранятся в отсортированном порядке по этому весу. Это идеально для таблиц лидеров, рейтингов, очередей с приоритетом -любых задач, где важен порядок. Добавление элемента: одна команда, Redis сам поддерживает сортировку.
Метод | Описание | Сложность |
|---|---|---|
| Добавляет элемент с указанным весом (score) | O(log N) |
| Возвращает позицию элемента по возрастанию (индекс) | O(log N) |
| Возвращает позицию элемента по убыванию | O(log N) |
| Возвращает элементы по индексам (по возрастанию веса) | O(log N + M), где M - количество возвращаемых элементов |
| Возвращает элементы по индексам (по убыванию веса) | O(log N + M), где M - количество возвращаемых элементов |
| Получает вес (score) элемента | O(1) |
| Удаляет элемент из коллекции | O(log N) |
| Увеличивает вес элемента на заданное значение | O(log N) |
Sorted Set внутри работает как сбалансированное дерево. Почти все операции - O(log N). Это как бинарный поиск: даже с 1 миллионом записей нужно всего около 20 шагов, чтобы найти позицию элемента.
Ну что, поехали тестировать!
Первый тест
По сути все будет один в 1, только будет вызываться redisService.
Давайте получим первые 10 элементов:
public List<User> getTop10() { return toUsers(redisTemplate.opsForZSet().reverseRange(LEADERBOARD_KEY, 0, 9)); } private List<User> toUsers(Set<String> members) { if (members == null) { return List.of(); } return members.stream() .map(this::getUser) .toList(); } @SneakyThrows private User getUser(String s) { return objectMapper.readValue(s, User.class); }
@Test public void shouldRedisTop10Time() { LoadTestReport loadTestReport = RunnableChecker.run( RunnableTesting.builder() .requestCount(Кол-во запросов) .task(redisUserService::getTop10) .build() ); System.out.println(loadTestReport.toString()); }
Кол-во запросов | Время ответа мс | Memory |
5 000 | 334.80 ms ≈ 0,3 секунды | 147 MB |
10 000 | 607 ms ≈ 0,6 секунды | 288 MB |
50 000 | 1786 ms ms ≈ 1,7 секунды | 1167 MB |
100 000 | 3074 ms ≈ 3 секунды | 1674 MB |
200 000 | 5876 ms ≈ 5,8 секунд | 2719 MB |
Видим, что прирост есть. Но будем честны - сейчас он не прям вау, чтобы бежать и переписывать всё на Redis.
Хотя с ростом нагрузки картина меняется. Postgres начинает задыхаться, а Redis продолжает работать стабильно. И вот тут уже видно: 100 000 запросов в Postgres ≈ 200 000 в Redis.
А это уже очень хороший результат.
Второй тест
Сейчас мы будем получать данные из центра:
private static final long MIDDLE_START = 500_000L; private static final long MIDDLE_END = 500_009L; public List<User> getFromMiddle() { return toUsers( redisTemplate.opsForZSet().reverseRange(LEADERBOARD_KEY, MIDDLE_START, MIDDLE_END)); }
@Test public void checkRedisMiddleRpsTime() { LoadTestReport loadTestReport = RunnableChecker.run( RunnableTesting.builder() .requestCount(Кол-во запросов) .task(redisUserService::getFromMiddle) .build() ); System.out.println(loadTestReport.toString()); }
Кол-во запросов | Время ответа | Heap |
1 000 | 217 ms ≈ 0,2 секунды | 89 MB |
5 000 | 431 ms ≈ 0,4 секунды | 153 MB |
10 000 | 590 ms ≈ 0,6 секунды | 268 MB |
50 000 | 1724 ms ≈ 1,7 секунды | 1113 MB |
100 000 | 3113 ms ≈ 3,1 секунды | 1790 MB |
500 000 | 15235 ms ≈ 15 секунд | 3842 MB |
Здесь уже видно колоссальное преимущество.
Postgres обрабатывал 50 000 записей за 14 минут. А Redis - 500 000 записей за 15 секунд.
Вдумайтесь: 50 тысяч за 14 минут против 500 тысяч за 15 секунд. Причём записей в 10 раз больше, а время - в 56 раз меньше.
Это другой уровень.
Третий тест
По факту третий тест самый интересный, так как он проверяет настоящую нагрузку, непонятно куда тыкнет пользователь, также все может меняться и создаваться новые пользователи:
private static final long MIN_RANDOM_OFFSET = 1L; private static final long MAX_RANDOM_OFFSET = 1_000_000L; public List<User> getRandomPage() { long offset = ThreadLocalRandom.current().nextLong(MIN_RANDOM_OFFSET, MAX_RANDOM_OFFSET + 1); return toUsers( redisTemplate.opsForZSet().reverseRange(LEADERBOARD_KEY, offset, offset + PAGE_SIZE - 1)); } @SneakyThrows public void addUser() { User user = userService.buildUser(); String userJson = objectMapper.writeValueAsString(user); redisTemplate.opsForZSet().add(LEADERBOARD_KEY, userJson, user.getBalance()); } @SneakyThrows public void updateRandomUser() { String member = redisTemplate.opsForZSet().randomMember(LEADERBOARD_KEY); if (member == null) { return; } User user = getUser(member); int newBalance = ThreadLocalRandom.current().nextInt(1, 10_001); user.setBalance(newBalance); redisTemplate.opsForZSet().remove(LEADERBOARD_KEY, member); redisTemplate.opsForZSet().add(LEADERBOARD_KEY, objectMapper.writeValueAsString(user), newBalance); }
Здесь я уже не боюсь проходить до самого низа редиса, ставлю лимит 1_000_000(знаю, что данных чуть больше, но сейчас это не так важно)
@Test public void checkRedisRandomPageWithWrites() { AtomicInteger step = new AtomicInteger(); LoadTestReport loadTestReport = RunnableChecker.run( RunnableTesting.builder() .requestCount(Кол-во запросов) .task(() -> battle( step, redisUserService::getRandomPage, redisUserService::addUser, redisUserService::updateRandomUser )) .build() ); System.out.println(loadTestReport.toString()); }
Кол-во запросов | Время ответа мс | Memory |
1 100 | 237 ms ≈ 0,2 секунды | 78 MB |
5 500 | 424 ms ≈ 0,4 секунды | 213 MB |
11 000 | 696 ms ≈ 0,7 секунды | 307 MB |
55 000 | 2311 ms ≈ 2,3 секунды | 1225 MB |
110 000 | 3660 ms ≈ 3,6 секунды | 2294 MB |
550 000 | 19867 ms ≈ 20 секунд | 3906 MB |
Postgres на 55 000 записей - 36 минут. Redis на 550 000 записей - 20 секунд.
Вывод
Сегодня я показал вам такого зверя, как Redis Sorted Set.
Конечно, применять его везде не получится. Всё зависит от ваших задач. И, как мы знаем, преждевременные оптимизации - зло.
Смотрите исходя из вашей нагрузки и потребностей пользователей. Иногда можно отделаться малой кровью - сделать простое решение на PostgreSQL, и оно будет отлично работать при своих нагрузках.
И важный момент: Redis не является основным хранилищем данных. Он не держит данные на диске как реляционная база. По умолчанию он хранит всё в памяти. Персистентность можно включить, но это не делает его полноценной заменой PostgreSQL. Держите это в голове, когда выбираете инструмент.
Выбирайте ваше решение от контекста и требований!
Всем хорошего дня и спасибо за чтение!)

