Всем привет, меня зовут Михаил, я работаю 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. Держите это в голове, когда выбираете инструмент.
Выбирайте ваше решение от контекста и требований!
Всем хорошего дня и спасибо за чтение!)