# 第 12 章：入群与退群

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

深入理解 IM 系统 · https://im.liko.page/zh/group-membership/

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

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

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

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

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

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

*（互动图：请在网页上查看 https://im.liko.page/zh/group-membership/）*

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

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

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

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

```sql
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。这一段就是成员区间。每人每进一次群一行，退了群的那一行不删。

全群也有一对：群的 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`](https://core.telegram.org/method/channels.togglePreHistoryHidden)），群信息里给每人一个 [`available_min_id`](https://core.telegram.org/constructor/channelFull)，这一条和比它小的都看不到。

## 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 的普通群就这样：普通群和私聊共用每个账号自己的一个信箱（[更新文档](https://core.telegram.org/api/updates)），你在群里时消息才写进你的信箱，边界天然准，不用另记区间；代价是第 10 章那笔写扩散的账。
- **只记在不在，接受边界上的误差。** 不在乎历史边界的产品（比如人人都能看全部历史的频道），这就够用。
- **Matrix：** 成员变化是房间时间线里的一个事件（[`m.room.member`](https://spec.matrix.org/latest/client-server-api/#mroommember)），新成员能看多少历史由房间的 [`m.room.history_visibility`](https://spec.matrix.org/latest/client-server-api/#room-history-visibility) 决定，和这一章同一个思路。
- **超大群成员按需加载。** 几万人的群，名单本身就大，留到第 40 章。

## 7. 这一章的决定

*（互动图：请在网页上查看 https://im.liko.page/zh/group-membership/）*

**决策卡**

- 问题：成员表只记“在不在群里”：新人一进群就能翻到第 1 条，退了又回来的人看得到他不在时的消息，退了的人换台手机连自己在群时的也拉不到；“新人可看进群前 100 条”没处写。
- 选择：入群、退群像消息一样占会话的一个 seq，和改成员表在同一个事务里。每人记 min_seq 和 max_seq（退了的不删），全群也记一对：第 N 条发给区间含 N 的人，历史只给区间里的。
- 代价：进退群从改一行变成一条发给全群的消息（500 人的群改 500 行、推 500 人）；发消息的成员检查要在锁着会话那一行时做；退了群的人那一行不能删。
- 什么时候重新考虑：手机怎么知道成员和群资料变了（第 13 章）；谁能给谁发消息（第 14 章）；成员上万的群（第 40 章）。
- 其他答案：按时间记入群、退群；每人一个信箱（Telegram 普通群）；只记在不在，接受边界误差；Matrix 的成员事件和历史可见性；超大群成员按需加载。

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