Ставочная система для Discord‑сервера на Java 21 + Spring Boot + JDA: почему множитель ×2 ломает экономику, как посчитать коэффициенты честно, что происходит, когда восемь одновременных кликов по кнопке пытаются потратить один и тот же баланс, и как сделать выплаты, переживающие падение сервиса на середине расчёта.
Фиксированный множитель выигрыша (
ставка × 2, как в наивных реализациях) создаёт валюту из воздуха. Экономика сервера умирает за неделю.Правильный ответ — тотализатор (pari‑mutuel): все ставки в общий пул, комиссия организатора, остаток делится между победителями пропорционально. Сумма выплат всегда равна пулу минус комиссия. Ноль эмиссии.
Кнопки Discord — это источник гонок. Спам‑клик по «Поставить» без блокировок = двойное списание и баланс в минусе.
Расчёт выплат обязан быть идемпотентным и переживать рестарт: итоги розыгрыша фиксируются один раз, каждая ставка считается в своей транзакции под флагом
settled.Тесты на всё это пишутся: Testcontainers + восемь потоков, дерущихся за один баланс.
Всё, что ниже — из живого пет‑проекта: бот начисляет участникам голосовых каналов баллы лояльности (LP), а на эти баллы люди устраивают пари в стиле Twitch Predictions.
Откуда взялась задача
У меня есть Discord‑сервер, где люди сидят в голосовых каналах. Бот раз в 5 минут начисляет за это баллы: обычному участнику 100 LP, зрителю стрима 150, самому стримеру 200 (если есть кто‑то, кто его слушает). Баллы можно тратить на бесполезные, но приятные вещи: отключить кого‑нибудь от голосового канала за 10 000 LP или замьютить за 50 000 LP.
Дальше случилось предсказуемое: у людей накопились баллы, и им захотелось на них спорить. «Скинем ли мы этого босса с первой попытки?», «Придёт ли Вася сегодня в войс?». Так появилась команда /lp-pari <название>, которая публикует в канал сообщение‑опрос с кнопками «Да» и «Нет»:
🎲 Скинем босса с первой попытки? Автор: @user ✅ Да ❌ Нет Итого 3 ставки 1 ставка 4 ставки 500 LP 500 LP Пул: 1000 LP ×1.90 ×1.90 Комиссия: 50 LP Приём ставок открыт
Участник жмёт кнопку, вводит сумму в модальном окне, сумма немедленно списывается с баланса. Автор пари ставить не может, менять выбор нельзя, поставить на оба исхода нельзя. Управление («Остановить ставки», «Завершить: ДА», «Завершить: НЕТ», «Отменить») доступно только автору.
Выглядит как задача на вечер. На практике вечер ушёл на UI, а следующие несколько — на то, чтобы система не разваливалась от арифметики, гонок и рестартов.
Ошибка № 1: множитель ×2, или как напечатать деньги
Первая версия выплат была такой, какой её пишут все:
case FINISHED -> { boolean won = Objects.equals(pari.getWinningOption(), bet.getOption()); payout = won ? bet.getAmount() * PariService.WIN_MULTIPLIER : 0L; // WIN_MULTIPLIER = 2 reason = TransactionReason.BET_WIN; }
Угадал — получи вдвое, не угадал — ставка сгорела. Интуитивно кажется, что это честно и сбалансированно: одни теряют, другие получают.
Посчитаем. Пусть на «Да» поставили 900 LP, на «Нет» — 100 LP. Побеждает «Да»:
Собрано с проигравших | 100 LP |
Выплачено победителям | 900 × 2 = 1800 LP |
Эмиссия из воздуха | 1700 LP |
Система обязана выплатить 1800 LP, а «сгорело» всего 100. Разницу бот берёт… ниоткуда. Он просто дописывает число в колонку balance.
И это не редкий случай, а типичный: люди ставят на очевидный исход. Именно очевидные исходы и выигрывают чаще всего. То есть система печатает деньги не в исключительных ситуациях, а по умолчанию, в большинстве розыгрышей.
Обратный случай не лучше: если на победивший вариант поставили 100 LP, а на проигравший 900 LP, то 700 LP просто исчезают из экономики. Ни игрокам, ни организатору.
Итог: денежная масса на сервере ходит ходуном, накопленные за месяцы сидения в войсе баллы обесцениваются за пару крупных пари, и «замьютить кого‑то за 50 000 LP» перестаёт быть достижением. Экономика, в которой единственный источник эмиссии должен быть трудовым (сиди в войсе — получай), внезапно получает второй источник, работающий в тысячу раз быстрее.
Решение: тотализатор
Взять схему со скачек мне подсказал @Z3R0ing — за что ему отдельное спасибо. Это pari‑mutuel, он же тотализатор, которому сто лет на ипподромах. Никаких заранее известных коэффициентов: все ставки складываются в общий котёл, организатор удерживает комиссию, а остаток делится между победителями пропорционально их ставкам.
total_pool = сумма всех ставок пари commission = ⌊total_pool × commission_rate⌋ prize_pool = total_pool − commission коэффициент варианта = prize_pool / пул этого варианта выплата победителю = ⌊ставка × prize_pool / winning_sum⌋
Ключевое свойство: сумма всех выплат равна общему пулу за вычетом комиссии. Всегда. Игра строго с нулевой суммой (точнее — с отрицательной на величину комиссии, что как раз делает пари лёгким стоком лишней валюты, а не источником).
Весь расчёт — в одном классе без состояния и без похода в базу:
/** Комиссия организатора, удерживаемая из общего пула. */ public static long commission(long totalPool, BigDecimal rate) { if (totalPool <= 0 || rate == null || rate.signum() <= 0) { return 0L; } return BigDecimal.valueOf(totalPool) .multiply(rate) .setScale(0, RoundingMode.FLOOR) .longValueExact(); } /** * Выплата по выигравшей ставке: доля призового фонда, пропорциональная размеру ставки. * Считается как amount * prizePool / winningSum без промежуточного округления * коэффициента, поэтому результат не «плывёт» от порядка обработки ставок. */ public static long payout(long betAmount, long prizePool, long winningSum) { if (betAmount <= 0 || prizePool <= 0 || winningSum <= 0) { return 0L; } return BigDecimal.valueOf(betAmount) .multiply(BigDecimal.valueOf(prizePool)) .divide(BigDecimal.valueOf(winningSum), 0, RoundingMode.FLOOR) .longValueExact(); }
Три детали, каждая из которых лечит отдельный класс багов:
Никакой плавающей точки. Балансы — целые
long, вся арифметика наBigDecimalс явнымRoundingMode.doubleв денежных расчётах — это когда пользователь получает569.9999999999999LP и открывает тикет.Округление вниз, всегда. Сумма выплат из‑за этого никогда не превышает призовой фонд. Неразделённый остаток (не более 1 LP на победителя) остаётся вместе с комиссией. Округление вверх при 200 победителях подарило бы им 200 LP из ниоткуда — то есть ровно ту проблему, ради которой всё затевалось.
Умножаем до деления.
amount × prizePool / winningSum, а неamount × коэффициент. Если сначала округлить коэффициент до 4 знаков, а потом умножать, суммарная выплата разъедется с призовым фондом, и разница будет тем больше, чем больше участников.
Пример
Комиссия 5%. На «Да» поставили 300 и 200 LP, на «Нет» — 500 LP.
Величина | Значение |
|---|---|
Общий пул | 1000 LP |
Комиссия (5%) | 50 LP |
Призовой фонд | 950 LP |
Коэффициент «Да» | 950 / 500 = 1.90 |
Коэффициент «Нет» | 950 / 500 = 1.90 |
Победило «Да»: поставивший 300 получает 300 × 950 / 500 = 570 LP, поставивший 200 — 380 LP. Итого выплачено 950 — ровно призовой фонд, ни баллом больше.
Коэффициенты живые, и это ломает людям мозг
Пока пари открыто, коэффициент пересчитывается с каждой новой ставкой: чем больше поставлено на вариант, тем ниже выплата на единицу ставки. В опросе показывается текущий коэффициент, а итоговым становится тот, что зафиксирован в момент объявления исхода.
Отсюда следует свойство, которое игроки узнают тяжело и обычно в комментариях к боту: коэффициент может оказаться меньше единицы. Если 95% пула поставлено на «Да» и «Да» побеждает — делить, кроме собственных денег этих же людей за вычетом комиссии, нечего. Ставишь на очевидное — получаешь обратно свою ставку минус комиссия.
Это не баг, это ровно тот механизм, который в схеме ×2 отсутствовал и из‑за отсутствия которого печатались деньги. Поэтому в подтверждении ставки бот прямым текстом предупреждает:
return "Ставка принята: **" + bet.getAmount() + "** LP на вариант «" + PariMessageService.optionName(option) + "». Текущий коэффициент: **" + PariMessageService.formatCoefficient(odds.coefficient(option)) + "**, он изменится с новыми ставками.";
Комиссия фиксируется при создании
Ставка комиссии берётся из настройки PARI_COMMISSION_RATE (по умолчанию 5%), но записывается в строку пари в момент создания:
ALTER TABLE paris ADD COLUMN commission_rate NUMERIC(5, 4) NOT NULL DEFAULT 0;
Иначе админ, поправивший конфиг на горячую, изменил бы правила уже идущей игры — люди ставили при 5%, а получили при 20%. Некорректное значение (отрицательное или ≥ 1) трактуется как ноль с предупреждением в лог: лучше бесплатное пари, чем упавший бот.
Особые случаи, без которых всё разваливается
На победивший вариант никто не поставил (
winning_sum = 0). Делить призовой фонд не между кем. Наивная реализация здесь делит на ноль или тихо съедает весь пул. Правильное поведение — вернуть ставки всем участникам полностью, без комиссии.Пари отменено — полный возврат всем.
Пари забыли. Открытое пари висит вечно, и ставки в нём заморожены навсегда. Планировщик раз в минуту отменяет пари старше
PARI_TIMEOUT_HOURS(по умолчанию 24 ч) с полным возвратом.
Ошибка № 2: кнопка в Discord нажимается восемь раз
Дальше начинается самое интересное, и оно уже не про экономику, а про конкурентный доступ.
Пользователь с балансом 1000 LP нажимает «Да», вводит 900 LP — и в этот момент у него лагает клиент. Он жмёт ещё раз. И ещё. Discord честно доставляет боту все взаимодействия, JDA обрабатывает их параллельно, в разных потоках. Наивный код
проверить баланс → списать → записать ставку
в восьми потоках даст восемь ставок по 900 LP с баланса в 1000 LP и итоговый баланс −6200.
Правильный приём ставки — одна транзакция со строгим порядком действий:
@Transactional public PariBet placeBet(Long pariId, Guild guild, User user, boolean option, long amount) { // 1. Блокируем пари: пока принимается ставка, статус не сможет измениться. Pari pari = pariRepository.findByIdForUpdate(pariId) .orElseThrow(() -> new PariException("Пари не найдено.")); // 2. Проверки: та же гильдия, статус OPEN, вызывающий не автор. if (pari.getStatus() != PariStatus.OPEN) { throw new PariException("Приём ставок по этому пари уже закрыт."); } if (pari.getAuthorId().equals(user.getId())) { throw new PariException("Автор не может делать ставки в собственном пари."); } GuildMember member = guildMemberService.getOrCreateMember(guild, user); // 3. Повторное чтение под блокировкой: с этого момента баланс не изменит никто другой. GuildMember lockedMember = guildMemberRepository.findByIdForUpdate(member.getId()) .orElseThrow(() -> new PariException("Участник не найден.")); // 4. Проверку существующей ставки делаем уже под блокировкой баланса, иначе два // одновременных клика могли бы оба увидеть, что ставки ещё нет. if (pariBetRepository.findByPariIdAndMemberId(pariId, lockedMember.getId()).isPresent()) { throw new PariException("Вы уже сделали ставку в этом пари. Изменить выбор нельзя."); } if (lockedMember.getBalance() < amount) { throw new PariException("Недостаточно поинтов..."); } // 5. Списание, запись ставки и транзакции BET_HOLD. lockedMember.setBalance(lockedMember.getBalance() - amount); ... }
Здесь важен каждый пункт, но особенно два.
Блокировка пари (шаг 1) — не про баланс, а про статус. Без неё возможен сценарий: участник начал ставить, автор в этот момент нажал «Завершить», расчёт прошёл по ставкам, существовавшим на тот момент, — и следом закоммитилась ставка, которая уже никогда не будет рассчитана. Деньги списаны, ставка в статусе settled = false, пари закрыто. Блокировка строки пари заставляет кнопку «Завершить» подождать.
Порядок шагов 3 и 4 принципиален. Если проверять «а нет ли уже ставки» до захвата блокировки, два одновременных клика успеют оба увидеть, что ставки нет, и оба пройдут дальше. Классическая TOCTOU‑гонка: проверка и действие обязаны быть по одну сторону блокировки.
Блокировки всегда берутся в одном порядке — пари → баланс, поэтому дедлоков не возникает. Это то самое правило, которое легко нарушить, когда через полгода добавляешь новую операцию и берёшь блокировки в удобном для неё порядке.
Сами блокировки — обычный пессимистичный SELECT ... FOR UPDATE через Spring Data:
@Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT m FROM GuildMember m WHERE m.id = :id") Optional<GuildMember> findByIdForUpdate(@Param("id") Long id);
Оборона в глубину
Логика в сервисе — не единственный рубеж. За ней стоит база:
CONSTRAINT uk_pari_bet_pari_member UNIQUE (pari_id, member_id), CONSTRAINT ck_pari_bet_amount_positive CHECK (amount > 0)
ALTER TABLE guild_members ADD CONSTRAINT ck_guild_members_balance_non_negative CHECK (balance >= 0) NOT VALID;
Уникальный индекс (pari_id, member_id) — последний рубеж против гонки: если приложение всё‑таки прозевало, вставка упадёт, и DataIntegrityViolationException превратится в понятное пользователю сообщение вместо стектрейса:
try { // saveAndFlush, а не save: исключение нужно поймать здесь, а не при коммите bet = pariBetRepository.saveAndFlush(bet); } catch (DataIntegrityViolationException e) { throw new PariException("Вы уже сделали ставку в этом пари. Изменить выбор нельзя."); }
А CHECK (balance >= 0) гарантирует, что никакой код в приложении — ни ставки, ни будущая фича, которую я напишу через год в три часа ночи, — не уведёт баланс в минус. Это инвариант данных, и жить он должен там, где данные.
Ошибка № 3: расчёт, который не переживает рестарт
Пари с сотней участников — это сто начислений. Наивно это делается одной транзакцией на весь розыгрыш, и наивно это плохо по трём причинам: длинная транзакция держит блокировки, падение на 87-м участнике откатывает первых 86, а рестарт контейнера посреди расчёта оставляет половину людей без выплат.
Решение — разделить объявление исхода и начисление.
Шаг 1: фиксируем итоги ровно один раз
@Transactional public Pari finish(Long pariId, String actorId, boolean winningOption) { Pari pari = lockForAuthor(pariId, actorId); // SELECT ... FOR UPDATE + проверка авторства requireActive(pari); PariStats stats = getStats(pariId); long totalPool = stats.totalPool(); long winningSum = winningOption ? stats.yesPool() : stats.noPool(); long prizePool = PariPayoutCalculator.prizePool(totalPool, pari.getCommissionRate()); pari.setStatus(PariStatus.FINISHED); pari.setWinningOption(winningOption); pari.setTotalPool(totalPool); pari.setPrizePool(prizePool); pari.setWinningSum(winningSum); pari.setWinningCoefficient(PariPayoutCalculator.coefficient(prizePool, winningSum)); pari.setClosedAt(Instant.now()); return pariRepository.save(pari); }
Здесь важно не то, что итоги считаются, а что они сохраняются в строку пари:
ALTER TABLE paris ADD COLUMN total_pool BIGINT; ALTER TABLE paris ADD COLUMN prize_pool BIGINT; ALTER TABLE paris ADD COLUMN winning_sum BIGINT; ALTER TABLE paris ADD COLUMN winning_coefficient NUMERIC(18, 4);
Выплата по каждой ставке считается только из этих зафиксированных чисел и никогда не пересчитывается по текущему состоянию пула. Иначе результат зависел бы от момента запуска расчёта — а расчёт, как мы увидим, может запуститься дважды и с разницей в минуту. Это ровно та ошибка, которую в схеме с фиксированным множителем не заметить: там результат от состояния пула не зависит вовсе.
Шаг 2: каждая ставка — своя транзакция
@Transactional public boolean settleBet(Long betId) { PariBet bet = pariBetRepository.findByIdForUpdate(betId).orElse(null); if (bet == null) { return false; } if (bet.isSettled()) { return false; // Уже рассчитана — повторное начисление исключено. } Payout payout = resolvePayout(bet); // из зафиксированных итогов пари if (payout.amount() > 0) { GuildMember member = guildMemberRepository.findByIdForUpdate(bet.getMember().getId())...; member.setBalance(member.getBalance() + payout.amount()); // + запись в журнал транзакций } bet.setSettled(true); // флаг и начисление коммитятся вместе bet.setPayout(payout.amount()); bet.setSettledAt(now); return true; }
settleBet живёт в отдельном бине — не потому что так красивее, а потому что @Transactional в Spring работает через прокси: вызов приватного метода того же класса не создаст новую транзакцию, и вся идея развалится молча.
Начисление и установка флага коммитятся вместе — значит, либо участник получил выплату и ставка помечена, либо не произошло ничего. Третьего состояния не существует.
Шаг 3: батч и восстановление
Батч перебирает нерассчитанные ставки порциями по 200 и, если за проход не удалось рассчитать ни одной, останавливается, чтобы не крутиться вхолостую при недоступной базе:
if (processedInBatch == 0) { log.warn("Расчёт пари {} не продвигается: осталось {} нерассчитанных ставок", pariId, betIds.size()); return processed; }
Пари помечается рассчитанным (settled_at) только когда не осталось ни одной ставки с settled = false. Пока это не так, его подбирает планировщик:
@Scheduled(fixedDelay = 60_000L) public void recoverPendingSettlements() { List<Pari> pending = pariRepository.findByStatusInAndSettledAtIsNull( List.of(PariStatus.FINISHED, PariStatus.CANCELED), PageRequest.of(0, 20)); for (Pari pari : pending) { settle(pari.getId()); pariMessageService.refresh(pari.getId()); } }
Итоговые свойства расчёта:
повторный запуск безопасен — рассчитанные ставки пропускаются по флагу;
сбой на одном участнике не откатывает остальных — у каждого своя транзакция;
расчёт переживает рестарт — недоделанное подберёт планировщик в течение минуты;
результат не зависит от того, когда именно прошёл расчёт — он берётся из зафиксированных итогов.
Бонус: сводка выплат, которая не дублируется
После расчёта бот публикует в канал сводку — ответом на сообщение‑опрос:
🏆 Победители @user1 — 300 LP → 570 LP (+270) @user2 — 200 LP → 380 LP (+180) 💀 Проигравшие @user3 — 500 LP → 0 LP (−500)
И тут же вылезает проблема: расчёт может запустить кнопка «Завершить», а через минуту — восстановительный планировщик. Оба честно дойдут до конца и оба захотят опубликовать итоги. Пользователь получит сводку дважды.
Городить synchronized бессмысленно (в нескольких экземплярах сервиса он не работает), проверять if (resultsPostedAt == null) — та же TOCTOU‑гонка, что и со ставками. Право на публикацию берётся атомарным UPDATE:
@Modifying(clearAutomatically = true) @Query("UPDATE Pari p SET p.resultsPostedAt = :now WHERE p.id = :id AND p.resultsPostedAt IS NULL") int markResultsPosted(@Param("id") Long id, @Param("now") Instant now);
Вернулась единица — публикуешь ты. Ноль — кто‑то успел раньше. Одна строка SQL вместо распределённой блокировки.
Пара деталей, которые дались опытом:
отметка ставится только после того, как канал найден: недоступный канал не должен «съедать» право на публикацию;
участники упоминаются через
<@id>— внутри эмбеда это рендерится как ник, но не рассылает пинги, поэтому сводка на 50 человек не превращается в ковровую бомбардировку уведомлениями;список обрезается по лимиту поля эмбеда (1024 символа) в строку «…и ещё N участников», иначе Discord просто отклонит сообщение;
ответ на сообщение пари отправляется с
failOnInvalidReply(false): если опрос удалили, сводка всё равно уйдёт в канал обычным сообщением;ошибки отправки только логируются: доставка сообщения не влияет на движение средств. Деньги уже у людей, а сообщение — это всего лишь сообщение.
Как это тестируется
Взаимодействие с Discord мокается целиком: JDA‑объекты (Guild, Member, User, события) — интерфейсы, а асинхронные RestAction проверяются через захват success/failure‑колбэков и их ручной вызов. Арифметика тотализатора покрыта юнит‑тестами до последнего округления.
А вот гонки юнит‑тестами не проверишь. Для них — Testcontainers с настоящим PostgreSQL и восемь потоков, стартующих одновременно по CountDownLatch:
@Test void concurrentClicksProduceExactlyOneBet() throws Exception { User spammer = user("spammer"); giveBalance(guild, spammer, 1_000L); Pari pari = pariService.createPari(guild, author, "Спам-клики"); int threads = 8; CountDownLatch start = new CountDownLatch(1); AtomicInteger accepted = new AtomicInteger(); for (int i = 0; i < threads; i++) { pool.submit(() -> { start.await(); pariService.placeBet(pari.getId(), guild, spammer, true, 900L); accepted.incrementAndGet(); }); } start.countDown(); // ... assertThat(accepted.get()).isEqualTo(1); assertThat(balanceOf(spammer)).isEqualTo(100L); // 1000 − 900, а не минус шесть тысяч }
Рядом — тест, который проверяет, что база сама не даст записать отрицательный баланс, и тест на повторный расчёт: settleAll() вызывается дважды, балансы сверяются один раз.
Про H2 вместо Postgres здесь можно сразу забыть: SELECT ... FOR UPDATE, CHECK‑констрейнты и поведение при конфликте уникального индекса — это ровно те вещи, ради которых и нужен настоящий движок. Контейнер поднимается один раз на всю JVM и переиспользуется всеми интеграционными тестами.
Что я вынес из этого
Экономика игровой валюты — это инженерная задача, а не UX‑мелочь. Прежде чем писать код выплат, полезно ответить на вопрос «откуда берутся деньги и куда деваются». Если ответ «из воздуха» — переписывать придётся, вопрос только когда.
Кнопка в мессенджере — это распределённая система. Пользователь кликнет дважды, сеть задублирует, клиент отправит ретрай. Любой обработчик, трогающий деньги, должен переживать повторную доставку.
Проверка и действие обязаны быть по одну сторону блокировки. Это правило нарушается незаметно и ломается редко — то есть на проде и в самый неудачный момент.
Идемпотентность дешевле компенсаций. Флаг settled + зафиксированные итоги + планировщик дорасчёта — три простые вещи, которые заменяют собой целый пласт разбирательств «а почему Васе начислили дважды».
Инварианты денег живут в базе. CHECK (balance >= 0) и уникальный индекс не заменяют логику в сервисе, но страхуют её от всего кода, который будет написан после.
Стек и что осталось за кадром
Java 21, Spring Boot (WebMVC, Data JPA, Thymeleaf), JDA 6, PostgreSQL, Flyway, Gradle, JUnit 5 + Mockito + AssertJ + Testcontainers, JaCoCo.
Отдельно в проекте живёт веб‑дашборд с балансами и временем в голосовых каналах — причём время нигде не хранится, а восстанавливается из журнала начислений: баллы капают фиксированными порциями раз в 5 минут, значит Σ (баллы по причине / начисление за интервал) × 5 минут даёт время. Но это уже тема для отдельной статьи.

