Введение в распределённые транзакции
При работе с микросервисами обеспечение согласованности данных между несколькими сервисами может быть сложной задачей. Распределённые транзакции — это способ управления этой сложностью, но они сопряжены со своими проблемами. В этой статье мы погрузимся в мир распределённых транзакций, уделив особое внимание механизму двухфазной фиксации (2PC) в Go.
Зачем нужны распределённые транзакции?
В архитектуре микросервисов каждый сервис может иметь свою собственную базу данных или систему хранения. Когда транзакция затрагивает несколько сервисов, важно гарантировать, что либо все изменения будут зафиксированы, либо ни одно из них не будет зафиксировано, чтобы поддерживать согласованность данных. Например, в приложении электронной коммерции, когда пользователь размещает заказ, система должна обновить как учетную запись пользователя, так и базу данных заказов. Если одно обновление завершается сбоем, другое должно быть отменено, чтобы обеспечить согласованность.
Двухфазная фиксация (2PC)
Двухфазная фиксация — это протокол, предназначенный для обеспечения атомарности в распределенных транзакциях. Вот общее представление о том, как это работает:
Фаза 1: Подготовка
На этапе подготовки каждому участнику (сервису) предлагается подготовиться к транзакции. Если какой-либо участник не может подготовиться, транзакция прерывается.
Фаза 2: Подтверждение или Откат
Если все участники успешно подготовятся, координатор транзакций отправляет сообщение о подтверждении. Если какой-либо участник терпит неудачу на этапе подготовки, отправляется сообщение об откате.
Реализация 2PC в Go
Чтобы реализовать 2PC в Go, мы можем использовать фреймворк DTM (Distributed Transaction Manager), который обеспечивает надежный способ обработки распределенных транзакций.
Настройка DTM
Сначала вам нужно настроить фреймворк DTM. Вот как вы можете это сделать:
git clone https://github.com/dtm-labs/dtm && cd dtm
go run main.go
Базовый пример
Давайте рассмотрим простой пример перевода средств между двумя учетными записями. У нас будет два сервиса: TransOut и TransIn.
Схема базы данных
CREATE TABLE IF NOT EXISTS user_account (
id INT(11) PRIMARY KEY AUTO_INCREMENT,
user_id INT(11) UNIQUE,
balance DECIMAL(10, 2) NOT NULL DEFAULT '0',
trading_balance DECIMAL(10, 2) NOT NULL DEFAULT '0',
create_time DATETIME DEFAULT NOW(),
update_time DATETIME DEFAULT NOW(),
KEY(create_time),
KEY(update_time)
);
Обработчики Try/Confirm/Cancel
В контексте 2PC нам нужно определить обработчики Try, Confirm и Cancel для каждого сервиса.
func transOutTry(ctx context.Context, req *busi.BusiReq) (interface{}, error) {
// Замораживаем сумму на счете отправителя
sql := "UPDATE user_account SET trading_balance = trading_balance + ? WHERE user_id = ? AND balance + trading_balance >= 0"
_, err := db.Exec(sql, req.Amount, req.UserID)
if err != nil {
return nil, err
}
return nil, nil
}
func transOutConfirm(ctx context.Context, req *busi.BusiReq) (interface{}, error) {
// Фиксируем транзакцию, переводя замороженную сумму получателю
sql := "UPDATE user_account SET balance = balance - ?, trading_balance = trading_balance - ? WHERE user_id = ?"
_, err := db.Exec(sql, req.Amount, req.Amount, req.UserID)
if err != nil {
return nil, err
}
return nil, nil
}
func transOutCancel(ctx context.Context, req *busi.BusiReq) (interface{}, error) {
// Откатываем транзакцию, размораживая сумму
sql := "UPDATE user_account SET trading_balance = trading_balance - ? WHERE user_id = ?"
_, err := db.Exec(sql, req.Amount, req.UserID)
if err != nil {
return nil, err
}
return nil, nil
}
// Аналогичные обработчики для службы TransIn
Создание транзакции 2PC
func main() {
// Создаем новую транзакцию
trans, err := dtmcli.NewTrans(dtmcli.MustGenGid(), dtmcli.DefaultConf, "transOut", "transIn")
if err != nil {
log.Fatal(err)
}
// Добавляем ветви для каждого сервиса
trans.AddBranch(&dtmcli.Branch{
Op: "transOutTry",
Target: "http://trans-out-service/try",
})
trans.AddBranch(&dtmcli.Branch{
Op: "transInTry",
Target: "http://trans-in-service/try",
})
trans.AddBranch(&dtmcli.Branch{
Op: "transOutConfirm",
Target: "http://trans-out-service/confirm",
})
trans.AddBranch(&dtmcli.Branch{
Op: "transInConfirm",
Target: "http://trans-in-service/confirm",
})
trans.AddBranch(&dtmcli.Branch{
Op: "transOutCancel",
Target: "http://trans-out-service/cancel",
})
trans.AddBranch(&dtmcli.Branch{
Op: "transInCancel",
Target: "http://trans-in-service/cancel",
})
// Начинаем транзакцию
err = trans.Submit()
if err != nil {
log.Fatal(err)
}
}
Обработка сетевых исключений
Одним из важных аспектов распределенных транзакций является обработка сетевых исключений. DTM предоставляет утилиту BranchBarrier для обеспечения идемпотентности и обработки сетевых сбоев.
func (bb *BranchBarrier) Call(tx *sql.Tx, busiCall BarrierBusiFunc) error {
// Это гарантирует, что операция внутри этой функции будет вызвана не более одного раза
return busiCall(tx)
}
Механизм отката
Если какая-либо часть транзакции завершается сбоем, DTM автоматически инициирует операции отмены для отката транзакции.
Последовательность Диаграммы
Координатор Участника как Координатор Транзакций Сервис А Участника как Сервис TransOut Сервис Б Участника как Сервис TransIn
Примечание к Координатору, Сервису B: Начать Транзакцию Координатор →> Сервис A: Подготовиться (transOutTry) Сервис A →> Координатор: Готово Координатор →> Сервис B: Подготовиться (transInTry) Сервис B →> Координатор: Готов
Примечание для Координатора, Сервиса B: Все сервисы подготовлены успешно Координатор →> Сервис А: Подтвердите (transOutConfirm) Сервис А →> Координатор: Зафиксировано Координатор →> Сервис В: Подтвердите (transInConfirm) Сервис В →> Координатор: Зафиксировано
Примечание для Координатора, Сервиса В: Транзакция успешно зафиксирована
Альтернатива сбою во время фазы подготовки
Координатор →> Сервис A: Подготовка (transOutTry) Сервис A →> Координатор: Готово Координатор →> Сервис B: Подготовка (transInTry) Сервис B →> Координатор: Сбой
Примечание Координатору, Сервису В: Один сервис не смог подготовиться Координатор →> Сервис A: Откат (transOutCancel) Сервис A →> Координатор: Отменен Координатор →> Сервис B: Откат (transInCancel) Сервис B →> Координатор: Отменен
Примечание Координатору, Сервису В: Транзакция отменена успешно Конец
Лучшие практики и альтернативы
Избегание распределённых транзакций при возможности
Хотя распределённые транзакции являются мощным инструментом, они могут значительно усложнить вашу систему. Если возможно, лучше избегать их, убедившись, что границы вашего микросервиса правильно определены и что каждый сервис управляет своей собственной согласованностью.
Принятие итоговой согласованности
В некоторых случаях итоговая согласованность может быть более простым и масштабируемым решением. Это включает использование очередей сообщений и источников событий для обеспечения согласованности данных между сервисами.
Использование шаблона Outbox
Шаблон outbox — это еще один подход для обеспечения согласованности. Он включает хранение событий в той же транзакции базы данных, что и основные данные, а затем асинхронную публикацию этих событий. Это гарантирует, что событие и сохраненные данные всегда согласованы.
Заключение
Распределенные транзакции — сложный, но необходимый аспект архитектуры микросервисов. Используя такие фреймворки, как DTM, и следуя передовым практикам, таким как обработка сетевых исключений и рассмотрение альтернатив, таких как итоговая согласованность и шаблон outbox, вы можете гарантировать, что ваша