那些年,我們見過的Java服務端「問題」
- 2019 年 10 月 11 日
- 筆記
導讀
明代著名的心學集大成者王陽明先生在《傳習錄》中有云:「道無精粗,人之所見有精粗。如這一間房,人初進來,只見一個大規模如此。處久,便柱壁之類,一一看得明白。再久,如柱上有些文藻,細細都看出來。然只是一間房。」
是的,知識理論哪有什麼精粗之分,只是人的認識程度不同而已。筆者在技術圈摸爬滾打數年,接觸了各式各樣的Java服務端架構,見得多了自然也就認識深了,就能分辨出各種方案的優劣了。這裡總結了一些初創公司存在的Java服務端問題,並嘗試性地給出了一些解決方案,供大家交流參考。
1.系統不是分散式
1.1.單機版系統搶單案例
// 搶取訂單函數public synchronized void grabOrder(Long orderId, Long userId) { // 獲取訂單資訊 OrderDO order = orderDAO.get(orderId); if (Objects.isNull(order)) { throw new BizRuntimeException(String.format("訂單(%s)不存在", orderId)); } // 檢查訂單狀態 if (!Objects.equals(order.getStatus, OrderStatus.WAITING_TO_GRAB.getValue())) { throw new BizRuntimeException(String.format("訂單(%s)已被搶", orderId)); } // 設置訂單被搶 orderDAO.setGrabed(orderId, userId);}以上程式碼,在一台伺服器上運行沒有任何問題。進入函數grabOrder(搶取訂單)時,利用synchronized關鍵字把整個函數鎖定,要麼進入函數前訂單未被人搶取,從而搶單成功,要麼進入函數前訂單已被搶取導致搶單失敗,絕對不會出現進入函數前訂單未被搶取而進入函數後訂單又被搶取的情況。
但是,如果上面的程式碼在兩台伺服器上同時運行,由於Java的synchronized關鍵字只在一個虛擬機內生效,所以就會導致兩個人能夠同時搶取一個訂單,但會以最後一個寫入資料庫的數據為準。所以,大多數的單機版系統,是無法作為分散式系統運行的。
1.2.分散式系統搶單案例
添加分散式鎖,進行程式碼優化:
// 搶取訂單函數public void grabOrder(Long orderId, Long userId) { Long lockId = orderDistributedLock.lock(orderId); try { grabOrderWithoutLock(orderId, userId); } finally { orderDistributedLock.unlock(orderId, lockId); }} // 不帶鎖的搶取訂單函數private void grabOrderWithoutLock(Long orderId, Long userId) { // 獲取訂單資訊 OrderDO order = orderDAO.get(orderId); if (Objects.isNull(order)) { throw new BizRuntimeException(String.format("訂單(%s)不存在", orderId)); } // 檢查訂單狀態 if (!Objects.equals(order.getStatus, OrderStatus.WAITING_TO_GRAB.getValue())) { throw new BizRuntimeException(String.format("訂單(%s)已被搶", orderId)); } // 設置訂單被搶 orderDAO.setGrabed(orderId, userId);}優化後的程式碼,在調用函數grabOrderWithoutLock(不帶鎖的搶取訂單)前後,利用分散式鎖orderDistributedLock(訂單分散式鎖)進行加鎖和釋放鎖,跟單機版的synchronized關鍵字加鎖效果基本一樣。
1.3.分散式系統的優缺點
分散式系統(Distributed System)是支援分散式處理的軟體系統,是由通訊網路互聯的多處理機體系結構上執行任務的系統,包括分散式作業系統、分散式程式設計語言及其編譯系統、分散式文件系統分散式資料庫系統等。
分散式系統的優點:
- 可靠性、高容錯性:
一台伺服器的崩潰,不會影響其它伺服器,其它伺服器仍能提供服務。
- 可擴展性:
如果系統服務能力不足,可以水平擴展更多伺服器。
- 靈活性:
可以很容易的安裝、實施、擴容和升級系統。
- 性能高:
擁有多台伺服器的計算能力,比單台伺服器處理速度更快。
- 性價比高:
分散式系統對伺服器硬體要求很低,可以選用廉價伺服器搭建分散式集群,從而得到更好的性價比。
分散式系統的缺點:
- 排查難度高:
由於系統分布在多台伺服器上,故障排查和問題診斷難度較高。
- 軟體支援少:
分散式系統解決方案的軟體支援較少。
- 建設成本高:
需要多台伺服器搭建分散式系統。
曾經有不少的朋友諮詢我:"找外包做移動應用,需要注意哪些事項?"
首先,確定是否需要用分散式系統。軟體預算有多少?預計用戶量有多少?預計訪問量有多少?是否只是業務前期試水版?單台伺服器能否解決?是否接收短時間宕機?……如果綜合考慮,單機版系統就可以解決的,那就不要採用分散式系統了。因為單機版系統和分散式系統的差別很大,相應的軟體研發成本的差別也很大。
其次,確定是否真正的分散式系統。分散式系統最大的特點,就是當系統服務能力不足時,能夠通過水平擴展的方式,通過增加伺服器來增加服務能力。然而,單機版系統是不支援水平擴展的,強行擴展就會引起一系列數據問題。由於單機版系統和分散式系統的研發成本差別較大,市面上的外包團隊大多用單機版系統代替分散式系統交付。
那麼,如何確定你的系統是真正意義上的分散式系統呢?從軟體上來說,是否採用了分散式軟體解決方案;從硬體上來說,是否採用了分散式硬體部署方案。
1.4.分散式軟體解決方案
作為一個合格的分散式系統,需要根據實際需求採用相應的分散式軟體解決方案。
1.4.1.分散式鎖
分散式鎖是單機鎖的一種擴展,主要是為了鎖住分散式系統中的物理塊或邏輯塊,用以此保證不同服務之間的邏輯和數據的一致性。
目前,主流的分散式鎖實現方式有3種:
- 基於資料庫實現的分散式鎖;
- 基於Redis實現的分散式鎖;
- 基於Zookeeper實現的分散式鎖。
1.4.2.分散式消息
分散式消息中間件是支援在分散式系統中發送和接受消息的軟體基礎設施。常見的分散式消息中間件有ActiveMQ、RabbitMQ、Kafka、MetaQ等。
MetaQ(全稱Metamorphosis)是一個高性能、高可用、可擴展的分散式消息中間件,思路起源於LinkedIn的Kafka,但並不是Kafka的一個拷貝。MetaQ具有消息存儲順序寫、吞吐量大和支援本地和XA事務等特性,適用於大吞吐量、順序消息、廣播和日誌數據傳輸等場景。
1.4.3.資料庫分片分組
針對大數據量的資料庫,一般會採用"分片分組"策略:
分片(shard):主要解決擴展性問題,屬於水平拆分。引入分片,就引入了數據路由和分區鍵的概念。其中,分表解決的是數據量過大的問題,分庫解決的是資料庫性能瓶頸的問題。
分組(group):主要解決可用性問題,通過主從複製的方式實現,並提供讀寫分離策略用以提高資料庫性能。
1.4.4.分散式計算
分散式計算( Distributed computing )是一種"把需要進行大量計算的工程數據分割成小塊,由多台電腦分別計算;在上傳運算結果後,將結果統一合併得出數據結論"的科學。
當前的高性能伺服器在處理海量數據時,其計算能力、記憶體容量等指標都遠遠無法達到要求。在大數據時代,工程師採用廉價的伺服器組成分散式服務集群,以集群協作的方式完成海量數據的處理,從而解決單台伺服器在計算與存儲上的瓶頸。Hadoop、Storm以及Spark是常用的分散式計算中間件,Hadoop是對非實時數據做批量處理的中間件,Storm和Spark是對實時數據做流式處理的中間件。
除此之外,還有更多的分散式軟體解決方案,這裡就不再一一介紹了。
1.5.分散式硬體部署方案
介紹完服務端的分散式軟體解決方案,就不得不介紹一下服務端的分散式硬體部署方案。這裡,只畫出了服務端常見的介面伺服器、MySQL資料庫、Redis快取,而忽略了其它的雲存儲服務、消息隊列服務、日誌系統服務……
1.5.1.一般單機版部署方案

架構說明:
只有1台介面伺服器、1個MySQL資料庫、1個可選Redis快取,可能都部署在同一台伺服器上。
適用範圍:
適用於演示環境、測試環境以及不怕宕機且日PV在5萬以內的小型商業應用。
1.5.2.中小型分散式硬體部署方案

架構說明:
通過SLB/Nginx組成一個負載均衡的介面伺服器集群,MySQL資料庫和Redis快取採用了一主一備(或多備)的部署方式。
適用範圍:
適用於日PV在500萬以內的中小型商業應用。
1.5.3.大型分散式硬體部署方案

架構說明:
通過SLB/Nginx組成一個負載均衡的介面伺服器集群,利用分片分組策略組成一個MySQL資料庫集群和Redis快取集群。
適用範圍:
適用於日PV在500萬以上的大型商業應用。
2.多執行緒使用不正確
多執行緒最主要目的就是"最大限度地利用CPU資源",可以把串列過程變成並行過程,從而提高了程式的執行效率。
2.1.一個慢介面案例
假設在用戶登錄時,如果是新用戶,需要創建用戶資訊,並發放新用戶優惠券。例子程式碼如下:
// 登錄函數(示意寫法)public UserVO login(String phoneNumber, String verifyCode) { // 檢查驗證碼 if (!checkVerifyCode(phoneNumber, verifyCode)) { throw new ExampleException("驗證碼錯誤"); } // 檢查用戶存在 UserDO user = userDAO.getByPhoneNumber(phoneNumber); if (Objects.nonNull(user)) { return transUser(user); } // 創建新用戶 return createNewUser(user);} // 創建新用戶函數private UserVO createNewUser(String phoneNumber) { // 創建新用戶 UserDO user = new UserDO(); ... userDAO.insert(user); // 綁定優惠券 couponService.bindCoupon(user.getId(), CouponType.NEW_USER); // 返回新用戶 return transUser(user);}其中,綁定優惠券(bindCoupon)是給用戶綁定新用戶優惠券,然後再給用戶發送推送通知。如果隨著優惠券數量越來越多,該函數也會變得越來越慢,執行時間甚至超過1秒,並且沒有什麼優化空間。現在,登錄(login)函數就成了名副其實的慢介面,需要進行介面優化。
2.2.採用多執行緒優化
通過分析發現,綁定優惠券(bindCoupon)函數可以非同步執行。首先想到的是採用多執行緒解決該問題,程式碼如下:
// 創建新用戶函數private UserVO createNewUser(String phoneNumber) { // 創建新用戶 UserDO user = new UserDO(); ... userDAO.insert(user); // 綁定優惠券 executorService.execute(()->couponService.bindCoupon(user.getId(), CouponType.NEW_USER)); // 返回新用戶 return transUser(user);}
現在,在新執行緒中執行綁定優惠券(bindCoupon)函數,使用戶登錄(login)函數性能得到很大的提升。但是,如果在新執行緒執行綁定優惠券函數過程中,系統發生重啟或崩潰導致執行緒執行失敗,用戶將永遠獲取不到新用戶優惠券。除非提供用戶手動領取優惠券頁面,否則就需要程式設計師後台手工綁定優惠券。所以,用採用多執行緒優化慢介面,並不是一個完善的解決方案。
2.3.採用消息隊列優化
如果要保證綁定優惠券函數執行失敗後能夠重啟執行,可以採用資料庫表、Redis隊列、消息隊列的等多種解決方案。由於篇幅優先,這裡只介紹採用MetaQ消息隊列解決方案,並省略了MetaQ相關配置僅給出了核心程式碼。
消息生產者程式碼:
// 創建新用戶函數private UserVO createNewUser(String phoneNumber) { // 創建新用戶 UserDO user = new UserDO(); ... userDAO.insert(user); // 發送優惠券消息 Long userId = user.getId(); CouponMessageDataVO data = new CouponMessageDataVO(); data.setUserId(userId); data.setCouponType(CouponType.NEW_USER); Message message = new Message(TOPIC, TAG, userId, JSON.toJSONBytes(data)); SendResult result = metaqTemplate.sendMessage(message); if (!Objects.equals(result, SendStatus.SEND_OK)) { log.error("發送用戶({})綁定優惠券消息失敗:{}", userId, JSON.toJSONString(result)); } // 返回新用戶 return transUser(user);}注意:可能出現發生消息不成功,但是這種概率相對較低。
消息消費者程式碼:
// 優惠券服務類@Slf4j@Servicepublic class CouponService extends DefaultMessageListener<String> { // 消息處理函數 @Override @Transactional(rollbackFor = Exception.class) public void onReceiveMessages(MetaqMessage<String> message) { // 獲取消息體 String body = message.getBody(); if (StringUtils.isBlank(body)) { log.warn("獲取消息({})體為空", message.getId()); return; } // 解析消息數據 CouponMessageDataVO data = JSON.parseObject(body, CouponMessageDataVO.class); if (Objects.isNull(data)) { log.warn("解析消息({})體為空", message.getId()); return; } // 綁定優惠券 bindCoupon(data.getUserId(), data.getCouponType()); }}解決方案優點:
採集MetaQ消息隊列優化慢介面解決方案的優點:
- 如果系統發生重啟或崩潰,導致消息處理函數執行失敗,不會確認消息已消費;由於MetaQ支援多服務訂閱同一隊列,該消息可以轉到別的服務進行消費,亦或等到本服務恢復正常後再進行消費。
- 消費者可多服務、多執行緒進行消費消息,即便消息處理時間較長,也不容易引起消息積壓;即便引起消息積壓,也可以通過擴充服務實例的方式解決。
- 如果需要重新消費該消息,只需要在MetaQ管理平台上點擊"消息驗證"即可。
3.流程定義不合理
3.1.原有的採購流程
這是一個簡易的採購流程,由庫管系統發起採購,採購員開始採購,採購員完成採購,同時迴流採集訂單到庫管系統。

其中,完成採購動作的核心程式碼如下:
/** 完成採購動作函數(此處省去獲取採購單/驗證狀態/鎖定採購單等邏輯) */public void finishPurchase(PurchaseOrder order) { // 完成相關處理 ...... // 迴流採購單(調用HTTP介面) backflowPurchaseOrder(order); // 設置完成狀態 purchaseOrderDAO.setStatus(order.getId(), PurchaseOrderStatus.FINISHED.getValue());}由於函數backflowPurchaseOrder(迴流採購單)調用了HTTP介面,可能引起以下問題:
- 該函數可能耗費時間較長,導致完成採購介面成為慢介面;
- 該函數可能失敗拋出異常,導致客戶調用完成採購介面失敗。
3.2.優化的採購流程
通過需求分析,把"採購員完成採購並迴流採集訂單"動作拆分為"採購員完成採購"和"迴流採集訂單"兩個獨立的動作,把"採購完成"拆分為"採購完成"和"迴流完成"兩個獨立的狀態,更方便採購流程的管理和實現。

拆分採購流程的動作和狀態後,核心程式碼如下:
/** 完成採購動作函數(此處省去獲取採購單/驗證狀態/鎖定採購單等邏輯) */public void finishPurchase(PurchaseOrder order) { // 完成相關處理 ...... // 設置完成狀態 purchaseOrderDAO.setStatus(order.getId(), PurchaseOrderStatus.FINISHED.getValue());} /** 執行迴流動作函數(此處省去獲取採購單/驗證狀態/鎖定採購單等邏輯) */public void executeBackflow(PurchaseOrder order) { // 迴流採購單(調用HTTP介面) backflowPurchaseOrder(order); // 設置迴流狀態 purchaseOrderDAO.setStatus(order.getId(), PurchaseOrderStatus.BACKFLOWED.getValue());}其中,函數executeBackflow(執行迴流)由定時作業觸發執行。如果迴流採購單失敗,採購單狀態並不會修改為"已迴流";等下次定時作業執行時,將會繼續執行迴流動作;直到迴流採購單成功為止。
3.3.有限狀態機介紹
3.3.1.概念
有限狀態機(Finite-state machine,FSM),又稱有限狀態自動機,簡稱狀態機,是表示有限個狀態以及在這些狀態之間的轉移和動作等行為的一個數學模型。
3.3.2.要素
狀態機可歸納為4個要素:現態、條件、動作、次態。

現態:指當前流程所處的狀態,包括起始、中間、終結狀態。
條件:也可稱為事件;當一個條件被滿足時,將會觸發一個動作並執行一次狀態的遷移。
動作:當條件滿足後要執行的動作。動作執行完畢後,可以遷移到新的狀態,也可以仍舊保持原狀態。
次態:當條件滿足後要遷往的狀態。「次態」是相對於「現態」而言的,「次態」一旦被激活,就轉變成新的「現態」了。
3.3.3.狀態
狀態表示流程中的持久狀態,流程圖上的每一個圈代表一個狀態。
初始狀態: 流程開始時的某一狀態;
中間狀態:流程中間過程的某一狀態;
終結狀態:流程完成時的某一狀態。
使用建議:
- 狀態必須是一個持久狀態,而不能是一個臨時狀態;
- 終結狀態不能是中間狀態,不能繼續進行流程流轉;
- 狀態劃分合理,不要把多個狀態強制合併為一個狀態;
- 狀態盡量精簡,同一狀態的不同情況可以用其它欄位表示。
3.3.4.動作
動作的三要素:角色、現態、次態,流程圖上的每一條線代表一個動作。
角色:誰發起的這個操作,可以是用戶、定時任務等;
現態:觸發動作時當前的狀態,是執行動作的前提條件;
次態:完成動作後達到的狀態,是執行動作的最終目標。
使用建議:
- 每個動作執行前,必須檢查當前狀態和觸發動作狀態的一致性;
- 狀態機的狀態更改,只能通過動作進行,其它操作都是不符合規範的;
- 需要添加分散式鎖保證動作的原子性,添加資料庫事務保證數據的一致性;
- 類似的動作(比如操作用戶、請求參數、動作含義等)可以合併為一個動作,並根據動作執行結果轉向不同的狀態。
4.系統間交互不科學
4.1.直接通過資料庫交互
在一些項目中,系統間交互不通過介面調用和消息隊列,而是通過資料庫直接訪問。問其原因,回答道:"項目工期太緊張,直接訪問資料庫,簡單又快捷"。
還是以上面的採購流程為例——採購訂單由庫管系統發起,由採購系統負責採購,採購完成後通知庫管系統,庫管系統進入入庫操作。採購系統採購完成後,通知庫管系統資料庫的程式碼如下:
/** 執行迴流動作函數(此處省去獲取採購單/驗證狀態/鎖定採購單等邏輯) */public void executeBackflow(PurchaseOrder order) { // 完成原始採購單 rawPurchaseOrderDAO.setStatus(order.getRawId(), RawPurchaseOrderStatus.FINISHED.getValue()); // 設置迴流狀態 purchaseOrderDAO.setStatus(order.getId(), PurchaseOrderStatus.BACKFLOWED.getValue());}其中,通過rawPurchaseOrderDAO(原始採購單DAO)直接訪問庫管系統的資料庫表,並設置原始採購單狀態為已完成。
一般情況下,直接通過數據訪問的方式是不會有問題的。但是,一旦發生競態,就會導致數據不同步。有人會說,可以考慮使用同一分散式鎖解決該問題。是的,這種解決方案沒有問題,只是又在系統間共享了分散式鎖。
直接通過資料庫交互的缺點:
- 直接暴露資料庫表,容易產生數據安全問題;
- 多個系統操作同一資料庫表,容易造成資料庫表數據混亂;
- 操作同一個資料庫表的程式碼,分布在不同的系統中,不便於管理和維護;
- 具有資料庫表這樣的強關聯,無法實現系統間的隔離和解耦。
4.2.通過Dubbo介面交互
由於採購系統和庫管系統都是內部系統,可以通過類似Dubbo的RPC介面進行交互。
庫管系統程式碼:
/** 採購單服務介面 */public interface PurchaseOrderService { /** 完成採購單函數 */ public void finishPurchaseOrder(Long orderId);}/** 採購單服務實現 */@Service("purchaseOrderService")public class PurchaseOrderServiceImpl implements PurchaseOrderService { /** 完成採購單函數 */ @Override @Transactional(rollbackFor = Exception.class) public void finishPurchaseOrder(Long orderId) { // 相關處理 ... // 完成採購單 purchaseOrderService.finishPurchaseOrder(order.getRawId()); }}其中,庫管系統通過Dubbo把PurchaseOrderServiceImpl(採購單服務實現)以PurchaseOrderService(採購單服務介面)定義的介面服務暴露給採購系統。這裡,省略了Dubbo開發服務介面相關配置。
採購系統程式碼:
/** 執行迴流動作函數(此處省去獲取採購單/驗證狀態/鎖定採購單等邏輯) */public void executeBackflow(PurchaseOrder order) { // 完成採購單 purchaseOrderService.finishPurchaseOrder(order.getRawId()); // 設置迴流狀態 purchaseOrderDAO.setStatus(order.getId(), PurchaseOrderStatus.BACKFLOWED.getValue());}其中,purchaseOrderService(採購單服務)為庫管系統PurchaseOrderService(採購單服務)在採購系統中的Dubbo服務客戶端存根,通過該服務調用庫管系統的服務介面函數finishPurchaseOrder(完成採購單函數)。
這樣,採購系統和庫管系統自己的強關聯,通過Dubbo就簡單地實現了系統隔離和解耦。當然,除了採用Dubbo介面外,還可以採用HTTPS、HSF、WebService等同步介面調用方式,也可以採用MetaQ等非同步消息通知方式。
4.3.常見系統間交互協議
4.3.1.同步介面調用
同步介面調用是以一種阻塞式的介面調用機制。常見的交互協議有:
- HTTP/HTTPS介面;
- WebService介面;
- Dubbo/HSF介面;
- CORBA介面。
4.3.2.非同步消息通知
非同步消息通知是一種通知式的資訊交互機制。當系統發生某種事件時,會主動通知相應的系統。常見的交互協議有:
- MetaQ的消息通知;
- CORBA消息通知。
4.4.常見系統間交互方式
4.4.1.請求-應答

適用範圍:
適合於簡單的耗時較短的介面同步調用場景,比如Dubbo介面同步調用。
4.4.2.通知-確認

適用範圍:
適合於簡單的非同步消息通知場景,比如MetaQ消息通知。
4.4.3.請求-應答-查詢-返回

適用範圍:
適合於複雜的耗時較長的介面同步調用場景,比如提交作業任務並定期查詢任務結果。
4.4.4.請求-應答-回調

適用範圍:
適合於複雜的耗時較長的介面同步調用和非同步回調相結合的場景,比如支付寶的訂單支付。
4.4.5.請求-應答-通知-確認

適用範圍:
適合於複雜的耗時較長的介面同步調用和非同步消息通知相結合的場景,比如提交作業任務並等待完成消息通知。
4.4.6.通知-確認-通知-確認

適用範圍:
適合於複雜的耗時較長的非同步消息通知場景。
5.數據查詢不分頁
在數據查詢時,由於未能對未來數據量做出正確的預估,很多情況下都沒有考慮數據的分頁查詢。
5.1.普通查詢案例
以下是查詢過期訂單的程式碼:
/** 訂單DAO介面 */public interface OrderDAO { /** 查詢過期訂單函數 */ @Select("select * from t_order where status = 5 and gmt_create < date_sub(current_timestamp, interval 30 day)") public List<OrderDO> queryTimeout();} /** 訂單服務介面 */public interface OrderService { /** 查詢過期訂單函數 */ public List<OrderVO> queryTimeout();}當過期訂單數量很少時,以上程式碼不會有任何問題。但是,當過期訂單數量達到幾十萬上千萬時,以上程式碼就會出現以下問題:
- 數據量太大,導致服務端的記憶體溢出;
- 數據量太大,導致查詢介面超時、返回數據超時等;
- 數據量太大,導致客戶端的記憶體溢出。
所以,在數據查詢時,特別是不能預估數據量的大小時,需要考慮數據的分頁查詢。
這裡,主要介紹"設置最大數量"和"採用分頁查詢"兩種方式。
5.2.設置最大數量
"設置最大數量"是一種最簡單的分頁查詢,相當於只返回第一頁數據。例子程式碼如下:
/** 訂單DAO介面 */public interface OrderDAO { /** 查詢過期訂單函數 */ @Select("select * from t_order where status = 5 and gmt_create < date_sub(current_timestamp, interval 30 day) limit 0, #{maxCount}") public List<OrderDO> queryTimeout(@Param("maxCount") Integer maxCount);} /** 訂單服務介面 */public interface OrderService { /** 查詢過期訂單函數 */ public List<OrderVO> queryTimeout(Integer maxCount);}適用於沒有分頁需求、但又擔心數據過多導致記憶體溢出、數據量過大的查詢。
5.3.採用分頁查詢
"採用分頁查詢"是指定startIndex(開始序號)和pageSize(頁面大小)進行數據查詢,或者指定pageIndex(分頁序號)和pageSize(頁面大小)進行數據查詢。例子程式碼如下:
/** 訂單DAO介面 */public interface OrderDAO { /** 統計過期訂單函數 */ @Select("select count(*) from t_order where status = 5 and gmt_create < date_sub(current_timestamp, interval 30 day)") public Long countTimeout(); /** 查詢過期訂單函數 */ @Select("select * from t_order where status = 5 and gmt_create < date_sub(current_timestamp, interval 30 day) limit #{startIndex}, #{pageSize}") public List<OrderDO> queryTimeout(@Param("startIndex") Long startIndex, @Param("pageSize") Integer pageSize);} /** 訂單服務介面 */public interface OrderService { /** 查詢過期訂單函數 */ public PageData<OrderVO> queryTimeout(Long startIndex, Integer pageSize);}適用於真正的分頁查詢,查詢參數startIndex(開始序號)和pageSize(頁面大小)可由調用方指定。
5.4.分頁查詢隱藏問題
假設,我們需要在一個定時作業(每5分鐘執行一次)中,針對已經超時的訂單(status=5,創建時間超時30天)進行超時關閉(status=10)。實現程式碼如下:
/** 訂單DAO介面 */public interface OrderDAO { /** 查詢過期訂單函數 */ @Select("select * from t_order where status = 5 and gmt_create < date_sub(current_timestamp, interval 30 day) limit #{startIndex}, #{pageSize}") public List<OrderDO> queryTimeout(@Param("startIndex") Long startIndex, @Param("pageSize") Integer pageSize); /** 設置訂單超時關閉 */ @Update("update t_order set status = 10 where id = #{orderId} and status = 5") public Long setTimeoutClosed(@Param("orderId") Long orderId)} /** 關閉過期訂單作業類 */public class CloseTimeoutOrderJob extends Job { /** 分頁數量 */ private static final int PAGE_COUNT = 100; /** 分頁大小 */ private static final int PAGE_SIZE = 1000; /** 作業執行函數 */ @Override public void execute() { for (int i = 0; i < PAGE_COUNT; i++) { // 查詢處理訂單 List<OrderDO> orderList = orderDAO.queryTimeout(i * PAGE_COUNT, PAGE_SIZE); if (OrderDO order : orderList) { // 進行超時關閉 ...... orderDAO.setTimeoutClosed(order.getId()); } // 檢查處理完畢 if(orderList.size() < PAGE_SIZE) { break; } } }}粗看這段程式碼是沒有問題的,嘗試循環100次,每次取1000條過期訂單,進行訂單超時關閉操作,直到沒有訂單或達到100次為止。但是,如果結合訂單狀態一起看,就會發現從第二次查詢開始,每次會忽略掉前startIndex(開始序號)條應該處理的過期訂單。這就是分頁查詢存在的隱藏問題:
當滿足查詢條件的數據,在操作中不再滿足查詢條件時,會導致後續分頁查詢中前startIndex(開始序號)條滿足條件的數據被跳過。
可以採用"設置最大數量"的方式解決,程式碼如下:
/** 訂單DAO介面 */public interface OrderDAO { /** 查詢過期訂單函數 */ @Select("select * from t_order where status = 5 and gmt_create < date_sub(current_timestamp, interval 30 day) limit 0, #{maxCount}") public List<OrderDO> queryTimeout(@Param("maxCount") Integer maxCount); /** 設置訂單超時關閉 */ @Update("update t_order set status = 10 where id = #{orderId} and status = 5") public Long setTimeoutClosed(@Param("orderId") Long orderId)} /** 關閉過期訂單作業(定時作業) */public class CloseTimeoutOrderJob extends Job { /** 分頁數量 */ private static final int PAGE_COUNT = 100; /** 分頁大小 */ private static final int PAGE_SIZE = 1000; /** 作業執行函數 */ @Override public void execute() { for (int i = 0; i < PAGE_COUNT; i++) { // 查詢處理訂單 List<OrderDO> orderList = orderDAO.queryTimeout(PAGE_SIZE); if (OrderDO order : orderList) { // 進行超時關閉 ...... orderDAO.setTimeoutClosed(order.getId()); } // 檢查處理完畢 if(orderList.size() < PAGE_SIZE) { break; } } }}