Performance unter 10k Spielern — was wirklich Engpass ist
Was wirklich passiert, wenn ein Minecraft-Server 10.000 aktive Spieler bedient: wo Hibernate, der Main-Thread und die JVM tatsächlich stocken — und welche Architektur-Entscheidungen es vermeiden.
Vor zwei Jahren saß ich nachts vor einem Server, dessen Tick-Rate unter Last von 20 auf 7 fiel. Achttausend Spieler online, Wirtschafts-System mit Auktionen, drei Custom-Game-Modi parallel. Auf dem Papier ein gutes Plugin-Set. In der Praxis ein Performance-Albtraum.
Was ich in den folgenden Monaten gelernt habe, ist die Grundlage für JEHibernate und für die Architektur, mit der ich heute Plugins schreibe, die unter zehntausend Spielern stabil bleiben. Dieser Post ist die Ablage von was tatsächlich Engpass ist — nicht das, was in Performance-Tutorials steht, sondern das, was man auf einem produktiven Server unter Spitzenlast misst.
Die drei Engpässe, die wirklich existieren
Wenn dein Server unter Last zuckt, ist die Ursache fast immer einer dieser drei Punkte. Garbage Collection, fehlerhafte Worldgen oder „zu viele Entities" sind Folgeerscheinungen, nicht Ursachen.
1. Der Main-Thread ist heilig
Bukkit, Spigot und Paper sind im Kern single-threaded. Alles, was auf der Welt passiert — Block-Updates, Entity-Tick, Spieler-Bewegung, Chunk-Generation — läuft auf einem Main-Thread, der zwanzig Mal pro Sekunde durchgehen muss. Dauert ein Tick länger als 50 ms, sehen Spieler Lag.
Die häufigste Sünde, die ich in fremden Plugins sehe: ein synchroner Datenbank-Zugriff im Event-Handler.
@EventHandler
public void onJoin(PlayerJoinEvent e) {
PlayerStats stats = jdbc.queryForObject(
"SELECT * FROM player_stats WHERE id = ?",
new Object[]{ e.getPlayer().getUniqueId() },
new StatsMapper()
);
e.getPlayer().sendMessage("Welcome back! Kills: " + stats.kills);
}Sieht harmlos aus. Bei 50 Joins pro Sekunde — ganz normaler Wert auf einem populären Server — werden hier 50 synchrone DB-Queries auf dem Main-Thread ausgeführt. Bei 5 ms pro Query sind das 250 ms purer Stillstand. Der Server hat in dieser Sekunde fünf Ticks verloren.
Lösung: alles, was nicht strikt sofort sein muss, geht async. JEHibernate macht das per Default. Die obige Query wird:
@EventHandler
public void onJoin(PlayerJoinEvent e) {
UUID id = e.getPlayer().getUniqueId();
statsRepo.findById(id).thenAccept(opt -> {
opt.ifPresent(s -> Bukkit.getScheduler().runTask(plugin, () ->
e.getPlayer().sendMessage("Welcome back! Kills: " + s.kills)
));
});
}Die Query läuft auf einem dedizierten Pool, der Main-Thread bleibt sauber. Das runTask-Wrapping ist nötig, weil Bukkit-API-Aufrufe selbst wieder synchron sein müssen — aber jetzt unterbricht nicht die Datenbank den Tick, sondern nur eine 1-µs-Lambda-Ausführung.
2. N+1 — der stumme Killer
Klassisches Hibernate-Problem, das in Plugins doppelt schmerzt, weil man es auch noch auf dem Main-Thread macht.
Beispiel: Spielerprofil mit zugehörigen Achievements. Naive Implementierung: eine Query für den Spieler, dann pro Achievement eine weitere — bei 30 Achievements sind das 31 Queries. Auf einem Server mit 10.000 Spielern und einem Auktionshaus, das Spielerprofile häufig auflöst, summieren sich diese N+1-Patterns zu Tausenden überflüssiger Queries pro Minute.
JEHibernate empfiehlt explizit Fetch-Strategien per Query:
public CompletableFuture<Optional<PlayerProfile>> findFull(UUID id) {
return runAsync(session -> session
.createQuery(
"SELECT p FROM PlayerProfile p " +
"LEFT JOIN FETCH p.achievements " +
"LEFT JOIN FETCH p.statistics " +
"WHERE p.id = :id",
PlayerProfile.class
)
.setParameter("id", id)
.uniqueResultOptional()
);
}Eine Query, drei Tabellen, kein Lazy-Loading-Drama. Die Latenz verschiebt sich von „31 × 0,5 ms" auf „eine Query mit Join" — auf modernen DBs mit Indexen unter 5 ms.
3. Connection-Pool-Größe ist nicht „mehr ist besser"
HikariCP-Defaults sind für Web-Applikationen geschrieben. Web-Applikationen haben hunderte gleichzeitige Requests, jeder kurz. Minecraft-Plugins haben fünf bis zwanzig parallel laufende Worker-Threads, die kontinuierlich Daten lesen und schreiben.
Konkret: ein Pool von 50 Connections für 5 Worker-Threads ist Verschwendung. Ein Pool von 5 Connections für ein Plugin mit 20 parallelen Tasks ist eine Bremse. Ich tune so:
hikari.maximumPoolSize = (workerThreads * 2) + 1
hikari.minimumIdle = workerThreads
hikari.connectionTimeout = 5000 // ms
hikari.idleTimeout = 60000 // ms
hikari.maxLifetime = 1800000 // 30 min
hikari.leakDetectionThreshold = 30000Die Formel (threads × 2) + 1 ist die HikariCP-Empfehlung für Workloads mit mehr CPU als I/O — typisch für Game-Server, weil ein nicht trivialer Teil der Persistenz-Arbeit Serialisierung und Validierung ist, nicht nur Netz-I/O.
Leak-Detection an. Immer. Eine vergessene Session in einem Async-Pfad fängt nach drei Tagen an, deinen Server zu ersticken — und Hikari schreibt dir genau, in welcher Methode es passiert ist.
Was JEHibernate konkret löst
Das alles wäre Folklore, wenn man es in jedem Plugin einzeln umsetzen müsste. JEHibernate ist die Sammlung dieser Patterns als kleiner Wrapper:
- Eine zentrale, plugin-eigene SessionFactory mit korrektem Class-Loader-Setup (das Problem #1 jedes Hibernate-in-Plugin-Versuchs).
- Eine
RepositoryService-Abstraktion, die CRUD-Operationen automatisch async ausführt und einCompletableFuturezurückgibt — der einzige Weg, synchron zu arbeiten, ist ein expliziterblockingGet()-Aufruf, der sich unangenehm genug anfühlt, dass man ihn nicht versehentlich verwendet. - Eingebaute Flyway-Integration für Schema-Migrationen ohne
hbm2ddl=update. Live-Servern undhbm2ddlsollte man niemals zusammenbringen. - Sane HikariCP-Defaults, die für Game-Server-Workloads getunt sind, plus Leak-Detection an by default.
Caching — der einzige echte Hebel ab 5k Spielern
Selbst mit perfekt async-Hibernate kommst du irgendwann an die Wand. Bei 10k Spielern und einem Spielerprofil, das beim Login + Inventur + Ranking-Anzeige + Auktionshaus-Lookup abgefragt wird, sind das schnell tausende DB-Hits pro Minute für Daten, die sich pro Spieler vielleicht alle paar Minuten einmal ändern.
Die Lösung ist klassisch: Read-Through-Cache. Caffeine als L1-In-Memory pro Server, optional Redis als L2 für Multi-Server-Setups.
private final AsyncLoadingCache<UUID, PlayerProfile> cache =
Caffeine.newBuilder()
.maximumSize(20_000)
.expireAfterWrite(Duration.ofMinutes(5))
.refreshAfterWrite(Duration.ofMinutes(2))
.buildAsync(this::loadFromDb);
public CompletableFuture<PlayerProfile> get(UUID id) {
return cache.get(id);
}
private CompletableFuture<PlayerProfile> loadFromDb(UUID id, Executor ex) {
return repo.findById(id).thenApply(opt -> opt.orElseGet(...));
}Jetzt landet der typische Lookup in 200 ns statt 5 ms — eine 25.000-fache Beschleunigung. Bei 10k Spielern macht das den Unterschied zwischen einem Server, der bei Spitzenlast zuckt, und einem, der unbeeindruckt durchläuft.
Die refresh-after-write-Einstellung ist hier wichtig: nach 2 Minuten lädt der Cache im Hintergrund proaktiv neu, ohne einen Spieler-Lookup zu blockieren. Spieler sehen nie eine kalte Cache-Miss-Latenz, solange sie sich nicht das erste Mal einloggen.
Wie ich heute Plugins schreibe
Drei nicht verhandelbare Regeln:
- Kein synchrones I/O auf dem Main-Thread. Niemals. Wenn ein Plugin das tut und ich es in einem Audit finde, schreibe ich es vor jedem anderen Refactoring um.
- Jede Repository-Methode gibt ein
CompletableFuturezurück. Die Async-Welt ist nicht der Sonderfall, sie ist der Default. Synchrone Aufrufe sind explizit (blockingGet()), nicht implizit. - Caching ab Tag eins, auch wenn es trivial wirkt. Ein Plugin ohne Cache ist ein Plugin, das in sechs Monaten umgeschrieben wird, sobald die Spielerzahl wächst. Caffeine einzubauen kostet 30 Minuten — man spart sich später drei Tage.
Diese Patterns laufen heute auf produktiven Servern mit mittleren fünfstelligen aktiven Spielerzahlen. Tick-Rate konstant 20.0, p99-Query-Latenz unter 8 ms, GC-Pause unter 5 ms. Das ist nicht Magie — das ist die Konsequenz, die oben genannten drei Engpässe ernst zu nehmen, bevor sie zur Brandstelle werden.
Wenn du jetzt anfangen willst
Wenn dein Server gerade unter Last zuckt und du nicht weißt, wo das Problem liegt — drei Diagnose-Schritte, kostenlos und in zehn Minuten gemacht:
- Spark / Timings. Beide zeigen, welcher Thread blockiert. Wenn der Main-Thread in einer DB-Methode hängt, hast du Engpass #1.
- Hibernate Stats Logger oder ähnliches aktivieren. Wenn du innerhalb einer Minute über tausend identische SELECTs siehst, hast du Engpass #2 (N+1) und gleichzeitig fehlt dir Caching.
- HikariCP-Pool-Stats beobachten. Wenn
activeConnectionsregelmäßig nahemaximumPoolSizeliegt, ist dein Pool zu klein oder du hast einen Connection-Leak.
Wenn du nach diesen drei Schritten ratlos bist, kannst du mich ansprechen — ein Plugin-Audit nach Stundenaufwand löst in der Regel das Performance-Problem schneller, als drei weitere Wochenenden Debugging-Versuche es tun.
JEHibernate ist Open Source und absichtlich klein gehalten, damit andere Entwickler den Code lesen und anpassen können. Pull Requests willkommen.
Wenn du wissen willst, woran ich sonst noch arbeite — meine GitRoll-Profilseite zeigt eine ehrliche Übersicht meiner Open-Source-Aktivität: