那些年,我们一起踩过的ZooKeeper的坑

背景介绍

因为项目中使用到了ZooKeeper作为服务发现,对这个比较通用的ZooKeeper项目进行了半年多的调研和使用。

ZooKeeper的基本介绍已经有很多资料,所以这篇文章不做介绍了。可能是因为ZooKeeper是使用Java开发的,很多项目都是以Java项目为背景来讲解ZooKeeper的注意事项,因为Java有成熟的API,CuratorZkClient,尤其是Curator,简直是ZooKeeper开发必备神器,可惜我们的项目是C++的,而C++写的好用的API几乎没有,于是只能自己动手造轮子,写了一个C++的ZooKeeper API,具体可以移步《Zookeeper C语言API封装和注意事项》

这篇文章主要讲一下开发和运营过程中,因为不熟悉ZooKeeper的机制,在开发和运维阶段容易踩入的坑。

开发中的坑

Watcher机制

ZooKeeper的Watcher,实际上分为节点子节点两个类型,再加上注册Watcher的时候提供的路径Context回调函数地址,这四个变量的值确定了唯一一个Watcher。

而ZooKeeper还提供一种全局Watcher的注册方式,使用全局Watcher则相当于Context和回调函数地址都使用初始化时传入的那个全局Context和回调函数地址,是一种特殊情况。

ZooKeeper的Watcher另一个特性是,只触发一次,所以要尽量避免漏消息的情况发生,如果Watcher触发后还需要继续监听,则需要重新注册一次。
Watcher的信息量比较大,因此不可能将所有的信息都传给服务端,实际上服务端只会保存一个标志位,比如这个路径存在节点或者子节点的Watcher,如果有触发,那么就会通知客户端,并且删除标志位。
客户端的处理也是类似的,只是额外存储了Context和回调函数地址,Watcher触发时,调用回调函数,并且删除Watcher记录。

客户端的回调会在同一个线程中串行执行,因此,回调里不能有太耗时的操作,否则会影响后面所有回调的触发,包括异步接口的回调,也是一样,不能有太耗时的操作。

因此,在使用过程中可能会踩入下面的坑:

  • Watcher触发后没重新注册,不会触发第二次。
  • 使用同样的参数,同样的方式注册多次,只会触发一次。
  • Exist和Get使用相同的参数重复注册,实际上只会触发一次,Exist和Get的区别只是Exist在节点不存在的时候能够注册Watcher,在节点创建的时候触发节点CREATE事件,而Get在节点不存在的时候无法注册Watcher。
  • 使用Exist、Get和GetChildren使用全局回调注册同一个节点,如果节点删除,只会触发一次Watcher。
  • 如果使用Client ID重连已存在的Session,所有的Watcher会失效,需要重新注册。

以上只触发一次的Watcher的情况在实际项目开发中都需要对本地的数据进行特殊处理,避免出现丢Watcher的现象发生。

Multi机制

ZooKeeper提供批量操作接口,但是它并不是事务操作,也不是原子操作,只是简单的顺序操作,而且最关键的一点是,如果批量操作中有一个操作未成功(返回值非ZOK),则后面的所有的操作都不会执行,在结果中均返回-2,整个批量请求的返回值为首个失败的值,并且前面执行成功的操作不会回退

返回值例子如下图所示,前面的Set和Create操作成功,但是因为Exists操作失败,后面的3个操作均不执行,返回-2。

因此,如果使用批量操作,如果正常情况下不是全成功的话,需要了解一下可能出现的这个烂摊子。
当然,也可以根据这个策略,正确使用,比如在我封装的API中,递归创建节点,就是用的批量操作递归检测目录是否存在。
比如要创建节点”/a/b/c/d”,那么就发一个批量请求:
Exists “/a”
Exists “/a/b”
Exists “/a/b/c”
Exists “/a/b/c/d”
如果成功,则表示节点已经存在;如果失败,则找到第一个失败的节点,比如“/a/b/c”,表示这个节点和后面的节点都不存在,那么再发送一个批量请求,将不存在的节点全部创建好:
Create “/a/b/c”
Create “/a/b/c/d”

其他的坑

除了上面的必须要了解的坑,如果想尝试封装API,可能还会遇到临时节点,重连,解除重注册Watcher、ACL的控制,使用Client ID恢复Session等一系列的坑,详细可以参考《Zookeeper C语言API封装和注意事项》

运维中的坑

磁盘满进程自动退出

由于ZooKeeper设计的时候就是以保障集群可用性作为目标,因此单节点的可用性并不是特别关心,所以一旦单节点出现异常情况,比如不同步或者磁盘满了,那么单台ZooKeeper会采取比较激进的处理方式:

  • 如果某个节点与Leader不同步了,根据ZAB协议,它将直接被踢出集群
  • 如果某个节点磁盘满了,那么进程会自动关闭
  • 如果数据异常了,那么它将无法启动

这样做的好处是处理简单,并且逻辑统一,而且偶然的单节点事故并不会对集群的可用性造成影响,其他的运维操作交给运维管理员根据各自的情况处理。
所以在运维过程中,需要对节点的可用性做监控,比如发送四字命令”ruok”进行进程存活判断。如果进程不正常了,需要自动拉起或者告警,如果无法拉起或者反复出现死机的情况,则需要人为干预修复。
因此,ZooKeeper的运维工作是必不可少的,毕竟ZooKeeper在项目中一般都处于关键路径,集群挂了的话,整个项目服务都可能瘫痪。设计中也可以“留一手”,比如在服务发现中缓存ZooKeeper的配置,即便ZooKeeper挂了,还能够使用缓存中的数据。

进程日志默认不滚动

ZooKeeper使用log4j库进行程序日志输出,默认情况是打印到控制台,使用自带的脚本启动会将打印信息重定向到zookeeper.out文件中,并且要命的是,这个日志文件不会进行滚动,于是等待你的可能是一个数十G的zookeeper.out文件。
因此,需要对ZooKeeper的日志配置部分进行修改:

 

事务日志和快照文件默认不清除

ZooKeeper设计者为了避免数据丢失,对事务日志和快照文件采取了一种比较保守的策略,即默认情况下永不删除。但是提供了删除不必要的日志的工具和配置,所以在ZooKeeper服务启动前,也需要做这项工作,否则日志文件会将磁盘撑满,然后ZooKeeper进程退出。

这里简单介绍一下ZooKeeper的事务日志文件和快照文件,事务日志文件和快照文件类似于Redis的AOF和RDB,详细资料可以参考ZooKeeper Administrator’s Guide-Data File Management

  • 事务日志文件
    每次写入事务都会写到事务日志文件中,保存在配置文件中的dataLogDir中,如果不存在,则保存在dataDir中,文件名为”log.[起始事务zxid]”。
    因为它对写操作影响很大,所以官方强烈建议将其放到一个单独的物理磁盘中,避免对写入性能造成影响,如果只是作为服务发现,基本没什么事务日志的话,就可以不用这样操作了。
    这个文件默认情况是64M一个,在配置文件中可通过preAllocSize参数配置,单位为KB。如果写满了或者重启或者生成了新的快照文件都会重新生成一份,旧的不会删除。因此时间长了,可能会产生上百G的日志文件。
  • 快照文件
    快照文件保存了一个时期所有的数据,快照加上事务日志文件,就可以恢复快照后任意zxid的数据版本,它保存在dataDir中,文件名为”snapshot.[起始事务zxid]”。
    快照的生成默认是每达到一定次数的事务就会重新生成一份,这个数值的配置项为snapCount,生成快照所需事务为snapCount/2到snapCount中随机选择一个值,避免集群中所有的节点同时开始生成快照。默认为10万,表示5到5W次事务会生成一次快照。

还好ZooKeeper提供了很方便的清除过期数据文件的方法,一般是设定保留N份快照文件以及这些快照文件恢复所需的日志文件。
清除过期日志文件目前有两种方法:
1、定期调用bin/zkCleanup.sh,用法为:

参数count表示保存的快照数量,必须>=3。

2、3.4版本后可以在配置中配置清理参数:
autopurge.snapRetainCount:表示保存的快照数量,必须>=3
autopurge.purgeInterval:自动清理的间隔时间(小时),必须>=1,为0表示不进行自动清理。
配置好之后,重启ZooKeeper,会进行第一次的数据清理工作,之后每隔autopurge.purgeInterval小时会进行一次数据的清理。

参考资料

Zookeeper C语言API封装和注意事项

背景说明

Zookeeper在分布式系统中扮演一个协调和少量数据存储的服务,具体可见维基百科的介绍,它提供了Java和C的简单接口供开发者使用。简单来说,可以将Zookeeper看做一个分布式的文件系统,它提供一个树形结构存储,每个节点可以存储信息,同时提供Watcher机制可以像Linux文件系统inotify那样监控文件系统的变化。
最近有机会使用Zookeeper作为项目的配置管理存储服务,项目使用C++作为主要开发语言,由于原生C接口提供的功能非常简单和底层,比较难用,我决定封装Zookeeper提供的C接口,将使用原API过程中遇到的一些问题记录总结下来,可以帮助到需要使用Zookeeper C语言API的读者。

封装API地址

封装好的API的地址:
https://github.com/godmoon/CppZooKeeperApi/blob/master/CppZookeeper.h
https://github.com/godmoon/CppZooKeeperApi/blob/master/CppZookeeper.cpp

单元测试用例地址:
https://github.com/godmoon/MoonLib/blob/master/gtest/src/CppZooKeeperTest.cpp

ZOOKEEPER接口介绍

Zookeeper提供的的接口主要集中在头文件/src/c/include/zookeeper.h中,其中主要包括以下方面的内容:

  • 常量类型
    • 全局错误码:ZOO_ERRORS
    • ACL(Access Control List,权限控制列表)控制参数
    • 日志级别:ZooLogLevel
    • 事件类型:ZOOKEEPER_WRITE,ZOOKEEPER_READ
    • 节点创建类型:ZOO_EPHEMERAL,ZOO_SEQUENCE
    • 连接状态:ZOO_*_STATE
    • 事件类型:ZOO_*_EVENT
  • 所需的数据结构和类型
    • 句柄:_zhandle
    • 会话ID:clientid_t
    • 批量操作相关:zoo_op,zoo_op_result
    • Watcher的定义:watcher_fn
    • 回调函数的定义:*_completion_t
  • API接口
    • Zookeeper初始化和连接:
      • zookeeper_init
      • zookeeper_close
      • zookeeper_interest
      • zookeeper_process
      • is_unrecoverable
      • zoo_add_auth
    • 批量操作类型生成
      • zoo_*_op_init
    • 内部数据操作接口
      • zoo_client_id
      • zoo_get_context
      • zoo_set_context
      • zoo_recv_timeout
      • zoo_set_watcher
      • zookeeper_get_connected_host
      • zoo_state
      • zerror
      • zoo_set_debug_level
      • zoo_set_log_stream
      • zoo_deterministic_conn_order
    • 节点操作接口
      • create系列:zoo_acreate,zoo_create
      • delete系列:zoo_adelete,zoo_delete
      • exists系列:zoo_aexists,zoo_awexists,zoo_exists,zoo_wexists
      • get系列:zoo_aget,zoo_awget,zoo_get,zoo_wget
      • set系列:zoo_aset,zoo_set,zoo_set2
      • get_children系列:zoo_aget_children,zoo_awget_children,zoo_aget_children2,zoo_awget_children2,zoo_get_children,zoo_wget_children,zoo_get_children2,zoo_wget_children2
      • 强制节点同步Leader:zoo_async,在切换到一个Observer节点时可能会出现读到老数据,调用此接口能强制让连接的节点同步Leader。
      • acl控制接口:zoo_aget_acl,zoo_aset_acl,zoo_get_acl,zoo_set_acl
      • 批量操作接口:zoo_amulti,zoo_multi
Zookeeper库

Zookeeper提供了多线程和单线程库。
多线程库会在内部创建2个线程,一个用于IO进行事件驱动,一个用于回调和Watcher的调用。
单线程需要用户调用zookeeper_interest来进行事件驱动,回调和Watcher均在zookeeper_interest中完成。
二者的接口一致,内部有适配器进行匹配。

由于多线程库操作起来比较简单,选择使用多线程库进行封装。需要注意的是所有的回调和Watcher内部对于线程间可能会同时操作的数据需要加锁保护。

需要的功能

了解了接口之后,对于C++ API的封装可以从以下几个方面着手:

  • 封装句柄的连接和释放。
  • 封装一系列内部数据操作接口。
  • 封装节点操作接口,节点操作接口不仅提供基础操作,还提供了异步、注册自定义Watcher、带额外节点信息等衍生接口,异步的在操作名称前面带’a’,注册自定义Watcher在名称前面加’w’,部分带Stat信息的接口在名称后面带2。在封装API接口的设计中可以通过默认参数简化接口,提高易用度。
  • 断线重连的状态恢复,比如临时节点和Watcher的恢复。
  • 由于原始API只支持函数指针方式回调,这样限制了接口的参数,因此封装的API要能够支持函数对象回调。
接口使用注意事项

接口封装的整体结构图如下:


在接口实现的过程中,有一些需要特别注意的地方:

  • 异步请求
    异步请求使用函数对象进行回调,封装API内部实现了所有的原始API的回调函数,用户在调用封装API接口时,会生成一个context上下文信息,用于保存本请求的一些数据,比如用户的回调函数对象。
    请求流程序列图如下:

  • Watcher注册机制
    Watcher的管理机制是这个API封装最复杂的地方,为了制定一个合理的封装方案,必须要先了解清楚Watcher的管理机制:

    • 对于同一个节点,Watcher是可以存在多个的,但是这个机制比较复杂,它存在三个数据,组合起来作为一个Key:
      • Watcher类型(GetExists,GetChildren)
        • Get:触发ZOO_DELETED_EVENT和ZOO_CHANGED_EVENT事件。
          当节点不存在时,无法注册Watcher,这点要和Exists区分。
        • Exists:触发ZOO_CREATED_EVENT、ZOO_DELETED_EVENT和ZOO_CHANGED_EVENT事件。
          这里还要考虑到节点不存在时,Watcher也是可以通过Exists接口注册成功的,在处理ZOO_DELETED_EVENT事件时,如果这个节点注册了Exists事件,那么这个事件需要重注册。
        • GetChildren:触发ZOO_CHILD_EVENT事件和ZOO_DELETED_EVENT事件。
          对于ZOO_DELETED_EVENT事件,Exists和GetChildren使用同样的Watcher和参数注册后,如果节点被删除,这个事件只会触发一次。
      • Watcher回调函数,自定义的和全局的不同,不同的自定义回调函数地址也算不同。
      • 传入的自定义数据指针地址。

    同样的参数,使用同样的方法注册2次,只会触发一次,因此封装的API在管理Watcher时,也需要排除重复。

    内部实现,可以将Get和Exists看做是对节点的事件注册,GetChildren就是对节点的子节点的事件注册。区别在于Exists允许节点不存在的时候注册事件,当节点创建时就会触发ZOO_CREATED_EVENT事件,而Get只允许在节点已经存在的时候注册事件。这里就会有一个问题,既然Get和Exists在内部都是针对节点进行事件3注册,如果Get和Exists使用同样的回调函数和参数注册,那么ZOO_DELETED_EVENT或ZOO_CHANGED_EVENT事件仅会触发一次。因此,如果需要自动注册Watcher,需要把Get和Exists合并管理,而且全局回调和自定义回调的处理方式也有所不同。

    在Exists、Get和GetChildren接口以及对应的异步接口中,需要对Watcher数据进行管理,以便可以进行关键功能重注册操作。将Watcher管理起来的一个必要条件,是Watcher生效,只需要请求成功了,那么Watcher就会生效。一个例外是,对于Exists接口,返回码为ZNONODE时,Watcher也会生效。
    实现上,同步接口可以在调用原始API后判断返回码直接添加Watcher。而异步接口需要在异步回调中判断返回码再添加Watcher,相关的信息可以在异步请求时加入到Context中,在回调中读取出来使用。

    这里可以稍微改进一下,将Watcher分为节点和子节点,所有节点的重注册,均使用Exists,而不再使用Get了,这样做能够将问题简化不少,API使用者也无需关注Exists和Get的区别了,只需要知道,节点Watcher在节点删除后,Watcher还是可以重注册,而子节点Watcher在节点删除后,不会再重注册了。

  • 断线重连
    Zookeeper API内部实现了断线重连功能,默认情况下,断线后,每隔10秒会尝试重连一次,重连后相关的Watcher仍然会生效,但是前提是服务端保存的Session并未失效,如果服务端Session已经失效,那么在重连成功后会收到ZOO_SESSION_EVENT事件,此时连接状态为ZOO_EXPIRED_SESSION_STATE,后续的操作都会失败,返回错误码ZSESSIONEXPIRED。
    此时需要手工重连直到成功,重连成功后,在全局Watcher中会收到ZOO_SESSION_EVENT并且状态为ZOO_CONNECTED_STATE的事件,这里要重新注册所有的Watcher并且恢复临时节点,因此如果使用到了Watcher和临时节点功能,这部分工作在封装的API中也是必不可少的,可以使用内部的全局Watcher来完成这一系列的操作。
    而实现此功能,就需要记住下面所有的信息:

    • 用户所有的临时节点信息:路径、数据、ACL和Flag,关于临时节点的操作,后面有单独的小节来说明。
    • 全局Watcher函数对象以及上下文数据。
    • 注册到全局Watcher上的所有路径以及对应的注册方式(Get、Exists和GetChildren)。
    • 用户自定义Watcher函数对象以及上下文数据。

    以上数据均可以使用Map存储于封装后的API中,当操作检测到Session失效(收到Session状态变为ZSESSIONEXPIRED),进行重连并且重新注册所有的Watcher,创建所有的临时节点。注意这些数据的操作如果在全局Watcher中进行,意味着它与用户调用的接口处于不同的线程,需要进行加锁保护。

  • Watcher自动重注册
    每个Watcher触发一次之后,就不会再触发第二次,如果仍需要触发,需要再次调用Watcher注册函数进行再次注册,如果需要API自动重注册,需要考虑到以下几个问题:

    • 回调是全局回调还是自定义回调?
      这个可以通过传入的参数data来区分,在data中保存相应的函数对象即可。
    • 前面说过,对于一个路径,Get和Exists注册的回调只会触发一次,内部实际上都是当做节点的事件注册,那么用户是希望再次注册是使用Exists还是Get呢?
      Get和Exists触发的事件,只有ZOO_CREATED_EVENT是有区别的,而这个仅在节点不存在时调用Exists才能够注册成功。按照原始API的实现方式,调用了Exists,如果节点存在,相当于调用了Get进行注册;如果节点不存在,那么仅注册ZOO_CREATED_EVEN。
      对于全局Watcher的记录,不同的注册类型使用位记录,设计时可以把Exists类型值包含Get类型:
    • 而对于自定义Watcher,只需要将路径对应的上下文保存下来即可,因为每次注册自定义Watcher,都会创建一个完全不同的上下文实例(指针值不同),因此不会出现Exists和Get冲突的问题。
    • 如何停止重注册?何时进行重注册?
      如果要实现重注册功能,就必须要提供相应的方式让用户决定停止重新注册,这个可以通过回调函数的返回值来实现,如果在回调后根据返回值来决定是否重注册,就会涉及到一个问题,注册Watcher必须调用Get、Exists或者GetChildren函数,这些函数会对本地缓存产生影响,如果调用用户回调和重注册之间,数据变了,那么用户可能就失去感知数据变化的能力,导致Watcher丢通知的情况发生,所以只能选择在回调函数前重注册,如果用户不需要重注册了,需要将Watcher从Zookeeper API和封装API中删除。
      然而原生API并没有提供这样的功能,通过原生API的代码,可以知道它把Watcher存储在zhandle_t中的3个zk_hashtable中:

      hashtable中存储的是watcher_object_list,这是一个watcher_object_t的单向链表,而watcher_object_t中就存储的是最终的数据:Watcher回到函数指针和context指针,还有链表下一个节点的地址。相关代码在zk_hashtable.c中:

      通过函数collectWatchers可以了解到客户端触发Watcher的方式:

      上面的函数根据不同的Watcher类型,从3个hashtable中取出对应路径的watcher,加入到list中,并且删除原hashtable中的记录。刚开始我是用这个函数去获得需要删除的Watcher,但是这个函数只能把对应路径中所有的Watcher一次性全部删除,这个并不符合我们的要求,一个路径可能对应多个Watcher,不能全部删除,否则会出Bug。
      了解了数据结构和数据对象,就可以写出删除指定context的Watcher的方法了:

      对于内部的数据结构,因为原生API并没有暴露出来,这里我们只能很恶心地把它重定义一份,其中需要注意的是,zk_adaptor.h函数中的_zhandle定义,是区分多线程和单线程的,多线程的库在编译前定义了宏THREADED,里面多了一些线程相关的变量,导致这个宏会影响到最终_zhandle的结构,所以在包含zk_adaptor.h前,一定要定义THREADED宏,否则变量内容都是错误的。

      另一个需要注意的情况是节点删除时的处理,因为节点被删除,Get和GetChildren类型节点无法重注册
      自动重注册在提供一定的便利性的同时,也会导致多调用一次API接口用于注册Watcher,这个在不同的项目场景下有不同的取舍。后续可以将这个接口获得的数据传递给用户的Watcher,从而减少用户可能进行的重复数据获取操作。
      Watcher的管理流程时序图如下:

  • Multi操作的机制
    Multi操作会按照用户传入的顺序进行操作,当遇到第一个失败时,操作即停止,后续的操作不再进行。返回的zoo_op_result_t中,前面成功的操作返回码为0,失败的操作返回码为该操作的返回码,后面所有的操作均返回ZRUNTIMEINCONSISTENCY(-2),表示并未执行。

  • 临时节点的维护
    临时节点的维护也并不简单,并不是用户创建了临时节点,在封装API中记录下来就完事了,后面如果用户对这个节点进行写操作(Set、Delete、改变ACL)时,都需要对记录的信息做修改,在Multi操作的封装中,也要对每个操作进行同样的判断,否则会出现Session失效重连恢复的状态和断开连接之前不一致的情况。

    • 在同步操作中,如果请求成功了,则更新临时节点数据。
    • 对于异步操作稍微麻烦一些,需要在异步回调中判断操作是否成功,如果成功了,才更新临时节点数据。
    • 对于Multi操作,需要对结果进行遍历,只更新成功的操作部分。
  • 原始API数据结构的内存管理
    Zookeeper C语言API对于内存管理比较简单粗暴,对于用惯了C++的同学来说会感觉有点麻烦,甚至会遇到一些坑,表现在部分同步操作返回的结果需要用户在使用完毕后手工释放资源,而异步操作返回的结果由Zookeeper API调用回调之后自动释放,具体问题和解决方法如下:

    • 在封装API析构函数中调用zookeeper_close来关闭连接。
    • get_children系列函数获得的String_vector对象在每次调用完成后需要调用方释放。这里有2个问题,一个是需要手工释放,第二个是不可重复使用
      对于第一个问题,可以封装一个自动释放的类型ScopedStringVector,为了兼容之前的接口以及使用方便,采用继承而不是组合,因为有自动释放,所以禁用赋值、拷贝、转移构造函数,当然你也可以自己重新申请内存进行深拷贝。
      对于第二个问题,封装接口zoo_get_children,在使用前对传入的ScopedStringVector调用Clear()函数释放内存。
      ScopedStringVector不带深拷贝的代码如下,其中deallocate_String_vector位于zookeeper.jute.h中,包含zookeeper.h时会自动包含:
    • 另一个需要调用后手工释放的类型是ACL_vector,和String_vector一样,封装一下,让它带析构自动释放功能,并且封装zoo_get_acl函数,在每次使用前调用Clear()释放内存。
  • 封装API的内存管理
    封装的API会显式申请堆内存保存以下数据:

    • 全局Context
    • 所有异步调用的Context
    • 所有Watcher的Context
    • 异步操作临时节点时,保存临时节点的信息用于成功后修改临时节点数据
    • 批量操作的路径和数据信息

所有的堆内存几乎都使用std::shared_ptr进行管理,在结构失效时,自动释放内存,大大降低了内存泄露的可能性。
API也经过了全面的测试用例的测试,使用valgrind并未检测出内存泄露。


  • 由于我们封装的是多线程的API,如果在Watcher或者回调函数和调用线程中同时对内部成员对象进行操作,那么就需要使用线程锁对成员对象加以保护,因为在Watcher或者回调函数是由Zookeeper API创建出来的线程调用。
    为了避免Zookeeper线程调用接口时对同一个锁多次加锁导致死锁,建议使用可重入锁(递归锁,C++中为std::recursive_mutex)进行保护,同时设计上避免死锁的情况发生。
  • 二进制值
    Zookeeper节点的值是二进制格式,因此如果存储的是字符串的话,如果末尾不包含’\0’,通过Get获得的值是不包含结束符的,如果直接使用字符串操作函数,很有可能会存在溢出问题,因此API提供了获得C语言风格字符串值的接口GetCString方便用户调用。
  • 递归接口
    原始API是不包含递归接口的,意味着递归创建或者删除路径需要比较复杂的操作。而递归操作在很多操作中都有比较重要的作用,比如Session过期后重连恢复临时节点时,需要创建临时节点的父路径。
    封装的API内部使用Multi接口实现了递归创建和删除节点的功能。
    对于递归创建接口,先使用Multi接口的Exists操作逐个判断节点是否存在,从第一个不存在的节点处,将后面的节点全部使用Multi接口的Create操作创建。
    对于递归删除接口,使用深度遍历,将要删除的路径的所有子节点遍历出来,然后逆序添加到Multi中进行批量删除。
    这里的实现过程中也需要考虑到临时节点信息的维护,如果删除接口中某个临时节点被其他连接删除了,那么在Multi中不会对这个临时节点数据做删除,所以连接重连后会重新创建临时节点,这是我们不希望看到的。因此需要在删除的递归接口中对所有临时节点信息进行遍历,如果临时节点的前缀与删除的路径一致,说明用户希望删除这个临时节点信息,此时可以将这个临时节点信息删除。
  • 相对路径
    原始API中已经支持相对路径,但是相对路径对于内部维护Watcher和临时节点并不方便,使用绝对路径不容易出错,因此在封装的API中,自己维护了相对路径的处理,所有操作都会在内部通过ChangeToAbsPath接口转换为绝对路径后再传给原始API处理,即没有用到原始API的相对路径功能。
  • 使用Client ID恢复Session
    ZooKeeper每个连接在Server端都会使用一个Client ID进行标识,客户端在连接Server成功后可以获得这个Client ID和对应随机生成的密码。原始API也支持在连接的时候传入Client ID和密码。这个Client ID和密码是用来重连恢复Session的。
    当程序意外退出,但是连接没关闭,Server端并不会释放Session,Session管理的临时节点也不会被删除,而是等到Session超时时间达到后再进行释放。
    此时如果服务重启,可以使用之前的Cliend ID和密码进行连接,恢复Session。如果Session已经超时,全局Watcher会收到Session超时的事件。如果Session恢复成功,那么用户端需要重建所有Watcher和临时节点信息。

    • 对于Watcher,重新注册即可。
    • 对于临时节点,不能删除原有的临时节点,而是要进行“托管”,即,如果临时节点存在,并且节点的OWNER(可通过Get节点的Stat数据获得)是自己的Client ID,则不作处理,将其加入到内部临时节点数据即可。
测试

在封装API的测试上,使用了gtest测试框架,辅以覆盖率数据作为参考,尽可能提高覆盖率,目前测试用例的覆盖率在80%以上。
对于ZooKeeper的超时重连的测试,在测试用例中使用iptabls可以模拟ZooKeeper与客户端之间断线:

具体代码可以参考测试用例代码:
https://github.com/godmoon/MoonLib/blob/master/gtest/src/CppZooKeeperTest.cpp

后续工作

上面对于Zookeeper的特性都只是建立在白盒测试观察的基础上,还有很多问题待解决,比如对于异步回调函数,如果请求发出后,没有回包,回调函数是否还会调用?如果不调用,参数的内存是否能够释放?如果要确定内部工作方式,还是需要花多点时间研究一下源代码才能确定,这部分后面再补充。

参考文档

zookeeper使用笔记
Zookeeper C API 指南二(监视(Wathes), 基本常量和结构体介绍)
zookeeper c 客户端源码分析以及使用注意点

使用Epoll实现简单的Echo Server和Client,附带性能分析和性能数据(一)

背景介绍

IO多路复用以其高性能、高并发、低系统开销等特性成为了后端Server框架开发的必备技术。
本文给出一个使用Epoll的Echo Server实现,读者很容易就可以将其扩展到自己的模块中去。
同时本文也给出了使用Epoll的客户端实现,读者可将其改造为一个性能压测工具。相比于别的Epoll示例,本文客户端实现使用单线程IO多路复用,也支持多线程,只是在Echo Server本机测试这种IO压力不大的CPU压力场景下,由于CPU线程切换的开销存在,性能并不如单线程的好。
所有代码只保留最必要保留的部分,对其中一些操作做了详细的注释,避免踩坑。

代码地址

由于代码会更新,实际代码与本文用于测试的代码会有行数不一致的情况,性能差别应该不会太大。

https://github.com/godmoon/MoonLib/blob/master/src/CppNet.cpp
https://github.com/godmoon/MoonLib/blob/master/src/CppNet.h
https://github.com/godmoon/MoonLib/blob/master/gtest/src/CppNetTest.cpp

代码说明

代码在gtest测试框架下运行,实现功能如下:
主线程启动服务端线程和1个客户端线程(支持多个客户端线程),每个客户端线程和服务端线程都管理一个Epoll池。客户端线程中的Epoll池管理多个客户端连接。
每个客户端连接以网络序发送当前微秒级别UTC时间戳到服务端,每个请求8个字节,服务端收到包后将数值加1并且返回,客户端对比发送的数据和接收的数据,正确则进行成功计数,否则进行失败计数。
主线程每隔一秒打印上一秒成功和失败的请求数以及成功率,运行指定秒数后,通知客户端结束,所有客户端线程结束后,通知服务端线程结束,主线程结束。

测试结果

在我本地Ubuntu14.04虚拟机上测试30秒结果大约为17~20W/s的请求量:

压测过程中服务器状态

在压测过程中,要考虑到性能瓶颈在哪儿,在Linux下,3大性能指标:磁盘IO,CPU,网络,下面我们逐一看看性能瓶颈在哪儿。

  • 磁盘IO
    代码中并没有显式对磁盘进行操作,在压测过程中通过命令”dstat -cdlmnpsy”得到结果如下:

    查看-dsk/total-列,发现并没有大量的磁盘读写,磁盘很平静,符合理论预期。
  • CPU
    由于测试属于本机压测,网络不存在太大问题,猜测性能瓶颈很有可能出现在CPU,压测环境的CPU为4核i5-3470,虚拟机也分配了4个核,所以虚拟机理论上可以充分利用主机的CPU资源。
    通过命令”top”可以看到压测进程”MoonLibTest”的CPU占用保持在190%以上,如下所示:

    这是因为客户端和服务端各为一个线程,每个线程可以占用一个核的100%的资源,加起来最多占用200%,在top页面按下大写的”H”,或者shift+h,可以看到线程的资源占用情况:

    其中前2个占用CPU 99%左右的线程分别为客户端和服务端,最后一个线程52941为主线程,代码中为启动客户端线程的线程,目前阻塞在thread::join()处,不占用CPU。
    所以目前单进程情况下,客户端和服务端基本已经处于CPU满负荷状态。
  • 网络
    对于网络这块的分析,由于陷入了一些误解,导致用了好几个小时来解决问题。下面完整讲一下分析过程,前面的部分基本上都是错的,后面会逐渐纠正前面的认识错误。
    一开始,我错误地认为由于是本机压测,每个包收发各8个字节,20W请求,猜想网卡吞吐量应该是
    8Bytes*200000/s=1600000Bytes/s,差不多1.5MB/s
    结果通过命令”dstat -N lo”可以查到当前环路网卡lo的数据吞吐量如下所示:

    结果显示网卡流量居然有23MB/s,出乎意料,难道是dstat命令统计错了?再通过命令”sar -n DEV 1 100″得到如下结果:

    查看rxkB/s列和txkB/s列,果然是将近23MB/s。
    通过网络数据的原始值/proc/net/dev查看,执行命令”while true;do cat /proc/net/dev;sleep 1;done”,也得到一样的结果。
    通过命令”sudo tcpdump -i lo port 20000 -w ~/tmp/test.cap”抓包,才想起来我居然忘了包头,每个包有Mac帧,IP头,TCP头以及数据,这四部分分别占用字节数如下:
    Mac帧:14字节
    IP头:20字节(无选项)
    TCP头:20字节+选项12字节=32字节
    数据:8字节
    应答包和上述一样,所以请求包和应答包应该是各74字节,在计算1KB以上数据的大包时包头大小可忽略,但是8字节的小包,包头长度就是数据长度的好几倍,不可忽略包头大小。
    这样算下来吞吐量应该是
    74Bytes*200000/s=14800000Bytes/s,约为14MB/s
    但是以上结果仍然与实际测试值23MB/s有冲突,而且差距还不小。
    还有一个疑问出现在包量的统计上,通过上面sar命令可以看到包量接收和发送均为40W/s左右,基本上是测试结果的2倍,这个问题我思考了很久,最后才想到,程序中统计请求量,是客户端一次收发完整请求,算一次请求量,每秒20W请求量,而对于网卡,一次请求量有一收一发,也就是2个包,因此包量是40W/s,符合理论分析。
    那么流量呢?也一样,一次请求,一收一发,都是走的本地环回网卡lo,于是一次请求对应2个包,统计的流量应该是单个包的翻倍,那么发送和接收的吞吐量按照计算应该是14MB/s*2=28MB/s,然而和实际的23MB/s的数据仍然有冲突。
    在寻找原因的过程中,无意中使用到iptraf这个工具,通过这个工具,查看到包大小为60字节:

    一开始我并没有注意到60字节这个数据,只以为是统计误差,于是我手工telnet到本机的一个服务随便发了点小数据,通过抓包得到了3个包,查看Length字段得到3个包大小分别为158、181和66字节:

    理论上统计的lo网卡接收数据量应该增加:
    158+181+66=405字节
    通过测试前后得到的lo网卡接收字节数(也可以通过ifconfig命令查看到)


    发现统计的接收字节数却只增加了:
    1681288224-1681287861=363字节
    二者差值为:
    405-363=42字节
    发现什么了吗?3个包差距42字节,平均每个包统计少了14字节,而联系到前面的包大小实际74字节,iptraf显示60字节,也是少统计14字节,而14字节刚好是Mac帧长度。可以猜测是lo网卡的流量统计并未计算Mac帧的长度14字节。但我并未找到相关文档对此进行说明,也不确定非lo网卡是否会计算Mac帧,这个留到后面补充。
    至此,重新计算理论流量:
    60Bytes*200000/s*2=24000000Bytes/s,约为22.8MB/s,和实际值相符。
    性能瓶颈是否在网络上,暂时还不确定,因为还不知道本地环回网络处理的性能瓶颈是多少。可以通过提高数据包大小来测一下吞吐量是否提升。后面有方法了再补充。
  • 结论
    从上面的分析,可以得到瓶颈最可能出现在CPU上,如果提高服务端线程数,使服务端运行在多个CPU核,可能可以提高每秒请求量。
其他讨论
  • 在写本文之前,我也在网上找了很多Epoll的典型用法,但是很多文章存在以下问题:
    1. 很多文章都是复制粘贴的代码,并没有给出性能数据和性能数据的对比,也没有对一些参数的使用做详细解释。
    2. Epoll只应用在服务端,客户端使用多线程或者只是发送请求接收应答,没有体现出Epoll带来的性能提升。如果对于本文的服务端使用多线程阻塞方式客户端压测,大约只能达到4W/s的性能数据(我在这里曾经纠结了很久,一直怀疑是我的服务端写的有问题,完全没想到性能瓶颈在客户端,直到我用我的客户端代码去压测Redis只有4W/s左右的性能,而同样的环境下,官方基于Epoll的压测工具有12W/s左右的性能),典型值应该是10W/s以上。
  • 不同的线程、客户端数量对于测试结果的影响比较大,一般客户端数量从1开始递增,达到一定程度,由于服务端的性能受限,压测数据便不再增加,此时再增加客户端数量会导致额外开销从而使性能略微降低,比如本文中使用30个客户端能达到20W/s的性能数据,如果使用100个客户端则只有19W/s左右的数据,如果达到300个客户端,则性能数据降低到15W/s左右:

    客户端数量每秒请求量(万)
    11
    59
    1019
    10020
    30015
    50013
    100011
    50009
  • 本文测试的客户端为长连接方式,如果为短连接方式,每次请求会增加额外的TCP握手,性能会到多少呢?后面有时间补充。
  • 几个特别注意的点:
    1. 发送基础类型的数据,要使用网络序。本机测试可能没问题,不过哪天跨平台就可能产生服务端和客户端主机序不一致的问题,所以在发送网络数据时最好养成良好的习惯。遇到这种情况建议使用Protobuf等更加通用的协议。
    2. 服务端对于监听的端口要设置REUSEADDR,防止服务重启时端口处于TIME_WAIT状态而无法重新监听。
    3. 服务端要忽略或者手工处理SIGPIPE信号,防止客户端关闭后,服务端往Socket中写入数据导致服务端收到此信号导致服务挂掉。
    4. Epoll实现要监听EPOLLRDHUP:要考虑到对端断开连接的情况,对端断开连接的话,2.6.17及之后的内核版本会抛出一个EPOLLRDHUP|EPOLLIN事件,之前的版本会抛出EPOLLIN事件,此时通过read读取的返回值为0,所以可以用以下两种方式检测对端关闭:
      1. 2.6.17及之后的内核版本:监听EPOLLRDHUP和EPOLLIN事件同时发生,稳妥起见,可以在此事件中判断read()==0
      2. 2.6.17及之前的内核版本,在EPOLLIN事件中检测read()==0
    5. 服务端对于监听的端口以及链接端口要设置成非阻塞,避免服务端进程被阻塞,可能读者会有疑问所有的读写都是在Epoll告诉程序可读可写之后才进行的为什么还会阻塞呢?考虑这种情况:当写入缓冲区只剩下1K,而要写入的数据有2K的时候,阻塞状态会阻塞直到2K数据全部写完,而非阻塞状态只会写入1K并返回,所以非阻塞状态下的写入需要考虑写了一半数据的情况,本文中代码并未考虑这种情况。
    6. 由于本文提供的是多线程程序示例,客户端和服务端处于不同的线程中,最终压测的性能还受到CPU核心数的影响,可以采用设置CPU亲和力(Linux下使用taskset命令)的方法将不同的线程固定到不同的CPU核心上来提高性能,这个网上有一些资料,我后面再专门写一篇文章来表达一下我的看法。

C++11多线程——单生产者多消费者模型

背景简介

单生产者多消费者模型适用于生产数据无需占用太多CPU,而消费数据需要大量CPU运算并且计算任务没有冲突的场景,将消费者计算任务分配到多核中加速运算。

示例目的

模拟实际多线程中单生产者多消费者,实现多线程模型,充分利用多核CPU特性进行并行计算。

设计要点

  1. 一个生产者产生一个数字,多个消费者处理这个数字(将其值加到公共的Sum中)。
  2. 消费者处理需要模拟一定的延时。
  3. 消费者获得生产者生产的数据,需要对数据加锁,防止其他消费者同时计算。
  4. 消费者取走数据后要通知生产者生产新的数据。
  5. 目前实现的是长度为1的队列,存储在CurrValue中,即生产者产出数据只有1个。当值为0时表示队列空,生产者可以工作;当值不为0时表示数据内容,消费者可以取出数据,消费者在取出数据后,需要将其置为0表示数据已取出。
  6. 代码使用GTest框架运行,可以将TEST(CppThreadTest, ConditionVariable)改成main函数并去除相应的校验直接执行。

代码示例(含注释)

代码实现过程以及每行的详细注释。

内部机制解析

  1. 为什么要条件变量?使用其他方法可以吗?
    这里使用条件变量检测条件并不是必须的,主要是为了解决以下方法的缺陷:

    1. 在消费者线程中通过while的方式检测,这样会导致CPU忙检测。
    2. 在消费这线程中通过while的方式检测,在数据未准备好时,通过usleep延时一段时间,比如100ms,虽然解决了CPU忙的问题,但是这样会导致部分数据处理时间过长,延时过大。
  2. 条件变量调用的wait内部做了什么?
    调用条件变量的wait的线程需要先获得lock,内部会对锁进行unlock使得其他并行线程也进入到wait中,然后进入阻塞状态,直到收到notify通知,wait返回的线程会获得锁,但是并不一定会达到可用的条件,比如上例中消费者在处理完数据解锁后,锁被其他消费者抢走,此时生产者并没有生产好数据,此处就需要条件变量的第二个参数来判断条件是否成立,如果不成立,则重新回到wait等待中,在打开代码中所有流程输出语句后,可以看到下面的情况。
    生产19997———–
    线程15抢到锁
    线程15即将wait,释放锁
    线程15wait后,value=19997
    线程15通知生产者
    线程15释放锁,处理19997
    生产19998———–
    线程16抢到锁
    线程16即将wait,释放锁
    线程16wait后,value=19998
    线程16通知生产者
    线程16释放锁,处理19998
    线程0抢到锁
    线程0即将wait,释放锁
    线程8抢到锁
    线程8即将wait,释放锁
    生产19999———–
    线程0wait后,value=19999
    线程0通知生产者
    线程0释放锁,处理19999
    解析一下:

    1. 在线程16释放锁后,通知生产者,生产者并没有被唤醒,而被线程0抢到了锁。
    2. 线程0抢到锁之后,进行wait,wait对锁进行unlock,被线程8抢到锁,线程8进入wait状态后,继续释放锁,最终被生产者线程抢到锁,继续生产。
  3. wait还有一个重载可以不用传入第二个判断条件成立的参数,但是此时需要在wait外部进行判断:
  4. 在生产者最后完成所有生产任务,置Stop为true后,需要调用GetCV.notify_all();通知所有的消费者线程,不然可能会有部分消费者线程阻塞在wait中永远出不来。在消费者调用wait,拿到锁后,需要判断退出条件,因为最后生产者置退出标志位后通知所有线程,此时数据并未准备好,但是Stop标志位已经表示可以退出,此时消费者线程需要做退出操作。

待解疑问

  1. 目前GetCV和SetCV使用同一个Mutex,是否需要分别使用?
    楼主尝试过使用不同的Mutex,但是会被锁住。
  2. notify和unlock的调用顺序问题,谁先谁后?有什么区别?
    维基百科条件变量词条中描述如下,目前没有一个示例能看出区别,需要补充:

    即将离开临界区的线程是先释放互斥锁还是先notify操作解除在条件变量上挂起线程的阻塞?表面看两种顺序都可以。但一般建议是先notify操作,后对互斥锁解锁。因为这既有利于上述的公平性,同时还避免了相反顺序时可能的优先级倒置。这种先notify后解锁的做法是悲观的(pessimization),因为被通知(notified)线程将立即被阻塞,等待通知(notifying)线程释放互斥锁。很多实现(特别是pthreads的很多实现)为了避免这种“匆忙与等待”(hurry up and wait)情形,把在条件变量的线程队列上处于等待的被通知线程直接移到互斥锁的线程队列上,而不唤醒这些线程。

参考资料

Visual Studio 2013新建工程导入现有代码文件夹并且保持目录结构

本文提供了一个在Windows环境下使用Visual Studio 2013编辑现有源代码并且保持目录结构的方法。

本文使用VS2013中文社区版做示例(本版本为免费版,可在VS官网下载),其他版本的VS操作方式类似。

  • 打开VS2013,选择【菜单】-【文件】-【新建】-【从现有代码创建项目】

img_566f6a72198a4.png

  • 选择项目类型:Visual C++

img_566f6a820aa70

  • 项目文件位置:创建完成后会在这个目录生成sln,suo,vcxproj,filters和user等VS解决方案和项目文件,这个必须和源代码的根目录保持一致,否则不能导入文件夹结构。
  • 项目名称填所需项目
  • 最后去除勾选【在解决方案资源管理器中显示所有文件】,避免文件太多

img_566f6a87e9f8f

  • 因为代码是Linux代码,并不需要在Windows下编译,所以选择使用外部生成系统即可

img_566f6a905d740

  • 无需编译的话,直接选择下一步

img_566f6a96ec2b8

  • 完成项目生成向导

img_566f6a9d3469b

  • 项目生成中,如果文件较多,会需要一些时间

img_566f6aa306436

  • 如果源文件来源于网络映射盘(比如笔者这里使用的是Linux的Samba共享磁盘),会弹出安全警告,去除勾选选择确定即可。

img_566f6aa987432

  • 项目创建好后,在解决方案资源管理器中会出现所有筛选过后项目目录的源文件,是未分文件夹的。如果看不到解决方案资源管理器,可以在【菜单】-【视图】中打开。

img_566f6ab0d17e4

  • 上面不分文件夹的源文件并不是我们想要的,如果需要分文件夹,在解决方案资源管理器中点击【显示所有文件】图标,如下图所示。点击之后,会出现文件夹。并且会在文件图标上标示出该文件是否属于项目文件。

img_566f6ab6bc306

  • 如果源代码路径文件有更新,比如增加或者删除文件,在解决方案资源管理器中点击刷新按钮,即可看到最新的文件状态。如果要将新文件添加进项目进行管理,则在新文件上点击右键,选择【包括在项目中】即可,添加后文件图标也会做出相应改变。

img_566f6abc02fb8

  • 由于源代码是Linux下,Linux的头文件与VS默认的头文件不一致,会导致很多头文件找不到或者是不匹配,影响代码提示和阅读。解决方法是添加Linux的头文件到Include路径。下面是具体操作方法:
  • 在解决方案资源管理器中右键项目,选择【属性】。

img_566f6ac674752

  • 在弹出的属性页中,找到VC++目录,其中的包含目录填上Linux下的头文件目录。

img_566f6ad15c6e5

  • 关于Linux的头文件如何获得,可以在Linux下使用Samba服务端,执行下面的命令后将所需的头文件复制到用户目录(保证用户目录没有usr目录),在Windows上连接Samba,将usr目录复制出来。
  • 复制完成后,将下面的路径粘贴至VS中即可(64位),其中【D:\Code\Linux\Ubuntu\】为Windows下对应Linux的根目录
  • 修复VS中__cplusplus宏固定为199711L导致无法识别C++11新增特性的办法
    比如<unordered_set>头文件中,定义了如下语句,导致VS包含的实际上是一个c++0x_warning.h的文件,无法真正识别unordered_set的实现:

    在网上找了很久,也没找到修改VS内置__cplusplus宏的方法,只能曲线救国了,即将所有头文件中201103L改成199711L即可,具体操作如下:

    • 将复制到Windows下的头文件路径重新打包(比如:D:\Code\Linux\Ubuntu\,因为复制到Windows前有很多符号链接,不能修改,所以最好重新在Windows下打包生成一份新的纯文件)在Linux下解压后执行:

    • 执行后将源文件复制回来即可。
  • 创建完成后项目文件目录会生成以下项目文件,以后需要打开项目直接双击sln解决方案文件即可

img_566f6ad923388

参考资料: