你知道微服务接口如何设计

1.前言

微服务是一种系统架构风格,是 SOA(面向服务架构)的一种实践。微服务架构通过业务拆分实现服务组件化,通过组件组合快速开发系统,业务单一的服务组件又可以独立部署,使得整个系统变得清晰灵活:

  • 原子服务

  • 独立进程

  • 隔离部署

  • 去中心化服务治理

一个大型复杂的软件应用,都可以拆分成多个微服务。各个微服务可被独立部署,各个微服务之间是松耦合的。现如今后台服务大部分以微服务的形式存在,每个微服务负责实现应用的一个功能模块。而微服务由一个个接口组成,每个接口实现某个功能模块下的子功能。

以一个 IM 应用为例,它的功能架构可能是下面这样的:

所以如果是后台开发的同学,经常需要实现一个后台微服务来提供相应的能力,完成业务功能。

服务以接口形式提供服务。在实现服务时,我们要将一个大的功能拆分成一个个独立的子功能来实现,每一个子功能就是我们要在服务中实现的一个接口。

有时一个服务会有很多接口,每个接口所要实现的功能可能会有关联,那么这就非常考验设计服务接口的功底,让服务变得简单可靠。

业界已经有很多比较成熟的实践原则,可以帮助我们设计实现出一个可靠易维护的服务。

微服务设计原则并没有严格的规范,下面结合业界成熟的方法和个人多年后台开发经验,介绍高可用,高性能,易维护,低风险服务常用的设计原则。

2.高可用

2.1 降级兜底

大部分服务是如下的结构,既要给使用方使用,又依赖于他人提供的第三方服务,中间又穿插了各种业务逻辑,这里每一块都可能是故障的来源。

如果第三方服务挂掉怎么办?我们业务也跟着挂掉?显然这不是我们希望看到的结果,如果能制定好降级兜底的方案,那将大大提高服务的可靠性。

比如我们做个性化推荐服务时,需要从用户中心获取用户的个性化数据,以便代入到模型里进行打分排序,但如果用户中心服务挂掉,我们获取不到数据了,那么就不推荐了?显然不行,我们可以在本地 cache 里放置一份热门商品以便兜底。

又比如做一个数据同步的服务,这个服务需要从第三方获取最新的数据并更新到 MySQL 中,恰好第三方提供了两种方式:

  • 一种是消息通知服务,只发送变更后的数据;

  • 一种是 HTTP 服务,需要我们自己主动调用获取数据。

我们一开始选择消息同步的方式,因为实时性更高,但是之后就遭遇到消息迟迟发送不过来的问题,而且也没什么异常,等我们发现一天时间已过去,问题已然升级为故障。合理的方式应该两个同步方案都使用,消息方式用于实时更新,HTTP 主动同步方式定时触发(比如 1 小时)用于兜底,即使消息出了问题,通过主动同步也能保证一小时一更新。

2.2 过载保护(保护自己)

如果是高并发场景使用的接口,那么需要做过载保护,防止服务过载引发雪崩。

相信很多做过高并发服务的同学都碰到类似事件:某天 A 君突然发现自己的接口请求量突然涨到之前的 10 倍,没多久该接口几乎不可使用,并引发连锁反应导致整个系统崩溃。

如何应对这种情况?生活给了我们答案:比如老式电闸都安装了保险丝,一旦有人使用超大功率的设备,保险丝就会烧断以保护各个电器不被强电流给烧坏。同理我们的接口也需要安装上“保险丝”,以防止非预期的请求对系统压力过大而引起的系统瘫痪,当流量过大时,可以采取拒绝或者引流等机制。

过载保护的做法:

  • 请求等待时间超时

比如把接收到的请求放在指定的队列中排队处理,如果请求等待时间超时了(假设是 100ms),这个时候直接拒绝超时请求;再比如队列满了之后,就清除队列中一定数量的排队请求,保护服务不过载,保障服务高可用。

  • 服务过载及早拒绝

根据服务当前指标(如 CPU、内存使用率、平均耗时等)判断服务是否处于过载,过载则及早拒绝请求并带上特殊错误码,告知上游下游已经过载,应做限流处理。

2.3 流量控制(保护下游)

流量控制,或者叫限流,一般用户保护下游不被大流量压垮。

常见的场景有:

(1)下游有严格的请求限制;比如银行转账接口,微信支付接口等都有严格的接口限频;

(2)调用的下游不是为高并发场景设计;比如提供异步计算结果拉取的服务,并不需要考虑各种复杂的高并发业务场景,提供高并发流量场景的支持。每个业务场景应该在拉取数据时缓存下来,而不是每次业务请求都过来拉取,将业务流量压垮下游。

(3)失败重试。调用下游失败了,一定要重试吗?如果不管三七二十一直接重试,这样是不对的,比如有些业务返回的异常表示业务逻辑出错,那么你怎么重试结果都是异常;又如有些异常是接口处理超时异常,这个时候就需要结合业务来判断了,有些时候重试往往会给后方服务造成更大压力,造成雪上加霜的效果。所有失败重试要有收敛策略,必要时才重试,做好限流处理。

控制流量,常用的限流算法有漏桶算法和令牌桶算法。必要的情况下,需要实现分布式限流。

2.4 快速失败

遵循快速失败原则,一定要设置超时时间。

某服务调用的一个第三方接口正常响应时间是 50ms,某天该第三方接口出现问题,大约有 15%的请求响应时间超过 2s,没过多久服务 load 飙高到 10 倍以上,响应时间也非常缓慢,即第三方服务将我们服务拖垮了。

为什么会被拖垮?没设置超时!我们采用的是同步调用方式,使用了一个线程池,该线程池里最大线程数设置了 50,如果所有线程都在忙,多余的请求就放置在队列里中。如果第三方接口响应时间都是 50ms 左右,那么线程都能很快处理完自己手中的活,并接着处理下一个请求,但是不幸的是如果有一定比例的第三方接口响应时间为 2s,那么最后这 50 个线程都将被拖住,队列将会堆积大量的请求,从而导致整体服务能力极大下降。

正确的做法是和第三方商量确定个较短的超时时间比如 200ms,这样即使他们服务出现问题也不会对我们服务产生很大影响。

2.5 无状态服务

尽可能地使微服务无状态。

无状态服务,可以横向扩展,从而不会成为性能瓶颈。

状态即数据。如果某一调用方的请求一定要落到某一后台节点,使用服务在本地缓存的数据(状态),那么这个服务就是有状态的服务。

我们以前在本地内存中建立的数据缓存、Session 缓存,到现在的微服务架构中就应该把这些数据迁移到分布式缓存中存储,让业务服务变成一个无状态的计算节点。迁移后,就可以做到按需动态伸缩,微服务应用在运行时动态增删节点,就不再需要考虑缓存数据如何同步的问题。

2.6 最少依赖

能不依赖的,尽可能不依赖,越少越好。

减少依赖,便可以减少故障发生的可能性,提高服务可靠性。

任何依赖都有可能发生故障,即使其如何保证,我们在设计上应尽可能地减少对第三方的依赖。如果无法避免,则需要对第三方依赖在发生故障时做好相应处理,避免因第三方依赖的抖动或不可用导致我们自身服务不可用,比如降级兜底。

2.7 简单可靠

可靠性只有靠不断追求最大程度的简化而得到。

乏味是一种美德。与生活中的其他东西不同,对于软件而言,“乏味”实际上是非常正面的态度。我们不想要自发性的和有趣的程序;我们希望这些程序按设计执行,可以预见性地完成目标。与侦探小说不同,缺少刺激、悬念和困惑是源代码的理想特征。

因为工程师也是人,他们经常对于自己编写的代码形成一种情感依附,这些冲突在大规模清理源代码的时候并不少见。一些人可能会提出抗议,“如果我们以后需要这个代码怎么办?”,“我们为什么不只是把这些代码注释掉,这样稍后再使用它的时候会更容易。”,“为什么不增加一个功能开关?”,这些都是糟糕的建议。源代码控制系统中的更改反转很容易,数百行的注释代码则会造成干扰和混乱;那些由于功能开关没有启用而没有被执行的代码,就像一个定时炸弹等待爆炸。极端地说,当你指望一个 Web 服务 7*24 可以用时,某种程度上,每一行新代码都是负担。

法国诗人 Antoine de Saint-Exupéry 曾写道:“不是在不能添加更多的时候,而是没有什么可以去掉的时候,才能达到完美”。这个原则同样适用于软件设计。API 设计是这个规则应该被遵循的一个清晰的例子。书写一个明确的、简单的 API 是接口可靠的保证。我们向 API 消费者提供的方法和参数越少,这些 API 就越容易理解。在软件工程上,少就是多!一个很小的,很简单的 API 通常也是一个对问题深刻理解的标志。

软件的简单性是可靠性的前提条件。当我们考虑如何简化一个给定的任务的每一步时,我们并不是在偷懒。相反,我们是在明确实际上要完成的任务是什么,以及如何容易地做到。我们对新功能说“不”的时候,不是在限制创新,而是在保持环境整洁,以免分心。这样我们可以持续关注创新,并且可以进行真正的工程工作。

2.8 分散原则

鸡蛋不要放一个篮子,分散风险。

比如一个模块的所有接口不应该放到同一个服务中,如果服务不可用,那么该模块的所有接口都不可用了。我们可以基于主次进行服务拆分,将重要接口放到一个服务中,次要接口放到另外一个服务中,避免相互影响。

再如所有交易数据都放在同一个库同一张表里面,万一这个库挂了,此时影响所有交易。我们可以对数据库水平切分,分库分表。

2.9 隔离原则

控制风险不扩散,不放大。

不同模块之间要相互隔离,避免单个模块有问题影响其他模块,传播扩散了影响范围。

比如部署隔离:每个模块的服务部署在不同物理机上;

再如 DB 隔离:每个模块单独使用自身的存储实例。

古代赤壁之战就是一个典型的反面例子,铁锁连船导致隔离性被破坏,一把大火烧了 80W 大军。

隔离是有级别的,隔离级别越高,风险传播扩散的难度就越大,容灾能力越强。

例如:一个应用集群由 N 台服务器组成,部署在同一台物理机上,或同一个机房的不同物理机上,或同一个城市的不同机房里,或不同城市里,不同的部署代表不同的容灾能力。

例如:人类由无数人组成,生活在同一个地球的不同洲上,这意味着人类不具备星球级别的隔离能力,当地球出现毁灭性影响时,人类是不具备容灾的。

2.10 幂等设计(可重入)

所谓幂等,简单地说,就是对接口的多次调用所产生的结果和调用一次是一致的。数据发生改变才需要做幂等,有些接口是天然保证幂等性的。

比如查询接口,有些对数据的修改是一个常量,并且无其他记录和操作,那也可以说是具有幂等性的。其他情况下,所有涉及对数据的修改、状态的变更就都有必要防止重复性操作的发生。实现接口的幂等性可防止重复操作所带来的影响。

重复请求很容易发生,比如用户误触,超时重试等。举个最简单的例子,那就是支付,用户购买商品后支付,支付扣款成功,但是返回结果时网络异常(超时成功),此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额返发现多扣钱了,流水记录也变成了两条,就没有保证接口的幂等性。

2.11 故障自愈

没有 100% 可靠的系统,故障不可避免,但要有自愈能力。

人体拥有强大的自愈能力,比如手指划破流血,会自动止血,结痂,再到皮肤再生。微服务应该像人体一样,当面对非毁灭性伤害(故障)时,在不借助外力的情况下,自行修复故障。比如消息处理或异步逻辑等非关键操作失败引发的数据不一致,需要有最终一致的修复操作,如兜底的定时任务,失败重试队列,或由用户在下次请求时触发修复逻辑。

2.12 CAP 定理

2000 年,加州大学伯克利分校的计算机科学家 Eric Brewer 在分布式计算原理研讨会(PODC)上提出了一个猜想,分布式系统有三个指标:

一致性(Consistency)
可用性(Availability)
分区容错性(Partition tolerance)

它们的第一个字母分别是 C、A、P。

Eric Brewer 说,这三个指标最多只能同时实现两点,不可能三者兼顾,这便是著名的布鲁尔猜想。

在随后的 2002 年,麻省理工学院(MIT)的 Seth Gilbert 和 Nancy Lynch 发表了布鲁尔猜想的证明,使之成为一个定理,即 CAP 定理。

CAP 定理告诉我们,如果服务是分布式服务,那么不同节点间通信必然存在失败可能性,即我们必须接受分区容错性(P),那么我们必须在一致性(C)和可用性(A)之间做出取舍,即要么 CP,要么 AP。

如果你的服务偏业务逻辑,对接用户,那么可用性显得更加重要,应该选择 AP,遵守 BASE 理论,这是大部分业务服务的选择。

如果你的服务偏系统控制,对接服务,那么一致性显得更加重要,应该选择 CP,遵守 ACID 理论,经典的比如 Zookeeper。

总体来说 BASE 理论面向的是大型高可用、可扩展的分布式系统。与传统 ACID 特性相反,不同于 ACID 的强一致性模型,BASE 提出通过牺牲强一致性来获得可用性,并允许数据段时间内的不一致,但是最终达到一致状态。同时,在实际分布式场景中,不同业务对数据的一致性要求不一样,因此在设计中,ACID 和 BASE 应做好权衡和选择。

2.13 BASE 理论

在 CAP 定理的背景下,大部分分布式系统都偏向业务逻辑,面向用户,那么可用性相对一致性显得更加重要。如何构建一个高可用的分布式系统,BASE 理论给出了答案。

2008 年,eBay 公司选则把资料库事务的 ACID 原则放宽,于计算机协会(Association for Computing Machinery,ACM)上发表了一篇文章Base: An Acid Alternative,正式提出了一套 BASE 原则。

BASE 基于 CAP 定理逐步演化而来,其来源于对大型分布式系统实践的总结,是对 CAP 中一致性和可用性权衡的结果,其核心思想是即使无法做到强一致性,但每个业务根据自身的特点,采用适当的方式来使系统达到最终一致性。BASE 可以看作是 CAP 定理的延伸。

BASE 理论指:

  • Basically Available(基本可用)

基本可用就是假设系统出现故障,要保证系统基本可用,而不是完全不能使用。比如采用降级兜底的策略,假设我们在做个性化推荐服务时,需要从用户中心获取用户的个性化数据,以便代入到模型里进行打分排序。但如果用户中心服务挂掉,我们获取不到数据了,那么就不推荐了?显然不行,我们可以在本地 cache 里放置一份热门商品以便兜底。

  • Soft state( 软状态)

软状态指的是允许系统中的数据存在中间状态,并认为该状态不影响系统的整体可用性,即允许系统在多个不同节点的数据副本存在数据延时。

  • Eventual consistency(最终一致性)

上面讲到的软状态不可能一直是软状态,必须有时间期限。在期限过后,应当保证所有副本保持数据一致性,从而达到数据的最终一致性,因此所有客户端对系统的数据访问最终都能够获取到最新的值,而这个时间期限取决于网络延时,系统负载,数据复制方案等因素。

3.高性能

3.1 无锁

3.1.1 锁的问题

高性能系统中使用锁,往往带来的坏处要大于好处。

并发编程中,锁带解决了安全问题,同时也带来了性能问题,因为锁让并发处理变成了串行操作,所以如无必要,尽量不要显式使用锁。

锁和并发,貌似有一种相克相生的关系。

为了避免严重的锁竞争导致性能的下降,有些场景采用了无锁化设计,特别是在底层框架上。无锁化主要有两种实现,无锁队列和无锁数据结构。

3.1.2 串行无锁

串行无锁最简单的实现方式可能就是单线程模型了,如 Redis/Nginx 都采用了这种方式。在网络编程模型中,常规的方式是主线程负责处理 I/O 事件,并将读到的数据压入队列,工作线程则从队列中取出数据进行处理,这种单 Reactor 多线程模型需要对队列进行加锁,这种模型叫单 Reactor 多线程模型。如下图所示:

上图的模式可以改成串行无锁的形式,当 MainReactor accept 一个新连接之后从众多的 SubReactor 选取一个进行注册,通过创建一个 Queue 与 I/O 线程进行绑定,此后该连接的读写都在同一个队列和线程中执行,无需进行队列的加锁。这种模型叫主从 Reactor 多线程模型。

3.1.3 无锁数据结构

利用硬件支持的原子操作可以实现无锁的数据结构,很多语言都提供 CAS 原子操作(如 Go 中的 atomic 包和 C++11 中的 atomic 库),可以用于实现无锁数据结构,如无锁链表。

我们以一个简单的线程安全单链表的插入操作来看下无锁编程和普通加锁的区别。

template<typename T>
struct Node {
    Node(const T &value) : data(value) {}
    T data;
    Node *next = nullptr;
};

有锁链表 WithLockList:

template<typename T>
class WithLockList {
    mutex mtx;
    Node<T> *head;
public:
    void pushFront(const T &value) {
        auto *node =
  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值