Mengoptimalkan Desain Skema untuk Spanner

Teknologi penyimpanan Google mendukung beberapa aplikasi terbesar di dunia. Namun, skala tidak selalu merupakan hasil otomatis dari penggunaan sistem ini. Desainer harus berpikir cermat tentang cara memodelkan data mereka untuk memastikan aplikasi mereka dapat diskalakan dan berperforma seiring pertumbuhannya dalam berbagai dimensi.

Spanner adalah database terdistribusi, dan untuk menggunakannya secara efektif, Anda perlu memikirkan desain skema dan pola akses secara berbeda dibandingkan dengan database tradisional. Sistem terdistribusi, pada dasarnya, memaksa desainer untuk memikirkan lokalitas data dan pemrosesan.

Spanner mendukung kueri dan transaksi SQL dengan kemampuan untuk melakukan penskalaan horizontal. Desain yang cermat sering kali diperlukan untuk mendapatkan manfaat penuh Spanner. Dokumen ini membahas beberapa ide utama yang akan membantu Anda memastikan aplikasi Anda dapat diskalakan ke tingkat yang diinginkan, dan untuk memaksimalkan performanya. Dua alat khususnya memiliki dampak besar pada skalabilitas: definisi kunci dan penyisipan.

Tata letak tabel

Baris dalam tabel Spanner diatur secara leksikografis berdasarkan PRIMARY KEY. Secara konseptual, kunci diurutkan berdasarkan penggabungan kolom dalam urutan yang dideklarasikan dalam klausa PRIMARY KEY. Hal ini menunjukkan semua properti standar lokalitas:

  • Memindai tabel dalam urutan leksikografis akan efisien.
  • Baris yang cukup berdekatan akan disimpan dalam blok disk yang sama, dan akan dibaca dan di-cache bersama.

Spanner mereplikasi data Anda di beberapa zona untuk ketersediaan dan skala. Setiap zona menyimpan replika lengkap data Anda. Saat menyediakan node instance Spanner, Anda menentukan kapasitas komputasi-nya. Kapasitas komputasi adalah jumlah resource komputasi yang dialokasikan ke instance Anda di setiap zona ini. Meskipun setiap replika adalah kumpulan lengkap data Anda, data dalam replika dipartisi di seluruh resource komputasi di zona tersebut.

Data dalam setiap replika Spanner disusun ke dalam dua tingkat hierarki fisik: pemisahan database, lalu blok. Pemisahan menampung rentang baris yang berdekatan, dan merupakan unit yang digunakan Spanner untuk mendistribusikan database Anda di seluruh resource komputasi. Seiring waktu, pemisahan dapat dibagi menjadi bagian yang lebih kecil, digabungkan, atau dipindahkan ke node lain di instance Anda untuk meningkatkan paralelisme dan memungkinkan aplikasi Anda diskalakan. Operasi yang mencakup pemisahan lebih mahal daripada operasi yang setara yang tidak mencakup pemisahan, karena peningkatan komunikasi. Hal ini berlaku meskipun pemisahan tersebut kebetulan ditayangkan oleh node yang sama.

Ada dua jenis tabel di Spanner: tabel root (terkadang disebut tabel tingkat teratas), dan tabel yang di-interleave. Tabel yang disisipkan ditentukan dengan menentukan tabel lain sebagai induk, sehingga baris dalam tabel yang disisipkan dikelompokkan dengan baris induk. Tabel akar tidak memiliki induk, dan setiap baris dalam tabel akar menentukan baris tingkat teratas baru, atau baris akar. Baris yang disisipkan dengan baris akar ini disebut baris turunan, dan kumpulan baris akar beserta semua turunannya disebut pohon baris. Baris induk harus ada sebelum Anda dapat menyisipkan baris turunan. Baris induk dapat sudah ada di database atau dapat disisipkan sebelum penyisipan baris turunan dalam transaksi yang sama.

Spanner secara otomatis memartisi bagian saat dianggap perlu karena ukuran atau beban. Untuk mempertahankan lokalitas data, Spanner lebih memilih menambahkan batas pemisahan sedekat mungkin dengan tabel root, sehingga setiap pohon baris tertentu dapat disimpan dalam satu pemisahan. Artinya, operasi dalam pohon baris cenderung lebih efisien karena tidak mungkin memerlukan komunikasi dengan pemisahan lainnya.

Namun, jika ada hotspot di baris turunan, Spanner akan mencoba menambahkan batas pemisahan ke tabel bersisipan untuk mengisolasi baris hotspot tersebut, beserta semua baris turunan di bawahnya.

Memilih tabel mana yang harus menjadi root adalah keputusan penting dalam mendesain aplikasi Anda agar dapat diskalakan. Root biasanya berupa hal-hal seperti Pengguna, Akun, Project, dan sejenisnya, dan tabel turunannya menyimpan sebagian besar data lain tentang entitas yang dimaksud.

Rekomendasi:

  • Gunakan awalan kunci umum untuk baris terkait dalam tabel yang sama untuk meningkatkan lokalitas.
  • Selalu selingi data terkait ke dalam tabel lain jika masuk akal.

Trade-off lokalitas

Jika data sering ditulis atau dibaca bersama, pengelompokan data tersebut dapat meningkatkan latensi dan throughput dengan memilih kunci utama secara cermat dan menggunakan penyisipan. Hal ini karena ada biaya tetap untuk berkomunikasi dengan server atau blok disk mana pun, jadi mengapa tidak mendapatkan sebanyak mungkin saat berada di sana? Selain itu, makin banyak server yang Anda ajak berkomunikasi, makin besar kemungkinan Anda akan menemukan server yang sedang sibuk untuk sementara, sehingga meningkatkan latensi ekor. Terakhir, transaksi yang mencakup pemisahan, meskipun otomatis dan transparan di Spanner, memiliki biaya CPU dan latensi yang sedikit lebih tinggi karena sifat terdistribusi dari commit dua fase.

Di sisi lain, jika data terkait tetapi tidak sering diakses bersama, pertimbangkan untuk memisahkannya. Hal ini paling bermanfaat jika data yang jarang diakses berukuran besar. Misalnya, banyak database menyimpan data biner besar di luar band dari data baris utama, dengan hanya referensi ke data besar yang disisipkan.

Perhatikan bahwa beberapa tingkat commit dua fase dan operasi data non-lokal tidak dapat dihindari dalam database terdistribusi. Jangan terlalu khawatir untuk mendapatkan cerita lokalitas yang sempurna untuk setiap operasi. Berfokuslah untuk mendapatkan lokalitas yang diinginkan untuk sebagian besar entitas root yang paling penting dan pola akses yang paling umum, dan biarkan operasi terdistribusi yang lebih jarang atau kurang sensitif terhadap performa terjadi saat diperlukan. Penerapan dua fase dan pembacaan terdistribusi ada untuk membantu menyederhanakan skema dan mempermudah kerja programmer: dalam semua kasus penggunaan kecuali yang paling penting untuk performa, lebih baik biarkan saja.

Rekomendasi:

  • Susun data Anda ke dalam hierarki sehingga data yang dibaca atau ditulis bersama cenderung berdekatan.
  • Pertimbangkan untuk menyimpan kolom besar dalam tabel non-interleaved jika jarang diakses.

Opsi indeks

Indeks sekunder memungkinkan Anda menemukan baris dengan cepat berdasarkan nilai selain kunci utama. Spanner mendukung indeks non-interleaving dan indeks interleaving. Indeks non-interleaved adalah indeks default dan jenis yang paling analog dengan yang didukung dalam RDBMS tradisional. Indeks ini tidak membatasi kolom yang diindeks dan, meskipun efektif, indeks ini tidak selalu menjadi pilihan terbaik. Indeks yang disisipkan harus ditentukan di kolom yang memiliki awalan dengan tabel induk, dan memungkinkan kontrol lokalitas yang lebih besar.

Spanner menyimpan data indeks dengan cara yang sama seperti tabel, dengan satu baris per entri indeks. Banyak pertimbangan desain untuk tabel juga berlaku untuk indeks. Indeks yang tidak di-interleave menyimpan data dalam tabel root. Karena tabel root dapat dibagi di antara baris root mana pun, hal ini memastikan bahwa indeks yang tidak di-interleave dapat disesuaikan ukurannya secara arbitrer dan, dengan mengabaikan hot spot, untuk hampir semua beban kerja. Sayangnya, hal ini juga berarti bahwa entri indeks biasanya tidak berada di pemisahan yang sama dengan data utama. Hal ini akan menambah pekerjaan dan latensi untuk setiap proses penulisan, serta menambahkan pemisahan tambahan yang harus diperhatikan saat membaca.

Sebaliknya, indeks yang di-interleave menyimpan data dalam tabel yang di-interleave. Metode ini cocok jika Anda menelusuri dalam domain satu entitas. Indeks yang disisipkan memaksa data dan entri indeks tetap berada di pohon baris yang sama, sehingga penggabungan di antara keduanya menjadi jauh lebih efisien. Contoh penggunaan indeks berselang-seling:

  • Mengakses foto Anda dengan berbagai urutan pengurutan seperti tanggal diambil, tanggal terakhir diubah, judul, album, dll.
  • Menemukan semua postingan Anda yang memiliki kumpulan tag tertentu.
  • Menemukan pesanan belanja sebelumnya yang berisi item tertentu.

Rekomendasi:

  • Gunakan indeks non-interleaved saat Anda perlu menemukan baris dari mana saja di database Anda.
  • Pilih indeks yang diselingi setiap kali penelusuran Anda dicakup ke satu entitas.

Klausa indeks STORING

Indeks sekunder memungkinkan Anda menemukan baris berdasarkan atribut selain kunci primer. Jika semua data yang diminta ada dalam indeks itu sendiri, data tersebut dapat dikueri sendiri tanpa membaca data utama. Hal ini dapat menghemat resource yang signifikan karena tidak diperlukan penggabungan.

Sayangnya, kunci indeks dibatasi hingga 16 jumlahnya dan 8 KiB ukuran gabungannya, sehingga membatasi apa yang dapat dimasukkan ke dalamnya. Untuk mengompensasi batasan ini, Spanner memiliki kemampuan untuk menyimpan data tambahan dalam indeks apa pun, menggunakan klausa STORING. STORING kolom dalam indeks akan menyebabkan nilai kolom tersebut diduplikasi, dengan salinan yang disimpan dalam indeks. Anda dapat menganggap indeks dengan STORING sebagai tampilan terwujud tabel tunggal sederhana (tampilan tidak didukung secara native di Spanner saat ini).

Aplikasi STORING lain yang berguna adalah sebagai bagian dari indeks NULL_FILTERED. Hal ini memungkinkan Anda menentukan apa yang secara efektif merupakan tampilan terwujud dari subset jarang tabel yang dapat Anda pindai secara efisien. Misalnya, Anda dapat membuat indeks tersebut di kolom is_unread kotak surat agar dapat menayangkan tampilan pesan yang belum dibaca dalam satu pemindaian tabel, tetapi tanpa membayar salinan lengkap setiap kotak surat.

Rekomendasi:

  • Gunakan STORING dengan bijak untuk menyeimbangkan performa waktu baca dengan ukuran penyimpanan dan performa waktu tulis.
  • Gunakan NULL_FILTERED untuk mengontrol biaya penyimpanan indeks jarang.

Anti-pola

Anti-pola: pengurutan stempel waktu

Banyak desainer skema cenderung menentukan tabel root yang diurutkan berdasarkan stempel waktu dan diperbarui setiap kali ada penulisan. Sayangnya, ini adalah salah satu hal yang paling tidak dapat diskalakan yang dapat Anda lakukan. Alasannya adalah desain ini menghasilkan hot spot besar di akhir tabel yang tidak dapat dimitigasi dengan mudah. Seiring dengan meningkatnya kecepatan penulisan, RPC ke satu pemisahan juga meningkat, begitu juga dengan peristiwa pertentangan kunci dan masalah lainnya. Sering kali masalah semacam ini tidak muncul dalam pengujian beban kecil, dan malah muncul setelah aplikasi digunakan dalam produksi selama beberapa waktu. Saat itu, sudah terlambat!

Jika aplikasi Anda benar-benar harus menyertakan log yang diurutkan berdasarkan stempel waktu, pertimbangkan apakah Anda dapat membuat log menjadi lokal dengan menyisipkannya di salah satu tabel root lainnya. Hal ini bermanfaat untuk mendistribusikan titik aktif di banyak root. Namun, Anda tetap harus berhati-hati agar setiap root yang berbeda memiliki kecepatan penulisan yang cukup rendah.

Jika Anda memerlukan tabel yang diurutkan berdasarkan stempel waktu global (lintas root), dan Anda perlu mendukung tingkat penulisan yang lebih tinggi ke tabel tersebut daripada yang dapat dilakukan oleh satu node, gunakan sharding tingkat aplikasi. Membuat shard tabel berarti mempartisinya menjadi beberapa jumlah N divisi yang hampir sama yang disebut shard. Hal ini biasanya dilakukan dengan menambahkan awalan pada kunci utama asli dengan kolom ShardId tambahan yang menyimpan nilai bilangan bulat antara [0, N). ShardId untuk penulisan tertentu biasanya dipilih secara acak, atau dengan melakukan hashing pada bagian kunci dasar. Hashing sering kali lebih disukai karena dapat digunakan untuk memastikan semua rekaman jenis tertentu masuk ke shard yang sama, sehingga meningkatkan performa pengambilan. Bagaimanapun juga, tujuannya adalah untuk memastikan bahwa dari waktu ke waktu, penulisan didistribusikan secara merata di semua shard. Pendekatan ini terkadang berarti bahwa operasi baca perlu memindai semua shard untuk merekonstruksi total pengurutan penulisan asli.

Ilustrasi shard untuk paralelisme dan baris dalam urutan waktu per shard

Rekomendasi:

  • Hindari tabel dan indeks yang diurutkan berdasarkan stempel waktu dengan kecepatan penulisan tinggi dengan segala cara.
  • Gunakan beberapa teknik untuk menyebarkan hot spot, baik itu interleaving di tabel lain atau sharding.

Anti-pola: urutan

Developer aplikasi senang menggunakan urutan database (atau penambahan otomatis) untuk membuat kunci utama. Sayangnya, kebiasaan dari era RDBMS ini (yang disebut kunci pengganti) hampir sama berbahayanya dengan anti-pola pengurutan stempel waktu yang dijelaskan di atas. Alasannya adalah bahwa urutan database cenderung memancarkan nilai secara quasi-monotonik, dari waktu ke waktu, untuk menghasilkan nilai yang dikelompokkan berdekatan satu sama lain. Hal ini biasanya menghasilkan hot spot saat digunakan sebagai kunci utama, terutama untuk baris root.

Berbeda dengan praktik umum RDBMS, sebaiknya Anda menggunakan atribut dunia nyata untuk kunci utama jika masuk akal. Hal ini terutama berlaku jika atribut tidak akan pernah berubah.

Jika Anda ingin membuat kunci primer unik numerik, usahakan agar bit urutan tinggi dari angka berikutnya didistribusikan secara merata di seluruh ruang angka. Salah satu triknya adalah membuat angka berurutan dengan cara konvensional, lalu membalikkan bit untuk mendapatkan nilai akhir. Atau, Anda dapat melihat generator UUID, tetapi berhati-hatilah: tidak semua fungsi UUID dibuat sama, dan beberapa menyimpan stempel waktu di bit urutan tinggi, sehingga menghilangkan manfaatnya. Pastikan generator UUID Anda memilih bit urutan tinggi secara pseudo-acak.

Rekomendasi:

  • Hindari penggunaan nilai urutan yang bertambah sebagai kunci utama. Sebagai gantinya, balikkan nilai urutan bit, atau gunakan UUID yang dipilih dengan cermat.
  • Gunakan nilai dunia nyata untuk kunci utama, bukan kunci pengganti.