目录
1. 全局唯一ID
1.1 特性和结构
1.2 redis实现全局唯一id
2. 秒杀业务
2.1 核心业务分析
2.2 代码实现
2.3 超卖问题
2.4 乐观锁解决超卖问题
2.5 实现一人一单
2.5.1 逻辑分析
2.5.2 判断订单是否存在代码实现
2.5.3 问题分析
2.5.4 最终代码
1. 全局唯一ID
1.1 特性和结构
秒杀业务往往会带来大量的数据,而mysql单表的容量不宜超过500w,数据量过大就要进行拆分表,但逻辑上他们是属于统一张表,因此id不能有冲突,需要保证id唯一性,引入全局唯一id。
全局id特性:
- 唯一性
- 高性能
- 安全性
- 递增性
- 高可用性
结构包括:1位符号位 永远为0,31为时间戳,32位序列号(秒内计数器,每秒可以产生2^32个不同的id)。
1.2 redis实现全局唯一id
public class RedisIdWorker {
//开始时间戳
private static final Long BEGIN_TIMESTAMP = 1704067200L;
//序列号位数
private static final int COUNT_BITS = 32;
private final StringRedisTemplate stringRedisTemplate;
public RedisIdWorker (StringRedisTemplate stringRedisTemplate){
this.stringRedisTemplate = stringRedisTemplate;
}
public Long getRedisId(String keyPrefix){
//获取当前时间戳
LocalDateTime now = LocalDateTime.now();
long second = now.toEpochSecond(ZoneOffset.UTC);
//当前时间戳减去开始时间戳
long timestamp = second - BEGIN_TIMESTAMP;
//记录当天生成的id个数,作为序列号
String data = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
Long count = stringRedisTemplate.opsForValue().increment("inc:" + keyPrefix + ":" + data);
if (count == null){
return timestamp << COUNT_BITS;
}
return timestamp << COUNT_BITS | count;
}
}
2. 秒杀业务
2.1 核心业务分析
秒杀业务的核心其实就是购买逻辑,总体来说就是用户发出订单,服务端修改库存,但秒杀业务一般都会有时限,因此还需要判断秒杀活动是否已开启,库存是否充足。流程图如下
2.2 代码实现
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);
}
2.3 超卖问题
在进行扣减库存前,虽然进行库存数量的判断,但由于未考虑到并发问题导致的都现成安全问题,可能会出现超卖现象。
例如,库存恰好只剩下一个,但此时来了多个并发请求订单,这几个请求先后到达,第一个到达的请求判断,库存为1,通过,进行后续处理。在第一个请求还未提交事务之前,其他请求仍然可以通过库存判断。但真实情况是库存只能满足一个请求,这就出现了问题。
这种由多线程并发引起的线程安全问题,解决方案往往就是进行加锁处理,常见的锁有悲观锁和乐观锁。
乐观锁:存在一个版本号,每次处理数据对应的版本号+1,再次提交数据时会进行校验版本号是否与之前的版本号大1,是,则进行操作成功,不是则证明数据被修改过,数据无法提交。乐观锁认为线程安全问题不一定会发生,因此不加锁,只是去判断其他线程是否对数据进行了修改。
悲观锁:悲观锁可以实现数据的串行化执行,比如syn和lock。悲观锁认为线程安全问题一定会发生,因此在进行数据操作之前要申请锁,确保线程串行执行。
2.4 乐观锁解决超卖问题
乐观锁的关键是判断之前查询得到的数据是否被修改过。
方案一:直接判断处理之前查询得到的数据是否和处理时得到的数据相等。
修改库存方案修改:直接在sql查找的时候进行判断。
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 = ?
这种方案有明显的缺陷,就是支持单线程,因为在多线程环境下,基本上只有一个线程可以通过。因为同时得到相同的库存后,但一个线程提交数据后,其他线程就查询不到数据。
方案二:查询时添加库存大于零的判断条件。
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update().gt("stock",0); //where id = ? and stock > 0
这种方案就可以有效解决上述问题。只查询库存大于零的,在多线程环境下,每次提交后更新库存,当库存降为零时,此时其他线程再想进行数据更新时就查不到这个数据,操作就会失败。
2.5 实现一人一单
需求:在秒杀活动中,一个人只能下一个订单。
2.5.1 逻辑分析
先判断时间是否充足,时间充足,判断库存是否足够,最后判断当前用户是否已经下单。
2.5.2 判断订单是否存在代码实现
直接根据用户ID和商品ID查询数据库中订单数据的订单数量(count),如果count>0(订单存在),直接返回 。否则,执行扣减库存和创建订单的业务。
// 一人一单逻辑
// 用户id
Long userId = UserHolder.getUser().getId();
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
2.5.3 问题分析
上述代码实现了一人一单的基本逻辑实现,但是还是存在很多问题,和超卖问题一样,在并发环境下,查询数据库都不存在订单。因此还是需要加锁。插入数据一般使用悲观锁,因为乐观锁无法进行判断(此时数据还未进行插入,根本就不存在乐观锁的version)。
这里加锁是使用synchronized锁,考虑到锁的粒度问题,这里只对userId进行加锁,这样相同的id进入是才需要进行申请锁,不同id进入则不需要。同时,由于当前方法是在spring的事务中,如果在方法中加锁,可能会导致当前方法事务还没提交,锁就已经被释放。因此将存在需要事务处理部分的代码提取出来,作为一个新方法createVoucherOrder(),对这个方法进行以userId加锁处理。
2.5.4 最终代码
public Result addSeckillVoucherOrder(Long voucherId) {
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//判断秒杀活动是否开启
if (voucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("秒杀尚未开始");
}
//判断秒杀活动是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束");
}
//查询库存不足
if (voucher.getStock()<1){
return Result.fail("库存不足");
}
//设置悲观锁,一人一订单。
Long userId = UserHolder.getUser().getId();
//释放锁需要再事务提交之后,因此环绕加锁方法。并且以用户id加锁,调用intern()方法确保获取的userid是同一个对象
synchronized (userId.toString().intern()) {
//设置代理,因为@Transactional事务需要动态代理才能实行,如果用this调用无法实现事务。
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
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.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.update();
if (!success) {
// 扣减失败
return Result.fail("库存不足!");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7.2.用户id
voucherOrder.setUserId(userId);
// 7.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}
上述代码已经解决了单体架构下的秒杀并发问题,但却不适用与分布式集群架构。因为分布式环境下,我们可能拥有多个服务器,也就是多个jvm,此时不同jvm下的线程是不能别锁住的,因此就需要分布式锁来解决这个问题。