QySQL – 数据库前置

本插件作为Qy系列插件的数据库前置使用,也可以用来作为数据库前置插件自行接入自己的插件。提供AI的api开发SKILL一份。

QySQL API 文档

版本: 1.0.0 | 作者: 清影 | 目标平台: Spigot 1.12.2

QySQL 是一个 Minecraft 插件数据库前置,为其他插件提供统一的数据库连接管理、玩家数据生命周期(加载/保存/周期保存)、跨服数据锁等功能。


快速开始

  1. 将 QySQL.jar 放入服务器 plugins/ 目录
  2. 在你的插件 plugin.yml 中添加依赖:
depend: [QySQL]
  1. onEnable() 中注册 Supplier 并初始化建表:
@Override
public void onEnable() {
    // 初始化数据库表
    QySQL.INSTANCE.initSchema(this, "schema/tables.sql");

    // 注册数据供应
    SupplierManager.register("MyPlugin")
        .withPriority(SupplierPriority.NORMAL)
        .onLoad(player -> {
            // 异步线程:从数据库加载玩家数据
        })
        .onSave(player -> {
            // 异步线程:保存玩家数据到数据库
        })
        .build();
}

Maven 依赖配置

QySQL 作为前置插件,编译时以 provided 方式引入(不打入 JAR):

<dependency>
    <groupId>com.qysql</groupId>
    <artifactId>QySQL</artifactId>
    <version>1.0.0</version>
    <scope>system</scope>
    <systemPath>${project.basedir}/libs/QySQL-1.0.0.jar</systemPath>
</dependency>

核心概念

数据生命周期

玩家加入 → [异步] Supplier.loadData() → [主线程] PlayerLoadEvent
                                                    ↓
                                            玩家正常游戏
                                                    ↓
周期保存 → [异步] Supplier.cycleSaveData() → [主线程] PlayerCycleSaveEvent
                                                    ↓
玩家退出 → [异步] Supplier.saveData() → [主线程] PlayerSaveEvent

线程模型

方法

执行线程

说明

Supplier.loadData()

异步线程

禁止调用 Bukkit API

Supplier.saveData()

异步线程

禁止调用 Bukkit API

Supplier.cycleSaveData()

异步线程

禁止调用 Bukkit API

PlayerLoadEvent

主线程

可安全操作玩家

PlayerSaveEvent

主线程

可安全操作玩家

加载保护

玩家加入后到数据加载完成前,QySQL 会:

  • 给玩家施加失明效果
  • 拦截所有交互、移动、命令、聊天等操作
  • 加载完成后自动移除限制

Supplier 数据供应接口

方式一:实现接口

public class MySupplier implements Supplier {

    @Override
    public String getName() {
        return "MyPlugin";
    }

    @Override
    public void loadData(Player player) {
        // 在异步线程执行,从数据库读取数据
        Connection conn = QySQL.INSTANCE.getConnectionFactory().getConnection();
        try {
            // ... 查询并缓存到内存
        } finally {
            conn.close();
        }
    }

    @Override
    public void saveData(Player player) {
        // 在异步线程执行,将内存数据写入数据库
        Connection conn = QySQL.INSTANCE.getConnectionFactory().getConnection();
        try {
            // ... 写入数据库
        } finally {
            conn.close();
        }
    }

    @Override
    public void cycleSaveData(Collection<Player> players) {
        // 强烈建议覆盖此方法实现批量写入
        Connection conn = QySQL.INSTANCE.getConnectionFactory().getConnection();
        try {
            PreparedStatement ps = conn.prepareStatement("...");
            for (Player player : players) {
                ps.setString(1, player.getUniqueId().toString());
                // ... 设置参数
                ps.addBatch();
            }
            ps.executeBatch();
        } finally {
            conn.close();
        }
    }
}

注册:

SupplierManager.registerSupplier(new MySupplier(), SupplierPriority.NORMAL);

方式二:Builder 链式 API

SupplierManager.register("MyPlugin")
    .withPriority(SupplierPriority.HIGH)
    .onLoad(player -> {
        // 加载逻辑
    })
    .onSave(player -> {
        // 保存逻辑
    })
    .onCycleSave(players -> {
        // 批量保存逻辑(可选,不设置则逐个调用 onSave)
    })
    .build();

优先级

优先级

加载顺序

保存顺序

用途

LOWEST

0

最先加载

最后保存

基础数据(如玩家基本信息)

LOW

1

 

NORMAL

2

默认优先级

HIGH

3

 

HIGHEST

4

依赖其他数据的计算

MONITOR

5

最后加载

最先保存

监控/日志,不修改数据

加载按优先级升序执行(LOWEST → MONITOR),保存按降序执行(MONITOR → LOWEST)。

注销 Supplier

// 按名称注销
SupplierManager.deleteSupplier("MyPlugin");

// 按实例注销
SupplierManager.deleteSupplier(mySupplier);

查询 Supplier

// 检查是否已注册
boolean exists = SupplierManager.isRegistered("MyPlugin");

// 获取实例
Supplier supplier = SupplierManager.getSupplier("MyPlugin");

// 获取所有已注册名称
Set<String> names = SupplierManager.getRegisteredNames();

// 获取总数
int count = SupplierManager.getSupplierCount();

数据库连接

获取连接

// 获取数据库连接(用完必须关闭)
try (Connection conn = QySQL.INSTANCE.getConnectionFactory().getConnection()) {
    PreparedStatement ps = conn.prepareStatement("SELECT * FROM my_table WHERE uuid = ?");
    ps.setString(1, player.getUniqueId().toString());
    ResultSet rs = ps.executeQuery();
    // ...
}

连接状态检查

ConnectionFactory factory = QySQL.INSTANCE.getConnectionFactory();

// 检查连接是否有效(参数为超时秒数)
boolean valid = factory.isValid(5);

// 检查是否已初始化
boolean init = factory.isInitialized();

// 获取实现名称("MySQL" 或 "SQLite")
String impl = factory.getImplementationName();

获取当前数据库类型

String type = QySQL.INSTANCE.getDatabaseType(); // "mysql" 或 "sqlite"

Schema 初始化

在插件 resources/ 目录下放置 SQL 文件,QySQL 会自动检测表是否存在并执行建表。

双文件模式(推荐)

优先使用此方式。 MySQL 和 SQLite 的 SQL 语法存在差异(如数据类型、自增语法、引擎声明等),分别提供建表脚本可以避免兼容性问题,也便于针对各数据库做性能优化。

QySQL.INSTANCE.initSchema(this, "schema/mysql/tables.sql", "schema/sqlite/tables.sql");

MySQL 示例(resources/schema/mysql/tables.sql):

CREATE TABLE IF NOT EXISTS `player_data` (
    `uuid` CHAR(36) NOT NULL,
    `name` VARCHAR(16) NOT NULL,
    `coins` BIGINT DEFAULT 0,
    `level` INT DEFAULT 1,
    PRIMARY KEY (`uuid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

SQLite 示例(resources/schema/sqlite/tables.sql):

CREATE TABLE IF NOT EXISTS `player_data` (
    `uuid` TEXT NOT NULL,
    `name` TEXT NOT NULL,
    `coins` INTEGER DEFAULT 0,
    `level` INTEGER DEFAULT 1,
    PRIMARY KEY (`uuid`)
);

单文件模式

仅当你的建表语句完全兼容两种数据库时才使用:

QySQL.INSTANCE.initSchema(this, "schema/tables.sql");

注意事项

  • SQL 文件支持 --// 注释
  • 每条语句以 ; 结尾
  • 已存在的表会自动跳过
  • 如果 MySQL 不支持 utf8mb4,会自动回退到 utf8

数据库迁移

用于版本化的数据库结构变更(如添加列、修改索引等)。

重要:插件必须在初期就接入双目录模式的数据库迁移。 即使当前版本只有建表语句,也应预留迁移目录结构。后期如果需要修改表结构(加字段、改索引、数据迁移等),没有预留迁移接口会导致无法平滑升级,只能让用户手动改库。

使用方式

优先使用双目录模式:

@Override
public void onEnable() {
    // 1. 初始化建表
    QySQL.INSTANCE.initSchema(this, "schema/mysql/tables.sql", "schema/sqlite/tables.sql");

    // 2. 执行数据库迁移(必须在 initSchema 之后)
    QySQL.INSTANCE.initMigrations(this, "migrations/mysql/", "migrations/sqlite/");

    // 3. 注册 Supplier ...
}

单目录模式仅当迁移 SQL 完全兼容两种数据库时使用:

QySQL.INSTANCE.initMigrations(this, "migrations/");

迁移脚本命名规范

migrations/
├── V1__initial.sql
├── V2__add_columns.sql
└── V3__create_tables.sql

格式:V{版本号}__{描述}.sql

  • 版本号为正整数,按顺序执行
  • 已执行的版本会记录在 qysql_migrations 表中,不会重复执行

事件系统

所有事件均在主线程触发,可安全调用 Bukkit API。

PlayerLoadEvent

玩家数据加载成功后触发。

@EventHandler
public void onPlayerLoad(PlayerLoadEvent event) {
    Player player = event.getPlayer();
    // 数据已加载完成,可以安全使用缓存数据
    player.sendMessage("欢迎回来!你的等级是 " + getLevel(player));
}

PlayerSaveEvent

玩家数据保存完成后触发。

@EventHandler
public void onPlayerSave(PlayerSaveEvent event) {
    Player player = event.getPlayer();
    // 数据已保存完成
}

PlayerCycleSaveEvent

周期保存完成后对每个玩家触发。

@EventHandler
public void onCycleSave(PlayerCycleSaveEvent event) {
    Player player = event.getPlayer();
    // 周期保存已完成
}

PlayerLoadFailedEvent

玩家数据加载失败时触发(Supplier 异常或跨服锁超时)。

@EventHandler
public void onLoadFailed(PlayerLoadFailedEvent event) {
    Player player = event.getPlayer();
    String reason = event.getReason();
    Throwable cause = event.getCause(); // 可能为 null

    getLogger().warning(player.getName() + " 数据加载失败: " + reason);
}

DatabaseConnectionEvent

数据库连接状态变更时触发。

@EventHandler
public void onDbConnection(DatabaseConnectionEvent event) {
    DatabaseConnectionEvent.Status status = event.getStatus();
    String dbType = event.getDatabaseType();
    String message = event.getMessage();

    if (status == DatabaseConnectionEvent.Status.DISCONNECTED) {
        getLogger().severe("数据库连接断开: " + message);
    }
}

状态枚举:

  • CONNECTED — 连接成功建立
  • DISCONNECTED — 连接断开
  • RECONNECTED — 重连成功

玩家状态查询

UserManager userManager = QySQL.INSTANCE.getUserManager();
UUID uuid = player.getUniqueId();

// 玩家是否正在加载数据(加入后到加载完成前)
boolean loading = userManager.isLoading(uuid);

// 玩家是否正在连接(同 isLoading)
boolean connecting = userManager.isConnecting(uuid);

// 玩家是否已标记离开
boolean leave = userManager.isLeave(uuid);

手动触发加载/保存

UserManager userManager = QySQL.INSTANCE.getUserManager();

// 异步加载(需要自行处理主线程回调)
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
    boolean success = userManager.loadDataAsync(player);
    Bukkit.getScheduler().runTask(plugin, () -> {
        if (success) {
            userManager.finishLoad(player);
        }
    });
});

// 异步保存
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
    userManager.saveDataAsync(player);
    Bukkit.getScheduler().runTask(plugin, () -> {
        userManager.finishSave(player);
    });
});

配置文件

config.yml

database:
  type: sqlite  # mysql 或 sqlite

  mysql:
    address: "localhost:3306"
    database: minecraft
    username: root
    password: ""
    pool:
      maximum-pool-size: 10
      minimum-idle: 10
      maximum-lifetime: 1800000
      keepalive-time: 0
      connection-timeout: 5000
      properties:
        useUnicode: true
        characterEncoding: utf8
        useSSL: false

  sqlite:
    file: data.db

server-id: ""  # 留空自动生成,多服环境需手动设置唯一值

settings:
  load-delay-ticks: 40       # 加入后延迟加载(tick)
  cycle-save-interval: 6000  # 周期保存间隔(tick),5分钟
  lock-update-interval: 3000 # 锁心跳间隔(tick),2.5分钟
  max-try-lock-count: 20     # 获取锁最大重试次数
  debug: false               # 调试日志开关

messages.yml

所有玩家可见消息均可自定义,支持 & 颜色代码和 {0} {1} 占位符。


完整接入示例

public class MyPlugin extends JavaPlugin {

    private final Map<UUID, PlayerData> cache = new ConcurrentHashMap<>();

    @Override
    public void onEnable() {
        // 1. 初始化数据库表(双文件模式)
        QySQL.INSTANCE.initSchema(this, "schema/mysql/tables.sql", "schema/sqlite/tables.sql");

        // 2. 执行数据库迁移(双目录模式,必须在 initSchema 之后)
        QySQL.INSTANCE.initMigrations(this, "migrations/mysql/", "migrations/sqlite/");

        // 3. 注册 Supplier
        SupplierManager.register("MyPlugin")
            .withPriority(SupplierPriority.NORMAL)
            .onLoad(this::loadPlayerData)
            .onSave(this::savePlayerData)
            .onCycleSave(this::batchSave)
            .build();

        // 3. 监听加载完成事件
        Bukkit.getPluginManager().registerEvents(this, this);
    }

    @Override
    public void onDisable() {
        // 注销 Supplier(可选,服务器关闭时 QySQL 会自动保存)
        SupplierManager.deleteSupplier("MyPlugin");
        cache.clear();
    }

    // ⚠️ 异步线程执行,禁止调用 Bukkit API
    private void loadPlayerData(Player player) {
        try (Connection conn = QySQL.INSTANCE.getConnectionFactory().getConnection()) {
            PreparedStatement ps = conn.prepareStatement(
                "SELECT coins, level FROM player_data WHERE uuid = ?");
            ps.setString(1, player.getUniqueId().toString());
            ResultSet rs = ps.executeQuery();
            if (rs.next()) {
                cache.put(player.getUniqueId(),
                    new PlayerData(rs.getLong("coins"), rs.getInt("level")));
            } else {
                cache.put(player.getUniqueId(), new PlayerData(0, 1));
            }
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    // ⚠️ 异步线程执行
    private void savePlayerData(Player player) {
        PlayerData data = cache.get(player.getUniqueId());
        if (data == null) return;

        try (Connection conn = QySQL.INSTANCE.getConnectionFactory().getConnection()) {
            PreparedStatement ps = conn.prepareStatement(
                "REPLACE INTO player_data (uuid, name, coins, level) VALUES (?, ?, ?, ?)");
            ps.setString(1, player.getUniqueId().toString());
            ps.setString(2, player.getName());
            ps.setLong(3, data.getCoins());
            ps.setInt(4, data.getLevel());
            ps.executeUpdate();
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    // ⚠️ 异步线程执行 - 批量保存性能更优
    private void batchSave(Collection<Player> players) {
        try (Connection conn = QySQL.INSTANCE.getConnectionFactory().getConnection()) {
            PreparedStatement ps = conn.prepareStatement(
                "REPLACE INTO player_data (uuid, name, coins, level) VALUES (?, ?, ?, ?)");
            for (Player player : players) {
                PlayerData data = cache.get(player.getUniqueId());
                if (data == null) continue;
                ps.setString(1, player.getUniqueId().toString());
                ps.setString(2, player.getName());
                ps.setLong(3, data.getCoins());
                ps.setInt(4, data.getLevel());
                ps.addBatch();
            }
            ps.executeBatch();
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    @EventHandler
    public void onPlayerLoad(PlayerLoadEvent event) {
        // 主线程:数据已就绪,可以安全使用
        Player player = event.getPlayer();
        PlayerData data = cache.get(player.getUniqueId());
        if (data != null) {
            player.sendMessage("§a你的金币: " + data.getCoins());
        }
    }
}

管理命令

命令

权限

说明

/qysql status

qysql.admin

查看数据库状态、Supplier 列表、在线玩家

/qysql reload

qysql.admin

重载配置(任务间隔、消息、调试开关)

/qysql help

qysql.admin

查看命令帮助

别名:/qys

注意:数据库连接配置(地址/密码等)修改后需要重启服务器才能生效。


跨服锁机制

仅在 MySQL 模式下生效。当玩家从服务器 A 切换到服务器 B 时:

  1. 服务器 B 检测到该玩家在其他服务器有锁
  2. 等待锁释放(服务器 A 保存完成后自动释放)
  3. 超过 max-try-lock-count 次重试后踢出玩家

锁心跳机制:每 lock-update-interval tick 刷新锁时间戳,超过 30 秒未刷新的锁视为过期。

配置要点

  • server-id:多服环境下必须为每台服务器设置唯一值
  • 留空则自动使用 服务器名称-端口 作为标识
© 版权声明
THE END