Сегментированные временные метки

Если коллекция содержит документы с последовательными индексированными значениями, 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 для ваших запросов, вы можете обойти ограничение, используя сегментированные метки времени :

  1. Добавьте поле shard рядом с полем timestamp . Используйте 1..n различных значений для поля shard . Это увеличит лимит записи для коллекции до 500*n , но вы должны агрегировать n запросов.
  2. Обновите логику записи, чтобы случайным образом назначать значение shard каждому документу.
  3. Обновите свои запросы, чтобы объединить сегментированные наборы результатов.
  4. Отключить индексы с одним полем как для поля shard , так и для поля timestamp . Удалить существующие составные индексы, содержащие поле timestamp .
  5. Создайте новые составные индексы для поддержки обновленных запросов. Порядок полей в индексе имеет значение, и поле 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

  1. Откройте страницу «Композитные индексы Cloud Firestore в консоли Firebase.

    Перейти к составным индексам

  2. Для каждого индекса, содержащего поле timestamp , нажмите кнопку и нажмите Удалить .

Консоль GCP

  1. В консоли Google Cloud перейдите на страницу Базы данных .

    Перейти к базам данных

  2. Выберите необходимую базу данных из списка баз данных.

  3. В меню навигации нажмите «Индексы» , а затем перейдите на вкладку «Составные» .

  4. Используйте поле «Фильтр» для поиска определений индекса, содержащих поле timestamp .

  5. Для каждого из этих индексов нажмите кнопку и нажмите Удалить .

Firebase CLI

  1. Если вы не настроили Firebase CLI, следуйте этим указаниям, чтобы установить CLI и запустить команду firebase init . Во время команды init обязательно выберите Firestore: Deploy rules and create indexes for Firestore .
  2. Во время настройки Firebase CLI загружает существующие определения индексов в файл, который по умолчанию называется firestore.indexes.json .
  3. Удалите все определения индекса, содержащие поле 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"
          }
        ]
      },
     ]
    }
    
  4. Разверните обновленные определения индекса:

    firebase deploy --only firestore:indexes
    

Обновление определений индекса по одному полю

Консоль Firebase

  1. Откройте страницу индексов отдельных полей Cloud Firestore в консоли Firebase.

    Перейти к индексам отдельных полей

  2. Нажмите «Добавить исключение» .

  3. Для идентификатора коллекции введите instruments . Для пути к полю введите timestamp .

  4. В разделе Область запроса выберите Коллекция и Группа коллекций .

  5. Нажмите «Далее» .

  6. Переключите все настройки индекса на Disabled . Нажмите Save .

  7. Повторите те же действия для поля shard .

Консоль GCP

  1. В консоли Google Cloud перейдите на страницу Базы данных .

    Перейти к базам данных

  2. Выберите необходимую базу данных из списка баз данных.

  3. В меню навигации нажмите «Индексы» , а затем нажмите вкладку «Одно поле» .

  4. Перейдите на вкладку «Одно поле» .

  5. Нажмите «Добавить исключение» .

  6. Для идентификатора коллекции введите instruments . Для пути к полю введите timestamp .

  7. В разделе Область запроса выберите Коллекция и Группа коллекций .

  8. Нажмите «Далее» .

  9. Переключите все настройки индекса на Disabled . Нажмите Save .

  10. Повторите те же действия для поля shard .

Firebase CLI

  1. Добавьте следующее в раздел fieldOverrides файла определений индекса:

    {
     "fieldOverrides": [
       // Disable single-field indexing for the timestamp field
       {
         "collectionGroup": "instruments",
         "fieldPath": "timestamp",
         "indexes": []
       },
     ]
    }
    
  2. Разверните обновленные определения индекса:

    firebase deploy --only firestore:indexes
    

Создать новые составные индексы

После удаления всех предыдущих индексов, содержащих timestamp , определите новые индексы, которые требуются вашему приложению. Любой индекс, содержащий поле timestamp , должен также содержать поле shard . Например, для поддержки запросов выше добавьте следующие индексы:

Коллекция Поля проиндексированы Область запроса
инструменты осколок, цена.валюта, временная метка Коллекция
инструменты осколок, обмен, временная метка Коллекция
инструменты осколок, instrumentType, временная метка Коллекция

Сообщения об ошибках

Вы можете построить эти индексы, выполнив обновленные запросы.

Каждый запрос возвращает сообщение об ошибке со ссылкой для создания необходимого индекса в консоли Firebase.

Firebase CLI

  1. Добавьте следующие индексы в файл определения индекса:

     {
       "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"
             }
           ]
         },
       ]
     }
    
  2. Разверните обновленные определения индекса:

    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 достаточно места между записями индекса, чтобы разделить записи между несколькими планшетами.

Что дальше?