Datas/horas divididas

Se uma coleção contiver documentos com valores indexados sequenciais, o Firestore limita a taxa de escrita a 500 escritas por segundo. Esta página descreve como dividir um campo de documento para ultrapassar este limite. Primeiro, vamos definir o que entendemos por "campos indexados sequenciais" e esclarecer quando este limite se aplica.

Campos indexados sequenciais

"Campos indexados sequenciais" refere-se a qualquer coleção de documentos que contenha um campo indexado que aumenta ou diminui monotonicamente. Em muitos casos, isto significa um campo timestamp, mas qualquer valor de campo que aumente ou diminua monotonicamente pode acionar o limite de gravação de 500 gravações por segundo.

Por exemplo, o limite aplica-se a uma coleção de user documentos com o campo indexado userid se a app atribuir valores userid da seguinte forma:

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

Por outro lado, nem todos os campos timestamp acionam este limite. Se um campo timestamp acompanhar valores distribuídos aleatoriamente, o limite de gravação não se aplica. O valor real do campo também não importa, apenas que o campo esteja a aumentar ou diminuir monotonicamente. Por exemplo, os dois conjuntos seguintes de valores de campos que aumentam monotonicamente acionam o limite de gravação:

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

Dividir um campo de data/hora

Suponha que a sua app usa um campo timestamp que aumenta monotonicamente. Se a sua app não usar o campo timestamp em nenhuma consulta, pode remover o limite de 500 gravações por segundo não indexando o campo de data/hora. Se precisar de um campo timestamp para as suas consultas, pode contornar o limite usando datas/horas fragmentadas:

  1. Adicione um campo shard junto ao campo timestamp. Use 1..nvalores distintos para o campo shard. Isto aumenta o limite de gravação da coleção para 500*n, mas tem de agregar n consultas.
  2. Atualize a lógica de escrita para atribuir aleatoriamente um valor shard a cada documento.
  3. Atualize as consultas para agregar os conjuntos de resultados divididos.
  4. Desative os índices de campo único para o campo shard e o campo timestamp. Elimine os índices compostos existentes que contenham o campo timestamp.
  5. Crie novos índices compostos para suportar as suas consultas atualizadas. A ordem dos campos num índice é importante, e o campo shard tem de vir antes do campo timestamp. Todos os índices que incluam o campo timestamp também têm de incluir o campo shard.

Deve implementar as datas/horas divididas apenas em exemplos de utilização com taxas de gravação sustentadas superiores a 500 gravações por segundo. Caso contrário, trata-se de uma otimização prematura. A divisão de um campo timestamp remove a restrição de 500 escritas por segundo, mas com a desvantagem de precisar de agregações de consultas do lado do cliente.

Os exemplos seguintes mostram como dividir um campo timestamp e como consultar um conjunto de resultados dividido.

Exemplo de modelo de dados e consultas

Por exemplo, imagine uma app para análise quase em tempo real de instrumentos financeiros, como moedas, ações comuns e ETFs. Esta app escreve documentos numa coleção instruments da seguinte forma:

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

Esta app executa as seguintes consultas e ordena-as pelo campo 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 alguma pesquisa, determina que a app vai receber entre 1000 e 1500 atualizações de instrumentos por segundo. Isto excede as 500 gravações por segundo permitidas para coleções que contêm documentos com campos de data/hora indexados. Para aumentar o débito de gravação, precisa de 3 valores de fragmentos, MAX_INSTRUMENT_UPDATES/500 = 3. Este exemplo usa os valores de fragmento x, y e z. Também pode usar números ou outros carateres para os valores de fragmentos.

Adicionar um campo de fragmentação

Adicione um campo shard aos seus documentos. Defina o campo shard para os valores x, y ou z, o que aumenta o limite de gravação na recolha para 1500 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();
}

Consultar a data/hora dividida

A adição de um campo shard requer que atualize as suas consultas para agregar resultados divididos:

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

Atualize as definições do índice

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

Elimine definições de índice composto

Consola do Firebase

  1. Abra a página Índices compostos do Firestore na consola do Firebase.

    Aceder a Índices compostos

  2. Para cada índice que contenha o campo timestamp, clique no botão e clique em Eliminar.

GCP Console

  1. Na Google Cloud consola, aceda à página Bases de dados.

    Aceda a Bases de dados

  2. Selecione a base de dados necessária na lista de bases de dados.

  3. No menu de navegação, clique em Índices e, de seguida, clique no separador Composto.

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

  5. Para cada um destes índices, clique no botão e em Eliminar.

Firebase CLI

  1. Se não configurou a Firebase CLI, siga estas instruções para instalar a CLI e executar o comando firebase init. Durante o comando init, certifique-se de que seleciona Firestore: Deploy rules and create indexes for Firestore.
  2. Durante a configuração, a Firebase CLI transfere as definições de índice existentes para um ficheiro denominado, por predefinição, firestore.indexes.json.
  3. Remova todas as definições de índice que contenham o campo 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. Implemente as definições de índice atualizadas:

    firebase deploy --only firestore:indexes
    

Atualize as definições de índice de campo único

Consola do Firebase

  1. Abra a página Índices de campo único do Firestore na consola do Firebase.

    Aceda a Índices de campo único

  2. Clique em Adicionar exceção.

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

  4. Em Âmbito da consulta, selecione Coleção e Grupo de coleções.

  5. Clique em Seguinte

  6. Alterne todas as definições de índice para Desativado. Clique em Guardar.

  7. Repita os mesmos passos para o campo shard.

GCP Console

  1. Na Google Cloud consola, aceda à página Bases de dados.

    Aceda a Bases de dados

  2. Selecione a base de dados necessária na lista de bases de dados.

  3. No menu de navegação, clique em Índices e, de seguida, clique no separador Campo único.

  4. Clique no separador Campo único.

  5. Clique em Adicionar exceção.

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

  7. Em Âmbito da consulta, selecione Coleção e Grupo de coleções.

  8. Clique em Seguinte

  9. Alterne todas as definições de índice para Desativado. Clique em Guardar.

  10. Repita os mesmos passos para o campo shard.

Firebase CLI

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

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

    firebase deploy --only firestore:indexes
    

Crie novos índices compostos

Depois de remover todos os índices anteriores que contenham timestamp, defina os novos índices de que a sua app precisa. Qualquer índice que contenha o campo timestamp também tem de conter o campo shard. Por exemplo, para suportar as consultas acima, adicione os seguintes índices:

Coleção Campos indexados Âmbito da consulta
instrumentos shard, price.currency, timestamp Coleção
instrumentos fragmento, troca, indicação de tempo Coleção
instrumentos shard, instrumentType, timestamp Coleção

Mensagens de erro

Pode criar estes índices executando as consultas atualizadas.

Cada consulta devolve uma mensagem de erro com um link para criar o índice necessário na consola do Firebase.

Firebase CLI

  1. Adicione os seguintes índices ao ficheiro de definição do í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. Implemente as definições de índice atualizadas:

    firebase deploy --only firestore:indexes
    

Compreender o limite de gravação para campos indexados sequenciais

O limite da taxa de gravação para campos indexados sequenciais resulta da forma como o Firestore armazena os valores de índice e dimensiona as gravações de índice. Para cada gravação de índice, o Firestore define uma entrada de chave-valor que concatena o nome do documento e o valor de cada campo indexado. O Firestore organiza estas entradas de índice em grupos de dados denominados tablets. Cada servidor do Firestore contém um ou mais tablets. Quando a carga de escrita para um tablet específico se torna demasiado elevada, o Firestore é dimensionado horizontalmente dividindo o tablet em tablets mais pequenos e distribuindo os novos tablets por diferentes servidores do Firestore.

O Firestore coloca entradas de índice lexicograficamente próximas no mesmo tablet. Se os valores de índice numa tabela forem demasiado próximos, como para campos de data/hora, o Firestore não consegue dividir eficientemente a tabela em tabelas mais pequenas. Isto cria um ponto crítico onde um único tablet recebe demasiado tráfego e as operações de leitura e escrita no ponto crítico tornam-se mais lentas.

Ao dividir um campo de data/hora, torna possível que o Firestore divida eficientemente as cargas de trabalho em vários tablets. Embora os valores do campo de data/hora possam permanecer próximos, o valor de fragmento e índice concatenado dá ao Firestore espaço suficiente entre as entradas de índice para dividir as entradas entre vários tablets.

O que se segue?