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

这篇具有很好参考价值的文章主要介绍了微服务 Spring Boot 整合Redis 实现优惠卷秒杀 一人一单。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

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

一、什么是全局唯一ID

⛅全局唯一ID

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

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

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

  • 全局唯一
  • 趋势有序

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

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

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

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

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

springboot 同步多商店优惠券,精通云原生,redis,spring boot,微服务,云原生,压力测试

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

springboot 同步多商店优惠券,精通云原生,redis,spring boot,微服务,云原生,压力测试

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) {
        // 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"));
        // 2.2.自增长
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);

        // 3.拼接并返回
        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就不再阻塞,统计出来的时间也就是所有分线程执行完后的时间。

二、环境准备

需要搭建登录环境,基础环境代码和sql文件均已上传 GitCode 链接:基础环境和SQL

三、实现秒杀下单

添加优惠卷

VoucherServiceImpl 核心代码

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


    // 该类无代码,直接MyBatis-Plus继承实现类 即可,自动完成持久化
    @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;
    
    /**
     * 新增秒杀券
     * @param voucher 优惠券信息,包含秒杀信息
     * @return 优惠券id
     */
    @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) {
        //1. 查询优惠卷
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        //2. 判断秒杀是否开始 开始时间大于当前时间表示未开始抢购
        if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀尚未开始!");
        }
        //3. 判断秒杀是否结束
        if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束!");
        }
        //4. 判断库存是否充足
        if (seckillVoucher.getStock() < 1) {
            return Result.fail("库存不足!");
        }

        Long userId = UserHolder.getUser().getId();
        //5. 查询订单
        //5.1 查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        //5.2 判断并返回
        if (count > 0) {
            return Result.fail("用户已经购买过!");
        }

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

        //7. 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        //8. 返回订单id
        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 新增以下接口

添加秒杀卷

springboot 同步多商店优惠券,精通云原生,redis,spring boot,微服务,云原生,压力测试

测试返回成功即可。

抢购秒杀优惠卷接口

springboot 同步多商店优惠券,精通云原生,redis,spring boot,微服务,云原生,压力测试

测试无误,抢购成功!

四、库存超卖问题

⏳问题分析

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

 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("库存不足!");
    }

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

springboot 同步多商店优惠券,精通云原生,redis,spring boot,微服务,云原生,压力测试

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

springboot 同步多商店优惠券,精通云原生,redis,spring boot,微服务,云原生,压力测试

悲观锁:

悲观锁可以实现对于数据的串行化执行,比如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无法执行成功

springboot 同步多商店优惠券,精通云原生,redis,spring boot,微服务,云原生,压力测试

⌚ 乐观锁解决库存超卖

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

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

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

知识拓展

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

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

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

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

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

springboot 同步多商店优惠券,精通云原生,redis,spring boot,微服务,云原生,压力测试

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

✅Jmeter 测试

添加线程组

springboot 同步多商店优惠券,精通云原生,redis,spring boot,微服务,云原生,压力测试

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

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

springboot 同步多商店优惠券,精通云原生,redis,spring boot,微服务,云原生,压力测试

加入以下判断

springboot 同步多商店优惠券,精通云原生,redis,spring boot,微服务,云原生,压力测试

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

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

启动,查看返回的结果

springboot 同步多商店优惠券,精通云原生,redis,spring boot,微服务,云原生,压力测试

查看聚合报告

springboot 同步多商店优惠券,精通云原生,redis,spring boot,微服务,云原生,压力测试

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

springboot 同步多商店优惠券,精通云原生,redis,spring boot,微服务,云原生,压力测试

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

springboot 同步多商店优惠券,精通云原生,redis,spring boot,微服务,云原生,压力测试

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) {
        //1. 查询优惠卷
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        //2. 判断秒杀是否开始 开始时间大于当前时间表示未开始抢购
        if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀尚未开始!");
        }
        //3. 判断秒杀是否结束
        if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束!");
        }
        //4. 判断库存是否充足
        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) {
        //5. 查询订单
        //5.1 查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        //5.2 判断并返回
        if (count > 0) {
            return Result.fail("用户已经购买过!");
        }

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


        //7. 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        //8. 返回订单id
        return Result.ok(orderId);
    }

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

@EnableAspectJAutoProxy(exposeProxy = true)

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

Jmeter 测试

再次测试,查看结果

springboot 同步多商店优惠券,精通云原生,redis,spring boot,微服务,云原生,压力测试

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

springboot 同步多商店优惠券,精通云原生,redis,spring boot,微服务,云原生,压力测试

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

查看数据库 信息

springboot 同步多商店优惠券,精通云原生,redis,spring boot,微服务,云原生,压力测试

优惠卷数量

springboot 同步多商店优惠券,精通云原生,redis,spring boot,微服务,云原生,压力测试

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

⛵小结

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

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

到了这里,关于微服务 Spring Boot 整合Redis 实现优惠卷秒杀 一人一单的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • Redis项目实战——优惠券秒杀

    如果用MySQL的自增长ID,ID的规律性太明显, 会暴漏一些信息 (比如销量等) 数据量太大时一张表存不下,需要多张表,MySQL多张表的自增长都是独立的, 会出现重复ID 需要一种在分布式系统下可以生成全局唯一ID的工具,必须唯一且递增 在某项目里,不管数据库的表有多少

    2024年02月10日
    浏览(36)
  • Spring Boot整合Redis实现订单超时处理

    🎉欢迎来到架构设计专栏~Spring Boot整合Redis实现订单超时处理 ☆* o(≧▽≦)o *☆嗨~我是IT·陈寒🍹 ✨博客主页:IT·陈寒的博客 🎈该系列文章专栏:架构设计 📜其他专栏:Java学习路线 Java面试技巧 Java实战项目 AIGC人工智能 数据结构学习 🍹文章作者技术和水平有限,如果文

    2024年02月03日
    浏览(41)
  • Spring Boot 整合SpringSecurity和JWT和Redis实现统一鉴权认证

    本文主要讲了Spring Security文章,如果有什么需要改进的地方还请大佬指出⛺️ 🎬作者简介:大家好,我是青衿🥇 ☁️博客首页:CSDN主页放风讲故事 🌄每日一句:努力一点,优秀一点 Spring Security Spring Security是一个强大且高度可定制的身份验证和访问控制框架。它是保护基

    2024年02月05日
    浏览(45)
  • Redis:原理速成+项目实战——Redis实战7(优惠券秒杀+细节解决超卖、一人一单问题)

    👨‍🎓作者简介:一位大四、研0学生,正在努力准备大四暑假的实习 🌌上期文章:Redis:原理速成+项目实战——Redis实战6(封装缓存工具(高级写法)缓存总结) 📚订阅专栏:Redis:原理速成+项目实战 希望文章对你们有所帮助 这篇文章写了很久。我自己在边实现、边用

    2024年01月24日
    浏览(95)
  • Spring Boot整合Redis

    Redis是一个开源(BSD许可)的、内存中的数据结构存储系统,它可以用作数据库、缓存和消息中间件,并提供多种语言的API。 Redis支持多种类型的数据结构,如 字符串(strings)、散列(hashes)、列表(lists)、集合(sets)、有序集合(sorted sets)与范围查询、bitmaps、 hyperlo

    2024年02月09日
    浏览(38)
  • 21 Spring Boot整合Redis

        目录 一、Redis简介 二、创建springboot整合redis工程 三、添加依赖 四、配置Yml 五、创建Redis配置类  六、创建Redis工具类,封装Redis的api 七、操作Redis  八、验证       简单来说 Redis 就是一个使用 C 语言开发的数据库,不过与传统数据库不同的是 Redis 的数据是存在内存中的

    2024年02月09日
    浏览(30)
  • spring boot 简单整合 Redis

    2024年02月12日
    浏览(45)
  • 微服务 Spring Boot Mybatis-Plus 整合 EasyPOI 实现 Excel 一对多 导入

    Excel导入 是 开发中 很常用的 功能 ,本篇 讲解 如何使用 Spring Boot + MyBatis -Plus 整合 EasyPOI 实现Excel 的一对多导入。 EasyPOI官网 采用 微服务 Spring Boot、Mybatis-Plus 整合 EasyPOI 实现Excel的一对多导入 Excel 导入 实现详细细节 前端采用 Vue+ElementUI 实现导入页面展示,要求 弹出上传框

    2024年02月03日
    浏览(49)
  • Spring Boot整合Redis的高效数据缓存实践

    引言 在现代Web应用开发中,数据缓存是提高系统性能和响应速度的关键。Redis作为一种高性能的缓存和数据存储解决方案,被广泛应用于各种场景。本文将研究如何使用Spring Boot整合Redis,通过这个强大的缓存工具提高应用的性能和可伸缩性。 整合redis,需要先安装redis Redis 

    2024年01月22日
    浏览(63)
  • 【Spring Boot+Kafka+Mysql+HBase】实现分布式优惠券后台应用系统(附源码)

    需要全部代码请点赞关注收藏后评论区留言私信~~~ 分布式优惠券后台应用系统服务于两类用户群体,一类是商户,商户可以根据自己的实际情况即进行优惠券投放,另一类是平台消费用户,用户可以去领取商户发放的优惠券 分布式优惠券后台应用系统采用SpringBoot作为主体开

    2024年02月10日
    浏览(41)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包