Целостность данных
5. Целостность данных
5.1. Модели консистентности
Представьте два сценария.
- Сценарий 1 (Банк): Вы переводите 100$. Вы делаете
Write(баланс -100), а затем сразуRead(показать новый баланс). Вы обязаны увидеть новый баланс. Это строгая консистеность (Strong Consistency). - Сценарий 2 (Соцсеть): Я лайкнул ваш пост. Я делаю
Write(likes +1). Вы сразу делаетеRead. Если вы увидите старое число лайков (10) еще полсекунды, а только потом новое (11), - ничего страшного не случится. Это консистентность в конечном счете (Eventually Consistency).
(На самом деле классификация разных типов консистентностей более сложная, но в контексте DynamoDB хватит этого понимания.)
Компромисс между консистентностью и производительностью
Под капотом DynamoDB хранит ваши данные в 3-х Availability Zones (ЦОДах).
- Когда вы делаете
Write, данные пишутся на лидера партиции, а он асинхронно реплицирует их на 2 другие реплики. - Eventually Consistent Read (по умолчанию): Ваш запрос на чтение может прийти к любой из 3-х реплик. Одна из них может отставать на 100 мс. Зато это быстро (т.к. можно читать с ближайшей) и дешево (1 RCU = 2 таких чтения).
- Strongly Consistent Read: Ваш запрос всегда идет на лидера партиции, который гарантирует, что у него самая свежая, подтвержденная запись. Но это больше нагружает лидера и дороже (1 RCU = 1 такое чтение).
Использование:
# Пример на Boto3 (Python)
import boto3
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('users')
# Eventually Consistent (по умолчанию)
response_eventual = table.get_item(
Key={'user_id': '123'}
)
# Strongly Consistent
response_strong = table.get_item(
Key={'user_id': '123'},
ConsistentRead=True # Вот этот флаг
)
Когда использовать каждую модель?
- Eventually Consistent: 95% всех сценариев. Лайки, комментарии, посты, IoT-данные, профили, кэш.
- Strongly Consistent: 5% сценариев. Финансовые транзакции, инвентарь ("осталось 3 товара, надо забронировать"), критичные сценарии "read your own writes" (вы изменили пароль и сразу должны с ним логиниться).
Модели консистентности в DynamoDB
Вопросов: 5
5.2. Транзакции
Долгие годы "NoSQL" был синонимом "никаких транзакций". Это было главным аргументом противников, и, честно говоря, главной головной болью разработчиков, которые пытались обеспечивать атомарность на уровне приложения.
Но что делать, если вы продаете билеты на концерт?
Update(ticket='A1', status='RESERVED', user='alice')Update(user='alice', balance=balance-100)
Что если шаг 1 прошел, а шаг 2 упал (например, ProvisionedThroughputExceededException)? Вы отдали билет бесплатно.
Что если шаг 2 прошел, а шаг 1 упал? Вы взяли деньги, но не дали билет.
Это классическая потребность в атомарности "все или ничего".
Но есть и вторая, не менее важная причина: атомарные проверки условий (Conditional Checks) на разных элементах.
Представьте тот же сценарий, но сложнее:
- Проверить, что
ticket='A1'имеетstatus='AVAILABLE'. - Проверить, что
user='alice'имеетbalance >= 100. - Только если ОБА условия верны, выполнить: a.
Update(ticket='A1', status='RESERVED'), b.Update(user='alice', balance=balance-100).
Вы не можете сделать это двумя отдельными запросами. Между шагом 1 и 2 другой пользователь может купить билет. Вам нужно, чтобы и проверка, и запись для обоих элементов произошли как одна неделимая операция.
Механизм транзакций в DynamoDB
DynamoDB предоставляет полноценные ACID-транзакции (Атомарность, Консистентность, Изоляция, Долговечность) для нескольких элементов (до 100 за раз). Они бывают двух типов:
TransactWriteItems
Это "рабочая лошадка" транзакций. Она позволяет вам сгруппировать до 100 операций Put, Update или Delete в один запрос "все или ничего".
- Атомарность: Либо все 100 операций успешно применяются, либо ни одна из них не применяется.
- Изоляция: Во время выполнения транзакции (которое занимает миллисекунды), другие запросы не увидят наполовину примененное состояние.
- Проверки условий: Это самое мощное. Каждая операция (
Update,Put...) внутри транзакции может иметь свой собственныйConditionExpression. Если хотя бы одно из 100 условий не выполняется (например,balance < 100илиticket_status != 'AVAILABLE'), вся транзакция немедленно откатывается.
import boto3
# Убедитесь, что у вас есть клиент
dynamodb_client = boto3.client('dynamodb')
try:
# Этот токен КРИТИЧЕСКИ важен для идемпотентности
# (подробнее об этом ниже)
client_token = "my-unique-transaction-id-123"
response = dynamodb_client.transact_write_items(
ClientRequestToken=client_token,
TransactItems=[
{
# 1. Забронировать билет
'Update': {
'TableName': 'Tickets',
'Key': {'ticket_id': {'S': 'A1'}},
'UpdateExpression': 'SET owner = :user',
# Условие: билет должен быть свободен
'ConditionExpression': 'attribute_not_exists(owner)',
'ExpressionAttributeValues': {':user': {'S': 'alice'}}
}
},
{
# 2. Списать деньги
'Update': {
'TableName': 'Users',
'Key': {'user_id': {'S': 'alice'}},
'UpdateExpression': 'SET balance = balance - :val',
# Условие: у пользователя должно быть достаточно денег
'ConditionExpression': 'balance >= :val',
'ExpressionAttributeValues': {':val': {'N': '100'}}
}
}
]
)
print("Transaction Successful!")
except dynamodb_client.exceptions.TransactionCanceledException as e:
# Это СПЕЦИАЛЬНАЯ ошибка для транзакций
print(f"Transaction Failed:")
# e.CancellationReasons покажет, какое условие не выполнилось
for reason in e.response.get('CancellationReasons', []):
if reason.get('Code') == 'ConditionCheckFailed':
print(f" Reason: Condition check failed for item {reason['Item']}.")
else:
print(f" Reason: {reason['Code']}")
except Exception as e:
# Другие ошибки (например, сетевые)
print(f"An error occurred: {e}")
TransactGetItems
Почему бы просто не сделать два GetItem подряд?
Представьте: У пользователя есть два счета: "Текущий" (checking) и "Накопительный" (savings). Вам нужно показать виджет "Общий баланс", который является суммой этих двух счетов.
- Ваше приложение делает
GetItem(account='checking')-> получает 100$. - В эту миллисекунду в бэкенде срабатывает
TransactWriteItems(перевод денег):Update(account='checking', balance=balance-50),Update(account='savings', balance=balance+50). - Ваше приложение (продолжая выполнять свой код) делает
GetItem(account='savings')-> и читает уже новое значение: 150$. - Результат: Приложение показывает "Общий баланс: 100$ + 150$ = 250$".
Хотя реальный общий баланс пользователя всегда был 200$ (100+100 до, 50+150 после). Вы только что создали 50$ из воздуха из-за "разорванного чтения" (torn read).
TransactGetItems решает эту проблему. Он "замораживает" время и читает до 100 элементов одновременно, гарантируя "снимок" (snapshot isolation). Выполняя TransactGetItems для checking и savings, вы гарантированно получите либо (100$, 100$), либо (50$, 150$), но никогда (100$, 150$). Ваш "Общий баланс" всегда будет 200$.
Важное замечание: TransactGetItems не решает гонку состояний (race condition) при записи. Если вы прочитали, что билет свободен, это не мешает другому процессу купить его в следующую миллисекунду. Для атомарной проверки-и-записи используется только TransactWriteItems с ConditionExpression. TransactGetItems решает проблему консистентности при отображении данных.
Ограничения и "подводные камни"
- Стоимость: Транзакции дорогие. Они потребляют в 2 раза больше RCU/WCU.
- Запись: Каждый
Updateв транзакции потребляет 2 WCU (вместо 1). 1 WCU за "подготовку" (чтение для проверки конфликтов) и 1 WCU за "фиксацию" (запись). - Чтение: Каждая
GetвTransactGetItemsпотребляет 2 RCU (вместо 1 RCU/0.5 RCU).
- Запись: Каждый
- Лимиты: До 100 элементов в одной транзакции. (Раньше было 25, так что это улучшение.) Общий размер транзакции не может превышать 4 МБ.
- Идемпотентность (Очень важно!):
TransactWriteItemsпо умолчанию не идемпотентна. Если вы отправили запрос, но получили тайм-аут сети, вы не знаете, прошла транзакция или нет. Если вы отправите тот же запрос еще раз, вы можете продать два билета и списать деньги дважды.- Решение: Вы обязаны использовать
ClientRequestToken. Это уникальная строка, которую вы генерируете. Если DynamoDB получает два запроса с одним и тем же токеном (в течение 10 минут), он выполнит его только один раз, а на второй запрос просто вернет сохраненный результат первого.
- Решение: Вы обязаны использовать
- Обработка ошибок: Не ловите
Exception. ЛовитеTransactionCanceledException. Оно содержит полеCancellationReasons, которое точно скажет вам, почему транзакция была отменена (например,ConditionCheckFailedилиThrottlingError).
Вывод: Транзакции - это мощнейший инструмент для обеспечения целостности данных, который закрыл большой пробел в NoSQL. Но они - "скальпель", а не "молоток". Они дороже и сложнее обычных операций. Используйте их только там, где атомарность "все или ничего" или атомарные проверки являются абсолютным бизнес-требованием.
Транзакции в DynamoDB
Вопросов: 6
5.3. Оптимистическая блокировка
Давайте вернемся к проблеме конкурентного доступа, но посмотрим на неё под другим углом.
Сценарий: Админ-панель и конфиги
Представьте, что у вас есть внутренняя админка для управления микросервисами.
- DevOps Ержан открывает страницу настроек сервиса
Payment-Gateway. Он видитtimeout: 30s. Он хочет изменить это на60s, так как сервис не справляется. - В эту же секунду SRE Катя открывает ту же страницу. Она тоже видит
timeout: 30s. Но она пришла, чтобы изменить другой параметр - включитьdebug_mode: trueдля отладки инцидента. - Ержан меняет таймаут, нажимает "Сохранить". В базу улетает JSON:
{timeout: 60, debug: false}. - Катя (которая всё еще смотрит на старую версию страницы, где таймаут 30) включает дебаг и нажимает "Сохранить". В базу улетает JSON:
{timeout: 30, debug: true}.
Проблема (Lost Update): Запись Кати полностью перезатерла изменения Ержана. Таймаут снова стал 30 секунд. Ержан уверен, что починил прод, но прод продолжает падать.
Решение: Оптимизм вместо блокировок
Оптимистическая блокировка работает по принципу git push. Если вы попытаетесь сделать пуш в репозиторий, но коллега успел залить свои коммиты раньше, Git отклонит ваш пуш и скажет: "Сначала стяни изменения (pull), разреши конфликты, а потом пушь".
В DynamoDB мы реализуем ту же логику через Versioning.
- Мы добавляем в каждый элемент атрибут
version(число). - Когда фронтенд читает конфиг, он запоминает версию (например,
v=1). - Когда фронтенд отправляет изменения назад, он говорит DynamoDB: "Обнови этот конфиг, НО ТОЛЬКО ЕСЛИ его версия в базе всё ещё равна
1".
В рамках этой же операции мы атомарно увеличиваем версию (v=2).
ConditionExpression - страж ваших данных
В DynamoDB нет команд LOCK или UNLOCK. Вся магия происходит в параметре ConditionExpression.
Это условие проверяется на сервере AWS атомарно перед записью. Если условие возвращает False (версия в базе уже 2, а мы прислали 1), запись отклоняется, и данные не портятся.
from botocore.exceptions import ClientError
def update_config_safely(service_name, new_config, version_read_by_admin):
try:
response = table.update_item(
Key={'service_name': service_name},
# 1. Мы обновляем поля конфига и инкрементируем версию
UpdateExpression='SET config = :new_conf, version = version + :incr',
# 2. ЖЕЛЕЗНОЕ УСЛОВИЕ:
# "Выполняй это, только если версия в базе совпадает с той, что была в браузере"
ConditionExpression='version = :expected_version',
ExpressionAttributeValues={
':new_conf': new_config,
':incr': 1,
':expected_version': version_read_by_admin
},
ReturnValues="UPDATED_NEW"
)
print("Успех! Конфиг обновлен. Новая версия:", response['Attributes']['version'])
except ClientError as e:
# 3. Перехватываем отказ
if e.response['Error']['Code'] == 'ConditionalCheckFailedException':
print("Конфликт! Кто-то (Катя?) изменил конфиг, пока вы редактировали.")
print("Нужно показать пользователю ошибку: 'Данные устарели, обновите страницу'.")
else:
raise
Транзакции vs Оптимистическая блокировка
Вы можете спросить: "Зачем мне это, если есть транзакции (TransactWriteItems)?".
Это вопрос цены и масштаба.
- Стоимость: Транзакции стоят x2 WCU (подготовка + коммит). Оптимистическая блокировка стоит x1 WCU. Это обычный
UpdateItem, просто с условием. - Сценарий:
- Транзакции нужны для атомарности нескольких разных элементов ("списать деньги у юзера А, начислить юзеру Б").
- Оптимистическая блокировка идеальна для защиты целостности одного сложного объекта, который долго редактируется на клиенте.
Где подвох?
Оптимистическая блокировка плоха при высокой конкуренции (High Contention).
Представьте, что вы реализуете счетчик "Количество мест на курсе" и 1,000 человек одновременно нажали "Купить".
- Все 1,000 прочитали
seats=10, version=5. - Все 1,000 пытаются сделать
Update ... Condition version=5. - Один запрос проходит.
- 999 запросов получают ошибку
ConditionalCheckFailedException.
В этом случае оптимистическая блокировка превращается в DDoS-атаку на самого себя, так как клиенты будут бесконечно пытаться перечитать и перезаписать данные. Для таких счетчиков лучше использовать атомарные инкременты (SET seats = seats - 1) без проверки версий, либо полноценные очереди.
5.4. Time To Live (TTL)
Ваша таблица user_sessions раздулась до 50 ТБ, и 99% из этого - сессии 5-летней давности. Это стоит вам денег за хранение.
Традиционный подход (плохой): Писать cron job, который Scan таблицу и Delete старые записи. Это ужасно: Scan дорогой, а Delete потребляет WCU.
Настройка и работа TTL
TTL - это бесплатное удаление элементов.
- Вы включаете TTL для таблицы, указывая имя атрибута (например,
expiration_time). - В этот атрибут вы должны записывать timestamp в формате Unix Epoch (секунды).
- Всё.
import time
# Записываем сессию, которая должна "умереть" через 24 часа
table.put_item(
Item={
'session_id': 'abc-123',
'user_id': 'alice',
'data': '...',
# Вот он, TTL
'expiration_time': int(time.time()) + 86400 # (now + 24h)
}
)
Особенности удаления
- Бесплатно: Удаление не потребляет WCU.
- Не мгновенно: DynamoDB гарантирует, что удалит элемент после
expiration_time, но не сразу. Обычно это занимает минуты, но AWS говорит, что может занять до 48 часов. - Не для логики: Не используйте TTL для точной бизнес-логики (например, "отменить заказ ровно в 12:00"). Используйте его для "сборки мусора".
- Streams: Удаленные через TTL элементы появляются в Streams, так что вы можете, например, триггерить Lambda и архивировать их в S3 перед удалением.
Оптимистическая блокировка и TTL
Вопросов: 5
5.5. Практика
Реализация целостности данных и транзакций для конкурента Twitter
Вы продолжаете работу как CTO стартапа-конкурента Twitter. Вместе с Назаром разберите модели консистентности (Eventually vs Strongly Consistent), транзакции для атомарных операций, оптимистическую блокировку для защиты от потерянных обновлений, и TTL для автоматической очистки устаревших данных.
Назар - ваш персональный ИИ-наставник. Он поможет закрепить материал через практику и ответит на ваши вопросы.
💡 Все обсуждения с ИИ могут быть прочитаны администратором для улучшения качества обучения.