Prácticas recomendadas sobre el diseño del esquema

La arquitectura distribuida de Spanner te permite diseñar tu esquema para evitar hotspots , es decir, situaciones en las que se envían demasiadas solicitudes al mismo servidor, lo que satura los recursos del servidor y puede causar latencias altas.

En esta página, se describen las prácticas recomendadas para diseñar tus esquemas y evitar la creación de hotspots. Una forma de evitar los hotspots es ajustar el diseño del esquema para permitir que Spanner divida y distribuya los datos en varios servidores. La distribución de datos en los servidores ayuda a que tu base de datos de Spanner funcione de manera eficiente, en especial cuando se realizan inserciones de datos masivas.

Spanner detecta automáticamente las oportunidades para aplicar las prácticas recomendadas de diseño de esquemas. Si hay recomendaciones disponibles para una base de datos, puedes verlas en la página Spanner Studio de esa base de datos. Para obtener más información, consulta Cómo ver las recomendaciones de prácticas recomendadas de diseño de esquemas.

Elige una clave primaria para evitar los hotspots.

Para evitar la creación de hotspots en tu base de datos, cuidadosamente elige una clave primaria durante el diseño del esquema.

Una causa común de los hotspots es usar una clave que aumente o disminuya de forma monotónica, como una marca de tiempo. Las claves monotónicas hacen que todas las entradas nuevas se escriban en el mismo rango de tu espacio de claves. Debido a que Spanner usa rangos de claves para distribuir datos en los servidores, una clave monotónica dirige todo el tráfico de inserción a un solo servidor, lo que crea un cuello de botella.

Por ejemplo, supongamos que deseas mantener una última columna de marca de tiempo de acceso en filas de la tabla UserAccessLogs. La siguiente definición de tabla usa una clave primaria basada en la marca de tiempo como la primera parte de la clave. No recomendamos esto si la tabla tiene una tasa de inserción alta:

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

El problema es que las filas se escriben en esta tabla en el orden de la marca de tiempo de último acceso y, debido a que las marcas de tiempo de último acceso siempre están en aumento, siempre se escriben al final de la tabla. El hotspot se crea porque un único servidor de Spanner recibe todas las escrituras, lo que sobrecarga a ese servidor único.

En el siguiente diagrama, se ilustra este inconveniente:

Tabla UserAccessLog ordenada por marca de tiempo con el hotspot correspondiente

La tabla UserAccessLogs anterior incluye cinco filas de datos de ejemplo, que representan cinco usuarios diferentes que realizan algún tipo de acción del usuario a un milisegundo de diferencia. En el diagrama, también se indica el orden en el que Spanner inserta las filas (las flechas etiquetadas indican el orden de las escrituras para cada fila). Debido a que las inserciones se ordenan por marca de tiempo, y el valor de la marca de tiempo siempre aumenta, Spanner siempre agrega las inserciones al final de la tabla y las dirige a la misma división. (Como se explica en Esquema y modelo de datos, una división es un conjunto de filas de una o más tablas relacionadas que Spanner almacena por orden de clave de fila).

Esto es problemático porque Spanner asigna trabajo a diferentes servidores en unidades de divisiones, por lo que el servidor asignado a esta división en particular termina administrando todas las solicitudes de inserción. A medida que aumenta la frecuencia de los eventos de acceso de los usuarios, también aumenta la frecuencia de las solicitudes de inserción en el servidor correspondiente. El servidor se vuelve propenso a convertirse en un hotspot y se ve como el borde y el fondo rojos que se muestran en la imagen anterior. En esta ilustración simplificada, cada servidor controla como máximo una división, pero Spanner puede asignar a cada servidor más de una división.

Cuando Spanner agrega más filas a la tabla, la división crece y, luego, Spanner crea divisiones nuevas según sea necesario. Para obtener más información sobre cómo se crean las divisiones, consulta División basada en la carga. Spanner agrega las filas nuevas subsiguientes a esta división nueva y el servidor que se le asigna se convierte en el hotspot potencial nuevo.

Cuando se producen hotspots, es posible que adviertas que las operaciones de inserción tarden demasiado y que otros trabajos en el mismo servidor se ralenticen. Cambiar el orden de la columna LastAccess a ascendente no resuelve este problema porque todas las escrituras se insertan en la parte superior de la tabla, lo que aún envía todas las inserciones a un solo servidor.

Práctica recomendada de diseño de esquemas n.º 1: no elijas una columna cuyo valor aumente o disminuya monótonamente como la primera parte clave de una tabla con una tasa de escritura alta.

Usa un identificador único universal (UUID)

Puedes usar un identificador único universal (UUID), según se define en RFC 9562 como la clave primaria. Recomendamos usar el UUID de la versión 4 , porque usa valores aleatorios en la secuencia de bits. No recomendamos los UUID de la versión 1 porque almacenan la marca de tiempo en los bits de orden superior. Puedes almacenar valores de UUID de la versión 4 en una columna UUID en Spanner.

Ten en cuenta lo siguiente antes de decidir usar UUIDs:

  • Funcionan de forma independiente del contenido del registro. A diferencia de las claves semánticas, como SingerId y AlbumId, un UUID es estrictamente un identificador único que no está relacionado con los datos en sí.
  • No mantienen la localidad entre los registros relacionados, por lo que el uso de un UUID elimina los hotspots.

Para una columna UUID, puedes usar la función NEW_UUID() de GoogleSQL de Spanner o la función gen_random_uuid() de PostgreSQL para crear valores de UUID.

Por ejemplo, para la siguiente tabla:

GoogleSQL

  CREATE TABLE UserAccessLogs (
    LogEntryId UUID DEFAULT (NEW_UUID()),
    LastAccess TIMESTAMP NOT NULL,
    UserId STRING(1024)
  ) PRIMARY KEY (LogEntryId);

PostgreSQL

  CREATE TABLE useraccesslogs (
    logentryid uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    lastaccess timestamptz NOT NULL,
    userid text);

Puedes usar la función UUID generada para crear valores LogEntryId nuevos.

GoogleSQL

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

PostgreSQL

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

Para una columna UUID, puedes usar la función NEW_UUID() de GoogleSQL de Spanner o la función gen_random_uuid() de PostgreSQL como el valor predeterminado de la columna para que Spanner genere automáticamente valores de UUID.

Por ejemplo, para la siguiente tabla:

GoogleSQL

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

PostgreSQL

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

Puedes insertar NEW_UUID() de GoogleSQL o gen_random_uuid() de PostgreSQL para generar los valores LogEntryId. Estas funciones producen un valor UUID, por lo que la columna LogEntryId debe usar el tipo UUID para GoogleSQL o PostgreSQL.

GoogleSQL

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

PostgreSQL

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

También puedes insertar valores de UUID que generaste en otro lugar, como tu aplicación de backend. Esto se debe a que los UUID son únicos, independientemente de dónde se generen.

GoogleSQL

INSERT INTO
  UserAccessLogs (LogEntryId, LastAccess, UserId)
VALUES
  ('4192bff0-e1e0-43ce-a4db-912808c32493', '2016-01-25 10:10:10.555555-05:00', 'TomSmith');

PostgreSQL

INSERT INTO
  useraccesslogs (logentryid, lastaccess, userid)
VALUES
  ('4192bff0-e1e0-43ce-a4db-912808c32493','2016-01-25 10:10:10.555555-05:00', 'TomSmith');

Revierte los bits de los valores secuenciales

Debes verificar que las claves primarias numéricas (INT64 en GoogleSQL o bigint en PostgreSQL) no aumenten ni disminuyan de forma secuencial. Las claves primarias secuenciales pueden causar hotspots a gran escala. Una forma de evitar este problema es revertir los bits de los valores secuenciales y asegurarse de distribuir los valores de clave primaria de manera uniforme en el espacio de claves.

Spanner admite la secuencia de bits invertida, que genera valores únicos de números enteros con bits invertidos. Puedes usar una secuencia en el primer (o único) componente de una clave primaria para evitar problemas de hotspots. Para obtener más información, consulta Secuencia de bits invertida.

Intercambia el orden de las claves

Una manera de distribuir escrituras en el espacio de clave de manera más uniforme es cambiar el orden de las claves para que la columna que contiene el valor monotónico no sea la primera parte de la clave:

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

En este esquema modificado, las inserciones ahora se ordenan por UserId, en lugar de por marca de tiempo de último acceso cronológico. En este esquema, se distribuyen las escrituras entre las diferentes divisiones porque es poco probable que un usuario produzca miles de eventos por segundo.

En la siguiente imagen, se muestran las cinco filas de la tabla UserAccessLogs que Spanner ordena con UserId en lugar de la marca de tiempo de acceso:

Tabla UserAccessLogs ordenada por UserId con capacidad de procesamiento de escritura equilibrada

Aquí, Spanner puede dividir los datos de UserAccessLogs en tres divisiones, y cada división contiene aproximadamente mil filas de valores UserId ordenados. Aunque los eventos del usuario ocurrieron con un milisegundo de diferencia, un usuario distinto generó cada evento, por lo que es menos probable que el orden de las inserciones cree un hotspot en comparación con el uso de la marca de tiempo para el ordenamiento. Para obtener más información sobre cómo se crean las divisiones, consulta División basada en la carga

Consulta también las prácticas recomendadas para ordenar claves basadas en marca de tiempo.

Genera un hash de la clave única y distribuye las escrituras en fragmentos lógicos

Otra técnica común para distribuir la carga en varios servidores es crear una columna que contenga el hash de la clave única real y, luego, usar la columna de hash (o la columna de hash más las columnas de la clave única) como clave primaria. Con este patrón, se evitan los hotspots, ya que las filas nuevas se distribuyen de manera más uniforme en el espacio de claves.

Puedes usar el valor de hash para crear fragmentos lógicos, o particiones, en tu base de datos. En una base de datos fragmentada de forma física, las filas se distribuyen en varios servidores de bases de datos. En una base de datos fragmentada de forma lógica, los datos de la tabla definen los fragmentos. Por ejemplo, para distribuir las escrituras en la tabla UserAccessLogs en N fragmentos lógicos, puedes anteponer una columna de clave ShardId a la tabla:

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

Para calcular el ShardId, genera un hash de una combinación de las columnas de clave primaria y, luego, calcula el módulo N del hash. Por ejemplo:

GoogleSQL

ShardId = hash(LastAccess and UserId) % N

La combinación de columnas y la función del hash determinan cómo se distribuyen las filas en el espacio de claves. A continuación, Spanner creará divisiones en las filas para optimizar el rendimiento.

En el siguiente diagrama, se ilustra cómo usar un hash para crear tres fragmentos lógicos puede distribuir la capacidad de procesamiento de escritura de manera más uniforme en los servidores:

Tabla UserAccessLogs ordenada por ShardId con capacidad de procesamiento de escritura equilibrada

Aquí, la tabla UserAccessLogs está ordenada por ShardId, que se calcula como una función hash de las columnas de claves. Las cinco filas UserAccessLogs se parten en tres fragmentos lógicos, cada uno de los cuales se encuentra casualmente en una división diferente. Las inserciones se distribuyen de manera uniforme entre las divisiones, lo que equilibra la capacidad de procesamiento de escritura en los tres servidores que administran las divisiones.

Spanner también te permite crear una función hash en una columna generada.

Para hacerlo en GoogleSQL, usa la FARM_FINGERPRINT durante el tiempo de escritura, como se muestra en el siguiente ejemplo:

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

La función de hash elegida determinará el nivel de distribución de las inserciones en el rango de claves. No necesitas un hash criptográfico, aunque contar con uno puede ser una buena opción. Cuando eliges una función de hash, debes tener en cuenta los siguientes factores:

  • Evitar hotspots. Una función que genera más valores de hash suele reducir los hotspots.
  • Eficacia de la lectura. Las lecturas en todos los valores de hash son más rápidas si hay menos valores de hash para analizar.
  • Recuento de nodos

Cuando uses un ShardId para evitar hotspots, sigue estos lineamientos para elegir el valor de N, la cantidad de fragmentos lógicos:

  • Correlaciona N con la cantidad de nodos: establece N para que sea igual a la cantidad de nodos que esperas que tenga tu instancia. Por ejemplo, si esperas que tu instancia escale verticalmente hasta 10 nodos, un valor de N=10 es un punto de partida eficaz. Esto ayuda a Spanner a distribuir la carga de escritura de manera uniforme en los nodos.

  • N es un valor estático: cambiar N después de la configuración inicial requiere una actualización del esquema y, posiblemente, un reabastecimiento de datos. Por lo tanto, debes elegir un valor para N que pueda adaptarse a tus necesidades de escalamiento.

  • Evita valores excesivamente grandes para N: si bien puede ser tentador elegir un valor muy grande para N para prepararse para el crecimiento, por lo general, no es necesario. Más fragmentos que los servidores físicos no mejorarán el rendimiento de manera significativa en comparación con el costo adicional de Spanner. Alinear N con la cantidad de nodos es una estrategia eficaz para distribuir la carga de trabajo.

Usa el orden descendente para las claves basadas en la marca de tiempo

Si tienes una tabla para tu historial que usa la marca de tiempo como clave, considera usar el orden descendente en la columna de claves si se cumple alguna de las siguientes condiciones:

  • Si deseas leer el historial más reciente, usas una tabla intercalada para el historial y lees la fila superior. En este caso, con una columna de marca de tiempo DESC, las entradas del historial más recientes se almacenan junto a la fila superior. De lo contrario, la lectura de la fila principal y su historial reciente requerirá una búsqueda por el medio para omitir el historial anterior.
  • Si lees entradas secuenciales en orden cronológico inverso y no sabes con exactitud qué tanto tiempo para atrás debes ir. Por ejemplo, puedes usar una consulta en SQL con un LIMIT para obtener los N eventos más recientes o cancelar la lectura después de leer una cierta cantidad de filas. En estos casos, debes comenzar con las entradas más recientes y leer las entradas más antiguas de forma secuencial hasta que se cumpla tu condición, lo que Spanner hace de forma más eficiente para claves de marca de tiempo almacenadas en orden descendente.

Agrega la palabra clave DESC para hacer que la clave de la marca de tiempo sea descendente. Por ejemplo:

GoogleSQL

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

Práctica recomendada sobre el diseño de esquemas n.º 2: el orden descendente o ascendente depende de las consultas del usuario, por ejemplo, la parte superior es la más reciente o la más antigua.

Cuándo usar un índice intercalado

Al igual que el ejemplo de clave primaria anterior que debes evitar, también es una mala idea crear índices no intercalados en columnas con valores que aumentan o disminuyen de forma monotónica, incluso si no son columnas de clave primaria.

Por ejemplo, supongamos que defines la siguiente tabla, en la que LastAccess es una columna que no es de clave primaria:

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

Puede parecer conveniente definir un índice en la columna LastAccess para consultar con rapidez en la base de datos los accesos de usuarios “desde la hora X”, de la siguiente manera:

GoogleSQL

CREATE NULL_FILTERED INDEX UsersByLastAccess ON Users(LastAccess);

PostgreSQL

CREATE INDEX usersbylastaccess ON users(lastaccess)
WHERE lastaccess IS NOT NULL;

Sin embargo, esto genera la misma dificultad que se describió en la práctica recomendada anterior, porque Spanner implementa los índices como tablas internas y la tabla de índice resultante usa una columna cuyo valor aumenta de forma monotónica como la primera parte de la clave.

Está bien crear un índice intercalado en el que las filas de último acceso se intercalen en la fila de usuario correspondiente. Esto se debe a que es poco probable que una fila superior produzca miles de eventos por segundo.

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;

Práctica recomendada sobre el diseño de esquema n.º 3: no crees un índice no intercalado en una columna de tasa de escritura alta cuyo valor aumente o disminuya de forma monotónica. Usa un índice intercalado o técnicas como las que usarías para el diseño de clave primaria de la tabla base cuando diseñes columnas de índice, por ejemplo, agrega `shardId`.

¿Qué sigue?