微服务架构(geek time)服务注册与发现 负载均衡 熔断 降级 限流 超时控制 调用第三方

服务注册与发现

原因

你的服务部署在不同的机房、不同的机器上,监听不同的端口,需要知道具体的定位信息。

服务上线

服务端启动的时候,需要往注册中心里注册自身的信息,主要是定位信息。

注册成功之后,注册中心和服务端要保持心跳。

客户端第一次发起对某个服务的调用之前,要先找注册中心获得所有可用服务节点列表,随后客户端会在本地缓存每个服务对应的可用节点列表。

客户端和注册中心要保持心跳和数据同步,后续服务端有任何变动,注册中心都会通知客户端,客户端会更新本地的可用节点列表。

客户端发送请求。

服务端返回响应。

img

服务下线

服务端通知注册中心自己准备下线了。

注册中心通知客户端某个服务端下线了。

客户端收到通知之后,新来的请求就不会再给该服务端发过去。

服务端等待一段时间之后,暂停服务并下线。

img

服务端崩溃检测

注册中心在没收到服务端的心跳,就要立刻通知客户端该服务端已经不可用了,那么客户端就不会再发请求过来。

注册中心还要继续往服务端发心跳,比如重试三次,而且重试间隔是十秒钟,发了一段时间之后发现心跳还是失败就不再发了,这意味着注册中心认定服务端彻底崩溃了。在彻底崩溃的场景下,注册中心不需要再次通知客户端,因为在之前注册中心就已经通知过了。

客户端容错

服务端崩溃的容错

在服务端节点崩溃之后,到注册中心发现,再到客户端收到通知,是存在一段延时的,这个延时是能算出来的。在这段延时内,客户端发送请求给这个服务端节点都会失败。这个时候需要客户端来做一些容错。

一般的策略是客户端在发现调不通之后,应该尝试换另外一个节点进行重试。如果客户端上的服务发现组件或者负载均衡器能够根据调用结果来做一些容错的话,那么它们应该要尝试将这个节点挪出可用节点列表,在短时间内不要再使用这个节点了。后面再考虑将这个节点挪回去。

如果注册中心最终发现服务端崩溃,然后通知了客户端,那么客户端就不用放回去了。等到注册中心发现服务端再次恢复了,那么注册中心会通知客户端,此时客户端更新可用节点列表就可以了。

客户端和服务端间不通的容错

但是有一种情况是需要客户端主动检测的。这种情况就是服务端节点还活着,注册中心也还活着,唯独客户端和服务端之间的网络有问题,导致客户端调用不通。

在这种情况下,类似于注册中心和服务端心跳失败,客户端也要朝着那个疑似崩溃的服务端节点继续发送心跳。如果心跳成功了,就将节点放回可用列表。如果连续几次心跳都没有成功,那么就不用放回去了,直接认为这个节点已经崩溃了。

客户端和注册中心不通的容错

这个分析也适用于客户端和注册中心心跳失败的场景。很显然在这种情况下,客户端可以直接使用本地缓存的可用节点列表,而后如果调不通了则处理方式完全一样。但是不同的是,如果客户端长期连不上注册中心,那么客户端本身应该考虑整个退出。

一个注册中心出故障之后你排查和后续优化的案例

案例背景:某天某公司的某个用Go语言编写的微服务项目突然出现了大量的服务调用超时和错误。通过查看日志和监控信息,初步怀疑是注册中心存在故障,导致服务之间无法正常调用。

  1. 故障排查

    1.1 查看各个微服务的日志,发现服务间调用时出现大量找不到服务提供者的错误信息,初步判断可能是由于注册中心故障导致。

    1.2 查看注册中心的状态和日志信息,发现注册中心CPU使用率持续100%,内存占用率也较高。

    1.3 查看注册中心的GC(垃圾回收)情况,发现频繁进行Full GC,导致注册中心频繁处于停顿状态,无法正常提供服务。

    1.4 查看注册中心的注册服务实例数量,发现注册服务实例数量远超预期,推测可能是服务注册重复或者服务未正确下线导致。

  2. 故障处理

    2.1 优化注册中心的JVM参数,增加堆内存大小,调整新生代、老年代比例,优化GC参数,尽量减少Full GC的频率和影响。

    2.2 清理注册中心的无效服务实例,通过查看各服务的健康状态和实际运行情况,手动剔除僵尸实例。

    2.3 通知各服务团队检查服务注册和注销逻辑,确保服务在启动时正确注册,关闭时正确注销。

    2.4 重启注册中心,恢复服务正常运行。

  3. 后续优化

    3.1 升级注册中心的硬件资源,提高注册中心的处理能力。

    3.2 对注册中心进行集群化部署,提高注册中心的高可用性。

    3.3 对注册中心实施监控预警,通过监控CPU、内存、GC、服务实例数量等关键指标,实时关注注册中心的运行状态,并在发现异常时及时报警。

    3.4 设置服务实例的有效期,对于长时间未发送心跳的服务实例,自动剔除,防止僵尸实例影响注册中心运行。

    3.5 定期对注册中心进行压力测试和容量规划,确保注册中心能够承载业务的发展。

    3.6 对Go微服务进行优化,提高服务的健壮性,例如增加熔断、降级和重试等机制,降低注册中心故障对业务的影响。

负载均衡

正向代理:隐藏客户端信息
反向代理:隐藏服务端信息

静态负载均衡算法

随机和加权法

随机法是最简单粗暴的负载均衡算法。
如果没有配置权重的话,所有的服务器被访问到的概率都是相同的。如果配置权重的话,权重越高的服务器被访问的概率就越大。
未加权重的随机算法适合于服务器性能相近的集群,其中每个服务器承载相同的负载。加权随机算法适合于服务器性能不等的集群,权重的存在可以使请求分配更加合理化。
不过,随机算法有一个比较明显的缺陷:部分机器在一段时间之内无法被随机到,毕竟是概率算法,就算是大家权重一样,也可能会出现这种情况。
于是,轮询法来了!

轮询和加权轮询法

轮询法是挨个轮询服务器处理,也可以设置权重。
如果没有配置权重的话,每个请求按时间顺序逐一分配到不同的服务器处理。如果配置权重的话,权重越高的服务器被访问的次数就越多。
未加权重的轮询算法适合于服务器性能相近的集群,其中每个服务器承载相同的负载。加权轮询算法适合于服务器性能不等的集群,权重的存在可以使请求分配更加合理化。

一致性 Hash 法

相同参数的请求总是发到同一台服务器处理,比如同个 IP的请求。

动态负载均衡

在实时中进行适应,当分发请求时会考虑到活跃的性能指标和服务器条件。

最小连接法

这些连接算法将每个新请求发送到当前活跃连接或打开请求最少的服务器,这需要实时跟踪每个后端服务器上的正在进行的连接数量,优点是新请求会根据剩余容量灵活地路由,然而,如果连接不均匀地堆积,负载可能不经意地集中在某些服务器上。

最快响应时间

最少响应时间算法将传入请求发送到当前延迟最低或响应时间最快的服务器,每个服务器的延迟都会持续被测量并被考虑在内。这种方法是高度自适应和反应迅速的,然而它需要持续的监测,这会带来显著的开销并增加复杂性,它也没有考虑每个服务器已经有多少现有的请求。

总的来说,简单的静态算法和更自适应的动态算法之间存在明显的权衡。我们需要考虑特定的性能目标、能力和约束来选择负载均衡策略,像轮询这样的静态算法很适合无状态的应用程序,动态算法有助于优化大型复杂应用的响应时间和可用性

熔断

一个服务作为调用方调用另一个服务时,为了防止被调用服务出现问题进而导致服务出现问题,所以调用服务需要进行自我保护,而保护的常用手段

在发起服务调用的时候,如果被调用方返回的错误率超过一定的阈值,那么后续的请求将不会真正发起请求,而是在调用方直接返回错误;

chaos框架的熔断器

在这个服务里,熔断策略是基于gobreaker库实现的。具体的熔断策略如下:

  1. MaxRequests:半开状态下的最大请求数。当断路器处于半开状态时,如果连续成功请求数达到MaxRequests,则断路器关闭,否则继续保持打开状态。在这个服务里,MaxRequests被设置为2。

  2. Interval:断路器关闭状态下的重置周期。在这个服务里,Interval设置为180秒。

  3. Timeout:断路器从打开状态切换到半开状态所需的时间。在这个服务里,Timeout设置为60秒。

  4. ReadyToTrip:定义了一个函数,用于判断是否需要触发熔断。在这个服务里,该函数计算失败请求比例,并在总请求数大于等于5且失败比例大于等于0.6时触发熔断。

  5. OnStateChange:定义了一个函数,用于监听断路器状态变化。在这个服务里,该函数会记录断路器状态变化的日志。

根据上述策略,当某个服务的失败请求比例达到一定阈值时,熔断器会打开,进入熔断状态。在熔断状态下,所有对该服务的请求都会被直接拒绝,避免对故障服务的持续访问。经过一段时间(Timeout)后,熔断器会进入半开状态,尝试发送一定数量(MaxRequests)的请求。
如果请求成功,则关闭熔断器,恢复正常访问;如果请求失败,则继续保持打开状态,直到下一次尝试。在整个过程中,熔断策略会根据服务的实际情况动态调整,有效地避免了服务故障导致的系统崩溃。

有空去看gozero的熔断器实现

熔断指标

服务的响应时间

比如说最简单的熔断策略就是根据响应时间来进行。当响应时间超过阈值一段时间之后就会触发熔断。我一般会根据业务情况来选择这个阈值,例如,如果产品经理要求响应时间是 1s,那么我会把阈值设定在 1.2s。如果响应时间超过 1.2s,并且持续三十秒,就会触发熔断。在触发熔断的情况下,新请求会被拒绝,而已有的请求还是会被继续处理,直到服务恢复正常。

组件是否可用

我还设计过一个很有趣的熔断方案。我的一个接口并发很高,对缓存的依赖度非常严重。所以我的熔断策略是要是缓存不可用,比如说 Redis 崩溃了,那么我就会触发熔断。这里如果我不熔断的话,请求会因为 Redis 崩溃而全部落到 MySQL 上,基本上会压垮 MySQL。在触发熔断之后,我会额外开启一个线程(如果是 Go 就换成 Goroutine)持续不断地 ping Redis。如果 Redis 恢复了,那么我就会退出熔断状态,新来的请求就不会被拒绝了。

降级

跨服务降级

跨服务降级的措施是很粗暴的,常见的做法有三个。

  • 整个服务停掉,例如前面提到的停掉退款服务。(自己的服务,比如一个免费服务和一个付费服务是一起部署的,可以停掉免费服务把资源都留给付费服务使用)
  • 停掉服务的部分节点,例如十个节点,停掉其中五个节点,这五个节点被挪作他用。
  • 停止访问某些资源。例如日志中心压力很大的时候,发信号给某些不重要的服务,让它们停止上传日志,只在本地保存日志。

服务内降级

返回默认值,这算是最简单的一种状况。

  • 禁用可观测性组件,正常来说在业务里面都充斥了各种各样的埋点。这些埋点本身其实是会带来消耗的,所以在性能达到瓶颈的时候,就可以考虑停用,或者降低采样率。
  • 同步转异步,即正常情况下,服务收到请求之后会立刻处理。但是在降级的情况下,服务在收到请求之后只会返回一个代表“已接收”的响应。后续服务会异步地开启线程来处理,或者依赖于定时任务来处理。(这个自己的服务可以用到)
  • 简化流程,如果你处理一个请求需要很多步骤,后续如果有一些步骤不关键的话,可以考虑不执行,或者异步执行。例如在内容生产平台,一般新内容要被推送到推荐系统里面。那么在降级的情况下你可以不推,而后可以考虑异步推送过去,也可以考虑等系统恢复之后再推送过去。

限流

原因

尽管云原生网关里有统一入口的限流(根据ip、userID来控制),但是有的微服务需要有自己的限流策略(比如根据不同的算法任务、不同的子产品来限制),所以封装了一个限流器公共包,可以在多个微服务中复用这个功能。直接原因是有一次某个子功能流量激增,大量任务失败。

关键步骤:

  • 实现限流策略,例如基于令牌桶或漏桶
  • 配置和初始化,微服务启动时加载配置和初始化限流器

限流对象

针对ip限流,例如1s限制50个请求,考虑到公共ip的情况;
针对某个算法任务,不限制vip用户,普通用户1s限制创建10个创建任务的请求。

限流后的做法

在这里插入图片描述

目前的做法是限流了就直接拒绝,抛出错误提示,还有其他的做法:

  • 同步阻塞等待一段时间。如果是偶发性地触发了限流,那么稍微阻塞等待一会儿,后面就有极大的概率能得到处理。比如说限流设置为一秒钟 100 个请求,恰好来了 101 个请求。多出来的一个请求只需要等一秒钟,下一秒钟就会被处理。但是要注意控制住超时,也就是说你不能让人无限期地等待下去。
  • 同步转异步。这里我们又一次看到了这个手段,它是指如果一个请求没被限流,那就直接同步处理;而如果被限流了,那么这个请求就会被存储起来,等到业务低峰期的时候再处理。这个其实跟降级差不多。(TODO 研究到降级时再过来看一下)
  • 调整负载均衡算法(用redis的话似乎跟负载均衡没关系,如果是在网关里做限流是可以调整负载均衡器的)。如果某个请求被限流了,那么就相当于告诉负载均衡器,应该尽可能少给这个节点发送请求。我在熔断里面给你讲过类似的方案。不过在熔断里面是负载均衡器后续不再发请求,而在限流这里还是会发送请求,只是会降低转发请求到该节点的概率。调整节点的权重就能达成这种效果。

怎么确定限流阈值

观测业务性能数据

我们公司有完善的监控,所以我可以通过观测到的性能数据来确定阈值。比如说观察线上的数据,如果在业务高峰期整个集群的 QPS 都没超过 1000,那么就可以考虑将阈值设定在 1200,多出来的 200 就是余量。
不过这种方式有一个要求,就是服务必须先上线,有了线上的观测数据才能确定阈值。并且,整个阈值很有可能是偏低的。因为业务巅峰并不意味着是集群性能的瓶颈。如果集群本身可以承受每秒 3000 个请求,但是因为业务量不够,每秒只有 1000 个请求,那么我这里预估出来的阈值是显著低于集群真实瓶颈 QPS 的。

压测

不过我个人觉得,最好的方式应该是在线上执行全链路压测,测试出瓶颈。即便不能做全链路压测,也可以考虑模拟线上环境进行压测,再差也应该在测试环境做一个压力测试。

借鉴链路上的其他服务

不过如果真的做不了,或者来不及,或者没资源,那么还可以考虑参考类似服务的阈值。比如说如果 A、B 服务是紧密相关的,也就是通常调用了 A 服务就会调用 B 服务,那么可以用 A 已经确定的阈值作为 B 的阈值。又或者 A 服务到 B 服务之间有一个转化关系。比如说创建订单到支付,会有一个转化率,假如说是 90%,如果创建订单的接口阈值是 100,那么支付的接口就可以设置为 90。

手动计算

实在没办法了,就只能手动计算了。也就是沿着整条调用链路统计出现了多少次数据库查询、多少次微服务调用、多少次第三方中间件访问,如 Redis,Kafka 等。举一个最简单的例子,假如说一个非常简单的服务,整个链路只有一次数据库查询,这是一个会回表的数据库查询,根据公司的平均数据这一次查询会耗时 10ms,那么再增加 10 ms 作为 CPU 计算耗时。也就是说这一个接口预期的响应时间是 20ms。如果一个实例是 4 核,那么就可以简单用 1000ms÷20ms×4=200 得到阈值。

四种静态限流算法

令牌桶

系统会以一个恒定的速率产生令牌,这些令牌会放到一个桶里面,每个请求只有拿到了令牌才会被执行。每当一个请求过来的时候,就需要尝试从桶里面拿一个令牌。如果拿到了令牌,那么请求就会被处理;如果没有拿到,那么这个请求就被限流了。(当令牌桶已满时,新生成的令牌会被丢弃,不会增加桶中的令牌数量。)
你需要注意,本身令牌桶是可以积攒一定数量的令牌的。比如说桶的容量是 100,也就是这里面最多积攒 100 个令牌。那么当某一时刻突然来了 100 个请求,它们都能拿到令牌。
在这里插入图片描述

漏桶

漏桶是指当请求以不均匀的速度到达服务器之后,限流器会以固定的速率转交给业务逻辑。
某种程度上,你可以将漏桶算法看作是令牌桶算法的一种特殊形态。你将令牌桶中桶的容量设想为 0,就是漏桶了。
在这里插入图片描述
在这里插入图片描述
所以你可以看到,在漏桶里面,令牌产生之后你就需要取走,没取走的话也不会积攒下来。因此漏桶是绝对均匀的,而令牌桶不是绝对均匀的。

固定窗口与滑动窗口

固定窗口是指在一个固定时间段,只允许执行固定数量的请求。比如说在一秒钟之内只能执行 100 个请求。滑动窗口类似于固定窗口,也是指在一个固定时间段内,只允许执行固定数量的请求。区别就在于,滑动窗口是平滑地挪动窗口,而不像固定窗口那样突然地挪动窗口。假设窗口大小是一分钟。此时时间是 t1,那么窗口的起始位置是 t1-1 分钟。过了 2 秒之后,窗口大小依旧是 1 分钟,但是窗口的起始位置也向后挪动了 2 秒,变成了 t1 - 1 分钟 + 2 秒。这也就是滑动的含义。
在这里插入图片描述

手写限流算法

参考:
https://blog.csdn.net/z3551906947/article/details/140477024,并且里面阐述了各个算法的优缺点,漏桶是可以用来处理突发流量的。

令牌桶

package limiter

import (
    "sync"
    "time"
)

// TokenBucketLimiter 令牌桶限流器
type TokenBucketLimiter struct {
    capacity      int        // 容量
    currentTokens int        // 令牌数量
    rate          int        // 发放令牌速率/秒
    lastTime      time.Time  // 上次发放令牌时间
    mutex         sync.Mutex // 避免并发问题
}

// NewTokenBucketLimiter 创建一个新的令牌桶限流器实例。
func NewTokenBucketLimiter(capacity, rate int) *TokenBucketLimiter {
    return &TokenBucketLimiter{
       capacity:      capacity,
       rate:          rate,
       lastTime:      time.Now(),
       currentTokens: 0, // 初始化时桶中没有令牌
    }
}

// TryAcquire 尝试从令牌桶中获取一个令牌。
func (l *TokenBucketLimiter) TryAcquire() bool {
    l.mutex.Lock()
    defer l.mutex.Unlock()

    now := time.Now()
    interval := now.Sub(l.lastTime) // 计算时间间隔

    // 如果距离上次发放令牌超过 1/rate 秒,则发放新的令牌
    if float64(interval) >= float64(time.Second)/float64(l.rate) {
       // 计算应该发放的令牌数量,但不超过桶的容量
       newTokens := int(float64(interval)/float64(time.Second)* l.rate) 
       l.currentTokens = minInt(l.capacity, l.currentTokens+newTokens)

       // 更新上次发放令牌的时间
       l.lastTime = now
    }

    // 如果桶中没有令牌,则请求失败
    if l.currentTokens == 0 {
       return false
    }

    // 桶中有令牌,消费一个令牌
    l.currentTokens--

    return true
}

// minInt 返回两个整数中的较小值。
func minInt(a, b int) int {
    if a < b {
       return a
    }
    return b
}

func TestName(t *testing.T) {
    tokenBucket := NewTokenBucketLimiter(5, 10)
    for i := 0; i < 10; i++ {
        fmt.Println(tokenBucket.TryAcquire())
    }
    time.Sleep(100 * time.Millisecond)
    fmt.Println(tokenBucket.TryAcquire())
}

漏桶

package limiter

import (
	"fmt"
	"math"
	"sync"
	"testing"
	"time"
)

// LeakyBucketLimiter 漏桶限流器
type LeakyBucketLimiter struct {
	capacity     int        // 桶容量
	currentLevel int        // 当前水位
	rate         int        // 水流速度/秒
	lastTime     time.Time  // 上次放水时间
	mutex        sync.Mutex // 避免并发问题
}

// NewLeakyBucketLimiter 初始化漏桶限流器
func NewLeakyBucketLimiter(capacity, rate int) *LeakyBucketLimiter {
	return &LeakyBucketLimiter{
		capacity:     capacity,
		currentLevel: 0, // 初始化时水位为0
		rate:         rate,
		lastTime:     time.Now(),
	}
}

// TryAcquire 尝试获取处理请求的权限
func (l *LeakyBucketLimiter) TryAcquire() bool {
	l.mutex.Lock() // 直接获取写锁
	defer l.mutex.Unlock()

	// 如果上次放水时间距今不到 1/rate 秒,不需要放水
	now := time.Now()
	interval := now.Sub(l.lastTime)

	// 计算放水后的水位
	if float64(interval) >= float64(time.Second)/float64(l.rate) {
		l.currentLevel = int(math.Max(0, float64(l.currentLevel)-float64(interval)/float64(time.Second)*float64(l.rate)))
		l.lastTime = now
	}
	// 尝试增加水位
	if l.currentLevel < l.capacity {
		l.currentLevel++
		return true
	}
	return false
}

func TestName(t *testing.T) {
	tokenBucket := NewLeakyBucketLimiter(5, 10)
	for i := 0; i < 10; i++ {
		fmt.Println(tokenBucket.TryAcquire())
	}
	time.Sleep(100 * time.Millisecond)
	fmt.Println(tokenBucket.TryAcquire())
}

固定窗口

package main

import (
	"sync"
	"time"
)

// FixedWindowRateLimiter 定义固定窗口限流器
type FixedWindowRateLimiter struct {
	mu           sync.Mutex
	maxRequests  int
	requestCount int
	window       time.Time // 窗口的起始点,每个窗口长度1s
}

// NewFixedWindowRateLimiter 创建一个新的固定窗口限流器实例
func NewFixedWindowRateLimiter(maxRequests int) *FixedWindowRateLimiter {
	return &FixedWindowRateLimiter{
		maxRequests: maxRequests,
		window:      time.Now().Truncate(time.Second),
	}
}

// TryAcquire 尝试获取请求许可
func (f *FixedWindowRateLimiter) TryAcquire() bool {
	f.mu.Lock()
	defer f.mu.Unlock()

	// 检查是否需要重置窗口
	if time.Now().After(f.window.Add(time.Second)) {
		f.requestCount = 0
		f.window = time.Now().Truncate(time.Second)
	}

	// 检查是否达到最大请求次数
	if f.requestCount >= f.maxRequests {
		return false
	}

	// 请求成功,递增计数器
	f.requestCount++
	return true
}

func main() {
	limiter := NewFixedWindowRateLimiter(5)
	for i := 0; i < 10; i++ {
		if limiter.TryAcquire() {
			fmt.Println("请求通过")
		} else {
			fmt.Println("请求被拒绝")
		}
		time.Sleep(100 * time.Millisecond)
	}
}

滑动窗口

package main

import (
	"sync"
	"time"
)

// SlidingWindowRateLimiter 定义滑动窗口限流器
type SlidingWindowRateLimiter struct {
	mu           sync.Mutex
	maxRequests  int
	windowSize  time.Duration // 窗口长度
	windows     []int
	windowIndex int
	currentTime time.Time //   上个滑窗的起始点
}

// NewSlidingWindowRateLimiter 创建一个新的滑动窗口限流器实例
func NewSlidingWindowRateLimiter(maxRequests int, windowSize time.Duration) *SlidingWindowRateLimiter {
	numWindows := int(windowSize.Seconds())
	return &SlidingWindowRateLimiter{
		maxRequests:  maxRequests,
		windowSize:   windowSize,
		windows:      make([]int, numWindows),
		currentTime:  time.Now().Truncate(time.Second),
		windowIndex:  0,
	}
}

// TryAcquire 尝试获取请求许可
func (s *SlidingWindowRateLimiter) TryAcquire() bool {
	s.mu.Lock()
	defer s.mu.Unlock()

	// 更新当前时间
	currentTime := time.Now().Truncate(time.Second)

	// 检查是否需要更新窗口
	if currentTime.After(s.currentTime.Add(s.windowSize)) {
		s.currentTime = currentTime
		s.windowIndex = 0
	} else if currentTime.After(s.currentTime.Add(time.Second)) {
		s.windowIndex = (s.windowIndex + 1) % len(s.windows)
	}

	// 清除过期窗口
	for i := range s.windows {
		if currentTime.Before(s.currentTime.Add(time.Duration(i+1)*time.Second)) {
			break
		}
		s.windows[i] = 0
	}

	// 检查是否达到最大请求次数
	totalRequests := 0
	for _, count := range s.windows {
		totalRequests += count
	}
	if totalRequests >= s.maxRequests {
		return false
	}

	// 请求成功,递增计数器
	s.windows[s.windowIndex]++
	return true
}

func main() {
	limiter := NewSlidingWindowRateLimiter(5, 10*time.Second)
	for i := 0; i < 10; i++ {
		if limiter.TryAcquire() {
			fmt.Println("请求通过")
		} else {
			fmt.Println("请求被拒绝")
		}
		time.Sleep(100 * time.Millisecond)
	}
}

分布式限流的具体实现

从单机或者集群的角度看,可以分为单机限流或者集群限流。集群限流一般需要借助 Redis 之类的中间件来记录流量和阈值。换句话说,就是你需要用 Redis 等工具来实现前面提到的限流算法。当然如果是利用网关来实现集群限流,那么可以摆脱 Redis。

超时控制

目标

超时控制有两个目标,
一是确保客户端能在预期的时间内拿到响应。这其实是用户体验一个重要理念“坏响应也比没响应好”的体现。
在这里插入图片描述

二是及时释放资源。这其中影响最大的是线程和连接两种资源。
释放线程:在超时的情况下,客户端收到了超时响应之后就可以继续往后执行,等执行完毕,这个线程就可以被用于执行别的业务。而如果没有超时控制,那么这个线程就会被一直占有。而像 Go 这种语言,协程会被一直占有。
释放连接:连接可以是 RPC 连接,也可以是数据库连接。类似的道理,如果没有拿到响应,客户端会一直占据这个连接。及时释放资源是提高系统可用性的有效做法,现实中经常遇到的一类事故就是因为缺乏超时控制引起了连接泄露、线程泄露。
在这里插入图片描述

确定超时时间

比如说大厂的 App 首页接口响应时间都有硬性规定。就像某司的要求是 50ms,也就是说不管你后端多复杂,不管你后面调用多少个服务,你的响应时间都必须控制在 50ms 以内。我后面会再深入讨论这个问题,它是你刷亮点的关键。

根据用户体验

一般的做法就是根据用户体验来决定超时时间。比如说产品经理认为这个用户最多只能在这里等待 300ms,那么你的超时时间就最多设置为 300ms。但如果仅仅依靠用户体验来决定超时时间也是不现实的,比如说当你去问产品经理某个接口对性能要求的时候,他让你看着办。那么这个时候你就要选择下一种策略了。

根据响应时间

在实践中,大多数时候都是根据被调用接口的响应时间来确定超时时间。一般情况下,你可以选择使用 99 线或者 999 线来作为超时时间。所谓的 99 线是指 99% 的请求,响应时间都在这个值以内。比如说 99 线为 1s,那么意味着 99% 的请求响应时间都在 1s 以内。999 线也是类似的含义。

但是使用这种方式要求这个接口已经接入了类似 Prometheus 之类的可观测性工具,能够算出 99 线或者 999 线。如果一个接口是新接口,你要调用它,而这时候根本没有 99 线或者 999 线的数据。那么你可以考虑使用压力测试。

压力测试

简单来说,你可以通过压力测试来找到被调用接口的 99 线和 999 线。而且压力测试应该尽可能在和线上一样的环境下进行。但是就像我在限流里面提到的,很多公司其实内部没有什么压测环境,也不可能让你停下新功能开发去做压力测试。那么就无法采用压力测试来采集到响应时间数据。所以你就只剩下最后一个手段,根据代码来计算。

根据代码计算

根据代码计算和我在限流里面讲的差不多。假如说你现在有一个接口,里面有三次数据库操作,还有一次访问 Redis 的操作和一次发送消息的操作,那么你接口的响应时间就应该这样计算:

接口的响应时间=数据库响应时间×3+Redis响应时间+发送消息的响应时间

如果你觉得不保险,那么你可以在计算出来的结果上再加一点作为余量。比如说你通过分析代码认为响应时间应该在 200ms,那么你完全可以加上 100ms 作为余量。你可以告诉这个接口的调用者,将超时时间设置为 300ms。

监控超时时间

在微服务框架里面,一般都是微服务框架客户端来监听超时时间。在一些特殊的微服务框架里面,框架服务端也会同步监听超时时间。

img

调用第三方

背景介绍

我的系统对可用性要求非常高,为此我综合使用了熔断、限流、降级、超时控制等措施。并且,我这个系统还有一个特别之处,就是它需要和很多第三方平台打交道。所以要想保证系统的可用性,我就需要保证和第三方打交道是高可用的。

我在刚接手这个项目的时候,这一块的设计和实现不太行。总体来说可扩展性、可用性、可观测性和可测试性都非常差。为了解决这个问题,全方位提高系统的可扩展性、可用性、可观测性和可测试性,我做了比较大的重构。

我重新设计了接口,提供了一个一致性抽象。(这里你可以补充你设计了哪些接口,然后强调一下效果)重构之后,研发效率提高了 30%,并且接入一个全新的第三方,也能对业务方做到完全没感知。

我引入客户端治理措施,主要是限流和重试,并且针对一些特殊的第三方接口,我还设计了一些特殊的容错方案。

我全方面接入了可观测性平台,包括 Prometheus 和 Skywalking,并且配置了告警。和原来比起来,现在能够做到快速响应故障了。

我还进一步提供了测试工具,可以按照业务方的预期返回响应,比如说成功响应、失败响应以及模拟接口超时。针对压测,我也做了一些改进。

一致性抽象

提供一个一致性抽象,屏蔽不同第三方平台 API 之间的差异。

这算是你这个模块或者服务最基本的目标。举个例子,如果你调用的是第三方支付平台,你们公司支持多种接入方式,包括微信支付、支付宝支付。

在这种情况下,业务方只希望调用你的某个接口,然后告诉你支付所需要的基本信息,比如说金额和方式。你这个接口的实现就能根据具体的支付方式发起调用,业务方完全不需要关心其中的任何细节。

这种一致性抽象会统一解决很多细节问题。比如不同的通信协议、不同的加密解密算法、不同的请求和响应格式、不同的身份认证和鉴权机制、不同的回调机制等等。这会带来两个好处。

  • 研发效率大幅提高,对于业务方来说他们不需要了解第三方的任何细节,所以他们接入一个第三方会是一件很简单的事情。

  • 高可扩展性,你可以通过扩展接口的方式轻松接入新的第三方,而已有的业务完全不会受到影响。

同步转异步

同步转异步在一些不需要立刻拿到响应的场景,如果你发现第三方已经崩溃了,你可以将业务方的请求临时存储起来。等后面第三方恢复了再继续调用第三方处理。这种方案一般用于对时效性要求不高的业务。比如业务方只是要求你上报数据,不要求你立刻成功,那么你就可以采用这种方案。

我们这种容错机制其实完全可以做成利用消息队列来彻底解耦的形式。在这种解耦的架构下,业务方不再是同步调用一个接口,而是把消息丢到消息队列里面。然后我们的服务不断消费消息,调用第三方接口处理业务。等处理完毕再将响应通过消息队列通知业务方。

img

自动替换第三方

这种策略和我在负载均衡里面提到的有些类似,即调用一个第三方的接口失败的时候,你可以考虑换一个第三方。

这里一些可能会追问的点。

  • 你怎么知道第三方出问题了?这个问题可以参考我们前面讲过多次的判断服务健康与否的方式,比如说用响应时间、错误率、超时率。那么自然可以将话题引导到熔断、降级、限流那边。

  • 如果全部可用的第三方都崩溃了怎么办?这种问题直接认怂就可以。因为一家出故障是小概率,多家同时出故障那就更是小概率事件了,在这种情况下你除了告警也没有别的办法了。也就是所谓的尽人事,听天命。

压测支持

压测支持每当你想搞压测的时候,你就会发现,所有的第三方接口都是压测路上的拦路虎。

正常来说,你不能指望第三方会配合你的压测。你可以设想,类似于微信之类的开放平台是不可能配合你搞什么压力测试的。甚至即便你是非常强硬的甲方,你想让乙方配合你做压力测试,也是不现实的。所以你只能考虑通过 mock 来提供压测支持。和正常的测试支持比起来,压测你需要做到三件事。

  • 模拟第三方的响应时间。
  • 模拟触发你的容错机制。如果你采用了同步转异步这种容错机制,那么你需要确保在流量很大的情况下,你确实转异步了;如果你采用的是自动切换第三方,那你也要确保真的如同你设想的那样真的切换了新的第三方。
  • 流量分发。如果是在全链路压测的情况下,压测流量你会分发到 mock 逻辑,而真实业务请求你是真的调用第三方。
  • 30
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值