Мы — небольшая команда, делаем Зархолд: карточную MMORPG в браузере. Каждый бой в ней — пошаговая дуэль колодами, а режимов много: дуэли, арена от 1×1 до 5×5, поле боя 5×5, подземелья на троих, рейды и мировые боссы на пятерых, бои в открытом мире. Сервер — TypeScript, Fastify и node-postgres, база — PostgreSQL за pgBouncer, файлы — Cloudflare R2.
До «Хроник» у каждого режима был свой итог в своей таблице — шесть разных мест. Нам захотелось одного открытого архива всех боёв: отчёт любого боя с числами по ходам, картам и целям, топы по каждому боссу с оценкой от 0 до 100, доля побед по классам и веткам талантов в PvP. Смотреть может кто угодно, без входа (золото, почта, учётки и адреса не отдаются никогда). Так появились «Хроники сражений». Расскажу, как они устроены и какие места оказались неочевидными.

Требования
Запись не влияет на бой. Ни задержка, ни ошибка «Хроник» не должны трогать итог боя, награды и рейтинг. Худший случай — бой не попал в архив.
Рейтинги не считаются по запросу. База небольшая и живая, тяжёлые агрегаты — только в фоне. Сайт и игра читают готовые маленькие таблицы.
Подробности — не в Postgres. Разбивка по картам, ходам и целям для рейда — это сотни значений на бой. Хранить их в базе дорого и незачем.
Один бой — одна запись, даже если он пришёл к нам двумя путями.
Общая схема
конец боя (итог уже закоммичен) └─ zapisatBojHroniki(paket) мгновенно, никогда не бросает └─ очередь в памяти (до 5000) └─ пачка до 25 боёв → одна транзакция Postgres ├─ справочники сборок (колода / таланты / вещи) ├─ шапка боя (hroniki_boj, разделы по месяцам) └─ строки бойцов (hroniki_bojec) └─ подробности → gzip JSON → R2 └─ не вышло → hroniki_outbox → повтор с паузой фоновая задача раз в 2 минуты └─ новые бои по курсору → маленькие таблицы рейтингов открытое API → только маленькие таблицы + кэш
1. «Выстрелил и забыл»
Запись зовут только там, где бой окончательно закончен и его итог уже закоммичен. Сама функция ничего не ждёт (код чуть упрощён):
export function zapisatBojHroniki(p: PaketBoya): void { try { if (!hronikiVklyucheny()) return; if (OCHERED.length >= MAKS_OCHEREDI) { OCHERED.shift(); // жертвуем самым старым statistikaHroniki.otbrosheno += 1; } OCHERED.push(p); statistikaHroniki.prinyato += 1; if (idet === null) { idet = new Promise<void>((resolve) => setImmediate(resolve)) .then(razobrat) .finally(() => { idet = null; if (OCHERED.length > 0) zapustitSnova(); }); } } catch { /* запись «Хроник» не должна мешать ничему */ } }
Разбор начинается на следующем витке цикла событий и идёт пачками по 25 боёв: одна транзакция на пачку вместо 25 маленьких. Очередь ограничена 5000 боями — если база надолго ляжет, процесс не съест память. Всё, что происходит, считают счётчики: принято, записано, склеено, отброшено, ошибок, ушло в R2, ушло в очередь отправки. Их видно в админке, и потеря не бывает тихой.
Есть и рубильник: переменная окружения выключает запись, и бои идут так, будто «Хроник» нет. Для случая, когда игрок хочет сразу поделиться ссылкой на бой, есть синхронный вариант — тот же путь, но он дожидается записи и возвращает номер боя (или null, если не вышло).
2. Что лежит в Postgres
В базе — только узкая шапка боя и строки живых героев и жителей мира с числами для рейтингов: урон, лечение, щиты, полученный урон, провокации и так далее. Враги, боссы, прислужники, все числа по картам, ходам и целям — в подробностях, в R2.
Шапка разбита по месяцам:
CREATE TABLE IF NOT EXISTS public.hroniki_boj ( id bigint NOT NULL DEFAULT nextval('public.hroniki_boj_id_seq'), nachalo timestamptz NOT NULL, konec timestamptz NOT NULL, vid text NOT NULL, -- duel | arena_1x1 … arena_5x5 | pole_boya | podzemelye | rejd | mirovoj_boss | mir -- … место, противник, сложность, ходы, победитель, версия движка … detali text NOT NULL DEFAULT 'net', -- r2 | outbox | net | poteryano PRIMARY KEY (id, nachalo) ) PARTITION BY RANGE (nachalo);
Зачем: старый месяц уезжает в архив или удаляется целиком и мгновенно, без DELETE по миллионам строк. Разделы на текущий и два следующих месяца создаёт SQL-функция — её зовёт миграция и раз в сутки фоновая задача. А раздел по умолчанию ловит строку, если месяц почему-то не создан: запись боя не падает никогда.
Первая неочевидная вещь: первичный ключ секционированной таблицы обязан включать ключ разбиения. Поэтому ключ — (id, nachalo), и обновления шапки мы ведём по обоим полям — так Postgres сразу идёт в нужный раздел, а не перебирает все:
UPDATE public.hroniki_boj SET detali = 'outbox' WHERE id = $1 AND nachalo = $2;
Номера для всей пачки берём одним запросом:
SELECT nextval('public.hroniki_boj_id_seq')::text AS id FROM generate_series(1, $1::int);
3. Справочники сборок
Колода, таланты и вещи героя от боя к бою почти не меняются, а хранить их хочется: в отчёте видно, чем именно человек играл. Поэтому каждая сборка лежит в справочнике один раз, под отпечатком:
export function otpechatokStroki(s: string): string { let h1 = 0x811c9dc5; // FNV-1a let h2 = 5381; // djb2 for (let i = 0; i < s.length; i++) { const c = s.charCodeAt(i); h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0; h2 = (Math.imul(h2, 33) + c) >>> 0; } return h1.toString(16).padStart(8, '0') + h2.toString(16).padStart(8, '0'); }
Два простых 32-битных хеша, склеенные в 16 шестнадцатеричных знаков. Это не криптография, но для справочника сборок хватает с запасом, считается быстро и одинаково в браузере и на сервере — функция живёт в общем пакете. Вставка — ON CONFLICT (otp) DO NOTHING, а чтобы не гонять в базу заведомо известные отпечатки, процесс помнит их в Set (и очищает его на 50 000 записях, чтобы память не росла).
4. Подробности — в R2
Подробности боя — JSON, сжатый gzip, под ключом вида hroniki/<мир>/<гггг>/<мм>/<дд>/<номер>.json.gz. Дата в ключе нужна, чтобы смотреть и чистить хранилище по дням, мир — потому что у каждого игрового мира свои рейтинги. R2 говорит на языке S3, поэтому клиент — обычный S3Client из AWS SDK с region: ‘auto’:
await getR2().send(new PutObjectCommand({ Bucket: vedroHroniki(), Key: klyuch, Body: telo, // уже gzip ContentType: 'application/json', ContentEncoding: 'gzip', }));
5. Outbox: когда R2 недоступен
Если положить подробности не вышло, уже сжатое тело ложится в таблицу hroniki_outbox вместе со временем следующей попытки, а у шапки боя detali = ‘outbox’. Фоновая задача берёт до 50 записей за проход и повторяет:
// Пауза растёт: 1, 2, 4 … минут, не больше 6 часов. const pauza = Math.min(360, 2 ** Math.min(9, row.popytok));
У очереди есть предел (по умолчанию 5000): самое старое удаляется, а у боя честно ставится detali = ‘poteryano’. Шапка и строки бойцов при этом целы — пропадают только подробности.
Самое приятное — чтение. Сервер берёт подробности из R2, а если там их нет — из outbox. Поэтому отчёт боя виден сразу, даже если R2 в эту минуту недоступен. В памяти сервера подробности живут 10 минут, а «не нашлось» — одну минуту, после чего сервер проверит снова.
6. Склейка: один бой — два пути
Часть боёв приходит к нам дважды. Например, сначала — запись без чисел, потом — итог со счётом. Или бой записан как выход героя, а потом его доиграли во второй вкладке. Для этого есть таблица ключей склейки:
INSERT INTO public.hroniki_klyuch (klyuch, boj_id, nachalo) VALUES ($1, $2, $3) ON CONFLICT (klyuch) DO NOTHING RETURNING boj_id::text;
Если RETURNING вернул строку — бой новый. Если пусто — бой уже есть: второй путь дописывает недостающие поля шапки через COALESCE, а если первый пришёл без чисел, а второй — со счётом, числа дописываются в тот же бой (строки бойцов и подробности) в той же транзакции. Настоящий конец заменяет запись о выходе, наоборот — нет. Ключи старше трёх суток чистит фоновая задача.
Честно: дописывание чисел и замену «выхода» настоящим концом мы добавили не сразу, а через два-три дня после запуска — когда увидели в архиве бои без чисел.
7. Агрегация: курсор, 30 секунд и замок через pgBouncer
Раз в две минуты фоновая задача разбирает новые бои по курсору пачками до 2000 и обновляет маленькие таблицы: лучшее героя по каждому противнику, сложности и мерилу; распределения для оценки 0–100; PvP по брекетам, классам и веткам; первые убийства и скорость гильдий; обзор по дням. Только приросты, одна короткая транзакция на пачку.
SELECT pg_try_advisory_xact_lock(hashtext('hroniki-agregaciya')) AS ok; SELECT … FROM public.hroniki_boj WHERE id > $1::bigint AND zapisano < now() - interval '30 seconds' ORDER BY id LIMIT $2;
Здесь две тонкости.
Номера из последовательности — не порядок коммитов. Пачка А взяла номера 101–125, пачка Б — 126–150 и закоммитилась первой. Если курсор сразу прыгнет на 150, бои пачки А не попадут в рейтинги никогда. Поэтому агрегатор берёт только бои, записанные больше 30 секунд назад.
Замок. Во время выкладки без простоя у нас одновременно живут две копии сервера, и у обеих есть эта фоновая задача. Считать должна одна. Сессионный advisory-замок через pgBouncer ненадёжен: в следующей транзакции под тобой может оказаться другое соединение. Транзакционный pg_try_advisory_xact_lock живёт ровно столько, сколько транзакция, — одна копия считает, вторая получает ok = false и спокойно пропускает проход.
8. Оценка от 0 до 100
Для каждого противника, сложности и мерила хранится 101 квантиль лучших результатов — по всем, по классу, по классу и ветке и по полосе уровня предметов. Оценка — доля квантилей строго ниже результата, лучший результат — 100:
export function kvantili(otsortirovano: readonly number[]): number[] { const n = otsortirovano.length; const out: number[] = []; if (n === 0) return out; for (let k = 0; k < KVANTILEJ; k++) { // KVANTILEJ = 101 const i = Math.min(n - 1, Math.max(0, Math.round((k / 100) * (n - 1)))); out.push(otsortirovano[i]!); } return out; } export function ocenkaIzKvantilej(kv: readonly number[], v: number): number | null { if (kv.length === 0) return null; if (v >= kv[kv.length - 1]!) return 100; let nizhe = 0; for (const q of kv) if (q < v) nizhe += 1; return Math.max(0, Math.min(99, Math.round((nizhe / kv.length) * 100))); }
Мерил три: урон по боссу в ход; лечение и щиты в ход за вычетом неистраченного щита (иначе рейтинг лекаря накручивался бы щитом, который никто не пробил); принятый на себя урон в ход — для танков. После крупной правки баланса открывается новый раздел рейтинга, чтобы старые результаты не соревновались с новыми.
9. Чтение
Открытое API без входа ходит только в маленькие таблицы рейтингов и по индексам: бой по номеру, бои героя. Ответы кэшируются в памяти на 60 секунд и отдаются с Cache-Control, есть страницы и ограничение частоты запросов.
Что мы вынесли
«Никогда не бросает» — это контракт, а не пожелание. Писатель архива не имеет права уронить то, ради чего архив существует.
Счётчики вместо надежды: если что-то теряется, это видно в цифрах.
Разделы по месяцам — с первого дня, и обязательно раздел по умолчанию.
В очередь отправки кладём уже сжатое тело — повтор ничего не пересчитывает.
Через pgBouncer — только транзакционные advisory-замки.
Курсору по последовательности нужна задержка: номера коммитятся не по порядку.
Посмотреть, как это выглядит со стороны игрока, можно на сайте — «Хроники» открыты всем: https://ru.zarhold.com/hroniki?utm_source=habr&utm_medium=article&utm_campaign=20261024_hroniki
Если есть вопросы по схеме или идеи, как сделать проще, — пишите в комментариях, ответим.
