基于redis实现分布式锁适用于秒杀场景

Redis秒杀

1.全局唯一ID

特性:

  1. 高可用

  2. 唯一性

  3. 高性能

  4. 安全性

  5. 递增性

全局唯一ID生成有很多方法例如:UUID、redis自增、snowflake算法、数据库自增等。

此我们失语redis自增的方式。

生成策略

具体代码实现

package com.hmdp.utils;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;

/**
 * 全局唯一id生成器,利用Redis的自增功能保证每个ID的唯一性。
 */
@Component
public class RedisWorker {

    /**
     * 系统开始时间戳,用于计算时间差
     */
    private static final long BEGIN_TIMESTAMP = 1640995200L; // 2022-01-01 00:00:00 的UTC时间戳

    /**
     * 序列号占用的位数
     */
    private static final long COUNT_BITS = 32; // 序列号占用32位

    private StringRedisTemplate stringRedisTemplate;

    /**
     * 构造函数,注入StringRedisTemplate
     * @param stringRedisTemplate 用于操作Redis的模板类
     */
    public RedisWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 生成下一个唯一ID
     * @param keyPrefix ID前缀,用于区分不同业务或类别的ID
     * @return 生成的唯一ID
     */
    public long nextId(String keyPrefix) {
        // 1. 计算当前时间戳(秒)
        long nowSecond = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC);
        long time = nowSecond - BEGIN_TIMESTAMP; // 时间差,用于占据高位

        // 2. 获取当天序列号,基于Redis自增实现
        // 根据日期生成Redis键,保证每天序列号从1开始递增
        String date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        Long increment = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
        // 3. 将时间差和序列号拼接成最终的ID
        return time << COUNT_BITS | increment; // 左移位数后与序列号进行位或操作
    }
}

2.库存超卖

在高并发下,去抢购商品优惠卷时会出现超卖,我们该如何避免呢?

使用锁

我们可以使用锁的机制去实现,说到锁我们脑海里通常会出现悲观锁和乐观锁,那么这两个分别是什么呢?

  1. 悲观锁:悲观锁是一种在并发控制中采用的策略,它假定多线程访问数据时发生冲突的可能性非常高,因此采取一种保守的机制来防止冲突。当一个事务使用悲观锁访问某个数据时,会先锁定这些数据,确保在整个事务处理过程中,没有任何其他事务能够修改这些数据。这种做法可以有效避免并发带来的数据不一致性问题,但可能会增加系统的锁开销,并可能引起其他的性能问题,如阻塞、死锁等。

  2. 乐观锁:一种在并发控制中采用的策略,与悲观锁相反,它假设多线程访问数据时发生冲突的可能性较低。在读取数据时,乐观锁不会立即锁定数据,而是先进行读取操作,然后在更新数据时检查在此期间是否有其他事务修改了数据。如果检测到有其他事务进行了修改,那么更新操作就会失败,否则更新成功。 乐观锁通常通过版本号(Version)或时间戳(Timestamp)来实现。在读取数据时,会记录当前的版本号或时间戳。当事务尝试更新数据时,会再次检查版本号或时间戳是否与之前读取时的一致。如果不一致,说明数据已被其他事务修改,更新操作就会失败;如果一致,说明没有发生冲突,更新操作可以继续。 相比于悲观锁,乐观锁的优点在于减少了锁的使用,从而降低了锁带来的开销和潜在的阻塞问题,提高了并发性能。然而,在高并发且冲突频繁的情况下,乐观锁可能导致大量的重试,这可能增加系统的计算负担。

案例

具体业务代码:

    @Override
    @Transactional(rollbackFor = Exception.class)
    public Result seckillVoucher(Long voucherId) {
//      1.获取优惠卷信息
        SeckillVoucher seckillVoucher = seckillVoucherMapper.selectById(voucherId);
//       2.判断秒杀是否开启
//       2.1获取当前时间
        LocalDateTime now = LocalDateTime.now();
        if (seckillVoucher.getBeginTime().isAfter(now) ) {
            return Result.fail("秒杀未开启");
        }
//        3.判断秒杀是否结束
        if (seckillVoucher.getEndTime().isBefore(now)){
            return Result.fail("秒杀已结束");
        }
//       4. 判断库存是否充足
        if (seckillVoucher.getStock() < 1) {
            return Result.fail("库存不足");
        }
//        5.扣减库存
        boolean res = seckillVoucherMapper.updateStock(voucherId);
        if (!res){
            return Result.fail("库存不足");
        }
//        6.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        voucherOrder.setUserId(UserHolder.getUser().getId());
        voucherOrder.setVoucherId(voucherId);
        long orderId = RedisWorker.nextId("order");
        voucherOrder.setId(orderId);
        save(voucherOrder);
        return Result.ok(orderId);
    }

当在多并发的情况下会出现以下情况:

线程一进入该业务逻辑此时获取到库存为1,查询到的同时线程二也开始进入进行查询获取到库存也为1。接下来线程一再执行扣减库库存后库存为0了,返回到线程二也会执行减库库存那么库存就会出现超卖。

此处使用乐观锁解决

乐观锁分为:版本号法实现与CAS法实现

版本号法:在数据库表添加一个version字段,当线程一执行时同时查出版本号(1)与库存(1),线程二也查询版本号(1)与库存(1),线程一开始执行修改操作(库存减一,版本加一),此时库存为0,版本号为2,那么在线程二执行库存扣减时发现库存版本号不对则不会执行。

 update tb_seckill_voucher set stock = stock - 1,version = version+1 where voucher_id = #{voucherId} and version=#{version};

CAS法:这种方式和版本号法非常相似,在库存的本身字段进行操作,当线程一进行扣减后库存为0,线程二在执行扣减时传入的库存值为1,会发现不匹配则不执行。

 update tb_seckill_voucher set stock = stock - 1 where voucher_id = #{voucherId} and stock=#{stock};

但是上面的成功概率极低,当多个线程都查询到1时就会出现大量失败。那么只要库存大于0就让该线程执行即可。

--  seckillVoucherMapper.updateStock(voucherId)的sql
updete tb_seckill_voucher set stock = stock - 1 where voucher_id = #{voucherId} and stock > 0;

3.一人一单

一人一单逻辑很简单,无非就是在执行扣减订单前添加判断该用户是否已经下过单。

3.1单服务适用
    @Override
    public Result seckillVoucher(Long voucherId) {
//        1.获取优惠卷信息
        SeckillVoucher seckillVoucher = seckillVoucherMapper.selectById(voucherId);
// 2.判断秒杀是否开启
//       2.1获取当前时间
        LocalDateTime now = LocalDateTime.now();
        if (seckillVoucher.getBeginTime().isAfter(now)) {
            return Result.fail("秒杀未开启");
        }
//        3.判断秒杀是否结束
        if (seckillVoucher.getEndTime().isBefore(now)) {
            return Result.fail("秒杀已结束");
        }
//       4. 判断库存是否充足
        if (seckillVoucher.getStock() < 1) {
            return Result.fail("库存不足");
        }
        
         //  5.判断用于是否已经领取
        Long userId = UserHolder.getUser().getId();
        if (query().eq("user_id", userId).eq("voucher_id", voucherId).count() > 0) {
            return Result.fail("用户已经领取过");
        }
//        6.扣减库存
        boolean res = seckillVoucherMapper.updateStock(voucherId);
        if (!res) {
            return Result.fail("库存不足");
        }
//        7.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        voucherOrder.setUserId(UserHolder.getUser().getId());
        voucherOrder.setVoucherId(voucherId);
        long orderId = RedisWorker.nextId("order");
        voucherOrder.setId(orderId);
        save(voucherOrder);
        return Result.ok(orderId);
    }

 但是在高并发情况下也会出现同样的问题,大量线程都库存count为0,未命中判断直接执行扣减了。我们该如何解决该问题呢?

此处使用悲观锁的方式  

    @Override
    public Result seckillVoucher(Long voucherId) {
//        1.获取优惠卷信息
        SeckillVoucher seckillVoucher = seckillVoucherMapper.selectById(voucherId);
// 2.判断秒杀是否开启
//       2.1获取当前时间
        LocalDateTime now = LocalDateTime.now();
        if (seckillVoucher.getBeginTime().isAfter(now)) {
            return Result.fail("秒杀未开启");
        }
//        3.判断秒杀是否结束
        if (seckillVoucher.getEndTime().isBefore(now)) {
            return Result.fail("秒杀已结束");
        }
//       4. 判断库存是否充足
        if (seckillVoucher.getStock() < 1) {
            return Result.fail("库存不足");
        }
        return createVoucherOrder(voucherId);
    }


    @Transactional(rollbackFor = Exception.class)
    public synchronized Result createVoucherOrder(Long voucherId) {
        // 5.判断用于是否已经领取
        Long userId = UserHolder.getUser().getId();
        if (query().eq("user_id", userId).eq("voucher_id", voucherId).count() > 0) {
            return Result.fail("用户已经领取过");
        }
//        6.扣减库存
        boolean res = seckillVoucherMapper.updateStock(voucherId);
        if (!res) {
            return Result.fail("库存不足");
        }
//        7.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        voucherOrder.setUserId(UserHolder.getUser().getId());
        voucherOrder.setVoucherId(voucherId);
        long orderId = RedisWorker.nextId("order");
        voucherOrder.setId(orderId);
        save(voucherOrder);
        return Result.ok(orderId);
    }

 此处我们可进行优化,因为synchronized锁定的是整个法大大降低了整个的执行效率,那么我们可以使用userid做为条件。

  @Override
    public Result seckillVoucher(Long voucherId) {
//        1.获取优惠卷信息
        SeckillVoucher seckillVoucher = seckillVoucherMapper.selectById(voucherId);
// 2.判断秒杀是否开启
//       2.1获取当前时间
        LocalDateTime now = LocalDateTime.now();
        if (seckillVoucher.getBeginTime().isAfter(now)) {
            return Result.fail("秒杀未开启");
        }
//        3.判断秒杀是否结束
        if (seckillVoucher.getEndTime().isBefore(now)) {
            return Result.fail("秒杀已结束");
        }
//       4. 判断库存是否充足
        if (seckillVoucher.getStock() < 1) {
            return Result.fail("库存不足");
        }
        Long userId = UserHolder.getUser().getId();
        synchronized(userId.toString().intern()){
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }
    }


    @Transactional(rollbackFor = Exception.class)
    public  Result createVoucherOrder(Long voucherId) {
        //       5.判断用于是否已经领取
        Long userId = UserHolder.getUser().getId();
        if (query().eq("user_id", userId).eq("voucher_id", voucherId).count() > 0) {
            return Result.fail("用户已经领取过");
        }
//        6.扣减库存
        boolean res = seckillVoucherMapper.updateStock(voucherId);
        if (!res) {
            return Result.fail("库存不足");
        }
//        7.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        voucherOrder.setUserId(UserHolder.getUser().getId());
        voucherOrder.setVoucherId(voucherId);
        long orderId = RedisWorker.nextId("order");
        voucherOrder.setId(orderId);
        save(voucherOrder);
        return Result.ok(orderId);

    }

主要代码:

  
 synchronized(userId.toString().intern()){
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }

为什么这里我们使用AopContext.currentProxy()获取代理对象去调用createVoucherOrder方法呢?

如果我们不调用代理对象的话会导致@Transactional失效,直接使用

 return this.createVoucherOrder(voucherId);

的话会发现使用的是本地对象的方法,没有用到spring容器,就无法触发AOP,导致事务失效

在实现以上代码是需要进行以下配置:

#在pom.xml添加依赖
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
#在application开启代理注解

@EnableAspectJAutoProxy(exposeProxy = true)//暴露代理对象
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {
    public static void main(String[] args) {
        SpringApplication.run(HmDianPingApplication.class, args);
    }
}

注意:以上操作只适用于单服务,如果出现集群负载均衡难么会出现问题。

因为synchronized关键字的监听器独立于自己的服务器,所以在集群情况下就行出现synchronized关键字失效。

使用分布式锁即可解决,那么什么是分布式锁呢?

3.2集群适用

分布式锁:满足分布式系统或者集群模式下多进程可见并且互斥的锁。

 

分布式锁的实现  

 

redis实现分布式锁:

 

package com.hmdp.utils;
import cn.hutool.core.lang.UUID;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
@Slf4j
public class SimpleRedisLock implements Lock{
    private String name;
    private StringRedisTemplate stringRedisTemplate;
    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }
    private static final String ID_PREFIX = UUID.fastUUID().toString(true);
    @Override
    public boolean tryLock(Long timeoutSec) {
        String key = "lock:" + name;
//        获取线程名称
        String id = ID_PREFIX+Thread.currentThread().getId();
        Boolean res = stringRedisTemplate.opsForValue().setIfAbsent(key, id , timeoutSec, TimeUnit.SECONDS);
        log.info("获取锁:{}",res);
        return Boolean.TRUE.equals(res);
    }

    @Override
    public void unLock() {
//        判断是否是同一把锁
        String key = "lock:" + name;
        String thread_id = ID_PREFIX+Thread.currentThread().getId();
        String id= stringRedisTemplate.opsForValue().get(key);
        if (thread_id.equals(id)){
            Boolean delete = stringRedisTemplate.delete("lock:" + name);
            log.info("删除锁:{}",delete);
        }
    }
}

问题:上面代码仍然存在问题,在jvm导致也无堵塞的环境下,还是会导致误删锁。  

 

问题所在:在上面代码中我们可以看出我们获取锁和释放锁是分开的两个操作,在上图中线程一出现了阻塞状态,假设阻塞了很久导致线程一的锁超时释放了,在阻塞的过程中线程二进入获取锁,此时刚好阻塞结束了要执行释放锁,这时就提前把线程二锁给释放了,导致了锁误删。

解决方案:使用redis的事务保证获取锁和释放锁的原子性,但是过去复杂,此处我们使用lua脚本实现。

lua脚本

Lua教程地址:Lua 教程 - Lua教程 - 菜鸟教程

在Redis中使用LUA脚本:

教程地址:Redis 脚本 - 菜鸟教程

 

无参语法:

EVAL "return redis.call('set','name','zs')" 0
#"return redis.call('set','name','zs')"  脚本内容
#0 表示脚本参数为0

 

有参语法:  

EVAL "return redis.call('set',KEYS[1],ARGV[1])" 1 name ww

整合:

在Resource下面添加unLock.lua文件

-- 该Lua函数用于判断Redis中指定键的值是否与给定值相等,如果相等则删除该键,否则返回0。函数接受两个参数,
-- 第一个参数为键名数组,第二个参数为给定的值。函数首先通过redis.call('get',KEYS[1])获取键名数组中第一个键的值,
-- 然后与给定值进行比较,如果相等则执行redis.call('del',KEYS[1])删除该键并返回结果,否则直接返回0。
if(redis.call('get',KEYS[1]) == ARGV[1]) then
    return redis.call('del',KEYS[1])
end
return 0
/**
 * 简单的Redis分布式锁实现类。
 * 使用StringRedisTemplate进行操作,通过设置和检查Redis中的值来实现锁机制。
 */
@Slf4j
public class SimpleRedisLock implements Lock{


    private String name; // 锁的名称

    private StringRedisTemplate stringRedisTemplate; // Redis模板,用于执行Redis操作

    /**
     * 构造函数
     * @param name 锁的唯一标识符
     * @param stringRedisTemplate 用于操作Redis的模板
     */
    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    // 使用UUID作为锁的ID前缀,保证唯一性
    private static final String ID_PREFIX = UUID.fastUUID().toString(true);

    private static final DefaultRedisScript<Long> un_lock; // 解锁的Lua脚本

    // 初始化解锁Lua脚本
    static {
        un_lock = new DefaultRedisScript<>();
        un_lock.setLocation(new ClassPathResource("unlock.lua"));
        un_lock.setResultType(Long.class);
    }

    /**
     * 尝试获取锁。
     * @param timeoutSec 获取锁的超时时间(秒)
     * @return 如果成功获取锁返回true,否则返回false
     */
    @Override
    public boolean tryLock(Long timeoutSec) {
        String key = "lock:" + name; // 构造锁的Redis键
        // 使用线程ID和ID前缀组合作为锁的ID,保证线程安全
        String id = ID_PREFIX+Thread.currentThread().getId();
        Boolean res = stringRedisTemplate.opsForValue().setIfAbsent(key, id , timeoutSec, TimeUnit.SECONDS);
        log.info("获取锁:{}",res);
        return Boolean.TRUE.equals(res);
    }

    /**
     * 释放锁。
     * 使用Lua脚本确保释放操作的原子性。
     */
    @Override
    public void unLock() {
            stringRedisTemplate.execute(un_lock,
            Collections.singletonList("lock:" + name)
            ,ID_PREFIX+Thread.currentThread().getId());
    }
}

缺点:

1.无法重入:同一个线程无法多次获取同一把锁。

2.不可重试:获取锁只尝试一次就返回false,没有重试机制

3.超时释放

4.主从一致性

根据以上缺点我们下章使用redisson去解决!!!

 

猜你喜欢

转载自blog.csdn.net/m0_64787068/article/details/139033637