Как я случайно напечатал инфляцию в Discord-боте и переделал ставки в тотализатор

от автора

Ставочная система для 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();}

Три детали, каждая из которых лечит отдельный класс багов:

  1. Никакой плавающей точки. Балансы — целые long, вся арифметика на BigDecimal с явным RoundingMode. double в денежных расчётах — это когда пользователь получает 569.9999999999999 LP и открывает тикет.

  2. Округление вниз, всегда. Сумма выплат из-за этого никогда не превышает призовой фонд. Неразделённый остаток (не более 1 LP на победителя) остаётся вместе с комиссией. Округление вверх при 200 победителях подарило бы им 200 LP из ниоткуда — то есть ровно ту проблему, ради которой всё затевалось.

  3. Умножаем до деления. 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.

Правильный приём ставки — одна транзакция со строгим порядком действий:

@Transactionalpublic 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: фиксируем итоги ровно один раз

@Transactionalpublic 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: каждая ставка — своя транзакция

@Transactionalpublic 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:

@Testvoid 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 минут даёт время. Но это уже тема для отдельной статьи.

ссылка на оригинал статьи https://habr.com/ru/articles/1077434/