Load 10 Triệu Bản Ghi với Spring Data JPA: Mẹo & Trick Tránh JVM "Khóc thét"
Vấn Đề: Database Lớn & JPA
Working với Spring Data JPA rất "mượt"... cho đến ngày bảng data của bạn từ 20.000 bản ghi nhảy phát lên 10–12 triệu, và app bắt đầu lag như mở Chrome với 200 tab.
Vấn đề lớn nhất của JPA là nó rất tham ăn: bạn gọi findAll() là nó cố nuốt hết mọi thứ vào RAM, nhét tất cả entity vào List, rồi giữ nguyên cả đống đó trong persistence context.
Khi số bản ghi tăng lên vài triệu:
- 1 triệu bản ghi: còn tạm sống.
- 5–10 triệu: query chậm, app khựng, memory spike.
- 12+ triệu: OutOfMemoryError: Java heap space trở thành bạn thân của bạn.
GC quay cuồng, throughput tụt, và chuyện ăn ngay OutOfMemoryError trong batch job / job export / báo cáo là quá bình thường.
Giải Pháp Cơ Bản: Stream Thay Vì "Ôm Hết"
Nguyên tắc đơn giản: đùng "ôm hết về nhà", hãy xử lý từng dòng.
Spring Data JPA hỗ trợ Stream<T>, cho phép đọc dữ liệu như một dòng chảy liên tục thay vì một cục nguyên khối:
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Stream<User> findAllByStatus(String status);
}
Ý tưởng: DB giữ cursor, app đọc từng nhóm bản ghi, xử lý xong là "đẩy trôi", chỉ giữ rất ít object trong bộ nhớ tại một thời điểm.
Cách Dùng Stream Đúng Cách
Vài điềm bắt buộc:
1. Chạy Trong Transaction Read-Only
@Transactional(readOnly = true)
public void processMillionsOfUsers() {
try (Stream<User> stream = userRepository.findAllByStatus("ACTIVE")) {
stream.forEach(user -> {
// Xử lý từng user
processUser(user);
});
}
}
Lý do: readOnly = true giúp Hibernate tối ưu hóa không cần tracking thay đổi, cursor tồn tại trong suốt quá trình.
2. Dùng Try-With-Resources
try (Stream<User> stream = userRepository.findAllByStatus("ACTIVE")) {
stream.forEach(this::processUser);
} // Auto-close stream, tránh leak connection
3. Detach Entity Định Kỳ
@Transactional(readOnly = true)
public void processLargeDataset() {
try (Stream<User> stream = userRepository.findAllByStatus("ACTIVE")) {
final AtomicInteger count = new AtomicInteger(0);
stream.forEach(user -> {
processUser(user);
// Cứ mỗi 1000 bản ghi, clear context
if (count.incrementAndGet() % 1000 == 0) {
entityManager.clear();
}
});
}
}
Tối ưu: Fetch Size
Thêm cấu hình để Hibernate gửi kết quả theo batch size:
@Query(value = "SELECT u FROM User u WHERE u.status = :status")
@QueryHints(@QueryHint(name = "org.hibernate.fetchSize", value = "2000"))
Stream<User> findAllByStatus(@Param("status") String status);
Fetch size 2000 Thường tốt nhất - Cân bằng giữa network round-trip và memory.
Kết luật
Với Streaming + fetch Size + detach định kỳ, bạn có thể xử lý 10 - 12 triệu bản ghi mà memory từ vài DB giảm xuống chỉ vài chục MB, JVM của bạn sẽ cảm ơn bạn vì không phải khóc thét môi lần chạy batch JOB nữa
All rights reserved