談談對分佈式事務的一點理解和解決方案

前提

最近,工作中要為現在的老系統做拆分和升級,剛好遇到了分佈式事務、冪等控制、異步消息亂序和補償方案等問題,剛好基於實踐結合個人的看法記錄一下一些方案和思路。

分佈式事務

首先,做系統拆分的時候幾乎都會遇到分佈式事務的問題,一個仿真的案例如下:

j-t-s-i-a-1.png

項目初期,由於用戶體量不大,訂單模塊和錢包模塊共庫共應用(大war包時代),模塊調用可以簡化為本地事務操作,這樣做只要不是程序本身的BUG,基本可以避免數據不一致。後面因為用戶體量越發增大,基於容錯、性能、功能共享等考慮,把原來的應用拆分為訂單微服務和錢包微服務,兩個服務之間通過非本地事務操(這裡可以是HTTP或者消息隊列等)作進行數據同步,這個時候就很有可能由於異常場景出現數據不一致的情況。

事務中直接RPC調用達到強一致性

以上面的訂單微服務請求錢包微服務進行扣款並更新訂單狀態為扣款這個調用過程為例,假設採用HTTP同步調用,項目如果由經驗不足的開發者開發這個邏輯,可能會出現下面的偽代碼:

[訂單微服務請求錢包微服務進行扣款並更新訂單狀態]

處理訂單微服務請求錢包微服務進行扣款並更新訂單狀態方法(){
    [開啟事務]
    1、查詢訂單
    2、HTTP調用錢包微服務扣款
    3、更新訂單狀態為扣款成功
    [提交事務]
}

這是一個從肉眼上看起來沒有什麼問題的解決方法,HTTP調用直接嵌入到事務代碼塊內部,猜想最初開發者的想法是:HTTP調用失敗拋出異常會導致事務回滾,用戶重試即可;HTTP調用成功,事務正常提交,業務正常完成。這種做法看似可取,但是帶來了極大的隱患,根本原因是:事務中嵌入了RPC調用。假設兩種比較常見的情況:

  • 1、上面方法中第2步由於錢包微服務本身各種原因導致扣款接口響應極慢,會導致上面的處理方法事務(準確來說是數據庫連接)長時間掛起,持有的數據庫連接無法釋放,會導致數據庫連接池的連接耗盡,很容易導致訂單微服務的其他依賴數據庫的接口無法響應。
  • 2、錢包微服務是單節點部署(並不是所有的公司微服務都做得很完善),升級期間應用停機,上面方法中第2步接口調用直接失敗,這樣會導致短時間內所有的事務都回滾,相當於訂單微服務的扣款入口是不可用的。
  • 3、網絡是不可靠的,HTTP調用或者接受響應的時候如果出現網絡閃斷有可能出現了服務間狀態不能互相明確的情況,例如訂單微服務調用錢包微服務成功,接受響應的時候出現網絡問題,會出現扣款成功但是訂單狀態沒有更新的可能(訂單微服務事務回滾)。

j-t-s-i-a-2.png

儘管現在有Hystrix等框架可以基於線程池隔離調用或者基於熔斷器快速失敗,但是這是收效甚微的。因此,個人認為事務中直接RPC調用達到強一致性是完全不可取的,如果使用了這種方式實現”分佈式事務”建議整改,否則只能每天祈求下游服務或者網絡不出現任何問題。

事務中進行異步消息推送

使用消息隊列進行服務之間的調用也是常見的方式之一,但是使用消息隊列交互本質是異步的,無法感知下游消息消費方是否正常處理消息。用前一節的例子,假設採用消息隊列異步調用,項目如果由經驗不足的開發者開發這個邏輯,可能會出現下面的偽代碼:

[訂單微服務請求錢包微服務進行扣款並更新訂單狀態]

處理訂單微服務請求錢包微服務進行扣款並更新訂單狀態方法(){
    [開啟事務]
    1、查詢訂單
    2、推送錢包微服務扣款消息(推送消息)
    3、更新訂單狀態為扣款成功
    [提交事務]
}

上面的處理方法如果抽象一點表示如下:

方法(){
    DataSource  dataSource = xx;
    Connection con = dataSource.getConnection();
    con.setAutoCommit(false);
    try{
       1、SQL操作;
       2、推送消息;
       3、SQL操作;
       con.commit();
    }catch(Exception e){
        con.rollback();
    }finally{
        釋放其他資源;
        release(con);
    }
}

這樣做,在正常情況下,也就是能夠正常調用消息隊列中間件推送消息成功的情況下,事務是能夠正確提交的。但是存在兩個明顯的問題:

  • 1、消息隊列中間件出現了異常,無法正常調用,常見的情況是網絡原因或者消息隊列中間件不可用,會導致異常從而使得事務回滾。這種情況看起來似乎合情合理,但是仔細想:為什麼消息隊列中間件調用異常會導致業務事務回滾,如果中間件不恢復,這個接口調用豈不是相當於不可用?
  • 2、如果消息隊列中間件正常,消息正常推送,但是第3步由於SQL存在語法錯誤導致事務回滾,這樣就會出現了下游微服務被調用成功,本地事務卻回滾的問題,導致了上下游系統數據不一致。

j-t-s-i-a-3.png

總的來說:事務中進行異步消息推送是一種並不可靠的實現

目前業界提供的解決方案

業界目前主流的分佈式事務解決方案主要有:多階段提交方案(2PC、3PC)、補償事務(TCC)和消息事務(主要是RocketMQ,基本思想也是多階段提交方案,並且基於中間提供件輪詢和重試,其他消息隊列中間件並沒有實現分佈式事務)。這些方案的原理在此處不展開,目前網絡中相應資料比較多,小結一下它們的特點:

  • 多階段提交方案:常見的有二階段和三階段提交事務,需要額外的資源管理器來協調事務,數據一致性強,但是實現方案比較複雜,對性能的犧牲比較大(主要是需要對資源鎖定,等待所有事務提交才能解鎖),不適用於高並發的場景,目前比較知名的有阿里開源的fescar
  • 補償事務:一般也叫TCC,因為每個事務操作都需要提供三個操作嘗試(Try)、確認(Confirm)和補償/撤銷(Cancel),數據一致性的強度比多階段提交方案低,但是實現的複雜度會有所降低,比較明顯的缺陷是每個業務事務需要實現三組操作,有可能出現過多的補償方案的代碼;另外有很多輸完液場景TCC是不合適的。
  • 消息事務:這裡只談RocketMQ的實現,一個事務的執行流程包括:發送預消息、執行本地事務、確認消息發送成功。它的消息中間件存儲了下游無法消費成功的消息,並且不斷重試推送下游消費消息,而生產者(上游)需要提供一個check接口,用於檢查成功發送預消息但是未確認最終消息發送狀態的事務的狀態。

項目實踐中最終使用的方案

個人所在的公司的技術棧中沒有使用RocketMQ,主要使用RabbitMQ,所以需要針對RabbitMQ做消息事務的適配。目前業務系統中消息異步交互存在三種場景:

  • 1、消息推送實時性高,可以接受丟失。
  • 2、消息推送實時性低,不能丟失。
  • 3、消息推送實時性高,不能丟失。

最終敲定使用了本地消息表的解決方案,這個方案十分簡單:

j-t-s-i-a-4.png

主要思路是:

  • 1、需要發送到消費方的消息的保存和業務處理綁定在同一個本地事務中,需要額外建立一張本地消息表。
  • 2、本地事務提交之後,可以在事務外對本地消息表進行查詢並且進行消息推送,或者採用定時調度輪詢本地消息表進行消息推送。
  • 3、下游服務消費消息成功可以回調一個確認到上游服務,這樣就可以從上游服務的本地消息表刪除對應的消息記錄。

偽代碼如下:

[消息推送實時性高,可以接受丟失-這種情況下可以不需要寫入本地消息表 - start]
處理方法(){
    [本地事務開始]
    1、處理業務操作
    [本地事務提交]
    2、組裝推送消息並且進行推送
}
[消息推送實時性高,可以接受丟失-這種情況下可以不需要寫入本地消息表 - end]

[消息推送實時性低,不能丟失 - start]
處理方法(){
    [本地事務開始]
    1、處理業務操作
    2、組裝推送消息並且寫入到本地消息表
    [本地事務提交]
}

消息推送調度模塊(){
    3、查詢本地消息表待推送數據進行推送
}
[消息推送實時性低,不能丟失 - end]

[消息推送實時性高,不能丟失 - start]
處理方法(){
    [本地事務開始]
    1、處理業務操作
    2、組裝推送消息並且寫入到本地消息表
    [本地事務提交]
    3、消息推送
}

消息推送調度模塊(){
    4、查詢本地消息表待推送數據進行推送
}
[消息推送實時性高,不能丟失 - end]
  • 對於”消息推送實時性高,可以接受丟失”這種情況,實際上不用依賴本地消息表,只要在業務操作事務提交之後組裝和推送消息即可,這種情況會存在因為消息隊列中間件不可用或者本地應用宕機導致消息丟失的問題(本質是因為數據是內存態,非持久化),可靠性不高,但是絕大多數情況下是沒有問題的。如果使用spring-tx的聲明式事務@Transactional或者編程式事務TransactionTemplate,可以使用事務同步器實現嵌入於業務操作事務代碼塊中的RPC操作延後到事務提交後執行,這樣子RPC調用的代碼物理位置就可以放置在事務代碼塊內,例如:
@Transactional(rollbackFor = RuntimeException.class)
public void process(){
	1.處理業務邏輯
	TransactionSynchronizationManager.getSynchronizations().add(new TransactionSynchronizationAdapter() {
		@Override
		public void afterCommit() {
			2.進行消息推送
		}
	});
}

對於使用到本地消息表的場景,需要警惕下面幾個問題:

  • 1、注意本地消息表盡量不要長時間積壓數據,推送成功的數據需要及時刪除。
  • 2、本地消息表的數據在查詢並且推送的時候,需要設計最大重試次數上限,達到上限仍然推送失敗的記錄需要進行預警和人為干預。
  • 3、如果入庫的消息體比較大,查詢可能消耗的IO比較大,需要考慮拆分單獨的一張消息內容表用於存放消息體內容,而經常更變的列應該單獨拆分到另外一張表。

例如本地消息表的設計如下:

CREATE TABLE `t_local_message`(
  id BIGINT PRIMARY KEY COMMENT '主鍵',
  module INT NOT NULL COMMENT '消息模塊',
  tag VARCHAR(20) NOT NULL COMMENT '消息標籤',
  business_key VARCHAR(60) NOT NULL COMMENT '業務鍵',
  queue VARCHAR(60) NOT NULL COMMENT '隊列',
  exchange VARCHAR(60) NOT NULL COMMENT '交換器',
  exchange_type VARCHAR(10) NOT NULL COMMENT '交換器類型',
  routing_key VARCHAR(60) NOT NULL COMMENT '路由鍵',
  retry_times TINYINT NOT NULL DEFAULT 0 COMMENT '重試次數',
  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '創建日期時間',
  edit_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改日期時間',
  seq_no VARCHAR(60) NOT NULL COMMENT '流水號',
  message_status TINYINT NOT NULL DEFAULT 0 COMMENT '消息狀態',
  INDEX idx_business_key(business_key),
  INDEX idx_create_time(create_time),
  UNIQUE uniq_seq_no(seq_no)
)COMMENT '本地消息表';


CREATE TABLE `t_local_message_content`(
  id BIGINT PRIMARY KEY COMMENT '主鍵',
  message_id BIGINT NOT NULL COMMENT '本地消息表主鍵',
  message_content TEXT COMMENT '消息內容',
  UNIQUE uniq_message_id(message_id)
)COMMENT '本地消息內容表';

分佈式事務小結

個人認為,解決分佈式事務的最佳實踐就是:

  • 規避使用強一致性的分佈式事務實現,基本觀念就是放棄ACID投奔BASE
  • 推薦使用消息隊列進行系統間的解耦,消息推送方為了確保消息推送成功可以獨立附加消息表把需要推送的消息和業務操作綁定在同一個事務內,使用異步或者調度的方式進行推送。
  • 消息推送方(上游)需要確保消息正確投遞到消息隊列中間件,消息消費或者補償方案由消息消費方(下游)自行解決,關於這一點後文一個章節專門解釋。

其實,對於一致性和實時性要求相對較高的分佈式事務的實現,使用消息隊列解耦也有對應的解決方案。

冪等控制

冪等(idempotence)這個術語原文來自於HTTP/1.1協議中的定義:

Methods can also have the property of 「idempotence」 in that (aside from error or expiration issues) the side-effects of N > 0 identical requests is the same as for a single request.

簡單來說就是:除了錯誤或者過期的請求(換言之就是成功的請求),無論多次調用還是單次調用最終得到的效果是一致的。通俗來說,有一次調用成功,採用相同的請求參數無論調用多少次(重複提交)都應該返回成功。

下游服務對外提供服務接口,必須承諾實現接口的冪等性,這一點在分佈式系統中極其重要。

  • 對於HTTP調用,承諾冪等性可以避免表單或者請求操作重複提交造成業務數據重複。
  • 對於異步消息調用,承諾冪等性通過對消息去重處理也是用於避免重複消費造成業務數據重複。

目前實踐中對於冪等的處理使用了下面三個方面的控制:

  • 1、實現冪等的接口調用時入口使用分佈式鎖,使用了主流的Redisson,控制鎖的粒度和鎖的等待、持有時間在合理範圍(筆者所在行業要求數據必須準確無誤,所以幾乎用悲觀鎖設計所有核心接口,寧願慢也不能錯,實際上如果衝突比較低的時候為了性能優化可以考慮使用樂觀鎖)。
  • 2、業務邏輯上的防重,例如創建訂單的接口先做一步通過訂單號查詢庫表中是否已經存在對應的訂單,如果存在則不做處理直接返回成功。
  • 3、數據庫表設計對邏輯上唯一的業務鍵做唯一索引,這個是通過數據庫層面做最後的保障。

舉一個基於消息消費冪等控制的偽代碼例子:

[處理消息消費]
listen(request){
    1、通過業務鍵構建分佈式鎖的KEY
    2、通過Redisson構建分佈式鎖並且加鎖
    3、加鎖代碼中執行業務邏輯(包括去重判斷、事務操作和非事務操作等)
    4、finally代碼塊中釋放分佈式鎖
}

補償方案

補償方案主要是HTTP同步調用的補償和異步消息消費失敗的補償。

HTTP同步調用補償

一般情況下,HTTP同步調用會得到下游系統的同步結果,對結果的處理存在下面幾種常見的情況:

  • 1、同步結果返回正常,得到了和下游約定的最終狀態,交互結束,一般認為成功就是最終狀態,不需要補償。
  • 2、同步結果返回正常,得到了和下游約定的最終狀態,需要定時補償到最終狀態或到達重試上限自行標記為最終狀態。
  • 3、同步結果返回異常,最常見的是下游服務不可用返回HTTP狀態碼為5XX。

首先要有一個簡單的認知:短時間內的HTTP重試通常情況下都是無效的。如果是瞬時的網絡抖動,短時間內HTTP同步重試是可行的,大部分情況下是下游服務無法響應、下游服務重啟中或者複雜的網絡情況導致短時間內無法恢復,這個時候做HTTP同步重試調用往往是無效的。

如果面對的場景是內部低並發量的系統之間的進行HTTP交互,可以考慮使用基於指數退避的算法進行重試,舉個例子:

1、第一次調用失敗,馬上進行第二次重試
2、第二次重試失敗,線程休眠2秒
3、第三次重試失敗,線程休眠4秒(2^2)
4、第四次重試失敗,線程休眠8秒(2^8)
5、第五次重試失敗,拋出異常

如果上面的例子中使用了Hystrix控制超時為1秒包裹着要執行的HTTP命令進行調用,上面的重試過程最大耗時小於20秒,在低並發的內部系統之間的交互是可以接受的。

但是,如果面對的是並發比較高、用戶體驗優先級比較高的場景,這樣做顯然是不合理的。為了穩妥起見,可以採取相對傳統而有效的方案:HTTP調用的調用瞬時內容保存到一張本地重試表中,這個保存操作綁定在業務處理的事務中,通過定時調度對未調用成功的記錄進行重試。這個方案和上文提到保證消息推送成功的方案類似,舉一個仿真的例子:

[下單接口請求下游錢包服務扣錢的過程]
process(){
    [事務代碼塊-start]
    1、處理業務邏輯,保存訂單信息,訂單狀態為扣錢處理中
    2、組裝將要向下游錢包服務發起的HTTP調用信息,保存在本地表中
    [事務代碼塊-end]
    3、事務外進行HTTP調用(OkHttp客戶端或者Apache的Http客戶端),調用成功更新訂單狀態為扣錢成功
}

定時調度(){
    4、定時查詢訂單狀態為扣錢處理中的訂單進行HTTP調用,調用成功更新訂單狀態為扣錢成功
}

異步消息消費失敗補償

異步消息消費失敗的場景發生只能在消息消費方,也就是下游服務。從降低成本的目的上看,消息消費失敗的補償應該由消息處理的一方(消費者)自行承擔,畫一個系統交互圖理解一下:

j-t-s-i-a-5.png

如果由上游服務進行補償,存在兩個明顯的問題:

  • 1、消息補償模塊需要在所有的上游服務中編寫,這是不合理的。
  • 2、一旦下游消費出現生產問題需要上游補償,需要先定位出對應的消息是哪個上游服務推送,然後通過該上游服務進行補償,處理生產問題的複雜度提高。

在最近的一些項目實踐中,確定在使用異步消息交互的時候,補償統一由消息消費方實現。最簡單的方式也是使用類似本地消息表的方式,把消費失敗的消息入庫,並且進行重試,到達重試上限依然失敗則進行預警和人工介入即可。簡單的流程圖如下:

j-t-s-i-a-6.png

異步消息亂序解決

異步消息亂序是使用消息隊列進行異步交互場景中需要考慮和解決的問題。下面舉一些可能不合乎實際但是能夠說明問題的例子。

場景一:上游某個服務向用戶服務通過消息隊列異步修改用戶的性別信息,假設消息簡化如下:

隊列:user-service.modify.sex.qeue
消息:
{
   "userId": 長整型,
   "sex": 字符串,可選值是MAN、WOMAN和UNKNOW
}  

用戶服務一共使用了10個消費者線程監聽user-service.modify.sex.qeue隊列。假設上游服務先後向user-service.modify.sex.qeue隊列推送下面兩條消息:

第一條消息:
{
   "userId": 1,
   "sex": "MAN"
}  

第二條消息:
{
   "userId": 1,
   "sex": "WOMAN"
}  

上面的消息推送和下游處理有比較高几率出現下面的情況:

j-t-s-i-a-7.png

原本用戶ID為1的用戶先把性別改為MAN(第一次請求),後來改為WOMAN(第二次請求),最終看到更新後的性別有可能是MAN,這顯然是不合理的。這個不是很合理的例子想說明的問題是:通過異步消息交互,下游服務處理消息的時序有可能和上游發送消息的時序並不一致,這樣有可能導致業務狀態錯亂。對於解決這個問題,提供幾個可行的思路:

  • 方案一:並發要求不高的情況下,可以充分利用消息隊列FIFO的特性(這一點RabbitMQ實現了,其他消息隊列中間件不確定),把下游服務的消費線程設置為1即可,那麼上游推送的消息和下游消費消息的時序是一致的。
  • 方案二:使用HTTP調用,這個要前端或者APP客戶端配合,請求設計成串行的即可。

場景二:沒有時序要求的異步消息處理,但是要求最終展示的時候是有時序的。這樣說可能有點抽象,舉個例子:在借唄上借了10000元,還款的時候,用戶是分多次還清(例如還款方案一:2000,3000,5000;還款方案二:1000,1000,1000,7000等等),每次還的錢都不一樣,最終要求賬單展示的時候是按照用戶的還款操作順序。

假設借唄的上游服務和它通過異步消息交互。詳細分析一下:這個場景其實對於借唄(主要是考慮收回用戶的還款這個目的)來說,對用戶還款的順序並不需要感知,只需要考慮用戶是否還清,但是使用異步交互,有可能導致下游無法正確得知用戶還款的操作順序。

解決方案很簡單:推送消息的時候附加一個帶有增長或者減少趨勢的標記位即可,例如使用帶有時間戳的標記位或者使用Snowflake算法生成自增趨勢的長整型數作為流水號,之後按照流水號排序即可得到消息操作的順序(這個流水號下游需要保存),但是實際消息處理的時候並不需要感知消息的時序。

異步消息結合狀態驅動

個人認為:異步消息結合狀態驅動是可以相對完善地解決分佈式事務,結合預處理(例如預扣除或者預增長)可以滿足比較高一致性和實時性。先引出一個經常用來討論分佈式事務強一致性的轉賬場景。

j-t-s-i-a-8.png

解決這個問題如果使用同步調用(其實像TCC2PC或者3PC等本質都是同步調用),在允許性能損失的情況下是能夠達到強一致性。這一節並不討論同步調用的情況下怎麼做,重點研究一下在使用消息隊列的情況下,如何從BASE的角度”達到比較高的一致性”。先把這個例子抽象化,假設兩個系統的賬戶表都設計成這樣:

CREATE TABLE `t_account`(
    id BIGINT PRIMARY KEY COMMENT '主鍵',
    user_id BIGINT NOT NULL COMMENT '用戶ID',
    balance DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '賬戶餘額',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '創建時間',
    edit_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改時間',
    version BIGINT NOT NULL DEFAULT 0 COMMENT '版本'
    // 省略索引
)COMMENT '賬戶表';

兩個系統都可以建立一張表結構相似的金額變更流水表,上游系統用於做預扣操作和流水記錄,下游系統用於做流水記錄,接着我們可以梳理出新的交互時序邏輯如下:

[A系統本地事務-start]
1、A系統t_account表X用戶餘額減去1000
2、A系統流水表寫入一條用戶X的預扣1000的記錄,標記狀態為處理中,生成全局唯一的流水號記為SEQ_NO
[A系統本地事務-end]
3、A系統通過消息隊列推送一條用戶X扣減1000的消息(一定要附帶流水號SEQ_NO)到消息隊列中間件(這裡可以用上文提到的技巧確保消息推送成功)
[B系統本地事務-start]
4、B系統t_account表X用戶餘額加上1000
5、B系統流水表寫入一條用戶X的餘額變更(增加)1000的記錄 <= 注意這裡B系統的流水只能insert不能update
[B系統本地事務-end]
6、B系統推送處理X用戶餘額處理成功的消息到消息隊列中間件,一定要附帶流水號SEQ_NO(這裡可以用上文提到的技巧確保消息推送成功)
[A系統本地事務-start]
7、A系統更新流水表中X用戶流水號為SEQ_NO的預扣記錄的狀態為處理成功(這一步一定要做好冪等控制,可以考慮用SEQ_NO作為分佈式鎖的KEY)
[A系統本地事務-end]

其他:
[A系統流水表處理中的記錄需要定時輪詢和重試]
1、定時調度重試A系統流水表中狀態為處理中的記錄

[A-B系統日切對賬模塊]
1、日切,用A系統中處理成功的T-1日流水記錄和B系統中的流水表所有T-1日的記錄進行對賬

j-t-s-i-a-9.png

上面的步驟看起來比較多,而且還需要編寫對賬和重試模塊。其實,在上下游系統、消息隊列中間件都正常運作的情況下,上面的這套交互方案可承受的並發量遠比同步方案高,出現了服務或者消息隊列中間件不可用的情況下,由於流水表有未處理的本地記錄,在這些問題恢復之後可以重試,可靠性也是比較高的。另外,重試和對賬的模塊,對於所有涉及金額交易的處理都是必須的,這一點其實選用同步或者異步交互方式並沒有關係。

小結

你會發覺,通篇文章有很多方案都是使用了待處理內容寫入本地表 + 事務外實時觸發 + 定時調度補償這個模式,其實我想表達的就是這個模式是目前分佈式解決方案中一個相對通用的模式,可以基本滿足分佈式事務、同步異步補償、實時非實時觸發等多種複雜場景的處理。這個模式也存在一些明顯的問題(如果實踐過的話一般會遇到):

  • 1、庫表(本地消息表)設計不合理或者處理不合理容易成為數據庫的瓶頸。
  • 2、補償或者本地表入庫處理的邏輯代碼容易冗餘和腐化。
  • 3、極端情況下,異常恢復的場景存在拖垮服務的隱患。

其實,更多的時候需要結合現有的系統或者場景進行分析,通過數據監控和分析進行後續優化。畢竟,架構是迭代出來,而不是設計出來的

(本文完 e-a-20190323 c-14-d 996 這是一篇2019年3月底寫的文章,現在發出來希望還沒有過時)

技術公眾號《Throwable文摘》(id:throwable-doge),不定期推送筆者原創技術文章(絕不抄襲或者轉載):