1. 程式人生 > >蜂鳥運單系統架構及實現

蜂鳥運單系統架構及實現

作者簡介

大錘,物流運單與服務負責人,同時也是運單系統最早的主力研發,運單系統這些年經歷了數次優化和重構,支撐起如今巨大的體量,大錘功不可沒

背景

運單系統是蜂鳥配送系統核心,支撐著所有配送業務。運單系統需要有很好的擴充套件性和穩定性,以應對網際網路產品千變化萬的更新迭代和大流量下的系統穩定。這幾年隨著蜂鳥業務的不斷髮展,使用者(消費者、商家、騎手、代理商)在產品功能和體驗上不斷提出新的要求。

蜂鳥每天會有上千萬的配送單量,每次上游的呼叫配送請求都會對後臺應用發出一系列呼叫。蜂鳥有多個上游流量入口,包括餓了麼商家呼叫蜂鳥配送、第三方平臺通過開放平臺(open api)接入方式呼叫蜂鳥配送、蜂鳥配送產品跑腿呼叫蜂鳥配送等。上游商戶有餐飲類外賣商戶,新零售超市、生鮮類商戶,零售類淘寶、天貓商戶等不同行業商戶的配送需求;各個行業不同型別商戶對配送的要求各有不同,餐飲類商戶配送要求較即時,配送範圍一般為商戶附近3公里範圍內,配送時效要求30分鐘左右;零售類商戶配送要求小時級,配送範圍有超10公里;也有配送要求當天送達,全城配送等。蜂鳥組織代理商、眾包和第三方運力完成配送,會根據商戶的配送要求,運力系統情況將運單分配給合適運力的騎手配送。且不同配送場景,運單履約過程各有不同,有普通外賣運單的到店、取餐配送,有零售類的前置倉配送,有取送模式的取分離和送分離配送等。一個好的運單系統需要有很好地擴充套件性和穩定性,運單系統作為物流基礎模組需要提前考慮到系統的擴充套件,為上層各產品系統提供強大的支撐。

業務架構.jpg

運單系統架構

運單系統核心是資料和狀態機,架構上分為流量接入、核心、運力對接、查詢、管理等功能模組。運單資訊主要包括基礎資訊、配送資訊、狀態資訊、起/終點資訊、費用資訊、屬性/畫像資訊等,運單系統負責抽象和定義運單資料結構,如何定義運單資料結構以支撐不同配送業務場景的資料儲存是系統設計上的一大難點。運單包括母單和子單,母單跟子單是一對多關係,運單系統根據上游配送請求和相應的唯一標誌生成母單,運單履約過程中會根據上游的不同配送要求和實際運力情況動態的生成多段子單以接力模式完成整個配送過程。運單定義標準狀態機,根據業務不同,定義不同的狀態機跳轉,子母單狀態互相影響,不支援逆向狀態機。

系統架構.jpg

資料儲存上運單儲存在三種資料介質:Mysql、Redis和ES。Mysql資料分為運單明細資料和運單查詢資料,兩類資料均以sharding方式儲存。因為描述一張運單的資訊屬性非常多,運單明細資料通過多張表儲存,包括運單基本資訊表、配送資訊表、狀態資訊表、起/終點資訊表、費用資訊表、屬性/畫像資訊表等。隨著運單系統支援的業務越來越多,業務越來越複雜,運單資料欄位又會根據資料的使用場合將公共可結構化的資料作為運單屬性儲存,業務方特有非公共屬性欄位資料以KV非結構化形式儲存在運單屬性資料表中。運單明細資料按物流商戶ID作為分割槽key分為512片儲存在32個數據庫叢集,每個叢集一主一備。運單查詢資料庫儲存運單關鍵ID的mapping關係,用於支援實時的多維查詢。Redis按資料塊快取運單明細資料,支援對一致性要求不是特別高的明細資料查詢。運單資料還會實時按天索引到ES中,支援複雜的較高,近實時的資料檢索和聚合計算。

運單明細資料庫按物流商戶ID分片,物流商戶ID是流量方商戶主體在物流側的對映,為自增ID,所以資料在不同的資料塊叢集上分佈非常均勻。

sharding資料結構32.png

運單明細資料儲存架構示例

因為蜂鳥即時配送模式下,單筆運單流轉有很強的地域特性,從使用者下單、到支付、到商家接單、到騎手到店取餐至送達使用者,整個運單的流轉發生在短短的幾十分鐘,一個運單的全生命週期基本可確保都發生在同一個shard(餓了麼多活地域概念,類似省份)。運單查詢資料是按餓了麼多活shard進行分片,因為運單有明確的shard資訊,且履單過程中涉及到的各個角色都有明確的地域特性,所以不同地域的請求操作會對應到不同的資料塊分片。但因為各個shard的業務量各有不同,我們會根據各個shard的業務量佔比,自定義shard的分割槽編碼,通過資料庫中介軟體DAL將shard流量對映到不同的資料庫叢集,儘可能的保證各個叢集的資料量均勻。

query資料庫.png

運單query資料儲存架構

運單接入

運單接入模組負責跟上游系統對接,將上游配送請求轉化成物流運單。運單接入模組是整個物流系統的入口模組,接入模組的穩定關乎整個物流,如何高效穩定地接入流量是該模組設計關鍵。

運單接入通過非同步方式跟上游系統進行對接,上游系統通過介面方式將呼單請求提交至接入模組,介面邏輯只做必要的引數驗證,引數驗證通過後接入模組會將請求引數記錄到資料庫並返回成功。因處理呼單請求的業務邏輯非常複雜,涉及多個內外部介面呼叫,接入模組內部通過執行緒池非同步方式處理呼單請求,通過訊息將呼單請求處理結果反饋給上游系統。

設計關鍵點:

  • 冪等:跟上游呼單系統約定請求唯一標識,當同一個呼單請求多次呼叫時,可通過唯一標識進行冪等處理,避免單子的重複生成。
  • 單方面保證原則:跟上游系統約定呼單遵循單方面保證原則,上游保證呼單介面呼叫成功,呼單模組保證請求處理成功。呼單介面內部邏輯足夠簡潔,處理邏輯極為簡單,當介面內部邏輯出錯,返回系統異常給上游系統,上游系統可基於系統異常進行補償重試,介面呼叫成功由上游系統保證。在介面邏輯中會儲存請求資料,當執行緒池處理請求出錯時,接入模組會有實時任務補償處理請求任務,可保證呼單請求一定處理成功。
  • 預處理:處理請求邏輯非常複雜,需在運單生成前準備好各種運單資料,包括當前運單所在地天氣資訊、商家使用者騎行距離等依賴外部資源資料。外部資源呼叫一般耗時較大且不穩定,如在處理任務環節實時呼叫外部系統,非常影響任務處理效率。接入模組一般會基於呼單的前置動作觸發呼單的資料預熱,將預熱好的資料進行快取,在準備運單資料時從快取獲取即可。如:基於使用者的下單動作就開始呼叫外部服務將天氣、距離等資訊獲取快取,使用者從下單到支付再到商戶呼叫物流配送中間時差必是秒級以上,所以預處理有足夠的時間將資料預熱好。同時,接入模組任務處理邏輯需要做好獲取預熱資料的降級邏輯。
  • 執行緒池:接入模組曾經嘗試過其他非同步元件(如:MQ)處理非同步任務,都因場景太關鍵,為減少關鍵鏈路上的依賴採用了執行緒池進行非同步處理請求任務。在請求資料寫入資料庫成功後,會在try catch中嘗試往執行緒池提交一個處理任務,當提交任務失敗,直接忽略,介面仍然返回成功,會通過分鐘級定時任務將未處理的請求拉起重新處理。通過執行緒池減少了介面邏輯中對其他訊息中介軟體的外部依賴,純記憶體操作,不會因內網、中介軟體問題等引起主流程阻塞。
  • 補償和隔離:當執行緒池任務處理慢會導致佇列堵塞,佇列滿了會導致繼續提交任務失敗。我們增加了每分鐘的任務做實時補償,將超過一定時間未處理的任務重新拉起執行。為避免相互影響,不同的流量呼單會隔離到不同的叢集,且補償任務也發生在單獨叢集。

主流程抽象

運單中心提供基礎的運單業務操作供各個上層系統呼叫,上游業務系統功能千變萬化,運單系統如何做到能快速地支援各業務系統功能快速開發迭代又能保證關鍵鏈路的穩定是運單主流程設計的關鍵。

我們將主流程對運單的操作分為三類:狀態類、資訊類和屬性類。狀態類操作是基於運單基礎狀態機配置依賴方需要的操作,供依賴方操作運單狀態。資訊類是配置化提供依賴方修改運單資訊的能力來修改運單基礎屬性資訊。屬性類是提供方便的資料介面,供依賴方回傳非公共、非結構化的運單資料。運單通過三種流程抽象,基本可以涵蓋大部分運單操作需求,避免頻繁定製化需求開發。

運單的業務操作底層是對運單資料進行修改,只是不同的業務動作操作的欄位和對應的業務校驗不同而已。我們將運單資料修改抽象如下(虛擬碼)流程:為了防止併發問題,運單在修改資料過程中我們加了分散式鎖;在鎖內我們獲取了運單的最新資料物件,然後copy成old和new兩個新的記憶體物件並存儲在threadlocal中;不同的業務邏輯會通過記憶體操作修改new物件的屬性值,業務邏輯修改的是記憶體運單new物件,此時並未將修改提交至資料庫;因為old物件描述的是修改前的運單資料,new物件描述的是業務邏輯修改後的運單資料,我們只需要在記憶體中compare出兩個物件的變化,就能提煉出本次業務邏輯對運單資料的修改;我們基於修改的明細資料以最小事物形式提交至運單基礎和查詢資料庫;同時我們還會觸發運單redis快取的刪除,但設定的超時極短,避免影響主流程;最後我們會觸發標準的運單topic訊息傳送。

lock(單號) {
	...
	1. get and copy
	2. 業務邏輯
	3. compare
	4. db
	5. redis
	6. MQ
	...
}
複製程式碼

狀態類運單流程抽象

配送狀態流程.jpg

運單基礎狀態機(餐飲類)

運單基礎狀態機定義運單最細粒度的可跳轉狀態,僅允許正向不可跳躍的狀態流轉。由於業務的多樣化,運單不僅需要提供基礎的運單狀態操作介面,還需要提供同狀態或跨越式運單狀態操作介面,且需要保證操作的原子性。如騎手端需要支援騎手快速取餐和轉單業務,快速取餐業務場景是運單還未分配騎手,騎手直接到店將運單取走進行配送,對於運單需要支援運單狀態從待分配騎手到騎手取餐配送中的狀態跳轉;轉單業務是支援騎手間轉單,對於運單屬於同狀態跳轉,有可能當前運單是待到店、待取餐或配送中。要支援如上兩個業務場景運單基礎操作動作無法滿足,若業務方自行組裝業務邏輯序列呼叫運單基礎操作介面,邏輯上無法保證業務動作的原子性。類似業務場景較多且非常雜,如何做到運單即不理解業務又能支援花式的業務邏輯是狀態類運單流程抽象的關鍵。

首先我們封裝了運單的基礎操作動作,如:accept()、assign()、arrival()等幾個基礎的運單操作,每個基礎的運單操作都會定義標準的輸入、內部業務校驗、資料影響。將不同的業務場景抽象成不同的操作code,配置操作code允許的起始狀態和終止狀態,內部執行時我們會根據基礎狀態機序列執行基礎運單操作,同時我們會merge基礎運單操作的引數描述對應到操作code。這樣,業務方有不同的業務需求時,我們只需要配置業務操作允許的起終點狀態,生成業務操作code和對應的引數描述,通過公共api呼叫傳入對應的操作code和引數即可完成業務呼叫。

基礎運單操作示例:

assign(a, b) {
	//業務邏輯
}
arrival(b, c) {
	//業務邏輯
}
fetch(b, d) {
	//業務邏輯
}
...
複製程式碼

快速取餐業務操作配置示例:

業務操作code 起點狀態列表 終點狀態列表 引數列表
quick_fetch [assign] [fetch] a,b,c,d
... [...] [...] ...

程式碼邏輯執行示例:

state_api(code, orderid, map{a, b, c, d}) {
	...
	lock(orderid) {
		1. get and copy
		2.{
			assign(a, b);
			arrival(b, c);
			fetch(b, d);
		}
		3. compare
		4. db
		5. redis
		6. MQ
	}
	...
}
複製程式碼

資訊修改類運單流程抽象

運單資訊修改類操作主要應對業務場景需要修改運單基礎屬性資訊的需求,如業務場景需要修改使用者電話號碼或商家經緯度等。運單內部定義運單可修改域對應的基礎修改方法和引數描述,業務場景code只需要配置業務操作code跟可修改域之間的關聯關係即可,一個業務場景需修改多個數據域只需要關聯多個可即可。

屬性域基礎操作方法示例:

customer(a, b) {
	Order.Customer.class.getMethod("setA", Object.class).invoke(new.getCustomer(), a);
	Order.Customer.class.getMethod("setB", Object.class).invoke(new.getCustomer(), b);
}
merchant(c, d) {
	Order.Merchant.class.getMethod("setC", Object.class).invoke(new.getMerchant(), c);
	Order.Merchant.class.getMethod("setD", Object.class).invoke(new.getMerchant(), d);
}
...
複製程式碼

業務修改操作配置示例:

業務操作code 操作列表 引數列表
modify_customer [customer] a,b
modify_merchant [merchant] c,d
modify_customer_and_merchant [customer, merchant] a,b,c,d
... [...] ...

程式碼邏輯執行示例:

modify_api(code, orderid, map{a, b, c, d}) {
	...
	lock(orderid) {
		1. get and copy
		2.{
			customer(a, b);
			merchant(c, d);
		}
		3. compare
		4. db
		5. redis
		6. MQ
	}
	...
}
複製程式碼

屬性類運單流程抽象

運單作為物流履約的資料基礎,業務方很多場合依賴運單儲存一些個性化資料,我們將這類資料儲存在運單的一個單獨kv資料塊中,以非結構化方式儲存。為了防止kv資料種類過多,不被業務方濫用,key由運單側定義,當需求方需要新增新的key時需要申請,運單側確認合理性後方可線上使用。另外,由於需求方新增屬性場景非常多,我們要求需求方根據業務場景定義key,同時支援追加方式新增key資料,儘可能把一類資料儲存在一塊,避免kv資料氾濫。同時,我們也會根據資料使用場合在運單查詢時結構化部分資料的返回,避免多方使用公共kv資料時各方都需要理解資料結構而進行解析,資料查詢篇幅會詳細講解kv資料的查詢邏輯。

kv方法示例:

addition(orderid, key, value) {
	//add kv
}
append_addition(orderid, key, map\<string, string\>) {
	//merge kv
}
...
複製程式碼

程式碼邏輯執行示例:

append_addition_api(orderid, key, map{a, b, c, d}) {
	...
	lock(orderid) {
		1. get and copy
		2.{
			append\_additiont(orderid, key, map{a, b, c, d});
		}
		3. compare
		4. db
		5. redis
		6. MQ
	}
	...
}
複製程式碼

參考資料

餓了麼多活





閱讀部落格還不過癮?

由餓了麼技術社群主辦的首屆物流技術開放日終於來啦!

時間: 2018年12月30日

地點:餓了麼上海總部:普陀區近鐵城市廣場北座5樓榴蓮酥

此次活動邀請到了物流團隊的6位重量級嘉賓。不僅會有前後端大佬分享最新的架構、演算法在物流團隊的落地實戰經驗,更有 P10 大佬教你如何在業務開發中獲得技術成長。當然,也會有各種技術書籍,紀念品拿到手軟,最後最重要的一點,完全免費!還等什麼,趕快點選 etech.ele.me/salon.html?… 瞭解更多細節並報名吧!

歡迎大家掃二維碼通過新增群助手,加入交流群,討論和部落格有關的技術問題,還可以和博主有更多互動

部落格轉載、線下活動及合作等問題請郵件至 [email protected] 進行溝通