Fragmentierte Zeitstempel

Wenn eine Sammlung Dokumente mit sequentiellen indexierten Werten enthält, beschränkt Cloud Firestore die Schreibrate auf 500 Schreibvorgänge pro Sekunde. Auf dieser Seite wird gezeigt, wie durch Fragmentierung eines Dokumentfelds dieses Limit umgangen werden kann. Zuerst wird definiert, was unter "sequenzielle indexierte Felder" zu verstehen ist. Anschließend folgt die Erklärung, wann dieses Limit gilt.

Sequenzielle indexierte Felder

"Sequenzielle indexierte Felder": jede Sammlung von Dokumenten, die ein kontinuierlich wachsendes oder abnehmendes indexiertes Feld enthält. In vielen Fällen ist dies ein timestamp-Feld. Allerdings kann jeder kontinuierlich steigende oder fallende Feldwert das Schreiblimit von 500 Schreibvorgängen pro Sekunde auslösen.

Das Limit gilt beispielsweise für eine Sammlung von user-Dokumenten mit indexiertem Feld userid, wenn eine Anwendung userid-Werte so zuweist:

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

Andererseits lösen nicht alle timestamp-Felder dieses Limit aus. Wenn ein timestamp-Feld zufällig verteilte Werte verfolgt, gilt das Schreiblimit nicht. Der tatsächliche Wert des Felds spielt keine Rolle, sondern nur, dass das Feld kontinuierlich zu- oder abnimmt. So lösen beispielsweise beide der folgenden Sätze kontinuierlich steigender Feldwerte das Schreiblimit aus:

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

Zeitstempelfeld fragmentieren

Angenommen, eine App verwendet ein kontinuierlich wachsendes Feld timestamp. Wenn die App das Feld timestamp nicht in Abfragen verwendet, können Sie das Limit von 500 Schreibvorgängen pro Sekunde entfernen, indem Sie das Zeitstempelfeld nicht indexieren. Wenn Sie allerdings ein timestamp-Feld für Abfragen benötigen, können Sie das Limit durch Verwenden von fragmentierten Zeitstempeln umgehen:

  1. Fügen Sie neben dem Feld timestamp das Feld shard hinzu. Verwenden Sie 1..n verschiedene Werte für das Feld shard. Dadurch wird das Schreiblimit für die Sammlung auf 500*n erhöht, aber Sie müssen n Abfragen aggregieren.
  2. Aktualisieren Sie die Schreiblogik so, dass jedem Dokument ein shard-Wert randomly (zufällig) zugewiesen wird.
  3. Aktualisieren Sie Ihre Abfragen, um die fragmentierte-Ergebnismenge zu aggregieren.
  4. Deaktivieren SIe Einzelfeldindexe für das Feld shard und das Feld timestamp. Löschen Sie vorhandene zusammengesetzte Indexe, die das Feld timestamp enthalten.
  5. Erstellen Sie neue zusammengesetzte Indexe, um die aktualisierten Abfragen zu unterstützen. Die Reihenfolge der Felder in einem Index ist von Bedeutung und das Feld shard muss vor dem Feld timestamp stehen. Alle Indexe, die das Feld timestamp enthalten, müssen auch das Feld shard enthalten.

Sie sollten fragmentierte Zeitstempel nur in Anwendungsfällen mit dauerhaften Schreibraten über 500 Schreibvorgängen pro Sekunde implementieren. Andernfalls ist dies eine zu frühzeitige Optimierung. Durch das Fragmentieren eines timestamp-Feldes wird das Limit von 500 Schreibvorgängen pro Sekunde aufgehoben. Der Nachteil ist dann, dass Abfragen Client-seitig aggregiert werden müssen.

Die folgenden Beispiele zeigen, wie ein timestamp-Feld fragmentiert wird und wie Sie eine fragmentierte Ergebnismenge abfragen.

Beispiel für ein Datenmodell und Abfragen

Stellen Sie sich beispielsweise eine App vor, mit der Finanzinstrumente wie Währungen, Stammaktien und ETFs nahezu in Echtzeit analysiert werden können. Diese App schreibt Dokumente wie folgt in die Sammlung 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();
}

Die App führt die folgenden Abfragen und Aufträge nach dem Feld 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 einigen Nachforschungen stellen Sie fest, dass die App pro Sekunde zwischen 1.000 und 1.500 Aktualisierungen für Instrumente erhält. Das ist mehr als die zulässigen 500 Schreibvorgänge pro Sekunde für Sammlungen, die Dokumente mit indexierten Zeitstempelfeldern enthalten. Damit Sie den Schreibdurchsatz erhöhen können, benötigen Sie drei Shard-Werte: MAX_INSTRUMENT_UPDATES/500 = 3. In diesem Beispiel werden die Shard-Werte x, y und z verwendet. Sie können auch Zahlen oder andere Zeichen für die Shard-Werte verwenden.

Shard-Feld hinzufügen

Fügen Sie Ihren Dokumenten das Feld shard hinzu. Setzen Sie das shard-Feld auf den Wert x, y oder z. Dadurch wird das Schreiblimit für die Sammlung auf 1.500 Schreibvorgänge pro Sekunde erhöht.

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

Fragmentierten Zeitstempel abfragen

Wenn Sie das Feld shard hinzufügen, müssen Sie Ihre Abfragen aktualisieren, damit die fragmentierten Ergebnisse aggregiert werden:

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

Zum Aufheben des Limits von 500 Schreibvorgängen pro Sekunde löschen Sie die vorhandenen Einzelfeld- und zusammengesetzten Indexe, die das Feld timestamp verwenden.

Definitionen von zusammengesetzten Indexen löschen

Firebase Console

  1. Öffnen Sie in der Firebase Console die Seite Cloud Firestore Zusammengesetzte Indexe.

    Zusammengesetzte Indexe aufrufen

  2. Klicken Sie bei jedem Index, der das Feld timestamp enthält, auf die Schaltfläche und anschließend auf Löschen.

GCP Console

  1. Rufen Sie in der Google Cloud Console die Seite Datenbanken auf.

    Zur Seite „Datenbanken“

  2. Wählen Sie die benötigte Datenbank aus der Liste der Datenbanken aus.

  3. Klicken Sie im Navigationsmenü auf Indexe und dann auf den Tab Komposit.

  4. Suchen Sie über das Feld Filter nach Indexdefinitionen, die das Feld timestamp enthalten.

  5. Klicken Sie bei jedem dieser Indexe auf die Schaltfläche und dann auf Löschen.

Firebase CLI

  1. Wenn Sie die Firebase CLI nicht eingerichtet haben, folgen Sie dieser Anleitung, um die Befehlszeile zu installieren und den Befehl firebase init auszuführen. Wählen Sie während des Befehls init die Option Firestore: Deploy rules and create indexes for Firestore aus.
  2. Während der Einrichtung lädt die Firebase CLI vorhandene Indexdefinitionen in eine Datei mit dem Namen firestore.indexes.json herunter.
  3. Entfernen Sie alle Indexdefinitionen, die das Feld timestamp enthalten, z. B.:

    {
    "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
    

Indexdefinitionen für einzelne Felder aktualisieren

Firebase Console

  1. Öffnen Sie in der Firebase Console die Seite Cloud Firestore Einzelfeldindexe.

    Einzelfeldindexe

  2. Klicken Sie auf Ausnahme hinzufügen.

  3. Geben Sie bei Sammlungs-ID die Option instruments ein. Geben Sie für Feldpfad die Option timestamp ein.

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

  5. Klicken Sie auf Weiter.

  6. Stellen Sie alle Indexeinstellungen auf Deaktiviert ein. Klicken Sie auf Speichern.

  7. Wiederholen Sie diese Schritte für das Feld shard.

GCP Console

  1. Rufen Sie in der Google Cloud Console die Seite Datenbanken auf.

    Zur Seite „Datenbanken“

  2. Wählen Sie die benötigte Datenbank aus der Liste der Datenbanken aus.

  3. Klicken Sie im Navigationsmenü auf Indexe und dann auf den Tab Einzelnes Feld.

  4. Klicken Sie auf den Tab Einzelfeld.

  5. Klicken Sie auf Ausnahme hinzufügen.

  6. Geben Sie bei Sammlungs-ID die Option instruments ein. Geben Sie für Feldpfad die Option timestamp ein.

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

  8. Klicken Sie auf Weiter.

  9. Stellen Sie alle Indexeinstellungen auf Deaktiviert ein. Klicken Sie auf Speichern.

  10. Wiederholen Sie diese Schritte für das Feld shard.

Firebase CLI

  1. Fügen Sie Folgendes zum Abschnitt fieldOverrides der Indexdefinitionsdatei 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
    

Neue zusammengesetzte Indexe erstellen

Nachdem Sie alle vorherigen Indexe mit timestamp entfernt haben, definieren Sie die neuen Indexe, die für die App erforderlich sind. Jeder Index, der das Feld timestamp enthält, muss auch das Feld shard enthalten. Damit beispielsweise die obigen Abfragen unterstützt werden können, fügen Sie die folgenden Indexe hinzu:

Sammlung Indexierte Felder Abfragebereich
instruments shard, price.currency, timestamp Sammlung
instruments shard, exchange, timestamp Sammlung
instruments shard, instrumentType, timestamp Sammlung

Fehlermeldungen

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

Jede Abfrage gibt eine Fehlermeldung mit einem Link zurück. Darüber können Sie den erforderlichen Index in der Firebase Console erstellen.

Firebase CLI

  1. Fügen Sie der Indexdefinitionsdatei die folgenden Indexe 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
    

Informationen zum Schreiblimit für sequenziell indexierte Felder

Das Limit für die Schreibrate bei sequenziellen indexierten Feldern ergibt sich daraus, 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 indexierten Felds verkettet. Cloud Firestoreordnet diese Indexeinträge in Datengruppen an, die als Tablenzeilen bezeichnet werden. Jeder Cloud Firestore-Server enthält eine oder mehrere Tabellenzeilen. Wenn die Schreiblast für eine bestimmte Tabellenreihe zu hoch wird, wird Cloud Firestore horizontal skaliert. Dazu wird die Tabellenreihe in mehrere kleinere Tabellenreihen aufgeteilt und die neuen Tabellenreihen werden auf verschiedene Cloud Firestore-Server verteilt.

Cloud Firestore platziert lexikografisch nahe beieinander liegende Indexeinträge in derselben Tabellenzeile. Wenn die Indexwerte in einer Tabellenzeile zu nahe beieinander liegen, z. B. bei Zeitstempelfeldern, kann Cloud Firestore die Tabellenzeile nicht effizient in kleinere Tabellenzeilen aufteilen. Dadurch entsteht ein Hotspot, bei dem eine einzelne Tabellenreihe zu viel Traffic erhält, sodass Lese- und Schreibvorgänge am Hotspot langsamer werden.

Durch das Fragmentieren eines Zeitstempelfelds kann Cloud Firestore Arbeitslasten effizient auf mehrere Tabellenreihen aufteilen. Die Werte des Zeitstempelfelds können dabei nahe beieinander bleiben, der verkettete Fragmentierungs- und Indexwert bietet aber Cloud Firestore genügend Raum zwischen Indexeinträgen, um die Einträge auf mehrere Tabellenreihen zu verteilen.

Nächste Schritte