深圳幻海软件技术有限公司 欢迎您!

这么牛的毕业生,来当CTO吧!

2023-02-28

时光如风飘渺,眨眼间已经在行业浸润多年了,见过无数厉害的人物,也见过更多更多的挫B。前几天刚上班,就接到面试一个毕业生的任务,让我感叹人与人之间的差距。他的水平,绝对的完爆工作多年的架构师。在下佩服之~我们的话题,是关于怎么构建一个可伸缩的高可用、高可靠大型网站。嗯,就让我们开始吧。1.要发问了大家

时光如风飘渺,眨眼间已经在行业浸润多年了,见过无数厉害的人物,也见过更多更多的挫B。

前几天刚上班,就接到面试一个毕业生的任务,让我感叹人与人之间的差距。

他的水平,绝对的完爆工作多年的架构师。在下佩服之~

我们的话题,是关于怎么构建一个可伸缩的高可用、高可靠大型网站。嗯,就让我们开始吧。

1. 要发问了

大家都知道,如今的互联网,数据量爆炸,服务请求飙升,即使是非常小的公司,也可能因为某个产品产生不同于往日的数十倍流量,当然这有时候是个梦想而已。

流量增加就意味着对后端服务能力的增加,如何构建每秒处理GB数据、QPS超过数十万的大型系统,就变成了一个挑战。尤其是某些天才的秒杀创意,让这个流量变的越发变态不可预料。

QQrhtNw4">在有效的资源下,如何让系统保持良好的反馈?以支撑老板们的梦想呢?你有什么处理方式?或者你有什么体系化的心得体会要和我分享一下的?

毕业生微微一笑:“我在这方面正好有点总结,我可以多花点时间聊聊这个”。

好吧,洗耳恭听。

2. 服务建设的重要指标

我首先要说明的是,服务的建设要关注几个指标。有了目标就有了方向,大体上我总结了四个”:

  • 可用性。 我们要保证服务的可用性,也就是SLA指标。只有服务能够正常响应,错误率保持在较低的水平,我们的服务才算正常。
  • 服务性能。 可用性和性能是相辅相成的,服务性能高了,有限的资源就能够支撑更多的请求,可用性就会提高。所以服务性能优化是一个持续性的工作,在巨量流量下每1ms的平均性能提升,都是值得追求的。
  • 可靠性。 分布式服务的组件非常多,每个组件都可能会产生问题,影响面也不尽相同。如何保证每个组件的运行时高可靠性,如何保证数据的一致性,都是有挑战的。
  • 可观测性。 想要获取服务优化的指标数据,就要求我们的服务,在设计开始能够保证服务的可观测性。宏观上能够识别组件类故障,微观上能为性能优化提供依据。在HPA等自动化伸缩场景中,遥测数据甚至是自动化决策的唯一依据。

对于一个服务来说,扩容的手段主要有两种:

  • scale-up:垂直扩展;
  • scale-out:水平扩展。

垂直扩展通过增加单台机器的配置来增加单节点的处理能力。这在某些业务场景下是非常有必要的。但我们的服务更多的是追求水平扩展,用更多的机器来支撑业务的发展。

只要服务满足了横向扩展的能力,满足无状态的特点,剩下的事情就是堆硬件了。听起来很美好,但实际上,对整个体系架构的挑战非常的大。

毕业生的一番分析像极了野鸡CTO的发言,废话连篇。我暗自点头,鼓励他继续深入、细化下去,拿出点不一样的东西。

3. 幂等性

如果接口调用失败怎么办?在早期的互联网中,因为网络原因,这样的情况可能更严重。HTTP状态码504,就是典型的代表网关超时的状态。第一次请求可能会超时失败,第二次可能就成功了。现实中需要严格重试的接口还是蛮多的,尤其是异步化的加入,使得重试变得更加重要。

但我们也要考虑由于快速重试所造成的重试风暴,因为超时本身可能就意味着服务器已经不堪重负,我们没有任何理由火上浇油。所以,重试都会有退避算法(exponential backoff),直到真正的结束请求进入异常处理流程。

可以看出,由于超时和重试机制的引入,服务的幂等变的格外重要。它不仅仅是在单台机器上支持重复的调用,在整个分布式集群环境中同样保证可以重入多次。

在数学上,它甚至有一个优美的函数公式:

f(f(f(x))) = f(f(x)) = f(x)

一旦接口拥有了幂等性,就有了能够忍受故障的能力。当我们因为偶发的网络故障、机器故障造成少量的服务调用失败时,可以通过重试和幂等很容易的最终完成调用。

对于查询操作来说,在数据集合不变的情况下,它天然是幂等的,不需要做什么额外的处理。比较有挑战的是添加和更新操作。

有不少的技术手段来保证幂等,比如使用数据库的唯一索引,使用提前生成好的交易ID,或者使用token机制来保证唯一调用。其中,token机制被越来越多的使用,其做法是在请求之前,先请求一个唯一的tokenId,此后的调用幂等就围绕着tokenId进行编程。

4. 健康检查

自从k8s把健康检查这个东西标准化之后,健康检查就成为了一个服务的必备选项。在k8s中,分为活跃探针(liveness probe)和 就绪探针(readiness probe)。

活跃探测主要用来查明应用程序是否处于活动状态。它只展示应用本身的状态,而不应依赖于外部其他系统的健康状态;就绪探测指示应用程序是否已准备好接受流量,如果应用程序实例的就绪状态为未就绪,则不会将流量路由到该实例。

如果你使用了SpringBoot的actuator组件,通过health接口,将很容易获取这部分功能。当容器或者注册中心通过health接口判断到服务出现了问题,会自动的把问题节点从节点列表中摘除,然后再通过一系列探测机制在服务恢复正常的时候再把它挂上去。

通过健康检查机制,能够避免流量被调度到错误的机器上去。

5. 服务自动发现

早期的软件开发人员,对服务上线的机制摸的门清,不是因为他们想要这样,而是不得不这样做。

比如,我要扩容一台机器,需要首先测试这台机器的存活性,然后部署服务,最后再在负载均衡软件比如nginx中将这台机器配置上。通常情况下,还要看一下日志,到底有没有流量到这台机器上来。

借助于微服务和持续集成,我们再也不需要这么繁杂的上线流程,只需要在页面上点一下构架、发布,服务就能够自动上线,并被其他服务发现。

注册中心在服务发现方面承担了非常重要的角色。它相当于一个信息集中地,所有的服务启动、关闭,都要上报到这里;同样,我想要调用某些服务,也需要到同一个注册中心去查询。

注册中心相当于一个中介,将这些频繁的上下线需求和查询需求,全部统一起来进行管理,现在已经成为微服务的必备设施。

这些查询需求可能是非常频繁的,所以在调用方本地,同样也会存储一份副本,这样在注册中心出现问题的时候,不至于因为大脑缺氧而造成大规模故障。有了副本就有了一致性问题,有注册中心通过Pull的方式更新信息,存在数据一致性的实效性。实效性处理的比较好的是有Push(通知)机制的组件,能够在较快的时间感知服务的变化。

许多组件可以充当服务注册中心,只要它有分布式存储数据的能力和数据一致性的能力。比如Eureka、Nacos、Zookeeper、Consul、Redis,甚至数据库,都能胜任这个角色。

6. 限流

web开发中,tomcat默认是200个线程池,当更多的请求到来,没有新的线程能够去处理这个请求,那这个请求将会一直等待在浏览器方。表现的形式是,浏览器一直在转圈(还没超过acceptCount),即使你请求的是一个简单的Hello world。

我们可以把这个过程,也看作是限流。它在本质上,是设置一个资源数量上限,超出这个上限的请求,将被缓冲,或者直接失败。

对于高并发场景下的限流来说,它有特殊的含义:它主要是用来保护底层资源的。如果你想要调用某些服务,你需要首先获取调用它的许可。限流一般由服务提供方来提供,对调用方能够做事的能力进行限制。

比如,某个服务为A、B、C都提供了服务,但根据提前申请的流量预估,限制A服务的请求为1000/秒、B服务2000/秒,C服务1w/秒。在同一时刻,某些客户端可能会出现被拒绝的请求,而某些客户端能够正常运行,限流被看作是服务端的自我保护能力。

常见的限流算法有:计数器、漏桶、令牌桶等。但计数器算法无法实现平滑的限流,在实际应用中使用较少。

7. 熔断

自从施耐德发明了断路器,这个熔断的概念席卷了全球。从A股熔断,到服务熔断,大有异曲同工之妙。

熔断的意思是:当电路闭合时,电流可以通过,当断路器打开时,电流停止。

通常情况下,用户的一个请求,需要后端多个服务配合才能完成工作。后端的这些服务,并不是每一个都是必须的,如果因为其中的某个服务有问题,就把用户的整个请求给拒绝掉,那是非常不合理的。

熔断期望某些服务,在发生问题时,返回一些默认值。整个请求依然可以正常进行下去。

比如风控。如果在某个时间风控服务不可用了,用户其实是应该能够正常交易的。这时候我们应该默认风控是通过的,然后把这些异常交易倒到另外一个地方,在风控恢复后再尽快赶在发货的之前处理。

从上面的描述可以看出,有的服务,熔断后简单的返回些默认数据就行,比如推荐服务;但有的服务就需要有对应的异常流程支持,算是一个if else;更要命的是,有些业务不支持熔断,那就只能Fail Fast。

一股脑的处理是没有思考的技术手段,不是我们所推荐的。

Hystrix、resilience4j、Sentinel等组件,是Java系广泛使用的工具。通过SpringBoot的集成,这些框架一般用起来都比较方便,可以达到配置化编程。

8. 降级

降级是一个比较模糊的说法。限流、熔断,在一定程度上,也可以看作是降级的一种。但通常所说的降级,切入的层次更加高级一些。

降级一般考虑的是分布式系统的整体性,从源头上切断流量的来源。比如在双11的时候,为了保证交易系统,将会暂停一些不重要的服务,以免产生资源争占。服务降级有人工参与,人为使得某些服务不可用,多属于一种业务降级方式。

在什么地方最适合做降级呢?就是入口。比如Nginx,比如DNS等。

在某些互联网应用中,会存在MVP(Minimum Viable Product)这个概念,意为最小化可行产品,它的SLA要求非常高。围绕着最小可行性产品,会有一系列的服务拆分操作,当然某些情况甚至需要重写。

比如,一个电商系统,在极端情况下,只需要把商品显示出来,把商品卖出去就行。其他一些支撑性的系统,比如评论、推荐等,都可以临时关掉。在物理部署和调用关系上,就要考虑这些情况。

9. 预热

请看下面一种情况。

一个高并发环境下的DB,进程死亡后进行重启。由于业务处在高峰期间,上游的负载均衡策略发生了重分配。刚刚启动的DB瞬间接受了1/3的流量,然后load疯狂飙升,直至再无响应。

原因就是:新启动的DB,各种Cache并没有准备完毕,系统状态与正常运行时截然不同。可能平常1/10的量,就能够把它带入死亡。

同理,一个刚刚启动的JVM进程,由于字节码并未被JIT编译器优化,在刚启动的时候,所有接口的响应时间都比较慢。如果调用它的负载均衡组件,并没有考虑这种刚启动的情况,1/n的流量被正常路由到这个节点,就很容易出现问题。

所以,我们希望负载均衡组件,能够依据JVM进程的启动时间,动态的慢慢加量,进行服务预热,直到达到正常流量水平。

10. 背压

考虑一下下面两种场景:

  • 没有限流。请求量过高,有多少收多少,极容易造成后端服务崩溃或者内存溢出
  • 传统限流。你强行规定了某个接口最大的承受能力,超出了直接拒绝,但此时后端服务是有能力处理这些请求的

如何动态的修改限流的值?这就需要一套机制。调用方需要知道被调用方的处理能力,也就是被调用方需要拥有反馈的能力。背压,英文Back Pressure,其实是一种智能化的限流,指的是一种策略。

背压思想,被请求方不会直接将请求端的流量直接丢掉,而是不断的反馈自己的处理能力。请求端根据这些反馈,实时的调整自己的发送频率。比较典型的场景,就是TCP/IP中使用滑动窗口来进行流量控制。

反应式编程(Reactive)是观察者模式的集大成者。它们大多使用事件驱动,多是非阻塞的弹性应用,基于数据流进行弹性传递。在这种场景下,背压实现就简单的多。

背压,让系统更稳定,利用率也更高,它本身拥有更高的弹性和智能。比如我们常见的HTTP 429状态码头,表示的意思就是请求过多,让客户端缓一缓,不要那么着急,算是一个智能的告知。

11. 隔离

即使在同一个instance中,同类型的资源,有时候也要做到隔离。一个比较浅显的比喻,就是泰坦尼克号,它有多个船舱。每个船舱都相互隔离,避免单个船舱进水造成整个船沉了。

当然,泰坦尼克号带着骚气的jack沉了,那是因为船舱破的太多的缘故。

在有些公司的软件中,报表查询服务、定时任务、普通的服务,都放在同一个tomcat中。它们使用同一套数据库连接池,当某些报表接口的请求一上升,其他正常的服务也无法使用。这就是混用资源所造成的后果。

除了遵循CQRS来把服务拆分,一个快速的机制就是把某类服务的使用资源隔离。比如,给报表分配一个单独的数据库连接池,分配一个单独的限流器,它将无法影响其他服务。

耦合除了出现在无状态服务节点,同时还会出现在存储节点。与其把报表服务的存储和正常业务的存储放在一个数据库,不如把它们拆开,分别提供服务。

一个和尚挑水喝,两个和尚也可以挑水喝。原因就是他们在两个庙。

12. 异步

如果你比较过BIO和NIO的区别,就可以看到,我们的服务其实大部分时间都是在等待返回,CPU根本就没有跑满。当然,NIO是底层的机制,避免了线程膨胀和频繁的上下文切换。

服务的异步化和NIO有点类似,采用之后可以避免无谓的等待。尤其是当调用路径冗长的时候,异步不会阻塞,响应也会变的迅速。

单机时候,我们会采用NIO;而在分布式环境中,我们会采用MQ。虽然它们是不同的技术,但道理都是相通的。

异步通常涉及到编程模型的改变。同步方式,请求会一直阻塞,直到有成功,或者失败结果的返回。虽然它的编程模型简单,但应对突发的、时间段倾斜的流量,问题就特别大,请求很容易失败。异步操作可以平滑的横向扩容,也可以把瞬时压力时间上后移。同步请求,就像拳头打在钢板上;异步请求,就像拳头打在海绵上。你可以想象一下这个过程,后者肯定是富有弹性,体验更加友好。

13. 缓存

缓存可能是软件中使用最多的优化技术了。比如,在最核心的CPU里,就存在着多级缓存;为了消除内存和存储之间的差异,各种类似Redis的缓存框架更是层出不穷。

缓存的优化效果是非常好的,可以让原本载入非常缓慢的页面,瞬间秒开;也能让本是压力山大的数据库,瞬间清闲下来。

缓存,本质上是为了协调两个速度差异非常大的组件,通过加入一个中间层,将常用的数据存放在相对高速的设备中。

在应用开发中,缓存分为本地缓存和分布式缓存。

那什么叫分布式缓存呢?它其实是一种集中管理的思想。如果我们的服务有多个节点,堆内缓存在每个节点上都会有一份;而分布式缓存,所有的节点,共用一份缓存,既节约了空间,又减少了管理成本。

在分布式缓存领域,使用最多的就是Redis。Redis支持非常丰富的数据类型,包括字符串(string)、列表(list)、集合(set)、有序集合(zset)、哈希表(hash)等常用的数据结构。当然,它也支持一些其他的比如位图(bitmap)一类的数据结构。

所以加下来的问题一定集中在缓存穿透、击穿和雪崩,以及一致性上,这个我就不多聊了。

14. Plan-B

一个成熟的系统都有B方案,除了异地多活和容灾等处置方案,Plan-B还以为着我们要为正常的服务提供异常的通道。

比如,专门运行一个最小可行性系统,运行公司的核心业务。在大面积故障的时候,将请求全面切换到这个最小系统上。

Plan-B通常都是全局性的,它保证了公司最基本的服务能力,我们期望它永远用不上。

15. 监控报警

问题之所以成为问题,是因为它留下了证据。没有证据的问题,你虽然看到了影响结果,但是你无法找到元凶。

而且问题通常都具有人性化,当它发现无法发现它的时候,它总会再次出现。就如同罪犯发现了漏洞,还会再次尝试利用它。

所以,要想处理线上问题,你需要留下问题发生的证据,这是重中之重。如果没有这些东西,你的公司,绝对会陷入无尽的扯皮之中。

日志是最常见的做法。通过在程序逻辑中进行打点,配合Logback等日志框架,可以快速定位到发生问题的代码行。我们需要看一下bug的详细发生过程,对可能发生问题的逻辑进行详细的日志记录,进行更加细致的日志输出,在发生问题的时候,就可以切换到debug进行调试。

如果是大范围的bug,那么强烈建议直接在线上进行调试。不太推荐使用Arthas等工具动态的修改字节码进行测试,当然也不推荐IDEA的远程调试。相反,推荐使用类似金丝雀发布的方式,导出非常小的一部分流量,构造一个新的版本进行测试。如果你没有金丝雀发布平台,类似Nginx的负载均衡工具也可以通过权重做到类似的事情。

日志系统与监控系统,对硬件的需求是比较大的,尤其是你的请求体和返回体比较大的情况下,对存储和计算资源的额要求更是高。它的硬件成本,在整个基础设施中,占比也是比较高的。但这些证据信息,对分析问题来说,是非常有必要的。所以即使比较贵,很多公司依然会有很大的投入在这上面,包括硬件投入和人力投入。

MTTD和MTTR是两个非常重要的指标,我们一定要加大关注。

16. 结尾

我看了一下表,这家伙很能说,预定的时间很快用完了。我挥挥手打住:”你还会哪些东西?简单的说一下吧!“

”也不是很多。像怎么构建一个DevOps团队支撑我们开发、测试、线上环境,如何进行更深入的性能优化,如何进行实际的故障排查。以及一些细节问题,比如怎么优化操作系统,网络编程和多线程,我这些还都没有聊。“

我说,”够了,你已经非常优秀了“。

”你把自己叫作毕业生,已经碾压绝大多数人了。你到底是哪里的毕业生啊!“

”我是B站的,昨天刚毕业~“,他腼腆的笑了。

我盯着他的眼睛,也笑了。枯木逢春犹再发,人可两度再少年!妙啊。