JavaRush /Курси /All lectures for UA purposes /Як реалізувати ACID у своєму додатку: теорія

Як реалізувати ACID у своєму додатку: теорія

All lectures for UA purposes
Рівень 1 , Лекція 632
Відкрита

7.1 Навіщо це треба

Ми з вами досить детально проговорабо всі властивості ACID, їх призначення та сценарії використання. Як ви вже зрозуміли, не всі бази даних пропонують гарантії ACID, жертвуючи ними заради більш високої продуктивності. Тому цілком може статися, що на вашому проекті буде обрано БД, що не пропонує ACID, і вам може знадобитися втілити частину необхідного функціоналу ACID на стороні програми. А якщо ваша система буде спроектована як мікросервіси, або якийсь інший вид розподілених додатків, то, що в одному сервісі було б звичайною локальною транзакцією, тепер стане розподіленою транзакцією – і, звичайно, втратить свою ACID-природу, навіть якщо БД кожного окремого мікросервіс буде ACID.

Я не хочу давати вам вичерпне керівництво щодо того, як створити менеджера транзакцій – просто тому, що це занадто велика та складна тема, а я хочу описати лише кілька основних технік. Якщо ж не йдеться про розподілені програми, то я не бачу сенсу намагатися повністю втілити ACID на стороні програми, якщо вам потрібні гарантії ACID - адже простіше і дешевше у всіх сенсах буде взяти вже готове рішення (тобто БД з ACID).

Але я хотів би показати вам деякі техніки, які допоможуть вам у здійсненні транзакцій на стороні програми. Зрештою, знання цих технік може допомогти вам у різних сценаріях, навіть не обов'язково пов'язаних із транзакціями, і зробить вас найкращими розробниками (сподіваюся на це).

7.2 Базовий інструментарій для любителів транзакцій

Оптимістична та песимістична блокування. Це два типи блокування деяких даних, яких може виникнути одночасний доступ.

Оптиміствважає, що ймовірність одночасного доступу не така велика, а тому він робить наступне: читає потрібний рядок, запам'ятовує номер її версії (або timestamp, або checksum / hash - якщо ви не можете змінити схему даних і додати стовпець для версії або timestamp), і перед тим, як записати БД зміни для цих даних, перевіряє, чи не змінилася версія цих даних. Якщо версія змінилася, то потрібно якось вирішити конфлікт і оновити дані (“commit”), або відкотити транзакцію (“rollback”). Мінус цього в тому, що він створює сприятливі умови для бага з довгою назвою “time-of-check to time-of-use”, скорочено TOCTOU: стан у період між перевіркою і записом може змінитися. Я не маю досвіду використання оптимістичного блокування,

Як приклад, я знайшов одну технологію з повсякденного життя розробника, яка використовує щось на кшталт оптимістичного блокування – це протокол HTTP. Відповідь на початковий HTTP-запит GET може включати заголовок ETag для наступних запитів PUT з боку клієнта, який той може використовувати в заголовку If-Match. Для методів GET і HEAD сервер відправить назад запитаний ресурс тільки якщо він відповідає одному зі знайомих йому ETag. Для PUT та інших небезпечних методів він завантажуватиме ресурс також тільки в цьому випадку. Якщо ви не знаєте, як працює ETag, то ось хороший приклад з використанням бібліотеки "feedparser" (яка допомагає парсити RSS та інші feeds).


>>> 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!' 

Песиміст виходить з того, що транзакції часто будуть «зустрічатися» на одних і тих же даних, і щоб спростити собі життя і уникнути зайвих race conditions, він просто блокує необхідні йому дані. Для того, щоб втілити механізм блокування, вам потрібно або підтримувати з'єднання з БД для вашої сесії (а не брати з'єднання з пулу - в цьому випадку вам, швидше за все, доведеться працювати з оптимістичною блокуванням), або використовувати ID для транзакції, яка може бути використана незалежно від з'єднання. Мінус песимістичного блокування в тому, що її використання уповільнює обробку транзакцій в цілому, але ви можете бути спокійні за дані і отримуєте справжню ізоляцію.

Додаткова небезпека, щоправда, таїться у можливому взаємному блокуванні („deadlock“), коли кілька процесів очікують ресурси, заблоковані друг другом. Наприклад, щодо транзакції необхідні ресурси А і Б. Процес 1 зайняв ресурс А, а процес 2 – ресурс Б. Жоден із двох процесів неспроможна продовжити виконання. Існують різні способи вирішення цього питання – я не хочу зараз вдаватися до деталей, тому для початку почитайте «Вікіпедію», але якщо коротко, тобто можливість створення ієрархії блокувань. Якщо ви хочете познайомитися докладніше з цією концепцією, то пропонують вам поламати голову над «Завданням про філософів, що обідають» (“dining philosophers problem”).

Ось тут є гарний приклад того, як поведуться обидві блокування в тому самому сценарії.

Щодо реалізації locks. Не хочу вдаватися до подробиць, але для розподілених систем існують менеджери блокувань, наприклад: ZooKeeper, Redis, etcd, Consul.

7.3 Ідемопотентність операцій

Ідемпотентність коду - це взагалі хороша практика, і це якраз той випадок, коли розробнику добре вміти це робити незалежно від того, використовує він транзакції чи ні. Ідемпотентність - це властивість операції давати той же результат при повторному застосуванні цієї операції до об'єкта. Функцію було викликано – дала результат. Викликана ще раз за секунду чи п'ять – дала той самий результат. Звичайно, якщо дані в БД змінабося, то результат буде іншим. Дані у третіх системах можуть не залежати від функції, але все, що залежить – має бути передбачуваним.

Прояв у ідемпотентності може бути кілька. Одне з них – це рекомендація до того, як треба писати свій код. Ви ж пам'ятаєте, що найкраща функція – це та, що робить одну річ? І що добре було б написати для цієї функції unit-тести? Якщо ви дотримуєтеся цих двох правил, то ви вже підвищуєте шанс, що ваші функції будуть ідемпотентні. Щоб не виникло плутанини, уточню, що ідемпотентні функції – не обов'язкові чисті (в сенсі „function purity“). Чисті функції – це функції, які оперують лише тими даними, які отримали вході, ніяк їх змінюючи і повертаючи оброблений результат. Це ті функції, які дозволяють скалювати програму, використовуючи техніки функціонального програмування. Оскільки ми говоримо про якісь загальні дані та БД, то наші функції навряд чи будуть чистими,

Ось це чиста функція:


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

А ось ця функція - не чиста, але ідемпотентна (прошу не робити висновків про те, як я пишу код за цими шматками):


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

Замість багатьох слів, я можу просто розповісти про те, як я вимушено навчився писати ідемпотентні програми. Я багато працюю з AWS, як ви вже могли зрозуміти, там є сервіс під назвою AWS Lambda. Lambda дозволяє не дбати про сервери, а просто завантажувати код, який запускатиметься у відповідь на якісь події або за розкладом. Подією може бути повідомлення, які доставляються брокером (message broker). У AWS таким брокером є AWS SNS. Думаю, що це має бути зрозумілим навіть для тих, хто не працює з AWS: у нас є брокер, який надсилає повідомлення по каналах (“topics”), та мікросервіси, які підписані на ці канали, отримують повідомлення та якось на них реагують.

Проблема полягає в тому, що SNS доставляє повідомлення "як мінімум один раз" ("at-least-once delivery"). Що це означає? Що рано чи пізно ваш код на Lambda буде викликано двічі. І це справді трапляється. Існує ціла низка сценаріїв, коли ваша функція має бути ідемпотентною: наприклад, коли з рахунку знімаються гроші, ми можемо очікувати, що хтось зніме одну й ту саму суму двічі, але ми повинні переконатися, що це дійсно 2 незалежні один від одного рази – інакше кажучи, це дві різні транзакції, а не повтор однієї.

Я для різноманітності наведу інший приклад – обмеження частоти запитів до API (“rate limiting”). Наша Lambda приймає подію з якимось user_id для якого повинна бути зроблена перевірка, чи не вичерпав чи користувач з таким ID свою кількість можливих запитів до якоїсь нашої API. Ми могли б зберігати в DynamoDB від AWS значення здійснених викликів, і збільшувати його з кожним викликом нашої функції на 1.

Але що робити, якщо ця Lambda-функція буде викликана однією і тією самою подією двічі? До речі, ви звернули увагу на аргументи функції lambda_handler(). Другий аргумент, context в AWS Lambda дається за замовчуванням, і він містить різні метадані, у тому числі – request_id, який генерується для кожного унікального виклику. Це означає, що тепер замість того, щоб зберігати в таблиці число здійснених викликів, ми можемо зберігати список request_id і при кожному викликі наша Lambda перевірятиме, чи був даний запит вже оброблений:

import json
import os
from typing import Any, Dict

from aws_lambda_powertools.utilities.typing import LambdaContext  # потрібно тільки для інструкції типу аргументу
import boto3

limit = os.getenv('LIMIT')

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

	request_id = context.aws_request_id

	# Знаходимо user_id у вхідній події
	user_id = event["user_id"]

	# Наша таблиця на DynamoDB
	table = boto3.resource('dynamodb').Table('my_table')

	# Робимо 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: написати подальшу логіку

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

Оскільки мій приклад фактично взятий з інтернету, то я залишу посилання на першоджерело, тим більше що він дає трохи більше інформації.

Пам'ятайте, я вже згадував, що щось на кшталт унікального ID транзакції можна використовувати для блокування загальних даних? Тепер ми довідалися, що його можна використовувати і для забезпечення ідемпотентності операцій. Давайте дізнаємося, якими способами можна самим генерувати такі ID.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ