Skip to content

Latest commit

 

History

History
560 lines (292 loc) · 37.5 KB

Cloud-Design-Pattern.md

File metadata and controls

560 lines (292 loc) · 37.5 KB

Cloud Design Patterns

原文

[TOC]

Cache-Aside 模式

Read:

  1. 检查数据当前是否在缓存中
  2. 如果数据不在缓存中, 则从数据库读取
  3. 将数据的拷贝保存在缓存中

**Write: **

  1. 如果数据不在缓存中, 直接写到数据库
  2. 如果数据在缓存中, 则删除缓存中的数据拷贝.

注意:

  • 缓存生命周期: 通常缓存数据会设置过期时间, 过长过短都会有问题. 缓存时间需要根据应用的数据访问模型来进行设置. 另外要注意, 缓存只在相对静态的数据或读显著高于写的情景下才能提高效率.
  • 数据的淘汰(Evicting): 当缓存快满时, 需要将一些数据干掉以释放空间, 通常使用LRU算法, 也可以根据自己业务来自定义算法. 注意淘汰数据的时候最好综合考虑从数据库读取数据的成本和保留缓存数据的成本以及处理淘汰策略的成本.
  • 预热(Priming): 在应用启动时将可能会访问到的数据提前加载到缓存, 以避免启动时服务和数据库的过载.
  • 一致性风险: 虽然应用内的代码会尽量处理好一致性问题, 但难免出现遗漏, 以及从其他程序对数据库的修改. 尤其是当复制库之间进行频繁同步的时候, 可能会导致难以理解的一致性问题.
  • 本地缓存: 使用本机内存作为缓存会在集群环境下带来一些不便. 不同节点间的缓存数据会快速出现大量不一致. 对数据快速过期只能缓解症状.

使用场景:

数据分布在一个较大的范围内(无法完全载入缓存), 并且实际数据需求难以预测, 因此将数据向缓存的加载推迟到数据将要被使用时.
另外要注意, 对于web应用的会话信息不适用该模式.

Circuit Breaker 模式

该模式用于在访问远端数据或服务时出现的临时性的失败或异常时进行控制.

简单使用 retry 反而会使得系统资源被迅速耗尽, 直至崩溃. 此时旧需要这种断路器模式了.

该模式的本质是个状态机, 来模拟电路中的断路器. 它具有三种状态:

  • Closed: 闭合状态. 此时正常请求, 如果出现错误则记录次数, 当一段时间内的错误率达到一定阈值, 则变为 Open 状态, 同时启动一个 timeout 定时器, 当定时器结束后状态将自动变为 Half-Open 状态.

  • Open: 此时会立刻返回收到的请求, 而不再执行请求对应的操作.

  • Half-Open: 这种状态下会尝试以限定的速度(次数)来执行操作并响应请求, 如果这些操作都成功, 则变为 Closed 状态并重置 failure 计数器, 此时服务恢复; 如果其中任何一个失败, 则再次将状态变为 Open 并设置新的 timeout 计时器.

通过合理设置 Half-Open 状态, 可以避免故障恢复后出现瞬间负载过高导致再次超时或故障. 当 Half-Open 状态再次转为 Open 状态时, 也可以逐步增加 timeout 计时器的时间. 实践中 Open 状态可以返回异常, 也可以返回一个有意义的默认值.

注意:

  • 对于比较复杂的情况, 可以根据失败时的不同异常类型来进行不同程度的处理.
  • 注意控制计时器的时间, 太长会导致故障恢复后无法尽快转换状态, 太短会导致频繁在 Open 和 Half-Open 状态之间切换.
  • 可以在 Open 状态下使用心跳检测或 ping 的方式来帮助尽早探测到服务的恢复, 以减少等待时间.
  • 对于可能会故障很久的情况, 采用管理员手动切换状态的方式也是一个办法.
  • 很多情况下, 断路器都需要应付高并发的场景, 请确保断路器实现能正确处理并发请求, 另外, 注意不要引入过高的性能开销.
  • 注意远端不同数据源的区别对待, 以免混淆. 比如数据库的不同 replication.
  • 某些情况下可以记录 Open 状态时的请求, 当状态正常后再重放这些请求.

Compensating Transaction 模式

不知道是不是可以翻译成事务补偿模式.

应用所依赖并需要修改的数据通常并不在一个位置, 他们分布在不同的数据库, 机器, 机房, 城市. 实践中, 在这样的情况下去保证事务一致性是低效且不可取的. 事实上应该去保证最终一致性而不是事务一致性.

一系列相关操作的回滚不是简单的将数据恢复到操作之前, 而是需要考虑到在这期间的其他并发操作的影响, 考虑到各个步骤间可能完全异构的数据持久化方式, 考虑服务本身状态变化的回滚等许多复杂因素.

一般可以使用 workflow 的方式实现最终一致性操作. 记录每一步操作, 并且每一个步骤都有对应的撤销操作. 多个步骤的撤销操作有可能是同时进行, 并不一定是按照正向操作完全相反的顺序进行.

Compensating Transaction 本身也有可能会失败. 系统应该能够从未完全执行的"事务"中进行恢复并继续执行. 这个过程中有可能需要重复执行上次失败的操作, 因此, 最好将所有的操作都设计为 idempotency (幂等)的. 另外有时可能某些异常或失败需要人工干预, 这时应该尽量保留关键业务中尽可能的各种信息以备审阅.

注意:

  • 有时候, 某个有问题的步骤并没有立即返回失败, 而是被阻塞了. 这个时候需要用超时机制来解决.
  • 补偿逻辑都是强业务相关的, 因此它以赖于步骤执行中是否可以提供充分的信息用来进行操作的撤销.
  • 执行事务逻辑的部分需要能快速从故障中恢复, 并完整的保留上次处理时的断点信息, 用来从故障中准确的恢复事务的处理.
  • 对于一次操作中所要使用的所有数据加一个时间较短的超时锁, 并在操作开始前先获取他们, 这样可以提高整体的操作成功率. 操作必须在获取到本次执行所需的所有数据后才开始, 必须在超时锁到期前结束才算完成.
  • 可以与重试模式结合, 在操作 fail 或异常后先重试, 再重试无法完成的情况下在进行事务的撤销.

Competing Consumers 模式

  1. 多应用程序(生产者)接收用户的请求, 将其封装为消息, 送到消息队列中.
  2. 多个后台服务程序(消费者)从队列中读取消息, 并对其进行处理.

作用与优点

  • 用于处理大量的请求(异步地), 业务逻辑处理过程中, 不会阻塞其他请求的处理.
  • 可以应付突发的大量请求, 消息队列可以作为一个 buffer.
  • 提高了服务的整体可靠性.
  • 消费者之间不需要太复杂的协调处理
  • 可扩展性, 易扩展性
  • 消息队列可以改造成支持事务的方式, 当消费者处理失败后可以重新放入队列被其他实例处理.

注意

  1. 需要注意负载的均衡性, 以免个别实例成为系统瓶颈
  2. 恰当处理消息的顺序. 将消息设计成具有幂等性质的, 以消除系统对消息的顺序的依赖.
  3. 需要检测并处理有问题的消息. 以免出现循环重试.
  4. 如果后台服务(消费者)完成了对消息的处理并生成了结果, 并且需要将这个结果返回给应用程序(生产者). 那么需要将结果保存在一个地方(可以被应用程序访问到), 并设法通知应用程序该消息已处理完成, 让它去指定位置取出结果即可.
  5. 当系统规模很大时, 单个消息队列会成为瓶颈. 可以考虑将生产者和消费者分成多个区, 每个区有一个消息队列, 也可以不分区, 而采用负载均衡的方式向多个消息队列分发消息.
  6. 消息队列本身需要保证可靠性, 即所有收到的消息至少需要被发送一次.

使用场景

  1. 业务逻辑可以被拆分成若干可异步执行的任务.
  2. 任务之间无依赖, 可以并行执行

PS:

PeekLock : 也称非破坏性读. 从消息队列读取消息时并不直接删除, 而仅将它对其他消费者隐藏. 首个消费者如果成功处理完成该消息, 会主动将它送消息队列中删除, 如果失败, 则 peeklock 会超时, 并将该消息重新置为可见状态.

##Compute Resource Consolidation 模式

有时针对一个应用或服务, 我们会将其中的每个小的计算过程拆分成独立的计算单元, 这样可以简化复杂业务逻辑的设计, 但是同时也增加了整个系统的运行开销可复杂度.

通过将多个计算单元合并起来, 可以降低开销, 提高利用率和通信速度, 并且易于管理.

可合并性: 可以从几个方面考虑. 1. 关于可扩展性相关的配置(如需要配置的实例个数等), 2. 运算生命周期, 3. 运算所需资源.

不适合合并的情况, 比如下面两个任务: 1. 轮询, 对时间敏感的消息发送服务; 2. 处理大量突发网络请求的服务. 体会一下他们两之间的差异.

关于生命周期: 在云上的服务设施可能会周期性的进行回收处理. 对于一些服务我们可能需要设置一些检查点, 使得当程序下次启动时可以从检查点的位置继续执行.

发布节奏: 某些计算可能会频繁发布新的代码, 这些发布导致的代码更新, build, 部署等操作同样会影响到与其合并在一起的其他计算任务.

容错: 合并在一起的计算任务, 如果一个出了问题就很可能会影响到另一个的正常执行.

资源竞争: 合并在一起的计算单元最好有着互补的资源需求, 以免发生对资源(CPU, 内存, IO等)的大量竞争.

不适用的场景

  • 关键的容错服务
  • 具有高敏感性或私有数据的服务
  • 具有独立安全配置的服务

Command and Query Responsibility Segregation (CQRS) 模式

命令与查询职责分离, 其实也相当与是一种读写分离.

传统的方式: 针对一类数据实体, 存在对应的数据传输对象(DTO), 对该数据的读和写都由 DTO 负责和控制. 对于小规模的简单逻辑, 这样的方式还可以胜任, 但当业务逻辑越来越复杂后, 开发维护成本, 以及潜在的安全风险和数据不一致性, 以及整体性能都会收到很大影响.

使用 CQRS 模式的方式: 针对一类数据实体, 读写操作分为两个不同的接口. 这样可以简化设计和实现方式, 但缺点是这种方式可能无法使用工具自动生成代码 (比如 JAVA ).

读和写通常可以操作同一个物理上的数据存储设备, 但也可以通过使用不同的物理数据层来提高性能 (比如 mongodb 的 secondary replication). 通过使用多个只读的数据层可以显著提高查询性能和应用程序的响应速度. 分离读写部分, 还有利于进行根具针对性的扩展来满足业务需求, 比如通常查询请求的量要明显高于写入/修改请求.

关注点:

  • 将数据存储在不同物理节点可以提高性能, 但代价是系统的复原性和最终一致性所带来的复杂度的上升.
  • 实现最终一致性的一种典型的方式是使用事件驱动来连接 CQRS. 写入模块是一个由事件驱动的, 只能追加的流. 所执行的指令用来更新读取模块的视图(view).

当联合 CQRS 和事件驱动一起使用:

  • 这种模式下, 仅能保证最终一致性. 在事件发生到数据最终被更新之间会存在一定的延迟.
  • 事件驱动会引入一定的复杂度来发出和处理事件等. CQRS 本身具有的复杂度和事件驱动模式结合后, 将使实现更加困难. 但事件驱动也带来了好处: 1, 更容易对一块业务进行建模; 2, 更容易对视图进行重建, 或直接重新生成一个. 因为将要变化的信息被保留在事件对应的数据中.
  • 需要留意数据处理所需的时间以及占用的资源. 因为所有相关的事件都需要被检查. 通过实现一个周期性的数据快照可以解决一部分这类问题.

Event Sourcing Pattern

append-only 的事件队列, 其中的事件描述了在针对一个 domain 中的数据进行的操作而不是数据的当前状态.

可以简化任务的复杂度, 避免对同步任务 model 的依赖, 还能提升性能, 扩展性, 响应速度, 提供数据一致性. 提供审计的线索, 并且支持进行补偿(compensate)操作.

传统 CRUD 的局限性:

  • 直接在数据存储上进行更新操作, 影响性能, 响应速度, 限制了扩展性.
  • 在多用户并发场景下会出现大量的冲突.
  • 无法进行数据操作的审计, 除非单独开发数据操作记录功能.

**解决方案: **
将对数据的操作描述封装成事件, 事件有序的插入处理队列中, 该队列也同时在存储设备上进行持久化. 这些事件本身就可以用于审计.
事件存储器同时也负责向对应的处理器发布这些事件, 这些事件的发布可以复制成多份, 发送给不同的服务.
注意, 应用程序中生成事件的部分, 和后端对事件进行处理的部分是接耦的.

典型的使用场景是用于维护某些数据实体的物化视图(materialized view). 当某个动作发生的时候, 该事件消息被发布到多个 handler 中, 对与这个动作影响的数据有关联的其他数据作出修改. 这些修改和动作本体接耦.

事件驱动的优点

  • 事件本身是不可变的对象, 因此可以用 append-only 的方式存储. UI, 工作流等进程生成并发出对应的事件后就可以继续向下执行了, 而操作的执行由后端的程序执行. 每个事务的执行之间不会存在竞争. 消除了事务之间的竞争, 将会大幅提高系统的性能以及可扩展性. 尤其是对于会话层和 UI 层.
  • 事件对象中的结构较简单, 主要包含对操作的描述和与操作相关的一些额外数据. 这些都相对比较易于实现和管理.
  • 事件对象对于领域内专业人士是有意义的, 但数据库里的数据可能对他们就没有可读性了.
  • 可以防止并发更新造成的冲突. 不过模型本身也需要在设计上防止读到不一致的数据才行.
  • 方便审计, 数据状态的还原, 重放, 以及测试和调试. 并且可以方便的撤销数据的变更到任意历史位置.
  • 解耦!

关注点:

  • 存储的事件是不可变的. 如果需要撤销修改, 也是通过发送互补事件来完成(相当于反向事务). 有不可变性带来的一个问题是, 当事件的格式更新的时候, 事件的存储器中会同时存在新旧两种不同格式的事件. 处理器需要能够同时应付二者, 因此, 事件信息中带有 version 信息就是一个比较好的策略了.
  • 多线程或多实例应用使用事件驱动时, 事件存储的一致性问题非常重要. 不同事件间的顺序可能对结果有很大影响. 给事件添加时间戳可以一定程度上缓解事件顺序带来的问题. 另外也可以在事件中使用数据ID和事件ID, 当出现操作的冲突时, 可以根据ID拒绝某个事件的操作.
  • 可以每隔一段时间(比如一小时)就给数据做一个快照, 每个时间间隔将事件流分成若干段. 这样就可以从任意快照获取当时的数据状态, 并且重放其前后的事件流就可以将数据状态拨到指定的时间点.
  • 事件流可以缓解数据操作冲突的可能性, 但无法完全避免这种问题. 对于一些边界情况依然需要有对应的策略进行处理.
  • 事件信息通常只能保证 "至少发送一次". 因此事件本身需要设计成幂等的. 需要避免同一事件被播放两次造成的错误操作.

使用场景

适用:

  • 需要知道每一次数据修改的目的或原因的场景
  • 需要减少甚至避免数据更新冲突的场景
  • 需要通过重放事件进行数据恢复或撤销的场景

不适用:

  • 太小, 业务逻辑太简单的系统.
  • 对数据一致性和数据的实时处理有严格要求的场景
  • 对数据历史, 审计, 回滚重放等没有需求的场景
  • 几乎不会发生数据更新冲突的场景.

External Configuration Store Pattern

将应用程序的配置文件从构建的包中转移到一个中心化管理的地方. 用于简化应用的配置管理以及赋予灵活的控制能力.

配置信息置于包中, 每次修改都需要重新部署. 不同应用之间也无法共享一些配置, 比如数据库url, UI主题等.

实现外部存储保存配置信息, 保证其接口可以简单高效的读写配置信息. 配置信息的保存应该是带类型并且格式化的(比如 json 或 yaml). 多数情况下该接口可能需要实现认证授权的功能来保护其中的配置信息. 最后, 需要支持多版本的配置信息, 用于 dev, staging, production 等可能的环境中.

如何处理配置信息的作用域问题, 以及配置之间的继承属性的设置(不同级别如公司, 应用, 实例等之间会有一些配置的继承或扩展关系).

配置信息健壮性的问题, 无法读取? 写入错误的数据? 字符大小写? 二进制数据的处理? null 和空字符串的处理?

Federated Identity Pattern

将身份验证代理给外部的 ID 系统.

类似于单点登录, OAuth.

Gatekeeper Pattern

Health Endpoint Monitoring Pattern

SLA (Service Level Agreement) : 服务水平协议. 如云计算厂商为用户保证的服务质量.

两个组成部分:

  1. 应用程序提供一个访问 API, 并负责进行一些必要的自身状态检查并返回结果
  2. 监控程序定期向应用程序发送请求, 然后分析检查应用返回的结果.

一个运行正常的服务/程序通常也意味着其依赖的其他服务和资源处于正常状态.

检查点:

  1. 检查 HTTP 的响应码;
  2. 检查响应内容, 看是否有报告异常的状况;
  3. 检查请求响应时间.
  4. 检查 SSL 认证证书的过期时间.
  5. DNS 解析时间和结果的有效性;

从多个不同的机器上进行监控, 并可以对比各自的监控结果;
如果可以, 尽量从接近用户的位置进行监控;
监控模型复杂时, 对应用的监控 API 可以分成多个不同粒度和优先级的;
不要给监控加入过度的细节检查, 代价太高且没必要;
服务的监控 API 需要考虑安全问题, 尽量不要直接暴露到外部以防恶意利用. 典型的手段: 1. 请求身份验证; 2. 采用晦涩的URL Path; 3. 将 API 隐藏到其他的IP或端口上; 4. API 只接收请求中包含指定特殊的识别码/操作码的请求, 否则返回 404;
当数据非常敏感重要时, 不要依赖简单的混淆和隐藏方式, 而应该使用更可靠的安全机制如 HTTPS;
多数情况下监控系统都需要实现自检机制. 确保监控本身的可用性和状态确定性.
注意, 监控并不能取代系统的日志和审计部分.
如何加入用户自定义的监控规则?

Index Table Pattern

当数据存储本身不支持次级索引(非主键索引)的时候, 我们如何自己实现该功能?

本质就是创建另外一个表. 又几种常见的实现策略:

  • 多个表中都包含完整的数据, 只是按照不同的索引字段来组织. 只适用于静态且较小的数据集.
  • 只有一个原始表包含全部数据, 其他索引表按对应的索引字段组织, 但对应的值不是原始数据, 而是原始表中该数据对应的主键. 适用具有动态性且较大的数据, 不过每次查询的开销较大(两次查询).
  • 采用前两中方案的混合. 索引表中保存数据的主键和常用字段, 如果访问的是常用字段则不需要再去原始表中查询; 如果访问的时不常用的字段, 则需要再根据数据的主键再进行一次查询. 该方案取得一个总体的平衡

维护次级索引的开销很高, 因此只在非常必要的时候再使用索引表.
当使用很多不同的索引表, 并且数据集非常大的时候, 各个索引表之间的数据一致性问题将会非常严重. 这时有必要专门实现一个程序来管理数据的最终一致性.
索引表本身也有可能时有复制集或分片的.

感觉需要自己实现索引的机会很渺茫, 这部分主要还是帮助理解数据库索引的基本原理吧.

Leader Election Pattern

通常以集群方式运行的服务, 各个实例之间多数时间都是各自自主工作, 但实例间的通讯也是必要的, 如用来避免冲突或资源竞争, 或者将各个实例的计算结果进行聚合.

常用的几种选举策略有:

  • 按照实例ID或者进程ID进行直接选举. 通常ID数值最小的优先;
  • 竞争一个互斥量, 谁拿到谁就是 Leader. 但需要注意确保 Leader 和信号量之间的接耦, 避免 Leader 挂了后该互斥量不可用.
  • 采用其他通用的选举算法, 比如 Bully 算法, Ring 算法. 手动实现一个合适的选举算法可以为定制和调优提供最大的灵活性,

Bully 算法
Ring 算法

选举进程需要能够应对短暂和永久性故障;

需要确定 Leader 节点从故障开始到该故障被系统检测到所用的时间. 该时间根据系统状况的不同而不同. 有时系统可以短暂的运行于无主状态下, 这提供了对于 Leader 短暂性失效的恢复能力, 但另外一些时候, 系统需要在 Leader 失效后立即选举出新的 Leader; 可以考虑使用外部服务来提供这个互斥量, 但会引入额外的外部依赖.

也可以使用一个专用程序来实现 Leader 的功能, 但是一旦该进程出现问题, 可能会影响到整个系统的性能和响应时间, 因为其他节点需要等待该程序的恢复.

切勿过度设计, 没有必要使用选举的时候不要多事. 比如: 各节点竞争的资源种类少逻辑简单, 直接用锁搞定; 某些系统中天然存在一些扮演 Leader 角色的进程; 第三方(云服务提供商)可以提供更合适的方案时.

Materialized View Pattern

数据在数据库中的存储结构和程序进行查询时所期望的结构之间存在矛盾或不一致.

将数据从库中载入, 并将其封装到一个设计良好的物化视图中(如一个 Object), 改视图一般仅用于特定查询, 通常不进行更新操作. 当应用本身要更新数据的时候, 通过一些特定的调度机制来完成物化视图的更新.

可以用来大幅提高性能, 主要是从多个数据源将复杂结构的业务实体数据拼接到一个 view 实体中.

也可以作为传递给 UI 的 DTO(Data Transfer Object).

materialized view 数据较常用于 report 系统或 dashboard 中.

不适用场景: 1. 数据简单, 查询方便; 2. 数据快速变化; 3. 高一致性

Pipes and Filters Pattern

将一个庞大的处理过程拆解成若干个小任务或步骤, 以提高系统的灵活性, 可扩展性, 提高性能和代码的复用.

拆解后的任务之间需要有一个标准化的数据结构, 每个处理单元接收此结构的数据, 进行处理后再向下一节点发送该结构的数据.

pipeline 上的某些节点可能成为瓶颈, 此时可以并行多个该节点来提高处理能力(吞吐率).

pipeline 上的不同节点可以运行在不同的机器上, 利用这点可以轻松的对系统做横向扩展.

系统的输入和输出如果支持流(stream)的方式, 那么就有可能使得各个任务同时进行.

类似事件驱动的消息队列. 需要考虑幂等, 重复发送等类似问题.

不适用的场景: 1. 每一步计算处理的不是相互独立的; 2.每个计算步骤所依赖的上下文较为复杂, 使得上下文的传递将整个系统的效率拉低.

Priority Queue Pattern

解决问题: 请求按顺序发送, 但是处理顺序不一定是请求顺序, 某些特定的请求需要提前被处理.

两种方式:

  1. 多个队列对应不同的优先级, 每个队列有专属于自己的消费者池, 高优先级对应的资源更好;
  2. 队列同上, 但所有队列共享一个大消费者池. 只要高优先级的队列有消息, 就总是先处理高优先级的而不管低的. 虽然提高了消费者的利用率, 但是却有可能造成低优先级队列的饥饿; 此外, 当所有消费者都在处理低优先级消息时, 有高优先级消息进入, 需要有一种任务抢占式调度的实现.

一种实现起来更复杂,但支持更灵活的优先级的方式: 所有进入队列的消息随着时间流逝其优先级也在逐渐提高. 这可以保证及时只使用一个队列也能实现多优先级的管理. 但这最好能在消息队列本身特性的支持下.

某些情况下, 可以动态调整消费者池的大小, 甚至将一些非常低优先级的队列挂起

Queue-Based Load Leveling Pattern

解决问题: 当负载(请求)波动较大时, 间歇性的高峰会使服务的质量下降甚至无法正常服务.

类似于消息队列, 其本身使用一个队列当作缓存使用, 对请求发起方和服务处理方接耦, 提高了服务整体的可用性. (即使后端服务挂了, 也依然可以继续接收请求)

可以提高扩展性, 也可以降低系统整体成本. (对系统的最大处理能力的要求降低了)

和 throttling 概念不同, throttling 是通过降低了整个系统的处理量来保证系统维持一定的可用性. 而 leveling 则是不损失可用性的前提下进行削峰平谷. 从另一个角度来说, leveling 就是为了尽量不要触发 throttling.

消息队列是一种单向的通信机制, 如果需要让后端的服务给予前端应用响应的话, 可以参考后面的异步消息引论.

如果系统支持 autoscaling, 注意如果在队列后监听了较多的消费者后, 可能会对其后依赖的共享资源产生较大压力, 因为消费者太多会导致 leveling 的缓冲效果被抵消.

Retry Pattern

重试策略:

  • 如果错误信息表明该错误不是暂时性的, 多次重试不可能得到正确的结果, 那么直接返回异常即可. 比如账户密码验证失败.
  • 如果错误信息非常奇怪罕见, 则有可能是由于运行环境如网络丢包等引起的, 这种状况下可以立即进行重试, 因为这类问题只是很小的概率发生.
  • 如果错误信息表明它是由常见的连接中断或者服务忙等类似原因导致, 则等待一小段时间后重试将有可能得到正确的响应.

对于存在大量实例的情况下, 重试时间需要尽量分散开, 避免所有实例的重试请求在同一时间发出, 给服务再次过载.

如果请求连续失败, 可以重复该重试策略, 直到总的重试次数或所用时间超过一个最大值. 重试间隔时间既可以按固定速度增加, 也可以采用经典的指数退避算法.

实际的重试策略需要根据业务和失败的具体情况进行调校.
对于一些非关键业务, 快速让其失败并返回有时要优于采用重试策略, 因为不断重试回消耗不少的系统资源.

对于某些出现大量重试的地方, 有可能是对应的资源不可用, 此时程序最好对所有访问该资源的请求直接返回失败, 等过了一段时间后再打开少量请求进行试探. 具体实现可以参考 Circuit Breaker Pattern.

一旦有出现重试重发等类似场景, 都需要仔细考虑是否有实现幂等的必要性. 因为重试的请求有可能被后端服务重复执行.

对于一个事务性操作中的某个步骤, 需要仔细调试重试逻辑, 尽可能不要使整个事务被回滚.

需要注意避免逻辑上的嵌套重试. 实现时, 尽量让更底层一些的任务快速失败并返回原因, 让上层(业务)逻辑来进行重试.

Runtime Reconfiguration Pattern

对于关键服务, 需要尽可能减少重启/部署的频率, 因此需要实现修改配置不需要重启就能生效的功能.

一个典型的运行中修改配置的例子: 动态修改应用的 log 级别, 用来在出现问题后不需重启就可以直接调整 log 级别进行 debug. 还有其他诸如动态换数据库, 动态修改程序执行逻辑等邪性的用法.

配置动态改变的模块还可以自动应对一些运行状态. 比如系统出现一些特定的错误的时候, 自动调整日志级别以收集更多有用的信息.

对于某些无法在运行时动态改变的配置项, 可以考虑对应用进行自动重启, 或者由管理员选择一个合适的时候进行重启, 同时要保证集群中的其他机器可以代为处理业务流量.

某些环境可以将配置发生变化的事件暴露给应用, 但是有些不具备这种功能的系统中, 可以采用简单的轮询检查配置是否发生了变化, 或者实现专用的 API 来进行控制.

访问和修改运行时配置的权限需要严格控制, 配置项的有效性也最好进行严格的验证, 避免将错误的配置提交到运行中的系统中.

如果应用以集群方式运行, 则有一个潜在问题: 当配置更新的时候, 可能不是所有实例在同一时间更新配置, 那么将有一小段时间, 部分实例使用旧配置而其他的使用新的. 设计时需要评估这种现象的影响.

有时可能需要对集群中的少量实例更新版本试运行, 此时需要给这些实例单独的配置, 使线上其他实例不受影响. 另外, 需要考虑实现配置的回滚, 以快速从错误配置导致的故障中恢复到最近一次正确的配置.

Scheduler Agent Supervisor Pattern

有时一个复杂的应用中的业务, 包含许多相互之间相对独立的步骤, 而这些步骤中有些还是需要请求远端服务或资源的. 这些操作被精心安排和控制并实现了一个复杂的业务处理过程. 程序需要尽可能的确保每次查询任务能够完成, 对于过程中可能出现的异常需要进行处理, 得确保服务本身端到端的完整性.

Scheduler Agent Supervisor 定义了几个角色:

  • 调度器: 负责整个任务的调度, 每个子任务的顺序, 中间可能需要的外部请求, 整体的请求时间和系统其他模块(角色)之间的通信等. 调度器需要将任务执行的过程信息和状态保存在一个持久化的数据存储 State Store 上.
  • 代理: 封装一个远程调用或请求, 并实现对应的错误处理和重试机制. 当任务有多个不同的远程调用时, 他们各拥有一个独立的 Agent.
  • 监督者: 监控任务执行过程中每一步的状态. 它周期性的执行, 检查各个由调度器处理中的任务状态, 如果发现有超时或失败的情况, 安排对应的代理或调度器回滚/重试等操作. 回滚/重试操作由调度或代理实现, 监督者只负责调用即可. 监督者需要读取 State Store 中的数据来

以上都是整个系统中的逻辑角色, 具体实现方式根据实际情况而不同, 比如 Agent 可能是一个完全独立 Web Service.
另外对于集群环境, 每个角色都可能不止一个实例. 不过需要注意监督者之间的管理, 避免不同监督者对同一个任务进行操作.

如果 agent 处理的时超时, 调度器应直接认为 agent 已经失败. 而对应的 Agent 应该什么也不返回, 并直接尝试对这次执行任务进行回滚, 因为有可能调度器会安排另一个 Agent 再次执行这个任务. 监督者通过检查每个任务的执行时间(或者剩余可用时间)来判断是否有任务超时. 如果超时, 则发信号给对应任务的调度器, 让其进行重试, 不过需要注意不应该无限重试, 若干次重试后还失败就应该进行下一步处理了.
如果重试几次后还没有成功, 则应该使用类似指数退避的方式隔一段时间再重试, 如最终超过一个最大限制. 可以采取整个事务的撤销操作. 撤销同样由监督者来发起, 撤销逻辑由调度器实现.

该模式的最大优点在于系统的恢复性. 它可以从意外的错误中恢复, 记录任务的执行过程, 在故障排除后继续执行. 整个系统具有自愈能力.

该系统实现起来较为繁琐, 对每个环节的可能的故障情况都需要充分测试.

监督者的执行频率需要设置合适.

Sharding Pattern

对于分布式的应用, 使用 vertical 的扩展方式, 会使硬件或网络资源很快到达不可在提升的临界点. 或者再进行单台硬件的提升成本将变得非常的高. 使用 horizontal 扩展方式, 或者说是 shard 分片的方式, 是解决该问题的一个好方法.

将数据分成若干分片, 每一部分都是完整数据的一个子集. 分片的关键在于如何决定数据应该存放在哪个分片上, 这通常和数据的某个属性或字段有关系, 也被叫做 shard key 或者 partition key. 这个 key 必须是静态数据, 不能在数据落地后再有修改操作. 否则会导致无法正确访问到这个数据.

数据存取时, 需要根据分片逻辑决定去哪个分片上存取数据. 分片逻辑可以由应用代码实现, 也可以由数据库系统来实现.

为了确保更好的性能和扩展性, 分片逻辑要更具实际业务中对数据的查询方式来设计.

为了提高分片的灵活性, 便于以后对于分片进行扩展和修改, 可以在控制逻辑中再插入一层虚拟分片(分区)表. 虚拟表对外提供少量的 Shard 集合, 将其映射到自己的虚拟分片后, 再由分片逻辑将每个虚拟分片上的数据做二次分片到若干物理分片.

三种比较通用的分片策略:

  1. 查询策略: 根据业务的查询需求来指定 shard 策略选择 shard key, 比如用户ID. 可用于进行查询的优化. 常常配合虚拟分片一起使用来解决业务平衡性的问题.
  2. 范围策略: key 是有序的, 按照 key 的范围不同分成不同的 shard. 数据自然有序, 适用于需要按照 key 的范围分区进行查询的情况.
  3. 哈希策略: 对数据的一个或多个属性进行 Hash, 并作为 shard key. 可以实现最大程度的 shard 平衡, 保证各个 shard 上的负载尽量平均, 防止数据访问出现单 shard 的热点.

这里需要注意一下: 我们常说的 shard 分片常指第三种策略. 甚至有时误以为前两种甚至不算是 shard.

不同的 shard 策略也暗示着它们在缩容扩容, 数据迁移和维护时的不同能力和特点:

  1. 查询策略: 数据的维护需要在用户层面实施. 挂起一部分或者是所有用户的访问行为, 通常会选择非高峰时段, 然后对这部分数据进行维护操作如迁移到别的机器, 修改虚拟分区下的物理分区并移动数据等. 完成操作后刷新对应的缓存.然后系统恢复正常服务.
  2. 范围策略: 可维护性比较查, 多数情况下无法通过拆分合并解决数据访问不平衡的问题. 一旦维护数据, 需要较大量数据离线.
  3. 哈希策略: 数据维护操作较为复杂, 每次修改 Hash 函数需要对数据进行重新 Hash 并放入对应的分片中.

Shard 只是分区方式中的一种, 其他的还有 vertical 分区, functional 分区等. 比如在单个 shard 上采用 vertical 分区, 而不同 shard 之间采用 functional 分区.

shard 的平衡性是一个重点, 可能即需要考虑存储空间的平衡, 还要考虑 IO 负载的平衡. 有时对数据做周期性的 rebalance 是有必要的. 周期长短要把握好, 因为 rebalance 的代价是比较大的. 注意 rebalance 的时候需要让各个 shard 保留一定的数据操作空间. 设计好灵活的 rebalance 策略并开发一些快捷易用的脚本来执行 rebalance 操作也是不错的选择.

需要注意某些系统中, 不同的 shard 上产生的全局 increment ID 有可能会出现重复. 如果用这种 ID 来生成数据的主键, 可能会导致两个不同的 shard 中存在主键相同的数据.

通常情况下, 大量小 shard 的方案要优于少量大 shard 的方案. 前者提供了更高的 Load balancing 能力, 和可维护性(小 shard 的移动更快速).

尽量对各个 shard 中的数据, 以及一次业务操作中设计的数据进行解耦. 以避免一次业务操作引用或修改的数据存放在不同的 shard 上, 以减少业务操作带来的潜在数据不一致性. 实践中很难完全避免数据不一致的可能性, 一种方式是, 找出业务中对数据一致性确实有强依赖性的部分进行特别处理; 另一种方式是实现最终一致性, 这样各个 shard 的操作可以独立进行, 由业务逻辑来确保一次业务的各部分全部完成.

shard 的地理分布方式可以提高各个区域的数据访问性能, 但是同样会带来一些额外的问题. 比如需要同时访问分布于各个不同位置的 shard 时的额外开销.

Static Content Hosting Pattern

非常常见且易于理解的模式. 动态资源和静态文件分离. 降低应用主体部分的计算开销. 提高静态文件的响应速度, 同时还可以降低硬件开销.

对于规模更大的系统, 采用 CDN 来发布静态资源可以取得更好的效果.

对于某些特殊的资源, 比如脚本或UI组件. 采用合适的版本管理方式, 可以大幅简化资源的更新和其他管理操作, 尤其是当更新代码或服务的时候.

如果静态存储部分使用了不一样的域名, 需要考虑到从脚本中动态加载这些资源时遇到的跨域问题.

通常静态资源对用户来说都是只读的. 记得检查访问权限.

对于太碎, 访问量又大的文件, 就不要硬拆出来做静态化了, 过高的连接开销反而导致性能下降.

Throttling Pattern

服务的负载在不同时间段有很大差异. 并且可能出现突发的大量访问. 有条件最好对服务进行 auto scaling 配置, 但即使这样, 也会有一个时间过程.

Valet Key Pattern

Asynchronous Messaging Primer

Autoscaling Guidance

Caching Guidance

Compute Partitioning Guidance

Data Consistency Primer

Data Partitioning Guidance

Data Replication and Synchronization Guidance

Instrumentation and Telemetry Guidance

Multiple Datacenter Deployment Guidance

Service Metering Guidance