Key-value: моделирование через структуру ключа
В прошлом уроке мы начали уходить из реляционного мира — разобрали документные базы. Теперь спустимся к самой простой модели хранения из существующих: key-value. Её представители — Redis, Amazon DynamoDB (в простейшем режиме), Memcached. Простая модель не означает простое моделирование. Наоборот: чем примитивнее хранилище, тем больше работы и дисциплины ложится на проектировщика. Здесь query-first мышление из прошлого урока доходит до предела.
Главная идея урока: в key-value хранилище моделирование данных — это дизайн структуры ключа. У вас нет таблиц, нет колонок, нет индексов по произвольным полям. Есть только ключи и значения. И вся ваша инженерная мысль уходит в то, как устроены ключи.
Что такое key-value хранилище
Key-value хранилище — это, по сути, гигантский словарь (как dict в Python или Map в JavaScript). Вы кладёте значение по ключу и достаёте значение по ключу. Всё.
SET user:1001:name "Анна Петрова"
GET user:1001:name -> "Анна Петрова"
SET session:abc123 "{user_id: 1001, expires: ...}"
GET session:abc123 -> "{user_id: 1001, expires: ...}"
Ключ — строка. Значение — что угодно: строка, число, сериализованный JSON, иногда более сложные структуры (Redis умеет списки, хеши, множества). Операции тоже сводятся к минимуму: положить по ключу (SET), достать по ключу (GET), удалить по ключу (DEL).
И вот критическое ограничение: запросить данные можно только по ключу. В SQL вы пишете WHERE city = 'Москва' — база найдёт нужные строки. В key-value так нельзя. Нет WHERE, нет «найди все значения, где внутри есть Москва». Если вы не знаете точный ключ — вы не можете достать значение. Это ограничение и есть отправная точка моделирования.
Моделирование начинается с access pattern
Раз достать данные можно только по точному ключу, моделирование строится строго наоборот по сравнению с реляционным. Не «какие у меня сущности и связи», а «какие запросы будет делать приложение». Это и есть access pattern — конкретный способ, которым приложение обращается к данным.
Порядок проектирования key-value хранилища:
- Выписать все access patterns: какие именно данные приложение будет читать и писать.
- Для каждого access pattern спроектировать ключ так, чтобы нужные данные доставались за один
GET. - Сложить значения по этим ключам.
Шаг 1 здесь — самый важный и самый недооценённый. В реляционной базе если вы забыли про какой-то запрос — не беда, напишете новый SELECT с новым WHERE. В key-value забытый access pattern означает, что нужных данных просто не достать — для них не спроектирован ключ. Поэтому в key-value сначала исчерпывающе собирают все паттерны доступа, и только потом проектируют ключи.
Дизайн структуры ключа
Ключ в key-value — не случайная строка. Это спроектированная структура, которая кодирует, что за данные внутри. Сложился отраслевой приём: ключ собирают из осмысленных частей через разделитель (обычно двоеточие).
<сущность>:<идентификатор>:<атрибут>
user:1001:profile -> профиль пользователя 1001
user:1001:cart -> корзина пользователя 1001
user:1001:orders -> список id заказов пользователя 1001
order:5501:details -> детали заказа 5501
product:SKU-100:stock -> остаток товара SKU-100
Почему именно так? Структурированный ключ решает три задачи. Первая — детерминированность: зная сущность и id, приложение само соберёт нужный ключ по шаблону, без обращения к какому-либо каталогу. Чтобы достать корзину пользователя 1001, код строит user:1001:cart — и делает GET. Вторая — отсутствие коллизий: префикс сущности (user:, order:) разводит пространства имён, ключ пользователя и ключ заказа не столкнутся. Третья — группировка: общий префикс user:1001: собирает все данные одного пользователя под одним «зонтиком», что удобно для управления (например, удалить всё про пользователя по префиксу).
Ключевой навык — проектировать ключ под access pattern. Разберём на примере.
Access pattern: «показать корзину пользователя». Приложение знает user_id. Проектируем ключ user:{user_id}:cart, кладём туда сериализованную корзину. Запрос — один GET user:1001:cart.
Access pattern: «показать заказы пользователя за 2026 год». Знаем user_id и год. Проектируем ключ user:{user_id}:orders:{year} — user:1001:orders:2026. Год в ключе позволяет достать заказы конкретного года напрямую, не перебирая все.
Когда access pattern не ложится в один ключ
Иногда нужный запрос не покрывается одним ключом напрямую. Классический приём — завести дополнительный ключ-индекс.
Пример. Access pattern: «найти пользователя по email» (при логине). Основные данные лежат по user:{user_id}:profile, но при логине известен email, а не id. Решение — второй ключ, который отображает email в id:
# основной ключ — данные по id
user:1001:profile -> {name: "Анна", email: "[email protected]", ...}
# ключ-индекс — email отображается в id
email_index:[email protected] -> "1001"
# логин в два шага:
# 1) GET email_index:[email protected] -> "1001"
# 2) GET user:1001:profile -> данные пользователя
Вы вручную построили то, что в реляционной базе делал бы индекс по столбцу email. В key-value нет автоматических индексов по полям — если нужен доступ по email, проектировщик заводит ключ-индекс сам. Это плата за простоту модели: гибкость доступа не дана из коробки, её строят руками, ключ за ключом, под каждый известный заранее access pattern.
Главный риск key-value моделирования — забытый access pattern. Если паттерн доступа не учтён при проектировании, под него нет ключа, и данные физически не достать без полного перебора хранилища (а это в проде недопустимо). Поэтому в key-value исчерпывающий сбор всех access patterns ДО проектирования — не формальность, а критический шаг. Меняется набор паттернов — приходится добавлять ключи и часто перекладывать данные.
Почему вообще key-value: цена за простоту
Возникает резонный вопрос: зачем мириться с такими ограничениями? Что даёт key-value взамен утраченной гибкости запросов?
Ответ — скорость и масштаб. Поскольку доступ всегда по точному ключу, хранилище не выполняет ни планирование запроса, ни сканирование, ни JOIN. GET по ключу — это, по сути, прямой просмотр в хеш-таблице, операция почти константного времени. Key-value хранилища обрабатывают огромный поток операций с очень малой задержкой. И они легко масштабируются: ключи распределяются по узлам по хешу (вспомните hash из модуля про Data Vault), нагрузка равномерно растекается.
Отсюда — типичные применения key-value: кэш (product:SKU-100:price с коротким временем жизни), пользовательские сессии (session:{token}), счётчики, очереди, профили под высоконагруженный доступ. Везде, где паттерн доступа простой и предсказуемый, а нужны экстремальная скорость и масштаб.
Это и есть осознанный компромисс. Реляционная база даёт гибкость запросов ценой более сложной обработки. Key-value отдаёт гибкость и взамен получает скорость и масштаб. Выбор хранилища — это выбор того, чем вы готовы пожертвовать; и моделирование под key-value есть искусство уложить все нужные access patterns в структуру ключей. В следующем уроке посмотрим на wide-column базы — следующий шаг сложности, где этот query-first подход развивается дальше.
Попробуй сам
Возьмите приложение — например, онлайн-кинотеатр с сущностями: пользователь, фильм, история просмотров, список «посмотреть позже».
- Выпишите 5-6 access patterns: «показать профиль пользователя», «показать список посмотреть позже», «продолжить просмотр фильма» и т.д. Старайтесь не пропустить ни одного — это критический шаг.
- Для каждого access pattern спроектируйте шаблон ключа из осмысленных частей через двоеточие.
- Один access pattern сделайте таким, что не ложится в один ключ напрямую (например, «найти пользователя по номеру телефона»). Спроектируйте для него ключ-индекс.
- Объясните, что произойдёт, если через полгода появится новый access pattern, который вы не предусмотрели. Почему в key-value это болезненнее, чем в SQL?