尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Spring Boot三层架构核心解析:职责边界、事务控制与常见坑
1. 三层架构的真面目不只是分包那么简单我刚带项目组那会儿最常被新人问的一个问题就是“这代码到底该往哪一层写”很多同学能背出“Controller是控制层、Service是业务层、Mapper是数据层”可真到写代码的时候业务逻辑还是忍不住往Controller里塞SQL拼得比业务代码还长。Spring Boot项目的三层架构Controller、Service、Mapper/DAO本质上解决的是一件事让每个请求从进入到响应的全流程都有清晰的职责边界。它不是Spring Boot发明的概念而是在JavaEE时代就沉淀下来的分层思想只是Spring Boot用注解和约定把这件事变得更顺手了。但顺手归顺手边界归边界两者不能混为一谈。我自己带过的项目里见过太多因为分层不清晰导致的后续灾难一个Controller上千行、改个查询条件要动三个层的代码、事务控制散落在各种奇怪的位置、测试代码根本没法写。所以这篇文章我不打算讲那种“包结构截图概念定义”的入门教程而是从一个实际项目的角度把这三层的关系、职责边界、调用规则以及最容易踩的坑一次讲透。这篇文章适合谁正在学Spring Boot但总搞不清代码往哪写的人或者工作了一两年但项目分层比较混乱、想系统梳理一下的开发者。我会用一个完整的用户模块案例贯穿前后把每层该做什么、不该做什么、相互之间怎么协作讲得明明白白。2. 为什么一定要分层职责边界的价值远比想象中大先讲个我在面试里常问的问题如果把一个查询用户列表的功能用三层来实现和用一坨代码来实现有什么区别很多人第一反应是“三层更规范”但再往下问“规范在哪里”就说不上来了。2.1 分层的本质不是代码组织而是依赖方向的控制三层架构最核心的价值在于它规定了依赖的方向必须是从上往下Controller依赖ServiceService依赖MapperMapper依赖数据库。反过来不行。这个单向依赖的约束带来的是“可替换性”。举个例子你的Service层通过接口定义了一个方法比如UserService.findUserById(Long id)Controller只认接口不认实现。这意味着你可以今天用一个从MySQL读数据的实现明天替换成一个从Redis缓存读数据的实现只要接口签名不变Controller一行都不用改。这种能力在没有分层约束的时候是根本做不到的。我用个生活化的比喻三层架构就像一家餐厅。Controller是前台服务员负责接单接收HTTP请求、传菜返回响应Service是后厨负责决定这道菜怎么做业务逻辑Mapper是食材采购员负责去仓库数据库把原材料拿回来。你不可能让服务员自己去仓库翻食材也不可能让采购员去跟客人解释菜品做法。各干各的活才能保证餐厅正常运转。2.2 分层的“隐性收益”可测试性与并行开发分层的第二个好处体现在可测试性上。在没有Spring Boot的时候写Java Web项目最痛苦的事情就是测试——你得启动一个Servlet容器在浏览器里点来点去才能验证一个功能。但有了分层之后Service不依赖HTTP协议你可以直接写单元测试构造一个Mapper的Mock对象把Service扔进去就能测。这一点在复杂业务系统里尤其重要。我之前做过一个订单系统光是订单状态的流转逻辑就有十几种分支。如果这些逻辑都写在Controller里测试几乎没法做但放在Service层用MockMvc或者单纯的JUnit就能把每个分支都覆盖到。再说并行开发。一个项目往往需要多个人同时开发如果分层清晰前后端约定好接口Controller和Service可以并行推进不同Service之间通过接口隔离也可以并行开发。我们做某跨平台系统的时候三个人负责不同的业务模块一人管用户、一人管订单、一人管商品只靠一套接口定义一个星期就完成了联调靠的就是分层带来的低耦合。2.3 分层的代价与边界不要为了分层而分层说了这么多分层的优点也得说句公道话分层是有代价的。每多一层就多一次方法调用的开销多一些需要写的样板代码多一些需要维护的映射关系。所以不要走极端——一个只有五张表的小项目非要用五层架构那是自找麻烦。根据我个人的经验判断该不该分层的标准很简单如果这个功能可能被多个Controller复用或者包含比较复杂的业务规则或者需要事务控制那必须把逻辑放到Service里。如果只是一个简单的参数校验加单表查询那Controller里写几行代码也无伤大雅。原则是“该沉淀的沉淀该轻量的轻量”而不是“铁律禁止”。3. 核心细节逐一拆解三层各自的职责与实现要点这一章我重点讲每一层具体干什么、怎么干以及最关键的——哪些事情绝对不能在这一层干。我见过很多代码问题本质上都是“越界”了Controller在写业务逻辑Service在拼SQLMapper在搞复杂计算。下面逐一来看。3.1 Controller层参数接收、校验与响应协议Controller在Spring Boot里的定位是“HTTP适配层”它只做三件事接收请求参数、做基础校验、调用Service并返回结果。注意这里说的校验是“基础校验”比如参数是否为空、格式是否正确而不是业务规则校验——那种校验必须放到Service里。实操中Controller层最容易犯的错就是写业务逻辑。比如有人会在Controller里循环判断一堆用户状态决定返回什么有人会把几个Service的方法串在一起处理一个复杂的业务流程还有人甚至直接注入Mapper操作数据库。这些都是典型的越界行为。一个合格的Controller方法应该短得让人一眼看完RestController RequestMapping(/api/users) public class UserController { private final UserService userService; // 构造器注入 public UserController(UserService userService) { this.userService userService; } PostMapping public ApiResponseUserVO createUser(RequestBody Valid UserCreateRequest request) { UserVO userVO userService.createUser(request); return ApiResponse.success(userVO); } GetMapping(/{id}) public ApiResponseUserVO getUser(PathVariable NotNull Long id) { UserVO userVO userService.getUserById(id); return ApiResponse.success(userVO); } }看到了吗没有多余的逻辑没有 try-catch 包裹业务异常再手动转换那应该交给全局异常处理器没有把一个Service方法当成砌墙的砖头在这里垒来垒去。Controller方法体超过二十行你就该问问自己是不是哪里写重了。Controller层另一个关键点是DTO/VO的对象转换。很多老项目直接在Controller层返回Entity实体类这是隐患极大的做法——实体类的字段直接暴露给前端一旦数据库表结构调整前端接口就跟着破相而且很容易把敏感字段泄露出去。正确做法是Controller入参用DTO比如UserCreateRequest出参用VO比如UserVO通过MapStruct或者手动转换在Service和Controller之间做隔离。3.2 Service层业务的真正大脑接口设计决定系统架构Service层是整个三层结构的核心也是单测覆盖的重点区域。它的职责是处理业务逻辑、协调多个Mapper调用、控制事务边界、实现业务规则校验。先讲一个非常重要的实践Service一定要有接口。刚学Spring Boot的时候我也觉得写接口是多余的——直接写一个UserServiceImpl类不用接口照样能用Autowired注入。但在中大型项目中接口是“可替换性”的基石。比如用户服务今天你用一个本地实现的UserServiceImpl明天公司统一要接入一套新的用户中心你就写一个新的实现类在配置层面切换而不动任何Controller代码。另外接口也是团队协同的契约。A同学负责用户模块的Service接口定义B同学负责ControllerC同学负责Mapper大家对着接口开发不用等别人写完才能动手效率会高很多。Service层的事务控制是最容易出问题的点。Spring声明式事务基于AOP实现默认只在RuntimeException或Error时回滚受检异常不会触发回滚。这是一个需要刻进DNA里的知识点否则就会出现“程序抛了异常但数据还是存进去了”的诡异现象。下面看一个标准的Service实现Service public class UserServiceImpl implements UserService { private final UserMapper userMapper; private final UserRoleMapper userRoleMapper; public UserServiceImpl(UserMapper userMapper, UserRoleMapper userRoleMapper) { this.userMapper userMapper; this.userRoleMapper userRoleMapper; } Override Transactional(rollbackFor Exception.class) public UserVO createUser(UserCreateRequest request) { // 1. 业务规则校验用户名不能重复 UserEntity existing userMapper.findByUsername(request.getUsername()); if (existing ! null) { throw new BusinessException(用户名已存在); } // 2. 创建用户主数据 UserEntity userEntity new UserEntity(); userEntity.setUsername(request.getUsername()); userEntity.setPassword(DigestUtils.md5DigestAsHex(request.getPassword().getBytes())); userMapper.insert(userEntity); // 3. 分配默认角色 UserRoleEntity userRoleEntity new UserRoleEntity(); userRoleEntity.setUserId(userEntity.getId()); userRoleEntity.setRoleCode(ROLE_USER); userRoleMapper.insert(userRoleEntity); // 4. 组装返回结果 return new UserVO(userEntity.getId(), userEntity.getUsername(), ROLE_USER); } }这个方法的几个细节值得注意业务校验放在Service里检查用户名是否重复算业务规则放在Service是最合理的Controller只负责参数格式校验。Transactional(rollbackFor Exception.class)显式声明所有异常都回滚避免受检异常导致的数据不一致。多个Mapper协作Service调用了两个Mapper的方法只有这两个操作都成功才是一个完整的“创建用户”业务。这也解释了为什么Mapper不能被Controller直接调用——Controller无法控制多Mapper之间的原子性只有Service接了事务这个“安全气囊”。返回VO而不是实体组装VO的逻辑放在Service里Controller拿到的就是最终要返回给前端的数据结构。Service层最怕“多功能化”。一个Service方法干一件事方法名用业务语言而不是数据库语言比如createUser而不是insertUserchangePassword而不是updatePassword。方法名一旦偏向数据库操作说明Service已经退化成Mapper的转发了业务逻辑本身并没有沉淀到Service里。3.3 Mapper/DAO层数据访问的守门员SQL与参数的精细控制Mapper层也叫DAO层负责与数据库交互。在Spring Boot MyBatis的组合下Mapper通常是接口加XML或注解SQL的组合。它的职责非常纯粹执行CRUD操作将结果映射为实体对象。Mapper层有个经典的误区——把复杂业务逻辑写进SQL里。我知道有些同学确实是被逼无奈因为MyBatis的联表查询写起来很顺手一行JOIN就能捞出所有数据然后在Service里写一堆循环去组装这又走上了另一个极端。我的建议是Mapper只解决“怎么查”不解决“查出来之后怎么办”。你可以写复杂的SQL去联表、去聚合、去分页但不要在SQL里做大量的字符串拼接也不要在SQL里处理敏感的业务过滤逻辑比如权限相关的条件这类逻辑放在Service里更明确、更容易测试和维护。看一个标准的Mapper接口Mapper public interface UserMapper { UserEntity selectById(Param(id) Long id); UserEntity selectByUsername(Param(username) String username); ListUserEntity selectPage(Param(offset) int offset, Param(limit) int limit, Param(keyword) String keyword); int insert(UserEntity user); int updateById(UserEntity user); int deleteById(Param(id) Long id); }配合XML写法select idselectPage resultTypecom.example.entity.UserEntity SELECT * FROM user where if testkeyword ! null and keyword ! AND (username LIKE CONCAT(%, #{keyword}, %) OR email LIKE CONCAT(%, #{keyword}, %)) /if /where ORDER BY id DESC LIMIT #{offset}, #{limit} /select这里有几个实操要点全是坑里趟出来的第一使用Param显式命名参数。如果你用了#{username}但方法参数没加Param(username)MyBatis在编译时获取的参数名可能变成arg0、param1这种莫名奇妙的名字等到SQL执行的时候才发现匹配不上非常头疼。第二LIMIT #{offset}, #{limit}这种分页写法在MySQL下没问题但注意PageHelper插件可以更优雅地处理分页不过用PageHelper也有坑。我踩过的坑是PageHelper的分页只对紧接着的第一条查询生效如果你在Mapper方法里写了多条SQL那只有第一条会被分页其余的不会。后来我干脆不用PageHelper要么用物理分页参数手写LIMIT要么用MyBatis-Plus的Page对象至少行为是确定的。第三关于结果映射。MyBatis可以把数据库字段snake_case自动映射到实体属性camelCase前提是在application.yml里开启mybatis: configuration: map-underscore-to-camel-case: true这个配置不打开的话user_name是映射不了到userName字段的查询结果就是一堆null。但注意如果涉及多表联查结果字段同名冲突的情况就得用resultMap来精确控制了。3.4 三层之间的调用约定与常见的“越界”行为把三层的职责说清楚了再聊聊它们之间是怎么协作的。正常的调用链是Controller接收请求 - 调用Service的方法 - Service内部调用Mapper - Mapper访问数据库。数据流的方向是HTTP参数 - DTO - Service - Entity - Mapper - 数据库返回时再逐层逆向上抛最终转成VO响应给前端。但线上项目里常见的越界行为不少我列几个最有代表性的Controller直接注入Mapper。这基本等同于让服务员自己去仓库搬食材。确实能跑但意味着Controller做了本该Service做的事——没有事务、没有业务校验、没有多Mapper协作。一旦后续要加校验或事务就得破坏调用链重构代价远超一开始就多写一层。Service直接接收HTTP参数对象。Controller把HttpServletRequest传给Service或者Service方法签名是(HttpServletRequest request)这也非常危险。Service就成了Web框架的俘虏没法脱离HTTP环境做单元测试也丧失了分层最重要的测试优势。Mapper层抛业务异常。Mapper只负责数据访问抛出的应该是以技术错误为主的异常比如数据不存在时的空结果。业务异常应该在Service层判断并抛出。如果你把“校验用户名唯一”的逻辑写进了Mapper的SQL里那后续这套逻辑复用的时候Service就不好控制错误提示信息了。**有一个“越界”是大家经常忽略的VO/DTO/Entity混用。**我曾经在一个项目里看到Entity被直接当作响应返回后来加了一个“密码”字段整个用户列表的接口把密码全暴露了。幸运的是那只是内部测试系统要是正式环境后果不堪设想。所以我的铁律是Entity只存在于Mapper层和Service层内部一旦跨Service边界就需要转化为DTO/VO。这不是麻烦这是安全边界。4. 从零到一用实际项目把三层串起来跑一遍前面讲了理论这一章我带你从零开始实现一个用户管理的增删改查模块看看三层是怎么协作的。我用Spring Boot 2.7 MyBatis MySQL Lombok拿来就能跑。4.1 项目结构与数据库准备先看最终的项目结构src/main/java/com/example/demo ├── controller │ └── UserController.java ├── service │ ├── UserService.java │ └── impl │ └── UserServiceImpl.java ├── mapper │ └── UserMapper.java ├── entity │ └── UserEntity.java ├── dto │ ├── UserCreateRequest.java │ └── UserVO.java ├── common │ ├── ApiResponse.java │ └── BusinessException.java └── config └── GlobalExceptionHandler.java对应的建表语句CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(64) NOT NULL, email VARCHAR(100) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 1, 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_username (username) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;4.2 关键代码逐层解读Entity实体类Data public class UserEntity { private Long id; private String username; private String password; private String email; private Integer status; private LocalDateTime createTime; private LocalDateTime updateTime; }注意Entity要与数据库字段一一对应但带下划线的字段通过map-underscore-to-camel-case: true自动映射所以create_time对应createTime即可。DTO与VOData public class UserCreateRequest { NotBlank(message 用户名不能为空) Size(min 3, max 20, message 用户名长度必须在3-20之间) private String username; NotBlank(message 密码不能为空) Size(min 6, max 32, message 密码长度必须在6-32之间) private String password; Email(message 邮箱格式不正确) private String email; } Data public class UserVO { private Long id; private String username; private String email; private Integer status; private LocalDateTime createTime; }这里有个容易忽略的点UserCreateRequest是入参负责接收前端传来的数据它的校验注解NotBlank、Size属于Controller层的参数校验。而UserVO是出参只有前端需要的数据跟数据库表结构没有关系。Mapper层Mapper public interface UserMapper { UserEntity selectById(Param(id) Long id); UserEntity selectByUsername(Param(username) String username); ListUserEntity selectPage(Param(offset) int offset, Param(limit) int limit); int insert(UserEntity user); int updateById(UserEntity user); int deleteById(Param(id) Long id); }XML实现resources/mapper/UserMapper.xmlmapper namespacecom.example.demo.mapper.UserMapper sql idbaseColumns id, username, password, email, status, create_time, update_time /sql select idselectById resultTypecom.example.demo.entity.UserEntity SELECT include refidbaseColumns/ FROM user WHERE id #{id} /select select idselectByUsername resultTypecom.example.demo.entity.UserEntity SELECT include refidbaseColumns/ FROM user WHERE username #{username} /select select idselectPage resultTypecom.example.demo.entity.UserEntity SELECT include refidbaseColumns/ FROM user ORDER BY id DESC LIMIT #{offset}, #{limit} /select insert idinsert useGeneratedKeystrue keyPropertyid INSERT INTO user (username, password, email, status) VALUES (#{username}, #{password}, #{email}, #{status}) /insert update idupdateById UPDATE user set if testemail ! nullemail #{email},/if if teststatus ! nullstatus #{status},/if /set WHERE id #{id} /update delete iddeleteById DELETE FROM user WHERE id #{id} /delete /mapper几个重要细节sql标签把公共列抽出来避免每个查询都写一整串字段。useGeneratedKeystrue keyPropertyid是插入后自动回填主键的配置这样插入后user.getId()才有值。set配合if实现了动态更新只更新非空字段但这个写法也有个坑如果全部字段都是null生成的SQL是UPDATE user SET WHERE id?直接语法错误。所以实际项目中要留意至少有一个必传字段或者用别的兜底策略。Mapper注解让Spring能扫描到这个接口也可以不加而用MapperScan(com.example.demo.mapper)在启动类上扫描我更推荐后者因为一个项目几十个Mapper接口一个个打注解太麻烦。Service层接口与实现public interface UserService { UserVO createUser(UserCreateRequest request); UserVO getUserById(Long id); ListUserVO listUsers(int page, int size); void updateUser(Long id, String email, Integer status); void deleteUser(Long id); }实现类Service public class UserServiceImpl implements UserService { private final UserMapper userMapper; public UserServiceImpl(UserMapper userMapper) { this.userMapper userMapper; } Override Transactional(rollbackFor Exception.class) public UserVO createUser(UserCreateRequest request) { // 业务校验用户名唯一 UserEntity existing userMapper.selectByUsername(request.getUsername()); if (existing ! null) { throw new BusinessException(用户名已存在); } UserEntity user new UserEntity(); user.setUsername(request.getUsername()); // 生产环境请用BCrypt这里仅演示MD5写法 user.setPassword(DigestUtils.md5DigestAsHex(request.getPassword().getBytes())); user.setEmail(request.getEmail()); user.setStatus(1); userMapper.insert(user); return toVO(user); } Override public UserVO getUserById(Long id) { UserEntity user userMapper.selectById(id); if (user null) { throw new BusinessException(用户不存在); } return toVO(user); } Override public ListUserVO listUsers(int page, int size) { int offset (page - 1) * size; ListUserEntity entities userMapper.selectPage(offset, size); return entities.stream().map(this::toVO).collect(Collectors.toList()); } Override Transactional(rollbackFor Exception.class) public void updateUser(Long id, String email, Integer status) { UserEntity user userMapper.selectById(id); if (user null) { throw new BusinessException(用户不存在); } userMapper.updateById(id, email, status); } Override Transactional(rollbackFor Exception.class) public void deleteUser(Long id) { userMapper.deleteById(id); } private UserVO toVO(UserEntity user) { UserVO vo new UserVO(); vo.setId(user.getId()); vo.setUsername(user.getUsername()); vo.setEmail(user.getEmail()); vo.setStatus(user.getStatus()); vo.setCreateTime(user.getCreateTime()); return vo; } }看到没有updateUser和deleteUser这种写操作哪怕只是调用了Mapper一行我也坚持在Service层包一层接口。为什么这么“多此一举”因为将来如果要在删除前检查外键关联或者在更新后发一个消息通知你只需要改ServiceController和Mapper完全不用动。这种接口隔离在项目演进中真的很救命。Controller层RestController RequestMapping(/api/users) public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService userService; } PostMapping public ApiResponseUserVO createUser(RequestBody Valid UserCreateRequest request) { return ApiResponse.success(userService.createUser(request)); } GetMapping(/{id}) public ApiResponseUserVO getUser(PathVariable NotNull Long id) { return ApiResponse.success(userService.getUserById(id)); } GetMapping public ApiResponseListUserVO listUsers(RequestParam(defaultValue 1) int page, RequestParam(defaultValue 10) int size) { return ApiResponse.success(userService.listUsers(page, size)); } PutMapping(/{id}) public ApiResponseVoid updateUser(PathVariable Long id, RequestBody UserUpdateRequest request) { userService.updateUser(id, request.getEmail(), request.getStatus()); return ApiResponse.success(null); } DeleteMapping(/{id}) public ApiResponseVoid deleteUser(PathVariable Long id) { userService.deleteUser(id); return ApiResponse.success(null); } }异常统一处理RestControllerAdvice public class GlobalExceptionHandler { ExceptionHandler(BusinessException.class) public ApiResponseVoid handleBusinessException(BusinessException e) { return ApiResponse.error(e.getMessage()); } ExceptionHandler(MethodArgumentNotValidException.class) public ApiResponseVoid handleValidException(MethodArgumentNotValidException e) { String msg e.getBindingResult().getFieldError().getDefaultMessage(); return ApiResponse.error(msg); } }这一套下来Controller层就是纯粹的路由和参数适配异常有统一的出口业务逻辑在Service里SQL在Mapper里。三个层各司其职你再看代码的时候大脑里会很清爽这个接口参数不对去Controller看这个业务逻辑复杂去Service看这个查询太慢去Mapper看。4.3 这套案例的“跑道图”一个请求的完整旅程为了帮助理解我把一次“创建用户”请求的流转过程梳理一下任何三层项目里的任何请求都跑不出这个模式前端发送POST /api/users请求体是JSON格式的{username: alice, password: 123456, email: aliceexample.com}。请求进入Controller的createUser方法Spring自动做JSON反序列化把JSON转成UserCreateRequest对象。Valid触发参数校验如果username为空或password长度不足6位MethodArgumentNotValidException会被GlobalExceptionHandler捕获并返回错误信息这一层拦截在进Service之前算是第一道防线。Controller调用userService.createUser(request)。Service先做业务校验用户名是否重复然后组装UserEntity并调用userMapper.insert(user)。Mapper执行INSERT语句数据库写入数据MyBatis将生成的主键回填到user.id。Service将UserEntity转换为UserVO返回给Controller。Controller包装成ApiResponseUserVO返回Spring自动序列化为JSON响应给前端。看起来很简单对吧但这里的每一步都有章法可循哪里校验、哪里拼装、哪里调SQL顺序是固定的换任何业务都是这个套路。这就是分层的魔力——不是在约束你而是在帮你减少做决策的脑力负担。5. 常见的“坑”我踩过的和你们即将踩的最后一章我整理了一些实际项目中高频出现的三层架构关联问题有的我自己踩过有的帮别人排查过每一个都是真金白银换来的经验。5.1 事务失效的六大场景占了三层架构问题的一半事务是Service层最重要的职责之一但很多事务配置写了等于没写。我见过的失效场景按频率排序场景一类内部方法调用事务失效。这是最典型的。在UserServiceImpl里写一个方法A调用同类的方法BB标注了Transactional结果B的执行不在事务控制内。为什么因为Spring AOP的原理是为Bean创建代理对象方法通过代理对象调用时事务才生效但类内部this.method()调用走的是原始对象代理完全没进门。等你终于反应过来去搜“事务不生效”搜出来的八成就是这个。场景二事务方法被非事务方法调用的异常处理不当。如果外层方法捕获了异常并吞掉事务就感知不到异常不会回滚。所以事务方法里抛出的异常要么不要catch要么catch后重新抛出RuntimeException。我的习惯是能让它抛就让它在最外层抛除非你有明确的补偿方案否则不要轻易catch。场景三使用Transactional(propagation Propagation.REQUIRED)默认传播级别时内外事务混在一起。如果外层方法有事务内层方法加入同一个事务内层方法抛了异常外层不捕获还能回滚但如果你外层也做了try-catch麻烦就来了——内层事务已经标记为rollback-only外层提交的时候发现这个标记直接抛UnexpectedRollbackException。这个异常坑得人一愣一愣的因为它不是立刻抛而是等到最后提交时才爆。场景四方法不是public。Transactional只对public方法生效因为Spring AOP代理基于JDK动态代理或CGLIB非public方法无法被代理拦截。这是那种“代码看起来没问题但就是事务不生效”的典型原因。场景五数据库表不支持事务。MySQL的MyISAM引擎就不支持事务改成InnoDB才能用。这个问题很隐蔽因为单条SQL增删改无所谓事务只有多表操作时才暴露出来。场景六异常被catch但只打日志不抛出。这个跟场景二类似但更隐蔽因为代码看起来“处理了异常”实际上数据已经写入了日志里只有一行ERROR。这不是事务失效是人为把事务“端掉”了。5.2 Mapper注入失败的几个常见原因Spring Boot项目里UserMapper注入报错或者启动失败通常逃不出这几类问题Mapper接口没有添加扫描方式。要么在每个Mapper接口上打Mapper要么在启动类上加MapperScan(com.example.demo.mapper)二选一同时用也行但不建议混用否则团队风格不统一。我比较推荐MapperScan它能把整个包下的Mapper批量注册少写很多重复注解。XML文件没放到正确位置或没绑对namespace。MyBatis的XML文件如果放在resources/mapper目录需要在application.yml里指定mybatis: mapper-locations: classpath:mapper/*.xml而XML里的namespace必须指向Mapper接口的全限定名比如com.example.demo.mapper.UserMapper否则运行时会报Invalid bound statement (not found)。这个报错信息很误导人因为它说的是“找不到statement”但其实问题不在方法名而在namespace写错了。select的resultType写错了包路径。这个也是新手高频错误。resultType要写实体类的全限定名或者配置type-aliases-package后用短类名。如果写错常见的报错是org.apache.ibatis.binding.BindingException。5.3 循环依赖两个Service互相引用的生死局在Spring Boot 2.6及以上版本中默认禁止了循环依赖程序启动时会直接报错。但老项目的Spring Boot版本低或者开发时没有注意两个Service互相注入的情况并不罕见。举个例子UserService需要调用OrderService查询用户的订单而OrderService需要调用UserService查询订单的用户信息。两个服务类都通过构造器注入对方就形成了构造器循环依赖。解决办法从根上切断重新设计业务边界。这是最推荐的通常两个Service互相依赖意味着业务边界划分不清应该把共同依赖的逻辑抽到一个新的Service或领域服务里。使用Lazy注解延迟加载。在其中一个注入点加Lazy让Spring先生成一个代理对象真正用到时才解析依赖。能用但治标不治本。用ObjectProviderT注入。Spring 5.0引入了ObjectProvider可以延迟获取依赖也算一种绕法。我的态度很明确循环依赖是坏味道代表着设计上有问题。宁可多花时间重构也不要用Lazy这种偏方掩盖问题。5.4 三层之间对象转换的“搬运工陷阱”很多项目里三层之间传递对象全靠无脑“复制粘贴”——DTO转Entity、Entity转VO、VO再转DTO写出来上百行setter调用。这种代码不是不能跑而是每次表加字段你得改五六个地方漏一个就是线上事故。推荐用MapStruct做编译期转换它是纯代码生成不依赖反射性能好而且类型安全。用法很简单写一个Mapper接口Mapper(componentModel spring) public interface UserConvert { UserConvert INSTANCE Mappers.getFactory(UserConvert.class); UserEntity toEntity(UserCreateRequest request); UserVO toVO(UserEntity user); }配置了componentModel spring之后Spring容器就能直接注入这个转换器代码里不需要手动INSTANCE调用。我个人用了这么多年最深的感受是MapStruct在项目里几乎零学习成本但省下来的“搬运工时间”是实打实的。写在最后的个人经验三层架构从JavaEE时代一路演进到今天核心思想始终没变用依赖方向的约束换取系统的可维护性和可测试性。Spring Boot只是让这件事变得更“默认正确”而已但它不会替你做架构决策。有一次我在帮同事排查一个线上BUG查到最后发现根因是Service里直接New了一个Mapper的实现类的Mock对象绕过了Spring的代理。这种情况在三层架构下其实不太应该发生——如果大家都遵守“Controller只依赖Service接口、Service只依赖Mapper接口、对象转换只发生在边界处”的约定很多低级错误根本不会出现。最后分享一个我个人的小习惯每写完一个模块我会把Controller、Service、Mapper三层的方法菜单打印出来看一遍。如果Controller里出现超过10行的方法、Service里出现直接访问数据库的细节、Mapper里出现业务逻辑注释那这个模块十有八九需要重构了。这套检查标准虽然听起来简单但执行了这些年给我挡掉了不少后续的维护噩梦。
RELATED

相关推荐

Anaconda安装PyTorch GPU版:十分钟搞定CUDA环境配置

Anaconda安装PyTorch GPU版:十分钟搞定CUDA环境配置

很多第一次碰深度学习的人,卡住的往往不是模型怎么写,而是环境装不对。明明照着教程敲了三行命令,torch.cuda.is_available()还是返回False,然后开始怀疑显卡、怀疑驱动、怀疑人生。其实在 Anaconda 里装 PyTorch GPU 版这件事&am…

📅 2026/10/16 4:56:39
LangChain与LangGraph怎么选?从本质差异到学习路线全解析

LangChain与LangGraph怎么选?从本质差异到学习路线全解析

这段时间私信里问得最多的一个问题,已经从"怎么把对话记录传给模型"变成了"LangChain和LangGraph到底该学哪个、项目里到底用哪个"。我能理解这种纠结,因为LangChain官方文档一边把重心往LangGraph上偏,一边又在自己博客…

📅 2026/10/16 4:56:39
JDK8 HashMap源码拆解:散列、红黑树与扩容机制一次讲透

JDK8 HashMap源码拆解:散列、红黑树与扩容机制一次讲透

1. 先看清HashMap的底层结构:数组、链表、红黑树是怎么拼在一起的HashMap大概是Java容器里被面试官问得最多、被线上故障坑得最惨的一个类。我最早认真读它的源码,正因为某个跨平台系统的压测频繁出现CPU100%,抓完线程栈发现所有线程都卡在Ha…

📅 2026/10/16 4:56:39
MORE NEWS

更多资讯

📰

SAP Fiori Elements实战:从CDS注解到RAP应用开发全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

实训5指纹设置全解析:从传感器原理到实操与安全

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

语音特征提取全流程:从原始波形到MFCC特征向量

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

Java线程start()方法原理与线程生命周期解析

1. 线程生命周期与start()方法本质当面试官抛出"为什么线程start()不能重复调用"这个问题时,实际上是在考察你对Java线程生命周期的理解深度。让我们先看一个典型的错误示例:Thread thread new Thread(() -> System.out.println("Run…

📰

Spring Cloud Gateway GatewayFilter 源码解析与责任链机制

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

粒子群算法优化LQR的二级倒立摆控制设计与避坑实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬