
Всё больше Java-разработчиков переходят от приложений, использующих синхронный стек, к реактивным решениям на базе Spring WebFlux и Kotlin Coroutines. Такой переход позволяет строить более масштабируемые и устойчивые к высокой нагрузке системы, эффективно используя пул потоков и асинхронное выполнение задач. Однако вместе с преимуществами реактивного подхода появляется и новая неочевидная проблема — потеря MDC-контекста (Mapped Diagnostic Context), который традиционно используется для сквозной трассировки запросов в логах.
Привет, Хабр! Меня зовут Иван Коньшин. Я работаю в Оkkо уже около трёх лет в команде разработки сервис-управления контекстом пользователя. Расскажу о том, как задача из разряда «Тут делов на пять минут» превратилась в целую статью. Разберем почему стандартные подходы к MDC не работают в реактивных приложениях, какие существуют решения для Spring Boot 2 (WebFlux + Coroutines), какие изменения появились в Spring Boot 3 (например, интеграция с Observation API) и какие практические приёмы помогают сохранить контекст в реактивных цепочках.
Проблема
MDC (Mapped Diagnostic Context) — неотъемлемая часть логирования, которая используется для хранения информации о контексте выполнения приложения. В приложениях, использующих синхронный стек, всё просто. Каждый входящий HTTP-запрос обрабатывается одним потоком, и значения MDC, такие, как идентификатор пользователя, запроса, трассировки (например, requestId, userId, traceId) сохраняются и доступны во всех логах, связанных с этим потоком. А поскольку MDC реализован на основе ThreadLocal, это автоматически обеспечивает ему сквозную передачу контекста между методами.
MDC.put("requestId", "123")
logger.info("Запрос получен") // В логах: [requestId=123] "Запрос получен"
Но в реактивном стеке ситуация меняется: в приложениях с использованием Spring WebFlux или Kotlin Coroutines операции выполняются на разных потоках из общего пула, что приводит к потере ThreadLocal-контекста. Например:
webClient.get()
.uri("/api/data")
.retrieve()
.subscribeOn(Schedulers.parallel()) // Переключение на другой поток
.doOnNext { logger.info("Ответ получен") } // MDC будет пустым!
Для сравнения, вот так выглядит схема работы для разных приложений:

Без корректно работающего MDC ваши логи превращаются в разрозненные записи, которые невозможно использовать для диагностики и мониторинга распределённых систем. Это особенно критично, если в проекте используются сторонние библиотеки, которые полагаются на наличие MDC для логирования.
Мы столкнулись с этой проблемой и начали искать решения, которые позволили бы нам её решить. Для восстановления сквозной трассировки обратились к существующим инструментам, среди которых наиболее подходящим оказался Spring Sleuth. Это библиотека для трассировки в приложениях, использующих Spring WebFlux. Она автоматически добавляла в логи уникальные идентификаторы (traceId, spanId). Поэтому мы решили использовать её для сохранения состояния MDC.
Spring WebFlux — это реактивный веб-фреймворк, входящий в экосистему Spring. В отличие от классического Spring MVC, он использует не блокирующую модель обработки запросов. WebFlux построен на основе Reactor и позволяет обрабатывать большое количество одновременных соединений с минимальным количеством потоков.
Решение проблемы
Рассмотрим подробнее, как реализуется сохранение MDC-контекста на практике, начиная с подхода на базе Reactor и Spring Sleuth. Это решение построено на механизмах Hooks из Reactor версии 2:
Hooks.onEachOperator вызывается для каждого оператора (map, flatMap и т.д.) в Sleuth используется для копирования MDC в Reactor Context.
Hooks.onLastOperator вызывается перед завершением цепочки и используется для очистки MDC.
Более подробно ознакомиться с внутренним механизмом библиотеки можно в цикле статей из Spring blog:

-
ScopePassingSpanSubscriber — специальный подписчик (Subscriber) Reactor, который:
Хранит parentTraceContext (родительский контекст трассировки).
При подписке вызывает метод CurrentTraceContext.maybeScope(..), который приводит к вызову метода CurrentTraceContext.attach(..).
-
EventPublishingContextWrapper — обертка над CurrentTraceContext’ом:
При вызове CurrentTraceContext.attach(..) бросает событие ScopeAttachedEvent, которое прикрепляет контекст к потоку.
-
При закрытии Scope’а трассировки бросает события:
ScopeClosedEvent — закрытие трассировочного Scope’а.
ScopeRestoredEvent — со ссылкой на текущий CurrentTraceContext.
-
Slf4ApplicationListener — слушатель, который реагирует на события EventPublishingContextWrapper следующим образом:
ScopeAttachedEvent/ScopeRestoredEvent берет значения из Span события — traceId и spanId и заполняет ими MDC.
ScopeClosedEvent очищает MDC.
К сожалению, это не позволило решить проблему целиком. В Spring WebFlux механизм MDC работал через Reactor Context, но при использовании Kotlin Coroutines все равно возникала та же проблема. Контекст MDC терялся при переключении между реактивными потоками и корутинами.

Это происходило сразу по нескольким причинам.
Разные модели выполнения:
WebFlux использует Reactor и работу с Publisher'ами (Mono/Flux)
Coroutines работают через suspending функции и собственный Dispatcher
Отсутствие автоматической передачи:
Reactor Context не передается автоматически в CoroutineContext
Переключение потоков:
При переходе между реактивными операторами и suspend-функциями
При использовании разных Dispatcher'ов (например, с withContext)
Проблема заложена в самом механизме вызова корутин из Spring WebFlux. Вызов корутины при HTTP-запросе происходит из InvocableHandlerMethod, где с помощью Kotlin Reflection определяется, является ли вызываемый метод контроллера suspend-функцией.
public class InvocableHandlerMethod extends HandlerMethod {
public Mono < Object > invoke (Message<?> message, Object... providedArgs) {
return getMethodArgumentValues(message, providedArgs).flatMap(args -> {
//...
if (KotlinDetector.isSuspendingFunction(method)) {
isSuspendingFunction = true;
value = CoroutinesUtils.invokeSuspendingFunction(method, getBean(), args);
} else {
value = method.invoke(getBean(), args);
}
//...
}
}
}
При помощи CoroutinesUtils корутина запускается на Unconfined Dispatcher’е.
public abstract class CoroutinesUtils {
public static Publisher<?> invokeSuspendingFunction(Method method, Object target, Object... args) {
//...
Mono<Object> mono = MonoKt.mono(Dispatchers.getUnconfined(), (scope, continuation) ->
KCallables.callSuspend(function, getSuspendedFunctionArgs(target, args), continuation))
//...
}
}
Никаких дополнительных Spring абстракций перед вызовом корутины не вызывается, поэтому фреймворк не предоставляет возможности забрать данные из Reactor Context’а и переложить в контекст корутины. Поэтому мы начали искать специализированные решения для этого стека. Так как проблема была заложена в самом механизме вызова корутин из Spring WebFlux, мы пошли искать решение в официальной документации Kotlin.
Решение 1. kotlinx.coroutines.slf4j
В документации говорится, что передать MDC в контекст корутины можно с помощью библиотеки kotlinx-coroutines-slf4j.
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-slf4j")
Важно, чтобы значения в MDC были проставлены до вызова функции withContext(MDCContext()).
Так как, Spring Sleuth гарантирует наличие правильных значений MDC в цепочках вызовов методов до InvocableHandlerMethod’а, остается только проставить withContext(MDCContext()) на методы контроллера:
@RestController
class MyAwesomeController {
@GetMapping("v1/awesome-api-1")
suspend fun test1() = withContext(MDCContext()) {
//do something
}
}
В этом случае все вызванные внутри блока withContext логи будут сопровождаться верными значениями из MDC.
Плюсы:
Просто и работает
Минусы:
Дублирование кода
Можно забыть проставить withContext
Поэтому мы решили искать более универсальный подход, позволяющий избежать дублирования кода.
Решение 2. AOP
Для этого необходимо вмешаться в работу REST-контроллера. То есть вставить обработчик между InvocableHandlerMethod и самим контроллером. Единственный универсальный подход, который пришёл на ум — аспектно-ориентированное программирование (AOP). Вот пример класса, с помощью которого это можно сделать.
@Aspect
@Component
class RestControllerMDCProvidingAspect {
@Pointcut("@within(org.springframework.web.bind.annotation.RestController)")
fun restControllerPointcut() {
}
@Pointcut("execution(public * *(..)) && args(.., kotlin.coroutines.Continuation)")
fun allSuspendMethods() {
}
@Suppress("UNCHECKED_CAST")
@Around("restControllerPointcut() && allSuspendMethods()")
fun aroundSuspendFunction(joinPoint: ProceedingJoinPoint): Any? {
val args = joinPoint.args
val continuation = args.last() as Continuation<Any?>
val suspendingFunctionArgs = args.sliceArray(0 until args.size - 1)
return suspend {
withContext(MDCContext()) {
suspendCoroutineUninterceptedOrReturn<Any?> { c ->
joinPoint.proceed(args: suspendingFunctionArgs +c)
}
}
}
}
}
Таким образом, мы определяем два Pointcut: один охватывает все классы, помеченные аннотацией REST-контроллера, а другой — все публичные (public) suspend-методы. Затем мы оборачиваем их в Around-аспект. Этот аспектный код приостанавливает выполнение текущей корутины, оборачивает её в withContext и после этого возобновляет работу.
Плюсы:
Убираем дублирование кода
Минусы:
Сложновато
Нужно проводить нагрузочное тестирование для понимания того, насколько это решение может повлиять на производительность.
Однако я считаю, что если для решения задачи вы прибегаете к AOP, то, скорее всего, у вас уже две проблемы. Таким образом, мы сталкиваемся с классической задачей, для которой существуют не самые удобные решения: либо дублирование кода, либо сложная реализация через AOP. Поэтому лучше поискать другое решение. И для этого не всегда приходится изобретать «велосипед», иногда бывает достаточно посмотреть вокруг и почитать какие фичи вышли в новых релизах ваших приложений.
Решение 3. Spring Boot 3
Оказывается за то время, что мы пытались побороть свои проблемы, в Spring Boot 3 уже начала использоваться 3-я версия Reactor, которая из коробки передавала Context’а Subscriber’а путем включения хука:
Hooks.enableAutomaticContextPropagation()
В связи с этим пропала необходимость в использовании библиотеки Spring Sleuth. Все остальные наработки переехали в библиотеку micrometer-tracing, поэтому для трассировки в Spring Boot 3 лучше использовать ее.
implementation("io.micrometer:micrometer-tracing")
Это все, что нужно для работы трассировки в Spring boot 3 при использовании WebFlux.
Так же добавился абстрактный класс CoWebFilter, который вызывается из InvocableHandlerMethod’а сразу после старта корутины, что позволяет обернуть последующие вызовы в блок withContext(MDCContext()).
@Component
@Order(Ordered.LOWEST_PRECEDENCE)
class ClientWebFilter : CoWebFilter() {
override suspend fun filter(exchange: ServerWebExchange, chain: CoWebFilterChain) {
withContext(MDCContext()) {
chain.filter(exchange)
}
}
}
Таким образом, отпадает необходимость в дублировании кода на контроллерах.
И все проблемы, связанные с дублированием кода и решениями на AOP, остаются в прошлом.
Заключение
Потеря MDC способна свести на нет все усилия по трассировке и мониторингу распределённых систем. Без надёжного контекста логи превращаются в набор разрозненных сообщений, а поиск причин в квест. Наш путь к корректной передаче MDC в реактивных приложениях оказался более извилистым, чем мы думали. Понадобилось немного покостылить и попробовать сторонние библиотеки и эксперименты с AOP, чтобы в итоге прийти к Spring Boot 3, где многие проблемы решаются «из коробки».
Как сказал Джеймс Гослинг (James Gosling, создатель Java):
"The real challenge in software is not writing code, but managing complexity."
(«Настоящая сложность в разработке ПО — не в написании кода, а в управлении сложностью.»)
Поэтому, если у вас возникает проблема, смотрите, может быть, кто-то уже решил ее за вас. Правильный выбор технических решений и своевременный апдейт помогают не только справиться с текущими проблемами, но и облегчают масштабирование и поддержку системы в будущем.