Category Archives: 技术

如何做一个靠谱的发号器

本文曾载于 有赞技术团队博客 https://tech.youzan.com/id_generator/

为什么需要一个发号器

在使用数据库时,表的主键经常会使用数据库的自增(auto_increment)来产生。这当然很方便也很高效。但是使用自增也会带来一些麻烦。如果从一个数据库以外的地方,也就是发号器来产生全局唯一 ID,这些问题就可以得到解决,生活就可以更美好。

  • 难以适应分片场景

    在采用数据库分片时,如果使用数据库自增 ID,不同分片上会产生相同的 ID。单靠 ID 无法唯一标示一个对象,还需要额外加上分片字段才行。如果需要将 ID 用于其他对象的关联时,会麻烦很多。而采用发号器生成的是全局唯一的 ID,单靠 ID 就能实现关联。同时,这也使得采用 ID 作为分片字段成为可能。

  • 主备切换时数据冲突

    在 MySQL 集群发生主备切换时,异步复制无法确保主从完全同步。在备库开放写入后,备库上产生的自增 ID 会和尚未同步的主库上的数据冲突。这样一来,即使原来的主库恢复了,也无法重新加入集群。数据修复也变成了一件非常困难的事情。引入发号器以后,备库上插入的 ID 和原来主库上的 ID 是不会重复的。因此,未复制的新增数据和对这些新增数据的修改就不会在备库发生冲突。

  • 网络异常时无法判断插入是否成功

    当插入记录时,如果使用数据库自增 ID,在完成插入后,才能得到产生的 ID。如果在执行语句时发生网络中断,客户端无法知道事务是否成功,即使成功,也无法再获得产生的 ID。如果使用发号器,就可以在插入之前预先产生 ID。如果碰到网络中断,可以用已经获得的 ID 去尝试查询来判断之前的插入是否成功。

此外,一些业务 ID 会需要一个全局唯一的整数作为它的组成部分。其他的分布式系统可以用全局单调的唯一 ID 作为事务号。有一个现成的服务就不用各自实现了。

发号器的必要特性

既然叫发号器,首先就得保证 ID 的全局唯一。就是说保证无论什么情况下都不会发出重复的 ID。这看起来很简单,但是事实上,很多实现却上并没有做到这点。要真正做到全局唯一,发号器必须要实现 crash safe,并不受外部环境变化影响。

  • crash safe

    首先是 crash safe。即得保证在服务崩溃重新恢复后,不会产生已经发过的 ID。在服务彻底完蛋时,也要能够在其他地方恢复出一个一定能用的。有的实现定期保存或者异步保存已经发过的 ID。如果发生崩溃,如果直接用保存过的 ID 继续发,就会发出已经发过的号。有的实现采用 MySQL 或 Redis 来产生 ID。由于 MySQL 和 Redis 的复制本身难以保证强一致,在发生主备切换时,备机尚未完全同步的话,还是会发出重复的 ID 来。有的实现没有使用副本,单纯靠分片来实现负载均衡和高可用,这时如果某个实例完蛋了,想要重新恢复一个就没法了。

  • 不受外部环境变化影响

    很多发号器实现是基于时间戳的。但是有些实现直接采用了机器上的时间戳作为 ID 的一部分。如果机器时间发生回跳(不要认为这不可能),就会造成 ID 重复。使用时间戳同时也对机器时间的精度有了依赖。

要让发号器能真正有用,还得实现高可用,并能支撑足够大的吞吐量。不然发号器本身也会成为一个单点或瓶颈。

如何设计发号器

有赞同样有对发号器的需求。经过对现有实现的考察后,我们还是打算实现一个自己的发号器,我给它起了个名字:March。我们的发号器同样要解决这些问题。

持久化

要满足真正的全局唯一,持久化是必须的。而且持久化还必须是不会丢失的,强一致的。

如果发号器实现是分散在各个应用服务器上的,由于应用服务器的持久化能力是难以保证的,可靠性就会受影响。而且这样一来,每个应用服务器也要有一个终身及死后也全局唯一的 ID 作为产生的 ID 的一部分,来满足全局唯一,这就大大提高了部署和运维的门槛。所以,我们认为发号器最好还是集中式的。

在采用集中式的前提下,持久化的副本也是不可少的。要自己实现这样的一个持久化系统是很难的。所以,在持久化方案上,我们选择了现成的 etcd。etcd 能满足不会丢失的,多副本,强一致的全部需求。持久化就可以全部放到 etcd 中,发号器本身就可以是无状态的,这样一来,高可用的实现也会容易一些。

是否全局单调

是否全局单调其实是个权衡。在确定要高可用的前提下,全局单调和负载均衡是不可兼得的(可以想想为什么)。我们最终还是选择实现全局单调。全局单调的 ID 有额外的好处。作为主键时,可以直接代替时间字段排序。由于 MySQL 二级索引是指向主键的,使用主键排序通常可以避免排序操作,直接利用索引就能完成。另外,如果要实现一些分布式一致性系统,一个全局单调的 ID 生成器也是一个必备的组件。

高可用

由于采用了全局单调,高可用方案就只能是主备的。一个集群内,同时只能有一个实例对外提供服务。这时候就要考虑怎么实现选主和故障切换。既然我们用了 etcd,实现高可用的时候也正好可以用上它的 TTL、Watch 这些特性。然后也要能让客户端知道哪个实例才是主实例,可以自动切换访问路径。

ID 的形式

发号器产生的 ID 一般都是 64 位整数,这样对数据库比较友好,容量也能满足业务需求,不会哪天爆了。通常产生的 ID 可以分成两大类。一类是单纯的 Sequence,即一个不断递增的整数。另一类是基于 Timestamp 的,由于机器时间的精度限制,通常都会额外再加一段 Sequence。为了分布式,还经常会加上各种不同的标示实例的位。不同的实现无非就是这些东西的组合以及各段的长短的变化。有赞之前已经有了几个实现。新的发号器要落地,也得兼容现有的。所以不同的 ID 的形式还是都得支持。但是具体实现细节上,可以比原有的更进一步。

认证和权限控制

使用发号器的业务方会有很多。为了信息安全,和避免相互干扰,认证和权限控制功能也有了需求。March 可以设置多个用户,为每个用户分配访问不同的发号器的权限,以及其他的创建,管理类权限。用户信息同样不能丢,所以也持久化在 etcd 中。

通信协议

作为一个服务,就会有和客户端交互。有交互,就要有一个协议。我们希望尽量能采用一个现成的协议。这样对实现不同语言的客户端会方便很多。同时这个协议要足够轻量高效,也能具备扩展性。我们最后选择了 Redis 协议。Redis 协议很简单,协议本身的负担小。由于是个广泛使用的东西,各种语言都有它的库。这样在实现客户端 SDK 的时候,就有了个很好的起点。现成的一些命令,如 INCR,INCRBY,GET 等本身也很适合用于发号器。在需要一些特殊的功能时,也可以自己添加新命令。高可用方面,Redis Cluster 的协议也可以用上。这样客户端的自动切换就不用自己实现了。对于服务端,好几个语言也都有现成的库。

发号器的实现

有赞的发号器 March 是用 Go 语言实现的。语言选择上其实没太大讲究。不过对于这类项目,Go 在开发效率,部署简便,和倾向低延迟的 gc 优化还是有一些优势。

ID生成

前面说过,发号器产生的 ID 可以分成两大类。一类是 Sequence,一类是基于 Timestamp 的。这两类有各自的实现。

  • Sequence

    March 在启动时会从 etcd 中载入之前持久化的已经发过的 id 作为起点。然后执行一次持久化,将起始 id + batch 保存下来。 [ id, id + batch ) 的区间就是缓存。客户端请求时,下发的 id 都是从这个缓存中取的。同时启动一个 goroutine 来做持久化。在这个缓存的容量低于水位线(默认是 50%)时,会异步通知这个持久化 goroutine 进行持久化,将 id + batch * 2 保存下来。此时,缓存的上界就扩容到了 id + batch * 2,以此类推。由于持久化是异步的,所以一般情况下,并不会阻塞请求,造成请求延迟增大。但是有突发的并发时,在持久化没进行完,缓存就已经耗尽的情况下,为了保证正确性,才会发生阻塞,等待持久化完成。所以,对于高并发的应用,配置一个大的缓存区间可以获取更高的性能。比如将 batch 设为 10000,平均发出 10000 个号才需要持久化一次。备机平时是不提供服务的,在发生主备切换时,备机才会从持久化中重新载入配置。所以备机提升为主机以后,也可以保证不会发重,只是从客户端看来,会跳空一段 id。不过这也算不上什么问题。

  • Timestamp

    Timestamp 类型的 ID 分成 3 段:node,timestamp,sequence。通过配置各个段的长度和偏移,以及时间戳的精度,就可以兼容各种已有的基于时间戳的发号器实现。多个请求到来时,如果 timestamp 相同,会增长 sequence。timestamp 改变时,就清零 sequence。有一点特别的地方是,我们允许 sequence 段溢出。 溢出的部分会加到 timestamp 段上去。这样即使在时间戳精度范围内 sequence 耗尽了,也不用阻塞请求。Timestamp 类型持久化的是时间,保存的是当前的 timestamp + 提前量。这里的 timestamp 是包含 sequence 溢出的部分。Timestamp 类型的持久化是定时进行的。由于已持久化的时间戳总是大于当前时间的,因此等待持久化而造成的阻塞基本上是不会发生的。March 启动时,如果获取的当前时间大于保存的时间,就使用当前时间作为起点,否则就使用已保存的时间作为起点。每次请求获取时间时也是类似。如果发现获取的时间小于已经发过的 timestamp,就继续使用当前 timestamp。这样就确保了即使机器时间跳变时,发出的 id 也是单调增长的,绝对不会重复。同时由于允许溢出,也不会因为时间回跳而阻塞。当然这种方式带来的一个影响是,如果从获取的 id 里解析出时间,可能并不是准确的时间。由于切换或溢出,看到的时间可能会提前。不过本来也不应该依赖这些细节不是么。

高可用

March 的高可用是利用 etcd 的 ttl 和 watch 实现的。启动时,先尝试创建一个新的带 ttl 的 Node。如果成功,就成为了主节点;如果由于已存在而失败,就成为了备节点。

  • 主节点

    定时用前一次请求返回的 index 刷新 Node 的 ttl,保持自己的主节点角色。发现刷新失败时,说明主节点角色已经被抢走,从抢主节点过程重新开始。与此同时,还会等待 demote 请求。收到 demote 请求时,会等待新的主节点信息,然后将自己置为备节点。

  • 备节点

    先查询主节点的信息。在备节点收到发号请求时,会按 Redis Cluster 协议重定向到主节点。之后就开始 Watch Node 的变化。检测到变化后,也开始抢主节点过程。

这样,可以做到在主节点发生故障时,最多等待一个 ttl 就能检测到,并完成切换。而在主动切换时,结合客户端,可以做到完全无损,只有毫秒级的阻塞。

此外,每个节点都会存保存各自的带 ttl 的节点信息,同时定时刷新,用于返回给客户端集群信息。每个发号器在每次持久化时,也会携带上上一次持久化获得的 index。一旦不匹配出错,也会将自身重置为备节点。这可以避免网络堵塞或进程僵死造成原主失效而自身却不知道。在发生非预期错误时,HA goroutine 会等待 2 * ttl,以免不断出错造成死循环。此外,备节点也需要能够完成用户认证。但因为认证是不能重定向的,所以还需要检测 etcd 上的用户信息变化,重新同步用户数据。

小结

发号器看起来简单,但是要实现一个靠谱的,易用的,要考虑到的地方还是很多的。其实很多东西都是这样。我们还做了更多。为了更容易接入落地,我们在数据库中间件中也做了集成。配置后,执行 insert 时,会自动代入配置的自增字段和 id 值,让业务方完全无痛。

排查 etcd 响应慢的问题

有一台 etcd,突然出现了响应缓慢的情况。排查中发现,系统 CPU,带宽,IO,内存,rlimit 都还有很大余量。但是 ss 发现,端口的backlog 堆积了大量连接。

$ ss -lnt
State      Recv-Q Send-Q            Local Address:Port              Peer Address:Port
...
LISTEN     745    65535               10.20.30.40:2379                         *:*
...

但是看并没有 SYN_RECV 状态的连接存在。所以不像由于并发连接太多造成的来不及 accept,而是 etcd 本身处理 accept
的过程慢。统计 ESTABLISHED 连接数,发现数量总是大致在 10000 个左右。这个连接数看起来很整,怀疑是不是有什么特殊的限制。

于是去看 etcd 的代码,发现 etcdmain/etcd.go 中,启动 etcd 的代码里有

l = transport.LimitListener(l, int(fdLimit-reservedInternalFDNum))

这个 LimitListener 限制了最多同时服务的客户端连接数,超过时就会等待已有连接退出。传入的 fdLimit 是获取的 open files rlimit 限制。reservedInternalFDNum 是保留的 fd 数,是个等于 150 的常量。看起来意图是在 fd 不够用的时候,保证自身的 IO 和集群通信不受影响。

后来了解到,ulimit 原本是 10240,后来调整到了 102400,当时为了不重启进程,动态修改了运行中的 etcd 的 rlimit。没想到 etcd 在启动的时候还使用这个值做了自己的限制。虽然 rlimit 改了,但是由于进程没重启,etcd 还是使用的原来的值做的限制。最后重启 etcd 就恢复了正常。

一个屏蔽 IP 的脚本

在测试可用性的时候,经常需要模拟断网。这时候用 iptables 是比较方便的。但是如果想更方便一点,不用去敲那么长的命令会更好些。于是就写了个 ban_ip 脚本。

#!/bin/bash

if [[ "$EUID" != 0 ]]; then
  echo "should run as root"
  exit 1
fi

action="DROP"
comment="ban_ip"
cmd="$1"
case "$cmd" in
list)
  iptables -L -n | awk -v "cmt=$comment" '$0~cmt{print $4}'
  ;;
add)
  ip="$2"
  if [[ -z "$ip" ]]; then
    echo "missing arg ip"
    exit 1
  fi
  iptables -A INPUT -s "$ip" -j "$action" -m comment --comment "$comment"
  ;;
del)
  ip="$2"
  if [[ -z "$ip" ]]; then
    echo "missing arg ip"
    exit 1
  fi
  iptables -D INPUT -s "$ip" -j "$action" -m comment --comment "$comment"
  ;;
*)
  echo "bad command: should be list, add <ip>, del <ip>"
  exit 1
  ;;
esac

用的时候就方便了不少,也便于查看当前已经 ban 掉的 ip。

$ sudo ./ban_ip.sh list
10.10.10.1

$ sudo ./ban_ip.sh add 10.10.10.2

$ sudo ./ban_ip.sh list
10.10.10.1
10.10.10.2

$ sudo ./ban_ip.sh del 10.10.10.1

$ sudo ./ban_ip.sh list
10.10.10.2

linux capabilities

在 linux 系统中,很多操作是需要 root 用户权限才能操作的。比如 chown,改变进程 uid,使用 raw socket 等等。要不就得用 sudo 提升权限,如果想让每个用户都能用特权来执行一个程序,配置管理和权限控制就很麻烦了。还有一个办法是使用粘滞位,通过 chmod +s,可以让一个 owner 为 root 的可执行文件再运行时具有 root 权限。在一些发型版中,ping 命令就是这么干的。给 ping 命令加上粘滞位以后,普通用户也可以使用这个命令通过 raw socket 发送 icmp 包了。不过这样一来,这个程序也就无所不能了。万一程序有啥漏洞,就容易造成严重后果。有没有办法只给这个程序开所需要的权限呢?其实是可以的。linux 有一套 capabilities 机制就是用来实现这个。

事实上,linux 本身对权限的检查就是基于 capabilities 的,root 用户有全部的 capabilities,所以啥都能干。如果想给一个可执行文件加上某个 capability,可以用 setcap 命令,如

setcap cap_net_raw=+ep ping

就可以给 ping 命令加上使用 raw socket 的权限。cap_net_raw 是 capability 的名字,后面是 mode,可以有

  • e:表示是否激活该 capability
  • p:是否允许进程设置该 capability
  • i:子进程是否能继承 capabilities

+ 表示启用,- 表示禁用。
这样执行以后,普通用户执行这个 ping 命令,也可以正常玩耍了。而且这个 ping 命令只获得了 raw socket 的权限。
通过 getcap ping 可以查看这个程序所拥有的 capabilities。

实现上,是通过 setxattr 系统调用,为文件加上 security.capability 的扩展属性。

man 7 capabilities 中可以看到所有可用的 capabilities。

man 3 cap_from_text 中可以看到关于 capability mode 的表达式说明。

为 bash 提示符加上 git 状态

在有一次手误合错分支以后,就决定为 bash 提示符加上显示当前分支,以及提交状态,这样就可以更清楚地知道当前在哪个分支,以及是不是 commit 了,是不是 push 了。代码如下:

function __git_prompt() {
  local s=$(git status -b --porcelain 2>/dev/null)
  if [[ -z "$s" ]]; then
    PS1="\h:\W \u$ "
  else
    local l=$(echo "$s"|wc -l)
    local br=$(echo "$s"|head)
    if [[ "${br%[ahead*}" != "$br" ]]; then
      local np=1
    fi
    br="${br#\#\# }"
    br="${br%...*}"
    if [[ "$l" -gt 1 ]]; then
      local g="(git:\[$(tput setaf 9)\]$br\[$(tput sgr0)\])" # dirty: red
    elif [[ -z "$np" ]]; then
      local g="(git:\[$(tput setaf 10)\]$br\[$(tput sgr0)\])" # clean: green
    else
      local g="(git:\[$(tput setaf 11)\]$br\[$(tput sgr0)\])" # not pushed: yellow
    fi
    PS1="\h:\W \u $g$ "
  fi
}


PROMPT_COMMAND='__git_prompt'

加入到 .bash_profile 即可。这里是按 MacOS 默认的 PS1 基础上改的,实际使用的时候可以根据需要调整。

page cache 造成 java 长时间 gc

最近在升级一个 java 应用时,在刚启动不久的时候发生了长时间的 gc,时间到了数秒,业务访问纷纷超时。

看了下 gc log:

2016-12-06T22:50:44.256+0800: 13.632: [GC pause (G1 Evacuation Pause) (young)
Desired survivor size 13631488 bytes, new threshold 1 (max 15)
- age   1:   27212320 bytes,   27212320 total
 13.632: [G1Ergonomics (CSet Construction) start choosing CSet, _pending_cards: 4890, predicted base time: 28.66 ms, remaining time: 71.34 ms, target pause time: 100.00 ms]
 13.632: [G1Ergonomics (CSet Construction) add young regions to CSet, eden: 89 regions, survivors: 13 regions, predicted young region time: 6880.88 ms]
 13.632: [G1Ergonomics (CSet Construction) finish choosing CSet, eden: 89 regions, survivors: 13 regions, old: 0 regions, predicted pause time: 6909.54 ms, target pause time: 100.00 ms]
 22.969: [G1Ergonomics (Heap Sizing) attempt heap expansion, reason: recent GC overhead higher than threshold after GC, recent GC overhead: 65.85 %, threshold: 10.00 %, uncommitted: 0 bytes, calculated expansion amount: 0 bytes (20.00 %)]
, 9.3374979 secs]
...
      [Object Copy (ms):  8821.5  6481.7  8743.0  6477.4  8173.8  6477.4  6477.5  6477.3  6792.5  6477.2  6477.0  6476.7  9331.9  9250.5  6476.0  6476.0  6471.3  7438.9
       Min: 6471.3, Avg: 7211.0, Max: 9331.9, Diff: 2860.6, Sum: 129797.8]
...

这还是一次 young gc,说明 java 自身的堆是够用的,并没有升级为 mix。但是为什么用了这么长时间呢?注意到大部分时间都是花在了 Object Copy 上。内存 copy 哪怕是几个 G,都不可能花那么久。这里必有蹊跷。

搜索到的结果里,有提到内存不足,使用 swap 造成的情况。但是我们的内存是足够的,而且已经禁用了 swap。但是这倒是给了个提示。看了下当时的内存情况,有大量内存在 cache 中。于是想到了一种可能:有大量文件写入,使 page cache 填满了内存。而我们的 java 应用使用了 4G 的堆大小,刚启动的时候,大部分虚拟内存都还没有分配实际的物理内存。而在 java 在 gc 的过程中需要映射物理内存时,需要等待系统将这部分内存释放出来。如果这时候脏页的数量大,就需要等待脏页刷写到磁盘。一涉及 IO,时间就完全没谱了。

然后在测试环境试着重现一下,先用 dd 写几个大文件,直到剩余的空闲内存很少,大部分都在 cache 中,然后再次启动应用,果然重现了。临时处理办法就是在启动应用的时候,如果发现空闲内存不够,就先用 vmtouch 刷一下占用 cache 的文件缓存(通常是日志)。为了更好的避免类似情况发生,就要控制 page cache 里脏页数量,这样就算是需要释放 cache 也不会花多少时间。可以通过 vm.dirty_background_ratiovm.dirty_ratio 内核参数来控制。

Java 应用在线性能分析和火焰图

在碰到线上性能问题的时候,如果能在线通过采样方式获取热点函数/方法就可以更方便地定位问题所在,进行优化。采用在线采样的方式,由于性能影响小,可以比较放心地在线上进行,获取第一手数据。Linux 平台上,对于多数 C/C++ 编写的应用,可以通过 perf 来方便的采样,还可以进一步生成火焰图来更直观地观察。Java 是没法直接用 perf 的。虽然有一个 perf-map-agent,但是并不方便,尝试过程中还弄出了 kernel panic,所以这玩意是不敢在线上用了。不过 JDK 自己其实已经带了一个采样工具 FlightRecorder ,算是 JMC 的一部分。这个功能是商业版本的功能,虽说启用的时候并没检查 license,但是理论上要在生产环境用,还是要花钱的。

首先,应用启动的时候,要给 java 加上参数:

-XX:+UnlockCommercialFeatures -XX:+FlightRecorder -XX:FlightRecorderOptions=loglevel=info

然后在想开始采样的时候

sudo -u <java_user> -i jcmd <pid> JFR.start filename=/tmp/app.jfr duration=60s

这里可以指定输出的文件路径,和采样时间。之后可以用

sudo -u <java_user> -i jcmd <pid> JFR.check

来检查采样是不是已经完成了。

更详细的用法可以参考官方文档:https://docs.oracle.com/javacomponents/jmc-5-4/jfr-runtime-guide/toc.htm

确认完成后,就可以把 jfr 文件传回本地,用 jmc 来分析了。如果想要生成火焰图,还有这么个工具:https://github.com/chrishantha/jfr-flame-graph。具体用法可以看文档。大致上,代码拖回来后,

cd jfr-flame-graph
install-mc-jars.sh
mvn clean install -U

就编译好了。另外还需要 https://github.com/brendangregg/FlameGraph

工具准备好以后,

path/to/jfr-flame-graph/run.sh -f app.jfr -o app.txt
cat app.txt | path/to/FlameGraph/flamegraph.pl >app.svg

就能生成一个漂亮的火焰图了。

一次连接超时问题排查的历程

我们有一个 java 应用,启动的时候要初始化连接池,在连接一堆 sharding 过的 DB 时,经常会有一部分连接超时失败的,集中在一两台后端机器上,但每次失败的后端服务器却又不固定,也并不是每次启动都能遇到。超时时间设为了 50ms,看起来有点短但是对局域网,和压力并不算大的 DB 来说,这个时间已经长得匪夷所思了。后来尝试调大成 100ms,还是有失败的。但是如果启动成功后,却没再记录到过连接超时的情况。

排查网络问题首先是抓包,本来打算看看是不是对端响应慢有啥重传的,结果发现了更神奇的事情:发起 TCP 连接的 SYN 包不够数!也就是说,有几个连接根本连 SYN 包都没发出去过。还发现有一两个连接收到了 DB 服务器的 SYN/ACK 后,居然发了 RST !所有服务器有响应的 SYN/ACK,包括被 RST 的,延迟都不到 0.2ms,速度挺正常的。那些个丢了的 SYN 和被 RST 的是怎么回事呢?

然后再用 strace 套着启动试试。这回也顺带了解了 java 在 linux 上连接超时的实现方式。首先发起一个非阻塞的 connect,然后用 poll 来等待直到超时,如果超时,则把 socket shutdown 了。然而在 strace 的记录里,所有的 connect 系统调用一个不少,只是在 poll 的时候超时了。这倒是可以解释前面抓包里 RST 的原因。在服务端 SYN/ACK 返回的时候,客户端已经超时 shutdown 了 socket,这时候自然就会返回 RST。但是奇怪的是为啥 connect 了却没看到 SYN 包,从被 RST 的现象推断,是从 connect 到发出 SYN 有了延迟。有的延迟发出后,返回的时候超时,于是就被 RST,绝大部分一直延迟到超时都没发出,于是就再也没了。但是为啥从 connect 到发出 SYN 包会有那么大的延迟呢?

于是去看了一下内核 connect 的实现,connect 系统调用对 TCP 来说,就是一路从数据构造 TCP 包、IP 包、Ethernet 包进入网卡的 QDisc。对非阻塞的 connect 来说就到此为止,返回 EINPROGRESS。对阻塞 connect,还会继续等待三次握手完成。进入 QDisc 后就是网卡驱动通过 DMA Engine 来发包了。因为是非阻塞 connect,这一路构造包的过程中没想出可以阻塞的点,于是怀疑是不是在 QDisc 或者 DMA Engine 发生了什么事情,研究实验了很久而无所得。

再一次碰到问题时,又抓了次包。这回凑巧没过滤 ARP 包,于是在结果里看到了一些查询 DB 服务器 MAC 地址的 ARP 请求。突然想起来,如果本地 ARP 缓存没命中的话,ARP 请求也是一个可能会有延迟的点。这个过程是发生在从 IP 包到构造 Ethernet 包的过程中的。于是把所有 ARP 包过滤出来看,真的发现对连接失败的 IP,ARP 请求第一次没有响应,隔 1s 重试以后才成功。就是这 1s 足以让超时时间是 50ms 的连接失败好多回了。

至于为啥 ARP 请求会超时倒不是啥难题。网络上有广播限流,之前也碰到过 ARP、VRRP 包被干的情况。至于启动成功后,应用和后端 DB 一直会有数据来往,ARP 缓存也就不会再被清掉,这也就解释了为啥问题只会出现在启动的时候。搞清楚原因以后,就不担心运行当中会出故障了。至于启动失败,重试就行了,或者 ping 一把后端 IP,产生一次 ARP 缓存就能绕过。彻底解决问题反倒不是个很紧迫的事了。

中间费了这么大劲,最后发现的问题却如此操蛋没技术含量,感觉挺失望的。不过大部分时候总是这样。隔壁老王有句名言,每个匪夷所思的问题背后,都有一个啼笑皆非的原因。

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。