速成课 · No. 18
当系统的一部分需要另一部分做点事情时,简单的做法是调用它并等待答复。强大的做法是把一条 message 丢进 queue 里然后继续往前走,让对方在准备好的时候再来取。这一个转变——从调用并等待,变成发消息并继续——正是系统保持快速、在故障中存活并实现扩展的方式。
只讲精髓 · 每个想法一个画面 · 掌握术语
要明白 queue 为什么存在,先得切身体会一下那个简单替代方案的痛:一个服务调用另一个服务并等待它。它一直管用,直到真正要紧的那一刻。
一次直接调用把两个服务绑在一起
给某人打电话,然后一直占着线直到他们把事情做完——你不能挂断,不能做别的任何事,而如果对方不接,你就被卡住了。
服务 A 想让服务 B 做点事情,最显而易见的方式是一次 synchronous 调用:A 发出请求,然后等待,在 B 答复之前把一切都悬着。这把它们紧紧地 coupling 在一起——A 的速度如今被 B 的速度劫持,A 在 B 完成之前无法继续。对于一次快速答复,这没问题。但对于缓慢或繁重的工作,A 就把时间花在僵在那里、等别人身上。
如果被调用方挂了,调用方就崩了
打电话给仓库去确认一笔订单,结果因为没人接,你索性拒绝接收任何新订单——一扇关闭的门就把整间店都卡死了。
更深层的问题是故障。如果 A 调用时 B 已经宕机或过载,A 的请求也会失败——故障会顺着链条一路向上 propagate 回来。一个缓慢或损坏的服务能拖垮所有依赖它的东西,而涌向 B 的流量高峰会把 A 一起拽下水。紧耦合意味着脆弱会扩散,而系统的可用性只取决于它最不稳的那一部分。
有些工作不该阻塞用户
在结账台,你付了钱、拿着小票就走——你不会站在收银台旁边等他们打包、发货、给你发邮件。那些慢的部分是在你走开之后才发生的。
很多工作并不需要在用户拿到响应之前完成——发送确认邮件、生成发票、缩放照片。让用户为这一切干等既慢又毫无意义。你想要的是接下请求、快速答复,然后让那些慢的部分随后再发生。一次直接调用做不到这一点;它让每个人都为每件事干等。
一次直接调用在时间上把两个服务 coupling 在一起:调用方等待,并继承了被调用方的迟缓与故障。对于缓慢或不紧急的工作,这是个陷阱。
解法是不再调用,改成发消息。queue 就是夹在中间的那个缓冲,让一边把工作留下、另一边来取——在时间上把它们解耦。
丢下 message,然后继续往前
留一条语音留言,而不是一直等着别人接听:你说清楚自己要什么,挂断,然后接着过你的日子——他们能腾出手时会处理的。
A 不再调用 B 并等待,而是写一条描述这项工作的 message,把它丢进一个 queue——一条让 message 排队等待被处理的队列。A 现在可以立刻自由地继续往前;它不在乎 B 什么时候来处理。这条 message 是一个小小的包裹,写着「这里有件事需要做」,是被交接出去,而不是就地完成的。这次交接就是全部的要点。
queue 是时间上的缓冲
桌上的一个收件盘:工作在里面堆积,被平稳地逐一处理,于是一阵突如其来的文件潮也不会压垮这个人——它只是让收件盘暂时高了一截而已。
queue 作为一个 buffer 坐落在两边之间,把 message 留住,直到它们被处理。这把它们 在时间上解耦:发送方和接收方不再必须很快、可用、甚至不必在同一时刻运行。一阵爆发产生的工作可以被平稳地消费。queue 吸收了工作到达的速度与工作能被完成的速度之间的落差。
asynchronous 意味着不等结果
寄一封信,而不是当面对话——你把信寄出去就接着忙别的,相信它稍后会被读到、被处理,而无需你杵在那里。
这就是 asynchronous 的工作:发送方不等结果。它把 message 发射出去就继续往前,而结果稍后才会在带外发生。你放弃了一次直接调用那种即时答复,作为交换,你不再被阻塞。对于任何不需要即时答复的事情,这笔交易正是快速、有韧性的系统的根基。
queue 是发送方与接收方之间的一个 buffer。丢下一条 message 然后继续往前——工作在时间上被解耦,在对方准备好时被 asynchronous 地完成。
三种角色让一个 queue 运转起来,把它们命名清楚就能厘清大部分术语。一边制造 message,一边处理它们,而中间有个东西把它们安全地留住。
producer 制造工作;consumer 去做工作
一间厨房:服务员把点单票钉上去,厨师再一张张取下来准备。这两边从不直接说话——那排票就是全部的接口。
创造 message 的那一边是 producer(或 publisher);处理它们的那一边是 consumer(或 worker)。producer 丢下一张票;consumer 取走一张,做这项工作,然后转向下一张。它们彼此既不了解也不等待——它们只认识 queue。正是这种干净的切分,让每一边都能各自被构建、扩展和修改。
broker 把 message 安全地留住
夹在发送方和收件人之间的一家邮局:它收下你的信,妥善保管,一直留着直到收件人来取——这样即便他们不在,也不会有东西丢失。
正中间坐着 broker——像 RabbitMQ 或 Apache Kafka 这样的软件,它接收 message、可靠地存储它们,并把它们投递给 consumer。它就是系统里的邮局。它的可靠性正是要点所在:哪怕每一个 consumer 都宕机了,broker 也会把 message 安全地留着,直到有人准备好去处理它们,于是工作是被停放,而非丢失。
加 consumer 来跑得更快
当点单票堆起来时,你就在这条线上多放几个厨师——每人取走下一张票,积压更快地被清空,而订单进来的方式毫无改变。
正因为 consumer 只是拉取下一条 message,你可以让 许多个 consumer 并行地 从同一个 queue 上取活儿。积压在增长?那就多加几个 worker,它们会自动分担负载——每人抓取一条不同的 message。这是这套模式的一大优势:你通过增加 consumer 来独立地扩展那个慢的部分,而无需触碰 producer 或 queue 本身。
producer 创造 message,consumer 处理它们,而 broker 在两者之间把它们安全地留住。需要更高的吞吐量?加 consumer——它们会自动分担这个 queue。
一条 message 可以是两种截然不同的东西:一道去做某事的命令,或一则某事已经发生的通告。后者悄悄地改变了你设计整个系统的方式。
一条 command 让一个 worker 去做一项任务
一份交给某个特定部门的工单:「缩放这张图片。」它指明了这件活儿,期望恰好一个团队去做,到此为止。
一条 command message 是瞄准一个 consumer 的直接指令:「发送这封邮件」、「处理这笔支付」。这是把 queue 当作待办清单来用——producer 知道该发生什么,把任务交接出去,由谁取走就由谁来做一次。这是 queue 最简单的用法:把特定的工作卸下,留待稍后恰好做一次。
一条 event 通告某事已经发生
布告栏上一则全公司范围的通告:「刚刚有一位新客户注册了。」它并不告诉任何人该做什么——在乎的人各自以自己的方式去反应。
一条 event 则不同:它陈述一个关于过去的事实——「订单已下」、「用户已注册」——而不说谁该做什么。producer 只是把它通告出去,既不了解也不在乎谁在听。也许没人反应;也许五个服务都反应。这翻转了这层关系:你不再命令某个特定的 worker,而是广播某件事是真的,然后让感兴趣的各方自行决定。
pub/sub:一条 event,多个反应
一份报纸印一次,投递给每一位订户,他们各自出于自己的理由去读它——体育迷、投资者、填字游戏爱好者——读的都是同一份报。
这套广播模型就是 publish/subscribe(pub/sub):一个 producer 把一条 event publish 到一个 topic,而每个感兴趣的 consumer subscribe 并拿到自己的一份副本。一条「订单已下」的 event 可以同时触发邮件服务、分析服务和发货服务——这就是 fan-out。publisher 甚至不知道它们的存在。稍后可以加入新的反应,而完全无需触碰 publisher。
event 把谁认识谁解耦开
给这份报纸新增一位订户,对报纸如何撰写毫无改变——publisher 永远不需要知道谁在读。
这正是 event-driven 设计深层的力量:一条 event 的 producer 与所有对它做出反应的人完全解耦。要新增一个响应「订单已下」的功能,你只需 subscribe 一个新的 consumer——订单服务一点也不用改。这样构建出来的系统靠增加监听者来成长,而不是靠编辑那个被监听的东西。这正是大型系统得以演进、而不必让一切都依赖一切的方式。
一条 command 让一个 consumer 去做一项任务。一条 event 通告一个事实,让任何人都能反应——而 pub/sub 把一条 event 扇出给许多人,将谁认识谁解耦开。
queue 增加了活动部件,所以它必须配得上这份代价。它配得上,因为它换来了三样几乎别无他法才能得到的东西:韧性、对高峰的平滑处理,以及独立扩展。
韧性:一个宕机的 consumer 只是延迟工作
如果厨房关门一小时,点单票就只是在那排票轨上等着——重新开门时,厨师们逐一消化积压。没有一笔订单丢失。
正因为 broker 把 message 留着,一个 consumer 崩溃并不会丢失工作——message 会一直等到它回来,然后被处理。把这跟一次直接调用比一比,在那里一个宕机的服务意味着彻头彻尾的失败。queue 把「服务宕机了」从一个硬性错误变成了一次临时延迟。这种 韧性——工作在故障中存活并恢复——往往是团队伸手去拿 queue 的单一最大理由。
负载削峰:吸收掉高峰
夹在一场山洪与一座小镇之间的一座水库:暴涨的洪水倾泻进水库,水库再以平稳、安全的速率向下游放水。
当一阵请求洪流同时到来——一场大促、一个走红的瞬间——queue 让你能 削平负载:高峰灌满 queue,而 consumer 按自己平稳的节奏排空它。下游那个慢的系统从不会看到高峰,只看到一股可控、均匀的流。这股由积压所承受的压力被称为 backpressure。没有 queue,同样的高峰会直接锤向系统,很可能把它掀翻。
让每一边各自扩展和演进
你可以多雇厨师、更换厨房的设备,或加一个新的工位——这一切都无需改变服务员接单的方式,因为他们只接触那排票轨。
解耦意味着每一边都能独立地改变。把 consumer 扩容或缩容以匹配需求;用另一种语言重写一个 consumer;新增一个全新的 consumer 去反应已有的 event——这一切都无需触碰 producer。queue 是系统各部分之间一道稳定的接缝,而稳定的接缝正是让一个大型系统能够一块块地成长和改变、而非一次性全改的东西。
queue 换来韧性(工作在崩溃中存活)、负载削峰(高峰被吸收),以及独立扩展——这些好处是一次直接调用根本给不了你的。
asynchronous 消息传递解决了真实的问题,也制造了新的问题。没有哪个是致命的,但假装它们不存在,正是基于 queue 的系统染上那些微妙又恼人的 bug 的原因。
message 可能到达两次
邮局为了保险起见,有时会把一封它拿不准是否送达的信再投一份副本——宁可两次也别一次都没有,但现在你可能会对同一道指令照做两遍。
大多数 broker 保证 at-least-once 投递:它们宁可把一条 message 发两次,也不愿冒丢失它的风险,于是一个 consumer 偶尔会收到同一条 message 不止一次。如果「向客户扣款」跑了两次,那就是个实打实的问题。解法是 idempotency——把工作设计成做两次和做一次效果相同(先检查「是否已扣款?」)。假定会有重复;让重复无害化。
顺序得不到保证
接连寄出的两封信,到达的先后可能颠倒——所以如果第二步比第一步先冒出来,收件人就懵了。
当有许多 message 和许多并行的 consumer 时,message 未必会按它们被发送的顺序被处理。如果「更新地址」和「删除账户」乱序到达,你就会得到一堆胡话。一些系统以一定代价在某个类别内保持顺序;通常更省事的做法是设计出不依赖严格顺序的工作。无论哪种方式,除非你专门做了安排,否则 永远不要假定顺序。
结果是 eventual consistency
一则通告在办公室里扩散:有那么几个瞬间,一些人知道而另一些人还不知道,直到消息传遍每个人,他们才重新一致起来。
正因为反应是 asynchronous 地发生的,系统是 eventual consistency 的:一条 event 刚发生之后,不同的部分可能短暂地各执一词——订单已经存在,但分析还没把它算进去。它在片刻之间就会追上,但「处处即时一致」没有了。你要围绕这道缝隙来做设计,给用户展示合理的中间态,而不是假定每个部分都在同一瞬间更新。
一条有毒的 message 需要有去处
一封没人能处理的信——糊掉了、根本看不懂——不能就这么永远重试下去,把整条线堵死。它会被放进一个专门的盘子里,留待有人来看。
有些 message 永远无法被成功处理——格式损坏,或者所指的数据已经没了。放着不管,一个 consumer 就会永远重试它们并堵住 queue。标准的答案是 dead-letter queue:在几次失败的尝试之后,这条 message 被挪到一旁一个单独的 queue 供检查,于是它不再毒害整条流。为会失败的 message 做好准备,否则一条坏 message 就会卡住它后面的一切。
asynchronous 消息传递带来重复(用 idempotency 来应对)、得不到保证的顺序、eventual consistency,以及会失败的 message(用 dead-letter queue)。这四样都要做好准备。
queue 是一件强力工具,而不是默认选项。本事在于:当解耦真正物有所值时伸手去拿它,而当简单更值钱时留住那次直接调用。
当等待就是问题所在时,才用 queue
对于稍后再来取的慢服务,你抽个号就走开——但对于一个快速的「是或否」,你直接在柜台问就好,因为抽号会比干等还要傻。
当工作 缓慢、突发或不紧急 时、当你想让调用方保持快速时,或者当好几个服务都应该对同一件事做出反应时,伸手去拿 queue。当你需要一个即时答复才能继续时——一次价格查询、一次权限检查——就留住一次 直接调用,因为在那里简单和即时的结果比解耦更值钱。让工具去匹配「等待究竟是不是问题所在」。
别条件反射地伸手去拿它
为了给隔壁桌的人传一张便条,就装上一整套收发室和分拣系统——这套机器现在的成本比它要解决的问题还高。
queue 增加了实打实的复杂度:一个要运行的 broker、要应对的重复与顺序、要围绕设计的 eventual consistency,还有更难追踪一个如今要在多条 message 间跳来跳去的请求。对于一个两部分之间只需一次快速调用的简单系统,那份额外开销并不值得。当一个具体的好处——韧性、扩展、解耦——能证明这份代价物有所值时再加 queue,而不是因为 event-driven 听起来很高级。
- 等待是问题所在吗——这项工作是否慢得、突发得、或不紧急得足以解耦? - command 还是 event——是一个 worker 做一项任务,还是许多服务对一个事实做出反应? - 这项 工作是 idempotent 的吗——跑两次是否安全,既然 message 可能到达两次? - 它是否假定了 顺序,而我是否做了安排或把这种依赖设计掉? - 系统能否容忍 短暂的 eventual 不一致? - 失败的 message 去哪里——有没有一个 dead-letter queue?
- synchronous / asynchronous——调用并等待,对比发消息并继续。 - coupling / decoupling——在时间上绑在一起,对比彼此自由。 - queue / message / broker—— 那个缓冲、那个工作单元、那个把它们留住的软件。 - producer / consumer——制造 message 的那一边、处理它们的那一边。 - command / event——做这项任务,对比 这件事发生了。 - pub/sub / topic / fan-out——把一条 event 广播给许多订户。 - at-least-once / idempotency / dead-letter queue / eventual consistency——asynchronous 的那些隐患 及其解法。
- 你把 缓慢、突发、不紧急 的工作排进队列,而为即时答复留住 直接调用。 - 你的 consumer 是 idempotent 的,于是一条重复的 message 不会造成伤害。 - 你 不假定 顺序,除非你明确做了安排。 - 你为 eventual consistency 做设计,而不是指望即时 一致。 - 失败的 message 落进一个 dead-letter queue,而不是堵死整条线。
用 queue 是要深思熟虑的:当在时间上解耦能换来韧性、扩展或 fan-out 时,伸手去拿一个——而当一个即时答复比这一切都更要紧时,留住那次直接调用。