Horodatages partagés

Si une collection contient des documents avec des valeurs indexées séquentiellement, Cloud Firestore limite le taux d'écriture à 500 écritures par seconde. Cette page décrit comment partitionner un champ de document pour surmonter cette limite. Tout d'abord, définissons ce que nous entendons par « champs indexés séquentiellement » et clarifions quand cette limite s'applique.

Champs indexés séquentiellement

« Champs indexés séquentiellement » désigne toute collection de documents contenant un champ indexé croissant ou décroissant de manière monotone. Dans de nombreux cas, cela signifie un champ timestamp , mais toute valeur de champ augmentant ou diminuant de manière monotone peut déclencher la limite d'écriture de 500 écritures par seconde.

Par exemple, la limite s'applique à une collection de documents user avec userid de champ indexé si l'application attribue des valeurs userid comme suit :

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

En revanche, tous les champs timestamp ne déclenchent pas cette limite. Si un champ timestamp suit des valeurs distribuées de manière aléatoire, la limite d'écriture ne s'applique pas. La valeur réelle du champ n'a pas d'importance non plus, seulement le fait que le champ augmente ou diminue de manière monotone. Par exemple, les deux ensembles suivants de valeurs de champ croissantes de manière monotone déclenchent la limite d'écriture :

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

Partager un champ d'horodatage

Supposons que votre application utilise un champ timestamp augmentant de manière monotone. Si votre application n'utilise pas le champ timestamp dans aucune requête, vous pouvez supprimer la limite de 500 écritures par seconde en n'indexant pas le champ d'horodatage. Si vous avez besoin d'un champ timestamp pour vos requêtes, vous pouvez contourner la limite en utilisant des horodatages fragmentés :

  1. Ajoutez un champ shard à côté du champ timestamp . Utilisez 1..n valeurs distinctes pour le champ shard . Cela augmente la limite d'écriture de la collection à 500*n , mais vous devez regrouper n requêtes.
  2. Mettez à jour votre logique d'écriture pour attribuer de manière aléatoire une valeur shard à chaque document.
  3. Mettez à jour vos requêtes pour agréger les jeux de résultats fragmentés.
  4. Désactivez les index à champ unique pour le champ shard et le champ timestamp . Supprimez les index composites existants qui contiennent le champ timestamp .
  5. Créez de nouveaux index composites pour prendre en charge vos requêtes mises à jour. L'ordre des champs dans un index est important et le champ shard doit précéder le champ timestamp . Tous les index qui incluent le champ timestamp doivent également inclure le champ shard .

Vous devez implémenter des horodatages fragmentés uniquement dans les cas d'utilisation avec des taux d'écriture soutenus supérieurs à 500 écritures par seconde. Sinon, il s’agit d’une optimisation prématurée. Le partage d'un champ timestamp supprime la restriction de 500 écritures par seconde, mais avec le compromis de nécessiter des agrégations de requêtes côté client.

Les exemples suivants montrent comment partitionner un champ timestamp et comment interroger un jeu de résultats fragmenté.

Exemple de modèle de données et de requêtes

À titre d'exemple, imaginez une application permettant d'analyser en temps quasi réel des instruments financiers tels que les devises, les actions ordinaires et les ETF. Cette application écrit des documents dans une collection instruments comme ceci :

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

Cette application exécute les requêtes et commandes suivantes par le champ timestamp :

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

Après quelques recherches, vous déterminez que l'application recevra entre 1 000 et 1 500 mises à jour d'instruments par seconde. Cela dépasse les 500 écritures par seconde autorisées pour les collections contenant des documents avec des champs d'horodatage indexés. Pour augmenter le débit d'écriture, vous avez besoin de 3 valeurs de partition, MAX_INSTRUMENT_UPDATES/500 = 3 . Cet exemple utilise les valeurs de partition x , y et z . Vous pouvez également utiliser des nombres ou d'autres caractères pour vos valeurs de partition.

Ajout d'un champ de partition

Ajoutez un champ shard à vos documents. Définissez le champ shard sur les valeurs x , y ou z , ce qui augmente la limite d'écriture de la collection à 1 500 écritures par seconde.

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

Interroger l'horodatage fragmenté

L'ajout d'un champ shard nécessite que vous mettiez à jour vos requêtes pour regrouper les résultats fragmentés :

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

Mettre à jour les définitions d'index

Pour supprimer la contrainte de 500 écritures par seconde, supprimez les index à champ unique et composites existants qui utilisent le champ timestamp .

Supprimer les définitions d'index composites

Console Firebase

  1. Ouvrez la page Index composites Cloud Firestore dans la console Firebase.

    Accédez aux index composites

  2. Pour chaque index contenant le champ timestamp , cliquez sur le bouton et cliquez sur Supprimer .

Console GCP

  1. Dans la console Google Cloud Platform, accédez à la page Bases de données .

    Aller aux bases de données

  2. Sélectionnez la base de données requise dans la liste des bases de données.

  3. Dans le menu de navigation, cliquez sur Index , puis cliquez sur l'onglet Composite .

  4. Utilisez le champ Filtre pour rechercher des définitions d'index contenant le champ timestamp .

  5. Pour chacun de ces index, cliquez sur le bouton et cliquez sur Supprimer .

CLI Firebase

  1. Si vous n'avez pas configuré la CLI Firebase, suivez ces instructions pour installer la CLI et exécutez la commande firebase init . Pendant la commande init , assurez-vous de sélectionner Firestore: Deploy rules and create indexes for Firestore .
  2. Lors de l'installation, la CLI Firebase télécharge vos définitions d'index existantes dans un fichier nommé, par défaut, firestore.indexes.json .
  3. Supprimez toutes les définitions d'index contenant le champ timestamp , par exemple :

    {
    "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. Déployez vos définitions d'index mises à jour :

    firebase deploy --only firestore:indexes
    

Mettre à jour les définitions d'index à champ unique

Console Firebase

  1. Ouvrez la page Index à champ unique Cloud Firestore dans la console Firebase.

    Accédez aux index à champ unique

  2. Cliquez sur Ajouter une exemption .

  3. Pour ID de collection , saisissez instruments . Pour Field path , saisissez timestamp .

  4. Sous Portée de la requête , sélectionnez à la fois Collection et Groupe de collections .

  5. Cliquez sur Suivant

  6. Basculez tous les paramètres d’index sur Disabled . Cliquez sur Enregistrer .

  7. Répétez les mêmes étapes pour le champ shard .

Console GCP

  1. Dans la console Google Cloud Platform, accédez à la page Bases de données .

    Aller aux bases de données

  2. Sélectionnez la base de données requise dans la liste des bases de données.

  3. Dans le menu de navigation, cliquez sur Index , puis cliquez sur l'onglet Champ unique .

  4. Cliquez sur l' onglet Champ unique .

  5. Cliquez sur Ajouter une exemption .

  6. Pour ID de collection , saisissez instruments . Pour Field path , saisissez timestamp .

  7. Sous Portée de la requête , sélectionnez à la fois Collection et Groupe de collections .

  8. Cliquez sur Suivant

  9. Basculez tous les paramètres d’index sur Disabled . Cliquez sur Enregistrer .

  10. Répétez les mêmes étapes pour le champ shard .

CLI Firebase

  1. Ajoutez ce qui suit à la section fieldOverrides de votre fichier de définitions d'index :

    {
     "fieldOverrides": [
       // Disable single-field indexing for the timestamp field
       {
         "collectionGroup": "instruments",
         "fieldPath": "timestamp",
         "indexes": []
       },
     ]
    }
    
  2. Déployez vos définitions d'index mises à jour :

    firebase deploy --only firestore:indexes
    

Créer de nouveaux index composites

Après avoir supprimé tous les index précédents contenant le timestamp , définissez les nouveaux index requis par votre application. Tout index contenant le champ timestamp doit également contenir le champ shard . Par exemple, pour prendre en charge les requêtes ci-dessus, ajoutez les index suivants :

Collection Champs indexés Portée de la requête
instruments Fragment , price.currency, horodatage Collection
instruments Fragment , échange , horodatage Collection
instruments Fragment , type d'instrument , horodatage Collection

Messages d'erreur

Vous pouvez créer ces index en exécutant les requêtes mises à jour.

Chaque requête renvoie un message d'erreur avec un lien pour créer l'index requis dans la console Firebase.

CLI Firebase

  1. Ajoutez les index suivants à votre fichier de définition d'index :

     {
       "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. Déployez vos définitions d'index mises à jour :

    firebase deploy --only firestore:indexes
    

Comprendre l'écriture pour les champs indexés séquentiels limités

La limite du taux d'écriture pour les champs indexés séquentiellement provient de la façon dont Cloud Firestore stocke les valeurs d'index et met à l'échelle les écritures d'index. Pour chaque écriture d'index, Cloud Firestore définit une entrée clé-valeur qui concatène le nom du document et la valeur de chaque champ indexé. Cloud Firestore organise ces entrées d'index en groupes de données appelés tablettes . Chaque serveur Cloud Firestore contient une ou plusieurs tablettes. Lorsque la charge d'écriture sur une tablette particulière devient trop élevée, Cloud Firestore évolue horizontalement en divisant la tablette en tablettes plus petites et en répartissant les nouvelles tablettes sur différents serveurs Cloud Firestore.

Cloud Firestore place les entrées d'index lexicographiquement proches sur la même tablette. Si les valeurs d'index dans une tablette sont trop rapprochées, comme pour les champs d'horodatage, Cloud Firestore ne peut pas diviser efficacement la tablette en tablettes plus petites. Cela crée un point chaud où une seule tablette reçoit trop de trafic et les opérations de lecture et d'écriture sur le point chaud deviennent plus lentes.

En partitionnant un champ d'horodatage, vous permettez à Cloud Firestore de répartir efficacement les charges de travail sur plusieurs tablettes. Bien que les valeurs du champ d'horodatage puissent rester proches les unes des autres, la partition concaténée et la valeur d'index donnent à Cloud Firestore suffisamment d'espace entre les entrées d'index pour diviser les entrées entre plusieurs tablettes.

Et après