优惠券秒杀
- 1.全局唯一ID
- 2.实现优惠券秒杀下单
- 3.超卖问题
- 4.一人一单
- 一人一单的并发安全问题
- 5.分布式锁
- 基于Redis的分布式锁
- Redis分布式锁误删问题
- 在这里插入图片描述
- 分布式锁的原子性问题
- 6.Redis优化秒杀
- 7.Redis消息队列实现异步秒杀
1.全局唯一ID
- 订单表如果使用数据库自增ID就存在一些问题:id的规律性太明显、受单表数据量的限制
全局唯一ID生成策略:
- UUID
Redis自增
snowflake算法
数据库自增
Redis自增ID策略:
- 每天一个key,方便统计订单量
ID构造是 时间戳 + 计数器
2.实现优惠券秒杀下单
说明 | |
请求方式 | POST |
请求路径 | /voucher-order/seckill/{id} |
请求参数 | id,优惠券id |
返回值 | 订单id |
- 下单扣库存
- 下单时需要判断两点:
- 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
- 库存是否充足,不足则无法下单
@Resource
private IVoucherOrderService voucherOrderService;
/**
* 秒杀下单功能
* @param voucherId
* @return
*/
@PostMapping("seckill/{id}")
public Result seckillVoucher(@PathVariable("id") Long voucherId) {
return voucherOrderService.seckillVoucher(voucherId);
}
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
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()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId)
// .eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.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 = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}
3.超卖问题
- 用Jemeter测试时记得带上请求头
- - 高并发情况下出现了超卖现象。
- 原因:在高并发情况下,假设线程 1 查询库存,查询结果为 1 ,当线程 1 准备要去扣减库存时,存在其他线程也去查询库存,结果查询出来的库存数也是 1,那么这时所有的线程查询到的库存数都是大于 0 的,所有的线程都会去执行扣减操作,就会导致超卖问题。
- 解决办法:
- 业务失败率特别高
- 修改方法:库存数只要大于0,就应该允许所有线程都能够执行扣减逻辑。
- 结果:没有超卖
- 总结:
超卖这样的线程安全问题,解决方案有哪些?
- 悲观锁:添加同步锁,让线程串行执行
优点:简单粗暴
缺点:性能一般 - 乐观锁:不加锁,在更新时判断是否有其它线程在修改
优点:性能好
缺点:存在成功率低的问题
- 像上面的库存这种案例比较特殊,只需判断库存是否大于 0 即可,但是有些情况可能就只能通过判断数据有没有发生变化,这种情况要想提高成功率,可以采用分批加锁(分段锁)的方案,将数据资源分成几份,以库存为例,假设库存数 100,可以将库存分到 10 张表中,每张表中库存数为 10,用户在抢购的时候可以在多张表中进行抢购,这样成功率就会提高。
4.一人一单
其实跟优惠券秒杀逻辑一样,在并发情况下,此时有 10 个线程同时查询当前用户是否对当前优惠券下过单,而所查询到的结果均为 0,也就并发执行了扣减逻辑。如何解决呢?能不能像扣减逻辑一样使用乐观锁呢?答案是不行,乐观锁只能针对并发更新数据的情况,而此处我们执行的是查询操作,只能使用悲观锁,即同步代码块的方式。
- 解决方法:
- 将代码抽取成方法;
- 将判断用户是否下过单的逻辑以及扣减逻辑抽取成方法是为了更好地进行加锁。另外,由于扣减逻辑是在抽取出的方法中执行的,即操作数据的逻辑,所以也就将注解 @Transactional 添加到该方法上(该注解主要用于事务管理)。为什么不在方法上添加 Synchronized,即加锁呢?将 synchronized 加在方法上,那么同步锁就是当前对象。不过,不建议将 synchronized 加在方法上,那么锁的范围就变成了整个方法,而且锁的对象是 this,即当前对象,也就说明任何一个用户执行该方法时,都会添加这个锁,而且是同一个锁,那么整个方法就会串行执行,整体性能就会变差。所谓的一人一单,实际上针对只是同一个用户,只有同一个用户在执行该方法时,才会进行判断该用户的并发安全问题,如果不是同一个用户,是不需要加锁的。那么锁的对象就不应该是 this,而是用户 id,将锁定资源范围缩小。synchronized 代码块中的锁,为什么要将 Long 类型的 userId 转化为 String 类型?由于 synchronized 代码块的锁为
( )
内的对象,每次请求时得到的 userId 不是同一个对象,必然是不同的锁,因此需要比较其数值。即我们在对用户 id 加锁时,应当保证同一个用户的 id 的值是一样的,但是 userId.toString() 方法,(String 类是不可变的,对String 操作都会返回新的 String 对象),即每次toString() 之后生成的是不同的 String 对象,它在底层实际上是重新 new 了一个字符串,所以 userId 每调一次 toString 方法,就会生成一个全新的字符串对象,这里我们就需要调用字符串的 **intern() 方法,**intern() 方法会手动将字符串添加进字符串常量池,在下一次调用 intern() 方法时,会首先去字符串常量池中查找当前字符串是否存在,如果存在则直接将常量池中数据返回,如果不存在,则将其添加进常量池。为什么不转为基本类型,而是转为 String 呢?synchronized 锁为对象,基本类型不属于引用类型。 - 注意:要先获取锁,将整个函数锁起来,在整个事务提交之后再释放锁。锁不能加在方法内部:开启事务,开始执行,获取锁,检查库存,提交订单,释放锁,提交事务【因为事务被spring管理,事务的提交在函数执行完之后由spring提交】,这个时候锁已经释放了,意味着其他线程已经可以进来了,而事务尚未提交,如果有其他线程进来查询订单,刚刚新增的订单很可能尚未写入数据库,所以依然可能存在并发安全问题。
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();
synchronized (userId.toString().intern()) {
//获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {
//判断是否购买过(一人一单)
Long userId = UserHolder.getUser().getId();
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") // set stock = stock - 1
// .eq("voucher_id", voucherId).eq("stock",voucher.getStock())
.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.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 = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
一人一单的并发安全问题
- 模拟业务场景
- 假设做集群部署时,有两个 Tomcat,每个 Tomcat 都有各自的 JVM。在 每个 JVM 内部会维护一个锁的监视器对象,而秒杀方法中,锁的监视器对象用的是用户 id,而用户 id 是放置在了常量池中,一个 JVM 中维护一个常量池,那么不同的 JVM 有不同的常量池,所以当其他线程去执行秒杀逻辑时,获取不到 其他 JVM 中已经存在的锁监视器对象,这种情况下就又会出现线程安全问题。
5.分布式锁
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
不同的服务启动不同的 JVM。在集群模式下,synchronized 锁只能保证单个 JVM 内部的多个线程之间的互斥,而没有办法让集群下的多个 JVM 进程之间互斥,想要解决这个问题,就不能再使用 JVM 内部的锁监视器,必须让多个 JVM 使用同一个锁监视器,因此该锁监视器必须是一个在 JVM 外部的、多 JVM 进程都能看到的锁监视器。这个时候,无论是 JVM 内部还是多 JVM 进程都应该来找该锁监视器。
基于Redis的分布式锁
- 在获取锁时,成功返回 OK,失败返回 nil。但是失败以后,又该如何操作呢?有两种处理方式,一种是阻塞式获取,即获取失败后会一直等待锁释放;另一种是非阻塞式获取,即获取失败后立即返回。在这里,我们使用非阻塞式获取的方式,阻塞式获取的方式,对内存有一定的浪费且实现起来比较困难。
非阻塞:尝试一次,成功返回 true,失败返回 false - 超时释放:避免服务宕机
- 但需保证操作的原子性,在添加锁的同时设置过期时间,如果先添加锁,此时 Redis 服务恰好宕机,那么锁就没法释放,可以使用下面的命令保证操作的原子性。
# EX 后跟过期时间,NX 保证互斥特性
set lock thread1 EX 10 NX
- 代码实现
public interface ILock {
/**
* 尝试获取锁
* @param timeoutSec 锁持有的超时时间,过期后自动释放
* @return true代表获取锁成功; false代表获取锁失败
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
public class SimpleRedisLock implements ILock{
/**
* 锁名称,不能固定写死,应当根据业务传入进来
*/
private String name;
private StringRedisTemplate stringRedisTemplate;
/**
* 锁前缀
*/
private static final String KEY_PREFIX = "lock:";
/**
* 构造函数
*
* @param name 锁名称,创建 SimpleRedisLock 实例时传入
* @param stringRedisTemplate
*/
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标识
long threadId = Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
@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,
// 如果不加,则是针对的所有用户,那么只要有用户获取到锁,其他用户就无法在进行操作。
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
// 获取互斥锁
boolean isLock = lock.tryLock(1200);
if (!isLock) {
// 获取锁失败,直接返回失败或者重试
return Result.fail("不允许重复下单!");
}
try {
//获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
//释放锁
lock.unlock();
}
}
Redis分布式锁误删问题
- 解决方法:造成这种情况的原因,其实就在于线程 1 在释放的时候将别人的锁释放掉了,如果在释放锁的时候,判断下当前线程与锁中存放的标识是否一致(此处业务在获取锁时将线程 id 存放到了锁监视器中),就可以避免这个问题。注意是在分布式存在多个JVM的情况下:不仅要区分不同的线程,还要区分不同 JVM上的线程。
- 修改之前的分布式锁实现,满足:
1.在获取锁时存入线程标识(可以用 UUID 表示)
2.在释放锁时先获取锁中的线程标识,判断是否与当前线程标识一致。如果一致则释放锁,如果不一致则不释放锁。 - 原理:使用 uuid + threadid 拼接字符串标识不同JVM上的不同线程
① 使用 UUID 区分不同的服务 JVM
② 使用 线程ID 区分JVM 的不同线程 - 代码
public class SimpleRedisLock implements ILock{
/**
* 锁名称,不能固定写死,应当根据业务传入进来
*/
private String name;
private StringRedisTemplate stringRedisTemplate;
/**
* 锁前缀
*/
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
/**
* 构造函数
*
* @param name 锁名称,创建 SimpleRedisLock 实例时传入
* @param stringRedisTemplate
*/
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@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);
}
}
}
分布式锁的原子性问题
- 要解决这个问题,**就必须确保判断锁标识的动作和释放锁的动作一起执行,不能存在间隔。**那如何保证两个动作的原子性呢?
- 解决::Lua 脚本解决多条命令原子性问题
public class SimpleRedisLock implements ILock {
private String name;
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) + "-";
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 success;
return Boolean.TRUE.equals(success);//防止自动拆箱带来的安全风险 Boolean——>boolean ,防止success为null,拆箱就变成空指针了
}
@Override
public void unlock() {
// 调用lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
6.Redis优化秒杀
- 用Redisson实现
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(){
//配置
Config config = new Config(); config.useSingleServer().setAddress("redis://127.0.0.1:6379");
//创建 RedissonClient 对象
return Redisson.create(config);
}
}
- 秒杀业务的优化思路是什么?
- 先利用Redis完成库存余量、一人一单判断,完成抢单业务
- 再将下单业务放入阻塞队列,利用独立线程异步下单
- 基于阻塞队列的异步秒杀存在哪些问题?
- 内存限制问题
- 数据安全问题
7.Redis消息队列实现异步秒杀
- 使用阻塞队列完成异步秒杀存在的问题
- 内存限制问题:我们自定义的阻塞队列的大小是我们自己设置的,一旦订单数量过多,导致阻塞队列内存占满,此时就会有订单丢失的风险。
- 数据安全问题:一旦我们redis服务宕机,阻塞队列的内存就会被清空,用户下单的数据也会随之丢失,因此存在数据的安全问题。
- 消息队列和阻塞队列的区别:
- ① 消息队列是在 JVM 以外的独立服务,所以不受 JVM 内存的限制
- ② 消息队列不仅仅做数据存储,还需要确保数据安全,存入到消息队列中的所有消息都需要做持久化,这样不管是服务宕机还是重启,数据都不会丢失。而且消息队列还会在消息投递给消费者后,要求消费者做消息确认,如果消费者没有确认,那么这条消息就会一直存在于消息队列中,下一次会继续投递给消费者,让消费者继续处理,直到消息被成功处理。
- Redis提供了三种不同的方式来实现消息队列:
- list结构:基于List结构模拟消息队列
- PubSub:基本的点对点消息模型
- Stream:比较完善的消息队列模型