IM 的已读为什么用游标,而不是逐条标记
逐条标记的问题:异步落库会造出「假未读」
直觉的做法是给每条消息一个已读标记,UPDATE im_message SET read = true WHERE ...。
但消息的写入路径是异步的:WebSocket 分发之后才由后台任务从 Redis 队列取出落库。
于是会出现这个顺序:
- 接收方打开会话 → 上报已读 → 打标记;
- 那条消息还没落库;
- 消息随后落库,
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 里。