1. Проблема: логи перемешиваются
Если вы только начали писать backend, то лог обычно выглядит довольно невинно: “start”, “loaded”, “done”. Пока запросов мало и всё запускается строго по очереди, мозг справляется. Но как только сервис начинает обрабатывать два запроса параллельно (а он будет, иначе зачем ему вообще быть сервисом), логи перемешиваются, как носки после стирки — найти пару можно, но это уже детектив.
Представьте, что у нас есть catalog-service, и два человека почти одновременно дернули разные endpoint’ы. Один просит карточку курса по slug, другой — список курсов с фильтрами. В консоли это может выглядеть так:
10:15:00.120 INFO CourseCatalogController - Getting course by slug
10:15:00.121 INFO CourseCatalogService - Loading course card
10:15:00.122 INFO CourseCatalogController - Listing courses
10:15:00.123 INFO CourseCatalogService - Filtering courses
10:15:00.124 INFO CourseCatalogService - Course found
10:15:00.125 INFO CourseCatalogService - Returning 12 courses
И вот вопрос на собеседовании (и в реальной жизни): какие строки относятся к первому запросу, а какие — ко второму? Пока их шесть, вы ещё угадаете. Когда их 600, и между ними влезли логи Tomcat’а, Jackson’а и вашего “полезного” DEBUG, это превращается в «угадай мелодию» — только без музыки и с нервами.
Решение наивное — добавлять идентификатор руками в каждое сообщение: log.info("[{}] Filtering courses", requestId). Работает, но быстро надоедает, потому что этот requestId нужно таскать параметром через пол-метода, пол-слоя и пол-проекта. И вот тут появляется идея: а можно ли один раз положить requestId в «контекст», чтобы все лог-события в рамках операции автоматически получали это поле? Можно. Это и есть correlation mindset + MDC.
2. Correlation fields
Перед тем как нырять в конкретную технику, важно зафиксировать правильный образ мышления. Correlation field — это маленькое стабильное поле, которое «склеивает» разрозненные лог-сообщения в одну историю. Оно не заменяет текст сообщения, а помогает понять, какие сообщения относятся к одной операции. Самый популярный пример — requestId (или correlationId) для HTTP-запроса.
Смысл correlation mindset довольно простой: у каждой важной операции должен быть один-два идентификатора, по которым её можно собрать из логов. Для HTTP это почти всегда requestId. Для нашего каталога курсов иногда полезно добавить ещё и доменный маркер, например courseSlug, если мы ищем конкретную карточку. Но ключевое слово тут — «маленькое» и «стабильное»: корреляция — это не «давайте запихнём в лог весь запрос и весь ответ», это именно «давайте дадим логам “скрепку”, по которой их можно собрать».
Чтобы не гадать, что класть в MDC, удобно держать в голове мини-таблицу «что ок, а что нет» (это не закон природы, но хороший старт):
| Кандидат в контекст | Хорошая идея? | Почему |
|---|---|---|
| requestId | Да | Дешёвый, безопасный, помогает собрать все строки одного запроса. |
| courseSlug | Да | Небольшой доменный идентификатор, помогает при разборе “почему конкретный курс не нашёлся”. |
| track, level, limit | Иногда | Хорошо для диагностики фильтрации, если не перегибать с количеством полей. |
| Authorization header / токены | Нет | Это чувствительные данные. Их нельзя логировать и тем более класть в MDC. |
| Полный JSON CourseCard | Нет | Большой объём, шум, риск утечки данных и “случайного DDoS” логами. |
| “всё, что пришло в request” | Нет | Это уже не корреляция, а мусорный дамп. Вредит больше, чем помогает. |
Ещё одна важная мысль: correlation field должен быть одинаково назван везде. Если в одном месте вы пишете requestId, в другом reqId, в третьем request_id, то вы сами себе усложняете жизнь. В логах, как в коде, консистентность — это бесплатная производительность мозга.
3. MDC: что это и как чистить
Теперь к главному герою. MDC (Mapped Diagnostic Context) — это механизм логирования, который позволяет «прикрепить» к текущему потоку выполнения (обычно к текущему thread’у) небольшую карту ключ -> значение. Логгер, когда создаёт log event, может взять значения из MDC и добавить их к событию. В structured logging это часто превращается в отдельные поля JSON. В plain text это можно вывести через шаблон (pattern), если он умеет читать MDC.
Важно не перепутать MDC с “глобальной Map”. MDC не должен быть общим на весь JVM «как статическое поле». Он, по смыслу, привязан к текущему потоку, и чаще всего реализован через ThreadLocal. Это одновременно и сила, и ловушка. Сила — потому что вы положили requestId один раз, и все логи в этом потоке автоматически его получили. Ловушка — потому что в servlet-мире потоки переиспользуются. Если вы забыли очистить MDC в конце запроса, следующий запрос на этом же thread’е унаследует старый requestId. И вы получите “паранормальный лог”, где два разных клиента случайно “делят одну душу”.
Минимальный пример MDC выглядит так:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
public class Demo {
private static final Logger log = LoggerFactory.getLogger(Demo.class);
public void run() {
// Кладём корреляционный идентификатор в MDC для текущего потока
MDC.put("requestId", "req-42");
try {
// Любой лог внутри try автоматически получит requestId (если формат логов это выводит)
log.info("Hello from catalog-service");
} finally {
// Важно: обязательно очищаем MDC, иначе значение "протечёт" в следующий запрос на этом потоке
MDC.remove("requestId");
}
}
}
В этом коде есть две вещи, которые стоит прям запомнить как “правило зубной щётки”. Первое: класть значение в MDC можно где угодно, но делать это нужно осмысленно — как можно ближе к границе операции. Второе: чистить MDC нужно обязательно, и самый надёжный способ для начинающего — try/finally, потому что исключения случаются не только у других людей.
Есть ещё удобная форма, которая помогает не забыть cleanup: MDC.putCloseable(...) (если она доступна в вашей версии SLF4J). Она возвращает объект, который удалит ключ при закрытии, и его можно использовать через try-with-resources:
import org.slf4j.MDC;
try (var ignored = MDC.putCloseable("requestId", "req-42")) {
log.info("Hello with safe cleanup");
}
Для catalog-service такого встроенного паттерна достаточно: отдельная обёртка вокруг MDC нужна только если команда правда решает ею какую-то повторяющуюся боль, а не просто переименовывает тот же try-with-resources.
По ощущениям это почти как «взял талончик — вышел — вернул талончик». Самое главное: cleanup гарантирован, и код короче, а значит — меньше шанс сделать глупость.
4. MDC в controller: courseSlug
В учебном проекте очень полезно начинать с самого простого варианта, который даёт эффект сразу, без большой перестройки инфраструктуры. Самая очевидная операция в catalog-service — получение курса по slug. И это отличный кандидат для “операционного” MDC-поля: courseSlug. Оно маленькое, не секретное, и по нему легко понять, о каком курсе спорят логи.
Предположим, у нас уже есть контроллер CourseCatalogController и метод GET /api/catalog/courses/{slug}. Добавим туда MDC вокруг вызова сервиса. Важно: мы кладём courseSlug ровно на время выполнения этого метода и гарантированно убираем, чтобы не «протекло» в другие запросы.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class CourseCatalogController {
private static final Logger log = LoggerFactory.getLogger(CourseCatalogController.class);
private final CourseCatalogService catalogService;
public CourseCatalogController(CourseCatalogService catalogService) {
this.catalogService = catalogService;
}
@GetMapping("/api/catalog/courses/{slug}")
public CourseCard getBySlug(@PathVariable String slug) {
// Временно кладём в MDC доменный идентификатор: он будет жить только в рамках обработки этого запроса
try (var ignored = MDC.putCloseable("courseSlug", slug)) {
log.info("Getting course by slug");
// Внутри service/repository эти логи тоже будут содержать courseSlug (если формат логов это выводит)
return catalogService.getBySlug(slug);
}
// После выхода из try-with-resources ключ автоматически удалится из MDC
}
}
Теперь, если внутри CourseCatalogService и репозитория вы тоже логируете что-то вроде “loading”, “found”, “not found”, эти сообщения окажутся “помечены” courseSlug. И вам не надо прокидывать slug как параметр в каждый лог-вызов. Он и так есть в методе, но чем дальше вниз по слоям, тем меньше хочется помнить, кто и как нас вызвал. MDC как раз спасает от этой «утечки контекста» в сигнатуры методов.
Здесь важно не впасть в другую крайность: courseSlug — хорошее поле, но оно работает только для endpoint’а “получить курс”. Для листинга курсов оно будет бессмысленно. Поэтому мы воспринимаем это как “локальный контекст на конкретное действие”, а общий “скелет” корреляции (обычно requestId) сделаем иначе — на уровне всего HTTP-запроса.
5. RequestId на каждый запрос
Когда вы почувствовали пользу MDC на одном endpoint’е, хочется сделать следующий логичный шаг: дать каждому HTTP-запросу requestId автоматически. То есть так, чтобы любой лог, который случается в процессе обработки запроса (controller, service, repository), имел общий ключ requestId. Это уже “базовый взрослеющий” logging baseline: без него сервис живёт, но диагностика получается дорогой.
Для Spring MVC самый дружелюбный для курса способ — HandlerInterceptor. Его удобно регистрировать через WebMvcConfigurer, который у нас уже есть как безопасная точка кастомизации MVC. Смысл перехватчика очень прост: в preHandle мы создаём (или принимаем) requestId и кладём его в MDC; в afterCompletion мы его убираем.
Сначала сделаем маленький генератор requestId, чтобы код не превратился в «анатомию UUID» прямо посреди лекции:
import java.util.UUID;
public final class RequestIdGenerator {
public String nextId() {
// Делаем короткий ID для удобства чтения в логах (полный UUID часто слишком длинный)
return "req-" + UUID.randomUUID().toString().substring(0, 8);
}
}
Теперь ключевой кусок перехватчика — логика “взяли ID из заголовка или сгенерировали новый, положили в MDC”. (Да, это допускает, что внешний gateway/прокси может прислать свой X-Request-Id, и мы его сохраним — в реальных системах это удобно.)
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.web.servlet.HandlerInterceptor;
public class RequestIdInterceptor implements HandlerInterceptor {
private final RequestIdGenerator requestIdGenerator = new RequestIdGenerator();
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
// 1) Пытаемся взять requestId от клиента/прокси (если он его уже сгенерировал)
String id = req.getHeader("X-Request-Id");
// 2) Если заголовка нет — генерируем новый
id = (id == null || id.isBlank()) ? requestIdGenerator.nextId() : id;
// 3) Кладём requestId в MDC: дальше любые логи в текущем потоке смогут его использовать
MDC.put("requestId", id);
// 4) Возвращаем requestId клиенту: это удобно для поддержки и трассировки
res.setHeader("X-Request-Id", id);
return true;
}
}
А теперь обязательный «выходной» кусок — cleanup. Он очень короткий, но по важности легко спорит с остальным кодом. Здесь вы буквально защищаете себя от “паранормальной корреляции”, когда один поток обслужил два запроса и утащил requestId из прошлого в будущее.
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.web.servlet.HandlerInterceptor;
public class RequestIdInterceptor implements HandlerInterceptor {
@Override
public void afterCompletion(HttpServletRequest req, HttpServletResponse res, Object handler, Exception ex) {
// Обязательная уборка MDC: потоки в servlet-контейнере переиспользуются
MDC.remove("requestId");
}
}
Дальше предполагаем, что RequestIdInterceptor уже зарегистрирован как Spring bean — например, через @Component или @Bean. Тогда его можно спокойно внедрить конструктором в MVC-конфигурацию.
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
private final RequestIdInterceptor requestIdInterceptor;
public WebConfiguration(RequestIdInterceptor requestIdInterceptor) {
// Внедряем интерцептор как зависимость (constructor injection)
this.requestIdInterceptor = requestIdInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
// Регистрируем интерцептор: он будет оборачивать обработку каждого HTTP-запроса
registry.addInterceptor(requestIdInterceptor);
}
}
На этом wiring заканчивается: requestId кладётся один раз на входе, очищается один раз на выходе, и теперь он всегда рядом с любым логом внутри запроса.
6. Временные поля в MDC
Когда requestId живёт у вас автоматически, появляется соблазн превратить MDC в «карман без дна»: положить туда вообще всё — от входных параметров до результата. Это очень быстро портит логи: поля становятся хаотичными, а самое ценное (корреляция) теряется в шуме. Поэтому хороший стиль — держать “постоянный минимум” (например, requestId) и добавлять дополнительные поля только в пределах маленького блока.
Например, в CourseCatalogService мы можем в момент фильтрации положить в MDC параметры фильтра, чтобы несколько логов внутри одной операции стали понятнее. Важно: класть только то, что реально помогает, и убирать сразу после. Здесь удобно использовать MDC.putCloseable(...) и вложенные try-with-resources.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
public class CourseCatalogService {
private static final Logger log = LoggerFactory.getLogger(CourseCatalogService.class);
public List<CourseCard> findCourses(CourseTrack track, int limit) {
// Временные поля MDC "живут" только в рамках этого блока (и автоматически чистятся)
try (var t = MDC.putCloseable("track", track.name());
// MDC хранит значения как строки, поэтому числа лучше явно приводить к String
var l = MDC.putCloseable("limit", String.valueOf(limit))) {
log.debug("Filtering courses");
// ... фильтрация/репозиторий
return List.of();
}
}
}
Здесь есть две маленькие, но важные дисциплины. Первая — в MDC почти всегда кладут строки, поэтому числа и enum’ы нужно явно превращать в String. Вторая — ключи должны быть договорёнными. Если завтра вы решите, что limit надо назвать max, а послезавтра — pageSize, то никакого “корреляционного удобства” уже не получится: логи станут непредсказуемыми. Внутри проекта лучше иметь единый словарик ключей и не стесняться его придерживаться.
Кстати, чтобы не плодить опечатки вида "requsetId" (классика жанра), можно завести маленький класс с константами. Он почти ничего не стоит, но экономит часы:
public final class MdcKeys {
// Единые имена ключей MDC по всему проекту — это маленький, но важный контракт
public static final String REQUEST_ID = "requestId";
public static final String COURSE_SLUG = "courseSlug";
private MdcKeys() {
// Утилитный класс: экземпляры не нужны
}
}
И потом использовать MdcKeys.REQUEST_ID вместо “магических строк”. Это не обязательная часть, но для новичков приятно: меньше риска сделать ошибку, которую компилятор не поймает.
7. MDC в логах: проверка и форматы
Очень частый момент: вы всё сделали правильно, MDC кладёте, чистите, даже радуетесь внутренне… а в консоли ничего не изменилось. И это не потому что Spring вас не любит. Просто MDC — это “дополнительные данные”, а вывод лога зависит от формата. Structured logging обычно умеет “показать поля” намного естественнее, а plain text-лог без настроенного шаблона может эти поля вообще не печатать.
Если вы пока живёте в plain text, минимальный способ убедиться, что MDC работает — добавить вывод нужного поля в шаблон консоли через property. Это не Logback XML и не “ручная настройка системы логирования на 200 строк”, а просто короткая конфигурация.
logging:
pattern:
console: "%d{HH:mm:ss.SSS} %-5level [%X{requestId}] %logger{36} - %msg%n"
После этого вы начнёте видеть requestId прямо в строке:
10:15:00.120 INFO [req-1a2b3c4d] CourseCatalogController - Getting course by slug
Если же у вас включен structured logging (например, в ECS/Logstash формате), лог-строка станет JSON, и MDC-поля обычно попадут туда как отдельные ключи. Мы не будем сейчас цепляться к точному shape каждого формата (это путь в “почему поле называется не так, как я ожидал”), но на уровне идеи это выглядит примерно так:
{
"@timestamp": "2026-03-18T10:15:00Z",
"level": "INFO",
"logger": "com.example.catalogservice.catalog.web.CourseCatalogController",
"message": "Getting course by slug",
"requestId": "req-1a2b3c4d",
"courseSlug": "spring-boot"
}
Практический смысл этого “поля отдельно” ровно такой: вы можете глазами (или позже инструментом) отфильтровать все события по requestId и получить историю запроса. А если вы добавили courseSlug на маленький кусок, то вы ещё и увидите, какой именно курс искали, без того чтобы повторять slug в каждом log.info(...).
8. Типичные ошибки при работе с MDC
В MDC легко влюбиться: ощущение, что вы “подклеили к логам интеллект”, очень приятное. Но MDC также умеет незаметно испортить жизнь, если пользоваться им без дисциплины. Хорошая новость в том, что грабли у всех примерно одинаковые, и их можно обойти заранее — без героизма и ночных дебаг-сессий.
Ошибка №1: забыли очистить MDC, и контекст «переехал» в следующий запрос.
В servlet-приложении один и тот же thread обслуживает много запросов подряд. Если вы положили requestId в MDC и не сделали remove/clear в конце, следующий запрос на этом потоке унаследует старое значение. В логах это выглядит как «два разных клиента почему-то имеют один requestId». Лечится просто: ставьте cleanup в afterCompletion (или в finally), и не спорьте с этим правилом.
Ошибка №2: кладут в MDC чувствительные данные, потому что “это же просто для логов”.
MDC — не секретное хранилище, а часть лог-событий. Если вы положили туда токен, e-mail, номер карты или Authorization header, вы фактически записали это в лог. А лог — штука живучая: он улетает в файлы, в сборщики, в чужие компьютеры, в баг-репорты. Держите в MDC только безопасные идентификаторы вроде requestId, courseSlug, максимум технические флаги.
Ошибка №3: превращают MDC в “дамп всего”, и корреляция тонет в шуме.
Проблема не только в объёме. Чем больше полей вы кладёте, тем меньше вы им доверяете: ключи начинают жить “как придётся”, появляются случайные названия, разные типы значений, и вы перестаёте понимать, что вообще искать. Хороший стиль — маленький стабильный набор и временные поля “на минутку” внутри try-блока.
Ошибка №4: разные имена ключей в разных местах проекта.
Сегодня вы написали requestId, завтра — reqId, послезавтра — request_id. В итоге вы не можете ни стабильно фильтровать, ни нормально читать. Особенно это неприятно в structured logging, где ключи — это и есть контракт логов. Спасает договорённость и, в идеале, константы вроде MdcKeys.REQUEST_ID.
Ошибка №5: ожидание, что MDC “магически работает везде”, включая другие потоки и асинхронность.
MDC обычно привязан к текущему thread’у. Если вы стартуете работу в другом потоке (или используете async механики), контекст туда сам по себе не переедет. В рамках нашего курса мы не уходим в сложную передачу MDC между потоками, но практическое правило простое: если вы не уверены, что остаётесь в одном потоке, не обещайте себе, что MDC всегда будет заполнен.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ