在日常开发中,我们经常会到"有状态"服务设计与"无状态"服务设计,何谓“无状态”?
简单来说,比如http请求一个静态网页,访问请求随便转发到服务器集群中的任何一个节点都行,集群在运行过程中,动态扩容或缩容对整体运行影响不大,就算正在访问的请求偶尔断了(比如:服务器意外重启或网络抖动),结合“重试、负载均衡”等常用手段,转发到其它节点也能继续提供服务,用户通常无感。
而有状态服务,就不能这么随便了,最典型的是websocket长连接这类应用,client通常与特定的server节点建立长连接,并且在server端维护了client的在线状态/总在线人数等状态信息,如果这台server挂了,这些状态信息就会丢失,就算client重连到其它server节点,原来那台server上的信息可能也找不回来了,另外对于集群的扩缩容,相对也更复杂,比如:如果采用一致性哈希取模算法进行负载均衡,通常需要知道集群中的节点总数,扩缩容导致节点总数变化,得做一些额外的处理。
很明显,“无状态”的设计,更易于弹性伸缩以及高可靠保障,所以大多数情况情况下,架构设计会首选“无状态”设计,但是“有状态”与”无状态”也并非一成不变,“有状态”设计通过一些优化,比如把状态信息抽取分离出来,也能变成“无状态”设计。
下面以呼叫中心为例,简单说下大体思路:
“呼叫中心客服系统”是一个典型的有状态的系统,大致结构参考下图:
假设有2通来电(用户1来电与用户2来电,分别用红色与绿色显示),来电信息经过OpenSips负载均衡转发到后面的freeswitch集群(注:来电是有状态的,1通具体的电话通常只能转发到某1个freeswitch实例) ,freeswitch再通过outbound外联方式,继续发送到某1台具体esl java服务上(注:对freeswitch esl不熟悉的同学,建议先阅读我之前写的系列文章),后面有一个分配系统,从当前空闲的客服资源中,按一定分配策略,找一个最适合的客服,即所谓的“自动呼叫分配”ACD(Automatic Call Distributor),如果业务繁忙时,可能需要分配好几轮,让用户听排队等待音,过好一阵才能分到空闲的客服进行服务。所以调用ACD Service
通常设计成异步调用,等ACD分到人后,再主动回调ESL Client,ESL Client调用对应的指令,最终把客人与客服的电话桥接(brige)起来,他俩才能听到对方的声音。 这里就有好多“状态”的问题,比如用户1咨询的是业务A,最终转发路由到Esl Client-1调用ACD Service1,这时ACD Service1 根据一定业务规则 ,分配的是懂业务A的客服回复,回调时,必须回调到Esl Client-1(这里需要1个寻找特定机器回调的处理),而且还要带上用户来电1的特定标识。否则,若回调到Esl Client-2上就串了,可能把业务A的客服,分配给来电2(如果来电2咨询的是业务B,这时候客人与客服都会很奇怪,感觉对方都是答非所问)。简言之,图中的红线、绿线不能串,否则用户1与用户2排队等到的客服,可能对自己咨询的业务并不熟悉,服务体验会很差。
另外,Freeswitch 与 ESL Client之间的Outbound连接,本身也是有状态的,每一通来电,都会有一个唯一的uuid,这通电话如果通话过程中断了,客人只能重新打进一通新电话。
如果仅关注 ESL Client 与 ACD Service之间的交互,可以把有状态的部分抽取出来,来电1与来电2如果同时进线,来电号码必然不同,而且FreeSwitch会为每1通电话,生成一个唯一的uuid,ESL Client异步调用ACD Service时,把这2个重要信息给到ACD Service(当然可能还有其它信息也会带上,比如:通常不同的被叫号码,能代表不同的咨询业务,按1咨询退款,按2查询订单之类)。ACD Service开始异步分配客服,双方可以约定好,用 "来电号码 电话的唯一标识uuid"做为key,ACD Service分配到人时,把分配结果(即:空闲的客服信息)写入redis。这样 ESL Client发起异步调用后,直接不停轮询Redis即可(当然:轮询通常要设置一个超时时间,比如:最长10分钟,防止让客人长时间等待)。相对第1张图的架构而言,去掉了ACD Service寻找特定机器异步回调ESL Client的过程,这部分的“有状态”就消除了。ESL Client与ACD Service 可以很简单的弹性伸缩而不影响业务。
不过,轮询通常有一定的时间间隔,这个不太好把握,间隔太短(比如:10ms)对系统开销太大,间隔太长(比如:1分钟)实时性又太差,影响客户体验。可以借助消息队列来改进,比如ACD Service分配到客服后,把分配结果发到Kafka队列中,ESL Client监听MQ即可,不过要注意的是,消息建议用广播的方式,让所有ESL Client都能监听消费每1条消息,如果1条消息只投递到1个消费者,业务上可能会有问题。比如:来电1的客服分配结果,ACD Service发到Kafka后,被ESL Client-2监听消费了,ESL Client-1可能就拿不到了(当然:ESL Client-2也可以判断,如果不是属于自己的分配结果 ,不做回执确认,再重新发给Kafka,但是这样就有点过于复杂,不够简洁)。
当然,对于一些可靠性要求非常高,对实时性要求也高的场合,如果担心Redis或MQ出问题(比如:网络/硬件故障/机房故障导致Redis集群或MQ不可用,也不是不可能),也可以双管齐下,上双保险,Redis轮询 MQ同时使用,Redis轮询、MQ实时消费,哪个先拿到分配结果了,以哪个为准。