我们开始第三部分
就是gRPC和服务发现
我们前面介绍了微服务的一些概览还有设计
我们现在对微服务整个有了一个基本的认识
微服务
服务与服务之间通信的核心其实就是RPC框架
包括我们后面也会介绍异步通信
因为RPC偏同步通信
异步通信像消息队列
这节主要重点介绍它的同步通信方面的一些东西
我们在RPC选型的过程中
也经历了好多个阶段
我们最早是使用Go的net/rpc
包括中间有一段时间使用HTTP库
我们现在其实最终就是演进到了使用gRPC
那么gRPC以及我们内部服务发现的一些演进
都是非常重要的节点
因为随着我们服务规模化变大以后
包括多语言的一些情况
RPC的选型
还有它的一些理念
包括服务发现的一些选型和理念
其实都决定了我们未来更大规模的
一些服务化体系下架构的稳定性
我们再看看这几章我们重点会介绍什么东西
第一块就是gRPC
我在选型gRPC的时候
可以有一些它的设计理念在里面
因为最早我是看了Google的SRE这本书
然后看到他们对选型的一些看重的点
然后结合后来接触到gRPC以后
发现它整个的设计理念非常符合
包括SRE书里面提到的
包括我自己对RPC的一些理解
我觉得它是非常符合我胃口的
所以最终我们选型也是使用到了gRPC
并不是我们一开始就选到了
我们真的是经历了好多个阶段
最早的我们其实使用标准库的net/rpc
因为Go语言非常多
兄弟部门有些使用Java
有些使用C++
我当时了解到的情况是C++的团队
他们主要是使用Thrift
Thrift Service是Facebook开源的一个RPC框架
然后我了解到像Java那边的话
他们可能有使用过类似像Dubbo
还有像Motan这种RPC框架
但是都基本上是偏向它自己语言的
当时可能Dubbo还没有其他语言的一些SDK
像Thrift的话主要是C++那边在使用
我们其他有些部门可能也会使用
类似像HTTP RESTful的接口
导致我们各个部门之间的RPC通信变得异常困难
应该也是在16年的时候
我当时去推进了整个RPC的统一
后面我刚刚说的就是结合SRE的一些理念
和RPC的一些认识
我觉得gRPC它的一些设计理念是非常符合我们的
我们看一下它主要有一些什么样的特点
我们看一下gRPC可以用官网的一句话概述
就是说A high-performance
open-source universal RPC framework
就是一个高性能开源的
并且是统一的一个RPC的框架
我们看一下这个图里面其实
就是涉及到Ruby的Client
还有安卓Java的Client
它们都能够和C++的一个Service直接进行通信
它们的协议都是用Protocol Buffer来序列化的
你看这个地方有Proto Request
和Proto Response
这个是最简单的一个通讯模型
它主要包含哪些设计理念
第一就是多语言
因为它是语言中立的
支持多种语言
到目前为止的话
我们内部的像Go、Java
还有PHP、C++
基本都是统一到了gRPC的框架
甚至我们的安卓和iOS
我们也是推进了gRPC的使用
因为大家有没有遇到过一个问题
以前我们内网可能是使用自己私有的协议
公网的话都会把这种私有的RPC
再转换成HTTP RESTful的方式去提供给移动端
其实这种转换
或者是从协议的统一性来说其实都不太好
内外网各一套
你得做各种转化还得做一些适配
觉得这个不是特别方便的
从gRPC设计理念里面来说
既然它提到了多语言
所以像Catalina、Android或者是iOS、Thrift
这些基本上都是支持的
也是非常方便的
就是各种语言从后端来说
从前端甚至从移动端的角度
它都是支持的
甚至像JS
在Web上也是可以操作gRPC的
我觉得这个点其实是
给我一个非常震撼的理由
就是非常震撼的一个点去使用gRPC
第二块是什么
就是它的轻量级和高性能
整个序列化的话是支持Protocol Buffer和JSON
Protocol Buffer它是一种语言无关的
高性能的序列化的框架
PB的话可以说是对我们的影响非常巨大的
为什么这么说
因为PB首先它的这种Schema的定义
然后包括它的版本
它整套机制是非常成熟的
所以在使用PB的时候也是得心应手
跟gRPC也可以结合的非常好
后面我在很多地方都会介绍PB在内部的使用
还有就是可插拔
因为作为一个RPC框架
肯定是要支持内部各种各样的一些插件
比方说我们可能需要扩展它的负载均衡
可能扩展它的服务发现
可能使用gRPC框架的时候
能够写一些Interceptor
就是所谓的插件
这样的话都是很方便的
然后就是非常核心的一个Key Feature
就叫IDL
它是基于一个PB的文件来定义一个服务
最终你可以通过proto3的工具
生成任何指定语言的数据结构
服务端的接口以及客户端的代码
这是非常方便的
也就是说我通过定义一个Protocol Buffer的
一个关于服务的约束定义
然后我可以生成客户端代码
生成服务端的代码
服务端代码其实没有逻辑
你肯定要自己完形填空去填
然后它的Request、Response都会有生成
我们以前可能在使用API的时候
最困惑的一个点是什么呢
代码更新了
文档没有更新
为什么会出现这种情况
因为就是你的API的定义和实现它实际上是分开的
那么会导致代码更新了文档没更新
或者文档更新了代码没更新
这种情况的存在
使用统一的IDL定义的话
无论是你的客户端
还有个好处啊
客户端代码都可以生成
所以说我们最终利用这种IDL文件
把一个完整的服务定义出来
里面包含服务的名称
API的名称
甚至它的输入输出的参数是什么样的
然后我们就可以在输入输出里面写上
它的一些自定义的描述
就是文档的注释也在里面
所以说这个文件一旦有了
意味着我文档是统一的
因为我去改这个文件的时候
始终是看我这个PB文件
我只要把这些PB文件统一管理起来
你是不是很方便去检索全公司的API
到底是怎么样定义
那么如果有了这个文件
服务端代码和客户端代码都能生成
你也不需要再去考虑
去连这个接口到底是用什么序列化方式
HTTP接口
还是个TCP私有协议实现
这些逻辑都不需要你去考虑的
所以整个使用体验是非常好的
还有它的一些设计的理念
还有他很重要的一个点是什么
它对移动端的支持
因为gRPC它是按照标准的HTTP2.0来设计的
所以它支持双向流
头压缩
甚至是单TCP的多路复用
就是HTTP1.1/1.x的问题是什么
是在于你要去大吞吐或者是大流量调用的时候
你可能会使用连接池
因为我们知道HTTP1.x
它是一个单向Request、Response的模型
会导致说连接无法
在发送请求之后都无法再使用
除非它再返回
那么它被复用的情况是不太好的
所以你必须搞很多连接
连接一多其实就浪费资源
大家不要小看连接
如果你是n个Consumer
然后m个Provider
它连接数最终是多少呢
它是不是就是一个n乘以m的连接数
这种连接数如果再加上连接池对吧
比方说你配50或者配多少
它其实连接放大会很多
间接地会消耗我们很多资源
就一个Consumer造成的连接数并不会很恶劣
但是一旦你的Consumer连接
你的Consumer数量在变得非常多的以后
连接就会爆炸
这种情况下就会浪费很多内存
所以这个是不太好的点
HTTP2.0很有优势的一个点
它是支持单个TCP连接的多路复用
所以说这个点是从连接复用的角度来说是非常好的
我们如果去自己了解过net/rpc
标准库的net/rpc的实现
它其实本质上也是一个基于单TCP的多路复用
它在实现原理上
实际上是在一个连接上通过标识请求的ID
比方说这一次RPC请求等于100
那么在Response回来的时候
也会带上一个100
我们叫Call ID
或者叫RPC ID
那么在一个连接发多个包的时候
每个人的序号不同
那么Response回来的时候就知道
这个Response对应谁的请求
你就可以当TCP的一个多路复用
还有就是它很好地能够支持服务端的推送
就是它支持Server Push
这些特性使得gRPC最终在移动端
注意它是在移动端能够更加省电和节省网络流量
我之前也说过我们移动端上是大面积
使用gRPC over HTTP2.0去使用的
其实还有一个很重要的设计理念是什么呢
它整个所谓的传输层
它其实是弱依赖的
以后也就是说
可以很方便地去基于HTTP3.0
也就是所谓的QUIC
类似UDP这种方式去连接
这个可能是未来gRPC over HTTP3.0
over QUIC更好的机制和实现
这些设计理念其实都会给我们很大的便利性
还有一个
我们看一下这个PB文件
刚刚提到了
你可以在上面定义注释
定义服务
定义RPC
RPC里面还包含
Request和Response
也就是它的输入和输出都定义了
那么分别
你可以在下面定义两个message
一个表示的叫HelloRequest
一个表示的是HelloReply
在这两个里面你分别就定义了它们的字段
以及结构体
这个message
就是它的文档描述是什么
所以我们看看
这样的一个PB定义
实际上就把服务
把RPC
把输入输出
把文档全部很方便地
通过一个统一的PB文件就描述出来了
最终你可以只使用gRPC的Protoc工具
使用PB protocol buffer的工具很方便实时
比方这个地方我就举了一个demo
就是helloword
helloworld.proto
你可以生成Go语言的
一个它对应的客户端代码和服务端代码
我觉得是非常方便的
那么它设计里面还有一个点是什么
叫服务非对象
消息非引用
促进微服务系统间粗粒度消息交互的设计理念
这个是什么意思呢
在巨石架构里面是
两两引用
两个功能的引用
其实它指的是对象应用
我们叫Reference引用
这个地方定义的是一个什么
定义的是一个服务
所以服务天然就不是一种通信机制
还有就是以前我是依赖别人的class
我们叫依赖或者叫引用
现在定义的是什么
定义是一个message
指的是消息间的传递
间接地通过这样的一个API预定义
我们促进了微服务的交互
并且我们使用的是
按照它的设计理念来讲
我们尽可能使用粗粒度的消息交互
比方说我们支持批量的
批量的用户查询接口
还有一块它是叫payload无关的
负载无关的
所以其实你可以使用protocol buffer
JSON、XML和Thrift
我们在做gRPC debug的时候
因为要去构建一个protocol buffer的对象
实际上是比较困难的
实际上也可以指定它
accept encoding可能是JSON
这样的话又可以使用JSON的
一个Request去请求它
这样的话
对于测试同学或者一些其他场景
去构建一些test的demo的话
是比较方便的
它除了类似这种单向的RPC的
Request/Response这种模型
它还支持一个叫Streaming API
它是支持一个流式的API的
这样的话在某些业务场景里面
也是会非常方便的
实现里面它其实是支持阻塞式和非阻塞式的
就是异步和同步的客户端
这个也是支持的
还有一个很关键的点
就是它支持元数据交互
像net/rpc就是标准库的话
它实际上不是很方便去
叫Out-Of-Band带外数据
去用于RPC流量的通信的
举个例子就像HTTP
我们知道Request参数
可能是个JSON的
放到你的body里面
或者放到我们Form表单里面
但是如果你有一些数据
它不是协议层面的
反而是一些元数据
比方说像IP这些东西
你可能是放到HTTP的头里面对吧
HTTP head里面
同样的gRPC
它是基于HTTP2.0的headers
来传递它自己的元数据的
这样的话我们通过元数据的交换能力
比方说通过类似像认证
Chasing跟踪
其实最典型就是链路追踪
链路追踪需要传递元数据
每个接口的重要性也是可以传递的
比方我在最root层定义一个接口的重要性
那么这个接口
它流量一旦定义了重要性以后
它传到下游所有其他的服务
接口重要性也是可以被传递的
这一次接口请求是非常重要的
那么在做一些信任流和熔断的时候
就可以基于重要性来做一些
做一些逻辑
这个也是很重要的
还有像超时传递这些
也是基于元数据交换来做的
那么关于超时传递
我会在可用性设计里面详细去介绍
利用超时传递
我们可以严格地控制一个请求
从上到下涉及到多个微服务的时候
它也能控制整个超时的一套机制
我觉得元数据交换基本上是RPC框架必备的
所以我们即便是自己做的RPC框架
也最好是要支持元数据的交换
另外就是说它是使用标准化的状态码
我们在协议设计里面API Design里面
最容易搞错的就是状态码
比方说HTTP1.1
我们既有状态码
业务返回的时候可能会返回自己的Error Code
就是错误码
其实这两个
很多同学可能是傻傻分不清楚
比方说我们到底报了一个内部错误的时候
是通过HTTP状态码传递
还是说通过JSON的序列化的Error Code带出去
其实很多接口设计是没有明确的
但是从我的视角来说
我认为我们尽量使用标准化的状态码
来规范一大类的错误
如果它是一些业务自定义的错误
我们再使用JSON Code
比方它只是一个业务的
我们所谓的业务提示
叫Error Code Hint
它是一种业务错误码的提示
它本质上不属于一种特定的错误
最好是使用JSON
它属于某大类的标准错误
比方404找不到
500错误
这个时候就可以使用 HTTP状态码
因为这样的话对运维也是更方便的
因为运维我们在做一些接口监控的时候
可以使用它标准的状态码
比方500、404就可以做一些最粗粒度的监控
如果你是放到Error Code里面
它不太可能再去拦截你的JSON返回
比方它去解析你的JSON
看你的Error Code等于多少
再去做报警
其实要复杂的多
我们通常都是使用标准的状态码
同样的gRPC也是使用标准状态码
因为我们知道为什么
gRPC它就是使用标准的HTTP2.0
所以使用标准的状态码对它来说
管理的成本和门槛都是相对比较低一点的
另外可能很多同学会关注
我的gRPC性能是不是不太行
然后是不是会测下来
好像没有我自己优化做得好
为什么
我建议是我们选型一个RPC框架
优先考虑它的标准化问题
一旦它的社区
很多其他的开源产品都使用gRPC
我们应该先考虑标准化、社区以及它生态的问题
接下来再考虑它的性能问题
因为随着开源框架越来越多人使用
越来越多人依赖
然后它的生态越来越成熟
开源社区越来越活跃
一定会有大量的人投入进去
对它做一些性能的优化
所以我们一定是先定标准后做性能
这个其实是对我来说也是很重要的一个点
我们不要过早去关注性能问题
那么这个基于种种考虑
就最终选型使用的gRPC
gRPC里面
关于它具体的使用
建议大家在课后再去看一下它的官方文档
还有demo
比方说真的是像很多开源的
像Etcd等等
都是使用gRPC的接口
我觉得这个还是非常方便的
那么我其实介绍gRPC
我并不会展开来讲
我会告诉大家设计理念
包括我选型的一些逻辑
我们很重要的一个点是什么
就要了解到RPC里面
很重要的就是HealthCheck
健康检测
gRPC它内置有个标准的健康检测的协议
那么在gRPC所有语言实现中
都提供了生成代码
用于设置运行状态的一个功能
也就是说你可以自己去扩展它的健康检测的协议
健康检测为什么会如此重要
主动的健康检测health check
可以在服务提供者服务不稳定的时候
被消费者感知到
我们临时地把它从负载均衡里面摘除
减少错误请求数
就是假设我依赖的一个RPC的服务提供者
它出现了抖动
我肯定是期望第一时间
或者尽早地能够被我的消费者感知到
然后临时地把它从负载均衡的连接池里面踢掉
那么这样的话可以减少错误请求
因为RPC不断地发送
如果有个节点是坏的
你肯定是期望它临时被摘除
当服务提供者重新稳定以后
health check肯定会成功
成功以后再把它重新加入到消费者的连接池里
重新构建它的负载均衡
恢复请求
所以说health check它其实可以理解为
当它不稳定的时候摘除流量
当它恢复的时候
或者在它正常的情况下
我们可以发送正常的RPC请求
发送流量进去
同样得也被用于外挂方式容器的健康检测
举个例子我们如果使用K8s Kubernetes
要知道一个K8s的pod
或者说一个container它是否健康
我们通常是使用K8s的
所谓的liveness和readiness
liveness指的是如何探测一个服务
它到底是活着还是挂了
比方说我们最简单的
就是看它进程在不在
或者是我用ping一下
看它监听的端口
然后我们去TCP check一下
readiness指的是
这个服务本身是否已经ready
是否已经准备完成
可以接收流量
因为活着和接受流量是两个概念
我们有个应用刚启动
它要做很多代码的初始化
虽然这个时候你去做进程监控
或者做一些类似自定义的liveness的check
判断它是否活对吧
判活那可能是成功的
但是它未必是能够立马接受流量的
它可能需要做一些代码的预热
比方说做一些JVM预热
或者是load一些本地的LocalCache
或者做一些初始化的逻辑
这个时候它其实不能提供流量
所以说readiness也是两个维度都需要的
readiness我们有时候会用K8s的readiness
指向RPC服务的health check接口
让它去探
问它是否能够提供流量提供服务
所以health check是非常有用的
health check在另外一个领域也是非常有用
我们看一下右边这张图
RPC我们通常上线以后
最早期也经历过所谓的使用物理机固定三个IP
然后使用集中式的负载均衡
把这三个IP写到Nginx里面
或者使用我们的DNS
或者是使用DNS去访问我们的服务提供者
因为早期的话
节点比较少
IP也是固定的
所以说这种模式我觉得也是
在流量规模
或者业务复杂度比较低的时候没问题的
但是随着流量越来越大
规模越来越复杂
我们的节点数量越来越多
那么以前的这种使用固定IP的方式
也不是特别方便
因为你的节点可能需要动态的扩缩容
随着节点的动态扩缩容的需求演进
我们使用Kubernetes
Kubernetes你的IP需要迭代发布的时候
IP都会变化
这个时候我们其实就间接
慢慢得从以前的这种集中式负载均衡
或者是使用固定的IP、DNS
这种发现的模式演进到
引入服务发现
因为服务发现和RPC基本上是成双成对出现的
那么我们的模型就变成右图里面的
服务提供者
它需要进行一个接口的
它需要把自己Endpoint的信息
比方说我是谁
我是哪个服务
我这个服务提供了什么样的协议
比方说是HTTP的还是gRPC的
然后它的IP、Port
就是它的IP和端口是什么
然后把这些信息
甚至还有一些元数据
比方说权重信息
我是哪个机房的
我是哪个集群的
我归属的机房和机群等等一系列的信息
元数据信息都注册到服务发现
然后Consumer服务消费者
因为要去进行一个
它需要调用服务提供者
这个时候我就要知道它的地址
这个时候服务消费者就需要
去一个地方集中式地查一下
根据服务名称查一下
拿到一堆Endpoint信息
然后在Consumer本地初始化连接池
然后使用一种负载均衡的方式
然后把RPC的请求发送给我们的Provider
但是这个模型里面
把服务提供者的生命周期托管给了
服务注册
服务发现
为什么还需要health check这种机制
因为我们知道
我们外挂依赖一个第三方的服务
来做生命周期的探测
无论这个服务多么灵敏或者可靠
网络上它不是在一块的
大家可以理解吧
假设我的Provider和Consumer之间
网络出现问题
它们可能调不通
但是Provider和Consumer
它们都和服务发现注册是相通的
这种情况下服务发现告诉我是正常的
但是我又调不通
我的流量如果还是无脑地发送过去到Provider
那么这个时候其实对我们的业务是有影响的
因为你会有大量的请求会报错
它不是处在一个严格的对等网络里面的
所以说这个时候如果Consumer有一种机制
能够探测到我对端的Provider
就跟我这个端点有问题连不上
我就可以把它临时摘掉
所以health check
在这种场景里面是非常有效的
举个例子
就像我们以前有个同事去机房里面
手不小心握住了我们的光纤
然后就这个光纤会丢包
它就跟某一个端口会出现问题
这个时候它可能通过这种health check的机制
可能就有效了
真的是什么情况都可能会发生
在一个复杂的网络环境里面
这是第一种
health check其实在结合我们做平滑发布
所谓的Graceful Restart
Graceful Shutdown
其实都是有很大的帮助的
甚至Graceful Start
我们叫平滑的下线、上线、重启
都是有作用的
这个模型叫Lame ducking
就是叫跛脚鸭
跛脚鸭状态一旦一个服务
它进入到待下线
或者是待上线的状态
我们就叫它跛脚鸭状态
什么意思
实际上意味着它是一个半残状态
就它不能正常提供服务
或者它接下来要退出
用一种特殊的状态标识出来
然后结合health check
就能做到很稳健的平滑发布
以前我记得很多传统企业
在发布的时候都要约定一个时间
凌晨3点开始停机维护三小时
其实对于一个高流量或者是一个持续在线的业务
其实是很难忍受停机维护
这种所谓的离线操作的
我们基本上都是要在线去滚动发布
我们可以滚动
比方有20个容器
我一次下线两个
再上线两个
下线两个再上线两个
逐渐这样地迭代
把所有的服务都进行重新发布
这种机制其实是非常常见的
把完整的服务给上线
Rolling Update滚动更新的方式
全部替换掉
这个很常见
如何结合health check来实现平滑发布
举个例子
应用要下线了
这个时候我肯定是在在线运行时的平台
也就是托管我们服务的一个平台
可能是K8s
点了一个所谓的下线按钮
这个时候其实就是K8s向应用发送一个kill信号
注意它不是kill -9
因为kill -9是强杀
我们可能就是发一个SIGQUIT
一个kill
普通的kill信号
SIGQUIT
这时候我的应用拦截到这个信号以后
交由我的应用
向我们的服务发现
发起一个注销的请求
我现在不干了
把我踢掉
因为之前我们都是持续注册的
或者发心跳给注册中心
但是我现在不干了
我肯定要注销请求
这是第一步
第二步就是我一旦进入到了优雅退出的流程
我刚才说了我们需要知道一种状态
它表示接下来要下线
所以说这个时候我们应该有一个全局变量
把自己标示成一个Lame ducking
或者是带下线的一个状态
这个状态又可以被health check的接口所依赖
所使用
如果你在调我的health check接口
我就告诉你我当下马上就不可用了
麻烦你把我从负载均衡连接池里面摘掉
为什么还需要这一步动作
我们不是向服务注册中心已经注销了吗
实际上某一些服务消费者
没有收到某一个节点的下线请求
有延迟
这种情况下
有health check的话
也能够感知到有人要下线
所以它是个双保险
就是既接收注销请求
以及它通知到我的Consumer
同时我的Consumer因为依赖health check的机制
我也可以想办法把它给注销掉
进入到注销流程
意味着我们通过一两个心跳周期
比方说一两个health check的心跳周期
或者是同时
我们注册中心也有一些心跳
这个时候几种机制都保障了
或者说有一个没保障
另外一个保障了
那就有办法把我从Consumer的连接池里面踢掉
这个是第二步
第三步
因为我的应用有一些正在运行的请求
我们把这种正在运行请求
一般叫什么
叫InFlight
InFlight请求正在运行
那么我可能要等它完完全全执行完
我才能下线
因为如果正在运行请求
我直接shut down这个应用的话
它就会报错
我要看当前有没有正在运行的请求
简单来说就是当前有没有正在运行的活跃连接
如果所有的活跃连接都退出了
我认为因为我已经注销了
所有新上线的Consumer是发现不到我的
同时因为health check的心跳机制
以及服务发现注销的请求
已经广播到所有的Consumer
这几种机制的结合下
我就能够安全退出
当然某些意外的情况下
这几种机制都没有生效
所以我们的K8s还需要支持一个兜底策略
超过几分钟
比方说超过一分钟
这个容器都无法退出
我就要什么
kill -9把它强杀
所以结合这些策略
我们能够尽可能最大努力地做到平滑下线
上线其实也是一样的
因为有了health check接口的存在
我这个时候
在我的应用初始化的时候
我的服务一启动的时候
我的全局状态
我的状态变量可能处于Lameducking
就属于跛脚鸭状态
随着我的初始化
JVM预热等等一系列逻辑
当然Go的话其实不需要做太多的
这种Runtime的预热
我们只需要做一些自己业务逻辑的预热
比方Cache的预热等等
连接的建立等等
做完这些之后我就是把这个状态变更为可用
Ok
那么health check就成功了
health check一旦成功
我的K8s的readiness读到了我可读
那么它可能就
比方说你使用的是kube-DNS
K8s自己的服务发现
或者说你的容器里面有个
类似Docker的Entry Point这种机制来
探测你
然后自动帮你注册
甚至或者是你自己的框架里面自己去注册的
因为你知道你当下是OK的
那么你再去做注册都是可以的
都可以依赖health check机制
所以我觉得health check在RPC框架里面
它是一种很有用很关键的一种机制
所以我们还是要完整地去了解它
提到gRPC的health check
配合地我们马上就要讲服务发现
服务发现它其实是有几种模型的
一种就是客户端发现
客户端发现它实际上是让两个端口
服务提供者
和服务消费者直连
也就是说我的服务提供者上线了以后
就向我的注册中心去注册
向我的注册中心注册
我的消费者就从我的注册中心
把这些IP端口给捞到
捞到以后
我的服务消费者在我的本地
建立一个连接池
然后使用某种本地的负载均衡算法
去把这个请求Forward出去
这样的话是一个点到点的连接
它没有经过一个集中式负载均衡
再去转发流量请求
我们把这种模型叫做客户端的发现模式
那么另外一种模型叫做服务端的发现模式
服务端发现模式是指什么
也就是说我的服务提供者
实际上是把我的请求全部注册给
一个统一的集中式的负载均衡
比方说我们有可能向API Gateway网关这种服务
它可能上面的SLB、ELB
要感知它的存在的话
需要从注册中心里面捞到它的IP
然后把这个IP在它的
就是在ELB或者SLB的连接池里面
进行一些初始化
然后请求再发送出去
对于上游
再上游的请求
实际上因为你要经过一个集中式的负载均衡
再传递到我的下层
也就是说它无论如何
都要经过一个集中式的负载均衡
再传递到下游
那么流量其实做了多一跳
但是好处是什么
对于上游的请求
比方我的API Gateway其实好多个节点
但是我对于ELB的上游来说
它完全不需要感知
你的API Gateway到底有多少个节点
它只需要直接把这个请求发到ELB或者SLB
它自动地帮我路由到下游的这么多节点
所以它相当于是过了一个集中的负载均衡
但是我们在讲微服务的理念里面
讲了一个点叫去中心化的服务治理
去中心化有一个很重要的点
就是去数据的中心
或者去流量中心
这种模型实际上是违背了刚刚我们讲的观点的
因为你经过了一个集中式的负载均衡
相当于我们的流量要经过一层转发
所以它容易产生流量热点
对于ELB、SLB这种场景的话
我觉得其实还好
它是整个公网的入口
我觉得这种情况下可能还好
但是对于内网的请求
这种模型我觉得还是不太好
虽然现在大家知道目前两种连接模式
一种直连
端到端的直连
就是A服务连B服务
它们没有任何的转发
那么还有一种情况就是
A服务到B服务可能通过Nginx转发
或者通过一个代理转发
这个代理如果是集中式部署的
通过LVS四层负载均衡
然后再加上代理本身是一个七层负载均衡
它就容易在LBS或者是在七层的负载均衡
容易很热
容易很热其实不太好
所以说包括现在业界比较流行的
像所谓的Service Mesh服务网格
服务网格实际上是两个人之间的一种模型
服务网格这个代理是部署在你的Pod里
就是你在同一台物理机上
或者在同一个容器组里面有两个容器
你们是一个IPC本地进程间的通信
你的流量先发送给Proxy
Proxy再把它转发到
它再直连到下游的服务提供者
这样的一个模式是什么
会让之前可能直连的模型
可能需要写大量的服务发现的代码
负载均衡的代码
把这些逻辑相当于搬到了Proxy里面
但是Proxy因为它不是集中部署的
它是跟你的容器在一块的
所以说它又间接地去中心化了
所以说它处于两种模型之间的一种模式
好处就是说既保障我们的Client不需要那么重
因为我们直连的这种Client
要考虑容错、熔断
负载均衡服务发现
我们把这种Client叫Smart Client
叫智能客户端
它非常得重
但是如果你是集中式负载均衡
它又容易产生热点
如果你把它下沉到你容器里面
是不是就简单了
应该是下沉到你同一个Pod里面
变成两个容器
然后你可以直接进程间通信
所以它这种模型又处于相对来说
就是既去中心化
同时让你的Client足够简单
所以我们又回到那句话
搞不定的加一层Proxy就解决了
所以这个也是现在非常流行的一种玩法
那么对比两种模型来看的话
客户端发现模式就是直连
我们再看一下右边这张图
服务端发现模式
实际上它是经过一个集中式的负载均衡
做了流量转发
比方说像Ingress LB、Traefik
或者是类似像Kube-DNS都是类似的模型
简单来说我们微服务的核心是去中心化
我们最好
我建议是使用客户端发现模式
当然了随着现在的Service Mesh的流行
我建议还是先观望
如果我们真的没有这么大体量
或者规模
或者是多种语言
我真的不建议使用Service Mesh
即便我们的体量非常大
如果你的框架非常统一
我觉得Service Mesh
带来的收益也是有限的
这个就需要你自己去做一些取舍和判定
服务发现我知道大家早期都会使用
最熟悉的就是类似像ZooKeeper来做服务发现
我们也一样
我们很早期其实
也是使用ZooKeeper来做服务发现
实际在一个海量的服务发现和注册场景里面
很多情况下服务状态是可以弱一致的
但我们尽可能要让我们的注册中心
它保证高可用的
所以我们在大部分场景里面
都是建议使用AP这种模型
因为我们基本上还是知道
CAP的理论里面
CP是保障
CP是一个强一致的
AP是尽可能保证可用性的
它牺牲的是一致性
只要你的数据是最终一致的
也是能接受的
我们就来根据AP系统的模型
来看一看
为什么AP系统能满足我们的需求
对于ZooKeeper
我们早期使用会遇到什么问题呢
因为ZooKeeper它作为一个分布式的协调服务
如果我们知道它是基于Apache source
那么ZooKeeper的话
比方我有三个节点
它实际上只能挂一个节点
如果产生一些网络分区的话
我们如果挂两个
如果我们产生一些网络分区的话
实际上有可能ZooKeeper是不能对外提供服务的
因为它要保证强一致
它要保证强一致
一旦服务对外不能提供服务
意味着我新上线的节点是无法注册的
更可怕的是
如果你的服务发现取得一些数据
没有缓存到内存里面
你是每次去请求的
那肯定直接就炸了
因为你的Consumer拿不到任何的地址
当然我们通常RPC框架都会
缓存之前拿到的那些End Point的信息
我们也叫Client SnapShot
就是在客户端做过快照的
一旦我们的服务发现、注册中心连接不上
我们用本地过期的一些信息也是没问题的
另外就是说ZooKeeper
它一旦发生一些网络分区
它的整个选master的时间是非常长的
这个过程我刚才说了
你新发布的节点因为无法注册
所以这个其实是比较糟糕的
另外我们很多场景里面
也需要考虑
就是我一个区域里面
一个园区里面要多数据中心
那么这个时候如果
距离稍远一点去部署ZooKeeper的话
我觉得也是非常头疼的
就是因为跨机房的部署ZooKeeper
它整个所谓的heartbeat心跳机制
未必是非常可靠的
这个时候一旦产生选举的话
对我们的服务注册
服务可用性影响是非常大的
还有一种情况当我们的体量规模上来以后
一旦ZooKeeper需要广播
因为我们一般都是通过ZooKeeper
使用临时节点加Watch的机制来做注册和发现的
那么这样的话
如果你的节点数量非常多
假如我有1000个节点
假设也有1000个节点
1000个Consumer
然后1000个Provider
那么它广播的数据量也是非常大的
很有可能如果我的ZooKeeper
出现了一个FullGC
这个时候也是会出现重新选举的
这个时候ZooKeeper都不不能用
所以我们当时在我们内部我们是
还是参考Netflix的Eureka的实现
我们做了一个自己的AP服务发现
我们试想两个场景
为什么能够牺牲一致性
最终一致性
能搞定服务发现的场景
第一个就是说注册时间延迟
也就意味着我新上线的节点
在不能够立马被其他所有的人感知到
我觉得比方你的服务是20个节点
那么我现在上线了5个节点
如果他们稍微晚一点
或者早一点
其实被其他的消费者感知到
顶多是有一些节点不均衡
我认为这种场景下是完全可以接受的
同理如果是注销的事件延迟了
我们刚刚讲了平滑下线
平滑下线里面有一个很关键的节点是
很关键的点是什么呢
如果平滑下线你的注销时间延迟了
但是因为我的还有RPC之间的
health check这种机制还存在
所以说它是双兜底的情况
有一些注销的事件延迟也没有关系
它影响会小得多
所以因为这种原因
我们觉得AP系统可能更适合大规模地使用
因为它最终其实保障了一个叫可用性
这就会好得多
其实另外一个场景
如果真的是Provider和Consumer和我们的
和我们的ZooKeeper之间产生了一个网络分区
大家是连不上
连不上ZooKeeper的
但ZooKeeper之间内部的网络又是通的
其实这种情况也是很糟糕的
因为你连不上
你取不到任何地址
这个是非常麻烦的一个点
所以我们最终就使用了类似像Eureka的这种思路
Eureka这种思路自研的一个服务发现
当然了因为也是随着体量架构会有不断地演进
我之前也看过像阿里开源的像Nacos
包括像蚂蚁开源的叫SOFARegistry
包括Netflix的Eureka
我觉得都是实际的非常不错的
包括像Consul
这些组件
我觉得都是很不错的一些实现
所以也是根据规模需要做不同的改造
目前的话我们的架构跟Eureka是非常像的
因为我们体量
我们整个注册的容器数量
可能是在10万左右
可能有一些公司像头条
或者是阿里
它们可能更大规模
可能上百万的节点
我觉得可能需要做更进一步的改造
后面我会简单提一提
Eureka1.0的这种架构可能存在的一些缺陷
可能是有一些什么样的缺陷
还是跟规模体量有关系
我们来看一下它大概的思路是什么样的
首先服务发现
几个点
第一就是它需要把我的节点信息注册上去
我刚刚说的你需要把你的服务名称
你的端口信息
你的IP:Port端口能够把它注册进去
同时能够附加一些元数据
包括像权重
包括我们后面要讲微服务多租户
微服务多租户的
会使用到类似像标签
染色标签
这个服务有一些多租户的场景
比方说服务可能需要被区分环境
区分Feature去做测试
这个时候就需要多租户
多租户其实就是使用染色标签
来带一些注册的元信息
能够在做RPC路由的时候
能够识别出来
我后面会讲
还有包括它的集群的信息
但是这里我最想强调的是什么
是我们的Family
也就是应用ID
它的命名非常有意思
我要单独强调我们以前习惯把一个服务的命名
按照组织结构来命名对吧
比方说A部门的一个什么应用
但我们发现
第一某一个底层的业务微服务
它其实有可能会随着我们的组织结构调整
它会经常变化
一旦组织结构发生变化以后
你的名称又不能改
大家可以想象一下
名称好改
其实是不太方便改的
因为你一旦被注册了有大量人使用
你要去改名字的话真的是伤筋动骨
非常麻烦
所以我建议大家可以参考一下
谷歌API对外的一些服务命名
基本上是三段式的
按照业务来命名
而不要按照组织结构来命名
按业务命名
比方说它可能是Gmail对吧
Gmail.account.service
它可能是Gmail的账号服务
这样无论它的组织结构怎么变化
其实它跟组织结构已经脱离关系了
所以我建议就是三段式的
业务.服务.子服务这样的方式
A.B.C这样的方式来去命名
第二个是什么呢
怎么注册的
我们看一下这个图里面
Service Provider实际上有三种事件
一种Register注册
我要上线了
我要上线一个应用
那么第二个是叫Renew
Renew其实类似心跳
就是保活
我要知道这个服务是不是持续活跃
我是不是只需要定期地去调一下Renew接口
去刷新一下
所谓的 Last time step
我的活跃时间
表示我还是活着的
第三个就是注销
注销在我们平滑下线的时候
其实也会利用主动注销
基本上就是这三种事件
对于刚启动的节点
我们一般
我刚才说的
我们可能需要加载本地的一些Cache
做JVM预热
为什么需要做JVM预热
因为我们一般来说刚启动的
像Java进程的这些服务
如果JVM没有做JAT的话
它可能会刚开始的负载
或者超时会变得厉害一些
最好是你自己可以去跑一跑代码
其实也是在做完这些事情以后
你才能去注册
这样会好得多
在故障的时候
一旦我们的注册中心
服务发现中心它故障
所有节点都挂掉了
我们建议Provider服务提供者
在这个阶段一定是不能重启和重新发布的
因为一旦重启或者发布以后
因为你连不上
也就说你的节点无法注册进去
会导致当下的Consumer
找不到一个可用的Provider
一定会出大故障
所以说我认为服务注册
服务发现
它和我们的发布平台要一个强配合
一旦出现这种大面积的服务注册中心不可用
一定要停发布
一定要停发布和重启
因为你的老节点存在的话
我们刚刚说了
我们Consumer一般都支持Client Snapshot
至于客户端节点的一些缓存信息
这个时候你只要不乱动
基本上是不受影响的
这个是一个点
还有就是说对于我们的Consumer启动的时候
因为Eureka它是轮询的
就是每30秒轮询一次
不管有没有节点信息都要轮询
我们自研的服务发现
我们内部叫Discovery
那么它的话它其实是一个长轮询
我认为更好的机制可能是Push
因为轮询的话
你还是会发起很多无用的请求
所以更好的机制当然是Push
可能是Push
所以它有个Fetch或者叫Poll的接口
去拖节点的
一旦我拿到节点信息以后
我就可以建立连接
然后这个地方我就可以调用Remote Call
来进行一个请求
然后Eureka我觉得它做的比较好的
几个点是什么
第一就是一旦Eureka的Server
发现假设每60秒
我发现有超过90秒的实例都没有来注册
没有来刷新它的Last Time Step字段
也就是它TTL到了
它有TTL的机制
为什么
比方说连续三次心跳
它都注册不上来
我认为这些节点就应该失效了
可能就挂掉了
或者因为一些原因出故障了
我就要把它踢下线
这个是它的踢除垃圾节点的机制
我觉得还有几个点做的比较好
我们一般在发布的时候
需要滚动更新
这个时候应该有大量的节点上线和下线
Eureka它实际上并不会立马把这些节点都踢掉
它可能是慢慢地摘除
它不会一下告诉你这么多节点全部下线
我觉得这个也是非常有意思的一个点
还有就是当我们的Provider提供者
和Eureka失联
可能出现网络分区大面积失联
这种情况下Eureka有个自保护的模式
它叫自保护
如果大量失联
比方说短时间内
整个心跳低于期望值的85%
那么它就会进入自保护
自保护指的是
我这个时候即便这些节点
已经TTL过期了
我仍然不会把它踢下线
因为踢下线
Consumer就没有任何节点可用
你没有任何节点可用
这个时候服务肯定出故障
没有数据肯定是不行的
所以说过期的数据比没有数据要好得多
自保护有可能数据是过期的
但是比没有数据好
同时因为来注册的这些事件
其实并不会丢
所以它是一些过期数据加一些新注册的数据
当然如果你的节点逐渐恢复的话
你又重新注册上
它又会解除自保护的模式
所以我觉得这个机制
也是做得比较好的一个点
在Eureka模型里面
其实它还是有一些致命的缺点的
我PPT里面其实没有体现
但我也可以简述一下
我们发现随着体量或者规模变大
比如我们现在有10万个节点
我们假设未来有100万个节点
因为它是全量复制
我们的服务提供者
它可以扔给任何一个Eureka
或者我们自己发实现的Discovery的节点
比方有ABC三个节点
就是A会拷到B
A会拷到C
它会这样去复制
但是这样复制一旦扩容
发现压力比较大去扩容Eureka节点的时候
它的数据
同样的注册量又会被广播到
它的写入量是没有
随着你扩容节点是没有变化的
它的写放大是非常厉害的
这个是一个点
Eureka1.0
它是每次下载所有的节点信息
那么100万节点下载100万个肯定不行的
Eureka2.0因为它整个还没有Release
2.0它的确是支持按照不同应用去下载节点的
因为我们其实只需要用到的节点下发下来
它以前的是全部下发
这个不太好
另外就是刚刚说的
这种全放大的复制模型肯定也不太好
所以我们看类似像SOFARegistry
或者是Nacos
它们也考虑到超大规模的一些注册机制
在这一块有些实现
可能是
因为Eureka是全量复制
那么类似像SOFA
SOFARegistry我看它的实现机制是数据沙顶
比方说它封成了beta-server
就是存储这些数据的
它做了沙顶
用一致性哈希来处理的
大家其实都是有一些
面对更大规模它都是有一些手段
找时间的话我会把
我们内部的一些新的想法
也会更新到PPT里面去
超大规模我们应该怎么去解决
这些注册的问题
所以这个也是Eureka的一个问题
另外一个点就是以前这种Poll模型
它无效的轮询次数是非常多的
所以说我建议就是最好使用Push的模型
也就是说当有节点信息变化的时候
它是推送到我们的Consumer端
这个的话它的收敛速度
就是传播速度会比较快
这个也是非常有优势的一个点
它的写放大的问题我觉得也是可以解决的
在2.0它提出的是一个读写分离的架构
跟SOFA
跟Nacos其实都是类似的机制
读写分离
我提供一坨读节点来负载
来负载下发的Push压力
比方我下行有1万个节点
我要广播给1万个人
这个压力
可能全部在Eureka的Server节点
那么我通过读写分离
我只把这些变更的数据
推送给我的读节点
我的读节点再去对外提供长链接
给到我们的Consumer去订阅
这样的话
其实我的广播压力就变小了
相当于数据广播是做了个收敛比
相当于假设我有10个写节点
广播给100个读节点
这100个读节点可以给更多的Client来使用
相当于我有100个写节点
应该是给1000个读节点
然后1000个读节点
可能可以给10万人甚至100万人
提供提供订阅和注册
所以大家感兴趣也可以看一下References里面
关于Eureka的一些机制
当然包括SOFA、Nacos的资料
我都在reference里面有提到
关于它更大规模的一些做法和优化
你们也可以去深入研究和思考一下
我们这一块服务发现和gRPC的内容
就介绍到这里了
我们在下一节课
主要会介绍微服务里面的多集群的概念
因为微服务是多节点
有好多节点
那么多集群是个什么意思
以及我们非常关注的多租户
多租户在全链路压测也好
构建多测试环境
知道gRPC有元数据传递的能力
以及注册中心可以存储一些
注册的时候带一些元数据
这些其实都是为下游
下面讲多集群和多租户铺垫的
我们今天内容就到这里了