ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot咖啡厅座位预约管理系统:数据库设计与并发冲突处理实践

SpringBoot咖啡厅座位预约管理系统:数据库设计与并发冲突处理实践 做预约类系统前我建议你先想清楚一个问题你做的到底是一个“能用”的系统还是一个“能演示的系统。这两个目标对应的设计思路完全不同。今天这个基于SpringBoot框架的咖啡厅座位预约管理系统我按“能用且能过答辩”的标准来拆解把技术选型、数据库设计、并发冲突处理、实操踩坑一次说透。这个系统的核心价值很明确解决咖啡厅高峰期“到店没座、等位混乱”的痛点让用户通过线上预约锁定座位门店通过后台管理座位状态。同时它也是一道非常典型的SpringBoot全栈练手题目——覆盖了CRUD、状态机、时间冲突检测、权限控制、简单统计报表等常见业务场景很适合作为毕业设计或简历项目。接下来我按实际开发顺序把整个项目的设计和实现过程完整过一遍。1. 系统整体设计与技术选型思路1.1 咖啡厅预约的业务场景与核心需求解析在动手写代码之前得先把业务场景想明白。咖啡厅座位预约和餐厅订桌不太一样它的特点是座位种类多靠窗、卡座、吧台、包间、预约时段灵活不像正餐有固定翻台节奏、用户可能只是来喝杯咖啡待一下午也可能是一小时就走。所以这个系统的核心需求可以拆成四块。第一是用户端预约用户注册登录之后能查看不同区域、不同时段的座位占用情况选定座位和时间段后提交预约。第二是座位管理管理员能在后台维护座位信息包括座位编号、所属区域、容纳人数、座位类型还能实时调整座位状态空闲、占用、停用。第三是预约管理用户能查看自己的预约记录、取消未开始的预约管理员能查看全店预约列表、手动确认或关闭订单。第四是数据统计按天、按周统计各时段预约量、座位利用率帮助门店判断忙闲时段。这里面最容易被忽略、但恰恰是系统设计核心的是“时间段”这个维度。座位本身是一个静态资源用户预约的是座位在某段时间内的使用权。如果用传统的单表CRUD思维去设计只记录预约的日期和座位ID那同一座位同一天被预约多次就一定会出问题。所以设计预约数据模型时必须把时间片的概念引入进去后面我会详细讲。1.2 为什么选SpringBoot技术选型背后的考量这个项目选择SpringBoot我认为有三个层面的理由分别对应开发的效率、生态的成熟度和学习的价值。从开发效率说SpringBoot最大的贡献是解决了Spring框架配置繁琐的问题。以前用SSMSpringSpringMVCMyBatis搭一个项目要写一堆XML配置数据源、事务管理器、扫描路径、视图解析器每个都得手动配置。SpringBoot用自动配置机制把这些都省了一个启动类加几个注解就能跑起来。加上内嵌的Tomcat打包成jar直接java -jar就能运行部署成本极低。从生态成熟度说SpringBoot在Java服务端领域基本是事实标准。无论是集成MyBatis操作数据库、集成Redis做缓存、集成Spring Security做权限控制还是对接支付宝/微信支付、对接微信小程序都有非常成熟的starter组件。这意味着你做完这个基础版本之后想加任何功能都有现成的轮子可以接上。从学习价值说SpringBoot并没有把Spring的核心思想丢掉。它底层还是IOC容器、AOP切面、MVC模型这套东西。你通过一个项目把SpringBoot玩熟以后看Spring Cloud微服务、看各种基于Spring的框架都能很快上手。对于毕业设计或面试项目来说这个技术栈的含金量也比较稳妥。我在实际开发中建议的版本组合是组件版本选择说明JDK1.8或11企业里8用得最多11也行别用太高版本SpringBoot2.7.x稳定资料多不要用3.x自找麻烦MyBatis-Plus3.5.x比原生MyBatis省太多重复劳动MySQL5.7或8.05.7够用8.0注意驱动配置差异Redis可选做缓存或分布式锁时引入毕设可加可不加前端Vue3 Element Plus如果不想写前端用Thymeleaf模板也行这套组合的兼容性我在多个项目里实测过稳。SpringBoot 3.x虽然已经出了但很多配套教程和starter还在陆续适配中毕设项目没必要当小白鼠。2. 数据库设计从业务需求到表的完整映射2.1 核心表结构与字段设计说明数据库设计是这类管理系统的地基。我的习惯是先梳理出系统涉及的所有实体然后根据实体间的关系设计表结构。咖啡厅座位预约系统主要涉及这几个实体用户、座位、预约订单以及辅助性的座位类型、时段配置、系统配置等。核心表设计如下。用户表user字段名类型说明idbigint主键自增usernamevarchar(50)用户名唯一passwordvarchar(100)密码BCrypt加密存储phonevarchar(20)手机号real_namevarchar(50)真实姓名roletinyint角色0普通用户1管理员statustinyint状态0禁用1正常create_timedatetime注册时间座位表seat字段名类型说明idbigint主键seat_novarchar(20)座位编号如A01seat_typevarchar(20)座位类型靠窗/卡座/吧台/包间capacityint可容纳人数areavarchar(50)所属区域一楼/二楼/户外statustinyint座位状态0可用1停用descriptionvarchar(255)座位描述预约订单表reservation字段名类型说明idbigint主键order_novarchar(32)订单编号唯一方便展示和查询user_idbigint预约用户IDseat_idbigint预约座位IDreserve_datedate预约日期start_timevarchar(10)开始时间如14:00end_timevarchar(10)结束时间如16:00statustinyint状态0已取消1待确认2已确认3已完成4已过期remarkvarchar(255)备注create_timedatetime下单时间2.2 为什么订单表要冗余座位和用户信息有些同学在设计订单表时会纠结既然有了seat_id和user_id为什么还要在订单表里冗余座位编号、座位类型、用户姓名这些字段规范化理论不是说要尽量减少冗余吗理论和工程实践在这里确实有冲突。我的建议是对外展示类的字段该冗余就冗余。原因很简单预约列表是需要频繁查询的页面。如果用户查看“我的预约”时每次都要先查订单表拿到seat_id和user_id再join座位表和用户表才能把数据凑齐虽然也能跑但SQL复杂度和数据库压力都会上升。更麻烦的是如果后来座位信息被修改了历史订单的展示也会跟着变这显然是不合理的。正确的做法是在生成订单时把座位编号、座位类型、区域、用户名这些展示字段快照到订单表里。这样历史订单永远显示用户下单那一刻的信息不会因为后续座位调整而改变。这也是很多真实业务系统的通用做法属于典型的“用空间换一致性”。2.3 状态字段设计一个容易被忽视的细节不管是座位状态还是订单状态我都强烈建议用tinyint类型的数字表示而不是直接存字符串。原因主要有两个。一是存储效率和查询效率。数字字段占用空间小索引效率高这在数据量大了以后体会更明显。二是代码维护的清晰度。你可以在Java代码里定义一个常量类或者枚举类把状态的含义统一管理起来。比如订单状态public interface ReservationStatus { // 已取消 int CANCELLED 0; // 待确认 int PENDING 1; // 已确认 int CONFIRMED 2; // 已完成 int COMPLETED 3; // 已过期 int EXPIRED 4; }这样在代码里判断状态时写ReservationStatus.CONFIRMED就比写魔法数字2清晰得多也方便后续做状态流转的权限控制。这个习惯养成之后不管做哪个管理系统都受用。3. 核心功能实现预约流程与并发冲突处理3.1 完整预约流程的链路设计预约是整个系统最核心的流程我把它拆成前端交互和后端处理两段来设计。前端页面的流程是这样的用户选择预约日期系统展示该日期下所有座位在按时段的占用情况用户点击某个空闲座位选择开始时间和结束时间确认预约信息后提交。关键交互点有两个一是座位网格的实时状态展示要有已预约、空闲、已停用三种视觉区分二是时间选择控件的联动逻辑选中开始时间后结束时间的可选范围要自动过滤掉已经被别人预约的时段。后端处理预约请求的逻辑顺序就复杂一些了需要做以下校验第一用户身份校验判断当前登录用户的角色和状态是否允许预约。第二参数合法性校验检查日期是否在今天之后、时间格式是否正确、开始时间是否早于结束时间。第三营业时间校验预约时段必须在咖啡厅的营业时间范围内。第四座位状态校验确认该座位当前没有处于停用状态。第五核心逻辑——时间段冲突校验也就是判断这个座位在用户选定的时间段内是否已经被其他订单占用。第六数据写入生成订单号插入预约记录。这里第五步是整个系统的核心难点也是一个管理系统的含金量所在。具体的冲突检测方案我单独用一节来讲。3.2 时间段冲突检测SQL方案与Java方案对比时间冲突检测本质上是一个区间重叠判断问题。假设现有预约占用的时间段是[start1, end1]用户要预约的时间段是[start2, end2]那么两者发生冲突的条件是start2小于end1且end2大于start1。这个逻辑很容易理解但实现方式却有好几种不同方案的性能和代码复杂度差别很大。方案一纯Java代码判断。把所有相关预约记录查出来在内存里逐个判断时间区间是否有重叠。// 伪代码示例 ListReservation list reservationMapper.selectBySeatIdAndDate(seatId, reserveDate); for (Reservation r : list) { // 跳过已取消的记录 if (r.getStatus() ReservationStatus.CANCELLED) { continue; } // 区间重叠判断新开始时间 已有结束时间 且 新结束时间 已有开始时间 if (startTime.compareTo(r.getEndTime()) 0 endTime.compareTo(r.getStartTime()) 0) { throw new BusinessException(该座位在此时段已被预约); } }方案二SQL条件判断。把时间条件的判断交给数据库通过构造SQL查询是否存在冲突记录。// Mapper接口定义 int countConflict(Param(seatId) Long seatId, Param(reserveDate) String reserveDate, Param(startTime) String startTime, Param(endTime) String endTime, Param(excludeId) Long excludeId);select idcountConflict resultTypeint SELECT COUNT(*) FROM reservation WHERE seat_id #{seatId} AND reserve_date #{reserveDate} AND status IN (1, 2) AND #{startTime} end_time AND #{endTime} start_time if testexcludeId ! null AND id ! #{excludeId} /if /select这两种方案的核心判断逻辑是一样的区别在于把判断放在哪一层执行。我的建议是优先使用方案二SQL判断因为大部分情况下预约记录是分散在不同座位上、不同日期里的通过seat_id和reserve_date两个条件过滤后需要检查的数据量很小SQL的性能完全够用。而且这样做还有一个额外的好处减少了Java代码里遍历集合的麻烦也减少了网络传输的数据量。但这里要特别提醒一点SQL条件判断在单机低并发下是安全的但一旦两个用户同时提交同一座位的同一时间段预约就会产生经典的“并发超卖”问题——两个请求都通过了冲突检查然后都插入了订单。解决这个问题需要引入事务隔离和数据库锁的机制。常用的方案有三种第一种悲观锁。在查询冲突记录时用SELECT ... FOR UPDATE主动锁定座位记录让其他预约请求排队等待。这种方式逻辑简单但锁粒度较大并发量高时会有性能问题。第二种乐观锁。给座位表加一个version字段更新座位状态时通过版本号来控制冲突。这种方式在读多写少的场景下性能好但在预约场景中座位状态可能长时间不变乐观锁的作用相对有限。第三种数据库唯一约束兜底。通过建一个唯一索引来保证同一个座位在同一个时间段只能有一条生效的预约记录。这种方案最稳妥但需要额外设计一个约束字段比如把seat_id、reserve_date、start_time、end_time拼接生成一个conflict_key用唯一索引挡住并发插入。我在实际项目中用的是“SQL冲突判断 数据库唯一索引兜底”的双保险方案。代码层面做好业务校验数据库层面做最后一道防线这样即使代码有漏洞数据库也会把超卖的数据拒之门外。3.3 订单号生成与状态流转控制订单号我采用的是“日期随机数自增ID”的组合方案。具体来说格式是yyyyMMddHHmmss 4位随机数 订单自增ID。这样生成的订单号在并发下几乎不会重复而且通过订单号就能直接看出下单时间排查问题的时候非常方便。实现上可以直接用Spring的代码生成也可以用MyBatis-Plus的ID策略加自定义前缀。订单状态流转是整个系统的另一个核心逻辑我用一张状态流图来说明用文字描述用户提交预约后订单处于“待确认”状态管理员在后台确认后变为“已确认”用户在开始时间之前可以主动“取消”系统在每天零点扫描一次把已过期但未开始且未取消的订单自动置为“已过期”状态用户到店消费后管理员手动将订单置为“已完成”。这里有几个细节要注意取消订单和后端状态机有联动。用户主动取消后该座位对应时间段的锁定期就释放了别人可以立即预约。所以状态变更后要确保冲突校验能够跳过已取消和已过期的订单否则会出现“明明座位空着却约不了”的诡异问题。这一点我在初版实现时踩过坑后面在问题排查章节细说。4. 实操记录从0到1搭建并跑通项目4.1 环境准备与项目初始化要点实操部分我先说环境准备。安装JDK 8、MySQL 5.7或8.0、Maven 3.6开发工具用IntelliJ IDEA。IDEA安装好之后引入Lombok插件这个是必须的能帮我们省掉大量getter/setter/构造方法的样板代码。项目初始化我用的方式是直接去Spring Initializr官网生成基础工程。这个网站可以根据你的选择自动生成一个带Maven配置和启动类的SpringBoot项目压缩包省去手动建目录的时间。关键配置我建议这样选配置项选择说明ProjectMaven用Maven管理依赖LanguageJavaJava为主Spring Boot2.7.x稳定版本DependenciesSpring Web、MyBatis Framework、MySQL Driver、Validation按需添加生成后下载解压用IDEA打开在pom.xml里加上MyBatis-Plus依赖因为官方Initializr默认不带这个。此外建议加上Hutool工具类库它的日期处理和随机数生成功能很实用能省不少事。4.2 核心代码实现从Mapper到Controller的完整链路这个系统我建议采用经典的三层架构Controller层负责接收请求和返回结果Service层负责业务逻辑Mapper层负责数据库操作。实体类用Lombok的Data注解简化代码统一返回结果用Result类包装。先看实体类示例座位实体Data TableName(seat) public class Seat { TableId(type IdType.AUTO) private Long id; private String seatNo; private String seatType; private Integer capacity; private String area; private Integer status; private String description; }订单实体需要额外加几个展示用的冗余字段Data TableName(reservation) public class Reservation { TableId(type IdType.AUTO) private Long id; private String orderNo; private Long userId; private Long seatId; /** 冗余字段座位编号方便列表展示 */ private String seatNo; /** 冗余字段座位类型 */ private String seatType; /** 冗余字段座位区域 */ private String area; private String reserveDate; private String startTime; private String endTime; private Integer status; private String remark; private Date createTime; }预约接口的Service实现是重点核心逻辑如下Override Transactional(rollbackFor Exception.class) public ReservationVO createReservation(ReservationCreateDTO dto, Long userId) { // 1. 校验参数 if (StringUtils.isBlank(dto.getReserveDate()) || StringUtils.isBlank(dto.getStartTime()) || StringUtils.isBlank(dto.getEndTime())) { throw new BusinessException(预约日期和时间不能为空); } if (dto.getStartTime().compareTo(dto.getEndTime()) 0) { throw new BusinessException(开始时间必须早于结束时间); } // 2. 校验营业时间 if (dto.getStartTime().compareTo(09:00) 0 || dto.getEndTime().compareTo(22:00) 0) { throw new BusinessException(预约时段必须在营业时间09:00-22:00内); } // 3. 校验座位状态 Seat seat seatMapper.selectById(dto.getSeatId()); if (seat null || seat.getStatus() SeatStatus.DISABLED) { throw new BusinessException(座位不存在或已停用); } // 4. 校验时间段冲突 int conflictCount reservationMapper.countConflict( dto.getSeatId(), dto.getReserveDate(), dto.getStartTime(), dto.getEndTime(), null); if (conflictCount 0) { throw new BusinessException(该座位在所选时间段已被预约请选择其他时间); } // 5. 构建订单并保存 Reservation reservation new Reservation(); reservation.setOrderNo(OrderNoGenerator.generate()); reservation.setUserId(userId); reservation.setSeatId(seat.getId()); reservation.setSeatNo(seat.getSeatNo()); reservation.setSeatType(seat.getSeatType()); reservation.setArea(seat.getArea()); reservation.setReserveDate(dto.getReserveDate()); reservation.setStartTime(dto.getStartTime()); reservation.setEndTime(dto.getEndTime()); reservation.setStatus(ReservationStatus.PENDING); reservation.setRemark(dto.getRemark()); reservationMapper.insert(reservation); // 6. 返回结果 return convertToVO(reservation); }这里有两个实操层面的说明。第一Transactional注解一定要加在createReservation这个方法上因为冲突检查和数据插入是两个步骤必须保证它们在同一个事务里否则可能出现“检查时不冲突插入时别人先插入”的间隙问题。加上事务后即使database层面的唯一约束拦住了并发也能保证异常时整个操作回滚。第二时间字段类型我这里用的是String而不是LocalTime。原因是数据库里预约时间的存储和界面展示格式高度一致String类型做比较判断完全够用不会涉及到复杂的日期运算。但如果你想做小时数差、跨天预约这类高级功能就得用LocalTime或LocalDateTime。4.3 前端页面与后端接口的联调要点前端我使用的是Vue3 Element Plus的组合。如果你不想自己写前端也可以直接用Thymeleaf模板SpringBoot的官方模板引擎服务端渲染不用考虑跨域开发速度更快。但用Vue做前后端分离的话需要处理跨域问题需要配置一个CORS过滤器或代理。这里我把CORS配置方式放出来经常有人在这块卡壳Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意allowedOriginPatterns()和allowCredentials(true)必须配合使用单独用allowedOrigins()加allowCredentials(true)会被浏览器拦截这是很多前后端分离项目联调时最大的坑之一。页面联调时建议开启浏览器的开发者工具重点看Networks面板里接口调用是否带上了认证信息比如Token以及响应状态码是否是200。出现403大概率是跨域配置问题出现401则是登录认证没过这两类问题在开发初期几乎每天都会遇到。5. 常见问题与排查技巧实录5.1 我实际踩过的四个坑这个项目开发过程中我踩过不少坑挑四个最有代表性的跟大家分享。第一个坑是时间冲突校验遗漏了订单状态过滤。初版冲突检测的SQL没有加status IN (1, 2)这个条件结果已取消的订单还占用着座位的时间段导致用户无法再次预约。这个问题非常隐蔽因为单测数据里不会特意放一条已取消订单去测试冲突。排查了很久才发现解决方式就是给countConflict加状态过滤并且给status字段建立索引避免数据量大了以后条件过滤变慢。第二个坑是并发预约超卖。开发阶段用Postman做并发测试时同一个座位同一时间段发出了10个预约请求竟然全部返回成功生成了10条预约记录。原因就是事务隔离级别和锁的机制没有配合好。最终方案是加数据库唯一索引兜底索引字段用了seat_id reserve_date start_time end_time的组合同时在插入前用catch捕获DuplicateKeyException转成友好的业务提示“该时段已被抢占请重新选择”。这里要记得加了唯一索引以后已取消的订单也会占用唯一索引所以需要在取消订单时同时删除或改变冲突键我采取的是在唯一索引的字段中把status加进去让不同状态的订单不冲突。第三个坑是前后端联调时的日期格式问题。前端传递的reserveDate格式是yyyy-MM-dd但后端实体里如果用了Date类型Spring默认的JSON序列化格式是时间戳导致接口返回的数据前端显示成很长一串数字。解决方式是在application.yml里统一配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8如果不加time-zone服务器在国外或者时区跟东八区不一致时返回的时间会少8小时。这个问题在部署到云服务器后尤其容易暴露排查起来也很费劲。第四个坑是座位状态和预约状态的联动。座位停用后已有的未开始预约怎么办我最初的实现是直接不让停用有预约的座位但这样管理员操作很不方便。后来改成座位停用前系统自动检查是否有待确认或已确认的未开始订单如果有则提示管理员先处理这批订单避免影响用户到店。这个逻辑虽然简单但能避免很多客诉问题。5.2 常见问题速查表把开发过程中遇到的典型问题整理成一张速查表方便大家对照排查问题现象可能原因解决方案启动报错找不到数据源application.yml配置错误检查url、username、password确认数据库已建接口返回404Controller路由映射错误或类未扫描到检查RestController、RequestMapping注解确认启动类位置能扫描到预约失败提示“座位已被预约”但页面显示空闲缓存数据未刷新检查是否有Redis缓存清理缓存或在更新后主动删除缓存跨域请求被拦截CORS配置缺失配置CorsFilter或CorsConfig注意allowedOrigins和allowCredentials冲突时间显示少8小时时区配置问题数据库连接串加serverTimezoneAsia/ShanghaiJackson配置time-zone数据库唯一索引冲突抛异常并发超卖触发了兜底捕获DuplicateKeyException转换为友好提示取消订单后座位仍无法预约缺少时间冲突校验对status的判断修改countConflictSQL过滤掉已取消/已过期订单5.3 项目扩展的几个方向基础版本做完以后如果想在毕设答辩或面试中多展示一些亮点可以从这几个方向做扩展。一是接入Redis缓存。把“查询某日期某座位的时间占用情况”这个高频只读接口加一层缓存缓存key设计为seatId date2秒过期能显著减轻数据库压力。同时Redis还可以用来做座位状态的热点数据存储展示更实时。二是引入Spring Security或Sa-Token做登录认证和权限控制。当前版本我用的是简单的拦截器加Session够用但不优雅。加上Sa-Token后登录、鉴权、踢人下线的代码量很小还能在简历上多写一个技术点。三是增加自动过期任务。用Spring的Scheduled注解每天凌晨跑一次批处理把所有“预约日期小于当前日期且状态为待确认/已确认”的订单批量置为已完成或已过期。这个功能看起来简单但能体现你对真实业务中数据一致性的考量。四是座位使用数据报表。用ECharts展示各时段的预约热度、各区域的座位利用率甚至预测未来一周的预约量。这个功能虽然不复杂但视觉冲击力很强答辩时评委一般都会有兴趣看一下。说到底SpringBoot咖啡厅座位预约管理系统这个题目技术难度适中业务逻辑清晰非常适合用来沉淀一整套“从需求分析到数据库设计从接口实现到联调部署”的完整经验。只要把时间冲突检测、并发兜底、状态流转这几个核心点吃透不管是毕业答辩还是面试聊项目你都能讲得比别人更有底气。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进