3.2 写入链路:先落库再推送
场景
消息模型定好之后,写消息的流程要定死。一个典型问句:用户按发送的那一瞬间,服务器是该先推给对方还是先存?推是目的,存是保障,顺序一旦反了,可靠性立刻塌方。先推后存如果推成功存失败,消息在对方手机上显示一次后永远消失;先存后推则无论推送结果如何,消息都在,对方重连总能拉到。我们的选择是先落库再推送,代价是多一次存储往返,换来的是确定性。
除了顺序,这条链路还要回答另外两个问题。一是失败语义:发送接口返回成功,客户端就可以把气泡从灰色转成正常色,那么这个成功的承诺到底由哪一步背书?我们的答案是落库,而不是推送到达。二是性能预算:整个发送接口的 P99 不得超过两百毫秒,晚高峰也不能破。这两条约束像两条护栏,夹出了后面整套设计。
失败语义里还有一种中间态要明确:客户端发出去了但迟迟收不到响应。这时候客户端会重试,重试就可能重复写入,所以服务端必须有去重——按客户端生成的 MsgID 幂等,重复请求直接返回第一次的结果。这套幂等设计的完整细节在 消息防丢:全链路幂等设计,此处只要知道发送链路从第一天起就预留了 MsgID 环节。重试与超时的配合也讲究:客户端超时阈值要显著大于服务端 P99,否则健康的请求会被大量判死,重试洪峰直接翻倍。
方案
完整写入链路分四步:鉴权与限流、分配序号并落库、更新会话与未读数、在线推送。前三步是同步链路,最后一步在线推送可以异步化。必须保证消息落库成功才通知客户端已发送,而不是推送成功才通知——这两者颠倒了就是客户端显示已发送但服务器没收到的问题。
| 步骤 | 同步或异步 | 失败处理 | 我们的实现 | 耗时占比 |
|---|---|---|---|---|
| 鉴权与限流 | 同步 | 拒绝 | 网关本地验票 | 百分之五 |
| 分配序号并落库 | 同步 | 返回失败,客户端重试 | Redis INCR 加 TDSQL-C | 百分之八十 |
| 更新会话与未读数 | 同步 | 记日志补偿 | Redis 多键操作 | 百分之十 |
| 在线推送 | 异步 | 客户端 sync 兜底 | 网关 Push | 不计入接口耗时 |
这个设计的关键在于推送是尽力而为的加速通道,拉取才是兜底的可靠性来源。客户端因此必须有两个入口拿消息:在线时被动收,离线重连时主动拉(见 离线消息:拉取与确认机制)。同步链路里我们还嵌了一层限流:按 UID 维度的令牌桶,每秒上限按发送频率产品规则配置,超了直接返回错误码让客户端降速——这一层挡掉了好友被恶意刷屏时的连带伤害。
落库这一步还有一个细节值得单说:批量写。单聊场景下一进一出的写法在压测时性能凑合,但晚高峰一过我们发现磁盘 IO 的利用率曲线尖刺特别多,每条消息一次事务的提交开销占比很高。于是把短时间内的写入按会话聚合,攒满八条或超过二十毫秒就提交一次。这个改动把落库耗时降了三成,代价是消息的可见性延迟略有上升,但仍在 P99 预算内。批量写上线后务必盯住一个指标:写入积压队列的深度,深度持续上涨说明批量窗口拖得太长,高峰期就该切回单条模式。这一段也给了我们一个方法论:链路里每个阶段性改动都要带开关,开关由监控指标驱动自动切换,而不是靠人半夜爬起来改配置。
配置与代码
发消息的核心函数按四步串起来,注意每一步都要能独立失败:
func (h *SendHandler) Send(m *Message) error {
if !h.limiter.Allow(m.SenderID) {
return ErrTooFast // 限流挡在最前面,不值钱的事先做
}
seq, err := h.store.NextSeq(m.ConvID)
if err != nil {
return err // 序号都拿不到,直接让客户端重试
}
m.Seq = seq
if err := h.store.Save(m); err != nil {
return err // 落库失败等于没发
}
h.cache.UpdateConv(m.ConvID, m) // 会话列表最新一条
h.cache.IncrUnread(m.ConvID, m.Receiver()) // 未读数加一
h.queue.Submit(pushTask{To: m.Receiver(), Msg: m}) // worker 池异步推
return nil
}
这里最容易忽视的是未读数更新与落库的一致性。如果先加未读再落库,落库失败时未读数就错乱了。我们的顺序是严格落库成功之后才动缓存。另外客户端拿到的成功响应里带上这条消息的 Seq,客户端据此更新本地的排序游标——这样即便推送和响应的到达顺序乱了,端上也不会把消息排错。
更新会话与未读数这一步用的是 Redis 的 pipeline,把会话最新一条、未读数 INCR、会话排序值更新三条命令一次发出去,省掉两次往返。注意 pipeline 不是原子操作,中间挂了要靠定时对账补偿(见踩坑二),不要指望它当事务用。会话列表的精剪也顺手做了:每个 UID 只保留最近一百条会话,超出的从列表尾部淘汰,完整列表在客户端进入旧会话时按需懒加载。
推送用的是固定 worker 数的 goroutine 池,队列满时丢弃任务并记一条计数器,因为消息已经落库,丢了推送也有 sync 兜底:
type pushQueue struct {
tasks chan pushTask
pool int
}
func (q *pushQueue) Submit(t pushTask) {
select {
case q.tasks <- t: // 正常入队
default:
metrics.PushDropped.Inc() // 队列满,靠客户端 sync 补
}
}
踩坑
第一个坑是异步推送的对象池问题。Push 直接 go 出去,高峰期十万条消息就是十万个临时 goroutine,GC 压力巨大,心跳接口的延迟跟着抖。后来改成固定大小的 worker 池加 channel,波动从锯齿变成直线。worker 数经验值是 CPU 核数的四倍,再多反而被网络 IO 的上下文切换吃掉收益。
第二个坑是未读数加了但推送没收到。用户在线但那一刻网络抖动推送失败,未读数加一而拉取触发不了,红点永远减不下去。解决办法是心跳周期里做未读数对账:客户端每分钟报一次未读,服务端与缓存值比对不一致时触发 sync。对账上线后红点相关的客诉每周从十几条降到两三条,剩下的多是客户端本地缓存 bug。
第三个坑是写入链路堵塞写入高峰。晚间高峰落库 RTT 抖动,整个发送接口被存储拖垮,限流被迫提前触发,用户体感是发消息卡。这正是后来引入 RocketMQ 削峰的原因,但引入 MQ 之前我们做了一个过渡方案:写不走同步而是落本地内存队列后台批量写,极端情况下会丢消息,所以未入生产,只作为压测对照,结论是重保场景绝不能拿内存兜底。
第四个坑是计数器与连接票据类似的漂移问题。Redis INCR 的计数器如果被误删(比如批量清理脚本写错 key 前缀),新分配的 seq 会小于已有记录,同步接口拉取结果为空,大量用户突然收不到新消息。我们的兜底是每次 nextSeq 时与 message 表 max(seq) 对比,小于则用 max 加一,这个对账逻辑后来写进了启动检查。
第五个坑是异步推送的顺序保证。同一个会话的两条消息先后进入 worker 池,被不同 worker 取走后网络到达顺序可能颠倒。我们的处理是相同接收人的任务按 ConvID 哈希到固定的 worker,保证单会话内严格先进先出;跨会话本来就没有顺序语义,客户端按各自 Seq 排即可。
第六个坑是重试风暴。客户端发送失败后的默认重试是指数退避,但某次存储抖动导致批量失败时,几万台客户端同时退避到同一时刻重试,第二波把存储再次打挂,来回三次才恢复。服务端侧的补丁是失败响应里带 suggestion 的重试间隔(服务端在负载高时下发更长间隔),客户端侧加随机抖动。这个坑告诉我们:任何集中式的重试都会放大故障,上行重试如此,下行重连亦然(见 心跳与重连:连接风暴怎么防)。
小结
先落库再推送的本质是把可靠性从不可靠的网络搬到可靠的存储上。记住三个顺序:落库成功才回客户端成功、落库成功才动缓存、推失败不影响主链路。高峰期的写入积压是这套设计的固有软肋,解决方案在 削峰填谷:RocketMQ 异步写入,存储侧的扩展在 存储扩容:先走读写分离。另外建议尽早把链路各段的耗时与失败计数埋点,上线第一天就能对着监控说话。
