Use Redis to implement distributed locks and solve problems such as distributed lock atomicity, deadlock, accidental deletion, reentrancy, and automatic renewal (implemented using SpringBoot environment)

I. Introduction

      Distributed locks are a mechanism used to achieve synchronized and mutually exclusive access in distributed systems. In a distributed system, multiple nodes accessing shared resources simultaneously may lead to data inconsistency or race conditions. Distributed locks provide a way to protect shared resources to ensure that only one node can access the resource at any time. For example, only one thread can operate the order cancellation function for each order at the same time.

  • Common distributed lock implementations:
    • MySQL: MySQL itself has a lock mechanism. Due to the business characteristics, it is not suitable to use MySQL as a distributed lock, and the performance is average. MySQL is generally rarely used to implement distributed locks.
    • ZooKeeper: ZooKeeper is a better solution for implementing distributed locks in enterprise-level development. Compared with Redis, ZooKeeper is more complicated to deploy and maintain. In addition, ZooKeeper's performance is relatively low and is suitable for scenarios that do not require high performance.
    • Redis: The implementation of Redis distributed lock usually uses SETNX (SET if Not eXists) command and EXPIRE command. Using SETNX you can attempt to set a key-value pair into Redis, which will only succeed if the key does not exist. Clients that successfully acquire the lock can set an expiration time to ensure that the lock is automatically released even in the event of a failure.

2. Characteristics of distributed locks

The distributed lock implemented needs to have the following characteristics:

Features describe
exclusivity At any time, only one thread holds the lock.
High availability & high performance The program is not easy to crash and ensures high availability at all times. In a redis cluster environment, lock acquisition and lock release cannot fail because a node hangs up. Distributed locks still have good performance under high concurrent requests. ;
Anti-deadlock There must be no deadlock problem, there must be a timeout retry mechanism or undo operation, and there must be a way to terminate and exit;
Don't rob Under multi-threading, to prevent arrogance, you can only unlock your own lock and cannot release other people's locks;
Reentrancy If the same thread on the same node acquires the lock, the thread can acquire and use the lock again.

3. Core ideas for implementing Redis distributed locks

      In the conventional implementation, the Redis lock mechanism is generally implemented by the setnx command, which is the abbreviation of "set if not exists". The syntax setnx key value will be The key is set to value. If the key does not exist, 1 will be returned. In this case, it is equivalent to the set command. When the key exists, doing nothing will return 0, and use expire to set the expiration time of a lock to avoid application exceptions that cause the lock to never be released.

For example:

127.0.0.1:6379> setnx key1 1
(integer) 1
127.0.0.1:6379> setnx key1 1
(integer) 0
127.0.0.1:6379> expire key1 60
(integer) 1

But the above setnx and expire methods of implementing distributed locks are unsafe. The two commands are non-atomic and cannot guarantee consistency. , you can implement atomic operations through some third-party frameworks or through scripts yourself. The following will be implemented through code analysis of distributed locks. lua

4. Distributed lock code implementation (solve the problems of distributed lock atomicity, deadlock, accidental deletion, reentrancy, automatic renewal, etc.)

      The SpringBoot environment is used here, and the API of RedisTemplate will be used to operate Redis to implement distributed locks and solve distributed locks Atomicity, deadlock, accidental deletion, reentrancy, Issues such as automatic renewal.

If you need SpringBoot integration to call Redis data, you can jump to:https://blog.csdn.net/weixin_44606481/article/details/133907103

4.1. Distributed lock implementation tool class

      This distributed lock implementation tool class has integrated distributed lock atomicity, deadlock, accidental deletion, reentrancy, and automatic renewal and other problems have been solved. In order to demonstrate and focus on the problem solving steps, there is no specific encapsulation here. You can encapsulate it yourself as needed to enhance scalability.

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

/**
 * @author Kerwin
 */
@Component
public class RedisLockUtils {
    
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    // 活跃锁key+value集合,续期的时候会使用
    private volatile static CopyOnWriteArraySet activeLockKeySet = new CopyOnWriteArraySet();
    // 定时线程池 用于续期
    private static ScheduledExecutorService executorService = Executors.newScheduledThreadPool(5);

    /**
     * 加锁
     * @Param key
     * @Param value 锁的value一般使用线程ID,在解锁时需要使用
     * @Param expireTime 过期时间 单位秒
     */
    public boolean lock(String key, String value, long expireTime) {
    
    
        // 为了实现锁的可重入这里要自己封装一个lua脚本,如果不考虑可重入可以直接使用redisTemplate.opsForValue().setIfAbsent(K key, V value, long timeout, TimeUnit unit)
        String lockLua = getLockLua();
        boolean result = executeLua(lockLua, key, value, String.valueOf(expireTime));
        // 当加锁成功且活跃锁key+value不在集合中则添加续期任务
        if (result && !activeLockKeySet.contains(key + value)) {
    
    
            // 将活跃锁key+value放入集合中
            activeLockKeySet.add(key + value);
            // 加锁成功添加续期任务
            resetExpire(key, value, expireTime);
        }
        return result;
    }

    /**
     * 获取加锁lua脚本
     */
    private String getLockLua() {
    
    
        // 封装加锁lua脚本 PS: 这个lua脚本应该是要定义成全局的,我这里为了演示定义成局部组装方便介绍每一步流程
        // lua脚本参数介绍 KEYS[1]:传入的key  ARGV[1]:传入的value  ARGV[2]:传入的过期时间
        // 在使用redisTemplate执行lua脚本时会传入key数组和参数数组,List<K> keys, Object... args,在lua脚本中取key值和参数值时使用KEYS和ARGV,数组下标从1开始
        StringBuilder lockLua = new StringBuilder();
        // 通过SETNX命令设置锁,如果设置成功则添加一个过期时间并且返回1,否则判断是否为重入锁
        lockLua.append("if redis.call('SETNX', KEYS[1], ARGV[1]) == 1 then\n");
        lockLua.append("    redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))\n");
        lockLua.append("    return 1\n");
        lockLua.append("else\n");
        // 当锁已经存在时,判断传入的value是否相等,如果相等代表为重入锁返回1并且重置过期时间,否则返回0
        lockLua.append("    if redis.call('GET', KEYS[1]) == ARGV[1] then\n");
        lockLua.append("        redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))\n");
        lockLua.append("        return 1\n");
        lockLua.append("    else\n");
        lockLua.append("        return 0\n");
        lockLua.append("    end\n");
        lockLua.append("end");
        return lockLua.toString();
    }

    /**
     * 解锁
     * @Param key
     * @Param value 锁的value一般使用线程ID,在解锁时需要判断是当前线程才运行删除
     */
    public boolean unlock(String key, String value) {
    
    
        // 为了实现避免误删锁,这里要自己封装一个lua脚本
        String unLockLua = getUnlockLua();
        boolean result = executeLua(unLockLua, key, value);
        if (result) {
    
    
            // 将活跃锁key+value从集合中删除
            activeLockKeySet.remove(key + value);
        }
        return result;
    }

    /**
     * 获取解锁lua脚本
     */
    private String getUnlockLua() {
    
    
        // 封装解锁lua脚本 PS: 这个lua脚本应该是要定义成全局的,我这里为了演示定义成局部组装方便介绍每一步流程
        // lua脚本参数介绍 KEYS[1]:传入的key  ARGV[1]:传入的value
        // 在使用redisTemplate执行lua脚本时会传入key数组和参数数组,List<K> keys, Object... args,在lua脚本中取key值和参数值时使用KEYS和ARGV,数组下标从1开始
        StringBuilder unlockLua = new StringBuilder();
        // 判断传入的锁key是否存在,如果不存在则直接返回1,如果存在则判断传入的value值是否和获取到的value相等
        unlockLua.append("if redis.call('EXISTS',KEYS[1]) == 0 then\n");
        unlockLua.append("    return 1\n");
        unlockLua.append("else\n");
        // 判断传入的value值是否和获取到的value相等,如果相等则代表是当前线程删除锁,执行删除对应key逻辑,然后返回1,否则返回0
        unlockLua.append("    if redis.call('GET',KEYS[1]) == ARGV[1] then\n");
        unlockLua.append("        return redis.call('DEL',KEYS[1])\n");
        unlockLua.append("    else\n");
        unlockLua.append("        return 0\n");
        unlockLua.append("    end\n");
        unlockLua.append("end");
        return unlockLua.toString();
    }


    /**
     * 封装redisTemplate执行lua脚本返回boolean类型执行器
     * @param scriptText lua脚本
     * @param key        传入数组keys的第一个元素这里就是我们锁key
     * @param args       传入数组args的第一个元素这里就是我们传入的value
     */
    private boolean executeLua(String scriptText, String key, Object... args) {
    
    
        // 通过 DefaultRedisScript 来执行 lua脚本
        DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>();
        // Boolean 对应 lua脚本返回的 0 1
        redisScript.setResultType(Boolean.class);
        // 指定需要执行的 lua脚本
        redisScript.setScriptText(scriptText);
        // 注意 需要提供 List<K> keys, Object... args 代表 keys 和 ARGV
        return redisTemplate.execute(redisScript, Collections.singletonList(key), args);
    }

    /**
     * 锁续期
     * @Param key
     * @Param value 锁的value一般使用线程ID,在解锁时需要使用
     * @Param expireTime 过期时间 单位秒,
     */
    private void resetExpire(String key, String value, long expireTime) {
    
    
        // 如果key+value在集合中不存在,则不再进行续期操作
        if (!activeLockKeySet.contains(key + value)) {
    
    
            return;
        }

        //设置过期时间,推荐设置成过期时间的1/3时间续期一次,比如30s过期,10s续期一次
        long delay = expireTime <= 3 ? 1 : expireTime / 3;
        executorService.schedule(() -> {
    
    
            System.out.println("自动续期 key="+key+ "  value="+value);

            // 执行续期操作,如果续期成功则再次添加续期任务,如果不成功则将不在进行任务续期,并且将活跃锁key+value从集合中删除
            if (executeLua(getResetExpireLua(), key, value, String.valueOf(expireTime))) {
    
    
                System.out.println("自动续期成功开启下一轮自动续期");
                resetExpire(key, value, expireTime);
            } else {
    
    
                System.out.println("自动续期失败锁key已经删除或不是指定value持有的锁,取消自动续期");
                activeLockKeySet.remove(key + value);
            }
        }, delay, TimeUnit.SECONDS);
    }

    /**
     * 获取锁续期lua脚本
     */
    private String getResetExpireLua() {
    
    
        // 封装续期lua脚本 PS: 这个lua脚本应该是要定义成全局的,我这里为了演示定义成局部组装方便介绍每一步流程
        // lua脚本参数介绍 KEYS[1]:传入的key  ARGV[1]:传入的value  ARGV[2]:传入的过期时间
        // 在使用redisTemplate执行lua脚本时会传入key数组和参数数组,List<K> keys, Object... args,在lua脚本中取key值和参数值时使用KEYS和ARGV,数组下标从1开始
        StringBuilder resetExpireLua = new StringBuilder();
        // 判断传入的锁key是否存在且获取到的value值是否和传入的value值相等,如果相等则重置过期时间,然后返回1,否则返回0
        resetExpireLua.append("if redis.call('EXISTS',KEYS[1]) == 1 and redis.call('GET',KEYS[1]) == ARGV[1] then\n");
        resetExpireLua.append("    redis.call('EXPIRE',KEYS[1],tonumber(ARGV[2]))\n");
        resetExpireLua.append("    return 1\n");
        resetExpireLua.append("else\n");
        resetExpireLua.append("    return 0\n");
        resetExpireLua.append("end");
        return resetExpireLua.toString();
    }
}

4.2. Test the distributed lock effect

      This test class simulates 1,000 users grabbing 10 products to test whether oversold conditions will occur. Two methods are provided, one using distributed locks and one not using distributed locks. When using distributed locks, no overselling will occur. Sell, oversold will definitely occur when distribution is not used.

import com.redisscene.utils.RedisLockUtils;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.*;

@RunWith(SpringRunner.class)
@SpringBootTest(classes = RedisSceneApplication.class)
public class RedisLockTest {
    
    
    @Autowired
    private RedisLockUtils redisLockUtils;

    // 产品库存lock前缀
    private final String productLockKeyPrefix = "PRODUCT_LOCK_KEY:";

    // 模拟产品库存信息
    private static Map<String, String> productMap = new HashMap<>();
    static {
    
    
        productMap.put("id", "P0001");
        productMap.put("title", "分布式锁");
        productMap.put("stock", "10");
        productMap.put("sold", "0");
    }

    @Test
    public void t1() throws InterruptedException {
    
    
        // 定义一个线程池,队列根据需要设置
        ThreadPoolExecutor executor = new ThreadPoolExecutor(100, 100, 10, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000));
        CountDownLatch countDownLatch = new CountDownLatch(1000);

        // 模拟10000个人抢10个商品
        for (int i = 0; i < 1000; i++) {
    
    
            executor.execute(() -> {
    
    
                // 加锁扣减库存
                deductStockLock();
                // 不加锁扣减库存
//                deductStockNotLock();
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        executor.shutdown();
        System.out.println("productMap=" + productMap.toString());
    }
    
    /**
     * 扣减库存:使用分布式锁
     */
    private void deductStockLock() {
    
    
        // 通过UUID和线程ID组合成value,标识当前线程,解锁的时候判断是否是当前线程持有的锁
        String uuidValue = UUID.randomUUID() + ":" + Thread.currentThread().getId();
        // 组装锁key
        String lockKey = productLockKeyPrefix + productMap.get("id");
        boolean lock = false;
        try {
    
    
            // 获取锁
            lock = redisLockUtils.lock(lockKey, uuidValue, 30);
            // 测试锁续期效果 这里模拟业务处理时间40s超过
            Thread.sleep(40000);
            // 再次获取锁,测试可重入效果
            lock = redisLockUtils.lock(lockKey, uuidValue, 30);
            // 如果没有获取到锁则直接返回
            if (!lock) {
    
    
                // 这里直接响应失败,也可以进行重试
                return;
            }
            System.out.println("获取锁成功 uuidValue=" + uuidValue);
            // 获取到锁执行业务逻辑,处理库存信息,假设每个线程每次购买1个商品
            Integer stock = Integer.valueOf(productMap.get("stock"));
            if (stock <= 0) {
    
    
//                System.out.println("库存不足");
                return;
            }
            // 库存 - 1
            productMap.put("stock", String.valueOf(Integer.valueOf(productMap.get("stock")) - 1));
            // 已售 + 1
            productMap.put("sold", String.valueOf(Integer.valueOf(productMap.get("sold")) + 1));
        } catch (Exception e) {
    
    
            e.printStackTrace();
        } finally {
    
    
            if (lock) {
    
    
                // 解锁
                redisLockUtils.unlock(lockKey, uuidValue);
            }
        }
    }

    /**
     * 扣减库存:不使用分布式锁
     */
    private void deductStockNotLock() {
    
    
        // 判断库存是否足够,假设每个线程每次购买1个商品
        Integer stock = Integer.valueOf(productMap.get("stock"));
        if (stock <= 0) {
    
    
//            System.out.println("库存不足");
            return;
        }
        // 暂停10毫秒方便呈现不加锁超卖效果
        try {
    
    
            Thread.sleep(10);
        } catch (InterruptedException e) {
    
    
            throw new RuntimeException(e);
        }
        // 库存 - 1
        productMap.put("stock", String.valueOf(Integer.valueOf(productMap.get("stock")) - 1));
        // 已售 + 1
        productMap.put("sold", String.valueOf(Integer.valueOf(productMap.get("sold")) + 1));
    }
}

5. Common problems and solutions to distributed locks

      In the distributed lock implementation tool class in Chapter 4, the following problems have been solved, and the comments are also detailed.

5.1. Distributed lock deadlock problem

5.1.1. Logic description

      The most feared problem when using distributed locks is deadlock. The deadlock written in my own business will not be explained here. Here I will only introduce how to solve the deadlock when abnormal situations occur. If the business exception is not handled well or the application service is down, there will be no explanation. Unlocking will cause a deadlock problem. The lock key will always be stored in Redis and will not be released. When the subsequent business resumes to obtain the lock, it has been unable to obtain the lock because the lock already exists. This is a deadlock problem, but the deadlock problem is very good. The solution is to just add an expiration time to the lock key.

5.1.2. Solution
  • Use RedisTemplate to add expiration time in the code
// setIfAbsent方法等同于setnx,当这个key不存在时插入成功返回true,当key存在时不做处理返回false
boolean lock = redisTemplate.opsForValue().setIfAbsent(key1, value1);
// 加锁成功设置一个30s过期时间
if(lock){
    
    
    redisTemplate.expire(key,30,TimeUnit.SECONDS);
}
  • There is a problem
    Obtaining the lock and adding the expiration time are two-step operations and are not atomic. There will be problems in concurrent operations. The following will be passedluaScript to solve atomicity issues.

5.2. Distributed lock atomicity issue

5.2.1. Logic description

      Setting a timeout for the lock key in 5.1 solved the deadlock problem, but because it is divided into two steps, Redis needs to be called separately and is not atomic. To ensure atomicity, you only need to merge the two steps. It can be called into a Redis. The core idea is to implement it through lua script. You can directly operate Redis execution lua script through RedisTemplate, and Redis executes luaThe script is also single-threaded so atomicity can be guaranteed.

5.2.2. Solution
  • 1. Uselua script to implement locking and add expiration time
-- KEYS[1]:传入的key  ARGV[1]:传入的value  ARGV[2]:传入的过期时间
-- 使用setnx插入key,如果成功给key设置一个过期时间然后返回1,如果失败直接返回0
if redis.call('SETNX', KEYS[1], ARGV[1]) == 1 then
    redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
    return 1
else
    return 0
end
  • 2. Use the method of RedisTemplate. The method is equivalent to , and this method also implements the atomic operation of adding expiration time to the key. The specific implementation is similar to our script abovesetIfAbsentsetIfAbsentsetnxlua
// 当这个key不存在时插入成功并且设置一个超时时间然后返回true,当key存在时不做处理返回false
boolean lock = redisTemplate.opsForValue().setIfAbsent(key1, value1, 30, TimeUnit.SECONDS);

5.3. Distributed lock reentrancy problem

5.3.1. Logic description

      Reentrant lock, also known as recursive lock: means that when the same thread acquires the lock in the outer method, the inner method that enters the thread will automatically acquire the lock (provided that the lock object must be the same object). It will not be caused by It has been acquired before but has not been released yet and is blocked. If it is a recursive calling method with synchronized modification, it will be very troublesome if the program is blocked by itself the second time it enters. Therefore, ReentrantLock and synchronized in Java are both reentrant locks. One advantage of reentrant locks is that they can avoid deadlocks to a certain extent. It is also relatively simple to implement reentrant locks in distributed locks, as long as the value is judged in a Lua script that ensures atomicity. Just value.

5.3.2. Solution
  • luaScript implementation
-- KEYS[1]:传入的key  ARGV[1]:传入的value  ARGV[2]:传入的过期时间
-- 通过SETNX命令设置锁,如果设置成功则添加一个过期时间并且返回1,否则判断是否为重入锁
if redis.call('SETNX', KEYS[1], ARGV[1]) == 1 then
    redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
    return 1
else
	-- 当锁已经存在时,判断传入的value是否相等,如果相等代表为重入锁返回1并且重置过期时间,否则返回0
    if redis.call('GET', KEYS[1]) == ARGV[1] then
        redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
        return 1
    else
        return 0
    end
end

5.4. How to prevent accidental deletion with distributed locks

5.4.1. Logic description

      The thread holding the lock is blocked inside the lock, causing its lock to be automatically released. At this time, other threads, thread 2, try to obtain the lock, and get the lock. Then, during the execution of thread 2 holding the lock, Thread 1 reacts and continues execution. During the execution of thread 1, it reaches the deletion lock logic. At this time, the lock that should belong to thread 2 will be deleted. This is accidental deletion. To solve the accidental deletion, you only need to make a judgment. Whether this lock belongs to you, you can only delete the lock that belongs to you. Here, a UUID + thread ID will be used as the value of the lock. When deleting the lock, you can judge whether the value values ​​are the same.

5.4.2. Solution
  • luaScript implementation
-- KEYS[1]:传入的key  ARGV[1]:传入的value
-- 判断传入的锁key是否存在,如果不存在则直接返回1,如果存在则判断传入的value值是否和获取到的value相等
if redis.call('EXISTS',KEYS[1]) == 0 then
    return 1
else
    -- 判断传入的value值是否和获取到的value相等,如果相等则代表是当前线程删除锁,执行删除对应key逻辑,然后返回1,否则返回0
    if redis.call('GET',KEYS[1]) == ARGV[1] then
        return redis.call('DEL',KEYS[1])
    else
        return 0
    end
end

5.5. Distributed lock automatic renewal problem

5.5.1. Logic description

      Suppose we set an expiration time of 30s for a certain business distributed lock, but this business needs to be executed for 40s. After the lock expires after 30s, other threads can also acquire the lock, but the previous thread has not completed execution. This is obviously If there is a problem, the lock must be automatically renewed for the business that has not been completed.

5.5.2. Solution

      What I use here is a timing thread poolScheduledExecutorService to implement it. When locking, a timing task is added to the timing thread pool synchronously. The timing time is generally set to 1/3 of the expiration time. , and considering the reentrancy problem, a set collection will be used to store the combined value of key+value. Each key+value can only be added once. The renewal method is in 4.1, distributed lock implementation tool class< Detailed description in a i=2>method. resetExpire

Guess you like

Origin blog.csdn.net/weixin_44606481/article/details/134373900
Recommended