Loading

03_基于redis实现锁机制

基于redis实现锁机制

环境准备:

引入依赖:

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

配置redis连接信息:

spring:
  redis:
    host: 127.0.0.1
    port: 6379

service层实现从redis中扣减库存:

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    private final static String STOCK_KEY = "stock";

    public void deduct() {
        String stock = stringRedisTemplate.opsForValue().get(STOCK_KEY);
        if (StringUtils.hasLength(stock)) {
            int value = Integer.parseInt(stock);
            if (value > 0) {
                stringRedisTemplate.opsForValue().set(STOCK_KEY, String.valueOf(value - 1));
            }
        }
    }

初始化库存数量:

set stock 5000

使用压力测试工具进行测试:

出现了并发问题库存扣减出现异常。

解决方案:

1、使用jvm提供的本地锁机制与mysql使用jvm锁机制类似,同样存在问题,参考上篇文章。

2、使用redis提供的事务机制,redis乐观锁机制:

watch stock
multi
set stock 5000
exec

在 Redis 中使用 watch 命令可以决定事务是执行还是回滚。一般而言,可以在 multi 命令之前使用 watch 命令监控某些键值对,然后使用 multi 命令开启事务,执行各类对数据结构进行操作的命令,这个时候这些命令就会进入队列。
当 Redis 使用 exec 命令执行事务的时候,它首先会去比对被 watch 命令所监控的键值对,如果没有发生变化,那么它会执行事务队列中的命令,提交事务;如果发生变化,那么它不会执行任何事务中的命令,而去事务回滚。无论事务是否回滚,Redis 都会去取消执行事务前的 watch 命令。

Redis 参考了多线程中使用的 CAS(比较与交换,Compare And Swap)去执行的。在数据高并发环境的操作中,我们把这样的一个机制称为乐观锁。

代码中实现redis事务机制:

public void deduct() {
        stringRedisTemplate.execute(new SessionCallback() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                operations.watch(STOCK_KEY);
                // 1. 查询库存信息
                Object stock = operations.opsForValue().get(STOCK_KEY);
                // 2. 判断库存是否充足
                int st = 0;
                if (stock != null && (st = Integer.parseInt(stock.toString())) > 0) {
                    // 3. 扣减库存
                    operations.multi();
                    operations.opsForValue().set(STOCK_KEY, String.valueOf(--st));
                    List exec = operations.exec();
                    if (exec.size() == 0) {
                        try {
                            Thread.sleep(50);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        deduct();
                    }
                    return exec;
                }
                return null;
            }
        });
    }

使用jmeter进行压力测试:

 

测试结果:库存扣减正常,但是性能下降明显,吞吐量降低,且连接数有限,不推荐使用。

3、使用redis分布式锁:

分布式锁:跨进程、跨服务、跨集群

使用场景:超卖现象、缓存击穿

特征:独占排他使用(使用setnx指令实现)

代码的方式实现redis分布式锁:

/** 使用redis的setnx命令实现分布式锁 **/
    public void deduct() {
        // 加锁setnx
        Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(LOCK_KEY, LOCK_YES_KEY);
        // 获取锁失败重试:递归调用
        if (lock != null && !lock) {
            try {
                Thread.sleep(50);
                this.deduct();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        } else {
            try {
                // 1. 查询库存信息
                String stock = stringRedisTemplate.opsForValue().get(STOCK_KEY);

                // 2. 判断库存是否充足
                if (StringUtils.hasLength(stock)) {
                    int st = Integer.parseInt(stock);
                    if (st > 0) {
                        // 3.扣减库存
                        stringRedisTemplate.opsForValue().set(STOCK_KEY, String.valueOf(st - 1));
                    }
                }
            } finally {
                // 解锁
                this.stringRedisTemplate.delete(LOCK_KEY);
            }
        }
    }

注意点:库存的扣减情况一定要在else中进行处理,因为递归调用的情况下,调用栈递归调用可能导致一次调用库存扣减出现了多次调用执行。

使用jmeter进行压力测试:

测试结果库存扣减情况正常,吞吐量一般。

获取锁失败使用递归调用存在一定的栈内存溢出风险,故进行优化:

/** 使用redis的setnx命令实现分布式锁 **/
    public void deduct() {// 获取锁失败重试:递归调用
        while (!Objects.requireNonNull(stringRedisTemplate.opsForValue().setIfAbsent(LOCK_KEY, LOCK_YES_KEY))) {
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        try {
            // 1. 查询库存信息
            String stock = stringRedisTemplate.opsForValue().get(STOCK_KEY);
            // 2. 判断库存是否充足
            if (StringUtils.hasLength(stock)) {
                int st = Integer.parseInt(stock);
                if (st > 0) {
                    // 3.扣减库存
                    stringRedisTemplate.opsForValue().set(STOCK_KEY, String.valueOf(st - 1));
                }
            }
        } finally {
            // 解锁
            this.stringRedisTemplate.delete(LOCK_KEY);
        }
    }

测试结果:库存扣减正常。

防死锁:

在服务集群部署的情况下,上述代码在扣减库存的过程中服务器宕机,则锁将无法得到释放,发生死锁情况。

解决方案:给锁增加过期时间,到期自动释放。

设置过期时间两种方式:

  1. 通过expire设置过期时间(缺乏原子性:如果在setnx和expire之间出现异常,锁也无法释放)

  2. 使用set指令设置过期时间:set key value ex 3 nx(既达到setnx的效果,又设置了过期时间)

stringRedisTemplate.opsForValue().setIfAbsent(LOCK_KEY, LOCK_YES_KEY,3, TimeUnit.SECONDS)

防误删

问题:可能会释放其他服务器的锁。

场景:如果业务逻辑的执行时间是7s。执行流程如下

  1. index1业务逻辑没执行完,3秒后锁被自动释放。

  2. index2获取到锁,执行业务逻辑,3秒后锁被自动释放。

  3. index3获取到锁,执行业务逻辑

  4. index1业务逻辑执行完成,开始调用del释放锁,这时释放的是index3的锁,导致index3的业务只执行1s就被别人释放。

    最终等于没锁的情况。

解决:setnx获取锁时,设置一个指定的唯一值(例如:uuid);释放前获取这个值,判断是否自己的锁。

防止误删代码实现方式:

public void deduct() {
        String LOCK_VALUE = UUID.randomUUID().toString();
        // 获取锁失败重试:递归调用
        while (!Objects.requireNonNull(stringRedisTemplate.opsForValue().setIfAbsent(LOCK_KEY, LOCK_VALUE, 3, TimeUnit.SECONDS))) {
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        try {
            // 1. 查询库存信息
            String stock = stringRedisTemplate.opsForValue().get(STOCK_KEY);
            // 2. 判断库存是否充足
            if (StringUtils.isNotBlank(stock)) {
                int st = Integer.parseInt(stock);
                if (st > 0) {
                    // 3.扣减库存
                    stringRedisTemplate.opsForValue().set(STOCK_KEY, String.valueOf(st - 1));
                }
            }
        } finally {
            // 解锁
            if (StringUtils.equals(LOCK_VALUE, stringRedisTemplate.opsForValue().get(LOCK_KEY))) {
                this.stringRedisTemplate.delete(LOCK_KEY);
            }
        }
    }

问题:删除操作和校验操作缺乏原子性。
场景:

  1. index1执行删除时,查询到的lock值确实和uuid相等
  2. index1执行删除前,lock刚好过期时间已到,被redis自动释放
  3. index2获取了lock
  4. index1执行删除,此时会把index2的lock删除

解决方案:没有一个命令可以同时做到判断 + 删除,所有只能通过其他方式实现(LUA脚本

现实问题

redis采用单线程架构,可以保证单个命令的原子性,但是无法保证一组命令在高并发场景下的原子性。例如:

在串行场景下:A和B的值肯定都是3

在并发场景下:A和B的值可能在0-6之间。

极限情况下1:

则A的结果是0,B的结果是3

极限情况下2:

则A和B的结果都是6

如果redis客户端通过lua脚本把3个命令一次性发送给redis服务器,那么这三个指令就不会被其他客户端指令打断。Redis 也保证脚本会以原子性(atomic)的方式执行: 当某个脚本正在运行的时候,不会有其他脚本或 Redis 命令被执行。 这和使用 MULTI/ EXEC 包围的事务很类似。

但是MULTI/ EXEC方法来使用事务功能,将一组命令打包执行,无法进行业务逻辑的操作。这期间有某一条命令执行报错(例如给字符串自增),其他的命令还是会执行,并不会回滚。

lua介绍

Lua 是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。

设计目的

​ 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。

Lua 特性

  • 轻量级:它用标准C语言编写并以源代码形式开放,编译后仅仅一百余K,可以很方便的嵌入别的程序里。

  • 可扩展:Lua提供了非常易于使用的扩展接口和机制:由宿主语言(通常是C或C++)提供这些功能,Lua可以使用它们,就像是本来就内置的功能一样。

  • 其它特性:

    • 支持面向过程(procedure-oriented)编程和函数式编程(functional programming);

    • 自动内存管理;只提供了一种通用类型的表(table),用它可以实现数组,哈希表,集合,对象;

    • 语言内置模式匹配;闭包(closure);函数也可以看做一个值;提供多线程(协同进程,并非操作系统所支持的线程)支持;

    • 通过闭包和table可以很方便地支持面向对象编程所需要的一些关键机制,比如数据抽象,虚函数,继承和重载等。

redis执行lua脚本 - EVAL指令

在redis中需要通过eval命令执行lua脚本。

格式:

EVAL script numkeys key [key ...] arg [arg ...]
script:lua脚本字符串,这段Lua脚本不需要(也不应该)定义函数。
numkeys:lua脚本中KEYS数组的大小
key [key ...]:KEYS数组中的元素
arg [arg ...]:ARGV数组中的元素

案例1:基本案例

EVAL "return 10" 0

输出:(integer) 10

案例2:动态传参

EVAL "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 5 10 20 30 40 50 60 70 80 90
# 输出:10 20 60 70

EVAL "if KEYS[1] > ARGV[1] then return 1 else return 0 end" 1 10 20
# 输出:0

EVAL "if KEYS[1] > ARGV[1] then return 1 else return 0 end" 1 20 10
# 输出:1

传入了两个参数10和20,KEYS的长度是1,所以KEYS中有一个元素10,剩余的一个20就是ARGV数组的元素。

redis.call()中的redis是redis中提供的lua脚本类库,仅在redis环境中可以使用该类库。
案例3:执行redis类库方法
set aaa 10  -- 设置一个aaa值为10
EVAL "return redis.call('get', 'aaa')" 0
# 通过return把call方法返回给redis客户端,打印:"10"

注意:脚本里使用的所有键都应该由 KEYS 数组来传递。但并不是强制性的,代价是这样写出的脚本不能被 Redis 集群所兼容。

案例4:给redis类库方法动态传参

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

案例5:pcall函数的使用(了解)

-- 当call() 在执行命令的过程中发生错误时,脚本会停止执行,并返回一个脚本错误,输出错误信息
EVAL "return redis.call('sets', KEYS[1], ARGV[1]), redis.call('set', KEYS[2], ARGV[2])" 2 bbb ccc 20 30
-- pcall函数不影响后续指令的执行
EVAL "return redis.pcall('sets', KEYS[1], ARGV[1]), redis.pcall('set', KEYS[2], ARGV[2])" 2 bbb ccc 20 30

注意:set方法写成了sets,肯定会报错。

使用lua保证删除原子性

删除LUA脚本:

if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end

代码实现删除操作的原子性:

public void deduct() {
        String LOCK_VALUE = UUID.randomUUID().toString();
        // 获取锁失败重试:递归调用
        while (!Objects.requireNonNull(stringRedisTemplate.opsForValue().setIfAbsent(LOCK_KEY, LOCK_VALUE, 3, TimeUnit.SECONDS))) {
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        try {
            // 1. 查询库存信息
            String stock = stringRedisTemplate.opsForValue().get(STOCK_KEY);
            // 2. 判断库存是否充足
            if (StringUtils.isNotBlank(stock)) {
                int st = Integer.parseInt(stock);
                if (st > 0) {
                    // 3.扣减库存
                    stringRedisTemplate.opsForValue().set(STOCK_KEY, String.valueOf(st - 1));
                }
            }
        } finally {
            // 解锁
            // 先判断是否自己的锁,再解锁
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] " +
                    "then return redis.call('del', KEYS[1]) " +
                    "else return 0 " +
                    "end";
            stringRedisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Collections.singletonList(LOCK_KEY), LOCK_VALUE);
//            if (StringUtils.equals(LOCK_VALUE, stringRedisTemplate.opsForValue().get(LOCK_KEY))) {
//                this.stringRedisTemplate.delete(LOCK_KEY);
//            }
        }
    }

使用jmeter进行压力测试,库存扣减情况正常,防误删操作的原子性完成:

可重入锁

由于上述加锁命令使用了 SETNX ,一旦键存在就无法再设置成功,这就导致后续同一线程内继续加锁,将会加锁失败。当一个线程执行一段代码成功获取锁之后,继续执行时,又遇到加锁的子任务代码,可重入性就保证线程能继续执行,而不可重入就是需要等待锁释放之后,再次获取锁成功,才能继续往下执行。

用一段 Java 代码解释可重入:

public synchronized void a() {
    b();
}

public synchronized void b() {
    // pass
}

假设 X 线程在 a 方法获取锁之后,继续执行 b 方法,如果此时不可重入,线程就必须等待锁释放,再次争抢锁。

锁明明是被 X 线程拥有,却还需要等待自己释放锁,然后再去抢锁,这看起来就很奇怪,我释放我自己

可重入性就可以解决这个尴尬的问题,当线程拥有锁之后,往后再遇到加锁方法,直接将加锁次数加 1,然后再执行方法逻辑。退出加锁方法之后,加锁次数再减 1,当加锁次数为 0 时,锁才被真正的释放。

可以看到可重入锁最大特性就是计数,计算加锁的次数。所以当可重入锁需要在分布式环境实现时,我们也就需要统计加锁次数。

解决方案:redis + Hash

ReentrantLock可重入锁加锁流程:

ReentrantLock.lock() --> NonfairSync.lock() --> AQS.acquire(1) --> NonfairSync.tryAcquire(1) --> Sync.nonfairTryAcquire(1)
1.CAS获取锁,如果没有线程占用锁(state==0),加锁成功并记录当前线程是有锁线程(两次)
2.如果state的值不为0,说明锁已经被占用。则判断当前线程是否是有锁线程,如果是则重入(state + 1)
3.否则加锁失败,入队等待

可重入锁解锁流程:

ReentrantLock.unlock() --> AQS.release(1) --> Sync.tryRelease(1)
1.判断当前线程是否是有锁线程,不是则抛出异常
2.对state的值减1之后,判断state的值是否为0,为0则解锁成功,返回true
3.如果减1后的值不为0,则返回false

参照ReentrantLock中的非公平可重入锁实现分布式可重入锁:

hash + lua脚本

加锁:
1.判断锁是否存在(exists),不存在则直接获取锁 hset key field value
2.如果锁存在则判断是否自己的锁(hexists),如果是自己的锁则重入:hincrby key field increment
3.否则重试:递归 循环

if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1
        then
            redis.call('hincrby', KEYS[1], ARGV[1], 1)
            redis.call('expire', KEYS[1], ARGV[2])
            return 1
        else 
            return 0
        end
        
        key: lock
        arg: uuid 30

放到一行:

if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 then redis.call('hincrby', KEYS[1], ARGV[1], 1) redis.call('expire', KEYS[1], ARGV[2]) return 1 else return 0 end

解锁:
1.判断自己的锁是否存在(hexists),不存在则返回nil
2.如果自己的锁存在,则减1(hincrby -1),判断减1后的值是否为0,为0则释放锁(del)并返回1
3.不为0,返回0

if redis.call('hexists', KEYS[1], ARGV[1]) == 0
        then
            return nil
        elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0
        then 
            return redis.call('del', KEYS[1])
        else 
            return 0
        end

        key: lock
        arg: uuid

放到一行:

if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then return nil elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 then return redis.call('del', KEYS[1]) else return 0 end

 可重入性代码实现:

封装到工具类中实现Lock接口:

public class DistributedRedisLock implements Lock {

    private final StringRedisTemplate redisTemplate;

    private final String lockName;

    private final String uuid;

    private long expire = 30;

    public DistributedRedisLock(StringRedisTemplate redisTemplate, String lockName, String uuid) {
        this.redisTemplate = redisTemplate;
        this.lockName = lockName;
        this.uuid = uuid;
    }

    @Override
    public void lock() {
        this.tryLock();
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {

    }

    @Override
    public boolean tryLock() {
        try {
            return this.tryLock(-1L, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return false;
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        if (time != -1) {
            this.expire = unit.toSeconds(time);
        }
        String script = "if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 " +
                "then " +
                "   redis.call('hincrby', KEYS[1], ARGV[1], 1) " +
                "   redis.call('expire', KEYS[1], ARGV[2]) " +
                "   return 1 " +
                "else " +
                "   return 0 " +
                "end";
        while (!Objects.requireNonNull(this.redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Collections.singletonList(lockName), getId(), String.valueOf(expire)))) {
            Thread.sleep(50);
        }
        return true;
    }

    @Override
    public void unlock() {
        String script =
                "if redis.call('hexists', KEYS[1], ARGV[1]) == 0 " +
                        "then " +
                        "   return nil " +
                        "elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 " +
                        "then " +
                        "   return redis.call('del', KEYS[1]) " +
                        "else " +
                        "   return 0 " +
                        "end";
        Long flag = this.redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Collections.singletonList(lockName), getId());
        if (flag == null) {
            throw new IllegalMonitorStateException("this lock doesn't belong to you!");
        }
    }

    @Override
    public Condition newCondition() {
        return null;
    }

    /**
     * 给线程拼接唯一标识
     */
    String getId() {
        return uuid + ":" + Thread.currentThread().getId();
    }
}

为了方便使用分布式锁封装一个工厂的工具类:

@Component
public class DistributedLockClient {

    @Autowired
    private StringRedisTemplate redisTemplate;

    private final String uuid;

    public DistributedLockClient() {
        this.uuid = UUID.randomUUID().toString();
    }

    public DistributedRedisLock getRedisLock(String lockName) {
        return new DistributedRedisLock(redisTemplate, lockName, uuid);
    }
}

可重入锁Service层实现:

public void deduct() {
        DistributedRedisLock redisLock = distributedLockClient.getRedisLock(LOCK_KEY);
        if (redisLock == null) {
            return;
        }
        redisLock.lock();
        try {
            // 1. 查询库存信息
            String stock = stringRedisTemplate.opsForValue().get(STOCK_KEY);
            // 2. 判断库存是否充足
            if (StringUtils.isNotBlank(stock)) {
                int st = Integer.parseInt(stock);
                if (st > 0) {
                    // 3.扣减库存
                    stringRedisTemplate.opsForValue().set(STOCK_KEY, String.valueOf(st - 1));
                }
            }
            // 测试可重入性
//            test();
        } catch (Exception ignore) {

        } finally {
            redisLock.unlock();
        }
    }

    public void test() {
        DistributedRedisLock redisLock = distributedLockClient.getRedisLock(LOCK_KEY);
        if (redisLock == null) {
            return;
        }
        redisLock.lock();
        System.out.println("测试可重入性");
        redisLock.unlock();
    }

使用jemeter测试,库存扣减情况正常:

测试可重入性,注意传入的uuid每台机器要随机生成一个uuid+当前线程ID作为map的key:

锁的释放没有问题。

自动续期

lua脚本编写:

if(redis.call('hexists', KEYS[1], ARGV[1]) == 1) then 
    redis.call('expire', KEYS[1], ARGV[2]); 
    return 1; 
else 
    return 0; 
end

放置在一行:

if(redis.call('hexists', KEYS[1], ARGV[1]) == 1) then redis.call('expire', KEYS[1], ARGV[2]); return 1; else return 0; end

需要注意的是定时任务与当前执行加锁的线程并非同一个线程,故当getId()时,自动续期的key与加锁的key不一致。

故需要改造构造方法,固定uuid:

    public DistributedRedisLock(StringRedisTemplate redisTemplate, String lockName, String uuid) {
        this.redisTemplate = redisTemplate;
        this.lockName = lockName;
        this.uuid = uuid + ":" + Thread.currentThread().getId();
    }

在加分布式锁时,开启定时任务自动续期:

public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        if (time != -1) {
            this.expire = unit.toSeconds(time);
        }
        String script = "if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 " +
                "then " +
                "   redis.call('hincrby', KEYS[1], ARGV[1], 1) " +
                "   redis.call('expire', KEYS[1], ARGV[2]) " +
                "   return 1 " +
                "else " +
                "   return 0 " +
                "end";
        while (!Objects.requireNonNull(this.redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Collections.singletonList(lockName), uuid, String.valueOf(expire)))) {
            Thread.sleep(50);
        }
        renewExpire();
        return true;
    }

自动续期代码实现:

private void renewExpire() {
        String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 1 " +
                "then " +
                "   return redis.call('expire', KEYS[1], ARGV[2]) " +
                "else " +
                "   return 0 " +
                "end";
        // 定时任务仅仅设置延迟时间,递归调用让其自动续期,续期失败后自动结束
        new Timer().schedule(new TimerTask() {
            @Override
            public void run() {
                if (Objects.requireNonNull(redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Collections.singletonList(lockName), uuid, String.valueOf(expire)))) {
                    renewExpire();
                }
            }
        }, this.expire * 1000 / 3);
    }

测试自动续期,在业务代码中增加线程休眠时间:

Thread.sleep(100*1000);

过期时间不断的自动重置:

使用jmeter进行压力测试库存扣减情况正常:

 

posted @ 2023-06-11 14:49  1640808365  阅读(42)  评论(0编辑  收藏  举报