数据库实现分布式锁
创建一张分布式表(字段:id,锁名,状态等),然后通过查询锁的状态(如果为可用,则获得锁,并将锁设置成不可用)。
首先我们需要注意的一个问题是:查询的时候防止幻读问题,需要用到行锁:
select state from table where lock ='lock_name' for update
其次我们还需要保证查询和修改是在同一个事务中,因此我们需要申明事务,具体的实现代码如下:
@Transactional
public boolean lock(String lockName) {
if("可用".equals(lockDao.selectLockStateByName(lockName))){
int result = orderDao.updateOrderState(lockName);
if(result>0){
return 1;
}
}
return 0;
}
这样基于数据库的分布式锁就基本完成了,那么我们来分析下这种分布式锁的问题:
- select 的 for update 操作是基于间隙锁 gap lock 实现的,这是一种悲观锁的实现方式,所以存在阻塞问题。
- 这把锁依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
- 这把锁只能是非阻塞的,因为数据的update操作,一旦更新失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁的操作。
- 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。
或许我们可以通过两个数据的双向同步来解决单点问题,可以通过while循环直到update成功来解决非阻塞问题,也可以通过加个字段来记录主机和当前线程的信息来解决非重入问题,但性能问题是没法解决的。
三种方案中,性能最低
Zookeeper 实现分布式锁
Zookeeper 是一种提供“分布式服务协调“的中心化服务,基于Zookeeper 实现分布式锁主要依靠Zookeeper的两个特性:顺序临时节点和Watch 机制。
Zookeeper的节点类型:可以分为持久节点(PERSISTENT )、临时节点(EPHEMERAL),每个节点还能被标记为有序性(SEQUENTIAL),一旦节点被标记为有序性,那么整个节点就具有顺序自增的特点。一般我们可以组合这几类节点来创建我们所需要的节点,例如,创建一个持久节点作为父节点,在父节点下面创建临时节点,并标记该临时节点为有序性。
Watch 机制:Zookeeper 还提供了另外一个重要的特性,Watcher(事件监听器)。ZooKeeper 允许用户在指定节点上注册一些 Watcher,并且在一些特定事件触发的时候,ZooKeeper 服务端会将事件通知给用户。
基于以上两个特性,我们来看看如何实现分布式锁:
1.首先,我们需要建立一个父节点,节点类型为持久节点(PERSISTENT),每当需要访问共享资源时,就会在父节点下建立相应的顺序子节点,节点类型为临时节点(EPHEMERAL),且标记为有序性(SEQUENTIAL),并且以临时节点名称父节点名称 + 顺序号组成特定的名字。
2.在建立子节点后,对父节点下面的所有以临时节点名称 name 开头的子节点进行排序,判断刚刚建立的子节点顺序号是否是最小的节点,如果是最小节点,则获得锁。
3.如果不是最小节点,则阻塞等待锁,并且获得该节点的上一顺序节点,为其注册监听事件,等待节点对应的操作获得锁。当调用完共享资源后,删除该节点,关闭 zk,进而可以触发监听事件,释放该锁。
一般我们可以直接引用 Curator 框架来实现 Zookeeper 分布式锁,代码如下:
InterProcessMutex lock = new InterProcessMutex(client, lockPath);
if ( lock.acquire(maxWait, waitUnit) )
{
try
{
// do some work inside of the critical section here
}
finally
{
lock.release();
}
}
Zookeeper 实现的分布式锁,例如相对数据库实现,有很多优点:
1.Zookeeper 是集群实现,可以避免单点问题。
2.能保证每次操作都可以有效地释放锁,这是因为一旦应用服务挂掉了,临时节点会因为 session 连接断开而自动删除掉。
但由于频繁地创建和删除结点,加上大量的 Watch 事件,对 Zookeeper 集群来说,压力非常大。
三种方案中,性能中等
Redis实现分布式锁
Redis2.6.12之前setnx和expire命令是非原子性的,也就是要执行完setnx命令后才能执行expire命令,如果在setnx命令执行完之后,发生了异常,那么就会导致expire命令不会执行,因此会导致死锁问题。但这都不重要,因为redis可以通过lua脚本来保障原子性操作。
// 加锁脚本
private static final String SCRIPT_LOCK = "if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then redis.call('pexpire', KEYS[1], ARGV[2]) return 1 else return 0 end";
// 解锁脚本
private static final String SCRIPT_UNLOCK = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Redis2.6.12之前获取锁:
public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
Long result = jedis.setnx(lockKey, requestId);//设置锁
if (result == 1) {//获取锁成功
// 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁
jedis.expire(lockKey, expireTime);//通过过期时间删除锁
return true;
}
return false;
}
Redis2.6.12后获取锁:
private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
/**
* 尝试获取分布式锁
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @param expireTime 超期时间
* @return 是否获取成功
*/
public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
}
解锁代码必须先获取锁再删除锁,目前Redis还没有针对这种删除的原子性命令,所以解锁的命令必须要用lua。
public class RedisTool {
private static final Long RELEASE_SUCCESS = 1L;
/**
* 释放分布式锁
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @return 是否释放成功
*/
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
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));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
}
基于Redis实现分布式锁的缺点:
需要手动设置过期时间,如果我们设置的过期时间比较短,而执行业务时间比较长,就会存在锁代码块失效的问题。我们需要将过期时间设置得足够长,来保证以上问题不会出现。
这个方案是目前最优的分布式锁方案,但如果是在 Redis 集群环境下,依然存在问题。由于 Redis 集群数据同步到各个节点时是异步的,如果在 Master 节点获取到锁后,在没有同步到其它节点时,Master 节点崩溃了,此时新的 Master 节点依然可以获取锁,所以多个应用服务可以同时获取到锁。
Redlock 算法
该算是是一种“大多数成功则成功的”方案,也就是说当应用服务成功获取锁的 Redis 节点超过半数(N/2+1,N 为节点数) 时,并且获取锁消耗的实际时间不超过锁的过期时间,则获取锁成功。
一旦获取锁成功,就会重新计算释放锁的时间,该时间是由原来释放锁的时间减去获取锁所消耗的时间;而如果获取锁失败,客户端依然会释放获取锁成功的节点。
具体的代码实现如下:
1.首先引入 jar 包:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.8.2</version>
</dependency>
2.实现Redisson 的配置文件:
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useClusterServers()
.setScanInterval(2000) // 集群状态扫描间隔时间,单位是毫秒
.addNodeAddress("redis://127.0.0.1:7000).setPassword("1")
.addNodeAddress("redis://127.0.0.1:7001").setPassword("1")
.addNodeAddress("redis://127.0.0.1:7002")
.setPassword("1");
return Redisson.create(config);
}
3.获取锁操作
long waitTimeout = 10;
long leaseTime = 1;
RLock lock1 = redissonClient1.getLock("lock1");
RLock lock2 = redissonClient2.getLock("lock2");
RLock lock3 = redissonClient3.getLock("lock3");
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功,且设置总超时时间以及单个节点超时时间
redLock.trylock(waitTimeout,leaseTime,TimeUnit.SECONDS);
...
redLock.unlock();
总结
实现分布式锁的方式有很多,有最简单的数据库实现,还有 Zookeeper 多节点实现和缓存实现。
从性能上来说:Redis 的性能是最好的,Zookeeper 次之,数据库最差。
从实现方式和可靠性来说,Zookeeper 的实现方式简单,且基于分布式集群,可以避免单点问题,具有比较高的可靠性。
因此,在对业务性能要求不是特别高的场景中,我建议使用 Zookeeper 实现的分布式锁。