ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

机房管理系统数据库设计:座位并发、计费与事务边界拆解

机房管理系统数据库设计:座位并发、计费与事务边界拆解

简介:这份资料包围绕学院机房管理系统展开,面向计算机相关专业学生及数据库课程设计初学者,解决从需求分析到数据库落地、设计文档撰写缺少完整样例的问题。压缩包共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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- MySQL 驱动,8.x 必须用 com.mysql.cj.jdbc.Driver --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- MyBatis-Plus 启动器,注意不要同时引 mybatis-spring-boot-starter,会冲突 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>cn.dev33</groupId> <artifactId>sa-token-spring-boot3-starter</artifactId> <version>1.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/ # 统一返回 R<T>、异常、常量 │ │ │ ├── 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.mode=always,否则每次重启都会尝试建表,虽然加了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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true 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 # 调试期打开,上线关掉

参数说明一下:serverTimezone=Asia/Shanghai是关键,很多人在本地跑正常、部署到服务器上时间全乱,根源就在容器默认 UTC。map-underscore-to-camel-case如果关掉,所有seat_no、room_id字段都查不出来,表现为「实体字段全是 null 但 SQL 明明有值」,第一次遇到会怀疑人生。log-impl只在调试期开,生产环境打全量 SQL 会吃掉大量磁盘。

再看数据流。前端选座页点「上机」,请求POST /api/checkin,参数是seatId,userId从 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_username
room机房,含开放时段百级—
seat座位,状态核心表万级uk_room_seat、idx_room_status
device主机、显示器资产万级uk_asset_no
course_schedule排课占用,按周次+节次千级idx_room_week
checkin_record上机记录百万级uk_active_seat、uk_active_user
billing_rule阶梯计费规则十级idx_room
user_wallet余额(分)万级主键
wallet_flow余额流水百万级idx_user_time
repair_order报修工单千级idx_seat_status
operation_log管理端操作审计百万级idx_operator_time

关系上,room一对多seat,seat可选绑定一台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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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()周一返回 1,Calendar.DAY_OF_WEEK周日返回 1,两套体系混用必然出错,项目里统一用前者。

4. 核心代码拆解:占座、计费、超时释放的三段事务边界

4.1 座位并发抢占:条件更新优先,行锁兜底

选座是这套系统里唯一真正有并发压力的接口。上课前五分钟,一个机房六十个人同时点「上机」,如果处理不好,会出现「两个人同时签到成功、座位上坐着一个人、另一个人站在旁边刷不开机」的场面。

第一版实现用的是SELECT ... FOR UPDATE加状态判断,能跑,但有两个问题:一是行锁持有时间覆盖了整个事务,insert记录、扣余额都在锁内,锁等待时间被拉长;二是如果 Service 里某处抛异常,锁释放不及时,容易连锁超时。后来改成条件更新,把「判断 + 修改」合成一条 SQL:

<!-- CheckinRecordMapper.xml / SeatMapper.xml --> <update id="occupy"> 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='计费规则';

有了effective_time,调价不用改旧记录,插一条新规则即可,签退时取「生效时间早于当前时间、按时间倒序第一条」,一条ORDER BY effective_time DESC LIMIT 1就够。

4.3 超时释放:定时扫描 + 条件更新做幂等

学生点了上机直接走人、不点签退,座位就一直挂着「使用中」,后面的人进不来。项目用定时任务扫超时记录,每 5 分钟跑一次,超过配置时长(默认 4 小时)的记录自动关闭并按规则扣费。

@Scheduled(cron = "0 */5 * * * ?") public void closeTimeoutSessions() { // 只捞进行中且超过阈值的记录,避免全表扫 List<CheckinRecord> 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 id="closeIfRunning"> 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 里固定serverTimezone=Asia/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_type(1 扫码 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 事务,结果刷脸成功但插入记录失败,座位被永久锁死,只能人工改库。从那以后我每次动签到链路,都强制走一遍「先跑并发用例再看日志」这个流程,宁可多花二十分钟,也不在线上给人当客服。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进