Cara Scaling Database: Index, Replika, sampai Sharding
Cara scaling database: model data dari query, index yang tepat, read replica beserta lag-nya, lalu sharding per key dengan ID tanpa counter bersama.
Oleh Matthews WongDitinjau pada
Poin utama
Scaling database berarti menerapkan perbaikan termurah yang menghilangkan bottleneck yang terukur: susun model data dari query-nya, tambahkan index yang tepat, tambahkan read replica dan atasi lag-nya, dan baru saat satu server tidak sanggup menampung penulisan, pecah baris-barisnya ke beberapa server berdasarkan shard key, dengan ID yang tidak perlu dikeluarkan oleh satu counter.
Setelah pelajaran ini, Anda bisa
- Mengurutkan perbaikan dari yang termurah sampai yang termahal, dan menyebut bottleneck mana yang dihilangkan masing-masing.
- Mengatur arah pembacaan agar user selalu melihat tulisannya sendiri walaupun replica tertinggal.
- Membedakan partitioning dari sharding, memilih shard key, dan memilih ID yang tidak butuh counter bersama.
Ikuti diagramnya langkah demi langkah
Tekan Putar, atau melangkah dengan panah. Setiap langkah juga ditulis di bawah diagram.
Dari satu primary ke shard: perbaikan termurah lebih dulu
Index membuat query di primary murah, read replica mengambil alih pembacaan tetapi tertinggal, mengarahkan pembacaan user ke primary setelah ia menulis menutupi lag itu, shard router memecah penulisan per tenant, dan key UUIDv7 tidak butuh counter bersama.
Tombol: ← → langkah · Home / End · K putar atau jeda
Satu primary Postgres menerima semua baca dan tulis. Perbaikannya diurutkan dari yang termurah: index di dalamnya, lalu kotak-kotak putus-putus, yaitu read replica, router, dan shard kedua.
Layar daftar memfilter order per tenant, terbaru dulu. Tanpa index, Postgres membaca setiap page; B-tree pada (tenant_id, created_at) langsung menuju baris yang cocok.
CREATE INDEX ON orders (tenant_id, created_at);Pembacaan terus bertambah. Read replica memutar ulang write-ahead log milik primary dan menjawab query read-only; semua penulisan tetap masuk ke satu primary itu.
Tebak langkah berikutnya Ana menyimpan order, lalu langsung reload dari replica. Apa yang ia lihat? (Order barunya · Daftar lama, tanpa order itu · Error dari replica)Penulisan di-commit di primary lebih dulu. Kalau replay di replica tertinggal 500 ms dan reload Ana datang setelah 100 ms, replica masih menampilkan daftar lama.
Selama beberapa detik setelah Ana menulis, arahkan pembacaannya ke primary; user lain tetap membaca dari replica. Itulah read-your-writes, dengan harga sedikit beban tambahan di primary.
SET pin:ana 1 EX 5Saat penulisan melampaui satu primary, hanya server tambahan yang menolong. Router meng-hash shard key, yaitu tenant_id, lalu mengirim tiap tenant ke satu shard. Partitioning tetap di satu server.
shard = hash(tenant_id) % 2Tebak langkah berikutnya Setiap shard punya sequence id sendiri. Apa yang terjadi pada id order? (Tetap unik di semua shard · Id yang sama ada di keduanya)Dua sequence akan mengeluarkan angka yang sama. Pakai UUIDv7: app membuatnya tanpa counter bersama, dan prefix waktunya menjaga key baru tetap berdekatan di index.
id = uuidv7()
Bagaimana memilih model data sebelum men-scale apa pun?
Mulailah dari pertanyaan yang harus dijawab sistem, bukan dari label SQL lawan NoSQL. Database relasional memungkinkan query yang fleksibel dan bisa menjawab pertanyaan yang belum terpikirkan, dengan harga berupa join. Store NoSQL seperti DynamoDB dirancang di sekitar query yang paling umum, dan panduan AWS mengingatkan bahwa query di luar pola yang direncanakan bisa mahal dan lambat.
Baca selengkapnya (1)Tutup
Jadi daftarkan pola aksesnya, masing-masing dengan angka: seberapa sering, seberapa banyak data, seberapa cepat. Kalau satu server Postgres dengan index yang tepat sanggup melayani semuanya, berhentilah di situ. Tambahkan store khusus hanya untuk satu pola yang tidak bisa dilayaninya, karena setiap datastore tambahan berarti satu hal lagi yang harus di-backup, di-patch, dan dipantau. Dan simpan setiap fakta di satu tempat saja: setiap salinan butuh mekanisme yang menjaganya tetap sinkron.
Kenapa index menjadi perbaikan pertama, dan apa harganya?
Index adalah salinan kedua dari beberapa kolom yang sudah diurutkan, lengkap dengan penunjuk ke barisnya. Tanpa index, mencari order terbaru milik satu tenant berarti membaca setiap page tabel; dengan B-tree pada kolom yang tepat, Postgres cukup menempuh jalur pendek dan membaca baris yang cocok dalam urutan yang sudah benar. Index yang tidak ada terlihat persis seperti tabel yang terlalu besar, karena itulah index datang sebelum server baru mana pun.
Baca selengkapnya (1)Tutup
Urutan kolom menentukan apakah index itu menolong. Pada B-tree multikolom, kondisi kesamaan di kolom-kolom terdepan, ditambah rentang pada kolom pertama yang tidak punya kondisi kesamaan, membatasi seberapa banyak index yang dipindai; kondisi di kolom yang lebih kanan hanya dicek sambil lalu. Jadi letakkan kolom yang dibandingkan dengan = di depan, dan kolom untuk urutan atau rentang di belakang. Harganya dibayar saat menulis: setiap insert menambah entri ke semua index, jadi simpan hanya index yang dibutuhkan query yang sering berjalan.
Apa yang didapat dari read replica, dan kenapa datanya bisa basi?
Replica menerima write-ahead log dari primary lalu memutarnya ulang. Di Postgres, hot standby menjalankan query read-only dan transaksinya tidak pernah bisa menulis, jadi replica menambah kapasitas baca, tidak pernah kapasitas tulis. Dokumentasinya terang-terangan menyebut ada jeda yang terukur antara primary dan standby: standby bersifat eventually consistent terhadap primary.
Baca selengkapnya (1)Tutup
Jeda itu berubah menjadi bug begitu user menulis lalu membaca. Commit-nya masuk ke primary, halaman berikutnya membaca replica yang belum memutar ulang perubahan itu, dan record milik user sendiri seolah hilang. Read-your-writes adalah jaminan bahwa sebuah proses melihat tulisannya sendiri yang sebelumnya, dan cara murah untuk menjaganya adalah mengarahkan pembacaan user ke primary selama beberapa detik setelah ia menulis. Pantau replay_lag di pg_stat_replication: untuk standby asinkron, nilainya mendekati lama waktu sampai transaksi terbaru terlihat di sana.
Kapan memakai partitioning, kapan sharding, dan bagaimana ID ikut berubah?
Partitioning memecah satu tabel logis yang besar menjadi potongan fisik yang lebih kecil di dalam satu server database. Engine-nya sendiri yang mengarahkan setiap baris, query yang memfilter partition key melewati potongan yang tidak mungkin cocok, dan menghapus partisi lama jauh lebih cepat daripada DELETE massal. Patokan dari Postgres: partitioning mulai sepadan begitu ukuran tabel melebihi memori fisik server. Partitioning tidak menambah perangkat keras: tetap satu mesin.
Baca selengkapnya (2)Tutup
Sharding menyebar baris-baris ke beberapa server, jadi setiap shard membawa CPU, memori, dan disknya sendiri. Inilah satu-satunya langkah yang menambah kapasitas tulis, dan routing-nya pindah ke sistem Anda: shard router menghitung pemilik baris dari shard key. Pilih key yang dibawa query paling umum, seperti tenant_id di aplikasi multi-tenant, supaya satu request hanya menyentuh satu shard. Konsekuensinya, laporan lintas semua tenant harus bertanya ke setiap shard lalu menggabungkan jawabannya, satu tenant raksasa tetap mendarat di satu shard, dan dengan hash modulo N, mengubah N memindahkan sebagian besar key.
ID juga ikut berubah. Sequence adalah satu counter di satu tempat, dan dua shard dengan sequence masing-masing akan mengeluarkan angka yang sama. UUIDv4 tidak butuh counter, tetapi menurut RFC 9562 locality index-nya buruk: insert mendarat di posisi acak di B-tree. UUIDv7 diawali timestamp milidetik 48 bit, jadi node mana pun bisa membuatnya tanpa koordinasi, dan key baru tetap berurutan berdekatan.
Konfigurasi nyata di balik pelajaran ini
Kutipan dari repositori yang membangun dan men-deploy situs ini, apa adanya.
* This derives them rather than asking an author to restate them, for one
* reason: structured data must mirror what the reader sees. A hand-maintained
* citation list is free to drift from the visible block; a derived one cannot.Situs ini tidak punya database, tetapi menyimpan salinan turunan: lib/blog-sources.json dibuat dari blok sumber di akhir setiap tulisan, jadi sitasi di structured data tidak bisa bergeser dari yang dilihat pembaca. Itu juga kontrak sebuah index atau replica: salinan hanya bisa dipercaya selama ia diturunkan dari satu sumber kebenaran dan tidak pernah diedit dengan tangan.
"prebuild": "node scripts/generate-blog-sources.mjs --check && node scripts/game/check-placements.mjs && node scripts/game/check-imports.mjs && node scripts/check-message-keys.mjs",Dan build tidak akan merilis salinan yang basi. prebuild menjalankan generator dengan --check lebih dulu, yang keluar dengan kode error saat JSON yang di-commit tidak lagi cocok dengan tulisan-tulisannya. Replica yang tertinggal menyajikan data basi; file turunan yang tertinggal di sini membuat build gagal, pilihan yang lebih ketat di antara keduanya.
// The date a reader sees comes from monthKey + year; the date Google sees
// comes from datePublished here (JSON-LD, sitemap lastmod, both feeds).
// Checking key sets alone let those drift: 168 of 386 posts had disagreed,
// several by ten months, so a card read "May 2025" while its own structured
// data said 2024-07-15. Nothing surfaced it because both values were
// individually valid.Inilah harga menyimpan satu fakta dua kali. Tanggal setiap tulisan tinggal di dua registry, dan 168 dari 386 tulisan sempat tidak sinkron, padahal masing-masing salinan tampak valid. Kini assertBlogParity() melempar error saat ada ketidakcocokan, sehingga build gagal. Di database, pelajaran yang sama membentuk model datanya: simpan satu fakta di satu tempat, atau bayar mekanisme yang menjaga salinan-salinannya tetap sinkron.
Kesalahan yang benar-benar membuang waktu
Sharding sebelum indexing
Index yang tidak ada terlihat persis seperti tabel yang terlalu besar. Pecah ke shard, maka setiap shard menjalankan full scan yang sama, ditambah query lintas shard dan operasional per shard. Baca hasil EXPLAIN lebih dulu.
Membaca tulisan sendiri dari replica
Commit-nya ada di primary, pembacaan berikutnya mengenai replica yang belum memutar ulang perubahan itu, dan record-nya seolah hilang. Arahkan pembacaan user itu ke primary selama beberapa detik setelah ia menulis. Membuat setiap commit menunggu standby sinkron menerapkannya, synchronous_commit = remote_apply, juga berhasil, dengan harga penulisan yang lebih lambat.
Satu tenant lebih besar dari satu shard
Hashing menyebar banyak key secara merata, tetapi tidak pernah memecah satu key. Tenant dengan baris sepuluh kali lebih banyak dari yang lain membuat shard-nya panas, sementara shard lain menganggur. Rancang key yang menyebar trafik dan menghindari hot spot, dan beri tenant yang terlalu besar shard sendiri.
UUID acak sebagai primary key
Nilai UUIDv4 mendarat di posisi acak di B-tree, jadi tabel yang sibuk menulis menyentuh page di seluruh index, bukan di satu ujung saja. RFC 9562 menyebutnya locality index yang buruk; UUIDv7 tetap unik tanpa tersebar acak.
Unique key pada tabel yang dipartisi
Postgres mewajibkan unique key atau primary key pada tabel yang dipartisi untuk memuat semua kolom partition key, karena index di tiap partisi hanya bisa menjamin keunikan di dalam partisinya sendiri. Key yang hanya berisi id akan ditolak, jadi Anda berakhir dengan (id, created_at), dan keunikan id lintas partisi tidak lagi dijamin oleh constraint.
| Partitioning | Sharding | |
|---|---|---|
| Lokasi potongannya | Di dalam satu server database | Di server-server terpisah |
| Yang mengarahkan baris | Engine database | Shard router milik Anda |
| Menambah kapasitas tulis? | Tidak: tetap satu mesin | Ya: setiap shard punya sendiri |
| Join dan transaksi | Normal, di satu node | Sulit jika lintas shard |
| Alasan utama memakainya | Ukuran scan dan penghapusan data lama yang murah | Penulisan atau data yang melampaui satu server |
Lakukan di komputer Anda sendiri
Anda butuh Docker dan Node. Jalankan Postgres 18 secara lokal, lihat index mengubah query plan, lihat nilai UUIDv7 berurutan menurut waktu, dan hitung apa saja yang dipindahkan shard router modulo.
Jalankan Postgres 18 lalu buka psql.
docker run --rm -d --name pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:18 docker exec -it pg psql -U postgresBuat satu juta order untuk 500 tenant, lalu baca plan untuk order terbaru milik satu tenant. Hasilnya Seq Scan dan Sort yang menyentuh ribuan buffer.
CREATE TABLE orders ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, tenant_id int NOT NULL, created_at timestamptz NOT NULL, items int NOT NULL ); INSERT INTO orders (tenant_id, created_at, items) SELECT g % 500, now() - g * interval '1 minute', g % 7 + 1 FROM generate_series(1, 1000000) AS g; ANALYZE orders; EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE tenant_id = 42 ORDER BY created_at DESC LIMIT 20;Tambahkan index komposit lalu jalankan EXPLAIN yang sama. Pada percobaan saya, jumlah buffer turun dari sekitar 7.400 menjadi 23, dan plan-nya berubah menjadi Index Scan Backward tanpa Sort.
CREATE INDEX ON orders (tenant_id, created_at); EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE tenant_id = 42 ORDER BY created_at DESC LIMIT 20;Minta tiga nilai UUIDv7 dari Postgres 18. Dua belas digit hex pertama adalah waktu pembuatan dalam milidetik, jadi nilai yang dibuat bersamaan diawali karakter yang sama dan berurutan sesuai waktu pembuatan.
SELECT uuidv7() FROM generate_series(1, 3);Simpan router.mjs lalu jalankan node router.mjs. Tenant 7 mendarat di shard 1 dari 2, dan menambah dari 2 ke 3 shard memindahkan sekitar dua pertiga dari semua tenant, itulah sebabnya router sungguhan memakai tabel lookup atau consistent hashing.
// router.mjs: which shard owns a tenant, and what moves when you add a shard import { createHash } from "node:crypto"; const shardOf = (tenantId, shards) => createHash("sha256").update(String(tenantId)).digest().readUInt32BE(0) % shards; const tenants = 100_000; let moved = 0; for (let t = 1; t <= tenants; t += 1) { if (shardOf(t, 2) !== shardOf(t, 3)) moved += 1; } console.log(`tenant 7 lives on shard ${shardOf(7, 2)} of 2`); console.log(`going from 2 to 3 shards moves ${((moved / tenants) * 100).toFixed(1)}% of tenants`);
Tiga pertanyaan sebelum lanjut
Pilih jawaban untuk melihat kenapa benar atau salah. Tidak ada yang disimpan atau dinilai, jadi tebak saja dengan bebas.
Jawab dalam hati, lalu cek
1.Apa beda partitioning dan sharding?Lihat jawabannya
Jawaban:Partitioning memecah tabel di dalam satu server dan engine-nya yang mengarahkan baris. Sharding menyebar data ke beberapa server dan router Anda yang mengarahkannya; hanya sharding yang menambah kapasitas tulis.
2.Kenapa read replica tidak menolong database yang berat di penulisan?Lihat jawabannya
Jawaban:Hot standby hanya menjalankan query read-only. Semua penulisan tetap masuk ke satu primary, yang juga harus mengirimkannya ke setiap replica.
3.Sebuah query memfilter tenant_id = 7 dan mengurutkan berdasarkan created_at. Kenapa index pada (tenant_id, created_at) cocok?Lihat jawabannya
Jawaban:Kolom kesamaan ada di depan, jadi pemindaian dibatasi pada entri milik satu tenant, dan entri itu sudah terurut berdasarkan created_at, sehingga Postgres membacanya berurutan tanpa Sort.
Semua artikel di modul ini, berurutan
Pelajaran di atas adalah petanya. Setiap artikel membahas satu bagiannya secara mendalam, lengkap dengan contoh.
- SQL vs NoSQL: Cara Memilih Database untuk Sistem BaruCara memilih antara SQL dan NoSQL untuk sistem baru: empat keluarga NoSQL, pemodelan dari access pattern, dan kapan Postgres JSONB sudah cukup.
- Database Indexing Dijelaskan: Cara Kerja B-Tree di PostgresCara kerja database index dan alasan B-tree mendominasi: hitungan fanout page, output EXPLAIN Postgres asli, urutan kolom composite index, dan biaya write setiap index.
- Database Replication: Leader-Follower, Sync vs AsyncCara kerja database replication: WAL shipping, trade-off commit sync vs async, failover, split-brain, serta kapan multi-leader atau leaderless dipakai.Diagram animasi
- Read Replica Mengembalikan Data Lama: Atasi Replication LagKenapa read replica menampilkan data lama tepat setelah write, cara mengukur lag di Postgres, dan cara me-routing read agar user selalu melihat perubahannya.
- Database Partitioning vs Sharding: Apa Bedanya?Partitioning memecah satu tabel di dalam satu database server, sharding menyebar data ke beberapa server. DDL Postgres, output EXPLAIN dan shard router.
- Distributed ID Generator: Snowflake vs UUIDv7 vs ULIDCara membuat ID unik di banyak server: batas auto-increment, dampak UUIDv4 pada index, UUIDv7 di RFC 9562, layout Snowflake 41/10/12, clock rollback, dan block allocation.
- Bloom Filter Explained: Kapan Dipakai di System DesignBloom filter adalah bit array plus k hash function yang menjawab mungkin ada atau pasti tidak ada. Lihat rumus m dan k, error rate terukur, dan pemakaiannya.
Sumber
Sumber primer yang diperiksa pada tanggal riset. Ikuti tautannya untuk versi terbaru.
- Dokumentasi AWS: desain NoSQL untuk DynamoDB
- Dokumentasi PostgreSQL: index multikolom
- Dokumentasi PostgreSQL: hot standby
- Dokumentasi PostgreSQL: pg_stat_replication dan replay_lag
- Jepsen: read your writes
- Dokumentasi PostgreSQL: partisi tabel
- Wikipedia: shard (arsitektur database)
- IETF RFC 9562: UUID, termasuk UUIDv7