go

我为什么从python转向go

应puppet大拿刘宇的邀请,我去西山居运维团队做了一个简短分享,谈谈为什么我要将我们的项目从python转向go。

坦白的讲,在一帮python用户面前讲为什么放弃python转而用go其实是一件压力蛮大的事情,语言之争就跟vim和emacs之争一样,是一个永恒的无解话题,稍微不注意就可能导致粉丝强烈地反击。所以我只会从我们项目实际情况出发,来讲讲为什么我最终选择了go。

为什么放弃python

首先,我其实得说说为什么我们会选择python。在我加入企业快盘团队之前,整个项目包括更早的金山快盘都是采用python进行开发的。至于为什么这么选择,当时的架构师葱头告诉我,主要是因为python上手简单,开发迅速。对于团队里面大部分完全没服务端开发经验的同学来说,python真的是一个很好的选择。

python的简单高效,我是深有体会的。当时私有云项目也就几个程序员,但是我们要服务多家大型企业,进行定制化的开发,多亏了python,我们才能快速出活。后来企业快盘挂掉之后,我们启动轻办公项目,自然也使用python进行了原始版本的构建。

python虽然很强大,但我们在使用的时候也碰到了一些问题,主要由如下几个方面:

  • 动态语言

    python是一门动态强类型语言。但是,仍然可能出现int + string这样的运行时错误,因为对于一个变量,在写代码的时候,我们有时候很容易就忘记这个变量到底是啥类型的了。

    在python里面,可以允许同名函数的出现,后一个函数会覆盖前一个函数,有一次我们系统一个很严重的错误就是因为这个导致的。

    上面说到的这些,静态语言在编译的时候就能帮我们检测出来,而不需要等到运行时出问题才知道。虽然我们有很完善的测试用例,但总有case遗漏的情况。所以每次出现运行时错误,我心里都想着如果能在编译的时候就发现该多好。

  • 性能

    其实这个一直是很多人吐槽python的地方,但python有它适合干的事情,硬是要用python进行一些高性能模块的开发,那也有点难为它了。

    python的GIL导致无法真正的多线程,大家可能会说我用多进程不就完了。但如果一些计算需要涉及到多进程交互,进程之间的通讯开销也是不得不考虑的。

    无状态的分布式处理使用多进程很方便,譬如处理http请求,我们就是在nginx后面挂载了200多个django server来处理http的,但这么多个进程自然导致整体机器负载偏高。

    但即使我们使用了多个django进程来处理http请求,对于一些超大量请求,python仍然处理不过来。所以我们使用openresty,将高频次的http请求使用lua来实现。可这样又导致使用两种开发语言,而且一些逻辑还得写两份不同的代码。

  • 同步网络模型

    django的网络是同步阻塞的,也就是说,如果我们需要访问外部的一个服务,在等待结果返回这段时间,django不能处理任何其他的逻辑(当然,多线程的除外)。如果访问外部服务需要很长时间,那就意味着我们的整个服务几乎在很长一段时间完全不可用。

    为了解决这个问题,我们只能不断的多开django进程,同时需要保证所有服务都能快速的处理响应,但想想这其实是一件很不靠谱的事情。

  • 异步网络模型

    tornado的网络模型是异步的,这意味着它不会出现django那样因为外部服务不可用导致这个服务无法响应的问题。话说,比起django,我可是非常喜欢tornado的,小巧简单,以前还写过几篇深入剖析tornado的文章了。

    虽然tornado是异步的,但是python的mysql库都不支持异步,这也就意味着如果我们在tornado里面访问数据库,我们仍然可能面临因为数据库问题造成的整个服务不可用。

    其实异步模型最大的问题在于代码逻辑的割裂,因为是事件触发的,所以我们都是通过callback进行相关处理,于是代码里面就经常出现干一件事情,传一个callback,然后callback里面又传callback的情况,这样的结果就是整个代码逻辑非常混乱。

    python没有原生的协程支持,虽然可以通过gevent,greenlet这种的上patch方式来支持协程,但毕竟更改了python源码。另外,python的yield也可以进行简单的协程模拟,但毕竟不能跨堆栈,局限性很大,不知道3.x的版本有没有改进。

  • 开发运维部署

    当我第一次使用python开发项目,我是没成功安装上项目需要的包的,光安装成功mysql库就弄了很久。后来,是一位同事将他整个python目录打包给我用,我才能正常的将项目跑起来。话说,现在有了docker,是多么让人幸福的一件事情。

    而部署python服务的时候,我们需要在服务器上面安装一堆的包,光是这一点就让人很麻烦,虽然可以通过puppet,salt这些自动化工具解决部署问题,但相比而言,静态编译语言只用扔一个二进制文件,可就方便太多了。

  • 代码失控

    python非常灵活简单,写c几十行代码才能搞定的功能,python一行代码没准就能解决。但是太简单,反而导致很多同学无法对代码进行深层次的思考,对整个架构进行细致的考量。来了一个需求,啪啪啪,键盘敲完开速实现,结果就是代码越来越混乱,最终导致了整个项目代码失控。

    虽然这也有我们自身的原因,譬如没好的代码review机制,没有好的项目规范,但个人感觉,如果一个程序员没经过良好的编码训练,用python很容易就写出烂的代码,因为太自由了。

    当然,我这里并不是说用python无法进行大型项目的开发,豆瓣,dropbox都是很好的例子,只是在我们项目中,我们的python代码失控了。

上面提到的都是我们在实际项目中使用python遇到的问题,虽然最终都解决了,但是让我愈发的觉得,随着项目复杂度的增大,流量性能压力的增大,python并不是一个很好的选择。

为什么选择go

说完了python,现在来说说为什么我们选择go。其实除了python,我们也有其他的选择,java,php,lua(openresty),但最终我们选择了go。

虽然java和php都是最好的编程语言(大家都这么争的),但我更倾向一门更简单的语言。而openresty,虽然性能强悍,但lua仍然是动态语言,也会碰到前面说的动态语言一些问题。最后,前金山许式伟用的go,前快盘架构师葱头也用的go,所以我们很自然地选择了go。

go并不是完美,一堆值得我们吐槽的地方。

  • error,好吧,如果有语言洁癖的同学可能真的受不了go的语法,尤其是约定的最后一个返回值是error。项目里面经常会充斥这样的代码:

      if _, err := w.Write(data1); err != nil {
          returun err
      }
      if _, err := w.Write(data2); err != nil {
          returun err
      }
    

    难怪有个梗是对于一个需求,java的程序员在写配置的时候,go程序员已经写了大部分代码,但是当java的程序员写完的时候,go程序员还在写err != nil

    这方面,errors-are-values倒是推荐了一个不错的解决方案。

  • 包管理,go的包管理太弱了,只有一个go get,也就是如果不小心更新了一个外部库,很有可能就导致现有的代码编译不过了。虽然已经有很多开源方案,譬如godep以及现在才出来的gb等,但毕竟不是官方的。貌似google也是通过vendor机制来管理第三方库的。希望go 1.5或者之后的版本能好好处理下这个问题。

  • GC,java的GC发展20年了,go才这么点时间,gc铁定不完善。所以我们仍然不能随心所欲的写代码,不然在大请求量下面gc可能会卡顿整个服务。所以有时候,该用对象池,内存池的一定要用,虽然代码丑了点,但好歹性能上去了。

  • 泛型,虽然go有inteface,但泛型的缺失会让我们在实现一个功能的时候写大量的重复代码,譬如int32和int64类型的sort,我们得为分别写两套代码,好冗余。go 1.4之后有了go generate的支持,但这种的仍然需要自己根据go的AST库来手动写相关的parser,难度也挺大的。虽然也有很多开源的generate实现,但毕竟不是官方的。

当然还有很多值得吐槽的地方,就不一一列举了,但是go仍旧有它的优势。

  • 静态语言,强类型。静态编译能帮我们检查出来大量的错误,go的强类型甚至变态到不支持隐式的类型转换。虽然写代码感觉很别扭,但减少了犯错的可能。
  • gofmt,应该这是我知道的第一个官方提供统一格式化代码工具的语言了。有了gofmt,大家的代码长一个样了,也就没有花括号到底放到结尾还是新开一行这种蛋疼的代码风格讨论了。因为大家的代码风格一样,所以看go的代码很容易。
  • 天生的并行支持,因为goroutine以及channel,用go写分布式应用,写并发程序异常的容易。没有了蛋疼的callback导致的代码逻辑割裂,代码逻辑都是顺序的。
  • 性能,go的性能可能赶不上c,c++以及openresty,但真的也挺强悍的。在我们的项目中,现在单机就部署了一个go的进程,就完全能够胜任以前200个python进程干的事情,而且CPU和MEM占用更低。
  • 运维部署,直接编译成二进制,扔到服务器上面就成,比python需要安装一堆的环境那是简单的太多了。当然,如果有cgo,我们也需要将对应的动态库给扔过去。
  • 开发效率,虽然go是静态语言,但我个人感觉开发效率真的挺高,直觉上面跟python不相上下。对于我个人来说,最好的例子就是我用go快速开发了非常多的开源组件,譬如ledisdb,go-mysql等,而这些最开始的版本都是在很短的时间里面完成的。对于我们项目来说,我们也是用go在一个月就重构完成了第一个版本,并发布。

实际项目中一些Go Tips

到现在为止,我们几乎所有的服务端项目都已经转向go,当然在使用的时候也遇到了一些问题,列出来算是经验分享吧。

  • godep,我们使用godep进行第三方库管理,但是godep我碰到的最大的坑就是build tag问题,如果一个文件有build tag,godep很有可能就会忽略这个文件。
  • IO deadline,如果能自己在应用层处理的都自己处理,go的deadline内部是timer来控制,但timer内部采用一个array来实现的heap,全局共用一个锁,如果大并发量,并且timer数量过多,timeout变动太频繁,很容易就引起性能问题。
  • GC,这个前面也说了,多用内存池,对象池,另外,我还发现,如果对象的生命周期跟goroutine一致,对性能的提升也不错,也在go的group问过相关问题,大家猜测可能是因为一些对象其实是在goroutine的8k栈上面分配的,所以一起回收没有额外GC了。
  • Go gob,如果要做RPC服务,gob并不是一个很好的选择,首先就跟python的pickle不通用,然后为了做不同系统的数据传入,任何包都必须带上类型的详细信息,size太大。go里面现在还没一套官方的RPC方案,gRPC貌似有上位的可能。

总结

虽然我现在选择了go,但是并不表示我以后不会尝试其他的语言。语言没有好坏,能帮我解决问题的就是好语言。但至少在很长的一段时间,我都会用go来进行开发。Let’ go!!!

Use Hashicorp Raft to build a Redis sentinel

Redis Sentinel

We use Redis not only for cache, but also storing important data, and we build up a Master/Slave replication topology to guarantee data security.

Master/Slave architecture works well, but sometimes we need a more powerful high availability solution. If master is down, we must check this immediately, reselect a new master from the slaves and do failover.

The official Redis supplies a solution named redis-sentinel, which is very powerful to use. But I still want to build my own sentinel solution, why?

  • I want to monitor not only Redis but also LedisDB, maybe other services using Redis serialization Protocol too.
  • I want to embed it into xcodis or other go service easily.
  • I want to study some consensus algorithms and use them in practice.
    Sentinel Cluster and Election

Building a single sentinel application is easy: checking master every second, and do failover when master is down. But if the sentinel is down too, how do we do?

Using sentinel cluster is a good choice, if one sentinel is down, other sentinel will still work. But let’s consider below scenario, if two sentinels in the cluster both see the master is down, and do failover at same time, sentinel1 may select slave1 as master, but sentinel2 may select slave2 as master, this may be a horrible thing for us.

A common use way is to elect a leader sentinel in the cluster and let it monitor and do failover. So we need a consensus algorithm helping us do this thing.

Paxos and Raft

Paxos may be the most famous consensus algorithm in the world, many companies use it in their distributed system. However, Paxos is very hard to understand and if you write a paxos lib by yourself, you even cann’t testify its correctness easily. Luckly, we have zookeeper, an open source centralized service based on Paxos. We can use zookeeper to manage our clusters like electing a leader.

Raft was born on 2013 in Stanford, it’s very new but awesome. Raft is easy to understand, everyone reading the Raft paper can write its own Raft implentation easily than Paxos. Now many projects use Raft, like Etcd, InfluxDB, CockroachDB, etc…

The above projects I list using Raft all use Go, and I will develop my own redis sentinel with Go too, so I decide to use Raft.

Use Hashicorp Raft

There are some Go raft projects, but I prefer Hashicorp Raft which is easy to be integrated in other project, and this package is used in Consul product and has already been tested in production environment (maybe!).

The create raft function declaration is below:

func NewRaft(conf *Config, fsm FSM, logs LogStore, stable StableStore, snaps SnapshotStore, peerStore PeerStore, trans Transport) (*Raft, error)

Although it looks a little complex, it’s still easy to use, we only need do following things:

  • Create a configuration using raft own DefaultConfig function. We should know that raft should be used with at least three nodes, but if we just want to try it with only one node, or first start a raft node, than add others later, we must set EnableSingleNode to true.
  • Define our own FSM struct, FSM is a state machine applying replicated log, generating point-in-time snapshot, and restoring from a snapshot. In our sentinel, the only data need to care is all Redis masters, whenever we add a master, remove a master or reset all masters, we should let all sentinels know. So my FSM struct is very easy, like below:
type masterFSM struct {
    sync.Mutex

    // below holding all Redis master addresses
    masters map[string]struct{}
}
  • Define our own FSMSnapshot struct. In our sentinel, this is a list of masters at some point. The struct like this:
type masterSnapshot struct {
    masters []string
}
  • Create a log storage storing and retrieving logs and a stable storage storing key configurations. Hashicorp supplies a LMDB lib and a BoltDB lib for both storage, we use BoltDB because of the pure Go implementation.
  • Create a snapshot storage saving FSM snapshot, we use raft own NewFileSnapshotStore generating a file saving this.
  • Create a peer storage storing all raft nodes, we use raft own NewJSONPeers generating a file saving all nodes with JSON format.
  • Create a transport allowing a raft node to communicate with other nodes, we use raft own NewTCPTransport generating a TCP transport.

After do that, we can create a raft, we can use LeaderCh and Leader function to check whether a raft node is leader or not. Only the leader node can handle operations. If the leader is down, raft can re-elect a new leader.

You can see the source here for more information.

Summary

Our redis sentinel is named redis-failover, although it looks a little simple and needs improvement, it still the first trial and later we will use raft in more projects, maybe instead of zookeeper.

redis-failover: https://github.com/siddontang/redis-failover

学习redis sort命令

LedisDB本来是没有sort命令的,而且实际我们也没有使用过该命令,但一位用户给我反应他迫切需要这个功能,我决定首先考察一下redis相关的实现,再看是否提供。

然后我一看redis的sort命令,真的是震惊了,这可能算得上redis里面最复杂的一个命令了,命令原型如下:

SORT key [BY pattern] [LIMIT offset count] [GET pattern [GET pattern ...]] [ASC|DESC] [ALPHA] [STORE destination]

如果不仔细看文档,或者看源码,一下子真的不知道这个命令怎么用。首先我们可以去掉LIMIT offset count这个选项,这个很容易理解,就是排好序之后取偏移数据。ASC和DESC这个也比较容易,就是正向和逆向排序。STORE destination这个其实就是将排好序的数据放到destination这个list里面,也比较容易理解。好了,去掉这些,那么sort的原型就是这个样子了:

SORT key [BY pattern] [GET pattern [GET pattern ...]] [ALPHA]

key里面存储的就是需要排序的东西,所以key只能是list,set或者zset类型,我们以list为例。假设做如下操作:

redis> lpush a 1 2 3
redis> lrange a 0 -1
1) "3"
2) "2"
3) "1"

如果使用sort,则排序结果如下:

redis> sort a
1) "1"
2) "2"
3) "3"

呢么ALPHA是什么意思呢?我们可以做如下操作解释:

redis> lpush b a1 a2 a3
redis> sort b
(error) ERR One or more scores can't be converted into double
redis> sort b alpha
1) "a1"
2) "a2"
3) "a3"

我们在b里面压入的是字符串,所以不能直接sort,必须指定alpha方式。所以alpha就是明确告知sort使用字节序排序,不然sort就会尝试将需要排序的数据转成double类型。

理解了alpha,我们再来看看by的含义,如下例子:

redis> set w_1 3
redis> set w_1 30
redis> set w_2 20
redis> set w_3 10
redis> sort a by w_*
1) "3"
2) "2"
3) "1"
127.0.0.1:6379>

如果有by了,sort就会首先取出对应的数据,也就是1,2,3,然后跟by的pattern进行组合,变成w_1,w_2,w_3,然后以这个作为key去获取对应的值,也就是30,20,10,在按照这些值进行排序。上面这个例子,1对应的by值最大,为30,所以升序排列的时候在最后。

说完了by,我们再来说说get,get是不参与排序的,只是在拍完序之后,将排好序的值依次跟get的pattern组合,获取对应的数据,进行返回,如下例子:

redis> set o_1 10
redis> set o_2 20
redis> set o_3 30
redis> sort a get o_*
1) "10"
2) "20"
3) "30"

再来一个多个get的例子:

redis> set oo_1 100
redis> set oo_2 200
redis> set oo_3 300
redis> sort a get o_* get oo_*
1) "10"
2) "100"
3) "20"
4) "200"
5) "30"
6) "300"

从上面可以看到,如果有多个get,那么sort的做法是对于排好序的一个值,依次通过get获取值,放到结果中,然后在处理下一个值。

如果有get,我们就能获取到相关的值,但这时候我们还需要返回原有的值怎么办?只需要get #就成了,如下:

redis> sort a get o_* get #
1) "10"
2) "1"
3) "20"
4) "2"
5) "30"
6) "3"

好了,折腾了这么久,我算是终于理解了sort的原理,然后在看完sort的实现,依葫芦画瓢在LedisDB里面支持了sort。当然在一些底层细节上面还是稍微跟redis不一样的。

Go中优雅的关闭HTTP服务

虽然写出7x24小时不间断运行的服务是一件很酷的事情,但是我们仍然在某些时候,譬如服务升级,配置更新等,得考虑如何优雅的结束这个服务。

当然,最暴力的做法直接就是kill -9,但这样直接导致的后果就是可能干掉了很多运行到一半的任务,最终导致数据不一致,这个苦果只有遇到过的人才能深深地体会,数据的修复真的挺蛋疼,有时候还得给用户赔钱啦。

所以,通常我们都是给服务发送一个信号,SIGTERM也行,SIGINTERRUPT也成,反正要让服务知道该结束了。而服务收到结束信号之后,首先会拒绝掉所有外部新的请求,然后等待当前所有正在执行的请求完成之后,在结束。当然很有可能当前在执行一个很耗时间的任务,导致服务长时间不能结束,这时候就得决定是否强制结束了。

具体到go的HTTP Server里面,如何优雅的结束一个HTTP Server呢?

首先,我们需要显示的创建一个listener,让其循环不断的accept新的连接供server处理,为啥不用默认的http.ListenAndServe,主要就在于我们可以在结束的时候通过关闭这个listener来主动的拒绝掉外部新的连接请求。代码如下:

l, _ := net.Listen("tcp", address)
svr := http.Server{Handler: handler}
svr.Serve(l)

Serve这个函数是个死循环,我们可以在外部通过close对应的listener来结束。

当listener accept到新的请求之后,会开启一个新的goroutine来执行,那么在server结束的时候,我们怎么知道这个goroutine是否完成了呢?

在很早之前,大概go1.2的时候,笔者通过在handler入口处使用sync WaitGroup来实现,因为我们有统一的一个入口handler,所以很容易就可以通过如下方式知道请求是否完成,譬如:

func (h *Handler) ServeHTTP(w ResponseWriter, r *Request) {
    h.svr.wg.Add(1)
    defer h.svr.wg.Done()

    ......
}

但这样其实只是用来判断请求是否结束了,我们知道在HTTP 1.1中,connection是能够keepalived的,也就是请求处理完成了,但是connection仍是可用的,我们没有一个好的办法close掉这个connection。不过话说回来,我们只要保证当前请求能正常结束,connection能不能正常close真心无所谓,毕竟服务都结束了,connection自动就close了。但谁叫笔者是典型的处女座呢。

在go1.3之后,提供了一个ConnState的hook,我们能通过这个来获取到对应的connection,这样在服务结束的时候我们就能够close掉这个connection了。该hook会在如下几种ConnState状态的时候调用。

  • StateNew:新的连接,并且马上准备发送请求了
  • StateActive:表明一个connection已经接收到一个或者多个字节的请求数据,在server调用实际的handler之前调用hook。
  • StateIdle:表明一个connection已经处理完成一次请求,但因为是keepalived的,所以不会close,继续等待下一次请求。
  • StateHijacked:表明外部调用了hijack,最终状态。
  • StateClosed:表明connection已经结束掉了,最终状态。

通常,我们不会进入hijacked的状态(如果是websocket就得考虑了),所以一个可能的hook函数如下,参考http://rcrowley.org/talks/gophercon-2014.html

s.ConnState = func(conn net.Conn, state http.ConnState) {
    switch state {
    case http.StateNew:
        // 新的连接,计数加1
        s.wg.Add(1)
    case http.StateActive:
        // 有新的请求,从idle conn pool中移除
        s.mu.Lock()
        delete(s.conns, conn.LocalAddr().String())
        s.mu.Unlock()
    case http.StateIdle:
        select {
        case <-s.quit:
            // 如果要关闭了,直接Close,否则加入idle conn pool中。
            conn.Close()
        default:
            s.mu.Lock()
            s.conns[conn.LocalAddr().String()] = conn
            s.mu.Unlock()
        }
    case http.StateHijacked, http.StateClosed:
        // conn已经closed了,计数减一
        s.wg.Done()
    }

当结束的时候,会走如下流程:

func (s *Server) Close() error {
    // close quit channel, 广播我要结束啦
    close(s.quit)

    // 关闭keepalived,请求返回的时候会带上Close header。客户端就知道要close掉connection了。
    s.SetKeepAlivesEnabled(false)
    s.mu.Lock()

    // close listenser
    if err := s.l.Close(); err != nil {
        return err 
    }

    //将当前idle的connections设置read timeout,便于后续关闭。
    t := time.Now().Add(100 * time.Millisecond)
    for _, c := range s.conns {
        c.SetReadDeadline(t)
    }
    s.conns = make(map[string]net.Conn)
    s.mu.Unlock()

    // 等待所有连接结束
    s.wg.Wait()
    return nil
}

好了,通过以上方法,我们终于能从容的关闭server了。但这里仅仅是针对跟客户端的连接,实际还有MySQL连接,Redis连接,打开的文件句柄,等等,总之,要实现优雅的服务关闭,真心不是一件很简单的事情。

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

一个Go可变参数问题

几天前纠结了一个蛋疼的问题,在go里面函数式支持可变参数的,譬如…T,go会创建一个slice,用来存放传入的可变参数,那么,如果创建一个slice,例如a,然后以a…这种方式传入,go会不会还会新建一个slice,将a的数据全部拷贝一份过去?

如果a很大,那么将会造成很严重的性能问题,不过后来想想,可能是自己多虑了,于是查看go的文档,发现如下东西:

Passing arguments to … parameters

If f is variadic with a final parameter p of type …T, then within f the type of p is equivalent to type []T. If f is invoked with no actual arguments for p, the value passed to p is nil. Otherwise, the value passed is a new slice of type []T with a new underlying array whose successive elements are the actual arguments, which all must be assignable to T. The length and capacity of the slice is therefore the number of arguments bound to p and may differ for each call site.

Given the function and calls

func Greeting(prefix string, who ...string)
Greeting("nobody")
Greeting("hello:", "Joe", "Anna", "Eileen")

within Greeting, who will have the value nil in the first call, and []string{“Joe”, “Anna”, “Eileen”} in the second.

If the final argument is assignable to a slice type []T, it may be passed unchanged as the value for a …T parameter if the argument is followed by …. In this case no new slice is created.

Given the slice s and call

s := []string{"James", "Jasmine"}
Greeting("goodbye:", s...)

within Greeting, who will have the same value as s with the same underlying array.

也就是说,如果我们传入的是slice…这种形式的参数,go不会创建新的slice。写了一个简单的例子验证:

package main

import "fmt"

func t(args ...int) {
    fmt.Printf("%p\n", args)
}

func main() {
    a := []int{1,2,3}
    b := a[1:]

    t(a...)
    t(b...)

    fmt.Printf("%p\n", a)
    fmt.Printf("%p\n", b)
}

//output
0x1052e120
0x1052e124
0x1052e120
0x1052e124

可以看到,可变参数args的地址跟实际外部slice的地址一样,用的同一个slice。

高性能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,希望感兴趣的童鞋共同参与。