Если коллекция содержит документы со значениями, проиндексированными последовательно, Cloud Firestore ограничивает скорость записи до 500 записей в секунду. На этой странице описано, как сегментировать поле документа, чтобы обойти это ограничение. Сначала давайте определим, что мы подразумеваем под «полями с последовательной индексацией», и уточним, когда применяется это ограничение.
Последовательно индексированные поля
«Последовательно индексированные поля» означают любую коллекцию документов, содержащую монотонно возрастающее или убывающее индексированное поле. Во многих случаях это означает поле timestamp , но любое монотонно возрастающее или убывающее значение поля может привести к превышению лимита записи в 500 записей в секунду.
Например, ограничение применяется к коллекции user документов с индексированным полем userid если приложение присваивает значения userid следующим образом:
-
1281, 1282, 1283, 1284, 1285, ...
С другой стороны, не все поля timestamp приводят к срабатыванию этого ограничения. Если поле timestamp отслеживает случайным образом распределенные значения, ограничение на запись не применяется. Фактическое значение поля также не имеет значения, важно лишь то, является ли значение поля монотонно возрастающим или убывающим. Например, оба следующих набора монотонно возрастающих значений поля приводят к срабатыванию ограничения на запись:
-
100000, 100001, 100002, 100003, ... -
0, 1, 2, 3, ...
Шардирование поля временной метки
Предположим, ваше приложение использует монотонно возрастающее поле timestamp . Если ваше приложение не использует поле timestamp ни в одном запросе, вы можете снять ограничение в 500 записей в секунду, не индексируя это поле. Если же вам необходимо поле timestamp для ваших запросов, вы можете обойти это ограничение, используя сегментированные временные метки :
- Добавьте поле
shardрядом с полемtimestamp. Используйте1..nразличных значений для поляshard. Это увеличит лимит записи для коллекции до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]); });
После некоторых исследований вы определяете, что приложение будет получать от 1000 до 1500 обновлений инструментов в секунду. Это превышает допустимую скорость записи в 500 операций в секунду для коллекций, содержащих документы с индексированными полями временной метки. Для увеличения пропускной способности записи вам потребуется 3 значения сегментов, MAX_INSTRUMENT_UPDATES/500 = 3 В этом примере используются значения сегментов x , y и z . Вы также можете использовать числа или другие символы для значений сегментов.
Добавление поля сегментации
Добавьте поле shard к вашим документам. Установите для поля shard значения x , y или z , что увеличит лимит записи в коллекцию до 1500 записей в секунду.
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
Откройте страницу « Составные индексы Cloud Firestore в консоли Firebase.
Для каждого индекса, содержащего поле
timestamp, нажмите кнопку , а затем кнопку Delete .
Консоль GCP
В консоли Google Cloud перейдите на страницу «Базы данных» .
Выберите необходимую базу данных из списка баз данных.
В навигационном меню щелкните «Индексы» , а затем перейдите на вкладку «Составной» .
Используйте поле «Фильтр» для поиска определений индексов, содержащих поле
timestamp.Для каждого из этих индексов нажмите кнопку , а затем кнопку Delete .
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
Откройте страницу « Однопольные индексы Cloud Firestore в консоли Firebase.
Нажмите «Добавить льготу» .
В поле «Идентификатор коллекции» введите
instruments. В поле «Путь к полю» введитеtimestamp.В разделе «Область запроса» выберите как «Коллекция» , так и «Группа коллекций» .
Нажмите Далее
Переключите все параметры индекса в положение «Отключено» . Нажмите «Сохранить» .
Повторите те же шаги для поля
shard.
Консоль GCP
В консоли Google Cloud перейдите на страницу «Базы данных» .
Выберите необходимую базу данных из списка баз данных.
В навигационном меню щелкните «Индексы» , а затем перейдите на вкладку «Отдельное поле» .
Щелкните вкладку «Одно поле» .
Нажмите «Добавить льготу» .
В поле «Идентификатор коллекции» введите
instruments. В поле «Путь к полю» введите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 . Например, для поддержки приведенных выше запросов добавьте следующие индексы:
| Коллекция | Индексированные поля | Область запроса |
|---|---|---|
| инструменты | shard, price.currency, timestamp | Коллекция |
| инструменты | shard, exchange, timestamp | Коллекция |
| инструменты | shard, instrumentType, timestamp | Коллекция |
Сообщения об ошибках
Вы можете создать эти индексы, выполнив обновленные запросы.
Каждый запрос возвращает сообщение об ошибке со ссылкой для создания необходимого индекса в консоли 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