Horodatages segmentés

Si une collection contient des documents avec des valeurs indexées séquentielles, Firestore limite le taux d'écriture à 500 écritures par seconde. Cette page explique comment segmenter un champ de document pour dépasser cette limite. Commençons par définir ce que nous entendons par "champs indexés séquentiels" et préciser quand cette limite s'applique.

Champs indexés séquentiels

Les "champs indexés séquentiels" désignent toute collection de documents contenant un champ indexé qui augmente ou diminue de façon linéaire. Dans de nombreux cas, cela signifie un champ timestamp, mais toute valeur de champ augmentant ou diminuant de façon linéaire peut déclencher une limite d'écriture de 500 écritures par seconde.

Par exemple, la limite s'applique à une collection de documents user avec le champ indexé userid 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 façon aléatoire, la limite d'écriture ne s'applique pas. La valeur du champ n'a pas d'importance non plus. Ce qui compte, c'est que le champ augmente ou diminue de façon linéaire. Par exemple, les deux ensembles suivants de valeurs de champ augmentent de façon linéaire et déclenchent la limite d'écriture :

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

Segmentation d'un champ d'horodatage

Supposons que votre application utilise un champ timestamp augmentant de façon linéaire. Si votre application n'utilise pas le champ timestamp dans les requêtes, vous pouvez supprimer la limite de 500 écritures par seconde en omettant l'indexation du champ d'horodatage. Si vous avez besoin d'un champ timestamp pour vos requêtes, vous pouvez contourner la limite en utilisant les horodatages segmentés :

  1. Ajoutez un champ shard à côté du champ timestamp. Utilisez des valeurs distinctes de 1..n pour le champ shard. Cela augmente la limite d'écriture pour la collection à 500*n, mais vous devez agréger les requêtes n.
  2. Mettez à jour votre logique d'écriture pour affecter de manière aléatoire une valeur shard à chaque document.
  3. Mettez à jour vos requêtes pour agréger les ensembles de résultats segmentés.
  4. Désactivez les index à champ unique pour les champs shard et timestamp. Supprimez les index composites existants qui contiennent le champ timestamp.
  5. Créez des 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 incluant le champ timestamp doivent également inclure le champ shard.

Vous devez uniquement mettre en œuvre des horodatages segmentés dans les cas d'utilisation dont le taux d'écriture est supérieur à 500 écritures par seconde. Sinon, il s'agit d'une optimisation prématurée. Le fait de segmenter un champ timestamp supprime la restriction de 500 écritures par seconde, mais avec le compromis d'avoir besoin d'agrégations de requêtes côté client.

Les exemples suivants montrent comment segmenter un champ timestamp et comment interroger un ensemble de résultats segmenté.

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

Imaginons, par exemple, une application pour l'analyse en temps quasi réel d'instruments financiers tels que les devises, les actions ordinaires et les ETF. Cette application écrit des documents dans une collection instruments comme ceci :

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

Cette application exécute les requêtes et les ordres suivants par le biais du champ 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]);
    });

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 trois valeurs de segmentation, MAX_INSTRUMENT_UPDATES/500 = 3. Cet exemple utilise les valeurs de segmentation x, y et z. Vous pouvez également utiliser des chiffres ou d'autres caractères pour vos valeurs de segmentation.

Ajouter un champ de segmentation

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 sur la collection à 1 500 écritures par seconde.

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

Interroger l'horodatage segmenté

Pour ajouter un champ shard, vous devez mettre à jour vos requêtes afin d'agréger les résultats segmentés :

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

Mettre à jour les définitions

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

Supprimer les définitions d'index composites

Console Firebase

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

    Accéder aux index composites

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

Console GCP

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

    Accéder 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 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 , puis sur Supprimer.

CLI Firebase

  1. Si vous n'avez pas configuré la CLI Firebase, suivez ces instructions pour l'installer et exécuter la commande firebase init. Lors de la commande init, veillez à sélectionner Firestore: Deploy rules and create indexes for Firestore.
  2. Lors de la configuration, la CLI Firebase télécharge vos définitions d'index existantes dans un fichier nommé firestore.indexes.json par défaut.
  3. Supprimez 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 Firestore Single Field Indexes (Index à champ unique Firestore) dans la console Firebase.

    Accéder aux index à champ unique

  2. Cliquez sur Ajouter une exception.

  3. Sous ID de collection, saisissez instruments. Sous Chemin d'accès du champ, saisissez timestamp.

  4. Sous Champ d'application de la requête, sélectionnez Collection et Groupe de collections.

  5. Cliquez sur Suivant

  6. Basculez tous les paramètres d'index sur Désactivé. Cliquez sur Enregistrer.

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

Console GCP

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

    Accéder 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 sur l'onglet Champ unique.

  4. Cliquez sur l'onglet Champ individuel.

  5. Cliquez sur Ajouter une exception.

  6. Sous ID de collection, saisissez instruments. Sous Chemin d'accès du champ, saisissez timestamp.

  7. Sous Champ d'application de la requête, sélectionnez Collection et Groupe de collections.

  8. Cliquez sur Suivant

  9. Basculez tous les paramètres d'index sur Désactivé. Cliquez sur Enregistrer.

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

CLI Firebase

  1. Ajoutez la ligne suivante à 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 des index composites

Après avoir supprimé tous les index précédents contenant le code 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 Champ d'application de la requête
instruments shard, price.currency, timestamp Collection
instruments shard, exchange, timestamp Collection
instruments shard, instrumentType, timestamp 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 permettant de 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
    

Description de la limite d'écriture dans les champs indexés séquentiels

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

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

En segmentant un champ d'horodatage, vous permettez à 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 valeur d'index et la segmentation concaténée permettent à Firestore de séparer les entrées entre plusieurs tablettes.

Étapes suivantes