Готовим редиску

Если вы не любите redis, возможно, вы просто не умеете его готовить! Этот материал о не совсем интуитивных способах использования redis.

В примерах будет задействован язык Groovy, прежде всего, из-за его простоты и компактности, а также потому, что почему бы и не Groovy. Чтобы запустить пример, потребуется его скачать и установить. Скрипты можно выполнять в Groovy Console. Директива @Grab, используемая в примерах, скачает необходимые библиотеки без вашего участия. Из-за неё, к сожалению, скрипты не будут выполняться в веб-консолях (по соображениям безопасности владельцы серверов не дают запускать сторонние библиотеки).

Для написания этой статьи был использован стандартный docker-образ, при запуске docker run --name redis -p 6379:6379 redis:latest. Но можно скачать и установить redis с официального сайта самостоятельно. Решать вам.

Таблица результатов

Как известно, компания Bytex занимаемся разработкой и тестированием игр. А игры — это такое дело, в котором часто важно не только добиться результата, но и показать другим, что ваш результат во всех смыслах быстрее, выше, сильнее! Для этого применяются таблицы результатов и рейтинги, также именуемые Leaderboard и High scores. Это не очень очевидно простому игроку, и даже не всегда очевидно программисту, но создание адекватной таблицы результатов — задача довольно-таки непростая.

Предположим, что мы делаем игру на злобу дня, в которой игроки, например, соревнуются, кто съест больше допинга или завоюет больше всего медалей. Таким образом, в нашей очень простой игре, у игрока есть только имя и количество очков. Имена сделаем покороче, например 文明 или 玉兰. Или даже ещё короче: например, пусть у нас будет 26 игроков с именами от a до z и результаты их будут от 1097 до 1122. Воспользуемся типом данных Sorted Set:

@Grab(group='redis.clients', module='jedis', version='2.9.0')
import redis.clients.jedis.*; final jedis = new Jedis();
('a'..'z').each { l -> jedis.zadd("score", 1000+(int)l, l) }

Если мы в игре своевременно все результаты будем дублировать таким образом в Redis, то определение места игрока r и его соседей будет моментальным. Проверим:

final r = jedis.zrank("score", 'r')
jedis.zrevrangeWithScores("score", r-2, r+2).each {println "$it.element: $it.score" }

Результат:

t: 1116.0
s: 1115.0
r: 1114.0
q: 1113.0
p: 1112.0

Чтобы было понятнее, зачем так делать, на тестовой таблице с игроками от aaaaa до zzzzz (всего около 10 миллионов игроков), определение места игрока ccccc и его соседей занимает от 0 до 1 мс. При этом на том же компьютере в MySQL запрос только на определение места игрока уже выполняется более 1000мс. Запрос таков: SELECT COUNT(*) FROM score WHERE score > 232, при этом в таблице score есть индекс по полю score. Причём это — только определение места по количеству очков, а ведь это самое количество очков нам ещё требуется получить, да ещё и найти всех соседей. Кстати, чем меньше очков у игрока, тем дольше выполняется запрос в MySQL. Да, MySQL из docker с настройками по умолчанию, ценой ещё большего потребления памяти (для игроков aaaaa…zzzzz около 6.5 ГБ) возможно улучшить производительность. Чтобы исключить спекуляции, вот схема таблицы:

CREATE TABLE `score` ( `name` varchar(32) NOT NULL, `score` int(11) NOT NULL,
PRIMARY KEY (`name`), KEY `score` (`score`)) ENGINE=InnoDB;

Домашнее задание:

  • Кто хочет почувствовать себя тестировщиком, найдите ошибку в коде. Она тут есть, проверено!
  • Кто хочет почувствовать себя программистом, напишите код, который покажет таблицу результатов в таком формате: сначала идут 1-3 места, затем многоточие, затем игрок и его 2 ближайших соседа, затем многоточие, затем 3 аутсайдера. Убедитесь, что метод корректно работает и в случае, если игрок занимает первое место, и в случае, если игрок занимает последнее место.

Экономное хранение

Давайте представим такую ситуацию: у нас есть 10 миллионов игроков, данные о которых мы уже храним в своей базе данных. И тут возникает у нас жизненная необходимость что-то трудоёмкое для них рассчитать и сохранить где-то отдельно. Не так важно, что это за данные — например, пусть это будет статистика для интернет-публикации на сторонних сайтах. Для каждого игрока примерно 5000 байт информации. Мы покупаем для этого новый сервер… долго перемножаем в столбик 10 миллионов и 5000, отложим немножко на веб-сервис, на линукс, на кэш и дисковые буферы… Получилось 64 ГБайт оперативной памяти, да? И уже купив сервер, с удивлением обнаруживаем, что 64 ГБ нам оказалось мало. Как так-то?

Оказывается, что в настоящей жизни данные не просто лежат в памяти подряд, а ещё используются всякие дополнительные ухищрения. Например, раз на игрока примерно 5000 байт, надо хранить точное значение длины. И для имени игрока тоже. И само имя игрока (в среднем 12 символов). Итого на игрока уже не 5000 байт, а 5020 байт. Но тогда, чтобы получить данные по конкретному игроку, придётся «прошерстить» весь набор данных. А что если он последний в списке? На помощь приходит каталог, как в библиотеке. Условно: «Начинается на c, идите в третий зал. Вторая буква c? Тогда левая половина зала. Третья буква c? Стеллаж 5. Четвёртая буква c? Полка 3». Словом, приходится добавлять память на индекс. А redis ещё и не всегда возвращает освобождённую память в ОС, то есть и на это надо сделать небольшой запас. В итоге, если не использовать оптимизацию, придётся нам покупать сервер на 128 ГБ.

Но в этом примере работать со столь большими цифрами мы не будем. Возьмём логины от a10000 до a99999, а значение для них сгенерируем, ну, например такое: {"username": "a10000", "data": "12345678901234567890123456789012345678901234567890"}. 84 байта. У redis есть команда info memory, её мы будем использовать, чтобы понимать, а сколько же памяти мы на самом деле задействовали.

Самый простой вариант — использовать set и get. Это самый простой способ использования, так работает memcached или любой другой простейший кэш. Напишем код:

@Grab(group='redis.clients', module='jedis', version='2.9.0')
import redis.clients.jedis.*; final jedis = new Jedis(); def raw = 0;
jedis.select(1); jedis.flushDB(); final p = jedis.pipelined();
for (int i = 10000; i < 100000; i++) {
final k = "a$i", v = "{\"username\": \"$k\", \"data\": \"12345678901234567890123456789012345678901234567890\"}";
p.set(k, v);
// p.hset("hash", k, v);
raw += k.bytes.length + v.bytes.length
}
p.close()
"Used memory: ${jedis.info('memory').split("\n").find {it=~/used_/}.replaceAll('used_memory:','')}, raw: $raw"

Запустим его в таком виде. Получим результат:

Used memory: used_memory:14919560, raw data: 8100000

Попробуем закомментировать p.set и воспользоваться p.hset.

Результат: Used memory: used_memory:14919560, raw data: 8100000.

Видно, что буквально на ровном месте возникло 1.5 МБ экономии, по 17 байт с одной записи. Для 10 миллионов — 170 МБ экономии на ровном месте.

Подумаем ещё, и займёмся последовательной оптимизацией. Почему бы нам не сделать каждый ключ короче на 1 байт, а название хэша взять не hash, а a? В случае, если появятся логины на букву b, название хэша, конечно, станет b. Да, сэкономим мы не слишком много, с 10 миллионов записей всего лишь 10 МБ памяти, но всё-таки!

А что, если нам сделать побольше хэшей, например назвав их от a10 до a99? В каждом будет по 1000 записей. Результат Used memory: 13172712 , raw data: 8100000.

Ещё лучше! А что, если мы воспользуемся такой классной штукой, как ziplist? Попробуем выполнить в redis-cli вот это:

config set hash-max-ziplist-entries 1024
config set hash-max-ziplist-value 128

Зато по сравнению с самым простым интуитивным хранением данных, потребление памяти снизилось на треть. Кстати, фактически объём данных состоял из 90000 * 84 = 7560000 байт данных и 90000 * 5 = 450000 байт ключей. Убедиться в этом несложно, сохранив пустые строчки вместо 84 байт и сравнив, что получилось. То есть, если в самом начале потребление памяти составляло около 15000000 байт (ключи занимали около 7500000 байт в памяти), то в финале они стали занимать лишь примерно 2200000 байт. Можно предположить, что в нашей начальной ситуации данные занимали бы 50 ГБ, и ключи занимали бы ещё 20-25 ГБ. После аналогичной оптимизации, ключи стали бы отъедать 7-10 ГБ памяти, и скорее всего вписались бы в объём памяти сервера 64 ГБ. Мы удвоили значения по умолчанию. Запускаем тот же самый код, и… результат стал ещё лучше: Result: Used memory: 9702112, raw data: 8100000. При этом в теории ухудшилась производительность: поиск по ziplist будет происходить медленнее, чем по hashmap. Но зная возможности современных процессоров и учитывая то, что все данные всегда в памяти (читаются с диска при старте redis), так ли долго выполнить цикл от 0 до 999?

Внимательный читатель, возможно, заинтересовался, почему для hash-max-ziplist-entries выбрано число 1024, а не 1000? Ответ: по привычке. А почему такая привычка, можно догадаться, выполнив домашнее задание.

Домашнее задание:

  • Кто хочет почувствовать себя тестировщиком, найдите ошибку в тексте статьи! И, по возможности, проверьте, каким был бы расход памяти с использованием MySQL или PostgreSQL.
  • Кто хочет почувствовать себя программистом, уменьшите расход памяти ещё сильнее.

Выводы

Redis — это гораздо больше, чем просто key-value хранилище. Использование внутренних типов redis, таких как SortedSet, ZipList или HashMap позволит улучшить производительность или сберечь драгоценную память.

Материалы

Материалы к статье (скрипты на Groovy) можно найти на Github.

P.S. Не запускайте генерацию SQL в groovyConsole, придётся убивать приложение

Текст: Руслан Балькин, Senior Developer, Bytex