ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+小程序餐厅预约系统:防超卖与并发设计实践

SpringBoot+小程序餐厅预约系统:防超卖与并发设计实践 1. 先聊聊这个系统的背景与选型思路做餐厅预约系统听上去是个挺经典的业务场景但真正动手去做坑比想象中多。尤其是当业务方拿着需求过来说“我要一个微信小程序用户能看餐厅、能选时间、能预约座位最好还能直接看到哪个时间段满了”如果你第一反应是“这不就是个CRUD吗”那后面大概率要被现实教育。我接到这个项目时需求已经比较明确用户端是微信小程序管理端用Web后台服务端统一走SpringBoot。这里先说一个核心判断——为什么小程序端要用微信生态而不是纯粹做一个H5页面原因很直接微信小程序的天然流量入口、免登录体验微信授权、以及用户习惯基本不需要培养。对于餐厅这种高频、低决策成本的消费场景让用户打开微信扫码直接预约比下载App或者记住一个网址要顺畅得多。后端选SpringBoot的理由就更不用多说了它本身已经是Java生态里做业务系统最成熟、最省心的框架。自动配置让项目能快速跑起来Starter体系让第三方集成变得非常轻配合MyBatis-Plus或者JPA做数据访问开发效率比早年用SSH那套高出一大截。而且SpringBoot对部署也十分友好一个jar包就能跑配合Docker也很轻松。这个系统的核心链路并不复杂用户在小程序里浏览餐厅列表、查看餐厅详情、选择就餐时间与桌型、提交预约、商家在后台处理预约结果。但越看似简单的链路越要在细节上下足功夫比如预约时间段的冲突处理、桌位容量的并发控制、微信登录态的维护、消息触达方式的选择以及小程序端常见的“页面列表加载”和“顶部导航栏适配”等细节问题。先说整体结论这套系统拆开看前端不是一个巨型小程序后端也不是高并发分布式架构但它覆盖了一个完整的业务闭环。做一遍下来你会把SpringBoot、MyBatis-Plus、微信登录授权、Redis缓存与分布式锁、以及小程序原生开发的一套完整流程全部摸一遍非常值得。2. 系统整体设计与功能架构拆解2.1 核心角色与业务链路餐厅预约系统天然有两个端两个角色。用户端是C端消费者他关心的是“附近有什么餐厅”“什么时间段还有位置”“怎么预约最快”后台是B端运营者他关心的是“今天有多少预约”“哪些时段满座了”“如何防止恶意占座”。所以系统的设计要围绕两条主线展开用户端微信小程序餐厅搜索与列表、餐厅详情、预约下单、预约记录、取消预约、消息通知。管理端Web后台餐厅信息管理、桌位/桌型管理、预约订单管理、预约时段配置、统计报表。两条线共享一个后端服务也就是典型的SpringBoot单体应用。之所以不拆微服务是因为这个业务规模根本没到那个程度。微服务是应对复杂度和扩展性的手段不是用来炫技的。一个餐厅预约系统拆出五六个服务只会让开发和部署都变得更痛苦。数据层面上核心表其实不多用户表、餐厅表、桌型/桌位表、预约时段配置表、预约订单表。后面我会把表结构的关键字段和设计要点展开讲。2.2 为什么选原生小程序而不是Uniapp现在很多人一提到小程序第一反应就是用Uniapp理由是“一套代码多端复用”。这个说法对不对对但不全对。如果你未来要同时做App、H5、小程序多个端Uniapp确实省成本。但如果你只做微信小程序而且项目里有不少微信特有的能力比如微信登录、订阅消息、定位我建议直接上原生小程序开发。原因有三条第一原生小程序的性能和调试体验更稳。微信开发者工具对原生代码的编译、报错信息、热更新支持都是最优的。虽然Uniapp也能调试但偶尔出现的黑盒问题排查起来很头疼。第二原生小程序的组件和API调用最直接。比如获取用户手机号、调用订阅消息、处理分享回调这些微信特色的东西原生写起来都是直接调API而Uniapp封装之后偶尔会有点小坑。第三原生小程序的包体积更好控制。微信要求主包不超过2MB如果你用Uniapp光框架运行时就会吃掉一部分体积一不小心就超出限制。我在做这个项目的过程中就看到过一个实际案例Uniapp打包后source size达到2612kb直接超过2MB限制。这个坑一旦踩到要么靠分包要么靠压缩代码来回折腾很耗时间。2.3 技术栈全景一览这里把整套系统的技术选型列出来方便你对照参考后端SpringBoot 2.7.x、MyBatis-Plus、Spring Validation、Redis缓存分布式锁、JWT无状态登录、Hutool工具包。前端小程序原生JavaScript WXML WXSS使用WeChat开发者工具调试。管理后台Vue3 Element Plus通过接口与后端交互非本系统核心但有必要提一嘴。数据库MySQL 8.0存储核心业务数据。部署Docker Nginx后端jar包镜像运行前端静态资源通过Nginx托管。这里注意一下SpringBoot的版本选择。我见过不少项目因为SpringBoot版本太高导致某些第三方依赖的兼容性出问题。比如SpringBoot 3.x要求JDK17而且很多旧版的Starter在3.x下会有兼容问题。如果你不是非要上JDK17的新特性老老实实用SpringBoot 2.7.x JDK8/JDK11会省掉很多不必要的折腾。3. 数据库设计核心表与关键字段规划数据库设计这块是整套系统的地基设计得不好后面写业务代码的时候处处别扭。我直接给出核心表的设计思路以及一些关键字段的取舍原因。3.1 用户表字段不多openid微信用户唯一标识做唯一索引、nickname、avatar、phone、create_time。这里的关键点是openid。每个微信用户在小程序里的openid是唯一且不变的它是用户身份的天然主键。虽然自增id也可以用但业务上判断用户是否存在直接查openid是最稳妥的。至于手机号建议设计成可选项因为微信获取用户手机号的能力需要企业认证和用户主动授权不是每个用户都愿意给的。3.2 餐厅表字段包括餐厅名称、地址、联系电话、营业时间JSON格式可表示周一至周日不同时间段、餐厅介绍、封面图URL、状态营业中/休息中、经纬度用于距离排序。很多人会忽略经纬度这个字段但其实对于找餐厅的场景非常重要。小程序端可以通过wx.getLocation获取用户当前定位然后按距离排序或者直接筛选“附近1公里”的餐厅。这个体验是非常加分的算是小程序端比较常用的一个场景。3.3 桌型表与桌位表这两个表要区分开。桌型是抽象概念比如“四人桌”“八人包间”桌位是物理实体比如“二楼A区3号桌”。一张桌型可以对应多个桌位。桌型表字段餐厅id、桌型名称、可容纳人数、桌位数可选如果按桌位管理则此字段冗余即可、描述。 桌位表字段餐厅id、桌型id、桌位编号、所在区域/楼层、预约状态空闲/已预约/已锁定。为什么要拆开因为一张具体桌位在同一时间只能被一个预约占用而“这个店有多少四人间”是一个汇总概念。拆开后查询“四人间是否还有空位”时可以先查桌型对应的桌位再判断哪些桌位在目标时间段没有被占用。3.4 预约时段配置表这个表是用来配置“每天有哪几个可预约时间段”的。餐厅不是24小时都在营业用户只能在营业时段内预约。字段包括餐厅id、日期类型周一至周日或节假日、开始时间、结束时间、每个时段最大可预约人数/桌量。这块需要结合业务做判断有些餐厅是按“桌”预约的用户在某个时间段预约一个桌有些是按“时间段”预约的比如“12:00-13:00”这个时段整店最多接多少单。我在实际设计时倾向于以“时段桌型数量”为约束这样既能控制每个时段的总容量又能按桌型精确分配。3.5 预约订单表表中最重要的字段是用户id、餐厅id、桌型id或桌位id、预约日期、预约时段开始时间-结束时间、就餐人数、备注、状态待确认/已确认/已取消/已完成/已过期、创建时间、取消时间。这里有两个设计细节值得你注意第一订单状态机。预约订单从创建到结束状态流转必须清晰。用户创建后是“待确认”商家接单后变“已确认”用户可在“待确认”或“已确认”状态下取消到店就餐后由商家标记“已完成”如果预约时间已过且未取消自动置为“已过期”。第二幂等性。用户手抖连续点了两次“提交预约”后端必须防止生成两条重复订单。可以通过前端按钮防抖 后端在创建时校验同一用户同一时间段是否已有有效预约来实现。4. 后端核心模块设计与实现要点4.1 项目初始化与目录结构这部分直接给你一份可以直接抄作业的工程结构com.example.restaurant ├── controller # 接口层只做参数接收和结果返回 ├── service # 业务逻辑层核心逻辑都在这层 ├── mapper # MyBatis-Plus接口数据访问层 ├── entity # 数据库实体类 ├── dto # 数据传输对象接收前端参数 ├── vo # 视图对象返回给前端的数据 ├── config # 配置类Redis、CORS、验证码等 ├── common # 公共类统一返回结果、异常处理、常量 ├── utils # 工具类JWT工具、日期工具等 └── exception # 自定义异常这个结构看起来简单但贵在清晰。有一点要提醒你controller不要写业务逻辑service不要写SQL。各自干各自的事后期维护会舒服很多。很多人一开始图省事把逻辑堆在controller里等接口一多就全乱了。4.2 微信登录态管理小程序端登录流程是这套系统相对难搞的环节。微信小程序的登录已经不是早期那种直接拿wx.login返回的code换openid就完事了。现在的标准做法是小程序端调用wx.login获取code。后端拿到code后调用微信接口jscode2session换取openid和session_key。后端使用openid到数据库查询用户是否存在不存在则自动注册。生成JWT令牌返回给小程序端后续请求通过Authorization头携带该令牌。这里要特别注意session_key的设计。session_key是微信用来解密用户敏感数据如手机号、UnionID的密钥一定不能下发到前端。我在实际项目中见过有人把session_key直接返回给小程序端这是非常危险的做法。JWT的过期时间建议设置为7天因为小程序的使用场景决定了用户不会频繁登录。设置太短会导致用户频繁被踢下线设置太长又有安全隐患。7天是一个比较折中的值配合Redis做黑名单可以应对需要强制下线的场景。4.3 预约核心逻辑防超卖与冲突校验这是整套系统最有含金量的部分。餐厅预约和电商秒杀有点类似——都存在“多人同时争抢有限资源”的问题。不同的是餐厅的资源是“某个时段内的某张桌位”而且约束条件更多。预约的基本流程用户选择一个日期、时间段、人数系统找出符合条件的桌型。系统查询该时段内该桌型下还有哪些桌位没有被占用。如果存在空闲桌位用户提交预约系统锁定桌位并创建订单。占用桌位设置有效占用时间例如预约时段前10分钟至预约时间段结束。这里最容易出的问题就是并发超卖。两个用户同时在12:00提交预约都查到“还有一张四人桌”如果处理不当两个人都成功创建了订单但实际只有一张桌子。解决这个问题的方案有两种。方案一数据库层面加约束。因为预约订单表中已经有“桌位id预约日期预约时段”的唯一索引插入时会直接抛异常这种方案最稳妥。不管是加索引、用悲观锁还是用Redis分布式锁最终防线都是唯一索引抢到只有一个成功。方案二使用Redis预校验 分布式锁。先通过Redis缓存每个时段每个桌位的预约状态提交预约时通过SET NX命令抢占标记位。只有抢占成功才能创建订单后续再异步同步数据到数据库。这里我分享一下我实际使用的方案先查数据库SELECT COUNT(*) FROM reserve_order WHERE table_id? AND reserve_date? AND time_slot? AND status IN (待确认,已确认) 如果该桌位此时间段已经有预约直接返回“已被预约”。 然后使用Redis分布式锁锁住桌位维度锁内再次校验并插入订单。为了防止极端情况下的并发穿透数据库层再补一张唯一索引作为最终防线。三层防护下来基本不会出问题。4.4 时段冲突与容量控制的细节处理还有一个很容易在需求阶段被忽略的问题预约时间段的粒度。假设餐厅把中午分成“11:00-12:00”和“12:00-13:00”两个时段那么预约12:00那一段的用户理论上他可能坐满一个小时。但如果有餐厅允许“11:30到店吃到12:30走”这就产生了交叉时段的冲突。我建议的做法是不要让用户选“时间段”而是让用户选“到店时间”然后设定一个默认就餐时长比如90分钟后台根据默认时长和餐厅承载力自动判断是否可预约。具体的容量计算可以用一个简单模型某时段的预约人数 正在就餐人数 餐厅总容量 其中正在就餐人数 在距今90分钟前到店且还没走的用户在实现上对每个预约系统会更新开始时间和结束时间。查询某时段的可约状态时只需要查询“预约开始时间 新预约的结束时间 且 预约结束时间 新预约的开始时间”的交集就能准确判断是否冲突。这个逻辑是预约系统最底层的判断标准也是区别于普通CRUD的关键设计。4.5 Redis缓存的应用这个系统里Redis主要在三个地方发挥作用第一缓存餐厅信息。餐厅列表和详情是高频率读取、低频变更的数据非常适合放缓存。直接用餐厅id作为key更新时删除缓存查询时先查缓存再查库可以显著减少数据库压力。第二分布式锁。前面提到的预约抢锁就是典型场景。这里注意锁的key设计要细粒度不建议全店一把锁那会把并发度直接拉满到1。更好的做法是锁“桌位时段”维度比如reserve:lock:{tableId}:{date}:{timeSlot}。第三库存预扣减。可以把每个时段每个桌型剩余桌位数量缓存在Redis里提交预约时先预扣减锁定成功后再落库。这样可以缩短数据库事务的持有时间优化高并发场景下的吞吐量。5. 小程序端实现要点与踩坑实录5.1 页面架构与公共模块小程序端的页面结构我建议这样划分pages/ ├── index/ # 首页餐厅列表、搜索 ├── detail/ # 餐厅详情信息展示、桌型预览 ├── reserve/ # 预约页选时间、选桌型、提交预约 ├── records/ # 预约记录历史预约、状态展示 ├── mine/ # 个人中心个人信息、设置 └── login/ # 登录页也可以做在mine页内公共部分包括一个request.js封装统一处理请求头、错误码、登录态过期、一个utils.js格式化时间、防抖节流等。小程序没有axios这个东西但自己封装一个request方法并不难核心逻辑就是统一拼接baseUrl、统一加上Authorization头、统一处理HTTP错误码和业务错误码。5.2 顶部导航栏高度适配这是小程序开发里的一个经典坑。Android和iOS的状态栏高度不一样Android一般是20-24pxiPhone X及以上是44px如果做自定义导航栏处理不好就会出现顶部内容被状态栏遮挡的情况。我建议直接使用官方的navigationStyle: custom然后通过wx.getWindowInfo()注意新版API已经废弃了wx.getSystemInfoSync()拿到statusBarHeight动态计算顶部导航栏的高度并让安全区域的内容自动下移。const winInfo wx.getWindowInfo(); const statusBarHeight winInfo.statusBarHeight; // 导航栏默认高度是44px加上状态栏高度就是总高度这段代码虽然简单但如果你不做上线后在iPhone上就会出现体验问题用户会觉得这个App“很业余”。5.3 列表加载更多的正确写法餐厅列表的分页加载是每个小程序项目都要处理的通用问题。我的做法是列表页引入onReachBottom生命周期函数在这个事件里判断是否还有更多数据如果有则加载下一页。这里有个技巧定义一个isLoading标志位防止用户在数据未返回时反复触底导致重复请求。正确写法是onReachBottom() { if (this.data.isLoading || this.data.isFinished) return; this.setData({ isLoading: true }); // 加载下一页 }没有这个标志位你会看到列表在快速滑动时发出大量重复请求既浪费资源还会导致列表数据出现重复。这个问题在真实项目里非常常见尤其是测试小姐姐在页面底部疯狂上滑的时候一旦出现重复数据她就会把这个当Bug提给你。5.4 微信订阅消息预约状态触达的实现方式用户预约之后如何通知他“预约成功”或者“商家已确认”常见的方案其实就是微信订阅消息。这里有几个使用层面的关键认知订阅消息的模板需要在小程序后台申请一次订阅只能推送一条消息。用户需要通过按钮主动触发订阅授权用wx.requestSubscribeMessage这个API。同一个模板用户订阅一次你只能发送一条消息。所以如果你想在“预约成功”和“商家确认”两个环节都发通知需要在提交预约时请求用户订阅两次。实际执行时我建议把订阅请求放在按钮事件里而不是页面加载时。因为微信有规定订阅消息必须要用户点击行为触发否则无法弹窗授权。而且不要试图在提交预约之后再调用那会直接失败。6. 管理后台与端到端联调要点6.1 预约状态的同步与确认机制预约订单从用户提交到商家确认中间有一段人工操作。管理后台要做的事很明确看到一个待确认的订单点击确认然后系统通知用户。这里有一个业务细节值得注意当商家确认预约时系统需要再次校验库存防止用户占座之后又把座位释放掉了比如取消其他订单导致的状态错乱。虽然概率很低但严谨一点没有坏处。6.2 前后端分离的联调注意事项小程序端请求本地后端接口时要打开微信开发者工具的“不校验合法域名”。生产环境则必须在微信公众平台配置request合法域名并且域名必须是HTTPS。这里涉及一个常见的部署问题后端接口如何暴露给微信服务器答案是使用Nginx反向代理把/api前缀的请求转发到SpringBoot服务。在联调阶段最常见的问题就是跨域虽然小程序端没有浏览器跨域的概念但如果你做Web管理后台就需要在SpringBoot里配置CORS。需要注意的是如果小程序端请求的接口域名和后台管理端的域名不同生产环境的CORS配置要精确到具体的来源不要直接用*放开否则存在安全风险。6.3 vue打包放进SpringBoot的那些坑我知道很多人图省事会把Vue管理后台打包后的dist目录直接放进SpringBoot的resources/static目录里让SpringBoot既当接口服务又当静态文件服务器。这个做法在早期确实行得通但现在SpringBoot处理history模式路由时有一个问题如果前端用了vue-router的history模式刷新页面时会出现404因为SpringBoot默认只匹配到/这一个路径。解决办法有几种方案一前端用hash模式URL带一个#丑但能用。方案二后端加一个转发规则把非/api开头的路径全部转发到index.html。方案三把静态文件交给Nginx托管SpringBoot只管接口。我的建议是方案三。虽然在项目开发里“一键部署”看起来很爽但生产环境中让专业的人干专业的事Nginx处理静态文件的性能比SpringBoot好得多而且配置更灵活。7. 常见问题与避坑经验总结7.1 编译报错与版本兼容问题SpringBoot版本太高导致的依赖冲突。我在项目启动的过程中就遇到过SpringBoot 3.x MyBatis-Plus旧版不兼容的问题启动直接报错。如果你跟着教程走发现教程里的依赖放到你的项目里无法启动大概率就是SpringBoot版本问题。检查一下你的JDK版本和SpringBoot版本是否匹配以及MyBatis-Plus的版本是否支持当前SpringBoot。Uniapp或原生小程序打包体积超限。前面已经说过微信小程序主包不能超过2MB。如果你的项目依赖太多建议用分包加载把不需要首屏加载的页面放到分包里。微信的分包机制是小程序开发必须会的基本功。7.2 微信登录与授权相关后端拿不到用户手机号。这个要分清一个概念wx.login拿到的code是用来换openid的不是用来获取手机号的。获取手机号需要用户在小程序里点击一个“获取手机号”的按钮通过button的open-typegetPhoneNumber来触发然后拿到加密数据用后端保存的session_key来解密。如果你发现解密一直失败检查一下你的session_key是不是已经过期微信的session_key有效期并不长或者你是不是用了别人的session_key。小程序开发工具里请求接口失败。首选排查问题是域名白名单。开发阶段可以在详情设置里勾选“不校验合法域名”但一旦上线这个勾就没用了。另外一个坑是开发工具里的request不走系统代理如果你本地的接口是通过代理工具访问的一定要检查代理设置是否正确。7.3 预约系统的业务逻辑漏洞用户重复提交预约。解决办法前端按钮提交后置灰或显示loading后端创建时检查用户在该日期该时段是否已有有效订单。还要注意处理用户的“编辑预约”场景修改预约时也要走同样的校验逻辑。预约超时未到店。这个属于业务规则的边界问题。我的做法是设置一个预约截止时间如果用户超过预约开始时间30分钟未到店订单自动置为“已过期”同时桌位自动释放。这个逻辑用SpringBoot的定时任务每天扫一遍即可也可以通过Redis的过期事件来触发但定时任务更可控排查问题也更容易。商家修改或取消预约。当商家端取消一个已确认的预约时一定要记得释放桌位资源。如果忘记做这一步会发现系统运行一段时间后可预约的桌位越来越少但实际去店里看根本没有那么多客人在用餐。7.4 一个小而重要的细节时间处理小程序端传给后端的时间建议统一用字符串格式yyyy-MM-dd HH:mm:ss后端用LocalDateTime接收。不要用时间戳虽然时间戳精度高但可读性和日志排查都很难受。数据库里的存储也是用datetime类型。还有一个细节用户的预约日期常常是“今天”或“明天”但餐厅的营业日期和自然日并不总是一致。设计时要把“餐厅营业日”当成一个独立的业务概念否则跨天营业的餐厅比如夜宵店会出现明明还在营业中却无法预约下一时段的尴尬情况。8. 最后的一点真实心得做这套系统走下来最大的体会是所谓的“简单系统”往往藏在细节里。SpringBoot本身确实降低了后端开发门槛小程序开发工具也把前端调试做得足够方便但真正拉开项目质量差距的永远是那些不起眼的环节——并发预约的防超卖、时段冲突的判断逻辑、微信登录态的安全处理、页面列表的加载体验、打包体积的控制。尤其是预约系统的核心逻辑如果你只做成“新增一条订单记录”那确实一会儿就写完了。但如果你把它做成“资源在时间轴上的分配系统”你会在校验、冲突、锁、缓存、状态流转这些方面不断打磨而这个打磨的过程才是真正让你涨经验的部分。我再分享一个实际执行中的小建议做预约系统之前先画一张“时间-资源”的分配表把每一个时段、每一张桌位当成一个矩阵单元再做业务设计。你会发现很多需求上的模糊点会在画这张表的时候自动暴露出来。先把这个想清楚代码怎么组织其实水到渠成。这套系统后续还可以扩展的方向也不少比如对接支付押金、支持多人拼桌预约、接入地图导航、引入评价体系甚至通过数据分析优化门店的时段承载策略。基础的骨架搭稳了扩展都是锦上添花的事情也都不会脱离业务本质。
返回列表