YDB и надёжность
Перед началом: Модули 1–5 (serverless-дизайн, Clean Architecture, Auth/API). Инструменты: YDB (serverless), YQL, Yandex Lockbox. Время: ~6 ч.
🔥 Инцидент: InternalServerError раз в час — и дубликаты в чате
Чат работает. Пользователи шлют сообщения, история доступна, real-time доставка через WebSocket. Команда выдыхает — и закрывает JIRA-борд.
Затем — баг-репорты. Раз в час YDB возвращает InternalServerError. Сообщения «теряются» — но потом
появляются дважды. Один пользователь видит «Привет!» два раза. Другой получает уведомление о трёх копиях
одного сообщения.
Разбор полётов: ретраи не настроены (клиент получает 500 и уходит), идемпотентности нет (YMQ доносит
сообщение второй раз — создаётся дубль), SELECT перед INSERT гонку не ловит. Один сбой YDB = каскад:
дубликаты у получателя, битая история, потерянное доверие. В этом модуле — модель данных, индексы,
идемпотентность через request_id и устойчивая обработка ошибок YDB.
Из M5: мы настроили локальный запуск и протестировали auth через curl. Теперь разберём хранилище детально — как создать таблицы, какие индексы нужны, как обеспечить идемпотентность.
📖 Теория
В этом модуле — модель данных чата в YDB, индексы под ключевые сценарии (история, автокомплит, комнаты пользователя), транзакции, идемпотентность и устойчивая обработка ошибок. Фокус: данные нельзя терять, а запросы — должны быть дешёвыми и предсказуемыми.
YDB (serverless) — SQL-совместимая база, оплата за RU/IOPS, автоматическое масштабирование.
Модель данных
Четыре таблицы:
- users — пользователи (
idPK,emailUNIQUE, хеш пароля). - rooms — комнаты (
idPK,type: p2p | group,created_by). - room_participants — состав комнат (составной PK
room_id+user_id,role). - messages — сообщения (
idPK,room_id,sender_id,request_id,created_at).
Создание таблиц (DDL)
Эти SQL-запросы выполняются при первом запуске (через миграции — см. «Локальная разработка» в M5):
sql-- YDB (YQL) CREATE TABLE users ( id Utf8, email Utf8, name Utf8, password_hash Utf8, created_at Datetime, PRIMARY KEY (id) ); CREATE INDEX idx_users_email GLOBAL ON users (email); -- UNIQUE-семантика через приложение CREATE TABLE rooms ( id Utf8, name Utf8, type Utf8, -- 'p2p' | 'group' created_by Utf8, created_at Datetime, PRIMARY KEY (id) ); CREATE INDEX idx_rooms_name GLOBAL ON rooms (name); -- автокомплит CREATE TABLE room_participants ( room_id Utf8, user_id Utf8, role Utf8, -- 'owner' | 'member' joined_at Datetime, PRIMARY KEY (room_id, user_id) ); CREATE INDEX idx_rp_user GLOBAL ON room_participants (user_id); -- комнаты пользователя CREATE TABLE messages ( id Utf8, room_id Utf8, sender_id Utf8, text Utf8, request_id Utf8, created_at Datetime, PRIMARY KEY (id) ); CREATE INDEX idx_msg_history GLOBAL ON messages (room_id, created_at); -- история (пагинация) CREATE INDEX idx_msg_dedup GLOBAL ON messages (room_id, request_id); -- идемпотентность
Почему
GLOBAL: YDB — распределённая БД. Глобальный индекс доступен для запросов с любого шарда. Уникальность(room_id, request_id)проверяется на уровне приложения (SELECT перед INSERT), а индекс обеспечивает быстрый поиск дубликатов.
Индексы под сценарии
Индексы в YDB — это отдельные таблицы; каждый индекс = стоимость записи и хранения. Проектируем под конкретные запросы, а не «на всякий случай»:
| Запрос | Индекс | Зачем |
|---|---|---|
| История сообщений комнаты | messages (room_id, created_at) | пагинация истории без full scan |
| Автокомплит по названию комнаты | rooms (name) | быстрый префиксный поиск |
| Комнаты пользователя | room_participants (user_id) | список «моих чатов» |
| Дедупликация / идемпотентность | messages UNIQUE (room_id, request_id) | защита от повторной обработки |
Антипаттерн: автокомплит, делающий full-scan по
rooms. На росте числа чатов latency и throttling растут линейно — это видно по метрикеydb.throttled. Поэтому сразу индекс поname.
Запросы (SQL)
Конкретные запросы, которые реализуют репозитории:
sql-- История сообщений (пагинация по курсору) DECLARE $room_id AS Utf8; DECLARE $cursor AS Datetime; -- created_at последнего загруженного (или NULL для первой страницы) SELECT id, sender_id, text, created_at FROM messages VIEW idx_msg_history -- явно используем индекс WHERE room_id = $room_id AND ($cursor IS NULL OR created_at < $cursor) ORDER BY created_at DESC LIMIT 20; -- Автокомплит (префиксный поиск) DECLARE $prefix AS Utf8; SELECT id, name, type FROM rooms WHERE name LIKE $prefix || '%' ORDER BY name LIMIT 10; -- Идемпотентность: проверка дубликата перед вставкой DECLARE $room_id AS Utf8; DECLARE $request_id AS Utf8; SELECT COUNT(*) > 0 AS exists FROM messages WHERE room_id = $room_id AND request_id = $request_id; -- Комнаты пользователя (для списка чатов) DECLARE $user_id AS Utf8; SELECT r.id, r.name, r.type, rp.role, rp.joined_at FROM room_participants AS rp INNER JOIN rooms AS r ON r.id = rp.room_id WHERE rp.user_id = $user_id ORDER BY rp.joined_at DESC;
Транзакции
Создание комнаты и добавление её создателя — одна транзакция (атомарность: не бывает «комнаты без владельца»):
goerr := txManager.Do(ctx, func(tx Tx) error { if err := roomRepo.Create(tx, room); err != nil { return err } member := &domain.Member{RoomID: room.ID, UserID: ownerID, Role: domain.RoleOwner} return memberRepo.Create(tx, member) })
SELECT ... FOR UPDATE — для блокировки строки при конкурентных записях (например, обновление счётчика
непрочитанных). В serverless-режиме YDB транзакции оптимистичные: кратко, без длинных блокировок.
Идемпотентность через request_id
Повторы (из YMQ, при ретраях клиента) не должны создавать дубли. Уникальный ограничение
(room_id, request_id) + проверка перед записью:
gofunc (r *YDBMessageRepository) Save(ctx context.Context, msg domain.Message) error { exists, err := r.existsByRequest(ctx, msg.RoomID, msg.RequestID) if err != nil { return err } if exists { return nil // уже обработано — идемпотентно } return r.insert(ctx, msg) // UNIQUE сработает как страховка при гонке }
request_id генерирует клиент (UUID) и передаёт в каждом запросе на отправку.
Обработка ошибок
К YDB нельзя относиться как к «просто базе» — бывают таймауты, throttling, сетевые сбои. Поэтому:
Повторы с экспоненциальным backoff + jitter — на временные сбои (throttling, таймаут):
goconst maxRetries = 4 var backoff = 100 * time.Millisecond for attempt := 0; attempt < maxRetries; attempt++ { err := op(ctx) if err == nil { return nil } if !isRetryable(err) { return err // не повторяемые — сразу наружу } sleep(ctx, backoff+randomJitter()) backoff *= 2 } return lastErr
Circuit breaker — если YDB «легла», не долбим её каждым запросом: размыкаем цепь на N секунд и даём «половину открытого» состояния для проверки восстановления. Иначе таймауты каскадно ложат сервис.
Fallback: при недоступности YDB сообщение сохраняем в очереди (YMQ) — оно дождётся восстановления и обработается. Это та самая связка из модуля 3.
Метрики для этого слоя:
ydb.query.duration,ydb.errors.total,ydb.throttled(см. модуль по observability).
Квоты и стоимость (RU/IOPS → ₽)
В serverless-режиме YDB тарифицирует запросные единицы (RU) и хранилище. Практические ориентиры:
- Чтение дешевле записи; full-scan дорог и прожорлив (поэтому индексы = деньги).
- Throttling = превышение выделенных RU → запросы замедляются/откладываются.
- Сценарий: 1000 сообщений/день × 30 дней ≈ 30 000 записей + чтения истории/автокомплита → укладывается в free-tier (1 000 000 RU/мес) с запасом.
Где прячутся деньги: автокомплит без индекса, выборка всей истории без пагинации, частые
SELECT *. Профилируйте запросы и следите за scan_reads — рост означает неэффективный запрос.
Компромиссы (Trade-offs)
| Решение | Плюс | Минус |
|---|---|---|
| Глобальные индексы | быстрый поиск с любого шарда | стоимость записи и хранения |
| UNIQUE через приложение | гибкость, нет блокировок | гонка при конкурентной записи |
| Курсор vs OFFSET | дёшево (20 строк, не 1020) | нельзя прыгнуть на страницу N |
| Оптимистичные транзакции | кратко, без длинных блокировок | retry при конфликте версий |
| Retry + backoff + jitter | переживаем throttling YDB | задержка на стороне клиента |
| Circuit breaker | не ложим сервис каскадно | недоступность в open-состоянии |
Вывод модуля: YDB нельзя «просто использовать». Идемпотентность, индексы и retry-стратегия — это часть дизайна, а не оптимизация на потом.
Практика
- Рассчитать стоимость хранения и чтения 1 млн сообщений в месяц (RU + storage).
- Написать воркер, обрабатывающий очередь задач с retry/backoff и логированием причин ошибок.
- Сэмулировать деградацию YDB (таймауты) и проверить, что сообщения копятся в YMQ и доходят после восстановления.
🧩 Проверь себя
🔗 Мостик к Модулю 7
Данные надёжны: индексы под сценарии, идемпотентность через request_id, транзакции для целостности,
retry/backoff/circuit breaker для устойчивости. Но чат без real-time — это просто форум. В Модуле 7
добавим real-time через WebSocket: Serverless Container как WS-хаб, hub с подписками на комнаты,
read/write pumps, graceful shutdown и переподключение клиентов при сбое.