一、项目概述
- 项目名称:高并发-秒杀系统(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
11CREATE 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
9CREATE 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 |
|
- 在初始版本中不加入任何的并发控制,后续会通过压力测试来暴露和分析最原始的并发问题。
三、并发问题的分析与演进
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 |
|
- 结果:数据依然错误,订单数为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
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
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响应返回给用户。
锁-初步
- Synchronized:Keyword、JVM内置的同步原语,简单、隐式的加锁和解锁机制。
- 使用方法:修饰实例方法、修饰静态方法、修饰代码块。
- 实现依赖于每个Java对象头部的Mark Word和JVM内部的Monitor对象监视器(当线程尝试获取锁时,JVM会执行Monitorenter字节码指令,尝试获得对象Monitor所有权。释放锁时即执行monitorexit)。
- 锁升级机制:偏向锁—>轻量级锁(当有第二个线程竞争时升级,竞争的线程通过自旋和CAS来尝试获取锁,不进入阻塞状态)—>重量级锁(竞争加剧,自旋失败,升级,未获取到锁的线程会被阻塞,由内核进行调度,性能开销最大)
- 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 内部通过一个
volatile的int类型的state变量来表示同步状态(0表示未锁定,>0表示已锁定),并使用 CAS 操作来原子性地修改这个state值。 - 功能丰富:等待可中断(等待锁的线程可被中断)、可实现公平锁(按线程请求的顺序获取锁)、可实现非公平锁(允许插队、吞吐量更高)、可尝试获取锁(可在指定时间内尝试获取锁,失败则返回)、可绑定多个条件(可分组唤醒等待的线程,实现更精细的线程通信)
选择问题:在绝大多数情况下,当并发冲突不激烈、同步逻辑简单时,优先选择Synchronized关键字,在特定的场景下,比如需要使用Synchronized不具备的高级功能时,或者是在我的秒杀项目中,需要将事务提交的完整过程都包裹在锁内,即手动控制锁时。


