写好海量后台服务最重要的是意识

作者:张富春(ahfuzhang),转载时请注明作者和引用链接,谢谢!


刚入行不久的我曾有一个想法:假设几个超牛的架构师,带着一群会编程的蓝领工人,熟练了严苛的开发规范后,是不是也能写出复杂的厉害的软件?

我想说的是:起码在海量后台这个领域,绝对不行!!!就算有很牛的架构,就算有很优秀的分布式组件,如果程序员的意识不到位,仍然无法写出稳定的高质量的海量后台服务。

下面我通过一组代码来说明,为什么开发海量服务时,最重要的是程序员的”海量服务“意识,而不是其他的因素加强就能够达成的。

以golang为例,某个接口收到请求后,通过HTTP协议查询另一个接口,然后返回结果:

第一步:请求得到结果,然后返回

import (
   "net/http"
)

func HttpPost(url string, postData []byte)(response []byte){
  // 省略,使用 http.Client 进行数据发送
}

// 发送请求到某个URL,然后返回结果 (省略上层函数)
http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
  w.Write(HttpPost("http://xxx", "xxxx"))
})

可以看见,http的处理函数非常简单,确实也完成了需求。

但是,这样够吗?

海量后台意识之——错误处理

网络通讯是随时可能失败的啊,怎么可能不处理错误呢?

这步应该很容易想到,让我们加上错误处理:

import (
   "net/http"
)

func HTTPPost(url string, postData []byte)(response []byte, err error){
    
}

// 发送请求到某个URL,然后返回结果
http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
  rsp, err := HTTPPost("http://xxx", "xxxx")
  if err!=nil{
     w.WriteHeader(http.StatusInternalServerError)
     w.Write([]byte("request error"))
     return
  }
  w.Write(rsp)
})

这样够了吗?

海量后台意识之——设置超时时间

要根据业务需求,配置合理的超时时间。

超时时间太长,异常时会产生无效的等待;超时时间太短,网络不好或者服务器忙的时候,会导致失败率升高。

并且,从用户请求,到整个请求链路的最后一环,超时时间的设置是阶梯状的,每向后一个调用,超时时间就要更短。

import (
   "net/http"
)

const defaultHTTPTimeout = time.Duration(500)*time.Millisecond  // 默认超时时间

func HTTPPost(url string, postData []byte, timeout time.Duration)(response []byte, err error){
    
}

// 发送请求到某个URL,然后返回结果
http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
  rsp, err := HTTPPost("http://xxx", "xxxx", defaultHTTPTimeout)
  if err!=nil{
     w.WriteHeader(http.StatusInternalServerError)
     w.Write([]byte("request error"))
     return
  }
  w.Write(rsp)
})

这样够了吗?

海量后台意识之——合理重试

假设后端是一个查询服务,在后端出问题后立即就失败,那么容错性就没那么好。

在查询的场景里面,通过一定次数的重试来提高成功率是可行的。

也要注意:重试一定是有限次数的

import (
   "net/http"
)

const defaultHTTPTimeout = time.Duration(500)*time.Millisecond
const maxRetry = 3  // 配置业务上允许的最大重试次数

func HTTPPost(url string, postData []byte, timeout time.Duration)(response []byte, err error){
    
}

// 发送请求到某个URL,然后返回结果
http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
  for retry:=0; retry<maxRetry; retry++ {
    rsp, err := HTTPPost("http://xxx", "xxxx", defaultHTTPTimeout)
    if err!=nil {
       continue
    }
    w.Write(rsp)
    return
  }
  w.WriteHeader(http.StatusInternalServerError)
  w.Write([]byte("request error"))
})

这样够了吗?

海量后台意识之——考虑避让

增加重试可以提高接口的成功率,但是也会带来风险——假设某个后端的所有节点一瞬间都不能访问了,那么所有请求端的节点会在这一瞬间疯狂重试,从而引发一系列的雪崩。

因此需要考虑避让机制:

import (
   "net/http"
)

const defaultHTTPTimeout = time.Duration(500)*time.Millisecond
const maxRetry = 3  // 配置业务上允许的最大重试次数

func HTTPPost(url string, postData []byte, timeout time.Duration)(response []byte, err error){
    
}

// 发送请求到某个URL,然后返回结果
http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
  for retry:=0; retry<maxRetry; retry++{
    rsp, err := HTTPPost("http://xxx", "xxxx", defaultHTTPTimeout)
    if err!=nil{
       time.Sleep(time.Duration(rand.Intn(50))*time.Millisecond)  // 随机睡眠一会儿,避免瞬间的疯狂重试
       continue
    }
    w.Write(rsp)
    return
  }
  w.WriteHeader(http.StatusInternalServerError)
  w.Write([]byte("request error"))
})

这样够了吗?

八荣八耻之——要打日志

当错误发生时,要通过日志记录上下文信息,便于后续的改进。
没有日志,出了问题就是抓瞎。
打日志是程序员的基本道德规范,具体请见:《程序员日常开发的八荣八耻

以规范日志为荣,以乱打日志为耻

import (
   "net/http"
)

const defaultHTTPTimeout = time.Duration(500)*time.Millisecond
const maxRetry = 3  // 配置业务上允许的最大重试次数

func HTTPPost(url string, postData []byte, timeout time.Duration)(response []byte, err error){
    
}

// 发送请求到某个URL,然后返回结果
http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
  log.Infof("request=%+v", r)  // 请求流水要打印
  for retry:=0; retry<maxRetry; retry++{
    rsp, err := HTTPPost("http://xxx", "xxxx", defaultHTTPTimeout)
    if err!=nil{
       log.Warnf("request error, err=%+v", err)  // 错误信息要打日志
       time.Sleep(time.Duration(rand.Intn(50))*time.Millisecond)  // 随机睡眠一会儿,避免瞬间的疯狂重试
       continue
    }
    w.Write(rsp)
    return
  }
  w.WriteHeader(http.StatusInternalServerError)
  w.Write([]byte("request error"))
})

这样够了吗?

海量后台意识之——立体化监控

程序代码中要加入足够多的”观测点“,以此了解程序运行中的各种行为。

对性能、业务量、异常量、延迟等指标,都要通过打点监控来获得直观的信息。

以下用prometheus api来演示如何加上观测点:

import (
   "net/http"
   "github.com/prometheus/client_golang/prometheus"
)

const defaultHTTPTimeout = time.Duration(500)*time.Millisecond
const maxRetry = 3  // 配置业务上允许的最大重试次数

func HTTPPost(url string, postData []byte, timeout time.Duration)(response []byte, err error){
    
}

var httpRequestTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
		Namespace:   "",
		Subsystem:   "",
		Name:        "http_request_total",
		Help:        "",
		ConstLabels: nil,
	}, []string{"path"})

var httpRequestFailTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
		Namespace:   "",
		Subsystem:   "",
		Name:        "http_request_fail_total",
		Help:        "",
		ConstLabels: nil,
	}, []string{"path"})

var httpRequestSendToXXTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
		Namespace:   "",
		Subsystem:   "",
		Name:        "http_request_sendto_xx_total",
		Help:        "",
		ConstLabels: nil,
	}, []string{"reason"})

// 发送请求到某个URL,然后返回结果
http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
  log.Infof("request=%+v", r)  // 请求流水要打印
  httpRequestTotal.WithLabelValues("/bar").Inc()  // 每次请求,请求量加1
  for retry:=0; retry<maxRetry; retry++{
    rsp, err := HTTPPost("http://xxx", "xxxx", defaultHTTPTimeout)
    if err!=nil{
       httpRequestSendToXXTotal.WithLabelValues(err.Error()).Inc()  // 每次错误,进行上报
       log.Warnf("request error, err=%+v", err)  // 错误信息要打日志
       time.Sleep(time.Duration(rand.Intn(50))*time.Millisecond)  // 随机睡眠一会儿,避免瞬间的疯狂重试
       continue
    }
    w.Write(rsp)
    return
  }
  w.WriteHeader(http.StatusInternalServerError)
  w.Write([]byte("request error"))
  httpRequestFailTotal.WithLabelValues("/bar").Inc()  // 用户端请求失败,进行上报
})

上面的代码增加了3个监控项:

  • 总接口请求量
  • 总转发错误量
  • 总接口失败量

上面的数据可以被prometheus采集,然后在grafana中展示出每分钟的曲线。其promQL表达式为:

sum by (path)(increase(http_request_total))

到了这里,还有非常关键的一步——配置告警:

  • 请求量上需要配置:
    • 最大值告警——在目前业务量的峰值的基础上高出一定范围,例如20%
    • 最小值告警——在当前业务量最小值的基础上低一定范围,如果上游出现问题,或者自身崩溃,就会触发告警
    • 波动告警——请求量发生剧烈抖动,突然猛涨,或者突然暴跌。必须通过告警来识别这种异常。
  • 错误量上的告警配置:
    • 最大值告警——例如每分钟最多允许发生5次错误,超过这个量就认为异常
    • SLA告警:错误一直持续,影响服务的总体服务水平
  • 接口失败量的告警:
    • 最大值告警
    • 成功率告警

上面的监控和告警,能够对运营期间系统明显的问题快速发现,但如果需要更加细致的健康和性能的评估,最好再加上时延的上报:

import (
   "net/http"
   "github.com/prometheus/client_golang/prometheus"
)

const defaultHTTPTimeout = time.Duration(500)*time.Millisecond
const maxRetry = 3  // 配置业务上允许的最大重试次数

func HTTPPost(url string, postData []byte, timeout time.Duration)(response []byte, err error){
    
}

var httpRequestTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
		Namespace:   "",
		Subsystem:   "",
		Name:        "http_request_total",
		Help:        "",
		ConstLabels: nil,
	}, []string{"path"})

var httpRequestFailTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
		Namespace:   "",
		Subsystem:   "",
		Name:        "http_request_fail_total",
		Help:        "",
		ConstLabels: nil,
	}, []string{"path"})

var httpRequestSendToXXTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
		Namespace:   "",
		Subsystem:   "",
		Name:        "http_request_sendto_xx_total",
		Help:        "",
		ConstLabels: nil,
	}, []string{"reason"})

// 用一个 histogram 类型的监控项来记录接口的延迟
var histogramForRequest := prometheus.NewHistogramVec(prometheus.HistogramOpts{
		Namespace:   "",
		Subsystem:   "",
		Name:        "http_request_total_seconds",
		Help:        "",
		ConstLabels: nil,
		Buckets:     []float64{0.001, 0.005, 0.010, 0.050, 0.100, 0.200, 0.300, 0.400, 0.500, 1.000, 2.0000},  // 这里分桶来记录延迟的分布
	}, []string{"path"})


// 发送请求到某个URL,然后返回结果
http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
  now := time.Now()
  defer func(){
    hist.WithLabelValues("/bar").Observe(time.Since(now).Seconds())  //上报接口的延迟
  }()
  log.Infof("request=%+v", r)  // 请求流水要打印
  httpRequestTotal.WithLabelValues("/bar").Inc()  // 每次请求,请求量加1
  for retry:=0; retry<maxRetry; retry++{
    rsp, err := HTTPPost("http://xxx", "xxxx", defaultHTTPTimeout)
    if err!=nil{
       httpRequestSendToXXTotal.WithLabelValues(err.Error()).Inc()  // 每次错误,进行上报
       log.Warnf("request error, err=%+v", err)  // 错误信息要打日志
       time.Sleep(time.Duration(rand.Intn(50))*time.Millisecond)  // 随机睡眠一会儿,避免瞬间的疯狂重试
       continue
    }
    w.Write(rsp)
    return
  }
  w.WriteHeader(http.StatusInternalServerError)
  w.Write([]byte("request error"))
  httpRequestFailTotal.WithLabelValues("/bar").Inc()  // 用户端请求失败,进行上报
})

通过上报,就可以知道每条接口的处理延迟情况。调用别的接口,也可以加上延迟分布的数据。

histogram的图片展示方法,请看:grafana中如何展示prometheus的延迟分布数据?

不过,你们也许也会觉得,一个简单的功能,实现的代码也太长了。

减少代码的办法就是把各种基础监控放在框架中实现,一个好的框架能够减少我们的代码量,减轻我们的心智负担。使用框架的时候也需要注意:

  • 一定要了解清楚框架为我们做了哪些事情,不能想当然的认为有了框架就万事大吉
  • 某些位置的监控,框架是监控不到的,这个时候开发要自己记得加上

当然,还没有结束……

海量后台意识之——过载保护

如果上游疯狂请求这条接口怎么办?接口的处理能力总有个上限,超过上限后不但无法正常提供服务,服务自身可能还会因为巨大的压力而崩溃。

因此接口必须做自我保护:

import (
   "net/http"
   "github.com/prometheus/client_golang/prometheus"
    "golang.org/x/time/rate"  // 使用官方提供的频率限制库
)

const defaultHTTPTimeout = time.Duration(500)*time.Millisecond
const maxRetry = 3  // 配置业务上允许的最大重试次数

func HTTPPost(url string, postData []byte, timeout time.Duration)(response []byte, err error){
    
}

var httpRequestTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
		Namespace:   "",
		Subsystem:   "",
		Name:        "http_request_total",
		Help:        "",
		ConstLabels: nil,
	}, []string{"path"})

var httpRequestFailTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
		Namespace:   "",
		Subsystem:   "",
		Name:        "http_request_fail_total",
		Help:        "",
		ConstLabels: nil,
	}, []string{"path"})

var httpRequestSendToXXTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
		Namespace:   "",
		Subsystem:   "",
		Name:        "http_request_sendto_xx_total",
		Help:        "",
		ConstLabels: nil,
	}, []string{"reason"})

// 用一个 histogram 类型的监控项来记录接口的延迟
var histogramForRequest := prometheus.NewHistogramVec(prometheus.HistogramOpts{
		Namespace:   "",
		Subsystem:   "",
		Name:        "http_request_total_seconds",
		Help:        "",
		ConstLabels: nil,
		Buckets:     []float64{0.001, 0.005, 0.010, 0.050, 0.100, 0.200, 0.300, 0.400, 0.500, 1.000, 2.0000},  // 这里分桶来记录延迟的分布
	}, []string{"path"})

// 速度限定为每个核 1 万/s
var limiter = rate.NewLimiter(rate.Every(time.Duration(10)*time.Millisecond), 100*runtime.GOMAXPROCS(0))
  

// 发送请求到某个URL,然后返回结果
http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
  if !limiter.Wait(context.Background()) {  //过载的时候丢弃请求,保护下游
    w.WriteHeader(http.StatusServiceUnavailable)
    return
  }
  now := time.Now()
  defer func(){
    hist.WithLabelValues("/bar").Observe(time.Since(now).Seconds())  //上报接口的延迟
  }()
  log.Infof("request=%+v", r)  // 请求流水要打印
  httpRequestTotal.WithLabelValues("/bar").Inc()  // 每次请求,请求量加1
  for retry:=0; retry<maxRetry; retry++{
    rsp, err := HTTPPost("http://xxx", "xxxx", defaultHTTPTimeout)
    if err!=nil{
       httpRequestSendToXXTotal.WithLabelValues(err.Error()).Inc()  // 每次错误,进行上报
       log.Warnf("request error, err=%+v", err)  // 错误信息要打日志
       time.Sleep(time.Duration(rand.Intn(50))*time.Millisecond)  // 随机睡眠一会儿,避免瞬间的疯狂重试
       continue
    }
    w.Write(rsp)
    return
  }
  w.WriteHeader(http.StatusInternalServerError)
  w.Write([]byte("request error"))
  httpRequestFailTotal.WithLabelValues("/bar").Inc()  // 用户端请求失败,进行上报
})

八荣八耻之——参数校验

还有一项基础的工作那就是对请求参数进行校验。这属于程序员基本职业素养的范畴:

以参数校验为荣,以运行异常为耻

import (
   "net/http"
   "github.com/prometheus/client_golang/prometheus"
   "golang.org/x/time/rate"  // 使用官方提供的频率限制库
)

const defaultHTTPTimeout = time.Duration(500)*time.Millisecond
const maxRetry = 3  // 配置业务上允许的最大重试次数

func HTTPPost(url string, postData []byte, timeout time.Duration)(response []byte, err error){
    
}

var httpRequestTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
		Namespace:   "",
		Subsystem:   "",
		Name:        "http_request_total",
		Help:        "",
		ConstLabels: nil,
	}, []string{"path"})

var httpRequestFailTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
		Namespace:   "",
		Subsystem:   "",
		Name:        "http_request_fail_total",
		Help:        "",
		ConstLabels: nil,
	}, []string{"path"})

var httpRequestSendToXXTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
		Namespace:   "",
		Subsystem:   "",
		Name:        "http_request_sendto_xx_total",
		Help:        "",
		ConstLabels: nil,
	}, []string{"reason"})

// 用一个 histogram 类型的监控项来记录接口的延迟
var histogramForRequest := prometheus.NewHistogramVec(prometheus.HistogramOpts{
		Namespace:   "",
		Subsystem:   "",
		Name:        "http_request_total_seconds",
		Help:        "",
		ConstLabels: nil,
		Buckets:     []float64{0.001, 0.005, 0.010, 0.050, 0.100, 0.200, 0.300, 0.400, 0.500, 1.000, 2.0000},  // 这里分桶来记录延迟的分布
	}, []string{"path"})

// 速度限定为每个核 1 万/s
var limiter = rate.NewLimiter(rate.Every(time.Duration(10)*time.Millisecond), 100*runtime.GOMAXPROCS(0))
  
func checkParam(r *http.Request) error {
  // 实现具体的参数检查逻辑
}

// 发送请求到某个URL,然后返回结果
http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
  if !limiter.Wait(context.Background()) {  //过载的时候丢弃请求,保护下游
    w.WriteHeader(http.StatusServiceUnavailable)
    return
  }
  now := time.Now()
  defer func(){
    hist.WithLabelValues("/bar").Observe(time.Since(now).Seconds())  //上报接口的延迟
  }()
  log.Infof("request=%+v", r)  // 请求流水要打印
  httpRequestTotal.WithLabelValues("/bar").Inc()  // 每次请求,请求量加1
  // 进行参数校验
  if err:=checkParam(r); err!=nil {
    w.WriteHeader(http.StatusBadRequest)
    w.Write([]byte("bad request:"+err.Error()))
    httpRequestFailTotal.WithLabelValues("/bar").Inc()
    return
  }
  for retry:=0; retry<maxRetry; retry++{
    rsp, err := HTTPPost("http://xxx", "xxxx", defaultHTTPTimeout)
    if err!=nil{
       httpRequestSendToXXTotal.WithLabelValues(err.Error()).Inc()  // 每次错误,进行上报
       log.Warnf("request error, err=%+v", err)  // 错误信息要打日志
       time.Sleep(time.Duration(rand.Intn(50))*time.Millisecond)  // 随机睡眠一会儿,避免瞬间的疯狂重试
       continue
    }
    w.Write(rsp)
    return
  }
  w.WriteHeader(http.StatusInternalServerError)
  w.Write([]byte("request error"))
  httpRequestFailTotal.WithLabelValues("/bar").Inc()  // 用户端请求失败,进行上报
})

海量后台意识之——降级服务

下面我们再把我们的服务想得更复杂一点点:同时从A和B查询数据,然后组合两个结果,返回给调用端。

在有的场景下,部分字段是关键的,必须正确返回,而部分字段没那么重要,就算丢失了,不影响关键体验。例如:一个IM工具中拉取用户详情的接口,账户ID/账户名称等是关键信息,必须要拉到;而个人简介这样的信息,就算丢失了也不影响整体的体验,用户端如果展现不出来,用户可以选择忽略,或者再次刷新。

假设A服务的成功率是99.99%, B服务的成功率是99.9%,则A和B同时查询成的总体成功率等于:99.99% * 99.9% = 99.89%。此时,如果返回的结果不强依赖于B,当B出现问题时,仍可以部分返回A的结果,那么接口的成功率仍然是99.99%。

降级服务还有一个别名叫做灰度服务。提供海量服务并非是绝对的非零即一,应该是重点保障核心功能可用,不能脆弱到一点点小小的故障就让整个系统完全瘫痪。以微信为例:朋友圈不能用了,希望起码还能聊天;语音视频不能用了,希望起码还能打字。

func processRequest()(response interface{}){
  rsp1, err1 := queryServerA()
  if err1!=nil{
    log.Errorf("关键服务请求失败,整体失败")
    return nil
  }
  rsp2, err2 := queryServerB()
  if err2!=nil{
    log.Warnf("次要服务请求失败,仅记录日志")
  }
  return map[string]interface{}{
    "rsp1": rsp1,
    "rsp2": rsp2,
  }
}

海量后台意识之——处理非预期的异常

应该让程序的绝大多数异常返回预期之内,通过golang的error类型来反映这些异常的影响。

但是也有可能发生预期之外的异常,那就是panic。

如果未捕获panic,可能导致整个进程崩溃,带来长时间的服务不可用。

建议:所有go出来的协程,在入口处都加上panic检查:

func ForPanic(){
  if err:=recover(); err!=nil{
    	const defaultStackSize = 1024 * 4
			var buf [defaultStackSize]byte
			n := runtime.Stack(buf[:], false)
			log.Errorf("panic, err=%+v\n%s", err, string(buf[:n]))
  }
}

func processRequest()(response interface{}){
  defer ForPanic()
  rsp1, err1 := queryServerA()
  if err1!=nil{
    log.Errorf("关键服务请求失败,整体失败")
    return nil
  }
  rsp2, err2 := queryServerB()
  if err2!=nil{
    log.Warnf("次要服务请求失败,仅记录日志")
  }
  return map[string]interface{}{
    "rsp1": rsp1,
    "rsp2": rsp2,
  }
}

最后,再来一点锦上添花的意识:

海量后台意识之——尽量并行

上面的代码,可以这样改动:

func ForPanic(){
  if err:=recover(); err!=nil{
    	const defaultStackSize = 1024 * 4
			var buf [defaultStackSize]byte
			n := runtime.Stack(buf[:], false)
			log.Errorf("panic, err=%+v\n%s", err, string(buf[:n]))
  }
}

func processRequest()(response interface{}){
  defer ForPanic()
  var (
     rsp1 *ResponseType1
     err1 errpr
     rsp2 *ResponseType2
     err2 error
     wg sync.WaitGroup
  )
  wg.Add(1)
  go func(){
    defer wg.Done()
    defer ForPanic()
    rsp1, err1 = queryServerA()  // 请求相互不依赖的服务的时候,尽量并行
  }  
  wg.Add(1)
  go func(){
    defer wg.Done()
    defer ForPanic()
    rsp2, err2 = queryServerB()
  }  
  wg.Wait()
  if err1!=nil{
    log.Errorf("关键服务请求失败,整体失败")
    return
  }
  if err2!=nil{
    log.Warnf("次要服务请求失败,仅记录日志")
  }
  return map[string]interface{}{
    "rsp1": rsp1,
    "rsp2": rsp2,
  }
}

总结

看起来只是短短的一个网络请求,考虑方方面面的因素后,代码扩充了很多倍。

这些增加的代码其实都是负责海量后台开发工作中血泪的教训。

好的意识+经验,才能把构建起海量服务的一个基本单元写好,这需要开发同学在工作中不断的学习和刻意训练。

希望这篇文章对大家有用,have fun 😃

posted on 2022-09-14 22:24  ahfuzhang  阅读(106)  评论(0编辑  收藏  举报