简述事务的概念与事务的特征_zookeeper基本概念简析

13cb6a40ec5ec1e9ec2b26ca2c5d7d0e.png

作者 | zeinlu(鲁子豪)

zookeeper是我们日常开发中每天都能接触到的组件,但是好像很多人对其缺乏了解,所以心血来潮写了这篇文章。

首先简单介绍一下zookeeper。zookeeper最开始是hadoop的子项目,后来升级为了apache的顶级项目。我想很多人刚开始接触zookeeper,都会带有这样的疑问,zookeeper的出现是为了解决什么样的问题?为了解决这样的问题,zookeeper引入了什么样的手段?这样的手段是通过什么原理去解决这个问题的?带着这样的疑惑,开始正文。友情提示:如果阅读过程中感到不适,可以直接跳到第五部分总结。

一. 起源

zookeeper起源于曾经的互联网巨头雅虎,基于Google的Chubby实现。zookeeper的出现是为了解决单点问题,所谓的单点问题大意指的是如果服务仅仅部署在一台机器上,万一机器故障,整个服务将会不可用。所以,在生产环境中,我们会需要若干个备胎,正如mysql的主从,redis的主从,集群所做的那样。zookeeper要做的就是快速解决备胎转正问题。
关于zookeeper的命名,也有一段趣闻,由于雅虎的项目很多都是由动物的名字命名的,所以给这个项目起了zookeeper这个名字,译为动物管理员,想要用其管理雅虎的各个项目。

zookeeper在创立之初设定了几个目标:

  • 简单的数据模型:目的是为了让使用者清晰明了的使用。

  • 可以构建集群:这个也很好理解,本身zookeeper就是用于辅助管理分布式系统,所谓打铁还需自身硬,zookeeper自己也需要保证自己的高可用。

  • 顺序访问:客户端的每个更新请求,zookeeper都会分配一个全局唯一ID,这个也很好理解,作为一个集群,如果没有一个唯一ID保证顺序,则可能会出现并发问题。

  • 高性能:zookeeper将全量数据存储在内存中。
    以上四点都是概念性的东西,只要理解一点即可,zookeeper想要又好又快的解决备胎转正问题,而且想要尽量减少纠纷。

二. zookeeper的写入过程简析

这部分想要简述zookeeper是如何进行数据写入的,包括集群模式下的数据同步。

1.zookeeper节点的数据结构

如果有zookeeper的使用经验,会知道zookeeper的节点路径非常类似于linux的文件系统,事实上它是如何进行存储的呢?
其实从本质上讲,zookeeper也是一个kv存储数据库,数据存储在DataTree这个数据结构中,核心存储是一个ConcurrentHashMap,其中key值是节点路径,value值为一个DataNode类型的数据。可以说,zookeeper的所有操作都是对这个Map的操作。在DataTree中存放数据的成员变量为:private final NodeHashMap nodes;下面可以看到其构造过程:

public NodeHashMapImpl(DigestCalculator digestCalculator) {
    this.digestCalculator = digestCalculator;
    nodes = new ConcurrentHashMap<>();
    hash = new AdHash();
    digestEnabled = ZooKeeperServer.isDigestEnabled();
}

正如那句俗语,美丽的外表千篇一律,其实redis的存储方式和zookeeper也是类似的,redis数据库的所有数据存放于下面的这个结构体中:

typedef struct redisDb {
    dict *dict;                 /* The keyspace for this DB */
    dict *expires;              /* Timeout of keys with a timeout set */
    dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP)*/
    dict *ready_keys;           /* Blocked keys that received a PUSH */
    dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
    int id;                     /* Database ID */
    long long avg_ttl;          /* Average TTL, just for stats */
    unsigned long expires_cursor; /* Cursor of the active expire cycle. */
    list *defrag_later;         /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;

第一个成员变量为redis自建的数据结构:字典,底层实现是一个hashtable,redis数据库的key值为一个字符串,value值是一个如下数据结构的redisObject及结构体:

typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
                            * LFU data (least significant 8 bits frequency
                            * and most significant 16 bits access time). */
    int refcount;
    void *ptr;
} robj;

类似于上文的DataNode

2. 一个创建节点请求

server端对客户端请求的处理是类似的,大体都是用IO多路复用技术,对不同类型事件做相应的处理,具体的实现还是很复杂的,这里将重点放在zookeeper对其内部对象的操作上,而不是底层的IO上。以创建节点为例:DataTree结构中有一个成员函数:

public void createNode(final String path, byte[] data, List acl, long ephemeralOwner,int parentCVersion, long zxid, long time, Stat outputStat) throws KeeperException.NoNodeException,KeeperException.NodeExistsException

用于创建节点,大致步骤为:首先拿到需要创建节点的父节点信息:

String parentName = path.substring(0, lastSlash);
String childName = path.substring(lastSlash + 1);
StatPersisted stat = createStat(zxid, time, ephemeralOwner);
DataNode parent = nodes.get(parentName);
if (parent == null) {
    throw new KeeperException.NoNodeException();
}

其次将节点添加其父节点的子节点记录中,然后将节点放到nodes中,

DataNode child = new DataNode(data, longval, stat);
parent.addChild(childName);
nodes.postChange(parentName, parent);
nodeDataSize.addAndGet(getNodeSize(path, child.data));
nodes.put(path, child);

 父节点的 addChild 函数只是将子节点的名称记录在父节点的一个set中,

private Set children = null;public synchronized boolean addChild(String child) {if (children == null) {
        children = new HashSet(8);
    }return children.add(child);
}

所以得出结论:父节点只存储子节点的节点路径,并不存储节点的实际内容。若想要拿到节点值,还需要从nodes这个Map中获取,时间复杂度为O(1)

最后,如果有客户端在监听这个节点事件,则将事件通知客户端:

dataWatches.triggerWatch(path, Event.EventType.NodeCreated);
childWatches.triggerWatch(parentName.equals("") ? "/" : parentName, Event.EventType.NodeChildrenChanged);

上述过程只是大体过程,省略了有些对节点事物ID的校验流程,但不影响理解。

3. 集群数据的写入过程

集群和主从是比较相似的概念,都是用若干机器保证服务的高可用。主从架构通常设计为主库处理事务请求,从库处理非事务请求,比如我们日常使用的redis,mysql就是如此,一主多从,主写从读(事务请求大体上指的是增,删,改,非事务请求为查询请求)。一般而言,集群应该是一个多主机集合的概念,比如redis的集群设计就是通过hash槽,将不同key值映射到不同的主机上,集群机器数目变更的同时要对应hash槽的重新分配。zookeeper的集群有点类似于主从,所有的事务请求需要交付Leader处理。其实这里可以发现,无论是redis还是zookeeper,一个事务必须交由单独的一台主机进行处理,不存在一对多的关系。先介绍一下zookeeper节点的三种角色:

  • Leader: 处理事务请求,保证事务顺序;调度集群内部服务。

  • Follower:转发事务请求给Leader节点;参与投票选举Leader;参与事务的proposal投票;

  • Observer:转发事务请求给Leader节点。

虽然Leader节点决定着集群的整体状态,但是每一个事务请求都需要过半的公民(具有选举权)投票通过才行,这个过程类似于2PC(对2PC没有了解的童鞋可以去看一下分布式事务解决方案),这个投票的流程被称为Proposal。这个过程中Leader会将提议放在投票箱中(final ConcurrentMap outstandingProposals = new ConcurrentHashMap();),同时广播给其他Follower节点,Follower收到后,开始Sync,完成后发送ACK消息给Leader,Leader收到过半消息后,进行COMMIT流程。这些步骤间涉及到的消息类型大致有:

    /**
     * This message type is sent to a leader to request and mutation operation.
     * The payload will consist of a request header followed by a request.
     */
    static final int REQUEST = 1; -- 非Leader节点转发事务请求给Leader

    /**
     * This message type is sent by a leader to propose a mutation.
     */
    public static final int PROPOSAL = 2; --Leader节点发出投票消息

    /**
     * This message type is sent by a follower after it has synced a proposal.
     */
    static final int ACK = 3; --Follower节点发送同意应答

    /**
     * This message type is sent by a leader to commit a proposal and cause
     * followers to start serving the corresponding data.
     */
    static final int COMMIT = 4; -- leader节点发出提交指令。

    /**
     * This message type informs observers of a committed proposal.
     */
    static final int INFORM = 8; -- 由于Observer节点没有不参与投票,没有事务记录,Leader需要发送特定的INFORM消息告诉它进行数据同步。

基于上述概念,假如一个非Leader节点收到一个事务请求,事务请求的大致流程:

  • 节点发送REQUEST请求同步到Leader节点。

  • 节点对请求进行判断。

  • Leader节点发出PROPOSAL请求。

  • Follower节点返回ACK应答。

  • Leader节点在收到超过半数的ACK应答后开始COMMIT事务。

  • Follower节点收到COMMIT消息进行事务提交。

  • Observer节点由于不参与投票,没有记录事务相关信息,所以Leader要发INFORM消息给Observer。

WARNING: 上述只是大致流程,省略了大量细节。

三. Watcher机制

zookeeper一个非常重要的应用就是它的发布/订阅功能,watcher机制是其实现这一功能的关键。zookeeper允许客户端向服务端针对某些特定场景注册一个watcher,在这些场景被触发时,服务端会回调watcher进行通知。下面展示一个监听事件的始末:

1. watcher属性

接口类watcher是一个事件处理器,其中有两个比较重要的枚举,分别定义了通知状态和事件类型。

enum KeeperState {
    // 客户端失联
    Disconnected(0),
    // 客户端与服务端处于连接状态
    SyncConnected(3),
    // 权限有问题
    AuthFailed(4),
    // 客户端连接着一个只读的server
    ConnectedReadOnly(5),
    // 客户端会话超时
    Expired(-112),
    // 客户端已关闭
    Closed(7);
}

// 看命名大概都能知道是什么类型的事件了
enum EventType {
    None(-1),
    NodeCreated(1),
    NodeDeleted(2),
    NodeDataChanged(3),
    NodeChildrenChanged(4),
    DataWatchRemoved(5),
    ChildWatchRemoved(6),
    PersistentWatchRemoved (7);
}
2. watcher注册

客户端可以通过getData, getChildren,exists方法进行注册watcher,大致流程为:

public void getData(final String path, Watcher watcher, DataCallback cb, Object ctx) {
    final String clientPath = path;
    PathUtils.validatePath(clientPath);

    // the watch contains the un-chroot path
    WatchRegistration wcb = null;
    if (watcher != null) {
        wcb = new DataWatchRegistration(watcher, clientPath);
    }

    final String serverPath = prependChroot(clientPath);

    RequestHeader h = new RequestHeader();
    h.setType(ZooDefs.OpCode.getData);
    GetDataRequest request = new GetDataRequest();
    request.setPath(serverPath);
    request.setWatch(watcher != null);
    GetDataResponse response = new GetDataResponse();
    cnxn.queuePacket(h, new ReplyHeader(), request, response, cb, clientPath, serverPath, ctx, wcb);
}

// 内部注册流程
public void register(int rc) {
    if (shouldAddWatch(rc)) {
        Map<String, Set> watches = getWatches(rc);
        synchronized (watches) {Set watchers = watches.get(clientPath);if (watchers == null) {
                watchers = new HashSet();
                watches.put(clientPath, watchers);
            }
            watchers.add(watcher);
        }
    }
}

可以看到一个注册的过程分为两个步骤,第一步把自己需要监听的事件记录到Map中,第二步将需要监听的事件发送到服务端上。

在客户端的监听请求送达服务端后,服务端通过两个Map对监听事件进行管理:

private final Map<String, Set> watchTable = new HashMap<>();
private final MapSet<String>> watch2Paths = new HashMap<>();

一个是从节点路径维度关联watcher,一个是从watcher维度关联节点路径。上文中已经看到过了,在节点发生事物变更时,会触发服务端的watcher监听trigger,这时候服务端就要把发生的事件通知客户端。这里需要注意的一点是:

// 服务端回调客户端的内容
public WatchedEvent(EventType eventType, KeeperState keeperState, String path) {
    this.keeperState = keeperState;
    this.eventType = eventType;
    this.path = path;
}

服务端回调客户端仅仅包含最基础的内容,节点路径,节点状态,事件类型,也就是说客户端只会知道这个节点发生了自己感兴趣的事件类型,想要拿到节点变更后的信息,则需要再次请求server端,server端的回调操作非常的轻量。

四. 实际应用

上文对zookeeper的原理做了非常非常简略的介绍,下面看一下其实际应用有哪些。

1. 管理配置信息

如果将某些配置注册在zookeeper上,那么在服务启动的时候先会拿到目前的配置信息,同时向服务端注册一个watcher,通过上文的描述我们知道,在节点发生变更时,服务端会主动通知客户端。如此一来,配置信息发生变更的时候,客户端会及时拿到最新的配置。

2. 命名服务

基于zookeeper,应用可以基于命名拿到真实数据,比如一些rpc框架,应用可以根据名字拿到服务提供者的真实信息,比如ip端口,URI等。
再比如为一个应用配置一个数据库节点,如果mysql主库挂了,在从库顶上后,应用可以迅速重新连接,不用更改任何配置。

3. 分布式锁

可以基于zookeeper实现一个比较完美的分布式锁,前两天一位同事分享了基于redis的分布式锁,基于redis的分布式锁一个比较困难的问题是锁续期的问题:

  • 锁时间设置过长,如果线程挂了锁就一直不会释放。

  • 锁的时间过短,则可能任务没做完锁就释放了。

为了解决上述问题还需要引入新的线程去监控任务线程。
如果使用zookeeper,每个资源使用方只需要建立临时节点同时注册watcher,等待节点变更通知即可,

  • 假如正常执行完,任务线程会将节点剔除。

  • 假如拿锁的线程在执行过程中挂掉,zookeeper会将其自动剔除,则下一个ID可以拿到锁。

这种操作在锁竞争压力大的时候,可能会出现惊群效应,依然可以通过zk解决,这里就不展开了。

4. 其他

zookeeper在很多大家耳熟能详的应用中有着作用,例如:我们日常使用的dubbo,推荐使用zookeeper作为注册中心,kafka使用zookeeper对broker进行管理,以及用zookeeper维护broker和topic的对应关系。

五. 总结

本文简述了zookeeper的几个基本概念:

  • 节点的数据结构: ConcurrentHashMap

  • 节点的写入过程:维持节点父子间的对应关系,同时将节点加入Map大家庭中。

  • Watcher机制:客户端向服务端注册需要监听的事件,服务端在检查到事件发生时通知客户端,客户端再去服务端查询到底发生了啥。

  • zookeeper应用:分布式锁,命名服务,注册中心等。

本文只是简述了zookeeper的非常基本的概念,甚至连Leader选举都没有讲,如果有兴趣的小伙伴欢迎自己查阅资料或者阅读源码研究,感谢阅读。

end

7a7bdbf3830d68be30a49e09d8060707.png

ded56d32f4e52d3cc934fb19740deae2.png在看点这里 6d2ccd0c0987e3ad439d7415cf6500a9.gif
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值