分布式锁的常见实现


现在的系统部署大部分都是分布式部署的方式,对于需要使用锁的场景不能再通过使用单纯的Java Api实现。产生了基于数据库,缓存(redis,memcached,tair),和zookeeper实现的分布式锁。

对于分布式锁我们希望的理想锁的表现

  1. 在分布式环境中保证同一个临界区在同一时间只在一台机器上执行。
  2. 这把分布式锁是可重入锁[避免死锁]
  3. 可以根据业务需要变成阻塞锁
  4. 获取和释放锁性能高

基于数据库实现分布式锁

基于唯一索引实现

1.创建一张带唯一索引的表

1
2
3
4
5
6
7
8
CREATE TABLE `blockLock` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`block_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的块名称',
`desc` varchar(1024) NOT NULL DEFAULT '备注信息',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
PRIMARY KEY (`id`),
UNIQUE KEY `uidx_method_name` (`block_name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

2.在想要添加锁的块代码之前插入数据,由于block_name做了唯一索引,同样块名称的操作只能有一个成功。

1
insert into methodLock(method_name,desc) values (‘block_name’,‘the same block_name commit’);

3.临界代码执行完毕需要释放锁,此时只需要将block_name这条数据删除或更新即可

1
delete from methodLock where method_name ='block_name'

优点:

  1. 实现方便,便于理解

缺点:

  1. 如果数据库是单点,则可靠性不能保证
  2. 没有失效时间,不会自动释放锁,一旦解锁失败会导致其他线程无法再获取到锁。
  3. 这把锁只能是非阻塞的,插入失败直接报错返回,无法自动阻塞再次尝试获取锁
  4. 这把锁是非重入锁,线程获取锁后无法再次获取此锁,因为数据库已存在唯一索引值。
  5. 对于基于数据库的锁获取和释放锁的开销相对比较大

基于数据库的排他锁

在mySql的InnoDB引擎的查询语句后增加for update,这样在查询的过程中数据库会增加排他锁【注意:如果想使用查询参数要建立唯一索引,由于InnoDB 预设是Row-Level Lock,所以只有「明确」的指定主键,MySQL 才会执行Row lock (只锁住被选取的数据) ,否则MySQL 将会执行Table Lock (将整个数据表单给锁住】

添加锁代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public boolean lock(){
Long timeout=10000
long futureTime = System.currentTimeMillis() + timeOuts;
connection.setAutoCommit(false)
while(true){
try{
result = select * from blockLock where block_name=xxx for update;
if(result==null){
return true;
}
}catch(Exception e){

}
sleep(1000);
if(futureTime<System.currentTimeMillis()){
break;
}
}
return false;
}

释放锁代码

1
2
3
public void unlock(){
connection.commit();
}

优点:

  1. 阻塞锁,for update语句会一直等待直到执行成功后返回结果
  2. 自动释放锁,当数据库连接断开时候会自动释放锁

缺点:

  1. 如果数据库是单点,则可靠性不能保证
  2. 对于基于数据库的锁获取和释放锁的开销相对比较大
  3. 使用不当容易变成表级锁,容易影响业务
  4. 利用事务进行加锁的时候会导致很多连接不能及时释放,导致连接池爆满

基于缓存实现分布式锁

相较于数据库实现的分布式锁,基于缓存实现的分布式锁更加高效,且有很多成熟的方案,redis,memcached以及tair等都有很好的支持。

下面是基于redis实现的分布式锁

添加锁代码

1
2
3
4
5
6
7
8
9
10
11
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";
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;
}
  • 第一个为key,我们使用key来当锁,因为key是唯一的。
  • 第二个为value,我们传的是requestId,很多童鞋可能不明白,有key作为锁不就够了吗,为什么还要用到value?原因就是我们在上面讲到可靠性时,分布式锁要满足第四个条件解铃还须系铃人,通过给value赋值为requestId,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成。
  • 第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
  • 第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。
  • 第五个为time,与第四个参数相呼应,代表key的过期时间。

##释放锁代码:

1
2
3
4
5
6
7
8
9
10
11
12
13

private static final Long RELEASE_SUCCESS = 1L;
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
//就是在eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。
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;

}

首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)。使用Lua语言来确保上述操作是原子性。

优点:

  1. 缓存服务可以做集群提高可用性
  2. 获取锁和释放锁效率高
  3. 可以设置超时时间,超时会自动释放锁

缺点:

  1. 这是把非阻塞锁,无论成功失败会直接返回
  2. 这是把非重入锁,当一个线程获取锁后在释放锁前此线程无法再次获得该锁
  3. 失效时间平衡设置比较困难(时间短,会产生并发问题,时间长,会导致浪费的资源等待)

基于zookeeper实现分布式锁

zookeeper会为客户端加锁的请求建立唯一一个瞬时有序节点,判断获取锁只需要判断此节点是否为此有序节点中序号最小的一个。当释放锁时候,只需要将这个瞬时节点删除可以。

使用curator客户端操作zookeeper

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
try {
return interProcessMutex.acquire(timeout, unit);
} catch (Exception e) {
e.printStackTrace();
}
return true;
}
public boolean unlock() {
try {
interProcessMutex.release();
} catch (Throwable e) {
log.error(e.getMessage(), e);
} finally {
executorService.schedule(new Cleaner(client, path), delayTimeForClean, TimeUnit.MILLISECONDS);
}
return true;
}

优点:

  1. 锁释放,当客户获取锁后突然挂掉(session连接断开),临时节点会自动删除。其他客户端可以再次获取锁
  2. 可实现阻塞锁,客户端通过在zk中创建有顺序节点,并且绑定监听,如果节点变化zk会通知客户端,客户端检查自己创建的节点是不是当前所有节点中序号最小的从而判断是否获取到锁。
  3. 可重入,客户端在创建节点时,zk会把当前客户端主机信息和线程信息写到节点中,客户端线程再次想获取锁时候和当前最小节点的数据对比一下就可以了。如果信息一样便是已获取到锁。
  4. 高可用,zk是集群部署的。

缺点:

  1. 由于需要很多判断和信息写入读取,以及分发信息,效率并没有基于缓存的高
  2. 有极低的概率会(zk有重试机制只有多次重试仍检测不到客户端心跳就会删除客户端临时节点)导致并发问题,如:当网络抖动失去客户端连接,别的客户端可能会得到分布式锁。

分布式锁的几种实现
Redis 分布式锁的正确实现方式( Java 版 )