# 第 13 章：群资料与群成员同步

> Ana 打开徒步群，每条消息要显示发的人在群里叫什么，输入 @ 要列出群里的人。手机上这份成员名单从哪来？怎么知道它变了？

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

[第 12 章](/zh/group-membership/)讲的是服务端：谁在群里、每人能看哪一段。可 Ana 的手机也要一份。

打开徒步群，每条消息要显示发的人在群里叫什么；输入 @，要列出群里的人；会话列表里还要有群名、群头像。这些来自两份数据：**群资料**（群名、头像、公告）和**成员名单**（每人的群昵称、角色）。每人自己的昵称和头像是用户资料，另一回事，这一章不讲。

## 1. 每次打开都拉一遍

最简单的做法：打开群时，向服务端要整份成员名单。一个成员约 40 字节（用户 ID、群昵称、角色），徒步群 500 人就是 **20 KB**。

一天打开 20 次，就是 400 KB，是 Ana 一天收到的文字（第 11 章）的 7 倍多。服务端每次也要读 500 行。可这一天里，成员只变了几次。

省流量有两种常见做法：

- **缓存一小时**，过期才重拉。流量少了一半多，可缓存期间的变化看不到：新进群的人 @ 不到，刚改的群昵称还是旧的。
- **带上手里那份的哈希**，没变就回“没变”。名单总是新的，可只要变了一个人，就整份重发：徒步群一天变 6 次，就是 120 KB。

下面是 Ana 的一天，三个群，每次打开一根柱，柱高是这次为名单下载了多少：

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

- **每次全拉**：一天 524 KB。
- **缓存 1 小时**：少了一半，可有几次打开（红圈）用的是旧名单。
- **带哈希**：只有变了之后的那次是高柱，一天 144 KB。

哈希只能说“变没变”，说不出“变了哪几个”。

## 2. 办法：每个群一个成员版本号

- **服务端：** 群的那一行（第 12 章取 seq 时锁的那一行）多记一个成员版本号。进群、退群、改群昵称、改角色，都在同一个事务里给它加一，并把新版本号写在那个成员的行上（退了的行不删，第 12 章）。
- **手机：** 存一份名单，记着它是第几版。打开群时带上版本号：“我是第 37 版。”
- **回复：** 没变，就回一个版本号，4 字节；变了，只回版本号比 37 大的那几行，每行约 44 字节。成员表上按“群 + 版本号”建个索引，读的就只是这几行。

在上面的图里选“带版本号”：同样的一天，三个群加起来半 KB 左右，每次名单都是新的。

这和第 11 章的记录版本是同一个想法：那个版本号按人记，贴在会话记录上；这个按群记，贴在成员的行上。

## 3. 群资料和“我加入的群”走会话记录

群名、头像、公告改了，会话列表要跟着变，不能等到打开群才知道。它们变得很少，就直接写进每个成员的会话记录，像第 11 章的置顶、免打扰一样涨记录版本，会话同步就把它带到每台设备。群里另发一条系统消息“Ana 把群名改成‘周六徒步’”，给人看。

新设备第一次登录时，群名和头像跟着会话记录一起下来（第 10 章）。

“我加入的群”也不用另一个版本号。Ana 进一个群，就多一条会话记录；退群，那条标成已退出（第 11 章）；两样都涨她的记录版本。通讯录里“我的群聊”，就是会话记录里她还在的那些群。在会话列表里删掉一个群聊，只是把记录标成隐藏，人还在群里。

于是一共两个版本号，各管一摊：

| 版本号 | 按谁记 | 管哪些数据 |
|---|---|---|
| 记录版本（第 11 章） | 按人 | 会话列表、我加入的群、群名头像公告 |
| 成员版本号（这一章） | 按群 | 一个群的成员名单 |

进群、退群本来就是系统消息（第 12 章），群开着的时候，手机能顺手把名单改了。版本号没跟着涨，下次打开会再收到这几行，覆盖一遍，不出错。改群昵称这种不发消息的变化，等下次打开时按版本号补上。

## 4. 算一下

- **一次打开：** 全拉，徒步群 20 KB；带版本号，没变时 4 字节，变了一个人约 44 字节。
- **一天：** 图上三个群，全拉 524 KB，带哈希 144 KB，带版本号约 0.5 KB。
- **服务端：** 全拉每次读 500 行；带版本号，没变时只读群的那一行。

## 5. 代价

- **成员表多一列、多一个索引。** 每次成员变化，多改那个成员的一行。
- **手机多存一份名单。** 只存打开过的群就行。
- **不发消息的变化有延迟。** 群开着的时候改了群昵称，要到下次打开才看到；想立刻看到，就得多推一个通知。
- **退群很久的行挪走以后，** 版本太旧的手机看不出谁走了，只能整份重拉一次。

## 6. 其他答案

- **每次打开全拉。** 小群就这么做也行：家人群 12 人，一次 480 字节。
- **Telegram：小群带版本号，大群带哈希。** 普通群的成员名单（[`chatParticipants`](https://core.telegram.org/constructor/chatParticipants)）带一个群版本号 `version`，每条成员变化的更新（如 [`updateChatParticipantAdd`](https://core.telegram.org/constructor/updateChatParticipantAdd)）也带着它，客户端据此丢掉旧的，和这一章的成员版本号是一回事，只是变化是推过来的。超级群可能有几十万人，成员按页拉（[`channels.getParticipants`](https://core.telegram.org/method/channels.getParticipants)），带上手里那页的哈希，没变就回 `channelParticipantsNotModified`；一旦变了，整页重发。
- **只拉看得到的人。** Matrix 可以只下发当前这页消息的发送者的成员信息（[lazy-loading room members](https://spec.matrix.org/latest/client-server-api/#lazy-loading-room-members)），完整名单要用时再拉。适合很大的房间。
- **每次变化推给每个成员。** 名单总是新的，可一次变化要推全群，离线的人回来还是得补。
- **超大群。** 几万人的群，名单不整份下发，按需分页、搜索，留到第 40 章。

## 7. 这一章的决定

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

**决策卡**

- 问题：手机每次打开群都拉整份成员名单：徒步群一次 20 KB，一天打开 20 次 400 KB，名单只变了几次。缓存一小时会用上旧名单；带哈希一变就整份重发。
- 选择：每个群一个成员版本号，进群、退群、改群昵称、改角色都加一，写在那个成员的行上；手机存名单和版本号，打开群时带上，没变回 4 字节，变了只回版本号更大的那几行。群名、头像、公告写进每人的会话记录，随会话同步到；群里另发一条系统消息。“我加入的群”就是会话记录里还在的那些群，用记录版本。
- 代价：成员表多一列、多一个索引；手机多存一份名单；不发消息的变化要到下次打开才看到；版本太旧的手机整份重拉。
- 什么时候重新考虑：谁能给谁发消息（第 14 章）；几台设备各存一份（第 16 章）；几万人的群（第 40 章）。
- 其他答案：每次打开全拉（小群够用）；Telegram（小群带版本号，大群带哈希）；只拉看得到的人（Matrix 懒加载）；每次变化推给每个成员。

群里的事，服务端和手机都清楚了。可私聊呢？陌生人能不能给 Ana 发，被她拉黑的人还能不能发，买家能不能找卖家，每个产品都不一样。下一章：谁能给谁发消息。
