Best Practices für Schemadesign

Mit der verteilten Architektur von Spanner können Sie Ihr Schema so gestalten, dass Hotspots vermieden werden. Das sind Situationen, in denen zu viele Anfragen an denselben Server gesendet werden, wodurch die Ressourcen des Servers gesättigt und potenziell hohe Latenzen verursacht werden.

Auf dieser Seite werden Best Practices für das Entwerfen von Schemas beschrieben, um das Erstellen von Hotspots zu vermeiden. Eine Möglichkeit, Hotspots zu vermeiden, besteht darin, das Schemadesign so anzupassen, dass Spanner die Daten auf mehrere Server aufteilen und verteilen kann. Durch die Verteilung von Daten auf Server kann Ihre Spanner-Datenbank effizient arbeiten, insbesondere beim Einfügen von Bulk-Daten.

Primärschlüssel zur Vermeidung von Hotspots auswählen

Wie unter Schema und Datenmodell erwähnt, sollten Sie bei der Auswahl eines Primärschlüssels im Schemadesign vorsichtig sein, damit nicht versehentlich Hotspots in der Datenbank entstehen. Eine Ursache für Hotspots ist die Verwendung einer Spalte, deren Wert sich als erster Schlüsselteil monoton ändert, da dies dazu führt, dass alle Einfügungen am Ende des Schlüsselbereichs erfolgen. Dieses Muster ist nicht wünschenswert, da Spanner Schlüsselbereiche verwendet, um Daten auf Server aufzuteilen. Das bedeutet, dass alle Ihre Einfügungen an einen einzigen Server gerichtet sind, der letztendlich die gesamte Arbeit erledigt.

Angenommen, Sie möchten eine Spalte mit dem Zeitstempel des letzten Zugriffs für Zeilen der Tabelle UserAccessLog beibehalten. In der folgenden Tabellendefinition wird als erster Schlüsselteil ein zeitstempelbasierter Primärschlüssel verwendet. Wir raten von dieser Vorgehensweise ab, wenn in der Tabelle eine hohe Anzahl von Einfügungen verzeichnet wird:

GoogleSQL


CREATE TABLE UserAccessLog (
LastAccess TIMESTAMP NOT NULL,
UserId     INT64 NOT NULL,
...
) PRIMARY KEY (LastAccess, UserId);

PostgreSQL


CREATE TABLE UserAccessLog (
LastAccess TIMESTAMPTZ NOT NULL,
UserId bigint NOT NULL,
...
PRIMARY KEY (LastAccess, UserId)
);

Das Problem hier besteht darin, dass Zeilen in der Reihenfolge des Zeitstempels des letzten Zugriffs in diese Tabelle geschrieben werden. Da die Zeitstempel des letzten Zugriffs immer zunehmen, werden sie immer an das Ende der Tabelle geschrieben. Der Hotspot entsteht, weil ein einzelner Spanner-Server alle Schreibvorgänge empfängt, was diesen Server überlastet.

In diesem Diagramm wird die Problematik dargestellt:

Nach Zeitstempel sortierte UserAccessLog-Tabelle mit entsprechendem Hotspot

Die oben dargestellte Tabelle UserAccessLog enthält fünf Beispieldatenzeilen, die fünf verschiedene Nutzer darstellen, wobei alle fünf eine Nutzeraktion im Abstand von etwa einer Millisekunde voneinander ausführen. Im Diagramm wird auch die Reihenfolge angegeben, in der Spanner die Zeilen einfügt. Die beschrifteten Pfeile geben die Reihenfolge der Schreibvorgänge für jede Zeile an. Da Einfügungen nach Zeitstempel sortiert sind und der Zeitstempelwert kontinuierlich zunimmt, fügt Spanner die Einfügungen immer am Ende der Tabelle hinzu und weist sie zum selben Split an. Wie unter Schema und Datenmodell erläutert, besteht ein Split aus einer Reihe von Zeilen aus einer oder mehreren verbundenen Tabellen, die Spanner in der Reihenfolge des Zeilenschlüssels speichert.

Dies ist problematisch, da Spanner verschiedenen Servern Arbeit in Split-Einheiten zuweist. Der Server, der diesem Split zugewiesen ist, verarbeitet also am Ende alle Einfügeanfragen. Je häufiger Nutzerzugriffe stattfinden, desto häufiger erhält der entsprechende Server Einfügungsanfragen. Der Server wird dann häufig zu einem Hotspot und sieht aus wie der rote Rahmen und den Hintergrund darüber. Beachten Sie, dass in dieser vereinfachten Darstellung jeder Server höchstens einen Split verarbeitet. Tatsächlich kann Spanner jedem Server jedoch mehr als einen Split zuweisen.

Wenn Spanner weitere Zeilen an die Tabelle anfügt, wird der Split erweitert. Wenn er ungefähr 8 GB erreicht, erstellt Spanner einen weiteren Split, wie unter Lastbasierte Aufteilung beschrieben. Spanner hängt nachfolgende neue Zeilen an diesen neuen Split an und der Server, der dem Split zugewiesen ist, wird zum neuen potenziellen Hotspot.

Beim Auftreten von Hotspots können Sie beobachten, dass Einfügungen langsam verarbeitet werden und auch andere Arbeiten auf demselben Server langsamer vorangehen. Die Änderung der Reihenfolge der Spalte LastAccess in aufsteigender Reihenfolge löst dieses Problem nicht, da dann alle Schreibvorgänge stattdessen am Anfang der Tabelle eingefügt werden. Auch in diesem Fall würden alle Einfügungen an einen einzigen Server gesendet.

Best Practice 1 für das Schemadesign: Wählen Sie keine Spalte aus, deren Wert als erster Schlüssel für eine Tabelle mit hoher Schreibrate monoton zu- oder abnimmt.

Universally Unique Identifier verwenden

Sie können als Primärschlüssel eine UUID (Universally Unique Identifier) gemäß RFC 4122 verwenden. Wir empfehlen die Verwendung der UUID Version 4, da bei dieser Version in der Bitsequenz zufällige Werte verwendet werden. Wir raten von UUIDs der Version 1 ab, da sie den Zeitstempel in Bits höherer Ordnung speichern.

Die UUID kann auf verschiedene Arten als Primärschlüssel gespeichert werden:

  • In einer STRING(36)-Spalte.
  • In einem INT64-Spaltenpaar
  • In einer BYTES(16)-Spalte.

Für eine STRING(36)-Spalte können Sie die Spanner-Funktion GENERATE_UUID() (GoogleSQL oder PostgreSQL) als Spaltenstandardwert verwenden, damit Spanner UUID-Werte automatisch generiert.

Die Verwendung einer UUID bringt einige Nachteile mit sich:

  • Ihre beträchtliche Größe belegt mindestens 16 Byte. Andere mögliche Primärschlüssel erfordern nicht so viel Speicherplatz.
  • Sie enthalten keine Informationen zum Datensatz. Beispielsweise hat der Primärschlüssel SingerId und AlbumId eine inhärente Bedeutung, eine UUID nicht.
  • Sie verlieren die Lokalität zwischen verwandten Einträgen. Deshalb werden durch die Verwendung einer UUID Hotspots beseitigt.

Bit-Umkehrungen für sequenzielle Werte

Sie sollten darauf achten, dass numerische Primärschlüssel (INT64 in GoogleSQL oder bigint in PostgreSQL) nicht sequenziell zu- oder abnehmen. Sequenzielle Primärschlüssel können ein Heißlaufen in großem Maßstab verursachen. Eine Möglichkeit, dieses Problem zu vermeiden, besteht darin, die sequentiellen Werte in Bit-Umkehrungen umzukehren und dafür zu sorgen, dass Primärschlüssel gleichmäßig über den Schlüsselbereich verteilt werden.

Spanner unterstützt eine bitumgekehrte Sequenz, die eindeutige Bit-umgekehrte Werte für Ganzzahlen generiert. Sie können eine Sequenz in der ersten (oder einzigen) Komponente in einem Primärschlüssel verwenden, um Heißlaufen zu vermeiden. Weitere Informationen finden Sie unter Bit-umgekehrte Sequenz.

Schlüsselreihenfolge vertauschen

Eine Möglichkeit, Schreibvorgänge gleichmäßiger über den Schlüsselbereich zu verteilen, besteht darin, die Reihenfolge der Schlüssel so zu ändern, dass die Spalte, die den monotonen Wert enthält, nicht der erste Schlüsselteil ist:

GoogleSQL

CREATE TABLE UserAccessLog (
LastAccess TIMESTAMP NOT NULL,
UserId     INT64 NOT NULL,
...
) PRIMARY KEY (UserId, LastAccess);

PostgreSQL

CREATE TABLE UserAccessLog (
LastAccess TIMESTAMPTZ NOT NULL,
UserId bigint NOT NULL,
...
PRIMARY KEY (UserId, LastAccess)
);

In diesem geänderten Schema werden die Einfügungen nun nach UserId und nicht in chronologischer Reihenfolge nach dem Zeitstempel des letzten Zugriffs angeordnet. Dieses Schema verteilt Schreibvorgänge auf verschiedene Splits, da es unwahrscheinlich ist, dass ein einzelner Nutzer Tausende von Ereignissen pro Sekunde erzeugt.

Das folgende Diagramm zeigt die fünf Zeilen aus der Tabelle UserAccessLog, die Spanner mit UserId anstelle des Zugriffszeitstempels bestellt:

Nach UserId sortierte UserAccessLog-Tabelle mit ausgeglichenem Schreibdurchsatz

Hier teilt Spanner die UserAccessLog-Daten in drei Splits auf, wobei jeder Split etwa tausend Zeilen mit geordneten UserId-Werten enthält. Dies ist eine angemessene Schätzung, wie die Nutzerdaten aufgeteilt werden können, vorausgesetzt, jede Zeile enthält etwa 1 MB Nutzerdaten und eine maximale Split-Größe von ungefähr 8 GB. Obwohl die Nutzerereignisse im Abstand von etwa einer Millisekunde auftraten, wurde jedes Ereignis von einem anderen Nutzer ausgelöst. Daher ist die Wahrscheinlichkeit, dass durch die Reihenfolge der Einfügungen ein Hotspot entsteht, viel geringer, als wenn der Zeitstempel für die Sortierung verwendet wird.

Weitere Informationen finden Sie in der verwandten Best Practice Auf dem Zeitstempel basierende Schlüssel anordnen.

Eindeutigen Schlüssel hashen und Schreibvorgänge auf logische Shards aufteilen

Die Last kann auch auf mehrere Server verteilt werden. Erstellen Sie zu diesem Zweck eine Spalte, die den Hash des eindeutigen Schlüssels enthält, und nutzen Sie diese Hash-Spalte (oder die Hash-Spalte und die Spalten mit dem eindeutigen Schlüssel) als Primärschlüssel. Mit diesem Muster können Hotspots vermieden werden, da neue Zeilen gleichmäßiger über den Schlüsselbereich verteilt werden.

Sie können den Hash-Wert verwenden, um logische Shards oder Partitionen in einer Datenbank zu erstellen. In einer physisch fragmentierten Datenbank sind die Zeilen auf mehrere Datenbankserver verteilt. In einer logisch fragmentierten Datenbank definieren die Daten in der Tabelle die Shards. Wenn Sie zum Beispiel Schreibvorgänge in die Tabelle UserAccessLog auf N logische Shards verteilen möchten, fügen Sie am Anfang der Tabelle eine Schlüsselspalte ShardId ein:

GoogleSQL

CREATE TABLE UserAccessLog (
ShardId     INT64 NOT NULL,
LastAccess  TIMESTAMP NOT NULL,
UserId      INT64 NOT NULL,
...
) PRIMARY KEY (ShardId, LastAccess, UserId);

PostgreSQL

CREATE TABLE UserAccessLog (
ShardId bigint NOT NULL,
LastAccess TIMESTAMPTZ NOT NULL,
UserId bigint NOT NULL,
...
PRIMARY KEY (ShardId, LastAccess, UserId)
);

Hashen Sie eine Kombination der Primärschlüsselspalten und berechnen Sie dann Modulo N des Hashs, um ShardId zu berechnen. Beispiel:

ShardId = hash(LastAccess and UserId) % N

Durch die Auswahl der Hash-Funktion und die Kombination der Spalten bestimmen Sie, wie die Zeilen über den Schlüsselbereich verteilt werden. Spanner erstellt dann Splits für die Zeilen, um die Leistung zu optimieren.

Im folgenden Diagramm wird dargestellt, wie durch die Verwendung eines Hash zum Erstellen dreier logischer Shards der Durchsatz für Schreibvorgänge gleichmäßiger auf die Server verteilt werden kann:

Nach ShardID sortierte UserAccessLog-Tabelle mit ausgeglichenem Schreibdurchsatz

In diesem Fall wird die Tabelle UserAccessLog nach ShardId sortiert, die als Hash-Funktion der Schlüsselspalten berechnet wird. Die fünf UserAccessLog-Zeilen werden in drei logische Shards aufgeteilt, die sich zufälligerweise jeweils in einem anderen Split befinden. Die Einfügungen werden gleichmäßig auf die Splits aufgeteilt. So wird auch der Durchsatz für Schreibvorgänge gleichmäßig auf die drei Server verteilt, die die Splits verarbeiten.

Mit Spanner können Sie auch eine Hash-Funktion in einer generierten Spalte erstellen.

Verwenden Sie dazu in Google SQL während des Schreibvorgangs die Funktion FARM_FINGERPrint, wie im folgenden Beispiel gezeigt:

GoogleSQL

CREATE TABLE UserAccessLog (
ShardId INT64 NOT NULL
AS (MOD(FARM_FINGERPRINT(CAST(LastAccess AS STRING)), 2048)) STORED,
LastAccess TIMESTAMP NOT NULL,
UserId    INT64 NOT NULL,
) PRIMARY KEY (ShardId, LastAccess, UserId);

Durch die Auswahl der Hash-Funktion wird festgelegt, wie gut die Einfügungen über den Schlüsselbereich verteilt werden. Sie benötigen keinen kryptografischen Hash, obwohl ein kryptografischer Hash eine gute Wahl sein könnte. Bei der Auswahl einer Hash-Funktion müssen Sie die folgenden Faktoren berücksichtigen:

  • Hotspot-Vermeidung Eine Funktion, die zu mehr Hashwerten führt, reduziert in der Regel Hotspots.
  • Leseeffizienz. Lesevorgänge in allen Hashwerten sind schneller, wenn weniger Hashwerte zu scannen sind.
  • Knotenanzahl.

Bei zeitstempelbasierten Schlüsseln absteigende Reihenfolge verwenden

Wenn Sie eine Tabelle für den Verlauf haben, die den Zeitstempel als Schlüssel verwendet, sollten Sie für die Schlüsselspalte die absteigende Reihenfolge verwenden, wenn einer der folgenden Punkte zutrifft:

  • Wenn Sie den neuesten Verlauf lesen möchten, verwenden Sie für den Verlauf eine verschränkte Tabelle und Sie lesen die übergeordnete Zeile. In diesem Fall werden bei einer DESC-Zeitstempelspalte die neuesten Verlaufseinträge neben der übergeordneten Zeile gespeichert. Andernfalls erfordert das Lesen der übergeordneten Zeile und des neusten Verlaufs einen Suchvorgang in der Mitte, um den älteren Verlauf zu überspringen.
  • Wenn Sie sequenzielle Einträge in umgekehrter chronologischer Reihenfolge lesen und nicht genau wissen, wie weit Sie zurückgehen. Sie können beispielsweise mit einer SQL-Abfrage mit einem LIMIT die neuesten N-Ereignisse abrufen oder Sie brechen möglicherweise den Lesevorgang ab, nachdem Sie eine bestimmte Anzahl von Zeilen gelesen haben. In diesen Fällen sollten Sie mit den neuesten Einträgen beginnen und nacheinander ältere Einträge lesen, bis die Bedingung erfüllt ist. Dies funktioniert bei Zeitstempelschlüsseln, die Spanner in absteigender Reihenfolge speichert, effizienter.

Fügen Sie das Schlüsselwort DESC hinzu, damit Sie den Zeitstempelschlüssel in absteigender Reihenfolge festlegen. Beispiel:

GoogleSQL

CREATE TABLE UserAccessLog (
UserId     INT64 NOT NULL,
LastAccess TIMESTAMP NOT NULL,
...
) PRIMARY KEY (UserId, LastAccess DESC);

Best Practice Nr. 2 für das Schemadesign: Die absteigende Reihenfolge oder aufsteigende Reihenfolge hängt von den Nutzerabfragen ab, z. B. ob oben die neueste oder oben die älteste zuerst steht.

Verschränkte Indexe für eine Spalte, deren Wert monoton zu- oder abnimmt, verwenden

Ähnlich wie beim vorherigen Primärschlüsselbeispiel, das Sie vermeiden sollten, ist es auch von Vorteil, nicht verschränkte Indexe für Spalten zu erstellen, deren Werte monoton zu- oder abnehmen, selbst wenn es sich nicht um Primärschlüsselspalten handelt.

Beispiel: Angenommen, Sie definieren die folgende Tabelle, in der LastAccess keine Primärschlüsselspalte ist.

GoogleSQL

CREATE TABLE Users (
UserId     INT64 NOT NULL,
LastAccess TIMESTAMP,
...
) PRIMARY KEY (UserId);

PostgreSQL

CREATE TABLE Users (
UserId     bigint NOT NULL,
LastAccess TIMESTAMPTZ,
...
PRIMARY KEY (UserId)
);

Es mag auf den ersten Blick praktisch erscheinen, einen Index für die Spalte LastAccess zu definieren, um die Nutzerzugriffe "seit dem Zeitpunkt X" schnell aus der Datenbank abrufen zu können:

GoogleSQL

CREATE NULL_FILTERED INDEX UsersByLastAccess ON Users(LastAccess);

PostgreSQL

CREATE INDEX UsersByLastAccess ON Users(LastAccess)
WHERE LastAccess IS NOT NULL;

Dies führt jedoch zum selben Problem wie in der vorherigen Best Practice beschrieben, da Spanner Indexe im Hintergrund als Tabellen implementiert und die resultierende Indextabelle eine Spalte verwendet, deren Wert als erster Schlüsselteil monoton zunimmt.

Es ist jedoch in Ordnung, einen verschachtelten Index wie diesen zu erstellen, da Zeilen von verschachtelten Indexen in entsprechenden übergeordneten Zeilen verschränkt sind und es wahrscheinlich ist, dass eine einzelne übergeordnete Zeile Tausende von Ereignissen pro Sekunde erzeugt.

Best Practice 3 für das Schemadesign: Erstellen Sie bei einer Spalte mit hoher Schreibrate, deren Wert monoton steigt oder fällt, keinen nicht verschachtelten Index. Verwenden Sie anstelle von verschachtelten Indexen Techniken wie die, die Sie beim Entwerfen von Indexspalten für das Design des Primärschlüssels der Basistabelle verwenden würden, indem Sie z. B. "shardId" hinzufügen.

Nächste Schritte