文章目录

  • 一、全局唯一ID
    • 1. 全局ID生成器
    • 2. 全局唯一ID生成策略
    • 3. Redis自增ID策略
  • 二、实现优惠券秒杀下单
    • 1. 添加优惠券
    • 2. 编写添加秒杀券的接口
  • 三、实现秒杀下单
  • 四、超卖问题
    • 1. 加锁方式 - 乐观锁
      • (1)版本号法
      • (2)CAS法
    • 2. 乐观锁解决超卖问题
    • 3. 小结
  • 五、一人一单问题
    • 1. 加锁分析
    • 2. 事务分析
  • 六、集群模式下并发安全问题

一、全局唯一ID

1. 全局ID生成器

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


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

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

所以tb_voucher_order表的主键不能用自增ID:

create table tb_voucher_order
(id          bigint                                        not null comment '主键'primary key,user_id     bigint unsigned                               not null comment '下单的用户id',voucher_id  bigint unsigned                               not null comment '购买的代金券id',pay_type    tinyint(1) unsigned default 1                 not null comment '支付方式 1:余额支付;2:支付宝;3:微信',status      tinyint(1) unsigned default 1                 not null comment '订单状态,1:未支付;2:已支付;3:已核销;4:已取消;5:退款中;6:已退款',create_time timestamp           default CURRENT_TIMESTAMP not null comment '下单时间',pay_time    timestamp                                     null comment '支付时间',use_time    timestamp                                     null comment '核销时间',refund_time timestamp                                     null comment '退款时间',update_time timestamp           default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间'
);

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

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

D的组成部分:

  • 符号位:1bit,永远为0,表示正数

  • 时间戳:31bit,以秒为单位,可以使用69年

  • 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID

编写全局ID生成器代码:

@Component
public class RedisIdWorker {/*** 开始时间戳,以2022.1.1为基准计算时间差*/private static final long BEGIN_TIMESTAMP = 1640995200L;/*** 序列号的位数*/private static final int COUNT_BITS = 32;private StringRedisTemplate stringRedisTemplate;public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}/*** 生成带有业务前缀的redis自增id* @param keyPrefix* @return*/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.拼接并返回// 用于是数字类型的拼接,所以不能像拼接字符串那样处理,而是通过位运算将高32位存 符号位+时间戳,低32位存 序列号return timestamp << COUNT_BITS | count;}public static void main(String[] args) {LocalDateTime time = LocalDateTime.of(2022, 1, 1, 0, 0, 0);long second = time.toEpochSecond(ZoneOffset.UTC);System.out.println(second);// 1640995200}
}

测试全局ID生成器:

@SpringBootTest
class HmDianPingApplicationTests {@Resourceprivate RedisIdWorker redisIdWorker;private ExecutorService executorService = Executors.newFixedThreadPool(500);@Testvoid testIdWorker() throws InterruptedException {CountDownLatch latch = new CountDownLatch(300);// 每个线程生成100个idRunnable task = () -> {for (int i = 0; i < 100; i++) {long id = redisIdWorker.nextId("order");System.out.println("id = " + id);}latch.countDown();};// 300个线程long begin = System.currentTimeMillis();for (int i = 0; i < 300; i++) {executorService.submit(task);}latch.await();long end = System.currentTimeMillis();System.out.println("time = " + (end - begin));}
}

测试结果:

2. 全局唯一ID生成策略

  • UUID(不是递增的)
  • Redis自增
  • 雪花算法(snowflake)
  • 数据库自增(单独建一张表存自增id,分配到分库分表后的表中)

3. Redis自增ID策略

  • 以日期作为前缀的key,方便统计订单量
  • 自增ID的结构:时间戳 + 计数器

二、实现优惠券秒杀下单

1. 添加优惠券

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


优惠券表信息:

  • tb_voucher:优惠券的基本信息,优惠金额、使用规则等(tb_voucher表的type字段区分是普通券还是秒杀券)
  • tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间(秒杀券才需要填写这些信息),同时秒杀券拥有普通券的基本信息(秒杀券表tb_seckill_voucher的主键id绑定的是普通券表tb_voucher的id)
create table tb_voucher
(id           bigint unsigned auto_increment comment '主键'primary key,shop_id      bigint unsigned                               null comment '商铺id',title        varchar(255)                                  not null comment '代金券标题',sub_title    varchar(255)                                  null comment '副标题',rules        varchar(1024)                                 null comment '使用规则',pay_value    bigint(10) unsigned                           not null comment '支付金额,单位是分。例如200代表2元',actual_value bigint(10)                                    not null comment '抵扣金额,单位是分。例如200代表2元',type         tinyint(1) unsigned default 0                 not null comment '0,普通券;1,秒杀券',status       tinyint(1) unsigned default 1                 not null comment '1,上架; 2,下架; 3,过期',create_time  timestamp           default CURRENT_TIMESTAMP not null comment '创建时间',update_time  timestamp           default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间'
);
create table tb_seckill_voucher
(voucher_id  bigint unsigned                     not null comment '关联的优惠券的id'primary key,stock       int(8)                              not null comment '库存',create_time timestamp default CURRENT_TIMESTAMP not null comment '创建时间',begin_time  timestamp default CURRENT_TIMESTAMP not null comment '生效时间',end_time    timestamp default CURRENT_TIMESTAMP not null comment '失效时间',update_time timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间'
)comment '秒杀优惠券表,与优惠券是一对一关系';

2. 编写添加秒杀券的接口

主要代码:

@RestController
@RequestMapping("/voucher")
public class VoucherController {@Resourceprivate IVoucherService voucherService;/*** 新增秒杀券* @param voucher 优惠券信息,包含秒杀信息* @return 优惠券id*/@PostMapping("seckill")public Result addSeckillVoucher(@RequestBody Voucher voucher) {voucherService.addSeckillVoucher(voucher);return Result.ok(voucher.getId());}
}
@Service
public class VoucherServiceImpl extends ServiceImpl<VoucherMapper, Voucher> implements IVoucherService {@Resourceprivate ISeckillVoucherService seckillVoucherService;@Override@Transactionalpublic 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);}
}

测试添加:

测试结果:

三、实现秒杀下单

下单时需要判断两点:

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

主要代码:

@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {@Resourceprivate IVoucherOrderService voucherOrderService;@PostMapping("seckill/{id}")public Result seckillVoucher(@PathVariable("id") Long voucherId) {return voucherOrderService.seckillVoucher(voucherId);}
}
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {@Resourceprivate ISeckillVoucherService seckillVoucherService;@Resourceprivate RedisIdWorker redisIdWorker;@Overridepublic 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();// 6.1.订单idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);// 6.2.用户idLong userId = UserHolder.getUser().getId();voucherOrder.setUserId(userId);// 6.3.代金券idvoucherOrder.setVoucherId(voucherId);save(voucherOrder);return Result.ok(orderId);}
}

简单测试秒杀成功:


扣减库存成功:

四、超卖问题

当有大量请求同时访问时,就会出现超卖问题


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

1. 加锁方式 - 乐观锁

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

(1)版本号法

(2)CAS法

  • 用库存代替了版本号,可以少加一个字段
  • 扣库存时,与查询时的库存比较,没被修改则可以扣减库存

2. 乐观锁解决超卖问题

乐观锁方式,通过CAS判断前后库存是否一致,解决超卖问题:

// 之前的代码
boolean success = seckillVoucherService.update().setSql("stock= stock -1").eq("voucher_id", voucherId).update();// 乐观锁方式,通过CAS判断前后库存是否一致,解决超卖问题
boolean success = seckillVoucherService.update().setSql("stock= stock -1") // set stock = stock -1.eq("voucher_id", voucherId).eq("stock",voucher.getStock()).update(); // where id = ? and stock = ?

又出现新的问题:

  • 假设100个线程同时请求,但通过CAS判断后,只有一个线程能扣减库存成功,其余99个线程全部失败
  • 此时,库存剩余99,但是实际业务可以满足其余99个线程扣减库存
  • 虽然能解决超卖问题,但是设计不合理

所以为了解决失败率高的问题,需要进一步改进:

  • 通过CAS 不再 判断前后库存是否一致,而是判断库存是否大于0
boolean success = seckillVoucherService.update().setSql("stock= stock -1").eq("voucher_id", voucherId).gt("stock",0).update(); // where id = ? and stock > 0

3. 小结

超卖这样的线程安全问题,解决方案有哪些?
(1)悲观锁:添加同步锁,让线程串行执行

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

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

  • 优点:性能相对悲观锁好(但是仍然需要同时查数据库,影响性能)
  • 缺点:存在成功率低的问题(可以采用分段锁方式提高成功率)

五、一人一单问题

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


在扣减库存之前,加上一人一单的逻辑:

// 5.一人一单逻辑
Long userId = UserHolder.getUser().getId();// 5.1.查询订单数量int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();// 5.2.判断是否下过单if (count > 0) {// 用户已经购买过了return Result.fail("用户已经购买过一次!");}

此处仍会出现并发问题,当同一用户模拟大量请求同时查询是否下过单时,如果正好都查询出count为0,就会跳过判断继续执行扣减库存的逻辑,此时就会出现一人下多单的问题

解决方法:

  • 由于是判断查询的数据是否存在,而不是像之前判断查询的数据是否修改过
  • 所以这里只能加悲观锁

1. 加锁分析

  • 首先将一人一单之后的逻辑全部加锁,所以将一人一单之后的逻辑抽取出一个方法进行加锁,public Result createVoucherOrder(Long voucherId)
  • 如果直接在方法上加锁,则锁的是this对象,锁的对象粒度过大,就算是不同的人执行都会阻塞住,影响性能,public synchronized Result createVoucherOrder(Long voucherId)
  • 所以将锁的对象改为userId,但是不能直接使用synchronized (userId),因为每次执行Long userId = UserHolder.getUser().getId();虽然值一样,但是对象不同,因此需要这样加锁 synchronized (userId.toString().intern()),intern()表示每次从字符串常量池中获取,这样值相同时,对象也相同
  • 为了防止事务还没提交就释放锁的问题,则不能将锁加在createVoucherOrder方法内部,例如:
@Transactional
public Result createVoucherOrder(Long voucherId) {synchronized (userId.toString().intern()) {。。。}
}

而是需要等事务提交完再释放锁,例如:

synchronized (userId.toString().intern()) {// 获取代理对象(事务)IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();return proxy.createVoucherOrder(voucherId);
}

2. 事务分析

  • 由于只有一人一单之后的逻辑涉及到修改数据库,所以只需对该方法加事务
    @Transactional
    public Result createVoucherOrder(Long voucherId)
  • 由于只对createVoucherOrder方法加了事务,而该方法是在seckillVoucher方法中被调用,seckillVoucher方法又没有加事务,为了防止事务失效,则不能直接在seckillVoucher方法调用createVoucherOrder方法,例如:
@Override
public Result seckillVoucher(Long voucherId) {。。。。synchronized (userId.toString().intern()) {return this.createVoucherOrder(voucherId);}
}

而是需要通过代理对象调用createVoucherOrder方法,因为@Transactional事务注解的原理是通过获取代理对象执行目标对象的方法,进行AOP操作,所以需要这样:

@Override
public Result seckillVoucher(Long voucherId) {。。。。// 获取代理对象(事务)IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();return proxy.createVoucherOrder(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);}
}

完整VoucherOrderServiceImpl代码:

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {@Resourceprivate ISeckillVoucherService seckillVoucherService;@Resourceprivate RedisIdWorker redisIdWorker;@Overridepublic 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("库存不足!");}Long userId = UserHolder.getUser().getId();synchronized (userId.toString().intern()) {// 获取代理对象(事务)IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();return proxy.createVoucherOrder(voucherId);}}@Transactionalpublic Result createVoucherOrder(Long voucherId) {// 5.一人一单逻辑Long userId = UserHolder.getUser().getId();// 5.1.查询订单数量int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();// 5.2.判断是否下过单if (count > 0) {// 用户已经购买过了return Result.fail("用户已经购买过一次!");}// 6,扣减库存// 乐观锁方式,通过CAS判断库存是否大于0,解决超卖问题:boolean success = seckillVoucherService.update().setSql("stock= stock -1").eq("voucher_id", voucherId).gt("stock",0).update(); // where id = ? and stock > 0if (!success) {// 扣减库存失败return Result.fail("库存不足!");}// 7.创建订单VoucherOrder voucherOrder = new VoucherOrder();// 7.1.订单idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);// 7.2.用户idvoucherOrder.setUserId(userId);// 7.3.代金券idvoucherOrder.setVoucherId(voucherId);save(voucherOrder);// 8.返回订单idreturn Result.ok(orderId);}}

六、集群模式下并发安全问题

  • 通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。
  1. 我们将服务启动两份,端口分别为8081和8082:
  2. 然后修改nginx的conf目录下的nginx.conf文件,配置反向代理和负载均衡:

    修改完后,重新加载nginx配置文件:

    配置2个端口不同的服务:


    模拟同一用户下单2次:

现在,用户请求会在这两个节点上负载均衡,再次测试下是否存在线程安全问题:

  • 访问8081端口的线程进入了synchronized中

  • 访问8082端口的线程也进入了synchronized中

  • 最终同一个用户下了2单扣了2个库存,所以在集群模式下,出现了一人多单的问题:

    分析:

  • 锁的原理是每个JVM中都有一个Monitor作为锁对象,所以当对象相同时,获取的就是同一把锁

  • 但是不同的JVM中的Monitor不同,所以获取的不是同一把锁

  • 因此集群模式下,加synchronized锁也会出现并发安全问题,需要加分布式锁

Redis(八) - Redis企业实战之优惠券秒杀相关推荐

  1. Redis实战篇--优惠券秒杀

    文章目录 Redis实战篇--优惠券秒杀 全局唯一ID 实现优惠券秒杀下单 超卖问题 一人一单 分布式锁 基于redis的分布式锁 Redis实战篇–优惠券秒杀 全局唯一ID 为什么需要全局唯一id? ...

  2. 实战篇--优惠券秒杀

    优惠券秒杀 全局唯一ID 当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题: id的规律性太明显 受单表数据量限制 全局ID生成 ...

  3. SpringBoot整合Redis实现优惠券秒杀服务(笔记+优化思路版)

    本文属于看黑马的redis的学习笔记,记录了思路和优化流程,精简版最终版请点击这里查看. 文章目录 一.全局ID生成器 1.1 理论 1.1.1 全局唯一ID生成策略 1.2 代码(Redis自增) ...

  4. 【Redis】Redis实战:黑马点评之优惠券秒杀

    Redis实战:黑马点评之优惠券秒杀 1 全局唯一ID 1.1全局唯一ID 每个店铺都可以发布优惠券: 当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据 ...

  5. Redis实战——优惠券秒杀(超卖问题)

    1 实现优惠券秒杀功能 下单时需要判断两点:1.秒杀是否开始或者结束2.库存是否充足 所以,我们的业务逻辑如下 1. 通过优惠券id获取优惠券信息 2.判断秒杀是否开始,如果未返回错误信息 3.判断秒 ...

  6. Redis实战11-实现优惠券秒杀下单

    本篇,咱们来实现优惠券秒杀下单功能.通过本篇学习,我们将会有如下收获: 1:优惠券领券业务逻辑: 2:分析在高并发情况下,出现超卖问题产生的原因: 3:解决超卖问题两种方案:版本号法及CAS法 4:乐 ...

  7. Redis应用案例之优惠券秒杀

    概述 秒杀下单流程 下单核心思路:当我们点击抢购时,会触发右侧的请求,我们只需要编写对应的controller即可. 秒杀下单应该思考的内容: 下单需要判断两点: 秒杀是否开始或结束,如果尚未开始或已 ...

  8. Redis教程--redis分布式锁+企业解决方案+redis实战

    Redis,目前全国甚至是全球最常用的缓存中间件之一,在现在公司的开发中,可以说是离不开Redis. 在企业越来越注重用户体验的今天,Redis因具有高性能.高响应的特性,大大提升应用的响应速度和用户 ...

  9. 【Redis企业实战】仿黑马点评项目

    目录 一.短信登陆:基于Redis实现共享session实现登录 1.发送短信验证码 2.短信验证码登录.注册 3.校验登陆状态 二.商户查询缓存 1.添加Redis缓存 2.缓存更新策略: 3.缓存 ...

最新文章

  1. 华为巨资收购为云计算趟平道路?
  2. .Net转Java自学之路—基础巩固篇一
  3. Expo大作战(十八)--expo如何发布成独立应用程序,打包成apk或者ipa,发布到对应应用商店...
  4. 干货丨综述卷积神经网络:从基础技术到研究前景
  5. CodeForces - 707C
  6. 如何一站式解决“人财物事”管理难题?
  7. Oracle GoldenGate Logdump工具简要说明
  8. syslog()的基本用法
  9. php swoole 项目实战,Laravel 中使用 swoole 项目实战开发案例一 (建立 swoole 和前端通信)...
  10. Labview 模型导入Veristand问题
  11. python输出今天时间_Python获取并输出当前日期时间
  12. teamcity mysql 配置_TeamCity : Build 基本配置
  13. 基于OpenCV的三维数据点的曲面重构_MySurefaceReconstruction
  14. Social Influence as Intrinsic Motivation for Multi-Agent Deep Reinforcement Learning-笔记
  15. 快速打开管理工具的命令
  16. 建立桌面文件管理格子_告别混乱!5分钟get电脑桌面整理术!
  17. PCDMIS 零件坐标系创建技巧 4
  18. 用js函数判断一个数是否为素数
  19. LaTex环境下在TexStudio中使用minted插入高亮代码
  20. 雷电模拟器导入burp证书

热门文章

  1. hdu 4802 GPA
  2. 动手| 一个人脸识别的K8s部署实践
  3. mysql表date类型长度_mysql中数据类型的长度解释
  4. 根据TXT文件中的文件名复制文件
  5. 计算机ping不通dns,电脑不能上网但是可以Ping通和DNS解析解决办法是什么
  6. 使用STAF进行自动化安装测试
  7. 基于iLog3的实时日志实现
  8. python3 规则引擎_Ilog、Drools、Jess规则引擎的Rule Language 对比
  9. 针对宝宝肠胃保护,五大国际医生组织推荐使用爱乐乐益生菌
  10. 完美字符子串 单调队列预处理+DP线段树优化