(优惠券秒杀) 本文为学习redis时做的笔记,学习内容来自黑马程序员Redis入门到实战教程,该教程是循序渐进的,所以不是一上来就讲完最后的解决方案了,请耐心看完 所需要的分布式锁知识请看我的下一篇博客
1. 全局id生成器
全局id生成器是一种分布式系统下的全局唯一id生成工具 不管有多少数据库表,redis只有一个,所以redis自增就是唯一的 为了增加安全性,可以不直接使用redis自增的数值,而是可以拼接一些其他信息
- 符号位:1bit,永远为0
- 时间戳:31bit,以秒为单位,可以使用69年
- 序列号:32bit,计数器,每秒能产生2的32次方个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;
}
}
调用示例:
RedisIdWorker redisIdWorker = new RedisIdWorker(stringRedisTemplate);
long orderId = redisIdWorker.nextId("order");
System.out.println(orderId);
2. 基础功能:添加秒杀优惠券
我们只需要在接口测试工具中(如apifox中)对应的接口中传入优惠券对象就可以
{
"beginTime": "2023-10-26T21:00:00",
"shopId": 1,
"subTitle": "周一至周五均可使用",
"type": 87,
"payValue": 18000,
"title": "200元代金券",
"stock": 50,
"endTime": "2023-11-11T12:48:08",
"status": 57,
"actualValue": 20000,
"rules": "全场通用\\n无需预约\\n可无限叠加\\不兑现、不找零\\n仅限堂食"
}
我们的秒杀优惠券:
3. 实现秒杀下单
下单时要判断:
- 秒杀是否开始或结束
- 库存是否充足
实现秒杀后返回订单id
@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 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("库存不足");
}
boolean success = seckillVoucherService.update().setSql("stock = stock-1")
.eq("voucher_id", voucherId)
.update();
if(!success){
Result.fail("库存不足!");
}
VoucherOrder order = new VoucherOrder();
//6.1 订单id
long orderId = redisIdWorker.nextId("order");
order.setId(orderId);
//6.2 用户id
order.setUserId(UserHolder.getUser().getId());
//6.3 优惠券id
order.setVoucherId(voucherId);
save(order);
return Result.ok(orderId);
}
}
因为对库存表和订单表都进行了操作,所以要加上事物
4. 库存超卖问题
使用jmeter测试200次请求
记着设置token信息: 记着设置json断言
最后的请求会显示库存不足 但是库存变为-9,创建了109个订单,这就是超卖问题
4.1 问题分析
线程一查询库存为1,在判断库存是否大于0之前其他线程也查询到库存为1,之后线程1判断库存大于0,库存减1到0。其他线程因为之前查询到库存也为1,所以也减了库存,库存变为负数,出现线程安全问题,最后导致超卖
4.2 解决
对于多线程安全问题,常见的解决方案是锁 下面了解乐观锁的解决方案
4.2.1 乐观锁
乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的有两种
- 版本号法
查询的时候查询库存和版本号,之后更新库存的同时查询版本号是否和之前查到的一致,一致则说明数据未得到其他线程更改,保证线程安全
- CAS法
CAS法其实是版本号法的一种简化方式,省略了版本号,更新库存的时候判断现在的库存是否和一开始查询到的库存一致
4.3 实现
只需要在原来的逻辑上对删减库存修改,因为库存比较特殊,所以不需要库存一定和开始查询的时候一致,只需要库存大于0就行
boolean success = seckillVoucherService.update().setSql("stock = stock-1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if(!success){
Result.fail("库存不足!");
}
再用jmeter测试两百次请求 库存正好为0,异常情况(库存不足情况)也为一半左右
5. 一人一单
修改业务,要求一个优惠券,一个用户只能下一单 在判断库存充足后判断订单是否存在
5.1 问题
先在扣减库存之前加入一人一单的判断
//5.一人一单
Long userId = UserHolder.getUser().getId();
//查询
Integer count = query().eq("userId", userId).eq("voucher_id", voucherId).count();
//判断订单是否存在
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) {
Result.fail("库存不足!");
}
使用jmeter测试: 本来应该一个用户只能下一单,但现在却下了10单,说明之前的方法出现了问题
5.2 分析
现在我们的数据库中根本没有订单,现有一百个线程并发的查询之前是否有订单,查询到的都是0,然后就插入了n多个数据,也是多线程问题,常见的方式是用锁解决,因为现在数据不存在,没法判断数据是否被修改过,所以不能用乐观锁,可以加悲观锁
解决
5.3 解决
5.3.1 悲观锁
先将判断订单是否已经存在,扣减库存,创建订单单独创建一个方法,并加上事物
@Transactional
public Result createVoucherOrder(Long voucherId) {
//5.一人一单
Long userId = UserHolder.getUser().getId();
//查询
Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
//判断订单是否存在
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) {
Result.fail("库存不足!");
}
//6.创建订单
VoucherOrder order = new VoucherOrder();
//6.1 订单id
long orderId = redisIdWorker.nextId("order");
order.setId(orderId);
//6.2 用户id
order.setUserId(userId);
//6.3 优惠券id
order.setVoucherId(voucherId);
save(order);
return Result.ok(orderId);
}
在原先的方法中调用创建订单方法,在方法外面加一层悲观锁synchronized
Long userId = UserHolder.getUser().getId();
//对用户id加锁,保证同一个用户加一个锁,不同的用户加不同的锁
//每次请求来,用户id对象都是一个新的对象,对象变了,锁就变了,我们要求值一样,所以用toString
//因为将id转为字符串,相当于new了一个字符串,同一个用户每次进来值虽然一样,但字符串地址
//不一样,锁还会变,所以用intern()方法,在字符串池中中找到与传入的值相同的字符串的地址
//保证用户id的值一样,锁就一样
synchronized (userId.toString().intern()) {
//事务提交之后再释放锁
//现在还有一些问题,下面会提到
return createVoucherOrder(voucherId);
}
注意:
- 上边的目的是对用户id加锁,保证同一个用户加一个锁,不同的用户加不同的锁,t提升性能
- 每次请求来,用户id对象都是一个新的对象,对象变了,锁就变了,我们要求值一样,所以用toString()
- 因为将id转为字符串,相当于new了一个字符串,同一个用户每次进来值虽然一样,但字符串地址不一样,锁还会变,所以用intern()方法,在字符串池中中找到与传入的值相同的字符串的地址,保证用户id的值一样,锁就一样
5.3.2 事物
上边内容是有错误的,seckillVoucher是一个非事物方法,而createVoucherOrder是一个事务方法,在同一个类里的非事物方法中调用事物方法,会导致事物失效
Spring的事务管理依靠的动态代理模式,当在同一个类中调用一个非事务方法,是不会生成代理对象的,自然也不会触发事务
return createVoucherOrder(voucherId);
其实省略了this,指的是一个非代理对象IVoucherService,所以接下来我们只需要得到它的代理对象即可,再用代理对象调用事务方法即可
synchronized (userId.toString().intern()) {
//获取当前的代理对象(事物)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
//事务提交之后再释放锁
//再用代理对象调用事务方法
return proxy.createVoucherOrder(voucherId);
}
使用AopContext.currentProxy()
方法,因为我们现在是在service的实现类是实现的事务方法,所以要记得在service里写入对应的方法
最后我们要引入一个动态代理模式的依赖
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
并且在启动类里添加注解暴露代理对象
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
//暴露代理对象
@EnableAspectJAutoProxy(exposeProxy = true)
public class HmDianPingApplication {
public static void main(String[] args) {
SpringApplication.run(HmDianPingApplication.class, args);
}
}
5.3.3 结果
使用jmeter测试一个用户200次请求 只有一个请求能成功
一个用户只生成了一个订单,库存只减少了1
5.4 集群下的线程并发安全问题
通过加锁可以解决在单机情况下的一人一单问题,但在集群下就不行了 在集群下,或者在分布式系统下,每个线程有自己的jvm,多个jvm存在,每个jvm都有自己的锁监视器,会有多个线程获得锁,可能出现安全问题,所以我们要想办法,让多个jvm使用同一把锁
请看我的下一篇博客
标签:优惠券,redis,order,voucherId,库存,秒杀,Result,id,stock From: https://blog.51cto.com/u_16277539/8174996