В прошлом уроке мы разложили данные по нескольким серверам. Но память каждого из них всё равно ограничена, и платишь ты за каждый гигабайт. Потолок maxmemory и политики вытеснения мы разобрали в уроке про TTL и вытеснение, а INFO memory читали в обзоре продакшена. Сегодня заглянем внутрь: сколько весит один ключ, почему один и тот же набор данных занимает то 2 МБ, то 10 МБ, и куда девается память после удаления.
Чемодан и шкаф
Вещи можно сложить в чемодан плотно, одну к другой. Места уходит мало, но чтобы достать носки со дна, придётся перебрать всё сверху. А можно разложить по шкафу с полками: любая вещь под рукой, зато шкаф занимает полкомнаты.
Redis хранит маленькие значения как чемодан, а большие как шкаф. Маленький хеш или множество лежит одним сплошным куском памяти, где элементы идут подряд. Искать в нём приходится перебором, но на сотне элементов перебор быстрый, а памяти уходит в разы меньше. Когда элементов становится много, Redis сам перекладывает всё в «шкаф»: хеш-таблицу или список с пропусками, где поиск мгновенный, но каждый элемент тащит за собой указатели.
Способ хранения внутри Redis называет кодировкой. Тип ты видишь через TYPE, а кодировку через OBJECT ENCODING.
Сколько весит ключ
MEMORY USAGE показывает, сколько байт занимает ключ вместе со значением и служебными полями:
127.0.0.1:6379> SET counter 10
OK
127.0.0.1:6379> MEMORY USAGE counter
(integer) 56
127.0.0.1:6379> SET title "Master and Margarita"
OK
127.0.0.1:6379> MEMORY USAGE title
(integer) 72
Число 10 заняло 56 байт, строка из 20 символов 72 байта. Почти всё это накладные расходы: запись в таблице ключей, сам ключ, заголовок объекта. Отсюда первое правило: миллион крошечных ключей обходится дороже, чем кажется по размеру данных.
Общую картину даёт MEMORY STATS. Вот отрывок с сервера, где лежат 100 000 строковых ключей:
127.0.0.1:6379> MEMORY STATS
...
21) "db.0"
22) 1) "overhead.hashtable.main"
2) (integer) 5048856
...
31) "keys.count"
32) (integer) 100000
33) "keys.bytes-per-key"
34) (integer) 47
35) "dataset.bytes"
36) (integer) 4712336
...
overhead.hashtable.main это память на саму таблицу ключей: 5 МБ из примерно 10. Значения london по 6 байт занимают меньше, чем таблица, которая на них указывает.
Компактные кодировки и их пороги
У каждого типа есть компактная кодировка и обычная:
| Тип | Компактно | Обычно | Когда переходит |
|---|---|---|---|
| строка | int, embstr | raw | число помещается в 64 бита; строка до 44 байт |
| хеш | listpack | hashtable | больше hash-max-listpack-entries полей или значение длиннее 64 байт |
| множество | intset, listpack | hashtable | целые числа: до 512 элементов; строки: до 128 элементов и не длиннее 64 байт |
| sorted set | listpack | skiplist | больше 128 элементов или элемент длиннее 64 байт |
| список | listpack | quicklist | список больше 8 КБ |
Listpack и есть наш чемодан. Пороги задаются настройками, их видно через CONFIG GET:
127.0.0.1:6379> CONFIG GET hash-max-listpack-*
1) "hash-max-listpack-value"
2) "64"
3) "hash-max-listpack-entries"
4) "512"
В официальном образе redis:7 порог для хеша 512 полей, для sorted set и множества строк 128. На твоём сервере значения могут быть другими: их часто меняют в конфиге, поэтому проверяй у себя.
Посмотрим, как выглядит переход. Заполним рейтинг на 128 книг одним скриптом и добавим 129-ю:
127.0.0.1:6379> EVAL "for i = 1, 128 do redis.call('ZADD', KEYS[1], i, 'book:' .. i) end return redis.call('ZCARD', KEYS[1])" 1 sales:week
(integer) 128
127.0.0.1:6379> OBJECT ENCODING sales:week
"listpack"
127.0.0.1:6379> MEMORY USAGE sales:week
(integer) 1592
127.0.0.1:6379> ZADD sales:week 129 book:129
(integer) 1
127.0.0.1:6379> OBJECT ENCODING sales:week
"skiplist"
127.0.0.1:6379> MEMORY USAGE sales:week
(integer) 13993
Один новый элемент, и ключ вырос с 1592 до 13 993 байт, почти в девять раз. Обратно Redis не переключается: если удалить элементы, ключ останется в skiplist, пока его не пересоздать.
Приём с корзинами
Раз маленький хеш так экономен, можно складывать в хеши то, что обычно лежит отдельными ключами. Допустим, у магазина 100 000 покупателей, и для каждого хранится город. Первый способ: ключ на покупателя. Второй: покупателей делим на корзины по 100, корзина это хеш, номер покупателя внутри корзины это поле.
user:12345:city = london → HSET users:123 45 london
Номер корзины 12345 / 100 = 123, поле 12345 % 100 = 45. Проверим на пустом сервере, где до опыта было занято 1,41 МБ:
127.0.0.1:6379> EVAL "for i = 1, 100000 do redis.call('SET', 'user:' .. i .. ':city', 'london') end return 100000" 0
(integer) 100000
127.0.0.1:6379> INFO memory
# Memory
used_memory_human:10.00M
...
127.0.0.1:6379> FLUSHALL
OK
127.0.0.1:6379> EVAL "for i = 1, 100000 do redis.call('HSET', 'users:' .. math.floor(i / 100), tostring(i % 100), 'london') end return 100000" 0
(integer) 100000
127.0.0.1:6379> DBSIZE
(integer) 1001
127.0.0.1:6379> INFO memory
# Memory
used_memory_human:2.53M
...
Данные те же, а памяти почти в восемь раз меньше: около 8,6 МБ против 1,1 МБ сверх пустого сервера. Цена приёма:
- Корзина должна оставаться компактной. Выберешь корзины по 1000 полей при пороге 512, и хеши перейдут в
hashtable: выигрыш пропадёт. - Срок жизни ставится на ключ, а не на поле. В Redis 7.4 появился
HEXPIREдля полей хеша, но со старыми версиями и с Valkey надо проверять. - Код усложняется:
getCity(id)теперь вычисляет корзину. Приём стоит усилий, когда ключей миллионы и память правда дорогая.
урок из курса
Урок из курса «Redis с нуля». Курс покупается один раз и остаётся навсегда: 500 ₽.
Оплата пока не подключена: премиум и курсы открываются промокодом.