好友聊天已读状态总结

在即时通讯系统中,用户需要知道发送的消息是否被对方阅读。传统方案是为每条消息单独存储 is_read 字段,但这种方式在高并发场景下会导致数据库压力过大------每收到一条消息就要更新一条记录,消息量增长后性能急剧下降。

为解决这个问题,我借鉴了一些大佬的想法,基于IM系统的设计,采用会话维度水位记录而不是逐条标记;

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

方案对比:

数据库设计:

  1. 消息表新增序号列

    ALTER TABLE chat_msg ADD COLUMN seq BIGINT NOT NULL DEFAULT 0 COMMENT '会话内单调递增序号';

  2. 会话序号分配表

    CREATE TABLE conversation_seq (
    conversation_key VARCHAR(50) PRIMARY KEY COMMENT '会话标识:小ID_大ID',
    seq BIGINT NOT NULL DEFAULT 0 COMMENT '下一条消息的序号'
    );

  3. 会话阅读水位表

    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,一次推送同步所有历史状态