Redis的优惠券秒杀问题之全局唯一ID秒杀下单超卖问题一人一单问题以及集群下的问题
Posted 爱上口袋的天空
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Redis的优惠券秒杀问题之全局唯一ID秒杀下单超卖问题一人一单问题以及集群下的问题相关的知识,希望对你有一定的参考价值。
目录
1、** 新增普通卷代码: **VoucherController
一、全局唯一ID
1、场景分析
首先,我们依照黑马的项目来进行分析,在什么情况下要使用到这个全局唯一ID。
在黑马点评这个项目中,使用的商品其实也就是优惠券
当用户抢购时,就会生成订单并保存到 tb_voucher_order 这张表中
CREATE TABLE `tb_voucher_order` (
`id` bigint NOT NULL COMMENT '主键',
`user_id` bigint UNSIGNED NOT NULL COMMENT '下单的用户id',
`voucher_id` bigint UNSIGNED NOT NULL COMMENT '购买的代金券id',
`pay_type` tinyint UNSIGNED NOT NULL DEFAULT 1 COMMENT '支付方式 1:余额支付;2:支付宝;3:微信',
`status` tinyint UNSIGNED NOT NULL DEFAULT 1 COMMENT '订单状态,1:未支付;2:已支付;3:已核销;4:已取消;5:退款中;6:已退款',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间',
`pay_time` timestamp NULL DEFAULT NULL COMMENT '支付时间',
`use_time` timestamp NULL DEFAULT NULL COMMENT '核销时间',
`refund_time` timestamp NULL DEFAULT NULL COMMENT '退款时间',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = COMPACT;
但是,这张SQL表里面的主键id,是不可以使用自增的!!!
2、不能用自增的原因
id的规律性太明显
如果使用自增的话,用户可以根据两笔订单的ID,来判断这段时间内订单的量。
受单表数据量的限制
订单的数据量一般很大,一天可能会有几百万,如果使用自增ID,就很难分库分表了!
3、全局唯一ID的条件
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:
4、全局唯一ID的Redis实现
为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息
符号位:1bit,永远为0
时间戳:31bit,以秒为单位,可以使用69年
序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID
5、代码实现
RedisIdWorker-Redis全局ID生成器工具类
/**
* Redis的全局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;
/**
* 生成全局ID
* Long 类型 8个字节 64个bit
* 符号位(1bit) + 时间戳(31bit) + 序列号(32bit)
* @param keyPrefix
* @return
*/
public long nextId(String keyPrefix)
// 1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
// ID的时间戳
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2.生成序列号
// 2.1.获取当前日期,精确到天
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// 2.2.自增长
// icr表示自增长,keyPrefix表示业务类型,一天一个key
// 例如: icr:order:2022:11:16
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3.拼接并返回 (位运算)
return timestamp << COUNT_BITS | count;
二、添加优惠卷
每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购:
tb_voucher:优惠券的基本信息,优惠金额、使用规则等
tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息
平价卷由于优惠力度并不是很大,所以是可以任意领取
而代金券由于优惠力度大,所以像第二种卷,就得限制数量,从表结构上也能看出,特价卷除了具有优惠卷的基本信息以外,还具有库存,抢购时间,结束时间等等字段
1、** 新增普通卷代码: **VoucherController
@PostMapping
public Result addVoucher(@RequestBody Voucher voucher)
voucherService.save(voucher);
return Result.ok(voucher.getId());
2、新增秒杀卷代码:VoucherController
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher)
voucherService.addSeckillVoucher(voucher);
return Result.ok(voucher.getId());
VoucherServiceImpl:
@Override
@Transactional
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);
3、实现秒杀下单
下单核心思路:当我们点击抢购时,会触发右侧的请求,我们只需要编写对应的 controller 即可
秒杀下单应该思考的内容:
下单时需要判断两点:
- 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
- 库存是否充足,不足则无法下单
下单核心逻辑分析:
当用户开始进行下单,我们应当去查询优惠卷信息,查询到优惠卷信息,判断是否满足秒杀条件,比如时间是否充足,如果时间充足,则进一步判断库存是否足够,如果两者都满足,则扣减库存,创建订单,然后返回订单 id,如果有一个条件不满足则直接结束。
代码实现:
VoucherOrderServiceImpl:
@Override
public 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.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2.用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(orderId);
存在问题
上述代码是写好了,运行起来看起来页没有什么问题,但是在多线程,高并发的场景下就会出现大问题,100%会发生超卖的情况!!
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("库存不足!");
有请求过来,只要库存充足,就进行减库存,生成订单的操作。
但是这样子在多线程高并发的场景下,一定会出现问题。
使用Jmeter进行压测
我们可以用 jmeter工具 复现一下场景,具体配置如下
配置authorization
运行程序,登入用户后,打开F12,获取authorization 的值
启动 Jmeter,进行压测,我们这里开了200个线程
我们这里设定有100个库存,所以正常的情况应该会有一半的线程(100个)的HTTP请求出现异常,但是这里显然不是!
查看数据库 tb_sckill_voucher表
stock为负数,超卖问题发生!
订单表 tb_voucher_order表 也是如此
发生超卖问题原因分析
5、解决方案
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁!!!
所以我们现在要研究是就是要加什么类型的锁?要怎么加锁?在哪里加锁?
悲观锁与乐观锁
先提一下,并不是说有一种锁叫乐观锁、叫悲观锁,“悲观”、“乐观”只是用来形容一种思想,一种方式!!!
相较于悲观锁而言, 乐观锁机制采取了更加宽松的加锁机制。自然性能方面会优于悲观锁!
在这个问题中我们用乐观锁来解决!最常见的方式有两个“版本号”、“CAS”
6、版本号法
但是,显然,在之前数据库设计的时候,没有version这个字段。
我们如果想要用这个方案也可以,但是比较麻烦!
代码实现
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId)
// 1. 查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
LocalDateTime nowTime = LocalDateTime.now();
// 2. 判断秒杀是否开始
if (nowTime.isBefore(voucher.getBeginTime()))
return Result.fail("活动未开始!");
// 3. 判断秒杀是否结束
if (nowTime.isAfter(voucher.getEndTime()))
return Result.fail("活动已结束!");
// 4. 判断库存
if (voucher.getStock() < 1)
return Result.fail("已买完!");
// 5. 减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId).gt("stock", 0) // CAS方案(乐观锁)!
.update();
if (!success)
return Result.fail("库存不足");
// 6. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2 用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(orderId);
核心是我们在减库存的时候,判断一下stock是否还大于0。
.gt("stock", 0)
我们这边是只要库存是大于0的就都可以购买,所以对于这个点,乐观锁的判断可以适当放宽,只对库存为0时的“减库存”操作加锁,对应的SQL:
update tb_seckill_voucher
set stock = stock - 1
where voucher_id = 18 and stock > 0
三、一人一单问题
问题描述
什么是一人一单问题?简单的来说就是模拟为了防止黄牛”屯“货而设计的,每一个用户ID,只能下一单!如下图,同一个用户下了很多单!!!
所以我们要修改秒杀业务,要求同一个优惠券,一个用户只能下一单
流程设计
解决方案
我们先获取一下用户的id,如果是相同的用户id“同时”执行到这里,只能允许一个进入该逻辑,执行减库存,生成订单的逻辑!其它的必须在此阻塞。
代码实现
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId)
// 1. 查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
LocalDateTime nowTime = LocalDateTime.now();
// 2. 判断秒杀是否开始
if (nowTime.isBefore(voucher.getBeginTime()))
return Result.fail("活动未开始!");
// 3. 判断秒杀是否结束
if (nowTime.isAfter(voucher.getEndTime()))
return Result.fail("活动已结束!");
// 4. 判断库存
if (voucher.getStock() < 1)
return Result.fail("已买完!");
Long userId = UserHolder.getUser().getId();
// 如果字符常量池中已经包含一个等于此String对象的字符串,则返回常量池中字符串的引用
// 对于任意两个字符串 s 和 t,当且仅当 s.equals(t) 为 true 时,s.intern() == t.intern() 才为 true
synchronized (userId.toString().intern())
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
@Transactional
public Result createVoucherOrder(Long voucherId)
// 一人一单
Long userId = UserHolder.getUser().getId();
Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0)
return Result.fail("用户已经购买过一次了!");
// 5. 减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId).gt("stock", 0) // CAS方案(乐观锁)!
.update();
if (!success)
return Result.fail("库存不足");
// 6. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2 用户id
voucherOrder.setUserId(userId);
// 6.3代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(orderId);
代码中技术点分析
1. intern()
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern())
...
intern() 方法返回字符串对象的规范化表示形式
对于任意两个字符串 s 和 t,当且仅当 s.equals(t) 为 true 时,s.intern() == t.intern() 才为 true。
String.intern()是一个Native方法,它的作用是:如果字符常量池中已经包含一个等于此String对象的字符串,则返回常量池中字符串的引用(不会新new一个),否则,将新的字符串放入常量池,并返回新字符串的引用。
在该业务场景中,会有多个线程会同时执行该逻辑。会生成多个userId,这里是要给相同的userId加锁!但是toString()方法会生成一个新的字符串对象。
如果不使用 intern() ,尽管这些字符串的值相同,它们的内存地址也会不同!!!所以并不会把它们认定为相同的字符串!
————————————————
版权声明:本文为CSDN博主「面向架构编程」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/weixin_43715214/article/details/127914721
2. 事务失效问题 currentProxy()
如果我们这里不生成其代理对象,则会导致事务失效
synchronized (userId.toString().intern())
return createVoucherOrder(voucherId);
在Spring中,事务的实现方式,是对当前类(VoucherOrderServiceImpl)做了动态代理!用其代理对象去做事务处理!
但是这里如果是上述代码, 实际上是this.createVoucherOrder(voucherId),这个this指的是VoucherOrderServiceImpl,是非代理对象,是没有事务功能的!!!所以如果代码这样子写,@Transactional标注的事务会失效!!!
————————————————
版权声明:本文为CSDN博主「面向架构编程」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/weixin_43715214/article/details/127914721
解决办法
synchronized (userId.toString().intern())
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
获取到当前对象的代理对象
AopContext.currentProxy()
然后再用该代理对象来调用方法
proxy.createVoucherOrder(voucherId);
这么改完之后还要在启动类上面加上注解,用来暴露代理对象
@EnableAspectJAutoProxy(exposeProxy = true) // 默认是关闭的
@EnableAspectJAutoProxy(exposeProxy = true) // 暴露代理对象!
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication
public static void main(String[] args)
SpringApplication.run(HmDianPingApplication.class, args);
四、在集群模式下的问题
问题描述
(1)IDEA启动镜像
我们可以使用IDEA自带的镜像,启动多个实例,来模拟集群!
-Dserver.port=8082
然后将这两个实例都启动起来。
(2)修改nginx配置
再打开nginx的配置文件——nginx.conf
完整配置如下:
worker_processes 1;
events
worker_connections 1024;
http
include mime.types;
default_type application/json;
sendfile on;
keepalive_timeout 65;
server
listen 8080;
server_name localhost;
# 指定前端项目所在的位置
location /
root html/hmdp;
index index.html index.htm;
error_page 500 502 503 504 /50x.html;
location = /50x.html
root html;
location /api
default_type application/json;
#internal;
keepalive_timeout 30s;
keepalive_requests 1000;
#支持keep-alive
proxy_http_version 1.1;
rewrite /api(/.*) $1 break;
proxy_pass_request_headers on;
#more_clear_input_headers Accept-Encoding;
proxy_next_upstream error timeout;
# 打开集群模式
# proxy_pass http://127.0.0.1:8081;
proxy_pass http://backend;
# 打开集群模式 backend
upstream backend
server 127.0.0.1:8081 max_fails=5 fail_timeout=10s weight=1;
server 127.0.0.1:8082 max_fails=5 fail_timeout=10s weight=1;
proxy_pass http://backend;
......
upstream backend
server 127.0.0.1:8081 max_fails=5 fail_timeout=10s weight=1;
server 127.0.0.1:8082 max_fails=5 fail_timeout=10s weight=1;
重启nginx.exe
nginx.exe -s reload
(3)验证nginx是否启动成功
因为默认是采用轮询的模式,再刷新一下上面的网页,8081和8082都是看到日志信息
HmDianPingApplication1
HmDianPingApplication2
到这里,说明我们的模拟集群已经配置成功了!接下来就是复现这个BUG
BUG复现
(1)获取Token
先登入一下,从页面中拿到这个token(authorization)
我们在代码中打上一个断点,这段代码中是有加锁的!即如果是同一个用户ID是不能同时进来的!
(2)使用Postman 发请求
然后,将authorization配置到postman的请求头中,分别用两个postman实例去发送请求!
但是,在下面的两个实例中,显然同一个用户ID请求都进去了,就出现了锁“失效”的问题!
(3)锁“失效”发生
两个请求,分别进入到不同的IDEA实例中(8081、8082),但是这一块内容我们是上锁的!也就是说在集群模式下,JVM级别的synchronized锁是起不了作用的!
HmDianPingApplication1
HmDianPingApplication2
问题分析
JVM级别的synchronized锁失效
换而言之,就是系统部署在不同的服务器中,那它们的JVM是不一样的,而 synchronized 只能锁当前JVM中的线程,是不能操作其它JVM的!
解决思路
要解决这个问题,就必须要借助别的工具!——分布式锁就孕育而生!
以上是关于Redis的优惠券秒杀问题之全局唯一ID秒杀下单超卖问题一人一单问题以及集群下的问题的主要内容,如果未能解决你的问题,请参考以下文章