速成课 · No. 03
你很少凭空发明一套架构。你识别出哪一种已知的形状契合眼前的问题——然后付出那个形状所收取的代价。
只讲精髓 · 每个模式一幅画面 · 用例子代替理论
模式是对一个反复出现的问题的可复用答案。懂这套目录,意味着你不再重新发明——而是开始做选择。
模式是一个有名字的答案,不是一条规则
下棋时你不会每盘都重新发明开局。你认出"这是西西里防御",于是已经知道它大致会通往哪里。
架构模式也是一样:一个跨越成千上万个系统反复出现的问题——各个部分怎么找到彼此?怎么扛住负载?——配上一种已知靠谱的形状和已知的代价。你不是在耍聪明。你是站在几十年里别人留下的伤疤之上。当你说"服务需要对别处发生的事情做出反应",那就是 event-driven——早已被解决、被命名、被记录下来了。
架构模式不是设计模式
设计模式是你怎么摆放一个房间里的家具。架构模式是整栋房子的平面图。
设计模式(Factory、Observer、Strategy)活在一个模块内部,活在代码里。架构模式决定那些大盒子以及它们之间的连线——什么被单独部署、什么和什么对话、数据住在哪里。这门课讲的是平面图,不是家具。
每个模式都收取代价
跑车和厢式车都是"对的"——只是对应不同的人生。抽象地讲,谁也不是更好的那辆车。
不存在最好的模式,只有契合不契合。微服务给你买来独立伸缩,向你收取一套要运维的分布式系统。分层单体给你买来简单,日后向你收取痛苦的伸缩。真正的本事是在签字之前先把账单读明白。
你不该挑那个出名的模式。你该挑那个你付得起其代价的模式。
大多数系统起步时都是单个可部署单元。最初的几个模式,讲的是你怎么安排它的内部,让它不至于变成一团烂泥。
分层(n-tier):把职责像楼层一样叠起来
一家餐厅:餐厅大堂接单,厨房做菜,储藏室存货。服务员从不下厨;厨师从不去地窖——那是下一层干的事。
所有模式里最常见的一种。代码被切成一层层横向的层——表现层、业务逻辑、数据访问、数据库——一个请求笔直地穿过它们落下去。例子:一个 web 应用里,controller 调 service,service 调 repository,repository 打到 Postgres。它容易理解,也容易测试(把下一层 mock 掉)。代价是:所有东西作为一个整体发布,一个微不足道的改动也可能意味着重新部署整个东西。
模块化单体:一栋楼,隔好墙的一套套公寓
一栋公寓楼——一个屋顶、一个大门,但每套公寓有自己的墙、自己的门。你不必穿过邻居的卧室才能走到楼梯。
仍然是单个可部署单元,但内部被切成一个个模块——计费、商品目录、账户——它们只通过清晰的 interface 对话,绝不伸手去碰彼此的表。例子:一个后端里,catalogue 从不 import billing 的内部,只调它的公开函数。**你得到微服务大部分的清晰,却不必背上那张网。**对几乎所有人来说,这是对的默认选项。
MVC / MVVM:让屏幕离脑子远一点
一座剧场:剧本、演员和导演、舞台和灯光,是由不同的人做的不同的活儿。你可以换掉布景,而不必重写整出戏。
这些模式在用户界面与业务逻辑和数据之间立起一道墙,这样一个对某样东西长什么样的改动,就不会危及它做什么。例子:Django 和 Rails 用 MVC(Model 是数据,View 是页面,Controller 是黏合剂);富 web 和移动应用偏向 MVVM,其中一个"view model"持有屏幕状态,view 只是照着它映射。那道墙永远是同一道:一边是表现,另一边是含义。
单体不等于一团乱。没有内部隔墙的单体,才是。
同一个想法画三种样子:把 framework、数据库和 web 推到最外缘,让业务规则对它们一无所知。
ports and adapters(hexagonal):核心周围是一圈标准插座
一台游戏机有标准的接口。游戏不知道你插的是手柄、方向盘还是街机摇杆——它只读到"输入"。
业务逻辑坐在正中间,并定义一组 port——像"保存一个订单"或"发送一个通知"这样的 interface。外侧的 adapter 针对某种具体技术去实现它们:Postgres、Stripe、email。例子:你的领域调用一个 OrderRepository port;生产环境里一个 adapter 用 Postgres 撑起它,测试里另一个 adapter 用一个内存列表撑起它。外部世界可以被换掉,而核心从不察觉。
Clean / Onion:依赖一律指向内
一颗洋葱:核心在正中,一层层裹在它外面。你可以剥掉最外那层皮而不伤到心——而心并不依赖那层皮。
同样的原则,画成一圈圈同心圆:entity 和 use-case 在正中,framework 和 I/O 在最外圈,而每一支依赖的箭头都指向中心。例子:"金额超过一万的发票需要审批"这条规则,活在一个朴素的 class 里,它不从你的 web framework 或 ORM import 任何东西——所以它比两者都活得久。你可以替换 UI 库、换掉 web framework、迁移数据库,而那些规则原封不动地存活下来。
Framework 是细节。把它当墙纸看待,而不是地基。
到了某种规模——负载的,或者团队的——一个盒子就不灵了。这些模式把系统切成一块块能独立发布的部件,并回手塞给你一张网。
微服务:一座美食广场,不是一个大厨房
一座美食广场——一个个独立的摊位,各有自己的厨房、菜单和收银台。披萨摊忙起来就多雇两个厨师;沙拉摊不必跟着雇。
应用被拆成一个个小服务,每个拥有一项能力和它自己的数据,各自独立地部署和伸缩。例子:订单、库存、发货是分开的服务,通过 HTTP 或一条消息总线对话;黑五那天你只伸缩"订单"和"支付"。它们是 SOA 精简后的后裔——SOA 当年尝试过同样的拆分,却把一切都路由经过一条沉重的企业服务总线,结果被它噎住了。**代价又陡又实在:**网络延迟、跨服务没有省心的事务,还有一套要运行的分布式系统。它适合大团队和不均匀的负载——对一个五个人的创业公司则是杀鸡用牛刀。
serverless / 函数:按菜租厨房
与其拥有一辆你坐不满的车,你每趟叫一辆出租车,只在计价器跑着的时候付钱。
你写一些小函数,云按需运行它们,从零伸缩到上千再缩回去,按每次执行计费。例子:一张图片被上传到存储,触发一个函数去生成缩略图——两次上传之间没有服务器闲坐着。很适合尖峰的、事件形状的活儿;对长任务很别扭,对任何无法容忍冷启动的东西也是。
分布式系统把函数调用变成网络调用。它们每一个都可能失败。
一旦你有了不止一个盒子,架构就大半在它们之间的连线里了。它们怎么对话,决定了它们怎么出故障。
请求-响应:在柜台前问完就等
在柜台点单——你问,你站在那儿,拿到咖啡才挪步。
默认的形状:一个客户端调用一个服务器,并等着那个答案(REST、gRPC、一次数据库查询)。简单又直接。问题在于时间上的耦合——如果服务器慢了或宕了,调用方也跟着卡着干等。例子:一次结账调用支付 API,在它回复之前一直阻塞。
event-driven:宣布它,让监听者各自反应
一间新闻编辑室。出了点事,公告发出去,谁在意——体育部、天气、广告——就各自反应。播报员不等他们。
不去直接调用服务,而是一个组件发出一个事件——"OrderPlaced"——任何感兴趣的东西都去反应。两种风味:broker(事件自由地链式触发,没有指挥)和 mediator(一个协调者按顺序跑完各步)。例子:"OrderPlaced" 扇出到 email、库存和分析——各自独立、各自可伸缩,谁都不阻塞订单。代价:流程更难跟踪、更难测试,而且你继承了重复和顺序的问题。
队列与 pub/sub:在它们之间放一座邮局
一座邮局替你存着信,直到收件人准备好。你扔下信就走;他们醒来时再读。
一个消息队列或 pub/sub broker(RabbitMQ、Kafka)坐在发送方和接收方之间,这样它们不再必须同一刻都在线。例子:一个慢吞吞的报表请求被扔到队列上,一个 worker 有空时把它捞起来,而网站保持麻利。这给你买来韧性,并把尖峰抹平——代价是结果是最终的,而非即时的。
pipe-and-filter:给数据装一条流水线
一条灌装线——洗瓶、灌装、封盖、贴标——每个工位只做一件事,然后把瓶子传下去。或者水穿过一摞滤芯。
数据流过一串独立的步骤,每一步都转换它再交给下一步。例子:一个 Unix 管道(grep | sort | uniq)、一个 ETL 作业,或一个编译器(分词、解析、优化、生成)。每个 filter 都简单、可测、可重新排序。当工作是一串清晰的转换序列时最佳——当各步需要回头互相通气时则不对。
同步是打电话。异步是写信。按"每一边该有多依赖对方醒着"来选。
有些模式根本不是关于盒子的。它们关于你怎么写、怎么读、怎么记住那份真相。
CQRS:把写的方式和读的方式分开
一座图书馆有一份用来找书的快速目录,还有一间真正把书归档、上架的后屋。查找按一种方式优化;存放按另一种方式优化。
CQRS 把写模型(改变东西的 command)和读模型(取出它们的 query)分开,这样每一个都能为自己那份活儿去塑形和伸缩。例子:一个仪表盘从一个去规范化、预先算好的视图里读,而写入则进到一个干净的事务型存储。当读远远多于写时威力很大——但这是两个要保持同步的模型,所以别把它当默认选项去拿。
event sourcing:存历史,推导出当下
一家银行不只是存你的余额——它存下每一笔存款和取款。余额只是它们的总和。丢了它,你也能从总账重新算出来。
不去保存当前状态再覆盖它,你存下发生过的完整事件流;当前状态是对它们的一次重放。例子:一个账户被存成"开户、+100、−30",而不是"余额:70"——这给了你一份完美的审计轨迹,以及问出"上周二它长什么样?"的能力。代价:查询"现在"更难了(常常和 CQRS 配对),而事件是永久的——你没法直接编辑历史。
saga:一笔可以倒退的长交易
订一趟旅行——机票、酒店、租车。如果租车告吹,你把酒店和机票也取消掉。没有一个单一的"撤销";你把每一步逐个倒走回去。
当一个业务动作横跨好几个各自拥有自己数据库的服务时,你没法把它裹进单个事务。一个 saga 把若干个本地事务链起来,一旦失败,就跑补偿动作去撤销前面那些。例子:下单、预留库存、扣卡;如果扣卡失败,就释放库存并取消订单。这就是你在没有全局锁的情况下,让分布式数据保持一致的办法。
一个事务是一颗意图的原子。当它横跨多个服务时,你得自己把这颗原子造出来。
有几个模式,是为某一个具体的痛而存在的。当你正好有那个痛时再去拿它们——一刻也别提前。
microkernel / 插件:一个小核心,配可换的部件
一把电钻:一个马达,十几个可互换的钻头。钻还是那把钻;活儿需要什么,你就咔哒一声装上什么。
一个极简的核心提供基础,其余一切都作为插件抵达,它们自己注册自己,彼此互不依赖。例子:VS Code、Eclipse 和浏览器大体上都是插件宿主;一个保险系统可以把各州特有的规则放成插件,这样新增一个州只是一个新模块,而不是一次重写。当 feature 多变、或因客户而异时最理想。
space-based:把数据复制进内存,干掉瓶颈
一座体育场有二十个售票窗口,每个手里都拿着自己那份座位图的副本——而不是大家排在一个总售票处前。
当单个数据库在极端负载下成为咽喉时,这个模式铺开许多个处理单元,每个都把数据保存在内存里、并在它们之间保持同步——没有一个中心数据库要去排队。例子:一场限时秒杀或一场拍卖,迎面撞上潮水般的并发出价。它威力大、花费高,只有在真正巨大、尖峰的并发下才挣回它的本钱。
peer-to-peer:根本没有中心
一场百乐餐——每个人既是厨师又是客人。没有餐厅,没有服务员;这群人自己喂饱自己。
各个节点彼此直接对话,没有中心服务器,每一个既是客户端又是提供者。例子:BitTorrent,每个下载者同时也在上传,所以加入的人越多,系统反而越强。对韧性和不靠中心宿主的伸缩极好——对一致性和控制则很难。
冷门模式是药,不是保健品。冲着确诊的病去吃它。
模式是一套词汇,不是一份你只点一道菜的菜单。真实的系统会叠用好几种——而最明智的一步,往往是最朴素的那一步。
你组合模式;你很少只挑一个
一栋房子同时用了很多模式——承重墙、管道、电路、保温层——而不是"唯一真正的那种建筑模式"。
一个真实的系统也许内部是一个模块化单体、每个模块里是 clean architecture、UI 用请求-响应、慢吞吞的副作用走一个事件、而那个重活前面挡着一个队列。问题从来不是"哪一个模式",而是**"哪些模式,放在哪里"**。例子:大多数成功的产品,都是一个无趣的分层单体,长出了零星几个事件,以及恰好长在痛处的那一两个抽出来的服务。
从能用的最简单的形状起步
你不会为了盖一间小木屋去浇一座体育场的地基。
几乎每个系统都该作为一个模块化单体起步,并去挣得它的复杂度。微服务、CQRS、event sourcing、space-based 网格——这些是对规模和团队大小那类问题的答案,而那些问题你也许永远不会有。等痛真实且被点了名时再采纳它们,而不是出于预期。最便宜的分布式系统,是你没造的那个。
- 这个形状解决的是哪个反复出现的问题? 用一句话把它说出来。 - 我现在有那个问题吗 ——还是我在 对未来瞎猜? - 它要花多少代价? 在延迟、运维、复杂度、金钱上。 - 能用的最简单的模式是哪个? 从那里 起步。 - 我能不能以后再采纳这个,等痛真的冒出来? - 这个模式得和哪些模式一起过日子? 它们得彼此 合得来。
- 你的服务比工程师还多。 - 一个单一的用户动作,要碰五个服务和两个队列,去做一个函数调用就能 做的事。 - 你在还没有读或审计问题之前,就加上了 CQRS 或 event sourcing。 - 团队里没人能凭记忆 把整个系统画出来。 - 你选这个模式,只因为它是大公司在用的。
- 一个新工程师能在几分钟内找到一个改动该放哪儿。 - 一起变化的部分住在一起;伸缩方式不同的部分 是分开的。 - 当某样东西坏了,波及范围是一个盒子,而不是所有盒子。 - 你能用一句话讲清每个模式的活儿 和它的代价。 - 架构匹配你实际拥有的负载和团队,而不是你想象中的那个。
最好的架构,是那个能撑过你真实需求的、最简单的架构——再多一个模式都不要。