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, автоматическое масштабирование.

Модель данных

Схема таблиц чата в YDB: ключи, индексы, связи

Четыре таблицы:

  • users — пользователи (id PK, email UNIQUE, хеш пароля).
  • rooms — комнаты (id PK, type: p2p | group, created_by).
  • room_participants — состав комнат (составной PK room_id + user_id, role).
  • messages — сообщения (id PK, 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 — распределённая БД. Глобальный индекс доступен для запросов с любого шарда. Уникальность email и (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;

Транзакции

Создание комнаты и добавление её создателя — одна транзакция (атомарность: не бывает «комнаты без владельца»):

go
err := 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) + проверка перед записью:

go
func (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, таймаут):

go
const 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 и доходят после восстановления.

🧩 Проверь себя

1. Зачем request_id в таблице messages?
2. YDB (serverless) оплачивается по...
3. Транзакция в YDB при отправке сообщения — что включается?
4. YDB serverless pricing основан на...
5. Идемпотентность через request_id в YDB — как?

🔗 Мостик к Модулю 7

Данные надёжны: индексы под сценарии, идемпотентность через request_id, транзакции для целостности, retry/backoff/circuit breaker для устойчивости. Но чат без real-time — это просто форум. В Модуле 7 добавим real-time через WebSocket: Serverless Container как WS-хаб, hub с подписками на комнаты, read/write pumps, graceful shutdown и переподключение клиентов при сбое.