Всем привет! В статье расскажу: Как и почему у нас возникла необычная проблема, вызвавшая поток 400-ых ошибок.  Как реализовали полноценную поддержку отмены операций в микросервисе. Как реализовали свой пул подключений к базе для переиспользования подключений к базе в рамках запроса к сервису. Как применили контексты в микросервисе и что от этого получили. Немного о себе: меня зовут Вадим Макеров, я разработчик в команде iSpring Tech. Занимаюсь разработкой микросервисов на Golang и получаю от этого удовольствие. Во время подготовки фичи к релизу мы заметили проблему: к одному из микросервисов шло много одновременных запросов на изменение модели, с которой работал микросервис. После нескольких секунд таких одновременных запросов в браузере стали сыпаться ошибки с кодом ответа 400. В этом контексте они означали, что в процессе запроса модель изменилась, и с фронтенда была послана неактуальная ревизия данных.  Мы используем DDD, и в доменной модели нашего сервиса лежит агрегат, у которого есть несколько листьев. Каждый запрос на изменение этого агрегата происходит под: Полной блокировкой этого агрегата (изменения агрегата должны быть консистентны).  Транзакцией (агрегат сохраняется в несколько шагов, и мы хотим соблюдать атомарность сохранения агрегата).  В наших микросервисах мы используем чистую архитектуру: каждое изменение в доменной модели проходит через слой Application. В нём мы и хотим обеспечивать атомарность и консистентность операций над доменом. Но просто взять сущности с уровня инфраструктуры и использовать их на уровне Application нельзя, поэтому ввели абстракции: UnitOfWork — единица работы — абстракция на транзакцией. Lock — абстракция над блокировкой через базу. Lock В большинстве операций нужны блокировка и транзакция вместе. Мы объединили их вместе под единой сущностью LockableUnitOfWork: под капотом сначала вызывали транзакцию, а потом уже блокировку. Применяя блокировку внутри транзакции, мы на короткое время допускали чтение незакоммиченных изменений. Это был первоначальный вариант: мы пошли на него, чтобы не организовывать собственный пул соединений, и тем самым выстрелили себе в ногу. Переместив транзакцию внутри блокировки, мы разрешили проблему чтения. Возникла новая проблема. Стали нужны два соединения с базой: одно — на Lock, другое — на транзакцию. Причина крылась в том, как изначально был реализован LockableUnitOfWork. До изменений он выглядел так: Здесь видно, что применение лока происходит после старта транзакции Плюсы такого решения: оно простое и не требует дополнительной сущности для переиспользования объекта соединения. Но при этом отсутствует гибкость: мы не можем просто так взять и поменять последовательность применения блокировки и начала транзакции. Код LockableUnitOfWork после изменений: В пуле соединений с базой данных хранится максимум N соединений. Значит, количество одновременных запросов к сервису, которые работают через эти абстракции, уменьшится вдвое — до N/2. Такое положение дел нужно исправлять, но важно держать в голове несколько моментов: Нельзя отказаться от этих абстракций: они универсальны и подходят для использования на Application уровне. Нужно иметь возможность использовать их отдельно друг от друга или попеременно: например, применить одну общую блокировку и под ней запустить ещё одну блокировку и транзакцию. Для работы этим транзакциям нужно использовать соединение с базой данных, и не важно, что было раньше применено: блокировка или транзакция. Эти операции друг другу не мешают.  Мы решили шарить соединение между UnitOfWork и Lock явным способом. Тут на помощь приходит объект Connection из стандартной библиотеки SQL. Он инкапсулирует под собой работу с соединением к базе данных: через него можно напрямую выполнять SQL-запросы, а также начинать транзакцию. Самое логичное решение — шарить объект: нам нужно соединение, и мы должны шарить объект соединения. Простой способ поделить объект между другими объектами — вынести ответственность за обмен на третий объект. Поэтому мы завели ConnectionProvider, у которого только одна задача: выдавать объект соединения из внутреннего пула соединений. Примечание: в Go в стандартной библиотеке SQL уже пулятся соединения к базе данных, но не получается контролировать, на каком соединении будет выполнен запрос. Поэтому нам нужен свой пул поверх пула из стандартной либы. Мы организовали шаринг через простой пул с помощью map и расположили по соседству мьютекс. У map кто-то должен выступать в качестве ключа: нужна уникальная для каждого запроса в сервис сущность, которая ходит по всему микросервису. Мы решили, что этой сущностью будет Context: он уникален для каждого запроса в сервис. Context идеально подходит для этой роли: Context потокобезопасен: если хотите изменить контекст, вы всегда должны создать новый. Context — это как раз та сущность, которая может ходить по всему сервису. В итоге получился ConnectionProvider: он позволяет шарить соединение через Context. LockableUnitOfWork UnitOfWork и Lock — вышестоящие абстракции, которые отвечают за атомарность операции и блокировки. Инфраструктурный код, которому нужно текущее соединение с базой, может просто использовать ConnectionProvider и Context, чтобы работать с текущим соединением. Для интеграции этого подхода решили использовать контексты по всему микросервису. Во время интеграции решения параллельно изучали, какие плюсы дают контексты. Хочу поделиться с вами, как и зачем вообще применять контексты в микросервисах.  Контексты в микросервисах служат как для передачи request-scope данных (к примеру, RequestID), так и для отмены операций. Пользователь отменил запрос к сервису — нет смысла ходить в базу, чтобы извлечь информацию, которая уже никому не нужна. Можно завершить работу. Context — это сущность, переносящая в себе сроки выполнения запроса и прочие request-scope данные. Под сроками выполнения запроса подразумевается, что по истечении определенного таймаута контекст отменяется. Любой контекст несёт в себе функцию cancellation token, и любой контекст может быть отменён. Создать контекст с отменой можно через: В WithCancel передаётся родительский контекст, возвращается контекст и функция отмены. Вызов функции отмены как раз и есть сигнал всем подписчикам канала context.Done(), что контекст был отменён. Выполняется длительная операция, идут итерации в цикле. Пользователю надоедает ждать, и он хочет отменить запрос. context, полученный из объекта запроса, будет закрыт. Не придётся выполнять лишнюю никому не нужную работу. Пример Как вы помните, context.Done() — это канал. При закрытии канала планировщик отпаузит все горутины, ожидающие <-ctx.Done(), и они смогут почистить свои ресурсы. Также, помимо принудительной, отмена контекста может быть по таймауту или при достижении определенного времени. context.WithTimeout автоматически отменяет контекст по таймауту. context.WithDeadline делает то же самое, только ему нужно передать время, а не Duration. Отмена контекста происходит по дереву: от родителя до всех детей Можно завести контекст в main приложения и все следующие контексты «наследовать» от него. Отмена контекста будет происходить при получении сигналов SigTerm или SigInt от системы. Тогда будут отменяться все контексты.  Если отменить один из ниже унаследованных контекстов, отменятся только его дети, а вышестоящие контексты — нет. Это очень удобно для объявления локальных таймаутов для каких-либо операций. Получить «новый» контекст можно через функции context.Background и context.TODO: они обе возвращают пустой контекст, но при этом не создают контексты. context.Background и context.TODO всегда возвращают один и тот же контекст при вызове. context.TODO предпочтительно использовать, когда: уже понятно, что в функцию нужно передать контекст,  но пока неизвестно, откуда контекст должен прийти.  Это будет как пометка, что здесь будет context.Background либо контекст передастся сверху.  Также некоторые линтеры могут отлавливать context.TODO и писать предупреждения. context.Background нужно использовать только в самом верху: например, в функции main при старте приложения. Есть ещё один повод использовать контекст — передавать в нём request-scope данные через context.WithValue. Этот метод создаёт новый контекст и зашивает в него значение под определенным ключом, которое можно получить через Context.Value. Если под таким ключом ничего нет, вернётся nil. К примеру, в контексте можно передавать RequestID. Обычно для ключей используются пустые структуры: это позволяет обеспечить уникальность ключа при минимальном потреблении памяти. Но в качестве ключа можно также использовать строки и числа. Рекомендую не хранить в контекстах сервисы или объекты, за временем жизни которых нужно следить. В остальном — решать вам. Давайте посмотрим реализацию. В стандартной библиотеке Go у объекта базы данных есть методы Exec, Query, QueryRow. Почти у всех методов есть аналоги, которые используют контекст ExecContext, QueryContext, QueryRowContext и так далее. Объекты Transaction и Connection имеют всё те же контекстные методы, что и sql.DB. Теперь при отмене контекста запрос к базе данных тоже будет отменяться. Это большая экономия ресурсов, если нужно выполнить много запросов в базу. Дополнительный бонус: если начинать транзакцию через контекстный метод BeginTx, она автоматически откатится при отмене контекста. К Redis — через go-redis Для межсервисного взаимодействия мы используем gRPC: там можно передать контекст при вызове метода другого сервиса. Экономия ресурсов достигается за счёт того, что каждый вызов к другому сервису может быть отменён — даже вся неограниченно длинная цепочка. К примеру, используется API Gateway.  В нём выставлен таймаут на запросы. API Gateway идёт к сервису A. Сервис А — к сервису B. Если сервис B будет долго обрабатывать запрос и у API Gateway выйдет таймаут, вся цепочка запроса прекратится. У сервисов A и B будет ошибка «context cancelled». Работу с контекстами поддерживает и обычный http.Client. Чтобы получить все плюсы от контекстов в микросервисах, нужно было вписать их в архитектуру. Со слоями Application и Infrastructure всё очень просто: во всех методах, сервисах и объектах мы стали принимать контекст. Пускать контекст в домен не хотели. Объект контекста всё же слишком непредсказуемый с его context.Value, а мы хотим, чтобы наш домен зависел от предсказуемых сущностей. Но контекст нужен после — на уровне инфраструктуры. К примеру, чтобы реализовать TransactionalOutbox, мы в одном из обработчиков доменных событий должны записать доменное событие в базу на той же транзакции.  Это противоречие мы разрешили, реализовав проксю над доменным ивент-диспатчером. Прокси передаёт событие вместе с контекстом в другой хендлер. Так в домен ничего не попадает, зато попадёт в инфраструктуру. Там контекст будет нужен, чтобы через ConnectionProvider получить соединение, на котором записать доменное событие в базу. При обработке доменного события иногда нужно использовать данные из базы, которые ещё не были закоммичены, или выполнить что-то на текущем соединении с базой.  В конструктор обработчика доменного события передавался UnitOfWorkFactory. Через него переиспользовался текущий UnitOfWork — он сохранялся в поле структуры.  Было Чтобы поддерживать работу этого обработчика, нужно было пересоздавать почти все сервисы на каждый запрос к микросервису. Благодаря ConnectionProvider мы смогли избавится от этого. Теперь обработчику можно передать в конструктор ConnectionProvider и принять context в методах. Причин пересоздавать сервисы больше нет: конструирование происходит ровно один раз. Стало Стало чище и более явно. Казалось бы —  мелочь. Но именно конструирование этих обработчиков в самом начале сборки зависимостей почти каждого сервиса вынуждало пересобирать многие сущности. Контесты — важная особенность языка Go, пришедшая из стандартной библиотеки x/net. При разработке сервиса они помогут сэкономить ресурсы, работать с операциями отмены более прозрачно и единым образом. Мы же благодаря контекстам: Решили проблему с соединениями к базе. Перешли на использование контекстов в микросервисе. Поняли, что хотим и дальше применять у себя контексты. Все новые сервисы реализуем сразу с их поддержкой. Бекенд-разработчик