Spring Boot Controller 层怎么实现并发安全的?

一则或许对你有用的小广告

欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于 Spring AI + Spring Boot3.x + JDK 21...点击查看;
  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot3.x + JDK 17...点击查看项目介绍; 演示链接: http://116.62.199.48:7070/;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/

面试考察点

  • 理解 Spring Bean 的默认作用域
    面试官想知道你是否清楚 Spring MVC 的 Controller 默认是单例(Singleton)的,而不是每个请求都会创建一个新实例。

  • 线程安全的基础知识
    单例意味着多个线程(即多个HTTP请求)会同时访问同一个 Controller 实例。此时,如果你在 Controller 里定义了可变的成员变量,就可能出现数据错乱。面试官在考察你是否具备基本的并发安全意识。

  • 解决并发问题的常见手段
    不只是知道问题,还要知道怎么解决。是无状态设计?还是用同步锁?或者使用 ThreadLocal?面试官希望看到你根据场景选择合适方案的能力。

  • 框架层面的支持与限制
    比如 @Scope("prototype")@SessionAttributesRequestContextHolder 等,了解这些说明你对 Spring 框架本身有较深入的认识。

  • 实际开发中的最佳实践
    最终,面试官会判断你在真实项目中是否会写出线程安全的代码,避免低级 bug。

核心答案

Spring Boot 中的 Controller 默认是单例的,也就是整个应用生命周期内只有一个实例来处理所有请求。因此,要实现并发安全,核心思路就是避免在 Controller 里定义可变的成员变量,让 Controller 成为无状态的。如果实在需要存储请求级别的数据,应该通过方法参数(如 @RequestParam@PathVariable)或者 ThreadLocal 来传递。

深度解析

原理 / 机制

我们来捋一下 Spring MVC 处理请求的过程:

  1. 客户端发送请求到 Servlet 容器(如 Tomcat)。
  2. Tomcat 为每个请求分配一个独立的线程(默认线程池模型)。
  3. Spring 的前端控制器 DispatcherServlet 接收请求,根据 URL 找到对应的 Controller 和 Method。
  4. 由于 Controller 是单例,多个线程会同时执行同一个 Controller 实例的方法。

这时,如果 Controller 里有这样一段代码:

@RestController
public class UnsafeController {
    
    private int count = 0;   // 共享可变状态

    @GetMapping("/increment")
    public String increment() {
        count++;  // 多线程下非原子操作
        return "Count: " + count;
    }
}

当多个请求同时打到 /increment 时,count++ 这个操作就不是原子的,很可能出现线程交错,导致最终结果比预期小(丢失更新)。这就是典型的线程安全问题。

代码示例

错误示例(有状态 Controller)

@RestController
public class WrongController {
    
    private int visitorCount = 0;   // 隐患!多个线程共享

    @GetMapping("/visit")
    public String visit() {
        visitorCount++;
        return "你是第 " + visitorCount + " 位访客";
    }
}

正确示例(无状态 Controller)

@RestController
public class SafeController {
    
    // 没有任何可变的成员变量,所有数据通过方法参数传递
    @GetMapping("/add")
    public int add(@RequestParam int a, @RequestParam int b) {
        return a + b;
    }
}

如果必须使用成员变量(比如需要注入 Service)

Service 本身也应该是无状态的(通常 Service 也是单例,且内部无可变成员变量)。如果 Service 有状态,同样需要注意并发问题。

@RestController
public class GoodController {
    
    @Autowired
    private UserService userService;   // 注入的 Service 如果是无状态的,则安全

    @GetMapping("/user/{id}")
    public User getUser(@PathVariable Long id) {
        return userService.findById(id);
    }
}

特殊情况:使用 ThreadLocal

某些场景下,我们确实需要在一次请求的多个地方共享数据,但又不想通过方法参数层层传递。这时可以使用 ThreadLocal,它能为每个线程(即每个请求)维护一个独立的副本。

Spring 框架本身也利用了 ThreadLocal 来实现事务管理(TransactionSynchronizationManager)和请求上下文(RequestContextHolder)。

@RestController
public class ThreadLocalController {
    
    private static final ThreadLocal<Integer> requestLocal = new ThreadLocal<>();

    @GetMapping("/set")
    public String set(@RequestParam int value) {
        requestLocal.set(value);
        return "set: " + value;
    }

    @GetMapping("/get")
    public String get() {
        Integer value = requestLocal.get();
        return "get: " + value;
    }
}

注意:用完 ThreadLocal 后一定要调用 remove() 方法清理,避免内存泄漏或请求间数据污染(尤其在使用了线程池的情况下)。

对比分析

方案优点缺点适用场景
无状态设计简单、性能好、无锁无法存储请求相关的成员变量绝大多数 Controller
同步锁(synchronized)保证线程安全串行化,性能极差,几乎不用极少数需要全局计数的场景(不推荐)
ThreadLocal每个请求独立,无锁内存开销,需手动清理需要在一次请求内传递上下文(如用户信息、traceId)
修改作用域为 prototype每次请求创建新实例创建开销大,无法利用单例优势极少用,通常不推荐

最佳实践

  1. 默认无状态
    Controller 里不要定义可变的实例变量。所有需要的数据都通过方法参数传入,或者从请求上下文中获取。

  2. 利用 Spring 的请求作用域 Bean
    如果确实需要一个请求级别的 Bean,可以使用 @Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS),这样每次请求都会产生一个新的 Bean 实例。

  3. 谨慎使用 ThreadLocal
    如果用,务必在请求结束时(例如通过 HandlerInterceptorafterCompletion)调用 remove() 清理。

  4. 避免在 Controller 中编写复杂业务逻辑
    业务逻辑交给 Service 层处理,Controller 只负责参数解析、路由和响应。Service 层也要保持无状态。

常见误区

  • 误区 1:认为每个请求都会创建一个新的 Controller 实例。
    事实:Controller 默认是单例,除非显式声明 @Scope("prototype")

  • 误区 2:觉得在方法内部定义的局部变量不会有线程安全问题。
    局部变量是线程私有的,确实安全。但要注意,如果局部变量引用的是一个共享的可变对象(如 ArrayList 成员变量),则仍然有并发问题。

  • 误区 3:滥用同步锁。
    在 Controller 里加 synchronized 会让所有请求串行处理,完全丧失并发能力,不到万不得已不要用。

  • 误区 4:忽视依赖注入的 Bean 的线程安全性。
    注入的 Service、Repository 等,如果它们内部有可变状态且是单例的,同样会引发并发问题。

总结

Spring Boot Controller 层的并发安全问题,本质上是由单例模式和多线程并发访问引起的。最优雅的解决方案就是让 Controller 保持无状态 —— 不持有可变的成员变量。如果实在需要存储请求级别的数据,可以借助 ThreadLocal 或请求作用域的 Bean,但要注意清理和性能开销。记住这些原则,你就能写出既安全又高效的 Controller 代码。