go http client 设置连接超时

go 语言的 http 客户端可以在初始化话的时候通过

client := http.Client{
	Timeout: 5 * time.Second,
}

来设置请求超时,即整个 http 请求到完成响应的时间限制。那么如果想另外设置 tcp 连接阶段的超时可以这样玩:

client := http.Client{
	Transport: &http.Transport{
		Proxy: http.ProxyFromEnvironment,
		Dial: (&net.Dialer{
			Timeout:   2  * time.Second,
			Deadline:  time.Now().Add(3  * time.Second),
			KeepAlive: 2 * time.Second,
		}).Dial,
		TLSHandshakeTimeout: 2 * time.Second,
	},
	Timeout: 5 * time.Second,
}

通过设置 Transport 结构中的 Dial 的属性来实现。如上面的代码中,Dial 的 Timetout 是在 tcp 连接时设置的连接超时,Deadline 则会在超过这个时间后强制关闭连接,在连接无响应的时候回有用。KeepAlive 则会发起心跳,检测连接是否存活。此外,可以设置 TLSHandshakeTimeout 作为 https 握手的超时。具体可以参考 net.Dialer 的文档。由于直接构造了 Transport 结构,不会自动设置 Proxy 属性,这里还得再这里补上。可以用 http.ProxyFromEnvironment 表示根据环境变量来设置,即 http_proxy 和 https_proxy 两个变量设置的 http 代理。如果想强制不使用代理,可以设置为

...
	Proxy: func(*http.Request) (*url.URL, error) {return nil, nil},
...

等待一个独立进程退出并获取 exit code

linux 里,对于进程的子进程,父进程可以用 wait、waitpid 来等待结果。但是对于一个独立的进程就不行了。

有时候想监控一个进程,或者在父进程异常退出后想找回子进程状态,就只能另辟蹊径。于是,想了个通过 ptrace 来跟踪进程退出的办法,做了个小程序:

https://github.com/xiezhenye/waitpid/

可以通过 waitpid 来等待一个独立进程退出并获取 exit code。

不通过 web server 获取 php-fpm 运行状态

php-fpm 可以配置一个 pm.status_path ,如 /status,然后通过 web server 访问这个地址来获取运行状态。但这样会侵入 web server 的配置,在一个 web server 后端有多个 php-fpm 的时候也不方便分别监控每一个后端的状态,为了安全,还要配置访问控制。

好在有个现成的工具 cgi-fcgi,可以把 fcgi 请求包装成 cgi 方式,这样就可以直接在命令行中调用 fastcgi。

cgi-fcgi 在 redhat/centos 中可以用 yum install fcgi 安装,在 ubuntu 中可以用 apt-get install libfcgi-dev 安装。

用以下方式就能获取 php-fpm 的状态了。

path=/status

export REQUEST_METHOD=GET
export SCRIPT_NAME="$path"
export SCRIPT_FILENAME="$path"
export QUERY_STRING=''
# export QUERY_STRING='full'
# export QUERY_STRING='json'
# export QUERY_STRING='full&xml'

addr=/var/run/php-fpm.socket
# addr=127.0.0.1:9000

cgi-fcgi -bind -connect "$addr"

QUERY_STRING 设置为 full 会显示每一个 worker 进程的状态。添加 json、xml、html 可以以不同格式显示结果。

下面是单行脚本的写法和运行结果:

# env REQUEST_METHOD=GET SCRIPT_NAME=/status SCRIPT_FILENAME=/status QUERY_STRING='' cgi-fcgi -bind -connect /var/run/php-fpm.socket
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Cache-Control: no-cache, no-store, must-revalidate, max-age=0
Content-type: text/plain;charset=UTF-8

pool:                 www
process manager:      dynamic
start time:           15/Jan/2016:10:48:15 +0800
start since:          4604
accepted conn:        693119
listen queue:         0
max listen queue:     0
listen queue len:     0
idle processes:       157
active processes:     3
total processes:      160
max active processes: 50
max children reached: 0
slow requests:        17

MySQL relay_log_purge=0 时的风险

有时候,我们希望将 MySQL 的 relay log 多保留一段时间,比如用于高可用切换后的数据补齐,于是就会设置 relay_log_purge=0,禁止 SQL 线程在执行完一个 relay log 后自动将其删除。但是在官方文档关于这个设置有这么一句话:

Disabling purging of relay logs when using the --relay-log-recovery option risks data consistency and is therefore not crash-safe.

究竟是什么样的风险呢?查找了一番后,基本上明白了原因。

首先,为了让从库是 crash safe 的,必须设置 relay_log_recovery=1,这个选项的作用是,在 MySQL 崩溃或人工重启后,由于 IO 线程无法保证记录的从主库读取的 binlog 位置的正确性,因此,就不管 master_info 中记录的位置,而是根据 relay_log_info 中记录的已执行的 binlog 位置从主库下载,并让 SQL 线程也从这个位置开始执行。MySQL 启动时,相当于执行了 flush logs ,会新开一个 relay log 文件,新的 relay log 会记录在新的文件中。如果默认情况 relay_log_purge=1 时,SQL 线程就会自动将之前的 relay log 全部删除。而当 relay_log_purge=0 时,旧的 relay log 则会被保留。虽然这并不会影响从库复制本身,但还是会有地雷:

  1. 由于崩溃或停止 MySQL 时,SQL 线程可能没有执行完全部的 relay log,最后一个 relay log 中的一部分数据会被重新下载到新的文件中。也就是说,这部分数据重复了两次。
  2. 如果 SQL 跟得很紧,则可能在 IO 线程写入 relay log ,但还没有将同步到磁盘时,就已经读取执行了。这时,就会造成新的文件和旧的文件中少了一段数据。

如果我们读取 relay log 来获取数据,必须注意这一点,否则就会造成数据不一致。而保留 relay log 的目的也在于此。因此,在处理 relay log 时必须格外小心,通过其中 binlog 头信息来确保正确性。

关于如何配置 crash safe 的复制本身的配置,可以参照:
http://blog.itpub.net/22664653/viewspace-1752588/
http://www.innomysql.net/article/34.html

参考资料:
http://blog.booking.com/better_crash_safe_replication_for_mysql.html
https://bugs.mysql.com/bug.php?id=73038
http://bugs.mysql.com/bug.php?id=74324

supervisord 的 fd 泄露问题

线上发现有几个 supervisord 启动的 php 后台进程没有正常工作。上去 strace 了一下,发现卡在了写 stdout 上:

# strace -p 2509
Process 2509 attached - interrupt to quit
write(1, "[2015-05-22 18:48:19]  **************"..., 82

为啥写 stdout 会卡住呢?看了进程 2509 的 fd 1,原来是个管道,那就是管道对面的进程没有读取数据了。于是就查找了一下管道对面是哪个进程。结果却很神奇,管道对面居然也是进程 2509 自己!

# lsof | awk '$8=="124628537" && $9=="pipe"'
php        2509       www    1w     FIFO                0,8       0t0  124628537 pipe
php        2509       www    2w     FIFO                0,8       0t0  124628537 pipe
php        2509       www 1066r     FIFO                0,8       0t0  124628537 pipe
php        2509       www 1067w     FIFO                0,8       0t0  124628537 pipe

这就难怪了,显然 php 自己不会干自己读自己写的事,于是就卡住了。但是这又是怎么造成的呢?看到一个进程自己和自己管道,就想到了这应该是父子进程间用管道 ipc,然后没关干净造成的吧。php 是由 supervisord 启动的,又了解到写往 stdout 的是传给 supervisord 记日志用的,同时发现,这个 php 的父进程已经变成了 1,也就是说,supervisord 曾经挂过。那么这问题很可能就是 supervisord 造成的。那么究竟是怎么形成的,又如何避免呢?

于是就去翻了 supervisors 的代码,终于明白了原因。supervisors 启动子进程的流程是这样的。

  1. 调用 pipe 生成一对管道
  2. fork 生成子进程
    父进程
    1. 关闭管道自己不用的端
    子进程
    1. dup2 将管道的 fd 覆盖掉自己的 0 1 2 即 stdin, stdout, stderr
    2. 关闭 3 ~ minfds 的所有 fd,只保留 0 1 2
    3. 初始化,execve 执行程序替换自身
  3. 这里的 minfds 是通过 supervisord 的启动参数设置的,默认为 1024。在 supervisord 的初始过程中,如果发现环境中的 rlimit 小于设置的值,会试图用 minfd 和 minproc 去设置相应的 rlimit 来满足需求,但是当环境中的 rlimit 大于设置时却没有处理,造成实际使用的 RLIMIT_NOFILE 大于 minfd。

    出问题的 supervisord 并没有设置这个参数,而系统的 rlimit 的默认 max open files 是 10240。pipe 生成的 fd 是 1066,大于 1024,于是就没有被清理掉。原本正常的管道应该是

    php:1 ----------- supervisord: 1066
    

    结果成了

    php:1 ----------- supervisord: 1066
    php:1066 --/
    

    最后在 supervisord 异常退出时,
    原本应该是

    php:1 ----------- XXX
    

    结果成了

    php:1 ----------- XXX
    php:1066 --/
    

    正常情况下,supervisord 挂了,管道也就坏了。php 往坏了的管道写数据就会触发 Broken pipe,默认就也会被干掉,不会造成现在的后果。但是由于有一头没清理掉,管道就还没坏,只是存活两头都是 php 自己,再往里写数据,就由于没人去读而卡死了。

    要解决问题,就必须在启动 supervisord 时设置 `-a/–minfds` 不小于环境中 max open file 值。一个更好的习惯是,让系统的 soft RLIMIT_NOFILE 保持默认的 1024,调大 hard RLIMIT_NOFILE。然后通过 supervisord 的参数来设置需要的 rlimit。这个值也并不是越大越好,在所有进程退出时,和类似 supervisord 启动子进程时都需要遍历关闭 RLIMIT_NOFILE 的所有 fd。值太大会影响系统性能,同时每个进程的 fd 表也会增大,进程数量多的情况下,还是会多费些内存的。

    最后还是去给 supervisord 提交了一个 issue:https://github.com/Supervisor/supervisor/issues/690

设置 linux 命令缓冲模式

默认情况下,*nix 命令的 stdout 和 stdin 如果是在终端中是行缓冲,stderr 则是无缓冲。而这些标准输入输出如果是在管道中或重定向文件则是全缓冲。有时候使用管道处理数据的时候,并不希望管道后面的命令一直阻塞等待前一个的输出填满缓冲区刷新的时候才能处理,而是希望能即时看到数据。

有些命令提供了参数来设置缓冲模式,比如 tcpdump 可以使用 -l 参数来强制设置为行缓冲,awk、grep、sed 等也有这样的参数。但是也还是有很多命令行工具并没有提供这样的功能,这时候可以利用 stdbuf 命令来设置。

比如

stdbuf -oL tcpdump ... | grep ...

就可以把 tcpdump 的 stdout 设置为行缓冲,起到同样的效果。同样可以用 -i, -e 来设置 stdin 和 stderr 的缓冲模式。可以设置为 L 表示行缓冲,0 表示无缓冲,或者设置一个指定的缓冲区大小,如 4K 。这样无论命令是否支持设置缓冲模式都可以解决这些问题了。

使用 row 格式 binlog 撤销操作

MySQL 在使用 row 格式,并使用默认的 binlog_row_image=full 的时候,binlog 中记录了完整的更新前后的数据镜像。因此,根据 row 格式 binlog 进行撤销操作是可行的。我就试着做了这么个工具

[GitHub 页面] [linux 二进制文件]

例如

./binlog_undo -f /data/mysql/log-bin.000004 -p 3958 -o binlog.out

会扫描 /data/mysql/log-bin.000004 从位置 3958 到末尾的所有事务,倒转其中所有事务顺序和每个事务中的语句顺序,并反向所有的操作,把 WRITE 和 DELETE 反转,把 UPDATE 的前后镜像互换,然后将生成后的 binlog 文件写入 binlog.out 中。之后就可以用 mysqlbinlog 工具来回放这个 binlog 来撤销操作了。

这个工具可以用于在主备切换后,撤销掉原主上未同步到备的操作,避免完全重建;也可以作为误操作的后悔药。

记一次 MySQL 循环复制

有朋友的一对 MySQL 出现远大于其他类似实例的大量更新。因为开启了主备双向复制和 log_slave_updates,所以首先猜测是不是有循环复制,主 stop slave 后暂时正常。看了下主上的 relay_log,验证了这一点。幸而来回复制的只是监控用的心跳更新,不会搞坏数据。

但是 MySQL 会比较 server_id ,一般情况下是不会出问题的。于是又仔细看了下主上不正常的 relay_log ,发现其中 event 的 server_id 和当前的都不同。 问下来确实曾经在线改过 server_id。

想了下怎么触发这个问题,在自己的实例上复现了一把

  1. 开启 主->备 的复制
  2. 主上插入若干记录
  3. 更改主的server_id
  4. 开启 备->主 的复制
  5. 轰!

实际操作中,由于存在复制延时,即使没有先停止备->的复制也有可能触发问题。

然后就是怎么拆掉这个雷了。由于来回复制的只是心跳更新,所以只需要跳过就行,其实如果不是的话也已经完蛋没法救了。首先想到的是 binlog_ignore_db 和 replicate_ignore_db 。但是这两个不是动态变量,重启一次服务代价太大。同事 @Tachikoma 提醒我 change master 还有个 ignore_server_ids 选项。于是就只需要 change master to ignore_server_ids (1,2) 忽略之前的 server_id 就行。待复制正常,change master to ignore_server_ids () 就可以解除掉了。

移植 spider 到 MySQL 5.6

MariaDB 中自带了很多 MySQL 中没有的插件。我对其中的 spider 存储引擎很有兴趣。这个引擎可以让 MySQL 作为一个 proxy ,来实现 sharding、高可用等功能。这些功能已经有一些产品实现了,比如 MaxScale、Cobar、OneProxy、Atlas。但是我觉着 spider 把自己作为一个存储引擎来实现这些功能是有其优势的。SQL 解析和查询优化是个非常复杂而且很难做好的工作。其他替代产品都是自己实现,由于复杂性,这些产品都带来了一下限制,没能支持全部常见的 SQL 语句,给使用和实施带来了困难。而作为一个存储引擎,这些工作都由 MySQL 自身完成了,后面的工作就会简单很多,想做点优化的话也会容易些。

由于 MariaDB 从 MySQL 5.5 时代就分道扬镳了,做过很多改动后,和目前版本的 MySQL 已经有了不小差异,所以插件基本上没法直接拿到 MySQL 里编译使用。我就花了点功夫,把 spider 引擎移植到了 MySQL 5.6。

https://github.com/xiezhenye/mysql-plugin-spider-engine

编译使用和一般的插件差不多

cp -r src /path/to/mysql-src/storage/spider
cd /path/to/mysql-src
cmake . -DBUILD_CONFIG=mysql_release -DCMAKE_INSTALL_PREFIX=<mysql install dir>
cd storage/spider
make
make install

之后,执行附带的 install_spider.sql 安装插件,创建所需要的系统表。

mysql ... < scripts/install_spider.sql

具体文档参见 https://mariadb.com/kb/en/mariadb/spider/

在测试过程中,发现安装插件以后,重启 MySQL 后会 crash,然后再也启动不了,移除 ha_spider.so 后才行。具体来说,是在配置为

log_bin=off
spider_support_xa=on
spider_internal_xa=off

的时候。然而在 MariaDB 中却是正常的。开始以为是自己移植过程带来的 bug,或者有什么兼容问题未解决。追踪到后来,发现这居然是 MySQL 自身的 bug。于是去提交了一下。http://bugs.mysql.com/bug.php?id=78050

在使用外部 XA 的时候,如果没启用 binlog,会把 XA 信息通过 TC_LOG_MMAP 来持久化。然后 bug 就出在了那里。

这个 bug 曾经在 2009 年就被发现过,2012 年被 fix 过。但是显然并没有改对。待我自己做了 fix 以后,进一步发现,这个 bug 在 MariaDB 中已经被修复过,然后发现原来在 MySQL 5.7 分支下也是修复过的,但是并没应用到 5.6 分支。

都是几个相当低级的 bug。有成员未初始化,指针计算时搞错了指针类型,未判断空指针…… 。虽然这个地方确实是一般使用很难碰到,但是这代码质量简直无语。

修复 disable myisam 插件的 crash 问题

之前写了一个 禁止创建 MyISAM 表的插件blog),有报 issue,在 MySQL 重启后 uninstall plugin 会引发 crash。

测试了一下果然可以复现。挂上 gdb 后,backstace 如下:

(gdb) bt
#0  0x00000000006f7658 in column_bitmaps_set (write_set_arg=0x189ad98, read_set_arg=0x189ad98, this=0x7fc3a800fa60)
    at /root/mysql-5.6.24-tp/sql/table.h:1228
#1  use_all_columns (this=0x7fc3a800fa60) at /root/mysql-5.6.24-tp/sql/table.h:1238
#2  mysql_uninstall_plugin (thd=thd@entry=0x1d1b030, name=0x1d1d858) at /root/mysql-5.6.24-tp/sql/sql_plugin.cc:2077
#3  0x00000000006e8daf in mysql_execute_command (thd=thd@entry=0x1d1b030) at /root/mysql-5.6.24-tp/sql/sql_parse.cc:4910
#4  0x00000000006ed9d8 in mysql_parse (thd=thd@entry=0x1d1b030, rawbuf=, length=, 
    parser_state=parser_state@entry=0x7fc3c61b02f0) at /root/mysql-5.6.24-tp/sql/sql_parse.cc:6391
#5  0x00000000006ef1cd in dispatch_command (command=COM_QUERY, thd=0x1d1b030, packet=, packet_length=)
    at /root/mysql-5.6.24-tp/sql/sql_parse.cc:1340
#6  0x00000000006f0f24 in do_command (thd=) at /root/mysql-5.6.24-tp/sql/sql_parse.cc:1037
#7  0x00000000006bd662 in do_handle_one_connection (thd_arg=thd_arg@entry=0x1d1b030) at /root/mysql-5.6.24-tp/sql/sql_connect.cc:982
#8  0x00000000006bd710 in handle_one_connection (arg=arg@entry=0x1d1b030) at /root/mysql-5.6.24-tp/sql/sql_connect.cc:898
#9  0x000000000095dea3 in pfs_spawn_thread (arg=0x1dbb410) at /root/mysql-5.6.24-tp/storage/perfschema/pfs.cc:1860
#10 0x00007fc3f7258182 in start_thread (arg=0x7fc3c61b1700) at pthread_create.c:312
#11 0x00007fc3f676530d in clone () at ../sysdeps/unix/sysv/linux/x86_64/clone.S:111

进一步追踪这个 column_bitmaps_set 的调用

  inline void column_bitmaps_set(MY_BITMAP *read_set_arg,
                                 MY_BITMAP *write_set_arg)
  {
    read_set= read_set_arg;
    write_set= write_set_arg;
    if (file && created)
      file->column_bitmaps_signal();
  }

发现是在 file->column_bitmaps_signal 这一步出错,反汇编以后,也确实是在 callq 指令处。注意到反汇编后,gdb 在 callq 指令所调用函数的名字没有能显示出来,说明这个地址很可能已经无效。这里 file 成员变量即是插件生成的替换了 create 操作的 ha_mysiam 的 wrapper。打印其内容发现 _vptr.handler = 0x7fc3d44768d0 ,这里也未能指出指向的类名。

阅读了 sql_handler.cc 中 mysql_uninstall_plugin 的代码后,明白了原因。卸载插件会删除 mysql.plugin 表中的相应记录。在 mysql_uninstall_plugin 中,先打开了 mysql.plugin 表,然后卸载插件,然后删除相应记录。问题就出在 plugin 表也是一个 MyISAM 表。如果打开的时候插件是启用的,那么其对应的 handler 是插件中的 wrapper。而插件卸载以后,对应地址的代码已经不复存在,因此就造成了 crash。那么既然是这个原因,又想到了,如果在插件加载期间打开过表,卸载插件后访问这些表应该也会出错。试了一下果然如此,一阵冷汗。

接着就是考虑如何解决这个问题。既然插件卸载后无法内存,那就需要在卸载时将原有的指向 wrapper 的 handler 都换回原有的 ha_myisam。handler 本身的内存是由 mysql 管理的,并不会因为插件卸载而释放。所以可以修改。wrapper 是 ha_myisam 的派生类,而且没有增加成员,因此内存布局和 ha_myisam 是完全相同的。唯一差异就在虚函数表。C++ 似乎没有这种强制转换到父类的语法。于是就考虑直接替换虚函数表指针。而 C++ 虚函数指针中总是对象最前面。于是直接建一个真的 ha_myisam 对象,然后把它的虚函数指针复制回去。试下来果然可以。接着就是在 wrapper 的构造和析构函数中维持一个当前现有 handler 的集合,用于在卸载时处理即可。