Публикация была переведена автоматически. Исходный язык: Русский
Привет, коллеги!
Сегодня хочу обсудить спринг, а именно JPA, и почему надо подумать перед тем, как сразу бежать создавать crud’ы. Дело в том, что не каждому удается за свою карьеру поработать в highload, поэтому как правило проблемы с производительностью многих обходят стороной. Даже будучи сеньором на своем пути можно НЕ столкнуться с какой-нибудь задачей по оптимизации и это абсолютно нормально.
Давайте представим, что вы пишите какой-нибудь ETL и вам приходит пачка из 5 млн записей. (Допустим в БД). Вам нужно мутировать каждую запись и обновить БД.
Junior: делает findAll() в надежде, что машина загрузит 5 млн в список. Открывает цикл и лопатит записи, по дороге изменяя данные объекта и вызывает save(entity).
Почему это не хорошо? Очевиден риск OOM и скорее всего так и будет и он побежит к DevOps коллеге просить увеличить лимиты.
Middle: делает findAll с пагинацией или подключает Spring Batch. В целом делает то же самое, но старается не грузить в список все записи. Далее сохраняет save или может saveAll и ставит флаг hibernate.jdbc.batch_size. Название у него решительное, и действительно это может сократить кол-во round trips, группируя операции.
Но проблема этих двух подходов немного глубже.
Hibernate кэширует не данные ради скорости, а состояние объектов ради консистентности ORM.
Это фундаментальная причина, почему он начинает мешать при highload
Причина 1. Persistence Context - кэш 1-го уровня.
Гарантирует, что одна запись в БД = 1 Java Object. Растет вместе с кол-вом сущностей. Не очищается, пока не будет commit/rollback.
Причина 2. Dirty Checking.
Проходит по всем managed entities, сравнивает снэпшоты и решает что обновлять, а что нет. Как следствие, мы всегда получаем overhead по памяти, по cpu.
Продолжаем...
Senior: убирает JPA и уходит на уровень ниже - JdbcTemplate. Здесь нет контекста, кэш, dirty checking и это отлично подходит для наших целей.
В целом, идея простая, читаем поток данных, почти не загружая в память и сразу же делаем batchUpdate. Почти не загружаем намеренно, чтобы удовлетворить batchUpdate и сократить расходы на транспорт, группируя операции БД.
ИИшка сгенерила очень простой пример или даже шаблон, который можно использовать. Я его здесь привожу для понимая происходящего.
List<Object[]> batch = new ArrayList<>(100);
jdbcTemplate.query(
"select id, value from source",
rs -> {
while (rs.next()) {
batch.add(new Object[]{
rs.getLong("id"),
rs.getBigDecimal("value")
});
if (batch.size() == 100) {
targetJdbcTemplate.batchUpdate(
"insert into target(id, value) values (?, ?)",
batch
);
batch.clear();
}
}
}
);
// добиваем остаток
if (!batch.isEmpty()) {
targetJdbcTemplate.batchUpdate(sql, batch);
}Таким образом, решается проблема с ООМ при правильном подходе во время чтения и решается проблема со скоростью при правильном insert/update.
Матерые скажут: "А где мои любимые тысяча параллельных потоков"
Давайте разберем и такие варианты.
1. Один instance, много потоков
Чтобы использовать пул потоков, надо для начала разделить чтение и запись, но идея такая же.
Допустим, ваш функционал не ограничивается только batch.add. Можно добавить потоки на процессинг записей, но в итоге все сведется к batch.add, правда потокобезопасный и с Back Pressure, например BlockingQueue. Это позволит держать баланс между скоростью и потреблением памяти.
2. Много instances, можно один поток
Этот подход позволяет размножить ваше приложение при условии, что он stateless. Оставляем Senior'ный подход, но меняем select на select + pessimistic locking. Это делается одной строкой select id, value from source for update skip locked. Работает при условии поддержки БД.
Итог:
1) Мы создали задачу
2) Решили задачу
3) Нашли проблему
4) Решили проблему
5) Поняли, что не решили и снова решили
6) Даже возможно масштабировали решение и ускорили процесс
Все как в жизни!
Спасибо за внимание! Пишите если что!
Важно!
- Чтобы в Postgres ускорить batch update, можно включить reWriteBatchedInserts=true.
- Для потокового чтения в Postgres надо обязательно ставить connection.setAutoCommit(false); иначе будет прочитан весь resultset, а не указанный fetchSize.
Привет, коллеги!
Сегодня хочу обсудить спринг, а именно JPA, и почему надо подумать перед тем, как сразу бежать создавать crud’ы. Дело в том, что не каждому удается за свою карьеру поработать в highload, поэтому как правило проблемы с производительностью многих обходят стороной. Даже будучи сеньором на своем пути можно НЕ столкнуться с какой-нибудь задачей по оптимизации и это абсолютно нормально.
Давайте представим, что вы пишите какой-нибудь ETL и вам приходит пачка из 5 млн записей. (Допустим в БД). Вам нужно мутировать каждую запись и обновить БД.
Junior: делает findAll() в надежде, что машина загрузит 5 млн в список. Открывает цикл и лопатит записи, по дороге изменяя данные объекта и вызывает save(entity).
Почему это не хорошо? Очевиден риск OOM и скорее всего так и будет и он побежит к DevOps коллеге просить увеличить лимиты.
Middle: делает findAll с пагинацией или подключает Spring Batch. В целом делает то же самое, но старается не грузить в список все записи. Далее сохраняет save или может saveAll и ставит флаг hibernate.jdbc.batch_size. Название у него решительное, и действительно это может сократить кол-во round trips, группируя операции.
Но проблема этих двух подходов немного глубже.
Hibernate кэширует не данные ради скорости, а состояние объектов ради консистентности ORM.
Это фундаментальная причина, почему он начинает мешать при highload
Причина 1. Persistence Context - кэш 1-го уровня.
Гарантирует, что одна запись в БД = 1 Java Object. Растет вместе с кол-вом сущностей. Не очищается, пока не будет commit/rollback.
Причина 2. Dirty Checking.
Проходит по всем managed entities, сравнивает снэпшоты и решает что обновлять, а что нет. Как следствие, мы всегда получаем overhead по памяти, по cpu.
Продолжаем...
Senior: убирает JPA и уходит на уровень ниже - JdbcTemplate. Здесь нет контекста, кэш, dirty checking и это отлично подходит для наших целей.
В целом, идея простая, читаем поток данных, почти не загружая в память и сразу же делаем batchUpdate. Почти не загружаем намеренно, чтобы удовлетворить batchUpdate и сократить расходы на транспорт, группируя операции БД.
ИИшка сгенерила очень простой пример или даже шаблон, который можно использовать. Я его здесь привожу для понимая происходящего.
List<Object[]> batch = new ArrayList<>(100);
jdbcTemplate.query(
"select id, value from source",
rs -> {
while (rs.next()) {
batch.add(new Object[]{
rs.getLong("id"),
rs.getBigDecimal("value")
});
if (batch.size() == 100) {
targetJdbcTemplate.batchUpdate(
"insert into target(id, value) values (?, ?)",
batch
);
batch.clear();
}
}
}
);
// добиваем остаток
if (!batch.isEmpty()) {
targetJdbcTemplate.batchUpdate(sql, batch);
}Таким образом, решается проблема с ООМ при правильном подходе во время чтения и решается проблема со скоростью при правильном insert/update.
Матерые скажут: "А где мои любимые тысяча параллельных потоков"
Давайте разберем и такие варианты.
1. Один instance, много потоков
Чтобы использовать пул потоков, надо для начала разделить чтение и запись, но идея такая же.
Допустим, ваш функционал не ограничивается только batch.add. Можно добавить потоки на процессинг записей, но в итоге все сведется к batch.add, правда потокобезопасный и с Back Pressure, например BlockingQueue. Это позволит держать баланс между скоростью и потреблением памяти.
2. Много instances, можно один поток
Этот подход позволяет размножить ваше приложение при условии, что он stateless. Оставляем Senior'ный подход, но меняем select на select + pessimistic locking. Это делается одной строкой select id, value from source for update skip locked. Работает при условии поддержки БД.
Итог:
1) Мы создали задачу
2) Решили задачу
3) Нашли проблему
4) Решили проблему
5) Поняли, что не решили и снова решили
6) Даже возможно масштабировали решение и ускорили процесс
Все как в жизни!
Спасибо за внимание! Пишите если что!
Важно!
- Чтобы в Postgres ускорить batch update, можно включить reWriteBatchedInserts=true.
- Для потокового чтения в Postgres надо обязательно ставить connection.setAutoCommit(false); иначе будет прочитан весь resultset, а не указанный fetchSize.