QQ是怎样创造出来的?——解密好友系统的设计
本篇介绍笔者接触的第一个后台系统,从自身见闻出发,因此涉及的内容相对比较基础,后台大牛请自觉略过。
什么是好友系统?
简单的说,好友系统是维护用户好友关系的系统。我们最熟悉的好友系统案例当属qq,实际上qq是一款即时通讯工具,凭着好友系统沉淀了海量的好友关系链,从而铸就了一个坚不可摧的商业帝国。好友系统的重要性可见一斑。
熟悉互联网产品的人都知道,当产品有了一定的用户量,往往会开发一个好友系统。其主要目的是增加用户粘性(有了好友就会常来)或者增加社区活跃度(有了好友就会多交流)。
而我的后台开发生涯就是从这样一个系统开始的。
那时候,好友系统对于我们团队大部分人来说,都是一个全新的事物,因为我们大部分人都是应届生。整个系统的架构自然不是我们一群黄毛小孩所能创造。当年的架构图已经找不到了,但是凭着一点记忆和多年来的经验积累,还是可以把当年的架构勾勒出来。
如图,好友系统的架构是常见的3层结构,包括接入层、逻辑层和数据层。
我们先从数据层讲起。
因为我们对qq太熟悉了,我们可以很容易地列出好友系统的数据主要包括用户资料、好友关系链、消息(聊天消息和系统消息)、在线状态等。
互联网产品往往要面对海量的请求并发,传统的关系型数据库比较难满足读写需求。在存储中,一般是读多写少的数据才会使用mysql等关系型数据库,而且往往还需要增加缓存来保证性能;nosql(not only sql)应该是目前的主流。
对于好友系统,用户资料和好友关系链都使用了kv存储,而消息使用公司自研的tlist(可以用redis的list替代),在线状态下面再介绍。
接着是逻辑层。
在这个系统中复杂度最高的应该是消息服务(而这个服务我并没有参与开发[捂脸])。
消息服务中,消息按类型分为聊天消息和系统消息(系统消息包括加好友消息、全局tips推送等),按状态分为在线消息和离线消息。在实现中,维护3种list:聊天消息、系统消息和离线消息。聊天消息是两个用户共享的,系统消息和离线消息每个用户独占。当用户在线时,聊天消息和系统消息是直接发送的;如果用户离线,就把消息往离线消息list存入一份,等用户再次登录时拉取。
这样看来,消息服务并不复杂?其实不然,系统设计中常规的流程设计往往是比较简单的,但是对于互联网产品,异常情况才是常态,当把各种异常情况都考虑进来时,系统就会非常复杂。
这个例子中,消息发送丢包是一种异常情况,怎么保证在丢包情况下,还能正常运行就是一个不小的问题。
常见的解决方法是收包方回复确认包,发送方如果没收到确认包就重发。但是确认包又可能丢包,那又可以给确认包增加一个确认包,这是一个永无止境的确认。
解决方法可以参考tcp的重传机制。那问题来了,我们为什么不用tcp呢?因为tcp还是比较慢的,聊天消息的可靠性没有交易数据要求那么高,丢几条消息并不会造成严重后果,但是如果用户每次发送消息后都要等很久才能被收到,那体验是很差的。
一个比较折中的方案是,收包方回复确认包,如果发送方在一定时间内没有收到确认就重发;如果收包方收到两个相同的包(自定义seq一样),去重即可。
一个面试题引发的讨论:
面试时我常常会问候选人一个问题:在分布式系统中怎样实现一个用户同时只能有一个终端在线(用户在两个地方先后登录账号,后一次登录可以把前一次登录踢下线)?这是互联网产品中非常基础的一个功能,考察的是候选人基本的架构设计能力。
设计要先从接入服务器(下称接口机)说起。接口机是好友系统对外的窗口,主要功能是维护用户连接、登录鉴权、加解密数据和向后端服务透传数据等。用户连接好友系统,首先是连接到接口机,鉴权成功后,接口机会在内存中维护用户session,后续的操作都是基于session进行。
如图所示,用户如果尝试登录两次,接口机通过session就可以将第一次的登录踢下线,从而保证只有一个终端在线。
问题解决了吗?
没有。因为实际系统肯定不会只有一台接口机,在多台接口的情况下,上面的方法就不可行了。因为每个接口机只能维护部分用户的session,所以如果用户先后连接到不同的接口机,就会造成用户多处登录的问题。
自然可以想到,解决的方法就是要维护一个用户状态的全局视图。在我们的好友系统中,称为在线状态服务。
在线状态服务,顾名思义就是维护用户的在线状态(登录时间、接口机ip等)的服务。用户登录和退出会通过接口机触发这里的状态变更。因为登录包和退出包都可能丢包,所以心跳包也用作在线状态维护(收到一次心跳标记为在线,收不到n次心跳标记为离线)。
一种常用的方法是,采用bitmap存储在线状态,具体是指在内存中分配一块空间,32位机器上的自然数一共有4294967296个,如果用一个bit来表示一个用户id(例如qq号),1代表在线,0代表离线,那么把全部自然数存储在内存只要4294967296 / (8 * 1024 * 1024) = 512mb(8bit = 1byte)。当然,实现中也可以根据需要给每个用户分配更多的bit。
于是,踢下线功能如图所示。
用户登录的时候,接口机首先查找本机上是否有session,如果有则更新session,接着给在线状态服务发送登录包,在线状态服务检查用户是否已经在线,如果在线则更新状态信息,并向上次登录的接口机ip发送踢下线包;接口机在收到踢下线包时会检查包中的用户id是否存在session,如果存在则给客户端发送踢下线包并删除session。
在实际中,踢下线功能还有很多细节问题需要注意。
又回到用户先后登录同一台接口机的情况:
图中踢下线流程是正确的,但是如果步骤10和13调换了顺序(在udp传输中是常见的)会发生什么?大家可以自己推演一下,后到的踢下线包会把第二次登录的a’踢下线了。这不是我们期望的。怎么办呢?
解决方法分几个细节,①接口机在收到13号登录成功包时,先将session a替换成session a’,然后给客户端a发生踢下线包(避免多处存活导致互相踢下线);②踢下线包中必须包含除用户id外的其他标识信息,session的唯一标识应该是id+xxx的形式(我最开始采用的是id+logintime),xxx是为了区分某次的登录;③接口机在收到踢下线包的时候只要判断id+xxx是否吻合来决定是否给客户端发踢下线包。
现实情况,问题总是千奇百怪的,好在办法总比问题多。
比如我在项目中遇到过接口机和在线状态服务时间漂移(差几秒)的情况。这样踢下线的唯一标识就不能是用户id+logintime的形式了。可以为每次的登录生成一个唯一的uuid解决。类似的问题还有很多,不再赘述。
总结一下,本篇主要介绍了好友系统的整体架构和部分模块的实现方式。分布式系统中各个模块的实现其实并不难,难点主要在于应对复杂网络环境带来的问题(如丢包、时延等)和服务器异常带来的问题(如为了应对服务器宕机会增加服务器冗余度,进而又会引发其它问题)。
好友系统虽然简单,但麻雀虽小五脏俱全,架构设计的各种技术基本都有涉及。例如分层结构、负载均衡、平行扩展、容灾、服务发现、服务器开发框架等方面,后面我会在各个不同的项目中介绍这些技术,敬请期待。
上一篇: 后台服务器框架中的瑞士军刀——MCP
下一篇: 封装axios