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

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

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