Lecture08-Redis-分布式锁与Lua

文章目录

    [toc]

    单词词性音标含义
    distributedadj./dɪˈstrɪbjuːtɪd/分布式的(多台机器共同完成,分布式锁跨实例互斥)
    lockn./v./lɒk/锁(SETNX 抢锁、DEL 释放)
    reentrantadj./ˌriːˈentrənt/可重入的(同一线程可重复获取同一把锁)
    luan./ˈluːə/一种轻量脚本语言,Redis 用 EVAL 执行,保证原子性
    atomicallyadv./əˈtɒmɪkli/原子地(要么全执行、要么全不执行,中间无其它命令插入)
    redissonn./ˈredɪsən/官方推荐的 Java 分布式锁客户端
    watchdogn./ˈwɒtʃdɒɡ/看门狗(Redisson 自动给锁续期,防止锁过期被误释放)
    multiadj./ˈmʌlti/多(MULTI 开启 Redis 事务)

    Lecture08-Redis-分布式锁与Lua

    目标:理解"为什么单机锁不够",掌握基于 Redis 的分布式锁,以及用 Lua 脚本保证多命令的原子性。

    1、为什么需要分布式锁

    1、单机 synchronized 的局限

    public synchronized void deductStock() { ... }
    
    • synchronized / Lock 只对同一 JVM 进程内的多个线程互斥。
    • 真实项目是多台服务器(集群/微服务)运行,多进程之间无法用 JVM 锁互斥——A 机器的线程和 B 机器的线程会同时进入。

    2、场景举例

    • 扣库存、下单、秒杀:多台应用服务器并发执行,必须保证"同一时刻只有一个实例在执行关键段"。

    distributed-lock

    结论:需要一个所有实例共享的互斥机制——用 Redis 的一个 key 充当锁。


    2、概念补充:原子性

    原子性(Atomicity):一个操作"要么全部完成、要么一点都不做",中间不会被其它操作插队。就像转账——扣款和入账要么都成功、要么都失败,不会停在"只扣了没到账"的中间状态。

    Java 中的 i++ 本质上是 i+1 然后给 i 赋值,所以是两步操作,不满足原子性。同样多行代码执行写操作也不满足原子性。

    为了让多个操作满足原子性,我们就需要用加锁的方式把多行代码封装为一个逻辑上的整体。

    为什么要先讲它?因为分布式锁的核心就是"原子地抢锁"——如果"判断锁是否存在"和"设置锁"这两步之间能被别人插进来,锁就形同虚设。

    Redis 有一个重要特性:

    核心主线程执行数据操作,是单线程执行的!

    两个层面的原子性,别混淆:

    层面含义例子
    单条命令的原子性Redis 单条命令本身就是原子的,不会被并发打断SET key value NX EX 30(判断 + 设置 + 过期,一条搞定)
    多条命令的原子性多条命令要当"一个整体"执行,中间不能插入别的命令用 Lua 脚本打包(如"比对持有者 + 删除")

    单条命令可以保证原子性,但是如果多条命令是一个逻辑上的整体,需要具备原子性怎么办?

    A 逻辑要执行的命令:黄色球

    B 逻辑要执行的命令:蓝色球

    此时相当于在执行 A 逻辑的同时,有 B 逻辑的操作命令插入进来了,这就无法保证原子性!

    image-20250613192822281

    比如上面图例中,我们希望黄色和蓝色命令能够各自独立执行,在执行过程中不受到其它命令的插入,不会被打乱顺序

    image-20250614101801246


    反例 vs 正例(体会为什么"一条命令 / 一个脚本"很重要):

    # ❌ 判断和设置分两步:中间可能被别人插队,两个客户端都以为抢到了锁
    if not exists lock  →  set lock owner
    
    # ✅ 一条命令搞定:判断 + 设置 + 过期 是一个原子操作
    set lock owner NX EX 30
    
    # ❌ GET 和 DEL 分开:比对完、还没删,锁可能已过期被别人拿到 → 把别人的锁删了
    get lock → 比对 → del lock
    
    # ✅ Lua 打包:GET + 比对 + DEL 一气呵成,中间不会插入其它命令
    EVAL "if redis.call('GET',KEYS[1])==ARGV[1] then return redis.call('DEL',KEYS[1]) else return 0 end" 1 lock owner
    

    一句话记忆:原子性 = "不可分割、不被插队";Redis 靠单条命令或 Lua 脚本来提供它,这正是分布式锁能正确工作的前提。


    3、基于 SETNX 的简单分布式锁

    1、加锁

    利用 SET key value NX EX 秒数 的原子性:key 不存在才设置成功(加锁成功),且带过期时间防止死锁。

    # 抢锁:成功返回 OK,失败返回 (nil)
    127.0.0.1:6379> set lock:order:001 owner-tom nx ex 30
    OK
    127.0.0.1:6379> set lock:order:001 owner-tom nx ex 30
    (nil)     # 已被占用,加锁失败
    

    2、释放锁

    # 简单释放
    127.0.0.1:6379> del lock:order:001
    (integer) 1
    

    ⚠️ 问题:如果 A 拿到锁还没执行完,锁过期被 Redis 删除;此时 B 抢到锁开始执行;A 执行完后 del ——把 B 的锁删掉了!→ 需要"释放前校验持有者"。


    3、加锁 + 校验持有者 + 释放(用 Lua 保证原子)

    释放锁必须"先比对 value 是不是自己,是才删",这两步要原子——用 Lua 脚本:

    -- lua/release.lua
    if redis.call('GET', KEYS[1]) == ARGV[1]
    then
        return redis.call('DEL', KEYS[1])
    else
        return 0
    end
    
    @Bean
    public RedisScript<Long> releaseLockScript() {
        return RedisScript.of(new ClassPathResource("lua/release.lua"), Long.class);
    }
    
    Long result = redis.execute(releaseLockScript,
            List.of("lock:order:001"), "owner-tom");
    if (result != null && result == 1L) {
        System.out.println("释放成功(确认是自己的锁)");
    }
    

    4、Lua 脚本:多命令原子性

    1、为什么用 Lua

    • Redis 单线程执行脚本,脚本内多条命令作为一个整体,中间不会有其它命令插入 → 原子性。
    • 一次请求替代多次网络往返 → 性能更好。

    2、Lua 基础

    Lua 是一个小巧的脚本语言,Lua脚本可以很容易的被C/C++ 代码调用,也可以反过来调用C/C++的函数

    Lua并没有提供强大的库,一个完整的 Lua 解释器不过 200k

    所以Lua不适合作为开发独立应用程序的语言,而是作为嵌入式脚本语言

    很多应用程序、游戏使用LUA作为自己的嵌入式脚本语言,以此来实现可配置性、可扩展性

    这其中包括魔兽争霸地图、魔兽世界、博德之门、愤怒的小鸟等众多游戏插件或外挂。


    -- KEYS[1]、KEYS[2]... 传入的 key(下标从 1 开始)
    -- ARGV[1]、ARGV[2]... 传入的参数
    -- redis.call('命令', ...) 执行 Redis 命令
    
    local current = redis.call('GET', KEYS[1])
    if current == ARGV[1] then
        redis.call('SET', KEYS[1], ARGV[2])
        return 1
    end
    return 0
    

    典型应用:CAS(Compare And Set,先比对再设置)、扣库存防超卖、限流。第 7 讲"释放锁判断"就是典型 CAS。


    3、用 Lua 扣库存(防超卖)

    -- lua/deduct_stock.lua
    -- KEYS[1]=库存key  ARGV[1]=扣减数量
    local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
    if stock >= tonumber(ARGV[1]) then
        redis.call('DECRBY', KEYS[1], ARGV[1])
        return 1          -- 扣减成功
    end
    return 0              -- 库存不足
    
    Long ok = redis.execute(deductStockScript,
            List.of("stock:sku:1001"), "1");
    if (ok == 1L) {
        System.out.println("扣减成功");
    }
    

    相比 GET 后再 DECR 两条命令,Lua 版没有中间插入,彻底解决并发超卖。


    4、在 SpringBoot 调用 Lua

    [1]创建Lua脚本
    • 外部调用 Lua 脚本时可以传入:
      • 多个key
      • 多个参数
    • KEYS[下标]:从多个key中取下标对应的那一个,下标从1开始
    • ARGV[下标]:从多个参数中取下标对应的那一个,下标从1开始
    • redis.call():执行Redis命令

    image-20250613204318160

    local current = redis.call('GET', KEYS[1])
    if current == ARGV[1]
    then redis.call('SET', KEYS[1], ARGV[2])
        return 1
    end
    return 0
    

    [2]创建 Lua 脚本对应的 bean
    • RedisScript:Lua 脚本封装的 Java 类型
    • 泛型:Lua 脚本最终返回值的类型
    • 调用RedisScript.of()方法创建RedisScript对象
      • 参数1:加载Lua脚本文件的Resource对象
      • 参数2:Lua脚本返回值类型

    image-20250613210751033

    @Bean
    public RedisScript<Boolean> redisScript() {
        // 这里需要指定 Lua 脚本的价值路径,路径的基准是类路径的根目录
        ClassPathResource resource = new ClassPathResource("lua/test.lua");
        return RedisScript.of(resource, Boolean.class);
    }
    

    [3]测试
    @SpringBootTest
    public class DemoRedisScriptTest {
    
        @Resource
        private RedisScript<Boolean> redisScript;
    
        @Resource
        private StringRedisTemplate redisTemplate;
    
        @Test
        public void test() {
            // 前置:保证 number = 555,否则脚本直接返回 0
            redisTemplate.opsForValue().set("number", "555");
    
            // execute(脚本, keys 列表, ARGV...)
            Boolean executeResult = redisTemplate.execute(
                    redisScript, Arrays.asList("number"), "555", "666");
    
            System.out.println(Boolean.TRUE.equals(executeResult) ? "已设置新值" : "未设置新值");
        }
    }
    
    

    5、Redisson 分布式锁(生产推荐)

    手写 SETNX + 过期有个痛点:锁过期了业务还没执行完。Redisson 的 watchDog 看门狗会自动续期,且支持可重入、公平锁等,生产更省心。

    1、引入依赖

    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson-spring-boot-starter</artifactId>
        <version>3.x.x</version>   <!-- 按官方最新稳定版 -->
    </dependency>
    

    2、使用

    spring:
      data:
        redis:
          host: 192.168.100.100
          port: 6379
    
    @Autowired
    private RedissonClient redisson;
    
    public void deductStock() {
        // 声明一个 key
        // 这个 key 带有当前要访问的目标资源的 ID 体现了前面说的分开加锁
        String key = "lock:stock:1001";
        
        // 基于 key 加分布式锁
        RLock lock = redisson.getLock(key);
        try {
            // 抢锁,默认 30 秒,每 10 秒续期一次;看门狗会持续续期直到 unlock
            // 也就是说:我们自己不要设置一个固定的过期时间,一旦设置了,看门狗机制就不起做了!
            // 不设置租期,就表示锁的过期时间交给看门狗来维护
            lock.lock();
            // 业务代码……扣库存
        } finally {
            lock.unlock();   // 释放
        }
    }
    

    记住:Redisson 解决了续期(看门狗)和可重入问题,是生产首选。


    6、Redis 事务(了解)

    1、命令

    命令作用
    MULTI开始事务,后续命令入队
    EXEC顺序执行队列中的命令
    DISCARD放弃已入队的命令
    WATCH监视 key,被改则事务失败(乐观锁)

    2、示例

    127.0.0.1:6379> multi
    OK
    127.0.0.1:6379(TX)> set number 100
    QUEUED
    127.0.0.1:6379(TX)> incr number
    QUEUED
    127.0.0.1:6379(TX)> exec
    1) OK
    2) (integer) 101
    

    ⚠️ 注意:Redis 事务不支持回滚(运行期报错不会撤销已执行命令);WATCH 是乐观锁,key 被改则 EXEC 返回 nil。复杂业务原子性优先用 Lua,而不是事务。


    7、对比与选型

    方案原子性续期可重入适用
    SETNX + 过期单条原子无(自己续)无简单场景/学习
    SETNX + Lua脚本原子无无需要自定义原子逻辑
    Redisson有有(看门狗)有生产首选
    Redis 事务顺序执行,不保证回滚--简单批量,业务用 Lua 替代

    8、课堂练习

    1. 用 SET NX EX 模拟"两个客户端抢同一把锁",一个成功一个 (nil)。
    2. 写一个 Lua 脚本 compare_and_set:比对 key 值等于指定值才设置新值,返回是否成功。
    3. 用 Lua 写"扣库存"脚本并测试超卖防护。
    4. (进阶)引入 Redisson,用 RLock 给"扣库存"加锁,体会看门狗续期。

    9、本讲小结

    • 多实例并发需要分布式锁,Redis 用 SETNX + EX 抢锁。
    • 释放锁要"先比对持有者再删",用 Lua 保证原子。
    • Lua 让多条命令成为原子整体,是防超卖、CAS 等场景的关键。
    • 生产用 Redisson(看门狗续期 + 可重入)。

    10、本节验证

    • 理解分布式锁解决什么问题(单机 synchronized 的局限)✅
    • 会用 SETNX + 过期时间实现简单分布式锁(加锁/释放)✅
    • 会用 Lua 保证多命令原子性(校验持有者+释放、扣库存防超卖)✅
    • 会用 Redisson 生产级分布式锁,理解看门狗自动续期 ✅
    end