Java 三层架构,90% 的人只答对一半:Controller 不是瘦,Service 不是透传,Dao 不是只写 SQL

Java 项目为什么要分 Controller、Service、Dao 三层?90% 的人只能答对一半

面试里这道题的出镜率高得离谱,但十个人里有九个会背一句"高内聚低耦合"就卡壳。面试官真正想听的,是每层的边界在哪、你在真实项目里踩过什么坑、又是怎么解决的。

这篇文章把多个一线项目的实战经验整合在一起,从原理、代码到踩坑和架构守护,一次讲透。不管你是准备面试,还是想在团队里把分层规范立起来,都值得读完。

一、先说结论:分层不是分代码,是分"变化"

很多人以为三层架构就是把代码分成三个文件夹,其实大错特错。

分层的本质是单一职责原则 + 关注点分离(Separation of Concerns) ,再往深了说,是按**"变化速率"**给代码分舱:

  • Controller 层为前端变化而生------UI 改字段、接口协议从 HTTP 换成 RPC,只动它;
  • Service 层为不变的业务规则而生------它是系统里最"值钱"也最稳定的资产;
  • Dao 层为存储变化而生------MySQL 换 ES、MyBatis 换 JPA、加缓存做读写分离,也只动它。

一句话概括:改前端字段只动 Controller,改业务规则只动 Service,换数据库只动 Dao。

有三样东西必须钉死在固定位置:

钉死的东西 唯一归属 为什么
业务规则(if/else 里的判断) Service 业务规则是系统唯一"值钱"的资产,不能被 HTTP 协议或 SQL 细节污染
事务边界 Service 一次业务动作的原子性,天然是业务概念,不是 HTTP 概念
SQL / 存储细节 Dao 换库、换 ORM、加缓存、读写分离,都只需要动这一层

打个不严谨但好记的比方:去饭店吃饭,跑堂负责接单上菜,厨师负责做菜,小工负责进库房取食材。你什么时候见过厨师一边颠勺一边跑去门口招呼客人?

代码世界也一样:Controller 是服务员,Service 是厨师,Dao 是采购员。 小店时代一个人全包还能撑,店一大就全乱了。

二、三层架构全景图:每层管什么,红线在哪

先看整体调用链路:

每层的职责和红线,背下面这张表就够了:

层级 角色定位 该干的 ✅ 绝不能干的 ❌ 入参 / 出参 常见组件
Controller 控制层 前台接待:迎宾小哥,检查入场券,不干重活 接收 HTTP 请求、@Valid 格式校验、DTO 转换、鉴权入口、组装统一响应 写业务规则、写 SQL、开事务、编排多个 Dao HTTP 参数 / DTO → 统一 Result / VO @RestController、@RequestMapping、Validation
Service 业务层 后厨大厨:核心逻辑中枢 业务规则判断、业务校验、事务边界、编排多个 Dao 和远程调用 出现 HttpServletRequest、拼 SQL、把 Controller 的 VO 当入参 DTO / BO → BO / DTO @Service、@Transactional、业务组件
Dao 持久层 库房管理员:只管存取,不关心菜谱 CRUD、SQL / ORM 映射、分页、批量操作 掺业务判断(if 用户是VIP 则改SQL)、调 Service、调第三方 PO / 查询条件 → PO / 数据结果 MyBatis、JPA、MyBatis-Plus、JdbcTemplate

Dao 应该只回答三个问题:数据有没有?查出来没有?写入成功没有? 剩下的判断,全是 Service 的事。

有两个细节经常被忽略,但恰恰是分层质量的试金石:

细节一:Service 层必须是无状态的。 不持有 Session、不碰 HTTP 对象,入参出参都是普通 Java 对象。做不到这一点,分层就只是个错觉。

细节二:依赖方向必须是单向的。 Controller → Service → Dao,上层知道下层,下层永远不知道谁在调用它。底层可替换、可 Mock、可加缓存,全靠这条规矩。

为什么 Service 通常要写"接口 + 实现类"?

不少人觉得这是样板代码,其实它有三层价值:

  1. 面向接口,方便替换实现------MyBatis 换 JPA、本地库换远程服务,调用方零感知;
  2. JDK 动态代理要求有接口------事务、日志、权限这些 AOP 增强才挂得稳;
  3. 单测可以直接 Mock 接口,不依赖任何实现。

当然这不是死规定:单实现、不需要多态的简单场景,直接用实现类也完全可以,别为了形式而形式。

三、先看反例:不分层会死在哪

实际开发中我们经常会遇到这种代码------一个 Controller 干完了所有事:

java 复制代码
// 反面教材:看着能跑,其实是定时炸弹
@RestController
@RequestMapping("/order")
public class OrderController {

    @Autowired private JdbcTemplate jdbcTemplate;   // 越界,干了 Dao 的活
    @Autowired private PayClient payClient;         // 越界,直接调三方

    @PostMapping("/create")
    public Map<String, Object> create(@RequestBody Map<String, Object> req) {
        if (req.get("skuId") == null) return Map.of("code", 400); // 参数校验混着业务校验

        // 业务规则直接写死在 Handler 里:满 100 减 20,VIP 再 9 折
        BigDecimal amount = new BigDecimal(req.get("price").toString());
        if (amount.compareTo(new BigDecimal("100")) >= 0) {
            amount = amount.subtract(new BigDecimal("20"));
        }
        if (Boolean.TRUE.equals(req.get("vip"))) {
            amount = amount.multiply(new BigDecimal("0.9"));
        }

        jdbcTemplate.update("INSERT INTO t_order(sku_id, amount) VALUES(?, ?)", req.get("skuId"), amount);
        jdbcTemplate.update("UPDATE t_stock SET num = num - 1 WHERE sku_id = ?", req.get("skuId"));
        payClient.pay(amount);   // 三件事没有事务保护,扣了库存可能建单失败
        return Map.of("code", 200, "amount", amount);
    }
}

这段代码有四个病:没有事务边界 (扣了库存可能建单失败)、规则无法复用 (App 端、小程序、定时任务想用这套逻辑只能 Ctrl+C / Ctrl+V)、无法单测 (必须连库起 Tomcat)、换库全员重写。

我之前就踩过这个坑:一个订单 Controller 从 500 行涨到 2000 行之后,修 bug 的平均耗时从 1 小时涨到 7 小时。原因不复杂------加个优惠券逻辑,顺手改了一个共享的 Map,结果把会员等级计算带崩了。

这种代码有名字,叫大泥球(Big Ball of Mud) ,或者更形象一点:意大利面条,牵一根,全身动。痛点一句话总结:改不动、测不了、复用不了、事务还没边界。

除了"全糊一锅",还有三种更隐蔽的越界,Code Review 时要重点盯:

反例 1:Controller 直连 Dao

java 复制代码
@RestController
public class BadUserController {
    @Autowired private UserDao userDao;

    @GetMapping("/user/{id}")
    public User getUser(@PathVariable Long id) {
        return userDao.selectById(id);   // 数据库实体直接返给前端
    }
}

技术上能跑,但没有业务承载点、没有事务边界,后续加权限、风控、缓存都很麻烦,数据库实体还直接暴露给了前端。

反例 2:Service 里处理 HTTP 细节

java 复制代码
public UserVO register(HttpServletRequest request, UserRegisterDTO dto) {
    String ip = request.getHeader("X-Forwarded-For");
    // ...
}

Service 被 Web 协议污染后,换成 MQ、RPC、定时任务来调就别扭了,复用性大打折扣。正确做法是 Controller 把需要的上下文提前组装好,再传给 Service。

反例 3:Dao 里写业务判断

java 复制代码
default boolean canRegister(String username) {
    return countByUsername(username) == 0
        && !username.contains("admin")
        && username.length() > 6;
}

业务规则跑到数据访问层,Dao 不再纯粹,规则一变就得翻到最底层去改。记住:Dao 只认表,不认业务。

四、标准姿势:用一个下单场景把三层落地

光说理论没意思,直接上代码。我们拿经典的用户下单场景,把事务、异常处理、依赖倒置、对象分层一次玩明白。

4.1 先搭基础设施:统一返回体

前后端协议统一,是分层架构的第一步。不管成功还是异常,返回格式永远一致:

java 复制代码
public class Result<T> {
    private Integer code;
    private String message;
    private T data;

    public static <T> Result<T> success(T data) {
        Result<T> result = new Result<>();
        result.code = 200;
        result.message = "success";
        result.data = data;
        return result;
    }

    public static <T> Result<T> fail(String message) {
        Result<T> result = new Result<>();
        result.code = 500;
        result.message = message;
        return result;
    }
}

4.2 Controller:薄到只剩"翻译"

java 复制代码
@RestController
@RequestMapping("/orders")
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;   // 依赖接口,不依赖实现

    /** 下单:只做「参数进 → 结果出」,业务一概不管 */
    @PostMapping
    public Result<OrderVO> create(@Valid @RequestBody OrderCreateDTO dto) {
        // @Valid 触发 JSR303 校验,非法参数直接 400,业务层拿到的永远是干净数据
        return Result.success(orderService.createOrder(dto));
    }

    @GetMapping("/{id}")
    public Result<OrderVO> detail(@PathVariable Long id) {
        return Result.success(orderService.getOrderDetail(id));
    }
}

关键原则:Controller 只做三件事------接参数、调 Service、返结果。 团队可以定个规矩:Controller 方法超过 15 行就问一句为什么,超过 30 行立刻重构。

4.3 Service:业务规则的唯一聚居地,事务边界的唯一归属

java 复制代码
public interface OrderService {          // 面向接口编程,方便多实现 / 动态代理 / Mock
    OrderVO createOrder(OrderCreateDTO dto);
    OrderVO getOrderDetail(Long id);
}

@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {

    private final OrderMapper orderMapper;
    private final StockMapper stockMapper;
    private final PriceCalculator priceCalculator;          // 易变的计价规则抽成独立组件
    private final ApplicationEventPublisher publisher;      // 事件解耦下游

    @Override
    @Transactional(rollbackFor = Exception.class)           // 业务动作 = 事务边界
    public OrderVO createOrder(OrderCreateDTO dto) {
        // 1. 业务校验(注意:不是格式校验,格式校验在 Controller)
        Stock stock = stockMapper.selectBySkuId(dto.getSkuId());
        if (stock == null || stock.getAvailable() < dto.getNum()) {
            throw new BizException("库存不足");
        }
        // 2. 业务规则,规则本身可替换(策略模式)
        BigDecimal payAmount = priceCalculator.calc(dto.getUserId(), dto.getSkuId(), dto.getNum());
        // 3. 原子扣库存:把判断和更新压成一条 SQL,防超卖
        int affected = stockMapper.deduct(dto.getSkuId(), dto.getNum());
        if (affected == 0) {
            throw new BizException("扣减库存失败");
        }
        // 4. 落订单,与扣库存在同一事务里,要么都成要么都回滚
        Order order = Order.create(dto, payAmount);         // 领域对象自己负责构造
        orderMapper.insert(order);
        // 5. 解耦下游:发事件而不是直接发 MQ,事务提交后才真正执行
        publisher.publishEvent(new OrderCreatedEvent(this, order.getId()));
        // 6. PO → VO,数据库实体绝不直接返回给前端
        return OrderVO.from(order);
    }

    @Override
    @Transactional(readOnly = true, rollbackFor = Exception.class)
    public OrderVO getOrderDetail(Long id) {
        Order order = orderMapper.selectById(id);
        if (order == null) {
            throw new BizException(ErrorCode.ORDER_NOT_FOUND);
        }
        return OrderVO.from(order);
    }
}

// 事务提交成功后才执行,回滚则不发送,避免脏消息
@Component
class OrderEventListener {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onCreated(OrderCreatedEvent e) { /* 发 MQ / 发券 / 发短信 */ }
}

这段代码里埋了几个亮点,值得单独拎出来:

亮点 1:原子扣库存防超卖。 不是"先查再减",而是一条带条件的 UPDATE:

java 复制代码
@Mapper
public interface StockMapper {
    @Update("update t_stock set num = num - #{n} where sku_id = #{skuId} and num >= #{n}")
    int deduct(@Param("skuId") Long skuId, @Param("n") Integer n);
}

判断和更新压进同一条 SQL,靠数据库行锁保证并发安全,返回受影响行数让 Service 判断结果。

亮点 2:@TransactionalEventListener(AFTER_COMMIT) 解耦下游。 下单成功后要发 MQ、发券、发短信,直接在事务里调容易出脏消息(事务回滚了消息却发出去了)。用事件 + 提交后回调,失败不发送,干净利落。

亮点 3:构造器注入 + final。 用 @RequiredArgsConstructor 走构造器注入,依赖不可变、方便单测 Mock,还能让循环依赖在启动时就暴露出来,而不是埋到运行时。

4.4 Dao:只认表,不认业务

java 复制代码
@Mapper
public interface OrderMapper {
    int insert(Order order);
    Order selectById(@Param("id") Long id);
    List<Order> selectPage(@Param("status") Integer status,
                           @Param("offset") int offset,
                           @Param("size") int size);
}
xml 复制代码
<!-- OrderMapper.xml:SQL 细节全部收敛在这里,换库 / 换 ORM 只改这层 -->
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
    INSERT INTO t_order (user_id, sku_id, num, pay_amount, status, create_time)
    VALUES (#{userId}, #{skuId}, #{num}, #{payAmount}, #{status}, NOW())
</insert>

分库分表、SQL 优化、索引调整,全部只在这一层迭代,Service 一行不动。

4.5 全局异常兜底:让 Service 只管抛

Service 抛出的业务异常,不需要 Controller 到处 try-catch,统一交给全局处理器:

java 复制代码
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BizException.class)
    public Result<Void> biz(BizException e) {
        return Result.fail(e.getMsg());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<Void> valid(MethodArgumentNotValidException e) {
        return Result.fail(e.getBindingResult().getFieldError().getDefaultMessage());
    }
}

这样一来,异常链路变成:Dao 抛原始异常 → Service 翻译成 BizException → Controller 零 try-catch → @RestControllerAdvice 统一兜底。异常不再跨层污染,每个接口的返回结构也统一了。

4.6 对象怎么流转:DTO / PO / VO 各就各位

几条红线必须守住:

  • 请求/响应对象只活在 Controller 层,绝不往 Service 里传;
  • DTO/BO 是业务对象,是 Service 的输入输出;
  • PO/DO 是数据库表的映射,只在 Dao 层和 Service 内部流转;
  • Entity/PO 绝不直接返回前端------表结构一变接口就被动变更,密码、盐值、逻辑删除字段随时可能泄露;
  • 转换集中到一个 Converter / MapStruct,别在方法里散着写 BeanUtils.copyProperties。

转换工具推荐 MapStruct ------编译期生成转换代码,零反射、类型安全,高并发下性能比 BeanUtils 的反射拷贝高一个量级:

java 复制代码
@Mapper(componentModel = "spring")
public interface OrderConvert {
    OrderConvert INSTANCE = Mappers.getMapper(OrderConvert.class);

    OrderVO toVO(Order po);
    Order toPO(OrderCreateDTO dto, BigDecimal amount);
    List<OrderVO> toVOList(List<Order> pos);
}

4.7 一次完整请求的全流程

把上面的代码串起来,看一个下单请求是怎么在三层中流转的:

4.8 换个场景再体会一遍:用户注册

下单是"写"的代表,再看一个注册场景,体会业务校验和事务怎么配合:

java 复制代码
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

    private final UserDao userDao;
    private final PasswordEncoder passwordEncoder;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public UserVO register(UserRegisterDTO dto) {
        // 1. 业务规则:用户名不能重复
        if (userDao.existsByUsername(dto.getUsername())) {
            throw new BizException(ErrorCode.USERNAME_EXIST);
        }
        // 2. DTO 转 PO,密码加密
        User user = UserConverter.toPO(dto);
        user.setPassword(passwordEncoder.encode(dto.getPassword()));
        // 3. 保存用户 + 送积分 + 写注册日志,同一事务,任一步失败全回滚
        userDao.insert(user);
        pointDao.insert(user.getId());
        userLogDao.insert(user.getId());
        // 4. PO 转 VO 返回
        return UserConverter.toVO(user);
    }
}

@Mapper
public interface UserDao {
    int insert(User user);

    @Select("SELECT COUNT(1) FROM t_user WHERE username = #{username}")
    int countByUsername(@Param("username") String username);

    default boolean existsByUsername(String username) {
        return countByUsername(username) > 0;
    }
}

注意 Dao 里的 default 方法:它只是对查询结果做了一层数据层面的包装(count 转 boolean),没有掺任何业务规则,依然是纯粹的。

五、为什么必须分:五个实打实的收益

前面讲了怎么做,这一节把"为什么"讲透。先看一张对照表:

# 收益 不分层会怎样 分层之后
1 职责单一,改动互不影响 一个类 3000 行,改一处崩三处 每层只干一件事,改动影响面可控
2 解耦 & 复用 下单逻辑写在 Controller,App / 小程序 / RPC 各复制一份 Service 一次编写,HTTP、RPC、MQ、定时任务都能调
3 事务边界清晰 事务加在 Controller 上,长事务拖垮连接池 @Transactional 只圈住业务原子操作
4 可测试 单测要起 Servlet 容器、连数据库,没人愿意写 Service 是纯 POJO,Mockito 一 Mock 就能测
5 团队并行 前后端、DBA 互相阻塞 按层分工,接口先行,Mock 并行开发

收益一:复用------写一次,处处调

同一个 UserService.register(),可以被所有入口复用:

HTTP 接口能调,后台任务能调,消息消费者能调,单元测试也能单独调。如果注册逻辑写在 Controller 里,领导说"再做个小程序下单入口",你就只能复制粘贴一遍。

同一份用户查询业务,可以同时给 Web 端、小程序端、OpenAPI 提供不同的 Controller 入口,但共用同一个 Service;多个 Service 也可以复用同一个 Dao 的基础查询。接口从 HTTP 换成 RPC,只需要改 Controller 的注解,业务层零改动。

收益二:事务边界有了天然的归属地

@Transactional 加在 Service,是一件"位置正确"的事。

一次业务动作(下单 = 扣库存 + 建订单 + 记流水)要么全成,要么全不成。加到 Controller 上是错的 :HTTP 请求是无状态的,一个请求可能触发多个业务动作,事务粒度会失控,而且 Controller 里还混着参数解析、鉴权这些不该回滚的动作;加到 Dao 上更错:Dao 只是一次数据库操作,它不知道业务边界在哪,跨表的原子性根本保证不了。

收益三:可测试性------最容易被低估的一条

业务逻辑能脱离 Web 容器和数据库跑单测。测"满 100 减 20 叠加 VIP 9 折"这条规则,只要 Mock 掉 Dao,不连数据库、不启 Tomcat,几百个用例几秒跑完:

java 复制代码
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock private StockDao stockDao;              // Dao 被 Mock 掉
    @InjectMocks private OrderServiceImpl orderService;

    @Test
    void 满100减20再打9折() {
        when(stockDao.getBySkuId(1L)).thenReturn(new Stock(1L, 10));
        BigDecimal pay = orderService.previewAmount(1L, 1L, 1, new BigDecimal("120"));
        assertThat(pay).isEqualByComparingTo("90.00");   // (120-20)*0.9
    }

    @Test
    void 库存不足应抛异常() {
        when(stockDao.deduct(any(), any())).thenReturn(0);
        assertThrows(BizException.class, () -> orderService.createOrder(dto));
    }
}

如果业务和 SQL 混在一起,就只能启动整个服务用 Postman 点,测一次两分钟。分层后各层的测试分工也很清晰:Controller 测参数校验和响应格式,Service 测业务逻辑分支,Dao 测 SQL 正确性。 线上出问题顺着调用链逐层排查,定位效率提升非常明显。

收益四:技术选型的可替换性

  • 今天 Spring MVC,明天换 WebFlux、换 Dubbo 暴露服务:Service 接口不变,业务代码一行不改;
  • 今天 MyBatis,明天换 JPA、加 Redis 缓存、做读写分离多数据源:只替换 Dao 实现,Service 感知不到。

背后是一条朴素的架构原则:变化频率高的放外面,变化频率低的放里面。 协议变化最快,业务次之,存储最慢------把不同速度的东西分开,改动的影响半径就最小。

收益五:团队并行协作

接口定义好之后,前端对着接口 Mock 数据做页面,Controller / Service / Dao 甚至能分给不同的人同时开工:前端联调看 Controller,业务开发写 Service,DBA 优化 Dao 层 SQL,各角色并行互不阻塞。

加分金句:分层最大的价值是强制约定------它让团队里水平参差的 20 个人,写出结构一致的代码。这比任何个人技术都重要。

六、深挖到源码级:事务失效重灾区

讲到这里,面试官最喜欢的追问来了:"你说事务加在 Service,那为什么我在 Service 里这么写就不回滚了?"

java 复制代码
// ❌ 事务失效重灾区:同类 this 调用
@Service
public class OrderService {

    public void batchCreate(List<OrderDTO> list) {
        list.forEach(this::createOne);   // 问题就在这个 this
    }

    @Transactional(rollbackFor = Exception.class)
    public void createOne(OrderDTO dto) { /* 扣库存 + 落单 */ }
}

根因 :Spring 声明式事务靠 AOP 动态代理实现,只有从代理对象进入 的调用才会被事务拦截器接管。this 指向的是原始目标对象,压根没经过代理,@Transactional 等于白加。

四种修法,按推荐度排序:

java 复制代码
// ✅ 推荐①:把事务方法拆到独立的 Service,边界清晰,职责也更单一
@Service
@RequiredArgsConstructor
public class OrderTxService {
    @Transactional(rollbackFor = Exception.class)
    public void createOne(OrderDTO dto) { /* ... */ }
}

// ✅ ②:TransactionTemplate 编程式事务,边界自己控制,最直观
private final TransactionTemplate txTemplate;
public void batchSave(List<Order> list) {
    txTemplate.executeWithoutResult(status -> {
        try {
            list.forEach(orderMapper::insert);
        } catch (Exception e) {
            status.setRollbackOnly();   // 精准控制回滚
            throw e;
        }
    });
}

// ③:暴露代理,从代理进入(需 @EnableAspectJAutoProxy(exposeProxy = true))
((OrderService) AopContext.currentProxy()).createOne(dto);

// ⚠️ ④:注入自身代理,能用但有循环依赖风险,别当常规手段
@Lazy @Resource private OrderService self;

顺手把 Spring 事务失效的完整检查清单奉上------面试官一听就知道你踩过坑:

失效场景 为什么 怎么修
同类 this 自调用 绕过 AOP 代理 拆 Service / TransactionTemplate / AopContext
方法不是 public 代理无法拦截 private、final、static 方法 事务方法必须 public
抛的是检查异常 默认只回滚 RuntimeException 和 Error rollbackFor = Exception.class
try/catch 把异常吞了 事务管理器根本不知道出错了 别吞异常,或手动 setRollbackOnly()
数据库引擎不支持事务 MyISAM 上 commit/rollback 直接被忽略,还不报错 建表用 InnoDB
传播行为配错 如 REQUIRES_NEW 配在了不该独立提交的方法上 明确每个业务方法的事务语义

比失效更隐蔽的:长事务拖垮连接池

事务里塞了 RPC、发短信、发 MQ,DB 连接被长时间占用,连接池很快被打满,接口集体超时。处理原则很明确:

事务里只做 DB 操作。 RPC、HTTP、文件、发消息全部挪到事务外;下游通知用 @TransactionalEventListener(AFTER_COMMIT) 或 TransactionSynchronization 在事务提交后执行。

跨服务的最终一致性,用本地消息表 / Outbox 模式 + MQ ,配合失败重试 + 幂等消费;强一致场景再考虑 TCC / Saga + 幂等补偿。

至于事务传播行为,日常开发默认 REQUIRED 够用,只有"这段逻辑必须独立提交、不受外层事务影响"(比如审计日志)时才用 REQUIRES_NEW。传播机制的完整版篇幅太长,我后面会单独写一篇拆透,感兴趣的话先点个关注,更新不迷路。

七、实战里的这些坑,几乎人人都踩过

分层不是分完就万事大吉,真正折磨人的是落地之后的坑。这些坑我基本都踩过,一个个说。

坑一:N+1 查询------列表页一查上千条 SQL

在 Service 里循环调 Dao,是分层后最常见的性能事故:

java 复制代码
// ❌ 反模式:1 次订单查询 + N 次用户查询
for (Order o : orders) {
    userMapper.selectById(o.getUserId());
}

// ✅ 正解:IN 批量查询 + 内存 Map 组装
List<Long> userIds = orders.stream().map(Order::getUserId).distinct().toList();
Map<Long, UserPO> userMap = userMapper.selectByIds(userIds).stream()
        .collect(Collectors.toMap(UserPO::getId, Function.identity()));

把 100 次查询压成 1 次 IN 查询,MyBatis 用 <foreach> 拼批量条件,JPA 可以用 @BatchSize,必要时上 JOIN 或缓存。分层和性能不冲突,反而给了你一个固定的优化切点------Dao 提供批量接口,Service 负责编排。

坑二:深分页------limit 100000, 20 慢成狗

offset 越大,MySQL 扫的行越多。解法是游标分页 :where id > #{lastId} order by id limit #{size},配合覆盖索引,翻到多深都快。

坑三:对象爆炸------每层一套 PO/BO/VO/DTO,类数量膨胀

这是分层最直观的代价。应对有两招:一是用 MapStruct 编译期自动转换 替代手写 getter/setter 和 BeanUtils,性能提升 10 倍以上;二是合理复用对象,避免过度分层------极简业务没必要四套对象全上。

坑四:Entity 直接返前端------密码字段说漏就漏

json 复制代码
{
  "id": 1,
  "username": "tom",
  "password": "$2a$10$xxxx",
  "deleted": 0,
  "createTime": "2026-01-01"
}

密码、逻辑删除字段直接暴露,关联对象还可能导致 JSON 序列化死循环。解法:DTO 进、VO 出、PO 只在 Dao 流转 ,MapStruct 做转换,实在要临时挡一下用 @JsonIgnore,但那只是补丁,不是方案。

坑五:校验逻辑散落------Controller 校一遍,Service 又校一遍

分工其实很清晰:Controller 做格式校验 (@Valid + 分组校验,管"参数长得对不对"),Service 做业务规则校验(管"这事能不能办")。格式校验放 Service 是浪费,业务校验放 Controller 是越界。

坑六:异常跨层污染------Dao 抛 SQLException,Controller 到处 try-catch

统一用自定义 BizException + @RestControllerAdvice 兜底(4.5 节那套),Service 负责把底层异常翻译成业务异常,Controller 一个 try-catch 都不用写。

坑七:单例 Bean 的线程安全------并发下数据串号

Service 是单例的,禁止定义可变成员变量 。需要状态就用方法参数传递,或者 ThreadLocal(记得在 finally 里清理,否则线程池场景下会内存泄漏 + 数据串台)。

坑八:循环依赖------A 注入 B,B 又注入 A

启动直接报 BeanCurrentlyInCreationException。别靠字段注入硬撑,正确姿势是从设计上消除:抽取第三方 Service、用事件解耦、做接口隔离;构造器注入能让问题在启动时就暴露,反而是好事。实在绕不开才用 @Lazy,那是最后的逃生门,不是常规武器。

坑九:分层变成"机械搬砖"------Service 一路透传

很多项目分层后变成:Controller 调 Service,Service 调 Dao,一路透传,看起来分层了,实际上没有业务抽象。Service 不应该只是 Dao 的代理:

java 复制代码
// 不够好:纯透传
public User getById(Long id) {
    return userDao.selectById(id);
}

// 更好:承载了业务规则
public UserVO getDetail(Long id) {
    User user = userDao.selectById(id);
    if (user == null) {
        throw new BizException(ErrorCode.USER_NOT_FOUND);
    }
    return UserConverter.toDetailVO(user);
}

如果一个 Service 方法真的只是转一遍,没有业务校验、没有跨 Dao 编排、没有事务,那说明这个接口本来就不需要 Service------这是代码问题,不是分层问题。

八、别靠人眼守分层:用 ArchUnit 锁死它

分层靠嘴说、靠 Code Review 提醒,都会慢慢烂掉。我的做法是把架构规则写成测试用例,跑进 CI ------新人第一天乱调用,mvn test 直接红给他看,不用等人来 Code Review 时尴尬。

xml 复制代码
<dependency>
    <groupId>com.tngtech.archunit</groupId>
    <artifactId>archunit-junit5</artifactId>
    <version>1.3.0</version>
    <scope>test</scope>
</dependency>
java 复制代码
@AnalyzeClasses(packages = "com.example.order", importOptions = ImportOption.DoNotIncludeTests.class)
class 分层架构守护Test {

    /** Controller 不许绕过 Service 直连 Dao */
    @ArchTest
    static final ArchRule Controller不得直连Dao = noClasses()
            .that().resideInAPackage("..controller..")
            .should().dependOnClassesThat().resideInAPackage("..dao..");

    /** Dao 是底,不许反向依赖上层 */
    @ArchTest
    static final ArchRule Dao不得反向依赖 = noClasses()
            .that().resideInAPackage("..dao..")
            .should().dependOnClassesThat().resideInAnyPackage("..service..", "..controller..");

    /** Service 保持"业务纯净":不碰 Web 容器和 Servlet 对象 */
    @ArchTest
    static final ArchRule Service不得依赖Web层 = noClasses()
            .that().resideInAPackage("..service..")
            .should().dependOnClassesThat()
            .resideInAnyPackage("org.springframework.web..", "javax.servlet..", "jakarta.servlet..");

    /** Controller 不许出现 SQL ------ 这事不属于它 */
    @ArchTest
    static final ArchRule Controller不许写SQL = noClasses()
            .that().resideInAPackage("..controller..")
            .should().dependOnClassesThat().resideInAnyPackage("java.sql..", "org.springframework.jdbc..");

    /** Controller 不许开事务 */
    @ArchTest
    static final ArchRule Controller不许开事务 = noClasses()
            .that().resideInAPackage("..controller..")
            .should().beAnnotatedWith(Transactional.class);
}

这就是"架构约束自动化":机器能卡的,别靠人记。 这也是分层能不能长期守住的关键。配合两条软约定效果更好:一是约定要具体(Controller 方法只做校验、转换、封装,超过 15 行就要问一句为什么);二是 Code Review 专盯边界,特别盯"新加的 if 属于业务还是格式"。

九、分层不是教条:什么时候可以打破边界

如果面试官反问"你们是不是过度设计了",别慌,正面接住。分层的判据始终是变化轴,而不是"教科书上写了三层":

  • Service 只剩透传的简单查询 ,别硬套三层。单表详情、纯列表分页这类读接口,可以让 Controller 直连读模型(Dao / Repository),少一层空转发。这叫胖模型、瘦服务 ,是务实的选择。用 CQRS 的思路说就是:查询侧读多写少可以直通,但写侧必须保留 Service 做事务边界。
  • 写操作、跨表编排、有业务规则的地方,必须过 Service,事务边界和规则聚居地不能丢。
  • 小项目为了快速上线可以简化 ,甚至合并 Service/Dao,但逻辑边界要留,别为了三层而三层,也别为了一时省事把未来重构的路堵死。

一句话:理解分层,是合格工程师;知道什么时候可以不分层,才是架构师。

Service 越写越胖怎么办

业务复杂度上来后,单个 OrderServiceImpl 动辄几千行,成了"上帝类",谁都不敢改。演进路线有三档:

  1. 按业务域 / 用例拆 Service :OrderQueryService、OrderPayService、UserRegisterService、RiskControlService,一个 Service 只负责一类业务能力;
  2. 引入 Manager 层(阿里开发规范的经典四层):Controller → Service → Manager → Dao。Manager 层沉淀通用能力------第三方接口封装、缓存组合、复用逻辑,Service 只做编排;
  3. 复杂到"规则之间互相组合"时,上 DDD:应用层只做编排,领域层承载业务规则和充血模型,业务下沉到实体本身。
架构 结构 适用场景
经典三层 Controller → Service → Dao CRUD 为主、业务规则不复杂的中小项目,成本最低的成熟范式
阿里规范四层 Controller → Service → Manager → Dao 需要沉淀通用能力:三方封装、缓存组合、复用逻辑
DDD 四层 接口层 → 应用层 → 领域层 → 基础设施层 业务规则复杂到贫血模型扛不住,团队开始用业务术语讨论问题

三层按技术职责 切分,DDD 按业务语义切分,本质是把三层里的 Service 再横切一刀。三层是基础款,DDD 是复杂业务的升级款------别为了架构而架构。

微服务下也一样成立:Dao 的物理形态从"访问我的库"变成"Feign 调别人的服务",但这一层要做的事反而更多------超时、重试、熔断、幂等、降级都得封在这一层,绝不能让业务代码直接感知 Feign 细节,否则 Service 又被 HTTP 细节污染了。

十、面试高频追问,一次答个痛快

把最常被追问的几个问题整理成快问快答,建议直接内化:

Q1:Service 只是把 Dao 的方法转一遍,那还要它干嘛?

如果真是转一遍,那是代码问题不是分层问题。我会反问一句:这个 Service 有没有业务校验、有没有跨 Dao 编排、有没有事务?有,它就不是透传;真没有,我同意简化。分层不是要求每个方法都走三层。

Q2:Controller 为什么不能直连 Dao?我就查个用户详情。

技术上能,@Autowired UserDao 就能跑。但分层约定破了口子之后,第二个人会直连 Dao 做多表关联,第三个人就在 Controller 里写 SQL 了。我的做法是留一个明确的出口:简单只读查询可以走读模型,写操作和有规则的查询必须过 Service,而且用 ArchUnit 卡住。

Q3:Service 一定要写接口吗?

不强制。单实现可以省略,但需要多实现、AOP 代理、Mock 解耦时,接口的价值就很大。

Q4:分层和 DDD 是什么关系?

三层按技术职责切,DDD 按业务语义切。三层适合 CRUD 为主的系统;业务规则复杂到团队开始用业务术语而不是"这个 SQL 怎么改"来讨论问题时,就该往 DDD 演进了。

Q5:分层会不会拖慢性能?

分层本身的开销可以忽略,多一层方法调用而已。真正影响性能的是跨层边界上的交互次数------也就是 N+1。解法在 7.1 节:Service 编排、Dao 提供批量接口。

Q6:复杂查询不知道该放哪?

列表页要查"用户信息 + 订单数 + 最近登录时间 + 标签 + 优惠券状态",在 Service 里循环查就是 N+1。按场景选方案:

场景 方案
简单业务查询 Service 组装多个 Dao
复杂报表 / 列表 单独写 QueryService / QueryDao
高频复杂查询 冗余字段、缓存、ES、宽表
强事务写入 走主链路 Dao
弱一致查询 走读库或异步同步

核心思想:命令查询可以分离(CQRS),复杂查询可以单独建模,不一定非要复用写入模型。

Q7:多端接口怎么复用?

Controller 按端拆(app/、admin/、openapi/),共用同一套 Service------这才是分层最大的复用红利。

Q8:你怎么保证团队真的按分层写?

三件事:约定要具体、Code Review 盯边界、架构守护测试进 CI。

Q9:分层有哪些代价?

主动说出来反而加分:类数量爆炸、调用链路变长、调试复杂度上升。对应的优化:通用基类(BaseController、BaseService)收样板代码、极简业务省略 Service 接口、MapStruct 消化转换成本。架构思维的全局观,就体现在敢讲弊端。

十一、写在最后

把全文浓缩成一段,面试前 30 秒扫一遍就够:

分 Controller、Service、Dao 三层,核心是为了职责分离和解耦。Controller 负责协议适配、参数校验和返回封装;Service 负责业务逻辑、事务控制和流程编排;Dao 负责数据库访问。改接口协议主要改 Controller,改业务规则主要改 Service,换数据库主要改 Dao。它的本质不是代码的分堆,而是**「变化速率」的分层**------把业务规则这份最值钱的资产,从 HTTP 协议和数据库细节里保护出来。当然,分层不是教条,没有业务规则的简单查询,允许它直连读模型。

落到行动上,给你一份可以直接抄的落地清单:

  1. 把三层职责和红线写进团队规范,越具体越好(比如"Controller 方法不超过 15 行");
  2. 事务统一收敛在 Service,全部加 rollbackFor = Exception.class,事务里只做 DB 操作;
  3. 统一 Result 返回体 + BizException + 全局异常处理器;
  4. DTO 进、VO 出、PO 不出 Service,转换交给 MapStruct;
  5. ArchUnit 架构守护测试进 CI,机器能卡的别靠人记;
  6. 简单读接口允许直通,写操作必须过 Service------把这条也写进规范。

三层架构不是银弹,也不是 Java 的强制规范,但它是经过无数互联网项目验证的、成本最低的成熟架构范式。它的好,在项目还小的时候感受不到,等到代码几十万行、团队十几个人、需求天天变的时候,你就知道这几层墙有多值钱了。

好的架构不是为了让代码看起来"专业",而是为了让系统在需求变化面前不崩溃。


聊到这儿,想问问大家:你们公司的项目分了几层?是标准三层、阿里四层,还是"勇气可嘉"的两层? 你有没有踩过事务失效的坑,遇到的是哪种场景?评论区聊聊,看看谁踩的坑最离谱。

相关推荐
Rain的Java大神实战圈1 天前
10 万人抢 2000 个座位,电影票选座系统怎么防超卖?一文讲透大厂高频场景题
场景设计题
Rain的Java大神实战圈4 天前
Java PDF导出,90%的人选错库!11个生产坑+选型+代码,一次讲透
场景设计题
Rain的Java大神实战圈7 天前
别再瞎装 RabbitMQ 了!从 0 到 1 部署到 Spring Boot 全链路实战,生产级坑全填平
场景设计题
Rain的Java大神实战圈8 天前
我劝你别再无脑用 MySQL:PostgreSQL 这 5 个底层能力,正在拉开架构师差距
场景设计题
Rain的Java大神实战圈16 天前
RabbitMQ、Kafka、RocketMQ 三选一?这篇把消息队列选型彻底讲透了
场景设计题
Rain的Java大神实战圈17 天前
Elasticsearch从0-1部署成功实战
场景设计题
Rain的Java大神实战圈18 天前
100亿订单号如何去重
场景设计题
Rain的Java大神实战圈18 天前
Git 冲突全攻略:从原理到实战,一文打通所有场景
场景设计题
Rain的Java大神实战圈18 天前
保证线程安全的方法有哪些
场景设计题