用户体验:引入异步处理

  • 在V1.1和V1.2,通过读写锁和信号量,构建了一个数据相对正确,流量可控的秒杀系统。尽管后端相对稳定,但用户体验糟糕,同步阻塞的模式,意味着用户必须在浏览器前“转圈圈”,等待后端的耗时操作。
  • 在V1.3中,实现异步化,将用户请求与后端耗时任务解耦,实现用户的及时响应。

代码改写

  • 线程池,创建ThreadPoolConfig.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
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import java.util.concurrent.*;

    @Configuration
    public class ThreadPoolConfig {

    @Bean
    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
    @Service
    public class SeckillService {

    @Autowired
    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
    @Configuration
    public class ThreadPoolConfig {

    @Bean
    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
    @Repository
    public interface ProductRepository extends JpaRepository<Product, Long> {

    /**
    * 【新增】原子化扣减库存的方法
    * 使用 @Modifying 注解来告诉 Spring Data JPA 这是一个“修改”操作
    * 使用 @Query 注解来定义我们的 JPQL 语句
    * WHERE 子句中的 "p.stock > 0" 是关键,它在数据库层面保证了不会超卖
    * @param productId 商品ID
    * @return 返回受影响的行数,如果 > 0 表示更新成功,= 0 表示库存不足或商品不存在
    */
    @Modifying
    @Query("UPDATE Product p SET p.stock = p.stock - 1 WHERE p.id = :productId AND p.stock > 0")
    int deductStock(@Param("productId") 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
    @Service
    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
2
3
4
5
# Spring Boot Actuator 配置
# 暴露所有 Web 端点(在生产环境中应按需暴露,* 是为了开发方便)
management.endpoints.web.exposure.include=*
# (可选) 让 health 端点总是显示详细信息
management.endpoint.health.show-details=always

学学八股

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 状态、数据库连接池、线程池活跃度等关键指标。

本站由 Xylumina 使用 Stellar 1.30.0 主题创建。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。

本"页面"访问 次 | 总访问 次 | 总访客