数据持久化
前面所有系统(经济、等级、公会)的数据,都必须落盘——否则重启全没。这一篇讲从文件到数据库的完整方案。
三层方案,按规模选
| 方案 | 适合 | 特点 |
|---|---|---|
| YAML 文件 | 小服(百人内) | 简单、零依赖、读写慢 |
| MySQL | 中大型服 | 可靠、支持复杂查询、并发好 |
| MySQL + Redis 缓存 | 大型服 | 快速读写 + 持久化,最佳实践 |
YAML 文件(小服)
用配置 API 的 YamlConfiguration。每个玩家一个文件,或全部塞一个文件。
单文件存全部
java
public void saveAll() {
FileConfiguration cfg = new YamlConfiguration();
for (Map.Entry<UUID, PlayerData> e : players.entrySet()) {
String path = e.getKey().toString();
cfg.set(path + ".level", e.getValue().getLevel());
cfg.set(path + ".coins", e.getValue().getCoins());
}
cfg.save(new File(plugin.getDataFolder(), "players.yml"));
}单文件会膨胀
玩家多了 players.yml 会变成几十 MB,每次全量读写都慢。超过几百人就该上数据库。
按玩家分文件
java
public void save(UUID id, PlayerData data) {
File file = new File(plugin.getDataFolder(), "players/" + id + ".yml");
FileConfiguration cfg = YamlConfiguration.loadConfiguration(file);
cfg.set("level", data.getLevel());
cfg.set("coins", data.getCoins());
cfg.save(file);
}读写只动单个文件,比单文件好。但仍不如数据库。
MySQL(中型服)
建表
sql
CREATE TABLE player_data (
uuid VARCHAR(36) PRIMARY KEY,
name VARCHAR(16),
level INT DEFAULT 1,
exp DOUBLE DEFAULT 0,
coins DOUBLE DEFAULT 0,
job VARCHAR(20),
guild_id VARCHAR(36),
last_login BIGINT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);连接池
别每次查询都新建连接
建连接很慢(握手开销)。用连接池(HikariCP)复用连接:
java
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://host:3306/dbname");
config.setUsername("user");
config.setPassword("pass");
config.setMaximumPoolSize(10);
HikariDataSource ds = new HikariDataSource(config);查询时从池借连接、用完归还。
异步查询
数据库操作必须走异步(见调度器红线):
java
public void loadAsync(UUID id, Consumer<PlayerData> callback) {
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
PlayerData data = loadFromDb(id); // 异步查库
Bukkit.getScheduler().runTask(plugin, () -> {
callback.accept(data); // 回主线程回调
});
});
}玩家进服时序
玩家进服触发 PlayerJoinEvent,此时数据还没从库加载完。要等异步加载完成再给玩家发奖/同步状态。期间标记"加载中",避免数据 race。
Redis 缓存(大型服)
MySQL 每次查都要走网络 + 磁盘,高频读(查余额)仍慢。Redis 是内存数据库,读写微秒级。
缓存模式
读:先查 Redis → 命中直接返回;未命中查 MySQL → 写回 Redis → 返回
写:先写 Redis → 异步写 MySQL(或写穿:同时写两边)java
public double getCoins(UUID id) {
// 1. 先查 Redis
String cached = jedis.get("coins:" + id);
if (cached != null) return Double.parseDouble(cached);
// 2. 未命中查 MySQL
double coins = queryCoinsFromDb(id);
// 3. 回填 Redis(带过期)
jedis.setex("coins:" + id, 300, String.valueOf(coins));
return coins;
}排行榜用 ZSET
Redis 的 Sorted Set 天生为排行榜设计(见排行榜)。金币、等级榜都用 ZSET 存。
Redis 适合什么
- 缓存:玩家常用数据(余额、等级)
- 排行榜:ZSET
- 计数器:在线人数、今日击杀(
INCR) - 跨服共享:多子服连同一 Redis(见跨服)
玩家数据生命周期
完整流程:
进服 → 异步加载(库→内存) → 标记加载完成 → 正常游玩(改内存)
↑ ↓
│ 定时保存(内存→库)
│ ↓
重连复用内存 退服立即保存(内存→库)关键时序
java
private final Map<UUID, PlayerData> cache = new HashMap<>();
private final Set<UUID> loading = new HashSet<>();
@EventHandler
public void onJoin(PlayerJoinEvent e) {
UUID id = e.getPlayer().getUniqueId();
loading.add(id);
loadAsync(id, data -> {
cache.put(id, data);
loading.remove(id);
applyToPlayer(e.getPlayer(), data); // 同步到玩家(等级、经验条)
});
}
@EventHandler
public void onQuit(PlayerQuitEvent e) {
UUID id = e.getPlayer().getUniqueId();
PlayerData data = cache.remove(id);
if (data != null) saveAsync(id, data); // 退服存库
}加载中禁止改数据
loading 集合里的玩家,其他事件(打怪给经验)要先跳过或排队,等加载完再处理。否则会覆盖刚加载的旧数据。
定时保存
java
new BukkitRunnable() {
@Override
public void run() {
for (Map.Entry<UUID, PlayerData> e : cache.entrySet()) {
saveAsync(e.getKey(), e.getValue());
}
}
}.runTaskTimer(plugin, 0L, 6000L); // 每 5 分钟(6000 tick)常见坑
- 崩服丢数据:只靠定时保存,崩服会丢定时窗口内的改动。关键操作(大额交易)即时存
- 死锁:异步线程和主线程同时改同一数据 → 加锁或单线程化
- 连接泄漏:借了连接没归还 → 用 try-with-resources
- UUID 为主键:玩家能改名,别用 name 当主键,永远用 UUID
- 跨服数据竞争:多服同时改同一玩家数据 → 用 Redis 分布式锁或单服写
下一篇性能优化与 TPS。
