ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot实战:核酸检测预约系统的并发控制与数据库设计

Spring Boot实战:核酸检测预约系统的并发控制与数据库设计 1. 项目全局梳理目标与核心需求1.1 这个系统到底要解决什么问题我在带毕业设计的过程中看到很多同学把核酸检测预约系统简单理解成一个能填表单的网页 一张数据库表这是典型的思路跑偏。实际上这个题目最核心的价值在于预约资源的分配逻辑而不是页面长什么样。你回想一下平时用过的预约类产品——挂号、约车、订会议室——本质都是多用户争抢有限时段资源核酸预约也不例外。具体到这个项目你要解决的无非是三个问题第一用户怎么方便地查询可预约的时段和检测点并完成预约第二检测机构怎么管理每天的预约名额、录入检测结果、处理爽约和取消第三管理员怎么统筹全局——检测点信息、每日放号量、历史数据统计。这三个问题拆开看都不难但合在一起就涉及用户认证、权限区分、状态流转、并发控制、定时任务、文件导出等一系列Spring Boot体系里的经典考点这也是为什么导师和学生都喜欢拿它当毕业设计题目的原因。1.2 技术选型为什么是Spring Boot先说一个很多新手困惑的问题市面上有SSM、有Spring Cloud为什么毕业设计几乎首选Spring Boot我的回答很直接——因为它把项目从配置地狱里解放出来了。我做过的项目里SSM时代最痛苦的就是那一大堆XML配置一个druid数据源配置能写三四十行还要手动配置事务管理器、MyBatis的SqlSessionFactory、Mapper扫描路径。Spring Boot用自动配置和约定大于配置把这些全部收敛了你只需要在application.yml里写几行连接信息依赖引入后就能跑。这意味着你写业务逻辑的时间占比大幅提升而毕业设计的时间本来就紧张把精力花在堆配置文件上毫无意义。具体到我推荐的技术组合是这样的模块技术选型核心原因基础框架Spring Boot 2.7.x稳定、学习资料多、社区问答丰富持久层MyBatis Plus单表CRUD无需手写SQL分页插件开箱即用数据库MySQL 5.7 / 8.0关系型数据模型的经典选择缓存Redis预约名额预扣减、防重复提交定时任务Spring Task支持cron表达式无需额外引入Quartz前端Vue 3 Element Plus或纯模板前后端分离与非分离均可按需选择这套选型的原则是每个组件都解决一个明确的问题不搞花活。比如Redis很多人的项目里只是简单存个登录token实际上它在预约场景下还有更重要的用途——后面我会专门讲。定时任务用Spring Task而不是Quartz是因为这个项目的任务复杂度远没到需要Quartz管理集群调度和持久化任务的程度Spring Task一个Scheduled注解就搞定了简单直接。1.3 系统整体功能模块拆解把需求翻译成功能模块表是设计的第一步。我习惯先把所有角色和动作列出来再划分模块边界。核酸预约系统里一共三类角色各自的关注点完全不同普通用户注册登录、查看检测点列表、选择日期时段预约、查看预约记录、取消预约、查看检测结果。检测机构/医护角色查看当日预约名单、录入检测结果阴性/阳性、调整可预约名额。系统管理员用户管理、检测点增删改查、放号规则配置、数据统计概览、系统日志。对应地系统的模块划分应该是认证与用户模块负责注册、登录、权限拦截、个人信息维护。这是所有业务模块的入口建议用JWT做无状态认证减轻Session管理和分布式场景下的会话共享问题。如果前端是原生HTML或Thymeleaf也可以直接用Session但既然都上Spring Boot了我建议用JWT后续扩展移动端接口也方便。检测点管理模块维护检测点的名称、地址、工作时间段、每日最大预约量、联系电话等基础信息。预约模块核心中的核心涉及时段查询、名额判断、预约创建、状态流转、取消预约。数据库设计的重点和并发控制的难点都集中在这个模块。结果管理模块检测机构录入结果、用户查看结果、结果状态变更通知。可以结合异步消息或定时任务实现出结果后短信通知的扩展功能。统计报表模块按日、按检测点统计预约量、完成量、取消量、阳性数。可以用ECharts展示也可以用Apache POI导出Excel。我见过不少人把模块细分到八九十来个结果每个controller里就三五个接口代码量没有维护成本倒是不小。毕设项目保持在五到六个模块比较合适既能覆盖核心业务又不至于让工作量失控。2. 数据库设计与核心建模2.1 核心数据实体的设计思路数据库设计是整篇论文里最容易被答辩老师盯上的部分。很多同学一上来就建表边写代码边改表结构这是一个非常不好的习惯。我会花四五个小时把ER图理清楚把所有实体间的关联关系画出来再动手建表。核酸预约系统里至少有这些实体用户表、检测点表、预约时段表、预约记录表、检测结果表、管理员操作日志表。数量不多但每张表之间都有业务关联设计时要注意用户表与预约记录表是一对多——一个用户可以有多次预约记录。检测点表与预约时段表是一对多——一个检测点一天可以配置多个检测时段比如8:00-10:00、10:00-12:00、14:00-16:00。预约时段表与预约记录表是一对多——一个时段可以被多人预约但不超过时段容量上限。预约记录表与检测结果表是一对一——每条预约最终对应一条结果。理清关系后再思考一个问题什么字段适合冗余我的做法是把检测点名称冗余到预约记录表里。因为用户查询我的预约列表时大概率只需要显示检测点名称如果每次都要去关联检测点表多一次JOIN不说一旦检测点改名历史预约记录展示也要跟着变。冗余字段在项目里是合理的取舍不是设计缺陷答辩时能说出来为什么这么做反而是加分项。2.2 关键表结构设计含关键字段我不会把每张表的建表语句全部贴一遍那太占篇幅但核心的预约记录表和时段表要拿出来单独说因为整个系统的业务核心都在这里。先看时段表的设计CREATE TABLE appointment_slot ( id bigint NOT NULL AUTO_INCREMENT, site_id bigint NOT NULL COMMENT 检测点ID, slot_date date NOT NULL COMMENT 可预约日期, start_time time NOT NULL COMMENT 时段开始时间, end_time time NOT NULL COMMENT 时段结束时间, total_quota int NOT NULL DEFAULT 50 COMMENT 总容量, booked_quota int NOT NULL DEFAULT 0 COMMENT 已预约数量, version int NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, status tinyint NOT NULL DEFAULT 1 COMMENT 状态1启用 0停用, PRIMARY KEY (id), KEY idx_site_date (site_id, slot_date) ) COMMENT预约时段表;这里的total_quota和booked_quota是一对关键字段。判断是否还能预约本质上就是比较booked_quota total_quota。但注意我加了一个version字段这个不是装饰它是我做并发控制的基础。在高并发场景下两个用户同时预约最后一个名额如果只做booked_quota total_quota判断再更新极大概率会出现超卖。这个问题放到第五章详细展开。再看预约记录表CREATE TABLE appointment_record ( id bigint NOT NULL AUTO_INCREMENT, appointment_no varchar(32) NOT NULL COMMENT 预约编号, user_id bigint NOT NULL COMMENT 用户ID, slot_id bigint NOT NULL COMMENT 时段ID, site_id bigint NOT NULL COMMENT 检测点ID, site_name varchar(100) NOT NULL COMMENT 检测点名称冗余, appointment_date date NOT NULL COMMENT 预约日期, time_slot varchar(32) NOT NULL COMMENT 时段描述如08:00-10:00, status tinyint NOT NULL DEFAULT 0 COMMENT 状态0待检测 1已检测 2已取消 3已过期, real_name varchar(50) NOT NULL COMMENT 真实姓名, id_card varchar(18) NOT NULL COMMENT 身份证号, phone varchar(11) NOT NULL 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_appointment_no (appointment_no), KEY idx_user_id (user_id), KEY idx_slot_id (slot_id) ) COMMENT预约记录表;这里有几个细节值得一提。第一个是appointment_no预约编号我建议做成日期 随机数或日期 自增序列的格式不仅业务上需要用户凭预约编号到现场核验数据库层面有唯一索引约束也能从根源上防止重复预约记录的产生。第二个是身份证号和手机号这两个字段在演示项目里直接明文存储问题不大但如果你论文里想提隐私保护可以加一段AES加密存储讲起来也是一个亮点。第三个是status状态字段我给它赋予了四个枚举值这个状态机设计直接影响业务代码的复杂度需要单独说明。2.3 状态机设计预约状态如何流转状态机是数据库设计里最容易忽视但最影响代码质量的部分。我把预约记录的状态设计了四个0待检测、1已检测、2已取消、3已过期流转路径如下用户成功提交预约状态置为待检测。用户在规定时间前主动取消状态从待检测变为已取消同时时段表的booked_quota要减一释放名额。用户按时到场完成检测机构录入结果状态变为已检测。用户预约了但没来也没有提前取消系统通过定时任务扫描将超过预约日期且仍为待检测的记录批量置为已过期。为什么要单独设计已过期而不是直接已取消因为两者的业务含义不同——取消是用户主动行为过期是系统判定行为。而且后续如果要做爽约次数统计来限制用户后续预约资格靠状态区分就能轻松完成不需要额外加字段。这就是状态机的价值用确定的枚举值表达清晰的业务语义代码里永远不出现硬编码的魔法数字。我在代码里建议加一个AppointmentStatusEnum枚举类把状态值和描述集中管理后续无论写判断逻辑还是做前端下拉选项都从这里取。这是很小的设计习惯但答辩时如果老师问你为什么要用枚举而不用数字常量你完全可以说出一套规范化的理由。3. 核心流程落地预约业务的关键实现3.1 项目结构与分层很多人拿到题目就开始写Controller这是大忌。先把包结构设计好写代码才有条理。我推荐一个标准的四层结构它也是业内最主流的MVC变体com.example.nucleic ├── controller // 接口层接收参数、调用service、返回结果 ├── service // 业务层核心业务逻辑全部在这一层 │ └── impl ├── mapper // 数据访问层继承MyBatis Plus的BaseMapper ├── entity // 实体类对应数据库表 ├── dto // 传输对象接收前端参数、响应前端结果 ├── config // 配置类如MyBatis Plus分页插件、跨域配置 ├── common // 公共类统一返回结果、状态码、异常处理 └── util // 工具类如JWT工具、日期工具Controller层只负责参数接收和结果包装业务逻辑一律下沉到Service层Mapper层只做数据操作这个边界要守住。我在评审毕设代码时经常看到业务逻辑写在Controller里的情况一个接口几百行复用性为零出了问题还难定位。示例代码虽然长但至少思路清晰、职责分明。3.2 预约接口的实现细节含并发控制方案预约接口是核心中的核心我专门写一个完整示例来演示。先看Service层的主逻辑Override Transactional(rollbackFor Exception.class) public AppointmentResult createAppointment(AppointmentCreateDTO dto) { // 1. 校验用户是否已登录登录信息从请求上下文取 Long userId UserContext.getUserId(); // 2. 查询时段信息判断是否还有名额 LambdaQueryWrapperAppointmentSlot slotQuery new LambdaQueryWrapper(); slotQuery.eq(AppointmentSlot::getId, dto.getSlotId()) .eq(AppointmentSlot::getStatus, 1); AppointmentSlot slot appointmentSlotMapper.selectOne(slotQuery); if (slot null) { throw new BusinessException(预约时段不存在或已停用); } // 3. 防止重复预约同一用户在同一日期同一天任意时段只能预约一次 LambdaQueryWrapperAppointmentRecord dupQuery new LambdaQueryWrapper(); dupQuery.eq(AppointmentRecord::getUserId, userId) .eq(AppointmentRecord::getAppointmentDate, slot.getSlotDate()) .in(AppointmentRecord::getStatus, Arrays.asList(0, 1)); Long count appointmentRecordMapper.selectCount(dupQuery); if (count 0) { throw new BusinessException(您已预约该日期请勿重复预约); } // 4. 并发控制使用MyBatis Plus的乐观锁插件 // update ... set booked_quota booked_quota 1, version version 1 // where id ? and booked_quota total_quota and version ? LambdaUpdateWrapperAppointmentSlot updateWrapper new LambdaUpdateWrapper(); updateWrapper.eq(AppointmentSlot::getId, slot.getId()) .eq(AppointmentSlot::getBookedQuota, slot.getBookedQuota()) .lt(AppointmentSlot::getBookedQuota, AppointmentSlot::getTotalQuota); AppointmentSlot updateEntity new AppointmentSlot(); updateEntity.setBookedQuota(slot.getBookedQuota() 1); int rows appointmentSlotMapper.update(updateEntity, updateWrapper); if (rows 0) { throw new BusinessException(名额已满预约失败); } // 5. 插入预约记录 AppointmentRecord record new AppointmentRecord(); record.setAppointmentNo(generateAppointmentNo()); // ... 其余字段赋值省略 appointmentRecordMapper.insert(record); // 6. 返回预约编号 return new AppointmentResult(record.getAppointmentNo()); }这版代码的关键在第4步。假设booked_quota当前是9总量是10两个用户同时发起预约用户A先执行UPDATEbooked_quota从9变成10影响行数为1。用户B再执行同样的UPDATE此时booked_quota已经是10不满足booked_quota total_quota条件影响行数为0预约失败。这就是基于条件更新的乐观锁它不需要在数据库事务里显式加锁靠UPDATE语句的条件判断保证数据一致性。SPRING BOOT就这么一个简单的写法就把超卖问题堵死了。我在很多项目里都用这个方案包括商品秒杀、活动报名原理是一样的。3.3 取消预约与名额释放的细节取消预约表面上只是改一个状态字段但有两个坑必须处理第一个坑是时段的名额要回补。用户取消了booked_quota不减回去后续名额就白白浪费。但要小心回补操作必须在同一事务里完成否则可能状态改了、名额没减数据不一致。我见过有同学在Service方法上不加Transactional两个数据库操作中间隔了一次远程调用结果事务控制全部失效。特别注意事务只对同一个方法内的数据库操作有效如果用this.xxx()调用本类方法事务注解会失效正确做法是注入自身代理或者拆分到不同类中。第二个坑是取消的时间限制。业务正常应该是检测当天的可预约时段开始后不能取消不然临开场被放鸽子机构很难安排补位。这个判断逻辑放在Service前缀层做如果当前时间已经晚于时段的开始时间就抛业务异常拒绝取消。3.4 定时任务过期检测与放号提醒过期检测是一个典型的定时任务场景。我用Spring Task的Scheduled注解实现代码非常简洁Component Slf4j public class AppointmentExpireTask { Resource private AppointmentRecordMapper appointmentRecordMapper; /** * 每天凌晨1点将所有预约日期早于今天且仍为待检测状态的记录置为已过期 */ Scheduled(cron 0 0 1 * * ?) public void processExpiredAppointments() { LambdaUpdateWrapperAppointmentRecord updateWrapper new LambdaUpdateWrapper(); updateWrapper.lt(AppointmentRecord::getAppointmentDate, LocalDate.now()) .eq(AppointmentRecord::getStatus, AppointmentStatusEnum.PENDING.getCode()) .set(AppointmentRecord::getStatus, AppointmentStatusEnum.EXPIRED.getCode()); int rows appointmentRecordMapper.update(null, updateWrapper); log.info(过期预约处理完成共处理 {} 条记录, rows); } }这里的cron表达式0 0 1 * * ?表示每天的1点整执行。为什么选凌晨因为半夜业务量最低批量扫描不会跟白天的预约操作争抢数据库资源。另外用lt小于而不是le小于等于判断日期保证当天还没结束的预约不会被误判细节要注意。同理如果你想做检测结果出来后短信通知用户也可以加一个类似的定时任务轮询检测结果表把新增的结果推送给用户或者用订阅发布模式实时通知。毕设里用定时轮询就够了讲起来也算一个合理的异步实现。4. 工程化细节与部署实践4.1 多环境配置管理一套代码在本地跑、在服务器上跑数据库地址、Redis地址都不同不可能每次手动改配置文件。Spring Boot的profiles机制就是干这个的。我在项目里维护三个配置文件application.yml公共配置比如应用名、日志级别。application-dev.yml本地开发环境指向本地MySQL和Redis。application-prod.yml生产环境指向云服务器上的数据库和缓存。启动时用--spring.profiles.activedev指定环境或者打成jar包后用java -jar xxx.jar --spring.profiles.activeprod传入参数。这个技巧实操性很强而且几乎是企业级项目的标配写进论文里能体现工程意识。敏感信息的加密也可以提一嘴比如数据库密码不要明文写在application-prod.yml里可以用jasypt框架加密答辩时讲出来是一个亮点。4.2 接口参数校验与统一返回后端接口如果不对前端传的参数做校验用户的任意输入都可能直接落到SQL里轻则报错重则引入注入风险。Spring Boot的Validated参数校验非常方便PostMapping(/appointment) public Result createAppointment(Validated RequestBody AppointmentCreateDTO dto) { return Result.success(appointmentService.createAppointment(dto)); }DTO类里用注解声明约束Data public class AppointmentCreateDTO { NotNull(message 时段ID不能为空) private Long slotId; NotBlank(message 真实姓名不能为空) private String realName; Pattern(regexp ^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[\\dXx]$, message 身份证号格式不正确) private String idCard; Pattern(regexp ^1[3-9]\\d{9}$, message 手机号格式不正确) private String phone; }校验失败时Spring Boot会抛出MethodArgumentNotValidException我在全局异常处理器里统一捕获返回业务码和具体错误信息而不是给前端一个500的HTTP状态加一段晦涩的堆栈信息。这样前端拿到的永远是统一的JSON结构调试体验直接提升一个档次。4.3 部署与压测注意事项本地调试通过之后部署到云服务器是另一个考验。我的建议是买一台2核4G的入门级云服务器装一个宝塔面板或直接命令行部署内存够用。部署步骤我列一下这是最简路线服务器安装JDK 8或11、MySQL、Redis、Nginx。Maven打包用mvn clean package -DskipTests生成jar包。用nohup java -jar nucleaic-system.jar --spring.profiles.activeprod app.log 21 启动应用。Nginx反向代理前端静态文件放在/usr/share/nginx/html/api路径代理到http://localhost:8080。云服务商安全组放行80端口和数据库端口数据库端口尽量不要对公网暴露用Nginx层挡在前面即可。压测上用JMeter模拟100个并发用户同时提交预约重点观察两个指标一是接口响应时间的均值二是预约成功后booked_quota有没有超卖。我实测过按上面的乐观锁方案100个并发抢10个名额最终成功预约数精确等于10一条不多一条不少。数据一致性验证通过后整个项目的核心质量就有保障了。我还会额外加一层Redis缓存把查询某检测点某日期剩余名额这种高频读接口缓存起来设置30秒过期减轻数据库压力。这是非常贴合真实场景的优化方案能讲出来的话技术水平一眼就能看出来。5. 常见问题与排查技巧实录5.1 高并发下预约数据不一致这是预约系统里出现频率最高的Bug我讲一个真实案例。有学员做测试时发现数据库里时段表的booked_quota为10总量也是10但预约记录表里有11条成功的记录。排查过程如下第一步看日志。打开应用日志搜索名额已满发现根本没有抛出过这个异常说明代码走的路径并不是我上面演示的乐观锁版本而是先SELECT查询数量、再判断、再UPDATE的三步逻辑。问题就出在这里——三个步骤之间有时间窗口用户A和用户B同时通过SELECT看到剩余名额是1都判断可以预约然后都执行UPDATE结果两个都成功。第二步看数据库隔离级别。MySQL默认的REPEATABLE_READ并不会锁住SELECT的区间所以两个事务读到的数据是一致的也不会互相等待。第三步修复方案。把先查后改换成条件更新用UPDATE ... WHERE booked_quota total_quota影响行数为0就说明名额已被抢走。改造后复测并发场景下数据完全一致。这个Bug的教训是读改写三步操作在并发场景下必须合并为一步原子操作否则加再多的锁都不一定能解决。5.2 定时任务在生产环境重复执行Spring Boot的Scheduled默认是单机单实例的看起来没什么问题。但有学员部署时用了两台服务器做负载均衡结果发现过期任务每天执行两遍部分记录的update_time被刷新了两次虽然业务结果不脏但日志里大量重复告警。解决方案有三种第一种部署层面确保Scheduled任务只在单台机器上启用用配置文件开关控制第二种引入分布式任务锁比如用Redis的SETNX命令抢锁抢到的实例才执行第三种如果用的是Spring Cloud生态加Scheduled和分布式锁配合或者直接上Quartz结合数据库锁表。毕设场景下第一种方案就够了加一个nucleic.task.enabled配置项生产环境只有一台机器为true。顺带说一个细节定时任务里如果有批量操作一定要控制单批大小并记录日志。假设你有十万条过期记录要处理一条UPDATE语句直接跑可能锁表影响在线预约接口分批处理是个好习惯。5.3 接口查询慢如何通过索引优化预约记录表的数据量一旦上来用户查询我的预约列表就会变慢。我见过最离谱的例子一张表只几万条数据查询竟然要800毫秒原因是appointment_date字段没有索引每次查询都是全表扫描。排查步骤很简单第一步用EXPLAIN SELECT * FROM appointment_record WHERE user_id 1 ORDER BY appointment_date DESC查看执行计划。如果type是ALL说明遍历了全表需要加索引。第二步建立复合索引ALTER TABLE appointment_record ADD INDEX idx_user_date (user_id, appointment_date);第三步再次EXPLAIN确认type变成ref或range查询耗时通常能缩短到几十毫秒内。在这个项目里我从一开始就在表设计阶段预埋了这些索引——appointment_slot表的idx_site_date、appointment_record表的idx_user_id和idx_slot_id就是为了避免数据量上来之后再返工。索引设计必须在建表时想清楚否则后期加索引要锁表线上环境风险很大。5.4 接口返回了成功的状态码但数据库没数据这算是事务方面非常经典的坑。我当时排查过一个学员的问题预约接口返回了200和预约成功的提示但数据库里就是查不到记录。排查到最后发现他写的Service方法里先执行了预约记录插入然后调用了一次外部接口短信通知短信接口超时抛了异常但由于方法上没有Transactional注解前面的插入操作已经被自动提交了异常发生后数据还是落库的——等等如果他说是数据库没数据那问题更可能出在别处。最后真相大白他插入的数据主键用的是手动生成的UUID字符串字段类型在数据库里是bigint插入时MySQL自动做了类型转换没报错但也没真正写入实际上看一下日志数据库是报了一个隐式转换警告的。所以这里要强调实体类的主键类型必须和数据库主键类型严格对应字符串主键用varchar长整型主键用bigint不要依赖MySQL的隐式转换。每次遇到这类状态码正常但数据不对的问题我建议的第一步永远是看应用日志里的SQL执行情况打开MyBatis的SQL日志输出一秒钟就能定位是SQL没执行、执行了没提交、还是提交到了错误的库表。写在最后按个人经验来收个尾做这个项目我自己最大的感受是技术栈其实都很常规Spring Boot那一套查文档都能写出来真正拉开差距的是对业务并发和数据一致性的理解。你愿不愿意为两个用户同时抢最后一个名额这种极小概率事件多写两行代码决定了你的方案是课程作业级别还是工程实践级别。如果你正在做这个毕设我给三个建议第一把数据库设计重新画一遍重点标出每个表的索引和状态字段第二用JMeter把预约接口的并发场景测一遍亲眼确认数据不会错第三论文里写清楚方案选型时为什么不用悲观锁、为什么不用纯Redis做存储、为什么选择Spring Boot而不是SSM——这些思考过程比结果本身更有说服力。再分享一个小技巧答辩演示的时候提前准备好一个线上故障修复的小故事比如并发压测时发现超卖然后怎么定位、怎么修复、复测结果如何。答辩老师特别吃这一套因为它证明你不是只会写代码而是真的具备排查问题的能力。祝顺利。
返回列表