Aller au contenu

· Développement

Donner une base de données à son plugin : SQLite ou MySQL

Schéma d’une base de données SQLite reliée à un serveur Minecraft Paper

La plupart des tutoriels sur la persistance dans un plugin Minecraft t’apprennent à casser ton serveur. Ils te font embarquer un pilote déjà présent, ils utilisent l’UUID du joueur comme clé primaire sans dire un mot du mode hors ligne, et ils lancent leurs requêtes en plein milieu de la boucle de jeu. Le plugin marche en test avec deux joueurs, puis perd des données et fait chuter le TPS en production. Voici les trois décisions qui comptent vraiment, avec le code qui va avec.

Ce que dit le parc réel, pas la documentation

Trois chiffres qui devraient décider de ton schéma

Commençons par une mesure, parce que c’est ce qui manque partout ailleurs. Sur un relevé de 62 948 serveurs remontés par bStats, la version la plus déployée est 1.21.11 avec 36,4 % du parc. Vient ensuite 26.1.2 à 14,9 %, puis 26.2 à 7,4 % et 1.21.4 à 7,2 %. Autrement dit : malgré le passage de Minecraft au versionnage calendaire, la famille 1.21 reste majoritaire, et de loin.

Part du parcCe que ça implique pour ta base
1.21.1136,4 %Ta cible de compatibilité par défaut
26.1.214,9 %La cible de production raisonnable en calendaire
26.27,4 %Pas de build stable Paper, à éviter en prod
1.21.47,2 %Encore assez présent pour compter

Deux conséquences directes. D’abord, si tu vises 26.2, tu vises une version dont Paper n’a pas de build stable : la cible de production raisonnable aujourd’hui est 26.1.2. Ensuite, Java 25 est le plancher des versions calendaires, mais compiler ton plugin en Java 21 reste le bon choix : c’est ce que fait tourner la majorité du parc, et un .jar compilé en Java 21 se charge sans problème sur un runtime plus récent. L’inverse est faux, et c’est exactement l’erreur qui produit un UnsupportedClassVersionError au démarrage.

Le troisième chiffre est le plus lourd de conséquences, et personne ne l’explique dans un tutoriel de base de données : 75,1 % des serveurs tournent en mode hors ligne. On y revient plus bas, parce que c’est ce qui décide de ton schéma.

Le pilote SQLite est déjà dans Paper

Zéro dépendance à ajouter pour SQLite

Voilà le fait que la moitié des tutoriels ignore : le pilote JDBC SQLite est déjà fourni avec Paper. Tu n’as rien à embarquer, rien à déclarer, rien à relocaliser. Tu ouvres une connexion et ça marche. HikariCP, lui, n’est pas fourni, et c’est une distinction qui change complètement la façon d’écrire son plugin.yml.

plugin.yml
name: PlayerData
version: 1.0.0
main: fun.example.playerdata.PlayerDataPlugin
api-version: '1.21'
folia-supported: true
# Aucun bloc libraries pour SQLite : le pilote est deja fourni par Paper.

L’ouverture tient en une quinzaine de lignes. Trois PRAGMA méritent d’être posés tout de suite, et le troisième surprend même des développeurs expérimentés : dans SQLite, les clés étrangères sont désactivées par défaut. Ton ON DELETE CASCADE ne fait rien tant que tu n’as pas exécuté PRAGMA foreign_keys = ON, et il faut le refaire à chaque connexion.

Java
package fun.example.playerdata;

import org.bukkit.plugin.java.JavaPlugin;

import java.io.File;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;

public final class PlayerDataPlugin extends JavaPlugin {

    private Connection connection;
    private PlayerStore store;

    @Override
    public void onEnable() {
        if (!getDataFolder().exists() && !getDataFolder().mkdirs()) {
            getLogger().severe("Dossier de donnees impossible a creer, plugin desactive.");
            getServer().getPluginManager().disablePlugin(this);
            return;
        }

        File file = new File(getDataFolder(), "data.db");
        try {
            // Paper fournit le pilote. Cet appel ne sert qu'a forcer son
            // enregistrement quand le classloader du plugin ne l'a pas encore vu.
            Class.forName("org.sqlite.JDBC");
            connection = DriverManager.getConnection("jdbc:sqlite:" + file.getAbsolutePath());

            try (Statement st = connection.createStatement()) {
                st.execute("PRAGMA journal_mode = WAL");
                st.execute("PRAGMA busy_timeout = 5000");
                st.execute("PRAGMA foreign_keys = ON");
            }
        } catch (ClassNotFoundException | SQLException e) {
            getLogger().severe("Base indisponible, plugin desactive : " + e.getMessage());
            getServer().getPluginManager().disablePlugin(this);
            return;
        }

        store = new PlayerStore(this, connection);
        store.createTables();

        getServer().getPluginManager().registerEvents(
            new PlayerDataListener(this, store), this);
    }

    @Override
    public void onDisable() {
        if (store != null) {
            store.flushBlocking();
        }
        if (connection != null) {
            try {
                connection.close();
            } catch (SQLException ignored) {
                // Plus rien d'utile a faire a l'arret.
            }
        }
    }
}

Note le disablePlugin dans les deux branches d’échec. Un plugin de persistance qui n’arrive pas à ouvrir sa base doit s’éteindre, pas continuer en silence avec un NullPointerException à chaque connexion de joueur. Note aussi l’enregistrement des écouteurs dans onEnable : un plugin qui les oublie compile parfaitement et ne fait rien du tout. La référence des méthodes disponibles est dans la Javadoc de Paper et pour la partie Bukkit historique sur hub.spigotmc.org.

Ne shade jamais le pilote SQLite

Embarquer sqlite-jdbc dans ton .jar alors que Paper le fournit déjà te donne deux copies du même pilote, chargées par deux classloaders différents. Résultat : des erreurs de type ClassNotFoundException ou No suitable driver qui n’ont aucun sens quand on lit le code, et un .jar inutilement gros. Pour tout ce qui n’est pas fourni, le bloc libraries: de plugin.yml remplace le shading : tu déclares la dépendance, le serveur la télécharge au démarrage. C’est documenté sur docs.papermc.io.

L’UUID en mode hors ligne n’est pas une identité

Le vrai piège, celui qui touche trois serveurs sur quatre

Tous les tutoriels écrivent la même ligne : player_uuid TEXT PRIMARY KEY. C’est correct sur un serveur en mode en ligne, où l’UUID vient de Mojang et ne change jamais. Mais 75,1 % des serveurs tournent en mode hors ligne, et dans ce mode l’UUID n’est pas attribué par Mojang : il est dérivé du pseudo. Le pseudo est la source de vérité, l’UUID n’en est qu’un condensé. Le comportement du paramètre online-mode est décrit sur le wiki officiel.

Deux scénarios très ordinaires suffisent alors à détruire tes données. Un joueur change de pseudo : son UUID change, il revient sur le serveur avec une identité neuve, son économie et sa progression restent orphelines dans la table. Ou bien l’administrateur passe le serveur en mode en ligne, souvent parce qu’il branche un proxy ou qu’il veut que les pseudos soient enfin vérifiés par Mojang : ce jour-là, tous les UUID changent d’un coup, et plus une seule ligne de la base ne correspond à un joueur connu. Personne n’a supprimé quoi que ce soit, et pourtant tout est perdu.

Le symptôme que tu vas voir

Le ticket ne dira jamais « problème d’UUID ». Il dira « j’ai perdu mes sous après avoir changé de nom », ou « depuis la mise à jour tout le monde est reparti de zéro ». C’est la même cause. Et comme la table contient toujours les anciennes lignes, la restauration d’une sauvegarde ne répare rien.
Java
package fun.example.playerdata;

import org.bukkit.Bukkit;
import org.bukkit.entity.Player;

import java.util.Locale;

public final class Identity {

    private Identity() {
    }

    /**
     * En mode en ligne, l'UUID vient de Mojang et survit a un changement de pseudo.
     * En mode hors ligne, il est derive du pseudo : il change avec lui.
     * On garde donc les deux colonnes et on ne cle jamais les autres tables dessus.
     */
    public static String offlineKey(Player player) {
        return player.getName().toLowerCase(Locale.ROOT);
    }

    public static boolean onlineMode() {
        return Bukkit.getServer().getOnlineMode();
    }

    public static void warnIfRisky(org.bukkit.plugin.Plugin plugin) {
        if (!onlineMode()) {
            plugin.getLogger().warning(
                "Serveur en mode hors ligne : les UUID sont derives des pseudos. "
                    + "Les donnees sont indexees sur offline_key, pas sur l'UUID.");
        }
    }
}

Un avertissement au démarrage quand le serveur est en mode hors ligne coûte trois lignes et évite des heures de diagnostic à celui qui héritera de ton plugin.

Le schéma qui survit à un changement de pseudo

Une clé interne, deux colonnes d’identité

La solution n’est pas de choisir entre l’UUID et le pseudo, c’est de ne relier aucune de tes tables métier à l’un des deux. Tu crées un identifiant interne, un simple entier auto-incrémenté, et tes tables de données pointent dessus. Les colonnes d’identité vivent dans une seule table, celle des joueurs. Le jour où une identité change, tu écris un UPDATE sur une ligne, et toute l’historique suit sans bouger.

SQL
CREATE TABLE IF NOT EXISTS players (
  id           INTEGER PRIMARY KEY AUTOINCREMENT,
  mojang_uuid  TEXT    UNIQUE,
  offline_key  TEXT    UNIQUE,
  last_name    TEXT    NOT NULL,
  first_seen   INTEGER NOT NULL,
  updated_at   INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS balances (
  player_id  INTEGER PRIMARY KEY REFERENCES players(id) ON DELETE CASCADE,
  coins      INTEGER NOT NULL DEFAULT 0
);

CREATE INDEX IF NOT EXISTS idx_players_last_name ON players(last_name);

Avec ce schéma, balances, homes, statistiques, tout ce que tu ajouteras plus tard référence players.id. Migrer un serveur du mode hors ligne au mode en ligne devient une opération de maintenance ordinaire au lieu d’une perte de données. C’est la seule différence structurelle entre un plugin de tutoriel et un plugin qui tient dans le temps.

Garde toujours last_name à jour

Le pseudo courant n’est pas qu’un confort d’affichage, c’est ton unique moyen de retrouver un joueur quand un administrateur te demande « rends ses sous à Kevin ». Mets-le à jour à chaque connexion, dans le même UPSERT que le reste.
Java
package fun.example.playerdata;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.UUID;

public final class PlayerStore {

    private static final String UPSERT = """
        INSERT INTO players (mojang_uuid, offline_key, last_name, first_seen, updated_at)
        VALUES (?, ?, ?, ?, ?)
        ON CONFLICT(offline_key) DO UPDATE SET
          mojang_uuid = excluded.mojang_uuid,
          last_name   = excluded.last_name,
          updated_at  = excluded.updated_at
        """;

    private final PlayerDataPlugin plugin;
    private final Connection connection;

    public PlayerStore(PlayerDataPlugin plugin, Connection connection) {
        this.plugin = plugin;
        this.connection = connection;
    }

    public int loadCoins(UUID uuid, String offlineKey) {
        String sql = """
            SELECT b.coins FROM balances b
            JOIN players p ON p.id = b.player_id
            WHERE p.offline_key = ? OR p.mojang_uuid = ?
            """;
        // PreparedStatement, jamais de concatenation : un pseudo est une saisie utilisateur.
        try (PreparedStatement ps = connection.prepareStatement(sql)) {
            ps.setString(1, offlineKey);
            ps.setString(2, uuid.toString());
            try (ResultSet rs = ps.executeQuery()) {
                return rs.next() ? rs.getInt(1) : 0;
            }
        } catch (SQLException e) {
            plugin.getLogger().warning("Lecture du solde impossible : " + e.getMessage());
            return 0;
        }
    }
}

Un accès base ne touche jamais le thread principal

La règle non négociable

Le thread principal exécute la boucle de jeu. Tant qu’il est occupé, rien n’avance : ni les mobs, ni la redstone, ni les paquets réseau. Une requête faite depuis un écouteur d’événement met donc le serveur entier en pause le temps de la réponse. Sur SQLite en local ça passe inaperçu en test, et ça se voit très bien le soir avec du monde connecté. Sur MySQL distant, chaque aller-retour réseau est un gel visible du TPS. Les accès base ne doivent jamais bloquer le thread principal, point.

Le motif qui marche tient en trois règles. Lire de façon asynchrone à la connexion, garder la valeur chaude en mémoire pendant la session, écrire de façon asynchrone à la déconnexion. Pendant le jeu, aucune commande, aucun événement ne parle à la base : ils parlent au cache.

Java
package fun.example.playerdata;

import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;

import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

public final class PlayerDataListener implements Listener {

    private final PlayerDataPlugin plugin;
    private final PlayerStore store;
    private final Map<UUID, Integer> cache = new ConcurrentHashMap<>();

    public PlayerDataListener(PlayerDataPlugin plugin, PlayerStore store) {
        this.plugin = plugin;
        this.store = store;
    }

    @EventHandler(priority = EventPriority.MONITOR)
    public void onJoin(PlayerJoinEvent event) {
        Player player = event.getPlayer();
        UUID uuid = player.getUniqueId();
        String key = Identity.offlineKey(player);

        // La lecture part hors du thread de jeu. Le joueur joue deja, il n'attend rien.
        Bukkit.getAsyncScheduler().runNow(plugin, task -> {
            int coins = store.loadCoins(uuid, key);
            cache.put(uuid, coins);

            // Retour sur le thread qui possede l'entite. Valable aussi sur Folia.
            player.getScheduler().run(plugin, back -> {
                if (player.isOnline()) {
                    player.sendMessage(Component.text(
                        "Solde charge : " + coins, NamedTextColor.YELLOW));
                }
            }, null);
        });
    }

    @EventHandler
    public void onQuit(PlayerQuitEvent event) {
        UUID uuid = event.getPlayer().getUniqueId();
        Integer coins = cache.remove(uuid);
        if (coins == null) {
            return;
        }
        String key = Identity.offlineKey(event.getPlayer());
        Bukkit.getAsyncScheduler().runNow(plugin, task -> store.saveCoins(uuid, key, coins));
    }

    /** Lecture instantanee depuis la memoire : aucun acces base sur le thread de jeu. */
    public int coins(UUID uuid) {
        return cache.getOrDefault(uuid, 0);
    }
}

Deux détails valent le détour. Bukkit.getAsyncScheduler() est le planificateur asynchrone de Paper, et il fonctionne aussi bien sur un serveur classique que sur Folia, contrairement aux vieux runTaskAsynchronously des tutoriels. Et le retour vers le joueur passe par player.getScheduler(), pas par le thread principal : sur Folia, le thread qui possède le joueur n’est pas forcément celui que tu crois.

Trois erreurs classiques dans ce motif

Fermer la connexion dans onDisable pendant que des écritures asynchrones sont encore en vol : tu perds les dernières sauvegardes, d’où le flushBlocking avant la fermeture. Appeler l’API Bukkit depuis le thread asynchrone, ce qui provoque des exceptions aléatoires impossibles à reproduire. Et concaténer un pseudo dans une requête au lieu d’utiliser un PreparedStatement : en mode hors ligne, un pseudo est une saisie utilisateur arbitraire, donc une injection SQL potentielle.

Quand MySQL devient nécessaire

La seule vraie frontière technique

MySQL n’est pas la version sérieuse de SQLite. C’est un outil qui résout un problème précis : plusieurs processus qui doivent lire et écrire les mêmes données. Un fichier SQLite est fait pour un seul processus. Deux serveurs Minecraft pointés sur le même fichier, surtout via un partage réseau, c’est de la corruption programmée, et les limites de verrouillage sont documentées par SQLite lui-même.

SQLiteMySQL
PiloteDéjà dans PaperÀ déclarer dans libraries:
InstallationAucune, un fichierUn serveur à héberger
Un seul serveurLe bon choixComplexité inutile
Serveurs multiplesCorruption assuréeLa raison d’exister
SauvegardeCopier le fichierUn dump à planifier
Point de panneAucun de plusLe réseau et la base

Si tu passes à MySQL, il te faut un pool de connexions, et donc HikariCP, qui n’est pas fourni. C’est exactement le cas d’usage du bloc libraries:. Déclare aussi le pilote MySQL plutôt que de supposer qu’il est présent.

plugin.yml
name: PlayerData
version: 1.0.0
main: fun.example.playerdata.PlayerDataPlugin
api-version: '1.21'
folia-supported: true
libraries:
  - com.zaxxer:HikariCP:5.1.0
  - com.mysql:mysql-connector-j:8.4.0
Java
package fun.example.playerdata;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

public final class Pool {

    private Pool() {
    }

    public static HikariDataSource create(String host, int port, String database,
                                          String user, String password) {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:mysql://" + host + ":" + port + "/" + database);
        config.setUsername(user);
        config.setPassword(password);

        // Un serveur Minecraft n'a qu'un thread de jeu. Un pool enorme ne le rend pas
        // plus rapide, il rend juste les fuites de connexion plus longues a reperer.
        config.setMaximumPoolSize(6);
        config.setMinimumIdle(2);
        config.setConnectionTimeout(5000);
        config.setPoolName("PlayerData");
        config.addDataSourceProperty("cachePrepStmts", "true");
        return new HikariDataSource(config);
    }
}

Vault reste la bonne porte pour l’économie

Si ta donnée est un solde d’argent, regarde d’abord Vault, le standard d’interopérabilité de l’écosystème. Un plugin qui expose son économie via Vault fonctionne avec tout le reste du serveur. Réinventer sa propre table de monnaie isolée, c’est se condamner à ne jamais s’intégrer.

Folia et les régions

Le multithread change les règles

Folia exécute les régions du monde sur plusieurs threads. Un plugin écrit en supposant un thread principal unique ne ralentit pas, il fait crasher le serveur. La bonne nouvelle, c’est que le motif décrit plus haut est déjà compatible : le travail base est asynchrone, et le retour se fait via le planificateur de l’entité concernée. Pour tout ce qui touche à un bloc ou à une position plutôt qu’à une entité, c’est le RegionScheduler qu’il faut utiliser, jamais le planificateur global. La documentation de référence est sur docs.papermc.io.

Déclarer folia-supported: true dans plugin.yml sans avoir fait ce travail est pire que de ne rien déclarer : le serveur te croit et charge le plugin.

Ce qui casse en vrai

Les limites que tu vas rencontrer

Voici la liste des choses qui échouent en production et qu’aucun tutoriel ne mentionne.

  • Le code copié depuis un tutoriel 1.21 ne compile plus. Paper 26.x embarque Adventure 5, qui a supprimé des API qui n’étaient auparavant que dépréciées. Tout ce qui touche aux messages et au texte affiché aux joueurs est concerné en premier.
  • La désobfuscation a changé le paysage. Minecraft est désobfusqué depuis 26.1 et le remapper de Paper a disparu. Les vieilles recettes d’accès aux internes du serveur, encore recopiées partout, ne décrivent plus la réalité.
  • SQLITE_BUSY sous charge. Deux threads de ton propre plugin qui écrivent en même temps suffisent. Le mode WAL et un busy_timeout règlent l’essentiel, mais il faut les poser explicitement.
  • La commande /reload. Elle laisse des connexions ouvertes et des tâches orphelines. Sur un plugin qui gère une base, elle produit des corruptions de données difficiles à relier à leur cause. Redémarre le serveur.
  • Le cache jamais vidé. Un HashMap alimenté à la connexion mais pas nettoyé à la déconnexion est une fuite mémoire lente. Elle se voit après plusieurs jours d’uptime, jamais en test.
  • L’absence de plan de sauvegarde. Copier un fichier SQLite pendant que le serveur écrit dedans donne une sauvegarde inutilisable. Copie-le serveur arrêté, ou utilise le mécanisme de sauvegarde en ligne de SQLite.

L’arbitrage que je recommande

Mon avis, sans nuance inutile

Un seul serveur Minecraft, quel que soit le nombre de joueurs : SQLite. Le pilote est déjà là, il n’y a rien à héberger, rien à surveiller, la sauvegarde est un cp, et le seul point de panne supplémentaire est un fichier sur ton disque. Je n’ai jamais vu un serveur unique atteindre une limite de SQLite qui ne venait pas, en réalité, d’une requête sur le thread principal ou d’un index manquant.

SQLite, ce que tu gagnes

Pilote fourni par Paper

Zéro service à héberger

Sauvegarde par copie de fichier

Aucune latence réseau

SQLite, ce que tu perds

Un seul processus écrivain

Pas de partage entre serveurs

Clés étrangères à activer à la main

Verrous à configurer soi-même

À partir du deuxième serveur qui doit voir les mêmes données, un lobby et un survie derrière un proxy par exemple, MySQL devient le bon outil et le seul raisonnable. Pas avant. Et si tu hésites parce que « plus tard il y en aura peut-être plusieurs », écris ton accès aux données derrière une petite interface : passer de l’un à l’autre coûtera une classe, pas une réécriture.

Un plugin avec sa base, sans écrire le code de plomberie

Décris ce que ton plugin doit retenir. Minax génère le Java, gère la persistance et te rend un .jar compilé, prêt à tester en ligne.

Créer mon plugin

Conclusion

Trois décisions suffisent à séparer un plugin qui tient d’un plugin qui perd des données. Choisis SQLite tant que tu n’as qu’un serveur, parce que son pilote est déjà dans Paper. Ne relie jamais tes tables métier à l’UUID d’un joueur, parce que sur 75,1 % des serveurs cet UUID n’est qu’un condensé de pseudo. Et ne laisse jamais une requête toucher le thread principal. Le reste, index, migrations, pool de connexions, découle de ces trois choix. Pour la suite, notre guide de dépannage couvre les erreurs de démarrage les plus fréquentes.

Articles connexes