Skip to the content.

Технологии программирования

Главная

Лекция 10. Эксплуатационные аспекты backend-приложений

Содержание

  1. Проблема: от разработки к production
  2. Системная модель backend-приложения
  3. Observability: метрики и мониторинг
  4. Logging и Trace ID
  5. Graceful Shutdown и жизненный цикл
  6. Profiles и конфигурация
  7. Кэширование
  8. Сквозной сценарий: от инцидента до исправления

1. Проблема: от разработки к production

Представьте: вы написали backend-сервис для управления пользователями — UserService. Локально всё работает отлично. Тесты проходят. API отвечает быстро. Вы деплоите в production и… начинаются проблемы.

Пользователи жалуются на медленную работу. Некоторые запросы возвращают 500. Во время обновления сервиса пользователи получают connection errors. А самое неприятное — баг, который воспроизводится только в production, но не на вашем ноутбуке.

Знакомая ситуация? Это классическая проблема: “работает на моей машине” ≠ “работает в production”.

Почему production отличается от разработки

На вашем ноутбуке:

В production:

Архитектура UserService в production

Сценарий: что может пойти не так

Сценарий 1: Невидимая деградация

UserService работает в production. Всё вроде бы нормально. Но постепенно время ответа растёт: 50ms → 200ms → 500ms → 2 секунды. Пользователи начинают жаловаться. Вы заходите на сервер, смотрите логи — ничего подозрительного. Перезапускаете сервис — помогает на час, потом снова медленно.

Проблема: без метрик вы не видите, что происходит. Нет графиков latency, нет информации о нагрузке на БД, нет данных о memory usage.

Сценарий 2: Ночной инцидент

3:00 AM. UserService перестал отвечать. Команда узнала об этом в 5:00 AM от пользователей. Логов нет (писали в stdout, контейнер перезапустился). Метрик нет. Непонятно, что произошло. Перезапуск помог, но проблема может повториться.

Проблема: нет observability. Система работает как чёрный ящик.

Сценарий 3: Деплой с потерей данных

Вы деплоите новую версию UserService. Kubernetes убивает старые pods. В этот момент:

Проблема: нет graceful shutdown. Приложение не умеет корректно завершать работу.

Сценарий 4: Перегрузка базы данных

UserService делает запрос SELECT * FROM users WHERE id = ? для каждого обращения. При росте нагрузки PostgreSQL не справляется. Connection pool исчерпан. Время ответа растёт до 5 секунд.

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

Сценарий 5: Хардкод конфигурации

В dev вы используете H2 in-memory базу. В production — PostgreSQL. Настройки захардкожены в коде. Для каждого окружения приходится пересобирать приложение.

Проблема: нет управления конфигурацией через profiles.

Вопросы, на которые нельзя ответить без инструментов

Без правильных инструментов вы не можете ответить на базовые вопросы:

Последствия для бизнеса

Архитектура UserService в production

Это не просто технические проблемы:

Что нужно для production-ready приложения

Чтобы приложение работало надёжно в production, нужно решить пять ключевых задач:

  1. Observability — видеть состояние системы (метрики, health checks)
  2. Structured logging — понимать, что произошло (логи с trace ID)
  3. Graceful shutdown — безопасно обновлять приложение
  4. Configuration management — гибко управлять настройками для разных окружений
  5. Caching — обеспечить производительность под нагрузкой

👉 Ключевая идея: production — это не просто “запустить код на сервере”. Это управление жизненным циклом, мониторинг, отказоустойчивость и производительность.

Но прежде чем решать эти задачи, нужно понять: что такое backend-приложение с точки зрения операционной системы и runtime? Как оно живёт, работает и умирает? Об этом — следующий блок.


2. Системная модель backend-приложения

Когда мы пишем код, мы думаем о классах, методах, объектах. Но в production backend-приложение — это не просто код. Это процесс операционной системы со своими ресурсами, состоянием и жизненным циклом.

Backend-приложение как процесс

Когда вы запускаете java -jar user-service.jar, операционная система создаёт процесс. Этот процесс:

Это не абстракция — это реальность. Ваш код работает внутри JVM, JVM работает как процесс ОС, и этот процесс конкурирует за ресурсы с другими процессами.

Жизненный цикл приложения

Backend-приложение проходит через несколько фаз:

Жизненный цикл приложения

Startup (запуск)

Что происходит при старте Spring Boot приложения:

  1. JVM загружается
  2. Spring инициализирует контекст
  3. Приложение подключается к PostgreSQL
  4. Создаётся connection pool (HikariCP)
  5. HTTP-сервер начинает слушать порт 8080
  6. Load balancer начинает отправлять трафик

На этом этапе приложение ещё не готово принимать запросы. Если load balancer начнёт слать трафик слишком рано, пользователи получат ошибки.

Running (работа)

Приложение обрабатывает запросы:

На этом этапе приложение потребляет ресурсы: CPU для обработки, память для объектов, сетевые соединения для БД и внешних сервисов.

Shutdown (остановка)

Когда приложение получает сигнал SIGTERM (например, при деплое):

  1. Прекращает принимать новые запросы
  2. Завершает активные запросы (grace period)
  3. Закрывает соединения с PostgreSQL
  4. Освобождает ресурсы
  5. Завершает процесс

Если этот процесс не управляется правильно, активные запросы прерываются, транзакции откатываются, пользователи видят ошибки.

Архитектура UserService

Наш сквозной пример — UserService. Вот как он выглядит в production:

Архитектура UserService в production

Каждый инстанс UserService:

Простой код, сложная реальность

Вот простой контроллер:

@RestController
@RequestMapping("/api/users")
public class UserController {
    
    private final UserService userService;
    
    public UserController(UserService userService) {
        this.userService = userService;
    }
    
    @GetMapping("/{id}")
    public UserResponse getUser(@PathVariable Long id) {
        return userService.findById(id);
    }
    
    @PostMapping
    public UserResponse createUser(@RequestBody CreateUserRequest request) {
        return userService.create(request);
    }
}

Кажется простым. Но что происходит при вызове GET /api/users/123:

  1. nginx получает запрос, выбирает один из трёх инстансов
  2. Tomcat (embedded в Spring Boot) выделяет thread из thread pool
  3. Spring маршрутизирует запрос к контроллеру
  4. UserService берёт connection из HikariCP pool
  5. PostgreSQL выполняет SELECT запрос
  6. JDBC преобразует ResultSet в объект User
  7. Jackson сериализует объект в JSON
  8. HTTP response отправляется клиенту
  9. Thread возвращается в pool
  10. Connection возвращается в pool

Что происходит при вызове GET /api/users/123

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

Границы системы

Важно понимать, что находится внутри приложения, а что снаружи:

Внутри процесса UserService:

Снаружи (внешние зависимости):

Каждая внешняя зависимость — это точка отказа. Если PostgreSQL недоступна, приложение не может работать. Если NotificationService медленный, запросы будут висеть.

Состояния приложения

Приложение не просто “работает” или “не работает”. У него есть состояния:

Эти состояния нужно отслеживать и сообщать load balancer’у. Иначе nginx будет слать трафик на инстанс, который ещё не готов или уже завершается.

Жизненный цикл backend-приложения

Ресурсы и их ограничения

Каждый ресурс ограничен:

Ресурс Ограничение Последствия превышения
CPU Число ядер Медленная обработка запросов
Memory Heap size OutOfMemoryError, GC паузы
DB connections Pool size (20) Запросы ждут свободного connection
Threads Thread pool size (200) Запросы отклоняются (503)
Network Bandwidth Медленная передача данных

👉 Ключевая идея: backend-приложение — это не просто код. Это процесс с ресурсами, состоянием и зависимостями. Чтобы управлять им в production, нужно понимать эту модель.

Теперь возникает вопрос: как наблюдать за этим процессом? Как понять, что он здоров? Сколько ресурсов потребляет? Об этом — следующий блок.


3. Observability: метрики и мониторинг

Представьте: UserService работает в production. Пользователи жалуются на медленную работу. Вы заходите на сервер и… что дальше? Как понять, в чём проблема?

Без observability (наблюдаемости) вы слепы. Вы не знаете:

Observability — это способность понять внутреннее состояние системы по её внешним сигналам.

Три столпа observability

Классическая модель observability включает три компонента:

  1. Metrics (метрики) — числовые показатели: RPS, latency, memory usage
  2. Logs (логи) — события и ошибки: “user created”, “database error”
  3. Traces (трейсы) — путь запроса через систему: User → UserService → PostgreSQL → NotificationService

В этом блоке мы сфокусируемся на метриках и health checks. Логи и трейсы разберём в следующем блоке.

Что такое метрики

Метрики — это числовые показатели состояния системы, собираемые во времени.

Примеры метрик для UserService:

Типы метрик

Counter (счётчик)

Монотонно растущее значение. Никогда не уменьшается.

Примеры:

Counter userCreatedCounter = Counter.builder("users.created")
    .description("Total users created")
    .register(registry);

userCreatedCounter.increment(); // +1

Gauge (датчик)

Текущее значение, которое может расти и уменьшаться.

Примеры:

Gauge.builder("users.online", userService, UserService::getOnlineCount)
    .register(registry);

Histogram (гистограмма)

Гистограмма

Распределение значений. Позволяет вычислять перцентили (p50, p95, p99).

Примеры:

Timer userLookupTimer = Timer.builder("users.lookup")
    .description("User lookup duration")
    .register(registry);

userLookupTimer.record(() -> {
    return userRepository.findById(id);
});

Зачем нужны метрики

Метрики позволяют:

  1. Мониторить производительность: видеть latency, throughput
  2. Обнаруживать проблемы: рост error rate, утечки памяти
  3. Планировать capacity: понимать, когда нужно масштабироваться
  4. Настраивать алерты: автоматически уведомлять команду о проблемах

👉 Ключевая идея: метрики — это не для отладки конкретного запроса. Это для понимания поведения системы в целом.

Health checks

Health checks

Health check — это endpoint, который показывает, здорово ли приложение.

Два типа health checks:

Liveness probe

Вопрос: приложение живо?

Если нет → нужно перезапустить процесс.

Проверяет:

Readiness probe

Вопрос: приложение готово принимать трафик?

Если нет → не слать запросы, но не перезапускать.

Проверяет:

Spring Boot Actuator

Spring Boot предоставляет встроенный модуль для production-ready features — Actuator.

Подключение

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

Конфигурация

# application.yml
management:
  endpoints:
    web:
      exposure:
        include: health, prometheus, info
  endpoint:
    health:
      show-details: when-authorized
      probes:
        enabled: true
  health:
    db:
      enabled: true

Endpoints

После подключения Actuator доступны endpoints:

Пример ответа /actuator/health:

{
  "status": "UP",
  "components": {
    "db": {
      "status": "UP",
      "details": {
        "database": "PostgreSQL",
        "validationQuery": "isValid()"
      }
    },
    "diskSpace": {
      "status": "UP",
      "details": {
        "total": 500000000000,
        "free": 250000000000
      }
    }
  }
}

Custom метрики с Micrometer

Micrometer — это фасад для метрик (как SLF4J для логов). Он позволяет писать метрики один раз, а экспортировать в разные системы: Prometheus, Graphite, Datadog.

Пример: метрики для UserService

@Service
public class UserService {
    
    private final UserRepository userRepository;
    private final Counter userCreatedCounter;
    private final Timer userLookupTimer;
    
    public UserService(UserRepository userRepository, MeterRegistry registry) {
        this.userRepository = userRepository;
        
        this.userCreatedCounter = Counter.builder("users.created")
            .description("Total users created")
            .register(registry);
        
        this.userLookupTimer = Timer.builder("users.lookup")
            .description("User lookup duration")
            .register(registry);
    }
    
    public UserResponse findById(Long id) {
        return userLookupTimer.record(() -> {
            User user = userRepository.findById(id)
                .orElseThrow(() -> new UserNotFoundException(id));
            return toResponse(user);
        });
    }
    
    public UserResponse create(CreateUserRequest request) {
        User user = new User();
        user.setName(request.getName());
        user.setEmail(request.getEmail());
        
        User saved = userRepository.save(user);
        userCreatedCounter.increment();
        
        return toResponse(saved);
    }
}

Prometheus + Grafana

Метрики нужно не только собирать, но и визуализировать.

Prometheus — система сбора и хранения метрик.
Grafana — система визуализации метрик.

Как это работает

  1. UserService экспортирует метрики на /actuator/prometheus
  2. Prometheus каждые 15 секунд опрашивает (scraping) этот endpoint
  3. Prometheus сохраняет метрики в time-series базу
  4. Grafana читает данные из Prometheus и строит графики

Пайплайн метрик: App → Prometheus → Grafana

Docker Compose для observability

# docker-compose.observability.yml
version: '3.8'

services:
  prometheus:
    image: prom/prometheus:v2.48.0
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
  
  grafana:
    image: grafana/grafana:10.2.0
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    depends_on:
      - prometheus

Конфигурация Prometheus

# prometheus.yml
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'user-service'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: ['host.docker.internal:8080']

Что показывает Prometheus endpoint

# HELP users_created_total Total users created
# TYPE users_created_total counter
users_created_total 42.0

# HELP users_lookup_seconds User lookup duration
# TYPE users_lookup_seconds histogram
users_lookup_seconds_bucket{le="0.001"} 10
users_lookup_seconds_bucket{le="0.01"} 95
users_lookup_seconds_bucket{le="0.1"} 100
users_lookup_seconds_sum 2.5
users_lookup_seconds_count 100

# HELP jvm_memory_used_bytes Memory used
# TYPE jvm_memory_used_bytes gauge
jvm_memory_used_bytes{area="heap"} 268435456

Ключевые метрики для UserService

Какие метрики важно отслеживать:

HTTP метрики

JVM метрики

Database метрики

Anti-patterns

Логировать метрики вместо экспорта

Плохо:

log.info("Request processed in {}ms", duration);

Хорошо:

timer.record(duration, TimeUnit.MILLISECONDS);

Проверять health вручную в production

Плохо: curl http://server/actuator/health каждый раз

Хорошо: автоматический мониторинг через Prometheus + алерты

Не отслеживать перцентили

Среднее время (average) скрывает проблемы. Важны p95, p99 — время для самых медленных запросов.

Запуск всего стека

# Запустить UserService
./gradlew bootRun

# Запустить Prometheus + Grafana
docker-compose -f docker-compose.observability.yml up -d

# Открыть Grafana
open http://localhost:3000
# Login: admin / admin

# Добавить Prometheus data source
# URL: http://prometheus:9090

# Создать dashboard с графиками:
# - rate(http_server_requests_total[5m]) — RPS
# - histogram_quantile(0.95, http_server_requests_duration_seconds) — p95 latency
# - jvm_memory_used_bytes — memory usage

👉 Ключевая идея: метрики показывают ЧТО происходит с системой. Но чтобы понять ПОЧЕМУ произошла конкретная ошибка, нужны логи.


4. Logging и Trace ID

Метрики показали, что error rate вырос с 0.1% до 5%. Но какие запросы падают? Почему? На каком инстансе?

Для ответа на эти вопросы нужны логи.

Проблема: найти иголку в стоге сена

Health checks

UserService работает в 3 инстансах. Каждый обрабатывает сотни запросов в секунду. Пользователь жалуется: “Мой запрос упал”.

Вопросы:

Без структурированных логов и trace ID ответить невозможно.

Unstructured vs Structured логи

❌ Плохо: неструктурированные логи

2024-01-15 10:23:45 INFO Processing user request for john@example.com
2024-01-15 10:23:45 ERROR Something went wrong
2024-01-15 10:23:46 INFO User created successfully

Проблемы:

✅ Хорошо: структурированные логи (JSON)

{
  "timestamp": "2024-01-15T10:23:45.123Z",
  "level": "ERROR",
  "logger": "com.example.userservice.UserService",
  "message": "Failed to create user",
  "traceId": "abc123def456",
  "userId": "john@example.com",
  "error": "ConnectionRefusedException",
  "errorMessage": "Connection to NotificationService refused",
  "instance": "user-service-2"
}

Преимущества:

Trace ID: нить Ариадны в распределённой системе

Trace ID — это уникальный идентификатор, который следует за запросом через все сервисы и инстансы.

Проблема без trace ID

Запрос проходит путь:

  1. Client → nginx
  2. nginx → UserService (instance-2)
  3. UserService → PostgreSQL
  4. UserService → NotificationService
  5. NotificationService → Email provider

Где-то произошла ошибка. Но где? На каком этапе? Как связать логи из разных сервисов?

Решение: trace ID

Каждому запросу присваивается уникальный ID (например, abc123def456). Этот ID:

Client request → traceId=abc123

UserService logs:
{"traceId": "abc123", "message": "Processing user request"}
{"traceId": "abc123", "message": "Calling NotificationService"}

NotificationService logs:
{"traceId": "abc123", "message": "Received notification request"}
{"traceId": "abc123", "level": "ERROR", "message": "Email provider timeout"}

Теперь можно найти все логи: traceId=abc123 → видим весь путь запроса.

MDC (Mapped Diagnostic Context)

MDC — это механизм в Logback/SLF4J для добавления контекстной информации в логи.

MDC работает как thread-local Map: вы кладёте туда данные (например, traceId), и они автоматически добавляются во все логи в рамках этого потока.

Реализация trace ID через MDC

@Component
public class TraceIdFilter implements Filter {
    
    private static final String TRACE_ID_HEADER = "X-Trace-Id";
    private static final String TRACE_ID_MDC_KEY = "traceId";
    
    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {
        
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        
        // Получаем trace ID из header или генерируем новый
        String traceId = httpRequest.getHeader(TRACE_ID_HEADER);
        if (traceId == null || traceId.isEmpty()) {
            traceId = UUID.randomUUID().toString().substring(0, 8);
        }
        
        // Кладём в MDC
        MDC.put(TRACE_ID_MDC_KEY, traceId);
        
        try {
            // Обрабатываем запрос
            chain.doFilter(request, response);
        } finally {
            // Очищаем MDC (важно!)
            MDC.clear();
        }
    }
}

Теперь все логи в рамках этого запроса будут содержать traceId.

Logback конфигурация для JSON логов

<!-- logback-spring.xml -->
<configuration>
    <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <!-- Включаем trace ID из MDC -->
            <includeMdcKeyName>traceId</includeMdcKeyName>
            
            <!-- Добавляем имя инстанса -->
            <customFields>{"instance":"${HOSTNAME:-unknown}"}</customFields>
        </encoder>
    </appender>
    
    <root level="INFO">
        <appender-ref ref="JSON"/>
    </root>
</configuration>

Зависимость:

<dependency>
    <groupId>net.logstash.logback</groupId>
    <artifactId>logstash-logback-encoder</artifactId>
    <version>7.4</version>
</dependency>

Propagation: передача trace ID в другие сервисы

Когда UserService вызывает NotificationService, нужно передать trace ID дальше.

@Service
public class NotificationClient {
    
    private final RestTemplate restTemplate;
    
    public NotificationClient(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }
    
    public void sendNotification(String userId, String message) {
        // Получаем trace ID из MDC
        String traceId = MDC.get("traceId");
        
        // Создаём headers с trace ID
        HttpHeaders headers = new HttpHeaders();
        headers.set("X-Trace-Id", traceId);
        headers.setContentType(MediaType.APPLICATION_JSON);
        
        // Создаём request
        NotificationRequest request = new NotificationRequest(userId, message);
        HttpEntity<NotificationRequest> entity = new HttpEntity<>(request, headers);
        
        // Отправляем запрос
        restTemplate.postForEntity(
            "http://notification-service/api/notify",
            entity,
            Void.class
        );
    }
}

Теперь NotificationService получит тот же trace ID и сможет добавить его в свои логи.

ELK Stack для централизованных логов

ELK = Elasticsearch + Logstash + Kibana

Docker Compose для ELK

# docker-compose.logging.yml
version: '3.8'

services:
  elasticsearch:
    image: elasticsearch:8.11.0
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    ports:
      - "9200:9200"
    volumes:
      - elasticsearch-data:/usr/share/elasticsearch/data
  
  kibana:
    image: kibana:8.11.0
    ports:
      - "5601:5601"
    environment:
      - ELASTICSEARCH_HOSTS=http://elasticsearch:9200
    depends_on:
      - elasticsearch

volumes:
  elasticsearch-data:

Как логи попадают в Elasticsearch

Есть несколько способов:

  1. Filebeat — читает файлы логов и отправляет в Elasticsearch
  2. Logstash — принимает логи по сети и отправляет в Elasticsearch
  3. Прямая отправка — приложение пишет напрямую в Elasticsearch (не рекомендуется)

Пайплайн логирования: App → ELK Stack

Для простоты можно использовать Filebeat:

# filebeat.yml
filebeat.inputs:
  - type: container
    paths:
      - '/var/lib/docker/containers/*/*.log'

output.elasticsearch:
  hosts: ["elasticsearch:9200"]

Поиск в Kibana

После того как логи попали в Elasticsearch, их можно искать в Kibana:

# Найти все ошибки с конкретным trace ID
level:ERROR AND traceId:"abc123def456"

# Найти все логи за последний час с ошибками
level:ERROR AND @timestamp:[now-1h TO now]

# Найти логи конкретного инстанса
instance:"user-service-2"

# Найти логи с конкретным пользователем
userId:"john@example.com"

Log levels: когда что использовать

Level Когда использовать Пример
ERROR Что-то сломалось, требует внимания “Failed to connect to database”
WARN Что-то подозрительное, но обработано “Retry attempt 3/5”
INFO Значимые бизнес-события “User created”, “Order placed”
DEBUG Детальная техническая информация “SQL query: SELECT …”
TRACE Очень детальная информация “Entering method X”

В production обычно используют INFO и выше. DEBUG и TRACE — только для отладки.

Anti-patterns

Логировать чувствительные данные

Плохо:

log.info("User login: {} with password: {}", username, password);

Никогда не логируйте: пароли, токены, номера карт, персональные данные.

Логировать в tight loops

Плохо:

for (int i = 0; i < 1000000; i++) {
    log.debug("Processing item {}", i);
}

Это убьёт производительность и заполнит диск.

Использовать System.out.println

Плохо:

System.out.println("User created");

Хорошо:

log.info("User created");

System.out.println не даёт:

Пример: полный flow с trace ID

  1. Client отправляет запрос без trace ID
  2. nginx генерирует trace ID и добавляет header X-Trace-Id: abc123
  3. UserService (instance-2):
    • получает trace ID из header
    • кладёт в MDC
    • логирует: {"traceId": "abc123", "message": "Creating user"}
    • вызывает NotificationService с header X-Trace-Id: abc123
  4. NotificationService:
    • получает trace ID из header
    • кладёт в MDC
    • логирует: {"traceId": "abc123", "message": "Sending email"}
    • ошибка: {"traceId": "abc123", "level": "ERROR", "message": "SMTP timeout"}
  5. Kibana: ищем traceId:abc123 → видим весь путь запроса и ошибку

Распространение Trace ID через сервисы

👉 Ключевая идея: Trace ID — это нить Ариадны в лабиринте распределённой системы. Без него невозможно отследить путь запроса через множество сервисов.

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


5. Graceful Shutdown и жизненный цикл

Мы научились наблюдать за приложением через метрики и логи. Но что происходит, когда мы обновляем приложение? Представьте: вы исправили баг в UserService и хотите задеплоить новую версию. Что происходит со старыми инстансами?

Проблема: потеря запросов при деплое

Сценарий:

  1. UserService обрабатывает 15 активных запросов
  2. Вы деплоите новую версию
  3. Docker отправляет SIGTERM старому контейнеру
  4. Что происходит с этими 15 запросами?

Наивный подход (без graceful shutdown):

Это не просто неудобство — это потеря данных и плохой user experience.

Что такое graceful shutdown

Graceful shutdown — это безопасная остановка приложения с завершением активных операций.

Правильная последовательность:

  1. Прекратить принимать новые запросы (HTTP server stops accepting connections)
  2. Завершить активные запросы (дать им время на выполнение)
  3. Закрыть ресурсы (DB connections, HTTP clients, file handles)
  4. Завершить процесс (exit code 0)

Это как корректное выключение компьютера vs выдёргивание шнура из розетки.

SIGTERM vs SIGKILL

Операционная система может отправить процессу разные сигналы:

SIGTERM (signal 15)

Мягкий сигнал остановки. Процесс может его обработать:

# Отправить SIGTERM
docker-compose stop user-service
# или
kill -15 <PID>

SIGKILL (signal 9)

Жёсткий сигнал остановки. Процесс убивается мгновенно:

# Отправить SIGKILL (не делайте так в production!)
kill -9 <PID>

👉 Правило: всегда используйте SIGTERM. SIGKILL — только в крайнем случае.

Spring Boot graceful shutdown

Spring Boot 2.3+ поддерживает graceful shutdown из коробки.

Конфигурация

# application.yml
server:
  shutdown: graceful  # Включаем graceful shutdown

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s  # Grace period — 30 секунд

Что происходит внутри

Когда Spring Boot получает SIGTERM:

  1. Останавливает прием новых запросов
    • Embedded Tomcat перестаёт принимать новые connections
    • Новые запросы получат connection refused
  2. Ждёт завершения активных запросов
    • Максимум 30 секунд (timeout-per-shutdown-phase)
    • Если запросы завершились раньше — отлично
    • Если не завершились за 30 секунд → принудительная остановка
  3. Вызывает @PreDestroy методы
    • В обратном порядке создания beans
    • Позволяет сделать cleanup
  4. Закрывает ApplicationContext
    • Уничтожает все beans
    • Закрывает connection pools (HikariCP)
    • Закрывает HTTP clients
  5. JVM завершается
    • Exit code 0 (успешное завершение)

Последовательность Graceful Shutdown

Custom cleanup с @PreDestroy

Вы можете добавить свою логику cleanup:

@Service
public class NotificationClient implements DisposableBean {
    
    private static final Logger log = LoggerFactory.getLogger(NotificationClient.class);
    
    private final HttpClient httpClient;
    
    public NotificationClient() {
        this.httpClient = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .build();
    }
    
    @Override
    public void destroy() {
        log.info("Closing HTTP client connections...");
        // HttpClient не имеет явного close(), но мы можем логировать
        log.info("HTTP client cleanup completed");
    }
}

Более сложный пример — cleanup для ExecutorService:

@Service
public class AsyncTaskService {
    
    private static final Logger log = LoggerFactory.getLogger(AsyncTaskService.class);
    
    private final ExecutorService executorService;
    
    public AsyncTaskService() {
        this.executorService = Executors.newFixedThreadPool(10);
    }
    
    @PreDestroy
    public void shutdown() {
        log.info("Shutting down executor service...");
        
        // Останавливаем прием новых задач
        executorService.shutdown();
        
        try {
            // Ждём завершения активных задач (максимум 30 секунд)
            if (!executorService.awaitTermination(30, TimeUnit.SECONDS)) {
                log.warn("Executor service did not terminate in time, forcing shutdown");
                executorService.shutdownNow();
            } else {
                log.info("Executor service shut down gracefully");
            }
        } catch (InterruptedException e) {
            log.error("Interrupted while waiting for executor shutdown", e);
            executorService.shutdownNow();
            Thread.currentThread().interrupt();
        }
    }
}

Проблема load balancer

Даже с graceful shutdown есть проблема: nginx может продолжать слать запросы на умирающий инстанс.

Сценарий:

  1. UserService получает SIGTERM
  2. Начинает graceful shutdown
  3. nginx ещё не знает об этом
  4. nginx отправляет новый запрос → connection refused → 502 для пользователя

Решение: health check должен стать “not ready” ДО начала shutdown.

@Component
public class GracefulShutdownHealthIndicator implements HealthIndicator {
    
    private volatile boolean shuttingDown = false;
    
    @EventListener(ContextClosedEvent.class)
    public void onShutdown() {
        log.info("Application shutdown initiated, marking as not ready");
        this.shuttingDown = true;
        
        // Даём nginx время заметить что мы not ready (5 секунд)
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
    
    @Override
    public Health health() {
        if (shuttingDown) {
            return Health.down()
                .withDetail("reason", "Shutting down")
                .build();
        }
        return Health.up().build();
    }
}

Deployment sequence с docker-compose

Правильная последовательность для zero-downtime deployment:

#!/bin/bash
# rolling-update.sh

# Обновляем instance-1
echo "Updating user-service-1..."
docker-compose stop user-service-1  # SIGTERM, graceful shutdown
docker-compose rm -f user-service-1
docker-compose up -d user-service-1

# Ждём пока health check пройдёт
echo "Waiting for user-service-1 to be healthy..."
until curl -f http://localhost:8081/actuator/health/readiness; do
    sleep 2
done

# Обновляем instance-2
echo "Updating user-service-2..."
docker-compose stop user-service-2
docker-compose rm -f user-service-2
docker-compose up -d user-service-2

until curl -f http://localhost:8082/actuator/health/readiness; do
    sleep 2
done

# Обновляем instance-3
echo "Updating user-service-3..."
docker-compose stop user-service-3
docker-compose rm -f user-service-3
docker-compose up -d user-service-3

until curl -f http://localhost:8083/actuator/health/readiness; do
    sleep 2
done

echo "Rolling update completed successfully!"

Docker Compose конфигурация

# docker-compose.yml
version: '3.8'

services:
  user-service-1:
    build: .
    ports:
      - "8081:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=production
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 40s
    stop_grace_period: 60s  # Даём 60 секунд на graceful shutdown
    depends_on:
      - postgres

Shutdown hooks и порядок

Важно: порядок cleanup имеет значение.

Плохо: закрываем DB pool до завершения запросов

@PreDestroy
public void cleanup() {
    dataSource.close();  // Закрыли БД
    // Активные запросы ещё работают → ошибки!
}

Хорошо: Spring сам управляет порядком

Anti-patterns

kill -9 (SIGKILL) — нет cleanup вообще

# Никогда не делайте так в production!
kill -9 <PID>

Нет timeout на shutdown — приложение может висеть вечно

# Плохо: нет timeout
spring:
  lifecycle:
    timeout-per-shutdown-phase: 0  # Ждём бесконечно

Закрытие DB pool до завершения запросов

@PreDestroy
public void cleanup() {
    hikariDataSource.close();  // Слишком рано!
}

Игнорирование активных background tasks

@Scheduled(fixedDelay = 60000)
public void longRunningTask() {
    // Задача может выполняться 5 минут
    // При shutdown она прервётся на середине
}

Мониторинг shutdown

Логируйте события shutdown для debugging:

@Component
public class ShutdownMonitor {
    
    private static final Logger log = LoggerFactory.getLogger(ShutdownMonitor.class);
    
    @EventListener(ContextClosedEvent.class)
    public void onShutdown() {
        log.info("=== Application shutdown initiated ===");
        log.info("Active requests will be completed");
        log.info("Grace period: 30 seconds");
    }
}

В логах вы увидите:

{
  "timestamp": "2024-01-15T10:30:00.000Z",
  "level": "INFO",
  "message": "=== Application shutdown initiated ===",
  "instance": "user-service-2"
}

👉 Ключевая идея: Graceful shutdown — это не просто “подождать”. Это протокол взаимодействия между приложением, балансировщиком и оркестратором. Без него каждый деплой — это потенциальная потеря данных и ошибки для пользователей.

Мы научились безопасно останавливать приложение. Но у нас есть ещё одна проблема: конфигурация. Как сделать так, чтобы одно и то же приложение работало по-разному в dev, staging и production?


6. Profiles и конфигурация

UserService подключается к localhost:5432 в development, но к db-cluster.prod.internal:5432 в production. Log level — DEBUG локально, но INFO в production. Cache TTL — 10 секунд в dev, но 5 минут в prod.

Как управлять этими различиями?

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

Типичное приложение работает в нескольких окружениях:

Profiles

Development (локально на ноутбуке):

Staging (тестовое окружение):

Production (боевое окружение):

Как управлять этими различиями без пересборки приложения?

Наивные подходы (плохо)

Хардкод в коде

public class DatabaseConfig {
    public DataSource dataSource() {
        String url = "jdbc:postgresql://localhost:5432/users";  // Захардкожено!
        // Для production нужно пересобирать
    }
}

if/else в коде

public DataSource dataSource() {
    if (System.getenv("ENV").equals("production")) {
        return createProductionDataSource();
    } else {
        return createDevDataSource();
    }
    // Месиво, сложно поддерживать
}

Несколько копий application.yml

application-dev.yml
application-staging.yml
application-production.yml

Но все в одной папке, легко перепутать, забыть обновить.

12-Factor App принцип

The Twelve-Factor App — методология для построения SaaS приложений.

Принцип III: Config

Конфигурация должна храниться в окружении (environment), а не в коде.

Что это значит:

Spring Profiles — механизм

Spring Profile — это именованный набор конфигурации.

Как активировать:

# В application.yml
spring:
  profiles:
    active: production

Или через environment variable:

export SPRING_PROFILES_ACTIVE=production
java -jar user-service.jar

Или через command line argument:

java -jar user-service.jar --spring.profiles.active=production

Можно активировать несколько профилей:

export SPRING_PROFILES_ACTIVE=production,monitoring

Profile-specific конфигурационные файлы

Spring автоматически загружает файлы по паттерну application-{profile}.yml.

application.yml (defaults для всех окружений)

spring:
  application:
    name: user-service

server:
  port: 8080

app:
  cache:
    ttl: 60s  # Default TTL

application-dev.yml

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/users
    username: dev_user
    password: dev_pass

logging:
  level:
    com.example: DEBUG
    org.springframework.web: DEBUG

app:
  cache:
    ttl: 10s  # Короткий TTL для dev
  notification-service:
    url: http://localhost:8081
    enabled: false  # Mock в dev

application-prod.yml

spring:
  datasource:
    url: jdbc:postgresql://db-cluster.prod.internal:5432/users
    username: ${DB_USERNAME}  # Из environment variable
    password: ${DB_PASSWORD}  # Из environment variable
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5

logging:
  level:
    com.example: INFO
    org.springframework.web: WARN

app:
  cache:
    ttl: 300s  # 5 минут для production
  notification-service:
    url: http://notification-service.internal:8080
    enabled: true

Environment variables — production way

Правило: пароли и секреты НИКОГДА не должны быть в config файлах.

Используйте ${ENV_VAR} синтаксис:

spring:
  datasource:
    url: ${DATABASE_URL}
    username: ${DATABASE_USERNAME}
    password: ${DATABASE_PASSWORD}

Docker Compose пример:

# docker-compose.yml
version: '3.8'

services:
  user-service:
    image: user-service:latest
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - DATABASE_URL=jdbc:postgresql://postgres:5432/users
      - DATABASE_USERNAME=admin
      - DATABASE_PASSWORD=${DB_PASSWORD_FROM_VAULT}  # Из .env файла
    depends_on:
      - postgres

  postgres:
    image: postgres:15
    environment:
      POSTGRES_DB: users
      POSTGRES_USER: admin
      POSTGRES_PASSWORD: ${DB_PASSWORD_FROM_VAULT}

.env файл (НЕ коммитим в git!):

DB_PASSWORD_FROM_VAULT=super_secret_password

@ConfigurationProperties — type-safe конфигурация

Вместо @Value используйте @ConfigurationProperties для группы настроек:

@ConfigurationProperties(prefix = "app.cache")
public record CacheProperties(
    Duration ttl,
    int maxSize,
    boolean enabled
) {}
@Configuration
@EnableConfigurationProperties(CacheProperties.class)
public class CacheConfig {
    
    @Bean
    public CacheManager cacheManager(CacheProperties props) {
        if (!props.enabled()) {
            return new NoOpCacheManager();  // Кэш отключен
        }
        
        // Используем props.ttl() и props.maxSize()
        // ...
    }
}
# application.yml
app:
  cache:
    ttl: 3600s
    max-size: 10000
    enabled: true

Преимущества:

Configuration hierarchy (приоритет)

От низшего к высшему приоритету:

  1. application.yml (defaults)
  2. application-{profile}.yml (profile-specific)
  3. Environment variables (OS environment)
  4. Command-line arguments (–server.port=9090)

Пример:

# application.yml
server:
  port: 8080

# application-prod.yml
server:
  port: 8081

# Environment variable
SERVER_PORT=8082

# Command line
--server.port=8083

Результат: 8083 (command line wins).

Conditional beans с @Profile

Разные beans для разных окружений:

@Configuration
public class NotificationConfig {
    
    @Bean
    @Profile("prod")
    public NotificationClient realNotificationClient(
            @Value("${app.notification-service.url}") String url) {
        return new HttpNotificationClient(url);
    }
    
    @Bean
    @Profile("dev")
    public NotificationClient fakeNotificationClient() {
        return new LoggingNotificationClient();  // Просто логирует, не шлёт
    }
}
public class LoggingNotificationClient implements NotificationClient {
    
    private static final Logger log = LoggerFactory.getLogger(LoggingNotificationClient.class);
    
    @Override
    public void sendNotification(String email, String message) {
        log.info("MOCK: Would send notification to {} with message: {}", email, message);
        // Не шлём реальное уведомление в dev
    }
}

Validation конфигурации

Используйте @Validated для проверки на старте:

@ConfigurationProperties(prefix = "app")
@Validated
public record AppProperties(
    @NotBlank String name,
    @Min(1) @Max(100) int maxUsers,
    DatabaseProperties database
) {
    public record DatabaseProperties(
        @NotBlank String url,
        @Min(1) @Max(100) int poolSize
    ) {}
}

Если конфигурация невалидна → приложение не запустится (fail-fast).

Практический пример

Запуск с разными профилями:

# Development
export SPRING_PROFILES_ACTIVE=dev
./gradlew bootRun

# Production
export SPRING_PROFILES_ACTIVE=prod
export DATABASE_URL=jdbc:postgresql://prod-db:5432/users
export DATABASE_USERNAME=admin
export DATABASE_PASSWORD=secret
java -jar build/libs/user-service.jar

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

curl http://localhost:8080/actuator/env | jq '.activeProfiles'
# ["prod"]

Anti-patterns

Коммитить пароли в git

# application-prod.yml
spring:
  datasource:
    password: super_secret_password  # НИКОГДА!

Использовать @Profile для бизнес-логики

@Service
@Profile("prod")
public class PremiumFeatureService {
    // Плохо: бизнес-логика не должна зависеть от профиля
}

Profiles — для инфраструктуры, не для бизнес-логики.

Полностью разные конфигурации

# application-dev.yml — 200 строк
# application-prod.yml — 300 строк, совсем другие

Должно быть 90% одинаково, 10% отличий.

Не использовать environment variables для secrets

spring:
  datasource:
    password: ${DB_PASSWORD:default_password}  # default — плохая идея

👉 Ключевая идея: Хороший конфиг — это когда 90% одинаково везде, и только 10% отличается между окружениями. Код не должен знать про окружения — только конфигурация.

Мы настроили приложение для разных окружений. Последний аспект, который мы рассмотрим — производительность. Как не ходить в базу данных за одними и теми же данными каждый раз?


7. Кэширование

UserService endpoint GET /users/{id} вызывается 1000 раз в секунду. Каждый вызов делает запрос в PostgreSQL. Но данные пользователя меняются редко — может быть раз в час. Мы делаем 999 избыточных запросов в БД.

Как оптимизировать?

Проблема: производительность при росте нагрузки

Сценарий:

Рост нагрузки:

Проблема: большинство запросов возвращают одни и те же данные. Зачем каждый раз ходить в БД?

Что такое кэш?

Кэш (cache) — это временное хранилище для часто используемых данных, расположенное ближе к потребителю.

Mental model:

Как работает:

  1. Проверяем кэш
  2. Если данные есть (cache hit) → возвращаем из кэша (быстро)
  3. Если данных нет (cache miss) → запрос в БД → сохраняем в кэш → возвращаем (медленно, но следующий раз будет быстро)

Profiles

Cache hit vs Cache miss

Cache hit — данные найдены в кэше:

User → UserService: GET /users/123
UserService → Cache: get("user:123")
Cache → UserService: User{id=123, name="John"}  ✅
UserService → User: 200 OK (1ms)

Cache miss — данных нет в кэше:

User → UserService: GET /users/456
UserService → Cache: get("user:456")
Cache → UserService: null  ❌
UserService → PostgreSQL: SELECT * FROM users WHERE id=456
PostgreSQL → UserService: User{id=456, name="Jane"}
UserService → Cache: set("user:456", User, TTL=1h)
UserService → User: 200 OK (52ms)

Hit rate — процент попаданий в кэш:

hit_rate = cache_hits / (cache_hits + cache_misses)

Цель: hit rate > 80%.

Простое кэширование: HashMap

Начнём с самого простого подхода:

@Service
public class UserService {
    
    private final Map<Long, UserResponse> cache = new ConcurrentHashMap<>();
    private final UserRepository userRepository;
    
    public UserResponse findById(Long id) {
        // Проверяем кэш
        UserResponse cached = cache.get(id);
        if (cached != null) {
            log.debug("Cache HIT for user {}", id);
            return cached;  // Cache hit
        }
        
        // Cache miss — запрос в БД
        log.debug("Cache MISS for user {}", id);
        User user = userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));
        
        UserResponse response = toResponse(user);
        
        // Сохраняем в кэш
        cache.put(id, response);
        
        return response;
    }
}

Работает! Но есть проблемы:

Нет TTL — данные устаревают, но остаются в кэше вечно
Нет size limit — кэш растёт бесконечно → OutOfMemoryError
Нет eviction policy — какую запись удалить когда кэш полон?
Нет статистики — как узнать hit rate?

Caffeine — production-ready in-memory cache

Caffeine — это библиотека для in-memory кэширования с поддержкой TTL, eviction, статистики.

Подключение

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

Конфигурация

@Configuration
@EnableCaching
public class CacheConfig {
    
    @Bean
    public CacheManager cacheManager(CacheProperties props) {
        CaffeineCacheManager manager = new CaffeineCacheManager("users");
        manager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(10_000)  // Максимум 10k записей
            .expireAfterWrite(props.ttl())  // TTL из конфигурации
            .recordStats());  // Включаем статистику
        return manager;
    }
}
# application.yml
app:
  cache:
    ttl: 3600s  # 1 час

Использование с аннотациями

@Service
public class UserService {
    
    private final UserRepository userRepository;
    
    @Cacheable(value = "users", key = "#id")
    public UserResponse findById(Long id) {
        // Этот метод вызывается только при cache miss
        log.info("Cache MISS: fetching user {} from database", id);
        
        User user = userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));
        return toResponse(user);
    }
    
    @CacheEvict(value = "users", key = "#id")
    public UserResponse updateUser(Long id, UpdateUserRequest request) {
        // Запись удаляется из кэша при обновлении
        User user = userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));
        
        user.setName(request.getName());
        user.setEmail(request.getEmail());
        
        User updated = userRepository.save(user);
        return toResponse(updated);
        // Следующий findById(id) будет cache miss
    }
    
    @CacheEvict(value = "users", allEntries = true)
    public void clearCache() {
        // Для admin/emergency use
        log.warn("Clearing all user cache");
    }
}

Spring Cache аннотации

@Cacheable — проверить кэш перед выполнением метода:

@CacheEvict — удалить запись из кэша:

@CachePut — обновить кэш (метод всегда вызывается):

@CachePut(value = "users", key = "#result.id")
public UserResponse createUser(CreateUserRequest request) {
    // Метод всегда выполняется
    // Результат сохраняется в кэш
}

Eviction strategies (стратегии вытеснения)

Когда кэш заполнен, нужно удалить старые записи. Какие?

LRU (Least Recently Used) — удаляем то, к чему давно не обращались:

LFU (Least Frequently Used) — удаляем то, к чему обращаются реже всего:

TTL (Time To Live) — удаляем по времени:

Size-based — удаляем когда достигнут лимит размера:

Caffeine использует W-TinyLFU — комбинацию LRU и LFU с оптимизациями.

Проблема: несколько инстансов

У нас 3 инстанса UserService за load balancer. Каждый имеет свой Caffeine кэш.

Проблема:

  1. Request 1 → Instance 1 → cache miss → БД → сохранили в кэш Instance 1
  2. Request 2 (тот же user) → Instance 2 → cache miss (!) → БД → сохранили в кэш Instance 2
  3. Дублирование работы, низкий hit rate

Решение: нужен distributed cache, разделяемый между всеми инстансами.

Redis — distributed cache

Redis (REmote DIctionary Server) — это in-memory key-value хранилище, которое работает как отдельный сервис.

Что такое Redis?

Зачем нужен?

Как запустить?

# Через Docker (самый простой способ)
docker run -d -p 6379:6379 redis:7-alpine

# Проверка
docker exec -it <container-id> redis-cli ping
# Ответ: PONG

Базовые команды:

# Сохранить
SET mykey "Hello"

# Получить
GET mykey
# Ответ: "Hello"

# Удалить
DEL mykey

# С TTL (автоматическое удаление через 60 секунд)
SETEX mykey 60 "Hello"

Spring Boot + Redis

Подключение

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Конфигурация

# application.yml
spring:
  cache:
    type: redis
    redis:
      time-to-live: 3600s  # 1 час
  
  data:
    redis:
      host: localhost
      port: 6379

Код остаётся тот же!

@Service
public class UserService {
    
    // Код не меняется! Spring Cache Abstraction
    @Cacheable(value = "users", key = "#id")
    public UserResponse findById(Long id) {
        // Теперь кэш в Redis, а не в памяти приложения
        User user = userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));
        return toResponse(user);
    }
}

Caffeine vs Redis

Аспект Caffeine (in-memory) Redis (distributed)
Deployment В процессе приложения Отдельный сервис
Sharing Каждый инстанс свой кэш Общий для всех инстансов
Latency ~0.1ms ~1ms
Setup Простой Требует Redis сервер
Use case Single instance Multiple instances
Production ❌ Не подходит для scale ✅ Production-ready

Иерархия кэширования: L1 → L2 → L3

Docker Compose с Redis

# docker-compose.yml
version: '3.8'

services:
  user-service:
    build: .
    ports:
      - "8080:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - SPRING_DATA_REDIS_HOST=redis
    depends_on:
      - postgres
      - redis

  postgres:
    image: postgres:15
    environment:
      POSTGRES_DB: users
      POSTGRES_USER: admin
      POSTGRES_PASSWORD: password

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru

Cache invalidation — “the hardest problem”

“There are only two hard things in Computer Science: cache invalidation and naming things.” — Phil Karlton

Проблема: когда удалять данные из кэша?

Стратегии:

  1. TTL-based (по времени):
    • Автоматическое удаление через N секунд
    • Просто, но данные могут устареть до истечения TTL
    • ✅ Используем для большинства случаев
  2. Event-based (по событиям):
    • При UPDATE user → @CachePut или @CacheEvict
    • При DELETE user → @CacheEvict
    • Всегда свежие данные, но сложнее
  3. Hybrid (комбинация):
    • TTL для автоматической очистки
    • Event-based для критичных изменений
    • 👉 Лучший подход

Что кэшировать в UserService?

Кэшировать:

Не кэшировать:

Мониторинг кэша

Caffeine автоматически экспортирует метрики через Micrometer:

# Метрики в Prometheus
cache_gets_total{result="hit"} 950
cache_gets_total{result="miss"} 50
cache_evictions_total 10
cache_size 1000

# Hit rate
hit_rate = 950 / (950 + 50) = 95%  ✅

Grafana dashboard:

# Hit rate
rate(cache_gets_total{result="hit"}[5m]) / 
rate(cache_gets_total[5m])

# Cache size
cache_size

# Eviction rate
rate(cache_evictions_total[5m])

Anti-patterns

Кэшировать всё

@Cacheable("everything")  // Плохо!
public Object doAnything() { ... }

Memory overhead, сложность, stale data.

Нет TTL

Caffeine.newBuilder()
    .maximumSize(10_000)
    // Нет expireAfterWrite → данные устаревают

Не мониторить hit rate

In-memory кэш для multiple instances

# Плохо для production с несколькими инстансами
spring:
  cache:
    type: caffeine  # Каждый инстанс свой кэш

Не обрабатывать Redis failures

// Если Redis упал → приложение падает
@Cacheable("users")
public User getUser(Long id) { ... }

Нужен fallback:

try {
    return cacheManager.getCache("users").get(id);
} catch (Exception e) {
    log.warn("Cache unavailable, falling back to database");
    return userRepository.findById(id).orElseThrow();
}

Performance improvement с кэшем

До кэша:

После кэша (hit rate 90%):

👉 Ключевая идея: Кэш — это не бесплатное ускорение. Это trade-off между скоростью и актуальностью данных. Всегда спрашивайте: “что произойдёт, если пользователь увидит данные 5-минутной давности?”

Мы рассмотрели все ключевые аспекты эксплуатации. Давайте соберём всё вместе и пройдём полный сценарий — от инцидента до исправления.


8. Сквозной сценарий: от инцидента до исправления

Мы изучили метрики, логи, graceful shutdown, profiles и кэширование. Теперь посмотрим, как всё это работает вместе в реальном production сценарии.

Сквозной сценарий: от инцидента до исправления

Сценарий: Monday morning incident

Понедельник, 10:00. Вы пьёте кофе. Grafana alert: “UserService p99 latency > 2s”.

Что делать? Давайте пройдём весь путь — от обнаружения до исправления.

Шаг 1: Обнаружение проблемы (Блок 3 — Метрики)

Открываем Grafana dashboard:

Что видим:

Гипотеза: кэш перестал работать → все запросы идут в БД → БД перегружена.

Шаг 2: Анализ логов (Блок 4 — Logging)

Открываем Kibana, фильтруем по времени 09:40-09:50.

Фильтр по уровню ERROR:

level:ERROR AND @timestamp:[2024-01-15T09:40 TO 2024-01-15T09:50]

Находим:

{
  "timestamp": "2024-01-15T09:44:23.456Z",
  "level": "ERROR",
  "logger": "org.springframework.data.redis.RedisConnectionFailureException",
  "message": "Unable to connect to Redis at localhost:6379",
  "traceId": "abc123def456",
  "instance": "user-service-2"
}

Ещё логи:

{
  "timestamp": "2024-01-15T09:44:25.123Z",
  "level": "WARN",
  "message": "Cache unavailable, falling back to database",
  "traceId": "xyz789",
  "instance": "user-service-1"
}
{
  "timestamp": "2024-01-15T09:45:10.789Z",
  "level": "ERROR",
  "message": "HikariPool - Connection is not available",
  "instance": "user-service-3"
}

Root cause: Redis недоступен → все запросы идут в БД → connection pool исчерпан.

Шаг 3: Проверка Redis

# Проверяем статус Redis
docker ps | grep redis
# Redis контейнер не запущен!

# Проверяем логи Docker
docker logs redis
# OOMKilled - Redis был убит из-за нехватки памяти

Причина: Redis потребил всю доступную память и был убит системой.

Шаг 4: Немедленное исправление (mitigation)

# Перезапускаем Redis
docker-compose up -d redis

# Проверяем что Redis работает
docker exec -it redis redis-cli ping
# PONG ✅

# Ждём 1-2 минуты

Результат:

Шаг 5: Post-mortem анализ

Что произошло:

  1. Redis накопил слишком много данных в памяти
  2. Система убила Redis (OOMKilled)
  3. UserService потерял кэш
  4. Все запросы пошли в PostgreSQL
  5. PostgreSQL не справился с нагрузкой
  6. Latency выросла

Что помогло:

Шаг 6: Долгосрочное исправление

Проблема: Redis не имел memory limit.

Решение 1: Добавить memory limit в docker-compose

# docker-compose.yml
services:
  redis:
    image: redis:7-alpine
    command: redis-server --maxmemory 512mb --maxmemory-policy allkeys-lru
    deploy:
      resources:
        limits:
          memory: 512M

Решение 2: Добавить мониторинг Redis

# prometheus.yml
scrape_configs:
  - job_name: 'redis'
    static_configs:
      - targets: ['redis:6379']

Решение 3: Добавить alert для Redis memory

# alerts.yml
groups:
  - name: redis
    rules:
      - alert: RedisMemoryHigh
        expr: redis_memory_used_bytes / redis_memory_max_bytes > 0.9
        for: 5m
        annotations:
          summary: "Redis memory usage > 90%"

Решение 4: Уменьшить TTL для некритичных данных

# application.yml
app:
  cache:
    ttl: 1800s  # Было 3600s, стало 30 минут

Шаг 7: Деплой исправления (Блок 5 — Graceful Shutdown)

# Обновляем docker-compose.yml с новыми настройками Redis
# Деплоим через rolling update

./rolling-update.sh

# Лог:
# Updating user-service-1...
# Waiting for user-service-1 to be healthy...
# ✅ user-service-1 is healthy
# Updating user-service-2...
# ✅ user-service-2 is healthy
# Updating user-service-3...
# ✅ user-service-3 is healthy
# Rolling update completed successfully!

Результат: zero downtime, пользователи не заметили обновление.

Итоговая таблица: как каждый блок помог

Проблема Инструмент Блок лекции
Обнаружили проблему Grafana alert (метрики) Блок 3: Observability
Нашли причину Kibana + traceId (логи) Блок 4: Logging
Починили без даунтайма Graceful shutdown Блок 5: Graceful Shutdown
Настроили для разных окружений Profiles + конфигурация Блок 6: Profiles
Оптимизировали производительность Многоуровневый кэш Блок 7: Кэширование

Что мы узнали из этого инцидента

  1. Observability критична: без метрик и логов мы бы не нашли проблему быстро
  2. Graceful degradation работает: приложение продолжило работать без Redis (медленнее, но работало)
  3. Мониторинг всех компонентов: нужно мониторить не только приложение, но и Redis, PostgreSQL
  4. Limits важны: Redis без memory limit — это бомба замедленного действия
  5. Graceful shutdown позволяет деплоить без страха: обновление прошло без ошибок для пользователей

Итоги лекции

Мы прошли путь от “работает на моей машине” до production-ready приложения.

Что мы изучили:

  1. Проблема (Блок 1): Production ≠ development. Нужны инструменты для наблюдения и управления.

  2. Системная модель (Блок 2): Backend-приложение — это процесс ОС с ресурсами, состоянием и зависимостями.

  3. Observability (Блок 3): Метрики + health checks позволяют понять, что происходит с системой.
    • Prometheus для сбора метрик
    • Grafana для визуализации
    • Spring Boot Actuator для экспорта метрик
  4. Logging + Trace ID (Блок 4): Структурированные логи + trace ID позволяют найти причину проблемы в распределённой системе.
    • JSON логи для машинной обработки
    • Trace ID для связи логов одного запроса
    • ELK stack для централизованного хранения
  5. Graceful Shutdown (Блок 5): Безопасная остановка приложения без потери запросов.
    • SIGTERM vs SIGKILL
    • Spring Boot graceful shutdown
    • Rolling update для zero downtime
  6. Profiles (Блок 6): Управление конфигурацией для разных окружений без пересборки.
    • Spring Profiles для разных окружений
    • Environment variables для secrets
    • 12-Factor App принципы
  7. Caching (Блок 7): Оптимизация производительности через кэширование.
    • От простого HashMap к Caffeine
    • От Caffeine к Redis для distributed cache
    • Cache invalidation strategies
  8. End-to-End (Блок 8): Всё работает вместе в реальном production сценарии.

Что дальше?

Следующие темы для изучения:

Контейнеризация и оркестрация:

CI/CD:

Distributed Tracing:

Advanced Topics:

Финальная мысль

Production-ready приложение — это не просто “работающий код”. Это:

Каждый из этих аспектов требует инструментов и практик, которые мы изучили в этой лекции.

👉 Главный вывод: Хороший код — это только 50% работы. Остальные 50% — это observability, операционная готовность и production practices.