資料分割的時間戳記

如果集合包含的文件具有依序索引值,Cloud Firestore 會將寫入速率限制為每秒 500 次。本頁面說明如何將文件欄位進行 shard 處理,以克服這項限制。首先,我們來定義「依序索引欄位」的含義,並釐清這項限制的適用時機。

依序建立索引的欄位

「依序建立索引的欄位」是指任何包含單調遞增或遞減索引欄位的文件集合。在許多情況下,這代表 timestamp 欄位,但任何單調遞增或遞減的欄位值,都可能觸發每秒 500 次寫入的寫入限制。

舉例來說,如果應用程式指派 userid 值,則限制適用於具有索引欄位 userid 的 user 文件集合,如下所示:

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

另一方面,並非所有 timestamp 欄位都會觸發這項限制。如果 timestamp 欄位追蹤隨機分布的值,則不適用寫入限制。欄位的實際值也不重要,只要欄位單調遞增或遞減即可。舉例來說,下列兩組單調遞增的欄位值都會觸發寫入限制:

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

將時間戳記欄位進行資料分割

假設應用程式使用單調遞增的 timestamp 欄位。如果應用程式在任何查詢中都不使用 timestamp 欄位,您可以不為時間戳記欄位建立索引,藉此移除每秒 500 次寫入的限制。如果查詢確實需要 timestamp 欄位,可以使用分區時間戳記來規避限制:

  1. 在 timestamp 欄位旁新增 shard 欄位。shard 欄位請使用 1..n 個不重複的值。這會將集合的寫入限制提高至 500*n,但您必須匯總 n 查詢。
  2. 更新寫入邏輯,為每份文件「隨機」指派 shard 值。
  3. 更新查詢,匯總 shard 結果集。
  4. 停用 shard 欄位和 timestamp 欄位的單一欄位索引。刪除含有 timestamp 欄位的現有複合式索引。
  5. 建立新的複合式索引,支援更新後的查詢。索引中的欄位順序很重要,shard 欄位必須位於 timestamp 欄位之前。任何包含 timestamp 欄位的索引也必須包含 shard 欄位。

只有在持續寫入速率超過每秒 500 次寫入的情況下,才應實作 shard 時間戳記。否則,這就是過早最佳化。對 timestamp 欄位進行資料分割,可移除每秒 500 次寫入的限制,但代價是需要用戶端查詢匯總。

以下範例說明如何將 timestamp 欄位進行資料分割,以及如何查詢資料分割結果集。

資料模型和查詢範例

舉例來說,假設有一個應用程式,可近乎即時分析貨幣、普通股和 ETF 等金融工具。這個應用程式會將文件寫入 instruments 集合,如下所示:

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();
}

這個應用程式會執行下列查詢,並依 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]);
    });

經過研究後,您判斷應用程式每秒會收到 1,000 到 1,500 次工具更新。這超過了集合每秒 500 次寫入的限制,其中包含具有索引時間戳記欄位的文件。如要提高寫入處理量,您需要 3 個 shard 值:MAX_INSTRUMENT_UPDATES/500 = 3。這個範例使用 shard 值 x、y 和 z。您也可以使用數字或其他字元做為 shard 值。

新增 shard 欄位

在文件中新增 shard 欄位。將 shard 欄位設為 x、y 或 z 值,將集合的寫入限制提高至每秒 1,500 次寫入。

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();
}

查詢已分區的時間戳記

新增 shard 欄位後,您必須更新查詢,匯總資料分割結果:

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]);
    });

更新索引定義

如要移除每秒 500 次寫入的限制,請刪除使用 timestamp 欄位的現有單一欄位和複合式索引。

刪除複合式索引定義

Firebase 控制台

  1. 在 Firebase 控制台中,依序前往「Databases & Storage」 >「Firestore」 >「Composite Indexes」分頁。

    前往「Composite Indexes」(複合式索引)

  2. 針對包含 timestamp 欄位的每個索引,按一下 按鈕,然後按一下「Delete」(刪除)。

GCP 控制台

  1. 前往 Google Cloud 控制台的「Databases」(資料庫) 頁面。

    前往「資料庫」

  2. 從資料庫清單中選取所需資料庫。

  3. 在導覽選單中,依序點選「索引」和「複合」分頁標籤。

  4. 使用「Filter」(篩選器) 欄位,搜尋包含 timestamp 欄位的索引定義。

  5. 針對這些索引,按一下 按鈕,然後點選「Delete」(刪除)。

Firebase CLI

  1. 如果尚未設定 Firebase CLI,請按照這些指示安裝 CLI 並執行 firebase init 指令。在 init 指令期間,請務必選取 Firestore: Deploy rules and create indexes for Firestore。
  2. 設定期間,Firebase CLI 會將現有索引定義下載到檔案,預設名稱為 firestore.indexes.json。
  3. 移除任何包含 timestamp 欄位的索引定義,例如:

    {
    "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. 部署更新後的索引定義:

    firebase deploy --only firestore:indexes
    

更新單一欄位索引定義

Firebase 控制台

  1. 在 Firebase 控制台中,依序前往「Databases & Storage」 >「Firestore」 >「Single Field Indexes」分頁。

    前往「Single Field Indexes」(單一欄位索引)

  2. 按一下「Add Exemption」(新增豁免條件)。

  3. 在「Collection ID」(集合 ID) 中輸入 instruments。在 「Field path」(欄位路徑) 中輸入 timestamp。

  4. 在「Query scope」下方,選取「Collection」和 「Collection group」。

  5. 點選「下一步」

  6. 將所有索引設定切換為「Disabled」。按一下「Save」(儲存)。

  7. 對 shard 欄位重複執行相同步驟。

GCP 控制台

  1. 前往 Google Cloud 控制台的「Databases」(資料庫) 頁面。

    前往「資料庫」

  2. 從資料庫清單中選取所需資料庫。

  3. 在導覽選單中,依序點選「Indexes」(索引) 和「Single Field」(單一欄位) 分頁標籤。

  4. 按一下「單一欄位」分頁標籤。

  5. 按一下「Add Exemption」(新增豁免條件)。

  6. 在「Collection ID」(集合 ID) 中輸入 instruments。在 「Field path」(欄位路徑) 中輸入 timestamp。

  7. 在「Query scope」下方,選取「Collection」和 「Collection group」。

  8. 點選「下一步」

  9. 將所有索引設定切換為「Disabled」。按一下「Save」(儲存)。

  10. 對 shard 欄位重複執行相同步驟。

Firebase CLI

  1. 在索引定義檔案的 fieldOverrides 區段中,新增下列內容:

    {
     "fieldOverrides": [
       // Disable single-field indexing for the timestamp field
       {
         "collectionGroup": "instruments",
         "fieldPath": "timestamp",
         "indexes": []
       },
     ]
    }
    
  2. 部署更新後的索引定義:

    firebase deploy --only firestore:indexes
    

建立新的複合式索引

移除所有包含 timestamp 的舊索引後,請定義應用程式所需的新索引。任何包含 timestamp 欄位的索引也必須包含 shard 欄位。舉例來說,如要支援上述查詢,請新增下列索引:

集合 已建立索引的欄位 查詢範圍
instruments 個 shard、 price.currency、 個時間戳記 集合
instruments 個 shard、 次交換、 個時間戳記 集合
instruments shard、 instrumentType、 timestamp 集合

錯誤訊息

執行更新後的查詢,即可建立這些索引。

每項查詢都會傳回錯誤訊息,並附上連結,方便您在 Firebase 控制台中建立必要的索引。

Firebase CLI

  1. 在索引定義檔案中新增下列索引:

     {
       "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. 部署更新後的索引定義:

    firebase deploy --only firestore:indexes
    

瞭解依序索引欄位的寫入限制

依序索引欄位的寫入速率限制,取決於 Cloud Firestore 儲存索引值和調度索引寫入資源的方式。針對每個索引寫入作業,Cloud Firestore 會定義鍵/值項目,串連文件名稱和每個索引欄位的值。Cloud Firestore 會將這些索引項目整理成稱為「Tablet」的資料群組。每個 Cloud Firestore 伺服器都有一或多個 Tablet。當特定 tablet 的寫入負載過高時,Cloud Firestore 會將 tablet 分割成較小的 tablet,並將新的 tablet 分散到不同的 Cloud Firestore 伺服器,以水平方式擴充。

Cloud Firestore 會將按字母順序排列的索引項目放在同一個 Tablet 上。如果 tablet 中的索引值太接近 (例如時間戳記欄位),Cloud Firestore 就無法有效率地將 tablet 分割成較小的 tablet。這會造成熱點,導致單一 Tablet 接收過多流量,且熱點的讀取和寫入作業速度變慢。

將時間戳記欄位進行 shard 處理,Cloud Firestore 就能有效率地將工作負載分配至多個 tablet。雖然時間戳記欄位的值可能仍會彼此接近,但串連的 shard 和索引值會為 Cloud Firestore 提供足夠的索引項目間距,以便在多個 tablet 之間分割項目。

後續步驟