用户体验:引入异步处理
- 在V1.1和V1.2,通过读写锁和信号量,构建了一个数据相对正确,流量可控的秒杀系统。尽管后端相对稳定,但用户体验糟糕,同步阻塞的模式,意味着用户必须在浏览器前“转圈圈”,等待后端的耗时操作。
- 在V1.3中,实现异步化,将用户请求与后端耗时任务解耦,实现用户的及时响应。
代码改写
- 线程池,创建
ThreadPoolConfig.java1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.*;
public class ThreadPoolConfig {
public ExecutorService seckillExecutorService() {
// 手动实现一个简单的 ThreadFactory
ThreadFactory namedThreadFactory = r -> new Thread("seckill-thread-" + r.hashCode());
// 创建线程池
ExecutorService pool = new ThreadPoolExecutor(
10, // corePoolSize: 核心线程数,即长期保持的线程数
20, // maximumPoolSize: 最大线程数
60L, // keepAliveTime: 空闲线程的存活时间
TimeUnit.SECONDS, // 时间单位
new LinkedBlockingQueue<>(100), // workQueue: 任务队列,容量为100
namedThreadFactory, // threadFactory: 线程工厂,用于给线程命名
new ThreadPoolExecutor.AbortPolicy() // rejectedExecutionHandler: 拒绝策略
);
return pool;
}
} - 重构SeckillService,分离提交与执行
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class SeckillService {
private ExecutorService seckillExecutorService; // 1. 注入我们创建的线程池
// ... 其他属性保持不变 ...
/**
* 新的入口方法:负责接收请求并提交到线程池
* 这个方法会【立刻】返回,不会等待后台线程执行完毕
*/
public String submitSeckillOrder(Long productId, Long userId) {
// 2. 创建一个任务(Runnable)
Runnable task = () -> {
// 在这个任务中,调用我们之前那个耗时的、带锁的真实秒杀逻辑
executeSeckill(productId, userId);
};
// 3. 将任务提交给线程池
seckillExecutorService.submit(task);
return "请求已接收,正在排队处理中,请稍后查看订单状态。";
}
/**
* 真实的秒杀执行逻辑,现在是一个私有方法
* 它会被后台线程池中的线程调用
* @param productId
* @param userId
*/
private void executeSeckill(Long productId, Long userId) {
boolean acquired = false;
try {
acquired = semaphore.tryAcquire(3, TimeUnit.SECONDS);
if (!acquired) {
log.warn("线程 {} 获取信号量许可超时", Thread.currentThread().getName());
return; // 获取不到许可,直接结束任务
}
// ... 省略了之前完整的、带 writeLock 和手动事务的业务逻辑 ...
// 注意:因为这个方法现在没有返回值了,你需要通过日志来记录成功或失败
// 比如在 commit 后 log.info("订单创建成功...")
// 在 rollback 后 log.error("订单创建失败...")
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("线程 {} 被中断", Thread.currentThread().getName());
} finally {
if (acquired) {
semaphore.release();
}
}
}
} - 更新 SeckillController,调用新的“提交”方法。
压测结果与分析
- JMeter设置与之前的版本相同,只执行写请求。
- 结果:有120个线程成功请求到了线程池,80个失败请求。没有任何的库存减少和新订单的建立,并且在日志中没有任何关于申请信号量许可的信息。
- 分析:
- 120个成功请求和80个失败请求:系统能够容纳的瞬时任务上限是20(正在执行)+100(排队等待)=120个,而剩下的80个失败请求对应的是因为线程池已满而被拒绝。
- 没有库存减少和新订单的建立:120个被线程池接收的任务,没有一个成功完成数据库操作。
- 日志中没有见到任何申请信号量许可的信息:甚至有可能没有进入
executeSeckill()方法。类似于“当第200个请求被拒绝后,所有200个请求都结束了”
- 原因:“守护线程”的提前退场
- 用户线程:通常创建的、执行核心任务的“前台线程”。JVM规则:只要还有一个用户线程没有执行完毕,JVM进程就必须等待,不能退出。
- 守护线程:特殊的“后台线程”,为其他线程服务。JVM规则:当程序中只剩下守护线程在运行时,JVM会认为所有核心工作已经全部完成,于是会退出并且终止所有仍在运行的守护线程。
- 由于某种原因,在seckillExecutorService中创建的工作线程,被设置为了守护线程。
改进
在创建线程时,明确设置为“用户线程”。
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
public class ThreadPoolConfig {
public ExecutorService seckillExecutorService() {
ThreadFactory namedThreadFactory = r -> {
Thread t = new Thread(r);
// 【关键改动】将线程设置为非守护线程
t.setDaemon(false);
t.setName("seckill-thread-" + t.hashCode());
return t;
};
// 创建线程池的其余代码保持不变
ExecutorService pool = new ThreadPoolExecutor(
10,
20,
60L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100),
namedThreadFactory,
new ThreadPoolExecutor.AbortPolicy()
);
return pool;
}
}在手动创建 Thread 对象后,调用了 t.setDaemon(false);。false 表示它是一个用户线程(非守护线程),这是 Java 线程的默认行为,但在这里我们明确地指定它,以覆盖任何可能的默认设置,确保万无一失。
结果:库存减少至0,新增订单数100,数据正常。日志显示正常。
单体应用的性能优化
数据库原子化更新、引入内存售罄标记
使用“原子化SQL更新”替代Java锁,将检查库存和扣减库存合并为同一条SQL语句,并且引入内存售罄标记。数据库原子化更新利用了数据库的行锁来保证原子性,性能远高于在Java应用层加锁。引入内存售罄标记,直接拒绝已知的无效流量,保护了后端服务。
修改 ProductRepository.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface ProductRepository extends JpaRepository<Product, Long> {
/**
* 【新增】原子化扣减库存的方法
* 使用 @Modifying 注解来告诉 Spring Data JPA 这是一个“修改”操作
* 使用 @Query 注解来定义我们的 JPQL 语句
* WHERE 子句中的 "p.stock > 0" 是关键,它在数据库层面保证了不会超卖
* @param productId 商品ID
* @return 返回受影响的行数,如果 > 0 表示更新成功,= 0 表示库存不足或商品不存在
*/
int deductStock( Long productId);
}重构 SeckillService.java 的核心逻辑
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
public class SeckillService {
// ... 其他属性 ...
// 【移除】不再需要写锁了!
// private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(true);
// private final Lock writeLock = rwLock.writeLock();
// ...
// 【新增】内存售罄标记
private volatile boolean isSoldOut = false;
public String submitSeckillOrder(Long productId, Long userId) {
// 【优化】在所有逻辑之前,先检查内存标记
if (isSoldOut) {
return "商品已售罄(内存标记拦截)";
}
// ... 之前的提交到线程池的逻辑保持不变 ...
// ...
}
private void executeSeckill(Long productId, Long userId) {
// ... Semaphore 的获取和释放逻辑保持不变 ...
boolean acquired = false;
try {
acquired = semaphore.tryAcquire(3, TimeUnit.SECONDS);
if (!acquired) {
// ...
return;
}
// 【移除】不再需要 Thread.sleep()
// Thread.sleep(1000);
// 【重构】调用新的、无锁的数据库操作方法
executeDbOperationsWithoutLock(productId, userId);
} catch (InterruptedException e) {
// ...
} finally {
if (acquired) {
semaphore.release();
}
}
}
// 【重构】创建一个新的、无锁的数据库操作方法
private void executeDbOperationsWithoutLock(Long productId, Long userId) {
// 【移除】不再需要 writeLock.lock()
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
TransactionStatus status = transactionManager.getTransaction(def);
try {
// 前置检查(重复下单等)依然可以保留
if (orderRepository.findByUserIdAndProductId(userId, productId) != null) {
throw new RuntimeException("您已秒杀过此商品,请勿重复下单");
}
// 1. 【核心改动】直接调用原子更新方法扣减库存
int result = productRepository.deductStock(productId);
// 2. 检查结果
if (result == 0) {
// 如果更新行数为0,说明库存不足
isSoldOut = true; // 【优化】设置内存售罄标记
throw new RuntimeException("商品已售罄");
}
// 3. 如果扣减成功,才创建订单...
transactionManager.commit(status);
log.info("线程 {} 秒杀成功,提交事务。", Thread.currentThread().getName());
} catch (Exception e) {
transactionManager.rollback(status);
log.error("线程 {} 秒杀失败,回滚事务: {}", Thread.currentThread().getName(), e.getMessage());
}
// 【移除】不再需要 finally { writeLock.unlock() }
}
}
集成Spring Boot Actuator
修改 pom.xml 文件
在
标签内,添加 Actuator 的 starter 依赖: 1
2
3
4<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>修改 src/main/resources/application.properties 文件
添加以下配置,来暴露所有的监控端点 (endpoints) 以供访问:
1 | # Spring Boot Actuator 配置 |
学学八股
ThreadPoolExecutor 线程池
- 核心作用
- 降低资源消耗:通过复用已创建的线程,避免了频繁创建和销毁线程的巨大开销。
- 提高响应速度:任务到达时,可以直接使用池中的线程执行,省去了创建线程的时间。
- 提高客观理性:可以对线程进行统一的分配、监控和调优,防止无限制的创建线程耗尽系统资源。
- 核心参数
- corePoolSize :核心线程数,线程池长期维持的线程数量,即使它们处于空闲状态。
- maximumPoolSize :最大线程数,线程池能够容纳的最大线程数量。当任务队列满了,且当前线程数小于最大线程数时,才会创建新线程。
- KeepAliveTime:空闲线程存活时间,当线程池中的数量大于corePoolSize时,多余的空闲线程在等待新任务时能够存活的最长时间。
- unit:时间单位
- workQueue:任务队列,用于存放等待执行的任务的阻塞队列。
- threadFactory: 线程工厂,用于创建新线程的工厂。
- rejectedExecutionHandler:拒绝策略,当任务队列和线程池都满了,新任务到来时所采取的策略。
原子化SQL更新
- 核心思想:放弃在 Java 应用层使用锁(如 ReentrantLock)来保证“读-改-写”的原子性,而是将这个职责下推到数据库层面。
- 底层原理:数据库的 InnoDB 引擎 会对符合 WHERE 条件的行加上行锁 (Row Lock),从而天然地保证了该操作的原子性。
Spring Boot Actuator
- 核心作用:通过一系列的HTTP端点,暴露应用的内部运行情况。
- Actuator 是构建可观测性系统的第一步。通过它暴露的 metrics 端点,可以与 Prometheus (数据采集) 和 Grafana (数据可视化) 等工具链集成,搭建出专业的监控仪表盘,实时监控 JVM 状态、数据库连接池、线程池活跃度等关键指标。