Если коллекция содержит документы с последовательными индексированными значениями, 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 записей в секунду, разрешенных для коллекций, содержащих документы с индексированными полями временных меток. Чтобы увеличить пропускную способность записи, вам нужно три значения сегмента.
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
В консоли Firebase перейдите на вкладку Databases & Storage (Базы данных и хранилище) > Firestore > Composite Indexes (Составные индексы).
Для каждого индекса, содержащего поле
timestamp, нажмите кнопку и выберите Удалить.
GCP Console
В консоли Google Cloud перейдите на страницу Databases (Базы данных).
Выберите нужную базу данных из списка.
В меню навигации нажмите Indexes (Индексы), а затем откройте вкладку Composite (Составные).
Используйте поле Фильтр, чтобы найти определения индексов, содержащие поле
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
В консоли Firebase перейдите на вкладку Databases & Storage (Базы данных и хранилище) > Firestore > Single Field Indexes (Индексы отдельных полей).
Нажмите Добавить исключение.
В поле Идентификатор коллекции введите
instruments. В поле Путь к полю введитеtimestamp.В разделе Область запроса выберите Коллекция и Группа коллекций.
Нажмите Далее.
Установите для всех настроек индексирования значение Отключено. Нажмите Сохранить.
Повторите эти действия для поля
shard.
GCP Console
В консоли Google Cloud перейдите на страницу Databases (Базы данных).
Выберите нужную базу данных из списка.
В меню навигации нажмите Индексы, а затем выберите вкладку Одно поле.
Перейдите на вкладку Одно поле.
Нажмите Добавить исключение.
В поле Идентификатор коллекции введите
instruments. В поле Путь к полю введитеtimestamp.В разделе Область запроса выберите Коллекция и Группа коллекций.
Нажмите Далее.
Установите для всех настроек индексирования значение Отключено. Нажмите Сохранить.
Повторите эти действия для поля
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. Например, чтобы поддерживать приведенные выше запросы, добавьте следующие индексы:
| Коллекция | Проиндексированные поля | Область действия запроса |
|---|---|---|
| инструменты | shard, price.currency, timestamp | Коллекция |
| инструменты | фрагмент, обмен, временная метка | Коллекция |
| инструменты | shard, instrumentType, timestamp | Коллекция |
Сообщения об ошибках
Чтобы создать эти индексы, выполните обновленные запросы.
Каждый запрос возвращает сообщение об ошибке со ссылкой для создания необходимого индекса в консоли 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.