routine
This commit is contained in:
+258
@@ -0,0 +1,258 @@
|
||||
# 演讲稿:从集中式到分布式,为什么消息中间件成为关键基础设施
|
||||
|
||||
建议时长:约 12–16 分钟。
|
||||
使用方式:可以按页讲,也可以根据时间压缩第 4–6 页与第 10 页的展开部分。
|
||||
|
||||
---
|
||||
|
||||
## 封面页
|
||||
|
||||
大家好,今天我想汇报的主题是:**从集中式到分布式,为什么消息中间件会成为关键基础设施**。
|
||||
|
||||
我这次不会把重点放在某一个具体产品的接口细节上,比如 Kafka、RabbitMQ 或 RocketMQ 的命令怎么写。今天我更想回答三个问题。
|
||||
|
||||
第一,集中式系统为什么在早期很好用,但后面又会遇到瓶颈。
|
||||
第二,系统拆成分布式之后,为什么会自然出现解耦、异步、削峰填谷、最终一致性、重试、幂等、顺序性和可靠投递这些问题。
|
||||
第三,消息中间件为什么会在这个阶段被引入,它到底解决了什么,又把复杂度转移到了哪里。
|
||||
|
||||
---
|
||||
|
||||
## 第 1 页:集中式系统——单体架构与数据流
|
||||
|
||||
我们先看最传统的集中式系统,也就是单体系统。
|
||||
|
||||
它的典型结构是:用户请求先进入统一入口层,然后进入一个单体应用。这个单体应用内部虽然可能也划分了用户、商品、订单、库存、支付、通知这些模块,但它们本质上还是运行在同一个进程里。最后,这些模块再统一访问同一个数据库,处理结束后同步把结果返回给用户。
|
||||
|
||||
这种架构在早期通常非常高效。原因也很直接。
|
||||
|
||||
第一,开发和部署成本低。大家维护的是一个工程、一个进程、一个数据库,协作路径很短。
|
||||
第二,调用发生在进程内,延迟低,定位问题也比较直接。
|
||||
第三,数据集中在一个库里,本地事务和强一致性都更容易实现。
|
||||
|
||||
所以单体并不是“错误架构”。相反,在业务规模还不大、边界也还比较稳定的时候,它往往是最经济、最务实的起点。
|
||||
|
||||
---
|
||||
|
||||
## 第 2 页:集中式系统的缺点——为什么要转向分布式
|
||||
|
||||
但随着业务增长,集中式系统的优势会慢慢变成它的约束。
|
||||
|
||||
首先,**发布开始耦合**。哪怕你只是改一个很小的库存逻辑,也可能需要重新构建和发布整个系统。
|
||||
其次,**扩容开始粗糙**。如果热点其实只在订单和支付链路上,你还是只能把整个单体一起扩容。
|
||||
第三,**故障容易放大**。一个局部模块出现问题,可能把整个进程拖慢,最后影响全站。
|
||||
第四,**数据库压力会集中**。当订单、库存、支付都共享一个核心库时,单库就会逐渐成为吞吐和可用性的瓶颈。
|
||||
|
||||
所以系统转向分布式,并不是因为单体“落后”,而是因为随着业务、流量和团队规模的上升,单体的边际成本越来越高。
|
||||
|
||||
换句话说,分布式不是为了“更先进”,而是为了获得三种能力:**按业务能力拆分、按热点独立扩容、按故障域做隔离**。
|
||||
|
||||
---
|
||||
|
||||
## 第 3 页:拆成分布式后——问题不是消失了,而是迁移了
|
||||
|
||||
当系统拆成分布式以后,我们确实会得到一些很明显的好处,比如独立部署、按需扩容、团队边界更清楚。
|
||||
|
||||
但是,这个时候复杂度并没有消失,它只是从“代码内部”转移到了“服务之间”。
|
||||
|
||||
在单体里,一个模块调用另一个模块,本质上是函数调用。
|
||||
可是在分布式里,一个服务调用另一个服务,已经变成了跨网络的 RPC。
|
||||
|
||||
同样,在单体里,订单、支付、库存如果都在一个数据库里,本地事务就能把它们一起包住。
|
||||
可是在分布式里,这些数据往往分散在不同服务、不同数据库里,于是跨服务协同就不再能靠一个本地事务解决。
|
||||
|
||||
这就是后面所有问题的来源。
|
||||
也就是说,**解耦、异步、削峰填谷、最终一致性、重试、幂等、顺序性、可靠投递,并不是消息中间件发明出来的问题,而是分布式系统天然会遇到的问题。**
|
||||
|
||||
---
|
||||
|
||||
## 第 4 页:问题来源之一——解耦、异步、削峰填谷
|
||||
|
||||
先看前三个问题:解耦、异步和削峰填谷。
|
||||
|
||||
### 1. 解耦从哪里来?
|
||||
|
||||
如果支付成功以后,订单服务还要直接去调用积分服务、短信服务、物流服务,那么每增加一个下游,主服务就多一条同步依赖。
|
||||
|
||||
这样一来,订单或支付服务不仅要关心自己的业务,还要感知每个下游的接口、可用性和上线节奏。这就是强耦合。
|
||||
|
||||
消息中间件的做法是,把“直接调用”改成“发布事件”。
|
||||
比如支付成功后,只发布一条 `PaymentSucceeded` 事件,下游谁需要谁订阅。这样发布者只负责表达业务事实,不再关心具体有几个消费者。
|
||||
|
||||
### 2. 异步从哪里来?
|
||||
|
||||
很多动作其实并不要求用户同步等待。比如发短信、发优惠券、写埋点、做分析,这些都不是主链路必须立刻完成的结果。
|
||||
|
||||
如果我们把这些动作都塞在同步请求里,用户的等待时间就会越来越长。
|
||||
|
||||
所以更合理的方式是:主链路只做关键事务,旁路任务通过 MQ 异步处理。这样用户更快拿到响应,系统也更轻。
|
||||
|
||||
### 3. 削峰填谷从哪里来?
|
||||
|
||||
大促、秒杀这类场景里,上游写入速度往往会在瞬间暴涨,而下游数据库和服务的处理能力是相对稳定的。
|
||||
|
||||
如果没有缓冲层,流量就会直接冲到下游,轻则超时,重则把下游打垮。
|
||||
|
||||
消息中间件在这里承担的是“缓冲区”的角色。上游先把消息写入队列,下游按照自己能承受的速率平滑消费。再配合限流、扩容、重试和死信治理,就能把高峰流量整形成可处理的流量。
|
||||
|
||||
---
|
||||
|
||||
## 第 5 页:问题来源之二——最终一致性、重试、幂等
|
||||
|
||||
接下来再看另外三个经常同时出现的问题:最终一致性、重试和幂等。
|
||||
|
||||
### 1. 最终一致性从哪里来?
|
||||
|
||||
在单体里,订单、支付、库存可以共享一个事务。
|
||||
但拆成分布式以后,这些状态往往分别保存在不同服务的数据库里,就很难再依赖一个本地事务同时提交。
|
||||
|
||||
这个时候,很多系统会采用事件驱动的一致性方式。也就是说,先让一个核心服务把自己的本地事务做成功,再通过事件把变更传播给其他服务,逐步把整个系统收敛到一致状态。
|
||||
|
||||
这就是我们说的“最终一致性”。如果某个下游暂时失败,也可以通过补偿、重试或者 Saga 机制继续推进,而不是要求所有服务在同一时刻一步到位。
|
||||
|
||||
这里补充解释一下 Outbox。
|
||||
在分布式系统里,一个很典型的问题叫做双写不一致。比如支付服务处理“支付成功”时,一方面要把自己的数据库状态改掉,另一方面又要把“支付成功”这个事件发到消息队列里。问题在于,这两个动作通常不在同一个原子事务里。
|
||||
|
||||
如果数据库已经提交成功了,但消息还没来得及发出去,服务就宕机了,那么下游系统就永远不知道这次支付成功,库存、通知、积分这些动作也就不会发生。
|
||||
反过来,如果消息先发出去了,但数据库事务最后回滚了,下游又会收到一个其实不应该存在的事件。
|
||||
|
||||
Outbox 模式就是为了解决这个问题。它的核心思路是:不直接把消息发到 MQ,而是先把“业务数据变更”和“待发送事件”一起写进同一个本地数据库事务里。
|
||||
也就是说,支付状态更新的同时,再往一张 Outbox 表里插入一条事件记录。只要本地事务提交成功,这两件事就一定同时成功;如果事务失败,这两件事就一起失败。
|
||||
|
||||
后面再由一个独立的 relay 程序,或者后台任务,去扫描 Outbox 表,把里面还没发送的事件可靠地投递到 MQ。等收到 Broker 的确认之后,再把这条 Outbox 记录标记为已发送。
|
||||
|
||||
所以 Outbox 的本质,就是把“数据库更新 + 发消息”这个难以保证原子性的过程,改造成“先本地原子落库,再异步可靠投递”。
|
||||
它解决的是事务与消息之间的衔接问题,是很多事件驱动架构里非常关键的一层。
|
||||
|
||||
不过也要注意,Outbox 解决的是“不丢消息、不漏事件”的问题,并不自动解决重复发送。所以即使用了 Outbox,下游消费者依然要做幂等。
|
||||
|
||||
### 2. 重试从哪里来?
|
||||
|
||||
只要是分布式调用,就必须接受失败是常态。网络会抖动,Broker 会重启,消费者会超时,ACK 也可能丢失。
|
||||
|
||||
一旦某次处理“看起来失败”,系统通常就会重试或者重投。
|
||||
|
||||
所以重试不是一个偶发现象,而是分布式系统必须显式设计的机制。一个成熟的设计通常至少要有:重试次数上限、退避策略、重试队列和死信队列。
|
||||
|
||||
### 3. 幂等从哪里来?
|
||||
|
||||
只要存在重试,就必须接受“同一条消息可能被处理多次”。
|
||||
|
||||
这意味着,我们不能假设消息只会来一次。工程上更可靠的思路是:默认它可能重复,然后通过 messageId、业务唯一键、去重表、唯一约束或者 upsert 让重复处理的结果保持不变。
|
||||
|
||||
所以可以把这三者连起来理解:**因为服务拆分,才需要最终一致性;因为最终一致性里一定会失败,才需要重试;因为有重试,才必须做幂等。**
|
||||
|
||||
---
|
||||
|
||||
## 第 6 页:问题来源之三——顺序性、可靠投递
|
||||
|
||||
再往下看两个更偏系统语义的问题:顺序性和可靠投递。
|
||||
|
||||
### 1. 顺序性从哪里来?
|
||||
|
||||
很多业务对象天然有顺序要求。比如同一个订单,应该先“创建订单”,再“支付成功”,最后才“发货”。
|
||||
|
||||
但分布式系统里,消息会分区、消费者会并发、失败后还会重试,这些都会打乱原本看似自然的顺序。
|
||||
|
||||
所以顺序不是默认就存在的,而是必须被明确定义出来。
|
||||
|
||||
工程上通常不会追求全局顺序,而是追求**局部顺序**。比如按订单 ID、账户 ID 这样的业务 key 来定义顺序范围,然后让相同 key 的消息进入同一分区、同一队列,或者由单线程、Single Active Consumer 这类方式串行消费。
|
||||
|
||||
### 2. 可靠投递从哪里来?
|
||||
|
||||
可靠投递也不能只看 Broker 本身。因为消息可能在四个地方出问题:
|
||||
|
||||
发送到 Broker 之前,Broker 持久化之前,Broker 投递给消费者之后,以及消费者处理完成但还没确认之前。
|
||||
|
||||
因此真正的可靠投递是一个端到端问题。
|
||||
|
||||
它通常包含几段机制:生产端要有 confirm 或 acks,Broker 侧要有持久化和副本,消费端要在本地事务完成后再 ACK,跨数据库与发消息之间还要通过 Outbox 或类似模式弥合空档。
|
||||
|
||||
所以工程上更常见的目标,不是抽象地追求“绝对恰好一次”,而是**至少一次 + 幂等 + 有边界的顺序**。
|
||||
|
||||
---
|
||||
|
||||
## 第 7 页:引入消息中间件后的纵览架构与数据流
|
||||
|
||||
理解了这些问题以后,我们再看为什么消息中间件会被引入。
|
||||
|
||||
引入 MQ 之后,整个系统一般会形成两条链路。
|
||||
|
||||
第一条是**同步核心链路**。也就是用户请求进入网关,再进入订单、支付这类必须同步完成的核心服务。
|
||||
第二条是**异步事件链路**。也就是核心服务本地事务成功后,把业务事件送入 MQ,再由库存、优惠券、通知、分析等下游独立消费。
|
||||
|
||||
这时候的典型数据流是:
|
||||
|
||||
第一,用户请求进入订单或支付服务。
|
||||
第二,核心服务把本地业务数据和 Outbox 一起提交。
|
||||
第三,事件被 relay 或 producer 投递到 MQ。
|
||||
第四,下游服务按需扇出消费。
|
||||
第五,各个消费者本地提交成功后再 ACK。
|
||||
第六,如果失败,就进入 Retry 或 DLQ,后续再补偿或人工介入。
|
||||
|
||||
这就是为什么消息中间件会成为关键基础设施。它把服务之间原来那种直接、同步、脆弱的依赖,转成了带缓冲、可恢复、可扩展的事件协作。
|
||||
|
||||
---
|
||||
|
||||
## 第 8 页:为什么要引入 MQ——它到底解决了什么
|
||||
|
||||
如果要用一句更直接的话来总结 MQ 的作用,我会说:
|
||||
|
||||
**它不是消灭问题,而是把问题从隐性的同步耦合,变成显性的系统机制。**
|
||||
|
||||
没有 MQ 时,系统常常是点对点同步调用。新增一个下游,就新增一条依赖;主链路越拉越长,用户等待时间也越来越长;流量和故障会沿调用链不断传导。
|
||||
|
||||
引入 MQ 之后,系统获得了四种关键能力:
|
||||
|
||||
第一,发布订阅,解决了解耦问题。
|
||||
第二,异步处理,让非关键任务离开主链路。
|
||||
第三,队列缓冲,把瞬时高峰转换成平滑消费。
|
||||
第四,配套 Outbox、ACK、重试和幂等之后,最终一致性和故障恢复开始变得可治理。
|
||||
|
||||
所以 MQ 的价值,并不只是“多了一个队列”,而是**重新定义了服务之间的协作方式**。
|
||||
|
||||
---
|
||||
|
||||
## 第 9 页:一个完整业务例子——支付成功后的事件流
|
||||
|
||||
这页我用一个支付成功的例子,把前面的概念串起来。
|
||||
|
||||
当支付服务确认支付成功时,它不会直接同步调用所有下游,而是先在本地事务里完成两件事:更新支付状态,同时写一条 Outbox 记录。
|
||||
|
||||
接着,relay 把这条 `PaymentSucceeded` 事件投递到 MQ。
|
||||
随后,库存服务会消费这条事件,去扣减库存;通知、优惠券、分析服务也可以并行地消费同一条事件。
|
||||
|
||||
如果库存服务处理成功,它就在本地提交完成之后再 ACK。
|
||||
如果库存服务失败,这条消息就进入 Retry 或 DLQ,等待后续重试或补偿。
|
||||
如果因为网络原因,这条消息又被投递了一次,那么库存服务也不应该被重复扣减,这就需要通过 `orderId + eventId` 这样的幂等键来兜底。
|
||||
|
||||
所以在这一条业务链路里,解耦、异步、最终一致性、重试、幂等、顺序和可靠投递,其实都会同时出现。
|
||||
这也是为什么消息中间件不是一个“点状技术”,而是整条业务链路的协作基础设施。
|
||||
|
||||
---
|
||||
|
||||
## 第 10 页:真正落地时,先想清楚的 4 件事
|
||||
|
||||
最后我想强调,落地时真正需要设计的,不只是 MQ 的集群怎么搭,而是下面这四件事。
|
||||
|
||||
第一,先划清边界。哪些动作必须同步完成,哪些动作可以异步收敛。
|
||||
第二,先定义事件。事件应该表达业务事实,而不是把 RPC 简单换个名字包成消息。
|
||||
第三,默认至少一次。也就是说,在设计上先接受重复,再用幂等和重试去消化它。
|
||||
第四,把事件流运营起来。持续监控 backlog、lag、重试率、死信队列和超时情况。
|
||||
|
||||
很多系统并不是死在 Broker 上,而是死在三个误区上:
|
||||
|
||||
要么把 MQ 当成强一致事务的替代品;
|
||||
要么无限重试,没有上限和告警;
|
||||
要么只搭了消息中间件,却没有配套的监控、补偿和事件治理。
|
||||
|
||||
所以真正成熟的设计,不是“用了 MQ”,而是知道**哪些问题应该让 MQ 解决,哪些问题不应该强行交给 MQ**。
|
||||
|
||||
---
|
||||
|
||||
## 第 11 页:总结
|
||||
|
||||
最后我用一句话收束今天的汇报:**消息中间件不是为了让系统看起来更高级,而是为了把分布式系统里原本隐性的协作复杂度,变成一套可以设计、治理和监控的机制。**
|
||||
|
||||
我的汇报就到这里,谢谢大家,欢迎交流。
|
||||
|
||||
---
|
||||
Reference in New Issue
Block a user