饿了网后端避坑指南:面试必问的并发与锁机制,别再被StackTrace吓哭
面对满屏红色的 Java 异常堆栈,你是不是第一反应就是懵?别慌,很多老手当年也在这栽过跟头。特别是处理【饿了网】这类高并发外卖业务时,代码跑得好好的,一上量就崩,报错信息看得人头大。这不仅仅是代码写错了,更是对底层原理理解不到位。今天我们就把这层窗户纸捅破,结合【面试必问】的实战场景,把那些让你半夜爬起来修 Bug 的坑,一个个填平。
现象:看似无害的报错,实则是线程安全的“定时炸弹”
很多新手在调试【饿了网】类似的订单系统时,经常遇到一种诡异的现象:本地跑测试用例全绿,一旦放到预发布环境或者稍微加点压力,控制台就疯狂打印 ConcurrentModificationException 或者 IllegalMonitorStateException。这时候你可能觉得是随机 Bug,重启服务就好了。
错!大错特错。
这种“时好时坏”的报错,往往指向多线程环境下的数据竞争。比如在【饿了网】的点餐场景里,两个用户同时抢购同一份限量优惠券,如果没有处理好锁的粒度,或者在持有锁的过程中抛出了异常导致锁没有释放,后续请求就会全部阻塞,甚至出现死锁。
很多开发同学看到 StackTrace 里的 at com.eweiqiu.service.CouponService.lock(CouponService.java:45) 就傻眼了,不知道这个锁是怎么加的,也不知道为什么没释放。其实,90% 的并发 Bug,根源都在于对 synchronized 或 ReentrantLock 的生命周期管理混乱。
根因:为什么你的锁会“失效”或“卡死”?
要解决【饿了网】这类高并发场景下的锁问题,必须先搞懂 Java 中锁的两种主流实现:内置锁(synchronized)和显式锁(ReentrantLock)。
1. synchronized 的隐式释放陷阱
synchronized 是 JVM 层面的关键字,它的锁释放是自动的。只要代码块执行结束,无论是否正常结束,锁都会自动释放。听起来很完美,对吧?但问题出在可重入性和范围控制上。
很多新手喜欢把整个 Service 方法都用 synchronized 包起来。比如:
public synchronized void deductStock(String skuId, int count) {// 查询库存Stock stock = stockDao.selectBySkuId(skuId);if (stock.getCount() < count) {throw new BusinessException("库存不足");}// 这里如果抛异常,锁会释放,但事务回滚了吗?// 如果这里耗时很长,其他线程全被挡在门外stockDao.updateCount(skuId, stock.getCount() - count);
}
在【饿了网】这种业务中,查询数据库、校验库存、更新库存,这三步如果都在锁内,性能会极差。更糟糕的是,如果 updateCount 抛出异常,虽然锁释放了,但如果你的事务注解配置不当,可能导致数据不一致。
2. ReentrantLock 的手动释放风险
相比之下,ReentrantLock 提供了更细粒度的控制,比如公平锁、可中断锁、尝试获取锁等。但它的代价是:必须手动释放。
如果在 try 块中抛出未捕获的异常,而你又忘了在 finally 块中释放锁,那么这个锁就永远被占用了。后续所有试图获取该锁的线程都会一直等待,最终导致服务假死。这就是为什么很多 StackTrace 最后都指向 LockSupport.park 或者线程状态为 WAITING 的原因。
根据 Oracle 官方源码仓库(OpenJDK)中 java.util.concurrent.locks.AbstractQueuedSynchronizer 的实现,锁的状态是通过一个 volatile int state 来维护的。一旦 state 被置为 1 且没有对应的 unlock 操作,AQS(AbstractQueuedSynchronizer)队列中的其他线程就会一直处于阻塞状态。
对比:错误写法 vs 正确写法
为了让你直观感受到区别,我们来看两段针对【饿了网】库存扣减的代码。
❌ 错误写法:粗粒度锁 + 忘记释放
// 错误示例:性能差,且有死锁风险
public class BadStockService {private final ReentrantLock lock = new ReentrantLock();public void deductStock(String skuId, int count) {lock.lock(); // 获取锁try {// 1. 查询库存(IO操作,耗时)Stock stock = stockDao.selectBySkuId(skuId);// 2. 业务校验if (stock.getCount() < count) {// 这里如果抛异常,虽然会进入finally,但如果逻辑复杂,容易遗漏throw new BusinessException("库存不足");}// 3. 更新库存(IO操作,耗时)stockDao.updateCount(skuId, stock.getCount() - count);} catch (Exception e) {log.error("扣减库存失败", e);// 注意:这里没有处理 lock.unlock(),虽然finally会处理,// 但如果逻辑嵌套过深,极易出错} finally {lock.unlock(); // 必须手动释放}}
}
问题分析:
- 锁粒度太大:查询和更新都在锁内,导致并发度极低。在【饿了网】高峰期,所有请求都在排队查库存,数据库连接池瞬间耗尽。
- 缺乏乐观锁机制:直接更新,没有考虑并发下的数据覆盖问题。
✅ 正确写法:细粒度锁 + 乐观锁 + 防御性编程
// 正确示例:利用数据库乐观锁 + 细粒度同步
public class GoodStockService {// 不再使用全局大锁,而是依赖数据库的乐观锁机制// 如果必须加锁,只锁住临界区极小的部分public void deductStock(String skuId, int count) {// 1. 先查询,不带锁Stock stock = stockDao.selectBySkuId(skuId);if (stock == null || stock.getCount() < count) {throw new BusinessException("库存不足");}// 2. 执行更新,使用 CAS (Compare And Swap) 思想// 只有当数据库中的 version 或 count 与我们查询时的一致时才更新int affectedRows = stockDao.updateCountWithVersion(skuId, count, stock.getVersion() // 传入当前版本号);// 3. 检查更新结果if (affectedRows == 0) {// 说明被别人抢先扣减了,或者库存真的不够了// 可以选择重试,或者直接抛出异常throw new BusinessException("扣减失败,请重试");}}
}
优势分析:
- 无锁化查询:查询操作不加锁,极大地提升了并发吞吐量。
- 数据库层保证一致性:通过
UPDATE stock SET count = count - #{count}, version = version + 1 WHERE sku_id = #{skuId} AND count >= #{count} AND version = #{version}这样的 SQL,原子性地完成了检查和更新。 - 避免 JVM 层面的锁竞争:将并发控制的压力转移给了数据库,这在【饿了网】这种分布式系统中是更合理的做法。
复现与修复:如何验证你的代码是否“抗造”
光看代码没用,得跑起来才知道有没有坑。我们可以用 JMeter 或简单的多线程测试来模拟【饿了网】的抢购场景。
1. 复现死锁场景
写一个简单的测试类,启动两个线程,同时调用 deductStock,每次扣减 1 件,总共 100 件。
@Test
public void testConcurrentDeduct() throws InterruptedException {int total = 100;int threadCount = 10;ExecutorService executor = Executors.newFixedThreadPool(threadCount);CountDownLatch latch = new CountDownLatch(threadCount);for (int i = 0; i < threadCount; i++) {final int finalI = i;executor.submit(() -> {try {// 每个线程尝试扣减 1 件goodStockService.deductStock("SKU_001", 1);} catch (Exception e) {// 记录失败情况} finally {latch.countDown();}});}latch.await();executor.shutdown();// 检查最终库存Stock finalStock = stockDao.selectBySkuId("SKU_001");assertEquals(0, finalStock.getCount()); // 期望值为0
}
如果使用之前的错误写法,你会发现要么库存扣多了(超卖),要么线程卡死导致测试超时。使用正确写法后,库存会精准地扣减到 0,且所有线程都能正常退出。
2. 监控锁竞争
在生产环境中,你需要通过 JMX 或 Arthas 监控锁的持有时间。如果发现某个锁的持有时间超过 100ms,那你的代码肯定有问题。对于【饿了网】这类毫秒级响应的系统,锁的持有时间必须在微秒级。
规避建议:面试必问的三大核心原则
在准备【面试必问】的技术点时,除了记住代码,更要理解背后的设计思想。以下是三条黄金法则:
1. 能不加锁就不加锁
优先考虑无锁数据结构(如 ConcurrentHashMap)、乐观锁(CAS)、或者数据库层面的约束。锁是最后的防线,不是第一选择。在【饿了网】的优惠券发放中,我们甚至采用了 Redis 的 decr 命令,在缓存层就拦截了大部分无效请求,数据库根本不需要加锁。
2. 锁的粒度要小,范围要短
如果必须加锁,只锁住修改共享数据的那几行代码。不要锁住整个方法,不要锁住 IO 操作。就像前面代码对比中展示的那样,查询和更新可以分离,只更新时需要保证原子性。
3. 永远在 finally 中释放锁
这是铁律。使用 try-with-resources(如果锁实现了 AutoCloseable)或者显式的 finally 块。对于 ReentrantLock,务必养成 try { lock.lock(); ... } finally { lock.unlock(); } 的肌肉记忆。
此外,还要注意锁的公平性。默认是非公平锁,性能更高,但在极端高并发下可能导致线程饥饿。如果业务对公平性要求高(如排队叫号),可以显式指定 new ReentrantLock(true)。
结尾互动
并发编程是 Java 后端开发的深水区,也是【面试必问】的高频考点。很多候选人背得滚瓜烂熟,但一遇到实际的 StackTrace 就手足无措。
这个知识点你面试被问过吗?或者你在项目中踩过类似的并发坑?留言说说你的经历,我们一起避坑!