7.1 ¿Por qué es necesario?

Hemos discutido con cierto detalle todas las propiedades de ACID, su propósito y casos de uso. Como puede ver, no todas las bases de datos ofrecen garantías ACID, sacrificándolas por un mejor rendimiento. Por lo tanto, bien puede ocurrir que se seleccione una base de datos que no ofrece ACID en su proyecto, y es posible que deba implementar algunas de las funciones necesarias de ACID en el lado de la aplicación. Y si su sistema está diseñado como microservicios, o algún otro tipo de aplicación distribuida, lo que sería una transacción local normal en un servicio ahora se convertirá en una transacción distribuida y, por supuesto, perderá su naturaleza ACID, incluso si la base de datos de cada microservicio individual será ACID.

No quiero darle una guía exhaustiva sobre cómo crear un administrador de transacciones, simplemente porque es demasiado grande y complicado, y solo quiero cubrir algunas técnicas básicas. Si no estamos hablando de aplicaciones distribuidas, entonces no veo ninguna razón para intentar implementar completamente ACID en el lado de la aplicación si necesita las garantías de ACID; después de todo, será más fácil y económico en todos los sentidos tomar una solución preparada ( es decir, una base de datos con ACID).

Pero me gustaría mostrarle algunas técnicas que lo ayudarán a realizar transacciones en el lado de la aplicación. Después de todo, conocer estas técnicas puede ayudarlo en una variedad de escenarios, incluso aquellos que no necesariamente involucran transacciones, y convertirlo en un mejor desarrollador (eso espero).

7.2 Herramientas básicas para los amantes de las transacciones

Bloqueo optimista y pesimista. Estos son dos tipos de bloqueos en algunos datos a los que se puede acceder al mismo tiempo.

Optimistaasume que la probabilidad de acceso simultáneo no es tan grande y, por lo tanto, hace lo siguiente: lee la línea deseada, recuerda su número de versión (o marca de tiempo, o suma de verificación / hash; si no puede cambiar el esquema de datos y agregar una columna para la versión o marca de tiempo), y antes de escribir cambios en la base de datos para estos datos, comprueba si la versión de estos datos ha cambiado. Si la versión ha cambiado, entonces debe resolver de alguna manera el conflicto creado y actualizar los datos ("confirmar") o revertir la transacción ("revertir"). La desventaja de este método es que crea condiciones favorables para un error con el nombre largo “tiempo de verificación a tiempo de uso”, abreviado como TOCTOU: el estado puede cambiar en el período de tiempo entre la verificación y la escritura. No tengo experiencia con el bloqueo optimista,

Como ejemplo, encontré una tecnología de la vida diaria de un desarrollador que usa algo así como bloqueo optimista: este es el protocolo HTTP. La respuesta a la solicitud HTTP GET inicial PUEDE incluir un encabezado ETag para solicitudes PUT posteriores del cliente, que el cliente PUEDE usar en el encabezado If-Match. Para los métodos GET y HEAD, el servidor devolverá el recurso solicitado solo si coincide con una de las ETags que conoce. Para PUT y otros métodos inseguros, solo cargará el recurso en este caso también. Si no sabe cómo funciona ETag, aquí hay un buen ejemplo usando la biblioteca "feedparser" (que ayuda a analizar RSS y otras fuentes).


>>> import feedparser 
>>> d = feedparser.parse('http://feedparser.org/docs/examples/atom10.xml') 
>>> d.etag 
'"6c132-941-ad7e3080"' 
>>> d2 = feedparser.parse('http://feedparser.org/docs/examples/atom10.xml', etag=d.etag) 
>>> d2.feed 
{} 
>>> d2.debug_message 
'The feed has not changed since you last checked, so the server sent no data.  This is a feature, not a bug!' 

El pesimista, por otro lado, parte del hecho de que las transacciones a menudo se "encuentran" en los mismos datos, y para simplificar su vida y evitar condiciones de carrera innecesarias, simplemente bloquea los datos que necesita. Para implementar el mecanismo de bloqueo, debe mantener una conexión de base de datos para su sesión (en lugar de extraer conexiones de un grupo, en cuyo caso lo más probable es que tenga que trabajar con bloqueo optimista), o usar una ID para la transacción. , que se puede utilizar independientemente de la conexión. La desventaja del bloqueo pesimista es que su uso ralentiza el procesamiento de las transacciones en general, pero puede estar tranquilo con los datos y obtener un aislamiento real.

Sin embargo, un peligro adicional acecha en el posible interbloqueo, en el que varios procesos esperan recursos bloqueados entre sí. Por ejemplo, una transacción requiere los recursos A y B. El proceso 1 ha ocupado el recurso A y el proceso 2 ha ocupado el recurso B. Ninguno de los dos procesos puede continuar la ejecución. Hay varias formas de resolver este problema: no quiero entrar en detalles ahora, así que primero lea Wikipedia, pero en resumen, existe la posibilidad de crear una jerarquía de bloqueo. Si quieres conocer este concepto con más detalle, te invitamos a devanarte los sesos con el “Dinning Philosophers Problem” (“problema de los filósofos del comedor”).

Aquí hay un buen ejemplo de cómo se comportarán ambos bloqueos en el mismo escenario.

En cuanto a las implementaciones de bloqueos. No quiero entrar en detalles, pero existen gestores de bloqueos para sistemas distribuidos, por ejemplo: ZooKeeper, Redis, etcd, Consul.

7.3 Idempotencia de las operaciones

El código idempotente es generalmente una buena práctica, y este es exactamente el caso cuando sería bueno que un desarrollador pudiera hacer esto, independientemente de si usa transacciones o no. La idempotencia es la propiedad de una operación de producir el mismo resultado cuando esa operación se aplica nuevamente a un objeto. La función fue llamada - dio el resultado. Llamado nuevamente después de un segundo o cinco, dio el mismo resultado. Por supuesto, si los datos en la base de datos han cambiado, el resultado será diferente. Los datos en terceros sistemas pueden no depender de una función, pero cualquier cosa que lo haga debe ser predecible.

Puede haber varias manifestaciones de idempotencia. Uno de ellos es solo una recomendación sobre cómo escribir su código. ¿Recuerdas que la mejor función es la que hace una sola cosa? ¿Y qué sería bueno escribir pruebas unitarias para esta función? Si cumple con estas dos reglas, entonces ya aumenta la posibilidad de que sus funciones sean idempotentes. Para evitar confusiones, aclararé que las funciones idempotentes no son necesariamente “puras” (en el sentido de “pureza de función”). Las funciones puras son aquellas funciones que operan solo en los datos que recibieron en la entrada, sin cambiarlos de ninguna manera y devolviendo el resultado procesado. Estas son las funciones que le permiten escalar su aplicación utilizando técnicas de programación funcional. Dado que estamos hablando de algunos datos generales y una base de datos, es poco probable que nuestras funciones sean puras,

Esta es una función pura:


def square(num: int) -> int: 
	return num * num 

Pero esta función no es pura, sino idempotente (por favor, no saques conclusiones sobre cómo escribo código a partir de estas piezas):


def insert_data(insert_query: str, db_connection: DbConnectionType) -> int: 
  db_connection.execute(insert_query) 
  return True 

En lugar de muchas palabras, solo puedo hablar sobre cómo me vi obligado a aprender a escribir programas idempotentes. Trabajo mucho con AWS, como puede ver ahora, y hay un servicio llamado AWS Lambda. Lambda le permite no ocuparse de los servidores, sino simplemente cargar el código que se ejecutará en respuesta a algunos eventos o de acuerdo con un cronograma. Un evento puede ser mensajes entregados por un intermediario de mensajes. En AWS, este corredor es AWS SNS. Creo que esto debería quedar claro incluso para aquellos que no trabajan con AWS: tenemos un corredor que envía mensajes a través de canales ("temas"), y los microservicios que están suscritos a estos canales reciben mensajes y de alguna manera reaccionan sobre ellos.

El problema es que SNS entrega mensajes "al menos una vez" ("entrega al menos una vez"). ¿Qué significa? Que tarde o temprano su código Lambda será llamado dos veces. Y realmente sucede. Hay una serie de escenarios en los que su función debe ser idempotente: por ejemplo, cuando se retira dinero de una cuenta, podemos esperar que alguien retire la misma cantidad dos veces, pero debemos asegurarnos de que sean realmente 2 veces independientes: en otras palabras, estas son 2 transacciones diferentes, y no una repetición de una.

Para variar, daré otro ejemplo: limitar la frecuencia de las solicitudes a la API ("limitación de velocidad"). Nuestro Lambda recibe un evento con un determinado user_id por lo que se debe realizar una comprobación para ver si el usuario con ese ID ha agotado su número de solicitudes posibles a alguna de nuestras APIs. Podríamos almacenar en DynamoDB de AWS el valor de las llamadas realizadas, e incrementarlo con cada llamada a nuestra función en 1.

Pero, ¿qué sucede si el mismo evento llama a esta función Lambda dos veces? Por cierto, ¿has prestado atención a los argumentos de la función lambda_handler()? El segundo argumento, el contexto en AWS Lambda, se proporciona de forma predeterminada y contiene varios metadatos, incluido el request_id que se genera para cada llamada única. Esto significa que ahora, en lugar de almacenar la cantidad de llamadas realizadas en la tabla, podemos almacenar una lista de request_id y en cada llamada, Lambda verificará si la solicitud dada ya se procesó:

import json
import os
from typing import Any, Dict

from aws_lambda_powertools.utilities.typing import LambdaContext  # needed only for argument type annotation
import boto3

limit = os.getenv('LIMIT')

def handler_name(event: Dict[str: Any], context: LambdaContext):

	request_id = context.aws_request_id

	# We find user_id in incoming event
	user_id = event["user_id"]

	# Our table for DynamoDB
	table = boto3.resource('dynamodb').Table('my_table')

	# Doing update
	table.update_item(
    	Key={'pkey': user_id},
    	UpdateExpression='ADD requests :request_id',
    	ConditionExpression='attribute_not_exists (requests) OR (size(requests) < :limit AND NOT contains(requests, :request_id))',
    	ExpressionAttributeValues={
        	':request_id': {'S': request_id},
        	':requests': {'SS': [request_id]},
        	':limit': {'N': limit}
    	}
	)

	# TODO: write further logic

	return {
    	"statusCode": 200,
    	"headers": {
        	"Content-Type": "application/json"
    	},
    	"body": json.dumps({
        	"status ": "success"
    	})
	}

Dado que mi ejemplo en realidad está tomado de Internet, dejaré un enlace a la fuente original, especialmente porque brinda un poco más de información.

¿Recuerdas que mencioné anteriormente que se puede usar algo así como una ID de transacción única para bloquear datos compartidos? Ahora hemos aprendido que también se puede usar para hacer que las operaciones sean idempotentes. Averigüemos de qué manera puede generar tales identificaciones usted mismo.