Розподілені транзакції в Go: що варто знати перед початком

Переклад 24 хв. читання

Розподілені транзакції в Go: Варто прочитати, перш ніж спробувати

У попередній статті я розглянув роботу транзакцій у багаторівневій архітектурі. Тепер розгляньмо транзакції, які мають охоплювати більше одного сервісу.

Якщо ви працюєте з мікросервісами, може настати час, коли вам знадобиться транзакція, яка охоплює декілька сервісів. Особливо, якщо спосіб їх розділення був непродуманим (прикрий, але ймовірний сценарій). Сервіс A викликає сервіс B, який викликає сервіс C, і якщо в кінці щось піде не так, система стає неузгодженою. Було б корисно мати можливість відкотити зміни у всіх сервісах.

Розподілені транзакції в Go: що варто знати перед початком

Зараз ми розглянемо розподілені транзакції або патерн «сага». Іноді існують вагомі причини для використання цього патерну, хоча частіше це перебір. Якщо ви оберете цей шлях, ваша архітектура швидко стане набагато складнішою, ніж вам хотілося б. (Про це я дізнався на власному досвіді).

Якщо вам потрібно, щоб усе було узгоджено, тоді навіщо вам мікросервіси? Вся ідея використання полягає в тому, щоб тримати незалежні концепції окремо.

Подивимося правді в очі: ваші межі мікросервісу, швидше за все, неправильні, якщо ви розглядаєте можливість використання розподіленої транзакції. Це дуже поширена проблема, і її нелегко вирішити. Але все може стати набагато гірше, якщо ви застосуєте неправильний шаблон до того безладу, який у вас вже є.

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

❌ Анти-патерн: Розподілені транзакції

Тримайтеся подалі від транзакцій, які охоплюють більше одного сервісу, за винятком випадків, коли немає іншого виходу.

Їх важко тестувати, налагоджувати та підтримувати. Ваша система стає тісно пов'язаною, і її складніше розширювати.

Приклад

Наведені нижче фрагменти є продовженням прикладу з попередньої публікації. Коротко нагадаємо: ми працюємо з веб-застосунком для електронної комерції, де користувачі отримують віртуальні бали. Вони можуть використовувати ці бали як знижку на наступне замовлення. Проблема полягає в тому, щоб підтримувати узгодженість між балами користувача і застосованою знижкою.

Уявімо, що ми працюємо з двома сервісами: користувачами для аутентифікації та замовленнями для розміщення замовлень і відстеження знижок користувачів. Спочатку поділ здавався гарною ідеєю, оскільки це окремі сфери. Пізніше ми зрозуміли, що не можемо оновлювати користувачів та їхні знижки в рамках однієї транзакції.

Сервіс замовлень відкриває кінцеву HTTP-адресу для додавання знижки. У сервісі користувачів обробник для використання балів як знижки виглядає так:

func (h UsePointsAsDiscountHandler) Handle(ctx context.Context, cmd UsePointsAsDiscount) error {
	err := h.userRepository.UpdateByID(ctx, cmd.UserID, func(user *User) (bool, error) {
		err := user.UsePoints(cmd.Points)
		if err != nil {
			return false, err
		}

		return true, nil
	})
	if err != nil {
		return fmt.Errorf("could not update user: %w", err)
	}

	err = h.ordersService.AddDiscount(ctx, cmd.UserID, cmd.Points)
	if err != nil {
		return fmt.Errorf("could not add discount: %w", err)
	}

	return nil
}

Повний код

Спочатку ми беремо бали користувача. Після здійснення транзакції ми викликаємо службу замовлень, щоб додати знижку. Це добре працює в разі успішного завершення транзакції, але HTTP- виклик працює за межами транзакції. Якщо щось піде не так, бали зникають, але ми не додаємо знижку. Найкраще, що ми можемо зробити, це зареєструвати помилку і відправити оповіщення, щоб хтось із операторів міг вручну додати знижку роздратованому клієнту.

Ласкаво просимо до розподіленого моноліту.

Розподілені транзакції в Go: що варто знати перед початком

❌ Антипатерн: Розподілений моноліт

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

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

Якщо ви бачите проблему в тому, що «нам не вистачає узгодженості між сервісами», то запуск розподіленої транзакції здається хорошим рішенням. Але справжня проблема полягає в тому, що «кордони неправильні». Через цю невірну інтерпретацію дуже легко зробити проект ще складнішим.

Одна з альтернатив - прийняти кінцеву узгодженість (eventual consistency).

Кінцева узгодженість (Eventual Consistency)

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

Якщо щось піде не так, система може бути розсинхронізована на довший час. Але важливо те, що врешті-решт вона знову стає узгодженою. Ми не приймаємо неузгодженість - ми просто чекаємо, коли це станеться. Подумайте про банківський переказ. Гроші зникають з вашого рахунку миттєво, але не одразу з'являються на іншому рахунку. Ви знаєте, що в кінцевому підсумку переказ надійде через кілька годин або днів, тому це не є проблемою.

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

Визначення меж транзакції звучить розумно в сценарії «використання балів як знижки». Але оскільки нам вже доводиться працювати з розподіленим монолітом, вибір кінцевої узгодженості може бути наступним найкращим рішенням замість реалізації розподіленої транзакції.

Примітка

Я міг би вибрати інший приклад, який би краще підходив для кінцевої узгодженості. У деяких випадках дані взагалі не потребують узгодженості.

Але цей сценарій ближчий до того, що ви можете побачити у своїй повсякденній роботі. У цьому випадку використання розподіленої транзакції може бути заманливою ідеєю, тому що не очевидно, чи зможуть дані бути узгодженими в кінцевому підсумку.

Події

Кінцева узгодженість зазвичай означає роботу з подіями.

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

Подія поміщається в чергу повідомлень (також відома як Pub/Sub) - надійну, високодоступну інфраструктуру для повідомлень. Служба замовлень отримує цю подію і реагує на неї, застосовуючи знижку до наступного замовлення. Потім ми можемо оновити значення знижки на сайті (наприклад, за допомогою Server-Sent Events). Здебільшого це займає мілісекунди, і клієнти не відчувають різниці.

Розподілені транзакції в Go: що варто знати перед початком

Іноді краще повторити операцію в системі замість того, щоб показувати користувачеві страшне повідомлення про помилку. Зрештою, проблема може бути тимчасовою, і повторна спроба часто є достатнім рішенням. Однак, це потрібно розглядати окремо для кожного сценарію. Часто дуже важливо, щоб користувач знав, що сталася помилка.

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

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

У більшості випадків нам не потрібно вручну досліджувати, які дані потрібно відновити, і нікого не потрібно будити посеред ночі. З часом система самовідновлюється.

✅ Тактика: Прийміть кінцеву узгодженість

Не всі операції повинні бути строго узгодженими, навіть якщо спочатку так здається.

При розробці розподілених систем слід пам'ятати про кінцеву узгодженість. Часто це простіше, ніж йти шляхом розподілених транзакцій.

Примітка

Події чи повідомлення?

Хоча іноді їх використовують як синоніми, між подіями та повідомленнями є різниця.

Повідомлення - це транспортна одиниця, яку ви публікуєте на Pub/Sub або отримуєте звідти. Воно схоже на HTTP-запит з методом, шляхом, заголовками та тілом.

Подія - це конкретне повідомлення. Воно являє собою факт про щось, що вже відбулося в системі. Подія представлена корисним навантаженням, що міститься в повідомленні (закодованим у форматі JSON або іншому форматі). Це те, що ви включаєте в тіло HTTP-запиту.

Реалізація

Щоб почати працювати з повідомленнями, ви повинні обрати брокера (інфраструктурну частину), так само як і при виборі бази даних. Ви публікуєте повідомлення брокеру, який асинхронно розсилає їх усім підписникам. У цій статті я буду використовувати Redis.

Розподілені транзакції в Go: що варто знати перед початком

Більшість популярних брокерів (Pub/Subs) мають Go бібліотеку (SDK), яка дозволяє з нею взаємодіяти. Але зазвичай вони пропонують низькорівневий API. Як би мені не подобався net/http Go, я все ж вважаю за краще використовувати Echo або Chi для роботи з їх високорівневим API. Аналогічно, я буду використовувати Watermill для роботи з повідомленнями.

Watermill - це бібліотека Go. Я не буду детально пояснювати, як вона працює. Вона підтримує багато бекендів Pub/Sub, таких як Kafka, AMQP, NATS або Redis.

Примітка

Усі наведені нижче ідеї є універсальними та можуть бути реалізовані з будь-якою іншою бібліотекою обміну повідомленнями.

Watermill не є фреймворком, тому його легко додавати або видаляти з проєкту. Це одна з основних ідей дизайну.

Як бачите, я не буду включати код, специфічний для Watermill, в логічний рівень. Не соромтеся використовувати щось інше, якщо воно краще відповідає вашим потребам.

Ми використаємо пару компонентів EventBus та EventProcessor з пакета cqrs Watermill.

Розподілені транзакції в Go: що варто знати перед початком

По-перше, замінімо HTTP-виклик на публікацію події. Шина подій дозволяє нам публікувати повідомлення з корисним навантаженням JSON. Налаштування має бути простим для розуміння, навіть якщо ви ще не знайомі з Watermill.

client := redis.NewClient(&redis.Options{
    Addr: redisAddr,
})

logger := watermill.NewStdLogger(false, false)

publisher, err := redisstream.NewPublisher(
    redisstream.PublisherConfig{
        Client: client,
    },
    logger,
)
if err != nil {
    return nil, err
}

eventBus, err := cqrs.NewEventBusWithConfig(publisher, cqrs.EventBusConfig{
    GeneratePublishTopic: func(params cqrs.GenerateEventPublishTopicParams) (string, error) {
        return params.EventName, nil
    },
    Marshaler: cqrs.JSONMarshaler{},
    Logger: logger,
})

Ми створюємо публікатор Redis і EventBus. Він буде використовувати назву події як тему. (Тема - це те, на що ви підписані для отримання повідомлень.) Він буде збирати події в JSON.

В обробнику команди ми замінимо синхронний виклик на публікацію події.

type EventPublisher interface {
    Publish(ctx context.Context, event any) error
}

type PointsUsedForDiscount struct {
    UserID int `json:"user_id"`
    Points int `json:"points"`
}

func (h UsePointsAsDiscountHandler) Handle(ctx context.Context, cmd UsePointsAsDiscount) error {
	err := h.userRepository.UpdateByID(ctx, cmd.UserID, func(user *User) (bool, error) {
		err := user.UsePoints(cmd.Points)
		if err != nil {
			return false, err
		}

		return true, nil
	})
	if err != nil {
		return fmt.Errorf("could not update user: %w", err)
	}

    event := PointsUsedForDiscount{
        UserID: cmd.UserID,
        Points: cmd.Points,
    }

    err = h.eventPublisher.Publish(ctx, event)
    if err != nil {
        return fmt.Errorf("could not publish event: %w", err)
    }

	return nil
}

У сервісі замовлень ми замінимо HTTP-обробник на обробник подій.

Налаштуємо EventProcessor для підписки так само, як ми налаштували шину подій для публікації.

client := redis.NewClient(&redis.Options{
    Addr: redisAddr,
})

logger := watermill.NewStdLogger(false, false)

router := message.NewDefaultRouter(logger)

eventProcessor, err := cqrs.NewEventProcessorWithConfig(router, cqrs.EventProcessorConfig{
    GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
        return params.EventName, nil
    },
    SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
        return redisstream.NewSubscriber(
            redisstream.SubscriberConfig{
                Client:        client,
                ConsumerGroup: "orders-svc",
            },
            logger,
        )
    },
    Marshaler: cqrs.JSONMarshaler{},
    Logger:    logger,
})

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

Далі нам потрібен обробник подій. Він просто зіставляє події з командою та виконує її, так само як це робив HTTP-обробник.

type OnPointsUsedForDiscountHandler struct {
    addDiscountHandler AddDiscountHandler
}

func (h OnPointsUsedForDiscountHandler) Handle(ctx context.Context, event *PointsUsedForDiscount) error {
    cmd := AddDiscount{
        UserID:   event.UserID,
        Discount: event.Points,
    }

    return h.addDiscountHandler.Handle(ctx, cmd)
}

Додаємо його в обробник подій.

err = eventProcessor.AddHandlers(
    cqrs.NewEventHandler(
        "OnPointsUsedForDiscountHandler",
        onPointsUsedForDiscountHandler.Handle,
    ),
)

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

Розподілені транзакції в Go: що варто знати перед початком

Ось і все! З невеликими змінами ми замінили синхронний HTTP- виклик на обробника повідомлень.

Звичайно, є складна частина запуску Pub/Sub на рівні продакшн. Я використовував Redis, але ви можете вибрати те, з чим вам зручно працювати, або те, що пропонує ваш провайдер хмарних послуг. Ви навіть можете почати з SQL-бази даних, якщо не хочете налаштовувати нову інфраструктуру.

Патерн Outbox

Залишається ще один сценарій, коли можуть виникнути проблеми. Оскільки публікація повідомлення відбувається через мережу, то може статися помилка, так само як і HTTP-запит до іншого сервісу. Pub/Sub зазвичай має високу доступність, але проблеми все ж трапляються (ніколи не припускайте, що мережа надійна!). Ми можемо втратити повідомлення після того, як транзакцію буде зафіксовано.

Розподілені транзакції в Go: що варто знати перед початком

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

Нам потрібно зберігати зміни в базі даних і публікувати подію в рамках однієї транзакції. Чи можливо це зробити?

Так, ми можемо використати шаблон outbox. Ідея полягає в тому, щоб зберегти подію в тій же базі даних, що і звичайні дані, в рамках тієї ж транзакції. Потім ми асинхронно публікуємо її в Pub/Sub. Таким чином, подія і збережені дані завжди будуть узгоджені, і ми не будемо покладатися на те, що Pub/Sub буде поруч. Події можна просто зберігати у спеціальній таблиці SQL.

Основною проблемою є очікування нових подій і перенесення їх до Pub/Sub. Залежно від бази даних, можна використовувати спеціальне програмне забезпечення, хоча налаштування часто буває досить складним. Ви можете реалізувати все з нуля, але це не так просто.

У цьому прикладі я використаю компонент Forwarder від Watermill, щоб це зробити. Його поведінка проста, завдяки тому, що важку роботу виконує реалізація Pub/Sub. Він прослуховує повідомлення від даного Pub/Sub і публікує їх до іншого Pub/Sub.

Розподілені транзакції в Go: що варто знати перед початком

Watermill підтримує Postgres як одну з реалізацій Pub/Sub, тому його легко додати до ваших налаштувань (так, ви можете просто використовувати базу даних SQL як інфраструктуру обміну повідомленнями).

Розподілені транзакції в Go: що варто знати перед початком

Тепер ми стикаємося з іншою дилемою проєктування. Як зробити так, щоб паблішер і репозиторій працювали разом, не змішуючи логіку і код бази даних?

Ми розширимо функцію UpdateFn, щоб вона вирішувала, які події слід публікувати. (Щоб дізнатися більше про те, як працює цей патерн, дивіться статтю про транзакції в базі даних).

func (h UsePointsAsDiscountHandler) Handle(ctx context.Context, cmd UsePointsAsDiscount) error {
	return h.userRepository.UpdateByID(ctx, cmd.UserID, func(user *User) (bool, []any, error) {
		err := user.UsePoints(cmd.Points)
		if err != nil {
			return false, nil, err
		}

		event := PointsUsedForDiscount{
			UserID: cmd.UserID,
			Points: cmd.Points,
		}

		return true, []any{event}, nil
	})
}

Репозиторій публікує повернуті події.

updated, events, err := updateFn(user)
if err != nil {
    return err
}

if !updated {
    return nil
}

_, err = tx.ExecContext(ctx, "UPDATE users SET email = $1, points = $2 WHERE id = $3", user.Email(), user.Points(), user.ID())
if err != nil {
    return err
}

eventBus, err := NewWatermillEventBus(tx)
if err != nil {
    return err
}

for _, event := range events {
    err = eventBus.Publish(ctx, event)
    if err != nil {
        return err
    }
}

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

У сервісі користувачів ми повинні змінити спосіб створення шини подій. Ми більше не будемо публікувати безпосередньо в Redis. Нам потрібно змінити паблішера з Redis на Postgres.

publisher, err = watermillSQL.NewPublisher(
    db,
    watermillSQL.PublisherConfig{
        SchemaAdapter: watermillSQL.DefaultPostgreSQLSchema{},
    },
    logger,
)

Далі ми обгортаємо цей паблішер за допомогою паблішера відправника. Він створює конверт навколо повідомлення, щоб пересилач знав, як його обробляти. Його конфігурація мінімальна.

publisher = forwarder.NewPublisher(
    publisher,
    forwarder.PublisherConfig{
        ForwarderTopic: forwarderTopic,
    },
)

Остання частина - це запуск форвардера. Хоча це може бути окремий сервіс, але також можна запустити його як іншу процедуру.

Для отримання збережених подій з бази даних нам потрібен SQL- підписник, а для публікації повідомлень там, де їх очікує сервіс замовлень, - Redis-публікатор.

sqlSubscriber, err := watermillSQL.NewSubscriber(
    db,
    watermillSQL.SubscriberConfig{
        SchemaAdapter:    watermillSQL.DefaultPostgreSQLSchema{},
        OffsetsAdapter:   watermillSQL.DefaultPostgreSQLOffsetsAdapter{},
        InitializeSchema: true,
    },
    logger,
)
if err != nil {
    return nil, err
}

client := redis.NewClient(&redis.Options{
    Addr: redisAddr,
})

redisPublisher, err := redisstream.NewPublisher(
    redisstream.PublisherConfig{
        Client: client,
    },
    logger,
)
if err != nil {
    return nil, err
}

fwd, err := forwarder.NewForwarder(
    sqlSubscriber,
    redisPublisher,
    logger,
    forwarder.Config{
        ForwarderTopic: forwarderTopic,
    },
)

Зверніть увагу, що тема форвардера має збігатися між паблішером форвардера і самим форвардером. У цьому випадку це та сама константа forwarderTopic (може бути будь-яким рядком).

Нарешті, ми запускаємо форвардер.

go func() {
    err := forwarder.Run(context.Background())
    if err != nil {
        panic(err)
    }
}()

На стороні сервісу замовлень не потрібно робити ніяких змін. Оскільки він отримує події з Redis, як і раніше.

✅ Тактика: шаблон «Вихідні

Використовуйте шаблон «Вихідні» для публікації подій в рамках транзакції бази даних. Не залишайте все на волю випадку, чи всі події будуть успішно опубліковані.

Спрощення чи ускладнення?

Тут є багато складників. Подієво-керовані патерни не є тривіальними, і вам до певної міри потрібно розуміти, що ви робите. Якщо ви ніколи раніше не використовували ці концепції, це може здатися складним.

У мене теж були такі побоювання. Система здається простішою, коли в ній є лише синхронні API та база даних. Навіщо все ускладнювати, додаючи всю цю фонову обробку?

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

Відкат може заважати іншим потокам, тому вам потрібні розподілені блокування для захисту стану. Відкати також можуть давати збої. Що ж робити, коли це трапляється?

Як видно з фрагментів, для початку роботи з подіями потрібно небагато коду, а обробники також прості. Саме тут допомагає Watermill, позбавляючи вас від низькорівневого коду. Ви можете зосередитися на налаштуванні обробників і публікації повідомлень за допомогою API, який простіше використовувати, ніж кінцеві точки HTTP.

Як тільки ви зрозумієте основи, ваша ментальна модель почне змінюватися, і, врешті-решт, до вас дійде. У вас з'явиться інтуїція щодо того, як створювати керовані подіями системи.

Події та зв'язок

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

Що мені подобається в цьому патерні, так це те, що він обмежує знання сервісів один про одного. Але тут легко помилитися.

У прикладі вище показано, що подія побудована досить погано. Ім'я UsePointsAsDiscount робить сервіс користувачів обізнаним про те, що відбувається в сервісі замовлень після того, як він його отримає. Однак вже занадто пізно, щоб уникнути цього. Межі сервісу від самого початку були неправильними.

Добре продумані події констатують факт, який стався в домені цього сервісу. Сервіс, який публікує подію, не повинен знати або піклуватися про те, що відбувається, коли інший сервіс її обробляє.

Тестування

Тестування сервісів, орієнтованих на події, не сильно відрізняється, але ось кілька порад.

Запустіть ваш Pub/Sub локально в контейнері Docker і протестуйте його. Хмарні Pub/Sub зазвичай пропонують емулятор (але часто без паритету функцій, тому будьте обережні).

Тестування коду обробника подій не має сенсу. Обробники подій повинні просто виконувати логіку програми з отриманими від неї даними. Замість цього використовуйте тести компонентів для перевірки поведінки сервісу за допомогою публічного API.

Сценарій тесту може бути приблизно таким:

  • Після публікації цієї події сутність, що зчитується через HTTP, повинна змінитися.
  • Після виклику цієї кінцевої точки HTTP має бути опублікована подія.
  • Після публікації цієї події має бути опублікована інша подія.

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

assert.EventuallyWithT(t, func(t *assert.CollectT) {
    row := discountDB.QueryRowContext(context.Background(), "SELECT next_order_discount FROM user_discounts WHERE user_id = $1", userID)

    var discount int
    err := row.Scan(&discount)
    require.NoError(t, err)

    assert.Equal(t, expectedDiscount, discount)
}, 2*time.Second, 100*time.Millisecond)

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

Нарешті, запускайте тести паралельно, щоб прискорити їх виконання. Оскільки багато часу витрачається на введення/виведення та очікування, це швидко накопичується. Однак є деякі особливості паралельного запуску тестів на Go. Роберт зараз працює над статтею про те, як це робити правильно - підпишіться на нашу розсилку, і ми дамо вам знати, коли вона буде готова.

Моніторинг

Спостережуваність - довга тема, і про метрики та трасування для систем, керованих подіями, можна багато чого сказати. Це виходить за рамки цієї статті, але я хочу згадати про моніторинг черги повідомлень, що очікують на обробку.

Якщо повідомлення не вдається обробити (тобто обробник повертає помилку з будь-якої причини), його буде доставлено знову. Залежно від обраного вами Pub/Sub та його конфігурації, він може блокувати доставку інших повідомлень.

Це ваш ключовий показник для моніторингу. Якщо повідомлення не обробляються протягом тривалого часу, це означає, що щось пішло не так, і це не тимчасова проблема. Вам потрібне ручне втручання, інакше ваша система не буде синхронізована.

Більше прикладів

Це лише деякі приклади переходу до кінцевої узгодженості, але для початку цього повинно бути достатньо. Щоб дізнатися про різні варіанти використання, перегляньте приклади Watermill. Тут є кілька базових, а також складніших і цікавіших прикладів.

Джерело: Distributed Transactions in Go: Read Before You Try
Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід