这几年生鲜电商火了之后,不少做区域配送的朋友都在琢磨怎么把手里的鲜奶订单从"微信群里接龙"升级成一套像样的业务系统。我去年就接手过一个社区奶站的转型项目,需求很简单:客户按周或按月订鲜牛奶,奶站每天按配送路线送货。但真把需求落到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 List<LocalDate> calculate(DeliveryRule rule, LocalDate startDate, int limitDays) { List<LocalDate> 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:00,XXL-Job会跑一个"生成明日配送任务"的调度,逻辑是:查出所有delivery_item里日期为明天的记录,按奶站和路线分组,装配成配送任务单(delivery_task),每个任务单包含配送员、路线、客户列表和配送顺序。
为什么要额外多一层delivery_task而不是直接让配送员查明细表?因为每天的实际配送路线是会变化的:某条路线的配送员请假了,要临时并到其他路线;某个小区明天有活动,配送员要晚半小时到;某些客户今天取消了配送,路线上的顺序要重排。这些运营层面的调整如果直接改delivery_item会污染履约数据,应该只体现在当天的任务单里。
定时任务生成完任务单后,如果发现某个配送员的路线客户数异常(比如比昨天少了一半),会发告警信息给奶站站长,让站长人工确认。我接手这个项目时,第一版没有这个检查机制,后来有一次因为商品下架问题导致某条路线明天只剩3个客户,配送员白跑了一趟,还是客户打电话来投诉才发现。所以现在的机制是:任务单生成后必须人工确认率达到100%才允许配送员开始接单,站长在后台一键确认或者手动微调。
5. 配送端和后台联动的几个实用设计
5.1 配送员的任务清单,核心是"少点一下"和"可离线"
配送员端的界面需求往往被后台开发忽略,但其实它是整个系统里用户量最大、使用频率最高的入口。这个项目里配送员主要用两个功能:查看今日任务清单、确认签收或录入拒收。
任务清单的接口设计非常简单粗暴:
GET /api/delivery/tasks?delivererId=xxx&date=today返回的是该配送员当天所有待配送的明细列表,按路线顺序排列。每条明细包含客户姓名、电话、地址快照、商品名称、数量、备注。这个接口必须做缓存——配送员早上打开一次,路上可能因为信号差反复刷新,如果每次刷新都去查一遍数据库,高峰期数据库压力不小。我用的方案是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单体应用在目前这个规模下依然是最舒服的形态——部署简单、调试直接、出问题好定位。真正值得花心思的地方永远在业务模型的准确性和边界情况的处理上,把这两件事做扎实了,系统就成功了一大半。