Marcas de tiempo fragmentadas

Si una colección contiene documentos con valores indexados secuenciales, Cloud Firestore limita la velocidad de escritura a 500 escrituras por segundo. Esta página describe cómo fragmentar un campo de documento para superar este límite. Primero, definamos qué queremos decir con "campos indexados secuenciales" y aclaremos cuándo se aplica este límite.

Campos indexados secuenciales

"Campos indexados secuenciales" significa cualquier colección de documentos que contiene un campo indexado que aumenta o disminuye monótonamente. En muchos casos, esto significa un campo timestamp , pero cualquier valor de campo que aumente o disminuya monótonamente puede activar el límite de escritura de 500 escrituras por segundo.

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

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

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

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

Fragmentar un campo de marca de tiempo

Supongamos que su aplicación utiliza un campo timestamp que aumenta monótonamente. Si su aplicación no utiliza el campo timestamp en ninguna consulta, puede eliminar el límite de 500 escrituras por segundo al no indexar el campo de marca de tiempo. Si necesita un campo timestamp para sus consultas, puede evitar el límite utilizando marcas de tiempo fragmentadas :

  1. Agregue un campo shard junto al campo timestamp . Utilice 1..n valores distintos para el campo shard . Esto aumenta el límite de escritura para la colección a 500*n , pero debe agregar n consultas.
  2. Actualice su lógica de escritura para asignar aleatoriamente un valor shard a cada documento.
  3. Actualice sus consultas para agregar los conjuntos de resultados fragmentados.
  4. Deshabilite los índices de campo único tanto para el campo shard como para el campo timestamp . Elimine los índices compuestos existentes que contengan el campo timestamp .
  5. Cree nuevos índices compuestos para respaldar sus consultas actualizadas. El orden de los campos en un índice es importante y el campo shard debe aparecer antes del campo timestamp . Cualquier índice que incluya el campo timestamp también debe incluir el campo shard .

Debe implementar marcas de tiempo fragmentadas solo en casos de uso con velocidades de escritura sostenidas superiores a 500 escrituras por segundo. De lo contrario, se trata de una optimización prematura. La fragmentación de un campo timestamp elimina la restricción de 500 escrituras por segundo, pero con la desventaja de necesitar agregaciones de consultas del lado del cliente.

Los siguientes ejemplos muestran cómo fragmentar un campo timestamp y cómo consultar un conjunto de resultados fragmentado.

Modelo de datos de ejemplo y consultas.

Como ejemplo, imagine una aplicación para el análisis casi en tiempo real de instrumentos financieros como divisas, acciones ordinarias y ETF. Esta aplicación escribe documentos en una colección instruments de esta manera:

Nodo.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 pedidos según el campo de timestamp :

Nodo.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, determina que la aplicación recibirá entre 1000 y 1500 actualizaciones de instrumentos por segundo. Esto supera las 500 escrituras por segundo permitidas para colecciones que contienen documentos con campos de marca de tiempo indexados. Para aumentar el rendimiento de escritura, necesita 3 valores de fragmento, MAX_INSTRUMENT_UPDATES/500 = 3 . Este ejemplo utiliza los valores de fragmento x , y y z . También puedes usar números u otros caracteres para los valores de tus fragmentos.

Agregar un campo de fragmento

Agregue un campo shard a sus documentos. Establezca el campo shard en los valores x , y o z , lo que aumenta el límite de escritura en la colección a 1500 escrituras por segundo.

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

Consultando la marca de tiempo fragmentada

Agregar un campo shard requiere que actualice sus consultas para agregar resultados fragmentados:

Nodo.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, elimine los índices compuestos y de campo único existentes que utilizan el campo timestamp .

Eliminar definiciones de índice compuesto

Consola de base de fuego

  1. Abra la página Índices compuestos de Cloud Firestore en Firebase console.

    Ir a índices compuestos

  2. Para cada índice que contenga el campo timestamp , haga clic en el botón y haga clic en Eliminar .

Consola GCP

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

    Ir a Bases de Datos

  2. Seleccione la base de datos requerida de la lista de bases de datos.

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

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

  5. Para cada uno de estos índices, haga clic en el botón y haga clic en Eliminar .

CLI de base de fuego

  1. Si no ha configurado Firebase CLI, siga estas instrucciones para instalar la CLI y ejecutar el comando firebase init . Durante el comando init , asegúrese de seleccionar Firestore: Deploy rules and create indexes for Firestore .
  2. Durante la instalación, Firebase CLI descarga las definiciones de índice existentes en un archivo denominado, de forma predeterminada, firestore.indexes.json .
  3. Elimine cualquier definición de índice que contenga 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. Implemente sus definiciones de índice actualizadas:

    firebase deploy --only firestore:indexes
    

Actualizar definiciones de índice de campo único

Consola de base de fuego

  1. Abra la página Índices de campo único de Cloud Firestore en Firebase console.

    Ir a índices de campo único

  2. Haga clic en Agregar exención .

  3. Para ID de colección , ingrese instruments . En Ruta de campo , introduzca timestamp .

  4. En Alcance de la consulta , seleccione Colección y Grupo de colección .

  5. Haga clic en Siguiente

  6. Cambie todas las configuraciones del índice a Desactivado . Clic en Guardar .

  7. Repita los mismos pasos para el campo shard .

Consola GCP

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

    Ir a Bases de Datos

  2. Seleccione la base de datos requerida de la lista de bases de datos.

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

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

  5. Haga clic en Agregar exención .

  6. Para ID de colección , ingrese instruments . En Ruta de campo , introduzca timestamp .

  7. En Alcance de la consulta , seleccione Colección y Grupo de colección .

  8. Haga clic en Siguiente

  9. Cambie todas las configuraciones del índice a Desactivado . Clic en Guardar .

  10. Repita los mismos pasos para el campo shard .

CLI de base de fuego

  1. Agregue lo siguiente a la sección fieldOverrides de su archivo de definiciones de índice:

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

    firebase deploy --only firestore:indexes
    

Crear nuevos índices compuestos

Después de eliminar todos los índices anteriores que contienen la timestamp , defina los nuevos índices que requiere su aplicación. Cualquier índice que contenga el campo timestamp también debe contener el campo shard . Por ejemplo, para admitir las consultas anteriores, agregue los siguientes índices:

Recopilación Campos indexados Alcance de la consulta
instrumentos fragmento , precio.moneda , marca de tiempo Recopilación
instrumentos fragmento , intercambio , marca de tiempo Recopilación
instrumentos fragmento , tipo de instrumento , marca de tiempo Recopilación

Error de mensajes

Puede crear estos índices ejecutando las consultas actualizadas.

Cada consulta devuelve un mensaje de error con un enlace para crear el índice requerido en Firebase Console.

CLI de base de fuego

  1. Agregue los siguientes índices a su 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. Implemente sus definiciones de índice actualizadas:

    firebase deploy --only firestore:indexes
    

Comprender la escritura para limitar campos indexados secuenciales

El límite en la velocidad de escritura para campos indexados secuenciales proviene de cómo Cloud Firestore almacena los valores de índice y escala las escrituras de índice. Para cada escritura de índice, Cloud Firestore define una entrada clave-valor que concatena el nombre del documento y el valor de cada campo indexado. Cloud Firestore organiza estas entradas de índice en grupos de datos llamados tabletas . Cada servidor de Cloud Firestore tiene capacidad para una o más tabletas. Cuando la carga de escritura en una tableta en particular se vuelve demasiado alta, Cloud Firestore escala horizontalmente dividiendo la tableta en tabletas más pequeñas y distribuyendo las nuevas tabletas en diferentes servidores de Cloud Firestore.

Cloud Firestore coloca entradas de índice lexicográficamente cercanas en la misma tableta. Si los valores de índice en una tableta están demasiado juntos, como en el caso de los campos de marca de tiempo, Cloud Firestore no puede dividir la tableta de manera eficiente en tabletas más pequeñas. Esto crea un punto de acceso donde una sola tableta 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, hace posible que Cloud Firestore divida cargas de trabajo de manera eficiente en varias tabletas. Aunque los valores del campo de marca de tiempo pueden permanecer muy juntos, el fragmento concatenado y el valor del índice le dan a Cloud Firestore suficiente espacio entre las entradas del índice para dividir las entradas entre varias tabletas.

Que sigue