1. 准备工作
秒杀业务首先的需求就是对于全局的id是有要求的
以秒杀业务的订单为例,思考一下订单表如果使用数据库自增的id会存在什么问题?
- id的规律性太明显,数据库自增的id每次都是加1,这样可能会导致泄露信息给用户
- 其次,还会受到单表数据量的限制,当这张表使用自增id,如果后期数据量变得庞大起来,对于数据库的分库分表会造成极大的麻烦
引入全局id生成器的作用:
全局id生成器是一种在分布式系统下用来生成全局唯一ID的工具,全局id生成器必须要满足几个要求:
- 唯一性
- 高可用
- 高性能
- 递增性
- 安全性
全局id生成器的实现:
- 雪花算法
- UUID
- 使用redis实现
其中雪花算法和使用redis的操作都是企业中比较常用的
我们以Redis为例
我们知道,redis是可以生成自增的数值的,但是为例增加ID的安全性,我们还应该拼接一下其他信息,我们采用和雪花算法类似的思想
这里我们手写一个Redis的ID生成器
// Redis的全局生成器
@Component
public class RedisIdWorker {
/**
* 开始时间戳
*/
private static final long BEGIN_TIMESTAMP = 1640995200L; // 这里是我生成的当前时间的秒数
/**
* 序列号位数
*/
private static final int COUNT_BITS = 32; // 这里采用的32位
@Resource
private StringRedisTemplate stringRedisTemplate; // 注入spring自带的redis序列化工具,默认键值都是String类型
public long nextId(String keyPrefix) { // 这里的参数是业务前缀,拼接key用的,可以让你的redis缓存结构更加清晰
// 1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSeconf(ZoneOffset.UTC) // 当前秒数
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2.生成序列号,Redis的自增策略
// 2.1 获取当前日期,精确到每天,这样做可以让每一天产生的订单使用相同的key,还可以避免超过缓存数量上限
String date = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
long count = stringRedistemplate.opsForValu().increment("icr:" + keyPrefix + ":" + date); // 拼接业务的key
// 因为返回的是long类型,这里我们采用位运算拼接id并返回 => 时间戳左移32位,并且低位来补充count
return timestamp << COUNT_BITS | count;
}
}
2. 开始实现秒杀业务
2.1 初始代码
/**
* 业务场景:我们以优惠券为例,优惠券有普通优惠券和秒杀类优惠券,秒杀类优惠券是普通优惠券的一个类别,我们 * 在每次抢到一张秒杀优惠券时,都要生成一个订单来记录,同时优惠券的总数要实现减1,并且在每次抢购之前,确
* 保时间必须在有效时间范围内
*/
@Resource
private ISeckillVoucherService seckillVoucherService;
// 注入我们先前定义好的ID生成器
private RedisIdWorker redisIdWorker;
// 秒杀
@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 success = seckillVoucherService.update()
.set("stock = stock - 1")
.eq("voucher_id",voucherId).update();
// 判断是否扣减成功
if(!success) {
// 扣减失败
return Result.fail("库存不足!");
}
// 6.成功,可以创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2 用户id
Long userId = UserHoler.getUser().getId(); // 这里我是将用户存在了登录拦截器的一个ThreadLocal里面,直接从里面取用户信息了
voucherOrder.setUserId(userId);
// 6.3 代金券id
voucherOrder.setVoucherId(voucherId);
// 7.把订单写入数据库
save(voucherOrder);
// 8.返回订单id
return Result.ok(orderId);
}
2.2 超卖问题
上面的代码看着挺合乎逻辑是吧,判断了购买时间,判断了库存,判断了是否购买成功等等。看着逻辑挺严密。但是如果你用jmeter模拟高并发场景去测试一下,你会惊奇的发现你的数据库里面的库存会变成负的。上面的代码会产生严重的超卖问题。
什么是超卖问题?
超卖问题是一种典型的多线程安全问题,假设一下,如果你的库存是100,但是有200个人同时执行了判断库存是否充足的逻辑,那他们得到的结果都是仍然有库存,然后继续执行购买逻辑,当他们执行完之后,你的库存就会变成-100了。
超卖问题的解决方案:
针对超卖问题,主要的方案就是给代码加锁
加悲观锁
悲观锁会认为线程安全问题一定会发生,因此在执行之前会先获取锁,确保线程串行执行,当我执行的时候,你就必须等待。这种方式简单粗暴,但是会降低执行效率- 例如 Synchronized,Lock 都是常见的悲观锁
加乐观锁
乐观锁认为线程安全问题不一定会发生,因此不进行加锁操作,只是在更新数据时去判断有没有其他线程对数据做了修改- 如果没有修改则认为是安全的,自己才更新数据
- 如果已经被其他线程修改,此时可以重试或者异常
悲观锁的操作很容易实现,这里就不做赘述了,我们分析乐观锁的实现
上面我们知道,乐观锁的关键是判断之前查询得到的数据是否有被修改过,那怎么实现呢?
- 版本号法:线程在每次得到数据时,都会得到一个版本号,在执行更新数据时,会判断当前的版本号是否和自己之前拿到的版本号一致,一致的话才执行修改操作,并且在修改之后,将版本号+1。
- CAS法:Compare And Swap,比较并交换,这种方法不再需要版本号作为额外的条件,而是直接将自己需要修改的字段作为条件,在执行修改时,先判断此时的值是否和之前的值一致,同样,一致才执行修修改。
–
2.3 第一次的修改
经过了前面的分析,我们可以对前面的代码做一次简单的修改
/**
* 业务场景:我们以优惠券为例,优惠券有普通优惠券和秒杀类优惠券,秒杀类优惠券是普通优惠券的一个类别,我们 * 在每次抢到一张秒杀优惠券时,都要生成一个订单来记录,同时优惠券的总数要实现减1,并且在每次抢购之前,确
* 保时间必须在有效时间范围内
*/
@Resource
private ISeckillVoucherService seckillVoucherService;
// 注入我们先前定义好的ID生成器
private RedisIdWorker redisIdWorker;
// 秒杀
@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 success = seckillVoucherService.update()
.set("stock = stock - 1")
.eq("voucher_id",voucherId)
.eq("stock",voucher.getStock()) // CAS:判断此时的库存是否和之前的库存一致
.update();
// 判断是否扣减成功
if(!success) {
// 扣减失败
return Result.fail("库存不足!");
}
// 6.成功,可以创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2 用户id
Long userId = UserHoler.getUser().getId(); // 这里我是将用户存在了登录拦截器的一个ThreadLocal里面,直接从里面取用户信息了
voucherOrder.setUserId(userId);
// 6.3 代金券id
voucherOrder.setVoucherId(voucherId);
// 7.把订单写入数据库
save(voucherOrder);
// 8.返回订单id
return Result.ok(orderId);
}
对代码做了修改之后,我们继续使用jmeter测试,测试结果会发现大多数都失败了?为什么呢?
这是由于我们并没有加自旋机制,失败之后没有重试机制,因此,继续完善代码
// 5.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id",voucherId)
.gt("stock",0) // CAS:判断库存是否 > 0,大于0才修改
.update();
到这里,最简单的秒杀业务就已经实现了。接下来继续深入
3. 秒杀业务拓展 => 一人一单
3.1 初始代码
在实际的业务中,我们经常会要求同一个用户只能购买一张优惠券,那么又该怎么实现呢?
// 秒杀
@Override
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("库存不足");
}
Long userId = UserHolder.getUser().getId(); // 从前面的登录拦截器里面获取用户从而获取id
synchronized(userId.toString().intern()) {
// 获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
// 加悲观锁
@Transactional
public Result createVoucherOrder(Long voucherId) {
// 5.一人一单判断
Long userId = UserHolder.getUser().getId(); // 从前面的登录拦截器里面获取用户从而获取id
// 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")
.eq("voucher_id", voucherId)
.gt("stock",0) // CAS:判断库存是否 > 0,大于0才修改
.update();
if (!success) {
// 扣减失败
return Result.fail("库存不足");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7.2 用户id
voucherOrder.setUserId(userId);
// 7.3 代金券id
voucherOrder.setVoucherId(voucherId);
// 8.把订单写入数据库
save(voucherOrder);
// 9.返回订单id
return Result.ok(orderId);
}
我们通过乐观锁和悲观锁的配合,就可以实现这个需求。
但是!!
但是!!
但是!!
这代码就没有任何问题了吗?
我们将这个服务在idea里面crtl+D,复制一份,修改端口号,启动两个同样的服务,来模拟集群环境,继续jmeter测试。
我们会发现,一个用户出现了购买多张优惠券的情况
?????
其实,这里出现的问题是由于我们使用的 synchronized 锁只能保证在一个jvm里面生效,一个jvm的锁监视器只能监视自己的工作范围内的锁,不能保证集群环境下工作。而当我们启动两个集群时,就相当于有两台jvm,它们之间互不干扰,因此会出现一个用户购买多张优惠券的情况。
那这种情况怎么解决呢?
来到我们的主角,分布式锁
4.分布式锁
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁
分布式的条件:
- 多进程可见
- 互斥
- 高可用
- 高性能
- 安全性
分布式锁的核心是多进程之间互斥,而满足这一点的方式有很多,常见的主要有三种
这里我们来实现Redis的分布式锁
实现分布式时需要实现的两个基本方法:
- 获取锁
# 添加锁,利用sexnx的互斥特性
SETNX lock thread1
# 添加过期时间,避免服务宕机引起的死锁
EXPIRE lcok 10
释放锁
- 手动释放
# 手动释放,直接删除即可 DEL key- 超时释放,获取锁时添加一个时间,超时自动释放
4.1 分布式锁的实现
定义接口
public interface ILock {
/**
* 尝试获取锁
* @param timeoutSec 锁持有的超时时间,过期后自动释放
* @return true代表获取锁成功;false代表获取锁失败
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
实现接口
public class SimpleRedisLock implements Ilokc {
private String name; // 业务名称,后面拼接锁的key
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-"; // true标识去掉横线
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name,threadId,timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success); // 防止拆箱时可能发生的异常
}
@Override
public void unlock() {
// 获得线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁中的标识
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断标识是否一致
if (threadId.equals(id)) {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
}
最基本的分布式锁就已经实现了
思考一下,还有优化的空间吗?
考虑一下这种情况:
假设当前线程在执行unlock()时,已经判断了锁中的标识就是自己的,也就是说可以执行释放锁操作了,但是此时这个线程阻塞了,直到锁自动过期了都还没有恢复运行,此时另一个线程可以获取锁了,并且此时之前那个阻塞的线程恢复运行,将要执行释放锁的操作了,但是此时的锁并不是自己的,不就又误删了吗?
简单来说,判断锁标识和释放锁时应该具有原子性的。
那怎么实现原子性呢,事务?其实这里加事务并不容易实现,因此我们提供了另一个解决思路。
我们编写一个lua脚本,里面编写多条Redis的命令,确保多条命令的原子性。
(Lua语言对于redis操作的学习成本很低,菜鸟上很多教程)
4.2 编写lua脚本
我们在resource目录下编写lua脚本,脚本内容其实就是前面unlock的内容
-- 获取锁中的线程标识 get key
local id = redis.call('get',KEYS[1])
-- 比较线程标识和锁中标识是否一致
if(id == ARGV[1]) then
-- 释放锁 del key
return redis.call('del',KEYS[1])
end
return 0
然后修改代码
public class SimpleRedisLock implements ILock{
private String name; // 业务名称,后面作为锁的key
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-"; // true标识去掉横线
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static { // 静态代码块,在类加载的时候就初始化完成
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name,threadId,timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success); // 防止拆箱时可能发生的异常
}
/**
* 基于lua脚本
*/
@Override
public void unlock() {
// 调用lua脚本,实现redis操作的原子性
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId()
);
}
此时这个分布式锁已经是一个较为完善的锁了,已经可以投入使用了。只是缺少了一些功能机制,比如不可重入,不可重试,超时释放的安全隐患,主从的一致性等等,但是这个分布式锁的思想是很完善的。
其实已经有分布式锁的框架可以使用了
Redisson就提供了许多分布式服务,其中就包含了各种分布式锁的实现,感兴趣的可以去学!!