前言
電商系統有一個非常典型的流量特徵:平時風平浪靜,一到促銷活動瞬間湧入幾千上萬個並發請求。
如果你的購物車系統跑在默認的 Spring Boot 設定上,這個瞬間幾乎必定會讓系統崩潰——不是 OOM,就是資料庫連線耗盡,要不然就是 Tomcat 執行緒池打滿、請求開始逾時。
這篇文章深入剖析我們在 PR #227 中對一個 Spring Boot 購物車系統進行的高並發改造,涵蓋三個核心技術方向:
- JDK 21 Virtual Threads:讓每個請求都跑在輕量級虛擬執行緒上
- HikariCP 連線池調校:消除資料庫連線成為瓶頸的問題
- Redis 分層快取:把熱點讀取從資料庫移到記憶體
系統架構概述
先看清楚我們在改什麼。這是一個標準的 Spring Boot 電商後端:
Client (Web/App)
↓ HTTP
Spring Boot API (port 9999)
├── CartController → CartService → MySQL (cart table)
├── ProductController → ProductService → MySQL (products table)
├── OrderController → OrderService → MySQL (orders/order_items)
│ → Stripe API (付款)
├── UserController → UserService → MySQL (users)
└── AuthController → AuthenticationService → MySQL (auth_tokens)
技術棧:Spring Boot 3.2.5 / JPA + Hibernate / MySQL / Redis / Stripe
幾個重要的資料流特徵:
- 每個 API 請求都會呼叫
AuthenticationService.getUser(token)做驗證(高頻讀取) - 商品列表
ProductService.listProducts()是最高流量的端點(讀多寫少) - 加入購物車
CartService.addToCart()在促銷時有大量並發寫入 - 結帳
OrderService.placeOrder()需要跨多張表的事務操作 + 外部 Stripe API 呼叫
改造前的問題分析
讓我們先看問題在哪。以下是 Spring Boot 默認設定下,系統在高流量時的崩潰路徑:
瓶頸一:Tomcat 執行緒池
Spring Boot 預設的 Tomcat 有 200 個執行緒(server.tomcat.threads.max=200)。
每個 HTTP 請求佔用一個執行緒,執行完才釋放。但如果請求需要等待 I/O(查資料庫、呼叫 Stripe API),這個執行緒就阻塞等待,什麼都不能做。
1000 個並發請求到達
→ 200 個執行緒立刻被佔滿
→ 800 個請求在佇列中等待
→ 等待超過 timeout → 返回 503
瓶頸二:資料庫連線池耗盡
HikariCP 預設的 maximum-pool-size = 10。
200 個執行緒 → 同時需要資料庫連線
→ 只有 10 個連線可用
→ 190 個執行緒等待連線(connection timeout 預設 30 秒)
→ 全部逾時,大量請求失敗
瓶頸三:重複的高頻資料庫查詢
AuthenticationService.getUser(token) 在每一個 API 請求都會被呼叫,直接打 MySQL:
1SELECT * FROM auth_tokens WHERE token = ?
2-- 再 JOIN user 表
3SELECT * FROM users WHERE id = ?
1000 個並發請求 = 最少 2000 次相同結構的 DB 查詢。
方案一:JDK 21 Virtual Threads
原理
傳統的 Platform Thread(平台執行緒)是 OS 執行緒的 1:1 映射,每個執行緒預設佔 1MB 的 Stack 記憶體,且當 I/O 阻塞時,OS 執行緒也跟著阻塞。
JDK 21 引入的 Virtual Threads(虛擬執行緒)是 JVM 管理的輕量執行緒:
- 初始 Stack 只有幾 KB,可以動態擴展
- 當遇到 I/O 阻塞(JDBC、HTTP client、Sleep),JVM 自動把虛擬執行緒卸載,把底層平台執行緒讓給其他任務
- I/O 完成後,再把虛擬執行緒重新掛載到任何可用的平台執行緒上繼續執行
Virtual Thread 遇到 JDBC 等待:
JVM: 把這個 vthread 的狀態存起來,把平台執行緒還給 carrier pool
→ 繼續執行其他 vthread
JDBC 完成: 重新掛載 vthread → 繼續執行
這讓你可以用幾百萬個虛擬執行緒處理並發請求,而不需要佔用幾百萬個 OS 執行緒。
啟用方式
Spring Boot 3.2+ 整合了 JDK 21 Virtual Threads,只需一行設定:
1# application.properties
2# Approach 1 — Virtual Threads (JDK 21 + Spring Boot 3.2)
3# REQUIRES Java 21. Each HTTP request gets a lightweight virtual thread.
4spring.threads.virtual.enabled=true
Spring Boot 會自動把 Tomcat 的執行緒池換成 Virtual Thread executor,每個請求都跑在自己的虛擬執行緒上,不再受 Tomcat 執行緒數限制。
為什麼購物車系統特別適合 Virtual Threads?
購物車的大多數操作都是 I/O 密集型:
addToCart 流程:
1. 驗證 token → JDBC 查詢(I/O)
2. 查商品 → JDBC 查詢(I/O)
3. 儲存 Cart → JDBC 寫入(I/O)
placeOrder 流程:
1. 驗證 token → JDBC(I/O)
2. 查購物車 → JDBC(I/O)
3. 儲存 Order → JDBC(I/O)
4. 呼叫 Stripe API → HTTP(I/O,最慢!幾百毫秒)
5. 清空購物車 → JDBC(I/O)
Virtual Thread 在所有這些 I/O 等待點都能把平台執行緒讓出,效率極高。
注意事項
Virtual Threads 不適合 CPU 密集型任務(例如圖片處理、加密運算),這些工作用傳統的固定執行緒池更合適。此外,舊版 MySQL connector(mysql-connector-java)有些 native 方法不支援 Virtual Thread 的 pinning unpark,需要升級到 mysql-connector-j。
方案二:HikariCP 連線池調校
問題根源
即使有 Virtual Threads,資料庫連線依然是有限資源。一個 Virtual Thread 在等 DB 連線時,仍然是被掛起等待,只是不浪費平台執行緒了。
但如果連線池只有 10 個連線,1000 個並發請求還是要排隊等待,造成延遲堆積。
配置調整
1# application.properties
2# Approach 2 — HikariCP connection pool tuning
3# Default pool (10) is the first bottleneck under concurrency.
4# Keep total connections < MySQL max_connections (default 151).
5spring.datasource.hikari.maximum-pool-size=50
6spring.datasource.hikari.minimum-idle=10
7spring.datasource.hikari.connection-timeout=3000 # 等待連線最多 3 秒
8spring.datasource.hikari.idle-timeout=600000 # 閒置 10 分鐘後關閉
9spring.datasource.hikari.max-lifetime=1800000 # 連線最多存活 30 分鐘
10spring.datasource.hikari.pool-name=ShoppingCartHikariPool
為什麼是 50,不是 200?
這是一個常見的誤解:把 pool size 調得越大越好。
錯誤:maximum-pool-size=500
MySQL 有一個硬限制:max_connections,預設是 151。超過這個數字,MySQL 會直接拒絕連線請求。
還有一個更深層的問題:MySQL 處理每個連線是用一個 OS 執行緒。連線數太多,OS 執行緒的切換開銷(context switch)反而比「多等一點」還要貴。
HikariCP 的官方建議(Pool Sizing for HikariCP):
最佳 pool size ≈ 核心數 × 2 + 有效的磁碟並行數
對一台 8 核的應用伺服器,理論最佳值大約是 17-20。我們設定 50 是因為:
- 考慮到多個應用實例共用 MySQL(
50 × n_instances < 151) - 購物車的查詢通常很短,連線快速釋放
- 保留一定緩衝給 DBA 工具和監控系統使用
搭配 Virtual Threads 的效果
Virtual Threads 的作用:讓平台執行緒不被 I/O 阻塞
HikariCP 的作用:確保有足夠的 DB 連線可用
兩者配合:
1000 個請求 → 1000 個 Virtual Threads
→ 同時有 50 個在執行 DB 操作(有連線)
→ 其餘 950 個等待連線時被 suspend(不佔平台執行緒)
→ 連線釋放 → 下一個 vthread 繼續
→ 整個過程中,平台執行緒幾乎不閒著
方案三:Redis 分層快取
設計思路
不是所有資料都要快取。我們根據三個維度來決定:
| 資料 | 讀取頻率 | 變化頻率 | 快取策略 |
|---|---|---|---|
| Token → User 映射 | 極高(每個請求都要) | 低(用戶重新登入時) | 快取,15 分鐘 TTL |
| 商品列表 | 高(首頁、搜尋) | 低(上下架時) | 快取,5 分鐘 TTL |
| 分類列表 | 高(每頁都需要) | 極低(幾乎不變) | 快取,30 分鐘 TTL |
| 購物車內容 | 中 | 高(用戶每次操作) | 不快取 |
| 訂單 | 低 | 低 | 不快取 |
RedisConfig 深入解析
1@Configuration
2public class RedisConfig {
3
4 public static final String CACHE_TOKENS = "tokens";
5 public static final String CACHE_PRODUCTS = "products";
6 public static final String CACHE_CATEGORIES = "categories";
7
8 @Bean
9 public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
10 ObjectMapper mapper = new ObjectMapper();
11 mapper.findAndRegisterModules(); // 自動載入 JavaTimeModule 等
12
13 // ⚠️ 關鍵安全設定(詳見後文)
14 PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
15 .allowIfSubType("com.yen.ShoppingCart")
16 .allowIfSubType("java.util")
17 .allowIfSubType("java.lang")
18 .build();
19
20 mapper.activateDefaultTyping(ptv,
21 ObjectMapper.DefaultTyping.NON_FINAL,
22 JsonTypeInfo.As.PROPERTY);
23
24 GenericJackson2JsonRedisSerializer jsonSerializer =
25 new GenericJackson2JsonRedisSerializer(mapper);
26
27 // 預設配置:Key 用字串序列化,Value 用 JSON
28 RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
29 .serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer()))
30 .serializeValuesWith(SerializationPair.fromSerializer(jsonSerializer))
31 .disableCachingNullValues();
32
33 // 每個 cache 有獨立的 TTL
34 Map<String, RedisCacheConfiguration> perCacheTtl = new HashMap<>();
35 perCacheTtl.put(CACHE_TOKENS, defaults.entryTtl(Duration.ofMinutes(15)));
36 perCacheTtl.put(CACHE_PRODUCTS, defaults.entryTtl(Duration.ofMinutes(5)));
37 perCacheTtl.put(CACHE_CATEGORIES, defaults.entryTtl(Duration.ofMinutes(30)));
38
39 return RedisCacheManager.builder(connectionFactory)
40 .cacheDefaults(defaults.entryTtl(Duration.ofMinutes(10)))
41 .withInitialCacheConfigurations(perCacheTtl)
42 .build();
43 }
44}
@Cacheable 和 @CacheEvict 的實際運作
AuthenticationService — Token 快取(最高頻):
1// 每個請求都呼叫這個方法驗證身分
2@Cacheable(
3 value = RedisConfig.CACHE_TOKENS,
4 key = "#token",
5 unless = "#result == null" // null 不快取,避免快取「無效 token」
6)
7public User getUser(String token) {
8 // 第一次呼叫:查 DB,結果存入 Redis(key = tokens::<token_value>)
9 // 後續呼叫:直接從 Redis 返回,不碰 DB
10 AuthenticationToken authToken = repository.findTokenByToken(token);
11 if (Helper.notNull(authToken) && Helper.notNull(authToken.getUser())) {
12 return authToken.getUser();
13 }
14 return null;
15}
16
17// 用戶重新登入時呼叫,必須讓舊 token 的快取失效
18@CacheEvict(value = RedisConfig.CACHE_TOKENS, key = "#authenticationToken.token")
19public void saveConfirmationToken(AuthenticationToken authenticationToken) {
20 repository.save(authenticationToken);
21}
快取後的流程:
1000 個並發請求(帶同一個 token)
沒有快取:
→ 1000 次 DB 查詢(tokens 表 + users 表 JOIN)
→ MySQL 承受 2000 次查詢
有 Redis 快取:
→ 第 1 次:查 DB,存入 Redis
→ 第 2-1000 次:直接讀 Redis(微秒級)
→ MySQL:只有 1 次查詢
ProductService — 商品列表快取(含 Cache Evict 聯動):
1// 讀取:有快取就直接回傳,沒有才查 DB 並存入快取
2@Cacheable(value = RedisConfig.CACHE_PRODUCTS, key = "'all'")
3public List<ProductDto> listProducts() {
4 List<Product> products = productRepository.findAll();
5 // ... 轉換為 DTO
6 return productDtos;
7}
8
9// 新增商品:必須使快取失效,否則下次讀到的是舊資料
10@CacheEvict(value = RedisConfig.CACHE_PRODUCTS, allEntries = true)
11public void addProduct(ProductDto productDto, Category category) {
12 productRepository.save(getProductFromDto(productDto, category));
13}
14
15// 更新商品:同樣要 evict
16@CacheEvict(value = RedisConfig.CACHE_PRODUCTS, allEntries = true)
17public void updateProduct(Integer productID, ProductDto productDto, Category category) {
18 Product product = getProductFromDto(productDto, category);
19 product.setId(productID);
20 productRepository.save(product);
21}
allEntries = true 的用法:因為我們用 key = "'all'" 把整個列表存成一個快取項目,evict 時也要把這個 key 清掉。如果是按 ID 快取個別商品,則可以用 key = "#productId" 精確 evict。
⚠️ 重要的安全漏洞修復
原始程式碼使用 LaissezFaireSubTypeValidator,這是一個嚴重的安全漏洞:
1// ❌ 舊的不安全設定
2mapper.enableDefaultTyping(LaissezFaireSubTypeValidator.instance, ...);
3// LaissezFaireSubTypeValidator 接受任意 Java 類型反序列化
4// 攻擊者可以在 Redis value 中注入惡意的 gadget chain,觸發 RCE
修復後的設定,使用白名單限制可反序列化的型別:
1// ✅ 安全的設定:只允許我們自己的 package 和 JDK 標準類型
2PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
3 .allowIfSubType("com.yen.ShoppingCart") // 我們的 domain 類型
4 .allowIfSubType("java.util") // List, Map, etc.
5 .allowIfSubType("java.lang") // String, Integer, etc.
6 .build();
7
8mapper.activateDefaultTyping(ptv,
9 ObjectMapper.DefaultTyping.NON_FINAL,
10 JsonTypeInfo.As.PROPERTY);
這個漏洞在 Jackson 2.x 的文件中有明確警告,但很多舊項目仍在使用不安全的設定。任何把 Java 物件序列化到 Redis 的系統都應該檢查這一點。
Spring Boot 2.x → 3.x 升級的關鍵變化
這次升級從 Spring Boot 2.4.5 到 3.2.5,有幾個必須處理的 breaking change:
javax.* → jakarta.* 命名空間遷移
Spring Boot 3.x 採用 Jakarta EE 9+,所有 javax.* import 必須改成 jakarta.*:
1// ❌ 舊(Spring Boot 2.x)
2import javax.persistence.Entity;
3import javax.persistence.Table;
4import javax.transaction.Transactional;
5import javax.validation.constraints.NotNull;
6
7// ✅ 新(Spring Boot 3.x)
8import jakarta.persistence.Entity;
9import jakarta.persistence.Table;
10import jakarta.transaction.Transactional;
11import jakarta.validation.constraints.NotNull;
這個改動遍及所有 Model 類別、Service 和 Repository。
Validation 變成需要明確引入
Spring Boot 3 預設不再包含 Bean Validation,需要在 pom.xml 明確加入:
1<dependency>
2 <groupId>org.springframework.boot</groupId>
3 <artifactId>spring-boot-starter-validation</artifactId>
4</dependency>
MySQL connector 名稱更換
1<!-- ❌ 舊 -->
2<dependency>
3 <groupId>mysql</groupId>
4 <artifactId>mysql-connector-java</artifactId>
5</dependency>
6
7<!-- ✅ 新 -->
8<dependency>
9 <groupId>com.mysql</groupId>
10 <artifactId>mysql-connector-j</artifactId>
11</dependency>
Swagger 套件更換
1<!-- ❌ 舊:springfox(已停止維護,不支援 Spring Boot 3) -->
2<dependency>
3 <groupId>io.springfox</groupId>
4 <artifactId>springfox-boot-starter</artifactId>
5 <version>3.0.0</version>
6</dependency>
7
8<!-- ✅ 新:springdoc-openapi -->
9<dependency>
10 <groupId>org.springdoc</groupId>
11 <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
12 <version>2.3.0</version>
13</dependency>
快取整合測試策略
快取邏輯很容易寫錯(例如 evict 沒有正確觸發),但不需要真的跑 Redis 才能測試。我們用 ConcurrentMapCacheManager 替代:
1@SpringJUnitConfig(ProductServiceCacheTest.TestConfig.class)
2class ProductServiceCacheTest {
3
4 @EnableCaching
5 @Configuration
6 static class TestConfig {
7
8 @Bean
9 public CacheManager cacheManager() {
10 // 用 in-memory 實作取代 Redis,不需要 Redis server
11 return new ConcurrentMapCacheManager(
12 RedisConfig.CACHE_TOKENS,
13 RedisConfig.CACHE_PRODUCTS,
14 RedisConfig.CACHE_CATEGORIES
15 );
16 }
17
18 @Bean
19 public ProductRepository productRepository() {
20 return Mockito.mock(ProductRepository.class); // Mock DB
21 }
22
23 @Bean
24 public ProductService productService() {
25 return new ProductService();
26 }
27 }
28
29 @Test
30 void listProducts_secondCall_shouldHitCacheNotRepo() {
31 when(productRepository.findAll()).thenReturn(List.of(product1, product2));
32
33 productService.listProducts(); // 第一次:查 DB
34 productService.listProducts(); // 第二次:應該走快取
35
36 // 關鍵驗證:DB 只被呼叫了一次
37 verify(productRepository, times(1)).findAll();
38 }
39
40 @Test
41 void addProduct_shouldEvictProductCache() {
42 when(productRepository.findAll()).thenReturn(List.of(product1));
43 productService.listProducts(); // 暖快取
44 verify(productRepository, times(1)).findAll();
45
46 productService.addProduct(productDto, category); // 觸發 evict
47
48 when(productRepository.findAll()).thenReturn(List.of(product1, product2));
49 List<ProductDto> afterAdd = productService.listProducts(); // 應重新查 DB
50
51 verify(productRepository, times(2)).findAll(); // 被呼叫了兩次
52 assertEquals(2, afterAdd.size());
53 }
54}
這種測試方式:
- 快:沒有 I/O,毫秒級
- 隔離:不依賴任何外部服務
- 精準:直接驗證快取行為,而不是功能行為
三個方案的效果與適用條件
方案比較
| 方案 | 解決的瓶頸 | 實作成本 | 效果 | 適用場景 |
|---|---|---|---|---|
| Virtual Threads | Tomcat 執行緒池耗盡 | 低(一行設定) | 高(I/O 密集) | JDK 21 + Spring Boot 3.2+ |
| HikariCP 調校 | DB 連線池耗盡 | 低(幾行設定) | 中高 | 所有場景 |
| Redis 快取 | 重複 DB 查詢 | 中(需要 Redis、設計快取策略) | 極高(對熱點讀取) | 讀多寫少的資料 |
什麼時候不該快取?
購物車內容(cart 表)刻意不快取,原因:
- 一致性要求高:用戶加入商品後,下一個請求必須看到最新的購物車
- 寫入頻率高:每次 addToCart、updateCartItem、deleteCartItem 都需要 evict,快取命中率極低
- 資料量小:每個用戶的購物車通常只有幾個商品,查 DB 很快
快取的本質是用一致性換取效能。如果資料一致性要求高於可接受的 staleness,就不應該快取。
生產環境的進一步優化方向
這次 PR 建立了高並發的基礎,但還有幾個進階方向值得繼續探索:
1. 庫存超賣問題(分散式鎖)
目前的 addToCart 沒有庫存控制。如果商品有數量限制,需要引入 Redis 分散式鎖:
1// 概念示意:使用 Redisson 分散式鎖防止超賣
2public void addToCartWithStockCheck(AddToCartDto dto, Product product, User user) {
3 String lockKey = "stock:lock:" + product.getId();
4 RLock lock = redissonClient.getLock(lockKey);
5 try {
6 if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
7 // 在鎖保護下檢查並扣減庫存
8 int currentStock = getStock(product.getId());
9 if (currentStock < dto.getQuantity()) {
10 throw new InsufficientStockException("庫存不足");
11 }
12 decrementStock(product.getId(), dto.getQuantity());
13 cartRepository.save(new Cart(product, dto.getQuantity(), user));
14 }
15 } finally {
16 if (lock.isHeldByCurrentThread()) lock.unlock();
17 }
18}
2. 非同步訂單處理(消息佇列)
placeOrder 呼叫 Stripe API(可能需要幾百毫秒),並同步清空購物車。在高流量下,可以把這個流程改成非同步:
用戶確認購買
→ API 立刻回應「訂單建立中」(Order status: PENDING)
→ 把訂單事件放入 Kafka / RabbitMQ
→ 背景 Consumer 非同步處理:呼叫 Stripe、更新庫存、清空購物車
→ 完成後推送通知給用戶
3. 讀寫分離
當寫入壓力增加,可以配置 MySQL 主從複製,讀請求走 Replica,寫請求走 Primary:
1# 概念設定(spring.datasource.routing)
2datasources:
3 primary: jdbc:mysql://primary-host:3306/shopping_cart
4 replica: jdbc:mysql://replica-host:3306/shopping_cart
4. 快取一致性的進階處理
目前的 @CacheEvict 是同步的。如果有多個應用實例,某個實例 evict 了快取,其他實例的本地快取(如果有的話)不會立刻失效。Redis 是中央快取,這個問題已經解決,但如果未來加入 Caffeine 等本地快取層,需要考慮 Cache Invalidation 機制(例如用 Redis Pub/Sub 通知其他實例)。
小結
這次改造的核心教訓:
沒有銀彈,高並發需要系統性地消除每一層的瓶頸。
Tomcat 執行緒層:Virtual Threads 讓 I/O 等待不再阻塞平台執行緒
↓
資料庫連線層:HikariCP 調校確保有足夠連線,但不超過 DB 的承載能力
↓
資料庫查詢層:Redis 快取把高頻讀取攔截在記憶體,不讓 DB 承受重複查詢
三個方案有各自的責任邊界,缺一不可。而且每個方案的代價都很低:Virtual Threads 是一行設定,HikariCP 是幾個數字,Redis 快取是幾個 annotation。
最難的部分不是程式碼,而是知道什麼該快取、什麼不該快取,以及理解系統在高流量下的真實崩潰路徑。
完整程式碼見:PR #227 - ShoppingCart-dev-008-high-concurrency
系列導覽
- 第一篇(本篇):Virtual Threads、HikariCP、Redis 快取
- 第二篇:Redisson 分散式鎖、讀寫分離路由、水平擴展與 Docker HA