本文假定一台机器 (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。