初始狀況
壓測的目標服務是 Query-API——整個系統的核心查詢層,直接承接來自多個上游系統的請求,並且共用同一個 Oracle。JMeter 直打 Query-API,不經過 Web-Backend,這樣才能精確定位 Query-API 本身的效能邊界。
初期數據不理想:
| 指標 | 初始值 | 目標 |
|---|---|---|
| TPS | 80 | 150(PROD 9000 RPM) |
| 平均回應時間 | 850ms | ≤ 500ms |
| P95 | 2.1s | ≤ 500ms |
| P99 | 3.5s | — |
| CPU 使用率 | 85% | — |
| 錯誤率 | 3% | 近 0 |
部署架構:3 台 Query-API(各自搭配 Traffic-Controller 同機部署)共用單一 Oracle + Redis Cluster。所有實例共用同一個 Oracle,這個約束影響了多個優化決策,特別是連線池的計算方式。
瓶頸定位方法
優化前先定位問題,不然很可能調了一圈都在優化不是瓶頸的地方。
整體指標異常(TPS 低、延遲高)
↓
APM Trace 找慢請求
↓
Thread Dump 看執行緒狀態(WAITING / BLOCKED?)
↓
DB Slow Query Log 確認是否資料庫問題
↓
GC Log 看是否 Full GC 頻繁這次的主要瓶頸依序是:SQL 查詢 → 連線池配置 → 快取命中率 → 執行緒池。
八個優化策略
策略一:SQL 查詢優化
最直接的瓶頸。N+1 查詢是最常見的問題:
// 優化前:N 次查詢
for (String acctId : acctIds) {
Balance balance = balanceRepo.findByAcctId(acctId); // 執行 N 次 SQL
}
// 優化後:1 次批次查詢
List<Balance> balances = balanceRepo.findByAcctIdIn(acctIds);
Map<String, Balance> balanceMap = balances.stream()
.collect(Collectors.toMap(Balance::getAcctId, Function.identity()));效果:DB 查詢時間從 120ms → 35ms(-71%),往返次數減少 80%。
另外補上缺少的索引,特別是 JOIN 欄位和 WHERE 條件的複合索引。可以透過 Oracle 的 EXPLAIN PLAN 確認是否走索引,還是全表掃描。
策略二:HikariCP 連線池調校(多機器環境特別注意)
這裡有個容易踩坑的地方:連線池大小要考慮所有機器實例的總和。
# query-api/application-prod.yml
spring:
datasource:
hikari:
# 計算方式:
# Oracle 最大連線數:300
# 保留給 DBA 管理工具:50
# 可用連線:250
# Query-API 有 3 台:250 / 3 ≈ 83,取 80 留緩衝
maximum-pool-size: 80
minimum-idle: 30
connection-timeout: 20000 # 等待連線超時 20 秒
idle-timeout: 300000 # 閒置連線超時 5 分鐘
max-lifetime: 1200000 # 連線最大生命週期 20 分鐘
leak-detection-threshold: 60000 # 連線洩漏偵測 60 秒一開始每台設 150,3 台加起來 450,超過 Oracle 上限,直接出現連線拒絕錯誤。
minimum-idle = 30(約 37.5%)確保閒置期間也有常駐連線,不需要在請求突然進來時等連線建立的時間。
效果:連線等待時間從 450ms → 15ms,連線數穩定在 90(3 台共約 270),消除連線池耗盡錯誤。
策略三 & 四:快取 + Token 預取
快取策略詳見「Cache 下沉策略」,Token 預取詳見「非同步 Token 預取」。
合計效果:
- 快取命中率 78%,DB 查詢減少 78%
- Token 取得時間從 250ms 移出請求關鍵路徑
策略五:Tomcat 執行緒池調整
server:
tomcat:
threads:
max: 300 # 預設 200,升到 300
min-spare: 50 # 預留常駐執行緒
max-connections: 1000
accept-count: 200max-threads 和 maximum-pool-size 的比例建議在 3-4:1 之間。執行緒數遠大於連線數沒意義,因為大多數執行緒最終都在等 DB 連線。
效果:吞吐量提升 45%,CPU 使用率降低 20%。
策略六:加鎖反而讓效能變好
這是這次調校中最有趣的發現。對某些熱點查詢加上 synchronized 之後,整體效能反而提升了。
// DateService - 快取失效瞬間加鎖,避免重複回源
public synchronized DataDate findDataDateSynchronized() {
// 快取未命中時,只有第一個請求查 DB
// 其他請求等待結果,複用同一份資料
return dateCache.get("current", () -> repo.findCurrent());
}
// ProductService - 同一個商品 key 序列化執行
@SynchronizedLock(key = "#productKey")
public ProductSet findProdSetByKey(ProductConfigKey productKey) {
// 細粒度鎖:不同 productKey 互不影響
return productRepo.findByKey(productKey);
}為什麼加鎖會變快?
未加鎖時的高併發情境:
T=0ms:Token/快取過期,100 個請求同時到達
所有請求:快取 miss → 全部打 DB
→ DB 連線等待時間爆炸
→ 慢查詢比例急升
→ P95/P99 被拉高加鎖後:
T=0ms:100 個請求同時到達,快取 miss
第 1 個請求取到鎖 → 查 DB
第 2-100 個請求等鎖(幾 ms)
第 1 個請求完成,更新快取,釋放鎖
第 2-100 個請求依序取鎖 → 快取命中 → 直接返回
→ DB 只被打 1 次,尖峰消除什麼情況加鎖會變慢?
- 鎖粒度太粗(把不同 key 的請求都鎖在一起)
- 鎖範圍內做太多不必要的工作
- 原本就是低併發場景(加鎖只增加排隊成本)
這個專案用的是「同 key 細粒度鎖」,目的是削峰,不是序列化所有查詢。
策略七:G1GC 調校
JAVA_OPTS="
-Xms4g -Xmx4g # 固定堆大小,避免調整開銷
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # 最大停頓時間目標
-XX:G1HeapRegionSize=16m # 適合 4GB 堆的區域大小
-XX:InitiatingHeapOccupancyPercent=45 # 較早觸發 Mixed GC,避免 Full GC
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/logs/heapdump.hprof
"Xms = Xmx 是一個常被忽略的優化:若 Xms 小於 Xmx,JVM 在堆使用量增長時需要動態擴展,這個擴展過程本身有開銷,且會觸發額外的 GC。固定堆大小消除這個問題。
InitiatingHeapOccupancyPercent=45:降低觸發 Mixed GC 的閾值(預設 45%),讓 G1 更主動地回收老年代,避免累積到需要 Full GC。
效果:GC 停頓時間從 450ms → 120ms,Full GC 從每小時 3 次 → 0 次。
策略八:日誌優化
生產環境的 DEBUG 日誌是個隱性效能殺手:
// 問題:即使不輸出,字串拼接仍然執行
logger.debug("Processing account: " + acctId + ", balance: " + balance);
// 正確:延遲求值,只在 DEBUG 開啟時才執行
if (logger.isDebugEnabled()) {
logger.debug("Processing account: {}, balance: {}", acctId, balance);
}# 生產環境 log level
logging:
level:
root: INFO
com.example.queryapi: WARN # 業務 log 只保留 WARN 以上效果:日誌 I/O 減少 60%,間接降低 CPU 使用率。
最終成果
| 指標 | 優化前 | 優化後 | 改善 |
|---|---|---|---|
| TPS | 80 | 450 | +463% |
| 平均回應時間 | 850ms | 180ms | ↓ 79% |
| P95 | 2.1s | 450ms | ↓ 79% |
| P99 | 3.5s | 820ms | ↓ 77% |
| CPU 使用率 | 85% | 55% | ↓ 35% |
| DB 連線數峰值 | 180 | 穩定 90 | ↓ 50% |
| 錯誤率 | 3% | 0.05% | ↓ 98% |
幾個壓測原則
每次只改一個變數:同時改多個參數,你不知道哪個起作用了。
基準數據要保留:每次壓測的 CSV/HTML 報告都存起來,方便前後對比。
看 P95/P99,不只看平均:平均 200ms 聽起來很好,但 P99 2 秒意味著每 100 個用戶有一個在等 2 秒。
壓測環境要像生產環境:包括機器規格、資料量、網路拓撲。在只有 1 台的環境測連線池多機器計算,得到的結果毫無意義。
不要只看一個維度:TPS 提升但 CPU 飆到 95%,不算優化,只是把問題移到別的地方。