Redis - 分布式锁实现以及相关问题解决方案

1.分布式锁是什么?

 分布式锁是控制分布式系统或不同系统之间共同访问共享资源的一种锁实现。如果不同的系统或同一个系统的不同主机之间共享了某个资源时,往往通过互斥来防止彼此之间的干扰。实现分布式锁的方式有很多,可以通过各种中间件来进行分布式锁的设计,包括RedisZookeeper等,这里我们主要介绍Redis如何实现分布式锁以及在整个过程中出现的问题和优化解决方案。

1.1 分布式锁设计目的

 可以保证在分布式部署的应用集群中,同一个方法的同一操作只能被一台机器上的一个线程执行。分布式锁至少包含以下三点:

  1. 具有互斥性。任意时刻只有一个服务持有锁。
  2. 不会死锁。即使持有锁的服务异常崩溃没有主动解锁后续也能够保证其他服务可以拿到锁。
  3. 加锁和解锁都需要是同一个服务。

1.2 分布式锁设计要求

  1. 分布式锁要是一把可重入锁(同时需避免死锁)。
  2. 分布式锁有高可用的获取锁和释放锁功能。
  3. 分布式锁获取锁和释放锁的性能要好

1.3 分布式锁设计思路

  1. 使用SETNX命令获取锁(Key存在则返回0,不存在并且设置成功返回1)。
  2. 若返回0则不进行业务操作,若返回1则设置锁Value为当前服务器IP + 业务标识,用于锁释放和锁延期时判断。同时使用EXPIRE命令给锁设置一个合理的过期时间,避免当前服务宕机锁永久存在造成死锁,并且设计需要保证可重入性。
  3. 执行业务,业务执行完成判断当前锁Value是否为当前服务器IP + 业务标识,若相同则通过DEL或者EXPIRE设置为0释放当前锁。

2.分布式锁实现

 我们在实现分布式锁的过程中大致思路就是上图的整个流程,这里我们主要记住几个要点:

  1. 锁一定要设置失效时间,否则服务宕机锁就会永久性存在,整个业务体系死锁。
  2. 业务执行完必须解锁,可将加锁和业务代码放置try/catch中,解锁流程放置finally中。

 若要用jar包方式后台启动服务,可用 nohup java -jar jar包名称 &命令。这里我们来看一下我们加解锁的主要代码。

ClusterLockJob.java

package com.springboot.schedule;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.util.Enumeration;
import java.util.concurrent.TimeUnit;

/** * @author hzk * @date 2019/7/2 */
@Component
public class ClusterLockJob {

    @Autowired
    private RedisTemplate redisTemplate;

    @Value("${server.port}")
    private String port;

    public static final String LOCK_PRE = "lock_prefix_";

    @Scheduled(cron = "0/5 * * * * *")
    public void lock(){
        String lockName = LOCK_PRE + "ClusterLockJob";
        String currentValue = getHostIp() + ":" + port;
        Boolean ifAbsent = false;
        try {
            //设置锁
            //Boolean ifAbsent = redisTemplate.opsForValue().setIfAbsent(lockName, currentValue,3600, TimeUnit.SECONDS);
            ifAbsent = redisTemplate.opsForValue().setIfAbsent(lockName, currentValue);

            if(ifAbsent){
               //获取锁成功,设置失效时间
                redisTemplate.expire(lockName,3600,TimeUnit.SECONDS);
                System.out.println("Lock success,execute business,current time:" + System.currentTimeMillis());
                Thread.sleep(3000);
            }else{
                //获取锁失败
                String value = (String) redisTemplate.opsForValue().get(lockName);
                System.out.println("Lock fail,current lock belong to:" + value);
            }
        }catch (Exception e){
            System.out.println("ClusterLockJob exception:" + e);
        }finally {
            if(ifAbsent){
                //若分布式锁Value与本机Value一致,则当前机器获得锁,进行解锁
                redisTemplate.delete(lockName);
            }
        }
    }

    /** * 获取本机内网IP地址方法 * @return */
    private static String getHostIp(){
        try{
            Enumeration<NetworkInterface> allNetInterfaces = NetworkInterface.getNetworkInterfaces();
            while (allNetInterfaces.hasMoreElements()){
                NetworkInterface netInterface = (NetworkInterface) allNetInterfaces.nextElement();
                Enumeration<InetAddress> addresses = netInterface.getInetAddresses();
                while (addresses.hasMoreElements()){
                    InetAddress ip = (InetAddress) addresses.nextElement();
                    if (ip != null
                            && ip instanceof Inet4Address
                            && !ip.isLoopbackAddress() //loopback地址即本机地址,IPv4的loopback范围是127.0.0.0 ~ 127.255.255.255
                            && ip.getHostAddress().indexOf(":")==-1){
                        return ip.getHostAddress();
                    }
                }
            }
        }catch(Exception e){
            e.printStackTrace();
        }
        return null;
    }

}

 这里我们给锁Key设定了一个和业务相关的唯一标示,用于当前业务分布式锁的相关操作,首先我们通过setIfAbsent也就是SETNX命令去加锁,若成功我们给锁加上失效时间并执行业务结束后解锁,否则重试或者结束等待下一次任务周期。这里我们不将服务打包多个部署在服务器上,直接本地修改端口启动三个项目。看下结果是否和我们预想一致。

port:8080

Lock fail,current lock belong to:192.168.126.1:8081
Lock fail,current lock belong to:192.168.126.1:8082
Lock fail,current lock belong to:192.168.126.1:8082
Lock fail,current lock belong to:192.168.126.1:8081
Lock success,execute business,current time:1562122895372
Lock fail,current lock belong to:192.168.126.1:8082
Lock success,execute business,current time:1562122905350
Lock success,execute business,current time:1562122910334
Lock success,execute business,current time:1562122915340
Lock fail,current lock belong to:192.168.126.1:8082

port:8081

Lock fail,current lock belong to:192.168.126.1:8080
Lock fail,current lock belong to:192.168.126.1:8082
Lock fail,current lock belong to:192.168.126.1:8080
Lock fail,current lock belong to:192.168.126.1:8080
Lock fail,current lock belong to:192.168.126.1:8080
Lock fail,current lock belong to:192.168.126.1:8082
Lock fail,current lock belong to:192.168.126.1:8082
Lock fail,current lock belong to:192.168.126.1:8082
Lock fail,current lock belong to:192.168.126.1:8080
Lock success,execute business,current time:1562122940330

port:8082

Lock fail,current lock belong to:192.168.126.1:8080
Lock fail,current lock belong to:192.168.126.1:8080
Lock success,execute business,current time:1562122920341
Lock success,execute business,current time:1562122925392
Lock success,execute business,current time:1562122930407
Lock fail,current lock belong to:192.168.126.1:8080
Lock fail,current lock belong to:192.168.126.1:8081
Lock success,execute business,current time:1562122945340
Lock fail,current lock belong to:192.168.126.1:8080
Lock success,execute business,current time:1562122955339

 当我们同时开启三个服务,模拟分布式项目,可以看到当我们执行同一段业务代码时,通过分布式锁的实现达到了我们预期的目的,同时只会有一个服务进行业务处理。

3.分布式锁实现过程中可能出现的问题以及解决方案

3.1 服务宕机造成死锁

 上面我们通过我们之前的设计思路,去构建了一个分布式锁的实现,但是在真实的场景中我们需要考虑更多可能出现的一些问题。上面我们实现的思路整体是没有问题的,但是还需要考虑一些特殊情况。


 通过以上两张图我们可以知道,当我们某个服务在成功获取锁之后,在还没有给当前锁设置失效时间之前服务宕机,那么该锁会永久存在,整个业务体系会形成死锁。我们这里模拟这个业务场景,先同时开启三个服务,然后当某个服务设置锁并未设置失效时间前我们把他给停止。

port:8080

Lock fail,current lock belong to:192.168.126.1:8082
Lock fail,current lock belong to:192.168.126.1:8082
Disconnected from the target VM, address: '127.0.0.1:8606', transport: 'socket'
Lock success,execute business,current time:1562124770986

 当8080端口服务获取到锁未设置锁失效时间时我们将其停止。观察另外两个服务获取锁情况。

port:8081

Lock fail,current lock belong to:192.168.126.1:8080
Lock fail,current lock belong to:192.168.126.1:8080
Lock fail,current lock belong to:192.168.126.1:8080
Lock fail,current lock belong to:192.168.126.1:8080
Lock fail,current lock belong to:192.168.126.1:8080
Lock fail,current lock belong to:192.168.126.1:8080
Lock fail,current lock belong to:192.168.126.1:8080
Lock fail,current lock belong to:192.168.126.1:8080
Lock fail,current lock belong to:192.168.126.1:8080
Lock fail,current lock belong to:192.168.126.1:8080

port:8082

Lock fail,current lock belong to:192.168.126.1:8080
Lock fail,current lock belong to:192.168.126.1:8080
Lock fail,current lock belong to:192.168.126.1:8080
Lock fail,current lock belong to:192.168.126.1:8080
Lock fail,current lock belong to:192.168.126.1:8080
Lock fail,current lock belong to:192.168.126.1:8080
Lock fail,current lock belong to:192.168.126.1:8080
Lock fail,current lock belong to:192.168.126.1:8080
Lock fail,current lock belong to:192.168.126.1:8080
Lock fail,current lock belong to:192.168.126.1:8080

 果然另外两个服务是会无止尽获取锁失败,进入一个无限循环拿不到锁的情况,此时就出现了我们所说的服务提供异常造成的死锁问题,这里我们有几种解决办法介绍给大家,主要的解决思路都是使SETNXSETEX包装成一个整体使其具有原子性来解决。

3.1.1 Lua脚本命令连用

Redis2.6.0版本起,通过内置的Lua解释器,可以使用EVAL命令对Lua脚本进行求值。关于Lua大家可以自己去了解,使用起来的话很简单。Redis使用单个Lua解释器去运行所有脚本,并且也保证脚本会以原子性的方式执行,即当某个脚本正在运行时不会有其他脚本或Redis命令被执行。这和使用MULTI/EXEC包围的事务类似。在其他客户端看来,脚本的效果要么是不可见的,要么是已完成的。关于EVAL命令使用可以参考Redis 命令参考 » Script(脚本)
 通过Redis对Lua脚本保持原子性的支持,我们可以利用此特性去实现SETNXSETEX并用,包装成一个整体执行。这里我们主要有以下几个步骤:

  1. 资源文件目录新建.lua文件并且编写lua脚本
  2. 代码中传递参数执行脚本

setnx_ex.lua

local lockKey = KEYS[1]
local lockValue = KEYS[2]

local result_nx = redis.call('SETNX',lockKey,lockValue)
if result_nx == 1 then
    local result_ex = redis.call('EXPIRE',lockKey,3600)
    return result_ex
else
    return result_nx
end

LuaClusterLockJob.java

package com.springboot.schedule;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Component;

import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.concurrent.TimeUnit;

/** * lua脚本命令连用 保证原子性 * @author hzk * @date 2019/7/2 */
@Component
public class LuaClusterLockJob {

    @Autowired
    private RedisTemplate redisTemplate;

    @Value("${server.port}")
    private String port;

    public static final String LOCK_PRE = "lock_prefix_lua_";

    private DefaultRedisScript<Boolean> lockLuaScript;

    @Scheduled(cron = "0/5 * * * * *")
    public void lock(){
        String lockName = LOCK_PRE + "LuaClusterLockJob";
        String currentValue = getHostIp() + ":" + port;
        Boolean luaResult = false;
        try {
            //设置锁
            luaResult = luaScript(lockName,currentValue);

            if(luaResult){
               //获取锁成功,设置失效时间
                System.out.println("Lock success,execute business,current time:" + System.currentTimeMillis());
                Thread.sleep(3000);
            }else{
                //获取锁失败
                String value = (String) redisTemplate.opsForValue().get(lockName);
                System.out.println("Lock fail,current lock belong to:" + value);
            }
        }catch (Exception e){
            System.out.println("ClusterLockJob exception:" + e);
        }finally {
            if(luaResult){
                //若分布式锁Value与本机Value一致,则当前机器获得锁,进行解锁
                redisTemplate.delete(lockName);
            }
        }
    }

    /** * 获取本机内网IP地址方法 * @return */
    private static String getHostIp(){
        try{
            Enumeration<NetworkInterface> allNetInterfaces = NetworkInterface.getNetworkInterfaces();
            while (allNetInterfaces.hasMoreElements()){
                NetworkInterface netInterface = (NetworkInterface) allNetInterfaces.nextElement();
                Enumeration<InetAddress> addresses = netInterface.getInetAddresses();
                while (addresses.hasMoreElements()){
                    InetAddress ip = (InetAddress) addresses.nextElement();
                    if (ip != null
                            && ip instanceof Inet4Address
                            && !ip.isLoopbackAddress() //loopback地址即本机地址,IPv4的loopback范围是127.0.0.0 ~ 127.255.255.255
                            && ip.getHostAddress().indexOf(":")==-1){
                        return ip.getHostAddress();
                    }
                }
            }
        }catch(Exception e){
            e.printStackTrace();
        }
        return null;
    }

    /** * 执行lua脚本 * @param key * @param value * @return */
    public Boolean luaScript(String key,String value){
        lockLuaScript = new DefaultRedisScript<Boolean>();
        lockLuaScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("setnx_ex.lua")));
        lockLuaScript.setResultType(Boolean.class);
        //封装传递脚本参数
        ArrayList<Object> params = new ArrayList<>();
        params.add(key);
        params.add(value);
        return (Boolean) redisTemplate.execute(lockLuaScript, params);
    }

}

 这里我们通过DefaultRedisScript去执行我们编写的Lua脚本,达到了NXEX连用保证原子性的目的。

3.1.2 RedisConnection命令连用

 由于redisTemplate本身通过valueOperation无法实现命令连用,但是我们可以通过RedisConnection这种方式去实现命令连用。

RedisConnectionLockJob .java

package com.springboot.schedule;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisStringCommands;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.types.Expiration;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.util.Enumeration;

/** * RedisConnection 实现命令连用 * @author hzk * @date 2019/7/2 */
@Component
public class RedisConnectionLockJob {

    @Autowired
    private RedisTemplate redisTemplate;

    @Value("${server.port}")
    private String port;

    public static final String LOCK_PRE = "lock_prefix_redisconnection_";

    @Scheduled(cron = "0/5 * * * * *")
    public void lock(){
        String lockName = LOCK_PRE + "RedisConnectionLockJob";
        String currentValue = getHostIp() + ":" + port;
        Long timeout = 3600L;
        Boolean result = false;
        try {
            //设置锁
            result = setLock(lockName,currentValue,timeout);

            if(result){
               //获取锁成功,设置失效时间
                System.out.println("Lock success,execute business,current time:" + System.currentTimeMillis());
                Thread.sleep(3000);
            }else{
                //获取锁失败
                String value = get(lockName);
                System.out.println("Lock fail,current lock belong to:" + value);
            }
        }catch (Exception e){
            System.out.println("ClusterLockJob exception:" + e);
        }finally {
            if(result){
                //若分布式锁Value与本机Value一致,则当前机器获得锁,进行解锁
                redisTemplate.delete(lockName);
            }
        }
    }

    /** * 获取本机内网IP地址方法 * @return */
    private static String getHostIp(){
        try{
            Enumeration<NetworkInterface> allNetInterfaces = NetworkInterface.getNetworkInterfaces();
            while (allNetInterfaces.hasMoreElements()){
                NetworkInterface netInterface = (NetworkInterface) allNetInterfaces.nextElement();
                Enumeration<InetAddress> addresses = netInterface.getInetAddresses();
                while (addresses.hasMoreElements()){
                    InetAddress ip = (InetAddress) addresses.nextElement();
                    if (ip != null
                            && ip instanceof Inet4Address
                            && !ip.isLoopbackAddress() //loopback地址即本机地址,IPv4的loopback范围是127.0.0.0 ~ 127.255.255.255
                            && ip.getHostAddress().indexOf(":")==-1){
                        return ip.getHostAddress();
                    }
                }
            }
        }catch(Exception e){
            e.printStackTrace();
        }
        return null;
    }

    /** * 设置锁 * @param key * @param value * @param timeout * @return */
    public Boolean setLock(String key,String value,Long timeout){
        try{
            return (Boolean)redisTemplate.execute(new RedisCallback<Boolean>() {
                @Override
                public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
                    return connection.set(key.getBytes(), value.getBytes(), Expiration.seconds(timeout), RedisStringCommands.SetOption.ifAbsent());
                }
            });
        }catch (Exception e){
            System.out.println("setLock Exception:" + e);
        }
        return false;
    }

    /** * 获取Key->Value * @param key * @return */
    public String get(String key){
        try{
            byte[] result = (byte[]) redisTemplate.execute(new RedisCallback<byte[]>() {
                @Override
                public byte[] doInRedis(RedisConnection connection) throws DataAccessException {
                    return connection.get(key.getBytes());
                }
            });
            if(result.length > 0){
                return new String(result);
            }
        }catch (Exception e){
            System.out.println("setLock Exception:" + e);
        }
        return null;
    }

}

 我们借助RedisConnection很轻松地实现了命令连用的功能。这里我们主要还是要多参考官方文档,查看当前版本支持哪些方法调用。

3.1.3 升级高版本Redis

 其实在提供Redis整合的团队里,由于分布式锁频繁的应用也有所改进,在高版本中通过RedisTemplate我们就可以实现NXEX的连用。


package com.springboot.schedule;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.util.Enumeration;
import java.util.concurrent.TimeUnit;

/** * @author hzk * @date 2019/7/2 */
@Component
public class ClusterLockJob {

    @Autowired
    private RedisTemplate redisTemplate;

    @Value("${server.port}")
    private String port;

    public static final String LOCK_PRE = "lock_prefix_";

    @Scheduled(cron = "0/5 * * * * *")
    public void lock(){
        String lockName = LOCK_PRE + "ClusterLockJob";
        String currentValue = getHostIp() + ":" + port;
        Boolean ifAbsent = false;
        try {
            //设置锁
            ifAbsent = redisTemplate.opsForValue().setIfAbsent(lockName, currentValue,3600, TimeUnit.SECONDS);

            if(ifAbsent){
               //获取锁成功,设置失效时间
                System.out.println("Lock success,execute business,current time:" + System.currentTimeMillis());
                Thread.sleep(3000);
            }else{
                //获取锁失败
                String value = (String) redisTemplate.opsForValue().get(lockName);
                System.out.println("Lock fail,current lock belong to:" + value);
            }
        }catch (Exception e){
            System.out.println("ClusterLockJob exception:" + e);
        }finally {
            if(ifAbsent){
                //若分布式锁Value与本机Value一致,则当前机器获得锁,进行解锁
                redisTemplate.delete(lockName);
            }
        }
    }

    /** * 获取本机内网IP地址方法 * @return */
    private static String getHostIp(){
        try{
            Enumeration<NetworkInterface> allNetInterfaces = NetworkInterface.getNetworkInterfaces();
            while (allNetInterfaces.hasMoreElements()){
                NetworkInterface netInterface = (NetworkInterface) allNetInterfaces.nextElement();
                Enumeration<InetAddress> addresses = netInterface.getInetAddresses();
                while (addresses.hasMoreElements()){
                    InetAddress ip = (InetAddress) addresses.nextElement();
                    if (ip != null
                            && ip instanceof Inet4Address
                            && !ip.isLoopbackAddress() //loopback地址即本机地址,IPv4的loopback范围是127.0.0.0 ~ 127.255.255.255
                            && ip.getHostAddress().indexOf(":")==-1){
                        return ip.getHostAddress();
                    }
                }
            }
        }catch(Exception e){
            e.printStackTrace();
        }
        return null;
    }

}

 在高版本的使用中,我们更方便快捷就可以避免命令无法连用造成的问题。

3.2 业务时间大于锁超时时间

3.2.1 解锁错位问题

 我们试想一个场景,当我们A线程加锁成功执行业务,但是由于业务时间大于锁超时时间,当锁超时之后B线程加锁成功开始执行业务,此时A线程业务执行结束,进行解锁操作。很多同学此时是没有考虑这种情况的,这种情况下就会造成B线程加的锁被A线程错位解掉,造成一种无锁的情况,另外的线程再竞争锁发现无锁又可以进行业务操作。
 这里我们主要提供几个思路。第一个思路就是在我们解锁时我们需要比对当前锁的内容是否属于当前线程锁加的锁,若是才进行解锁操作。第二个思路就是我们在锁内容比较时需要先从Redis中取出当前锁内容,如果此时取值仍然为A线程占用,当前取值就是A线程的锁内容,但是在下一刻锁超时导致B线程拿到了锁,此时A锁取到的值就是一个脏数据,所以我们要通过之前我们解决问题的思想,将取值和比较以及解锁封装成一个原子性操作。这里我们依然通过Lua脚本可以实现,来看下如何达到这种目的的吧。

release_lock.lua

--
-- Created by IntelliJ IDEA.
-- User: hzk
-- Date: 2019/7/3
-- Time: 18:31
-- To change this template use File | Settings | File Templates.
--
local lockKey = KEYS[1]
local lockValue = KEYS[2]

local result_get = redis.call('get',lockKey);
if lockValue == result_get then
    local result_del = redis.call('del',lockKey)
    return result_del
else
    return false;
end

LuaClusterLockJob2.java

package com.springboot.schedule;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Component;

import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.util.ArrayList;
import java.util.Enumeration;

/** * lua脚本命令连用 保证原子性 * @author hzk * @date 2019/7/2 */
@Component
public class LuaClusterLockJob2 {

    @Autowired
    private RedisTemplate redisTemplate;

    @Value("${server.port}")
    private String port;

    public static final String LOCK_PRE = "lock_prefix_lua_";

    private DefaultRedisScript<Boolean> lockLuaScript;

    @Scheduled(cron = "0/5 * * * * *")
    public void lock(){
        String lockName = LOCK_PRE + "LuaClusterLockJob2";
        String currentValue = getHostIp() + ":" + port;
        Boolean luaResult = false;
        try {
            //设置锁
            luaResult = luaScript(lockName,currentValue);

            if(luaResult){
               //获取锁成功,设置失效时间
                System.out.println("Lock success,execute business,current time:" + System.currentTimeMillis());
                Thread.sleep(3000);
            }else{
                //获取锁失败
                String value = (String) redisTemplate.opsForValue().get(lockName);
                System.out.println("Lock fail,current lock belong to:" + value);
            }
        }catch (Exception e){
            System.out.println("LuaClusterLockJob2 exception:" + e);
        }finally {
            if(luaResult){
                //若分布式锁Value与本机Value一致,则当前机器获得锁,进行解锁
// redisTemplate.delete(lockName);
                Boolean releaseLock = luaScriptReleaseLock(lockName, currentValue);
				if(releaseLock){
                    System.out.println("release lock success");
                }else{
                    System.out.println("release lock fail");
                }
        }
    }

    /** * 获取本机内网IP地址方法 * @return */
    private static String getHostIp(){
        try{
            Enumeration<NetworkInterface> allNetInterfaces = NetworkInterface.getNetworkInterfaces();
            while (allNetInterfaces.hasMoreElements()){
                NetworkInterface netInterface = (NetworkInterface) allNetInterfaces.nextElement();
                Enumeration<InetAddress> addresses = netInterface.getInetAddresses();
                while (addresses.hasMoreElements()){
                    InetAddress ip = (InetAddress) addresses.nextElement();
                    if (ip != null
                            && ip instanceof Inet4Address
                            && !ip.isLoopbackAddress() //loopback地址即本机地址,IPv4的loopback范围是127.0.0.0 ~ 127.255.255.255
                            && ip.getHostAddress().indexOf(":")==-1){
                        return ip.getHostAddress();
                    }
                }
            }
        }catch(Exception e){
            e.printStackTrace();
        }
        return null;
    }

    /** * 执行lua脚本 * @param key * @param value * @return */
    public Boolean luaScript(String key,String value){
        lockLuaScript = new DefaultRedisScript<Boolean>();
        lockLuaScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("setnx_ex.lua")));
        lockLuaScript.setResultType(Boolean.class);
        //封装传递脚本参数
        ArrayList<Object> params = new ArrayList<>();
        params.add(key);
        params.add(value);
        return (Boolean) redisTemplate.execute(lockLuaScript, params);
    }

    /** * 执行lua脚本(释放锁) * @param key * @param value * @return */
    public Boolean luaScriptReleaseLock(String key,String value){
        lockLuaScript = new DefaultRedisScript<Boolean>();
        lockLuaScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("release_lock.lua")));
        lockLuaScript.setResultType(Boolean.class);
        //封装传递脚本参数
        ArrayList<Object> params = new ArrayList<>();
        params.add(key);
        params.add(value);
        return (Boolean) redisTemplate.execute(lockLuaScript, params);
    }

}

 通过Lua脚本我们解决了这个问题,大家可以动手试试。

3.2.2 业务并发执行问题

 我们大家在使用分布式锁的时候需要思考一个问题,那就是锁超时时间如何设置?如果业务中包含了一些请求或者排队操作,都可能会导致业务时间被大幅拉长,业务并未执行完成锁就已经失效,此时就可能会出现多个业务同时在执行的情况。如果对并发要求严格的业务,那这就是不可接受的,所以我们就需要去思考如何才能避免这种情况。
 在整个设计中,我们的思路主要是通过开启一个守护线程去周期性进行检测续时,直接上代码更直观,这里有几个需要注意的地方:

  1. 在续时锁的时候,我们需要检测当前锁需要续时的锁是否是当前线程锁占有,此时涉及取值和设时两个操作,考虑到之前的并发情况,我们仍然采用Lua脚本去实现续时。
  2. 开启的守护线程执行频率需要控制,不可频繁执行造成资源浪费,我们这里以2/3过期时间周期去检测执行。
  3. 当我们业务执行完成,该守护线程需要被销毁,不可无限制执行。

ExpandLockExpireTask .java

package com.springboot.task;

import com.springboot.schedule.LuaClusterLockJob2;

/** * 锁续时任务 * @author hzk * @date 2019/7/4 */
public class ExpandLockExpireTask implements Runnable {

    private String key;
    private String value;
    private long expire;
    private boolean isRunning;
    private LuaClusterLockJob2 luaClusterLockJob2;

    public ExpandLockExpireTask(String key, String value, long expire, LuaClusterLockJob2 luaClusterLockJob2) {
        this.key = key;
        this.value = value;
        this.expire = expire;
        this.luaClusterLockJob2 = luaClusterLockJob2;
        this.isRunning = true;
    }

    @Override
    public void run() {
        //任务执行周期
        long waitTime = expire * 1000 * 2 / 3;
        while (isRunning){
            try {
                Thread.sleep(waitTime);
                if(luaClusterLockJob2.luaScriptExpandLockExpire(key,value,expire)){
                    System.out.println("Lock expand expire success! " + value);
                }else{
                    stopTask();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    private void stopTask(){
        isRunning = false;
    }

}

LuaClusterLockJob2.java

package com.springboot.schedule;

import com.springboot.task.ExpandLockExpireTask;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Component;

import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.util.ArrayList;
import java.util.Enumeration;

/** * lua脚本命令连用 保证原子性 * @author hzk * @date 2019/7/2 */
@Component
public class LuaClusterLockJob2 {

    @Autowired
    private RedisTemplate redisTemplate;

    @Value("${server.port}")
    private String port;

    public static final String LOCK_PRE = "lock_prefix_lua_";

    private DefaultRedisScript<Boolean> lockLuaScript;

    @Scheduled(cron = "0/5 * * * * *")
    public void lock(){
        String lockName = LOCK_PRE + "LuaClusterLockJob2";
        String currentValue = getHostIp() + ":" + port;
        Boolean luaResult = false;
        Long expire = 30L;
        try {
            //设置锁
            luaResult = luaScript(lockName,currentValue,expire);

            if(luaResult){
               //获取锁成功,设置失效时间
                System.out.println("Lock success,execute business,current time:" + System.currentTimeMillis());
                //开启守护线程 定期检测 续锁
                ExpandLockExpireTask expandLockExpireTask = new ExpandLockExpireTask(lockName,currentValue,expire,this);
                Thread thread = new Thread(expandLockExpireTask);
                thread.setDaemon(true);
                thread.start();

                Thread.sleep(600 * 1000);
            }else{
                //获取锁失败
                String value = (String) redisTemplate.opsForValue().get(lockName);
                System.out.println("Lock fail,current lock belong to:" + value);
            }
        }catch (Exception e){
            System.out.println("LuaClusterLockJob2 exception:" + e);
        }finally {
            if(luaResult){
                //若分布式锁Value与本机Value一致,则当前机器获得锁,进行解锁
// redisTemplate.delete(lockName);
                Boolean releaseLock = luaScriptReleaseLock(lockName, currentValue);
                if(releaseLock){
                    System.out.println("release lock success");
                }else{
                    System.out.println("release lock fail");
                }
            }
        }
    }

    /** * 获取本机内网IP地址方法 * @return */
    private static String getHostIp(){
        try{
            Enumeration<NetworkInterface> allNetInterfaces = NetworkInterface.getNetworkInterfaces();
            while (allNetInterfaces.hasMoreElements()){
                NetworkInterface netInterface = (NetworkInterface) allNetInterfaces.nextElement();
                Enumeration<InetAddress> addresses = netInterface.getInetAddresses();
                while (addresses.hasMoreElements()){
                    InetAddress ip = (InetAddress) addresses.nextElement();
                    if (ip != null
                            && ip instanceof Inet4Address
                            && !ip.isLoopbackAddress() //loopback地址即本机地址,IPv4的loopback范围是127.0.0.0 ~ 127.255.255.255
                            && ip.getHostAddress().indexOf(":")==-1){
                        return ip.getHostAddress();
                    }
                }
            }
        }catch(Exception e){
            e.printStackTrace();
        }
        return null;
    }

    /** * 执行lua脚本 * @param key * @param value * @return */
    public Boolean luaScript(String key,String value,Long expire){
        lockLuaScript = new DefaultRedisScript<Boolean>();
        lockLuaScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("setnx_ex.lua")));
        lockLuaScript.setResultType(Boolean.class);
        //封装传递脚本参数
        ArrayList<Object> params = new ArrayList<>();
        params.add(key);
        params.add(value);
        params.add(String.valueOf(expire));
        return (Boolean) redisTemplate.execute(lockLuaScript, params);
    }

    /** * 执行lua脚本(释放锁) * @param key * @param value * @return */
    public Boolean luaScriptReleaseLock(String key,String value){
        lockLuaScript = new DefaultRedisScript<Boolean>();
        lockLuaScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("release_lock.lua")));
        lockLuaScript.setResultType(Boolean.class);
        //封装传递脚本参数
        ArrayList<Object> params = new ArrayList<>();
        params.add(key);
        params.add(value);
        return (Boolean) redisTemplate.execute(lockLuaScript, params);
    }

    /** * 执行lua脚本(锁续时) * @param key * @param value * @param expire * @return */
    public Boolean luaScriptExpandLockExpire(String key,String value,Long expire){
        lockLuaScript = new DefaultRedisScript<Boolean>();
        lockLuaScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("expand_lock_expire.lua")));
        lockLuaScript.setResultType(Boolean.class);
        //封装传递脚本参数
        ArrayList<Object> params = new ArrayList<>();
        params.add(key);
        params.add(value);
        params.add(String.valueOf(expire));
        return (Boolean) redisTemplate.execute(lockLuaScript, params);
    }
}

setnx_ex.lua

--
-- Created by IntelliJ IDEA.
-- User: hzk
-- Date: 2019/7/4
-- Time: 15:19
-- To change this template use File | Settings | File Templates.
--
local lockKey = KEYS[1]
local lockValue = KEYS[2]
local expire = KEYS[3]

local result_nx = redis.call('SETNX',lockKey,lockValue)
if result_nx == 1 then
    local result_ex = redis.call('EXPIRE',lockKey,expire)
    return result_ex
else
    return result_nx
end

release_lock.lua

--
-- Created by IntelliJ IDEA.
-- User: hzk
-- Date: 2019/7/3
-- Time: 18:31
-- To change this template use File | Settings | File Templates.
--
local lockKey = KEYS[1]
local lockValue = KEYS[2]

local result_get = redis.call('get',lockKey);
if lockValue == result_get then
    local result_del = redis.call('del',lockKey)
    return result_del
else
    return false;
end

expand_lock_expire.lua

--
-- Created by IntelliJ IDEA.
-- User: hzk
-- Date: 2019/7/4
-- Time: 15:19
-- To change this template use File | Settings | File Templates.
--
local lockKey = KEYS[1]
local lockValue = KEYS[2]
local expire = KEYS[3]

local result_get = redis.call('GET',lockKey);
if lockValue == result_get then
    local result_expire = redis.call('EXPIRE',lockKey,expire)
    return result_expire
else
    return false;
end

 这里大家可以自己动手去实现验证,当我们设置30s过期时间,业务执行时间设置远大于30s时,是否每20s会进行一次续时操作。

3.2.3 可重入锁

 我们之前在考虑服务崩溃或者服务器宕机时,想到了锁会变成永久性质,造成死锁的情况以及如何去解决。这里我们再细想一下,如果我们A服务获取到锁并且设置成功失效时间,此时服务宕机,那么其他所有服务都需要等待一个周期之后才会有新的业务可以获取锁去执行。这里我们就要考虑一个可重入性,若我们当前A服务崩溃之后立刻恢复,那么我们是否需要允许该服务可以重新获取该锁权限,实现起来很简单,只需要在加锁失败之后验证当前锁内容是否和当前服务所匹配即可。

LuaClusterLockJob2 .java

package com.springboot.schedule;

import com.springboot.task.ExpandLockExpireTask;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Component;

import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.concurrent.TimeUnit;

/** * lua脚本命令连用 保证原子性 * @author hzk * @date 2019/7/2 */
@Component
public class LuaClusterLockJob2 {

    @Autowired
    private RedisTemplate redisTemplate;

    @Value("${server.port}")
    private String port;

    public static final String LOCK_PRE = "lock_prefix_lua_";

    private DefaultRedisScript<Boolean> lockLuaScript;

    @Scheduled(cron = "0/5 * * * * *")
    public void lock(){
        String lockName = LOCK_PRE + "LuaClusterLockJob2";
        String currentValue = getHostIp() + ":" + port;
        Boolean luaResult = false;
        Long expire = 60L;
        try {
            //设置锁
            luaResult = luaScript(lockName,currentValue,expire);

            if(luaResult){
                //开启守护线程 定期检测 续锁
                executeBusiness(lockName,currentValue,expire);
            }else{
                //获取锁失败
                String value = (String) redisTemplate.opsForValue().get(lockName);
                //校验锁内容 支持可重入性
                if(currentValue.equals(value)){
                    Boolean expireResult = redisTemplate.expire(lockName, expire, TimeUnit.SECONDS);
                    if(expireResult){
                        executeBusiness(lockName,currentValue,expire);
                    }
                }
                System.out.println("Lock fail,current lock belong to:" + value);
            }
        }catch (Exception e){
            System.out.println("LuaClusterLockJob2 exception:" + e);
        }finally {
            if(luaResult){
                //若分布式锁Value与本机Value一致,则当前机器获得锁,进行解锁
// redisTemplate.delete(lockName);
                Boolean releaseLock = luaScriptReleaseLock(lockName, currentValue);
                if(releaseLock){
                    System.out.println("release lock success");
                }else{
                    System.out.println("release lock fail");
                }
            }
        }
    }

    /** * 获取本机内网IP地址方法 * @return */
    private static String getHostIp(){
        try{
            Enumeration<NetworkInterface> allNetInterfaces = NetworkInterface.getNetworkInterfaces();
            while (allNetInterfaces.hasMoreElements()){
                NetworkInterface netInterface = (NetworkInterface) allNetInterfaces.nextElement();
                Enumeration<InetAddress> addresses = netInterface.getInetAddresses();
                while (addresses.hasMoreElements()){
                    InetAddress ip = (InetAddress) addresses.nextElement();
                    if (ip != null
                            && ip instanceof Inet4Address
                            && !ip.isLoopbackAddress() //loopback地址即本机地址,IPv4的loopback范围是127.0.0.0 ~ 127.255.255.255
                            && ip.getHostAddress().indexOf(":")==-1){
                        return ip.getHostAddress();
                    }
                }
            }
        }catch(Exception e){
            e.printStackTrace();
        }
        return null;
    }

    /** * 执行lua脚本 * @param key * @param value * @return */
    public Boolean luaScript(String key,String value,Long expire){
        lockLuaScript = new DefaultRedisScript<Boolean>();
        lockLuaScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("setnx_ex.lua")));
        lockLuaScript.setResultType(Boolean.class);
        //封装传递脚本参数
        ArrayList<Object> params = new ArrayList<>();
        params.add(key);
        params.add(value);
        params.add(String.valueOf(expire));
        return (Boolean) redisTemplate.execute(lockLuaScript, params);
    }

    /** * 执行lua脚本(释放锁) * @param key * @param value * @return */
    public Boolean luaScriptReleaseLock(String key,String value){
        lockLuaScript = new DefaultRedisScript<Boolean>();
        lockLuaScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("release_lock.lua")));
        lockLuaScript.setResultType(Boolean.class);
        //封装传递脚本参数
        ArrayList<Object> params = new ArrayList<>();
        params.add(key);
        params.add(value);
        return (Boolean) redisTemplate.execute(lockLuaScript, params);
    }

    /** * 执行lua脚本(锁续时) * @param key * @param value * @param expire * @return */
    public Boolean luaScriptExpandLockExpire(String key,String value,Long expire){
        lockLuaScript = new DefaultRedisScript<Boolean>();
        lockLuaScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("expand_lock_expire.lua")));
        lockLuaScript.setResultType(Boolean.class);
        //封装传递脚本参数
        ArrayList<Object> params = new ArrayList<>();
        params.add(key);
        params.add(value);
        params.add(String.valueOf(expire));
        return (Boolean) redisTemplate.execute(lockLuaScript, params);
    }

    /** * 执行业务 * @param lockName * @param currentValue * @param expire */
    private void executeBusiness(String lockName,String currentValue,Long expire) throws InterruptedException {
        System.out.println("Lock success,execute business,current time:" + System.currentTimeMillis());
        //开启守护线程 定期检测 续锁
        ExpandLockExpireTask expandLockExpireTask = new ExpandLockExpireTask(lockName,currentValue,expire,this);
        Thread thread = new Thread(expandLockExpireTask);
        thread.setDaemon(true);
        thread.start();

        Thread.sleep(600 * 1000);
    }
}

4.总结

 通过循序渐进对分布式锁的了解以及如果动手去实现,想必大家都有了一个比较清晰的了解。这里我们还针对在整个分布式锁应用中可能存在的一些问题进行了分析以及解决。其实关于分布式锁的实现方式还有很多,这里我们只是针对Redis实现了分布式锁,并且可能还有一些我们没有考虑到的问题,只有在实际应用中才会深入去研究探索。近年来分布式系统越来越流行的情况下,分布式锁出现频率已经十分频繁,所以大家有精力还是可以去补充这方面的知识,之后我可能会介绍一下其他实现分布式锁的方式,如果有问题还希望大家提出,我也可以学习改正,共同进步。