写好海量后台服务最重要的是意识
作者:张富春(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 😃