Всё больше 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.”

(«Настоящая сложность в разработке ПО — не в написании кода, а в управлении сложностью.»)

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

Комментарии (0)