写点什么

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

  • 2023-08-29
    北京
  • 本文字数:3823 字

    阅读完需:约 13 分钟

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加锁脚本-- 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 转载请注明来源

发布于: 刚刚阅读数: 5
用户头像

拥抱技术,与开发者携手创造未来! 2018-11-20 加入

我们将持续为人工智能、大数据、云计算、物联网等相关领域的开发者,提供技术干货、行业技术内容、技术落地实践等文章内容。京东云开发者社区官方网站【https://developer.jdcloud.com/】,欢迎大家来玩

评论

发布
暂无评论
redis分布式锁,setnx+lua脚本的java实现 | 京东物流技术团队_redis_京东科技开发者_InfoQ写作社区