Stempel waktu yang di-sharding

Jika suatu koleksi berisi dokumen dengan nilai terindeks berurutan, Cloud Firestore akan membatasi tingkat penulisan menjadi 500 penulisan per detik. Halaman ini menjelaskan cara melakukan sharding kolom dokumen untuk mengatasi batas ini. Pertama, mari definisikan arti "kolom terindeks berurutan" dan perjelas kapan batas ini berlaku.

Kolom terindeks berurutan

"Kolom terindeks berurutan" adalah koleksi dokumen apa pun yang berisi kolom terindeks yang meningkat atau menurun secara monoton. Sering kali, yang dimaksud adalah kolom timestamp, tetapi nilai kolom apa pun yang meningkat atau menurun secara monoton dapat memicu batas penulisan sebanyak 500 penulisan per detik.

Misalnya, batas tersebut akan berlaku pada koleksi dokumen user dengan kolom terindeks userid jika aplikasi menetapkan nilai userid seperti berikut:

  • 1281, 1282, 1283, 1284, 1285, ...

Di sisi lain, tidak semua kolom timestamp memicu batas ini. Jika kolom timestamp melacak nilai yang terdistribusi secara acak, batas penulisan tidak berlaku. Nilai kolom yang sebenarnya juga tidak berpengaruh, yang penting kolom tersebut meningkat atau menurun secara monoton. Misalnya, kedua set nilai kolom yang meningkat secara monoton berikut ini akan memicu batas penulisan:

  • 100000, 100001, 100002, 100003, ...
  • 0, 1, 2, 3, ...

Sharding kolom stempel waktu

Misalkan aplikasi Anda menggunakan kolom timestamp yang meningkat secara monoton. Jika aplikasi Anda tidak menggunakan kolom timestamp dalam kueri apa pun, Anda dapat menghapus batas 500 penulisan per detik dengan cara tidak mengindeks kolom stempel waktu. Jika Anda benar-benar membutuhkan kolom timestamp untuk kueri, Anda dapat menyiasati batasnya menggunakan stempel waktu yang di-sharding:

  1. Tambahkan kolom shard di samping kolom timestamp. Gunakan nilai 1..n yang berbeda untuk kolom shard. Hal ini akan meningkatkan batas penulisan untuk koleksi menjadi 500*n, tetapi Anda harus menggabungkan kueri n.
  2. Perbarui logika penulisan agar secara acak menetapkan nilai shard pada setiap dokumen.
  3. Update kueri Anda untuk menggabungkan kumpulan hasil yang di-sharding.
  4. Nonaktifkan indeks kolom tunggal untuk kolom shard dan kolom timestamp. Hapus indeks komposit yang ada yang berisi kolom timestamp.
  5. Buat indeks komposit baru untuk mendukung kueri Anda yang telah diperbarui. Urutan kolom dalam indeks bersifat penting, dan kolom shard harus ditempatkan sebelum kolom timestamp. Setiap indeks yang menyertakan kolom timestamp juga harus menyertakan kolom shard.

Anda harus menerapkan stempel waktu yang di-sharding hanya dalam kasus penggunaan dengan tingkat penulisan berkelanjutan di atas 500 penulisan per detik. Di luar itu, pengoptimalan ini bersifat prematur. Sharding kolom timestamp akan menghapus batas 500 penulisan per detik, tetapi akibatnya Anda memerlukan penggabungan kueri sisi klien.

Contoh berikut menunjukkan cara melakukan sharding kolom timestamp dan cara membuat kueri untuk kumpulan hasil yang di-sharding.

Contoh model data dan kueri

Sebagai contoh, bayangkan aplikasi untuk melakukan analisis yang hampir real time bagi instrumen keuangan seperti mata uang, saham biasa, dan ETF. Aplikasi ini menulis dokumen ke koleksi instruments seperti berikut:

Node.js
async function insertData() {
  const instruments = [
    {
      symbol: 'AAA',
      price: {
        currency: 'USD',
        micros: 34790000
      },
      exchange: 'EXCHG1',
      instrumentType: 'commonstock',
      timestamp: Timestamp.fromMillis(
          Date.parse('2019-01-01T13:45:23.010Z'))
    },
    {
      symbol: 'BBB',
      price: {
        currency: 'JPY',
        micros: 64272000000
      },
      exchange: 'EXCHG2',
      instrumentType: 'commonstock',
      timestamp: Timestamp.fromMillis(
          Date.parse('2019-01-01T13:45:23.101Z'))
    },
    {
      symbol: 'Index1 ETF',
      price: {
        currency: 'USD',
        micros: 473000000
      },
      exchange: 'EXCHG1',
      instrumentType: 'etf',
      timestamp: Timestamp.fromMillis(
          Date.parse('2019-01-01T13:45:23.001Z'))
    }
  ];

  const batch = fs.batch();
  for (const inst of instruments) {
    const ref = fs.collection('instruments').doc();
    batch.set(ref, inst);
  }

  await batch.commit();
}

Aplikasi ini menjalankan kueri berikut dan mengurutkan berdasarkan kolom timestamp:

Node.js
function createQuery(fieldName, fieldOperator, fieldValue, limit = 5) {
  return fs.collection('instruments')
      .where(fieldName, fieldOperator, fieldValue)
      .orderBy('timestamp', 'desc')
      .limit(limit)
      .get();
}

function queryCommonStock() {
  return createQuery('instrumentType', '==', 'commonstock');
}

function queryExchange1Instruments() {
  return createQuery('exchange', '==', 'EXCHG1');
}

function queryUSDInstruments() {
  return createQuery('price.currency', '==', 'USD');
}
insertData()
    .then(() => {
      const commonStock = queryCommonStock()
          .then(
              (docs) => {
                console.log('--- queryCommonStock: ');
                docs.forEach((doc) => {
                  console.log(`doc = ${util.inspect(doc.data(), {depth: 4})}`);
                });
              }
          );
      const exchange1Instruments = queryExchange1Instruments()
          .then(
              (docs) => {
                console.log('--- queryExchange1Instruments: ');
                docs.forEach((doc) => {
                  console.log(`doc = ${util.inspect(doc.data(), {depth: 4})}`);
                });
              }
          );
      const usdInstruments = queryUSDInstruments()
          .then(
              (docs) => {
                console.log('--- queryUSDInstruments: ');
                docs.forEach((doc) => {
                  console.log(`doc = ${util.inspect(doc.data(), {depth: 4})}`);
                });
              }
          );
      return Promise.all([commonStock, exchange1Instruments, usdInstruments]);
    });

Setelah melakukan riset, Anda menentukan bahwa aplikasi akan menerima antara 1.000 hingga 1.500 pembaruan instrumen per detik. Ini melampaui 500 penulisan per detik yang diizinkan untuk koleksi yang berisi dokumen dengan kolom stempel waktu terindeks. Untuk meningkatkan throughput penulisan, Anda memerlukan 3 nilai shard, MAX_INSTRUMENT_UPDATES/500 = 3. Contoh ini menggunakan nilai shard x, y, dan z. Anda juga dapat menggunakan angka atau karakter lain untuk nilai shard Anda.

Menambahkan kolom shard

Tambahkan kolom shard ke dokumen Anda. Tetapkan kolom shard ke nilai x, y, atau z yang menaikkan batas penulisan pada koleksi menjadi 1.500 penulisan per detik.

Node.js
// Define our 'K' shard values
const shards = ['x', 'y', 'z'];
// Define a function to help 'chunk' our shards for use in queries.
// When using the 'in' query filter there is a max number of values that can be
// included in the value. If our number of shards is higher than that limit
// break down the shards into the fewest possible number of chunks.
function shardChunks() {
  const chunks = [];
  let start = 0;
  while (start < shards.length) {
    const elements = Math.min(MAX_IN_VALUES, shards.length - start);
    const end = start + elements;
    chunks.push(shards.slice(start, end));
    start = end;
  }
  return chunks;
}

// Add a convenience function to select a random shard
function randomShard() {
  return shards[Math.floor(Math.random() * Math.floor(shards.length))];
}
async function insertData() {
  const instruments = [
    {
      shard: randomShard(),  // add the new shard field to the document
      symbol: 'AAA',
      price: {
        currency: 'USD',
        micros: 34790000
      },
      exchange: 'EXCHG1',
      instrumentType: 'commonstock',
      timestamp: Timestamp.fromMillis(
          Date.parse('2019-01-01T13:45:23.010Z'))
    },
    {
      shard: randomShard(),  // add the new shard field to the document
      symbol: 'BBB',
      price: {
        currency: 'JPY',
        micros: 64272000000
      },
      exchange: 'EXCHG2',
      instrumentType: 'commonstock',
      timestamp: Timestamp.fromMillis(
          Date.parse('2019-01-01T13:45:23.101Z'))
    },
    {
      shard: randomShard(),  // add the new shard field to the document
      symbol: 'Index1 ETF',
      price: {
        currency: 'USD',
        micros: 473000000
      },
      exchange: 'EXCHG1',
      instrumentType: 'etf',
      timestamp: Timestamp.fromMillis(
          Date.parse('2019-01-01T13:45:23.001Z'))
    }
  ];

  const batch = fs.batch();
  for (const inst of instruments) {
    const ref = fs.collection('instruments').doc();
    batch.set(ref, inst);
  }

  await batch.commit();
}

Membuat kueri untuk stempel waktu yang di-sharding

Untuk menambahkan kolom shard, Anda harus memperbarui kueri untuk menggabungkan hasil yang di-sharding:

Node.js
function createQuery(fieldName, fieldOperator, fieldValue, limit = 5) {
  // For each shard value, map it to a new query which adds an additional
  // where clause specifying the shard value.
  return Promise.all(shardChunks().map(shardChunk => {
        return fs.collection('instruments')
            .where('shard', 'in', shardChunk)  // new shard condition
            .where(fieldName, fieldOperator, fieldValue)
            .orderBy('timestamp', 'desc')
            .limit(limit)
            .get();
      }))
      // Now that we have a promise of multiple possible query results, we need
      // to merge the results from all of the queries into a single result set.
      .then((snapshots) => {
        // Create a new container for 'all' results
        const docs = [];
        snapshots.forEach((querySnapshot) => {
          querySnapshot.forEach((doc) => {
            // append each document to the new all container
            docs.push(doc);
          });
        });
        if (snapshots.length === 1) {
          // if only a single query was returned skip manual sorting as it is
          // taken care of by the backend.
          return docs;
        } else {
          // When multiple query results are returned we need to sort the
          // results after they have been concatenated.
          // 
          // since we're wanting the `limit` newest values, sort the array
          // descending and take the first `limit` values. By returning negated
          // values we can easily get a descending value.
          docs.sort((a, b) => {
            const aT = a.data().timestamp;
            const bT = b.data().timestamp;
            const secondsDiff = aT.seconds - bT.seconds;
            if (secondsDiff === 0) {
              return -(aT.nanoseconds - bT.nanoseconds);
            } else {
              return -secondsDiff;
            }
          });
          return docs.slice(0, limit);
        }
      });
}

function queryCommonStock() {
  return createQuery('instrumentType', '==', 'commonstock');
}

function queryExchange1Instruments() {
  return createQuery('exchange', '==', 'EXCHG1');
}

function queryUSDInstruments() {
  return createQuery('price.currency', '==', 'USD');
}
insertData()
    .then(() => {
      const commonStock = queryCommonStock()
          .then(
              (docs) => {
                console.log('--- queryCommonStock: ');
                docs.forEach((doc) => {
                  console.log(`doc = ${util.inspect(doc.data(), {depth: 4})}`);
                });
              }
          );
      const exchange1Instruments = queryExchange1Instruments()
          .then(
              (docs) => {
                console.log('--- queryExchange1Instruments: ');
                docs.forEach((doc) => {
                  console.log(`doc = ${util.inspect(doc.data(), {depth: 4})}`);
                });
              }
          );
      const usdInstruments = queryUSDInstruments()
          .then(
              (docs) => {
                console.log('--- queryUSDInstruments: ');
                docs.forEach((doc) => {
                  console.log(`doc = ${util.inspect(doc.data(), {depth: 4})}`);
                });
              }
          );
      return Promise.all([commonStock, exchange1Instruments, usdInstruments]);
    });

Memperbarui definisi indeks

Untuk menghapus batas 500 penulisan per detik, hapus indeks kolom tunggal dan indeks komposit yang ada yang menggunakan kolom timestamp.

Menghapus definisi indeks komposit

Firebase Console

  1. Buka halaman Indeks Komposit Cloud Firestore di Firebase console.

    Buka Composite Indexes

  2. Untuk setiap indeks yang berisi kolom timestamp, klik tombol dan klik Delete.

GCP Console

  1. Di konsol Google Cloud, buka halaman Databases.

    Buka Databases

  2. Pilih database yang diperlukan dari daftar database.

  3. Di menu navigasi, klik Indexes, lalu klik tab Composite.

  4. Gunakan kolom Filter untuk mencari definisi indeks yang berisi kolom timestamp.

  5. Untuk setiap indeks ini, klik tombol dan klik Hapus.

Firebase CLI

  1. Jika Anda belum menyiapkan Firebase CLI, ikuti petunjuk ini untuk menginstal CLI dan menjalankan perintah firebase init. Selama perintah init, pastikan untuk memilih Firestore: Deploy rules and create indexes for Firestore.
  2. Selama penyiapan, Firebase CLI akan mendownload definisi indeks yang ada ke file yang secara default bernama firestore.indexes.json.
  3. Hapus semua definisi indeks yang berisi kolom timestamp, misalnya:

    {
    "indexes": [
      // Delete composite index definition that contain the timestamp field
      {
        "collectionGroup": "instruments",
        "queryScope": "COLLECTION",
        "fields": [
          {
            "fieldPath": "exchange",
            "order": "ASCENDING"
          },
          {
            "fieldPath": "timestamp",
            "order": "DESCENDING"
          }
        ]
      },
      {
        "collectionGroup": "instruments",
        "queryScope": "COLLECTION",
        "fields": [
          {
            "fieldPath": "instrumentType",
            "order": "ASCENDING"
          },
          {
            "fieldPath": "timestamp",
            "order": "DESCENDING"
          }
        ]
      },
      {
        "collectionGroup": "instruments",
        "queryScope": "COLLECTION",
        "fields": [
          {
            "fieldPath": "price.currency",
            "order": "ASCENDING"
          },
          {
            "fieldPath": "timestamp",
            "order": "DESCENDING"
          }
        ]
      },
     ]
    }
    
  4. Deploy definisi indeks Anda yang telah diperbarui:

    firebase deploy --only firestore:indexes
    

Memperbarui definisi Indeks kolom tunggal

Firebase Console

  1. Buka halaman Indeks Kolom Tunggal Cloud Firestore di Firebase console.

    Buka Single Field Indexes

  2. Klik Add Exemption.

  3. Untuk ID Koleksi, masukkan instruments. Untuk Jalur kolom, masukkan timestamp.

  4. Pada Cakupan kueri, pilih Koleksi dan Grup koleksi.

  5. Klik Berikutnya

  6. Alihkan semua setelan indeks menjadi Dinonaktifkan. Klik Simpan.

  7. Ulangi langkah yang sama untuk kolom shard.

GCP Console

  1. Di konsol Google Cloud, buka halaman Databases.

    Buka Databases

  2. Pilih database yang diperlukan dari daftar database.

  3. Di menu navigasi, klik Indexes, lalu klik tab Single Field.

  4. Klik tab Single Field.

  5. Klik Tambahkan Pengecualian.

  6. Untuk ID Koleksi, masukkan instruments. Untuk Jalur kolom, masukkan timestamp.

  7. Pada Cakupan kueri, pilih Koleksi dan Grup koleksi.

  8. Klik Berikutnya

  9. Alihkan semua setelan indeks menjadi Dinonaktifkan. Klik Simpan.

  10. Ulangi langkah yang sama untuk kolom shard.

Firebase CLI

  1. Tambahkan yang berikut ini ke bagian fieldOverrides dari file definisi indeks Anda:

    {
     "fieldOverrides": [
       // Disable single-field indexing for the timestamp field
       {
         "collectionGroup": "instruments",
         "fieldPath": "timestamp",
         "indexes": []
       },
     ]
    }
    
  2. Deploy definisi indeks Anda yang telah diperbarui:

    firebase deploy --only firestore:indexes
    

Membuat indeks komposit baru

Setelah menghapus semua indeks sebelumnya yang berisi timestamp, tentukan indeks baru yang diperlukan aplikasi Anda. Setiap indeks yang berisi kolom timestamp juga harus berisi kolom shard. Misalnya, untuk mendukung kueri di atas, tambahkan indeks berikut:

Koleksi Kolom yang diindeks Cakupan kueri
instruments shard, price.currency, timestamp Koleksi
instruments shard, exchange, timestamp Koleksi
instruments shard, instrumentType, timestamp Koleksi

Pesan Error

Anda dapat mem-build indeks ini dengan menjalankan kueri yang diperbarui.

Setiap kueri akan menampilkan pesan error yang berisi link untuk membuat indeks yang diperlukan di Firebase Console.

Firebase CLI

  1. Tambahkan indeks berikut ke file definisi indeks Anda:

     {
       "indexes": [
       // New indexes for sharded timestamps
         {
           "collectionGroup": "instruments",
           "queryScope": "COLLECTION",
           "fields": [
             {
               "fieldPath": "shard",
               "order": "DESCENDING"
             },
             {
               "fieldPath": "exchange",
               "order": "ASCENDING"
             },
             {
               "fieldPath": "timestamp",
               "order": "DESCENDING"
             }
           ]
         },
         {
           "collectionGroup": "instruments",
           "queryScope": "COLLECTION",
           "fields": [
             {
               "fieldPath": "shard",
               "order": "DESCENDING"
             },
             {
               "fieldPath": "instrumentType",
               "order": "ASCENDING"
             },
             {
               "fieldPath": "timestamp",
               "order": "DESCENDING"
             }
           ]
         },
         {
           "collectionGroup": "instruments",
           "queryScope": "COLLECTION",
           "fields": [
             {
               "fieldPath": "shard",
               "order": "DESCENDING"
             },
             {
               "fieldPath": "price.currency",
               "order": "ASCENDING"
             },
             {
               "fieldPath": "timestamp",
               "order": "DESCENDING"
             }
           ]
         },
       ]
     }
    
  2. Deploy definisi indeks Anda yang telah diperbarui:

    firebase deploy --only firestore:indexes
    

Memahami batas penulisan untuk kolom terindeks berurutan

Batas tingkat penulisan untuk kolom terindeks berurutan berasal dari cara Cloud Firestore menyimpan nilai indeks dan menskalakan penulisan indeks. Untuk setiap penulisan indeks, Cloud Firestore mendefinisikan entri key-value yang menggabungkan nama dokumen dan nilai setiap kolom terindeks. Cloud Firestore mengatur entri indeks ini ke dalam kelompok data yang disebut tablet. Setiap server Cloud Firestore menampung satu tablet atau lebih. Saat beban penulisan ke tablet tertentu menjadi terlalu tinggi, Cloud Firestore akan melakukan penskalaan secara horizontal dengan memecah tablet tersebut menjadi tablet-tablet yang lebih kecil dan menyebarkan tablet-tablet baru tersebut ke berbagai server Cloud Firestore.

Cloud Firestore menempatkan entri indeks yang mirip secara leksikografis pada tablet yang sama. Jika nilai indeks dalam tablet terlalu berdekatan, seperti untuk kolom stempel waktu, Cloud Firestore tidak dapat memecah tablet tersebut menjadi tablet-tablet yang lebih kecil secara efisien. Hal ini menghasilkan hot spot, yaitu satu tablet menerima terlalu banyak traffic, dan operasi baca serta tulis ke hot spot tersebut menjadi lebih lambat.

Dengan sharding kolom stempel waktu, Anda memungkinkan Cloud Firestore untuk secara efisien membagi beban kerja ke beberapa tablet. Meskipun nilai-nilai kolom stempel waktu mungkin tetap berdekatan, gabungan nilai indeks dan shard memberi Cloud Firestore cukup ruang antarentri indeks untuk membagi entri tersebut ke beberapa tablet.

Langkah berikutnya