3.1 消息模型:单聊、群聊与序号

场景

写消息层第一件事是定表结构。结构没定好,后面所有逻辑都在还债。我们的协作产品里既有单聊也有群聊,还有系统通知;一条消息可能是文本、图片或文件;群消息还要记谁已读谁未读。更麻烦的是,产品同学提了一个灵魂需求:聊天记录要支持按关键词搜索。所有这些汇总到一起,就是消息模型要回答的全部问题。

还在设计阶段我们就收到了量级预估:日活五十万,人均每天发送三十条消息,其中四成发生在三个晚高峰小时内,算下来高峰写入约每秒一万六千条;消息留存六个月、可按用户导出。这个量级直接排除了几个浪漫主义方案:单库单表硬扛不现实,但一开始就分库分表又过度设计。我们最终定的是先单库、把分片键(UID 或 ConvID 的哈希)留在建表语句注释里,留好扩容的口子,这个决策在第四篇存储扩容时兑现了。

方案

第一个决策是会话抽象。单聊和群聊在业务上是两个东西,但在存储层面其实是一回事:一堆消息挂在同一个会话 ID 下。我们统一用 ConvID 标识会话——单聊的 ConvID 由双方 UID 有序拼接生成,群聊的 ConvID 就是群 ID。这样单聊群聊共用一套消息表、一套拉取逻辑、一套未读数模型,代码量直接减半。唯一的例外是系统通知,它没有明确的对端,单独走一张表。ConvID 的拼接规则上线后就不能改,我们把它封进一个函数,连文档搜索的关键字都严格落库前统一小写,避免出现同会话两个 ID 的灵异事件。

第二个决策是序号策略。每个会话维护一个自增 Seq,消息落库时分配。Seq 必须满足三条:会话内严格单调、跨消息连续(允许空洞,但不允许回退)、分配要快。早期的图省事版本是拿消息表 max(seq) 加一,高并发下直接撞锁;后来的生产版本是 Redis 的 INCR,每会话一个计数器,重启不丢(见 未读数与会话列表 的持久化策略)。

方案 写入并发 实现复杂度 Seq 单调保证 我们的选择
表 max(seq) 加一 极差 低 强(但锁死) 否
数据库自增列 中 低 单调但跨会话混杂 否
Redis INCR 高 中 强(单线程) 是
雪花 ID 当 Seq 高 低 弱(时钟回拨乱序) 否

群消息的已读位置表也一并设计好:一张 group_read 表,群成员每人是 (群ID, 成员ID, 已读Seq) 三元组,用户点开群时更新。读扩散与写扩散的完整对比放在第四篇 群消息:读扩散与写扩散怎么选,此处只定字段。

第三个决策是消息内容怎么存。文本直接落 content 字段;图片、语音、文件这类富媒体,消息体里只存一段 JSON,包含下载地址、尺寸、时长与缩略图地址,真正的内容走对象存储。这样做有两个好处:消息表保持窄而均匀的行宽,写入和拉取的吞吐稳定;媒体文件的生命周期与消息解耦,过期清理互不影响。早期我们把图片 base64 塞进 content 的灰度版本,单条消息能把整行撑到两兆,拉取接口的 P99 直接飙到两秒,这个版本只活了三天。

第四个决策是全局 MsgID 的生成。序号解决会话内顺序,MsgID 解决全局唯一与跨服务追踪。我们用类雪花算法:时间戳四十一位、机器位十位、序列位十二位。机器位不自动获取本机 IP,而是读启动配置的实例编号,运维分配端口段时一并登记,这样 MsgID 反解出来的机器号能对上部署表,排查问题时一眼定位是哪台网关发的消息。

配置与代码

消息表的核心字段如下,索引按查询模式建:(ConvID, Seq) 联合索引用于按会话拉取,MsgID 唯一索引用于幂等去重:

type Message struct {
    MsgID    int64  `json:"msg_id"`    // 雪花 ID,全局唯一
    ConvID   string `json:"conv_id"`   // 会话 ID
    SenderID int64  `json:"sender_id"`
    MsgType  int8   `json:"msg_type"`
    Content  string `json:"content"`   // 文本或媒体 URL 的 JSON
    Seq      int64  `json:"seq"`       // 会话内单调递增
    CreateAt int64  `json:"create_at"` // 毫秒时间戳
}

分配 Seq 的函数封装在 store 层,业务代码只管调:

// 给会话分配下一个序号
func (s *Store) nextSeq(convID string) (int64, error) {
    key := "seq:" + convID
    return redis.Incr(ctx, key).Result() // 原子自增
}

群已读位置的更新逻辑也很薄:

func (s *Store) markGroupRead(groupID string, uid int64, seq int64) error {
    _, err := s.db.Exec(
        "replace into group_read(group_id, uid, read_seq) values(?, ?, ?)",
        groupID, uid, seq) // 高于已有值才写,见踩坑一
    return err
}

撤回占位结构也一并定义,前端按 type 渲染,并不需要为撤回单独拉接口:

type RecallEvent struct {
    MsgID   int64  `json:"msg_id"`
    Sender  int64  `json:"sender_id"`
    Operate int8   `json:"operate"` // 1 撤回 2 删除(仅自己可见)
}

还有一个实用设计:消息行里预留 flags 字段。目前只用了两位,一位标记撤回、一位标记_atall。未来产品再要加已读回执状态、密聊模式、消息引用这些功能时,不用加列也不用迁表——加列在两亿行表上是按天计的工程。

踩坑

第一个坑就是 markGroupRead 的 replace 语义。最初没做比较,客户端重连时把旧的已读位置重新上报,直接覆盖了更新更大的值,用户已读位置倒退。修复方法是 SQL 里加上 where 条件,只在传入 seq 大于现有值时更新,或者用 replace 配合应用层先读后判断。

第二个坑是搜索需求反噬表结构。为了全文搜索我们把消息正文塞进了 MySQL 的全文索引,结果写入慢了三倍,索引膨胀速度快得离谱。最后还是把搜索拆到独立的检索链路,消息表只保正文字段。这事教育我们:存储层尽量不要兼职做搜索。

第三个坑是大群的 Seq 热点。万人群的每条消息都 INCR 同一个 key,单 key QPS 到两千就开始抖。我们的做法是每条消息预取一个序号段(一次 INCRby 100),网关内存里发完再取下一段,热点直接降两个数量级,代价是序号会有空洞——前面说过允许空洞,客户端按 seq 排序不受影响。这里有个细节:段的大小按群的日均消息量定,活跃大群用一百,普通群用十,否则小群取一次段一百条消息用不完,序号断连得像掉牙。

第四个坑是消息撤回。撤回不能删行,删了之后 seq 空洞之外的客户端同步逻辑直接报错。我们的做法是把 content 替换成撤回占位结构,保留行,前端渲染成这一条消息已被撤回。占位结构上线那天还有个小插曲:只替换了 content 没改 msg_type,老版本客户端把撤回占位当图片消息解析,群里齐刷刷裂图。热修方案是撤回消息 msg_type 保持原样,把占位 JSON 塞进 content,新客户端按 flags 位识别,老客户端最多显示加一番提示。

第五个坑踩得最久: MsgID 与 Seq 的边界。搜索、举报、审核这些链路只用 MsgID;会话内排序、未读计算、拉取游标只用 Seq。早期有同事在拉取接口里用 MsgID 排序,两台网关时钟差两毫秒,同一会话相邻两条消息的 MsgID 顺序就反了,客户端界面表现为消息在跳来跳去。规范明确后,凡是面向客户端的接口一律按 (ConvID, Seq) 出数据,MsgID 只在服务端内部用。

小结

消息模型的三块基石:统一的会话抽象、单调可空洞的会话序号、全局唯一的消息 ID。表结构一旦上线改动成本极高——给两亿行的消息表加一列,在 TDSQL-C 上跑迁移的窗口要卡在凌晨三点,还得祈祷高峰别提前来——所以搜索、撤回、已读这些需求要在设计阶段就预留字段位置。我们的经验是设计评审时强制回答三个问题:这条数据两年后还在吗?产品下次会提什么字段?这个查询能走索引吗?三个都答得上来才允许建表。下一篇 写入链路:先落库再推送 就是在这套模型上跑写入流程。

群消息的 seq 分配必须按会话串行,这是拿事故换来的经验。初期我们图并发高,一个群的消息 seq 由多个 goroutine 各自原子递增,结果同一会话里消息入库顺序与 seq 大小偶尔不一致,客户端按 seq 排序时文案颠倒,群里经常出现回答跑在问题前面的尴尬场面。修复方案很朴素:每个会话一个单 worker 串行分配 seq 并串行写入库,单 worker 的吞吐足够支撑每秒数千条,真正的瓶颈从来不在分配而在磁盘。这个改动之后乱序工单彻底清零。

延伸阅读