机房管理系统数据库设计:座位并发、计费与事务边界拆解 简介这份资料包围绕学院机房管理系统展开面向计算机相关专业学生及数据库课程设计初学者解决从需求分析到数据库落地、设计文档撰写缺少完整样例的问题。压缩包共3个文件包含2份Word文档与1份SQL脚本体积约155KB分别承载数据库设计说明书、系统详细设计和建表查询脚本便于对照阅读与直接运行验证。已有756人学习下载说明其在课程设计选题中具有一定参考价值。内容覆盖机房、设备、用户、预约及使用记录等核心实体涉及ER模型、关系模式转换、1NF至3NF范式、索引设计与查询优化文档侧重需求描述、模块划分与表结构说明SQL脚本可用于建表、约束定义及典型增删改查练习。读者可据此梳理实体关系、理解主外键与字段设计并参考文档完成课程设计报告和数据库实现适合作为数据库原理课程的实践案例与查漏补缺模板。1. 机房管理系统一份把「上机、排课、计费」串起来的数据库设计样本「机房管理系统」听起来像教务系统里的一个边角模块真拆开看它其实是数据库设计的一次集中考试座位是稀缺资源时间是互斥资源金额是不能出错的资源。这三样同时压在一张seat表上字段类型写错一个后面全靠补丁堆。这套源码围绕「机房—座位—设备—上机记录—计费规则—排课」六个主体建模后端Spring Boot 3 MyBatis-Plus MySQL 8前端Vue3 Vite Element Plus功能覆盖学生自助选座签到、教师排课占位、管理员设备台账与报修、按阶梯规则自动计费、超时自动释放座位。我最看重的是它的数据库设计11 张表、几处唯一索引、两处生成列把「一个人不能同时占两个座位」「一个座位不能同时被两个人占」这类业务规则直接写进了约束层而不是靠 Service 层层 if 判断。适合两类读者一是想找一个业务闭环完整、跑起来就能改的中小型管理系统做二次开发的二是正在啃数据库设计想知道索引、唯一约束、事务边界到底该落在哪里的。下面按「结构 → 表 → 代码 → 踩坑 → 改造」的顺序拆。2. 技术栈与工程结构Spring Boot 3 Vue3 的分层怎么落2.1 选型与版本边界这套项目没有追新选型思路是「跑得稳、改得动、不依赖外部服务」。数据库只用 MySQL 一个存储Redis 设为可选依赖不装也能启动只是在登录态和短时占座锁上会退化到数据库方案。这一点对小规模机房很实用很多学校机房的服务器只有一台装 Redis 反而增加运维负担。层次选型版本落地理由运行环境JDK17Spring Boot 3 的最低要求LTS后端框架Spring Boot3.2.x自带定时任务、参数校验、统一异常持久层MyBatis-Plus3.5.x逻辑删除、乐观锁插件、代码生成数据库MySQL8.0需要生成列 函数索引能力连接池HikariCP内置默认即可调maximum-pool-size前端Vue3 Vite3.x / 5.x组合式 API构建快UIElement Plus2.x表格、表单、对话框开箱即用鉴权Sa-Token1.37比 Spring Security 配置短适合中小项目依赖部分只列关键几项pom.xml里其余是常规的 lombok、validation、hutool 工具包dependencies !-- Web 与参数校验 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency !-- MySQL 驱动8.x 必须用 com.mysql.cj.jdbc.Driver -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency !-- MyBatis-Plus 启动器注意不要同时引 mybatis-spring-boot-starter会冲突 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.5/version /dependency dependency groupIdcn.dev33/groupId artifactIdsa-token-spring-boot3-starter/artifactId version1.37.0/version /dependency /dependencies这里有个容易翻车的点Boot 3 之后 MyBatis-Plus 的 starter 坐标变了从mybatis-plus-boot-starter换成了mybatis-plus-spring-boot3-starter写错的话启动时不会报错而是Mapper注入失败看堆栈会以为是包扫描问题实际是版本不匹配。JDK 版本也要统一pom.xml里用maven.compiler.release固定住 17避免有人本地用 8 编译出Unsupported class file major version。2.2 目录结构从 Controller 到 Mapper 的一条线后端采用最直白的三层结构没有做领域驱动拆分因为业务逻辑集中在「签到」「签退」「计费」三个动作上过度分层反而增加阅读成本。前端按页面模块切和后端接口一一对应。lab-room/ ├── lab-room-server/ # 后端 │ ├── src/main/java/com/example/labroom/ │ │ ├── common/ # 统一返回 RT、异常、常量 │ │ │ ├── R.java │ │ │ ├── BizException.java │ │ │ └── GlobalExceptionHandler.java │ │ ├── config/ # MyBatis-Plus、跨域、Sa-Token 配置 │ │ ├── controller/ # 只做参数接收与出参封装 │ │ │ ├── SeatController.java │ │ │ ├── CheckinController.java │ │ │ └── ScheduleController.java │ │ ├── service/ # 业务事务边界都在这层 │ │ │ ├── CheckinService.java │ │ │ └── impl/CheckinServiceImpl.java │ │ ├── mapper/ # 接口 resources/mapper/*.xml │ │ ├── entity/ # 与表一一对应 │ │ └── dto/ # 入参出参不直接暴露 entity │ └── src/main/resources/ │ ├── application.yml │ ├── mapper/CheckinRecordMapper.xml │ └── db/schema.sql # 建表脚本首次启动手动执行 └── lab-room-web/ # 前端 ├── src/api/ # axios 封装按模块拆 ├── src/views/seat/ # 选座页含座位网格组件 ├── src/views/admin/ # 机房、设备、计费规则管理 └── src/router/index.js我一般会把建表语句单独放db/schema.sql不接 Flyway 这类迁移工具因为这个项目的表结构在二次开发时改动频繁迁移脚本反而成为负担。但要注意一点schema.sql只在初始化时手动执行一次别在application.yml里配spring.sql.init.modealways否则每次重启都会尝试建表虽然加了IF NOT EXISTS不会报错但会拖慢启动且掩盖真实错误。2.3 配置项与一个签到请求的链路application.yml里有三处必须显式配置少一处就会在运行期出问题时区、逻辑删除、驼峰映射。server: port: 8080 spring: datasource: # serverTimezone 必须写否则 DATETIME 存取会偏 8 小时 # 计费按分钟算偏 8 小时就是几十块钱的差额 url: jdbc:mysql://127.0.0.1:3306/lab_room?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: your_password hikari: maximum-pool-size: 20 # 机房并发量不大20 足够 connection-timeout: 3000 # 拿不到连接 3 秒就失败别让请求一直挂着 mybatis-plus: global-config: db-config: logic-delete-field: deleted # 全局逻辑删除字段名 logic-delete-value: 1 logic-not-delete-value: 0 configuration: map-underscore-to-camel-case: true # seat_no - seatNo log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 调试期打开上线关掉参数说明一下serverTimezoneAsia/Shanghai是关键很多人在本地跑正常、部署到服务器上时间全乱根源就在容器默认 UTC。map-underscore-to-camel-case如果关掉所有seat_no、room_id字段都查不出来表现为「实体字段全是 null 但 SQL 明明有值」第一次遇到会怀疑人生。log-impl只在调试期开生产环境打全量 SQL 会吃掉大量磁盘。再看数据流。前端选座页点「上机」请求POST /api/checkin参数是seatIduserId从 Sa-Token 的会话里取。CheckinController拿到参数后只做两件事校验seatId非空、调CheckinService.checkin(userId, seatId)。Service 里是整个项目最需要小心的一段Transactional(rollbackFor Exception.class) public CheckinVO checkin(Long userId, Long seatId) { // 第一道闸条件更新抢座位返回 0 说明已经被人抢先 int affected seatMapper.occupy(seatId, userId, LocalDateTime.now()); if (affected 0) { throw new BizException(该座位已被占用请刷新后重试); } // 第二道闸插入上机记录靠唯一索引兜底重复签到 CheckinRecord record new CheckinRecord(); record.setUserId(userId); record.setSeatId(seatId); record.setRoomId(seatMapper.selectRoomIdById(seatId)); record.setStartTime(LocalDateTime.now()); record.setStatus(0); recordMapper.insert(record); // 唯一索引冲突会抛 DuplicateKeyException return new CheckinVO(record.getId(), record.getStartTime()); }逻辑说明整个方法在一个事务里occupy和insert要么都成要么都回滚。occupy用的是条件更新而不是SELECT ... FOR UPDATE原因在 4.1 展开。insert阶段的DuplicateKeyException由GlobalExceptionHandler统一转成「你已有正在进行的上机记录」不需要在 Service 里 catch。注意rollbackFor Exception.class不能省Spring 默认只回滚RuntimeException如果哪天换成受检异常事务就悄悄失效了。3. 数据库设计11 张核心表把座位、时间、金额管住3.1 表清单与实体关系先把表摆出来再讲为什么这么切。这套设计遵循一个原则凡是会被并发修改的状态单独放一张窄表凡是只追加不改写的历史允许字段冗余。座位状态是高频改写的所以seat表只有十来个字段上机记录是只追加的所以checkin_record里可以冗余room_id避免统计时反复 join。表名作用预估量级关键索引sys_user学生/教师/管理员万级uk_usernameroom机房含开放时段百级—seat座位状态核心表万级uk_room_seat、idx_room_statusdevice主机、显示器资产万级uk_asset_nocourse_schedule排课占用按周次节次千级idx_room_weekcheckin_record上机记录百万级uk_active_seat、uk_active_userbilling_rule阶梯计费规则十级idx_roomuser_wallet余额分万级主键wallet_flow余额流水百万级idx_user_timerepair_order报修工单千级idx_seat_statusoperation_log管理端操作审计百万级idx_operator_time关系上room一对多seatseat可选绑定一台device有的机房是座位固定机器有的是自由坐所以device_id允许为空。checkin_record同时引用user_id和seat_id并冗余room_id方便按机房维度做日报。billing_rule挂在room_id上而不是全局因为不同机房的机器配置差异大单价不一样是常态。3.2 座位表与上机记录表的建表细节这两张表是整个系统的地基字段类型选错后面改起来要停机迁移。重点看几处。CREATE TABLE seat ( id BIGINT NOT NULL AUTO_INCREMENT, room_id BIGINT NOT NULL COMMENT 所属机房, seat_no VARCHAR(16) NOT NULL COMMENT 座位号如 A-03, device_id BIGINT DEFAULT NULL COMMENT 绑定设备可空, status TINYINT NOT NULL DEFAULT 0 COMMENT 0空闲 1使用中 2预约锁定 3停用, current_user_id BIGINT DEFAULT NULL COMMENT 当前使用人, occupy_time DATETIME DEFAULT NULL COMMENT 本次占用开始时间, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本, deleted TINYINT 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, PRIMARY KEY (id), UNIQUE KEY uk_room_seat (room_id, seat_no), KEY idx_room_status (room_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT座位;CREATE TABLE checkin_record ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, room_id BIGINT NOT NULL COMMENT 冗余避免统计时 join seat, seat_id BIGINT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME DEFAULT NULL, duration_min INT DEFAULT NULL COMMENT 计费分钟数, fee_fen INT NOT NULL DEFAULT 0 COMMENT 费用单位分, rule_id BIGINT DEFAULT NULL COMMENT 命中的计费规则快照, status TINYINT NOT NULL DEFAULT 0 COMMENT 0进行中 1已签退 2超时关闭 3异常, -- 生成列只有进行中的记录才参与唯一约束签退后置 NULL 不占位 active_seat_id BIGINT GENERATED ALWAYS AS (IF(status 0, seat_id, NULL)) VIRTUAL, active_user_id BIGINT GENERATED ALWAYS AS (IF(status 0, user_id, NULL)) VIRTUAL, PRIMARY KEY (id), UNIQUE KEY uk_active_seat (active_seat_id), UNIQUE KEY uk_active_user (active_user_id), KEY idx_user_time (user_id, start_time), KEY idx_room_time (room_id, start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT上机记录;逻辑说明active_seat_id和active_user_id是本项目数据库设计里最关键的一处设计。MySQL 不支持部分索引WHERE status 0那种但支持生成列 唯一索引的组合。status 0时生成列取真实 ID唯一约束生效保证一个座位同时只有一条进行中记录、一个用户同时只有一条进行中记录一旦签退status变成 1生成列变NULL而 MySQL 的唯一索引允许多个NULL历史记录就不受约束了。参数说明fee_fen用INT存「分」不用DECIMAL。原因是金额在这里只有加减没有乘除和汇率换算用整数分可以彻底规避浮点误差和scale不一致的问题。如果哪天规则要精确到厘再改成DECIMAL(10,4)并统一换算常量。duration_min冗余存一份是因为计费规则可能中途调整统计历史消费时按当时的分钟数算才准不能事后用end_time - start_time重算。提示生成列如果写成STORED会占磁盘VIRTUAL不占存储但每次查询现算本场景写入少查询多用VIRTUAL更划算。3.3 索引与约束把业务规则下沉到数据库层很多人写管理系统把「不能重复签到」放在 Service 里if (recordMapper.countActive(userId) 0) throw ...。单机低并发时能用一旦两个请求同时进来都查到 0都放行就出现了双开。数据库层的唯一索引是最后一道闸它不依赖应用层的时序假设。除了上面两个生成列唯一索引seat表的uk_room_seat也很重要。座位号是人录进去的管理员批量导入时很容易出现「同一个机房两个 A-03」没有这个唯一索引选座页面上就会出现两个一模一样的座位学生点了哪个全看运气。idx_room_status则是为选座页服务的页面要查「某机房所有空闲座位」(room_id, status)的联合索引能直接覆盖过滤条件避免全表扫。再补一层约束业务上「一个座位只能绑定一台设备一台设备也只能出现在一个座位」ALTER TABLE seat ADD UNIQUE KEY uk_device (device_id);注意device_id允许NULL多个NULL不冲突正好满足「自由坐机房不绑设备」的需求。这条约束的价值在设备报修场景体现报修单里带device_id后台按设备反查座位如果一台设备被绑到两个座位工单就不知道该派给谁。3.4 排课与预约的时间冲突检测时间是这套系统里第二类稀缺资源。机房白天要上课晚上开放自由上机排课占用的时间段不能和自由选座撞车。这里有个设计选择用DATETIME存具体日期还是用「周次 节次」项目选了后者。原因是学校的课表是按教学周循环的第 3 周周三第 5-6 节的课第 4 周同一时间还有用具体日期存意味着每周都要生成一遍记录一学期下来course_schedule表膨胀十几倍而且调课要改一堆行。改成「起始周、结束周、星期几、起始节、结束节」五个字段后一条记录就覆盖整个学期。冲突检测用半开区间的写法这是我最想强调的一处细节-- 检测某机房在某周某天某节次区间是否已被占用 -- 区间统一用 [start, end) 半开避免 5-6 节和 6-7 节被误判为冲突 SELECT COUNT(1) FROM course_schedule WHERE room_id #{roomId} AND weekday #{weekday} AND start_week #{week} AND end_week #{week} AND start_section #{endSection} AND end_section #{startSection};逻辑说明两个区间重叠的判定条件是A.start B.end AND A.end B.start用严格小于和严格大于不要用/。如果写成start_section endSection那么「第 1-2 节」和「第 3-4 节」这种首尾相接但实际不冲突的排课会被误判为冲突管理员排课时会莫名其妙被拦住。参数说明week是当前教学周由学期起始日期和当前日期算出来不存表weekday用 1-7 表示周一到周日和 Java 的DayOfWeek对齐但要注意 Java 的DayOfWeek.getValue()周一返回 1Calendar.DAY_OF_WEEK周日返回 1两套体系混用必然出错项目里统一用前者。4. 核心代码拆解占座、计费、超时释放的三段事务边界4.1 座位并发抢占条件更新优先行锁兜底选座是这套系统里唯一真正有并发压力的接口。上课前五分钟一个机房六十个人同时点「上机」如果处理不好会出现「两个人同时签到成功、座位上坐着一个人、另一个人站在旁边刷不开机」的场面。第一版实现用的是SELECT ... FOR UPDATE加状态判断能跑但有两个问题一是行锁持有时间覆盖了整个事务insert记录、扣余额都在锁内锁等待时间被拉长二是如果 Service 里某处抛异常锁释放不及时容易连锁超时。后来改成条件更新把「判断 修改」合成一条 SQL!-- CheckinRecordMapper.xml / SeatMapper.xml -- update idoccupy UPDATE seat SET status 1, current_user_id #{userId}, occupy_time #{now}, version version 1 WHERE id #{seatId} AND status 0 !-- 只有空闲才能占等价于一次 CAS -- AND deleted 0 /update// Service 层拿到影响行数后做分支不需要先查再判 int affected seatMapper.occupy(seatId, userId, now); if (affected 0) { // 可能是被占用也可能是座位停用/不存在统一提示让用户刷新 throw new BizException(座位不可用请刷新后重试); }逻辑说明UPDATE ... WHERE status 0在 InnoDB 里会对命中行加排他锁但锁的范围只有这一行且语句一执行完事务提交前其他事务的相同更新会阻塞在行锁上阻塞时间等于当前事务剩余时间。因为后面只剩一个insert事务很短实测六十个并发请求下 P99 在 200ms 以内。version version 1是为了配合 MyBatis-Plus 的乐观锁插件后续如果要加「管理员强制释放座位」这类并发修改乐观锁能防止覆盖。参数说明#{seatId}必须有主键索引这条 SQL 走主键等值查询加锁粒度最小deleted 0不能省否则逻辑删除的座位还能被占。还有个容易忽略的点这条 SQL 的返回值是影响行数不是查询结果MyBatis 里update标签的返回类型写int即可别写成Seat。如果业务上允许「预约锁定」status 2的座位在学生到场后要转成1这时条件更新要改成status IN (0, 2) AND (current_user_id IS NULL OR current_user_id #{userId})保证只有预约本人能激活别人抢不走。这类条件放在WHERE里比放在 Java 里判断可靠得多。4.2 计费落账整数分 规则快照计费是这套系统里唯一涉及钱的地方也是最容易出纠纷的地方。学生签退时看到扣了 3.5 元管理员后台看到 3.49 元这种事发生一次就要查半天。项目里的做法是全程用整数分计算只在展示层除以 100。/** * 按阶梯规则计算费用单位分 * param start 上机开始时间 * param end 签退时间 * param rule 命中的计费规则 */ public int calcFeeFen(LocalDateTime start, LocalDateTime end, BillingRule rule) { long minutes Duration.between(start, end).toMinutes(); // 免费时长内直接返回 0比如前 10 分钟不计费 if (minutes rule.getFreeMinutes()) { return 0; } long billable minutes - rule.getFreeMinutes(); // 不足一个计费步长按一个步长算向上取整 long steps (billable rule.getStepMinutes() - 1) / rule.getStepMinutes(); long fee steps * rule.getUnitPriceFen(); // 单日封顶防止学生忘签退被扣穿 return (int) Math.min(fee, rule.getDailyCapFen()); }逻辑说明Duration.between(...).toMinutes()是向下取整上机 59 秒算 0 分钟配合免费时长短时间试机不收费。steps的向上取整写法(a b - 1) / b是整数运算的标准技巧比Math.ceil(a / (double) b)更稳不会有浮点边界问题。封顶逻辑放在最后是为了让「封顶价」在规则调整后立即生效而不是在累加过程中判断。参数说明freeMinutes、stepMinutes、unitPriceFen、dailyCapFen四个字段来自billing_rule表签退时把rule_id一起写进checkin_record这就是「规则快照」。为什么要快照因为管理员随时可能调价如果统计报表按「当前规则」重算历史记录上个月的消费金额会跟着变对账时根本说不清。快照之后历史记录永远按当时的规则算。CREATE TABLE billing_rule ( id BIGINT NOT NULL AUTO_INCREMENT, room_id BIGINT NOT NULL, free_minutes INT NOT NULL DEFAULT 0 COMMENT 免费时长, step_minutes INT NOT NULL DEFAULT 60 COMMENT 计费步长分钟, unit_price_fen INT NOT NULL DEFAULT 0 COMMENT 每个步长单价分, daily_cap_fen INT NOT NULL DEFAULT 2000 COMMENT 单日封顶分, effective_time DATETIME NOT NULL COMMENT 生效时间支持调价留痕, PRIMARY KEY (id), KEY idx_room_effective (room_id, effective_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT计费规则;有了effective_time调价不用改旧记录插一条新规则即可签退时取「生效时间早于当前时间、按时间倒序第一条」一条ORDER BY effective_time DESC LIMIT 1就够。4.3 超时释放定时扫描 条件更新做幂等学生点了上机直接走人、不点签退座位就一直挂着「使用中」后面的人进不来。项目用定时任务扫超时记录每 5 分钟跑一次超过配置时长默认 4 小时的记录自动关闭并按规则扣费。Scheduled(cron 0 */5 * * * ?) public void closeTimeoutSessions() { // 只捞进行中且超过阈值的记录避免全表扫 ListCheckinRecord list recordMapper.listTimeout(MAX_HOURS); for (CheckinRecord r : list) { int fee billingService.calcFeeFen(r.getStartTime(), LocalDateTime.now(), r.getRoomId()); // 条件更新只有 status 还是 0 才能关闭防止用户刚好在这一刻签退导致重复扣费 int n recordMapper.closeIfRunning(r.getId(), LocalDateTime.now(), fee); if (n 0) { continue; // 已被用户抢先签退跳过 } // 座位释放放在记录关闭之后顺序不能反 seatMapper.release(r.getSeatId()); walletService.deduct(r.getUserId(), fee, r.getId()); } }逻辑说明这里的关键是幂等。定时任务和用户签退可能在同一秒发生如果不加条件更新两条路径都会扣一次费学生余额直接少一倍。closeIfRunning的 SQL 带AND status 0影响行数为 0 就说明被别人处理过了直接跳过。update idcloseIfRunning UPDATE checkin_record SET end_time #{endTime}, duration_min TIMESTAMPDIFF(MINUTE, start_time, #{endTime}), fee_fen #{fee}, status 1 WHERE id #{id} AND status 0 /update参数说明TIMESTAMPDIFF(MINUTE, ...)在数据库侧算时长和 Java 侧的toMinutes()结果一致但省一次数据往返。注意status 0的判断必须带在WHERE里写在 Java 的if里没用因为查出来到更新之间有时间窗。顺序上「先关记录、再释放座位、最后扣款」这个顺序是有讲究的。如果先释放座位别的学生立刻占了座然后这条超时记录扣款失败比如余额不足抛异常事务回滚后座位状态是「空闲」但记录还是「进行中」两边对不上。先关记录再释放最坏情况是座位晚几秒释放不影响正确性。5. 常见问题排查数据库设计和并发控制里最容易踩的五个坑5.1 数据建模阶段的三个坑坑一逻辑删除和唯一索引打架。现象是管理员删掉一个座位后重新录入相同座位号提示「重复」但列表里明明看不到。原因是deleted字段参与了逻辑删除唯一索引uk_room_seat仍然包含被删的那行。解决方式是把唯一索引改成包含deleted的三列组合或者更彻底一点给deleted存主键 ID 而不是 1删除时把deleted更新成自己的id这样多条删除记录之间不会冲突。坑二DATETIME和TIMESTAMP混用。现象是本地跑正常服务器上签到时间比实际早 8 小时导致跨天的记录被拆成两条。原因是TIMESTAMP会随会话时区转换容器默认 UTC而DATETIME不做转换。项目里统一用DATETIME并在 JDBC URL 里固定serverTimezoneAsia/Shanghai两处配合才能保证一致。坑三把余额存在user_wallet却忘了加乐观锁。现象是学生同时用手机和浏览器各签退一次余额被扣两次。原因是两个请求都读到余额 1000都减去 350都写回 650。解决方式是UPDATE user_wallet SET balance_fen balance_fen - #{fee} WHERE user_id #{id} AND balance_fen #{fee}用数据库的原子减法代替读改写影响行数为 0 就说明余额不足。这比version乐观锁更简洁因为余额扣减天然满足结合律。5.2 并发与时序的两个坑坑四座位状态和上机记录不同步。现象是学生签退后座位仍是「使用中」得管理员手动改。原因是签退方法里先放开了座位锁、再更新记录中间抛了异常事务回滚时座位更新被撤销但上层缓存没刷新。排查的方法是看两边的update_time差了一秒以上基本就能定位。解决方式是所有涉及座位的写操作都放在同一个Transactional方法里且顺序固定为「记录 → 座位 → 钱包」同时给座位查询接口加一层「记录状态二次校验」防止脏状态被读到。坑五定时任务把还没到期的记录关掉。现象是有学生上机一小时后出去吃个饭回来发现记录被强制关闭扣了全款。原因是超时阈值配成了分钟数而代码里按小时比较单位错位。排查时先看listTimeout的 SQL再看调用方传参。解决方式是把阈值写进sys_config表而不是硬编码并在定时任务里打印一行「本次扫描到 N 条超时记录」日志里有数字配错了立刻能看出来。注意上面五条里坑一和坑三是最容易被忽略的因为它们只在「删除后重建」「并发签退」这两个非主干路径上暴露日常点一遍流程根本测不出来。6. 二次开发从加一个「刷脸签到接口」看改造路径假设要在现有系统上接一个刷脸设备学生站到座位前刷脸自动签到不扫码不输账号。这类改造最怕的是「从 Controller 开始改」一路改到 Service 发现表结构不支持又回头改表。改造路径我一般按「表 → 接口 → 策略 → 验证」四步走。表层面checkin_record加两列checkin_type1 扫码 2 刷脸 3 管理员代开和face_score比对分数仅刷脸有值前者用来做统计和风控后者用来排查误识别。两列都允许NULL历史数据不用回填这是增量改造的基本纪律。改造点文件/位置改动量注意事项表结构checkin_record加 2 列checkin_type给默认值 1回填历史设备对接controller/FaceController新增 1 个类独立鉴权不复用前端 token业务策略service/CheckinService抽接口扫码和刷脸共用占座逻辑前端views/seat/index.vue不改刷脸走设备端页面无感知执行顺序很关键先执行ALTER TABLE并回填checkin_type 1再发后端代码。反过来做的话新代码上线时字段还不存在接口直接 500。// 把「怎么识别用户」抽成策略占座和计费逻辑完全复用 public interface CheckinStrategy { Long resolveUserId(FaceCheckinCmd cmd); // 刷脸返回 userId int checkinType(); // 返回 2用于写记录 } Service public class FaceCheckinStrategy implements CheckinStrategy { Override public Long resolveUserId(FaceCheckinCmd cmd) { // 设备上报的 faceToken 换 userId比对分数低于阈值直接拒绝 FaceResult r faceClient.verify(cmd.getImageBase64(), cmd.getRoomId()); if (r.getScore() 80) { throw new BizException(识别失败请人工签到); } return r.getUserId(); } Override public int checkinType() { return 2; } }CheckinService里只多了一行「根据类型找策略」占座、写记录、扣费全部复用原有事务这样刷脸和扫码的计费结果必然一致不会出现两套逻辑对不上账。验证方法上我会跑三个用例一是同一人连续刷两次第二次应命中uk_active_user被拒二是刷脸签到后立刻用手机扫码签退金额应只扣一次三是把face_score阈值临时调到 99确认前端提示是「识别失败」而不是「座位已占用」。三个用例覆盖了唯一约束、幂等和错误提示三条线。这类改造我踩过的最大一次坑是图省事直接在Controller里写了一段「刷脸成功后手动UPDATE seat」绕过了 Service 事务结果刷脸成功但插入记录失败座位被永久锁死只能人工改库。从那以后我每次动签到链路都强制走一遍「先跑并发用例再看日志」这个流程宁可多花二十分钟也不在线上给人当客服。希望帮到你。本文还有配套的精品资源点击获取