1. 全局唯一ID
在秒杀后生成的订单,订单ID的设计是值得考虑的。是采用数据库的自增?必然是不行的,首先若是一张订单表,其表的容量是有上限的,且订单的数据量巨大,若是采用多库多表进行存储,那么每个表自增ID都是从1开始,会造成订单ID的重复,且自增ID规律性强,容易被猜测,具有安全隐患。
1.1 ID生成策略
- 采用UUID
- 雪花算法
- 采用Redis的自增并且根据业务进行拼接
采用Redis的自增并且根据业务进行拼接:
第一位是符号位,永远为0,表示正数
2-31位是时间戳,可以定义一个起始时间,然后在获取下单时间,两者相减即可
后32位是序列号位,采用Redis的自增长
那么Redis自增长的key应该如何设计呢?
可以采用业务名:日期进行设计,如果不拼接日期的话,虽然Redis的自增长支持到2^64,但是我们设计的ID的序列号只有32位,所以如果不按照日期进行划分,业务量巨大的情况下,是会导致自增的数值超过序列号的位数。且以日期作为key的拼接,也方便做每天订单量的统计操作。
代码实现
/**
* 开始时间戳
*/
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;
}
2. 秒杀
声明,秒杀接口传入秒杀物品的ID,订单流程图如下
首先实现基本的下单功能
/**
* 获取秒杀优惠券的service
*/
@Autowired
private ISeckillVoucherService iSeckillVoucherService;
/**
* 全局ID的生成类
*/
@Autowired
private RedisIdWorker redisIdWorker;
/**
* 下单的mapper
*/
@Autowired
private VoucherOrderMapper voucherOrderMapper;
/**
* 秒杀
* @param voucherId
* @return
*/
@Transactional
@Override
public Result seckillVoucher(Long voucherId) {
// 1. 查询优惠券
SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);
// 2. 判断秒杀是否开始
LocalDateTime beginTime = voucher.getBeginTime();
if(beginTime.isAfter(LocalDateTime.now())){
return Result.fail("秒杀尚未开始");
}
// 3. 判断秒杀是否结束
LocalDateTime endTime = voucher.getEndTime();
if (endTime.isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束");
}
// 4. 库存是否充足
if (voucher.getStock()<1){
return Result.fail("库存不足");
}
// 5.扣减库存
boolean flag = iSeckillVoucherService.update().setSql("stock = stock -1").eq("voucher_id", voucherId).update();
if (!flag){
return Result.fail("下单失败");
}
// 6. 创建订单
VoucherOrder order = new VoucherOrder();
// ID生成类
long orderId = redisIdWorker.nextId("order");
// 获取userId
Long userId = UserHolder.getUser().getId();
order.setId(orderId);
order.setVoucherId(voucherId);
order.setUserId(userId);
voucherOrderMapper.insert(order);
// 7. 返回订单ID
return Result.ok(orderId);
}
这种代码只是基本的业务代码,在高并发的情况下必然会出现超卖的情况
解决方式:使用锁
-
悲观锁
认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。
例如Synchronized、Lock都属于悲观锁 -
乐观锁
认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。
如果没有修改则认为是安全的,自己才更新数据。
如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常
乐观锁,悲观锁不是锁,它是锁的一种思想
2.1 乐观锁
2.1.1 版本号法
数据库里保存version字段,在修改数据前先读取版本号,在修改数据时加上条件 version = 之前读取的version,如果条件不符合,则说明有线程修改过,则此次修改失败。每次修改成功后,version = version +1。
2.1.2 CAS法
CAS法和版本号法思想一致,只是版本号法使用新的字段version字段来辨别版本是否被修改过,CAS使用已有的字段,在上述场景中,可以使用库存代替版本号,在修改库存时,先读出库存,在修改时,stock = stock -1 where
stock = 读取的stock即可
代码实现CAS法
/**
* 获取秒杀优惠券的service
*/
@Autowired
private ISeckillVoucherService iSeckillVoucherService;
/**
* 全局ID的生成类
*/
@Autowired
private RedisIdWorker redisIdWorker;
/**
* 下单的mapper
*/
@Autowired
private VoucherOrderMapper voucherOrderMapper;
/**
* 秒杀
* @param voucherId
* @return
*/
@Transactional
@Override
public Result seckillVoucher(Long voucherId) {
// 1. 查询优惠券
SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);
// 2. 判断秒杀是否开始
LocalDateTime beginTime = voucher.getBeginTime();
if(beginTime.isAfter(LocalDateTime.now())){
return Result.fail("秒杀尚未开始");
}
// 3. 判断秒杀是否结束
LocalDateTime endTime = voucher.getEndTime();
if (endTime