那些年,咱們見過的 Java 服務端「問題」

導讀

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

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

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

  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.通常單機版部署方案

架構說明:

只有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消息隊列優化慢接口解決方案的優勢:

  1. 若是系統發生重啓或崩潰,致使消息處理函數執行失敗,不會確認消息已消費;因爲MetaQ支持多服務訂閱同一隊列,該消息能夠轉到別的服務進行消費,亦或等到本服務恢復正常後再進行消費。
  2. 消費者可多服務、多線程進行消費消息,即使消息處理時間較長,也不容易引發消息積壓;即使引發消息積壓,也能夠經過擴充服務實例的方式解決。
  3. 若是須要從新消費該消息,只須要在MetaQ管理平臺上點擊"消息驗證"便可。

3.流程定義不合理

3.1.原有的採購流程

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

其中,完成採購動做的核心代碼以下:

/** 完成採購動做函數(此處省去獲取採購單/驗證狀態/鎖定採購單等邏輯) */
public void finishPurchase(PurchaseOrder order) {
    // 完成相關處理
    ......

    // 迴流採購單(調用HTTP接口)
    backflowPurchaseOrder(order);

    // 設置完成狀態
    purchaseOrderDAO.setStatus(order.getId(), PurchaseOrderStatus.FINISHED.getValue());
}

因爲函數backflowPurchaseOrder(迴流採購單)調用了HTTP接口,可能引發如下問題:

  1. 該函數可能耗費時間較長,致使完成採購接口成爲慢接口;
  2. 該函數可能失敗拋出異常,致使客戶調用完成採購接口失敗。

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.狀態

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

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

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

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

使用建議:

  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.請求-應答

適用範圍:

適合於簡單的耗時較短的接口同步調用場景,好比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();
}

當過時訂單數量不多時,以上代碼不會有任何問題。可是,當過時訂單數量達到幾十萬上千萬時,以上代碼就會出現如下問題:

  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拼車"自在人心,聽說如今已經成爲了一個"民間組織"。



本文做者:中間件小哥

閱讀原文

本文爲雲棲社區原創內容,未經容許不得轉載。

相關文章
相關標籤/搜索