做了这套系统之后,我有个很直接的感受:共享自习室的预约,难点从来不在“能选座”这三个字上,而在于选座之后整条链路——签到、暂离、释放、违约——有没有被有效地管起来。我见过一家备考自习室,座位全靠前台登记,管理员拿Excel记录谁坐哪,学生中午出去吃顿饭,位子就被另一个人占了,回来又是一顿扯皮。后来我按照“基于Spring Boot Java框架的共享自习室座位预约选座系统”这个思路帮他做了一套,从需求调研到上线部署一共花了一个多月。这篇文章就把这套系统的设计与实现完整复盘一遍,重点讲数据库设计、预约状态机、并发抢座的处理方式,以及那些不实际跑一遍很难发现的坑。建议准备做类似毕设课设的同学,或者刚接触企业级Java开发、想了解一个单体项目如何落地的读者,把这篇文章当作一份“踩坑版”设计文档来看。
1. 需求边界:先别急着敲代码,把“选座”变成一条完整业务链
很多人拿到这类题目,第一反应是:画个座位图,点一下预约,完事。但真去自习室蹲半天,你会发现问题远不止这些。
1.1 角色与地盘:谁在管理谁在使用
这套系统的用户,我把它分成三类:
- 普通用户:也就是真正来上自习的人。他们关心的是能不能快速找到空位、预约之后要不要签到、离开一会儿怎么保住座位。
- 前台管理员:负责座位维护、处理异常释放、查看预约记录。他们最关心的是“现在哪些座位被占了”“哪些人超时没来”。
- 系统管理员:管理自习室、配置预约规则、处理违约申诉。
对应到系统功能上,用户端就做几件事:查看自习室和座位图、选座预约、扫码签到、暂离、主动结座、查询违约记录。管理端则是:自习室和座位的增删改查、设置开放时段和预约规则(比如单次最长使用时间、可提前预约天数)、查看预约流水、手动释放异常座位。
权限这块我用的是RBAC的思路,JWT里带上角色信息,接口层用权限注解控制。不要一上来就上Spring Security那一套复杂的过滤器链,对这类系统来说,JWT加拦截器已经足够清晰,后面要扩展再换也来得及。
1.2 核心流程:从选座到离座,一个订单的生命周期
把整个流程走一遍,你会发现一条很清晰的业务主线:
- 用户选座,创建预约单,座位进入“锁定”状态。
- 系统给这个预约单设置一个签到截止时间,比如30分钟内必须到馆签到。
- 用户到馆后,打开小程序或H5扫码,校验通过后状态变为“使用中”。
- 用户中途要离开,可以选择“暂离”,座位保留一段时间。
- 暂离超时未归,座位被自动释放,并且记一次违约。
- 用户主动结座,或者到达预约结束时间,座位彻底释放。
这条链路里,最难处理的不是第1步,而是第4步和第5步——暂离策略和超时释放。如果只做“选座预约”,系统其实很简单,但加上“暂离保护”之后,你就要处理定时任务、状态冲突、违约判定,复杂度一下子上来了。我建议做毕设的时候,一定把这条完整链路跑通,答辩时的可说的东西也多很多。
1.3 哪些功能其实可以砍掉(需求减法)
做项目最怕需求失控。当时朋友列了一堆想法,什么AI推荐座位、自动测温、人脸识别,我全给砍了。人脸识别涉及隐私和设备改造成本,自习室场景根本没有必要;AI推荐座位听着酷,但用户习惯就是喜欢靠窗或角落,排序规则做好就够。
我觉得这类系统真正该保留的非功能需求就三条:并发场景下座位不超卖、预约状态一致性不出错、管理端能随时人工干预异常单。砍掉花哨功能,把核心链路做扎实,比堆砌功能有用得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型复盘:Spring Boot为主干,Redis和MySQL如何分工
关于技术栈,我做之前也纠结过一阵子,毕竟现在微服务到处都在讲。但冷静下来之后,最终选型很朴素:Spring Boot 2.7 + MyBatis-Plus + MySQL 8 + Redis,前端用Vue3搭了一个管理后台,用户端做了H5页面,后续要包小程序也方便。
2.1 后端框架:为什么Spring Boot足够,不必上微服务
自习室预约这个场景,本质是个中等规模的单体应用。一个座位就是一个资源,预约操作是短事务,就算一个学校有几千个座位、同时在线几百人,单机Spring Boot完全扛得住。
微服务呢?引入服务注册、配置中心、分布式事务这些,对这个小项目来说纯粹是给自己找麻烦。我当时跟朋友说得很直白:微服务带来的复杂度,在这个业务场景里没有收益,只有成本。Spring Boot的自动配置、起步依赖、内嵌容器,打成一个jar包丢服务器上就能跑,这已经是最适合的形态了。
持久层我选了MyBatis-Plus。说实话,用JPA也行,但MyBatis-Plus的优点在于单表CRUD不用写SQL,复杂查询又能自己控制SQL,排错清晰。预约系统里有不少多表关联查询,比如查某个自习室某天所有座位时段的占用情况,手写SQL会更可控。
2.2 缓存与数据库的定位:热门座位状态放Redis,预约流水放MySQL
这个系统里MySQL和Redis的职责,我分得很清楚:
- MySQL负责最终数据:用户、座位、座位时段、预约单、违约记录。
- Redis负责热数据:座位时段的实时状态缓存、分布式锁、登录令牌、验证码、暂离超时的延时处理。
为什么座位状态要放Redis?很简单,选座页面一打开,用户要看到整个自习室的座位状态图,几十上百个座位,如果每次都去数据库查,高峰期的查询压力不小。把座位时段状态缓存到Redis里,读接口直接查缓存,速度快很多。
但这里就引出一个经典问题:缓存和数据库的一致性。我的处理方式是写操作先更新数据库,事务提交后再删缓存或者更新缓存。如果事务回滚了,缓存还能保持旧值,不会出现“库是新的、缓存是旧的”这种情况。
2.3 权限认证:JWT + 拦截器 vs Spring Security的取舍
很多参考项目喜欢直接上Spring Security,但我个人觉得,预约系统这种角色少、接口权限规则简单的项目,用JWT + 自定义拦截器更轻量。
实现思路很直接:
- 登录成功之后用用户ID和角色生成JWT。
- 拦截器统一校验Token,解析出用户信息放到ThreadLocal。
- 需要管理员权限的接口,自己写个注解,拦截器里判断角色。
这里有一个安全细节容易忽略:密码存储用BCrypt加密,千万不要明文入库。我当时还给登录接口加了错误次数限制,连续失败几次就要等一段时间,防止有人爆破弱口令。
3. 数据库设计:所有并发难题,都在这些表结构里提前化解
预约系统的表结构,我建议按照“资源-订单-行为记录”这样一条线来设计。资源是座位和时段,订单是预约单,行为记录是违约和操作日志。
3.1 核心表:座位、预约、违约记录
先看最核心的几张表。用户表不展开说了,就是基础信息加角色、信用分。自习室表记录名称、位置、开放时段。重点说座位和预约。
座位表seat:id、自习室id、座位编号、排号、列号、是否靠窗、是否有电源、物理状态(正常/禁用)。注意,这里不要存“当前是否被占用”这种动态字段,因为座位是否被占要结合日期和时段来看。
座位时段表seat_period:id、座位id、日期、开始时间、结束时间、状态、版本号。这就是我把动态状态独立出来的核心,下面会细讲。
预约单appointment:id、用户id、自习室id、座位id、座位时段id、预约日期、开始时间、结束时间、状态、签到时间、释放时间、违约标记、创建时间。
违约记录violation:id、用户id、违约类型、关联预约单id、描述、创建时间。
这几个表建好之后,整个系统的数据骨架就出来了。后面的查询,比如“某自习室某天下午的空座”,就是查seat_period里状态为FREE的记录,再关联seat和room信息。
3.2 一个关键决定:座位状态为什么要拆到时段记录里
这是我在设计过程中最纠结的一个点。一开始想简单一点,直接在seat表上加一个status字段,0表示空闲、1表示占用。但仔细一想就会发现问题:同一个座位,上午被A预约了,下午被B预约了,晚自习又被C预约了。如果你只在seat上存一个状态,A占座之后,B想来约下午的座位,一看状态是占用,就直接放弃了,这明显不合理。
所以我把“座位”和“座位可预约时段”拆开。一个座位一天可以被切分成多个时段,每个时段独立管理状态。用户的每一次预约,实际锁定的是一个“座位+日期+时段”的组合。座位本身是静态资源,真正的动态资源是seat_period记录。
这样做还有一个好处:并发控制可以精确到时段。两个人抢同一个座位,但一个是上午一个是下午,互不干扰;两个人同时抢同一个座位的同一天同一时段,那才需要加锁。
3.3 字段冗余与状态枚举:宁可多存一个状态,也不要到处join
预约单里我冗余了user_name、room_name、seat_no这些字段。很多人会觉得违反第三范式,但在实际项目里,列表页要展示“张三预约了A自习室3排5座”,如果不冗余,每个列表查询都要join三张表。我选择在创建预约单的时候直接把冗余字段快照进去,查询省心很多,数据也不会乱变。
预约单的状态我用枚举管理,Java侧和数据库都用字符串存,避免魔法值:
- PENDING:待签到,用户选了座但还没到馆。
- USING:使用中,已经签到入座。
- SUSPEND:暂离中,座位给用户保留。
- FINISHED:正常结束,座位已释放。
- CANCELED:已取消,可能是用户主动取消或管理员取消。
- VIOLATION_END:违规释放,比如暂离超时。
注意,如果未来要做计费,预约单上还需要冗余预约时长和实际使用时长,这些字段可以后面加,不会影响已有的表结构。
4. 核心预约流程的状态机设计与接口落地
系统里最容易被问“怎么实现”的,就是选座、签到、暂离、释放这套状态流转。别小看这块,如果状态流转控制不好,会出现“座位被释放了预约单还在使用中”这种烂账。
4.1 状态机:预约单从PENDING到FINISHED的合法路径
我整理了一张状态流转表,开发时所有状态变更都要经过这个状态机校验:
| 当前状态 | 触发事件 | 下一状态 |
|---|---|---|
| PENDING | 到馆签到 | USING |
| PENDING | 超过签到截止时间 | CANCELED |
| PENDING | 用户主动取消 | CANCELED |
| USING | 点击暂离 | SUSPEND |
| SUSPEND | 暂离中返回签到 | USING |
| USING | 主动结座或到时 | FINISHED |
| SUSPEND | 暂离超时 | VIOLATION_END |
| 任意非终态 | 管理员强制释放 | CANCELED |
实现的时候,我记得最简单的方法是写一个状态流转校验方法,用Set或者Map存合法路径,每次变更状态前先校验。千万别直接用代码if-else一路写到底,后面加状态的时候会改到怀疑人生。
4.2 选座接口的实现顺序:查缓存、加锁、开事务、写流水
选座是并发要求最高的接口,直接上最终实现顺序:
java复制public Result<String> selectSeat(SelectSeatDTO dto) {
// 1. 校验用户状态:是否有未完成预约、是否被限制预约
// 2. 校验时段合法性:是否在开放时间、时长是否超限
// 3. 快速查缓存:如果seatPeriod的状态不是FREE,直接返回已抢光
// 4. 尝试获取Redis分布式锁,锁的key为 seatPeriod:{id}
// 使用命令 SET lockKey owner NX PX 5000
// 5. 拿锁成功之后,开启事务:
// UPDATE seat_period
// SET status = 'LOCKED', version = version + 1
// WHERE id = #{periodId} AND status = 'FREE'
// 如果影响行数为1,创建预约单,状态PENDING
// 6. 提交事务,事务提交成功后删除Redis缓存
// 7. 释放Redis锁(使用Lua脚本,校验当前线程持有锁再删除)
}
这里每一步都有原因。缓存预检是为了挡住大部分无效请求;分布式锁是为了让同一个座位时段的抢座操作串行化;数据库的条件更新,是为了在锁出问题的时候兜底,防止超卖。
特别提醒一句:Redis锁的释放一定要放在事务提交之后。如果事务还没提交就释放锁,第二个请求进来,看到数据库里状态还是FREE,就会再抢一次,造成同一时段被两个人预约。我刚开始就踩过这个坑,后来改成事务提交后再释放锁,问题才解决。
4.3 签到与暂离:时间策略和被动释放如何配合
签到接口相对简单,用户扫码或者输入预约码,后端把预约单从PENDING变成USING,同时更新seat_period为占用状态。
暂离就麻烦一些。用户点击暂离,前端调接口,后端把预约单从USING改成SUSPEND,记录暂离开始时间。然后我需要一个机制,在暂离超时之后释放座位。这里有两个方案:
- 定时任务扫表:每分钟扫一次SUSPEND状态的预约单,超时的就释放。
- 延迟消息:用Redis的ZSet或者消息队列做延迟任务。
考虑到系统规模不大,我最后用了定时任务+Redis缓存辅助判断。定时任务只扫状态为SUSPEND且暂离结束时间小于当前时间的记录,数量不会很大,不会造成数据库压力。核心是:暂离结束时间在创建暂离记录时就计算好,并写入预约单,防止后续计算出现时区或精度问题。
5. 并发抢座的方案演进:从行锁等待到“Redis前置+数据库兜底”
第一次压测的时候,50个线程同时抢同一个座位,数据库直接出现行锁等待超时。后来我把方案改成了现在这个三级防线,才算稳定。
5.1 为什么纯数据库事务会在高峰期遇到麻烦
最直接的做法,是选座接口里用SELECT ... FOR UPDATE把座位时段记录锁住,再判断状态。这个做法在逻辑上没错,MySQL的行锁能保证并发正确性。但问题是,一旦有大量请求同时抢同一个热门座位,所有的请求都会排队等同一把行锁,数据库连接被长时间占用,连接池很容易被打满。
而且事务里如果有其他耗时的操作,比如发短信、调外部接口,行锁持有时间更长,整个系统的吞吐量都会垮掉。所以我很快就放弃了“纯数据库锁”的思路,把Redis加进来了。
5.2 我们最终采用的三层防线
第一层是Redis缓存预检。选座接口先查缓存,如果座位时段状态不是FREE,直接返回失败。这步能拦住大部分读请求,防止无效流量打到数据库。
第二层是Redis分布式锁。真正要写数据了,先拿到对应seatPeriod的分布式锁。锁的粒度和业务资源一致,同一个座位时段的抢座请求,在Redis层就已经串行化了。
第三层是数据库条件更新兜底。拿着锁之后,仍然用带条件的UPDATE来更新seat_period:
sql复制UPDATE seat_period
SET status = 'LOCKED', version = version + 1
WHERE id = #{periodId} AND status = 'FREE';
如果影响行数为0,说明有人已经抢先更新了,事务回滚,释放锁,返回“抢座失败”。这一层就是防Redis锁万一过期失效的兜底,保证最终不会超卖。
这里还应该给seat_period加一个唯一约束或者业务索引。比如在同一张预约单里,对“座位时段id”加唯一索引,保证同一个时段只能存在一条有效的未取消预约。真出现极端并发bug,数据库会做最后一道防线。
5.3 锁的边界:分布式锁过期、事务未提交、缓存与库不一致
分布式锁不是加了就万事大吉。我整理了几个容易翻车的点:
锁过期时间太短。 如果业务执行时间超过锁过期时间,锁会被Redis自动删除,后面另一个请求就能拿到锁,两个线程同时执行,这时候只能靠数据库的条件更新兜底。解决办法是给过期时间留足余量,或者用支持看门狗续期的锁组件。
释放锁不校验归属。 如果直接执行DEL把锁删了,万一锁已经被别的请求拿到,就会把别人的锁误删。必须用Lua脚本先判断value是不是自己,再删除:
lua复制if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
事务提交顺序问题。 前面说过,释放锁和删缓存必须在事务提交之后。我在代码里把事务方法拆出来,锁的获取和释放在外层方法,事务提交后再执行删除缓存和释放锁,彻底避开这问题。
6. 交付前的自测清单与踩坑实录
系统能跑通接口,和系统敢上线,之间还差着不少距离。这里我把自测重点和新手常踩的坑一起说。
6.1 用Jmeter模拟抢座的三个关键指标
我压测的时候,场景是同时50个线程抢同一个座位,再看几个指标:
- 错误率必须为0,尤其不能出现超卖。
- p99响应时间控制在1000ms以内,超过这个数,前端会有明显的等待感。
- 数据库连接池活跃连接数不能持续飙高,如果事务里锁等待严重,活跃连接会一直涨。
压测的时候建议把慢SQL日志打开。我压完之后发现一条查询座位列表的SQL没走索引,好几秒才返回,加完索引之后才压过。
6.2 新手最容易踩的坑:事务失效、锁顺序和时区
好几个坑我是真实踩过的,写出来大家避一避:
事务失效。 在同一个类里,一个方法调用另一个带@Transactional的方法,事务会失效。因为Spring的事务是通过代理实现的,this调用不会经过代理。我当时的做法是,把需要事务的代码放到独立的service类里,或者通过注入自己的代理对象来调用。
长事务。 千万别在事务里做网络请求。创建预约单的事务里,我曾经顺手加了短信通知,结果短信接口超时,整个事务3秒都没提交,数据库行锁一直被占着。后来所有短信、WebSocket推送全部挪到事务提交之后,通过事件监听来做。
时区问题。 数据库连接串里一定要配serverTimezone=Asia/Shanghai,不然存进去的预约时间会差8个小时。我有个测试数据就是晚上12点预约,签到时间变成了第二天凌晨,排查了半天才发现是时区配置的问题。
前端重复点击。 用户快速点了两下“预约”,如果后端没有防重处理,可能生成两笔预约。处理方式是在创建预约单的时候,检查当前用户是否存在未完成的预约,再加一个唯一索引做兜底。
6.3 从“能跑通”到“敢上线”的补全清单
最后列一个补全清单,是我每次做这类系统交付前都会过一遍的:
- 预约规则是否都配置化,管理员改规则不用改代码。
- 关键操作是否有操作日志,尤其是管理员手动释放座位,必须能追溯。
- 是否有兜底开关,比如临时关闭某个自习室的预约。
- 数据库是否有自动备份,约定备份保留天数。
- 定时任务是否做了幂等,防止多实例部署时重复释放座位。
我把这些补完,系统才算真正达到“可以拿来给人用”的状态,而不是一个只会在本地跑通的Demo。
这套系统的核心心得其实很简单:先把业务链路想透,再把数据库设计做实,最后才考虑并发优化。很多看起来高大上的问题,比如分布式锁、缓存一致性,本质上都是因为你没能在表结构和状态机层面提前化解风险。自习室预约这类系统,规模不大,但麻雀虽小五脏俱全,非常适合用来练手完整的企业级开发流程。如果你也正在做类似方向,希望这篇复盘能帮你少走几个弯。
