Если коллекция содержит документы с последовательными индексированными значениями, 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 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
. Например, для поддержки приведенных выше запросов добавьте следующие индексы:
Коллекция | Поля проиндексированы | Область запроса |
---|---|---|
инструменты | осколок, цена.валюта, временная метка | Коллекция |
инструменты | осколок | , обмен , временная меткаКоллекция |
инструменты | осколок | , тип инструмента , временная меткаКоллекция |
Сообщения об ошибках
Вы можете построить эти индексы, выполнив обновленные запросы.
Каждый запрос возвращает сообщение об ошибке со ссылкой для создания необходимого индекса в консоли 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