5.1 La cuestión de la simultaneidad

Comencemos con una teoría un poco distante.

Cualquier sistema de información (o simplemente, una aplicación) que crean los programadores consta de varios bloques típicos, cada uno de los cuales proporciona una parte de la funcionalidad necesaria. Por ejemplo, la memoria caché se usa para recordar el resultado de una operación que requiere muchos recursos para garantizar una lectura más rápida de los datos por parte del cliente, las herramientas de procesamiento de secuencias le permiten enviar mensajes a otros componentes para el procesamiento asíncrono y las herramientas de procesamiento por lotes se usan para " rastrillo" los volúmenes acumulados de datos con cierta periodicidad. .

Y en casi todas las aplicaciones, las bases de datos (DB) están involucradas de una forma u otra, que generalmente realizan dos funciones: almacenar datos cuando los recibe de usted y luego proporcionárselos cuando los solicite. Rara vez alguien piensa en crear su propia base de datos, porque ya hay muchas soluciones listas para usar. Pero, ¿cómo elige el adecuado para su aplicación?

Entonces, imaginemos que ha escrito una aplicación con una interfaz móvil que le permite cargar una lista guardada previamente de tareas en la casa, es decir, leer de la base de datos y complementarla con nuevas tareas, así como priorizar cada una específica. tarea - de 1 (más alto) a 3 (más bajo). Digamos que su aplicación móvil es utilizada por una sola persona a la vez. Pero ahora te atreviste a contarle a tu madre sobre tu creación, y ahora se ha convertido en la segunda usuaria habitual. ¿Qué sucede si decide al mismo tiempo, justo en el mismo milisegundo, establecer alguna tarea, "lavar las ventanas", con un grado diferente de prioridad?

En términos profesionales, las consultas a la base de datos de usted y de su madre pueden considerarse como 2 procesos que realizaron una consulta a la base de datos. Un proceso es una entidad en un programa de computadora que puede ejecutarse en uno o más subprocesos. Por lo general, un proceso tiene una imagen de código de máquina, memoria, contexto y otros recursos. En otras palabras, el proceso se puede caracterizar como la ejecución de instrucciones de programa en el procesador. Cuando su aplicación realiza una solicitud a la base de datos, estamos hablando del hecho de que su base de datos procesa la solicitud recibida a través de la red desde un proceso. Si hay dos usuarios sentados en la aplicación al mismo tiempo, entonces puede haber dos procesos en cualquier momento en particular.

Cuando algún proceso realiza una solicitud a la base de datos, la encuentra en un estado determinado. Un sistema con estado es un sistema que recuerda eventos anteriores y almacena alguna información, que se llama "estado". Una variable declarada como integerpuede tener un estado de 0, 1, 2 o digamos 42. Mutex (exclusión mutua) tiene dos estados: bloqueado o desbloqueado , al igual que un semáforo binario ("requerido" frente a "liberado") y generalmente binario Tipos de datos (binarios) y variables que solo pueden tener dos estados: 1 o 0.

Basado en el concepto de estado, se basan varias estructuras matemáticas y de ingeniería, como un autómata finito - un modelo que tiene una entrada y una salida y está en uno de un conjunto finito de estados en cada momento del tiempo - y el "estado “Patrón de diseño, en el que un objeto cambia de comportamiento en función del estado interno (por ejemplo, en función de qué valor se asigna a una u otra variable).

Por lo tanto, la mayoría de los objetos en el mundo de las máquinas tienen algún estado que puede cambiar con el tiempo: nuestra canalización, que procesa un gran paquete de datos, arroja un error y falla, o la propiedad del objeto Wallet, que almacena la cantidad de dinero que queda en la cuenta del usuario . cuenta, cambios después de recibos de nómina.

Una transición ("transición") de un estado a otro, digamos, de en curso a fallado , se denomina operación. Probablemente, todo el mundo conoce las operaciones CRUD - create, read, update, deleteo métodos HTTP similares - POST, GET, PUT, DELETE. Pero los programadores a menudo dan otros nombres a las operaciones en su código, porque la operación puede ser más compleja que solo leer un cierto valor de la base de datos: también puede verificar los datos y luego nuestra operación, que ha tomado la forma de una función, se llamará, por ejemplo, ¿Y validate()quién realiza estas operaciones-funciones? procesos ya descritos.

¡Un poco más y comprenderá por qué describo los términos con tanto detalle!

Cualquier operación, ya sea una función o, en sistemas distribuidos, enviar una solicitud a otro servidor, tiene 2 propiedades: el tiempo de invocación y el tiempo de finalización (completion time) , que será estrictamente mayor que el tiempo de invocación (investigadores de Jepsen proceden de las suposiciones teóricas de que ambas marcas de tiempo recibirán relojes imaginarios, totalmente sincronizados y disponibles a nivel mundial).

Imaginemos nuestra aplicación de lista de tareas pendientes. Haces una solicitud a la base de datos a través de la interfaz móvil en 14:00:00.014, y tu madre 13:59:59.678(es decir, 336 milisegundos antes) actualizó la lista de tareas pendientes a través de la misma interfaz y le agregó lavar los platos. Teniendo en cuenta el retraso de la red y la posible cola de tareas para su base de datos, si, además de usted y su madre, todos los amigos de su madre también usan su aplicación, la base de datos puede ejecutar la solicitud de la madre después de procesar la suya. En otras palabras, existe la posibilidad de que dos de sus solicitudes, así como las solicitudes de las novias de su madre, se envíen a los mismos datos al mismo tiempo (concurrentemente).

Así que hemos llegado al término más importante en el campo de las bases de datos y las aplicaciones distribuidas: concurrencia. ¿Qué puede significar exactamente la simultaneidad de dos operaciones? Si se dan alguna operación T1 y alguna operación T2, entonces:

  • T1 puede iniciarse antes de la hora de inicio de la ejecución T2 y finalizar entre la hora de inicio y finalización de T2
  • T2 se puede iniciar antes de la hora de inicio de T1 y finalizar entre el inicio y el final de T1
  • T1 se puede iniciar y finalizar entre la hora de inicio y finalización de la ejecución de T1
  • y cualquier otro escenario donde T1 y T2 tengan un tiempo de ejecución común

Está claro que en el marco de esta lección, estamos hablando principalmente de las consultas que ingresan a la base de datos y cómo el sistema de administración de la base de datos percibe estas consultas, pero el término concurrencia es importante, por ejemplo, en el contexto de los sistemas operativos. No me desviaré mucho del tema de este artículo, pero creo que es importante mencionar que la concurrencia de la que estamos hablando aquí no está relacionada con el dilema de concurrencia y concurrencia y su diferencia, que se discute en el contexto de sistemas operativos y computación de alto rendimiento. El paralelismo es una forma de lograr la concurrencia en un entorno con múltiples núcleos, procesadores o computadoras. Hablamos de concurrencia en el sentido de acceso simultáneo de diferentes procesos a datos comunes.

¿Y qué, de hecho, puede salir mal, puramente teóricamente?

Cuando se trabaja con datos compartidos, pueden ocurrir numerosos problemas relacionados con la concurrencia, también llamados "condiciones de carrera". El primer problema ocurre cuando un proceso recibe datos que no debería haber recibido: datos incompletos, temporales, cancelados o "incorrectos". El segundo problema es cuando el proceso recibe datos obsoletos, es decir, datos que no corresponden al último estado guardado de la base de datos. Digamos que alguna aplicación ha retirado dinero de la cuenta de un usuario con saldo cero, porque la base de datos devolvió el estado de la cuenta a la aplicación, sin tener en cuenta el último retiro de dinero de la misma, que ocurrió hace solo un par de milisegundos. La situación es regular, ¿no?

5.2 Las transacciones vinieron a salvarnos

Para resolver tales problemas, apareció el concepto de transacción: un cierto grupo de operaciones secuenciales (cambios de estado) con una base de datos, que es una operación lógicamente única. Daré un ejemplo con un banco nuevamente, y no por casualidad, porque el concepto de transacción apareció, aparentemente, precisamente en el contexto de trabajar con dinero. El ejemplo clásico de una transacción es la transferencia de dinero de una cuenta bancaria a otra: primero debe retirar el monto de la cuenta de origen y luego depositarlo en la cuenta de destino.

Para que esta transacción se lleve a cabo, la aplicación deberá realizar varias acciones en la base de datos: verificar el saldo del remitente, bloquear el monto en la cuenta del remitente, agregar el monto a la cuenta del destinatario y descontar el monto del remitente. Habrá varios requisitos para tal transacción. Por ejemplo, la aplicación no puede recibir información desactualizada o incorrecta sobre el saldo, por ejemplo, si al mismo tiempo una transacción paralela terminó con un error a la mitad y los fondos no se debitaron de la cuenta, y nuestra aplicación ya recibió información. que los fondos fueron cancelados.

Para resolver este problema, se recurrió a una propiedad de una transacción como el "aislamiento": nuestra transacción se ejecuta como si no hubiera otras transacciones en el mismo momento. Nuestra base de datos realiza operaciones concurrentes como si las estuviera ejecutando una detrás de otra, secuencialmente ; de ​​hecho, el nivel de aislamiento más alto se llama Strict Serializable . Sí, el más alto, lo que significa que hay varios niveles.

"Para", dices. Mantenga sus caballos, señor.

Recordemos como describí que cada operación tiene un tiempo de llamada y un tiempo de ejecución. Para mayor comodidad, puede considerar llamar y ejecutar como 2 acciones. Luego, la lista ordenada de todas las acciones de llamada y ejecución se puede llamar el historial de la base de datos. Entonces, el nivel de aislamiento de transacciones es un conjunto de historias. Usamos niveles de aislamiento para determinar qué historias son "buenas". Cuando decimos que una historia "rompe la serialización" o "no es serializable", queremos decir que la historia no está en el conjunto de historias serializables.

Para que quede claro de qué tipo de historias estamos hablando, daré ejemplos. Por ejemplo, existe un tipo de historia: lectura intermedia . Ocurre cuando la transacción A puede leer datos de una fila que ha sido modificada por otra transacción B en ejecución y aún no se ha confirmado ("no confirmado"); es decir, de hecho, los cambios aún no se han confirmado finalmente por transacción B, y puede cancelarlos en cualquier momento. Y, por ejemplo, la lectura abortada es solo nuestro ejemplo con una transacción de retiro cancelada

Hay varias anomalías posibles. Es decir, las anomalías son algún tipo de estado de datos no deseado que puede ocurrir durante el acceso competitivo a la base de datos. Y para evitar ciertos estados no deseados, las bases de datos usan diferentes niveles de aislamiento, es decir, diferentes niveles de protección de datos contra estados no deseados. Estos niveles (4 piezas) se enumeraron en el estándar ANSI SQL-92.

La descripción de estos niveles parece vaga para algunos investigadores y ofrecen sus propias clasificaciones más detalladas. Te aconsejo que prestes atención al ya mencionado Jepsen, así como al proyecto Hermitage, que pretende aclarar exactamente qué niveles de aislamiento ofrecen determinados DBMS, como MySQL o PostgreSQL. Si abre los archivos de este repositorio, puede ver qué secuencia de comandos SQL usan para probar la base de datos en busca de ciertas anomalías, y puede hacer algo similar para las bases de datos que le interesen). Aquí hay un ejemplo del repositorio para mantenerlo interesado:

-- Database: MySQL

-- Setup before test
create table test (id int primary key, value int) engine=innodb;
insert into test (id, value) values (1, 10), (2, 20);

-- Test the "read uncommited" isolation level on the "Intermediate Reads" (G1b) anomaly
set session transaction isolation level read uncommitted; begin; -- T1
set session transaction isolation level read uncommitted; begin; -- T2
update test set value = 101 where id = 1; -- T1
select * from test; -- T2. Shows 1 => 101
update test set value = 11 where id = 1; -- T1
commit; -- T1
select * from test; -- T2. Now shows 1 => 11
commit; -- T2

-- Result: doesn't prevent G1b

Es importante comprender que para la misma base de datos, por regla general, puede elegir uno de varios tipos de aislamiento. ¿Por qué no elegir el aislamiento más resistente? Porque, como todo en informática, el nivel de aislamiento elegido debe corresponder a una compensación que estamos dispuestos a hacer; en este caso, una compensación en la velocidad de ejecución: cuanto mayor sea el nivel de aislamiento, más lentas serán las solicitudes. procesada. Para comprender qué nivel de aislamiento necesita, debe comprender los requisitos de su aplicación, y para comprender si la base de datos que ha elegido ofrece este nivel, deberá consultar la documentación; para la mayoría de las aplicaciones, esto será suficiente, pero si tiene algunos requisitos particularmente estrictos, es mejor organizar una prueba como la que hacen los muchachos del proyecto Hermitage.

5.3 "I" y otras letras en ACID

El aislamiento es básicamente lo que la gente quiere decir cuando habla de ACID en general. Y es por eso que comencé el análisis de esta sigla con aislamiento, y no fui en orden, como suelen hacer quienes intentan explicar este concepto. Ahora veamos las tres letras restantes.

Recordemos de nuevo nuestro ejemplo con una transferencia bancaria. Una transacción para transferir fondos de una cuenta a otra incluye una operación de retiro de la primera cuenta y una operación de reposición en la segunda. Si la operación de recarga de la segunda cuenta falló, probablemente no desee que ocurra la operación de retiro de la primera cuenta. En otras palabras, o la transacción tiene éxito por completo o no ocurre en absoluto, pero no puede realizarse solo en una parte. Esta propiedad se llama "atomicidad", y es una "A" en ACID.

Cuando se ejecuta nuestra transacción, entonces, como cualquier operación, transfiere la base de datos de un estado válido a otro. Algunas bases de datos ofrecen las llamadas restricciones , es decir, reglas que se aplican a los datos almacenados, por ejemplo, con respecto a claves primarias o secundarias, índices, valores predeterminados, tipos de columnas, etc. Entonces, al realizar una transacción, debemos estar seguros de que todas estas restricciones se cumplirán.

Esta garantía se llama "consistencia" y letra Cen ACID (no confundir con la consistencia del mundo de las aplicaciones distribuidas, de la que hablaremos más adelante). Daré un ejemplo claro de coherencia en el sentido de ACID: una aplicación para una tienda en línea quiere agregar ordersuna fila a la tabla, y la ID de la tabla product_idse indicará en la columna - típico .productsforeign key

Si el producto, por ejemplo, se eliminó del surtido y, en consecuencia, de la base de datos, entonces la operación de inserción de fila no debería ocurrir y obtendremos un error. Esta garantía, en comparación con otras, es un poco exagerada, en mi opinión, aunque solo sea porque el uso activo de restricciones de la base de datos significa cambiar la responsabilidad de los datos (así como un cambio parcial de la lógica comercial, si estamos hablando de una restricción como CHECK ) de la aplicación a la base de datos, que, como dicen ahora, es así.

Y finalmente, queda D: "resistencia" (durabilidad). Una falla del sistema o cualquier otra falla no debe conducir a la pérdida de los resultados de la transacción o del contenido de la base de datos. Es decir, si la base de datos respondió que la transacción fue exitosa, significa que los datos se registraron en una memoria no volátil, por ejemplo, en un disco duro. Esto, por cierto, no significa que verá inmediatamente los datos en la próxima solicitud de lectura.

Justo el otro día, estaba trabajando con DynamoDB de AWS (Amazon Web Services) y envié algunos datos para guardarlos, y después de recibir una respuesta HTTP 200(OK), o algo así, decidí verificarlo, y no vi esto. datos en la base de datos durante los próximos 10 segundos. Es decir, DynamoDB comprometió mis datos, pero no todos los nodos se sincronizaron instantáneamente para obtener la copia más reciente de los datos (aunque puede haber estado en la memoria caché). Aquí nuevamente nos metimos en el territorio de la consistencia en el contexto de los sistemas distribuidos, pero aún no ha llegado el momento de hablar de ello.

Así que ahora sabemos qué son las garantías de ACID. E incluso sabemos por qué son útiles. Pero, ¿realmente los necesitamos en todas las aplicaciones? Y si no, ¿cuándo exactamente? ¿Todos los DB ofrecen estas garantías y, de no ser así, qué ofrecen en su lugar?