写过消息系统的读者应该都有类似体验:消息一多,整个处理链路就像堵了水的管道,日志里看不到报错,但消息越积越多,延迟越来越大,查来查去都找不到那一环出了问题。我最近把自己维护的一个轻量消息框架的内部实现重新梳理了一遍,这个框架叫 Flowing,名字取的就是“让消息流动起来”。它的核心引擎是一套消息循环(message loop),所有消息的投递、排队、调度、执行、确认都围绕这个循环展开。这篇文章我会从消息循环的基本模型讲起,把 Flowing 的设计思路、核心组件、线程模型、背压策略、实操配置和常见故障排查都过一遍,适合正在做事件驱动架构、自己写消息处理管道,或者想深入理解消息循环机制的读者。
1. 先搞清楚消息循环到底在循环什么
很多人第一次接触“消息循环”这个词,是在图形界面编程或者 Node.js 的事件循环里。它其实不是什么高深概念,恰恰相反,它是计算机系统里最朴素、最通用的运转方式。
1.1 最基本的模型:一个循环加一个队列
想象一家只有一个服务员的小餐馆。门口有一个取号机,客人来了先取号排队,服务员从叫号列表里按顺序喊号,喊到谁谁就来点单。这个叫号列表就是队列,服务员不断重复“取一个号、处理一个号”的动作,就是一个循环。操作系统里的消息泵、GUI 程序的主线程、网络框架的 IO 线程,本质上都是这套模型:先注册事件来源,然后在一个循环里等待事件、取出事件、分发事件、执行回调,最后回到等待状态。
Flowing 的消息循环也是同样的骨架,但它在“取号—处理”这两个动作之间加了很多东西,比如优先级插队、失败重试、耗时统计、背压控制。加完之后,表面上看还是一个循环,但行为上已经和玩具模型拉开了距离。
1.2 Flowing 的定位:不是再造一个消息中间件
先说清楚边界。很多人一听到消息框架,就会联想到 Kafka、RabbitMQ 这类消息中间件。Flowing 做的事和它们不一样:它不负责跨服务、跨机器的消息传输,也不负责持久化和副本同步。它只解决一个进程内的问题——当业务线程把一条消息丢进来之后,这条消息如何在进程内部高效、有序、可控地被消费掉。
举个实际场景。一个订单服务收到下单请求后,需要同时执行扣库存、发通知、写审计日志三个动作。如果用原始线程方案,最简单粗暴的做法是new Thread一把梭,但线程数量一多,线程切换开销、锁竞争、异常丢失的问题全来了。用消息中间件又显得过重,毕竟这些都是进程内的本地动作,走网络序列化纯属浪费。Flowing 就是为这种“进程内异步解耦”的场景设计的:业务线程只负责把消息投进入口队列,剩下的事交给消息循环去调度。
它解决的具体问题有三个:一是消灭回调地狱,二是规范线程使用,三是让每条消息从诞生到消费完的完整路径都可观测。这三个价值在后面的实现细节里都能看到对应设计。
1.3 设计目标拆解:让消息真正“流”起来
我给 Flowing 定了三个核心设计目标,后续所有代码和配置都围绕它们展开。
第一个是有序性。消息循环必须保证,同一个消息分组内的消息按投递顺序被消费,不能因为并发调度把顺序打乱。第二个是可观测性。每条消息经过循环的每一步,都要留下痕迹,出了问题能直接定位是入口、路由、执行、确认中哪一环丢的。第三个是可控性。当处理速度跟不上投递速度时,循环要有明确的策略去处理积压,而不是无脑堆积直到内存爆炸。
这三个目标对应到代码里,分别是一个带分组信息的消息结构、一套埋点日志规范、以及可配置的背压策略。下面我逐个拆开讲。
2. Flowing 的核心架构:消息从哪里来到哪里去
先把 Flowing 的骨架画在脑子里:入口队列(Inbox)接收消息,路由器(Router)根据消息类型找到对应的处理器,工作线程池(Workers)真正执行处理器逻辑,最后结果通过出口(Outbox)回写或确认。整条链路围绕“消息循环”这个中枢运转。
2.1 消息对象:长什么样、带什么信息
Flowing 的消息对象不是简单的{topic, payload}二元组。我在实践里发现,如果消息本身缺少上下文信息,排查问题时会非常痛苦。所以 Flowing 里每条消息是这样的结构:
type Message struct { ID string // 全局唯一消息ID Topic string // 消息主题,路由的依据 Group string // 分组标识,同组内保序 Priority int // 优先级,数值越大越先处理 Payload interface{} // 业务数据 TraceID string // 链路追踪ID,贯穿整条链 CreatedAt time.Time // 投递时间 RetryCount int // 已重试次数 State MessageState // 消息状态机 }Group字段是很多人容易忽略但极其重要的设计。有了它,同一个订单的扣库存、发通知、写日志可以设成同一个分组,这样消息循环在调度时会保证这个组内的消息串行执行,而不同组之间可以并行。TraceID则是调试时候的救命稻草,没有它,你要把一条消息在整个系统里留下的几百条日志串起来几乎不可能。
消息状态机我定义成五个状态:PENDING(等待被拉取)、DISPATCHED(已出队待执行)、EXECUTING(正在执行)、COMPLETED(完成)、FAILED(失败待处理)。状态字段的价值在监控章节会体现,有了它你可以随时知道系统里有多少消息正在处理中,有多少卡住了。
2.2 四个关键组件:入口、路由、执行、出口
入口队列 Inbox 是一个有界队列,它的容量在创建框架时就要定好,目的是防止无限制的内存增长。注意这里用的是“有界”,不是“无界”。无界队列在流量突发的时候会把内存直接打爆,这是个非常常见的生产事故,后面我会专门讲怎么规避。
路由器 Router 维护了一张映射表,Topic -> Handler。它从 Inbox 取出消息后,根据 Topic 查表找到对应的处理器,然后把消息交给 Worker 去执行。这里有一个性能关键点:路由查找必须做成 O(1),不能每次做遍历匹配。简单实现是直接用 Go 的map[string]Handler,再包一层sync.RWMutex做读保护。
工作线程池 Workers 是真正跑业务逻辑的地方。线程数量不是越多越好,它取决于你的业务是 CPU 密集型还是 IO 密集型。CPU 密集型的话,线程数接近 CPU 核数就够了;IO 密集型的可以适当调大,因为线程大部分时间在等 IO 返回。
出口 Outbox 负责执行后处理,包括回写结果、记录完成日志、触发后续消息。很多人设计消息框架会忽略 Outbox 这一步,直接把后处理逻辑写在 Handler 里。这会导致一个很隐蔽的问题:Handler 既要干业务,又要管消息生命周期,代码耦合严重,而且一旦出异常,业务逻辑和消息确认逻辑一起挂掉,你根本分不清到底是业务错了还是消息系统错了。
2.3 一条消息的完整旅程
我用一个下单后发通知的场景,把消息的完整旅程串一遍。
业务线程调用flowing.Publish("order.created", msg),消息先进入 Inbox,此刻状态是PENDING,并记录一条CREATED日志。消息循环的主循环线程会持续做一件事:从 Inbox 头部取消息,取出后状态改为DISPATCHED,写入DEQUEUED日志。接着 Router 根据order.created找到通知处理器,把消息连同处理器交给下一个空闲的 Worker。Worker 从接收消息到执行完,状态变为COMPLETED,Outbox 把结果记录到结果通道,此时框架会调用你注册的OnMessageHandled回调,整个旅程结束。
如果处理器执行过程中抛异常,消息状态会变成FAILED,框架按配置的重试策略决定是直接进重试队列,还是进入死信队列等待人工处理。
这条链路里每一步的日志都带有TraceID,所以出问题时,只要拿一条消息的 TraceID 在日志系统里搜一遍,立刻就能定位它到底走到哪一步、卡在了哪里。
3. 消息循环实现中的关键取舍
表面上看,实现一个消息循环就是写一个for { msg := queue.Pop(); handler(msg) },但要在高并发生产环境里跑得稳,有几个取舍必须在写代码之前想清楚。这一节我把四个最关键的决策点展开讲。
3.1 拉取还是推送:两种模式的实际差异
消息循环从队列拿消息,有两种思路。一种叫推模式(push),队列主动把消息推给 Worker,由框架去控制每个 Worker 拿多少消息。另一种叫拉模式(pull),每个 Worker 循环自己去队列拿消息,拿到一条处理一条,处理完再拿下一条。
推模式的优点是控制集中,框架可以精确掌握每条消息分配给哪个 Worker,方便做负载均衡。缺点是队列和 Worker 之间有双向依赖,队列要维护一个“谁空闲”的注册表,复杂度高不少,还要处理 Worker 崩溃后消息回收的问题。
拉模式的优点恰恰是简单,每个 Worker 只有两个动作:取消息、处理。Worker 处理和取消息天然形成了反馈回路——这条处理完了才会取下一条,所以不会出现 Worker 积压大量“半处理”消息的情况。Flowing 最终选的是拉模式,核心原因就是它把复杂度降了一个量级,而且消息在队列里天然安全,即使某个 Worker 处理消息时崩溃了,消息还在队列里,重新排给别的 Worker 就行。
两种模式的取舍可以用一句话概括:推模式适合你需要精确控制每个消费者的负载和进度的时候;拉模式适合你更看重简单可靠、消息不留存在消费者手里的时候。Flowing 的场景是进程内处理,消息留在队列里没有任何代价,所以拉模式是更优解。
3.2 线程模型与并发安全
Flowing 的线程模型分为三层。
第一层是投递线程,也就是调用Publish的业务线程。它们只有一个动作:把消息写入 Inbox。为了保证线程安全,Inbox 底层是有锁队列,或者用 Go 的chan直接做无锁通信。这里有个细节:Publish方法本身不能阻塞太长时间,如果 Inbox 满了要立即返回错误或走背压策略,不能死等。
第二层是循环线程。它负责从 Inbox 取消息、查路由表、把消息交给 Worker。这个线程本身不执行业务代码,所以它的循环速度应该是极快的。判断一个消息循环写得好不好,就看循环线程在“不干活”时的 CPU 占用是多少。设计良好的循环线程在消息稀疏时几乎不消耗 CPU,因为它会在队列空时阻塞休眠,而不是空转轮询。
第三层是 Worker 线程池。它们执行真正的 Handler。
三层之间通过队列解耦:投递线程写 Inbox,循环线程读 Inbox 再写工作队列,Worker 从工作队列取消息。每一层的输出只和下一层的输入对接,没有直接函数调用关系。
并发安全方面最需要注意的坑是路由表的读写竞争。Handler 可能在运行中动态注册,所以要保证 Router 的并发安全。我总是用读写锁:读锁给循环线程查表用,写锁给注册 Handler 用。查表的频率远高于注册,读写锁能让多个循环线程并发读,性能不会劣化。
3.3 背压策略:循环跑不动了怎么办
背压(backpressure)是消息系统里最容易忽略、也最容易出事故的一个点。简单说,当投递速度持续大于消费速度时,Inbox 会逐渐填满。如果没有背压策略,会发生两种情况:内存持续增长直到 OOM,或者消息无限堆积导致延迟爆炸。
Flowing 提供三种背压策略,通过配置切换。
第一种是阻塞式。Inbox 满了之后,Publish调用阻塞住,直到队列腾出空间。这种策略最安全,因为它天然限制了生产者的速度,让整个系统以消费者的能力为上限。代价是业务线程会被卡住,下单接口的响应时间会被拉长。
第二种是丢弃式。Inbox 满了直接丢弃新消息并返回错误。适合对实时性要求不高的场景,比如日志采集、监控指标上报,丢几条不影响大局。
第三种是降级式。Inbox 满了之后,消息不丢弃而是进入一个独立的溢出队列,由一批低频 Worker 慢慢处理。这种策略适合那种“可以不及时但不能丢”的消息,比如用户行为数据的上报。
我在实际项目里用的最多的是阻塞式,因为大部分业务消息都是必须处理的,宁可让生产者等一下,也不能丢消息。但阻塞式有个隐患:如果消费者线程因为 bug 全部卡死,生产者会全线阻塞,故障会快速扩散。所以 Flowing 在阻塞式背压里内置了一个超时参数,Publish最多阻塞 500 毫秒,超时后返回一个明确的背压超时错误,让上层业务决定是重试还是降级。
4. 实操:用 Flowing 搭一个订单处理管道
这一节是纯实战。我用 Flowing 搭一个最典型的订单处理管道,把扣库存、发通知、写审计日志三条消息处理链串起来。代码以 Go 为例,整体思路移植到 Java、TypeScript 也完全成立。
4.1 最小可运行示例
先看初始化部分:
func main() { engine := flowing.NewEngine(flowing.Config{ QueueCapacity: 10000, // Inbox 容量 WorkerCount: 8, // Worker 线程数 Backpressure: "block", // 阻塞式背压 PublishTimeout: 500 * time.Millisecond, EnableTrace: true, }) engine.Register("order.stock", stockHandler) engine.Register("order.notify", notifyHandler) engine.Register("order.audit", auditHandler) engine.Start() defer engine.Stop() }然后在下单的业务代码里投递三条消息:
func createOrder(ctx *Context, order Order) error { traceID := ctx.TraceID stockMsg := flowing.NewMessage(). SetTopic("order.stock"). SetGroup(order.OrderID). SetTraceID(traceID). SetPayload(order) notifyMsg := flowing.NewMessage(). SetTopic("order.notify"). SetGroup(order.OrderID). SetTraceID(traceID). SetPayload(order) auditMsg := flowing.NewMessage(). SetTopic("order.audit"). SetGroup(order.OrderID). SetTraceID(traceID). SetPayload(order) err := engine.Publish(stockMsg, notifyMsg, auditMsg) return err }注意三条消息都设置了Group为订单号。这意味着同一个订单的扣库存、发通知、写审计日志在 Worker 里是串行执行的,不会出现“通知发出去了但库存还没扣成功”这种顺序混乱问题。而不同订单之间完全并行,互不干扰。
三个 Handler 的写法也很简单:
func stockHandler(ctx *flowing.Context) error { order := ctx.GetPayload().(Order) // 扣库存逻辑 return nil }Handler 只做业务,不需要关心消息重试、确认、状态流转这些事,那是框架层的事。这正是 Flowing 想达到的效果。
4.2 参数配置与调优思路
参数怎么定,不能拍脑袋。我一般按下面的顺序一步步调。
先看WorkerCount。如果 Handler 里有数据库、外部 API 调用这种 IO 密集型操作,Worker 数量可以按 CPU 核数的 4 到 8 倍来设。反例是我踩过的坑:某个项目里一台 16 核的机器,业务方把 Worker 数量配成了 64,本来以为越大越好,结果是线程切换开销直接让吞吐量降了三成。后来改成 32,吞吐量反而上来了。
再看QueueCapacity。这个值和背压策略强相关。如果你用的是阻塞式背压,队列容量不用太大,比如 10000 就足够,因为生产者会被阻塞,队列多一点少一点影响不大。如果你用的是丢弃式背压,队列容量就是你的“缓冲蓄水池”,要按“峰值流量 × 峰值持续时长”来估。比如某个接口高峰每秒投 2000 条消息,能持续 10 秒,那队列至少要能装 20000 条。
然后是重试策略。Flowing 里每个 Handler 可以单独配置重试次数和退避时间。我建议重试次数不要超过 3 次,退避时间按指数增长:第一次等 500 毫秒,第二次等 1 秒,第三次等 2 秒。更重要的是,重试的消息必须放在独立的延迟队列里,不能直接塞回 Inbox 头部——否则会阻塞后面所有正常消息。
4.3 验证数据流:日志与追踪
框架搭好之后,第一步不是测业务,而是验证消息能不能按照预期的路径完整走一遍。我习惯打开 EnableTrace,然后投一条测试消息,看完整日志:
[TRACE] msg=msg_001 topic=order.stock state=PENDING event=CREATED ts=12:00:00.000 [TRACE] msg=msg_001 topic=order.stock state=DISPATCHED event=DEQUEUED ts=12:00:00.001 [TRACE] msg=msg_001 topic=order.stock state=EXECUTING event=STARTED ts=12:00:00.002 [TRACE] msg=msg_001 topic=order.stock state=COMPLETED event=SUCCESS ts=12:00:00.005四行日志,清清楚楚展示了这条消息从入队到执行完的每一步耗时。如果某条消息在DISPATCHED之后没有对应的EXECUTING,说明循环线程分发出去之后 Worker 没有正常接收,基本可以断定是线程池假死。如果EXECUTING到COMPLETED间隔特别长,说明 Handler 本身耗时高,要去查业务逻辑。
这个追踪能力在排查消息丢失问题时价值巨大,因为绝大多数消息丢失都不是真的“消息没了”,而是消息在某一步卡住或异常退出,日志被吞了。
5. 常见问题与排查技巧实录
消息循环系统跑久了,遇到的故障类型其实非常有限。我总结出四个最典型的,每个都配上排查路径,基本覆盖 90% 的线上问题。
5.1 消息静默丢失:最隐蔽的故障
现象是日志里看不到报错,但某个下游系统的数据就是缺了。最典型的根源有两个。
第一个是Handler 里自己 recover 了所有 panic 然后假装没事。我在代码审查里见过太多次这种写法。Handler 内部捕获了异常,业务逻辑没执行完,但是方法返回了nil,框架以为处理成功了,消息就“成功丢失”了。排查方法是看COMPLETED的日志数量是否和业务真实执行量对得上,更多时候是直接复现:在 Handler 里故意抛异常,看框架会不会标记为FAILED。正常的框架设计里,Handler 绝对不能让 panic 静默吞掉,要么让框架统一 recover,要么把 panic 转换成错误返回。
第二个是消息投递成功但写库失败,两件事没有放在同一个事务里。比如先发了消息,然后业务代码抛异常回滚了数据库,但消息已经在队列里了,消费端照样处理了。这种问题属于业务侧的事务边界没划对。正确顺序是先把业务数据落库,事务提交后再投递消息;必要的时候用事务发件箱(Transactional Outbox)模式,把“待发送消息”和业务数据写在同一张表里,由后台任务去投递。
5.2 队头阻塞:一条慢消息拖垮整个队列
这个故障的场景是:队列里大部分消息处理只要几毫秒,但偶尔来了一条要调外部接口、耗时 10 秒的慢消息。如果拉模式的 Worker 恰好拿到了这条慢消息,整个 Worker 就被卡住 10 秒,它负责的所有消息全部排队等待。
解决思路有两个方向。
第一个方向是把慢消息和快消息拆到不同的队列组。Flowing 支持按 Topic 前缀分队列,比如所有order.notify_*的慢操作走一个专门的慢队列,分配独立的 Worker。这样慢消息再慢也只影响自己那一组。
第二个方向是给 Worker 设置处理超时。如果一条消息执行超过阈值,比如 5 秒,框架把它标记为FAILED并重试或进死信队列,同时释放 Worker。注意这里不能直接在 Worker 线程上暴力打断,正确做法是把业务执行放在一个独立协程里,Worker 等它 5 秒,超时了就不再等待,让协程在后台自己收尾。
5.3 消息重复消费:幂等是底线
这个故障的根本原因是“至少一次投递”语义。Flowing 在 Worker 处理消息时,如果消息处理成功但确认动作丢了,或者 Worker 在处理一半时崩溃重启,消息会被重新拉取,导致业务逻辑执行两次。
消息系统保证不了不重复,所以业务侧必须做幂等。我的习惯是给每条消息的 Handler 配一个去重表:在消息 ID 上建唯一索引,处理前先插入,插入成功才继续处理,插入失败说明已经处理过,直接返回成功。
去重表的粒度很重要。如果整个系统用一张大表,并发高的时候会变成瓶颈;如果按业务类型分表,又会在跨多张表时增加管理成本。我实践下来,按 Topic 分组去重表是性价比最高的方案,既不会出现单表热点,又足够分散。
5.4 循环线程饥饿与假死
循环线程假死是最难排查的故障之一。现象是整个系统还有响应,但消息队列的深度持续上涨,处理量断崖式下跌。日志上最后一条记录是某个DISPATCHED事件,之后就再也没有动静了。
这种问题通常是循环线程被某个非阻塞但很耗 CPU 的操作卡住,或者死锁在某个锁上。排查手段也很直接:定期 dump 一下循环线程的堆栈,看它到底停在哪里。更好的办法是从架构上预防——Flowing 在启动时会内置一个看门狗定时器,每 5 秒检查一次循环线程的心跳计数。如果发现心跳 30 秒没有更新,就立刻打印线程堆栈快照并告警。这个功能让我在一次线上事故里少熬了一个通宵。
6. 监控、度量与运维建议
框架能跑起来只是第一步,真正考验人的是它在生产环境里的表现能不能被度量、被预警。这节我讲四个必须盯住的指标,以及我积累下来的调优顺序。
6.1 必须盯住的四个指标
| 指标 | 含义 | 预警阈值建议 |
|---|---|---|
| Queue Depth | Inbox 当前积压消息数 | 超过容量的 60% 就要关注 |
| InFlight Count | 正在执行但未完成的消息数 | 持续大于 Worker 数说明卡顿 |
| Latency P99 | 消息从入队到完整处理的耗时 | 超过业务容忍的 3 倍必须处理 |
| Error Rate | 处理失败(含重试)的比例 | 超过 1% 需要告警 |
Queue Depth 是唯一一个告诉你“系统还能扛多久”的指标。它涨上去只有两个原因:进来太多,或者处理太慢。所以看到深度上涨,先不要急着加 Worker,先去看 P99 耗时——如果 P99 也涨了,问题在处理能力;如果 P99 没涨,问题在投递量。
InFlight Count 这个指标很多人不看,但它比 Queue Depth 更能暴露问题。假设你有 8 个 Worker,但 InFlight 一直卡在 60,说明消息没有被正常标记为完成,大概率是某个 Handler 死循环或者线程池异常。这时候看 Worker 堆栈比看消息日志更直接。
6.2 我的调优顺序建议
我每次做性能调优都遵循同一个顺序,可以大幅减少走弯路的时间。
第一步,先确认瓶颈是 CPU 还是 IO。用最基础的工具看一眼进程的 CPU 利用率。如果 CPU 打满了,说明 Handler 里有耗 CPU 的密集计算,加 Worker 没用,反而更糟,应该去优化业务算法。如果 CPU 很低,说明大家都在等 IO,这种时候增加 Worker 数量是有用的。
第二步,调队列容量和背压策略。这个要配合压测来做,用略高于峰值的流量压进去,观察 Queue Depth 曲线。如果深度曲线是线性上涨的,说明消费跟不上,优先调 Worker 数;如果深度涨到某个值之后稳定了,说明背压已经在起作用,系统处于平衡状态。
第三步,调重试和超时参数。把重试次数尽量调小,靠超时机制快速失败,而不是让消息反复重试占用 Worker。我在优化一个第三方通知链路时,把重试次数从 5 次降到 2 次,P99 延迟反而降了 40%,因为之前大量 Worker 都在处理注定失败的重试消息。
第四步,做故障注入。主动把某个 Handler 改慢,或者把 Worker 数调成 0,看监控能不能第一时间报警。这一步很多人忽略,但它比任何参数调优都重要——如果你的监控系统在真实故障发生时失灵,那之前所有调优都没有意义。
7. 最后分享一点个人的体会
做完 Flowing 这个项目之后,我对“消息循环”这四个字的理解比之前深了一层。它表面上是一个技术组件,实际上是一个思维模型——任何“有入口、有处理、有出口”的系统,都可以用循环加队列的方式组织起来,关键是分清谁在投递、谁在消费、谁在排队,以及每一条消息从进入到完成经历了哪些状态。
如果你现在正准备自己写一个类似的消息循环,我给三条具体建议。第一,给消息加 TraceID,让它贯穿整个生命周期,这会让排查问题的时间缩短一个量级。第二,队列必须有界,背压必须提前设计,不要在系统崩了之后才想起来。第三,Handler 里永远不要自己吞 panic,处理不了的错误交给框架去重试或进死信队列,你要保证消息的每一种终态都有明确的去向。
我踩过最多的坑,不是框架本身写不出来,而是“以为消息已经处理了,其实没有”。消息系统最大的敌人永远是模糊——消息在哪个状态、卡在哪里、为什么失败,这三个问题如果系统自己都答不上来,那整个管道就变成了一个黑盒。Flowing 这套设计,说到底就是在和模糊做对抗。希望这篇文章能让你在设计自己的消息处理管道时,少踩几个我踩过的坑。