PlayerSync/src/main/java/vip/fubuki/playersync/util/JDBCsetUp.java
laforetbrut 59bd884263 perf: zero JDBC on server thread + HikariCP + parallel shutdown + audit fixes
- Migrate connection pool from manual LinkedBlockingQueue to HikariCP
  (eliminates isValid() ping on every query visible in Spark profiler)
- Move ALL DB writes off server thread: logout uses snapshot+async+latch,
  shutdown uses snapshot+CompletableFuture.allOf for parallel saves
- Pre-read curios/accessories/cosmeticarmor/attachments on background
  thread during login (4-7 fewer DB queries on main thread per login)
- Auto-save interval increased to 5 minutes
- Fix pool shutdown ordering: shutdownPool() now runs AFTER all shutdown
  saves complete (previously could fire before, silently losing all data)
- Fix connection leak in executeQuery/executePreparedQuery when
  prepareStatement throws (leaked connections exhaust HikariCP pool)
- Fix duplication bug: saveStorageContents guard used nbt.size()<=1 which
  blocked legitimately emptied backpacks from saving to DB
- Fix stale SaveToFile overwriting logout: check playerLocks.containsKey
  before writing to prevent stale background task from regressing data
- Remove LIMIT 1000 on startup online=0 reset (could leave players stuck)
- Add executorService.shutdown() on server stop to prevent JVM hang
- Add apply methods (applyCuriosFromData, applyAccessoriesFromData, etc.)
  to separate entity writes from DB reads for thread-safe restore
- Add UUID collectors (collectBackpackUuids, collectSSUuids) and
  background save methods for snapshot+async logout/shutdown pattern
2026-03-29 18:58:27 +02:00

231 lines
9.2 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package vip.fubuki.playersync.util;
import com.mojang.logging.LogUtils;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.slf4j.Logger;
import vip.fubuki.playersync.config.JdbcConfig;
import java.sql.*;
/**
* JDBC utility backed by HikariCP connection pool.
*
* Why HikariCP instead of the old manual pool?
* - Old pool called conn.isValid(2) on every borrow → SELECT 1 round-trip → visible as
* "pingInternal" in Spark profiler (~1% server thread constantly).
* - HikariCP uses TCP keepalive and only validates idle connections at a configurable
* interval (keepaliveTime=5min), never on hot-path queries.
* - Automatic reconnection, proper idle-connection eviction, and thread-safe internals
* are all handled by HikariCP without manual LinkedBlockingQueue management.
*/
public class JDBCsetUp {
private static final Logger LOGGER = LogUtils.getLogger();
private static volatile HikariDataSource dataSource;
// -------------------------------------------------------------------------
// Pool lifecycle
// -------------------------------------------------------------------------
/**
* Initialises the HikariCP pool. Must be called once after the MySQL database
* has been created (i.e. at the end of the CREATE DATABASE step in PlayerSync).
* Safe to call again on server-restart scenarios — closes the old pool first.
*/
public static void initPool() {
if (dataSource != null && !dataSource.isClosed()) {
dataSource.close();
}
HikariConfig cfg = new HikariConfig();
cfg.setJdbcUrl(buildUrl(true));
cfg.setUsername(JdbcConfig.USERNAME.get());
cfg.setPassword(JdbcConfig.PASSWORD.get());
// Pool sizing: 2 warm connections, up to 10 under load
cfg.setMaximumPoolSize(10);
cfg.setMinimumIdle(2);
// Connection lifecycle
cfg.setConnectionTimeout(30_000L); // 30 s how long to wait for a free slot
cfg.setIdleTimeout(600_000L); // 10 min evict idle connections
cfg.setMaxLifetime(1_800_000L); // 30 min recycle before MySQL wait_timeout
cfg.setKeepaliveTime(300_000L); // 5 min ping idle connections (NOT hot path)
cfg.setAutoCommit(true);
cfg.setPoolName("PlayerSync");
dataSource = new HikariDataSource(cfg);
LOGGER.info("[PlayerSync] HikariCP pool ready (maxPool={}, minIdle={})",
cfg.getMaximumPoolSize(), cfg.getMinimumIdle());
}
/**
* Closes all pooled connections. Called on server shutdown.
*/
public static void shutdownPool() {
if (dataSource != null && !dataSource.isClosed()) {
dataSource.close();
dataSource = null;
LOGGER.info("[PlayerSync] HikariCP pool closed.");
}
}
// -------------------------------------------------------------------------
// Internal helpers
// -------------------------------------------------------------------------
private static String buildUrl(boolean selectDatabase) {
String dbName = JdbcConfig.DATABASE_NAME.get();
String url = "jdbc:mysql://" + JdbcConfig.HOST.get() + ":" + JdbcConfig.PORT.get();
if (selectDatabase && !dbName.isEmpty()) {
url += "/" + dbName;
}
// No autoReconnect — HikariCP handles reconnection transparently
url += "?useUnicode=true&characterEncoding=utf-8&useSSL=" + JdbcConfig.USE_SSL.get()
+ "&serverTimezone=UTC&allowPublicKeyRetrieval=true";
return url;
}
/**
* Returns a connection from the HikariCP pool (selectDatabase=true)
* or a raw DriverManager connection (selectDatabase=false, used only for
* startup DDL that must run without a selected database).
*
* With HikariCP, calling connection.close() returns the connection to the
* pool — no separate returnConnection() call needed.
*/
public static Connection getConnection(boolean selectDatabase) throws SQLException {
if (!selectDatabase) {
// Raw connection for DDL that runs before/without the pool database
return DriverManager.getConnection(
buildUrl(false), JdbcConfig.USERNAME.get(), JdbcConfig.PASSWORD.get());
}
if (dataSource == null || dataSource.isClosed()) {
throw new SQLException("[PlayerSync] HikariCP pool is not initialised — call initPool() first.");
}
return dataSource.getConnection();
}
public static Connection getConnection() throws SQLException {
return getConnection(true);
}
// -------------------------------------------------------------------------
// Query helpers (API unchanged — callers need no modification)
// -------------------------------------------------------------------------
public static QueryResult executeQuery(String sqlFormatString, Object... args) throws SQLException {
String sql = String.format(sqlFormatString, args);
LOGGER.trace(sql);
Connection connection = getConnection();
try {
PreparedStatement stmt = connection.prepareStatement(sql);
ResultSet rs = stmt.executeQuery();
return new QueryResult(connection, stmt, rs);
} catch (SQLException e) {
try { connection.close(); } catch (SQLException ignored) {}
throw e;
}
}
private static void executeUpdateInternal(boolean selectDatabase, String sqlFormatString, Object... args) throws SQLException {
String sql = String.format(sqlFormatString, args);
LOGGER.trace(sql);
try (Connection conn = getConnection(selectDatabase);
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.executeUpdate();
// conn.close() is called by try-with-resources:
// - pool connection → returned to HikariCP pool
// - raw connection → truly closed
}
}
public static void executeUpdate(String sqlFormatString, Object... args) throws SQLException {
executeUpdateInternal(true, sqlFormatString, args);
}
/** Overload used by startup DDL that must bypass the pool (selectDatabase=false). */
public static void executeUpdate(String sql, int dummy) throws SQLException {
LOGGER.trace(sql);
try (Connection conn = getConnection(false);
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.executeUpdate();
}
}
public static void update(String sql, String... argument) throws SQLException {
LOGGER.trace(sql);
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
for (int i = 0; i < argument.length; i++) {
stmt.setString(i + 1, argument[i]);
}
stmt.executeUpdate();
}
}
public static void executePreparedUpdate(String sql, Object... params) throws SQLException {
LOGGER.trace(sql);
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
for (int i = 0; i < params.length; i++) {
stmt.setObject(i + 1, params[i]);
}
stmt.executeUpdate();
}
}
public static QueryResult executePreparedQuery(String sql, Object... params) throws SQLException {
LOGGER.trace(sql);
Connection conn = getConnection();
try {
PreparedStatement stmt = conn.prepareStatement(sql);
for (int i = 0; i < params.length; i++) {
stmt.setObject(i + 1, params[i]);
}
ResultSet rs = stmt.executeQuery();
return new QueryResult(conn, stmt, rs);
} catch (SQLException e) {
try { conn.close(); } catch (SQLException ignored) {}
throw e;
}
}
// -------------------------------------------------------------------------
// QueryResult — holds connection open until caller closes it
// -------------------------------------------------------------------------
/**
* Auto-closeable holder for a live query result.
* Closing it releases the ResultSet and PreparedStatement, then calls
* connection.close() which returns the connection to the HikariCP pool.
*/
public record QueryResult(
Connection connection,
PreparedStatement preparedStatement,
ResultSet resultSet
) implements AutoCloseable {
@Override
public void close() {
if (resultSet != null) {
try { resultSet.close(); } catch (SQLException e) {
LOGGER.error("[PlayerSync] Error closing ResultSet", e);
}
}
if (preparedStatement != null) {
try { preparedStatement.close(); } catch (SQLException e) {
LOGGER.error("[PlayerSync] Error closing PreparedStatement", e);
}
}
if (connection != null) {
try { connection.close(); } catch (SQLException e) {
LOGGER.error("[PlayerSync] Error returning connection to pool", e);
}
}
}
}
}