theme: minimalism highlight: xcode
案例描述
两张数据库表:商品库存表,用户抢购商品成功记录表。业务流程:用户抢购商品,判断商品库存是否充足,用户是否已经抢购某商品(每个用户只能抢购1次),若满足条件,更新商品库存表,新增用户抢购商品成功记录表信息。
创库创表sql语句
CREATE DATABASE IF NOT EXISTS `seckill_db`;
USE `seckill_db`;
-- 商品库存表
CREATE TABLE IF NOT EXISTS `inventory` (
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '库存id',
`commodity_id` int unsigned NOT NULL COMMENT '商品id',
`commodity_name` varchar(50) NOT NULL COMMENT '商品名称',
`total_number` int unsigned NOT NULL DEFAULT (0) COMMENT '库存总数',
`transaction_number` int unsigned NOT NULL DEFAULT (0) COMMENT '交易完成数量',
PRIMARY KEY (`id`),
UNIQUE KEY `commodity_id` (`commodity_id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='库存表';
-- 插入测试数据
INSERT INTO `inventory` (`id`, `commodity_id`, `commodity_name`, `total_number`, `transaction_number`) VALUES
(1, 10011, '笔记本电脑', 10, 0);
-- 用户抢购商品成功记录表
CREATE TABLE IF NOT EXISTS `user_record` (
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '记录id',
`user_id` int unsigned NOT NULL COMMENT '用户id',
`commodity_id` int unsigned NOT NULL COMMENT '商品id',
`create_time` datetime DEFAULT NULL COMMENT '交易成功记录时间',
PRIMARY KEY (`id`),
KEY `user_id_commodity_id` (`user_id`,`commodity_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户秒杀抢购成功记录表';
创建springboot项目
pom.xml文件内容:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.15</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.2</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
启动类文件内容:
@SpringBootApplication
@Slf4j
public class SeckillApplication {
public static void main(String[] args) {
SpringApplication.run(SeckillApplication.class, args);
log.info("秒杀案例项目启动成功");
}
}
application.yml文件内容:
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/seckill_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
username: root
password: ***********
使用工具生成基础代码
秒杀接口代码1.0
通过请求头的方式携带用户id信息,商品信息代码里写死。
用户抢购记录处理器接口:
@GetMapping
public String seckill(@RequestHeader("userId") Integer userId) {
return userRecordService.seckill(userId);
}
业务层实现代码:
@Service("userRecordService")
@RequiredArgsConstructor
public class UserRecordServiceImpl extends ServiceImpl<UserRecordMapper, UserRecord> implements UserRecordService {
// 商品库存服务
private final InventoryService inventoryService;
/**
* 商品信息写死:10011
*/
@Override
@Transactional
public String seckill(Integer userId) {
// 1. 查询商品库存信息
Inventory inventory = inventoryService.lambdaQuery().eq(Inventory::getCommodityId, 10011).one();
// 2. 判断
if (null == inventory) {
throw new RuntimeException("商品库存信息不存在");
}
if (inventory.getTransactionNumber() >= inventory.getTotalNumber()) {
throw new RuntimeException("商品库存不足");
}
// 3. 查询记录表
Long count = lambdaQuery()
.eq(UserRecord::getUserId, userId)
.eq(UserRecord::getCommodityId, 10011)
.count();
if (count != null && count >= 1) {
throw new RuntimeException("已抢购成功,无需重复抢购");
}
// 4. 更新库存信息
int row = inventoryService.updateTransactionNumber(10011);
if (row == 0) {
throw new RuntimeException("抢购失败");
}
// 5. 插入抢购记录
UserRecord userRecord = new UserRecord();
userRecord.setUserId(userId);
userRecord.setCommodityId(10011);
userRecord.setCreateTime(LocalDateTime.now());
save(userRecord);
return "抢购成功,抢购id:" + userRecord.getId();
}
}
4. 更新库存信息代码:
@Update("update inventory set transaction_number = transaction_number + 1 where commodity_id = #{id}")
int updateTransactionNumber(Integer id);
测试
单线程请求秒杀接口
观察数据库数据变化:
单线程首次请求下数据正常,再次请求,同样的参数:
测试事务回滚,故意抛出错误
以上测试基于单线程,在正常与非正常的情况下,数据都保持了一致性。
多线程测试秒杀接口1.0
使用到的工具:jmeter
处理请求中,请求头携带不同用户id
1.准备一个文本文件:
2.配置jmeter请求
2.1 创建线程组
2.2 配置线程组
2.3 配置请求
2.4 请求参数填写
2.5 为请求添加CSV数据配置文件
2.6 配置文件源位置与变量名称
2.7 添加http信息头管理器
2.8 配置请求头管理器
准备就绪,开始测试
观察数据库数据:
出现了超卖的问题,解决思路:加锁,悲观锁:Synchronized,ReentrantLock,让线程串行执行,效率低。乐观锁,利用数据库字段,条件成立才能修改。
秒杀接口代码2.0
利用乐观锁,解决超卖的问题。
@Update("update inventory set transaction_number = transaction_number + 1 where commodity_id = #{id} and transaction_number < total_number")
int updateTransactionNumber(Integer id);
添加判断条件在sql上:and transaction_number < total_number
若没有修改成功库存信息,则代表抢购失败。
开始测试,观察数据库,返回结果:
解决了超卖的问题。
锁失效问题
一个用户,同一时间大量请求,未锁住同一个用户只能抢购1次的问题
解决方案:加锁,加同一用户的锁
解释:利用每个用户的id加锁,防止不同的用户请求阻塞的情况。
用户id是包装器整数类型,避免享元模式
带来的比较问题,转换为String
类型,并且每次拿String对象需要去字符串共享池中拿取,保证String的唯一性,才可正常加锁。
引发的事务边界问题
经过同步锁的改造,理论上用户限领数量判断的逻辑应该已经是解决了。不过,经过测试后,发现问题依然存在。
整体业务流程是这样的:
- 开启事务
- 获取锁
- 统计用户已抢购的数量
- 判断是否超出限买数量
- 如果没超,新增一条用户抢购记录
- 释放锁
- 提交事务
总结:由于锁过早释放,导致了事务尚未提交,判断出现错误,最终导致并发安全问题发生。
这其实就是事务边界
和锁边界
的问题。
解决方案:-业务开始前,先获取锁,再开启事务。-业务结束后:先提交事务,再释放锁
将判断数据是否抢购过,更新仓库信息,新增记录信息提取到一个方法中,单独加事务控制。
解决了事务边界代理的问题
事务失效问题
事务失效的一些常见案例:
-
@Transactional
注解修饰的方法不是public
-
非事务方法
调用了事务方法
- 事务方法的异常被捕获了
- 事务方法抛出的异常不支持【@Transactional(rollbackFor = RuntimeException.class)】
- 事务方法之间的传播行为不同 【@Transactional(propagation = Propagation.REQUIRES_NEW)】
- 事务方法没有被 Spring 管理
解决方案:事务失效的原因是方法内部调用走的是this,而不是代理对象。只要获取代理对象,操作代理对象即可恢复事务的控制。
上面代码中,出现了 非事务方法
调用了 事务方法
,解决过程如下:
1.引入坐标
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
2.启动类开启暴露代理对象
@EnableAspectJAutoProxy(exposeProxy = true)
3.利用 AopContext.currentProxy()
获取当前代理对象,调用事务方法。
观察数据库:
总结
在解决高并发请求中,利用锁机制来完成,包括乐观锁与悲观锁的合理利用,在保证数据的一致性时,提高了运行效率。在涉及事务时,会出现事务晚提交,事务失效等问题。