前阵子帮朋友打理一家社区快餐店,发现每天中午高峰期点餐收银乱成一锅粥:顾客排队到门口,收银员一边听口头点单一边按计算器,后厨出餐全凭吼。朋友随口问了一句“能不能搞个小程序扫码点餐”,于是就有了这个代号 m080 的快餐店点餐服务系统。整套系统从需求梳理到上线跑通用了不到三周,覆盖顾客扫码点餐、后厨大屏展示、收银台核销、经营报表四个核心场景。这篇文章把整个设计与实现过程完整拆开讲,包括技术选型背后的理由、数据库和状态机的设计、接口实现的难点、前端交互的坑,以及上线前必须检查的细节。如果你正准备给中小餐饮店做一套类似的点餐系统,或者想了解一个完整业务系统从 0 到 1 怎么落地,这篇应该能帮你少走不少弯路。
1. 项目概述与需求拆解
1.1 快餐店点餐的核心痛点
做系统前我在这家店蹲了两天,把真实流程摸了一遍。店铺面积不大,约 80 平米,就一张收银台、一个出餐口、后厨三个灶台。高峰期集中在 11:30 到 13:00,大约 120 到 150 单。原来的流程是顾客到收银台看灯箱菜单,口头点单,收银员在收银机上选品、收款、打小票,顾客拿着小票等叫号。问题非常明确:
- 点餐环节是串行的,所有顾客都堵在收银台,后厨却常常有空闲。
- 没有完整的菜品图,顾客对“红烧牛腩饭”和“咖喱鸡排饭”只能靠名字猜,容易点错。
- 收银员需要记住所有菜品编码和价格,新人培训成本高,高峰期容易按错。
- 订单数据不完整,每天卖了多少、哪个菜卖得好、损耗多少,基本靠月底手工翻小票。
这些痛点决定了系统不能只做一个“替代收银的电子菜单”,而是要解决分流、数字化记录、后厨协同三件事。m080 这个代号就是当时的版本迭代编号,后续还会继续滚动发布,所以整体设计上我刻意保留了可扩展性。
1.2 功能需求与角色划分
经过和店长、收银员、后厨师傅分别聊过,最终把角色定为四类:
| 角色 | 使用终端 | 核心操作 |
|---|---|---|
| 顾客 | 手机 H5 / 微信扫码 | 浏览菜单、加购、下单、支付、查看取餐号 |
| 收银员 | 收银台 Web 管理端 | 手动下单、改价、折扣、核销订单、退款、打印小票 |
| 后厨 | 厨房大屏(KDS) | 查看新订单、标记制作完成、催菜提醒 |
| 店长/老板 | 数据看板 | 查看营业额、菜品销量、时段分析、退款记录 |
这里有个容易忽略的点:很多点餐系统只做“顾客自助下单”,但快餐店实际运营中一定有现金支付、老人不会用手机、顾客临时要求去冰少盐之类的场景。所以系统必须保留“收银员代下单”的能力,并且扣减库存、统计报表的逻辑要和自助下单完全一致。我在设计需求时把“扫码自助下单”和“收银台代下单”两条流程统一为同一个订单模型,只是来源字段不同,避免后期维护两套逻辑。
1.3 非功能需求与选型约束
快餐店的业务特点决定了几个硬性指标:
- 高峰并发:按最坏情况估算,1 分钟内可能有 30 个顾客同时加购下单,数据库写入和支付回调要做到能扛住每秒 50 TPS 以上。
- 稳定性优先:店里的网络环境一般,Wi-Fi 偶尔抖动,不能因为网络差就丢单。订单数据必须本地落库,前端要有重试机制。
- 易用性:后厨师傅平均年龄偏大,大屏界面字号要大、颜色要分明、操作要少于两步。
- 成本敏感:小店不会养专门的运维,所以部署结构越简单越好,最好一台云服务器搞定所有服务。
基于这些约束,我没有选择微服务,也没有引入消息队列和容器编排,而是采用“单体应用 + 模块化拆分”的方式,配合 Redis 做缓存和分布式锁。这个决策在下单高峰时被证明是对的:系统足够简单,出问题排查快,一台 2 核 4G 的云服务器就能稳定运行,月成本控制在几十块。
2. 系统架构与核心设计思路
2.1 整体技术选型
技术栈选型主要围绕团队熟悉度、生态成熟度、招聘难易度来决定。我最终选用 Spring Boot 2.7 + MyBatis-Plus + MySQL 8.0 + Redis + Vue 3 + Vant 4。
- 后端用 Spring Boot,因为 Java 生态对事务、支付对接、定时任务的处理最成熟,出现诡异问题的概率低。
- 前端顾客端用 Vue 3 + Vant 4,Vant 是移动端组件库,表单、弹出层、数量步进器都有现成组件,开发速度快。
- 管理后台用 Vue 3 + Element Plus,桌面上操作密度高,表格表单组件丰富。
- 数据库用 MySQL,数据量不大,单表百万以内完全不是瓶颈,但要注意索引设计和事务隔离级别。
- Redis 用来缓存菜单列表、购物车临时数据、用户登录态,以及生成全局唯一的取餐号,避免数据库自增主键暴露订单量。
整个系统部署在一台阿里云 2 核 4G 的 ECS 上,使用 Docker Compose 编排 Nginx、Spring Boot 应用、MySQL、Redis 四个容器。Nginx 承担静态资源托管和 API 反向代理,同时配置了 HTTP/2 和 Gzip,减少移动端加载时间。
2.2 前后端交互流程设计
顾客扫码后打开的是 H5 点餐页,整个链路设计如下:
- 顾客扫描桌台二维码,URL 携带 shopId 和 tableId,例如
https://order.example.com/h5?shopId=1&tableId=8。 - 前端加载时先请求后端
/api/menu/list?categoryId=all获取菜单,同时请求/api/cart/get获取该桌位 Redis 中缓存的购物车。 - 顾客加购、修改数量时,前端更新本地 Store,并异步调用
/api/cart/update把整个购物车快照同步到 Redis,这样换手机扫码也能恢复购物车。 - 提交订单时,前端把购物车明细、备注、就餐人数、桌位号一并提交给后端
/api/order/submit,后端开启事务,校验库存、生成订单、计算金额、清空购物车。 - 后端返回订单号和预支付参数,前端拉起微信支付或者支付宝支付。支付成功后后端收到回调,更新订单状态为“已支付”,并推送给后厨大屏。
- 后厨大屏通过 WebSocket 监听新订单事件,显示待制作条目。
这里为了让点餐链路不至于被支付卡住,我采用“先下单后支付”的模式:用户点击“去结算”成功后订单已生成,状态为“待支付”,可以保留 15 分钟。超过 15 分钟未支付自动取消,释放菜品库存。这样即使用户支付过程中断,也不会产生超卖问题。
2.3 关键业务模块划分
从后端代码结构上划分为五个模块:
controller:暴露 REST API,包括菜单、购物车、订单、支付、管理端等。service:业务逻辑,包括订单状态流转、支付回调处理、库存增减、报表聚合。mapper:数据访问层,使用 MyBatis-Plus 的 BaseMapper 以及自定义 SQL。mq:这里没有用专门的消息队列,而是借助 Redis 的 Pub/Sub 和 WebSocket 实现事件通知,后续如果量上来再替换成 RocketMQ。task:定时任务,包括取消超时订单、每日凌晨汇总报表、清理过期购物车缓存。
前端顾客端、管理后台、厨房大屏是三个独立工程,共享后端的 API 文档(使用 Knife4j 生成 Swagger)。厨房大屏不依赖复杂的 UI 框架,直接用 Vue 3 + CSS Grid 做成 1920x1080 的展示页面,配置在电视机顶盒上自动打开浏览器全屏运行。
3. 数据库设计与核心表单
3.1 实体关系梳理
餐饮系统的实体不算多,核心围绕着“菜单—订单—支付”这几个概念展开。我一开始画了五张主表:shop(门店)、category(菜品分类)、dish(菜品)、sku(规格,比如大份/小份、加冰/去冰)、orders(订单)、order_item(订单明细)。再加三张辅助表:cart(购物车缓存,但可以只存 Redis)、payment_log(支付流水)、stock_log(库存流水)。
门店、分类、菜品之间的关系是一对多,菜品和规格是一对多。快餐店场景下规格比较简化,比如饮品的大杯中杯、米饭的大份小份,不涉及像服装那样的多层 SKU。设计sku表是为了后续扩展套餐和加料,表结构如下:
CREATE TABLE `sku` ( `id` bigint NOT NULL AUTO_INCREMENT, `dish_id` bigint NOT NULL COMMENT '菜品ID', `name` varchar(50) NOT NULL COMMENT '规格名称,如大份/小份', `price` decimal(10,2) NOT NULL COMMENT '售价', `stock` int NOT NULL DEFAULT '0' COMMENT '当前库存', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1上架 0下架', `sort` int NOT NULL DEFAULT '0', PRIMARY KEY (`id`), KEY `idx_dish_id` (`dish_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜品规格表';3.2 关键表结构设计
订单表是整个系统的核心,字段设计时我特别注意区分“订单业务状态”和“支付状态”。早期很多开发会把这两个状态混在一个字段里,导致后续退款、售后逻辑擦屁股很痛苦。我拆成两个字段:
CREATE TABLE `orders` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '业务单号,如M080202501031200001', `shop_id` bigint NOT NULL, `table_id` bigint DEFAULT NULL, `customer_name` varchar(50) DEFAULT NULL, `total_amount` decimal(10,2) NOT NULL, `discount_amount` decimal(10,2) NOT NULL DEFAULT '0.00', `pay_amount` decimal(10,2) NOT NULL, `status` tinyint NOT NULL DEFAULT '0' COMMENT '0待支付 1已支付 2制作中 3制作完成 4待取餐 5已完成 6已取消 7已退款', `pay_status` tinyint NOT NULL DEFAULT '0' COMMENT '0未支付 1已支付 2已退款 3部分退款', `pay_type` tinyint DEFAULT NULL COMMENT '1微信 2支付宝 3现金', `pay_time` datetime DEFAULT NULL, `source` tinyint NOT NULL DEFAULT '1' COMMENT '1扫码自助 2收银台代下单', `remark` varchar(200) DEFAULT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_shop_status` (`shop_id`, `status`, `create_time`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';订单明细表order_item记录每个菜品的快照字段,包括菜品名称、规格、单价、数量、做法备注。这里一定要做快照,不能只存dish_id,因为菜品价格和名称未来会修改,历史订单必须保留当时的信息。
库存设计值得一提。快餐店的库存不是实时扣毛料,而是按“可售数量”控制。比如每天准备 50 份卤鸡腿,顾客下单就扣减一份,后厨出餐后发现实际报废了 2 份,再通过后台的盘点功能修正库存。因此我建了stock_log表,每次扣减都记录原因(下单、取消、手工调整),方便实时对账。
3.3 订单状态机设计
订单状态是最容易出边界 bug 的地方。我画了严格的状态机,并且在后端 Service 层用状态机校验,而不是让人在 Service 里随意order.setStatus()。核心流转如下:
- 待支付:创建订单后初始状态。用户可取消、可支付,超过 15 分钟系统自动取消。
- 已支付:支付回调成功后进入,同时需要通知后厨。
- 制作中:后厨在 KDS 点击“开始制作”后进入。
- 制作完成:后厨点击“出餐”后进入,此时顾客端显示“请取餐”。
- 已完成:收银员核销取餐码后进入,也可以由系统在出餐后 30 分钟自动完成。
- 已取消:用户主动取消、超时取消或后台取消。
- 已退款:支付成功后发生整单退款时进入。
我用一个OrderStateMachine类集中管理,任何状态变更前先做合法性校验。例如“已支付”不能直接变成“已取消”,必须经过“退款”流程。这个设计在后来的运营中帮了大忙,因为退款请求时有发生,如果状态随意跳转,财务对账会乱成一团。
4. 后端核心模块实现
4.1 扫码点餐与购物车实现逻辑
扫码点餐的第一步是解析二维码参数。店铺的每个桌位码固定生成,格式为?shopId=1&tableId=3,二维码贴在桌上。用户扫出来的是 H5 页面地址,Nginx 直接返回静态资源,页面再根据 URL 参数请求后端接口。这里有个细节:二维码要使用短链接或者固定地址,不要包含特殊字符,否则部分微信版本打开会截断参数。
购物车设计我采用了 Redis 缓存,键名是cart:{shopId}:{tableId},值为用户最近一次提交的购物车 JSON 快照。为什么不用 MySQL?因为购物车是临时数据,用户可能加菜后没下单就走了,表里的数据就变成垃圾。Redis 可以设置过期时间,比如 24 小时自动清理。唯一要注意的是并发问题:同一桌位多个人同时加菜,后写的覆盖先写的。我采用“读取—合并—写回”的策略,在更新接口中先用 Lua 脚本做原子操作,避免两个请求同时读旧值。
核心加购逻辑封装在CartService:
public synchronized Cart addCartItem(AddCartRequest request) { String key = buildCartKey(request.getShopId(), request.getTableId()); Cart cart = getCartFromRedis(key); Optional<CartItem> exist = cart.getItems().stream() .filter(i -> i.getSkuId().equals(request.getSkuId())) .findFirst(); if (exist.isPresent()) { exist.get().setQuantity(exist.get().getQuantity() + request.getQuantity()); } else { CartItem item = new CartItem(); item.setSkuId(request.getSkuId()); item.setDishId(request.getDishId()); item.setDishName(request.getDishName()); item.setSpecName(request.getSpecName()); item.setPrice(request.getPrice()); item.setQuantity(request.getQuantity()); cart.getItems().add(item); } redisTemplate.opsForValue().set(key, JSON.toJSONString(cart), 24, TimeUnit.HOURS); return cart; }方法上加了synchronized,虽然多实例部署时不那么优雅,但对单实例部署的现状来说最简单可靠。如果后续水平扩展,再改成 Redis 分布式锁也不迟。
4.2 订单提交与支付流程
订单提交是整个系统最需要小心的环节,涉及事务、库存、金额计算、幂等。我执行的顺序是:
- 前端传递购物车快照,后端根据 skuId 重新从数据库查询最新价格,不能相信前端传的价格。
- 校验库存:用
UPDATE sku SET stock = stock - #{quantity} WHERE id = #{skuId} AND stock >= #{quantity}语句原子扣减,如果影响行数为 0 说明库存不足,抛出业务异常。 - 生成订单号,规则是
M080 + yyyyMMddHHmmss + 4位自增序号,自增序号由 RedisINCR生成,保证同一秒内不重复。 - 计算总金额、优惠金额、实付金额,如果是现金支付则直接置为已支付;在线支付则生成支付单,返回给前端。
- 在同一个事务中插入订单主表和明细表,并且把扣减库存和记录
stock_log放在同一事务里。
伪代码片段如下:
@Transactional(rollbackFor = Exception.class) public OrderSubmitResult submit(OrderSubmitRequest req) { // 1. 校验并查库获取最新价格 List<Sku> skuList = skuMapper.selectBatchIds(req.getItemSkuIds()); // 2. 原子扣库存 for (OrderItemRequest item : req.getItems()) { int rows = skuMapper.deductStock(item.getSkuId(), item.getQuantity()); if (rows == 0) { throw new BizException("菜品[" + item.getDishName() + "]库存不足"); } } // 3. 生成订单号 String orderNo = generateOrderNo(); // 4. 组装订单和明细 Orders order = buildOrder(req, orderNo); orderMapper.insert(order); // 5. 插入明细 orderItemMapper.batchInsert(order.getId(), req.getItems()); // 6. 记录库存流水 stockLogMapper.batchInsert(buildStockLogs(req)); return new OrderSubmitResult(orderNo, order.getPayAmount()); }支付回调处理也要考虑幂等。微信或支付宝回调可能会发送多次,我用了payment_log表来记录回调流水号,每次 callback 先查询流水是否已存在,存在则直接返回成功。更新订单状态时采用UPDATE orders SET status = 1, pay_status = 1, pay_time = NOW() WHERE order_no = ? AND status = 0,保证只有待支付状态才能被更新为已支付,防止并发重复处理。
4.3 厨房KDS展示与状态同步
后厨大屏是容易被低估的模块,但实际使用频率最高。我采用 WebSocket 实现后端到前端的实时推送。Spring Boot 中配置一个TextWebSocketHandler,前端在页面加载时通过ws://host/ws/kitchen建立连接。后端在订单支付成功后调用SendMessage推送一条NEW_ORDER事件,内容为订单号和菜品明细。
大屏页面分三列展示:待制作、制作中、已完成。每个菜品卡片比较大,正常坐在三米外能看清。后厨师傅点击“开始制作”后,前端发送请求到/api/kitchen/start,后端更新状态并广播给所有连接的大屏端,让其他屏也能同步刷新。为了应对断网,前端每 30 秒轮询一次/api/kitchen/pending作为兜底,发现 WebSocket 断开就自动重连。
这里有一个需要注意的体验问题:同一个订单有多个菜品,不能要求后厨一次性全部做完。比如一个订单包含煲仔饭和饮料,煲仔饭要 8 分钟,饮料是现成的。所以 KDS 支持按菜品维度操作,而不是按订单维度。我专门建了一张order_item_status字段存在order_item表中,后厨可以单独把某个菜品标记为“已完成”,只有全部菜品完成后,订单才允许进入“待取餐”状态。
4.4 报表统计实现
店长最关心的几个数据是:今日营业额、订单量、客单价、TOP10 菜品、分类占比、时段分布、退款金额。这些数据如果直接实时查大表,高峰期会拖慢主库,我采用“定时汇总 + 实时查询”结合的方式。
每天凌晨 2 点定时任务会扫描前一天的订单表,把聚合数据写入daily_report表。报表接口优先查daily_report;当天的实时数据则用缓存存储,每 5 分钟通过聚合 SQL 刷新一次到 Redis。这样大屏上的“今日实时营业额”最多延迟 5 分钟,对于快餐店老板来说完全够用。
一段核心统计 SQL 示例:
SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS day, COUNT(*) AS order_count, IFNULL(SUM(pay_amount), 0) AS total_amount, IFNULL(SUM(CASE WHEN source = 1 THEN 1 ELSE 0 END), 0) AS scan_order_count, ROUND(IFNULL(AVG(pay_amount), 0), 2) AS avg_price FROM orders WHERE pay_status = 1 AND status IN (1, 2, 3, 4, 5) AND create_time BETWEEN #{startTime} AND #{endTime} GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d') ORDER BY day DESC注意统计时剔除退款订单,只算pay_status = 1且status处于有效范围,避免把已退款的数据计入营业额。
5. 前端与交互落地
5.1 顾客端H5页面设计
顾客端 H5 的原则是“三秒内能找到想点的菜”。首页采用顶部分类横向滚动、下方菜品列表纵向滚动的经典布局,每个菜品卡片显示图片、名称、价格和加号按钮。底部是固定的购物车栏,显示已选数量和总价,点击弹出购物车明细侧边栏。
菜单数据从后端接口获取后,前端做了本地缓存,设置 10 分钟过期。这样用户从菜单页到购物车页往返时不会重复加载。但要注意,缓存不能影响库存展示,如果某个菜品库存为 0,后端接口会返回soldOut标识,前端立即把卡片置灰并显示“售罄”。
购物车交互我踩过一个坑:Vant 的Stepper组件在数量为 0 时仍然会显示减号按钮,点击后数量变成 -1。后来我监听change事件,当数量小于等于 0 时,弹窗确认是否删除该商品。如果直接删除,用户误触就很烦。最终做法是:数量减到 0 时不仅删除该项,还同步调后端更新接口,避免本地和 Redis 缓存不一致。
H5 路由用的vue-router,采用 history 模式,Nginx 配置了try_files回退到index.html。支付成功后跳转到“订单详情页”,页面展示取餐号和一个大的进度提示:待支付、支付成功、制作中、请取餐。
5.2 管理后台设计
管理后台面向收银员和店长,左侧菜单包括工作台、点餐收银、订单管理、菜品管理、分类管理、桌台管理、优惠券、数据报表和系统设置。
收银台页面设计成类似 POS 的布局:左侧是分类和菜品,点击加购;右侧是当前订单列表和结算按钮。收银员可以手动选择支付方式(现金/微信/支付宝),现金支付时直接点击“收款”即可。如果顾客已经在手机上自助下单但还没支付,收银台也能看到该桌订单,可以选择“协助支付”或“取消订单”。
订单管理页需要支持按订单号、订单状态、手机号、时间段筛选。列表默认显示最近 7 天的数据,超过 7 天默认从orders_history归档表查。这个归档策略是上线第二周加的,因为发现订单表增长比预想快,按天分区的 MySQL 表查询也变慢,后来做了按月归档。
菜品管理中最重要的是图片上传,我建议直接用阿里云 OSS 或腾讯云 COS 存储,不要存在本地服务器。菜品图片会频繁访问,如果放在应用服务器,磁盘网络 IO 会成为瓶颈。我使用前端直传 OSS 的方式,后端只生成一个上传凭证,这样能减轻带宽压力。
5.3 接口联调与权限控制
前后端分离开发时,接口文档一定要提前定好。我先用 Knife4j 生成 Swagger 文档,前端拿到文档后 mock 数据开发,进度可以并行。联调阶段最容易出问题的三个点:时间格式、金额精度、空值处理。
时间格式统一使用yyyy-MM-dd HH:mm:ss,后端的 LocalDateTime 通过 Jackson 配置全局格式化。金额一律用BigDecimal以“元”为单位传递,前端展示时保留两位小数,后端计算时杜绝浮点运算。空值方面,前端需要兼容字段为null的情况,比如备注没有填写时接口返回null,如果直接渲染会显示undefined。我在 axios 响应拦截器里做了统一清洗,把空字符串转成--。
权限控制采用 Sa-Token 框架,比 Shiro 轻量,和 Spring Boot 集成也简单。顾客端接口通过@SaIgnore注解放行部分路径,管理端接口通过拦截器校验 token,并按角色做二级校验。收银员只能操作订单和点餐,店长才可以看到报表和系统设置。密码存储使用 BCrypt 加密,不允许明文。
6. 部署与上线检查清单
6.1 环境部署
部署上我用了 Docker Compose 编排,一个docker-compose.yml文件拉起全部服务。Dockerfile中后端使用多阶段构建,基础镜像选alpine以减少体积。前端构建后的dist目录直接挂载到 Nginx 容器的静态目录中。
Nginx 关键配置节选:
server { listen 443 ssl http2; server_name order.example.com; ssl_certificate /etc/nginx/ssl/example.pem; ssl_certificate_key /etc/nginx/ssl/example.key; gzip on; gzip_types text/plain text/css application/json application/javascript; location / { root /usr/share/nginx/html/h5; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://backend:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }上线时要注意 HTTPS 证书的自动续期,我直接使用了 certbot 的 docker 镜像定期刷新,避免证书过期导致页面打不开。服务器防火墙只开放 80、443、22 端口,后端服务的 8080 端口不直接暴露,MySQL 和 Redis 也不暴露公网,减少安全风险。
6.2 上线前测试要点
功能测试大家都会做,但有几个场景特别容易漏:
- 支付成功回调延迟:用户已经在收银台用现金支付了,但微信支付回调后到,需要保证订单不会从“已完成”变成“已支付”状态。
- 超时未支付自动取消与用户同时点击支付:两个请求并发时,必须保证只能有一个成功。我在取消订单的方法上加了一个
SELECT ... FOR UPDATE行锁,先查订单状态,如果是待支付才执行取消,否则放弃。 - 菜品库存临界值:下单时库存剩 1 份,两个用户同时下单,最终只会成功一个,扣库存 SQL 的原子性要专门压测。
- 后厨大屏断网重连:模拟关闭网络 30 秒再打开,观察 WebSocket 心跳重连后是否能把断线期间的订单补拉回来。
这些场景需要写自动化测试脚本,我用 JUnit 5 的并发测试工具模拟多线程请求,配合 MySQL 的事务回滚,确保不依赖脏数据。一旦发现并发问题,优先排查锁范围和事务边界。
6.3 常见问题与排查实录
上线两周整理了一份问题清单,列几个最有代表性的:
| 现象 | 原因 | 解决方式 |
|---|---|---|
| 顾客提交订单后页面一直转圈 | 后端接口响应超时,可能是 Redis 连接池满 | 检查 Redis 最大连接数,调整maxTotal为 200,并设置合理等待时间 |
| 后厨大屏偶尔闪退 | 电视盒子内存不足,WebSocket 断开后重连创建新实例未清理 | 前端监听onbeforeunload主动断开;电视盒子定时重启 |
| 优惠金额计算错误 | 使用了浮点型直接相减 | 统一改用BigDecimal,并保留两位小数 |
| 扫描桌码进入后长时间空白 | 二维码参数被微信转义成& | Nginx 层做一次参数解码,或者在生成二维码时使用短链服务 |
| 退款后库存没有恢复 | 退款逻辑里只改了订单状态,没有调库存回补方法 | 在退款事务中统一调用restoreStock(orderId) |
其中退款回补库存这个 bug 最隐蔽,因为测试时往往只退一个完整订单,但实际运营中会出现“部分退款”。我后来在payment_log和stock_log之间建立了关联,每次部分退款都会在stock_log里记录负数流水,方便追踪。
还有一个小技巧:所有对外返回的异常不要直接抛出系统异常堆栈,需要统一包装成{code: 500, message: "库存不足"}这样的结构。后端可以建一个全局异常处理器,把BizException的 message 透传给前端,前端弹 toast 提示。这样既不泄露数据库信息,也能让顾客第一时间知道失败原因。
我个人在实际操作中最深的体会是:餐饮系统的难点从来不是技术框架,而是对业务细节的把控。点餐、支付、后厨协同、库存、报表,每个环节都有大量边界情况,如果前期没有深入门店观察流程,匆匆忙忙写代码,上线后一定会在高峰期暴露问题。这套 m080 系统上线后,朋友店的排队时长从平均 12 分钟降到了 5 分钟以内,后厨师傅说大屏比吼叫清楚多了,老板也能每天看报表做采购决策。如果你也在做类似的系统,建议先花两天泡在店里,把每个角色的真实动作记录下来,再动手写第一行代码。