如果集合包含具有序列索引值的文件,Cloud Firestore 會將寫入頻率限制為每秒 500 次寫入。本頁說明如何將文件欄位分片,以克服這項限制。首先,我們來定義「連續索引欄位」的意義,並釐清這項限制的適用時機。
連續索引欄位
「循序索引欄位」是指包含單調遞增或遞減索引欄位的任何文件集合。在許多情況下,這表示 timestamp
欄位,但任何單調遞增或遞減的欄位值都可能觸發每秒 500 次寫入的限制。
舉例來說,如果應用程式以這種方式指派 userid
值,則限制會套用至具有已建立索引欄位 userid
的 user
文件集合:
1281, 1282, 1283, 1284, 1285, ...
另一方面,並非所有 timestamp
欄位都會觸發這項限制。如果 timestamp
欄位追蹤隨機分布的值,則不適用寫入限制。欄位的實際值也不重要,只要欄位單調遞增或遞減即可。舉例來說,下列兩組單調遞增的欄位值都會觸發寫入限制:
100000, 100001, 100002, 100003, ...
0, 1, 2, 3, ...
將時間戳記欄位分片
假設您的應用程式使用單調遞增的 timestamp
欄位。
如果應用程式在任何查詢中都沒有使用 timestamp
欄位,您可以不要為時間戳記欄位建立索引,藉此移除每秒 500 次寫入的限制。如果查詢需要 timestamp
欄位,可以使用分片時間戳記來避開限制:
- 在
timestamp
欄位旁新增shard
欄位。請為shard
欄位使用1..n
相異值。這樣一來,集合的寫入限制就會提高至500*n
,但您必須彙整n
個查詢。 - 更新寫入邏輯,為每個文件隨機指派
shard
值。 - 更新查詢,彙整分片結果集。
- 針對
shard
和timestamp
欄位停用單一欄位索引。刪除含有timestamp
欄位的現有複合式索引。 - 建立新的複合式索引,支援更新後的查詢。索引中的欄位順序很重要,且
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
。本範例使用分片值 x
、y
和 z
。您也可以使用數字或其他字元做為分片值。
新增分片欄位
在文件中新增 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 主控台
在 Firebase 控制台中開啟「Cloud Firestore 複合索引」頁面。
針對包含
timestamp
欄位的每個索引,按一下 按鈕,然後按一下「刪除」。
GCP 控制台
前往 Google Cloud 控制台的「資料庫」頁面。
從資料庫清單中選取所需資料庫。
在導覽選單中,按一下「索引」,然後點選「複合」分頁標籤。
使用「Filter」(篩選器) 欄位,搜尋含有
timestamp
欄位的索引定義。針對每個索引,按一下
按鈕,然後按一下「刪除」。
Firebase CLI
- 如果尚未設定 Firebase CLI,請按照這些指示安裝 CLI 並執行
firebase init
指令。在init
指令中,請務必選取Firestore: Deploy rules and create indexes for Firestore
。 - 設定期間,Firebase CLI 會將現有的索引定義下載至名為
firestore.indexes.json
的檔案 (預設名稱)。 移除含有
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" } ] }, ] }
部署更新後的索引定義:
firebase deploy --only firestore:indexes
更新單一欄位索引定義
Firebase 主控台
在 Firebase 控制台中開啟「Cloud Firestore Single Field Indexes」(單一欄位索引) 頁面。
按一下「新增豁免」。
在「Collection ID」(集合 ID) 中輸入
instruments
。針對「Field path」(欄位路徑),輸入timestamp
。在「查詢範圍」下方,選取「集合」和「集合群組」。
按一下「下一步」
將所有索引設定切換為「已停用」。按一下「儲存」。
針對
shard
欄位重複上述步驟。
GCP 控制台
前往 Google Cloud 控制台的「資料庫」頁面。
從資料庫清單中選取所需資料庫。
在導覽選單中,按一下「索引」,然後點選「單一欄位」分頁標籤。
按一下「單一欄位」分頁標籤。
按一下「新增豁免」。
在「Collection ID」(集合 ID) 中輸入
instruments
。針對「Field path」(欄位路徑),輸入timestamp
。在「查詢範圍」下方,選取「集合」和「集合群組」。
按一下「下一步」
將所有索引設定切換為「已停用」。按一下「儲存」。
針對
shard
欄位重複上述步驟。
Firebase CLI
在索引定義檔案的
fieldOverrides
區段中新增下列內容:{ "fieldOverrides": [ // Disable single-field indexing for the timestamp field { "collectionGroup": "instruments", "fieldPath": "timestamp", "indexes": [] }, ] }
部署更新後的索引定義:
firebase deploy --only firestore:indexes
建立新的複合式索引
移除所有包含 timestamp
的先前索引後,請定義應用程式所需的新索引。如果索引包含 timestamp
欄位,也必須包含 shard
欄位。舉例來說,如要支援上述查詢,請新增下列索引:
集合 | 已建立索引的欄位 | 查詢範圍 |
---|---|---|
instruments | 分片、 price.currency、 時間戳記 | 集合 |
instruments | 分片、 交易所、 時間戳記 | 集合 |
instruments | 分片、 instrumentType、 時間戳記 | 集合 |
錯誤訊息
您可以執行更新後的查詢來建構這些索引。
每個查詢都會傳回錯誤訊息,並附上在 Firebase 控制台中建立必要索引的連結。
Firebase CLI
在索引定義檔案中新增下列索引:
{ "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" } ] }, ] }
部署更新後的索引定義:
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足夠的空間,將項目分割到多個平板電腦。
後續步驟
- 請參閱大規模設計的最佳做法
- 如果單一文件的寫入率很高,請參閱「分散式計數器」一文。
- 請參閱Cloud Firestore的標準限制