那些年,我們見過的 Java 服務端“問題”

芊寶寶最可愛發表於2019-10-12

明代著名的心學集大成者王陽明先生在《傳習錄》中有云:

道無精粗,人之所見有精粗。如這一間房,人初進來,只見一個大規模如此。處久,便柱壁之類,一一看得明白。再久,如柱上有些文藻,細細都看出來。然只是一間房。

是的,知識理論哪有什麼精粗之分,只是人的認識程度不同而已。筆者在初創公司摸爬滾打數年,接觸了各式各樣的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. 可靠性、高容錯性:

一臺伺服器的崩潰,不會影響其它伺服器,其它伺服器仍能提供服務。

  1. 可擴充套件性:

如果系統服務能力不足,可以水平擴充套件更多伺服器。

  1. 靈活性:

可以很容易的安裝、實施、擴容和升級系統。

  1. 效能高:

擁有多臺伺服器的計算能力,比單臺伺服器處理速度更快。

  1. 價效比高:

分散式系統對伺服器硬體要求很低,可以選用廉價伺服器搭建分散式叢集,從而得到更好的價效比。

分散式系統的缺點:

  1. 排查難度高:

由於系統分佈在多臺伺服器上,故障排查和問題診斷難度較高。

  1. 軟體支援少:

分散式系統解決方案的軟體支援較少。

  1. 建設成本高:

需要多臺伺服器搭建分散式系統。

曾經有不少的朋友諮詢我:"找外包做移動應用,需要注意哪些事項?"

首先,確定是否需要用分散式系統。軟體預算有多少?預計使用者量有多少?預計訪問量有多少?是否只是業務前期試水版?單臺伺服器能否解決?是否接收短時間當機?……如果綜合考慮,單機版系統就可以解決的,那就不要採用分散式系統了。因為單機版系統和分散式系統的差別很大,相應的軟體研發成本的差別也很大。

其次,確定是否真正的分散式系統。分散式系統最大的特點,就是當系統服務能力不足時,能夠透過 水平擴充套件的方式,透過增加伺服器來增加服務能力。然而,單機版系統是不支援水平擴充套件的,強行擴充套件就會引起一系列資料問題。由於單機版系統和分散式系統的研發成本差別較大,市面上的外包團隊大多用單機版系統代替分散式系統交付。那麼,如何確定你的系統是真正意義上的分散式系統呢?從軟體上來說,是否採用了 分散式軟體解決方案;從硬體上來說,是否採用了 分散式硬體部署方案

1.4.分散式軟體解決方案

作為一個合格的分散式系統,需要根據實際需求採用相應的分散式軟體解決方案。

1.4.1.分散式鎖

分散式鎖是單機鎖的一種擴充套件,主要是為了鎖住分散式系統中的物理塊或邏輯塊,用以此保證不同服務之間的邏輯和資料的一致性。

目前,主流的分散式鎖實現方式有3種:

  1. 基於資料庫實現的分散式鎖;
  2. 基於Redis實現的分散式鎖;
  3. 基於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.一般單機版部署方案


那些年,我們見過的 Java 服務端“問題”


架構說明:

只有1臺介面伺服器、1個MySQL資料庫、1個可選Redis快取,可能都部署在同一臺伺服器上。

適用範圍:

適用於演示環境、測試環境以及不怕當機且日PV在5萬以內的小型商業應用。

1.5.2.中小型分散式硬體部署方案


那些年,我們見過的 Java 服務端“問題”


架構說明:

透過SLB/Nginx組成一個負載均衡的介面伺服器叢集,MySQL資料庫和Redis快取採用了一主一備(或多備)的部署方式。

適用範圍:

適用於日PV在500萬以內的中小型商業應用。

1.5.3.大型分散式硬體部署方案


那些年,我們見過的 Java 服務端“問題”


架構說明:

透過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訊息佇列最佳化慢介面解決方案的優點:

  1. 如果系統發生重啟或崩潰,導致訊息處理函式執行失敗,不會確認訊息已消費;由於MetaQ支援多服務訂閱同一佇列,該訊息可以轉到別的服務進行消費,亦或等到本服務恢復正常後再進行消費。
  2. 消費者可多服務、多執行緒進行消費訊息,即便訊息處理時間較長,也不容易引起訊息積壓;即便引起訊息積壓,也可以透過擴充服務例項的方式解決。
  3. 如果需要重新消費該訊息,只需要在MetaQ管理平臺上點選"訊息驗證"即可。

3.流程定義不合理

3.1.原有的採購流程

這是一個簡易的採購流程,由庫管系統發起採購,採購員開始採購,採購員完成採購,同時迴流採集訂單到庫管系統。


那些年,我們見過的 Java 服務端“問題”


其中,完成採購動作的核心程式碼如下:

/** 完成採購動作函式(此處省去獲取採購單/驗證狀態/鎖定採購單等邏輯) */
public void finishPurchase(PurchaseOrder order) {
    // 完成相關處理
    ......
    // 迴流採購單(呼叫HTTP介面)
    backflowPurchaseOrder(order);
    
    // 設定完成狀態
    purchaseOrderDAO.setStatus(order.getId(), PurchaseOrderStatus.FINISHED.getValue());
}

由於函式backflowPurchaseOrder(迴流採購單)呼叫了HTTP介面,可能引起以下問題:

  1. 該函式可能耗費時間較長,導致完成採購介面成為慢介面;
  2. 該函式可能失敗丟擲異常,導致客戶呼叫完成採購介面失敗。

3.2.最佳化的採購流程

透過需求分析,把"採購員完成採購並回流採集訂單"動作拆分為"採購員完成採購"和"迴流採集訂單"兩個獨立的動作,把"採購完成"拆分為"採購完成"和"迴流完成"兩個獨立的狀態,更方便採購流程的管理和實現。


那些年,我們見過的 Java 服務端“問題”


拆分採購流程的動作和狀態後,核心程式碼如下:

/** 完成採購動作函式(此處省去獲取採購單/驗證狀態/鎖定採購單等邏輯) */
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個要素:現態、條件、動作、次態。


那些年,我們見過的 Java 服務端“問題”


現態:指當前流程所處的狀態,包括起始、中間、終結狀態。

條件:也可稱為事件;當一個條件被滿足時,將會觸發一個動作並執行一次狀態的遷移。

動作:當條件滿足後要執行的動作。動作執行完畢後,可以遷移到新的狀態,也可以仍舊保持原狀態。

次態:當條件滿足後要遷往的狀態。“次態”是相對於“現態”而言的,“次態”一旦被啟用,就轉變成新的“現態”了。

3.3.3.狀態

狀態表示流程中的持久狀態,流程圖上的每一個圈代表一個狀態。

初始狀態: 流程開始時的某一狀態;

中間狀態: 流程中間過程的某一狀態;

終結狀態: 流程完成時的某一狀態。

使用建議:

  1. 狀態必須是一個持久狀態,而不能是一個臨時狀態;
  2. 終結狀態不能是中間狀態,不能繼續進行流程流轉;
  3. 狀態劃分合理,不要把多個狀態強制合併為一個狀態;
  4. 狀態儘量精簡,同一狀態的不同情況可以用其它欄位表示。

3.3.4.動作

動作的三要素:角色、現態、次態,流程圖上的每一條線代表一個動作。

角色: 誰發起的這個操作,可以是使用者、定時任務等;

現態: 觸發動作時當前的狀態,是執行動作的前提條件;

次態: 完成動作後達到的狀態,是執行動作的最終目標。

使用建議:

  1. 每個動作執行前,必須檢查當前狀態和觸發動作狀態的一致性;
  2. 狀態機的狀態更改,只能透過動作進行,其它操作都是不符合規範的;
  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)直接訪問庫管系統的資料庫表,並設定原始採購單狀態為已完成。

一般情況下,直接透過資料訪問的方式是不會有問題的。但是,一旦發生競態,就會導致資料不同步。有人會說,可以考慮使用同一分散式鎖解決該問題。是的,這種解決方案沒有問題,只是又在系統間共享了分散式鎖。

直接透過資料庫互動的缺點:

  1. 直接暴露資料庫表,容易產生資料安全問題;
  2. 多個系統操作同一資料庫表,容易造成資料庫表資料混亂;
  3. 操作同一個資料庫表的程式碼,分佈在不同的系統中,不便於管理和維護;
  4. 具有資料庫表這樣的強關聯,無法實現系統間的隔離和解耦。

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.同步介面呼叫

同步介面呼叫是以一種阻塞式的介面呼叫機制。常見的互動協議有:

  1. HTTP/HTTPS介面;
  2. WebService介面;
  3. Dubbo/HSF介面;
  4. CORBA介面。

4.3.2.非同步訊息通知

非同步訊息通知是一種通知式的資訊互動機制。當系統發生某種事件時,會主動通知相應的系統。常見的互動協議有:

  1. MetaQ的訊息通知;
  2. CORBA訊息通知。

4.4.常見系統間互動方式

4.4.1.請求-應答


那些年,我們見過的 Java 服務端“問題”


適用範圍:

適合於簡單的耗時較短的介面同步呼叫場景,比如Dubbo介面同步呼叫。

4.4.2.通知-確認


那些年,我們見過的 Java 服務端“問題”


適用範圍:

適合於簡單的非同步訊息通知場景,比如MetaQ訊息通知。

4.4.3.請求-應答-查詢-返回


那些年,我們見過的 Java 服務端“問題”


適用範圍:

適合於複雜的耗時較長的介面同步呼叫場景,比如提交作業任務並定期查詢任務結果。

4.4.4.請求-應答-回撥


那些年,我們見過的 Java 服務端“問題”


適用範圍:

適合於複雜的耗時較長的介面同步呼叫和非同步回撥相結合的場景,比如支付寶的訂單支付。

4.4.5.請求-應答-通知-確認


那些年,我們見過的 Java 服務端“問題”


適用範圍:

適合於複雜的耗時較長的介面同步呼叫和非同步訊息通知相結合的場景,比如提交作業任務並等待完成訊息通知。

4.4.6.通知-確認-通知-確認


那些年,我們見過的 Java 服務端“問題”


適用範圍:

適合於複雜的耗時較長的非同步訊息通知場景。

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();
}

當過期訂單數量很少時,以上程式碼不會有任何問題。但是,當過期訂單數量達到幾十萬上千萬時,以上程式碼就會出現以下問題:

  1. 資料量太大,導致服務端的記憶體溢位;
  2. 資料量太大,導致查詢介面超時、返回資料超時等;
  3. 資料量太大,導致客戶端的記憶體溢位。

所以,在資料查詢時,特別是不能預估資料量的大小時,需要考慮資料的分頁查詢。

這裡,主要介紹"設定最大數量"和"採用分頁查詢"兩種方式。

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.3.分頁查詢隱藏問題

假設,我們需要在一個定時作業(每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;
            }
        }
    }
}

後記

本文是《 那些年,我們見過的Java服務端“亂象”》的姐妹篇,前文主要介紹的是 Java服務端規範上的問題,而本文主要介紹的是 Java服務端方案上的問題。

謹以此文獻給當年"E代駕"下的"KK拼車"團隊,懷念曾經一起奮鬥過的兄弟們,懷念那段為代駕司機深夜返程保駕護航的歲月。深感遺憾的是,"KK拼車"剛剛嶄露頭角,還沒來得及好好發展,就被公司斷臂裁撤了。值得欣慰的是,"KK拼車"自在人心,據說現在已經成為了一個"民間組織"。

原文連結

本文為雲棲社群原創內容,未經允許不得轉載。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69949601/viewspace-2659725/,如需轉載,請註明出處,否則將追究法律責任。

相關文章