資料分割的時間戳記

如果集合包含具有序列索引值的文件,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. 針對 shardtimestamp 欄位停用單一欄位索引。刪除含有 timestamp 欄位的現有複合式索引。
  5. 建立新的複合式索引,支援更新後的查詢。索引中的欄位順序很重要,且 shard 欄位必須位於 timestamp 欄位之前。如果索引包含 timestamp 欄位,也必須包含 shard 欄位。

只有在持續寫入速率超過每秒 500 次的用途中,才應實作分片時間戳記。否則,這就是過早的最佳化。對 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 個分片值,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. 針對每個索引,按一下 按鈕,然後按一下「刪除」

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 Single Field Indexes」(單一欄位索引) 頁面。

    前往「單一欄位索引」

  2. 按一下「新增豁免」

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

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

  5. 按一下「下一步」

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

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

GCP 控制台

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

    前往「資料庫」

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

  3. 在導覽選單中,按一下「索引」,然後點選「單一欄位」分頁標籤。

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

  5. 按一下「新增豁免」

  6. 在「Collection ID」(集合 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 會將這些索引項目整理成稱為「平板電腦」的資料群組。每個Cloud Firestore伺服器都包含一或多個平板電腦。當特定子表的寫入負載過高時,Cloud Firestore 會水平擴充,將子表分割成較小的子表,並將新子表分散到不同的 Cloud Firestore 伺服器。

Cloud Firestore 詞彙上接近的索引項目位於同一平板電腦。如果子表中的索引值過於接近 (例如時間戳記欄位),Cloud Firestore 就無法有效率地將子表分割成較小的子表。這會造成熱點,也就是單一平板電腦接收的流量過多,導致熱點的讀寫作業變慢。

將時間戳記欄位分片後,Cloud Firestore 就能有效將工作負載分配到多個平板電腦。雖然時間戳記欄位的值可能仍很接近,但串連的分片和索引值會在索引項目之間提供Cloud Firestore足夠的空間,將項目分割到多個平板電腦。

後續步驟