Gesplitterte Zeitstempel

Wenn eine Sammlung Dokumente mit sequentiell indizierten Werten enthält, begrenzt Cloud Firestore die Schreibrate auf 500 Schreibvorgänge pro Sekunde. Auf dieser Seite wird beschrieben, wie Sie ein Dokumentfeld teilen, um dieses Limit zu überwinden. Lassen Sie uns zunächst definieren, was wir unter „sequentiellen indizierten Feldern“ verstehen, und klären, wann diese Grenze gilt.

Sequentielle indizierte Felder

„Sequentiell indizierte Felder“ bezeichnet jede Sammlung von Dokumenten, die ein monoton steigendes oder fallendes indiziertes Feld enthält. In vielen Fällen handelt es sich dabei um ein timestamp , aber jeder monoton steigende oder fallende Feldwert kann das Schreiblimit von 500 Schreibvorgängen pro Sekunde auslösen.

Der Grenzwert gilt beispielsweise für eine Sammlung von user mit indizierten Feldbenutzer userid , wenn die App userid -Werte wie folgt zuweist:

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

Andererseits lösen nicht alle timestamp dieses Limit aus. Wenn ein timestamp zufällig verteilte Werte verfolgt, gilt die Schreibbeschränkung nicht. Auch der tatsächliche Wert des Feldes spielt keine Rolle, nur dass das Feld monoton ansteigt oder abnimmt. Beispielsweise lösen beide der folgenden Sätze monoton steigender Feldwerte das Schreiblimit aus:

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

Sharding eines Zeitstempelfeldes

Angenommen, Ihre App verwendet ein monoton steigendes timestamp . Wenn Ihre App das timestamp in keiner Abfrage verwendet, können Sie die Beschränkung auf 500 Schreibvorgänge pro Sekunde aufheben, indem Sie das Zeitstempelfeld nicht indizieren. Wenn Sie für Ihre Abfragen ein timestamp benötigen, können Sie das Limit umgehen, indem Sie Shard-Zeitstempel verwenden:

  1. Fügen Sie neben dem timestamp ein shard Feld hinzu. Verwenden Sie 1..n unterschiedliche Werte für das shard Feld. Dadurch wird das Schreiblimit für die Sammlung auf 500*n erhöht, Sie müssen jedoch n Abfragen aggregieren.
  2. Aktualisieren Sie Ihre Schreiblogik, um jedem Dokument zufällig einen shard Wert zuzuweisen.
  3. Aktualisieren Sie Ihre Abfragen, um die fragmentierten Ergebnismengen zu aggregieren.
  4. Deaktivieren Sie Einzelfeldindizes sowohl für das shard Feld als auch für das timestamp . Löschen Sie vorhandene zusammengesetzte Indizes, die das timestamp enthalten.
  5. Erstellen Sie neue zusammengesetzte Indizes, um Ihre aktualisierten Abfragen zu unterstützen. Die Reihenfolge der Felder in einem Index ist wichtig und das shard -Feld muss vor dem timestamp stehen. Alle Indizes, die das timestamp enthalten, müssen auch das shard Feld enthalten.

Sie sollten Shard-Zeitstempel nur in Anwendungsfällen mit anhaltenden Schreibraten über 500 Schreibvorgängen pro Sekunde implementieren. Ansonsten handelt es sich um eine vorzeitige Optimierung. Durch die Aufteilung eines timestamp wird die Beschränkung auf 500 Schreibvorgänge pro Sekunde aufgehoben, allerdings mit dem Nachteil, dass clientseitige Abfrageaggregationen erforderlich sind.

Die folgenden Beispiele zeigen, wie ein timestamp fragmentiert und ein fragmentierter Ergebnissatz abgefragt wird.

Beispieldatenmodell und Abfragen

Stellen Sie sich als Beispiel eine App für die Analyse von Finanzinstrumenten wie Währungen, Stammaktien und ETFs nahezu in Echtzeit vor. Diese App schreibt Dokumente wie folgt in eine instruments :

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

Diese App führt die folgenden Abfragen und Bestellungen anhand des timestamp aus:

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

Nach einiger Recherche stellen Sie fest, dass die App zwischen 1.000 und 1.500 Instrumentenaktualisierungen pro Sekunde erhält. Dies übersteigt die zulässigen 500 Schreibvorgänge pro Sekunde für Sammlungen, die Dokumente mit indizierten Zeitstempelfeldern enthalten. Um den Schreibdurchsatz zu erhöhen, benötigen Sie 3 Shard-Werte, MAX_INSTRUMENT_UPDATES/500 = 3 . In diesem Beispiel werden die Shard-Werte x , y und z verwendet. Sie können für Ihre Shard-Werte auch Zahlen oder andere Zeichen verwenden.

Hinzufügen eines Shard-Felds

Fügen Sie Ihren Dokumenten ein shard -Feld hinzu. Legen Sie das shard Feld auf die Werte x , y oder z fest, wodurch das Schreiblimit für die Sammlung auf 1.500 Schreibvorgänge pro Sekunde erhöht wird.

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

Abfrage des Shard-Zeitstempels

Das Hinzufügen eines shard Felds erfordert, dass Sie Ihre Abfragen aktualisieren, um Shard-Ergebnisse zu aggregieren:

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

Indexdefinitionen aktualisieren

Um die Einschränkung von 500 Schreibvorgängen pro Sekunde aufzuheben, löschen Sie die vorhandenen Einzelfeld- und zusammengesetzten Indizes, die das timestamp verwenden.

Zusammengesetzte Indexdefinitionen löschen

Firebase-Konsole

  1. Öffnen Sie die Seite „Cloud Firestore Composite Indexes“ in der Firebase-Konsole.

    Gehen Sie zu Zusammengesetzte Indizes

  2. Klicken Sie für jeden Index, der das timestamp enthält, auf die Schaltfläche und dann auf „Löschen“ .

GCP-Konsole

  1. Gehen Sie in der Google Cloud Platform Console zur Seite „Datenbanken“ .

    Gehen Sie zu Datenbanken

  2. Wählen Sie aus der Liste der Datenbanken die gewünschte Datenbank aus.

  3. Klicken Sie im Navigationsmenü auf Indizes und dann auf die Registerkarte Zusammengesetzt .

  4. Verwenden Sie das Feld „Filter“ , um nach Indexdefinitionen zu suchen, die das timestamp enthalten.

  5. Klicken Sie für jeden dieser Indizes auf die Schaltfläche und dann auf „Löschen“ .

Firebase-CLI

  1. Wenn Sie die Firebase-CLI nicht eingerichtet haben, befolgen Sie diese Anweisungen, um die CLI zu installieren und den firebase init Befehl auszuführen . Stellen Sie während des init Befehls sicher, dass Sie Firestore: Deploy rules and create indexes for Firestore auswählen.
  2. Während der Einrichtung lädt die Firebase-CLI Ihre vorhandenen Indexdefinitionen in eine Datei mit dem Standardnamen firestore.indexes.json herunter.
  3. Entfernen Sie alle Indexdefinitionen, die das timestamp enthalten, zum Beispiel:

    {
    "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. Stellen Sie Ihre aktualisierten Indexdefinitionen bereit:

    firebase deploy --only firestore:indexes
    

Aktualisieren Sie die Indexdefinitionen für einzelne Felder

Firebase-Konsole

  1. Öffnen Sie die Seite „Cloud Firestore-Einzelfeldindizes“ in der Firebase-Konsole.

    Gehen Sie zu Einzelfeldindizes

  2. Klicken Sie auf Ausnahme hinzufügen .

  3. Geben Sie als Sammlungs-ID „ instruments ein. Geben Sie als Feldpfad timestamp ein.

  4. Wählen Sie unter Abfragebereich sowohl Sammlung als auch Sammlungsgruppe aus.

  5. Weiter klicken

  6. Schalten Sie alle Indexeinstellungen auf Deaktiviert . Klicken Sie auf Speichern .

  7. Wiederholen Sie die gleichen Schritte für das shard Feld.

GCP-Konsole

  1. Gehen Sie in der Google Cloud Platform Console zur Seite „Datenbanken“ .

    Gehen Sie zu Datenbanken

  2. Wählen Sie aus der Liste der Datenbanken die gewünschte Datenbank aus.

  3. Klicken Sie im Navigationsmenü auf Indizes und dann auf die Registerkarte Einzelfeld .

  4. Klicken Sie auf die Registerkarte „Einzelfeld“ .

  5. Klicken Sie auf Ausnahme hinzufügen .

  6. Geben Sie als Sammlungs-ID „ instruments ein. Geben Sie als Feldpfad timestamp ein.

  7. Wählen Sie unter Abfragebereich sowohl Sammlung als auch Sammlungsgruppe aus.

  8. Weiter klicken

  9. Schalten Sie alle Indexeinstellungen auf Deaktiviert . Klicken Sie auf Speichern .

  10. Wiederholen Sie die gleichen Schritte für das shard Feld.

Firebase-CLI

  1. Fügen Sie dem Abschnitt fieldOverrides Ihrer Indexdefinitionsdatei Folgendes hinzu:

    {
     "fieldOverrides": [
       // Disable single-field indexing for the timestamp field
       {
         "collectionGroup": "instruments",
         "fieldPath": "timestamp",
         "indexes": []
       },
     ]
    }
    
  2. Stellen Sie Ihre aktualisierten Indexdefinitionen bereit:

    firebase deploy --only firestore:indexes
    

Erstellen Sie neue zusammengesetzte Indizes

Nachdem Sie alle vorherigen Indizes entfernt haben, die den timestamp enthalten, definieren Sie die neuen Indizes, die Ihre App benötigt. Jeder Index, der das timestamp enthält, muss auch das shard Feld enthalten. Um beispielsweise die oben genannten Abfragen zu unterstützen, fügen Sie die folgenden Indizes hinzu:

Sammlung Felder indiziert Abfragebereich
Instrumente Shard, Preis.Währung, Zeitstempel Sammlung
Instrumente Shard, Austausch, Zeitstempel Sammlung
Instrumente Shard, Instrumententyp, Zeitstempel Sammlung

Fehlermeldungen

Sie können diese Indizes erstellen, indem Sie die aktualisierten Abfragen ausführen.

Jede Abfrage gibt eine Fehlermeldung mit einem Link zum Erstellen des erforderlichen Index in der Firebase-Konsole zurück.

Firebase-CLI

  1. Fügen Sie Ihrer Indexdefinitionsdatei die folgenden Indizes hinzu:

     {
       "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. Stellen Sie Ihre aktualisierten Indexdefinitionen bereit:

    firebase deploy --only firestore:indexes
    

Verstehen des Schreibens für limitierte sequentiell indizierte Felder

Die Begrenzung der Schreibrate für sequenziell indizierte Felder ergibt sich aus der Art und Weise, wie Cloud Firestore Indexwerte speichert und Indexschreibvorgänge skaliert. Für jeden Indexschreibvorgang definiert Cloud Firestore einen Schlüsselwerteintrag, der den Dokumentnamen und den Wert jedes indizierten Felds verkettet. Cloud Firestore organisiert diese Indexeinträge in Datengruppen, die als Tablets bezeichnet werden. Jeder Cloud Firestore-Server enthält ein oder mehrere Tablets. Wenn die Schreiblast auf ein bestimmtes Tablet zu hoch wird, skaliert Cloud Firestore horizontal, indem es das Tablet in kleinere Tablets aufteilt und die neuen Tablets auf verschiedene Cloud Firestore-Server verteilt.

Cloud Firestore platziert lexikografisch nahestehende Indexeinträge auf demselben Tablet. Wenn die Indexwerte in einem Tablet zu nahe beieinander liegen, beispielsweise bei Zeitstempelfeldern, kann Cloud Firestore das Tablet nicht effizient in kleinere Tablets aufteilen. Dadurch entsteht ein Hotspot, an dem ein einzelnes Tablet zu viel Datenverkehr empfängt und Lese- und Schreibvorgänge am Hotspot langsamer werden.

Durch das Sharding eines Zeitstempelfelds ermöglichen Sie Cloud Firestore, Arbeitslasten effizient auf mehrere Tablets aufzuteilen. Obwohl die Werte des Zeitstempelfelds möglicherweise nahe beieinander bleiben, bietet der verkettete Shard- und Indexwert Cloud Firestore genügend Platz zwischen den Indexeinträgen, um die Einträge auf mehrere Tablets aufzuteilen.

Was kommt als nächstes