在即时通讯系统中,用户需要知道发送的消息是否被对方阅读。传统方案是为每条消息单独存储 is_read 字段,但这种方式在高并发场景下会导致数据库压力过大------每收到一条消息就要更新一条记录,消息量增长后性能急剧下降。
为解决这个问题,我借鉴了一些大佬的想法,基于IM系统的设计,采用会话维度水位记录而不是逐条标记;
核心思想:
- 每条消息在会话内分配单调递增序号 seq
- 每个用户在每个会话只存一条"阅读水位"记录
- 判断规则:消息.seq <= 用户.last_read_seq → 已读
方案对比:

数据库设计:
-
消息表新增序号列
ALTER TABLE chat_msg ADD COLUMN seq BIGINT NOT NULL DEFAULT 0 COMMENT '会话内单调递增序号';
-
会话序号分配表
CREATE TABLE conversation_seq (
conversation_key VARCHAR(50) PRIMARY KEY COMMENT '会话标识:小ID_大ID',
seq BIGINT NOT NULL DEFAULT 0 COMMENT '下一条消息的序号'
); -
会话阅读水位表
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,一次推送同步所有历史状态