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

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

时间:2023-08-29 12:06:49浏览次数:46  
标签:脚本 java KEYS redis lua key return 分布式

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加锁脚本
-- 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解锁脚本
-- 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 Lua true returned the number 1 to the Redis client, and returning a false used to return a null .

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

RESP3简介

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

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

调用类实现代码

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();
    }
}

分布式锁实现类代码

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分布式锁: https://www.cnblogs.com/niceyoo/p/13711149.html

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

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

LUA API: https://redis.io/docs/interact/programmability/lua-api/

作者:京东物流 牟佳义

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

标签:脚本,java,KEYS,redis,lua,key,return,分布式
From: https://blog.51cto.com/u_15714439/7274199

相关文章

  • Java 15 JSTL实现登录退出
     jstl.jsp<%@pagecontentType="text/html;charset=UTF-8"language="java"%><%@taglibprefix="c"uri="http://java.sun.com/jsp/jstl/core"%><%--if--%><%@taglibprefix="fmt"uri=&......
  • Java 14 EL表达式
     eljstl.jsp<%@pagecontentType="text/html;charset=UTF-8"language="java"%><html><head><title>Title</title></head><body><h1>首页</h1><%--指令包含<%@includefile=......
  • JAVA SE基础《十》 ---- 面对对象高级一
    目录一、static  1、static修饰成员变量2、static修饰成员变量的应用场景:在线人数统计3、static修饰成员方法4、static修饰成员方法的应用场景:工具类5、static的注意事项6、static的应用知识:代码块7、static的应用知识:单例设计模式二、面向......
  • Redis常用命令
    目录起因配置bind端口连接开启连接关闭连接命令key相关字符串相关hash相关List相关Set相关SortedSet相关GEO相关服务器命令引用起因从标题来看,似乎这篇博文没有看下去的必要了,谁用redis还敲命令行,不都是GUI嘛。确实,GUI现在特别的好用,直观、高效还美观。但是,在生产环境下GUI可......
  • Redis基础
    1.Redis5种数据类型string(字符串)hash(哈希散列)list(列表)set(集合)zset(sortedset:有序集合)2.对应的基本操作2.1Stringvalue:最大为512Msetnamezhangsangetname2.2hash最多属性字段为2的32次方-1个hmsetuserid:1namezhangsanage1hmgetalluserid:1hmgetu......
  • Java代码审计之目录穿越
    一、目录穿越漏洞1、什么是目录穿越所谓的目录穿越指利用操作系统中的文件系统对目录的表示。在文件系统路径中,".."表示上一级目录,当你使用"../"时,你正在引用当前目录的上一级目录。如果你使用"../../",你实际上在两次".."的基础上,再次引用上一级目录,从而返回到上两级目录。......
  • Java并发编程的挑战与解决方案
    Java并发编程是一个复杂而重要的主题,开发者在处理并发场景时经常面临各种挑战。本文将讨论Java并发编程中的一些重要概念和问题,并提供实际的代码示例和解决方案。一、Java并发编程的基本概念线程与进程的区别并发和并行的概念共享资源与竞态条件线程安全性与数据同步二、Java......
  • 深入探究Java中的多线程并发与同步
    在后端开发中,多线程编程是一项关键技术,能够充分利用多核处理器,提高系统性能和响应能力。然而,多线程编程涉及到并发与同步问题,可能引发复杂的线程安全难题。本篇博客将深入探讨Java中的多线程编程,重点关注并发问题和同步机制。并发与多线程并发是指多个任务在同一时间段内执行,而多线......
  • 基于Redis的队列
    1.队列//发布@ApiOperation(value="put普通队列")@PostMapping("/queuePut")publicObjectput(@RequestBodyCommonMapRespDTOrespDTO){for(inti=0;i<20;i++){//队列RQueue<Object>queue=redissonClient.g......
  • Redis 入门篇
    1.初始Redis1.1认识NoSQL‍NoSql可以翻译做NotOnlySql(不仅仅是SQL),或者是NoSql(非Sql的)数据库。是相对于传统关系型数据库而言,有很大差异的一种特殊的数据库​,因此也称之为非关系型数据库。‍1.1.1结构化与非结构化‍​​1.1.2关系型与非关系型的差异存储方式......