← Νέα

Γιατί ξαναχτίσαμε σε Go τον real-time server του Gammon42 για online τάβλι

Πώς κάναμε πιο γρήγορο το online τάβλι μας υπό φόρτο: μεταφέραμε το real-time hot path σε ένα μικρό service γραμμένο σε Go.

technical 1 Ιουλίου 2026

Κάθε online παιχνίδι ζει ή πεθαίνει με ένα πράγμα: πώς νιώθει τη στιγμή που ακουμπάς ένα πούλι και περιμένεις να κινηθεί το ταμπλό. Αν αυτή η στιγμή είναι ακαριαία, το παιχνίδι νιώθει ζωντανό. Αν κολλάει — έστω και για ένα δευτερόλεπτο — τα πάντα φαίνονται χαλασμένα, όσο καλά κι αν είναι όλα τα υπόλοιπα.

Αυτή είναι η ιστορία του πώς κάναμε αυτή τη στιγμή πιο γρήγορη και πιο σταθερή στο Gammon42 — το δωρεάν online multiplayer τάβλι μας — μεταφέροντας το real-time κομμάτι του backend μας από το framework που τρέχει το υπόλοιπο app σε μια μικρή, ειδικά φτιαγμένη υπηρεσία γραμμένη σε Go. Είναι γραμμένη για δύο ειδών αναγνώστες: αν θέλεις απλώς να μάθεις γιατί το παιχνίδι νιώθει καλύτερα τώρα, το πρώτο μισό είναι για σένα. Αν θέλεις τη μηχανική λεπτομέρεια — τα load tests, την αρχιτεκτονική, τους συμβιβασμούς — όλα αυτά βρίσκονται στο δεύτερο μισό, ξεκάθαρα σημειωμένα. Και αν προτιμάς απλώς να παίξεις τάβλι online τώρα αμέσως, ελεύθερα — τα υπόλοιπα θα είναι εδώ και μετά.

Η σύντομη εκδοχή

Το backend μας ήταν μία μοναδική εφαρμογή Dart χτισμένη πάνω στο framework Serverpod. Έκανε τα πάντα: λογαριασμούς, φίλους, τουρνουά, αγορές εντός εφαρμογής, chat — και το ίδιο το ζωντανό παιχνίδι. Το Serverpod είναι πραγματικά εξαιρετικό για αυτό — επέτρεψε σε μια μικρή ομάδα να παραδώσει γρήγορα ένα πλήρες, typed client-server προϊόν, και εξακολουθεί να τρέχει το μεγαλύτερο μέρος του Gammon42 σήμερα. Όμως το ζωντανό παιχνίδι είναι θεμελιωδώς διαφορετικό είδος δουλειάς από τα υπόλοιπα, και καθώς όλο και περισσότεροι άνθρωποι έπαιζαν ταυτόχρονα, τα δύο άρχισαν να ανταγωνίζονται για τους ίδιους πόρους. Το κομμάτι του συστήματος που χρειάζεται περισσότερο να είναι γρήγορο — η μετακίνηση των πουλιών σε έναν ζωντανό αγώνα — μοιραζόταν την ίδια λωρίδα με όλα τα υπόλοιπα.

Έτσι, ξεχωρίσαμε το κομμάτι του ζωντανού παιχνιδιού σε μια δική του, αποκλειστική υπηρεσία. Διαχειρίζεται μόνο το real-time κρίσιμο μονοπάτι: ζάρια, κινήσεις, κατάσταση ταμπλό και τον συγχρονισμό των δύο παικτών. Όλα τα υπόλοιπα έμειναν ακριβώς εκεί που ήταν. Το αποτέλεσμα είναι ότι ένα πολυσύχναστο βράδυ με matchmaking δεν επιβραδύνει πια τη στιγμή που κάνεις μια κίνηση — και τα δύο μισά του συστήματος μπορούν πλέον να μεγαλώνουν ανεξάρτητα.

Γιατί έχει σημασία η αναμονή

Σε έναν online multiplayer αγώνα τάβλι, τα μηνύματα που πετάνε ανάμεσα στο κινητό σου και στον server μας είναι μικροσκοπικά και συνεχή: έριξα τα ζάρια, να τι ήρθε· μετακίνησα αυτό το πούλι· είναι νόμιμη αυτή η κίνηση; να το νέο ταμπλό· η σειρά σου. Δεκάδες τέτοια ανά παιχνίδι, όλα ευαίσθητα στην καθυστέρηση, όλα πρέπει να φτάσουν και στους δύο παίκτες σχεδόν ακαριαία — αυτό σημαίνει real-time multiplayer όταν ρίχνεις ζάρια απέναντι σε έναν ζωντανό αντίπαλο.

Αυτό είναι πολύ διαφορετικό είδος δουλειάς από, ας πούμε, τη φόρτωση της λίστας φίλων σου ή την επεξεργασία μιας αγοράς. Αυτά συμβαίνουν περιστασιακά και μπορούν να ανεχτούν μερικές εκατοντάδες χιλιοστά του δευτερολέπτου. Οι ζωντανές κινήσεις δεν μπορούν — μια καθυστέρηση ενός δευτερολέπτου σε μια κίνηση είναι η διαφορά ανάμεσα σε ένα παιχνίδι που νιώθει άμεσο και σε ένα που νιώθει laggy.

Όταν ένα σύστημα διαχειρίζεται και τα δύο, η περιστασιακή-αλλά-βαριά δουλειά μπορεί να μπει στη μέση της συνεχούς-αλλά-ελαφριάς δουλειάς. Αυτό ακριβώς αρχίσαμε να βλέπουμε.


Η τεχνική ιστορία

Από εδώ και πέρα, μπαίνουμε στη μηχανική. Αν αυτό δεν είναι το κομμάτι σου, το συμπέρασμα παραπάνω είναι όλο το νόημα: το παιχνίδι είναι πιο γρήγορο επειδή η πιο χρονικά ευαίσθητη δουλειά του τρέχει τώρα μόνη της.

Το σημείο εκκίνησης

Το αρχικό backend είναι μια εφαρμογή Serverpod γραμμένη σε Dart, και αξίζει να το πούμε ξεκάθαρα: το Serverpod είναι ένα εξαιρετικό framework. Μας έδωσε πολλά δωρεάν — παραγόμενα client SDK, ένα typed protocol μοιρασμένο ανάμεσα στην εφαρμογή Flutter και τον server, auth, πρόσβαση σε βάση δεδομένων, WebSocket streams — και επέτρεψε σε μια μικρή ομάδα να κινηθεί αξιοσημείωτα γρήγορα. Εξακολουθεί να τρέχει το μεγαλύτερο μέρος του προϊόντος σήμερα: ταυτοποίηση, χρήστες, φίλους, τουρνουά, αγορές εντός εφαρμογής, chat και εργαλεία διαχείρισης, και δεν έχουμε σχέδια να το αλλάξουμε αυτό.

Το real-time παιχνίδι έτρεχε μέσα στην ίδια διεργασία. Ο server ήταν αυθεντικός: έριχνε τα ζάρια, επικύρωνε κάθε κίνηση σε σχέση με τους κανόνες του τάβλι, κρατούσε την κανονική κατάσταση του ταμπλό και έσπρωχνε ενημερώσεις και στους δύο clients μέσω WebSocket streams. Σωστό και καθαρό — αλλά μοιραζόταν ένα runtime με κάθε άλλο endpoint του προϊόντος.

Βρίσκοντας τον τοίχο

Πριν αλλάξουμε οτιδήποτε, θέλαμε νούμερα, όχι διαισθήσεις. Έτσι χτίσαμε ένα load-test harness που δημιουργεί συνθετικούς παίκτες που κάνουν ταυτοποίηση, μπαίνουν σε matchmaking, ζευγαρώνονται σε πραγματικά παιχνίδια και παίζουν τυχαίες νόμιμες κινήσεις από άκρη σε άκρη — περνώντας μέσα από τα ίδια ακριβώς WebSocket streams που χρησιμοποιεί ένας πραγματικός client Flutter. Χωρίς συντομεύσεις, χωρίς προσομοιωμένα endpoints.

Μερικές σκόπιμες επιλογές έκαναν τα αποτελέσματα αξιόπιστα:

  • Διαφορετικές περιοχές, ξεχωριστό υλικό. Ο παραγωγός φορτίου έτρεχε σε ένα datacenter (Νυρεμβέργη) και χτυπούσε τον server σε ένα άλλο (Ελσίνκι), με απόσταση περίπου 30 ms. Αυτό είναι πιο κοντά σε έναν πραγματικό χρήστη από ένα benchmark στην ίδια μηχανή, και εγγυόταν ότι ο παραγωγός φορτίου δεν μπορούσε να κλέψει CPU από τον server που μετρούσαμε.
  • Αυτόματα κλιμακούμενη ταυτοχρονία. Το harness διπλασίαζε τον αριθμό των ταυτόχρονων παικτών σε σταθερό πρόγραμμα και κατέγραφε ένα στιγμιότυπο κάθε πέντε δευτερόλεπτα: latency ταυτοποίησης, latency matchmaking και latency επιβεβαίωσης κίνησης σε p50/p95/p99, συν αριθμούς σφαλμάτων και timeout παιχνιδιών.
  • Έντιμα όρια αστοχίας. Ορίσαμε το «χαλασμένο» εκ των προτέρων — p95 επιβεβαίωσης κίνησης πάνω από 1.5 s, ή ποσοστό σφάλματος κίνησης πάνω από 2% — ώστε το test να κηρύσσει την αποτυχία αντικειμενικά, αντί να κοιτάζουμε εμείς ένα γράφημα.

Το σχήμα των αποτελεσμάτων ήταν ξεκάθαρο. Σε χαμηλή ταυτοχρονία ο server ήταν απόλυτα υγιής: με μερικές δεκάδες παίκτες, οι επιβεβαιώσεις κινήσεων επέστρεφαν σε περίπου 100–180 ms. Αλλά καθώς το harness κλιμάκωνε — 80, μετά 160, μετά 320 ταυτόχρονους παίκτες — το real-time μονοπάτι υποβαθμιζόταν απότομα. Το p95 επιβεβαίωσης κίνησης ανέβαινε στο εύρος των πολλών δευτερολέπτων, η ουρά p99 τραβούσε πολύ χειρότερα, το matchmaking επιβραδυνόταν, και τα παιχνίδια άρχιζαν να πεθαίνουν στο timeout παραίτησης των τεσσάρων λεπτών: λίγα στην αρχή, μετά δεκάδες. Κάπου γύρω στο σημείο των 320 παικτών, τα όρια αστοχίας ξεπεράστηκαν.

Η σημαντική διαπίστωση δεν ήταν «η Dart είναι αργή» ή «το Serverpod δεν το αντέχει» — κανένα από τα δύο δεν ισχύει, και τα δύο τρέχουν άνετα το υπόλοιπο Gammon42 μέχρι σήμερα. Ήταν πιο απλή: ένα μοντέλο διαχείρισης αιτημάτων γενικής χρήσης, που κουβαλά κάθε βαρύ endpoint του προϊόντος ταυτόχρονα, δεν είναι το ιδανικό σχήμα για έναν καταρράκτη από μικροσκοπικά, κρίσιμα ως προς το latency μηνύματα παιχνιδιού. Αυτό ισχύει σχεδόν για κάθε all-in-one framework — το κρίσιμο μονοπάτι είχε αναπτύξει το δικό του σύνολο απαιτήσεων, και χρειαζόταν και ένα μοντέλο ταυτοχρονίας ρυθμισμένο ακριβώς γι’ αυτό αλλά και το δικό του runtime, ώστε να σταματήσει να ανταγωνίζεται με όλα τα υπόλοιπα.

Η απόφαση: ένα sidecar, όχι μια ξαναγραφή

Η δελεαστική κίνηση — να ξαναγράψουμε ολόκληρο το backend — ήταν επίσης η λάθος. Το μεγαλύτερο μέρος της επιφάνειας του Serverpod (λογαριασμοί, IAP, τουρνουά, chat) δούλευε μια χαρά και δεν είχε καμία σχέση με το σημείο συμφόρησης. Η ξαναγραφή του θα ήταν τεράστιο ρίσκο για μηδενικό όφελος.

Αντ’ αυτού χρησιμοποιήσαμε το μοτίβο sidecar. Χτίσαμε μια μικρή, ξεχωριστή υπηρεσία που κατέχει μόνο το real-time κρίσιμο μονοπάτι του παιχνιδιού, και αφήσαμε το Serverpod να κατέχει όλα τα υπόλοιπα. Τα δύο τρέχουν το ένα δίπλα στο άλλο στο ίδιο VPS και μοιράζονται την ίδια Postgres και Redis, οπότε δεν υπάρχει πρόβλημα συγχρονισμού δεδομένων μεταξύ τους — απλώς ένας καθαρός διαχωρισμός ευθυνών. Η ακτίνα επίδρασης ήταν μικρή, η ανάπτυξη μπορούσε να γίνει σταδιακά, και αν κάτι πήγαινε στραβά μπορούσαμε να επιστρέψουμε πίσω.

Για αυτή την υπηρεσία επιλέξαμε τη Go, συγκεκριμένα λόγω του τρόπου που χειρίζεται την ταυτοχρονία.

Η αρχιτεκτονική: μία goroutine ανά δωμάτιο

Ο βασικός σχεδιασμός είναι μία goroutine ανά δωμάτιο παιχνιδιού.

Στη Go, μια goroutine είναι μια εξαιρετικά ελαφριά μονάδα ταυτόχρονης εκτέλεσης — κοστίζει λίγα kilobytes, όχι ένα ολόκληρο νήμα λειτουργικού συστήματος — και το runtime της Go πολυπλέκει πολλές χιλιάδες από αυτές στους διαθέσιμους πυρήνες CPU. Αυτή η ιδιότητα είναι που κάνει τον σχεδιασμό εφικτό: κάθε ενεργός αγώνας παίρνει τη δική του goroutine που κατέχει αποκλειστικά την κατάσταση αυτού του αγώνα.

Αυτό αγοράζει δύο πράγματα που έχουν τεράστια σημασία για έναν game server:

  • Χωρίς locks στο κρίσιμο μονοπάτι. Επειδή μία μοναδική goroutine κατέχει την κατάσταση κάθε δωματίου, κάθε ρίψη ζαριών, κίνηση και ενημέρωση ταμπλό για αυτόν τον αγώνα επεξεργάζεται μία-μία από αυτή τη goroutine. Άλλα μέρη του συστήματος μιλούν σε ένα δωμάτιο στέλνοντάς του μια εντολή, όχι απλώνοντας το χέρι μέσα και αγγίζοντας τα δεδομένα του. Δεν υπάρχει κοινή μεταβλητή κατάσταση για να διεκδικηθεί στο κρίσιμο μονοπάτι, πράγμα που σημαίνει καμία διαμάχη locks και καμία data race — η κατηγορία bug της ταυτοχρονίας εξαλείφεται με τον σχεδιασμό αντί να αποσφαλματώνεται.
  • Φυσική απομόνωση. Ένα πολυσύχναστο ή προβληματικό δωμάτιο δεν μπορεί να αλλοιώσει ή να μπλοκάρει ένα άλλο. Δέκα χιλιάδες ήσυχα δωμάτια κοστίζουν σχεδόν τίποτα όσο περιμένουν.

Γύρω από αυτές τις goroutines των δωματίων κάθεται ένα λεπτό υποστηρικτικό καστ:

  • Ένα hub δωματίων σε επίπεδο διεργασίας — ένας απλός χάρτης πίσω από ένα lock βελτιστοποιημένο για ανάγνωση — που δίνει αναζήτηση O(1) ενός δωματίου με βάση το ID. Το lock κρατιέται μόνο για τη μικροσκοπική αναζήτηση· όλη η πραγματική δουλειά ανά δωμάτιο γίνεται πίσω από τη δική του goroutine κάθε δωματίου, οπότε το hub δεν γίνεται ποτέ σημείο συμφόρησης.
  • Ένα μη μπλοκαριστικό στρώμα WebSocket. Ο βρόχος ανάγνωσης της σύνδεσης κάνει όσο το δυνατόν λιγότερα: αποκωδικοποιεί έναν εισερχόμενο φάκελο μηνύματος και τον παραδίδει στη σωστή goroutine δωματίου ή lobby. Η βαριά δουλειά δεν γίνεται ποτέ στο μονοπάτι ανάγνωσης του socket, οπότε μια αργή λειτουργία δεν μπορεί να κολλήσει τη σύνδεση.
  • Ένα connection pool προς την Postgres (μέσω pgx) και ένα συμπαγές, εκδοσιοποιημένο wire protocol του οποίου οι τύποι μηνυμάτων σκόπιμα αντικατοπτρίζουν τα enums του Serverpod. Αυτή η τελευταία λεπτομέρεια είναι που επέτρεψε στην εφαρμογή Flutter να μιλήσει στη νέα υπηρεσία χρησιμοποιώντας το ίδιο κοινό λεξιλόγιο που ήδη γνώριζε — προσθέσαμε έναν αποκλειστικό game client στην πλευρά της εφαρμογής και μπορέσαμε να μεταφέρουμε τη ζωντανή κίνηση στην υπηρεσία Go παιχνίδι-παιχνίδι, αντί για μία επικίνδυνη μαζική αλλαγή.

Το όλο πράγμα χτίζεται σε ένα μικροσκοπικό container: ένα multi-stage Docker build που παράγει μια distroless runtime image. Μικρή image, γρήγορη εκκίνηση και μέτριο αποτύπωμα μνήμης — που έχει σημασία όταν μοιράζεται ένα μηχάνημα με το κύριο backend.

Γιατί αυτό είναι πιο γρήγορο υπό φορτίο — έντιμα

Είμαστε σκόπιμα προσεκτικοί εδώ: έχουμε μια αυστηρή γραμμή βάσης για το παλιό μονοπάτι (τα load tests παραπάνω), και δεν πρόκειται να παραθέσουμε ακριβή νούμερα «μετά» για τα οποία δεν έχουμε δημοσιεύσει τα δεδομένα. Αλλά οι λόγοι που ο νέος σχεδιασμός αντέχει καλύτερα υπό ταυτοχρονία είναι δομικοί, όχι ευσεβείς πόθοι:

  • Ταυτοχρονία που ταιριάζει στο φόρτο εργασίας. Ένα ζωντανό παιχνίδι μοντελοποιείται φυσικά ως ένας ανεξάρτητος, μακρόβιος, καθοδηγούμενος από μηνύματα actor. Το goroutine-ανά-δωμάτιο το αντιστοιχίζει ένα-προς-ένα. Χιλιάδες ταυτόχρονοι αγώνες γίνονται χιλιάδες φθηνές goroutines που το runtime χρονοδρομολογεί σε κάθε πυρήνα — αντί για χιλιάδες αιτήματα που κάνουν ουρά μέσα από ένα μοντέλο χτισμένο για διαφορετικό σχήμα κίνησης.
  • Καμία διαμάχη στο κρίσιμο μονοπάτι. Η σειριοποίηση της κατάστασης κάθε δωματίου μέσα από τη δική του goroutine αφαιρεί το locking από το κομμάτι του συστήματος που τρέχει πιο συχνά και έχει τη μεγαλύτερη σημασία. Λιγότερο κόστος συντονισμού σημαίνει πιο προβλέψιμο latency, ειδικά στην ουρά p99 όπου το παλιό μονοπάτι πονούσε χειρότερα.
  • Απομόνωση πόρων. Η real-time υπηρεσία και το κύριο backend δεν μοιράζονται πια runtime. Μια έξαρση matchmaking ένα πολυσύχναστο βράδυ δεν μπορεί να λιμοκτονήσει τον κώδικα που επικυρώνει την κίνησή σου, και αντίστροφα. Το καθένα μπορεί να κλιμακωθεί, να μετρηθεί και να αναλυθεί μόνο του.
  • Ένα μεταγλωττισμένο, προβλέψιμο runtime. Ένα native binary της Go με έναν garbage collector χαμηλού latency χειρίζεται ομαλά ριπές από μικροσκοπικά, συχνά μηνύματα — ακριβώς το προφίλ κίνησης του ζωντανού τάβλι.

Τι κρατήσαμε, και τι θα σου λέγαμε να αντιγράψεις

Το κομμάτι για το οποίο είμαστε πιο χαρούμενοι δεν είναι η επιλογή της γλώσσας — είναι η αυτοσυγκράτηση. Δεν ξαναγράψαμε ένα σύστημα που δούλευε. Εντοπίσαμε το ένα συστατικό του οποίου οι απαιτήσεις είχαν αποκλίνει από όλα τα γύρω του, δώσαμε σε αυτό το συστατικό το runtime που πραγματικά χρειαζόταν και αφήσαμε τα υπόλοιπα ήσυχα. Κοινή βάση δεδομένων, κοινή cache, συμβατό protocol, σταδιακή μετάβαση, εύκολη επιστροφή.

Αν κρατήσεις ένα πράγμα από αυτό: πριν στραφείς σε μια ξαναγραφή, κάνε load-test αρκετά έντιμα ώστε να βρεις τον πραγματικό τοίχο, και μετά μετακίνησε μόνο αυτό που πραγματικά τον χτυπάει.


Θέλεις να νιώσεις τη διαφορά μόνος σου; Μπορείς να παίξεις τάβλι online δωρεάν — χωρίς λογαριασμό — απέναντι σε πραγματικούς αντιπάλους σε γρήγορους, αξιόπιστους multiplayer αγώνες, μέσα στον browser σου στο play.table-online.ro. Προτιμάς το κινητό σου; Κατέβασε το Gammon42, το online παιχνίδι τάβλι με ζάρια μας, από το App Store ή το Google Play και ρίξε ζάρια απέναντι σε παίκτες από όλο τον κόσμο.

Καλές ζαριές. 🎲

Έτοιμος να παίξεις;

Παίξε Τώρα
Παίξε Τώρα