目录
各个部分的问题以及技术问题解决方案如下
1. 全局唯一 ID 生成
- 问题:如何保证生成的订单 ID 唯一且高效。
- 技术解决方案:
- 使用 Redis 的
incr
自增键生成订单 ID,确保高并发情况下 ID 唯一。 - 拼接时间戳确保每天生成的 ID 不重复。
- 使用 Redis 的
2. 秒杀订单创建
- 问题:如何高效、准确地创建秒杀订单并保证库存一致性。
- 技术解决方案:
- 使用 Redis 分布式锁避免超卖。
- 使用乐观锁(CAS)确保库存扣减安全。
3. 库存超卖问题
- 问题:并发请求下,库存查询与扣减不一致,可能导致超卖。
- 技术解决方案:
- 使用悲观锁确保库存操作的线程安全。
- 使用乐观锁(CAS)避免并发条件下的数据不一致。
4. 分布式锁与超卖防范
- 问题:多个分布式节点可能导致重复扣减库存,出现超卖。
- 技术解决方案:
- 使用 Redis 或 Redisson 实现分布式锁,保证只有一个线程能够操作库存。
- 加入锁超时机制,避免死锁发生。
5. 异步处理优化
- 问题:同步处理方式会导致订单创建速度较慢,影响系统性能。
- 技术解决方案:
- 使用阻塞队列和线程池进行异步处理,将秒杀订单处理过程分离,提升并发处理能力。
6. Lua 脚本的使用
- 问题:多个操作需要保证原子性,以避免超卖和重复下单。
- 技术解决方案:
- 使用 Lua 脚本在 Redis 中实现原子性操作,确保秒杀流程中的库存查询、扣减与订单生成等操作一致性。
7. 分布式锁的管理
- 问题:如何高效地管理分布式锁,避免竞争和锁超时释放问题。
- 技术解决方案:
- 使用 Redisson 提供的分布式锁 API,简化锁的获取、释放及超时处理,确保秒杀系统的稳定性和高效性。
8. 一人一单限制
- 问题:同一用户可能重复下单,导致库存不准确。
- 技术解决方案:
- 使用 Redis
Set
结构来记录用户是否已参与秒杀,确保每个用户只能下单一次。
- 使用 Redis
全局唯一Id
业务背景
当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题:id的规律性太明显受单表数据量的限制。
全局id生成器,要求有以下的特性
为了增加ID的安全性,我们可以不直接使用redis自增的数值,而是拼接一些其他的信息。
采用的序列号使用Redis中的string的increament来生成,注意自增的id的对应的key应该每一天都不一样
我们需要指定开始的时间戳的时间,生成自增id的key(利用每日的时间作为key保证每日生成的用到的key不同)
@Component
public class RedisIdWorker {
//主要流程:
//1获取某一个时间的时间戳作为时间记录起点
//2.确定时间戳的位数
private static final long startTimeStap=1711843200L;
private static final int COUNT_BITS=32;
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public Long nextId(String keyPrefix){
//获取当前时间距离其实时间的距离(生成的时间戳)
LocalDateTime now = LocalDateTime.now();
//随后就是要确定序列号
Long nowSecond=now.toEpochSecond(ZoneOffset.UTC);
//先获取当前时间的时间戳以及基本的当前模板时间
Long timeStamp=nowSecond-startTimeStap;
String format = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
//根据incr+key+:+当前时间的模板生成唯一id
Long count = stringRedisTemplate.opsForValue().increment("incr" + keyPrefix + ":" + format);
Long unitId=timeStamp<<COUNT_BITS|count;
return unitId;
}
}
实现优惠券秒杀下单
需要先创建订单才行
优惠券秒杀下单流程
1.秒杀需要判断是否以及开启或者是否已经结束
2.判断库存是否充足
即看图说活
@Resource
private ISeckillVoucherService iSeckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
public Result seckillVoucher(Long voucherId) {
//首先查询优惠卷ixnx
SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);
//查看活动是否过期
if(voucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("活动还未开始");
}
if(voucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("活动已经结束");
}
//查看库存是否充足
if(voucher.getStock()<1){
return Result.fail("库存不足");
}
boolean success = iSeckillVoucherService.update().setSql("stock=stock-1").eq("voucher_id", voucherId).update();
if(!success){
return Result.fail("库存不足");
}
//新增订单
VoucherOrder voucherOrder = new VoucherOrder();
long OrderId=redisIdWorker.nextId("order");
voucherOrder.setId(OrderId);
voucherOrder.setUserId(UserHolder.getUser().getId());
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(OrderId);
}
单体情况下的一人多单超卖问题
为什么会产生超卖问题?多线程并发导致的,观察下图
倘若只剩下一单,在线程1获取到剩余的还有1,于是继续操作,线程2,3在线程1判断库存还剩1单的时候(但是还没有进行扣减的时候)也获取到库存还有1单,于是判断能继续购买,这样结束之后库存变成-2.
防止超卖问题:
- 悲观锁:判断线程安全问题一定发生,操作数据库之前先获取锁,例如synchronized
- 乐观锁,不加锁,只在更新数据库时候判断有没有其他线程堆数据进行修改,没修改才会更新数据库。
悲观锁vs乐观锁
- 悲观锁比乐观锁的性能低
- 悲观锁比乐观锁的冲突处理能力低
- 悲观锁比乐观锁的并发度低
CAS
乐观锁的一种实现方式,是一个原子操作,保证了线程安全性,操作失败会重试。
所以一人一单的超卖问题如何解决?
首先要判断的是库存是否充足,随后要判断的还有就是是否一人只有一单。
关于库存是否充足,使用CAS法解决:
线程1查询完库存后进行库存扣减操作,线程2在查询库存时,发现库存充足,也准备执行库存扣减操作,但是判断当前的库存是否为刚开始查询时候的数目,结果发现数量发生了改变,这就说明数据库中的数据已经发生了修改,需要进行重试(或者直接抛异常中断)
boolean success=iSeckillVoucherService.update(new LambdaUpdateWrapper<SeckillVoucher>()
.eq(SeckillVoucher::getVoucherId,voucherId)
.eq(SeckillVoucher::getVoucherId,0)
.setSql("stock = stock -1"));
if(!success){
throw new RuntimeException("订单创建失败");
}
随后就是如何判断目前是不是单个人购买?
这个无法使用CAS方法解决,因为库存是否变化能够通过获取库存量检查,但是判断当前用户是否购买过当前券方法:
-
- 添加字段
- 使用synchronized(用户id)即可
当然是第二种好
注意!加锁不要直接加在整个创建订单的方法上,为什么?
public Result createVoucherOrder(Long userId,Long voucherId){
//判断当前用户是否是第一单
synchronized (userId.toString().intern()){
int count =this.count(new LambdaQueryWrapper<VoucherOrder>()
.eq(VoucherOrder::getUserId,userId));
if(count>=1){
return Result.fail("用户已购买");
}
boolean success=iSeckillVoucherService.update(new LambdaUpdateWrapper<SeckillVoucher>()
.eq(SeckillVoucher::getVoucherId,voucherId)
.eq(SeckillVoucher::getVoucherId,0)
.setSql("stock = stock -1"));
if(!success){
throw new RuntimeException("订单创建失败");
}
//创建对应的订单添加到数据库中
VoucherOrder voucherOrder=new VoucherOrder();
long OrderId=redisIdWorker.nextId("order");
voucherOrder.setId(OrderId);
voucherOrder.setUserId(UserHolder.getUser().getId());
voucherOrder.setVoucherId(voucherId);
boolean result=save(voucherOrder);
if(!result){
throw new RuntimeException("创建订单失败");
}
return Result.ok(OrderId);
}
}
如果是在上面的方法加上锁的话,那么在会发生在锁释放之后才会提交事务,但是在锁释放之后才提交事务依然会有超卖问题,锁释放后其他的线程又能获取到锁了!!!
所以使用代理对象,调用完方法后才释放锁(为什么使用代理对象?因为直接使用this来调用会导致事务失效,而使用代理对象保证不会事务失效)
方法如下!
@Transactional
@Override
public Result seckillVoucher(Long voucherId) {
//首先查询优惠卷ixnx
SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);
//查看活动是否过期
if(voucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("活动还未开始");
}
if(voucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("活动已经结束");
}
//查看库存是否充足
if(voucher.getStock()<1){
return Result.fail("库存不足");
}
Long userId=UserHolder.getUser().getId();
//根据用户的id来加锁保证事务是在订单完成之后才加锁的
synchronized (userId.toString().intern()){
//使用代理对象调用自身的创建订单的方法,如果直接使用this的话会导致失效
IVoucherOrderService proxy=(IVoucherOrderService)AopContext.currentProxy();
return proxy.createVoucherOrder(userId,voucherId);
}
//新增订单
}
@Override
@Transactional
public Result createVoucherOrder(Long userId,Long voucherId){
//判断当前用户是否是第一单
int count =this.count(new LambdaQueryWrapper<VoucherOrder>()
.eq(VoucherOrder::getUserId,userId));
if(count>=1){
return Result.fail("用户已购买");
}
boolean success=iSeckillVoucherService.update(new LambdaUpdateWrapper<SeckillVoucher>()
.eq(SeckillVoucher::getVoucherId,voucherId)
.eq(SeckillVoucher::getVoucherId,0)
.setSql("stock = stock -1"));
if(!success){
throw new RuntimeException("订单创建失败");
}
//创建对应的订单添加到数据库中
VoucherOrder voucherOrder=new VoucherOrder();
long OrderId=redisIdWorker.nextId("order");
voucherOrder.setId(OrderId);
voucherOrder.setUserId(UserHolder.getUser().getId());
voucherOrder.setVoucherId(voucherId);
boolean result=save(voucherOrder);
if(!result){
throw new RuntimeException("创建订单失败");
}
return Result.ok(OrderId);
}
集群下的一人一单超卖问题
在多个节点的情况下,可以很清晰看到会出现多个线程同时获得锁的情况。为什么?
synchronized介绍:
synchronized是本地锁,只能提供线程级别的同步,每个JVM都有一个这个锁,并不能跨JVM上锁。如果有多个节点,就代表着有多个JVM,自然就能获得到多个锁了
分布式锁代替本地锁
分布式锁能保障多个集群下只有一个线程访问一个代码块,所以我们直接使用一个分布式锁,在全局下设置一个锁监视器,保证不同节点的jvm都能识别这个锁
分布式锁特点
- 多线程可见
- 高可用:节点故障情况下依然能正常获得锁
- 高性能:减少堆共享资源的访问等待时间
- 可重入性:如果一个节点已经获得了锁,那么它可以继续请求获取该锁而不会造成死锁。
- 锁超时机制:确保锁的自动释放
分布式锁的不同实现方式
redis一般就是使用setnx来实现分布式锁的,或者使用redession来实现
分布式锁优化
但是简单的使用key来尝试获取分布式锁真的·安全吗?
当线程1获取鄋后,由于业务阻塞,1的锁超时释放了,这时候线程2趁虚而入拿到了锁,然后此时线程1业务完成了,然后把线程2刚刚获取的锁给释放了,这时候线程3又趁虚而入拿到了锁,这就导致又出现了超卖问题!
解决方法:给分布式锁添加线程标识
只有是自己的锁才能释放,不是自己的锁就不释放了。解决多个线程同时获取锁的情况导致超卖。
关键步骤在释放锁处实现
public class SimpleRedisLock implements ILock {
private StringRedisTemplate redisTemplate;
private String name;
public final static String ID_PREFIX= UUID.randomUUID().toString();
public SimpleRedisLock(StringRedisTemplate redisTemplate,String name) {
this.redisTemplate=redisTemplate;
this.name=name;
}
@Override
public boolean tryLock(long expireTime) {
String LockId=ID_PREFIX+Thread.currentThread().getId()+"";
Boolean result = redisTemplate.opsForValue().setIfAbsent("lock:" + name, LockId, expireTime, TimeUnit.SECONDS);
return Boolean.TRUE.equals(result);
}
@Override
public void unLock() {
String currentThreadFlag=ID_PREFIX+Thread.currentThread().getId()+"";
String redisTreadFlag=redisTemplate.opsForValue().get("lock:" + name);
if(currentThreadFlag.equals(redisTreadFlag)){
redisTemplate.delete("lock:"+name);
}
}
//首先就是获取锁啦,锁的id就是当前线程的id
业务处修改
Long userId=UserHolder.getUser().getId();
//根据用户的id来加锁保证事务是在订单完成之后才加锁的
SimpleRedisLock lock=new SimpleRedisLock(stringRedisTemplate,"order:"+userId);
boolean isLock = lock.tryLock(1200);
if(!isLock){
return Result.fail("一人只能下一单");
}
try{
IVoucherOrderService proxy=(IVoucherOrderService)AopContext.currentProxy();
return proxy.createVoucherOrder(userId,voucherId);
}finally {
lock.unLock();
}
分布式锁优化2
依旧有优化问题,如果当线程1获取锁,执行完业务然后并且判断完当前锁是自己的锁时,但就在此时发生了阻塞,结果锁被超时释放了,线程2立马就趁虚而入了,获得锁执行业务,但就在此时线程1阻塞完成,由于已经判断过锁,已经确定锁是自己的锁了,于是直接就删除了锁,结果删的是线程2的锁,这就又导致线程3趁虚而入了,从而继续发生超卖问题。
为什么会有这样的问题,问题就是源自这段代码中,判断是不是同一个线程获取的锁以及释放锁是分为两段代码的,如果中间出现的阻塞其他线程就会有机可乘(当然是在超时释放的时候),原子性问题,显然使用lua脚本解决。
String currentThreadFlag=ID_PREFIX+Thread.currentThread().getId()+"";
String redisTreadFlag=redisTemplate.opsForValue().get("lock:" + name);
if(currentThreadFlag.equals(redisTreadFlag)){
redisTemplate.delete("lock:"+name);
}
lua脚本编写如下,想要了解需自行学习lua脚本
-- 比较缓存中的线程标识与当前线程标识是否一致
if (redis.call('get', KEYS[1]) == ARGV[1]) then
-- 一致,直接删除
return redis.call('del', KEYS[1])
end
-- 不一致,返回0
return 0
最后只需要把释放锁的代码从自己编写变成使用lua脚本释放锁。
redisson
当然了,学到最后依旧是白雪,最后使用进一步优化的分布式锁,redission,实现了分布式锁,分布式对象,分布式集合,分布式服务,同步锁。
实现列分布式锁
-
- 不可重入
- 不可重试
- 超时释放
- 主从一致
使用redisson更加简单的一匹
- 首先引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
- 随后就是编写配置类,编写redis的端口以及密码来配置
@Configuration
public class RedissonConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private String port;
@Value("${spring.redis.password}")
private String password;
@Bean
public RedissonClient redissonClient(){
Config config=new Config();
config.useSingleServer().setAddress("rediss://"+this.host+":"+this.port)
.setPassword(this.password);
return Redisson.create(config);
}
}
修改业务代码
自己写的锁统统不要(白雪)随后直接使用redisson实现的分布式锁实现获取以及释放锁
Long userId=UserHolder.getUser().getId();
//根据用户的id来加锁保证事务是在订单完成之后才加锁的
RLock lock = redissonClient.getLock("lock:order:" + userId);
boolean isLock = lock.tryLock();
if(!isLock){
return Result.fail("一人只能下一单");
}
try{
IVoucherOrderService proxy=(IVoucherOrderService)AopContext.currentProxy();
return proxy.createVoucherOrder(userId,voucherId);
}finally {
lock.unlock();
}
规范学习Redisson分布式锁对于四个问题的应对
基于setnx实现的分布式锁存在着四个问题
- 不可重入(一个线程不能多次获取同一个锁)
- 不可重试(获取锁只尝试一次随后就直接返回false)
- 超时释放(业务执行时间较长也会导致锁释放)
- 主从一致性(主从同步存在延迟,当主当宕机的是偶,如果从同步主中的锁数据,会出现锁实现)
Redisson解决了上面的四个问题,具体的可以查看官方文档或者查看博客Redisson分布式锁的可重入、重试、续约机制原理_redis重试机制-优快云博客了解具体机制以及源码
秒杀优化
目前锁的优化已经到达了机制,现在对性能和稳定性进行进一步的优化。
通过异步秒杀来优化
同步:程序按照顺序依次执行,每一次操作完成后再进行下一步
异步:是指程序在执行任务时,不需要等待当前任务完成,而是在任务执行的同时继续执行其他任务。
一般而言异步是效率比同步高不少的,只不过需要牺牲部分的一致性。
之前的秒杀的流程:
可以看出是同步的,如果使用异步的话能优化不少的速率。
关键是将一部分数据交给redis去执行,而且不能直接调用redis,而是通过开启一个独立的子线程区异步执行。
改进秒杀业务,提高并发性能
流程如下
- 1:新增优惠券的同时也要存入redis
- 2:基于Lua脚本判断秒杀库存,一人一单,决定用户是否抢购成功
- 3:如果抢购成功将优惠券id以及用户id封装后存入阻塞队列
- 4:开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
前面是优惠券以及其持有的数量
后面的是当前优惠券的set合集,保存的是哪些用户购买的这些东西。
具体的流程如下
关于扣取库存,下单的过程是在redis中实现的,创建订单需要塞入队列随后交给mysql修改数据库,同时判断是否买过优惠卷也是在redis中实现的。
- 1:如何存储订单的库存,防止超卖问题?》使用String数据类型,Redis的IO操作是单线程的,能保证线程安全以及防止超卖问题
- 2:如何解决一人一单问题:使用Redis存储订单信息,在使用set存储订单,因为订单是唯一,一个用户如果拥有一个独特的订单,就不能再添加一个新的订单
- 3:使用Lua脚本保证一人一单,超卖问题
解决异步带来的技术改变
1:异步线程无法从ThreaLocal中获取userId,需要从voucherOrder中获取userId
2:AopContext代理对象是无法在异步吸纳成中使用的,也是需要ThreadLocal来获取的,可以通过将代理对象的作用域提升,使其变成成员变量。
需要注意的是,使用lua脚本能够解决一人一单以及超卖问题
至于在处理订单以及创建订单的时候获取锁检查一人一单以及超卖问题的是为了兜底,不是不要实现步骤
全部代码如下,包括了
- 创建线程执行异步任务(接受消息异步处理订单
- 使用lua脚本解决一人一单以及超卖问题
- 将IVoucherOrderService代理对象变成成员变量用于执行处理订单,防止异步时无法获得。
- 在类初始化后就立马开始执行从阻塞队列中不断获取订单
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService iSeckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Autowired
private RedissonClient redissonClient;
@PostConstruct
private void init() {
// 执行线程任务
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
//初始化lua脚本
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static{
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
//存贮着订单的阻塞队列
private BlockingQueue<VoucherOrder> orderTasks=new ArrayBlockingQueue<>(1024*1024);
//创建号对应的线程池先,以便后面异步线程调用捏,一个够用
private static final ExecutorService SECKILL_ORDER_EXECUTOR= Executors.newSingleThreadExecutor();
//线程任务,不断从阻塞队列中获取订单
private class VoucherOrderHandler implements Runnable{
@Override
public void run() {
while(true){
try{
//获取订单
VoucherOrder order=orderTasks.take();
handleVoucherOrder(order);
}catch (Exception e){
throw new RuntimeException("处理订单错误");
}
}
}
}
//需要提前获取代理对象,异步的时候不能获取异步对象
private IVoucherOrderService proxy;
//异步获取到订单之后,处理订单的函数
public void handleVoucherOrder(VoucherOrder order){
Long userId=order.getUserId();
//
RLock lock = redissonClient.getLock("lock:order:" + userId);
boolean isLock = lock.tryLock();
if(!isLock){
//一人只能下一单
log.error("一人只能下一单");
return;
}
try {
proxy.createVoucherOrder(order);
}finally {
lock.unlock();
}
}
@Transactional
@Override
public Result seckillVoucher(Long voucherId) {
// 1、执行Lua脚本,判断用户是否具有秒杀资格
Long result = null;
try {
result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(),
UserHolder.getUser().getId().toString()
);
} catch (Exception e) {
log.error("Lua脚本执行失败");
throw new RuntimeException(e);
}
if (result != null && !result.equals(0L)) {
// result为1表示库存不足,result为2表示用户已下单
int r = result.intValue();
return Result.fail(r == 2 ? "不能重复下单" : "库存不足");
}
//有购买之歌
Long orderId = redisIdWorker.nextId("order");
//TODO 保存到阻塞队列
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(orderId);
voucherOrder.setUserId(UserHolder.getUser().getId());
voucherOrder.setVoucherId(voucherId);
// 将订单保存到阻塞队列中
orderTasks.add(voucherOrder);
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
this.proxy=proxy;
return Result.ok(orderId);
}
@Transactional
@Override
public void createVoucherOrder(VoucherOrder voucherOrder){
Long userId = voucherOrder.getUserId();
Long voucherId = voucherOrder.getVoucherId();
// 1、判断当前用户是否是第一单
int count = this.count(new LambdaQueryWrapper<VoucherOrder>()
.eq(VoucherOrder::getUserId, userId));
if (count >= 1) {
// 当前用户不是第一单
log.error("当前用户不是第一单");
return;
}
// 2、用户是第一单,可以下单,秒杀券库存数量减一
boolean flag = iSeckillVoucherService.update(new LambdaUpdateWrapper<SeckillVoucher>()
.eq(SeckillVoucher::getVoucherId, voucherId)
.gt(SeckillVoucher::getStock, 0)
.setSql("stock = stock -1"));
if (!flag) {
throw new RuntimeException("秒杀券扣减失败");
}
// 3、将订单保存到数据库
flag = this.save(voucherOrder);
if (!flag) {
throw new RuntimeException("创建秒杀券订单失败");
}
}