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

Шаг 1: понять, кто ест память

Первое, что я сделал, — включил снятие дампа при падении:

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/

Через три дня получил файл на 2,4 ГБ и открыл его в Eclipse MAT. Гистограмма показала: 78% кучи — объекты одного класса, org.postgresql.jdbc.PgArray. То есть не «утечка строк», а накопление массивов из базы.

Шаг 2: ошибка, которую я делал сам

Код выглядел безобидно:

public List<Item> findAll() {
    List<Item> result = new ArrayList<>();
    jdbcTemplate.query("select * from items", rs -> {
        result.add(map(rs));
    });
    return result;
}

Строк было 8 миллионов. Каждый запрос загружал их целиком, а кэш в приложении держал результат «на всякий случай» с TTL 24 часа. При 40 обновлениях в час получалось ровно то, что и должно было получиться.

Шаг 3: почему я не увидел этого в метриках

Потому что метрика была heap_used_bytes, а падала она только в конце. Первые два с половиной дня потребление выглядело как «нормальная пила»: сборщик мусора успевал убирать мусор, и график шёл вдоль верхней границы. Смотря на эту пилу, я и потерял три месяца.

Правильная метрика — не использование, а частота сборок:

jvm_gc_collection_seconds_count{name="G1 Young Generation"}

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

Шаг 4: лечение

  • Убрал select * — только нужные колонки.
  • Заменил загрузку полного списка на постраничную обработку через QueryCursor с fetchSize.
  • Кэш перевёл на Caffeine с maximumSize и весом по объёму, а не по количеству записей.

После этого сервис работал без перезапуска полгода.

Вывод, который стоит записать: OutOfMemoryError почти никогда не случается внезапно. Он приходит ровно в момент, когда кривая, на которую никто не смотрел, пересекла границу.