Praktik terbaik desain skema

Arsitektur terdistribusi Spanner memungkinkan Anda mendesain skema untuk menghindari hotspot - situasi saat terlalu banyak permintaan dikirim ke server yang sama sehingga membebani resource server dan berpotensi menyebabkan latensi tinggi.

Halaman ini menjelaskan praktik terbaik untuk mendesain skema guna menghindari pembuatan hotspot. Salah satu cara untuk menghindari hotspot adalah menyesuaikan desain skema agar Spanner dapat membagi dan mendistribusikan data di beberapa server. Mendistribusikan data di seluruh server membantu database Spanner Anda beroperasi secara efisien, terutama saat melakukan penyisipan data massal.

Pilih kunci utama untuk mencegah hotspot

Seperti yang disebutkan dalam Skema dan model data, Anda harus berhati-hati saat memilih kunci utama dalam desain skema agar tidak secara tidak sengaja membuat hotspot di database Anda. Salah satu penyebab hotspot adalah memiliki kolom yang nilainya berubah secara monoton sebagai bagian kunci pertama, karena hal ini menyebabkan semua penyisipan terjadi di akhir ruang kunci Anda. Pola ini tidak diinginkan karena Spanner menggunakan rentang kunci untuk membagi data di antara server, yang berarti semua penyisipan Anda ditujukan ke satu server yang akhirnya melakukan semua pekerjaan.

Misalnya, Anda ingin mempertahankan kolom stempel waktu akses terakhir pada baris tabel UserAccessLogs. Definisi tabel berikut menggunakan kunci utama berbasis stempel waktu sebagai bagian kunci pertama. Kami tidak merekomendasikan hal ini jika tabel melihat rasio penyisipan yang tinggi:

GoogleSQL

CREATE TABLE UserAccessLogs (
  LastAccess TIMESTAMP NOT NULL,
  UserId STRING(1024),
  ...
) PRIMARY KEY (LastAccess, UserId);

PostgreSQL

CREATE TABLE useraccesslogs (
  lastaccess timestamptz NOT NULL,
  userid text,
  ...
PRIMARY KEY (lastaccess, userid)
);

Masalahnya di sini adalah baris ditulis ke tabel ini dalam urutan stempel waktu akses terakhir, dan karena stempel waktu akses terakhir selalu bertambah, baris tersebut selalu ditulis ke akhir tabel. Hotspot dibuat karena satu server Spanner menerima semua operasi tulis, yang membebani server tersebut.

Diagram berikut menggambarkan kesalahan ini:

Tabel UserAccessLog yang diurutkan berdasarkan stempel waktu dengan hotspot yang sesuai

Tabel UserAccessLogs sebelumnya mencakup lima contoh baris data, yang mewakili lima pengguna berbeda yang melakukan semacam tindakan pengguna dengan selisih satu milidetik. Diagram ini juga menjelaskan urutan penyisipan baris oleh Spanner (panah berlabel menunjukkan urutan penulisan untuk setiap baris). Karena penyisipan diurutkan berdasarkan stempel waktu, dan nilai stempel waktu selalu bertambah, Spanner selalu menambahkan penyisipan ke akhir tabel dan mengarahkannya pada pemisahan yang sama. (Seperti yang dibahas dalam Model skema dan data, pemisahan adalah sekumpulan baris dari satu atau beberapa tabel terkait yang disimpan Spanner dalam urutan kunci baris.)

Hal ini bermasalah karena Spanner menetapkan tugas ke server yang berbeda dalam unit pemisahan, sehingga server yang ditetapkan ke pemisahan tertentu ini akhirnya menangani semua permintaan penyisipan. Seiring meningkatnya frekuensi peristiwa akses pengguna, frekuensi permintaan penyisipan ke server yang sesuai juga meningkat. Server kemudian menjadi rentan menjadi hotspot, dan terlihat seperti latar belakang dan batas merah yang ditunjukkan pada gambar sebelumnya. Dalam ilustrasi yang disederhanakan ini, setiap server menangani paling banyak satu pemisahan, tetapi Spanner dapat menetapkan lebih dari satu pemisahan ke setiap server.

Saat Spanner menambahkan lebih banyak baris ke tabel, pemisahan akan bertambah dan kemudian membuat pemisahan baru sesuai kebutuhan. Untuk mempelajari lebih lanjut cara membuat pemisahan, lihat Pemisahan berbasis beban. Spanner menambahkan baris baru berikutnya ke pemisahan baru ini, dan server yang ditetapkan ke pemisahan menjadi potensi hotspot baru.

Saat terjadi hotspot, Anda mungkin mengamati bahwa penyisipan Anda lambat dan pekerjaan lain di server yang sama mungkin melambat. Mengubah urutan kolom LastAccess menjadi urutan menaik tidak menyelesaikan masalah ini karena semua penulisan akan dimasukkan di bagian atas tabel, yang masih mengirimkan semua penyisipan ke satu server.

Praktik terbaik desain skema #1: Jangan memilih kolom yang nilainya meningkat atau menurun secara monoton sebagai bagian penting pertama untuk tabel kecepatan penulisan yang tinggi.

Gunakan ID Unik Universal (UUID)

Anda dapat menggunakan ID Unik Universal (UUID) sebagaimana ditentukan oleh RFC 4122 sebagai kunci utama. Sebaiknya gunakan UUID Versi 4, karena menggunakan nilai acak dalam urutan bit. Sebaiknya jangan gunakan UUID Versi 1 karena UUID tersebut menyimpan stempel waktu di bit urutan tinggi.

Ada beberapa cara untuk menyimpan UUID sebagai kunci utama:

  • Di kolom STRING(36).
  • Dalam sepasang kolom INT64.
  • Di kolom BYTES(16).

Untuk kolom STRING(36), Anda dapat menggunakan fungsi Spanner GENERATE_UUID() (GoogleSQL atau PostgreSQL) sebagai nilai default kolom agar Spanner otomatis membuat nilai UUID.

Misalnya, untuk tabel berikut:

GoogleSQL

CREATE TABLE UserAccessLogs (
  LogEntryId STRING(36) NOT NULL,
  LastAccess TIMESTAMP NOT NULL,
  UserId STRING(1024),
  ...
) PRIMARY KEY (LogEntryId, LastAccess, UserId);

PostgreSQL

CREATE TABLE useraccesslogs (
  logentryid VARCHAR(36) NOT NULL,
  lastaccess timestamptz NOT NULL,
  userid text,
  ...
PRIMARY KEY (lastaccess, userid)
);

Anda dapat menyisipkan GENERATE_UUID() untuk membuat nilai LogEntryId. GENERATE_UUID() menghasilkan nilai STRING, sehingga kolom LogEntryId harus menggunakan jenis STRING untuk GoogleSQL, atau jenis text untuk PostgreSQL.

GoogleSQL

INSERT INTO
  UserAccessLogs (LogEntryId, LastAccess, UserId)
VALUES
  (GENERATE_UUID(), '2016-01-25 10:10:10.555555-05:00', 'TomSmith');

PostgreSQL

INSERT INTO
  useraccesslogs (logentryid, lastaccess, userid)
VALUES
  (spanner.generate_uuid(),'2016-01-25 10:10:10.555555-05:00', 'TomSmith');

Ada beberapa kerugian menggunakan UUID:

  • Ukurannya sedikit besar, menggunakan 16 byte atau lebih. Opsi lain untuk kunci primer tidak menggunakan penyimpanan sebanyak ini.
  • Tidak membawa informasi tentang data. Misalnya, kunci utama SingerId dan AlbumId memiliki makna yang melekat, sedangkan UUID tidak.
  • Anda kehilangan lokalitas antara catatan terkait, itulah sebabnya penggunaan UUID menghilangkan hotspot.

Lakukan bit-reverse pada nilai yang berurutan

Anda harus memastikan bahwa kunci primer numerik (INT64 di GoogleSQL atau bigint di PostgreSQL) tidak meningkat atau menurun secara berurutan. Kunci utama berurutan dapat menyebabkan hotspot dalam skala besar. Salah satu cara untuk menghindari masalah ini adalah dengan membalikkan nilai berurutan bit, dengan memastikan untuk mendistribusikan nilai kunci utama secara merata di seluruh ruang kunci.

Spanner mendukung urutan bit-reverse, yang menghasilkan nilai bit-reverse bilangan bulat unik. Anda dapat menggunakan urutan dalam komponen pertama (atau satu-satunya) di kunci utama untuk menghindari masalah hotspot. Untuk mengetahui informasi selengkapnya, lihat Urutan bit-reversed.

Menukar urutan tombol

Salah satu cara untuk menyebarkan penulisan di seluruh ruang kunci secara lebih seragam adalah dengan menukar urutan kunci sehingga kolom yang berisi nilai monoton bukan merupakan bagian kunci pertama:

GoogleSQL

CREATE TABLE UserAccessLogs (
UserId     INT64 NOT NULL,
LastAccess TIMESTAMP NOT NULL,
...
) PRIMARY KEY (UserId, LastAccess);

PostgreSQL

CREATE TABLE useraccesslogs (
userid bigint NOT NULL,
lastaccess TIMESTAMPTZ NOT NULL,
...
PRIMARY KEY (UserId, LastAccess)
);

Dalam skema yang diubah ini, penyisipan kini diurutkan terlebih dahulu berdasarkan UserId, bukan berdasarkan stempel waktu akses terakhir secara kronologis. Skema ini menyebarkan penulisan di antara beberapa pemisahan karena kemungkinan satu pengguna menghasilkan ribuan peristiwa per detik sangat kecil.

Gambar berikut menunjukkan lima baris dari tabel UserAccessLogs yang diurutkan Spanner dengan UserId, bukan stempel waktu akses:

Tabel UserAccessLogs diurutkan berdasarkan UserId dengan throughput penulisan yang seimbang

Di sini, Spanner dapat membagi data UserAccessLogs menjadi tiga bagian, dengan setiap bagian berisi sekitar seribu baris nilai UserId yang diurutkan. Meskipun peristiwa pengguna terjadi dengan selisih sekitar satu milidetik, setiap peristiwa dimunculkan oleh pengguna yang berbeda, sehingga urutan penyisipan cenderung tidak membuat hotspot dibandingkan dengan menggunakan stempel waktu untuk pengurutan. Untuk mempelajari lebih lanjut cara membuat pemisahan, lihat Pemisahan berbasis beban

Lihat juga praktik terbaik terkait untuk mengurutkan kunci berbasis stempel waktu.

Buat hash kunci unik dan sebarkan penulisan di seluruh shard logis

Teknik umum lainnya untuk mendistribusikan beban di beberapa server adalah dengan membuat kolom yang berisi hash dari kunci unik sebenarnya, lalu menggunakan kolom hash (atau kolom hash dan kolom kunci unik secara bersamaan) sebagai kunci utama. Pola ini membantu menghindari hotspot, karena baris baru didistribusikan lebih merata di seluruh ruang kunci.

Anda dapat menggunakan nilai hash untuk membuat shard logis, atau partisi, di database Anda. Dalam database yang di-shard secara fisik, baris tersebar di beberapa server database. Dalam database yang di-shard secara logis, data dalam tabel menentukan shard. Misalnya, untuk menyebarkan penulisan ke tabel UserAccessLogs di seluruh N shard logis, Anda dapat menambahkan kolom kunci ShardId ke tabel:

GoogleSQL

CREATE TABLE UserAccessLogs (
ShardId     INT64 NOT NULL,
LastAccess  TIMESTAMP NOT NULL,
UserId      INT64 NOT NULL,
...
) PRIMARY KEY (ShardId, LastAccess, UserId);

PostgreSQL

CREATE TABLE useraccesslogs (
shardid bigint NOT NULL,
lastaccess TIMESTAMPTZ NOT NULL,
userid bigint NOT NULL,
...
PRIMARY KEY (shardid, lastaccess, userid)
);

Untuk menghitung ShardId, buat hash kombinasi kolom kunci utama, lalu hitung modulo N dari hash tersebut. Contoh:

GoogleSQL

ShardId = hash(LastAccess and UserId) % N

Pilihan fungsi hash dan kombinasi kolom Anda menentukan cara penyebaran baris di seluruh ruang kunci. Kemudian, Spanner akan membuat pemisahan di seluruh baris untuk mengoptimalkan performa.

Diagram berikut menggambarkan cara penggunaan hash untuk membuat tiga shard logis dapat menyebarkan throughput penulisan secara lebih merata di seluruh server:

Tabel UserAccessLogs diurutkan berdasarkan ShardId dengan throughput tulis yang seimbang

Di sini, tabel UserAccessLogs diurutkan berdasarkan ShardId, yang dihitung sebagai fungsi hash kolom utama. Lima baris UserAccessLogs dikelompokkan menjadi tiga shard logis, yang masing-masing secara kebetulan berada dalam pemisahan yang berbeda. Penyisipan didistribusikan secara merata di antara pemisahan, yang menyeimbangkan throughput tulis ke tiga server yang menangani pemisahan.

Spanner juga memungkinkan Anda membuat fungsi hash di kolom yang dihasilkan.

Untuk melakukannya di GoogleSQL, gunakan fungsi FARM_FINGERPRINT selama waktu penulisan, seperti yang ditunjukkan dalam contoh berikut:

GoogleSQL

CREATE TABLE UserAccessLogs (
ShardId INT64 NOT NULL
AS (MOD(FARM_FINGERPRINT(CAST(LastAccess AS STRING)), 2048)) STORED,
LastAccess TIMESTAMP NOT NULL,
UserId    INT64 NOT NULL,
) PRIMARY KEY (ShardId, LastAccess, UserId);

Pilihan fungsi hash Anda menentukan seberapa baik penyisipan Anda tersebar di seluruh rentang kunci. Anda tidak memerlukan hash kriptografi, meskipun hash kriptografi mungkin merupakan pilihan yang baik. Saat memilih fungsi hash, Anda perlu mempertimbangkan faktor-faktor berikut:

  • Menghindari hotspot. Fungsi yang menghasilkan lebih banyak nilai hash cenderung mengurangi hotspot.
  • Efisiensi baca. Pembacaan di semua nilai hash lebih cepat jika nilai hash yang dipindai lebih sedikit.
  • Jumlah node.

Menggunakan urutan menurun untuk kunci berbasis stempel waktu

Jika Anda memiliki tabel untuk histori yang menggunakan stempel waktu sebagai kunci, pertimbangkan untuk menggunakan urutan menurun untuk kolom kunci jika salah satu hal berikut berlaku:

  • Jika Anda ingin membaca histori terbaru, Anda menggunakan tabel yang disisipkan untuk histori, dan Anda membaca baris induk. Dalam hal ini, dengan kolom stempel waktu DESC, entri histori terbaru disimpan berdekatan dengan baris induk. Jika tidak, membaca baris induk dan histori terbarunya akan memerlukan pencarian di tengah untuk melewati histori yang lebih lama.
  • Jika Anda membaca entri berurutan dalam urutan kronologis terbalik, dan Anda tidak tahu persis seberapa jauh Anda kembali. Misalnya, Anda dapat menggunakan kueri SQL dengan LIMIT untuk mendapatkan N peristiwa terbaru, atau Anda dapat berencana membatalkan pembacaan setelah membaca sejumlah baris tertentu. Dalam kasus ini, Anda ingin memulai dengan entri terbaru dan membaca entri yang lebih lama secara berurutan hingga kondisi Anda terpenuhi, yang dilakukan Spanner secara lebih efisien untuk kunci stempel waktu yang disimpan Spanner dalam urutan menurun.

Tambahkan kata kunci DESC untuk membuat kunci stempel waktu menurun. Contoh:

GoogleSQL

CREATE TABLE UserAccessLogs (
UserId     INT64 NOT NULL,
LastAccess TIMESTAMP NOT NULL,
...
) PRIMARY KEY (UserId, LastAccess DESC);

Praktik terbaik desain skema #2: Urutan menurun atau urutan menaik bergantung pada kueri pengguna, misalnya, yang teratas adalah yang terbaru, atau yang teratas adalah yang terlama.

Kapan harus menggunakan indeks yang diselingi

Mirip dengan contoh kunci utama sebelumnya yang harus Anda hindari, membuat indeks non-sisipan pada kolom yang nilainya meningkat atau menurun secara monoton juga merupakan ide yang buruk, meskipun kolom tersebut bukan kolom kunci utama.

Misalnya, Anda menentukan tabel berikut, dengan LastAccess adalah kolom non-kunci-primer:

GoogleSQL

CREATE TABLE Users (
UserId     INT64 NOT NULL,
LastAccess TIMESTAMP,
...
) PRIMARY KEY (UserId);

PostgreSQL

CREATE TABLE Users (
userid     bigint NOT NULL,
lastaccess TIMESTAMPTZ,
...
PRIMARY KEY (userid)
);

Mungkin akan lebih mudah untuk menentukan indeks pada kolom LastAccess untuk mengkueri database dengan cepat untuk akses pengguna "sejak waktu X", seperti ini:

GoogleSQL

CREATE NULL_FILTERED INDEX UsersByLastAccess ON Users(LastAccess);

PostgreSQL

CREATE INDEX usersbylastaccess ON users(lastaccess)
WHERE lastaccess IS NOT NULL;

Namun, hal ini menyebabkan kesalahan yang sama seperti yang dijelaskan dalam praktik terbaik sebelumnya, karena Spanner menerapkan indeks sebagai tabel di balik layar, dan tabel indeks yang dihasilkan menggunakan kolom yang nilainya meningkat secara monoton sebagai bagian kunci pertamanya.

Anda dapat membuat indeks yang disisipkan tempat baris akses terakhir disisipkan di bawah baris pengguna yang sesuai. Hal ini karena satu baris induk tidak mungkin menghasilkan ribuan peristiwa per detik.

GoogleSQL

CREATE NULL_FILTERED INDEX UsersByLastAccess
ON Users(UserId, LastAccess),
INTERLEAVE IN Users;

PostgreSQL

CREATE INDEX usersbylastaccess ON users(userid, lastaccess)
WHERE lastaccess IS NOT NULL,
INTERLEAVE IN Users;

Praktik terbaik desain skema #3: Jangan membuat indeks non-sisipan pada kolom dengan kecepatan penulisan tinggi yang nilainya meningkat atau menurun secara monoton. Gunakan indeks yang di-interleave, atau gunakan teknik seperti yang akan Anda gunakan untuk desain kunci utama tabel dasar saat mendesain kolom indeks—misalnya, tambahkan `shardId`.

Langkah berikutnya