微服务 Spring Boot 整合Redis 实现优惠卷秒杀 一人一单

CSDN话题挑战赛第2期
参赛话题:Java技术分享

文章目录

一、什么是全局唯一ID

⛅全局唯一ID

在分布式系统中,经常需要使用 全局唯一ID查找对应的数据。产生这种ID需要保证系统全局唯一,而且要高性能以及占用相对较少的空间。

全局唯一ID在数据库中一般会被设成 主键,这样为了保证数据插入时索引的快速建立,还需要保持一个有序的趋势。

这样全局唯一ID就需要保证这两个需求:

  • 全局唯一
  • 趋势有序

我们的场景是 优惠卷秒杀抢购, 当用户抢购时,就会生成订单 并保存到 数据库 的订单表中,而订单表 如果使用数据库自增ID就会存在以下问题

  • id的规律性太明显
  • 受单表数据量限制

场景分析: 如果我们的id具有太明显的规则, 用户或者说商业对手很容易猜测出来我们的一些敏感信息 ,比如 商城在一天时间内,卖出了多少单,这明显不合适。

场景分析二: 随着我们商城规模越来越大, MySQL 的单表的容量不宜超过500W,数据量过大之后,我们要 进行拆库拆表,但拆分表了之后,他们从 逻辑上讲他们是同一张表,所以他们的id是不能一样的, 于是乎我们需要保证id的唯一性。

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

微服务 Spring Boot 整合Redis 实现优惠卷秒杀 一人一单

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

微服务 Spring Boot 整合Redis 实现优惠卷秒杀 一人一单

ID的组合为

  • 符号位: 1bit,永远为0
  • 时间戳: 31bit,以秒为单位可以使用69年
  • 序列号: 32bit,秒内的计数器,支持每秒产生 2^32 个 不同ID

; ⚡Redis实现全局唯一ID

编写工具类

@Component
public class RedisIdWorker {

    private static final long BEGIN_TIMESTAMP = 1640995200L;

    private static final int COUNT_BITS = 32;

    private StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public long nextId(String keyPrefix) {

        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowSecond - BEGIN_TIMESTAMP;

        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));

        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);

        return timestamp << COUNT_BITS | count;
    }
}

测试存入Redis

@Autowired
private RedisIdWorker redisIdWorker;

private ExecutorService es = Executors.newFixedThreadPool(500);

@Test
public void testWorkerId() 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("times = " + (end- begin));

}

这里用到了 CountDownlatch,简单的介绍一下:

CountDownLatch名为信号枪:主要的作用是 同步协调在多线程的等待于唤醒问题

我们如果没有CountDownLatch ,那么由于程序是异步的,当异步程序没有执行完时,主线程就已经执行完了,然后我们期望的是分线程全部走完之后,主线程再走,所以我们此时需要使用到CountDownLatch

CountDownLatch 中有两个最重要的方法

  • countDown
  • await

await 是阻塞方法, 我们担心线程没有执行完时,main线程就执行,所以可以 使用await就阻塞主线程, 那么什么时候main线程不在阻塞呢? 当 CountDownLatch 内部维护的变量为0时,就不再阻塞,直接放行

什么时候 CountDownLatch 维护的变量变为0 呢,我们只需要调用一次countDown ,内部变量就减少1,我们让分线程和变量绑定, 执行完一个分线程就减少一个变量,当分线程全部走完,CountDownLatch 维护的变量就是0,此时await就不再阻塞,统计出来的时间也就是所有分线程执行完后的时间。

二、环境准备

需要搭建登录环境, &#x57FA;&#x7840;&#x73AF;&#x5883;&#x4EE3;&#x7801;&#x548C;sql&#x6587;&#x4EF6; 均已上传 GitCode 链接:基础环境和SQL

三、实现秒杀下单

添加优惠卷

VoucherServiceImpl 核心代码

@Service
public class VoucherServiceImpl extends ServiceImpl<VoucherMapper, Voucher> implements IVoucherService {

    @Autowired
    private ISeckillVoucherService seckillVoucherService;

    @Override
    public ResultBean<List<Voucher>> queryVoucherOfShop(Long shopId) {

        List<Voucher> vouchers = getBaseMapper().queryVoucherOfShop(shopId);

        return ResultBean.create(0, "success", vouchers);
    }

    @Override
    public void addSeckillVoucher(Voucher voucher) {

        save(voucher);

        SeckillVoucher seckillVoucher = new SeckillVoucher();
        seckillVoucher.setVoucherId(voucher.getId());
        seckillVoucher.setStock(voucher.getStock());
        seckillVoucher.setBeginTime(voucher.getBeginTime());
        seckillVoucher.setEndTime(voucher.getEndTime());
        seckillVoucherService.save(seckillVoucher);
    }
}

VoucherController 接口层

@RestController
@CrossOrigin
@RequestMapping("/voucher")
public class VoucherController {

    @Autowired
    private IVoucherService voucherService;

    @PostMapping("seckill")
    public ResultBean addSeckillVoucher(@RequestBody Voucher voucher) {
        voucherService.addSeckillVoucher(voucher);
        return Result.ok(voucher.getId());
    }
}

编写下单业务

VoucherOrderServiceImpl 优惠卷订单核心业务类

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder>  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())) {
            return Result.fail("秒杀尚未开始!");
        }

        if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束!");
        }

        if (seckillVoucher.getStock() < 1) {
            return Result.fail("库存不足!");
        }

        Long userId = UserHolder.getUser().getId();

        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();

        if (count > 0) {
            return Result.fail("用户已经购买过!");
        }

        boolean success = seckillVoucherService.update().setSql("stock = stock -1")
                .eq("voucher_id", voucherId).update();
        if (!success) {
            return Result.fail("库存不足!");
        }

        VoucherOrder voucherOrder = new VoucherOrder();
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);

        return Result.ok(orderId);
    }
}

VoucherOrderController 接口层

@RestController
@CrossOrigin
@RequestMapping("/voucher_order")
public class VoucherOrderController {

    @Autowired
    private IVoucherOrderService voucherOrderService;

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

测试抢购秒杀优惠卷

ApiFox 新增以下接口

添加秒杀卷

微服务 Spring Boot 整合Redis 实现优惠卷秒杀 一人一单

测试返回成功即可。

抢购秒杀优惠卷接口

微服务 Spring Boot 整合Redis 实现优惠卷秒杀 一人一单

测试无误,抢购成功!

四、库存超卖问题

⏳问题分析

有关超卖问题分析:在我们原有代码中是这么写的

 if (voucher.getStock() < 1) {

        return Result.fail("库存不足!");
    }

    boolean success = seckillVoucherService.update()
            .setSql("stock= stock -1")
            .eq("voucher_id", voucherId).update();
    if (!success) {

        return Result.fail("库存不足!");
    }

假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。

微服务 Spring Boot 整合Redis 实现优惠卷秒杀 一人一单

超卖问题是典型的多线程安全问题, 这种情况下 常见的解决方案就是 加 锁:而对于加锁,我们 通常有两种解决方案

微服务 Spring Boot 整合Redis 实现优惠卷秒杀 一人一单

悲观锁:

悲观锁 可以实现对于数据的串行化执行,比如syn,和lock都是悲观锁的代表,同时, 悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等

乐观锁:

会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过,当然乐观锁还有一些变种的处理方式比如cas

乐观锁的典型代表:就是 CAS,利用CAS 进行无锁化机制加锁,varNum是操作前读取的内存值,while中的var1+var2 是预估值,如果预估值 == 内存值,则代表中间没有被人修改过,此时就将新值去替换 内存值

其中do while 是为了在操作失败时,再次进行自旋操作,即把之前的逻辑再操作一次。

int varNum;
do {
    varNum = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

return var5;

我们 采用的方式为:

在操作时, 对版本号进行+1 操作,然后要求version 如果是1 的情况下,才能操作,那么第一个线程在操作后,数据库中的version变成了2,但是他自己满足version=1 ,所以没有问题,此时线程2执行, 线程2 最后也需要加上条件version =1 ,但是现在由于线程1已经操作过了,所以线程2,操作时就不满足version=1 的条件了,所以线程2无法执行成功

微服务 Spring Boot 整合Redis 实现优惠卷秒杀 一人一单

⌚ 乐观锁解决库存超卖

加入以下代码解决超卖问题

之前的方式要修改前后都保持一致,但是这样我们分析过,成功的概率太低,所以我们的乐观锁需要变一下,改成stock大于0 即可

boolean success = seckillVoucherService.update()
            .setSql("stock= stock -1")
            .eq("voucher_id", voucherId).update().gt("stock",0);

知识拓展

针对 CAS中的自旋压力过大,我们可以使用Longaddr这个类去解决

Java8 提供的一个对AtomicLong改进后的一个类,LongAdder

大量线程并发更新一个原子性的时候,天然的问题就是自旋,会导致并发性问题,当然这也比我们直接使用syn来的好

所以利用这么一个类,LongAdder来进行优化

如果获取某个值, 则会对cell和base的值进行递增,最后返回一个完整的值

微服务 Spring Boot 整合Redis 实现优惠卷秒杀 一人一单

以上的解决方式,依然有些问题,下面使用Jmeter进行测试

✅Jmeter 测试

添加线程组

微服务 Spring Boot 整合Redis 实现优惠卷秒杀 一人一单

添加JSON断言,我们认为返回结果为false的就是请求失败

在线程组右击选择断言 –> JSON 断言

微服务 Spring Boot 整合Redis 实现优惠卷秒杀 一人一单

加入以下判断

微服务 Spring Boot 整合Redis 实现优惠卷秒杀 一人一单

判断success字段,值是否为true,是true就是返回成功~ 反之失败

查看结果树、HTTP信息请求头、汇总报告、聚合报告等均在http请求右击添加即可

启动,查看返回的结果

微服务 Spring Boot 整合Redis 实现优惠卷秒杀 一人一单

查看聚合报告

微服务 Spring Boot 整合Redis 实现优惠卷秒杀 一人一单

异常率这么高,再来看数据库

微服务 Spring Boot 整合Redis 实现优惠卷秒杀 一人一单

数量正确,我们再看订单表

微服务 Spring Boot 整合Redis 实现优惠卷秒杀 一人一单

id都一样,这可不行啊,我们 真实场景下,发放优惠卷不会让一个用户去抢购所有的订单秒杀优惠卷,这样商家就太亏了,全让黄牛给抢走了,这可不行,我们 需要限制用户的抢购数量。

; 五、优惠卷秒杀 实现一人一单

初步实现

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

存在问题:现在的问题还是和之前一样,并发过来,查询数据库,都不存在订单,所以我们还是需要加锁,但是乐观锁比较适合更新数据,而现在是插入数据,所以我们需要使用悲观锁操作

注意:在这里提到了非常多的问题,我们需要慢慢的来思考,首先我们的初始方案是封装了一个createVoucherOrder方法,同时为了确保他线程安全,在方法上添加了一把synchronized 锁

加上悲观锁

@Override
    public Result seckillVoucher(Long voucherId) {

        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);

        if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀尚未开始!");
        }

        if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束!");
        }

        if (seckillVoucher.getStock() < 1) {
            return Result.fail("库存不足!");
        }

        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()) {
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId, userId);
        }
    }

    @Transactional
    @Override
    public Result createVoucherOrder(Long voucherId, Long userId) {

        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();

        if (count > 0) {
            return Result.fail("用户已经购买过!");
        }

        boolean success = seckillVoucherService.update().setSql("stock = stock -1")
                .eq("voucher_id", voucherId).gt("stock", 0).

                update();
        if (!success) {
            return Result.fail("库存不足!");
        }

        VoucherOrder voucherOrder = new VoucherOrder();
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);

        return Result.ok(orderId);
    }

在启动类加入以下注解,启动AspectJ

@EnableAspectJAutoProxy(exposeProxy = true)

以上代码,采用悲观锁解决了高并发下,一人多单的场景,同时,也解决了事务失效。引入了AspectJ解决!

Jmeter 测试

再次测试,查看结果

微服务 Spring Boot 整合Redis 实现优惠卷秒杀 一人一单

可见返回的结果异常率如此高,再看请求信息

微服务 Spring Boot 整合Redis 实现优惠卷秒杀 一人一单

可见已经成功的拦截了错误请求,JSON断言正确。

查看数据库 信息

微服务 Spring Boot 整合Redis 实现优惠卷秒杀 一人一单

优惠卷数量

微服务 Spring Boot 整合Redis 实现优惠卷秒杀 一人一单

可见成功的完成了 在高并发请求下 的一人一单功能。

⛵小结

以上就是【 Bug 终结者】对 微服务Spring Boot 整合Redis 实现优惠卷秒杀 一人一单 的简单介绍, 在分布式系统下,高并发的场景下,会出现此类库存超卖问题,本篇文章介绍了采用乐观锁来解决,但是依然是有弊端,下章节,我们将继续进行优化,持续关注!

如果这篇【文章】有帮助到你,希望可以给【 Bug 终结者】点个赞👍,创作不易,如果有对【 后端技术】、【 前端领域】感兴趣的小可爱,也欢迎关注❤️❤️❤️ 【 Bug 终结者】❤️❤️❤️,我将会给你带来巨大的【收获与惊喜】💝💝💝!

Original: https://blog.csdn.net/weixin_45526437/article/details/126926185
Author: Bug 终结者
Title: 微服务 Spring Boot 整合Redis 实现优惠卷秒杀 一人一单

原创文章受到原创版权保护。转载请注明出处:https://www.johngo689.com/560538/

转载文章受原作者版权保护。转载请注明原作者出处!

(0)

大家都在看

亲爱的 Coder【最近整理,可免费获取】👉 最新必读书单  | 👏 面试题下载  | 🌎 免费的AI知识星球