做了这么多年餐饮SaaS,说实话扫码点餐这个需求我之前一直没觉得有什么技术含量,直到接了那个海外华人餐厅的单子,才发现"国际版"三个字背后藏着一整座冰山。很多人以为国际版扫码点餐就是把按钮翻译成英文、菜单改成双语,真正动手做起来才知道,从订单状态机的设计、菜品的多语言与多规格模型,到多币种金额处理、多时区的时间归因,再到跨境支付回调的幂等防重,每一层都是坑。这篇文章把我用Java从零实现国际版扫码点餐系统的完整过程、技术选型逻辑、核心模块设计以及踩过的坑一次说透,适合正在做餐饮系统、或准备把手里的本地点餐项目推向海外市场的Java开发者参考。
1. 扫码点餐不是简单"扫码+下单":先理清国际版的真实需求
1.1 从用户动线拆解核心流程
我接这个项目时,需求文档上写的是"做一套和国内版差不多的扫码点餐,支持英文和中文就行"。拿到这种需求,千万别急着写代码。我习惯第一步把用户动线完整画出来:顾客进店坐下,看到桌角的二维码,掏出手机扫码,进入餐厅的菜单页,选菜加购,提交订单,后厨出餐,服务员上菜,顾客吃完后买单。
这七步看着简单,每一步放到国际版场景里都有变数。扫码之后手机浏览器打开的是H5,不是小程序,因为海外用户不装微信;菜单加载要考虑CDN分发和多语言切换;提交订单要处理不同国家的支付网关;后厨出餐要对接KDS(厨房显示系统)或者热敏打印机;买单环节,有的国家习惯桌边刷卡,有的习惯扫码支付,有的直接去收银台。所以做国际版扫码点餐,核心是先把业务动作抽象成一套与"国家/地区"解耦的流程引擎,再把语言、货币、支付、打印格式做成可插拔的配置。
1.2 国际版和国内版的本质差异
这里我得说一个很多人忽略的点:国际版不是国内版的翻译版,而是另一套产品逻辑。国内版的扫码点餐,微信支付和支付宝基本覆盖了90%的场景,支付回调、退款、对账都围绕这两家来做。海外不同国家的支付方式极度碎片化:北美流行信用卡和Apple Pay,欧洲很多国家用iDEAL、Klarna,东南亚用GrabPay、当地电子钱包,拉美则大量依赖现金和Pix(巴西)。
支付只是差异之一。数据库里的时间,国内一个时区搞定,国际版要面对全球几十个时区;货币单位不同,日元的金额是0位小数,科威特第纳尔是3位小数,如果数据库里金额字段用double存,早晚出事;还有菜单的多语言,英文描述通常比中文长一倍,UI空间和打印机的小票格式全要跟着调;再往深处说,有的国家对食品过敏原有强制标注要求,有的国家对数字隐私有特定法规,这些都要在系统里预留字段和逻辑。
1.3 我的技术倾向与项目边界
我最终采用的方案是:Spring Boot 3.x单体架构起步,按模块划分边界,预留接口拆分微服务。理由后面详细说。项目整体分四个端:顾客扫码后的H5点餐端、服务员用的管理端、后厨展示/打印端、商家后台运营端,外加一个定时任务模块处理订单超时、库存释放、对账单生成。技术栈以Java 17为主,Spring MVC做REST API,MyBatis-Plus做数据持久化,Redis做缓存和分布式锁,RabbitMQ做订单事件异步通知,MySQL 8.0存核心业务数据,MongoDB存菜单的多语言副本和操作日志。这个组合不算新潮,但胜在稳定、社区资料多、出问题好排查,餐饮系统不是炫技的地方。
2. Java技术栈选型:从单体到微服务的取舍
2.1 为什么主力语言还是Java
这个项目不是从零选语言,但我也认真对比过Go、Node.js和Java的优劣势。餐饮点餐系统的特点是:业务逻辑复杂(订单状态机、库存、优惠、支付对账)、并发有峰值但整体可控(午晚餐高峰)、团队后续要长期维护迭代。Java在这三个维度上依然是综合分最高的。它的生态里有成熟的状态机框架、分布式事务方案、丰富的支付SDK对接案例,而且Java开发者在全球范围内都好招。Go在处理高并发连接上有优势,但业务模型复杂之后,开发效率反而不如Java加上一套好用的框架。
具体的方案上,我没有引入Spring Cloud那套全家桶,而是先把所有功能做在一个可水平扩展的应用里。原因很直接:这个项目的首版核心目标是快速跑通流程、验证需求,单体架构在业务迭代初期的部署和调试成本远低于微服务。等订单量上来、团队扩充到多组并行开发时,再按"点餐服务、订单服务、支付服务、基础数据服务"四个域拆也不迟,模块边界我已经在代码层面用Package分好了。
2.2 核心框架的版本与搭配细节
Spring Boot 3.x要求Java 17起步,这正好让我避开了老项目中一堆基于javax包的兼容问题。这里要说个实操经验:很多老程序员拿到Spring Boot 3.x项目,把别人的配置一拷,结果启动报错,十有八九是依赖包引入了旧版javax.servlet、javax.annotation。我在项目里用了一个笨办法保证干净:统一用Maven BOM管理依赖版本,pom.xml里显式排除所有javax开头的传递依赖,核心依赖列表如下。
| 组件 | 版本 | 用途说明 |
|---|---|---|
| Spring Boot | 3.2.x | Web、Validation、Actuator |
| MyBatis-Plus | 3.5.x | ORM与分页插件 |
| Redis客户端 | Lettuce | 缓存与分布式锁 |
| RabbitMQ | 3.12.x | 订单事件异步解耦 |
| ZXing | 3.5.x | 二维码生成 |
| Quartz | 2.3.x | 超时订单扫描与对账定时任务 |
数据库连接池我用了HikariCP,Spring Boot默认集成,配置时注意maximum-pool-size不要贪大,我以前见过有人设成100,结果数据库连接数被打满,整个系统雪崩。按部署实例数平均,每个实例20个连接在小规模餐饮项目里完全够用。
2.3 从单体出发:哪些模块必须提前预留接口
虽然我说了用单体架构,但有两处接口必须提前抽象。第一是支付网关,我定义了一个PaymentGateway接口,下面先实现Stripe和PayPal两个适配器,后续要接Adyen、Klarna,只需要扩展适配器,下单流程和回调处理完全不动。第二是消息推送,推送到H5端我用了WebSocket + Spring Messaging,推送到后厨大屏用的是SSE(Server-Sent Events),这两条通道在接口层统一封装为OrderEventPublisher,业务代码只发一个OrderEvent,具体走WebSocket还是SSE由配置决定。
这里加密说一句:单体架构下代码粒度不是越大越好,模块之间的依赖方向要在Package结构上卡死。我按"接口层→应用服务层→领域服务层→基础设施层"的依赖规则去做架构评审,谁违反谁重构,这样后续拆微服务时,只需要把不同Package拎出来部署并补上Feign调用即可,业务代码改动量很小。
3. 多语言、多币种、多时区:国际化三大硬骨头的落地解法
3.1 多语言不等同于资源文件
Spring的MessageSource做多语言简单,但放到点餐场景里就复杂了:一个菜品的名称、描述、过敏原提示、口味说明都是多语言内容,而且品类不同语言的字符长度差异很大。英文的"Grilled Salmon with Lemon Butter Sauce"放在中文"香煎三文鱼配柠檬黄油汁"的位置,肯定要换行甚至撑破布局。所以我的方案是,菜单基础表只存菜品ID和全局唯一编码,菜品名称、描述、过敏原、自定义标签存到独立的多语言表,结构大致这样。
CREATE TABLE dish_i18n ( id BIGINT AUTO_INCREMENT PRIMARY KEY, dish_id BIGINT NOT NULL, locale VARCHAR(16) NOT NULL, name VARCHAR(200) NOT NULL, description VARCHAR(1000), allergen_info VARCHAR(500), UNIQUE KEY uk_dish_locale (dish_id, locale) );使用方通过一个I18nMenuService取菜品时,按当前请求头里的Accept-Language或者顾客选购时选择的语言偏好,去查对应语言字段。这里有一个细节:内容不能只按语言存,还要按国家/地区存,因为同样是英语,英国、美国、澳洲的菜品描述和拼写习惯是有差异的(比如"chips"和"fries"),所以locale我存的是en-US、en-GB、zh-CN这种完整格式,fallback链是en-US → en → 默认语言。
3.2 金额处理:BigDecimal是底线
国际版涉及多币种,最数据安全的点是金额精度。数据库凡是涉及金额的字段全部用DECIMAL(12, 2)或DECIMAL(12, 4),Java代码里一律用BigDecimal,严禁double和float。一套菜品在不同国家定价,涉及到汇率换算,我单独建了currency_rate表,每天定时任务从汇率服务拉取最新汇率,换算时用中间货币USD做锚点,避免A国货币直接换算B国货币时的交叉汇率误差。
国际版还有一个容易被忽视的点:不同货币的小数位数不同。JPY是0位小数,USD和EUR是2位,BHD和KWD是3位。我的Money对象里封装了currencyCode和BigDecimal amount,格式化显示时通过Currency.getDefaultFractionDigits(currencyCode)动态取小数位,前端也通过接口拿到小数位配置,避免显示"¥100.00"这种在日本场景里奇怪的精度。小票打印的金额格式也一样,不能写死。
3.3 时区与时间归因:数据库统一存UTC
一台部署在新加坡的服务器,服务的顾客可能来自全球各地。订单的创建时间、支付时间、对账时间在数据库里必须统一存UTC时间戳,展示层再按餐厅所在时区或者顾客所在时区去转换。我封装了一个TimeZoneResolver组件,从餐厅表里取该店铺的默认时区,配合H5前端通过JS获取用户本地时区,两相结合决定每个请求里的时间上下文。
这里还有一个业务归因问题:报表统计需要按"餐厅本地日期"分组,而不能按服务器日期直接按天聚合。比如一个美国洛杉矶的餐厅,UTC时间凌晨2点对应的还是当地前一天晚上7点。我的做法是在生成日报时,先从UTC时间按餐厅时区偏移到本地时间,再按本地y-m-d分组,否则每天的单量和营业额会算错边界。类似的坑,做跨时区项目的一定要提前设计。
4. 扫码点餐核心链路的设计与实现:从桌台码到订单状态机
4.1 桌台码的生成与绑定
每张桌子的二维码,我存的核心信息只有三个:餐厅ID、桌台ID、一个随机校验签名。扫码后前端拿到这些参数,请求后端换取一个绑定桌台的短期Token,后续的所有操作都带着这个Token。二维码内容我经过ZXing库生成,编码格式用的是带校验的Payload,内容设计如下。
public String generateTableCode(Long restaurantId, Long tableId) { String raw = "restaurant=" + restaurantId + "&table=" + tableId + "&expire=" + (System.currentTimeMillis() + 30 * 24 * 3600 * 1000L); String sign = HmacUtils.hmacSha256Hex(SECRET_KEY, raw); return Base64.getUrlEncoder().encodeToString((raw + "&sign=" + sign).getBytes(StandardCharsets.UTF_8)); }这里有人会问:为什么不直接用桌台ID做码?因为要防伪造。如果有人篡改二维码里的桌位参数,就可能占别人的桌台或者给后厨下恶作剧订单。签名校验能够保证二维码是系统签发的,同时过期时间避免二维码被长期滥用。扫码落地后,桌台在Redis里生成一个状态Key,标记"有人用餐",等结账后再清除。
4.2 购物车与菜品多规格的处理
顾客扫码后的点餐过程,购物车在服务端Session和本地存储之间我选择了本地存储+服务端校验的混合模式。购物车明细保存在H5前端的localStorage里,减少后端压力;提交订单时,后端一次性校验菜品库存、价格有效性、规格组合是否合法。为什么不把购物车放Redis?因为点餐场景下用户加购、改购的请求频率高,每个操作都走Redis网络I/O在弱网环境下体验会很差,本地存储则零延迟。价格和库存校验收口在服务端完成,就不会有客户端篡改价格的风险。
多规格(比如饮品选糖度、小料,牛排选熟度)我建了一张sku表,简单的规格组合用JSON字段存,复杂的组合按SKU维度拆库存。关键点在于:规格的显示文本也是多语言的,糖度的"少糖""半糖""正常"在不同的语言环境里要有对应翻译,涨价和库存扣减只能针对SKU,不能对着菜品级别操作,否则会出现不同规格共享库存导致超卖。
4.3 订单状态机的驱动力:事件驱动 + 状态流转
扫码点餐的订单状态比电商订单复杂,因为它有"已下单→已接单→制作中→已上菜→已完成"这条线下链路,中间还可能插入"催单""退菜""超时未支付自动取消"等异常操作。我用一张订单状态表记录状态,状态流转全部通过OrderStatusEvent事件驱动,用RabbitMQ异步执行后续动作,核心状态机模型如下。
| 当前状态 | 触发事件 | 目标状态 | 执行动作 |
|---|---|---|---|
| PENDING_PAYMENT | PAY_SUCCESS | RECEIVED | 后厨推送、打印订单 |
| PENDING_PAYMENT | PAY_TIMEOUT | CANCELLED | 释放桌台、释放库存 |
| RECEIVED | KITCHEN_START | PREPARING | 更新预计出餐时间 |
| PREPARING | DISH_READY | DELIVERED | 叫号通知顾客 |
| DELIVERED | SETTLE_ACCOUNT | COMPLETED | 生成对账单 |
状态流转不能散落在业务代码里,我统一收口在一个OrderStateMachine类中,每个合法转移都做前置校验,防止脏数据把订单卡在一个进退两难的节点。这里要特别提醒:支付成功的回调处理,必须保证幂等,即同一笔订单的支付成功事件重复消费不会导致状态重复流转。我在事件里引入了eventId去重表,消费前先查一条一样eventId的记录有没有处理过,处理过就直接ACK不做任何状态变更。
4.4 超时未支付的订单处理:延时消息与定时扫单
国际版的顾客支付习惯不同,有些国家的顾客下单后习惯慢慢选支付方式,支付超时时间不能写死。我设置了一个可配置的支付超时时间,默认15分钟。实现上用了三级保障:第一级,RabbitMQ的延迟消息插件在订单创建后发送一条延时消息,到点检查是否已支付;第二级,Quartz每3分钟扫描一次处于待支付、时间超过阈值的订单做兜底取消;第三级,顾客主动取消接口直接释放资源。三级保障确保了即使某个环节出问题,也不至于把桌台和库存扣住不放。
关于RabbitMQ延迟消息,有一点踩坑经验:rabbitmq_delayed_message_exchange插件是把消息存在Mnesia(Erlang的分布式数据库)里,插入和读取性能远不如普通队列,如果订单量很大,会让Broker压力剧增。所以我只在OrderCreated事件上用延迟消息,其他业务事件还是走普通交换机。后来测试,高峰期订单创建几千笔时,延迟插件所在Broker的内存和消息堆积明显偏高,后来我调整了策略,延迟消息只用于订单超时提醒,5分钟以上的超时场景改用定时扫单兜底,插件压力就降下来了。
spring: rabbitmq: listener: simple: retry: enabled: true max-attempts: 3 initial-interval: 1000订单事件消费者我开启了重试机制。这里有个学习要点:如果消费者处理消息时抛出异常,不能简单无限重试,否则会造成消息循环堆积甚至死信。配置如上,最多重试3次,仍失败进入死信队列,由定时任务查库纠正状态。消息和数据库状态是两个独立系统,最终要对账一致性,绝对不能只靠消息驱动。
5. 国际版高并发场景下的性能优化实践
5.1 菜单缓存与热点数据隔离
扫码点餐在午晚餐高峰期的特点非常明显:短时间内大量顾客同时打开菜单,菜品信息和图片请求量瞬间拉升。菜单数据是典型的读多写少,我用Redis做了两级缓存:第一级是本地Caffeine缓存,过期时间60秒,等于每个服务实例在本地留一份热点菜单;第二级是Redis缓存,过期时间5分钟,负责多实例间的数据同步和穿透兜底。请求进来先查Caffeine,不中再查Redis,再回源查MySQL。
这套机制有一个细节要注意:菜单更新(比如菜品下架、临时沽清)需要主动淘汰缓存,否则顾客看到的菜单和实际库存不一致。我在菜单更新接口里写了一个cacheService.evictMenu(restaurantId)方法,先删除Redis里的菜单缓存,再把本地Caffeine实例删除。单个实例删除本地缓存比较容易,但有多个实例时,得通过Redis Pub/Sub广播一个缓存的失效事件,让其他实例也删。不然有的用户看到的是新菜单,有的还是旧菜单,在"时差"问题下体验很糟糕。
5.2 高并发扣库存:Redis原子操作 + 数据库兜底
点餐系统和电商系统一样存在超卖风险,但餐饮的库存模型更轻,主要针对"沽清"(每日限量菜品,如例汤、特色甜点)。我用Redis的Lua脚本做库存预扣,原子性由Redis保证,脚本逻辑是:判断当前库存大于0就减一,否则返回失败。同时落数据库用乐观锁版本号做兜底,防止Redis数据丢失后重启恢复不一致。
if redis.call('exists', KEYS[1]) == 1 then local stock = tonumber(redis.call('get', KEYS[1])) if stock > 0 then redis.call('decr', KEYS[1]) return 1 end return 0 end return -1分布式环境下,我可以更进一步做订单维度的防重:同一个桌台、同一顾客、同一种菜品在30秒内的重复下单,用Redis的SETNX加一个锁来拦截,避免顾客手滑多点了几份。这个防重用后来看非常有效,至少挡掉了很大比例的重复请求。
5.3 订单通知的推送通道:WebSocket与SSE分工
国际版点餐前端是H5页面,自然优先考虑WebSocket维持长连接,但WebSocket在弱网环境里的自动重连、断线恢复处理比较麻烦。我的做法是按消息类型拆分:面向点餐顾客的状态变化通知(比如订单制作完成、可以取餐了)用SSE单向通道,服务端主动推给顾客,代码简洁,断线自动重连由浏览器原生能力处理;面向顾客扫码后的实时聊天(如果有)才用WebSocket双向通道。
这里有一个隐藏性能点:SSE和WebSocket连接都属于长连接,每个实例的连接数上限受Nginx和Tomcat配置影响。我在压测时发现,默认Nginx没有配置超时时间时,空闲的SSE连接会一直挂着,限制并发能力。后来我把proxy_read_timeout设为60秒,配合前端SSE的心跳重连逻辑,连接数和稳定性都比较理想。线程模型上,Tomcat的默认最大线程数是200,每个SSE连接会占一个线程,所以当在线顾客多的时候,不能盲目开太多SSE连接,我按餐厅ID做连接复用,一个餐厅连接一个推送通道,消息里带桌台标识进行广播。
6. 部署与运维阶段踩过的坑:多区域、支付回调与日志监控
6.1 多区域部署与数据同步
国际版自然要考虑多区域部署。我最初天真地以为部署在一个区域、全球访问就行,结果东南亚的顾客打开欧洲节点的页面时,菜单图片加载慢得让人抓狂。后来前端静态资源挂CDN,核心API部署到两个区域(一个主区域,一个灾备区域),MySQL做主从复制,Redis跨区域只读副本。这里有一个一致性取舍:跨区域同步数据有延迟,所以我把订单创建强一致留在主区域,菜单、餐厅信息这类低频变更数据通过MQ异步同步到其他区域的只读副本。
有一个小的经验:不同区域之间的数据库时区设置要保持一致(统一UTC),表结构变更要走Online DDL工具,比如pt-online-schema-change,否则在复制环境下添加字段可能锁表。我踩过一次直接ALTER TABLE加索引,大表在从库同步时延迟飙升,线上点餐查询慢得不行,从那以后全部走gh-ost或者pt-osc。
6.2 支付回调的幂等与防重
Stripe、PayPal、Adyen这些支付网关的回调机制和国内支付平台大同小异,但有两个额外痛点:回调来源IP不确定、回调可能重复送达且不保证顺序。我在所有支付回调入口做了三件事:验签(用各自的Webhook签名机制)、幂等(eventId去重表)、状态机校验(只有待支付订单才能流转到已支付)。另外支付回调接口必须返回200响应给支付网关,如果业务处理成功前就直接返回200,支付网关会认为投递成功,丢失订单;如果业务处理失败,得要返回4xx来触发网关重试,但要小心重试风暴,所以重试次数和间隔要在网关后台配置好。
我在开发时遇到过一个问题:Stripe回调顺序不是严格按时间排序的,比如payment_intent.succeeded事件先到,charge.refunded事件后到,两个事件处理同一个订单,处理逻辑必须都是幂等的,否则就会把已支付的订单又标记成已退款,中间还经历了状态机拦截。后来单独写了一个callback_audit表,把每个事件的原始Payload、处理结果、耗时都记录下来,方便对账和排障。
6.3 日志与监控的国际化陷阱
多语言环境下日志里不能出现乱码,这是基本要求。我所有应用日志文件统一UTF-8编码,数据库连接串加了characterEncoding=utf8,RabbitMQ消息体强制JSON字符串UTF-8编码。以前我遇到过一个问题:中文菜单名写入消息队列后,消费者拿到的字符串末尾多了一个问号,排查半天发现是生产者用的字符集是平台默认字符集(在Linux下是UTF-8,在Windows下是GBK),后来强制在消息发送时指定StandardCharsets.UTF_8,再没出现过问题。
监控方面,我用了Spring Boot Actuator暴露指标(健康、线程、内存、缓存命中率、数据库连接池水位),配合Prometheus抓取、Grafana展示。关键报警围绕三点:订单支付成功率突降、支付回调积压数超过阈值、订单表数据量接近分表阈值。餐饮高峰期一过,凌晨定时对账如果发现异常,触发告警到值班群。这几种监控就够用了,没必要一开始就上全链路追踪,先把核心链路盯住。
6.4 索引设计与慢查询治理:一段实际优化实录
系统上线运营两个月后,发现顾客查看历史订单的接口越来越慢,慢查询日志显示一条SQL扫了60多万行。这条SQL的问题是一个多月前埋下的:WHERE restaurant_id = #{restaurantId} ORDER BY created_at DESC LIMIT 20,当时没建联合索引,结果随着订单表数据量增长,排序和过滤都全表扫描了。我加了一个联合索引(restaurant_id, created_at DESC)以后,接口耗时从1.8秒降到40毫秒。这个案例想说的是,索引设计一定要配合实际查询模式来,列表页的筛选字段、排序字段,都应该通过联合索引覆盖,不要等到慢查询报警才去补救。
再补充一条分页的坑:如果订单量特别大,用LIMIT 10000, 20这种深分页性能很差,可以用游标分页代替。也就是上一页最后一条订单的时间戳作为下一页的查询条件,避免扫大量无关行。这个优化对移动端下拉加载历史订单特别有效。
最终这套系统上线稳定运行了大半年。从一台最小配置的云主机起步,到高峰期扛过了一天几万的订单量。整体看下来,国际版扫码点餐的难点不在单点技术,而在业务理解和抽象能力。
我在实际开发中的最大体会是:做国际化项目,第一个版本就要把语言、时区、币种当成一等公民来设计,千万不要在中后期打补丁。刚开始多花一周时间把基础模型做扎实,后面整体能省出一个月的返工时间。还有一点,如果你也是自己做整个系统,一定要留出足够的联调时间,国际版的三方对接(支付网关、CDN、消息推送、汇率API)比国内版本多得多,联调周期至少按国内版的1.5倍估。扫码点餐的脚手架代码不难找,但每一家餐厅的菜单结构、后厨动线和支付习惯都不同,真正值钱的不是那几行Java代码,而是对业务的深入理解。希望这篇文章能帮你少踩几个坑,把项目顺利做上线。