Раздел 37 · Redis с нуля

Память и политики вытеснения

junior~30 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В прошлом уроке мы разложили данные по нескольким серверам. Но память каждого из них всё равно ограничена, и платишь ты за каждый гигабайт. Потолок 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, embstrrawчисло помещается в 64 бита; строка до 44 байт
хешlistpackhashtableбольше hash-max-listpack-entries полей или значение длиннее 64 байт
множествоintset, listpackhashtableцелые числа: до 512 элементов; строки: до 128 элементов и не длиннее 64 байт
sorted setlistpackskiplistбольше 128 элементов или элемент длиннее 64 байт
списокlistpackquicklistсписок больше 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 ₽.

Оплата пока не подключена: премиум и курсы открываются промокодом.