假设一个场景:
在分布式系统中,通常会遇到多个服务器处理同一个业务的场景,我们需要利用某种机制避免并发问题。Java语言中,我们可以通过锁的方式避免单个服务的多线程并发问题,而分布式系统中的并发问题用Java的锁机制是很难解决的。
分布式锁也有类似地“首先获取锁, 然后执行操作,最后释放锁”的动作,为了解决分布式系统的并发问题,我们可以使用redis实现一个跨机器的分布式锁。
下面先看redis的SETNX命令:
命令:
SETNX key value
解释:
将 key 的值设为 value ,当且仅当 key 不存在。
若给定的 key 已经存在,则 SETNX 不做任何动作。
返回值:
设置成功,返回 1 。
设置失败,返回 0 。
redis提供的SETNX命令天生具有基本的加锁功能。把要锁定资源的某个标识作为key,并设置全局唯一的值作为redis的key的值,如果SETNX key value返回1那么我们认为获取锁成功,否则认为获取锁失败。
但是使用SETNX只能构建一个简单的分布式锁,无法解决如下问题:
1.获取锁的方法是否有超时时间?
2.某个服务成功获取了某个分布式锁,此时该服务崩溃,那么该分布式锁将因锁持有者崩溃而无法释放。
为了解决如上问题,我们将对redis的SETNX命令进行封装,构建一个具有高级特性的分布式锁。
获取锁方法的基本思路:为了对数据进行排他性访问,程序首先需要获取锁,利用SETNX命令为key设value,如果key不存在则设值成功,此时认为获取锁成功并把value返回。我们可以认为key和value共同构成了一把锁,在释放锁的实现中将利用key和value来保证释放正确的锁;如果获取锁失败,程序将不断重试直到设值成功或超过给定时间限制。为了防止锁持有者奔溃而导致锁无法释放,在调用SETNX设置成功之后,我们将调用redis的另一个命令EXPIRE为锁也就是key设置过期时间,使得redis可以自动删除过期的锁而不必担心锁持有者崩溃造成锁无法释放。为了避免在获取锁成功后、设置过期时间之前获取锁的方法出现异常从而导致的设置锁的过期时间失败,在获取锁失败后,获取锁的方法还将检查已设值的key是否设置了过期时间,如果没有设置过期时间,程序将给该锁设置过期时间以保证万无一失。
以下是代码实现:
以下是测试方法:
在我的计算机(12G内存,I5处理器)对分布式锁进行测试,模拟5个获取锁的线程,每个线程请求5次,结果如下:
在分布式系统中,通常会遇到多个服务器处理同一个业务的场景,我们需要利用某种机制避免并发问题。Java语言中,我们可以通过锁的方式避免单个服务的多线程并发问题,而分布式系统中的并发问题用Java的锁机制是很难解决的。
分布式锁也有类似地“首先获取锁, 然后执行操作,最后释放锁”的动作,为了解决分布式系统的并发问题,我们可以使用redis实现一个跨机器的分布式锁。
下面先看redis的SETNX命令:
命令:
SETNX key value
解释:
将 key 的值设为 value ,当且仅当 key 不存在。
若给定的 key 已经存在,则 SETNX 不做任何动作。
返回值:
设置成功,返回 1 。
设置失败,返回 0 。
redis提供的SETNX命令天生具有基本的加锁功能。把要锁定资源的某个标识作为key,并设置全局唯一的值作为redis的key的值,如果SETNX key value返回1那么我们认为获取锁成功,否则认为获取锁失败。
但是使用SETNX只能构建一个简单的分布式锁,无法解决如下问题:
1.获取锁的方法是否有超时时间?
2.某个服务成功获取了某个分布式锁,此时该服务崩溃,那么该分布式锁将因锁持有者崩溃而无法释放。
为了解决如上问题,我们将对redis的SETNX命令进行封装,构建一个具有高级特性的分布式锁。
获取锁方法的基本思路:为了对数据进行排他性访问,程序首先需要获取锁,利用SETNX命令为key设value,如果key不存在则设值成功,此时认为获取锁成功并把value返回。我们可以认为key和value共同构成了一把锁,在释放锁的实现中将利用key和value来保证释放正确的锁;如果获取锁失败,程序将不断重试直到设值成功或超过给定时间限制。为了防止锁持有者奔溃而导致锁无法释放,在调用SETNX设置成功之后,我们将调用redis的另一个命令EXPIRE为锁也就是key设置过期时间,使得redis可以自动删除过期的锁而不必担心锁持有者崩溃造成锁无法释放。为了避免在获取锁成功后、设置过期时间之前获取锁的方法出现异常从而导致的设置锁的过期时间失败,在获取锁失败后,获取锁的方法还将检查已设值的key是否设置了过期时间,如果没有设置过期时间,程序将给该锁设置过期时间以保证万无一失。
以下是代码实现:
/**
* 获取锁。
* 该获取锁方法有如下特性:
* 1.如果获取锁成功,会设置锁的生存时间;
* 2.虽然大多数情况下redis的锁都有生存时间,
* 但是为了防止在上锁后、设置锁的生存周期
* 之前获取锁的方法出现了异常而终止。我们加入如下判断:
* 如果获取锁失败,会检查已存在锁是否设置有生存时间,
* 如果没有设置生存时间,那么会给锁设置生存时间。
* 。
*
* @param conn redis连接
* @param lockName 锁名称
* @param waitTimeOut 等待获取锁的超时时间(毫秒)
* @param lockTimeOut 锁的生存时间(秒)
* @return 如果获取锁成功则返回锁键对应值,否则返回null
*/
private String acquireLockWithTimeOut(Jedis conn, String lockName, long waitTimeOut, int lockTimeOut) {
String lockKey = "lock:" + lockName;
String lockId = UUID.randomUUID().toString();
long end = System.currentTimeMillis() + waitTimeOut;
int i = 0;
while (System.currentTimeMillis() < end) {
if (conn.setnx(lockKey, lockId) == 1) {
conn.expire(lockKey, lockTimeOut);
System.out.println("acquire lock '" + lockName + "',lockId=" + lockId + ",retry " + i);
return lockId;
}
if (conn.ttl(lockKey) < 0) {
conn.expire(lockKey, lockTimeOut);
}
try {
Thread.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
i++;
}
return null;
}
既然有获取锁的方法,那么也会有释放锁的方法:在获取锁之后,获取锁的方法会返回锁的value标识,在释放锁的时候,将根据锁和锁的value标识来释放锁,以免错误地释放了其他持有者的锁。此外,释放锁的方法也带有超时功能,如果释放失败,程序将重试直到成功或超时。
以下是代码实现:
/**
* 解锁。
* 解锁时将判断锁键对应值是否是给定的值,防止误解锁。
*
* @param conn redis连接
* @param lockName 锁名称
* @param lockId 锁键对应值
* @param waiteTimeOut 解锁动作的超时时间(毫秒)
* @return true如果解锁成功,否则返回false
*/
private boolean releaseLock(Jedis conn, String lockName, String lockId, long waiteTimeOut) {
String lockKey = "lock:" + lockName;
long end = System.currentTimeMillis() + waiteTimeOut;
int i = 0;
while (System.currentTimeMillis() < end) {
conn.watch(lockKey);
if (lockId.equals(conn.get(lockKey))) {
Transaction trans = conn.multi();
trans.del(lockKey);
List<Object> exec = trans.exec();
if (exec != null) {
System.out.println("release lock '" + lockName + "',lockId=" + lockId + ",retry " + i);
return true;
}
i++;
continue;
}
conn.unwatch();
break;
}
return false;
}
以下是测试方法:
/**
* 分布式锁的测试方法
*
* @param threads 模拟获取锁的请求线程数
*/
public void test(int threads) {
final AtomicInteger acquireFailCount = new AtomicInteger();
final AtomicInteger acquireCount = new AtomicInteger();
final CountDownLatch latch = new CountDownLatch(0);
final CountDownLatch endLatch = new CountDownLatch(threads);
final List<Long> countList = new ArrayList<Long>();
ExecutorService executorService = Executors.newFixedThreadPool(threads);
for (int i = 0; i < threads; i++) {
executorService.execute(new Runnable() {
public void run() {
final Jedis conn = new Jedis("localhost");
conn.select(0);
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int i1 = 0; i1 < 5; i1++) {
long start = System.currentTimeMillis();
acquireCount.incrementAndGet();
String aLock = acquireLockWithTimeOut(conn, "aLock", 100, 1);
if (aLock != null) {
countList.add(System.currentTimeMillis() - start);
releaseLock(conn, "aLock", aLock, 100);
} else {
acquireFailCount.incrementAndGet();
}
}
endLatch.countDown();
}
});
}
latch.countDown();
try {
endLatch.await();
} catch (InterruptedException ignore) {
}
executorService.shutdown();
long count = 0;
for (Long aLong : countList) {
count += aLong;
}
System.out.println("并发量:" + threads + ",尝试获取锁" + acquireCount + "次,其中成功" + (acquireCount.get() - acquireFailCount.get()) + "次,获取锁平均耗时" + (count / (double) countList.size()) + "毫秒。");
}
在我的计算机(12G内存,I5处理器)对分布式锁进行测试,模拟5个获取锁的线程,每个线程请求5次,结果如下:
并发量:5,尝试获取锁25次,其中成功25次,获取锁平均耗时17.32毫秒。
可以看出,这个性能还是可以满足日常需求的。