Giter Club home page Giter Club logo

blog's People

Contributors

ma6174 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

blog's Issues

mongodb几个坑

使用mongodb有一段时间了,总体来说,mongodb还是非常给力的,但是有几个问题也是需要注意的。

对非UTF8编码的KEY支持不好

  • version: 1.8
  • problem: 数据库中存在重复的非uft8编码的主键

理论上说在数据库中,一个主键(单一主键或者联合主键)在数据库中的值是唯一确定的。在一次倒数据时发现对于同一个主键(存在非UTF8编码字符)在数据库中存在多个值,并且值还是不完全一样的。使用数据库API查询的时候当然只能查到一个值,但查到的值不一定是最新的,可能会导致数据不一致现象。因此在数据库存储过程中最好不要使用非UTF8编码的KEY

主从数据库数据不一致

  • version: 2.4, replset
  • problem: 主数据库和从数据库数据条目数不一致

mongodb使用复制集的时候,可以指定一个主数据库,接收所有的写请求(包括修改),然后记录相应的操作日志即oplog,从数据库从主数据库拉取oplog,再按先后顺序重放一下oplog,最终主数据库和从数据库数据应该是一致的。

最近在倒数据偶然发现主数据库和从数据库数据条目数不一致,表现为所有从数据库数据条目数比主数据库少并且所有从数据库数据条目数是一致的,数据正确性还需要进一步验证。主数据库在几天前已经停止写入和修改数据库了,从数据库已经同步完成了,从数据库条目数一致可以从反面印证。后来经过精确主从数据库数据对比发现有些数据只在主数据库有,有些数据只在从数据库有,有些数据在两个数据库都有但是内容不一致,最奇怪的一条是从数据库中的记录少了几个字段。后来查询数据库详细日志发现从数据库有恢复迹象,当数据文件损坏的时候数据库会进行数据修复。目前的问题是一个从数据库修复会影响所有的从数据库导致主从不一致。当磁盘出现问题的时候要格外关注,操作不当会导致丢数据,需要慎重。

大量数据导入速度缓慢

  • version: 2.4

当数据量达到亿级别以后,从数据源倒数据到mongodb性能会快速下降。有一张tokumx和mongodb的对比图可以看出来随着数据量的增加mongodb写入性能下降非常快。
http://www.tokutek.com/wp-content/uploads/2013/06/mongodb-blog-09-iibench-tps.png
我曾经测试过几种导入方式,比如单条写入、批量写入、使用mongoimport或mongorestore等官方工具导入,速度都不理想,导入速度越来越慢,可能和需要创建索引有关。sharding数据库导入速度更慢,估计和mongodb在分片的时候平衡数据有关。

因此需要对数据的量最好预估,不要在磁盘满的时候再想换大磁盘,不要等数据放不下的时候再做分片。

另外tokumx数据库看起来性能比mongodb好很多,还支持数据压缩,能节省很大的磁盘空间,并且是基于mongodb改造的,大部分api接口都是兼容的,可以做进一步的测试。

【翻译】Go语言, REST APIs 和 指针

原文链接:https://willnorris.com/2014/05/go-rest-apis-and-pointers


go-github项目中,有一个有趣的设计就是几乎所有和Github交互的API结构体成员,几乎全部使用指针。经过大量试错,我决定将我的遇到的问题和解决方案分享出来,并且我认为其他用Go语言写API客户端的人也需要思考。最原始的问题来源在google/go-github#19,整个讨论过程可能也是比较有趣的;这篇文章会以一种更加容易理解的方式展示这个问题。接下来会讲以下内容的相互影响:Go语言的零值、JSON和XML标签中的omitempty参数,和HTTP请求中PATCH的语义。

从最简单的开始

Go语言对大部分数据编码优雅而简单。你只需要定义一个结构体,对结构体里面的每一个成员添加一个标签来说明这个成员如何被编码成特定的格式就可以了。例如,对于Github仓库,可以使用一个简单的结构体来表示:

type Repository struct {
    Name        string `json:"name"`
    Description string `json:"description"`
    Private     bool   `json:"private"`
}

结构体中每一个成员都明确指定当被编码成JSON之后key的名字,接下来我们构建一个新的仓库并且编码成JSON格式:

r := new(Repository)
b, _ := json.Marshal(r)
println(string(b))

输出 >>> {"name":"","description":"","private":false}

当我们创建了一个新的仓库之后,每一个成员都被赋值为其对应的零值:字符串类型是一个空字符串"",布尔类型是false。Go语言不存在一个变量定义了但是没初始化这一说。当前按照定义,如果最初一个变量没有被赋值,那么就会被初始化为其对应的零值。牢记,这个当前非常重要。

理解 PATCH

正如PATCH名字一样,基于REST的API会传递当前资源的状态信息。这在HTTP协议中很常见,也很直接:要获取当前资源的信息,发一个GET请求到资源的URI就可以了。要更新一个资源,发送一个PUT请求到其资源的URI,需要附带上资源的新描述信息。PUT被定义为将URI对应的资源完整替换,也就是说你必须提供一个资源的完整信息。但是如果你只想更新资源的部分信息呢?这个需要通过PATCH来完成,定义在:RFC 5789

PATCH请求中的body内容如何应用到对应的资源要根据请求的media type来确定。Github(和其他很多JSON API)处理PATCH请求的方式是:对于要更新的资源,你以json形式提供一个新的值,JSON中不存在的字段将不会被更新。举个例子,要更新一个仓库的描述信息,HTTP请求应该看起来是这个样子:

PATCH /repos/google/go-github HTTP/1.1
Host: api.github.com

{"description": "new description"}

要删除描述信息,直接将其设置为空字符串就好了:

PATCH /repos/google/go-github HTTP/1.1
Host: api.github.com

{"description": ""}

如果你发了一个PATCH请求,包含了资源的每一个字段会怎么样?这和你使用相同Body的发了一个PUT请求是等价的。事实上,正因如此,所有的资源更新操作在Github上都由PATCH来完成。他们甚至不支持(至少文档上没说)使用PUT来完成这种请求。

忽略空值

go-github 库有一个名为Edit的方法用于更新仓库信息。因为Repository结构体包含了需要更新的字段,更新描述信息的Go代码应该是这样的:

r := &github.Repository{Description:"new description"}
client.Repositories.Edit("google", "go-github", r)

这段代码发出的HTTP请求是什么样的?如果你记得之前关于JSON编码的讨论,那么请求应该是这个样子:

PATCH /repos/google/go-github HTTP/1.1
Host: api.github.com

{"name": "", "description": "new description", "private": false}

但这并不和我们所指定的字段完全一样。Repository结构体即使没有设置nameprivate这两个字段,Body里面包含了。但是需要明确的是,这些字段被设置为它们的零值,因此和我们指定的是一致的。name字段倒是问题不大,因为它是不变的,github会忽略它。但是是private问题就比较大了,如果原先是一个私有仓库,类似看起来无害的操作可能导致仓库被公开!

为了避免这个问题,我们可以更新我们的Repository类型,让JSON编码的时候自动忽略空值:

type Repository struct {
    Name        string `json:"name,omitempty"`
    Description string `json:"description,omitempty"`
    Private     bool   `json:"private,omitempty"`
}

现在name的空字符串和privatefalse值被忽略了,实现了我们所预期的HTTP请求:

PATCH /repos/google/go-github HTTP/1.1
Host: api.github.com

{"description": "new description"}

到目前为止看起来是没问题的。

故意设置空值

现在我们回到上一个例子看一下代码是什么样子,现在我们删除仓库的描述信息,通过设置一个空字符串实现:

r := &github.Repository{Description:""}
client.Repositories.Edit("google", "go-github", r)

之前我们的结构体加上了omitempty参数,对这个请求会有影响吗?不幸的是,请求不是我们所预期的:

PATCH /repos/google/go-github HTTP/1.1
Host: api.github.com

{}

因为我们设置Repository结构体里面所有值为他们的零值,编码之后就成了一个空的JSON对象,这个请求不会产生任何影响,无法实现预期的删除仓库描述信息目的。

我们需要有一种方式来区分某个字段是因为初始化而被赋值为零值(JOSN序列化需要忽略)还是开发者有意将其设置为零值(JOSN序列化需要包含这些字段)。这时候指针就派上用场了、

指针

指针的零值是nil,不管指针指向什么类型。因此通过在我们的结构体成员中使用指针,我们可以明确区分因为没有设置而导致的空值:nil,和有意设置的空值,比如"",false0golang/protobuf就是这样做的,就是因为这个原因,我们的Repository结构体最终定义是这个样子的:

type Repository struct {
    Name        *string `json:"name,omitempty"`
    Description *string `json:"description,omitempty"`
    Private     *bool   `json:"private,omitempty"`
}

这确实会带来一些损耗,不管是内存分配还是开发者体验,因为如果一个字段是字符串或布尔值,是必须创建一个指向它的指针才能赋值,这个确实比较烦人。最终可能会写出类似这样的过于冗余代码:

d := "new description"
r := &github.Repository{Description:&d}
client.Repositories.Edit("google", "go-github", r)

为了让这个操作更加简单,go-github提供了一个获取某种类型对应的指针的函数(拷贝自protobuf包):

r := &github.Repository{Description: github.String("new description")}
client.Repositories.Edit("google", "go-github", r)

使用指针也意味着使用这个库的客户端必须进行适当nil检查去避免程序painc。protobuf库提供了访问这些成员的代码,这让开发更简单一些。但是go-github并没有提供。

其他库

你Go实现的客户端API会因此受到影响吗?答案是看情况。如果API不提供像PATCH这样的部分更新功能,那么你不需要设置omitempty这个参数,也不用担心指针问题,你原来是方式没有问题。如果你在你的JSON或者XML请求里面永远不会设置零值,比如空字符串、false0(或许不太可能),那么你只需要设置omitempty参数就好了,其他照常。但是对于大部分现代API接口,这些都不应该出现的。你可以测试一下你当前的库是否会阻止你执行特定的操作。

(我想再说明一点是在google/go-github#19讨论了一些可替代的方法并没有实际尝试,比如使用字段掩码或者直接使用protobuf包。研究学习一下可能是值得的。指针仅仅在这个库里面是有效的;其他方法只要能解决问题都是没问题的)。

深入阅读

如何进入正在执行的 docker container

当一个container起来之后,我们有时候希望能进入container内部去看看,比如查查日志,执行些操作等。目前有几种方式可以实现:

1. docker attach

这个是官方提供的一种方法。

测试,首先启动一个container:

$ docker run -i -t ubuntu bash
root@4556f5ad6067:/#

不要退出,打开另一个终端:

$ docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
4556f5ad6067        ubuntu:14.04        "bash"              45 seconds ago      Up 43 seconds                           jolly_ardinghelli

$ docker attach 4556f5ad6067
root@4556f5ad6067:/#

这样就连接进去了。这时候如果我们输入一些命令,就能看到在两个终端都有显示和输出。这种方式有比较大的局限性,如果知道了entrypoint或者有程序正在执行,通过docker attach进入之后是不能执行操作的,一个终端退出之后整个container就终止了。不推荐使用这种方式。

2. lxc-attach

如果使用这种方式,首先要保证docker是以lxc方式启动的,具体可以这样做:

  1. 修改/etc/default/docker增加DOCKER_OPTS="-e lxc"
  2. 重启docker服务sudo service docker restart

启动container的方式和之前一样:

$ docker run -i -t ubuntu bash
root@e7f01f0ff598:/#

进入container可以这样:

$ docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
e7f01f0ff598        ubuntu:14.04        "bash"              17 seconds ago      Up 15 seconds                           grave_jones

$ ps aux | grep e7f01f0ff598
root     23691  0.0  0.0  43140  1876 pts/9    Ss   21:47   0:00 lxc-start -n e7f01f0ff598c80d70a996135c98fbaeddc6daa61436bbbfa735233e8b6f8ebe -f /var/lib/docker/containers/e7f01f0ff598c80d70a996135c98fbaeddc6daa61436bbbfa735233e8b6f8ebe/config.lxc -- /.dockerinit -g 172.17.42.1 -i 172.17.0.3/16 -mtu 1500 -- bash
ma6174   23756  0.0  0.0  13428   928 pts/12   S+   21:47   0:00 grep --color=auto e7f01f0ff598

$ sudo lxc-attach -n e7f01f0ff598c80d70a996135c98fbaeddc6daa61436bbbfa735233e8b6f8ebe
root@e7f01f0ff598:/#

这种方式还是很方便的。前提是需要重启docker服务以lxc的方式执行,进入container之后会有一个终端可以执行命令,不影响正在执行的程序。

3. nsenter

如果docker不是以lxc方式启动的,这时候还想进入一个正在执行的container的话,可以考虑使用nsenter

这个程序的安装方式很独特,使用docker进行安装:

$ docker run --rm -v /usr/local/bin:/target jpetazzo/nsenter

使用方法也很简单,首先你要进入的container的PID

$ PID=$(docker inspect --format {{.State.Pid}} <container_name_or_ID>)

然后就可以用这个命令进入container了:

$ nsenter --target $PID --mount --uts --ipc --net --pid

为了使用方便可以写一个脚本自动完成:

$ cat /bin/docker_enter
#!/bin/bash
sudo nsenter --target `docker inspect --format {{.State.Pid}} $1` --mount --uts --ipc --net --pid bash

这样每次要进入某个container只需要执行docker_enter <container_name_or_ID>就可以了。

4. ssh

这个原理也很简单,在container里面启动ssh服务,然后通过ssh的方式去登陆到container里面,不推荐这种方式,主要是配置ssh登陆比较繁琐,开启ssh服务也会耗费资源,完全没有必要。

~~~~~~~~~~~~~~~华丽的分割线~~~~~~~~~~~~~~~

docker 1.2.0发布,带来一个比较实用的特性的是支持restart参数,可以在docker run的时候指定:

  --restart=""               Restart policy to apply when a container exits (no, on-failure, always)

有这个参数就比较方便了,比如container里面的服务因为某些原因退出了,之前只能通过外部程序去重新启动container,有了这参数之后可以放container自动重启,当然也可以设置失败重试次数,通过on-failure:5这种方式来指定失败后最多尝试重启5次。

官方的两个例子:

docker run --restart=always redis

docker run --restart=on-failure:5 redis

其他新特性参考:https://blog.docker.com/2014/08/announcing-docker-1-2-0/

Go GC问题

近期网站访问量大增,随之而来出现一些莫名其妙的慢请求,表现为:有两个服务A和B,服务A要去服务B请求数据,从日志看,服务A有比较多的秒级的慢请求,根据日志判断慢请求是因为服务B变慢了,接下来查B的日志,发现B只有极少的慢请求的日志,最起码从某个慢请求跟踪来看,服务B的处理返回数据很快,并没有出现所谓的慢请求。这个就比较有趣了,A说B变慢了,B说我没有变慢。到底是A自己慢了,还是B慢了,或者是A到B之间的链路慢了?

首先要确认是否是链路变慢了,A和B两个服务在同一个局域网内,两个服务互ping时间在1ms以内。之前有发现因为丢包导致网络变慢的现象,但是查两台机器发现丢包率都很低,在正常范围内。这个基本可以排除网络问题。

因为之前日志记录不是很完善,接下来首先要做的是补日志,关键操作记录时间。随后分析日志发现,服务A从收到请求到将请求发送到B这个时间是毫秒级的,B从收到请求到处理完返回也是毫秒级的,也就是说两边处理请求都很快。后来详细对比服务A和B的时间戳发现一个惊人的现象:服务A发请求给B的时间和服务B收到请求的时间差了几秒!出现这样的情况首先要确认服务A和服务B所在是机器时间是一致的,经检查确实是一致的。会不会是网络丢包导致的?如果有比较严重的丢包问题的话,收发请求出现大量重传可能会导致比较大的网络延迟。但是之前已经分析过,两台机器之间的网络状况非常好,没有丢包现象。即使有丢包的话,出现丢包问题的时候,如果如果等待200ms没收到回复会进行重传,如果要达到秒级别的延时的话至少一个请求要丢包5次以上,这已经是很严重的丢包事故了,很容易被检测到。

这样看起来似乎问题没法继续分析解答了。就在这时,服务B出现了一条500的日志,看错误信息是too many open files,一般出现这种情况是因为服务B自己文件句柄数超了,当超过6000的时候就比较容易出现上面的错误。这里的files不一定是真实的文件,有可能是tcp连接,因为在linux下一切皆是文件。先写个程序监测一些服务B的文件句柄数吧,检查一个进程的文件句柄数可以用lsof -n -p pid | wc -l来观测,pid就是进程的pid。写个shell脚本循环观测:

#!/bin/sh
while true
do
        echo -n `date`"\t"
        lsof -n -p `pidof service_name` | wc -l
        sleep 0.5
done

部分观测结果:

Tue Jul 22 19:42:51 CST 2014    2715
Tue Jul 22 19:42:52 CST 2014    2713
Tue Jul 22 19:42:53 CST 2014    2711
Tue Jul 22 19:42:53 CST 2014    2711
Tue Jul 22 19:42:54 CST 2014    2708
Tue Jul 22 19:42:55 CST 2014    10177
Tue Jul 22 19:42:59 CST 2014    3103
Tue Jul 22 19:43:00 CST 2014    2722
Tue Jul 22 19:43:00 CST 2014    2719

从上面的结果我们很容易看出来,在19:42:55文件句柄数从2708突然升到了10177,随后很快恢复。之前说过,当文件句柄数超过6000就有可能会出现too many open files错误。这里文件句柄数都升到1w了。继续观察fd(文件句柄)突然升高的频率,发现在网络高峰期大约每2~3分钟就会出现一次。是不是因为fd突然升高导致慢请求呢?继续分析服务A的日志,发现确实在fd升高的时候会出现慢请求。如果是这样的话,是不是就能认为服务B在那个时间点有太多请求了导致从服务A新来的连接无法建立而出现慢请求?这只是一种猜测,当然还有一个更大的疑惑需要答案:服务B的fd为什么会突然升高?

因为服务B基本不会读写本地文件,因此可以认定fd升高肯定是突然有大量请求到来。是不是服务A的请求数有大幅度波动导致B有大幅度波动?这个需要统计服务A的每秒的请求数,发现并没有突然升高迹象。因为我们对服务B做了负载均衡,如果B1服务器突然有大量请求到来的话,那么B2服务器也应当会同时出现相同的fd突然升高现象。观察发现,B2服务器确实有升高现象,升高频率相似,但是时间点不同,能差几十秒。从这里我们基本能确定是B服务器在某个时间点发生了什么事情导致fd升高。

fd升高会有什么影响?继续查B的日志,惊奇的发现,fd升高之前,会有几秒钟没有任何日志的情况。难道是服务卡住了?如果是卡住的话,还能接受请求吗?为了验证这个问题,我们用一个简单的curl来测试

#!/bin/sh
while true
do
        echo `date`
        curl http://127.0.0.1:8080 -o /dev/null > /dev/null 2>&1
        sleep 0.2
done

部分结果:

Tue Jul 22 18:26:09 CST 2014
Tue Jul 22 18:26:09 CST 2014
Tue Jul 22 18:26:09 CST 2014
Tue Jul 22 18:26:09 CST 2014
Tue Jul 22 18:26:14 CST 2014
Tue Jul 22 18:26:14 CST 2014
Tue Jul 22 18:26:14 CST 2014

从这里我们看到,在18:26:09curl确实卡住了,直到18:26:14才恢复,卡了将近5秒!我们有理由相信,curl会卡住,那么来自服务A的请求也有可能会卡住,因为我们在这段时间并没有看到服务B的任何日志,也就是说服务B卡住了,拒绝提供服务了。

如果我们curl和检查fd同时进行,对比结果会有新发现(左边是检查fd结果,右边是curl日志)。

Tue Jul 22 19:44:52 CST 2014    2708        │Tue Jul 22 19:44:57 CST 2014
Tue Jul 22 19:44:52 CST 2014    2710        │Tue Jul 22 19:44:57 CST 2014
Tue Jul 22 19:44:53 CST 2014    2707        │Tue Jul 22 19:44:58 CST 2014
Tue Jul 22 19:44:54 CST 2014    2709        │Tue Jul 22 19:44:58 CST 2014
Tue Jul 22 19:44:54 CST 2014    2729        │Tue Jul 22 19:44:58 CST 2014
Tue Jul 22 19:44:55 CST 2014    2707        │Tue Jul 22 19:44:58 CST 2014
Tue Jul 22 19:44:56 CST 2014    2709        │Tue Jul 22 19:45:03 CST 2014
Tue Jul 22 19:44:56 CST 2014    2714        │Tue Jul 22 19:45:03 CST 2014
Tue Jul 22 19:44:57 CST 2014    2718        │Tue Jul 22 19:45:03 CST 2014
Tue Jul 22 19:44:58 CST 2014    2710        │Tue Jul 22 19:45:03 CST 2014
Tue Jul 22 19:44:58 CST 2014    2713        │Tue Jul 22 19:45:04 CST 2014
Tue Jul 22 19:44:59 CST 2014    2713        │Tue Jul 22 19:45:04 CST 2014
Tue Jul 22 19:45:02 CST 2014    10219       │Tue Jul 22 19:45:04 CST 2014
Tue Jul 22 19:45:03 CST 2014    2877        │Tue Jul 22 19:45:04 CST 2014
Tue Jul 22 19:45:04 CST 2014    2721        │Tue Jul 22 19:45:04 CST 2014
Tue Jul 22 19:45:05 CST 2014    2710        │Tue Jul 22 19:45:05 CST 2014
Tue Jul 22 19:45:05 CST 2014    2710        │Tue Jul 22 19:45:05 CST 2014
Tue Jul 22 19:45:06 CST 2014    2714        │Tue Jul 22 19:45:05 CST 2014
Tue Jul 22 19:45:07 CST 2014    2716        │Tue Jul 22 19:45:05 CST 2014

我们可以看到,在19:44:58,curl卡住了,直到19:45:03恢复。而句柄数升高是在19:45:02。我们基本能够确认,在19:44:58的时候,服务B卡住了,但是服务A的请求还是会到来,请求会堆积,大约4~5秒之后,服务B恢复正常,大量请求到来导致服务B句柄数升高。

看来句柄数升高只是一个结果,服务B卡住是直接原因,那么服务B为什么会卡住呢?从上面的分析看,当服务B卡住之后,会出现下面几个现象:

  1. 拒绝接受请求
  2. 没有任何日志滚动

如果是某个goroutine卡住了,其他goroutine不一定卡住,还会有日志的,也能正常接收新请求。看起来整个进程卡住了,卡住这段时间发生了什么?Go有自动垃圾回收(gc)机制,我们有理由怀疑是因为gc卡住了。

如何验证是否是GC的问题?最简单的方法是看GC日志。如果你使用Go1.2之前的版本,可以这样启动程序:

GOGCTRACE=1 /path/to/your/program

如果你是Go1.2之后的版本,需要这样开启GC日志:

GODEBUG=gctrace=1 /path/to/your/program

这样程序启动的时候我们就能看到类似这样的日志:

gc1(1): 0+0+0 ms, 0 -> 0 MB 16 -> 18 (19-1) objects, 0(0) handoff, 0(0) steal, 0/0/0 yields
gc2(1): 0+0+0 ms, 0 -> 0 MB 29 -> 29 (30-1) objects, 0(0) handoff, 0(0) steal, 0/0/0 yields
gc3(1): 0+0+0 ms, 0 -> 0 MB 972 -> 747 (973-226) objects, 0(0) handoff, 0(0) steal, 0/0/0 yields
gc4(1): 0+0+0 ms, 0 -> 0 MB 1248 -> 904 (1474-570) objects, 0(0) handoff, 0(0) steal, 0/0/0 yields

想了解每个字段的含义的话可以看 源码,这里我们关注的是GC在什么时候发生的,频率是多少,GC一次花多长时间,GC的效果可以看内存减少了多少,也可以看对象减少多少。

开启GC log之后,我们再去看日志,就会发现有这样的信息:

2014/07/25 11:03:28 app log...
gc183(8): 18+4043+495 ms, 32426 -> 16950 MB 205909094 -> 3045275 (23371853344-23368808069) objects, 60(1982) handoff, 71(105762) steal, 564/267/314 yields
2014/07/25 11:03:33 app log...

很明显,在11:03:2811:03:33这几秒时间都在GC,再看之前的fd测试和curl测试结果:

Fri Jul 25 11:03:26 CST 2014    2463      │Fri Jul 25 11:03:27 CST 2014
Fri Jul 25 11:03:26 CST 2014    2461      │Fri Jul 25 11:03:27 CST 2014
Fri Jul 25 11:03:27 CST 2014    2460      │Fri Jul 25 11:03:27 CST 2014
Fri Jul 25 11:03:28 CST 2014    2459      │Fri Jul 25 11:03:28 CST 2014
Fri Jul 25 11:03:29 CST 2014    2462      │Fri Jul 25 11:03:28 CST 2014
Fri Jul 25 11:03:31 CST 2014    2462      │Fri Jul 25 11:03:28 CST 2014
Fri Jul 25 11:03:31 CST 2014    9738      │Fri Jul 25 11:03:28 CST 2014
Fri Jul 25 11:03:34 CST 2014    2501      │Fri Jul 25 11:03:33 CST 2014
Fri Jul 25 11:03:35 CST 2014    2500      │Fri Jul 25 11:03:33 CST 2014
Fri Jul 25 11:03:36 CST 2014    2492      │Fri Jul 25 11:03:34 CST 2014
Fri Jul 25 11:03:36 CST 2014    2493      │Fri Jul 25 11:03:34 CST 2014
Fri Jul 25 11:03:37 CST 2014    2494      │Fri Jul 25 11:03:34 CST 2014
Fri Jul 25 11:03:38 CST 2014    2490      │Fri Jul 25 11:03:35 CST 2014
Fri Jul 25 11:03:38 CST 2014    2490      │Fri Jul 25 11:03:35 CST 2014
Fri Jul 25 11:03:39 CST 2014    2490      │Fri Jul 25 11:03:35 CST 2014
Fri Jul 25 11:03:40 CST 2014    2489      │Fri Jul 25 11:03:35 CST 2014
Fri Jul 25 11:03:40 CST 2014    2493      │Fri Jul 25 11:03:36 CST 2014

现在原因很清晰了,程序发生了GC,curl会卡住,GC结束之后大量请求到来会导致fd升高。GC竟然卡了5秒!对于一个线上服务来说,这个是不能忍受的。

GC时间长,说明程序创建了大量的对象,占用了较多的内存,进行垃圾回收的时间肯定会延长,对比了一下,在半夜访问量比较少的时候,GC的时间会更短。

如何解决GC时间长导致程序卡住问题?

先去网上找了一下,果然找到一个人遇到了类似的情况:https://groups.google.com/forum/#!topic/golang-nuts/S9goEGuoMRM,原文是这样的:

Hello everyone, 

Our business suffered from an annoying problem. We are developing an 
iMessage-like service in Go, the server can serves hundreds of 
thousands of concurrent TCP connection per process, and it's robust 
(be running for about a month), which is awesome. However, the process 
consumes 16GB memory quickly, since there are so many connections, 
there are also a lot of goroutines and buffered memories used. I 
extend the memory limit to 64GB by changing runtime/malloc.h and 
runtime/malloc.goc. It works, but brings a big problem too - The 
garbage collecting process is then extremely slow, it stops the world 
for about 10 seconds every 2 minutes, and brings me some problems 
which are very hard to trace, for example, when stoping the world, 
messages delivered may be lost. This is a disaster, since our service 
is a real-time service which requires delivering messages as fast as 
possible and there should be no stops and message lost at all. 

I'm planning to split the "big server process" to many "small 
processes" to avoid this problem (smaller memory footprint results to 
smaller time stop), and waiting for Go's new GC implementation. 

Or any suggestions for me to improve our service currently? I don't 
know when Go's new latency-free garbage collection will occur. 

Thanks. 

大体意思是他用Go开发了一个实时消息系统,访问量很大,占用内存比较多,服务每两分钟就会因为GC问题卡住10秒(比我们那个还严重),对于一个实时的系统来说,这个是不能接受的。

后面有比较多的讨论,有建议他升级Go语言版本,他确实升级了,效果也比较明显,升级之后GC减小到1~2秒,不过还是比较长的:

Hello everyone, 

Thanks for all your help, I updated our Go version to: 

go version devel +852ee39cc8c4 Mon Nov 19 06:53:58 2012 +1100 

and rebuilt our servers, now GC duration reduced to 1~2 seconds, it's 
a big improvement! 
Thank contributors on the new GC! 

看来升级Go版本是一个办法,新版本会对GC进行优化。尝试升级Go版本。

先从Go1.1升级到Go1.2.1,发现GC问题基本没解决,甚至比之前更差。升到1.3之后,惊喜得发现GC时间明显缩短,一次GC在几十毫秒内就能完成,fd没有突然升高现象了,curl也没有卡住现象了:

gc43(8): 44+21+49805+104 us, 3925 -> 7850 MB, 50135750 (1781280527-1731144777) objects, 549097/394298/0 sweeps, 117(9232) handoff, 69(2683) steal, 969/439/2061 yields

再看一下go 1.3的release note,确实对GC有优化:

Changes to the garbage collector

For a while now, the garbage collector has been precise when examining values in the heap; the Go 1.3 release adds equivalent precision to values on the stack. This means that a non-pointer Go value such as an integer will never be mistaken for a pointer and prevent unused memory from being reclaimed.

Starting with Go 1.3, the runtime assumes that values with pointer type contain pointers and other values do not. This assumption is fundamental to the precise behavior of both stack expansion and garbage collection. Programs that use package unsafe to store integers in pointer-typed values are illegal and will crash if the runtime detects the behavior. Programs that use package unsafe to store pointers in integer-typed values are also illegal but more difficult to diagnose during execution. Because the pointers are hidden from the runtime, a stack expansion or garbage collection may reclaim the memory they point at, creating dangling pointers.

Updating: Code that uses unsafe.Pointer to convert an integer-typed value held in memory into a pointer is illegal and must be rewritten. Such code can be identified by go vet

几十毫秒是能接受的。如果升级Go版本没有用那么还有没有其他措施?

  1. 部署更多服务分担负载

服务个数多了,每个服务的请求数就少了,程序内部对象数目就少了,GC的时间会减少。

  1. 引入对象池

对于频繁使用的,数量较多的对象,程序初始化的时候生成一定数量的对象,以后程序用到这类对象的时候不是去重新生成而是去对象池中取一个对象。这样对象的总数是一定的,减少垃圾回收的对象个数。

  1. 修改GC并发数(未测试)

Go默认并发数是8,参考http://golang.org/src/pkg/runtime/malloc.h#L140,如果机器性能比较高的话,可以尝试提高GC并发数,理论上说应该会快一些。

  1. 服务超时自动重试其他机器

假设正常的话服务B1在100毫秒内会返回数据,某次请求发现150毫秒还没有返回数据,这时候服务A主动断开,尝试去服务B2去请求数据。这个依据是服务B1和服务B2并不一定会同时卡住。这样的好处是即使服务B的GC时间比较长,也不会影响服务A,因为A可以选一个没有GC的的B去请求数据。

Go HTTP如何正确重试

问题来源

为了提高系统可靠性,当一个HTTP请求因为某些原因失败了之后,需要重新发送请求进行尝试。如果是GET请求,重试很简单,再重新发一下GET请求就可以了,如果是带有BodyPOST请求,在重发请求的时候需要把Body也重发一遍。我们先看看Go HTTP中对Request定义

type Request struct {
    Method string
    URL *url.URL
    Header Header
    Body io.ReadCloser
    ContentLength int64
    TransferEncoding []string
    Close bool
    Host string
    Form url.Values
    PostForm url.Values
    MultipartForm *multipart.Form
    Trailer Header
    RemoteAddr string
    RequestURI string
    TLS *tls.ConnectionState
}

这里的Body类型是io.ReadCloser,再简化一些就是一个Reader,服务端收到请求之后就会从这个Reader中调用Read()函数去读取数据,通常情况当服务端去读取数据的时候,offset会随之改变,下一次再读的时候会从offset位置继续向后读取。

当一个请求失败了我们要进行重试的时候,就必须重新发送Body,因为Body有可能已经被读过了,因此需要将Body这个Reader进行重置,我们可能首先想到的就是将Body执行Seek操作,Seek到Reader的起始位置,这样做的前提是这个Reader支持Seek操作,也就是说这个Reader是一个ReadSeeker。这样做有没有问题呢?我们先写一个脚本测试一下:

package main

import (
    "io"
    "log"
    "net/http"
    "os"
    "strings"
    "time"

    "github.com/ma6174/slowrw"
)

func handle(rw http.ResponseWriter, req *http.Request) {
    go func() {
        buf := make([]byte, 1)
        n, err := io.ReadFull(req.Body, buf)
        if err != nil || n != 1 {
            log.Fatal("read failed:", n, err)
        }
        if buf[0] != '1' {
            log.Fatalf("buf not start at 1: %#v", string(buf))
        }
        out, err := os.Open(os.DevNull)
        if err != nil {
            log.Fatal(err)
        }
        io.Copy(out, req.Body)
    }()
    time.Sleep(time.Second)
}

func main() {
    log.SetFlags(log.Lshortfile | log.LstdFlags)
    go func() {
        http.HandleFunc("/", handle)
        log.Fatal(http.ListenAndServe(":9999", nil))
    }()
    reader := strings.NewReader("1234567890abcdefghigklmnopqrst")
    sr := slowrw.NewReadSeeker(reader, time.Millisecond)
    req, err := http.NewRequest("POST", "http://127.0.0.1:9999/", sr)
    if err != nil {
        log.Fatal(err)
    }
    client := http.Client{
        Timeout: time.Millisecond * 10,
    }
    for {
        _, err := client.Do(req)
        if err != nil {
            log.Println(err)
        }
        _, err = sr.Seek(0, 0)
        if err != nil {
            log.Fatal("seek failed", err)
        }
    }
}

对上面的测试简单说明一下:这里定义了客户端和服务端,先说服务端,收到请求后会开一个goutine去异步地读取数据,读数据的时候先读一个字节,正常情况下我们认为第一个字节数据是1,如果读到的数据非1则认为body已经被读过了,数据是错的。

通常情况下读数据和收数据非常快,为了放慢速度方便测试,这里引用了我写的一个 github.com/ma6174/slowrw 库,这里我们用的是SlowReader,也就是说客户端在发数据的时候,1毫秒才能发一个字符。服务端读数据不限速。

另外客户端有设置一个超时时间:Timeout: time.Millisecond * 10,因为上面说了客户端没1毫秒才能发送一个字符,我们设置的字符有30个,所以全部发送完至少要30毫秒,但是我们客户端限制整个请求时间是10毫秒,也就是说客户端请求最终肯定会超时失败,这样做的目的是模拟客户端请求失败情况,并触发重试。

先看一下执行结果:

$ go run seek_http.go
2015/01/11 16:35:39 seek_http.go:51: Post http://127.0.0.1:9999/: read tcp 127.0.0.1:9999: use of closed network connection
2015/01/11 16:35:39 seek_http.go:51: Post http://127.0.0.1:9999/: read tcp 127.0.0.1:9999: use of closed network connection
2015/01/11 16:35:39 seek_http.go:51: Post http://127.0.0.1:9999/: read tcp 127.0.0.1:9999: use of closed network connection
2015/01/11 16:35:39 seek_http.go:51: Post http://127.0.0.1:9999/: read tcp 127.0.0.1:9999: use of closed network connection
2015/01/11 16:35:39 seek_http.go:51: Post http://127.0.0.1:9999/: read tcp 127.0.0.1:9999: use of closed network connection
2015/01/11 16:35:39 seek_http.go:51: Post http://127.0.0.1:9999/: read tcp 127.0.0.1:9999: use of closed network connection
2015/01/11 16:35:39 seek_http.go:22: buf not start at 1: "4"
exit status 1
$
$ go run seek_http.go
2015/01/11 16:35:42 seek_http.go:51: Post http://127.0.0.1:9999/: read tcp 127.0.0.1:9999: use of closed network connection
2015/01/11 16:35:42 seek_http.go:51: Post http://127.0.0.1:9999/: read tcp 127.0.0.1:9999: use of closed network connection
2015/01/11 16:35:42 seek_http.go:51: Post http://127.0.0.1:9999/: read tcp 127.0.0.1:9999: use of closed network connection
2015/01/11 16:35:42 seek_http.go:51: Post http://127.0.0.1:9999/: read tcp 127.0.0.1:9999: use of closed network connection
2015/01/11 16:35:42 seek_http.go:22: buf not start at 1: "5"
exit status 1
$
$ go run seek_http.go
2015/01/11 16:35:46 seek_http.go:51: Post http://127.0.0.1:9999/: read tcp 127.0.0.1:9999: use of closed network connection
2015/01/11 16:35:46 seek_http.go:51: Post http://127.0.0.1:9999/: read tcp 127.0.0.1:9999: use of closed network connection
2015/01/11 16:35:46 seek_http.go:51: Post http://127.0.0.1:9999/: read tcp 127.0.0.1:9999: use of closed network connection
2015/01/11 16:35:46 seek_http.go:22: buf not start at 1: "4"
exit status 1

看程序运行结果能看出,在经过几次正常(数据以1开始,最终超时)的请求之后,服务端就会出现收到不是以1开始的Body,这就意味着请求失败了,这样证实了通过每次Seek到起始位置的方法是不靠谱的。

问题分析

HTTP Server在读取数据的时候可能是在一个goroutine里面进行,当一个请求失败之后,虽然客户端调用了CancelRequest将请求取消,但是服务端可能还会继续读取部分数据。如果我们调用Seek之后服务端还在读取数据的话,就会造成数据偏移,导致服务端下次再读数据的时候读到的数据不完整。当Body中数据量比较大或者网络不稳定或者客户端速度限制等原因造成读取数据传输时间很长的情况,用Seek出现问题的可能性就非常大。

问题解决

解决方案也很多,既然同一个reader可能被并发读取,那我们只要保证一个reader只能被读取一次就可以了,最简单的方法是每次都重新构造一个reader。上面的代码简单修改是这样的:

package main

import (
    "io"
    "log"
    "net/http"
    "os"
    "strings"
    "time"

    "github.com/ma6174/slowrw"
)

func handle(rw http.ResponseWriter, req *http.Request) {
    go func() {
        buf := make([]byte, 1)
        n, err := io.ReadFull(req.Body, buf)
        if err != nil || n != 1 {
            log.Fatal("read failed:", n, err)
        }
        if buf[0] != '1' {
            log.Fatalf("buf not start at 1: %#v", string(buf))
        }
        out, err := os.Open(os.DevNull)
        if err != nil {
            log.Fatal(err)
        }
        io.Copy(out, req.Body)
    }()
    time.Sleep(time.Second)
}

func main() {
    log.SetFlags(log.Lshortfile | log.LstdFlags)
    go func() {
        http.HandleFunc("/", handle)
        log.Fatal(http.ListenAndServe(":9999", nil))
    }()
    reader := strings.NewReader("1234567890abcdefghigklmnopqrst")
    sr := slowrw.NewReader(reader, time.Millisecond)
    req, err := http.NewRequest("POST", "http://127.0.0.1:9999/", sr)
    if err != nil {
        log.Fatal(err)
    }
    client := http.Client{
        Timeout: time.Millisecond * 10,
    }
    for {
        _, err := client.Do(req)
        if err != nil {
            log.Println(err)
        }
        reader = strings.NewReader("1234567890abcdefghigklmnopqrst")
        sr = slowrw.NewReader(reader, time.Millisecond)
        req, err = http.NewRequest("POST", "http://127.0.0.1:9999/", sr)
        if err != nil {
            log.Fatal(err)
        }
    }
}

这段代码执行没有任何问题,但是存在隐患,主要体现在reader每次都是通过通过数据新生成的,这里用的是strings.NewReader,这就意味着客户端必须持有原始数据,直到请求成功或者放弃重试。如果请求的数据比较大或者请求量大的话可能会占用大量的内存。

另外一种解决方案是用io.ReaderAt,我们只需要对现有的reader进行封装,对于每一个请求,保证数据是从起始位置开始读取即可,先上代码:

package main

import (
    "io"
    "log"
    "net/http"
    "os"
    "strings"
    "time"

    "github.com/ma6174/slowrw"
)

func handle(rw http.ResponseWriter, req *http.Request) {
    go func() {
        buf := make([]byte, 1)
        n, err := io.ReadFull(req.Body, buf)
        if err != nil || n != 1 {
            log.Fatal("read failed:", n, err)
        }
        if buf[0] != '1' {
            log.Fatalf("buf not start at 1: %#v", string(buf))
        }
        out, err := os.Open(os.DevNull)
        if err != nil {
            log.Fatal(err)
        }
        io.Copy(out, req.Body)
    }()
    time.Sleep(time.Second)
}

type Reader struct {
    Reader io.ReaderAt
    Offset int64
}

func (p *Reader) Read(val []byte) (n int, err error) {
    n, err = p.Reader.ReadAt(val, p.Offset)
    p.Offset += int64(n)
    return
}

func (p *Reader) Close() error {
    if rc, ok := p.Reader.(io.ReadCloser); ok {
        return rc.Close()
    }
    return nil
}

func main() {
    log.SetFlags(log.Lshortfile | log.LstdFlags)
    go func() {
        http.HandleFunc("/", handle)
        log.Fatal(http.ListenAndServe(":9999", nil))
    }()
    reader := strings.NewReader("1234567890abcdefghigklmnopqrst")
    sr := slowrw.NewReaderAt(reader, time.Millisecond)
    ra := &Reader{reader, 0}
    req, err := http.NewRequest("POST", "http://127.0.0.1:9999/", ra)
    if err != nil {
        log.Fatal(err)
    }
    client := http.Client{
        Timeout: time.Millisecond * 10,
    }
    for {
        _, err := client.Do(req)
        if err != nil {
            log.Println(err)
        }
        sr = slowrw.NewReaderAt(reader, time.Millisecond)
        reader2 := &Reader{sr, 0}
        req.Body = reader2
    }
}

上面代码中,reader只有一个,但是每次都用&Reader{sr, 0}去生成一个从起点开始读数据的reader2,因为reader2每次都是从起点开始读取的,所以不会出现一个reader被多次读取的情况。当然用这种方式的前提是这个reader必须是io.ReaderAt即支持ReadAt()方法。

mongodb正则表达式搜索bug

当正则表达式搜索条件中包含\x00(或者\u0000,\0,null)这个特殊字符时,可能得到非预期的结果,我们可以一步一步来验证:

1. \x00是一个合法的Value

mongodb对文档的定义可以参考http://docs.mongodb.org/manual/core/document/#field-value-limit,这里面提到,key不能包含null,但是value是可以的,实际测试下来也是这样的:

$ mongo
MongoDB shell version: 3.0.3
connecting to: test
> db.test.save({"a\x00":"a0"}) // key contains \x00 is invalid
2015-06-13T10:46:41.223+0800 E QUERY    Error: JavaScript property (name) contains a null char which is not allowed in BSON. {}
    at addToOperationsList (src/mongo/shell/bulk_api.js:602:29)
    at insert (src/mongo/shell/bulk_api.js:649:14)
    at DBCollection.insert (src/mongo/shell/collection.js:243:18)
    at DBCollection.save (src/mongo/shell/collection.js:493:21)
    at (shell):1:9 at src/mongo/shell/bulk_api.js:602
> db.test.save({"_id":"a\x00"}) // value contains \x00 is ok
WriteResult({ "nMatched" : 0, "nUpserted" : 1, "nModified" : 0, "_id" : "a\u0000" })
> db.test.find()
{ "_id" : "a\u0000" }
>

2. 为了方便对比测试再添加几个文档

> db.test.save({"_id":"a\x00b"})
WriteResult({ "nMatched" : 0, "nUpserted" : 1, "nModified" : 0, "_id" : "a\u0000b" })
> db.test.save({"_id":"ab"})
WriteResult({ "nMatched" : 0, "nUpserted" : 1, "nModified" : 0, "_id" : "ab" })
> db.test.save({"_id":"a"})
WriteResult({ "nMatched" : 0, "nUpserted" : 1, "nModified" : 0, "_id" : "a" })
> db.test.find()
{ "_id" : "a\u0000" }
{ "_id" : "a\u0000b" }
{ "_id" : "ab" }
{ "_id" : "a" }
>

3. 使用简单查询可以正确取到文档

> db.test.find({_id:"a\x00"})
{ "_id" : "a\u0000" }
> db.test.find({_id:"a\x00b"})
{ "_id" : "a\u0000b" }
> db.test.find({_id:"ab"})
{ "_id" : "ab" }
> db.test.find({_id:"a"})
{ "_id" : "a" }

4. 使用正则表达式搜索会返回非预期文档

  • 搜索以a\x00为前缀的文档,{ "_id" : "a" }不应该出现
> db.test.find({_id: {"$regex" : "^a\x00"}})
{ "_id" : "a" }
{ "_id" : "a\u0000" }
{ "_id" : "a\u0000b" }
{ "_id" : "ab" }
>
  • 搜索以\x00开头,期望不返回任何文档,结果是返回所有文档
> db.test.find({_id: {"$regex" : "^\x00"}})
{ "_id" : "a" }
{ "_id" : "a\u0000" }
{ "_id" : "a\u0000b" }
{ "_id" : "ab" }
>
  • 搜索以\x00RandomString开头,期望不返回任何文档,结果是返回所有文档
> db.test.find({_id: {"$regex" : "^\x00RandomString"}})
{ "_id" : "a" }
{ "_id" : "a\u0000" }
{ "_id" : "a\u0000b" }
{ "_id" : "ab" }
>
  • 搜索\x00RandomString,期望不返回任何文档,结果是返回所有文档
> db.test.find({_id: {"$regex" : "\x00RandomString"}})
{ "_id" : "a" }
{ "_id" : "a\u0000" }
{ "_id" : "a\u0000b" }
{ "_id" : "ab" }
>

5. 正则表达式是可以正确匹配\x00

mongodb使用的正则表达式是Perl compatible regular expressions (i.e. “PCRE” ) version 8.36 with UTF-8 support(http://docs.mongodb.org/manual/reference/operator/query/regex/)

  • 使用pcretest测试

下载地址:http://sourceforge.net/projects/pcre/?source=typ_redirect

编译参数:./configure --enable-utf8 --enable-unicode-properties

pcre-8.36$ ./pcretest -C
PCRE version 8.36 2014-09-26
Compiled with
  8-bit support
  UTF-8 support
  Unicode properties support
  No just-in-time compiler support
  Newline sequence is LF
  \R matches all Unicode newlines
  Internal link size = 2
  POSIX malloc threshold = 10
  Parentheses nest limit = 250
  Default match limit = 10000000
  Default recursion depth limit = 10000000
  Match recursion uses stack

pcre-8.36$ ./pcretest -d
PCRE version 8.36 2014-09-26

  re> "^\x61\x00"
------------------------------------------------------------------
  0   8 Bra
  3     ^
  4     a\x00
  8   8 Ket
 11     End
------------------------------------------------------------------
Capturing subpattern count = 0
Options: anchored
No first char
No need char
data> a
No match
data> a\x00
 0: a\x00
data> a\x00b
 0: a\x00
data> ab
No match
data>
  • 使用mongodb shell测试
$ mongo
MongoDB shell version: 3.0.3
connecting to: test
> /^a\x00/.test("a")
false
> /^a\x00/.test("a\x00")
true
> /^a\x00/.test("a\x00b")
true
> /^a\x00/.test("ab")
false
>
  • 使用nodejs进行测试:
$ node
> var reg = new RegExp("^a\x00")
undefined
> reg.test("a")
false
> reg.test("a\x00")
true
> reg.test("a\x00b")
true
> reg.test("ab")
false
>

6. 确认mongodb shell正确将请求发送给mongodb

抓取这个请求的包

> db.test.find({_id: {"$regex" : "^a\x00b"}})
{ "_id" : "a" }
{ "_id" : "a\u0000" }
{ "_id" : "a\u0000b" }
{ "_id" : "ab" }
>

结果:

$ sudo tcpdump -i lo0 -X dst port 27017
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on lo0, link-type NULL (BSD loopback), capture size 65535 bytes
11:27:01.302450 IP localhost.64032 > localhost.27017: Flags [P.], seq 2309521406:2309521476, ack 2695537044, win 11950, options [nop,nop,TS val 1556359160 ecr 1556325546], length 70
    0x0000:  4500 007a 6867 4000 4006 0000 7f00 0001  E..zhg@.@.......
    0x0010:  7f00 0001 fa20 6989 89a8 7ffe a0aa a194  ......i.........
    0x0020:  8018 2eae fe6e 0000 0101 080a 5cc4 27f8  .....n......\.'.
    0x0030:  5cc3 a4aa 4600 0000 5600 0000 0000 0000  \...F...V.......
    0x0040:  d407 0000 0000 0000 7465 7374 2e74 6573  ........test.tes
    0x0050:  7400 0000 0000 0000 0000 2000 0000 035f  t.............._
    0x0060:  6964 0016 0000 0002 2472 6567 6578 0005  id......$regex..
    0x0070:  0000 005e 6100 6200 0000                 ...^a.b...
11:27:01.303661 IP localhost.64032 > localhost.27017: Flags [.], ack 105, win 11946, options [nop,nop,TS val 1556359161 ecr 1556359161], length 0
    0x0000:  4500 0034 9232 4000 4006 0000 7f00 0001  E..4.2@.@.......
    0x0010:  7f00 0001 fa20 6989 89a8 8044 a0aa a1fc  ......i....D....
    0x0020:  8010 2eaa fe28 0000 0101 080a 5cc4 27f9  .....(......\.'.
    0x0030:  5cc4 27f9                                \.'.
11:27:01.304142 IP localhost.64032 > localhost.27017: Flags [P.], seq 70:149, ack 105, win 11946, options [nop,nop,TS val 1556359161 ecr 1556359161], length 79
    0x0000:  4500 0083 f7af 4000 4006 0000 7f00 0001  E.....@.@.......
    0x0010:  7f00 0001 fa20 6989 89a8 8044 a0aa a1fc  ......i....D....
    0x0020:  8018 2eaa fe77 0000 0101 080a 5cc4 27f9  .....w......\.'.
    0x0030:  5cc4 27f9 4f00 0000 5700 0000 0000 0000  \.'.O...W.......
    0x0040:  d407 0000 0000 0000 7465 7374 2e24 636d  ........test.$cm
    0x0050:  6400 0000 0000 ffff ffff 2900 0000 0169  d.........)....i
    0x0060:  734d 6173 7465 7200 0000 0000 0000 f03f  sMaster........?
    0x0070:  0166 6f72 5368 656c 6c00 0000 0000 0000  .forShell.......
    0x0080:  f03f 00                                  .?.
11:27:01.304442 IP localhost.64032 > localhost.27017: Flags [.], ack 299, win 11940, options [nop,nop,TS val 1556359161 ecr 1556359161], length 0
    0x0000:  4500 0034 63bb 4000 4006 0000 7f00 0001  E..4c.@.@.......
    0x0010:  7f00 0001 fa20 6989 89a8 8093 a0aa a2be  ......i.........
    0x0020:  8010 2ea4 fe28 0000 0101 080a 5cc4 27f9  .....(......\.'.
    0x0030:  5cc4 27f9                                \.'.

0x0070行能看到6100 620x61a0x62b,中间的字符是\x00,确认mongodb shell正确将查询请求发送给了mongodb,mongodb处理有问题,返回了非预期的结果。

7. 简单分析

mongodb在正则表达式处理上面有问题,\x00这个字符有可能直接将整个正则表达式截断了,使用不完整的正则表达式进行匹配得到错误的结果。

根据bsonspec对正则表达式的定义:

regexp  ::= "\x0B" e_name cstring cstring
cstring ::= (byte*) "\x00"

正则表达式是由4部分组成:

  • 第一部分\x0B,一个标志位。
  • 第二部分e_name是正则表达式的名字。
  • 第三部分cstring是正则表达式字符串。
  • 第四部分cstring是正则表达式的标志位。

再看cstring的定义,就是一个字节数组,以\x00结束。官方也明确说cstring是不能包含\x00字符的:

Zero or more modified UTF-8 encoded characters followed by '\x00'. The (byte*) MUST NOT contain '\x00', hence it is not full UTF-8.

如果mongodb使用bson来存储正则表达式,看起来是天然有问题的,首先正则表达式是可以使用\x00字符,但是bson的正则表达式不允许使用\x00字符,如果使用了整个正则表达式会被截断。

如果正则表达式部分使用string而不是cstring存储就不会有问题,看一下string的定义:

string  ::= int32 (byte*) "\x00"

string的组成里面,第一部分是一个int32的数,表示整个字符串长度,因为长度明确,那么即使中间出现\x00也不会被截断。

这个问题终归到底看起来是bson的设计问题,正则表达式应该使用string存储而不是cstring存储

mongodb自身也没处理好,如果不允许使用 \x00,应该明确指出,在用户使用的时候需要报一个错,而不是返回错误的结果。

8. 风险

如果用户输入中包含\x00字符,有可能会取得非预期结果,有可能会获取整个collection里面的所有内容,这个还是需要重视的。测试会影响所有版本的mongodb。

9. 如何规避风险

在mongodb修复这个bug之前,可以采取几种措施:

  1. 对用户的输入严格检查,禁止用户使用\x00字符。
  2. 如果必须包含\x00字符,需要对mongodb返回的结果用标准正则表达式再匹配一次。

10. 其他

已经向mongodb官方报bug:https://jira.mongodb.org/browse/SERVER-18824,欢迎围观。

docker container修改时区

docker container默认是UTC时间的,如果要使用北京时间需要修改时区,有以下几种方式:

1. 使用dpkg-reconfigure命令

$ docker run -i -t ubuntu bash
root@1f8ccb4c3dc1:/# date
Wed Sep 10 16:02:38 UTC 2014
root@1f8ccb4c3dc1:/# dpkg-reconfigure tzdata

Current default time zone: 'Asia/Shanghai'
Local time is now:      Thu Sep 11 00:02:50 CST 2014.
Universal Time is now:  Wed Sep 10 16:02:50 UTC 2014.

root@1f8ccb4c3dc1:/# date
Thu Sep 11 00:02:52 CST 2014
root@1f8ccb4c3dc1:/# 

2. 挂载使用系统/etc/localtime

$ docker run -i -t -v /etc/localtime:/etc/localtime ubuntu bash
root@6213cd50d722:/# date
Thu Sep 11 00:08:56 CST 2014
root@6213cd50d722:/#

旧版本的dockere可能文件是/etc/localtime:ro,需要修改挂载方式为:-v /etc/localtime:/etc/localtime:ro

3. 直接覆盖/etc/localtime

$ docker run -i -t ubuntu bash
root@d4b926fb2c75:/# date
Wed Sep 10 16:11:15 UTC 2014
root@d4b926fb2c75:/# apt-get install -y wget > /dev/null 2>&1
root@d4b926fb2c75:/# wget http://ma6174.u.qiniudn.com/localtime -O /etc/localtime -o /dev/null
root@d4b926fb2c75:/# date
Thu Sep 11 00:13:05 CST 2014
root@d4b926fb2c75:/#

snappy压缩

近期测试了一下snappy压缩算法,总体感觉是压缩、解压速度非常快,应用场景也很多。

官网: https://code.google.com/p/snappy/

简介

Snappy is a compression/decompression library. It does not aim for maximum compression, or compatibility with any other compression library; instead, it aims for very high speeds and reasonable compression. For instance, compared to the fastest mode of zlib, Snappy is an order of magnitude faster for most inputs, but the resulting compressed files are anywhere from 20% to 100% bigger. On a single core of a Core i7 processor in 64-bit mode, Snappy compresses at about 250 MB/sec or more and decompresses at about 500 MB/sec or more.

基本意思就是说snappy压缩算法不追求压缩比,而是追求压缩和解压速度。在i7 64位CPU上压缩速度在250MB/s,解压速度达到500MB/s

snappy VS gzip

  • 测试环境:
    • MacBook Pro (Retina, 13-inch, Mid 2014)
    • 2.6 GHz Intel Core i5
    • 8 GB 1600 MHz DDR3
  • 测试文件(不同文件类型可能结果稍有偏差):
    • VirtualBox ubuntu vdi 镜像文件,大小 3.8G
  • 测试工具:
    • gzip: Apple gzip 242
    • snappy: snappy
测试结果:
压缩速度 解压速度 压缩比
snappy 146.6M/s 257.7M/s 54.8%
gzip 23.8M/s 233.1M/s 69.2%
详细测试中间数据
  • 原始文件大小统计:
$ time cat ubuntu_ele.vdi | wc -c
 4062183424
cat ubuntu_ele.vdi  0.08s user 3.61s system 15% cpu 23.522 total
wc -c  21.20s user 1.00s system 94% cpu 23.521 total
  • 压缩对比:
$ time cat ubuntu_ele.vdi | snappy | wc -c
 1837598172
cat ubuntu_ele.vdi  0.09s user 3.18s system 12% cpu 27.215 total
snappy  25.45s user 0.98s system 97% cpu 27.217 total
wc -c  13.59s user 0.91s system 53% cpu 27.216 total

$ time cat ubuntu_ele.vdi | gzip | wc -c
 1252402891
cat ubuntu_ele.vdi  0.08s user 2.63s system 1% cpu 2:44.34 total
gzip  161.52s user 1.11s system 98% cpu 2:44.34 total
wc -c  12.66s user 0.30s system 7% cpu 2:44.34 total
  • 解压对比:
$ time cat ubuntu_ele.vdi | snappy | snappy -d | wc -c
 4062183424
cat ubuntu_ele.vdi  0.09s user 3.12s system 9% cpu 33.553 total
snappy  28.39s user 1.31s system 88% cpu 33.552 total
snappy -d  13.36s user 1.67s system 44% cpu 33.552 total
wc -c  24.09s user 1.03s system 74% cpu 33.553 total

$ time cat ubuntu_ele.vdi | gzip | gzip -d | wc -c
 4062183424
cat ubuntu_ele.vdi  0.08s user 2.70s system 1% cpu 2:44.92 total
gzip  161.62s user 1.15s system 98% cpu 2:44.92 total
gzip -d  15.63s user 0.99s system 10% cpu 2:44.92 total
wc -c  24.36s user 0.71s system 15% cpu 2:44.92 total

应用场景

这些都在用snappy进行压缩了:

MongoDB
Cassandra
Couchbase
Hadoop
LessFS
LevelDB (which is in turn used by Google Chrome)
Rocksdb
Lucene
VoltDB

在Google内部也大量被使用

Snappy is widely used inside Google, in everything from BigTable and MapReduce to our internal RPC systems. (Snappy has previously been referred to as “Zippy” in some presentations and the likes.)

总结

如果对压缩解压速度要求比较高,并且压缩比一般可以接受的话,snappy是一种比较好的选择。永久存储(日志等)或者实时传输(rpc等)都是比较好的使用场景。

为了方便使用,我写了一个类似gzip的一个工具snappy,可以随时对文件进行压缩,也可以通过管道对流实时压缩传输,项目主页: https://github.com/ma6174/snappy

Go按行读取数据的坑

首先从一个日志分析的Go程序说起,基本功能就是一行一行读取数据并处理。代码大体是这样的:

func main() {
    scanner := bufio.NewScanner(os.Stdin)
    for scanner.Scan() {
        line := scanner.Text()
        if err := deal(line); err != nil {
            log.Println(line)
        }
    }
}

因为数据是用hadoop直接cat出来的,通过管道传输到日志处理程序,所以这里输入源是stdin

实际处理的时候发现一个比较奇怪的现象,每个日志文件的总行数差别不大,但是有的文件处理时间明显比其他短,并且还会能看到这样的日志信息:cat: Unable to write to output stream.。这个日志不是Go里面的,确认是hadoop报了这个错误,起初以为是hadoop在某些情况下可能会出现这个错误,因为出现这个错误就意味着某些文件没有处理完程序就退出了。这个是不能容忍的。于是便向运维那边报bug。

运维那边承认这个错误是hadoop报的,但是出现这个错误一般是因为stream断了,比如如果有head操作就会出现这个错误:

$ hdfs dfs -cat  0.log | head -1
*****log contents*****
cat: Unable to write to output stream.

为了证明是后面处理程序的问题而不是hadoop的问题,运维那边做了这样一个操作:

  1. 将hadoop中的文件下载到本地
  2. 直接用UNIX的cat命令将数据重定向到处理程序
  3. 查看cat和处理程序的返回值
$ cat 0.log | go_program
$ echo ${PIPESTATUS[@]}
141 0

cat非0,后面的程序返回是0,UNIX基本程序一般是不会有问题的,看来是后面的Go程序有问题。开始检查验证。

首先要确认的问题有两个:

  1. Go程序真的没有处理完数据就退出了吗?
  2. 如果异常退出了那么是在处理哪一行的时候退出了?

查询这个问题也是比较容易的,将处理过的每一行日志输出并打印行号,这样就知道处理到哪一行了,也就能知道在哪一行出错。

修改程序后重新执行,确实是Go程序提前退出了,并且没处理的下一行唯一的特殊之处是这一行超级长。难道Go不能处理很长的一行?这看起来不可能发生,但是还要验证一下。

首先是重新review代码,按理说程序出错之后会有错误日志的,为啥Go程序不但没报错还正常退出了?首先是review数据处理代码,没发现问题,然后就开始怀疑是bufio.Scanner的问题,重新看了一下Go文档,官网有一个example是这么写的:

scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
    fmt.Println(scanner.Text()) // Println will add back the final '\n'
}
if err := scanner.Err(); err != nil {
    fmt.Fprintln(os.Stderr, "reading standard input:", err)
}

原来scanner还有一个Err()方法,官方的说法是Err returns the first non-EOF error that was encountered by the Scanner.,也就是说如果scanner.Scan()如果出错的话错误信息是要通过Err()方法才能得到的,我的go程序将这个Err忽略了,出错后退出for循环就直接退出程序了,到底是有什么错误?代码补充完整之后看到这样的错误:bufio.Scanner: token too long

到目前能确认的确实是Go程序问题,在读数据的时候出现错误,上面的错误是什么意思?翻Go代码。

~/go$ grep -n ` find -name "*.go"` -e "bufio.Scanner: token too long"
./src/pkg/bufio/scan.go:61:     ErrTooLong         = errors.New("bufio.Scanner: token too long")

确实有这个错误,继续看scan.go,看看在哪报ErrTooLong这个错误:

~/go$ grep -n ` find -name "*.go"` -e "ErrTooLong"
./src/pkg/bufio/scan.go:61:     ErrTooLong         = errors.New("bufio.Scanner: token too long")
./src/pkg/bufio/scan.go:147:                            s.setErr(ErrTooLong)
./src/pkg/bufio/scan_test.go:244:       if err != ErrTooLong {
./src/pkg/bufio/scan_test.go:245:               t.Fatalf("expected ErrTooLong; got %s", err)

scan.go 147行出现的错误,看具体代码:

   144                  // Is the buffer full? If so, resize.
   145                  if s.end == len(s.buf) {
   146                          if len(s.buf) >= s.maxTokenSize {
   147                                  s.setErr(ErrTooLong)
   148                                  return false
   149                          }
   150                          newSize := len(s.buf) * 2
   151                          if newSize > s.maxTokenSize {
   152                                  newSize = s.maxTokenSize
   153                          }
   154                          newBuf := make([]byte, newSize)
   155                          copy(newBuf, s.buf[s.start:s.end])
   156                          s.buf = newBuf
   157                          s.end -= s.start
   158                          s.start = 0
   159                          continue
   160                  }

看了一下Scanner模块的代码,原来是这样的:Scanner在初始化的时候有设置一个maxTokenSize,这个值默认是MaxScanTokenSize = 64 * 1024 ,当一行的长度大于64*1024即65536之后,就会出现ErrTooLong错误。这样就能解释之前为什么长行处理报错的问题了。

毕竟日志一行可能不止65536这么长,问题还是要解决的,有两种方案:

  1. 调大maxTokenSize的值
  2. 换用其他的函数

对于方案1,仔细看了一下Scnner,并没有发现有接口可以修改这个值,除非修改Go源码并重新编译Go,这个太折腾。在官方文档里面发现这样一段话:

Scanning stops unrecoverably at EOF, the first I/O error, or a token too large to fit in the buffer. When a scan stops, the reader may have advanced arbitrarily far past the last token. Programs that need more control over error handling or large tokens, or must run sequential scans on a reader, should use bufio.Reader instead.

它告诉我们如果token太大(行太长)的时候,要使用bufio.Reader。这里是第一个坑,怪当初用的时候没仔细看文档,没仔细研读Go源码。从这里我们可以得到一个教训: 除非能确定行长度不超过65536,否则不要使用bufio.Scanner!

那接下来老老实实用bufio.Reader吧。那bufio.Reader有没有类似bufio.Scanner的问题呢?

看了一下bufio.Reader代码,这个也是有缓冲区大小限制的,并且默认缓冲区大小是4096,还好有一个函数NewReaderSize可以调整这个缓冲区大小。

还是之前的需求,如何按行去读取数据呢?bufio.Reader提供了一个ReadLine()函数,文档上是这么说的:

ReadLine is a low-level line-reading primitive. Most callers should use ReadBytes('\n') or ReadString('\n') instead or use a Scanner.

意思是这个函数比较底层,建议使用ReadBytes或ReadString或者Scanner。继续看文档说明:

ReadLine tries to return a single line, not including the end-of-line bytes. If the line was too long for the buffer then isPrefix is set and the beginning of the line is returned. The rest of the line will be returned from future calls. isPrefix will be false when returning the last fragment of the line. The returned buffer is only valid until the next call to ReadLine. ReadLine either returns a non-nil line or it returns an error, never both.

从这里我们能看出来设置缓冲区的作用了,ReadLine会尽量去读取并返回完整的一行,但是如果行太长缓冲区满了的话,就不会返回完整的一行而是返回缓冲区里面的内容,并且会设置isPrefix为true。这时候需要继续调用ReadLine直到将完整一行读完。然后外层调用程序需要将这些块拼起来才能组成完整的行。不仅要处理isPrefix还要处理前缀,麻烦!除非我们主动设置一个非常大的缓冲,但是前提是你必须知道最长行的长度,在大多数情况下这个是无法预先知道的。怪不得建议我们使用ReadBytes或ReadString或者Scanner。

之前讨论了,Scanner在行太长的时候是有问题的,ReadBytes和ReadString原理上是一样的,这里我们以ReadString为例看看这个函数。ReadString原理很简单,使用也很方便,基本用法是指定一个分隔符,比如我们如果读取一行的话就指定分隔符为\n,这样ReadString就去不断去读数据,直到发现分隔符\n或者出错为止。官网是这么说的:

ReadString reads until the first occurrence of delim in the input, returning a string containing the data up to and including the delimiter. If ReadString encounters an error before finding a delimiter, it returns the data read before the error and the error itself (often io.EOF). ReadString returns err != nil if and only if the returned data does not end in delim. For simple uses, a Scanner may be more convenient.

(ps: 这里又在推荐Scanner...)

ReadString有没有缓冲区大小限制呢?这个其实也还是有的,不过,它在代码里面就将这个缓冲区的问题处理好了,也就是说能保证返回的一行肯定是完整的。最起码用起来比ReadLine函数方便。

那么用ReadString去按行读取有没有坑呢?答案是肯定的。

package main

import (
    "bufio"
    "fmt"
    "io"
    "strings"
)

func main() {
    s := "a\nb\nc"
    reader := bufio.NewReader(strings.NewReader(s))
    for {
        line, err := reader.ReadString('\n')
        if err != nil {
            if err == io.EOF {
                break
            }
            panic(err)
        }
        fmt.Printf("%#v\n", line)
    }
}

上面这段代码的输出是:

"a\n"
"b\n"

为什么c没有输出?这里算是一个坑。之前讨论过,按行读取的话ReadString函数需要以\n作为分割,上面那种特殊情况当数据末尾没有\n的时候,直到EOF还没有分隔符\n,这时候返回EOF错误,但是line里面还是有数据的,如果不处理的话就会漏掉最后一行。简单修改一下:

package main

import (
    "bufio"
    "fmt"
    "io"
    "strings"
)

func main() {
    s := "a\nb\nc"
    reader := bufio.NewReader(strings.NewReader(s))
    for {
        line, err := reader.ReadString('\n')
        if err != nil {
            if err == io.EOF {
                fmt.Printf("%#v\n", line)
                break
            }
            panic(err)
        }
        fmt.Printf("%#v\n", line)
    }
}

这样执行会输出:

"a\n"
"b\n"
"c"

这样处理的时候也要当心,因为最后一行后面没有\n

相比之下,python处理要简单和清晰很多,有生成器,直接for循环遍历即可:

import StringIO

s = "a\nb\nc"

sio = StringIO.StringIO(s)

for line in sio:
    print line,

mongodb灾难修复

数据库采用一个master两个slave方式部署,分别设置priority为3,2,1,当master数据库受到毁灭性的破坏(比如文件误删,系统挂掉)之后,master已经完全不可用,此时两个slave会进行选举,priority为2的slave会被选为master。此时数据库处在一个危险状态,如果再有任何一个数据库挂掉那么整个集群将变只读。

如何修复?要分几种情况:

  1. master所在机器在一段时间能完全恢复,并且预期恢复的时间远小于oplog记录时间,那么等机器恢复后只要重新启动数据库就可以了,数据会自动同步,数据同步完成会被重新选举为master。
  2. 如果机器有硬件问题,即使恢复了最好将数据库移走,或者最起码要将master移走。
  3. 如果是人为误操作破坏了数据库文件,那么只需要在原来机器上将数据库恢复即可。如果oplog记录了所有操作,那么只要启动一个新的数据库即可,数据库加入集群后会自动同步数据。如果不幸oplog不全,那么只能手动恢复。恢复方案基本就是停一台slave,拷贝数据库文件到别的机器,再将数据库重新启动。但是上面提到,数据库现在不允许停机,因为只有两个节点,再停掉一个后数据库将无法选出master。这时候有一种解决方案是给集群增加两个选举节点,这时候整个集群机器数为5个,现在的状态是挂掉1个,如果再停掉一台进行数据拷贝,那么集群中还有3台数据库,大于总数的一半,此时集群也是能正常工作的。

数据库当发生重新选举的时候,会导致数据库短时无master而不能正常提供服务。根据经验,在集群中增加一个节点是安全的,不会触发重新选举。但是删除节点会导致重新选举。不管是添加还是删除节点,都会或多或少的导致数据库出现慢请求。还有一个问题是当需要移除一个节点的时候,最好先将该节点停掉再移除,因为测试发现不停机直接移除导致数据库出现不可用和慢请求的个数远比停机操作多。

从上面的分析看,网上有人为了节省,只设置了一个master和一个slave,再加一个选举节点,当某个非选举节点挂掉之后有很大可能性是无法恢复的。所以集群中至少应该设置3个数据节点。

Go语言闭包

A Tour of Go看到这样一段代码:

package main

import "fmt"

func adder() func(int) int {
    sum := 0
    return func(x int) int {
        sum += x
        return sum
    }
}

func main() {
    pos, neg := adder(), adder()
    for i := 0; i < 10; i++ {
        fmt.Println(
            pos(i),
            neg(-2*i),
        )
    }
}

上述代码执行结果:

0 0
1 -2
3 -6
6 -12
10 -20
15 -30
21 -42
28 -56
36 -72
45 -90

官方是这么解释的:

Go 函数可以是闭包的。闭包是一个函数值,它来自函数体的外部的变量引用。函数可以对这个引用值进行访问和赋值;换句话说这个函数被“绑定”在这个变量上。例如,函数 adder 返回一个闭包。每个闭包都被绑定到其各自的 sum 变量上。

记得之前在面试某团的时候面试官一直问我闭包的定义,当时只知道这个东西怎么定义,如何使用,概念没答上来,后来特意搜了一下,wikipedia是这么说的:

在计算机科学中,闭包(Closure)是词法闭包(Lexical Closure)的简称,是引用了自由变量的函数。这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外。所以,有另一种说法认为闭包是由函数和与其相关的引用环境组合而成的实体。闭包在运行时可以有多个实例,不同的引用环境和相同的函数组合可以产生不同的实例。

在一些语言中,在函数中可以(嵌套)定义另一个函数时,如果内部的函数引用了外部的函数的变量,则可能产生闭包。运行时,一旦外部的 函数被执行,一个闭包就形成了,闭包中包含了内部函数的代码,以及所需外部函数中的变量的引用。其中所引用的变量称作上值(upvalue)。

闭包可以用来在一个函数与一组“私有”变量之间建立关联关系。在给定函数被多次调用的过程中,这些私有变量能够保持其持久性。变量的作用域仅限于包含它们的函数,因此无法从其它程序代码部分进行访问。不过,变量的生存期是可以很长,在一次函数调用期间所建立所生成的值在下次函数调用时仍然存在。正因为这一特点,闭包可以用来完成信息隐藏,并进而应用于需要状态表达的某些编程范型中。

有这样一个需求:我要测试一个服务的平均响应时间、最大响应时间,每100次输出一次结果。
一般我们会这样写:

package main

import (
    "fmt"
    "math/rand"
    "time"
)

func testService() {
    time.Sleep(time.Duration(rand.Intn(10)+1) * time.Millisecond)
}

func main() {
    rand.Seed(time.Now().UnixNano())
    timeTotal := 0.0
    timeMax := 0.0
    timeCount := 0
    printCount := 100
    for i := 0; i < 1000; i++ {
        timeStart := time.Now()
        testService()
        timeCost := time.Since(timeStart).Seconds()
        timeCount += 1
        timeTotal += timeCost
        if timeCost > timeMax {
            timeMax = timeCost
        }
        if timeCount%printCount == 0 {
            fmt.Printf("%d runs ave time: %.2fms, max time %.2fms\n", printCount, timeTotal/float64(timeCount)*1000, timeMax*1000)
            timeMax = 0
            timeCount = 0
            timeTotal = 0
        }
    }
}

执行结果示例

$ go run count_time.go
100 runs ave time: 5.26ms, max time 10.18ms
100 runs ave time: 6.03ms, max time 10.19ms
100 runs ave time: 5.75ms, max time 10.19ms
100 runs ave time: 6.06ms, max time 10.31ms
100 runs ave time: 5.62ms, max time 10.19ms
100 runs ave time: 5.52ms, max time 10.20ms
100 runs ave time: 5.82ms, max time 10.19ms
100 runs ave time: 5.61ms, max time 10.19ms
100 runs ave time: 5.41ms, max time 10.19ms
100 runs ave time: 6.24ms, max time 10.19ms

上面的代码在功能方面是没有问题的,但是服务和统计代码混合到一块,看起来比较乱。程序也不方便复用,如果有其他类似的统计又要再写一次,比较麻烦。

看看用闭包如何实现类似的功能:

package main

import (
    "fmt"
    "math/rand"
    "time"
)

func testService() {
    time.Sleep(time.Duration(rand.Intn(10)+1) * time.Millisecond)
}

func NewTimeCount(printCount int) func(timeCost float64) {
    rand.Seed(time.Now().UnixNano())
    timeTotal := 0.0
    timeMax := 0.0
    timeCount := 0
    return func(timeCost float64) {
        timeCount += 1
        timeTotal += timeCost
        if timeMax < timeCost {
            timeMax = timeCost
        }
        if timeCount%printCount == 0 {
            fmt.Printf("%d runs ave time: %.2fms, max time %.2fms\n", printCount, timeTotal/float64(timeCount)*1000, timeMax*1000)
            timeMax = 0
            timeCount = 0
            timeTotal = 0
        }
    }
}

func main() {
    timeCount := NewTimeCount(100)
    for i := 0; i < 1000; i++ {
        timeStart := time.Now()
        testService()
        timeCost := time.Since(timeStart).Seconds()
        timeCount(timeCost)
    }
}

这里我们定义了一个NewTimeCount函数,这个函数的参数是每隔printCount输出一次统计结果。函数内部包含了timeTotaltimeMaxtimeCount三个变量,在函数内部又定义了一个匿名函数来做我们的统计工作,我们需要每次将timeCost参数传递给这个匿名函数,执行匿名函数的时候,计数器自动加一,时间总数,最大时间自动自动更新,到我们设定的timeCount之后自动输出并清零。timeTotaltimeMaxtimeCount这三个变量在NewTimeCount之外是不可见的,可以认为是匿名函数的全局变量。

再看一下main函数,比之前清晰了好多,这里我们是每100次输出一次结果,我们可以通过NewTimeCount来生成多个计数器来做统计,代码可重用行和可读性都比之前的代码要好很多。

这里还有一个问题是要计算TimeCost,对首次使用的人来说可能不不清楚如何计算。如果程序再简化一些,将计算TimeCost也自动化的话就方便多了。可以这样做:

package main

import (
    "fmt"
    "math/rand"
    "time"
)

func testService() {
    time.Sleep(time.Duration(rand.Intn(10)+1) * time.Millisecond)
}

func NewTimeCount(printCount int) (clockStart, clockStop func()) {
    rand.Seed(time.Now().UnixNano())
    var (
        timeTotal float64
        timeMax   float64
        timeCount int
        timeStart time.Time
    )
    clockStart = func() {
        timeStart = time.Now()
    }
    clockStop = func() {
        timeCost := time.Since(timeStart).Seconds()
        timeCount += 1
        timeTotal += timeCost
        if timeMax < timeCost {
            timeMax = timeCost
        }
        if timeCount%printCount == 0 {
            fmt.Printf("%d runs ave time: %.2fms, max time %.2fms\n", printCount, timeTotal/float64(timeCount)*1000, timeMax*1000)
            timeMax = 0
            timeCount = 0
            timeTotal = 0
        }
    }
    return
}

func main() {
    clockStart, clockStop := NewTimeCount(100)
    for i := 0; i < 1000; i++ {
        clockStart()
        testService()
        clockStop()
    }
}

这次我们修改了NewTimeCount函数,让其返回两个函数,一个函数用来指定程序的起始时间,另一个来指定终止时间并统计和输入。main函数得到更进一步简化。

如果将testService作为参数传入的话代码可以更进一步简化:

package main

import (
    "fmt"
    "math/rand"
    "time"
)

func testService() {
    time.Sleep(time.Duration(rand.Intn(10)+1) * time.Millisecond)
}

func NewTimeCount(printCount int) func(f func()) {
    rand.Seed(time.Now().UnixNano())
    timeTotal := 0.0
    timeMax := 0.0
    timeCount := 0
    return func(f func()) {
        timeStart := time.Now()
        f()
        timeCost := time.Since(timeStart).Seconds()
        timeCount += 1
        timeTotal += timeCost
        if timeMax < timeCost {
            timeMax = timeCost
        }
        if timeCount%printCount == 0 {
            fmt.Printf("%d runs ave time: %.2fms, max time %.2fms\n", printCount, timeTotal/float64(timeCount)*1000, timeMax*1000)
            timeMax = 0
            timeCount = 0
            timeTotal = 0
        }
    }
}

func main() {
    timeCount := NewTimeCount(100)
    for i := 0; i < 1000; i++ {
        timeCount(testService)
    }
}

总的来说,闭包这个功能还是比较实用的。

docker大杂烩

docker 启动

使用lxc方式启动可以方便进行管理控制

docker -d -e lxc -dns 8.8.8.8 -dns 8.8.4.4

使用Dockfile生成镜像

docker build -t tag_name --rm=true .

将container打tag

docker container_id tag_name

docker 启动一个实例

下面的命令将container的8080端口映射为主机的80端口,指定container_name,将主机目录挂载到container里面,启动时执行bash命令。

docker run -t -i -p 80:8080 --name container_name -v host_addr:container_addr  image_name /bin/bash

container管理

docker ps
ps -ef | grep 5531741b0ff0
sudo lxc-attach -n 5531741b0ff05a46c793b24bfaa2b33dba66cad29fe9926641fee98b6b3e24df

image备份和恢复

docker save test_2 | gzip > image.tar.gz
cat image.tar.gz | gzip -d | sudo docker load
cat image.tar.gz | gzip -d | sudo docker import - test_import

docker registery相关命令

docker tag ubuntu:12.04 10.0.0.6:5000/ubuntu
docker commit 797d1da068ce 10.0.0.6:5000/busybox:version2 -a ma6174 -m "rm aaaa"
docker push 10.0.0.6:5000/ubuntu
docker pull 10.0.0.6:5000/ubuntu
docker history 10.0.0.6:5000/busybox

宿主机器内核

内核版本一定要在3.8以上,不然容易出一些莫名其妙的问题,还有被提权的风险。

使用 go_dep_search 分析项目里面的包依赖

开发过程中经常会遇到这样的问题:我改了一个包,哪些服务受影响?我们期望是把所有相关的服务都升级,避免出现遗漏。

对于业务不复杂或者比较偏上层的包,大部分直接在代码库里面grep一下就知道了。如果的偏底层的包,业务不一定会直接引用,这种直接分析的话就比较复杂了。

之前有种比较麻烦但是也相对有效的方法。我们知道Go的编译是有缓存的,如果代码有变更的话就会触发包重新编译,通过 go build -v可以把重新编译的包的名字输出出来,当然也包含最终的服务程序。实际操作的时候需要避免ide编译影响。

后来研究了一下,实际有更好的办法来实现。go list命令有个-json的参数,会自动分析各个包的依赖关系,以json的格式输出出来,我们以官方库里面的encoding/base64包为例:

点击展开查看...

# go list -json encoding/base64
{
	"Dir": "/usr/local/go/src/encoding/base64",
	"ImportPath": "encoding/base64",
	"Name": "base64",
	"Doc": "Package base64 implements base64 encoding as specified by RFC 4648.",
	"Target": "/usr/local/go/pkg/linux_amd64/encoding/base64.a",
	"Root": "/usr/local/go",
	"Match": [
		"encoding/base64"
	],
	"Goroot": true,
	"Standard": true,
	"GoFiles": [
		"base64.go"
	],
	"Imports": [
		"encoding/binary",
		"io",
		"strconv"
	],
	"Deps": [
		"encoding/binary",
		"errors",
		"internal/bytealg",
		"internal/cpu",
		"internal/race",
		"io",
		"math",
		"math/bits",
		"reflect",
		"runtime",
		"runtime/internal/atomic",
		"runtime/internal/math",
		"runtime/internal/sys",
		"strconv",
		"sync",
		"sync/atomic",
		"unicode",
		"unicode/utf8",
		"unsafe"
	],
	"TestGoFiles": [
		"base64_test.go"
	],
	"TestImports": [
		"bytes",
		"errors",
		"fmt",
		"io",
		"io/ioutil",
		"reflect",
		"strings",
		"testing",
		"time"
	],
	"XTestGoFiles": [
		"example_test.go"
	],
	"XTestImports": [
		"encoding/base64",
		"fmt",
		"os"
	]
}

其中有几个字段需要关注一下:

  • ImportPath:在执行包引用时的名字 encoding/base64
  • Name:包的名字 base64
  • Imports:这个包直接引用的包:"encoding/binary", "io", "strconv"
  • Deps:这个包所有递归依赖的包

有了上面的Deps信息之后,开头提到的依赖分析问题就比较简单了。只要在Deps里面有我们修改的包就可以了,如果要找是哪个服务的话,需要看Namemain的包。另外go list -json ./... 就可以分析当前项目里面的所有包。

go list -json ./... 输出的信息还是挺多的,为了方便分析,写了个小工具 go_dep_search (https://github.com/ma6174/go_dep_search)

比如找出官方库里面所有依赖net/http或者encoding/json的可执行程序(ImportPath那一列):

/usr/local/go/src# go list -json ./... | go_dep_search -main net/http encoding/json
Name	->	ImportPath	->	dep_package
main	->	cmd/compile	->	encoding/json
main	->	cmd/cover	->	encoding/json
main	->	cmd/dist	->	encoding/json
main	->	cmd/go	->	net/http
main	->	cmd/go	->	encoding/json
main	->	cmd/link	->	encoding/json
main	->	cmd/pprof	->	net/http
main	->	cmd/pprof	->	encoding/json
main	->	cmd/test2json	->	encoding/json
main	->	cmd/trace	->	net/http
main	->	cmd/trace	->	encoding/json
main	->	cmd/vet	->	encoding/json

希望对大家有帮助。

再玩IFTTT

一直觉得IFTTT是一个很伟大的产品,它将互联网的世界串了起来,用 if (this) then {that} 这样简单又强大的语句对世界编程,我们的生活将更加丰富多彩。

最初的IFTTT Channel比较少,我们能做的比较有限,今天登陆IFTTT发现Channel数竟然达到了121个之多,有几个是非常好玩的。

最明显的是增加了移动客户端,使用安卓或IOS安装IFTTT客户端之后,我们不仅能在客户端上创建和修改recipes,客户端也成了一个trigger或者Action。比如对安卓客户端来说,我们可以使用以下触发器:

  1. wifi的连接和断开,我们可以指定当连接某个wifi,比如公司wifi或家庭wifi的时候,做一些事情,比如进入公司wifi自动将手机调成静音,断开家庭的wifi的时候自动将电源关闭等。
  2. 位置信息,通过定位,当你进入或离开某个区域的时候自动做一些事情。
  3. 通话记录和短信,未接电话,收到短信等都可以触发一些其他事情,比如自动发邮件提醒有未接电话,自动备份所有短信等。
  4. 拍照或截图之后自动分享或保存

移动设备可以触发的Action

  1. 设备提醒,包括响铃,震动等
  2. 修改系统设置,比如调节音量,切换壁纸等。
  3. 发送短信,拨打电话

智能硬件最近很火,IFTTT上也有一些关于智能硬件的,比如说谷歌眼镜,安卓智能手表(手环),智能开关等有这些硬件的支持,可以做更多有趣的事情,比如下班回家离开公司自动打开家里的电源开始煮饭,比如晚上十点自动打开热水器烧水洗澡,比如晚上回家手机连接家里wifi之后自动打开客厅的灯,有智能开关,一切变得简单。

关于提醒的Channel更丰富了,比如pushover,pushbullet,instapush等,有了这些专业消息推送工具,可以让信息提醒更加简单和实时。比如gmail收到某人的一封重要邮件自动推送到手机或其他客户端,比如社交网站有动态自动提醒,比如当天气突变的时候发送提醒等。

关于文章、RSS、阅读、笔记等Channel也是非常高效和实用。比如我们可以用feedly来订阅感兴趣的文章,if (feedly有更新) then (自动推送到pocket),然后我们就可以在pocket上阅读文章,if (i started an article) then (save to evernote)还可以设置一个触发器当我感觉某篇文章比较好加了星标之后自动保存到印象笔记进行备份,当然也可以再自动分享这篇文章到社交网络,这一切都是自动的。

gmail这个channel之后,我们可以使用邮件来扩展更丰富的内容。比如在网上淘到一本电子书,我们可以保存到dropbox里面,if (dropbox新增电子书) then (自动使用邮箱将附件发送到kindle邮箱),随后我们就可以用kindle来阅读。

一个比较有趣的channel是github,目前提供了这样几个trigger

  1. issue创建和关闭
  2. issue指派给你
  3. 某个用户或组织创建了新的项目
  4. 某个项目有pull request

有了上面的trigger我们就可以做写提醒啥的。此外还提供了一个Action: 创建一个新的issue,测试了一下貌似不太好用。

IFTTT上还有一个“神器”是Yo,这个大家可能不太熟悉,国内的仿造品是呵呵,功能超级简单,官方是这么说的:

世界上最简单、最高效的通信工具。

Yo是一款单触式、零字符的通信工具。
Yo就是一切,它完全取决于你,取决于收信人,取决于你们有没有时间说Yo。

想说“早上好?”Yo一下。
想说“宝贝我想你?”-Yo一下
“我开完会了,来我办公室”-Yo一下
“你起床了吗?”-Yo一下。
无穷无尽的可能性。

我们不要你的电子邮件、Facebook,没有搜索,什么都没有。只有Yo。

打开应用程序,轻按Yo就好了。

就是那么简单。Yo。"

IFTTT增加了一个触发器,当你“Yo” IFTTT的时候去执行某个操作,比如Yo一下打开或关闭电灯,Yo一下自动给自己打一个电话(可能在想逃离某个场合的时候比较有用),比如Yo一下将实时的天气发过来。当然还有更多可能性的,Yo一下,就这么简单。

IFTTT还有好多好玩的Recipes可以去探索:https://ifttt.com/recipes

IFTTT还可以变得更加强大,目前仅支持if this then that这种模式,关于“this”,可以支持 andor,也就是说多个条件同时成立的时候去触发某个Action,可以支持not,当某个条件不成立的时候触发某个Action,可以支持else,这样trigger可以更加灵活。同样的,某个trigger可以支持多个Action,当某个事情发生的时候,自动触发去做一系列的事情。此外,目前的channel是比较固定的,如果能开放api,让开发者自己去实现trigger,自己去定义Action,IFTTT将会变得更加强大和有趣。

一致性哈希模型如何优雅扩容

如果不清楚一致性哈希的原理,先移步这里: http://blog.codinglabs.org/articles/consistent-hashing.html

项目初期,为了节省成本,可能只用3台机器做缓存,通过一致性哈希方式将请求分摊到3台机器上面。后面随着业务量扩大,3台机器遇到性能瓶颈就需要扩容。假设我们扩容一倍到6台机器,如果直接将三个新节点加入一致性哈希环里面,就意味着一半的缓存会失效,这无疑会给后端服务造成巨大冲击。为了减小冲击,可以每次新增一个节点,第一次有25%的缓存失效,为了减小对后端的冲击可能需要在凌晨操作,过一段时间,等缓存新节点里面有足够多数据之后,再增加第二个节点,理想情况下第二次添加节点只有20%的缓存失效。同样的方式再增加第三个节点,16.7%的缓存失效。后面再新增节点失效的缓存会逐渐减少。

回顾一下上面的的扩容方式,有几个地方不太优雅:

  1. 扩容导致缓存失效。如果服务器压力已经很大,25%的缓存失效意味着对后端服务会有较大冲击。
  2. 每次新增一个节点,不能立即增加下一个节点,需要等缓存填充,这个时间不可控,有可能导致扩容的周期非常长,增加运维人员负担。

有没有什么方式可能一次性扩容多个节点,并且缓存不失效呢?这个想法很美好,其实也是可以做到的。

对应原先三台机器A,B,C组成一个哈希环H1,三台新机器D,E,F和A,B,C组成一个新的哈希环H2,我们需要实现一个特殊的读逻辑:先去H2读,当H2不存在的时候去H1读,两个都不存在意味着缓存不存在。写操作全部在H2,一段时间后当D,E,F三台机器缓存被填充之后移除哈希环H1,只保留H2,也就是我们希望的扩容的最终结果。

再来分析一下上面扩容方式的优缺点

  • 优点: 一次性扩容,可扩容任意多机器数,扩容期间缓存不失效,对后端无冲击。
  • 缺点:读逻辑变复杂,3台扩6台之后,有一半的缓存需要读两次才能读到,并且所有缓存不存在的情况都需要读两次才能最终确认。

大部分情况下,读缓存的时间远小于读后端的时间,可以推测,两次读缓存的时间也是远小于读后端的时间的,这种情况下读两次带来的额外开销可以忽略。

新的扩容方式还有优化的空间,当在H2读不到的时候,会再去H1读,因为我们H2集群里面已经包含了H1集群,如果在H2读的是A,B,C三台机器,那么就不需要再去H1集群读,因为即使去H1读,也是同一台机器,结果是一样的。优化之后每台机器的读写请求数不会比扩容前高。

对于简单哈希,也可以使用上面的方法进行优雅扩容。

ssh内网穿透连接树莓派

为了不让树莓派继续在家吃灰,周末又拿出来折腾了一下。apt-get升了个级,300M+的更新之后竟然没挂!可以继续折腾。。

家里电信宽带,路由器admin密码有待破解,在外网如何方便控制家里的树莓派成了一个问题。

目前有个小vps,可以作为中继连接树莓派,有几种方案选择:

1. 首选VPN

这个很简单,vps上搭建一个vpn,然后树莓派和控制端都连上vpn,那么树莓派和控制端就在同一个局域网内部了,可以直接ssh连接的。

网上找到这样的方法连vpn:sudo pptpsetup --create vpnname --server ip --username test --password test --encrypt --start,但是树莓派一执行这个命令就断网,原因未知,只能重启。

2. 通过ssh隧道

原理也很简单,假设vps地址是1.1.1.1,树莓派通过ssh连接到vps,同时将vps上某个端口比如1234映射到树莓派的ssh端口比如22,这样在vps上访问1234端口就相当于访问树莓派的22端口,命令很简单:

$ ssh -f -N -R 2222:localhost:22 [email protected]

上面那个命令会将vps上的2222端口映射到树莓派的22端口(ps:你也可以映射你感兴趣的其他端口^_^):

# lsof -i :2222
COMMAND   PID   USER   FD   TYPE    DEVICE SIZE/OFF NODE NAME
sshd    16893 username   10u  IPv6 238723171      0t0  TCP localhost:2222 (LISTEN)
sshd    16893 username   11u  IPv4 238723172      0t0  TCP localhost.localdomain:2222 (LISTEN)
#
# telnet localhost 2222
Trying ::1...
Connected to localhost.localdomain.
Escape character is '^]'.
SSH-2.0-OpenSSH_6.0p1 Debian-4+deb7u2
^]
telnet>

看起来这样就OK了,但是,如果树莓派的ssh因为某种原因断开了,我们就无法再控制了,因此我们需要让ssh断开之后自动重连,autossh是一个好选择,在树莓派开启ssh隧道需要这样做:

  1. 将树莓派的ssh public key 添加到vps

我们需要autossh自动连接,就需要让树莓派可以免密码ssh登陆vps,这样重连的时候可以自动连上:

$ ssh-copy-id [email protected]
  1. 树莓派上启动autossh
$ autossh -M 5678 -fNR 2222:localhost:22 [email protected]
  1. 设置开机自动启动autossh

假设上面的操作我们是在树莓派上用rpi这个账户

$ cat /etc/rc.local | grep autossh
su rpi -c "autossh -M 5678 -fNR 2222:localhost:22 [email protected]" &

这样树莓派只要开机就自动连接vps,我们就可以方便地控制和重启了。

上面提到,在vps上,2222这个端口实际上是在127.0.0.1上监听的,也就是说在外网是没法直接ssh的,我们可以通过以下方式来解决

1. 笨办法

ssh登上vps,然后再ssh -p 2222 rpi@localhost登陆树莓派

2. 将2222端口映射到外网

可以用iptables做端口映射,例如将vps的23端口映射到vps的2222端口

# iptables -t nat -A PREROUTING -p tcp --dport 23 -j REDIRECT --to-ports 2222

需要注意的是需要打开ip_forward功能:

# echo '1' > /proc/sys/net/ipv4/ip_forward

3. 配置客户端netstat转发

修改客户端的~/.ssh/config如下

$ cat ~/.ssh/config
Host vps
    Hostname 1.1.1.1
    User username

Host 127.0.0.1 rpi
    Hostname localhost
    Port 2222
    User rpi
    ProxyCommand ssh vps netcat -q 600 %h %p 2> /dev/null

保存之后首先测试 ssh vps可以正常登陆,然后确认vps上有安装netcat,接下来就可以ssh rpi来登陆树莓派了,如果ssh-copy-id之后是可以一键直接登陆的,还可以用scp来拷贝文件,非常方便。

接下来就是看看在树莓派上折腾点东西玩~

ps: 贴张树莓派的照片

http://ma6174.u.qiniudn.com/rpi_pic.jpg

IFTTT 新产品:Do

先上链接:

https://ifttt.com/products/do/button
https://ifttt.com/products/do/note
https://ifttt.com/products/do/camera

Do由三个独立的APP组成:Do ButtonDo NoteDo Camera

传统的 IFTTTIf This Then That中的 This 条件是由已有的Channel自动触发的,Do 最大的创新之处是This条件由用户主动去触发,触发也超级简单,Do Button只需要按一个按钮就好了,Do Note只需要输入一些文字,Do Camera只需要拍一张照片。当用户点击Do之后,Do会记录你点击的时间、地理坐标,输入的文字,拍摄的照片等信息,这些都可以被加进That中去。

看看大家怎么用这三个工具:

Do Button

  • 通过按钮控制智能家居的电灯开关
  • 点击按钮实时记录自己的位置状态
  • 点击按钮向slack群组发消息是否有人要一杯咖啡

Do Note

  • 发表内容到微博,twitter等
  • 向Google日历中添加事件
  • 在Evernote中保存记事
  • 设定智能家居空调的温度

Do Camera

  • 拍一张照片并立即分享到facebook
  • 实时备份自己拍到的照片到dropbox
  • 拍下照片立即分享给讨论组好友

每个Do工具最多只能设置三个Recipes,这样也好,切换起来非常方便,最常用的功能也不过两三个。

我现在最常用的是Do Note

  1. 用来发twitter,这个可以认为是最简单的发表twitter的方式
  2. 用来给slack发送消息,手机和电脑可以方便的同步信息
  3. 保存记事到evernote

Http Trailer

一般HTTP请求或响应包含HeaderBody,如果有些信息是在Body发完才知道,比如Body的校验、数字签名、后期处理结果等希望在同一个请求里面延后发送,就需要用到Trailer

传输格式

一个带Trailer的响应例子:

HTTP/1.1 200 OK 
Content-Type: text/plain 
Transfer-Encoding: chunked
Trailer: Expires

7\r\n 
Mozilla\r\n 
9\r\n 
Developer\r\n 
7\r\n 
Network\r\n 
0\r\n 
Expires: Wed, 21 Oct 2015 07:28:00 GMT\r\n
\r\n

使用Trailer有几个注意事项:

  1. Header里面的Transfer-Encoding必须是chunked,也就是说不能指定Content-Length
  2. Trailer 的字段名字必须在 Header里面提前声明,比如上面的Trailer: Expires
  3. TrailerBody发完之后再发,格式和Header类似。

实战

用Go实现一个HTTP客户端,对所发的Body计算MD5并通过Trailer传给服务端。
服务端收到请求并对Body进行校验。

服务端程序:

package main

import (
	"crypto/md5"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
)

func index(w http.ResponseWriter, r *http.Request) {
	fmt.Printf("header: %+v\n", r.Header)
	fmt.Printf("trailer before read body: %+v\n", r.Trailer)
	data, err := ioutil.ReadAll(r.Body)
	bodyMd5 := fmt.Sprintf("%x", md5.Sum(data))
	fmt.Printf("body: %v,body md5: %v, err: %v\n", string(data), bodyMd5, err)
	fmt.Printf("trailer after read body: %+v\n", r.Trailer)
	if r.Trailer.Get("md5") != bodyMd5 {
		panic("body md5 not equal")
	}
}

func main() {
	http.HandleFunc("/", index)
	log.Fatal(http.ListenAndServe(":1235", nil))
}

客户端程序:

package main

import (
	"crypto/md5"
	"fmt"
	"hash"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
)

type headerReader struct {
	reader io.Reader
	md5    hash.Hash
	header http.Header
}

func (r *headerReader) Read(p []byte) (n int, err error) {
	n, err = r.reader.Read(p)
	if n > 0 {
		r.md5.Write(p[:n])
	}
	if err == io.EOF {
		r.header.Set("md5", fmt.Sprintf("%x", r.md5.Sum(nil)))
	}
	return
}

func main() {
	h := &headerReader{
		reader: strings.NewReader("body"),
		md5:    md5.New(),
		header: http.Header{"md5": nil, "size": []string{strconv.Itoa(len("body"))}},
	}
	req, err := http.NewRequest("POST", "http://localhost:1235", h)
	if err != nil {
		panic(err)
	}
	req.ContentLength = -1
	req.Trailer = h.header
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	fmt.Println(resp.Status)
	_, err = io.Copy(os.Stdout, resp.Body)
	if err != nil {
		panic(err)
	}
}

运行结果:

$ go run server.go
header: map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
trailer before read body: map[Md5:[] Size:[]]
body: body,body md5: 841a2d689ad86bd1611447453c22c6fc, err: <nil>
trailer after read body: map[Md5:[841a2d689ad86bd1611447453c22c6fc] Size:[4]]

$ go run client.go
200 OK

通过nc来看服务端收到的请求

$ nc -l 1235
POST / HTTP/1.1
Host: localhost:1235
User-Agent: Go-http-client/1.1
Transfer-Encoding: chunked
Trailer: Md5,Size
Accept-Encoding: gzip

4
body
0
Md5: 841a2d689ad86bd1611447453c22c6fc
size: 4

可以看到服务端在读完body之前只能知道有Md5这个Trailer,值为空;读完body之后,能正常拿到TrailerMd5值。

Go语言使用Trailer也有几个注意事项:

  1. req.ContentLength 必须设置为0或者-1,这样body才会以chunked的形式传输。
  2. req.Trailer需要在发请求之前声明所有的key字段,在body发完之后设置相应的value,如果客户端提前知道Trailer的值的话也可以提前设置,比如上面例子里面的size字段。
  3. 发完body之后Trailer不允许再更改,否则可能会因为map并发读写,导致程序panic,同样的道理服务端在读body的时候也不应该对Trailer有引用。
  4. 服务端必须读完body之后才能知道Trailer的值。

参考:

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.