微服务可用性设计
一、隔离
对系统或资源进行分割,实现当系统发生故障时能限定传播范围和影响范围。进一步的,通过隔离能够降低系统之间得耦合度,使得系统更容易维护和扩展。某些业务场景下合理使用隔离技巧也能提高整个业务的性能。我理解隔离本质就是一种解耦手段。
1.1 动静隔离
动静隔离本质上是根据资源的变化频率,对它们进行划分和隔离。本质上是加速/缓存变化频率小的数据,将它们与其它资源进行隔离,避免其它资源频繁变化影响到它们,从而提高性能
场景一: 将对象存储和业务服务隔离
对象存储费用低、存储量大,并且放入对象存储空间的资源变化频率也低。我们将对象存储作为一个单独的模块让所有业务服务共享,这样业务服务就无需考虑存储问题,比如空间不足、静态资源和业务数据在带宽上无法隔离造成业务卡顿等问题。
本质上是对静态资源(静态文件)和动态资源(动态业务数据)进行了隔离,将它们进行隔离提高了业务服务的带宽性能、每个业务服务也不需要考虑自身的存储空间以及数据之间的一致性问题,将业务服务变为一种无状态的服务,更方便扩缩容。
场景二:数据表字段的分离
针对一个业务设计了一张数据表,该表包括了一个对象的基本信息和统计信息。基本信息的变动频率往往很低(用户名、性别、状态、手机号、密码),而统计信息变化频率往往很高(点赞数、关注数、粉丝数、资产数)等等
我们可以将基本信息和统计信息进行分离,能够得到以下好处:
- 修改统计信息只会使得统计信息的缓存失效,不会使得基本信息的缓存失效
- 使得B+树叶子节点能够存放更多记录,从而降低树的高度,缓解单表数据量大导致读写操作慢的问题
场景三: 各种数据库的读写分离也借鉴了该思想
1.2 快慢隔离
把服务的吞吐想象成一个蓄水池,当突然洪流进来的时候,池子需要一定时间才能将其排放完,而这时候其它的小流量在池子里面待的时间取决于这个蓄水池的排放能力,为了提高排放能力(接口响应速度),我们可以对服务进行快慢隔离。
当我们请求一个接口时,它可能涉及到很多业务处理,有些业务轻处理快、有些重处理慢。在不影响用户体验的前提下,我们可以将这些重的业务特别是一些大的并发业务从上游解耦到消息队列的消费者端处理,提高整个api的响应速度,保证业务最终完成即可。
1.3 热点隔离
统计某个热点数据重访问频次最高TopKS数据进行缓存,或者将remoteCache升级为localCache,提升效率
1.4 物理隔离
- 线程池隔离
- 同一主机下使用cgroup隔离服务的cpu、内存
- 进程隔离、集群隔离等
二、超时控制
超时控制,通过限制请求在系统里停留的最长时间,避免请求长时间在系统中占用资源,降低系统的性能。(当用户发起一次请求过了2~3秒都没反应,用户往往会重试,上次的请求也就应该丢弃了,如果不设置超时时间它还会继续在系统中执行业务,消耗系统的资源)
超时控制应该具备传递功能,当上游服务已经返回超时错误时,下游服务不要再继续执行。Quota传递到下游服务中,应该继承超时策略
go-grpc框架中,会依赖gRPC Metadata Exchange,基于HTTP2的Headers传递grpc-timeout字段传递到下游,构建待timeout的context。
简单说,如果你调用grpc方法中传入的context携带timeout,那么grpc则会在http2的header frame中设置字段grpc-timeout为context.DeadLine(),而grpc server在接收请求时若发现grpc-timeout的存在,则会构造一个带timeout的context传给handler,从而实现grpc的超时传递
go-zero的超时控制实现
go-zero 中我们可以再BFF层配置Timeout字段设置接口的超时时间,它会帮我们完成超时传递
go-zero/core/fx/timeout.go:超时控制
package fximport ("context""fmt""runtime/debug""strings""time"
)var (// ErrCanceled is the error returned when the context is canceled.ErrCanceled = context.Canceled// ErrTimeout is the error returned when the context's deadline passes.ErrTimeout = context.DeadlineExceeded
)// DoOption defines the method to customize a DoWithTimeout call.
type DoOption func() context.Context// DoWithTimeout runs fn with timeout control.
func DoWithTimeout(fn func() error, timeout time.Duration, opts ...DoOption) error {parentCtx := context.Background()for _, opt := range opts {parentCtx = opt()}ctx, cancel := context.WithTimeout(parentCtx, timeout)defer cancel()// create channel with buffer size 1 to avoid goroutine leakdone := make(chan error, 1)panicChan := make(chan any, 1)go func() {defer func() {if p := recover(); p != nil {// attach call stack to avoid missing in different goroutinepanicChan <- fmt.Sprintf("%+v\n\n%s", p, strings.TrimSpace(string(debug.Stack())))}}()done <- fn()}()select {case p := <-panicChan:panic(p)case err := <-done:return errcase <-ctx.Done():return ctx.Err()}
}// WithContext customizes a DoWithTimeout call with given ctx.
func WithContext(ctx context.Context) DoOption {return func() context.Context {return ctx}
}
参考
小米技术团队:https://xiaomi-info.github.io/2019/12/30/grpc-deadline/
三、过载保护、限流
本质上是根据系统的情况限制请求接收的数量,将能力以外的请求快速响应失败,但是二者有一些区别
过载保护:在未达到阈值前尽可能的接收请求,当cpu达到阈值(一般为90%)时得到系统的最大负载,开始丢弃一些请求。但是由于这个时候已经接近满负载了并且丢弃请求本身也具有一丢丢的性能损耗,此时若请求仍大量进来还是会将服务打崩。
限流:限流算法从始至终都在限制请求接收的速率,弥补过载保护在极端条件的风险,使得服务更加稳健
过载保护
通过记录当前机器的各项性能指标,比如cpu、内存、响应延迟等信息判断是否过载,如果过载则开启过载保护,以过载的系统负载作为阈值,判断当前请求数量是否超过阈值。
负载统计:
const (// 250ms and 0.95 as beta will count the average cpu load for past 5 secondscpuRefreshInterval = time.Millisecond * 250allRefreshInterval = time.Minute// moving average beta hyperparameterbeta = 0.95
)func init() {go func() {cpuTicker := time.NewTicker(cpuRefreshInterval)defer cpuTicker.Stop()allTicker := time.NewTicker(allRefreshInterval)defer allTicker.Stop()for {select {case <-cpuTicker.C:threading.RunSafe(func() {curUsage := internal.RefreshCpu()prevUsage := atomic.LoadInt64(&cpuUsage)// cpu = cpuᵗ⁻¹ * beta + cpuᵗ * (1 - beta)//指数衰变算法 usage := int64(float64(prevUsage)*beta + float64(curUsage)*(1-beta))atomic.StoreInt64(&cpuUsage, usage)})case <-allTicker.C:printUsage()}}}()
}
过载保护:/core/load/adaptiveshedder.go
func (as *adaptiveShedder) shouldDrop() bool {if as.systemOverloaded() || as.stillHot() {if as.highThru() {flying := atomic.LoadInt64(&as.flying)as.avgFlyingLock.Lock()avgFlying := as.avgFlyingas.avgFlyingLock.Unlock()msg := fmt.Sprintf("dropreq, cpu: %d, maxPass: %d, minRt: %.2f, hot: %t, flying: %d, avgFlying: %.2f",stat.CpuUsage(), as.maxPass(), as.minRt(), as.stillHot(), flying, avgFlying)logx.Error(msg)stat.Report(msg)return true}}return false
}
分布式限流
滑动事件窗口计数器
统计一段时间内的计数器,采用滑动窗口使得计数粒度更细,更平滑,缓解窗口与窗口之间的毛刺现象
package counterimport ("sync""time"
)type SlideWinCounter struct {window []int // 计数窗口size int // 窗口大小maxReq int // 单位时间内最大请求数startTime time.Time // 窗口开始时间startOffset int // 窗口左侧偏移量interval time.Duration // 滑动单位(时间间隔)mu sync.Mutex // 互斥锁
}func NewSlideWinCounter(size, max int, interval time.Duration) *SlideWinCounter {win := make([]int, 0, size)for i := 0; i < size; i++ {win = append(win, 0)}return &SlideWinCounter{window: win,startTime: time.Now(),interval: interval,size: size,maxReq: max,}
}func (c *SlideWinCounter) Allow() bool {c.mu.Lock()defer c.mu.Unlock()now := time.Now()//根据当前时间更新窗口,得到当前窗口偏移量offset := c.updateWindow(now)if c.count() > c.maxReq {return false}c.window[offset] += 1return true
}func (c *SlideWinCounter) updateWindow(now time.Time) int {offset := 0dis := now.Sub(c.startTime)span := int(dis.Milliseconds())/int(c.interval.Milliseconds())if span-c.size >= c.size { // 窗口全部过期c.toZero()} else if span >= c.size { // 窗口部分过期zeroSpan := (span - c.size + 1) % c.sizefor i := 0; i < zeroSpan; i++ {c.window[(c.startOffset+i)%c.size] = 0}c.startOffset = (c.startOffset + span - c.size) % c.sizeoffset = (c.startOffset + c.size - 1) % c.sizec.startTime = now} else { // 窗口没过期offset = (c.startOffset + span) % c.size}return offset
}func (c *SlideWinCounter) count() int {count := 0for _, c := range c.window {count += c}return count
}func (c *SlideWinCounter) toZero() {for i := 0; i < c.size; i++ {c.window[i] = 0}c.startOffset = 0c.startTime = time.Now()
}
参考go-zero的分布式限流实现:https://pandaychen.github.io/2020/09/21/A-DISTRIBUTE-GOREDIS-RATELIMITER-ANALYSIS/