简单概括
什么是分布式锁
- 分布式锁可用需要满足 3 个条件
- 互斥性: 任意时刻,只有一个客户端能持有锁
- 不会发生死锁: 即使有一个客户端在持有锁的期间因为崩溃等原因没有主动释放锁,也能保证后续其他客户端能够加锁
- 加锁和释放锁都必须是同一个客户端,即当前客户端不能释放其他客户端的锁
如何实现
- 总体上使用
setnxsetex - 避免锁无法释放,需要保持原子性
- 使用 lua 脚本实现原子性
- 使用命令连用的方式实现原子性
解锁需要注意什么
- 加锁和解锁必须是同一个客户端,即当前客户端不能释放其他客户端的锁
redis 的分布式锁实现
简单版本
- 先
setnx(key, value),如果返回1继续,0返回 - 进行
setex(key, time, value)对setnx的键值对设置过期时间 setnx的线程需要释放该锁
代码简单示范
private void tryOnce() {Boolean result = false;String myValue = getRandomValue();try {// setnxresult = redisTemplate.opsForValue().setIfAbsent(KEY, myValue);// setexredisTemplate.opsForValue().set(KEY, myValue, 10, TimeUnit.SECONDS);if (!result) {// 要立即返回return;} else {System.out.println("ok: " + System.currentTimeMillis());}TimeUnit.SECONDS.sleep(1);} catch (Exception e) {log.error(e.toString(), e);} finally {// 需要判断对应 key 的值是不是当前线程设置的if (result && myValue.equals(redisTemplate.opsForValue().get(KEY))) {redisTemplate.delete(KEY + serviceName);System.out.println("释放锁");}}}/*** 获取随机值,生成上可以设置为当前服务的ip** @return {@link java.lang.String} 随机字符串*/private String getRandomValue() {return UUID.randomUUID().toString().replace("-", "");}
原子性优化点一
- 在
setnx后,出现下列问题,会导致setnx的值过期时间为-1- 当前线程所在服务宕机
redis不可用
- 此后如果不及时处理,会导致其他服务永远拿不到锁
所以就要让
setnx和setex操作作为 原子操作Lua数据类型和redis返回值类型转换规则 | Lua数据类型 | 整数回复(Lua的数字类型会被自动转换成整数) | | —- | —- | | 字符串类型 | 字符串回复 | | table类型(数组形式) | 多行字符串回复 | | table类型(只有一个ok字段存储状态信息) | 状态回复 | | table类型(只有一个err字段存储错误信息) | 错误回复 |
- 在
resources目录下新建一个one.lua脚本
-- KEYS... , ARGS... 注意 KEYS 和 ARGS 之间的逗号两边都有空格, 而他们各自的值是用空格分开的-- 下标从 1 开始local key = KEYS[1]local value = KEYS[2]local expired_time = ARGV[1]-- 判断结果是否包含指定的键local checkResultContainKey = function(tableName, keyName)for key, value in pairs(tableName) do-- 方式一if string.find(key, keyName) thenreturn trueend-- 方式二if key == keyName thenreturn trueendreturn falseendend-- 开始 setnx setex 操作local result_setnx = redis.call("SETNX", key, value)if result_setnx == 1 then -- 注意 setnx 返回 1-- setnx 成功会进行 setexlocal result_setex = redis.call("SETEX", key, expired_time, value)if result_setnx and checkResultContainKey(result_setex, 'ok') thenreturn trueelsereturn falseendelsereturn result_setnxend
代码中读取lua 脚本
/*** <h2>实现 setnx setex 原子化</h2>** @param key 键* @param value 值* @param time 过期时间,目前限制为 seconds* @return {@link java.lang.Boolean}*/private Boolean luaExpress(String key, String value, Integer time) {// 泛型表示 lua 返回值DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>();redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("one.lua")));// 也可以在这里设置返回值内容redisScript.setResultType(Boolean.class);// 封装参数List<String> keyList = new ArrayList<>();keyList.add(key);keyList.add(value);return (Boolean) redisTemplate.execute(redisScript, keyList, time);}
修改原来的逻辑
// 将之前代码中的 try {} 中的内容进行替换try {// setnx + setex 原子化result = luaExpress(KEY, serviceName, 30);if (!result) {// 要立即返回return;} else {System.out.println("ok: " + System.currentTimeMillis());}TimeUnit.SECONDS.sleep(1);}
命令连用
- redis 2.6 (?不确定) 之后可以实现方法连用
- 代码演示
/*** 使用 redisConnection 中重写方法实现方法连用** @param key 键* @param value 值* @param time 时间,目前单位规定为 seconds* @return {@link java.lang.Boolean}*/private Boolean overwrite(String key, String value, Integer time) {try {Boolean result = redisTemplate.execute(new RedisCallback<Boolean>() {@Overridepublic Boolean doInRedis(RedisConnection connection) throws DataAccessException {return connection.set(key.getBytes(StandardCharsets.UTF_8),value.getBytes(StandardCharsets.UTF_8),Expiration.seconds(time),RedisStringCommands.SetOption.ifAbsent());}});return result;} catch (Exception e) {log.error("出现异常: {}", e.getLocalizedMessage(), e);}return false;}
- 逻辑修改
// 将之前代码中的 try {} 中的内容进行替换try {// setnx + setex 原子化result = overwrite(KEY, myValue, 30);if (!result) {// 要立即返回return;} else {System.out.println("ok: " + System.currentTimeMillis());}TimeUnit.SECONDS.sleep(1);}
SET key value [EX seconds] [PX milliseconds] [NX|XX]
- 原子的么?
原子性优化点二
- 在释放锁的时候,由于
setex设置的过期时间过短,在抢到锁的当前服务执行完毕后将要删除该锁时,该锁已经过期,此时如果别人抢占了锁,那么当前服务可能会删除到了别的服务的锁 - 即在代码中
finally阶段,需要进行get、判断是否相同、del操作- 如果在
get、判断是否相同,在进行del时,这时候锁过期,被其他线程抢到锁设置了一个相同key的锁,此时del就相当于释放了别人的锁 - 所以在进行上面
finally三步骤时,要保持 原子性 - 可以说有进行
get+ operation 的情景,即后续操作是依托于get的结果的情景,get和 后续操作要保持原子性
- 如果在
用lua
- 脚本 ```lua local key = KEYS[1] —local myValue = ARGV[1] — ARGV 传入的字符串居然会带双引号!!!!! local myValue = KEYS[2]
— 判断 key 是否为自己设置的 myValue local is_key_exist = redis.call(“GET”, key)
if is_key_exist == myValue then local deleted = redis.call(“DEL”, key) — 返回删除状态 return deleted else — 标明该 key 不是自己删除的 return false end
- 读取lua脚本```java/*** 判断目标值是否为自己设置的值** @param key 键* @param myValue 当前设置的值* @return {@link java.lang.Boolean}*/private Boolean removeIfSame(String key, String myValue) {DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>();redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("removeIfSame.lua")));redisScript.setResultType(Boolean.class);List<String> keyList = new ArrayList<>(2);keyList.add(key);keyList.add(myValue);// 这里将 key 和 myValue 当作 KEYS 传入return redisTemplate.execute(redisScript, keyList);}
- 修改代码逻辑
finally {// 判断目标值是否存在,且是否为自己设置的值// get + del 原子化if (result && removeIfSame(KEY + serviceName, serviceName)) {System.out.println("释放了自己设置的锁");}}
