ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

多商户家政平台开发实战:预约、抢单、商城与分账系统设计

多商户家政平台开发实战:预约、抢单、商城与分账系统设计 做多商户家政平台这个项目前后折腾了大半年。最初接手时需求文档里写着“预约下单、服务人员抢单、自营商城”三块我以为就是一个普通后台管理系统套个电商模板真正落地才意识到这个组合的复杂度远超想象。预约考验时段与库存的精确性抢单要处理高并发下的唯一性自营商城则牵扯多商户账务与结算。项目最终选择了Java技术栈定位是多商户模式下的家政综合服务平台如果你也在做类似场景或者正想找一个能写进简历的完整Java项目这篇总结应该对你有用。1. 为什么一个家政平台要同时做预约、抢单、自营商城很多朋友听到“家政平台”第一反应是这不就是信息中介吗用户下单平台派单完事。但真正跑起来会发现纯派单模式只适合平台自营一旦引入多商户业务关系就变成平台、商户、服务人员、用户四方博弈。只有把预约、抢单、自营商城放在同一个体系里才能把资源调度和商业闭环讲通。1.1 三种业务形态的本质区别预约是典型的强计划性业务。用户的诉求是“我周六下午2点需要保洁”这里核心不是订单本身而是时间片。服务人员的时间是稀缺资源一个保洁员一天能接的订单上限取决于路程和时长所以必须把“时间”建模成可预约的库存商品。抢单是强实时性业务。用户发出需求后附近符合条件的服务人员同时收到消息先到先得。这个场景最怕的不是没人接而是多人同时抢同一单造成的冲突以及抢单失败的体验问题。抢单本质上是一个并发资源竞争问题和秒杀高度相似。自营商城则是典型的多商户电商。平台自己上架清洁用品、收纳工具第三方商户也可以入驻商品、库存、价格、售后都由商户各自管理平台只做统一交易入口和结算分账。这三块业务各有各的难点放在一个系统里还要共享会员、钱包、支付、优惠券这些基础能力这就决定了不能用一个小单体糊弄过去。1.2 技术选型为什么选Java微服务而不是单体当初团队也有过争论有人提出用单体应用快速上线把三个模块做成三个包就行。后来算了一笔账预约模块要接定时任务做排班释放抢单模块要接消息推送和Redis锁商城模块要对接支付回调三者的负载特征完全不同。抢单场景可能某天中午流量突然冲高商城和预约却是平稳曲线如果塞在一个应用里扩容只能整体扩资源浪费太严重。所以最终定了Spring Cloud Alibaba这套微服务方案按业务边界拆成用户服务、订单服务、预约服务、抢单服务、商城服务、结算服务六个核心服务用Nacos做注册与配置中心服务间通过OpenFeign调用异步场景交给RabbitMQ。不过说实话这套架构放到今天不算新真正的问题永远在细节里后面的章节我会逐个展开。2. 预约模块排班、库存与防超卖预约模块是整张订单业务的起点它做得稳不稳直接决定后面抢单和商城模块是否会被连带带崩。这一节我会把排班建模、库存扣减、取消释放三条线完整讲透。2.1 可预约时段的数据建模预约服务最容易踩的坑是把时段当成一个简单的字符串字段存进订单表。第一次迭代我就是这么干的结果报表统计、并发校验全都卡壳。后来改成标准的“排班计划 时段实例”双层结构才顺过来。排班计划由商户创建比如某个保洁阿姨周一到周五每天8:00-18:00提供服务每个时段1.5小时且间隔0.5小时。计划存的是模板数据不参与具体库存计算。真正参与库存的是每日生成的时段实例每条实例记录所属商户、服务人员、开始时间、结束时间、可接单数、已接单数、状态。生成时段实例用定时任务每天凌晨跑一次生成未来14天的数据。这里有个性能细节不要一次性把所有商户的时段全部加载到内存循环插入而是先查商户列表再分批用insert批量语句写入5000个商户大约能压到3秒内完成。时段表加唯一索引(merchant_id, worker_id, start_time, end_time)防止重复生成。2.2 锁单防超卖从数据库到Redis的改造预约下单时用户选中的其实是一个具体的时段实例。防超卖本质上只有一个问题同一个时段两个用户同时下单怎么办。最朴素的做法是在数据库层控制下单时执行一条带条件的updateupdate time_slot set booked_count booked_count 1 where id #{slotId} and booked_count capacity这种方式胜在绝对准确行锁天然保证并发安全但问题也很明显。高峰时段大量请求同时命中同一行InnoDB的行锁会让请求排队数据库连接会被迅速占满。我第一次压测时1000个并发预约直接把数据库连接池打爆。所以最终方案改成了Redis预扣库存加异步写库。用户下单时先走Lua脚本原子扣减Redis中的剩余库存扣减成功才生成订单记录再通过MQ异步通知预约服务减少数据库中的已接单数。Redis减库存是判断性的Lua脚本里同时完成存在性检查和自减操作避免先get再set带来的竞态窗口。注意Redis预扣之后如果用户支付超时或取消预约必须保证库存回补。这个补偿动作不能只放在Java代码里手动调用要同时配合定时对账任务每天凌晨扫描Redis库存和数据库已接单数的差异自动修正。2.3 预约取消与排班回收取消预约看起来只是把订单状态改成已取消实际牵扯两件事库存回补和排班状态刷新。库存回补我采用延迟队列实现。用户发起取消后订单状态先置为取消中同时发一条延迟消息到RabbitMQ延迟时间设为15分钟。15分钟内用户反悔可以撤销取消操作消息到了之后再真正释放时段库存。这种设计虽然多了一层状态但能显著减少误操作带来的库存抖动。排班回收要处理更复杂的情况同一个服务人员的多个时段存在依赖。一个保洁员下午有两个订单分别占用14:00-15:30和16:00-17:30用户取消了后者前者的服务时长并不会自动延长所以时段本身不需要联动。但如果是商户把整个排班计划改了比如明天临时休息那么明天所有已预约订单都要进入待确认状态并通知用户改期。这里我用的方案是计划变更事件广播由预约服务发布消息订单服务批量更新订单状态再通过推送告知用户。3. 抢单模块并发场景下的订单分配抢单是家政平台流量最集中的地方也是技术上最有意思的部分。整体设计可以概括为广播需求、并发抢单、超时回收、兜底派单环环相扣。3.1 抢单的核心流程用户在小程序提交即时需求后订单服务会创建一个待抢单状态的订单然后根据用户地理位置和服务类型圈定候选服务人员集合向集合内人员推送抢单通知。服务人员端不是点对点实时连接而是通过WebSocket接入推送网关。推送内容只包含一个抢单标识符真正的订单详情要等抢单成功后才能查看避免订单信息在广播阶段被过多服务人员拿到造成隐私风险。抢单动作发生在一个独立的接口上// 伪代码突出流程 public Boolean grabOrder(GrabOrderRequest request) { // 1. 幂等校验同一个服务人员只能抢一次 // 2. 基于Redis Lua抢占订单归属 // 3. 抢占成功则发MQ消息异步更新订单状态 // 4. 同步通知用户端已被接单 return success; }这里最核心的一个问题是谁先抢到判断标准不能只看请求到达的时间因为网络抖动会导致时间失真。我最终采用了Redis的有序集合加Lua脚本以服务端接收到请求的顺序为准保证判定的公平性。3.2 用Redis Lua把抢单做安全抢单和预约扣库存不一样库存扣减是数量维度抢单走的是归属维度。我要保证一个订单只能被一个服务人员抢到而且同一人不能重复抢。先定义Redis的键结构。每个待抢订单对应一个抢单列表键存储候选服务人员的身份标识加一个订单归属键存储最终抢到的人grab:pool:{orderId} - Set候选服务人员id grab:owner:{orderId} - String抢到的服务人员id抢单时执行Lua脚本逻辑是先把当前服务人员id加入尝试集合然后判断归属键是否为空为空则设置归属并返回抢单成功否则返回已被抢走。整个过程在Redis单线程内完成天然原子不需要额外分布式锁。真正的订单状态变更放在MQ消息里做异步处理。为什么要异步因为状态变更要更新数据库订单表、服务人员当天接单数、用户端通知这些操作加起来有几十毫秒如果放在抢单接口的同步链路里接口耗时会被拉长到两三百毫秒服务人员端就明显感觉卡顿。异步化之后抢单接口只做Redis操作P95耗时稳定在20毫秒以内。3.3 自动派单与超时流动抢单机制有个天然缺陷单子可能没人抢。服务人员不喜欢的单子比如距离远、时间尴尬就会一直挂在池子里。所以必须配套流动策略。我设置的规则是新订单发布后2分钟内无人抢单系统自动把订单推送给评分较高且空闲的3名服务人员这算是定向邀约。再过2分钟仍然无人接订单进入自动派单队列按综合评分和距离实时计算权重分配给最合适的服务人员。自动派单的权重算法不复杂但很实用优先级 服务评分权重 * 0.4 距离因子 * 0.35 历史接单率 * 0.25距离因子采用线性衰减超过5公里后快速下降。整个计算过程放在一个独立的派单服务中使用定时任务扫描超时未接订单。这里需要注意扫描任务不能只扫一次就结束因为排队中的订单可能因为服务人员拒绝而再次流动要设计成循环处理每轮轮询所有待派订单并更新状态。4. 自营商城多商户商品与订单体系商城模块表面上是最标准的电商业务但因为叠加了多商户和平台自营两条线建模时需要格外小心。我见过不少项目把商户和店铺混为一谈导致后期分账时怎么都对不上账。4.1 商户、店铺、商品三层的建模思路先理清概念。商户是有经营资质的法人主体店铺是商户在平台上的展示和交易单元一个商户可以有多个店铺。商品归属于店铺但规则上必须同时标记商户ID方便按商户维度结算。商品核心表设计了三张商品主表存SPU级信息比如保洁服务包、清洁剂大瓶装商品规格表存SKU级信息比如不同容量、不同时长组合店铺库存表则记录每个SKU在具体店铺的库存数量。自营商城和第三方商户共用这一套表结构区别只在于merchant_type字段平台自营是固定商户标识第三方则是入驻商家。这样做的好处在于优惠券、购物车、订单、支付全链路都不需要区分商品来源只有结算时按商户类型路由到不同分账策略。上架一个商品时后台会同时校验资质图片、类目、价格区间然后触发一个审核流程。审核通过才允许设置库存并上架。这一步看似多余实际上是把入驻商户的违规风险前移后期运营成本能省很多。4.2 商城订单状态机设计商城订单的状态流转是我在整个项目里反复打磨的部分。家政商城订单不只包含实物商品还可能出现预约服务类商品所以状态机要考虑物流和履约两条轴线。实物商品典型链路是待付款、待发货、待收货、已完成中间穿插退款和售后。服务类商品则是待付款、待预约、已预约、服务中、已完成。表面看是两套状态但我用同一个订单主表加一个order_type字段区分然后把针对不同类型的状态流转规则独立配置。状态机我建议不要用数据库字段硬编码判断而是建一张状态流转规则表配置每个当前状态允许跳转到哪些目标状态。订单服务里每次状态变更都走统一的流转校验接口不满足则直接抛异常。这样后续运营要加一个“已取消并退款中”的状态只需改配置表不用动代码。4.3 预约、商城、充值三类订单的账务统一多商户平台最头疼的是账务。用户可能通过预约下单买了服务又在商城买了清洁剂还用钱包余额付了一部分最终平台要给不同商户结算不同金额。我最后的做法是统一抽象成“交易流水”把预约单、商城单、充值单全部视为交易主单的子类型每笔交易关联若干条资金流水每条资金流水记录用户支出、优惠券抵扣、商户收入、平台佣金。这样做最直接的好处是财务对账只需要对“流水表”做核对而不是分别对三张订单表。清算时按商户ID聚合流水减去平台佣金得到应付金额。这里有个细节优惠券抵扣金额不能直接算作商户收入因为优惠券的成本由平台承担必须在流水中单列否则商户结算永远算不对。5. 数据一致性与分账结算谈到Java后端热点话题绕不开分布式事务和数据一致性。家政平台同时涉及预约、抢单、商城三个核心流程每个流程都包含跨服务调用完全靠单个数据库事务是不现实的。我在这个项目里没有追求理论上完美的分布式事务而是针对不同场景选了不同策略。5.1 分布式事务的取舍实践最终采用了三种策略混合使用。预约下单核心链路要求强一致采用本地消息表加MQ消息确认确保订单创建和库存扣减最终一致。抢单链路追求的是实时性和最终归属唯一性全部交给Redis原子操作去保证数据库层只做异步落账。商城支付链路则用TCC方案的简化版本预冻结资金、确认扣款、异常释放。为什么不全程用Seata我在压测时发现全局事务锁对性能影响很大尤其是在抢单这种高并发场景一个事务锁住订单相关表所有抢单请求都会排队吞吐量直接腰斩。相反预约场景本身并发量没那么高但对一致性要求苛刻反而适合用Seata管理。所以更合理的做法是把强一致的场景尽量收敛在少量服务边界内能用最终一致解决的绝不引入全局事务。5.2 库存扣减方案对比库存扣减这个题目Java面试经常问项目中真实用下来三种方案各有利弊。我把它们做了对比方案优点缺点适用场景数据库乐观锁实现简单绝对准确并发高时大量重试性能差并发量低的商户后台数据库悲观锁一致性强行锁排队连接易耗尽内部审核扣减低频操作Redis Lua原子扣减吞吐量高响应快需要补偿与对账机制预约下单、抢单、秒杀redis方案的重点在于扣减成功并不代表订单一定创建成功所以要在订单创建失败或超时未支付时主动回补。为了保证不丢数据每次回补也要产生一条补偿记录后续对账时检查补偿记录和实际库存的差额。5.3 平台、商户、服务人员的三级分账多商户家政平台和纯电商平台最大的差异在分账层级。电商平台通常只有平台和商户两层家政平台中间还夹着一个服务人员层级而且服务人员不是商户员工是按单抽成的合作关系。我的结算模型是按订单生命周期触发三个节点订单完成后计算标准分成服务人员确认完成后计算浮动奖励用户发起售后时生成扣回单。实际分成逻辑里最容易被忽略的是平台优惠券和服务人员加成之间的冲突。比如用户用了一张平台补贴的满减券服务人员的提成不能跟着缩减否则服务人员会拒单或消极接单。所以我规定平台券补贴只影响平台收入商户和服务人员的分成基数统一按原价计算。这条规则直接影响服务人员的接单意愿是运营侧反复验证后定下来的技术侧要做的就是把它写进分成引擎避免人工算错。6. 实操踩坑与性能优化实录最后这部分是我最想分享的很多问题不是看文档能看出来的全部来自真实跑生产环境后的教训。6.1 抢单惊群效应与削峰处理第一次上线抢单功能时一个订单发布瞬间候选池里200多个服务人员的WebSocket同时收到推送所有人都点抢单Redis的QPS瞬间冲到每秒几万。虽然Lua脚本保证了正确性但Redis单节点的CPU被打到80%连带其他业务一起受影响。后来加了两个优化。第一个是推送削峰每次推送不是同一时刻全部推完而是按候选人的在线状态和接单热度分批推送每批50人批间间隔400毫秒。第二个是接口入口做令牌桶限流对同一订单ID的抢单请求限制每秒最多500个请求进入Redis操作层超出部分直接返回“手慢了”的提示文案。这两个优化叠加后抢单高峰期的Redis负载从80%降到了25%用户几乎感知不到延迟变化。这里我学到的经验是不要让推送洪峰直接打在Redis上推送本身就要成为流量控制的第一道闸门。6.2 排班时段更新的脏数据问题预约模块上线两个月后运营反馈有用户反映“下单成功后收到的确认短信显示的是错误时段”。排查下来问题出在商户修改排班计划时只更新了模板数据没有同步处理已生成的时段实例。比如某阿姨原计划周六干活周六用户下单成功周五商户临时改班把周六改成休息。由于时段实例还是旧有的可预约状态用户毫不知情地完成了支付。这个问题的根因是模板和实例之间的联动缺失。修复方案是给排班计划增加版本号每日生成时段实例时快照计划版本。当计划发生变更时实例要根据新版本重新生成并对已预约的时段发起冲突检测锁定冲突订单进入客服介入状态。上线后这类投诉基本归零建议所有做排班业务的系统都把这个逻辑考虑到。6.3 常见问题速查表整理一份我在项目维护期最常遇到的排查清单供各位直接参考现象可能原因快速排查方法预约高峰期接口超时数据库行锁排队查看数据库当前锁等待观察预约时段的SQL执行计划抢单成功但用户端无提示MQ消息丢失或消费失败检查死信队列确认订单状态是否落在Redis商品库存变负数Redis扣减后数据库异步写入失败核对补偿记录查到失败后手动对账回补结算金额与订单总额不一致优惠券分摊逻辑错误比对流水表中优惠券字段与商户收入字段用户取消预约后时段仍不可约延迟队列消息被消费但回补失败扫描补偿表检查回补SQL的执行结果6.4 压测结果与关键参数项目上线前做了一轮压测测试环境配置是4核8G的节点共6个模拟1000用户同时在线操作。最终预约接口的TPS稳定在每秒480左右抢单接口在削峰策略下TPS约每秒320商城下单接口TPS约每秒260。这里面有几个关键参数可以分享。Redis连接池最大连接数设置为300连接超时500毫秒避免突发流量瞬间创建大量连接。RabbitMQ消费者的并发线程数设置为每个队列20个prefetch设置为50兼顾吞吐和内存占用。数据库连接池用的是HikariCP最大连接数设为100这个数值在压测时反复调过调到120时数据库CPU不升反降说明连接抖动带来的上下文切换已经超过并发带来的收益。最后再说一个容易被忽略的点抢单接口要加一个请求级别的幂等校验建议用服务人员ID和订单ID拼接后作为唯一键在业务逻辑入口直接查Redis判断是否已抢过尽量减少Lua脚本内无效的重复操作。我在实测中发现加入这个前置校验后接口的无效请求量降低了约四成Redis压力明显缓解。如果你也在做一个涉及预约、并发抢单和多商户交易的Java项目建议先花时间把订单状态机和库存扣减方案想清楚数据库表结构设计得再“宽”一点都没关系但状态流转必须严格收敛。抢单和预约的边界也要划分明确预约是时间资源管理抢单是实时任务分配两者混用同一套状态逻辑会越做越乱。整个项目做下来我个人最大的体会是技术方案没有绝对的好坏重点是理解业务场景的并发特性和一致性要求再决定用哪把钥匙去开哪把锁。
返回列表