问题背景
最近在维护一个 Spring Boot + Shiro + MyBatis-Plus 的后台管理系统时,遇到了一个诡异的 Bug:给 Controller 方法加上 @Transactional 注解后,该接口直接返回 404,去掉就恢复正常。更诡异的是,同类中的其他接口不受影响。
问题复现
@RestController
@RequestMapping("/api/admin/tools")
@RequiredArgsConstructor
public class AdminToolController {
private final ToolMapper toolMapper;
// 这个接口一直正常
@PostMapping("/list")
public Result<PageResult<Tool>> list(@RequestBody PageQuery q) {
// ...
}
// 加上 @Transactional 后,POST /api/admin/tools/create → 404
@PostMapping("/create")
@RequiresPermissions("content:tool:add")
@Transactional // ← 罪魁祸首
public Result<?> create(@RequestBody Map<String, Object> body) {
Tool tool = buildTool(body);
toolMapper.insert(tool);
return Result.ok();
}
}
去掉 @Transactional,重启,POST /api/admin/tools/create 正常响应。加上,重启,404。
排查过程
第一步:确认不是路由冲突
检查了所有 Controller,没有任何 URL 路径冲突。/api/admin/tools/create 在全局是唯一的。
第二步:确认不是 Shiro 拦截
Shiro 配置中 /api/admin/** 走 JWT 过滤器。如果认证失败,返回的是 401,不是 404。而且同一个类里其他方法都能正常访问,排除 Shiro 问题。
第三步:定位到 @Transactional
逐个对比能正常访问的接口和 404 的接口,唯一区别就是 @Transactional。去掉注解就正常,加上就 404,百发百中。
根因分析
Spring AOP 代理机制
@Transactional 基于 Spring AOP 实现。当 Spring 发现 Bean 上有 @Transactional 方法时,会为该 Bean 创建一个 CGLIB 动态代理对象。
// 你写的
@RestController
public class AdminToolController {
@PostMapping("/create")
@Transactional
public Result<?> create(...) { ... }
}
// Spring 实际放到容器里的
AdminToolController$$EnhancerBySpringCGLIB$$xxxxx // CGLIB 子类代理
关键矛盾
Spring MVC 的 RequestMappingHandlerMapping 在启动时扫描 @RestController 的 Bean,从中提取 @PostMapping、@GetMapping 等注解来构建路由映射表。
当 Bean 被 CGLIB 代理后,RequestMappingHandlerMapping 拿到的可能是代理对象而不是原始对象。在特定条件下(尤其是项目同时存在多个 AOP 切面,如 Shiro 权限注解 + 事务注解),代理链可能导致 @PostMapping 注解无法被正确解析,路由注册失败。
请求过来时,DispatcherServlet 在映射表中找不到匹配的 Handler → 404。
为什么有的项目没问题?
┌──────────────────────────────────────────┬───────────────────────────┐
│ 条件 │ 是否触发 │
├──────────────────────────────────────────┼───────────────────────────┤
│ @Transactional 在 Service 层 │ ❌ 不触发,这是标准做法 │
├──────────────────────────────────────────┼───────────────────────────┤
│ Spring Boot 使用原生 AspectJ LTW │ ❌ 不触发,LTW 不创建代理 │
├──────────────────────────────────────────┼───────────────────────────┤
│ Controller 上只有一个 AOP 注解 │ ⚠️ 低概率触发 │
├──────────────────────────────────────────┼───────────────────────────┤
│ Controller 上混用 Shiro + @Transactional │ ✅ 高概率触发 │
├──────────────────────────────────────────┼───────────────────────────┤
│ Spring 5.3.x + CGLIB 默认代理 │ ✅ 你的情况 │
└──────────────────────────────────────────┴───────────────────────────┘
简单说:不是你写错了,是 AOP 代理链的组合碰巧踩中了这个坑。
解决方案
方案一:去掉 Controller 上的 @Transactional(最简单)
@PostMapping("/create")
@RequiresPermissions("content:tool:add")
// @Transactional ← 删掉,单条 insert 本来就不需要事务
public Result<?> create(@RequestBody Map<String, Object> body) {
Tool tool = buildTool(body);
toolMapper.insert(tool);
return Result.ok();
}
方案二:事务逻辑下沉到 Service 层(推荐)
// Controller
@PostMapping("/create")
@RequiresPermissions("content:tool:add")
public Result<?> create(@RequestBody Map<String, Object> body) {
toolService.createTool(body);
return Result.ok();
}
// Service
@Service
public class ToolService {
@Transactional
public void createTool(Map<String, Object> body) {
Tool tool = buildTool(body);
toolMapper.insert(tool);
}
}
方案三:如需在 Controller 批量操作,用编程式事务
@PostMapping("/plans/{toolId}")
public Result<?> savePlans(@PathVariable Long toolId, @RequestBody List<PricingPlan> plans) {
transactionTemplate.execute(status -> {
pricingPlanMapper.delete(...);
for (PricingPlan p : plans) {
pricingPlanMapper.insert(p);
}
return null;
});
return Result.ok();
}
总结
1. @Transactional 不要放 Controller 层,即使别的项目没出问题,这也是反模式
2. 事务最好放在 Service 层,职责清晰、代理稳定
3. Controller 的单条 insert / update / delete 不需要 @Transactional,MyBatis / JPA 自带原子操作
4. 遇到 "加了注解就 404" 这种诡异问题,优先怀疑 AOP 代理干扰了路由映射
---
▎ 一句话记住:Controller 做路由分发的,Service 做业务逻辑的,事务是业务逻辑的一部分,放 Service。
{{commentItem.nickName}}
{{formatIntervalTime(commentItem.createTime)}}{{childComment.nickName}} {{childComment.replyNickName}}
{{childComment.createTimeDescribe}}