Cómo funcionan las lecturas y escrituras de Spanner

Spanner es una base de datos distribuida, escalable y con una consistencia fuerte creada por ingenieros de Google para admitir algunas de las aplicaciones más importantes de Google. Toma ideas clave de las comunidades de bases de datos y sistemas distribuidos, y las amplía de nuevas formas. Spanner expone este servicio interno de Spanner como un servicio disponible públicamente en Google Cloud Platform.

Como Spanner debe cumplir los exigentes requisitos de tiempo de actividad y escalabilidad que imponen las aplicaciones empresariales críticas de Google, hemos diseñado Spanner desde cero para que sea una base de datos distribuida. El servicio puede abarcar varias máquinas, centros de datos y regiones. Aprovechamos esta distribución para gestionar conjuntos de datos y cargas de trabajo enormes, sin dejar de mantener una disponibilidad muy alta. También queríamos que Spanner ofreciera las mismas garantías de coherencia estricta que otras bases de datos de nivel empresarial, ya que queríamos crear una experiencia excelente para los desarrolladores. Es mucho más fácil razonar y escribir software para una base de datos que admita una coherencia sólida que para una base de datos que solo admita la coherencia a nivel de fila o de entidad, o que no tenga ninguna garantía de coherencia.

En este documento, describimos en detalle cómo funcionan las escrituras y las lecturas en Spanner, y cómo Spanner garantiza una coherencia inmediata.

Puntos de partida

Hay algunos conjuntos de datos que son demasiado grandes para caber en una sola máquina. También hay casos en los que el conjunto de datos es pequeño, pero la carga de trabajo es demasiado pesada para que la gestione una sola máquina. Esto significa que tenemos que encontrar una forma de dividir nuestros datos en partes independientes que se puedan almacenar en varias máquinas. Nuestro enfoque consiste en particionar las tablas de la base de datos en intervalos de claves contiguos denominados divisiones. Una sola máquina puede servir varias divisiones y hay un servicio de búsqueda rápida para determinar las máquinas que sirven un intervalo de claves determinado. Los usuarios de Spanner no tienen que preocuparse por los detalles de cómo se dividen los datos ni en qué máquinas se encuentran. El resultado es un sistema que puede proporcionar latencias bajas tanto para lecturas como para escrituras, incluso con cargas de trabajo pesadas y a gran escala.

También queremos asegurarnos de que los datos sean accesibles a pesar de los fallos. Para asegurarnos de que esto sea así, replicamos cada división en varias máquinas de distintos dominios de fallos. La replicación coherente en las diferentes copias de la división se gestiona mediante el algoritmo Paxos. En Paxos, mientras esté activa la mayoría de las réplicas de votación de la división, se puede elegir una de esas réplicas como líder para procesar las escrituras y permitir que otras réplicas sirvan lecturas.

Spanner ofrece transacciones de solo lectura y transacciones de lectura y escritura. Las primeras son el tipo de transacción preferido para las operaciones (incluidas las instrucciones SQL SELECT) que no modifican los datos. Las transacciones de solo lectura siguen proporcionando una coherencia sólida y funcionan, de forma predeterminada, con la copia más reciente de tus datos. Sin embargo, pueden ejecutarse sin necesidad de ningún tipo de bloqueo interno, lo que las hace más rápidas y escalables. Las transacciones de lectura y escritura se usan para las transacciones que insertan, actualizan o eliminan datos, incluidas las transacciones que realizan lecturas seguidas de una escritura. Siguen siendo altamente escalables, pero las transacciones de lectura y escritura introducen bloqueos y deben ser orquestadas por los líderes de Paxos. Ten en cuenta que el bloqueo es transparente para los clientes de Spanner.

Muchos sistemas de bases de datos distribuidas anteriores no han ofrecido garantías de coherencia sólidas debido a la costosa comunicación entre máquinas que suele ser necesaria. Spanner puede proporcionar copias de la base de datos con una coherencia sólida gracias a una tecnología desarrollada por Google llamada TrueTime. Al igual que el condensador de flujo de una máquina del tiempo de 1985, TrueTime es lo que hace posible Spanner. Es una API que permite que cualquier máquina de los centros de datos de Google conozca la hora global exacta con un alto grado de precisión (es decir, con un margen de error de unos pocos milisegundos). Esto permite que diferentes máquinas de Spanner razonen sobre el orden de las operaciones transaccionales (y que ese orden coincida con lo que ha observado el cliente) a menudo sin ninguna comunicación. Google tuvo que equipar sus centros de datos con hardware especial (¡relojes atómicos!) para que TrueTime funcionara. La precisión y la exactitud del tiempo resultantes son mucho mayores que las que se pueden conseguir con otros protocolos (como NTP). En concreto, Spanner asigna una marca de tiempo a todas las lecturas y escrituras. Se garantiza que una transacción en la marca de tiempo T1 reflejará los resultados de todas las escrituras que se hayan producido antes de T1. Si una máquina quiere satisfacer una lectura en T2, debe asegurarse de que su vista de los datos esté actualizada al menos hasta T2. Gracias a TrueTime, esta determinación suele ser muy económica. Los protocolos para asegurar la coherencia de los datos son complejos, pero se explican con más detalle en el documento original de Spanner y en este documento sobre Spanner y la coherencia.

Ejemplo práctico

Veamos algunos ejemplos prácticos para ver cómo funciona todo:

CREATE TABLE ExampleTable (
 Id INT64 NOT NULL,
 Value STRING(MAX),
) PRIMARY KEY(Id);

En este ejemplo, tenemos una tabla con una clave principal de número entero simple.

Dividir KeyRange
0 [-∞,3)
1 [3224)
2 [224.712)
3 [712,717)
4 [717,1265)
5 [1265,1724)
6 [1724,1997)
7 [1997,2456)
8 [2456,∞)

Dado el esquema de ExampleTable anterior, el espacio de claves principal se particiona en divisiones. Por ejemplo, si hay una fila en ExampleTable con un Id de 3700, se encontrará en Split 8. Como se ha detallado anteriormente, Split 8 se replica en varias máquinas.

Tabla que muestra la distribución de las divisiones en varias zonas y máquinas

En esta instancia de Spanner, el cliente tiene cinco nodos y la instancia se replica en tres zonas. Las nueve divisiones están numeradas del 0 al 8, y los líderes de Paxos de cada división están sombreados en oscuro. Los parciales también tienen réplicas en cada zona (con un sombreado claro). La distribución de las divisiones entre los nodos puede ser diferente en cada zona y los líderes de Paxos no residen todos en la misma zona. Esta flexibilidad ayuda a Spanner a ser más robusto frente a determinados tipos de perfiles de carga y modos de fallo.

Escritura de división única

Supongamos que el cliente quiere insertar una nueva fila (7, "Seven") en ExampleTable.

  1. API Layer busca la división que tiene el intervalo de claves que contiene 7. Se encuentra en la división 1.
  2. La capa de la API envía la solicitud de escritura al líder de la división 1.
  3. El líder inicia una transacción.
  4. El líder intenta obtener un bloqueo de escritura en la fila Id=7. Esta es una operación local. Si otra transacción de lectura y escritura simultánea está leyendo esta fila, la otra transacción tiene un bloqueo de lectura y la transacción actual se bloquea hasta que pueda adquirir el bloqueo de escritura.
    1. Es posible que la transacción A esté esperando un bloqueo que tiene la transacción B, y que la transacción B esté esperando un bloqueo que tiene la transacción A. Como ninguna de las transacciones libera ningún bloqueo hasta que adquiere todos los bloqueos, esto puede provocar un interbloqueo. Spanner usa un algoritmo estándar de prevención de interbloqueos "wound-wait" para asegurarse de que las transacciones avancen. En concreto, una transacción "más reciente" esperará un bloqueo que tenga una transacción "más antigua", pero una transacción "más antigua" "abortará" una transacción más reciente que tenga un bloqueo solicitado por la transacción más antigua. Por lo tanto, nunca tenemos ciclos de interbloqueo de esperas de bloqueo.
  5. Una vez que se adquiere el bloqueo, el líder asigna una marca de tiempo a la transacción basándose en TrueTime.
    1. Se garantiza que esta marca de tiempo es posterior a la de cualquier transacción confirmada anteriormente que haya afectado a los datos. De esta forma, se asegura de que el orden de las transacciones (tal como lo percibe el cliente) coincida con el orden de los cambios en los datos.
  6. El líder informa a las réplicas de Split 1 sobre la transacción y su marca de tiempo. Una vez que la mayoría de esas réplicas hayan almacenado la mutación de la transacción en un almacenamiento estable (en el sistema de archivos distribuido), la transacción se confirma. De esta forma, la transacción se puede recuperar aunque se produzca un fallo en una minoría de máquinas. Las réplicas aún no aplican las mutaciones a su copia de los datos.
  7. El líder espera hasta que pueda asegurarse de que la marca de tiempo de la transacción ha transcurrido en tiempo real. Normalmente, esto requiere unos milisegundos para que podamos esperar a que se resuelva cualquier incertidumbre en la marca de tiempo de TrueTime. De esta forma, se garantiza una coherencia sólida: una vez que un cliente ha conocido el resultado de una transacción, se garantiza que todos los demás lectores verán los efectos de la transacción. Este "tiempo de espera de confirmación" suele coincidir con la comunicación de la réplica en el paso anterior, por lo que su coste de latencia real es mínimo. Puedes consultar más detalles en este documento.

  8. El líder responde al cliente para indicarle que la transacción se ha confirmado y, opcionalmente, le informa de la marca de tiempo de confirmación de la transacción.

  9. Paralelamente a la respuesta al cliente, las mutaciones de la transacción se aplican a los datos.

    1. El líder aplica las mutaciones a su copia de los datos y, a continuación, libera los bloqueos de transacción.
    2. El líder también informa a las otras réplicas de Split 1 para que apliquen la mutación a sus copias de los datos.
    3. Cualquier transacción de lectura y escritura o de solo lectura que deba ver los efectos de las mutaciones espera hasta que se apliquen las mutaciones antes de intentar leer los datos. En el caso de las transacciones de lectura y escritura, esto se aplica porque la transacción debe tomar un bloqueo de lectura. En el caso de las transacciones de solo lectura, esto se aplica comparando la marca de tiempo de la lectura con la de los datos aplicados más recientes.

Todo esto suele ocurrir en unos pocos milisegundos. Esta escritura es el tipo de escritura más barato que realiza Spanner, ya que solo implica una división.

Escritura multisplit

Si hay varias divisiones, es necesario un nivel adicional de coordinación (mediante el algoritmo de confirmación de dos fases estándar).

Supongamos que la tabla contiene cuatro mil filas:

1 "one"
2 "two"
... ...
4000 "cuatro mil"

Supongamos que el cliente quiere leer el valor de la fila 1000 y escribir un valor en las filas 2000, 3000 y 4000 en una transacción. Se ejecutará en una transacción de lectura y escritura de la siguiente manera:

  1. El cliente inicia una transacción de lectura y escritura, t.
  2. El cliente envía una solicitud de lectura de la fila 1000 a la capa de la API y la etiqueta como parte de t.
  3. API Layer busca la división que tiene la clave 1000. Se encuentra en Split 4.
  4. API Layer envía una solicitud de lectura al líder de la división 4 y la etiqueta como parte de t.

  5. El líder de Split 4 intenta obtener un bloqueo de lectura en la fila Id=1000. Esta es una operación local. Si otra transacción simultánea tiene un bloqueo de escritura en esta fila, la transacción actual se bloqueará hasta que pueda adquirir el bloqueo. Sin embargo, este bloqueo de lectura no impide que otras transacciones obtengan bloqueos de lectura.

    1. Al igual que en el caso de una sola división, el interbloqueo se evita mediante "wound-wait".
  6. El líder busca el valor de Id 1000 ("Mil") y devuelve el resultado de lectura al cliente.


    Más adelante...

  7. El cliente envía una solicitud de confirmación para la transacción t. Esta solicitud de confirmación contiene 3 mutaciones: ([2000, "Dos Mil"], [3000, "Tres Mil"] y [4000, "Quatro Mil"]).

    1. Todos los splits implicados en una transacción se convierten en participantes de la transacción. En este caso, participan las divisiones 4 (que ha atendido la lectura de la clave 1000), 7 (que gestionará la mutación de la clave 2000) y 8 (que gestionará las mutaciones de las claves 3000 y 4000).
  8. Uno de los participantes se convierte en coordinador. En este caso, quizás el líder de la división 7 se convierta en coordinador. El coordinador se encarga de que la transacción se confirme o se cancele de forma atómica en todos los participantes. Es decir, no se completará en un participante y se cancelará en otro.

    1. El trabajo realizado por los participantes y los coordinadores lo llevan a cabo las máquinas líderes de esas divisiones.
  9. Los participantes adquieren bloqueos. Esta es la primera fase de la confirmación en dos fases.

    1. La partición 7 adquiere un bloqueo de escritura en la clave 2000.
    2. La partición 8 adquiere un bloqueo de escritura en la clave 3000 y en la clave 4000.
    3. Split 4 verifica que sigue teniendo un bloqueo de lectura en la clave 1000 (es decir, que el bloqueo no se ha perdido debido a un fallo de la máquina o al algoritmo wound-wait).
    4. Cada participante de la división registra su conjunto de bloqueos replicándolos en (al menos) la mayoría de las réplicas de la división. De esta forma, las exclusivas pueden mantenerse incluso si se produce un fallo en el servidor.
    5. Si todos los participantes notifican correctamente al coordinador que sus bloqueos se han mantenido, la transacción general se puede confirmar. De esta forma, se asegura de que haya un momento en el que se mantengan todos los bloqueos que necesita la transacción. Este momento se convierte en el punto de confirmación de la transacción, lo que nos permite ordenar correctamente los efectos de esta transacción en comparación con otras transacciones que se hayan producido antes o después.
    6. Es posible que no se puedan adquirir bloqueos (por ejemplo, si detectamos que puede haber un interbloqueo mediante el algoritmo de espera herida). Si algún participante dice que no puede confirmar la transacción, se cancela toda la transacción.
  10. Si todos los participantes y el coordinador adquieren los bloqueos correctamente, el coordinador (Split 7) decide confirmar la transacción. Asigna una marca de tiempo a la transacción basada en TrueTime.

    1. Esta decisión de confirmación, así como las mutaciones de la clave 2000, se replican en los miembros de la división 7. Una vez que la mayoría de las réplicas de Split 7 registran la decisión de confirmación en el almacenamiento estable, la transacción se confirma.
  11. El coordinador comunica el resultado de la transacción a todos los participantes. Esta es la segunda fase de la confirmación en dos fases.

    1. Cada líder de participante replica la decisión de confirmación en las réplicas de la división del participante.
  12. Si se confirma la transacción, el coordinador y todos los participantes aplican las mutaciones a los datos.

    1. Al igual que en el caso de la división única, los lectores posteriores de datos en el coordinador o los participantes deben esperar hasta que se apliquen los datos.
  13. El coordinador responde al cliente para indicarle que la transacción se ha confirmado y, opcionalmente, le devuelve la marca de tiempo de confirmación de la transacción.

    1. Al igual que en el caso de la división única, el resultado se comunica al cliente después de una espera de confirmación para asegurar una coherencia sólida.

Todo esto suele ocurrir en unos pocos milisegundos, aunque normalmente se tarda un poco más que en el caso de una sola división debido a la coordinación adicional entre divisiones.

Lectura fuerte (multipartición)

Supongamos que el cliente quiere leer todas las filas en las que Id >= 0 y Id < 700 forman parte de una transacción de solo lectura.

  1. API Layer busca las divisiones que tienen alguna clave en el intervalo [0, 700). Estas filas son propiedad de Split 0, Split 1 y Split 2.
  2. Como se trata de una lectura sólida en varias máquinas, la capa de API elige la marca de tiempo de lectura mediante el TrueTime actual. De esta forma, se asegura de que ambas lecturas devuelvan datos de la misma instantánea de la base de datos.
    1. Otros tipos de lecturas, como las lecturas obsoletas, también eligen una marca de tiempo para leer (pero la marca de tiempo puede ser del pasado).
  3. La capa de la API envía la solicitud de lectura a alguna réplica de Split 0, alguna réplica de Split 1 y alguna réplica de Split 2. También incluye la marca de tiempo de lectura que ha seleccionado en el paso anterior.
  4. En el caso de las lecturas sólidas, la réplica de servicio suele hacer una llamada a procedimiento remoto al líder para pedir la marca de tiempo de la última transacción que necesita aplicar. La lectura puede continuar una vez que se haya aplicado la transacción. Si la réplica es la principal o determina que se ha puesto al día lo suficiente como para atender la solicitud desde su estado interno y TrueTime, atiende la lectura directamente.

  5. Los resultados de las réplicas se combinan y se devuelven al cliente (a través de la capa de la API).

Ten en cuenta que las lecturas no adquieren ningún bloqueo en las transacciones de solo lectura. Además, como cualquier réplica actualizada de una división determinada puede atender las lecturas, el rendimiento de lectura del sistema puede ser muy alto. Si el cliente puede tolerar lecturas que tengan una antigüedad de al menos diez segundos, el rendimiento de lectura puede ser aún mayor. Como el líder suele actualizar las réplicas con la marca de tiempo segura más reciente cada diez segundos, las lecturas con una marca de tiempo obsoleta pueden evitar una RPC adicional al líder.

Conclusión

Tradicionalmente, los diseñadores de sistemas de bases de datos distribuidas han descubierto que las garantías transaccionales sólidas son caras debido a toda la comunicación entre máquinas que se requiere. Con Spanner, nos hemos centrado en reducir el coste de las transacciones para que sean viables a gran escala y a pesar de la distribución. Una de las razones principales por las que funciona es TrueTime, que reduce la comunicación entre máquinas para muchos tipos de coordinación. Además, gracias a una ingeniería cuidadosa y a la optimización del rendimiento, hemos conseguido un sistema de alto rendimiento que ofrece garantías sólidas. En Google, hemos comprobado que esto ha facilitado significativamente el desarrollo de aplicaciones en Spanner en comparación con otros sistemas de bases de datos con garantías más débiles. Cuando los desarrolladores de aplicaciones no tienen que preocuparse por las condiciones de carrera o las incoherencias en sus datos, pueden centrarse en lo que realmente les importa: crear y lanzar una aplicación excelente.