好友聊天已读状态总结

好友聊天已读状态总结

在即时通讯系统中,用户需要知道发送的消息是否被对方阅读。传统方案是为每条消息单独存储 is_read 字段,但这种方式在高并发场景下会导致数据库压力过大——每收到一条消息就要更新一条记录,消息量增长后性能急剧下降。
为解决这个问题,我借鉴了一些大佬的想法,基于IM系统的设计,采用会话维度水位记录而不是逐条标记;

核心思想: - 每条消息在会话内分配单调递增序号 seq - 每个用户在每个会话只存一条"阅读水位"记录 - 判断规则:消息.seq <= 用户.last_read_seq → 已读

方案对比:

数据库设计:

  1. 消息表新增序号列
ALTER TABLE chat_msg ADD COLUMN seq BIGINT NOT NULL DEFAULT 0 COMMENT '会话内单调递增序号';
  1. 会话序号分配表
CREATE TABLE conversation_seq ( conversation_key VARCHAR(50) PRIMARY KEY COMMENT '会话标识:小ID_大ID', seq BIGINT NOT NULL DEFAULT 0 COMMENT '下一条消息的序号' );
  1. 会话阅读水位表
CREATE TABLE conversation_read ( id BIGINT PRIMARY KEY AUTO_INCREMENT, conversation_key VARCHAR(50) NOT NULL COMMENT '会话标识', user_id BIGINT NOT NULL COMMENT '阅读方用户ID', last_read_seq BIGINT NOT NULL DEFAULT 0 COMMENT '读到的最大消息序号', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_conv_user (conversation_key, user_id) );

后端实现:

1.会话序号分配(原子递增)

@Insert("INSERT INTO conversation_seq (conversation_key, seq) VALUES (#{key}, 1) " + "ON DUPLICATE KEY UPDATE seq = seq + 1") void incrementSeq(String key);

每次发送消息时调用,INSERT … ON DUPLICATE KEY UPDATE 保证原子性。
2.阅读水位更新(取最大值)

@Insert("INSERT INTO conversation_read (conversation_key, user_id, last_read_seq) " + "VALUES (#{key}, #{userId}, #{seq}) " + "ON DUPLICATE KEY UPDATE last_read_seq = GREATEST(last_read_seq, #{seq})") void upsertLastReadSeq(String key, Long userId, Long seq);

3. 发送消息时分配序号

public ChatMsg saveMsg(Long sendId, Long receiveId, String content) { String convKey = buildKey(sendId, receiveId); // "小ID_大ID" long seq = conversationService.nextSeq(convKey); ChatMsg msg = new ChatMsg(); msg.setSeq(seq); // ... 其他字段 save(msg); return msg; }

4. 获取历史消息时计算已读状态

public ChatHistoryVO getHistoryMsg(Long userId, Long friendId) { String convKey = buildKey(userId, friendId); // 打开聊天时,先更新我的阅读水位 long myLastReadSeq = updateMyReadSeq(convKey, userId, friendId); // 查询对方的阅读水位(判断我发的消息是否被对方已读) long friendLastReadSeq = getLastReadSeq(convKey, friendId); // 查询消息列表 List<ChatMsg> list = list(...); // 计算每条消息的已读状态 for (ChatMsg m : list) { if (m.getReceiveUserId().equals(userId)) { // 对方发给我的:用我的水位判断 m.setReadStatus(m.getSeq() <= myLastReadSeq ? 1 : 0); } else if (m.getSendUserId().equals(userId)) { // 我发给对方的:用对方的水位判断 m.setReadStatus(m.getSeq() <= friendLastReadSeq ? 1 : 0); } } return new ChatHistoryVO(list, myLastReadSeq); }

5.WebSocket 推送已读回执

@PostMapping("/read") public Result<Void> readMsg(@RequestParam String username) { Long userId = UserHolder.getUserId(); User friend = userService.selectByUserName(username); long maxSeq = chatMsgService.readMsg(friend.getId(), userId); // 推送已读回执给发送方 if (maxSeq > 0) { ChatWebSocket.pushToUser(friend.getId(), "READ|" + maxSeq + "|" + userId); } return Result.success(null); }

前端实现

收到新消息时立即标记已读:

onPush: (msg) => { if (String(msg.sendUserId) === String(activeId.value)) { // 我在聊天界面内 → 立即标记已读 messages.value.push({ ...msg, readStatus: 1 // 本地立即标记 }); // 异步调用 readMsg,更新后端水位并推送回执 readMsg(activeUsername.value).catch(() => {}); } }

收到已读回执时批量更新状态:

onRead: (maxSeq, readerId) => { // readerId 已读到 maxSeq // 我发给 readerId 的消息中 seq <= maxSeq 的 → 已读 messages.value.forEach((m) => { if (String(m.sendUserId) === String(myId.value) && String(m.receiveUserId) === String(readerId) && m.seq <= maxSeq) { m.readStatus = 1; } }); }

关键结束点:

  • 会话标识固定格式:min(userId1, userId2) + “_” + max(userId1, userId2),保证双方使用同一 key
  • 原子递增序号:INSERT … ON DUPLICATE KEY UPDATE seq = seq + 1
  • 水位更新防回退:GREATEST(last_read_seq, #{seq}) 取最大值
  • 前端实时体验:收到消息立即本地标记已读,异步调用后端更新水位
  • WebSocket 推送格式:READ|maxSeq|readerId,一次推送同步所有历史状态