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 ausgelastet werden und es zu hohen Latenzen kommen kann.

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 Schemadesign so anzupassen, dass Spanner die Daten auf mehrere Server aufteilen und verteilen kann. Wenn Sie Daten auf mehrere Server verteilen, kann Ihre Spanner-Datenbank effizienter betrieben werden, 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 Ihres Schlüsselbereichs vorgenommen werden. Dieses Muster ist nicht wünschenswert, da Spanner die Daten mithilfe von Schlüsselbereichen auf die Server verteilt. Das bedeutet, dass alle Ihre Einfügungen an einen einzelnen Server gerichtet werden, der die gesamte Arbeitslast bewältigen muss.

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 auf einem Zeitstempel basierender Primärschlüssel als erster 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 useraccesslog (
  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 Falle:

Nach Zeitstempel sortierte UserAccessLog-Tabelle mit entsprechendem Hotspot

Die vorherige 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. Aus dem Diagramm geht auch die Reihenfolge, in der Spanner die Zeilen einfügt, hervor (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 Spanner in der Reihenfolge des Zeilenschlüssels gespeichert werden.

Das ist problematisch, weil 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 jedoch jedem Server mehr als einen Split zuweisen.

Je mehr Zeilen an die Tabelle angehängt werden, desto größer wird der Split. Wenn die Größe etwa 8 GB erreicht hat, wird von Spanner ein neuer Split erstellt, wie unter Lastbasierte Aufteilung beschrieben. Spanner hängt diesem neuen Split nachfolgende neue Zeilen an und der dem Split zugewiesene Server 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 ihnen 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 der 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 useraccesslog (
  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
  UserAccessLog (LogEntryId, LastAccess, UserId)
VALUES
  (GENERATE_UUID(), '2016-01-25 10:10:10.555555-05:00', 'TomSmith');

PostgreSQL

INSERT INTO
  useraccesslog (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

Achten Sie darauf, dass numerische Primärschlüssel (INT64 in GoogleSQL oder bigint in PostgreSQL) nicht sequenziell steigen oder fallen. Sequenzielle Primärschlüssel können im großen Maßstab zu Hotspots führen. Eine Möglichkeit, dieses Problem zu vermeiden, besteht darin, die sequenziellen Werte zu bitumkehren und die Primärschlüsselwerte gleichmäßig über den Schlüsselbereich zu verteilen.

Spanner unterstützt Bit-Umkehr-Sequenzen, mit denen eindeutige Ganzzahlwerte in Bit-Umkehr 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 Bitweise 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 mit dem monoton steigenden Wert nicht der erste Schlüsselteil ist:

GoogleSQL

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

PostgreSQL

CREATE TABLE useraccesslog (
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.

Die folgende Abbildung zeigt die fünf Zeilen aus der Tabelle UserAccessLog, die Spanner nach UserId anstelle des Zeitstempels des Zugriffs sortiert:

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 gute Schätzung, wie Nutzerdaten aufgeteilt werden können. Jede Zeile enthält etwa 1 MB Nutzerdaten und die maximale Split-Größe beträgt etwa 8 GB. Die Nutzerereignisse liegen zwar nur etwa eine Millisekunde auseinander, jedes Ereignis wurde jedoch von einem anderen Nutzer ausgelöst. Deshalb ist es im Vergleich zur Sortierung nach Zeitstempel wesentlich unwahrscheinlicher, dass die Reihenfolge der Einfügungen zu einem Hotspot führt.

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

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 Fragmentierungen 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.

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

Verwenden Sie dazu in GoogleSQL die Funktion FARM_FINGERPRINT während des Schreibvorgangs, 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 Auswählen der Hash-Funktion bestimmen Sie, wie gut die Einfügungen über den Schlüsselbereich verteilt werden. Ein kryptografischer Hash-Wert wird nicht benötigt, 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.
  • Leseffizienz. 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 letzten Verlauf lesen möchten, verwenden Sie für den Verlauf eine verschachtelte Tabelle 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 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 in absteigender Reihenfolge 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 2 für das Schemadesign: Ob die Reihenfolge absteigend oder aufsteigend ist, hängt von den Nutzeranfragen ab. Beispiel: Die obersten Einträge sind die neuesten oder die ältesten.

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

Ähnlich wie beim vorherigen Beispiel für Primärschlüssel, das Sie vermeiden sollten, ist es 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 Indexe in Spanner als Tabellen implementiert werden und die resultierende Indextabelle eine Spalte verwendet, deren Wert als erster Schlüsselteil monoton zunimmt.

Es ist dagegen in Ordnung, einen verschachtelten Index nach diesem Muster zu erstellen, da die Zeilen von verschachtelten Indexen in den entsprechenden übergeordneten Zeilen verschachtelt werden. Es ist unwahrscheinlich, dass eine einzige ü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 verschachtelten Indexen Techniken, die Sie auch für den Primärschlüssel der Basistabelle verwenden würden, wenn Sie Indexspalten entwerfen. Fügen Sie beispielsweise „shardId“ hinzu.

Nächste Schritte