ARTICLE DETAIL

资讯详情

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

SpringBoot+微信小程序餐厅预约系统实战:从表结构到并发控制

SpringBoot+微信小程序餐厅预约系统实战:从表结构到并发控制 春节前后那段时间我帮朋友的小餐厅做了一个预约点餐的小程序。朋友店不大但一到饭点高峰期电话响个不停要么是问还有没有位子要么是临时订桌结果到了发现已经被坐满。做之前我调研了一圈市面上扫码点餐的SaaS挺多但大多是固定模板要么只支持菜品下单要么预约功能做得太浅商家连“哪个时段放多少桌”“超时多久自动释放”这种基本规则都改不了。干脆直接用SpringBoot写后端微信小程序做前端从零搓一套餐厅预约系统。这套系统跑下来预约到店率明显提升朋友也第一次感受到了“数据化排班”的爽快感。这篇文章就把整个项目的核心设计、关键代码和踩过的坑全部整理出来给准备自己动手做同类型项目的开发者一个完整的参考。1. 整体设计与技术选型为什么是SpringBoot加小程序这对组合1.1 技术选型背后的逻辑先说后端。SpringBoot在这个场景里几乎是最稳妥的选择不是因为它花哨而是因为它的生态成熟度和开发效率太适合这种中小型业务系统了。预约系统本质上就是一组CRUD加状态流转加上一点并发控制并不需要微服务那套复杂度。SpringBoot自带嵌入式Tomcat、自动装配、spring-boot-starter-data-jpa或者MyBatis-Plus集成写起来非常顺手。部署也简单打一个jar包扔到服务器上java -jar一跑就完事。唯一要留意的是SpringBoot版本问题后面我会专门讲因为版本选不对坑起来真的很要命。小程序端则是刚需。餐厅预约这个场景极具“低频但不临时”的特点——用户不会天天打开但一旦打开基本都是即时需求比如“我现在过去还有位子吗”。这种需求如果让用户下载App转化率基本归零如果做H5用户用完即走下次再找入口又很麻烦。小程序正好卡在两者中间微信里搜一下就能开用完可以留在“最近使用”列表里下次直接下拉就能找到。而且微信提供了wx.login静默登录用户连注册流程都省了。这套组合的本质逻辑是后端追求稳定快速交付前端追求零门槛触达一重一轻正好互补。1.2 系统角色与模块边界在设计阶段我把系统分成了三个角色普通用户、餐厅商家、系统管理员。听起来简单但这个角色划分直接决定了我后来所有表结构和接口的设计。用户端看到的功能是餐厅列表、餐厅详情包括餐位类型、营业时间、当前排队人数、提交预约选日期、时段、人数、备注、查看我的预约、取消预约。商家端要复杂一些餐厅基础信息维护、餐位类型管理比如四人桌、六人桌、包间、预约时段配置比如午餐时段11:00-14:00分几个slot、预约审核/确认、到店核销、预约统计。管理员不多做主要管用户封禁和全局参数比如允许提前多少天预约、超时未到店自动取消多久生效。这样的模块划分有一个关键好处边界清晰前端页面和后端接口都能一一对应。我见过很多项目图省事用户端和商家端混在一个接口里靠参数区分角色结果改一处崩一片。宁可前期多建两张表、多拆几个Controller也别为了省事埋雷。2. 小程序前端核心功能从预约流程到列表加载的细节处理2.1 预约主流程与页面设计预约是这套系统的心脏。整个流程我串成了四条页面餐厅列表页 - 餐厅详情页 - 预约下单页 - 我的预约列表页。流程看起来平铺直叙但每个环节都有细节。餐厅列表页的排序逻辑直接影响了用户体验。单纯按距离排序看似合理但用户更关心的是“这家现在还能不能约”。所以我在后端接口里做了一个复合排序营业状态正常的优先打烊的直接沉底今日剩余可预约时段数多的排前面最后才是距离。这个逻辑放在SQL里做很别扭我是在Java里取列表后根据当时的时间动态算出来的因为“剩余可预约时段”是会随着时间变化的缓存反而麻烦。餐厅详情页有个小细节很多人容易忽略营业状态。如果你是做纯预约系统页面倒还好但如果餐厅在午休时间用户预约晚餐时段你得让用户明确感知到“当前是休息时间但可以预约晚上”。我的方案是页面顶部显示一个实时状态条由后端根据餐厅营业配置和时间动态算出并返回前端拿这个字段做展示而不是前端自己算因为餐厅可能有临时歇业等特殊配置前端算容易不一致。预约下单页需要同时处理三个维度的状态日期、时段、人数。我最初的设计是把“人数”放在详情页就让用户选进了下单页只选日期和时段。结果发现挺多用户会进到下单页才发现人数选错了又得退回去。后来我把人数选择挪到了下单页第一步日期第二步时段第三步每个步骤一个Picker组件。这样流程更顺后端校验逻辑也更清晰因为“某时段某日期下还有没有对应人数的空位”在同一个请求里就能算清楚。提交预约的按钮必须做防止二次提交处理。这不是什么高级功能但很实在用户手抖点了两下后端收到了两条一模一样的预约请求如果接口没有幂等处理用户就看到两个预约单。我的方案是小程序端提交时生成一个UUID作为reqId后端在Redis里用SETNX做去重同一reqId只处理一次。没有Redis的话可以用数据库的唯一索引兜底实战中这招值得每张核心业务表都用上。2.2 列表加载更多与顶部导航适配餐厅列表页不可能一次拉全量数据一是数据可能多二是一次性渲染几十个餐厅卡片在小程序里会卡顿。所以必须做分页加载。我采用的是最简单的页码分页page和size两个参数后端返回当前页数据加一个hasMore标志位。前端用onReachBottom触发下一页加载。这里有一个新手很容易踩的坑列表数据的初始加载和下拉加载必须用同一个函数但loading状态要分开控制。初始加载用骨架屏或整页loading加载更多用底部“加载中”提示。如果都用一个loading变量会出现一个典型Bug用户快速上下滚动下拉触发加载的时候全局loading被顶掉页面整个闪了一下白屏。我的做法是维护两个状态initLoading和loadMoreLoading数据渲染也不同——请求返回后拼接数组而不是覆盖数组。再说顶部导航栏高度这个细节。小程序原生导航栏在不同机型上的高度不一样尤其是有胶囊按钮的机型自定义导航栏时如果硬编码一个固定的高度iPhone和某些安卓机型上就会出现按钮重叠。我的兼容方案是用wx.getMenuButtonBoundingClientRect()拿到胶囊按钮的位置信息再根据系统信息计算导航栏高度。计算方式是胶囊按钮的top值减去状态栏高度再加上胶囊高度的一半得到导航栏中心点再乘以2得到导航栏总高度。这套公式在小程序社区里已经流传很久实测兼容性很好。餐厅详情页头部有个返回按钮和店名标题如果高度适配不对标题会顶在很靠上的位置视觉上看非常业余。3. SpringBoot后端核心实现表结构、并发控制与接口规范3.1 核心表结构设计预约系统的表结构说复杂也复杂说简单也简单关键看你想做到什么粒度。我最终是拆成了五张核心表餐厅表restaurant、餐位类型表seat_type、预约时段配置表time_slot、预约订单表reservation、用户表user_info。餐厅表和餐位类型表是一对多的关系一家餐厅有多个餐位类型比如“四人桌”库存10张“包间”库存2个。这张表在初始化时就要把库存这个概念埋进去因为后面所有的并发控制、剩余量计算都依赖于“库存”。我建表时的几个关键字段摘出来给参考CREATE TABLE seat_type ( id BIGINT PRIMARY KEY AUTO_INCREMENT, restaurant_id BIGINT NOT NULL COMMENT 餐厅ID, name VARCHAR(30) NOT NULL COMMENT 类型名称如四人桌, capacity INT NOT NULL COMMENT 可容纳人数, total_count INT NOT NULL COMMENT 总数, reserved_count INT DEFAULT 0 COMMENT 已预约数, sort_order INT DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_restaurant (restaurant_id) );time_slot表存的是模板比如每天的午餐时段拆成几个slot每个slot起止时间以及每slot的放号总量。这里有个设计取舍是把某个日期的某个slot作为一条记录如2025-06-01 11:00-12:30一条还是把星期几作为模板周一11:00-12:30一条然后预约时再去动态算如果系统只支持未来一周预约那每天的slot在凌晨生成任务批量创建是更好的方式因为每天的库存独立方便做“今日剩余”统计也方便设置“某一天临时关闭预订”。我选择了后者每天凌晨由定时任务给未来7天生成slot记录。这样表里查预约容量、算余量都非常直接不用在查询时做复杂的时段匹配。reservation表是重中之重。我除了业务字段外还特意加了status字段0待确认、1已确认、2已到店、3已取消、4超时未到店和version字段乐观锁用。为什么不用更复杂的订单状态机因为预约业务的流转其实是线性的提交-确认-到店中间插入取消和超时两个异常分支状态枚举就够了上状态机框架属于过度设计。3.2 并发预约与防超卖预约系统的并发量远没有秒杀那么夸张但“防超卖”的需求是一样的。高峰期同一时段可能有几十个人同时在预约如果都只做简单的先查后插就会发生两个人同时查看到还剩1个名额同时提交预约结果都成功了但实际只该有一个人成功。我的处理思路是三层防线。第一层Redis预热库存Lua脚本来保证原子性适用于高并发时段第二层数据库乐观锁用version字段控制更新第三层数据库唯一约束保证同一用户同一时段只能有一条未取消的预约。对于餐厅这种体量我实测下来第三层核实已经能解决绝大多数并发问题但考虑到未来做连锁餐厅、多家分店还是上了RedisLua。先看最基础的“先查后插”写法为什么有问题// 错误示例先查后插会产生超卖 SeatType seatType seatTypeMapper.selectById(seatTypeId); if (seatType.getReservedCount() seatType.getTotalCount()) { seatType.setReservedCount(seatType.getReservedCount() 1); seatTypeMapper.updateById(seatType); // 创建预约订单 }这个操作用两个线程并发执行时A和B同时读到reserved_count 9总量10A更新为10B也更新为10最后reserved_count还是10但实际上两个预约都成功了库存超卖。数据库层面虽然没有报错业务上却已经错了。我最终采用的方案是数据库乐观锁更新// 先执行更新返回影响行数 int count seatTypeMapper.updateReservedCount(seatTypeId, version); if (count 0) { throw new BizException(手慢了该时段名额刚刚被抢完); }对应的SQL是UPDATE seat_type SET reserved_count reserved_count 1, version version 1 WHERE id #{seatTypeId} AND version #{version}这里有两个关键点一是先去更新更新成功再创建订单把两个操作放进同一个事务里二是update时把version作为条件带进去影响行数为0说明版本号已经变了即有人在本次查询之后修改过了于是直接抛出业务异常。这样避免了并发情况下的超卖而且不需要额外的分布式锁对于单体应用足够可靠。如果要用Redis做前置拦截Lua脚本的核心逻辑是local reserved tonumber(redis.call(HGET, KEYS[1], ARGV[1]) or 0) local total tonumber(redis.call(HGET, KEYS[1], ARGV[2]) or 0) if reserved total then redis.call(HINCRBY, KEYS[1], ARGV[1], 1) return 1 else return 0 endRedis库存和数据库库存之间的一致性靠定时对账任务兜底每分钟扫描一次HASH中当天最新的商户库存和数据库进行比对找出不一致的以数据库为准修正同时补一个对账日志。这套对账逻辑虽然简单但能避免Redis和数据库在极端场景下越差越大属于“宁可多做不可不做”的保障。真正做的时候建议把Redis的库存只作为准入控制数据库的乐观锁再做一次校验双保险。3.3 接口设计与统一返回规范后端我用的Controller层只做参数接收和返回响应核心业务逻辑全部下沉到Service。每个接口的返回结构统一是public class ResultT { private Integer code; // 0成功非0业务错误 private String msg; // 提示信息 private T data; // 业务数据 }重点讲两个接口的设计。第一个是“获取可预约时段”参数是restaurantId和date返回一个slot列表每个slot含有剩余可预约量。这个接口我一开始直接在Service里查数据库后来发现性能不行——餐厅详情页几乎每个用户进来都会请求一次如果赶上高峰期数据库压力不小。后来加了Redis缓存key设计为“reservation:slot:restaurantId:date”过期时间设为该日期当天的结束时间这样当天数据一旦生成就不会频繁查库。唯独“剩余可预约量”这个字段不能缓存太久因为每产生一个预约都要实时扣减。我的方案是缓存slot的基础信息剩余量实时查Redis如果Redis挂了降级走数据库保证基础可用。第二个是“提交预约”参数包含restaurantId、seatTypeId、date、slotId、人数、备注。这个接口要做三步校验餐厅是否营业、slot是否开放、剩余量是否充足。三步校验顺序不能乱先查餐厅状态可以最快拦截无效请求再查slot是时间维度的精确判断最后查库存是数量判断。校验全部通过后进入上面说的乐观锁更新逻辑然后创建预约订单。整套逻辑放在Transactional里保证seat_type的更新和reservation的插入要么都成功要么都失败。这里要注意事务的隔离级别默认的READ_COMMITTED就够了不要为了图省事把隔离级别设置成SERIALIZABLE那样并发性能会断崖式下降。4. 联调与上线部署小程序真机调试、域名配置与版本坑4.1 小程序联调开发者工具只是开始真机才是真相整个项目开发周期里联调阶段花的时间占比不小。小程序开发者工具的模拟器很好用但它和真机之间的差异比很多人想象得大。我遇到过的几个典型场景模拟器上定位正常真机拿不到位置权限模拟器上request请求没问题真机上报“不在以下合法域名列表”中模拟器上页面滑动流畅真机上下拉加载更多的时候卡顿明显。所以我的习惯是功能在开发工具里调通后第一时间用真机跑一轮全流程。真机调试有几个准备工作一是开发者工具右上角“详情”里勾选“不校验合法域名”但注意这只是开发阶段用的发布前一定要关掉然后在小程序后台配置request合法域名二是用真机配合代理工具抓包看请求我个人喜欢用Charles能看到小程序和服务器之间的完整HTTP交互排查问题效率非常高。这里想多说一句关于接口联调的细节小程序端的request封装必须统一处理登录态失效问题。具体做法是在封装的request函数里统一加上token通常是从wx.login获取的code换来的session后端返回特定错误码如401时全局拦截并跳转到登录页重新授权登录。如果没有这一层用户在一个页面停留十分钟后token过期再点提交预约就会报一个莫名其妙的错误体验非常糟糕。4.2 SpringBoot版本选型与部署注意事项SpringBoot的版本真的是老生常谈但我还是要重点说因为我就在这上面栽过跟头。我最初图新鲜用了SpringBoot 3.5结果发现我的旧版MyBatis-Plus不兼容不得不换到3.5.1版本才跑通。不是版本越新越好要看你的核心依赖是否适配。如果团队对Cloud、MyBatis-Plus等生态链没有把握SpringBoot 2.7.x或者2.9的稳定分支其实更省心。另外一个常见的启动配置问题是端口冲突。SpringBoot默认8080但服务器上可能已经跑着别的服务。我习惯在application.yml里显式配置端口、上下文路径和时区server: port: 8080 servlet: context-path: /api spring: jackson: time-zone: GMT8 date-format: yyyy-MM-dd HH:mm:ss datasource: url: jdbc:mysql://localhost:3306/restaurant?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Drivercontext-path设置为/api有几个好处一是解决多服务反代时的路径区分问题二是前端请求统一以/api开头代码里路由前缀清晰三是生产环境Nginx转发时不用重写路径那么麻烦。时区配置这块千万别省Java默认时区和MySQL的时区如果不一致日期字段查询出来会莫名少8小时排查起来极其痛苦。部署上线的时候还有一笔账要算清楚小程序要求所有请求必须是HTTPS。这意味着服务器上必须部署SSL证书。证书可以用云服务商的免费证书搞定比如阿里云或腾讯云每人都能申请免费的DV证书一年有效期到期前有邮件提醒。别忘了小程序后台的request合法域名配置域名必须是备案过的且不能带端口号。这是很多人第一次开发小程序时最容易卡住的地方——本地调试没问题一发布线上就白屏十有八九就是合法域名没配置或者证书没过期。4.3 常见问题速查表与排查技巧整理一份我实际开发过程中踩过的坑按问题、原因、解决方案列出来给读者一份可以随时翻查的清单。问题现象根本原因解决方案小程序真机请求失败开发者工具正常没配置request合法域名在小程序后台配置域名或开发阶段勾选“不校验合法域名”后端返回时间比实际少8小时MySQL连接串没配serverTimezoneJDBC URL加上serverTimezoneAsia/Shanghai并设置Jackson时区提交预约偶发重复单前端重复点击接口没做幂等前端生成reqId后端用Redis SETNX去重高峰期时段剩余量显示不准缓存了剩余量字段剩余量字段不缓存实时查Redis/DB基础数据才能缓存小程序自定义导航栏标题被胶囊遮挡硬编码导航栏高度用getMenuButtonBoundingClientRect动态计算SpringBoot启动闪退版本过高或过低与依赖不兼容锁定SpringBoot稳定版配套MyBatis-Plus版本用官方推荐的对应版本用户预约后商家不知道有新订单没有通知机制接入了订阅消息用户在预约成功后请求一次订阅授权商家审核时触发通知排查问题时我建议按“前端拦截 - 后端接口 - 数据库数据”这个顺序逐层排查。先看小程序端的Network请求返回了什么再看后端日志里有没有业务流程输出的关键节点比如提交预约时校验的每个步骤是否通过最后查表确认数据状态。这套顺序在最复杂的联调场景里帮我节省了大量时间。反向排查先查数据库再找原因通常会把问题放大因为你很难判断数据异常是前端传错还是后端逻辑错了。5. 预约排队与实时通知的进阶处理基础系统的预约功能跑通之后需求方通常会追加两个“更人性化”的功能排队叫号和实时通知。这两个功能是提升体验的关键也是小程序端和后端技术上有一定挑战的部分。排队叫号的场景是餐厅位子已满用户可以选择进入排队队列等待有位置空出时微信通知他。这里就涉及到“排队队列如何实现”与“如何发送叫号通知”。我选择的是用户点击“进入排队”后端创建一条queue_recordstatus为waiting餐厅每完成一个预约的到店核销就把同类型的排队队列的队头用户状态改为notify_sent并通过微信订阅消息发一条叫号通知。这里有个简单但有效的做法不引入消息队列只用数据库的偏序状态流转。因为餐厅排队量不大且叫号通知是主动触发的用数据库记录状态、前台轮询的方式完全跑得动比自己搭一套Kafka或者RabbitMQ省太多事。但“轮询”这套方案如果想做得更平滑可以考虑微信小程序的WebSocket能力。小程序有原生WebSocket支持后端用Spring的WebSocket端点做推送用户进入小程序且停留在餐厅详情页时建立连接服务端有队列变化就直接推过去。SpringBoot整合WebSocket其实很简单一个ServerEndpoint注解就能搞定难点在于管理连接和用户身份绑定。我的做法是连接建立时通过URL参数传递一个小程序端生成的随机会话标识后端把这个标识和userId的关联关系写入一个ConcurrentHashMap推送时根据userId找到对应的WebSocketSession发送。订阅消息的坑也要单独说。小程序的订阅消息和公众号的模板消息不一样它是一次性订阅用户点了订阅授权之后你只能给他推一次消息。所以预约成功、排队叫号这类消息要在用户操作的那个节点去请求授权。比如用户提交预约成功后弹出一个邀请授权的对话框他点击允许你才有推一次预约结果通知的资格如果他不点那这个通知就发不出去。这就意味着消息推送不能作为核心流程的依赖只能当作锦上添花。我在实际项目里是这么处理这个限制的在被推送方商家那端用短信代替因为商家是高频使用方需要稳定可靠的通知方式在用户端则明确不依赖订阅消息的送达率如果用户没收到通知他打开小程序看到“我的预约”状态变化也能感知到。这个设计既降低了开发成本又保证了核心体验不崩塌。千万不要试图用订阅消息做“预约成功”之后的强依赖逻辑送达率无法保证。实时叫号还有一个效果上的选择是做数字显示在餐厅大屏上还是只给用户发通知如果你的客户是大型连锁餐厅那可能真的要做个大屏页面让用餐区所有顾客都能看到如果只是中小餐厅那给用户发通知就够了不必增加硬件成本。系统的扩展性体现在这里——不要把功能堆上去而要根据实际规模做恰当的取舍。6. 运营数据与预约状态流转的兜底设计预约系统上线后商家最关心的是“今天有多少人预约”“哪些时段最热门”“有没有人预约了没来”。所以想尽办法把数据留全后面数据报表才做得出价值。预约订单的状态流转我设计了五个状态待确认、已确认、已到店、已取消、超时未到店。这里有两个容易被忽略的设计细节。第一个是超时未到店状态的处理。用户预约了晚上7点的位子如果6点50还没有确认到店难道要一直等到晚上吗我在预约时段结束前30分钟跑一个定时任务把该时段内所有状态仍是“待确认”或“已确认”的订单自动置为“超时未到店”并把对应餐位库存回补。这个策略对商家非常有用因为餐位是全天循环利用的资源一个订了不来的用户空占位子远比损失一个客户的预约更伤。定时任务用的是SpringBoot的Scheduled注解corn表达式每天定时扫描配合状态字段做幂等处理避免重复扫描和重复回补。第二个是预约取消的库存回补。用户主动取消预约时需要把对应时段餐位的已预约数减回去。如果只是把订单状态改为“已取消”不回去更新seat_type表里的reserved_count那么随着取消次数增加库存会越来越少最终导致系统显示没有位子但实际餐厅空位满满。所以取消的逻辑一定要在同一个事务里做两件事更新订单状态为取消、回补餐位库存且这个回补必须是原子操作。回补SQL和预订时的逻辑对称UPDATE seat_type SET reserved_count reserved_count - 1, version version 1 WHERE id #{seatTypeId}admin端的数据统计模块我按期初做了一个“预约趋势折线图”和“餐位利用率热力图”既满足商家对“周一到周日哪个时段最旺”的认知需求又能用数据反推运营策略。统计接口不追求实时每天凌晨把前一天的数据汇总进一张统计表查询时直接读统计表不做复杂的SQL聚合——既快又减轻数据库压力。7. 从项目实战到方案沉淀后续可扩展的方向系统交付之后我自己复盘了一下整个设计和开发过程最大的体会是预约系统的难点不在写代码而在“边界”的把握。你要想清楚哪个状态流转需要事务、哪个可以异步哪个库存要实时扣减、哪个可以容忍短时不一致哪个通知能作为强依赖、哪个只能做补充。技术选型上SpringBoot加小程序是一个被验证过无数次的黄金组合它能用最小的成本覆盖掉餐厅预约的主流场景。如果你正在做类似的项目我把几个值得扩展的方向列出来供参考。第一个方向是“预约点餐”一体化。预约到店后紧接着就是点餐如果能把预约单和点餐单打通用户到店后不用再扫一次二维码点餐商家也能提前备菜。这个场景在多人聚餐、宴会预订时价值特别大。第二个方向是接入支付押金或定金。对于包间、节假日座位这类稀缺资源单纯的预约很容易被放鸽子。在预约流程中做一个“预付定金”环节后端接入微信支付用经济手段提高履约率。不过微信支付的小程序接入需要额外申请商户号资质审核流程不短预算充足才建议做。第三个方向是闲时流量承接。餐厅的预约系统不只有预约功能还可以做“闲时套餐”展示比如工作日下午茶时段、夜宵时段通过优惠价格引导用户在这些时段预约。这个方向虽然偏向运营但系统的架构只需要多一个营销模块收益却是实打实的。最后补充一点个人经验做这类中小型系统代码质量和架构规范固然重要但更重要的是快速交付、快速验证。初期版本先砍掉复杂的数据统计、砍掉消息队列、砍掉精细的权限控制把预约的核心链路做扎实上线跑通后再迭代。我见过太多人花了两周设计表结构、一个月开发管理后台结果核心预约流程连一次规规整整的联调都没做过。做系统就像做菜先端出一锅能吃的再慢慢调味好过规划了一个满汉全席最后灶台都没开火。这个项目做完我自己最大的收获反而不是技术上的——SpringBoot和小程序的知识网上一搜一大把。真正值钱的是那些踩坑踩出来的经验时区为什么会错8小时、乐观锁在什么场景下真正有效、订阅消息的送达率天花板在哪、小程序自定义导航栏到底怎么适配。这些内容教科书里没有千里之外的同行也不会专门告诉你。整理出来分享给大家也是希望后来者能少走几步弯路。
返回列表