WhatsApp 群上限 1,024 人。Signal 1,000 人。Telegram supergroup 能再多一些,但靠分片硬撑。大多数即时通讯软件都卡在几千人这一档 —— 因为它们的服务器必须把每条群消息分发(fanout)给每个成员:N 个成员就复制 N 份、路由 N 份、推送 N 次。
BlindPost 不是这样。对我们来说,发一条群消息更像发布一篇文章,而不是寄信。作者发布一次,任何有这篇"文章"的地址、又拿到"钥匙"的人,都可以来读。阅读人数没有理论上限 —— 因为"发布"这一步不会因为读者变多而变难。
我们的服务器就是存这篇加密的"文章"。任何持有群密钥的人(也就是成员)可以来拉、来解密。没有 fanout、没有按人头复制、没有成员名单。下面用标准密码学的层面讲清楚这套架构。
一条普通的群消息在普通 messenger 里走了哪些步骤
你在 WhatsApp 群里发了一条消息,服务器大致做这些事:
- 消息到达服务器,地址是 "ABC 群"。
- 服务器查 ABC 群的成员表 — 假设有 1,000 人。
- 服务器给这 1,000 个人每人的投递队列各 push 一份消息 envelope。
- 1,000 部手机各自收到一份。
这就是服务端 fanout:一条入站消息变成 N 条出站推送。服务器的 CPU、带宽、存储都跟成员数线性相关,再乘上历史发过的所有群消息。人数到几千之后这道数学就开始算不平了,所以 WhatsApp 死守 1,024 这个数。
BlindPost 的群是怎么搭出来的
BlindPost 的一个"群",在底层就是一个 X25519 公钥。仅此而已。我们的服务器在"群"那一列存的,是这个公钥的 base58 编码。
你创建一个群的时候,客户端:
- 在本地生成一对 X25519 密钥。
- 把公钥那一半当作群的永久标识符。
- 把私钥那一半,通过端到端加密,分发给你邀请的每个成员。
BlindPost 里"成员资格"的定义只有一句:谁持有这把群私钥,谁就是成员。我们的服务器上没有任何"成员名单"。成员的进群退群,本质上是私钥的进圈出圈 —— 服务器没办法枚举这个圈。
两层标识符
仔细的读者可能注意到一个尴尬的推论:既然"群就是一把 X25519 公钥",那这把密钥一旦轮换(下文会讲为什么要轮换,踢人就是),群的"密码学标识符"也跟着变了。从服务器视角看这没事;从用户视角看("我的群是不是换 ID 了?")就有事。
所以每个 BlindPost 客户端给每个群维护两层标识符:
server_group_id—— 密码学层。等于当前的 X25519 公钥。密钥轮换时跟着换。用于消息寻址和路由。local_group_id—— 建群那一刻本地生成的稳定标签,永远不变。用在你的会话列表、群设置、邀请链接,凡是用户会理解为"群身份"的地方。客户端在本地私下维护一份映射:这个稳定标签 → 当前的密码学标识符是什么。
本文其他地方说"群的公钥标识符"的时候,指的都是密码学这一层。稳定的本地标签纯粹是给 UI 用的,让底层密钥轮换的时候用户感知不到任何"群变了"。
一条群消息在 BlindPost 里走了哪些步骤
你给群发一条消息:
- 客户端临时生成一对 X25519 密钥,跟群的公钥做 ECDH 算出共享密钥,加密一次消息。
- 把一份 envelope 发给服务器,接收方写群的公钥标识符。
- 服务器把这份 envelope 存进这个群的 channel。完事。
没有 fanout 这一步。你的发送方带宽是 O(1) —— 不管这个群是 5 个人还是 5 万人,你发出去的就这一份。
成员怎么收到消息:稀疏拉取(sparse pull)
成员自己决定什么时候拉。每个成员的客户端维护一个 cursor — 它在群 channel 里读到哪个 seq 了 — 隔段时间问服务器一次:"ABC 群里 seq N 之后还有新 envelope 吗?"
这就是稀疏拉取。每个客户端自己决定:
- 什么时候问 — app 在前台?每分钟一次。后台?降频。手机关机?用户下次开机再说。
- 拉多少 — 没什么落差?跳过。落了五天?抓最新 200 条。新装客户端?可能只拉最新 50 条够看就行。
- 要不要回拉更老的历史 — 客户端自己定。
服务器把消息按群的公钥标识符索引存盘,问什么 range 给什么 range。它不跟踪谁在读、谁是成员、谁落后了。从服务器视角看,每次拉取都是"某个客户端要 ABC 群 seq N 之后的 envelope" —— 这个客户端可能是 10 万真人里的一个、可能是一个人五个设备里的一台、可能是个无聊的工具随便拿到公钥来拉看看。我们无从分辨。
成本结构整个翻了过来:
- 发送方带宽:每条消息 O(1)
- 服务器存储:每群 O(消息数),跟成员数无关
- 服务器 CPU:push O(1),pull O(1)
- 接收方带宽:每个成员只拉自己需要的那部分
"成员数"这个值,在我们整套基础设施里压根不存在。
顺带白送的隐私属性
我们不是主动想藏起群成员表的 — 我们就没地方写。服务器上根本没有"群成员"这张表。"谁在 ABC 群?" 这个问题没有数据支撑得起来。
传票最多能从我们这里拿到的是:"IP A 在某个时间窗内跟群公钥 B 之间有过加密字节往返"。A 是 B 群的成员、还是个拿着公钥乱拉的脚本 — 我们没办法告诉你。
代价
天下没有白吃的午餐:
- 成员必须主动拉。客户端不醒着的时候不会"收到"消息。我们在拉取路径的工程上投入很多(同步区间记录、空洞检测、上滑回填历史)。
- 没有服务端送达回执。服务器没法告诉你"50 个人里 47 个收到了" —— 它压根不知道这 50 个是谁。已读回执这类东西,都是端到端的客户端之间互传。
- 没有服务端管理工具。踢人、禁言、改角色,全是成员之间签名信令传递;服务器只能转发签名,不能帮你执行封禁。
但这些代价换回来的是:一个不知道你社交图的服务器,和一个"人数上限是你手机渲染极限决定的、不是我们集群分发能力决定的"群聊。
BlindPost