☰
Spring Boot三层架构与数据访问层实战:告别三百行业务代码
2026/10/9 8:32:58 网站建设 项目流程

如果你也遇过这样的代码——一个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依赖Service,Service依赖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,出参用VO,Service内部用Entity。

举个例子:

@RestController @RequestMapping("/api/order") public class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { this.orderService = orderService; } @PostMapping("/create") public Result<OrderVO> create(@RequestBody @Valid OrderCreateRequest request) { OrderVO vo = orderService.createOrder(request); return Result.ok(vo); } }

Service实现里负责把DTO转换成Entity,再把Entity转换成VO:

@Service 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); }

对应XML:

<select id="selectByUsername" resultType="com.example.shop.entity.User"> SELECT * FROM user WHERE username = #{username} </select>

Spring Data JPA属于全自动ORM。你定义实体类,继承JpaRepository,框架自动帮你生成标准CRUD的SQL:

@Entity @Table(name = "user") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; } public interface UserRepository extends JpaRepository<User, Long> { Optional<User> findByName(String name); }

3.2 我的选型建议

两个方案都有大量生产环境案例,关键看取舍:

维度MyBatisSpring Data JPA
SQL控制力强,SQL完全可见弱,简单查询由框架生成
复杂查询适合多表join、报表SQL需要学Criteria或写@Query
简单CRUD需要维护SQL效率极高,几乎零代码
调试体验SQL可以直接复制到数据库执行需要开启SQL日志查看生成的语句
学习曲线上手快,理解SQL即可需要理解持久化上下文、缓存机制
团队要求对SQL水平要求高对领域建模和框架理解要求高

我做业务系统的个人倾向是:默认选MyBatis。理由很实在:

  • 国内绝大多数业务系统的核心复杂度都集中在多表查询和报表统计上,SQL可见、可控,优化的时候能精准到一行。
  • 出现问题时排查链路短。线上慢SQL打出来,复制到数据库里EXPLAIN一下,基本就定位了。
  • 团队协作时,后端开发只要会SQL,就能快速维护Mapper,不需要先补一套JPA/Hibernate的领域建模知识。

但这不代表JPA不能选。如果你的系统以标准CRUD为主、领域模型很清晰、并且团队对Hibernate有足够的掌控力,JPA的开发和迭代效率确实很高。最怕的是选了JPA,却没人说得清一级缓存、二级缓存、懒加载、级联这些机制,最后系统里到处是N+1查询和莫名其妙的脏数据。

4. 数据访问层最核心的三件事:参数传递、动态SQL与结果映射

4.1 Mapper接口与XML文件的对应关系

MyBatis的Mapper接口和XML映射文件靠namespace和id绑定。接口里写方法签名,XML里写具体SQL,两者的id必须和方法名一致。

启动类上加上@MapperScan,Spring Boot才能扫描到所有Mapper:

@SpringBootApplication @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、arg1,XML里写#{id}会直接报参数找不到。
  • 参数很多时,建议组装一个查询对象,比如UserQuery,而不是传五六个散的参数。

然后是最重要的SQL注入问题。

#{}是预编译占位符,MyBatis会把它替换成?,交给PreparedStatement处理,值不会被当作SQL执行。这是99%场景下应该用的写法。

${}是字符串拼接,MyBatis直接把值替换进SQL语句。比如:

SELECT * FROM user WHERE username = '${username}'

如果username传入' OR '1'='1,SQL就变成了:

SELECT * FROM user WHERE username = '' OR '1'='1'

整张表都能被查出来。这就是SQL注入。

所以规则很明确:值永远用#{},只有表名、列名、排序字段这类没法用占位符的场景才考虑${},而且必须做白名单校验。比如动态排序,只允许传入固定的几个列名:

String sortColumn = switch (query.getSortField()) { case "createTime" -> "create_time"; case "price" -> "price"; default -> "id"; };

而不是把用户输入直接拼进SQL。

4.3 高频动态SQL写法

业务系统里动态条件查询太常见了,MyBatis的<where>加<if>是标配:

<select id="selectByCondition" resultType="User"> SELECT * FROM user <where> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="status != null"> AND status = #{status} </if> <if test="startTime != 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 id="batchInsert"> INSERT INTO order_item (order_id, product_id, quantity, price) VALUES <foreach collection="list" item="item" 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 id="orderWithItems" type="Order"> <id property="id" column="id"/> <result property="orderNo" column="order_no"/> <collection property="items" ofType="OrderItem"> <id property="id" column="item_id"/> <result property="productId" column="product_id"/> <result property="quantity" column="quantity"/> <result property="price" column="price"/> </collection> </resultMap> <select id="selectOrderWithItems" resultMap="orderWithItems"> 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调用方法B,B上有@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); List<User> users = userMapper.selectList(); PageInfo<User> pageInfo = new PageInfo<>(users);

用法简单但坑也明显。PageHelper基于ThreadLocal实现,startPage之后必须紧跟第一条查询语句,中间不能有任何其他查询,否则分页信息会套到错误的SQL上。而且它自动生成的count语句在复杂SQL下可能不够准确,一旦分页总数不对,优先改成手写count。

更可控的做法是手动分页,用LIMIT #{offset}, #{pageSize}:

<select id="selectPage" resultType="User"> SELECT * FROM user WHERE status = #{status} ORDER BY id DESC LIMIT #{offset}, #{pageSize} </select>

对应的Java侧计算出offset:

int offset = (pageNum - 1) * pageSize;

这里还有一个深分页问题:当页数很深时,比如LIMIT 100000, 20,数据库仍然要扫描前十万行,性能很差。优化思路一般是两种:要么限制最多翻多少页,要么用“上一页最大ID”代替页码,用WHERE id > #{lastId} ORDER BY id ASC LIMIT 20来翻页。

5.3 多表查询:N+1问题的两条出路

N+1问题在多表查询里极其常见。典型场景:查订单列表,然后遍历每个订单再去查它的明细:

List<Order> 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,实体类用驼峰:Order,Mapper叫OrderMapper。x`
  • Mapper接口方法命名统一:查询用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包日志级别开到debug:

logging: 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、一个个事务边界和每一处映射关系的踏实处理。框架一直在变,但数据访问层里的这些基本功,任何项目都绕不开。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询