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

#Java#面试八股

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

结论先行:Controller 默认是单例,所有请求线程共享同一个实例,所以并发安全的唯一正解是"不共享可变状态":状态放在方法局部变量、请求参数、ThreadLocal 或外部存储里。需要计数用原子类,需要互斥用细粒度锁或数据库行锁,跨实例则必须用 Redis 分布式锁或幂等设计——synchronized 在集群里连同一台机器的多个 JVM 都管不住。

一、先明确风险点

Tomcat 线程池(200 线程)
   ├─ T1 ─┐
   ├─ T2 ─┼──▶ 同一个 Controller 实例(单例)
   └─ T3 ─┘        ├─ 成员变量 count++      ← 竞态
                   ├─ 成员变量 SimpleDateFormat ← 线程不安全
                   └─ 成员 Map 做缓存        ← 无界 + 非并发容器
状态存放位置是否线程安全说明
方法内局部变量安全栈上私有,首选
请求参数 / HttpServletRequest安全每请求独立
ThreadLocal安全(需 remove)线程池复用会串数据
单例成员变量(可写)不安全必须消除或用原子类
ConcurrentHashMap 成员容器安全,业务不一定注意复合操作的原子性
数据库 / Redis需事务或锁跨实例唯一可靠方案

二、四种典型方案

方案适用代价
无状态 + 局部变量绝大多数接口无
AtomicLong / LongAdder单机计数、限流阈值仅单机有效
数据库乐观锁(version)库存扣减、状态流转需重试
Redis 分布式锁 / 幂等令牌跨实例互斥、防重复提交依赖 Redis 可用性

三、代码:从错误到正确

// 反例:单例里放共享可变状态
@RestController
public class BadController {
    private int seq = 0;                                  // 竞态
    private final Map<String, Object> cache = new HashMap<>();  // 非并发 + 无界

    @GetMapping("/seq")
    public int seq() {
        return ++seq;                                     // 并发丢更新
    }
}
@RestController
@RequestMapping("/api/stock")
public class StockController {

    private final StockService stockService;              // Service 内部用乐观锁
    private final AtomicLong requestCounter = new AtomicLong();   // 单机指标

    public StockController(StockService stockService) {
        this.stockService = stockService;
    }

    @PostMapping("/deduct/{skuId}")
    public Result<Void> deduct(@PathVariable Long skuId,
                               @RequestParam int count,
                               @RequestHeader("Idempotency-Key") String idemKey) {
        requestCounter.incrementAndGet();                 // 原子自增,安全
        // 幂等 + 互斥交给服务端存储,而不是 JVM 锁
        stockService.deductWithLock(skuId, count, idemKey);
        return Result.ok();
    }
}
@Service
public class StockService {

    private final StockMapper stockMapper;
    private final StringRedisTemplate redis;

    // 乐观锁:UPDATE ... WHERE version = ?,影响行数为 0 说明被并发改过
    @Transactional(rollbackFor = Exception.class)
    public void deductWithLock(Long skuId, int count, String idemKey) {
        String key = "idem:stock:" + idemKey;
        Boolean first = redis.opsForValue().setIfAbsent(key, "1", Duration.ofMinutes(5));
        if (Boolean.FALSE.equals(first)) {
            return;                                       // 重复提交,直接返回
        }
        int updated = stockMapper.deduct(skuId, count);   // 库存不足时 updated = 0
        if (updated == 0) {
            throw new BizException("库存不足或并发冲突");
        }
    }
}

四、几组容易混淆的对比

  • @Scope("prototype") 能解决吗? 不能真正解决。每次请求拿新实例,看似隔离,但 Controller 通常只被容器创建一次(除非 proxyMode),且业务状态若在 Service/缓存里,仍会共享;代价是对象创建与依赖注入开销。
  • synchronized 为什么不够? 锁的是单个 JVM 内的对象,多实例部署、容器水平扩容后完全失效;单机场景还会把接口串行化,吞吐骤降。
  • ThreadLocal 解决什么? 解决"每请求上下文"(用户 ID、TraceId、租户),不解决"多请求共享计数"。且必须 finally { remove(); },否则线程池复用导致数据串号与内存泄漏。
  • ConcurrentHashMap 就安全了吗? 单次 get/put 安全,但 if (!map.containsKey(k)) map.put(k, v) 这种复合操作仍需 computeIfAbsent 或外部锁。

五、常见追问

  • 问:SimpleDateFormat 为什么会出错? 内部 Calendar 是共享可变状态,多线程 format 可能抛 NumberFormatException 或输出错乱,改用 DateTimeFormatter(不可变、线程安全)。
  • 问:限流用 AtomicInteger 行吗? 单机可以,集群要用 Redis + Lua 保证"判断 + 自增"的原子性。
  • 问:@Transactional 能替代锁吗? 不能。事务保证原子性与隔离级别,但默认隔离级别下仍可能出现丢失更新,需乐观锁或 SELECT ... FOR UPDATE。
  • 问:怎么发现这类问题? 代码扫描(成员变量白名单)、并发压测、JFR/arthas 观察异常,以及 Code Review 时盯住"Controller 里的字段"。

小结

一句话:Controller 只做协议转换,状态一律外移。答题顺序:先点明"单例 + 共享可变状态 = 不安全",再给"无状态化 → 原子类 → 数据库锁/分布式锁"的升级路径,最后补 ThreadLocal 清理与集群失效两点,就能覆盖面试与实战。

笔记加载中…