«Хроники сражений»: как мы сохраняем каждый бой браузерной MMO в PostgreSQL и Cloudflare R2

Мы — небольшая команда, делаем Зархолд: карточную 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
Если есть вопросы по схеме или идеи, как сделать проще, — пишите в комментариях, ответим.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.