go

Docker实践

起因

Docker算是现在非常火的一个项目,但笔者对其一直不怎么感冒,毕竟没啥使用场景。只是最近,笔者需要在自己的mac电脑上面安装项目的开发环境,发现需要安装MySQL,LedisDB,xcodis,Redis,Zookeeper等一堆东西,而同样的流程仍然要在Windows的机器上面再来一遍,陡然觉得必须得有一个更好的方式来管理整个项目的开发环境了。自然,笔者将目光放到了Docker上面。

根据官方自己的介绍,Docker其实是一个为开发和运维人员提供构建,分发以及运行分布式应用的开源平台(野心真的不小,难怪CoreOS要新弄一个Rocket来跟他竞争的)。

Docker主要包括Docker Engine,一个轻量级的运行和包管理工具,Docker Hub,一个用来共享和自动化工作流的云服务。实际在使用Docker的工程中,我们通常都是会在Docker Hub上面找到一个base image,编写Dockerfile,构建我们自己的image。所以很多时候,学习使用Docker,我们仅需要了解Docker Engine的东西就可以了。

至于为啥选用Docker,原因还是很明确的,轻量简单,相比于使用VM,Docker实在是太轻量了,笔者在自己的mac air上面同时可以运行多个Docker container进行开发工作,而这个对VM来说是不敢想象的。

后面,笔者将结合自己的经验,来说说如何构建一个MySQL Docker,以及当中踩过的坑。

MySQL Docker

笔者一直从事MySQL相关工具的开发,对于MySQL的依赖很深,但每次安装MySQL其实是让笔者非常头疼的一件事情,不同平台安装方式不一样,加上一堆设置,很容易就把人搞晕了。所以自然,我的Docker第一次尝试就放到了MySQL上面。

对于mac用户,首先需要安装boot2docker这个工具才能使用Docker,这个工具是挺方便的,但也有点坑,后续会说明。

笔者前面说了,通常使用Docker的方式是在Hub上面找一个base image,虽然Hub上面有很多MySQL的image,但笔者因为开发go-mysql,需要在MySQL启动的时候传入特定的参数,所以决定自行编写Dockerfile来构建。

首先,笔者使用的base image为ubuntu:14.04,Dockerfile文件很简单,如下:

FROM ubuntu:14.04

# 安装MySQL 5.6,因为笔者需要使用GTID
RUN apt-get update \
    && apt-get install -y mysql-server-5.6

# 清空apt-get的cache以及MySQL datadir
RUN apt-get clean
RUN rm -rf /var/lib/apt/lists/* /var/lib/mysql

# 使用精简配置,主要是为了省内存,笔者机器至少要跑6个MySQL
ADD my.cnf /etc/mysql/my.cnf

# 这里主要是给mysql_install_db脚本使用
ADD my-default.cnf /usr/share/mysql/my-default.cnf

# 增加启动脚本
ADD start.sh /start.sh
RUN chmod +x /start.sh

# 将MySQL datadir设置成可外部挂载
VOLUME ["/var/lib/mysql"]

# 导出3306端口
EXPOSE 3306

# 启动执行start.sh脚本
CMD ["/start.sh"]

我们需要注意,对于MySQL这种需要存储数据的服务来说,一定需要给datadir设置VOLUMN,这样你才能存储数据。笔者当初就忘记设置VOLUMN,结果启动6个MySQL Docker container之后,突然发现这几个MySQL使用的是同一份数据。

如果有VOLUMN, 我们可以在docker run的时候指定对应的外部挂载点,如果没有指定,Docker会在自己的vm目录下面生成一个唯一的挂载点,我们可以通过docker inspect命令详细了解每个container的情况。

对于start.sh,比较简单:

  • 判断MySQL datadir下面有没有数据,如果没有,调用mysql_install_db初始化。
  • 允许任意ip都能使用root账号访问,mysql -uroot -e "GRANT ALL ON *.* TO 'root'@'%' IDENTIFIED BY '' WITH GRANT OPTION;",否则我们在外部无法连接MySQL。
  • 启动mysql

构建好了MySQL Docker image,我们就能使用docker run来运行了,很简单

docker run -d -p 3306:3306 --name=mysql siddontang/mysql:latest

这里,我们基于siddontang/mysql这个image创建了一个名叫mysql的container并运行,它会调用start.sh脚本来启动MySQL。

而我们通过docker stop mysql就可以停止mysql container了。

如果笔者需要运行多个MySQL,仅仅需要多新建几个container并运行就可以了,当然得指定对应的端口。可以看到,这种方式非常的简单,虽然使用mysqld_multi也能达到同样的效果,但是如果我需要在新增一个MySQL实例,mysqld_mutli还需要去更改配置文件,以及在对应的MySQL里面设置允许mysqld_multi stop的权限,其实算是比较麻烦的。而这些,在Docker里面,一个docker run就搞定了。

完整的构建代码在这里,mysql-docker,你也可以pull笔者提交到Hub的image siddontang/mysql来直接使用docker pull siddontang/mysql:latest

Boot2Docker Pitfall

从前面可以看到,Docker的使用是非常方便的,但笔者在使用的时候仍然碰到了一点坑,这里记录一下。

IP

最开始碰到的就是ip问题,笔者在run的时候做了端口映射,但是外部使用MySQL客户端死活连接不上,而这个只在笔者mac上面出现,linux上面正常,后来发现是boot2docker的问题,我们需要使用boot2docker ip返回的ip来访问container,在笔者的机器上面,这个ip为192.168.59.103。

Volumn

仍然是boot2docker的问题,笔者在docker run的时候,使用-v来将外部的目录绑定到datadir这个VOLUMN上面,这个在linux上面是成功的,可是在mac上面,笔者发现mysql_install_db死活没有权限写入磁盘。后来才知道,boot2docker只允许对自己VM下面的路径进行绑定。鉴于在mac下面仅仅是调试,数据不许持久化保存,这个问题也懒得管了。反正只要不删除掉container,数据还是会在的。

Flatten Image

在使用Dockerfile构建自己的image的时候,对于Dockerfile里面的每一步,Docker都会生成一个layer来对应,也就是每一步都是一次提交,到最后你会发现,生成的image非常的庞大,而当你push这个image到Hub上面的时候,你的所有layer都会提交上去,加之我们国家的网速水平,会让人崩溃的。

所以我们需要精简生成的image大小,也就是flatten,这个Docker官方还没有支持,但至少我们还是有办法的:

  • docker export and docker import,通过对特定container的export和import操作,我们可以生成一个无历史的新container,详见这里
  • docker-squash,很方便的一个工具,笔者就使用这个进行image的flatten处理。

后记

总的来说,Docker还是很容易上手的,只要我们熟悉了它的命令,Dockerfile的编写以及相应的运行机制,就能很方便的用Docker来进行团队的持续集成开发。而在生产环境中使用Docker,笔者还没有相关的经验,没准后续私有云会采用Docker进行部署。

后续,对于多个Container的交互,以及服务发现,扩容等,笔者也还需要好好研究,CoreOS没准是一个方向,或者研究下rocket :-)

LedisDB Replication设计

对于使用SQL或者NoSQL的童鞋来说,replication都是一个避不开的话题,通过replication,能极大地保证你的数据安全性。毕竟谁都知道,不要把鸡蛋放在一个篮子里,同理,也不要把数据放到一台机器上面,不然机器当机了你就happy了。

在分布式环境下,对于任何数据存储系统,实现一套好的replication机制是很困难的,毕竟CAP的限制摆在那里,我们不可能实现出一套完美的replication机制,只能根据自己系统的实际情况来设计和对CAP的取舍。

对于replication更详细的说明与解释,这里推荐Distributed systems
for fun and profit
,后面,我会根据LedisDB的实际情况,详细的说明我在LedisDB里面使用的replication是如何实现的。

BinLog

最开始的时候,Ledisdb采用的是类似MySQL通用binlog的replication机制,即通过binlog的filename + position来决定需要同步的数据。这套方式实现起来非常简单,但是仍然有一些不足,主要就在于hierarchical replication情况下如果master当掉,选择合适的slave提升为master是比较困难的。举个最简单的例子,假设A为master,B,C为slave,如果A当掉了,我们会在B,C里面选择同步数据最多的那个,但是是哪一个呢?这个问题,在MySQL的replication中也会碰到。

MySQL GTID

在MySQL 5.6之后,引入了GTID(Global transaction ID)的概念来解决上述问题,它通过Source:ID的方式来在binlog里面表示一个唯一的transaction。Source为当前server的uuid,这个是全局唯一的,而ID则是该server内部的transaction ID(采用递增保证唯一)。具体到上面那个问题,采用GTID,如果A当掉了,我们只需要在B和C的binlog里面查找比较最后一个A这个uuid的transaction id的大小,譬如B的为uuid:10,而C的为uuid:30,那么铁定我们会选择C为新的master。

当然使用GTID也有相关的限制,譬如slave也必须写binlog等,但它仍然足够强大,解决了早期MySQL replication的时候一大摊子的棘手问题。但LedisDB并不准备使用,主要就在于应用场景没那么复杂的情况,我需要的是一个更加简单的解决方案。

Google Global Transaction ID

早在MySQL的GTID之前,google的一个MySQL版本就已经使用了global transaction id,在binlog里面,它对于任何的transaction,使用了group id来唯一标示。group id是一个全局的递增ID,由master负责维护生成。当master当掉之后,我们只需要看slave的binlog里面谁的group id最大,那么那一个就是能被选为master了。

可以看到,这套方案非常简单,但是限制更多,譬如slave端的binlog只能由replication thread写入,不支持Multi-Masters,不支持circular replication等。但我觉得它已经足够简单高效,所以LedisDB准备参考它来实现。

Raft

弄过分布式的童鞋应该都或多或少的接触过Paxos(至少我是没完全弄明白的),而Raft则号称是一个比Paxos简单得多的分布式一致性算法。

Raft通过replicated log来实现一致性,假设有A,B,C三台机器,A为Leader,B和C为follower,(其实也就是master和slave的概念)。A的任何更新,都必须首先写入Log(每个Log有一个LogID,唯一标示,全局递增),然后将其Log同步到至少Follower,然后才能在A上面提交更新。如果A当掉了,B和C重新选举,如果哪一台机器当前的LogID最大,则成为Leader。看到这里,是不是有了一种很熟悉的感觉?

LedisDB在支持consensus replication上面,参考了Raft的相关做法。

名词解释

在详细说明LedisDB replication的实现前,有必要解释一些关键字段。

  • LogID:log的唯一标示,由master负责生成维护,全局递增。
  • LastLogID:当前程序最新的logid,也就是记录着最后一次更新的log。
  • FirstLogID:当前程序最老的logid,之前的log已经被清除了。
  • CommitID:当前程序已经处理执行的log。譬如当前LastLogID为10,而CommitID为5,则还有6,7,8,9,10这几个log需要执行处理。如果CommitID = LastLogID,则证明程序已经处于最新状态,不再需要处理任何log了。

LedisDB Replication

LedisDB的replication实现很简单,仍然是上面的例子,A,B,C三台机器,A为master,B和C为slave。

当master有任何更新,master会做如下事情:

  1. 记录该更新到log,logid = LastLogID + 1,LastLogID = logid
  2. 同步该log到slaves,等待slaves的确认返回,或者超时
  3. 提交更新
  4. 更新CommitID = logid

上面还需要考虑到错误处理的情况。

  • 如果1失败,记录错误日志,然后我们会认为该次更新操作失败,直接返回。
  • 如果3失败,不更新CommitID返回,因为这时候CommitID小于LastLogID,master进入read only模式,replication thread尝试执行log,如果能执行成功,则更新CommitID,变成可写模式。
  • 如果4失败,同上,因为LedisDB采用的是Row-Base Format的log格式,所以一次更新操作能够幂等多次执行。

对于slave

如果是首次同步,则进入全同步模式:

  1. master生成一个snapshot,连同当前的LastLogID一起发送给slave。
  2. slave收到该dump文件之后,load载入,同时更新CommitID为dump文件里面的LastLogID。

然后进入增量同步模式,如果slave已经有相关log,则直接进入增量同步模式。

在增量模式下面,slave向master发送sync命令,sync的参数为下一个需要同步的log,如果slave当前没有binlog(譬如上面提到的全同步情况),则logid = CommitID + 1, 否则logid = LastLogID + 1。

master收到sync请求之后,有如下处理情况:

  • sync的logid小于FirstLogID,master没有该log,slave收到该错误重新进入全同步模式。
  • master有该sync的log,于是将log发送给slave,slave收到之后保存,并再次发送sync获取下一个log,同时该次请求也作为ack告知master同步该log成功。
  • sync的log id已经大于LastLogID了,表明master和slave的状态已经到达一致,没有log可以同步了,slave将会等待新的log直到超时再次发送sync。

在slave端,对于接受到的log,由replication thread负责执行,并更新CommitID。

如果master当机,我们只需要选择具有最大LastLogID的那个slave为新的master就可以了。

Limitation

总的来说,这套replication机制很简单,易于实现,但是仍然有许多限制。

  • 不支持Multi-Master,因为同时只能有一个地方进行全局LogID的生成。不过我真的很少见到Multi-Master这样的架构模式,即使在MySQL里面。
  • 不支持Circular-Replication,slave写入的log id不允许小于当前的LastLogID,这样才能保证只同步最新的log。
  • 没有自动master选举机制,不过我觉得放到外部去实现更好。

Async/Sync Replication

LedisDB是支持强一致性的同步replication的,如果配置了该模式,那么master会等待slave同步完成log之后再提交更新,这样我们就能保证当master当机之后,一定有一台slave具有跟master一样的数据。但在实际中,可能因为网络环境等问题,master不可能一直等待slave同步完成log,所以通常都会有一个超时机制。所以从这点来看,我们仍然不能保证数据的强一致性。

使用同步replication机制会极大地降低master的写入性能,如果对数据一致性不敏感的业务,其实采用异步replication就可以了。

Failover

LedisDB现在没有自动的failover机制,master当机之后,我们仍然需要外部的干预来选择合适的slave(具有最大LastLogID那个),提升为master,并将其他slave重新指向该master。后续考虑使用外部的keeper程序来处理。而对于keeper的单点问题,则考虑使用raft或者zookeeper来处理。

后记

虽然LedisDB现在已经支持replication,但仍然需要在生产环境中检验完善。

LedisDB是一个采用Go实现的高性能NoSQL,接口类似Redis,现在已经用于生产环境,欢迎大家使用。

Official Website

Github

使用gopkg进行Go包管理

在使用go的过程中,我们有时候会引入一些第三方库来使用,而通常的方式就是使用 go get ,但是这种方式有一个很严重的问题,如果第三方库更新了相关接口,很有可能你就无法使用了,所以我们一套很好地包管理机制。

读生产环境下go语言最佳实践有感一文中,我介绍过soundcloud公司的做法,直接将第三库的代码check下来,放到自己工程的vendor目录里面,或者使用godep。

不过现在,我发现了一种更好的包管理方式gopkg。它通过约定使用带有版本号的url来让go tool去check指定的版本库,虽然现在只支持github的go repositories,但是我觉得已经足够强大。

一个很简单的例子,我们通过如下方式获取go的yaml包

go get gopkg.in/yaml.v1

而实际上,该yaml包对应的地址为:

https://github.com/go-yaml/yaml

yaml.v1表明版本为v1,而在github上面,有一个对应的v1 branch。

gopkg支持的url格式很简单:

gopkg.in/pkg.v3      → github.com/go-pkg/pkg (branch/tag v3, v3.N, or v3.N.M)
gopkg.in/user/pkg.v3 → github.com/user/pkg   (branch/tag v3, v3.N, or v3.N.M)

我们使用v.N的方式来定义一个版本,然后再github上面对应的建立一个同名的分支。gopkg支持(vMAJOR[.MINOR[.PATCH]])这种类型的版本模式,如果存在多个major相同的版本,譬如v1,v1.0.1,v1.1.2,那么gopkg会选用最高级别的v1.1.2使用,譬如有如下版本:

  • v1
  • v2.0
  • v2.0.3
  • v2.1.2
  • v3
  • v3.0

那么gopkg对应选用的方式如下:

  • pkg.v1 -> v1
  • pkg.v2 -> v2.1.2
  • pkg.v3 -> v3.0

gopkg不建议使用v0,也就是0版本号。

gopkg同时列出了一些建议,在更新代码之后是否需要升级主版本或者不需要,一些必须升级主版本的情况:

  • 删除或者重命名了任何的导出接口,函数,变量等。
  • 给接口增加,删除或者重命名函数
  • 给函数或者接口增加参数
  • 更改函数或者接口的参数或者返回值类型
  • 更改函数或者接口的返回值个数
  • 更改结构体

而一下情况,则不需要升级主版本号:

  • 增加导出接口,函数或者变量
  • 给函数或者接口的参数名字重命名了
  • 更改结构体

上面都提到了更改结构体,譬如我给一个结构体增加字段,就可能不需要升级主版本,但是如果删除结构体的一个导出字段,那就必须要升级了。如果只是单纯的更改改结构体里面非导出字段的东西,也不需要升级。

更加详细的信息,请直接查看gopkg

可以看到,gopkg使用了一种很简单地方式让我们方便的对go pakcage进行版本管理。于是我也依葫芦画瓢,给我的log package做了一个v1版本的,你可以直接

go get gopkg.in/siddontang/go-log.v1/log

我的编程语言经历

Alan Perlis 说过:“一种不改变你编程的思维方式的语言,不值得去学。”,虽然写了这么多年程序,用了这么多的语言,但我自认还没悟道编程语言如何改变我的思维方式。

几天前,我需要用python来为ledisdb写一个客户端,我突然发现,对于c++,go这种语言,我如果需要实现一个功能,首先想到的是问题是代码应该怎么写。但是当我使用python的时候,我首先考虑的问题是在哪里去找一个库用来解决我的问题。可能这就是使用不同语言带给我的不同思考方式吧。

我的编程语言经历没有那么复杂,没用过很多,但是其实也够我受的了,尤其是在不同语言语法糖之间切换的时候,有种让人崩溃的感觉。没准我应该升级一下我的大脑cpu,使其能够更快速的进行中断处理。

c

我是从大学才开始学习编程的,相比现在的小朋友来说,可以叫做输在了起跑线上面。谁叫以前生活在山区,没机会接触电脑这玩意。

我的第一门编程语言是c,不同于很多童鞋使用的谭浩强神书,我用的是周纯杰的<>,不知道每年有多少同学受到过它的摧残,当然还有那哥们蹩脚的普通话。

在大学里面,很多同学的c的毕业设计都是我帮其完成,但我始终觉得自己仍然是个半吊子,除了c的基础稍微强一点之外,很多方面譬如操作系统,算法等完全不会。(现在随着工作年限的增加让我越发后悔,当初怎么就不稍微学点这些知识,尤其是编译原理。)

我几乎没怎么用c开发过项目,只在tencent可怜的维护过别人的c项目,但至少能看懂c代码,这就够了。

因为大多数时候,我用的是c++,而不是c来解决我的问题。

c++

c++是我工作使用的第一门语言,也是我使用时间最长的一门语言,都七年之痒了,不过还是有点不离不弃的。

以前上学的时候有一句口头禅,叫学好c++,走遍天下都不怕。但是有几个人能把它学好的?所以千万别说自己精通c++,那会被人鄙视的。

我使用c++可以分为三个阶段:

类c阶段

这个阶段主要是我第一份工作的时候,那时候才毕业,c的烙印很深,面向对象除了有个概念,真正是啥完全不知道。所以最喜欢的方式还是写着一堆函数来解决问题,当初VIA身边那帮c++的牛人竟然能忍受我这样的代码,真佩服他们。

面向对象阶段

后来去了第二家公司linekong,开始做游戏,才开始真正意义上的用c++写代码了。

记得最开始去第一家公司面试的时候,被问了啥是面向对象,当时不假思索的答了继承,多态和封装。

啥叫封装?整一个class,把该包的都包了,一个同事曾告诉我,他见过有几万行代码的class,看来我这个几千行的太小儿科了。

啥叫继承?先来一个父类,干脆叫bird,有一个fly方法,再来一个子类,叫duck吧,继承了bird,不过duck会fly吗?一个父类不够,再来一个,搞个多重继承,什么?出现了菱形继承,那干脆在来一个virtual继承得了。

啥叫多态?不就是virtual function,然后父类指针能在运行时根据实际情况调用相应的子类实现。那c++的多态是怎么实现的?看看<<深度探索c++对象模型>>不就行了。

这段时间,可以算是我写c++代码最多的时候,都快写到吐了,尤其还要忍受那龟速的编译。我们竟然都实现了直接通过汇编改c++的虚表,使其调用自己的函数这种变态的东西。在那时候我就得出结论,如果不想早死,尽量别用这个东西写代码。可是到如今我都在不停的慢性自杀。

现代C++阶段

不知道从什么时候开始,我突然觉得我应该来点modern c++的编写方式了,c++0x都出了,还不玩一下就晚了。当然新特性那么多,不可能全部都拿来用的,Bjarne Stroustrup貌似都说过,c++0x应该算是另一门语言了。

于是哥就走上了伪modern c++之路,class还是需要的,不然数据怎么封装。继承吗,比重减轻吧,最好采用面向接口的编程方式。而多态,能不用就不用吧,反而觉得bing + function的方式实现的delegate模型反而更方便,没准也更酷哟。

shared_ptr,weak_ptr是需要用的了,c++没有gc,所以一套好的内存管理方式是必不可少的,每次new之后还要记得delete也比较烦,最严重的是可能忘记那就内存泄露了。

于是,我就自认为我进化了,最典型的例子就是我写的高性能网络库libtnet,感觉很modern了。

lua

最开始知道lua,是云风那本编程感悟的书,当时可是菊花一紧,觉得这东西是啥,为什么能跟c结合到一起使用?

后来自己开发游戏了,才发现lua真的是一门很强大的语言,短小精悍,嵌入简单,性能超强,完全是作为游戏脚本语言开发的不二人选。不过我最开始使用lua的经历不怎么happy,最开始就是接手了一个c++与lua的粘合层库,关于这个库的传说,见这里Lua 不是 C++。后来,在踩了无数个坑,填了无数个坑之后,我终于弄得相对稳定了。貌似现在我以前的同事还在使用,不过正如我在lua c函数注册器中说明的那样,对于语言的交互,简单一点才好。现在以前做的游戏已经开源,见这里,那个传说中的蛋疼粘合层也见了世面。当然,我可不会告诉你们好多搓代码是我写的。

后来,在现在的公司,因为项目的需要,我们急需解决python的很多性能大坑问题,于是我开始推广使用openresty,一个用lua包裹的nginx,用了之后,腰不痛了,腿不痛了,性能妥妥的。

因为lua,我第一次尝到了在代码里面直接写配置的便捷,用一个table搞定,相比起来,c++处理ini,json这些的弱爆了。另外,动态语言的热更新机制使其代码升级异常方便,不过你得非常小心lua的闭包,没准你重新加载了代码运行还是老样子。

lua是一个动态语言,所以不用我们管内存释放问题,但是仍然可能会有引用泄露,在开发游戏的时候,为了解决我们程序lua内存泄露的问题,我曾经干过直接从_G递归遍历,扫描整个lua数据的事情。相比在c++使用valgrind这些程序的工具,lua配套的东西还是太小儿科了。

lua的调试也是一个大头问题,我曾今写过几个lua的调试器,例如这个,甚至都支持了类似gdb那样ctrl+c之后动态的设置断点,可是仍然没觉得比gdb方便,所以多数时候,我都是写log为主。

python

虽然小时候吃过很多蛇,但是蟒蛇可是从来没吃过的,现在看来python味道还不错。

我是来了kingsoft之后才开始正式使用python的。对于为啥使用python,我曾跟拉我进来的技术老大讨论过,他直接就说,开发快速,上手容易。

python开发的快速很大程度是建立在丰富的第三方库上面的,我们也使用了很多库,譬如tornado,gevent,django等,但是正如我最开始说的,因为我们有太多的选择,面对一个问题的时候,往往考虑的是如何选择一个库,而不是自己如何实现,这其实在某种程度上面使得很多童鞋知其然而不知其所以然。这点,lua可能是另一个极端,lua的定位在于嵌入式和高性能,所以自然地,你得自己动手造轮子(当然,现在也有很多好的第三方库了),虽然有时候写起来很不方便,但是至少自己很清楚程序怎么跑的。

当然,我绝对没有贬低python的意思,我很喜欢这门语言,用它来开发了很多东西,同时也知道很多公司使用python构建了很多大型稳定的系统(我们的产品应该也算吧)。

只是现在我越发觉得,看起来简单的语言,如果没有扎实的基本功底,写出来的东西也很烂,而python,恰恰给人放了一个很大的烟雾弹,你以为它有多容易,其实它是在玩你。

go

好了,终于开始说go了,let’s go!!!

我使用go的历史不长,可能也就一年多,但是它现在完全成了我最爱的语言,go具有了python开发的迅速,同时也有着c运行的性能。(当然,还是有差距的!)

网上有太多的语言之争,包括go,有人恨,有人爱。但萝卜白菜,各有所爱,对于我来说,能帮我解决问题,让我用着舒服的语言就是好语言。

go非常适用于服务端程序开发,比起用c++开发,我陡然觉得有一种很幸福的感觉,譬如对于网络编程,在c++里面,我需要自己写epoll的事件处理,而且这种异步的机制完全切分了整个逻辑,使得代码不怎么好写,我在开发libtnet的时候感触尤其深刻。但是在go里面,因为天生coroutine的支持,使得异步代码同步化了,非常利于代码的编写。

现在我的主要在项目中推动go的使用,我们已经用go搭建了一个高性能的推送服务器,后续还有几个系统会上线,而且开发的进度并不比使用python差,另外也很稳定,这让我对go的未来充满了期待。

我也用go写了很多的开源程序,也算是拿的出手了,譬如:

  • ledisdb:一个基于leveldb的提供类似redis高级数据结果的高性能NoSQL,真挺绕口的,简单点就是一个高性能NoSQL。
  • Mixer:一个mysql-proxy,现在支持通用的mysql命令代理,读写分离,以及自动主备切换。后续将要参考vitess支持分区,为此一直在恶补编译原理的知识。
  • go-log:一个类似python log模块的东西,支持多种handler,以及不同的log级别。

还有一些,可以参考我的github,譬如moonmq(一个高性能push模型的消息服务器),polaris(一个类似tornado的restful web框架),因为go,我开始热衷于开源了,并且认识了很多的好基友,这算得上一个很大的收获吧。

其它

好了,说完了上面我的长时间使用语言(至少一年以上),我也用了很多其他的语言,现在虽然使用时间比较短,但不排除后续会长久使用。

Objective-C

因为我家终于有了苹果三件套,所以决定开发app了,首要的任务就是得学会使用Objective-C。我承认这真是一门奇葩的语言,如果没有xcode的自动补齐,我真不知道写起来是神马样的感觉。

而且第一次见到了一门推荐写函数名,变量名就像写文章的语言,至少我写c++,go这些的一个函数名字不会写成一个句子。

我现在在自学iOS的开发,慢慢在整出点东西,毕竟答应给老婆的iphone做点啥的。后续干脆就写一个《小白学iOS》系列blog吧(如果我有精力!),反正对于iOS,我真是一个小白。

java

好吧,我也在android上面写过程序,build到我的S3上面去过,对于java,我的兴趣真不大,貌似自己还买过两本《java编程思想》,那时候脑袋铁定秀逗了。

但是不得不承认,java在服务器领域具有非常强的优势,很多很多的大企业采用java作为其服务器的编程语言。典型的就是淘宝,据传杭州的很多软件公司都不用java的,你用java就等于给淘宝培养人才了。(不过我发现他们很多基础组件譬如TFS这些的可是c++的哟!)

java是门好语言,只是我个人不怎么喜欢,可能我就是太小众了,只对c语言体系的感兴趣。所以很多公司我去不了,哈哈!

erlang

受《计算机程序的构造与解释》影像,我一直想学一门函数式编程语言,最开始玩的是elisp,谁叫以前我是个深度的emacser(后来竟然变成了一个vimer,再后来就是sublimer,这世界真神奇)。

后来还是决定好好学习一下erlang,也第一次领略到了函数式编程的魅力。自己唯一用erlang开发过的东西就是bt下载的客户端,不过后来发现用处不大就没继续进行下去了。(好吧,我承认当时想下岛国的东西)

学习erlang之后最大的优势在于现在能看懂很多优秀的erlang项目,譬如我在做moonmq以及公司的推送服务的时候,研究了rabbitmq,这玩意可是用erlang写的,而我竟然还能看懂,太佩服我了。

还有么?

想想自己还学了哪些语言?貌似没了,不知道awk算不算一门。看来我会得语言真不多。

后续可能会学的

逆水行舟,不进则退,计算机发展这么迅速,我也需要不断提升自己,其中学习一门新的语言可能是一个很好的提升途径,至少能为我打开一扇门。譬如,如果掌握了日文,就能更好的理解岛国片的精髓。我不会日文,所以还是个门外汉。

ruby

ruby是一门很优雅的语言,很多大神级别的人物推荐,github貌似也是ruby的幕后推手。

因为ROR的兴起,使得ruby更加流行。看来,一个好的框架库对于语言的推广帮助真挺大的。相比而言,python有django,tornado等,光选择适合自己的就得费点时间。

ruby可以算是一门完全面向对象的语言,连number这种的都是对象,而且看了几本Matz的书,觉得这哥们挺不错的,对技术的感悟很深,所以更让我有兴趣去了解ruby了。

javascript

作为一个技术人员,没一个自己的个人网站怎么行,我的阿里云都是包年买的(明年还是买国外的vps吧),自己的个人站点还无影无踪。

好吧,我完全不会javscript,看着css就头疼,没准我从一开始想自己写代码搭建个人站点这个步子就迈的太大,扯着蛋了。如果先用一个开源的框架搭建起来,再自己调整完善,可能会更好。但无论怎样,javascript这门语言还是要学习了解的,尤其是以后随着html5的流行,加之node.js疯狂流行,这门语言也会愈发的发光发热。

C Sharp

其实本来不准备跟ms的东西扯上关系的,虽然vs是一个很强大的开发工具,但是我自从换成mac之后就不准备再迁回windows。

只是c#我可能是必须要学会的,因为那个坑爹的unity3d,虽然unity3d也提供了其它语言的支持(譬如伪javascript),但是大量的开发者还是选用了c#,至少在中国我问过很多朋友,都妥妥的用c#,既然这样,我也只能考虑学习使用了。

至于我为啥蛋疼的想玩unity3d,毕竟干了很多年游戏开发,一直有自己弄一个简单小游戏的梦想,还是妥妥的unity3d吧。

自己造一个?

语言千千万,我不可能全部学会的,而且以后没准因为业务的需要,没准都会自己造一门语言,也就是DSL。不过这个貌似还离我比较遥远,编译原理的东西太差了(说多了都是泪呀)。自己写词法分析还成,后面就菜了。这也是Mixer一直没啥进展的原因。不过已经买了龙书,在学习屠龙秘籍,希望成为顶尖高手吧。

后记

写了这么多,看来随着年岁的增加,越来越啰嗦了。不是有一句古话:吾生也有涯,而知也无涯 。以有涯随无涯,殆已。不过不停地追逐不也是乐趣一件?

只是,现在我首先要做的就是向我老婆申请资金升级电脑了吧!

高性能NoSQL LedisDB设计2

ledisdb现在已经支持replication机制,为ledisdb的高可用做出了保障。

使用

假设master的ip为10.20.187.100,端口6380,slave的ip为10.20.187.101,端口为6380.

首先我们需要master打开binlog支持,在配置文件中指定:

use_bin_log : true

在slave的机器上面我们可以通过配置文件指定slaveof开启replication,或者通过命令slaveof显示的开启或者关闭。

slaveof 10.20.187.100 6380

ledisdb的replication机制参考了redis以及mysql的相关实现,下面简单说明。

redis replication

redis的replication机制主要介绍在这里,已经说明的很详细了。

  • slave向master发送sync命令
  • master将其当前的数据dump到一个文件,同时在内存中缓存新增的修改命令
  • 当数据dump完成,master就将其发送给slave
  • slave接受完成dump数据之后,将其本机先前的数据清空,然后在导入dump的数据
  • master再将先前缓存的命令发送给slave

在redis2.8之后,为了防止断线导致重新生成dump,redis增加了psync命令,在断线的时候master会记住当前的同步状态,这样下次就能进行断点续传了。

mysql replication

mysql的replication主要是通过binlog的同步来完成的。在master的任何数据更新,都会写入binlog,至于binlog的格式这里不再累述。

假设binlog的basename为mysql,index文件名字为mysql-bin.index,该文件记录着当前所有的binlog文件。

binlog有max file size的配置,当binlog写入的的文件大小超过了该值,mysql就会生成一个新的binlog文件。当mysql服务重启的时候,也会生成一个新的binlog文件。

在Percona的mysql版本中,binlog还有一个max file num的设置,当binlog的文件数量超过了该值,mysql就会删除最早的binlog。

slave有一个master.info的文件,用以记录当前同步master的binlog的信息,主要就是当前同步的binlog文件名以及数据偏移位置,这样下次重新同步的时候就能从该位置继续进行。

slave同步的数据会写入relay log中,同时在后台有另一个线程将relay log的数据存入mysql。

因为master的binlog可能删除,slave同步的时候可能会出现binlog丢失的情况,mysql通过dump+binlog的方式解决,其实也就是slave完全的dump master数据,在生成的dump中也同时会记录当前的binlog信息,便于下次继续同步。

ledisdb replication

ledisdb的replication机制参考了redis以及mysql,支持fullsync以及增量sync。

master没有采用aof机制,而是使用了binlog,通过指定max file size以及max file num用来控制binlog的总体大小,这样我就无需关心aof文件持续增大需要重新rewrite的过程了。

binlog文件名格式如下:

ledis-bin.0000001
ledis-bin.0000002

binlog文件名的后缀采用数字递增,后续我们使用index来表示。

slave端也有一个master.info文件,因为ledisdb会严格的保证binlog文件后缀的递增,所以我们只需要记录当前同步的binlog文件后缀的index即可。

整个replication流程如下:

  • 当首次同步或者记录的binlog信息因为master端binlog删除导致不一致的时候,slave会发送fullsync进行全同步。
  • master收到fullsync信息之后,会将当前的数据以及binlog信息dump到文件,并将其发送给slave。
  • slave接受完成整个dump文件之后,清空所有数据,同时将dump的数据导入leveldb,并保存当前dump的binlog信息。
  • slave通过sync命令进行增量同步,sync命令格式如下:

      sync binlog-index binlog-pos
    

    master通过index定位到指定的binlog文件,并seek至pos位置,将其后面的binlog数据发送给slave。

  • slave接收到binlog数据,导入leveldb,如果sync没有收到任何新增数据,1s之后再次sync。

对于最后一点,最主要就是一个问题,即master新增的binlog如何让slave进行同步。对于这点无非就是两种模型,push和pull。

对于push来说,任何新增的数据都能非常及时的通知slave去获取,而pull模型为了性能考虑,不可能太过于频繁的去轮询,略有延时。

mysql采用的是push + pull的模式,当binlog有更新的时候,仅仅通知slave有了更新,slave则是通过pull拉取实际的数据。但是为了支持push,master必须得维持slave的一些状态信息,这稍微又增加了一点复杂度。

ledisdb采用了非常简单的一种方式,定时pull,使用1s的间隔,这样既不会因为轮询太过频繁导致性能开销增大,同时也能最大限度的减少当机数据丢失的风险。

总结

ledisdb的replication机制才刚刚完成,后续还有很多需要完善,但足以使其成为一个高可用的nosql选择了。

ledisdb的网址在这里https://github.com/siddontang/ledisdb,希望感兴趣的童鞋共同参与。

高性能NoSQL LedisDB设计1

ledisdb是一个用go实现的基于leveldb的高性能nosql数据库,它提供多种数据结构的支持,网络交互协议参考redis,你可以很方便的将其作为redis的替代品,用来存储大于内存容量的数据(当然你的硬盘得足够大!)。

同时ledisdb也提供了丰富的api,你可以在你的go项目中方便嵌入,作为你app的主要数据存储方案。

与redis的区别

ledisdb提供了类似redis的几种数据结构,包括kv,hash,list以及zset,(set因为我们用的太少现在不予支持,后续可以考虑加入),但是因为其基于leveldb,考虑到操作硬盘的时间消耗铁定大于内存,所以在一些接口上面会跟redis不同。

最大的不同在于ledisdb对于在redis里面可以操作不同数据类型的命令,譬如(del,expire),是只支持kv操作的。也就是说,对于del命令,ledisdb只支持删除kv,如果你需要删除一个hash,你得使用ledisdb额外提供的hclear命令。

为什么要这么设计,主要是性能考量。leveldb是一个高效的kv数据库,只支持kv操作,所以为了模拟redis中高级的数据结构,我们需要在存储kv数据的时候在key前面加入相关数据结构flag。

譬如对于kv结构的key来说,我们按照如下方式生成leveldb的key:

func (db *DB) encodeKVKey(key []byte) []byte {
    ek := make([]byte, len(key)+2)
    ek[0] = db.index
    ek[1] = kvType
    copy(ek[2:], key)
    return ek
}

kvType就是kv的flag,至于第一个字节的index,后面我们在讨论。

如果我们需要支持del删除任意类型,可能的一个做法就是在另一个地方存储该key对应的实际类型,然后del的时候根据查出来的类型再去做相应处理。这不光损失了效率,也提高了复杂度。

另外,在使用ledisdb的时候还需要明确知道,它只是提供了一些类似redis接口,并不是redis,如果想用redis的全部功能,这个就有点无能为力了。

db select

redis支持select的操作,你可以根据你的业务选择不同的db进行数据的存放。本来ledisdb只打算支持一个db,但是经过再三考虑,我们决定也实现select的功能。

因为在实际场景中,我们不可能使用太多的db,所以select db的index默认范围就是[0-15],也就是我们最多只支持16个db。redis默认也是16个,但是你可以配置更多。不过我们觉得16个完全够用了,到现在为止,我们的业务也仅仅使用了3个db。

要实现多个db,我们开始定了两种方案:

  • 一个db使用一个leveldb,也就是最多ledisdb将打开16个leveldb实例。
  • 只使用一个leveldb,每个key的第一个字节用来标示该db的索引。

这两种方案我们也不知道如何取舍,最后决定采用使用同一个leveldb的方式。可能我们觉得一个leveldb可以更好的进行优化处理吧。

所以我们任何leveldb key的生成第一个字节都是存放的该db的index信息。

KV

kv是最常用的数据结构,因为leveldb本来就是一个kv数据库,所以对于kv类型我们可以很简单的处理。额外的工作就是生成leveldb对应的key,也就是前面提到的encodeKVKey的实现。

Hash

hash可以算是一种两级kv,首先通过key找到一个hash对象,然后再通过field找到或者设置相应的值。

在ledisdb里面,我们需要将key跟field关联成一个key,用来存放或者获取对应的值,也就是key:field这种格式。

这样我们就将两级的kv获取转换成了一次kv操作。

另外,对于hash来说,(后面的list以及zset也一样),我们需要快速的知道它的size,所以我们需要在leveldb里面用另一个key来实时的记录该hash的size。

hash还必须提供keys,values等遍历操作,因为leveldb里面的key默认是按照内存字节升序进行排列的,所以我们只需要找到该hash在leveldb里面的最小key以及最大key,就可以轻松的遍历出来。

在前面我们看到,我们采用的是key:field的方式来存入leveldb的,那么对于该hash来说,它的最小key就是“key:”,而最大key则是“key;”,所以该hash的field一定在“(key:, key;)”这个区间范围。至于为什么是“;”,因为它比“:”大1。所以“key:field”一定小于“key;”。后续zset的遍历也采用的是该种方式,就不在说明了。

List

list只支持从两端push,pop数据,而不支持中间的insert,这样主要是为了简单。我们使用key:sequence的方式来存放list实际的值。

sequence是一个int整形,相关常量定义如下:

listMinSeq     int32 = 1000
listMaxSeq     int32 = 1<<31 - 1000
listInitialSeq int32 = listMinSeq + (listMaxSeq-listMinSeq)/2

也就是说,一个list最多存放1<<31 - 2000条数据,至于为啥是1000,我说随便定得你信不?

对于一个list来说,我们会记录head seq以及tail seq,用来获取当前list开头和结尾的数据。

当第一次push一个list的时候,我们将head seq以及tail seq都设置为listInitialSeq。

当lpush一个value的时候,我们会获取当前的head seq,然后将其减1,新得到的head seq存放对应的value。而对于rpush,则是tail seq + 1。

当lpop的时候,我们会获取当前的head seq,然后将其加1,同时删除以前head seq对应的值。而对于rpop,则是tail seq - 1。

我们在list里面一个meta key来存放该list对应的head seq,tail seq以及size信息。

ZSet

zset可以算是最为复杂的,我们需要使用三套key来实现。

  • 需要用一个key来存储zset的size
  • 需要用一个key:member来存储对应的score
  • 需要用一个key:score:member来实现按照score的排序

这里重点说一下score,在redis里面,score是一个double类型的,但是我们决定在ledisdb里面只使用int64类型,原因一是double还是有浮点精度问题,在不同机器上面可能会有误差(没准是我想多了),另一个则是我不确定double的8字节memcmp是不是也跟实际比较结果一样(没准也是我想多了),其实更可能的原因在于我们觉得int64就够用了,实际上我们项目也只使用了int的score。

因为score是int64的,我们需要将其转成大端序存储(好吧,我假设大家都是小端序的机器),这样通过memcmp比较才会有正确的结果。同时int64有正负的区别,负数最高位为1,所以如果只是单纯的进行binary比较,那么负数一定比正数大,这个我们通过在构建key的时候负数前面加”<”,而正数(包括0)加”=”来解决。所以我们score这套key的格式就是这样:

key<score:member //<0
key=score:member //>=0

对于zset的range处理,其实就是确定某一个区间之后通过leveldb iterator进行遍历获取,这里我们需要明确知道的事情是leveldb的iterator正向遍历的速度和逆向遍历的速度完全不在一个数量级上面,正向遍历快太多了,所以最好别去使用zset里面带有rev前缀的函数。

总结

总的来说,用leveldb来实现redis那些高级的数据结构还算是比较简单的,同时根据我们的压力测试,发现性能还能接受,除了zset的rev相关函数,其余的都能够跟redis保持在同一个数量级上面,具体可以参考ledisdb里面的性能测试报告以及运行ledis-benchmark自己测试。

后续ledisdb还会持续进行性能优化,同时提供expire以及replication功能的支持,预计6月份我们就会实现。

ledisdb的代码在这里https://github.com/siddontang/ledisdb,希望感兴趣的童鞋共同参与。

LedisDB嵌入使用介绍

ledisdb现在可以支持嵌入式使用。你可以将其作为一个独立的lib(类似leveldb)直接嵌入到你自己的应用中去,而无需在启动单独的服务。

ledisdb提供的API仍然类似redis接口。首先,你需要创建ledis对象:

import "github.com/siddontang/ledisdb/ledis"

configJson = []byte('{
    "data_db" : 
    {
        "path" : "/tmp/testdb",
        "compression":true,
        "block_size" : 32768,
        "write_buffer_size" : 2097152,
        "cache_size" : 20971520
    }    
}
')

l, _ := ledis.Open(configJson)

data_db就是数据存储的leveldb位置,简单起见,所有的size配置全部使用byte作为单位。

然后我们选择一个db使用,

db, _ := l.Select(0)

类似redis,我们也只支持数字类型的db,最多16个db,索引范围为[0-15]。支持太多的db真没啥意义。

下面是一些简单的例子:

kv

db.Set(key, value)
db.Get(key)
db.SetNX(key, value)
db.Incr(key)
db.IncrBy(key, 10)
db.Decr(key)
db.DecrBy(key, 10)

db.MSet(KVPair{key1, value1}, KVPair{key2, value2})
db.MGet(key1, key2)

list

db.LPush(key, value1, value2, value3)
db.RPush(key, value4, value5, value6)

db.LRange(key, 1, 10)
db.LIndex(key, 10)

db.LLen(key)

hash

db.HSet(key, field1, value1)
db.HMSet(key, FVPair{field1, value1}, FVPair{field2, value2})

db.HGet(key, field1)

db.HGetAll()
db.HKeys()

zset

db.ZAdd(key, ScorePair{score1, member1}, ScorePair{score2, member2})

db.ZCard(key)

//range by score [0, 100], withscores = true and no limit
db.ZRangeByScore(key, 0, 100, true, 0, -1)

//range by score [0, 100], withscores = true and limit offset = 10, count = 10
db.ZRangeByScore(key, 0, 100, true, 10, 10)

db.ZRank(key, member1)

db.ZCount(key, member1)

ledisdb的源码在这里https://github.com/siddontang/ledisdb,欢迎反馈。

Go语言最佳实践

最近看了一篇关于go产品开发最佳实践的文章,go-in-procution。作者总结了他们在用go开发过程中的很多实际经验,我们很多其实也用到了,鉴于此,这里就简单的写写读后感,后续我也争取能将这篇文章翻译出来。后面我用soundcloud来指代原作者。

开发环境

在soundcloud,每个人使用一个独立的GOPATH,并且在GOPATH直接按照go规定的代码路径方式clone代码。

$ mkdir -p $GOPATH/src/github.com/soundcloud
$ cd $GOPATH/src/github.com/soundcloud
$ git clone git@github.com:soundcloud/roshi

对于go来说,通常的工程管理应该是如下的目录结构:

proj/
    src/
        modulea/
            a.go
        moudleb/
            b.go
        app/
            main.go
    pkg/
    bin/

然后我们在GOPATH里面将proj的路径设置上去,这样就可以进行编译运行了。这本来没啥,但是如果我们要将其代码提交到github,并允许另外的开发者使用,我们就不能将整个proj的东西提交上面,如果提交了,就很蛋疼了。外面的开发者可能这么引用:

import "github.com/yourname/proj/src/modulea"

但是我们自己在代码里面就可以直接:

import "github.com/yourname/proj/modulea"

如果外面的开发者需要按照去掉src的引用方式,只能把GOPATH设置到proj目录,如果import的多了,会让人崩溃的。

我曾今也被这事情给折腾了好久,终于再看了vitess的代码之后,发现了上面这种方式,觉得非常不错。

工程目录结构

如果一个项目中文件数量不是很多,直接放在main包里面就行了,不需要在拆分成多个包了,譬如:

github.com/soundcloud/simple/
    README.md
    Makefile
    main.go
    main_test.go
    support.go
    support_test.go

如果真的有公共的类库,在拆分成单独的包处理。

有时候,一个工程可能会包括多个二进制应用。譬如,一个job可能需要一个server,一个worker或者一个janitor,在这种情况下,建立多个子目录作为不同的main包,分别放置不同的二进制应用。同时使用另外的子目录实现公共的函数。

github.com/soundcloud/complex/
README.md
Makefile
complex-server/
    main.go
    main_test.go
    handlers.go
    handlers_test.go
complex-worker/
    main.go
    main_test.go
    process.go
    process_test.go
shared/
    foo.go
    foo_test.go
    bar.go
    bar_test.go

这点我的做法稍微有一点不一样,主要是参考vitess,我喜欢建立一个总的cmd目录,然后再在里面设置不同的子目录,这样外面就不需要猜测这个目录是库还是应用。

代码风格

代码风格这没啥好说的,直接使用gofmt解决,通常我们也约定gofmt的时候不带任何其他参数。

最好将你的编辑器配置成保存代码的时候自动进行gofmt处理。

Google最近发布了go的代码规范,soundcloud做了一些改进:

  • 避免命名函数返回值,除非能明确的表明含义。
  • 尽量少用make和new,除非真有必要,或者预先知道需要分配的大小。
  • 使用struct{}作为标记值,而不是bool或者interface{}。譬如set我们就用map[string]struct{}来实现,而不是map[string]bool。

如果一个函数有多个参数,并且单行长度很长,需要拆分,最好不用java的方式:

// Don't do this.
func process(dst io.Writer, readTimeout,
    writeTimeout time.Duration, allowInvalid bool,
    max int, src <-chan util.Job) {
    // ...
}

而是使用:

func process(
    dst io.Writer,
    readTimeout, writeTimeout time.Duration,
    allowInvalid bool,
    max int,
    src <-chan util.Job,
) {
    // ...
}

类似的,当构造一个对象的时候,最好在初始化的时候就传入相关参数,而不是在后面设置:

f := foo.New(foo.Config{    
    Site: "zombo.com",            
    Out:  os.Stdout,
    Dest: conference.KeyPair{
        Key:   "gophercon",
        Value: 2014,
    },
})

// Don't do this.
f := &Foo{} // or, even worse: new(Foo)
f.Site = "zombo.com"
f.Out = os.Stdout
f.Dest.Key = "gophercon"
f.Dest.Value = 2014

如果一些变量是后续通过其他操作才能获取的,我觉得就可以在后续设置了。

配置

soundcloud使用go的flag包来进行配置参数的传递,而不是通过配置文件或者环境变量。

flag的配置是在main函数里面定义的,而不是在全局范围内。

func main() {
    var (
        payload = flag.String("payload", "abc", "payload data")
        delay   = flag.Duration("delay", 1*time.Second, "write delay")
    )
    flag.Parse()
    // ...
}

关于使用flag作为配置参数的传递,我持保留意见。如果一个应用需要特别多的配置参数,使用flag比较让人蛋疼了。这时候,使用配置文件反而比较好,我个人倾向于使用json作为配置,原因在这里

日志

soundcloud使用的是go的log日志,他们也说明了他们的log并不需要太多的其他功能,譬如log分级等。对于log,我参考python的log写了一个,在这里。该log支持log级别,支持自定义loghandler。

soundcloud还提到了一个telemetry的概念,我真没好的办法进行翻译,据我的了解可能就是程序的信息收集,包括响应时间,QPS,内存运行错误等。

通常telemetry有两种方式,推和拉。

推模式就是主动的将信息发送给特定的外部系统,而拉模式则是将其写入到某一个地方,允许外部系统来获取该数据。

这两种方式都有不同的定位,如果需要及时,直观的看到数据,推模式是一个很好的选择,但是该模式可能会占用过多的资源,尤其是在数据量大的情况下面,会很消耗CPU和带宽。

soundcloud貌似采用的是拉模型。

关于这点我是深表赞同,我们有一个服务,需要将其信息发送到一个统计平台共后续的信息,开始的时候,我们使用推模式,每产生一条记录,我们直接通过http推给后面的统计平台,终于,随着压力的增大,整个统计平台被我们发挂了,拒绝服务。最终,我们采用了将数据写到本地,然后通过另一个程序拉取再发送的方式解决。

测试

soundcloud使用go的testing包进行测试,然后也使用flag的方式来进行集成测试,如下:

// +build integration

var fooAddr = flag.String(...)

func TestToo(t *testing.T) {
    f, err := foo.Connect(*fooAddr)
    // ...
}

因为go test也支持类似go build那种flag传递,它会默认合成一个main package,然后在里面进行flag parse处理。

这种方式我现在没有采用,我都是在测试用例里面直接写死了一个全局的配置,主要是为了方便的在根目录进行 go test ./…处理。不过使用flag的方式我觉得灵活性很大,后面如果有可能会考虑。

go的testing包提供的功能并不强,譬如没有提供assert_equal这类东西,但是我们可以通过reflect.DeepEqual来解决。

依赖管理

这块其实也是我非常想解决的。现在我们的代码就是很暴力的用go get来解决依赖问题,这个其实很有风险的,如果某一个依赖包更改了接口,那么我们go get的时候可能会出问题了。

soundcloud使用了一种vendor的方式进行依赖管理。其实很简单,就是把依赖的东西全部拷贝到自己的工程下面,当做自己的代码来使用。不过这个就需要定期的维护依赖包的更新了。

如果引入的是一个可执行包,在自己的工程目录下面建立一个_vendor文件夹(这样go的相关tool例如go test就会忽略该文件夹的东西)。把_vendor作为单独的GOPATH,例如,拷贝github.com/user/dep到_vendor/src/github.com/user/dep下面。然后将_vendor加入自己的GOPATH中,如下:

GO ?= go
GOPATH := $(CURDIR)/_vendor:$(GOPATH)

all: build

build:
    $(GO) build

如果引入的是一个库,那么将其放入vendor目录中,将vendor作为package的前缀,例如拷贝github.com/user/dep到vendor/user/dep,并更改所有的相关import语句。

因为我们并不需要频繁的对这些引入的工程进行go get更新处理,所以大多数时候这样做都很值。

我开始的时候也采用的是类似的做法,只不过我不叫vendor,而叫做3rd,后来为了方便还是决定改成直接go get,虽然知道这样风险比较大。没准后续使用godep可能是一个不错的解决办法。

构建和部署

soundcloud在开发过程中直接使用go build来构建系统,然后使用一个Makefile来处理正式的构建。

因为soundcloud主要部署很多无状态的服务,类似Heroku提供了很简单的一种方式:

$ git push bazooka master
$ bazooka scale -r <new> -n 4 ...
$ # validate
$ bazooka scale -r <old> -n 0 ...

这方面,我们直接使用一个简单的Makefile来构建系统,如下:

all: build 

build:
    go install ${SRC}

clean:
    go clean -i ${SRC}

test:
    go test ${SRC} 

应用程序的发布采用最原始的scp到目标机器在重启的方式,不过现在正在测试使用salt来发布应用。而对于应用程序的启动,停止这些,我们则使用supervisor来进行管理。

总结

总的来说,这篇文章很详细的讲解了用go进行产品开发过程中的很多经验,希望对大家有帮助。

高性能NoSQL LedisDB介绍

起因

ledisdb是一个参考ssdb,采用go实现,底层基于leveldb,类似redis的高性能nosql数据库,提供了kv,list,hash以及zset数据结构的支持。

我们现在的应用极大的依赖redis,但随着我们用户量越来越大,redis的内存越来越不够用,并且replication可能还会导致超时问题。虽然后续我们可以通过添加多台机器来解决,但是在现有机器配置下面,我们仍希望单台机器承载更多的用户。另外,因为业务的特性,我们其实并不需要将所有的数据放到内存,只需要存放当前活跃用户。

经过我们的调研,发现ssdb已经很好的帮我们解决了这个问题,它提供了跟redis一致的接口(当然有些地方还是稍微不同),但是底层采用leveldb进行存储。根据其官网的描述,性能已经接近甚至超越了redis。

本着造轮子的精神,我决定用go实现一个类似的db,取名为ledisdb,也就是level-redis-db,为啥不用现成的ssdb,我觉得有如下几个原因:

  • go语言开发的快速,这点毋庸置疑,虽然性能上面铁定离c++的代码有差距,但是我能够快速的进行原型搭建并实验。实际上,我在很短的时间里面就开发出了ledisdb,让我后续继续开发有了信心。
  • leveldb的研究,我一直很想将leveldb应用到我们的项目中,作为本机热点数据的首选数据存储方式,通过ledisdb,让我对leveldb的使用有了很多经验。
  • redis的熟悉,虽然我用了很久的redis,但是很多redis的命令仍然需要去查手册,通过实现ledisdb,我更加熟悉了redis的命令,同时,因为要了解这个命令redis如何实现,对redis内部又重新来了一次剖析。

在准备开发ledisdb的时候,我就在思索一个问题,我需不需要开发另一个redis?其实这是一个很明确的问题,我不需要另一个redis。ledisdb虽然参考了redis,但为了实现简单,有时候我做了很多减法或者变更,譬如对于zset这种数据结构,我就只支持int64类型的score,而redis的score是double类型的,具体原因后续讲解zset的时候详细说明。

所以,我们可以认为,ledisdb是一个基于redis通信协议,提供了多种高级数据结构的nosql数据库,它并不是另一个redis。

编译安装

因为ledisdb是用go写的,所以首先需要安装go以及配置GOROOT,GOPATH。

mkdir $WORKSPACE
cd $WORKSPACE
git clone git@github.com:siddontang/ledisdb.git src/github.com/siddontang/ledisdb

cd src/github.com/siddontang/ledisdb

#构建leveldb,如果已经安装了,可忽略
./build_leveldb.sh  

#安装ledisdb go依赖
. ./bootstap.sh     

#配置GOPATH等环境变量
. ./dev.sh          

go install ./... 

具体的安装说明,可以查看代码目录下面的readme。

Example

使用ledisdb很简单,只需要运行:

./ledis-server -config=/etc/ledis.json

ledisdb的配置文件采用json格式,为啥选用json,我在使用json作为主要的配置格式里面有过说明。

我们可以使用任何redis客户端连接ledisdb,譬如redis-cli,如下:

127.0.0.1:6380> set a 1
OK
127.0.0.1:6380> get a
"1"
127.0.0.1:6380> incr a
(integer) 2
127.0.0.1:6380> mset b 2 c 3
OK
127.0.0.1:6380> mget a b c
1) "2"
2) "2"
3) "3"

leveldb

因为leveldb是c++写的,所以在go里面需要使用,cgo是一种很好的方式。这里,我直接使用了levigo这个库,并在上面进行了封装,详见这里。虽然有一个go-leveldb,无奈仍不能用。

cgo的性能开销还是有的,这点在我做benchmark的时候就明显感觉出来,不过后续优化的空间很大,譬如将多个leveldb的调用逻辑该用c重写,这样只需要一次cgo就可以了。不过这个后续在考虑。

leveldb的一些参数在构建编译的时候是需要调整的,这点我没啥经验,只能google和参考ssdb。譬如下面这几个:

+ db/dbformat.h

// static const int kL0_SlowdownWritesTrigger = 8;
static const int kL0_SlowdownWritesTrigger = 16;

// static const int kL0_StopWritesTrigger = 12;
static const int kL0_StopWritesTrigger = 64;

+ db/version_set.cc

//static const int kTargetFileSize = 2 * 1048576;
static const int kTargetFileSize = 32 * 1048576;

//static const int64_t kMaxGrandParentOverlapBytes = 10 * kTargetFileSize;
static const int64_t kMaxGrandParentOverlapBytes = 20 * kTargetFileSize;

相关参数的调优,只能等我后续深入研究leveldb了在好好考虑。

性能测试

任何一个服务端服务没有性能测试报告那就是耍流氓,我现在只是简单的用了redis_benchmark进行测试,测试环境为一台快两年的老爷mac air机器。

测试语句:

redis-benchmark -n 10000 -t set,incr,get,lpush,lpop,lrange,mset -q

redis-benchmark默认没有hash以及zset的测试,后续我在自己加入。

leveldb配置:

compression       = false
block_size        = 32KB
write_buffer_size = 64MB
cache_size        = 500MB

redis

SET: 42735.04 requests per second
GET: 45871.56 requests per second
INCR: 45248.87 requests per second
LPUSH: 45045.04 requests per second
LPOP: 43103.45 requests per second
LPUSH (needed to benchmark LRANGE): 44843.05 requests per second
LRANGE_100 (first 100 elements): 14727.54 requests per second
LRANGE_300 (first 300 elements): 6915.63 requests per second
LRANGE_500 (first 450 elements): 5042.86 requests per second
LRANGE_600 (first 600 elements): 3960.40 requests per second
MSET (10 keys): 33003.30 requests per second

ssdb

SET: 35971.22 requests per second
GET: 47393.37 requests per second
INCR: 36630.04 requests per second
LPUSH: 37174.72 requests per second
LPOP: 38167.94 requests per second
LPUSH (needed to benchmark LRANGE): 37593.98 requests per second
LRANGE_100 (first 100 elements): 905.55 requests per second
LRANGE_300 (first 300 elements): 327.78 requests per second
LRANGE_500 (first 450 elements): 222.36 requests per second
LRANGE_600 (first 600 elements): 165.30 requests per second
MSET (10 keys): 33112.59 requests per second

ledisdb

SET: 38759.69 requests per second
GET: 40160.64 requests per second
INCR: 36101.08 requests per second
LPUSH: 33003.30 requests per second
LPOP: 27624.31 requests per second
LPUSH (needed to benchmark LRANGE): 32894.74 requests per second
LRANGE_100 (first 100 elements): 7352.94 requests per second
LRANGE_300 (first 300 elements): 2867.79 requests per second
LRANGE_500 (first 450 elements): 1778.41 requests per second
LRANGE_600 (first 600 elements): 1590.33 requests per second
MSET (10 keys): 21881.84 requests per second

可以看到,ledisdb的性能赶redis以及ssdb还是有差距的,但也不至于不可用,有些差别并不大。至于为啥lrange比ssdb高,我比较困惑。

后续的测试报告,我会不断在benchmark文件里面更新。

Todo。。。。。。

ledisdb还是一个非常新的项目,比起ssdb已经在生产环境中用了很久,还有很多路要走,还有一些重要的功能需要实现,譬如replication等。

欢迎有兴趣的童鞋一起参与进来,在漫漫程序开发路上有一些好基友可是很幸运的。

为什么使用Go

这里,我并不打算引起语言争论的口水仗,我并不是什么大牛,对语言的造诣也不深,只是想通过自己实际的经历,来说说为什么我在项目中选择go。

其他语言的经历

C++

在接触go之前,我已经有多年的c++开发经验。主要用在游戏服务端引擎开发以及P2P上面,那可是一段痛并快乐的时期,以至于我看到任何的程序钉子问题都觉得可以用c++这把锤子给敲定。但是对于互联网项目开发来说,除非你的团队整体的c++技术水平nb,并且有很强的代码规范,不然真可能是一场灾难,更别说我们现有团队几乎没其他人会这玩意了。

本来,我打算在现有项目中的推送系统中使用c++,并用业余时间写好了一个网络底层库libtnet,但后来还是决定打住,因为没有人能够协助我开发。令我比较欣慰的是,libtnet有一个游戏公司在使用,现处于内部测试阶段,即将放出去,我倒是很期待他们的好消息。

Lua

在做游戏的过程中,我也学会了lua这门语言,并且还有幸接触并完善了云风在Lua 不是 C++中提到的那个恐怖的lua,c++粘合层。

lua真的是一门非常好的语言,性能高,开发快速。不光游戏公司大量使用,在互联网领域,因为openresty的流行,一些公司(包括我们)也开始在web端使用lua进行开发。(颇为自豪的是还给openresty反馈过几个bug)

但是,lua因为太短小精悍,功能库并不多,很多需要自己去实现,而且,写出高性能,高质量lua代码也并不是很容易的事情。另外,因为其动态语言的特性,我们也栽了不少坑,这个后续在详说。

Python

在我来现有的团队之前,他们就已经使用python进行整个系统的开发,甚至包括客户端GUI(这对客户端童鞋当时就是一个灾难,后来换成Qt就舒爽了)。

python的好处不必说,从数不清的公司用它进行开发就知道,库非常丰富,代码简洁,开发迅速。

但是,在项目中经过两年多python开发之后,我们渐渐出现了很多问题:

  • 性能,python的性能是比较偏低的,对于很多性能热点代码,通常都会采用其他的方式实现。在我们的项目中,需要对任何API调用进行签名认证,认证服务我们开始使用的是tornado实现,但很不幸运的是,放到外网并没有顶住压力。所以我们引入openresty,专门用以解决性能问题。
  • 部署,python的库因为太丰富了,所以我们的童鞋引入了很多的库,个人感觉在pythoner的世界里面,可没有造轮子的兴趣。有时候发版本的时候,我们会因为忘记安装一个库导致程序无法运行。这可能跟我们团队没有成熟运维经验有关,后续通过salt,puppet这种发布工具应该能解决。
  • 质量,通常我们都认为,因为python代码的简洁,我们很容易的能写出高质量的代码,但是如果没有好的代码控制手段,用python也仍然能写出渣的代码,我甚至觉得因为其灵活性,可能会更容易写出烂的代码。这可以说是我们团队的教训。

这里,我并没有喷python的意思,它真的是一门好语言,我能够通过它快速的构建原型,验证我的想法,而且还一直在使用。只是在项目中,我们的一些疏忽,导致代码不可控了,到了不得不重构的地步了。

Why GO?

前面说了我的语言经历,以及项目到了重构地步的原因,但是为什么会是go呢?我们可以有很多其他的选择,譬如java,erlang,或者仍然采用python。我觉得有很多因素考量:

  • 静态,go是一门静态语言,有着强类型约束,所以我们不太可能出现在python中变量在运行时类型不匹配(譬如int + string)这样的runtime error。 在编译阶段就能够帮我们发现很多问题,不用等到运行时。(当然,这个静态语言都能做到)

  • 代码规范,很多人都比较反感go强制的编码规范,譬如花括号的位置。但我觉得,就因为强制约定,所以大家写出来的go代码样子都差不多,不用费心再去深究代码样式问题。而且我发现,因为规范统一,我很容易就能理解别人写的代码。

  • 库支持,go的库非常丰富,而且能通过go get非常方便的获取github,google code上面的第三方库(质量你自己得担着了),再不行,用go自己造轮子也是很方便的,而且造的轮子通常都比较稳定。

  • 开发迅速,不得不说,当你习惯用go开发之后,用go开发功能非常的快,相对于静态语言c++,开发的效率快的没话说,我觉得比python都不差,而且质量有保证。我们花了不到一个星期进行推送服务核心功能开发,到现在都没怎么变动,稳定运行。

  • 部署方便,因为是静态的,只需要build成一个可运行程序就可以了,部署的时候直接扔一个文件过去,不需要像python那样安装太多的依赖库。

GO特性

go现在的这个样子,有些人喜欢,有些人不喜欢,我无法知道为啥google那帮人把go设计成这样,但是我觉得,既然存在,就有道理,我只需要知道什么该用,什么不该用就可以了。

gc

GO提供了gc,这对于c++的童鞋来说,极大的减少了在内存上面犯错的机会,只是go的gc这个效率还真的不好恭维,比起java来说,还有很大的提升空间。

所以有时候写代码,我们还得根据tuning来提升gc的效率,譬如采用内存池的方式来管理大块的slice分配,采用no copy的方式来进行string,slice的互转。

不过go1.3貌似gc性能有了很大的改善,这点让我比较期待。

defer

go的defer其实是一个让人又爱又恨的东西,对于防止资源泄露,defer可是一个很不错的东西,但是滥用defer可是会让你面临很严重的内存问题,尤其是像下面的代码:

for {
    defer func(){
        //do somthing
    }
}

别以为go会在调用完成defer之后就好好的进行gc回收defer里面的东西,在我们进行内存profile的时候,发现大量的内存占用都是defer引起的。所以使用起来需要特别谨慎。

但我觉得,这个go应该会稍微改善,在go1.3里面,也有了对defer的优化。

error

也许error是一个让人争议很大的东西,现代方式的exception那里去呢?但是我觉得error能够非常明确的告诉使用者该函数会有错误返回,如果使用exception,除非文档足够详细,我还真不知道哪里就会蹦出一个异常了。

只是,go又提供了类似exception的defer,panic,recover,这是要闹哪出。

其实这篇文章我觉得已经解释的很好了,go程序的惯例是对外的API使用error,而内部错误处理可以用defer,recover和panic来简化流程。

其实这倒跟我一贯的编程准则对应,在团队在用python进行开发的时候,我们都明确要求库对外提供的API需要使用返回值来表示错误,而在内部可以使用try,catch异常机制。

interface

go提供了interface来进行抽象编程。何谓接口,最通常的例子就是鸭子的故事,“当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子“。

在go里面,interface就是一堆方法的集合,如果某个对象实现了这些方法,那么该对象可以就算是该interface。使用interface,我们可以很方便的实现非侵入式编程,进行模块功能的替换。

对于长时间沉浸c++和python的童鞋来说,一下子要用interface来解决抽象问题,可能会很不适应。但当习惯之后,你会发现,其实interface非常的灵活方便。

写到后面

在使用的时候,我们需要知道go到底适用在什么地方,譬如我们现在也就将API服务使用go重构,我们可没傻到用go去替换openresty。

总之,go是一门很新的语言,国内也已经有很多公司开始吃这个螃蟹,也有成功的例子了,而我们也正开始了这段旅程。