Praktik terbaik desain skema

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

Halaman ini menjelaskan praktik terbaik untuk mendesain skema agar tidak membuat hotspot. Salah satu cara untuk menghindari hotspot adalah dengan 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.

Memilih 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. 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 diarahkan ke satu server yang akhirnya melakukan semua pekerjaan.

Misalnya, Anda ingin mempertahankan kolom stempel waktu akses terakhir pada baris tabel UserAccessLog. Definisi tabel berikut menggunakan kunci utama berbasis stempel waktu sebagai bagian kunci pertama. Sebaiknya jangan lakukan ini jika tabel memiliki tingkat penyisipan yang tinggi:

GoogleSQL

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

PostgreSQL

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

Masalahnya di sini adalah baris ditulis ke tabel ini berdasarkan 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 satu server tersebut.

Diagram berikut mengilustrasikan masalah ini:

Tabel UserAccessLog yang diurutkan berdasarkan stempel waktu dengan hotspot yang sesuai

Tabel UserAccessLog sebelumnya menyertakan lima contoh baris data, yang mewakili lima pengguna berbeda yang melakukan tindakan pengguna dengan selisih waktu milidetik satu sama lain. Diagram ini juga menganotasi urutan penyisipan baris oleh Spanner (panah berlabel menunjukkan urutan penulisan untuk setiap baris). Karena penyisipan diurutkan berdasarkan stempel waktu, dan nilai stempel waktu selalu meningkat, Spanner selalu menambahkan penyisipan ke akhir tabel dan mengarahkannya ke bagian yang sama. (Seperti yang dibahas dalam Skema dan model data, pemisahan adalah kumpulan 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 batas dan latar belakang merah yang ditampilkan pada gambar sebelumnya. Dalam ilustrasi sederhana ini, setiap server menangani maksimal satu bagian, tetapi Spanner dapat menetapkan lebih dari satu bagian ke setiap server.

Saat Spanner menambahkan lebih banyak baris ke tabel, bagian akan bertambah, dan saat mencapai sekitar 8 GB, Spanner akan membuat bagian lain, seperti yang dijelaskan dalam Pembagian berbasis beban. Spanner menambahkan baris baru berikutnya ke bagian baru ini, dan server yang ditetapkan ke bagian tersebut menjadi hotspot potensial baru.

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

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

Menggunakan ID Unik Universal (UUID)

Anda dapat menggunakan ID Unik Universal (UUID) sebagaimana didefinisikan 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 dalam 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 GENERATE_UUID() Spanner (GoogleSQL atau PostgreSQL) sebagai nilai default kolom agar Spanner otomatis menghasilkan 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 useraccesslog (
  logentryid VARCHAR(36) NOT NULL,
  lastaccess timestamptz NOT NULL,
  userid text,
  ...
PRIMARY KEY (lastaccess, userid)
);

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

GoogleSQL

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

PostgreSQL

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

Ada beberapa kelemahan menggunakan UUID:

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

Lakukan bit-reverse pada nilai yang berurutan

Anda harus memastikan bahwa kunci utama numerik (INT64 di GoogleSQL atau bigint di PostgreSQL) tidak bertambah atau menurun secara berurutan. Kunci utama berurutan dapat menyebabkan hotspot dalam skala besar. Salah satu cara untuk menghindari masalah ini adalah dengan membalikkan bit nilai berurutan, 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 di komponen pertama (atau satu-satunya) dalam kunci utama untuk menghindari masalah hotspot. Untuk informasi selengkapnya, lihat Urutan bit terbalik.

Menukar urutan tombol

Salah satu cara untuk menyebarkan operasi tulis di ruang kunci secara lebih merata adalah dengan menukar urutan kunci sehingga kolom yang berisi nilai monoton bukan bagian kunci pertama:

GoogleSQL

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

PostgreSQL

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

Dalam skema yang diubah ini, penyisipan kini diurutkan terlebih dahulu menurut UserId, bukan berdasarkan stempel waktu akses terakhir kronologis. Skema ini menyebarkan operasi tulis di antara pemisahan yang berbeda karena satu pengguna tidak mungkin menghasilkan ribuan peristiwa per detik.

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

Tabel UserAccessLog yang diurutkan berdasarkan UserId dengan throughput tulis yang seimbang

Di sini, Spanner mengelompokkan data UserAccessLog menjadi tiga bagian, dengan setiap bagian berisi sekitar seribu baris nilai UserId yang diurutkan. Ini adalah estimasi yang wajar tentang cara data pengguna dapat dibagi, dengan asumsi setiap baris berisi sekitar 1 MB data pengguna dan diberikan ukuran pemisahan maksimum sekitar 8 GB. Meskipun peristiwa pengguna terjadi dengan selang waktu sekitar satu milidetik, setiap peristiwa diangkat oleh pengguna yang berbeda, sehingga urutan penyisipan kemungkinan besar tidak akan membuat hotspot dibandingkan dengan menggunakan stempel waktu untuk pengurutan.

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

Meng-hash kunci unik dan menyebarkan operasi tulis di seluruh shard logis

Teknik umum lainnya untuk menyebarkan beban di beberapa server adalah dengan membuat kolom yang berisi hash kunci unik yang 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 tersebar lebih merata di seluruh ruang kunci.

Anda dapat menggunakan nilai hash untuk membuat shard atau partisi logis di database. 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 operasi tulis ke tabel UserAccessLog di seluruh shard logika N, Anda dapat menambahkan kolom kunci ShardId ke tabel:

GoogleSQL

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

PostgreSQL

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

Untuk menghitung ShardId, lakukan hashing pada kombinasi kolom kunci utama, lalu hitung modulo N dari hash. Contoh:

GoogleSQL

ShardId = hash(LastAccess and UserId) % N

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

Diagram berikut menggambarkan cara menggunakan hash untuk membuat tiga shard logika dapat menyebarkan throughput operasi tulis secara lebih merata di seluruh server:

Tabel UserAccessLog yang diurutkan berdasarkan ShardId dengan throughput tulis yang seimbang

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

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

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

GoogleSQL

CREATE TABLE UserAccessLog (
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 kriptografis, meskipun hash kriptografis mungkin merupakan pilihan yang baik. Saat memilih fungsi hash, Anda harus mempertimbangkan faktor-faktor berikut:

  • Penghindaran hotspot. Fungsi yang menghasilkan lebih banyak nilai hash cenderung mengurangi hotspot.
  • Efisiensi baca. Pembacaan di semua nilai hash akan lebih cepat jika ada lebih sedikit nilai hash yang akan dipindai.
  • 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 ingin membaca histori terbaru, Anda menggunakan tabel interleaved untuk histori, dan Anda membaca baris induk. Dalam hal ini, dengan kolom stempel waktu DESC, entri histori terbaru disimpan di samping 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 sejauh mana Anda akan kembali. Misalnya, Anda dapat menggunakan kueri SQL dengan LIMIT untuk mendapatkan peristiwa N terbaru, atau Anda dapat merencanakan untuk membatalkan pembacaan setelah membaca sejumlah baris tertentu. Dalam kasus ini, Anda ingin memulai dengan entri terbaru dan membaca secara berurutan entri yang lebih lama 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 UserAccessLog (
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, bagian atas adalah yang terbaru, atau bagian atas adalah yang terlama.

Menggunakan indeks sisipan pada kolom yang nilainya meningkat atau menurun secara monoton

Serupa dengan contoh kunci utama sebelumnya yang harus Anda hindari, sebaiknya jangan membuat indeks non-sisipan pada kolom yang nilainya meningkat atau menurun secara monoton, meskipun bukan kolom kunci utama.

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

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 menghasilkan jebakan 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.

Namun, Anda dapat membuat indeks yang diselingi seperti ini, karena baris indeks yang diselingi diselingi di baris induk yang sesuai, dan tidak mungkin satu baris induk menghasilkan ribuan peristiwa per detik.

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

Langkah selanjutnya