1 前言
在当初工作中,为保障服务的高可用,应答单点故障、负载量过大等单机部署带来的问题,生产环境罕用多机部署。为解决多机房部署导致的数据不统一问题,咱们常会抉择用分布式锁。
目前其余比拟常见的实现计划我列举在上面:
- 基于缓存实现分布式锁(本文次要应用redis实现)
- 基于数据库实现分布式锁
- 基于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命令 - SETNX
和 EXPIRE
。 为保障命令的原子性,咱们将这两个命令写入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 转载请注明起源