Сервис работал ровно три дня, потом падал по 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 почти никогда не случается внезапно. Он приходит ровно в момент, когда кривая, на которую никто не смотрел, пересекла границу.