ARTICLE DETAIL

资讯详情

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

SpringBoot + MyBatis Plus + Redis 构建咖啡厅座位预约系统:从数据库设计到并发控制实战

SpringBoot + MyBatis Plus + Redis 构建咖啡厅座位预约系统:从数据库设计到并发控制实战 这段时间把一个咖啡厅座位预约管理系统从需求梳理、数据库设计到前后端联调完整跑了一遍。项目本身不算多复杂但涉及的技术点非常典型SpringBoot做后端、MyBatis Plus操作数据库、Redis处理高并发预约、Vue3配Element Plus搭管理端。这种“XX管理系统”题目经常出现在课程设计和项目实战中但真正做过一遍会发现难点根本不在增删改查而在座位状态怎么管、预约冲突怎么防、事务边界怎么划。这篇记录我把整套设计思路、核心表结构、预约并发控制和踩坑过程都摊开讲给准备做SpringBoot预约类项目或者想系统学一遍Web后端开发的朋友做个参考。很多人写这类系统容易一上来就堆功能、堆代码最后能跑起来却说不清为什么这么设计。我建议反过来先把自己当成咖啡厅老板想清楚每天营业会遇到什么场景再推导功能和技术方案。这篇文章会按“业务痛点 - 技术选型 - 数据库设计 - 核心链路实现 - 问题排查”的顺序推进所有代码和SQL都基于一套能真实运行的项目结构你拿过去改改就能用。1. 项目概览与技术选型为什么用SpringBoot搭建预约系统1.1 咖啡厅座位预约到底要解决什么问题咖啡厅的座位资源本质上是“限时占用型资产”。周末下午两三点是高峰期顾客进店发现没位置只能扭头走人这对门店来说是实打实的损失。人工登记预约也有问题店员用本子记录信息不透明顾客打电话来问有没有靠窗位店员得跑现场看预约和实际到店情况对不上座位空着但系统里显示被预约翻台效率就上不去。所以这套系统要解决的第一个问题是“可视化”——把每个座位在每个时间段的状态放到线上顾客自己就能看、自己约。第二个问题是“防冲突”——同一个座位同一个时段不能卖两次这背后是并发控制是整个项目最核心也是最有技术含量的地方。第三个问题是“可追踪”——顾客来了怎么核销、没来怎么处理、哪个区域的座位利用率高这些数据可以帮助门店做经营决策。系统角色拆成两类就够普通用户顾客和管理员。顾客注册登录后可以按日期查看座位和时段提交预约、取消预约、查看历史记录管理员负责维护座位信息、核销预约、锁定/解锁座位、查看基础统计。至于会员积分、优惠券这些都属于锦上添花核心链路先把预约跑通扩展功能后续再加。这也是我给做类似项目朋友的第一条建议先做减法把主流程做扎实再谈加功能。1.2 技术选型SpringBoot MyBatis Plus Vue3 的组合逻辑选SpringBoot做后端基本没有悬念它已经成为Java Web开发的默认选项。最直观的好处是省配置以前做SSM整合要写一堆XML配置文件一个数据源配错都能折腾半天SpringBoot用自动配置把常用组件都预置好了内置Tomcat一个main方法启动整个Web服务这对个人项目和中小型团队来说体验是碾压级的。具体版本上我推荐SpringBoot 2.7.x。原因很简单2.7版本基于JDK 8兼容性最好大多数教程和资料也都是针对这个版本SpringBoot 3.x强制要求JDK 17及以上如果本机环境是JDK 8直接用3.x会遇到一堆无谓的环境兼容问题。当然如果你本来就是JDK 17环境那直接用3.x也没问题后续升级更平滑。前端配套方案管理端用Vue3 Element Plus。Element Plus是饿了么团队开源的后台组件库表格、表单、弹窗、日期选择器都是现成的写管理页面效率非常高。用户端则可以简单一些用原生HTML Bootstrap就足够因为顾客端核心操作就是“看座位 - 选时段 - 提交预约”页面复杂度不高没必要再套一层前端工程。如果你打算做小程序用户端可以换成uni-app接口不用变。数据层选MyBatis Plus而不是Spring Data JPA主要是国内项目习惯和SQL可控性的考虑。MyBatis Plus在MyBatis基础上封装了常用CRUD方法单表操作不需要手写SQL复杂查询又能自己写XML属于“既要效率又要灵活”的折中选择。配合代码生成器建好表之后Mapper、Service、Controller的骨架代码几分钟就能生成完省下来的时间足够去打磨核心业务逻辑。数据库用MySQL 8.x缓存和分布式锁用Redis。Redis不是必选项如果本机没有Redis环境纯靠数据库的乐观锁也能保证预约不冲突但Redis在缓存座位状态、分布式锁这两个场景下效果立竿见影建议有条件就加上。2. 数据库设计一套支撑预约业务的核心表结构2.1 从业务场景推导实体关系数据库设计是整个系统的地基设计得好不好直接决定后面代码难不难写。我刚做这类项目时犯过一个典型错误想直接在seat表上放一个status字段表示“空闲/占用”。运行几天就发现不对了——同一个座位今天下午空闲明天上午有人约了下午又空闲单靠一个字段根本表达不了这种“不同时间不同状态”的信息。正确的做法是引入“座位时段”这个概念。就像电影院卖票一样座位是资源场次是时间维度某座位某场次才是一次可售卖的商品。映射到咖啡厅场景一张靠窗桌是座位2月14日14:00-15:30就是一个时段这两个维度组合唯一确定一次可预约的机会。实体关系梳理下来其实只有四个核心表用户表user、座位表seat、座位时段表seat_slot、预约记录表reservation。关系是一个用户对应多条预约记录一个座位对应多个座位时段一个座位时段可以被预约多次但同一个用户不能重复约同一个座位同一个时段。为什么座位时段会被预约多次因为一次预约可能约多个人简单起见可以限制一个订单只约一个时段如果允许一个用户一次约多个座位那就是购票逻辑可以再加订单详情表这里不展开。2.2 核心表结构与字段设计直接上建表SQL这些是我在项目中实际用的结构字段和索引都经过验证。-- 用户表 CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL COMMENT 用户名, password VARCHAR(100) NOT NULL COMMENT 密码(BCrypt加密), phone VARCHAR(20) DEFAULT NULL COMMENT 手机号, nickname VARCHAR(50) DEFAULT NULL COMMENT 昵称, role TINYINT NOT NULL DEFAULT 0 COMMENT 角色 0-普通用户 1-管理员, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态 1-正常 0-禁用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0 COMMENT 逻辑删除标记, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;-- 座位表 CREATE TABLE seat ( id BIGINT NOT NULL AUTO_INCREMENT, seat_no VARCHAR(20) NOT NULL COMMENT 座位编号如A01, area VARCHAR(30) DEFAULT NULL COMMENT 区域靠窗/卡座/吧台/包间, capacity INT NOT NULL DEFAULT 1 COMMENT 可容纳人数, status TINYINT NOT NULL DEFAULT 0 COMMENT 座位状态 0-正常营业 1-锁定/维护, description VARCHAR(255) DEFAULT NULL COMMENT 座位描述, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_seat_no (seat_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT座位表;-- 座位时段表核心表 CREATE TABLE seat_slot ( id BIGINT NOT NULL AUTO_INCREMENT, seat_id BIGINT NOT NULL COMMENT 座位ID, slot_date DATE NOT NULL COMMENT 预约日期, start_time TIME NOT NULL COMMENT 开始时间, end_time TIME NOT NULL COMMENT 结束时间, status TINYINT NOT NULL DEFAULT 0 COMMENT 时段状态 0-可预约 1-已预约 2-锁定, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_seat_date_time (seat_id, slot_date, start_time), KEY idx_slot_date_status (slot_date, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT座位时段表;-- 预约记录表 CREATE TABLE reservation ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 预约单号, user_id BIGINT NOT NULL COMMENT 预约用户ID, seat_id BIGINT NOT NULL COMMENT 座位ID, slot_id BIGINT NOT NULL COMMENT 座位时段ID, reserve_date DATE NOT NULL COMMENT 预约日期, start_time TIME NOT NULL COMMENT 开始时间, end_time TIME NOT NULL COMMENT 结束时间, status TINYINT NOT NULL DEFAULT 0 COMMENT 预约状态 0-待核销 1-已完成 2-已取消 3-爽约, remark VARCHAR(255) DEFAULT NULL COMMENT 备注, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_slot_id (slot_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT预约记录表;说说几个关键设计点。seat_slot表是整个系统的核心。它把“座位”和“时间”解耦每个座位每天可以生成多个时段记录比如每个时段90分钟从10:00营业到21:00就能生成8条左右。唯一约束uk_seat_date_time(seat_id, slot_date, start_time)是防同座位同时段重复预约的数据库底线这比在应用层做判断可靠得多。status和version字段配合使用后面讲并发控制时会详细说明。reservation表里冗余了reserve_date、start_time、end_time字段。有人可能会问通过slot_id关联seat_slot表不就能查出来吗为什么要冗余冗余的考虑是查询效率和数据快照。预约记录是历史数据如果座位时段表后面调整了时间历史预约记录跟着变就出问题了。冗余字段能保证预约记录查出来永远是当时预约的样子。order_no预约单号用唯一索引这个号要保证全局唯一。我用的生成方案是时间戳 用户ID 随机数用Hutool工具类的IdUtil.getSnowflakeNextIdStr()生成雪花ID也行字符串不超过32位可读性和唯一性都兼顾。2.3 逻辑删除与索引设计的一些经验所有表都带了deleted字段做逻辑删除这是ORM框架的常见做法。好处有两点一是防止物理删除导致历史关联数据断裂二是方便以后做数据分析和统计。比如你想统计上个月的预约总量如果用户把预约记录物理删了数据就缺失了。MyBatis Plus里只要在实体类字段上加TableLogic注解再配合全局配置所有删除操作会自动变成UPDATE ... SET deleted 1查询自动加WHERE deleted 0不需要手动干预。索引设计这块我的经验是“按需添加宁缺毋滥”。很多初学者喜欢把所有字段都加索引觉得能加速查询但实际上索引会拖慢插入和更新速度还占用磁盘空间。这个项目里真正高频的查询路径就几条用户查自己的预约记录user_id索引、查某座位某天的时段seat_id slot_date联合索引但已经有唯一索引覆盖了前两列所以额外索引不需要、按日期查可预约时段slot_date status索引。这些建上就够了。order_no作为业务单号经常按单号精确查询所以也建了唯一索引。3. 核心功能实现预约流程与并发锁的落地3.1 项目初始化和依赖配置创建一个SpringBoot项目最省事的方式是直接用IDEA的Spring Initializr也可以通过 start.spring.io 生成后导入。我习惯在创建时只需要勾选Spring Web其余依赖后面在pom.xml里手动添加这样对每个依赖的作用心里更有数。核心依赖如下dependencies !-- Web 启动器 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MyBatis Plus -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency !-- Redis -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency !-- Lombok 简化实体类 -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency !-- JWT 鉴权 -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency !-- Hutool 工具类 -- dependency groupIdcn.hutool/groupId artifactIdhutool-all/artifactId version5.8.18/version /dependency /dependencies为什么加Hutool因为这个项目里要用到雪花ID生成预约单号、日期时间处理、随机数等工具方法自己写容易出边界问题Hutool封装得够全个人项目用起来很方便。application.yml配置如下server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/cafe_reservation?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 0 timeout: 3000ms mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0数据源URL里的serverTimezoneAsia/Shanghai一定要加上MySQL 8.x驱动默认时区和本地有偏差不加这个参数查出来的时间可能差8小时。map-underscore-to-camel-case开启后数据库的create_time字段自动映射到Java的createTime属性免去手写resultMap。开发阶段打开SQL日志log-impl方便定位问题上线后记得关掉以免日志刷屏。3.2 座位状态机设计从空闲到占用的完整流转状态机是这个项目业务逻辑的灵魂。初学者最容易犯的错是状态散落在各个Controller里今天在这里改一下明天在那里改一下最后状态流转完全失控。我建议开工之前先把状态流转图画清楚代码实现只是把图翻译出来而已。座位状态分成三个维度座位本身的状态正常/维护、座位时段的状态可预约/已预约/锁定、预约记录的状态待核销/已完成/已取消/爽约。这三者之间有联动关系但各自独立。时段状态流转当前状态触发动作下一状态可预约用户提交预约成功已预约可预约管理员手动锁定锁定锁定管理员解锁可预约已预约管理员核销用户到店预约记录变为已完成时段释放回可预约已预约用户取消预约预约记录变为已取消时段释放回可预约已预约超过预约开始时间30分钟未核销预约记录变为爽约时段释放回可预约注意一个细节预约完成后时段状态是否马上释放我的处理是核销/取消/爽约的同时把seat_slot状态改回0可预约这样当天如果有空位还能继续卖。但真实业务里一个时段被核销后到时段结束前能不能再次预约取决于门店规则。如果你希望做得严谨可以在seat_slot表再加一个is_expired字段表示时段是否已过或者按当前时间和时段结束时间判断这个可以按需求灵活调整。在Service层实现时状态的修改必须封装在对应的方法内部对外只暴露业务动作。比如cancelReservation(Long userId, Long reservationId)方法内部完成“校验-改预约状态-改时段状态”不允许Controller里直接调Mapper改状态。这样做的目的是让状态流转逻辑集中、可审计出问题时只要看一个类就能理清全链路。3.3 高并发时刻防止“超卖”的三层防线这节是整个项目最有技术含量的地方。场景是这样的周末下午最热门的靠窗卡座放出来3个时段几十个用户同时刷到并提交预约这时候系统必须保证同一时段只能被一个用户预约成功。如果不加控制常规的“先查状态再插入”逻辑在高并发下一定会出问题——两个事务同时查到时段状态是“可预约”同时执行插入数据库里就出现了两条对同时段的预约记录这在业务上就是“超卖”。我在实现中用了三层防线每一层解决不同层面的问题。第一层数据库唯一约束兜底。这是最底层也是永远不能丢的防线。seat_slot表上uk_seat_date_time(seat_id, slot_date, start_time)唯一索引是物理层面的保证即使应用层代码有Bug唯一索引也会让重复插入直接报DuplicateKeyException。这一层不需要写额外代码但一定要建好索引。可以看到很多线上事故最后都是靠这层兜底才没造成更大损失。第二层乐观锁。业务上更友好的方式是在更新时段状态时带上版本号条件。MyBatis Plus的Version注解可以实现但它的使用有一个前置条件更新时UPDATE语句会自动带上WHERE version 旧版本号更新成功后版本号加1。我们用这个特性把“预约操作”和“状态更新”合二为一// 定义在 SeatSlotMapper 中也可以用 MyBatis Plus 的 UpdateWrapper Update(UPDATE seat_slot SET status 1, version version 1 WHERE id #{slotId} AND status 0 AND deleted 0) int occupySlot(Param(slotId) Long slotId);这个方法返回int类型的受影响行数。如果返回1说明这个时段从“可预约”成功变为“已预约”当前用户抢到了如果返回0说明这个时段已经被人约走了直接提示用户“手速慢了已被预约”。整个判断是原子性的数据库的行锁保证同一时刻只有一个事务能更新成功不需要写显式锁代码也不用SELECT ... FOR UPDATE。为什么不推荐悲观锁SELECT FOR UPDATE会锁住一行直到事务提交在高并发场景下容易造成锁等待堆积而且需要细心管理事务边界稍不注意就出现锁未释放的问题。乐观锁在冲突不极端激烈的场景下性能更好逻辑也更简洁。第三层Redis分布式锁。如果你预期某个时段的并发抢购特别激烈比如做个促销活动可以在乐观锁前面再加一层Redis锁把同一时段的预约请求先串行化降低数据库压力同时给用户更快的失败反馈。实现可以用Redis的SETNX命令或者直接用Spring Data Redis的opsForValue().setIfAbsent()。下面是一个简化版实现public boolean tryLock(String key, String requestId, long expireSeconds) { // 只有设置成功返回true保证同一时刻只有一个线程能拿到锁 Boolean success stringRedisTemplate.opsForValue() .setIfAbsent(key, requestId, Duration.ofSeconds(expireSeconds)); return Boolean.TRUE.equals(success); } public void unlock(String key, String requestId) { String value stringRedisTemplate.opsForValue().get(key); if (requestId.equals(value)) { stringRedisTemplate.delete(key); // 只用持有锁的请求能解锁 } }注意Redis锁容易踩的坑一是锁的key一定要细粒度到具体座位时段比如lock:seat_slot:123不能全局一把锁否则一个用户的预约失败会阻塞所有用户的预约请求二是必须设置过期时间防止持有锁的线程突然宕机导致死锁三是解锁前要校验requestId防止误解了别人刚设置的锁。3.4 接口设计与统一返回结构后端接口设计得好不好直接影响前端联调效率。我习惯先定一套统一的返回结构和错误码再写具体接口。项目里定义了一个ResultT泛型类Data public class ResultT { private Integer code; // 200 成功500 业务失败401 未登录 private String message; // 提示信息 private T data; // 业务数据 public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }这样前后端约定就变得很简单无论什么接口返回的JSON格式都是{code, message, data}前端只需要在response拦截器里统一判断code是否为200不是的话直接弹出message。主要接口清单如下请求方法路径功能角色POST/api/auth/register用户注册公开POST/api/auth/login登录并获取JWT公开GET/api/seat/list查询所有座位登录用户GET/api/seat/slots查询某座位某天的时段登录用户POST/api/reservation创建预约登录用户GET/api/reservation/my我的预约记录登录用户POST/api/reservation/{id}/cancel取消预约登录用户POST/api/reservation/{id}/checkin核销预约管理员PUT/api/seat/{id}/lock锁定座位管理员PUT/api/seat/{id}/unlock解锁座位管理员GET/api/admin/statistics预约数据统计管理员JWT鉴权我用了一个简单的拦截器实现登录成功后签发token客户端请求头带上Authorization: Bearer token拦截器校验token并解析出用户ID放入请求上下文。排除掉登录注册和座位列表这些公开接口其余接口都要走鉴权。管理员接口额外校验role 1不通过就返回403。这块逻辑不多但却是管理系统安全性的基础不能省。3.5 预约核心方法的完整实现预约是核心中的核心我把完整流程串起来方便你对照实现Service RequiredArgsConstructor public class ReservationServiceImpl implements ReservationService { private final SeatSlotMapper seatSlotMapper; private final ReservationMapper reservationMapper; private final StringRedisTemplate stringRedisTemplate; Override Transactional(rollbackFor Exception.class) public ResultVoid createReservation(CreateReservationDTO dto, Long userId) { // 1. 检查用户是否有未核销的预约每人同时只能有一个有效预约规则可以根据门店调整 Long activeCount reservationMapper.selectCount(new LambdaQueryWrapperReservation() .eq(Reservation::getUserId, userId) .in(Reservation::getStatus, 0)); if (activeCount 0) { return Result.error(您有未完成的预约请先核销或取消后再预约); } // 2. 查时段信息并校验时间合法性 SeatSlot slot seatSlotMapper.selectById(dto.getSlotId()); if (slot null || slot.getStatus() ! 0) { return Result.error(该时段不可预约); } // 3. Redis分布式锁保护同一时段的预约请求串行化 String lockKey lock:seat_slot: slot.getId(); String requestId UUID.randomUUID().toString(); if (!tryLock(lockKey, requestId, 5)) { return Result.error(系统繁忙请稍后再试); } try { // 4. 乐观锁更新时段状态返回0说明被别人抢先 int rows seatSlotMapper.occupySlot(slot.getId()); if (rows 0) { return Result.error(手速慢了这个座位刚刚被约走了); } // 5. 构造预约记录并插入 Reservation reservation new Reservation(); reservation.setOrderNo(IdUtil.getSnowflakeNextIdStr()); reservation.setUserId(userId); reservation.setSeatId(slot.getSeatId()); reservation.setSlotId(slot.getId()); reservation.setReserveDate(slot.getSlotDate()); reservation.setStartTime(slot.getStartTime()); reservation.setEndTime(slot.getEndTime()); reservation.setStatus(0); reservationMapper.insert(reservation); return Result.success(null); } finally { // 6. 释放锁注意必须在事务提交之后释放 unlock(lockKey, requestId); } } }这里有几个细节值得展开讲。为什么需要Transactional因为“更新时段状态”和“插入预约记录”这两个数据库操作必须保证原子性要么都成功要么都失败。如果不加事务可能出现时段状态更新了但预约记录插入失败导致时段被无故占用或者反过来预约记录插入了但时段没有被更新导致同一时段被预约两次。Redis锁为什么在finally里释放但有人又说要在事务提交后释放这是个经典的坑。上面代码里Transactional的方法内finally块执行时Spring的事务是否已经提交答案是没有。Spring的事务提交发生在方法返回后、代理拦截器执行完事务管理器提交逻辑时才完成。所以finally里解锁可能会导致下一个请求在第一个事务还未提交时就进入数据库操作。不过由于有数据库层的唯一索引和乐观锁兜底最坏的情况是这个请求在数据库层也更新失败返回“已被预约”不会产生脏数据。如果你要做极致优化可以在事务提交后再释放锁用TransactionSynchronizationManager.registerSynchronization()在afterCommit里释放这对本项目来说属于可选优化不是必须的。为什么提前校验用户是否有未核销预约这是业务规则不是技术必需。咖啡厅场景下如果允许一个用户同时占多个时段的座位会出现“一个人约了所有位置”的极端情况。限制一个用户同时只有一个有效预约能有效提高座位利用率。当然这条规则要跟实际运营确认有些咖啡厅可能允许用户替朋友一起预约多桌。4. 常见问题排查与优化实录4.1 并发预约时出现超卖如何定位和修复我在开发和压测阶段确实遇到过超卖。现象是用JMeter模拟50个并发用户同时预约同一个座位时段数据库中出现了两条预约记录指向同一个slot_id。排查步骤如下。第一步看日志。MySQL打印的SQL显示两个事务先后执行了SELECT * FROM seat_slot WHERE id ?都查到了status 0随后都执行了INSERT INTO reservation两条插入都成功。这说明应用层没有做任何并发控制是一个典型的“先查后写”竞态。第二步检查数据库唯一约束。回头看我最初的seat_slot表设计当时只在seat_id和slot_date上建了唯一索引居然漏了start_time。没有唯一约束兜底单纯靠应用层判断并发下必然出问题。修复方案就是给表加上uk_seat_date_time(seat_id, slot_date, start_time)唯一索引。第三步加乐观锁。只靠唯一约束能保证同一时段不重复预约但如果时段状态从“可预约”变成“已预约”后另一个用户再查询时看到的还是“可预约”缓存没更新他还是会走插入逻辑最终在唯一索引上撞车报错。用户体验不好。所以在更新时段状态时用UPDATE ... WHERE status 0配合受影响行数判断在业务层面直接拦截给出友好提示。给做类似项目的朋友一个建议并发问题一定不要只靠代码“感觉”要设计一个可复现的压测场景。我是用JMeter模拟了50个线程同时点击“立即预约”再查数据库确认预约记录数这个方式简单直观几秒钟就能暴露问题。4.2 事务注解不生效的典型场景有朋友遇到过这种情况createReservation方法明明加了Transactional但运行时一个操作成功、另一个操作失败数据不一致排查发现事务根本没生效。最经典的原因是在同一个类内部调用this.createReservation()比如Controller里先调了Service的validate方法这个方法内部又调了同一个类的createReservation。Spring的事务是基于AOP动态代理实现的this调用的目标是当前对象而不是代理对象事务拦截器根本没有机会介入注解自然失效。解决方案有两种一是把内部调用拆到另一个Service类中保证createReservation是被Spring代理调用的二是通过AopContext.currentProxy()获取当前代理对象再调用。实际项目里我倾向第一种职责拆分更清晰也方便单独复用。排查这类问题还有个技巧看控制台日志里有没有打印“Transaction synchronization”相关输出如果连事务开始日志都没有十有八九是代理调用问题。4.3 Redis缓存与数据库数据不一致项目中我用Redis缓存了座位时段的状态用户高频查询“某天有哪些可预约时段”时直接走缓存减少数据库压力。但随之而来的问题是用户预约成功后时段状态变了Redis里还是旧数据其他用户仍然看到“可预约”点进去提交时才发现已经被约了。这个问题的本质是缓存与数据库的一致性问题。我在项目里用的是比较经典的Cache Aside Pattern读操作先读缓存缓存没有则读数据库再回写缓存写操作先更新数据库再删除缓存不是更新缓存。为什么是删除而不是更新因为更新缓存需要把整个对象的序列化做好而且并发写时最后更新的缓存值可能和数据库不一致删除缓存则简单很多下一次读请求会自动回填最新的数据。同时给缓存设置了10到30分钟的过期时间作为兜底即使某个key在极端情况下没有被正确删除也会因过期而自动淘汰。这种方案在业务可接受范围内简单实用。如果你要做更严格的一致性可以引入延迟双删或者订阅数据库binlog异步更新缓存但对咖啡厅座位预约这个场景来说属于过度设计不推荐。4.4 跨域和登录鉴权的几个小坑开发阶段用Vue3前端工程前后端分离部署最常遇到的就是跨域问题。浏览器拦截了前端请求打开控制台报CORS error。解决方案是在后端加一个全局CORS配置或者直接在Controller或启动类上加CrossOrigin。我建议用Configuration实现WebMvcConfigurer统一配置避免每次都要在方法上写注解Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowCredentials(true) .maxAge(3600); } }注意allowedOriginPatterns(*)和allowCredentials(true)不能同时使用旧版的allowedOrigins(*)因为配置了Credentials时浏览器要求Origin不能是通配符Spring Boot 2.4提供了allowedOriginPatterns来解决这个冲突。JWT鉴权另一个常见坑是前端通过axios发送请求时如果请求头没有携带Authorization后端拦截器会直接返回401但前端没有统一处理401跳回登录页导致用户看着页面结果所有操作都在报错。我的习惯是前端封装一个axios实例在response拦截器里判断code 401时清空token并跳转登录页同时用Element Plus的ElMessage给用户提示体验会好很多。4.5 时段生成和自动取消的定时任务设计最后补充一个容易被忽略的环节时段怎么生成如果让管理员每天手动创建座位时段那这个系统上线第一天就会把店员累死。我的做法是提供一个“按日期批量生成时段”的接口传入日期和座位ID列表后端起一个定时任务每天晚上自动为第二天生成所有座位的时段。Component RequiredArgsConstructor public class SlotGenerateTask { private final SeatMapper seatMapper; private final SeatSlotMapper seatSlotMapper; // 每天凌晨1点执行 Scheduled(cron 0 0 1 * * ?) public void generateNextDaySlots() { LocalDate nextDay LocalDate.now().plusDays(1); ListSeat seats seatMapper.selectList(null); for (Seat seat : seats) { if (seat.getStatus() 1) { continue; // 锁定维护的座位不生成时段 } generateSlotsForSeat(seat.getId(), nextDay); } } private void generateSlotsForSeat(Long seatId, LocalDate date) { // 从早上10:00到晚上21:00每90分钟一个时段 LocalTime start LocalTime.of(10, 0); LocalTime end LocalTime.of(21, 0); ListSeatSlot slots new ArrayList(); while (start.plusMinutes(90).isBefore(end) || start.plusMinutes(90).equals(end)) { SeatSlot slot new SeatSlot(); slot.setSeatId(seatId); slot.setSlotDate(date); slot.setStartTime(start); slot.setEndTime(start.plusMinutes(90)); slot.setStatus(0); slots.add(slot); start start.plusMinutes(90); } seatslotMapper.batchInsert(slots); } }执行前要判断当天时段是否已经生成过了避免重复插入撞唯一索引。可以查一下seat_slot表里该日期是否已有记录有就直接返回。定时任务启用需要在启动类加EnableScheduling注解。另外配合一个超时自动取消的定时任务比如每小时扫描一次reservation表把超过开始时间30分钟且状态仍为“待核销”的记录改成“爽约”同时释放对应的seat_slot状态这样整个业务闭环就完整了。整个项目做下来我最深的体会是技术选型不是越新越好而是要选自己最熟悉、最能把控的业务规则梳理永远比写代码更花时间状态机画清楚后后面的实现就是填肉并发控制别一上来就上重量级中间件从数据库唯一索引到乐观锁再到分布式锁按需逐层往上加才是务实路线。如果你准备做一个类似的预约管理系统我建议先把状态流转图和白板上的实体关系画明白再动手敲代码。后续可以扩展的方向也很多比如预约成功短信提醒、对接微信小程序、加入时段销售热力图。就我个人的经验来说这种管理系统类项目是最适合入门SpringBoot实战的题目——业务模型不复杂但该踩的坑基本都能踩一遍做完之后再去看Spring源码、JVM调优这些深水区至少已经知道实际运行的代码长什么样了。希望这篇记录能帮你少走一点弯路。
返回列表