玩家缓存
自 v6.2.0 起
@PlayerCache 注解自 UltiTools-API v6.2.0 起可用。
插件经常在 Map<UUID, ?> 字段中存储玩家相关数据(冷却时间、设置、打开的 GUI 等)。如果在玩家退出时忘记清理这些 Map,就会导致内存泄漏。UltiTools 提供了 @PlayerCache 注解,在玩家断开连接时自动移除对应条目。
基本用法
在托管 Bean 中的任何 Map<UUID, ?> 字段上添加 @PlayerCache 注解:
package com.ultikits.docs.cache;
import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.*;
import com.ultikits.ultitools.aop.ExceptionHandler;
import com.ultikits.ultitools.interfaces.DataOperator;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.reflect.Method;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class CooldownService {
@PlayerCache
private final Map<UUID, Long> cooldowns = new ConcurrentHashMap<>();
public boolean isOnCooldown(UUID playerId) {
Long expiry = cooldowns.get(playerId);
return expiry != null && System.currentTimeMillis() < expiry;
}
public void setCooldown(UUID playerId, long durationMs) {
cooldowns.put(playerId, System.currentTimeMillis() + durationMs);
}
}当玩家退出时,框架会自动调用 cooldowns.remove(playerUuid),无需手动清理。
缓存已存储的数据
@PlayerCache 的 Map 最适合用作存放在别处(例如数据库表)的数据的读缓存。通过缓存读取,每次修改在发生时就写入,玩家退出时由框架移除条目:
package com.ultikits.docs.cache;
import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.Autowired;
import com.ultikits.ultitools.annotations.PlayerCache;
import com.ultikits.ultitools.annotations.Service;
import com.ultikits.ultitools.entities.WhereCondition;
import com.ultikits.ultitools.interfaces.DataOperator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class PlayerSettingsService {
@Autowired
private UltiToolsPlugin plugin;
// A read cache only. Nothing is ever written from it: the framework drops the entry when the
// player quits, and a change is written at the moment it is made.
@PlayerCache
private final Map<UUID, PlayerSettings> settingsCache = new ConcurrentHashMap<>();
public PlayerSettings getSettings(UUID playerId) {
return settingsCache.computeIfAbsent(playerId, id -> {
PlayerSettingsEntity stored = readStored(id);
return stored != null ? PlayerSettings.fromEntity(stored) : new PlayerSettings();
});
}
public void setShowScoreboard(UUID playerId, boolean show) {
DataOperator<PlayerSettingsEntity> operator = plugin.getDataOperator(PlayerSettingsEntity.class);
// Read the stored row now, not the cache: another server may have created or changed it.
PlayerSettingsEntity stored = readStored(playerId);
if (stored == null) {
operator.insert(PlayerSettingsEntity.builder()
.playerId(playerId.toString())
.showScoreboard(show)
.build());
} else {
// Write only the column that changed, so nothing else in the row is touched.
operator.update("show_scoreboard", show, stored.getId());
}
// Drop the cached copy; the next read loads what is stored.
settingsCache.remove(playerId);
}
private PlayerSettingsEntity readStored(UUID playerId) {
List<PlayerSettingsEntity> rows = plugin.getDataOperator(PlayerSettingsEntity.class)
.getAll(WhereCondition.builder().column("player_id").value(playerId.toString()).build());
return rows.isEmpty() ? null : rows.get(0);
}
}setShowScoreboard 读取已存储的行,只写入发生变化的那一列,然后移除缓存条目,让下一次读取载入已存储的值。玩家退出时不会写入任何内容,因为 Map 里没有尚未写入的修改。不要把缓存中的副本写回已存储的行,见在共享 MySQL 数据库上使用缓存。
移除前保存
设置 saveBeforeRemove = true 并实现 PlayerCacheSaver 接口,框架会在移除条目之前调用 savePlayerData(playerUuid)。它适用于只存在于本服务器内存中、需要在玩家离开时写入一次的状态。它不适用于已存储的行的缓存副本,因为副本可能比数据库里的行更旧。
玩家加入本服务器的时间就是这类状态:没有任何已存储的行保存它。下面的服务在玩家退出时为已结束的会话追加一行新记录,不读取也不覆盖任何已存储的行:
package com.ultikits.docs.cache;
import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.Autowired;
import com.ultikits.ultitools.annotations.PlayerCache;
import com.ultikits.ultitools.annotations.PlayerCacheSaver;
import com.ultikits.ultitools.annotations.PreDestroy;
import com.ultikits.ultitools.annotations.Service;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class PlaySessionService implements PlayerCacheSaver {
@Autowired
private UltiToolsPlugin plugin;
// State that exists only in this server's memory: when each player joined here.
// No stored row holds it, so there is nothing a copy could go stale against.
@PlayerCache(saveBeforeRemove = true)
private final Map<UUID, Long> joinedAt = new ConcurrentHashMap<>();
// Called from a join listener.
public void sessionStarted(UUID playerId) {
joinedAt.put(playerId, System.currentTimeMillis());
}
// Called by the framework when the player quits, just before the entry is removed.
@Override
public void savePlayerData(UUID playerId) {
Long start = joinedAt.get(playerId);
if (start == null) {
return;
}
// Append one new row for the finished session. No stored row is read or overwritten.
plugin.getDataOperator(PlaySessionEntity.class).insert(PlaySessionEntity.builder()
.playerId(playerId.toString())
.durationMillis(System.currentTimeMillis() - start)
.build());
}
// The framework does not call savePlayerData when the server stops (players are disconnected
// after plugins are disabled), so write the sessions that are still open.
@PreDestroy
public void saveOpenSessions() {
for (UUID playerId : joinedAt.keySet()) {
savePlayerData(playerId);
}
joinedAt.clear();
}
}服务器停止时仍在线的玩家不会触发 savePlayerData,因此由 @PreDestroy 方法写入尚未结束的会话。
当玩家退出时,框架会:
- 调用
savePlayerData(playerUuid)(因为saveBeforeRemove = true) - 从 Map 中移除该条目
自 v6.3.0 起,每个 @PlayerCache 字段单独处理。如果 savePlayerData 抛出异常(例如数据库不可用),框架会记录一条警告,写明 bean 类、字段和玩家,照样移除该条目,并继续处理所有 bean 的其他字段。v6.3.0 之前,第一个失败会中断清理:该字段及其后所有字段的条目都会留在内存中(#643)。框架不会重试失败的保存;不能丢失数据的模块需要在 savePlayerData 内自行重试或存储。
在共享 MySQL 数据库上使用缓存
多台服务器可以共用一个 MySQL 数据库,例如放在代理端后面。这时某台服务器在内存里持有的行副本,可能比数据库里的行更旧。玩家从服务器 A 去了服务器 B,并在 B 上修改了一项设置。如果 A 仍然持有修改之前的行,并在玩家退出时把它写回,A 的写入会用旧值覆盖 B 上的修改。关服时写回缓存,或者之后每次修改都写回整份副本,也会出现同样的结果。
对存储在数据库中的数据,遵循下面的规则:
- 缓存只在玩家处于本服务器期间有效。玩家加入时或第一次读取时载入条目,玩家退出时由
@PlayerCache移除。 - 永远不要把缓存中的副本写回:数值变化时不写,退出时不写(
saveBeforeRemove),关服时也不写。 - 每次修改都重新读取已存储的行,把修改应用到这一行上再写入。
- 新值取决于读到的旧值时(例如计数器或开关),用
updateIf写入,条件为你读到的各列的值。返回false说明其他写入方先修改了这一行:重新读取并重新判断。尝试几次后仍然失败就放弃(UltiEconomy 最多尝试三次)。 - 增量也按这种比较并设置的循环来写,不要写成缓存值加增量。
自 v6.3.0 起,DataOperator#updateIf 只在已存储的行仍然满足所有条件时才写入实体,详见条件更新。它会写入实体的每个已映射字段,所以条件必须覆盖实体映射的每一列,而不只是你的代码读取或修改的那几列:漏掉的列如果在此期间被其他服务器修改,会被你读到的旧值覆盖。下面的示意假定 PlayerStats 除 id 外只映射 coins 和 kills 两列。下面的示意给一行数据增加金币。它需要 UltiTools-API v6.3.0 或更高版本,不属于编译检查的示例:
// 需要 UltiTools-API v6.3.0 或更高版本(DataOperator#updateIf)。
private static final int MAX_ATTEMPTS = 3;
public boolean addCoins(UUID playerId, int amount) {
DataOperator<PlayerStats> stats = plugin.getDataOperator(PlayerStats.class);
for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
PlayerStats row = stats.getById(playerId.toString()); // 已存储的行,不是缓存
if (row == null) {
return false;
}
int coins = row.getCoins();
int kills = row.getKills();
row.setCoins(coins + amount); // 只应用这一项修改
boolean written = stats.updateIf(row,
WhereCondition.builder().column("coins").value(coins).build(),
WhereCondition.builder().column("kills").value(kills).build());
if (written) {
return true;
}
// 其他写入方先修改了这一行:重新读取并重新判断。
}
return false; // 争用过多:提示玩家重试,而不是写入
}不依赖读到的值的修改,例如把设置切换为玩家选择的值,可以用 update(column, value, id) 只写那一列,PlayerSettingsService 就是这样做的。在 JSON 和 SQLite 后端上,数据属于一台服务器,同样的代码无需改动。
本页的示例在玩家第一次修改设置时为该玩家插入一行。请给表的玩家列(player_id)建立唯一索引,这样两台服务器在同一时刻为同一玩家做首次修改时,不会各存一行:第二次插入会失败而不是再存一行,调用方可以读取已存储的行,把修改写到那一行上。
注解属性
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
saveBeforeRemove | boolean | false | 如果为 true,在移除条目前调用 Bean 的 savePlayerData(UUID) 方法。Bean 必须实现 PlayerCacheSaver 接口。 |
PlayerCacheSaver 接口
public interface PlayerCacheSaver {
void savePlayerData(UUID playerId);
}该接口是可选的,仅在使用 saveBeforeRemove = true 时才需要实现。
多个 Map
一个 Bean 可以有多个 @PlayerCache 字段,每个都会独立清理:
package com.ultikits.docs.cache;
import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.*;
import com.ultikits.ultitools.aop.ExceptionHandler;
import com.ultikits.ultitools.interfaces.DataOperator;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.reflect.Method;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class GameService implements PlayerCacheSaver {
@PlayerCache
private final Map<UUID, Integer> scores = new ConcurrentHashMap<>();
@PlayerCache(saveBeforeRemove = true)
private final Map<UUID, Inventory> openInventories = new ConcurrentHashMap<>();
@PlayerCache
private final Map<UUID, Long> lastActivity = new ConcurrentHashMap<>();
@Override
public void savePlayerData(UUID playerId) {
// Called only for the saveBeforeRemove=true field,
// but you can save all data here
}
}使用要求
- 字段必须是以
UUID为键的Map(自 v6.3.0 起也可以是Set<UUID>,或值为UUID的Map) - 字段必须在容器管理的 Bean 中(
@Service、@CmdExecutor、@EventListener) - 使用
saveBeforeRemove = true时,Bean 必须实现PlayerCacheSaver - 如果 Map 会被异步线程访问,请使用
ConcurrentHashMap
完整示例
package com.ultikits.docs.cache;
import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.Autowired;
import com.ultikits.ultitools.annotations.PlayerCache;
import com.ultikits.ultitools.annotations.Service;
import com.ultikits.ultitools.entities.WhereCondition;
import com.ultikits.ultitools.interfaces.DataOperator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class TeleportRequestService {
@Autowired
private UltiToolsPlugin plugin;
// Pending teleport requests: requester -> target. Held in memory only.
@PlayerCache
private final Map<UUID, UUID> pendingRequests = new ConcurrentHashMap<>();
// Player's teleport settings: a read cache of the stored row, dropped on quit and never written back.
@PlayerCache
private final Map<UUID, TeleportPrefs> preferences = new ConcurrentHashMap<>();
public void sendRequest(UUID from, UUID to) {
pendingRequests.put(from, to);
}
public UUID getRequest(UUID from) {
return pendingRequests.get(from);
}
public void acceptRequest(UUID from) {
pendingRequests.remove(from);
}
public TeleportPrefs getPreferences(UUID playerId) {
return preferences.computeIfAbsent(playerId, id -> {
TeleportPrefsEntity stored = readStored(id);
return stored != null ? TeleportPrefs.fromEntity(stored) : new TeleportPrefs(false);
});
}
public void setAutoAccept(UUID playerId, boolean autoAccept) {
DataOperator<TeleportPrefsEntity> operator = plugin.getDataOperator(TeleportPrefsEntity.class);
// The change is written when it is made, to the row as stored now.
TeleportPrefsEntity stored = readStored(playerId);
if (stored == null) {
operator.insert(TeleportPrefsEntity.builder()
.playerId(playerId.toString())
.autoAccept(autoAccept)
.build());
} else {
operator.update("auto_accept", autoAccept, stored.getId());
}
preferences.remove(playerId);
}
private TeleportPrefsEntity readStored(UUID playerId) {
List<TeleportPrefsEntity> rows = plugin.getDataOperator(TeleportPrefsEntity.class)
.getAll(WhereCondition.builder().column("player_id").value(playerId.toString()).build());
return rows.isEmpty() ? null : rows.get(0);
}
}每个按玩家存储的 Map 都应使用 @PlayerCache
@PlayerCache 可以消除 Minecraft 插件中最常见的内存泄漏来源。建议在每个存储玩家状态的 Map<UUID, ?> 字段上使用它。