鲜奶订购配送系统设计与实现:基于SpringBoot的周期履约与库存方案

发布时间:2026/10/22 4:34:07
鲜奶订购配送系统设计与实现:基于SpringBoot的周期履约与库存方案 这几年生鲜电商火了之后不少做区域配送的朋友都在琢磨怎么把手里的鲜奶订单从微信群里接龙升级成一套像样的业务系统。我去年就接手过一个社区奶站的转型项目需求很简单客户按周或按月订鲜牛奶奶站每天按配送路线送货。但真把需求落到SpringBoot代码里才发现鲜奶订购和普通电商完全是两回事——它本质是按周期履约的订阅制配送不是一次性买卖。这篇就把整套系统的设计与实现过程完整拆开讲从数据库建模到并发扣库存再到最后上线踩过的坑一步不落。先说说这套东西最终做出来的样子客户在小程序端选择鲜奶品类、配送周期和起送日期在线支付后系统按天生成配送任务奶站站长按路线分发给配送员配送员在移动端确认送达后台能管理商品、奶站、配送员、退款和订阅变更。整套后端基于SpringBoot搭建配了MySQL、Redis、RabbitMQ和定时任务调度。如果你也在做类似的项目或者正在准备这个方向的设计与实现答辩这篇文章可以直接作为落地参考。1. 鲜奶订购和普通电商的本质差异决定了整套系统的设计走向1.1 从需求差异倒推系统形态普通电商订单是一次性交易用户下单商家发货订单结束。鲜奶订购完全不同——用户这周订了下周还要送而且送奶不是快递物流那样的寄一箱出去是每天固定路线、固定时间、固定数量的循环履约。这意味着系统里必须同时存在两个核心实体订购单代表了客户的订阅关系和配送明细代表每天实际要送的货。很多第一次做这类系统的人会犯一个经典错误把订购单当订单表用每天复制一份订单结果一个月30天产生了30条毫不相干的订单记录后续想查这个客户这周是否暂停了配送完全无从下手。第二个差异是时效。鲜奶的保质期按天算奶站每天能供应的数量也基本固定——今天送多少、明天剩多少不能像卖衣服那样超卖后等补货。这逼着系统在下单时就要把未来一段时间的库存占用算清楚而不是等发货时再扣。更进一步客户实际配送日是可以动态调整的今天临时出差要暂停三天明天要改送奶地址这些操作直接影响后续每一天的配送任务必须有独立的状态管理而不是简单改一个订单状态就能糊弄过去。第三个差异是配送维度。普通电商的物流是包裹从A点到B点鲜奶配送是配送员在固定区域按路线投递。也就是说订单必须归属到奶站奶站再归属到配送路线否则每天的配送任务无法生成。很多毕业设计或小项目把用户地址直接写到订单表里就完事了上线跑一个月后运营想按路线统计就觉得生不如死。1.2 技术选型的核心权衡这套系统后端我选的是 SpringBoot 2.7 MyBatis-Plus MySQL 8.0缓存用的Redis消息队列用的RabbitMQ定时任务框架用的XXL-Job整体架构不复杂但对业务场景的适配性很强。为什么是SpringBoot而不是其他框架说实话在这个体量的业务场景里SpringBoot的核心优势不是性能而是生态成熟度和开发效率。订阅制配送涉及的模块很多——用户、商品、订购、配送、支付、消息通知、后台管理——SpringBoot的自动配置和starter机制能让这些模块的集成成本降到最低而且后续招人维护也容易。MyBatis-Plus的选择理由很简单业务里有大量复杂的条件查询和分页统计比如查询某配送员今天的所有配送任务、统计某奶站本月的订阅收入MP的Wrapper机制写这类查询非常顺手lambda表达式也不会像XML里拼SQL那样容易出错。Redis负责两件事一是分布式锁后面细说二是热点数据的缓存比如商品规格和配送日历。RabbitMQ在这里扮演的是业务解耦主消息管道支付成功之后需要触发库存占用确认、短信通知、配送任务生成预计算如果这些都在支付回调里同步执行回调接口的响应时间会很难看而且任何一个环节失败都会导致整个回调重试。用MQ把链路拆开后每个环节可以独立重试互不拖累。定时任务则承担日终计算的职责每晚固定时间扫描未来几天的订购单生成对应的配送单每天早上扫描即将到期的订阅给客户发续订提醒。这个我用的是XXL-Job因为支持分片和失败重试比Spring自带的Scheduled在可观测性上强不少。2. 以日期为中心的数据库模型设计是整个系统最见功力的部分2.1 商品表与配送日保质期倒逼出来的字段设计先看最基础的商品维度。鲜奶类目看起来简单实际建模时有两个坑一是同一款奶有不同规格200ml、500ml、1L二是价格策略复杂——按周订、按月订、连续订三个月单价都不一样而且不同配送频率每天送、隔天送、周末不送实际折算价格也不同。我最终的商品表product字段大概是这样id、name、spec规格描述、category_idbase_price单瓶基准价price_weekly、price_monthly、price_quarterly按订购周期对应的折算单价delivery_frequency允许的配送频率配置如每天/隔天/工作日stock_per_day每日可供库存上限voucher_rule订阅赠送优惠规则可为空status上下架状态设计时最关键的一个字段是stock_per_day。普通电商的商品表只有总库存但鲜奶的库存天然是按天分配的今天收200瓶订单明天可能只能再收80瓶因为奶站明天的产能就这么多。所以必须把每日产能上限独立出来配合后面的每日库存明细表使用。再来是配送日。客户选择了每天送还是工作日送本质上是生成一组合法的配送日期规则。我建了一张delivery_rule表用非常直白的方式表达rule_type如EVERY_DAY、WORKDAY、CUSTOM、custom_days自定义星期集合用JSON存储另外加一个start_date和end_date代表这个订阅的生效区间。这样设计的好处是后面生成配送任务时只需要一条循环就能把这组日期枚举出来非常直白。2.2 订购单与配送记录的拆分避免一单到底的混乱这一节是整个数据库设计的灵魂。我拆了两张核心表subscribe_order订购单主键、用户ID、商品ID、配送规则ID、奶站ID、开始日期、结束日期可为空代表长期有效、订购周期类型周/月/季、周期内配送总次数、剩余配送次数、已付金额、状态待支付/履约中/已暂停/已结束/已退款、备注。delivery_item配送明细主键、订购单ID、期望配送日期、实际配送日期、配送员ID、奶站ID、客户地址快照、状态待配送/配送中/已签收/拒收/补送/取消、签收时间、拒收原因。这两张表的关系是1对N。订购单只负责一件事记录订阅关系本身。每天该不该给这个客户送奶不是扫描订购单而是扫描delivery_item里日期 今天且状态为待配送的记录。为什么要刻意做这个拆分因为算账和履约的关注维度完全不同。算账关心的是这个月客户应该付多少钱还剩多少配送次数履约关心的是今天这几个配送员各自要送多少瓶哪家的客户临时暂停了得从今天的任务里划掉。如果这两类数据混在一张表里业务上的每一次变更都会演变成一场SQL灾难。在实际开发中生成delivery_item的时机也要讲究。我的方案是下单支付成功后立即生成整个订购周期内所有的delivery_item记录比如月订30条但每条都处于待配送状态。这样做的原因是后续的暂停、改期、换地址操作可以只针对某几条明细做局部修改不需要重新推演整个计划。当然代价是数据量会膨胀——一个配送周期长的客户会占据多条记录但MySQL存这点数据毫无压力查起来反而因为索引清晰而更快。2.3 奶站和地址的关系建模直接决定配送链路能否落地在这个系统里配送不是快递式的上门取件而是片区内按路线送。所以用户地址不能简单存一个字符串了事它必须能推导出这单归哪个奶站管。我的做法是维护一张station奶站表和一张station_route配送路线表。奶站包含名称、覆盖范围描述行政区划编码半径范围、配送员数量、联系电话。路线上记录了路线名称、所属奶站、负责配送员ID、路线上的小区/楼栋列表用JSON存储。用户下单时提交的地址会先做一次地址解析把省市区、街道、小区名结构化存入user_address表同时计算出归属的奶站ID和路线ID。这里不需要接太重的地图服务靠一张小区-路线映射表就能覆盖绝大多数场景——毕竟一个社区奶站的覆盖范围通常就是周边两三公里。这样设计的直接收益是配送任务生成时只需按delivery_item里的奶站ID和路线ID分组就能直接输出每个配送员今天的配送清单不需要实时调用地图API去算归属。我实测过几千条待配送明细的分组查询毫秒级返回运营侧体验很好。3. 订购主流程落地下单、锁库存、支付回调一个都不能想当然3.1 下单接口锁库存的并发处理鲜奶的库存不是总量而是未来N天内每日剩余产能。下单时不是只扣今天的量而是要把订购周期内每一天的产能都先占用。比如今天下单一个月的每日配送商品如果改商品日产能是200瓶系统需要检查从明天开始连续30天的剩余产能是否都大于等于1瓶然后对每一天做扣减。这里最直接的并发问题是两个客户同时下单都看到明天还剩1瓶同时下单就可能超卖。我用了两种手段叠加处理。第一层是MySQL乐观锁。每天每天的库存占用都落在daily_stock表里表结构简化为product_id delivery_date total_stock sold_stock version。下单时使用如下逻辑UPDATE daily_stock SET sold_stock sold_stock #{need}, version version 1 WHERE product_id #{productId} AND delivery_date #{date} AND sold_stock #{need} total_stock AND version #{version}如果更新影响行数为0说明这一天的剩余产能不足或者版本冲突整个下单事务回滚。这条SQL把检查和扣减合并成一个原子操作是防超卖的基础保证。第二层是Redis分布式锁锁的粒度是商品ID 日期防止同一商品同一天的多个下单请求并发进入那段批量扣减的循环。我用Redisson实现的String lockKey stock:lock: productId : startDate : endDate; RLock lock redissonClient.getLock(lockKey); boolean locked lock.tryLock(3, 10, TimeUnit.SECONDS); if (!locked) { throw new BizException(当前订购人数过多请稍后重试); } try { // 批量检查并扣减每一天的库存 } finally { lock.unlock(); }这里有个细节值得提醒如果不用锁而是直接用多条UPDATE语句扣减就必须把检查每一天剩余产能和逐天扣减放在同一个事务里否则会出现检查通过了、但扣减时某一天失败的情况。事务隔离级别我用的默认的RR可重复读配合上面的原子UPDATE语句实测并发压测下没有出现超卖。3.2 支付回调的幂等设计与状态推进用户在小程序端拉起支付支付完成之后支付平台会异步回调后端接口。这个回调接口是整个系统里对可靠性要求最高的一个入口。首先必须做幂等同一个支付单号支付平台可能因为网络重试发多次回调处理第一次时状态已经从待支付变已支付第二次回调进来不能重复发货、不能重复扣库存、不能重复发通知。我的做法是在回调处理入口先查subscribe_order的当前状态如果已经是已支付或履约中直接返回成功不再做任何业务操作。同时在事务里对支付流水表做了唯一约束靠数据库层兜底防止重复插入。处理链路如下回调请求进来先验签确认是支付平台发来的合法请求根据支付流水号查本地支付记录如果记录不存在说明这是伪造回调直接拒绝如果支付记录状态已经是SUCCESS返回成功响应不再处理开启事务更新支付记录状态为SUCCESS、更新订购单状态为履约中、批量生成delivery_item明细、对daily_stock执行扣减事务提交后发送MQ消息异步触发短信通知和管理后台的待配送任务预计算。第4步是整个事务最重的部分尤其是货量大的奶站一次月付成功可能涉及30条明细生成和30天库存扣减。实测这个事务在普通云数据库上耗时约200-400ms完全可以接受。如果未来数据量再翻几倍可以考虑把库存扣减做成异步补偿但目前的体量下没必要冒这个复杂度。支付失败或超时的情况怎么处理我设置了一个超时关单下单后15分钟内未支付定时任务会把订购单置为已取消同时释放掉之前占用的库存。这里释放库存同样要走事务把daily_stock.sold_stock减回去如果减回去后发现有其他订单在等待这些库存就通过MQ发一个库存释放事件让排队中的下单请求重试。3.3 取消订购与退款别把多退少补想简单了鲜奶订购的退款比普通电商复杂因为客户是先付整个周期的钱按天履约。如果客户在周期中途取消已经配送的那部分不能退没配送的那部分要按比例退。于是退款金额的计算公式变成退款金额 订购实付金额 - 单期折算单价 × 已配送数量举个例子月订30天实付90元每天折算3元已经送了10天取消时应退 90 - 3×10 60元。但这个公式背后有个隐藏问题库存已经扣掉的未来日期必须释放。取消之后订购单状态变成已结束所有未生效的delivery_item置为取消同时把对应的daily_stock.sold_stock回退。这些操作和退款一起必须在一个事务里完成否则会出现款已经退了明天的配送单还在生成的严重错漏。至于支付平台退款接口的调用我做成异步任务事务提交后发MQ消息退款worker消费消息调用支付平台退款接口退款结果回调后再更新本地退款单状态。这里同样要处理幂等退款单唯一键 状态机推进防止重复退款。4. 配送日历与订阅变更的状态管理细节最多、也最容易翻车的模块4.1 配送日计算逻辑千万别在代码里散落一堆日期判断下单成功后会按配送规则生成整个周期的delivery_item这一步的配送日计算其实是个不起眼但坑特别多的地方。比如工作日配送要考虑周六日而奶站可能自己定义周日不送周一送隔天送要区分从哪天起算再加上法定节假日调休如果全在代码里散落判断逻辑后面改一个规则就要全局搜索。我的做法是单独写一个DeliveryDateCalculator组件输入是规则类型、开始日期、周期长度输出是一组日期列表。内部维护一份特殊日期配置表奶站运营可以在后台配置某天是否停运。核心伪代码public ListLocalDate calculate(DeliveryRule rule, LocalDate startDate, int limitDays) { ListLocalDate result new ArrayList(); LocalDate cursor startDate; int count 0; while (count limitDays) { if (isHolidayStop(cursor)) { // 查询特殊日期配置表 cursor cursor.plusDays(1); continue; } if (rule.getRuleType().equals(EVERY_DAY) || (rule.getRuleType().equals(WORKDAY) !isWeekend(cursor))) { result.add(cursor); count; } cursor cursor.plusDays(1); } return result; }这样业务侧永远不会出现周几的判断逻辑所有配送日规则都收敛到这一个组件里。以后奶站说清明期间停运三天运营只需要后台配置三天特殊日期代码一行不用改。4.2 暂停、改期、换地址订阅操作的状态机客户使用过程中最常见的操作是暂停配送、改期、换地址这些操作本质上是对delivery_item明细的局部调整千万不要去改订购单的周期。暂停操作客户说下周一到周三停送系统要做的是把对应那三天的delivery_item状态从待配送改为暂停并把日期对应的库存释放掉。等暂停期过了从周四开始产品经理可能会问那这三天的奶是顺延补送还是直接跳过这需要配置。我的默认处理是直接跳过因为鲜奶是按天生产的没有补送本周一的三瓶这种操作顺延会导致日期和规则错位。改期操作比如客户说周六那天的奶改到周日下午送本质是把周六的delivery_item状态改为取消新生成一条delivery_item记录日期是周日收货地址不变。这里要注意库存周六的库存释放周日要重新占用如果周日剩余产能不足改期请求就要如实告知客户失败。很多人在这个地方漏掉库存检查结果周日配送员手里多出一瓶没人要的奶。换地址操作相对简单只更新该订单后续待配送明细上的地址快照。为什么用快照而不是直接改用户的主地址因为一份订购单可能在某个历史日期已经配送过了如果改了主地址历史配送记录里的地址也会跟着变会让财务对账和客户投诉追溯变得混乱。所以delivery_item上保留一份address_snapshot字段下单时从用户地址表复制过来配送员看的是这个快照。这些变更操作全部走一个统一入口SubscriptionChangeService每一个操作对应一个方法方法内部通过状态机校验当前状态是否允许该操作。比如已结束的订单不能再暂停暂停中的订购单不能再改期到过去日期——这些校验逻辑统一收敛避免各处散落、越改越乱。4.3 定时任务日终生成配送任务人工兜底不能少虽然下单时就已经生成了delivery_item但配送任务清单并不是直接从delivery_item查出来的。每晚21:00XXL-Job会跑一个生成明日配送任务的调度逻辑是查出所有delivery_item里日期为明天的记录按奶站和路线分组装配成配送任务单delivery_task每个任务单包含配送员、路线、客户列表和配送顺序。为什么要额外多一层delivery_task而不是直接让配送员查明细表因为每天的实际配送路线是会变化的某条路线的配送员请假了要临时并到其他路线某个小区明天有活动配送员要晚半小时到某些客户今天取消了配送路线上的顺序要重排。这些运营层面的调整如果直接改delivery_item会污染履约数据应该只体现在当天的任务单里。定时任务生成完任务单后如果发现某个配送员的路线客户数异常比如比昨天少了一半会发告警信息给奶站站长让站长人工确认。我接手这个项目时第一版没有这个检查机制后来有一次因为商品下架问题导致某条路线明天只剩3个客户配送员白跑了一趟还是客户打电话来投诉才发现。所以现在的机制是任务单生成后必须人工确认率达到100%才允许配送员开始接单站长在后台一键确认或者手动微调。5. 配送端和后台联动的几个实用设计5.1 配送员的任务清单核心是少点一下和可离线配送员端的界面需求往往被后台开发忽略但其实它是整个系统里用户量最大、使用频率最高的入口。这个项目里配送员主要用两个功能查看今日任务清单、确认签收或录入拒收。任务清单的接口设计非常简单粗暴GET /api/delivery/tasks?delivererIdxxxdatetoday返回的是该配送员当天所有待配送的明细列表按路线顺序排列。每条明细包含客户姓名、电话、地址快照、商品名称、数量、备注。这个接口必须做缓存——配送员早上打开一次路上可能因为信号差反复刷新如果每次刷新都去查一遍数据库高峰期数据库压力不小。我用的方案是Redis缓存整个任务清单配送员点击开始配送时主动失效对应路线的缓存。签收操作注意一个细节配送员可能一次签收多瓶牛奶但点击签收按钮时网络不好请求发出去了但客户端显示失败配送员又点了一次导致重复请求。所以签收接口必须是幂等的用delivery_item_id date做唯一键第二次提交直接返回成功。拒收处理也有讲究客户不在家、客户明确退订当天牛奶。拒收时要记录原因同时这个信息要异步通知到奶站运营因为拒收的牛奶可能涉及库存调整和客户账单的减免计算。如果拒收比例突然升高说明某个客户可能要退订运营需要提前介入。5.2 订阅提醒与通知链路鲜奶订购的很多业务动作需要通知客户支付成功通知、配送即将开始提醒早上6点半推送、配送签收提醒、订阅即将到期提醒。这些通知如果都在主流程里同步调用会很影响接口响应速度。我的做法是业务操作完成后统一发送MQ消息通知worker消费消息后根据用户的通知偏好短信或小程序订阅消息发送。比较特殊的是订阅到期提醒和续订优惠。到期前3天定时任务扫描所有即将到期的订购单给客户推送一条续订提醒里面带上续订优惠金额。这个提醒不算复杂但要注意去重同一个客户同一天不能收到多条续订提醒不然客户体验很糟糕。我给通知表加了唯一索引user_id subscribe_order_id notice_type notice_date保证每种通知每天最多发一次。有一点经验通知文案千万别写您的订单已发货这类电商话术鲜奶客户更关心的是明天早上7点前送到这种明确信息。文案模板要由运营在后台配置开发只负责把占位符替换成真实数据不要硬编码在代码里。6. 测试与上线期间踩过的坑以及最终效果6.1 并发压测发现的三个问题项目在联调阶段用压测工具对下单、支付回调、配送任务生成三个核心链路做了压力测试200并发下单用户持续跑10分钟。结果暴露了三个问题。第一个问题是数据库连接池被打满。原因是我最初用的是默认的HikariCP配置最大连接数只有10下单链路涉及多表操作和批量库存扣减每个事务要持有连接较长时间并发一上来连接池就不够用了。后来调整成最大连接数50并给纯读接口单独加了一层Redis缓存情况才好转。第二个问题是批量库存扣减时死锁。多商品同期下单时事务A扣的是1号商品明天的库存事务B扣的是1号商品后天的库存但如果两条记录的更新顺序不一致极端情况下会出现相互等待。解决办法是把所有库存扣减操作按product_id date排序后执行从源头消除环形等待。第三个问题是XXL-Job的任务执行和业务高峰重叠。原本生成明日配送任务定在凌晨0:05执行但恰好和日终对账任务重叠数据库IO飙高。后来把配送任务生成改到晚上21:00对账改到凌晨1:00错峰之后两边都稳定了。6.2 上线后客服反馈最集中的三个场景上线运营一个月后从客服那边收回来的问题很多不是技术Bug而是业务规则没想全。第一个场景客户以为今天下单明天就能喝到实际系统里当天下午3点后下单的订购单默认从后天开始配送。这个规则在产品上虽然写清楚了但总有客户不看说明直接下单第二天没收到奶就来投诉。后来在下单页加了一个醒目的开始配送日期确认组件客户确认后才进入支付投诉量立刻降了一大截。第二个场景暂停配送后客户忘记恢复导致后续几天的配送都被暂停了。我们原本的设计是暂停只作用于客户指定的日期范围但有些客户实际操作时只选了开始日期没选结束日期系统就默认无限暂停直到订购周期结束。后来做了兜底暂停操作必须强制选择结束日期否则不允许提交。第三个场景配送员签收后客户又反馈说没收到奶。排查发现是配送员签名时点错了条目。后来给配送员的签收页面加了一步二次确认弹窗要求看到客户地址和商品信息后再点确认误操作比例下降了很多。6.3 这套系统运行一段时间后的整体感受系统上线运行到现在整体稳定。日均处理配送明细大概几千条数据库负载很低Redis的内存占用也不大。最核心的收益是把人工排线的过程彻底解放了以前奶站站长每天晚上要手动排第二天的配送路线现在系统自动生成任务单后站长只需要花几分钟确认和微调。以前最怕客户半夜发消息说明天暂停一天因为要在纸质表格上改来改去现在客户在小程序上自助操作配送员第二天打开手机就能看到更新后的任务链路顺畅了很多。如果现在让我重新设计这套系统可能在两个方向上会做得更重一是把商品和配送计划的规则引擎做得更灵活让运营可以在后台自己配置复杂的订阅策略而不是依赖开发改代码二是给配送员端加上离线模式和更智能的路径排序目前还是按固定路线顺序展示遇到临时改路线的情况效率会打折。但这些属于锦上添花核心的订购履约模型已经经住了真实业务的考验方向是对的。最后分享一个选型的体会做这种面向真实业务的系统别一开始就追求微服务、追求完美架构。SpringBoot单体应用在目前这个规模下依然是最舒服的形态——部署简单、调试直接、出问题好定位。真正值得花心思的地方永远在业务模型的准确性和边界情况的处理上把这两件事做扎实了系统就成功了一大半。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询