MySQL Connection Pooling Caching yang Salah: Di Balik Bug 0,001% yang Merusak Produksi
Ringkasnya
Pelajari cara bug 0,001% pada MySQL connection pooling caching merusak produksi dan cara mendeteksi serta memperbaikinya di backend game Anda.
Backend Anda mengembalikan set hasil kosong untuk query yang selalu mengembalikan data. Tingkat errornya 0,001% — kira-kira satu dari setiap 100.000 permintaan. Telemetri Anda tidak menunjukkan apa pun. Trace tidak menunjukkan apa pun. Dan satu-satunya petunjuk yang Anda miliki adalah ini tidak pernah terjadi saat caching dinonaktifkan.
Itulah situasi yang dialami tim Hyperdrive dari Cloudflare saat membawa dukungan MySQL di connection pooler mereka menuju ketersediaan umum. Bug yang mereka temukan adalah pelajaran berharga tentang bagaimana MySQL connection pooling caching bisa rusak secara diam-diam ketika dua lapisan tidak sepakat tentang format byte yang bergerak melalui wire. Jika Anda menjalankan lapisan database yang di-pool dan di-cache untuk backend game Anda — dan sebaiknya Anda melakukannya — mode kegagalan ini berlaku untuk Anda.
Postingan ini menguraikan bug, detail protokol yang memungkinkannya terjadi, cara mendeteksi dan memperbaikinya, serta artinya bagi arsitektur backend game Anda.
Mengapa Connection Pooling Ada
Membangun handshake TCP + TLS + MySQL ke database membutuhkan minimal lima kali round trip jaringan. Jika server game Anda membuka koneksi baru untuk setiap permintaan, Anda membayar biaya itu pada setiap pembacaan leaderboard, setiap pengambilan save-game, setiap pencarian inventaris.
Pooling memperbaiki ini dengan menjaga koneksi tetap hangat dan meminjamkannya. MySQL lebih ramah terhadap hal ini daripada Postgres — Postgres membuat satu proses OS utuh per koneksi, sementara koneksi MySQL relatif murah — tetapi game populer dengan ribuan pemain bersamaan tetap bisa cepat menghabiskan batas koneksi.
Namun, pooling memperkenalkan masalahnya sendiri: kebocoran status sesi. Jika query pemain A mengatur variabel sesi dan pemain B mendapatkan koneksi yang sama, Anda memiliki bug kebenaran. Pooler mengatasi ini dengan pembersihan koneksi di antara checkout. MySQL menyediakan perintah ringan untuk hal ini: COM_RESET_CONNECTION.
Dan di situlah bug halus pertama dalam cerita ini berada.
Bug #1: Pergeseran Encoding Setelah Reset Koneksi
Tim Hyperdrive menegosiasikan utf8 dalam handshake awal. Itu berhasil — sampai pemain mulai menggunakan emoji di chat dan nama karakter, dan data yang kembali menjadi berantakan.
Masalahnya: utf8 di MySQL adalah alias yang tidak digunakan lagi untuk utf8mb3, yang hanya mendukung 1–3 byte per code point Unicode. UTF-8 penuh (termasuk emoji, ekstensi CJK, dan sebagian besar skrip non-Latin yang akan digunakan basis pemain global Anda) membutuhkan hingga 4 byte per code point. Perbaikannya adalah menegosiasikan utf8mb4 sebagai gantinya.
Tapi di sinilah jebakan yang menjebak hampir semua orang:
-- Startup handshake: negotiate utf8mb4. All good.
-- Then, between checkouts, the pooler runs:
COM_RESET_CONNECTION;
-- Surprise: the server does NOT reset to the negotiated charset.
-- It resets to the SERVER DEFAULT, e.g. latin1.
COM_RESET_CONNECTION tidak mengembalikan koneksi ke character set yang Anda negosiasikan saat startup. Ia mereset ke charset default server — yang pada banyak instance MySQL masih latin1. Setelah reset pertama, setiap koneksi yang di-pool secara diam-diam menurun ke encoding era 1990-an.
Remediasi: terapkan ulang konfigurasi sesi Anda setelah setiap reset, bukan hanya saat startup:
COM_RESET_CONNECTION;
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET SESSION time_zone = '+00:00';
Aturan praktis: anggap koneksi sebagai sepenuhnya tidak terkonfigurasi setelah setiap reset, dan tegaskan kembali semua yang Anda pedulikan. Apa pun yang Anda atur sekali saat connect pada akhirnya akan hilang.
Bug #2: Bug Respons Kosong 0,001%
Inilah bug yang butuh berhari-hari untuk ditemukan. Seorang pelanggan dengan volume tinggi melaporkan tingkat respons query kosong yang kecil tetapi konsisten, padahal respons non-kosong dijamin. Error terjadi sekitar 0,001% dari waktu — terlalu langka untuk menangkap trace end-to-end secara andal, terlalu sering untuk diabaikan.
Tim menyisir telemetri, meningkatkan tingkat trace, mencari anomali jaringan. Tidak ada. Satu petunjuk yang memecahkannya: bug hanya terjadi pada instance dengan caching yang diaktifkan.
Arsitektur yang Memungkinkannya Terjadi
Hyperdrive menggunakan topologi terpisah:
- Node Client — berjalan dekat dengan Worker, menangani caching lokal, meneruskan query ke depan.
- Node Endpoint — berjalan di pusat data terdekat dengan database asal, memegang connection pool yang sebenarnya.
Kedua node menggunakan cache CDN lokal di pusat data mereka. Ketika keduanya berjalan di pusat data yang sama, mereka berbagi satu cache. Berbagi itu adalah keuntungan performa — tetapi membawa prasyarat tersembunyi: kedua node harus meng-cache byte yang sama persis untuk respons query yang sama.
Ketidakcocokan Protokol
Protokol client/server MySQL memiliki Fase Koneksi di mana client dan server menegosiasikan capabilities — fitur opsional seperti CLIENT_DEPRECATE_EOF, yang mengubah cara set hasil diakhiri:
- Tanpa
CLIENT_DEPRECATE_EOF: paket EOF setelah definisi kolom, lalu EOF lagi di akhir set hasil. - Dengan itu: EOF metadata dihilangkan dan EOF terakhir menjadi paket OK.
Hyperdrive mendukung capability tersebut dan menegosiasikannya dengan server MySQL asal. Tetapi library client di sisi lain — misalnya, mysql2 untuk Node.js, yang saat ini tidak mendukung CLIENT_DEPRECATE_EOF — mungkin tidak. Jadi Hyperdrive harus menerjemahkan: menulis ulang set hasil untuk menyisipkan paket EOF, mengonversi paket OK kembali menjadi EOF, sehingga byte-nya cocok dengan yang diharapkan oleh driver Worker.
Konsekuensinya: byte yang ditulis Hyperdrive kembali ke client memiliki format yang berbeda dari byte yang diterimanya dari server asal.
Asumsi yang Keliru
Saat caching Client diimplementasikan untuk Postgres, tim menanamkan asumsi: Anda dapat meng-cache persis byte yang Anda tulis kembali ke client. Untuk wire protocol Postgres, itu benar. Untuk MySQL, tidak.
Sekarang rangkai kegagalannya:
- Endpoint melayani query Q, meng-cache byte respons dalam format capability Hyperdrive ke cache bersama.
- Client (pusat data yang sama) mendapatkan permintaan untuk Q, menemukan entri cache, dan menyajikannya ke Worker tanpa menerjemahkan capabilities — karena byte-nya terlihat seperti sudah ditulis untuk Worker.
- Driver Worker, yang tidak pernah menegosiasikan
CLIENT_DEPRECATE_EOF, salah mengurai set hasil yang diakhiri OK — menghasilkan respons kosong, hang, atau desync protokol.
Bug ini hanya muncul ketika Client dan Endpoint berkolokasi dan salah satunya membaca entri cache milik yang lain. Itulah mengapa bug ini 0,001% dan tidak terlihat oleh tracing biasa. Ini adalah padanan wire-protocol dari bug desync — dua komponen yang memegang asumsi berbeda tentang status bersama. Jika Anda pernah men-debug divergensi status antara prediksi client dan otoritas server, bentuk kegagalan ini akan terasa familier; ini adalah kelas masalah yang sama seperti yang dijelaskan di memperbaiki masalah replikasi RPC Unreal Engine yang merusak state Anda, hanya saja di lapisan wire database.
Perbaikannya, secara konseptual: cache dalam format kanonik (apa yang dikirim origin), dan terjemahkan ke capabilities client yang meminta pada saat pembacaan. Jangan pernah meng-cache byte pasca-terjemahan ketika terjemahan bergantung pada negosiasi per-client.
Runbook: Mendeteksi Korupsi Cache di Stack Anda Sendiri
Jika Anda menjalankan pooler atau lapisan cache di depan MySQL (atau apa pun dengan wire protocol yang stateful), berikut cara menerapkan postmortem ini.
Deteksi
- Korelasikan error dengan topologi. Bug hanya muncul pada instance di mana Client dan Endpoint berbagi pusat data dan cache. Catat node mana yang melayani setiap respons dan apakah respons itu berasal dari cache. Jika tingkat error berkorelasi dengan cache hit, curigai ketidakcocokan format antara penulis dan pembaca.
- Burulah error yang langka tetapi konsisten, bukan lonjakan. Tingkat error 0,001% tidak pernah memicu alert. Buat query di pelacakan error Anda untuk kemunculan berulang dari signature yang sama terlepas dari volume. Konsistensi pada tingkat rendah adalah sinyal bug logika yang lebih kuat daripada lonjakan yang berkorelasi dengan traffic.
- Uji dengan asimetri capability secara sengaja. Hubungkan satu client uji dengan
CLIENT_DEPRECATE_EOFdan satu tanpa, aktifkan caching, dan jalankan berulang kali query yang identik. Sebagian besar bug terjemahan protokol muncul dalam hitungan menit di bawah asimetri ini.
Remediasi
- Kanonikalkan entri cache. Simpan format respons mentah dari origin; terjemahkan saat pembacaan. Ini adalah perbaikan yang tepat untuk pooler mana pun yang client-nya memiliki capabilities heterogen.
- Tegaskan kembali status sesi setelah reset. Seperti yang ditunjukkan di atas,
COM_RESET_CONNECTIONmengembalikan default server, bukan pengaturan yang Anda negosiasikan. - Beri versi pada cache key Anda. Jika Anda mengubah perilaku protokol apa pun (capabilities, encoding, framing respons), sertakan versi format di cache key sehingga entri lama tidak dapat disajikan oleh kode baru di tengah deploy.
# Cache key hygiene, in pseudocode
def cache_key(query_normalized: str, schema_version: int, format_version: int) -> str:
# format_version bumps whenever wire-format handling changes,
# preventing stale-format entries from poisoning new deploys
return sha256(f"{schema_version}:{format_version}:{query_normalized}")
Pencegahan
- Tulis tes kesesuaian wire-protocol terhadap dua library client, bukan satu. Celah
CLIENT_DEPRECATE_EOFhanya terlihat karena driver Node.js yang populer tidak mendukungnya. - Jangan berasumsi apa pun tentang "byte yang sama" di seluruh lapisan. Komponen apa pun yang menulis ulang pesan tidak boleh berasumsi bahwa output-nya identik byte dengan input-nya.
- Uji beban dengan cache aktif sejak hari pertama. Tim Hyperdrive memvalidasi keandalan selama berbulan-bulan sebelum bug spesifik cache muncul — karena irisan fitur (pooling × caching × terjemahan capability) adalah tempat bug-bug langka bersembunyi.
Lima Praktik Terbaik untuk Pooling dan Caching di Backend Game
- Selalu negosiasikan
utf8mb4, jangan pernahutf8. Nama pemain, chat, dan data save akan mengandung karakter 4-byte. Lalu tegaskan kembaliSET NAMES utf8mb4setelah setiapCOM_RESET_CONNECTION, karena reset mengembalikan default server, bukan handshake Anda. - Cache data kanonik, bukan data berformat client. Jika ada lapisan yang mengubah respons sebelum mengirimnya, byte yang telah diubah tidak boleh masuk ke cache bersama. Simpan format origin; terjemahkan per-pembaca.
- Tambahkan versi format ke cache key. Perubahan satu baris, menyelamatkan Anda dari entri protokol basi selama rolling deploy.
- Buat alert pada konsistensi error, bukan hanya tingkat error. Signature berulang 0,001% tanpa korelasi traffic adalah bug logika. Lonjakan yang berkorelasi dengan traffic adalah masalah kapasitas. Tangani keduanya secara berbeda.
- Uji client dengan capabilities yang tidak cocok. Kebenaran pooler Anda bergantung pada penanganan setiap library client yang mungkin digunakan tim Anda (atau pengguna SDK Anda) untuk terhubung.
Pelajaran Arsitektur Backend
Pelajaran terdalam di sini sama sekali bukan tentang MySQL. Ini tentang sistem berlapis dengan negosiasi per-peer. Saat Anda menyisipkan middlebox — pooler, proxy, cache — yang berbicara protokol di kedua sisi, Anda mewarisi kewajiban untuk menerjemahkan dengan setia dan hanya meng-cache format yang berlaku secara universal. Postgres berhasil; MySQL rusak; tidak ada yang salah dengan logika caching kecuali satu asumsi yang diwarisi dari protokol yang berbeda.
Bagi studio game, ini adalah argumen kuat untuk tidak membuat sendiri lapisan pooler-plus-cache. Upaya rekayasa untuk membuat happy path berfungsi mungkin seminggu; upaya rekayasa untuk membuat jalur-jalur langka benar — negosiasi capability, pergeseran encoding, kolokasi cache — itulah yang membuat tim infrastruktur khusus menghabiskan waktu berhari-hari untuk memburu satu bug 0,001%. Pengakuan Cloudflare sendiri layak diulang: perbedaan antara beta dan GA adalah "hanya 20 perbaikan bug yang dibungkus jas hujan."
Jika backend game Anda membutuhkan persistensi yang andal tanpa Anda harus memelihara pooler, skema invalidasi cache, dan lapisan terjemahan protokol, di situlah Backend-as-a-Service layak digunakan. horizOn menangani Cloud Save yang terikat akun dengan pembacaan dan penulisan yang sadar revisi — client Anda menyelesaikan konflik, dan Anda melewati seluruh ladang ranjau korektness pooled-connection-plus-cache untuk data pemain. Padukan dengan leaderboard dan konfigurasi jarak jauh, dan masalah latensi query yang dibahas di postingan ini sebagian besar berhenti menjadi masalah Anda.
Langkah berikutnya: audit konfigurasi pooler Anda hari ini. Periksa tiga hal — charset yang dinegosiasikan, apakah status sesi ditegaskan kembali setelah reset koneksi, dan apakah cache Anda menyimpan byte kanonik atau terjemahan. Jika Anda tidak bisa menjawab ketiganya dalam sepuluh menit, Anda punya pekerjaan rumah — dan jika Anda lebih memilih tidak melakukannya sama sekali, lihatlah horizOn dan cari tahu apakah Cloud Save dan leaderboard-nya mencakup apa yang saat ini dilakukan lapisan database Anda secara manual.
Sumber: Inside Hyperdrive: Bagaimana kami memecahkan bug caching MySQL Hyperdrive yang langka