Recomendaciones sobre el diseño del esquema

La arquitectura distribuida de Spanner te permite diseñar el esquema para evitar los hotspots, 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 esquemas con el fin de 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 la base de datos de Spanner funcione de manera eficiente, en especial cuando se realizan inserciones masivas de datos.

Elige una clave primaria para evitar los hotspots

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

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

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

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

En el siguiente diagrama, se ilustra este inconveniente:

Tabla UserAccessLog ordenada por marca de tiempo con el hotspot correspondiente

En la tabla UserAccessLog anterior, se incluyen 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 su valor 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 controlando 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 fondo y el borde rojos de arriba. Ten en cuenta que, en esta ilustración simplificada, cada servidor controla como máximo una división, pero, en realidad, Spanner puede asignar a cada servidor más de una división.

Cuando Spanner agrega más filas a la tabla, la división aumenta y, cuando llega a los 8 GB aproximadamente, crea otra división, como se describe en la sección División basada en la carga. Spanner agrega las filas nuevas posteriores a esta división nueva, y el servidor asignado a la división se convierte en el nuevo hotspot potencial.

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 la versión 4 del UUID, ya que utiliza valores aleatorios en la secuencia de bits. No recomendamos los UUID de la versión 1 porque almacenan la marca de tiempo en 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 valores de UUID de forma automática.

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 tienen 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 el uso de un UUID elimina los 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 causar la generación de hotspots a gran escala. Una forma de evitar este problema es revertir los bits de los valores secuenciales y asegurarte de distribuir los valores de la clave primaria de manera uniforme en el espacio de claves.

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

Intercambia el orden de las claves

Una forma de distribuir las escrituras en el espacio de claves de manera 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 (
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)
);

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 diferentes divisiones porque es poco probable que un solo usuario produzca miles de eventos por segundo.

En el siguiente diagrama, se ilustran las cinco filas de la tabla UserAccessLog que Spanner ordena con UserId en lugar de la marca de tiempo de acceso:

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

Aquí, Spanner fragmenta los datos UserAccessLog en tres divisiones, y cada una contiene alrededor de mil filas de valores UserId ordenados. Esta es una estimación razonable de cómo se podrían dividir los datos del usuario, suponiendo que cada fila contiene alrededor de 1 MB de datos del usuario y con un tamaño máximo de división de, aproximadamente, 8 GB. Aunque los eventos de usuario ocurrieron con un milisegundo de diferencia, un usuario diferente envió cada evento, por lo que el orden de las inserciones es mucho menos probable que cree un hotspot en comparación con el uso de la marca de tiempo para el pedido.

Consulta también las prácticas recomendadas relacionadas para ordenar claves basadas en la 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, calcula el módulo N del hash. Por ejemplo:

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. 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 columna generada.

Para hacerlo en Google SQL, 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 qué tan bien se distribuyen tus inserciones en el rango de claves. No necesitas un hash criptográfico, aunque podría ser una buena opción. Cuando elijas una función hash, debes considerar 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 usa la marca de tiempo como clave, considera usar el orden descendente para la columna de clave si se aplica alguna de las siguientes condiciones:

  • Si deseas leer el historial más reciente, debes usar una tabla intercalada para el historial y leer 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 manera más eficiente para las 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 para el diseño de esquemas n.o 2: El orden ascendente o descendente depende de las consultas de los usuarios, por ejemplo, si la parte superior es la más reciente o la parte superior es la más antigua.

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

Al igual que en el ejemplo de clave primaria anterior que debes evitar, también es una mala idea crear índices no intercalados en columnas cuyos valores aumentan o disminuyen de forma monótona, 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 el mismo error que se describe en la práctica recomendada anterior, ya que Spanner implementa índices como tablas de forma interna y la tabla de índices resultante usa una columna cuyo valor aumenta de forma monotónica como su primera parte de la clave.

Sin embargo, está bien crear un índice intercalado como este, ya que las filas de estos se intercalan en las filas superiores correspondientes y es poco probable que una sola fila superior produzca miles de eventos por segundo.

Práctica recomendada para el diseño de esquemas n.o 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 la clave primaria de la tabla base cuando diseñes columnas de índices; por ejemplo, agrega “shardId”.

¿Qué sigue?