← 返回筆記列表

IM 的已读为什么用游标,而不是逐条标记

發布于 來自 Kite

  • 工程笔记
  • Mini-IM
  • PostgreSQL
  • IM

逐条标记的问题:异步落库会造出「假未读」

直觉的做法是给每条消息一个已读标记,UPDATE im_message SET read = true WHERE ...。 但消息的写入路径是异步的:WebSocket 分发之后才由后台任务从 Redis 队列取出落库。 于是会出现这个顺序:

  1. 接收方打开会话 → 上报已读 → 打标记;
  2. 那条消息还没落库;
  3. 消息随后落库,read = false。

结果是一条刚被读过的消息又变成未读。这个 bug 只在数据库抖动或队列堆积时出现, 本地怎么测都测不出来。

游标天然规避这个问题:它记的是「我读到哪个时间点」,而不是「哪几条读过了」。 晚到的消息落在游标之前,自然算已读。

游标只前进:GREATEST

上报已读是幂等的、而且可能并发(多端登录、快速切换会话),因此写入用 upsert 且取较大值:

return d.table().Clauses(clause.OnConflict{
    Columns: []clause.Column{{Name: "user_id"}, {Name: "peer_id"}},
    DoUpdates: clause.Assignments(map[string]interface{}{
        // 并发上报时取较大值,避免游标被回拨
        "read_time":   gorm.Expr("GREATEST(im_read_state.read_time, ?)", readTime),
        "update_time": readTime,
    }),
}).Create(&state).Error

GREATEST 而不是直接覆盖,是为了让「晚到的旧上报」不会把游标往回拉 —— 否则用户切回一个旧会话再切回来,未读数会莫名其妙涨回去。

未读数:一条 SQL,游标缺失时回退

未读数不能是「总消息数 - 已读数」两次查询,那样又回到了逐条口径。做法是一条 SQL:

SELECT m.user_id AS peer_id, COUNT(*) AS cnt
FROM im_message m
LEFT JOIN im_read_state r ON r.user_id = ? AND r.peer_id = m.user_id
WHERE m.friend_id = ?
  AND m.create_time > COALESCE(r.read_time,
      (SELECT COALESCE(MAX(s.create_time), 0)
         FROM im_message s
        WHERE s.user_id = ? AND s.friend_id = m.user_id))
GROUP BY m.user_id

COALESCE 那一段是这篇笔记里最值钱的一行:对方还没跟我说过话时,游标是不存在的。 这时候把「我最后发言的时间」当作游标,未读数就是 0 —— 符合直觉; 少了这段回退,新好友的第一条消息会被算成「我有无数条未读」。

顺带:单向状态要另外建模

删好友、清空会话、拉黑都是只影响我这一侧的行为,但好友关系是一条双向记录。 删行会让双方历史一起消失,所以 im_friend 永不删行,单侧状态另开一张表:

type ContactState struct {
    UserID    int64 `gorm:"not null;uniqueIndex:uk_im_contact_state_pair,priority:1"`
    PeerID    int64 `gorm:"not null;uniqueIndex:uk_im_contact_state_pair,priority:2"`
    Removed   bool  `gorm:"not null;default:false"` // 我是否已把对方移除(单向)
    ClearTime int64 `gorm:"not null;default:0"`     // 我清空会话的时间点
}

重新加回好友时只清 removed、保留 clear_time —— 于是「重新加回来是空会话」, 而不是把当年聊过的内容又还给他。这两个字段的语义边界必须一开始就写清楚, 否则迟早有人把它当成「会话的创建时间」来用。

还有一个方向性问题

黑名单只约束一个方向:CanSendMessage(from, to) 查的是 to 是否拉黑了 from。 而且:

blocked, err := dao.NewBlacklistDao().Exists(toID, fromID)
if err != nil {
    return true // 查询失败时放行:宁可漏拦,也不能让正常聊天发不出去
}

「失败放行」在这类判定里是通例:权限/拦截类的旁路故障不该让主流程不可用。 同一条原则也用在好友关系校验上。

已知边界

  • 单会话最多返回 200 条历史,没有向前翻页;
  • 离线消息队列的补发是「先 LLen 再循环 RPop」,非原子 —— 同一用户多端同时上线可能各拿到一部分;
  • 离线 key 没有 TTL,用户长期不上线会一直堆在 Redis 里。