全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具。
一般需要满足以下特性:
为了增加ID的安全性,我们可以不直接使用redis自增数值,而是拼接一些其他信息:
首先,我们可以使用2022年1月1日零点的时间的秒数作为开始时间戳,用生成ID的本地时间减去开始时间戳作为最终时间戳。
通过下面代码我们可以获取2022年1月1日零点的时间的秒数,并将其定义为一个常量。
接下来我们生成序列号,将时间戳和序列号进行拼接然后返回。
拼接时间戳和序列号的做法是首先将时间戳使用位运算向左移动三十二位,然后将右边空出来的三十二位与序列号做或运算,得到最终的id。
@Component
public class RedisIdWorker {
public static final long BEGIN_TIMESTAMP = 1640995200;
//序列号位数
public static final int COUNT_BITS = 32;
@Autowired
private StringRedisTemplate stringRedisTemplate;
public Long nextId(String keyPrefix) {
//1. 生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSeconds = now.toEpochSecond(ZoneOffset.UTC);
long timeStamp = nowSeconds - BEGIN_TIMESTAMP;
//2. 生成序列号
//2.1 获取当前日期,精确到天
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
//调用redis自增长策略
Long count = stringRedisTemplate.opsForValue().increment("icr" + keyPrefix + ":" + date);
//3. 拼接并返回
//时间戳左移32位,左移留下的32位0与count做或运算
return timeStamp << COUNT_BITS | count;
}
}
每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券就需要秒杀抢购。
我们在优惠券的接口中添加了新增普通优惠券和秒杀券的方法,我们可以通过请求去添加需要的优惠券。
@RestController
@RequestMapping("/voucher")
public class VoucherController {
@Resource
private IVoucherService voucherService;
/**
* 新增普通券
* @param voucher 优惠券信息
* @return 优惠券id
*/
@PostMapping
public Result addVoucher(@RequestBody Voucher voucher) {
voucherService.save(voucher);
return Result.ok(voucher.getId());
}
/**
* 新增秒杀券
* @param voucher 优惠券信息,包含秒杀信息
* @return 优惠券id
*/
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
voucherService.addSeckillVoucher(voucher);
return Result.ok(voucher.getId());
}
实现我们的优惠券秒杀功能首先需要满足以下两点:
接下来我们梳理一下秒杀流程图,可以说业务流程并不复杂,我们使用代码实现一下。
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
private RedisIdWorker RedisIdWorker;
/**
* 优惠券秒杀
* @param voucherId
* @return
*/
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
//1. 根据优惠券id查询优惠券信息
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. 充足,扣减优惠券数量
voucher.setStock(voucher.getStock()-1);
boolean success = seckillVoucherService.updateById(voucher);
if (!success) {
return Result.fail("优惠券已被抢完");
}
//6. 创建订单
//6.1 设置id
VoucherOrder voucherOrder = new VoucherOrder();
Long voucherOrderId = RedisIdWorker.nextId("voucherOrder");
voucherOrder.setId(voucherOrderId);
//6.2 设置user_id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
//6.3 设置优惠券id
voucherOrder.setVoucherId(voucherId);
this.save(voucherOrder);
return Result.ok(voucherOrderId);
}
}
我们学习使用redis在做项目就需要考虑并发问题,我们来看一下如下几种情况,观察我们的程序会出现哪些问题。
超卖问题的典型就是多线程问题,针对这一问题的常见解决方案就是加锁。
加锁有乐观锁和悲观锁两种,其中,如果有高并发的需求,悲观锁因其串行执行保证线程安全而不能满足需要,因此,对于高并发解决问题一般是使用乐观锁。
当然,使用乐观锁也不一定能完全解决高并发带来的线程安全问题,我们接下去还会继续学习其他的解决方案。
乐观锁的关键是判断之前查询得到的数据是否发生变化,常见的方法有如下两种
CAS法是根据版本号法进行改进的:既然库存和版本号是同时进行修改的,那我们何苦新增一个版本号字段来增加复杂度呢?我们直接判断扣减库存前的库存跟之前查询的库存是否一致不就解决了吗。
是的,如果扣减之前查询的库存没有发生变化,则进行扣减操作,如果扣减之前查询的库存发生变化,则说明有其他线程对库存进行了修改,这时候我们就不进行库存扣减操作,返回操作失败的提示信息。
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
//1. 根据优惠券id查询优惠券信息
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();
//5. 充足,扣减优惠券数量
//当更新时查询的库存为未更新前的库存时进行库存减一
boolean success = seckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherId)
.eq("stock", voucher.getStock()).update();
if (!success) {
return Result.fail("优惠券已被抢完");
}
//6. 创建订单
//6.1 设置id
VoucherOrder voucherOrder = new VoucherOrder();
Long voucherOrderId = RedisIdWorker.nextId("voucherOrder");
voucherOrder.setId(voucherOrderId);
//6.2 设置user_id
voucherOrder.setUserId(userId);
//6.3 设置优惠券id
voucherOrder.setVoucherId(voucherId);
this.save(voucherOrder);
return Result.ok(voucherOrderId);
}
}
我们使用Jmeter进行高并发测试
我们使用Jmeter进行一秒钟两百个线程进行优惠券秒杀,按我们猜想应该是优惠券不够卖,至少有一半的线程是不能抢到优惠券的,结果确实如此嘛?
我们打开数据库看一下库存和订单
可以看出,我们启动了两百个线程但只卖出了20几张优惠券,这是为什么呢?
因为我们设置的条件是当我们扣减库存时查询的库存必须和我们扣减之前保持一致,而在高并发下,我们很多线程被这一个条件卡在了“门外”,进而导致抢购失败的情况。
如何优化呢?其实我们设置的条件可以不再是扣减库存时查询的库存必须和我们扣减之前保持一致,而是库存大于0,就是不一致只要库存大于0是不是也能满足需要。说干就干,我们修改一下代码
boolean success = seckillVoucherService.update()
.setSql("stock=stock-1")
.gt("stock", 0)
.eq("voucher_id", voucherId).update();
对于秒杀优惠券,我们要求是同一个用户只能购买一张优惠券,不能多买。
@Transactional
public Result seckillVoucher(Long voucherId) {
//1. 根据优惠券id查询优惠券信息
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();
//一人一单
int count = this.query().eq("user_id", userId)
.eq("voucher_id", voucherId).count();
if(count>0){
return Result.fail("一个用户只能购买一个优惠券");
}
//当更新时查询的库存大于0时进行库存减一
boolean success = seckillVoucherService.update()
.setSql("stock=stock-1")
.gt("voucher_id", 0)
.eq("stock", voucher.getStock()).update();
if (!success) {
return Result.fail("优惠券已被抢完");
}
//6. 创建订单
//6.1 设置id
VoucherOrder voucherOrder = new VoucherOrder();
Long voucherOrderId = RedisIdWorker.nextId("voucherOrder");
voucherOrder.setId(voucherOrderId);
//6.2 设置user_id
voucherOrder.setUserId(userId);
//6.3 设置优惠券id
voucherOrder.setVoucherId(voucherId);
this.save(voucherOrder);
return Result.ok(voucherOrderId);
}
其实,一人一单在高并发情况下也是会出现一人可以买多张优惠券的问题 ,这时我们就只加悲观锁进行限制。
但是,加悲观锁在单机情况下能解决安全问题,但是在集群模式下就不行了
为什么呢?
因为我们如果在集群模式下,有很多个JVM,每个JVM中的锁是不一样的。如下图所示,当线程1在JVM1中获取锁成功时,线程3也成功的获取了JVM2中的锁,因为两个锁是不是同一个,因此肯定能获取成功,这样我们就又无法保证在集群模式下实现一人一单的问题了。
怎么解决呢?我也还没学
接下去我会继续学习分布式锁,等我学会了再来分享。
看到这里的朋友记得点赞关注一下,我会持续学习并且更新,加油!!!