- SXSSF, FastExcel, StreamingResponseBody qoʻllash jarayoni -
1. Kirish
Korxona tomonidan ishlab chiqilgan tizim Kafka, NATS JetStream kabi foydalanuvchanlikni taqdim etuvchi yengil xabar brokerni platformasining monitoring dashtbordidir. Event xabarnomasining Pub/Sub holatini monitoring qilish va Event Entry jurnalini ko‘rish imkoniyatini taqdim etadi.
Jumladan, Event Entry jurnalini Excel formatida yuklab olish funksiyasini ishlab chiqishda OOM muammosi yuzaga keldi. Dastlab, odatiy Apache POI asosida amalga oshirilgan edi va ma'lumotlar soni ko'p bo'lmaganda katta muammo bo'lmagan.
Biroq, ish jarayonida yuklab olinadigan ma'lumotlar soni ortishi boshladi va taxminan 80 mingdan ortiq ma'lumotlarni yuklab olgan paytdan boshlab quyidagi muammolar paydo bo'ldi.
-
Javob vaqti keskin oshishi
-
Full GC takroriy yuz berishi
-
Heap xotira foydalanishining keskin ortishi
-
Kubernetes Pod qayta ishga tushirilib OOMKilled yuzaga kelishi
O'sha paytda Kubernetes muhitidagi xotira cheklovlari (800Mi) sababli Pod OOMKilled holatida qolishi sodir bo'ldi. Tezkor muammoni hal qilish uchun avval xotirani 800Mi → 1500Mi ga oshirdik, lekin bu vaqtincha chora edi. Ma'lumotlar davom etayotgan sari, bir kun kelib xuddi shu muammo takrorlanishi aniq edi va asosiy sababi aniq edi.
"Barcha ma'lumotlarni xotiraga yuklab Excel yaratish strukturasining o'zi"
Ushbu hujjat bir marta javobni topgan hikoya emas. Kutubxona almashinuvi, chunkSize (chunk o'lchami)ni sozlash, strukturani o'zgartirishgacha bir necha marta harakatlar o'tkazildi va nima haqiqiy muammo ekani tartib bilan qayd etiladi.
2. Mavjud strukturaning muammolari
2-1. Barcha ma'lumotlarni xotiraga yuklash tuzilmasi
Mavjud yuklab olish usuli quyidagi oqimda edi.
DB 전체 조회
→ List 메모리 적재
→ XSSFWorkbook 생성
→ ByteArrayOutputStream 생성
→ byte[] 변환
→ HTTP 응답 반환
Bu jarayonning deyarli barcha bosqichlari JVM Heap asosida ishlamoqda. So‘rov ma'lumotlar ro'yxati, Workbook obyekti va Row ma'lumotlar, ByteArrayOutputStream buferi, oxirgi byte[] gacha barchasi xotirada saqlanib, javobni qaytarish tuzilmasi edi. Ma'lumotlar soni ko'paygan sari Heap foydalanish miqdori chiziqli dan ortiq ko'paydi va Full GC takroran paydo bo'ldi.
2-2. Ekran so'rov logicini qayta ishlatishdan kelib chiqadigan N+1 muammosi
Excel yuklab olish logikasi ekran so'roviga to‘g‘ridan-to‘g‘ri qayta ishlatilayotgan edi. Shuning uchun Lazy Loading asosidagi bog'liq entitilarni qo'shimcha ko'rish yuzaga kelib, ma'lumotlar soni ko'paygan sari SQL bajarish soni keskin oshadigan N+1 muammosi birga kelayotgandi.
Buni hal qilish uchun Excel yuklab olish uchun alohida so'rovni yozdim va JOIN orqali bog'liq ma'lumotlarni bir paytda olib, DTOga bevosita mapping qilishga o'zgartirdim. Shuningdek, odatiy LIMIT / OFFSET usuli offset o'sgan sari DBning oldingi ma'lumotlarni qayta skanerlash xarajatlari chiziqli ravishda oshish muammosi bor, lastOffset > ? sharti asosidagi kursor usuliga o'zgartirdim. Kursor usuli har doim indeks diapazoni skanerlash bilan bajarilib, katta hajmda ham ko‘rish samaradorligini barqaror saqlaydi.
3. 1-chi urinish: SXSSFWorkbook + chunkSize
3-1. Yondashuv
Dastlab "Apache POI o'zida muammo" deb hisobladim. POIdan rasmiy ravishda taqdim etilgan oqim usuli bo‘lgan SXSSFWorkbookga almashtirdim va to'liq ma'lumotlarni bir marta ko'rish o'rniga belgilangan son (chunkSize) bo'yicha bo'lib, DBdan olib, Excel Rowga joylash usulini o'zgartirdim.
int chunkSize = 1000;
int offset = 0;
while (true) {
List<EventEntryDto> chunk = repository.findByChunk(offset, chunkSize);
if (chunk.isEmpty()) break;
for (EventEntryDto item : chunk) {
Row row = sheet.createRow(rowIndex++);
row.createCell(0).setCellValue(item.getOffset());
row.createCell(1).setCellValue(item.getPartitionNo());
// ...
}
offset += chunkSize;
}
3-2. Natija va muammo
Mahalliy muhit testlarida OOMsiz ishladi. Mahalliy asosida 80 mingta taxminan 1 daqiqa 48 soniya davom etdi, tezlik muammosi bo‘ldi, lekin har holda ishladi.
Biroq, ishlab chiqarish muhitiga (Docker muhiti) joylashtirganda kutilmagan xato paydo bo'ldi.
java.lang.NullPointerException
at sun.awt.FontConfiguration.getVersion(FontConfiguration.java:1264)
at sun.awt.FontConfiguration.readFontConfigFile(FontConfiguration.java:219)
at sun.awt.FontConfiguration.init(FontConfiguration.java:107)
at sun.awt.X11FontManager.createFontConfiguration(X11FontManager.java:774)
at sun.font.SunFontManager$2.run(SunFontManager.java:431)
...
SXSSFWorkbook ichki holatda tizim shriftlarini nazarda tutayotgan edi, lekin Docker konteynerida bu shrift o'rnatilmagan edi, shuning uchun xato paydo bo'ldi.
Hal qilish usuli sifatida konteynerga shriftlarni to'g'ridan-to'g'ri o'rnatish usuli mavjud edi, lekin bu yo'nalishni tanlamadim. Mahalliy va ishlab chiqarish hamda ishga tushirish muhitlari o'rtasidagi bog'liqlik farqlari paydo bo'lishi va shrift o'rnatilishi yuqori xizmat ko'rsatish murakkabligi sababli xato paydo bo'lganda ko'tarilishi mumkin. Ishlab chiqarish muhitiga tashqi bog'liqlik qo'shishdan ko'ra, shrift bog'liqligi umuman bo'lmagan kutubxonaga almashish asosiy yechim sifatida hisoblangan.
1-chi urinish xulosasi: Mahalliy muhitda ishladi, lekin ishlab chiqarish muhitiga joylashtirganda shrift xatosi paydo bo'ldi. Tezlik muammosi va muhitga bog'liqlik muammolari birgalikda qoldi.
4. 2-chi urinish: FastExcel + StreamingResponseBody
4-1. Kutubxona tanlash asoslari
font errorni hal qilar ekan, tezlik muammosini ham hal qiladigan kutubxonani ko'rib chiqdik.
-
EasyExcel: Streaming jarayoni yaxshi edi, lekin ishlab chiqish jarayonida SXSSFWorkbook bilan bir xil font error paydo bo'ldi. Ichki tizim fontlarini muqobil ishlatishi o'xshash bo'lganligi sababli.
-
FastExcel (dhatim/fastexcel): Qator bo'yicha streaming asosida ishlaydi va tizim fontlariga deyarli bog'liq emas, Docker muhitida alohida sozlamalarsiz normal ishladi. Rasmiy hujjatga ko'ra, Apache POI (non-streaming) ga nisbatan taxminan 12 barobar kam Heap xotira ishlatadi.
4-2. Qo'shimcha aniqlangan muammo: byte[] konversiya
Kutubxonani almashish jarayonida avvalgi tuzilma bilan bog'liq yana bir muammo topdik. Excel yaratish tugaganidan so'ng ByteArrayOutputStream → byte[] ga konversiya jarayoni katta hajmda xotirani yana bir marta keskin oshiradi.
기존: 전체 생성 완료 → byte[] 변환 → 응답 시작
개선: Row 생성 → 즉시 OutputStream write → 클라이언트 전송 시작
Buni StreamingResponseBody bilan hal qildik. Excel qatorlarini OutputStream ga to'g'ridan-to'g'ri yozib, bir vaqtning o'zida HTTP javobiga o'tkazish strukturasidir. StreamingResponseBody dan foydalanganda Excel to'liq yaratilmasdan oldin mijozda yuklab olish boshlanadi. Umumiy tugatish vaqti bir xil, lekin brauzerda faylni yuklab olish boshlangan vaqti oldinga surilgani sababli foydalanuvchilar sezadigan tezlik sezilarli darajada yaxshilanadi.
@PostMapping("/export-entries/fetch")
public ResponseEntity<StreamingResponseBody> exportEntries(@RequestBody ExportEntriesFetch fetch) {
fetch.validate();
StreamingResponseBody response = out ->
entryExportService.exportToExcel(out, fetch.getStreamId(), fetch.getPartitionId(),
fetch.getName(), fetch.getEntryOffset());
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"entries-%s.xlsx\"".formatted(fetch.getStreamId()))
.body(response);
}
public void exportToExcel(OutputStream out, String streamId, String partitionId,
String name, String searchOffset) {
try (Workbook workbook = new Workbook(out, "MyApp", "1.0")) {
Worksheet sheet = workbook.newWorksheet("Entries");
String[] headers = {"No", "Partition", "Offset", "Produced At",
"Route Key", "Compression", "Format", "Checksum", "Payload"};
for (int i = 0; i < headers.length; i++) {
sheet.value(0, i, headers[i]);
}
for (PartitionSummaryDto partition : partitions) {
while (true) {
List<EntryExportDto> entries = entryRepository.findExportEntries(
streamId, partition.getId(), name, searchOffset, lastOffset, CHUNK_SIZE);
for (EntryExportDto entry : entries) {
sheet.value(rowIdx, 0, rowIdx);
// ... 각 컬럼 값 삽입
rowIdx++;
}
sheet.flush();
if (entries.size() < CHUNK_SIZE) break;
lastOffset = entries.get(entries.size() - 1).getEntryOffset();
}
}
workbook.finish();
out.flush();
}
}
4-3. Natijalar va muammolar
font error bartaraf etildi va sezilarli tezlik o'zgarishi ro'y berdi. Mahalliy qoidalar bo'yicha 20 mingdan 1.92s, 60 mingdan 6.03s uchun birinchi marta ishlash tezligi sezilarli darajada yaxshilandi.
Ammo 170 mingda OOM takrorlandi. Sabablarni tahlil qilsak, chunkSize bo'yicha DB so'rovlarini bo'lib tashlashimiz mumkin bo'lsa-da, har bir chunk ichidagi ob'ektlar GC qilinmasdan to'planib qolayotgan edi. JPA ob'ektlari aloqalar haqida ma'lumot, Proxy ob'ektlar, PersistenceContext havolalari va haqiqiy ma'lumotdan tashqari bir qancha meta-ma'lumotlarni o'z ichiga oladi. Chunklarni bo'lganda ham bu ob'ektlar havolalarini saqlagan holda aylansa, xotira yetarlicha bo'shamaydi.
2-chi urinish xulosasi: font errorni hal qilish, tezlikni yaxshilash. Ammo ob'ektlarni to'planganda katta hajmda OOM takrorlandi.
5. 3-chi urinish (oxirgi): engil DTO ayirboshlash + chunkSize real o'lchovlarni sozlash
5-1. Asosiy sabab: ob'ektlar chunkni ishga solmayapti
Chunk orqali ko'rish bo'laklariga bo'lib tashlashimiz xotiraning jo'natilmasligi sabab JPA ob'ektlari xususiyatidir. Ob'ektlar oddiy ma'lumot konteyneri emas. JPA tomonidan boshqariladigan ob'ektlar quyidagi qo'shimcha ma'lumotlarni ham o'z ichiga oladi.
-
Aloqa maydonlari (Lazy proxy ham kiritilgan)
-
Hibernate ichki meta-ma'lumotlari
-
PersistenceContextning 1-marta кеши havolasi
chunk bo'yicha tsikl o'tkazsangiz ham, oldingi chunkning entitiyalari hali ham havolani saqlab tursa, GC ob'ekti bo'lmaydi. Excelni yuklab olish kabi minglab entitiyalardan o'tayotganda, bu to'plam oxir-oqibat OOMga olib keladi.
YeChim oddiy edi. Excelga kerakli maydonlardan iborat engil DTO orqali so'rov o'tkazsangiz, JPA boshqarish obyekti yaratilmaydi va chunkni qayta ishlagandan so'ng darhol GC ob'ekti bo'ladi.
// 기존: JPA 엔티티 조회 — 메타데이터, 연관 관계까지 메모리에 올라옴
List<EventEntry> entries = entryRepository.findByStreamId(streamId);
// 변경: 경량 DTO 조회 — 엑셀에 필요한 필드만 포함
List<EntryExportDto> entries = entryRepository.findExportEntries(...);
public record EntryExportDto(
Integer partitionNo,
Long entryOffset,
String producedAt,
String routeKey,
String compressionType,
String payloadFormat,
String checksum,
String payload
) {}
5-2. chunkSizeni amalda sozlash jarayoni
DTOni aylantirgandan so'ng ham, chunkSizeni qanday o'rnatishingiz natijalarni o'zgartirishiga bog'liq edi. Amalda to'g'ri qiymatni topdik.
|
chunkSize |
Natija |
|---|---|
|
5,000 |
OOM yuz berdi — chunk uchun xotira yuklamasi hali ham katta |
|
1,500 |
Ba'zan OOM — chekka holatlarda noaniq |
|
500 |
OOM yo'q — lekin DBni so'rash soni ortishi sababli umumiy tezlik juda sekin |
|
1,000 (oxirgi qabul qilingan) |
OOM yo'q + tezlik qarash doirasi |
chunkSize faqat "kichikroq bo'lsa, xavfsizroq" degani emas. Juda kichik bo'lsa, DB ga qaytish soni ortadi va juda katta bo'lsa, chunk ichidagi xotira yuklanishi yana oshadi. Haqiqiy ma'lumot hajmini (ayniqsa, o'lchami o'zgaruvchan ustunlar kabi Payload maydoni) hisobga olib, haqiqiy o'lchovlarni amalga oshirib, qaror qabul qilish muhim.
Excel yuklab olish bu partiyaviy ish. Biroz vaqt talab qilsa ham, OOMsiz tugatish foydalanuvchi uchun yaxshiroq tajribadir. 2 daqiqa kutishdan ko'ra, yuklab olish paytida server o'lishi ancha yomon.
Haqiqiy ish muhiti "eng yuqori tezlik"dan ko'ra "barqaror tugatish" muhimroq bo'ldi.
5-3. Yakuniy natija
Quyidagi jadvalda mahalliy raqamlar 1-2-marto'ra urinish davrida o'lchovlar bo'lib, dasturlash muhiti raqamlari hozirgi tarqatilgan yakuniy versiya asosidadir.
|
Ma'lumotlar soni |
Muhit |
1-qism (SXSSF) |
2-qism (FastExcel) |
Yakuni (DTO + chunk 1000) |
|---|---|---|---|---|
|
80 ming |
Mahalliy |
1 daqiqa 48 soniya |
— |
— |
|
20000 ta |
mahalliy |
— |
1.92s |
— |
|
60000 ta |
mahalliy |
— |
6.03s |
— |
|
170000 ta |
mahalliy |
— |
OOM |
— |
|
— |
dasturlash soha |
font xatosi |
— |
— |
|
50 000 ta |
dasturlash soha |
— |
— |
taxminan 4s |
|
120 000 ta |
dasturlash soha |
— |
— |
taxminan 5.5s |
|
320000 ta |
rivojlantirish |
— |
— |
OOM (takomillashtirilgan) |
Rivojlantirishning oxirgi versiyasi bo'yicha ishlash ko'rsatkichlari quyidagicha.
|
Ma'lumotlar soni |
Javob vaqti |
OOM holati |
|---|---|---|
|
50000 ta |
taxminan 4s |
yo'q |
|
120000 ta |
taxminan 5.5s |
yo'q |
|
32 ming qonun |
— |
Vujudga kelishi (takomillashtirish rejalashtirilgan) |
6. Qo'shimcha yaxshilash yo'nalishi (320,000 ta OOM tahlili)
Hozirda 320 mingta holatda OOM qayta sodir bo'lmoqda. Avvaliga bu faqat chunkSize hali katta ekanligi deb o'ylagandim. Ammo sababini yanada chuqurroq o'rganib chiqqanimda, Payload kabi katta va noyob satrlarni Workbook darajasida yig'ish strukturasining mavjudligi muhim ahamiyatga ega ekanligini angladim.
FastExcelni oʻz ichiga olgan aksariyat xlsx kutubxonalari ichki jihatdan shared string tableni saqlaydi. sheet.value() yordamida satrni yozganda, ushbu satr ushbu keshga roʻyxatga olinadi va sheet.flush() yordamida Row ma'lumotlari eksport qilinishi mumkin, lekin shared string table Workbook yopilguncha xotirada saqlanadi. Payload kabi har birida qiymati farq qiladigan katta hajmdagi satrlar yigʻilib ketsa, chunkni qanchalik kichik boʻlishidan qat'iy nazar, Workbook umumiy umr bo'yi xotira summasi tugatishdan qochib bo'lmaydi.
chunk bo'lagi xotira ozod qilinishi yaxshi ishlayotgan edi, ammo Workbook hayoti bilan birga yashayotgan shared string cache alohida yig'ish nuqtasiga aylanib qoldi. sheet.value() o'rniga sheet.inlineString() dan foydalansangiz, satrni shared string cache ga saqlamay, to'g'ridan-to'g'ri hujayraga yozishingiz mumkin, bu esa flush dan keyin xotiradan ozod qilinishi ehtimolini oshiradi. Payload satrini yaratish takrorlanishini yo'qotish, chiqish uzunligini cheklash kabi masalalar ham ko'rib chiqilishi kerak. Bu qism keyingi yaxshilanish maqsadi.
7. Yakun
Bu ish orqali "lokalda bo'ladi" va "operatsion muhitda barqaror ishlaydi" degan juda boshqa hikoya ekanligini bevosita boshdan kechirdim. Kutilgan kutubxonani tanlash faqatgina ishlash ko'rsatkichlari bilan bog'liq emas, balki konteyner muhiti kabi operatsion cheklovlar ham tanlash mezoniga kiritilishi kerak edi.
Shuningdek, chunk orqali so‘rovni bo‘lishdan qat‘i nazar, entity emas, engil DTOdan foydalanish kerak, chunki chunk ma'noga ega bo‘lishi uchun va chunkSize nazariy emas, balki o‘lchov orqali aniqlanishi kerakligi ham ushbu ishda olingan asosiy fikrdir.
To'liq hal etilgan hikoya emasligiga ochiqchasiga qoldirish, o'sha muammoni boshdan kechirayotgan hamkasblarga ko'proq yordam beradi deb o'ylayman.
tavsiya materiallari
-
FastExcel GitHub: https://github.com/dhatim/fastexcel
-
XSSF, SXSSF va EasyExcel taqqoslash: https://velog.io/@dradnats1012/Springboot-Excel-imal-qilish-jarayoni-yaxshilashXSSF-vs-SXSSF-vs-EasyExcel
-
FastExcel bilan bog'liq ma'lumotlar: https://jaimemin.tistory.com/2191
-
font xatosi haqida: https://joalog.tistory.com/121
pong