本插件作为Qy系列插件的数据库前置使用,也可以用来作为数据库前置插件自行接入自己的插件。提供AI的api开发SKILL一份。
QySQL API 文档
版本: 1.0.0 | 作者: 清影 | 目标平台: Spigot 1.12.2
QySQL 是一个 Minecraft 插件数据库前置,为其他插件提供统一的数据库连接管理、玩家数据生命周期(加载/保存/周期保存)、跨服数据锁等功能。
快速开始
- 将 QySQL.jar 放入服务器
plugins/目录 - 在你的插件
plugin.yml中添加依赖:
depend: [QySQL]
- 在
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
线程模型
|
方法 |
执行线程 |
说明 |
|
|
异步线程 |
禁止调用 Bukkit API |
|
|
异步线程 |
禁止调用 Bukkit API |
|
|
异步线程 |
禁止调用 Bukkit API |
|
|
主线程 |
可安全操作玩家 |
|
|
主线程 |
可安全操作玩家 |
加载保护
玩家加入后到数据加载完成前,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());
}
}
}
管理命令
|
命令 |
权限 |
说明 |
|
|
|
查看数据库状态、Supplier 列表、在线玩家 |
|
|
|
重载配置(任务间隔、消息、调试开关) |
|
|
|
查看命令帮助 |
别名:/qys
注意:数据库连接配置(地址/密码等)修改后需要重启服务器才能生效。
跨服锁机制
仅在 MySQL 模式下生效。当玩家从服务器 A 切换到服务器 B 时:
- 服务器 B 检测到该玩家在其他服务器有锁
- 等待锁释放(服务器 A 保存完成后自动释放)
- 超过
max-try-lock-count次重试后踢出玩家
锁心跳机制:每 lock-update-interval tick 刷新锁时间戳,超过 30 秒未刷新的锁视为过期。
配置要点
server-id:多服环境下必须为每台服务器设置唯一值- 留空则自动使用
服务器名称-端口作为标识

