系统设计-分布式服务篇
系统架构:每秒1万次请求的系统要做服务化拆分吗?
什么时候采用微服务拆分:但是因为你的系统是按照一体化架构部署的,在部署结构上没有分层,应用服务器直接连接数据库,那么当前端请求量增加,部署的应用服务器扩容,数据库的连接数也会大增。数据库连接最多可以设置16384。
其实可以把与用户相关的逻辑部署成一个单独的服务,其它无论是用户池、内容池还是互动池都连接这个服务来获取和更改用户信息,也就是说只有这个服务可以连接用户库,其它的业务池都不直连用户库获取数据。由于这个服务只处理和用户相关的逻辑,所以不需要部署太多的实例就可以承担流量,这样就可以有效地控制用户库的连接数,提升了系统的可扩展性。那么如此一来,我们也可以将内容和互动相关的逻辑都独立出来,形成内容服务和互动服务,这样我们就通过按照业务做横向拆分的方式解决了数据库层面的扩展性问题。
再比如,我们在做社区业务的时候,会有多个模块需要使用地理位置服务,将 IP 信息或者经纬度信息转换为城市信息。比如推荐内容的时候,可以结合用户的城市信息做附近内容的推荐;展示内容信息的时候也需要展示城市信息等等。
微服务架构:微服务化后系统架构要如何改造?
服务拆分时要遵循哪些原则?
服务的边界如何确定?服务的粒度是怎样的?
在服务化之后会遇到哪些问题呢?我们又将如何来解决?
原则一,做到单一服务内部功能的高内聚和低耦合。也就是说每个服务只完成自己职责之内的任务,对于不是自己职责的功能交给其它服务来完成。
原则二,你需要关注服务拆分的粒度,先粗略拆分再逐渐细化。拆分初期可以把服务粒度拆得粗一些,后面随着团队对于业务和微服务理解的加深,再考虑把服务粒度细化。比如对于一个社区系统来说,你可以先把和用户关系相关的业务逻辑,都拆分到用户关系服务中,之后,再把比如黑名单的逻辑独立成黑名单服务。
原则三,拆分的过程,要尽量避免影响产品的日常功能迭代。1. 优先剥离比较独立的边界服务(比如短信服务、地理位置服务),从非核心的服务出发减少拆分对现有业务的影响,也给团队一个练习、试错的机会;2. 当两个服务存在依赖关系时优先拆分被依赖的服务。
原则四,服务接口的定义要具备可扩展性。服务拆分之后,由于服务是以独立进程的方式部署,所以服务之间通信就不再是进程内部的方法调用而是跨进程的网络通信了。所以服务接口的参数类型最好是封装类,这样如果增加参数就不必变更接口的签名,而只需要在类中添加字段就可以了。
一些常用微服务中间件的原理和使用方式:
快速完成中间件的部署运行,建立对它感性的认识;阅读它的文档中基本原理和架构设计部分;必要时阅读它的源码,加深对它的理解,这样可以帮助你在维护你的微服务时排查中间件引起的故障和解决性能问题。
RPC框架:10万QPS下如何实现毫秒级的服务调用?
说到 RPC(Remote Procedure Call,远程过程调用),你不会陌生,它指的是通过网络调用另一台计算机上部署服务的技术。如果你服务拆分得更细粒度,那么多出的网络调用就会越多,请求的延迟就会更长,而这就是你为了提升系统的扩展性在性能上所付出的代价。
一次 RPC 的调用都经过了哪些步骤:
在一次 RPC 调用过程中,客户端首先会将调用的类名、方法名、参数名、参数值等信息,序列化成二进制流;然后客户端将二进制流通过网络发送给服务端;服务端接收到二进制流之后将它反序列化,得到需要调用的类名、方法名、参数名和参数值,再通过动态代理的方式调用对应的方法得到返回值;服务端将返回值序列化,再通过网络发送给客户端;客户端对结果反序列化之后,就可以得到调用的结果了。
从这张图中你可以看到网络传输的过程,将请求序列化和反序列化的过程, 所以如果要提升 RPC 框架的性能,需要从网络传输和序列化两方面来优化。
如何提升网络传输性能
在网络传输优化中,你首先要做的是选择一种高性能的 I/O 模型。所谓 I/O 模型,就是我们处理 I/O 的方式。而一般单次 I/O 请求会分为两个阶段,每个阶段对于 I/O 的处理方式是不同的。
同步阻塞 I/O;同步非阻塞 I/O;同步多路 I/O 复用;信号驱动 I/O;异步 I/O。
这五种 I/O 模型中最被广泛使用的是多路 I/O 复用,Linux 系统中的 select、epoll 等系统调用都是支持多路 I/O 复用模型的,Java 中的高性能网络框架 Netty 默认也是使用这种模型。你可以选择它。
Nagles算法:如果是连续的小数据包,大小没有一个 MSS(Maximum SegmentSize,最大分段大小),并且还没有收到之前发送的数据包的 Ack 信息,那么这些小数据包就会在发送端暂存起来,直到小数据包累积到一个 MSS,或者收到一个 Ack 为止。
TCP默认是开启的,意味着如果仅仅传输 1 字节的数据,也会等待超时时间 40ms才会发送ACK。
解决的方式非常简单:只要在 Socket 上开启 tcp_nodelay 就好了,这个参数关闭了 Nagle`s 算法,这样发送端就不需要等到上一个发送包的 ACK 返回直接发送新的数据包就好了。这对于强网络交互的场景来说非常的适用,基本上,如果你要自己实现一套网络框架,tcp_nodelay 这个参数最好是要开启的。
选择合适的序列化方式
如果对于性能要求不高,在传输数据占用带宽不大的场景下可以使用 JSON 作为序列化协议;
如果对于性能要求比较高,那么使用 Thrift 或者 Protobuf 都可以。而 Thrift 提供了配套的 RPC 框架,所以想要一体化的解决方案,你可以优先考虑 Thrift;
在一些存储的场景下,比如说你的缓存中存储的数据占用空间较大,那么你可以考虑使用 Protobuf 替换 JSON 作为存储数据的序列化方式。
注册中心:分布式系统如何寻址?
目前业界有很多可供你来选择的注册中心组件,比如说老派的 ZooKeeper、Kubernetes 使用的 ETCD、阿里的微服务注册中心 Nacos、Spring Cloud 的 Eureka 等等。
这些注册中心的基本功能有两点:
其一是提供了服务地址的存储;
其二是当存储内容发生变化时,可以将变更的内容推送给客户端。
服务状态管理如何来做
1.主动探测 2.心跳
除此之外,在实际项目中,我们还发现注册中心另一个重要的问题就是“通知风暴”。你想一想,变更一个服务的一个节点,会产生多少条推送消息?假如你的服务有 100 个调用者,有 100 个节点,那么变更一个节点会推送 100 * 100 = 10000 个节点的数据。那么如果多个服务集群同时上线或者发生波动时,注册中心推送的消息就会更多,会严重占用机器的带宽资源,这就是我所说的“通知风暴”。那么怎么解决这个问题呢?你可以从以下几个方面来思考:
首先,要控制一组注册中心管理的服务集群的规模,具体限制多少没有统一的标准,你需要结合你的业务以及注册中心的选型来考虑,主要考察的指标就是注册中心服务器的峰值带宽;
其次,你也可以通过扩容注册中心节点的方式来解决;
再次,你可以规范一下对于注册中心的使用方式,如果只是变更某一个节点,那么只需要通知这个节点的变更信息即可;
最后,如果是自建的注册中心,你也可以在其中加入一些保护策略,比如说如果通知的消息量达到某一个阈值就停止变更通知。
街道新增了一条道路,通知给各个车辆,注册中心的注册和发现
监控每个道路的车辆运行情况 服务的监控治理
平衡每个道路的车辆数 需要交警的协调 服务的负载均衡
道路出现拥堵或者维修 服务的熔断引流
调查道路拥堵的原因 分布式的追踪
分布式Trace:横跨几十个分布式组件的慢请求要如何排查?
有requestId,可以根据requestId跟踪日志信息。但是如果所有接口都打印耗时,一次请求可能要打印十几条日志,如果你的电商系统的 QPS 是 10000 的话,就是每秒钟会产生十几万条日志,对于磁盘 I/O 的负载是巨大的,那么这时,你就要考虑如何减少日志的数量。
你可以考虑对请求做采样,采样的方式也简单,比如你想采样 10% 的日志,那么你可以只打印“requestId%10==0”的请求。
把日志不打印到本地文件中,而是发送到消息队列里,再由消息处理程序写入到集中存储中,比如 Elasticsearch。这样,你在排查问题的时候,只需要拿着 requestId 到 Elasticsearch 中查找相关的记录就好了。在加入消息队列和 Elasticsearch 之后,我们这个排查程序的架构图也会有所改变:
如何来做分布式 Trace
你的请求从用户端过来,先到达 A 服务,A 服务会分别调用 B 和 C 服务,B 服务又会调用 D 和 E 服务。单次请求可能跨越多个 RPC 服务,这就造成了单次的请求的日志会分布在多个服务器上。用户到 A 服务之后会初始化一个 traceId 为 100,spanId 为 1;A 服务调用 B 服务时,traceId 不变,而 spanId 用 1.1 标识代表上一级的 spanId 是 1,这一级的调用次序是 1;A 调用 C 服务时,traceId 依然不变,spanId 则变为了 1.2,代表上一级的 spanId 还是 1,而调用次序则变成了 2,以此类推。可以使用全局开关,方便在线上随时将日志打印关闭。
负载均衡:怎样提升系统的横向扩展能力?
负载均衡的含义是:将负载(访问的请求)“均衡”地分配到多个处理节点上。这样可以减少单个处理节点的请求量,提升整体系统的性能。
负载均衡服务大体上可以分为两大类:一类是代理类的负载均衡服务;另一类是客户端负载均衡服务。
代理类的负载均衡服务以单独的服务方式部署,所有的请求都要先经过负载均衡服务,在负载均衡服务中选出一个合适的服务节点后,再由负载均衡服务调用这个服务节点来实现流量的分发。LVS 在 OSI 网络模型中的第四层,传输层工作,所以 LVS 又可以称为四层负载;而 Nginx 运行在 OSI 网络模型中的第七层,应用层,所以又可以称它为七层负载。LVS 是在网络栈的四层做请求包的转发,请求包转发之后,由客户端和后端服务直接建立连接,后续的响应包不会再经过 LVS 服务器,所以相比 Nginx 性能会更高,也能够承担更高的并发。
不过这两个负载均衡服务适用于普通的 Web 服务,对于微服务架构来说,它们是不合适的。因为微服务架构中的服务节点存储在注册中心里,使用 LVS 就很难和注册中心交互获取全量的服务节点列表。另外,一般微服务架构中,使用的是 RPC 协议而不是 HTTP 协议,所以 Nginx 也不能满足要求。
所以,我们会使用另一类的负载均衡服务,客户端负载均衡服务,也就是把负载均衡的服务内嵌在 RPC 客户端中。
负载均衡的策略可以优先选择动态策略,保证请求发送到性能最优的节点上;如果没有合适的动态策略,那么可以选择轮询的策略,让请求平均分配到所有的服务节点上。
Nginx 可以引入 nginx_upstream_check_module,对后端服务做定期的存活检测,后端的服务节点在重启时,也要秉承着“先切流量后重启”的原则,尽量减少节点重启对于整体系统的影响。
API网关:系统的门面要如何做呢?
API 网关(API Gateway)不是一个开源组件,而是一种架构模式,它是将一些服务共有的功能整合在一起,独立部署为单独的一层,用来解决一些服务治理的问题。你可以把它看作系统的边界,它可以对出入系统的流量做统一的管控。
在我看来,API 网关可以分为两类:一类叫做入口网关,一类叫做出口网关。
入口网关是我们经常使用的网关种类,它部署在负载均衡服务器和应用服务器之间,主要有几方面的作用。
- 它提供客户端一个统一的接入地址,API 网关可以将用户的请求动态路由到不同的业务服务上,并且做一些必要的协议转换工作。在你的系统中,你部署的微服务对外暴露的协议可能不同:有些提供的是 HTTP 服务;有些已经完成 RPC 改造,对外暴露 RPC 服务;有些遗留系统可能还暴露的是 Web Service 服务。API 网关可以对客户端屏蔽这些服务的部署地址以及协议的细节,给客户端的调用带来很大的便捷。
- 另一方面,在 API 网关中,我们可以植入一些服务治理的策略,比如服务的熔断、降级、流量控制和分流等等。
- 再有,客户端的认证和授权的实现,也可以放在 API 网关中。你要知道,不同类型的客户端使用的认证方式是不同的。在我之前项目中,手机 APP 使用 Oauth 协议认证,HTML5 端和 Web 端使用 Cookie 认证,内部服务使用自研的 Token 认证方式。这些认证方式在 API 网关上可以得到统一处理,应用服务不需要了解认证的细节。
- 另外,API 网关还可以做一些与黑白名单相关的事情,比如针对设备 ID、用户 IP、用户 ID 等维度的黑白名单。
- 最后,在 API 网关中也可以做一些日志记录的事情,比如记录 HTTP 请求的访问日志,我在讲述分布式追踪系统时,提到的标记一次请求的 requestId 也可以在网关中来生成。
出口网关就没有这么丰富的功能和作用了。我们在系统开发中,会依赖很多外部的第三方系统,典型的例子:第三方账户登录、使用第三方工具支付等等。我们可以在应用服务器和第三方系统之间,部署出口网关,在出口网关中,对调用外部的 API 做统一的认证、授权、审计以及访问控制。
API 网关的设计要注意扩展性,也就是你可以随时在网关的执行链路上增加一些逻辑,也可以随时下掉一些逻辑(也就是所谓的热插拔)。引入责任链模式。
为了提升网关对于请求的并行处理能力,我们一般会使用线程池来并行的执行请求。所以基础服务不能被业务影响,根据不同服务拆分不同的线程池或者每个服务限制最大线程数。
API 网关可以替代原本系统中的 Web 层,将 Web 层中的协议转换、认证、限流等功能挪入到 API 网关中,将服务聚合的逻辑下沉到服务层。
多机房部署:跨地域的分布式系统如何做?
1. 北京同地双机房之间的专线延迟一般在 1ms~3ms。
我们的接口响应时间需要控制在 200ms 之内,而一个接口可能会调用几次第三方 HTTP 服务或者 RPC 服务。如果这些服务部署在异地机房,那么接口响应时间就会增加几毫秒,是可以接受的。
2. 国内异地双机房之间的专线延迟会在 50ms 之内。
在这个延迟数据下,要想保证接口的响应时间在 200ms 之内,就要尽量减少跨机房的服务调用,更要避免跨机房的数据库和缓存操作了。
3. 如果你的业务是国际化的服务,需要部署跨国的双机房,那么机房之间的延迟就更高了,依据各大云厂商的数据来看,比如,从国内想要访问部署在美国西海岸的服务,这个延迟会在 100ms~200ms 左右。在这个延迟下,就要避免数据跨机房同步调用,而只做异步的数据同步。
1. 同城双活
首先,数据库的主库可以部署在一个机房中,比如部署在 A 机房中,那么 A 和 B 机房数据都会被写入到 A 机房中。然后,在 A、B 两个机房中各部署一个从库,通过主从复制的方式,从主库中同步数据,这样双机房的查询请求可以查询本机房的从库。一旦 A 机房发生故障,可以通过主从切换的方式将 B 机房的从库提升为主库,达到容灾的目的。
缓存也可以部署在两个机房中,查询请求也读取本机房的缓存,如果缓存中数据不存在,就穿透到本机房的从库中加载数据。数据的更新可以更新双机房中的数据,保证数据的一致性。
不同机房的 RPC 服务会向注册中心注册不同的服务组,而不同机房的 RPC 客户端,也就是 Web 服务,只订阅同机房的 RPC 服务组,这样就可以实现 RPC 调用尽量发生在本机房内,避免跨机房的 RPC 调用。
2. 异地多活
在数据写入时,你要保证只写本机房的数据存储服务再采取数据同步的方案,将数据同步到异地机房中。一般来说,数据同步的方案有两种:
一种基于存储系统的主从复制,比如 MySQL 和 Redis。也就是在一个机房部署主库,在异地机房部署从库,两者同步主从复制实现数据的同步。
另一种是基于消息队列的方式。一个机房产生写入请求后,会写一条消息到消息队列,另一个机房的应用消费这条消息后再执行业务处理逻辑,写入到存储服务中。
论是采取哪种方案,数据从一个机房传输到另一个机房都会有延迟,所以,你需要尽量保证用户在读取自己的数据时,读取数据主库所在的机房。为了达到这一点,你需要对用户做分片,让一个用户每次的读写都尽量在同一个机房中。同时,在数据读取和服务调用时,也要尽量调用本机房的服务。
Service Mesh:如何屏蔽服务化系统的服务治理细节?
- 用 RPC 框架解决服务通信的问题;
- 用注册中心解决服务注册和发现的问题;
- 使用分布式 Trace 中间件,排查跨服务调用慢请求;
- 使用负载均衡服务器,解决服务扩展性的问题;
- 在 API 网关中植入服务熔断、降级和流控等服务治理的策略。
如何屏蔽服务化架构中服务治理的细节,或者说,如何让服务治理的策略在多语言之间复用呢?
可以考虑将服务治理的细节,从 RPC 客户端中拆分出来,形成一个代理层单独部署。这个代理层可以使用单一的语言实现,所有的流量都经过代理层来使用其中的服务治理策略。这是一种“关注点分离”的实现方式,也是 Service Mesh 的核心思想。
在这种形式下,RPC 客户端将数据包先发送给与自身同主机部署的 Sidecar,在 Sidecar 中经过服务发现、负载均衡、服务路由、流量控制之后,再将数据发往指定服务节点的 Sidecar,在服务节点的 Sidecar 中,经过记录访问日志、记录分布式追踪日志、限流之后,再将数据发送给 RPC 服务端。
Iptables 方式的优势在于对业务完全透明,业务甚至不知道有 Sidecar 存在,这样会减少业务接入的时间。不过它也有缺陷,那就是它是在高并发下,性能上会有损耗,因此国内大厂采用了另外一种方式:轻量级客户端。
请求被发送到服务端的 Sidecar 上后,然后在服务端记录访问日志和分布式追踪日志,再把请求转发到真正的服务节点上。当然,服务节点在启动时,会委托服务端 Sidecar 向注册中心注册节点,Sidecar 也就知道了真正服务节点部署的端口是多少。