設(shè)計與核心模塊源碼解析)
貨運行業(yè)做SaaS和做普通企業(yè)管理系統(tǒng)最大的區(qū)別在哪里我先把話說在前面不是功能堆得多而是架構(gòu)扛得住。一套系統(tǒng)要同時服務(wù)幾十上百家物流公司每家公司看到的都是自己的訂單、自己的司機、自己的賬單可底層跑的是同一套代碼、同一個數(shù)據(jù)庫集群——這種“看起來各用各的、實際上共用一套”的玩法就是多租戶架構(gòu)要解決的核心問題。這篇博文圍繞我實際落地的一個SaaS化貨運平臺展開重點拆兩層內(nèi)容一是多租戶架構(gòu)從租戶識別、數(shù)據(jù)隔離到權(quán)限控制的完整鏈路怎么設(shè)計二是訂單中心、計費中心、運力池這三個核心模塊的關(guān)鍵源碼逐行怎么分析。適合正在做SaaS產(chǎn)品化改造的后端工程師、架構(gòu)師以及想把物流運輸系統(tǒng)從單客戶定制升級成多租戶訂閱模式的同學(xué)參考。我盡量把每一步選的什么方案、為什么這么選、踩過什么坑都講清楚你們看完能直接拿去用。1. 項目背景與整體架構(gòu)拆解1.1 為什么貨運系統(tǒng)一定要走SaaS化先說行業(yè)現(xiàn)狀。傳統(tǒng)的物流運輸管理系統(tǒng)基本都是定制開發(fā)或者單租戶部署一家干線物流公司一套系統(tǒng)一套數(shù)據(jù)庫代碼庫還經(jīng)常因為客戶需求不同而分叉出好幾個版本。早期這么搞沒問題客戶少、功能簡單但問題會隨著規(guī)模增長越來越明顯。我接觸過一個做了七八年的運輸管理系統(tǒng)代碼倉庫里按客戶名建了五六套分支每套分支的訂單表字段都不完全一樣A客戶加了“是否需要回單”B客戶加了個“冷鏈溫度記錄”C客戶要“多點配送”。每加一個新客戶不是復(fù)制一份代碼再改就是在一堆if (customerType ...)里繼續(xù)堆邏輯。升級一個公共功能要在所有分支里各改一遍漏一個就是線上事故。這種模式運維成本爆炸而且小物流公司根本養(yǎng)不起獨立的系統(tǒng)開發(fā)團隊他們也需要一套能按月付費、開箱即用的系統(tǒng)。SaaS化解決的就是這個問題一套代碼、一套基礎(chǔ)服務(wù)所有租戶共用但邏輯上互相隔離。貨主登錄看到的是自己的發(fā)貨記錄司機看到的是自己的運單平臺管理員看到的是全量數(shù)據(jù)。新接入一個租戶不是給它單獨部署一套實例而是開一個租戶賬號、配置好它的計價規(guī)則和權(quán)限角色當天就能用起來。我自己做這個項目的時候最深刻的體會是SaaS化不是把原來的單租戶系統(tǒng)加一個tenant_id字段這么簡單。它是一個從表結(jié)構(gòu)設(shè)計、接口規(guī)范、權(quán)限模型、數(shù)據(jù)備份、消息隊列到定時任務(wù)全部要重新過一遍的架構(gòu)升級。標題里說的“多租戶架構(gòu)”絕不僅僅是數(shù)據(jù)庫隔離層面的事。1.2 多租戶隔離模式怎么選三種方案對比多租戶架構(gòu)第一個要拍板的問題就是租戶之間在數(shù)據(jù)庫層面怎么隔離。這個決定直接關(guān)系到成本、擴展性和運維復(fù)雜度。行業(yè)里主流方案有三種我列個對比表你一看就明白。隔離方案數(shù)據(jù)隔離強度單租戶成本運維復(fù)雜度適用場景獨立數(shù)據(jù)庫最強最高高每個庫都要監(jiān)控備份大型租戶、簽約大客戶、數(shù)據(jù)敏感共享數(shù)據(jù)庫、獨立Schema較強中中一套庫管理多套表結(jié)構(gòu)中型租戶要求一定程度隔離共享表、租戶ID區(qū)分最弱最低低共用表結(jié)構(gòu)小微租戶、標準業(yè)務(wù)場景絕大多數(shù)物流公司的主營業(yè)務(wù)——運單、車輛、司機、線路——業(yè)務(wù)流程是高度標準化的。所以我的選擇是默認用共享表方案業(yè)務(wù)表統(tǒng)一帶tenant_id靠代碼層強制隔離少數(shù)頭部大租戶確實有數(shù)據(jù)私有化要求或者它的訂單量級實在太大會單獨給它拆一個獨立數(shù)據(jù)庫也就是“共享為主、獨立為輔”的混合策略。為什么這么選我給你算筆賬。假設(shè)平臺有三百家租戶每家平均一天一千單。共享表方案只需要一套訂單表加一個租戶ID索引數(shù)據(jù)庫連接池、緩存、備份都統(tǒng)一管理。如果三百家全部獨立數(shù)據(jù)庫光連接數(shù)就夠嗆更別說備份、升級、遷移的工作量中小團隊根本扛不住。而少數(shù)大租戶獨立庫是劃算的因為它們的單量可能占全平臺的三成以上獨立出來能避免互相影響也方便定制?;谶@個決策整個系統(tǒng)的設(shè)計原則就定了所有業(yè)務(wù)表必須包含tenant_id字段所有寫SQL的地方必須保證租戶隔離條件不缺失所有跨租戶的數(shù)據(jù)查詢必須走專門的平臺管理接口禁止在業(yè)務(wù)接口里傳遞其他租戶ID。1.3 系統(tǒng)整體模塊地圖SaaS化貨運平臺不是一個簡單的CRUD項目它至少要包含下面幾大塊租戶中心租戶的注冊、開通、套餐配置、狀態(tài)管理是整個多租戶體系的基礎(chǔ)基礎(chǔ)資料中心司機、車輛、線路、客戶檔案、貨物類型的統(tǒng)一管理訂單中心運單從創(chuàng)建、分配、運輸?shù)胶炇盏娜芷诠芾碚{(diào)度中心運力池維護、自動派單、人工調(diào)度、異常改派計費中心計價規(guī)則配置、運費計算、費用明細生成結(jié)算中心對賬、司機結(jié)算單生成、付款狀態(tài)跟蹤報表中心各租戶的運單量、收入、成本、時效統(tǒng)計模塊之間的依賴關(guān)系我用一句話概括租戶中心管“誰在用”基礎(chǔ)資料管“有什么資源”訂單中心管“產(chǎn)生了什么業(yè)務(wù)”調(diào)度中心管“誰來干”計費結(jié)算中心管“賺了多少錢”。這五條線串起來就是一個貨運SaaS的主干。我在拆分這些模塊時最看重的一點是訂單狀態(tài)不能被計費邏輯和調(diào)度邏輯直接亂改。后面源碼分析你就能看到訂單狀態(tài)機是單獨封裝的其他模塊只能通過領(lǐng)域服務(wù)的方法來改變訂單狀態(tài)不允許自己update order set status ...一把梭。2. 多租戶架構(gòu)落地租戶識別與數(shù)據(jù)隔離2.1 租戶上下文如何從HTTP請求傳到數(shù)據(jù)庫多租戶架構(gòu)的第一道關(guān)卡是確定“當前請求屬于哪個租戶”。這個信息最早上游起源于登錄用戶的租戶歸屬——一個用戶登錄時后端校驗完賬號密碼就從它的用戶信息里拿到tenantId。后續(xù)整個請求生命周期里所有查詢和寫入都要帶上這個標識。我用的方案是ThreadLocal結(jié)合攔截器。簡單畫一下結(jié)構(gòu)public class TenantContext { private static final ThreadLocalString TENANT_ID new ThreadLocal(); public static void setTenantId(String tenantId) { TENANT_ID.set(tenantId); } public static String getTenantId() { return TENANT_ID.get(); } public static void clear() { TENANT_ID.remove(); } }攔截器在HTTP請求進入Controller之前解析Token從Token里解出租戶ID寫入TenantContext請求結(jié)束在afterCompletion里調(diào)用clear()清理。這樣Controller層、Service層、DAO層所有代碼都能通過TenantContext.getTenantId()拿到當前的租戶標識不用每個接口都手動傳參數(shù)。注意ThreadLocal看著簡單坑卻不少。最典型的兩個問題一是線程池復(fù)用導(dǎo)致租戶上下文串了二是用完沒清理導(dǎo)致內(nèi)存泄漏或臟數(shù)據(jù)。第一個問題在異步場景里特別容易出現(xiàn)。比如訂單創(chuàng)建后發(fā)MQ消息給調(diào)度中心MQ的消費線程池里的線程是復(fù)用的。如果消費任務(wù)里沒有設(shè)置TenantContext那這個線程處理完A租戶的消息后到處理B租戶的消息時上下文里可能還殘留著A租戶的ID。解決辦法是每次MQ消費入口顯式設(shè)置租戶上下文并且在消費完finally中清理。第二個問題是clear()必須放進finally里否則請求拋出異常時上下文不會被清理長連接線程會把上一次的租戶ID帶到下一次請求查出來的數(shù)據(jù)就是串的。我見過線上事故就是這個問題導(dǎo)致的一個司機登錄后看到的運單是另一個公司的嚇死人。2.2 DAO層自動注入租戶條件攔截器源碼怎么拼SQL確定了租戶上下文的傳遞下一個核心問題就是不能每個SQL都手動寫where tenant_id ?。項目里上百個Mapper方法手寫必然漏。我采用的是MyBatis攔截器方案在SQL執(zhí)行前自動解析和拼接租戶條件。先定義一個注解用來標記哪些表不需要租戶隔離Documented Target({ElementType.TYPE, ElementType.METHOD}) Retention(RetentionPolicy.RUNTIME) public interface TenantIgnore { }然后在MyBatis的攔截器里對查詢語句做處理Intercepts({ Signature(type Executor.class, method update, args {MappedStatement.class, Object.class}), Signature(type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) }) public class TenantLineInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { // 1. 判斷是否被 TenantIgnore 標記標記了直接放行 // 2. 從 TenanatContext 取租戶ID取不到直接放行可能是不需要租戶隔離的基礎(chǔ)數(shù)據(jù)操作 // 3. 改寫 SQL在 WHERE 后拼接 AND tenant_id ? // 4. 給 PreparedStatement 設(shè)置參數(shù) return invocation.proceed(); } }SQL改寫邏輯上我用的是JSqlParser這類工具把原始SQL解析成抽象語法樹找到對應(yīng)的表名在WHERE條件前面加上租戶條件。這里有一個細節(jié)要特別強調(diào)不是所有表都加租戶條件。比如系統(tǒng)級的字典表、租戶套餐配置表本身是全平臺共享的加上租戶條件反而查不到數(shù)據(jù)。所以TenantIgnore注解是必須的而且要在項目啟動時掃描一遍所有Mapper檢查哪些表既沒有被租戶表白名單包含、又沒有加注解直接啟動報錯把漏配的問題扼殺在編譯期。另外一個很重要的工程實踐是索引。共享表方案下所有業(yè)務(wù)表的數(shù)據(jù)都在一張表里tenant_id必須和其他業(yè)務(wù)唯一鍵組成聯(lián)合索引。舉個例子訂單表的索引不是只建order_no而是要建uk_tenant_order (tenant_id, order_no)。道理很簡單如果只建order_no唯一索引兩個租戶的訂單號都是DD20250600001時直接就沖突了。復(fù)合唯一索引既保證了租戶內(nèi)的單號唯一又保證了跨租戶不互相干擾。2.3 權(quán)限與數(shù)據(jù)范圍的聯(lián)動設(shè)計租戶隔離不等于權(quán)限控制。租戶隔離解決的是“A租戶不能看B租戶的數(shù)據(jù)”權(quán)限控制解決的是“同一個租戶里不同角色能看的數(shù)據(jù)范圍不一樣”。我實際用的是經(jīng)典的RBAC模型加數(shù)據(jù)范圍。一個租戶下的角色可以分為幾類租戶管理員能看全租戶數(shù)據(jù)調(diào)度員能看自己負責(zé)的線路和車輛司機只能看自己承接的運單。數(shù)據(jù)范圍的實現(xiàn)不是簡單在SQL里拼條件而是通過一個數(shù)據(jù)權(quán)限注解Documented Retention(RetentionPolicy.RUNTIME) Target(ElementType.METHOD) public interface DataScope { // 數(shù)據(jù)范圍類型ALL / DEPARTMENT_VEHICLE / ONLY_SELF DataScopeType value() default DataScopeType.ALL; }在AOP攔截器里根據(jù)當前登錄用戶的角色和數(shù)據(jù)范圍類型自動把額外的過濾條件加到SQL上。它與租戶條件疊加的時候SQL最終會變成where tenant_id ? and driver_id ?。這兩層條件要一起生效缺一個都不行。這里我想提醒一句數(shù)據(jù)權(quán)限和租戶隔離是兩個獨立維度一定要拆開設(shè)計。如果把它們混在一個攔截器里后面調(diào)整權(quán)限模型或者調(diào)整租戶隔離策略時候拆起來非常痛苦。我第一版就是圖省事寫在一起的后來加了個“按線路配數(shù)據(jù)權(quán)限”的需求改得頭皮發(fā)麻。3. 核心模塊源碼分析訂單中心與狀態(tài)機3.1 訂單模型字段怎么設(shè)計既靈活又統(tǒng)一訂單模塊是整個貨運平臺的中樞。貨主下單之后從報價、派車、取貨、干線運輸?shù)胶炇账辛鞒潭家杂唵螢楹诵牧鬓D(zhuǎn)。我先把訂單相關(guān)的核心表結(jié)構(gòu)梳理一下。訂單主表字段大致分幾類基礎(chǔ)信息order_no租戶內(nèi)唯一單號、tenant_id、order_type整車/零擔(dān)/冷鏈等、goods_type、weight、volume、pieces地址信息start_province、start_city、start_address、end_province、end_city、end_address、planned_distance時效信息expect_pickup_time、expect_delivery_time、actual_pickup_time、actual_delivery_time狀態(tài)信息status、current_node、cancel_reason、reject_reason來源信息channelAPP/小程序/接口/人工錄入、customer_id、platform_order_no此外還有兩張擴展表訂單貨物明細表一單多貨和訂單運費明細表運費、裝卸費、等候費等。我強烈建議不要把所有信息塞進訂單主表原因不是范式強迫癥而是貨運場景里一對多的情況實在太常見。一車貨發(fā)往三個不同城市的客戶這在零擔(dān)場景下天天見如果把貨物和費用全塞在主表里拆單和計費邏輯會被字段結(jié)構(gòu)活活逼瘋。3.2 訂單狀態(tài)機從下單到簽收的流轉(zhuǎn)規(guī)則訂單狀態(tài)是訂單中心最容易做崩的地方。如果每個狀態(tài)流轉(zhuǎn)都散落在Service層不同的方法里用一堆if (order.status 1 ...)判斷項目到后期會出現(xiàn)“這個狀態(tài)應(yīng)該能取消但代碼里忘了放行”之類的隱蔽bug。我在項目里用枚舉加狀態(tài)流轉(zhuǎn)表來實現(xiàn)狀態(tài)機核心代碼大致是這樣public enum OrderStatus { PENDING(10, 待接單), ACCEPTED(20, 已接單), PENDING_PICKUP(30, 待取貨), IN_TRANSIT(40, 運輸中), PENDING_SIGN(50, 待簽收), SIGNED(60, 已簽收), CANCELED(90, 已取消), EXCEPTION(99, 異常處理中); private final int code; private final String desc; }每個業(yè)務(wù)方法要修改訂單狀態(tài)的時候統(tǒng)一走領(lǐng)域服務(wù)public class OrderStatusService { private static final MapString, ListOrderStatus TRANSITIONS new HashMap(); static { // 待接單 - 已接單 / 已取消 TRANSITIONS.put(10-20, Arrays.asList(...)); // 已接單 - 待取貨 / 已取消 // 運輸中 - 待簽收 / 異常處理中 // 異常處理中 - 運輸中 / 已取消 } public void transition(Order order, OrderStatus target) { String key order.getStatus().getCode() - target.getCode(); if (!TRANSITIONS.containsKey(key)) { throw new IllegalStateException(非法狀態(tài)流轉(zhuǎn): key); } order.setStatus(target); } }為什么用狀態(tài)機因為訂單的流轉(zhuǎn)不是線性的。正常流程是待接單-已接單-待取貨-運輸中-待簽收-已簽收但中間隨時可能插入異常分支客戶取消、司機拒單、貨物破損、天氣延誤。這些分支和主流程之間要有清晰、可控的轉(zhuǎn)換關(guān)系。狀態(tài)機把流轉(zhuǎn)規(guī)則集中在一個地方管理任何非法流轉(zhuǎn)直接拋異常日志里能明確看到誰想從“運輸中”改到“已簽收”——這明顯是邏輯漏洞。還有一點經(jīng)驗業(yè)務(wù)狀態(tài)不要只用字符串或者數(shù)字散落在代碼里要配合狀態(tài)變更歷史表一起用。每次狀態(tài)變更都記錄一條流水order_id、from_status、to_status、operator_type、operator_id、change_reason、create_time。后面排查問題的時候這張表是救命稻草。有一次客戶投訴說“我明明沒收到貨系統(tǒng)卻顯示已簽收”我直接查狀態(tài)流水發(fā)現(xiàn)是某個腳本批量調(diào)用把狀態(tài)改錯了十分鐘定位到問題靠的全是流水。3.3 拆單、合單與改派的源碼邏輯訂單中心除了狀態(tài)流轉(zhuǎn)還有幾個繞不開的業(yè)務(wù)邏輯拆單、合單、改派。拆單場景最常見的是一票貨物有多個收貨地址。核心邏輯是按收貨城市分組把原來的父訂單拆成多個子訂單。這里的關(guān)鍵是父訂單本身的金額怎么分攤子訂單之間怎么關(guān)聯(lián)運費怎么重新計算。我的做法是拆單時保留父單實體但父單狀態(tài)變?yōu)椤耙巡鸱帧苯痤~清零實際業(yè)務(wù)流轉(zhuǎn)全部走子單子單通過parent_order_no關(guān)聯(lián)。合單則相反同一發(fā)貨地、同一收貨地、要求時效接近的多個訂單合并成一個運單由一個司機承運。合單不是簡單把兩個訂單的金額相加而要重新按合并后的貨物總重、總體積計價。這里要特別小心合單前必須校驗兩個訂單有沒有沖突貨物類型是否兼容常溫貨和冷鏈貨不能合并甚至要考慮貨物是否超載。我見過一次線上合單事故兩個訂單各1噸合單后總重2.5噸調(diào)度員沒注意就把單派給了一輛核載2噸的4米2車最終以超載拒運收場。所以合單邏輯里必須有重量、體積、車型匹配的校驗而且合并后要重新跑一遍運力匹配不能直接把原來某個司機的運單合并給另一個司機。改派邏輯相對簡單但要注意權(quán)限邊界只有調(diào)度員角色能改派司機不能自行轉(zhuǎn)單。改派時的關(guān)鍵操作是同時改訂單的driver_id和車輛 ID并且新增一條調(diào)度流水記錄誰改的、從哪個司機改到哪個司機、原因是什么方便運費結(jié)算時的責(zé)任劃分。4. 核心模塊源碼分析計費中心與規(guī)則引擎4.1 計價規(guī)則為何一定要配置化貨運計價是所有運輸系統(tǒng)中的“萬惡之源”因為每家公司都有自己的報價算法。有的按重量、有的按體積、有的按里程、有的按車次還有的是“起步價加噸公里價”的復(fù)合模式。如果計價邏輯在全代碼里寫死每接入一個新租戶就得改一次代碼SaaS就搞不下去了。對應(yīng)的設(shè)計思路是“計價規(guī)則引擎”。核心抽象是策略接口public interface PricingRule { // 判斷當前規(guī)則是否適用 boolean supports(PricingContext context); // 計算運費 BigDecimal calculate(PricingContext context); }然后每種計價方式一個實現(xiàn)類Component public class WeightBasedPricingRule implements PricingRule { Override public boolean supports(PricingContext context) { return WEIGHT.equals(context.getPricingType()); } Override public BigDecimal calculate(PricingContext context) { BigDecimal unitPrice context.getUnitPrice(); BigDecimal weight context.getTotalWeight(); return unitPrice.multiply(weight); } }配置層面每個租戶可以在后臺配置計價方式、單價、起步價、階梯價格。訂單創(chuàng)建時計價引擎讀取租戶的配置動態(tài)選擇對應(yīng)的規(guī)則實現(xiàn)。這樣做的好處是新租戶接入時只需要在后臺配置計價模板不需要發(fā)版、不需要改代碼真正做到了“開箱即用”。計價規(guī)則配置化之后還有一層要考慮同一個租戶下不同線路的報價可能不同。比如華東線每噸200元東北線每噸300元。所以計價配置上不能只掛在租戶級別要支持“租戶線路”級別的配置。我的做法是配置表設(shè)三檔優(yōu)先級全局模板 區(qū)域模板 線路模板計價引擎按優(yōu)先級取最高匹配級的配置。4.2 從計費快照到費用明細的完整鏈路有個很容易忽略但是非常重要的點費用計算的結(jié)果必須存快照。什么意思就是說訂單簽收那一刻算出來的運費是1300元那它就是1300元。哪怕后來管理員修改了計價規(guī)則把單價從200改成250歷史訂單的運費也不應(yīng)該變。實現(xiàn)思路是在訂單費用表里把計算時的規(guī)則快照存下來public class OrderFee { private String orderNo; private String tenantId; private String feeType; // 運費/裝卸費/等候費/保價費 private String pricingRuleId; // 計算用的規(guī)則ID private BigDecimal unitPrice; // 計算時的單價快照 private BigDecimal quantity; // 計算時的數(shù)量 private BigDecimal amount; // 最終金額 private String snapshotJson; // 整個規(guī)則上下文的快照 }snapshotJson字段里面存的是計算當時的完整上下文包括貨物類型、重量、體積、起終點、計價規(guī)則版本。后續(xù)如果對賬有爭議或者司機投訴“運費算錯了”我們可以直接調(diào)出快照看看當時到底是怎么算出來的。這個快照機制幫我擋住了無數(shù)次的扯皮。費用明細和賬單是兩回事。費用明細是一筆一筆的費用記錄賬單是給貨主看的匯總單據(jù)。我的賬單生成邏輯是一個訂單的所有費用明細按費用類型匯總生成賬單賬單狀態(tài)有“待確認”“已確認”“已支付”。結(jié)算中心在賬單確認后生成應(yīng)收記錄。4.3 平臺與司機的對賬清分邏輯SaaS貨運平臺是中間平臺從貨主那里收運費再向承運司機支付運輸報酬。中間的差價就是平臺利潤。這里有一個非常核心的對賬需求平臺的錢和第三方的錢要對平。主要包括兩類流水平臺應(yīng)收流水貨主支付了多少錢司機應(yīng)付流水平臺要給司機結(jié)算多少錢支付渠道流水第三方支付平臺的收款回調(diào)我對賬模塊的處理流程是定時任務(wù)拉取一段時間內(nèi)的平臺應(yīng)收單和支付渠道流水按平臺訂單號金額匹配匹配上的標記為“已核銷”匹配不上的進入“差異池”差異池里的記錄由運營人工處理可能是渠道手續(xù)費差異、可能是退款、可能是系統(tǒng)漏單對賬的代碼核心是匹配邏輯簡單版本如下public class ReconciliationService { public void reconcile(LocalDate bizDate) { ListReceiptRecord receipts receiptMapper.selectByDate(bizDate); ListChannelTransaction transactions channelMapper.selectByDate(bizDate); for (ReceiptRecord receipt : receipts) { // 嘗試按平臺單號和金額精確匹配 OptionalChannelTransaction matched transactions.stream() .filter(t - t.getOrderNo().equals(receipt.getOrderNo())) .filter(t - t.getAmount().compareTo(receipt.getAmount()) 0) .findFirst(); if (matched.isPresent()) { markMatched(receipt.getId(), matched.get().getId()); } else { markDifference(receipt.getId(), 金額不一致或渠道無記錄); } } } }司機結(jié)算單的生成是另一條鏈路訂單簽收后系統(tǒng)按承運司機、結(jié)算周期按單/按天/按周把司機的應(yīng)得收入?yún)R總成結(jié)算單。這里司機和貨主的計價是分開的平臺向貨主收1300元給司機結(jié)算可能是1100元差價200元是平臺收入。兩套價目表相互獨立規(guī)則引擎分別配置不能混在一起。4.4 司機結(jié)算單生成流程實操司機結(jié)算單生成是一個典型的集成流程我列一下它要經(jīng)過的狀態(tài)節(jié)點等待生成訂單已簽收等待下一個結(jié)算周期已生成結(jié)算單創(chuàng)建完成包含一個或多個訂單的明細待審核財務(wù)審核結(jié)算單核對金額和運單明細待支付審核通過等待推送支付支付中已調(diào)用支付接口已支付支付回調(diào)確認完成支付異常打款失敗觸發(fā)人工處理這個流程里最容易出問題的節(jié)點是“已生成”到“待審核”因為結(jié)算單有可能包含多張運單每張運單的狀態(tài)必須全部是“已簽收”。只要有一張單處于“異常處理中”整張結(jié)算單就不能進入審核。我在代碼里用了一個校驗方法生成結(jié)算單前先遍歷所有關(guān)聯(lián)訂單狀態(tài)不滿足的訂單直接踢出結(jié)算單并把異常情況記錄到結(jié)算單備注里。支付回調(diào)的狀態(tài)同步也要注意。支付結(jié)果必須通過回調(diào)更新不能靠發(fā)起支付響應(yīng)成功就更新狀態(tài)。因為支付渠道的真實結(jié)果可能延遲只有回調(diào)到達才代表錢真到了司機賬戶。5. 核心模塊源碼分析運力池與調(diào)度中心5.1 司機、車輛、線路的租戶化建?;A(chǔ)資料中心的建模比訂單要簡單一些套路是一樣的所有表帶tenant_id。但有一些貨運特有的業(yè)務(wù)規(guī)則要注意。司機表tenant_id、driver_name、phone、id_card_no脫敏存儲、driving_type、status空閑/運輸中/休假/禁用、score車輛表tenant_id、plate_no、vehicle_type車型、load_weight、load_volume、status可用/在途/維修/禁用司機和車輛的綁定關(guān)系一個司機可以綁定多輛車但一輛車同一時間只能被一個司機使用??紤]到換班場景綁定關(guān)系表要有生效時間和失效時間。我在設(shè)計時沒有把“當前綁定車輛”直接放司機表里而是單獨一張driver_vehicle_binding表這樣一條司機記錄可以關(guān)聯(lián)多條車輛記錄同時通過is_active字段標記當前默認車輛換車時只需要改一條記錄的激活標記不需要動司機主表。還有一個容易被忽略的細節(jié)身份證號、手機號這類敏感字段無論從數(shù)據(jù)庫存儲到日志輸出都要做脫敏。司機手機號在日志里出現(xiàn)完整號段這種事故被客戶投訴一次就夠了。我的習(xí)慣是在實體類上定義SensitiveField注解Jackson序列化和日志切面統(tǒng)一脫敏日志里永遠只看到138****1234這種格式。5.2 運力池匹配邏輯車型、距離、評分多重篩選運力池是調(diào)度中心的核心。它本質(zhì)上是一個“當前租戶下所有可用運力”的集合作引。訂單創(chuàng)建后調(diào)度中心從運力池里找到合適的司機來承運。匹配邏輯的核心排序維度我總結(jié)為四層車型匹配訂單要求6米8車就絕不能派4米2車地理位置司機當前位置離裝貨地越近越優(yōu)先歷史評分同樣距離下評分高的司機關(guān)單率低優(yōu)先派活躍度最近48小時有完單記錄的司機優(yōu)先排除長期不接單的匹配邏輯示例public ListDriverVehicle matchDrivers(Order order) { String tenantId TenantContext.getTenantId(); // 第一步租戶內(nèi)所有可用車輛 ListDriverVehicle candidates driverVehicleMapper .selectAvailableByTenant(tenantId); // 第二步過濾車型和載重 candidates candidates.stream() .filter(dv - dv.getVehicleType().equals(order.getRequireVehicleType())) .filter(dv - dv.getLoadWeight().compareTo(order.getTotalWeight()) 0) .collect(Collectors.toList()); // 第三步按距離排序按經(jīng)緯度計算距離省略具體實現(xiàn) candidates.sort(Comparator.comparingDouble(dv - GeoUtils.calcDistance(order.getStartLat(), order.getStartLng(), dv.getCurrentLat(), dv.getCurrentLng()))); return candidates; }自動匹配結(jié)果不能直接當最終派單結(jié)果這很重要。平臺上一旦出現(xiàn)自動派單給一個評分極低的司機貨主基本就跑了。所以我的設(shè)計是“自動匹配生成候選列表調(diào)度員在列表中確認或改派”。完全無人化的自動派單需要足夠的數(shù)據(jù)積累和信用體系支撐貨運行業(yè)信用體系不成熟前人工兜底是必須的。5.3 調(diào)度臺的人工干預(yù)設(shè)計調(diào)度臺的人工干預(yù)能力包括手工指派、線路改派、強制派單。手工指派的核心是權(quán)限控制調(diào)度員只能選擇自己負責(zé)的線路范圍內(nèi)的司機不能看到全租戶的司機列表。數(shù)據(jù)范圍在這里和權(quán)限模型聯(lián)動正好用到了前面講到的DataScope注解。強制派單的場景比較特殊司機已經(jīng)拒絕接單但平臺的運營判斷這個運單必須由該司機承擔(dān)于是強制派給司機。這里需要給司機一個“強制派單”的明確標識并且在司機端的訂單列表里顯示“調(diào)度強制指派如有異議請聯(lián)系調(diào)度臺”。強制派單的次數(shù)要記錄作為司機月度考核的一個負面指標。如果不做次數(shù)統(tǒng)計強制派單就會被濫用司機體驗會很差最終導(dǎo)致司機流失。調(diào)度流水同樣要走獨立的變更歷史記錄便于事后審計和績效追溯。我見過很多團隊做調(diào)度中心時只設(shè)計了訂單狀態(tài)和司機信息沒有調(diào)度日志表結(jié)果司機投訴“為啥給我派這單”的時候完全查不到依據(jù)。一張調(diào)度流水表按時間順序記錄每次派單、改派、強制派單操作能解決大量客服糾紛。6. 實際踩坑與排查實錄6.1 我遇到過的租戶隔離問題這部分分享幾個實際線上踩坑案例都是血淚教訓(xùn)希望能幫你們繞過去。第一個是定時任務(wù)不經(jīng)過攔截器導(dǎo)致數(shù)據(jù)無租戶ID。當時有一個“超時未接單自動取消”的定時任務(wù)直接調(diào)用訂單Service但沒有經(jīng)過HTTP請求TenantContext是空的。這個任務(wù)把所有租戶的超時訂單都掃出來了但沒有租戶ID的條件更新時把tenant_id更新成空串導(dǎo)致一批訂單在下一次查詢時從任何租戶里都查不出來。排查方法很簡單掃訂單表發(fā)現(xiàn)一批tenant_id is null和tenant_id 的記錄。第二個是線程池復(fù)用導(dǎo)致租戶上下文串了。MQ消費端接入了一個新的異步處理器我在消費邏輯里忘了設(shè)置租戶上下文結(jié)果線程池上一個任務(wù)留下的租戶ID被新任務(wù)繼續(xù)使用。最終導(dǎo)致司機端一個查詢請求拿到了另一個租戶的訂單列表。這個問題的排查比第一個更難因為數(shù)據(jù)看起來沒有丟只是“臟”你必須事后比對日志才能發(fā)現(xiàn)上下文沒有正確切換。第三個是手動SQL沒有走攔截器。報表模塊有大量的聯(lián)表查詢、子查詢、UNION這些SQL在MyBatis攔截器里的解析邏輯對UNION支持不完善我當時直接在白名單里讓報表SQL繞過了租戶條件結(jié)果報表數(shù)據(jù)串得一塌糊涂。后來補丁是把報表SQL改成必須顯式傳入租戶ID拼到SQL里不允許繞過。第四個問題是SQL改寫對子查詢的處理。MyBatis攔截器在改寫SQL時如果不小心處理子查詢會把租戶條件同時拼到子查詢和外層查詢里結(jié)果造成where tenant_id ? and order_id in (select order_id from order where tenant_id ?)這種重復(fù)條件??雌饋聿恢旅坏┳硬樵兝锏谋韯e名叫錯了SQL直接報列名不明確線上直接報警。6.2 排查問題的方法與速查表遇到租戶數(shù)據(jù)異常先別急著改代碼我通常按這個順序排查看接口日志請求的租戶ID是什么返回的數(shù)據(jù)里有沒有跨租戶的ID看SQL日志執(zhí)行的SQL有沒有帶上租戶條件帶上的條件值是不是當前租戶看調(diào)用鏈這個請求是從HTTP進來的還是MQ消息還是定時任務(wù)看線程上下文確認TenantContext在當前調(diào)用鏈路的各個節(jié)點是否都正確設(shè)置我把常見的現(xiàn)象和原因整理成一張排查表你們可以截圖保存現(xiàn)象可能原因排查步驟查詢結(jié)果為空租戶條件值傳錯或傳成了空檢查請求Token里租戶ID是否正常檢查上下文設(shè)置處查詢結(jié)果串租戶線程池復(fù)用上下文未清理或未設(shè)置查MQ消費入口和異步線程包裝層插入數(shù)據(jù)沒有租戶ID定時任務(wù)/回調(diào)接口未設(shè)置上下文查任務(wù)調(diào)度入口和回調(diào)處理入口SQL報列名不明確攔截器改寫SQL時子查詢處理不正確打開SQL日志檢查改寫前后的SQL報表數(shù)據(jù)異常手動SQL繞過攔截器檢查是否在報表查詢中顯式拼接租戶條件6.3 架構(gòu)層面的經(jīng)驗總結(jié)走完整個SaaS化貨運平臺的落地我把最重要的幾條經(jīng)驗放到最后說這些比任何一段源碼都值錢。第一租戶隔離方案一定要在項目第一天就定下來。中途從單租戶改成共享表技術(shù)可行但要花三倍的測試時間。所有表加tenant_id所有索引變成“租戶ID業(yè)務(wù)鍵”的復(fù)合索引這個工作越早做越輕松。第二不要為了架構(gòu)而架構(gòu)。很多團隊一聽說SaaS就覺得必須上獨立數(shù)據(jù)庫反而把自己搞死。實際做下來90%以上的租戶共享一套服務(wù)完全沒問題少數(shù)高端、大額、定制化租戶才需要獨立部署。技術(shù)選型脫離商業(yè)場景就是耍流氓。第三多租戶SaaS的測試成本遠高于單租戶系統(tǒng)。單租戶系統(tǒng)只需要驗證功能對不對多租戶要驗證功能、隔離性、權(quán)限范圍、數(shù)據(jù)統(tǒng)計、并發(fā)隔離。我強烈建議測試環(huán)境里至少準備三個租戶的數(shù)據(jù)交叉驗證每次發(fā)布前跑一遍“租戶A登錄不能看到租戶B數(shù)據(jù)”的冒煙測試用例。第四訂單、計費、調(diào)度三個模塊在邊界上最容易打架。訂單狀態(tài)機統(tǒng)一管理計費模塊通過計費流水與訂單交互調(diào)度模塊通過與訂單領(lǐng)域服務(wù)協(xié)作完成派單絕不允許模塊間直接修改對方的表數(shù)據(jù)。這個約束寫進開發(fā)規(guī)范比寫進代碼注釋有用得多。最后再分享一個處理多租戶異步任務(wù)的小技巧所有MQ消息體里必須帶上tenantId字段消費者收到消息第一時間設(shè)置租戶上下文再加finally清理。這比依賴調(diào)用鏈傳遞上下文要可靠得多因為它不依賴線程池里的執(zhí)行環(huán)境消息是顯式攜帶了租戶歸屬的。我后來把所有異步任務(wù)都按這個規(guī)范重寫了一遍租戶相關(guān)的線上事故從此基本絕跡。