Zookeeper应用场景和各种分布式锁的实现

该文参考资源:

浅谈分布式锁--基于数据库实现篇_数据库_powerful的博客-CSDN博客

配置中心

在平常的业务开发过程中,我们通常需要将系统的一些通用的全局配置,例如机器列表配置,运行时开关配置,数据库配置信息等统-集中存储,让集群所有机器共享配置信息,系统在启动会首先从配置中心读取配置信息,进行初始化。传统的实现方式将配置存储在本地文件和内存中,一旦机器规模更大,配置变更频繁情况下,本地文件和内存方式的配置维护成本较高,使用zookeeper作为分布式的配置中心就可以解决这个问题。

我们将配置信息存在zk中的一个节点中,同时给该节点注册一个数据节点变更的watcher监听,一旦节点数据发生变更,所有的订阅该节点的客户端都可以获取数据变更通知。

负载均衡

建立server节点,并建立监听器监视servers子节点的状态(用于在服务器增添时及时同步当前集群中服务器列表)。在每个服务器启动时,在servers节点下建立具体服务器地址的子节点,并在对应的子节点下存入服务器的相关信息。这样,我们在zookeeper服务器上可以获取当前集群中的服务器列表及相关信息,可以自定义一个负载均衡算法,在每个请求过来时从zookeeper服务器中获取当前集群服务器列表,根据算法选出其中一个服务器来处理请求。

命名服务

命名服务是分布式系统中的基本功能之一。被命名的实体通常可以是集群中的机器、提供的服务地址或者远程对象,这些都可以称作为名字。常见的就是一些分布式服务框架(RPC、RMI)中的服务地址列表,通过使用名称服务客户端可以获取资源的实体、服务地址和提供者信息。命名服务就是通过一个资源引用的方式来实现对资源的定位和使用。在分布式环境中,上层应用仅仅需要一个全局唯一名称,就像数据库中的主键。

在单库单表系统中可以通过自增ID来标识每一条记录,但是随着规模变大分库分表很常见,那么自增ID有仅能针对单一表生成ID,所以在这种情况下无法依靠这个来标识唯一ID。UUID就是一种全局唯一标识符。但是长度过长不易识别。

在 Zookeeper中通过创建顺序节点就可以实现,所有客户端都会根据自己的任务类型来创建一个顺
序节点,例如 job-00000001。

Zookeeper应用场景和各种分布式锁的实现
//CreateMode.PERSISTENT_SEQUENTIAL可以在第一个参数path后面附加一个八位的全局递增的数字
String path = zooKeeper.create("/type1/iob", "app2Value".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT_SEQUENTIAL);

System.out.println(path);

DNS服务

域名配置

在分布式系统应用中,每一个应用都需要分配一个域名,日常开发中,往往使用本地HOST绑定域名解析,开发阶段可以随时修改域名和IP的映射,大大提高开发的调试效率。如果应用的机器规模达到一定程度后,需要频繁更新域名时,需要在规模的集群中变更,无法保证实时性。所有我们在zk上创建一个节点来进行域名配置。

域名解析

应用解析时,首先从zk域名节点中获取域名映射的IP和端口。

域名变更

每个应用都会在在对应的域名节点注册一个数据变更的watcher监听,一旦监听的域名节点数据变更, zk会向所有订阅的客户端发送域名变更通知。

集群管理

随着分布式系统规模日益扩大,集群中机器的数量越来越多。有效的集群管理越来越重要了,
zookeeper集群管理主要利用了watcher机制和创建临时节点来实现。以机器上下线和机器监控为例:

机器上下线

新增机器的时候,将Agent部署到新增的机器上,当Agent部署启动时,会向zookeeper指定的节点下创建一个临时子节点,当Agent在zk上创建完这个临时节点后,当关注的节点zookeeper/machines下的子节点加入新的节点时或删除都会发送通知,这样就对机器的上下线进行监控。

机器监控

在机器运行过程中,Agent会定时将主机的的运行状态信息写入到/machines/hostn主机节点,监控中心通过订阅这些节点的数据变化来获取主机的运行信息。

分布式锁

分布式锁的实现方式通常有三种:数据库、redis、zookeeper

数据库悲观锁

这部分的原理参考MySQL之表锁、行锁、MVCC详解

insert自动加锁

思想是,当我们要锁住某个方法或资源时,我们就在该表中增加一条记录,想要释放锁的时候就删除这条记录。

对于UPDATE、DELETE和INSERT语句,InnoDB会自动给涉及的数据集加排他锁

创建数据库:

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

注意method_name必须是唯一索引,否则锁的是全表而不是记录本身。

获得锁:

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

释放锁:

delete from methodLock where method_name ='method_name';

上面这种简单的实现有以下几个问题:

  1. 这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
  2. 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
  3. 这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
  4. 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。

select手动加锁

select ... for update如果记录存在,会给记录加上行锁实现一致性锁定读。

获得锁:

select * from methodLock where method_name = #{currentMethod} for update;

释放锁:

commit

这个方法的问题是,如果查询的记录不存在,mysql会加上间隙锁,而间隙锁是共享锁,也就是允许读操作,不会阻塞,所以当查询的记录不存在这种方法是不适用的。

这里还可能存在另外一个问题,虽然我们对 method_name 使用了唯一索引,并且显示使用for update 来使用行级锁。但是,MySql 会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下InnoDB 将使用表锁,而不是行锁。

数据库乐观锁

数据库的乐观锁认为大多数时候操作都不会冲突,它给数据添加一个版本号,读取出数据时,将此版本号一同读出,之后更新时,对此版本号加1。在更新过程中,会对版本号进行比较,如果是一致的,没有发生改变,则会成功执行本次操作;如果版本号不一致,则会更新失败。

获得锁:

select state,version from methodLock where method_name = 'currentMethod';

update methodLock set state = 'locked',version = version + 1 where
method_name = 'currentMethod' and state = 'unlock' and version = 'version';

释放锁:

update methodLock set state = 'unlock' where method_name = 'currentMethod' and state = 'locked';

乐观锁只能解决持久化是 DB 数据的一次更新问题。假如你的数据不是在 DB,或者一个过程有三个数据的更新操作,线程 A 更新了数据 1 和数据 2,线程 B 更新了数据 3,乐观锁就不能起作用。

Redis

redis分布式锁的实现和数据库的思想差不多,它是基于setnx(set if not exists)实现的,设置成功,返回1;设置失败,返回0,释放锁的操作通过del指令来完成。

如果设置锁后在执行中间过程时,程序抛出异常,导致del指令没有调用,锁永远无法释放,这样就会陷入死锁。所以我们拿到锁之后会给锁加上一个过期时间,这样即使中间出现异常,过期时间到后会自动释放锁。

同时在setnxexpire 如果进程挂掉,expire不能执行也会死锁。所以要保证setnx和expire是一个原子性操作即可。redis 2.8之后推出了setnx和expire的组合指令

set key value ex 5 nx

redis 实现分布式锁存在的问题:为了解决redis单点问题,我们会部署redis集群,在 Sentinel 集群中,主节点突然挂掉了。同时主节点中有把锁还没有来得及同步到从节点。这样就会导致系统中同样一把锁被两个客户端同时持有,不安全性由此产生。redis官方为了解决这个问题,推出了Redlock 算法解决这个问题。但是带来的网络消耗较大。

使用redisson(一个redis的作用于java的第三方库)可以解决这个问题。

Zookeeper

原理:

zookeeper通过创建临时序列节点来实现分布式锁,适用于顺序执行的程序,大体思路就是创建临时序列节点,找出最小的序列节点,获取分布式锁,程序执行完成之后此序列节点消失,通过watch来监控自己前一个节点的删除,当自己为序列中编号最小的节点的时候,获得分布式锁。当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。

优点

  1. 无单点问题。ZK 是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务。
  2. 持有锁任意长的时间,可自动释放锁。使用 Zookeeper 可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在 ZK 中创建一个临时节点,一旦客户端获取到锁之后突然挂掉(Session 连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。这避免了基于 Redis 的锁对于有效时间(lock validity time)到底设置多长的两难问题。实际上,基于 ZooKeeper 的锁是依靠 Session(心跳)来维持锁的持有状态的,而 Redis 不支持 Session。
  3. 可阻塞。使用 Zookeeper 可以实现阻塞的锁,客户端可以通过在 ZK 中创建顺序节点,并且在节点上绑定监听器,一旦节点有变化,Zookeeper 会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。
  4. 可重入。客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。zookeeper 第三方库Curator 客户端中封装了一个可重入的锁服务。

缺点

zookeeper 实现的分布式锁其实存在一个缺点,那就是性能上可能并没有缓存服务那么高。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK 中创建和删除节点只能通过 Leader 服务器来执行,然后将数据同不到所有的 Follower 机器上。

分布式队列

队列特性:FIFO(先入先出),zookeeper实现分布式队列的步骤:

  • 在队列节点下创建临时顺序节点,例如/queue_info/192.168.1.1-0000001
  • 调用 getChildren()接口来获取/queue_info节点下所有子节点,获取队列中所有元素
  • 比较自己节点是否是序号最小的节点,如果不是,则等待其他节点出队列,在序号最小的节点注册watcher
  • 获取watcher通知后,重复步骤

Zookeeper应用场景和各种分布式锁的实现

原创文章,作者:彭晨涛,如若转载,请注明出处:https://www.codetool.top/article/zookeeper%e5%ba%94%e7%94%a8%e5%9c%ba%e6%99%af%e5%92%8c%e5%90%84%e7%a7%8d%e5%88%86%e5%b8%83%e5%bc%8f%e9%94%81%e7%9a%84%e5%ae%9e%e7%8e%b0/

发表评论

电子邮件地址不会被公开。