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 清理与集群失效两点,就能覆盖面试与实战。