Best Practices für Schemadesign

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

Auf dieser Seite werden Best Practices für das Entwerfen von Schemas beschrieben, mit denen Hotspots vermieden werden können. Eine Möglichkeit, Hotspots zu vermeiden, besteht darin, das Schema 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 bei Bulk-Dateneinfügungen.

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

Wie unter Schema und Datenmodell beschrieben, sollten Sie bei der Auswahl eines Primärschlüssels im Schemadesign vorsichtig vorgehen, damit Sie nicht versehentlich Hotspots in der Datenbank erzeugen. Hotspots können entstehen, wenn Sie eine Spalte auswählen, in der der Wert des ersten Schlüsselteils monoton zunimmt. Dies führt dazu, dass alle Einfügungen am Ende des Schlüsselbereichs vorgenommen werden. Dieses Muster ist nicht wünschenswert, weil Spanner Schlüsselbereiche verwendet, um Daten auf Server aufzuteilen. Das bedeutet, dass alle Einfügungen an einen einzelnen Server gerichtet werden, der die gesamte Arbeit erledigt.

Angenommen, Sie möchten eine Spalte mit dem Zeitstempel des letzten Zugriffs für Zeilen der Tabelle UserAccessLogs beibehalten. Bei der folgenden Tabellendefinition wird ein auf einem Zeitstempel basierender Primärschlüssel im ersten Schlüsselteil verwendet. Wir empfehlen dies nicht, wenn in die Tabelle häufig Daten eingefügt werden:

GoogleSQL

CREATE TABLE UserAccessLogs (
  LastAccess TIMESTAMP NOT NULL,
  UserId STRING(1024),
  ...
) PRIMARY KEY (LastAccess, UserId);

PostgreSQL

CREATE TABLE useraccesslogs (
  lastaccess timestamptz NOT NULL,
  userid text,
  ...
PRIMARY KEY (lastaccess, userid)
);

Das Problem liegt hier darin, dass die Zeilen in der Reihenfolge des Zeitstempels des letzten Zugriffs in die Tabelle geschrieben werden. Da die Zeitstempel des letzten Zugriffs sich stetig erhöhen, werden sie immer an das Ende der Tabelle geschrieben. Der Hotspot entsteht dadurch, dass ein einzelner Spanner-Server alle Schreibvorgänge erhält und somit überlastet wird.

Das folgende Diagramm veranschaulicht diese Problematik:

Nach Zeitstempel sortierte UserAccessLog-Tabelle mit entsprechendem Hotspot

Die vorherige Tabelle UserAccessLogs 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. Aus dem Diagramm geht auch die Reihenfolge hervor, in der Spanner die Zeilen einfügt (die beschrifteten Pfeile geben die Reihenfolge der Schreibvorgänge für die Zeilen an). Da die Einfügungen nach Zeitstempel sortiert werden und der Zeitstempelwert stetig zunimmt, werden die Einfügungen immer am Ende der Tabelle vorgenommen und demselben Split zugewiesen. Wie unter Schema und Datenmodell erläutert, besteht ein Split aus einer Reihe von Zeilen aus einer oder mehreren verbundenen Tabellen, die in der Reihenfolge des Zeilenschlüssels gespeichert werden.

Problematisch ist dabei, dass Spanner verschiedenen Servern Arbeit in Split-Einheiten zuweist, sodass der diesem Split zugewiesene Server alle Einfügungsanfragen alleine verarbeitet. Je häufiger Nutzerzugriffe stattfinden, desto häufiger erhält der entsprechende Server Einfügungsanfragen. Der Server läuft dann Gefahr, zu einem Hotspot zu werden, was durch den roten Rahmen und Hintergrund im vorherigen Bild verdeutlicht wird. In dieser vereinfachten Abbildung verarbeitet jeder Server höchstens einen Split. Spanner kann jedem Server jedoch mehr als einen Split zuweisen.

Wenn Spanner weitere Zeilen an die Tabelle anhängt, wird der Split größer und es werden nach Bedarf neue Splits erstellt. Weitere Informationen zum Erstellen von Splits finden Sie unter Lastbasiertes Aufteilen. 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 Version 4 der UUID, da bei dieser Version in der Bitsequenz zufällige Werte verwendet werden. Wir empfehlen keine UUIDs der Version 1, da bei diesen der Zeitstempel in den Bits höherer Ordnung gespeichert wird.

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 Standardwert für die Spalte verwenden, damit Spanner automatisch UUID-Werte generiert.

Beispiel:

GoogleSQL

CREATE TABLE UserAccessLogs (
  LogEntryId STRING(36) NOT NULL,
  LastAccess TIMESTAMP NOT NULL,
  UserId STRING(1024),
  ...
) PRIMARY KEY (LogEntryId, LastAccess, UserId);

PostgreSQL

CREATE TABLE useraccesslogs (
  logentryid VARCHAR(36) NOT NULL,
  lastaccess timestamptz NOT NULL,
  userid text,
  ...
PRIMARY KEY (lastaccess, userid)
);

Sie können GENERATE_UUID() einfügen, um die LogEntryId-Werte zu generieren. GENERATE_UUID() gibt einen STRING-Wert zurück. Daher muss für die Spalte LogEntryId der Typ STRING für GoogleSQL oder der Typ text für PostgreSQL verwendet werden.

GoogleSQL

INSERT INTO
  UserAccessLogs (LogEntryId, LastAccess, UserId)
VALUES
  (GENERATE_UUID(), '2016-01-25 10:10:10.555555-05:00', 'TomSmith');

PostgreSQL

INSERT INTO
  useraccesslogs (logentryid, lastaccess, userid)
VALUES
  (spanner.generate_uuid(),'2016-01-25 10:10:10.555555-05:00', 'TomSmith');

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. Zum Beispiel hat ein Primärschlüssel aus SingerId und AlbumId im Gegensatz zu einer UUID eine inhärente Bedeutung.
  • Die Lokalität zwischen Datensätzen, die sich aufeinander beziehen, geht verloren. Daher können durch die Nutzung von UUIDs Hotspots vermieden werden.

Bit-Umkehrungen für sequenzielle Werte

Sie sollten darauf achten, dass numerische Primärschlüssel (INT64 in GoogleSQL oder bigint in PostgreSQL) nicht sequenziell auf- oder absteigend sind. Sequenzielle Primärschlüssel können bei großem Umfang zu Hotspots führen. Eine Möglichkeit, dieses Problem zu vermeiden, besteht darin, die sequenziellen Werte bitweise umzukehren, um die Primärschlüsselwerte gleichmäßig über den Schlüsselbereich zu verteilen.

Spanner unterstützt Bit-Umkehr-Sequenzen, mit denen eindeutige, bitweise umgekehrte Ganzzahlwerte generiert werden. Sie können eine Sequenz in der ersten (oder einzigen) Komponente eines Primärschlüssels verwenden, um Hotspot-Probleme zu vermeiden. Weitere Informationen finden Sie unter Bit-reversed sequence (Bit-umgekehrte Sequenz).

Schlüsselreihenfolge vertauschen

Eine Methode zum gleichmäßigeren Verteilen der Schreibvorgänge über den Schlüsselbereich besteht darin, die Reihenfolge der Schlüssel so zu ändern, dass die Spalte mit dem monotonen Wert nicht der erste Schlüsselteil ist:

GoogleSQL

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

PostgreSQL

CREATE TABLE useraccesslogs (
userid bigint NOT NULL,
lastaccess TIMESTAMPTZ 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. Mit diesem Schema werden die Schreibvorgänge auf unterschiedliche Splits verteilt, da es unwahrscheinlich ist, dass ein einzelner Nutzer Tausende von Ereignissen pro Sekunde erzeugt.

Das folgende Bild zeigt die fünf Zeilen aus der Tabelle UserAccessLogs, die Spanner nach UserId anstelle des Zeitstempels des Zugriffs sortiert hat:

Nach UserId sortierte UserAccessLogs-Tabelle mit ausgeglichenem Schreibdurchsatz

Hier teilt Spanner die UserAccessLogs-Daten möglicherweise in drei Splits auf, wobei jeder Split etwa tausend Zeilen mit sortierten UserId-Werten enthält. Die Nutzerereignisse liegen zwar nur etwa eine Millisekunde auseinander, jedes Ereignis wurde jedoch von einem anderen Nutzer ausgelöst. Deshalb ist es im Vergleich zu der Sortierung nach Zeitstempel wesentlich unwahrscheinlicher, dass die Reihenfolge der Einfügungen zu einem Hotspot führt. Weitere Informationen zum Erstellen von Splits finden Sie unter Lastbasierte Aufteilung.

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 durch die Daten in der Tabelle definiert. Wenn Sie zum Beispiel Schreibvorgänge in die Tabelle UserAccessLogs auf N logische Shards verteilen möchten, fügen Sie am Anfang der Tabelle eine Schlüsselspalte ShardId ein:

GoogleSQL

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

PostgreSQL

CREATE TABLE useraccesslogs (
shardid bigint NOT NULL,
lastaccess TIMESTAMPTZ NOT NULL,
userid bigint NOT NULL,
...
PRIMARY KEY (shardid, lastaccess, userid)
);

Zum Berechnen der ShardId hashen Sie eine Kombination der Primärschlüsselspalten und berechnen dann den Modulo N des Hashs. Beispiel:

GoogleSQL

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-Werts zum Erstellen dreier logischer Shards der Durchsatz für Schreibvorgänge gleichmäßiger auf die Server verteilt werden kann:

Nach ShardID sortierte UserAccessLogs-Tabelle mit ausgeglichenem Schreibdurchsatz

In diesem Fall wird die Tabelle UserAccessLogs nach ShardId sortiert, die als Hash-Funktion der Schlüsselspalten berechnet wird. Die fünf UserAccessLogs-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 GoogleSQL die Funktion FARM_FINGERPRINT zur Schreibzeit, wie im folgenden Beispiel gezeigt:

GoogleSQL

CREATE TABLE UserAccessLogs (
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 Auswählen der Hash-Funktion bestimmen Sie, wie gut die Einfügungen über den Schlüsselbereich verteilt werden. Ein kryptografischer Hash ist hier nicht zielführend, kann in einem anderen Fall aber durchaus geeignet sein. Beim Auswählen einer Hash-Funktion müssen Sie die folgenden Faktoren berücksichtigen:

  • Hotspots vermeiden. 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 für Ihren Verlauf eine Tabelle mit Zeitstempel als Schlüssel haben, sollten Sie eine absteigende Reihenfolge für die Schlüsselspalte in Betracht ziehen, wenn eine der folgenden Bedingungen zutrifft:

  • Wenn Sie den neuesten Verlauf lesen möchten, eine verschachtelte Tabelle für den Verlauf verwenden und die übergeordnete Zeile lesen. 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 möchten Sie mit den neuesten Einträgen beginnen und sequenziell ältere Einträge lesen, bis die Bedingung erfüllt ist. Spanner ist bei Zeitstempelschlüsseln, die in absteigender Reihenfolge gespeichert sind, effizienter.

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

GoogleSQL

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

Best Practice 2 für das Schemadesign: Die absteigende oder aufsteigende Reihenfolge hängt von den Nutzeranfragen ab, z. B. ob oben die neuesten oder die ältesten Ergebnisse angezeigt werden sollen.

Wann sollte ein verschachtelter Index verwendet werden?

Ähnlich wie beim vorherigen Beispiel für Primärschlüssel, das Sie vermeiden sollten, ist es auch keine gute Idee, nicht verschränkte Indexe für Spalten mit monoton zu- oder abnehmenden Werten zu erstellen, selbst wenn sie keine Primärschlüsselspalten sind.

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, das in der vorherigen Best Practice beschrieben wurde, da Spanner Indexe als verkappte Tabellen implementiert und die resultierende Indextabelle eine Spalte verwendet, deren erster Schlüsselteil monoton zunimmt.

Es ist in Ordnung, einen verschachtelten Index zu erstellen, in dem die Zeilen für den letzten Zugriff in der entsprechenden Nutzerzeile verschachtelt werden. Es ist unwahrscheinlich, dass eine einzelne übergeordnete Zeile Tausende von Ereignissen pro Sekunde erzeugt.

GoogleSQL

CREATE NULL_FILTERED INDEX UsersByLastAccess
ON Users(UserId, LastAccess),
INTERLEAVE IN Users;

PostgreSQL

CREATE INDEX usersbylastaccess ON users(userid, lastaccess)
WHERE lastaccess IS NOT NULL,
INTERLEAVE IN Users;

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 einen verschränkten Index oder Techniken, die Sie auch für den Primärschlüssel der Basistabelle verwenden würden, wenn Sie Indexspalten entwerfen, z. B. `shardId`.

Nächste Schritte