一、最初的构想:引入读写锁

  • 在秒杀开始前,有成千上万的用户疯狂刷新商品详情页,他们只是想看看库存还剩下多少,即进行读操作。在当前的实现下,大量的读请求也必须排队等待获取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
      29
      public 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 复杂得多,因此在读写竞争不明显或者并发度不高的情况下,它的开销可能会比简单的互斥锁更大。不要盲目使用,只有在明确的“读多写少”且存在性能瓶颈的场景下,它才是最佳选择。

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

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