В архитектуре многопользовательских сервисов с общими ресурсами — будь то распределение складских остатков, бронирование билетов или синхронное открытие кейсов в игровом проекте 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) для каждого игрока.

Резервирование счетчиков выполняется обновлением строк в таблице 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 требует учета двух критических факторов:
- Подготовленные выражения (Prepared Statements). Они привязаны к сессии PostgreSQL. В режиме транзакционного пулинга каждый последующий запрос может уйти в другое серверное соединение. Начиная с версии 1.21 PgBouncer умеет отслеживать prepared statements, но в строке подключения Prisma необходимо явно передавать флаг
?pgbouncer=true. - Миграции схемы данных. Миграции открывают длительные транзакции, создают блокировки 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-узел.
- Таймауты транзакций с массовой вставкой записей явно согласованы с возможностями дисковой подсистемы и пула соединений.
