每年到了毕设季,总能看到一大批同学抱着同一个题目来找我:“基于Java springboot大学生家教预约管理系统”。说实话,第一次听到这个选题时,我也觉得这不就是一套标准的增删改查吗?可真把需求拆开才发现,预约类系统的复杂度被严重低估了:时间冲突处理、订单状态流转、三种角色权限、并发下防止同一时段被重复预约,任何一个环节没做好,演示现场都可能当场翻车。
这篇我打算把整个项目从需求拆解到落地的完整过程讲一遍,包括我是怎么梳理业务的、为什么选Spring Boot这套技术栈、数据库表怎么设计才扛得住预约场景、核心预约链路怎么实现,以及一些真实的启动失败和事务失效踩坑记录。目标是让正在做课设、毕设,或者想拿Spring Boot做第一个完整项目的同学,看完能少走一周弯路。
1. 需求梳理先行:家教预约系统到底在管什么
1.1 三类角色和一条主线流程
家教预约系统的本质,是一个连接“有空闲教学时间的人”和“有补课需求的人”的撮合平台。只不过这个平台卖的是时间,而时间天然具有排他性——同一个老师周六上午10点到12点只能教一个学生。这就决定了它跟普通电商系统的核心逻辑有本质差别。
系统涉及三类角色:
- 学生/家长:注册登录、按科目/时段/预算筛选老师、发起预约、确认完成授课、对老师进行评价。
- 家教老师:完善个人资料(学校、年级、专业、擅长科目、可教时段、课时费)、接收学生的预约申请、接单或拒单、标记授课完成。
- 管理员:审核老师资料、维护科目分类、查看订单数据、处理异常订单。
主线流程我最终定义成:学生浏览老师或发布补课需求 -> 老师收到预约申请 -> 接单 -> 线下授课 -> 老师标记完成 -> 学生确认并评价 -> 订单归档。这里值得注意的一点是:系统里可以同时存在“学生直接预约老师的固定时段”和“学生发布需求等待老师接单”两个入口,它们对应不同的交互形态,但最终都收敛到同一张订单表,只是订单的来源字段不同。我建议两个入口都做,功能完整度会明显提升,而且实现成本并不高。
1.2 功能清单先于建表
需求阶段最忌讳一上来就建表。先整理功能清单,后面所有表结构、接口设计都围绕它展开。
| 模块 | 功能点 | 说明 |
|---|---|---|
| 用户中心 | 注册/登录、个人信息维护、密码修改 | 区分学生、老师、管理员三类账号 |
| 教师模块 | 教师资料认证、可教时段设置、课时价格维护 | 教师资料需后台审核通过才可接单 |
| 科目管理 | 科目分类维护、按科目检索 | 管理员维护科目表 |
| 需求广场 | 学生发布补课需求、老师浏览接单 | 需求状态与订单表关联 |
| 预约管理 | 发起预约、接单/拒单、取消、确认完成 | 核心状态流转,详见第4章 |
| 评价系统 | 评分、评价内容、评价列表 | 仅允许已完成订单评价 |
| 消息通知 | 站内信、预约状态变更提醒 | 状态变更时写入消息表 |
| 后台管理 | 教师审核、订单查看、数据统计 | 管理员专属权限 |
这套清单对应着绝大多数“管理系统类”项目的常见模块。但真正能拉开差距的,不是CRUD是否齐全,而是预约部分的时间冲突和状态流转有没有做对。
1.3 容易被忽略的隐形需求
有几个隐形需求,是我做完一遍之后回头补上的,但补完之后项目立刻像样了很多:
一是“同一时段不能重复预约”。没有这条约束,演示时一旦有人同时约了某老师周六上午10点的时段,系统直接出丑。这不只是代码逻辑问题,还涉及并发场景下的数据一致性,后面单独讲。
二是“订单状态不能乱跳”。业务上不允许从“已完成”回到“待接单”,也不允许从“已取消”变成“授课中”。状态流转必须在一个可控的状态机内进行。
三是“支付状态的预留”。毕设一般不接真实支付,但订单表里要有pay_status(未支付/已支付/已退款)和amount字段。这样业务闭环更完整,以后想接微信或支付宝支付,只需要加一个支付回调接口,不需要改表结构。
四是“取消订单的时间边界”。比如约定上课时间12小时前允许学生免费取消,之后取消需扣除部分费用。这个规则哪怕你只在文档里写明、代码里做简化版,也会让评审或面试官觉得你思考过真实业务。
需求梳理到这里,主干就清晰了。我最早做类似项目时,拿到题目直接开Navicat建表,后来几乎所有返工都来自表结构没对齐业务。先花两天把需求理清,后面至少省一周的返工时间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型复盘:为什么Spring Boot是这类项目的最优解
2.1 自动配置机制省掉的“配置地狱”
技术栈选型上,Spring Boot基本没有悬念。现在回头从零搭一个Spring MVC + XML配置的老项目,光数据源、MyBatis、视图解析器、事务管理器这几样配置就够折腾半天。Spring Boot的核心价值,是把“按条件自动装配”做成了框架能力:引入一个spring-boot-starter,配置文件里写上数据源连接信息,框架会自动创建DataSource、SqlSessionFactory、TransactionManager这些底层组件。我们只需要写业务代码,不用关心组件装配过程。
当然,自动配置的原理必须懂一点,答辩和面试都爱问。一句话概括:spring-boot-autoconfigure包里有一堆XxxAutoConfiguration类,框架通过SpringFactoriesLoader加载META-INF/spring.factories(新版是AutoConfiguration.imports)里声明的配置类,再配合@ConditionalOnClass、@ConditionalOnProperty这类条件注解判断当前类路径下是否有对应依赖、配置文件中是否指定了参数,条件满足才会创建对应的Bean。理解到这一层之后,遇到“为什么我的配置没生效”这类问题,排查起来就有了方向,不会瞎试。
2.2 数据访问层与前端方案的搭配
数据层我用了MyBatis-Plus。选它的原因非常实际:这个项目里一半以上的表操作是单表查询和简单更新,MyBatis-Plus自带BaseMapper的通用方法能省掉大量重复SQL;复杂查询(比如按科目、时段、价格组合筛选老师)可以用QueryWrapper,也可以自己在XML里写SQL。我的习惯是:筛选、统计类复杂SQL自己写在XML里,完全可控;简单CRUD交给通用方法,开发效率高很多。
前端这块,市面上的解决方案主要有两种:Vue前后端分离,或者Thymeleaf服务端渲染。如果是毕业设计,我更推荐Vue + Element UI的前后端分离模式。有三个理由:
第一,现在主流公司都是前后端分离,这个方向对就业更友好;第二,前端路由、Axios请求封装、跨域处理这些内容能体现完整技术栈;第三,演示视频里页面观感好很多,答辩时UI是第一印象。
如果不想单独部署前端,可以把Vue项目执行一次npm run build,把dist目录里的静态文件拷贝到Spring Boot的src/main/resources/static下,最终打成单个jar直接运行。这个方案要注意一点:前端代码里的接口地址不能写死成http://localhost:8080,最好用相对路径/api,后端接口统一加前缀,否则部署到服务器上会出现“页面打开了、所有数据请求全404”的奇怪问题。我第一次这么干的时候踩过,后来查了半天才发现是请求路径写死了本机地址。
2.3 版本与JDK搭配是第一个拦路虎
版本选择必须单独提,因为它的坑又深又隐蔽。当前Spring Boot主要分2.7.x和3.x两条大版本线,差异非常大:
| 版本线 | JDK要求 | 关键包名 | 资料生态 |
|---|---|---|---|
| Spring Boot 2.7.x | JDK 8/11 | javax.* | 资料最多,网上教程90%可以直接照抄 |
| Spring Boot 3.x | JDK 17+ | jakarta.* | 资料较少,老代码照搬大概率报错 |
做课设或毕设,我的建议是用Spring Boot 2.7.x + JDK 8/11。不是3.x不好,而是当你需要查“拦截器怎么配”“Spring Security路径放行怎么写”这类问题时,2.7的资料存量最大,踩坑后的解决方案最容易找到。如果你想在简历上体现对新技术的跟进,可以研究Spring Boot 3.x,但要做好自己处理javax到jakarta包名迁移、Spring Security 6配置API大改的准备。
另外强烈提醒:Spring Boot版本和MyBatis-Plus版本必须匹配。MyBatis-Plus 3.5.3以上对Spring Boot 3的支持才比较成熟。如果用了Spring Boot 3.x再配一个老版MyBatis-Plus,启动时会直接抛Invalid bean definition之类的异常。这个坑曾让不少同学在项目还没开始写代码时就被劝退。
技术选型的最终结论可以浓缩成一句话:选你最有把握、资料最多、能讲得最清楚的那套。项目分值永远来自“你能完整说出来”的部分,而不是“你用了最新版”的部分。
3. 数据库设计:预约系统的地基,全看冲突处理和状态字段
3.1 核心表结构拆解
数据库是整个项目最不该偷懒的地方。预约系统最怕的是“业务上不允许坏的数据,在表结构上没有任何兜底”。我最终落地的核心表大概这样:
用户表(sys_user)
sql复制CREATE TABLE sys_user (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名',
password VARCHAR(100) NOT NULL COMMENT 'BCrypt密文',
role TINYINT NOT NULL COMMENT '1-学生/家长 2-老师 3-管理员',
phone VARCHAR(20),
real_name VARCHAR(50),
avatar_url VARCHAR(255),
status TINYINT DEFAULT 1 COMMENT '1-正常 0-禁用',
create_time DATETIME,
update_time DATETIME
);
教师资料表(teacher_profile)
sql复制CREATE TABLE teacher_profile (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
school VARCHAR(100),
major VARCHAR(100),
grade VARCHAR(30),
introduce TEXT,
hourly_rate DECIMAL(10,2) COMMENT '课时单价',
audit_status TINYINT DEFAULT 0 COMMENT '0-待审核 1-通过 2-拒绝',
audit_remark VARCHAR(255),
UNIQUE KEY uk_user (user_id)
);
可教时段表(teacher_time_slot)
sql复制CREATE TABLE teacher_time_slot (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
teacher_id BIGINT NOT NULL,
week_day TINYINT COMMENT '1-7 周一到周日',
start_time TIME NOT NULL,
end_time TIME NOT NULL,
slot_status TINYINT DEFAULT 1 COMMENT '1-可用 0-禁用',
UNIQUE KEY uk_teacher_week_time (teacher_id, week_day, start_time, end_time)
);
预约订单表(appointment_order)
sql复制CREATE TABLE appointment_order (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
order_no VARCHAR(32) NOT NULL UNIQUE,
demand_id BIGINT COMMENT '来源需求ID,可为空',
teacher_id BIGINT NOT NULL,
student_id BIGINT NOT NULL,
subject_id BIGINT NOT NULL,
lesson_date DATE NOT NULL,
start_time TIME NOT NULL,
end_time TIME NOT NULL,
amount DECIMAL(10,2),
pay_status TINYINT DEFAULT 0 COMMENT '0-未支付 1-已支付 2-已退款',
order_status TINYINT DEFAULT 0 COMMENT '见状态机',
remark VARCHAR(255),
create_time DATETIME,
update_time DATETIME,
KEY idx_teacher_date (teacher_id, lesson_date)
);
此外还有科目表(subject)、需求表(tutor_demand)、评价表(order_comment)。其中评价表建议对order_id建唯一索引,保证一个订单只能评一次。
3.2 时段冲突的两种处理方案
时段冲突是预约系统里最核心的数据问题,必须在表结构层面就有预案。我给出两种方案,你可以根据自己的技术深度选择:
方案一:唯一索引兜底。给预约订单表加(teacher_id, lesson_date, start_time)的唯一索引。这样即使业务代码出了问题,数据库层面也保证了同一老师同一天同一开始时间只能有一条预约记录。缺点是无法覆盖“开始时间不同但时间段重叠”的场景,比如老师9:00-11:00已预约,新订单想约9:30-10:30,这种重叠用唯一索引就拦不住。
方案二:Service层做重叠区间校验。发起预约时,查询该老师当天所有有效预约,判断新旧时间段是否重叠。核心SQL类似这样:
sql复制SELECT COUNT(*) FROM appointment_order
WHERE teacher_id = ? AND lesson_date = ?
AND order_status NOT IN (4, 5) -- 排除已取消、已退款
AND start_time < #{endTime}
AND end_time > #{startTime}
这条SQL的逻辑是:找出所有“开始时间早于新预约结束时间、并且结束时间晚于新预约开始时间”的记录。只要查到任何一条,就说明时间段存在交集,直接拒绝预约。
我的实际建议是:方案一和方案二叠加使用。先用Service层重叠校验,给出友好的“该老师此时段已被预约,请选其他时间”提示;再用数据库唯一索引兜住极端并发情况。双保险的好处是,演示时你可以故意开两个浏览器同时提交同一时段的预约,系统依然不会出现脏数据。这种细节本身就是答辩加分项。
3.3 订单状态字段:不要用裸的0/1/2
很多新手设计订单状态时,直接用一个TINYINT,在注释里写着1预约中、2已接单、3已完成。能用,但是代码里到处散落魔法数字,状态流转没有约束,不同模块的判断口径还可能不一致。
推荐的做法是建一个OrderStatus枚举,把所有状态和流转规则集中管理。数据库里仍然存int值,但Java代码里不再出现裸数字。简单的枚举骨架长这样:
java复制public enum OrderStatus {
PENDING(0, "待接单"),
ACCEPTED(1, "已接单"),
IN_PROGRESS(2, "授课中"),
COMPLETED(3, "已完成"),
CANCELLED(4, "已取消"),
REFUNDED(5, "已退款");
private final int code;
private final String desc;
// 定义允许的流转:from -> to
private static final Map<Integer, Set<Integer>> ALLOWED_TRANSITIONS = new HashMap<>();
static {
ALLOWED_TRANSITIONS.put(0, Set.of(1, 4));
ALLOWED_TRANSITIONS.put(1, Set.of(2, 4));
ALLOWED_TRANSITIONS.put(2, Set.of(3, 4));
ALLOWED_TRANSITIONS.put(3, Set.of(5));
}
public static boolean canTransition(int from, int to) {
return ALLOWED_TRANSITIONS.containsKey(from)
&& ALLOWED_TRANSITIONS.get(from).contains(to);
}
}
Service层每次更新订单状态前,统一调用OrderStatus.canTransition(oldStatus, newStatus),不合法就直接抛异常。这样状态流转逻辑集中、可测试、可解释,答辩时直接讲“我用状态机管理订单生命周期”就比“我用if判断”高级得多。
4. 核心预约链路:状态机与并发防重
4.1 状态机定义与流转校验
预约订单的状态流转,我建议按下面的表来设计。不同项目的叫法可以微调,但核心思想是明确“谁在什么条件下可以做什么操作”。
| 当前状态 | 允许流转到 | 触发动作 |
|---|---|---|
| 待接单 (0) | 已接单 (1)、已取消 (4) | 老师接单 / 学生取消 |
| 已接单 (1) | 授课中 (2)、已取消 (4) | 开始授课 / 双方协商取消 |
| 授课中 (2) | 已完成 (3)、已取消 (4) | 老师标记完成 / 超时异常取消 |
| 已完成 (3) | 已评价 (5) | 学生提交评价 |
| 已取消 (4) | — | 终态 |
| 已退款 (5) | — | 终态 |
实际开发里,我会把“已评价”单独拆成一个状态,而不是用评价表是否存在来反推。因为“订单完成”和“学生是否评价”是两件独立的事,合并在一起会让查询变得别扭。
4.2 并发防重:仅校验远远不够
很多人在写创建预约时只做了一件事:查询有没有冲突,没有就插入订单。这在单用户操作下没问题,但并发场景下会翻车。想象两个学生同时点击“预约”按钮,两个请求同时通过了冲突检测,然后先后插入订单,数据库里就出现了同一时段的两条有效预约。
解决这个问题有几种手段,按推荐程度排列:
第一,数据库唯一索引兜底。这是最硬的一道防线。前面在表结构中提到的(teacher_id, lesson_date, start_time)唯一索引,就是干这个用的。当第二个请求插入时,数据库会直接报DuplicateKeyException,代码里捕获这个异常并转成“该时段已被抢走”的提示。
第二,乐观锁。给可教时段表加一个version字段,更新时段状态时带上version条件,例如:
sql复制UPDATE teacher_time_slot
SET slot_status = 0, version = version + 1
WHERE id = ? AND slot_status = 1 AND version = #{version}
如果更新影响行数为0,说明时段已经被别人占用了。这种做法的好处是不需要锁表,适合读多写少场景。
第三,悲观锁。在事务里执行SELECT * FROM teacher_time_slot WHERE id = ? FOR UPDATE,把该时段的行锁住,直到事务提交。这是最稳妥但并发能力最低的方式。单机毕设项目用它完全没问题,但我建议至少在文档里提一句它的局限性,显得你思考过。
我实际项目里用的是“Service层重叠校验 + 数据库唯一索引 + 捕获DuplicateKeyException”的组合。代码组织大致是这样的:
java复制@Transactional(rollbackFor = Exception.class)
@Override
public AppointmentOrder createOrder(CreateOrderDTO dto) {
// 1. 重叠校验(业务层友好提示)
int conflictCount = orderMapper.countConflict(
dto.getTeacherId(), dto.getLessonDate(),
dto.getStartTime(), dto.getEndTime());
if (conflictCount > 0) {
throw new BusinessException("该老师此时段已被预约,请重新选择");
}
// 2. 生成业务订单号
String orderNo = OrderNoGenerator.generate();
// 3. 插入订单,唯一索引兜底
AppointmentOrder order = buildOrder(dto, orderNo);
try {
orderMapper.insert(order);
} catch (DuplicateKeyException e) {
throw new BusinessException("手慢了,该时段刚刚被预约");
}
// 4. 发送站内通知
notifyService.send(NoticeType.APPOINTMENT_CREATED,
dto.getTeacherId(), "您收到一条新的预约申请");
return order;
}
注意这里有个关键点:如果依靠数据库唯一索引兜底,那么catch的是DuplicateKeyException;如果你用了乐观锁更新时段表,那么判断的是更新影响行数。两条路线写法和报错位置都不同,别混着来,不然排查会很痛苦。
4.3 Service层事务边界怎么定
事务边界也是做完被坑出来的经验。创建预约这个方法,我把“校验冲突”“插入订单”“发送通知”放在同一个事务里。但细想其实有讲究:
校验冲突加插入订单必须在同一个事务内,因为这两个操作要么一起成功要么一起失败。而发送站内通知属于外部副作用,理想情况应该是事务提交成功后再发,避免“订单没创建成功但通知发出去了”这种尴尬。对于毕设项目,你可以简单粗暴地放在事务里,但我更建议了解一个进阶方案:用Spring的TransactionSynchronizationManager注册事务提交后的回调:
java复制TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
notifyService.send(NoticeType.APPOINTMENT_CREATED,
dto.getTeacherId(), "您收到一条新的预约申请");
}
});
这样写的好处是,只有事务真正提交了,通知才会发出去。这个细节如果讲给答辩老师听,他会觉得你确实理解事务边界,而不是只会加个@Transactional注解。
5. 权限与安全:最容易翻车却又最常被忽视的部分
5.1 登录认证选型:JWT还是Session
预约系统有三类角色,登录认证和权限控制就不能不做。前后端分离模式下,我选的是JWT + 拦截器方案。理由很简单:
Session方案依赖Cookie,前后端分离部署时会遇到跨域携带Cookie的各种限制;JWT是无状态token,前端拿到后存在本地,每次请求放到Authorization请求头里,后端拦截器解析校验即可,跨域也只需要配一个CORS规则。
当然JWT也有明显的短板:服务端不保存状态,所以无法主动“踢人下线”、无法强制失效某一个token。对于毕设项目这个短板可以接受,但建议在文档里写一句“如果生产环境需要主动失效会话,可以用Redis保存token白名单”,显得你思考过方案边界。
密码存储必须用BCrypt加密,Spring Security里的BCryptPasswordEncoder可以单独引入使用。注意不要在数据库里存明文密码,也不要只用简单MD5。答辩时“我的密码是BCrypt加盐哈希保存的”比“我加密了”严谨得多。
5.2 三种角色权限与拦截器设计
权限模型我采用“登录即可访问大部分功能 + 管理员接口单独校验”的方式。核心实现是一个拦截器:
java复制public class JwtInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 放行预检请求
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
// 从请求头获取token
String token = request.getHeader("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
throw new BusinessException(401, "未登录或登录已过期");
}
// 解析token,拿到用户信息
LoginUser loginUser = JwtUtil.parseToken(token.substring(7));
// 放入request属性,后续Controller里直接取
request.setAttribute("loginUser", loginUser);
return true;
}
}
注册拦截器时,放行登录、注册接口和GET类型的公共查询接口,其余路径全部拦截:
java复制@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new JwtInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/api/auth/login", "/api/auth/register", "/error");
}
}
管理员接口单独校验。比较简单的做法是在需要管理员权限的Controller方法上加一个自定义注解@RequireRole("ADMIN"),拦截器里解析到这个注解后比对当前用户角色,权限不够直接返回403。这个做法比在方法里反复写if判断干净得多,也容易讲清楚。
5.3 Spring Boot默认CGLIB代理带来的实务影响
这个话题在面试里考察频率很高,也直接影响项目代码该怎么写。Spring Boot从2.x开始,默认proxyTargetClass=true,也就是默认使用CGLIB动态代理,而不是JDK动态代理。这意味着两件事:
第一,你的Service类不实现接口也能被Spring代理,事务注解、拦截器功能照样生效。所以现在很多Spring Boot项目都不写接口,直接就是Service类,这是完全正常的。
第二,同类内部调用不会经过代理对象。这是最隐蔽的坑。比如:
java复制@Service
public class OrderServiceImpl {
public void cancelOrder(Long orderId) {
// 业务校验...
this.updateOrderStatus(orderId, 4); // 自调用
}
@Transactional(rollbackFor = Exception.class)
public void updateOrderStatus(Long orderId, Integer status) {
// 更新数据库...
}
}
外面调用cancelOrder时,看起来updateOrderStatus上的@Transactional应该生效,但实际上不会。因为cancelOrder内部用的是this指针调用,走的是原始对象,不是Spring生成的代理对象,事务切面根本没有机会介入。
解决办法有三个:一是把updateOrderStatus拆到另一个Service类里,注入后调用;二是注入自身代理:@Autowired private OrderService self;,然后self.updateOrderStatus();三是用ApplicationContext.getBean获取代理对象再调用。我推荐第一种,“拆分到另一个类”最简单也最直观。
另外补充一点:private方法上加@Transactional也是无效的,动态代理根本不会拦截private方法。写代码时要注意,别在这个问题上浪费两天调试时间。
6. 实测踩坑记录:从启动失败到定时任务失效
6.1 Spring Boot版本过高引发的连锁问题
我最早尝试用Spring Boot 3.3 + JDK 17来做这套系统,结果刚起步就被一系列兼容性问题绊住了。最典型的一个是:从网上复制的代码里用了javax.servlet.http.HttpServletRequest,Spring Boot 3里这个包已经改成了jakarta.servlet.http.HttpServletRequest,直接编译报错。改完一个又有下一个,Spring Security 6的配置方式跟5完全不同,MyBatis-Plus老版本也不兼容。折腾两天之后,我换回了Spring Boot 2.7.x + JDK 11,所有问题瞬间消失。
这不是说3.x不好,而是说要学会算环境成本。做项目最重要的是在有限时间内把业务闭环做完整,而不是在环境兼容性上消耗大量时间。我的建议是:如果网上找不到你电脑上这套版本组合的现成解决方案,果断切回生态最成熟的版本,不值得硬刚。
6.2 事务注解失效的三种现场
事务失效是这类项目里最高频的Bug,至少我见过的同学里,十个有八个都栽过。常见的失效现场有三个:
第一种,异常被吞掉。@Transactional默认只在RuntimeException和Error时触发回滚。如果你在方法里catch住了异常,没有重新抛出,事务照样提交。比如:
java复制@Transactional
public void completeOrder(Long orderId) {
try {
orderMapper.updateStatus(orderId, 3);
} catch (Exception e) {
log.error("更新失败", e);
// 异常被吞掉,事务不会回滚
}
}
这可能是最常见也最容易忽略的。解决方法是:如果是可恢复的异常,记录日志后重新抛出或标记事务回滚;如果确实不需要回滚,那这个catch就要明确理由。
第二种,自调用绕过代理。也就是前面讲CGLIB那部分,this.method()直接原始对象调用,事务注解不生效。
第三种,非public方法加@Transactional,代理不处理private方法,注解形同虚设。
排查事务失效问题时,我建议先检查这三点,90%的现场都在这里。
6.3 @Scheduled定时任务的单线程阻塞
系统里需要做课前提醒:上课前1小时给老师和学生发一条站内通知。我用的是Spring自带的@Scheduled:
java复制@Component
public class LessonReminderTask {
@Scheduled(cron = "0 0 * * * ?") // 每小时执行一次
public void remindBeforeLesson() {
// 查询2小时后开始的预约,发送通知
}
}
功能本身没问题,但要注意一个隐藏特性:Spring默认的定时任务调度器,核心线程池大小是1。也就是说所有@Scheduled任务都在同一个线程里排队执行。如果某一个任务执行时间过长,比如发送通知的数据库查询很慢,或者某次发送接口超时,后续任务就全被堵住,表现为“到点了没执行”。
处理办法有两种。简单做法是在配置类里设置线程池大小:
java复制@Configuration
public class ScheduleConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.setScheduler(Executors.newScheduledThreadPool(5));
}
}
进阶做法是给耗时操作加上@Async注解,让它在业务线程池里异步执行,定时任务只负责触发。另外提醒一下:cron表达式在Spring里是6位,不是7位,不要在网上复制Linux的7位cron直接贴进来,秒级之后多出来的那一位会导致启动报错。
6.4 排查链路还原:一次数据库连接耗尽问题
最后分享一个完整排查链路,这类思路比答案更重要。当时系统跑了一会儿之后,页面开始偶尔报“连接不可用”,再过一阵直接全部超时,日志里出现Connection is not available, request timed out。
排查过程我按顺序做了四步:
第一步,看数据库连接池监控。确认HikariCP的active连接数是否达到最大值。我配置里写的maximum-pool-size是10,日志显示active已经稳定在10。
第二步,看哪些请求占用了连接。打开日志里的慢SQL记录,发现有一条统计报表SQL,在数据量增大之后执行时间从几十毫秒变成了好几秒。每条慢SQL在事务结束后才释放连接,并发一高就把连接池占满了。
第三步,确认事务边界。这里发现根本原因——统计报表方法上加了@Transactional,而方法内部做了大量计算和多次数据库查询,整个事务从第一个查询开始就持有连接,直到最后一个计算完成才释放。这纯属事务粒度太大。
第四步,修复:去掉统计查询上的事务,或者把统计放到独立的只读事务里跑,并将结果缓存到本地。改完之后连接池占用率立刻降下来,系统恢复正常。
这个排查过程如果能在讲解视频里讲一遍,效果会非常好。它展示了你不是只会写CRUD,而是真遇到过问题、能独立解决。
7. 交付物组织:源码、文档、运行视频、讲解视频怎么准备才有说服力
7.1 文档不是堆字数,是让看的人能复现
源码、文档、运行视频、讲解视频这四件套里,文档最容易被敷衍,但对答辩和面试最重要。我的文档组织方式是这样划分的:
需求分析文档:包含用例图、核心流程图、功能清单。重点是让看的人明白系统解决了什么问题、分哪些角色、核心流程怎么走。
数据库设计文档:包含ER图、每个表的字段说明、状态机定义。这份文档其实是技术评审重点,把第3章和第4章的内容整理进去就足够有料了。
接口设计文档:列出每个接口的路径、请求参数、返回格式。我建议统一返回格式为{code, message, data},文档里给出两三个示例,方便别人对接。
部署文档:写清楚环境要求(JDK版本、Maven版本、MySQL版本)、初始化SQL执行步骤、配置文件修改点、启动命令、默认测试账号。目标是让一个完全没碰过这个项目的人,按文档操作能在半小时内跑起来。这个要求很简单,但能做到的人不多,做到了就是加分项。
7.2 运行视频和讲解视频的录制思路
运行视频不必长,5到8分钟足够,但必须走完整的核心链路:学生注册登录 -> 浏览老师 -> 发起预约 -> 老师接单 -> 课程完成 -> 学生评价。录制前清空浏览器缓存和数据库里的脏数据,保证演示过程干净流畅。鼠标移动要慢,重点操作要停留一两秒。另外建议屏幕分辨率调高一些,视频清晰度直接決定观感。
讲解视频是重头戏,建议控制在15到20分钟,顺序按“项目背景一句话 -> 技术架构 -> 数据库设计 -> 核心代码 -> 运行演示”来组织。不要照着代码逐行念,而是挑两个最能体现深度的点深入讲:一个是订单状态机和并发防重,一个是权限拦截器设计。把“当时遇到了什么问题、为什么这么解决、有没有其他方案”讲清楚,比任何代码朗读都有说服力。
7.3 让项目真正“长在你身上”的几个亮点
评审或面试时,大家最想确认的是“这个项目是不是你亲手做的、你理解到什么程度”。有几个点我可以根据自己的经验列一下,都是能讲出内容、不容易被问倒的:
- 统一返回结果和全局异常处理。Controller不再返回裸对象,统一走Result封装,全局异常处理器把业务异常和系统异常分开处理。
- 状态机管理订单全生命周期。回答“为什么不用if判断”时,可以说状态流转集中管理、可测试、可扩展。
- 数据库唯一索引与业务校验双层防重。这是预约系统最核心的亮点,必须讲透。
- 定时任务与线程池配置。体现出你对Spring调度机制的了解,而不是只会写一个@Scheduled。
- 日志与AOP。写一个简单的AOP切面,记录Controller层每个请求的耗时。这是成本极低但非常讨喜的小功能。
把以上内容准备到位之后,你会发现答辩或面试时,主动权基本在自己手里——你聊的都是自己真真切切踩过坑、做过决策的地方,而不是背出来的概念。
最后再分享一个小技巧:项目跑通之后,找一台没装过JDK和MySQL的干净电脑,按你的部署文档从零开始部署一遍。只要你能在一台干净机器上顺利跑起来,这个项目的成熟度就真的过关了。一个好项目不是写出来就完了,是能被别人顺利跑起来才算真正完成。
