The Jerusalem PostStarlink slowed, Ukraine’s drones stalled: The private power behind a modern warPunchKaduna debunks N2.3tn APC rally spending claim, says letter fakeInquirerPDEA seizes P6.8M ‘shabu’ in Zamboanga City buy-bustESPNNHL Power Rankings: Fantasy hockey pickups for all 32 teamsInquirer EntertainmentMeghan Markle ‘distressed’ over father’s alleged child abuse images found in PH한겨레김정은, 단둥서 중국 권력서열 4위 만나…“신압록강대교까지 배웅 받아”Screen RantAvengers: Doomsday & Secret Wars Get Major Update From Legendary Marvel Comics WriterZDF heuteEntdecken Sie das ZDF-NachrichtenstudioCollider'MobLand' Star Reacts To That Intense Season 2 Pierce Brosnan Dinner SceneSCMP ChinaUkraine’s Starlink woes should be a wake-up call for Taiwan, analysts sayPopular ScienceHow to turn your smartphone into a mobile video editing suiteRai NewsTG1 Motori del 24/10/2026
The Daily Newsstand · Free, Always
Saturday, October 24, 2026

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

Translate

Мы — небольшая команда, делаем Зархолд: карточную MMORPG в браузере. Каждый бой в ней — пошаговая дуэль колодами, а режимов много: дуэли, арена от 1×1 до 5×5, поле боя 5×5, подземелья на троих, рейды и мировые боссы на пятерых, бои в открытом мире. Сервер — TypeScript, Fastify и node-postgres, база — PostgreSQL за pgBouncer, файлы — Cloudflare R2.

До «Хроник» у каждого режима был свой итог в своей таблице — шесть разных мест. Нам захотелось одного открытого архива всех боёв: отчёт любого боя с числами по ходам, картам и целям, топы по каждому боссу с оценкой от 0 до 100, доля побед по классам и веткам талантов в PvP. Смотреть может кто угодно, без входа (золото, почта, учётки и адреса не отдаются никогда). Так появились «Хроники сражений». Расскажу, как они устроены и какие места оказались неочевидными.

Бой с боссом в Зархолде: отряд из пяти героев против босса и его свиты

Бой с боссом в Зархолде: отряд из пяти героев против босса и его свиты

Требования

  1. Запись не влияет на бой. Ни задержка, ни ошибка «Хроник» не должны трогать итог боя, награды и рейтинг. Худший случай — бой не попал в архив.

  2. Рейтинги не считаются по запросу. База небольшая и живая, тяжёлые агрегаты — только в фоне. Сайт и игра читают готовые маленькие таблицы.

  3. Подробности — не в Postgres. Разбивка по картам, ходам и целям для рейда — это сотни значений на бой. Хранить их в базе дорого и незачем.

  4. Один бой — одна запись, даже если он пришёл к нам двумя путями.

Общая схема

конец боя (итог уже закоммичен)
  └─ 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

Если есть вопросы по схеме или идеи, как сделать проще, — пишите в комментариях, ответим.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.