Если коллекция содержит документы с последовательными индексированными значениями, 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
, нажмите кнопку и нажмите «Удалить» .
Консоль GCP
В консоли Google Cloud перейдите на страницу «Базы данных» .
Выберите нужную базу данных из списка баз данных.
В меню навигации нажмите Индексы , а затем перейдите на вкладку Составной .
Используйте поле «Фильтр» для поиска определений индексов, содержащих поле
timestamp
.Для каждого из этих индексов нажмите кнопку
и нажмите «Удалить» .
Интерфейс командной строки Firebase
- Если вы не настроили интерфейс командной строки Firebase, следуйте этим инструкциям, чтобы установить интерфейс командной строки и запустить команду
firebase init
. Во время командыinit
обязательно выберитеFirestore: Deploy rules and create indexes for Firestore
. - Во время установки интерфейс командной строки Firebase загружает существующие определения индексов в файл с именем по умолчанию
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
.В разделе «Область запроса» выберите «Коллекция» и «Группа коллекций» .
Нажмите Далее
Переключите все настройки индекса на Disabled . Нажмите Сохранить .
Повторите те же действия для поля
shard
.
Консоль GCP
В консоли Google Cloud перейдите на страницу «Базы данных» .
Выберите нужную базу данных из списка баз данных.
В меню навигации нажмите «Индексы» , а затем перейдите на вкладку «Одно поле» .
Откройте вкладку «Одно поле» .
Нажмите «Добавить исключение» .
В качестве идентификатора коллекции введите
instruments
. В поле «Путь к полю» введитеtimestamp
.В разделе «Область запроса» выберите «Коллекция» и «Группа коллекций» .
Нажмите Далее
Переключите все настройки индекса на Disabled . Нажмите Сохранить .
Повторите те же действия для поля
shard
.
Интерфейс командной строки Firebase
Добавьте следующее в раздел
fieldOverrides
файла определений индекса:{ "fieldOverrides": [ // Disable single-field indexing for the timestamp field { "collectionGroup": "instruments", "fieldPath": "timestamp", "indexes": [] }, ] }
Разверните обновленные определения индексов:
firebase deploy --only firestore:indexes
Создание новых составных индексов
После удаления всех предыдущих индексов, содержащих timestamp
, определите новые индексы, необходимые вашему приложению. Любой индекс, содержащий поле timestamp
также должен содержать поле shard
. Например, для поддержки приведенных выше запросов добавьте следующие индексы:
Коллекция | Поля проиндексированы | Область запроса |
---|---|---|
инструменты | осколок | , цена , метка времениКоллекция |
инструменты | осколок | , обмен , метка времениКоллекция |
инструменты | осколок | , тип инструмента , метка времениКоллекция |
Сообщения об ошибках
Вы можете построить эти индексы, выполнив обновленные запросы.
Каждый запрос возвращает сообщение об ошибке со ссылкой для создания необходимого индекса в консоли Firebase.
Интерфейс командной строки Firebase
Добавьте следующие индексы в файл определения индекса:
{ "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