1021 lines
38 KiB
Markdown
1021 lines
38 KiB
Markdown
# 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
|
||
|
||
```sql
|
||
-- 消息表
|
||
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
|
||
|
||
```sql
|
||
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
|
||
|
||
```javascript
|
||
// 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 结构体定义
|
||
|
||
```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)
|
||
|
||
```go
|
||
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)
|
||
|
||
```go
|
||
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)
|
||
|
||
```go
|
||
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. 纯文本消息
|
||
|
||
```json
|
||
{
|
||
"content": "Hello World"
|
||
}
|
||
```
|
||
|
||
### 2. Drafty 富文本消息
|
||
|
||
Drafty 是 Tinode 的富文本格式:
|
||
|
||
```json
|
||
{
|
||
"content": {
|
||
"txt": "Hello **World**",
|
||
"fmt": [
|
||
{"tp": "SB", "at": 6, "len": 7}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
**格式类型**:
|
||
| tp | 说明 |
|
||
|----|------|
|
||
| `ST` | 斜体 |
|
||
| `SB` | 粗体 |
|
||
| `CO` | 代码 |
|
||
| `BR` | 换行 |
|
||
| `LN` | 链接 |
|
||
| `MN` | 提及 (@用户) |
|
||
| `HT` | 隐藏文本 |
|
||
|
||
### 3. 带附件消息
|
||
|
||
```json
|
||
{
|
||
"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. 回复/转发消息
|
||
|
||
```json
|
||
{
|
||
"content": {
|
||
"txt": "Replying to...",
|
||
"replyTo": {
|
||
"seqid": 42,
|
||
"from": "usrABC",
|
||
"content": {"txt": "Original message"}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 5. 消息头
|
||
|
||
消息头用于存储元数据:
|
||
|
||
```json
|
||
{
|
||
"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` 参数拉取消息:
|
||
|
||
```json
|
||
// 客户端请求
|
||
{
|
||
"get": {
|
||
"topic": "grpABC123",
|
||
"data": {
|
||
"since": 2,
|
||
"before": 5,
|
||
"limit": 10
|
||
}
|
||
}
|
||
}
|
||
|
||
// 服务端返回 seqid >= 2 且 < 5 的消息
|
||
```
|
||
|
||
#### 2. 已读回执
|
||
|
||
订阅者表中记录已读位置:
|
||
|
||
```sql
|
||
-- subscriptions 表
|
||
recvseqid = 3 -- 已收到消息序号
|
||
readseqid = 2 -- 已读消息序号
|
||
```
|
||
|
||
```json
|
||
// 客户端发送已读通知
|
||
{
|
||
"note": {
|
||
"topic": "grpABC123",
|
||
"what": "read",
|
||
"seqid": 4
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3. 未读消息计数
|
||
|
||
```go
|
||
unreadCount = topic.lastID - subscription.readSeqId
|
||
```
|
||
|
||
#### 4. 消息删除范围
|
||
|
||
```json
|
||
// 删除 seqid 3-5 的消息
|
||
{
|
||
"del": {
|
||
"topic": "grpABC123",
|
||
"what": "data",
|
||
"ranges": [{"low": 3, "hi": 6}]
|
||
}
|
||
}
|
||
```
|
||
|
||
### SeqId 分配
|
||
|
||
```go
|
||
// topic.go
|
||
func (t *Topic) saveAndBroadcastMessage(...) {
|
||
message.SeqId = t.lastID + 1 // Topic 内递增
|
||
t.lastID++ // 更新缓存
|
||
}
|
||
|
||
// 数据库层保证唯一性
|
||
UNIQUE INDEX messages_topic_seqid (topic, seqid)
|
||
```
|
||
|
||
---
|
||
|
||
## 消息删除机制
|
||
|
||
Tinode 支持两种删除方式:**软删除**和**硬删除**。
|
||
|
||
### 1. 软删除
|
||
|
||
只对当前用户隐藏消息,其他用户仍可见。
|
||
|
||
#### 数据库记录
|
||
|
||
```sql
|
||
-- dellog 表记录删除操作
|
||
INSERT INTO dellog(topic, deletedfor, delid, low, hi)
|
||
VALUES ('grpABC123', 123, 1, 3, 6); -- 删除 seqid [3, 6) 的消息
|
||
```
|
||
|
||
#### 查询时过滤
|
||
|
||
```sql
|
||
-- 查询消息时排除已删除的
|
||
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. 硬删除
|
||
|
||
物理删除消息,所有用户都不可见。
|
||
|
||
#### 数据库更新
|
||
|
||
```sql
|
||
-- 标记消息为已删除
|
||
UPDATE messages
|
||
SET deletedat = NOW(), delid = ?
|
||
WHERE topic = ? AND seqid BETWEEN ? AND ?;
|
||
```
|
||
|
||
#### 硬删除条件
|
||
|
||
只有以下情况可以硬删除:
|
||
- Topic 所有者
|
||
- 消息发送者(在一定时间内)
|
||
- `forUser` 为零(管理员操作)
|
||
|
||
### 3. 删除范围
|
||
|
||
删除操作支持范围:
|
||
|
||
```json
|
||
// 删除单条消息
|
||
{
|
||
"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}] // 删除全部
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 消息查询
|
||
|
||
### 查询接口
|
||
|
||
```go
|
||
// store/store.go
|
||
func (messagesMapper) GetAll(topic string, forUser types.Uid,
|
||
opt *types.QueryOpt) ([]types.Message, error)
|
||
```
|
||
|
||
### 查询参数
|
||
|
||
```go
|
||
type QueryOpt struct {
|
||
Since int // 起始 seqid(包含)
|
||
Before int // 结束 seqid(不包含)
|
||
Limit int // 返回数量限制
|
||
IdRanges []Range // 指定范围列表
|
||
IfModifiedSince *time.Time // 增量查询时间戳
|
||
}
|
||
```
|
||
|
||
### 查询示例
|
||
|
||
#### 1. 拉取最新消息
|
||
|
||
```json
|
||
{
|
||
"get": {
|
||
"topic": "grpABC123",
|
||
"data": {
|
||
"limit": 20
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
```sql
|
||
SELECT * FROM messages
|
||
WHERE topic = 'grpABC123'
|
||
ORDER BY seqid DESC
|
||
LIMIT 20;
|
||
```
|
||
|
||
#### 2. 拉取历史消息
|
||
|
||
```json
|
||
{
|
||
"get": {
|
||
"topic": "grpABC123",
|
||
"data": {
|
||
"before": 50,
|
||
"limit": 20
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
```sql
|
||
SELECT * FROM messages
|
||
WHERE topic = 'grpABC123' AND seqid < 50
|
||
ORDER BY seqid DESC
|
||
LIMIT 20;
|
||
```
|
||
|
||
#### 3. 增量同步
|
||
|
||
```json
|
||
{
|
||
"get": {
|
||
"topic": "grpABC123",
|
||
"data": {
|
||
"since": 100,
|
||
"limit": 50
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
```sql
|
||
SELECT * FROM messages
|
||
WHERE topic = 'grpABC123' AND seqid >= 100
|
||
ORDER BY seqid ASC
|
||
LIMIT 50;
|
||
```
|
||
|
||
#### 4. 按范围查询
|
||
|
||
```json
|
||
{
|
||
"get": {
|
||
"topic": "grpABC123",
|
||
"data": {
|
||
"ranges": [
|
||
{"low": 10, "hi": 15},
|
||
{"low": 20, "hi": 25}
|
||
]
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
```sql
|
||
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 适配器
|
||
|
||
```go
|
||
// 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 适配器
|
||
|
||
```go
|
||
// 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 适配器
|
||
|
||
```go
|
||
// db/mongodb/adapter.go
|
||
func (a *adapter) MessageSave(msg *t.Message) error {
|
||
// 直接插入整个结构体
|
||
_, err := a.db.Collection("messages").InsertOne(a.ctx, msg)
|
||
return err
|
||
}
|
||
```
|
||
|
||
### 性能优化
|
||
|
||
#### 索引设计
|
||
|
||
```sql
|
||
-- MySQL/PostgreSQL
|
||
CREATE UNIQUE INDEX messages_topic_seqid ON messages(topic, seqid);
|
||
CREATE INDEX messages_topic_created ON messages(topic, createdat);
|
||
```
|
||
|
||
```javascript
|
||
// MongoDB
|
||
db.messages.createIndex({ "topic": 1, "seqid": 1 }, { unique: true })
|
||
db.messages.createIndex({ "topic": 1, "createdat": -1 })
|
||
```
|
||
|
||
#### 分区策略
|
||
|
||
对于大流量场景,可以考虑按 Topic 分区:
|
||
|
||
```sql
|
||
-- MySQL 分区(按 topic hash)
|
||
CREATE TABLE messages (
|
||
...
|
||
) PARTITION BY HASH(topic) PARTITIONS 16;
|
||
```
|
||
|
||
---
|
||
|
||
## 常见问题
|
||
|
||
### 1. 消息 ID vs SeqId 的区别?
|
||
|
||
- **id**: 全局唯一,使用 Snowflake 算法生成,用于数据库主键
|
||
- **seqid**: Topic 内唯一递增,用于消息同步、已读回执、删除范围
|
||
|
||
### 2. 如何实现消息编辑?
|
||
|
||
编辑消息实际上是创建新消息并关联原消息:
|
||
|
||
```json
|
||
{
|
||
"pub": {
|
||
"topic": "grpABC123",
|
||
"content": {"txt": "Edited content"},
|
||
"head": {
|
||
"replace": 42 // 替换 seqid=42 的消息
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3. 如何实现消息撤回?
|
||
|
||
在一定时间内(`msg_delete_age` 配置)可以撤回:
|
||
|
||
```json
|
||
{
|
||
"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. **多数据库支持** - 统一接口,适配多种数据库 |