SpringBoot使用redis搭配lua脚本实现分布式并发锁

在微服务开发中,经常会因为并发带来一些麻烦的问题。最经典案例的就是秒杀减库存的问题,想要解决这个问题,那么就需要使用分布式并发锁来将资源锁住,等业务逻辑执行完了之后在把锁释放掉。

实现分布式并发锁有多种实现方式:

1、使用数据库做锁

2、使用Redis做锁

3、使用ZooKeeper做锁

可以用来做分布式锁需要满足什么条件可以自行百度,我这里介绍下在SpringBoot中如何使用redis搭配lua脚本实现分布式锁。

为什么redis可以做分布式锁:

1、redis是单线程的,所以不存在并发问题

2、可以使用redis的SETNX命令(set if not exists,即设置key的value值,若key已经存在,不做任何操作)来抢占key这把锁的资源,如:

SETNX a "a"

如果设值成功,则认为抢到了a这把锁,如果设值失败(a的值已经存在),则认为加锁失败。

3、为了避免死锁,可以针对key设置过期时间。

为什么需要搭配lua脚本:

1、上述的2和3的操作是2条命令,如果2的操作执行完毕了,因为某些不可控的原因3的操作未执行线程就死掉了,这就导致了死锁的发生。

2、为了避免A线程业务处理太久导致对资源a加的锁已经自动过期了,这个时候B线程需要操作资源a,同样对a加锁,刚好A线程业务处理完毕,误解除了B对资源a加的锁,导致B的锁失效。所以我们在加锁的时候可以对加的锁的key设置一个value值当做锁的密码,这样只有加锁的线程可以使用自己知道的密码去解除这把锁,而不会发生误解锁的事情。所以我们需要在解锁的时候先判断锁是不是我们加的,如果是才能进行解锁操作。这样这里又是2个操作

3、因为上述2个原因是非原子性的操作,所以我们需要引入lua脚本,因为redis在执行lua脚本的时候是原子操作。

下面是分布式加锁和解锁的代码,可以直接复制使用:

/**
 * redis分布式锁工具
 * 使用lua脚本实现加锁和解锁操作,保证原子性
 * @author liqingcan
 */
@Component
public class RedisLockService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    /**
     * 加锁的lua脚本
     */
    private final static RedisScript<Long> LOCK_LUA_SCRIPT = new DefaultRedisScript<>(
            "if redis.call(\"setnx\", KEYS[1], KEYS[2]) == 1 then return redis.call(\"expire\", KEYS[1], KEYS[3]) else return 0 end"
            , Long.class
    );

    /**
     * 加锁失败结果
     */
    private final static Long LOCK_FAIL = 0L;

    /**
     * 解锁的lua脚本
     */
    private final static RedisScript<Long> UNLOCK_LUA_SCRIPT = new DefaultRedisScript<>(
            "if redis.call(\"get\",KEYS[1]) == KEYS[2] then return redis.call(\"del\",KEYS[1]) else return -1 end"
            , Long.class
    );

    /**
     * 解锁失败结果
     */
    private final static Long UNLOCK_FAIL = -1L;

    /**
     * 加锁方法
     * 对key加锁,value为key对应的值,expire是锁自动过期时间防止死锁
     * @param key key
     * @param value value
     * @param expire 锁自动过期时间(秒)
     * @return
     */
    public boolean lock(String key, String value, Long expire){
        if (key == null || value == null || expire == null) {
            return false;
        }
        List<String> keys = Arrays.asList(key, value, expire.toString());
        Long res = redisTemplate.execute(LOCK_LUA_SCRIPT, keys);
        return !LOCK_FAIL.equals(res);
    }

    /**
     * 解锁方法
     * 对key解锁,只有value值等于redis中key对应的值才能解锁,避免误解锁
     * @param key
     * @param value
     * @return
     */
    public boolean unlock(String key, String value){
        if (key == null || value == null) {
            return false;
        }
        List<String> keys = Arrays.asList(key, value);
        Long res = redisTemplate.execute(UNLOCK_LUA_SCRIPT, keys);
        return !UNLOCK_FAIL.equals(res);
    }

}

这里有几个点解释下:

1、RedisTemplate需要指定使用RedisTemplate<String, String>,否则会导致执行lua脚本的时候发生异常。

2、加锁失败和解锁失败的标志为啥一个是0一个是-1?

加锁时,设置过期时间的命令expire失败返回的是0,成功返回1,所以判断加锁失败的标记为0

解锁时,删除key的命令del返回的是被删除的key数量,所以如果在解锁的过程中,key已经过期失效,也当做解锁成功,这种情况返回的是0,为了跟这种情况区分开,所以判断解锁失败的标记为-1

完整的demo演示可以参考:https://gitee.com/floow/blog-demo demo14中的测试类RedisLockTest