JavaRush /Курси /JAVA 25 SELF /Логування в багатопотокових і веб-застосунках

Логування в багатопотокових і веб-застосунках

JAVA 25 SELF
Рівень 63 , Лекція 2
Відкрита

1. Потокобезпечність логування

В однопоточних програмах усе просто: один потік пише логи, ніхто йому не заважає. А от у реальних застосунках — веб‑сервісах, мікросервісах — одночасно працюють десятки й сотні потоків. Уявіть, якби кілька людей одночасно писали ручкою в один і той самий рядок блокнота — результат був би, м’яко кажучи, нечитабельним.

Потокобезпечність (thread safety) — це гарантія, що навіть якщо 100500 потоків одночасно пишуть логи, повідомлення не переплутаються, не зіллються і не загубляться.

Як це реалізовано в бібліотеках?

Сучасні бібліотеки логування (Log4j 2, Logback, java.util.logging) від початку спроєктовані так, щоб бути потокобезпечними. Це означає:

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

ВАЖЛИВО: Сам логер (наприклад, об’єкт Logger із SLF4J або Log4j) можна використовувати як static final поле в будь‑якому класі — це не призведе до проблем із потоками.

Приклад

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class MultiThreadedLoggerExample {
    private static final Logger logger = LoggerFactory.getLogger(MultiThreadedLoggerExample.class);

    public static void main(String[] args) {
        Runnable task = () -> {
            for (int i = 0; i < 5; i++) {
                logger.info("Потік {} записує повідомлення {}", Thread.currentThread().getName(), i);
            }
        };

        Thread t1 = new Thread(task, "Перший");
        Thread t2 = new Thread(task, "Другий");
        t1.start();
        t2.start();
    }
}

У логах ви побачите акуратні повідомлення від обох потоків — без каші та накладання.

2. Контекст логування: MDC (Mapped Diagnostic Context)

Уявіть: ваш застосунок обробляє сотні запитів одночасно, кожен — у своєму потоці. У логах миготять повідомлення, але незрозуміло, що до якого запиту належить. Хочеться бачити не просто «що сталося», а з ким і в якому запиті це сталося.

MDC (Mapped Diagnostic Context) — це спеціальний механізм, який дозволяє «прикріпити» додаткову інформацію до логів, пов’язану з поточним потоком. Усі повідомлення, які пише потік, автоматично отримують ці додаткові дані.

Приклад: логування ідентифікатора запиту

У веб‑застосунку кожному запиту можна призначити унікальний ID (наприклад, UUID). За допомогою MDC цей ID буде автоматично додаватися до всіх логів, які пише потік, що обслуговує запит.

Як це виглядає в коді (SLF4J + Logback):

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;

import java.util.UUID;

public class MdcExample {
    private static final Logger logger = LoggerFactory.getLogger(MdcExample.class);

    public static void main(String[] args) {
        Runnable task = () -> {
            // Генеруємо унікальний ідентифікатор запиту
            String requestId = UUID.randomUUID().toString();
            MDC.put("requestId", requestId); // додаємо до MDC

            logger.info("Обробляємо запит");
            doSomeWork();
            logger.info("Завершили обробку");

            MDC.clear(); // обов’язково очищаємо після завершення!
        };

        Thread t1 = new Thread(task, "Потік-1");
        Thread t2 = new Thread(task, "Потік-2");
        t1.start();
        t2.start();
    }

    static void doSomeWork() {
        logger.debug("Виконуємо роботу...");
    }
}

Налаштування формату логу (наприклад, logback.xml):

<encoder>
    <pattern>%d{HH:mm:ss} [%thread] %-5level %logger{36} [requestId=%X{requestId}] - %msg%n</pattern>
</encoder>

Результат:

12:01:23 [Потік-1] INFO  MdcExample [requestId=ad8d...f3] - Обробляємо запит
12:01:23 [Потік-1] DEBUG MdcExample [requestId=ad8d...f3] - Виконуємо роботу...
12:01:23 [Потік-1] INFO  MdcExample [requestId=ad8d...f3] - Завершили обробку

Важливо!

  • MDC працює лише в межах одного потоку. Якщо ви передаєте роботу в інший потік (наприклад, через пул потоків), вам потрібно вручну передати значення MDC (або використовувати спеціальні бібліотеки, які роблять це автоматично).
  • Не забувайте очищати MDC! Якщо не очистити, дані можуть «перетекти» на наступний запит у тому самому потоці (наприклад, у пулі потоків веб‑сервера). Використовуйте MDC.clear() у блоці finally.

3. Логування у веб-застосунках

Веб‑застосунок — це не просто програма, яка запустилася та працює. Це справжній конвеєр: запити прилітають, обробляються, йдуть відповіді. І все це — одночасно, сотнями. Тут логування — не розкіш, а необхідність!

Що логувати у веб-застосунках?

  • HTTP‑запити та відповіді: метод, URL, параметри, статус відповіді, час обробки.
  • Помилки та винятки: усі неочікувані збої, stack trace.
  • Бізнес‑події: реєстрація, вхід, оформлення замовлення, оплата тощо.
  • Технічні деталі: взаємодія з базою даних, зовнішніми сервісами, час виконання операцій.

Головне правило: логувати так, щоб через місяць, коли щось зламається о 3 годині ночі, ви змогли розібратися, що пішло не так.

Приклад: логування HTTP‑запиту (Spring Boot)

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

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.UUID;

@Component
public class RequestLoggingFilter implements Filter {
    private static final Logger logger = LoggerFactory.getLogger(RequestLoggingFilter.class);

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        String requestId = UUID.randomUUID().toString();
        MDC.put("requestId", requestId);

        HttpServletRequest httpRequest = (HttpServletRequest) request;
        logger.info("Запит: {} {}", httpRequest.getMethod(), httpRequest.getRequestURI());

        long start = System.currentTimeMillis();
        try {
            chain.doFilter(request, response); // далі ланцюжком (до контролера)
        } finally {
            long duration = System.currentTimeMillis() - start;
            logger.info("Відповідь надіслано, час обробки: {} мс", duration);
            MDC.clear();
        }
    }
}

Логування помилок і винятків

У веб‑фреймворках (наприклад, Spring) прийнято використовувати спеціальні обробники помилок (@ExceptionHandler), щоб гарно логувати всі неочікувані збої.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ControllerAdvice
public class GlobalExceptionHandler {
    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(Exception.class)
    public String handleException(Exception ex) {
        logger.error("Сталася помилка: ", ex); // логуємо із повним stack trace!
        return "error"; // повертаємо сторінку помилки
    }
}

Інтеграція з веб-фреймворками

Майже всі сучасні веб‑фреймворки (Spring, Jakarta EE, Micronaut тощо) інтегруються з логерами «з коробки». Зазвичай достатньо додати залежність SLF4J/Logback у проєкт — і всі стандартні повідомлення (старту застосунку, обробки запитів, помилок) будуть логуватися автоматично.

4. Практика: приклад логування в багатопотоковому завданні

Додаймо до нашого навчального застосунку багатопотокову обробку й подивімося, як логування допомагає не втратити голову.

Приклад: обробка замовлень у кількох потоках із MDC

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;

import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class OrderProcessingApp {
    private static final Logger logger = LoggerFactory.getLogger(OrderProcessingApp.class);

    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(3);

        for (int i = 1; i <= 5; i++) {
            final int orderId = i;
            executor.submit(() -> {
                String requestId = UUID.randomUUID().toString();
                MDC.put("requestId", requestId);

                try {
                    logger.info("Починаємо обробку замовлення {}", orderId);
                    processOrder(orderId);
                    logger.info("Замовлення {} оброблено успішно", orderId);
                } catch (Exception ex) {
                    logger.error("Помилка під час обробки замовлення " + orderId, ex);
                } finally {
                    MDC.clear();
                }
            });
        }
        executor.shutdown();
    }

    static void processOrder(int orderId) throws InterruptedException {
        if (orderId % 2 == 0) {
            throw new RuntimeException("Симуляція помилки для парного замовлення");
        }
        Thread.sleep(500); // імітація роботи
    }
}

Що відбувається:

  • Кожне замовлення обробляється в окремому потоці.
  • Для кожного потоку створюється унікальний requestId (через MDC).
  • Усі логи по одному замовленню можна знайти за цим ідентифікатором.
  • Помилки логуються з повним стеком.

Формат логу налаштовано так, щоб показувати requestId.

5. Важливі нюанси та особливості

  • Потоки, пули та MDC. Якщо працюєте з пулами потоків (а ви напевно працюєте), запам’ятайте: потоки в пулі перевикористовуються! Якщо забути очистити MDC, дані від одного запиту можуть потрапити в логи іншого. Завжди викликайте MDC.clear() наприкінці роботи.
  • MDC та асинхронні завдання. В асинхронних веб‑фреймворках (наприклад, Spring WebFlux) MDC не завжди працює «з коробки», бо обробка запиту може стрибати між потоками. Для таких випадків існують спеціальні розширення або адаптери.
  • Логування в мікросервісах. У мікросервісній архітектурі заведено логувати не лише локальний ідентифікатор запиту, а й глобальний (traceId), який передається між сервісами. Це дозволяє простежити шлях запиту через усю систему (distributed tracing). Для цього часто використовують системи на кшталт Zipkin, Jaeger, OpenTelemetry.

6. Демонстрація: різниця між System.out.println і логуванням

System.out.println — просто друкує рядок у консоль. У багатопоточному середовищі:

  • Повідомлення можуть перемішуватися.
  • Немає інформації про час, потік, рівень, контекст.
  • Не можна налаштувати виведення у файл, формат, фільтрацію за рівнем.

Логер — пише структуровані повідомлення, враховує потоки, рівні, формат, підтримує виведення в різні місця (файл, консоль, мережа).

Приклад порівняння

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class PrintVsLogger {
    private static final Logger logger = LoggerFactory.getLogger(PrintVsLogger.class);

    public static void main(String[] args) {
        Runnable task = () -> {
            for (int i = 0; i < 3; i++) {
                System.out.println("System.out: " + Thread.currentThread().getName() + " крок " + i);
                logger.info("Logger: крок {}", i);
            }
        };
        new Thread(task, "T1").start();
        new Thread(task, "T2").start();
    }
}

Висновок:

  • System.out — повідомлення можуть іти впереміш, без часу й рівня.
  • Логер — кожне повідомлення містить час, потік, рівень, його можна відфільтрувати й швидко знайти потрібне.

7. Типові помилки під час логування в багатопотокових і веб-застосунках

Помилка № 1: Використання System.out.println замість логера. У багатопоточному середовищі це призводить до «каші» в консолі, неможливості фільтрувати повідомлення та втрати інформації про контекст.

Помилка № 2: Ігнорування MDC або неправильне його використання. Якщо не використовувати MDC для передавання ідентифікатора запиту/користувача, логи стають беззмістовними — неможливо зрозуміти, до якого запиту належить помилка. Якщо забувати очищати MDC, дані можуть «витекти» в інший запит.

Помилка № 3: Логер створюється як локальна змінна. Краще використовувати private static final Logger — так логер створюється один раз на клас, не витрачається пам’ять і немає ризику помилок.

Помилка № 4: Логування чутливих даних. У логи не повинні потрапляти паролі, банківські картки, персональні дані — це порушення безпеки!

Помилка № 5: Логування лише «INFO» або лише «ERROR». Використовуйте відповідні рівні: DEBUG для налагодження, INFO для бізнес‑подій, ERROR для помилок. Не варто все писати одним рівнем — інакше логи втрачають сенс.

Помилка № 6: Не логуються stack trace винятків. Якщо писати просто logger.error("Помилка: " + ex.getMessage()), втрачається інформація про причину помилки. Завжди логуйте повний виняток: logger.error("Помилка", ex).

Помилка № 7: Непотокобезпечні самописні логери. Якщо хтось вирішив «зробити свій логер» без синхронізації — у багатопоточному середовищі це майже гарантована втрата або псування логів.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ