ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

货运SaaS多租户架构设计与核心模块源码解析

货运SaaS多租户架构设计与核心模块源码解析 货运行业做SaaS和做普通企业管理系统最大的区别在哪里我先把话说在前面不是功能堆得多而是架构扛得住。一套系统要同时服务几十上百家物流公司每家公司看到的都是自己的订单、自己的司机、自己的账单可底层跑的是同一套代码、同一个数据库集群——这种“看起来各用各的、实际上共用一套”的玩法就是多租户架构要解决的核心问题。这篇博文围绕我实际落地的一个SaaS化货运平台展开重点拆两层内容一是多租户架构从租户识别、数据隔离到权限控制的完整链路怎么设计二是订单中心、计费中心、运力池这三个核心模块的关键源码逐行怎么分析。适合正在做SaaS产品化改造的后端工程师、架构师以及想把物流运输系统从单客户定制升级成多租户订阅模式的同学参考。我尽量把每一步选的什么方案、为什么这么选、踩过什么坑都讲清楚你们看完能直接拿去用。1. 项目背景与整体架构拆解1.1 为什么货运系统一定要走SaaS化先说行业现状。传统的物流运输管理系统基本都是定制开发或者单租户部署一家干线物流公司一套系统一套数据库代码库还经常因为客户需求不同而分叉出好几个版本。早期这么搞没问题客户少、功能简单但问题会随着规模增长越来越明显。我接触过一个做了七八年的运输管理系统代码仓库里按客户名建了五六套分支每套分支的订单表字段都不完全一样A客户加了“是否需要回单”B客户加了个“冷链温度记录”C客户要“多点配送”。每加一个新客户不是复制一份代码再改就是在一堆if (customerType ...)里继续堆逻辑。升级一个公共功能要在所有分支里各改一遍漏一个就是线上事故。这种模式运维成本爆炸而且小物流公司根本养不起独立的系统开发团队他们也需要一套能按月付费、开箱即用的系统。SaaS化解决的就是这个问题一套代码、一套基础服务所有租户共用但逻辑上互相隔离。货主登录看到的是自己的发货记录司机看到的是自己的运单平台管理员看到的是全量数据。新接入一个租户不是给它单独部署一套实例而是开一个租户账号、配置好它的计价规则和权限角色当天就能用起来。我自己做这个项目的时候最深刻的体会是SaaS化不是把原来的单租户系统加一个tenant_id字段这么简单。它是一个从表结构设计、接口规范、权限模型、数据备份、消息队列到定时任务全部要重新过一遍的架构升级。标题里说的“多租户架构”绝不仅仅是数据库隔离层面的事。1.2 多租户隔离模式怎么选三种方案对比多租户架构第一个要拍板的问题就是租户之间在数据库层面怎么隔离。这个决定直接关系到成本、扩展性和运维复杂度。行业里主流方案有三种我列个对比表你一看就明白。隔离方案数据隔离强度单租户成本运维复杂度适用场景独立数据库最强最高高每个库都要监控备份大型租户、签约大客户、数据敏感共享数据库、独立Schema较强中中一套库管理多套表结构中型租户要求一定程度隔离共享表、租户ID区分最弱最低低共用表结构小微租户、标准业务场景绝大多数物流公司的主营业务——运单、车辆、司机、线路——业务流程是高度标准化的。所以我的选择是默认用共享表方案业务表统一带tenant_id靠代码层强制隔离少数头部大租户确实有数据私有化要求或者它的订单量级实在太大会单独给它拆一个独立数据库也就是“共享为主、独立为辅”的混合策略。为什么这么选我给你算笔账。假设平台有三百家租户每家平均一天一千单。共享表方案只需要一套订单表加一个租户ID索引数据库连接池、缓存、备份都统一管理。如果三百家全部独立数据库光连接数就够呛更别说备份、升级、迁移的工作量中小团队根本扛不住。而少数大租户独立库是划算的因为它们的单量可能占全平台的三成以上独立出来能避免互相影响也方便定制。基于这个决策整个系统的设计原则就定了所有业务表必须包含tenant_id字段所有写SQL的地方必须保证租户隔离条件不缺失所有跨租户的数据查询必须走专门的平台管理接口禁止在业务接口里传递其他租户ID。1.3 系统整体模块地图SaaS化货运平台不是一个简单的CRUD项目它至少要包含下面几大块租户中心租户的注册、开通、套餐配置、状态管理是整个多租户体系的基础基础资料中心司机、车辆、线路、客户档案、货物类型的统一管理订单中心运单从创建、分配、运输到签收的全生命周期管理调度中心运力池维护、自动派单、人工调度、异常改派计费中心计价规则配置、运费计算、费用明细生成结算中心对账、司机结算单生成、付款状态跟踪报表中心各租户的运单量、收入、成本、时效统计模块之间的依赖关系我用一句话概括租户中心管“谁在用”基础资料管“有什么资源”订单中心管“产生了什么业务”调度中心管“谁来干”计费结算中心管“赚了多少钱”。这五条线串起来就是一个货运SaaS的主干。我在拆分这些模块时最看重的一点是订单状态不能被计费逻辑和调度逻辑直接乱改。后面源码分析你就能看到订单状态机是单独封装的其他模块只能通过领域服务的方法来改变订单状态不允许自己update order set status ...一把梭。2. 多租户架构落地租户识别与数据隔离2.1 租户上下文如何从HTTP请求传到数据库多租户架构的第一道关卡是确定“当前请求属于哪个租户”。这个信息最早上游起源于登录用户的租户归属——一个用户登录时后端校验完账号密码就从它的用户信息里拿到tenantId。后续整个请求生命周期里所有查询和写入都要带上这个标识。我用的方案是ThreadLocal结合拦截器。简单画一下结构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请求结束在afterCompletion里调用clear()清理。这样Controller层、Service层、DAO层所有代码都能通过TenantContext.getTenantId()拿到当前的租户标识不用每个接口都手动传参数。注意ThreadLocal看着简单坑却不少。最典型的两个问题一是线程池复用导致租户上下文串了二是用完没清理导致内存泄漏或脏数据。第一个问题在异步场景里特别容易出现。比如订单创建后发MQ消息给调度中心MQ的消费线程池里的线程是复用的。如果消费任务里没有设置TenantContext那这个线程处理完A租户的消息后到处理B租户的消息时上下文里可能还残留着A租户的ID。解决办法是每次MQ消费入口显式设置租户上下文并且在消费完finally中清理。第二个问题是clear()必须放进finally里否则请求抛出异常时上下文不会被清理长连接线程会把上一次的租户ID带到下一次请求查出来的数据就是串的。我见过线上事故就是这个问题导致的一个司机登录后看到的运单是另一个公司的吓死人。2.2 DAO层自动注入租户条件拦截器源码怎么拼SQL确定了租户上下文的传递下一个核心问题就是不能每个SQL都手动写where tenant_id ?。项目里上百个Mapper方法手写必然漏。我采用的是MyBatis拦截器方案在SQL执行前自动解析和拼接租户条件。先定义一个注解用来标记哪些表不需要租户隔离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取不到直接放行可能是不需要租户隔离的基础数据操作 // 3. 改写 SQL在 WHERE 后拼接 AND tenant_id ? // 4. 给 PreparedStatement 设置参数 return invocation.proceed(); } }SQL改写逻辑上我用的是JSqlParser这类工具把原始SQL解析成抽象语法树找到对应的表名在WHERE条件前面加上租户条件。这里有一个细节要特别强调不是所有表都加租户条件。比如系统级的字典表、租户套餐配置表本身是全平台共享的加上租户条件反而查不到数据。所以TenantIgnore注解是必须的而且要在项目启动时扫描一遍所有Mapper检查哪些表既没有被租户表白名单包含、又没有加注解直接启动报错把漏配的问题扼杀在编译期。另外一个很重要的工程实践是索引。共享表方案下所有业务表的数据都在一张表里tenant_id必须和其他业务唯一键组成联合索引。举个例子订单表的索引不是只建order_no而是要建uk_tenant_order (tenant_id, order_no)。道理很简单如果只建order_no唯一索引两个租户的订单号都是DD20250600001时直接就冲突了。复合唯一索引既保证了租户内的单号唯一又保证了跨租户不互相干扰。2.3 权限与数据范围的联动设计租户隔离不等于权限控制。租户隔离解决的是“A租户不能看B租户的数据”权限控制解决的是“同一个租户里不同角色能看的数据范围不一样”。我实际用的是经典的RBAC模型加数据范围。一个租户下的角色可以分为几类租户管理员能看全租户数据调度员能看自己负责的线路和车辆司机只能看自己承接的运单。数据范围的实现不是简单在SQL里拼条件而是通过一个数据权限注解Documented Retention(RetentionPolicy.RUNTIME) Target(ElementType.METHOD) public interface DataScope { // 数据范围类型ALL / DEPARTMENT_VEHICLE / ONLY_SELF DataScopeType value() default DataScopeType.ALL; }在AOP拦截器里根据当前登录用户的角色和数据范围类型自动把额外的过滤条件加到SQL上。它与租户条件叠加的时候SQL最终会变成where tenant_id ? and driver_id ?。这两层条件要一起生效缺一个都不行。这里我想提醒一句数据权限和租户隔离是两个独立维度一定要拆开设计。如果把它们混在一个拦截器里后面调整权限模型或者调整租户隔离策略时候拆起来非常痛苦。我第一版就是图省事写在一起的后来加了个“按线路配数据权限”的需求改得头皮发麻。3. 核心模块源码分析订单中心与状态机3.1 订单模型字段怎么设计既灵活又统一订单模块是整个货运平台的中枢。货主下单之后从报价、派车、取货、干线运输到签收所有流程都以订单为核心流转。我先把订单相关的核心表结构梳理一下。订单主表字段大致分几类基础信息order_no租户内唯一单号、tenant_id、order_type整车/零担/冷链等、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状态信息status、current_node、cancel_reason、reject_reason来源信息channelAPP/小程序/接口/人工录入、customer_id、platform_order_no此外还有两张扩展表订单货物明细表一单多货和订单运费明细表运费、装卸费、等候费等。我强烈建议不要把所有信息塞进订单主表原因不是范式强迫症而是货运场景里一对多的情况实在太常见。一车货发往三个不同城市的客户这在零担场景下天天见如果把货物和费用全塞在主表里拆单和计费逻辑会被字段结构活活逼疯。3.2 订单状态机从下单到签收的流转规则订单状态是订单中心最容易做崩的地方。如果每个状态流转都散落在Service层不同的方法里用一堆if (order.status 1 ...)判断项目到后期会出现“这个状态应该能取消但代码里忘了放行”之类的隐蔽bug。我在项目里用枚举加状态流转表来实现状态机核心代码大致是这样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; }每个业务方法要修改订单状态的时候统一走领域服务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(非法状态流转: key); } order.setStatus(target); } }为什么用状态机因为订单的流转不是线性的。正常流程是待接单-已接单-待取货-运输中-待签收-已签收但中间随时可能插入异常分支客户取消、司机拒单、货物破损、天气延误。这些分支和主流程之间要有清晰、可控的转换关系。状态机把流转规则集中在一个地方管理任何非法流转直接抛异常日志里能明确看到谁想从“运输中”改到“已签收”——这明显是逻辑漏洞。还有一点经验业务状态不要只用字符串或者数字散落在代码里要配合状态变更历史表一起用。每次状态变更都记录一条流水order_id、from_status、to_status、operator_type、operator_id、change_reason、create_time。后面排查问题的时候这张表是救命稻草。有一次客户投诉说“我明明没收到货系统却显示已签收”我直接查状态流水发现是某个脚本批量调用把状态改错了十分钟定位到问题靠的全是流水。3.3 拆单、合单与改派的源码逻辑订单中心除了状态流转还有几个绕不开的业务逻辑拆单、合单、改派。拆单场景最常见的是一票货物有多个收货地址。核心逻辑是按收货城市分组把原来的父订单拆成多个子订单。这里的关键是父订单本身的金额怎么分摊子订单之间怎么关联运费怎么重新计算。我的做法是拆单时保留父单实体但父单状态变为“已拆分”金额清零实际业务流转全部走子单子单通过parent_order_no关联。合单则相反同一发货地、同一收货地、要求时效接近的多个订单合并成一个运单由一个司机承运。合单不是简单把两个订单的金额相加而要重新按合并后的货物总重、总体积计价。这里要特别小心合单前必须校验两个订单有没有冲突货物类型是否兼容常温货和冷链货不能合并甚至要考虑货物是否超载。我见过一次线上合单事故两个订单各1吨合单后总重2.5吨调度员没注意就把单派给了一辆核载2吨的4米2车最终以超载拒运收场。所以合单逻辑里必须有重量、体积、车型匹配的校验而且合并后要重新跑一遍运力匹配不能直接把原来某个司机的运单合并给另一个司机。改派逻辑相对简单但要注意权限边界只有调度员角色能改派司机不能自行转单。改派时的关键操作是同时改订单的driver_id和车辆 ID并且新增一条调度流水记录谁改的、从哪个司机改到哪个司机、原因是什么方便运费结算时的责任划分。4. 核心模块源码分析计费中心与规则引擎4.1 计价规则为何一定要配置化货运计价是所有运输系统中的“万恶之源”因为每家公司都有自己的报价算法。有的按重量、有的按体积、有的按里程、有的按车次还有的是“起步价加吨公里价”的复合模式。如果计价逻辑在全代码里写死每接入一个新租户就得改一次代码SaaS就搞不下去了。对应的设计思路是“计价规则引擎”。核心抽象是策略接口public interface PricingRule { // 判断当前规则是否适用 boolean supports(PricingContext context); // 计算运费 BigDecimal calculate(PricingContext context); }然后每种计价方式一个实现类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); } }配置层面每个租户可以在后台配置计价方式、单价、起步价、阶梯价格。订单创建时计价引擎读取租户的配置动态选择对应的规则实现。这样做的好处是新租户接入时只需要在后台配置计价模板不需要发版、不需要改代码真正做到了“开箱即用”。计价规则配置化之后还有一层要考虑同一个租户下不同线路的报价可能不同。比如华东线每吨200元东北线每吨300元。所以计价配置上不能只挂在租户级别要支持“租户线路”级别的配置。我的做法是配置表设三档优先级全局模板 区域模板 线路模板计价引擎按优先级取最高匹配级的配置。4.2 从计费快照到费用明细的完整链路有个很容易忽略但是非常重要的点费用计算的结果必须存快照。什么意思就是说订单签收那一刻算出来的运费是1300元那它就是1300元。哪怕后来管理员修改了计价规则把单价从200改成250历史订单的运费也不应该变。实现思路是在订单费用表里把计算时的规则快照存下来public class OrderFee { private String orderNo; private String tenantId; private String feeType; // 运费/装卸费/等候费/保价费 private String pricingRuleId; // 计算用的规则ID private BigDecimal unitPrice; // 计算时的单价快照 private BigDecimal quantity; // 计算时的数量 private BigDecimal amount; // 最终金额 private String snapshotJson; // 整个规则上下文的快照 }snapshotJson字段里面存的是计算当时的完整上下文包括货物类型、重量、体积、起终点、计价规则版本。后续如果对账有争议或者司机投诉“运费算错了”我们可以直接调出快照看看当时到底是怎么算出来的。这个快照机制帮我挡住了无数次的扯皮。费用明细和账单是两回事。费用明细是一笔一笔的费用记录账单是给货主看的汇总单据。我的账单生成逻辑是一个订单的所有费用明细按费用类型汇总生成账单账单状态有“待确认”“已确认”“已支付”。结算中心在账单确认后生成应收记录。4.3 平台与司机的对账清分逻辑SaaS货运平台是中间平台从货主那里收运费再向承运司机支付运输报酬。中间的差价就是平台利润。这里有一个非常核心的对账需求平台的钱和第三方的钱要对平。主要包括两类流水平台应收流水货主支付了多少钱司机应付流水平台要给司机结算多少钱支付渠道流水第三方支付平台的收款回调我对账模块的处理流程是定时任务拉取一段时间内的平台应收单和支付渠道流水按平台订单号金额匹配匹配上的标记为“已核销”匹配不上的进入“差异池”差异池里的记录由运营人工处理可能是渠道手续费差异、可能是退款、可能是系统漏单对账的代码核心是匹配逻辑简单版本如下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(), 金额不一致或渠道无记录); } } } }司机结算单的生成是另一条链路订单签收后系统按承运司机、结算周期按单/按天/按周把司机的应得收入汇总成结算单。这里司机和货主的计价是分开的平台向货主收1300元给司机结算可能是1100元差价200元是平台收入。两套价目表相互独立规则引擎分别配置不能混在一起。4.4 司机结算单生成流程实操司机结算单生成是一个典型的集成流程我列一下它要经过的状态节点等待生成订单已签收等待下一个结算周期已生成结算单创建完成包含一个或多个订单的明细待审核财务审核结算单核对金额和运单明细待支付审核通过等待推送支付支付中已调用支付接口已支付支付回调确认完成支付异常打款失败触发人工处理这个流程里最容易出问题的节点是“已生成”到“待审核”因为结算单有可能包含多张运单每张运单的状态必须全部是“已签收”。只要有一张单处于“异常处理中”整张结算单就不能进入审核。我在代码里用了一个校验方法生成结算单前先遍历所有关联订单状态不满足的订单直接踢出结算单并把异常情况记录到结算单备注里。支付回调的状态同步也要注意。支付结果必须通过回调更新不能靠发起支付响应成功就更新状态。因为支付渠道的真实结果可能延迟只有回调到达才代表钱真到了司机账户。5. 核心模块源码分析运力池与调度中心5.1 司机、车辆、线路的租户化建模基础资料中心的建模比订单要简单一些套路是一样的所有表带tenant_id。但有一些货运特有的业务规则要注意。司机表tenant_id、driver_name、phone、id_card_no脱敏存储、driving_type、status空闲/运输中/休假/禁用、score车辆表tenant_id、plate_no、vehicle_type车型、load_weight、load_volume、status可用/在途/维修/禁用司机和车辆的绑定关系一个司机可以绑定多辆车但一辆车同一时间只能被一个司机使用。考虑到换班场景绑定关系表要有生效时间和失效时间。我在设计时没有把“当前绑定车辆”直接放司机表里而是单独一张driver_vehicle_binding表这样一条司机记录可以关联多条车辆记录同时通过is_active字段标记当前默认车辆换车时只需要改一条记录的激活标记不需要动司机主表。还有一个容易被忽略的细节身份证号、手机号这类敏感字段无论从数据库存储到日志输出都要做脱敏。司机手机号在日志里出现完整号段这种事故被客户投诉一次就够了。我的习惯是在实体类上定义SensitiveField注解Jackson序列化和日志切面统一脱敏日志里永远只看到138****1234这种格式。5.2 运力池匹配逻辑车型、距离、评分多重筛选运力池是调度中心的核心。它本质上是一个“当前租户下所有可用运力”的集合作引。订单创建后调度中心从运力池里找到合适的司机来承运。匹配逻辑的核心排序维度我总结为四层车型匹配订单要求6米8车就绝不能派4米2车地理位置司机当前位置离装货地越近越优先历史评分同样距离下评分高的司机关单率低优先派活跃度最近48小时有完单记录的司机优先排除长期不接单的匹配逻辑示例public ListDriverVehicle matchDrivers(Order order) { String tenantId TenantContext.getTenantId(); // 第一步租户内所有可用车辆 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()); // 第三步按距离排序按经纬度计算距离省略具体实现 candidates.sort(Comparator.comparingDouble(dv - GeoUtils.calcDistance(order.getStartLat(), order.getStartLng(), dv.getCurrentLat(), dv.getCurrentLng()))); return candidates; }自动匹配结果不能直接当最终派单结果这很重要。平台上一旦出现自动派单给一个评分极低的司机货主基本就跑了。所以我的设计是“自动匹配生成候选列表调度员在列表中确认或改派”。完全无人化的自动派单需要足够的数据积累和信用体系支撑货运行业信用体系不成熟前人工兜底是必须的。5.3 调度台的人工干预设计调度台的人工干预能力包括手工指派、线路改派、强制派单。手工指派的核心是权限控制调度员只能选择自己负责的线路范围内的司机不能看到全租户的司机列表。数据范围在这里和权限模型联动正好用到了前面讲到的DataScope注解。强制派单的场景比较特殊司机已经拒绝接单但平台的运营判断这个运单必须由该司机承担于是强制派给司机。这里需要给司机一个“强制派单”的明确标识并且在司机端的订单列表里显示“调度强制指派如有异议请联系调度台”。强制派单的次数要记录作为司机月度考核的一个负面指标。如果不做次数统计强制派单就会被滥用司机体验会很差最终导致司机流失。调度流水同样要走独立的变更历史记录便于事后审计和绩效追溯。我见过很多团队做调度中心时只设计了订单状态和司机信息没有调度日志表结果司机投诉“为啥给我派这单”的时候完全查不到依据。一张调度流水表按时间顺序记录每次派单、改派、强制派单操作能解决大量客服纠纷。6. 实际踩坑与排查实录6.1 我遇到过的租户隔离问题这部分分享几个实际线上踩坑案例都是血泪教训希望能帮你们绕过去。第一个是定时任务不经过拦截器导致数据无租户ID。当时有一个“超时未接单自动取消”的定时任务直接调用订单Service但没有经过HTTP请求TenantContext是空的。这个任务把所有租户的超时订单都扫出来了但没有租户ID的条件更新时把tenant_id更新成空串导致一批订单在下一次查询时从任何租户里都查不出来。排查方法很简单扫订单表发现一批tenant_id is null和tenant_id 的记录。第二个是线程池复用导致租户上下文串了。MQ消费端接入了一个新的异步处理器我在消费逻辑里忘了设置租户上下文结果线程池上一个任务留下的租户ID被新任务继续使用。最终导致司机端一个查询请求拿到了另一个租户的订单列表。这个问题的排查比第一个更难因为数据看起来没有丢只是“脏”你必须事后比对日志才能发现上下文没有正确切换。第三个是手动SQL没有走拦截器。报表模块有大量的联表查询、子查询、UNION这些SQL在MyBatis拦截器里的解析逻辑对UNION支持不完善我当时直接在白名单里让报表SQL绕过了租户条件结果报表数据串得一塌糊涂。后来补丁是把报表SQL改成必须显式传入租户ID拼到SQL里不允许绕过。第四个问题是SQL改写对子查询的处理。MyBatis拦截器在改写SQL时如果不小心处理子查询会把租户条件同时拼到子查询和外层查询里结果造成where tenant_id ? and order_id in (select order_id from order where tenant_id ?)这种重复条件。看起来不致命但一旦子查询里的表别名叫错了SQL直接报列名不明确线上直接报警。6.2 排查问题的方法与速查表遇到租户数据异常先别急着改代码我通常按这个顺序排查看接口日志请求的租户ID是什么返回的数据里有没有跨租户的ID看SQL日志执行的SQL有没有带上租户条件带上的条件值是不是当前租户看调用链这个请求是从HTTP进来的还是MQ消息还是定时任务看线程上下文确认TenantContext在当前调用链路的各个节点是否都正确设置我把常见的现象和原因整理成一张排查表你们可以截图保存现象可能原因排查步骤查询结果为空租户条件值传错或传成了空检查请求Token里租户ID是否正常检查上下文设置处查询结果串租户线程池复用上下文未清理或未设置查MQ消费入口和异步线程包装层插入数据没有租户ID定时任务/回调接口未设置上下文查任务调度入口和回调处理入口SQL报列名不明确拦截器改写SQL时子查询处理不正确打开SQL日志检查改写前后的SQL报表数据异常手动SQL绕过拦截器检查是否在报表查询中显式拼接租户条件6.3 架构层面的经验总结走完整个SaaS化货运平台的落地我把最重要的几条经验放到最后说这些比任何一段源码都值钱。第一租户隔离方案一定要在项目第一天就定下来。中途从单租户改成共享表技术可行但要花三倍的测试时间。所有表加tenant_id所有索引变成“租户ID业务键”的复合索引这个工作越早做越轻松。第二不要为了架构而架构。很多团队一听说SaaS就觉得必须上独立数据库反而把自己搞死。实际做下来90%以上的租户共享一套服务完全没问题少数高端、大额、定制化租户才需要独立部署。技术选型脱离商业场景就是耍流氓。第三多租户SaaS的测试成本远高于单租户系统。单租户系统只需要验证功能对不对多租户要验证功能、隔离性、权限范围、数据统计、并发隔离。我强烈建议测试环境里至少准备三个租户的数据交叉验证每次发布前跑一遍“租户A登录不能看到租户B数据”的冒烟测试用例。第四订单、计费、调度三个模块在边界上最容易打架。订单状态机统一管理计费模块通过计费流水与订单交互调度模块通过与订单领域服务协作完成派单绝不允许模块间直接修改对方的表数据。这个约束写进开发规范比写进代码注释有用得多。最后再分享一个处理多租户异步任务的小技巧所有MQ消息体里必须带上tenantId字段消费者收到消息第一时间设置租户上下文再加finally清理。这比依赖调用链传递上下文要可靠得多因为它不依赖线程池里的执行环境消息是显式携带了租户归属的。我后来把所有异步任务都按这个规范重写了一遍租户相关的线上事故从此基本绝迹。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表