Recomendaciones sobre el diseño del esquema

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

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 entre servidores ayuda a que tu base de datos de Spanner funcione de forma eficiente, en especial, cuando se realizan inserciones de datos.

Elige una clave primaria para evitar los hotspots

Como se menciona en Esquema y modelo de datos, debes tener cuidado cuando eliges una clave primaria en el diseño del esquema para no crear hotspots de forma accidental en tu base de datos. Una de las causas de los hotspots es tener una columna cuyo valor cambia de forma monótona como la primera parte de la clave, ya que esto da como resultado que todas las inserciones ocurran al final del espacio de claves. Este patrón no es recomendable porque Spanner usa intervalos de claves para dividir los datos entre servidores, lo que significa que todas tus inserciones se dirigen a un solo servidor que hace todo el trabajo.

Por ejemplo, supongamos que quieres mantener una columna de marca de tiempo de último acceso en las filas de la tabla UserAccessLog. La siguiente definición de tabla usa una clave primaria basada en la marca de tiempo como la primera parte de la clave. No la recomendamos si la tabla presenta una alta tasa de inserción:

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

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 solo servidor de Spanner recibe todas las operaciones de escritura, lo que sobrecarga a ese servidor.

En el siguiente diagrama, se ilustra este inconveniente:

Tabla UserAccessLog ordenada por marca de tiempo con el hotspot correspondiente

La tabla UserAccessLog anterior incluye cinco filas de datos de ejemplo, que representan a cinco usuarios diferentes que realizan algún tipo de acción del usuario sobre un milisegundos de diferencia entre sí. 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 en 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 pasa a ser un hotspot y se ve así 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 asignarle más de una división.

Cuando Spanner agrega más filas a la tabla, la división crece; cuando esta alcanza aproximadamente 8 GB, Spanner crea otra división, como se describe en División basada en la carga. Spanner agrega filas nuevas subsiguientes a esta división nueva, y el servidor asignado a la división 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 4122 como la clave primaria. Recomendamos usar el UUID de la versión 4, ya que 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.

Existen varias maneras de almacenar el UUID como la clave primaria:

  • En una columna STRING(36)
  • En un par de columnas INT64
  • En una columna BYTES(16)

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

Por ejemplo, en la siguiente tabla:

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

Puedes insertar GENERATE_UUID() para generar los valores de LogEntryId. GENERATE_UUID() produce un valor STRING, por lo que la columna LogEntryId debe usar el tipo STRING para GoogleSQL o el tipo text para PostgreSQL.

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

El uso de un UUID presenta algunas desventajas:

  • Son un poco grandes y usan 16 bytes o más. Las otras opciones de claves primarias no usan tanto almacenamiento.
  • No contienen información sobre el registro. Por ejemplo, una clave primaria de SingerId y AlbumId tiene un significado inherente, mientras que un UUID no lo tiene.
  • Se pierde la localidad entre los registros relacionados, por lo que se usa un UUID. elimina hotspots.

Revierte los bits de los valores secuenciales

Debes asegurarte de 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 generar 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 forma uniforme en el espacio de claves.

Spanner admite secuencias de bits invertidos, que generan valores de números enteros únicos con bits invertidos. Puedes usar una secuencia en el primer (o únicamente) en una clave primaria para evitar problemas de hotspot. Para obtener más información, consulta Reversión de bits secuencia.

Intercambia el orden de las claves

Una manera de distribuir las operaciones de escritura en el espacio de clave de forma más uniforme es cambiar el orden de las claves para que la columna que contiene el valor monótono no sea la primera parte de la clave:

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

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

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

Tabla UserAccessLog ordenada por UserId con capacidad de procesamiento de escritura equilibrado

Aquí, Spanner fragmenta los datos de UserAccessLog en tres divisiones, y cada división contiene aproximadamente mil filas de UserId ordenados de salida. Es una estimación razonable de cómo se pueden dividir Suponiendo que cada fila contiene alrededor de 1 MB de datos de usuario y que, según un tamaño máximo de división de aproximadamente 8 GB. Si bien los eventos de usuario ocurrieron con un milisegundo de diferencia, un usuario diferente genera un evento, por lo que es mucho menos probable que el orden de inserciones cree un punto de acceso que con el uso del y marca de tiempo para hacer el pedido.

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 UserAccessLog en N fragmentos lógicos, puedes anteponer una columna de clave ShardId a la tabla:

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

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

GoogleSQL

ShardId = hash(LastAccess and UserId) % N

La función hash y la combinación de columnas que elijas determina la forma en que las filas se distribuyen 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 UserAccessLog ordenada por ShardId con capacidad de procesamiento de escritura equilibrada

Aquí, la tabla UserAccessLog está ordenada por ShardId, que se calcula como una función hash de las columnas de claves. Las cinco filas UserAccessLog 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 generación columna.

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

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

La función de hash que elijas determina la distribución de tus inserciones en el rango de claves. No necesitas un hash criptográfico, aunque hash criptográfico podría ser una buena opción. Cuando elijas una función de hash, debes tener en cuenta los siguientes factores:

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

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

Si tienes una tabla para tu historial que utiliza la marca de tiempo como clave, considera usando el orden descendente para la columna de clave si se aplica alguna de las siguientes condiciones:

  • Si quieres leer el historial más reciente, usas un filtro del historial, y leerás 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 de 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 que Spanner almacena 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 UserAccessLog (
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, el elemento superior es el más reciente o el más antiguo.

Usa un índice intercalado en una columna cuyo valor aumente o disminuya de forma monotónica

Similar al ejemplo anterior de clave primaria que debes evitar, también es un no es una buena idea crear índices no intercalados en columnas cuyos valores se aumentan o disminuyen monótonamente, 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 da como resultado la misma dificultad que se describió en las mejores prácticas anteriores porque Spanner implementa índices como tablas de forma interna, y la tabla de índice resultante usa una columna cuyo valor aumenta de forma monotónica como su primera parte clave.

De todas formas, está bien crear un índice intercalado como este, porque las filas de índices intercalados están intercaladas en las filas superiores correspondientes y no es probable que una fila superior produzca miles de eventos por segundo.

Práctica recomendada sobre el diseño de esquemas 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. En lugar de usar índices intercalados, usa técnicas como las que usarías para el diseño de clave primaria de la tabla base cuando se diseñan columnas de índice, para Por ejemplo, agrega `shardId`.

¿Qué sigue?