2026/10/11 4:24:03

Spring Boot+Redis实现共享自习室座位预约选座系统

Spring Boot+Redis实现共享自习室座位预约选座系统 共享自习室的门店这两年越开越多但真正把座位管明白的没几家。前台手动排座、微信群接龙占座、到点了不知道谁还赖着不走这些场景我见的实在太多了。所以当有朋友提出要做一套共享自习室座位预约选座系统时我的第一反应不是兴奋而是想把这个行业的琐碎规则彻底捋清楚。基于Spring Boot Java技术栈配合MySQL和Redis把这套系统从零到一搭起来前后倒腾了差不多两个月。这篇文章就以这个项目为主线把预约、选座、签到、计时、释放这个完整闭环的落地过程做个复盘。不管你是要做课程设计、毕业设计还是真想开一家自习室用来管店这里面的设计思路和踩坑记录应该都能帮你省不少事。1. 项目背景与核心需求拆解1.1 共享自习室正面临的排座困境共享自习室这个模式在高校周边和一二线城市的写字楼里已经非常普遍。表面上看只是把一张桌子租出去但顾客一旦多起来问题就全冒出来了。我在线下几家自习室蹲过点最典型的三类乱象第一类是前台手动排座。顾客到店后在纸质表格上找位置高峰期十几个人围着前台登记速度慢还经常写错日期。有的店用Excel登记稍微有点素质但并发一高照样乱套。第二类是微信群接龙占座。群消息刷得快有人早上接龙占了晚上黄金位结果人根本没来位置白白空一晚上。运营想让别人补位又怕得罪原预约的人夹在中间两头受气。第三类是没有统一的结束机制。到点以后没人提醒下一位顾客来了发现座位还是满的只能空等。久而久之老客户流失差评一堆店长还觉得是顾客不讲道理。这些问题归结起来就一句话座位资源是有限的、时间片是动态的、用户行为是不可预测的。要做好这套系统必须用一套清晰的状态机把座位整个生命周期管起来同时必须能扛住高峰期几百人同时刷座的并发压力。这也是当时项目立项时最核心的出发点。1.2 需求拆解预约、选座、签到、计时、释放把运营方那些口语化的需求描述翻译成系统功能其实就是一条完整业务闭环。我习惯拆成用户端和管理端来看这样聊需求的时候不跑偏。用户端的核心功能有查看开放的自习室和座位列表按日期和时段筛选空闲座位通过可视化座位图选座类似电影院的选座页面提交预约订单生成电子回执到店后扫码或输入订单号签到系统自动记录签到时间并开始计时结束学习时点击离座系统结算费用并释放座位管理端的功能则集中在维护多个自习室、多个房间和座位的基础信息实时查看座位使用状态和用户订单处理迟到了不签到、超过预约时间不离开等异常订单查询每天的座位使用率、时段热度、营收等统计数据这些需求整理成下表方便后面做模块划分和排期模块功能点优先级用户认证注册、登录、身份校验高座位预约日期时段选择、选座、订单提交高签到计时到店签到、计时、离座结算高异常处理超时释放、迟到判定、取消预约高管理后台座位维护、订单管理、数据统计中1.3 把完整业务流程画成人话版本这个系统的核心链路是这样的注册登录 - 选择日期时段 - 查看座位图 - 选择座位 - 提交预约 - 到店签到 - 计时开始 - 结束离座 - 释放座位 - 生成订单记录。这里有一个特别容易忽略的点预约成功并不等于座位马上归你了。绝大多数线下自习室允许提前一天到两天预约但要求到场后十五分钟内签到否则订单自动取消。这个预约保留期的设定是整个业务里最容易被拍脑袋定、也最容易被人钻空子的规则。后面的预约状态机、超时释放逻辑全部围绕这一条规则展开。从用户角度这套流程和机票值机、影院选座是一模一样的交互心智不需要额外教育成本。这也是为什么整个系统采用线上选座方案对运营方和用户方都友好。2. 技术选型与总体架构设计2.1 为什么选Spring Boot而不是别的什么题目定的是Spring Boot加Java这个选择其实非常合理。我自己做同类管理型业务系统的感受是Java生态的成熟度确实高特别是遇到下面这些情况的时候起步快。Maven拉几个starter依赖内嵌Tomcat打包成jar直接跑部署成本低。生态全。Spring Security做登录鉴权、Spring Data JPA或MyBatis做持久层、Spring Scheduled做定时任务都是现成方案。事务管理方便。Transactional注解一把梭业务失败自动回滚不用自己写一堆补偿逻辑。团队协作友好。分层结构清晰后来交接到别人手里维护不至于看不懂。选Spring Boot还有一个隐藏好处是招人容易。哪怕是小型创业团队拉一个会Java的开发者比拉一个会小众技术栈的心态要稳得多。后续系统不再是一个人守着也方便别人接手。2.2 系统分层与项目结构项目的包结构我习惯这样安排com.example.studyspace ├── controller // 接口层处理HTTP请求 ├── service // 业务逻辑层接口 实现类 ├── mapper // 数据库访问层 ├── entity // 实体类 ├── dto // 请求响应对象 ├── config // 全局配置 ├── common // 通用返回结果、异常定义、工具类 └── task // 定时任务这种经典分层的价值不在于装样子而是把职责切清楚。Controller只做参数校验和返回包装Service负责核心业务判断和事务控制Mapper死磕SQL。出了问题从报错位置能直接定位是哪一层的锅改起来不牵连别的地方。config目录里一般要放三样东西跨域配置、Redis序列化配置、自动填充配置。比如created_at和updated_at这种字段全部用自动填充策略统一处理绝不在业务代码里一个个set省事不说还不会漏。2.3 关键中间件选型与理由数据库方面MySQL作为主力这类系统的数据量远没到分库分表的水平单库单表加合理索引完全能撑住。关键是索引别乱建也别不建。Redis在这个项目里的用处主要有两块座位锁定。用户提交预约时用分布式锁防止同一个座位被多人同时抢到锁粒度控制在单个座位。热点数据缓存。座位状态、房间信息这类高频读取的数据放进缓存避免每次都穿透数据库。定时任务直接用Spring自带的Scheduled就够不需要引入复杂的分布式调度框架。但要注意系统单机部署时Scheduled完全没问题一旦多实例部署同一个任务可能被多个节点重复执行后面专门聊这个坑。3. 数据库设计与预约状态机3.1 核心表结构设计我把系统的表分成四类用户、座位、预约、使用流水。这四类能覆盖日常业务又不会让表关系乱成一锅粥。用户表userCREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, open_id VARCHAR(64) UNIQUE, nickname VARCHAR(64), phone VARCHAR(20), status TINYINT DEFAULT 1, created_at DATETIME, updated_at DATETIME );open_id存的是第三方平台返回的唯一标识手机号留作运营通知和找回账号用status控制账号是否被禁用。座位表seatCREATE TABLE seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_id BIGINT NOT NULL, seat_no VARCHAR(20) NOT NULL, row_no INT, column_no INT, has_power TINYINT DEFAULT 1, is_window TINYINT DEFAULT 0, status TINYINT DEFAULT 0, version INT DEFAULT 0 );room_id关联到自习室seat_no是座位编号row_no和column_no用于前端渲染座位图时定位status表示空闲、使用中、维护中version是乐观锁版本号。预约表reservation是核心CREATE TABLE reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) UNIQUE, user_id BIGINT, seat_id BIGINT, reservation_date DATE, start_time DATETIME, end_time DATETIME, status TINYINT DEFAULT 0, check_in_time DATETIME, amount DECIMAL(10,2), created_at DATETIME, updated_at DATETIME, UNIQUE KEY uk_seat_date (seat_id, reservation_date, start_time) );唯一索引加在(seat_id, reservation_date, start_time)从数据库层面保证同一个座位同一个开始时间只能有一条记录。这个索引非常重要后面并发章节会反复提到。使用记录表seat_usage_record用来落账CREATE TABLE seat_usage_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT, seat_id BIGINT, reservation_id BIGINT, start_time DATETIME, end_time DATETIME, duration_minutes INT, amount DECIMAL(10,2) );临时预约最终都必须落地成一条使用流水方便后续做收入统计和座位利用率分析。另外我强烈建议加一张座位日志表记录每次状态变更的actor_id、action、before_status、after_status。一开始觉得多余等线上出问题需要追溯是谁动了某个座位状态的时候你就知道它有多值钱了。3.2 预约状态机的流转规则状态字段我用int类型代码里定义枚举。比字符串省空间也更严谨。状态定义如下0待签到预约成功但人还没到1已签到人到店了开始计时2使用中计时进行中3已完成流程正常结束座位释放4已取消用户主动取消5已超时预约后没在规定时间签到系统自动取消正常流转路径是0 - 1 - 2 - 3。异常路径是0 - 40 - 5。这个状态机是整个业务逻辑的中枢代码里所有地方都不允许跳状态更不允许反向流转。有同学问既然点了签到就开始计时为什么还要区分已签到和使用中实际场景里用户在门口扫码签到后可能还要花两分钟走到座位、放下书包这个缓冲期是有意义的。同时后台也能区别人已到但还没坐下和正在安心学习两种状态方便管理端干预。不过为了简化流程很多系统直接把这两个合并成一个状态只要业务逻辑自洽也可以。3.3 业务规则怎么落地成约束规则落地分三个层次。第一层是数据库约束。比如唯一索引兜底冲突非空约束杜绝脏数据。最简单的排座规则一个座位一天最多一个用户的一个主预约就靠唯一索引实现。第二层是业务代码判断。比如查一下该时间段是否冲突、用户当天预约次数是否超限、结束时间是否晚于开始时间。这些判断和数据库约束是互补关系数据库约束管不住灵活的业务分支代码判断管不住异常流量两层都上才安全。第三层是接口参数校验。比如预约日期不能是过去日期、最早只能提前两天预约、单次预约时长最短两小时最长八小时。这些规则用注解或者手动校验都行关键是别漏。三层都堵上以后绝大多数异常数据进不了库。我见过不少项目只做接口校验数据库索引一个都不加并发测试一跑半夜飙出一堆重复预约记录。索引这种东西建立起来不要心疼空间关键时刻真的能救命。4. 核心功能模块的实现细节4.1 座位列表查询与座位图渲染座位图在前端看起来是长方形布局每个座位是一个小方块空闲显示绿色、占用显示灰色、维护中显示橙色。前端怎么拿到座位数据后端提供一个接口返回整个房间的座位JSON数组每个元素包含seatId、seatNo、rowIndex、colIndex、status。后端实现其实不复杂关键是在查询时要把当前时间段已经被预约的座位过滤掉。SQL大致是这样SELECT s.*, CASE WHEN s.status 2 THEN 2 WHEN EXISTS( SELECT 1 FROM reservation r WHERE r.seat_id s.id AND r.reservation_date #{date} AND r.status IN (0, 1, 2) AND NOT (#{endTime} r.start_time OR #{startTime} r.end_time) ) THEN 1 ELSE s.status END AS display_status FROM seat s WHERE s.room_id #{roomId}时间冲突判断的写法是一句话开始时间大于等于别人的结束时间或者结束时间小于等于别人的开始时间两个同时不满足就说明冲突。这个逻辑看起来简单但它决定了后面所有并发判断的基础。4.2 预约下单的完整实现预约是整个系统最核心的接口也是最容易出问题的接口。我直接贴当时用的关键逻辑Override Transactional(rollbackFor Exception.class) public ReservationResult createReservation(CreateReservationDTO dto, Long userId) { String lockKey lock:seat: dto.getSeatId(); boolean lock redisLock.tryLock(lockKey, 5, TimeUnit.SECONDS); if (!lock) { return ReservationResult.fail(系统繁忙请稍后再试); } try { Seat seat seatMapper.selectBySeatIdForUpdate(dto.getSeatId()); if (seat null || !seat.canReserve()) { return ReservationResult.fail(该座位暂不可预约); } Long conflict reservationMapper.countConflict( dto.getSeatId(), dto.getReservationDate(), dto.getStartTime(), dto.getEndTime()); if (conflict ! null conflict 0) { return ReservationResult.fail(该时段已被他人预约); } Long dayCount reservationMapper.countByUserAndDate(userId, dto.getReservationDate()); if (dayCount ! null dayCount 3) { return ReservationResult.fail(每人每天最多预约3次); } Reservation reservation new Reservation(); reservation.setOrderNo(generateOrderNo()); reservation.setUserId(userId); reservation.setSeatId(dto.getSeatId()); reservation.setReservationDate(dto.getReservationDate()); reservation.setStartTime(dto.getStartTime()); reservation.setEndTime(dto.getEndTime()); reservation.setStatus(ReservationStatus.PENDING); reservationMapper.insert(reservation); seatMapper.updateStatusById(dto.getSeatId(), SeatStatus.OCCUPIED); return ReservationResult.ok(reservation.getId()); } finally { redisLock.unlock(lockKey); } }这段代码有几个关键细节。第一Redis锁获取失败时不要无限等待直接告诉用户系统繁忙让用户再点一次。因为抢座动作本身频率不高锁持有时间极短失败重试的体验比重试用复杂算法要好。第二在Redis锁之内还做了一层数据库行锁查询。很多同学会问有Redis锁不就行了为什么还要select ... for update因为Redis锁只能保证单一JVM实例内的线程互斥如果以后系统改成多实例部署或者Redis意外宕机兜底的就是数据库这一层行锁。业务数据安全永远是第一位不能拿缓存中间件当唯一防线。第三新建订单和修改座位状态必须在同一个事务里。如果订单插入成功座位状态更新失败事务回滚两边都不会留脏数据。Transactional(rollbackFor Exception.class) 里的rollbackFor参数非常容易被忽略默认只回滚RuntimeException而业务异常往往是自定义Exception。忘了设置这个参数轻则数据不一致重则重复占座。4.3 签到计时与离座释放签到的流程比较简单用户到店后通过订单号或扫码找到待签到的预约校验预约状态必须是0然后把状态改成1记录签到时间。public void checkIn(Long reservationId) { Reservation r reservationMapper.selectById(reservationId); if (r null || !r.getStatus().equals(ReservationStatus.PENDING.getCode())) { throw new BizException(预约不存在或状态不允许签到); } long diffMinutes getMinutesBetween(new Date(), r.getStartTime()); // 如果距开始时间超过10分钟不允许提前签到 if (diffMinutes 10) { throw new BizException(尚未到签到时间); } // 如果开始时间已过则标记迟到 boolean isLate r.getStartTime().before(new Date()); r.setStatus(ReservationStatus.SIGNED.getCode()); r.setCheckInTime(new Date()); r.setLateFlag(isLate ? 1 : 0); reservationMapper.updateById(r); }结束释放的动作更简单但注意一点释放座位不能只把seat的status改成空闲一定要同时把使用记录落库、金额结算完成。否则后台统计出来的收入会和真实体验完全对不上。我的第一版就漏了这一步每天营收报表数据惨不忍睹折腾了两天才补上。结算时按分钟计费不满半小时按半小时算这个口径要在文档里和运营对齐。4.4 管理端功能与统计报表管理端我做了三个核心页面。第一个是房间与座位管理支持对座位做增删改查还能一键把某个区域设置成维护状态。这里的维护状态要设计成时间范围按小时生效不然运营改了忘记恢复座位就永远锁死了。第二个是预约订单管理按日期、状态、手机号搜索订单支持对异常订单做手动取消或强制结束。强制结束是个高风险操作原因记录和操作人记录必须落到日志表里方便审计。第三个是数据统计看板展示今日预约量、上座率、营收等指标。上座率的计算口径要和运营对齐否则拿到数据会被吐槽。我当时定的是当天实际签到订单数 / 当天可预约座位数量。口径定义清楚后写进项目文档里避免后续扯皮。5. 并发场景与核心难点避坑5.1 多个用户抢同一个座位怎么办自习室的热门时段就那几个晚上六点到九点、周末全天。真到高峰期同一秒可能有三五个用户同时来抢同一个靠窗带插座的位子。如果不做控制三个请求一起进来数据库查询都发现座位空闲然后各自插入预约记录这就出事故了。常用的控制手段有三种乐观锁。给座位表加version字段更新时校验version。update … where id ? and version #{oldVersion}影响行数为0就说明被别人改过了重试或者直接提示失败。悲观锁。select ... for update把座位行锁住后面要抢这个座位的请求等锁释放后才能接手相当于在数据库层排了个队。分布式锁。用Redis的SET NX EX实现互斥锁锁的粒度控制在单个座位抢锁成功才能进业务逻辑。我最终的方案是第三种为主、第二种兜底。理由前面说过分布式锁的侵入小锁和业务逻辑天然解耦未来多实例扩容不需要改代码。数据库行锁的成本也不高给核心资源上了一道双保险。至于乐观锁在这种场景下不太适合因为预约一次操作涉及多张表光对seat表加版本号无法控制reservation表层面的冲突反而容易把事情搞复杂。5.2 预约超时自动释放怎样才靠谱预约成功但用户不来座位不能一直占着。这里常用的有两条路线。第一条是轮询扫描。写一个定时任务每过一分钟扫一次预约表把超过签到截止时间仍处于待签到的订单标记为超时同时释放座位。优点是简单直接缺点是存在最多一分钟的延迟。第二条是延迟消息。用消息队列的延迟队列预约成功后投递一个到点检查的消息时间到了自动触发。这个方案精准但项目本身如果没有引入MQ为这一个功能额外部署一套消息中间件性价比不高。我当时选了轮询扫描重点是要解决定时任务多实例重复执行的问题。做法是用Redis分布式锁包一层每个任务执行前先抢锁抢到了就执行抢不到说明另一个实例已经在跑直接跳过。这个设计很简单但避免了重复释放座位、重复取消订单这类恶心问题。5.3 缓存和数据库的一致性问题座位状态放进Redis缓存后查询速度确实快但缓存更新这个环节非常容易出幺蛾子。最典型的现象明明有人预约了座位前端页面还显示空闲用户点进去提交订单后端又提示预约失败。体验极差。我用的方案是标准的Cache Aside模式。查询时先读缓存缓存没有就读数据库把结果回填缓存更新时先更新数据库再删除缓存。这里宁可删缓存也不要更新缓存因为下次查询会自动重建不会出现半个字段是旧值、半个字段是新值的诡异状态。删除缓存失败的情况确实存在概率不高但真遇到了可以给缓存设置一个短过期时间兜底比如五分钟。就算某个瞬间缓存没删掉顶多五分钟内看到旧状态业务上完全可接受。这里还要分清数据的一致性要求座位占用状态这种强一致要求的数据不设过期时间靠主动删除保证准确用户昵称头像这种弱一致数据缓存三十分钟完全没问题。6. 常见问题与排查技巧实录6.1 实测踩坑清单整理了一下在测试和试运营阶段踩过的典型问题做成速查表方便对照排查。问题现象可能原因排查方向重复预约同一座位唯一索引缺失 / 锁粒度太大检查表索引核对锁的key是否到座位级超时释放后用户还能签到定时任务扫描与签到接口并发竞争签到接口加二次状态校验更新改成条件更新座位图状态长时间不更新缓存删除失败检查缓存失效策略和执行日志订单已插入但事务没回滚Transactional缺少rollbackFor确认回滚异常类型凌晨之后日期显示错乱时区默认值不对统一数据库连接参数时区举一个具体的案例。运营反馈用户A约了下午三点的座位三点十分到店签到的时候系统提示预约已超时但后台订单状态还是待签到。我查下来发现定时任务扫描和签到接口之间没有状态校验定时任务读到待签到订单正要改状态时用户同时点了签到两个操作各写各的最终状态被覆盖成互相矛盾的结果。解决方法是给签到接口加二次校验同时把定时任务更新状态改为条件更新update reservation set status 5 where id #{id} and status 0。影响行数为0就说明已经被其他地方改过了任务直接放弃。这就是典型的并发更新竞争问题没有实际并发测试根本发现不了。6.2 排查工具与现场操作习惯给几个实打实有用的建议。第一日志里必须带订单号和用户标识。线上查问题一切从一条日志入手。如果没有订单号要把时间范围内的日志捞出来人工比对效率没法忍。我把日志统一格式化成[order_no...][user_id...]配合Logback的pattern直接打出。第二在Redis预留手工操作的空间。比如座位卡在异常状态了用命令行直接删缓存、改状态比重启应用快太多。但临时操作一定要在值班文档里记录清楚不然等于留了个定时炸弹。第三定时任务日志要设置滚动策略。这类日志最容易增长一旦把磁盘撑满应用直接假死。日志框架里按天滚动加单文件大小上限这是基本操作。6.3 上线前必须验证的三件事开发完不叫完事下面三步走完才算稍微踏实。第一用压测工具模拟多用户同时抢同一个座位确认不会出现重复订单。并发线程数从10加到50再到200观察锁和事务能不能扛住。压测的时候别只看接口成功率也要看响应时间超过一秒就要检查是不是锁等待或者SQL慢查询。第二设置规则以外的异常路径比如预约完立刻取消、预约时间跨天、座位被管理员标记成维护状态、用户重复点击签到按钮。把这些数据全跑一遍确认状态流转不会乱。第三做长时间运行测试连续跑两三天观察Redis内存和MySQL连接数是否稳定定时任务是否每天准点执行。很多问题只会在长时间运行后暴露比如内存泄漏、连接池不够用、缓存穿透。验收的时候别只跑半小时就算完。7. 个人实操心得与后续可扩展方向7.1 落地过程中的几点真实体会这套系统最花时间的不是功能开发而是把线下那些说不清道不明的运营规则翻译成代码逻辑。前期多花一天做业务流程梳理和状态图设计后期就能少折腾一个月。我的建议是拿到需求先画状态机把每个状态的流转条件、每条分支的入场/出场条件写清楚再动手写代码。状态图不完善写得越多的bug越多。第二个体会是别把简单问题复杂化。当时有同事建议用微服务架构每个模块独立部署、独立扩缩容。对一个小型自习室系统来说这是纯粹过度设计。单体应用加缓存加合理的SQL索引已经能扛到足够大的量级。架构是为业务服务的不是拿来炫技的。第三个体会是做技术选型时先问自己这系统以后会不会真的变大。如果只是门店级别的预约系统一台普通云服务器就够了如果打算做成SaaS平台那从一开始就要考虑多租户数据隔离和权限模型这部分设计又不能省。我的经验是先做单店版本跑通业务验证模型没问题再往上抽象。7.2 后续可以往哪些方向扩展如果这套系统要用于商业场景扩展方向其实不少。接第三方支付实现余额充值和在线支付完成从预约到结算的完整资金闭环。增加会员体系按时长包月、次卡、早鸟优惠等手段提高用户粘性配合营销活动。对接门口门禁或智能电源用户扫码签到后自动开灯通电离座后自动断电。增加硬件联动能显著提升体验。扩展成多门店版把自习室做成一个小型SaaS平台面向中小自习室老板提供订阅服务。扩展时记住一个原则新功能尽量独立成模块不要和现有预约主流程耦合太深。之前加会员体系时因为耦合太严重我被迫把预约服务重写了一遍。这个教训分享出来给大家避个雷。我个人实操下来的体会是预约系统的难点从来不在框架用得有多花而在于并发控制、状态流转和边界情况的处理。功能写出来只是第一步真正有价值的往往是测试阶段和试运营阶段暴露出来的问题。所以做同类项目时建议多留半个月给联调和压测这比前期赶进度重要得多。如果是课程设计或者毕业设计先把主体流程跑通再往里面点缀所谓的新颖功能骨架稳了内容才有地方生长。