默认
打赏 发表评论 0
想开发IM:买成品怕坑?租第3方怕贵?找开源自已撸?别走弯路了... 找站长给点建议
详解IM消息已读未读功能的高并发技术实现(含生产级源码)
阅读(95) | 评论(0) 收藏1 淘帖1 赞1
扫一扫关注!

本文由银河技术分享,即时通讯网有修订和排版优化。


1、引言


即时通讯IM系统中的聊天消息已读未读功能,从来都不是UI上那个“小红点功能”那么简单,它本质上是一个横跨长连接、消息顺序、状态一致性、在线路由与高并发存储的完整系统工程。

本文不讲 Demo,而是站在IM生产环境的视角,完整拆解一套可水平扩展、可容灾、可演进的聊天消息单聊已读未读架构技术实现。

详解IM消息已读未读功能的高并发技术实现(含生产级源码)_cover_opti.png

2、 “已读未读”有多难?


很多团队第一次做 IM 或站内信,都会低估已读未读的复杂度。

在单机 Demo 中,这个问题看起来非常简单:

  • 1)消息写库;
  • 2)给接收人未读数 +1;
  • 3)用户打开会话时把消息标记为已读;
  • 4)通知发送人“对方已读”。

但一旦进入生产环境,问题会立刻升级:

  • 1)连接是分布式的:发送方在 ws-gateway-1,接收方可能在 ws-gateway-8;
  • 2)消息是异步流转的:写库、推送、ACK、未读数更新未必发生在同一个事务里;
  • 3)客户端可能乱序上报:先收到第 105 条消息的 ACK,再收到第 104 条 ACK;
  • 4)系统必须抗重复:Kafka 重投、客户端重发、网关重连都可能导致重复处理;
  • 5)系统必须可扩展:不能把已读状态压死在数据库行锁上;
  • 6)系统必须可恢复:节点宕机、Redis 短暂抖动、Kafka 消费回溯都不能把状态打乱。

所以,生产级已读未读系统的核心目标并不是“做出这个功能”,而是:

  • 1)正确性优先:已读位置单调递增,不能回退。
  • 2)顺序性可控:同一会话内消息与回执的业务顺序可推导。
  • 3)高并发可承载:写路径异步化,热点状态缓存化。
  • 4)系统可水平扩展:网关无状态,业务服务无会话粘性依赖。
  • 5)故障下可收敛:允许最终一致,但要保证最终正确。

3、“已读”到底是在实现什么?


很多系统后期返工,不是因为代码写错,而是因为语义定义不清。

单聊“已读未读”至少有三种常见定义:
详解IM消息已读未读功能的高并发技术实现(含生产级源码)_1.png

对于单聊生产系统,我建议优先采用:按会话维护“已读游标(read cursor)”。

即:

  • 1)每个会话(conversation)对每个参与人,只维护一个 last_read_message_seq;
  • 2)只要消息序号 <= last_read_message_seq,都视为已读;
  • 3)客户端只上报“我已经读到了哪一条”,而不是逐条 ACK。

这个模型有四个优势:

  • 1)状态量从“消息数”降到“会话数”;
  • 2)天然支持 ACK 合并,避免 ACK 风暴;
  • 3)更新逻辑天然幂等,适合 MQ 重试;
  • 4)很适合与 Redis、Kafka、MySQL 做组合架构。

4、彻底解耦长连接、消息存储、状态计算


生产环境不要把 WebSocket、消息写库、未读计算、回执推送全部堆在一个服务里。

推荐拆成如下角色:
详解IM消息已读未读功能的高并发技术实现(含生产级源码)_2.png

整体链路:
详解IM消息已读未读功能的高并发技术实现(含生产级源码)_3.png

为什么一定要解耦?

因为 WebSocket 网关的第一职责应该是:

  • 1)快速收包;
  • 2)轻量校验;
  • 3)异步投递;
  • 4)快速回写。

而不是:

  • 1)做复杂事务;
  • 2)查多张表;
  • 3)做长时间阻塞;
  • 4)参与高冲突状态更新。

一旦网关承担太多业务逻辑,系统会同时失去三件事:

  • 1)连接承载能力;
  • 2)扩容弹性;
  • 3)故障隔离能力。

5、数据模型设计


会话与消息表:
CREATE TABLE im_conversation (
    id BIGINT PRIMARY KEY,
    biz_type VARCHAR(32) NOT NULL DEFAULT 'single',
    user_a_id BIGINT NOT NULL,
    user_b_id BIGINT NOT NULL,
    last_msg_seq BIGINT NOT NULL DEFAULT 0,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_user_pair (user_a_id, user_b_id)
);

CREATE TABLE im_message (
    id BIGINT PRIMARY KEY,
    conversation_id BIGINT NOT NULL,
    msg_seq BIGINT NOT NULL,
    sender_id BIGINT NOT NULL,
    receiver_id BIGINT NOT NULL,
    msg_type VARCHAR(16) NOT NULL,
    content JSON NOT NULL,
    client_msg_id VARCHAR(64) NOT NULL,
    send_time DATETIME NOT NULL,
    status TINYINT NOT NULL DEFAULT 0,
    created_at DATETIME NOT NULL,
    UNIQUE KEY uk_client_msg_id (client_msg_id),
    UNIQUE KEY uk_conv_seq (conversation_id, msg_seq),
    KEY idx_receiver_time (receiver_id, created_at)
);

设计要点:

  • 1)msg_seq 必须在会话内单调递增;
  • 2)client_msg_id 用于发送幂等,避免客户端重试造成重复消息;
  • 3)uk_conv_seq 保证同一会话序号唯一。

已读游标表:
CREATE TABLE im_conversation_read_cursor (
    id BIGINT PRIMARY KEY,
    conversation_id BIGINT NOT NULL,
    user_id BIGINT NOT NULL,
    last_read_seq BIGINT NOT NULL DEFAULT 0,
    last_read_time DATETIME DEFAULT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_conv_user (conversation_id, user_id)
);

Redis 状态模型:建议 Redis 只承担高频读写缓存状态,不要把它当最终事实源。
在线路由
im:online:user:{userId} -> {gatewayInstanceId}   TTL=90s

会话已读游标
im:read:cursor:{conversationId} -> Hash
field={userId}, value={lastReadSeq}

会话未读数
im:unread:{userId} -> Hash
field={conversationId}, value={unreadCount}

网关本地会话索引
gateway:{instanceId}:users -> Set(userId)   TTL=90s

原则很简单:

  • 1)Redis 负责快;
  • 2)MySQL 负责准;
  • 3)Kafka 负责解耦与削峰。

6、关键原理:“游标推进”比“逐条更新”更稳


6.1已读推进的本质


已读操作本质不是“把一堆消息标记成已读”,而是:把某个用户在某个会话中的已读上界向前推进。

也就是:

old_cursor = 100
new_ack_seq = 105
最终 cursor = max(old_cursor, new_ack_seq) = 105


这带来两个决定性的工程收益:

  • 1)ACK 重复上报不会出错;
  • 2)ACK 乱序到达不会回退状态。

6.2未读数如何计算?


未读数有两种策略:
详解IM消息已读未读功能的高并发技术实现(含生产级源码)_4.png

生产环境建议两者结合:

  • 1)真实语义:由 last_msg_seq 与 last_read_seq 推导;
  • 2)高频展示:通过 Redis 冗余缓存 unreadCount。

这样做的好处是:即使缓存短暂不一致,也可以通过真实游标修正。

7、从发消息到已读通知的完整闭环


发送消息时序:

  • 1)Client A 通过 WebSocket 发送消息包,携带 clientMsgId;
  • 2)ws-gateway 完成鉴权与协议校验,把事件投递到 chat.send topic;
  • 3)chat-service 消费事件;
  • 4)以 clientMsgId 做幂等去重;
  • 5)为该会话分配新的 msg_seq;
  • 6)消息写入 MySQL;
  • 7)Redis 中接收人的未读数 +1;
  • 8)生产 push.message 事件;
  • 9)push-router 查询接收人的在线网关;
  • 10)对在线用户做实时推送;不在线则保留离线拉取。

已读 ACK 时序:

  • 1)Client B 进入会话页,并向服务端上报 ackSeq=105;
  • 2)ws-gateway 把 ACK 投递到 receipt.ack topic;
  • 3)receipt-service 消费 ACK;
  • 4)用原子脚本推进 last_read_seq=max(old, ackSeq);
  • 5)扣减 Redis 未读数;
  • 6)异步更新 MySQL im_conversation_read_cursor;
  • 7)生产 push.read-notify 事件;
  • 8)push-router 把已读通知路由给发送人所在网关;
  • 9)Client A 收到“对方已读至 105”事件。

8、生产级代码示例:不只能跑,还能上线


下面给出一套更接近生产工程的代码骨架。

WebSocket 接入层:
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    private final ChatWebSocketHandler chatWebSocketHandler;
    private final AuthHandshakeInterceptor authHandshakeInterceptor;

    public WebSocketConfig(ChatWebSocketHandler chatWebSocketHandler,
                           AuthHandshakeInterceptor authHandshakeInterceptor) {
        this.chatWebSocketHandler = chatWebSocketHandler;
        this.authHandshakeInterceptor = authHandshakeInterceptor;
    }

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(chatWebSocketHandler, "/ws/chat")
                .addInterceptors(authHandshakeInterceptor)
                .setAllowedOriginPatterns("*");
    }
}

握手鉴权:
@Component
public class AuthHandshakeInterceptor implements HandshakeInterceptor {

    private final JwtTokenService jwtTokenService;

    public AuthHandshakeInterceptor(JwtTokenService jwtTokenService) {
        this.jwtTokenService = jwtTokenService;
    }

    @Override
    public boolean beforeHandshake(ServerHttpRequest request,
                                   ServerHttpResponse response,
                                   WebSocketHandler wsHandler,
                                   Map<String, Object> attributes) {
        String token = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
        if (token == null || !token.startsWith("Bearer ")) {
            response.setStatusCode(HttpStatus.UNAUTHORIZED);
            return false;
        }

        UserPrincipal principal = jwtTokenService.parse(token.substring(7));
        attributes.put("userId", principal.userId());
        attributes.put("deviceId", principal.deviceId());
        return true;
    }

    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
                               WebSocketHandler wsHandler, Exception exception) {
    }
}

本地连接管理:
@Component
public class SessionManager {

    private final ConcurrentMap<Long, CopyOnWriteArraySet<WebSocketSession>> userSessions =
            new ConcurrentHashMap<>();

    public void register(Long userId, WebSocketSession session) {
        userSessions.computeIfAbsent(userId, k -> new CopyOnWriteArraySet<>()).add(session);
    }

    public void unregister(Long userId, WebSocketSession session) {
        CopyOnWriteArraySet<WebSocketSession> sessions = userSessions.get(userId);
        if (sessions == null) {
            return;
        }
        sessions.remove(session);
        if (sessions.isEmpty()) {
            userSessions.remove(userId);
        }
    }

    public boolean isOnlineLocally(Long userId) {
        CopyOnWriteArraySet<WebSocketSession> sessions = userSessions.get(userId);
        return sessions != null && !sessions.isEmpty();
    }

    public void sendToUser(Long userId, String payload) {
        CopyOnWriteArraySet<WebSocketSession> sessions = userSessions.get(userId);
        if (sessions == null) {
            return;
        }
        for (WebSocketSession session : sessions) {
            if (session.isOpen()) {
                try {
                    session.sendMessage(new TextMessage(payload));
                } catch (IOException ignored) {
                }
            }
        }
    }
}

网关只做轻逻辑:
@Component
public class ChatWebSocketHandler extends TextWebSocketHandler {

    private final SessionManager sessionManager;
    private final PresenceService presenceService;
    private final UpstreamEventPublisher upstreamEventPublisher;
    private final ObjectMapper objectMapper;

    public ChatWebSocketHandler(SessionManager sessionManager,
                                PresenceService presenceService,
                                UpstreamEventPublisher upstreamEventPublisher,
                                ObjectMapper objectMapper) {
        this.sessionManager = sessionManager;
        this.presenceService = presenceService;
        this.upstreamEventPublisher = upstreamEventPublisher;
        this.objectMapper = objectMapper;
    }

    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        Long userId = (Long) session.getAttributes().get("userId");
        sessionManager.register(userId, session);
        presenceService.online(userId);
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        Long userId = (Long) session.getAttributes().get("userId");
        WsPacket packet = objectMapper.readValue(message.getPayload(), WsPacket.class);

        switch (packet.type()) {
            case CHAT_SEND -> upstreamEventPublisher.publishChatSend(userId, packet);
            case READ_ACK -> upstreamEventPublisher.publishReadAck(userId, packet);
            case PING -> presenceService.renew(userId);
            default -> throw new IllegalArgumentException("unsupported packet type");
        }
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        Long userId = (Long) session.getAttributes().get("userId");
        sessionManager.unregister(userId, session);
        presenceService.offlineIfNoSession(userId);
    }
}

这段代码有一个很重要的生产原则:网关不直接写业务库,不直接推进已读,不直接计算未读。

它只负责:

  • 1)接包;
  • 2)轻量校验;
  • 3)丢进 Kafka;
  • 4)维护连接。

9、消息发送链路一次讲透


发送事件模型:
public record ChatSendEvent(
        String eventId,
        Long conversationId,
        Long senderId,
        Long receiverId,
        String clientMsgId,
        String msgType,
        String content,
        Instant occurredAt
) {
}

Kafka 生产:
@Component
public class UpstreamEventPublisher {

    private final KafkaTemplate<String, Object> kafkaTemplate;

    public UpstreamEventPublisher(KafkaTemplate<String, Object> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }

    public void publishChatSend(Long userId, WsPacket packet) {
        ChatSendEvent event = new ChatSendEvent(
                UUID.randomUUID().toString(),
                packet.conversationId(),
                userId,
                packet.peerUserId(),
                packet.clientMsgId(),
                packet.msgType(),
                packet.content(),
                Instant.now()
        );
        kafkaTemplate.send("chat.send", String.valueOf(packet.conversationId()), event);
    }

    public void publishReadAck(Long userId, WsPacket packet) {
        ReadAckEvent event = new ReadAckEvent(
                UUID.randomUUID().toString(),
                packet.conversationId(),
                userId,
                packet.ackSeq(),
                Instant.now()
        );
        kafkaTemplate.send("receipt.ack", String.valueOf(packet.conversationId()), event);
    }
}

这里选择 conversationId 作为分区键,是因为:

  • 1)同一会话内消息必须尽量保持顺序;
  • 2)已读 ACK 也希望进入同一分区,降低乱序复杂度。

消息持久化服务:
@Service
public class ChatCommandService {

    private final ConversationRepository conversationRepository;
    private final MessageRepository messageRepository;
    private final UnreadCacheService unreadCacheService;
    private final PushEventPublisher pushEventPublisher;

    @Transactional
    public void handle(ChatSendEvent event) {
        if (messageRepository.existsByClientMsgId(event.clientMsgId())) {
            return;
        }

        Conversation conversation = conversationRepository.lockById(event.conversationId())
                .orElseThrow();

        long nextSeq = conversation.getLastMsgSeq() + 1;
        conversation.setLastMsgSeq(nextSeq);
        conversationRepository.save(conversation);

        Message message = Message.builder()
                .conversationId(event.conversationId())
                .msgSeq(nextSeq)
                .senderId(event.senderId())
                .receiverId(event.receiverId())
                .msgType(event.msgType())
                .content(event.content())
                .clientMsgId(event.clientMsgId())
                .sendTime(LocalDateTime.now())
                .build();
        messageRepository.save(message);

        unreadCacheService.increment(event.receiverId(), event.conversationId(), 1L);
        pushEventPublisher.publishMessage(message);
    }
}

这里用了一个常见但很有效的做法:对会话行加锁,串行分配 msg_seq。

对于单聊来说,会话维度天然足够细,这种做法比全局序列更简单,也更容易保证顺序。

如果未来要进一步扩展到极高吞吐,可把会话序号分配升级为:

  • 1)Redis 原子自增;
  • 2)Snowflake + 会话内重排;
  • 3)号段分配器。

但大多数中大型业务,先把正确性做稳,比过早设计复杂序列更重要。

10、已读推进链路:用Lua保证原子性与幂等


ACK 事件:
public record ReadAckEvent(
        String eventId,
        Long conversationId,
        Long userId,
        Long ackSeq,
        Instant occurredAt
) {
}

Redis Lua 脚本:
local key = KEYS[1]
local field = ARGV[1]
local newSeq = tonumber(ARGV[2])

local oldSeq = redis.call('HGET', key, field)
if oldSeq then
    oldSeq = tonumber(oldSeq)
else
    oldSeq = 0
end

if newSeq <= oldSeq then
    return {oldSeq, oldSeq}
end

redis.call('HSET', key, field, newSeq)
return {oldSeq, newSeq}

已读服务实现:
@Service
public class ReadReceiptService {

    private final StringRedisTemplate redisTemplate;
    private final ReadCursorRepository readCursorRepository;
    private final UnreadCacheService unreadCacheService;
    private final PushEventPublisher pushEventPublisher;
    private final DefaultRedisScript<List> advanceCursorScript;

    @Transactional
    public void handle(ReadAckEvent event) {
        String key = "im:read:cursor:" + event.conversationId();
        List<Long> result = redisTemplate.execute(
                advanceCursorScript,
                Collections.singletonList(key),
                String.valueOf(event.userId()),
                String.valueOf(event.ackSeq())
        );

        long oldSeq = result.get(0);
        long newSeq = result.get(1);
        if (newSeq <= oldSeq) {
            return;
        }

        unreadCacheService.decreaseByCursor(event.userId(), event.conversationId(), oldSeq, newSeq);
        readCursorRepository.upsert(event.conversationId(), event.userId(), newSeq, LocalDateTime.now());
        pushEventPublisher.publishReadNotify(event.conversationId(), event.userId(), newSeq);
    }
}

这个实现有三个生产级特征:

  • 1)单调推进:永远不会回退;
  • 2)天然幂等:重复 ACK 无副作用;
  • 3)支持乱序:晚到的小序号 ACK 会被直接忽略。

11、推送不要广播全网关,尽量精准投递


很多文章讲到跨节点推送时,会直接建议:

  • 1)所有网关都订阅一个推送 topic;
  • 2)收到后自己判断目标用户是否在本机。

这个方案简单,但在实例多、在线用户多时,广播成本会越来越高。

推荐做法:在线路由表 + 定向推送。

用户上线时:

im: online:user: 10001 -> ws-gateway-03


推送时:

  • 1)push-router 先查 Redis,找到目标网关实例;
  • 2)以 gatewayInstanceId 作为 Kafka key 发送到 gateway.push;
  • 3)只有对应网关消费并投递。

路由服务示例:
@Service
public class PushRouterService {

    private final PresenceService presenceService;
    private final KafkaTemplate<String, Object> kafkaTemplate;

    public void routeMessage(Long userId, Object payload) {
        String gatewayId = presenceService.findGateway(userId);
        if (gatewayId == null) {
            return;
        }
        GatewayPushEvent event = new GatewayPushEvent(gatewayId, userId, payload);
        kafkaTemplate.send("gateway.push", gatewayId, event);
    }
}

网关消费定向推送:
@KafkaListener(topics = "gateway.push", groupId = "ws-gateway")
public void onGatewayPush(GatewayPushEvent event) {
    if (!localInstanceId.equals(event.gatewayId())) {
        return;
    }
    sessionManager.sendToUser(event.userId(), toJson(event.payload()));
}

注意这里的消费模型要根据你的 Kafka group 策略设计:

  • 1)如果一个网关实例对应一个独立消费者身份,可以精准消费;
  • 2)如果所有网关共用一个 group,则要确保 topic 分区与实例映射清晰。

很多团队这里会踩坑。

我的建议是:

  • 1)中小规模:Redis Pub/Sub 或广播消费就够用;
  • 2)中大规模:独立 push-router + Kafka 定向 topic 更稳。

12、未读数缓存不是事实源,修正机制必须存在


首页会话列表、消息角标、应用角标都会频繁读取未读数,所以未读数必须走缓存。

但缓存未读数很容易在以下场景中漂移:

  • 1)消费失败导致少加一次;
  • 2)ACK 重试导致多减一次;
  • 3)补偿任务重复回放;
  • 4)节点故障导致中间状态未提交。

因此,生产设计必须遵循一个原则:未读数可以短暂不一致,但必须可重算、可修正。

推荐做法:

  • 1)Redis 维护热未读数,服务接口优先读 Redis;
  • 2)MySQL 持久化已读游标与最后消息序号;
  • 3)定时任务对热点会话做校准。

Redis 未读数接口:
@Service
public class UnreadCacheService {

    private final StringRedisTemplate redisTemplate;

    public void increment(Long userId, Long conversationId, Long delta) {
        redisTemplate.opsForHash().increment(
                "im:unread:" + userId,
                String.valueOf(conversationId),
                delta
        );
    }

    public void decreaseByCursor(Long userId, Long conversationId, long oldSeq, long newSeq) {
        long delta = Math.max(0, newSeq - oldSeq);
        if (delta == 0) {
            return;
        }
        redisTemplate.opsForHash().increment(
                "im:unread:" + userId,
                String.valueOf(conversationId),
                -delta
        );
    }
}

严格来说:这里的 delta = newSeq - oldSeq 只有在单聊且消息连续属于未读区间时才完全准确。

所以更稳妥的生产做法是:

  • 1)单聊场景:可以直接用游标差值;
  • 2)群聊或复杂过滤场景:应通过消息表区间统计或批量预聚合计算。

13、真实场景:电商客服单聊已读未读如何落地?


为了避免文章停留在抽象层,我们用一个真实业务来代入。

电商平台里,一个用户与一个客服建立单聊会话:

  • 1)用户发起咨询;
  • 2)客服工作台实时接收消息;
  • 3)客服进入会话页后,用户端应看到“已读”;
  • 4)用户离线时,客服回复只增加未读数,不做实时送达。

会话列表接口需要什么?

会话列表通常要一次返回:

  • 1)最后一条消息摘要;
  • 2)最后一条消息时间;
  • 3)当前未读数;
  • 4)对方在线状态;
  • 5)是否已读到最新。

这意味着你的接口不能每次都去扫消息表。

推荐的聚合模型是:
详解IM消息已读未读功能的高并发技术实现(含生产级源码)_5.png

前端 ACK 合并策略:前端不要逐条消息上报已读。

更稳的策略是:

  • 1)用户进入会话页;
  • 2)拉到最新消息位置;
  • 3)记录当前可见最大消息序号;
  • 4)延迟 200ms 到 500ms 合并发送一次 ackSeq;
  • 5)页面停留期间继续按节流策略上报更大游标。

这样可以把一屏几十条消息的 ACK 操作合并为 1 次,极大降低后端压力。

14、高并发优化:瓶颈通常不在你以为的地方


14.1常见瓶颈一:网关线程被阻塞


症状:

  • 1)WebSocket 收包延迟升高;
  • 2)心跳超时变多;
  • 3)连接数一上来就不稳。

原因通常是:

  • 1)在 handleTextMessage 里直接查库;
  • 2)做同步 JSON 重处理;
  • 3)调下游 RPC。

解决方案:

  • 1)接入层只做轻逻辑;
  • 2)大对象序列化异步化;
  • 3)限流与背压前置。

14.2常见瓶颈二:数据库热点锁


如果你把“逐条消息已读更新”做成:
update im_message set read_flag = 1 where conversation_id = ? and receiver_id = ? and msg_seq <= ?

在高并发下,这会非常痛苦:

  • 1)更新行数大;
  • 2)行锁范围广;
  • 3)redo/undo 压力大;
  • 4)容易拖慢主库。

游标模型就是为了解决这个问题。

14.3常见瓶颈三:推送全网广播


网关实例从 5 台增长到 50 台后,广播推送的内部流量会指数放大。

所以系统演进通常是:

  • 1)初期广播;
  • 2)中期按用户在线节点定向;
  • 3)后期按用户分片粘性路由,减少跨节点推送。

14.4Kafka 参数建议


生产环境可以重点关注:

  • 1)acks=all
  • 2)enable.idempotence=true
  • 3)max.in.flight.requests.per.connection=1 或结合业务顺序评估;
  • 4)消费端手动提交 offset;
  • 5)分区键统一使用 conversationId。

如果业务特别看重“消息事件”和“已读事件”的先后关系,可以把二者设计进同一有序流,或者在客户端加序列缓冲。

15、系统出问题时,状态还能不能收回来?


你无法避免最终一致,但可以保证最终正确。在这类系统里,不要幻想所有动作都强一致。

真实情况通常是:

  • 1)消息写库成功了,但推送失败;
  • 2)ACK 入 Kafka 了,但数据库持久化稍后才完成;
  • 3)Redis 更新了,但消费者刚好重启了。

生产级设计追求的是:

  • 1)核心状态单调;
  • 2)事件可重复消费;
  • 3)缓存可重建;
  • 4)数据可补偿。

建议增加的保障机制:

  • 1)发送幂等:clientMsgId 唯一索引;
  • 2)ACK 幂等:游标只前进不回退;
  • 3)消息 Outbox:写库与发事件解耦时,用 Outbox 保证可靠投递;
  • 4)补偿任务:定期校准未读数与游标;
  • 5)死信队列:Kafka 消费异常进入 DLQ,避免吞事件。

一个很容易忽视的点:客户端乱序展示。

即使服务端已保证大部分顺序,客户端仍可能因为:

  • 1)重连补拉;
  • 2)历史消息与实时消息并发到达;
  • 3)已读通知先于正文到达。

导致视觉错乱。

因此客户端至少要支持:

  • 1)按 msg_seq 排序渲染;
  • 2)对“未来消息”做短暂缓冲;
  • 3)已读通知如果引用的消息尚未到达,先缓存再关联。

16、容器化与云原生部署


16.1WebSocket 网关的 K8s 关键配置


apiVersion: apps/v1
kind: Deployment
metadata:
  name: ws-gateway
spec:
  replicas: 4
  template:
    spec:
      containers:
        - name: ws-gateway
          image: registry.example.com/ws-gateway:1.0.0
          ports:
            - containerPort: 8080
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
          lifecycle:
            preStransform: translateY(
              exec:
                command:
                  - /bin/sh
                  - -c
                  - "sleep 15"

16.2为什么 preStop 很重要?


因为 WebSocket 场景下,实例被摘流不等于连接立刻迁走。

推荐策略:

  • 1)先让 Pod 进入 not ready;
  • 2)等待负载均衡不再分配新连接;
  • 3)给老连接一点迁移和重连时间;
  • 4)TTL 到期后 Redis 在线路由自然收敛。

16.3在线状态 TTL 的经验值


假设客户端心跳间隔是 30 秒:

  • 1)Redis TTL 建议设为 90 秒;
  • 2)即心跳间隔的 3 倍左右。

这样既能避免短抖动导致频繁误判离线,也不会让“幽灵在线”持续太久。

17、架构演进:从1万连接到百万在线


如果你准备长期做这套系统,建议按下面路线演进,而不是一开始就过度设计。

第一阶段:单体可用

  • 1)单个 WebSocket 服务;
  • 2)MySQL 存消息;
  • 3)Redis 存未读数。

适合验证功能与产品模型。

第二阶段:网关与业务拆分

  • 1)长连接网关独立;
  • 2)消息写库异步化;
  • 3)ACK 异步化。

这一步通常就能支撑大部分业务初期增长。

第三阶段:多网关分布式

  • 1)Redis 存在线路由;
  • 2)Kafka 解耦业务事件;
  • 3)推送支持跨节点路由。

第四阶段:高并发精细治理

  • 1)推送定向化;
  • 2)热会话分片;
  • 3)未读数校准任务;
  • 4)客户端 ACK 合并。

第五阶段:大规模与多地域

  • 1)用户路由分片;
  • 2)单元化部署;
  • 3)跨机房就近接入;
  • 4)多活容灾与数据异步同步。

18、一套真正能落地的“已读未读”方案就该这样


如果你读到这里,会发现一个事实:

“已读未读”根本不是在消息表上加一个 read_flag 那么简单。


真正生产级的实现,至少要同时满足下面几点:

  • 1)模型正确:使用会话级已读游标,而不是逐条消息状态更新;
  • 2)接入解耦:WebSocket 网关只负责连接与事件转发;
  • 3)链路异步:消息、ACK、推送全部走事件流解耦;
  • 4)状态可恢复:Redis 做缓存,MySQL 做最终状态,Kafka 做中枢;
  • 5)高并发友好:通过游标推进、ACK 合并、定向推送降低系统成本;
  • 6)工程可演进:从广播到定向,从单区到单元化,路径清晰。

一句话总结这套架构:

用“会话已读游标”解决状态模型问题,用“Kafka + Redis + MySQL 分层”解决高并发与一致性问题,用“网关无状态化”解决扩展问题。


如果你正在做客服、IM、协同办公、消息中心,这套方案足够作为第一版生产架构的基线。

后续如果你要继续演进,还可以进一步扩展:

  • 1)多端同步已读;
  • 2)群聊已读列表;
  • 3)离线推送融合;
  • 4)消息撤回与已读状态联动;
  • 5)基于 Netty/Reactor 的超大连接承载优化。

19、最后的建议


最后给三条非常实用的建议:

  • 1)先把语义定死,再写代码:已读是“看到消息”还是“进入会话页”?是按消息还是按游标?这些必须先统一。
  • 2)先保证单调正确,再追求极致性能:已读游标不能回退、消息不能乱、未读数能修正,这比“少一次 Redis 操作”重要得多。
  • 3)一定要做压测与故障演练。

包括但不限于:

  • 3.1)Kafka 重复消费;
  • 3.2)网关实例重启;
  • 3.3)Redis 短时不可用;
  • 3.4)ACK 暴增;
  • 3.5)客户端断线重连与补拉。

只有这些场景都走过,你的“已读未读”才真正算得上生产级。

20、参考资料


[1] 零基础IM开发入门(一):什么是IM聊天系统?
[2] 零基础IM开发入门(四):什么是IM聊天系统的消息时序一致性?
[3] IM热门功能思考:为什么微信里没有消息“已读”功能?
[4] IM群聊消息的已读未读功能在存储空间方面的实现思路探讨
[5] IM群聊消息的已读回执功能该怎么实现?
[6] 企业微信的IM架构设计揭秘:消息模型、万人群、已读回执、消息撤回等
[7] 阿里IM技术分享(一):企业级IM王者——钉钉在后端架构上的过人之处
[8] 如何保证IM实时消息的“时序性”与“一致性”?
[9] 浅谈移动端IM的多点登录和消息漫游原理
[10] 一套亿级用户的IM架构技术干货(下篇):可靠性、有序性、弱网优化等
[11] 阿里IM技术分享(七):闲鱼IM的在线、离线聊天数据同步机制优化实践
[12] IM开发干货分享:浅谈IM系统中离线消息、历史消息的最佳实践
[13] 如何保障分布式IM聊天系统的消息可靠性(即消息不丢)
[14] IM踩坑:聊天消息ID怎么设计?一个UUID搞不定,我用双ID解决
[15] 一套海量在线用户的移动端IM架构设计实践分享(含详细图文)
[16] 一套亿级用户的IM架构技术干货(下篇):可靠性、有序性、弱网优化等
[17] 融云技术分享:全面揭秘亿级IM消息的可靠投递机制
[18] 微信团队分享:来看看微信十年前的IM消息收发架构,你做到了吗
[19] 深度剖析IM消息乱序的根本原因及优化方法

即时通讯网 - 即时通讯开发者社区! 来源: - 即时通讯开发者社区!

上一篇:IM踩坑:聊天消息ID怎么设计?一个UUID搞不定,我用双ID解决

本帖已收录至以下技术专辑

推荐方案
打赏楼主 ×
使用微信打赏! 使用支付宝打赏!

返回顶部