写好海量后台服务最重要的是意识
作者:张富春(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 😃
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· AI 智能体引爆开源社区「GitHub 热点速览」
· C#/.NET/.NET Core技术前沿周刊 | 第 29 期(2025年3.1-3.9)
· 从HTTP原因短语缺失研究HTTP/2和HTTP/3的设计差异
2020-09-14 【小实验】rust的数组是在堆上分配还是在栈上分配的呢?
2020-09-14 【记录一个问题】go.mod中使用replace后,编译出现神奇的错误:
2018-09-14 在graphviz中创建可点击的图形