原创教程 · 环境:Minecraft 1.20.1 / Forge 47.x · 示例 modid
mymod· 包名com.example.mymod前置:07 能力系统、08 存档数据、13 配置文件、14 自定义命令 本章用到的类/方法名按 1.20.1 官方映射,并在forge-1.20.1-47.3.0jar 上核对过类是否存在于对应包路径
22. 连接数据库:JDBC 实战¶
签到、兑换码、等级、转账、寄售行、封禁——这六件事都有同一个特征:数据要跟着玩家走、还要能被游戏外的程序(网站后台、多台服务器)读写。NBT / SavedData 做不到,这时候该上数据库了。
本章不说"数据库是什么",只说在你的 Forge mod 里怎么把它跑稳。
目录¶
- 先判断:你到底需不需要数据库
- 三条路线:选哪条
- 基础设施:依赖、配置、连接池、异步执行器
- 实战案例(6 个)
- 表结构 & 踩坑清单
- 什么时候该从 SavedData 迁过去
- 其他联动方式(补全)
- 速查表
1. 先判断:你到底需不需要数据库¶
| 你的数据 | 该用什么 |
|---|---|
| 玩家余额、击杀数、开关状态(一人一份、跟存档走) | ✅ player.getPersistentData()(NBT,最省事) |
| 全服共享的一份数据(温度、段位、活动进度) | ✅ SavedData(见 08 存档数据) |
| 价目表、活动配置(要手改、好备份) | ✅ JSON / TOML 配置文件 |
| 几万行以上、要随手筛选/排序/join | ⬆️ 数据库(本章) |
| 多个服务器/多个程序同时读写同一份数据 | ⬆️ 数据库(本章) |
| 想让网站后台改数据、游戏里立刻生效 | ⬆️ 数据库(本章) |
💡 一句话:NBT/SavedData 是"存档的一部分",数据库是"一个服务"。存档够用就别上库;上库就要认真对待连接、线程和事务。
2. 三条路线(先选路线,再抄代码)¶
| 路线 | 怎么连 | 优点 | 代价 |
|---|---|---|---|
| A. JDBC 直连(本章主线) | mod 里用 DriverManager / 连接池直接连 MySQL/SQLite |
链路最短、类型安全、能开事务 | 要把驱动打进 mod;连接/线程/异常全得自己管 |
| B. HTTP 桥 | mod 用 HttpClient 调你服务器上的 Node/PHP,后端连库(KubeJS 文档第 2 节有一份可直接跑的 Node 后端示例) |
mod 不用带驱动、改 SQL 不用重新发包、天然跨版本 | 多写一个后端接口;多了网络跳 |
| C. 前置 mod | 用别人做好的"数据库 mod"提供的 API | 可能开箱即用 | 要自行确认版本/维护状态/与整合包冲突 |
本章主线是 A:Forge 里你可以把驱动打进自己的 mod,这是 KubeJS 脚本做不到的,所以直连在 Java 侧是划算的。 如果你的库还要给网站后台用,路线 A 和 B 可以共存:游戏走 JDBC,网站走自己那套。
3. 基础设施:依赖、配置、连接池、异步执行器¶
3.1 依赖(Gradle)¶
dependencies {
minecraft "net.minecraftforge:forge:1.20.1-47.4.23"
// ① 连接池(强烈建议:自己 new Connection 会泄漏、会卡)
implementation 'com.zaxxer:HikariCP:5.1.0'
// ② MySQL 驱动;想用 SQLite 就换成 org.xerial:sqlite-jdbc:3.45.3.0
implementation 'com.mysql:mysql-connector-j:8.4.0'
// ③ 打进 jar,玩家/服主不用手动丢 jar
jarJar(group: 'com.zaxxer', name: 'HikariCP', version: '[5.1.0,5.2)')
jarJar(group: 'com.mysql', name: 'mysql-connector-j', version: '[8.4.0,8.5)')
}
jarJar.enable() // ForgeGradle 6 / Forge 47 的嵌套 jar 机制
两个必踩的坑
jarJar的库会被放进META-INF/jarjar/(嵌套 jar),Java 的ServiceLoader在嵌套 jar 里可能发现不了 JDBC 驱动 → 初始化时显式Class.forName("com.mysql.cj.jdbc.Driver")。- HikariCP 5.x 依赖 slf4j:如果启动报
NoClassDefFoundError: org/slf4j/LoggerFactory,把org.slf4j:slf4j-api:1.7.36也jarJar进去。 - 嫌麻烦也可以直接用 Shadow 插件把驱动类合并进你的 mod jar(不是嵌套 jar,类直接在同一 classpath 上),代价是 mod 包变大。
3.2 配置项(别把密码写死在代码里)¶
// com/example/mymod/db/DbConfig.java
public final class DbConfig {
public static final ForgeConfigSpec SPEC;
public static final ForgeConfigSpec.ConfigValue<String> URL, USER, PASS;
static {
ForgeConfigSpec.Builder b = new ForgeConfigSpec.Builder();
b.comment("数据库连接。建议用只对本 mod 开放的专用账号,权限只给 SELECT/INSERT/UPDATE/DELETE").push("database");
URL = b.comment("JDBC 地址。MySQL 示例:jdbc:mysql://127.0.0.1:3306/serverdata?useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai"
+ "\nSQLite 示例:jdbc:sqlite:config/mymod.db")
.define("url", "jdbc:mysql://127.0.0.1:3306/serverdata?useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai");
USER = b.define("user", "mymod");
PASS = b.define("password", "change-me");
b.pop();
SPEC = b.build();
}
}
主类里注册(ModConfig.Type.COMMON 就够,服务端用):
3.3 连接池 + 异步执行器(本章最重要的一段)¶
Minecraft 服务端是单线程 tick 循环。你在主线程里 connection.query() 一次,只要数据库慢 200ms,全世界就卡 200ms——这是本教程里唯一"必须做对"的事。
所以:所有数据库操作丢到自己的线程池,结果用 server.execute() 丢回主线程再动游戏数据。
// com/example/mymod/db/Db.java
public final class Db {
private static final Logger LOG = LogUtils.getLogger();
private static final ExecutorService IO = Executors.newFixedThreadPool(2, r -> {
Thread t = new Thread(r, "mymod-db");
t.setDaemon(true); // 别阻止 JVM 退出
return t;
});
private static volatile HikariDataSource ds;
public static void start() {
try {
Class.forName("com.mysql.cj.jdbc.Driver"); // 嵌套 jar 下显式加载(SQLite 用 org.sqlite.JDBC)
} catch (ClassNotFoundException e) {
LOG.error("[DB] 找不到 JDBC 驱动,数据库功能将不可用", e);
}
HikariConfig cfg = new HikariConfig();
cfg.setJdbcUrl(DbConfig.URL.get());
cfg.setUsername(DbConfig.USER.get());
cfg.setPassword(DbConfig.PASS.get());
cfg.setMaximumPoolSize(4);
cfg.setMinimumIdle(1);
cfg.setConnectionTimeout(3000); // ★ 连不上就快速失败,别吊死
cfg.setMaxLifetime(30 * 60 * 1000L);
cfg.setPoolName("mymod-db");
ds = new HikariDataSource(cfg);
LOG.info("[DB] 连接池就绪:{}", DbConfig.URL.get());
}
public static void stop() {
if (ds != null) ds.close();
}
/** 在 IO 线程里执行一段数据库操作:自动取连接、自动归还、失败返回 null */
public static <T> CompletableFuture<T> query(SqlFunction<T> work) {
if (ds == null) return CompletableFuture.completedFuture(null);
return CompletableFuture.supplyAsync(() -> {
try (Connection c = ds.getConnection()) {
return work.apply(c);
} catch (Throwable t) {
LOG.warn("[DB] 操作失败:{}", t.toString()); // 数据库挂了也绝不能崩服
return null;
}
}, IO);
}
/** 回到服务器主线程(改玩家数据、发消息必须在这里) */
public static void toMain(Runnable task) {
MinecraftServer server = ServerLifecycleHooks.getCurrentServer();
if (server != null) server.execute(task);
}
@FunctionalInterface
public interface SqlFunction<T> { T apply(Connection c) throws Exception; }
}
生命周期挂钩(不要写在 mod 构造函数里——那时配置可能还没加载;集成服/单机也会起 server):
@Mod.EventBusSubscriber(modid = Mymod.MODID, bus = Mod.EventBusSubscriber.Bus.FORGE)
public final class DbLifecycle {
@SubscribeEvent public static void onStarted(ServerStartedEvent e) { Db.start(); }
@SubscribeEvent public static void onStopping(ServerStoppingEvent e) { Db.stop(); }
}
3.4 最常用的两个封装¶
/** 加钱(幂等累加,玩家不存在就建一行) */
public static void addMoney(UUID id, String name, long delta) {
Db.query(c -> {
try (PreparedStatement ps = c.prepareStatement(
"INSERT INTO mm_players (uuid, name, money) VALUES (?,?,?) "
+ "ON DUPLICATE KEY UPDATE name = VALUES(name), money = money + VALUES(money)")) {
ps.setString(1, id.toString());
ps.setString(2, name);
ps.setLong(3, delta);
ps.executeUpdate();
}
return Boolean.TRUE;
});
}
/** 读余额(查完回到主线程再回调,回调里可以放心动玩家) */
public static void getMoney(UUID id, Consumer<Long> cb) {
Db.query(c -> {
try (PreparedStatement ps = c.prepareStatement("SELECT money FROM mm_players WHERE uuid = ?")) {
ps.setString(1, id.toString());
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getLong(1) : 0L; }
}
}).thenAccept(v -> Db.toMain(() -> cb.accept(v == null ? 0L : v)));
}
统一命名前缀
给本 mod 的表统一加前缀(如 mm_),多程序共用一个库时才不会撞名。下面所有案例都用这套表。
3.5 文件放哪里(Forge 工程视角)¶
| 文件 | 放哪 | 说明 |
|---|---|---|
Mymod.java(主类) |
src/main/java/com/example/mymod/ |
构造里注册配置;不要在这里连数据库 |
Db.java / DbConfig.java / DbLifecycle.java |
.../mymod/db/ |
连接池、配置、生命周期各一个类 |
| 业务类(Money / Sign / Market…) | .../mymod/feature/ |
每个功能一个类,内部只调 Db.query(...) |
mods.toml |
src/main/resources/META-INF/ |
依赖声明(含 §7.1 的前置驱动 mod)、displayTest |
| 配置生成物 | 运行时 config/mymod-common.toml |
ForgeConfigSpec 自动生成,别手动塞进 jar |
| SQLite 文件(用它的话) | config/mymod.db 或 world/mymod.db |
路径用 FMLPaths.CONFIGDIR.get() 拼,别写死绝对路径 |
| 建表 SQL | 仓库根留一份 schema.sql |
部署 / 迁移时手动跑 |
| 打包产物 | build/libs/mymod-1.0.0.jar |
丢进服务端 mods/(驱动已由 jarJar 打进去) |
src/main/java/com/example/mymod/
├─ Mymod.java ← 主类:注册配置、注册事件总线
├─ db/
│ ├─ Db.java ← 连接池 + query() / toMain()
│ ├─ DbConfig.java ← ForgeConfigSpec(地址 / 账号 / 密码)
│ └─ DbLifecycle.java ← ServerStarted / ServerStoppingEvent
└─ feature/
├─ MoneyFeature.java
├─ SignFeature.java
└─ MarketFeature.java
src/main/resources/META-INF/mods.toml
build/libs/mymod-1.0.0.jar → 服务端 mods/
⚠️ 客户端也要装这个 mod 的话,
mods.toml的displayTest要写对(纯服务端功能用IGNORE_ALL_VERSION);但数据库相关代码必须只在服务端跑,客户端类里别引用java.sql。
3.6 怎么连 & 自检¶
| 场景 | 连接串 / 方式 | 注意 |
|---|---|---|
| 本机 MySQL | jdbc:mysql://127.0.0.1:3306/serverdata?... |
账号写 'mymod'@'127.0.0.1' |
| 内网库(多服共用) | jdbc:mysql://10.0.0.5:3306/serverdata?... |
账号放行内网段、防火墙只开内网 |
| 云库 / 跨机 | 同上 + useSSL=true&requireSSL=true |
延迟敏感:务必异步 + 缓存 |
| SQLite(单机) | jdbc:sqlite:config/mymod.db |
路径用 FMLPaths.CONFIGDIR 拼出来 |
建库 + 建专用账号(只做一次):
CREATE DATABASE serverdata DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
CREATE USER 'mymod'@'127.0.0.1' IDENTIFIED BY '一个强密码';
GRANT SELECT, INSERT, UPDATE, DELETE ON serverdata.* TO 'mymod'@'127.0.0.1';
FLUSH PRIVILEGES;
自检三步:
- 看日志有没有
[DB] 连接池就绪:jdbc:mysql://...(Db.start()里打的); - 加一条管理员命令专门测连通:
/mymod dbtest→ 内部SELECT 1,把结果回显; - 故意把数据库停掉再启动服务器,确认只是"数据库功能不可用"、服务器照常进人("降级不崩服"的验收)。
// /mymod dbtest —— 一条命令验连通
event.getDispatcher().register(Commands.literal("mymod").requires(s -> s.hasPermission(2))
.then(Commands.literal("dbtest").executes(ctx -> {
Db.query(c -> {
try (Statement st = c.createStatement(); ResultSet rs = st.executeQuery("SELECT 1")) {
return rs.next();
}
}).thenAccept(okRes -> Db.toMain(() -> ctx.getSource().sendSystemMessage(
Component.literal(okRes != null && okRes ? "§a数据库连接正常" : "§c数据库连接失败,看服务器日志"))));
return 1;
})));
💡 排错顺序:配置读到了吗(
config/mymod-common.toml里 url 对不对)→ 驱动加载了吗(Class.forName有没有报错)→ 能连上服务吗(终端先mysql -h ... -u mymod -p试)→ 权限够吗(能否SELECT)→ 最后才怀疑 SQL 本身。
4. 实战案例(6 个)¶
每个案例只讲四件事:【表】→【Java 逻辑】→【游戏里怎么触发】→【坑】。 时间统一用
System.currentTimeMillis()(毫秒BIGINT),时区用固定ZoneId(别信机器默认时区)。
4.1 每日签到(防重复 + 连签奖励)¶
表:
CREATE TABLE mm_sign (
uuid CHAR(36) NOT NULL,
day DATE NOT NULL, -- 服务端按固定时区算出来的"今天"
streak INT NOT NULL DEFAULT 1,
ts BIGINT NOT NULL,
PRIMARY KEY (uuid, day) -- ★ 天然去重:同一天插两次必然失败
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Java 逻辑:
private static final ZoneId TZ = ZoneId.of("Asia/Shanghai");
/** 返回 {streak, reward};-1 = 今天签过;null = 数据库不可用 */
public static CompletableFuture<int[]> signIn(UUID id, String name) {
return Db.query(c -> {
LocalDate today = LocalDate.now(TZ);
c.setAutoCommit(false); // 事务开始
try {
String lastDay = null; int lastStreak = 0;
try (PreparedStatement ps = c.prepareStatement(
"SELECT day, streak FROM mm_sign WHERE uuid = ? ORDER BY day DESC LIMIT 1")) {
ps.setString(1, id.toString());
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) { lastDay = rs.getString(1); lastStreak = rs.getInt(2); }
}
}
if (today.toString().equals(lastDay)) { // 今天已经签过
c.rollback();
return new int[]{-1, 0};
}
int streak = (lastDay != null && LocalDate.parse(lastDay).plusDays(1).equals(today))
? lastStreak + 1 : 1; // 断签归 1
int reward = Math.min(100 + (streak - 1) * 50, 500); // 连签越多越多,封顶 500
try (PreparedStatement ps = c.prepareStatement(
"INSERT INTO mm_sign (uuid, day, streak, ts) VALUES (?,?,?,?)")) {
ps.setString(1, id.toString());
ps.setString(2, today.toString());
ps.setInt(3, streak);
ps.setLong(4, System.currentTimeMillis());
ps.executeUpdate();
}
addMoneyTx(c, id, name, reward); // 同一事务里入账,见 4.3
c.commit();
return new int[]{streak, reward};
} catch (Throwable t) {
c.rollback();
throw t;
} finally {
c.setAutoCommit(true); // 归还连接前恢复默认
}
});
}
触发(命令 /signin,见 4.6 里的命令注册写法):
event.getDispatcher().register(Commands.literal("signin").executes(ctx -> {
ServerPlayer p = ctx.getSource().getPlayerOrException();
signIn(p.getUUID(), p.getName().getString())
.thenAccept(r -> Db.toMain(() -> {
if (r == null) { p.sendSystemMessage(Component.literal("§c签到服务暂时不可用,稍后再试")); return; }
if (r[0] == -1) { p.sendSystemMessage(Component.literal("§e今天已经签过啦,明天再来~")); return; }
p.sendSystemMessage(Component.literal("§a签到成功!连签 §e" + r[0] + " §a天,获得 §6" + r[1] + " §a金币"));
}));
return 1;
}));
坑
- 日期必须服务端算:玩家本地时钟可以随便改,客户端传来的"今天"一律不信。
- 时区固定一处:
ZoneId.of("Asia/Shanghai")或纯 UTC,全项目统一,别混用。 PRIMARY KEY (uuid, day)是最后一道防重复保险,别省。
4.2 兑换码 / 激活码(并发安全的一次性核销)¶
表:
CREATE TABLE mm_redeem (
code VARCHAR(32) PRIMARY KEY,
reward BIGINT NOT NULL,
max_use INT NOT NULL DEFAULT 1,
used INT NOT NULL DEFAULT 0,
expire BIGINT NOT NULL DEFAULT 0 -- 0 = 永不过期
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE mm_redeem_log ( -- ★ 谁兑换过什么
code VARCHAR(32) NOT NULL,
uuid CHAR(36) NOT NULL,
ts BIGINT NOT NULL,
PRIMARY KEY (code, uuid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Java 逻辑(先原子占名额 → 再写日志 → 日志冲突就回滚):
/** 返回奖励;-1 = 无效/用完/过期;-2 = 你已经用过;null = 数据库不可用 */
public static CompletableFuture<Long> redeem(UUID id, String name, String rawCode) {
String code = rawCode.trim().toUpperCase(Locale.ROOT);
return Db.query(c -> {
long now = System.currentTimeMillis();
c.setAutoCommit(false);
try {
// ① 原子占名额:只有"没过期 且 还有余量"才会 +1(并发下不会超发)
int n;
try (PreparedStatement ps = c.prepareStatement(
"UPDATE mm_redeem SET used = used + 1 "
+ "WHERE code = ? AND used < max_use AND (expire = 0 OR expire > ?)")) {
ps.setString(1, code);
ps.setLong(2, now);
n = ps.executeUpdate();
}
if (n == 0) { c.rollback(); return -1L; }
// ② 同一玩家只能用一次:唯一键冲突 = 已经用过,回滚后名额不消耗
try (PreparedStatement ps = c.prepareStatement(
"INSERT INTO mm_redeem_log (code, uuid, ts) VALUES (?,?,?)")) {
ps.setString(1, code);
ps.setString(2, id.toString());
ps.setLong(3, now);
ps.executeUpdate();
} catch (SQLIntegrityConstraintViolationException dup) { // SQLite: SQLiteConstraintException
c.rollback();
return -2L;
}
long reward;
try (PreparedStatement ps = c.prepareStatement("SELECT reward FROM mm_redeem WHERE code = ?")) {
ps.setString(1, code);
try (ResultSet rs = ps.executeQuery()) { reward = rs.next() ? rs.getLong(1) : 0L; }
}
addMoneyTx(c, id, name, reward);
c.commit();
return reward;
} catch (Throwable t) {
c.rollback();
throw t;
} finally {
c.setAutoCommit(true);
}
});
}
触发:/redeem <code>(Commands.argument("code", StringArgumentType.word())),回调里按 -1 / -2 / 正数 分支提示。
坑
- 绝不能写成"先
SELECT看有没有余量,再UPDATE"——两个玩家同时点就会双发。 - 先占名额、后写日志的顺序不能反:反了会出现"日志写成功但名额没了"的脏数据。
- 兑换码建议只存大写去空格后的形式(
toUpperCase(Locale.ROOT)+trim())。
4.3 等级 / 经验(累加 + 服务端定级)¶
表:
CREATE TABLE mm_leveling (
uuid CHAR(36) PRIMARY KEY,
name VARCHAR(32) NOT NULL DEFAULT '',
exp BIGINT NOT NULL DEFAULT 0,
lv INT NOT NULL DEFAULT 1
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Java 逻辑:
/** 阈值函数:全项目只此一份(服务端算等级,客户端只负责显示) */
public static int lvOf(long exp) {
int lv = 1;
long need = 100;
while (exp >= need) { exp -= need; need = Math.round(need * 1.25); lv++; }
return lv;
}
public record ExpResult(long exp, int lv, boolean up) {}
public static CompletableFuture<ExpResult> addExp(UUID id, String name, long add) {
if (add < 0 || add > 100_000) return CompletableFuture.completedFuture(null); // 服务端二次校验
return Db.query(c -> {
c.setAutoCommit(false);
try {
try (PreparedStatement ps = c.prepareStatement(
"INSERT INTO mm_leveling (uuid, name, exp) VALUES (?,?,?) "
+ "ON DUPLICATE KEY UPDATE exp = exp + VALUES(exp), name = VALUES(name)")) {
ps.setString(1, id.toString());
ps.setString(2, name);
ps.setLong(3, add);
ps.executeUpdate();
}
long exp; int oldLv;
try (PreparedStatement ps = c.prepareStatement("SELECT exp, lv FROM mm_leveling WHERE uuid = ?")) {
ps.setString(1, id.toString());
try (ResultSet rs = ps.executeQuery()) {
rs.next(); exp = rs.getLong(1); oldLv = rs.getInt(2);
}
}
int newLv = lvOf(exp);
if (newLv != oldLv) {
try (PreparedStatement ps = c.prepareStatement("UPDATE mm_leveling SET lv = ? WHERE uuid = ?")) {
ps.setInt(1, newLv);
ps.setString(2, id.toString());
ps.executeUpdate();
}
}
c.commit();
return new ExpResult(exp, newLv, newLv > oldLv);
} catch (Throwable t) { c.rollback(); throw t; }
finally { c.setAutoCommit(true); }
});
}
/** 事务内加钱(4.1 / 4.2 / 4.4 / 4.5 复用) */
private static void addMoneyTx(Connection c, UUID id, String name, long delta) throws Exception {
try (PreparedStatement ps = c.prepareStatement(
"INSERT INTO mm_players (uuid, name, money) VALUES (?,?,?) "
+ "ON DUPLICATE KEY UPDATE name = VALUES(name), money = money + VALUES(money)")) {
ps.setString(1, id.toString());
ps.setString(2, name == null ? "" : name);
ps.setLong(3, delta);
ps.executeUpdate();
}
}
触发(打死怪物给击杀者加经验,升级全服播报):
@SubscribeEvent
public static void onKill(LivingDeathEvent event) {
if (!(event.getSource().getEntity() instanceof ServerPlayer killer)) return;
if (event.getEntity() instanceof ServerPlayer) return; // 玩家互杀不给经验,防刷
addExp(killer.getUUID(), killer.getName().getString(), 5).thenAccept(r -> Db.toMain(() -> {
if (r != null && r.up()) {
killer.server.getPlayerList().broadcastSystemMessage(
Component.literal("§6[等级] §e" + killer.getName().getString() + " §a升到了 §cLv." + r.lv() + "§a!"), false);
}
}));
}
坑
- 等级永远服务端算并回传(
ExpResult.lv)。客户端自己算一遍阈值,公式一改就对不上。 - 经验值也可能被网站后台直接改,所以别用本地缓存"推算"等级——要显示就查一次。
- 高频事件(每次攻击、每个 tick)不要查库;经验只在小事件点(击杀/完成任务)加。
4.4 玩家转账(一条 SQL 完成"检查 + 扣减")¶
表:复用 4.1 的 mm_players + 第 5 节的 mm_money_log。
/** 返回 null = 数据库不可用;ok=false 时 msg 是失败原因 */
public static CompletableFuture<Boolean> transfer(UUID from, String fromName,
UUID to, String toName, long amt) {
if (amt <= 0 || amt > 1_000_000 || from.equals(to)) return CompletableFuture.completedFuture(false);
return Db.query(c -> {
c.setAutoCommit(false);
try {
// ① 扣钱条件写在 WHERE 里:一条 SQL 完成"检查余额 + 扣减",并发下不会扣成负数
int n;
try (PreparedStatement ps = c.prepareStatement(
"UPDATE mm_players SET money = money - ? WHERE uuid = ? AND money >= ?")) {
ps.setLong(1, amt);
ps.setString(2, from.toString());
ps.setLong(3, amt);
n = ps.executeUpdate();
}
if (n == 0) { c.rollback(); return false; } // 余额不足
addMoneyTx(c, to, toName, amt); // ② 收款方 +amt
// ③ 双方各记一条流水
long now = System.currentTimeMillis();
try (PreparedStatement ps = c.prepareStatement(
"INSERT INTO mm_money_log (uuid, delta, reason, ts) VALUES (?,?,?,?)")) {
ps.setString(1, from.toString()); ps.setLong(2, -amt);
ps.setString(3, "transfer→" + toName); ps.setLong(4, now); ps.executeUpdate();
ps.setString(1, to.toString()); ps.setLong(2, amt);
ps.setString(3, "transfer←" + fromName); ps.setLong(4, now); ps.executeUpdate();
}
c.commit();
return true;
} catch (Throwable t) { c.rollback(); throw t; }
finally { c.setAutoCommit(true); }
});
}
触发(/pay <target> <amount>,目标用实体选择器拿,天然避免重名):
event.getDispatcher().register(Commands.literal("pay")
.then(Commands.argument("target", EntityArgument.player())
.then(Commands.argument("amount", IntegerArgumentType.integer(1, 1_000_000))
.executes(ctx -> {
ServerPlayer from = ctx.getSource().getPlayerOrException();
ServerPlayer to = EntityArgument.getPlayer(ctx, "target");
int amt = IntegerArgumentType.getInteger(ctx, "amount");
transfer(from.getUUID(), from.getName().getString(),
to.getUUID(), to.getName().getString(), amt)
.thenAccept(ok -> Db.toMain(() -> {
if (ok == null) from.sendSystemMessage(Component.literal("§c转账服务暂不可用"));
else if (!ok) from.sendSystemMessage(Component.literal("§c余额不足或金额不合法"));
else {
from.sendSystemMessage(Component.literal("§a已转给 §e" + to.getName().getString() + " §6" + amt + " §a金币"));
to.sendSystemMessage(Component.literal("§a收到来自 §e" + from.getName().getString() + " §a的 §6" + amt + " §a金币"));
}
}));
return 1;
}))));
坑
- 绝不能先
SELECT money再UPDATE money = money - ?——两个请求交叉执行就会扣成负数。 - 金额范围在服务端再校验一次(客户端传入的参数一律不可信)。
- 有流水表的余额系统出问题能查、能回滚;没有流水表的余额系统就是黑盒。
4.5 寄售行(挂单 → 抢单 → 事务成交)¶
表:
CREATE TABLE mm_market (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
seller CHAR(36) NOT NULL,
sname VARCHAR(32) NOT NULL DEFAULT '',
item TEXT NOT NULL, -- 物品 NBT 的字符串形式
price BIGINT NOT NULL,
ts BIGINT NOT NULL,
INDEX (seller), INDEX (price)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
物品怎么存(1.20.1 官方映射:ItemStack#save(CompoundTag) / ItemStack.of(CompoundTag) / TagParser.parseTag(String)):
// 物品 → 字符串(附魔、耐久、自定义名字全都在里面)
String json = stack.save(new CompoundTag()).toString();
// 字符串 → 物品
ItemStack back = ItemStack.of(TagParser.parseTag(json));
上架 + 购买(抢单靠 DELETE ... WHERE id = ? 的返回行数判定):
public static CompletableFuture<Boolean> list(UUID seller, String sname, String itemJson, long price) {
if (price <= 0 || price > 100_000_000) return CompletableFuture.completedFuture(false);
return Db.query(c -> {
try (PreparedStatement ps = c.prepareStatement(
"INSERT INTO mm_market (seller, sname, item, price, ts) VALUES (?,?,?,?,?)")) {
ps.setString(1, seller.toString()); ps.setString(2, sname);
ps.setString(3, itemJson); ps.setLong(4, price);
ps.setLong(5, System.currentTimeMillis());
ps.executeUpdate();
}
return Boolean.TRUE;
});
}
/** 返回 null = 出错;item 为 null = 已被买走;否则是买到的物品 + 成交价 */
public record BuyResult(String itemJson, long price) {}
public static CompletableFuture<BuyResult> buy(UUID buyer, long id) {
return Db.query(c -> {
c.setAutoCommit(false);
try {
// ★ 抢单:谁能 DELETE 成功谁拿到货(原子,不会被两个人同时买到)
int n;
String itemJson; long price; String seller;
try (PreparedStatement ps = c.prepareStatement(
"SELECT seller, item, price FROM mm_market WHERE id = ?")) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) { c.rollback(); return new BuyResult(null, 0); }
seller = rs.getString(1); itemJson = rs.getString(2); price = rs.getLong(3);
}
}
try (PreparedStatement ps = c.prepareStatement("DELETE FROM mm_market WHERE id = ?")) {
ps.setLong(1, id);
n = ps.executeUpdate();
}
if (n == 0) { c.rollback(); return new BuyResult(null, 0); } // 手慢了
int paid;
try (PreparedStatement ps = c.prepareStatement(
"UPDATE mm_players SET money = money - ? WHERE uuid = ? AND money >= ?")) {
ps.setLong(1, price); ps.setString(2, buyer.toString()); ps.setLong(3, price);
paid = ps.executeUpdate();
}
if (paid == 0) { c.rollback(); return new BuyResult(null, 0); } // 余额不足,整笔回滚
addMoneyTx(c, UUID.fromString(seller), null, price);
c.commit();
return new BuyResult(itemJson, price);
} catch (Throwable t) { c.rollback(); throw t; }
finally { c.setAutoCommit(true); }
});
}
触发:/sell <price>(把主手物品上架,成功后 stack.shrink(stack.getCount()))、/market(打印前 10 条,用 Component.literal 拼文本;要图形界面就接 05 菜单)、/buy <id>(成功后 player.getInventory().add(ItemStack.of(TagParser.parseTag(r.itemJson()))))。
坑
- 抢单必须用
DELETE ... WHERE id = ?的n == 0判定,不要"先查后删"。 - 物品用
save/ItemStack.of完整序列化,别自己拼 NBT 字符串。 - 出货和扣钱的顺序:本案例是"先删单(锁住商品)→ 扣钱 → 加钱 → 提交",只有提交成功买家才真正付钱;发货在回调里做,若发货前崩服,建议再加一张"待发货"表兜底。
4.6 封禁 / 白名单(进服校验 + 缓存)¶
表:
CREATE TABLE mm_bans (
uuid CHAR(36) PRIMARY KEY,
name VARCHAR(32) NOT NULL DEFAULT '',
reason VARCHAR(128) NOT NULL DEFAULT '',
until BIGINT NOT NULL DEFAULT 0 -- 0 = 永久
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
命令注册 + 进服校验(RegisterCommandsEvent 写法在 4.1 已给;下面是进服钩子):
@SubscribeEvent
public static void onLogin(PlayerEvent.PlayerLoggedInEvent event) {
if (!(event.getEntity() instanceof ServerPlayer p)) return;
Db.query(c -> {
try (PreparedStatement ps = c.prepareStatement("SELECT reason, until FROM mm_bans WHERE uuid = ?")) {
ps.setString(1, p.getUUID().toString());
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null; // 没被 ban
long until = rs.getLong(2);
if (until > 0 && until < System.currentTimeMillis()) return null; // 已过期
return rs.getString(1) + "|" + until;
}
}
}).thenAccept(hit -> Db.toMain(() -> {
if (hit == null) return;
String[] parts = hit.split("\\|", 2);
String until = "0".equals(parts[1]) ? "永久" : new Date(Long.parseLong(parts[1])).toString();
p.connection.disconnect(Component.literal("§c你已被封禁:" + parts[0] + "\n到期时间:" + until));
}));
}
减少请求:给名单加缓存
服务器人多时,进服一人一次 SQL 也还行;想更省就60 秒拉一次全量封禁名单到内存 Set<UUID>,进服先查内存、命中再拒绝。封禁操作走后台或命令,写入库后再让缓存过期即可。
坑
- 进服校验是异步的(本章设计如此),所以玩家可能"先进服再被踢"——这是可接受的;若要"进服前拦",得用连接阶段的网络事件,代价大得多。
- 数据库不可用时放行并记日志,不要让"库挂了"变成"全服进不去"。
- 解封/加封都走同一张表,网站后台一封游戏里立刻生效。
4.7 多服 / 多程序共享一份数据(要点)¶
| 要点 | 说明 |
|---|---|
| 主键用 UUID | 玩家改名、跨服都对得上;名字只当显示字段 |
| 写操作要原子/幂等 | 能用 UPDATE ... WHERE 条件 + 返回行数就别"先查再写";多表就包事务 |
| 数据只信服务端 | 客户端只负责发请求和显示;金额、等级、胜负一律服务端算完回传 |
| 连接池按需开 | 每个服务端实例 4~8 个连接足够;maximumPoolSize 别写成 50 |
| 账号权限最小化 | 游戏账号只给 SELECT/INSERT/UPDATE/DELETE,不给 DROP/ALTER;建表用单独的运维账号 |
| 索引 | uuid、ts、常排序的列(price、money)都加索引,否则数据一多就慢 |
5. 表结构 & 踩坑清单¶
| 坑 | 怎么办 |
|---|---|
| 卡服 / TPS 掉 | ★ 数据库操作永远不在主线程做:丢 Db.query(),结果 Db.toMain() 回主线程 |
| 中文变问号 | 连接串带 characterEncoding=utf8,表用 utf8mb4 |
| 时间差 8 小时 | 存毫秒时间戳(BIGINT)自己换算;或连接串加 serverTimezone=Asia/Shanghai |
| 金额用浮点丢精度 | BIGINT 存最小单位(分/点),或 DECIMAL(18,2);Java 侧用 long |
| 玩家改名后对不上 | 主键永远 UUID,名字只当显示列 |
| 主键冲突 | INSERT ... ON DUPLICATE KEY UPDATE(MySQL)/ INSERT OR REPLACE(SQLite) |
| 库挂了脚本崩 / 服崩 | Db.query() 内部 try/catch 返回 null,调用方判空走兜底文案 |
| 连接泄漏 | 用连接池 + try-with-resources;永远别在脚本里裸 getConnection() 不放 |
| 启动报找不到驱动 | ServerStartedEvent 里显式 Class.forName(...)(嵌套 jar 的 ServiceLoader 不可靠) |
报 NoClassDefFoundError: org/slf4j/... |
HikariCP 5.x 需要 slf4j-api,一并 jarJar |
| 关闭时连接不释放 | ServerStoppingEvent 里 ds.close() |
| 密码泄露进日志 | 不要把 DSN/密码打进 LOGGER.info;配置写 config/ 目录,权限收紧 |
| 数据被玩家刷 | 后端/服务端二次校验数值范围 + 关键操作写流水表 |
推荐的两张基础表:
-- 玩家数据(跨服共享)
CREATE TABLE mm_players (
uuid CHAR(36) PRIMARY KEY,
name VARCHAR(32) NOT NULL DEFAULT '',
money BIGINT NOT NULL DEFAULT 0,
data JSON NULL -- MySQL 5.7+ 原生 JSON 列,塞自定义字段很方便
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 操作流水(审计/回滚/防刷)
CREATE TABLE mm_money_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL,
delta BIGINT NOT NULL,
reason VARCHAR(64) NOT NULL DEFAULT '',
ts BIGINT NOT NULL,
INDEX (uuid), INDEX (ts)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
6. 什么时候该从 SavedData 迁过去¶
出现下面任意一条,就该迁了:
- 存档里的数据需要跨服共享(同一个玩家在多个服余额一致)
- 需要按条件查("前 100 名"、"最近 7 天活跃")而不是全量遍历
- 数据要给网站后台改,改完游戏里要立刻生效
- 数据量已经让
.dat/ NBT 变得笨重(几万条记录,每次save都卡)
迁移套路(不用停机):
- 建库建表;
- 服务端启动后跑一次"导入任务":读旧
SavedData→Db.query()批量INSERT(用addBatch()/executeBatch(),别一条条提交); - 之后所有读写走数据库;旧
SavedData降级为本地缓存/备份,留着应急。
7. 其他联动方式(补全)¶
第 2 节那三条是同一条轴上的选择。实际生产环境里还常看到下面这几种,很多比"自己写"更划算:
| 编号 | 方式 | 一句话 | 适合 |
|---|---|---|---|
| D | 装「JDBC 驱动 mod」当前置 | 驱动已在 classpath,你不必 jarJar | 想少打包,或想让第三方脚本(如 KubeJS)也能用 |
| E | 混合服 + Bukkit 插件 | 插件生态第一天就支持 JDBC | 你本来就在跑 Arclight/Mohist |
| F | Redis / Jedis | 跨服即时同步 + 缓存 | 多服、要求「立刻一致」 |
| G | 现成存库 mod | GriefLogger / Whitelist Sync 2 / SimpleEventLogger | 需求重合,别自己造 |
| H | 文件 / 日志桥 | 游戏侧零依赖,只读落地 | 只读榜单/公告/离线分析 |
| I | RCON / HTTP 回调反向推 | 后台改库后通知游戏刷新 | 后台才是数据主人 |
| J | 托管 / 云数据服务 | Supabase、Cloudflare D1/KV、Upstash、PlanetScale | 不想运维数据库 |
7.1 D:驱动前置 mod(免 jarJar)¶
社区有一组「把新版驱动包成 mod」的通用库,同时是 Bukkit 插件和 Forge/Fabric/NeoForge 模组,支持版本含 1.20.1:
- Minecraft MySQL JDBC(MySQL Connector/J)
- Minecraft PostgreSQL JDBC、Minecraft SQLite JDBC、MariaDB JDBC、Microsoft SQL JDBC
- Minecraft Jedis (Redis Driver)(配合 7.3 用)
用法:编译期可以不声明(或 compileOnly 拿 IDE 补全),运行时靠这个 mod 提供驱动;同时在 mods.toml 声明前置依赖,忘装就明确报错、而不是 ClassNotFoundException 堆栈:
[[dependencies.mymod]]
modId = "mysql-jdbc" # 以该 mod 实际 modId 为准
mandatory = true
versionRange = "[1,)"
ordering = "AFTER"
side = "SERVER" # 专用服务器装即可,不必发给客户端
- ✅ 优点:完全绕开 3.1 里的 jarJar / ServiceLoader 两个坑;
- ⚠️ 缺点:多了一个必装前置(服主忘装就起不来),开服包/容器要把它一并打进去。
7.2 E:混合服 / 插件路线¶
在 Arclight / Mohist 这类混合端上,插件与 mod 共存,而 Bukkit 插件生态里 JDBC 是第一天就支持的事:CoreProtect、LuckPerms、Jobs Reborn、Plan、AuctionHouse、Dynmap… 全都支持 MySQL。
- 分工建议:数据/后台交给插件,玩法逻辑交给 mod,两边共用同一个库;
- ⚠️ 混合端本身的兼容性/性能自担;插件的表结构不是你的私有字段,共库只读最稳。
7.3 F:Redis 做跨服同步层¶
数据库适合「存」,不适合「每 5 秒互相问一遍」。多服要求即时一致时:
implementation 'redis.clients:jedis:5.1.2'
jarJar(group: 'redis.clients', name: 'jedis', version: '[5.1.2,5.2)')
(或直接装 Minecraft Jedis 前置 mod,见 7.1。)
- 模式:Redis 当同步/缓存层(快),数据库当落盘层(稳)——数值变动先写 Redis 并
publish到频道,各服订阅后更新内存缓存;定时/退出时批量刷回 MySQL; - 典型场景:跨服聊天、全服公告、跨服比赛比分、排队、经济余额(读多写少的缓存)。
7.4 G:现成存库 mod(先看有没有人做过)¶
- GriefLogger(Forge/Fabric/NeoForge,含 1.20.1):用 SQLite/MySQL 记录玩家交互,相当于「模组版的 CoreProtect」。
- Whitelist Sync 2(含 1.20.1):多服共享白名单 / OP / 封禁名单——它自己就是一个「多服共享一份数据」的实现。
- SimpleEventLogger(1.20.1 / 1.21.1):MySQL 审计日志 + 事件重放。
结论:查账、日志、名单同步、经济、统计这几类,社区已有成熟实现;先评估「直接用」,再决定「自己写」。
7.5 H:文件 / 日志桥(最省事的只读方案)¶
- 只读展示(榜单、公告、统计):外部程序定时把 SQL 结果导成 JSON,mod 侧用
Gson/JsonIO读;游戏侧完全不连数据库; - 审计:mod 追加写日志文件,外部程序
tail -F后入库; - ⚠️ 同一文件只能有一个写者,多进程写必须加锁;延迟是分钟级。
7.6 I:RCON / HTTP 回调反向推¶
后台改完数据,反向通知游戏:
- RCON:
rcon-cli发say或自定义指令(如/mymod reload)→ 游戏侧刷新缓存; - 回调:游戏侧用
com.sun.net.httpserver.HttpServer起一个只监听127.0.0.1的小服务,后端变更后 POST 通知。 - ⚠️ 这两种都别暴露公网,必须带 token 校验。
7.7 J:托管 / 云数据服务¶
Supabase(Postgres)、Cloudflare D1(SQLite)/ KV、Upstash Redis、PlanetScale 等托管服务大多提供 HTTP/REST 接口:
- 想少写 JDBC 样板 → mod 直接打 REST;想给后台/运营用 → 人家有现成控制台与权限管理;
- 代价:外网依赖、延迟、免费额度,以及玩家数据出境/合规要自己评估。
7.8 怎么选(一张表)¶
| 你的情况 | 选哪个 |
|---|---|
| 单机/小服,数据量小 | SQLite(§3)+ 驱动 mod,或干脆 SavedData |
| 单服,要和网站后台共享 | MySQL/MariaDB + JDBC(本章主线) |
| 多服,要求「立刻一致」 | Redis 同步 + MySQL 落盘(7.3) |
| 后台是主人,游戏被动刷 | RCON / 回调(7.6) |
| 只要榜单/公告这种只读数据 | 文件桥(7.5)或 HTTP 桥 |
| 不想运维数据库 | 托管服务(7.7) |
| 需求别人已经做过 | 现成 mod / 插件(7.4 / 7.2) |
💡 与 KubeJS 里的做法相通:脚本侧那套「HTTP 桥 / 驱动 mod / FetchJS / Redis」的完整对比见 KubeJS 文档《数据存储进阶:连接数据库》第 7 节。
8. 速查表¶
| 想做什么 | 写法 |
|---|---|
| 初始化连接池 | new HikariDataSource(new HikariConfig()),在 ServerStartedEvent 里 |
| 显式加载驱动 | Class.forName("com.mysql.cj.jdbc.Driver") / "org.sqlite.JDBC" |
| 异步查库 | CompletableFuture.supplyAsync(() -> …, ioExecutor)(不在主线程) |
| 结果回主线程 | server.execute(task) / 本章的 Db.toMain(task) |
| 自动开关连接 | try (Connection c = ds.getConnection()) { … } |
| 防 SQL 注入 | c.prepareStatement("… WHERE uuid = ?") + ps.setString(1, …),绝不拼字符串 |
| 开事务 | c.setAutoCommit(false) → c.commit() / c.rollback() → finally { c.setAutoCommit(true) } |
| 幂等累加 | INSERT … ON DUPLICATE KEY UPDATE money = money + VALUES(money) |
| 防负余额 | UPDATE … SET money = money - ? WHERE uuid = ? AND money >= ?,判返回行数 |
| 防并发超发 | 条件 UPDATE / DELETE + 返回行数判定,不先查后写 |
| 抢单 | DELETE FROM mm_market WHERE id = ? 返回 1 才算抢到 |
| 物品 ↔ 字符串 | stack.save(new CompoundTag()).toString() / ItemStack.of(TagParser.parseTag(s)) |
| 批量插入 | ps.addBatch() … ps.executeBatch() |
| 关闭 | ServerStoppingEvent 里 ds.close() |
| 不崩服的自检 | 加 /mymod dbtest(SELECT 1),并测"数据库停掉后服务器照常启动" |
| 代码放哪 | db/(连接池+配置+生命周期)、feature/(业务);SQLite 文件放 config/ |
| 换数据库只改两处 | 连接串(DbConfig)+ 方言差异(ON DUPLICATE KEY / ON CONFLICT、驱动类名) |
| 免 jarJar 拿驱动 | 装 Minecraft MySQL/SQLite/PostgreSQL JDBC 前置 mod |
| 免自己写 JDBC(只需要 HTTP) | 见 KubeJS 文档第 7 节;mod 侧也可直接打 REST |
| 跨服即时同步 | Redis/Jedis(同步层)+ 数据库(落盘层) |
| 现成存库 mod | GriefLogger / Whitelist Sync 2 / SimpleEventLogger |
| 反向通知游戏 | RCON 指令 / 只监听 127.0.0.1 的 HTTP 回调 |
💡 记忆口诀:能进存档就别上库;上库必须异步——主线程只负责"发请求"和"处理结果",SQL、连接、事务全在 IO 线程;服务端算数值、流水表留痕、条件写进
WHERE,跑得稳也扛得住刷。再补一层:先分清「谁是数据主人」(游戏 / 网站 / 多个服),再评估「借现成的」(驱动 mod、存库 mod、插件、Redis、托管服务),最后才自己写 JDBC。
相关章节:07 能力系统 · 08 存档数据 · 13 配置文件 · 14 自定义命令
待复核清单¶
- 类路径已核对:
net.minecraft.nbt.TagParser/NbtUtils/NbtIo、net.minecraft.world.item.ItemStack、net.minecraft.server.level.ServerPlayer、net.minecraft.commands.Commands、net.minecraft.commands.arguments.EntityArgument、net.minecraft.network.chat.Component、net.minecraftforge.event.RegisterCommandsEvent、net.minecraftforge.event.server.ServerStoppingEvent、net.minecraftforge.event.entity.player.PlayerEvent、net.minecraftforge.event.entity.living.LivingDeathEvent、net.minecraftforge.common.ForgeConfigSpec、net.minecraftforge.server.ServerLifecycleHooks均存在于forge-1.20.1-47.3.0jar(本地 jar 的字段/方法名为 SRG 占位名,方法名按 1.20.1 官方映射书写;若某方法名与你本地映射不符,以 IDE 跳转为准)。 - HikariCP / mysql-connector-j 版本号为示例(HikariCP 5.1.0、mysql-connector-j 8.4.0),
jarJar的[x,y)区间写法按 ForgeGradle 6 的jarJar(group:, name:, version:)形式。 - 案例里的 SQL 为 MySQL 语法;SQLite 差异点已在注释里标出(
INSERT OR REPLACE、SQLiteConstraintException)。