跳转至

原创教程 · 环境: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.0 jar 上核对过类是否存在于对应包路径

22. 连接数据库:JDBC 实战

签到、兑换码、等级、转账、寄售行、封禁——这六件事都有同一个特征:数据要跟着玩家走、还要能被游戏外的程序(网站后台、多台服务器)读写。NBT / SavedData 做不到,这时候该上数据库了。

本章不说"数据库是什么",只说在你的 Forge mod 里怎么把它跑稳。

目录

  1. 先判断:你到底需不需要数据库
  2. 三条路线:选哪条
  3. 基础设施:依赖、配置、连接池、异步执行器
  4. 实战案例(6 个)
  5. 表结构 & 踩坑清单
  6. 什么时候该从 SavedData 迁过去
  7. 其他联动方式(补全)
  8. 速查表

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 就够,服务端用):

public Mymod() {
    ModLoadingContext.get().registerConfig(ModConfig.Type.COMMON, DbConfig.SPEC);
}

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;

自检三步:

  1. 看日志有没有 [DB] 连接池就绪:jdbc:mysql://...(Db.start() 里打的);
  2. 加一条管理员命令专门测连通:/mymod dbtest → 内部 SELECT 1,把结果回显;
  3. 故意把数据库停掉再启动服务器,确认只是"数据库功能不可用"、服务器照常进人("降级不崩服"的验收)。
// /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 都卡)

迁移套路(不用停机):

  1. 建库建表;
  2. 服务端启动后跑一次"导入任务":读旧 SavedData → Db.query() 批量 INSERT(用 addBatch() / executeBatch(),别一条条提交);
  3. 之后所有读写走数据库;旧 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.0 jar(本地 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)。