设计并不只是存在于全新项目的开始阶段,而可能存在于软件生命周期的任何一个时间点。软件是注定要出错的,这是因为人的大脑在思考时存在局限性,或许也是源于“内存不足”。当一个问题出现了以后,在寻求解决方案时大致存在两类解决方案。第一种就是“头痛治头,脚痛治脚”,即只针对这单个问题去设计解决方法。第二种方案则是采用设计通用机制的方法去解决问题,这种方案通常除了解决已经发生的问题外,还能解决其它类似的还没有发生但我们知道将会发生的问题。第一种方案其实就是在寻求方案时只站在一个点上,而第二种则“站得更高”、更具全局性。下面通过两个具体的例子来说明什么是通过机制解决问题。

    第一个例子来源于一个呼叫处理系统(注:一个呼叫处理就是用户完整地打完一个电话的流程),假设完成一个完整的呼叫其所有处理逻辑可以由图1中的(a)表示。从软件设计的角度来看,整个处理逻辑最终将由多个软件模块共同合作来完成,现在假设是由A、B和C三个模块共同合作来完成的,如图1中的(b)。接着假设每一个模块都是采用有限状态机(Finite State Machine,或FSM,后面将简称为状态机)的方式实现的,每个模块所对应的状态机分别是FSM_A、FSM_B和FSM_C,如图1中的(c)。在很多的系统实现中,都是采用多任务(或线程)的,以提高系统的处理性能,这一点在这一呼叫处理系统中也不例外,如果系统中的每一个状态机都是由一个专门的任务进行处理的话,则我们得到图1中的(d)。也就是说,最终一个呼叫处理业务是由A、B和C三个任务协同完成的。

图1

    在大多情形下,三个任务都会设计成拥有各自的消息队列,以应对上万用户的外部或内部消息。另外,每一个用户对于呼叫处理系统来说应当是独立的,在系统中都有其独立的数据。在这种设计条件下,存在一种可能,即多个任务需要同时存取同一个用户的数据,如图2所示。当然,对于每一个用户的数据都应当设计相应的锁来保护其数据的完整性以防止出现竞争问题。另外,还要注意程序中有可能存在错误造成一个用户的数据被永久的锁上,如此一来别的任务无论如何也获取不到这一锁了。在《局部问题缩小,全局问题放大》一文中提出了对于每一个用户的锁应当采用超时的功能以防止某一任务因为获取不到锁而造成对于消息队列中的所有后续消息都将无法处理这类严重问题。

图2

    如果数据的存取设计有上锁等待超时功能,那就一定会面临另一个问题。当一个任务由于处理某一类消息需要存取一个用户的数据却因为另外一个任务当前正在使用这一用户的数据从而出现超时时,这时怎么办?这种超时可能并不是问题,而是系统负荷太重从而造成持有用户数据的任务无法在超时时间内完成操作。诚然,对于一个电信级的产品,不能在出现这种超时时简单地将消息丢弃,以等待消息的发送者进行重发,那时再看有没有机会获得用户数据。另一个最为简单的办法就是当出现超时时,进行一定的重试处理而不是简单的丢弃。比如,可以启动一个定时器,在定时器到期了以后,再对这一消息进行重新处理,这种处理方法所带来的设计可能会比较的复杂,因为每一个消息都需要考虑这里所谈到的重试问题。对于这种处理方法,笔者认为它仍没有击中问题的要害,因此,仍将其称为“头痛治头”的方法。

    静下心来想一想的话将发现,其实,数据存取的超时问题有可能发生在任何一个任务处理任何一条消息时。我们需要考虑设计一个通用机制来解决这类问题。为了彻底的解决这类问题,那就是应当让整个系统不存在存取数据时出现超时问题。如果要做到这一点,则需要让整个系统在处理消息时保证不会有多个任务同时存取同一个用户的数据。那如何做到呢?回到图1中的(c)和(d)的转化过程,采用状态机的实现方法应当没有问题,但是将一个线程与一个状态机进行一对一的绑定这种方式最终造成了出现了(d),即一个呼叫处理需要多个线程协同工作。打破这种一对一的绑定或许就能解决需要多个任务协同工作这一问题,如果我们采用一种设计,其并不将一个状态机固定地绑定在一个线程上,取而代之的是让每一个线程都可以处理任一个状态机,但是通过某一种方法让一个用户的消息只会交于一个线程处理。也就是说,这里的变化本质是将状态机绑定变成了用户绑定。一个呼叫系统中,每一个用户一定会有一定的标识方法以表征其唯一性,当收到一个消息以后,通过消息中所带的用户唯一表识符来将其分发给一个固定的线程。当一个用户的消息总是被一个线程处理时,就不存在前面提到的这类超时问题了,其相当于对于一个用户的所有消息进行了序列化处理。当然,多任务还是可以保留,因为这有助于提升系统的处理能力。至于当收到一个用户的第一条消息时,是将其分发给哪一个任务处理,这需要设计一定的算法,比如round-robin等。一旦用户的消息决定被派发给某一任务后,可以将任务信息记录在用户数据中,以保证对后面后续的消息总是派发给同一线程。这一例子给我们的启示是什么?通用机制在设计时所采用的方法更加接近问题的本质,或许有时就是本质,而不是游离于问题的表象,进而“头痛治头”。

    下面再看一个关于状态机的例子。现在假设存在两个设备,分别是A和B,它们之间通过网络进行通讯。假设正常情形下存在图3所示的通讯片段,即设备A向设备B发送一个请求(REQ),在设备B收到来自A的请求后,经过一定的消息处理以后发送回应(RSP)以回应这一请求,设备A则以确认(ACK)回应来自设备B的回应。这三个消息的来回就完成了一次完整的通讯。

图3

    接下来让我们只关注设备B,如果在设备B上是采用状态机的实现来处理消息的话,则将得到图4所示的状态机,注意其中的REQ/send RSP所表示的是在wait REQ状态如果收到REQ消息则发送RSP消息并迁移到wait ACK状态。

图4
    到目前为止一切都看起来不错,但是设备A与设备B之间的通讯是有可能出现错误的,比如当设备B发送RSP到设备A后,很有可能这一消息丢失了从而导致设备A收不到RSP,图5示例了这一情形。显然,设备A如果没有收到来自设备B的RSP的话,它将采用重传REQ的方式以进行下一次尝试。
图5
    麻烦的是,设备B的状态机在发送完了RSP后,立即迁移到了wait ACK状态。解决这一问题最为直接的办法是更改状态机的实现,以实现图6所示的状态机,即在wait ACK状态下增加对于收到REQ消息的处理。这种方法乍一看是能解决问题,但是它增加了设备B软件实现的复杂度。试想一想,现在wait ACK的扇入(fanin)数只是1,如果存在大量的状态需要迁移到wait ACK状态(即扇入数很大的情形),则需要在wait ACK状态中增加大量的应当在前一状态处理的消息,如此一来wait ACK状态的实现会非常的复杂,且有可能最终造成不可行。有此看来,这一解决方法又是“脚痛治脚”的方法,有更好的吗?
图6
    对于这一类问题相信读者也不陌生,比如TCP协议的设计就是为了帮助解决应用层数据流的重传问题的,那我们也可以采用类似的方法来解决这一问题。首先要引入一个新的抽象层,如图7所示。图中在应用FSM层之下建立了一个新的抽象层 —— 消息缓存与重发层。其思路就是,所有驱动状态机的消息(或称之为事件)都应当经由消息缓存与重发层,当应用FSM层处理完了一个消息并且需要发送回应消息时,也必须经过消息缓存与重发层。消息缓存与重发层在收到来自应用FSM层发出的回应消息时,会记录这一回应所对应的消息来源是什么。如此一来,当消息缓存层收到一个重传的消息时,只需要简单的将已缓存的回应消息发送出去就行了,而不要将这一消息送达应用FSM层。其所带来的好处是明显的,应用FSM层就不需要考虑由于消息丢失从而造成的消息重传问题,进而简化了应用FSM层的实现。
图7
    最后留给读者一个问题以作第二个例子的结束,前面讲的是设备B所发出的RSP有可能因为某种原因造成设备A收不到,那如果设备A收到了RSP但其所发出的ACK也同样因为某种原因从而造成设备B收不到,如图8所示,此时又应当如果处理呢?请读者试着分别从增加消息缓存与重发层和不增加这一层去分析应当如何解决这一问题。

图8
    这两个例子都展示了什么是通过机制解决问题,说到底就是在寻求解决方案时,需要我们具有足够的洞察力以试图找到问题的根结点。设计的方案一旦是针对问题的根结点,就能解决由其所引起的所有类似问题,而不只是几个单点。