掘金 后端 ( ) • 2024-04-29 09:27

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: ***********

使用工具生成基础代码

image.png

秒杀接口代码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);

测试

单线程请求秒杀接口

image.png

观察数据库数据变化:

image.png

单线程首次请求下数据正常,再次请求,同样的参数:

image.png

测试事务回滚,故意抛出错误

image.png

image.png

image.png

以上测试基于单线程,在正常与非正常的情况下,数据都保持了一致性。

多线程测试秒杀接口1.0

使用到的工具:jmeter

image.png

处理请求中,请求头携带不同用户id

1.准备一个文本文件:

image.png

2.配置jmeter请求

2.1 创建线程组 image.png

2.2 配置线程组 image.png

2.3 配置请求 image.png

2.4 请求参数填写 image.png

2.5 为请求添加CSV数据配置文件

image.png

2.6 配置文件源位置与变量名称

image.png

2.7 添加http信息头管理器

image.png

2.8 配置请求头管理器

image.png

准备就绪,开始测试

image.png

观察数据库数据:

image.png

出现了超卖的问题,解决思路:加锁,悲观锁: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

image.png

若没有修改成功库存信息,则代表抢购失败。

开始测试,观察数据库,返回结果:

image.png

image.png

解决了超卖的问题。

锁失效问题

一个用户,同一时间大量请求,未锁住同一个用户只能抢购1次的问题

image.png

解决方案:加锁,加同一用户的锁

image.png

解释:利用每个用户的id加锁,防止不同的用户请求阻塞的情况。

用户id是包装器整数类型,避免享元模式带来的比较问题,转换为String类型,并且每次拿String对象需要去字符串共享池中拿取,保证String的唯一性,才可正常加锁。

引发的事务边界问题

经过同步锁的改造,理论上用户限领数量判断的逻辑应该已经是解决了。不过,经过测试后,发现问题依然存在。

image.png

整体业务流程是这样的:

  • 开启事务
  • 获取锁
  • 统计用户已抢购的数量
  • 判断是否超出限买数量
  • 如果没超,新增一条用户抢购记录
  • 释放锁
  • 提交事务

总结:由于锁过早释放,导致了事务尚未提交,判断出现错误,最终导致并发安全问题发生。 这其实就是事务边界锁边界的问题。

解决方案:-业务开始前,先获取锁,再开启事务。-业务结束后:先提交事务,再释放锁

image.png

将判断数据是否抢购过,更新仓库信息,新增记录信息提取到一个方法中,单独加事务控制。

image.png

解决了事务边界代理的问题

事务失效问题

image.png

事务失效的一些常见案例:

  • @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()获取当前代理对象,调用事务方法。

image.png

观察数据库:

image.png

总结

在解决高并发请求中,利用锁机制来完成,包括乐观锁与悲观锁的合理利用,在保证数据的一致性时,提高了运行效率。在涉及事务时,会出现事务晚提交,事务失效等问题。