Redis企业开发实战(三)——点评项目之优惠券秒杀

news/2025/2/9 1:32:54/

目录

一、全局唯一ID 

(一)概述 

(二)全局ID生成器 

(三)全局唯一ID生成策略 

1. UUID (Universally Unique Identifier)

2. 雪花算法(Snowflake)

3. 数据库自增

4. Redis INCR/INCRBY

5.总结

(四)Redis实现全局唯一ID

1.工具类

2.测试类

3.关于countdownlatch 

二、实现优惠券秒杀下单

三、超卖问题

(一)超卖问题出现的原因

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

1.悲观锁

2.乐观锁

3.乐观锁的CAS

4.CAS的自旋 

4.1如何缓解自旋压力

(三)超卖问题总结

四、超领问题(一人一单)

(一)需求说明

(二)悲观锁解决单机情况的超领问题

(三)特别说明!!!

(四)集群模式下的超领问题

1.模拟集群模式

2.断点调试 

3.运行结果 

4.结果分析——锁监视器


一、全局唯一ID 

(一)概述 

        每个店铺都可以发布优惠券:

        当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题:

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

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

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

(二)全局ID生成器 

        全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:唯一性、高可用、高性能、递增性、安全性。 

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

ID的类型:Long类型,8个字节,64位

ID的组成部分:

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

(三)全局唯一ID生成策略 

常见的全局唯一ID生成策略及其优缺点和适用场景:

1. UUID (Universally Unique Identifier)

实现方式

        UUID通常是一个128位的值,可以通过多种算法生成,如基于时间戳、随机数或MAC地址等。

优点

        简单易用:无需额外的基础设施支持。

        高可用性:由于其生成机制,几乎可以保证全球唯一性。

        无中心化管理:可以在任何地方独立生成,非常适合分布式环境。

缺点

        长度较长:标准的UUID为128位,占用较多存储空间。

        性能问题:对于某些高性能要求的应用场景,生成速度可能成为瓶颈。

        不可排序:UUID不具备自然的时间顺序,不利于按时间排序查询。

使用场景

        适合于对唯一性有严格要求但对排序和性能要求不高的场景,例如日志记录、会话标识等。

2. 雪花算法(Snowflake)

实现方式

        Twitter Snowflake是一种分布式ID生成算法,生成64位的整型ID,包含时间戳、机器ID、数据中心ID和序列号。

优点

        高效:能够快速生成大量唯一的ID。

        有序性:生成的ID按时间顺序递增,有利于数据库索引优化。

        可扩展性:支持分布式部署,每个节点都可以独立生成ID。

缺点

        依赖时钟同步:如果服务器之间的时间不同步,可能会导致ID冲突。

        复杂度较高:需要考虑机器ID分配、数据中心配置等问题。

使用场景

        适合于高并发环境下需要快速生成有序ID的场景,如订单编号生成、用户ID生成等。

3. 数据库自增

实现方式

        利用关系型数据库提供的自增字段功能来生成ID。

优点

        简单直接:实现起来非常简单,直接利用数据库特性。
        天然有序:自增ID天然具有顺序性,便于后续处理。

缺点

        单点故障风险:如果使用单一数据库实例,则存在单点故障的风险。
        不适合分布式系统:在分布式环境下,难以保证全局唯一性且性能受限。

使用场景

        适合于小型应用或不需要高度分布式的场景。对于需要极高可靠性的大型分布式系统,通常需要结合其他技术(如分段分配)使用。

4. Redis INCR/INCRBY

实现方式

        通过Redis的INCR或INCRBY命令来生成自增ID。

优点

        高性能:Redis作为内存数据库,操作速度快。

        原子性:INCR操作是原子性的,确保ID唯一性。

        易于扩展:可以方便地与现有Redis集群集成。

缺点

        有限范围:虽然Redis支持64位整数,但对于某些超大规模的应用仍可能不足。
        外部依赖:增加了对Redis服务的依赖,若Redis出现故障会影响ID生成。

使用场景

        适用于中等到大规模应用中需要高效生成唯一ID的情况,尤其是那些已经使用了Redis作为缓存或其他用途的项目。

5.总结

  1. UUID:适合去中心化的应用,对唯一性和简易性有较高要求的场合。
  2. 雪花算法:适用于高并发、分布式环境下的有序ID生成需求。
  3. 数据库自增:简单直接,但更适合于非分布式或者规模较小的应用。
  4. Redis INCR:提供了高效的ID生成方案,并且容易与现有的Redis架构集成,适合中到大规模应用。

        选择哪种方法取决于具体的应用需求、系统架构以及性能要求等因素。在实际开发过程中,也可以根据具体情况混合使用上述方法以达到最佳效果。

(四)Redis实现全局唯一ID

Redis自增ID策略:

  • 每天一个key,方便统计订单量
  • ID构造是时间戳+计数器

1.工具类

@Component
@Slf4j
public class RedisIdWorker {@Resourceprivate StringRedisTemplate stringRedisTemplate;// 初始时间戳private static final long BEGIN_TIMESTAMP = 1640995200L;// 位数private static final int COUNT_BITS = 32;public long nextId(String keyPrefix) {// 1.生成时间戳// 获取当前时间戳,单位为秒,使用当前时间戳减去初始时间戳LocalDateTime now = LocalDateTime.now();long nowEpochSecond = now.toEpochSecond(ZoneOffset.UTC);long currentTimeStamp = nowEpochSecond - BEGIN_TIMESTAMP;// 2.生成序列号,使用redis的自增长// 2.1 获取当前日期,精确到天,好处是:避免超过32位的上限,和方便按照日期查询String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));// 这里不会存在空指针问题,long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);// 3.使用位运算拼接并返回/*** 或运算:* 将两个数转为二进制,对于每个位,如果两个相应的位有一个为 1,则结果位为 1;否则为 0* 这里左移32位后,剩余的32位全部为0,使用或运算,存放的就全部是序列号了*/return currentTimeStamp << COUNT_BITS | count;}public static void main(String[] args) {LocalDateTime time = LocalDateTime.of(2022, 1, 1, 0, 0, 0);long epochSecond = time.toEpochSecond(ZoneOffset.UTC);System.out.println(epochSecond); // 1640995200}
}

2.测试类

@SpringBootTest
class HmDianPingApplicationTests {@Resourceprivate CacheClient cacheClient;@Resourceprivate RedisIdWorker redisIdWorker;// 线程池private ExecutorService es = Executors.newFixedThreadPool(500);@Testvoid testIdWorker() throws InterruptedException {CountDownLatch latch = new CountDownLatch(300);Runnable task = () -> {// 生成100个idfor (int i = 0; i < 100; i++) {long id = redisIdWorker.nextId("order");System.out.println("id = " + id);}latch.countDown();};long start = System.currentTimeMillis();// 将任务提交300次,会生成30000个idfor (int i = 0; i < 300; i++) {es.submit(task);}latch.await();long end = System.currentTimeMillis();System.out.println("time=" + (end - start)); // time=1978毫秒}
}

3.关于countdownlatch 

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

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

CountDownLatch 中有两个最重要的方法

1、countDown

2、await

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

运行结果: 

 

二、实现优惠券秒杀下单

下单时需要判断两点:

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

 

@Override
public Result seckillVoucher(Long voucherId) {// 查询优惠券是否存在SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);// 查询秒杀是否开始LocalDateTime beginTime = seckillVoucher.getBeginTime();if (beginTime.isAfter(LocalDateTime.now())) {return Result.fail("秒杀尚未开始");}// 查询秒杀是否结束LocalDateTime endTime = seckillVoucher.getEndTime();if (endTime.isBefore(LocalDateTime.now())) {return Result.fail("秒杀已经结束");}// 判断库存是否充足Integer stock = seckillVoucher.getStock();if (stock < 1) {return Result.fail("库存不足");}// 扣减库存seckillVoucher.setStock(stock - 1);boolean success = seckillVoucherService.updateById(seckillVoucher);if (!success){return Result.fail("库存不足");}// 创建订单VoucherOrder voucherOrder = new VoucherOrder();// 订单idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);// 用户idvoucherOrder.setUserId(UserHolder.getUser().getId());// 代金券idvoucherOrder.setVoucherId(voucherId);// 创建订单详情save(voucherOrder);// 返回订单idreturn Result.ok(orderId);
}

三、超卖问题

(一)超卖问题出现的原因

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

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

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

1.悲观锁

        认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。例如Synchronized、Lock都属于悲观锁。

2.乐观锁

        认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时,去判断有没有其他线程对数据做了修改。

  • 如果没有修改,则认为是安全的,才会进行数据更新;
  • 如果已经被其它线程修改,说明发生了线程安全问题,此时可以重试或者抛异常。

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

 

3.乐观锁的CAS

        CAS(Compare-And-Swap)是一个底层的原子操作,它可以被用来实现乐观锁。CAS操作能够确保只有当预期值与内存中的当前值相等时,才会进行更新,否则更新失败。这种特性非常适合于实现乐观锁,特别是在无锁编程中。

        在本项目中,CAS不设置版本号,因为版本号的操作和库存的操作是一样的,所以使用stock库存代替版本号。

 

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

4.CAS的自旋 

        “CAS操作本质上是一个原子操作,它尝试比较内存位置的当前值与预期值是否相同,如果相同,则更新为新值;否则,不进行任何修改,并返回失败。

        当多个线程同时试图对同一变量执行CAS操作时,只有一个线程能够成功更新该变量,其余线程将收到失败的结果。为了实现乐观锁或其他无锁算法,这些失败的线程通常会进入一个循环,反复尝试直到成功更新为止。这个过程被称为“自旋”。

4.1如何缓解自旋压力
  1. 退避策略:可以在每次CAS失败后引入短暂的休眠或等待时间(如使用Thread.yield()Thread.sleep(n)),以减少CPU的占用率。这种方式可以降低自旋频率,但也会增加总的延迟。

  2. 限制重试次数:设定一个最大重试次数,超过该次数则采取其他措施,比如回退并重新排队或者抛出异常让上层逻辑处理。

  3. 结合锁机制:在极端高争用的情况下,考虑切换回传统的锁机制(如互斥锁),尽管这会牺牲一些并发性,但可以避免过度的自旋压力。

  4. 优化数据结构设计:通过优化共享数据结构的设计来减少热点争用点,例如分片存储、局部化访问等方法,可以有效降低CAS操作的竞争程度。

  5. 使用更高级别的同步工具:现代编程语言和框架提供了许多高级别的同步原语(如读写锁、信号量等),它们能够在不同场景下提供更好的性能和灵活性。

(三)超卖问题总结

1.悲观锁:添加同步锁,让线程串行执行

  • 优点:简单粗暴
  • 缺点:性能一般

2.乐观锁:不加锁,在更新时判断是否有其它线程在修改

  • 优点:性能好
  • 缺点:存在成功率低的问题

四、超领问题(一人一单)

(一)需求说明

需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单。

        乐观锁是在更新数据的时候使用的,而这里的领取优惠券是插入数据,每个用户领取一个优惠券会新增一条优惠券订单,因此需要使用悲观锁来解决。从查询订单,到判断订单,最后到新增订单都要放在锁里面。

(二)悲观锁解决单机情况的超领问题

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {@Resourceprivate ISeckillVoucherService seckillVoucherService;@Resourceprivate RedisIdWorker redisIdWorker;@Overridepublic Result seckillVoucher(Long voucherId) {// 查询优惠券是否存在SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);if (seckillVoucher == null) {return Result.fail("优惠券不存在");}// 查询秒杀是否开始LocalDateTime beginTime = seckillVoucher.getBeginTime();if (beginTime.isAfter(LocalDateTime.now())) {return Result.fail("秒杀尚未开始");}// 查询秒杀是否结束LocalDateTime endTime = seckillVoucher.getEndTime();if (endTime.isBefore(LocalDateTime.now())) {return Result.fail("秒杀已经结束");}// 判断库存是否充足Integer stock = seckillVoucher.getStock();if (stock < 1) {return Result.fail("库存不足");}Long userId = UserHolder.getUser().getId();synchronized (userId.toString().intern()) {// 获取和事务有关的代理对象IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();// 返回订单idreturn proxy.createVoucherOrder(voucherId);}}@Transactionalpublic Result createVoucherOrder(Long voucherId) {// 同一个用户加锁Long userId = UserHolder.getUser().getId();// 一人一单long count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();if (count > 0) {return Result.fail("用户已经购买过一次了");}// 扣减库存
//        seckillVoucher.setStock(stock - 1);
//        boolean success = seckillVoucherService.updateById(seckillVoucher);/*** 为什么有两个 update()* 第一个 update() 实际上是 MyBatis-Plus 提供的一个便捷入口,用来开始构建更新操作的链式调用。* 第二个 update() 是真正执行数据库更新的方法,它基于之前通过链式调用定义的所有条件和设置来进行更新。*/boolean success = seckillVoucherService.update().setSql("stock = stock - 1") // set stock = stock - 1.eq("voucher_id", voucherId).gt("stock", 0) // where voucher_id = ? and stock > 0.update();if (!success) {return Result.fail("库存不足");}// 创建订单VoucherOrder voucherOrder = new VoucherOrder();// 订单idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);// 用户idvoucherOrder.setUserId(userId);// 代金券idvoucherOrder.setVoucherId(voucherId);// 创建订单详情save(voucherOrder);return Result.ok(orderId);}
}
public interface IVoucherOrderService extends IService<VoucherOrder> {Result seckillVoucher(Long voucherId);Result createVoucherOrder(Long voucherId);
}

引入依赖:

<dependency><groupId>org.aspectj</groupId><artifactId>aspectjweaver</artifactId>
</dependency>

启动类暴露代理对象:

@EnableAspectJAutoProxy(exposeProxy = true) // 暴露代理对象
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {public static void main(String[] args) {SpringApplication.run(HmDianPingApplication.class, args);}
}

(三)特别说明!!!

 

        首先,这里使用了悲观锁,保证必须先获取锁,再执行调用的事务操作,最后才会释放锁,保证了安全性,不会发生事务未提交,锁就被释放的情况。

        其次, 在同一个类内直接调用另一个带有@Transactional注解的方法,如果这个调用是在同一个实例内完成的(即非代理调用),则事务不会生效。这是因为直接调用未经过代理对象,所以Spring无法插入事务管理逻辑。Spring的事务管理是基于AOP实现的,Spring AOP默认使用的是JDK动态代理,它只能代理接口中的方法或公开的方法(即public方法)。

         解决方案就是:通过AopContext.currentProxy()在同一个类内获取代理对象并调用目标方法,以此来确保事务管理等AOP增强能够在自我调用的情况下也生效。

IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
// 返回订单id
return proxy.createVoucherOrder(voucherId);

        并且,使用代理对象必须暴露代理对象,在启动类上添加@EnableAspectJAutoProxy(exposeProxy = true)

        最后,引入org.aspectj:aspectjweaver依赖主要是为了支持 AspectJ 的编织功能,特别是在Spring应用中启用 AspectJ 的加载时编织。

(四)集群模式下的超领问题

1.模拟集群模式

启动这两个类,模拟两个节点的集群 

 

修改nginx.conf文件:

保存后,CMD窗口重新加载nginx 

nginx.exe -s reload

 

重复刷新该链接:http://localhost:8080/api/voucher/list/1

8081和8082的控制台都会有输出,轮询访问到两个端口,当前nginx已经有了负载均衡的效果了。 

2.断点调试 

在这两个地方打断点:

 

ApiFox设置两个一样的接口,分别访问

3.运行结果 

会注意到两次访问都会进入锁内,判断count=0,放行所有断点后,优惠券会有两个订单,同时库存也会减少2个

 

4.结果分析——锁监视器

        由于现在我们部署了多个tomcat,每个tomcat都有一个属于自己的jvm,那么假设在服务器A的tomcat内部,有两个线程,这两个线程由于使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的。

        但是如果现在是服务器B的tomcat内部,又有两个线程,但是他们的锁对象写的虽然和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2实现互斥。

        这就是集群环境下,syn锁失效的原因,在这种情况下,我们就需要使用分布式锁来解决这个问题。


http://www.ppmy.cn/news/1570469.html

相关文章

预训练语言模型(笔记)

笔记来源&#xff1a;Transformer、GPT、BERT&#xff0c;预训练语言模型的前世今生&#xff08;目录&#xff09; - B站-水论文的程序猿 - 博客园 预训练语言模型的发展并不是一蹴而就的&#xff0c;而是伴随着诸如词嵌入、序列到序列模型及 Attention 的发展而产生的。 一、…

7.PPT:“中国梦”学习实践活动【20】

目录 NO1234​ NO5678​ NO9\10\11 NO1234 考生文件夹下创建一个名为“PPT.pptx”的新演示文稿Word素材文档的文字&#xff1a;复制/挪动→“PPT.pptx”的新演示文稿&#xff08;蓝色、黑色、红色&#xff09; 视图→幻灯片母版→重命名&#xff1a;“中国梦母版1”→背景样…

【Elasticsearch】Global 聚合

7. 总结全局聚合是 Elasticsearch 中一个非常强大的工具&#xff0c;它允许你在执行特定查询的同时&#xff0c;对整个数据集进行统计分析。通过合理使用全局聚合&#xff0c;可以实现多维度的数据分析&#xff0c;帮助你更好地理解数据的整体情况和特定条件下的差异。希望这些…

Facebook矩阵营销:多维度布局,精准打击

随着社交媒体的迅猛发展&#xff0c;企业和品牌在数字营销中的竞争愈发激烈。Facebook&#xff0c;作为全球最大的社交平台之一&#xff0c;已成为了品牌推广的关键阵地之一。然而&#xff0c;仅仅依靠单一的Facebook页面进行营销已经无法满足品牌发展的需求&#xff0c;如何通…

微信小程序~电器维修系统小程序

博主介绍&#xff1a;✌程序猿徐师兄、8年大厂程序员经历。全网粉丝15w、csdn博客专家、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java技术领域和毕业项目实战✌ &#x1f345;文末获取源码联系&#x1f345; &#x1f447;&#x1f3fb; 精彩专栏推荐订阅&#x1f447;…

@emotion/styled / styled-components创建带有样式的 React 组件

一、安装依赖 npm install emotion/styled styled-components 二、使用 import styled from emotion/styled; import styled from styled-components;// 创建一个带样式的按钮 const StyledButton styled.buttonbackground-color: #4caf50;color: white;padding: 10px 20px…

微服务知识——微服务拆分规范

文章目录 一、微服务拆分规范1、高内聚、低耦合2、服务拆分正交性原则3、服务拆分层级最多三层4、服务粒度适中、演进式拆分5、避免环形依赖、双向依赖6、通用化接口设计&#xff0c;减少定制化设计7、接口设计需要严格保证兼容性8、将串行调用改为并行调用&#xff0c;或者异步…

C#中的委托(Delegate)

什么是委托? 首先,我们要知道C#是一种强类型的编程语言,强类型的编程语言的特性,是所有的东西都是特定的类型 委托是一种存储函数的引用类型,就像我们定义的一个 string str 一样,这个 str 变量就是 string 类型. 因为C#中没有函数类型,但是可以定义一个委托类型,把这个函数…