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 :
- Ajoutez un champ
shard
à côté du champtimestamp
. Utilisez des valeurs distinctes de1..n
pour le champshard
. Cela augmente la limite d'écriture pour la collection à500*n
, mais vous devez agréger les requêtesn
. - Mettez à jour votre logique d'écriture pour affecter de manière aléatoire une valeur
shard
à chaque document. - Mettez à jour vos requêtes pour agréger les ensembles de résultats segmentés.
- Désactivez les index à champ unique pour les champs
shard
ettimestamp
. Supprimez les index composites existants qui contiennent le champtimestamp
. - 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 champtimestamp
. Tous les index incluant le champtimestamp
doivent également inclure le champshard
.
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
Ouvrez la page Index composites Firestore dans la console Firebase.
Pour chaque index contenant le champ
timestamp
, cliquez sur le bouton , puis sur Supprimer.
Console GCP
Dans la console Google Cloud, accédez à la page Base de données.
Sélectionnez la base de données requise dans la liste des bases de données.
Dans le menu de navigation, cliquez sur Index, puis sur l'onglet Composite.
Utilisez le champ Filtre pour rechercher des définitions d'index contenant le champ
timestamp
.Pour chacun de ces index, cliquez sur le bouton
, puis sur Supprimer.
CLI Firebase
- 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 commandeinit
, veillez à sélectionnerFirestore: Deploy rules and create indexes for Firestore
. - 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. 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" } ] }, ] }
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
Ouvrez la page Firestore Single Field Indexes (Index à champ unique Firestore) dans la console Firebase.
Cliquez sur Ajouter une exception.
Sous ID de collection, saisissez
instruments
. Sous Chemin d'accès du champ, saisisseztimestamp
.Sous Champ d'application de la requête, sélectionnez Collection et Groupe de collections.
Cliquez sur Suivant
Basculez tous les paramètres d'index sur Désactivé. Cliquez sur Enregistrer.
Répétez les mêmes étapes pour le champ
shard
.
Console GCP
Dans la console Google Cloud, accédez à la page Base de données.
Sélectionnez la base de données requise dans la liste des bases de données.
Dans le menu de navigation, cliquez sur Index, puis sur l'onglet Champ unique.
Cliquez sur l'onglet Champ individuel.
Cliquez sur Ajouter une exception.
Sous ID de collection, saisissez
instruments
. Sous Chemin d'accès du champ, saisisseztimestamp
.Sous Champ d'application de la requête, sélectionnez Collection et Groupe de collections.
Cliquez sur Suivant
Basculez tous les paramètres d'index sur Désactivé. Cliquez sur Enregistrer.
Répétez les mêmes étapes pour le champ
shard
.
CLI Firebase
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": [] }, ] }
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
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" } ] }, ] }
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.
Étape suivante
- Consultez les bonnes pratiques en matière de conception à grande échelle
- Pour les cas où le débit d'écriture est élevé pour un seul document, reportez-vous à la page Distributed counters.
- Consultez les limites standards de Firestore.