En este documento se describen los métodos que pueden usar los administradores de bases de datos y los desarrolladores de aplicaciones para generar secuencias numéricas únicas en aplicaciones que usan Spanner.
Introducción
A menudo, las empresas necesitan un ID numérico único y sencillo, por ejemplo, un número de empleado o un número de factura. Las bases de datos relacionales convencionales suelen incluir una función para generar secuencias únicas de números que aumentan de forma monótona. Estas secuencias se usan para generar identificadores únicos (claves de fila) de los objetos almacenados en la base de datos.
Sin embargo, usar valores que aumenten (o disminuyan) de forma monótona como claves de fila puede no seguir las prácticas recomendadas de Spanner, ya que crea puntos de acceso en la base de datos, lo que provoca una reducción del rendimiento. En este documento se proponen mecanismos para implementar un generador de secuencias mediante una tabla de base de datos de Spanner y lógica de capa de aplicación.
También puedes usar un generador de secuencias invertidas de bits integrado en Spanner. Para obtener más información sobre el generador de secuencias de Spanner, consulta Crear y gestionar secuencias.
Requisitos de un generador de secuencias
Cada generador de secuencias debe generar un valor único para cada transacción.
Según el caso práctico, es posible que un generador de secuencias también tenga que crear secuencias con las siguientes características:
- Ordenada: los valores más bajos de la secuencia no deben emitirse después de los valores más altos.
- Sin espacios: no debe haber espacios en la secuencia.
El generador de secuencias también debe generar valores con la frecuencia que requiera la aplicación.
Puede ser difícil cumplir todos estos requisitos, especialmente en un sistema distribuido. Si es necesario para alcanzar tus objetivos de rendimiento, puedes hacer concesiones en los requisitos de que la secuencia esté ordenada y no tenga huecos.
Otros motores de bases de datos tienen formas de gestionar estos requisitos. Por ejemplo, las secuencias de PostgreSQL y las columnas AUTO_INCREMENT de MySQL pueden generar valores únicos para transacciones independientes, pero no pueden producir valores sin espacios si se revierten las transacciones. Para obtener más información, consulta las notas de la documentación de PostgreSQL y las implicaciones de AUTO_INCREMENT en MySQL.
Generadores de secuencias que usan filas de tablas de bases de datos
Tu aplicación puede implementar un generador de secuencias mediante una tabla de base de datos para almacenar los nombres de las secuencias y el siguiente valor de la secuencia.
Leer e incrementar la celda next_value
de la secuencia dentro de una transacción de base de datos genera valores únicos sin necesidad de sincronizar los procesos de la aplicación.
Primero, define la tabla de la siguiente manera:
CREATE TABLE sequences (
name STRING(64) NOT NULL,
next_value INT64 NOT NULL,
) PRIMARY KEY (name)
Para crear secuencias, inserta una fila en la tabla con el nombre de la nueva secuencia y el valor inicial (por ejemplo, ("invoice_id", 1)
). Sin embargo, como la celda next_value
se incrementa por cada valor de secuencia generado, el rendimiento se limita a la frecuencia con la que se puede actualizar la fila.
Las bibliotecas de cliente de Spanner usan transacciones reintentables para resolver conflictos. Si se modifican en otro lugar las celdas (valores de columna) que se leen durante una transacción de lectura y escritura, la transacción se bloqueará hasta que se complete la otra transacción. Después, se cancelará y se volverá a intentar para que lea los valores actualizados. De esta forma, se minimiza la duración de los bloqueos de escritura, pero también significa que se puede intentar realizar una transacción varias veces antes de que se confirme correctamente.
Como solo puede haber una transacción en una fila a la vez, la frecuencia máxima de emisión de valores de secuencia es inversamente proporcional a la latencia total de la transacción.
La latencia total de la transacción depende de varios factores, como la latencia entre la aplicación cliente y los nodos de Spanner, la latencia entre los nodos de Spanner y la incertidumbre de TrueTime. Por ejemplo, la configuración multirregional tiene una latencia de transacción mayor porque debe esperar a que se alcance el quórum de confirmaciones de escritura de los nodos de diferentes regiones para completarse.
Por ejemplo, si una transacción de lectura y actualización en una sola celda (una columna de una sola fila) tiene una latencia de 10 milisegundos (ms), la frecuencia teórica máxima de emisión de valores de secuencia es de 100 por segundo. Este máximo se aplica a toda la base de datos, independientemente del número de instancias de la aplicación cliente o del número de nodos de la base de datos. Esto se debe a que un solo nodo siempre gestiona una sola fila.
En la siguiente sección se describen formas de evitar esta limitación.
Implementación del lado de la aplicación
El código de la aplicación debe leer y actualizar la celda next_value
de la base de datos. Hay varias formas de hacerlo, cada una con diferentes características de rendimiento e inconvenientes.
Generador de secuencias sencillo dentro de una transacción.
La forma más sencilla de gestionar la generación de secuencias es incrementar el valor de la columna dentro de la transacción cada vez que la aplicación necesite un nuevo valor secuencial.
En una sola transacción, la aplicación hace lo siguiente:
- Lee la celda
next_value
para obtener el nombre de la secuencia que se va a usar en la aplicación. - Incrementa y actualiza la celda
next_value
del nombre de la secuencia. - Usa el valor obtenido para cualquier valor de columna que necesite la aplicación.
- Completa el resto de la transacción de la aplicación.
Este proceso genera una secuencia ordenada y sin huecos. Si nada actualiza la celda next_value
de la base de datos a un valor inferior, la secuencia también será única.
Como el valor de la secuencia se obtiene como parte de la transacción de la aplicación más amplia, la frecuencia máxima de generación de secuencias depende de la complejidad de la transacción de la aplicación en general. Una transacción compleja tendrá una latencia mayor y, por lo tanto, una frecuencia máxima posible menor.
En un sistema distribuido, se pueden intentar muchas transacciones al mismo tiempo, lo que provoca una gran contención en el valor de la secuencia. Como la celda next_value
se actualiza en la transacción de la aplicación, cualquier otra transacción que intente incrementar la celda next_value
al mismo tiempo se bloqueará por la primera transacción y se volverá a intentar.
Esto provoca un aumento considerable del tiempo necesario para que la aplicación complete la transacción correctamente, lo que puede causar problemas de rendimiento.
El siguiente código proporciona un ejemplo de un generador de secuencias simple en una transacción que devuelve solo un valor de secuencia por transacción. Esta restricción se debe a que las escrituras de una transacción que usa la API Mutation no se pueden ver hasta que se confirma la transacción, ni siquiera en las lecturas de la misma transacción. Por lo tanto, si se llama a esta función varias veces en la misma transacción, siempre se devolverá el mismo valor de secuencia.
En el siguiente ejemplo de código se muestra cómo implementar una función getNext()
síncrona:
El siguiente código de ejemplo muestra cómo se usa la función síncrona getNext()
en una transacción:
Generador de secuencias síncronas y dentro de la transacción mejorado
Puedes modificar la abstracción anterior para generar varios valores en una sola transacción haciendo un seguimiento de los valores de secuencia emitidos en una transacción.
En una sola transacción, la aplicación hace lo siguiente:
- Lee la celda
next_value
para obtener el nombre de la secuencia que se va a usar en la aplicación. - Almacena este valor como una variable internamente.
- Cada vez que se solicita un nuevo valor de secuencia, se incrementa la variable
next_value
almacenada y se almacena en el búfer una escritura que define el valor de celda actualizado en la base de datos. - Completa el resto de la transacción de la aplicación.
Si usas una abstracción, el objeto de esta abstracción debe crearse en la transacción. El objeto realiza una sola lectura cuando se solicita el primer valor. El objeto registra internamente la celda next_value
para que se pueda generar más de un valor.
Las mismas advertencias sobre latencia y contención que se aplicaban a la versión anterior también se aplican a esta versión.
En el siguiente ejemplo de código se muestra cómo implementar una función getNext()
síncrona:
El siguiente código de ejemplo muestra cómo usar la función síncrona getNext()
en una solicitud de dos valores de secuencia:
Generador de secuencias fuera de transacción (asíncrono)
En las dos implementaciones anteriores, el rendimiento del generador depende de la latencia de la transacción de la aplicación. Puedes mejorar la frecuencia máxima (a costa de tolerar huecos en la secuencia) incrementando la secuencia en una transacción independiente. Este es el enfoque que usa PostgreSQL. Debe recuperar los valores de secuencia que se van a usar primero, antes de que la aplicación inicie su transacción.
La aplicación hace lo siguiente:
- Crea una primera transacción para obtener y actualizar el valor de la secuencia:
- Lee la celda
next_value
para obtener el nombre de la secuencia que se va a usar en la aplicación. - Almacena este valor como una variable.
- Incrementa y actualiza la celda
next_value
de la base de datos con el nombre de la secuencia. - Completa la transacción.
- Lee la celda
- Usa el valor devuelto en una transacción independiente.
La latencia de esta transacción independiente será cercana a la latencia mínima, y el rendimiento se aproximará a la frecuencia teórica máxima de 100 valores por segundo (suponiendo una latencia de transacción de 10 ms). Como los valores de secuencia se obtienen por separado, la latencia de la transacción de la aplicación no cambia y la contención se minimiza.
Sin embargo, si se solicita un valor de secuencia y no se usa, se deja un hueco en la secuencia porque no es posible revertir los valores de secuencia solicitados. Esto puede ocurrir si la aplicación se interrumpe o falla durante la transacción después de solicitar un valor de secuencia.
En el siguiente ejemplo de código se muestra cómo implementar una función que obtiene e incrementa la celda next_value
de la base de datos:
Puedes usar esta función fácilmente para obtener un único valor de secuencia nuevo, como se muestra en la siguiente implementación de una función getNext()
asíncrona:
El siguiente código de ejemplo muestra cómo usar la función asíncrona getNext()
en una solicitud de dos valores de secuencia:
En el ejemplo de código anterior, puede ver que los valores de secuencia se solicitan fuera de la transacción de la aplicación. Esto se debe a que Cloud Spanner no admite la ejecución de una transacción dentro de otra en el mismo subproceso (también conocidas como transacciones anidadas).
Puedes eludir esta restricción solicitando el valor de la secuencia mediante un hilo en segundo plano y esperando el resultado:
Generador de secuencias de lotes
Puedes mejorar significativamente el rendimiento si también eliminas el requisito de que los valores de la secuencia estén ordenados. De esta forma, la aplicación puede reservar un lote de valores de secuencia y emitirlos internamente. Las instancias de aplicaciones individuales tienen su propio lote de valores, por lo que los valores que se emiten no están ordenados. Además, las instancias de la aplicación que no usen todo su lote de valores (por ejemplo, si la instancia de la aplicación se cierra) dejarán huecos de valores no utilizados en la secuencia.
La aplicación hará lo siguiente:
- Mantener un estado interno para cada secuencia que contenga el valor inicial, el tamaño del lote y el siguiente valor disponible.
- Solicita un valor de secuencia del lote.
- Si no quedan valores en el lote, haga lo siguiente:
- Crea una transacción para leer y actualizar el valor de la secuencia.
- Lee la celda
next_value
de la secuencia. - Almacena este valor internamente como el valor inicial del nuevo lote.
- Incrementa la celda
next_value
de la base de datos en una cantidad igual al tamaño del lote. - Completa la transacción.
- Devuelve el siguiente valor disponible e incrementa el estado interno.
- Usa el valor devuelto en la transacción.
Con este método, las transacciones que usen un valor de secuencia experimentarán un aumento de la latencia solo cuando se tenga que reservar un nuevo lote de valores de secuencia.
La ventaja es que, al aumentar el tamaño del lote, el rendimiento se puede incrementar hasta cualquier nivel, ya que el factor limitante pasa a ser el número de lotes emitidos por segundo.
Por ejemplo, con un tamaño de lote de 100 (suponiendo una latencia de 10 ms para obtener un nuevo lote y, por lo tanto, un máximo de 100 lotes por segundo), se pueden emitir 10.000 valores de secuencia por segundo.
En el siguiente ejemplo de código se muestra cómo implementar una función getNext()
mediante lotes. Ten en cuenta que el código reutiliza la getAndIncrementNextValueInDB()
función definida anteriormente para obtener nuevos lotes de valores de secuencia de la base de datos.
El siguiente código de ejemplo muestra cómo usar la función asíncrona getNext()
en una solicitud de dos valores de secuencia:
De nuevo, los valores deben solicitarse fuera de la transacción (o mediante un hilo en segundo plano), ya que Spanner no admite transacciones anidadas.
Generador de secuencias de lotes asíncronas
En las aplicaciones de alto rendimiento en las que no se puede aceptar ningún aumento de la latencia, puedes mejorar el rendimiento del generador de lotes anterior teniendo preparado un nuevo lote de valores para cuando se agote el lote de valores actual.
Para ello, puede definir un umbral que indique cuándo es demasiado bajo el número de valores de secuencia que quedan en un lote. Cuando se alcanza el umbral, el generador de secuencias empieza a solicitar un nuevo lote de valores en un subproceso en segundo plano.
Al igual que en la versión anterior, los valores no se emiten en orden y habrá huecos de valores no utilizados en la secuencia si las transacciones fallan o si se cierran las instancias de la aplicación.
La aplicación hará lo siguiente:
- Mantener un estado interno para cada secuencia, que contenga el valor inicial del lote y el siguiente valor disponible.
- Solicita un valor de secuencia del lote.
- Si los valores restantes del lote son inferiores al umbral, haz lo siguiente en un subproceso en segundo plano:
- Crea una transacción para leer y actualizar el valor de la secuencia.
- Lee la celda
next_value
para ver el nombre de la secuencia que se va a usar en la aplicación. - Almacena este valor internamente como el valor inicial del siguiente lote.
- Incrementa en la base de datos la celda
next_value
en una cantidad igual al tamaño del lote. - Completa la transacción.
- Si no quedan valores en el lote, recupera el valor inicial del siguiente lote del subproceso en segundo plano (esperando a que se complete si es necesario) y crea un nuevo lote con el valor inicial recuperado como siguiente valor.
- Devuelve el siguiente valor e incrementa el estado interno.
- Usa el valor devuelto en la transacción.
Para que el rendimiento sea óptimo, el subproceso en segundo plano debe iniciarse y completarse antes de que se agoten los valores de secuencia del lote actual. De lo contrario, la aplicación tendrá que esperar al siguiente lote y la latencia aumentará. Por lo tanto, tendrás que ajustar el tamaño del lote y el umbral bajo en función de la frecuencia con la que se emitan los valores de la secuencia.
Por ejemplo, supongamos que el tiempo de transacción para obtener un nuevo lote de valores es de 20 ms, el tamaño del lote es de 1000 y la frecuencia máxima de emisión de secuencias es de 500 valores por segundo (un valor cada 2 ms). Durante los 20 ms en los que se emite un nuevo lote de valores, se emitirán 10 valores de secuencia. Por lo tanto, el umbral del número de valores de secuencia restantes debe ser superior a 10 para que el siguiente lote esté disponible cuando sea necesario.
En el siguiente ejemplo de código se muestra cómo implementar una función getNext()
mediante lotes. Ten en cuenta que el código usa la función getAndIncrementNextValueInDB()
definida anteriormente para obtener un lote de valores de secuencia mediante un
subproceso en segundo plano.
El siguiente código de ejemplo muestra cómo se usa la función asíncrona por lotes getNext()
en una solicitud de dos valores que se van a usar en la transacción:
Ten en cuenta que, en este caso, los valores se pueden solicitar dentro de la transacción, ya que la recuperación de un nuevo lote de valores se produce en un subproceso en segundo plano.
Resumen
En la siguiente tabla se comparan las características de los cuatro tipos de generadores de secuencias:
Síncrona | Asíncrono | Lotes | Lote asíncrono | |
---|---|---|---|---|
Valores únicos | Sí | Sí | Sí | Sí |
Valores ordenados globalmente | Sí | Sí | No Pero con una carga lo suficientemente alta y un tamaño de lote lo suficientemente pequeño, los valores estarán cerca entre sí |
No Pero con una carga lo suficientemente alta y un tamaño de lote lo suficientemente pequeño, los valores estarán cerca entre sí |
Sin pausas | Sí | No | No | No |
Rendimiento | Latencia de 1 transacción, (unas 25 transacciones por segundo) |
Entre 50 y 100 valores por segundo | Entre 50 y 100 lotes de valores por segundo | Entre 50 y 100 lotes de valores por segundo |
Aumento de la latencia | > 10 ms Significativamente más alto con una alta contención (cuando una transacción tarda mucho tiempo) |
10 ms en cada transacción Significativamente más alto con una alta contención |
10 ms, pero solo cuando se obtiene un nuevo lote de valores | Cero, si el tamaño del lote y el umbral inferior se han definido con valores adecuados |
La tabla anterior también ilustra el hecho de que es posible que tengas que hacer concesiones en los requisitos de los valores ordenados globalmente y las series de valores sin huecos para generar valores únicos y, al mismo tiempo, cumplir los requisitos generales de rendimiento.
Pruebas de rendimiento
Puedes usar una herramienta de prueba o análisis de rendimiento, que se encuentra en el mismo repositorio de GitHub que las clases de generador de secuencias anteriores, para probar cada uno de estos generadores de secuencias y demostrar las características de rendimiento y latencia. La herramienta simula una latencia de transacción de aplicación de 10 ms y ejecuta varios hilos simultáneamente que solicitan valores de secuencia.
Las pruebas de rendimiento solo necesitan una instancia de Spanner de un solo nodo para realizar las pruebas, ya que solo se modifica una fila.
Por ejemplo, el siguiente resultado muestra una comparación del rendimiento y la latencia en el modo síncrono con 10 hilos:
$ ITERATIONS=2000
$ MODE=SYNC
$ NUMTHREADS=10
$ java -jar sequence-generator.jar \
$INSTANCE_ID $DATABASE_ID $MODE $ITERATIONS $NUMTHREADS
2000 iterations (10 parallel threads) in 58739 milliseconds: 34.048928 values/s
Latency: 50%ile 27 ms
Latency: 75%ile 31 ms
Latency: 90%ile 1189 ms
Latency: 99%ile 2703 ms
En la siguiente tabla se comparan los resultados de varios modos y números de hilos paralelos, incluido el número de valores que se pueden emitir por segundo y la latencia en los percentiles 50, 90 y 99:
Modo y parámetros | Número de cadenas | Valores/s | Latencia del percentil 50 (ms) | Latencia del percentil 90 (ms) | Latencia del percentil 99 (ms) |
---|---|---|---|---|---|
SINCRONIZAR | 10 | 34 | 27 | 1189 | 2703 |
SINCRONIZAR | 50 | 30,6 | 1191 | 3513 | 5982 |
ASYNC | 10 | 66,5 | 28 | 611 | 1460 |
ASYNC | 50 | 78,1 | 29 | 1695 | 3442 |
LOTE (tamaño 200) |
10 | 494 | 18 | 20 | 38 |
LOTE (tamaño del lote 200) | 50 | 1195 | 27 | 55 | 168 |
LOTE ASÍNCRONO (tamaño del lote 200, LT 50) |
10 | 512 | 18 | 20 | 30 |
LOTE ASÍNCRONO (tamaño del lote 200, LT 50) |
50 | 1622 | 24 | 28 | 30 |
Como puedes ver, en el modo síncrono (SYNC), al aumentar el número de hilos, se produce un aumento de la contención. Esto provoca latencias de transacción significativamente más altas.
En el modo asíncrono (ASYNC), como la transacción para obtener la secuencia es más pequeña y está separada de la transacción de la aplicación, hay menos contención y la frecuencia es mayor. Sin embargo, aún puede producirse una contención, lo que provoca latencias más altas en el percentil 90.
En el modo por lotes (BATCH), la latencia se reduce significativamente, excepto en el percentil 99, que corresponde a cuando el generador necesita solicitar de forma síncrona otro lote de valores de secuencia de la base de datos. El rendimiento es muchas veces mayor en el modo BATCH que en el modo ASYNC.
El modo por lotes de 50 hilos tiene una latencia mayor porque las secuencias se emiten tan rápido que el factor limitante es la potencia de la instancia de máquina virtual (en este caso, una máquina de 4 vCPUs funcionaba al 350% de CPU durante la prueba). Si se usan varias máquinas y varios procesos, los resultados generales serían similares a los del modo por lotes de 10 hilos.
En el modo ASYNC BATCH, la variación de la latencia es mínima y el rendimiento es mayor, incluso con un gran número de subprocesos, porque la latencia de solicitar un nuevo lote a la base de datos es completamente independiente de la transacción de la aplicación.
Siguientes pasos
- Consulta las prácticas recomendadas para diseñar esquemas en Spanner.
- Consulta cómo elegir claves e índices para las tablas de Spanner.
- Consulta arquitecturas de referencia, diagramas y prácticas recomendadas sobre Google Cloud. Consulta nuestro Centro de arquitectura de Cloud.