
面试必问:搞懂不及卢家有莫愁,项目落地不再卡壳
看了一堆教程还是不会写项目?这种痛苦我太懂了。很多开发者在 CSDN 上收藏了上百篇 Java 并发或者 Python 异步的文章,但真到了公司里,面对高并发场景下的数据一致性,还是得抓瞎。今天我们要聊的“不及卢家有莫愁”,其实是个非常形象的比喻,用来形容你在高并发环境下,因为没处理好资源竞争,导致业务逻辑错乱,最后还得去“补漏”的尴尬处境。
这在面试必问的并发编程题目里,是个高频考点。面试官不会直接问你这个诗,但会问你:“怎么防止库存超卖?”或者“怎么保证分布式锁的可靠性?”如果你只能背出 Redis setnx 或者 Zookeeper 节点创建,那还是不够。你得懂背后的原理,知道为什么会出现“卢家”抢到了资源,而你“不及”的情况。
一句话原理:资源独占与时间窗口
底层原理其实很简单:在多线程或多进程环境下,对共享资源的访问必须互斥,否则会出现“竞态条件”(Race Condition)。
想象一下,两个线程 A 和 B 同时想要修改同一个变量 count。
A 读取 count (值为 0)。
B 读取 count (值为 0)。
A 计算 count + 1,准备写入 1。
B 计算 count + 1,准备写入 1。
A 写入 1。
B 写入 1。
结果:count 应该是 2,但实际是 1。这就叫“不及”,你本来应该拿到第 2 个名额,结果因为 B 的动作,你“不及卢家”(没抢到或抢错了),最后数据错了。
类比解释:抢票与排号
为了讲透这个原理,我们用个更接地气的类比:抢火车票。
假设 12306 系统里,某张票只剩 1 张。
场景一(无锁,裸奔):用户甲和用户乙同时点击“购买”。系统同时查询库存,都看到“有票”。甲下单成功,乙也下单成功。结果:超卖了。用户乙拿到票,但实际没票,这就引发了投诉,这就是“不及卢家有莫愁”——你本来想安安静静买票,结果系统搞出乱子,让你很愁。
场景二(悲观锁,排队):系统给这张票加把锁。甲来买,先拿锁,锁定库存。乙来买,发现锁被占,只能等待。甲买完,释放锁。乙再进来,发现库存没了,提示“已售罄”。虽然乙慢了,但数据是对的。
场景三(乐观锁,CAS):系统不加锁,但给每张票加个版本号。甲来买,发现版本号是 V1,直接改库存,版本号变 V2。乙来买,发现版本号是 V2(因为甲改过),乙的修改会被拒绝,提示“重试”。乙重试一次,发现没票了,结束。
在编程里,悲观锁就像 synchronized 或 ReentrantLock,乐观锁就像 CAS(Compare And Swap)或数据库的 version 字段。
源码片段:Java 中的 CAS 实现
光说不练假把式,我们来看一段 Java 代码,看看 JDK 里是怎么用 CAS 实现原子变量的。
import java.util.concurrent.atomic.AtomicInteger;
public class CASExample {
private static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) {
// 模拟两个线程同时增加 count
Thread t1 = new Thread(() - {
for (int i = 0; i 10000; i++) {
count.incrementAndGet();
}
});
Thread t2 = new Thread(() - {
for (int i = 0; i 10000; i++) {
count.incrementAndGet();
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Final Count: + count.get());
// 输出应该是 20000,而不是一个随机的小数字
}
}
逐行讲解:
AtomicInteger:这是 JDK 提供的原子类,底层依赖 Unsafe 类的 compareAndSwapInt 方法。
incrementAndGet():这个方法内部是一个循环。它先读取当前值,加 1,然后尝试用 CAS 操作更新。如果更新失败(说明别的线程改了),它就重新读取,再加 1,再尝试。这个过程会一直循环,直到成功为止。
关键点:虽然 incrementAndGet 是非阻塞的(没有挂起线程),但它可能会自旋多次,消耗 CPU 资源。在高竞争场景下,CAS 的性能可能不如锁,因为自旋会浪费 CPU。
这就是“不及”的微观体现:你的线程可能因为 CAS 失败而反复重试,虽然最终成功了,但过程很“愁”。
流程描述:从请求到落地的完整链路
为了让你在面试时能完整描述,我们梳理一个典型的高并发库存扣减流程。
1. 请求接入
用户点击“立即购买”,请求到达 Nginx,再转发到 Tomcat 线程池。
2. 前置校验
在业务逻辑层,先检查用户权限、库存是否存在。这一步可以用 Redis 做缓存查询,减少数据库压力。
避坑点:不要在 Redis 里直接扣减库存,因为 Redis 是单线程的,但网络抖动可能导致重复请求。
3. 获取锁
进入核心逻辑,必须加锁。
方案 A(本地锁):如果服务是单机部署,可以用 synchronized 或 ReentrantLock。
方案 B(分布式锁):如果是集群部署,必须用 Redis 或 Zookeeper。
Redis 实现:SET key value NX PX 30000。
注意:一定要设置过期时间,防止死锁。还要保证 value 是唯一的(如 UUID),防止误删别人的锁。
4. 执行业务
拿到锁后,去数据库查询并更新库存。
SQL:UPDATE stock SET count = count - 1 WHERE id = 1 AND count 0;
关键点:count 0 是双重保险,防止超卖。
5. 释放锁
业务处理完,释放锁。
注意:释放锁前,要检查 value 是否还是自己设置的,防止锁过期后,误删了其他线程的锁。
6. 异步通知
库存扣减成功后,通过 MQ(如 Kafka)发送消息,通知订单服务创建订单。
流程图(文字版):
[用户请求] - [Nginx] - [Tomcat线程] - [Redis查询库存] - [获取分布式锁] - [DB更新库存] - [释放锁] - [MQ发送消息] - [订单服务消费]
实战验证:如何在项目中落地?
光懂原理不够,得能在项目里跑通。这里分享一个我在实际项目中遇到的“不及”案例,以及解决方案。
案例:秒杀活动库存超卖
某电商大促,1 万件商品,10 万人抢。初版代码用了 synchronized,结果服务器 CPU 100%,接口响应慢如蜗牛。为什么?因为 synchronized 是互斥锁,线程会阻塞,上下文切换开销大。
解决方案:Redis + Lua 脚本
我们把库存预热到 Redis,并用 Lua 脚本保证原子性。
Lua 脚本示例:
local key = KEYS[1]
local count = tonumber(ARGV[1])
local stock = tonumber(redis.call('get', key))
if stock 0 then
-- 扣减库存
redis.call('decr', key)
return 1 -- 扣减成功
else
return 0 -- 库存不足
end
Java 调用代码:
String script = local key = KEYS[1] +
local count = tonumber(ARGV[1]) +
local stock = tonumber(redis.call('get', key)) +
if stock 0 then +
redis.call('decr', key) +
return 1 +
else +
return 0 +
end;
ListObject result = redisTemplate.execute(
new DefaultRedisScript(script, Long.class),
Collections.singletonList(stock:1001),
1L
);
if (result.get(0) == 1L) {
// 扣减成功,异步处理订单
orderService.createOrderAsync(userId, productId);
} else {
// 扣减失败,提示已售罄
throw new BusinessException(商品已售罄);
}
优势:
原子性:Lua 脚本在 Redis 中是原子执行的,不会有竞态条件。
高性能:Redis 是内存操作,速度极快,避免了数据库锁的开销。
解耦:库存扣减和订单创建解耦,通过 MQ 异步处理,提高了系统吞吐量。
避坑指南
锁粒度:尽量缩小锁的范围。不要锁整个方法,只锁核心代码块。
锁超时:分布式锁一定要设置超时时间,并考虑续期问题(如 Redisson 看门狗)。
幂等性:由于网络抖动,请求可能重复。在业务层要做幂等处理,比如用订单号作为唯一键,防止重复创建订单。
降级策略:如果 Redis 挂了,要有降级方案,比如直接查数据库,或者返回“系统繁忙,请稍后再试”。
进阶技巧:从“不及”到“从容”
讲到这里,你可能觉得并发编程很复杂。其实,核心就两点:互斥和原子性。
互斥:同一时间,只有一个线程能访问临界区。用锁实现。
原子性:操作要么全做,要么全不做。用 CAS 或事务实现。
在面试中,如果你能清晰地说出:“我通过 Redis 分布式锁 + Lua 脚本保证库存扣减的原子性,再通过 MQ 异步处理订单,避免了数据库锁的性能瓶颈,同时通过幂等性设计防止重复下单。” 面试官会对你刮目相看。
这就是从“不及卢家有莫愁”到“从容应对”的过程。你不再是那个被竞态条件困扰的新手,而是一个能设计高并发系统的工程师。
结尾互动
技术不是背出来的,是踩坑踩出来的。我在文中提到的 Redis 锁和 Lua 脚本,在实际项目中可能会有各种意想不到的坑,比如 Redis 集群下的一致性、Lua 脚本的执行超时等。
你公司项目里是怎么处理高并发库存扣减的?是用 Redis 还是 Zookeeper?有没有遇到过锁误删或者死锁的问题?欢迎在评论区分享你的实战经验,咱们一起避坑!