本文来自:智趣网-C/C++语言编程技术交流论坛http://www.bczh.net
mysql
本文假定一台机器 (host) 只有一个 IP,不考虑 multihome 的状况。同时假定分布式系统中的每一台机器都正确运行了 NTP,各台机器的时间大致同步。 “进程 process”是操做系统的两大基本概念之一,指的是在内存中运行的程序。在平常交流中,“进程”这个词一般不止这一个意思。有时候咱们会说 “httpd 进程”或者“mysqld 进程”,指的实际上是 program,而不必定是特指某一个“进程”——某一次 fork() 系统调用的产物。一个“httpd 进程”重启了,它仍是“一个 httpd 进程”。本文讨论的是,如何为一个程序每次运行 的进程取一个惟一标识符。也就是说,httpd 程序第一次运行,进程是 httpd_1,它原地重启了,进程是 httpd_2。
本文所指的“进程标识符”是用来惟一标识一个程序的“一次运行”的。每次启动一个进程,这个进程应该被赋予一个惟一的标识符,与当前正在运行的全部进程都不一样;不只如此,它应该与历史上曾经运行过,目前已消亡的进程也都不一样(这两条的直接推论是,与未来可能运行的进程也都不一样)。“为每一个进程命名”在分布式系统中有至关大的实际意义,特别是在考虑 failover 的时候。由于一个程序重启以后的新进程和它的“前世进程”的状态一般不同,凡是与它打交道的其余进程(s)最好能经过它的进程标识符变动来很容易地判断该程序已经重启,而采起必要的救灾措施,防止搭错话。
本文先假定每一个服务端程序的端口是静态分配的,在公司内部有一个公用 wiki 来记录端口和程序的对应关系(而后经过 NIS 或 DNS 发布)。好比端口 11211 始终对应 memcached,其余程序不会使用 11211 端口;3306 始终留给 mysqld;3690 始终留给 svnserve。在分布式系统的初级阶段,这是一般的作法;到了高级阶段,多半会用动态分配端口号,由于端口号只有 6 万多个,是稀缺资源,在公司内部也有分配完的一天。本文只考虑 TCP 协议,不考虑 UDP 协议,“端口”都指的是 TCP 端口。
另外,咱们假定在一台机器上,一个 listening port 同时只能由一个进程使用,不考虑古老的 listen() + fork() 模型(多个进程能够 accept 同一个端口上进来的链接),关于这点陈硕已经写的不少,见《Linux 新增系统调用的启示 》《多线程服务器的适用场合 》。
错误作法
在分布式系统中,如何指涉(refer to)某一个进程呢,或者说一个进程如何取得本身的全局标识符 (如下简称 gpid)?容易想到的有两种作法:
*ip:port (port 是这个进程对外提供网络服务的端口号,通常就是它的 tcp listening port)
*host:pid
而这两种作法都有问题。为何?
若是进程自己是无状态的,或者重启了也没有关系,那么用 ip:port 来标识一个“服务”是没问题的,好比常见的 httpd 和 memcached 均可以用它们的惯用 port (80 和 11211)来标识。咱们能够在其余程序里安全地引用(refer to)“运行在 10.0.0.5:80 的那个 http 服务器”,或者“10.0.0.6:11211 的 memcached”,就算这两个 service 重启了,也不会有太恶劣的后果,大不了客户端重试一下,或者自动切换到备用地址。
若是服务是有状态的,那么 ip:port 这种标识方法就有大问题,由于客户端没法区分从头至尾和本身打交道的是一个进程仍是前后多个进程。在开发服务端程序的时候,为了能快速重启,咱们通常都会设置 SO_REUSEADDR,这样的结果是前一秒钟站在 10.0.0.7:8888 后面的进程和后一秒钟占据 10.0.0.7:8888 的进程可能不相同——服务端程序快速重启了。
比方说,考虑一个相似 GFS 的分布式文件系统的 master,若是它仅以 ip:port 来标识本身,而后它向 shadows (不是 chunk server)下达同步指令,那么 shadows 如何得知 master 是否是已经重启呢?发指令的是 master 的“前世”仍是“此生”?是否是应该拒绝“前世”的遗命?
若是考虑改为 host:pid 这种标识方式会不会好一点?我认为换汤不换药,由于 pid 的状态空间很小,重复的几率比较大。好比 Linux 的 pid 的最大值是 32768 (/proc/sys/kernel/pid_max),一个程序重启以后,得到与“前世”相同 pid 的几率是 1/32768。或许有读者不相信重启以后 pid 会重复,由于 pid 是递增的,遇到上限再回到目前空闲的最小 pid。考虑一个服务端程序 A,它的 pid 是 1234,它已经稳定运行了好几天,这期间,pid 已经增加了几个轮回(由于这台机器时常会启动一些 scripts 执行一些辅助工做)。在 A 崩溃的前一刻,最近被使用的 pid 已经回到了 1232,当 A 崩溃以后,某个守护进程启动一个脚本(pid = 1233)来清理 A 的 log,而后再重启 A 程序;这样一来,重启以后的 A 程序的 pid 碰巧和它的前世相同,都是 1234。也就是说,用 host:pid 不能惟一标识进程。
那么合在一块儿,用 ip:port:pid 呢?也不能作到惟一。它和 host:pid 面临的问题是同样的,由于 ip:port 这部分在重启以后不会变,pid 可能轮回。
我猜这时有人会想,建一个中心服务器,专门分配系统的 gpid 好了,每一个进程启动的时候向它询问本身的 gpid。这错得更远:这个全局 pid 分配器的 gpid 由谁来定?如何保证它分配的 gpid 不重复(考虑这个程序也可能意外重启)?它是否是成为系统的 single point of failure?若是要对该 gpid 分配器作容错,是否是面临分布式系统的基本问题:状态迁移?
还有一种办法,用一个足够强的随机数作 gpid,这样一来确实不会重复,可是这个 gpid 自己也没有多大额外的意义,不便于管理和维护(比方说根据 gpid 找到是哪一个机器上运行的哪一个进程)。
正确作法:以四元组 ip:port:start_time:pid 做为分布式系统中进程的 gpid,其中 start_time 是 64-bit 整数,表示进程的启动时刻(UTC 时区,muduo::Timestamp)。理由以下:
*容易保证惟一性。若是程序短期重启,那么两个进程的 pid 一定不重复(尚未走完一个轮回:就算每秒建立 1000 个进程,也要 30 多秒才会轮回,而以这么高的速度建立进程的话,服务器已基本瘫痪了。);若是程序运行了至关长一段时间再重启,那么两次启动的 start_time 一定不重复。(见下文关于时间重复的解释)
*产生这种 gpid 的成本很低(几回低成本系统调用),没有用到全局服务器,不存在 single point of failure。
*gpid 自己有意义,根据 gpid 马上就能知道是什么进程(port),运行在哪台机器(ip),是什么时间启动的,在 /proc 目录中的位置 (/proc/pid) 等,进程的资源使用状况也能够经过运行在那台机器上的监控程序报告出来。
*gpid 具备历史意义,便于未来追溯。比方说进程 crash,那么我知道它的 gpid,就能够去历史记录中查询它 crash 以前的 cpu/mem 负载有多大。
若是仅以 ip:port:start_time 做为 gpid,则不能保证惟一性,若是程序短期重启(间隔一秒或几秒),start_time 可能会往回跳变(NTP 在调时间)或暂停(正好处于闰秒期间)。关于时间跳变的问题留给下一篇博客《〈程序中的日期与时间〉第二章:计时与定时》,简单地说,计算机上的时钟不必定是单调递增的。
没有 port 怎么办?通常来讲,一个网络服务程序会侦听某个端口来提供服务,若是它是个纯粹的客户端,只主动发起链接,没有主动侦听端口,gpid 该如何分配呢?根据陈硕在《分布式系统的工程化开发方法 》一文中的观点“在程序里内置 http 服务器”,分布式系统中的每一个长期运行的、会与其余机器打交道的进程都应该提供一个管理接口,对外提供一个维修探查通道,能够查看进程的所有状态。这个管理接口就是一个 TCP server,它会侦听某个 port。
使用这样的维修通道的一个额外好处是,能够自动防止重复启动程序。由于若是重复启动,bind 到那个运维 port 的时候会出错(端口已被占用),程序会马上退出。更妙的是,不用担忧进程 crash 没来得及清理锁(若是用跨进程的 mutex 就有这个风险),进程关闭的时候操做系统会自动把它打开的 port 都关上,下一个进程能够顺利启动。
进一步,还能够把程序的名称和版本号做为 gpid 的一部分,这起到锦上添花的做用。
TCP 协议的启示
我在《分布式系统的工程化开发方法 》中提到“从 TCP 协议能学到什么?”,今天讲的这个 gpid 其实也是由 TCP 协议启发而来。TCP 用 ip:port 来表示 endpoint,两个 endpoint 构成一个 socket。这彷佛符合一开始提到的以 ip:port 来标识进程的作法。其实否则。在发起 TCP 链接的时候,为了防止前一次一样地址的链接(相同的 local_ip:local_port:remote_ip:remote_port)的干扰(称为 wandering duplicates ,即流浪的 packets),TCP 协议使用 seq 号码(这种在 SYN packet 里第一次发送的 seq 号码称为 initial sequence number, ISN)来区分本次链接和以往的链接。TCP 的这种思路与咱们防止进程的“前世”干扰“此生”很相像。内核每次新建 TCP 链接的时候会设法递增 ISN 以确保与上次链接最后使用的 seq 号码不一样。至关于说把 start_time 加入到了 endpoint 之中,这就很接近咱们后面提到的“正确的 gpid”作法了。(固然,原始 BSD 4.4 的 ISN 生成算法有安全漏洞,会致使 TCP sequence prediction attack,Linux 内核已经采用更安全的办法来生成 ISN算法