深入理解 IM 系统

第 1 章

最小的聊天

手机怎样问服务器“有新消息吗”,才能一条不漏、一条不重?

产品经理说:“这周给 App 加上聊天。”

App 现在有 1,000 个日活用户。Ana 和 Ben 想在 App 里互相发消息。你手上有一台服务器、一个现成的数据库,还有一周时间。

先说一句为什么要自己做。市面上有现成的云 IM 服务,接进来就能用;也有开源的 IM 服务端(比如 Matrix 的 Synapse、OpenIM),自己部署起来就有这些功能。很多团队仍然自己做,原因通常是数据要放在自己手里、产品要完全可控,或者规模大了以后成本更低。不管自己做还是用现成的,弄懂它是怎么设计的都有用,这个系列讲的就是这件事。

1. 最简单的设计

一个程序,一张表:

messages(id, conversation_id, sender_id, text, created_at)  -- id 自增
  • 发:Ana 的 App 发一个请求 POST /messages。服务器检查 Ana 能不能在这个会话里说话,把消息写进表里,回复它的 id。
  • 收:Ben 的 App 每 2 秒问一次服务器:“把 id 比我手上最大的那条还大的消息给我。”
GET /messages?after=<我手上最大的 id>

为什么按 id 问,而不是按时间问?id 只由服务器这一个地方、一个接一个地发出来,手机只要记住自己见过的最大 id 就行;按时间问就得操心同一时刻的两条消息、不同机器的时钟。按 id 问更简单。

在这个规模下,有两件事是顺带得到的:

  • 顺序:只有一个数据库发号,id 的先后就是所有人看到的先后。
  • 补齐:一台关机的手机,再打开时带着旧的 after 来问,就能拿到这期间的所有消息。

2. 跑一遍

下面是一个模拟器。它不连真的服务器,只是按规则推演:Ana 在 20 秒里发了 6 条消息(有时连发两条),Ben 的手机每 2 秒问一次。

Ben 每 2 秒问一次,网络单程 0.1 秒。Ana 的消息平均要等多久,才出现在 Ben 的手机上?

带回消息的轮询空轮询 消息等了多久

在 1,000 个用户的规模下,这个设计没有什么会坏:每条消息都到了,只到了一次。能看到的是它的两个特点:

  • 很多轮询是空的:灰色的虚线来回,问了,什么也没有。这一轮 Ana 发得很密(20 秒 6 条),空轮询还不到一半;真实的用户发得稀得多,第 4 节会算出 96% 的轮询是空的。
  • 消息要等一会儿:蓝色横条是每条消息从 Ana 按下发送到出现在 Ben 手机上的时间。刚好在 Ben 问之前到的,只等 0.2 秒左右;刚问完才到的,要等将近一个轮询间隔。平均下来是半个间隔,再加两段单程的网络时间(Ana 发上去、Ben 收回来,各 0.1 秒)。

勾上“Ben 的手机在第 6–14 秒关机”:手机重新打开时,它带着关机前的 after 去问,关机期间的 4 条消息一次全补回来。这个 after=<id>,就是后面第 6 章同步游标同步游标sync cursor每台设备一个,记着这台设备在自己的信箱里同步到了哪里。设备回来时,从游标之后接着拉。在术语表里查看的雏形。

3. 这个设计依赖的几个前提

这个设计在 v0 够用,但它依赖几件事,后面的章节会一件件遇到:

  • id 按提交顺序可见。 一行写进表里,要等事务“提交”以后,别人才查得到;而 id 是在插入时就分配好的,提交的先后不一定跟着 id 走。即使只有一个程序,它也会同时处理几个请求:如果 102 号先提交、101 号还没提交,Ben 这时来问,会拿到 102,把 after 记成 102,101 号就永远被跳过了。在每秒 1.4 条的 v0 里,这种情况极少发生,但不是不可能。简单的防法有两个:让写入一条一条地排队;或者每次多往回取一段(要比最长的事务还长),按 id 去掉重复。第 5、6 章会用每个会话自己、一个接一个发的序号解决它:序号是连续的,有空缺就说明还有消息没到。
  • id 只管顺序,不管连续。 回滚、别的会话的消息都会让 id 跳号,所以客户端不能把“id 有空缺”当成“丢了消息”;id 的先后是服务器写入的先后,也不一定是两个人按下发送的先后。
  • 发送方重试会产生重复。 如果 Ana 的请求超时了、App 又发一次,同一句话会被写进表里两次。这一章的“只到一次”说的是接收这一边;发送这一边的重复,第 3、4 章用消息 ID 解决。
  • 一次不能返回太多。 一台关机一周的手机,不可能在一个回复里拿到所有消息。实际的查询是“我的会话里、id 大于游标的消息,按 id 排,最多 100 条”,再带一个 has_more,告诉手机“还有,接着问”。

4. 估算:每 2 秒问一次,代价是多少

设计定了。接下来算算它的代价,数字来自全书共用的假设:

  • 1,000 个日活用户,高峰时 10% 在线:100 台手机开着 App。
  • 每台每 2 秒问一次:100 ÷ 2 = 每秒 50 个请求。一台服务器轻轻松松。
  • 消息量:每人每天发 40 条,一天 40,000 条,平均每秒 0.46 条,高峰时乘 3,每秒 1.4 条。
  • 单聊里,一条消息只送到对方的手机(平均 1.5 台):每秒 1.4 × 1.5 ≈ 2 次轮询能拿到东西,其余 48 次是空的,也就是 96% 的轮询是空的(发送者自己的其他设备也会收到,空的比例会再低一点点)。就算按全书假设里含群聊的“每条消息平均送到 10 台设备”算,也至少有 72% 是空的。
  • 一条消息在 Ben 的 2 秒周期里随机落下,平均要等半个周期,再加上两段单程的网络时间(共 0.2 秒):平均 1.2 秒,最长 2.2 秒。默认那一轮只有 6 条,平均 1.6 秒;模拟 10 分钟、300 条消息,平均 1.17 秒,最长 2.2 秒。

拖一拖下面的滑块,看看规模变大时会怎样:

在线的手机
100
每秒请求
50/秒
空轮询(单聊)
96%
平均等待
1.2 秒最长 2.2 秒

把日活拖到 10 万,每秒请求变成 5,000 个,平均等待还是 1.2 秒。这就是第 2 章的问题。

5. 打开这个程序和它的表:五个部分都已经在了

这一章没有“拆”任何东西,但如果打开这个程序和它的那张表看,第 0 章的四层和旁边的业务层其实都已经在了。注意这里的“收”和“发”是站在服务器的角度说的:消息层“收下”Ana 发来的消息,分发层把它“送出”给 Ben。

v0 的第一张图:第 0 章的四层和业务层都已经在了。三层挤在一个程序里,存储层就是那个数据库,业务层是程序里的几行判断。
AnaBenPOST /messages每 2 秒问一次after=<id>一个程序连接层回答每个 HTTP 请求消息层(收)检查、写入、回复 id分发层(发)回答轮询:id 更大的给你业务层(旁路)Ana 在这个会话里吗存储层数据库messages 表
  • 连接层:回答每台手机发来的 HTTP 请求。这里每次都是一个新的请求,问完就断。
  • 消息层(收):检查 Ana 能不能说话,写进表里,回复 id。
  • 分发层(发):回答轮询,把 id 更大的消息给出去。
  • 存储层:那张 messages 表。
  • 业务层:“Ana 在这个会话里吗?”

后面的每一个版本,都是因为某个部分扛不住了,才把它从这个程序里拆出来、做得更深。

6. 代价和其他答案

代价:

  • 不管有没有新消息,每台开着 App 的手机都在问:服务器白做工,手机白白耗流量,也多耗一些电。
  • App 退到后台以后,手机系统不会让它每 2 秒问一次,所以轮询只能在 App 开着的时候收消息。在后台把消息送到手机,要靠后面讲的推送。
  • 平均要等半个轮询间隔。想等得少一半,请求就要多一倍。

其他答案:

  • 按服务器的时间问,再加一点重叠、按 id 去重:用服务器写下的时间做游标,每次往回多取一点,重复的按 id 丢掉。能用,但要多做一层去重。
  • 由服务器发一个“下次从这里问”的令牌:比如开源的 Matrix 协议,客户端同步时带上服务器上次给的 since 令牌,令牌里是什么由服务器决定。思路和按 id 问一样:位置由服务器给,不靠客户端的时钟。
  • 长轮询:服务器先不回复,等有消息再回。这是从轮询走向推送的第一步,第 2 章会把这条路完整讲一遍。

7. 实践中

作者做过的第一个聊天系统,第一版也是一台机器:收消息、发消息都在一个程序里,数据放在内存中。和这一章不同的是,它从第一天起就用 WebSocket 长连接把消息推给客户端,没有经过轮询这一步。

单机、数据放内存,是第一版很自然的选择:开发快,跑得也快。要留意的是,重启或崩溃时,内存里的东西都会丢。所以这一章的最小版本也把消息写进表里;在线状态、缓存这类丢了还能重建的东西,才适合只放在内存里。

8. 这一章的决定

到这里,聊天能用了。下一章,用户涨到 10 万,大家开始嫌消息慢。