微服务

微服务常见的方案,如限流、容错、熔断、降级、负载均衡、服务注册与发现

[TOC]

一些概念理解

  • 微服务:http://dockone.io/article/3687

  • 中间件(Middleware):是处于操作系统和应用程序之间的软件,用来屏蔽底层的技术细节,以自身的复杂性换来了应用程序开发的简单。广义中间件的定义是非常广泛的,比如消息、注册配置中心、网关、数据库访问、集成平台等等,都属于中间件的范畴。

  • 云原生:应用程序从设计之初即考虑到云的环境,原生为云而设计,在云上以最佳状态运行,充分利用和发挥云平台的弹性和分布式优势。代表技术包括容器、服务网格、微服务、不可变基础设施和声明式API(如K8s),组合起来就算服务容器化,整体使用微服务架构,应用支持容器编排调度。

    云原生的本质其实是基础设施与业务的解耦,以及基础设施自身的标准化

  • IaaS:基础结构即服务,基础设施,如AWS的EC2、S3等,只提供比较原始的硬件功能,用户不用买服务器,自己去构建网络,防火墙、硬盘存储等基础设施,即提供基础环境配备。剩下的由用户自己完成。

  • PaaS:平台即服务,中间件、解决方案、工具和服务的集合,如AWS的DocDB、Redis、SQS、SNS等设施,让用户更专注自己业务逻辑的开发,对于使用的工具,拿来即用。

  • FaaS:功能即服务,service less,如AWS的lambda,用户只需要写对应的业务方法就能提供对应的服务,其他都不用管

  • SaaS:软件即服务,应用层面了,比如Shopify、moka,提供某一业务领域的解决方案,直接注册使用即可

  • DevOps:一种模式,与敏捷挂钩,集文化理念、实践和工具于一身,快速迭代和部署,提供组织快速交付应用的能力。自动化部署、自动化运维,适合敏捷开发和快速迭代,解决传统开发和运维相互独立,沟通频繁,效率低等问题

限流

下面的方案都是单机版本的,在分布式环境下可以把限流的实例放到Redis里,或者直接使用Lua脚本实现,保证并发安全。

固定窗口

规定单位时间内可访问的次数,比如规定接口一分钟内只能访问10次,以第一次请求为起始,计数1,一分钟内计数超过10后,后续的请求直接拒绝,只能等到这一分钟结束后,重置计数,重新开始计数。

但是这样有个问题,如果在大部分请求集中在第一个窗口的后10s内,和第二个窗口的前10s内,虽然他们都符合限流策略,但是在临界的20s内,请求还是有可能压垮系统。

算法Demo:

type fixWinLimiter struct {
	lock            *sync.Mutex
	maxLimitCount    int64   // 最大限制数
	currentCount     int64   // 当前计数
	fixInterval      int64   // 为了简化,单位设置为秒
	lastReqStartAt   int64   // 秒级时间戳
}

func NewFixWinLimit(fixInterval int64, maxLimitCount int64) *fixWinLimiter {
	return &fixWinLimiter{
		lock:           new(sync.Mutex),
		maxLimitCount:  maxLimitCount,
		fixInterval:    fixInterval,
	}
}

func (f *fixWinLimiter) IsPass() bool {
	f.lock.Lock()
	defer f.lock.Unlock()
	now := time.Now().Unix()
	if now - f.lastReqStartAt > f.fixInterval {
		f.currentCount = 0
		f.lastReqStartAt = now
		return true
	}
	if f.currentCount + 1 >= f.maxLimitCount {
		return false
	}
	f.currentCount++
	return true
}

// 测试
func main() {
    // 10s内只允许通过10次
	f1 := NewFixWinLimit(10, 10)
	for i := 0; i < 20; i++ {
		go func(index int) {
			if f1.IsPass() {
				fmt.Println(fmt.Sprintf("pass: %d", index))
			} else {
				fmt.Println(fmt.Sprintf("no pass: %d", index))
			}
		}(i)
	}
	time.Sleep(100 * time.Second)
}

滑动窗口

与固定窗口类似,只是以时间片划分,比如规定窗口一秒内只能请求5次,每次请求时会按照前一秒内的请求数量进行判断,超过则拒绝。

这种算法本质上只是把固定窗口计数限流的时间片变小了,仍然有可能出现固定窗口计数限流的临界点问题。

算法Demo:

type slidingWinLimiter struct {
	lock          *sync.Mutex
	limitInterval int64 // 限制的时间间隔
	lastReqAt     int64 // 上一次请求的时间

	winCount           []int64 // 窗口计数
	currentWinIndex    int64   // 当前窗口索引
	currentWinMaxLimit int64   // 当前窗口最大限制数
	winNum             int64   // 窗口数量
}

func NewSlidingWinLimiter(limitInterval int64, winMaxLimitCount int64, winNum int64) *slidingWinLimiter {
	return &slidingWinLimiter{
		lock:               new(sync.Mutex),
		limitInterval:      limitInterval,
		lastReqAt:          time.Now().Unix(),
		winCount:           make([]int64, winNum),
		currentWinMaxLimit: winMaxLimitCount,
		winNum:             winNum,
	}
}

func (s *slidingWinLimiter) IsPass() bool {
	s.lock.Lock()
	defer s.lock.Unlock()
	now := time.Now().Unix()
	// 每个窗口的时间间隔
	eachWinInterval := float64(s.limitInterval) / float64(s.winNum)
	// 判断前后两次请求的时间差是否在当前窗口内,不是则重置当前窗口,使用下一个窗口
	if float64(now - s.lastReqAt) > eachWinInterval {
		s.winCount[s.currentWinIndex] = 0
		s.currentWinIndex = (s.currentWinIndex + 1) % s.winNum
		s.lastReqAt = now
	}
	// 判断是否超过当前窗口的限制
	if s.winCount[s.currentWinIndex] >= s.currentWinMaxLimit {
		return false
	}
	s.winCount[s.currentWinIndex]++
	return true
}

func main() {
	f1 := NewSlidingWinLimiter(1, 10, 1)
	for i := 0; i < 20; i++ {
		go func(index int) {
			if f1.IsPass() {
				fmt.Println(fmt.Sprintf("pass: %d", index))
			} else {
				fmt.Println(fmt.Sprintf("no pass: %d", index))
			}
		}(i)
	}
	time.Sleep(100 * time.Second)
}

漏桶

控制流水,水(请求)持续加入桶中,底部以恒定速度流出,如果加水速度大于漏出速度,水则溢出,请求拒绝。即宽进严出,无论请求多少,请求的速率有多大,都按照固定的速率流出。

优点:能够确保资源不会瞬间耗尽,避免请求处理发生阻塞现象,还能保护被系统调用的外部服务,让其免受突发请求的冲击。

缺点:对于突发请求仍然会以一个恒定的速率进行处理,其灵活性会弱一些,容易发生突然请求超过漏桶容量,导致后续请求被直接丢弃。

一般用在对第三方提供服务的请求限制上,比如我们服务接入shopify的服务,为了不触发shopify的限流,就可以使用漏桶算法。

算法Demo:

type bucketLimiter struct {
	lock          *sync.Mutex
	lastReqAt     int64 // 单位:秒
	bucketCap     int64 // 桶的容量
	bucketBalance int64 // 桶的余量
	rate          int64 // 每时间单位内漏桶的漏出速率
}

func NewBucketLimiter(rate int64, bucketCap int64) *bucketLimiter {
	return &bucketLimiter{
		lock:          new(sync.Mutex),
		bucketCap:     bucketCap,
		bucketBalance: 0,
		rate:          rate,
		lastReqAt:     time.Now().Unix(),
	}
}

func (b *bucketLimiter) IsPass() bool {
	b.lock.Lock()
	defer b.lock.Unlock()
	now := time.Now().Unix()
	// 计算时间差内可通过的计数量
	diffInterval := now - b.lastReqAt
	diffCount := diffInterval * b.rate
	// 计算桶中剩余的量
	b.bucketBalance -= diffCount
	if b.bucketBalance < 0 {
		b.bucketBalance = 0
	}
	b.lastReqAt = now
	// 判断是否加水后是否溢出
	if b.bucketBalance+1 <= b.bucketCap {
		b.bucketBalance++
		return true
	}
	return false
}

// 测试
func main() {
    	// 单位时间内漏出速度是2,桶的容量是5
	f1 := NewBucketLimiter(2, 5)
	for i := 0; i < 20; i++ {
		go func(index int) {
			if f1.IsPass() {
				fmt.Println(fmt.Sprintf("pass: %d", index))
			} else {
				fmt.Println(fmt.Sprintf("no pass: %d", index))
			}
		}(i)
	}
	time.Sleep(100 * time.Second)
}

令牌桶

类似信号量,令牌桶会单独维护一个令牌的存储桶,为这个桶设置一个上限,同时又会有令牌持续将放入桶中,以应对一定的突发流量,比如桶的上限是1000,每秒持续放入1000个令牌,当前1s有800个请求发生并消耗令牌,由于每秒会放入1000个令牌,后1s就有1200个令牌可以被消耗,因此下一秒可以应对1200个请求。

优点:在限制平均流入速率的同时,还能面对突发请求,确保资源被充分利用,不会被闲置浪费。

缺点:舍弃处理速率的强控制力,如果某些功能依赖外部服务,可能会让外部服务无法承受压力,导致无法正常返回,还浪费此次获取的令牌。

漏桶和令牌桶的区别:漏桶是限制流出速度,令牌桶是限制流入速度。

算法Demo:

type tokenLimiter struct {
	lock           *sync.Mutex
	lastReqAt      int64 // 单位:秒
	availableToken int64 // 可用的令牌数量
	maxTokenLimit  int64 // 最大令牌数量
	rate           int64 // 每时间单位内token的加入速率
}

func NewTokenLimiter(rate int64, maxTokenLimit int64) *tokenLimiter {
	return &tokenLimiter{
		lock:           new(sync.Mutex),
		lastReqAt:      time.Now().Unix(),
		maxTokenLimit:  maxTokenLimit,
		availableToken: maxTokenLimit,
		rate:           rate,
	}
}

func (t *tokenLimiter) IsPass() bool {
	t.lock.Lock()
	defer t.lock.Unlock()
	now := time.Now().Unix()
	// 计算时间差内可通过的计数量
	diffInterval := now - t.lastReqAt
	diffCount := diffInterval * t.rate
	// 把可通过的量加入令牌桶里
	t.availableToken += diffCount
	if t.availableToken > t.maxTokenLimit {
		t.availableToken = t.maxTokenLimit
	}
	t.lastReqAt = now
	// 判断是否有令牌可取
	if t.availableToken > 0 {
		t.availableToken--
		return true
	}
	return false
}

// 测试
func main() {
    	// 单位时间内漏出速度是2,桶的容量是5
	f1 := NewTokenLimiter(2, 5)
	for i := 0; i < 20; i++ {
		go func(index int) {
			if f1.IsPass() {
				fmt.Println(fmt.Sprintf("pass: %d", index))
			} else {
				fmt.Println(fmt.Sprintf("no pass: %d", index))
			}
		}(i)
	}
	time.Sleep(100 * time.Second)
}

容错

一旦发现上游服务调用失败,为了进一步减少错误的影响,可以设置容错策略

  • FailFast 快速失败:当消费者调用远程服务失败时,立即报错,消费者只发起一次调用请求。

  • FailOver 失败自动切换:当消费者调用远程服务失败时,重新尝试调用服务,重试的次数一般需要指定,防止无限次重试。

  • FailSafe 失败安全:当消费者调用远程服务失败时,直接忽略,请求正常返回报成功。一般用于可有可无的服务调用。

  • FailBack 失败自动恢复:当消费者调用远程服务失败时,定时重发请求。一般用于消息通知。

  • Forking 并行调用:消费者同时调用多个远程服务,任一成功响应则返回。

熔断

作用:防止下游服务不断地尝试可能超时和失败的服务,能达到应用程序执行而不必等待上游服务修正错误;下游服务可以自我诊断上游服务的错误是否已修正,如果没有,则不放量,如果有,则会慢慢增加请求,再次尝试调用。

上游服务是指那些不依赖于任何其他服务的服务,而下游服务依赖于上游服务的服务。

熔断一般分为三种状态:

  • Closed关闭:服务正常时,熔断处于关闭状态

  • Open开启:当我们设定10s的滑动窗口内错误率达90%,则从Closed变为Open状态

  • HalfOpen半开启:再经过10s的窗口期,此时从Open状态转为HalfOpen状态,按照 0.5 * (Now() - Start()) / Duration 的公式放量,直到成功率变为90%,转为Closed状态,否则转为Open状态。

滑动窗口的时间不能设置太长,否则熔断恢复时间也会变长;错误统计只统计系统异常不通知业务异常。

降级

作用:解决资源不足喝访问量增加的矛盾。在有限资源下,放弃部分无关紧要的服务,保证主业务流程能平稳运行。

降级一般可以是将部分功能关闭,简化,或者将强一致性变成最终一致性。

负载均衡

常用算法

  • 随机轮询:在可用的服务中,随机选择一个进行调用,因为每个随机数生成的概率是一致的,所以可以保证每个服务访问概率一致,一般用在服务性能差别不大,请求量远超服务节点数量的场景,保证各个服务被访问的概率相同

  • 顺序轮询:按请求的顺序分配给各个服务器,适用于各台服务器性能相同,适用于服务各个实例性能平等且无状态的场景,如果每个服务性能不一致,可以改成动态加权轮询的方式

  • 加权轮询:给各个服务器附上权重值,按权重的高低分配请求,适用于各台服务器性能不同,性能高的服务器权重也高

    静态加权轮询:服务启动时就设定好权重值,按权重值进行轮询

    动态加权轮询:需要客户端维护每个服务性能统计快照,并且每个一段时间更新这个快照,根据这个快照动态更新权重值,按权重值进行轮询,缺点是统计这些数据的时候存在一定的滞后性;

  • 加权随机轮询:随机 + 二分查找的方式负载均衡,时间复杂度为O(logn)

  • 最少链接:将请求发送给当前最少连接数的服务器上,一般来说,连接数少,说明服务空闲,向空闲的服务发送请求,获取更快响应速度,适用于服务节点性能差异较大,不好设置权重值的场景;

  • 加权最少链接:在最少连接的基础上,根据服务器的性能为每台服务器分配权重,再根据权重计算出每台服务器能处理的连接数

  • 最小响应时间轮询:客户端需要记录每个服务所需的请求,得出平均的响应时间,然后根据响应时间选择最小响应时间,进行轮询,缺点是统计这些数据的时候存在一定的滞后性;

  • IP地址哈希:哈希均匀分布

  • 二次随机选择轮询:适合后端节点权重一致的情况,通过两次随机算法,获取到两个节点,对比节点CPU等信息,选择最优节点;(比较流行)

  • 会话保持:根据客户端IP或cookie进行会话保持,同一个客户端每次选取后端节点的IP保持一致,适用于节点保持登录验证会话的场景,比较少用。

负载不均衡可能的原因

  • 长连接 + 多路复用

  • 开启会话保持

  • 服务端健康检测异常,导致客户端误以为服务端出现问题,没有均衡请求

通常的解决办法是根据服务端状态加权

探活

负载均衡器上面一般会挂一个服务的多个复制集,进行流量的负载均衡,因此需要检查这多个复制集的健康情况,保证流量能正确路由到可用节点上。

主动健康检查

设定一定时间间隔内对各个复制集执行ping操作,一般在获取节点数量过少的场景下才触发,避免长时间频繁的ping操作增加节点负担。

比如通过当前节点数与15分钟前的节点数的比较,当小于80%时触发主动健康检查。

被动健康检查

通过检查节点真实流量的响应结果,判断节点是否正常

服务注册与发现

调用模式

  • 调用方发现模式

    由一个服务发现系统和其提供得SDK组成,SDK由调用方使用,同时SDK也可以提供负载均衡和故障转移等功能;代表例子:Eureka

    各个调用方在启动时会向服务发现系统注册其实例信息,比如ip、port、serviceName等,待到调用方需要调用其他服务时,便根据被调用方服务的名称去服务发现服务查询对应的ip和port,发起请求。

    这种方式有个缺点,就是调用方可能有多个实例,而SDK只能针对一个调用方做负载均衡,多实例下仍然有可能导致请求负载不均衡;可能还需要准备多种语言的SDK。

  • 服务端发现模式

    由一个专门的load balancer服务与服务发现系统配合,该load balancer服务会实时订阅服务发现系统中各个节点的信息,起到一个反向代理的作用,将收到的请求分发到对应的服务。代表例子:kube-proxy,在Kubernetes中,各个节点上会运行一个kube-proxy,kube-proxy会实时watch Service和Endpoint对象,当其配置发生变化后,会在各自的Node节点设置相关的iptables或IPVS规则,便于Pod内的服务通过service的clusterIP,经过iptables或IPCS设置的规则进行路由和转发。

    这种方式的好处是调用方无需感知服务发现系统,所有服务发现相关的功能都被隔离在load balancer和服务发现系统之间,但是会多一层转发,也就多一次延迟,多一次发生故障的机会。

基本功能

  1. 提供服务地址与域名的映射注册、查找和更新

    订阅机制有三种:

    • 服务发现系统push推送,可以基于socket长连接推送,比如Zookeeper;基于HTTP连接的Long Polling,将各个服务的注册信息推送给各个节点,但是这种长连接的方式会存在消息丢失问题。

    • 调用方SDK定时轮询,向服务发现系统拉取各个节点的注册信息,但可能存在延迟问题

    • 以上两种方式相结合,比如Consul,调用方和服务发现系统间会建立一个最长30s的HTTP长连接,如果发生变更,调用方就会立即收到推送,如果超过30s,调用方会立即建立新连接,开始新一轮订阅。

  2. 提供多种负载均衡方案

  3. 健康检查,比如心跳检测

    • 服务主动探活:服务注册到注册中心后,定时发送续租请求到注册中心,表明自己存活

      优点:该方案可最大程度避免IP重用导致的节点在旧服务依然存活的问题(比如k8s环境)

      缺点:造成注册中心写操作变多,特别是在强一致性的注册中心上,频繁的节点变更会导致产生大量的通知事件,在同步到多个注册中心复制集时性能不佳;另外,仍然可能发生服务虽无法对外提供服务了,但仍然可以发送续租请求;面对不同的客户端需要提供不同语言的SDK

    • 注册中心主动探活:服务提供健康检查接口,比如/ping,注册中心定时访问验证节点存活;k8s的Pod探针机制

      优点:一定程度上解决服务主动探活不能说明服务健康的问题

      缺点:IP重用问题,比如A、B两个服务都有相同的端口和/ping接口做健康检查,但是当发生服务替换时,无法区分是哪个服务,除非加上名称检查

    • 服务外挂一个负载均衡器实现服务探活、与注册中心的通信

      优点:注册中心和服务均不需要主动探活,均交由负载均衡器实现

      缺点:需要外挂的负载均衡器,增加成本

  4. 注册中心需要保证CP或者AP,一般来说,注册中心对一致性C的要求不是很高,因为节点的注册和反注册后通知到客户端也需要时间;对可用性的优先级较高。

  5. 将上述功能包装成SDK,简化使用

可能遇到的问题

注册中心

  • 注册中心挂掉时,此时可以持久化之前注册的Provider节点信息,并在重启后进入保护模式一段时间,在此期间先不剔除不健康的Provider节点(因为宕机期间Provider心跳无法上报),否则可能导致在一个TTL内大量的Provider节点失效;

  • 需要网络闪断保护,当监测到大面积Provider心跳没有上报,则自动进入保护模式,该模式下不会剔除因心跳上报失败的Provider;

  • 注册中心故障时,服务注册功能失效,服务也无法执行扩容操作,可以在服务内缓存服务注册表,保证服务可通信;

调用方

  • 当调用方访问到某个服务发现系统不可用时,可以立即切换到下一个节点尝试;

  • 调用方优先使用缓存的服务注册表,当接收到的服务注册表的节点过少时,放弃使用;

  • 如果调用方重启了,内存中的数据不存在,可以走本地配置降级;

  • 通过在load balancer中加入被动健康检查和主动健康检查来剔除服务注册表中失效的节点,保证服务注册表的可用性

  • 参考Service Mesh的Envoy设计,不完全信任注册中心推送过来的服务注册表;

  • 面对节点和服务频繁变更,导致广播风暴的场景,可以通过合并设定时间内产生的消息后再进行推送,目的是为了减少广播次数,但是这样会影响消息的时效性,要注意设定的时间不宜过长。

常见的注册中心区别

这里再说下K8s提供的默认服务发现(1.12后默认使用coreDNS),通过为Pod挂一个 Service 或者 Pod 定义了hostname + subdomain,CoreDNS也会为其生成Pod的 DNS A记录,然后 CoreDNS 会把 Service 或 Pod 产生的DNS记录写入 CoreDNS 的 cache 或者 ETCD 中,同时在Pod的/etc/resolv.conf文件中添加CoreDNS服务的访问配置,Pod即可通过 service 名称进行访问。

CoreDNS会监听集群内所有Service API,当服务不可用时移除记录,在新服务创建时插入新记录,这些记录会存储在CoreDNS的cache或者ETCD中。

参考:https://github.com/kubernetes/dns/blob/master/docs/specification.md

具体例子查看Kubernetes那篇文章Service服务发现部分的内容。

Last updated