全局唯一id
解释:为什么需要全局唯一Id呢?因为我们的下单的账单id 避免重复。
如果 这张券商品 id重复了,对应了多个用户,到时候你发货就会白白亏损。
全局唯一id的特点
- 唯一性
- 高可用性
- 高性能
- 递增性:确保会有递增性,这样放到数据库的时候生成索引的索引,能够更好的提高查询效率
- 安全性:不能简单的让别人推测出你的id的规律。
这里采取的 全局唯一id生成的如下
即全局唯一ID(long类型) = 符号位(1位) + 时间戳(31位) + 自动生成的id(32位)
代码编写
@Component
public class RedisIdWorker {
@Resource
private StringRedisTemplate stringRedisTemplate;
private static final long BEGIN_TIMESTAMP = 1640995200L;// 2022年1月1日的时间戳
public Long nextId(String userPrefix){
// 这个Id 如何生成?
// 第一步确定什么?redis,会根据 key的不同生成 序列号,这个序列号 最大能够达到2的64次方。所以我们最好让这个key变化起来,而不是固定写死。
//虽然很难达到2的64次方。
// 所以获取全局唯一id的时候,要组合成一个id才行。 【自身功能(icr):调用者模块:日期】,这样设计的好处
// 调用者模块,根据当前日期,生成了多少个id,这样就能够统计,每年或者每月,每天生成了多少个id,这样就能够统计每天出售了多少东西。
// 记得把日期也调成 年:月:日
//1.根据 redis的string类型的increment方法,生成自增的序列号。
String date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
Long increment = stringRedisTemplate.opsForValue().increment("inr:" + userPrefix + ":" + date);
//2.生成 31位的时间戳
// 2.1定义一个基准时间(BEGIN_TIMESTAMP ),那样就能够让这个方法被使用时间,减去基地时间,就能得到时间间隔,
// 然后转成时间戳即可。这也是递增的。为什么要冲洗
long now = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC);//当前时间的时间戳
long timeSpace = now - BEGIN_TIMESTAMP;
//3.生成全局唯一id
return (timeSpace<< 32) | increment;
}
}
优惠券秒杀下单
> 请求参数:优惠券id
> 请求路劲:http://localhost:8080/api/voucher-order/seckill/{id}
> 请求方式:post请求
> 返回值: 订单id(返回给前端看,是否下单成功)
实现流程
下单时:需要注意点的东西。
> 一定要确保秒杀券,当前时间,在秒杀券的有效时间段内,否则全段将不会展示这张票据(这是前端的校验),
> 但是别人完全可以通过postman或者其他什么途径访问你的controller,所以必须要在接收到请求之后,一定要去判断这个优惠券是否在有效时间内。
> 最后别人完全可能访问不存在的优惠券Id,导致缓存穿透问题,所以要做好预防缓存穿透的准备,可以选择缓存空数据,或者使用布隆过滤器来完成
执行流程
> 这样的执行流程可能会出现超卖问题
代码实现
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
@Transactional//最后是添加事物,设计到两张表的操作。这样一旦出现问题就可以回滚。
public Result seckillVoucher(Long voucherId) {
//1.查询 优惠券。 这个优惠券,差的不是普通的优惠券,而是秒杀券,所以要从秒杀券对应的库中找。
// 空指针判断,否则别人绕过前端,通过postman来访问你这个接口
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
//2.判断抢券活动是否开始
if (seckillVoucher == null) {
return Result.fail("优惠券不存在");//防止别人直接绕过前端,通过postman来访问你这个借口
}
//3.判断抢券活动是否结束
LocalDateTime beginTime = seckillVoucher.getBeginTime();
if (LocalDateTime.now().isBefore(beginTime)) { //当前时间在开始时间之前。
return Result.fail("秒杀尚未开始");
}
LocalDateTime endTime = seckillVoucher.getEndTime();
if (LocalDateTime.now().isAfter(endTime)) { //当前时间是在结束时间之后。
return Result.fail("秒杀已经结束了");
}
//4.判断库存是否充足
if (seckillVoucher.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();
// 订单id ,使用id生成器自动生成的
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 用户id,从登录拦截器中获取用户id
UserDTO user = UserHolder.getUser();
voucherOrder.setUserId(user.getId());
// 代金券id
voucherOrder.setVoucherId(voucherId);
//将订单写入数据库
save(voucherOrder);
//7.返回订单id
return Result.ok(orderId);
}
}
超卖问题
> 就是剩下最后一张飘的时候,更好有多少个线程来,线程A查询数据得到数据A,线程B查询数据库得到B,线程C查询得到C。 然后去判断 A B C 结果都是大于,然后对数据库执行扣减,就导致了超卖问题。
解决方案:锁
乐观锁和悲观锁:
悲观锁:让为线程安全问题一定会发生,因此在操作数据之前会先获取锁,确保线程的串行执行,就不会出现并发的问题。比如Synchronized 、Lock
悲观锁:认为线程安全问题不一定会发生,因此不加锁,但是在数据更新的时候) 判断有没有其他线程对数据进行了修改。例如:执行Update语句的时候,store 查询出来是1,当我update的时候,where 条件是 store = 查询出来的store才进行修改,如果 数据库的store跟查出来的store不一样,说明它被人修改过了,那么我就不修改了。
乐观锁
乐观锁的关键就是判断之前查询得到的数据是否被修改过。常见的判断方式是:
- 版本号法和 CAS方法
> 版本号法:给表多添加一个字段version,每执行一次修改,就让version字段+1;判断version有没有变化。
> 版本号法:给表多添加一个字段version,每次执行修改操作,都让version++
代码实现
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
@Transactional//最后是添加事物,设计到两张表的操作。这样一旦出现问题就可以回滚。
public Result seckillVoucher(Long voucherId) {
//1.查询 优惠券。 这个优惠券,差的不是普通的优惠券,而是秒杀券,所以要从秒杀券对应的库中找。
// 空指针判断,否则别人绕过前端,通过postman来访问你这个接口
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
//2.判断抢券活动是否开始
if (seckillVoucher == null) {
return Result.fail("优惠券不存在");//防止别人直接绕过前端,通过postman来访问你这个借口
}
//3.判断抢券活动是否结束
LocalDateTime beginTime = seckillVoucher.getBeginTime();
if (LocalDateTime.now().isBefore(beginTime)) { //当前时间在开始时间之前。
return Result.fail("秒杀尚未开始");
}
LocalDateTime endTime = seckillVoucher.getEndTime();
if (LocalDateTime.now().isAfter(endTime)) { //当前时间是在结束时间之后。
return Result.fail("秒杀已经结束了");
}
//4.判断库存是否充足
if (seckillVoucher.getStock() < 1) {
return Result.fail("库存不足了");
}
//5.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1")// stock = stock -1;
.eq("voucher_id", voucherId) //where id = voucherId
.eq("stock",seckillVoucher.getStock()) // and stock = getStock
.update();
if (!success) {
//更新失败,一般来说就是库存不组
return Result.fail("库存不足");
}
//6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 订单id ,使用id生成器自动生成的
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 用户id,从登录拦截器中获取用户id
UserDTO user = UserHolder.getUser();
voucherOrder.setUserId(user.getId());
// 代金券id
voucherOrder.setVoucherId(voucherId);
//将订单写入数据库
save(voucherOrder);
//7.返回订单id
return Result.ok(orderId);
}
}
> 乐观锁并没有真正的加锁,只是多加了一个sql的判断条件。
乐观锁的弊端
> 如果我们修改数据的时候:只让stock = stock 的时候才能修改,那么成功率会十分低。
> 改进方案就是:双stock校验。
第一个使用查询出来的stock 校验,如果<=0 直接返回卖完了
第二个修改库存的时候,判断数据库中的stock > 0,如果是才能进行修改。
一人一单的问题
> 就是秒杀券,因为使用价值很高,所以一般来说只会让一个人买一张。所以有什么办法吗?
意思就是:让同一个用户只能操作数据库一次。
注意点
- 高并发的情况下,我完全可能一个用户,开启外挂,同时大量的去抢购这个票对吧。所以避免避免并发的情况,我们只能采取锁了,使用乐观锁可以吗?不行,因为我还没有下单,数据库中都没有我这条数据,没办法根据版本号法或者CAS方法,来进行判断是否更新数据库。所以只能采取 悲观锁。
- 锁住之后,执行业务。
- 一定要去先【订单表中】根据 当前用户id,和 优惠券id去数据库找,如果找到了说明数据库中有数据,就被让它再下单了。
- 如果订单表中没有找到,就生成订单数据,并存储到数据库中
- 并且将 【优惠表】对应的优惠券id那个库存自减,这就是超卖的那个逻辑嘛
执行流程
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
//1.查询 优惠券。 这个优惠券,差的不是普通的优惠券,而是秒杀券,所以要从秒杀券对应的库中找。
// 空指针判断,否则别人绕过前端,通过postman来访问你这个接口
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
//2.判断抢券活动是否开始
if (seckillVoucher == null) {
return Result.fail("优惠券不存在");//防止别人直接绕过前端,通过postman来访问你这个借口
}
//3.判断抢券活动是否结束
LocalDateTime beginTime = seckillVoucher.getBeginTime();
if (LocalDateTime.now().isBefore(beginTime)) { //当前时间在开始时间之前。
return Result.fail("秒杀尚未开始");
}
LocalDateTime endTime = seckillVoucher.getEndTime();
if (LocalDateTime.now().isAfter(endTime)) { //当前时间是在结束时间之后。
return Result.fail("秒杀已经结束了");
}
//4.判断库存是否充足
if (seckillVoucher.getStock() < 1) {
return Result.fail("库存不足了");
}
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) { // 这是最后倒数的几步:这样加锁,就能确保,方法执行完毕(提交事物之后),就释放锁。
//必须去启动类,暴露代理对象,这样才能直接获取。
// 为什么使用:IVoucherOrderService接收,,因为AOP是对IVoucherOrderService 这个实现类的方法进行功能完善的。使用到AOP的。
// 所以它的代理对象一定是:IVoucherOrderService接口来的。
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();//这个时候就拿到代理对象了。
return proxy.getResult(voucherId);//这样事物就能生效了
}
//还没有完,因为 getResult(voucherId) 这个方法,添加了事物。
//而我们调用这个方法是用过this来完成了。this调用这个方法,就是普通方法,跟没加@Transactionanl一摸一样的效果。这是根据 AOP来实现的。
// 如果使用this就没有AOP的功能了。所以必须使用代理对象来调用getResult()这个方法,才能有AOP的功能。
//事物失效的解决方案:
// 拿到事物的代理对象,然后通过代理对象,来执行这个getResult
//最后必须得导入依赖。
//启动类,去暴露道理对象
}
@Transactional//最后是添加事物,设计到两张表的操作。这样一旦出现问题就可以回滚。
public Result getResult(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// synchronized (userId.toString().intern()) { //但是userId.toString()底层是通过newString来执行了,所以每一个线程进来都会newString,仍然锁不住。
//所以要调用intern()这个方法,只要你传过来的值一样,只要我电脑上还有这个值,那么就一定能取出来,并且不会new。
//而不同的用户不会被锁定,那么性能就能提高。
//方法内部加锁,仍然可能会出现一个问题。
// 流程一般是这样了,执行了相关业务之后,释放锁,然后提交事务。
// 就是这个事物有spring操控的,不一定说释放锁之后就马上提交事物。
// 所以当我们释放锁的时候,此时有大量的请求进来,查询订单的话,我们之前事物还没有提交对吧,那说明还没有完成插入操作。 所以说再次查询的时候依然不存在,所以仍然可能会出现并发安全问题。
// 所以我们要再事物提交之后,才能释放锁。
// 所以不能锁在方法上,同时又不能锁在方法内。同时又要通过id来锁。 --->那么就只能在调用这个方法之前,获取id,然后通过锁id,锁的代码就是调用这个方法。
//TODO 一人一单
//查询订单
int count = query()
.eq("user_id", userId)
.eq("voucher_id", voucherId)
.count();
if (count > 0) {
return Result.fail("用户已经购买过了");
}
//5.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1")// stock = stock -1;
.eq("voucher_id", voucherId) //where id = voucherId
.gt("stock", 0) // and stock = getStock ×:相等会导致成功率过低。 提高成功率的方案:只要stock > 0即可。
.update();
if (!success) {
//更新失败,一般来说就是库存不组
return Result.fail("库存不足");
}
//6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 订单id ,使用id生成器自动生成的
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 用户id,从登录拦截器中获取用户id
UserDTO user = UserHolder.getUser();
voucherOrder.setUserId(user.getId());
// 代金券id
voucherOrder.setVoucherId(voucherId);
//将订单写入数据库
//7.返回订单id
return Result.ok(orderId);
// }
}
}