Timestamp sbozzati

Se una raccolta contiene documenti con valori indicizzati sequenziali, Cloud Firestore limita la frequenza di scrittura a 500 scritture al secondo. Questa pagina descrive come partizionare un campo documento per superare questo limite. Innanzitutto, definiamo cosa intendiamo per "campi indicizzati sequenziali" e chiariamo quando si applica questo limite.

Campi indicizzati sequenziali

Per "campi indicizzati sequenziali" si intende qualsiasi raccolta di documenti che contiene un campo indicizzato che aumenta o diminuisce monotonicamente. In molti casi, si tratta di un campo timestamp, ma qualsiasi valore di campo che aumenta o diminuisce monotonicamente può attivare il limite di scrittura di 500 scritture al secondo.

Ad esempio, il limite si applica a una raccolta di user documenti con campo indicizzato userid se l'app assegna i valori userid come segue:

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

D'altra parte, non tutti i campi timestamp attivano questo limite. Se un campo timestamp tiene traccia dei valori distribuiti in modo casuale, il limite di scrittura non si applica. Anche il valore effettivo del campo non ha importanza, ma solo che il campo aumenti o diminuisca monotonicamente. Ad esempio, entrambi i seguenti insiemi di valori di campo che aumentano monotonicamente attivano il limite di scrittura:

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

Partizionare un campo timestamp

Supponiamo che la tua app utilizzi un campo timestamp che aumenta monotonicamente. Se l'app non utilizza il campo timestamp in nessuna query, puoi rimuovere il limite di 500 scritture al secondo non indicizzando il campo timestamp. Se hai bisogno di un campo timestamp per le query, puoi aggirare il limite utilizzando timestamp partizionati:

  1. Aggiungi un campo shard accanto al campo timestamp. Utilizza valori distinti 1..n per il campo shard. In questo modo, il limite di scrittura per la raccolta aumenta a 500*n, ma devi aggregare n query.
  2. Aggiorna la logica di scrittura per assegnare in modo casuale un valore shard a ogni documento.
  3. Aggiorna le query per aggregare i set di risultati partizionati.
  4. Disattiva gli indici a campo singolo per il campo shard e il campo timestamp. Elimina gli indici composti esistenti che contengono il campo timestamp.
  5. Crea nuovi indici composti per supportare le query aggiornate. L'ordine dei campi in un indice è importante e il campo shard deve precedere il campo timestamp. Tutti gli indici che includono il campo timestamp devono includere anche il campo shard.

Devi implementare i timestamp partizionati solo nei casi d'uso con frequenze di scrittura sostenute superiori a 500 scritture al secondo. In caso contrario, si tratta di un'ottimizzazione prematura. La partizione di un campo timestamp rimuove la limitazione di 500 scritture al secondo, ma con il compromesso di dover eseguire aggregazioni di query lato client.

Gli esempi seguenti mostrano come partizionare un campo timestamp e come eseguire query su un set di risultati partizionati.

Modello di dati ed esempi di query

Ad esempio, immagina un'app per l'analisi quasi in tempo reale di strumenti finanziari come valute, azioni comuni ed ETF. Questa app scrive documenti in una raccolta instruments come segue:

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

Questa app esegue le seguenti query e ordina in base al 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]);
    });

Dopo alcune ricerche, hai stabilito che l'app riceverà tra 1000 e 1500 aggiornamenti di strumenti al secondo. Questo supera le 500 scritture al secondo consentite per le raccolte contenenti documenti con campi timestamp indicizzati. Per aumentare il throughput di scrittura, hai bisogno di 3 valori di partizione, MAX_INSTRUMENT_UPDATES/500 = 3. Questo esempio utilizza i valori di partizione x, y e z. Puoi anche utilizzare numeri o altri caratteri per i valori di partizione.

Aggiungere un campo di partizione

Aggiungi un campo shard ai documenti. Imposta il campo shard sui valori x, y o z, che aumentano il limite di scrittura nella raccolta a 1500 scritture al secondo.

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

Eseguire query sul timestamp partizionato

L'aggiunta di un campo shard richiede l'aggiornamento delle query per aggregare i risultati partizionati:

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

Aggiornare le definizioni degli indici

Per rimuovere il vincolo di 500 scritture al secondo, elimina gli indici a campo singolo e composti esistenti che utilizzano il campo timestamp.

Eliminare le definizioni degli indici composti

Console Firebase

  1. Apri la pagina Cloud Firestore Indici composti nella console Firebase.

    Vai a Indici composti

  2. Per ogni indice che contiene il campo timestamp, fai clic sul pulsante e poi su Elimina.

Console di GCP

  1. Nella console Google Cloud, vai alla pagina Database.

    Vai a Database

  2. Seleziona il database richiesto dall'elenco dei database.

  3. Nel menu di navigazione, fai clic su Indici, quindi sulla scheda Composto.

  4. Utilizza il campo Filtra per cercare le definizioni degli indici che contengono il campo timestamp.

  5. Per ciascuno di questi indici, fai clic sul pulsante e poi su Elimina.

Interfaccia a riga di comando di Firebase

  1. Se non hai configurato l'interfaccia a riga di comando di Firebase, segui queste istruzioni per installarla ed eseguire il comando firebase init command. Durante il comando init, assicurati di selezionare Firestore: Deploy rules and create indexes for Firestore.
  2. Durante la configurazione, l'interfaccia a riga di comando di Firebase scarica le definizioni degli indici esistenti in un file denominato, per impostazione predefinita, firestore.indexes.json.
  3. Rimuovi tutte le definizioni degli indici che contengono il campo timestamp, ad esempio:

    {
    "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. Esegui il deployment delle definizioni degli indici aggiornate:

    firebase deploy --only firestore:indexes
    

Aggiornare le definizioni degli indici a campo singolo

Console Firebase

  1. Apri la pagina Cloud FirestoreIndici a campo singolo nella console Firebase.

    Vai a Indici a campo singolo

  2. Fai clic su Aggiungi esenzione.

  3. In ID raccolta, inserisci instruments. In Percorso campo, inserisci timestamp.

  4. In Ambito query, seleziona sia Raccolta che Gruppo di raccolte.

  5. Fai clic su Avanti.

  6. Imposta tutte le impostazioni degli indici su Disattivato. Fai clic su Salva.

  7. Ripeti gli stessi passaggi per il campo shard.

Console di GCP

  1. Nella console Google Cloud, vai alla pagina Database.

    Vai a Database

  2. Seleziona il database richiesto dall'elenco dei database.

  3. Nel menu di navigazione, fai clic su Indici, quindi sulla scheda Campo singolo.

  4. Fai clic sulla scheda Campo singolo.

  5. Fai clic su Aggiungi esenzione.

  6. In ID raccolta, inserisci instruments. In Percorso campo, inserisci timestamp.

  7. In Ambito query, seleziona sia Raccolta che Gruppo di raccolte.

  8. Fai clic su Avanti.

  9. Imposta tutte le impostazioni degli indici su Disattivato. Fai clic su Salva.

  10. Ripeti gli stessi passaggi per il campo shard.

Interfaccia a riga di comando di Firebase

  1. Aggiungi quanto segue alla sezione fieldOverrides del file di definizioni degli indici:

    {
     "fieldOverrides": [
       // Disable single-field indexing for the timestamp field
       {
         "collectionGroup": "instruments",
         "fieldPath": "timestamp",
         "indexes": []
       },
     ]
    }
    
  2. Esegui il deployment delle definizioni degli indici aggiornate:

    firebase deploy --only firestore:indexes
    

Creare nuovi indici composti

Dopo aver rimosso tutti gli indici precedenti contenenti il timestamp, definisci i nuovi indici richiesti dall'app. Qualsiasi indice contenente il campo timestamp deve contenere anche il campo shard. Ad esempio, per supportare le query precedenti, aggiungi i seguenti indici:

Raccolta Campi indicizzati Ambito di query
strumentazioni partizione, price.currency, timestamp Raccolta
strumentazioni partizione, exchange, timestamp Raccolta
strumentazioni partizione, instrumentType, timestamp Raccolta

Messaggi di errore

Puoi creare questi indici eseguendo le query aggiornate.

Ogni query restituisce un messaggio di errore con un link per creare l'indice richiesto nella console Firebase.

Interfaccia a riga di comando di Firebase

  1. Aggiungi i seguenti indici al file di definizioni degli indici:

     {
       "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. Esegui il deployment delle definizioni degli indici aggiornate:

    firebase deploy --only firestore:indexes
    

Comprendere il limite di scrittura per i campi indicizzati sequenziali

Il limite della frequenza di scrittura per i campi indicizzati sequenziali deriva dal modo in cui Cloud Firestore archivia i valori degli indici e ridimensiona le scritture degli indici. Per ogni scrittura di indice, Cloud Firestore definisce una coppia chiave-valore che concatena il nome del documento e il valore di ogni campo indicizzato. Cloud Firestore organizza queste voci di indice in gruppi di dati denominati tablet. Ogni Cloud Firestore server contiene uno o più tablet. Quando il carico di scrittura su un determinato tablet diventa troppo elevato, Cloud Firestore esegue lo scale up orizzontale dividendo il tablet in tablet più piccoli e distribuendo i nuovi tablet su diversi server Cloud Firestore.

Cloud Firestore inserisce le voci di indice lessicograficamente vicine nello stesso tablet. Se i valori degli indici in un tablet sono troppo vicini, ad esempio per i campi timestamp, Cloud Firestore non può dividere in modo efficiente il tablet in tablet più piccoli. In questo modo si crea un hotspot in cui un singolo tablet riceve troppo traffico e le operazioni di lettura e scrittura sull'hotspot diventano più lente.

Partizionando un campo timestamp, consenti a Cloud Firestore di dividere in modo efficiente i carichi di lavoro su più tablet. Anche se i valori del campo timestamp potrebbero rimanere vicini, il valore di partizione e indice concatenato offre a Cloud Firestore uno spazio sufficiente tra le voci di indice per dividerle tra più tablet.

Passaggi successivi