Разработка Java-серверов на Paper · 6 мин чтения

База плагина: UUID, одноразовые награды и неоднозначные выплаты

Проверьте модель одноразовой награды сначала в маленькой SQLite: уникальный ключ не позволит повторному запросу создать вторую выдачу. Ниже — запускаемый SQL со связанным outbox и конкретные проверки повторения.

Редакция LonelyCraft · Обновлено

Схема: Ключ → Операция → Миграция
Стабильный ключ, согласованная операция и проверенная миграция старых данных. Авторская схема LonelyCraft.

Создайте отдельную учебную базу

Скачайте SQLite CLI с официальной страницы: на Windows нужен комплект tools, на Linux можно использовать пакет sqlite3 вашего дистрибутива. В новой пустой папке выполните sqlite3 rewards-test.db; на Windows с распакованным exe в текущей папке — .\sqlite3.exe rewards-test.db из PowerShell. Интерактивная консоль покажет приглашение sqlite>.

Это отдельный тест схемы, не прямое редактирование БД рабочего плагина. SQLite хранит UUID текстом; у PostgreSQL можно выбрать тип uuid. Синтаксис ниже относится именно к SQLite и использует PRIMARY KEY, CHECK и внешние ключи.

Создайте две таблицы

Вставьте SQL в консоль или сохраните как 001_rewards.sql и выполните sqlite3 rewards-test.db ".read 001_rewards.sql". Включайте foreign_keys для каждого соединения. Запись разблокировки и намерение награды используют один составной ключ: UUID, стабильное правило и период. Для постоянной цели период может быть permanent.

PRAGMA foreign_keys = ON;
CREATE TABLE unlocks (
  player_uuid TEXT NOT NULL,
  rule_id TEXT NOT NULL,
  period_key TEXT NOT NULL,
  PRIMARY KEY (player_uuid, rule_id, period_key)
);
CREATE TABLE reward_outbox (
  player_uuid TEXT NOT NULL,
  rule_id TEXT NOT NULL,
  period_key TEXT NOT NULL,
  amount INTEGER NOT NULL CHECK (amount > 0),
  status TEXT NOT NULL DEFAULT 'pending'
    CHECK (status IN ('pending', 'paying', 'paid', 'review')),
  PRIMARY KEY (player_uuid, rule_id, period_key),
  FOREIGN KEY (player_uuid, rule_id, period_key)
    REFERENCES unlocks(player_uuid, rule_id, period_key)
);

Повторите одну транзакцию два раза

Сохраните следующий SQL как unlock.sql и запустите sqlite3 rewards-test.db ".read unlock.sql" дважды. UUID ниже учебный. В ответ каждый раз должно быть 1 для каждой таблицы. ON CONFLICT DO NOTHING обрабатывает повтор известного ключа, не скрывая произвольные CHECK-ошибки через общий INSERT OR IGNORE.

Обе записи создаются одной транзакцией. Если outbox-INSERT не проходит, в Java обработайте исключение и выполните rollback; затем не сообщайте игроку об успехе. Уникальность не зависит от HashSet и сохраняется при закрытии/повторном открытии SQLite.

PRAGMA foreign_keys = ON;
BEGIN IMMEDIATE;
INSERT INTO unlocks VALUES
 ('00000000-0000-4000-8000-000000000001', 'first_cave', 'permanent')
 ON CONFLICT DO NOTHING;
INSERT INTO reward_outbox(player_uuid, rule_id, period_key, amount) VALUES
 ('00000000-0000-4000-8000-000000000001', 'first_cave', 'permanent', 50)
 ON CONFLICT DO NOTHING;
COMMIT;
SELECT count(*) FROM unlocks;
SELECT count(*) FROM reward_outbox;

Подключите модель к плагину через JDBC

В Java-плагине используйте PreparedStatement и setString/setInt для параметров, try-with-resources для соединений/запросов и явный rollback в обработке ошибки. Начало подключения и драйверы разобраны в Paper Using databases; SQLite JDBC выпускает Xerial. Сам CLI-пример уже проверяет схему, но не подменяет этот интеграционный код.

Outbox не делает внешний Vault-вызов частью SQLite-транзакции. До внешней выдачи сохраните paying, после подтверждения — paid. Если сервер упал между вызовом и записью, переводите неоднозначную операцию в review и сверяйте историю провайдера, когда API не принимает идемпотентный ключ. SQL-запросы выполняйте вне игрового потока с ограниченной очередью.

Сначала сформулируйте факты, которые нельзя потерять

Профиль хранит устойчивую идентичность, прогресс — измеряемое состояние, а разблокировка — подтверждённый результат конкретного правила. Разделите их до выбора таблиц. Для ежедневной награды ключ должен содержать период; для постоянного достижения — стабильный идентификатор правила. Отображаемое русское название можно менять без повторной выдачи.

У события есть отдельный eventId. Запрос от GUI или внешней интеграции может повториться из-за сетевого сбоя. Уникальное ограничение на этот идентификатор позволяет распознать уже принятую операцию. Проверка «SELECT, затем INSERT» без транзакции и ограничения оставляет окно, в котором два конкурирующих запроса увидят отсутствие записи.

Пример схемы награды

Таблица unlocks связывает player_uuid, rule_id и period_key, а их комбинация имеет UNIQUE. Таблица reward_outbox хранит id, ссылку на разблокировку, тип награды, величину и состояние обработки. Изменение фактов, создание разблокировки и намерения награды происходит одной транзакцией. Если транзакция не завершилась, интерфейс не сообщает о заработанном результате.

Это пример архитектуры, а не универсальная SQL-схема для любой БД. Тип UUID, синтаксис конфликтов и уровень изоляции выбираются по конкретному движку. Важно сохранить инвариант: одной разблокировке соответствует один набор намерений награды. Список в памяти может ускорять чтение, но устойчивое ограничение остаётся в хранилище.

Слушатель передаёт событие успеха, а хранилище решает одноразовую разблокировку. Подтверждённые игровые факты.

Внешний Vault не входит в вашу SQL-транзакцию

Если деньги находятся в другом плагине, запись собственной БД и Vault-вызов не становятся одной атомарной операцией. Сбой после начисления, но до отметки успеха оставляет неопределённость. Повтор «на всякий случай» способен выдать деньги дважды. Нужно явно выбрать политику такой ситуации и сохранить сведения для сверки.

Один вариант журнала использует pending, paying и paid. Перед внешним вызовом устойчиво записывается paying, после успешного ответа — paid. Незавершённое paying после аварии требует review, если внешняя система не умеет идемпотентный ключ. Администратор сверяет конкретную операцию с историей экономики. Не обещайте exactly once, если используемый API не предоставляет необходимый контракт.

Цена, списание и выдача должны сохранять смысл при повторном запросе и рестарте. Покупки и устойчивый журнал.

Запросы выполняйте с ограниченным ресурсом

Используйте параметры PreparedStatement для пользовательских значений. Имя таблицы и другие структурные части SQL выбирайте из фиксированного набора, а не принимайте строкой от игрока. Закрывайте Statement и ResultSet, задавайте предел получения соединения и размер пула. Сотня кликов не должна создать сотню необслуживаемых соединений.

Долгую работу с БД выносите из игрового потока. Возврат результата требует актуальной сессии, если он меняет GUI или игрока. Для покупки сам факт записи может завершиться после отключения; доставка сообщения является отдельным действием. Уточните это в контракте сервиса, чтобы выход пользователя не отменил уже подтверждённое списание без возврата.

Миграции проверяйте на прежних данных

Добавление столбца с осмысленным default отличается от пересоздания таблицы с потерей истории. При удалении или переименовании поля учитывайте старый бинарник: возможность вернуть JAR не означает совместимость обновлённой схемы. Для крупных переходов планируйте миграцию и откат как отдельную работу.

  1. Храните номер применённой схемы.
  2. Сделайте согласованную копию перед изменением.
  3. Выполните миграцию существующего набора, включая пустые и редкие записи.
  4. Проверьте количество фактов и уникальные ограничения.
  5. Убедитесь, что повторный запуск не применяет изменение второй раз.
  6. Зафиксируйте совместимость и способ возврата прежнего приложения.

Тестируйте сбой между шагами

Два одновременных завершения должны создать одну разблокировку. Повтор того же eventId не меняет итог. Рестарт после записи outbox сохраняет намерение, а после внешнего неопределённого вызова не делает слепую повторную выдачу. Эти тесты проверяют смысл механики лучше, чем один успешный INSERT в пустую БД.

Используйте реальный движок для ограничений и транзакций, а не только mock репозитория. Для SQLite предусмотрите поддерживаемый backup, учитывающий WAL. Журнал ошибок содержит идентификатор операции и безопасную причину; пароль соединения не нужен для диагностики дубликата. После восстановления копии повторите игровой сценарий и чтение прежних наград.

Частые вопросы

Почему ник не подходит как главный ключ?

Отображаемое имя может измениться. Используйте устойчивый идентификатор выбранной системы аккаунтов, отдельно проверяя особенности Java и Bedrock.

SELECT перед INSERT защищает от повторной награды?

Сам по себе нет: два запроса могут одновременно увидеть отсутствие записи. Нужны транзакция и подходящее уникальное ограничение.

Outbox гарантирует единственную выплату через любой Vault?

Нет. Если внешний API не поддерживает идемпотентность, сбой между выплатой и отметкой успеха требует явной политики сверки.

Почему mock базы недостаточен?

Он не обязательно воспроизводит реальные ограничения, изоляцию, WAL и конкурентное выполнение. Ключевые инварианты проверяйте на используемом движке.

Просмотры: … · Один просмотр с браузера за день

Было полезно?Общий счёт: …

Одна реакция с браузера. Нажмите повторно, чтобы снять её, или выберите другую.

Источники и документация

Minecraftразработка плагиновPaper

Читайте дальше