Сервис развивается: тестируем формат, собираем идеи, улучшаем сервис. Есть идеи? Написать
Дайджесты новостей
Архитектурный баланс транзакционного слоя PostgreSQL под конкурентной нагрузкой: атомарный захват слотов, предотвращение дедлоков детерминированной сортировкой, пулинг PgBouncer и маршрутизация репликации

Четверо в одной транзакции: как удержать баланс между дедлоками, пулом PgBouncer и репликацией

В архитектуре многопользовательских сервисов с общими ресурсами — будь то распределение складских остатков, бронирование билетов или синхронное открытие кейсов в игровом проекте Caseforge — рано или поздно возникает один и тот же критический вызов. Что происходит, когда несколько независимых пользователей одновременно пытаются изменить одни и те же финансовые счета, занять ограниченные слоты и получить общий выигрыш?

Стандартные абстракции высокоуровневых ORM и наивные двухшаговые проверки («прочитал баланс, если хватает — списал») под реальной конкурентной нагрузкой неминуемо терпят крах. В лучшем случае система падает под грузом взаимных блокировок базы данных; в худшем — баланс уходит в минус, в комнату на двоих садятся трое, а выигравший пользователь видит пустой инвентарь из-за задержки репликации. Опыт выстраивания транзакционного слоя Caseforge на стеке PostgreSQL, Prisma и PgBouncer предлагает набор строгих инженерных решений, применимых в любой высоконагруженной системе.

Атомарный захват ресурсов и условный UPDATE

Классическая ошибка начинающих инженеров при проектировании лобби или покупке ограниченных товаров — шаблон «проверить и вставить» (check-then-act). Приложение делает SELECT COUNT(*), видит, что в комнате свободно одно место, и следом отправляет INSERT. Если два запроса приходят с интервалом в пару миллисекунд, оба видят свободный слот, оба выполняют вставку, и в лобби на четверых оказывается пять человек.

Изоляция уровня Read Committed от этого не защищает, а перевод всей системы на строгий Serializable создает лавину ошибок сериализации, требующих бесконечных повторов. Единственный надежный способ — перенос инкремента счетчика непосредственно в условный SQL-запрос с проверкой условия на уровне строк таблицы:

-- Атомарный захват слота: денормализованный счетчик защищает от переполнения
UPDATE battles 
SET "filledSlots" = "filledSlots" + 1 
WHERE id = $1 
  AND status = 'WAITING' 
  AND "filledSlots" < slots 
RETURNING "filledSlots";

Предложение RETURNING возвращает новое, уже увеличенное значение счетчика, которое сразу становится порядковым номером места участника. Если параллельный запрос успел занять последний слот раньше, база данных возвращает ноль обновленных строк, и приложение моментально фиксирует отказ.

Ключевой архитектурный принцип здесь — списание средств за участие должно происходить в той же самой транзакции и по той же схеме:

// Списание средств и захват слота внутри единой транзакции
const [slotResult, debitResult] = await prisma.$transaction(async (tx) => {
  const slot = await tx.$queryRaw`
    UPDATE battles 
    SET "filledSlots" = "filledSlots" + 1 
    WHERE id = ${battleId} AND status = 'WAITING' AND "filledSlots" < slots 
    RETURNING "filledSlots"
  `;

  if (!slot.length) {
    throw new Error('LOBBY_FULL');
  }

  const debit = await tx.$queryRaw`
    UPDATE users 
    SET balance = balance - ${entryFee} 
    WHERE id = ${userId} AND balance >= ${entryFee} 
    RETURNING balance
  `;

  if (!debit.length) {
    throw new Error('INSUFFICIENT_FUNDS');
  }

  return [slot[0], debit[0]];
});

Если у пользователя не хватило баланса или кто-то перехватил последний слот, транзакция полностью откатывается. С точки зрения базы данных никакого списания не происходило. Это избавляет систему от опасного механизма компенсаций («списать деньги, попытаться посадить в лобби, если не вышло — сделать возврат»). Любой возврат порождает вторую запись в журнале, временной зазор между операциями и риск зависания денег при аварийном завершении процесса.

Предотвращение дедлоков: глобально детерминированный порядок

Когда последнее место в лобби заполнено, система должна рассчитать исход раунда. В Caseforge для матча на четверых по тридцать раундов это означает генерацию 120 открытий, создание 120 строк инвентаря и пакетное резервирование криптографических счетчиков случайности (nonce) для каждого игрока.

Сравнительная диаграмма блокировок в PostgreSQL: циклический дедлок при произвольном порядке обновления строк против детерминированной последовательной очереди по UUID

Резервирование счетчиков выполняется обновлением строк в таблице server_seeds. Но оператор UPDATE накладывает эксклюзивную блокировку на строку до конца транзакции. Если два матча заполняются одновременно и в них участвуют одни и те же пользователи, возникает классический дедлок: первая транзакция блокирует игрока А и ждет игрока Б, а вторая в это же время заблокировала игрока Б и ждет игрока А. PostgreSQL распознает цикл ожидания и аварийно завершает одну из транзакций.

Решение состоит в установлении глобального детерминированного порядка захвата строк во всем приложении:

// Предотвращение дедлоков: сортировка участников по глобальному ключу перед блокировкой
const sortedPlayers = [...battle.players].sort((a, b) => 
  a.userId.localeCompare(b.userId)
);

for (const player of sortedPlayers) {
  // Блокировки захватываются строго в едином порядке во всех транзакциях сервиса
  await tx.$queryRaw`
    UPDATE server_seeds 
    SET nonce = nonce + ${battle.rounds} 
    WHERE "userId" = CAST(${player.userId} AS uuid) 
      AND "isActive" = true 
    RETURNING id, seed, nonce
  `;
}

Сортировка выполняется не по внутреннему номеру места в матче, а по первичному ключу пользователя userId. Поскольку все транзакции проекта всегда захватывают строки в алфавитном порядке их UUID, перекрестная блокировка становится математически невозможной.

Пул соединений: как подружить Prisma и PgBouncer

В архитектуре на Node.js каждый рабочий инстанс держит собственный пул соединений. При горизонтальном масштабировании сервиса лимит max_connections в PostgreSQL исчерпывается задолго до того, как процессоры базы данных окажутся загружены реальной работой. Для решения проблемы перед базой ставится пул соединений PgBouncer в режиме transaction pooling, позволяющий мультиплексировать тысячи клиентских подключений приложения в несколько десятков реальных серверных сессий.

Однако стандартная связка Prisma и PgBouncer требует учета двух критических факторов:

  1. Подготовленные выражения (Prepared Statements). Они привязаны к сессии PostgreSQL. В режиме транзакционного пулинга каждый последующий запрос может уйти в другое серверное соединение. Начиная с версии 1.21 PgBouncer умеет отслеживать prepared statements, но в строке подключения Prisma необходимо явно передавать флаг ?pgbouncer=true.
  2. Миграции схемы данных. Миграции открывают длительные транзакции, создают блокировки DDL и временные типы данных, что несовместимо с транзакционным пулингом. Для миграций Prisma требует отдельное прямое подключение к базе данных.
// Настройка двойного подключения в schema.prisma
datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")       // Строка подключения к PgBouncer (порт 6432)
  directUrl = env("DIRECT_DATABASE_URL") // Прямое подключение к PostgreSQL (порт 5432)
}

Дополнительно в строке DATABASE_URL за пулером обязательно задается параметр connection_limit. Дефолтное значение Prisma рассчитывает размер пула как удвоенное число ядер процессора плюс один, что полностью обесценивает смысл внешнего пулера при масштабировании контейнеров.

Репликация и задержка: правило, которое нельзя выразить типом

При разделении нагрузки на Primary (запись) и Standby (чтение) инженеры неизбежно сталкиваются с лагом репликации (Replication Lag). Даже задержка в 300–500 миллисекунд способна разрушить пользовательский опыт: игрок видит победу в матче, обновляет профиль, но страница инвентаря, обратившаяся к отстающей реплике, показывает, что выигранного предмета нет. Это гарантированный повод для тревоги и шквала обращений в службу поддержки.

В архитектуре Caseforge это решается жестким правилом маршрутизации запросов (паттерн Read-Your-Own-Writes), которое невозможно надежно выразить системой типов TypeScript, но которое обязано соблюдаться на уровне сервисных интерфейсов:

Категория данныхУзел базы данныхОбоснование выбора
Публичный каталог и спискиРеплика (Standby)Данные меняются редко, небольшое запаздывание некритично для витрины.
Аналитические отчеты и CRMРеплика (Standby)Тяжелые сканирующие запросы не должны отнимать память и ресурсы у транзакций записи.
Лента недавних событийКэш RedisМассовые запросы при перезапуске сервиса забираются из бэклога в оперативной памяти.
Личный баланс и инвентарьОсновной узел (Primary)Пользователь обязан моментально видеть результаты собственных финансовых действий.
Расчет матча и блокировкиОсновной узел (Primary)Любые конкурентные операции требуют абсолютной изоляции и актуальности состояния.

Чек-лист проектирования конкурентных финансовых операций

Для проверки архитектуры перед запуском в промышленную эксплуатацию сформирован прикладной контрольный список:

  • Все проверки доступности ресурсов (места, лимиты, остатки) объединены с операцией записи в один условный UPDATE ... WHERE ... RETURNING.
  • Списание средств и бронирование слота запечатаны в единую транзакцию; логика возврата денег через компенсационные проводки исключена.
  • Перед захватом блокировок нескольких строк в любых транзакциях сервиса применяется детерминированная сортировка по первичному ключу.
  • В конфигурации PgBouncer включено отслеживание подготовленных выражений, а миграции ORM направлены в обход пулера через directUrl.
  • Чтение данных, только что измененных пользователем, заблокировано от обращения к реплике и направляется строго на Primary-узел.
  • Таймауты транзакций с массовой вставкой записей явно согласованы с возможностями дисковой подсистемы и пула соединений.