文章目录
- 全局ID生成器
- 超卖
- 乐观锁
- 一人一单
- 悲观锁
当我们确认订单时,系统需要给我们返回我们的订单编号。这个时候就会出现两个大问题。
1.订单id采用数据库里的自增的话,安全性降低。比如今天我的订单是10,我明天的订单是100,那么我就可以知道在昨天总共有多少订单,从而给恶意用户钻空子。
2.订单数很多时,订单id增长到几百上千万,单张表无法存储大量数据,那就需要将这些数据分到多张表,同时要重新设计订单id,避免出现相同ID。
所以,这里我们使用全局ID生成器:
全局ID生成器
在分布式系统下用来生成全局唯一ID的工具。
其基本核心就是ID的生成:
符号位:正负数
时间戳:当前时间减初始时间
序列号:基于redis自增INCR命令
对存储在指定键中的整数值进行原子性递增的核心命令.
当key不存在时,redis自动创建一个新key,并设置其value为0.然后执行incr操作,将value递增为1并返回。
key存在时,直接将value递增。
@Component
public class RedisIdWorker {/*** 开始时间戳*/private static final long BEGIN_TIMESTAMP = 1640995200L;//2022年1月1日0点0分0秒/*** 序列号的位数*/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;}
}
超卖
在简单的优惠券秒杀下单中,我们的基本步骤:
1.根据优惠券id查询是否存在
2.确认抢购时间在时间范围内
3.确认优惠券库存>0
4.根据RedisIdWorker生成订单id,优惠券数量减1
这在简单的场景下是没有问题的,但是在实际场景中,我们要考虑到多线程导致的超卖问题。
现在有100张优惠券,有200人来抢
理论上来说,应卖出100张,有100人抢到,但事实却是多卖出了9张
当涉及多线程时,各个线程的运行顺序我们是无法肯定的。
当线程1查询库存为1时,线程2插进来了,也查到为1,线程1按照逻辑扣减库存,线程2也按照逻辑扣除库存,这样就导致最终库存为-1。更多个线程,可能导致库存更低,这就是超卖。
乐观锁
悲观锁和乐观锁都只是一种思想!
乐观锁:先操作,提交时再检查冲突
认为并发操作很少发生冲突,只在提交操作时检查是否冲突,比如CAS操作,数据库的乐观锁和Java中的Atomic类。
举个例子:
1.购物车结算时才检查库存(默认没人抢购)
2.或者在网上订票,系统显示还有1个座位,你点击预订,系统会先让你填写信息,然后提交的时候检查是否还有座位。如果有,预订成功;如果没有,提示你重新选择
这里就以乐观锁为核心解决方法:判断之前查询到的数据是否有被修改过。
-
版本号法
给优惠券再设置一个字段“版本号”,初始值为1,每次被修改就加1。
这样每个线程在查询到库存和版本号时,要想修改数据,必须在当前版本号基础上实现,否则不成功。
-
CAS法
本质还是版本思想,做了简化,每个线程在查询到库存后,要想修改数据,必须在当前库存基础上实现,否则不成功。
但是乐观锁同样存在问题,当其他线程发现数据被修改后,他就不再执行,导致优惠券没有卖完。
所以这里其他线程只需要在将修改条件改为stock>0。只要有库存,我就可以减。
这样会不会恍然中带点疑惑:这跟最初有什么区别?都是判断库存是否>0。
NO,最初的问题出现在先判断,再修改;而现在是要修改的时候才做判断。
@Transactionalpublic Result createVoucherOrder(Long voucherId) {// 5.一人一单Long userId = UserHolder.getUser().getId();// 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.订单idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);// 7.2.用户idvoucherOrder.setUserId(userId);// 7.3.代金券idvoucherOrder.setVoucherId(voucherId);save(voucherOrder);// 7.返回订单idreturn Result.ok(orderId);}}
一人一单
悲观锁
悲观锁:提前加锁
认为并发操作一定会发生冲突,因此每次访问数据时都会加锁,比如synchronized和ReentrantLock。
举个例子:出门时锁门(默认有小偷)
上面的解决中,还存在一个问题:一个用户不可以买多张优惠券。
那如果我们直接简单的判断该用户是否下过单来处理的话:
// 5.1.查询订单int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();// 5.2.判断是否存在if (count > 0) {// 用户已经购买过了log.error("不允许重复下单!");return;}// 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) {// 扣减失败log.error("库存不足!");return;}// 7.创建订单save(voucherOrder);
假设用户A同时发起多个请求,每个请求都执行这段代码。这时候,可能会出现多个线程同时通过第5.1步的查询(count=0),然后都进入扣减库存和创建订单的步骤,导致用户A创建了多个订单,违反了“一人一单”的要求。
为什么会这样?
两个线程同时执行查询时,此时数据库中还没有该用户的订单,所以两个线程都认为可以继续执行。然后它们都会去扣减库存,假设库存足够,两个线程都成功扣减,然后各自创建订单。
所以我们最终的解决方法就是再加上一个悲观锁:
一个用户加一把锁(确保不会重复下单),不同用户加不同锁
@Transactionalpublic Result createVoucherOrder(Long voucherId) {// 5.一人一单Long userId = UserHolder.getUser().getId();synchronized (userId.toString().intern()) {// 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.订单idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);// 7.2.用户idvoucherOrder.setUserId(userId);// 7.3.代金券idvoucherOrder.setVoucherId(voucherId);save(voucherOrder);// 7.返回订单idreturn Result.ok(orderId);}}
synchronized (userId.toString().intern())
这里为什么通过用户ID来加锁,为什么是userId.toString().intern()?
synchronized:实现线程同步,确保同一时刻只有一个线程可以执行某个代码块或方法。
toString()
将userId
转换为字符串,虽然是同一个userId,但是会新生成不同的字符串对象。public static String toString(long i) {int size = stringSize(i);if (COMPACT_STRINGS) {byte[] buf = new byte[size];getChars(i, size, buf);return new String(buf, LATIN1);} else {byte[] buf = new byte[size * 2];StringUTF16.getChars(i, size, buf);return new String(buf, UTF16);}}
而
intern()
方法会返回该字符串在常量池中的引用,确保相同值的字符串引用同一个对象,从而正确同步。
- 如果常量池已存在相同值的字符串,直接返回该引用;
- 如果不存在,将该字符串加入常量池后再返回引用。
不过又发现一个问题:这里用户加锁-操作-释放锁,但如果此时事务还没有提交上去,其他线程来了,依然可能出现并发问题。
我们希望整个事务提交上去后再释放锁。
也就是给这个函数加上锁。
当函数1(无事务)调用这个函数2时(有事务),事务是否还生效?
事务
当我们在一个类的方法上使用 @Transactional注解时,Spring会为该类创建一个代理对象。这个代理对象在调用方法时会处理事务的开启、提交或回滚等操作。
如果在一个类内部的方法A调用另一个有@Transactional注解的方法B,这时候方法A调用的是实际的实例方法,而不是通过代理对象调用的。因此,事务不会生效,因为代理对象没有被使用到。
解决:
不断学习中,感谢大家的观看>W<