Carimbos de data/hora fragmentados

Se uma coleção contiver documentos com valores indexados sequenciais, o Cloud Firestore vai limitar a taxa de gravação a 500 gravações por segundo. Esta página descreve como fragmentar um campo de documento para ultrapassar esse limite. Primeiro, vamos definir o significado de "campos indexados sequenciais" e esclarecer quando esse limite é aplicado.

Campos indexados sequenciais

"Campos indexados sequenciais" são qualquer coleção de documentos que contém um campo indexado monotonicamente crescente ou decrescente. Em muitos casos, isso significa um campo de timestamp, mas qualquer valor de campo que aumenta ou diminui monotonicamente pode acionar o limite de 500 gravações por segundo.

Por exemplo, o limite será aplicado a uma coleção de documentos user com campo indexado userid se o aplicativo atribuir valores de userid como:

  • 1281, 1282, 1283, 1284, 1285, ...

Por outro lado, nem todos os campos de timestamp acionam esse limite. Se um campo de timestamp rastreia valores distribuídos aleatoriamente, o limite de gravação não é aplicado. Da mesma forma, o valor real do campo também não importa, sendo relevante apenas se ele está aumentando ou diminuindo monotonicamente. Por exemplo, os dois conjuntos a seguir de valores de campo que aumentam monotonicamente acionam o limite de gravação:

  • 100000, 100001, 100002, 100003, ...
  • 0, 1, 2, 3, ...

Como fragmentar um campo de carimbo de data/hora

Suponha que o aplicativo use um campo de timestamp que aumenta monotonicamente. Se o aplicativo não usar o campo de timestamp em uma consulta, será possível remover o limite de 500 gravações por segundo não indexando o campo de carimbo de data/hora. Se for necessário um campo de timestamp para consultas, será possível contornar o limite usando carimbos de data/hora fragmentados:

  1. Adicione um campo de shard ao lado do campo de timestamp. Use valores distintos de 1..n para o campo shard. Isso aumenta o limite de gravação da coleção para 500*n, mas você deve agregar n consultas.
  2. Atualize a lógica de gravação para atribuir aleatoriamente um valor de shard para cada documento.
  3. Atualize as consultas para agregar os conjuntos de resultados fragmentados.
  4. Desative os índices de campo único para o campo de shard e o campo de timestamp. Exclua os índices compostos atuais que contêm o campo de timestamp.
  5. Crie novos índices compostos para suportar as consultas atualizadas. A ordem dos campos em um índice é importante, e o campo shard deve vir antes do campo timestamp. Qualquer índice que inclua o campo timestamp também deve incluir o campo shard.

Carimbos de data/hora fragmentados devem ser implementados apenas em casos de uso com taxas de gravação sustentadas acima de 500 gravações por segundo. Caso contrário, essa é uma otimização prematura. A fragmentação de um campo de timestamp remove a restrição de 500 gravações por segundo, mas requer agregações de consulta do lado do cliente.

Os exemplos a seguir mostram como fragmentar um campo de timestamp e como consultar um conjunto de resultados fragmentados.

Exemplos de consultas e modelos de dados

Como exemplo, imagine um aplicativo para análise quase em tempo real de instrumentos financeiros como moedas, ações ordinárias e ETFs. Este aplicativo grava documentos em uma coleção de instruments tal como:

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();
}

Este aplicativo executa as seguintes consultas e pedidos pelo campo de 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]);
    });

Após algumas pesquisas, você determina que o aplicativo receberá entre 1.000 e 1.500 atualizações de instrumento por segundo. Isso ultrapassa as 500 gravações por segundo permitidas para coleções contendo documentos com campos de carimbo de data/hora indexados. Para aumentar a capacidade de gravação, você precisa de três valores de fragmento, MAX_INSTRUMENT_UPDATES/500 = 3. Este exemplo usa os valores de fragmento x, y e z. Também é possível usar números ou outros caracteres para valores de fragmento.

Como adicionar um campo de fragmento

Adicione um campo de shard aos documentos. Defina o campo shard para os valores de x, y ou z, o que aumenta o limite de gravação na coleção para 1.500 gravações por segundo.

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();
}

Como consultar o carimbo de data/hora fragmentado

A adição de um campo de shard exige que as consultas sejam atualizadas para agregar resultados fragmentados:

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]);
    });

Atualizar definições de índice

Para remover a restrição de 500 gravações por segundo, exclua os índices compostos e de campo único existentes que usam o campo de timestamp.

Excluir definições de índice composto

Console do Firebase

  1. Abra a página Índices compostos do Cloud Firestore no Console do Firebase.

    Acessar "Índices compostos"

  2. Para cada índice que contém o campo de timestamp, clique no botão e clique em Excluir.

Console do GCP

  1. No Console do Google Cloud, acesse a página Bancos de Dados.

    Acessar "Bancos de dados"

  2. Selecione o banco de dados necessário na lista de bancos de dados.

  3. No menu de navegação, clique em Índices e na guia Composto.

  4. Use o campo Filtro para procurar definições de índice que contenham o campo timestamp.

  5. Para cada um desses índices, clique no botão e em Excluir.

CLI do Firebase

  1. Se a CLI do Firebase não foi configurada, siga estas instruções para instalar a CLI e executar o comando firebase init. Durante o comando init, certifique-se de selecionar Firestore: Deploy rules and create indexes for Firestore.
  2. Durante a configuração, a CLI do Firebase faz o download das suas definições de índice atuais para um arquivo nomeado, por padrão, firestore.indexes.json.
  3. Remova as definições de índice que contiverem o campo de timestamp, por exemplo:

    {
    "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. Implante as definições de índice atualizadas:

    firebase deploy --only firestore:indexes
    

Atualizar definições de índice de campo único

Console do Firebase

  1. Abra a página Índices de campo único do Cloud Firestore no Console do Firebase.

    Acessar "Índices de campo único"

  2. Clique em Adicionar isenção.

  3. Para ID da coleção, insira instruments. Para Caminho do campo, insira timestamp.

  4. Em Escopo da consulta, selecione Grupos de coleções e Coleção.

  5. Clique em Próximo.

  6. Alterne todas as configurações de índice para Desativado. Clique em Salvar.

  7. Repita as mesmas etapas para o campo de shard.

Console do GCP

  1. No Console do Google Cloud, acesse a página Bancos de Dados.

    Acessar "Bancos de dados"

  2. Selecione o banco de dados necessário na lista de bancos de dados.

  3. No menu de navegação, clique em Índices e na guia Campo único.

  4. Clique na guia Campo único.

  5. Clique em Adicionar isenção.

  6. Para ID da coleção, insira instruments. Para Caminho do campo, insira timestamp.

  7. Em Escopo da consulta, selecione Grupos de coleções e Coleção.

  8. Clique em Próximo.

  9. Alterne todas as configurações de índice para Desativado. Clique em Salvar.

  10. Repita as mesmas etapas para o campo de shard.

CLI do Firebase

  1. Adicione o seguinte à seção fieldOverrides do arquivo de definições de índice:

    {
     "fieldOverrides": [
       // Disable single-field indexing for the timestamp field
       {
         "collectionGroup": "instruments",
         "fieldPath": "timestamp",
         "indexes": []
       },
     ]
    }
    
  2. Implante as definições de índice atualizadas:

    firebase deploy --only firestore:indexes
    

Criar novos índices compostos

Após remover todos os índices anteriores que contêm o timestamp, defina os novos índices que o aplicativo requer. Qualquer índice que contenha o campo de timestamp também precisará conter o campo de shard. Por exemplo, para dar suporte às consultas acima, adicione os seguintes índices:

Coleção Campos indexados Escopo da consulta
instruments shard, price.currency, timestamp Coleção
instruments shard, exchange, timestamp Coleção
instruments shard, instrumentType, timestamp Coleção

Mensagens de erro

É possível criar esses índices executando as consultas atualizadas.

Cada consulta retorna uma mensagem de erro com um link para criar o índice necessário no Console do Firebase.

CLI do Firebase

  1. Adicione os seguintes índices ao arquivo de definição de índice:

     {
       "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. Implante as definições de índice atualizadas:

    firebase deploy --only firestore:indexes
    

Como entender o limite de gravação para campos indexados sequenciais

O limite da taxa de gravação para os campos indexados sequenciais tem origem na forma como o Cloud Firestore armazena os valores e dimensiona as gravações do índice. Para cada gravação de índice, o Cloud Firestore define uma entrada de chave-valor que concatena o nome do documento e o valor de cada campo indexado. O Cloud Firestore organiza essas entradas de índice em grupos de dados chamados blocos. Cada servidor do Cloud Firestore tem um ou mais blocos. Quando a carga de gravação em um bloco específico se torna muito alta, o Cloud Firestore é dimensionado horizontalmente dividindo o bloco em blocos menores que são espalhados pelos diferentes servidores do Cloud Firestore.

O Cloud Firestore coloca entradas de índice lexicograficamente próximas no mesmo bloco. Se os valores de índice em um bloco estiverem muito próximos, como nos campos de carimbo de data/hora, o Cloud Firestore não poderá dividir o bloco com eficiência em blocos menores. Isso cria um hot spot onde um único bloco recebe muito tráfego e as operações de leitura e gravação ficam mais lentas.

Compartilhar um campo de carimbo de data/hora possibilita que o Cloud Firestore divida eficientemente as cargas de trabalho em vários blocos. Embora os valores do campo de carimbo de data/hora possam permanecer próximos, o valor concatenado do fragmento e do índice fornecem ao Cloud Firestore espaço suficiente entre as entradas do índice para dividi-las em vários blocos.

A seguir