VMWare下开启时间同步可能会导致Ubuntu系统Crontab不精确

问题背景

最近发现VMWare中Ubuntu中每分钟运行的Crontab任务的开始时间总有几秒的偏差,并且时长跳变,对部分任务影响比较大,而且不同的环境不一定能重现,比如有这样的一个crontab实现每隔15秒运行一个任务(其中任务使用date命令代替了):

/tmp/p1中记录每个任务开始时间,这个时间按道理应该是00秒或者01秒,但是往往发现这个时间是04或者08甚至13秒才开始。

问题分析

刚开始以为是别的任务没有在最后补&进行后台运行导致影响其他任务,但是最后只保留这4个任务也会出现同样的问题,甚至也怀疑过>,>>和&的优先级的问题,都没找到解决办法。

有一次无意中看系统日志,发现居然每分钟都有系统时间被改变的日志:

看来很有可能是这个原因,但是为什么系统日志会被这么频繁地改变呢?可以排除是ntpd之类的时间同步进程导致。看了一下其他的服务器,也没有这种情况发生。

突然想到VMWare有时间同步到虚拟机的功能,果然这个服务器是开启了VMWare的时间同步功能:

将此功能关闭后,终于正常了,这个时间同步,还不清楚为什么会引起时间跳变,但是跳变过大,对系统服务会有很大的影响,目前遇到的除了还有Zookeeper连接莫名其妙超时,所以有ntp服务条件的还是关掉后使用系统ntpd服务进行时间同步比较好。

那些年,我们一起踩过的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小时会进行一次数据的清理。

参考资料

从Chrome导出Cookie给curl使用

背景

由于自己折腾的一些Linux小工具,有的功能需要登录网站才能够获得,遇到这种问题一般就是用Chrome自带的F12找到并分析一下登录包,一般登录协议都是类似表单的提交,使用HTTP POST协议,然后将登录协议使用Linux下命令行curl工具尝试一下POST数据(-d “登录数据”)看看返回包是否与浏览器登录的结果一致。如果不一致,就尝试增加一些HTTP头,比如Referer或者User-Agent头,有的后端会检查这些字段,最坏的可能就是把所有浏览器中发过去的HTTP头都带上,这样一般情况下都可以登录成功。
但是由于一些网站登录时在登录包中会发一些特殊的标记字符串过去,我们并不了解它发过去的字符串是什么含义,也不那么容易找到这个字符串是怎么计算出来的。为了更加方便地说明问题,我随便找了一个安全系数非常高的淘宝网,淘宝网的登录相信不少人已经研究过了,这篇文章不打算深入研究。

使用Chrome抓到淘宝的登录协议,需要勾选Preserve log防止页面跳转清空抓到的包,得到的请求表单如下,相关的隐私数据我已经替换成*或者其他的内容。

可以看到登录协议里有几个字段是经过编码或者是特殊标记的,比如ncoToken,gvfdcre,TPL_password_2,um_token和ua。如果需要分析出淘宝的登录协议,需要找到这几个字段是否必须,如何计算。
如果遇到验证码或者一些特殊的验证方式,比如拼图之类的,自动登录就难上加难了。

折中的解决方法

为了绕过这种登录协议比较复杂的网站的登录,可以采用浏览器人工登录,然后导入Cookie的方式,不过这样也会带来几个比较严重的问题:

  • 登录必须人工登录,并且还要把Cookie导出到服务器。
  • Cookie有过期时间,不持久,容易失效。

如果接受上述两个条件的话,那么就可以进入实操阶段了。

使用chrome导出Cookie

Chrome有很多插件用于编辑、导入和导出Cookie,我试用了几款,最终选择了EditThisCookie这款功能比较多的插件。

安装好插件后,可以在选项中设置导出Cookie的格式,我们选择Netscape HTTP Cookie File,因为curl支持这种格式。

现在登录淘宝,完成后点击插件,然后点击导出Cookie按钮:

导出的Cookie文件内容大约是这样,其中隐私内容已经被替换掉。

把这些内容保存成Linux下的一个文件,名字随便取,我取成taobao.cookie好了。
要注意的是,Netscape Cookies文件的格式是这样的,每一行代表一个Cookie值,每一行从左到右分别由下面的字段组成,中间使用Tab分隔,来源Netscape’s cookie.txt file

使用Cookies

从Chrome中导出Cookie后,可以使用下面的命令请求一下淘宝个人主页,加-L是为了让curl跟随页面跳转:

可以看到返回了页面内容,但是仔细看的话,会发现其中并没有我们的淘宝用户名,页面的状态还是未登录的。这是因为EditThisCookie导出的Cookies文件其实不符合curl的规范的,目前有2个问题:

  • EditThisCookie导出的Cookie在过期时间(Expiration)没填写的时候,不会填充0或者其他数据,相应的位置会空着,使用2个Tab键分割,然而curl会将2个Tab键当做一个分割,于是后面的Name字段就被当做Expiration字段了。临时解决方法是,当Expiration没填写时,填个默认值0。
  • EditThisCookie导出的Cookie的Flag(也叫做Host Only)字段与curl填充的习惯不符合,比如导出的.taobao域名对应的Cookie大多是FALSE,那么在curl中,访问https://i.taobao.com就不会将本行的Cookie填充到HTTP头中。临时解决方法比较简单粗暴,即这一个字段全改成TRUE就好了,这样可能会存在一些安全问题。

为了更加方便得获得curl可用的Cookies文件,我临时写了一个简单的PHP对EditThisCookie导出的Json格式(在EditThisCookie的配置中可以配置成JSON格式导出)Cookies进行转换,文件名为cookies.php,它会将提交的json格式的Cookies文件内容转换为curl可识别的Cookies文件,保存在/tmp/cookies路径中:

做了修改后,Cookies文件看起来是这样的:

再使用curl请求,此时就可以从回包中得到用户名了,说明Cookies已经成功导出并且可以使用,至于后面要抢月饼还是拉取数据,就看个人需要咯。
其中**表示登录的淘宝用户名:

遗留问题
  • Netscape Cookie的每个字段的含义还不是特别清楚,有时间可以看看Netscape Cookie每个字段的具体用法。
  • 不知道有什么比PHP提交更好的提交Cookies文件的方法。
  • Cookies过期目前还不清楚是什么机制,是否有办法一次登录,多次延期?这个可能不同的网站有不同的处理方式。

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 客户端源码分析以及使用注意点

联想Windows平板Miix2-8安装Linux的方法和总结(2017年3月22日更新状态:完美用做家用服务器)

背景

2年前买了Miix2-8 32G版,搭配Intel Z3740 CPU,2G内存,出厂为32位的Windows 8系统,去年Windows 10出来之后,毫无压力升级到Windows 10,系统稳定性提高不少,后来闲置在家里,清空了机器,装了干净的原版Windows 10,自带OEM激活,使用VMware 10(只有10可以在32位Windows上使用CPU虚拟化运行64位的系统)安装了个Ubuntu14.04服务器版,给1G内存和10G硬盘,用极路由2的USB口供电,搭配花生壳内网穿透,当服务器使用。

服务器上跑了我的微信公众号,我的Blog(也就是读者现在看到的这个),还有一些常用脚本定期运行,使用花生壳做内网映射,2个端口,一个用于80端口的HTTP服务,另一个用于SSH服务,稳定运行了大半年,没有出过故障。

但是总有一颗折腾的心在跳跃,因为平板的内存只有2G,虚拟机只能分配1G内存,并且磁盘读写并没有系统直接操作来的快速,而且磁盘容量有限,因此一直想直接安装Linux到平板上。这2年也略微尝试过几次,均以失败告终,主要是想安装64位系统,而Miix2自带的是32位的UEFI(无64位UEFI),无法引导64位系统,安装盘都进不去,更别说安装系统了,网上针对Miix2的Linux安装文章也是少得可怜,基本上找不到直接的资料。

经过2年的发展,UEFI启动已经成熟了不少,Debian 8.0已经支持了64位系统使用32位引导,此时再启动这个工作,应该比之前要容易不少,于是花了一些时间来尝试安装各主流发行版。下面是我走过不少弯路之后的一个关于Miix2-8平板安装Linux的总结。

准备的额外硬件
  • USB HUB,最好可同时供电,用于连接鼠标键盘以及U盘。
  • 一个U盘,2G以上容量即可。
  • 【选用】外置USB无线网卡,或者外置USB有线网卡。因为安装过程中内置网卡无法识别,我用的是水星USB无线网卡,没有USB有线网卡,读者可自行测试一下是否可用,在Ubuntu安装后可加载无线网卡驱动,因此可不用,其他系统比如Debian需要。
安装操作步骤

所有的安装方法都是采用下载原版ISO镜像,用U盘引导工具(比如UltraISO)制作U盘启动。一些对镜像的修改可在刻录完后直接在U盘中修改。

Miix2-8 UEFI启动方法:开机时按住【音量键上】+【开关键】。

Miix2-8 强制关机方法:长按【音量键下】+【开关键】10秒左右。

问题列表

以下列出我在折腾过程中遇到的一些问题,测试的系统版本太多(主要是Ubuntu的版本太多了),就不列出具体的系统版本号了,特殊情况我会单独列出来,尽量使用14年以后的版本,14年以前的版本我试了一下,也有各种问题,就不说了,新版本对于硬件的兼容性会好很多。

另外,所有系统基本上都是64位的,32位的部分安装尝试过,但是没有去尝试解决问题,可能会有部分遗漏,毕竟版本太多,问题也是逐步发现并修复。关键问题解决途径会重点标记。

  1. 64位系统安装镜像无法在32位UEFI中启动,表现于引导菜单中无法识别启动盘。
    下载bootia32.efi,刻录U盘镜像后放入/efi/boot/目录下即可。
  2. Ubuntu 15.04以及之前的Server版本
    无法识别内置磁盘,暂时不知道如何处理。
  3. Ubuntu 15.10 Server版
    能识别磁盘,但是写入一定时间后随机报错,无法完成安装,/var/log/syslog中有错误日志:”mmc0: Timeout waiting for hardware interrupt”,参考此处说是老内核对eMMC磁盘的兼容性有问题,具体原因没找到(2016年9月20日更新:后面有解决办法,2017年3月22日更新:通过自己编译新内核完美解决)。
  4. Ubuntu 14.04.4 桌面版64位
    进入系统后屏幕一闪就黑了,实际上是内置显卡配置问题,在启动选择时按E,进入grub编辑模式,找到linux后的部分,加上nomodeset,按F10启动即可,此时是没有显卡加速的,因此图形会有点问题。此方法可以进入Live CD。
    安装过程中磁盘写入出问题,无法完成安装,放弃。
  5. Ubuntu 15.10 桌面版64位和Ubuntu 16.04.1桌面版64位
    这是一个相对来说比较好的版本,实现的功能也最多。

    • 安装系统
      使用nomodeset模式可以完成安装,只是最后一步安装grub会出错,需要使用安装盘进入grub手工引导,进入安装盘选择菜单时,按C进入grub,输入以下命令,其中*表示当前系统版本的内核,不同的系统不一样,比如vmlinuz-3.16.0-23-generic,可以使用Tab键补全,也有的系统有2个内核,比如Ubuntu 15.10,可随便选择一个,保证linux和initrd选择的内核相同即可。
      root=/dev/mmcblk0p2表示根目录的设备,这个可以进入Live CD模式通过命令”ls -l /dev”看看。

      正常情况下,可以进入系统了,如果不能进入系统,进入Live CD模式看看/var/log/syslog或者/var/log/dmesg。
    • 安装32位grub,参考来源这里,我这边做个简单的翻译
      • 打开终端安装依赖包:
      • 从下载grub代码:ftp://ftp.gnu.org/gnu/grub/
      • 编译32位grub:
      • 安装grub:
      • 替换启动项,也许/boot/efi/EFI/中没有ubuntu这个目录,使用它里面自带的目录即可:
      • 更新grub:
      • 参考文章说此时可以使用efibootmgr命令查看当前启动项,我测试后发现并不管用,必须要重启后才有用。
        另外参考文章说Ubuntu 15.04可以使用apt-get直接安装32位grub,我在15.10中测试失败:
    • 解决显卡加速问题
      前文说到可以使用nomodeset禁止Linux加载显卡驱动来避免加载驱动时导致显示错误,但是这样同时也失去了显卡加速,整个界面会很卡。经过很长时间的搜索,在这里找到了可用的解决方法,但是这种方式每次更新内核或者执行sudo update-grub时都需要重新修改一遍:

      2016年9月20日更新:另一种更好的方式来源于riverzhou,执行sudo vim /etc/default/grub,修改或者添加以下行:
    • 加载内置WiFi驱动
      进入系统后,执行下面的命令安装驱动,其中*使用自动补全即可。
      如果没有文件/sys/firmware/efi/efivars/nvram-*,可以到这里下载一个,测试过是OK的。
    • 屏幕背光关闭
      正常情况下,miix2安装了Linux后,发现无论如何屏幕背光都无法关闭,使用了riverzhou内核后启动时无法显示屏幕内容,但是等待屏幕休眠超时后,可以正常显示,并且屏幕背光也可以关闭,具体编译过程可以参考此贴
    • 问题
      • 【已解决】安装到这里,我本以为这个系统作为家用小服务器使用已经很完美了,各种使用都没太严重的问题。但当我往里面恢复数据时,还是会死机,会出现磁盘IO错误,在大量磁盘写入的情况下,磁盘出错。搜索了一下,没有发现解决方案,放弃。
        • 2016年9月11日更新:
          这个问题在Ubuntu论坛提出半年后,一位网友riverzhou终于发现了问题所在并且给出了一个解决方法。问题是由于CPU睡眠模式导致,在内核启动参数中设置CPU睡眠模式最高设置为C1即可。具体方法是,使用U盘安装时,修改引导参数(在前面加nomodeset的地方),添加intel_idle.max_cstate=1
          系统安装成功后,也在新的/boot/grub/grub.cfg中所有linux内核引导参数后加上这一参数即可。
          需要注意的是,每次更新内核后,需要重新修改这个文件,包含上面3D加速的修改。
        • 2016年9月20日更新:执行sudo vim /etc/default/grub,修改或者添加以下行:

          其中i915.force_backlight_pmic=1是riverzhou给的内核关闭屏幕的参数。
        • 2017年3月22日更新:使用riverzhou大大提供的内核,下载后编译安装,不需要加任何编译参数,完美解决。
  6. Debian 8.3 64位(debian-8.3.0-amd64-netinst.iso)
    此镜像为比较老的3.16内核,由于是网络安装,并且内置网卡在安装阶段无法使用(实际上在安装好之后也无法使用),需要使用外置USB网卡联网,我用的水星外置网卡需要从Debian官网下载驱动,放入启动U盘根目录或者firmware目录,图形界面安装完全无问题,磁盘读写也没问题,32位引导也没问题。可惜的是,无法开启内置显卡加速以及内置Wifi,使用上面Ubuntu的方法,均以失败告终。
  7. Fedora 23 64位(Fedora-Live-Workstation-x86_64-23-10.iso)
    4.2.3内核,比较新,但是镜像的格式需要比较大的改动才能够进入安装页面,下面是具体操作步骤:

    • U盘镜像制作好后,将bootia32.efi放入/EFI/BOOT目录下。
    • 在根目录创建/boot/grub目录,把U盘下的/EFI/BOOT下的fonts文件夹和grub.cfg文件复制到/boot/grub目录下,可能是bootia32.efi只认这个目录的引导文件,不这样做的话,开机引导就直接进入grub命令行界面了,无法使用grub配置文件。
    • 修改刚刚复制过来的/boot/grub/grub.cfg文件:
      • grub提示找不到linuxefi命令:将所有的linuxefi改成linux,一共3处。
      • grub提示找不到initrdefi命令:将所有的initrdefi改成initrd,一共3处。
      • 引导后会提示找不到label为”Fedora-Live-WS-x86_64-23-10″的磁盘:将”Fedora-Live-WS-x86_64-23-10″改成”Fedora-Live”,看了一下磁盘label,只有”Fedora-Live”,于是就这样改了,一共4处。
    • 进行过上面的操作后,可以进入引导选单界面了,选择”Troubleshooting”,然后选择”Start Fedora Live in basic graphics mode”启动,其实就是引导参数加上了nomodeset。直接启动会黑屏。
    • 启动后,选择安装,本以为一切顺利,结果还是在安装过程中死机了,推测还是内核太高,磁盘读写有问题,和Ubuntu 15.10以及之后的系统一样。
最终状态

经过一周左右的折腾,从网上找到一些问题的解决方法,但是还是没办法达到完美的使用的状态,最后还是找了一个精简版的Windows 10,搭配VMware 10使用。如果对于上面的问题,有解决的方法,希望大牛们可以不吝赐教。

2017年3月22日更新:目前安装Ubuntu 16.04的方法:
先使用启动参数nomodeset和intel_idle.max_cstate=1安装,完成后安装必须的编译工具,gcc,make之类,将riverzhou打过补丁的内核(目前我用的是4.10.y分支)Clone下来编译安装,去掉加上的内核参数即可。
来个截图:


下面总结一下目前折腾的结果,作为家用服务器的话,跑个crontab定期任务,一个Blog,一个小型网站,一个微信公众号,基本上完美了:

  • 没有尝试解决(优先级较低)
    • 重力感应(无法使用)
    • 屏幕旋转
      • Ubuntu 16.04.1下,可以调整屏幕方向,但是触屏方向没有跟着改,改了方向后触屏无法使用。
    • 前后摄像头(无法使用)
    • 麦克风(无法使用)
    • 声音(无法使用)
  • 已经解决
    • 屏幕背光关闭:使用riverzhou打过补丁的内核
    • 屏幕显示:首次安装时使用nomodeset,然后使用riverzhou打过补丁的内核,可开启3D加速。
    • 读卡器:Ubuntu 16.04.1安装好后即可使用,不支持写入
    • 显卡加速:后面使用riverzhou打过补丁的内核
    • 触屏:OK,不支持多点触摸
    • WiFi:Ubuntu中加载驱动,15.10和16.04.01经过测试可用。
    • USB:OK
    • 硬盘:Debian 8.3中完美,Ubuntu使用riverzhou打过补丁的内核
    • 内存:OK
参考资料
  1. Installing Ubuntu on BayTrail tablets (version 2)
  2. UBUNTU (OR OTHER LINUX) ON THE ASUS TRANSFORMER BOOK T100
  3. 4.4r3, Miix 2 8inch doesn’t boot
  4. LATEST STEPS TO INSTALL UBUNTU ON THE ASUS T100TA
  5. 【求助】联想miix2 8安装Linux的问题(显示和eMMC磁盘识别问题)探讨和解决
  6. Installing Debian On Asus T100TA