Skip to content

Instantly share code, notes, and snippets.

@sudot
Last active March 9, 2022 01:00
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save sudot/2db87b83286042b8321dcc5112261f5d to your computer and use it in GitHub Desktop.
Save sudot/2db87b83286042b8321dcc5112261f5d to your computer and use it in GitHub Desktop.
使用Redis服务实现的分布式锁
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();
}
}
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