Best Practices für Schemadesign

Mit der verteilten Architektur von Spanner können Sie Ihr Schema so entwerfen, dass Hotspots vermieden werden, also Situationen, in denen zu viele Anfragen an denselben Server gesendet werden, wodurch die Ressourcen des Servers ausgelastet werden und möglicherweise hohe Latenzen auftreten.

Auf dieser Seite werden Best Practices für das Entwerfen von Schemas beschrieben, um die Erstellung von Hotspots zu vermeiden. Hotspots lassen sich beispielsweise vermeiden, indem Sie das Schemadesign so anpassen, dass Spanner die Daten auf mehrere Server aufteilen und verteilen kann. Die Verteilung von Daten auf mehrere Server trägt zu einem effizienten Betrieb Ihrer Spanner-Datenbank bei, 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 Sie nicht versehentlich Hotspots in Ihrer Datenbank erzeugen. Eine Ursache für Hotspots ist das Vorhandensein einer Spalte, deren Wert sich monoton als erster Schlüsselteil ä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 zur Aufteilung von Daten zwischen Servern verwendet. Das bedeutet, dass alle Einfügungen an einen einzigen Server geleitet werden, der die gesamte Arbeit ausführt.

Angenommen, Sie möchten eine Spalte mit dem Zeitstempel des letzten Zugriffs für Zeilen der Tabelle UserAccessLog beibehalten. In der folgenden Tabellendefinition wird ein zeitstempelbasierter Primärschlüssel als erster Schlüsselteil verwendet. Wir raten davon ab, wenn in der Tabelle eine hohe Einfügungsrate festgestellt 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 ist hier, dass Zeilen in der Reihenfolge des Zeitstempels des letzten Zugriffs in diese Tabelle geschrieben werden. Da die Zeitstempel des letzten Zugriffs immer größer werden, werden sie immer an das Ende der Tabelle geschrieben. Der Hotspot entsteht, weil ein einzelner Spanner-Server alle Schreibvorgänge empfängt, wodurch dieser Server überlastet wird.

Das folgende Diagramm veranschaulicht dieses Problem:

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 ist auch die Reihenfolge vermerkt, 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 werden und der Zeitstempelwert kontinuierlich zunimmt, fügt Spanner die Einfügungen immer am Ende der Tabelle hinzu und leitet sie zum selben Split weiter. (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. Dadurch verarbeitet der Server, der diesem Split zugewiesen ist, alle Einfügeanfragen. Je häufiger Nutzerzugriffe stattfinden, desto häufiger erhält der entsprechende Server Einfügungsanfragen. Der Server wird dann anfällig für Hotspots und sieht aus wie der rote Rand und der Hintergrund oben. Beachten Sie, dass in dieser vereinfachten Abbildung jeder Server maximal einen Split verarbeitet, in Wirklichkeit aber Spanner jedem Server mehr als einen Split zuweisen kann.

Wenn Spanner weitere Zeilen an die Tabelle anhängt, 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. 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 Version 1-UUIDs ab, da diese den Zeitstempel in den 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 automatisch UUID-Werte 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 jedoch nicht.
  • Da die Lokalität zwischen verwandten Einträgen verloren geht, werden durch die Verwendung einer UUID Hotspots vermieden.

Bit-Umkehrungen für sequenzielle Werte

Achten Sie darauf, dass die numerischen Primärschlüssel (INT64 in GoogleSQL oder bigint in PostgreSQL) nicht sequenziell zu- oder abnehmen. Sequenzielle Primärschlüssel können in großem Maßstab zu Heißlaufen führen. Eine Möglichkeit, dieses Problem zu vermeiden, besteht darin, die sequenziellen Werte per Bit-Umkehrung zu kehren. Achten Sie dabei darauf, dass die Primärschlüsselwerte gleichmäßig über den Schlüsselbereich verteilt werden.

Spanner unterstützt eine bitumgekehrte Sequenz, die eindeutige Bitumkehrwerte nach Ganzzahl generiert. Sie können eine Sequenz in der ersten (oder einzigen) Komponente in einem Primärschlüssel verwenden, um Heißlaufensprobleme zu vermeiden. Weitere Informationen finden Sie unter Bitumkehrte 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 vertauschen, dass die Spalte mit dem monotonen Wert 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.

Im Folgenden sehen Sie die fünf Zeilen aus der Tabelle UserAccessLog, die Spanner mit UserId anstelle des Zugriffszeitstempels sortiert:

Nach UserId sortierte UserAccessLog-Tabelle mit ausgeglichenem Schreibdurchsatz

Hier teilt Spanner die UserAccessLog-Daten in drei Splits auf, wobei jeder Split ungefähr 1.000 Zeilen geordneter UserId-Werte enthält. Dies ist eine vernünftige Schätzung, wie die Nutzerdaten aufgeteilt werden könnten. Dabei wird davon ausgegangen, dass jede Zeile etwa 1 MB Nutzerdaten enthält und eine maximale Split-Größe von etwa 8 GB gegeben ist. Obwohl die Nutzerereignisse etwa eine Millisekunde auseinander traten, wurde jedes Ereignis von einem anderen Nutzer ausgelöst. Daher ist es viel weniger wahrscheinlich, dass bei der Reihenfolge der Einfügungen ein Hotspot entsteht, als wenn der Zeitstempel für die Sortierung verwendet wurde.

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 werden die Shards von den Daten in der Tabelle definiert. 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 zum Berechnen von ShardId eine Kombination der Primärschlüsselspalten und berechnen Sie dann den Modulo N des Hash. 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 der Schreibzeit 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);

Die ausgewählte Hash-Funktion bestimmt, wie gut die Einfügungen über den Schlüsselbereich verteilt werden. Sie benötigen keinen kryptografischen Hash, obwohl ein kryptografischer Hash unter Umständen eine gute Wahl sein könnte. Beim Auswählen 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, in der der Zeitstempel als Schlüssel verwendet wird, sollten Sie die Schlüsselspalte absteigend sortieren, wenn eine der folgenden Bedingungen zutrifft:

  • Wenn Sie den neuesten Verlauf lesen möchten, verwenden Sie eine verschränkte Tabelle für den Verlauf und 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 sequenziell ältere Einträge lesen, bis Ihre Bedingung erfüllt ist. Spanner ist dann effizienter für Zeitstempelschlüssel, die Spanner in absteigender Reihenfolge speichert.

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: Absteigende oder aufsteigende Reihenfolge hängt von den Nutzerabfragen ab, z. B. ist „Top“ die neueste oder „Top“ die Älteste.

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 nicht sinnvoll, nicht verschränkte Indexe für Spalten zu erstellen, deren Werte monoton zu- oder abnehmen, auch 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 gleichen Problem wie in der vorherigen Best Practice beschrieben, da Spanner Indexe als Tabellen implementiert und die resultierende Indextabelle eine Spalte verwendet, deren erster Schlüsselteil monoton zunimmt.

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

Best Practice 3 für das Schemadesign: Erstellen Sie keinen nicht verschränkten Index für eine Spalte mit hoher Schreibrate, deren Wert monoton zu- oder abnimmt. Verwenden Sie anstelle von verschränkten Indexen Techniken wie die, die Sie beim Entwerfen von Indexspalten für das Design des Primärschlüssels der Basistabelle verwenden würden, z. B. `shardId` hinzufügen.

Nächste Schritte