分布式锁

前情提要

基于分布式的 CAP (一致性、可用性、容错性)原理,系统在设计之初必须要考虑取舍,在互联网领域的绝大多数场景中,一般会牺牲强一致性来换取高可用性,只需要保证最终一致性即可,而这个最终一致的时间是用户可接受的范围即可。

为了保证最终一致性,一般会使用分布式事务、分布式锁等,分布式锁也有多种实现方案:

  • 基于数据库
  • 基于缓存
  • 基于 zookeeper

分布式锁的实现目标:

  • 可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行;
  • 必须是可重入锁,以避免死锁
  • 可以根据业务决定是不是一个阻塞锁
  • 有高可用的加锁解锁功能
  • 加锁解锁操作的性能要高

基于数据库

基于表

在数据库中创建表,要锁住某个方法或资源时,在表中增加一条记录,想要释放时删除该记录。

创建表:

CREATE TABLE `methodLock` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `method_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` (`method_name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';

加锁操作:

insert into methodLock(method_name,desc) values (method_name,desc);

解锁操作:

delete from methodLock where method_name ='method_name';

缺点:

  • 这把锁强依赖于数据库的高可用性;
  • 锁没有失效时间,一旦解锁操作失败,将导致其他线程无法再获得锁;
  • 这把锁只能是非阻塞的,因为数据的插入操作一旦失败就会返回报错。没有获得锁的线程并不会排队进入队列,想要再次获得锁就要再次出发获得锁操作;
  • 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为表中数据已经存在了。

解决方法:

  • 使用分布式数据库;
  • 使用定时任务,按时清理超时的锁;
  • 通过 while 循环直到插入成功;
  • 添加字段,记录当前获得锁的主机的信息和线程信息,然后在下次获取锁时先查询,如果信息匹配则分配锁。

基于数据库排它锁

基于 MySQL 中 InnoDB 自带的排它锁实现加锁:

public boolean lock(){
    connection.setAutoCommit(false)
    while(true){
        try{
            result = select * from methodLock where method_name=xxx for update;
            if(result==null){
                return true;
            }
        }catch(Exception e){

        }
        sleep(1000);
    }
    return false;
}

在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排它锁,当某条记录被加上排它锁之后,其他线程则无法再在该记录上增加排它锁。

InnoDB 引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。这里我们希望使用行级锁,就要给method_name添加索引,同时一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题,重载方法的话建议同时保存参数类型。

这时可以认为获得排他锁的线程即获得了分布式锁,当获取到锁之后,可以执行方法的业务逻辑,最后再进行解锁操作:

public void unlock(){
    connection.commit();
}

这种方式可以有效解决上面提到的无法解锁和阻塞锁的问题。

  • 阻塞锁:for update语句会在执行成功之后立即返回,在执行失败时一直处于阻塞状态,直到成功;
  • 锁定之后服务宕机造成的无法释放:这种方式下,服务宕机时数据库会自动释放锁。

但是仍然无法解决单点问题和可重入问题。

另一个问题:虽然对method_name使用了唯一索引,并且显式使用for update来使用行级别锁。但是,MySQL 会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据仍然是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 自认为扫表效率更高,比如对一些很小的表,则不会使用索引,这是将会使用表锁而不是行锁。。。

同时,我们要使用排它锁来进行分布式锁的 lock,那么一个排它锁长时间不提交就会占用数据库连接,连接过多则造成数据库不可用。

基于缓存

基于类似 Redis、Memcached、Tair等缓存时,性能会表现更好。

比如在 Tair 中使用TairManager.put进行加锁和解锁操作:

public boolean trylock(String key) {
    ResultCode code = ldbTairManager.put(NAMESPACE, key, "This is a Lock.", 2, 0);
    if (ResultCode.SUCCESS.equals(code))
        return true;
    else
        return false;
}
public boolean unlock(String key) {
    ldbTairManager.invalid(NAMESPACE, key);
}

同样会存在的问题:

  • 锁没有失效时间;
  • 只能是非阻塞的,无论成功失败都会直接返回;
  • 是非重入的,一个线程获得锁之后,在释放之前,都无法再获得锁,因为使用的 KEY 在 缓存中已存在,无法再次进行 put 操作。

解决方式:

  • put 时设置过期时间;
  • while 重复执行知道成功;
  • 保存主机和线程信息。

但是失效时间设计为多久合适呢?太短的话还没执行完就自动释放了,太久则浪费时间。

基于 ZK

基于 ZK 临时有序节点来实现分布式锁。

每个客户端对某个方法加锁时,在 ZK 中的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。当释放锁的时候,只需要将这个节点删除即可。同时可以避免因宕机而造成的死锁。

是否能解决其他实现遇到的问题:

  • 可以有效解决无法释放问题,因为在创建锁的时候,客户端会在 ZK 中创建一个临时节点,一点客户端宕机则 session 连接断开,这时临时节点会自动删除,其他客户端会再次获得锁。
  • 阻塞锁:客户端通过在 ZK 中创建顺序节点,并且在节点中绑定监听器,一旦节点有变化,ZK 会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中需要最小的,如果是,那么自己就获得锁。
  • 可重入:客户端在创建节点时同时保存主机与线程信息,下次想要获取时和当前最小节点中的信息进行对比即可,相同则获得锁,不同在创建一个新的节点来进行排队。
  • 单点:ZK 本身就作为高可用集群部署。

同时可以直接使用 ZK 的三方库 Curator 客户端,这个客户端中已经封装了一个可重入锁服务:

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;
}

只是基于 ZK 的实现在性能上达不到基于缓存的实现,这是由 ZK 的分布式机制决定的(Leader 执行再同步到所有 follower)。

同时可能带来并发问题,只是不常见而已。比如,由于网络抖动,客户端与 ZK 集群的 session 断开了,那么 ZK 会以为客户端挂了,这时删除临时节点,这时其他节点则获得了锁,则可能产生并发问题。不常见是因为 ZK 有重试机制,一旦 ZK 集群检测不到客户端心跳就会进行重试,Curator 客户端支持多种尝试策略,多次重试仍然不行则删除临时节点。

总结

从理解的难以程度(由低到高):数据库 - 缓存 - ZK

从实现复杂度(由低到高):ZK -= 缓存 - 数据库

性能角度(由高到低):缓存 - ZK -= 数据库

可靠性(由高到低):ZK - 缓存 - 数据库