为什么要使用全局唯一ID:
当用户抢购时,就会生成订单并保存到订单表中,而订单表如果使用数据库自增ID就存在一些问题:
受单表数据量的限制
id的规律性太明显
场景分析一:如果我们的id具有太明显的规则,用户或者说商业对手很容易猜测出来我们的一些敏感信息,比如商城在一天时间内,卖出了多少单,这明显不合适.
场景分析三:如果全部使用数据库自增长ID,那么多张表都会出现相同的ID,不满足业务需求.
在分布式系统下全局唯一ID需要满足的特点:
唯一性
递增性
安全性
高可用(服务稳定)
高性能(生成速度够快)
ID的组成部分:符号位:1bit,永远为0
我们的生成策略是基于redis的自增长,及序列号部分,在实现的时候需要传入不同的前缀(即不同业务不同序列号)
我们开始实现时间戳位数,先设置一个基准值,即某一时间的秒数,使用的时候用当前时间秒数-基准时间=所得秒数即时间戳;
public static void main(String[] args) {
System.out.println(l);
}
LocalDateTime dateTime = LocalDateTime.now();
//秒数设置时区
long nowSecond = dateTime.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
然后生成序列号,采用Redis的自增操作实现.keyPrefix业务Key(传入的)
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix);
//获取当日日期,精确到天
String date = dateTime.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
//自增长上限2^64
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
这样做的好处是:
在redis中缓存是分层的,方便查看,也方便统计每天、每月的订单量或者其他数据等
不会超过Redis的自增长的值,安全性提高
timestamp << COUNT_BITS | count;
在同一个业务中使用全局唯一ID生成.
/**
* 测试全局唯一ID生成器
* @throws InterruptedException
*/
@Test
public void testIdWorker() throws InterruptedException {
for (int i = 0; i < 100; i++) {
for (int i = 0; i < 300; i++) {
System.out.println("time= "+(endTime-begin));
}
取两个相近的十进制转为二进制对比:
0010 0000 1110 1101 0000 1001 0111 0000 0000 0000 0000 0000 0000 1001 0000
0010 0000 1110 1101 0000 1001 0111 0000 0000 0000 0000 0000 0000 1001 0001
仅支持很小的调用量,用于生成活动配置类编号,保证全局唯一
import java.util.Calendar;
import java.util.Random;
/**
* @author xbhog
* @describe:短码生成策略,仅支持很小的调用量,用于生成活动配置类编号,保证全局唯一
* @date 2022/9/18
*/
@Slf4j
@Component
public class ShortCode implements IIdGenerator {
}
}
日志记录:
14:40:22.336 [main] INFO ShortCode - 年:2023,周:5,日:7,小时:14
14:40:22.341 [main] INFO ShortCode - 查看拼接之后的值:314057012
314057012
秒杀条件分析:
秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
库存是否充足,不足则无法下单
业务流程图:
开发流程:
优惠卷订单服务处理流程
查询优惠卷
判断用户是否在秒杀时间段内
判断是否库存充足
不足:返回异常信息
创建优惠卷订单
落库
返回订单ID
流程比较简单,这里需要注意的点是在库存扣减这部分
@Override
public Result seckillVoucher(Long voucherId) {
if (voucher.getStock() < 1) {
return Result.ok(orderId);
}
jmeter进行测试:
原因分析:
解决方式
悲观锁:可以实现对于数据的串行化执行,比如syn,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等
乐观锁:会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过,当然乐观锁还有一些变种的处理方式比如cas
采用乐观锁解决超卖问题:
修改上述代码有两种修改方式:
只要我扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的.
0即可(解决问题)
0
上述秒杀订单有一个问题,一个用户可以秒杀多次;优惠卷是为了引流,但是目前的情况是,一个人可以无限制的抢这个优惠卷,所以我们应当增加一层逻辑,让一个用户只能下一个单,而不是让一个用户下多个单.
相关流程图如下:
在原来的代码上增加用户判断:
// ⑤一人一单逻辑
// ⑤1.用户id
Long userId = UserHolder.getUser().getId();
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// ⑤2.判断是否存在
if (count > 0) {
return Result.fail("用户已经购买过一次!");
}
存在问题:现在的问题还是和之前一样,并发过来,查询数据库,都不存在订单,所以我们还是需要加锁,但是乐观锁比较适合更新数据,而现在是插入数据,所以我们需要使用悲观锁操作
当前注意点:
线程安全实现
锁的范围(颗粒度)
事务问题
处理线程安全问题,将对数据库更新和插入的操作单独作为一个方法进行封装:
@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {
return Result.ok(orderId);
}
当前操作虽然可以解决线程安全,但是效率太低,每个进来的线程都要锁一下,这里我们可以尝试以用户ID来作为锁条件,但是使用userId.toString(),是重新new了一个对象,这就造成每个线程进来都不一样,锁不住.
public static String toString(long i) {
int size = (i < 0) ? stringSize(-i) + 1 : stringSize(i);
return new String(buf, true);
}
这里我们使用userId.toString().intern()从常量池中查找数据.解决锁对象不一致的问题.
Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()){
}
//这里事务还没有提交事务,但是锁已经释放了.
}
但是! 以上代码还是存在问题;
问题的原因在于当前方法被spring的事务控制,如果你在方法内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放也会导致问题.
解决:把用户ID放入外部.将当前方法整体包裹起来,确保事务不会出现问题
@Slf4j
@Service
public class VoucherOrderServiceImpl extends ServiceImpl implements IVoucherOrderService {
if(voucher.getStock() < 1){
}
}
但是但是!还是有问题.
因为我们调用的方法,其实是this.的方式调用的,事务想要生效,还得利用代理来生效,所以这个地方,我们需要获得原始的事务对象, 来操作事务.
代理使用需要进行配置和包的引入:
org.aspectj
aspectjweaver
在启动类中加入:@EnableAspectJAutoProxy(exposeProxy = true);暴露代理对象,不设置无法获取代理对象;
在调用时,通过AopContext来获取当前代理对象.
synchronized (userId.toString().intern()){
return iVoucherOrderService.createVoucherOrder(voucherId);
}
Jmeter测试条件:100线程,循环1次,查看结果树和汇总报告可以看出;
查看数据库,一个用户秒杀成功一个订单,对比异常率,满足我们的需求.