問題背景
批次轉檔服務需要在每日指定時間,將業務資料從 Oracle 批次轉檔到 Redis Cluster,作為查詢層的快取資料來源。
每次轉檔涉及:
- 數萬個主體(帳戶/用戶)
- 每個主體 5 種資料類型(主資料、明細資料、歷史記錄、排行資料A、排行資料B)
初版做法(全量載入後寫入)的問題:
全量查詢帳戶 → 全量查詢所有資料 → 批次寫入 Redis
問題:
- 10,000 帳戶 × 5 種資料 = 數十萬筆,一次塞進記憶體
- JVM Heap 峰值達 3.5GB,頻繁觸發 Full GC,偶發 OOM
- 執行時間 45 分鐘
- 失敗率約 5%(OOM 或超時導致)架構設計:三層批次處理
批次分割層
帳戶清單按 batchSize=50 切割
↓
並行控制層
Semaphore 限制同時執行的批次數
CompletableFuture + 自訂執行緒池
↓
資料處理層(單批次內)
批次查詢(1 SQL 查 50 帳戶,避免 N+1)
依帳戶分組
逐帳戶寫入 Redis(錯誤隔離)
明確釋放記憶體核心實作
批次分割 + Semaphore 流量控制
public long transferByBatch(int monthsBack) throws InterruptedException {
// 1. 查詢帳戶清單
List<AccountEntity> accounts = accountRepo.findByDateRange(dateRange);
// 2. 初始化控制結構
Semaphore semaphore = new Semaphore(semaphoreNumber); // 限制並行批次數
AtomicInteger successCount = new AtomicInteger(0);
AtomicBoolean oomDetected = new AtomicBoolean(false);
ErrorCollector errorCollector = new ErrorCollector(maxErrorLogCount);
List<CompletableFuture<Boolean>> futures = new ArrayList<>();
for (int i = 0; i < accounts.size(); i += batchSize) {
// OOM 偵測:提前終止
if (oomDetected.get()) break;
List<AccountEntity> batch = accounts.subList(
i, Math.min(i + batchSize, accounts.size())
);
semaphore.acquire(); // 取得許可(超過上限則阻塞等待)
CompletableFuture<Boolean> future = transferService
.redisTransfer(batch, successCount, oomDetected, errorCollector);
future.whenComplete((res, ex) -> semaphore.release()); // 完成後釋放許可
futures.add(future);
// 每 100 批次建議 GC
if (i % (batchSize * 100) == 0) {
System.gc();
}
}
// 等待所有批次完成
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
// 根據是否觸發 OOM 決定最終狀態
if (oomDetected.get()) {
handleOomTermination(successCount.get(), accounts.size());
} else {
statusService.setStatus(Status.COMPLETE);
}
return successCount.get();
}Semaphore 的作用:假設帳戶 10,000 個、批次大小 50,會切成 200 個批次。若全部同時丟進執行緒池,瞬間 200 個任務搶 DB 連線和 Redis 連線,反而塞車。Semaphore 設為 10,保持最多 10 個批次同時執行,避免資源爭奪。
批次內的錯誤隔離
@Async("accountTaskExecutor")
public CompletableFuture<Boolean> redisTransfer(
List<AccountEntity> batch,
AtomicInteger successCount,
AtomicBoolean oomDetected,
ErrorCollector errorCollector) {
try {
// 單批次內執行,帶帳號級錯誤隔離
Set<String> successful = processAccountBatch(batch, errorCollector);
successCount.addAndGet(successful.size());
return CompletableFuture.completedFuture(true);
} catch (Exception e) {
// 整批失敗時回滾
batch.forEach(this::rollbackEntity);
if (isOomError(e)) {
oomDetected.set(true); // 通知上層提前終止
}
return CompletableFuture.completedFuture(false);
}
}帳號級錯誤隔離的意義:帳號 A 的資料有問題,不應該連帶讓同批次的帳號 B-Z 也失敗。
public Set<String> processAccountBatch(List<AccountEntity> accounts, ErrorCollector collector) {
List<String> entityIdList = accounts.stream().map(AccountEntity::getEntityId).toList();
Set<String> successful = new HashSet<>(entityIdList);
Set<String> failed = new HashSet<>();
// 批次查詢(1 SQL,而非 N 次)
List<DataRecord> dataList = dataRepo.findByEntityIds(entityIdList, dataDate);
// 依主體 ID 分組
Map<String, List<DataRecord>> grouped = dataList.stream()
.collect(Collectors.groupingBy(DataRecord::getEntityId));
// 明確釋放原始清單(幫助 GC)
dataList.clear();
dataList = null;
// 逐主體處理
for (Map.Entry<String, List<DataRecord>> entry : grouped.entrySet()) {
String entityId = entry.getKey();
if (failed.contains(entityId)) continue; // 已在前面的階段失敗
try {
redisDao.save(entityId, entry.getValue());
} catch (Exception e) {
collector.addError(e, "data write", entityId);
successful.remove(entityId);
failed.add(entityId);
} finally {
// 每筆處理完立即清理
entry.getValue().clear();
}
}
// 回滾失敗主體
failed.forEach(entityId -> rollbackEntityData(entityId));
return successful;
}執行緒池配置
@Bean(name = "accountTaskExecutor")
public Executor accountTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(corePoolSize); // 20
executor.setMaxPoolSize(maxPoolSize); // 50
executor.setQueueCapacity(queueCapacity); // 500
executor.setThreadNamePrefix("Acct-Trans-");
// CallerRunsPolicy:佇列滿時由呼叫者執行 → 提供自然背壓
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}CallerRunsPolicy 的妙用:當執行緒池和佇列都滿了,不是直接拋異常(AbortPolicy),而是讓提交任務的主執行緒自己執行這個批次。主執行緒被佔用的這段時間,就自然無法繼續提交新批次,形成背壓效果。
記憶體管理技巧
明確置 null 幫助 GC
List<DataRecord> dataList = null;
Map<String, List<DataRecord>> grouped = null;
try {
dataList = dataRepo.findByEntityIds(entityIdList, dataDate);
grouped = dataList.stream()
.collect(Collectors.groupingBy(DataRecord::getEntityId));
// 原始清單用完立即釋放
dataList.clear();
dataList = null; // 明確置 null,讓 GC 知道可以回收
// 繼續處理 grouped...
} finally {
if (grouped != null) {
grouped.clear();
grouped = null;
}
}這在高併發批次處理中特別重要。如果 20 個執行緒同時持有各自的 dataList,每個 10MB,就是 200MB 同時壓在 Heap 上。及早 null 讓 GC 有機會在下一個 Minor GC 就回收,而不是堆積到老年代觸發 Full GC。
Redis OOM 偵測
private boolean isOomError(Exception e) {
Throwable cause = e.getCause();
return cause instanceof RedisCommandExecutionException
&& cause.getMessage() != null
&& cause.getMessage().contains("OOM");
}Redis Cluster 節點記憶體不足時,會回 OOM command not allowed when used memory > 'maxmemory'。偵測到這個訊號後,oomDetected 設為 true,上層批次分割迴圈看到後停止提交新批次,等現有批次完成後優雅退出,保存已成功的資料。
JVM 參數建議
-Xms2g -Xmx4g # 預留足夠空間
-XX:+UseG1GC # G1 適合大 Heap + 低停頓需求
-XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/logs/heapdump.hprofHeapDumpOnOutOfMemoryError 務必開啟。OOM 發生瞬間的 heap dump 是事後分析的唯一證據,沒有它基本上猜不到是誰吃掉記憶體。
Redis 寫入重試
private void executeWithRetry(Runnable operation) {
int retryCount = 0;
long waitMs = 500;
while (retryCount < MAX_RETRIES) {
try {
operation.run();
return;
} catch (Exception e) {
retryCount++;
if (retryCount >= MAX_RETRIES) throw e;
Thread.sleep(waitMs);
waitMs = Math.min(waitMs * 2, 5000); // 指數退避,上限 5 秒
}
}
}Redis Cluster 在拓撲變更(節點重平衡)時可能短暫不可用,指數退避重試比固定間隔重試更友善——前幾次快速重試,多次失敗後才慢下來,不會一直轟炸剛恢復的節點。
效能改善結果
| 指標 | Before | After | 改善 |
|---|---|---|---|
| 10,000 帳戶轉檔時間 | 45 分鐘 | 8 分鐘 | ↓ 82% |
| 記憶體峰值 | 3.5 GB | 2.2 GB | ↓ 37% |
| 失敗率 | ~5% | < 0.1% | ↓ 98% |
| CPU 使用率 | 20-30% | 60-70% | ↑ 2.5x |
可調整的三個核心參數
| 參數 | 預設值 | 作用 | 調整方向 |
|---|---|---|---|
batchSize |
50 | 每批帳戶數 | 記憶體壓力大→降;速度慢→升 |
semaphoreNumber |
10 | 最大並行批次數 | OOM 風險高→降;速度優先→升 |
corePoolSize |
20 | 執行緒池核心數 | 依機器 CPU 核心數 × 2 |
這三個參數互相影響:提高 semaphoreNumber 會讓更多批次同時執行,需要更多執行緒(corePoolSize),同時也需要更多記憶體(batchSize 越大影響越明顯)。調整時建議一次改一個參數觀察效果。
適用場景
適合:
- 大量資料 ETL(RDBMS → NoSQL)
- 批次報表產生
- 快取預熱
不適合:
- 即時性要求 < 1 秒的場景
- 資料量 < 1,000 筆(過度設計)
- 需要嚴格 ACID 事務的場景(分批導致部分成功問題)