【黑马点评】(三)优惠卷秒杀

(一)全局唯一ID

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

(二)Redis实现全局唯一id

@Component
public class RedisIdWorker {

    /**
     * 开始的时间戳
     */
    private static final long BEGIN_TIMESTAMP = 1640995200L;
    /**
     * 序列号的位数
     */
    private static final int COUNT_BITS = 32;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    public long nextId(String keyPrefix){
        // 1.生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowSecond - BEGIN_TIMESTAMP;
        // 2.生成序列号
        // 2.1 获取当前日期
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
        // 3.拼接并返回

        return timestamp << COUNT_BITS | count;
    }

//    public static void main(String[] args) {
//        LocalDateTime localDateTime = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
//        long second = localDateTime.toEpochSecond(ZoneOffset.UTC);
//        System.out.println("second = " + second);
//    }
}
    @Resource
    private RedisIdWorker redisIdWorker;

    private ExecutorService es = Executors.newFixedThreadPool(500);

    @Test
    void testIdWorker() throws InterruptedException {

        CountDownLatch latch = new CountDownLatch(300);

        Runnable task = () -> {
            for (int i = 0; i < 100; i++) {
                long id = redisIdWorker.nextId("order");
                System.out.println("id = " + id);
            }
            latch.countDown();
        };
        long begin = System.currentTimeMillis();
        for (int i = 0; i < 300; i++) {
            es.submit(task);
        }

        latch.await();
        long end = System.currentTimeMillis();
        System.out.println("time = " + (end - begin));
    }

在这里插入图片描述

在这里插入图片描述

(三)添加优惠卷

普通优惠卷(平价卷)

CREATE TABLE `tb_voucher` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `shop_id` bigint unsigned DEFAULT NULL COMMENT '商铺id',
  `title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '代金券标题',
  `sub_title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '副标题',
  `rules` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '使用规则',
  `pay_value` bigint unsigned NOT NULL COMMENT '支付金额,单位是分。例如200代表2元',
  `actual_value` bigint NOT NULL COMMENT '抵扣金额,单位是分。例如200代表2元',
  `type` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '0,普通券;1,秒杀券',
  `status` tinyint unsigned NOT NULL DEFAULT '1' COMMENT '1,上架; 2,下架; 3,过期',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=COMPACT;

秒杀卷

CREATE TABLE `tb_seckill_voucher` (
  `voucher_id` bigint unsigned NOT NULL COMMENT '关联的优惠券的id',
  `stock` int NOT NULL COMMENT '库存',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `begin_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '生效时间',
  `end_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '失效时间',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`voucher_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=COMPACT COMMENT='秒杀优惠券表,与优惠券是一对一关系';

在这里插入图片描述

@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_voucher")
public class Voucher implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 主键
     */
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    /**
     * 商铺id
     */
    private Long shopId;

    /**
     * 代金券标题
     */
    private String title;

    /**
     * 副标题
     */
    private String subTitle;

    /**
     * 使用规则
     */
    private String rules;

    /**
     * 支付金额
     */
    private Long payValue;

    /**
     * 抵扣金额
     */
    private Long actualValue;

    /**
     * 优惠券类型
     */
    private Integer type;

    /**
     * 优惠券类型
     */
    private Integer status;
    /**
     * 库存
     */
    @TableField(exist = false)
    private Integer stock;

    /**
     * 生效时间
     */
    @TableField(exist = false)
    private LocalDateTime beginTime;

    /**
     * 失效时间
     */
    @TableField(exist = false)
    private LocalDateTime endTime;

    /**
     * 创建时间
     */
    private LocalDateTime createTime;


    /**
     * 更新时间
     */
    private LocalDateTime updateTime;


}

调用接口,这里记得添加请求凭证到请求头当中,当然你也可以在拦截器里面不拦截添加优惠卷的请求路由

在这里插入图片描述

(四)实现秒杀下单

在这里插入图片描述

在这里插入图片描述
controller

@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {

    @Resource
    private IVoucherOrderService voucherOrderService;

    @PostMapping("seckill/{id}")
    public Result seckillVoucher(@PathVariable("id") Long voucherId) {
        return voucherOrderService.seckillVoucher(voucherId);
    }
}

service实现类

public interface IVoucherOrderService extends IService<VoucherOrder> {

    Result seckillVoucher(Long voucherId);
}

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService{

    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIdWorker redisIdWorker;

    @Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        // 1. 查询优惠卷
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        // 2. 判断秒杀是否开始
        if(voucher.getBeginTime().isAfter(LocalDateTime.now())){
            // 尚未开始
            return Result.fail("秒杀尚未开始");
        }

        // 3. 判断秒杀是否结束
        if(voucher.getEndTime().isBefore(LocalDateTime.now())){
            // 尚未开始
            return Result.fail("秒杀已经结束");
        }
        // 4. 判断库存是否充足
        if(voucher.getStock() < 1){
            // 库存不足
            return Result.fail("库存不足");
        }
        // 5. 扣减库存
        boolean success = seckillVoucherService.update().
                setSql("stock = stock - 1").
                eq("voucher_id", voucherId).update();
        if(!success){
            // 库存不足
            return Result.fail("库存不足");
        }
        // 6. 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        // 用户id
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        // 代金卷id
        voucherOrder.setVoucherId(voucherId);
        // 7. 返回订单id
        save(voucherOrder);
        return Result.ok(voucherOrder);
    }
}

在这里插入图片描述

(五)库存超卖问题分析

在这里插入图片描述

正常情况下:
在这里插入图片描述
高并发问题下:

在这里插入图片描述

自己去apipost实现压测,也是同样的问题

在这里插入图片描述
在这里插入图片描述
同时会发现订单表不止100条订单数据。。。就是超卖了。

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

(六)乐观锁解决超卖问题

cas操作:在执行sql语句时将stock与一开始查询到的stock进行比较

// 5. 扣减库存
boolean success = seckillVoucherService.update().
        setSql("stock = stock - 1"). // set stock = stock - 1
        eq("voucher_id", voucherId).eq("stock", voucher.getStock()) // where id = ? and stock =
        .update();

再次进行测试:发现100的库存剩下84 。。。 超卖解决完毕了,但是stock按道理应该为0吧

在这里插入图片描述
在这里插入图片描述
订单表也只有16个数据
在这里插入图片描述
重新修改代码,因为之前的代码是比较库存是否和当前查询的相同,比如当100张票的时候有十几个线程同时消费库存,此时只有一个线程会成功,因此只需要将条件改为库存大于0即可。

 // 5. 扣减库存
 boolean success = seckillVoucherService.update().
         setSql("stock = stock - 1"). // set stock = stock - 1
         eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
         .update();

此时压测发现,库存成功从100变为0,订单表中数据也恰好为100条。

在这里插入图片描述
在这里插入图片描述

(七)实现一人一单的功能

之前使用的乐观锁,所有的购票都是同一个用户买的100单。。。。成黄牛了哈哈哈,因此需要判断用户是否买过这个优惠卷。

在这里插入图片描述
先上代码,这里的代码可能有点小复杂,在乐观锁的基础上,对用户id进行了加上了悲观锁,因为不加悲观锁也会导致同一个id买到多张卷的问题,原因类比乐观锁的超卖。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.12.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.hmdp</groupId>
    <artifactId>hm-dianping</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>hm-dianping</name>
    <description>Demo project for Spring Boot</description>
    <properties>
        <java.version>1.8</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
            <version>5.1.47</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.4.3</version>
        </dependency>
        <!--hutool-->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.7.17</version>
        </dependency>
        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjweaver</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

新增了 aspectj 依赖,实现代理。。。 同时启动类增加@EnableAspectJAutoProxy(exposeProxy = true) 暴露代理

@EnableAspectJAutoProxy(exposeProxy = true)
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {

    public static void main(String[] args) {
        SpringApplication.run(HmDianPingApplication.class, args);
    }

}

public interface IVoucherOrderService extends IService<VoucherOrder> {

    Result seckillVoucher(Long voucherId);

    Result createVoucherOrder(Long voucherId);
}


@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService{

    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIdWorker redisIdWorker;

    @Override
    public Result seckillVoucher(Long voucherId) {
        // 1. 查询优惠卷
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        // 2. 判断秒杀是否开始
        if(voucher.getBeginTime().isAfter(LocalDateTime.now())){
            // 尚未开始
            return Result.fail("秒杀尚未开始");
        }

        // 3. 判断秒杀是否结束
        if(voucher.getEndTime().isBefore(LocalDateTime.now())){
            // 尚未开始
            return Result.fail("秒杀已经结束");
        }
        // 4. 判断库存是否充足
        if(voucher.getStock() < 1){
            // 库存不足
            return Result.fail("库存不足");
        }
        // ctrl + alt + m 封装方法
        Long userId = UserHolder.getUser().getId();
        synchronized(userId.toString().intern()){
            // 获取代理对象(事务)
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }

    }

    @Transactional
    public  Result createVoucherOrder(Long voucherId) {
        // 一人一单
        // 查询订单,判断是否存在
        // 用户id
        Long userId = UserHolder.getUser().getId();

        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if(count > 0){
            // 用户已经购买过了
            return Result.fail("用户已经购买过一次!");
        }

        // 5. 扣减库存
        boolean success = seckillVoucherService.update().
                setSql("stock = stock - 1"). // set stock = stock - 1
                        eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
                .update();
        if(!success){
            // 库存不足
            return Result.fail("库存不足");
        }


        // 6. 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);

        voucherOrder.setUserId(userId);
        // 代金卷id
        voucherOrder.setVoucherId(voucherId);
        // 7. 返回订单id
        save(voucherOrder);
        return Result.ok(voucherOrder);


    }
}

本地apipost压测200个线程跑1s,因为都是同一个用户,最终stock的数量只会到99,订单表里面也只有一个订单数据。

总体来说:这是一个实现秒杀优惠券功能的Java服务代码,主要逻辑是:先校验秒杀活动时间(未开始/已结束则报错)和库存(不足则报错),然后通过用户ID级别的同步锁确保同一用户只能有一个请求进入下单流程,在事务方法中再次校验"一人一单"规则(防止重复购买),使用数据库乐观锁(stock > 0条件)安全扣减库存,最后用Redis生成分布式ID创建订单并返回结果。整个过程通过双重校验(活动状态+库存+用户订单)和锁机制保障数据一致性。

很多小伙伴不懂这个地方的代理为啥要这样写:

 // ctrl + alt + m 封装方法
 Long userId = UserHolder.getUser().getId();
 synchronized(userId.toString().intern()){
     // 获取代理对象(事务)
     IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
     return proxy.createVoucherOrder(voucherId);
 }

使用代理对象是为了确保事务注解 @Transactional 生效。由于 Spring 的事务功能通过动态代理实现,当同一个类中直接调用 this.createVoucherOrder() 时,会绕过代理对象导致事务失效。通过 AopContext.currentProxy() 获取当前类的代理对象再调用方法,能够触发 Spring 的事务拦截器,保证库存扣减和订单创建在同一个事务中执行,实现操作的原子性(要么同时成功,要么同时回滚)。

小伙伴的疑问又来了 当前类的代理对象是什么 为什么Service是代理对象 而不是当前impl的是代理对象

关于代理对象和实现类的关系(清晰版解释):

实现类:你编写的 VoucherOrderServiceImpl 类(带 @Service 注解)

代理对象:Spring 运行时动态生成的增强版实现类

为什么实现类本身不是代理对象?

你编写的 VoucherOrderServiceImpl.java 是原始代码
→ 无法包含动态生成的事务逻辑(事务开启/提交/回滚)

类比理解:
想象你买了部手机(实现类),Spring 就像手机贴膜+装保护壳的服务:

原始手机(实现类):功能正常但易损坏(无事务保护)

带壳手机(代理对象):增加了防摔保护(事务能力)

当你从口袋(@Autowired)拿出时,拿到的是带壳手机

但在手机内部电路(this)看来,它仍是原始手机

这就是为什么需要显式获取代理对象——为了唤醒"保护壳"的事务功能。

(八)集群下的线程并发安全问题

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

用这个接口测试两个服务是否成功:http://localhost:8080/api/shop/1 因为不需要token 但是可能会被缓存,第二个8082不需要走数据库了。

理想情况下:
在这里插入图片描述

但是集群下,syn锁同一个用户在两个服务下可能都能进去,因为不是同一把锁,因此需要->分布式锁

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值