redis分布式锁,setnx+lua脚本的java实现 | 京东物流技术团队

1 前言

在现在工作中,为保障服务的高可用,应对单点故障、负载量过大等单机部署带来的问题,生产环境常用多机部署。为解决多机房部署导致的数据不一致问题,我们常会选择用分布式锁。

目前其他比较常见的实现方案我列举在下面:

1.基于缓存实现分布式锁(本文主要使用redis实现)

2.基于数据库实现分布式锁

3.基于zookeeper实现分布式锁

本文是基于redis缓存实现分布式锁,其中使用了setnx命令加锁,expire命令设置过期时间并lua脚本保证事务一致性。Java实现部分基于JIMDB提供的接口。JIMDB是京东自主研发的基于Redis的分布式缓存与高速键值存储服务。

2 SETNX

基本语法:SETNX KEY VALUE

SETNX 是表示 SET ifNot eXists, 即命令在指定的 key 不存在时,为 key 设置指定的值。

KEY 是表示待设置的key名

VALUE是设置key的对应值

若设置成功,则返回1;若设置失败(key存在),则返回0。

由此,我们会选择用SETNX来进行分布式锁的实现,当Key存在时,会返回加锁失败的信息。

SET 与 SETNX 区别:

SET 如果key已经存在,则会覆盖原值,且无视类型

SETNX 如果key已经存在,则会返回0,表示设置key失败

Redis 2.6.12版本前后对比:

2.6.12版本前:分布式锁并不能只用SETNX实现,需要搭配EXPIRE命令设置过期时间,否则,key将永远有效。其中,为保证SETNX和EXPIRE在同一个事务里,我们需要借助LUA脚本来完成事务实现。(由于在写这篇文章时,JIMDB还未支持SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL] 语法,故本文依然用lua事务)

2.6.12版本后:SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL] 语法糖可用于分布式锁并支持原子操作,无需EXPIRE命令设置过期时间。

3 LUA脚本

什么是LUA脚本?

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

为什么需要用到LUA脚本?

本文的锁实现是基于两个Redis命令 - SETNXEXPIRE。 为保证命令的原子性,我们将这两个命令写入LUA脚本,并上传至Redis服务器。Redis服务器会单线程执行LUA脚本,以确保两个命令在执行期间不被其他请求打断。

LUA脚本的优势

•减少网络开销。若干命令的多次请求,可组合成一个脚本进行一次请求

•高复用性。脚本编辑一次后,相同代码逻辑可多处使用,只需将不同的参数传入即可。

•原子性。若期望多个命令执行期间不被其他请求打断,或出现竞争状态,可以用LUA脚本实现,同时保证了事务的一致性。

分布式锁LUA脚本的实现

假设在同一时刻只能创建一个订单,我们可以将orderId作为key值,uuid作为value值。过期时间设置为3秒。

LUA脚本如下,通过Redis的eval/evalsha命令实现:

lua 复制代码
-- lua加锁脚本
-- KEYS[1],ARGV[1],ARGV[2]分别对应了orderId,uuid,3
-- 如果setnx成功,则继续expire命令逻辑
if redis.call('setnx',KEYS[1],ARGV[1]) == 1 
    then 
      -- 则给同一个key设置过期时间
       redis.call('expire',KEYS[1],ARGV[2]) 
       return 1 
    else 
      -- 如果setnx失败,则返回0
       return 0 
end
lua 复制代码
-- lua解锁脚本
-- KEYS[1],ARGV[1]分别对应了orderId,uuid
-- 若无法获取orderId缓存,则认为已经解锁
if redis.call('get',KEYS[1]) == false 
    then 
        return 1 
    -- 若获取到orderId,并value值对应了uuid,则执行删除命令
    elseif redis.call('get',KEYS[1]) == ARGV[1] 
    then 
        -- 删除缓存中的key
    	return redis.call('del',KEYS[1]) 
    else 
        -- 若获取到orderId,且value值与存入时不一致,则返回特殊值,方便进行后续逻辑
        return 2 
end

【注】 根据Redis的版本,在LUA脚本中,当使用redis.call('get',key)判定缓存key不存在时,需要注意对比值为布尔类型的false,还是null。

根据官方文档:Lua Boolean ->RESP3 Boolean reply(note that this is a change compared to the RESP2, in which returning a Boolean Luatruereturned the number 1 to the Redis client, and returning afalseused to return anull.

在RESP3中,redis cli返回的是空值时,lua会用布尔类型false来代替。

RESP3简介

RESP3是Redis6的新特性,是RESP v2的新版本。该协议用于客户端和服务器之间的请求响应通信。由于该协议可以不对称的使用,即客户端发送一个简单的请求,服务器可以将更复杂的并扩充后的相关信息返回到客户端。升级后的协议,引入了13种数据类型,使之更适用于数据库的交互场景。

4 基于JIMDB的Java分布式锁实现

调用类实现代码

csharp 复制代码
SoRedisLock soJimLock = null;
try{
    soJimLock = new SoRedisLock("orderId", jimClient);
    if (!soJimLock.lock(3)) {
        log.error("订单创建加锁失败");
        throw new BPLException("订单创建加锁失败");
    }
} catch(Exception e) {
    throw e;
} finally {
    if (null != soJimLock) {
        soJimLock.unlock();
    }
}

分布式锁实现类代码

kotlin 复制代码
public class SoRedisLock{

    /** 加锁标志 */
    public static final String LOCKED = "TRUE";
    /** 锁的关键词 */
    private String key;
    private Cluster jimClient;
    
    /**
     * lock的构造函数
     * 
     * @param key
     *            key+"_lock" (key使用唯一的业务单号)
     * @param
     *
     */
    public SoRedisLock(String key, Cluster jimClient)
    {
        this.key = key + "_LOCK";
        this.jimClient = jimClient;
    }
    
    /**
     * 加锁
     *
     * @param expire
     *            锁的持续时间(秒),过期删除
     * @return 成功或失败标志
     */
    public boolean lock(int expire)
    {
        try
        {
            log.info("分布式事务加锁,key:{}", this.key);   
            String lua_scripts = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then " +
            		"redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";
            String sha = jimClient.scriptLoad(lua_scripts);
            List<String> keys = new ArrayList<>();
            List<String> values = new ArrayList<>();
            keys.add(this.key);
            values.add(LOCKED);
            values.add(String.valueOf(expire));
            this.locked = jimClient.evalsha(sha, keys, values, false).equals(1L);
            return this.locked;
        } catch (Exception e){
        	throw new RuntimeException("Locking error", e);
        }
    }

    /**
     * 解锁 无论是否加锁成功,都需要调用unlock 建议放在finally 方法块中
     */
    public void unlock()
    {
        if (this.jimClient == null || !this.locked) {
            return ;
        }
        try {
        String luaScript = "if redis.call('get',KEYS[1]) == false then return 1 " +
        		"elseif redis.call('get',KEYS[1]) == ARGV[1] then " +
        		"return redis.call('del',KEYS[1]) else return 2 end";
        String sha = jimClient.scriptLoad(luaScript);
        if(!jimClient.evalsha(sha, Collections.singletonList(this.key), Collections.singletonList(LOCKED), false).equals(1L)){
        	throw new RuntimeException("解锁失败,key:"+this.key);
        }
        } catch (Exception e) {
                log.error("unLocking error, key:{}", this.key, e);
        	throw new RuntimeException("unLocking error, key:"+this.key);
        }
    }
}

由于我们只是使用key-value做一个加锁动作,value并无意义。故,本文key对应的value给定固定值。Jimdb提供了上传脚本的API,我们通过scriptLoad()方法将lua脚本上传至redis服务器中。并利用evalsha()方法来进行脚本的执行。evalsha()返回值即为脚本中的设置的return的返回值。

我们通过list将参数传入脚本中,并对应脚本中的标记位。例如上方的代码中:

"orderId_LOCK"对应了脚本中的KEYS[1]

"TRUE"对应了脚本中的ARGV[1]

"3"对应了脚本中的ARGV[2]

【注】 若在一个脚本中存在多个key,需要确保redis中的hashtag被启用,以防分片导致的key不处于同一分片,进而出现"Only support single key or use same hashTag"异常。当然,hashtag启用需要谨慎,否则分片不均导致流量的集中,造成服务器压力过大。

实际使用中的日志截图

5 总结

通过上述介绍我们了解到如何保证Redis多个命令的原子性。当然,Redis事务一致性,也可以选择Redis的事务(Transaction)操作来实现。Jimdb也有API支持事务的multi,discard,exec,watch和unwatch命令。本文之所以选择使用LUA脚本来进行实现,主要是考虑到目前Jimdb在执行事务时,流量只会打到主实例,多实例的负载均衡会失效。更多的可行方案等待大家的探索,我们下个文档见。

6 参考资料

Redis分布式锁: www.cnblogs.com/niceyoo/p/1...

Redis中使用Lua脚本:zhuanlan.zhihu.com/p/77484377

Redis Eval命令: www.redis.net.cn/order/3643....

LUA API: redis.io/docs/intera...

作者:京东物流 牟佳义

来源:京东云开发者社区 自猿其说Tech 转载请注明来源

相关推荐
学习编程的Kitty3 分钟前
算法——位运算
java·前端·算法
用户9047066835722 分钟前
如何使用 Spring MVC 实现 RESTful API 接口
java·后端
刘某某.23 分钟前
数组和小于等于k的最长子数组长度b
java·数据结构·算法
程序员飞哥27 分钟前
真正使用的超时关单策略是什么?
java·后端·面试
用户9047066835729 分钟前
SpringBoot 多环境配置与启动 banner 修改
java·后端
小old弟1 小时前
后端三层架构
java·后端
花花鱼1 小时前
spring boot 2.x 与 spring boot 3.x 及对应Tomcat、Jetty、Undertow版本的选择(理论)
java·后端
温柔一只鬼.1 小时前
Docker快速入门——第二章Docker基本概念
java·docker·容器
要争气1 小时前
5 二分查找算法应用
java·数据结构·算法
郑..方..醒1 小时前
java实现ofd转pdf
java·pdf