lua脚本+Redission实现分布式锁

发布于:2025-05-12 ⋅ 阅读:(12) ⋅ 点赞:(0)

实现分布式锁最简单的一种方式:基于Redis

不论是本地锁还是分布式锁,核心都在于“互斥”。

在 Redis 中, SETNX 命令是可以帮助我们实现互斥。SETNXset if not exists (对应 Java 中的 setIfAbsent 方法),如果 key 不存在的话,才会设置 key 的值。如果 key 已经存在, SETNX 啥也不做。

SETNX lockKey uniqueValue
(integer) 1
SETNX lockKey uniqueValue
(integer) 0

释放锁的话,直接通过 DEL 命令删除对应的 key 即可。

DEL lockKey
(integer) 1

为了防止误删到其他的锁,这里我们建议使用 Lua 脚本通过 key 对应的 value(唯一值)来判断。

选用 Lua 脚本是为了保证解锁操作的原子性。因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。

// 释放锁时,先比较锁对应的 value 值是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

这是一种最简易的 Redis 分布式锁实现,实现方式比较简单,性能也很高效。不过,这种方式实现分布式锁存在一些问题。就比如应用程序遇到一些问题比如释放锁的逻辑突然挂掉,可能会导致锁无法被释放,进而造成共享资源无法再被其他线程/进程访问。

为了避免锁无法被释放,我们可以想到的一个解决办法就是:给这个 key(也就是锁) 设置一个过期时间 。

127.0.0.1:6379> SET lockKey uniqueValue EX 3 NX
OK
  • lockKey:加锁的锁名;
  • uniqueValue:能够唯一标识锁的随机字符串;
  • NX:只有当 lockKey 对应的 key 值不存在的时候才能 SET 成功;
  • EX:过期时间设置(秒为单位)EX 3 标示这个锁有一个 3 秒的自动过期时间。与 EX 对应的是 PX(毫秒为单位),这两个都是过期时间设置。

这样确实可以解决问题,不过,这种解决办法同样存在漏洞:如果操作共享资源的时间大于过期时间,就会出现锁提前过期的问题,进而导致分布式锁直接失效。如果锁的超时时间设置过长,又会影响到性能。

你或许在想:如果操作共享资源的操作还未完成,锁过期时间能够自己续期就好了!

好 它来了

Redission+lua脚本实现互斥锁

Redisson 是一个开源的 Java 语言 Redis 客户端,提供了很多开箱即用的功能,不仅仅包括多种分布式锁的实现。并且,Redisson 还支持 Redis 单机、Redis Sentinel、Redis Cluster 等多种部署架构。

Redisson 中的分布式锁自带自动续期机制,使用起来非常简单,原理也比较简单。在Redisson中需要手动加锁,并且可以控制锁的失效时间和等待时间。当锁住的一个业务还没有执行完成时,Redisson会引入一个看门狗机制,每隔一段时间检查当前业务是否还持有锁。如果持有,就增加加锁的持有时间。

实践一下:优惠券秒杀一人一单防止超卖实现步骤

1 引入依赖
<dependencies>
    <!-- Redisson -->
    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson</artifactId>
        <version>3.16.1</version>
    </dependency>
    
    <!-- Spring Boot Data Redis -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    
    <!-- Spring Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>
2. Redisson 配置
@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("redis://" + host + ":" + port)
              .setPassword(password == null || password.isEmpty() ? null : password)
              .setDatabase(0);
        return Redisson.create(config);
    }
}
3. 常量类
public interface RedisConstants {
    String SECKILL_STOCK_KEY = "seckill:stock:";
    String SECKILL_ORDER_KEY = "seckill:order:";
    String LOCK_COUPON_KEY = "lock:coupon:";
    long LOCK_TIMEOUT = 30; // 锁超时时间(秒)
}
4. service
@Service
public class CouponService {
    @Autowired
    private RedissonClient redissonClient;
    
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    @Autowired
    private CouponMapper couponMapper;
    
    @Autowired
    private OrderMapper orderMapper;
    
    // 秒杀优惠券
    public Result seckillCoupon(Long couponId, Long userId) {
        // 1. 生成锁key
        String lockKey = RedisConstants.LOCK_COUPON_KEY + couponId;
        
        // 2. 获取Redisson锁
        RLock lock = redissonClient.getLock(lockKey);
        
        // 3. 尝试获取锁,等待10秒,自动释放时间30秒 这里没有启用看门狗 因为设置了自动30s超时释放 
        boolean isLocked = false;
        try {
            isLocked = lock.tryLock(10, RedisConstants.LOCK_TIMEOUT, TimeUnit.SECONDS);
            if (!isLocked) {
                return Result.fail("抢购失败,请稍后再试");
            }
            
            // 4. 执行Lua脚本校验库存和用户订单
            String script = buildSeckillScript();
            List<String> keys = Arrays.asList(couponId.toString());
            List<String> args = Arrays.asList(userId.toString());
            
            Long result = stringRedisTemplate.execute(
                new DefaultRedisScript<>(script, Long.class),
                keys, 
                args
            );
            
            // 5. 处理脚本返回结果
            if (result == null) {
                return Result.fail("系统异常");
            }
            
            if (result == 0) {
                return Result.fail("库存不足");
            }
            
            if (result == -1) {
                return Result.fail("每个用户限购一次");
            }
            
            // 6. 创建订单(这里简化处理,实际项目可能需要更复杂的订单创建逻辑)
            createOrder(couponId, userId);
            return Result.ok("抢购成功");
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return Result.fail("系统异常");
        } finally {
            // 7. 释放锁
            if (isLocked && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
    
    // 构建秒杀Lua脚本
    private String buildSeckillScript() {
        return "local stockKey = 'seckill:stock:' .. KEYS[1] " +
               "local orderKey = 'seckill:order:' .. KEYS[1] " +
               "local userId = ARGV[1] " +
               "local stock = tonumber(redis.call('get', stockKey) or 0) " +
               "if stock <= 0 then return 0 end " +
               "if redis.call('sismember', orderKey, userId) == 1 then return -1 end " +
               "redis.call('decr', stockKey) " +
               "redis.call('sadd', orderKey, userId) " +
               "return 1";
    }
    
    // 创建订单
    private void createOrder(Long couponId, Long userId) {
        // 查询优惠券信息
        Coupon coupon = couponMapper.selectById(couponId);
        
        // 创建订单
        Order order = new Order();
        order.setUserId(userId);
        order.setCouponId(couponId);
        order.setPayAmount(coupon.getPrice());
        // 设置其他订单字段...
        
        // 保存订单
        orderMapper.insert(order);
    }
}
5. controller
@RestController
@RequestMapping("/api/coupon")
public class CouponController {
    @Autowired
    private CouponService couponService;
    
    @PostMapping("/seckill/{couponId}")
    public Result seckillCoupon(@PathVariable Long couponId, 
                                @RequestHeader("userId") Long userId) {
        return couponService.seckillCoupon(couponId, userId);
    }
}
6. 初始化库存和优惠券
@Service
public class InitService {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    @Autowired
    private CouponMapper couponMapper;
    
    // 初始化优惠券库存到Redis
    @PostConstruct
    public void initCouponStock() {
        // 查询所有可用优惠券
        List<Coupon> coupons = couponMapper.selectList(new QueryWrapper<Coupon>()
            .eq("status", 1)
            .gt("stock", 0)
            .lt("start_time", LocalDateTime.now())
            .gt("end_time", LocalDateTime.now()));
        
        // 将优惠券库存加载到Redis
        for (Coupon coupon : coupons) {
            stringRedisTemplate.opsForValue().set(
                RedisConstants.SECKILL_STOCK_KEY + coupon.getId(), 
                coupon.getStock().toString()
            );
        }
    }
}

lua脚本详解

-- 获取库存键和订单键
local stockKey = 'seckill:stock:' .. KEYS[1] 
local orderKey = 'seckill:order:' .. KEYS[1] 

-- 获取用户ID参数
local userId = ARGV[1] 

-- 获取当前库存(如果不存在则为0)
local stock = tonumber(redis.call('get', stockKey) or 0) 

-- 检查库存是否不足
if stock <= 0 then return 0 end 

-- 检查用户是否已购买过
if redis.call('sismember', orderKey, userId) == 1 then return -1 end 

-- 扣减库存
redis.call('decr', stockKey) 

-- 记录用户已购买
redis.call('sadd', orderKey, userId) 

-- 返回成功标识
return 1

看门狗机制在哪体现捏? 

当你调用tryLock()方法没有显式指定锁的持有时间(即只传等待时间,不传释放时间)时,看门狗机制会自动生效。例如:

// 启用看门狗:不指定leaseTime,使用默认续期时间(默认30秒)
lock.tryLock(10, null, TimeUnit.SECONDS);

// 禁用看门狗:显式指定leaseTime,锁到期后不会续期
lock.tryLock(10, 30, TimeUnit.SECONDS); // 你提供的代码使用这种方式

如果启用dog 建议增加配置来调整看门狗的默认续期时间: 

Config config = new Config();
config.useSingleServer()
      .setAddress("redis://localhost:6379")
      .setLockWatchdogTimeout(60 * 1000); // 设置看门狗续期时间为60秒

Redisson实现的分布式锁是可重入的吗?它是怎么实现的?

是的,Redisson 实现的分布式锁是可重入的。可重入锁允许同一个线程多次获取同一把锁而不会被阻塞,这可以有效避免死锁问题,同时让代码逻辑更清晰。

Redisson 如何实现可重入锁

Redisson 是基于 Redis 的哈希结构来存储锁信息的。打个比方,我们有个叫 “myLock” 的锁,这就是锁的唯一标识,相当于哈希结构里的 Key。而每个尝试获取锁的线程都有自己唯一的标识,像线程 ID 或者 UUID,这就是哈希结构里的 Field。线程获取锁的次数,也就是重入次数,就是哈希结构里的 Value。

加锁过程

当一个线程想去获取锁的时候,Redisson 首先会检查这个锁对应的 Key 存不存在。要是不存在,那就说明当前没有线程持有这把锁,Redisson 就会创建这个锁,把 Field 设成当前线程的标识,Value 设为 1,同时给锁设置一个过期时间。要是锁已经存在,Redisson 就会去检查 Field 是不是和当前线程的标识一样。如果一样,那就意味着当前线程已经持有这把锁了,Redisson 就把 Value 加 1,并且刷新锁的过期时间。要是不一样,那就表示锁被其他线程占着,当前线程就得等着锁被释放。

释放锁过程

释放锁的时候,Redisson 会先看看锁的 Field 和当前线程标识是不是一致。如果一致,就把 Value 减 1。要是减完之后 Value 变成 0 了,那就说明当前线程已经完全释放了这把锁,Redisson 就把锁对应的 Key 删除。要是 Value 还大于 0,说明当前线程还有重入的情况,还持有锁,Redisson 就刷新一下锁的过期时间。

防止死锁

Redisson 在防止死锁方面也有很实用的机制。一方面,加锁的时候会给锁设置过期时间,就算某个线程出问题了,一直不释放锁,到时间了锁也会自动被删除。另一方面,它的可重入机制也能避免因为线程嵌套调用导致的死锁。

可重入锁

Redisson 的可重入锁优势也很明显。从线程安全角度看,只有持有锁的线程才能释放锁,这就保证了不会出现线程安全问题。在性能上,它通过 Lua 脚本确保加锁和释放锁的操作是原子性的,避免了竞态条件,效率很高。


网站公告

今日签到

点亮在社区的每一天
去签到