一、项目概述

  • 项目名称:高并发-秒杀系统(1.0 单体应用原型)
  • 项目目标:从零开始设计并实现一个功能完备的秒-杀业务原型,旨在深入理解高并发场景下常见的技术挑战,如数据一致性(超卖、重复下单)、性能瓶颈等,并通过多种技术手段进行分析和优化。
  • 技术栈:
    • 后端框架:Spring Boot
    • 数据持久层:Spring Data JPA,Hibernate
    • 数据库:MYSQL
    • 构建工具:Maven
    • 测试工具:JMeter

二、项目搭建

1. 环境搭建和初始化

  • 使用 Spring Initializr 快速搭建了项目骨架,并集成 Spring Web、Spring Data JPA、MySQL Driver 等核心依赖。

    Spring Web:是构建Web应用程序的核心模块。内嵌Web服务器,提供Spring MVC框架,用于HTTP报文处理能力。负责监听网络接口,接受所有外来的HTTP请求,然后根据请求的URL和方法,精准的转接给后台相应的Controller去处理。
    Spring Data JPA:是一个用来极大简化数据库访问的框架。可以自动化SQL,进行对象-关系映射(ORM),并进行简化的自定义查询。

  • 在 application.properties 中完成了数据库连接池的基础配置。

2. 核心业务

  • 设计核心数据表
    • 商品表
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      CREATE TABLE `product` (
      `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '商品ID',
      `name` VARCHAR(100) NOT NULL COMMENT '商品名称',
      `title` VARCHAR(255) NOT NULL COMMENT '商品标题',
      `image` VARCHAR(255) DEFAULT '' COMMENT '商品图片URL',
      `price` DECIMAL(10, 2) NOT NULL COMMENT '秒杀价格',
      `stock` INT NOT NULL COMMENT '库存数量',
      `start_time` DATETIME NOT NULL COMMENT '秒杀开始时间',
      `end_time` DATETIME NOT NULL COMMENT '秒杀结束时间',
      PRIMARY KEY (`id`)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
    • 订单表
      在订单表的设计中加入了 (user_id, product_id)的唯一索引,即每个用户只能下一单。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      CREATE TABLE `seckill_order` (
      `id` BIGINT NOT NULL AUTO_INCREMENT,
      `user_id` BIGINT NOT NULL COMMENT '用户ID',
      `product_id` BIGINT NOT NULL COMMENT '商品ID',
      `order_price` DECIMAL(10, 2) NOT NULL COMMENT '订单成交价格',
      `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
      PRIMARY KEY (`id`),
      UNIQUE KEY `idx_user_product` (`user_id`, `product_id`) COMMENT '唯一索引,防止同一用户重复秒杀同一商品'
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

3. 基础功能实现

  • 采用标准的MVC分层架构,创建Controller, Service, Repository 层。

    Controller:作为应用的入口,直接处理外部的HTTP请求。
    Service:实现应用的核心业务逻辑。
    DAO:负责与数据库进行直接交互,完成数据的持久化操作。
    Model/Entity:数据的载体,定义了应用中的核心领域对象。

请求过程
请求过程
  • 实现秒杀接口的核心业务逻辑。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Transactional
public String processSeckill(Long productId, Long userId) {
// 1. 从数据库读取 Product 对象到内存
Optional<Product> productOpt = productRepository.findById(productId);
Product product = productOpt.get(); // 假设此时读到的 stock 是 100

// ... 其他检查 ...

// 2. 在内存中计算新库存
product.setStock(product.getStock() - 1); // 内存中的 stock 变为 99

// 3. 将内存中的 Product 对象保存回数据库
productRepository.save(product);

// 4. 创建订单
SeckillOrder order = new SeckillOrder();
// ...
orderRepository.save(order);

return "秒杀成功!";
}
  • 在初始版本中不加入任何的并发控制,后续会通过压力测试来暴露和分析最原始的并发问题。

三、并发问题的分析与演进

1. 库存设置与JMeter设置

  • stock设置为100
  • JMeter
    • 线程数:200
    • 1秒内同时发出200个并发请求
    • 计数器自增UserId

2. 实验结果

  • 订单数增加了200个但是库存数只减少了20个

    订单数
    订单数
    库存数
    库存数
  • 分析原因:事务和内存状态————Spring的@Transaction注解和JPA的工作机制

    • 一级缓存:当第一个请求通过findById加载了ID为1的商品后,这个product对象会被放入当前事务的一级缓存中。
    • 事务提交时才真正更新:productRepository.save(product)这个操作,并不是立即向数据库发送UPDATE语句,而要等到整个processSeckill方法执行完毕、事务准备提交时,才会生成并发送给数据库。
  • 丢失的更新(stock=80):前十个线程有可能都加载到了还没有提交的product,即此时读到的stock依然是100,后面的九次更新覆盖了第一次更新,所以最终结果和只更新一次是完全一样的(stock变成了99)。每次都有一批线程在竞争,但最后只有一个线程的更新“活”到了最后,导致库存最终只减少了大约20次。

  • 订单的正常建立(新增order数=200):orderRepository.save(order)这个操作,因为每个订单的主键都是自增的,并且(user_id,product_id)的组合也是唯一的,所以每一次订单的插入都能成功。

3. 改进

第一次尝试-在 processSeckill 方法上加 synchronized 锁
  • 预期结果:锁生效,200个线程变为串行处理,数据一致性得以保证。即库存数为0,订单数为100,但吞吐量降低,平均响应时间升高。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Transactional
public synchronized String processSeckill(Long productId, Long userId) {
// 1. 从数据库读取 Product 对象到内存
Optional<Product> productOpt = productRepository.findById(productId);
Product product = productOpt.get(); // 假设此时读到的 stock 是 100

// ... 其他检查 ...

// 2. 在内存中计算新库存
product.setStock(product.getStock() - 1); // 内存中的 stock 变为 99

// 3. 将内存中的 Product 对象保存回数据库
productRepository.save(product);

// 4. 创建订单
SeckillOrder order = new SeckillOrder();
// ...
orderRepository.save(order);

return "秒杀成功!";
}
  • 结果:数据依然错误,订单数为200,库存数为79,而吞吐量和平均响应时间没有发生明显变化。
  • 分析:@Transaction 与 synchronized 的冲突
    • 当给一个方法加上@Transaction注解时,Spring为了能够控制事务的开启、提交和回滚,并不会让你直接调用这个方法,相反,Spring会在运行时创建一个该类的代理对象proxy。
    • 事务先生效:即代理对象的同名方法会被触发,代理对象会先开启一个事务
    • synchronized被绕过:它是Java原生关键字,作用于对象实例的锁,但Spring的代理机制在调用目标方法时,可能会导致锁机制失效,因为代理方法本身没有synchronized,调用父类(本身SeckillService)的方法时,锁的上下文可能已经丢失或不准确。
      即,Spring的AOP代理与Java原生的锁关键字之间存在冲突,代理绕过了锁,导致synchronized锁机制未生效。
第二次尝试-在事务内使用ReentrantLock
  • 预期结果:锁生效,200个线程变为串行处理,数据一致性得以保证。即库存数为0,订单数为100,但吞吐量降低,平均响应时间升高。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    @Transactional
    public String processSeckill(Long productId, Long userId) { // 1. 事务由代理对象在这里开启
    // 2. 在方法开始时手动加锁
    lock.lock();
    try {
    // 所有业务逻辑都放在 try 块中
    // 其他检查

    product.setStock(product.getStock() - 1);
    productRepository.save(product);

    SeckillOrder order = new SeckillOrder();
    // ...
    orderRepository.save(order);

    return "秒杀成功!订单创建中...";
    } finally {
    // 3. 【至关重要】在 finally 块中解锁,确保即使发生异常也能释放锁
    lock.unlock();
    }
    } // 4. 事务由代理对象在这里提交
  • 显示控制:lock.lock()是代码逻辑的一部分,在代理调用了实际方法之后,业务逻辑执行之前被调用。任何线程进入这个方法后,都必须先获取到这个锁才能继续执行。

  • 安全释放:使用try…finally结构,确保无论业务逻辑是否成功,锁最终都会被释放,避免了“死锁”的风险。

  • 结果:数据依然错误,订单数为200,库存数为79,而吞吐量和平均响应时间没有发生明显变化。

  • 分析:锁的范围 VS 事务的范围

    • 锁释放在前,事务提交在后,所以当线程A解锁后,UPDATE语句还没有提交进入数据库,而此时线程B立即取锁进入了方法,此时B读取到的库存数还是旧值。重演丢失更新问题。
第三次尝试-ReentrantLock + 手动事务
  • 预期结果:锁生效,200个线程变为串行处理,数据一致性得以保证。即库存数为0,订单数为100,但吞吐量降低,平均响应时间升高。

  • 解决方案:将事务控制移入锁内

    • 放弃@Transaction注解,因为这个注解无法精细控制提交时机,改用最经典、最原始的手动事务管理。
    • 使用PlatformTransactionManager 来手动控制事务的开始、提交和回滚。将整个生命周期包裹在锁内。
      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
      @Autowired
      private PlatformTransactionManager transactionManager;

      private final Lock lock = new ReentrantLock();

      public String processSeckill(Long productId, Long userId) {
      // 1. 上锁
      lock.lock();

      DefaultTransactionDefinition def = new DefaultTransactionDefinition();
      def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
      // 2. 开启事务
      TransactionStatus status = transactionManager.getTransaction(def);

      try {
      // ...所有业务逻辑,和之前一样
      // 3. 【关键】在锁释放前,手动提交事务
      transactionManager.commit(status);

      return "秒杀成功!订单创建中...";
      } catch (Exception e) {
      // 如果发生任何异常,手动回滚事务
      transactionManager.rollback(status);
      // 将异常信息返回或记录日志
      return e.getMessage();
      } finally {
      // 4. 最后,释放锁
      lock.unlock();
      }
      }
  • 结果:数据正确,库存为0,订单数为100,吞吐量没有下降,甚至比没有并发控制的1.0 版本还要高。

  • 分析: 在高并发场景下,无序的并发混乱,有时远比有序的串行执行要慢。下表为初始版本和三次尝试版本的压测数据。

Label 样本 平均值 中位数 90% 百分位 95% 百分位 99% 百分位 最小值 最大值 异常 % 吞吐量 接收 KB/sec 发送 KB/sec
1.0秒杀系统 200 740 810 1044 1078 1083 254 1084 0.00% 95.92 18.27 20.51
2.0秒杀系统_添加关键字synchronized 200 578 598 744 763 767 79 831 0.00% 113.38 21.59 24.24
2.0秒杀系统_手动添加锁ReentrantLock 200 638 663 819 837 879 237 891 0.00% 105.76 20.14 22.62
2.0秒杀系统_不使用@Transaction手动加锁 200 905 1024 1148 1160 1175 203 1178 0.00% 100.45 18.25 21.48
  • 无锁版虽然看似并行,但造成了数据库层面大量的行锁竞争、唯一键冲突错误、事务回滚,这些“无效”的数据库操作成为了真正的性能瓶颈。而加锁后的版本,虽然在应用层是串行的,但它保护了数据库,向数据库发送的是一连串干净、有效的请求,避免了数据库的内部冲突和错误处理开销,因此整体系统的有效吞吐量反而更高。

学学八股

@Transaction注解 & 一个请求的生命周期

  • Controller接受请求,调用Service方法,但此时Service引用的是Spring的代理对象,而不是原始实例。代理对象的方法被触发,检查到方法上有@Transaction注解,代理对象向事务管理器(platformTransactionManager)请求开启一个新的事务。从数据库连接池中获取一个数据库连接,向MySQL服务器发送指令。此时一个数据库事务的“上下文”已经建立,但还没有任何实际的业务SQL被执行。在开启事务后,代理对象才会调用原始实例的方法,开始执行业务逻辑。但需要注意的是,这个阶段中是没有发生任何数据库操作的,只是告诉JPA的持久化上下文,标记了该对象,后续需要“留意”。
  • 所有代码都执行完毕后,到达return语句,准备将结果返回。此时代理对象接收到真实方法的返回值,因为没有捕获到任何异常,代理对象判断这次业务执行是成功的,通知事务管理器可以提交事务了。事务管理器执行数据库同步,在真正COMMIT之前,JPA会执行一次Flush,生成SQL语句,发送到MYSQL服务器并执行,直到COMMIT被成功执行了,这次更新才被永久地鞋屋数据库磁盘。
  • 代理对象将你方法的返回值传递给Controller,Controller再将其封装成HTTP响应返回给用户。

锁-初步

  1. Synchronized:Keyword、JVM内置的同步原语,简单、隐式的加锁和解锁机制。
  • 使用方法:修饰实例方法、修饰静态方法、修饰代码块。
  • 实现依赖于每个Java对象头部的Mark Word和JVM内部的Monitor对象监视器(当线程尝试获取锁时,JVM会执行Monitorenter字节码指令,尝试获得对象Monitor所有权。释放锁时即执行monitorexit)。
  • 锁升级机制:偏向锁—>轻量级锁(当有第二个线程竞争时升级,竞争的线程通过自旋和CAS来尝试获取锁,不进入阻塞状态)—>重量级锁(竞争加剧,自旋失败,升级,未获取到锁的线程会被阻塞,由内核进行调度,性能开销最大)
  1. ReentrantLock:是JUC工具包的核心成员,显式加锁和解锁机制。
  • 使用方法:标准的使用范式
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // 1. 在类中声明一个 Lock 实例
    private final ReentrantLock lock = new ReentrantLock();

    public void someMethod() {
    // 2. 在 try...finally 结构中进行加锁和解锁
    lock.lock(); // 获取锁
    try {
    // 3. 保护的同步代码
    } finally {
    // 4. 必须在 finally 块中释放锁
    lock.unlock();
    }
    }
  • 实现依赖于JUC的核心框架AQS,所有获取锁失败的线程,会被封装成节点放入一个CLH虚拟双向队列中进行排队等待。当锁被释放时,会从队列头部唤醒下一个等待的线程。整个过程都在用户态完成,避免了频繁的内核态切换。

    AQS 内部通过一个 volatileint 类型的 state 变量来表示同步状态(0表示未锁定,>0表示已锁定),并使用 CAS 操作来原子性地修改这个 state 值。

  • 功能丰富:等待可中断(等待锁的线程可被中断)、可实现公平锁(按线程请求的顺序获取锁)、可实现非公平锁(允许插队、吞吐量更高)、可尝试获取锁(可在指定时间内尝试获取锁,失败则返回)、可绑定多个条件(可分组唤醒等待的线程,实现更精细的线程通信)

选择问题:在绝大多数情况下,当并发冲突不激烈、同步逻辑简单时,优先选择Synchronized关键字,在特定的场景下,比如需要使用Synchronized不具备的高级功能时,或者是在我的秒杀项目中,需要将事务提交的完整过程都包裹在锁内,即手动控制锁时。


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

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