蜂鳥運單系統架構及實現

做者簡介html

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

背景

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

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

業務架構.jpg

運單系統架構

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

系統架構.jpg

數據存儲上運單存儲在三種數據介質:Mysql、Redis和ES。Mysql數據分爲運單明細數據和運單查詢數據,兩類數據均以sharding方式存儲。由於描述一張運單的信息屬性很是多,運單明細數據經過多張表存儲,包括運單基本信息表、配送信息表、狀態信息表、起/終點信息表、費用信息表、屬性/畫像信息表等。隨着運單系統支持的業務愈來愈多,業務愈來愈複雜,運單數據字段又會根據數據的使用場合將公共可結構化的數據做爲運單屬性存儲,業務方特有非公共屬性字段數據以KV非結構化形式存儲在運單屬性數據表中。運單明細數據按物流商戶ID做爲分區key分爲512片存儲在32個數據庫集羣,每一個集羣一主一備。運單查詢數據庫存儲運單關鍵ID的mapping關係,用於支持實時的多維查詢。Redis按數據塊緩存運單明細數據,支持對一致性要求不是特別高的明細數據查詢。運單數據還會實時按天索引到ES中,支持複雜的較高,近實時的數據檢索和聚合計算。後端

運單明細數據庫按物流商戶ID分片,物流商戶ID是流量方商戶主體在物流側的映射,爲自增ID,因此數據在不一樣的數據塊集羣上分佈很是均勻。api

sharding數據結構32.png

運單明細數據存儲架構示例

由於蜂鳥即時配送模式下,單筆運單流轉有很強的地域特性,從用戶下單、到支付、到商家接單、到騎手到店取餐至送達用戶,整個運單的流轉發生在短短的幾十分鐘,一個運單的全生命週期基本可確保都發生在同一個shard(餓了麼多活地域概念,相似省份)。運單查詢數據是按餓了麼多活shard進行分片,由於運單有明確的shard信息,且履單過程當中涉及到的各個角色都有明確的地域特性,因此不一樣地域的請求操做會對應到不一樣的數據塊分片。但由於各個shard的業務量各有不一樣,咱們會根據各個shard的業務量佔比,自定義shard的分區編碼,經過數據庫中間件DAL將shard流量映射到不一樣的數據庫集羣,儘量的保證各個集羣的數據量均勻。緩存

query數據庫.png

運單query數據存儲架構

運單接入

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

運單接入經過異步方式跟上游系統進行對接,上游系統經過接口方式將呼單請求提交至接入模塊,接口邏輯只作必要的參數驗證,參數驗證經過後接入模塊會將請求參數記錄到數據庫並返回成功。因處理呼單請求的業務邏輯很是複雜,涉及多個內外部接口調用,接入模塊內部經過線程池異步方式處理呼單請求,經過消息將呼單請求處理結果反饋給上游系統。數據結構

設計關鍵點:

  • 冪等:跟上游呼單系統約定請求惟一標識,當同一個呼單請求屢次調用時,可經過惟一標識進行冪等處理,避免單子的重複生成。
  • 單方面保證原則:跟上游系統約定呼單遵循單方面保證原則,上游保證呼單接口調用成功,呼單模塊保證請求處理成功。呼單接口內部邏輯足夠簡潔,處理邏輯極爲簡單,當接口內部邏輯出錯,返回系統異常給上游系統,上游系統可基於系統異常進行補償重試,接口調用成功由上游系統保證。在接口邏輯中會保存請求數據,當線程池處理請求出錯時,接入模塊會有實時任務補償處理請求任務,可保證呼單請求必定處理成功。
  • 預處理:處理請求邏輯很是複雜,需在運單生成前準備好各類運單數據,包括當前運單所在地天氣信息、商家用戶騎行距離等依賴外部資源數據。外部資源調用通常耗時較大且不穩定,如在處理任務環節實時調用外部系統,很是影響任務處理效率。接入模塊通常會基於呼單的前置動做觸發呼單的數據預熱,將預熱好的數據進行緩存,在準備運單數據時從緩存獲取便可。如:基於用戶的下單動做就開始調用外部服務將天氣、距離等信息獲取緩存,用戶從下單到支付再到商戶呼叫物流配送中間時差必是秒級以上,因此預處理有足夠的時間將數據預熱好。同時,接入模塊任務處理邏輯須要作好獲取預熱數據的降級邏輯。
  • 線程池:接入模塊曾經嘗試過其餘異步組件(如: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?… 瞭解更多細節並報名吧!

歡迎你們掃二維碼經過添加羣助手,加入交流羣,討論和博客有關的技術問題,還能夠和博主有更多互動

博客轉載、線下活動及合做等問題請郵件至 shadowfly_zyl@hotmail.com 進行溝通

相關文章
相關標籤/搜索