使用SessionID替代UserID
当前消息采用用户ID作为发送对象,而不是会话ID,这就造成了在同一个聊天窗口里,A 发的消息和 B 发的消息,使用的是两套完全不相关的序列号体系 。你无法简单地通过 Seq 排序来还原整个对话的先后顺序(Timeline),当然也是可以还原先后顺序的,客户端拉取会话的最新消息时,必须同时查询发给我的和我发出的两份数据,并尝试在本地进行时间排序,这样逻辑非常容易出问题。
其实上面的使用UserID作为发送对象本质上就是一个收件箱模型,当你使用UserID来分配序列号时,你实际上就是在维护每个人的私有收件箱。当B 发消息给A 时,系统认为是投递到 A 的邮箱,所以消耗的是 A 的序列号。
当你抽象出SessionID并以此分配序列号时,就是在构建公共的时间线,是一个发件箱模型。每个会话都有一个专属的计数器。无论是A还是B发消息,发送的消息都得在Session 1中排队获取ID,这样整个会话内的消息就是天然有序的,客户端只需要记录一个MaxSeq,下次拉取时直接请求Seq > MaxSeq的所有消息即可,无论是谁发送的。
其实上面两种讨论的本质就是对于写放大和读放大的取舍。使用SessionID模式优化了写放大,在UserID路由模式中,如果你在一个500人的群里面发一条消息,你就需要查找群里所有人的用户ID,然后再插入500条消息,使每个群成员的收件箱里面各插一条,写放大严重。采用会话模式的话就只需要在会话表中插入一条消息即可。
但是会话模式通常存在读放大风险,在UserID即收件箱模式中,用户打开App,查询所有未读消息时,只需要查一次自己的收件箱表,读取极快而且逻辑简单。但是在会话模式的话,需要去扫描所有相关的会话表,聚合计算每个会话是否有新消息以及有多少未读,这会产生巨大的读放大 。
这种方式可以使用写扩散索引 + 读扩散内容的混合模式解决。消息内容表中使用SessionID存储,无论群聊规模多大(例如 5000 人大群),一条消息在物理上只存储一份。这彻底消除了消息体层面的写放大,节省了海量存储空间。再加一个会话索引表,引入一个轻量级的 会话索引表 (Session Index),使用 UserID (OwnerId) 建立索引,这张表仅存储元数据,如 ConversationID 、 LastMsgID 、 UnreadCount 、 TopStatus 等,当用户打开 App 时,只需基于 UserID 查询这张索引表,即可 O(1) 复杂度获取完整的会话列表和未读状态,解决了读放大问题。这样的话其实还是有索引更新的压力,假设群聊有500人,写一条Message存入共享存储,之后就需要更新500行会话索引表,更新 LastMsgID 指针并增加 UnreadCount,这部分可以使用消息队列异步更新的方式解决。
下面是改为使用会话ID所作的变动:
go
func getSessionID(id1, id2 string) string {
if strings.HasPrefix(id2, "G") {
return id2
}
// P2P: 保证 ID 顺序一致
if id1 > id2 {
id1, id2 = id2, id1
}
hash := md5.Sum([]byte(fmt.Sprintf("%s:%s", id1, id2)))
return hex.EncodeToString(hash[:])
}
首先在StateServer中增加这个函数,对于单聊场景,这个函数就是将双方ID排序后拼接min:max并计算MD5,确保A和B无论谁发送消息,生成的SessionID都是同一个。群聊的话就直接使用群ID作为会话ID就好。
go
sessionID = getSessionID(req.Uid, msgCmd.ReceiverId)
seq, err = s.alloc.NextSeq(ctx, sessionID)
这段变动很简单,就是要求分配器按照会话为粒度去分配连续的序号流,解决了时间线割裂问题,客户端拉取历史消息变得非常简单,只拉取Seq > LocalMax。之后落库也是按照这个分配的会话ID去落入消息库,避免写放大。
由于我们使用了上面提到的混合逻辑,所以在落库的时候我们不仅要落库model.Message,还需要更新会话表相关信息,不然消息接收方的会话界面就不会更改,提示用户有未读消息。具体来说,在用户的会话表中加上写入会话的当前Seq号,便于用户获取消息时快速根据会话的Seq号去消息表中根据SessionID索引去拉取消息。用户打开 App,本地存的最后一条消息 Seq 是 100,用户先查自己的 Session 表,发现某个会话的MaxSeq是 150,说明此时有50条新消息。客户端此时就会去Message表请求,利用索引快速拉回缺失的消息。所以我们还需要落库Session表,在落库成功之后开启一个协程去写Session表,当然这里也可以同步写,另起一个协程写是因为可能一条消息需要更新多个会话表,此时耗时增加,同步写的话就会尝试阻塞现象。
go
go s.updateSessionIndex(ctx, req.Uid, msgCmd.ReceiverId, msgCmd.Content, seq)
下面解释一下更新会话表的函数:
go
func (s *Service) updateSessionIndex(ctx context.Context, sendId, receiveId, content string, seq int64) {
s.upsertSession(sendId, receiveId, content, 0, seq)
if strings.HasPrefix(receiveId, "G") {
// TODO: 获取群成员列表并循环更新
} else {
s.upsertSession(receiveId, sendId, content, 1, seq)
}
}
func (s *Service) upsertSession(ownerId, peerId, content string, unreadInc int, seq int64) {
now := time.Now()
err := dao.GormDB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "uuid"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"last_message": content,
"last_message_at": now,
"unread_count": gorm.Expr("unread_count + ?", unreadInc),
"max_seq": seq,
}),
}).Create(&model.Session{
Uuid: fmt.Sprintf("S%s", getSessionID(ownerId, peerId)),
SendId: ownerId,
ReceiveId: peerId,
ReceiveName: peerId, // 简化
LastMessage: content,
LastMessageAt: sql.NullTime{Time: now, Valid: true},
UnreadCount: unreadInc,
MaxSeq: seq,
CreatedAt: now,
}).Error
if err != nil {
log.Printf("[StateServer] Failed to update session index owner=%s peer=%s: %v", ownerId, peerId, err)
}
}
第一个函数是供上层调度的函数,决定谁的会话需要更新,其实就是分别更新发送者的会话,表示发送者给接收者发送了一条消息,并在外层会话界面内显示发送的最后一条消息,更新接收者的会话就是让会话表对应项的消息未读数+1,然后显示最后一条消息,如果是群聊,这里留了个 TODO ,实际逻辑应该是去查群成员表,然后循环调用 upsertSession 给几百个群成员都更新一遍,这里是因为还没重构群聊相关部分,因为这部分涉及到超大群优化,留到后面重构。
upsertSession负责执行具体的数据库操作,首先尝试往Session表里插入一行新数据,如果A和B从来每聊过天,这行就会插入成功,创建一个新会话。如果数据库里已经有一行 uuid 相同的记录,就执行更新操作,更新最新消息预览,更新事件,更新未读消息数量,更新会话内最新消息Seq。
总结
现在我们来梳理一下重构项目前后消息发送和查询的逻辑变化。
之前的消息发送逻辑是,客户端通过WebSocket发送消息,服务端通过c.Conn.ReadMessage()读取数据,直接塞入一个全局的Chan或者消息队列,服务端的主循环从Chan或者消息队列中取出消息,然后调用如下函数落库:
go
message := model.Message{
Uuid: fmt.Sprintf("M%s", random.GetNowAndLenRandomString(11)),
SessionId: chatMessageReq.SessionId,
Type: chatMessageReq.Type,
Content: chatMessageReq.Content,
Url: chatMessageReq.Url,
SendId: chatMessageReq.SendId,
SendName: chatMessageReq.SendName,
SendAvatar: chatMessageReq.SendAvatar,
ReceiveId: chatMessageReq.ReceiveId,
FileSize: chatMessageReq.FileSize,
FileType: chatMessageReq.FileType,
FileName: chatMessageReq.FileName,
Status: message_status_enum.Unsent,
CreatedAt: time.Now(),
}
虽然插入消息时给SessionID字段赋值了,但是这个SessionID没有被使用,因为后面的查询代码并没有使用SessionID进行查询,而且这个SessionID是直接从前端传过来的请求参数里取的,此时前端传的SessionID存的值实际上还是接收者ID,这就导致在数据库中,A和B的聊天记录依然不可以通过会话查询。
此时也没有Seq的概念,纯粹是把一条记录插入数据库。然后就走到消息分发的逻辑了,这部分也很暴力,就是判断ReceiveID是用户ID还是群ID,如果是用户ID的话就直接查Map找到对应的客户端句柄发送过去就好了,如果是群聊的话就要先从数据库中查出群成员列表,同样在Map中找到所有对应的客户端句柄,排除自己,然后发送就好。
下面是查询历史消息的流程,之前查询单聊消息时使用的Sql是:
sql
SELECT * FROM message
WHERE (send_id = 'A' AND receive_id = 'B')
OR (send_id = 'B' AND receive_id = 'A')
ORDER BY created_at ASC
SELECT * FROM message
WHERE receive_id = 'G'
ORDER BY created_at ASC
上面是单聊查询,下面是群聊查询。在单聊查询场景中,因为没有统一的会话ID,系统只能去数据库里把"A说的话"和"B说的话"分别捞出来,然后按时间戳硬排在一起,假装它们是一个会话。由于在消息表中send_id和receive_id都建立了索引,所以其实还是会走索引查询然后再merge到一起,但还是效率低,因为二级索引需要回表,而且Merge的时候需要排序去重,有CPU和内存开销。群聊消息的查询就更加简单,因为所有发给群的消息,receive_id都是群ID,所以直接在消息表中查这个字段就可以。
之前架构的消息存储和转发相当低效,OR 查询虽然能命中 send_id 和 receive_id 的二级索引,但会导致两次索引扫描 和 大量回表,数据库必须将两部分结果集加载到内存中,根据created_at进行排序,当消息量达到百万级时,这种即时计算的排序开销会让数据库 CPU 飙升,响应延迟从毫秒级劣化到秒级。
而且在会话内查询相当暴力,在查询到消息之后,完全依赖时间戳进行排序,但是这在分布式系统中是大忌,因为时钟偏移是不可避免地,每台服务器上的时钟可能不同步,而且可能会产生时钟回拨,这样的话就无法通过时间去体现消息的因果顺序了。并且使用created_at实现增量同步很复杂,如果服务端NTP校准导致时钟回拨,新消息的时间戳可能比客户端记录的还小,导致永久漏拉消息,即使时间准确,在高并发下同一毫秒内可能存在多条消息,客户端若使用 > time 查询会漏掉同毫秒的其他消息,若使用 >= time 查询则需要处理大量重复数据,逻辑脆弱且不可靠。相比之下,使用严格单调递增的Seq(逻辑时钟)能彻底避免这些问题,实现精准、高效的断点续传。
现在我们来看优化之后的消息架构,message表中通过(session_id, seq) 这个联合索引来定位数据。此时当客户端需要拉取某个会话的增量消息时,服务端执行 SQLSELECT * FROM message WHERE session_id = 'S_A_B' AND seq > 100 ORDER BY seq ASC,由于联合索引的存在,数据库引擎可以直接定位到 (S_A_B, 100) 这个索引节点,然后沿着 B+ 树叶子节点顺序向后读取即可。这种操作是纯粹的顺序 IO ,效率远高于之前的全表扫描 + 内存排序。而且Seq是严格递增的整数,这意味着我们不再需要依赖不可靠的 created_at 时间戳来排序。无论系统时钟是否回拨,无论并发有多高,只要 Seq 是 101,它就一定排在 100 后面,就可以做强因果保证。
上面说的是消息表,我们还增加了Session表,这个表存储的是每个人自己的会话信息,比如说A看到一个会话的最后一条消息是"hello",未读消息数有3条,对这个会话的备注名是B,这样类似的信息。该表采用收件箱模式设计,为每个用户维护一份独立的会话视图(包含 OwnerID 、 UnreadCount 、 MaxSeq 和 LastMessage ),读取会话时,用户只需查询自己的私有索引表,即可 O(1) 复杂度瞬间获取带有红点和最新消息预览的完整列表,彻底解决了旧架构中拉取列表需要全表扫描聚合的性能瓶颈。
引入Session表之后查询变得高效,客户端首先查询Session表,瞬间渲染出首页会话列表,当用户点进某个会话时,客户端携带 SessionID 和本地记录的 LocalMaxSeq ,直接去 Message 表执行范围查询( WHERE session_id = ? AND seq > LocalMaxSeq ),这种基于联合索引的单索引扫描 ,不仅查询速度极快,而且利用 Seq 的单调递增特性,彻底解决了并发场景下的消息乱序和时钟回拨导致的数据丢失问题。
下行消息链路
之前我们介绍了采用推拉结合的方式实现下行消息的可靠性,我们先完善下行消息的协议,要在MESSAGEDOWN信令中新增会话ID和Seq。
protobuf
message MessageDown {
string uuid = 1; // 消息唯一ID
int32 type = 2; // 消息类型 (文本/图片等)
string content = 3; // 消息内容
string receiver_id = 4; // 接收者ID (群聊则是群ID)
int64 seq = 5; // 会话内序号
string session_id = 6; // 会话ID
string sender_id = 7; // 发送者ID
}
服务端只负责找到对应客户端并且推送下行消息,客户端检查序列号是否连续,如果不连续的话就会发起Pull请求主动拉取数据。客户端还需要维护一个Message表和Session表,其中Message表存储历史聊天消息,具体的聊天内容,Session表就存储会话的元数据,比如说客户端的local_seq,会话备注,未读数和最后一条消息。
首先来看客户端下行消息可靠性的代码,客户端解析消息信令之后发现是CommandType_MESSAGEDOWN就会路由到下行消息处理函数。
go
localMsg := &LocalMessage{
UUID: down.Uuid,
SessionID: down.SessionId,
Seq: down.Seq,
SenderID: down.SenderId,
ReceiverID: down.ReceiverId,
Content: down.Content,
Type: down.Type,
}
首先就是通过服务端发送的下行消息构造本地消息,分别落库和进行Seq比较,然后就走到下面这个函数了。
go
cdb.SaveMessage(localMsg)
func (cdb *ClientDB) SaveMessage(msg *LocalMessage) (bool, int64, int64, error) {
cdb.mu.Lock()
defer cdb.mu.Unlock()
var isGap bool
var gapStart, gapEnd int64
err := cdb.db.Transaction(func(tx *gorm.DB) error {
var sess Session
if err := tx.Where("session_id = ?", msg.SessionID).First(&sess).Error; err != nil {
if err != gorm.ErrRecordNotFound {
return err
}
sess.SessionID = msg.SessionID
}
if msg.Seq <= sess.LastSeq {
return nil
}
if msg.Seq > sess.LastSeq+1 {
isGap = true
gapStart = sess.LastSeq + 1
gapEnd = msg.Seq - 1
return nil
}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "session_id"}, {Name: "seq"}},
DoNothing: true,
}).Create(msg).Error; err != nil {
return err
}
sess.LastSeq = msg.Seq
sess.UnreadCount++
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "session_id"}},
DoUpdates: clause.AssignmentColumns([]string{"last_seq", "max_seq", "unread_count", "updated_at"}),
}).Create(&sess).Error; err != nil {
return err
}
return nil
})
if err != nil {
return false, 0, 0, err
}
return isGap, gapStart, gapEnd, nil
}
首先查询出消息所属会话,然后就知道了会话的lastSeq,该会话本地已存储的最大序号。如果发现msg.Seq <= sess.LastSeq的话就代表是重复消息,已经存储过了。如果是msg.Seq > sess.LastSeq+1表示有消息漏洞,需要客户端发起Pull请求去拉取消息,看是否是真的有漏洞。如果都不是的话就代表正好是客户端期待的消息序号,此时直接落库即可。
如果客户端调用这个函数时发现有漏洞时,就会主动发起消息补洞请求:
go
if isGap {
fmt.Printf("GAP DETECTED: %s missing %d-%d\n", down.SessionId, gapStart, gapEnd)
syncCmd := &protocol.SyncCommand{
SessionId: down.SessionId,
StartSeq: gapStart - 1,
EndSeq: down.Seq,
Limit: 100,
}
syncBytes, _ := proto.Marshal(syncCmd)
env := &protocol.Command{
Type: protocol.CommandType_SYNC,
Data: syncBytes,
}
envBytes, _ := proto.Marshal(env)
if err := wsutil.WriteClientBinary(conn, envBytes); err != nil {
log.Printf("send sync failed: %v", err)
} else {
log.Printf("Sent SYNC request for %s: %d-%d (blocking current msg %d)", down.SessionId, gapStart, down.Seq, down.Seq)
}
}
同样,服务端收到消息补洞请求之后就会按照客户端要求的序列号在数据库中进行查询,然后将得到的消息进行发送。
go
func (s *Service) handleSync(ctx context.Context, cmd *protocol.Command, req *pb.ReceiveMessageRequest) (*pb.ReceiveMessageResponse, error) {
var syncCmd protocol.SyncCommand
if err := proto.Unmarshal(cmd.Data, &syncCmd); err != nil {
return nil, err
}
limit := int(syncCmd.Limit)
if limit <= 0 || limit > 500 {
limit = 100
}
msgs, err := s.store.GetMessagesBySeqRange(ctx, syncCmd.SessionId, syncCmd.StartSeq, syncCmd.EndSeq, limit)
if err != nil {
log.Printf("[StateServer] Sync failed: %v", err)
return nil, err
}
log.Printf("[StateServer] Sync found %d messages", len(msgs))
for _, m := range msgs {
down := &protocol.MessageDown{
Uuid: m.Uuid,
Type: int32(m.Type),
Content: m.Content,
ReceiverId: m.ReceiveId,
Seq: m.Seq,
SessionId: m.SessionId,
SenderId: m.SendId,
}
downBytes, _ := proto.Marshal(down)
downCmd := &protocol.Command{
Type: protocol.CommandType_MESSAGEDOWN,
Data: downBytes,
}
downPayload, _ := proto.Marshal(downCmd)
pushAddr, _ := s.rdb.Get(ctx, fmt.Sprintf("UserID:%s", req.Uid)).Result()
if pushAddr != "" {
client, err := s.getPushClient(pushAddr)
if err == nil {
client.Push(ctx, &pb.PushRequest{UserId: req.Uid, Payload: downPayload})
}
}
}
respCmd := &protocol.Command{
Type: protocol.CommandType_SYNC,
Code: 0,
}
respBytes, _ := proto.Marshal(respCmd)
return &pb.ReceiveMessageResponse{
ResponsePayload: respBytes,
}, nil
}
首先从通用的 Command 结构中提取出具体的 SyncCommand。这里面包含了客户端想要的 SessionId 以及缺失的消息区间(StartSeq 到 EndSeq),这里还进行了限流处理,强制限制单次同步的消息条数(最大 500 条),防止客户端恶意请求或超大范围查询拖垮数据库。
然后就是调用存储层根据序号区间抓取消息,获取到消息列表之后就把查出来的历史消息push给客户端,将历史消息封装进 MESSAGEDOWN 类型,客户端收到后,会再次调用你刚才那个 SaveMessage 函数,从而填补本地的空洞。最后就是告诉客户端处理已经完成。
到这里就已经完成了下行消息的可靠性,在线消息通过服务端直接Push给客户端,如果发现有消息漏洞的话客户端就去pull服务端的消息,此时下行消息是靠TCP和客户端主动拉取实现可靠性的。如果TCP连接不稳定,即在弱网环境下时,TCP连接断开,此时下行消息就丢失了,但是我们还是可以基于客户端的Pull请求实现消息可靠性,只需要保证每次建立连接或者客户端登录的时候都主动进行一次Pull请求拉取数据就好了,这样就算重建连接的话,还是可以根据客户端缺失的消息进行Pull请求拉取消息。