深入理解 IM 系统

第 12 章

入群与退群

Ben 退了徒步群,一周后又进来;Cara 刚进群。他们各自往上翻,服务端该给哪些消息?

这一章是初稿,会在写完前八章后再修订。

第 10、11 章把会话放到了服务端:消息在会话里存一份,谁要看就来拉。这一章问:谁能拉到哪些?

徒步群 500 人,已经聊到第 4,900 条。Ben 从建群起就在,在 4,816 退了群,一周后又回来;Cara 是 4,837 进的群。规矩是产品定的,最常见的一种:在群里那段时间的消息能看;新人能不能往前翻一些,是一个设置。

1. 成员表只记“在不在”

最直接的做法:成员表一人一行,有这一行就是成员。翻历史时,是成员就全给,不是就不给。不少系统就是这样,产品本来就让人人看全部历史时也没问题;麻烦出在产品要边界的时候。

Cara 一进群,就能往上翻到第 1 条;Ben 回来后,他不在的那一周也看得到;他要是没回来,换台手机,连他在群里时的消息也拉不到了。至于“新人可看进群前 100 条”,表里没有地方写。

下面是这条 seq 轴,两行是 Ben 和 Cara 翻历史能拿到的:

Ben 退群 · 4,816Cara 入群 · 4,837Ben 回来 · 4,880BenCara1…4,7004,8004,900

能看,给了不该看,却给了能看,却没给不该看,没给

Ben 多看到 64 条不该看的;Cara 多看到 4,836 条不该看的。

  • 现在是成员就全给:Cara 多看到 4,836 条,Ben 多看到他不在时的 64 条;去掉“Ben 又进群了”,他在群时的 4,815 条一条也拉不到。
  • 按 min_seq / max_seq:每人正好是自己那几段。勾上“新人可看进群前 100 条”,Cara 的起点往前挪 100 条。

问题出在:成员表只有“在不在”一个开关,没有“从哪条到哪条”;要记“从哪条”,成员变化得先在 seq 这条线上占个位置。

2. 办法:入群、退群也占一个 seq

让成员变化走消息的路。以退群为例,像写一条消息一样,先给会话取下一个 seq,在同一个事务里改成员表、写一条系统消息:

BEGIN;
UPDATE conversations SET last_seq = last_seq + 1 WHERE id = :g RETURNING last_seq;  -- 4,816
UPDATE members SET max_seq = 4815 WHERE group_id = :g AND user_id = :ben;
INSERT INTO messages (conversation_id, seq, sender_id, text) VALUES (:g, 4816, 0, 'Ben 退出了群聊');  -- sender 0:系统
COMMIT;

取 seq 那一句锁着会话那一行(第 5 章),所以成员变化和消息排成了一条线:4,815 是 Ben 退群前的最后一条,4,817 是他走后的第一条。

成员表里每人记两个数:min_seq,他能看的第一条;max_seq,他能看的最后一条,还在群里就不设。Ben 的 max_seq 是 4,815;Cara 入群占了 4,837,她的 min_seq 就是 4,837。这一段就是成员区间成员区间membership range一个人在一个群里能看的那一段 seq:min_seq 是第一条,max_seq 是最后一条(还在群里就不设)。入群和退群像消息一样占会话的一个 seq,所以每条消息该发给谁、谁能翻到哪条历史,都按这一段算,和什么时候去算无关。全群也有一对:max_seq 是最新 seq,min_seq 以下谁都看不到(第 12 章)。在术语表里查看。每人每进一次群一行,退了群的那一行不删。

全群也有一对:群的 max_seq 就是会话的 last_seq(上面 SQL 里取的那个);群的 min_seq 以下,谁都看不到,比如群主清空了记录,或者只留最近 90 天。一个人能看的,是两段重叠的那一截。

3. 区间管四件事

  • 能翻到哪。 拉历史只给他那几行区间里的 seq。新人能不能看进群前的消息,是产品的设置:能看进群前 100 条,就把 Cara 的 min_seq 设成 4,737。Ana 清空自己的聊天记录,就把她的 min_seq 挪到最新那条之后,她每台设备都一样。
  • 发给谁。 第 N 条发给区间含 N 的人,推送也一样。退了的人那一行还在,所以晚一点再算,结果也一样。
  • 能不能发。 发消息的事务本来就锁着会话那一行取 seq,顺手查发消息的人还在不在群里。Ben 退群和他发言都要先锁会话那一行:退群在前,他那条就发不出去。
  • 各端看到什么。 “Ben 退出了群聊”就是 seq 4,816 这一条,和别的消息一起按顺序到,所以也要发给全群:一个谁都收不到的 seq,各端会当成跳号(第 5 章:seq 不连续就去补拉)。Ben 自己从他的会话记录得知(第 11 章)。

Telegram 的超级群就是这么做的:消息按群编号,每个人看到的号都一样;管理员可以设新成员看不看得到进群前的消息(channels.togglePreHistoryHidden),群信息里给每人一个 available_min_id,这一条和比它小的都看不到。

4. 算一下

  • 成员表: 每人每进一次群一行,用户 ID 8 字节、min_seq 和 max_seq 共 8 字节、角色 1 字节,约 17 字节;500 人约 8.5 KB。
  • 一次成员变化的价钱: 以前改一行;现在是一条发给全群的消息:500 人的会话记录各改一行(第 11 章),加推给 500 人。

5. 代价

  • 进退群从改一行变成发一条消息。 人员进出频繁的大群,这笔账会显出来。一次拉 30 个人进群,合成一条“Ana 邀请 Cara 等 30 人加入了群聊”,一个 seq。
  • 成员检查挪进了锁里。 发消息本来就要查是不是成员,现在要在锁着会话那一行时查;成员变化也在这一行上和消息排队。
  • 退了群的人那一行要留着。 退群前那些消息的待办跑完后,可以挪到历史表,但不能删:他补拉退群前的消息、新设备拉他那段历史,都还要查它。
  • 再进群,加一行。 旧的那行不改,翻历史把几行都算上,中间不在的那段看不到。人进进出出,行就多起来。

6. 其他答案

  • 按时间记入群、退群。 成员表记 joined_at、left_at,退了的人也留着,按消息自己的时间比。很多系统这样做,效果和这一章很接近;差在边界上:取时间的先后不等于提交的先后,几台机器的钟也不齐,进退群前后一两条可能算错,发言检查也和退群排不出先后。
  • 每人一个信箱(写扩散)。 Telegram 的普通群就这样:普通群和私聊共用每个账号自己的一个信箱(更新文档),你在群里时消息才写进你的信箱,边界天然准,不用另记区间;代价是第 10 章那笔写扩散的账。
  • 只记在不在,接受边界上的误差。 不在乎历史边界的产品(比如人人都能看全部历史的频道),这就够用。
  • Matrix: 成员变化是房间时间线里的一个事件(m.room.member),新成员能看多少历史由房间的 m.room.history_visibility 决定,和这一章同一个思路。
  • 超大群成员按需加载。 几万人的群,名单本身就大,留到第 40 章。

7. 这一章的决定

第 12 章:入群、退群也像消息一样占会话的一个 seq,和改成员名单在同一个事务里。成员表每人每进一次群一行,记着能看的第一条和最后一条(min_seq、max_seq,退了的不删),全群也记一对。第 11 章的待办只发给区间含这条 seq 的人,翻历史也只给区间里的;业务层管群、成员和角色。
Ana发件箱(本地库)Ana 的笔记本同步会话记录Ben记录版本消息 → ← ACK推送带 seq;回来带记录版本,只拿变了的一个程序连接层持有长连接;记着 用户 → 连接消息层(收)取 seq(消息、入群、退群),写入和待办分发层(发)待办发给区间含这个 seq 的成员业务层(旁路)群、成员、角色成员 min/max seq存储层唯一键与会话记录发送者+消息ID会话.last_seq会话记录+版本待办:改成员记录照片分片上传(经上传服务存进对象存储)上传地址对象存储 + CDN按文件 id 存整份文件Ben 先看缩略图,点开才下载原图

服务端知道谁在群里了。可 Ana 的手机也要一份:显示昵称和头像、@ 人,都要用成员名单;她在几百个群里,大群几百人,每次全拉一遍不现实。下一章:群资料与群成员同步。