初創公司5大Java服務困局,阿里工程師如何打破?
初創公司遇到的每一個問題都可能攸關生死。創業之初更應該總結行業的常見問題,對比方案尋找最優解。阿里巴巴地圖技術專家常意在技術圈摸爬滾打數年,接觸了各式各樣的Java服務端架構。服務端問題見得多了,也就更能分辨出各種方案的優劣。今天,常意總結了5大初創公司存在的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
- @Service
- public 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);
- for (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);
- for (OrderDO order : orderList) {
- // 進行超時關閉
- ......
- orderDAO.setTimeoutClosed(order.getId());
- }
- // 檢查處理完畢
- if(orderList.size() < PAGE_SIZE) {
- break;
- }
- }
- }
- }