MySQL问题两则

这段时间处理了两个比较有意思的MySQL问题,一个死锁的,一个优化的,陡然发现其实自己对MySQL的理解还不深入,很多运行机制也是知其然但不知其所以然,后续还需要好好恶补一下底层知识。

一次不可思议的死锁

假设有如下表结构:

mysql> show create table tt \G;
*************************** 1. row ***************************
       Table: tt
Create Table: CREATE TABLE `tt` (
  `id` int(11) NOT NULL DEFAULT '0',
  `fileid` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `fileid` (`fileid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
1 row in set (0.00 sec)

启动三个shell,连接MySQL,然后begin开启一个事务,各个shell分别执行对应的更新语句,

shell 1:

shell 1> update tt set id = 2 where fileid = 1;

shell 2:

shell 2> update tt set id = 3 where fileid = 1;

shell 3:

shell 3> update tt set id = 4 where fileid = 1;

假设shell 1先执行,这时候2和3会block,然后shell 1 commit提交,我们发现shell 2执行成功,但是3出现死锁错误,通过show engine innodb status我们得到如下死锁信息:

------------------------
LATEST DETECTED DEADLOCK
------------------------
2015-01-23 14:24:16 10ceed000
*** (1) TRANSACTION:
TRANSACTION 24897, ACTIVE 3 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 360, 1 row lock(s)
MySQL thread id 8, OS thread handle 0x10cea5000, query id 138 127.0.0.1 root updating
update tt set id = 4 where fileid = 1
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 495 page no 4 n bits 72 index `fileid` of table `test`.`tt` trx id 24897 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 2; compact format; info bits 32
 0: len 4; hex 80000001; asc     ;;
 1: len 4; hex 80000002; asc     ;;

*** (2) TRANSACTION:
TRANSACTION 24896, ACTIVE 8 sec updating or deleting
mysql tables in use 1, locked 1
4 lock struct(s), heap size 1184, 3 row lock(s), undo log entries 2
MySQL thread id 7, OS thread handle 0x10ceed000, query id 136 127.0.0.1 root updating
update tt set id = 3 where fileid = 1
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 495 page no 4 n bits 72 index `fileid` of table `test`.`tt` trx id 24896 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 2; compact format; info bits 32
 0: len 4; hex 80000001; asc     ;;
 1: len 4; hex 80000002; asc     ;;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 495 page no 4 n bits 72 index `fileid` of table `test`.`tt` trx id 24896 lock mode S waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 2; compact format; info bits 32
 0: len 4; hex 80000001; asc     ;;
 1: len 4; hex 80000002; asc     ;;

*** WE ROLL BACK TRANSACTION (1)
------------

刚开始碰到这个死锁问题,真心觉得很奇怪,每个事务一条语句,通过一个唯一索引去更新同一条记录,正常来说完全不可能发生死锁,但确确实实发生了。笔者百思不得其解,幸好有google,然后搜到了这篇,一个最不可思议的MySQL死锁分析,虽然触发情况不一样,但是死锁原理都应该类似的,后续如果有精力,笔者将好好深入研究一下。

顺带再说一下,MySQL 加锁处理分析这篇文章也是干活满满,这两篇加起来深入理解了,对MySQL的deadlock就会有一个很全面的认识了。

一次坑爹的优化

我们需要在一张表里面删除某种类型的数据,大概的表结构类似这样:

CREATE TABLE t (
    id INT,
    tp ENUM ("t1", "t2"),
    PRIMARY KEY(id)
) ENGINE=INNODB;

假设我们需要删除类型为t2的数据,语句可能是这样delete from t where tp = "t2",这样没啥问题,但我们这张表有5亿数据,好吧,真的是5亿,所以以后别再跟我说MySQL表存储百万级别数据就要分表了,百万太小case了。

这事情我交给了一个小盆友去帮我搞定,他最开始写出了如下的语句delete from t where tp = "t2" limit 1000,使用limit来限制一次删除的个数,可以了,不过这有个很严重的问题,就是越往后,随着t2类型的减少,我们几乎都是全表遍历来删除,所以总的应该是O(n*n)的开销。

于是我让他考虑主键,每次操作的时候,记录当前最大的主键,这样下次就可以从这个主键之后开始删除了,首先 select id from t where id > last_max_select_id and tp = "t2" limit 1000,然后delete from t where id in (ids),虽然这次优化采用了两条语句,但是通过主键,我们只需要遍历一次表就可以了,总的来说,性能要快的。

但是,实际测试的时候,我们却发现,select这条语句耗时将近30s,太慢了。虽然我们使用了主键,但是MySQL仍然需要不停的读取数据判断条件,加之t2类型的数据在表里面比较少量,所以为了limit 1000这个条件,MySQL需要持续的进行IO读取操作,结果自然是太慢了。

想清楚了这个,其实就好优化了,我们只需要让条件判断在应用层做,MySQL只查询数据返回,语句就是 select id, tp from t where id > last_max_select_id limit 1000,得到结果集之后,自行判断需要删除的id,然后delete。看似我们需要额外处理逻辑,并且网络开销也增大了,但MySQL只是简单的IO读取,非常快,总的来说,性能提升很显著。当然笔者后续还需要更深入的分析。

最后执行,很happy的是,非常快速的就删完了相关数据,而select的查询时间消耗几乎忽略不计。

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连接,打开的文件句柄,等等,总之,要实现优雅的服务关闭,真心不是一件很简单的事情。

Elasticsearch学习笔记

Why Elasticsearch?

由于需要提升项目的搜索质量,最近研究了一下Elasticsearch,一款非常优秀的分布式搜索程序。最开始的一些笔记放到github,这里只是归纳总结一下。

首先,为什么要使用Elasticsearch?最开始的时候,我们的项目仅仅使用MySQL进行简单的搜索,然后一个不能索引的like语句,直接拉低MySQL的性能。后来,我们曾考虑过sphinx,并且sphinx也在之前的项目中成功实施过,但想想现在的数据量级,多台MySQL,以及搜索服务本身HA,还有后续扩容的问题,我们觉得sphinx并不是一个最优的选择。于是自然将目光放到了Elasticsearch上面。

根据官网自己的介绍,Elasticsearch是一个分布式搜索服务,提供Restful API,底层基于Lucene,采用多shard的方式保证数据安全,并且提供自动resharding的功能,加之github等大型的站点也采用Elasticsearch作为其搜索服务,我们决定在项目中使用Elasticsearch。

对于Elasticsearch,如果要在项目中使用,需要解决如下问题:

  1. 索引,对于需要搜索的数据,如何建立合适的索引,还需要根据特定的语言使用不同的analyzer等。
  2. 搜索,Elasticsearch提供了非常强大的搜索功能,如何写出高效的搜索语句?
  3. 数据源,我们所有的数据是存放到MySQL的,MySQL是唯一数据源,如何将MySQL的数据导入到Elasticsearch?

对于1和2,因为我们的数据都是从MySQL生成,index的field是固定的,主要做的工作就是根据业务场景设计好对应的mapping以及search语句就可以了,当然实际不可能这么简单,需要我们不断的调优。

而对于3,则是需要一个工具将MySQL的数据导入Elasticsearch,因为我们对搜索实时性要求很高,所以需要将MySQL的增量数据实时导入,笔者唯一能想到的就是通过row based binlog来完成。而近段时间的工作,也就是实现一个MySQL增量同步到Elasticsearch的服务。

Lucene

Elasticsearch底层是基于Lucene的,Lucene是一款优秀的搜索lib,当然,笔者以前仍然没有接触使用过。:-)

Lucene关键概念:

  • Document:用来索引和搜索的主要数据源,包含一个或者多个Field,而这些Field则包含我们跟Lucene交互的数据。
  • Field:Document的一个组成部分,有两个部分组成,name和value。
  • Term:不可分割的单词,搜索最小单元。
  • Token:一个Term呈现方式,包含这个Term的内容,在文档中的起始位置,以及类型。

Lucene使用Inverted index来存储term在document中位置的映射关系。
譬如如下文档:

  • Elasticsearch Server 1.0 (document 1)
  • Mastring Elasticsearch (document 2)
  • Apache Solr 4 Cookbook (document 3)

使用inverted index存储,一个简单地映射关系:

Term Count Docuemnt
1.0 1 <1>
4 1 <3>
Apache 1 <3>
Cookbook 1 <3>
Elasticsearch 2 <1>.<2>
Mastering 1 <2>
Server 1 <1>
Solr 1 <3>

对于上面例子,我们首先通过分词算法将一个文档切分成一个一个的token,再得到该token与document的映射关系,并记录token出现的总次数。这样就得到了一个简单的inverted index。

Elasticsearch关键概念

要使用Elasticsearch,笔者认为,只需要理解几个基本概念就可以了。

在数据层面,主要有:

  • Index:Elasticsearch用来存储数据的逻辑区域,它类似于关系型数据库中的table概念。一个index可以在一个或者多个shard上面,同时一个shard也可能会有多个replicas。
  • Document:Elasticsearch里面存储的实体数据,类似于关系数据中一个table里面的一行数据。
    document由多个field组成,不同的document里面同名的field一定具有相同的类型。document里面field可以重复出现,也就是一个field会有多个值,即multivalued。
  • Document type:为了查询需要,一个index可能会有多种document,也就是document type,但需要注意,不同document里面同名的field一定要是相同类型的。
  • Mapping:存储field的相关映射信息,不同document type会有不同的mapping。

对于熟悉MySQL的童鞋,我们只需要大概认为Index就是一个table,document就是一行数据,field就是table的column,mapping就是table的定义就可以了。

Document type这个概念其实最开始也把笔者给弄糊涂了,其实它就是为了更好的查询,举个简单的例子,一个index,可能一部分数据我们想使用一种查询方式,而另一部分数据我们想使用另一种查询方式,于是就有了两种type了。不过这种情况应该在我们的项目中不会出现,所以通常一个index下面仅会有一个type。

在服务层面,主要有:

  • Node: 一个server实例。
  • Cluster:多个node组成cluster。
  • Shard:数据分片,一个index可能会存在于多个shards,不同shards可能在不同nodes。
  • Replica:shard的备份,有一个primary shard,其余的叫做replica shards。

Elasticsearch之所以能动态resharding,主要在于它最开始就预先分配了多个shards(貌似是1024),然后以shard为单位进行数据迁移。这个做法其实在分布式领域非常的普遍,codis就是使用了1024个slot来进行数据迁移。

因为任意一个index都可配置多个replica,通过冗余备份的方式保证了数据的安全性,同时replica也能分担读压力,类似于MySQL中的slave。

Restful API

Elasticsearch提供了Restful API,使用json格式,这使得它非常利于与外部交互,虽然Elasticsearch的客户端很多,但笔者仍然很容易的就写出了一个简易客户端用于项目中,再次印证了Elasticsearch的使用真心很容易。

Restful的接口很简单,一个url表示一个特定的资源,譬如/blog/article/1,就表示一个index为blog,type为aritcle,id为1的document。

而我们使用http标准method来操作这些资源,POST新增,PUT更新,GET获取,DELETE删除,HEAD判断是否存在。

这里,友情推荐httpie,一个非常强大的http工具,个人感觉比curl还用,几乎是命令行调试Elasticsearch的绝配。

一些使用httpie的例子:

# create
http POST :9200/blog/article/1 title="hello elasticsearch" tags:='["elasticsearch"]'

# get
http GET :9200/blog/article/1

# update
http PUT :9200/blog/article/1 title="hello elasticsearch" tags:='["elasticsearch", "hello"]'

# delete
http DELETE :9200/blog/article/1

# exists
http HEAD :9200/blog/article/1

索引和搜索

虽然Elasticsearch能自动判断field类型并建立合适的索引,但笔者仍然推荐自己设置相关索引规则,这样才能更好为后续的搜索服务。

我们通过定制mapping的方式来设置不同field的索引规则。

而对于搜索,Elasticsearch提供了太多的搜索选项,就不一一概述了。

索引和搜索是Elasticsearch非常重要的两个方面,直接关系到产品的搜索体验,但笔者现阶段也仅仅是大概了解了一点,后续在详细介绍。

同步MySQL数据

Elasticsearch是很强大,但要建立在有足量数据情况下面。我们的数据都在MySQL上面,所以如何将MySQL的数据导入Elasticsearch就是笔者最近研究的东西了。

虽然现在有一些实现,譬如elasticsearch-river-jdbc,或者elasticsearch-river-mysql,但笔者并不打算使用。

elasticsearch-river-jdbc的功能是很强大,但并没有很好的支持增量数据更新的问题,它需要对应的表只增不减,而这个几乎在项目中是不可能办到的。

elasticsearch-river-mysql倒是做的很不错,采用了python-mysql-replication来通过binlog获取变更的数据,进行增量更新,但它貌似处理MySQL dump数据导入的问题,不过这个笔者真的好好确认一下?话说,python-mysql-replication笔者还提交过pull解决了minimal row image的问题,所以对elasticsearch-river-mysql这个项目很有好感。只是笔者决定自己写一个出来。

为什么笔者决定自己写一个,不是因为笔者喜欢造轮子,主要原因在于对于这种MySQL syncer服务(增量获取MySQL数据更新到相关系统),我们不光可以用到Elasticsearch上面,而且还能用到其他服务,譬如cache上面。所以笔者其实想实现的是一个通用MySQL syncer组件,只是现在主要关注Elasticsearch罢了。

项目代码在这里go-mysql-elasticsearch,仍然处于开发状态,预计下周能基本完成。

go-mysql-elasticsearch的原理很简单,首先使用mysqldump获取当前MySQL的数据,然后在通过此时binlog的name和position获取增量数据。

一些限制:

  • binlog一定要变成row-based format格式,其实我们并不需要担心这种格式的binlog占用太多的硬盘空间,MySQL 5.6之后GTID模式都推荐使用row-based format了,而且通常我们都会把控SQL语句质量,不允许一次性更改过多行数据的。
  • 需要同步的table最好是innodb引擎,这样mysqldump的时候才不会阻碍写操作。
  • 需要同步的table一定要有主键,好吧,如果一个table没有主键,笔者真心会怀疑设计这个table的同学编程水平了。多列主键也是不推荐的,笔者现阶段不打算支持。
  • 一定别动态更改需要同步的table结构,Elasticsearch只能支持动态增加field,并不支持动态删除和更改field。通常来说,如果涉及到alter table,很多时候已经证明前期设计的不合理以及对于未来扩展的预估不足了。

更详细的说明,等到笔者完成了go-mysql-elasticsearch的开发,再进行补充。

总结

最近一周,笔者花了不少时间在Elasticsearch上面,现在算是基本入门了。其实笔者觉得,对于一门不懂的技术,找一份靠谱的资料(官方文档或者入门书籍),蛋疼的对着资料敲一遍代码,不懂的再问google,最后在将其用到实际项目,这门技术就算是初步掌握了,当然精通还得在下点功夫。

现在笔者只是觉得Elasticsearch很美好,上线之后铁定会有坑的,那时候只能慢慢填了。话说,笔者是不是要学习下java了,省的到时候看不懂代码就惨了。:-)

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 :-)

MySQL高可用浅析

对于多数应用来说,MySQL都是作为最关键的数据存储中心的,所以,如何让MySQL提供HA服务,是我们不得不面对的一个问题。当master当机的时候,我们如何保证数据尽可能的不丢失,如何保证快速的获知master当机并进行相应的故障转移处理,都是需要我们好好思考的。这里,笔者将结合这段时间做的MySQL proxy以及toolsets相关工作,说说我们现阶段以及后续会在项目中采用的MySQL HA方案。

Replication

要保证MySQL数据不丢失,replication是一个很好的解决方案,而MySQL也提供了一套强大的replication机制。只是我们需要知道,为了性能考量,replication是采用的asynchronous模式,也就是写入的数据并不会同步更新到slave上面,如果这时候master当机,我们仍然可能会面临数据丢失的风险。

为了解决这个问题,我们可以使用semi-synchronous replication,semi-synchronous replication的原理很简单,当master处理完一个事务,它会等待至少一个支持semi-synchronous的slave确认收到了该事件并将其写入relay-log之后,才会返回。这样即使master当机,最少也有一个slave获取到了完整的数据。

但是,semi-synchronous并不是100%的保证数据不会丢失,如果master在完成事务并将其发送给slave的时候崩溃,仍然可能造成数据丢失。只是相比于传统的异步复制,semi-synchronous replication能极大地提升数据安全。更为重要的是,它并不慢,MHA的作者都说他们在facebook的生产环境中使用了semi-synchronous(这里),所以我觉得真心没必要担心它的性能问题,除非你的业务量级已经完全超越了facebook或者google。

如果真的想完全保证数据不会丢失,现阶段一个比较好的办法就是使用gelera,一个MySQL集群解决方案,它通过同时写三份的策略来保证数据不会丢失。笔者没有任何使用gelera的经验,只是知道业界已经有公司将其用于生产环境中,性能应该也不是问题。但gelera对MySQL代码侵入性较强,可能对某些有代码洁癖的同学来说不合适了:-)

我们还可以使用drbd来实现MySQL数据复制,MySQL官方文档有一篇文档有详细介绍,但笔者并未采用这套方案,MHA的作者写了一些采用drdb的问题,在这里,仅供参考。

在后续的项目中,笔者会优先使用semi-synchronous replication的解决方案,如果数据真的非常重要,则会考虑使用gelera。

Monitor

前面我们说了使用replication机制来保证master当机之后尽可能的数据不丢失,但是我们不能等到master当了几分钟才知道出现问题了。所以一套好的监控工具是必不可少的。

当master当掉之后,monitor能快速的检测到并做后续处理,譬如邮件通知管理员,或者通知守护程序快速进行failover。

通常,对于一个服务的监控,我们采用keepalived或者heartbeat的方式,这样当master当机之后,我们能很方便的切换到备机上面。但他们仍然不能很即时的检测到服务不可用。笔者的公司现阶段使用的是keepalived的方式,但后续笔者更倾向于使用zookeeper来解决整个MySQL集群的monitor以及failover。

对于任何一个MySQL实例,我们都有一个对应的agent程序,agent跟该MySQL实例放到同一台机器上面,并且定时的对MySQL实例发送ping命令检测其可用性,同时该agent通过ephemeral的方式挂载到zookeeper上面。这样,我们可以就能知道MySQL是否当机,主要有以下几种情况:

  1. 机器当机,这样MySQL以及agent都会当掉,agent与zookeeper连接自然断开
  2. MySQL当掉,agent发现ping不通,主动断开与zookeeper的连接
  3. Agent当掉,但MySQL未当

上面三种情况,我们都可以认为MySQL机器出现了问题,并且zookeeper能够立即感知。agent与zookeeper断开了连接,zookeeper触发相应的children changed事件,监控到该事件的管控服务就可以做相应的处理。譬如如果是上面前两种情况,管控服务就能自动进行failover,但如果是第三种,则可能不做处理,等待机器上面crontab或者supersivord等相关服务自动重启agent。

使用zookeeper的好处在于它能很方便的对整个集群进行监控,并能即时的获取整个集群的变化信息并触发相应的事件通知感兴趣的服务,同时协调多个服务进行相关处理。而这些是keepalived或者heartbeat做不到或者做起来太麻烦的。

使用zookeeper的问题在于部署起来较为复杂,同时如果进行了failover,如何让应用程序获取到最新的数据库地址也是一个比较麻烦的问题。

对于部署问题,我们要保证一个MySQL搭配一个agent,幸好这年头有了docker,所以真心很简单。而对于第二个数据库地址更改的问题,其实并不是使用了zookeeper才会有的,我们可以通知应用动态更新配置信息,或者使用proxy来解决。

虽然zookeeper的好处很多,但如果你的业务不复杂,譬如只有一个master,一个slave,zookeeper可能并不是最好的选择,没准keepalived就够了。

Failover

通过monitor,我们可以很方便的进行MySQL监控,同时在MySQL当机之后通知相应的服务做failover处理,假设现在有这样的一个MySQL集群,a为master,b,c为其slave,当a当掉之后,我们需要做failover,那么我们选择b,c中的哪一个作为新的master呢?

原则很简单,哪一个slave拥有最近最多的原master数据,就选哪一个作为新的master。我们可以通过show slave status这个命令来获知哪一个slave拥有最新的数据。我们只需要比较两个关键字段Master_Log_File以及Read_Master_Log_Pos,这两个值代表了slave读取到master哪一个binlog文件的哪一个位置,binlog的索引值越大,同时pos越大,则那一个slave就是能被提升为master。这里我们不讨论多个slave可能会被提升为master的情况。

在前面的例子中,假设b被提升为master了,我们需要将c重新指向新的master b来开始复制。我们通过CHANGE MASTER TO来重新设置c的master,但是我们怎么知道要从b的binlog的哪一个文件,哪一个position开始复制呢?

GTID

为了解决这一个问题,MySQL 5.6之后引入了GTID的概念,即uuid:gid,uuid为MySQL server的uuid,是全局唯一的,而gid则是一个递增的事务id,通过这两个东西,我们就能唯一标示一个记录到binlog中的事务。使用GTID,我们就能非常方便的进行failover的处理。

仍然是前面的例子,假设b此时读取到的a最后一个GTID为3E11FA47-71CA-11E1-9E33-C80AA9429562:23,而c的为3E11FA47-71CA-11E1-9E33-C80AA9429562:15,当c指向新的master b的时候,我们通过GTID就可以知道,只要在b中的binlog中找到GTID为3E11FA47-71CA-11E1-9E33-C80AA9429562:15这个event,那么c就可以从它的下一个event的位置开始复制了。虽然查找binlog的方式仍然是顺序查找,稍显低效暴力,但比起我们自己去猜测哪一个filename和position,要方便太多了。

google很早也有了一个Global Transaction ID的补丁,不过只是使用的一个递增的整形,LedisDB就借鉴了它的思路来实现failover,只不过google貌似现在也开始逐步迁移到MariaDB上面去了。

MariaDB的GTID实现跟MySQL 5.6是不一样的,这点其实比较麻烦,对于我的MySQL工具集go-mysql来说,意味着要写两套不同的代码来处理GTID的情况了。后续是否支持MariaDB再看情况吧。

Pseudo GTID

GTID虽然是一个好东西,但是仅限于MySQL 5.6+,当前仍然有大部分的业务使用的是5.6之前的版本,笔者的公司就是5.5的,而这些数据库至少长时间也不会升级到5.6的。所以我们仍然需要一套好的机制来选择master binlog的filename以及position。

最初,笔者打算研究MHA的实现,它采用的是首先复制relay log来补足缺失的event的方式,但笔者不怎么信任relay log,同时加之MHA采用的是perl,一个让我完全看不懂的语言,所以放弃了继续研究。

幸运的是,笔者遇到了orchestrator这个项目,这真的是一个非常神奇的项目,它采用了一种Pseudo GTID的方式,核心代码就是这个

create database if not exists meta;

drop event if exists meta.create_pseudo_gtid_view_event;

delimiter ;;
create event if not exists
  meta.create_pseudo_gtid_view_event
  on schedule every 10 second starts current_timestamp
  on completion preserve
  enable
  do
    begin
      set @pseudo_gtid := uuid();
      set @_create_statement := concat('create or replace view meta.pseudo_gtid_view as select \'', @pseudo_gtid, '\' as pseudo_gtid_unique_val from dual');
      PREPARE st FROM @_create_statement;
      EXECUTE st;
      DEALLOCATE PREPARE st;
    end
;;

delimiter ;

set global event_scheduler := 1;

它在MySQL上面创建了一个事件,每隔1s,就将一个uuid写入到一个view里面,而这个是会记录到binlog中的,虽然我们仍然不能像GTID那样直接定位到一个event,但也能定位到一个1s的区间了,这样我们就能在很小的一个区间里面对比两个MySQL的binlog了。

继续上面的例子,假设c最后一次出现uuid的位置为s1,我们在b里面找到该uuid,位置为s2,然后依次对比后续的event,如果不一致,则可能出现了问题,停止复制。当遍历到c最后一个binlog event之后,我们就能得到此时b下一个event对应的filename以及position了,然后让c指向这个位置开始复制。

使用Pseudo GTID需要slave打开log-slave-update的选项,考虑到GTID也必须打开该选项,所以个人感觉完全可以接受。

后续,笔者自己实现的failover工具,将会采用这种Pseudo GTID的方式实现。

在《MySQL High Availability》这本书中,作者使用了另一种GTID的做法,每次commit的时候,需要在一个表里面记录gtid,然后就通过这个gtid来找到对应的位置信息,只是这种方式需要业务MySQL客户端的支持,笔者不很喜欢,就不采用了。

后记

MySQL HA一直是一个水比较深的领域,笔者仅仅列出了一些最近研究的东西,有些相关工具会尽量在go-mysql中实现。

我的2014

每到年末,都要进行一次总结了,看看今年都做了哪些事情,有啥提高,明年目标是什么样的,大概计划怎样。

家人

今年对我来说最大的两件事情就是我奶奶的离世以及我女儿的出生。我奶奶是在正月初一走的,可惜我没在旁边,因为要照顾快分娩的老婆。得到消息之后,立刻定了第二天的机票赶回老家送了奶奶最后一程。我爷爷是在五十年前的大年三十去世的,整整五十年零一天,真的像是冥冥中自有天注定似的。家里面突然少了一个人,陡然觉得心里空荡荡了许多。

在奶奶去世之后的二十五天,我的女儿出生了,家里面又多了一个人,当了爸爸,责任重了,压力大了。要考虑多多赚钱给她更好的生活了。

养育孩子真的是一件挺辛苦的事情,这里真的要感谢一下我的老婆,真的辛苦了。

等我女儿大了懂事了,我也会跟她说说她太奶奶的事情,虽然离她很遥远,但这是我宝贵的回忆。

技术

今年我仍然继续着我的开源之路,比较欣慰的是弄的LedisDB终于算是小有成功,被用在生产环境中,而且还有一些国外用户使用。不过比较让我郁闷的是这个东西在国内收到很多嘲讽,谩骂。后来跟一些中国其他开源作者交流了,发现几乎也都是这样。想想我们的心态也还真是好。

除了NoSQL,我还尝试在MySQL领域耕耘,于是就有了一个MySQL proxy,mixer,这个项目其实我很看好前景的,并且也有了很多关注,只是因为一些其他方面的原因我没有继续开发了。不过后来一些用户告诉我他们正在使用mixer里面的一些代码进行自己的proxy开发,这让我感到很欣慰。弄开源,最想的其实就是有人使用,得到认可。

当然今年还做了很多一些小的开源组件,其实也都是一直围绕着NoSQL,MySQL上面来做的。

今年在技术上面最大的感触就是要走出去,跟人多交流,站在别人的肩膀上面。自己造轮子固然好,但是有时候基于别人的好的东西再搭积木,没准更好。一个最好的例子就是xcodis,我一直想让LedisDB支持集群,然后也想到使用proxy的方式,只是一直没时间去弄,这时候豌豆荚开源了codis,而这个就是我需要的东西,于是我在codis的基础上面直接弄了一个支持LedisDB的集群proxy出来。

今年为了提升自己的算法水平,蛋疼的把leetcode上面的题目全做了一遍,虽然一些是google出来的,但也至少让自己学到了很多。现在也正在写一本Leetcode题解的书,希望明年能搞定,当然不是为了出版,只是为了更好的提升自己的算法水平。

分享

今年秉着走出去的原则,参加了两场分享会,一场是在腾讯的技术沙龙讲LedisDB,不过自己演讲水平太挫,效果特差。另一场是在珠海北理工讲web service的开发,忽悠了半天,后来发现,现在的学生盆友重点关注的是iOS,android这些的东西。

这两场分享让我锻炼了一下口才,其实还蛮不错的,如果明年有机会,也争取参加一下。

工作

今年的工作主要集中在推送服务器以及go服务重构上面,当时写push,设想的是能给整个公司提供推送服务,只是计划没有变化快,最后也成了一个鸡肋产品。

以前我们的服务是用openresty + python,随着系统越来越复杂,这套架构也有了很多弊端,所以我们决定使用go完全重构,现在仍在进行中。

今年整个公司变动挺大的,我的老大,一个11年的员工也出去创业了。想想比较唏嘘,当初面试我的两个老大,把我拉进来的都走了。

明年我的担子比较重,因为go的重构是我负责,明年铁定要上的,到时候很多问题都需要我来协调处理了。

展望

扯了这么多,其实发现2014年也没做什么,但就这么快速的过去了,那2015年了,干点啥呢:

  • 育儿,这个发现真挺重要的,孩子马上一岁了,逐渐懂事了,所以铁定要多花时间陪孩子了。
  • 继续开源,仍然用go开发,还是NoSQL和MySQL方向,没准MQ也会有。重点完成mysql proxy的后续开发。
  • 练习英语,现在跟外国盆友交流只敢打字,说话什么的那是不可能的。所以这方面一定要加强。奶爸推荐的一些学习方法还是挺不错的,要强迫自己学习。另外,争取看完全英文的哈利波特,或者其他入门级的英文读物。
  • 完成LeetCode题解这本书,前面已经说了,这是对我自己一段时间算法学习的总结。
  • 争取每周一篇文章,技术的,人文的都可以,今年零零散散写了一点,但还是不够。明年争取能在medium上面用英文多写几篇文章,今年只写了可怜的两篇。
  • docker,这玩意现在太火了,我也正在推进开发中使用docker,不过没准能在生产环境中也用到,需要好好研究。
  • 深入学习网络以及Linux底层知识,这方面要加强,现在只是知道大概,稍微深入一点就不懂了,做高性能服务器开发,得掌握。
  • 读书,不是技术书籍,争取每月读一本好书,小说也行,哲学,经济,历史都成。
  • 锻炼,好吧,现在身体太差,不能这样懒了。跑步也行,游泳也成,总之要动起来了。

最后

2014年就快过去了,马上迎来2015,希望明年越来越好。

学习zookeeper

最近研究了一下zookeeper(后续以zk简称),对于一个自认为泡在服务器领域多年的老油条来说,现在才开始关注zk这个东西,其实有点晚了,但没办法,以前的工作经历让我压根用不到这个玩意。只是最近因为要考虑做ledisdb的cluster方案,以及重新考虑mixer的协调管理,才让我真正开始尝试去了解zk。

什么是zookeeper

根据官网的介绍,zookeeper是一个分布式协调服务,主要用来处理分布式系统中各系统之间的协作问题的。

其实这么说有点抽象,初次接触zk,很多人真不知道用它来干啥,你可以将它想成一个总控节点(当然它能用多机实现自身的HA),能对所有服务进行操作。这样就能实现对整个分布式系统的统一管理。

譬如我现在有n台机器,需要动态更新某一个配置,一些做法可能是通过puppet或者salt将配置先分发到不同机器,然后运行指定的reload命令。zk的做法可能是所有服务都监听一个配置节点,直接更改这个节点的数据,然后各个服务就能收到更新消息,然后同步最新的配置,再自行reload了。

上面只是一个很简单的例子,其实通过它并不能过多的体现zk的优势(没准salt可能还更简单),但zk不光只能干这些,还能干更awesome的事情。网上有太多关于zk应用场景一览的文章了,这里就不详细说明,后续我只会说一下自己需要用zk解决的棘手问题。

架构

zk使用类paxos算法来保证其HA,每次通过选举得到一个master用来处理client的请求,client可以挂载到任意一台zk server上面,因为paxos这种是强一致同步算法,所以zk能保证每一台server上面数据都是一致的。架构如下:

                                                                     
                      +-------------------------------+                         
                      |                               |                         
              +----+--++          +----+---+        +-+--+---+                  
              | server |          | server |        | server |                  
              |        +----------+ master +--------+        |                  
              +--^--^--+          +----^---+        +----^---+                  
                 |  |                  |                 |                      
                 |  |                  |                 |                      
                 |  |                  |                 |                      
           +-----+  +-----+            +------+          +---------+            
           |              |                   |                    |            
           |              |                   |                    |            
      +----+---+        +-+------+         +--+-----+           +--+-----+      
      | client |        | client |         | client |           | client |      
      +--------+        +--------+         +--------+           +--------+

Data Model

zk内部是按照类似文件系统层级方式进行数据存储的,就像这样:

                        +---+             
                        | / |             
                        +++-+             
                         ||               
                         ||               
          +-------+------++----+-------+  
          | /app1 |            | /app2 |  
          +-+--+--+            +---+---+  
            |  |                   |      
            |  |                   |      
            |  |                   |      
+----------++ ++---------+    +----+-----+
| /app1/p1 |  | /app1/p2 |    | /app2/p1 |
+----------+  +----------+    +----------+

对于任意一个节点,我们称之为znode,znode有很多属性,譬如Zxid(每次更新的事物ID)等,具体可以详见zk的文档。znode有ACL控制,我们可以很方便的设置其读写权限等,但个人感觉对于内网小集群来说意义不怎么大,所以也就没深入研究。

znode有一种Ephemeral Node,也就是临时节点,它是session有效的,当session结束之后,这个node自动删除,所以我们可以用这种node来实现对服务的监控。譬如一个服务启动之后就向zk挂载一个ephemeral node,如果这个服务崩溃了,那么连接断开,session无效了,这个node就删除了,我们也就知道该服务出了问题。

znode还有一种Sequence Node,用来实现序列化的唯一节点,我们可以通过这个功能来实现一个简单地leader服务选举,譬如每个服务启动的时候都向zk注册一个sequence node,谁最先注册,zk给的sequence最小,这个最小的就是leader了,如果leader当掉了,那么具有第二小sequence node的节点就成为新的leader。

Znode Watch

我们可以watch一个znode,用来监听对应的消息,zk会负责通知,但只会通知一次。所以需要我们再次重新watch这个znode。那么如果再次watch之前,znode又有更新了,client不是收不到了吗?这个就需要client不光要处理watch,同时也需要适当的主动get相关的数据,这样就能保证得到最新的消息了。也就是消息系统里面典型的推拉结合的方式。推只是为了提升性能,快速响应,而拉则为了更好的保证消息不丢失。

但是,我们需要注意一点,zk并不能保证client收到消息之后同时处理,譬如配置文件更新,zk可能通知了所有client,但client并不能全部在同一个时间同时reload,所以为了处理这样的问题,我们需要额外的机制来保证,这个后续说明。

watch只能应用于data(通过get,exists函数)以及children(通过getChildren函数)。也就是监控znode数据更新以及znode的子节点的改变。

API

zk的API时很简单的,如下:

  • create
  • delete
  • exists
  • set data
  • get data
  • get chilren
  • sync

就跟通常的文件系统操作差不多,就不过多说明了。

Example

总的来说,如果我们不深入zk的内部实现,譬如paxos等,zk还是很好理解的,而且使用起来很简单。通常我们需要考虑的就是用zk来干啥,而不是为了想引入一个牛的新特性而用zk。

Lock

用zk可以很方便的实现一个分布式lock,记得最开始做企业群组盘的时候,我需要实现一个分布式lock,然后就用redis来弄了一个,其实当时就很担心redis单点当掉的问题,如果那时候我就引入了zk,可能就没这个担心了。

官方文档已经很详细的给出了lock的实现流程:

  1. create一个类似path/lock-n的临时序列节点
  2. getChilren相应的path,注意这里千万不能watch,不然惊群很恐怖的
  3. 如果1中n是最小的,则获取lock
  4. 否则,调用exists watch到上一个比自己小的节点,譬如我现在n是5,我就可能watch node-4
  5. 如果exists失败,表明前一个节点没了,则进入步骤2,否则等待,直到watch触发重新进入步骤2

Codis

最近在考虑ledisdb的cluster方案,本来也打算用proxy来解决的,然后就在想用zk来处理rebalance的问题,结果这时候codis横空出世,发现不用自己整了,于是就好好的研究了一下codis的数据迁移问题。其实也很简单:

  1. config发起pre migrate action
  2. proxy接收到这个action之后,将对应的slot设置为pre migrate状态,同时等待config发起migrate action
  3. config等待所有的proxy返回pre migrate之后,发起migrate action
  4. proxy收到migrate action,将对应的slot设置为migrate状态

上面这些,都是通过zk来完成的,这里需要关注一下为啥要有pre migrate这个状态,如果config直接发起migrate,那么zk并不能保证proxy同一时间全部更新成migrate状态,所以我们必须有一个中间状态,在这个中间状态里面,proxy对于特定的slot不会干任何事情,只能等待config将其设置为migrate。虽然proxy对于相应slot一段时间无法处理外部请求,但这个时间是很短的(不过此时config当掉了就惨了)。config知道所有proxy都变成pre migrate状态之后,就可以很放心的发送migrate action了。因为这时候,proxy只有两种可能,变成migrate状态,能正常工作,仍然还是pre migrate状态,不能工作,也自然不会对数据造成破坏。

其实上面也就是一个典型的2PC,虽然仍然可能有隐患,譬如config当掉,但并不会对实际数据造成破坏。而且config当掉了我们也能很快知晓并重新启动,所以问题不大。

总结

总的来说,zk的使用还是挺简单的,只要我们知道它到底能用到什么地方,那zk就真的是分布式开发里面一把瑞士军刀了。不过我挺不喜欢装java那套东西,为了zk也没办法,虽然go现在也有etcd这些类zk的东西了,但毕竟还没经受过太多的考验,所以现在还是老老实实的zk吧。

深入浅出Web Service

红薯的邀约,决定给某大学的童鞋讲讲Web Service相关知识,鉴于是第一次在学校献丑,所以还是老老实实的准备,先把类似逐字稿的东西写出来,然后在准备PPT吧。

关于Web service,这个话题太广太泛,加之我也只熟悉一些特定的领域,所以准备从两方面入手,1,什么是Web service,就当是概念性的介绍,让大家有个相关认识。2,则是根据一个简单的例子,告诉大家如何构建一个Web service服务。

什么是Web service

首先根据Wiki的定义:A Web Service is a method of communication between two electronic devices over a network.

简单来说,Web Service就是基于网络不同设备之间互相通信的一种方式。Web Service是一个软件服务,它提供很多API,而客户端通过Web协议进行调用从而完成相关的功能。

Web service并不是一个新奇的概念,相反从很早的分布式计算,到网格计算,到现在的云,都或多或少的有着Web service的影子。只不过随着近几年一浪高过一浪的互联网热潮以及Google,Amazon等公司的大力推动,Web service变得愈发流行。

越来越多的公司开始提供Web service,而同时又有更多的公司基于这些Web service提供了更加上层的Web service。

Amazon的S3(Simple Storage Service)是一个文件存储服务,用户通过S3将文件存放到Amazon的服务器上面,Amazon负责保证该文件的安全(包括不被别人获取,不丢失等)。而Drew Houston则在S3的基础上,构造了一个令人惊奇的同步网盘:Dropbox,同时,Dropbox又将相关API提供出去,供其他的Application其同步服务。

可以看到,正是因为有了越来越多的Web services,才让我们现在的互联网生活变得越发精彩。

实现一个简单的Web service

好了,上面扯了这么多,是不是心痒痒想自己开发一个Web service?开发一个Web service并不是那么容易的事情,尤其是涉及到分布式之后。不过我觉得一个小例子没准就能说明很多东西。当然我自认并不是Web service的专家(这年头专家架构师太多,我只能算打酱油的),很多东西难免疏漏,并且一些设计也会带有很强烈的个人色彩,如果大家有啥更好的认识,欢迎跟我讨论(妹子优先!)。

一个简单的例子:KV Storage Service,后面就叫KV吧。类似于S3,只是我们不是存文件,而是元数据。后面我们就用KV来表明服务的名字吧。

对于KV来说,它只会涉及到三种操作,如果用代码表示如下:

//根据指定的key获取对应的value
Get(key)

//设置key的值为value,如果key本来存在,则更新,否则新建
Put(key, value)

//删除key
Delete(key)

交互协议

既然是Web service,自然选用HTTP来做交互,比起自己实现一套不通用的协议,或者使用Google Protocol Buffers这些的,HTTP具有太多的优势,虽然它的性能稍微有点差,数据量稍微有点臃肿,但几乎所有的浏览器以及数不清的库能直接支持,想想都有点小激动了。

所以我们唯一要做的,就是设计好我们的API,让外面更方便的使用Web service。

API

根据wiki的定义,Web service通常有两种架构方式,RESTful和Arbitrary(RPC,SOAP,etc)。

REST是Representational state transfer的缩写,而满足REST架构模型的我们通常称之为Restful:

  • 使用URI来表示资源,譬如http://example.com/user/1 代表ID为1的user。
  • 使用标准HTTP方法GET,POST,PUT,DELETE等来操作资源,譬如Get http://example.com/user/1 来获取user 1的信息,而使用Delete http://example.com/user/1 来删除user 1。
  • 支持资源的多种表现形式,譬如上例Get中设置Content-Type为json,让服务端返回json格式的user信息。

相对于Restful,另一种则是Arbitrary的,我不熟悉SOAP,这里就以RPC为例。

RPC就是remote procedure call,它通过在HTTP请求中显示的制定需要调用的过程名字以及参数来与服务端进行交互。仍然是上面的例子,如果我们需要得到用户的信息,可能就是这样 Get http://example.com/getuser?userid=1, 如果要删除一个用户,没准是这样Get http://example.com/delUser?userid=1

那选择何种架构呢?在这里,我倾向使用Restful架构模型,很大原因在于它理解起来很容易,而且实现简单,而现在越来越多的Web service提供的API采用的是Restful模式,从另一个方面也印证了它的流行。

所以这个Web service的接口就是这样了:

GET http://kv.com/key
DELETE http://kv.com/key
POST http://kv.com/key -dvalue
PUT http://kv.com/key -dvalue

上面POST和PUT可以等价,如果key存在,则用value覆盖,不存在则新建。

架构

好了,扯了这么多,我们也要开始搭建我们的Web service了。因为采用的是HTTP协议,所以我们可以直接使用现成的HTTP server来帮我们处理HTTP请求。譬如nginx,apache,不过用go或者python直接写一个也不是特别困难的事情。

我们还需要一个storage server用来存放key-value,mysql可以,redis也行,或者我的ledisdb,谁叫红薯说可以打广告的。

最开始,我们就只有一台机器,启动一个nginx用来处理HTTP请求,然后启动一个ledisdb用来存放数据。然后开始对外happy的提供服务了。

KV开始工作的很好,突然有一天,我们发现随着用户量的增大,一台机器处理不过来了。好吧,我们在加一台机器,将nginx和ledisdb放到不同的机器上面。

可是好景不长,用户量越来越多,压力越来越大,我们需要再加机器了,因为nginx是一个无状态的服务,所以我们很容易的将其扩展到多台机器上面去运行,最外层通过DNS或者LVS来做负载均衡。但是对于有状态的服务,譬如上面的ledisdb,可不能这么简单的处理了。好吧,我们终于要开始扯到分布式了。

CAP

在聊分布式之前,我们需要知道CAP定理,因为在设计分布式系统的时候,CAP都是必须得面对的。

  • Consistency,一致性
  • Avaliability,可用性
  • Partition tolerance,分区容忍性

CAP的核心就在于在分布式系统中,你不可能同时满足CAP,而只能满足其中两项,但在分布式中,P是铁定存在的,所以我们设计系统的时候就需要在C和A之间权衡。

譬如,对于MySQL,它最初设计的时候就没考虑分区P,所以很好的满足CA,所以做过MySQL proxy方面工作的童鞋应该都清楚,要MySQL支持分布式是多么的蛋疼。

而对于一般的NoSQL,则是倾向于采用AP,但并不是说不管C,只是允许短时间的数据不一致,但能达到最终一致。

而对于需要强一致的系统,则会考虑牺牲A来满足CP,譬如很多系统必须写多份才算成功,

Replication

对于前面提到的Ledisdb,因为涉及到数据存放,本着不要把鸡蛋放到一个篮子里面的原则,我们也不能将数据放到一台机器上面,不然当机了就happy了。而解决这个的办法就是replication。

熟悉MySQL或者Redis的童鞋对replication应该都不会陌生,它们的replication都采用的是异步的方式,也就是在一段时间内不满足数据一致性,但铁定能达到最终一致性。

但如果真想支持同步的replication,怎么办呢?谁叫我们容不得数据半点丢失。通常有几种做法:

  • 2PC,3PC
  • Paxos,Raft

因为这方面的坑很深,就不在累述,不过我是很推崇Raft的,相比于2PC,3PC,以及Paxos,Raft足够简单,并且很好理解。有机会在说明吧。

水平扩展

好了,通过replication解决了ledisdb数据安全问题,但总有一天,一台机器顶不住了,我们要考虑将ledisdb的数据进行拆分到多台机器。通常做法如下:

  • 最简单的做法,hash(key) % num,num是机器的数量,但这种做法在添加或者删除机器的时候会造成rehash,导致大量的数据迁移。
  • 一致性hash,它相对于传统的hash,在添加或者删除节点的时候,它能尽可能的少的进行数据迁移。不过终归还是有数据流动的。
  • 路由映射表,不同于一致性hash,我们在外部自己负责维护一张路由表,这样添加删除节点的时候只需要更改路由表就可以了,相对于一致性hash,个人感觉更加可控。

我个人比较喜欢预分配+路由表的方式来进行水平扩展,所谓预分配,就是首先我就将数据切分到n个(譬如1024)shard,开始这些shard可以在一个node里面,随着node的增加,我们只需要迁移相关的shard,同时更新路由表就可以了。这种方式个人感觉灵活性最好,但对程序员要求较高,需要写出能自动处理resharding的健壮代码。

好了,解决了replication,解决了水平扩展,很长一段时间我们都能happy,当然坑还是挺多的,遇到了慢慢再填吧。

没有提到的关键点

  • Cache,无论怎样,cache在服务器领域都是一个非常关键的东西,用好了cache,你的服务能处理更多的并发访问。facebook这篇paper Scaling Memcache at Facebook专门讲解了相关知识,那是绝对的干货。
  • 消息队列,当并发量大了之后,光靠同步的API调用已经满足不了整个系统的性能需求,这时候就该MQ上场了,譬如RabbitMQ都是不错的选择。
  • 很多很多其他的。。。。。。。这里就不列举了。

总结

上面只是我对于Web service一点浅显的见解,如果里面的知识稍微对你有用,那我已经感到非常高兴了。但就像实践才是检验真理的唯一标准一样,理论知道的再多,还不如先弄一个Web service来的实在,反正现在国内阿里云,腾讯云,百度云啥的都不缺,缺的只是跑在上面的好应用。

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