简介:一份基于Java构建的食堂订餐与打单系统源码工程,适合正在学习Java Web开发、订单管理系统设计的学生与初级开发者,以校园食堂日常订餐、订单生成与打单打印为核心业务场景。包内共25个文件、约107KB,逻辑主体由10个Java源文件承担,覆盖订餐流程与打单业务;4个XML配置文件管理数据库连接、系统参数等运行配置;3个SQL脚本用于初始化食堂菜单、订单等数据表,便于本地快速部署;3个JFreeChart图形文件则以图表形式辅助展示统计结果,方便食堂运营人员直观掌握各时段订餐情况。另有Markdown说明文档、Git忽略文件、属性文件及开源协议文件,从编码、配置、数据到文档各环节较为完整。项目采用面向对象设计,代码结构清晰,对理解Java企业级项目组织方式和食堂订餐业务流程有直接参考价值。目前已有245人学习下载,是一份可用于课程设计、毕业设计或私活练手的轻量级开源参考实现。
1. 食堂订餐与打单系统:先想清楚订单闭环再写代码
很多人看到“基于Java实现的食堂订餐与打单系统”这个标题,第一反应是“又是一套 CRUD”。但真正在食堂现场待过的人知道,麻烦全在链条两端:订餐端要防重复下单、防超卖,打单端要保证厨房小票不延迟、不丢单、中文不乱码。
这套系统我基于 Spring Boot + MyBatis 落地过几个中型食堂。下面按业务模型、表结构、ESC/POS 打印指令、下单事务和踩坑顺序展开,适合做课程设计、毕业设计,或者准备做中小型食堂信息化改造的开发者参考。读完你能直接照搬表结构和代码骨架,也能避开那些只有到现场才会踩的坑。
2. 拆解业务模型:状态机与表结构决定系统上限
2.1 三种食堂订餐形态,先分清要不要锁库存
食堂这个场景看似简单,实际有三种常见形态,表结构设计差别很大。
自选餐线。用户订当天某一餐次的几样菜,到窗口取餐,后厨按小票备餐。这种形态下,限量菜(红烧肉、清蒸鱼)每天供应量固定,下单时必须锁库存,否则窗口一开就超卖。套餐预订。提前一天或提前几天预订次日固定套餐,食堂按所有订单汇总备餐,库存模型可以弱化,菜品表只需要上下架状态,不需要 stock 字段。现场扫码点餐。用户到食堂才扫桌码下单,厨房立即出票,对打单实时性要求最高,打印机在线状态和重试机制是核心。
很多食堂一餐里同时有套餐和自选菜,所以我一般建议表结构按最复杂的“自选+限量锁库存”来设计,套餐预订复用同一套订单表,只是下单时不扣库存。这么设计的好处是把扩展成本降到最低:包厢订餐、团餐预订、现场加菜都可以挂在同一套表上,只改订单的 meal_type 或加一个 order_type 字段。
2.2 三张主表加一张明细表:字段设计与建表 SQL
我常用的表结构是用户表t_user、菜品表t_dish、订单表t_order和订单明细表t_order_item,一套下来基本够用。
CREATE TABLE `t_user` ( `id` BIGINT AUTO_INCREMENT COMMENT '主键', `name` VARCHAR(50) NOT NULL COMMENT '姓名/工号', `card_no` VARCHAR(50) DEFAULT NULL COMMENT '一卡通号', `dept` VARCHAR(100) DEFAULT NULL COMMENT '部门', `balance` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '账户余额', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';balance 字段是按需加的。如果食堂走账户充值、订餐扣余额的模式就保留;如果走微信/支付宝扫码支付则可以去掉,订单金额单独走支付流水表。用户表不做复杂扩展,工号、部门这类字段按单位情况增减即可。
CREATE TABLE `t_dish` ( `id` BIGINT AUTO_INCREMENT COMMENT '主键', `name` VARCHAR(100) NOT NULL COMMENT '菜品名', `price` DECIMAL(10,2) NOT NULL COMMENT '单价', `category` VARCHAR(50) DEFAULT NULL COMMENT '分类:荤菜/素菜/汤/主食', `stock` INT NOT NULL DEFAULT 0 COMMENT '当日剩余份数', `daily_limit` INT NOT NULL DEFAULT 0 COMMENT '每日供应份数', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1-上架 0-下架', PRIMARY KEY (`id`), KEY `idx_category` (`category`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜品表';daily_limit 和 stock 的区别是:daily_limit 是每日投放总数,stock 是当天剩余数。每天早上定时任务把 daily_limit 重置进 stock,后端只认 stock。这样做的好处是盘点、退货、临时加菜都只改一个字段,不会出现“当日总数被改乱”的傻问题。
CREATE TABLE `t_order` ( `id` BIGINT AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号', `user_id` BIGINT NOT NULL, `meal_type` TINYINT NOT NULL COMMENT '1-早餐 2-午餐 3-晚餐', `order_date` DATE NOT NULL COMMENT '订餐日期', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-待支付 1-已支付 2-制作中 3-待取餐 4-已完成 5-已取消', `total_amount` DECIMAL(10,2) NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_date` (`user_id`, `order_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';订单号、用户、日期、餐次这一组字段上建议做唯一索引,这是防重复下单的最后一层兜底,靠代码判重总有并发漏网的时候。状态字段存 int,不存字符串,状态机的解释权完全交给 Java 枚举。
CREATE TABLE `t_order_item` ( `id` BIGINT AUTO_INCREMENT, `order_id` BIGINT NOT NULL COMMENT '订单ID', `dish_id` BIGINT NOT NULL, `dish_name` VARCHAR(100) NOT NULL COMMENT '下单时的菜品名快照', `price` DECIMAL(10,2) NOT NULL COMMENT '下单时价格快照', `quantity` INT NOT NULL DEFAULT 1, PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';dish_name 和 price 冗余快照是必须的。否则菜品改名或调价后,历史订单打出来的小票会跟着变,财务对账也会对不上。点餐系统里这是常识,但不少课程设计会把关联做到菜品表上,等改价就翻车。
2.3 订单状态机:用 Java 枚举把流转规则写死
订单状态流转是这个系统里最容易写乱的地方。我遇到过不少实现用一堆 if 判断当前状态允许做什么,结果漏了分支,订单走到“已取消”还能被扫码取餐。正确做法是把状态流转规则集中到一个枚举里。
public enum OrderStatus { PENDING_PAY(0, "待支付"), PAID(1, "已支付"), COOKING(2, "制作中"), READY(3, "待取餐"), DONE(4, "已完成"), CANCELLED(5, "已取消"); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public static boolean canTransit(int from, int to) { switch (from) { case 0: return to == 1 || to == 5; case 1: return to == 2 || to == 5; case 2: return to == 3 || to == 5; case 3: return to == 4; default: return false; } } }逻辑说明:待支付可以去支付或取消;已支付进入制作,或超时未动可取消;制作中可标为待取餐,也可走退单;待取餐只能完成。所有非法流转一律 false,调用方拿到 false 直接抛业务异常。这套规则写死之后,下单、支付、制作、取餐、取消五个接口里就不需要各自维护状态判断,统一调canTransit(oldStatus, newStatus),后面加“退餐中”这类新状态也只改这一处。
3. 打单模块:热敏打印机、ESC/POS 指令与中文编码
3.1 先选连接方式:串口打印机 vs 网口打印机
食堂场景的打印机基本是 58mm 或 80mm 热敏小票机,连接方式就两种。
USB/串口。拿一根线插在收银电脑上,装驱动,通过虚拟串口打印。便宜,但要求后厨那台电脑不能关,也不能同时被多台机器连。Java 里要引入 jSerialComm 这类库,权限和驱动在 Windows/Linux 上都有坑。网口(LAN)。打印机直接插网线,给一个固定 IP,Java 端用 Socket 连接 9100 端口(大部分热敏打印机默认端口)发送数据。不用装驱动,不用管后厨电脑,一台打印机可以被多个服务端连。
我落地的项目里全用网口,稳定性比串口好,排错也直接。打印机离线时,网口方案至少能通过 IP 通不通来判断问题,串口方案出了问题连是驱动坏了还是线松了都说不清。
3.2 Socket 连接网络打印机的 Java 实现
打印机的底层协议是 ESC/POS,网口方案本质上就是把 ESC/POS 指令字节流通过 TCP 发到打印机。先写一个最简的打印客户端:
import java.io.OutputStream; import java.net.InetSocketAddress; import java.net.Socket; public class ThermalPrinter implements AutoCloseable { private Socket socket; private OutputStream out; public void connect(String ip, int port) throws Exception { socket = new Socket(); // 连接超时 3 秒,避免打印机离线时接口卡死 socket.connect(new InetSocketAddress(ip, port), 3000); socket.setSoTimeout(5000); out = socket.getOutputStream(); } public void write(byte[] data) throws Exception { out.write(data); out.flush(); } @Override public void close() throws Exception { if (socket != null) { socket.close(); } } }连接超时设 3 秒、读超时设 5 秒,这两个参数是血泪教训。默认 Socket 连接在打印机离线时会一直等 TCP 握手,下单接口就被卡死几十秒。超时一设,接口才能快速失败走重试逻辑。连接使用上我不用长连接池:打印机连接建立很快,固件对并发连接又很敏感,短连接用完即关反而稳定。
提示:网口打印机测试时先用 telnet 做连通性测试:
telnet 192.168.1.100 9100,通了再排查代码。
3.3 小票模板:ESC/POS 控制指令与 GBK 编码
ESC/POS 指令不多,常用就这几个:初始化1B 40、对齐方式1B 61 n、倍宽倍高1D 21 n、走纸1B 64 n、切纸1D 56 42。重点是所有文本必须按 GBK 编码发送,不是 UTF-8。热敏打印机出厂默认中文字库基本是 GBK,直接发 UTF-8 字节流会出现一堆乱码。
import java.io.ByteArrayOutputStream; public class ReceiptBuilder { public static byte[] buildOrderReceipt(OrderVO order) throws Exception { ByteArrayOutputStream buf = new ByteArrayOutputStream(); // 1. 初始化打印机,清空缓冲区 buf.write(new byte[]{0x1B, 0x40}); // 2. 标题居中并放大:0x1D 0x21 0x11 表示倍宽倍高 buf.write(new byte[]{0x1D, 0x21, 0x11}); buf.write(new byte[]{0x1B, 0x61, 0x01}); buf.write("××食堂取餐单".getBytes("GBK")); buf.write(new byte[]{0x1D, 0x21, 0x00}); buf.write("\n".getBytes("GBK")); // 3. 分隔线左对齐 buf.write(new byte[]{0x1B, 0x61, 0x00}); buf.write("--------------------------------".getBytes("GBK")); buf.write("\n".getBytes("GBK")); // 4. 订单基础信息 buf.write(("订单号:" + order.getOrderNo()).getBytes("GBK")); buf.write("\n".getBytes("GBK")); buf.write(("日期:" + order.getOrderDate() + " 餐次:" + order.getMealTypeDesc()).getBytes("GBK")); buf.write("\n".getBytes("GBK")); // 5. 明细行 for (OrderItemVO item : order.getItems()) { String line = String.format("%-12s x%d ¥%.2f", item.getDishName(), item.getQuantity(), item.getPrice()); buf.write(line.getBytes("GBK")); buf.write("\n".getBytes("GBK")); } // 6. 合计与取餐提示 buf.write("--------------------------------".getBytes("GBK")); buf.write("\n".getBytes("GBK")); buf.write(("合计:¥" + String.format("%.2f", order.getTotalAmount())).getBytes("GBK")); buf.write("\n".getBytes("GBK")); buf.write("请凭小票到 2 号窗口取餐".getBytes("GBK")); buf.write("\n".getBytes("GBK")); // 7. 走纸 5 行,切纸 buf.write(new byte[]{0x1B, 0x64, 0x05}); buf.write(new byte[]{0x1D, 0x56, 0x42}); return buf.toByteArray(); } }排版逻辑说明:0x1B 0x61 0x00左对齐、0x01居中、0x02右对齐;0x1D 0x21 0x11是倍宽倍高,打完标题必须用0x1D 0x21 0x00恢复标准字号,否则后面的正文全是放大字。String.format("%-12s")在中文名超过 6 个汉字时会撑破对齐,菜品名不宜过长,超过 8 个字的菜名建议做截断。切纸指令0x1D 0x56 0x42不同品牌支持度略有差异,个别型号不支持,发送后无效果但不报错。
编码参数这里必须显式getBytes("GBK"),不要用getBytes()。Java 默认字符集在 Linux 服务器上通常是 UTF-8,直接发 UTF-8 字节给打印机,中文小票就是一团乱码。个别打印机还需要发送0x1B 0x74 0x11显式切换字符集为 GBK,品牌不同指令有差异,先试默认,乱码再查说明书选字符集指令。
3.4 打单任务的异步推送与失败重试
订餐接口不能直接同步打印,否则打印机离线时下单也失败。正确做法是下单事务提交后,发一条消息到打单任务队列。我这里的做法是落库加内存队列,双层保障:
@Component public class PrintDispatcher { private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(); private final PrintTaskMapper printTaskMapper; private final OrderMapper orderMapper; public void asyncSend(Long orderId) { EXECUTOR.submit(() -> { try { OrderVO order = orderMapper.findDetailById(orderId); byte[] data = ReceiptBuilder.buildOrderReceipt(order); try (ThermalPrinter printer = getPrinter()) { printer.connect(printerIp, printerPort); printer.write(data); } printTaskMapper.markSuccess(orderId); } catch (Exception e) { // 失败记录状态,交给定时任务补打 printTaskMapper.markFailed(orderId, e.getMessage()); } }); } }线程池用newSingleThreadExecutor,让打单任务串行化。热敏打印机一次只能处理一个连接,多个线程同时打印会出现交错乱码。失败时不要在这里死循环重试,而是标记状态让定时任务处理。定时任务每 5 分钟扫一次失败记录,连续失败 5 次发告警通知管理员去后厨看打印机。补打时按订单创建时间排序,避免恢复后乱序出票。
4. Spring Boot + MyBatis 落地:下单接口与库存扣减
4.1 项目骨架与数据源配置
我用的组合是 Spring Boot 2.7.x + mybatis-spring-boot-starter 2.3.x + MySQL 8.0,兼容性和网上资料最全。项目分包按 controller / service / mapper / entity / dto / common 拆,简单直接。SQL 的写法上我习惯 XML 与注解并存:涉及动态条件(列表筛选、统计报表)用 XML,单表简单 CRUD 用注解。
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/canteen?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.canteen.entity configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case这行必须开,数据库下划线字段自动映射到 Java 驼峰属性,省掉大量 resultMap。如果你翻过 MyBatis 的一级缓存源码,会知道默认是 SqlSession 级别缓存,在 Spring 集成下每次 mapper 操作都在新 session 里执行,缓存形同虚设,所以不用担心这里产生脏数据。
4.2 下单接口:事务里做乐观锁扣库存
下单是系统里最核心的写操作,要保证三件事:订单不重复、库存不超卖、事务要么全成功要么全回滚。
@Service @RequiredArgsConstructor public class OrderService { private final DishMapper dishMapper; private final OrderMapper orderMapper; private final OrderItemMapper orderItemMapper; private final PrintDispatcher printDispatcher; @Transactional(rollbackFor = Exception.class) public OrderVO createOrder(OrderCreateRequest request) { // 1. 幂等校验:同一用户同一天同一餐次,不允许已有进行中的订单 int exist = orderMapper.countActiveByUserAndDate( request.getUserId(), request.getOrderDate(), request.getMealType()); if (exist > 0) { throw new BusinessException("该餐次已下单,请勿重复提交"); } // 2. 生成业务订单号 String orderNo = generateOrderNo(); // 3. 逐项扣减库存,乐观锁保护 for (OrderItemRequest item : request.getItems()) { int affected = dishMapper.deductStock(item.getDishId(), item.getQuantity()); if (affected == 0) { throw new BusinessException("菜品[" + item.getDishName() + "]库存不足或已下架"); } } // 4. 保存订单主表与明细 Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(request.getUserId()); order.setOrderDate(request.getOrderDate()); order.setMealType(request.getMealType()); order.setStatus(OrderStatus.PENDING_PAY.getCode()); order.setTotalAmount(calcTotal(request.getItems())); orderMapper.insert(order); for (OrderItemRequest item : request.getItems()) { orderItemMapper.insert(order.getId(), item.getDishId(), item.getDishName(), item.getPrice(), item.getQuantity()); } // 5. 事务提交后触发打单,避免回滚了还打票 TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { printDispatcher.asyncSend(order.getId()); } }); return OrderMapper.toVO(order); } }事务边界的说明:扣库存、写订单、写明细在同一个事务里,任何一步抛异常整体回滚。afterCommit里触发打单,保证只有事务真正提交后才把数据推给打印机,这是避免“回滚了还打出小票”的关键手段。
对应的 MyBatis 扣库存 SQL 是乐观锁的核心:
<update id="deductStock"> UPDATE t_dish SET stock = stock - #{quantity} WHERE id = #{dishId} AND stock >= #{quantity} AND status = 1 </update>逻辑说明:stock >= #{quantity}这个条件在数据库层面保证了并发下不会扣成负数。两个请求同时扣同一道菜的库存,MySQL 行锁会让后到的更新等前一个提交,再执行时条件不满足,影响行数为 0,代码里affected == 0抛库存不足。这比 Java 层 synchronized 靠谱得多——服务多实例部署时 synchronized 各锁各的,照样超卖。
4.3 打单任务表与补打联动
打单任务表是打印环节的兜底,字段不多但设计要合理:
CREATE TABLE `t_print_task` ( `id` BIGINT AUTO_INCREMENT, `order_id` BIGINT NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-待打 1-成功 2-失败', `fail_count` INT NOT NULL DEFAULT 0 COMMENT '连续失败次数', `last_try_time` DATETIME DEFAULT NULL COMMENT '上次重试时间', `err_msg` VARCHAR(255) DEFAULT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_status_try` (`status`, `last_try_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='打单任务表';下单事务提交时插入一条 status=0 的记录,PrintDispatcher 消费后更新为 1 或 2。定时任务扫status=2 AND fail_count < 5的记录重新推数据,连续失败 5 次就不再自动重试,改为告警通知。有了这张表,哪怕打印机在高峰期坏了半小时,恢复后漏单也能自动补打,窗口前不会出现一堆人等着、后厨一张票都没见的情况。管理后台的接口设计上,最核心的是这几项:菜品上下架、每日限量设置、订单查询、手动补打。订单查询列表按 created_at 倒序,餐次按 meal_type 排序,用 Java 8 的 Comparator.comparing 链式排序在内存里做,比在 SQL 里写多个 ORDER BY 更灵活。
5. 避坑记录:订餐打单系统最常翻车的 5 个现场
5.1 订餐截止时间:10:30 到底包不包括秒
现象:用户 10:30:30 下单成功,食堂规定 10:30 截止,现场扯皮。
原因:比较时间用了new Date()和 10:30:00 做毫秒比较,忽略了秒。10:30:30大于10:30:00就放行了,但食堂的本意是 10:30:59.999 之前都算截止前。
解决:用LocalTime.now().isAfter(LocalTime.parse("10:30:00"))判断,并把截止时间定义成“10:30:00 之前可下单,10:30:00 及之后拒绝”。如果要给后厨留备餐缓冲,把配置项写成“10:29:30”,而不是在代码里做减法,配置化方便食堂自己改。
5.2 中文小票乱码:编码问题是最隐形的翻车
现象:小票打出来中文全是问号乱码,英文和数字正常。原因:服务器或代码里用了 UTF-8 编码打印,热敏打印机字库默认 GBK,不认识 UTF-8 的中文字节。解决:所有写打印机的文本统一getBytes("GBK"),不要相信平台默认字符集。排查顺序是:先打一行纯中文测试文本确认打印机字库,再用代码打一张测试小票,最后确认编码是否显式指定。这个坑我最早是发版后到食堂现场才发现的,后来养成习惯:凡是涉及编码和打印机指令的改动,先打测试小票再发正式版本。
5.3 菜品超卖:Java 的 synchronized 锁不住分布式
现象:高峰期同一道限量红烧肉卖出超过当日供应量的份数。原因:下单接口用了synchronized (dishId)做锁,单机部署勉强有效,一旦服务扩到多个实例,每个实例各锁各的,数据库层面没有保护。库存校验和扣减是两步操作,并发下两个请求都通过了校验再扣减,就超卖了。解决:把锁下沉到数据库,用UPDATE ... SET stock = stock - #{quantity} WHERE stock >= #{quantity}这种带条件的 UPDATE,让数据库行锁去扛并发。超卖这个话题在“java面试题”里经常聊到高并发锁方案,但这里的结论很直接:能不用分布式锁就别用,一条条件 UPDATE 就把问题解决掉了。
5.4 打单延迟:打印机离线时消息在队列里堆积
现象:厨房打印机断电半小时,恢复供电后突然吐出一堆旧订单小票,顺序和下单时间对不上。原因:异步线程里生产者一直往内存队列丢任务,消费者连不上打印机时不断重试,队列被阻塞,恢复后一次性冲刷出来。解决:三个措施同时上。一是打单任务落库,用数据库表当队列,内存队列只做暂存;二是失败任务标记状态后立即返回,不让消费者线程卡在重试上;三是补打任务按 created_at 排序,恢复后按实际下单顺序出票。这里要强调的是,内存队列当唯一通道是不可靠的,进程一重启队列就丢了,落库才是兜底。
5.5 取消订单不回补库存
现象:用户取消订餐后,菜品库存没加回来,当日限量菜剩余份数越来越少。原因:取消接口只改了订单状态,没有反向调用库存增加方法。扣库存是正向事务做的,取消是独立事务,不回滚扣减。解决:取消逻辑里同步回补库存,并且回补和改状态必须在同一个事务里。incrStock的 SQL 依然走数据库更新,SET stock = stock + #{quantity}即可。另一个坑是“已支付”状态取消时要同时处理退款,退款和回补库存要么在一个事务里,要么先回补再异步退款,顺序不能反过来,否则用户看到退款未到账会投诉。这个场景我也在状态机里加了约束:只有 PENDING_PAY 和 PAID 状态才允许走取消流转,取餐完成后的订单直接拒绝取消请求。
6. 从单食堂到多食堂:扩展改造与性能验证
一套订餐系统做出来,下一步通常就是复制到其他食堂。多食堂扩展时,最省事的做法是加一张t_tenant租户表,所有业务表增加tenant_id字段,SQL 里统一带上租户过滤。菜品、订单、打单任务都按租户隔离,打印机的 IP 和端口配置按租户存,打单时从配置中心读取对应的打印机地址。这么改的意义在于,打印任务能分发给正确食堂的打印机,而不是所有订单都打到同一个后厨。
性能验证上,下单接口至少做一轮压测,重点盯两个指标:QPS 和库存扣减成功率。用 JMeter 模拟 100 个并发用户同时抢同一道限量菜,观察是否存在超卖、订单号是否重复、数据库死锁日志有没有出现。死锁是扣库存最容易出的问题,如果出现,检查多个更新语句的锁定顺序是否一致,比如扣菜 A 和扣菜 B 的顺序在两个接口里不同,锁顺序不一致就会死锁。解决方式是所有扣库存语句按 dish_id 升序执行,锁顺序全局统一。
另一个值得长期养的验证习惯是打印端到端测试:下单后 1 秒内打单任务记录应该落库,5 秒内打印状态应该变成成功。把这条链路做成一个测试接口,每次发版后跑一遍,确认打单链路没被新代码破坏。我最早做第一版系统时,打印乱码的坑就是发版后没跑链路测试、到食堂现场才发现的。后来所有涉及打印的改动,都在测试环境先打一张测试小票再发布,下单、出票、取餐这条链路每个版本都完整走一遍,再没有让现场的人替我发现过问题。希望帮到你。
本文还有配套的精品资源,点击获取