如果你也遇过这样的代码——一个Controller里塞了三百多行查询和更新逻辑全写在方法体里业务规则和SQL语句揉成一团改一个字段得翻三个地方——那你大概率能体会我在接手这类项目时的感受。写这篇文章是因为我在Spring Boot的三层架构和数据访问层上栽过不少跟头也沉淀了一些真实可用的经验。这篇不打算复述教科书里“Controller-Service-DAO”的定义而是从真实项目的组织方式出发讲清楚Spring Boot工程里三层架构到底怎么落数据访问层的Mapper、SQL、事务、分页怎么设计才不容易挖坑。适合已经能写Spring Boot入门Demo、正准备做后台业务系统的同学参考。1. 为什么Controller里堆了三百行业务代码项目就一定会翻车1.1 Controller越界之后代码会变成什么样我以前接手过一个订单模块OrderController里一个“创建订单”的接口就有三百多行。代码大概是这样的形态RestController RequestMapping(/order) public class OrderController { Autowired private JdbcTemplate jdbcTemplate; PostMapping(/create) public Result create(RequestBody OrderDTO dto) { if (dto.getUserId() null) { return Result.fail(userId不能为空); } User user jdbcTemplate.queryForObject( SELECT * FROM user WHERE id ?, User.class, dto.getUserId()); Product product jdbcTemplate.queryForObject( SELECT * FROM product WHERE id ?, Product.class, dto.getProductId()); if (product.getStock() dto.getQuantity()) { return Result.fail(库存不足); } double totalPrice product.getPrice() * dto.getQuantity(); jdbcTemplate.update(INSERT INTO order (user_id, total_price) VALUES (?, ?), dto.getUserId(), totalPrice); jdbcTemplate.update(UPDATE product SET stock stock - ? WHERE id ?, dto.getQuantity(), dto.getProductId()); return Result.ok(); } }这段代码只是示意但你应该见过类似的真实版本。它看起来能跑甚至在流量不大的时候跑得还挺好。可一旦系统持续迭代问题就开始密集暴露没有层次阅读成本极高。你想知道“订单金额怎么算的”要顺着Controller从头看到尾还没看到半路就被库存更新的SQL打断。复用变成复制粘贴。第二个接口需要查商品信息没法直接复用只能把查询代码复制一遍。一段时间后同样的SQL散落在四五个地方字段改起来极其痛苦。测试几乎没法写。你没法单独测“计算价格”这一段逻辑必须启动整个Web容器发HTTP请求才能验证。单元测试在这种结构下形同虚设。团队协作互相打架。三个人同时改一个类Git冲突频率高到让人怀疑人生。改动风险被无限放大。一个接口的改动很可能影响其他接口因为都共享同一个方法体、同一个临时变量状态。1.2 三层架构到底在解决什么三层架构的本质是“关注点分离”。把处理HTTP请求、执行业务规则、读写数据库这三类不同性质的事情拆开让每一部分可以独立演化、独立测试、独立替换。我常用的一个类比是餐厅客人进店点菜服务员负责记录需求、把菜端上桌后厨负责怎么配菜、怎么炒菜采购部门负责从供应商手里拿原材料。服务员不会冲进后厨炒菜采购也不会跑到餐桌前问客人要什么。每一层只关心自己那一摊事出了问题也知道该找谁。对应到代码里Controller是服务员接收参数、校验格式、把结果包装成响应返回给前端。Service是后厨接收Controller传来的“菜单”执行业务规则协调处理多张表的数据。Mapper/DAO是采购只负责从数据库取数据、写数据不关心菜怎么做也不关心客人怎么评价。很多项目翻车就是因为没有守住这个边界。Controller越做越多Service变成空壳或者Mapper里写满了业务判断。架构不是靠画图画出来的是靠每一行代码的归属感维持的。2. Spring Boot下三层架构的目录组织与职责边界2.1 标准包结构与职责划分Spring Boot对包结构没有强约束但一个维护性强的三层架构工程目录基本长这样com.example.shop ├── controller # 表现层接收请求、返回响应 ├── service # 业务层接口定义 │ └── impl # 业务实现 ├── mapper # 数据访问层MyBatis Mapper接口 ├── entity # 数据实体对应表结构 ├── dto # 传输对象入参/出参 ├── config # 配置类 └── common # 通用结果、异常、工具类这里的核心思想是依赖方向从上到下。Controller依赖ServiceService依赖Mapper反过来不行。如果哪天你在Mapper里注入了一个Service就该停下来重新想想设计。每层职责我再展开说细一点。Controller层只做四件事接收HTTP参数做基础格式校验配合Valid。调用Service完成业务。把Service的返回值包装成统一响应结构。捕获必要的异常转成HTTP层可识别的错误信息。Service层做三件事编排业务规则比如下单前检查库存、计算金额、保存主表、保存明细、扣库存。划定事务边界把一组必须同时成功或同时失败的操作放进一个事务。跨Mapper协调一个业务往往涉及订单Mapper、商品Mapper、库存Mapper由Service统一调度。Mapper层只做一件事数据访问。把SQL写明白把结果映射做对不做任何业务判断。2.2 DTO、Entity与VO层与层之间到底传什么初学者最容易忽略的是Controller不能直接把Entity返回给前端。Entity是数据库表结构的映射字段往往包含password、secretKey这些绝对不能暴露的列。更合理的做法是Controller入参用DTO出参用VOService内部用Entity。举个例子RestController RequestMapping(/api/order) public class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { this.orderService orderService; } PostMapping(/create) public ResultOrderVO create(RequestBody Valid OrderCreateRequest request) { OrderVO vo orderService.createOrder(request); return Result.ok(vo); } }Service实现里负责把DTO转换成Entity再把Entity转换成VOService public class OrderServiceImpl implements OrderService { private final OrderMapper orderMapper; private final ProductMapper productMapper; Override Transactional public OrderVO createOrder(OrderCreateRequest request) { Product product productMapper.selectById(request.getProductId()); if (product.getStock() request.getQuantity()) { throw new BizException(库存不足); } Order order new Order(); order.setUserId(request.getUserId()); order.setTotalPrice(product.getPrice() * request.getQuantity()); orderMapper.insert(order); productMapper.deductStock(product.getId(), request.getQuantity()); return toOrderVO(order); } }DTO和VO的区分看起来很繁琐但一旦接口开始对接外部系统或者表结构发生调整你就知道这层隔离有多值得。字段变化只影响Entity和对应Mapper不会把接口契约搞得千疮百孔。3. MyBatis还是Spring Data JPA数据访问层选型背后的取舍3.1 两条路的典型形态进入数据访问层第一个绕不开的问题就是选型。Spring Boot里主流的方案是MyBatis和Spring Data JPA两者代表了完全不同的思路。MyBatis属于半自动ORM。你写SQL它帮你做参数映射和结果映射。XML或注解里写什么SQL就执行什么SQL数据库查出来的列怎么映射到Java对象你自己控制。一个典型的MyBatis Mapper是这样的Mapper public interface UserMapper { User selectByUsername(Param(username) String username); }对应XMLselect idselectByUsername resultTypecom.example.shop.entity.User SELECT * FROM user WHERE username #{username} /selectSpring Data JPA属于全自动ORM。你定义实体类继承JpaRepository框架自动帮你生成标准CRUD的SQLEntity Table(name user) public class User { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String name; } public interface UserRepository extends JpaRepositoryUser, Long { OptionalUser findByName(String name); }3.2 我的选型建议两个方案都有大量生产环境案例关键看取舍维度MyBatisSpring Data JPASQL控制力强SQL完全可见弱简单查询由框架生成复杂查询适合多表join、报表SQL需要学Criteria或写Query简单CRUD需要维护SQL效率极高几乎零代码调试体验SQL可以直接复制到数据库执行需要开启SQL日志查看生成的语句学习曲线上手快理解SQL即可需要理解持久化上下文、缓存机制团队要求对SQL水平要求高对领域建模和框架理解要求高我做业务系统的个人倾向是默认选MyBatis。理由很实在国内绝大多数业务系统的核心复杂度都集中在多表查询和报表统计上SQL可见、可控优化的时候能精准到一行。出现问题时排查链路短。线上慢SQL打出来复制到数据库里EXPLAIN一下基本就定位了。团队协作时后端开发只要会SQL就能快速维护Mapper不需要先补一套JPA/Hibernate的领域建模知识。但这不代表JPA不能选。如果你的系统以标准CRUD为主、领域模型很清晰、并且团队对Hibernate有足够的掌控力JPA的开发和迭代效率确实很高。最怕的是选了JPA却没人说得清一级缓存、二级缓存、懒加载、级联这些机制最后系统里到处是N1查询和莫名其妙的脏数据。4. 数据访问层最核心的三件事参数传递、动态SQL与结果映射4.1 Mapper接口与XML文件的对应关系MyBatis的Mapper接口和XML映射文件靠namespace和id绑定。接口里写方法签名XML里写具体SQL两者的id必须和方法名一致。启动类上加上MapperScanSpring Boot才能扫描到所有MapperSpringBootApplication MapperScan(com.example.shop.mapper) public class ShopApplication { public static void main(String[] args) { SpringApplication.run(ShopApplication.class, args); } }对应的配置mybatis: configuration: map-underscore-to-camel-case: true mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.shop.entity这里有一个容易忽略的点map-underscore-to-camel-case开启后数据库的order_no自动映射成Java的orderNo省掉大量resultMap配置。但如果表字段和实体属性命名差异很大或者多表查询时列有前缀冲突就要老老实实写resultMap。4.2 参数传递#{}、${}和Param的边界参数传递是新手踩坑重灾区。先说最基础的规则单个简单参数#{}里的名字可以随便写比如#{id}、#{value}都能用。多个参数时必须用Param显式命名。不加就编译成arg0、arg1XML里写#{id}会直接报参数找不到。参数很多时建议组装一个查询对象比如UserQuery而不是传五六个散的参数。然后是最重要的SQL注入问题。#{}是预编译占位符MyBatis会把它替换成?交给PreparedStatement处理值不会被当作SQL执行。这是99%场景下应该用的写法。${}是字符串拼接MyBatis直接把值替换进SQL语句。比如SELECT * FROM user WHERE username ${username}如果username传入 OR 11SQL就变成了SELECT * FROM user WHERE username OR 11整张表都能被查出来。这就是SQL注入。所以规则很明确值永远用#{}只有表名、列名、排序字段这类没法用占位符的场景才考虑${}而且必须做白名单校验。比如动态排序只允许传入固定的几个列名String sortColumn switch (query.getSortField()) { case createTime - create_time; case price - price; default - id; };而不是把用户输入直接拼进SQL。4.3 高频动态SQL写法业务系统里动态条件查询太常见了MyBatis的where加if是标配select idselectByCondition resultTypeUser SELECT * FROM user where if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if teststatus ! null AND status #{status} /if if teststartTime ! null AND create_time gt; #{startTime} /if /where ORDER BY id DESC /select注意两个细节。第一if里判断空字符串时字符串类型的字段要同时判断null和否则前端传个空字符串也会被拼进SQL。第二XML里面大于号、小于号要用gt;、lt;写原生的会解析报错。批量插入用到foreach但批量插入的SQL语句长度受数据库max_allowed_packet限制一次插入几百上千条没问题别一次塞几万条insert idbatchInsert INSERT INTO order_item (order_id, product_id, quantity, price) VALUES foreach collectionlist itemitem separator, (#{item.orderId}, #{item.productId}, #{item.quantity}, #{item.price}) /foreach /insert动态更新用set配合一段需要注意的SQL隐患——set会自动去掉最后一个逗号但如果所有if都不满足UPDATE user SET WHERE id ?这种畸形SQL就会出现所以至少保证有一个主键条件。4.4 结果映射与多表结构的resultMap单表查询开了map-underscore-to-camel-case用resultType就够了。多表查询或者字段对不上时需要resultMap。最常见的多表场景是“订单订单明细”的一对多结构resultMap idorderWithItems typeOrder id propertyid columnid/ result propertyorderNo columnorder_no/ collection propertyitems ofTypeOrderItem id propertyid columnitem_id/ result propertyproductId columnproduct_id/ result propertyquantity columnquantity/ result propertyprice columnprice/ /collection /resultMap select idselectOrderWithItems resultMaporderWithItems SELECT o.id, o.order_no, i.id AS item_id, i.product_id, i.quantity, i.price FROM order o LEFT JOIN order_item i ON o.id i.order_id WHERE o.id #{orderId} /select这里有几个容易踩的坑主表的id列和明细表的id列名字相同必须用别名区分否则MyBatis可能把明细表的id覆盖到主表id上。collection映射一对多时如果查询结果里主表字段存在大量重复要确认主表的id列定义是否正确错误的id映射会导致集合数据错乱。不要在resultMap里写“数据库里没有的列”并期望它返回固定值。想要固定值就在SQL里用别名SELECT 男 AS gender来生成虚拟列。5. 事务、分页与多表查询数据访问层必须过的三道关5.1 事务注解的三个常见坑Spring Boot里使用声明式事务很简单一个Transactional就完事但它有三个经典坑。第一默认不回滚受检异常。Spring对事务回滚的默认策略是遇到RuntimeException或Error回滚遇到受检异常比如IOException不回滚。所以业务代码里如果有类似“远程调用失败抛出Exception”的情况事务不会自动回滚数据就毁了。规范写法是Transactional(rollbackFor Exception.class)第二自调用事务失效。同一个类里方法A调用方法BB上有Transactional但调用发生在类内部Spring AOP代理没有介入事务不会生效public void createOrder() { this.updateStock(); // 事务不生效 } Transactional public void updateStock() { ... }解决办法是把updateStock拆到另一个Service里或者通过AopContext.currentProxy()拿到代理对象再调用。这块属于每个团队都应该写进规范的知识点。第三事务粒度太大会拖垮性能。一个事务里做了网络请求、文件读写、耗时的远程调用数据库连接就被占住很久。事务里尽量只放数据库操作远程调用和外部IO放事务外面。5.2 分页从PageHelper到手动分页MyBatis生态里分页插件用得最多的是PageHelper。用法很简单PageHelper.startPage(pageNum, pageSize); ListUser users userMapper.selectList(); PageInfoUser pageInfo new PageInfo(users);用法简单但坑也明显。PageHelper基于ThreadLocal实现startPage之后必须紧跟第一条查询语句中间不能有任何其他查询否则分页信息会套到错误的SQL上。而且它自动生成的count语句在复杂SQL下可能不够准确一旦分页总数不对优先改成手写count。更可控的做法是手动分页用LIMIT #{offset}, #{pageSize}select idselectPage resultTypeUser SELECT * FROM user WHERE status #{status} ORDER BY id DESC LIMIT #{offset}, #{pageSize} /select对应的Java侧计算出offsetint offset (pageNum - 1) * pageSize;这里还有一个深分页问题当页数很深时比如LIMIT 100000, 20数据库仍然要扫描前十万行性能很差。优化思路一般是两种要么限制最多翻多少页要么用“上一页最大ID”代替页码用WHERE id #{lastId} ORDER BY id ASC LIMIT 20来翻页。5.3 多表查询N1问题的两条出路N1问题在多表查询里极其常见。典型场景查订单列表然后遍历每个订单再去查它的明细ListOrder orders orderMapper.selectList(); for (Order order : orders) { order.setItems(orderItemMapper.selectByOrderId(order.getId())); }如果一次查出100个订单就要执行1条查询订单SQL加100条查询明细SQL数据库压力翻了一百倍。这在开发环境看不见问题生产环境一上流量就立刻暴露。解决思路有两条。第一条用单条SQL join配合resultMap里的一对多collection映射一次查询把主表和从表数据全查出来就是上一节展示的写法。第二条批量查询。先查出订单列表收集所有订单ID然后一条WHERE order_id IN (...)查出所有明细在Java内存里按订单ID分组再装配回去。这种方式SQL简单逻辑直观在列表页性能也够用。我一般优先用批量查询。因为join的SQL在商品、用户、订单等多表关联且字段特别多的时候映射配置复杂且SQL语句越写越难维护而批量查询的两条SQL都很简单内存组装逻辑也一目了然。6. 实战项目里沉淀下来的数据访问层规范与排查手段6.1 命名规范与文件组织数据访问层的规范越早统一越省心。我经手的团队通常维护这样一套约定表名用蛇形user_order实体类用驼峰OrderMapper叫OrderMapper。xMapper接口方法命名统一查询用selectByXxx插入用insert或insertSelective更新用update或updateSelective删除用deleteByXxx。一个Mapper接口对应一个XML文件放resources/mapper下文件名和接口同名。这样任何人打开工程都能按图索骥。简单固定的查询可以用注解Select写在接口上但一旦超过两行立刻搬进XML。注解里写复杂SQL既不美观也不方便调试。还有一个非常容易踩的坑SQL字段风格不统一。同一个库里有的表用user_id有的表用userId有的表干脆叫uid。一旦混入开启驼峰映射也没法救resultMap能写到怀疑人生。所以表结构设计阶段就要定好命名规范数据访问层才能省力。6.2 连接池、SQL日志与慢查询排查HikariCP是Spring Boot默认的连接池性能好但默认参数不一定适合所有项目。生产环境里我通常会根据自己的数据量和QPS调整一下spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 connection-test-query: SELECT 1连接池太小高峰期会连接不够用太大数据库本身又会成为瓶颈。这个值没有固定答案要根据线上监控一点一点调但至少要有一个明确的初始值。排查SQL问题时日志是最直接的手段。把项目里的Mapper包日志级别开到debuglogging: level: com.example.shop.mapper: debug跑一次接口就能在控制台看到MyBatis实际执行的完整SQL和参数直接复制到数据库客户端里执行再配合EXPLAIN看执行计划索引有没有失效、扫描了多少行一目了然。6.3 数据访问层的几个长期建议做了几年Spring Boot项目我总结出几条关于数据访问层的长期经验。第一不要让Mapper“太聪明”。数据访问层应该保持“哑”只负责按传入条件查数据、写数据。业务规则放在Service层。有人在Mapper里做了金额计算和状态判断看着方便后面一扩展就全面崩盘。第二不要为了“通用”而过度抽象。很多人喜欢一开始就写一个BaseMapper把所有CRUD都泛型化然后业务复杂了又开始写各种特例。通用的CRUD确实香但针对复杂业务宁可每个业务写各自的SQL也不要硬套通用模板。第三表结构变更时顺序应该是先改表结构再改实体和Mapper再改Service和VO。很多人先改代码再改表结果SQL跑不通、映射对不上来回折腾。按从上到下的顺序走改动面最小。第四每一条“看起来挺快”的SQL都要跑到上万条数据以后再验证。开发环境数据量太小索引问题完全暴露不出来很多慢SQL都是上了生产才被发现。数据访问层是后端系统离数据库最近的一层也是最需要“多看一眼SQL”的地方。三层架构只是给代码划了一个边界真正让项目长期不烂尾的还是那一条条SQL、一个个事务边界和每一处映射关系的踏实处理。框架一直在变但数据访问层里的这些基本功任何项目都绕不开。