一、最初的构想:引入读写锁
- 在秒杀开始前,有成千上万的用户疯狂刷新商品详情页,他们只是想看看库存还剩下多少,即进行读操作。在当前的实现下,大量的读请求也必须排队等待获取ReentrantLock,严重影响了用户体验。这是一个典型的“读多写少”的场景,所以引入读写锁。
改写代码
重构SeckillService,将业务逻辑拆分出两个核心方法:
- checkStock(): 专门用于查询库存,使用读锁。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20/*
* 新增方法,用于查询商品库存,专门用于处理读请求
* 使用读写锁中的读锁,允许并发读取,互不堵塞
*/
public Integer checkStock(Long productId) {
log.info("线程 {} 尝试获取读锁...", Thread.currentThread().getName());
readLock.lock(); // 读操作上读锁
log.info("线程 {} 成功获取到读锁", Thread.currentThread().getName());
try {
Optional<Product> productOpt = productRepository.findById(productId);
if (!productOpt.isPresent()) {
throw new RuntimeException("商品不存在");
}
log.info("线程 {} 读取库存为: {}", Thread.currentThread().getName(), productOpt.get().getStock());
return productOpt.get().getStock();
} finally {
log.info("线程 {} 准备释放读锁.", Thread.currentThread().getName());
readLock.unlock();
}
}- processSeckill(): 负责执行秒杀下单,使用写锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29public String processSeckill(Long productId, Long userId) {
log.info("线程 {} 尝试获取写锁...", Thread.currentThread().getName());
writeLock.lock(); // 2. 秒杀操作上写锁,确保互斥
log.info("线程 {} 成功获取到写锁", Thread.currentThread().getName());
// 3. 定义事务
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
// 4. 开启事务
TransactionStatus status = transactionManager.getTransaction(def);
try {
// ...所有业务逻辑,和之前一样
// 5. 【关键】在锁释放前,手动提交事务
transactionManager.commit(status);
log.info("线程 {} 秒杀成功,提交事务。", Thread.currentThread().getName());
return "秒杀成功!订单创建中...";
} catch (Exception e) {
// 6. 如果发生任何异常,手动回滚事务
transactionManager.rollback(status);
log.error("线程 {} 秒杀失败: {}", Thread.currentThread().getName(), e.getMessage());
// 将异常信息返回或记录日志
return e.getMessage();
} finally {
// 7. 最后,释放锁
log.info("线程 {} 准备释放写锁.", Thread.currentThread().getName());
writeLock.unlock();
}
}
同时,我也在 Controller 层为查询库存新增了一个 GET 方式的 API 接口。
JMeter设置
- 线程组一:读请求
- 线程数: 500。
- Ramp-up: 1
- 循环次数: 10
- 在该线程组下,创建一个 HTTP 请求,指向读接口:
GET /seckill/stock/1
- 线程组二:写请求
- 线程数: 200
- Ramp-up: 1
- 循环次数: 1
- 在该线程组下,创建之前配置好的、带计数器的 HTTP 请求,指向写接口:
POST /seckill/1?userId=${uniqueUserId}
同时启动压测:在 JMeter 中,同时运行多个线程组。
压测结果与分析
- 结果:读请求均为库存为0;写请求正常,数据库显示,库存数为0,订单数新建100。

- 分析: 瞬时的大量写请求,在一个极端的时间窗口内将库存扣减完毕。读请求因为写锁被阻塞,等到它们能够被执行时,秒杀已经结束。
二、改进:并发测试场景设计
调整JMeter设置
由于200个/500个线程数太多,在日志中无法回看到最初的日志信息,所以修改线程数,便于观察。
“写请求”线程组:
- 线程数: 20
- Ramp-up Period: 从
1改成20。- 作用: 这意味着 JMeter 会在20秒内“缓慢地”启动这200个线程,大约每1秒启动一个。这给了读请求在两个写请求之间“插进来”的机会。
- 循环次数 (Loop Count): 1
“读请求”线程组:
- 线程数: 50
- Ramp-up Period: 也改成
20。 - 循环次数: 保持在
10。
【关键】为两个线程组设置“调度器”:
- 在两个线程组的配置界面下方,找到并勾选 “调度器”。
- 在“持续时间”中,输入
30。- 作用: 这会强制两个线程组都在运行30秒后自动停止。这能确保读写请求在同一个时间窗口内并发执行。
压测结果与分析
- 结果:读请求在日志中能够显示出库存数逐渐减少,写请求正常,且日志有如下模式,体现了读写锁“读共享、写独占”的理论:
一次写 -> 一大批读(读到的值都一样) -> 又一次写 -> 又一大批读(读到的新值都一样)…
学学八股
ReentrantReadWriteLock
是一个读写锁的实现,在内部维护了一对关联的锁:一个共享的读锁和一个独占的写锁。在”读多写少“的场景下,如果使用ReentrantLock这种普通互斥锁,会因为大量的饿读操作也必须串行执行而导致性能低下。读写锁则允许多个读线程并发访问,极大提升了这类场景下的系统吞吐量。
- 读锁:如果当前没有任何线程持有写锁,那么任意数量的线程都可以成功获取并持有读锁。即读-读共享。
- 写锁:只有在没有任何线程持有读锁或写锁的情况下,一个线程才有可能成功获取写锁。即写-写互斥,写-读互斥。
可能的问题
- “写饥饿”问题: 在非公平、高并发读的场景下,如果读请求源源不断,写线程可能很难有机会获取到写锁,因为它总能看到有线程持有读锁。这就是所谓的“写饥饿”。使用公平锁是缓解这个问题的一种方式。
- 性能并非总是更优:
ReentrantReadWriteLock的内部机制比ReentrantLock复杂得多,因此在读写竞争不明显或者并发度不高的情况下,它的开销可能会比简单的互斥锁更大。不要盲目使用,只有在明确的“读多写少”且存在性能瓶颈的场景下,它才是最佳选择。

