1) 目标与问题分解
- 离线消息(IM/弹幕/系统) :用户退出 → 再进来,能补齐缺的消息、按房间顺序展示。
- 礼物消息(强一致/交易相关) :既要不重扣/不重计,又要离线可补、回放有上限、动画去重/合并。
2) 通用做法:“双轨”+ 水位线
-
事件双轨
- 实时推送轨:WebSocket/RTC 数据通道,低延迟在场用户收消息。
- 可回放轨(Room Log) :所有房间事件(聊天/礼物/系统)顺序写入持久日志(Kafka/Redis Stream/自建顺序表),用于补偿/离线拉取。
-
水位线/偏移(offset/seq)
- 每条房间消息都带 roomSeq(单房间严格递增)。
- 客户端维护 lastSeenSeq;重进房间时带上,服务端返回 [lastSeenSeq+1, highWatermark] 的增量。
- 超出回放窗口(如用户离线太久) → 发 快照(state snapshot)+ 最近 N 条,避免海量回放。
3) 服务端架构小图
Client
⇅ WS/HTTP
Gateway (stateless)
⇅ pub/sub
Room-Timeline Bus (Kafka/RedisStream,按 roomId 分区保序)
↙ ↓
Timeline Writer Offline Store (Cassandra/MySQL/ClickHouse; 热数据 Redis RingBuffer)
↓
Fanout/Push to online users
- 按 roomId 分区保证同房间全量事件有序。
- Redis RingBuffer 存最近 X 分钟/条,快速回补;落长期库用于超窗回放/归档。
4) 进入房间的握手与补偿协议
入场请求
{
"roomId": "r123",
"sinceSeq": 105432, // 客户端上次看到的最后一条 seq(没有就 0)
"cap": 500, // 本次最大补偿条数(有限制门槛)
"streams": ["chat","gift","system"]
}
服务端响应
{
"roomId":"r123",
"hiSeq":105980, // 当前高水位
"messages":[...], // [sinceSeq+1, hiSeq] 截断到 cap 的增量
"snapshot":{ // 超窗或太久离线时下发
"topGifter":[...], "totals":{"giftA":1234},
"pinnedMsg":...
},
"resumeToken":"abc", // 继续实时订阅的游标/校验
"window":"last_10_min" // 本次补偿覆盖窗口
}
要点
- 有窗补偿(如最近 10 分钟/500 条),超窗走快照(礼物榜、总额、主播 PK 分数)。
- 客户端拿到 messages 后按 roomSeq 接上渲染;并将 lastSeenSeq = hiSeq。
5)礼物消息的“强一致 + 去重 + 离线可补”
-
交易→事件:礼物支付/扣款写入 GiftOrder(DB) ;采用 Outbox Pattern 同事务写出 GiftEvent(outbox) ;后台 Outbox Publisher 幂等地发到 Room-Timeline Bus。
-
唯一 ID:每个礼物事件 giftEventId = orderId,下游幂等消费:
- 计数/榜单:Redis HINCRBY 前先 SETNX processed:orderId(或 Redis-Stream 去重组 / DB 唯一键);
- 动画/弹幕:客户端/网关均按 giftEventId 去重窗口(例如 2~5 分钟 TTL)。
-
回放:礼物也写入 Room Log;重进房间按 seq 补礼物事件,但客户端可以做“合流/仅渲染最新” (见 §7)。
6) 送达语义与一致性选择
- 实时分发:至少一次推送(网络重发/重连会重复),靠 idempotent 渲染 抑制重复。
- 离线补偿:基于 roomSeq 不漏不重(从 sinceSeq+1 精准补)。
- 礼物计费:后端必须幂等(以 orderId 为准),不要把“是否已播动画”当成计费事实。
7) 端侧渲染策略(避免“补偿洪峰”卡顿)
- 消息合并:补偿窗口内的礼物连击/同款礼物聚合展示(“用户A × 5”),避免逐条播放动画。
- 动画节流:队列最大长度/单次最大播放时长(如 2s/条),超出只更新计数 UI。
- 弱网/重连:若检测到seq 断层 → 自动发补偿请求;期间 UI 用快照数据(榜单/总额)覆盖。
- 本地去重:维护 recentGiftIds LRU(Set) + recentMsgIds,窗口 2~5 分钟,拦截重复渲染。
8) 失败与边缘场景
- 离线时间过长:只给快照 + 最近 N 条,同时提示“查看更多历史”;历史走分页拉取接口。
- 分区迁移/多 shard:保证同房间 key 到同一分区;迁移期间通过单写多读/双写平滑切换。
- 多设备登录:lastSeenSeq 按用户×房间维度在服务端可选持久化,用最大值规则避免回退。
- 时钟问题:一律以服务器生成的 roomSeq 排序,不要用时间戳排序渲染。
9) 实现清单(最小可行)
服务端
-
Room Log:Kafka/Redis Stream(按 roomId 分区),写入 {roomId, roomSeq, type, payload}。
-
Redis RingBuffer:每房间最近 10 分钟/1000 条。
-
Gift Outbox:DB outbox 表 + 定时稳态发布;消费端去重表(processed:orderId)。
-
入场补偿 API:sinceSeq → 返回 [since+1, hi] 且带快照、窗口信息。
-
实时推送:WS 连接订阅 roomId;握手返回 hiSeq、resumeToken。
-
快照服务:榜单/累计值放 Redis,定时与长库对账。
客户端
- 维护 lastSeenSeq;入场/重连携带;收到消息更新。
- 断层检测:expectedSeq = lastSeenSeq + 1;不连续则发补偿请求。
- 礼物/消息去重窗口;礼物合并/节流渲染。
- 弱网重试:指数退避 + 上限;离线时转到“仅快照”模式。
10) 小示例:入场补偿伪代码
// Client
join(roomId, sinceSeq = localStore.get(roomId) ?? 0)
ws.send({op:"JOIN", roomId, sinceSeq})
// Server
const hi = timeline.hiSeq(roomId)
if (hi - sinceSeq <= WINDOW_MAX) {
const msgs = timeline.range(roomId, sinceSeq+1, hi)
reply({hiSeq:hi, messages:msgs})
} else {
const msgs = timeline.tail(roomId, MAX_TAIL)
const snap = snapshot(roomId)
reply({hiSeq:hi, messages:msgs, snapshot:snap, window:"partial"})
}
一句话总结
- 用**“实时推送 + 可回放日志(roomSeq)”保证不漏不重**;
- 礼物走交易幂等 + Outbox,在播报/渲染侧去重合并;
- 入场握手带 sinceSeq,窗口内补偿,超窗给快照;
- 端侧做断层检测/合并节流,体验与性能都稳。