首页
学习
活动
专区
圈层
工具
发布

如何保证 Controller 的并发安全?

昨天晚上十一点多吧,我在公司楼下抽烟(别学哈),我们组小李一边刷手机一边问我:“哥,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”,我估计还得再骂两句…

  • 发表于:
  • 原文链接https://page.om.qq.com/page/O_VIBZgJeoODg3UDsj7TY1Hg0
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。
领券