月度归档:2014年12月

责任链设计模式的实践应用

Gof在『设计模式』中将责任链模式归为行为模式,它的目的是:

使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。

说得直白一点的就是,当客户端提交了一个请求后,责任链中的对象都可以处理这个请求,并且可以选择将请求继续传递下去或者就此终止。责任链中的对象只关心自己是否有能力或者有职责处理请求,客户端则只关心请求提交上去,而不在意是谁在处理,也不在意有没有人处理。

除了解耦,责任链模式能带来一个显而易见的好处就是可以通过调整责任链中对象数量及顺序来改变请求处理流程。软件工程中使用责任链设计模式的场景很常见,比如J2EE中filter和拦截器的设计,再比如一些应用程序中插件的设计。

下图是责任链模式的标准代码结构:

责任链模式

它由三部分组成:
1. Client: 向责任链提交请求。
2. Handler: 定义一个处理请求的接口和方法,此处为HandlerRequest()。
3. ConcreteHandler: Handler接口的实现类,通常有多个。可以处理请求,也可以将请求委托给链中的下一个ConcreteHandler处理。

简单介绍完责任链模式的基本概念后,现在来看看它的具体应用。自动化部署系统在执行部署任务的时候,会涉及到中央控制服务器与部署主机(Agent)之间较多的指令交互。我们采用的是一种通用协议,将具体的部署任务(包含部署、更新、回滚、启动、停止等操作)或配置文件抽象为一个个模板,中央控制服务器通过模板生成文件后下发给Agent,最终Agent直接执行脚本或者替换配置来完成部署任务。这个过程中,中央控制服务器要从不同地方获取部署任务相关的产品信息、环境信息、实例信息、版本号、部署路径、端口号等等,然后用这些信息来填充模板中的占位符。这个模板参数收集和填充的过程很适合用责任链模式来实现。

下面是实现过程和步骤:

0x00: 定义一个抽象接口Chain:

1
2
3
public interface Chain {
    public Map<String, Object> handle(Map<String, Object> params);
}

0x01: 按需实现各种ConcreteChain,这里以获取环境信息为例,实现EnvChain:

1
2
3
4
5
6
7
8
9
10
11
public class EnvChain implements Chain {
    @Override
    public Map<String, Object> handle(Map<String, Object> params) {
        long envId = (Long) params.get("envId");
        Env env = DbAction.getEnvsById(envId);
        // ......省略代码......
        Map<String, Object> dataMap = params;
        dataMap.put("env", env);
        return dataMap;
    }
}

0x02: 实现一个TemplateHolder类,用于责任链对象的注册和暴露请求提交接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class TemplateHolder {
    private List<Chain> chainList = new ArrayList<Chain>();
 
    // 注册链对象。
    public boolean register(final Chain chain) {
        chainList.add(chain);
        return true;
    }
 
    // 暴露请求提交接口,用于客户端提交模板参数填充请求。
    public ScriptObject exec(Map<String, Object> params) {
        Map<String, Object> dataMap = new HashMap<String, Object>();
        // 根据链对象的注册顺序,依次调用对象的handle()方法处理。
        for (Chain c : chainList) {
            dataMap.putAll(c.handle(params));
        }
        String file = Merger.mergeTemplate(dataMap, tmpl);
        // ......省略代码......
    }
}

0x03: 客户端提交请求,返回一个合成好的文件。

1
2
3
4
5
6
7
TemplateHolder holder = TemplateHolder.getHolder();
// 注册链对象 
holder.register(new EnvChain()); 
Map<String, Object> params = new HashMap<String, Object>();
params.put("envId", envId);
// 客户端发出请求,最终模板在ScriptObject对象中返回。
ScriptObject so = holder.exec(params);

使用责任链模式可以为模板文件生成提供很大的灵活性和代码重用率,因为大部分模板都是需要产品、环境和实例相关的信息的,因此基本上每个ProductChain,EnvChain和InsChain都是可用重用的。另外,自动化部署系统采用的责任链模式并没有严格遵循其定义,因为所有的链中对象都负责对请求进行了部分处理,而不是某个对象单独承担。从形式上看,我们的实现同观察者模式有点类似,但有一个核心区别:观察者模式中的观察者角色之间没有依赖关系,互相独立;但在我们的场景里,链对象之间存在状态共享,比如在InsChain对象中查询实例信息,得到环境ID后再传递给EnvChain进行后续处理。

--EOF--

『漫步东京』

『漫步东京』如果没打上作者署名,如果没有配上那玩世不恭的嘻哈解说,谁也看不出这本影集居然来自荒木经惟之手。我估计这又是大师信手拈来的一本非情色主题影集,取材均来自街边。在这个大环境下,正规出版渠道几乎不可能出版荒木经惟赖以成名的私房影集,我估计是无缘得见了。

把“失焦”理解为忠实记录当时的感受,“黑白”解读为由观众填充色彩,推崇“随性之所致”的方式摄影,反模式反视觉,是乃大师与学徒的区别。原本一张废片,听了它的故事以后,恍然大悟:哦,原来还能这样解读。

至今仍懊恼没从日本带几本原版影集回来,本来有机会的,只因书店关门太早,差之毫厘。

--EOF--

RabbitMQ与Erlang

Erlang是一门动态类型的函数式编程语言,它也是一门解释型语言,由Erlang虚拟机解释执行。从语言模型上说,Erlang是基于Actor模型的实现。在Actor模型里面,万物皆Actor,每个Actor都封装着内部状态,Actor相互之间只能通过消息传递这一种方式来进行通信。对应到Erlang里,每个Actor对应着一个Erlang进程,进程之间通过消息传递进行通信。相比共享内存,进程间通过消息传递来通信带来的直接好处就是消除了直接的锁开销(不考虑Erlang虚拟机底层实现中的锁应用),那么消息传递有没有开销?有,但是基本可以忽略,消息传递在单机上的本质就是内存复制,要知道DDR3 SDRAM的内存带宽约为16GB/s。

RabbitMQ是用Erlang实现的一个高并发高可靠AMQP消息队列服务器,那么Erlang从语言层面带给RabbitMQ有哪些直接好处?

1. 高并发。
Erlang进程是完全由Erlang虚拟机进行调度和生命周期管理的一种数据结构,它与操作系统进程及线程完全没关系,也不存在数值上的什么对应关系。实际上,一个Erlang虚拟机对应一个操作系统进程,一个Erlang进程调度器对应一个操作系统线程,一般来说,有多少个CPU核就有多少个调度器。Erlang进程都非常轻量级,初始状态只包括几百个字节的PCB和233个字大小的私有堆栈,并且创建和销毁也非常轻量,都在微秒数量级。这些特征使得一个Erlang虚拟机里允许同时存在成千上万个进程,这些进程可以被公平地调度到各个CPU核上执行,因此可以在多核的场景下充分利用资源。

在RabbitMQ的进程模型里,AMQP概念里的channel和queue都被设计成了Erlang进程,从接受客户端连接请求开始,到消息最终持久化到磁盘,消息经过的进程链如下:
RabbitMQ Processes
上图中,tcp_acceptor进程用于接收客户端连接,然后初始化出rabbit_reader,rabbit_writer和rabbit_channel进程。rabbit_reader进程用于接收客户端数据,解析AMQP帧。rabbit_writer进程用于向客户端返回数据。rabbit_channel进程解析AMQP方法,然后进行消息路由等操作,是RabbitMQ的核心进程。rabbit_amqqueue_process是队列进程,rabbit_msg_store是负责进行消息持久化的进程,这两种类型进程都是RabbitMQ启动或者创建队列时创建的。从数量角度看,整个系统中存在,一个tcp_acceptor进程,一个rabbit_msg_store进程,多少个队列就有多少个rabbit_amqqueue_process进程,每条客户端连接对应一个rabbit_reader和rabbit_writer进程,至多对应65535个rabbit_channel进程。结合进程的数量,RabbitMQ的进程模型也可以描述如下图:
messages

RabbitMQ这种细粒度的进程模型正是得益于Erlang的高并发性。

2. 软实时。
Erlang的软实时特性可以从两方面看出。

首先是Erlang也是一门GC语言,但是Erlang的垃圾回收是以Erlang进程为粒度的。因为Erlang的消息传递和进程私有堆机制,使得按进程进行GC很容易实现,不必对一块内存或一个对象进行额外的引用计数。虽然对于单个进程来说,GC期间是“Stop The World”,但是前面也说过一个Erlang应用允许同时存在成千上万个进程,因此一个进程STW对于系统整体性能影响几乎微乎其微。另外,当进程需要销毁时,这个进程占用的所有内存可以直接回收,因为这块内存中的数据都是这个进程私有的。

另一方面,Erlang虚拟机对进程的调度采用的是抢占式策略。每个进程的运行周期都会分配到一定数量的reduction,当进程在进行外部模块函数调用,BIF调用,甚至算术运算都会减少reduction数量,当reduction数量减为0时,此进程交出CPU使用权,被其他进程抢占。相比于一些基于时间分片的软实时系统调度算法,reduction机制更加关注的是进程在执行期间能做多少事情,而不是时间上的绝对平均。

RabbitMQ将每个队列设计为一个Erlang进程,由于进程GC也是采用分代策略,当新老生代一起参与Major GC时,Erlang虚拟机会新开内存,根据root set将存活的对象拷贝至新空间,这个过程会造成新老内存空间同时存在,极端情况下,一个队列可能短期内需要两倍的内存占用量,因此设置内存流控阈值vm_memory_high_watermark时需要注意,默认的0.4就是一个保险的值,当超过0.5时,就有可能造成系统内存被瞬间吃完,RabbitMQ程序被系统OOM Killer杀掉。

3. 分布式。
Erlang可以说原生支持分布式,先看一段程序:

1
2
3
4
5
6
7
8
9
10
run(Node) -> 
    Pid = spawn(Node, fun ping/0), 
    Pid ! self(), 
    receive 
        ping -> ok 
    end. 
ping() ->
    receive
        From -> From ! ping 
    end.

上述程序演示的是一个分布式并发程序,运用了spawn/2,!,receive...end这三个并发原语。spawn/2用于创建进程,!用于异步发送消息,receive...end用于接收消息,注意spawn/2的第一个参数Node,它表示节点名称,这意味着对于应用来说,Pid ! Msg 就是将Msg消息发送到某一个Erlang进程,而无论这个进程是本地进程还是存在于远程的某个节点上,Erlang虚拟机会帮应用搞定一切底层通信机制。也就是说,物理节点分布式对上层Erlang应用来说是透明的。

这为实现RabbitMQ的集群和HA policy机制提供了极大的便利,主节点只要维护哪个是Pid(master),哪几个是slave_pids(slave)信息就行,根据不同的类型(publish和非publish),对队列操作进行replication。

4. 健壮性。
在Erlang的设计哲学里,有一个重要的概念就是“let it crash”。Erlang不提倡防御式编程,它认为程序既然遇到错误就应该让它崩溃,对于一个健壮的系统来说,崩溃不要紧,关键要重新起来。Erlang提供一种supervisor的行为模式,用于构建一棵健壮的进程监督树。监督者进程本身不包含业务逻辑,它只负责监控子进程,当子进程退出时,监督者进程可以根据一些策略将子进程重启。据说爱立信用Erlang写的AXD301交换机系统,可靠性为9个9,这意味着运行20年差不多有1秒的不可用时间,如此高的可靠性就是supervisor行为模式及其背后任其崩溃思想的极致体现(当然也离不开Erlang另外一个法宝代码热更新)。

在RabbitMQ里,supervisor行为模式运用得非常多,基本上每个worker进程都有相应的监督者进程,层层监督。比如下图所示的网络层进程监督树模型(已做过简化):
RabbitMQ Supervisor Tree

椭圆表示进程,矩形表示重启策略,one_for_all表示一个进程挂了其监督者进程的其他子进程也会被重启,比如一个rabbit_reader进程挂了,那么rabbit_channel_sup3进程也会重启,然后所有rabbit_channel根据AMQP协议协商后重新创建。simple_one_for_one则表示一种需要时再初始化的子进程重启策略,适用于一些动态添加子进程的场景,比如图中的rabbit_channel进程和tcp_acceptor进程。

--EOF--

『蛙』

『蛙』取书名『蛙』的象征意义深远:蛙有强大的生育能力,“蛙”与“娃”同音,“蛙”与“哇”同音,这是新生命发出的第一声,“蛙”与抟土造人的女“娲”同音,用蛙来命名一部讲述生育政策相关的小说再合适不过。莫言笔下刻画的是一个乡村妇产科医生“姑姑”,早年用科学的接生方法,从接生婆手下挽救了一对对母婴,而后由于角色转变,响应国家计划生育号召,成为坚定的计生工作人员,扼杀了数以千万计的胎儿。姑姑从最初的乡民眼中的功臣变成了人见人骂的罪人,这中间值得反思的东西够多的,从莫言最后创作的话剧来看,他认为计划生育政策的利害现在难以定论,但是至少姑姑是干净的、神圣的,说到底,这都是时代强加给个体的束缚。莫言重笔描绘的是计划生育体制下农村人民的众生相,姑姑最后时刻为王胆接生,生下陈眉,也算是作者借此希望这个变态时代下人性并未完全泯灭,生命川川不息。

计划生育政策从70年代开始执行,以一种极端的方式控制了人口暴增的局面。这个政策在城市里推行并没有多大问题,因为大部分人会迫于生计和事业,选择结扎或者绝育。但是在农村并不如此,养儿防老的概念深入人心,加上重男轻女,家家都是不生到儿子为止誓不罢休。这就造成了一系列如今看来有点荒诞的社会现象,比如家族不顾女人安危,强迫怀孕;比如一家超生,非但自己家的房子要被推倒,还会殃及到四周的邻居,这种鼓励乡民间互相揭发的行径堪称文革遗毒。阶级斗争、右派、批斗大会、苏修、人民公社、书记、主任、上级领导……这一系列的词汇读着令人反胃,注定了『蛙』所描写的半个世纪里发生的故事是充满仪式感和脸谱化的,莫言用一种压缩时间的写作方式,令人在叹息别人的一生如此短暂和虚无的同时,产生一种沧海桑田的感觉,就如同『星际穿越』中男女主角在米勒星球待了个把小时,回来后发现同伴已老去23年。

大部分现代人的观念里,对待计生的态度就是最好别人执行,自己不执行。这个态度就好像支持建个垃圾焚烧厂,但不要建在我家旁边;支持汽车限购,但是等我买来车以后,等等。作为本身也是计划生育政策下的产物,我对这个政策并没有更多的理解,能检验它的只有时间。早期推广计划生育的理由“人口不控制,粮食不够吃,衣服不够穿,教育搞不好,人口质量难提高,国家难富强”在现在看来并不充分,与当前的形势也格格不入,这就造成了计生人员一个尴尬的存在:当一件本身伤天害理的事情失去了最重要的存在意义,并可能带来十分严重的人口比例失调、老龄化等问题时,他们的执行力和决断会如“姑姑”这样强烈吗?还会强烈到宁愿牺牲自己生命去捍卫这份事业吗?毕竟在传统观念里,这是有损“阴德”的事情。

附录:『蛙』精彩描写片段片段

--EOF--

RabbitMQ镜像队列的故障恢复

RabbitMQ的mirror queue(镜像队列)机制是最简单的队列HA方案,它通过在cluster的基础上增加ha-mode、ha-param等policy选项,可以根据需求将cluster中的队列镜像到多个节点上,从而实现高可用,消除cluster模式中队列内容单点带来的风险。

在使用镜像队列之前,有几点注意事项必须熟记于心(下文中将混用主节点和master,从节点和slave):

1. 镜像队列不能作为负载均衡使用,因为每个操作在所有节点都要做一遍。

2. ha-mode参数和durable declare对exclusive队列都不生效,因为exclusive队列是连接独占的,当连接断开,队列自动删除。所以实际上这两个参数对exclusive队列没有意义。

3. 将新节点加入已存在的镜像队列时,默认情况下ha-sync-mode=manual,镜像队列中的消息不会主动同步到新节点,除非显式调用同步命令。当调用同步命令(via rabbitmqctl or web-based ui)后,队列开始阻塞,无法对其进行操作,直到同步完毕。当ha-sync-mode=automatic时,新加入节点时会默认同步已知的镜像队列。由于同步过程的限制,所以不建议在生产环境的active队列(有生产消费消息)中操作。

4. 每当一个节点加入或者重新加入(例如从网络分区中恢复回来)镜像队列,之前保存的队列内容会被清空。

5. 镜像队列有主从之分,一个主节点(master),0个或多个从节点(slave)。当master宕掉后,会在slave中选举新的master。选举算法为最早启动的节点。

6. 当所有slave都处在(与master)未同步状态时,并且ha-promote-on-shutdown policy设置为when-syned(默认)时,如果master因为主动的原因停掉,比如是通过rabbitmqctl stop命令停止或者优雅关闭OS,那么slave不会接管master,也就是说此时镜像队列不可用;但是如果master因为被动原因停掉,比如VM或者OS crash了,那么slave会接管master。这个配置项隐含的价值取向是优先保证消息可靠不丢失,放弃可用性。如果ha-promote-on-shutdown policy设置为alway,那么不论master因为何种原因停止,slave都会接管master,优先保证可用性。

7. 镜像队列中最后一个停止的节点会是master,启动顺序必须是master先起,如果slave先起,它会有30秒的等待时间,等待master启动,然后加入cluster。当所有节点因故(断电等)同时离线时,每个节点都认为自己不是最后一个停止的节点。要恢复镜像队列,可以尝试在30秒之内同时启动所有节点。

8. 对于镜像队列,客户端basic.publish操作会同步到所有节点;而其他操作则是通过master中转,再由master将操作作用于salve。比如一个basic.get操作,假如客户端与slave建立了TCP连接,首先是slave将basic.get请求发送至master,由master备好数据,返回至slave,投递给消费者。

9. 由8可知,当slave宕掉时,除了与slave相连的客户端连接全部断开之外,没有其他影响。当master宕掉时,会有以下连锁反应:1)与master相连的客户端连接全部断开。2)选举最老的slave为master。若此时所有slave处于未同步状态,则未同步部分消息丢失。3)新的master节点requeue所有unack消息,因为这个新节点无法区分这些unack消息是否已经到达客户端,亦或是ack消息丢失在到老master的通路上,亦或是丢在老master组播ack消息到所有slave的通路上。所以处于消息可靠性的考虑,requeue所有unack的消息。此时客户端可能受到重复消息。4)如果客户端连着slave,并且basic.consume消息时指定了x-cancel-on-ha-failover参数,那么客户端会收到一个Consumer Cancellation Notification通知,Java SDK中会回调Consumer接口的handleCancel()方法,故需覆盖此方法。如果不指定x-cancel-on-ha-failover参数,那么消费者就无法感知master宕机,会一直等待下去。

上面列出的注意事项整理自官方的HA文档

下面的镜像队列恢复才是本文重点:

* 前提:两个节点(A和B)组成一个镜像队列。

* 场景1:A先停,B后停。

该场景下B是master,只要先启动B,再启动A即可。或者先启动A,再在30秒之内启动B即可恢复镜像队列。

* 场景2: A, B同时停。

该场景可能是由掉电等原因造成,只需在30秒之内连续启动A和B即可恢复镜像队列。

* 场景3:A先停,B后停,且A无法恢复。

该场景是场景1的加强版,因为B是master,所以等B起来后,在B节点上调用rabbitmqctl forget_cluster_node A,解除与A的cluster关系,再将新的slave节点加入B即可重新恢复镜像队列。

* 场景4:A先停,B后停,且B无法恢复。

该场景是场景3的加强版,比较难处理,早在3.1.x时代之前貌似都没什么好的解决方法,可能是我不知道,但是现在已经有解决方法了,在3.4.2版本亲测有效。因为B是master,所以直接启动A是不行的,当A无法启动时,也就没办法在A节点上调用rabbitmqctl forget_cluster_node B了。新版本中,forget_cluster_node支持--offline参数,offline参数允许rabbitmqctl在离线节点上执行forget_cluster_node命令,迫使RabbitMQ在未启动的slave节点中选择一个作为master。当在A节点执行rabbitmqctl forget_cluster_node --offline B时,RabbitMQ会mock一个节点代表A,执行forget_cluster_node命令将B剔出cluster,然后A就能正常启动了。最后将新的slave节点加入A即可重新恢复镜像队列。

* 场景5: A先停,B后停,且A、B均无法恢复,但是能得到A或B的磁盘文件。

该场景是场景4的加强版,更加难处理。将A或B的数据库文件(默认在$RABBIT_HOME/var/lib目录中)拷贝至新节点C的目录下,再将C的hostname改成A或B的hostname。如果拷过来的是A节点磁盘文件,按场景4处理方式;如果拷过来的是B节点磁盘文件,按场景3处理方式。最后将新的slave节点加入C即可重新恢复镜像队列。

* 场景6:A先停,B后停,且A、B均无法恢复,且无法得到A或B的磁盘文件。

洗洗睡吧,该场景下已无法恢复A、B队列中的内容了。

--EOF--