一次线程同步问题的Bugfix

前两天碰到一个Java多线程未同步引起的Bug。Bug本身并没多少槽点,无非是本该同步的方法没加同步,造成最后执行结果非预期结果,但是在解决Bug的过程中发现还是有些知识点值得记录下来的。

本文分成两部分:1. Bug分析与解决。 2. synchronized注意事项。

1. Bug分析与解决

Watcher

这是出现Bug的业务场景和关键代码。Wathcer Service监听Etcd中产生的事件,然后提交给线程池处理。假设XJob是负责处理相关Etcd事件的类,其业务逻辑如代码所示,问题出在注释3-5处,当Etcd在很短的时间间隔内连续产生同一个操作对象的两个相同事件(该场景是我在设计之初未考虑到)时,会有多个XJob实例(线程)同时执行到注释3所在的区块。由于注释4处的rpc调用是个比较耗时的操作,因此可能会造成某个Entry在第一个线程中尚未写回到DB时,又被第二个线程取出来操作,导致数据不一致。

分析出问题所在后,最简单的方法就是通过synchronized关键字对相关代码进行同步。修复后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void run() {
    String id = getId(context);
    ......
    if (condition) {
        ......
    } else {
        synchronized(id.intern()) {
            Entry e = getFromDb(id, DEAD);
            rpc(e);
            update2Db(e, ALIVE);
            ......
        }
    }
}

这里有两个问题需在后面解答。

1). 在run()方法前加synchronized来修复Bug是否可行?

2). 为什么要通过synchronized(id.intern()){}而不是synchronized(id){}?

2. synchronized注意事项

1). synchronized关键字可以修饰方法(类方法/实例方法),也可以修饰代码块。本质上,进入synchronized修饰的区块都会得到一把锁,一块代码到底会不会同步执行,关键就是看多线程竞争的是否同一把锁。如此来理解所谓的对象锁和类锁就容易多了。先说对象锁,顾名思义,锁的粒度是对象。比如当synchronized修饰实例方法,它等同于用synchronized(this){}来修饰这个方法内的代码,也就是说除非是多个线程调用同一个实例(对象)的方法,否则synchronized不生效。以此类推,相同实例(对象)的不同synchronized方法也可以同步执行,因为他们的锁对象都是当前对象地址(this指针)。再说类锁,锁的粒度是类,一般修饰类方法或类变量,或者称为静态(static)方法或静态(static)变量。当synchronized修饰static方法时,它等同于用synchronized(类名.class){}来修饰这个静态方法内的代码。

举个栗子。某Test类有静态方法m1(),非静态方法m2()和m3(),三个方法都被synchronized关键字修饰,现有一个Test类的实例test。两个线程T1和T2,执行m2()和m3()时会同步,因为它们都要获得对象锁test。但是它们可以同时调用m1()和m2(),或者m1()和m3(),因为执行m1()是获得类锁Test.class,而执行m2()/m3()是获得对象锁test。

至此第1部分的两个问题原因显而易见了:

(1). run()方法前加synchronized来修复Bug是否可行?不行。因为提交给线程池执行的XJob对象不是单例的,XJob有很多个对象,不能用对象锁的方式。

(2). 为什么不是直接用synchronized(id){}?因为id对象是运行时生成的,这个String对象肯定分配在堆里。既然分配在堆里,即使id相同(equals()返回true),他们也属于不同的对象,不能用对象锁的方式。再看看String对象的intern()方法做了什么,intern()方法表示返回当前String对象在常量池中的地址,如果常量池中尚未存在该对象的值,那么就会将值放入常量池后再返回其地址。例如,两个String对象s1和s2,如果他们的值相同s1.equals(s2),那么s1.intern()==s2.intern()。当然它也有副作用,比如说频繁调用之后会引起Full GC。关于intern()实际上还有很多要注意的地方,涉及到GC性能,而且在不同的JDK版本下表现还不同,等有时间可以写一篇文章整理一下。

2). 虽然synchronized不可以修饰构造方法,但还是可以通过synchronized(this){}的方式修饰构造方法中代码块,然而这并没有什么意义,synchronized不会生效。想象一下,什么场景下多个线程需要初始化出相同地址的一个对象?如果构造方法中的初始化代码真的需要同步,可以通过类锁或其他的方式,但绝不会是synchronized(this)。

3). 进入synchronized代码块时获得的锁是由JVM负责释放的,这意味着无论代码是正常结束还是异常(checked, unchecked)退出,都不必关心锁未释放的问题。

--EOF--

Erlang进程GC引发RabbitMQ Crash

生产环境下曾数次出现过RabbitMQ异常崩溃的场景,好在镜像队列的部署方式对服务未产生影响。

我们的RabbitMQ部署环境是:

KVM: 2 VCPU, x86_64, 4G RAM, swap disabled。
OS: Debian 7.0, Linux Kernel 3.2.0-4-amd64
Erlang: R15B01 (erts-5.9.1) [64-bit] [smp:2:2]
RabbitMQ: 3.1.5, {vm_memory_high_watermark,0.4}, {vm_memory_limit,1663611699}

分析最近一次崩溃产生的erl_crash.dump文件:

=erl_crash_dump:0.1
Tue Jun 16 00:46:49 2015
Slogan: eheap_alloc: Cannot allocate 1824525600 bytes of memory (of type "old_heap").
System version: Erlang R15B01 (erts-5.9.1) [source] [64-bit] [smp:2:2] [async-threads:30] [kernel-poll:true]
Compiled: Sun Jan 27 18:19:34 2013
Taints:
Atoms: 22764
=memory
total: 1980253120
processes: 1563398383
processes_used: 1563397555
system: 416854737
atom: 703377
atom_used: 674881
binary: 385608216
code: 18475052
ets: 7643488
......

发现Crash原因是:Cannot allocate 1824525600 bytes of memory (of type "old_heap")。从数据来看,Erlang虚拟机已占用约1.98G内存(其中分配给Erlang进程的占1.56G),此时仍向操作系统申请1.82G,因为操作系统本身以及其他服务也占用一些内存,当前系统已经分不出足够的内存了,所以Erlang虚拟机崩溃。

此处有两个不符合预期的数据:
1. vm_memory_limit控制在1.67G左右,为什么崩溃时显示占用了1.98G?
2. 为什么Erlang虚拟机会额外再申请1.82G内存?

经过一些排查和总结,发现几次崩溃都是出现在队列中消息堆积较多触发了流控或者有大量unack消息requeue操作的时候,基本把原因确定为Erlang对进程进行Major GC时系统内存不足上。

在之前的『RabbitMQ与Erlang』一文中,曾简单介绍过Erlang的软实时特性,其中按进程GC是实现软实时的一个重要手段:

RabbitMQ将每个队列设计为一个Erlang进程,由于进程GC也是采用分代策略,当新老生代一起参与Major GC时,Erlang虚拟机会新开内存,根据root set将存活的对象拷贝至新空间,这个过程会造成新老内存空间同时存在,极端情况下,一个队列可能短期内需要两倍的内存占用量。

这也是RabbitMQ将内存流控的安全阈值设置为0.4的原因,即使double,也是0.8,还是安全的。0.4的意思是,当broker的内存使用量大于40%时,开始进行生产者流控,但是该参数并不承诺broker的内存使用率不大于40%。官方文档也强调过这一点:

The default memory threshold is set to 40% of installed RAM. Note that this does not prevent the RabbitMQ server from using more than 40%, it is merely the point at which publishers are throttled. Erlang's garbage collector can, in the worst case, cause double the amount of memory to be used (by default, 80% of RAM).

实际上通过RabbitMQ运行日志很容易证实,broker的实际内存使用量可以远远大于vm_memory_high_watermark设定值,比如:

$ less /var/log/rabbitmq/rabbit@10.xx.xx.xx.log
=INFO REPORT==== 16-Jun-2015::00:46:25 ===
vm_memory_high_watermark set. Memory used:2497352280 allowed:1663611699
=WARNING REPORT==== 16-Jun-2015::00:46:25 ===
memory resource limit alarm set on node 'rabbit@10.xx.xx.xx'.

内存流控阈值掐在1.6G,但是实际使用了近2.5G!再加上操作系统本身和其他服务吃掉的内存,轻松超过3G。如果此时触发Erlang进程Major GC,需要占用双倍的当前堆内存大小,那么报本文开头的old_heap堆内存分配错误也就不足为奇了。

知道了问题所在,如何解决这个问题呢?

遗憾的是,我目前也没有确切的解决方法(如有人知道,烦请告知)。但是从问题的形成原因来看,至少从以下几个方面可以显著降低问题出现概率。

1. RabbitMQ独立部署,不与其他Server共享内存资源。
2. 进一步降低vm_memory_high_watermark值,比如设置成0.3,但是这种方式会造成内存资源利用率太低。
3. 开启swap,问题在于容易造成性能和吞吐量恶化。
4. 升级RabbitMQ至新版(3.4+)。我个人觉得这个问题的根本原因在于RabbitMQ对内存的管理上,特别是早期版本,Matthew(RabbitMQ作者之一)也曾谈到过这个问题。尽管设计之初就考虑了各种优化,使得队列进程私有堆尽量小,比如当触发了某些条件,会page out到磁盘,或以二进制数据的方式存储在共享数据区,但是即便如此,在某些特定时刻,队列进程私有堆仍会消耗大量内存,这点从Management Plugin或者Remote shell里都可以看出来,而每次的“Cannot allocate old_heap”问题恰恰也总是出现在这些时刻。RabbitMQ 3.4版本发布后,官方发布了一篇新版内存管理方面的博客,从介绍上来看,要准确计算和控制当前系统使用的内存量确实非常困难。所以作者的感慨有理:

Of course, this still doesn't give a perfect count of how much memory is in use by a queue; such a thing is probably impossible in a dynamic system. But it gets us much closer.

--EOF--

Linux JRE中文字体支持

默认情况下,无论Oracle JDK还是OpenJDK,Linux JRE都不提供中文字体支持,这会给那些AWT应用开发者或者基于AWT实现的图表库(jfreechart等)使用者带来一些困扰,本来应该显示中文的地方都被方框代替:

fonts

解决方法可以很简单:

1. 拷贝中文字体到JRE目录。以宋体为例,从一台含中文字体的机器上(Mac下字体在/library/fonts目录,Windows下字体在C:\Windows\Fonts目录)拷贝SimSun.ttf文件到目标机器的$JAVA_HOME/jre/lib/fonts。
2. 重启应用(JVM)。

下面这段程序可以查看当前JRE环境支持哪些字体:

import java.awt.Font;
import java.awt.GraphicsEnvironment;

public class FontTest {

    public static void main(String[] args) {
        Font[] fonts = GraphicsEnvironment
                        .getLocalGraphicsEnvironment().getAllFonts();
        for (Font f : fonts) {
            System.out.println("Name:" + f.getFontName());
        }
    }
}

--EOF--

一种分布式系统消息服务器设计

设计一个分布式系统,首先面临的就是如何解决服务间的通信问题,同步还是异步,是采用基于消息总线的事件驱动架构(EDA)还是分布式服务框架,这在很大程度上决定了系统的可扩展性。

消息服务器的可选择性很多,比如早些年的XMPP服务器,传统的JMS服务器,还有目前比较流行的AMQP消息服务器,简单的优缺点对比如下:

类型 优点 缺点
Openfire (XMPP) 1. 成熟,稳定。
2. 适合做聊天服务,
   在IM领域(Gtalk,网易POPO等)应用广泛。
1. 消息可靠性无保障。
2. 路由策略不够灵活。
3. 集群模式不完善。
4. 协议太重。
ActiveMQ (JMS) 1. 成熟,稳定。
2. 与Java应用契合度高。
1. 路由策略不够灵活。
2. 集群模式不稳定。
RabbitMQ (AMQP) 1. 成熟,稳定。
2. 路由策略灵活。
3. 消息可靠传输。
4. 集群方案成熟。
1. 配置项多,学习和运维成本高。

本文分享一种基于RabbitMQ的消息服务器设计方案:

MQ Server

如上图所示,黄色虚线表示的是分布式系统的一个子服务,假设分别为WEB、REPO和CTRL服务。P表示生产者,C表示消费者。生产者把消息发送到一个topic模式的Exchange (X),根据route key和binding key的运算结果,将消息路由到相应队列,再由队列投递给具体的消费者。

我们将请求从服务调用方的角度分成两类:同步和异步。同步(rpc.call)是指需要应答的请求,比如获取依赖服务的状态。异步(rpc.cast)是指无需应答的请求,比如下发一个命令。

1. 对于同步请求,服务调用方将消息publish到Exchange,根据route key路由到相应队列,投递给服务提供方的消费者处理。服务提供方消费者处理完请求后,将响应内容封装成消息格式,指定route key(TYPETYPE.${HOSTNAME}),将处理结果消息返回。

2. 对于异步请求,服务调用方将消息publish到Exchange,根据route key路由到相应队列,投递给服务提供方的消费者处理。服务提供方消费者处理完请求后无需返回。

无论同步还是异步,服务调用方在发出请求(消息)后会立即返回,不会阻塞。如果是同步请求,那么只需提供回调处理函数,等待响应事件驱动。

每个服务启动后会初始化一条AMQP连接(基于TCP),该连接由3个Channel复用:一个Channel负责生产消息,一个Channel负责从TYPE(REPO/CTRL/WEB等)类型的队列消费消息,一个Channel负责从TYPE.${HOSTNAME}类型的队列消费消息。从队列的角度来看,一个TYPE.${HOSTNAME}类型队列只有一个消费者,一个TYPE类型队列可能有多个消费者。

这样的设计满足以下四类需求:

1. 点对点(P2P)或请求有状态服务:消息的route key设置为TYPE.${HOSTNAME}。比如host1上的WEB服务需要向host2上的REPO服务发送同步请求,只需将消息的route key设置为REPO.host2即可。REPO服务处理完请求后,将响应消息的route key设置为WEB.host1,发送回消息服务器。再比如REPO服务是有状态服务,伸缩性不好做,需要WEB服务做Presharding或者一致性哈希来决定调用哪个REPO服务,也跟上面一样,WEB服务根据计算好的值填充REPO.${HOSTNAME},进行点对点模式的消息通信。

2. 请求无状态服务:如果服务提供方是无状态服务,服务调用方不关心由哪个服务进行响应,那么只需将消息的route key设置为TYPE。比如CTRL是无状态服务,host1上的WEB服务只需将消息的route key设置为CTRL即可。CTRL队列会以Round-robin的调度算法将消息投递给其中的一个消费者。视WEB服务是否无状态而定,CTRL可以选择将响应消息的route key设置为WEB(假设WEB无状态)或者WEB.host1(假设WEB有状态)。

3. 组播:如果服务调用方需要与某类服务的所有节点通信,可以将消息的route key设置为TYPE.*,Exchange会将消息投递到所有TYPE.${HOSTNAME}队列。比如WEB服务需通知所有CTRL服务更新配置,只需将消息的route key设置为CTRL.*

4. 广播:如果服务调用方需要与所有服务的所有节点通信,也就是说对当前系统内所有节点广播消息,可以将消息的route key设置为*.*

本方案优缺点如下:

优点:
1. 路由策略灵活。
2. 支持负载均衡。
3. 支持高可用部署。
4. 支持消息可靠传输(生产者confirm,消费者ack,消息持久化)。
5. 支持prefetch,流控。

缺点:
1. 存在消息重复投递的可能性。
2. 对于多服务协作的场景支持度有限。比如以下场景:WEB服务发送同步请求给CTRL服务,CTRL本身无法提供该服务,需要调用REPO服务,再将REPO服务的响应结果返回给WEB。这个时候就需要CTRL缓存WEB请求,直至REPO响应。
3. 缺少超时管理,错误处理等。

以上列举缺点需要由业务方考虑解决。

顺便再提供本方案的一个简单SDK:Gear

Gear SDK提供的功能包括:
1. 基础组件(Exchange, Binding, Queue, etc)初始化。
2. 连接复用,断线重连。
3. P2P方式,组播,广播消息发送。
4. 异步消息接收。

--EOF--

Borg

4月份Google发了一篇介绍大规模集群管理系统Borg的论文『Large-scale cluster management at Google with Borg』,因为我近期也在做相关方面的工程,所以就在第一时间读了一遍,对Borg的架构及其特性有了一个大概的了解。

论文大致可分为7个部分:

1. Introduction(User perspective):用户视角的Borg介绍
2. Borg architecture: Borg整体架构
3. Scalability:Borg系统可扩展性
4. Availability:Borg系统本身的高可用性和用户服务的高可用性
5. Utilization:资源利用率
6. Isolation:隔离性
7. Lessons:经验教训

1. Introduction(User perspective)

Borg是什么?

Borg是一个集群管理工具,可以管理、调度、启动、重启、监控应用程序。可以想象成它是一个集群操作系统,产品或服务无需关心硬件层的差异,只需声明所需的资源类型和配额即可。Borg有以下特性:

1) 物理资源利用率高。
2) 服务器共享,在进程级别做隔离。
3) 应用高可用,故障恢复时间短。
4) 调度策略灵活。
5) 应用接入和使用方便。提供了完备的Job描述语言,服务发现,实时状态监控和诊断工具。

对应用来说,它能带来的几点好处:

1) 对外隐藏底层资源管理和调度、故障处理等。
2) 实现应用的高可靠和高可用。
3) 足够弹性,支持应用跑在成千上万的机器上。

Borg定义了几个概念:

1) Workload

Borg将运行其上的服务(workload)分为两类:
* prod:在线任务,长期运行、对延时敏感、面向终端用户等,比如Gmail,Google Docs, Web Search服务等。
* non-prod:离线任务,也称为批处理任务(batch),比如一些分布式计算服务等。

2) Cell

Cell是一个逻辑上的概念,它是一系列服务器的集合。一个Cell上跑一个集群管理系统Borg。其实在Cell的概念之上还有一个Cluster的概念,一个Cluster可以有一个或多个Cell(规模、用途各异),并且归属于一个IDC。Cell的规模有大有小,一个中等规模的Cell大约含有1w台服务器。一般来说,Cell中的服务器都是异构的,它们可能有着不同的规格(CPU, RAM, disk, network)、处理器类型,有着性能和容量上的差异等。

通过定义Cell可以让Borg对服务器资源进行统一抽象,作为用户就无需知道自己的应用跑在哪台机器上,也不用关心资源分配、程序安装、依赖管理、健康检查及故障恢复等。

3) Job和Task

用户以Job的形式提交应用部署请求。一个Job包含一个或者多个相同的Task,每个Task运行相同的一份应用程序,Task数量就是应用的副本数。Job也是逻辑上的概念,Task才是Borg系统的可调度单元。每个Task可以对应到Linux上的一组进程,这组进程运行在LXC容器里,Google解释了采用容器技术的原因:1. 硬件虚拟化成本太高。2. Borg开始研发那会儿,虚拟化技术并不成熟。

每个Job可以定义一些属性、元信息和优先级,优先级涉及到抢占式调度过程。Borg对Job的定义了如下4个优先级:monitoring, production, batch, and best effort(test),其中monitoring和production属于prod workload,batch和test属于non-prod workload。高优先级可以抢占低优先级,相同优先级的Job之间不能互相抢占,另外,prod workload的Job不能被抢占,也就是说,能被抢占的只有低优先级的batch和test任务。

以下是Job和Task的生命周期状态图:
Job的生命周期

从状态图中可见,无论是pending状态还是running状态的任务,Borg都支持它们动态更新,实时生效,这依赖于描述Job和Task所采用的BCL语言(GCL的一个变种)特性,具体原理和实现不明。

4) Naming

Borg的服务发现通过BNS(Borg name service)来实现。Task调度和部署完毕后,Borg把Task的主机名和端口号写入Chubby(解决分布式环境下一致性问题的文件系统),服务调用方通过RPC系统去自动发现服务。举例说明一个Task在服务发现配置中心的命名规则:
50.jfoo.ubar.cc.borg.google.com可表示在一个名为cc的Cell中由用户ubar部署的一个名为jfoo的Job下的第50个Task。

有了服务发现,即使Task下线、被抢占、故障恢复等情况出现,整个服务仍然可用的,当然服务前提是多副本的。

2. Borg architecture

Borg的整体架构分成两部分:Borgmaster和Borglet。整体架构如下:
Borg架构图

1. Borgmaster:Borgmaster是整个Borg系统逻辑上的中心节点,它又分成两个部分,Borgmaster主进程和scheduler调度进程。职责划分如下:

1) Borgmaster主进程:
* 处理客户端RPC请求,比如创建Job,查询Job等。
* 维护系统组件和服务的状态,比如服务器、Task等。
* 负责与Borglet通信。

2) scheduler进程:

用户提交Job后,Borgmaster主进程将Job信息持久化到Paxos存储池中,并且将其Task丢到一个Pending队列里。由scheduler进程定期轮询Pending队列,对Task进行调度执行。调度策略为:从高到低,相同优先级采用round-robin策略。

调度过程分为两阶段:

阶段一: feasibility checking,初步的调度可行性分析,确定一批符合调度要求的服务器。
阶段二: scoring,根据一些算法对阶段一中筛选出的服务器进行打分,选出最适合调度的一台机器。scoring过程可考虑的因素较多,比如可以考虑怎么调度才能使被抢占的低优先级任务数量最少,考虑目标服务器已经存在目标任务的包,考虑目标任务的跨域(power and failure domain)部署,考虑在目标机器上进行高低优先级任务的混合部署。

Borg前后经历了三种scoring模型:
1) E-PVM: 对多维度的资源类型降维,最终得到一个一维的数值。这是一种"worst fit"模型,各服务器负载尽量平均,好处是能够容忍应用的峰值,但是会增加资源碎片,导致大型应用找不到合适服务器部署。
2) "best fit": 与第一类“worst fit”模型相反,它会尽量利用服务器资源,优先将已分配服务器资源跑满。这种模型的好处是不会产生很多资源碎片,坏处是要求应用申请时的配额一定要准确,否则可能被kill掉。另外这种模型对存在峰值的应用类型容忍能力有限。
3) hybrid: 它综合了"worst fit"和"best fit",Borg目前采用的是这种模型,它的优势在于避免产生无法利用的资源碎片。所谓资源碎片,是指服务器某类资源耗尽,但是其他类型资源充足。比如说内存已经分配完,但是CPU使用率仍旧很低,那么这部分CPU资源是无法被利用的,就产生了CPU碎片。

可见从理论上看Borg的调度算法很完善,但是过于完善却不利于实际应用,因为每次调度都会涉及大量计算。Google从实用型角度做了一些优化:

1) Score caching: 对一次完整计算过后结果进行缓存,当服务器或者任务的状态未发生变更或者变更很少时,直接采用缓存数据,避免重复计算。
2) Equivalence classes: 因为一个Job下的所有Task都是等价的,所以可以采用等价类的思路,无需为Job中每个Task进行feasibility checking和scoring,调度多个相同的Task只需计算一次。
3) Relaxed randomization: 引入一些随机性,即每次随机选择一些机器,只要符合需求的服务器数量达到一定值时,就可以停止计算,无需每次对Cell中所有服务器进行feasibility checking。

经过上述3点优化,实验结果表明:一次未经优化的调度过程需要3天,优化后只需几百秒!另外,在实际应用中,一个Job从用户提交,到最后得到调度和执行,平均消耗时间是25s,包安装占了其中的80%时间。

2. Borglet

Borglet是部署在所有服务器上的Agent,负责接收Borgmaster进程的指令。

3. Scalability

Google在Scalability部分低调地炫耀了一把,他们在论文中宣称,目前还不确定关于中心化架构的Borg系统的极限在哪里。关于“炫耀”,在论文中多处可见,比如作者多次提到Borg已稳定运行10多年,比如作者在介绍架构设计的时候提到虽然Borg不是业界第一个集群管理系统的解决方案,但在量级上却无人出其左右。目前,一个Borg系统能处理的服务器数量级在万台这个级别,每分钟处理的Task数量超过10000,部署Borgmaster的机器规格为10-14核、50GB内存。

扩展性从三个方面的设计上说明:

1). Borgmaster主进程有5个副本,一主四从。每个副本都在内存中维护一份整个集群的状态,另外,集群状态也会被持久化到一个分布式的高可用Paxos存储系统上。在Cell初始化或者主节点挂掉的情况下,存活的副本会进行Paxos选举,选举新的主节点。主节点会在Chubby(提供了分布式的锁服务)获得一把锁来标记自己的主节点身份。所有能涉及改变集群状态的操作都由主节点完成,比如用户提交Job、结束Task执行或者机器下线之类的操作,主节点可读可写,从节点只读。主节点选举和failover的过程大概会持续10秒,在一些大规格Cell中可能会需要1分钟。

2). scheduler进程也是多副本运行的设计,一主多从。副本个数没有说明,应该没有限制。scheduler进程做的事情是状态无关的,从节点定期从主节点上获取当前集群的状态变更部分(含已分配和待调度任务之类的信息),合并到本地的集群状态副本中,然后根据已有信息对任务进行调度计算,计算完毕将调度结果通知主节点,由主节点来决定是否进行调度。比如主节点发现scheduler副本本地缓存的集群状态已经过时,那么计算出来的调度结果也是无效的,那么就不会采纳。也就是说,scheduler从节点只计算,不进行实际调度。

3). Borgmaster采用轮询的方式定期从Borglet获取该节点的状态。之所以采用"拉"模型,而不是Borglet的“推”模型,Google的考虑是:在Borg管理的集群规模上,采用“推”模型将很难进行流量控制,但是“拉”模型可以通过动态改变轮询周期来进行控制,另外,在“推”模型中,当Borgmaster从故障中恢复时,会瞬间产生流量风暴。其实在我看来,采用“推”还是“拉”模型,可能更多的是由Borgmaster本身是否带状态来决定的。Borg采用的是带状态的master设计(区分主从),因此如果Borglet采用了“推”模型,当产生上述的流量风暴时便不可控,如果Borgmaster是无状态的,那么通过一些负载均衡技术就很容易化解所谓的recovery storms,便无需考虑流量控制。此外,每个Borgmaster从节点都有自己负责轮询和通信的固定数量的Borglet,这是通过进程内部的link shard模块实现的,Borglet每次都会上报自己的全量数据,Borgmaster从节点收到全量数据后,与自己本地缓存的集群状态进行比对,如果发现该Borglet有状态变化,则把状态变化的增量部分汇报给Borgmaster主节点,由其进行最终的集群状态变更操作,经过从节点对数据的预处理后,主节点能少处理很多信息。

4. Availability

高可用性设计分为两个方面:

1) 应用高可用

* 被抢占的non-prod任务放回pending queue,等待重新调度。
* 多副本应用跨故障域部署。所谓故障域有大有小,比如相同机器、相同机架或相同电源插座等,一挂全挂。
* 对于类似服务器或操作系统升级的维护操作,避免大量服务器同时进行。
* 支持幂等性,支持客户端重复操作。
* 当服务器状态变为不可用时,要控制重新调度任务的速率。因为Borg无法区分是节点故障还是出现了短暂的网络分区,如果是后者,静静地等待网络恢复更利于保障服务可用性。
* 当某种"任务@服务器"的组合出现故障时,下次重新调度时需避免这种组合再次出现,因为极大可能会再次出现相同故障。
* 记录详细的内部信息,便于故障排查和分析。

保障应用高可用的关键性设计原则是:无论何种原因,即使Borgmaster或者Borglet挂掉、失联,都不能杀掉正在运行的服务(Task)。

2) Borg系统高可用

实践中,Borgmaster的可用性达到了4个9(99.99%)。

* Borgmaster组件多副本设计。
* 采用一些简单的和底层(low-level)的工具来部署Borg系统实例,避免引入过多的外部依赖。
* 每个Cell的Borg均独立部署,避免不同Borg系统相互影响。

5. Utilization

解决资源利用率问题是类似Borg这样的集群管理系统存在的核心价值,从已有资料来看,只有Borg完全做到了,并且能给出数据佐证。百度的Matrix也号称做到了,具体成效待实践证明。要提升资源利用率,“混部”是一个很好的解决思路,Borg和Matrix也是采用这样的思路,通过将在线任务(prod)和离线任务(non-prod, batch)混合部署,空闲时,离线任务可以充分利用计算资源,繁忙时,在线任务通过抢占的方式保证优先得到执行,合理地利用资源。这背后需要一个很完备的调度算法做支撑,遗憾的时,Borg论文中并没有过多涉及相关的技术,只能从一些数据中了解Borg带来的收益:

* 98%的服务器实现了混部。
* 90%的服务器中跑了超过25个Task和4500个线程。
* 在一个中等规模的Cell里,在线任务和离线任务独立部署比混合部署所需的服务器数量多出约20%-30%。可以简单算一笔账,Google的服务器数量在千万级别,按20%算也是百万级别,大概能省下的服务器采购费用就是百亿级别了,这还不包括省下的机房等基础设施和电费等费用。

6. Isolation

隔离性从两方面讨论:

1. 安全性隔离:采用chroot jail实现。Borg时代还没有namespace技术,chroot可以认为是一个简略版的mnt namespace。
2. 性能隔离:采用基于cgroup的容器技术实现。前文已经提到,Borg将任务类型分成在线和离线两种,在线任务(prod)是延时敏感(latency-sensitive)型的,优先级高,而离线任务(non-prod,batch)优先级低,Borg通过不同优先级之间的抢占式调度来优先保障在线任务的性能,牺牲离线任务。另一方面,Borg将资源类型也分成两类,可压榨的(compressible)和不可压榨的(non-compressible)。compressible类型的资源比如CPU、磁盘IO带宽,当这类资源成为瓶颈时,Borg不会Kill掉相应的任务。non-compressible类型的资源比如内存、磁盘空间,当这类资源成为瓶颈时,Borg会Kill掉相应的任务。

7. Lessons

Google在论文中总结了Borg系统设计的优缺点,并在它的开源版本Kubernetes(k8s)中进行了传承和改善。

关于一些不好的设计:
* Job和Task之间的关系过于耦合,不能灵活指定Task。k8s中引入label机制,可以通过label指定任何Task(k8s中称为Pod)集合。
* 部署在同一台服务器上的Task共享一个IP,通过端口号区分彼此。这使得所有Task共享服务器上的端口空间,使得端口本身也成为一种竞争资源,成为调度的一个考虑因素。k8s中每个Pod独享IP和该IP上的整个端口空间。
* Borg系统中存在一些特权用户,使用法变得复杂。

关于一些好的设计:
* 对用户暴露内部的运行和调试日志,用户可以自行排查。
* 微服务架构设计。

虽说Google在k8s中重新设计了一些Borg中不好的地方,但那都是一些皮毛,真正核心的东西比如资源调度根本没在k8s中体现。

以上是我对Borg论文学习总结,中间夹带了一些自己的理解,如有错误,还请指正。

--EOF--