栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > Java

黑马点评项目-优惠券秒杀

Java 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

黑马点评项目-优惠券秒杀

一、全局唯一ID 1.1 知识点介绍

每个店铺都可以发布优惠券,而每张优惠券都是唯一的。当用户抢购时,就会生成订单并保存到 tb_voucher_order 这张表中,而订单表如果使用数据库自增 ID 就存在一些问题:

  • id 的规律太明显。如果 id 规律太明显,用户就能够根据 id 猜测出一些信息。比方说,某用户第一天下了一单,此时 id 为 10,第二天同一时刻,该用户又下了一单,此时 id 为 100,那么用户就能够推断出昨天一天店家卖出了 90 单,这就将一些信息暴露给用户。
  • 受单表数据量的限制。订单的一个特点就是数据量比较大,只要用户不停地产生购买行为,就会不停地产生新的订单。如果网站做到一定的规模,用户量达到数百万,这时候每天都会产生数十万甚至近百万的订单,一年下来就会达到数千万的订单,那么两年三年不断累积下来,订单量就会越来越庞大,此时单张表就无法保存这么多的订单数据,就需要将单张表拆分成多张表。MySQL 的每张表会自己计算自己的自增长,如果每张表都使用自增长,订单 id 就一定会重复。

全局 ID 生成器,是一种在分布式系统下用来生成全局唯一 ID 的工具,一般要满足下列特性:

  • 唯一性
  • 高可用
  • 高性能
  • 递增型
  • 安全性

为了增加 ID 的安全性,我们可以不直接使用 Redis 自增的数值,而是拼接一些其它信息:

ID 组成部分:

  • 符号位:1 bit,永远为 0
  • 时间戳:31 bit,以秒为单位,可以使用 69 年
  • 序列号:32 bit,秒内的计数器,支持每秒产生 2^32 个不同的 ID
1.2 Redis 实现全局唯一 id
package com.hmdp.utils;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;

@Component
public class RedisIdWorker {

    private static final long BEGIN_TIMESTAMP = 1640995200L;

    private static int COUNT_BITS = 32;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    public Long nextId(String keyPrefix){
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long time = nowSecond - BEGIN_TIMESTAMP;

        String format = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        // Redis Incrby 命令将 key 中储存的数字加上指定的增量值。
        // 如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 INCRBY 命令。
        Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + format);

        return time << COUNT_BITS | count;
    }

    public static void main(String[] args) {
        LocalDateTime of = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
        long l = of.toEpochSecond(ZoneOffset.UTC);
        // LocalTime类的toEpochSecond()方法用于
        // 将此LocalTime转换为自1970-01-01T00:00:00Z以来的秒数
        System.out.println(l);
    }
}

测试:

@SpringBootTest
class HmDianPingApplicationTests {

    @Autowired
    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));
    }

}

1.3 总结

全局唯一 ID 生成策略:

  • UUID:16进制的字符串ID,可以做唯一ID,但不支持自增
  • Redis 自增
  • snowflake 雪花算法:long 类型的 64 ID,性能更好,但是比较依赖于时钟,如果时间不准确,可能会出现异常问题
  • 数据库自增:单独创建一张表,用于实现自增

Redis 自增 ID 策略:

  • 每天一个 key,方便统计订单量
  • ID 构造是 时间戳 + 计数器
二、实现优惠券秒杀下单 2.1 案例分析

每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购:

表关系如下:

  • tb_voucher:优惠券的基本信息,优惠金额、使用规则等。
  • tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息。

功能实现

下单时需要判断两点:

  • 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
  • 库存是否充足,不足则无法下单

2.2 代码实现

VoucherOrderController

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

    @Autowired
    private IVoucherOrderService voucherOrderService;

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

IVoucherOrderService

public interface IVoucherOrderService extends IService {

    Result seckillVoucher(Long voucherId);
}

VoucherOrderServiceImpl

@Service
public class VoucherOrderServiceImpl extends ServiceImpl implements IVoucherOrderService {

    @Autowired
    private ISeckillVoucherService seckillVoucherService;

    @Autowired
    private RedisIdWorker redisIdWorker;

    @Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {

        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);

        // 判断秒杀是否还未开始
        if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
            Result.fail("秒杀尚未开始!");
        }

        // 判断秒杀是否已经结束
        if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
            Result.fail("秒杀已经结束!");
        }

        // 判断库存是否充足
        if (seckillVoucher.getStock() < 1) {
            Result.fail("库存不足!");
        }

        // 扣减库存
        boolean success = seckillVoucherService.update().
                setSql("stock = stock - 1").
                eq("voucher_id", voucherId).update();

        // 扣减失败
        if(!success){
            return Result.fail("库存不足!");
        }

        // 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 生成订单 id
        Long orderId = redisIdWorker.nextId("order");
        voucherOrder.setVoucherId(voucherId);
        // 用户 id
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        voucherOrder.setId(orderId);
        save(voucherOrder);

        return Result.ok(orderId);
    }
}
三、超卖问题

使用 Jmeter 测试高并发情况下优惠券秒杀功能。

Jmeter 设置如下:
1、新增线程组

2、设置线程组参数

3、新增 HTTP 请求

并设置参数:

4、右键 HTTP 请求,添加查看结果树及聚合报告

5、添加身份验证 token。右键 HTTP 请求,选择 add—Config Element — HTTP Header Manager.

并设置参数:

如何查看 authorization 的值?
启动黑马点评项目,然后登录,进入系统后,按F12,选择 Network,选择 Header,就可以看到authorization

测试结果如下:

聚合报告中显示 200 个线程均成功了,但事实上我们只有 100 件库存。
再来看下数据库中秒杀表的结果:

可以看出库存结果显示为 -100,商品出现超卖现象。
在实际生活中,超卖问题是不允许出现,这会给商家造成巨大的损失。

3.1 超卖问题出现的原因

在高并发情况下,假设线程 1 查询库存,查询结果为 1 ,当线程 1 准备要去扣减库存时,其他线程也去查询库存,结果查询出来的库存数也是 1,那么这时所有的线程查询到的库存数都是大于 0 的,所有的线程都会去执行扣减操作,就会导致超卖问题。

超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:

乐观锁

乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:

1、版本号法:

所谓版本号法就是给查询得到的数据加一个版本号,在多线程并发的时候,基于版本号来判断数据有没有被修改过,每当数据被修改,版本号就会加1。

假设当前有两个线程,线程 1 和线程 2,线程1 在执行查询操作时,将库存数据以及版本号查询出来,而在线程 1 执行扣减库存前,线程 2 开始执行查询操作,查询出的库存数据与版本号与线程 1 一致。而后,线程 1 开始执行扣减操作,在修改时,需判断版本号是否发生改变,如果一致,扣减库存并修改版本号。此时,线程 2 也开始扣减库存,线程 2 在扣减时判断版本号与之前查询得到的版本号是否一致,此时版本号已经被线程 1 修改,所以得到的结果也就不一致,扣减失败。

2、CAS(Compare And Swap) 法

CAS 法,即比较和替换法,是在版本号法的基础上改进而来。CAS 法去除了版本号法中的版本号信息,以库存信息本身有没有变化为判断依据,当线程修改库存时,判断当前数据库中的库存与之前查询得到的库存数据是否一致,如果一致,则说明线程安全,可以执行扣减操作,如果不一致,则说明线程不安全,扣减失败。

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/843094.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号