高德Go生态的服务稳定性建设|性能优化的实战总结
Posted 轻风博客
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了高德Go生态的服务稳定性建设|性能优化的实战总结相关的知识,希望对你有一定的参考价值。
目前go语言不仅在阿里集团内部,在整个互联网行业内也越来越流行,本文把高德过去go服务开发中的性能调优经验进行总结和沉淀,希望能为正在使用go语言的同学在性能优化方面带来一些参考价值。
前言
-
从理论的角度,和你一起捋清性能优化的思路,制定最合适的优化方案。 -
推荐几款go语言性能分析利器,与你一起在性能优化的路上披荆斩棘。 -
总结归纳了众多go语言中常用的性能优化小技巧,总有一个你能用上。 -
基于高德go服务百万级QPS实践,分享几个性能优化实战案例,让性能优化不再是纸上谈兵。
一、性能调优-理论篇
1.1 衡量指标
-
cpu:对于偏计算型的应用,cpu往往是影响性能好坏的关键,如果代码中存在无限循环,或是频繁的线程上下文切换,亦或是糟糕的垃圾回收策略,都将导致cpu被大量占用,使得应用程序无法获取到足够的cpu资源,从而响应缓慢,性能变差。 -
内存:内存的读写速度非常快,往往不是性能的瓶颈,但是内存相对来说容量有限切价格昂贵,如果应用大量分配内存而不及时回收,就会造成内存溢出或泄漏,应用无法分配新的内存,便无法正常运行,这将导致很严重的事故。 -
带宽:对于偏网络I/O型的应用,例如网关服务,带宽的大小也决定了应用的性能好坏,如果带宽太小,当系统遇到大量并发请求时,带宽不够用,网络延迟就会变高,这个虽然对服务端可能无感知,但是对客户端则是影响甚大。 -
磁盘:相对内存来说,磁盘价格低廉,容量很大,但是读写速度较慢,如果应用频繁的进行磁盘I/O,那性能可想而知也不会太好。
以上这些都是系统资源层面用于衡量性能的指标,除此之外还有应用本身的稳定性指标:
-
异常率:也叫错误率,一般分两种,执行超时和应用panic。panic会导致应用不可用,虽然服务通常都会配置相应的重启机制,确保偶然的应用挂掉后能重启再次提供服务,但是经常性的panic,会导致应用频繁的重启,减少了应用正常提供服务的时间,整体性能也就变差了。异常率是非常重要的指标,服务的稳定和可用是一切的前提,如果服务都不可用了,还谈何性能优化。 -
响应时间(RT):包括平均响应时间,百分位(top percentile)响应时间。响应时间是指应用从收到请求到返回结果后的耗时,反应的是应用处理请求的快慢。通常平均响应时间无法反应服务的整体响应情况,响应慢的请求会被响应快的请求平均掉,而响应慢的请求往往会给用户带来糟糕的体验,即所谓的长尾请求,所以我们需要百分位响应时间,例如tp99响应时间,即99%的请求都会在这个时间内返回。 -
吞吐量:主要指应用在一定时间内处理请求/事务的数量,反应的是应用的负载能力。我们当然希望在应用稳定的情况下,能承接的流量越大越好,主要指标包括QPS(每秒处理请求数)和QPM(每分钟处理请求数)。
1.2 制定优化方案
-
优化代码
1)提高复用性,将通用的代码抽象出来,减少重复开发。
2)池化,对象可以池化,减少内存分配;协程可以池化,避免无限制创建协程打满内存。
3)并行化,在合理创建协程数量的前提下,把互不依赖的部分并行处理,减少整体的耗时。
4)异步化,把不需要关心实时结果的请求,用异步的方式处理,不用一直等待结果返回。
5)算法优化,使用时间复杂度更低的算法。
-
使用设计模式
-
空间换时间或时间换空间
-
使用更好的三方库
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
从上面可以看出zap的性能比同类结构化日志包更好,也比标准库更快,那我们就可以选择更好的三方库。
二、性能调优-工具篇
2.1 benchmark
package main
import (
"fmt"
"strconv"
"testing"
)
func BenchmarkStrconv(b *testing.B)
for n := 0; n < b.N; n++
strconv.Itoa(n)
func BenchmarkFmtSprint(b *testing.B)
for n := 0; n < b.N; n++
fmt.Sprint(n)
我们可以用命令行go test -bench . 来运行基准测试,输出结果如下:
goos: darwin
goarch: amd64
pkg: main
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkStrconv-12 41988014 27.41 ns/op
BenchmarkFmtSprint-12 13738172 81.19 ns/op
ok main 7.039s
goos: darwin
goarch: amd64
pkg: main
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkStrconv-12 211533207 31.60 ns/op
BenchmarkFmtSprint-12 69481287 89.58 ns/op
PASS
ok main 18.891s
goos: darwin
goarch: amd64
pkg: main
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkStrconv-12 217894554 31.76 ns/op
BenchmarkStrconv-12 217140132 31.45 ns/op
BenchmarkStrconv-12 219136828 31.79 ns/op
BenchmarkFmtSprint-12 70683580 89.53 ns/op
BenchmarkFmtSprint-12 63881758 82.51 ns/op
BenchmarkFmtSprint-12 64984329 82.04 ns/op
PASS
ok main 54.296s
结果变化也不大,看来strconv是真的比fmt.Sprint快很多。那快是快,会不会内存分配上情况就相反呢?
goos: darwin
goarch: amd64
pkg: main
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkStrconv-12 43700922 27.46 ns/op 7 B/op 0 allocs/op
BenchmarkFmtSprint-12 143412 80.88 ns/op 16 B/op 2 allocs/op
PASS
ok main 7.031s
可以看到strconv在内存分配上是0次,每次运行使用的内存是7字节,只是fmt.Sprint的43.8%,简直是全方面的优于fmt.Sprint啊。那究竟是为什么strconv比fmt.Sprint好这么多呢?
const digits = "0123456789abcdefghijklmnopqrstuvwxyz"
const smallsString = "00010203040506070809" +
"10111213141516171819" +
"20212223242526272829" +
"30313233343536373839" +
"40414243444546474849" +
"50515253545556575859" +
"60616263646566676869" +
"70717273747576777879" +
"80818283848586878889" +
"90919293949596979899"
// small returns the string for an i with 0 <= i < nSmalls.
func small(i int) string
if i < 10
return digits[i : i+1]
return smallsString[i*2 : i*2+2]
func formatBits(dst []byte, u uint64, base int, neg, append_ bool) (d []byte, s string)
...
for j := 4; j > 0; j--
is := us % 100 * 2
us /= 100
i -= 2
a[i+1] = smallsString[is+1]
a[i+0] = smallsString[is+0]
...
而fmt.Sprint则是通过反射来实现这一目的的,fmt.Sprint得先判断入参的类型,在知道参数是int型后,再调用fmt.fmtInteger方法把int转换成string,这多出来的步骤肯定没有直接把int转成string来的高效。
// fmtInteger formats signed and unsigned integers.
func (f *fmt) fmtInteger(u uint64, base int, isSigned bool, verb rune, digits string)
...
switch base
case 10:
for u >= 10
i--
next := u / 10
buf[i] = byte(\'0\' + u - next*10)
u = next
...
benchmark还有很多实用的函数,比如ResetTimer可以重置启动时耗费的准备时间,StopTimer和StartTimer则可以暂停和启动计时,让测试结果更集中在核心逻辑上。
2.2 pprof
2.2.1 使用介绍
-
runtime/pprof 通过在代码中显式的增加触发和结束埋点来收集指定代码块运行时数据生成性能报告。 -
net/http/pprof 是对runtime/pprof的二次封装,基于web服务运行,通过访问链接触发,采集服务运行时的数据生成性能报告。
package main
import (
"os"
"runtime/pprof"
"time"
)
func main()
w, _ := os.OpenFile("test_cpu", os.O_RDWR | os.O_CREATE | os.O_APPEND, 0644)
pprof.StartCPUProfile(w)
time.Sleep(time.Second)
pprof.StopCPUProfile()
我们也可以使用另外一种方法,net/http/pprof:
package main
import (
"net/http"
_ "net/http/pprof"
)
func main()
err := http.ListenAndServe(":6060", nil)
if err != nil
panic(err)
将程序run起来后,我们通过访问http://127.0.0.1:6060/debug/pprof/就可以看到如下页面:
pprof支持两种查看模式,终端和web界面,注意: 想要查看可视化界面需要提前安装graphviz。
go tool pprof -http :6060 test_cpu
2.2.1 火焰图 Flame Graph如何阅读
2.2.2 dot Graph 图如何阅读
-
节点颜色:
-
红色表示耗时多的节点; -
绿色表示耗时少的节点; -
灰色表示耗时几乎可以忽略不计(接近零);
-
节点字体大小 :
-
字体越大,表示占“上层函数调用”比例越大;(其实上层函数自身也有耗时,没包含在此) -
字体越小,表示占“上层函数调用”比例越小;
-
线条(边)粗细:
-
线条越粗,表示消耗了更多的资源; -
反之,则越少;
-
线条(边)颜色:
-
颜色越红,表示性能消耗占比越高; -
颜色越绿,表示性能消耗占比越低; -
灰色,表示性能消耗几乎可以忽略不计;
-
虚线:表示中间有一些节点被“移除”或者忽略了;(一般是因为耗时较少所以忽略了) -
实线:表示节点之间直接调用 -
内联边标记:被调用函数已经被内联到调用函数中 (对于一些代码行比较少的函数,编译器倾向于将它们在编译期展开从而消除函数调用,这种行为就是内联。)
2.2.3 TOP 表如何阅读
-
flat:当前函数,运行耗时(不包含内部调用其他函数的耗时) -
flat%:当前函数,占用的 CPU 运行耗时总比例(不包含外部调用函数) -
sum%:当前行的 flat% 与上面所有行的 flat% 总和。 -
cum:当前函数加上它内部的调用的运行总耗时(包含内部调用其他函数的耗时) -
cum%:同上的 CPU 运行耗时总比例
2.3 trace
-
与goroutine调度有关的事件信息:goroutine的创建、启动和结束;goroutine在同步原语(包括mutex、channel收发操作)上的阻塞与解锁。 -
与网络有关的事件:goroutine在网络I/O上的阻塞和解锁; -
与系统调用有关的事件:goroutine进入系统调用与从系统调用返回; -
与垃圾回收器有关的事件:GC的开始/停止,并发标记、清扫的开始/停止。
-
并行执行程度不足的问题:比如没有充分利用多核资源等; -
因GC导致的延迟较大的问题; -
Goroutine执行情况分析,尝试发现goroutine因各种阻塞(锁竞争、系统调用、调度、辅助GC)而导致的有效运行时间较短或延迟的问题。
2.3.1 trace性能报告
-
View trace:以图形页面的形式渲染和展示tracer的数据,这也是我们最为关注/最常用的功能 -
Goroutine analysis:以表的形式记录执行同一个函数的多个goroutine的各项trace数据 -
Network blocking profile:用pprof profile形式的调用关系图展示网络I/O阻塞的情况 -
Synchronization blocking profile:用pprof profile形式的调用关系图展示同步阻塞耗时情况 -
Syscall blocking profile:用pprof profile形式的调用关系图展示系统调用阻塞耗时情况 -
Scheduler latency profile:用pprof profile形式的调用关系图展示调度器延迟情况 -
User-defined tasks和User-defined regions:用户自定义trace的task和region -
Minimum mutator utilization:分析GC对应用延迟和吞吐影响情况的曲线图
2.3.2 view trace
采样状态
-
Title:事件的可读名称; -
Start:事件的开始时间,相对于时间线上的起始时间; -
Wall Duration:这个事件的持续时间,这里表示的是G1在P4上此次持续执行的时间; -
Start Stack Trace:当P4开始执行G1时G1的调用栈; -
End Stack Trace:当P4结束执行G1时G1的调用栈;从上面End Stack Trace栈顶的函数为runtime.asyncPreempt来看,该Goroutine G1是被强行抢占了,这样P4才结束了其运行; -
Incoming flow:触发P4执行G1的事件; -
Outgoing flow:触发G1结束在P4上执行的事件; -
Preceding events:与G1这个goroutine相关的之前的所有的事件; -
Follwing events:与G1这个goroutine相关的之后的所有的事件 -
All connected:与G1这个goroutine相关的所有事件。
2.3.3 Goroutine analysis
2.4 后记
-
获取性能报告麻烦:一般大家做压测,为了更接近真实环境性能态,都使用生产环境/pre环境进行。而出于安全考虑,生产环境内网一般和PC办公内网是隔离不通的,需要单独配置通路才可以获得生产环境内网的profile 文件下载到PC办公电脑中,这也有一些额外的成本; -
查看profile分析报告麻烦:之前大家在本地查看profile 分析报告,一般 go tool pprof -http=":8083" profile 命令在本地PC开启一个web service 查看,并且需要至少安装graphviz 等库。 -
查看trace分析同样麻烦:查看go trace 的profile 信息来分析routine 锁和生命周期时,也需要类似的方式在本地PC执行命令 go tool trace mytrace.profile 。 -
分享麻烦:如果我想把自己压测的性能结果内容,分享个另一位同学,那只能把1中获取的性能报告“profile文件”通过钉钉发给被分享人。然而有时候本地profile文件比较多,一不小心就发错了,还不如截图,但是截图又没有了交互放大、缩小、下钻等能力。处处不给力! -
留存复盘麻烦:系统的性能分析就像一份病历,每每看到阶段性的压测报告,总结或者对照时,不禁要询问,做过了哪些优化和改造,病因病灶是什么,有没有共性,值不值得总结归纳,现在是不是又面临相似的性能问题?
三、性能调优-技巧篇
3.1 字符串拼接
// 推荐:用+进行字符串拼接
func BenchmarkPlus(b *testing.B)
for i := 0; i < b.N; i++
s := "a" + "b"
_ = s
// 不推荐:用fmt.Sprintf进行字符串拼接
func BenchmarkFmt(b *testing.B)
for i := 0; i < b.N; i++
s := fmt.Sprintf("%s%s", "a", "b")
_ = s
goos: darwin
goarch: amd64
pkg: main
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkPlus-12 1000000000 0.2658 ns/op 0 B/op 0 allocs/op
BenchmarkFmt-12 16559949 70.83 ns/op 2 B/op 1 allocs/op
PASS
ok main 5.908s
// 推荐:初始化时指定容量
func BenchmarkGenerateWithCap(b *testing.B)
nums := make([]int, 0, 10000)
for n := 0; n < b.N; n++
for i:=0; i < 10000; i++
nums = append(nums, i)
// 不推荐:初始化时不指定容量
func BenchmarkGenerate(b *testing.B)
nums := make([]int, 0)
for n := 0; n < b.N; n++
for i:=0; i < 10000; i++
nums = append(nums, i)
goos: darwin
goarch: amd64
pkg: main
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkGenerateWithCap-12 23508 336485 ns/op 476667 B/op 0 allocs/op
BenchmarkGenerate-12 22620 68747 ns/op 426141 B/op 0 allocs/op
PASS
ok main 16.628s
3.3 遍历 []struct 使用下标而不是 range
func getIntSlice() []int
nums := make([]int, 1024, 1024)
for i := 0; i < 1024; i++
nums[i] = i
return nums
// 用下标遍历[]int
func BenchmarkIndexIntSlice(b *testing.B)
nums := getIntSlice()
b.ResetTimer()
for i := 0; i < b.N; i++
var tmp int
for k := 0; k < len(nums); k++
tmp = nums[k]
_ = tmp
// 用range遍历[]int元素
func BenchmarkRangeIntSlice(b *testing.B)
nums := getIntSlice()
b.ResetTimer()
for i := 0; i < b.N; i++
var tmp int
for _, num := range nums
tmp = num
_ = tmp
goos: darwin
goarch: amd64
pkg: demo/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkIndexIntSlice-12 3923230 270.2 ns/op 0 B/op 0 allocs/op
BenchmarkRangeIntSlice-12 4518495 287.8 ns/op 0 B/op 0 allocs/op
PASS
ok demo/test 3.303s
可以看到,在遍历[]int时,两种方式并无差别。
type Item struct
id int
val [1024]byte
// 推荐:用下标遍历[]struct
func BenchmarkIndexStructSlice(b *testing.B)
var items [1024]Item
for i := 0; i < b.N; i++
var tmp int
for j := 0; j < len(items); j++
tmp = items[j].id
_ = tmp
// 推荐:用range的下标遍历[]struct
func BenchmarkRangeIndexStructSlice(b *testing.B)
var items [1024]Item
for i := 0; i < b.N; i++
var tmp int
for k := range items
tmp = items[k].id
_ = tmp
// 不推荐:用range遍历[]struct的元素
func BenchmarkRangeStructSlice(b *testing.B)
var items [1024]Item
for i := 0; i < b.N; i++
var tmp int
for _, item := range items
tmp = item.id
_ = tmp
goos: darwin
goarch: amd64
pkg: demo/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkIndexStructSlice-12 4413182 266.7 ns/op 0 B/op 0 allocs/op
BenchmarkRangeIndexStructSlice-12 4545476 269.4 ns/op 0 B/op 0 allocs/op
BenchmarkRangeStructSlice-12 33300 35444 ns/op 0 B/op 0 allocs/op
PASS
ok demo/test 5.282s
可以看到,用for循环下标的方式性能都差不多,但是用range遍历数组里的元素时,性能则相差很多,前面两种方法是第三种方法的130多倍。主要原因是通过for k, v := range获取到的元素v实际上是原始值的一个拷贝。所以在面对复杂的struct进行遍历的时候,推荐使用下标。但是当遍历对象是复杂结构体的指针([]*struct)时,用下标还是用range迭代元素的性能就差不多了。
3.4 利用unsafe包避开内存copy
func Str2bytes(s string) []byte
x := (*[2]uintptr)(unsafe.Pointer(&s))
h := [3]uintptrx[0], x[1], x[1]
return *(*[]byte)(unsafe.Pointer(&h))
func Bytes2str(b []byte) string
return *(*string)(unsafe.Pointer(&b))
我们通过benchmark来验证一下是否性能更优:
// 推荐:用unsafe.Pointer实现string到bytes
func BenchmarkStr2bytes(b *testing.B)
s := "testString"
var bs []byte
for n := 0; n < b.N; n++
bs = Str2bytes(s)
_ = bs
// 不推荐:用类型转换实现string到bytes
func BenchmarkStr2bytes2(b *testing.B)
s := "testString"
var bs []byte
for n := 0; n < b.N; n++
bs = []byte(s)
_ = bs
// 推荐:用unsafe.Pointer实现bytes到string
func BenchmarkBytes2str(b *testing.B)
bs := Str2bytes("testString")
var s string
b.ResetTimer()
for n := 0; n < b.N; n++
s = Bytes2str(bs)
_ = s
// 不推荐:用类型转换实现bytes到string
func BenchmarkBytes2str2(b *testing.B)
bs := Str2bytes("testString")
var s string
b.ResetTimer()
for n := 0; n < b.N; n++
s = string(bs)
_ = s
goos: darwin
goarch: amd64
pkg: demo/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkStr2bytes-12 1000000000 0.2938 ns/op 0 B/op 0 allocs/op
BenchmarkStr2bytes2-12 38193139 28.39 ns/op 16 B/op 1 allocs/op
BenchmarkBytes2str-12 1000000000 0.2552 ns/op 0 B/op 0 allocs/op
BenchmarkBytes2str2-12 60836140 19.60 ns/op 16 B/op 1 allocs/op
PASS
ok demo/test 3.301s
可以看到使用unsafe.Pointer比强制类型转换性能是要高不少的,从内存分配上也可以看到完全没有新的内存被分配。
3.5 协程池
var wg sync.WaitGroup
ch := make(chan struct, 3)
for i := 0; i < 10; i++
ch <- struct
wg.Add(1)
go func(i int)
defer wg.Done()
log.Println(i)
time.Sleep(time.Second)
<-ch
(i)
wg.Wait()
这里通过限制channel长度为3,可以实现最多只有3个协程被创建的效果。
func Test_ErrGroupRun(t *testing.T)
errgroup := WithTimeout(nil, 10*time.Second)
errgroup.SetMaxProcs(4)
for index := 0; index < 10; index++
errgroup.Run(nil, index, "test", func(context *gin.Context, i interface) (interface,
error)
t.Logf("[%s]input:%+v, time:%s", "test", i, time.Now().Format("2006-01-02 15:04:05"))
time.Sleep(2*time.Second)
return i, nil
)
errgroup.Wait()
输出结果如下:
=== RUN Test_ErrGroupRun
errgroup_test.go:23: [test]input:0, time:2022-12-04 17:31:29
errgroup_test.go:23: [test]input:3, time:2022-12-04 17:31:29
errgroup_test.go:23: [test]input:1, time:2022-12-04 17:31:29
errgroup_test.go:23: [test]input:2, time:2022-12-04 17:31:29
errgroup_test.go:23: [test]input:4, time:2022-12-04 17:31:31
errgroup_test.go:23: [test]input:5, time:2022-12-04 17:31:31
errgroup_test.go:23: [test]input:6, time:2022-12-04 17:31:31
errgroup_test.go:23: [test]input:7, time:2022-12-04 17:31:31
errgroup_test.go:23: [test]input:8, time:2022-12-04 17:31:33
errgroup_test.go:23: [test]input:9, time:2022-12-04 17:31:33
--- PASS: Test_ErrGroupRun (6.00s)
PASS
errgroup可以通过SetMaxProcs设定协程池的大小,从上面的结果可以看到,最多就4个协程在运行。
3.6 sync.Pool 对象复用
type ActTask struct
Id int64 `ddb:"id"` // 主键id
Status common.Status `ddb:"status"` // 状态 0=初始 1=生效 2=失效 3=过期
BizProd common.BizProd `ddb:"biz_prod"` // 业务类型
Name string `ddb:"name"` // 活动名
Adcode string `ddb:"adcode"` // 城市
RealTimeRuleByte []byte `ddb:"realtime_rule"` // 实时规则json
...
type RealTimeRuleStruct struct
Filter []*struct
PropertyId int64 `json:"property_id"`
PropertyCode string `json:"property_code"`
Operator string `json:"operator"`
Value []string `json:"value"`
`json:"filter"`
ExtData [1024]byte `json:"ext_data"`
func (at *ActTask) RealTimeRule() *form.RealTimeRule
if err := json.Unmarshal(at.RealTimeRuleByte, &at.RealTimeRuleStruct); err != nil
return nil
return at.RealTimeRuleStruct
以这里的实时投放规则为例,我们会将过滤规则反序列化为字节数组。每次json.Unmarshal都会申请一个临时的结构体对象,而这些对象都是分配在堆上的,会给 GC 造成很大压力,严重影响程序的性能。
var realTimeRulePool = sync.Pool
New: func() interface
return new(RealTimeRuleStruct)
,
然后调用 Pool 的 Get() 和 Put() 方法来获取和放回池子中。
rule := realTimeRulePool.Get().(*RealTimeRuleStruct)
json.Unmarshal(buf, rule)
realTimeRulePool.Put(rule)
-
Get() 用于从对象池中获取对象,因为返回值是 interface,因此需要类型转换。 -
Put() 则是在对象使用完毕后,放回到对象池。
var realTimeRule = []byte("\\\\\\"filter\\\\\\":[\\\\\\"property_id\\\\\\":2,\\\\\\"property_code\\\\\\":\\\\\\"search_poiid_industry\\\\\\",\\\\\\"operator\\\\\\":\\\\\\"in\\\\\\",\\\\\\"value\\\\\\":[\\\\\\"yimei\\\\\\"],\\\\\\"property_id\\\\\\":4,\\\\\\"property_code\\\\\\":\\\\\\"request_page_id\\\\\\",\\\\\\"operator\\\\\\":\\\\\\"in\\\\\\",\\\\\\"value\\\\\\":[\\\\\\"all\\\\\\"]],\\\\\\"white_list\\\\\\":[\\\\\\"property_id\\\\\\":1,\\\\\\"property_code\\\\\\":\\\\\\"white_list_for_adiu\\\\\\",\\\\\\"operator\\\\\\":\\\\\\"in\\\\\\",\\\\\\"value\\\\\\":[\\\\\\"j838ef77bf227chcl89888f3fb0946\\\\\\",\\\\\\"lb89bea9af558589i55559764bc83e\\\\\\"]],\\\\\\"ipc_user_tag\\\\\\":[\\\\\\"property_id\\\\\\":1,\\\\\\"property_code\\\\\\":\\\\\\"ipc_crowd_tag\\\\\\",\\\\\\"operator\\\\\\":\\\\\\"in\\\\\\",\\\\\\"value\\\\\\":[\\\\\\"test_20227041152_mix_ipc_tag\\\\\\"]],\\\\\\"relation_id\\\\\\":0,\\\\\\"is_copy\\\\\\":true")
// 推荐:复用一个对象,不用每次都生成新的
func BenchmarkUnmarshalWithPool(b *testing.B)
for n := 0; n < b.N; n++
task := realTimeRulePool.Get().(*RealTimeRuleStruct)
json.Unmarshal(realTimeRule, task)
realTimeRulePool.Put(task)
// 不推荐:每次都会生成一个新的临时对象
func BenchmarkUnmarshal(b *testing.B)
for n := 0; n < b.N; n++
task := &RealTimeRuleStruct
json.Unmarshal(realTimeRule, task)
goos: darwin
goarch: amd64
pkg: demo/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkUnmarshalWithPool-12 3627546 319.4 ns/op 312 B/op 7 allocs/op
BenchmarkUnmarshal-12 2342208 490.8 ns/op 1464 B/op 8 allocs/op
PASS
ok demo/test 3.525s
可以看到,两种方法在时间消耗上差不太多,但是在内存分配上差距明显,使用sync.Pool后内存占用仅为不使用的1/5。
3.7 避免系统调用
// 推荐:不使用系统调用
func BenchmarkNoSytemcall(b *testing.B)
b.RunParallel(func(pb *testing.PB)
for pb.Next()
if configs.PUBLIC_KEY != nil
)
// 不推荐:使用系统调用
func BenchmarkSytemcall(b *testing.B)
b.RunParallel(func(pb *testing.PB)
for pb.Next()
if os.Getenv("PUBLIC_KEY") != ""
)
goos: darwin
goarch: amd64
pkg: demo/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkNoSytemcall-12 1000000000 0.1495 ns/op 0 B/op 0 allocs/op
BenchmarkSytemcall-12 37224988 31.10 ns/op 0 B/op 0 allocs/op
PASS
ok demo/test 1.877s
四、性能调优-实战篇
案例1: go协程创建数据库连接不释放导致内存暴涨
案例2: 优惠索引内存分配大,gc 耗时高
Elasticsearch 性能优化
Elasticsearch 是当前流行的企业级搜索引擎,设计用于云计算中,能够达到实时搜索,稳定,可靠,快速,安装使用方便。作为一个开箱即用的产品,在生产环境上线之后,我们其实不一定能确保其的性能和稳定性。如何根据实际情况提高服务的性能,其实有很多技巧。这章我们分享从实战经验中总结出来的 elasticsearch 性能优化,主要从硬件配置优化、索引优化设置、查询方面优化、数据结构优化、集群架构优化等方面讲解。
硬件配置优化
升级硬件设备配置一直都是提高服务能力最快速有效的手段,在系统层面能够影响应用性能的一般包括三个因素:CPU、内存和 IO,可以从这三方面进行 ES 的性能优化工作。
CPU 配置
一般说来,CPU 繁忙的原因有以下几个:
- 线程中有无限空循环、无阻塞、正则匹配或者单纯的计算;
- 发生了频繁的 GC;
- 多线程的上下文切换;
大多数 Elasticsearch 部署往往对 CPU 要求不高。因此,相对其它资源,具体配置多少个(CPU)不是那么关键。你应该选择具有多个内核的现代处理器,常见的集群使用 2 到 8 个核的机器。如果你要在更快的 CPUs 和更多的核数之间选择,选择更多的核数更好。多个内核提供的额外并发远胜过稍微快一点点的时钟频率。
内存配置
如果有一种资源是最先被耗尽的,它可能是内存。排序和聚合都很耗内存,所以有足够的堆空间来应付它们是很重要的。即使堆空间是比较小的时候,也能为操作系统文件缓存提供额外的内存。因为 Lucene 使用的许多数据结构是基于磁盘的格式,Elasticsearch 利用操作系统缓存能产生很大效果。
64 GB 内存的机器是非常理想的,但是 32 GB 和 16 GB 机器也是很常见的。少于8 GB 会适得其反(你最终需要很多很多的小机器),大于 64 GB 的机器也会有问题。
由于 ES 构建基于 lucene,而 lucene 设计强大之处在于 lucene 能够很好的利用操作系统内存来缓存索引数据,以提供快速的查询性能。lucene 的索引文件 segements 是存储在单文件中的,并且不可变,对于 OS 来说,能够很友好地将索引文件保持在 cache 中,以便快速访问;因此,我们很有必要将一半的物理内存留给 lucene;另一半的物理内存留给 ES(JVM heap)。
内存分配
当机器内存小于 64G 时,遵循通用的原则,50% 给 ES,50% 留给 lucene。
当机器内存大于 64G 时,遵循以下原则:
- 如果主要的使用场景是全文检索,那么建议给 ES Heap 分配 4~32G 的内存即可;其它内存留给操作系统,供 lucene 使用(segments cache),以提供更快的查询性能。
- 如果主要的使用场景是聚合或排序,并且大多数是 numerics,dates,geo_points 以及 not_analyzed 的字符类型,建议分配给 ES Heap 分配 4~32G 的内存即可,其它内存留给操作系统,供 lucene 使用,提供快速的基于文档的聚类、排序性能。
- 如果使用场景是聚合或排序,并且都是基于 analyzed 字符数据,这时需要更多的 heap size,建议机器上运行多 ES 实例,每个实例保持不超过 50% 的 ES heap 设置(但不超过 32 G,堆内存设置 32 G 以下时,JVM 使用对象指标压缩技巧节省空间),50% 以上留给 lucene。
禁止 swap
禁止 swap,一旦允许内存与磁盘的交换,会引起致命的性能问题。可以通过在 elasticsearch.yml 中 bootstrap.memory_lock: true
,以保持 JVM 锁定内存,保证 ES 的性能。
GC 设置
保持 GC 的现有设置,默认设置为:Concurrent-Mark and Sweep(CMS),别换成 G1 GC,因为目前 G1 还有很多 BUG。
保持线程池的现有设置,目前 ES 的线程池较 1.X 有了较多优化设置,保持现状即可;默认线程池大小等于 CPU 核心数。如果一定要改,按公式 ( ( CPU 核心数 * 3 ) / 2 ) + 1 设置;不能超过 CPU 核心数的 2 倍;但是不建议修改默认配置,否则会对 CPU 造成硬伤。
磁盘
硬盘对所有的集群都很重要,对大量写入的集群更是加倍重要(例如那些存储日志数据的)。硬盘是服务器上最慢的子系统,这意味着那些写入量很大的集群很容易让硬盘饱和,使得它成为集群的瓶颈。
在经济压力能承受的范围下,尽量使用固态硬盘(SSD)。固态硬盘相比于任何旋转介质(机械硬盘,磁带等),无论随机写还是顺序写,都会对 IO 有较大的提升。
如果你正在使用 SSDs,确保你的系统 I/O 调度程序是配置正确的。当你向硬盘写数据,I/O 调度程序决定何时把数据实际发送到硬盘。大多数默认 *nix 发行版下的调度程序都叫做 cfq(完全公平队列)。
调度程序分配时间片到每个进程。并且优化这些到硬盘的众多队列的传递。但它是为旋转介质优化的:机械硬盘的固有特性意味着它写入数据到基于物理布局的硬盘会更高效。
这对 SSD 来说是低效的,尽管这里没有涉及到机械硬盘。但是,deadline 或者 noop 应该被使用。deadline 调度程序基于写入等待时间进行优化,noop 只是一个简单的 FIFO 队列。
这个简单的更改可以带来显著的影响。仅仅是使用正确的调度程序,我们看到了 500 倍的写入能力提升。
如果你使用旋转介质(如机械硬盘),尝试获取尽可能快的硬盘(高性能服务器硬盘,15k RPM 驱动器)。
使用 RAID0 是提高硬盘速度的有效途径,对机械硬盘和 SSD 来说都是如此。没有必要使用镜像或其它 RAID 变体,因为 Elasticsearch 在自身层面通过副本,已经提供了备份的功能,所以不需要利用磁盘的备份功能,同时如果使用磁盘备份功能的话,对写入速度有较大的影响。
最后,避免使用网络附加存储(NAS)。人们常声称他们的 NAS 解决方案比本地驱动器更快更可靠。除却这些声称,我们从没看到 NAS 能配得上它的大肆宣传。NAS 常常很慢,显露出更大的延时和更宽的平均延时方差,而且它是单点故障的。
索引优化设置
索引优化主要是在 Elasticsearch 的插入层面优化,Elasticsearch 本身索引速度其实还是蛮快的,具体数据,我们可以参考官方的 benchmark 数据。我们可以根据不同的需求,针对索引优化。
批量提交
当有大量数据提交的时候,建议采用批量提交(Bulk 操作);此外使用 bulk 请求时,每个请求不超过几十M,因为太大会导致内存使用过大。
比如在做 ELK 过程中,Logstash indexer 提交数据到 Elasticsearch 中,batch size 就可以作为一个优化功能点。但是优化 size 大小需要根据文档大小和服务器性能而定。
像 Logstash 中提交文档大小超过 20MB,Logstash 会将一个批量请求切分为多个批量请求。
如果在提交过程中,遇到 EsRejectedExecutionException 异常的话,则说明集群的索引性能已经达到极限了。这种情况,要么提高服务器集群的资源,要么根据业务规则,减少数据收集速度,比如只收集 Warn、Error 级别以上的日志。
增加 Refresh 时间间隔
为了提高索引性能,Elasticsearch 在写入数据的时候,采用延迟写入的策略,即数据先写到内存中,当超过默认1秒(index.refresh_interval)会进行一次写入操作,就是将内存中 segment 数据刷新到磁盘中,此时我们才能将数据搜索出来,所以这就是为什么 Elasticsearch 提供的是近实时搜索功能,而不是实时搜索功能。
如果我们的系统对数据延迟要求不高的话,我们可以通过延长 refresh 时间间隔,可以有效地减少 segment 合并压力,提高索引速度。比如在做全链路跟踪的过程中,我们就将 index.refresh_interval
设置为30s,减少 refresh 次数。再如,在进行全量索引时,可以将 refresh 次数临时关闭,即 index.refresh_interval
设置为-1,数据导入成功后再打开到正常模式,比如30s。
在加载大量数据时候可以暂时不用 refresh 和 repliccas,index.refresh_interval 设置为-1,index.number_of_replicas 设置为0。
修改 index_buffer_size 的设置
索引缓冲的设置可以控制多少内存分配给索引进程。这是一个全局配置,会应用于一个节点上所有不同的分片上。
indices.memory.index_buffer_size: 10%
indices.memory.min_index_buffer_size: 48mb
indices.memory.index_buffer_size
接受一个百分比或者一个表示字节大小的值。默认是10%,意味着分配给节点的总内存的10%用来做索引缓冲的大小。这个数值被分到不同的分片(shards)上。如果设置的是百分比,还可以设置 min_index_buffer_size
(默认 48mb)和 max_index_buffer_size
(默认没有上限)。
修改 translog 相关的设置
一是控制数据从内存到硬盘的操作频率,以减少硬盘 IO。可将 sync_interval 的时间设置大一些。默认为5s。
index.translog.sync_interval: 5s
也可以控制 tranlog 数据块的大小,达到 threshold 大小时,才会 flush 到 lucene 索引文件。默认为512m。
index.translog.flush_threshold_size: 512mb
注意 _id 字段的使用
_id 字段的使用,应尽可能避免自定义 _id,以避免针对 ID 的版本管理;建议使用 ES 的默认 ID 生成策略或使用数字类型 ID 做为主键。
注意 _all 字段及 _source 字段的使用
_all 字段及 _source 字段的使用,应该注意场景和需要,_all 字段包含了所有的索引字段,方便做全文检索,如果无此需求,可以禁用;_source 存储了原始的 document 内容,如果没有获取原始文档数据的需求,可通过设置 includes、excludes 属性来定义放入 _source 的字段。
合理的配置使用 index 属性
合理的配置使用 index 属性,analyzed 和 not_analyzed,根据业务需求来控制字段是否分词或不分词。只有 groupby 需求的字段,配置时就设置成 not_analyzed,以提高查询或聚类的效率。
减少副本数量
Elasticsearch 默认副本数量为3个,虽然这样会提高集群的可用性,增加搜索的并发数,但是同时也会影响写入索引的效率。
在索引过程中,需要把更新的文档发到副本节点上,等副本节点生效后在进行返回结束。使用 Elasticsearch 做业务搜索的时候,建议副本数目还是设置为3个,但是像内部 ELK 日志系统、分布式跟踪系统中,完全可以将副本数目设置为1个。
查询方面优化
Elasticsearch 作为业务搜索的近实时查询时,查询效率的优化显得尤为重要。
路由优化
当我们查询文档的时候,Elasticsearch 如何知道一个文档应该存放到哪个分片中呢?它其实是通过下面这个公式来计算出来的。
shard = hash(routing) % number_of_primary_shards
routing 默认值是文档的 id,也可以采用自定义值,比如用户 ID。
不带 routing 查询
在查询的时候因为不知道要查询的数据具体在哪个分片上,所以整个过程分为2个步骤:
- 分发:请求到达协调节点后,协调节点将查询请求分发到每个分片上。
- 聚合:协调节点搜集到每个分片上查询结果,再将查询的结果进行排序,之后给用户返回结果。
带 routing 查询
查询的时候,可以直接根据 routing 信息定位到某个分配查询,不需要查询所有的分配,经过协调节点排序。
向上面自定义的用户查询,如果 routing 设置为 userid 的话,就可以直接查询出数据来,效率提升很多。
Filter VS Query
尽可能使用过滤器上下文(Filter)替代查询上下文(Query)
- Query:此文档与此查询子句的匹配程度如何?
- Filter:此文档和查询子句匹配吗?
Elasticsearch 针对 Filter 查询只需要回答「是」或者「否」,不需要像 Query 查询一样计算相关性分数,同时Filter结果可以缓存。
深度翻页
在使用 Elasticsearch 过程中,应尽量避免大翻页的出现。
正常翻页查询都是从 from 开始 size 条数据,这样就需要在每个分片中查询打分排名在前面的 from+size 条数据。协同节点收集每个分配的前 from+size 条数据。协同节点一共会受到 N*(from+size) 条数据,然后进行排序,再将其中 from 到 from+size 条数据返回出去。如果 from 或者 size 很大的话,导致参加排序的数量会同步扩大很多,最终会导致 CPU 资源消耗增大。
可以通过使用 Elasticsearch scroll 和 scroll-scan 高效滚动的方式来解决这样的问题。
也可以结合实际业务特点,文档 id 大小如果和文档创建时间是一致有序的,可以以文档 id 作为分页的偏移量,并将其作为分页查询的一个条件。
脚本(script)合理使用
我们知道脚本使用主要有 3 种形式,内联动态编译方式、_script 索引库中存储和文件脚本存储的形式;一般脚本的使用场景是粗排,尽量用第二种方式先将脚本存储在 _script 索引库中,起到提前编译,然后通过引用脚本 id,并结合 params 参数使用,即可以达到模型(逻辑)和数据进行了分离,同时又便于脚本模块的扩展与维护。具体 ES 脚本的深入内容请参考 Elasticsearch 脚本模块的详解。
数据结构优化
基于 Elasticsearch 的使用场景,文档数据结构尽量和使用场景进行结合,去掉没用及不合理的数据。
尽量减少不需要的字段
如果 Elasticsearch 用于业务搜索服务,一些不需要用于搜索的字段最好不存到 ES 中,这样即节省空间,同时在相同的数据量下,也能提高搜索性能。
避免使用动态值作字段,动态递增的 mapping,会导致集群崩溃;同样,也需要控制字段的数量,业务中不使用的字段,就不要索引。控制索引的字段数量、mapping 深度、索引字段的类型,对于 ES 的性能优化是重中之重。
以下是 ES 关于字段数、mapping 深度的一些默认设置:
index.mapping.nested_objects.limit: 10000
index.mapping.total_fields.limit: 1000
index.mapping.depth.limit: 20
Nested Object vs Parent/Child
尽量避免使用 nested 或 parent/child 的字段,能不用就不用;nested query 慢,parent/child query 更慢,比 nested query 慢上百倍;因此能在 mapping 设计阶段搞定的(大宽表设计或采用比较 smart 的数据结构),就不要用父子关系的 mapping。
如果一定要使用 nested fields,保证 nested fields 字段不能过多,目前 ES 默认限制是 50。因为针对 1 个 document,每一个 nested field,都会生成一个独立的 document,这将使 doc 数量剧增,影响查询效率,尤其是 JOIN 的效率。
index.mapping.nested_fields.limit: 50
对比 | Nested Object | Parent/Child |
---|---|---|
优点 | 文档存储在一起,因此读取性高 | 父子文档可以独立更新,互不影响 |
缺点 | 更新父文档或子文档时需要更新整个文档 | 为了维护 join 关系,需要占用部分内存,读取性能较差 |
场景 | 子文档偶尔更新,查询频繁 | 子文档更新频繁 |
选择静态映射,非必需时,禁止动态映射
尽量避免使用动态映射,这样有可能会导致集群崩溃,此外,动态映射有可能会带来不可控制的数据类型,进而有可能导致在查询端出现相关异常,影响业务。
此外,Elasticsearch 作为搜索引擎时,主要承载 query 的匹配和排序的功能,那数据的存储类型基于这两种功能的用途分为两类,一是需要匹配的字段,用来建立倒排索引对 query 匹配用,另一类字段是用做粗排用到的特征字段,如 ctr、点击数、评论数等等。
集群架构设计
合理的部署 Elasticsearch 有助于提高服务的整体可用性。
主节点、数据节点和协调节点分离
Elasticsearch 集群在架构拓朴时,采用主节点、数据节点和负载均衡节点分离的架构,在 5.x 版本以后,又可将数据节点再细分为“Hot-Warm”的架构模式。
Elasticsearch 的配置文件中有 2 个参数,node.master 和 node.data。这两个参数搭配使用时,能够帮助提供服务器性能。
主(master)节点
配置 node.master:true
和 node.data:false
,该 node 服务器只作为一个主节点,但不存储任何索引数据。我们推荐每个集群运行3 个专用的 master 节点来提供最好的弹性。使用时,你还需要将 discovery.zen.minimum_master_nodes setting
参数设置为 2,以免出现脑裂(split-brain)的情况。用 3 个专用的 master 节点,专门负责处理集群的管理以及加强状态的整体稳定性。因为这 3 个 master 节点不包含数据也不会实际参与搜索以及索引操作,在 JVM 上它们不用做相同的事,例如繁重的索引或者耗时,资源耗费很大的搜索。因此不太可能会因为垃圾回收而导致停顿。因此,master 节点的 CPU,内存以及磁盘配置可以比 data 节点少很多的。
数据(data)节点
配置 node.master:false
和 node.data:true
,该 node 服务器只作为一个数据节点,只用于存储索引数据,使该 node 服务器功能单一,只用于数据存储和数据查询,降低其资源消耗率。
在 Elasticsearch 5.x 版本之后,data 节点又可再细分为“Hot-Warm”架构,即分为热节点(hot node)和暖节点(warm node)。
hot 节点:
hot 节点主要是索引节点(写节点),同时会保存近期的一些频繁被查询的索引。由于进行索引非常耗费 CPU 和 IO,即属于 IO 和 CPU 密集型操作,建议使用 SSD 的磁盘类型,保持良好的写性能;我们推荐部署最小化的 3 个 hot 节点来保证高可用性。根据近期需要收集以及查询的数据量,可以增加服务器数量来获得想要的性能。
将节点设置为 hot 类型需要 elasticsearch.yml 如下配置:
node.attr.box_type: hot
如果是针对指定的 index 操作,可以通过 settings 设置 index.routing.allocation.require.box_type: hot
将索引写入 hot 节点。
warm 节点:
这种类型的节点是为了处理大量的,而且不经常访问的只读索引而设计的。由于这些索引是只读的,warm 节点倾向于挂载大量磁盘(普通磁盘)来替代 SSD。内存、CPU 的配置跟 hot 节点保持一致即可;节点数量一般也是大于等于 3 个。
将节点设置为 warm 类型需要 elasticsearch.yml 如下配置:
node.attr.box_type: warm
同时,也可以在 elasticsearch.yml 中设置 index.codec:best_compression
保证 warm 节点的压缩配置。
当索引不再被频繁查询时,可通过 index.routing.allocation.require.box_type:warm
,将索引标记为 warm,从而保证索引不写入 hot 节点,以便将 SSD 磁盘资源用在刀刃上。一旦设置这个属性,ES 会自动将索引合并到 warm 节点。
协调(coordinating)节点
协调节点用于做分布式里的协调,将各分片或节点返回的数据整合后返回。该节点不会被选作主节点,也不会存储任何索引数据。该服务器主要用于查询负载均衡。在查询的时候,通常会涉及到从多个 node 服务器上查询数据,并将请求分发到多个指定的 node 服务器,并对各个 node 服务器返回的结果进行一个汇总处理,最终返回给客户端。在 ES 集群中,所有的节点都有可能是协调节点,但是,可以通过设置 node.master
、node.data
、node.ingest
都为 false
来设置专门的协调节点。需要较好的 CPU 和较高的内存。
- node.master:false和node.data:true,该node服务器只作为一个数据节点,只用于存储索引数据,使该node服务器功能单一,只用于数据存储和数据查询,降低其资源消耗率。
- node.master:true和node.data:false,该node服务器只作为一个主节点,但不存储任何索引数据,该node服务器将使用自身空闲的资源,来协调各种创建索引请求或者查询请求,并将这些请求合理分发到相关的node服务器上。
- node.master:false和node.data:false,该node服务器即不会被选作主节点,也不会存储任何索引数据。该服务器主要用于查询负载均衡。在查询的时候,通常会涉及到从多个node服务器上查询数据,并将请求分发到多个指定的node服务器,并对各个node服务器返回的结果进行一个汇总处理,最终返回给客户端。
关闭 data 节点服务器中的 http 功能
针对 Elasticsearch 集群中的所有数据节点,不用开启 http 服务。将其中的配置参数这样设置,http.enabled:false
,同时也不要安装 head, bigdesk, marvel 等监控插件,这样保证 data 节点服务器只需处理创建/更新/删除/查询索引数据等操作。
http 功能可以在非数据节点服务器上开启,上述相关的监控插件也安装到这些服务器上,用于监控 Elasticsearch 集群状态等数据信息。这样做一来出于数据安全考虑,二来出于服务性能考虑。
一台服务器上最好只部署一个 node
一台物理服务器上可以启动多个 node 服务器节点(通过设置不同的启动 port),但一台服务器上的 CPU、内存、硬盘等资源毕竟有限,从服务器性能考虑,不建议一台服务器上启动多个 node 节点。
集群分片设置
ES 一旦创建好索引后,就无法调整分片的设置,而在 ES 中,一个分片实际上对应一个 lucene 索引,而 lucene 索引的读写会占用很多的系统资源,因此,分片数不能设置过大;所以,在创建索引时,合理配置分片数是非常重要的。一般来说,我们遵循一些原则:
- 控制每个分片占用的硬盘容量不超过 ES 的最大 JVM 的堆空间设置(一般设置不超过 32 G,参考上面的 JVM 内存设置原则),因此,如果索引的总容量在 500 G 左右,那分片大小在 16 个左右即可;当然,最好同时考虑原则 2。
- 考虑一下 node 数量,一般一个节点有时候就是一台物理机,如果分片数过多,大大超过了节点数,很可能会导致一个节点上存在多个分片,一旦该节点故障,即使保持了 1 个以上的副本,同样有可能会导致数据丢失,集群无法恢复。所以,一般都设置分片数不超过节点数的 3 倍。
以上是关于高德Go生态的服务稳定性建设|性能优化的实战总结的主要内容,如果未能解决你的问题,请参考以下文章