昨天晚上十一点多吧,我在公司楼下抽烟(别学哈),我们组小李一边刷手机一边问我:“哥,Controller 并发安全到底咋弄啊?我看 Spring 里都是单例,那不炸了吗…”我当时困得要死,但这问题真挺容易线上出事故的,尤其那种“看起来没问题”的代码。
你们先记一个特别土但特别准的点:Controller 默认是单例,多线程共享同一个对象。所以你只要在 Controller 里放了“会变的东西”,比如计数器、StringBuilder、List、Map(不是并发 Map),就可能被两个请求同时改,数据乱飞。很多人还喜欢把“上一次请求的用户”塞字段里,哎这就更刺激了。
比如这种我见过好几次,压测一上来就开始玄学:
@RestController
@RequestMapping("/demo")
publicclass BadController {
// 共享可变状态:线程不安全
privateint counter = 0;
// 共享可变对象:线程不安全
privatefinal StringBuilder sb = new StringBuilder();
@GetMapping("/hit")
public String hit(@RequestParam String name) {
counter++; // 竞态:丢增量
sb.setLength(0);
sb.append("hello ").append(name).append(", counter=").append(counter);
return sb.toString(); // 可能串台
}
}
这玩意在你本地单线程测,永远“好好的”,上线一到高峰,返回值里 name 会串,counter 还会莫名其妙倒退(你没看错,就是丢更新)。所以最核心的“并发安全”不是加锁,而是——Controller 尽量无状态,别存请求相关数据到成员变量里,别缓存临时对象到字段里。
改法一般就俩方向,一个是“别共享”,一个是“共享也得用对工具”。
“别共享”最简单:把临时变量都放方法里,让它天然线程隔离:
@RestController
@RequestMapping("/demo")
public class GoodController {
@GetMapping("/hit")
public String hit(@RequestParam String name) {
// 局部变量每个线程一份
int localCounter = 1; // 例子而已,真实计数别这么玩
StringBuilder local = new StringBuilder();
local.append("hello ").append(name).append(", local=").append(localCounter);
return local.toString();
}
}
那有人就说了:哥我就是要计数、要统计 QPS、要做全局开关咋办?行,那就“共享也得用对工具”。计数这种用原子类就够了,别上来 synchronized 把接口锁死(锁一把,吞吐直接跪):
@RestController
@RequestMapping("/metrics")
public class MetricsController {
private final java.util.concurrent.atomic.AtomicLong counter = new java.util.concurrent.atomic.AtomicLong();
@GetMapping("/hit")
public long hit() {
return counter.incrementAndGet();
}
}
但注意哈,这种 AtomicLong 只能保证“这个变量的自增”是安全的,不等于你整个业务逻辑安全。业务里最常见的并发坑是“查-改-写”三连,比如下单扣库存:两个请求同时查到库存=1,然后都扣成 0,超卖了。你在 Controller 里加锁也不靠谱,因为服务是多实例的,你锁住一个 JVM,别的机器照样改。
这时候就得把“并发安全”往下沉:要么交给数据库的锁/事务,要么用乐观锁版本号,要么用分布式锁/幂等机制。我一般跟人说:Controller 只做参数校验 + 编排,真正的并发一致性交给 Service 和存储层。
给你们一个我常用的“乐观锁”写法,比较干净。表里加个 version 字段(int),更新时带上 version 条件,更新成功才算抢到:
// 简化的库存扣减 Service
@Service
publicclass StockService {
privatefinal StockMapper stockMapper;
public StockService(StockMapper stockMapper) {
this.stockMapper = stockMapper;
}
@Transactional
public void deduct(long skuId, int count) {
Stock stock = stockMapper.selectBySkuId(skuId);
if (stock.getAvailable() < count) {
thrownew IllegalStateException("库存不足");
}
int updated = stockMapper.deductWithVersion(skuId, count, stock.getVersion());
if (updated == 0) {
// 没更新到:说明并发冲突,版本被别人改了
thrownew IllegalStateException("并发冲突,请重试");
}
}
publicstaticclass Stock {
privatelong skuId;
privateint available;
privateint version;
public long getSkuId() { return skuId; }
public int getAvailable() { return available; }
public int getVersion() { return version; }
}
}
mapper 这边(我就当你们用 MyBatis 风格哈)核心是 where 带 version,然后 version 自增:
public interface StockMapper {
StockService.Stock selectBySkuId(long skuId);
// UPDATE stock SET available = available - #{count}, version = version + 1
// WHERE sku_id = #{skuId} AND version = #{version} AND available >= #{count}
int deductWithVersion(long skuId, int count, int version);
}
你看,Controller 根本不用管锁,来了就调用 service,失败就提示“重试”。这比你在 Controller 里 synchronized 强一万倍,尤其多实例场景。
还有个超常见的坑:重复提交。用户手抖点两下、客户端重试、网关重放,都能把同一笔业务打两遍。很多人以为“并发安全”就是线程安全,结果订单重复创建,财务对不上,哭都来不及。这个我一般用“幂等 key”兜底:客户端带一个 Idempotency-Key,同一个 key 只处理一次。
我写个不依赖外部中间件的最小版(真实线上一般放 Redis,用 SETNX + 过期时间),先让你们感受一下思路:
@Component
publicclass IdempotencyGuard {
privatefinal java.util.concurrent.ConcurrentHashMap<String, Long> seen = new java.util.concurrent.ConcurrentHashMap<>();
privatefinallong ttlMillis = 30_000; // 30秒窗口
public boolean tryPass(String key) {
long now = System.currentTimeMillis();
// 顺手清理一下过期(很粗糙,但够演示)
seen.entrySet().removeIf(e -> now - e.getValue() > ttlMillis);
return seen.putIfAbsent(key, now) == null;
}
}
Controller 里用起来就很直白:
@RestController
@RequestMapping("/order")
publicclass OrderController {
privatefinal IdempotencyGuard guard;
privatefinal OrderService orderService;
public OrderController(IdempotencyGuard guard, OrderService orderService) {
this.guard = guard;
this.orderService = orderService;
}
@PostMapping("/create")
public String create(@RequestHeader("Idempotency-Key") String key,
@RequestBody CreateOrderReq req) {
if (!guard.tryPass(key)) {
return"重复请求,已拦截";
}
long orderId = orderService.create(req);
return"ok:" + orderId;
}
publicstaticclass CreateOrderReq {
publiclong skuId;
publicint count;
}
}
这个例子你别拿去直接上生产哈,我就是演示“Controller 不存状态,但可以用并发容器做轻量防抖”。线上你要考虑分布式、过期、容量、误杀,最后还是 Redis 那套更稳。
对了,还有一种“看着很聪明其实很阴险”的写法:ThreadLocal。有人把 userId、traceId 放 ThreadLocal 里,然后 Controller 里到处取。听着不错对吧?但线程池复用,清理不干净就会串请求,甚至内存泄漏。你真要用,必须 finally 里 remove,别偷懒,不然你迟早被它背刺。
反正说到这儿,你们脑子里就形成一个画面:Controller 像收银台,别把顾客的购物篮放柜台上当公共资产;真正的“库存、扣款、幂等、锁”这些,得在仓库和账本那层做。行了我先去把刚才那根烟掐了,等会儿群里要是有人又问“那我到底该不该在 Controller 里加 synchronized”,我估计还得再骂两句…