String

Предпосылки: Что такое Redis, event loop.

Логические базы | Hash

Один ключ, одно значение, время жизни

Сессия с 30-минутным временем жизни. Счётчик запросов к API. Блокировка на операцию с заказом. Три разных задачи — но все сводятся к одному: положить значение под ключ, при необходимости задать TTL (time to live — время, через которое Redis автоматически удалит ключ), при необходимости добавить условие записи. STRING — базовый тип Redis, который покрывает все три случая. Значение — произвольные байты до 512 МБ: текст, число, сериализованный объект, бинарные данные.

Самый простой сценарий — сессия. Пользователь авторизовался, приложение сохраняет данные сессии с TTL в полчаса:

SET session:abc '{"user_id":42,"role":"admin"}' EX 1800
GET session:abc    -- → '{"user_id":42,"role":"admin"}'
TTL session:abc    -- → 1793 (осталось секунд)

SET key value EX seconds — одна атомарная операция: запись и установка TTL. Через 1800 секунд Redis удалит ключ автоматически.

Счётчик запросов — другой паттерн. Нужно атомарно увеличивать число и получать результат, без гонок между клиентами:

SET api:rate:user:42 0 EX 60    -- окно в 1 минуту
INCR api:rate:user:42            -- → 1
INCR api:rate:user:42            -- → 2
INCRBY api:rate:user:42 5        -- → 7

INCR — не два шага «прочитать, увеличить, записать», а одна атомарная операция в однопоточном event loop. Никакая другая команда не может вклиниться между чтением и записью. INCR возвращает новое значение — приложение сразу видит результат и может решить, превышен ли лимит.

Блокировка — третий паттерн. Два процесса пытаются обработать один заказ. Нужен механизм «запиши, только если ключа ещё нет»:

SET lock:order:42 "worker-1" NX EX 30
-- NX = запись только если ключа нет; EX 30 = TTL 30 секунд
-- → OK (захватили блокировку)
-- второй процесс:
SET lock:order:42 "worker-2" NX EX 30
-- → nil (ключ уже существует, блокировка не получена)

NX и EX в одной команде — критически важно. Если разделить на SETNX + EXPIRE, между ними возможен сбой: ключ будет создан, но TTL не установлен, и блокировка останется навсегда. Паттерны rate limiting и distributed lock подробнее рассмотрены в разделе паттернов.

TTL: когда данные должны исчезнуть

Хранить данные — полдела. Контролировать, когда они исчезнут, — вторая. Сессия без TTL живёт вечно и занимает память после того, как пользователь ушёл. Блокировка без TTL зависает навсегда, если процесс-владелец упал. Redis удаляет истёкшие ключи двумя механизмами, каждый из которых решает свою проблему.

Lazy expiration срабатывает при обращении к ключу: Redis проверяет TTL и, если время вышло, удаляет ключ и возвращает nil. Клиент никогда не получит данные с истёкшим TTL. Но если к ключу никто не обращается, он продолжает занимать память — мёртвый ключ без посетителей.

Active expiration решает именно эту проблему. 10 раз в секунду Redis выбирает случайную выборку из ключей с TTL и удаляет истёкшие. Если более 25% проверенных ключей оказались истёкшими, цикл повторяется немедленно. Механизм адаптивный: при массовом истечении (например, после пикового часа, когда тысячи сессий истекают одновременно) он ускоряется, не давая мёртвым ключам накапливаться. Без active expiration сервер с миллионами сессий терял бы гигабайты на ключи, к которым больше никто не обратится.

TTL — основа TTL-based инвалидации кэша: данные устаревают автоматически, без явного отслеживания изменений в origin.

Когда STRING становится расточительным

Один ключ на поле объекта — один overhead на каждое поле. Каждый ключ в Redis несёт ~70 байт служебных данных: метаданные объекта (redisObject — служебная обёртка Redis вокруг каждого значения), запись в глобальной хеш-таблице, запись в таблице TTL (если есть). Профиль пользователя из 10 полей (user:123:name, user:123:age, user:123:email, …) — это ~700 байт overhead сверх полезных данных.

Один Hash под ключом user:123 с полями name, age, email — один overhead на весь объект. Экономия памяти — основная, но не единственная причина: HASH даёт атомарное чтение и запись нескольких полей одной командой.

Под капотом: SDS

Redis нужны строки, которые знают свою длину и хранят бинарные данные. C-строки для этого не подходят: длина вычисляется обходом до нулевого байта (O(n)), а нулевой байт внутри данных обрывает строку.

SDS (Simple Dynamic String, src/sds.h) — обёртка над массивом байт, которая хранит длину строки и размер свободного буфера в заголовке. Эффект: STRLEN работает за O(1) — длина читается из заголовка, а не вычисляется. Бинарные данные (изображения, сериализованные protobuf, нулевые байты) хранятся безопасно — SDS ориентируется на длину, а не на терминатор.

При изменении значения SDS автоматически расширяет буфер. Стратегия роста: при маленьких размерах буфер удваивается, при размерах свыше 1 МБ прибавляется по 1 МБ. Свободное место после расширения позволяет последующим APPEND не выделять память заново, если новые данные вмещаются в буфер.

Три кодировки: int, embstr, raw

SDS определяет, как Redis хранит байты. Но если значение — число, зачем хранить байты? Redis оптимизирует представление в зависимости от содержимого.

int — если значение представимо как 64-битное целое. Redis хранит число прямо в указателе redisObject, без выделения отдельной памяти под строку. INCR и INCRBY работают арифметически, не конвертируя в строку и обратно. Для часто используемых малых чисел (0–9999) Redis держит разделяемый пул — тысячи ключей со значением 1 ссылаются на один и тот же объект.

embstr — для коротких строк (до ~44 байт на 64-bit системах). Метаданные объекта и данные SDS размещаются в одном непрерывном блоке памяти — одно выделение вместо двух, процессор читает обе части за одно обращение к памяти. Кодировка read-only: любая модификация (APPEND, SETRANGE) конвертирует значение в raw.

raw — для строк длиннее 44 байт. Метаданные и данные хранятся в двух отдельных блоках — два выделения памяти, два потенциально медленных обращения к памяти вместо одного.

Проверить кодировку можно командой OBJECT ENCODING key. Подробнее о redisObject и системе кодировок — в заметке о кодировках.

См. также

Sources


Логические базы | Hash