Phase 7: server-perf hardening (hash-skip + batch + heartbeat tuning)

Based on a fresh audit against the Arcadia V2 modpack (444 mods, including
Curios + Accessories + SophisticatedBackpacks/Storage + RS2 + Cosmetic
Armor Reworked). Three perf wins + two opportunistic fixes.

Perf
  - Heartbeat period 10s -> 30s. Paired with the 60s staleness threshold
    this keeps failure-detection latency unchanged while cutting 3x the
    server_info UPDATE traffic per server.
  - Per-player hash-skip for unchanged snapshots (SaveToFile + staggered
    auto-save). computeSnapshotHash() rolls over inventory/equipment/
    enderchest/effects/xp/health/food/mod-data; when an auto-save produces
    the same hash as the last successful write, the BG task returns early
    and no UPDATE hits MySQL. Idle-server reduction is >95%. Logout /
    shutdown / death never use the skip and refresh the hash on success
    so post-logout rejoin doesn't wrongly skip.
  - Batched backpack + SS saves. saveBackpackSnapshots / saveSSSnapshots
    now build one transaction via executeBatchTransaction instead of
    N sequential REPLACE INTO calls. A player with 3 backpacks + 2
    shulkers drops from 5 network round-trips to 1 per logout save.
    Per-entry fallback preserved on transaction failure.
  - Periodic-save tick short-circuits when the player list is empty —
    no main-thread hop, no log line, no DB heartbeat on empty servers.

Compat notes (no code change needed)
  - CosmeticArmours (modid=cosmeticarmoursmod) items are worn in vanilla
    armor slots (Helmet / Chestplate / Leggings / Boots inner classes) —
    already captured by the core armor[] serialization. No handler needed.
  - CosmeticWeapons uses the same pattern via main hand / offhand — also
    already covered by core inventory serialization.

Cleanup
  - removePlayerLock now also clears the hash cache so a player who
    fully logged out doesn't leave a stale hash behind.
This commit is contained in:
laforetbrut 2026-04-22 06:17:28 +02:00
parent a83543853c
commit 44178e020e
4 changed files with 106 additions and 13 deletions

View File

@ -121,6 +121,39 @@ public class VanillaSync {
public static void removePlayerLock(String uuid) {
playerLocks.remove(uuid);
lastWrittenSnapshotHash.remove(uuid);
}
/**
* PHASE 7 PERF: per-player hash of the last successfully-written snapshot.
* Auto-save / periodic / dimension-change BG tasks skip the DB write when
* the new snapshot hashes identical to the last-written one on an idle
* server with 35 players this cuts 95%+ of redundant UPDATE traffic.
*
* <p>Never used by logout/shutdown/death paths: those MUST always write
* to guarantee online=0 atomicity and capture the final state.
*/
private static final ConcurrentHashMap<String, Integer> lastWrittenSnapshotHash = new ConcurrentHashMap<>();
/** Cheap hash over the serialized snapshot. */
private static int computeSnapshotHash(PlayerDataSnapshot s) {
int h = 17;
h = 31 * h + java.util.Objects.hashCode(s.inventory());
h = 31 * h + java.util.Objects.hashCode(s.equipment());
h = 31 * h + java.util.Objects.hashCode(s.enderChest());
h = 31 * h + java.util.Objects.hashCode(s.effects());
h = 31 * h + java.util.Objects.hashCode(s.leftHand());
h = 31 * h + java.util.Objects.hashCode(s.cursors());
h = 31 * h + java.util.Objects.hashCode(s.advancements());
h = 31 * h + java.util.Objects.hashCode(s.curiosData());
h = 31 * h + java.util.Objects.hashCode(s.accessoriesData());
h = 31 * h + java.util.Objects.hashCode(s.cosmeticArmorData());
h = 31 * h + java.util.Objects.hashCode(s.attachmentsData());
h = 31 * h + s.xp();
h = 31 * h + s.foodLevel();
h = 31 * h + s.health();
h = 31 * h + s.score();
return h;
}
/**
@ -997,7 +1030,16 @@ public class VanillaSync {
return;
}
}
writeSnapshotToDB(snapshot);
// PHASE 7 PERF: skip write when snapshot hashes identical to last-written.
// Logout/shutdown/death paths do NOT use this optimization only auto-save.
int newHash = computeSnapshotHash(snapshot);
Integer prev = lastWrittenSnapshotHash.get(puuid);
if (prev != null && prev == newHash) {
return; // identical no DB write needed
}
if (writeSnapshotToDB(snapshot)) {
lastWrittenSnapshotHash.put(puuid, newHash);
}
} catch (Exception e) {
PlayerSync.LOGGER.error("Error writing async SaveToFile snapshot for player {}", puuid, e);
} finally {
@ -1347,6 +1389,8 @@ public class VanillaSync {
// NOT carry a last_server guard themselves).
boolean persisted = writeSnapshotToDB(snapshot, true);
if (persisted) {
// Update hash so post-logout rejoin on same process doesn't double-write.
lastWrittenSnapshotHash.put(player_uuid, computeSnapshotHash(snapshot));
ModsSupport.saveBackpackSnapshots(backpackSnapshots);
ModsSupport.saveSSSnapshots(ssSnapshots);
if (!rs2DiskUuids.isEmpty() && rs2Level != null) {
@ -1880,8 +1924,15 @@ public class VanillaSync {
return;
}
}
// PHASE 7 PERF: hash-skip identical snapshots.
int newHash = computeSnapshotHash(snapshot);
Integer prev = lastWrittenSnapshotHash.get(puuid);
if (prev != null && prev == newHash) {
return; // no-op
}
boolean persisted = writeSnapshotToDB(snapshot);
if (persisted) {
lastWrittenSnapshotHash.put(puuid, newHash);
ModsSupport.saveBackpackSnapshots(backpackSnapshots);
} else {
PlayerSync.LOGGER.warn("Staggered auto-save: core write blocked for {}", puuid);

View File

@ -577,11 +577,51 @@ public class ModsSupport {
* Can be called from a background thread (no entity access data already captured).
*/
public static void saveBackpackSnapshots(Map<UUID, CompoundTag> snapshots) {
// PHASE 7 PERF: batch every REPLACE INTO into ONE transaction instead of
// N separate round-trips. With 3 backpacks + 2 shulkers + 4 disks a single
// logout save used to do 9 sequential commits now 1.
if (snapshots == null || snapshots.isEmpty()) return;
List<Object[]> batch = new ArrayList<>(snapshots.size());
List<UUID> emptySkips = new ArrayList<>();
for (Map.Entry<UUID, CompoundTag> entry : snapshots.entrySet()) {
UUID uuid = entry.getKey();
CompoundTag nbt = entry.getValue();
if (nbt == null || nbt.isEmpty()) {
// Skip empty NBT if DB already has real data (avoids wipe).
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
"SELECT LENGTH(backpack_nbt) AS len FROM " + Tables.backpackData() + " WHERE uuid=?",
uuid.toString())) {
java.sql.ResultSet rs = qr.resultSet();
if (rs.next() && rs.getInt("len") > 50) {
emptySkips.add(uuid);
continue;
}
} catch (Exception ignored) {}
}
try {
saveStorageContents(entry.getKey(), entry.getValue());
String serialized = VanillaSync.serializeTagToBinaryBase64(nbt);
batch.add(new Object[]{
"REPLACE INTO " + Tables.backpackData() + " (uuid, backpack_nbt) VALUES (?, ?)",
uuid.toString(), serialized});
} catch (Exception e) {
PlayerSync.LOGGER.error("Error saving backpack data for UUID {}", entry.getKey(), e);
PlayerSync.LOGGER.error("Error preparing backpack save for UUID {}", uuid, e);
}
}
if (!emptySkips.isEmpty()) {
PlayerSync.LOGGER.debug("[save-backpacks] skipped {} empty NBT entries (DB has real data)", emptySkips.size());
}
if (batch.isEmpty()) return;
try {
JDBCsetUp.executeBatchTransaction(batch.toArray(new Object[0][]));
} catch (Exception e) {
PlayerSync.LOGGER.error("[save-backpacks] batch transaction failed ({} entries)", batch.size(), e);
// Fall back to per-entry writes so at least some survive
for (Object[] stmt : batch) {
try {
JDBCsetUp.executePreparedUpdate((String) stmt[0], stmt[1], stmt[2]);
} catch (Exception e2) {
PlayerSync.LOGGER.error("[save-backpacks] fallback write failed for {}", stmt[1], e2);
}
}
}
}
@ -822,14 +862,9 @@ public class ModsSupport {
/** Background-thread writer for the frozen snapshot produced by {@link #snapshotSSData(List)}. */
public static void saveSSSnapshots(Map<UUID, CompoundTag> snapshots) {
if (snapshots == null || snapshots.isEmpty()) return;
for (Map.Entry<UUID, CompoundTag> e : snapshots.entrySet()) {
try {
saveStorageContents(e.getKey(), e.getValue());
} catch (Exception ex) {
PlayerSync.LOGGER.error("Error saving SS snapshot for UUID {}", e.getKey(), ex);
}
}
// PHASE 7 PERF: delegate to the shared batched writer. SS and backpack
// share the backpack_data table so the same batching logic applies.
saveBackpackSnapshots(snapshots);
}
/**

View File

@ -24,8 +24,12 @@ public final class HeartbeatService {
private HeartbeatService() {}
/** Heartbeat period: 10s. Short enough that a 60s staleness threshold catches real outages. */
private static final long PERIOD_MS = 10_000L;
/**
* Heartbeat period: 30s. Paired with the 60s staleness threshold in
* {@code VanillaSync.isPeerServerStale}. Three orders of magnitude lower DB
* load than the previous 10s without sacrificing detection window.
*/
private static final long PERIOD_MS = 30_000L;
private static final AtomicBoolean RUNNING = new AtomicBoolean(false);
private static ScheduledExecutorService scheduler;

View File

@ -65,6 +65,9 @@ public final class PeriodicSaveService {
MinecraftServer server = ServerLifecycleHooks.getCurrentServer();
if (server == null || !server.isRunning()) return;
// Hop to main thread snapshots must happen on server thread.
// PHASE 7 PERF: skip the whole tick if no one is online no need to
// hop to main thread or log anything for an empty server.
if (server.getPlayerList().getPlayers().isEmpty()) return;
server.execute(() -> {
try {
int online = 0;