引言
在当今的互联网时代,分布式系统非常重要,但凡稍有规模的系统,基本都需要分布式系统做支撑。云计算、大数据分析、社交网络、电子商务,分布式系统无处不在。
分布式系统通过在网络中的多个计算节点之间分配和协调任务,提供了一种强大而灵活的方式来处理高并发请求,这种分散和协作带来了很多挑战。这些挑战包括但不限于数据一致性、系统可用性、负载均衡。如何保证这些,又有足够的性能,非常重要。
一叶知秋
上面所说非常宽泛,如何简单快速认知分布式系统,概念上看,或许很简单,但是上手实践,也许就会变得束手无策。
我们可以从一个单体应用,不断的提高的并发性,看具体会遇到哪些问题,我们再来一一解决问题。在解决这些问题的时候,我们能够更加深刻的理解分布式系统。这一篇文章不可能突出所有的问题,但是就像你不必看遍秋天所有的落叶,也能知道秋天的到来。
我们需要找到一个系统,在规模不断增大的时候,必然需要分布式系统来处理,而且,尽量遇到分布式系统中较为棘手的难题。
即时通讯
即时通讯系统就具备我们要找的系统的特性,而且在服务巨量用户的情况下,技术上,极具挑战性。使用这个系统有一个好处,我们天天使用,对一些它的场景了如指掌,当使用技术解决的时候,也非常容易理解。
即时通讯的萌芽
系统设计之初,我们先看一个单体应用,为了更好的集中突出问题,我们只关注用户之间的通讯。如图:

无论有多少个用户,只要我们的单体服务能够承受,用户之间发送消息,消息都可以轻松的送到。包括用户在线,离线的情况下,都很容易处理消息。在线的话,消息直接送达,并落库。不在线,消息也直接落库,只不过需要一张表来记录消息状态而已。
萌芽期就不做过多讨论,我进行下一步,单体服务已经无法满足日益增长的用户数量。
不断增加的用户
当一台服务器无法满足用户同时在线时,我们就要使用多台服务器提供服务。说起来容易,这里有很多问题,假设我们又增加了一台服务器,我们先看下图有什么问题:

我们先不考虑消息的发送,用户A是如何连接掉Server1 的?用户B又是如何连接到ServerB 的?虽然在这个图中,可以做到,但是显然是不合理的。我们需要增加系统组件,来解决这个问题。
负载均衡
我们需要用户与服务器之间添加一层,万事万物,如果变得复杂,效率就会降低,但是,为了提高我们的服务能力,这一层必须增加。看下图,还是有问题,我们要不断的抽丝剥茧:

增加了负载均衡之后,我们解决服务出入口的统一。这图乍一看,好像没问题,用户A似乎就可以跟用户B通信了,然而,虽然我看看到了他们中间好像有一个桥梁"均衡负载",但是这玩意主打一个超高性能,不提供任何业务逻辑,只是按规则高速的转发请求和响应。于是,系统再次遇到问题。
灵光乍现
在上一张图,我们灵光乍现,如果把Server1和Server2连接起来是不是就可以了?我们看图片:

我们看这条红线,于是乎,系统之间相连之后,消息顺利的发送到了用户B那里。比如,我们把2的 IP地址配置到1里,把1的IP配置到2里,也能解决问题。如果我们进一步优化,提供统一的服务IP地址列表,不止两台,多少台服务器都可以,每个服务器都知道彼此在哪里。如下图:

新增组件是服务IP地址管理,提供这个逻辑之后,服务到底是怎么运行的呢?
- 用户A向B发送消息时。
- 用户A在系统1中没有找到用户B。
- 系统1向IP服务器发出请求,获取所有服务的IP地址。
- 系统1对发现的所有服务进行遍历。
- 最终在系统2里找到了用户B。
- 于是系统1把消息发给系统2.
- 系统2把消息发送到了用户B。 这个组件辅助了一个分布式系统完成了用户跨系统通信。里面细节很多,但是,终能完成。
这个设计有什么问题呢,瓶颈在哪里呢? 如微信这样的体量,有成千上万的服务,且每秒都有成千上万的用户在发送消息,那么这个系统设计就会遇到很大的挑战:
- 每个服务都会相互询问用户是否在线,约等于分布式系统没有做到压力分配,每个系统都承受了高并发的压力。
- 每个服务在处理消息时,无法及时落库,引起消息丢失或者系统崩溃。 第一个挑战,我们可以使用Redis等内存中间件解决,但是,我们再继续在这条路上走下去就有些迷失了,因为这条路还有很多问题在未来的路上埋伏着,我们要换一条光明的道路。
但是我们一步步一步来。
消息队列中间件
上面提到,如果在某一时间段,有大量消息,服务无法处理,会导致消息丢失,甚至服务器无法响应用户请求。于是,我们引入了一个消息中间件来接收大量消息,我们还是先看图片:

在这个架构中,我们有效的解决了大量消息高并发的场景,也去掉了IP地址中间件。集群中的每个服务都连接这个这个消息队列中间件,当有用户发送消息的时候,服务器先不做任何处理,直接把消息发送到消息中间。在这过程中,可以几乎保证消息不会丢失,但是,下一步是消息的发布,为什么要使用消息的发布,因为在这个架构中,必须使用订阅/发布的模式,才能让消息被正确的用户接收到。
很明显,我们再次遇到性能瓶颈,虽然这是一个服务器集群,但是,当出现高并发的消息时,紧接着消息会被消息中间件广播到每一台服务器,每个服务器并没有被分担压力。
这次我们遇到了一个难题:如何让消息精准送达?
状态同步
如何让消息精准送达? 到这里,我们不再误入歧途了,直接使用内存服务中间件Redis来解决问题,仅仅引入一个Redis是无法解决问题的,这里我们直接上图,进行更多的改造,来解决问题。

这个图变化比较大,一下子新增的东西也比较多,接下来一一讲解这些新增的东西,和作用。
消息中间件的改动
- 我们把消息中间件配置为生产者消费者模式,也就是,也就是一条消息到来时,即使有多个server连接,也只有其中一个server消费消息。
- 而且每个serve都至少配置两个专属的消息队列,和一个离线的消息队列。
- 消息队列1: 当前server只要有用户发来消息,直接把消息塞入这个队列,不做任何逻辑处理,提供服务的处理能力。
- 消息队列2: 只有是发给当前服务器连接的客户端的消息才会进入这个队列,这里怎么做到?后续马上讲到。
- 离线消息队列:所有的服务器都会消费这个队列,你向离线用户发送的消息,都会进入这个队列,然后每个服务器都来消费这个消息队列,把他们存入数据库,写上对应状态,等待用户上线之后读取这些数据。
Redis
- 每个服务启动的时候,有生成一个唯一的标识,在应用生命周期内可全局访问这个标识。
- 每当有用户登录到这个服务器的时候,都会把用户ID作为key,服务器 ID作为value存入到Redis。
- 用户退出登录时,删除这个key。
在上面,我们改造了消息中间件,给每个server都配置了一个消息队列,用来直接写入当前服务产生的消息,同时,这个消息队列,也只有当前服务可以消费。给消息队列配置过期时间和死信队列,放在当前服务宕机或者来不消费消息,这样其它服务就可以消费死信队列的消息。
我们来看看基本的情况,当前服务消费当前服务产生的消息逻辑。
- 当收到一条消息时,查看这条消息的接收者。
- 拿着接收者ID去redis里查找这个用户在哪个服务器。
- 如果找到了,把消息重新发送到对应的服务队列里。
- 如果没有找到,意味着这是一个离线用户,把消息发送到离线队列。
现在这个队列里的每条消息都得到妥善处理,消息路由到了用户对应服务器的消息队列和离线队列。那么接下来就好办了,每个服务器都一个自己用户的队列,拿到消息,看用户是否真的在线,在线就发送消息,不在就放入离线队列。
总结
至此,一个几乎完美的架构就算是设计好了,这里一个大的组件没有讲,那就是数据库,数据库是非常成熟的组件,也无甚多讲,基本都支持分布式系统,当时当数据足够多时,也会面临分裤分表的压力,也需要不断尝试。
回顾
回顾上面:
- 我们通过负载均衡解决服务多实例问题,给服务提供统一的出入口。
- 配置粘性会话,保证用户的socket连接的稳定性。
- 为了同步用户状态,引入了redis高速内存服务。
- 为了标识用户所在的服务器,给每一个服务器生成唯一的ID
- 为了稳定的处理消息,我们配置了消息中间件。
- 解决高并发消息的方式,是给每台服务器创建独立的消息队列,服务器不做任何处理,直接塞入消息。
- 消息队列里的消息,是由消费者的消费能力决定的,消息队列支持持久化。
- 为了防止当前服务器积压的消息太多,设置了消息的过期时间以及私信队列,这样其它服务器可以处理这个死信队列里的消息。
其它
这样的系统架构,服务数百万乃至千万的用户是没有问题的。但是,当用户到达亿级的时候,不得不考虑一些成本优化。
比如我们的Redis服务,假如它只是存储了用户ID和服务ID的大量健值对,如果这些ID太长的话,很显然,大量的健值对包括维护这些健值对状态的数据会消耗大量的内存,虽然Redis的分布式系统是可以处理的,但是这样的服务费用相当高。况且我们不只是存储用户ID和服务ID的健值对,我们还有其它数据需要存储。
- 其实用户注册是一个频率较低的场景,在这种情况下,用户的ID生成,完全没必要使用uuid之类的,网上应该有很多方案,我们选择最简单的方案(当然不是数据库提供的自增ID,毕竟数据也是分布式),我们使用Redis提供的INCR命令,它支持并发安全、持久化且足够大的数字。
- server ID的生成也可以使用这个,毕竟server实例的启动更是极低的频率。
使用这种非常简单的方式,就能极大的优化Redis的存储。
这里为什么没有代码?
- 分布式系统架构是一种思想,不像某个算法,一个函数就能搞定。
- 可以说是时间吧,写这些东西不容易,在配置上适当的代码,就更加繁琐了。
- 每家业务各不相同,必须就问题来寻找解决方案。
不过,有机会,就上面的架构,开源一份代码。
感谢读完这篇文章,希望有所收获。