Привет, Хабр!
Микросервисы стали выбором многих благодаря их гибкости, масштабируемости и способности поддерживать сложные приложения в динамично меняющемся мире.
Но не всё так просто. Одной из ключевых проблем, с которыми сталкиваются при переходе от монолитных архитектур к микросервисным, является обеспечение согласованности данных.
Каждый сервис работает автономно, управляя своим собственным набором данных. Как гарантировать, что данные, распределенные по разным сервисам, будут консистентными? Как избежать потери данных и обеспечить их актуальность в реальном времени?
Основные проблемы согласованности в микросервисах
Разделение данных между сервисами
В микросервисных архитектурах каждый сервис обычно обладает собственной базой данных, чтобы гарантировать независимость, масштабируемость и устойчивость системы. Такой подход называется "Database per Service".
Однако, когда разные аспекты одного бизнес-процесса обрабатываются разными сервисами с разными базами данных, возникает проблема синхронизации и согласованности данных между этими сервисами.
В традиционных монолитных системах транзакционная согласованность достигается через использование ACID-транзакций (атомарность, согласованность, изоляция, долговечность) на уровне одной базы данных. В микросервисной архитектуре обеспечить ACID-транзакционность между разными базами данных значительно сложнее, так как они физически и логически разделены.
Коммуникация между сервисами обычно происходит через сеть, используя протоколы вроде HTTP/REST, gRPC или асинхронные сообщения. Это вводит дополнительные задержки и потенциальные точки отказа. Например, если один сервис должен обновить данные, а затем уведомить другой сервис об этих изменениях, проблемы со связью могут привести к несогласованности данных.
Разделение данных означает разделение бизнес-логики, связанной с этими данными. Каждый микросервис обрабатывает только часть бизнес-процесса, что может привести к ситуациям, когда для выполнения одной задачи необходимо взаимодействовать с несколькими сервисами. Это усложняет бизнес-логику и увеличивает вероятность ошибок.
Сетевые задержки и проблемы связи
В микросервисных архитектурах сервисы взаимодействуют друг с другом через сетевые вызовы, используя протоколы как HTTP/HTTPS, gRPC, AMQP и другие. Каждый из этих вызовов может включать сетевые задержки, которые, в зависимости от различных факторов (например, физическое расстояние между серверами, качество сети), могут значительно различаться.
Сетевые задержки могут привести к увеличению времени ответа сервисов. Это заметно в операциях, требующих множественных взаимодействий между сервисами. Например, операция, которая требует последовательных вызовов к нескольким микросервисам, может значительно замедлиться из-за суммарной задержки каждого вызова.
Сетевые задержки также увеличивают вероятность сбоев и ошибок. Если один сервис ожидает ответа от другого и задержка превышает ожидаемое время, это может привести к тайм-аутам и сбоям в работе системы. Такие ситуации требуют реализации механизмов повторных попыток и балансировки нагрузки.
В условиях сетевых задержек поддержание консистентности данных между сервисами становится сложной задачей. Если данные обновляются в одном сервисе, сетевая задержка может привести к тому, что другие сервисы будут работать с устаревшими данными.
Реализация распределенных транзакций в условиях сетевых задержек представляет собой сложную задачу. Традиционные методы, такие как двухфазный коммит, могут стать неэффективными из-за увеличения времени ожидания подтверждений от всех участвующих сервисов.
Чтобы снизить влияние сетевых задержек, часто используется распределенное кеширование и оптимизация запросов. Однако это влечет за собой дополнительные сложности, связанные с обеспечением согласованности кешей и управлением их жизненным циклом.
Различия в обработке транзакций и состоянии данных
Каждый сервис часто разрабатывается независимо, что может привести к использованию различных подходов и технологий для обработки транзакций. Например, один сервис может использовать традиционные реляционные базы данных с поддержкой ACID-транзакций, в то время как другой может использовать NoSQL-решения, ориентированные на BASE-согласованность (Basically Available, Soft state, Eventual consistency).
Различные подходы к обработке транзакций в разных сервисах создают сложности в поддержании согласованности данных. Например, если один сервис обновляет данные и эти изменения должны быть отражены в другом сервисе, механизмы синхронизации и согласованности могут быть сложными и подвержены ошибкам, особенно если сервисы используют разные модели согласованности данных.
Проблемы в обеспечении целостности данных
В микросервисных архитектурах данные обычно распределены по различным сервисам. Каждый микросервис может управлять своей базой данных, что означает, что нет единой, централизованной точки для контроля над всеми данными. Это усложняет обеспечение их целостности, поскольку изменения в одном сервисе могут потребовать соответствующих изменений в других сервисах.
В традиционных монолитных приложениях целостность данных часто обеспечивается с помощью ACID-транзакций в рамках одной базы данных. В микросервисных архитектурах достичь такой же степени транзакционной целостности сложнее из-за физической и логической разделенности баз данных.
В условиях распределенных систем часто используется репликация данных между сервисами для улучшения производительности и отказоустойчивости. Однако репликация вносит дополнительные трудности в поддержание целостности, особенно в аспектах синхронизации и обработки конфликтов.
Стратегии cогласованности
Сильная согласованность
Сильная согласованность означает, что все узлы системы в любой момент времени имеют одну и ту же информацию о состоянии данных. Как только данные изменяются в одной части системы, эти изменения мгновенно становятся доступными для всех других частей системы.
Для достижения сильной согласованности часто используются блокирующие операции, гарантирующие, что изменение данных будет полностью выполнено прежде, чем оно станет видимым для других операций.
Системы, обеспечивающие сильную согласованность, обычно реализуют принципы ACID-транзакций, где соблюдается атомарность, согласованность, изоляция и долговечность изменений.
Традиционные реляционные БД, такие как PostgreSQL или MySQL, часто обеспечивают сильную согласованность.
Слабая согласованность (Weak Consistency)
В системах со слабой согласованностью гарантируется, что изменения данных в конечном итоге будут видны всем узлам, но не уточняется, когда это произойдет.
Системы со слабой согласованностью часто используют асинхронные механизмы репликации и обновления данных.
Нет гарантии мгновенного отражения изменений данных во всей системе, что может приводить к временным несоответствиям.
Cлабая согласованность обеспечивается с помощью систем кеширования, такими как Memcached, где приемлемо некоторое временное несоответствие данных. Также обеспечивается с помощью некоторых типов NoSQL баз данных, которые оптимизированы для высокой производительности и масштабируемости, но с компромиссом в согласованности данных.
Событийная согласованность (Eventual Consistency)
Событийная согласованность обеспечивает, что если изменения в данных прекратятся, то после некоторого времени все узлы системы сойдутся к одному и тому же состоянию данных.
Часто реализуется через асинхронные процессы репликации и синхронизации данных.
Обеспечивает баланс между согласованностью и доступностью, принимая временное расхождение данных.
Реализуется с помощью файловых систем, баз данных типа Amazon DynamoDB, где важна высокая доступность и отказоустойчивость.
Шаблоны проектирования для управления согласованностью
Паттерн Saga
Не всегда возможно использовать традиционные средства управления транзакциями, такие как двухфазный коммит. Saga решает эту проблему, позволяя разбить большую транзакцию на серию меньших, локальных транзакций.
Существует два основных способа реализации Saga: оркестрация и хореография.
В орекстрации один центральный сервис (оркестратор) контролирует последовательность шагов и логику выполнения Saga.
В хореографии в свою очередь каждый сервис самостоятельно знает, какие действия ему предпринимать и какие события отправлять после завершения своей части Saga.
Если в какой-то момент Saga не может быть завершена успешно (например, из-за ошибки в одном из сервисов), необходимо выполнить откат изменений. Для этого в Saga определяются компенсационные транзакции, которые отменяют изменения, сделанные в предыдущих шагах.
Важно учитывать состояние Saga и обеспечивать его долговечность, что позволяет системе восстанавливаться после сбоев. Это может быть реализовано с помощью сохранения состояния Saga в постоянное хранилище.
Ключевым аспектом в паттерне Saga является идемпотентность компенсирующих действий. Давайте разберемся, почему это так важно и как это работает на практике.
Идемпотентное действие – это такое действие, которое при повторном выполнении дает тот же результат, что и при первом. Формально это описывается как
где f – идемпотентная операция, а x – ее аргумент.
В Saga идемпотентность компенсационных действий означает, что если процесс отката не завершается успешно (например, из-за сбоя системы) и требуется его повторение, повторный откат не приведет к нежелательным эффектам или изменениям состояния, отличным от ожидаемого.
Каждый шаг в Saga имеет свою компенсационную транзакцию, которая вызывается для отката изменений, если в последующих шагах происходит сбой.
Например, если Saga включает бронирование отеля, билета на самолет и аренду автомобиля, и сбой происходит при аренде автомобиля, компенсационные транзакции будут отменять бронирование отеля и билета на самолет.
Компенсационные действия должны быть идемпотентны, чтобы их можно было безопасно повторять. Это означает, что если процесс отката прерывается, его можно безопасно запустить снова, не боясь повторных изменений или ухудшения состояния системы.
Для обеспечения идемпотентности компенсационные действия часто включают логику проверки текущего состояния системы или объекта. Если состояние указывает на то, что компенсация уже была выполнена (или не требуется), дальнейшие действия не предпринимаются.
Представим, что у нас есть операция, которая добавляет запись в базу данных. Компенсационная операция удаляет эту запись. Если процесс отката запускается повторно, он сначала проверяет, существует ли запись в базе. Если записи нет, это означает, что компенсация уже была выполнена, и ничего делать не нужно.
CQRS (Command Query Responsibility Segregation)
Основная идея CQRS заключается в разделении ответственности между командами (commands), которые изменяют состояние системы (например, создание, изменение, удаление данных), и запросами (queries), которые читают состояние системы. Это разделение позволяет оптимизировать и независимо масштабировать обе части системы.
Команды могут представлять собой операции, которые изменяют данные, и они не должны возвращать какой-либо результат данных. Напротив, запросы используются исключительно для чтения данных и не должны влиять на их состояние.
В CQRS для операций чтения и записи используются разные модели:
Модели для записи
Модели записи обычно оптимизированы для транзакционной целостности и эффективного выполнения операций изменения. В этой части часто используются реляционные базы данных, обеспечивающие ACID-транзакции.
Модели для чтения
Модели чтения могут быть спроектированы для максимально эффективного доступа к данным, что включает в себя использование NoSQL-баз данных, денормализацию данных и специализированные индексы.
Поскольку модели чтения могут быть спроектированы специально для удовлетворения конкретных запросов, они могут быть значительно проще и более эффективны в использовании, чем более сложные модели записи.
Преимущества и недостатки CQRS:
Преимущества:
Улучшенная производительность и масштабируемость.
Упрощение бизнес-логики за счет разделения команд и запросов.
Гибкость в выборе технологий и подходов для разных аспектов системы.
Недостатки:
Увеличение сложности системы.
Потенциальное увеличение объема работы, поскольку требуется поддержка двух разных моделей.
Сложности с согласованностью данных между моделями чтения и записи.
CQRS может значительно помочь в управлении сложностью и согласованностью данных. Каждый микросервис может имплементировать CQRS независимо, обеспечиваяоптимальное использование ресурсов и более простую интеграцию с другими сервисами системы.
Создадим пример CQRS, который будет иллюстрировать разделение логики на команды (для изменения данных) и запросы (для чтения данных). В реальных приложениях CQRS обычно реализуется в более сложных системах, включая разделение на разные сервисы или использование специальных фреймворков и баз данных:
class User:
def __init__(self, name, email):
self.name = name
self.email = email
# Сервис для обработки команд
class CommandService:
def __init__(self):
self.users = []
def create_user(self, name, email):
user = User(name, email)
self.users.append(user)
print(f"User {name} created")
def update_user_email(self, name, new_email):
user = next((u for u in self.users if u.name == name), None)
if user:
user.email = new_email
print(f"User {name}'s email updated to {new_email}")
# Сервис для обработки запросов
class QueryService:
def __init__(self, command_service):
self.command_service = command_service
def get_user(self, name):
user = next((u for u in self.command_service.users if u.name == name), None)
if user:
print(f"User {name}: {user.email}")
return user
else:
print(f"User {name} not found")
# Использование CQRS
command_service = CommandService()
query_service = QueryService(command_service)
command_service.create_user("Alice", "alice@example.com")
query_service.get_user("Alice")
command_service.update_user_email("Alice", "alice_new@example.com")
query_service.get_user("Alice")
User
– это простая модель данных пользователя. CommandService
отвечает за обработку команд (создание и обновление пользователей). Этот сервис изменяет состояние данных.
QueryService
отвечает за обработку запросов и использует данные, управляемые CommandService
, для предоставления информации. Этот сервис не изменяет данные, только читает их.
CQRS часто используется в сочетании с Event Sourcing, где изменения состояния системы фиксируются как серия событий, что упрощает восстановление исторических состояний и аудит.
Event Sourcing
Вместо традиционного подхода сохранения текущего состояния объектов в базе данных, Event Sourcing сохраняет последовательность событий, которые описывают, как состояние объекта изменялось со временем.
Каждое событие является неизменяемым и представляет факт, произошедший в прошлом. Это означает, что однажды записанные события не могут быть изменены или удалены.
Чтобы получить текущее состояние объекта, система "проигрывает" все события от начала до конца. Это позволяет восстановить состояние объекта на любой момент времени.
Для реализации ES требуется специализированная система (например Apache Kafka) или база данных для хранения событий. Эти системы оптимизированы для быстрого добавления данных и эффективного чтения последовательностей событий.
Каждое событие моделируется как объект, содержащий всю необходимую информацию для восстановления части состояния. Например, событие "Товар добавлен в корзину" может содержать информацию о товаре, его количестве и идентификаторе пользователя.
В Event Sourcing агрегат – это объект или сущность, состояние которого восстанавливается через последовательность событий. Агрегаты являются основными элементами, вокруг которых строится логика приложения.
Система обрабатывает команды, которые могут приводить к созданию новых событий. Например, команда "Добавить товар в корзину" может породить событие "Товар добавлен в корзину".
Для оптимизации операций чтения можно использовать проекции, которые представляют собой представления агрегатов, оптимизированные для конкретных запросов или операций.
Преимущества и недостатки Event Sourcing:
Преимущества:
Полная история изменений, что полезно для аудита, отладки и бизнес-аналитики.
Упрощение сложных бизнес-транзакций, так как система работает с последовательностями событий.
Возможность "отката" состояния приложения к любому моменту времени.
Недостатки:
Сложность в реализации и понимании, особенно для команд, не знакомых с этим подходом.
Возможное увеличение объема хранимых данных из-за необходимости хранения всех событий.
Сложности с интеграцией с традиционными системами и инструментами.
Распределённые транзакции и двухфазный коммит
Распределённые транзакции
Атомарность означает, что все операции в рамках одной транзакции либо выполняются полностью, либо не выполняются вовсе.
Согласованность гарантирует, что транзакция переводит систему из одного консистентного состояния в другое. Это означает, что данные остаются валидными с точки зрения бизнес-правил и ограничений базы данных.
Распределённые транзакции включают взаимодействие между различными базами данных или сервисами, что требует сложной координации. Одним из способов реализации такой координации является использование менеджера транзакций.
Одной из основных проблем при работе с распределёнными транзакциями является задержка, вызванная необходимостью координации между участниками и сетевыми задержками.
Двухфазный Коммит (2PC)
Двухфазный коммит – это протокол, который обеспечивает атомарность распределённых транзакций. Он делится на две фазы: фазу подготовки и фазу коммита.
Фаза подготовки (предкоммит)
В этой фазе координатор транзакций запрашивает участников (например, баз данных) подготовиться к фиксации транзакции. Каждый участник выполняет необходимые операции и готовит изменения к коммиту, но не фиксирует их окончательно.
Если все участники сообщают о готовности, процесс переходит ко второй фазе. Если хотя бы один участник сообщает о невозможности выполнения транзакции, начинается процесс отката.
Фаза коммита
Если все участники готовы, координатор посылает команду на фиксациютранзакции. После этого каждый участник фиксирует свою часть транзакции.
Если происходит сбой в процессе коммита, вступает в силу механизм восстановления для обеспечения целостности данных.
Координатор – это компонент, который управляет процессом двухфазного коммита, общаясь с каждым участником и управляя процессом подготовки и коммита.
Весь процесс зависит от координатора, что может стать узким местом и причиной сбоев.
Если представить 2pc в коде, то это будет выглядеть так:
class TransactionCoordinator:
def __init__(self):
self.participants = []
def add_participant(self, participant):
self.participants.append(participant)
def prepare(self):
for participant in self.participants:
if not participant.prepare():
return False
return True
def commit(self):
for participant in self.participants:
participant.commit()
def rollback(self):
for participant in self.participants:
participant.rollback()
class Participant:
def __init__(self, name):
self.name = name
self.prepared = False
def prepare(self):
# Выполнить необходимые действия для подготовки
# Вернуть True, если подготовка прошла успешно, иначе - False
self.prepared = True
return self.prepared
def commit(self):
if self.prepared:
# Фиксировать изменения
print(f"Committing {self.name}")
def rollback(self):
# Откатить изменения, если что-то пошло не так
print(f"Rolling back {self.name}")
# Использование координатора транзакций и участников
coordinator = TransactionCoordinator()
coordinator.add_participant(Participant("Database 1"))
coordinator.add_participant(Participant("Database 2"))
# Фаза подготовки
if coordinator.prepare():
# Фаза коммита
coordinator.commit()
else:
# Откат в случае ошибки
coordinator.rollback()
TransactionCoordinator
управляет процессом транзакции, координируя действия всех участников. Каждый Participant
представляет собой участника транзакции (например, сервис или базу данных). В методе prepare
каждого участника происходит подготовка к транзакции. Если хотя бы один участник не готов, координатор инициирует откат. Если все участники готовы, координатор инициирует фазу коммита.
Контроль версий данных
Контроль версий данных включает в себя отслеживание изменений, вносимых в каждый элемент данных. Это может быть реализовано путем сохранения метаданных о каждом изменении, например, временных меток, идентификаторах версий или журналах изменений.
В распределенных системах данные могут одновременно изменяться различными процессами. Механизмы контроля версий помогают управлять этим конкурентным доступом, предотвращая конфликты и потерю данных.
В случае ошибок или сбоев системы механизмы контроля версий позволяют восстановить предыдущее состояние данных, что критически важно для поддержания надежности системы.
Реализация в микросервисных архитектурах
Использование распределенных журналов, таких как Apache Kafka, позволяет отслеживать изменения данных во всей системе. Каждое изменение записывается как событие в журнал, обеспечивая возможность восстановления и аудита.
В Event Sourcing изменения состояния системы фиксируются как последовательность событий. Это обеспечивает версионирование всего состояния системы на основе этих событий.
Оптимистичная блокировка подразумевает, что конфликты редки, и система должна обрабатывать их после возникновения. Пессимистичная блокировка предполагает блокирование ресурсов на время операции, предотвращая конфликты.
Многие NoSQL-базы данных, такие как Cassandra или MongoDB, предоставляют встроенные механизмы версионирования и управления конкурентными изменениями.
В RESTful API ETag используется для определения версии ресурса, что позволяет эффективно управлять обновлениями и предотвращать конфликты.
Заключение
Управление данными в распределенных системах – это сложная, но в то же время интересная задача. Выбор определенной стратегии должен базироваться на конкретных требованиях и условиях вашей системы. Важно помнить, что внедрение новых технологий проект также влечет за собой повышенную сложность и потребность в дополнительных ресурсах для поддержания и развития системы.
Напоследок хочу порекомендовать бесплатный урок от моих коллег из OTUS, где они расскажут про основные паттерны использования GraphQL и gRPC.
Зарегистрироваться на бесплатный вебинар