資料分割的時間戳記

如果集合包含含有序列索引值的文件,Cloud Firestore 會將寫入速度限制為每秒 500 次寫入。本頁說明如何分割文件欄位,以克服這項限制。首先,讓我們定義「依序編入索引的欄位」的意思,並說明何時會套用這項限制。

依序索引欄位

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

舉例來說,如果應用程式將 userid 值指派如下,則此限制就會套用至含有已建立索引欄位 useriduser 文件集合:

  • 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. 更新查詢,匯總分割的結果集。
  4. shard 欄位和 timestamp 欄位停用單一欄位索引。刪除含有 timestamp 欄位的現有複合式索引。
  5. 建立新的複合式索引,以支援更新後的查詢。索引中的欄位順序很重要,shard 欄位必須位於 timestamp 欄位之前。包含 timestamp 欄位的索引也必須包含 shard 欄位。

只有在每秒寫入次數超過 500 次的持續寫入速率用途,才應實作分割時間戳記。否則,這就是不成熟的最佳化。分割 timestamp 欄位可移除每秒 500 次寫入的限制,但需要用戶端查詢匯總。

以下範例說明如何切割 timestamp 欄位,以及如何查詢已切割的結果集。

資料模型和查詢範例

舉例來說,假設有個應用程式可近乎即時分析貨幣、普通股和交易所交易基金等金融工具,這個應用程式會將文件寫入 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 個區塊值 MAX_INSTRUMENT_UPDATES/500 = 3。本範例使用分割區值 xyz。您也可以使用數字或其他字元做為區塊值。

新增分割欄位

在文件中新增 shard 欄位。將 shard 欄位設為 xyz 值,可將集合的寫入限制提高至每秒 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 控制台中開啟「Cloud Firestore 複合索引」頁面。

    前往「複合式索引」

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

GCP 控制台

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

    前往「資料庫」

  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 控制台中開啟「Cloud Firestore 單一欄位索引」頁面。

    前往「單一欄位索引」

  2. 按一下「新增豁免條款

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

  4. 在「查詢範圍」下方,選取「集合」和「集合群組」

  5. 按一下「下一步」

  6. 將所有索引設定切換為「已停用」。按一下「儲存」。

  7. 針對 shard 欄位重複上述步驟。

GCP 控制台

  1. 前往 Google Cloud 控制台的「Databases」頁面。

    前往「資料庫」

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

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

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

  5. 按一下「新增豁免條款

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

  7. 在「查詢範圍」下方,選取「集合」和「集合群組」

  8. 按一下「下一步」

  9. 將所有索引設定切換為「已停用」。按一下「儲存」。

  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 分片、 price.currency、 時間戳記 集合
instruments 分割區、 交換、 時間戳記 集合
instruments 區塊、 instrumentType、 時間戳記 集合

錯誤訊息

您可以執行更新後的查詢來建立這些索引。

每個查詢都會傳回錯誤訊息,並附上連結,可在 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 伺服器都會保留一或多個平板電腦。當特定子表的寫入負載過高時,Cloud Firestore 會將子表分割成較小的子表,並將新子表分散到不同的 Cloud Firestore 伺服器,以便橫向擴充。

Cloud Firestore 會將字典順序相近的索引項目放在同一個平板電腦上。如果子表中的索引值過於相近 (例如時間戳記欄位),Cloud Firestore 就無法有效地將子表分割成較小的子表。這會造成熱點,也就是單一平板電腦收到過多流量,而熱點的讀取和寫入作業速度變慢。

透過分割時間戳記欄位,Cloud Firestore 就能在多個平板電腦之間有效地分割工作負載。雖然時間戳記欄位的值可能會彼此相近,但連結的區塊和索引值會為 Cloud Firestore 提供足夠的空間,讓索引項目之間分散在多個平板電腦上。

後續步驟