Marcas de tiempo fragmentadas

Si una colección contiene documentos con valores indexados secuenciales, Firestore limita la velocidad de escritura a 500 escrituras por segundo. En esta página se describe cómo fragmentar un campo de documento para superar este límite. En primer lugar, vamos a definir qué entendemos por "campos indexados secuenciales" y a aclarar cuándo se aplica este límite.

Campos indexados secuenciales

"Campos indexados secuenciales" hace referencia a cualquier colección de documentos que contenga un campo indexado que aumente o disminuya de forma monótona. 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 escritura de 500 escrituras por segundo.

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

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

Por otro lado, no todos los campos timestamp activan este límite. Si un campo timestamp registra valores distribuidos aleatoriamente, no se aplica el límite de escritura. El valor real del campo tampoco importa, solo que el campo aumente o disminuya de forma monótona. Por ejemplo, los dos conjuntos de valores de campo que aumentan de forma monótona siguientes activan el límite de escritura:

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

Fragmentar un campo de marca de tiempo

Supongamos que tu aplicación usa un campo timestamp que aumenta de forma monótona. Si tu aplicación no usa el campo timestamp en ninguna consulta, puedes eliminar el límite de 500 escrituras por segundo si no indexas el campo de marca de tiempo. Si necesitas un campo timestamp para tus consultas, puedes evitar el límite usando marcas de tiempo fragmentadas:

  1. Añade un campo shard junto al campo timestamp. Usa 1..n valores distintos para el campo shard. De esta forma, el límite de escritura de la colección aumenta a 500*n, pero debes agregar n consultas.
  2. Actualiza tu lógica de escritura para asignar aleatoriamente un valor shard a cada documento.
  3. Actualiza tus consultas para agregar los conjuntos de resultados fragmentados.
  4. Inhabilita los índices de campo único para los campos shard y timestamp. Elimina los índices compuestos que contengan el campo timestamp.
  5. Crea índices compuestos para admitir las consultas actualizadas. El orden de los campos de un índice es importante y el campo shard debe ir antes del campo timestamp. Los índices que incluyan el campo timestamp también deben incluir el campo shard.

Solo debes implementar marcas de tiempo fragmentadas en casos prácticos con tasas de escritura sostenidas superiores a 500 escrituras por segundo. De lo contrario, se trata de una optimización prematura. Al fragmentar un campo timestamp, se elimina la restricción de 500 escrituras por segundo, pero se necesita una agregación de consultas del lado del cliente.

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

Modelo de datos y consultas de ejemplo

Por ejemplo, imagina una aplicación para analizar en tiempo casi real instrumentos financieros, como divisas, acciones comunes y ETFs. Esta aplicación escribe documentos en una colección 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 aplicación ejecuta las siguientes consultas y las ordena 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]);
    });

Tras investigar un poco, determinas que la aplicación recibirá entre 1000 y 1500 actualizaciones de instrumentos por segundo. De esta forma, se supera el límite de 500 escrituras por segundo permitido en las colecciones que contienen documentos con campos de marca de tiempo indexados. Para aumentar el rendimiento de escritura, necesitas 3 valores de fragmento, MAX_INSTRUMENT_UPDATES/500 = 3. En este ejemplo se usan los valores de fragmento x, y y z. También puedes usar números u otros caracteres para los valores de partición.

Añadir un campo de partición

Añade un campo shard a tus documentos. Asigna al campo shard los valores x, y o z, lo que aumenta el límite de escritura de la colección a 1500 escrituras 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 la marca de tiempo fragmentada

Para añadir un campo shard, debe actualizar sus consultas para agregar 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]);
    });

Actualizar definiciones de índice

Para eliminar la restricción de 500 escrituras por segundo, elimina los índices de campo único y compuestos que usen el campo timestamp.

Eliminar definiciones de índices compuestos

Consola de Firebase

  1. Abre la página Índices compuestos de Firestore en la consola de Firebase.

    Ir a Índices compuestos

  2. En cada índice que contenga el campo timestamp, haz clic en el botón y, a continuación, en Eliminar.

Consola de GCP

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

    Ir a Bases de datos

  2. Seleccione la base de datos que necesite de la lista de bases de datos.

  3. En el menú de navegación, haga clic en Índices y, a continuación, en la pestaña Compuesto.

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

  5. En cada uno de estos índices, haz clic en el botón y, a continuación, en Eliminar.

CLI de Firebase

  1. Si no has configurado la CLI de Firebase, sigue estas instrucciones para instalarla y ejecutar el comando firebase init. Durante el comando init, asegúrate de seleccionar Firestore: Deploy rules and create indexes for Firestore.
  2. Durante la configuración, la CLI de Firebase descarga las definiciones de índice que ya tengas en un archivo llamado firestore.indexes.json de forma predeterminada.
  3. Elimina las definiciones de índice 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 las definiciones de índice actualizadas:

    firebase deploy --only firestore:indexes
    

Actualizar definiciones de índices de campo único

Consola de Firebase

  1. Abre la página Índices de un solo campo de Firestore en la consola de Firebase.

    Ir a índices de un solo campo

  2. Haz clic en Añadir exención.

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

  4. En Ámbito de la consulta, seleccione Colección y Grupo de colecciones.

  5. Haz clic en Siguiente.

  6. Activa todos los ajustes de índice en Inhabilitado. Haz clic en Guardar.

  7. Repite los mismos pasos con el campo shard.

Consola de GCP

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

    Ir a Bases de datos

  2. Seleccione la base de datos que necesite de la lista de bases de datos.

  3. En el menú de navegación, haga clic en Índices y, a continuación, en la pestaña Campo único.

  4. Haz clic en la pestaña Campo único.

  5. Haz clic en Añadir exención.

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

  7. En Ámbito de la consulta, seleccione Colección y Grupo de colecciones.

  8. Haz clic en Siguiente.

  9. Activa todos los ajustes de índice en Inhabilitado. Haz clic en Guardar.

  10. Repite los mismos pasos con el campo shard.

CLI de Firebase

  1. Añade lo siguiente 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 las definiciones de índice actualizadas:

    firebase deploy --only firestore:indexes
    

Crear índices compuestos

Después de quitar todos los índices anteriores que contengan timestamp, define los nuevos índices que necesite tu aplicación. Cualquier índice que contenga el campo timestamp también debe contener el campo shard. Por ejemplo, para admitir las consultas anteriores, añade los siguientes índices:

Colección Campos indexados Ámbito de consulta
instrumentos shard, price.currency, timestamp Colección
instrumentos Fragmento , intercambio , marca de tiempo Colección
instrumentos shard, instrumentType, timestamp Colección

Mensajes de error

Puedes crear estos índices ejecutando las consultas actualizadas.

Cada consulta devuelve un mensaje de error con un enlace para crear el índice necesario en la consola de Firebase.

CLI de Firebase

  1. Añade los siguientes índices al archivo de definición 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. Implementa las definiciones de índice actualizadas:

    firebase deploy --only firestore:indexes
    

Información sobre el límite de escritura de los campos indexados secuenciales

El límite de la velocidad de escritura de los campos indexados secuenciales se debe a la forma en que Firestore almacena los valores de los índices y escala las escrituras de los índices. Por cada escritura de índice, Firestore define una entrada de clave-valor que concatena el nombre del documento y el valor de cada campo indexado. Firestore organiza estas entradas de índice en grupos de datos denominados tablets. Cada servidor de Firestore contiene una o varias tablets. Cuando la carga de escritura en una tablet concreta es demasiado alta, Firestore se escala horizontalmente dividiendo la tablet en tablets más pequeñas y distribuyendo las nuevas tablets en diferentes servidores de Firestore.

Firestore coloca las entradas de índice lexicográficamente cercanas en la misma tablet. Si los valores de índice de una tablet están demasiado cerca entre sí, como en el caso de los campos de marca de tiempo, Firestore no puede dividir la tablet de forma eficiente en tablets más pequeñas. Esto crea un punto de acceso en el que una sola tablet recibe demasiado tráfico y las operaciones de lectura y escritura en el punto de acceso se vuelven más lentas.

Al fragmentar un campo de marca de tiempo, Firestore puede dividir las cargas de trabajo de forma eficiente en varias tablets. Aunque los valores del campo de marca de tiempo pueden estar muy próximos entre sí, el valor concatenado del fragmento y del índice proporciona a Firestore suficiente espacio entre las entradas del índice para dividir las entradas entre varias tablets.

Siguientes pasos