Last active
March 9, 2022 01:00
-
-
Save sudot/2db87b83286042b8321dcc5112261f5d to your computer and use it in GitHub Desktop.
使用Redis服务实现的分布式锁
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package net.sudot.commons.lock; | |
import redis.clients.jedis.Jedis; | |
import java.time.Duration; | |
import java.util.Collections; | |
import java.util.concurrent.TimeUnit; | |
import java.util.concurrent.locks.Condition; | |
/** | |
* 使用Redis服务实现的分布式锁 | |
* | |
* @author tangjialin on 2018-03-18. | |
*/ | |
public class JedisRedisLock implements java.util.concurrent.locks.Lock, AutoCloseable { | |
/** 锁的默认过期时长:10秒 */ | |
private static final Duration DEFAULT_EXPIRATION_TIME = Duration.ofSeconds(10L); | |
/** 锁实际的过期时长 */ | |
private final Duration expirationTime; | |
/** 锁的KEY */ | |
private final String key; | |
private final Jedis jedis; | |
private long lockValue; | |
/** | |
* 构造函数 | |
* | |
* @param jedis Redis客户端连接 | |
* @param name 锁的命名 | |
* @param key 锁的key | |
*/ | |
public JedisRedisLock(Jedis jedis, String name, String key) { | |
this(jedis, name, key, DEFAULT_EXPIRATION_TIME); | |
} | |
/** | |
* 构造函数 | |
* | |
* @param jedis Redis客户端连接 | |
* @param name 锁的命名 | |
* @param expirationTime 锁的过期时间,若在此时间内未解锁,则自动解锁 | |
*/ | |
public JedisRedisLock(Jedis jedis, String name, Duration expirationTime) { | |
this(jedis, name, null, expirationTime); | |
} | |
/** | |
* 构造函数 | |
* | |
* @param jedis Redis客户端连接 | |
* @param name 锁的命名 | |
* @param key 锁的key | |
* @param expirationTime 锁的过期时间,若在此时间内未解锁,则自动解锁 | |
*/ | |
public JedisRedisLock(Jedis jedis, String name, String key, Duration expirationTime) { | |
this.jedis = jedis; | |
this.key = (key == null || key.isEmpty()) ? name : name + ":" + key; | |
this.expirationTime = expirationTime; | |
} | |
/** | |
* 释放锁 | |
* <h2>方案一:</h2> | |
* <p>方案一有缺陷,已由方案二代替</p> | |
* <pre> | |
* 方案一的问题在于: | |
* 如果调用jedis.del()方法的时候,这把锁已经不属于当前客户端的时候会解除他人加的锁。 | |
* 那么是否真的有这种场景?答案是肯定的。 | |
* 比如客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()之前,锁突然过期了。 | |
* 此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户端B的锁给解除了。 | |
* | |
* // 若获得的锁小于当前时间,则该锁已过期,无须释放锁(加1秒,延迟处理) | |
* if (lock + 1000L <= System.currentTimeMillis()) { return; } | |
* Long andSet = (Long) redisTemplate.opsForValue().getAndSet(key, lock); | |
* if (andSet != null && andSet.longValue() == lock) { | |
* redisTemplate.delete(key); | |
* } | |
* </pre> | |
* | |
* | |
* <h2>方案二:</h2> | |
* <pre> | |
* Jedis调用原生API执行Lua脚本实现方式: | |
* String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; | |
* Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId)); | |
* Long success = 1L; | |
* return success.equals(result); | |
* | |
* redisTemplate实现方式: | |
* redisTemplate.execute((RedisCallback<Boolean>) connection -> { | |
* RedisSerializer<String> serializer = redisTemplate.getStringSerializer(); | |
* String command = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; | |
* return connection.eval(serializer.serialize(command), ReturnType.BOOLEAN, 1, serializer.serialize(key), serializer.serialize(String.valueOf(lock))); | |
* }); | |
* </pre> | |
* | |
* @param key 锁 | |
* @param lock 获得的锁 | |
*/ | |
private void releasableLock(String key, long lock) { | |
if (key == null) { return; } | |
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; | |
jedis.eval(script, Collections.singletonList(key), Collections.singletonList(String.valueOf(lock))); | |
} | |
/** | |
* 获得执行锁 | |
* <h2>方案一:</h2> | |
* <p>方案一有缺陷,已由方案二代替</p> | |
* <pre> | |
* 方案一的问题在于: | |
* 1. 由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步。 | |
* 2. 当锁过期的时候,如果多个客户端同时执行jedis.getSet()方法,那么虽然最终只有一个客户端可以加锁,但是这个客户端的锁的过期时间可能被其他客户端覆盖。 | |
* 3. 锁不具备拥有者标识,即任何客户端都可以解锁。 | |
* | |
* 以下为方案一实现逻辑: | |
* A 向 lock.foo 发送 SETNX 命令。 | |
* 因为崩溃掉的 B 还锁着 lock.foo ,所以 Redis 向 A 返回 0 。 | |
* A 向 lock.foo 发送 GET 命令,查看 lock.foo 的锁是否过期。如果不,则休眠(sleep)一段时间,并在之后重试。 | |
* 另一方面,如果 lock.foo 内的 unix 时间戳比当前时间戳老,A 执行以下命令: | |
* GETSET lock.foo <current Unix timestamp + lock timeout + 1> | |
* | |
* 因为 GETSET 的作用,A 可以检查看 GETSET 的返回值,确定 lock.foo 之前储存的旧值仍是那个过期时间戳,如果是的话,那么 A 获得锁。 | |
* 如果其他客户端,比如 C,比 A 更快地执行了 GETSET 操作并获得锁,那么 A 的 GETSET 操作返回的就是一个未过期的时间戳(C 设置的时间戳)。A 只好从第一步开始重试。 | |
* 注意,即便 A 的 GETSET 操作对 key 进行了修改,这对未来也没什么影响。 | |
* | |
* 这里假设锁key对应的value没有实际业务意义,否则会有问题,而且其实其value也确实不应该用在业务中。 | |
* | |
* // 系统时间 | |
* long timeMillis = System.currentTimeMillis(); | |
* // 过期时间点(如果获得锁之后,在什么时候未释放算过期) | |
* long expirationTime = timeMillis + 1000L * expirationPeriod; | |
* ValueOperations<String, Object> opsForValue = redisTemplate.opsForValue(); | |
* // 设置过期时间并返回操作结果,若为true则表示成功获得锁 | |
* if (opsForValue.setIfAbsent(key, expirationTime)) { | |
* // 成功获得锁之后,设置锁本身的过期时间,防止锁一直存在于redis中 | |
* redisTemplate.expire(key, expirationPeriod, TimeUnit.SECONDS); | |
* return expirationTime; | |
* } | |
* // 未获得锁,获得当前锁的过期时间 | |
* Long oldExpirationTime = (Long) opsForValue.get(key); | |
* oldExpirationTime = oldExpirationTime == null ? 0 : oldExpirationTime; | |
* // 若timeMillis < oldExpirationTime,则该锁未过期,获取锁失败 | |
* if (timeMillis < oldExpirationTime) { return 0; } | |
* // 当锁已过期,尝试获得锁,并返回上一次锁的过期时间 | |
* Long andSet = (Long) opsForValue.getAndSet(key, expirationTime); | |
* if (andSet == null) { return expirationTime; } | |
* // 若oldExpirationTime == andSet,则表示获得了锁,否则表示锁被其它操作获得 | |
* return oldExpirationTime.longValue() == andSet.longValue() ? expirationTime : 0; | |
* </pre> | |
* | |
* | |
* <h2>方案二:</h2> | |
* <pre> | |
* Jedis原生API实现方式: | |
* // expireTime单位:毫秒 | |
* String result = jedis.set(lockKey, lockMark, "NX", "PX", expiration.toMillis()); | |
* // expireTime单位:秒 | |
* String ok = jedis.set(lockKey, lockMark, "NX", "EX", expiration.getSeconds()); | |
* return "OK".equals(ok); | |
* | |
* redisTemplate实现方式: | |
* return redisTemplate.execute(connection -> { | |
* RedisSerializer<String> serializer = redisTemplate.getStringSerializer(); | |
* long timeMillis = System.currentTimeMillis(); | |
* byte[] rawKey = serializer.serialize(key); | |
* byte[] rawValue = serializer.serialize(String.valueOf(timeMillis)); | |
* // 系统时间 | |
* Boolean set = connection.set(rawKey, rawValue, Expiration.seconds(expirationPeriod), RedisStringCommands.SetOption.SET_IF_ABSENT); | |
* return Boolean.TRUE.equals(set) ? timeMillis : 0L; | |
* }, true); | |
* | |
* Redis指令说明 | |
* SET key value [EX seconds] [PX milliseconds] [NX|XX] | |
* 将字符串值 value 关联到 key 。 | |
* 如果 key 已经持有其他值, SET 就覆写旧值,无视类型。 | |
* 对于某个原本带有生存时间(TTL)的键来说, 当 SET 命令成功在这个键上执行时, 这个键原有的 TTL 将被清除。 | |
* 可选参数 | |
* 从 Redis 2.6.12 版本开始, SET 命令的行为可以通过一系列参数来修改: | |
* EX second :设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value 。 | |
* PX millisecond :设置键的过期时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value 。 | |
* NX :只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value 。 | |
* XX :只在键已经存在时,才对键进行设置操作。 | |
* </pre> | |
* | |
* @param key 锁 | |
* @param expiration 锁的过期时间,若在此时间内未解锁,则自动解锁 | |
* @return 返回过期的时间.返回0表示未成功加锁 | |
*/ | |
private long lock(String key, Duration expiration) { | |
if (key == null) { return 0L; } | |
long timeMillis = System.currentTimeMillis(); | |
String ok = jedis.set(key, String.valueOf(timeMillis), "NX", "EX", expiration.getSeconds()); | |
return "OK".equals(ok) ? timeMillis : 0L; | |
} | |
@Override | |
public void lock() { | |
while (!tryLock()) { | |
try {Thread.sleep(100);} catch (InterruptedException e) {} | |
} | |
} | |
@Override | |
public void lockInterruptibly() throws InterruptedException { | |
throw new UnsupportedOperationException(); | |
} | |
@Override | |
public boolean tryLock() { | |
lockValue = lock(key, expirationTime); | |
return lockValue != 0; | |
} | |
@Override | |
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException { | |
if (time <= 0) { return tryLock(); } | |
time = System.currentTimeMillis() + unit.toMillis(time); | |
while (!tryLock() && time > System.currentTimeMillis()) { | |
Thread.sleep(100); | |
} | |
return lockValue != 0; | |
} | |
@Override | |
public void unlock() { | |
releasableLock(key, lockValue); | |
} | |
@Override | |
public Condition newCondition() { | |
throw new UnsupportedOperationException(); | |
} | |
public String getKey() { | |
return key; | |
} | |
@Override | |
public void close() throws Exception { | |
this.unlock(); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package net.sudot.commons.lock; | |
import org.springframework.data.redis.connection.RedisStringCommands; | |
import org.springframework.data.redis.connection.ReturnType; | |
import org.springframework.data.redis.core.RedisCallback; | |
import org.springframework.data.redis.core.RedisTemplate; | |
import org.springframework.data.redis.core.types.Expiration; | |
import org.springframework.data.redis.serializer.RedisSerializer; | |
import java.time.Duration; | |
import java.util.concurrent.TimeUnit; | |
import java.util.concurrent.locks.Condition; | |
/** | |
* 使用Redis服务实现的分布式锁 | |
* | |
* @author tangjialin on 2018-03-18. | |
*/ | |
public class SpringRedisTemplateRedisLock implements java.util.concurrent.locks.Lock, AutoCloseable { | |
/** 锁的默认过期时长:10秒 */ | |
private static final Duration DEFAULT_EXPIRATION_TIME = Duration.ofSeconds(10L); | |
/** 锁实际的过期时长 */ | |
private final Duration expirationTime; | |
/** 锁的KEY */ | |
private final String key; | |
private final RedisTemplate<?, ?> redisTemplate; | |
private String lockValue; | |
/** | |
* 构造函数 | |
* | |
* @param redisTemplate RedisTemplate | |
* @param name 锁的命名 | |
* @param key 锁的key | |
*/ | |
public SpringRedisTemplateRedisLock(RedisTemplate<?, ?> redisTemplate, String name, String key) { | |
this(redisTemplate, name, key, DEFAULT_EXPIRATION_TIME); | |
} | |
/** | |
* 构造函数 | |
* | |
* @param redisTemplate RedisTemplate | |
* @param name 锁的命名 | |
* @param expirationTime 锁的过期时间,若在此时间内未解锁,则自动解锁 | |
*/ | |
public SpringRedisTemplateRedisLock(RedisTemplate<?, ?> redisTemplate, String name, Duration expirationTime) { | |
this(redisTemplate, name, null, DEFAULT_EXPIRATION_TIME); | |
} | |
/** | |
* 构造函数 | |
* | |
* @param redisTemplate RedisTemplate | |
* @param name 锁的命名 | |
* @param key 锁的key | |
* @param expirationTime 锁的过期时间,若在此时间内未解锁,则自动解锁 | |
*/ | |
public SpringRedisTemplateRedisLock(RedisTemplate<?, ?> redisTemplate, String name, String key, Duration expirationTime) { | |
this.redisTemplate = redisTemplate; | |
this.key = (key == null || key.isEmpty()) ? name : name + ":" + key; | |
this.expirationTime = expirationTime; | |
} | |
/** | |
* 释放锁 | |
* <h2>方案一(有缺陷,已由方案二代替):</h2> | |
* <pre> | |
* 方案一的问题在于: | |
* 如果调用jedis.del()方法的时候,这把锁已经不属于当前客户端的时候会解除他人加的锁。 | |
* 那么是否真的有这种场景?答案是肯定的。 | |
* 比如客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()之前,锁突然过期了。 | |
* 此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户端B的锁给解除了。 | |
* | |
* // 若获得的锁小于当前时间,则该锁已过期,无须释放锁(加1秒,延迟处理) | |
* if (lock + 1000L <= System.currentTimeMillis()) { return; } | |
* Long andSet = (Long) redisTemplate.opsForValue().getAndSet(key, lock); | |
* if (andSet != null && andSet.longValue() == lock) { | |
* redisTemplate.delete(key); | |
* } | |
* </pre> | |
* | |
* | |
* <h2>方案二:</h2> | |
* <pre> | |
* Jedis调用原生API执行Lua脚本实现方式: | |
* String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; | |
* Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId)); | |
* Long success = 1L; | |
* return success.equals(result); | |
* | |
* redisTemplate实现方式: | |
* redisTemplate.execute((RedisCallback<Boolean>) connection -> { | |
* RedisSerializer<String> serializer = redisTemplate.getStringSerializer(); | |
* String command = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; | |
* return connection.eval(serializer.serialize(command), ReturnType.BOOLEAN, 1, serializer.serialize(key), serializer.serialize(lockValue)); | |
* }); | |
* </pre> | |
* | |
* @param key 锁 | |
* @param lockValue 获得的锁 | |
*/ | |
private void releasableLock(String key, String lockValue) { | |
redisTemplate.execute((RedisCallback<Boolean>) connection -> { | |
RedisSerializer<String> serializer = redisTemplate.getStringSerializer(); | |
String command = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; | |
return connection.eval(serializer.serialize(command), ReturnType.BOOLEAN, 1, serializer.serialize(key), serializer.serialize(lockValue)); | |
}); | |
} | |
/** | |
* 获得执行锁 | |
* <h2>方案一(有缺陷,已由方案二代替):</h2> | |
* <pre> | |
* 方案一的问题在于: | |
* 1. 由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步。 | |
* 2. 当锁过期的时候,如果多个客户端同时执行jedis.getSet()方法,那么虽然最终只有一个客户端可以加锁,但是这个客户端的锁的过期时间可能被其他客户端覆盖。 | |
* 3. 锁不具备拥有者标识,即任何客户端都可以解锁。 | |
* | |
* 以下为方案一实现逻辑: | |
* A 向 lock.foo 发送 SETNX 命令。 | |
* 因为崩溃掉的 B 还锁着 lock.foo ,所以 Redis 向 A 返回 0 。 | |
* A 向 lock.foo 发送 GET 命令,查看 lock.foo 的锁是否过期。如果不,则休眠(sleep)一段时间,并在之后重试。 | |
* 另一方面,如果 lock.foo 内的 unix 时间戳比当前时间戳老,A 执行以下命令: | |
* GETSET lock.foo <current Unix timestamp + lock timeout + 1> | |
* | |
* 因为 GETSET 的作用,A 可以检查看 GETSET 的返回值,确定 lock.foo 之前储存的旧值仍是那个过期时间戳,如果是的话,那么 A 获得锁。 | |
* 如果其他客户端,比如 C,比 A 更快地执行了 GETSET 操作并获得锁,那么 A 的 GETSET 操作返回的就是一个未过期的时间戳(C 设置的时间戳)。A 只好从第一步开始重试。 | |
* 注意,即便 A 的 GETSET 操作对 key 进行了修改,这对未来也没什么影响。 | |
* | |
* 这里假设锁key对应的value没有实际业务意义,否则会有问题,而且其实其value也确实不应该用在业务中。 | |
* | |
* // 系统时间 | |
* long timeMillis = System.currentTimeMillis(); | |
* // 过期时间点(如果获得锁之后,在什么时候未释放算过期) | |
* long expirationTime = timeMillis + 1000L * expirationPeriod; | |
* ValueOperations<String, Object> opsForValue = redisTemplate.opsForValue(); | |
* // 设置过期时间并返回操作结果,若为true则表示成功获得锁 | |
* if (opsForValue.setIfAbsent(key, expirationTime)) { | |
* // 成功获得锁之后,设置锁本身的过期时间,防止锁一直存在于redis中 | |
* redisTemplate.expire(key, expirationPeriod, TimeUnit.SECONDS); | |
* return expirationTime; | |
* } | |
* // 未获得锁,获得当前锁的过期时间 | |
* Long oldExpirationTime = (Long) opsForValue.get(key); | |
* oldExpirationTime = oldExpirationTime == null ? 0 : oldExpirationTime; | |
* // 若timeMillis < oldExpirationTime,则该锁未过期,获取锁失败 | |
* if (timeMillis < oldExpirationTime) { return 0; } | |
* // 当锁已过期,尝试获得锁,并返回上一次锁的过期时间 | |
* Long andSet = (Long) opsForValue.getAndSet(key, expirationTime); | |
* if (andSet == null) { return expirationTime; } | |
* // 若oldExpirationTime == andSet,则表示获得了锁,否则表示锁被其它操作获得 | |
* return oldExpirationTime.longValue() == andSet.longValue() ? expirationTime : 0; | |
* </pre> | |
* | |
* | |
* <h2>方案二:</h2> | |
* <pre> | |
* Jedis原生API实现方式: | |
* // expireTime单位:毫秒 | |
* String result = jedis.set(lockKey, lockMark, "NX", "PX", expiration.toMillis()); | |
* // expireTime单位:秒 | |
* String result = jedis.set(lockKey, lockMark, "NX", "EX", expiration.getSeconds()); | |
* return "OK".equals(result); | |
* | |
* redisTemplate实现方式: | |
* return redisTemplate.execute(connection -> { | |
* RedisSerializer<String> serializer = redisTemplate.getStringSerializer(); | |
* String value = String.valueOf(System.currentTimeMillis()); | |
* byte[] rawKey = serializer.serialize(key); | |
* byte[] rawValue = serializer.serialize(value); | |
* // 系统时间 | |
* Boolean set = connection.set(rawKey, rawValue, Expiration.from(expiration), RedisStringCommands.SetOption.SET_IF_ABSENT); | |
* return Boolean.TRUE.equals(set) ? value : null; | |
* }, true); | |
* | |
* Redis指令说明 | |
* SET key value [EX seconds] [PX milliseconds] [NX|XX] | |
* 将字符串值 value 关联到 key 。 | |
* 如果 key 已经持有其他值, SET 就覆写旧值,无视类型。 | |
* 对于某个原本带有生存时间(TTL)的键来说, 当 SET 命令成功在这个键上执行时, 这个键原有的 TTL 将被清除。 | |
* 可选参数 | |
* 从 Redis 2.6.12 版本开始, SET 命令的行为可以通过一系列参数来修改: | |
* EX second :设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value 。 | |
* PX millisecond :设置键的过期时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value 。 | |
* NX :只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value 。 | |
* XX :只在键已经存在时,才对键进行设置操作。 | |
* </pre> | |
* | |
* @param key 锁 | |
* @param expiration 锁的过期时间,若在此时间内未解锁,则自动解锁 | |
* @return 返回过期的时间.返回0表示未成功加锁 | |
*/ | |
private String lock(String key, Duration expiration) { | |
return redisTemplate.execute(connection -> { | |
RedisSerializer<String> serializer = redisTemplate.getStringSerializer(); | |
String value = String.valueOf(System.currentTimeMillis()); | |
byte[] rawKey = serializer.serialize(key); | |
byte[] rawValue = serializer.serialize(value); | |
// 系统时间 | |
Boolean set = connection.set(rawKey, rawValue, Expiration.from(expiration), RedisStringCommands.SetOption.SET_IF_ABSENT); | |
return Boolean.TRUE.equals(set) ? value : null; | |
}, true); | |
} | |
@Override | |
public void lock() { | |
while (!tryLock()) { | |
try {Thread.sleep(100);} catch (InterruptedException e) {} | |
} | |
} | |
@Override | |
public void lockInterruptibly() throws InterruptedException { | |
throw new UnsupportedOperationException(); | |
} | |
@Override | |
public boolean tryLock() { | |
lockValue = lock(key, expirationTime); | |
return lockValue != null; | |
} | |
@Override | |
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException { | |
if (time <= 0) { return tryLock(); } | |
time = System.currentTimeMillis() + unit.toMillis(time); | |
while (!tryLock() && time > System.currentTimeMillis()) { | |
Thread.sleep(100); | |
} | |
return lockValue != null; | |
} | |
@Override | |
public void unlock() { | |
releasableLock(key, lockValue); | |
} | |
@Override | |
public Condition newCondition() { | |
throw new UnsupportedOperationException(); | |
} | |
public String getKey() { | |
return key; | |
} | |
@Override | |
public void close() throws Exception { | |
this.unlock(); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment