一、全局ID生成器
每个店铺都可以发布优惠券:
当用户抢购时,就会生成订单并保存到 tb_voucher_order 这张表中,而订单表如果使用数据库自增ID就存在一些问题:
id自增的问题:
- id的规律性太明显:今天下单id=1,明天下单id=100,就暴露了销售额信息等
- 受单表数据量的限制:数据量大的时候,单表不能保存如此多的数据。如果分表的话,每张表就会计算自己的自增长,ID就会出现重复,而订单是唯一的(违背了唯一性)
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般满足下列特性:
- 唯一性
- 高可用:基本不会down机
- 高性能:速度快
- 递增性:变大的
- 安全性
利用Redis的incr命令,为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其他信息---数值类型,java里的Long类型,8个字节共64位
ID的组成部分:(时间相同的情况下,序列号不一样)
- 符号位:1bit,永远为0
- 时间戳:31bit,以秒为单位,可以使用69年
- 序列号:32bit秒内的计数器,支持每秒产生2的32次方的不同ID
代码实现
utils包下定义一个类 RedisIdWorker (基于Redis的Id生成器)
注意:该自增是利用Redis的自增方法,我们位移后获取的是二进制因为我们左移了,32+32,二进制的低32位就是我们的count,高32位就是时间戳,如果高并发下时间戳一样,看起来就是自增的,如:
- 97354934231498754、2022-09-20 08:26:52
- 97354934231498755、2022-09-20 08:26:52
- 这两个id的创建时间都是一样的,只不过序列号不一样,序列号利用Redis自增,如果时间戳不一样,那十进制看起来区别就比较大了,如:97354960001302534,正式这种方式,更好
如:
- count=6
- 时间戳+count左移位前=22667218 6
- 时间戳+count左移位后的二进制:
- 0000 0001 0101 1001 1101 1111 1101 0010 0000 0000 0000 0000 0000 0000 0000 0110
- 移位后的二进制转十进制(整体二进制转十进制)
- id=97354960001302534
@Component
@Slf4j
public class RedisIdWorker {
/**
* 开始时间戳
*/
private static final long BEGIN_TIMESTAMP = 1640995200L;
/**
* 序列号位数
*/
private static final int COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 生成全局ID
*/
public long nextId(String keyPrefix) {
/**
* 生成时间戳
* 31位的数字,单位秒,他的值是要有一个 基础的时间 作为开始时间
* 时间戳(秒数) = 当前时间 - 基础时间,
* 即:从开始时间 隔了多少秒
*/
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
// 当前时间 - 基础时间 = 时间戳
long timestamp = nowSecond - BEGIN_TIMESTAMP;
/**
* 生成序列号
* 利用Redis的自增长 用字符串结构 默认一次自增 1,
* 不同的业务有不同的key,
* stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":");
* 这样不可以:
* 1、这样的话默认就是整个订单业务都是一个key,不管过了多少年,随着业务的订单越来越多
* ,而redis 单个key的自增长是有上限的,是2的64次方,虽然大也是有上限的
* 2、而且key里边真正用来记录序列号的,只有32个bit位,如果说将来key,超过了32个bit,那就存不下了。
* 所以,哪怕是同一个业务,也不能使用同一个key
* 解决:
* 我们可以在后边拼一个当期日期 比如20220910,到了第二号,就是一个新的key了20220911
* 这样就还有一个统计的效果
*/
// 获取当前日期,自定义格式化, 这样就还可以统计 年 月 日的单
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// 这里用基本类型,后面要做运算,如果key不存在,会自动创建的,不会有空指针
// 注意,插到库里这个key 是一个,value就是count 回覆盖之前
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
log.info("count={}", count);
/**
* 拼接并返回,利用位运算,
* 前面说了就当做long类型8个字节,
* 全局id = 0 + 时间戳 + 序列号
* 将时间的戳的值左移32位,然后空出来的32位,
* 利用 | 运算去将序列号填充即可
*
* 或运算| 一个为真即为真,现在后面的32位都是0,
* 而count的值 可能为0 可能为1,我们希望不管为0还是1都需要填充到后32位
* 0|0 = 0
* 0|1 = 1
* 即:count值将来是什么,后32位就保留什么了
*/
return timestamp << COUNT_BITS | count;
}
/**
* 计算基础(当前)时间秒数
*/
public static void main(String[] args) {
// of 方法可以指定年月日
LocalDateTime now = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
// 接收时期
long second = now.toEpochSecond(ZoneOffset.UTC);
System.out.println(second);
}
}
注意:每天一个key,方便我们去统计每天的订单量
总结
全局唯一ID生成策略:
- UUID,是一长串16进制的,没有自增长无规律,是字符串
- Redis自增,有规律的,例如我们的案例,是数字类型,long类型的64位数字
- snowflak(雪花算法,也是long类型的64位数字,性能理论上来讲比Redis好,但依赖于时钟,)
- 数据库自增,单独整一张表,N张表用的是同一张表的自增ID
Redis自增ID策略:(Redis保存key需要注意)
- 每天一个key,方便统计订单量(每天的,月的,年的)、限定key自增的值不会让key太大,以至于超过自增的上限
- ID结构是:时间戳+计数器
测试类:
private ExecutorService es = Executors.newFixedThreadPool(500);
@Test
void testIdWorker() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(300);
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
long id = redisIdWorker.nextId("order");
System.out.println("id = " + id);
}
latch.countDown();
};
long begin = System.currentTimeMillis();
for (int i = 0; i < 300; i++) {
es.submit(task);
}
latch.await();
long end = System.currentTimeMillis();
System.out.println("time = " + (end - begin));
}
二、实现优惠券的秒杀下单
实现优惠券秒杀下单
在VoucherController中提供了一个接口,可以添加秒杀优惠券;
http://localhost:8081/voucher/seckill
添加秒杀券也是优惠券,只不过在t_voucher实体类里已经把秒杀券的信息拿到了都,
{
"shopId":1,
"title":"100元代金券",
"subTitle":"周一至周五均可使用",
"rules":"全场通用\\n无需预约\\n可无限叠加\\不兑现、不找零\\n仅限堂食",
"payValue":8000,
"actualValue":10000,
"type":1,
"stock":100
}
2、实现下单接口,完成抢购功能
实现优惠券秒杀的下单功能:
下单时需要判断两点
- 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
- 库存是否充足,不足则无法下单
基本功能实现:
@Resource
private ISeckillVoucherService seckillVoucherService; // 秒杀券的业务层
@Resource
private IVoucherOrderService voucherOrderService; // 订单业务层
@Resource
private RedisIdWorker redisIdWorker; // 全局区域ID工具类
/**
* 当有两张表以上的操作时,要加上事务,出现问题会及时回滚,
* 否则无法回滚的
* @param voucherId
* @return
*/
@Override
@Transactional
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 isUpdate = seckillVoucherService.update().setSql("stock = stock-1").eq("voucher_id", voucherId).update();
if (!isUpdate) {
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);
// 6.4 保存订单信息
voucherOrderService.save(voucherOrder);
// 7、返回订单 id
return Result.ok(orderId);
}
以上代码有超卖问题
三、库存超卖问题
利用JMeter模拟高并发
/voucher-order/seckill/10
注意:这里我们需要在JMeter里加一个请求头,即:用户token的请求头,否则拦截器过不去,redis里找的:authorization:6a44cf0d7b564030906ad8ed9577285c
模拟结果,order表出现109条数据,库存是-9,
这样就出现了问题,我们要卖100个券,实际却卖了109张券
出现抢占资源的(并发安全)问题
加锁:悲观锁&乐观锁,这两个所只是一种理念
悲观锁:还有像数据库的互斥锁也是悲观锁
乐观锁:比如我查DB的优惠券库存,要更新了,更新之前我去判断一下,有没有别人修改库存,
乐观锁方式:
- 1、版本号机制
- 修改前判断版本号有无变化,where条件 如果查到了,那么就表示没人操作,我就可以修改,where条件如果没有查到,就说明有人操作了,重试或异常
- 说白了,版本号法是用版本来标识数据有没有变化,我们在第一步查到的版本,和更新时的版本一致,证明就没有人更新
- 2、CAS(比较 and set)机制
- 在版本号的基础上做了一些简化,我们这个业务,查数据的时候,库存是要查的,更新的时候,库存也要更新,库存和版本所做的事是一样的,so可以用库存来代替版本,实现方式同理
- 即用数据本身有无变化(库存)去判断线程是否安全
修改代码:然后继续执行JMeter,我们发现并不好用,库存只卖出了21件,订单也是21个
/**
* 扣减库存
* 修改代码,添加乐观锁,CAS机制
* 添加where条件让stock等于我们上边查到的stock值
* 如果查到了,说明没人修改,如果没查到说明有人修改了
* 光加这一个的话,库存并没有到0,数据库还有79个
*/
boolean isUpdate = seckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherId)
.eq("stock", seckillVoucher.getStock())
.update();
券还没卖完,就返回没有库存了,这就是设计乐观锁的一个弊端,当一个线程修改成功了,其余线程也在执行,发现没有查到,就修改失败了,这就浪费了一次线程任务,虽然修改失败了,但是并没有线程安全问题,我们需要做处理,不用不等于就报错,可以直接让stock只要大于0就好
/ * 我们修改一下,只要stock>0即可,不一定要必须等于,ge大于的意思
*/
boolean isUpdate = seckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
四、一人一单(同一个用户只能下一单)
解决办法就在tb_voucher_order里userId和VoucherId 联合查询,就能确定是同一个用户
解决思路:
代码片段:
Long userId = UserHolder.getUser().getId();
/**
* 新添加一人一单
*/
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count>0) {
return Result.fail("用户已经买过了"+count+"次!");
}
/**
* 以下的就继续走
*/
boolean isFlag = seckillVoucherService.update().
setSql("stock = stock-1").
eq("voucher_id", seckillVoucher.getVoucherId())
.gt("stock", 0)
.update();
if (!isFlag){
return Result.fail("库存不足");
}
跑批后发现数据库数据没有对上,200个线程,同一用户,却跑出了10个订单,以前是一人下100单,现在是一人下了10单,问题依旧存在。即:多线程并发的情况下,上述代码,大家都去查询,查询的count都是0,然后都往下走,去创建订单了,这就又出现多线程并发问题
注意: 这里不能用乐观锁了,乐观锁是在更新数据的时候用的,而我们这块的代码是新增,只能用悲观锁了
而锁的对象是不应该是this整个对象,而应该是一人一锁,即:是用户ID
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
/*
1、判断是否开始
2、判断是否结束
3、判断是否有库存
4、下单
5、减库存
6、返回
*/
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("活动尚未开始");
}
if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("活动已结束");
}
Integer stock = seckillVoucher.getStock();
if (stock<1) {
return Result.fail("库存不足");
}
/**
* 每个人,即:每个用户自己有独立的一把锁,
* 但是,要知道每次请求来的时候,我们创建的这个userId都是一个全新的对象,因此对象变了,锁也就没有意思了。
* 要求值是一样的,所以我们toString,转成字符串,锁的是值
* 而toString的底层代码里,也是return new String(buf, true); new了一个字符串
* 所以每调一次toString,也是一个全新字符串对象,锁对象也还是会变的,
* 每次来,虽然都是1011,即使转成字符串也是一个全新的对象,还是不可以,所以我们调用
* 调用toString.intern() 方法,
* 简单理解为去常量池里查,如果字符串常量池里有一个equals比较为true的,那就返回池子
* 中的字符串,不会new新的字符串了,这样锁的就是同一个用户了,而不同的用户就不会被锁定,这样性能就提高了
* 但是,有个问题,
* 我们开启事务开始执行,执行之后,先释放锁,才会提交事务,
* 而事务是有spring管理的
* 就是这个函数方法执行完后,由spring去做提交,而这个锁在 synchronized{} 大括号结束后已经释放了,
* 锁释放了,就意味着其他线程可以进来了,而此时事务尚未提交,那有其他线程进来查询操作,我们新增的这个订单可能还没有写入数据库
* 这个时候别的线程去查询可能依然不存在,存在并发安全问题,因此在里边锁的情况,锁的范围就有点小了,
* 应该是事务提交之后我们再去释放锁,我们应该把整个方法锁起来
*
* 事务失效:非public 目标对象
* 我们的事务是在另一个方法开启的,而不是在调用它的方法开启的,这就会导致spring管理的事务失效
* 在方法里调用别的方法,会导致事务失效,即:目标对象,而不是代理对象了
*
* 这里需要去拿到事务的代理对象才可以
* 借助一个API
*
*/
// return createVoucherOrder(voucherId);
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
/**
* 借助一个API 通过这个方法去拿到当前对象的代理对象 我们称为 普绕SEI,
* 而 当前代理对象就是他的service - > IVoucherOrderService
*/
// 这样我们就拿到了当前代理对象了
// 获取代理对象,和事务有关的代理对象,然后再去调用方法函数就没有问题了
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
/*
用代理对象去调用该函数,而不是用this调用,这样的话这个函数就会被spring管理了
因为proxy 这个对象是由spring创建的,所以proxy.createVoucherOrder2(voucherId, userId);
他是带有事务的这样一个函数
函数不存在的原因是因为IVoucherOrderService接口不存在这个函数,我们创建就好了
我们是在实现类里做的,我们创建一下就好了,
IVoucherOrderService接口有了,我们才能基于接口去做调用
现在我们的事务才能生效
这么做的话还需要做两件事
1、新添加依赖,aspectj包下的aspectjweaver 这样一依赖,(动态代理的模式)
2、启动类添加一个注解:去暴露这个代理对象:
@EnableAspectJAutoProxy(exposeProxy = true) 默认值是false
将默认值改为true,false是不会暴露的,不暴露的去获取是获取不到的,
一但暴露设置好了,我们这样就可以拿到代理对象了
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
*/
return proxy.createVoucherOrder2(voucherId, userId);
}
}
@Transactional
public Result createVoucherOrder1(Long voucherId, Long userId) {
/**
* 每个人,即:每个用户自己有独立的一把锁,
* 但是,要知道每次请求来的时候,我们创建的这个userId都是一个全新的对象,因此对象变了,锁也就没有意思了。
* 要求值是一样的,所以我们toString,转成字符串,锁的是值
* 而toString的底层代码里,也是return new String(buf, true); new了一个字符串
* 所以每调一次toString,也是一个全新字符串对象