尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于Java的共享自习室系统:表设计与并发控制实战

基于Java的共享自习室系统:表设计与并发控制实战 简介这是一份基于Java的共享自习室系统毕业设计源码包面向高校计算机专业学生适用于课程设计、毕业设计与Java Web开发实践。系统覆盖用户注册登录、自习室信息维护、预约规则处理、消息提醒等核心模块整体采用前后端分离结构后端以Java为主含280个java文件和29个xml配置、properties配置前端基于Vue生态含102个vue、130个js以及svg、less、scss等样式文件辅以图标字体与图片资源全包共664个文件体积仅4.77MB。已有151人学习下载。包内除完整项目源码外还含构建配置、环境配置与说明文档目录分层清晰便于按模块阅读和二次开发。通过研读本项目可系统掌握后端接口开发、数据库表设计、前端组件化与前后端联调方法也可将其扩展为校园共享空间预约场景的产品原型。1. 共享自习室系统的功能边界与 Java 技术栈定位解压“基于Java的共享自习室系统.zip”、导入 IDE、等 Maven 拉完依赖是所有人接触项目的第一步。它看起来是个普通 Java Web CRUD 项目真正和图书馆占座工具区分开的是座位、时段、订单、会员之间的状态约束同一座位同一时段只允许一个订单占用订单超时未支付自动释放座位。这些规则落在数据库约束和 Java 状态机里不是简单的增删改查。系统的常见技术栈是 Spring Boot MyBatis Plus MySQL配合 Redis 处理高并发座位扣减。难点不在语法而在业务建模。文章面向学完 Java 基础、想用真实业务练手的后端初学者面试前想理清超卖、缓存一致性等八股文的求职者以及拿到源码想跑起来、继续改业务的人。2. 从需求到数据模型共享自习室系统的核心表设计共享经济类系统的共同特点是把物理资源切成时间片来售卖。自习室的可售库存不是“座位数”而是“座位 × 时段”的组合。设计表结构时如果只建一张 seat 表后续的预约校验、排班、报表都会很别扭。我建议先把领域对象拆成场地、座位、时段、订单、会员五类再谈实现。2.1 五张核心表场地、座位、时段、订单、会员表名职责关键字段说明venue场地id, name, address, open_time, close_time一个品牌下可以有多个门店seat物理座位id, venue_id, seat_no, type, statustype 区分普通/包厢/靠窗status 区分可用、停用time_slot可售时段id, venue_id, slot_date, start_time, end_time时段是库存的基本单位order预约订单id, order_no, user_id, seat_id, time_slot_id, amount, status主表只存一行订单不存明细member会员与余额id, user_id, balance, level, status余额变动单独走流水表这里最容易犯错的是把场地信息挂在 seat 表上导致 venue 表形同虚设。正确做法是 seat 通过 venue_id 关联场地time_slot 也通过 venue_id 归属场地。这样同一个座位在不同场地之间不会因为复制数据而产生冲突后续做“查找附近自习室”和“场地维度报表”时只需要在 venue 表上做筛选。2.2 时段表设计决定并发扣减的粒度时段粒度的选择直接影响表行数和查询性能。按 30 分钟切一个时段晚上 18:00-22:00 就是 8 个时段按 1 小时切只有 4 个。时段越细用户选择越灵活但可售库存行数成倍增长索引和查询语句的压力也越大。低并发阶段用 start_time、end_time 两个字段配合 BETWEEN 查询没问题预约量一上来就变成扫描热点。常见做法是给 time_slot 表一次性生成未来 30 天到 60 天的数据避免每次查询都做区间计算。生成语句可以直接在 MySQL 里用 INSERT…SELECT 完成-- 生成 2025-06-01 至 2025-06-30 期间每天 08:00-22:00 的时段粒度为 1 小时 INSERT INTO time_slot (venue_id, slot_date, start_time, end_time, status) SELECT v.id AS venue_id, DATE_ADD(2025-06-01, INTERVAL t.n DAY) AS slot_date, CONCAT(LPAD(8 h.n, 2, 0), :00) AS start_time, CONCAT(LPAD(9 h.n, 2, 0), :00) AS end_time, 1 AS status FROM (SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10 UNION ALL SELECT 11 UNION ALL SELECT 12 UNION ALL SELECT 13) AS h, (SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10 UNION ALL SELECT 11 UNION ALL SELECT 12 UNION ALL SELECT 13 UNION ALL SELECT 14 UNION ALL SELECT 15 UNION ALL SELECT 16 UNION ALL SELECT 17 UNION ALL SELECT 18 UNION ALL SELECT 19 UNION ALL SELECT 20 UNION ALL SELECT 21 UNION ALL SELECT 22 UNION ALL SELECT 23 UNION ALL SELECT 24 UNION ALL SELECT 25 UNION ALL SELECT 26 UNION ALL SELECT 27 UNION ALL SELECT 28 UNION ALL SELECT 29) AS t, venue v;这段 SQL 用 h 子查询生成小时序号0 到 13t 子查询生成日期偏移量0 到 29再与 venue 表做笛卡尔积一次性插满 30 天 × 14 个时段。h 从 8 开始是为了让 start_time 从 08:00 起end_time 用 9 h最后一行的结束时间是 22:00。如果要做半小时粒度把 h 子查询扩展到 0 到 27并把时间拼成 08:00、08:30 的格式即可。这里没有用存储过程循环是因为 INSERT…SELECT 在数据库内部完成执行一次就是全量数据应用层不需要感知生成过程。2.3 座位可用性的查询语句与常见坑用户在前端选了“6 月 10 日 19:00-20:00”后端第一件事是查这个时段哪些座位还空着。SQL 写法如下SELECT s.id, s.seat_no, s.type FROM seat s WHERE s.venue_id 1 AND s.status 1 AND s.id NOT IN ( SELECT o.seat_id FROM order o JOIN time_slot ts ON o.time_slot_id ts.id WHERE ts.slot_date 2025-06-10 AND ts.start_time 19:00 AND o.status IN (1, 2) -- 1 已支付2 进行中这两种状态才占座 );这个查询的正确性完全依赖子查询里的状态过滤。第一次写的人经常漏掉o.status IN (1, 2)把已取消、已超时的订单也算作占用用户看到一半座位不可选后台却查不到对应订单。另一个高发坑是 order 表名order 是 MySQL 保留字建表必须加反引号Java 实体上要写TableName(order)显式指认否则启动时 MyBatis 直接报语法错误。NOT IN在子查询结果集较大时性能一般实际项目里我会改写成NOT EXISTS或者在 seat_slot_available 中间表上用联合索引直接过滤状态位。但无论用哪种写法状态枚举值必须全局统一不要让某些接口传字符串、某些接口传数字那是后期维护最大的隐患。3. 预约下单流程库存扣减、状态机与超时释放预约接口是共享自习室系统的核心业务。用户在 App 或小程序上选好座位和时段点击“立即预约”后端要依次完成用户校验、时段校验、座位占用校验、订单创建、支付回调处理。低并发时可以用同步编码一路写下来要做活动或秒杀场景就得引入 Redis 预减和 Lua 脚本。3.1 用一个 Java 方法串起预约主流程下面代码演示预约 service 层的核心逻辑Controller 层和支付回调省略Service public class ReserveService { Autowired private SeatSlotMapper seatSlotMapper; Autowired private OrderMapper orderMapper; Autowired private UserService userService; Transactional(rollbackFor Exception.class) public ReserveResult reserve(ReserveRequest req) { // 1. 校验用户状态 UserVO user userService.getById(req.getUserId()); if (user null || user.getStatus() ! 1) { return ReserveResult.fail(用户不可预约); } // 2. 锁定座位seat_slot_available 表做条件 UPDATE int locked seatSlotMapper.tryLockSeat( req.getSeatId(), req.getTimeSlotId(), req.getUserId()); if (locked 0) { return ReserveResult.fail(座位已被占用); } // 3. 创建待支付订单 Order order new Order(); order.setOrderNo(generateOrderNo(req.getUserId())); order.setSeatId(req.getSeatId()); order.setTimeSlotId(req.getTimeSlotId()); order.setAmount(calcAmount(req.getTimeSlotId())); order.setStatus(OrderStatus.WAIT_PAY); orderMapper.insert(order); // 4. 返回订单号前端调起支付 return ReserveResult.ok(order.getOrderNo()); } }tryLockSeat 是整个方法的关键它执行一条条件 UPDATEUPDATE seat_slot_available SET user_id #{userId} WHERE seat_id #{seatId} AND time_slot_id #{timeSlotId} AND user_id IS NULL;这条 SQL 依靠数据库行锁保证同一时刻只有一个事务能把 user_id 从 NULL 改成当前用户。受影响行数为 1 表示占座成功为 0 表示被抢走。先 SELECT 再 UPDATE 的两步写法在并发下会同时通过校验这里用一条 UPDATE 天然避免了竞态条件是成本最低的防超卖手段。订单号不要用数据库自增主键直接暴露给前端。常见做法是“yyyyMMddHHmmss 用户 ID 后四位 随机串”并在 order_no 字段上加唯一索引防止支付回调或前端重试时重复创建订单。金额计算单独抽 calcAmount 方法因为价格可能来自时段价格、会员折扣、优惠券叠加放在主流程里会让事务过长。3.2 Redis 预减 Lua 脚本应对瞬时高并发预约在写真实的 MySQL 库存前先用 Redis 做一次计数预减。减成功的请求才继续走数据库 UPDATE数据库失败再回补。预减和检查必须保证原子性所以用 Lua 脚本-- KEYS[1]座位时段库存 key例如 seat:stock:1001:2025061019 -- ARGV[1]扣减数量通常为 1 local stock tonumber(redis.call(get, KEYS[1]) or 0) if stock 0 then return 0 end redis.call(decrby, KEYS[1], ARGV[1]) return 1Java 侧用 DefaultRedisScript 执行这段脚本时返回类型要声明为 Long。Lua 脚本返回 0 或 1Redis 客户端会转为整数如果声明成 Integer 在序列化时容易出类型转换异常。库存 key 建议拼成seat:stock:{seatId}:{timeSlotId}让每个座位独立计数。把所有请求打到同一个 key 上热点压力会非常集中按座位拆 key 后热点被自然打散。Redis 预减完成后如果数据库事务回滚需要补偿回补。常见做法是在 catch 或 finally 里执行redisTemplate.opsForValue().increment(key, 1)。有人会问先操作 Redis 还是先操作数据库我的结论是预减阶段先操作 Redis最终一致性靠定时任务扫订单表对账数据库永远是准的Redis 只是挡在数据库前面的第一道阀门。3.3 订单超时未支付的释放策略用户提交订单后一直不付款座位会被白白占用。常见方案是定时任务扫描待支付订单超时 15 分钟自动取消。订单状态流转可以先用一张表固定住状态值名称触发入口0待支付预约下单成功1已支付支付回调2进行中到达开始时间3已完成时段结束4已取消用户取消或运营退单5已超时定时任务释放定时任务用 Spring 的 Scheduled 实现Scheduled(cron 0 */1 * * * ?) public void releaseExpiredOrders() { ListOrder expiredOrders orderMapper.selectExpired(15); if (expiredOrders.isEmpty()) { return; } for (Order order : expiredOrders) { orderMapper.cancelById(order.getId()); seatSlotMapper.unlockSeat(order.getSeatId(), order.getTimeSlotId(), order.getUserId()); redisTemplate.opsForValue().increment(buildStockKey(order), 1); } log.info(本次释放超时订单 {} 笔, expiredOrders.size()); }释放速率选 1 分钟还是 5 分钟取决于业务对座位实时性的要求。自习室用户通常就在前台等着选座1 分钟延迟可以接受。大批量释放时不要逐条 UPDATE把订单 ID 收集后用UPDATE ... WHERE id IN (...)批量取消再一次性回补 Redis减少数据库交互次数。unlockSeat 的条件里必须带 user_id否则并发下可能释放掉别人刚占到的座位。若用线程池并发处理多个场地的订单主线程可以用 CountDownLatch 等待所有释放任务结束再打印日志这也是“线程等待都完成”在此项目里最自然的应用场景。4. 管理端后台与多端接口的 Java 实现管理端面向运营人员功能集中在排班、调价、退单、会员管理。和用户端最大的区别在于操作粒度用户端操作单条订单管理端操作一批座位、一批时段、一批会员。实现时优先考虑批量能力和权限边界。4.1 排班与调价的批量更新思路运营人员选择场地和日期范围后点击“生成排班”本质是向 time_slot 表批量插入时段记录。如果时段已存在应该更新状态而非重复插入。MySQL 里用 INSERT … ON DUPLICATE KEY UPDATE 一条语句完成INSERT INTO time_slot (venue_id, slot_date, start_time, end_time, status) VALUES (1, 2025-06-11, 08:00, 09:00, 1) ON DUPLICATE KEY UPDATE status VALUES(status);这条语句依赖唯一索引(venue_id, slot_date, start_time)。有些项目的 time_slot 表没加唯一索引运营人员多点几次生成按钮库里就多出几条相同时段的记录用户端查询时出现一个座位匹配多个时段数据的诡异问题。调价逻辑我建议抽象成“时段类型 × 座位类型 价格”的规则表而不是在每个时段上直接加 price 字段。运营人员要给所有工作日夜间时段加价 5 元只需改一条规则记录不需要遍历几百条 time_slot 逐条 UPDATE。Java 实现里可以定义 PriceStrategy 接口让普通价、周末价、会员折扣各自成一个策略类业务逻辑里用 Map 按条件路由这比堆 if-else 好维护得多。4.2 一个注解搞定接口鉴权AOP 与 Java 动态代理的关系管理端接口必须做权限校验。常见做法是写 HandlerInterceptor但更贴合 Spring 风格的是自定义注解加 AOP。先定义一个注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequireRole { String value() default USER; }在管理端 Controller 方法上标RequireRole(ADMIN)再用 Aspect 切面拦截Aspect Component public class RoleCheckAspect { Around(annotation(roleCheck)) public Object check(ProceedingJoinPoint pjp, RequireRole roleCheck) throws Throwable { String requiredRole roleCheck.value(); UserVO currentUser UserContext.get(); if (currentUser null || !requiredRole.equals(currentUser.getRole())) { throw new BizException(无权访问); } return pjp.proceed(); } }这里annotation(roleCheck)会把方法上的注解实例直接绑定到切面参数切面内无需反射就能拿到角色要求。AOP 的底层是 Spring 容器在启动时为 Bean 生成动态代理对象方法调用先进代理再进真实方法。这也是面试题常问的 Java 动态代理在此项目中的落点JDK 动态代理要求目标类实现接口CGLIB 可以代理普通类Spring Boot 默认对没有接口的类使用 CGLIB。初学容易把拦截器和 AOP 混用。拦截器工作在 Spring MVC 的 Handler 执行链上拿不到 Service 方法上的注解AOP 工作在 Bean 方法调用层可以在任意 Service 方法上做鉴权、日志、限流。我的分工是接口鉴权用 AOP静态资源和跨域配置用拦截器两者各管一段。4.3 小程序端轮询查询与接口裁剪自习室小程序端需要展示座位实时状态。最稳妥的方案是前端每 10 秒轮询一次座位列表不必一开始就上 WebSocket。轮询接口在 Java 侧只需给原列表方法加缓存把 Redis 里的座位状态直接返回。注意座位状态变化后要主动删缓存删除时机放在预约成功、取消订单、定时释放之后和数据库操作同一个事务边界内完成避免用户看到旧状态。接口返回字段尽量精简。用户端列表只返回 seatId、seatNo、status、price把场地的详细地址、管理员备注等字段留在管理端接口。字段裁剪除了减少传输体积更重要的是不把内部数据暴露给前端降低被遍历接口获取敏感信息的风险。会员余额、手机号这类字段一律不进列表接口单独提供详情接口并按角色做字段过滤。5. 拿到 zip 包后的导入、订单验证与并发压测源码包与文档之间的差距永远比想象中大。解压一个 Java 项目 zip 后先检查三件事JDK 版本、Maven 配置、数据库初始化脚本。大多数导入报错都出在这三个地方和代码本身无关。5.1 导入 IDE 前先做三个环境检查java -version mvn -v grep -E jdbc|redis src/main/resources/application.yml第一条确认 JDK 版本与 pom.xml 里java.version一致不一致就手动下载对应 JDK 并配置环境变量。第二条确认 Maven 仓库路径私服地址连不通时依赖全部报红。第三条看数据库和 Redis 连接信息是否指向本机账号密码改了之后应用启动才能连上库。如果遇到“failed to copy spatial iop zip”这类打包报错先怀疑本地 Gradle 缓存损坏删掉缓存目录里对应模块重新拉取通常能直接解决。5.2 用 curl 模拟一次完整预约验证状态流转应用启动后不要急着开前端页面先用 curl 把核心链路打通。# 1. 登录拿 token curl -X POST http://localhost:8080/api/auth/login \ -H Content-Type: application/json \ -d {username:test,password:123456} | jq -r .data.token # 2. 查询 6 月 10 日 19:00 时段空闲座位 curl -X GET http://localhost:8080/api/seat/list?date2025-06-10start19:00 \ -H Authorization: Bearer $TOKEN # 3. 提交预约 curl -X POST http://localhost:8080/api/reserve \ -H Content-Type: application/json \ -H Authorization: Bearer $TOKEN \ -d {seatId:5,timeSlotId:123}登录后把 token 存在变量里后续请求放进 Authorization 头。预约接口返回后去 MySQL 里查 order 表确认状态是 0待支付再查 seat_slot_available 表确认 user_id 已写入。想验证超时释放把定时任务 cron 临时改成每 10 秒一次15 分钟后看订单状态是否变成 5。5.3 并发预约压测验证防超卖最直接的方法是并发请求同一个座位。命令行用 xargs 起 10 个并发请求seq 1 10 | xargs -P 10 -I {} \ curl -s -X POST http://localhost:8080/api/reserve \ -H Content-Type: application/json \ -H Authorization: Bearer $TOKEN \ -d {seatId:5,timeSlotId:123} | grep -c 成功期望结果是输出 1也就是 10 个请求里只有 1 个成功。如果输出大于 1说明 tryLockSeat 的条件 UPDATE 或唯一索引没有生效回到第 3.1 节检查 SQL 的 WHERE 条件再确认 seat_slot_available 表上有没有建(seat_id, time_slot_id)的唯一索引。压测通过后再回放一遍 5.2 的完整预约流程确认数据库状态、Redis 计数、缓存删除三方数据一致。本文还有配套的精品资源点击获取
返回列表