Post

Логирование в Java: архитектура и современные практики

Логирование в Java: архитектура и современные практики

Для логирования в Java обычно достаточно создать Logger, вызвать log.info() и получить сообщение в консоли или файле. Из-за этого может сложиться впечатление, что логирование представляет собой довольно простую модель записи строк.

На самом деле за одним вызовом log.info() скрывается целая архитектура. Сообщение проходит через API SLF4J, преобразуется во внутреннее представление события, обрабатывается системой логирования и только после этого попадает в консоль, файл или другое место назначения.

В этой статье разберем, как устроено логирование в Java, какую роль играют SLF4J и Logback, что происходит после вызова методов логирования и как устроены наиболее важные модели, с которыми разработчик сталкивается в повседневной работе.

Архитектура логирования в Java

От разных фреймворков к единому API

Исторически в Java существовало несколько независимых систем логирования. Наиболее известными среди них были java.util.logging (JUL), Log4j и Jakarta Commons Logging (JCL). Каждая из них предоставляла собственный API, собственную модель конфигурации и собственный способ записи сообщений.

Это создавало определенные сложности при разработке приложений. Например, приложение могло использовать одну систему логирования, ORM-фреймворк — другую, а HTTP-клиент — третью. В результате для каждой библиотеки приходилось поддерживать отдельную конфигурацию, а сообщения могли выводиться в разные файлы, иметь различный формат или вовсе отсутствовать.

Со временем в Java сформировался другой подход. Вместо того чтобы привязывать код к конкретной системе логирования, был выделен отдельный уровень абстракции — единый API, через который приложение и библиотеки выполняют запись сообщений. Конкретная реализация логирования при этом подключается отдельно и может быть заменена без изменения исходного кода.

Именно такую роль выполняет SLF4J (Simple Logging Facade for Java). Он не записывает сообщения самостоятельно и не содержит собственной реализации логирования. Его задача — предоставить единый интерфейс, поверх которого могут работать различные системы логирования.

Одной из наиболее распространенных реализаций такого интерфейса является Logback. Именно связка SLF4J + Logback сегодня используется по умолчанию в большинстве приложений на Spring Boot и во многих других Java-проектах.

Почему именно SLF4J

Основная идея SLF4J заключается в разделении интерфейса логирования и его реализации.

Код приложения работает не с конкретной системой логирования, а с API, предоставляемым SLF4J. Благодаря этому приложение не зависит от того, какая именно библиотека будет выполнять запись сообщений.

На практике это означает, что разработчик использует классы и интерфейсы SLF4J:

1
2
3
4
private static final Logger log =
        LoggerFactory.getLogger(UserService.class);

log.info("Order created");

При этом приложение ничего не знает о том, какая система логирования находится “под капотом”. Это может быть Logback, Log4j2 или другая реализация, поддерживающая API SLF4J.

Такой подход напоминает работу JDBC. Код использует интерфейсы Connection, Statement и ResultSet, не привязываясь к конкретному JDBC-драйверу. Аналогично и SLF4J предоставляет единый API, а конкретная реализация логирования подключается отдельно.

1
2
3
4
5
6
7
8
           Приложение
                │
                ▼
            SLF4J API
                │
        ┌───────┴────────┐
        ▼                ▼
     Logback          Log4j2

Подобное разделение оказалось особенно полезным при сопровождении крупных проектов. Например, в конце 2021 года в Log4j была обнаружена критическая уязвимость Log4Shell. Во многих приложениях, использовавших API SLF4J, переход на другую систему логирования свелся к замене зависимостей и конфигурации без изменения прикладного кода.

Адаптеры (Bridges)

Несмотря на то что сегодня SLF4J стал фактическим стандартом логирования в Java, далеко не все библиотеки используют его напрямую. Многие существующие проекты продолжают работать через java.util.logging (JUL), Apache Commons Logging (JCL) или Log4j.

Чтобы сообщения всех библиотек попадали в одну систему логирования, используются специальные адаптеры (bridges): jul-to-slf4j, jcl-over-slf4j или log4j-over-slf4j.

Их задача заключается в том, чтобы перехватывать вызовы одной системы логирования и перенаправлять их в другую.

Например, если библиотека использует JUL, а приложение настроено на работу через SLF4J и Logback, поток вызовов будет выглядеть следующим образом:

1
2
3
4
5
6
7
8
9
10
11
12
13
Библиотека
    │
    ▼
 java.util.logging
    │
    ▼
   Bridge
    │
    ▼
   SLF4J
    │
    ▼
  Logback

Аналогичным образом могут перенаправляться вызовы из Apache Commons Logging или Log4j.

Благодаря этому приложение, Spring Framework и сторонние библиотеки могут использовать разные API логирования, однако все сообщения в конечном итоге будут обрабатываться одной системой и записываться по единым правилам.

Стоит отметить, что адаптеры работают только в одном направлении. Если одновременно настроить взаимное перенаправление между двумя системами логирования, например JUL → SLF4J и SLF4J → JUL, возникнет бесконечный цикл передачи сообщений. Поэтому при настройке логирования важно использовать только одну конечную реализацию, а остальные системы направлять в нее через соответствующие bridges.

Logback как реализация SLF4J

SLF4J предоставляет только интерфейс для записи сообщений. Он определяет, как приложение взаимодействует с системой логирования, но не содержит собственного кода, который записывает сообщения в консоль, файл или другое место назначения.

Непосредственной обработкой сообщений занимается реализация, подключаемая к проекту отдельно. Одной из наиболее распространенных таких реализаций является Logback.

Получается следующая архитектура:

1
2
3
4
5
6
7
8
9
10
            Код приложения
                  │
                  ▼
              SLF4J API
                  │
                  ▼
               Logback
                  │
                  ▼
      Консоль / Файл / База данных / Сеть

При вызове

1
log.info("Order created");

код приложения обращается только к API SLF4J. Все последующие действия — создание внутреннего представления сообщения, проверка уровня логирования, форматирование и вывод — выполняет Logback.

Именно благодаря такому разделению приложение не зависит от конкретной реализации логирования. Если вместо Logback потребуется использовать другую библиотеку, в большинстве случаев достаточно изменить зависимости и конфигурацию проекта. Код, использующий API SLF4J, при этом останется без изменений.

Компоненты системы логирования

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

В Logback можно выделить следующие основные компоненты:

  • Logger — принимает сообщение, формирует событие логирования и передает его дальше;
  • Appender — определяет, куда будет направлено событие (например, в консоль, файл или по сети);
  • Layout / Encoder — преобразует событие в текстовое представление необходимого формата перед записью.

Их взаимодействие можно представить следующим образом:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
            log.info(...)
                  │
                  ▼
               Logger
                  │
                  ▼
            LoggingEvent
                  │
                  ▼
             Appender(ы)
                  │
                  ▼
         Layout / Encoder
                  │
                  ▼
    Консоль / Файл / База данных / Сеть

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

Именно такое разделение позволяет независимо изменять формат сообщений, место их хранения и даже саму систему логирования без изменения кода приложения.

В следующих разделах подробно рассмотрим каждый из этих компонентов.

Logger

Работа системы логирования начинается с объекта Logger. Именно его разработчик получает при помощи LoggerFactory и использует для записи сообщений.

Обычно логер объявляют следующим образом:

1
2
private static final Logger log =
        LoggerFactory.getLogger(OrderService.class);

Затем через него выполняется запись сообщений различных уровней:

1
2
3
4
5
log.trace("...");
log.debug("...");
log.info("...");
log.warn("...");
log.error("...");

Может показаться, что именно Logger записывает сообщения в консоль или файл, но это не так. Его задача заключается в другом — принять сообщение, проверить, должно ли оно быть обработано, сформировать внутреннее представление события (объект) и передать его следующим компонентам системы логирования.

Упрощенно этот процесс можно представить следующим образом:

1
2
3
4
5
6
7
8
9
            log.info(...)
                  │
                  ▼
               Logger
                  │
      Проверка уровня логирования
                  │
                  ▼
           LoggingEvent

Если для данного логера уровень INFO разрешен, сообщение преобразуется во внутреннее представление события (LoggingEvent) и передается дальше. Если же уровень отключен, обработка завершается на этом этапе, а последующие компоненты системы даже не будут вызваны.

Таким образом, Logger отвечает не за запись сообщений, а за принятие решения о том, должно ли сообщение продолжить свой путь по системе логирования.

LoggingEvent

После проверки уровня логирования Logger создает объект, представляющий собой событие логирования. В Logback этот объект называется LoggingEvent.

Он содержит не только текст сообщения, но и всю информацию, которая может понадобиться при дальнейшей обработке:

  • уровень логирования (INFO, WARN, ERROR и т.д.);
  • имя логера;
  • текст сообщения;
  • параметры сообщения;
  • время создания события;
  • имя потока;
  • информацию об исключении (если оно было передано);
  • дополнительные данные (MDC, маркеры и др.).

Упрощенно содержимое события можно представить следующим образом:

1
2
3
4
5
6
7
8
9
LoggingEvent
├── timestamp
├── level
├── loggerName
├── threadName
├── message
├── arguments
├── throwable
└── MDC

После создания LoggingEvent дальнейшие компоненты системы работают уже не с вызовом log.info(), а именно с этим объектом.

Это дает несколько преимуществ.

Во-первых, все необходимые данные собираются в одном месте и не требуют повторного вычисления.

Во-вторых, один и тот же объект события может быть передан сразу нескольким Appender. Например, одно сообщение может одновременно выводиться в консоль, записываться в файл и отправляться в систему централизованного сбора логов.

1
2
3
4
5
6
           LoggingEvent
                 │
      ┌──────────┼──────────┐
      ▼          ▼          ▼
 Console     File      Socket
Appender    Appender   Appender

Таким образом, LoggingEvent можно рассматривать как единый контейнер, содержащий всю информацию о произошедшем событии. Именно этот объект проходит через остальные компоненты системы логирования вплоть до момента записи сообщения в место назначения.

Appender

После создания LoggingEvent событие передается одному или нескольким Appender.

Именно Appender определяет, куда будет записано сообщение. Это может быть консоль, файл, удаленный сервер логирования, база данных или любое другое место назначения.

Например, при использовании консольного и файлового аппендеров одно событие логирования одновременно попадет и в консоль, и в файл:

1
2
3
4
5
6
7
8
           LoggingEvent
                 │
      ┌──────────┴──────────┐
      ▼                     ▼
ConsoleAppender       FileAppender
      │                     │
      ▼                     ▼
   Консоль                Файл

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

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

Различные реализации аппендеров позволяют направлять сообщения в самые разные системы. Среди наиболее распространенных можно выделить:

  • ConsoleAppender — вывод в консоль;
  • FileAppender — запись в файл;
  • RollingFileAppender — запись в файл с автоматической ротацией;
  • SocketAppender — отправка событий по сети.

Благодаря такой архитектуре место назначения логов можно изменить без каких-либо изменений в коде приложения. Достаточно заменить или добавить необходимый Appender в конфигурации системы логирования.

Encoder и Layout

К моменту, когда событие попадает в Appender, оно все еще представляет собой объект LoggingEvent. Однако записать такой объект в консоль или файл невозможно — сначала его необходимо преобразовать в текст.

Именно эту задачу выполняют Encoder и Layout.

Layout отвечает за формирование текстового представления события. Он определяет, какие поля будут включены в сообщение, в каком порядке они будут расположены и как будут отформатированы.

Например, на основе одного и того же LoggingEvent можно получить различные варианты записи:

1
2
3
4
5
INFO Order created

2026-07-17 10:42:18 INFO Order created

2026-07-17 10:42:18 INFO [http-nio-8080-exec-3] OrderService - Order created

В свою очередь, Encoder преобразует полученный текст в последовательность байтов и записывает ее в поток вывода. Благодаря этому одно и то же сообщение может быть записано в файл, выведено в консоль или отправлено по сети независимо от конкретного места назначения.

Упрощенно этот этап можно представить следующим образом:

1
2
3
4
5
6
7
8
9
10
11
12
        LoggingEvent
              │
              ▼
        Layout / Pattern
              │
     Текстовое сообщение
              │
              ▼
           Encoder
              │
              ▼
      Поток вывода (OutputStream)

В современных версиях Logback при настройке ConsoleAppender и FileAppender обычно используется PatternLayoutEncoder, объединяющий оба этапа. Сначала он формирует текст сообщения по заданному шаблону, а затем выполняет его запись в поток вывода.

Именно шаблон определяет внешний вид логов. Например, следующая конфигурация

1
<pattern>%d{yyyy-MM-dd HH:mm:ss} %-5level %logger - %msg%n</pattern>

позволит получить сообщения такого вида:

1
2026-07-17 10:42:18 INFO  com.example.OrderService - Order created

Таким образом, Appender отвечает за место назначения сообщения, а Layout и Encoder — за то, в каком виде оно будет туда записано.

Шаблоны форматирования

Внешний вид сообщений определяется шаблоном (pattern), используемым PatternLayoutEncoder. Он состоит из спецификаторов, каждый из которых отвечает за вывод определенной информации.

Наиболее часто используются следующие:

СпецификаторОписание
%dдата и время
%threadимя потока
%levelуровень логирования
%loggerимя логера
%msgтекст сообщения
%exстек вызовов исключения
%nперевод строки

Например, следующая конфигурация

1
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger - %msg%n</pattern>

сформирует сообщения такого вида:

1
2026-07-17 10:42:18 [main] INFO  com.example.OrderService - Order created

Следует учитывать, что некоторые спецификаторы требуют дополнительной обработки. Например, %class, %method и %line заставляют Logback определять место вызова через анализ стека вызовов.

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

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

Иерархия логеров

До этого момента мы рассматривали отдельный объект Logger, однако в реальном приложении логеров обычно гораздо больше. Практически каждый класс создает собственный экземпляр:

1
2
private static final Logger log =
        LoggerFactory.getLogger(OrderService.class);

Может показаться, что все эти логеры существуют независимо друг от друга. На самом деле это не так. В Logback они образуют иерархию, основанную на их именах.

Чаще всего в качестве имени используется полное имя класса:

1
2
3
com.example.OrderService
com.example.service.UserService
com.example.service.auth.AuthService

Поскольку имена имеют общие префиксы, Logback автоматически строит из них дерево:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ROOT
│
└── com
    │
    └── example
        │
        ├── OrderService
        │
        └── service
            │
            ├── UserService
            │
            └── auth
                │
                └── AuthService

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

Например, достаточно определить уровень логирования для пакета com.example.service, чтобы эта настройка автоматически распространилась на все расположенные внутри него классы, если для них не указаны собственные параметры.

Именно иерархия логеров делает возможными наследование настроек, использование общего набора Appender и централизованную конфигурацию логирования.

ROOT Logger

На вершине дерева находится специальный логер, называемый ROOT (корневой логер). Он создается автоматически при инициализации системы логирования и существует всегда.

Все остальные логеры являются его потомками — непосредственно или через другие логеры в иерархии.

Именно поэтому минимальная конфигурация Logback обычно содержит только настройку корневого логера:

1
2
3
4
5
6
7
8
9
10
11
12
<configuration>

    <appender name="CONSOLE"
              class="ch.qos.logback.core.ConsoleAppender">
        ...
    </appender>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
    </root>

</configuration>

Несмотря на то что в приложении могут существовать сотни или даже тысячи логеров, подобной конфигурации уже достаточно для работы. Каждый логер, не имеющий собственных настроек, будет использовать параметры, унаследованные от ROOT.

Например, если в приложении имеется следующий код:

1
2
3
4
private static final Logger log =
        LoggerFactory.getLogger(OrderService.class);

log.info("Order created");

то сообщение будет обработано с использованием уровня INFO и аппендера CONSOLE, определенных для корневого логера.

При необходимости отдельные части приложения можно настроить независимо. Например, для пакета com.example.service можно включить более подробное логирование:

1
<logger name="com.example.service" level="DEBUG"/>

В этом случае логеры, расположенные внутри пакета com.example.service, будут использовать уровень DEBUG, а остальные части приложения продолжат работать с уровнем INFO, унаследованным от ROOT.

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

Наследование Appender

Иерархия логеров используется не только для наследования уровней логирования. Аналогичным образом наследуются и Appender.

Предположим, что в конфигурации определен только один аппендер, подключенный к корневому логеру:

1
2
3
<root level="INFO">
    <appender-ref ref="CONSOLE"/>
</root>

Несмотря на это, сообщения будут выводиться из любого логера приложения:

1
2
3
4
5
LoggerFactory.getLogger(OrderService.class)
             .info("Order created");

LoggerFactory.getLogger(UserService.class)
             .info("User created");

Это происходит потому, что логеры, не имеющие собственных Appender, используют аппендеры своих родителей. В конечном итоге поиск доходит до ROOT, где и находится CONSOLE.

Упрощенно этот процесс можно представить следующим образом:

1
2
3
4
5
6
7
8
9
10
11
12
ROOT
 │
 ├── CONSOLE
 │
 └── com.example
      │
      └── service
            │
            └── OrderService
                   │
                   ▼
             наследует CONSOLE

Благодаря этому достаточно определить аппендер один раз — обычно у корневого логера. Все остальные логеры автоматически смогут использовать его без дополнительной настройки.

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

Additivity

До этого момента мы рассматривали ситуацию, когда Appender наследуется от родительских логеров. Однако логер может иметь и собственные аппендеры.

Например, представим следующую конфигурацию:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<appender name="CONSOLE"
          class="ch.qos.logback.core.ConsoleAppender">
    ...
</appender>

<appender name="FILE"
          class="ch.qos.logback.core.FileAppender">
    ...
</appender>

<root level="INFO">
    <appender-ref ref="CONSOLE"/>
</root>

<logger name="com.example.service">
    <appender-ref ref="FILE"/>
</logger>

В этом случае сообщение, записанное через логер com.example.service, будет передано сразу двум аппендерам:

1
2
3
4
5
6
7
8
9
10
                 LoggingEvent
                       │
            com.example.service
                       │
          ┌────────────┴────────────┐
          ▼                         ▼
        FILE                 ROOT (унаследован)
                                    │
                                    ▼
                                CONSOLE

В результате одно и то же сообщение окажется одновременно в файле и в консоли.

Такое поведение является стандартным для Logback и называется additivity. После обработки текущим логером событие продолжает подниматься вверх по дереву логеров, передаваясь аппендерам каждого родительского узла вплоть до ROOT.

В некоторых случаях подобное поведение оказывается нежелательным. Например, если сообщения определенного пакета должны записываться только в отдельный файл и не должны попадать в общий лог приложения.

Для этого используется атрибут additivity:

1
2
3
<logger name="com.example.service" additivity="false">
    <appender-ref ref="FILE"/>
</logger>

Теперь после обработки логером com.example.service распространение события прекратится, и сообщение будет записано только через FILE.

1
2
3
4
5
6
7
8
9
                 LoggingEvent
                       │
            com.example.service
                       │
                       ▼
                     FILE

         дальнейшее распространение
                остановлено

Таким образом, additivity не запрещает наследование настроек и не влияет на уровни логирования. Он управляет только распространением событий по дереву логеров и определяет, будут ли использоваться аппендеры вышестоящих узлов.

Уровни логирования

Одной из основных задач Logger является проверка уровня логирования. Именно на этом этапе принимается решение, будет ли сообщение обработано дальше или его обработка завершится сразу после вызова log.info() или log.debug().

В Logback определены следующие уровни логирования (в порядке возрастания важности):

УровеньНазначение
TRACEМаксимально подробная диагностическая информация.
DEBUGОтладочная информация, полезная при разработке и поиске ошибок.
INFOОсновные события, отражающие нормальную работу приложения.
WARNПредупреждения о потенциальных проблемах, не препятствующих работе приложения.
ERRORОшибки, из-за которых операция не была выполнена или была выполнена некорректно.

При настройке логера указывается минимальный уровень, который должен обрабатываться:

1
2
3
<root level="INFO">
    <appender-ref ref="CONSOLE"/>
</root>

В этом случае сообщения уровней INFO, WARN и ERROR будут записаны, а TRACE и DEBUG будут проигнорированы.

1
2
3
4
5
log.trace("Trace");
log.debug("Debug");
log.info("Info");
log.warn("Warning");
log.error("Error");

Результат будет выглядеть следующим образом:

1
2
3
INFO  Info
WARN  Warning
ERROR Error

Это объясняется тем, что уровни образуют иерархию. Если разрешен уровень INFO, автоматически разрешаются все уровни с более высоким приоритетом.

1
2
3
4
5
6
7
8
9
ERROR
  ▲
WARN
  ▲
INFO   ← level="INFO"
  ▲
DEBUG
  ▲
TRACE

Поэтому проверка уровня логирования выполняется еще до создания LoggingEvent. Если сообщение не удовлетворяет заданному уровню, дальнейшая обработка не выполняется: объект события не создается, аппендеры не вызываются, а форматирование сообщения не производится.

Именно поэтому настройка уровней логирования влияет не только на объем записываемых логов, но и на производительность приложения.

Выбор уровня логирования

Несмотря на то что уровней логирования всего пять, на практике именно их неправильное использование чаще всего делает логи менее полезными.

Например, запись обычных информационных сообщений с уровнем ERROR приведет к постоянным ложным срабатываниям систем мониторинга. С другой стороны, сообщения об ошибках, записанные с уровнем DEBUG, могут никогда не попасть в рабочие журналы приложения.

Уровни логирования принято использовать следующим образом.

TRACE

Используется для максимально подробной диагностики работы приложения.

На этом уровне обычно записывают информацию, которая необходима только при глубоком анализе поведения программы: прохождение отдельных участков кода, значения внутренних переменных, промежуточные результаты вычислений.

В рабочей среде уровень TRACE обычно отключен.

DEBUG

Предназначен для отладочной информации.

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

Во многих проектах именно DEBUG используется как основной уровень для диагностического логирования.

INFO

Используется для записи основных событий жизненного цикла приложения.

Например:

  • запуск и завершение приложения;
  • успешная обработка запроса;
  • выполнение фоновой задачи;
  • подключение к внешнему сервису;
  • завершение важной бизнес-операции.

Сообщения уровня INFO обычно составляют основу журналов работающего приложения.

WARN

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

Например:

  • использование устаревшего API;
  • превышение рекомендуемого времени выполнения операции;
  • работа с некорректными, но допустимыми входными данными;
  • повторная попытка подключения к внешнему сервису.

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

ERROR

Используется для ситуаций, когда операция завершилась неуспешно или приложение не смогло корректно выполнить свою задачу.

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

Сообщения уровня ERROR часто используются системами мониторинга для формирования уведомлений о возникших проблемах.

Логирование сторонних библиотек

При поиске ошибок далеко не всегда требуется увеличивать уровень логирования всего приложения. Во многих случаях достаточно включить подробное логирование только для конкретной библиотеки или пакета.

Например, если необходимо разобраться в работе Hibernate, можно включить уровень DEBUG только для соответствующих логеров:

1
<logger name="org.hibernate" level="DEBUG"/>

Аналогичным образом можно получить более подробную информацию о работе Spring Framework, HTTP-клиентов, драйверов баз данных и других библиотек:

1
2
3
4
5
<logger name="org.springframework.web"
        level="DEBUG"/>

<logger name="org.apache.http"
        level="DEBUG"/>

Благодаря иерархии логеров изменение уровня для одного пакета не влияет на остальные части приложения. Это позволяет получить необходимую диагностическую информацию без значительного увеличения объема журналов.

Параметризованные сообщения

При записи сообщений рекомендуется использовать параметризованные шаблоны вместо конкатенации строк. Например, вместо:

1
log.info("Заказ " + orderId + " успешно обработан");

обычно пишут:

1
log.info("Заказ {} успешно обработан", orderId);

На первый взгляд оба варианта дают одинаковый результат:

1
Заказ 1254 успешно обработан

Однако работают они по-разному.

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

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

По этой причине параметризованные сообщения считаются предпочтительным способом работы с большинством реализаций SLF4J.

Следует отметить, что сами аргументы метода вычисляются до вызова логера. Например, в следующем коде

1
log.debug("Результат: {}", calculateStatistics());

метод calculateStatistics() будет вызван независимо от того, включен уровень DEBUG или нет.

Если подготовка данных требует значительных вычислений или обращения к внешним ресурсам, имеет смысл предварительно проверить уровень логирования:

1
2
3
if (log.isDebugEnabled()) {
    log.debug("Результат: {}", calculateStatistics());
}

В большинстве случаев такая проверка не требуется. Она становится полезной только тогда, когда вычисление аргументов действительно является дорогостоящей операцией.

Логирование исключений

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

1
2
3
4
5
try {
    processOrder(orderId);
} catch (Exception e) {
    log.error(e.getMessage());
}

или

1
log.error("{}", e.getMessage());

Однако в этом случае в журнал попадет только текст сообщения исключения:

1
Order not found

При этом наиболее ценная диагностическая информация — стек вызовов — будет потеряна. По этой причине объект исключения рекомендуется передавать отдельным аргументом:

1
2
3
4
5
try {
    processOrder(orderId);
} catch (Exception e) {
    log.error("Ошибка обработки заказа {}", orderId, e);
}

Исключение при этом не становится частью строки сообщения. Во время формирования LoggingEvent сообщение и объект исключения сохраняются независимо друг от друга. Упрощенно это можно представить следующим образом:

1
2
3
4
5
LoggingEvent
├── level = ERROR
├── message = "Ошибка обработки заказа {}"
├── arguments = [orderId]
└── throwable = RuntimeException(...)

При обработке события Logback обнаруживает, что в поле throwable присутствует исключение, и автоматически записывает после сообщения полный стек вызовов.

В результате журнал будет содержать не только текст ошибки, но и информацию о месте ее возникновения:

1
2
3
4
2026-07-17 12:45:18 ERROR OrderService - Ошибка обработки заказа 1254
java.lang.IllegalStateException: Order not found
    at com.example.OrderService.processOrder(OrderService.java:42)
    at com.example.Application.main(Application.java:18)

Распространенные ошибки

Одной из наиболее распространенных ошибок является запись только текста исключения:

1
log.error("Ошибка {}", e.getMessage());

или

1
log.error(e.toString());

В обоих случаях стек вызовов потеряется, а в журнал будет записано только сообщение:

1
Ошибка Order not found

или

1
java.lang.IllegalStateException: Order not found

Еще одна распространенная ошибка выглядит следующим образом:

1
log.error("Ошибка: {}", e);

На первый взгляд кажется, что результат будет тем же самым, что и при вызове

1
log.error("Ошибка", e);

Однако это разные операции.

В первом случае объект Throwable воспринимается как обычный аргумент для подстановки в шаблон сообщения. В журнал попадет только результат вызова e.toString():

1
Ошибка: java.lang.IllegalStateException: Order not found

Во втором случае исключение передается логеру отдельно и сохраняется в поле throwable объекта LoggingEvent. Благодаря этому Logback автоматически выведет полный стек вызовов.

Принцип “Log or Throw”

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

Don’t log and throw. Either log it or throw it.

Не записывайте исключение в журнал и не пробрасывайте его одновременно. Либо журналируйте его, либо передавайте выше.

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

Например, одна ошибка может выглядеть следующим образом:

1
2
3
4
5
6
7
8
9
10
11
ERROR Repository - Failed to save order
...
Caused by: java.sql.SQLException ...

ERROR Service - Unable to process order
...
Caused by: java.sql.SQLException ...

ERROR Controller - Request processing failed
...
Caused by: java.sql.SQLException ...

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

Рассмотрим следующий пример:

1
2
3
4
5
6
7
public void saveOrder(Order order) {
    try {
        repository.save(order);
    } catch (SQLException e) {
        throw new OrderStorageException(e);
    }
}

В данном случае метод не занимается обработкой ошибки. Он лишь преобразует одно исключение в другое и передает его выше. Логирование здесь не требуется, поскольку решение о дальнейшей судьбе исключения еще не принято.

Совсем другая ситуация возникает на границе приложения — например, при обработке HTTP-запроса, выполнении фоновой задачи или запуске консольной программы.

1
2
3
4
5
try {
    orderService.process(orderId);
} catch (Exception e) {
    log.error("Ошибка обработки заказа {}", orderId, e);
}

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

Во многих современных фреймворках эта задача уже решена средствами инфраструктуры. Например, Spring Boot автоматически перехватывает необработанные исключения, записывает их в журнал вместе со стеком вызовов и формирует HTTP-ответ. Если же исключение было перехвачено в коде приложения, ответственность за его логирование полностью переходит к разработчику.

Таким образом, логирование исключений следует рассматривать не как обязательное действие в каждом блоке catch, а как фиксацию ошибки в том месте, где она действительно считается обработанной.

Асинхронное логирование

В большинстве приложений запись сообщений выполняется синхронно. Это означает, что поток, вызвавший log.info() или log.error(), будет ожидать завершения записи сообщения в место назначения.

Для консольного вывода подобная задержка обычно несущественна. Однако при записи в файлы, передаче логов по сети или работе с удаленными системами хранения она может стать заметной.

В подобных случаях используется AsyncAppender.

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

Упрощенно это выглядит следующим образом:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Код приложения
       │
       ▼
     Logger
       │
       ▼
 LoggingEvent
       │
       ▼
 AsyncAppender
       │
       ▼
   Очередь событий
       │
       ▼
 Фоновый поток
       │
       ▼
 FileAppender / SocketAppender

Такой подход позволяет уменьшить влияние логирования на время выполнения основной бизнес-логики приложения.

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

MDC

В современных приложениях одновременно могут обрабатываться десятки или сотни запросов. Если все они записывают сообщения в один журнал, понять, какие записи относятся к конкретному запросу, становится непросто.

Например, журнал может выглядеть следующим образом:

1
2
3
4
5
INFO  Processing order 1254
INFO  Processing order 7812
INFO  Order saved
INFO  Payment completed
INFO  Sending notification

Из такого журнала невозможно определить, какие сообщения относятся к одному запросу, а какие — к другому.

Для решения этой задачи используется MDC (Mapped Diagnostic Context).

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

Например, перед обработкой запроса можно сохранить его идентификатор:

1
2
3
4
5
6
7
MDC.put("requestId", requestId);

try {
    processRequest();
} finally {
    MDC.clear();
}

Если шаблон логирования содержит спецификатор %X{requestId}

1
2
3
<pattern>
%d %-5level [%X{requestId}] %logger - %msg%n
</pattern>

каждая запись автоматически получит соответствующий идентификатор:

1
2
3
2026-07-17 10:42:18 INFO [a3f71d] OrderService - Processing order
2026-07-17 10:42:18 INFO [a3f71d] PaymentService - Payment completed
2026-07-17 10:42:18 INFO [a3f71d] NotificationService - Sending notification

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

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

В Spring Boot заполнение MDC часто выполняется в фильтрах (Filter) или интерсепторах (HandlerInterceptor), где для каждого входящего HTTP-запроса формируется уникальный идентификатор (requestId). Благодаря этому все сообщения, относящиеся к обработке одного запроса, автоматически получают общий контекст.

Использование MDC особенно полезно при диагностике проблем в многопоточных приложениях. Даже если журнал содержит записи от большого количества одновременно выполняющихся запросов, общий идентификатор позволяет быстро найти все сообщения, относящиеся к одной операции.

Заключение

На первый взгляд логирование в Java сводится к нескольким вызовам log.info() или log.error(). Однако за ними скрывается целая архитектура, включающая API логирования, иерархию логеров, события логирования, аппендеры, форматирование сообщений и модели передачи контекста.

Понимание того, как взаимодействуют Logger, LoggingEvent, Appender, уровни логирования и MDC, позволяет эффективнее настраивать журналы, быстрее диагностировать проблемы и использовать возможности системы логирования в полной мере.

В современных приложениях логирование обычно является лишь одной из составляющих наблюдаемости (observability). Наряду с журналами широко используются метрики и распределенный трейсинг, позволяющие получить более полное представление о работе системы.

This post is licensed under CC BY 4.0 by the author.