天天看點

初創公司5大Java服務困局,阿裡工程師如何打破?1.系統不是分布式2.多線程使用不正确3.流程定義不合理4.系統間互動不科學5.資料查詢不分頁

初創公司5大Java服務困局,阿裡工程師如何打破?1.系統不是分布式2.多線程使用不正确3.流程定義不合理4.系統間互動不科學5.資料查詢不分頁

阿裡妹導讀:初創公司遇到的每一個問題都可能攸關生死。創業之初更應該總結行業的常見問題,對比方案尋找最優解。阿裡巴巴地圖技術專家常意在技術圈摸爬滾打數年,接觸了各式各樣的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種:

  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一般單機版部署方案

初創公司5大Java服務困局,阿裡工程師如何打破?1.系統不是分布式2.多線程使用不正确3.流程定義不合理4.系統間互動不科學5.資料查詢不分頁

架構說明:隻有1台接口伺服器、1個MySQL資料庫、1個可選Redis緩存,可能都部署在同一台伺服器上。

适用範圍:适用于示範環境、測試環境以及不怕當機且日PV在5萬以内的小型商業應用。

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

初創公司5大Java服務困局,阿裡工程師如何打破?1.系統不是分布式2.多線程使用不正确3.流程定義不合理4.系統間互動不科學5.資料查詢不分頁

架構說明:通過SLB/Nginx組成一個負載均衡的接口伺服器叢集,MySQL資料庫和Redis緩存采用了一主一備(或多備)的部署方式。

适用範圍:适用于日PV在500萬以内的中小型商業應用。

1.5.3大型分布式硬體部署方案

初創公司5大Java服務困局,阿裡工程師如何打破?1.系統不是分布式2.多線程使用不正确3.流程定義不合理4.系統間互動不科學5.資料查詢不分頁

架構說明:通過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.原有的采購流程

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

初創公司5大Java服務困局,阿裡工程師如何打破?1.系統不是分布式2.多線程使用不正确3.流程定義不合理4.系統間互動不科學5.資料查詢不分頁

其中,完成采購動作的核心代碼如下:

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

    // 回流采購單(調用HTTP接口)
    backflowPurchaseOrder(order);

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

由于函數backflowPurchaseOrder(回流采購單)調用了HTTP接口,可能引起以下問題:

  1. 該函數可能耗費時間較長,導緻完成采購接口成為慢接口;
  2. 該函數可能失敗抛出異常,導緻客戶調用完成采購接口失敗。

3.2.優化的采購流程

通過需求分析,把"采購員完成采購并回流采集訂單"動作拆分為"采購員完成采購"和"回流采集訂單"兩個獨立的動作,把"采購完成"拆分為"采購完成"和"回流完成"兩個獨立的狀态,更友善采購流程的管理和實作。

初創公司5大Java服務困局,阿裡工程師如何打破?1.系統不是分布式2.多線程使用不正确3.流程定義不合理4.系統間互動不科學5.資料查詢不分頁

拆分采購流程的動作和狀态後,核心代碼如下:

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

初創公司5大Java服務困局,阿裡工程師如何打破?1.系統不是分布式2.多線程使用不正确3.流程定義不合理4.系統間互動不科學5.資料查詢不分頁

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

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

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

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

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

初創公司5大Java服務困局,阿裡工程師如何打破?1.系統不是分布式2.多線程使用不正确3.流程定義不合理4.系統間互動不科學5.資料查詢不分頁

适用範圍:适合于簡單的耗時較短的接口同步調用場景,比如Dubbo接口同步調用。

4.4.2通知-确認

初創公司5大Java服務困局,阿裡工程師如何打破?1.系統不是分布式2.多線程使用不正确3.流程定義不合理4.系統間互動不科學5.資料查詢不分頁

适用範圍:适合于簡單的異步消息通知場景,比如MetaQ消息通知。

4.4.3請求-應答-查詢-傳回

初創公司5大Java服務困局,阿裡工程師如何打破?1.系統不是分布式2.多線程使用不正确3.流程定義不合理4.系統間互動不科學5.資料查詢不分頁

适用範圍:适合于複雜的耗時較長的接口同步調用場景,比如送出作業任務并定期查詢任務結果。

4.4.4請求-應答-回調

初創公司5大Java服務困局,阿裡工程師如何打破?1.系統不是分布式2.多線程使用不正确3.流程定義不合理4.系統間互動不科學5.資料查詢不分頁

适用範圍:适合于複雜的耗時較長的接口同步調用和異步回調相結合的場景,比如支付寶的訂單支付。

4.4.5請求-應答-通知-确認

初創公司5大Java服務困局,阿裡工程師如何打破?1.系統不是分布式2.多線程使用不正确3.流程定義不合理4.系統間互動不科學5.資料查詢不分頁

适用範圍:适合于複雜的耗時較長的接口同步調用和異步消息通知相結合的場景,比如送出作業任務并等待完成消息通知。

4.4.6通知-确認-通知-确認

初創公司5大Java服務困局,阿裡工程師如何打破?1.系統不是分布式2.多線程使用不正确3.流程定義不合理4.系統間互動不科學5.資料查詢不分頁

适用範圍:适合于複雜的耗時較長的異步消息通知場景。

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.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;
            }
        }
    }
}           

原文釋出時間為:2019-11-6

作者: 常意

本文來自雲栖社群合作夥伴“

阿裡技術

”,了解相關資訊可以關注“

”。