文章目录
上篇文章我们讲到微服务的定义,优缺点,对外暴露等,服务除了对外暴露之外,服务之间还需要相互进行调用,不同的服务之间通过什么样的协议进行交互,服务发现如何实现,如何保证服务的平滑发布与重启,测试环境的问题如何解决等。
一:服务间通信方式: gRPC
为什么采用 gRPC?
- 多语言:语言中立,支持多种语言。
- 轻量级、高性能:序列化支持
PB(Protocol Buffer)
和JSON
,PB
是一种语言无关的高性能序列化框架。 - 可插拔
IDL
:基于文件定义服务,通过proto3
工具生成指定语言的数据结构、服务端接口以及客户端Stub
。- 移动端:基于标准的
HTTP2
设计,支持双向流、消息头压缩、单TCP
的多路复用、服务端推送等特性,这些特性使得gRPC
在移动端设备上更加省电和节省网络流量。 - 服务而非对象、消息而非引用:促进微服务的系统间粗粒度消息交互设计理念。
- 负载无关的:不同的服务需要使用不同的消息类型和编码,例如
protocol buffers、JSON、XML 和 Thrift
。 - 流:
Streaming API
。 - 阻塞式和非阻塞式:支持异步和同步处理在客户端和服务端间交互的消息序列。
- 元数据交换:常见的横切关注点,如认证或跟踪,依赖数据交换。
- 标准化状态码:客户端通常以有限的方式响应
API
调用返回的错误。
为什么不使用 restful
- 每个客户端都需要单独写
SDK
,复杂麻烦 - 需要单独写文档,常常会因为代码更新了但是文档没更新陷入坑中
- 性能不太好,
json
传递相对于pb
更耗流量,性能更低 http1.1
是一个单连接的请求,在内部网络环境,使用http
比较浪费restful
是一个松散约束的协议,非常灵活,每个人,每个团队出来的代码都不太一样,比较容易出错
一般前后端交互使用restful,而后端各服务内部使用RPC
二:服务优雅启动(注册)与服务退出(注销)
优雅启动(服务注册思路)
Provider
启动,k8s
中的启动脚本会定时去检查服务的健康检查接口- 健康检查通过之后,服务注册脚本向注册中心注册服务
(rpc://ip:port)
- 消费者定时从服务注册中心获取服务方地址信息
- 获取成功后,会定时的向服务方发起健康检查,健康检查通过后才会向这个地址发起请求,在运行过程中如果健康检查出现问题,会从消费者本地的负载均衡中移除
优雅退出(服务注销思路)
- 触发下线操作: 首先用户在发布平台点击发版/下线按钮
- 发布部署平台向注册中心发起服务注销请求,在注册中心下线服务的这个节点,这里在发布部署平台实现有个好处,不用每个应用都去实现一遍相同的逻辑,在应用受到退出信号之后由应用主动发起注销操作也是可以的
2.1 注册中心下线应用之后,消费者会获取到服务注销的事件
2.2 然后将服务方的节点从本地负载均衡当中移除,注意这一步操作会有一段时间,下面的第4
步并不是这一步结束了才开始 - 发布部署平台向应用发送
SIGTERM
信号,应用捕获到之后执行将健康检查接口设置为不健康,返回错误
这个时候如果消费者还在调用应用程序,调用健康检查接口发现无法通过,也会将服务节点从本地负载均衡当中移除
调用grpc/http
的shutdown
接口,并且传递超时时间,等待连接全部关闭后退出,这个超时时间一般为2
个心跳周期 - 发布部署平台如果发现应用程序长时间没有完成退出,发送
SIGKILL
强制退出应用,这个超时时间根据应用进行设置一般为10 - 60s
优雅启动和关闭简要代码
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
)
// 模拟慢请求
func sleep(ctx *gin.Context) {
t := ctx.Query("t")
s, err := strconv.Atoi(t)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"msg": "参数错误: " + t})
return
}
time.Sleep(time.Duration(s) * time.Second)
ctx.JSON(http.StatusOK, gin.H{"msg": fmt.Sprintf("sleep %d s", s)})
}
const (
stateHealth = "health"
stateUnHealth = "unhealth"
)
var state = stateHealth
func health(ctx *gin.Context) {
status := http.StatusOK
if state == stateUnHealth {
status = http.StatusServiceUnavailable
}
ctx.JSON(status, gin.H{"data": state})
}
func main() {
e := gin.Default()
e.GET("/health", health)
e.GET("/sleep", sleep)
server := &http.Server{
Addr: ":8080",
Handler: e,
}
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server run err: %+v", err)
}
}()
// 用于捕获退出信号
quit := make(chan os.Signal)
// kill (no param) default send syscall.SIGTERM
// kill -2 is syscall.SIGINT
// kill -9 is syscall.SIGKILL but can't be catch, so don't need add it
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
// 捕获到退出信号之后将健康检查状态设置为 unhealth
state = stateUnHealth
log.Println("Shutting down state: ", state)
// 设置超时时间,两个心跳周期,假设一次心跳 3s
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
// Shutdown 接口,如果没有新的连接了就会释放,传入超时 context
// 调用这个接口会关闭服务,但是不会中断活动连接
// 首先会将端口监听移除
// 然后会关闭所有的空闲连接
// 然后等待活动的连接变为空闲后关闭
// 如果等待时间超过了传入的 context 的超时时间,就会强制退出
// 调用这个接口 server 监听端口会返回 ErrServerClosed 错误
// 注意,这个接口不会关闭和等待websocket这种被劫持的链接,如果做一些处理。可以使用 RegisterOnShutdown 注册一些清理的方法
if err := server.Shutdown(ctx); err != nil {
log.Fatal("Server forced to shutdown:", err)
}
log.Println("Server exiting")
}
测试时,可以调用sleep
,传一个长一点的时间,然后kill -2
结束服务,但是服务还是会等到sleep
相应完成才退出。
可以参考另外两篇博客:18. Go实现Gin服务优雅关机 以及 14. Go实现简易分布式注册中心
三:注册中心要点
CP、CA、还是 AP
实际场景是海量服务发现和注册,服务状态可以弱一致, 需要的是 AP
系统,只需最终一致性即可
-
注册的事件延迟
高可用的服务在这方面问题不大 -
注销的事件延迟
因为有上文提到的健康检查的机制,即使注销延迟,客户端也会主动的将节点移除 -
服务注册
注册:服务方启动后向注册中心任意一个节点发送注册请求,然后这个节点会向其他节点进行广播同步
心跳:注册后定期(30s
)向注册中心发送心跳,或注册中心向服务发起心跳检测
下线:下线时向注册中心发送下线请求
注意:注册中心节点启动时需要加载缓存进行预热,所以不建议这个时候服务进行重启或者是发版 -
服务发现
消费者定期向注册中心长轮询获取节点信息,获取到之后缓存到本地 -
网络故障
服务方与注册中心:注册中心会定期(60s
)检测已失效(90s
未更新)的实例,失效之后就会移除,但是如果短时间内丢失大量心跳连接,(15min
内心跳低于期望值的85%
)就会开启自我保护模式,保留过期的服务不会进行删除
注册中心与消费者:消费者本地有缓存,问题不大
服务方与消费者 :有健康检查,健康检查不通过时,会从消费者本地负载均衡中移除 -
注册中心故障
1.不建议这个时候服务进行重启或者是发版,因为这个时候注册不上,会导致服务不可用,不发版短时间没有影响
2.如果注册中心节点全部挂掉,启动时必须要等两到三个心跳周期,等所有的服务都注册上之后再开始提供服务运行,让消费者拉取数据
3.如果挂掉一个注册中心节点,需要等其他的节点将信息同步到本机之后再提供服务
4.数据同步时会对比时间戳,会保证当前节点的数据是最新的
四:多集群
注意这里的多集群都是单个机房内的,多个机房的一般就是异地多活了
多集群需求从何而来?
对于类似账号服务的L0
级别的服务,几乎所有的服务都有依赖,需要尽可能的提高服务的可用性
- 从单一集群考虑,多个节点保证可用性,我们通常使用
N+2
的方式来冗余节点。N
一般通过压测得出 - 从单一集群故障带来的影响面角度考虑冗余多套集群。例如依赖的
redis
出现问题,整个集群挂掉了,还可以访问其他集群 - 多机房部署,如果在云上可能是多个可用区
什么是多集群?
给某个服务部署多套,每一套都拥有独立的缓存,物理上相当于有多套资源,逻辑上划分为不同的集群,在服务注册的时候向注册中心注册的时候携带相关的集群标签
五:多租户
简介
随着互联网的发展,越来越多的企业开始向多租户的方向转型,提高竞争力。微服务架构中允许同一个系统多套代码共存,这一般被称为多租户(multi-tenancy)。
多租户系统允许多个租户共享同一套应用程序和基础设施,每个租户都拥有自己的数据和隐私保护。
租户可以是测试,金丝雀发布,影子系统(shadow systems),甚至服务层或者产品线,使用租户能够保证代码的隔离性并且能够基于流量租户做路由决策。
染色发布
可以把待测试的服务 B
在一个隔离的沙盒环境中启动一个B'
,并且在沙盒环境下可以访问集成环境(UAT)C
和 D
。
把测试流量路由到服务 B'
,同时保持生产流量正常流入到集成服务(如在请求中带上泳道环境标识,请求便会优先路由到对应的泳道上去,链路上的任意服务部署了该泳道下的实例,则请求该泳道下的实例,若没有部署该泳道下的实例,则路由到基准环境(集成环境)下的实例
。
服务 B'
仅仅处理测试流量而不处理生产流量。
生产中的测试提出了两个基本要求,它们也构成了多租户体系结构的基础:
-
流量路由:能够基于流入栈中的流量类型做路由
(如POE(Product Offline Environment)PPE(Product Preview Environment)以及是否带泳道)
。 -
隔离性:能够可靠地隔离测试和生产中的资源,这样可以保证对于关键业务微服务没有副作用。
为了实现多租户系统,需要考虑多维度的设计,涉及到数据隔离、安全性等问题。
多租户系统设计需求
在多租户系统设计过程中,需要考虑如下需求:
- 数据隔离:每个租户的数据需要被隔离,不能相互干扰。
- 安全性:保证每个租户的数据隐私和安全性。
- 扩展性:系统需要支持横向和纵向扩展。
- 高可用性:系统需要保证高可用性,不能因为某个租户的问题导致整个系统崩溃。
- 管理性:系统需要提供方便的管理和维护功能。
多维度的设计方案
为了满足上述需求,需要从多个维度考虑设计方案。
数据隔离
在设计数据隔离方案时,可以采用以下策略:
- 独立数据库:对于每个租户,分配一个独立的数据库实例,确保数据不会被混淆。
- 共享数据库但不同表或不同Schema:对于每个租户,不同的租户使用同一个数据库,可以采用不同的
schema
或者表前缀,确保不同租户之间不会访问到相同的数据。 - 共享数据库、共享表、共享Schema:在数据表中新增
TenantID
字段,通过字段进行数据隔离
安全性
为了保证安全性,可以采用以下措施:
为每个租户分配一个唯一的标识符,确保不同租户之间数据不会被误用。
使用加密技术对租户数据进行保护。
采用权限控制机制,保证每个租户只能访问属于自己的数据。
扩展性
为了支持横向和纵向扩展,可以采用以下策略:
- 采用负载均衡机制,将请求分配到不同的节点上,以支持多节点的扩展。
- 设计合理的分表策略,以支持大规模数据量的存储。
- 对于大数据量或者并发量大的租户,可以采用分片或者分块技术,以支持高效的数据处理。
高可用性
为了保证高可用性,可以采用以下措施:
- 设计合理的系统架构,支持多节点、多副本、多数据中心等机制,防止单点故障。
- 采用容错机制,保证即使发生故障,也可以继续提供服务。
管理性
为了提高管理和维护效率,可以采用以下策略:
- 提供简单易用的管理界面,方便管理员进行维护和监控。
- 提供合理的数据备份和恢复机制,保证数据的安全性和可靠性。
- 采用自动化部署和配置管理机制,提高系统的可维护性。