關於魯棒性的思考

在計算機科學中,魯棒性(英語:Robustness)是指一個計算機系統在執行過程中處理錯誤,以及算法在遭遇輸入、運算等異常時繼續正常運行的能力。 

魯棒性關注的重點在於系統的穩定性,在不同場景下衍生了複雜的設計考量,且本身是一個廣泛且難以具像化的特性。因此,針對特定目標實現魯棒性分析,形成切實可行的魯棒性意識,保障安全性。

基於魯棒性分析,以設計規約爲目標,有三個維度可以拆解:輸入、處理、輸出;以代碼規範爲核心,我們可以從三個方面來分析,分別爲:代碼質量、代碼性能以及代碼優雅。

設計規約

 失敗設計思維

針對輸入和處理環節,失敗設計思維是保證魯棒性的有效設想。該思維要貫穿代碼生命週期始終,把失敗當作代碼設計中合理存在,提前準備好從運行失敗的場景中恢復。倡導防禦式編程思想,拒絕契約式編程。

正例:當系統弱依賴於多個外部服務時,如果下游服務耗時過長,則會嚴重影響當前調用者,必須採取相應降級措施,比如,當調用鏈路中某個下游服務調用的平均響應時間或錯誤率超過閾值時,系統自動進行降級或熔斷操作,屏蔽弱依賴負面影響,保護當前系統主幹功能可用。

反例:用戶在淘寶付款過程中,銀行扣款成功,發送給用戶扣款成功短信,但是支付寶入款時由於斷網演練產生異常,淘寶訂單頁面依然顯示未付款,導致用戶投訴。

 圖式表達設計

針對處理環節,圖式表達設計保證魯棒性的有效舉措。在複雜多變的業務場景中,圖式表達往往能夠以清晰、結構化的展現業務關聯關係,對技術鏈路包括失敗異常分支也有充分的分析幫助。

正例:淘寶訂單狀態有已下單、待付款、已付款、待發貨、已發貨、已收貨等。比如已下單與已收貨這兩種狀態之間是不可能有直接轉換關係的。

 異常錯誤處理

針對輸出環節,異常錯誤處理是保障魯棒性的重要依據。業務代碼必然會有錯誤失敗出現,是否符合預期表現,是否在正常處理流中,是否可以快速對錯誤定位,往往要有一定的判斷依據。面對異常分支,就需要異常錯誤輸出,也是系統監控的基礎。

 實戰 Case

聚划算章魚互動升級爲 “聚財氣” 頻道,新增氣泡獎勵玩法。氣泡獎勵分登錄獎勵和時長獎勵,其中時長獎勵包括獎勵 1 倒計時 30 秒、獎勵 2 每日 9 點以及獎勵 3 每日 20 點。

場景演示:用戶在 10:00 進入頻道後,收取完登陸獎勵,喚起了一個 30 秒後的獎勵的氣泡;30 秒後用戶點擊領獎,喚起了一個提示今日 20:00 可領的提示(該獎勵未領);用戶次日再來,收取完登陸獎勵後喚起了 30 秒後的獎勵氣泡....

實現效果

通過氣泡任務的需求描述,簡單分析可以得知,任務開始到權益發放間有狀態變更,氣泡任務間有優先級邏輯。因此,基於設計規約,我們可以對需求進行清晰的分析和開發設計。

1、圖式表達設計

氣泡任務的複雜度主要在於多狀態的變更,所以採用圖式表達方式完成狀態的變遷。可以看出,運用狀態圖是較合適的。(狀態圖:主要用於描述一個對象在其生存期間的動態行爲,表現爲一個對象所經歷的狀態序列,引起狀態轉移的事件,以及因狀態轉移而伴隨的動作)

氣泡任務狀態圖

氣泡任務間展示狀態圖

2、失敗設計思維

針對氣泡任務,失敗設計思維的側重在於防禦式編程和服務降級限流。在防禦式編程中,利用斷言型接口,對氣泡透傳前置條件校驗、狀態扭轉識別以及有效性檢驗。同時,在服務降級預案中,考慮到氣泡任務並不影響玩法頻道的用戶主流程,因此設計了兩種預案:一是獎勵資格和權益發放大面積失敗或異常時,氣泡任務全部降級處理;二是特定氣泡邏輯存在異常問題時,該氣泡降級關閉。此外,設定服務限流閾值,在大促流量高峯時保護系統穩定。

3、異常錯誤處理

異常錯誤處理主要在於失敗後的反應動作和前臺用戶表達。氣泡任務狀態轉移中,會存在獎勵資格和權益發放失敗的現象。失敗的發生有着難以枚舉的原因。針對失敗,首先保持冪等性,進行系統重試或者用戶行爲重試;其次,失敗異常日誌輸出,利用錯誤碼設計儘可能準確描述失敗原因;最後,異常和錯誤監控,基於分鐘級錯誤日誌統計報警,開發同學可第一時間介入定位問題。另外重要的一點是,由於真正使用的是用戶,所以前臺表達一定要是友好的、便於理解的,不然歧義的表述會造成大面積輿情發生。

基於上述三點,貫穿氣泡任務的設計、開發等過程,不同維度地保證了系統魯棒性。此外,在實際開發階段,氣泡任務採用了責任鏈模式來實現的,可動態調整氣泡間依賴關係,提供一定的擴展性。

代碼魯棒性

以具體場景和實例來描述代碼規範和技巧,提升代碼魯棒性和系統穩定性。

 代碼質量

在使用 java.util.stream.Collectors 類的 toMap() 方法轉爲 Map 集合時,一定要使用含有參數類型爲 BinaryOperator,參數名爲 mergeFunction 的方法,否則當出現相同 key 值時會拋出 IllegalStateException 異常。

「說明」參數 mergeFunction 的作用是當出現 key 重複時,自定義對 value 的處理策略。

正例:

List<Pair<String, Double>> pairArrayList = new ArrayList<>(3);
pairArrayList.add(new Pair<>("version", 6.19));
pairArrayList.add(new Pair<>("version", 10.24));
pairArrayList.add(new Pair<>("version", 13.14));
Map<String, Double> map = pairArrayList.stream().collect(
// 生成的map集合中只有一個鍵值對:{version=13.14}
Collectors.toMap(Pair::getKey, Pair::getValue, (v1, v2) -> v2));

反例:

String[] departments = new String[] {"iERP", "iERP", "EIBU"};
// 拋出IllegalStateException異常
Map<Integer, String> map = Arrays.stream(departments)
    .collect(Collectors.toMap(String::hashCode, str -> str));

在使用 java.util.stream.Collectors 類的 toMap() 方法轉爲 Map 集合時,一定要注意當 value 爲 null 時會拋 NPE 異常。

「說明」在 java.util.HashMap 的 merge 方法裏會進行如下的判斷

  public static <T> T requireNonNull(T obj) {
        if (obj == null)
            throw new NullPointerException();
        return obj;
    }

反例:

List<Pair<String, Double>> pairArrayList = new ArrayList<>(2);
pairArrayList.add(new Pair<>("version1", 4.22));
pairArrayList.add(new Pair<>("version2", null));
Map<String, Double> map = pairArrayList.stream().collect(
// 拋出NullPointerException異常
Collectors.toMap(Pair::getKey, Pair::getValue, (v1, v2) -> v2));

Collections 類返回的對象,如:emptyList()/singletonList() 等都是 immutable list,不可對其進行添加或者刪除元素的操作。

ArrayList 的 subList 結果不可強轉成 ArrayList,否則會拋出 ClassCastException 異常:在 subList 場景中,高度注意對父集合元素的增加或刪除,均會導致子列表的遍歷、增加、刪除產生 ConcurrentModificationException 異常。

「說明」subList() 返回的是 ArrayList 的內部類 SubList,並不是 ArrayList 本身,而是 ArrayList 的一個視圖,對於 SubList 的    所有操作最終會反映到原列表上。列表改動均會引起 checkForComodification 異常

 private void checkForComodification() {
        if (this.modCount != l.modCount)
            throw new ConcurrentModificationException();
    }

在使用 Collection 接口任何實現類的 addAll() 方法時,都要對輸入的集合參數進行 NPE 判斷。

「說明」在 ArrayList#addAll 方法的第一行代碼即 Object[] a = c.toArray();其中 c 爲輸入集合參數,如果爲 null,則直接拋出異常。

泛型通配符 <? extends T> 允許調用讀方法 T get() 獲取 T 的引用,但不允許調用寫方法 set(T)傳入 T 的引用(傳入 null 除外);<? super T > 允許調用寫方法 set(T)傳入 T 的引用,但不允許調用讀方法 T get()獲取 T 的引用(獲取 Object 除外)。

「說明」PECS (Producer Extends Consumer Super) 原則:如果需要返回 T,它是生產者(Producer),要使用 extends 通配符;如果需要寫入 T,它是消費者(Consumer),要使用 super 通配符。因此,頻繁往外讀取內容的,適合用 <? extends T>。經常往裏插入的,適合用 <? super T>。

不要在 foreach 循環裏進行元素的 remove/add 操作。remove 元素請使用 Iterator 方式,如果併發操作,需要對 Iterator 迭代器對象加鎖。

反例:

List<String> list = new ArrayList<>();
list.add("targetItem");
list.add("other");
for (String item : list) {
    if ("targetItem".equals(item)) {
        list.remove(item);
    }
}

正例:

Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
    String item = iterator.next();
    if (刪除元素的條件) {
        iterator.remove();
    }
}

禁止使用構造方法 BigDecimal(double) 的方式把 double 值轉化爲 BigDecimal 對象。

「說明」BigDecimal(double) 存在精度損失風險,在精確計算或值比較的場景中可能會導致業務邏輯異常。如:BigDecimal g = new BigDecimal(0.1f); 實際的存儲值爲:0.100000001490116119384765625

正例:

優先推薦入參爲 String 的構造方法,或使用 BigDecimal 的 valueOf 方法,此方法內部其實執行了 Double 的 toString,而 Double 的 toString 按 double 的實際能表達的精度對尾數進行了截斷。

    BigDecimal recommend1 = new BigDecimal("0.1");
    BigDecimal recommend2 = BigDecimal.valueOf(0.1);

獲取當前毫秒數:System.currentTimeMillis(); 而不是 new Date().getTime()

「說明」如果想獲取更加精確的納秒級時間值,使用 System.nanoTime 的方式。在 JDK8 中,針對統計時間等場景,推薦使用 Instant 類。

日期格式化時,傳入 pattern 中表示年份統一使用小寫的 y。

「說明」日期格式化時,yyyy 表示當天所在的年,而大寫的 YYYY 代表是 week in which year,意思是 當天所在的周屬於的年份,一週從週日開始,週六結束,只要本週跨年,返回的 YYYY 就是下一年。

正例:

表示日期和時間的格式如下所示

new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

 代碼性能

判斷所有集合內部的元素是否爲空,使用 isEmpty() 方法,而不是 size()==0 的方式。

「說明」任何 Collection.isEmpty() 實現的時間複雜度都是 O(1),但是某些 Collection.size() 實現的時間複雜度可能是 O(n) 。

如 ConcurrentLinkedQueue 的 size() 是將所有元素重新統計了一遍,因此時間複雜度爲 O(n)。

正例:

Map<String, Object> map = new HashMap<>(16);
if(map.isEmpty()) {
    System.out.println("no element in this map.");
}

集合初始化時,指定集合初始值大小。

「說明」HashMap 使用如下構造方法進行初始化,如果暫時無法確定集合大小,那麼指定默認值(16)即可;如果 hashMap 存放元素較多,由於沒有設置容量初始大小,隨着元素增加而被迫不斷擴容,resize() 方法不斷調用,反覆重建哈希表和數據遷移。當放置的集合元素個數達千萬級時會影響程序性能。

  /**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

利用 Set 元素唯一的特性,可以快速對另一個集合進行去重操作,避免使用 List 的 contains() 進行遍歷去重或者判斷包含操作

 代碼優雅

外部正在調用或者二方庫依賴的接口,不允許修改方法簽名,避免對接口調用方產生影響。接口過時必須加 @Deprecated 註解,並清晰地說明採用的新接口或者新服務是什麼。

Object 的 equals 方法容易拋空指針異常,應使用常量或確定有值的對象來調用 equals。

「說明」推薦使用 JDK7 引入的工具類 java.util.Objects#equals(Object a, Object b)

正例:"test".equals(object)

反例:object.equals("test")

循環體內,字符串的聯接方式,使用 StringBuilder 的 append 方法進行擴展。

「說明」若直接用兩字符串拼接,反編譯出的字節碼文件顯示每次循環都會 new 出一個 StringBuilder 對象,然後進行 append 操作,最後通過 toString 方法返回 String 對象,造成內存資源浪費。

反例:

String str = "start";
for (int i = 0; i < 100; i++) {
    str = str + "hello";
}

 實戰 Case

代碼魯棒性是運用在編程過程中的,是過程導向結果產出的特性,所以並不能用一個典型案例覆蓋全部。但結合上文氣泡任務需求的設計,我們可以針對特定細節詳細表述。

當用戶進入互動玩法頻道後,代碼邏輯是先獲取所有當前氣泡任務列表,然後判斷其狀態,最後根據氣泡優先級進行過濾展示。其中氣泡過濾過程採用了責任鏈模式。流程圖如下所示:

核心 Filter

/**
 * 過濾器抽象
 *
 * @author la.lda
 * @date 4/12/21
 */
@Data
@Slf4j
public abstract class Filter {
    /**
     * 氣泡類型
     */
    public BubbleType bubbleType;
    /**
     * 上一氣泡過濾器
     */
    public Filter nextFilter;
    /**
     * 下一氣泡過濾器
     */
    public Filter beforeFilter;
    public Boolean beforeFilter(BubbleContext bubbleContext) {
        return true;
    }
    public void afterFilter(BubbleContext bubbleContext) {
    }
    /**
     * 氣泡過濾邏輯
     *
     * @param bubbleContext
     */
    abstract void Filter(BubbleContext bubbleContext);
    /**
     * 鏈式過濾器核心邏輯
     *
     * @param bubbleContext
     */
    void doFilter(BubbleContext bubbleContext) {
        if (bubbleType == null || bubbleContext == null || !bubbleContext.bubbleContextEffective()) {
            return;
        }
        if (!beforeFilter(bubbleContext)) {
            return;
        }
        Filter(bubbleContext);
        afterFilter(bubbleContext);
        if (nextFilter != null) {
            nextFilter.doFilter(bubbleContext);
        }
     }
}

在 doFilter 核心邏輯中,多處進行了判空和有效性檢查,是防禦式編程的典型行爲。此處沒有用到 try catch 捕獲異常,其考慮是爲了將異常傳導到業務層,利於定位問題,因此在業務調用處存在 try catch 的異常處理。

總結

魯棒性,是一種具有自我保護的系統特性,落實到細節的地方絕不止設計和開發環節。此外,上述設計和代碼建議,意圖不在於消除代碼的創新性,也不是以一種標準化的姿態限定代碼魔幻的邊界,而更多的是給出一種較好的方式處理做事。

系統魯棒性的構建絕不是一朝一夕就能搞定的,保持匠心精神、積累經驗、不斷學習纔是其根本。如何做到系統穩如泰山,也許是每一位開發同學共同的使命之一吧。

作者 | 鋰昂

編輯 | 橙子君

出品 | 阿里巴巴新零售淘系技術

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/gvZ009wPwkUmOAwy707Uaw