Marcas de tiempo fragmentadas

Si una colección contiene documentos con valores indexados secuenciales, Firestore limita la tasa de escritura a 500 operaciones por segundo. En esta página, se describe cómo fragmentar un campo de documento para superar este límite. Primero, definamos qué significa “campos indexados secuenciales” y aclaremos cuándo se aplica este límite.

Campos indexados secuenciales

“Campos indexados secuenciales” se refiere a cualquier colección de documentos que contiene un campo indexado que aumenta o disminuye monótonamente. En muchos casos, se trata de un campo timestamp, pero cualquier valor de campo que aumente o disminuya de forma monótona puede activar el límite de 500 operaciones de escritura por segundo.

Por ejemplo, el límite se aplica a una colección de documentos de user con un campo userid indexado si la app asigna valores userid de la siguiente manera:

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

Sin embargo, no todos los campos timestamp activan este límite. Si un campo timestamp rastrea valores distribuidos de forma aleatoria, no se aplica el límite de escritura. El valor real del campo tampoco importa, solo importa que el campo aumente o disminuya monótonamente. Por ejemplo, los siguientes conjuntos de valores de campo con aumento monótono activan el límite de escritura:

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

Fragmenta un campo de marca de tiempo

Supongamos que tu app utiliza un campo timestamp que aumenta de forma monótona. Si en tu app no se utiliza el campo timestamp en ninguna consulta, puedes quitar el límite de 500 operaciones de escritura por segundo. Para ello, no indexes el campo de marca de tiempo. Sin embargo, si necesitas un campo timestamp para tus consultas, puedes utilizar marcas de tiempo fragmentadas a fin de evitar el límite de la siguiente manera:

  1. Agrega un campo shard junto al campo timestamp. Utiliza valores 1..n distintos en el campo shard. Esto aumenta el límite de escritura de la colección a 500*n, pero debes agregar n consultas.
  2. Actualiza la lógica de escritura para asignar aleatoriamente un valor shard a cada documento.
  3. Actualiza tus consultas para que agreguen los conjuntos de resultados fragmentados.
  4. Inhabilita los índices de campo único para los campos shard y timestamp. Borra los índices compuestos existentes que contienen el campo timestamp.
  5. Crea índices compuestos nuevos que respalden tus consultas actualizadas. El orden de los campos de un índice es importante, por lo que el campo shard debe anteceder al campo timestamp. Si un índice incluye el campo timestamp, también debe incluir el campo shard.

Te recomendamos que solo implementes marcas de tiempo fragmentadas en los casos de uso que tengan tasas de escritura sostenidas superiores a 500 operaciones por segundo. De lo contrario, se tratará de una optimización prematura. Cuando se fragmenta un campo timestamp se quita la restricción de 500 operaciones de escritura por segundo, pero, en su lugar, se necesitan agregaciones de consultas en el cliente.

En los siguientes ejemplos se muestra cómo fragmentar un campo timestamp y hacer consultas a un conjunto fragmentado de resultados.

Ejemplo de modelo de datos y consultas

Como ejemplo, supongamos que tienes una app para el análisis casi en tiempo real de instrumentos financieros como monedas, acciones comunes y fondos cotizados (ETF). Esta app escribe documentos en una colección de instruments de la siguiente manera:

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 ejecuta las siguientes consultas y ordena los datos por el 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]);
    });

Después de investigar un poco, determinas que la app recibirá entre 1,000 y 1,500 actualizaciones de instrumentos por segundo. Esto supera las 500 operaciones de escritura por segundo permitidas para colecciones que contienen documentos con campos de marca de tiempo indexados. Para aumentar la capacidad de procesamiento de escritura, necesitas 3 valores de fragmento (es decir, MAX_INSTRUMENT_UPDATES/500 = 3). En este ejemplo, se utilizan los valores de fragmento x, y y z. También puedes usar números y otros caracteres para tus valores de fragmento.

Agrega un campo de fragmento

Agrega un campo shard a tus documentos. Establece los valores x, y o z para el campo shard, esto eleva el límite de escritura de la colección a 1,500 operaciones 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();
}

Consulta la marca de tiempo fragmentada

Para agregar un campo shard, debes actualizar tus consultas a fin de que agreguen los 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]);
    });

Actualiza las definiciones de índices

Para quitar el límite de 500 operaciones de escritura por segundo, borra los índices existentes de campo único y compuestos que usan el campo timestamp.

Borra las definiciones de índice compuesto

Firebase console

  1. En Firebase console, abre la página Índices compuestos de Firestore.

    Ir a Índices compuestos

  2. Para cada índice con el campo timestamp, haz clic en el botón  y, luego, en Borrar.

Consola de GCP

  1. En la consola de Google Cloud, ve a la página Bases de datos.

    Ir a Bases de datos

  2. Selecciona la base de datos requerida de la lista.

  3. En el menú de navegación, haz clic en Índices y, luego, en la pestaña Compuestos.

  4. Usa el campo Filtrar para buscar definiciones de índice que contengan el campo timestamp.

  5. Para cada uno de estos índices, haz clic en el botón  y, luego, en Borrar.

Firebase CLI

  1. Si aún no configuraste Firebase CLI, sigue estas instrucciones para instalarlo y ejecutar el comando firebase init. Durante la ejecución del comando init, asegúrate de seleccionar Firestore: Deploy rules and create indexes for Firestore.
  2. Durante la configuración, Firebase CLI descarga tus definiciones de índice existentes a un archivo llamado firestore.indexes.json de forma predeterminada.
  3. Quita las definiciones de índices que contengan el campo timestamp, por ejemplo:

    {
    "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. Implementa tus definiciones de índice actualizadas con este comando:

    firebase deploy --only firestore:indexes
    

Actualiza las definiciones de índice de campo único

Firebase console

  1. En Firebase console, abre la página Índices de campo único de Firestore.

    Ir a Índices de campo único

  2. Haz clic en Agregar exención.

  3. En ID de la colección, ingresa instruments. En Ruta del campo , ingresa timestamp.

  4. En Alcance de la consulta, selecciona Colección y Grupo de colecciones.

  5. Haz clic en Siguiente.

  6. Cambia la configuración de todos los índices a Inhabilitado. Haz clic en Guardar.

  7. Repite los mismos pasos para el campo shard.

Consola de GCP

  1. En la consola de Google Cloud, ve a la página Bases de datos.

    Ir a Bases de datos

  2. Selecciona la base de datos requerida de la lista.

  3. En el menú de navegación, haz clic en Índices y, luego, en la pestaña De campo único.

  4. Haz clic en la pestaña De campo único.

  5. Haz clic en Agregar exención.

  6. En ID de la colección, ingresa instruments. En Ruta del campo , ingresa timestamp.

  7. En Alcance de la consulta, selecciona Colección y Grupo de colecciones.

  8. Haz clic en Siguiente.

  9. Cambia la configuración de todos los índices a Inhabilitado. Haz clic en Guardar.

  10. Repite los mismos pasos para el campo shard.

Firebase CLI

  1. Agrega el siguiente código a la sección fieldOverrides de tu archivo de definiciones de índice:

    {
     "fieldOverrides": [
       // Disable single-field indexing for the timestamp field
       {
         "collectionGroup": "instruments",
         "fieldPath": "timestamp",
         "indexes": []
       },
     ]
    }
    
  2. Implementa tus definiciones de índice actualizadas con este comando:

    firebase deploy --only firestore:indexes
    

Crea índices compuestos nuevos

Después de quitar todos los índices anteriores que contienen timestamp, define los índices nuevos que requiere tu app. Si un índice incluye el campo timestamp, también debe incluir el campo shard. Por ejemplo, agrega los siguientes índices para admitir las consultas anteriores:

Colección Campos indexados Alcance de la consulta
instruments shard, price.currency, timestamp Colección
instruments shard, exchange, timestamp Colección
instruments shard, instrumentType, timestamp Colección

Mensajes de error

Para crear estos índices, ejecuta las consultas actualizadas.

Cada consulta muestra un mensaje de error con un vínculo para crear el índice requerido en Firebase console.

Firebase CLI

  1. Agrega los siguientes índices a tu archivo de definición de índices:

     {
       "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. Implementa tus definiciones de índice actualizadas con este comando:

    firebase deploy --only firestore:indexes
    

Comprende el límite de escritura de los campos indexados secuenciales

El límite de la tasa de escritura de los campos indexados secuenciales proviene del método que usa Firestore para almacenar los valores de índices y escalar las escrituras de índices. En cada operación de escritura de índice, Firestore define una entrada clave-valor que concatena el nombre del documento con el valor de cada campo indexado. Firestore organiza estas entradas de índice en grupos de datos llamados tablets. Cada servidor de Firestore almacena una o más tablets. Cuando la carga de escritura en una tablet en particular es demasiado alta, Firestore ajusta la escala de forma horizontal. Para ello, divide la tablet en unidades más pequeñas y las distribuye en diferentes servidores de Firestore.

Firestore coloca las entradas de índice con similitud lexicográfica en la misma tablet. Si los valores de índice de una tablet son demasiado cercanos, como en el caso de los campos de marca de tiempo, Firestore no puede dividir la tablet de manera eficiente. Esto crea hotspots en los que una sola tablet recibe demasiado tráfico y las operaciones de lectura y escritura se vuelven más lentas.

Si fragmentas un campo de marca de tiempo, permites que Firestore divida de forma eficiente las cargas de trabajo en varias tablets. Aunque los valores del campo de marca de tiempo pueden permanecer juntos, el fragmento concatenado y el valor de índice le dan a Firestore suficiente espacio entre las entradas de índice para dividirlas en varias tablets.

¿Qué sigue?