去年九月开学那阵子,学校超市的老板找我诉苦:课间十分钟能涌进来几十个学生,收银台前排长队,还不断有人打电话说“帮我拿瓶可乐送到六号楼”。那一刻我意识到,校园场景里的外卖配送,跟美团饿了么那种商圈外卖完全是两码事——单量不大,但时效要求极高、配送范围高度集中、订单波峰波谷极其明显,通用外卖商家后台根本顶不住这种节奏。
我当时正在做的这个项目,就是一套基于Java的高校超市外卖配送系统,本文聚焦的是其中商家端(也就是超市老板和店员操作的那一端)的完整实现思路。商家端是整个系统里最容易被低估的模块,但实际承担了商品发布、库存管理、订单接收、拣货打包、配送调度这些核心环节,每一块都有不少值得展开的细节。适合正在做校园外卖类课程设计、毕业设计,或者真的想在校内跑一套配送系统的人参考。我会把需求分析、技术选型、数据库设计、状态机、配送逻辑和实战中踩过的坑一次讲清楚。
1. 高校超市外卖的商家端,和普通外卖商家端差在哪
1.1 需求背景:为什么校园外卖不能直接套用通用商家后台
先把场景说透。高校超市和商圈餐厅相比,有几个很特殊的点。
第一,物理距离短。宿舍楼到校内超市一般不超过一公里,绝大多数订单步行五分钟内能送到。这意味着配送动作本身很快,整个订单的生命周期也短——从下单到送达往往控制在二十分钟以内。商家端的所有流程设计都必须围绕“快”来做。
第二,订单波峰极其集中。大学的上课作息决定了订单高峰出现在课间和午晚休时段,比如上午第二节课后的10:00到10:20,下午最后一节课后的17:00到17:30。短时间内的并发下单量是平时的好几倍,商家端的接单、拣货、派单操作如果设计得不够顺,高峰期就全卡在店员手上了。
第三,收货地址是“点”而不是“面”。商圈外卖的收货地址是小区楼栋门牌号,而校园外卖的收货点就是宿舍楼、教学楼、图书馆、行政楼这些固定点位。配送员对路线了如指掌,不需要导航,商家端在派单时只需要指定“送几号楼”就行。
第四,商品SKU结构和餐厅完全不同。超市里从饮料、零食到文具、日用品,SKU可能上千个,而外卖上架的商品往往是其中动销比较快的那一两百个。这就决定了商家端必须有一套轻量但完整的商品管理能力,分类、上下架、库存维护、价格批量调整,一个都不能少。
1.2 商家端的功能边界:哪些模块必须做进商家端
结合上面的场景,我把商家端拆成七个功能模块,第一版的核心范围是这样定的:
| 模块 | 核心功能 | 优先级 |
|---|---|---|
| 商家信息 | 店铺名称、营业时间、公告、配送范围设置 | 必做 |
| 商品管理 | 商品分类、上下架、价格维护、库存维护、批量导入 | 必做 |
| 订单接收 | 实时接收新订单、语音/弹窗提醒、订单筛选 | 必做 |
| 订单处理 | 接单、拒单、拣货完成、呼叫配送、异常处理 | 必做 |
| 配送调度 | 查看配送员、指派配送员、改派、配送状态跟踪 | 必做 |
| 数据统计 | 当日销售额、订单量、热销商品Top10 | 二期 |
| 营销工具 | 折扣、满减、限时秒杀 | 二期 |
第一版别贪多。很多人做这种系统上来就想把数据看板做得花里胡哨,结果核心的订单流转反而没有时间打磨。这个项目的核心命脉是“订单从产生到配送完成”的这条链路,其他都是辅助。
1.3 这个阶段最容易犯的错误:一上来就做全,结果什么都做不像
我见过不少同学做校园外卖系统,一上来就列了十几个数据表,商家端还要搞什么财务报表、用户画像,最后数据库建了二十多张表,前端页面写了没几张就烂尾了。
我的建议很直接:第一版就死磕订单闭环。商品能上架、库存能扣减、订单能进来、商家能接单、配送员能把货送到、状态能流转到底,这条链路通了,这个系统就已经成功了一大半。数据统计、营销工具这些都是锦上添花,后面随时可以补。
另外提醒一句,校园配送系统有一个非常关键但被很多人忽略的设定:配送费用怎么算?校园场景一般客单价低,如果每单收两三块配送费,学生用户大概率会流失。所以商家端通常要支持“满额免配送费”的配置,这笔配送成本实际上由商家或平台来承担。这个逻辑在普通外卖商家后台里一般不常涉及,但在校园系统里是刚需。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型与项目架构:我为什么选了Spring Boot + MyBatis Plus这套组合
2.1 后端框架选择的思考逻辑
做校园超市外卖配送系统,后端技术栈的选型要综合考虑开发效率、代码可维护性和部署成本。我最终选了 Spring Boot 2.7.x + Java 8 这对组合,没有上 Spring Cloud 微服务,也没有用 JPA,理由很实际。
先说不选微服务。高校超市外卖的并发量没到那个量级,高峰期撑死几百单并发,一个单体应用完全扛得住。微服务带来的服务拆分、注册中心、配置中心、链路追踪,对于这种规模的项目反而是负担。你还要额外部署Nacos、Gateway这些组件,光运维成本就能劝退大半人。单体架构配合合理分包,后期如果真要把用户端、商家端、配送员端拆成独立服务,代码层面也预留了模块边界,并不冲突。
再说不选JPA,选MyBatis Plus。Hibernate/JPA 在实体映射上确实省事,但在这个项目里,订单表、订单明细表、商品表之间的关联查询非常频繁,而且多表join、动态SQL、批量插入的场景很多,MyBatis Plus 写起来更可控,分页、条件构造器、逻辑删除这些常用功能也都有现成封装,开发效率并不低。国内用 MyBatis 的团队多,遇到问题社区资料也好找。
2.2 商家端的前后端分离方案
商家端我用的是 Vue3 + Element Plus 做管理后台页面。为什么选 Element Plus?因为它本身就是做后台管理系统出身的组件库,表格、表单、弹窗、树形控件都很成熟,做商品管理、订单列表这种典型后台页面效率极高。
另一个很关键的技术点,是商家端如何实时接收新订单。这里我用的是 WebSocket,而不是轮询。原因很简单:高峰期订单密集,轮询间隔短了会造成大量无效请求,间隔长了又延迟明显。WebSocket 可以在订单到达的瞬间把消息推到商家端页面,配合提示音和新订单弹窗,店员不需要一直盯着列表刷新。
简单放一下我后端 WebSocket 配置的思路:
java复制@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Resource
private OrderWebSocketHandler orderWebSocketHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(orderWebSocketHandler, "/ws/merchant/{merchantId}")
.setAllowedOrigins("*");
}
}
每个商家连接时用 merchantId 作为路径参数,后端维护一个 merchantId 到 WebSocketSession 的映射,有新订单就推到这个商家自己的连接上。注意 setAllowedOrigins 的写法在新版本里建议改为 setAllowedOriginPatterns,不然跨域配置会有问题。
2.3 Redis 在商家端扮演的三个角色
Redis 在这个项目里不是可有可无的装饰品,它承担了三个很实在的任务。
第一个是库存预扣。超市商品是标品,库存有限,下单后立刻扣减库存可以有效防止超卖。但数据库的行锁在高峰期会成为性能瓶颈,所以我在下单时优先扣 Redis 里的库存,支付成功后再异步同步到 MySQL,这个后面第6章细讲。
第二个是热点数据缓存。商品列表、商品分类、店铺信息这些读多写少的数据,放进 Redis 之后能显著减轻数据库压力。
第三个是订单通知的轻量队列。下单时把订单ID push 到 Redis 的 List 结构里,商家端 WebSocket 服务从这个 List 中 pop 数据再推送给前端。中间隔了一层队列,就算商家端 WebSocket 连接闪断,订单数据也不会丢,重连之后再补推就行。
2.4 项目整体目录结构参考
后端我用的是标准的 Maven 多模块结构,按业务域分包,方便后期维护:
code复制supermarket-delivery/
├── delivery-common # 通用工具类、常量、枚举
├── delivery-admin-api # 商家端接口模块
├── delivery-user-api # 用户端接口模块
├── delivery-rider-api # 配送员端接口模块
└── delivery-system # 系统核心:订单、商品、配送、支付
很多人做单体应用习惯把所有代码堆在一个模块里,package 按 controller/service/mapper 三层分,这样做短平快,但后续如果要拆微服务或者做模块复用会很痛苦。按业务域先分好模块边界,前期多花十分钟,后期少加三天班。
3. 数据库设计:商品、订单、配送三张核心表的建模思路
3.1 商品表:库存字段为什么要拆成三个
超市商品管理的核心表是 product 和 product_category。商品分类表很简单,就是分类ID、分类名称、排序权重。商品表则有几个值得展开的字段设计。
先看建表的核心结构:
sql复制CREATE TABLE `product` (
`product_id` bigint(20) NOT NULL AUTO_INCREMENT,
`merchant_id` bigint(20) NOT NULL COMMENT '所属商家ID',
`category_id` bigint(20) NOT NULL COMMENT '商品分类ID',
`product_name` varchar(100) NOT NULL COMMENT '商品名称',
`product_img` varchar(255) DEFAULT NULL COMMENT '商品图片',
`price` decimal(10,2) NOT NULL COMMENT '售价',
`original_price` decimal(10,2) DEFAULT NULL COMMENT '原价(划线价)',
`total_stock` int(11) NOT NULL DEFAULT '0' COMMENT '总库存',
`pre_sold_stock` int(11) NOT NULL DEFAULT '0' COMMENT '已预占库存',
`available_stock` int(11) NOT NULL DEFAULT '0' COMMENT '可用库存',
`warning_stock` int(11) NOT NULL DEFAULT '10' COMMENT '库存预警阈值',
`sale_count` int(11) NOT NULL DEFAULT '0' COMMENT '销量',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1上架 0下架',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`product_id`),
KEY `idx_category_id` (`category_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';
关键的细节在库存字段设计。我没有只用一个 stock 字段,而是拆成了 total_stock(总库存)、pre_sold_stock(已预占)、available_stock(可用库存)三个。原因在于校园场景的库存变动是一个多步骤的过程:用户下单先预占库存,支付成功才真正扣减,超时未支付要释放回库存。如果只有一个字段,这些中间状态根本没法准确表达。
价格字段用 decimal(10,2),一定不要用 float 或 double。浮点数的精度问题在做订单金额累乘、优惠计算的时候会引发各种诡异误差,这是我见过最多的低级错误。
另外,上架状态用 tinyint 而不是 varchar,1 表示上架、0 表示下架。有些同学喜欢用“on_sale”/“off_sale”这种字符串,查询性能差,逻辑判断也绕。
3.2 订单表和订单明细表:为什么必须拆成两张表
订单表设计是整个系统的重心。我用的方案是订单主表 + 订单明细表的一对多模型。
sql复制CREATE TABLE `orders` (
`order_id` bigint(20) NOT NULL AUTO_INCREMENT,
`order_no` varchar(32) NOT NULL COMMENT '订单编号',
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`merchant_id` bigint(20) NOT NULL COMMENT '商家ID',
`order_status` tinyint(4) NOT NULL COMMENT '订单状态:0待支付 1待接单 2拣货中 3待配送 4配送中 5已完成 6已取消',
`total_amount` decimal(10,2) NOT NULL COMMENT '商品总金额',
`discount_amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '优惠金额',
`delivery_fee` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '配送费',
`pay_amount` decimal(10,2) NOT NULL COMMENT '实付金额',
`remark` varchar(255) DEFAULT NULL COMMENT '用户备注',
`receive_address` varchar(200) NOT NULL COMMENT '收货地址描述(如6号楼321)',
`receive_name` varchar(50) NOT NULL COMMENT '收货人',
`receive_phone` varchar(20) NOT NULL COMMENT '收货电话',
`accept_time` datetime DEFAULT NULL COMMENT '商家接单时间',
`pickup_time` datetime DEFAULT NULL COMMENT '拣货完成时间',
`finish_time` datetime DEFAULT NULL COMMENT '完成时间',
`cancel_reason` varchar(255) DEFAULT NULL COMMENT '取消原因',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`order_id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_merchant_status` (`merchant_id`, `order_status`),
KEY `idx_user_id` (`user_id`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
订单明细表则是把每个商品的快照信息存下来:
sql复制CREATE TABLE `order_item` (
`item_id` bigint(20) NOT NULL AUTO_INCREMENT,
`order_id` bigint(20) NOT NULL,
`product_id` bigint(20) NOT NULL,
`product_name` varchar(100) NOT NULL COMMENT '商品名称快照',
`product_img` varchar(255) DEFAULT NULL COMMENT '商品图片快照',
`price` decimal(10,2) NOT NULL COMMENT '下单时单价',
`quantity` int(11) NOT NULL COMMENT '购买数量',
`subtotal` decimal(10,2) NOT NULL COMMENT '小计金额',
PRIMARY KEY (`item_id`),
KEY `idx_order_id` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';
为什么明细表要冗余商品名称和图片?因为商品信息是会变的——商家改了商品价格,或者把商品下架了,历史订单里不应该跟着变。把下单那一刻的商品快照存下来,是订单系统保障可追溯性的基本操作。
订单状态字段我用 tinyint 存整数码值。有人喜欢直接存字符串状态名,比如“WAITING_ACCEPT”,这样做确实可读性好,但有几个问题:一是存储空间更大,二是在代码里做状态流转判断时字符串比较容易出错且不便于做枚举映射,三是如果哪天要改状态名,数据库里的历史数据全部要跟着改。用整数码值 + 代码层枚举映射,是更稳妥的方案。
3.3 配送任务表:配送信息为什么不能直接挂在订单表上
配送模块单独建了一张 delivery_order 表。有人可能会问,订单表里已经有收货地址和收货人了,为什么还要单独建一张配送表?
原因有两个。第一,一个订单不一定只配送一次,可能存在订单被拣货完成后发现配送员联系不上、需要改派的情况,每次配送调度都应该有记录。第二,配送状态和订单状态并不是完全同步的,订单可能已经处于“待配送”,但配送任务才刚刚创建,如果所有信息都塞在订单表里,字段会越来越混乱。
配送任务表的核心设计:
sql复制CREATE TABLE `delivery_order` (
`delivery_id` bigint(20) NOT NULL AUTO_INCREMENT,
`order_id` bigint(20) NOT NULL COMMENT '关联订单ID',
`merchant_id` bigint(20) NOT NULL COMMENT '商家ID',
`courier_id` bigint(20) DEFAULT NULL COMMENT '配送员ID',
`delivery_status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0待指派 1待取货 2配送中 3已完成 4已取消',
`pickup_time` datetime DEFAULT NULL COMMENT '取货时间',
`delivered_time` datetime DEFAULT NULL COMMENT '送达时间',
`recipient_address` varchar(200) NOT NULL COMMENT '收货地址快照',
`recipient_name` varchar(50) NOT NULL COMMENT '收货人快照',
`recipient_phone` varchar(20) NOT NULL COMMENT '收货电话快照',
`cancel_reason` varchar(255) DEFAULT NULL COMMENT '取消原因',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`delivery_id`),
KEY `idx_order_id` (`order_id`),
KEY `idx_courier_id` (`courier_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='配送任务表';
这里我同样做了地址和联系人的快照冗余。理由和订单明细表一样:订单收货信息如果有变更,历史配送任务应该保留当时的信息。
3.4 状态变更日志表:排查问题最好的朋友
最后强烈安利一张表:order_status_log。这个表只做一件事,就是记录订单状态每次变更的时间、操作人、旧状态、新状态、变动原因。
sql复制CREATE TABLE `order_status_log` (
`log_id` bigint(20) NOT NULL AUTO_INCREMENT,
`order_id` bigint(20) NOT NULL,
`from_status` tinyint(4) NOT NULL,
`to_status` tinyint(4) NOT NULL,
`operator_type` tinyint(4) NOT NULL COMMENT '操作方:1用户 2商家 3配送员 4系统',
`operator_id` bigint(20) DEFAULT NULL COMMENT '操作人ID',
`remark` varchar(255) DEFAULT NULL COMMENT '备注',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`log_id`),
KEY `idx_order_id` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单状态变更日志表';
这张表的效果,等你上线跑一周之后订单出了问题就知道了。没有状态日志,排查问题时只能靠猜;有了状态日志,点开一个订单就能看到完整时间线,是用户取消还是配送超时,一目了然。
4. 订单流转的核心逻辑:从下单提醒到配送完成的完整状态机
4.1 订单状态枚举的Java定义
订单状态是商家端最核心的领域概念。我用 Java 枚举把它定义清楚:
java复制public enum OrderStatus {
WAITING_PAY(0, "待支付"),
WAITING_ACCEPT(1, "待接单"),
PICKING(2, "拣货中"),
WAITING_DELIVERY(3, "待配送"),
DELIVERING(4, "配送中"),
COMPLETED(5, "已完成"),
CANCELED(6, "已取消");
private final Integer code;
private final String desc;
OrderStatus(Integer code, String desc) {
this.code = code;
this.desc = desc;
}
public static OrderStatus of(Integer code) {
for (OrderStatus status : values()) {
if (status.code.equals(code)) {
return status;
}
}
throw new IllegalArgumentException("非法订单状态: " + code);
}
}
这个枚举就是订单状态机的锚点。写业务逻辑的时候,任何对订单状态的判断都必须基于这个枚举,不允许散落魔法数字。
4.2 商家接单的完整链路设计
接下来走一遍订单从产生到送达的完整链路,这是商家端的骨架流程。
第一步,用户提交订单,支付成功后订单状态变成 WAITING_ACCEPT(待接单),订单数据插入 MySQL,同时订单ID写入 Redis 队列。
第二步,商家端的 WebSocket 服务感知到新订单,通过 WebSocket 推送到商家页面,前端触发提示音并弹出新订单卡片。
第三步,店员点击“接单”,后端执行接单逻辑:
java复制@PostMapping("/merchant/order/accept")
@Transactional(rollbackFor = Exception.class)
public Result<String> acceptOrder(@RequestBody @Valid AcceptOrderRequest request) {
Long merchantId = SecurityUtils.getMerchantId();
// 使用悲观锁防止两个收银员同时处理同一订单
Order order = orderMapper.selectByIdForUpdate(request.getOrderId());
if (order == null || !order.getMerchantId().equals(merchantId)) {
return Result.error("订单不存在或无权操作");
}
if (order.getOrderStatus() != OrderStatus.WAITING_ACCEPT.getCode()) {
return Result.error("订单状态已变更,请刷新后重试");
}
order.setOrderStatus(OrderStatus.PICKING.getCode());
order.setAcceptTime(LocalDateTime.now());
orderMapper.updateById(order);
orderStatusLogMapper.insert(new OrderStatusLog(
order.getOrderId(),
OrderStatus.WAITING_ACCEPT.getCode(),
OrderStatus.PICKING.getCode(),
OperatorType.MERCHANT.getCode(),
merchantId,
"商家接单"));
// 通过WebSocket通知用户端
websocketService.pushToUser(order.getUserId(),
new OrderNotifyMessage(order.getOrderId(), OrderStatus.PICKING.getCode()));
return Result.success("接单成功");
}
这里有一个很容易忽略的细节:接单接口一定要加悲观锁。场景是校园超市高峰期,店里可能有两三个店员同时在操作后台,如果同一笔订单被A店员和B店员同时点击了“接单”,没有锁的话两个请求都能通过状态判断,最后重复处理。selectByIdForUpdate 加上之后,第二个请求会阻塞,等第一个事务提交后再读取,状态已经变了,就会返回“订单状态已变更”。
第四步,店员拣货完成后点击“完成拣货”,订单状态流转到 WAITING_DELIVERY(待配送),前端进入配送调度环节。
第五步,商家指派配送员,配送员接单后开始配送,状态流转到 DELIVERING(配送中)。
第六步,配送员确认送达,状态流转到 COMPLETED(已完成),整个订单生命周期结束。
完整的状态流转关系可以用下面这张表表达:
| 操作 | 原状态 | 新状态 | 操作方 |
|---|---|---|---|
| 用户支付 | 待支付 | 待接单 | 用户端 |
| 商家接单 | 待接单 | 拣货中 | 商家端 |
| 商家完成拣货 | 拣货中 | 待配送 | 商家端 |
| 商家指派配送员 | 待配送 | 配送中 | 商家端 |
| 配送员确认送达 | 配送中 | 已完成 | 配送员端 |
| 用户取消 | 待接单 | 已取消 | 用户端 |
| 商家拒单 | 待接单 | 已取消 | 商家端 |
| 超时未接单 | 待接单 | 已取消 | 系统自动 |
4.3 超时未接单的自动处理方案
校园超市也会遇到店员没注意后台、订单长时间没人处理的情况。我的方案是加一个定时任务,每30秒扫描一次“待接单”状态的订单,如果超过5分钟还没有商家接单,系统自动取消订单并原路退款。
java复制@Component
public class OrderTimeoutTask {
@Resource
private OrderMapper orderMapper;
@Scheduled(fixedDelay = 30_000)
public void cancelTimeoutOrders() {
LocalDateTime deadline = LocalDateTime.now().minusMinutes(5);
List<Order> timeoutOrders = orderMapper.selectList(new LambdaQueryWrapper<Order>()
.eq(Order::getOrderStatus, OrderStatus.WAITING_ACCEPT.getCode())
.lt(Order::getCreateTime, deadline)
.last("LIMIT 200"));
for (Order order : timeoutOrders) {
cancelOrder(order, "超时未接单,系统自动取消");
}
}
}
注意 last("LIMIT 200") 这个细节。定时任务扫描不能一次性把所有超时订单都捞出来处理,如果某天商家正好休息,积压了大量待接单订单,一次性取消几百单会对数据库产生较大压力,分批处理更稳妥。
4.4 取消和退款常见的坑
用户侧取消和商家拒单,在代码路径上最终都会走向同一个状态“已取消”,但业务语义不同:用户取消是用户主动操作,商家拒单通常是商品缺货。我在取消逻辑里区分了 cancel_type 字段,方便后期统计拒单率。
另一个常见的坑是退款时序。取消订单之前,一定要先做库存回滚,再做支付渠道的退款。如果顺序反了,可能会发生库存已经释放但退款失败,用户钱货两空的严重问题。哪怕退款失败也要有补偿任务去重试,而不是直接抛异常结束。
5. 配送模块的商家视角:派单、改派与配送异常处理
5.1 派单模式:手动派单比自动派单更可靠
校园外卖系统的配送员通常就是学生兼职,人数少、班次不固定,高峰期可能同时在线的只有三五个配送员。这种情况下我建议用手动派单模式,商家端在“待配送”订单列表里点“指派配送员”,系统把当前在线的配送员列出来,商家选人分配。
自动派单算法看起来高级,比如按距离、负载均衡分配,但校园环境下配送员位置数据不准确,反而是最简单的手动指派最可靠。一期就做手动派单,先把业务跑通,以后配送员数量多了再引入自动派单也不迟。
5.2 配送状态推进的接口设计
商家指派配送员后,生成配送任务并通知配送员。配送员端有三个核心接口:
- 接单接口:配送员确认接受这个配送任务
- 取货接口:配送员到店取货,状态从“待取货”变为“配送中”
- 送达接口:配送员确认送达,状态变为“已完成”
商家端在订单详情页可以实时看到配送任务的位置,也就是当前的配送状态。这里不需要做高精度的实时定位——对学生兼职配送员来说,订单状态的推进比GPS坐标更有业务价值。
5.3 多单合并配送的实现思路
这是一个真实存在的需求:同一个配送员,从超市同时取走两三个订单,送往同一个宿舍楼,这是校园配送的常态。
我的做法是在配送任务表里增加一个 batch_id 字段,商家在指派配送员时,可以把几个“待配送”订单勾选后放在同一个配送批次里,生成一个 batch_id,配送员一次取货、一次送达多个订单。订单状态各自推进,但配送任务共用同一个批次号,方便后续做配送绩效统计。
这个功能的业务价值很大,能显著降低配送员的跑腿次数,也能降低商家的配送成本。一期如果时间紧张可以先不做,但数据库设计时预留 batch_id 字段,后面加功能就不需要动表结构了。
5.4 超时未配送与用户催单的商家端处理
配送环节最容易出问题的就是配送员接单后迟迟不取货。我给商家端做了一个配送超时提醒:订单进入“配送中”状态超过20分钟未送达,商家端页面对该订单高亮显示,同时给配送员推送催单通知。
用户催单的逻辑也需要商家端配合。用户端点击“催单”后,商家端会收到一条催单提醒,商家可以结合配送员实际情况选择“联系配送员”或“回复用户”。这个交互虽然简单,但在实际运营中非常有用,能显著减少用户投诉。
6. 库存与商品管理的联动细节:防止超卖和恶意锁单
6.1 下单时的Redis库存预扣机制
超市外卖和餐饮外卖最大的区别之一,就是标品库存必须严谨。饮料卖完了就是卖完了,不能像餐厅菜品那样“沽清”之后还能现做。所以我做了两级库存扣减:
用户下单时,先扣 Redis 里的库存:
java复制public boolean preDeductStock(Long productId, Integer quantity) {
String stockKey = "stock:product:" + productId;
Long remain = redisTemplate.opsForValue().decrement(stockKey, quantity);
if (remain < 0) {
// 扣成负数,回补
redisTemplate.opsForValue().increment(stockKey, quantity);
return false;
}
// 记录预占明细,用于超时回补
return true;
}
用户支付成功后,异步将 Redis 扣减同步到 MySQL:
java复制public void confirmDeductStock(Long productId, Integer quantity) {
productMapper.deductAvailableStock(productId, quantity);
productMapper.increasePreSoldStock(productId, quantity);
}
用户超时未支付,系统取消订单时,需要把预占的库存释放回 Redis:
java复制public void releaseStock(Long productId, Integer quantity) {
redisTemplate.opsForValue().increment("stock:product:" + productId, quantity);
}
这套机制的要点是:Redis 管并发、MySQL 管最终一致性。高峰期大量订单涌入时,扣库存操作走 Redis 内存操作,毫秒级完成,不会打到数据库造成锁竞争。等到支付成功再异步落库,即使 Redis 意外重启丢了少量数据,MySQL 的幂等扣减兜底也能保证不超卖。
6.2 库存回滚的三个场景
库存回滚是我实际开发中踩过最多坑的地方,主要有三个触发场景。
第一个场景是用户下单后超时未支付,系统自动取消订单。需要在定时任务里把 Redis 库存回补。
第二个场景是用户主动取消订单。此时要判断订单是否已经支付,如果已支付还要走退款流程。
第三个场景是商家拒单。这个最容易被忽略,因为商家拒单意味着库存实际上没有消耗,但 Redis 里已经预扣了。我的做法是商家拒单时统一调用 releaseStock 回补库存,同时在 order_status_log 里记录回补的操作人和原因,便于后期审计。
6.3 库存预警与商品批量维护
给商家端做了一个简单但实用的库存预警:当商品可用库存低于 warning_stock 阈值时,商品列表里显示“库存偏低”标签,并在商品管理页顶部汇总显示预警数量。这个功能对超市来说非常刚需,特别是热销饮料和零食,一旦在订单高峰期卖断货,体验影响非常大。
商品批量导入导出功能也值得一提。超市一两百个SKU,如果让店员一个个在页面上录入,效率极低。我做了一个 Excel 批量导入模板,店员按模板填好商品名称、分类、价格、库存,一键导入。
批量导入的时候有一个经典的坑:Excel 一行数据缺失导致数组越界。我在解析逻辑里先对行的每一列做空值校验,再按列索引取值,发现问题就定位到具体行号返回给前端提示。这个体验细节很加分,否则店员导入报错后根本不知道哪里错了。
6.4 促销场景下的库存控制
二期如果做秒杀、限时折扣,库存控制会更复杂。我预留的思路是:秒杀商品单独维护一套 Redis Key,比如“seckill:stock:productId”,秒杀请求先走 Lua 脚本原子校验库存并扣减,秒杀结束后再把实际销量同步回商品表。直接用 Lua 脚本是为了避免多个 Redis 命令之间的竞态条件,这个等做到那一步再展开。
7. 部署实测中的典型问题与优化方案
7.1 并发下单时的数据库死锁问题
这个坑我印象特别深。系统联调的时候,模拟20个并发用户同时下单,数据库瞬间报出 Deadlock found when trying to get lock,订单表里出现了脏数据。
排查链路是这样的:先看 MySQL 的错误日志,发现死锁发生在 product 表的行锁上。再分析业务代码,发现用户下单时,系统先插入了订单主表,再逐条更新商品库存。问题在于两个订单如果都包含商品A和商品B,但更新顺序不同,就可能出现互相持有对方需要的行锁,最终形成死锁。
解决方案有两个层面。第一,统一所有商品库存扣减的更新顺序,按 productId 升序处理,保证所有并发事务以相同顺序获取行锁。第二,在事务代码里增加重试机制,捕获死锁异常后延迟随机时间重试一次。
这个问题的根因很经典:多行更新的顺序不一致导致死锁。任何一个做电商类系统的团队早晚都会遇到,早点理解这个原理对后续开发大有帮助。
7.2 商家端订单页面刷新延迟的问题
早期版本我用的是前端每5秒轮询一次接口拉取新订单,高峰期的时候发现订单延迟比较明显,而且服务器负载很高。后来把轮询改成 WebSocket 推送,延迟从秒级降到了毫秒级,服务器压力也大幅下降。
这里有一个经验:WebSocket 项目在本地开发一切正常,但部署到服务器后如果用了 Nginx 反向代理,一定要记得配置 WebSocket 升级相关的请求头,否则连接会反复断开。Nginx 配置里需要加上:
nginx复制proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
很多前后端分离项目单独跑没问题,一联调就掉线,九成是 Nginx 的 WebSocket 配置没写对。
7.3 Lombok 编译报错问题
开发过程中我遇到过 “java: you aren't using a compiler supported by lombok, so lombok will not work” 这个报错。当时百思不得其解,代码前一天还能跑,第二天突然编译失败。
后来发现是因为本地 JDK 版本被切换到了 JDK 17,而我用的 Lombok 版本还停留在 1.18.20,不支持 JDK 17 的编译器。解决办法是升级 Lombok 版本到 1.18.30 及以上,或者把 JDK 切回 8/11。这个问题在网络热搜里也反复出现,说明踩的人不少。这里给个明确建议:如果项目用的是 Java 8,Lombok 用 1.18.20 没问题,但如果升级到 JDK 11 及以上,一定同步升级 Lombok 版本。
7.4 低配服务器上的内存溢出问题
项目早期部署在一台1核2G的云服务器上,运行了几天后出现了 "java.lang.OutOfMemoryError: Insufficient memory" 的报错,服务直接挂掉。
排查后发现两个原因:一是 JVM 默认堆内存设置过大,2G 物理内存的机器上默认堆上限占了很大比例,加上元空间和线程栈,直接触发了系统内存不足;二是代码里有一次性查询大量订单数据的地方,没有做分页,高峰期内存直接被打满。
处理方案分两步。第一步调低 JVM 参数:
bash复制java -jar supermarket-delivery.jar -Xms256m -Xmx512m -XX:+UseG1GC
第二步修代码,把订单列表、商品列表这些查询全部改成强制分页,禁止不带 LIMIT 的查询。
内存问题的核心其实不是 JVM 参数调完就完了,而是代码层面不能有数据量不受控的查询。把这两个点都堵住,低配服务器也能稳定运行。
7.5 压测数据与最终优化效果
系统优化完成后,我用 JMeter 做了一轮基础压测。在1核2G的服务器上,单机部署,模拟高峰期课间场景:100个并发用户同时下单,接口平均响应时间在500毫秒以内,订单创建成功率100%,没有出现超卖现象。Redis 的库存预扣在并发场景下表现稳定,数据库连接池没有出现耗尽的情况。
当然,这个数据是在本地网络环境和测试数据量下得出的,真实校园场景的网络状况和学生手机型号差异会带来额外波动。但至少说明一点:单体架构 + Redis 预扣 + 合理的 SQL 索引,应对一个高校的订单量绰绰有余。
关于这个项目,我个人最大的体会是:校园外卖系统的技术复杂度并没有想象中那么高,真正的难点在于理解业务场景的特殊性。波峰波谷的订单节奏、集中式的收货点、学生群体对价格的敏感度、兼职配送员的管理方式——这些问题都直接影响系统设计,代码反而是最不需要担心的部分。做这种项目,先跑通订单闭环,再想其他,这是我踩过很多坑之后的肺腑之言。
