前言
第一篇解決了系統的吞吐量問題:Virtual Threads 讓執行緒不再阻塞在 I/O 上,HikariCP 調校讓資料庫連線不再是瓶頸,Redis 快取讓熱點讀取從記憶體直接回傳。
但吞吐量提升後,反而暴露出一個更深層的問題:
並發請求同時寫入同一份資料,怎麼保證正確性?
想像雙十一活動開始的那一秒,同一個用戶的兩個 Tab 同時按下「加入購物車」;或者同一個人網路不穩,重試了兩次「立即結帳」——這兩種場景都會導致重複訂單或資料不一致。
這篇介紹 PR #228 帶來的三個進階改造:
- Redisson 分散式鎖:防止同一用戶的並發寫入互相干擾
- 讀寫分離路由:把讀請求分流到 Replica,Primary 只處理寫入
- Docker HA 水平擴展:Nginx + 多個 App 實例 + MySQL 主從複製
方案四:Redisson 分散式鎖
為什麼需要分散式鎖?
第一篇的 CartService.addToCart() 在高並發下有一個隱患:
1// Part 1 的版本(沒有鎖)
2public void addToCart(AddToCartDto addToCartDto, Product product, User user) {
3 Cart cart = new Cart(product, addToCartDto.getQuantity(), user);
4 cartRepository.save(cart); // ← 多個執行緒可能同時執行這一行
5}
當同一個用戶同時發出兩個「加入購物車」請求時:
Thread A: new Cart(productX, qty=1, user) → save → 購物車有 1 個 productX
Thread B: new Cart(productX, qty=1, user) → save → 購物車有 2 個 productX(重複!)
cartRepository.save() 是 INSERT,沒有天然的唯一性保護。兩個並發的 INSERT 都會成功,導致購物車重複項目。
OrderService.placeOrder() 的問題更嚴重:
Thread A 和 Thread B 同時呼叫 placeOrder:
A: listCartItems → 看到 [iPhone, AirPods]
B: listCartItems → 看到 [iPhone, AirPods] ← 還沒被 A 清掉
A: 建立 Order,清空購物車
B: 建立 Order(相同商品!),清空購物車(已經空了,沒有報錯)
結果:兩張一模一樣的訂單
分散式鎖的基本原則
本地的 synchronized 或 ReentrantLock 只能保護同一個 JVM 內的並發。在水平擴展(多個 App 實例)的環境下,不同實例各有自己的鎖,完全無效。
分散式鎖需要一個所有實例共享的外部儲存來協調,Redis 是最常見的選擇。
RedissonConfig 設定
1@Configuration
2public class RedissonConfig {
3
4 @Value("${spring.data.redis.host:localhost}")
5 private String redisHost;
6
7 @Value("${spring.data.redis.port:6379}")
8 private int redisPort;
9
10 @Bean(destroyMethod = "shutdown")
11 public RedissonClient redissonClient() {
12 Config config = new Config();
13 config.useSingleServer()
14 .setAddress("redis://" + redisHost + ":" + redisPort)
15 .setConnectionMinimumIdleSize(4)
16 .setConnectionPoolSize(64); // 從 10 提升到 64,避免高並發下 Redis 連線池本身成為瓶頸
17 return Redisson.create(config);
18 }
19}
連線池從預設的 10 提升到 64,理由和 HikariCP 類似:在高並發場景下,Redisson 需要同時持有大量連線來處理鎖的取得、心跳(watchdog)、以及快取操作。
CartService:加入購物車的鎖
1@Slf4j
2@Service
3@Transactional
4public class CartService {
5
6 @Autowired
7 private CartRepository cartRepository;
8
9 @Autowired
10 private RedissonClient redissonClient;
11
12 public void addToCart(AddToCartDto addToCartDto, Product product, User user) {
13
14 // 鎖的粒度:per-user,而不是全局鎖
15 // 不同用戶的操作互不干擾,可以完全並行
16 RLock lock = redissonClient.getLock("cart:user:" + user.getId());
17 try {
18 // tryLock 而非 lock():
19 // waitTime=3s:最多等 3 秒,等不到就直接拋例外(fail-fast)
20 // leaseTime=10s:鎖最多持有 10 秒,即使 JVM crash 也會自動釋放
21 if (!lock.tryLock(3, 10, TimeUnit.SECONDS)) {
22 throw new RuntimeException(
23 "Could not acquire cart lock for user " + user.getId()
24 + " — another request is already updating this cart");
25 }
26 Cart cart = new Cart(product, addToCartDto.getQuantity(), user);
27 cartRepository.save(cart);
28 } catch (InterruptedException e) {
29 Thread.currentThread().interrupt();
30 throw new RuntimeException("Interrupted while acquiring cart lock", e);
31 } finally {
32 // isHeldByCurrentThread() 防止 ABA 問題:
33 // 如果 leaseTime 到了,鎖可能已被其他執行緒取得,此時不應 unlock
34 if (lock.isHeldByCurrentThread()) lock.unlock();
35 }
36 }
37
38 // ... 其他方法
39}
關鍵設計決策:tryLock 而非 lock
lock() 是阻塞等待,直到獲得鎖為止。在 Redis 延遲偶發性升高時(網路抖動、Redis GC),可能有數百個執行緒同時阻塞在 lock() 等待,迅速耗盡執行緒池。
tryLock(waitTime=3s) 最多等 3 秒。如果 3 秒內拿不到鎖,立刻失敗並回傳錯誤。這是fail-fast 策略:對用戶的影響是看到一個錯誤訊息,但系統的整體可用性不受影響。
情境:Redis 短暫延遲,100 個請求同時加入購物車
lock() 行為:
100 個執行緒全部阻塞等待 Redis 回應
→ 執行緒池耗盡 → 其他請求(包括讀取操作)也開始 timeout
tryLock(3s) 行為:
每個請求等最多 3 秒
→ 3 秒後,還沒拿到鎖的請求直接失敗
→ 執行緒快速釋放 → 系統整體可用性維持
鎖的粒度:per-user,不是 per-product
鎖的粒度是一個關鍵設計選擇:
全局鎖:一次只有一個操作,吞吐量極低
per-product 鎖:防止對同一商品的並發操作,但不能防止同一用戶的重複提交
per-user 鎖:防止同一用戶的並發操作,不同用戶完全並行 ← 選這個
對購物車場景,問題的根源是「同一用戶」的並發請求,所以 per-user 鎖是最合適的粒度。
OrderService:結帳的鎖(更複雜的設計)
placeOrder 的鎖設計有一個關鍵的難點:鎖和事務的交互順序。
錯誤設計:鎖在事務內部
1// ❌ 錯誤:鎖在事務內部取得
2@Transactional
3public void placeOrder(User user, String sessionId) {
4 RLock lock = redissonClient.getLock("order:user:" + user.getId());
5 lock.lock();
6 try {
7 // ... 業務邏輯
8 } finally {
9 lock.unlock(); // 鎖先釋放
10 }
11 // @Transactional 在方法結束後 commit → 問題!
12}
問題:unlock() 在 finally 執行,但 @Transactional 的 commit 在方法結束後才發生。這意味著:
Thread A: unlock() → 鎖釋放
Thread B: 立刻取得鎖 → 讀到 A 還未 commit 的資料(舊資料)→ 重複下單
正確設計:鎖在事務外部
1// ✅ 正確:鎖包在事務外面
2@Service // 注意:OrderService 類別本身沒有 @Transactional
3public class OrderService {
4
5 // 1. 鎖的取得:在事務開始之前
6 // 2. doPlaceOrder 執行(含事務 commit)
7 // 3. 鎖釋放:在事務 commit 之後
8 public void placeOrder(User user, String sessionId) {
9
10 RLock lock = redissonClient.getLock("order:user:" + user.getId());
11 try {
12 if (!lock.tryLock(3, 30, TimeUnit.SECONDS)) {
13 throw new RuntimeException(
14 "Could not acquire order lock for user " + user.getId()
15 + " — a concurrent checkout is already in progress");
16 }
17 doPlaceOrder(user, sessionId); // 事務在這裡 commit
18 } catch (InterruptedException e) {
19 Thread.currentThread().interrupt();
20 throw new RuntimeException("Interrupted while acquiring order lock", e);
21 } finally {
22 if (lock.isHeldByCurrentThread()) lock.unlock(); // commit 之後才釋放
23 }
24 }
25
26 // 實際的業務邏輯放在一個有 @Transactional 的 package-private 方法
27 @Transactional
28 void doPlaceOrder(User user, String sessionId) {
29
30 CartDto cartDto = cartService.listCartItems(user);
31 List<CartItemDto> cartItemDtoList = cartDto.getCartItems();
32
33 // 冪等性防護:
34 // 場景:A 和 B 同時呼叫 placeOrder,A 先拿到鎖並完成下單,清空購物車
35 // B 拿到鎖後,購物車已空 → 不應該建立一張空訂單
36 if (cartItemDtoList.isEmpty()) {
37 log.warn("placeOrder called with empty cart for user {} — skipping (possible duplicate request)",
38 user.getId());
39 return;
40 }
41
42 // 建立訂單、寫入訂單項目、清空購物車...
43 Order newOrder = new Order();
44 newOrder.setCreatedDate(new Date());
45 newOrder.setSessionId(sessionId);
46 newOrder.setUser(user);
47 newOrder.setTotalPrice(cartDto.getTotalCost());
48 orderRepository.save(newOrder);
49
50 for (CartItemDto cartItemDto : cartItemDtoList) {
51 OrderItem orderItem = new OrderItem();
52 orderItem.setCreatedDate(new Date());
53 orderItem.setPrice(cartItemDto.getProduct().getPrice());
54 orderItem.setProduct(cartItemDto.getProduct());
55 orderItem.setQuantity(cartItemDto.getQuantity());
56 orderItem.setOrder(newOrder);
57 orderItemsRepository.save(orderItem);
58 }
59
60 cartService.deleteUserCartItems(user);
61 // ← 方法結束,@Transactional commit 在這裡發生
62 }
63
64 // 讀取操作標記 readOnly=true,讓讀寫分離路由把這些查詢導向 Replica
65 @Transactional(readOnly = true)
66 public List<Order> listOrders(User user) {
67 return orderRepository.findAllByUserOrderByCreatedDateDesc(user);
68 }
69
70 @Transactional(readOnly = true)
71 public Order getOrder(Integer orderId) throws OrderNotFoundException {
72 Optional<Order> order = orderRepository.findById(orderId);
73 if (order.isPresent()) return order.get();
74 throw new OrderNotFoundException("Order not found");
75 }
76}
正確的執行順序:
Thread A 呼叫 placeOrder:
1. tryLock("order:user:7") → 成功,取得鎖
2. doPlaceOrder() 開始執行(Spring 開始事務)
3. 讀取購物車 → 有商品
4. 建立訂單、清空購物車
5. doPlaceOrder() 返回 → Spring commit 事務
6. finally: unlock() → 釋放鎖
Thread B 同時呼叫 placeOrder(在 A 持有鎖期間):
1. tryLock("order:user:7") → 等待
2. A 釋放鎖(已 commit)→ B 取得鎖
3. doPlaceOrder() 執行
4. 讀取購物車 → 已空(A 已清空)
5. 空購物車防護:log.warn + return
6. 沒有重複訂單 ✅
leaseTime=30s 的選擇
placeOrder 的 leaseTime 設為 30 秒(比 addToCart 的 10 秒長),因為它需要:
- 多次資料庫操作(讀購物車 + N 次 INSERT + 清空購物車)
- 可能的網路延遲
如果擔心 JVM crash 導致鎖永遠不釋放,Redisson 的 watchdog 機制會在 leaseTime 到期前自動續期(但只有使用 lock() 而非 tryLock(leaseTime) 時才會啟動 watchdog)。使用固定 leaseTime 的 tryLock,鎖會在 leaseTime 後自動過期,不依賴 watchdog,但需要確認業務操作能在 leaseTime 內完成。
鎖的單元測試設計
1@ExtendWith(MockitoExtension.class)
2class CartServiceLockTest {
3
4 @Mock CartRepository cartRepository;
5 @Mock RedissonClient redissonClient;
6 @Mock RLock rLock;
7
8 @InjectMocks CartService cartService;
9
10 @BeforeEach
11 void setUp() throws InterruptedException {
12 when(redissonClient.getLock(anyString())).thenReturn(rLock);
13 when(rLock.tryLock(anyLong(), anyLong(), any(TimeUnit.class))).thenReturn(true);
14 when(rLock.isHeldByCurrentThread()).thenReturn(true);
15 }
16
17 // 驗證鎖的 key 是 per-user 的
18 @Test
19 void addToCart_shouldAcquireLockWithUserScopedKey() {
20 cartService.addToCart(dto, product, user); // user.getId() = 42
21 verify(redissonClient).getLock("cart:user:42");
22 }
23
24 // 驗證 tryLock 在 save 之前呼叫(順序很重要)
25 @Test
26 void addToCart_shouldCallTryLockBeforeSave() throws InterruptedException {
27 InOrder order = inOrder(rLock, cartRepository);
28 cartService.addToCart(dto, product, user);
29 order.verify(rLock).tryLock(anyLong(), anyLong(), eq(TimeUnit.SECONDS));
30 order.verify(cartRepository).save(any(Cart.class));
31 }
32
33 // 驗證拿不到鎖時,DB 操作完全不執行
34 @Test
35 void addToCart_shouldThrow_whenTryLockFails() throws InterruptedException {
36 when(rLock.tryLock(anyLong(), anyLong(), any())).thenReturn(false);
37 assertThrows(RuntimeException.class, () -> cartService.addToCart(dto, product, user));
38 verify(cartRepository, never()).save(any()); // ← DB 完全不觸碰
39 }
40
41 // 驗證不同用戶使用不同的鎖
42 @Test
43 void addToCart_differentUsers_shouldAcquireDifferentLocks() throws InterruptedException {
44 User user2 = new User(); user2.setId(99);
45 RLock lock2 = mock(RLock.class);
46 when(redissonClient.getLock("cart:user:99")).thenReturn(lock2);
47 when(lock2.tryLock(anyLong(), anyLong(), any())).thenReturn(true);
48 when(lock2.isHeldByCurrentThread()).thenReturn(true);
49
50 cartService.addToCart(dto, product, user); // user id=42
51 cartService.addToCart(dto, product, user2); // user id=99
52
53 // 兩個不同的鎖,各自 lock/unlock,互不影響
54 verify(rLock).unlock();
55 verify(lock2).unlock();
56 }
57}
方案五:讀寫分離路由(AbstractRoutingDataSource)
為什麼需要讀寫分離?
當寫入請求(addToCart、placeOrder)被鎖序列化後,DB 的寫入壓力可控。但讀取操作(listProducts、listOrders、getUser)依然是大量並發的,這些都打在 Primary MySQL 上。
讀寫分離讓讀取請求走 Replica,寫入請求走 Primary:
- Primary 的負載大幅降低,對寫入吞吐量的影響最小化
- Replica 可以部署在不同硬體,甚至不同地區(降低讀取延遲)
- Primary 故障時,可以提升 Replica 為 Primary(HA)
DataSourceConfig 深入解析
1@Configuration
2@ConditionalOnProperty(name = "app.datasource.replica.enabled", havingValue = "true")
3public class DataSourceConfig {
4
5 static final String KEY_PRIMARY = "primary";
6 static final String KEY_REPLICA = "replica";
7
8 @Bean("primaryDataSource")
9 @ConfigurationProperties(prefix = "app.datasource.primary")
10 public DataSource primaryDataSource() {
11 return DataSourceBuilder.create().build(); // 從 app.datasource.primary.* 讀設定
12 }
13
14 @Bean("replicaDataSource")
15 @ConfigurationProperties(prefix = "app.datasource.replica")
16 public DataSource replicaDataSource() {
17 return DataSourceBuilder.create().build();
18 }
19
20 @Primary
21 @Bean
22 public DataSource dataSource(
23 @Qualifier("primaryDataSource") DataSource primary,
24 @Qualifier("replicaDataSource") DataSource replica) {
25
26 RoutingDataSource routing = new RoutingDataSource();
27 Map<Object, Object> targets = new HashMap<>();
28 targets.put(KEY_PRIMARY, primary);
29 targets.put(KEY_REPLICA, replica);
30 routing.setTargetDataSources(targets);
31 routing.setDefaultTargetDataSource(primary);
32 routing.afterPropertiesSet(); // 必須呼叫,初始化路由表
33
34 // 關鍵:用 LazyConnectionDataSourceProxy 包裝(詳見下方說明)
35 return new LazyConnectionDataSourceProxy(routing);
36 }
37
38 static class RoutingDataSource extends AbstractRoutingDataSource {
39 @Override
40 protected Object determineCurrentLookupKey() {
41 // 根據當前事務的 readOnly 標記決定路由
42 return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
43 ? KEY_REPLICA
44 : KEY_PRIMARY;
45 }
46 }
47}
@ConditionalOnProperty 的設計意圖
1# 預設不啟用讀寫分離(單機開發環境不需要 Replica)
2# app.datasource.replica.enabled=false ← 默認
3
4# 生產環境才啟用
5app.datasource.replica.enabled=true
6app.datasource.primary.jdbc-url=jdbc:mysql://primary-host:3306/shopping_cart
7app.datasource.replica.jdbc-url=jdbc:mysql://replica-host:3306/shopping_cart
用功能開關(feature flag)控制是否啟用讀寫分離,讓開發環境和生產環境使用同一份程式碼,而不需要修改任何業務邏輯。
LazyConnectionDataSourceProxy:解決路由時機的關鍵
這是這個實作中最容易踩坑的地方。
問題:Spring 的 transaction manager 何時取得 DB 連線?
沒有 LazyConnectionDataSourceProxy 的執行順序:
1. 請求進入 @Transactional 方法
2. Spring 的 TransactionManager:立刻呼叫 DataSource.getConnection()
→ 此時 TransactionSynchronizationManager.isCurrentTransactionReadOnly() = false(還沒設!)
→ 路由到 PRIMARY
3. TransactionManager 設定 readOnly 標記
4. SQL 開始執行(但已經在 PRIMARY 連線上了)
AbstractRoutingDataSource.determineCurrentLookupKey() 在 getConnection() 時被呼叫,但 readOnly 標記在 getConnection() 之後才被設定,所以路由永遠返回 PRIMARY。
解法:
有 LazyConnectionDataSourceProxy 的執行順序:
1. 請求進入 @Transactional 方法
2. TransactionManager:呼叫 DataSource.getConnection()
→ LazyConnectionDataSourceProxy 返回一個「假」的 Proxy Connection,不實際建立連線
3. TransactionManager 設定 readOnly 標記
4. 第一個 SQL 執行時,Proxy 才真正呼叫底層的 getConnection()
→ 此時 readOnly 已設定 → 路由到 REPLICA ✅
1// LazyConnectionDataSourceProxy 把真實的 getConnection() 延遲到第一個 SQL 執行時
2return new LazyConnectionDataSourceProxy(routing);
jakarta.transaction.Transactional 的坑
這是一個很容易踩到的細節。Jakarta EE 的 @Transactional 不支援 readOnly 屬性:
1// ❌ 這個不支援 readOnly — jakarta.transaction.Transactional 沒有 readOnly
2import jakarta.transaction.Transactional;
3
4@Transactional(readOnly = true) // 編譯錯誤:readOnly 不存在
5public List<Order> listOrders(User user) { ... }
6
7// ✅ 必須用 Spring 的 @Transactional
8import org.springframework.transaction.annotation.Transactional;
9
10@Transactional(readOnly = true) // 正確:Spring 版本支援 readOnly
11public List<Order> listOrders(User user) { ... }
當你在 Spring Boot 3.x 從 javax.* 遷移到 jakarta.* 時,很容易不小心把 @Transactional 也一起換成 jakarta 版本,導致讀寫分離路由失效。
路由的單元測試
1class DataSourceRoutingTest {
2
3 private Object routingDataSource;
4 private Method determineKey;
5
6 @BeforeEach
7 void setUp() throws Exception {
8 // 用反射實例化 package-private 的 RoutingDataSource 內部類別
9 Class<?> routingClass = null;
10 for (Class<?> c : DataSourceConfig.class.getDeclaredClasses()) {
11 if (c.getSimpleName().equals("RoutingDataSource")) {
12 routingClass = c;
13 break;
14 }
15 }
16 routingDataSource = routingClass.getDeclaredConstructor().newInstance();
17 determineKey = routingClass.getDeclaredMethod("determineCurrentLookupKey");
18 determineKey.setAccessible(true);
19 TransactionSynchronizationManager.initSynchronization();
20 }
21
22 @Test
23 void readOnlyTransaction_shouldRouteToReplica() throws Exception {
24 TransactionSynchronizationManager.setCurrentTransactionReadOnly(true);
25 assertEquals(DataSourceConfig.KEY_REPLICA, determineKey.invoke(routingDataSource));
26 }
27
28 @Test
29 void writeTransaction_shouldRouteToPrimary() throws Exception {
30 TransactionSynchronizationManager.setCurrentTransactionReadOnly(false);
31 assertEquals(DataSourceConfig.KEY_PRIMARY, determineKey.invoke(routingDataSource));
32 }
33
34 @Test
35 void toggleBetweenReadAndWrite_shouldSwitchCorrectly() throws Exception {
36 TransactionSynchronizationManager.setCurrentTransactionReadOnly(true);
37 assertEquals(DataSourceConfig.KEY_REPLICA, determineKey.invoke(routingDataSource));
38
39 TransactionSynchronizationManager.setCurrentTransactionReadOnly(false);
40 assertEquals(DataSourceConfig.KEY_PRIMARY, determineKey.invoke(routingDataSource));
41 }
42}
這個測試直接操作 TransactionSynchronizationManager,驗證路由邏輯本身,不需要任何資料庫或 Spring Context。
方案七:Docker HA 水平擴展
生產架構設計
Internet
↓
Nginx (反向代理)
/ | \
App Instance 1 App 2 App 3
\ | /
Redis (快取 + 鎖)
↓
MySQL Primary (讀寫)
↓
MySQL Replica (唯讀)
docker-compose-ha.yml 架構
1services:
2 # Nginx 作為 L7 負載均衡器
3 nginx:
4 image: nginx:alpine
5 ports:
6 - "80:80"
7 volumes:
8 - ./nginx.conf:/etc/nginx/nginx.conf:ro
9 depends_on:
10 - app1
11 - app2
12
13 # 多個 App 實例(水平擴展)
14 app1:
15 build: .
16 environment:
17 - SPRING_PROFILES_ACTIVE=docker
18 - APP_DATASOURCE_REPLICA_ENABLED=true
19 - APP_DATASOURCE_PRIMARY_JDBC_URL=jdbc:mysql://mysql-primary:3306/shopping_cart
20 - APP_DATASOURCE_REPLICA_JDBC_URL=jdbc:mysql://mysql-replica:3306/shopping_cart
21 - SPRING_DATA_REDIS_HOST=redis
22
23 app2:
24 build: .
25 environment:
26 - SPRING_PROFILES_ACTIVE=docker
27 # ... 相同設定
28
29 # Redis(單節點,生產環境考慮 Redis Sentinel 或 Cluster)
30 redis:
31 image: redis:7-alpine
32 command: redis-server --save 60 1 --loglevel warning
33
34 # MySQL Primary(接受讀寫)
35 mysql-primary:
36 image: mysql:8.0
37 environment:
38 MYSQL_ROOT_PASSWORD: rootpass
39 MYSQL_DATABASE: shopping_cart
40 command:
41 - --server-id=1
42 - --log-bin=mysql-bin
43 - --binlog-format=ROW
44 - --gtid-mode=ON # 啟用 GTID 複製,比傳統 binlog position 更可靠
45 - --enforce-gtid-consistency=ON
46
47 # MySQL Replica(唯讀,只接受從 Primary 複製的寫入)
48 mysql-replica:
49 image: mysql:8.0
50 environment:
51 MYSQL_ROOT_PASSWORD: rootpass
52 MYSQL_DATABASE: shopping_cart
53 command:
54 - --server-id=2
55 - --read-only=ON # 拒絕直接的寫入請求
56 - --gtid-mode=ON
57 - --enforce-gtid-consistency=ON
Nginx 設定
1upstream shopping_cart_backend {
2 least_conn; # 把請求導到連線數最少的實例(比 round-robin 更均勻)
3 server app1:9999;
4 server app2:9999;
5 keepalive 32; # 保持 32 個長連線,避免每次請求都重新 TCP 握手
6}
7
8server {
9 listen 80;
10
11 # 限速:每個 IP 每秒最多 100 個請求(防止單一 IP 的惡意攻擊或 bug 導致的請求風暴)
12 limit_req_zone $binary_remote_addr zone=api_limit:10m rate=100r/s;
13 limit_req zone=api_limit burst=200 nodelay;
14
15 location / {
16 proxy_pass http://shopping_cart_backend;
17 proxy_http_version 1.1;
18 proxy_set_header Connection ""; # 支援 HTTP/1.1 keepalive
19 proxy_set_header Host $host;
20 proxy_set_header X-Real-IP $remote_addr; # 傳遞真實 IP(用於日誌和限速)
21 proxy_read_timeout 30s;
22 }
23}
Session 一致性:無狀態設計的重要性
當有多個 App 實例時,用戶的同一個 Session 可能打到不同的實例。如果 Session 存在本地記憶體(默認設定),就會出現:
請求 1 → App 1:登入成功,Session 存在 App 1 記憶體
請求 2 → App 2(Nginx 負載均衡):找不到 Session → 要求重新登入
這個系統用 Token(JWT-like 的 auth_token)做驗證,Token 存在 MySQL 並快取在 Redis,沒有本地 Session,天然支援水平擴展。這是微服務和容器化部署的基本前提。
多階段 Docker Build
1# Stage 1:用 Maven 編譯
2FROM maven:3.9-eclipse-temurin-17 AS builder
3WORKDIR /build
4COPY pom.xml .
5RUN mvn dependency:go-offline -q # 預先下載依賴(利用 Docker layer cache)
6COPY src ./src
7RUN mvn package -DskipTests -q
8
9# Stage 2:只複製 JAR,不帶 Maven 工具和源碼
10FROM eclipse-temurin:17-jre-alpine # Alpine 映像,比 ubuntu 小 80%
11WORKDIR /app
12COPY --from=builder /build/target/*.jar app.jar
13
14# JVM 調校(針對容器環境)
15ENV JAVA_OPTS="-XX:+UseContainerSupport \
16 -XX:MaxRAMPercentage=75.0 \
17 -XX:+ExitOnOutOfMemoryError"
18# UseContainerSupport:JVM 讀取 Docker 的 cgroup 限制,而非主機記憶體
19# MaxRAMPercentage:使用容器最大記憶體的 75%(剩餘給 OS 和 off-heap)
20# ExitOnOutOfMemoryError:OOM 時讓 container 重啟,而不是讓 JVM 在殭屍狀態繼續跑
21
22ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
三個方案的整合視角
把 Part 1 和 Part 2 的所有方案放在一起看:
用戶請求
↓
Nginx(限速 + 負載均衡)
↓
App Instance(Virtual Threads 處理 I/O 密集操作)
├── 讀取操作
│ ├── Redis 快取命中 → 直接回傳(微秒)
│ └── 快取未命中
│ ├── @Transactional(readOnly=true)
│ └── LazyProxy + RoutingDataSource → MySQL Replica
│
└── 寫入操作
├── Redisson 分散式鎖(per-user,fail-fast)
│ └── tryLock 成功 → 繼續
│ tryLock 失敗 → 立刻返回錯誤(保護執行緒池)
├── @Transactional → MySQL Primary(HikariCP pool size=50)
└── Commit 後釋放鎖 + 同步更新 Redis 快取(@CacheEvict)
各層故障的影響分析
| 元件故障 | 影響範圍 | 降級策略 |
|---|---|---|
| Nginx 單點 | 全站不可用 | 多 Nginx 實例 + VIP(Keepalived) |
| App 一個實例 | Nginx 自動將流量切到其他實例 | 健康檢查 + 自動剔除 |
| Redis 故障 | 快取失效(讀壓力轉到 DB)、鎖失效(可能重複下單) | Redis Sentinel / Cluster |
| MySQL Replica 故障 | 讀取全走 Primary,負載增加 | @ConditionalOnProperty 切回單一 DataSource |
| MySQL Primary 故障 | 寫入不可用 | 手動或自動 promote Replica |
小結:兩篇的演進路徑
出發點:默認的 Spring Boot + MySQL
↓ PR #227
方案 1:Virtual Threads(吞吐量)
方案 2:HikariCP 調校(連線池)
方案 3:Redis 快取(熱點讀取)
↓ PR #228
方案 4:Redisson 分散式鎖(寫入正確性)
方案 5:讀寫分離路由(讀取擴展性)
方案 7:Docker HA(水平擴展 + 可用性)
每個改造都是為了解決上一個改造暴露的新問題:
- 吞吐量提升 → 並發寫入問題浮現 → 加分散式鎖
- 鎖序列化寫入 → 讀取仍是瓶頸 → 讀寫分離
- 單機容量見頂 → 水平擴展
這就是高並發系統演進的典型路徑:不是一開始就設計「完美」的架構,而是根據實際瓶頸逐步演進。
完整程式碼見:PR #228 - ShoppingCart-dev-009-high-concurrency-pt-2
系列導覽
- 第一篇:Virtual Threads、HikariCP、Redis 快取
- 第二篇(本篇):Redisson 分散式鎖、讀寫分離路由、水平擴展與 Docker HA