Fragmenty sygnatur czasowych

Jeśli kolekcja zawiera dokumenty z sekwencyjnymi wartościami indeksowanymi, Cloud Firestore ogranicza liczbę operacji zapisu do 500 na sekundę. Z tej strony dowiesz się, jak podzielić pole dokumentu na fragmenty, aby obejść to ograniczenie. Najpierw wyjaśnimy, co rozumiemy przez „sekwencyjne pola indeksowane”, i kiedy to ograniczenie ma zastosowanie.

Sekwencyjne pola indeksowane

„Sekwencyjne pola indeksowane” to dowolna kolekcja dokumentów, która zawiera pole indeksowane rosnąco lub malejąco. W wielu przypadkach jest to pole timestamp, ale dowolna rosnąca lub malejąca wartość pola może spowodować przekroczenie limitu 500 operacji zapisu na sekundę.

Na przykład limit ma zastosowanie do kolekcji dokumentów user z polem indeksowanym userid jeśli aplikacja przypisuje wartości userid w ten sposób:

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

Z drugiej strony nie wszystkie pola timestamp powodują przekroczenie tego limitu. Jeśli pole timestamp śledzi wartości rozproszone losowo, limit operacji zapisu nie ma zastosowania. Rzeczywista wartość pola też nie ma znaczenia – ważne jest tylko to, że pole rośnie lub maleje monotonicznie. Na przykład oba te zestawy rosnących monotonicznie wartości pól powodują przekroczenie limitu operacji zapisu:

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

Dzielenie pola sygnatury czasowej na fragmenty

Załóżmy, że Twoja aplikacja używa rosnącego monotonicznie pola timestamp. Jeśli aplikacja nie używa pola timestamp w żadnych zapytaniach, możesz usunąć limit 500 operacji zapisu na sekundę, nie indeksując pola sygnatury czasowej. Jeśli jednak potrzebujesz pola timestamp w zapytaniach, możesz obejść ten limit, używając sygnatur czasowych podzielonych na fragmenty:

  1. Dodaj pole shard obok pola timestamp. Użyj 1..n różnych wartości pola shard. Zwiększa to limit operacji zapisu w kolekcji do 500*n, ale musisz agregować n zapytań.
  2. Zaktualizuj logikę zapisu, aby losowo przypisywać wartość shard do każdego dokumentu.
  3. Zaktualizuj zapytania, aby agregować podzielone na fragmenty zestawy wyników.
  4. Wyłącz indeksy pojedynczych pól zarówno dla pola shard, jak i pola timestamp. Usuń istniejące indeksy złożone, które zawierają pole timestamp.
  5. Utwórz nowe indeksy złożone, aby obsługiwać zaktualizowane zapytania. Kolejność pól w indeksie ma znaczenie, a pole shard musi występować przed polem timestamp. Wszystkie indeksy, które zawierają pole timestamp, muszą też zawierać pole shard.

Sygnatury czasowe podzielone na fragmenty należy implementować tylko w przypadkach użycia, w których liczba operacji zapisu na sekundę przekracza 500. W przeciwnym razie jest to przedwczesna optymalizacja. Podzielenie pola timestamp na fragmenty usuwa ograniczenie 500 operacji zapisu na sekundę, ale wymaga agregacji zapytań po stronie klienta.

Poniższe przykłady pokazują, jak podzielić pole timestamp na fragmenty i jak wysyłać zapytania do podzielonego na fragmenty zestawu wyników.

Przykładowy model danych i zapytania

Wyobraź sobie aplikację do analizy instrumentów finansowych, takich jak waluty, akcje i fundusze ETF, w czasie zbliżonym do rzeczywistego. Ta aplikacja zapisuje dokumenty w kolekcji instruments w ten sposób:

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

Ta aplikacja uruchamia te zapytania i sortuje je według pola 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]);
    });

Po przeprowadzeniu badań stwierdzasz, że aplikacja będzie otrzymywać od 1000 do 1500 aktualizacji instrumentów na sekundę. Przekracza to limit 500 operacji zapisu na sekundę dozwolony w przypadku kolekcji zawierających dokumenty z indeksowanymi polami sygnatury czasowej. Aby zwiększyć przepustowość zapisu, potrzebujesz 3 wartości fragmentów: MAX_INSTRUMENT_UPDATES/500 = 3. W tym przykładzie używamy wartości fragmentów x, y i z. Jako wartości fragmentów możesz też używać liczb lub innych znaków.

Dodawanie pola fragmentu

Dodaj do dokumentów pole shard. Ustaw pole shard na wartości x, y lub z, co zwiększa limit operacji zapisu w kolekcji do 1500 operacji na sekundę.

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

Wysyłanie zapytań do podzielonej na fragmenty sygnatury czasowej

Dodanie pola shard wymaga zaktualizowania zapytań, aby agregować podzielone na fragmenty wyniki:

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

Aktualizowanie definicji indeksów

Aby usunąć ograniczenie 500 operacji zapisu na sekundę, usuń istniejące indeksy pojedynczych pól i indeksy złożone, które używają pola timestamp.

Usuwanie definicji indeksów złożonych

Konsola Firebase

  1. W konsoli Firebase otwórz kartę Bazy danych i pamięć masowa > Firestore > Indeksy złożone.

    Otwórz Indeksy złożone

  2. W przypadku każdego indeksu, który zawiera pole timestamp, kliknij przycisk i kliknij Usuń.

konsola GCP

  1. W konsoli Google Cloud otwórz stronę Bazy danych.

    Otwórz Bazy danych

  2. Na liście baz danych wybierz wymaganą bazę danych.

  3. W menu nawigacyjnym kliknij Indeksy, a następnie kartę Złożone.

  4. Użyj pola Filtr , aby wyszukać definicje indeksów, które zawierają pole timestamp.

  5. W przypadku każdego z tych indeksów kliknij przycisk , a następnie Usuń.

wiersz poleceń Firebase

  1. Jeśli nie masz skonfigurowanego wiersza poleceń Firebase, zainstaluj go i uruchom polecenie firebase init zgodnie z tymi instrukcjami. Podczas wykonywania polecenia init wybierz Firestore: Deploy rules and create indexes for Firestore.
  2. Podczas konfiguracji wiersz poleceń Firebase pobiera istniejące definicje indeksów do pliku o nazwie domyślnej firestore.indexes.json.
  3. Usuń wszystkie definicje indeksów, które zawierają pole timestamp, na przykład:

    {
    "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. Wdróż zaktualizowane definicje indeksów:

    firebase deploy --only firestore:indexes
    

Aktualizowanie definicji indeksów pojedynczych pól

Konsola Firebase

  1. W konsoli Firebase otwórz kartę Bazy danych i pamięć masowa > Firestore > Indeksy pojedynczych pól.

    Otwórz Indeksy pojedynczych pól

  2. Kliknij Dodaj wykluczenie.

  3. W polu Identyfikator kolekcji wpisz instruments. W polu Ścieżka pola, wpisz timestamp.

  4. W sekcji Zakres zapytania wybierz Kolekcja i Grupa kolekcji.

  5. Kliknij Dalej.

  6. Przełącz wszystkie ustawienia indeksu na Wyłączone. Kliknij Zapisz.

  7. Powtórz te same czynności w przypadku pola shard.

konsola GCP

  1. W konsoli Google Cloud otwórz stronę Bazy danych.

    Otwórz Bazy danych

  2. Na liście baz danych wybierz wymaganą bazę danych.

  3. W menu nawigacyjnym kliknij Indeksy, a następnie kartę Pojedyncze pole.

  4. Kliknij kartę Pojedyncze pole.

  5. Kliknij Dodaj wykluczenie.

  6. W polu Identyfikator kolekcji wpisz instruments. W polu Ścieżka pola, wpisz timestamp.

  7. W sekcji Zakres zapytania wybierz Kolekcja i Grupa kolekcji.

  8. Kliknij Dalej.

  9. Przełącz wszystkie ustawienia indeksu na Wyłączone. Kliknij Zapisz.

  10. Powtórz te same czynności w przypadku pola shard.

wiersz poleceń Firebase

  1. Dodaj te informacje do sekcji fieldOverrides w pliku definicji indeksów:

    {
     "fieldOverrides": [
       // Disable single-field indexing for the timestamp field
       {
         "collectionGroup": "instruments",
         "fieldPath": "timestamp",
         "indexes": []
       },
     ]
    }
    
  2. Wdróż zaktualizowane definicje indeksów:

    firebase deploy --only firestore:indexes
    

Tworzenie nowych indeksów złożonych

Po usunięciu wszystkich poprzednich indeksów zawierających timestamp zdefiniuj nowe indeksy wymagane przez aplikację. Każdy indeks zawierający pole timestamp musi też zawierać pole shard. Aby na przykład obsługiwać powyższe zapytania, dodaj te indeksy:

Kolekcja Zindeksowane pola Zakres zapytania
instruments shard, price.currency, timestamp Kolekcja
instruments shard, exchange, timestamp Kolekcja
instruments shard, instrumentType, timestamp Kolekcja

Komunikaty o błędach

Te indeksy możesz utworzyć, uruchamiając zaktualizowane zapytania.

Każde zapytanie zwraca komunikat o błędzie z linkiem do utworzenia wymaganego indeksu w konsoli Firebase.

wiersz poleceń Firebase

  1. Dodaj te indeksy do pliku definicji indeksów:

     {
       "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. Wdróż zaktualizowane definicje indeksów:

    firebase deploy --only firestore:indexes
    

Informacje o limicie operacji zapisu w przypadku sekwencyjnych pól indeksowanych

Limit operacji zapisu w przypadku sekwencyjnych pól indeksowanych wynika ze sposobu, w jaki Cloud Firestore przechowuje wartości indeksów i skaluje operacje zapisu indeksów. W przypadku każdej operacji zapisu indeksu Cloud Firestore definiuje wpis klucz-wartość, który łączy nazwę dokumentu i wartość każdego pola indeksowanego. Cloud Firestore porządkuje te wpisy indeksu w grupy danych nazywane tabletami. Każdy Cloud Firestore serwer zawiera co najmniej 1 tablet. Gdy obciążenie zapisu na danym tablecie staje się zbyt duże, Cloud Firestore skaluje się poziomo dzieląc tablet na mniejsze tablety i rozpraszając nowe tablety na różnych serwerach Cloud Firestore.

Cloud Firestore umieszcza leksykograficznie bliskie wpisy indeksu na tym samym tablecie. Jeśli wartości indeksu w tablecie są zbyt blisko siebie, np. w przypadku pól sygnatury czasowej, Cloud Firestore nie może skutecznie podzielić tabletu na mniejsze tablety. Powoduje to powstanie gorącego punktu, w którym jeden tablet otrzymuje zbyt duży ruch, a operacje odczytu i zapisu w tym punkcie stają się wolniejsze.

Dzieląc pole sygnatury czasowej na fragmenty, umożliwiasz Cloud Firestore efektywne rozdzielanie obciążeń na wiele tabletów. Chociaż wartości pola sygnatury czasowej mogą pozostać blisko siebie, połączona wartość fragmentu i indeksu daje Cloud Firestore wystarczająco dużo miejsca między wpisami indeksu, aby podzielić wpisy na wiele tabletów.

Co dalej?