从订单超卖到千万级并发:一文打通Java多线程实战全链路
假设你入职了一家电商创业公司,上班第一天,Leader 丢给你一个任务:「把那个库存超卖的 bug 修了。」你以为是简单的校验逻辑错误,打开代码一看——同一个库存字段,被三个定时任务、两个消息队列消费者、外加用户下单请求同时读写。没有任何同步措施。
修这个 bug 的过程,会把 Java 并发编程从头到尾踩一遍。这篇文章就是这趟踩坑之旅的全记录:从 synchronized 止血开始,到 CompletableFuture 编排异步链路,最后面对分布式环境下的并发问题。每一步都是上一步问题的自然延伸。
看完你会知道:多线程不是你背的那些八股文,而是你线上出了问题知道该怎么想、怎么查、怎么修。
1. 凌晨三点的报警:库存变成负数
系统做的是一个生鲜电商,每天凌晨会有一波抢购活动。那天凌晨三点,客服电话被打爆了——用户下了单付了款,系统却说没货了。
打开数据库一看,某款牛奶的库存字段是 -7。
为什么会超卖
简化一下当时的代码:
// 下单扣库存
public void deductStock(Long productId, int quantity) {
int currentStock = stockMapper.selectStock(productId);
if (currentStock >= quantity) {
stockMapper.updateStock(productId, currentStock - quantity);
}
}
这段代码在单线程跑的时候没有任何问题。但凌晨抢购活动期间,Tomcat 线程池里几十个线程同时执行这个方法,就出事了:
- 线程 A 查到库存是 5
- 线程 B 也查到库存是 5
- 线程 A 判断
5 >= 3,更新库存为5 - 3 = 2 - 线程 B 判断
5 >= 4,更新库存为5 - 4 = 1 - 最终库存变成 1,但实际卖出了 7 件
这就是典型的读-检查-写竞态条件。线程 A 的写操作还没提交,线程 B 已经读到了旧值。
第一反应:加 synchronized
public synchronized void deductStock(Long productId, int quantity) {
int currentStock = stockMapper.selectStock(productId);
if (currentStock >= quantity) {
stockMapper.updateStock(productId, currentStock - quantity);
}
}
synchronized 加在实例方法上,同一个 Controller 实例同时只有一个线程能执行这个方法。库存不会变负数了。
但问题也来了。
第二天上线后,抢购活动的 QPS 从 800 掉到了 200。用户在页面上点「立即购买」,要等三四秒才有反应。为什么?因为 synchronized 把整个方法锁住了——所有商品的扣库存操作,不管买的是牛奶还是面包,全部排队串行。
关键认知:synchronized 解决的是正确性问题,但它不管性能。它的锁范围是你写多大就多大,而锁范围越大,并发度越低。
2. 止血之后:把锁拆细
synchronized 的问题在于锁的范围太粗。买牛奶的请求和买面包的请求根本不应该互相等待——它们操作的是不同的库存行。
按商品 ID 拆锁
// 每个商品自己一把锁
private final ConcurrentHashMap<Long, Object> locks = new ConcurrentHashMap<>();
public void deductStock(Long productId, int quantity) {
Object lock = locks.computeIfAbsent(productId, k -> new Object());
synchronized (lock) {
int currentStock = stockMapper.selectStock(productId);
if (currentStock >= quantity) {
stockMapper.updateStock(productId, currentStock - quantity);
}
}
}
现在买牛奶的等牛奶锁,买面包的等面包锁,互不影响。QPS 恢复到 600 多。
但这里又引出了新问题:ConcurrentHashMap 的 computeIfAbsent 在高并发下可能导致同一个 key 多次创建锁对象吗?不会——ConcurrentHashMap 保证了原子性。但删锁是个坑:如果你用完就 remove,可能线程 A 刚删掉锁,线程 B 又 computeIfAbsent 创建了一个新锁——两个线程持有不同的锁对象,又回到了没有同步的状态。
关键认知:锁粒度不是越细越好。拆太细之后,锁的生命周期管理会成为新的复杂度来源。
你不是一直需要写锁
继续看业务代码,发现大部分请求其实是查库存,不是扣库存。商品详情页、购物车校验、首页推荐——这些读操作远多于写操作。
Java 的 ReadWriteLock 就是为这个场景设计的:
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
public int getStock(Long productId) {
rwLock.readLock().lock();
try {
return stockMapper.selectStock(productId);
} finally {
rwLock.readLock().unlock();
}
}
public void deductStock(Long productId, int quantity) {
rwLock.writeLock().lock();
try {
int currentStock = stockMapper.selectStock(productId);
if (currentStock >= quantity) {
stockMapper.updateStock(productId, currentStock - quantity);
}
} finally {
rwLock.writeLock().unlock();
}
}
读锁是共享的——十个线程同时读库存,谁也不阻塞谁。只有写锁是互斥的——写的时候既不能读也不能写。在这个业务场景下,库存查询的吞吐量翻了好几倍。
什么时候用 ReadWriteLock:读操作显著多于写操作,且写操作不是特别频繁。如果写操作也很频繁,写锁会一直阻塞读锁,反而退化成普通互斥锁的性能。
ReentrantLock 比 synchronized 多出来的能力
在上面的例子中你可能会问:为什么不用 ReentrantLock?synchronized 和 ReentrantLock 有什么区别?
ReentrantLock 多出了三个关键能力,这是 synchronized 做不到的:
1. 尝试获取锁,拿不到就先干别的
public void deductStock(Long productId, int quantity) {
boolean locked = lock.tryLock(500, TimeUnit.MILLISECONDS);
if (!locked) {
// 500ms 内拿不到锁,返回「系统繁忙」而不是一直等
throw new BizException("当前下单人数过多,请稍后重试");
}
try {
// 扣库存逻辑
} finally {
lock.unlock();
}
}
synchronized 拿不到锁就死等,用户就只能看着页面转圈。tryLock 给了你一个选择:等不到就快速失败,给用户一个明确的反馈。这在秒杀场景里是标准做法。
2. 公平锁——让等待最久的线程先拿到锁
private final ReentrantLock fairLock = new ReentrantLock(true); // true = 公平锁
默认的 synchronized 是非公平的:新来的线程可能先抢到锁,而已经等了很久的线程可能一直抢不到。对用户来说,就是「明明我先点的,他的订单却先完成了」。当然公平锁有性能代价——需要维护等待队列。大多数业务场景用非公平锁就够,公平锁只在需要严格保证顺序时使用。
3. 可以查询锁的状态
if (lock.isLocked()) {
log.warn("锁被占用,当前等待队列长度: {}", lock.getQueueLength());
}
线上排查问题时,能知道「现在有人在等锁吗」「有多少线程在等」,这个信息非常关键。synchronized 是完全黑盒的。
3. 别让你的线程满天飞
库存问题解决之后,接下来重构订单处理流程。当时代码里到处是这样的写法:
new Thread(() -> {
sendSms(order.getPhone(), "下单成功");
}).start();
new Thread(() -> {
pushToErp(order);
}).start();
new Thread(() -> {
updateRecommendationModel(order.getUserId());
}).start();
这段代码的问题不是一眼能看出来的。每个 new Thread() 都会在操作系统层面创建一个真正的内核线程。JVM 里起几百个线程是小意思,但每个线程默认占用约 1MB 的栈内存——起一千个就是 1GB。更糟的是,线程创建和销毁的开销很大,线程之间切换也需要 CPU 做上下文切换。
有一次大促,瞬时流量冲过来,系统创建了几千个线程,然后 OOM 挂了。
ThreadPoolExecutor 的正确打开方式
线程池解决的是「复用」的问题:预先创建一批线程,任务来了分配一个去执行,执行完了线程不销毁,回去等着接下一个任务。
ThreadPoolExecutor executor = new ThreadPoolExecutor(
10, // corePoolSize: 常驻线程数
50, // maximumPoolSize: 最大线程数
60L, TimeUnit.SECONDS, // 空闲线程存活时间
new LinkedBlockingQueue<>(2000), // 任务队列
new ThreadFactoryBuilder().setNameFormat("order-processor-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);
每个参数都对应一个生产问题,逐一解释:
corePoolSize 设多少? 不是越大越好。如果你的任务是 CPU 密集型的(比如图片处理、加密解密),线程数接近 CPU 核心数就够了。如果是 IO 密集型(调数据库、调 RPC、读磁盘),线程数可以设大一些,因为线程大部分时间在等 IO 返回,CPU 闲着。一个经验公式是 CPU核心数 * 2 起步,然后根据实际压测调整。不要背公式,去压测。
maximumPoolSize 和队列的关系:很多人都搞混了。线程池的工作流程是:先派活给 core 线程 → core 线程都忙就放队列 → 队列满了才创建新线程(不超过 maximumPoolSize)→ 线程数满了队列也满了才触发拒绝策略。所以如果你设了一个无界队列(LinkedBlockingQueue 不传容量),maximumPoolSize 根本不会生效——线程数永远不会超过 corePoolSize,因为队列永远不会满。
拒绝策略:当线程数达到 maximumPoolSize 且队列也满了,新来的任务怎么处理?
// CallerRunsPolicy: 让提交任务的线程自己执行
// 相当于「老子不接了,你自己干吧」
// 好处是把压力往回传,变相限流
new ThreadPoolExecutor.CallerRunsPolicy();
// AbortPolicy: 直接抛异常
// 适合必须执行成功的任务,失败了让上层感知
new ThreadPoolExecutor.AbortPolicy();
// DiscardPolicy: 直接丢弃,不抛异常
// 适合不重要的任务,比如埋点上报
new ThreadPoolExecutor.DiscardPolicy();
在我们的订单系统里,订单落库用的是 AbortPolicy——丢订单是不可接受的,宁可抛异常让用户重试。发短信通知用的是 CallerRunsPolicy——短信慢一点没关系,但不能丢,让主线程自己发就是了。
线程池的命名是救命的
new ThreadFactoryBuilder().setNameFormat("order-processor-%d").build()
一定要给线程池里的线程起名字。线上出问题的时候,你用 jstack 看线程 dump:
"order-processor-3" #42 prio=5 os_prio=0 tid=0x00007f8a1c001000 nid=0x1c2b waiting on condition
"http-nio-8080-exec-7" #37 prio=5 os_prio=0 tid=0x00007f8a1c002800 nid=0x1c1f runnable
一眼就能分辨哪些是你的业务线程,哪些是 Tomcat 的请求线程。没命名的话,看着一堆 pool-3-thread-1、pool-3-thread-2,根本分不清谁是谁。
4. 一个订单的异步之旅:CompletableFuture 编排实战
到现在为止,我们的下单流程还是串行的:
参数校验 → 查库存 → 锁优惠券 → 算价格 → 创建订单 → 发短信
每一步等上一步完成,一个下单请求的总耗时是各步耗时之和。但仔细看,「查库存」和「锁优惠券」没有依赖关系,「发短信」和「更新推荐模型」也没有依赖关系——它们完全可以并行执行。
这就是 CompletableFuture 的用武之地。它不是让你去「用多线程」,而是让你描述任务之间的依赖关系,然后框架自动帮你调度并行。
最基本的异步调用
// 假设每个操作返回 CompletableFuture
CompletableFuture<StockResult> stockFuture =
CompletableFuture.supplyAsync(() -> stockService.checkStock(productId, quantity));
CompletableFuture<CouponResult> couponFuture =
CompletableFuture.supplyAsync(() -> couponService.lockCoupon(userId, couponId));
supplyAsync 把任务提交到公共的 ForkJoinPool 去异步执行,主线程不会阻塞。两个查询同时发出,总耗时约等于较慢的那个,而不是两个之和。
但你这样写完之后,主线程怎么知道这两个任务都完成了?用 thenCombine:
CompletableFuture<OrderContext> orderFuture = stockFuture.thenCombine(couponFuture,
(stockResult, couponResult) -> {
// 两个都完成后,在这里组装订单上下文
if (!stockResult.isSufficient()) {
throw new BizException("库存不足");
}
Price price = priceCalculator.calculate(productId, quantity, couponResult);
return new OrderContext(stockResult, couponResult, price);
}
);
thenCombine 的语义是:「等 A 和 B 都完成之后,拿它们的结果做一件事」。A 和 B 是并行执行的,这一步才开始串行。
完整的下单编排
把整个下单流程用 CompletableFuture 串起来:
public CompletableFuture<OrderResult> createOrder(OrderRequest request) {
// 第一阶段:并行获取库存和锁定优惠券
CompletableFuture<StockResult> stockF =
CompletableFuture.supplyAsync(() -> stockService.checkStock(request.getProductId(), request.getQty()));
CompletableFuture<CouponResult> couponF =
CompletableFuture.supplyAsync(() -> couponService.lockCoupon(request.getUserId(), request.getCouponId()));
// 第二阶段:等库存和优惠券都就绪,计算价格并创建订单
CompletableFuture<Order> orderF = stockF.thenCombine(couponF, (stock, coupon) -> {
if (!stock.isSufficient()) throw new BizException("库存不足");
Price price = priceCalculator.calculate(request.getProductId(), request.getQty(), coupon);
// 扣库存这个动作要串行,因为涉及数据一致性
stockService.deductStock(request.getProductId(), request.getQty());
return orderService.create(request, price);
});
// 第三阶段:订单创建成功后,并行发通知和更新推荐
orderF.thenAcceptAsync(order -> {
smsService.send(order.getPhone(), "下单成功,预计30分钟送达");
pushService.notifyWarehouse(order);
});
orderF.thenAcceptAsync(order -> {
recommendService.updateUserProfile(order.getUserId());
});
return orderF.thenApply(OrderResult::from);
}
这个流程可以用一张图来理解:
┌─ checkStock ──┐
│ ├─ thenCombine ── createOrder ──┬─ notify
└─ lockCoupon ──┘ └─ updateRecommend
三个阶段的耗时从原来的「所有步骤加起来」变成了「每阶段内最慢的那个加起来」。对于一个有 6 个步骤的下单流程,如果每步平均 100ms,串行是 600ms,用这个编排方案大约能压缩到 300ms 以内。
异常处理:别让一个通知失败毁了整个订单
上面那段代码有个隐患:如果短信发送失败抛了异常,会发生什么?默认情况下,CompletableFuture 的异常会默默吞掉——你的订单创建成功了,但用户没收到通知,你自己也不知道。
orderF.thenAcceptAsync(order -> {
smsService.send(order.getPhone(), "下单成功");
}).exceptionally(ex -> {
log.error("发送通知失败, orderId={}", orderF.join().getId(), ex);
// 放入重试队列
retryQueue.add(order);
return null; // 不影响主流程
});
三个重要的异常处理方法:
| 方法 | 作用 | 使用场景 |
|---|---|---|
exceptionally | 发生异常时提供一个备选值 | 有兜底逻辑,比如缓存挂了走数据库 |
handle | 无论成功失败都回调,拿到结果或异常 | 需要做统一的清理或日志 |
whenComplete | 类似 finally,不改变结果 | 记录日志、释放资源 |
超时控制:不能让用户无限等
正常情况下一个订单 2 秒内处理完。但如果第三方服务(比如优惠券系统)卡住了,CompletableFuture 会一直等下去。
CompletableFuture<OrderResult> result = createOrder(request);
try {
return result.get(3, TimeUnit.SECONDS);
} catch (TimeoutException e) {
log.error("订单处理超时, orderId={}", request.getTraceId());
// 关键:超时不代表任务没在执行,要主动取消
result.cancel(true);
throw new BizException("系统繁忙,请稍后查看订单状态");
}
注意 cancel(true) 只是发中断信号,能不能真正中断取决于你的任务代码是否响应中断。如果你的 stockService.checkStock 里是个死循环或者阻塞 IO 不响应中断,cancel 也没有用。这就是另一个话题了——写异步代码时,你的每个任务方法都应该考虑「被中断了怎么办」。
5. 当一台机器不够用:分布式并发的第一课
日子过得不错,直到公司为了应对大促加了一台机器。
两台应用服务器,负载均衡轮询分发请求。上线第一天,库存又开始超卖了。
synchronized 在集群里失效
原因很简单:synchronized 和 ReentrantLock 都是 JVM 级别的锁。JVM A 里持有锁,JVM B 完全不知道。两个请求分别落到两台机器上,各自获取各自的锁,然后一起扣库存——跟没加锁一样。
要在多台机器之间协调「谁先谁后」,你需要一个所有机器都能访问到的协调者。这就是分布式锁。
Redis 分布式锁:最轻量的方案
public boolean deductStockWithLock(Long productId, int quantity) {
String lockKey = "lock:stock:" + productId;
String lockValue = UUID.randomUUID().toString();
// 尝试获取锁,过期时间 10 秒
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, Duration.ofSeconds(10));
if (!locked) {
throw new BizException("系统繁忙,请稍后重试");
}
try {
int currentStock = stockMapper.selectStock(productId);
if (currentStock >= quantity) {
stockMapper.updateStock(productId, currentStock - quantity);
return true;
}
return false;
} finally {
// 释放锁:只能删自己的锁
// 用 Lua 脚本保证原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) " +
"else return 0 end";
redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey), lockValue);
}
}
这段代码里藏着几个分布式锁的关键细节,每个都是线上血泪教训:
为什么用 setIfAbsent + 过期时间,而不是先 set 再 expire? 因为「先 set 再 expire」是两步操作。如果 set 成功之后、expire 之前程序崩溃了,这把锁永远不会释放。setIfAbsent + 过期时间是一个原子操作,要么同时成功,要么同时失败。
为什么 value 用 UUID? 防止误删别人的锁。假设线程 A 拿了锁,处理时间超过了 10 秒,锁自动过期了。线程 B 拿到了新锁。此时线程 A 处理完了,去 DEL lockKey——如果不校验 value,线程 A 删掉的是线程 B 的锁。用 UUID 作为 value,释放时判断「这把锁是不是我加的那把」。
为什么释放锁用 Lua 脚本? 「判断 value + 删除」也是两步操作。如果判断通过之后、删除之前,锁恰好过期了,又会误删。Lua 脚本在 Redis 里是原子执行的,两条命令之间不会插入其他操作。
分布式锁选型速查
| 方案 | 可靠性 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| Redis 单节点 | 一般 | 高 | 低 | 非关键业务,允许极小概率锁失效 |
| Redisson | 较高 | 高 | 中 | 大多数业务场景,封装了看门狗自动续期 |
| Redis Redlock | 高 | 中 | 高 | 关键业务,不能接受锁失效 |
| ZooKeeper | 最高 | 低 | 高 | 强一致性要求,如资金、库存扣减 |
对于我们的库存扣减场景,最终选的是 Redisson,因为它封装了「锁自动续期」这个关键能力——如果你的业务逻辑执行时间不确定,锁可能在业务完成前就过期。Redisson 的看门狗机制会每隔一段时间自动延长锁的过期时间。
6. 线上出了问题怎么排查:线程 Dump 实战
写多线程代码,写出 bug 是常态。重要的是出问题的时候你知道怎么查。
一个真实的死锁案例
上个月同事写了一段代码,导致订单处理线程池里的所有线程全部卡死:
// 线程 A:先锁优惠券,再锁库存
synchronized(couponLock) {
synchronized(stockLock) {
// 扣库存 + 用券
}
}
// 线程 B:先锁库存,再锁优惠券
synchronized(stockLock) {
synchronized(couponLock) {
// 先扣库存,再核销优惠券
}
}
线程 A 拿到了 couponLock,等 stockLock;线程 B 拿到了 stockLock,等 couponLock。两人互相等,谁都走不下去。
用 jstack 定位死锁
线上发现线程池卡死后,第一件事是 dump 线程栈:
jstack -l <pid> > thread_dump.txt
打开 dump 文件,jstack 会很贴心地直接告诉你死锁在哪:
Found one Java-level deadlock:
=============================
"order-processor-3":
waiting to lock monitor 0x00007f8a1c001000 (object 0x000000076b5c8e88, a StockLock),
which is held by "order-processor-7"
"order-processor-7":
waiting to lock monitor 0x00007f8a1c002000 (object 0x000000076b5c8e90, a CouponLock),
which is held by "order-processor-3"
Java stack information for the threads listed above:
===================================================
"order-processor-3":
at com.example.service.OrderService.processOrder(OrderService.java:42)
- waiting to lock <0x000000076b5c8e88>
- locked <0x000000076b5c8e90>
"order-processor-7":
at com.example.service.OrderService.processOrder(OrderService.java:56)
- waiting to lock <0x000000076b5c8e90>
- locked <0x000000076b5c8e88>
jstack 不仅告诉你有死锁、哪些线程参与,还把每个线程持有的锁(locked)和等待的锁(waiting to lock)列了出来。你直接就能定位到 OrderService.java:42 和 OrderService.java:56,看两行代码就知道问题在哪。
没有死锁但线程全在等——怎么查
更常见的情况是:没有死锁,但线程池里的线程全在 WAITING 状态,吞吐量几乎为零。这种时候看 dump 里的线程状态:
"order-processor-5" #47 daemon prio=5 os_prio=0 tid=0x00007f8a1c003800 nid=0x1c35 waiting on condition
java.lang.Thread.State: WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
- waiting to lock <0x000000076b5c9000>
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt
WAITING (parking) 说明线程在等锁。只是这次没有形成环,所以不算死锁。但从业务角度看效果一样——线程全卡住了。排查思路:
- 看谁持有那个锁(
waiting to lock <0x...>里的那个地址) - 在 dump 里搜索
locked <0x000000076b5c9000>,找到持有锁的线程 - 看那个线程在干什么——可能在等数据库响应、可能在 sleep、可能在等第三方回调
有一次我们就是通过这个链路找到了根因:持有锁的线程在调一个第三方物流接口,那个接口超时设了 120 秒,120 秒内所有其他线程都排着队干等。
预防死锁只有一条铁律:所有需要获取多把锁的地方,必须按固定顺序获取。如果线程 A 的顺序是(couponLock → stockLock),那线程 B 也必须是(couponLock → stockLock),不能反过来。
7. 从「会用」到「敢用」
回顾这一路:从最简单的 synchronized 到分布式锁,每个新方案都不是因为「这个技术更高级所以要用」,而是老方案解决不了新问题了。
synchronized 能解决单机并发 → 性能不够就拆锁粒度 → 读多写少就用 ReadWriteLock → IO 密集型任务用线程池复用 → 异步编排用 CompletableFuture → 多机部署上分布式锁。
这不是一个「技术选型表」,而是一个问题升级链。你不需要一上来就把所有东西都用上——你应该在遇到下一个瓶颈的时候,刚好知道有这么一个东西可以解决它。
实战 checklist
下次你需要在项目里写多线程代码,按这个顺序过一遍:
- 真的需要多线程吗? 很多场景用消息队列异步解耦更简单、更可靠。多线程是在同一个进程内的异步。
- 你的锁范围是不是最小了? 锁里面只放必须互斥的操作,能挪到锁外面的代码全部挪出去。
- 你的线程池有名字吗?给线程池设了合理的拒绝策略吗?
- CompletableFuture 编排时,有没有超时兜底和异常处理?
- 如果是分布式部署,你的同步机制跨 JVM 能生效吗?
下一步建议
如果你现在的项目里只有一个单体的 Spring Boot 应用,建议从「把同步调用改成 CompletableFuture 异步编排」开始动手。找一个调用链比较长的接口——比如下单、支付回调——画出它内部步骤的依赖关系,把没有依赖的步骤并行化。你会立刻看到响应时间的改善,而且改动风险可控。
多线程不是背出来的,是调出来的。你代码写完了,用 jstack 看看线程在干什么,用压测验证你的线程池参数是否合理,用监控观察你的锁竞争情况。这些才是面试官真正在意的东西——不是「你背没背过 AQS 源码」,而是「你有没有真正在生产环境里解决过多线程的问题」。
写于 2026 年 7 月 24 日