cloud design patterns
16 min read

cloud design patterns

经典《设计模式》GoF对写好客户端代码很有帮助,那么在云计算的时代,又该有怎样的设计模式呢? 偶然看到微软的云设计模式文档,虽然是基于Azure的解决方案,但思路是相通的。

  • Ambassador

在请求远程服务时,通过加一个前置代理来完成各种繁琐通用操作,比如身份验证,失败重试以及断路器等等。这样应用的代码逻辑就比较简洁清晰。

  • Anti-Corruption Layer

随着新旧系统的迁移,各系统的依赖已经发生了改变。为了避免对新旧系统进行代码修改,可以加入中间层做单/双向转换以维持原有接口不变,保证新旧系统正常对接。

这个在硬件产品中经常会看到,比如新式手机都不提供3.5mm耳机孔了,但你可以购买一个TypeC转3.5mm适配器,就可以继续使用旧耳机。

任何系统的改造都是有成本的,硬件产品如此,软件系统亦如此。

  • Asynchronous Reques-Reply

使用异步的请求模式,以保证用户操作的流畅性。因为服务请求的处理时间可能会比较长,所以服务端在接到请求之后,可以立即先回复202表示已接到请求,然后接着慢慢处理。客户端则在得到确认之后,通过轮询等待结果,而不至于卡死操作界面。

  • Backends for Frontends

对于一个产品,可能会有不同的前端程序,比如PC客户端,手机app和网页等等。如果都通过同一个后台服务接口访问,可能会因为不同前端业务需求而搞得很复杂。最好还是为每一个前端程序提供专门的后台服务,这样互不干扰。

  • Bulkhead

对服务实例和共享资源进行分组管理,避免有些实例因为负载过重或者故障而影响所有服务,而应把问题限制在分组内。就像船舱隔板,一旦某个舱壁漏水而不至于蔓延到整船而导致沉没。

  • Cache-Aside

使用缓存来避免直接访问数据源。对于集中和频繁访问的资源,缓存可以有效的降低对数据源的访问压力。

  • Choreography

当一项任务需要调用多个服务来完成时,不需要一个总指挥,而是由各个服务自行协调完成。每个服务从消息总线获取任务,又把结果放回消息总线。

  • Circuit Breaker

对于远程服务调用失败的时候,我们往往会采取反复重试的机制。但是集中的频繁的重试,可能会给原本已经不堪重压的远程服务又踏上了一脚,最后就挂了。特别的,当它刚刚从故障中恢复时,巨量的重试又会瞬时把它打垮,完全没机会恢复正常。

所以应该在重试模块前面加一个断路器,当发现频繁重试或者访问返回了暂时不可恢复的错误码时,就会进入断开状态,阻止继续访问。等到一段时间之后,再进入半开状态,最后完全合上。

这个跟电网里用电过猛或短路会跳闸一样。家庭电路有断路器,插线板有过载保护,微波炉有保险管,都是为了阻止故障扩散。

  • Claim Check

消息总线适合传递大量短消息,如果要传递包含大数据的消息(比如文件)就不合适了。这时候就应该通过其它途径(比如共享存贮)先存放大的数据,然后只在消息里传递提取标识和校验信息。

这跟人们日常在文字聊天消息里放置网盘链接和提取码是一个道理。

  • Command and Query Responsibility Segregation(CQRS)

数据一般是写入一次反复读取;写入的代价比较高,需要做各种校验和转换,并尽可能的保存所有信息,延迟可以比较高。读取一般只读少量信息,要求延迟低。如果底层存贮使用同一个模型,就较难同时满足两者的要求。

因此,需要把写入和读取分开,使用不同的模型。写入可以使用异步消息模式。为了优化读取效率,可以生成专门的数据模型。

  • Compensating Transaction

一项用户请求往往会调用很多服务来完成,而每个服务都有自己的数据库。一旦失败,并没有简单的事务处理机制可以横跨所有服务来回滚所有操作,而只能一个个服务进行取消回退。

所以每项服务都要提供自己的回退操作。通过在执行的过程中记录所有操作,一旦失败就可以根据记录相应执行补偿操作。不是简单恢复原有数据库数值这么简单,因为中间可能会有人修改了对应的数值。而应该根据具体业务进行针对性的补偿,比如刚才减1了,现在就要加1,如此等等。

让问题更加复杂的是,回退操作本身也会出错,所以也要能恢复。因为需要反复尝试,所以补偿操作应该是幂等(Idempotency)的。

  • Competing Consumers

服务请求者和服务提供者通过消息总线进行解耦,请求方把消息放于总线上,提供方获取消息进行处理。出于效率和可靠性的需要,在一条总线上会有多个服务实例接收消息。但消息只能处理一次,所以要防止多个服务实例接收同一个消息。同时,因为服务实例在处理消息的时候可能会失败,这时又应该允许另一个服务实例来接收该消息以继续完成处理。

  • Compute Resource Consolidation

因为相关性,有些服务是紧密相连的,所以最好放在一起,方便管理和提高性能。

比如kubenets的pod就包含了多个相关的container,并运行在同一个node上。

  • Deployment Stamps

部署服务的多个实例,可以通过DNS等负载均衡机制把用户自动指到特定的实例。

每个实例相互独立,访问自己的数据,互不干扰,典型的水平扩展。

  • Event Sourcing

记录数据的修改记录,而不只是修改结果。这样就可以回放和追溯。也可以通过回放修改记来进行数据同步,还可以根据需要生成不同的数据模型。

比特币就是典型的例子;在区块链里保存的是历史交易记录,所谓超级账本。这个账本并没有当前的状态信息,也就是说你不能简单的查询某个账户当前的余额是多少。要知道这个信息,你需要从头开始回放整个交易记录,并跟踪某个账户的变化情况。因此,比特币客户端在首次使用前需要下载所有的区块数据,并对所有交易进行跟踪,记录哪些币还没有被使用(UTXO),保存在本地数据库里。在以后校验交易时,直接检查UTXO数据库即可。与此同时,比特币客户端还需要根据当前钱包的密钥列表,过滤出哪些UTXO是属于当前钱包的,所谓账户余额。所以,比特币客户端首次运行需要很长的时间,就是因为整个回放过程。不仅如此,客户端还需要持续运行,接收最新的区块数据,进行持续回放,以更新本地数据库。

对于普通用户来说,他并不关心其它人的数据,所以没必要记录所有的UTXO,他只需要处理跟自己相关的交易即可,这就是SPV钱包。SPV钱包客户端不需要下载所有完整区块,而只请求(或者过滤)跟自己相关的交易记录,只维护当前账户的状态。因此,首次启动的时候就可以很快,以后数据的同步量也比较少。

当然,你也可以把以上流程委托给交易所来做,你只要有交易所的密码即可,那就跟区块链没啥关系了。

在比特币之后的区块链产品里(比如以太坊),作为改进,都会把最新的回放结果直接记录在最新区块里(即可每个账户的最新余额以及所有变量的最新值)。这样客户端首次使用时,就不用麻烦的回放整个交易历史了,只要校验最新区块的有效性即可。

  • External Configuration Store

配置信息统一位置存放,这样就可以保证一致性,以及方便维护。

比如etcd就是一个实例,k8s里的config也是。

  • Federated Identity

使用统一的身份校验服务,这样方便跟已有的系统整合,使用同一个身份。

  • Gatekeeper

反向代理作为守门员,放在真正的服务之前,用于对客户请求进行校验。

服务开发人员一般主要考虑的是业务逻辑的实现,较少考虑如何防止恶意攻击。因此,如果服务直接对外暴露,就可能因无防备而陷入险境。前面加一个专业的代理服务,可对付常见攻击,并只暴露有限接口,降低风险面。

  • Gateway Aggregation

有些功能需要客户端跟多个后台服务进行交互才能完成,不仅客户端逻辑复杂,一旦后台服务发生变化也很麻烦。这时,可以由后台系统提供专门的业务代理,客户端只要向代理发出一个请求,然后由代理依次向后面多个服务进行请求,结果汇集后再发给客户端。

这样客户端不仅逻辑简单,请求返回更迅速。因为代理与后面服务的距离更近,交互效率更高。

比如搜索,用户向google.com提交搜索关键字,后者需要向多台服务器进行同时请求,结果聚合后返回。你不会知道这个查询涉及了多少台服务器。

比如DNS查询,客户端向8.8.8.8发了一个dns请求,在没有缓存的情况下,后者需要从根域名开始递归查询多次,最后才能把结果返回。这些操作,客户端完全可以自己完成的,只是效率比较低而已。

  • Gateway Offloading

把一些通用功能放在前置代理服务器完成,最常见的是SSL卸载。其它还有身份校验,流量监控,和访问限流等。

  • Gateway Routing

对用户暴露统一的访问端口,然后在接入服务器上通过路径来区分并传递给对应的后台服务。

反向代理服务器的主要功能之一。

  • Geodes

在一个数据中心部署完整服务,可以为任何用户服务,但主要是为附近地区的用户提供更快的服务。

数据在geode保存,并在多个geode之间进行同步,保证用户的全球漫游。

  • Health Endpoint Monitoring

每个服务都提供健康检查端口,供外部程序对本服务的状态进行监控,以即时发现异常。

  • Index Table

为常见的数据查询创建相应的索引,因为有无索引的检索效率是天壤之别。

  • Leader Election

多实例共同协作时,为避免冲突,可通过共识机制推举其中一个实例担任领导以协调任务。

  • Materialized View

根据每个应用的需求,对原始数据进行读取,预先生成优化的数据模型,以供快速查询。

比特币客户端就是从区块链提取生成针对当前用户的钱包模型并提供专门访问接口。这样应用层就只要操作这个模型即可,不需要去直接读取区块数据。

  • Pipes and Filters

把复杂的业务进行模块化,基于消息总线的组合和变换,灵活实现不同的业务功能。

就像生产流水线一样,每个环节只做简单固定操作。

  • Priority Queue

给每条消息设置优先级,在消息总线上传递时,高优先级消息应优先处理。当有大量消息堆积时,优先级设置可以保证重要消息可以及时处理。

如果消息总线没有优先级功能,则可以通过使用多条总线来实现。

  • Publisher/Subscriber

使用发布/订阅模式使消息发布者和接收者进行解耦,系统更容易扩展。发布者不用针对接收者进行发布,一条消息总线可服务所有人。接收者可根据需要,对消息进行过滤,只接收关注的消息。

  • Queue-Based Load Leveling

使用消息队列来接收用户请求,可吸收访问峰值,保护后台处理服务不受异常流量冲击而瘫痪,总是按正常节奏处理。

  • Retry

对于远程服务的访问,会因网络异常出现暂时性失败,一般只要多试几下就好。所以应该在代码里增加自动重试逻辑,避免把暂时性失败的情形暴露给用户而产生不良体验。

但对于短时不可恢复的失败情况,则不应该重试,避免给被调用服务雪上加霜而引发雪崩。

  • Saga

当一个事务涉及多个服务来共同完成的时候,每个服务内部都有自己的事务机制。一个服务完成之后,即可通知下一个服务开始。一旦失败,则取消自己的事务,并通知其它服务进行相应的事务取消。

服务之间可以通过消息总线来自动协同,或者通过专门的调度服务来统一协调。

  • Scheduler Agent Supervisor

当一个任务需要调用多个服务来完成时,需要由调度器来产生多个子任务,并通过监控器来监控子任务的执行状态。调度器负责指挥子任务重试,或者取消,保证获得最终执行结果。

包工头和监工。

  • Sequential Convoy

对于关联的消息,应该由同一个服务实例处理,以保证处理次序。

比如同一个用户的多个连续相关操作,如果被不同服务实例处理,就会产生相互依赖的问题。

因此对消息的分发,应该遵循某种特定的规则进行分组,而不应该完全随机。比如通过ip或者user的哈希。

  • Sharding

对数据按照特定规则进行分组,每个服务只访问自己的分组,从而避免服务之间产生竞争冲突。

分组规则可以是查表,数据范围,以及主键哈希等。

  • Sidecar

涉及多个模块的应用,因为开发语言,程序框架,以及安全考虑等原因,可以把相关的模块放置在单独进程或者容器里。但它们是紧密绑定的,缺一不可的,必须一起部署。

比如主要功能放在一个进程里,附加功能放在另一个进程里。一个可以用C/C++写,另一个可以用Go写,这就是独立进程的好处。

  • Static Content Hosting

对于需要提供静态数据的服务,可以把静态资源放到CDN上供用户直接访问。在节省了当前服务的计算和带宽资源的同时,效率也提高。

  • Strangler Fig

在旧系统在向新系统迁移的过程中,为了不对用户产生影响,可以通过创建前置代理层来做转换。

迁移完成,代理层即可废除。

  • Throttling

对资源使用进行限定,防止某个服务耗尽整个系统的资源,从而影响全局导致系统瘫痪。

使用限定是一种预防机制,也是一种报警机制。超于设定阈值,可能是故障或者攻击,也可能是正常的用户激增。对于后者,应该在限流的同时提高负载处理能力。

根据服务的不同,使用者的不同,使用不同的限定值。

  • Valet Key

当涉及到对第三方资源操作的时候,让用户直接操作可能比服务中转的效率更高。

比如需要用户上传文件的话,可以让用户直接上传到共享贮存,而不是先传给自己再做转存。

这需要控制好访问授权,只开放必要的权限,并及时取消授权。