私信系统与端到端加密:会话模型、消息时序、E2EE 与多端同步

系统讲解微型博客私信系统与端到端加密的工程实现:从可靠、有序与隐私三角出发,设计单聊群聊的会话模型与消息存储,用单调递增序号与因果序解决消息时序,详解 X3DH 密钥协商与双棘轮算法,落地多端同步的设备列表与密钥分发,并覆盖长连接投递、离线补偿、已读回执一致性、元数据保护、加密数据可检索困境与合规举报通道。

私信是社交产品里「最私密、最难做」的一环:它要求消息不丢、不重、不乱序,还要求别人(包括平台自己)看不到内容。这三个诉求互相拉扯——强一致好做但延迟高,端到端加密安全但难做多端同步与检索。本文讲透私信的工程实现:会话模型、消息时序、E2EE 密钥体系、多端同步、可靠投递、回执一致性、元数据保护与合规边界。

前置:身份与设备会话、推送与触达、多端状态同步。

目录

1. 私信的挑战:可靠、有序与隐私三角

私信系统要在三个维度同时达标,任何一维妥协都会立刻被用户感知。

三个硬指标:
□ 可靠性:消息不丢(断网、杀进程、切设备都不丢)
□ 有序性:同一会话内消息顺序在所有端一致
□ 隐私性:服务端只见密文,内容不可读

三个软指标:
□ 低延迟:P99 送达 < 500ms(在线)
□ 多端一致:手机、平板、Web 看到的会话状态一致
□ 可恢复:换机/重装后历史可重建

三角的核心矛盾在于「服务端能不能读」。E2EE 让服务端只剩密文,于是排序、去重、回执、检索这些原本靠服务端理解内容就能做的事,全部要重新设计成「只依赖元数据」的方案。

能力明文方案E2EE 方案
排序服务端序号客户端序号 + 服务端元数据
去重内容哈希消息 ID + 发送方随机数
检索全文索引本地索引或加密索引
回执服务端聚合逐设备加密回执

2. 会话模型:单聊、群聊与消息存储

会话(conversation)是私信的基本容器,单聊与群聊在数据模型上应尽量统一。

-- 会话表
CREATE TABLE conversation (
  conv_id      BIGINT PRIMARY KEY,
  type         TINYINT,          -- 1 单聊 2 群聊
  owner_uid    BIGINT,           -- 单聊时归一化后的 owner,避免双份
  created_at   BIGINT,
  last_msg_id  BIGINT,
  member_count INT
);

-- 成员表(含未读游标)
CREATE TABLE conversation_member (
  conv_id      BIGINT,
  uid          BIGINT,
  read_cursor  BIGINT,           -- 已读到的消息序号
  mute         TINYINT,
  joined_at    BIGINT,
  PRIMARY KEY (conv_id, uid)
);

单聊的经典坑是「双份会话」:A 给 B 发消息时若各建一条,就会出现两个会话 ID。解决方式是归一化 owner:conv_id = hash(min(uidA,uidB), max(uidA,uidB)),双端共享同一会话。

群聊成员上限决定架构:小群(<500)可以用「写扩散」——消息写一份、成员各自维护游标;大群(>5000)必须用「读扩散」——消息只存一份,成员按需拉取。两者的分界点由「写放大系数 × 成员数」决定。

写扩散成本 = 成员数 × 消息数
读扩散成本 = 拉取次数 × 单次消息数
选择:成员数 × 发送频率 > 阈值 → 读扩散

3. 消息时序:单调递增序号与因果序

分布式系统里「时间」不可靠,因此私信排序不能依赖物理时钟,要用逻辑序号。

序号设计:
seq = 会话内单调递增整数,由服务端分配(单聊/群聊统一)
client_msg_id = 客户端生成的 UUID,用于去重与回执匹配

排序规则:同一会话内按 seq 升序;客户端本地先乐观插入(用 client_msg_id 占位),收到服务端回执后用 seq 修正位置。

场景排序依据处理
同会话同端seq直接排序
同会话多端seq服务端为准
跨会话(引用)引用消息的 seq拉取被引用消息
时钟漂移忽略物理时钟只信 seq

E2EE 下服务端无法读内容,但仍可分配 seq——因为 seq 是元数据。真正的难点是因果序:群聊里「回复某条消息」需要表达因果关系。做法是消息携带 reply_to_seq,客户端渲染时按因果链展示,而不是按到达顺序。

因果链渲染:
msg.reply_to_seq 存在 → 先渲染被引用消息 → 再渲染当前消息
形成缩进或引用块,保证跨端一致的对话语义

4. 端到端加密:密钥交换与双棘轮

E2EE 的两块基石是「如何协商密钥」与「如何持续换钥」。

密钥协商:Signal 的 X3DH 协议用「身份密钥 + 一次性预密钥 + 签名预密钥」组合出共享密钥,即使长期密钥泄露,历史会话仍安全(前向保密)。

X3DH 输入:
  IK_A  A 的身份私钥
  EK_A  A 的临时私钥
  SPK_B B 的签名预密钥(长期)
  OPK_B B 的一次性预密钥(用完即弃)
输出:SK = KDF(DH1 ‖ DH2 ‖ DH3 ‖ DH4)

持续换钥:双棘轮(Double Ratchet)由「DH 棘轮」与「对称棘轮」组成——每收到对方新的 DH 公钥就推进 DH 棘轮,每条消息推进对称棘轮,从而保证「一条密钥只加密一条消息」,实现后向保密(Post-Compromise Security)。

双棘轮状态机:
发送:chain_key → KDF → message_key + next_chain_key
接收:收到新 DH 公钥 → 推进 DH 棘轮 → 重置接收链
性质依赖机制保障
前向保密DH 棘轮 + 临时密钥旧密钥泄露不影响历史
后向保密对称棘轮逐条换钥单条泄露不影响后续
身份认证签名预密钥 + 指纹校验防中间人

客户端应展示「安全码」(指纹),让用户可线下核对,这是对抗服务端作恶的最后一道防线。

5. 多端同步:设备列表与密钥分发

E2EE 下,一条消息要给「收件人的所有设备」各加密一份,因此设备列表与密钥分发是核心。

发送流程(1 对 N 设备):
1. 拉取接收方设备列表(device_id 列表)
2. 对每个设备执行一次棘轮加密 → 密文列表
3. 服务端按设备路由,各设备只解自己的密文

多端同步的关键设计:

  • 设备信任链:新设备加入必须由已有设备批准(扫码/确认),否则不能解密历史。
  • 发送方设备同步:自己发的消息也要加密给「自己的其他设备」,否则换机看不到自己发的内容。
  • 历史消息同步:新设备默认只看新消息;要看历史需触发「历史密钥传递」,由旧设备解密后重新加密给新设备。
设备列表变更 → 生成新会话密钥 → 用每台设备的公钥封装 → 各端解封
关键:设备列表变更后必须「换钥」,否则被移除的设备仍能解密
事件处理风险
新增设备旧设备批准 + 换钥未批准设备不可解密
移除设备立即换钥旧设备无法解新消息
设备丢失撤销 + 全量换钥需重新验证安全码

6. 消息投递:长连接、推送与离线补偿

投递链路要覆盖「在线、后台、离线」三种状态。

在线:WebSocket 长连接直投(延迟最低)
后台:厂商推送通道(APNs/FCM/厂商通道)唤醒
离线:消息落库,待上线后按 seq 增量拉取

可靠性靠三段式确认:客户端收到消息回 ACK,服务端收到 ACK 才标记「已投递」,否则在超时后重投。

投递状态机:
SENT(服务端已收) → DELIVERED(对端已收 ACK) → READ(对端已读)

超时重投:DELIVERED 前,每隔 T 秒重投,最多 N 次
去重:客户端按 client_msg_id / seq 去重,重复消息直接丢弃

离线补偿用「游标拉取」:客户端上报本地最大 seq,服务端返回 seq > cursor 的所有消息,分页拉取直到追平。注意拉取与实时推送可能重叠,必须靠 seq 去重,避免「拉取到的消息又被推送一次」。

状态通道保证
在线长连接秒级送达
后台推送唤醒后拉取
离线落库上线增量拉
弱网重投至少一次 + 去重

7. 已读回执与在线状态的一致性

回执和在线状态是「高频、小、多端」的典型场景,设计不当会放大成写风暴。

已读回执:read_cursor = max(已读 seq),只上报「游标」不上报「逐条」
在线状态:typing / online / last_seen,用 TTL 键 + 心跳维持

关键设计:

  • 回执用游标而非逐条:只上报「我已读到 seq=1000」,服务端即可推断前 1000 条已读,避免逐条写。
  • 在线状态用过期键:SETEX presence:uid 30 "online",心跳续期,超时即离线,避免「僵尸在线」。
  • typing 状态要节流:输入中状态只在变化时上报(开始/停止),且限频,否则每次按键都发一次。
状态存储过期一致性
已读游标成员表字段永久强一致(服务端为准)
在线Redis TTL30s最终一致
输入中Redis TTL5s尽力而为
最后在线Redis/DB永久最终一致

多端一致性要点:已读游标取「所有端的最大值」(一端读了就算读);在线状态取「任一端在线即在线」。这两个聚合规则要在服务端统一,避免各端各算。

8. 安全边界:元数据、备份与举报

E2EE 保护的是内容,但元数据(谁在什么时候和谁聊天)仍然暴露,这是必须明确告知用户的安全边界。

E2EE 能保护:消息内容、图片、文件(加密后上传)
E2EE 难保护:会话双方、时间戳、消息长度、设备信息

加固元数据的常见手段:

  • 密封发送(Sealed Sender):发送者身份也加密,服务端只知「发给谁」不知「谁发的」。
  • 填充与延迟:消息填充到固定长度桶,随机延迟发送,削弱流量分析。
  • 匿名凭证:用匿名凭证发送,切断发送者与账号的直接关联。

备份是 E2EE 最尴尬的地方:云端备份若可被服务端解密,等于没有 E2EE。方案有三:

备份方案服务端可读换机恢复代价
明文云备份是容易丧失 E2EE
端加密 + 密码否需密码忘记密码即丢失
端加密 + 恢复码否需恢复码用户易丢码

举报是合规刚需,但 E2EE 下服务端看不到内容。可行做法是「客户端举报」:用户主动选择要举报的消息,客户端把该条明文(连同密钥)交给审核系统——只举报、不扫描,兼顾隐私与合规。

9. 存储与合规:加密数据的可检索困境

E2EE 下服务端只剩密文,全文检索、内容审核、数据导出都失效,需要专门设计。

可检索加密的三种折中:
1. 本地索引:客户端建索引,只在本机可搜(隐私最好,换机丢)
2. 加密索引:倒排索引加密后上传,查询用可搜索加密(性能差)
3. 明文索引 + 端加密正文:索引泄露风险(不推荐)

工程上多数产品选「本地索引 + 服务端只存密文」:搜索在客户端完成,服务端只做同步。代价是换机后搜索索引要重建(拉全量密文重新索引),成本高但可接受。

数据导出与合规同样受限:GDPR 的「数据可携带」要求用户能导出自己的数据——导出的是密文,用户需要在客户端用自己的密钥解密。删除权则较好实现:删除会话时同步删除服务端的密文与元数据。

合规项明文方案E2EE 方案
全文检索服务端索引客户端本地索引
内容审核服务端扫描客户端举报 + 被动抽检
数据导出服务端导出客户端解密导出
删除权服务端删除删除密文与元数据
执法调取直接提供只能提供元数据

工程要点:私信系统的设计顺序应是「先定隐私边界,再定数据模型」——确定服务端能看什么(元数据),不能看什么(内容),然后所有排序、去重、回执、检索都基于元数据重建。可靠投递靠三段式确认 + seq 去重,多端一致靠游标聚合规则统一,E2EE 靠 X3DH 协商 + 双棘轮换钥 + 设备信任链,元数据加固靠密封发送与填充。记住:E2EE 不是「加个加密库」,而是整套「服务端不再理解内容」的架构重构。

10. 速查表与一句话记忆

问题一句话答案
单聊会话怎么归一conv_id = hash(min,max),双端共享
消息怎么排序会话内单调 seq,忽略物理时钟
怎么去重client_msg_id + seq 双保险
密钥怎么协商X3DH(身份+签名预密钥+一次性预密钥)
怎么持续换钥双棘轮:DH 棘轮 + 对称棘轮
多端怎么同步设备列表 + 逐设备加密 + 变更即换钥
投递怎么可靠三段式确认 + 超时重投 + seq 去重
回执怎么省上报已读游标而非逐条
元数据怎么保护密封发送 + 填充延迟 + 匿名凭证
E2EE 怎么合规客户端举报 + 本地索引 + 密文导出

一句话记忆:私信 = 归一化会话(hash min/max)+ 单调 seq 排序(不信时钟)+ client_msg_id 去重 + X3DH 协商(前向保密)+ 双棘轮换钥(后向保密)+ 设备信任链(变更即换钥)+ 三段式确认投递(至少一次 + 去重)+ 游标回执(省写)+ 密封发送与填充(护元数据)+ 客户端举报与本地索引(E2EE 下的合规折中)——在「可靠、有序、隐私」三角里,用元数据重建服务端能理解的一切。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「miniblog」更多文章

  1. 微型博客的可观测性与 SRE 实践:SLO、告警、容量与故障演练
  2. 国际化与全球化运营架构:文案、时区、多区域部署与合规
  3. API 设计与 GraphQL/BFF 聚合层:Schema 设计、N+1、聚合与缓存