ARTICLE DETAIL

资讯详情

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

Spring Boot高校讲座预约系统实战:并发控制与事务管理的关键设计

Spring Boot高校讲座预约系统实战:并发控制与事务管理的关键设计 1. 需求梳理高校讲座预约的真实痛点与功能边界我在高校信息化建设这块摸爬滚打了几年接触过不少讲座预约相关的需求。说实话在动手做这个基于Spring Boot的高校学习讲座预约系统之前我一度觉得这活儿挺简单——不就是发个讲座、报个名、签个到嘛。但真正深入到场景里才发现高校讲座预约面临的痛点远不止“填个表单”这么简单。先说几个特别典型的现场场景。第一种是“微信群接龙报名”这是最常见的做法但问题一堆有人复制粘贴错行有人重复接龙统计起来全靠人工截图核对一次200人的讲座光统计名单就能耗掉半天第二种是“现场排队扫码”到了讲座开场前门口挤一堆人扫码枪对着纸质签到表回头导数据还是人工活第三种是“教务处网站发公告 学院汇总”学生要先去网站看通知再找班长报名班长再汇总Excel发邮件。这个流程不但慢而且学生根本不知道自己是报上了还是没报上讲座临时改时间也压根通知不到所有人。所以真正做一个预约系统的核心价值不是把“报名表”搬到网上而是解决三个关键问题信息触达学生能及时看到讲座预告和剩余名额、预约确定性预约成功或者失败有明确反馈不用盲等、数据闭环讲座结束后的签到记录可以直接用于第二课堂学分、考勤统计、参与率分析。也正因为如此系统功能边界需要划清楚别一开始就野心勃勃什么都做。我给这个项目的核心功能圈定了六块讲座发布与审核教师/组织者提交讲座信息管理员审核后发布讲座大厅与检索学生浏览、按分类或关键词筛选、查看讲座详情预约与取消预约核心业务涉及并发控制和状态管理签到管理讲座现场通过二维码或验证码签到个人中心我的讲座、我的预约记录、取消记录管理后台讲座审核、预约名单导出、基础数据统计至于论坛、评论、弹幕互动、直播回放这类“锦上添花”的东西我建议放到二期。做项目最忌讳一上来就把系统撑大核心链路跑不通其他都是空中楼阁。明确了这些需求Spring Boot在这个场景下的优势就体现出来了开发效率高、生态成熟、部署简单适合高校这种中小规模并发但逻辑闭环完整的业务。下面我从工程结构开始逐步拆解整个实现过程。2. 工程结构设计与技术选型为什么Spring Boot是这个场景的稳妥选择2.1 一个清晰的包结构决定了后续开发心不心烦很多初学者喜欢把Controller、Service、Mapper全往一个包里塞项目一膨胀就直接崩溃。我做这个预约系统时采用的是常见的分层结构但做了一些针对性的调整com.example.lecture ├── controller // 接口层 │ ├── admin // 管理端接口 │ ├── lecturer // 讲座组织者接口 │ └── student // 学生端接口 ├── service // 业务逻辑层 │ ├── lecture │ ├── reservation │ └── checkin ├── mapper // 数据访问层 ├── entity // 数据库实体 ├── dto // 前端交互对象避免实体直接暴露 ├── config // 配置类 ├── common // 统一返回、异常处理、常量 └── util // 工具类这个结构有一个值得强调的思路接口层按照角色拆包。很多项目按功能模块拆结果就是管理端的接口和学生端的接口混在一起权限配置非常痛苦。按照角色拆包后接口路径清晰、权限注解好写、前端对接也容易管理端路径统一带/api/admin前缀通过网关或拦截器做权限控制非常直观。另外DTO和Entity分离是个老话题但我还是要强调不要把数据库实体直接当接口返回对象。比如讲座实体里有数据库自增ID、内部备注字段、创建时间这些大概率不该直接暴露给前端。用DTO做一层隔离接口更安全前端拿到的字段也更干净。2.2 技术选型对比这几个组合是怎么定下来的Spring Boot版本上我用的还是2.7.x系列。有人会问Spring Boot 3.x都出来一年多了为什么不上新版原因很实在高校系统最看重稳定很多依赖库对Jakarta EE迁移的适配程度参差不齐尤其是一些老牌的权限框架和工具库在Spring Boot 3.x上的兼容方案还没有经过足够长时间的生产验证。Spring Boot 2.7还在维护期内对JDK 8/11支持良好足够支撑这个项目。如果你是新项目且团队对新技术栈掌控力强上3.x也没问题但没必要为了“新”而新。数据访问层我选择了MyBatis-Plus而不是 Spring Data JPA。原因有两个一是团队里很多同学对SQL更熟悉MyBatis-Plus在保留SQL灵活性的同时提供了大量单表CRUD封装写起来效率比原生MyBatis高很多二是预约系统里有大量“剩余名额扣减”“状态更新”这类简单更新操作MP的UpdateWrapper用起来非常顺手。JPA更适合领域驱动设计玩得溜的团队但在这种业务明确、表结构清晰的系统里反而显得笨重。数据库用了MySQL 8.0这是高校场景的绝对主力。Redis我引入得比较克制——只用来做两件事讲座列表页的缓存和预约接口的分布式锁。不滥用缓存是因为这个系统核心数据的一致性要求很高名额、状态缓存一旦和数据库不一致用户在预约时看到的“还有5个名额”点进去却提示“已满”这种体验比慢一点更糟糕。所以缓存命中率低的数据宁可走数据库查询。认证方案上我直接上了JWT Spring Security的组合。高校系统通常只有一个统一的后台不需要对接复杂的单点登录JWT的无状态特性在前端分离部署时非常舒服。后面会详细提到JWT的过期策略和用户角色声明的设计是这个系统安全性的关键点。前端部分选了 Vue 3 Element Plus Vite构建后直接打包进Spring Boot的src/main/resources/static目录最终一个jar包搞定部署。关于前端打包进Spring Boot的具体配置和经验我会在后面的踩坑部分专门写一段。3. 数据模型设计讲座、预约、签到背后的表结构推演数据模型是这个系统的基础也是最容易一开始设计失误的地方。我前前后后调整过三版表结构最终稳定下来的设计思路可以总结为一句话把“讲座”“预约”“签到”三个核心对象拆清楚状态字段用整型枚举时间字段全用LocalDateTime预留一个冗余字段做扩展。3.1 核心三张表讲座表、预约表、用户表讲座表lecture我设计的字段如下字段名类型说明idbigint主键titlevarchar(100)讲座标题lecturer_namevarchar(50)主讲人lecturer_introvarchar(500)主讲人简介contenttext讲座内容简介categoryvarchar(20)分类技术/学术/文化/职业规划等locationvarchar(100)讲座地点start_timedatetime开始时间end_timedatetime结束时间capacityint总容量remainint剩余名额statustinyint状态0草稿 1待审核 2已发布 3已结束 4已取消creator_idbigint创建人IDcreate_timedatetime创建时间update_timedatetime更新时间versionint乐观锁版本号这里有两个细节值得展开说。第一为什么单独设计remain字段而不是每次实时统计。预约系统中“剩余名额”是高频查询字段讲座大厅每加载一次就要显示一次。如果每次都SELECT COUNT(*) FROM reservation WHERE lecture_id ? AND status 1再拿capacity去减数据库压力会翻好几倍。用remain字段做冗余查询时直接取字段即可而更新时通过SQL条件控制防超卖后面讲并发时细说。第二为什么预留version乐观锁字段。预约是典型的读-改写操作先查剩余名额判断大于0再执行插入任务、扣减名额。这个过程中如果有两个用户同时操作很可能出现超卖。乐观锁版本号是解决这个问题最轻量的方案。预约表reservation是业务的核心字段如下字段名类型说明idbigint主键lecture_idbigint讲座IDuser_idbigint预约用户IDstatustinyint状态0已取消 1已预约 2已签到reserve_timedatetime预约时间checkin_timedatetime签到时间可空sourcetinyint来源1网页预约 2管理员代约这里最关键的约束是(lecture_id, user_id)的组合唯一索引。它在数据库层面堵死了同一个用户对同一场讲座的重复预约这是业务兜底的根本保障。业务代码里可以先查一次做友好提示但真正可靠的是这条唯一约束。用户表复用了常见的User表结构但增加了一个student_no学号字段。这个字段在高校场景里很重要因为签到名单导出后是需要跟学工系统对账的没有学号等于白做。3.2 讲座状态与预约状态的状态机设计状态机设计得好业务逻辑就清爽。讲座状态我设置了五个状态草稿 → 待审核 → 已发布 → 已结束 / 已取消。有一个容易漏掉的细节已结束状态的转移时点。很多人在讲座时间到了以后不处理状态导致过了时间的讲座还显示在“正在预约”列表里学生点了预约直接报错。我的做法是写了一个定时任务Spring Boot中的Scheduled每五分钟扫描一次把end_time NOW() AND status 2已发布的讲座批量更新为已结束。这个定时任务成本极低却能避免大量用户疑惑。预约状态相对简单已取消、已预约、已签到。有一条隐含的业务规则是已取消的预约不能签到已签到的不能取消。这些规则在Service层判断但为了保证极端情况下的数据一致也可以在数据层加约束或者在签到和取消的SQL语句上直接带状态条件比如UPDATE reservation SET status 2 WHERE id ? AND status 1只有状态为“已预约”的记录才能被更新为“已签到”。3.3 为什么我加了“候补队列”这个柔性功能高校讲座有一个非常常见的现象热门讲座开放预约后30秒内名额就被抢光但实际到场率往往只有70%。经常出现讲座开始前两小时有人临时有事取消预约名单上空出一两个位置而想去的同学根本不知道。所以我新增了一张waitlist表专门记录候补名单。预约名额满时用户可以选择“进候补队列”如果有人取消预约释放了名额系统按候补顺序自动推送通知。这个功能在技术上并不复杂——一张表加一个定时分配的逻辑但它带来的用户体验提升非常明显也成了这个系统在系里推广时一个很有说服力的卖点。要注意的是候补队列不能无限长否则意义不大。我的实现是每个讲座最多记录前20个候补用户超过就直接提示“候补名额已满”这样限定能让后续的自动分配逻辑简单很多。4. 核心接口实现从发布讲座到完成签到的完整链路4.1 讲座发布与审核的Service设计讲座发布了状态是“草稿”组织者确认信息无误后提交审核状态变为“待审核”。管理员审核通过后状态变“已发布”讲座大厅立即可见审核驳回则状态回到“草稿”同时返回驳回理由。这个流程里有一个我自己踩过坑后补上的逻辑驳回后必须回退状态否则组织者改完再提交管理员那端会看到一个永远审不完的“待审核”记录。我第一次做的时候驳回只是给管理员弹了个提示状态没变结果组织者修改后点击提交系统判断“当前状态是待审核不能重复提交”直接报错。用户一脸懵管理员一脸懵后来加了状态回退才顺了。核心代码如下Service public class LectureServiceImpl extends ServiceImplLectureMapper, Lecture implements LectureService { Transactional(rollbackFor Exception.class) public void submitForAudit(Long lectureId, Long operatorId) { Lecture lecture getById(lectureId); // 只有创建人才能提交审核 if (!lecture.getCreatorId().equals(operatorId)) { throw new BusinessException(无权操作该讲座); } // 草稿状态才能提交审核 if (lecture.getStatus() ! LectureStatus.DRAFT.getCode()) { throw new BusinessException(当前状态不可提交审核); } lecture.setStatus(LectureStatus.PENDING_AUDIT.getCode()); updateById(lecture); } }注意这里的Transactional注解。所有涉及写操作并且跨多张表的业务方法都必须加上事务这是Java Web开发里最基础也最容易被忽视的安全线。后面讲预约逻辑时它的作用会更加明显。4.2 预约接口的并发控制超卖问题怎么防预约接口是整个系统技术含量最高的一环。200个名额瞬间被抢空如果代码写得糙数据库被锁死都算轻的严重的会直接把剩余名额扣成负数。防超卖有几种常见思路我逐一对比后选了一个组合方案。第一种方案纯乐观锁。int rows lectureMapper.updateRemainByVersion(lectureId, version); // update lecture set remain remain - 1, version version 1 // where id ? and remain 0 and version ?如果rows 0说明名额不够或者版本号冲突业务抛异常提示“名额已满”。这种方式不需要Redis代码也简单但问题是抢购高峰时大量请求在版本号上冲突很多请求会失败重试用户体验会稍微差一些。第二种方案Redis分布式锁。在预约操作前先尝试SET lock_lecture_{id} requestId NX EX 10拿到锁才能执行查询更新逻辑。这个方案能串行化同一个讲座的预约请求但引入了Redis依赖且要处理锁的过期时间、释放、重入等一系列分布式锁的经典问题。第三种方案我最终采用的数据库原子更新 唯一索引兜底。核心思路分两步走。第一步执行“插入预约记录”的SQL第二步执行“扣减剩余名额”的SQL但扣减SQL自带条件Transactional(rollbackFor Exception.class) public Reservation reserve(Long lectureId, Long userId) { Lecture lecture lectureMapper.selectByIdForUpdate(lectureId); if (lecture null || lecture.getStatus() ! LectureStatus.PUBLISHED.getCode()) { throw new BusinessException(讲座不存在或不在预约期); } if (lecture.getRemain() 0) { throw new BusinessException(名额已满); } // 插入预约记录 Reservation reservation new Reservation(); reservation.setLectureId(lectureId); reservation.setUserId(userId); reservation.setStatus(ReservationStatus.RESERVED.getCode()); try { reservationMapper.insert(reservation); } catch (DuplicateKeyException e) { throw new BusinessException(您已预约过这场讲座); } // 原子更新剩余名额 int rows lectureMapper.decreaseRemain(lectureId); if (rows 0) { throw new BusinessException(名额已满预约失败); } return reservation; }decreaseRemain对应的SQL是UPDATE lecture SET remain remain - 1 WHERE id ? AND remain 0这个SQL里的AND remain 0是防超卖的致命关键。无论有多少并发请求同时到达数据库行的行级锁会保证这些UPDATE请求一个个串行执行剩下的名额在第一次扣到0之后后续的UPDATE影响行数全是0于是rows 0事务回滚插入的预约记录也会一并回滚。这比乐观锁版本号更直接——它不需要先查再改而是“改的时候判断条件”天然具备原子性。此外还有selectByIdForUpdate这个细节它在查询讲座记录时加了FOR UPDATE行级锁确保从查询到更新之间不会插入其他修改。实际使用中我发现预约高峰期这个行锁确实会有一定的性能影响但200~500人的讲座规模完全扛得住。如果是上万人的抢课系统那需要更细粒度的方案比如把“扣减名额”和“插入预约记录”彻底解耦但那种复杂度对高校讲座系统来说没有必要。4.3 取消预约名额释放必须和状态更新保持同一事务取消预约看似简单但处理不好会带来一批脏数据。我见过有些系统取消预约后状态改了可剩余名额没加回来导致名额越来越少最后讲座大厅显示“已满”后台一查实际到场的人根本没那么多。取消预约的正确姿势是在一个事务里把预约状态更新为“已取消”同时把讲座表的remain加1。代码逻辑如下Transactional(rollbackFor Exception.class) public void cancelReservation(Long reservationId, Long userId) { Reservation reservation reservationMapper.selectById(reservationId); if (reservation null || !reservation.getUserId().equals(userId)) { throw new BusinessException(预约记录不存在); } if (reservation.getStatus() ReservationStatus.CHECKED_IN.getCode()) { throw new BusinessException(已签到无法取消); } // 状态为已预约才能更新为已取消 int rows reservationMapper.updateStatus( reservationId, ReservationStatus.RESERVED.getCode(), ReservationStatus.CANCELED.getCode() ); if (rows 0) { throw new BusinessException(预约状态异常取消失败); } // 剩余名额恢复 int updateRows lectureMapper.increaseRemain(reservation.getLectureId()); if (updateRows 0) { throw new BusinessException(讲座不存在); } }特别说明一下updateStatus方法中带原状态条件的写法UPDATE reservation SET status 2 WHERE id ? AND status 1这个条件可以防止重复取消操作——两个请求同时取消同一预约只有一个会成功另一个影响行数为0直接抛异常。这种“状态条件更新”的思路在多个业务场景里通用建议养成习惯。另外一个隐藏的操作讲座开始前N小时禁止取消。高校讲座经常有“报名后放鸽子”的现象为了控制到座率我的设置是开场前2小时不允许取消。界面上倒计时提示后台接口再校验一次双保险。4.4 签到接口二维码方案下的状态流转签到环节我采用了“预约成功 → 生成二维码 → 现场扫码签到”的流程。学生预约成功后个人中心会显示讲座详情和二维码讲座开场时工作人员用管理端扫码枪或手机扫一扫即完成签到。签到接口核心逻辑Transactional(rollbackFor Exception.class) public void checkIn(String qrCodeContent) { Long reservationId decodeQrCode(qrCodeContent); Reservation reservation reservationMapper.selectById(reservationId); if (reservation null) { throw new BusinessException(无效的签到码); } Lecture lecture lectureMapper.selectById(reservation.getLectureId()); if (lecture null || lecture.getStatus() ! LectureStatus.PUBLISHED.getCode()) { throw new BusinessException(讲座不存在或已结束); } Date now new Date(); if (now.before(lecture.getStartTime())) { throw new BusinessException(讲座尚未开始无法签到); } int rows reservationMapper.updateStatus( reservation.getId(), ReservationStatus.RESERVED.getCode(), ReservationStatus.CHECKED_IN.getCode() ); if (rows 0) { throw new BusinessException(签到失败预约状态异常); } }二维码内容是预约记录ID的加密串。这里有个安全细节不能直接拿ID当二维码内容。否则学生可以随手改个ID扫别人的码。我的做法是把预约ID 用户ID拼起来做Base64加签签到接口先验签再解码同时校验当前扫码用户和预约用户身份。不过现场扫码通常是管理员统一扫码身份校验的严格程度可以按场景调整。签到时间窗口也值得思考。我的设定是签到开放时段为讲座开始前30分钟到结束后30分钟超出这个时间窗口一律拒绝签到。这既保证了正常签到需求又防止提前太多到场的用户误触。5. 管理端与统计功能审核、导出、数据分析的实战做法5.1 审核流程里的“粗粒度权限”与“细粒度状态校验”管理端的接口我用Spring Security做了角色权限控制PreAuthorize(hasRole(ADMIN))作用在Controller方法上简单直接。但这个粒度是粗的——它只保证了“是管理员才能调用”不能保证“这个讲座的状态允许这个操作”。所以我在Service层又做了一次状态校验前面submitForAudit方法里的lecture.getStatus() ! LectureStatus.DRAFT.getCode()就是典型例子。审核通过的方法逻辑PreAuthorize(hasRole(ADMIN)) Transactional(rollbackFor Exception.class) public void approveLecture(Long lectureId) { Lecture lecture getById(lectureId); if (lecture null) { throw new BusinessException(讲座不存在); } if (lecture.getStatus() ! LectureStatus.PENDING_AUDIT.getCode()) { throw new BusinessException(只有待审核状态的讲座才能审核); } // 发布前还要校验时间和地点是否真实有效 if (lecture.getStartTime().isBefore(LocalDateTime.now())) { throw new BusinessException(讲座开始时间不能早于当前时间); } lecture.setStatus(LectureStatus.PUBLISHED.getCode()); updateById(lecture); }有些项目把业务校验写在Controller里Service层变成一层“空壳”这是非常坏的习惯。Controller只负责接收参数、调用服务和做最基础的格式校验所有业务规则必须下沉到Service层。这样写的好处是后续如果有管理员批量导入讲座的定时任务或者开放了API给第三方系统调用它们即使绕过Controller也绕不过Service里的业务校验安全底线就兜住了。5.2 预约名单与Excel导出用什么方案最省心讲座结束后管理员第一件事就是导出预约名单交到学工处。Excel导出这个功能我对比过Apache POI和阿里EasyExcel。最终选了EasyExcel。原因非常实际POI在并发场景下写大文件容易内存溢出EasyExcel流式写入对内存友好而且注解式导出代码简洁。导出接口大概长这样GetMapping(/admin/lecture/{id}/export) public void exportReservationList(PathVariable Long id, HttpServletResponse response) { Lecture lecture lectureService.getById(id); if (lecture null) { throw new BusinessException(讲座不存在); } ListReservationExportDTO list reservationMapper.selectExportListByLectureId(id); String fileName URLEncoder.encode(lecture.getTitle() 预约名单, UTF-8); response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(utf-8); response.setHeader(Content-Disposition, attachment;filename*utf-8 fileName .xlsx); EasyExcel.write(response.getOutputStream(), ReservationExportDTO.class).sheet(预约名单).doWrite(list); }注意文件名中的中文编码问题。如果直接fileName .xlsx放进Header里前端下载时中文名会乱码。我踩过这个坑后统一用了URLEncoder.encode再加filename*utf-8的写法稳妥得很。导出名单里至少包含序号、姓名、学号、学院、预约时间、签到状态、签到时间。这份表格要直接能交到学工处或者教务系统那边入库所以字段命名要规范不能随手写拼音缩写。5.3 数据统计不搞复杂报表先看三个指标管理后台的统计功能我没有上复杂的报表系统因为高校讲座管理的决策者团委老师、学工处老师需要的核心数据其实很固定各时段讲座数量分布看看周几讲座多哪天冷场讲座平均到座率预约人数除以签到人数热门讲座Top10按预约人数排序这三个指标的SQL都不复杂无需引入专门的大数据组件。到座率统计这里有一个业务细节需要注意分母应该用“预约人数”还是“总容量”我的做法是两者都算分别叫“预约到座率”和“容量使用率”。前者反映的是学生诚信履约情况后者反映的是讲座宣传效果。两个指标一起看才能发现“讲座宣传一般但约了就来”和“宣传火爆但放鸽子严重”两种截然不同的情况。6. 踩坑实录Spring Boot实现预约系统时常见的四个坑6.1 LocalDateTime在前后端交互中的格式问题项目里所有时间字段用的是LocalDateTime但在前后端联调时遇到了大量的格式解析异常。前端传2024-05-20 14:30:00给后端后端反序列化直接报错后端返回的2024-05-20T14:30:00前端又显示不出来。解决方法是全局配置统一的JSON序列化和反序列化规则Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder - { builder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); builder.deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); }; } }配置之后前后端一律用yyyy-MM-dd HH:mm:ss格式传时间联调顺畅多了。另外一个和数据库交互的问题MySQL驱动的高版本对LocalDateTime支持很好但要注意数据库连接串上加上serverTimezoneAsia/Shanghai否则本地环境没问题服务器部署后时间会差8小时。这个问题排查起来极其痛苦我当时花了一个晚上才发现是时区问题。6.2 超卖问题为什么“先查再改”一定会出问题我最早写预约接口时用的是“先查剩余名额判断大于0再执行更新”的常规思路Lecture lecture lectureMapper.selectById(lectureId); if (lecture.getRemain() 0) { throw new BusinessException(名额已满); } int rows lectureMapper.decreaseRemain(lectureId);表面上看先查后改逻辑正确但在高并发下两个请求可能同时读到remain 1都通过了if判断然后都执行扣减最终remain变成 -1。这就是经典的超卖问题。后来我把SQL改成了UDPATE ... WHERE id ? AND remain 0问题就解决了。核心思路是把“检查条件”和“更新数据”合并成一条原子的SQL让数据库的行锁来保证并发安全而不是靠业务代码里的if判断。这个经验在电商秒杀、库存管理、甚至签到码核销里都能复用。6.3 前端Vue打包进Spring Boot后的路径和路由问题项目上线时为了部署简单我把Vue前端构建后的dist目录内容直接复制到了Spring Boot的src/main/resources/static下这样最终打出的jar包自带页面一个java -jar就能跑起来。但这个方案有两个坑。第一个坑是前端路由模式。Vue Router默认的history模式在Spring Boot里直接访问http://localhost:8080/lecture/123会返回404因为Spring Boot的静态资源处理器找不到这个路径对应的文件而前端是SPA路由是交给JS处理的。解决办法要么改成hash模式URL带#号不太美观要么配置一个转发规则Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addViewControllers(ViewControllerRegistry registry) { // 所有前端路由都转发到index.html由前端路由接管 registry.addViewController(/{path:[^\\.]*}).setViewName(forward:/index.html); } }这个配置要注意[^\\.]*这个正则它保证只有不带文件后缀的路径才转发到index.html像/api/**这类后端接口路径不会被误拦截。如果你的项目使用了/api前缀记得在转发规则里把/api排除掉。第二个坑是静态资源缓存。前端每次发版后用户浏览器还保留着旧的JS和CSS文件导致页面看不到新功能。我的做法是给打包工具配置文件名带哈希值Vite默认行为同时在Spring Boot的静态资源处理上设置较短的缓存时间。这些细节虽然不影响功能但对上线体验影响很大。6.4 定时任务与事务Scheduled里的事务边界讲座状态自动变更、候补队列自动分配这两个功能都用到了Scheduled定时任务。但定时任务里有个容易被忽略的问题Scheduled方法本身不参与Spring的声明式事务管理——事务是基于AOP的而AOP代理只拦截外部调用定时任务直接调用自身方法时Transactional是不生效的。所以我的做法是定时任务只负责任务调度具体业务逻辑拆到一个单独的Service方法里由Spring的代理对象调用事务才能正常生效。Component public class LectureScheduledTask { Resource private LectureService lectureService; Scheduled(cron 0 0/5 * * * ?) public void autoFinishLectures() { lectureService.autoFinishExpiredLectures(); } }LectureService接口里的实现类方法上的Transactional才能正常工作。这个细节曾经困扰过我因为定时任务跑起来不报错但数据总是没更新查了很久才发现是代理问题。另外一个和定时任务相关的思考轮询频率定多少合适讲座状态更新我用了5分钟一次候补队列分配用了1分钟一次。频率太高的定时任务会白白消耗资源频率太低会导致用户体验迟钝。按业务实时性要求调整即可没有统一标准。7. 从开发到上线的几点体会系统从开发到上线前前后后改动最多的反而不是功能本身而是那些“你以为不会出问题的地方”时区、编码、状态回退、并发边界。这也让我更加确信预约类系统的核心不在页面多炫而在数据一致性上。数据库事务边界划清晰、状态机定义严谨、关键SQL带条件更新这些基本功扎实了系统自然稳。个人在实际部署中的一个小建议正式上线前一定找几个人模拟一次真实抢讲座的过程——同时打开多个手机一起点预约再一起取消再看看名额数字和预约名单对不对得上。这一步能提前暴露90%的并发和状态问题。别问我怎么知道的都是现场交过学费的。
返回列表