Files
im_server/docs/MESSAGE-STORAGE.md
T
2026-08-11 15:59:57 +08:00

38 KiB
Raw Blame History

Tinode 消息存储机制详解

本文档详细介绍 Tinode 消息的存储机制,包括数据结构、存储流程、数据库 Schema 和查询方式。


目录

  1. 存储架构概览
  2. 数据库表结构
  3. 消息数据结构
  4. 消息存储流程
  5. 消息内容格式
  6. 消息序号机制
  7. 消息删除机制
  8. 消息查询
  9. 各数据库实现差异

存储架构概览

整体架构

┌─────────────────────────────────────────────────────────────────────────────┐
│                              应用层                                          │
│   ┌─────────────────────────────────────────────────────────────────────┐   │
│   │  Topic.handlePublish() -> 消息处理入口                               │   │
│   └─────────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                              存储抽象层                                      │
│   ┌─────────────────────────────────────────────────────────────────────┐   │
│   │  store.Messages.Save() -> 消息保存接口                               │   │
│   │  store.Messages.GetAll() -> 消息查询接口                             │   │
│   │  store.Messages.DeleteList() -> 消息删除接口                         │   │
│   └─────────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                              数据库适配层                                    │
│   ┌─────────┐   ┌─────────┐   ┌─────────┐   ┌─────────┐                   │
│   │  MySQL  │   │PostgreSQL│   │ MongoDB │   │RethinkDB│                   │
│   │ adapter │   │ adapter │   │ adapter │   │ adapter │                   │
│   └─────────┘   └─────────┘   └─────────┘   └─────────┘                   │
└─────────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                              数据库层                                        │
│   ┌─────────────────────────────────────────────────────────────────────┐   │
│   │  messages 表/集合 - 存储消息内容                                     │   │
│   │  dellog 表/集合 - 记录删除操作                                       │   │
│   │  topics 表/集合 - 存储话题元数据                                     │   │
│   └─────────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────────┘

相关文件

文件 作用
server/store/types/types.go 消息数据结构定义
server/store/store.go 存储抽象层实现
server/db/adapter.go 数据库适配器接口
server/db/mysql/adapter.go MySQL 实现
server/db/postgres/adapter.go PostgreSQL 实现
server/db/mongodb/adapter.go MongoDB 实现
server/db/mysql/schema.sql MySQL 表结构
server/topic.go 消息处理逻辑

数据库表结构

MySQL Schema

-- 消息表
CREATE TABLE messages(
    id          INT NOT NULL AUTO_INCREMENT,   -- 自增主键
    createdat   DATETIME(3) NOT NULL,          -- 创建时间(毫秒精度)
    updatedat   DATETIME(3) NOT NULL,          -- 更新时间
    deletedat   DATETIME(3),                    -- 删除时间(软删除)
    delid       INT DEFAULT 0,                  -- 删除操作ID
    seqid       INT NOT NULL,                  -- 消息序号(Topic内递增)
    topic       CHAR(25) NOT NULL,             -- Topic名称(如 grpXXX)
    `from`      BIGINT NOT NULL,               -- 发送者用户ID(数值)
    head        JSON,                          -- 消息头(元数据)
    content     JSON,                          -- 消息内容(JSON格式)

    PRIMARY KEY(id),
    UNIQUE INDEX messages_topic_seqid (topic, seqid)
);

-- 删除日志表(记录软删除)
CREATE TABLE dellog(
    id          INT NOT NULL AUTO_INCREMENT,
    topic       CHAR(25) NOT NULL,             -- Topic名称
    deletedfor  BIGINT NOT NULL DEFAULT 0,     -- 删除目标用户ID
    delid       INT NOT NULL,                  -- 删除操作ID
    low         INT NOT NULL,                  -- 删除范围起始seqid
    hi          INT NOT NULL,                  -- 删除范围结束seqid(不含)

    PRIMARY KEY(id),
    FOREIGN KEY(topic) REFERENCES topics(name),
    INDEX dellog_topic_delid_deletedfor(topic, delid, deletedfor),
    INDEX dellog_topic_deletedfor_low_hi(topic, deletedfor, low, hi)
);

PostgreSQL Schema

CREATE TABLE messages(
    id          SERIAL PRIMARY KEY,
    createdat   TIMESTAMP(3) NOT NULL,
    updatedat   TIMESTAMP(3) NOT NULL,
    deletedat   TIMESTAMP(3),
    delid       INT DEFAULT 0,
    seqid       INT NOT NULL,
    topic       CHAR(25) NOT NULL,
    "from"      BIGINT NOT NULL,
    head        JSONB,
    content     JSONB,

    UNIQUE INDEX messages_topic_seqid (topic, seqid)
);

MongoDB Schema

// messages 集合
{
    "_id": ObjectId("..."),           // MongoDB ObjectId
    "createdat": ISODate("..."),
    "updatedat": ISODate("..."),
    "deletedat": ISODate("..."),
    "delid": NumberInt(0),
    "seqid": NumberInt(1),
    "topic": "grpABC123",
    "from": "usrXYZ456",
    "head": {
        "mime": "text/x-drafty",
        "attachments": ["file123"]
    },
    "content": {
        "txt": "Hello World",
        "fmt": [...]
    }
}

// 索引
db.messages.createIndex({ "topic": 1, "seqid": 1 }, { unique: true })

消息数据结构

Go 结构体定义

// server/store/types/types.go

// Message 消息结构体
type Message struct {
    ObjHeader `bson:",inline"`              // 基础头部(ID、时间戳)

    DeletedAt *time.Time `json:"DeletedAt,omitempty" bson:",omitempty"`
    DelId     int        `json:"DelId,omitempty" bson:",omitempty"`
    DeletedFor []SoftDelete `json:"DeletedFor,omitempty" bson:",omitempty"`

    SeqId     int       // 消息序号(Topic内唯一递增)
    Topic     string    // Topic名称
    From      string    // 发送者UID(不带 usr 前缀)
    Head      KVMap     // 消息头(键值对元数据)
    Content   any       // 消息内容(任意JSON结构)
}

// ObjHeader 基础头部
type ObjHeader struct {
    Id        string     `json:"id,omitempty" bson:"_id,omitempty"`
    CreatedAt time.Time  `json:"createdat,omitempty" bson:",omitempty"`
    UpdatedAt time.Time  `json:"updatedat,omitempty" bson:",omitempty"`
}

// SoftDelete 软删除记录
type SoftDelete struct {
    User    string     `json:"user"`
    SeqId   int        `json:"seqid,omitempty"`
}

字段说明

字段 类型 说明
id string 全局唯一ID(Snowflake算法)
createdat time.Time 消息创建时间
updatedat time.Time 消息更新时间(编辑时)
deletedat *time.Time 删除时间(硬删除时设置)
delid int 删除操作ID(用于追踪删除)
seqid int 消息序号(Topic内递增,核心字段)
topic string Topic名称(如 grpABC123)
from string 发送者UID(如 usrXYZ456)
head KVMap 消息头(元数据,如 MIME 类型、附件等)
content any 消息内容(任意 JSON 结构)

消息存储流程

完整流程图

┌─────────────────────────────────────────────────────────────────────────────┐
│  1. 客户端发送消息                                                            │
│     WebSocket: {"pub", "topic":"grpXXX", "content":{"text":"Hello"}}        │
└─────────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│  2. WebSocket Handler 处理 (hdl_websock.go)                                  │
│     serveWebSocket() -> sess.readLoop() -> sess.dispatchRaw(raw)            │
│     解析 JSON,识别消息类型为 "pub"                                           │
└─────────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│  3. Session 消息分发 (session.go)                                            │
│     dispatchRaw() -> 构造 ClientComMessage -> 发送到 Hub                     │
│     msg := &ClientComMessage{                                                │
│         Pub: &MsgClientPub{Topic: "grpXXX", Content: ...}                   │
│     }                                                                        │
└─────────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│  4. Hub 路由分发 (hub.go)                                                    │
│     hub.routeCli <- msg                                                      │
│     hub.routeClientMessage(msg) -> 根据 msg.RcptTo 找到 Topic               │
│     topic.clientMsg <- msg  // 发送到 Topic 的消息通道                       │
└─────────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│  5. Topic 处理消息 (topic.go)                                                │
│     select {                                                                 │
│         case msg := <-t.clientMsg:                                           │
│             t.processClientMessage(msg)                                      │
│     }                                                                        │
│     -> t.handlePublish(msg)                                                  │
│     -> t.saveAndBroadcastMessage(msg, ...)                                   │
└─────────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│  6. 保存前检查 (topic.go:saveAndBroadcastMessage)                            │
│     a. 检查发送者是否有写权限 (IsWriter)                                      │
│     b. 处理消息头 (sender、attachments)                                       │
│     c. 构造 types.Message 对象                                               │
│        SeqId = t.lastID + 1  // 序号递增                                     │
└─────────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│  7. 存储层保存 (store/store.go:Messages.Save)                                │
│     a. msg.InitTimes()  // 初始化时间戳                                       │
│     b. msg.SetUid(Store.GetUid())  // 生成全局唯一ID                          │
│     c. adp.TopicUpdateOnMessage(msg.Topic, msg)  // 更新Topic的seqid         │
│     d. adp.MessageSave(msg)  // 插入消息到数据库                              │
│     e. adp.SubsUpdate()  // 更新发送者的 ReadSeqId/RecvSeqId                 │
│     f. adp.FileLinkAttachments()  // 关联附件文件                             │
└─────────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│  8. 数据库写入 (db/mysql/adapter.go:MessageSave)                              │
│     INSERT INTO messages(createdAt, updatedAt, seqid, topic, `from`,        │
│                          head, content)                                       │
│     VALUES(?, ?, ?, ?, ?, ?, ?)                                               │
│                                                                              │
│     参数:                                                                     │
│     - createdAt: msg.CreatedAt                                               │
│     - updatedAt: msg.UpdatedAt                                               │
│     - seqid: msg.SeqId (t.lastID + 1)                                        │
│     - topic: msg.Topic                                                       │
│     - from: store.DecodeUid(ParseUid(msg.From))                              │
│     - head: msg.Head (JSON)                                                  │
│     - content: ToJSON(msg.Content) (JSON)                                    │
└─────────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│  9. 更新 Topic 缓存 (topic.go)                                               │
│     t.lastID++  // 递增最后消息序号                                           │
│     t.touched = msg.Timestamp  // 更新最后消息时间                            │
└─────────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│  10. 广播给订阅者 (topic.go)                                                 │
│      for sess := range t.sessions {                                          │
│          sess.queueOut(&ServerComMessage{                                    │
│              Data: &MsgServerData{                                           │
│                  Topic:   t.name,                                            │
│                  SeqId:   t.lastID,                                          │
│                  From:    asUid.UserId(),                                     │
│                  Content: content,                                           │
│              },                                                               │
│          })                                                                   │
│      }                                                                        │
└─────────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│  11. 推送通知 (push/push.go)                                                 │
│      如果订阅者离线,发送 FCM 推送通知                                         │
│      push.Push(userId, &PushData{...})                                       │
└─────────────────────────────────────────────────────────────────────────────┘

关键代码解析

消息保存入口 (topic.go:999)

func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid,
    noEcho bool, attachments []string, head map[string]any, content any) error {

    pud, userFound := t.perUser[asUid]

    // 1. 权限检查
    if t.cat != types.TopicCatSys {
        if !(pud.modeWant & pud.modeGiven).IsWriter() {
            msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp))
            return types.ErrPermissionDenied
        }
    }

    // 2. 处理 sender 头(代理发送)
    if msg.sess != nil && msg.sess.uid != asUid {
        if head == nil {
            head = map[string]any{}
        }
        head["sender"] = msg.sess.uid.UserId()
    }

    // 3. 构造消息对象并保存
    if err, _ := store.Messages.Save(
        &types.Message{
            ObjHeader: types.ObjHeader{CreatedAt: msg.Timestamp},
            SeqId:     t.lastID + 1,  // 关键:序号递增
            Topic:     t.name,
            From:      asUid.String(),
            Head:      head,
            Content:   content,
        }, attachments, (pud.modeGiven & pud.modeWant).IsReader()); err != nil {
        return err
    }

    // 4. 更新 Topic 状态
    t.lastID++

    // 5. 广播给订阅者
    // ...
}

存储层实现 (store/store.go:682)

func (messagesMapper) Save(msg *types.Message, attachmentURLs []string,
    readBySender bool) (error, bool) {

    // 1. 初始化时间戳
    msg.InitTimes()

    // 2. 生成全局唯一ID(Snowflake算法)
    msg.SetUid(Store.GetUid())

    // 3. 更新 Topic 的 SeqId(数据库层)
    err := adp.TopicUpdateOnMessage(msg.Topic, msg)
    if err != nil {
        return err, false
    }

    // 4. 插入消息到数据库
    err = adp.MessageSave(msg)
    if err != nil {
        return err, false
    }

    // 5. 标记发送者已读
    markedReadBySender := false
    if readBySender {
        fromUid := types.ParseUid(msg.From)
        if !fromUid.IsZero() {
            if err := adp.SubsUpdate(msg.Topic, fromUid,
                map[string]any{
                    "RecvSeqId": msg.SeqId,
                    "ReadSeqId": msg.SeqId,
                }); err == nil {
                markedReadBySender = true
            }
        }
    }

    // 6. 关联附件文件
    if len(attachmentURLs) > 0 {
        var attachments []string
        for _, url := range attachmentURLs {
            if fid := mediaHandler.GetIdFromUrl(url); !fid.IsZero() {
                attachments = append(attachments, fid.String())
            }
        }
        if len(attachments) > 0 {
            return adp.FileLinkAttachments("", types.ZeroUid, msg.Uid(), attachments), markedReadBySender
        }
    }

    return nil, markedReadBySender
}

MySQL 适配器实现 (db/mysql/adapter.go:2661)

func (a *adapter) MessageSave(msg *t.Message) error {
    ctx, cancel := a.getContext()
    if cancel != nil {
        defer cancel()
    }

    // 执行插入
    res, err := a.db.ExecContext(ctx,
        `INSERT INTO messages(createdAt, updatedAt, seqid, topic, `+"`from`"+`, head, content)
         VALUES(?, ?, ?, ?, ?, ?, ?)`,
        msg.CreatedAt,       // createdat
        msg.UpdatedAt,       // updatedat
        msg.SeqId,           // seqid
        msg.Topic,           // topic
        store.DecodeUid(t.ParseUid(msg.From)),  // from (数值UID)
        msg.Head,            // head (JSON)
        common.ToJSON(msg.Content))             // content (JSON)

    return err
}

消息内容格式

1. 纯文本消息

{
  "content": "Hello World"
}

2. Drafty 富文本消息

Drafty 是 Tinode 的富文本格式:

{
  "content": {
    "txt": "Hello **World**",
    "fmt": [
      {"tp": "SB", "at": 6, "len": 7}
    ]
  }
}

格式类型:

tp 说明
ST 斜体
SB 粗体
CO 代码
BR 换行
LN 链接
MN 提及 (@用户)
HT 隐藏文本

3. 带附件消息

{
  "content": {
    "txt": "Check this image",
    "fmt": [
      {
        "tp": "IM",
        "at": 6,
        "len": 1,
        "url": "https://example.com/image.jpg",
        "mime": "image/jpeg",
        "size": 12345,
        "width": 800,
        "height": 600
      }
    ]
  }
}

4. 回复/转发消息

{
  "content": {
    "txt": "Replying to...",
    "replyTo": {
      "seqid": 42,
      "from": "usrABC",
      "content": {"txt": "Original message"}
    }
  }
}

5. 消息头

消息头用于存储元数据:

{
  "head": {
    "mime": "text/x-drafty",      // 内容类型
    "attachments": ["file123"],    // 附件ID列表
    "replyTo": 42,                 // 回复的消息序号
    "sender": "usrXYZ",            // 代理发送者(如果是代理发送)
    "replace": 41                  // 替换的消息序号(编辑消息)
  }
}

消息序号机制

SeqId 的作用

seqid 是消息在 Topic 内的递增序号,是消息同步的核心:

Topic: grpABC123
┌────────┬──────────────┬─────────────────────────────────┐
│ seqid │    from      │ content                          │
├────────┼──────────────┼─────────────────────────────────┤
│   1    │ usrAlice     │ {"text": "Hello everyone"}       │
│   2    │ usrBob       │ {"text": "Hi Alice"}             │
│   3    │ usrCarol     │ {"text": "Hey all"}              │
│   4    │ usrAlice     │ {"text": "How are you?"}         │
│  ...   │ ...          │ ...                              │
└────────┴──────────────┴─────────────────────────────────┘

SeqId 的用途

1. 消息同步

客户端通过 since 和 before 参数拉取消息:

// 客户端请求
{
  "get": {
    "topic": "grpABC123",
    "data": {
      "since": 2,
      "before": 5,
      "limit": 10
    }
  }
}

// 服务端返回 seqid >= 2 且 < 5 的消息

2. 已读回执

订阅者表中记录已读位置:

-- subscriptions 表
recvseqid = 3  -- 已收到消息序号
readseqid = 2  -- 已读消息序号
// 客户端发送已读通知
{
  "note": {
    "topic": "grpABC123",
    "what": "read",
    "seqid": 4
  }
}

3. 未读消息计数

unreadCount = topic.lastID - subscription.readSeqId

4. 消息删除范围

// 删除 seqid 3-5 的消息
{
  "del": {
    "topic": "grpABC123",
    "what": "data",
    "ranges": [{"low": 3, "hi": 6}]
  }
}

SeqId 分配

// topic.go
func (t *Topic) saveAndBroadcastMessage(...) {
    message.SeqId = t.lastID + 1  // Topic 内递增
    t.lastID++                     // 更新缓存
}

// 数据库层保证唯一性
UNIQUE INDEX messages_topic_seqid (topic, seqid)

消息删除机制

Tinode 支持两种删除方式:软删除和硬删除。

1. 软删除

只对当前用户隐藏消息,其他用户仍可见。

数据库记录

-- dellog 表记录删除操作
INSERT INTO dellog(topic, deletedfor, delid, low, hi)
VALUES ('grpABC123', 123, 1, 3, 6);  -- 删除 seqid [3, 6) 的消息

查询时过滤

-- 查询消息时排除已删除的
SELECT m.* FROM messages m
LEFT JOIN dellog d ON m.topic = d.topic
    AND m.seqid >= d.low
    AND m.seqid < d.hi
    AND d.deletedfor = ?
WHERE m.topic = ?
    AND (d.id IS NULL)  -- 未被软删除
ORDER BY m.seqid DESC
LIMIT ?

软删除流程

用户 A 删除消息
        │
        ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│  1. 检查权限                                                                 │
│     - 用户是否有删除权限(D权限)                                             │
│     - 消息是否过期(msg_delete_age 配置)                                    │
└─────────────────────────────────────────────────────────────────────────────┘
        │
        ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│  2. 生成删除记录                                                             │
│     dellog{                                                                  │
│         topic: "grpABC123",                                                  │
│         deletedfor: usrA,                                                    │
│         delid: topic.delID++,                                                │
│         low: 3, hi: 6                                                        │
│     }                                                                        │
└─────────────────────────────────────────────────────────────────────────────┘
        │
        ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│  3. 写入数据库                                                               │
│     INSERT INTO dellog(topic, deletedfor, delid, low, hi)                   │
│     更新 subscription.delId                                                  │
└─────────────────────────────────────────────────────────────────────────────┘
        │
        ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│  4. 广播删除通知                                                             │
│     {pres: "del", seqid: 3, delid: 1}                                       │
└─────────────────────────────────────────────────────────────────────────────┘

2. 硬删除

物理删除消息,所有用户都不可见。

数据库更新

-- 标记消息为已删除
UPDATE messages
SET deletedat = NOW(), delid = ?
WHERE topic = ? AND seqid BETWEEN ? AND ?;

硬删除条件

只有以下情况可以硬删除:

  • Topic 所有者
  • 消息发送者(在一定时间内)
  • forUser 为零(管理员操作)

3. 删除范围

删除操作支持范围:

// 删除单条消息
{
  "del": {
    "topic": "grpABC123",
    "what": "data",
    "ranges": [{"low": 5, "hi": 0}]  // hi=0 表示单条
  }
}

// 删除范围消息
{
  "del": {
    "topic": "grpABC123",
    "what": "data",
    "ranges": [
      {"low": 1, "hi": 5},   // 删除 seqid [1, 5)
      {"low": 10, "hi": 15}  // 删除 seqid [10, 15)
    ]
  }
}

// 删除所有消息
{
  "del": {
    "topic": "grpABC123",
    "what": "data",
    "ranges": [{"low": 1, "hi": 2147483647}]  // 删除全部
  }
}

消息查询

查询接口

// store/store.go
func (messagesMapper) GetAll(topic string, forUser types.Uid,
    opt *types.QueryOpt) ([]types.Message, error)

查询参数

type QueryOpt struct {
    Since      int         // 起始 seqid(包含)
    Before     int         // 结束 seqid(不包含)
    Limit      int         // 返回数量限制
    IdRanges   []Range     // 指定范围列表
    IfModifiedSince *time.Time  // 增量查询时间戳
}

查询示例

1. 拉取最新消息

{
  "get": {
    "topic": "grpABC123",
    "data": {
      "limit": 20
    }
  }
}
SELECT * FROM messages
WHERE topic = 'grpABC123'
ORDER BY seqid DESC
LIMIT 20;

2. 拉取历史消息

{
  "get": {
    "topic": "grpABC123",
    "data": {
      "before": 50,
      "limit": 20
    }
  }
}
SELECT * FROM messages
WHERE topic = 'grpABC123' AND seqid < 50
ORDER BY seqid DESC
LIMIT 20;

3. 增量同步

{
  "get": {
    "topic": "grpABC123",
    "data": {
      "since": 100,
      "limit": 50
    }
  }
}
SELECT * FROM messages
WHERE topic = 'grpABC123' AND seqid >= 100
ORDER BY seqid ASC
LIMIT 50;

4. 按范围查询

{
  "get": {
    "topic": "grpABC123",
    "data": {
      "ranges": [
        {"low": 10, "hi": 15},
        {"low": 20, "hi": 25}
      ]
    }
  }
}
SELECT * FROM messages
WHERE topic = 'grpABC123'
  AND ((seqid >= 10 AND seqid < 15)
    OR (seqid >= 20 AND seqid < 25))
ORDER BY seqid;

各数据库实现差异

存储方式对比

特性 MySQL PostgreSQL MongoDB RethinkDB
内容类型 JSON JSONB BSON 原生JSON
主键 AUTO_INCREMENT SERIAL ObjectId UUID
索引 B+Tree B+Tree B+Tree B+Tree
事务 支持 支持 支持 支持
分布式 否 否 分片 分片

MySQL 适配器

// db/mysql/adapter.go
func (a *adapter) MessageSave(msg *t.Message) error {
    _, err := a.db.ExecContext(ctx,
        `INSERT INTO messages(createdAt, updatedAt, seqid, topic, `+"`from`"+`, head, content)
         VALUES(?, ?, ?, ?, ?, ?, ?)`,
        msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic,
        store.DecodeUid(t.ParseUid(msg.From)),
        msg.Head,
        common.ToJSON(msg.Content))
    return err
}

PostgreSQL 适配器

// db/postgres/adapter.go
func (a *adapter) MessageSave(msg *t.Message) error {
    var id int
    err := a.db.QueryRow(ctx,
        `INSERT INTO messages(createdAt, updatedAt, seqid, topic, "from", head, content)
         VALUES($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
        msg.CreatedAt, msg.UpdatedAt, msg.SeqId, msg.Topic,
        store.DecodeUid(t.ParseUid(msg.From)),
        msg.Head,
        common.ToJSON(msg.Content)).Scan(&id)
    return err
}

MongoDB 适配器

// db/mongodb/adapter.go
func (a *adapter) MessageSave(msg *t.Message) error {
    // 直接插入整个结构体
    _, err := a.db.Collection("messages").InsertOne(a.ctx, msg)
    return err
}

性能优化

索引设计

-- MySQL/PostgreSQL
CREATE UNIQUE INDEX messages_topic_seqid ON messages(topic, seqid);
CREATE INDEX messages_topic_created ON messages(topic, createdat);
// MongoDB
db.messages.createIndex({ "topic": 1, "seqid": 1 }, { unique: true })
db.messages.createIndex({ "topic": 1, "createdat": -1 })

分区策略

对于大流量场景,可以考虑按 Topic 分区:

-- MySQL 分区(按 topic hash)
CREATE TABLE messages (
    ...
) PARTITION BY HASH(topic) PARTITIONS 16;

常见问题

1. 消息 ID vs SeqId 的区别?

  • id: 全局唯一,使用 Snowflake 算法生成,用于数据库主键
  • seqid: Topic 内唯一递增,用于消息同步、已读回执、删除范围

2. 如何实现消息编辑?

编辑消息实际上是创建新消息并关联原消息:

{
  "pub": {
    "topic": "grpABC123",
    "content": {"txt": "Edited content"},
    "head": {
      "replace": 42  // 替换 seqid=42 的消息
    }
  }
}

3. 如何实现消息撤回?

在一定时间内(msg_delete_age 配置)可以撤回:

{
  "del": {
    "topic": "grpABC123",
    "what": "data",
    "ranges": [{"low": 42, "hi": 0}]
  }
}

4. 消息存储限制?

  • 最大消息大小:由 max_message_size 配置(默认 128KB)
  • 大文件通过附件存储,消息中只保留引用

总结

Tinode 消息存储的核心设计:

  1. JSON 格式存储 - 灵活支持各种消息类型
  2. SeqId 递增 - 每个 Topic 内消息有序,便于同步
  3. Topic 分片 - 消息按 Topic 分组,便于查询
  4. 软/硬删除 - 支持两种删除策略
  5. 多数据库支持 - 统一接口,适配多种数据库