消息可靠性:用 outbox 模式把 IM 消息投递变得靠谱

SailTrack
2026-05-24
点 赞
0
热 度
17
评 论
0
  1. 首页
  2. 后端开发
  3. 消息可靠性:用 outbox 模式把 IM 消息投递变得靠谱

消息可靠性:用 outbox 模式把 IM 消息投递变得靠谱

做即时通讯的时候,最怕的不是消息发不出去,而是消息发出去了对方没收到——你以为通知到位了,对方以为你没通知。

我们在 Workforce Hub 的 IM 模块里踩了不少坑,最后用 outbox 模式 + 定时调度 + 死信队列的思路把可靠性搭了出来。这篇就把这条路走一遍。

一、问题在哪?——WebSocket 推送的三个坑

上一篇讲了用 WebSocket 做实时推送,看起来挺简单的:服务端 session.sendMessage() 一发,客户端就收到了。

但实际跑起来,问题一个接一个。下面是我踩过的三个最深坑——

坑一:双写不一致——事务还没提交,推送已经发出去了

第一版代码大概是这样的:

//  第一版:天真模式
@Transactional
public void sendMessage(SendImMessageRequest req) {
    ImMessage saved = messageRepository.insert(req);   // ① INSERT
    wsSession.sendToUser(req.toUserId(), saved);        // ② 推送
    // ③ 如果后面抛异常,事务回滚 →
    //    但是②已经推了!用户看到了,刷新页面又没了
}

问题本质insert 走 JDBC 事务,sendToUser 走 WebSocket,两者不在同一个原子边界里。

具体踩坑场景:

@Transactional
public void sendMessage(SendImMessageRequest req) {
    ImMessage saved = messageRepository.insert(req);
    wsSession.sendToUser(req.toUserId(), saved);
    
    // 这里抛异常了!比如敏感词检测不过
    contentModerationService.check(saved.getContent());
    // → 事务回滚,saved 不存在了
    // → 但 wsSession.sendToUser 已经发出去了!
}

用户 A 看到消息发出去了(绿色 ),用户 B 刷新后消息消失——“你是不是把消息撤回了?”“没有啊我发了的。”

不是 bug,是架构缺陷——两个写操作(数据库 + WebSocket)天然无法在一个事务里。

坑二:离线就丢——静默失败,没人知道

//  第二版:加了状态判断,但没解决根本问题
public void pushToUsers(List<String> userIds, ImWebSocketEvent event) {
    for (String userId : userIds) {
        String sessionId = sessionRegistry.getSessionId(userId);
        if (sessionId == null) {
            log.warn("用户 {} 不在线,跳过推送", userId);
            return;  // ← 直接 return!消息丢了!
        }
        wsSession.sendToUser(sessionId, event);
    }
}

用户 A 发给用户 B,B 手机没电关机了。sessionRegistry 里没有 B 的会话 → log.warn 打了一行日志 → 没了。

没有重试、没有离线队列、没有任何兜底。用户 B 开机后永远不知道 A 说了什么。

这个坑比坑一更严重——坑一至少能手动重发,坑二是直接丢了,连重发的机会都没有。

坑三:服务重启——内存里的"没推完"全丢了

// 推送状态只能在内存里,靠 Map<sessionId, wsSession> 持有
// 一旦进程重启:
//   - WebSocket 连接全断
//   - 正在推送中的消息丢失
//   - 还没来得及推送的也丢了
//   - MySQL 里数据还在,但没人知道"这些消息还没推"

画个时间线:

12:00:00  用户 A 发消息 → INSERT 成功 
12:00:01  还没来得及 push → OOM Kill → JVM 进程挂 
12:00:05  新进程启动 → 没有任何记录表明"这条消息没推"
12:30:00  用户 B 上线 → 看不到 A 的消息

这条消息变成了「僵尸消息」——数据库里有,但没有任何机制能发现它还没送达。

三个坑的共同解

三个坑串起来看,本质是同一个问题:推送操作不可靠,但没有持久化的"投递状态"来追踪

根因

解法

双写不一致

insert + push 无事务边界

替换为 insert + outbox,事务提交后异步 push

离线丢消息

离线 = 丢弃,无重试

outbox 表持久化,离线用户下次上线也能被扫到

重启丢状态

推送状态只在内存

推送状态持久化到 outbox 表

一句话总结:推送不应该发生在主业务流程里,它应该是一个独立的、有状态追踪的异步过程。

二、outbox 模式——先把信放进邮筒

outbox 模式的核心思想很简单:不直接推,先写表。

WebSocket推送的三个坑

整个链路变成三步:

  1. 写入:把推送事件写入 im_event_outbox 表,跟消息数据在同一个事务里

  2. 调度:独立的定时任务从 outbox 表拉取待投递事件

  3. 投递:逐个推送,成功标 SENT,失败标 FAILED + 安排重试

outbox 表结构(DDL + 索引踩坑)

CREATE TABLE im_event_outbox (
    id              BIGINT AUTO_INCREMENT PRIMARY KEY,
    event_type      VARCHAR(32)   NOT NULL COMMENT 'message.created / conversation.updated',
    conversation_id BIGINT        NOT NULL,
    message_id      BIGINT        NOT NULL COMMENT '关联消息ID',
    seq_no          BIGINT        NOT NULL COMMENT '消息序号,保证顺序',
    sender_id       BIGINT        NOT NULL,
    receiver_id     BIGINT        NOT NULL COMMENT '接收方用户ID',
    payload         JSON          NOT NULL COMMENT '消息体快照,推送时直接用',
    status          VARCHAR(16)   NOT NULL DEFAULT 'PENDING'
                    COMMENT 'PENDING → SENDING → SENT/FAILED/DEAD',
    retry_count     INT           NOT NULL DEFAULT 0,
    next_retry_at   DATETIME      NOT NULL COMMENT '下次可重试时间',
    claimed_at      DATETIME      NULL     COMMENT '被调度器认领的时间',
    created_at      DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at      DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP
                    ON UPDATE CURRENT_TIMESTAMP,
    
    -- ⭐ 最关键索引:调度器每2秒扫一次
    INDEX idx_status_retry (status, next_retry_at),
    -- 辅助索引:按接收方查投递历史
    INDEX idx_receiver_time (receiver_id, created_at),
    -- 辅助索引:按消息ID查投递状态
    INDEX idx_message_id (message_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

索引选择的血泪教训

idx_status_retry(status, next_retry_at) 是最重要的索引——调度器的核心查询是:

SELECT id FROM im_event_outbox
WHERE (status IN ('PENDING', 'FAILED') AND next_retry_at <= NOW())
   OR (status = 'SENDING' AND claimed_at < NOW() - INTERVAL 60 SECOND)
ORDER BY next_retry_at ASC
LIMIT 100
FOR UPDATE SKIP LOCKED

我一开始只给 status 加了单列索引,结果 next_retry_at 的范围扫描不走索引,每次全表扫描十几万行,调度周期 CPU 直接飙到 60%。加上联合索引后降到 2ms。

payload 字段为什么要 JSON 快照? 因为 outbox 生命周期可能比消息短(死信会被清理),而且推送时不需要再 JOIN messages 表——payload 里就是完整内容,少一次查询。

写入代码

@Transactional
public void sendMessage(SendImMessageRequest req) {
    // ① 存消息
    ImMessage saved = messageRepository.insert(req);
    
    // ② 构造 outbox 事件
    ImWebSocketEvent event = ImWebSocketEvent.of(
        "message.created", saved.conversationId(),
        saved.id(), saved.seqNo(), saved.senderId(), saved
    );
    // ③ 写入 outbox——跟 ① 在同一个事务里
    outboxService.enqueue(List.of(req.toUserId()), event);
    
    // ④ 事务提交后才触发推送(afterCommit 回调)
    pushService.pushToUsersAfterCommit(List.of(req.toUserId()), event);
}

pushToUsersAfterCommit 的实现:

public void pushToUsersAfterCommit(List<String> userIds, ImWebSocketEvent event) {
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                // 事务真的提交了,才推
                dispatchService.dispatch(userIds, event);
            }
        }
    );
}

关键点afterCommit 保证消息落库 + outbox 落库都成功了才开始推送。事务回滚 → outbox 也回滚 → 调度器扫不到 → 不会推送。坑一解决。

三、投递调度的细节——几个写代码时容易忽略的问题

光是"定时扫表 + 推送"听着简单,但细节决定可靠性。

3.1 竞争条件——多个调度器实例不能抢同一个事件

生产环境通常会跑多个实例(比如两个 pod),如果两个实例的定时任务同时抢到同一条记录,消息就会被投递两次。

FOR UPDATE SKIP LOCKED 解决:

SELECT id FROM im_event_outbox
WHERE (status IN ('PENDING', 'FAILED') AND next_retry_at <= ?)
   OR (status = 'SENDING' AND claimed_at < ?)
ORDER BY next_retry_at ASC, id ASC
LIMIT ?
FOR UPDATE SKIP LOCKED

FOR UPDATE SKIP LOCKED 的意思是:把符合条件的行锁住,但已经被其他事务锁住的行直接跳过不阻塞。这样两个实例各抢各的,互不影响。

3.2 状态机——PENDING → SENDING → SENT/FAILED/DEAD

outbox投递状态机

四种状态:PENDING(待投递)、SENDING(正在投递)、SENT(已投递)、FAILED(失败等重试)、DEAD(彻底失败)。

关于 SENDING 状态:调度器认领事件后会先 markSending,记录 claimed_at 时间。如果投递过程中调度器挂了(进程 crash),这个事件就卡在 SENDING 再也不动了。所以在抢事件的 SQL 里加了一个"防卡死"逻辑:

-- stale 检测:超过 60 秒还卡在 SENDING 的,允许其他实例捡起来
OR (status = 'SENDING' AND claimed_at < ?)

这是调度系统中常见的"认领过期"机制——超时了就允许重新争夺。

3.3 重试策略——指数退避 + 最大次数

// 每次失败延迟翻倍,最多重试 5 次,之后进死信
retryDelayMs: 5000, 20000, 80000, 320000, 1280000
maxAttempts: 5              // 超过后标记 DEAD

不需要指数退避?这里的 retryDelayMs 实际上是固定的(5 秒),因为 ImEventOutboxRepository.markFailed 直接把 nextRetryAt 设为了 now + retryDelayMs,没有翻倍逻辑——这是当前实现的一个简化点,后续可以优化。

为什么不是指数退避? 说实话这里偷了懒——后续版本可以改成 retryDelayMs * 2^(retryCount-1)。但目前看 5 次重试 + 5 秒间隔在实际使用中已经足够覆盖瞬时网络抖动。

3.4 死信处理——重试 5 次还失败怎么办?

boolean dead = nextRetryCount >= maxAttempts;
outboxRepository.markFailed(id, dead ? "DEAD" : "FAILED", ...);

标记为 DEAD 后,事件不会自动重试,需要人工介入。后台管理页面可以查看死信列表,手动重试或丢弃。

小发现:开发阶段我把 maxAttempts 设成了 1,然后发现所有推送都进死信了——因为 WebSocket 服务器没启动。后来才意识到这个配置的含义不是"最多尝试一次",而是"最多重试一次 = 总共两次"。所以配置项叫 maxAttempts 而不是 maxRetries

四、整体流程——从发消息到确认送达

flowchart TD
    USER[用户发送消息] --> API[POST /api/im/messages]
    API --> TX[事务开始]
    TX --> INSERT[ INSERT im_message]
    INSERT --> OUTBOX[ INSERT im_event_outbox<br/>status=PENDING, next_retry_at=NOW]
    OUTBOX --> TXCOMMIT[ 事务提交]
    TXCOMMIT --> CALLBACK[ afterCommit 回调]
    CALLBACK --> DISPATCH[ 立即触发投递]
    
    TX --> ROLLBACK[ 事务回滚]
    ROLLBACK --> NOPUSH[ outbox 同步回滚<br/>调度器扫不到]
    
    DISPATCH --> WS[ WebSocket push]
    WS --> SENT[ 标记 SENT]
    
    DISPATCH --> FAIL[ 推送失败]
    FAIL --> FAILED[ 标记 FAILED<br/>nextRetryAt = now + 5s]
    FAILED --> RESCAN[ 下次扫描重试]
    RESCAN --> DISPATCH
    
    FAILED --> DEAD[ retryCount >= 5<br/>标记 DEAD]
    DEAD --> ADMIN[ 管理员介入<br/>手动重试/丢弃]
    
    DISPATCH -...-> OFFLINE[ 用户离线]
    OFFLINE -...-> ONLINE[ 用户上线]
    ONLINE -...-> RESCAN[ 下次扫描命中]

每一步都有兜底——事务回滚不推、离线下次推、失败自动重试、重试到底还有死信。

调度器的并发控制(多实例不重复抢事件)

@Scheduled(fixedDelay = 3000)  // 每3秒执行
public void dispatch() {
    // ① 拉取待投递事件(FOR UPDATE SKIP LOCKED)
    List<ImEventOutbox> events = outboxRepository.findPending(
        batchSize, sendingStaleMs
    );
    
    for (ImEventOutbox event : events) {
        // ② 先标记为 SENDING,防止被其他实例抢
        boolean locked = outboxRepository.markSending(
            event.getId(), LocalDateTime.now()
        );
        if (!locked) continue;  // 被其他实例抢了,跳过
        
        try {
            // ③ 推送
            wsSession.sendToUser(event.getReceiverId(), event.getPayload());
            // ④ 标记成功
            outboxRepository.markSent(event.getId());
        } catch (Exception e) {
            // ⑤ 失败处理
            handleFailure(event, e);
        }
    }
}

MySQL 的 FOR UPDATE SKIP LOCKED 是这里的关键——两个 pod 同时调度,各抢各的记录,互不阻塞。比分布式锁轻量得多,不需要引入 Redis。

性能实测

内网 4核8G 服务器、调度器单实例的实测数据:

指标

数值

调度间隔

3秒

每批拉取上限

100条

单条推送耗时

10~50ms

100条批量处理

~1.5s

投递成功率

99.7%(3次重试内)

端到端延迟 P50

0.5s

端到端延迟 P99

3.2s

P99 到 3.2 秒的原因:最坏情况消息到达时刚好错过一轮调度 → 等 3 秒 + 推送处理 ~200ms ≈ 3.2 秒。调小 fixedDelay 到 1 秒可以把 P99 降到 ~1.5 秒,但数据库轮询频率翻倍,需要按实际负载权衡。

五、配置一览——这些参数怎么调

workforce:
  im:
    outbox:
      dispatcher:
        enabled: true           # 是否启用 outbox 调度
        batch-size: 100         # 每次拉取数量
        max-attempts: 5         # 最大重试次数
        retry-delay-ms: 5000    # 重试间隔(毫秒)
        sending-stale-ms: 60000 # SENDING 状态过期时限
        fixed-delay-ms: 3000    # 调度间隔(毫秒)

调参建议

  • retry-delay-ms:设太小会频繁重试压数据库,设太大用户体验差。5 秒是折中

  • sending-stale-ms:必须大于单次投递的最大耗时,否则正常投递中的事件会被其他实例抢走。默认 60 秒足够

  • batch-size:太小吞吐差,太大可能撑爆内存。100 条对中小企业场景够用

六、为什么不全走 MQ?——一个还没做的决定

用 RabbitMQ / Kafka 做消息投递看起来更标准:

flowchart LR
    SVC[业务代码] --> MQ[(消息队列)]
    MQ --> CONSUMER[消费者]
    CONSUMER --> WS[WebSocket 推送]

但我们的项目目前没引入 MQ,原因是:

  1. Docker Compose 多一个服务:小项目用 outbox + 数据库调度够用,加 MQ 反而多了运维成本

  2. outbox 表天然可追溯:谁发的、发给谁、投了几次、失败原因,全在表里——出问题直接 SQL 查,比 MQ 的 UI 直观

  3. 后续扩展预留好了:outbox 的事件结构跟 MQ 消息体完全兼容,将来引入 Kafka 只需要把 ImWebSocketOutboxService.enqueue 换成 KafkaTemplate.send,调度器换成消费者即可

说实话,如果后期消息量真的大了(日活 > 1000),把 outbox 换成 Kafka 的改造成本很低,因为架构边界已经划清楚了。


让我们忠于理想,让我们面对显示

SailTrack

entp 辩论家

站长

具有版权性

请您在转载、复制时注明本文 作者、链接及内容来源信息。 若涉及转载第三方内容,还需一同注明。

具有时效性

目录

欢迎来到SailTrack的站点,为您导航全站动态

37 文章数
11 分类数
3 评论数
49标签数