web server性能优化浅谈

作者:ZhiYan,Jack47

转载请保留作者和原文出处
Update:

2018.8.8 在无锁小节增加了一些内容

性能优化,优化的东西一定得在主路径上,结合测量的结果去优化。不然即使性能再好,逻辑相对而言执行不了几次,其实对提示性能的影响微乎其微。记得抖哥以前说多隆在帮忙查广告搜索引擎的问题,看到了一处代码,激动的说这里用他的办法,性能可以提升至少10倍。但实际上,这里的逻辑基本走不到 face_palm。

性能优化的几个跟语言无关的大方向:

减少算法的时间复杂度

例子1

我们实现了一个CallBack的机制,一段执行流程里,会有多个plugin,每个plugin可以添加callback,每个callback有唯一的名字;添加callback时,需要注意覆盖的问题,如果覆盖了,需要返回老的callback。一开始我们的实现机制是使用数组,这样添加时,需要挨个遍历,查看是否时覆盖的情况。Update操作的时间复杂度为O(n);后来我们添加了一个辅助的Map,用来存储 <name, callbackIdx>的映射关系。Update的平均时间复杂度降低为O(1)

例子2

在我们的pipeline场景里,类似net/http里的context,我们有个task的概念的。每个阶段(plugin)都可以向里面塞数据,一开始为了支持cancel某个阶段,重新执行这个阶段的功能,我们是使用嵌套,类似递归的方式。这样就可以很方便的撤销某个阶段放入的数据。但是这种设计,如果要从里面取数据,需要层层遍历,类似递归一样,时间复杂度为O(n);因为每个plugin都会与task打交道,所以这里 task里数据的存取是高频操作,而且我们后来经过权衡,觉得支持取消掉某个阶段对task的操作,不是必须的,不支持也没关系,所以后来简化了task的设计,直接用一个map来做,这样时间复杂度又降下来了。

根据业务逻辑,设计优化的数据结构

我们有个场景,是要对URL执行类似归一化的操作,把里面重复的\字符删掉,比如 \\ -> \。这个逻辑对于网关,是高频逻辑,因为每个请求来了,都需要判断,但是真正要删掉重复的\的操作,其实比较少,大部分场景是检查完,发现正常,不需要做修改。

一开始我们的实现是把url字符挨个检查,没问题的放入 bytes.Buffer 中,最终返回 buffer.String();后来我们优化了一下,采用了标准库中 path/path.go 中的 Lazybuf 的方式,LazyBuf中发现要写入的字符和基准的字符不一样时,才分配内存来存储修改后的字符串,不然最终还是基准的字符,直接返回就行,避免了无谓的内存拷贝操作。

这里其实体现了一个小技巧,尽量想想自己需要的操作,是否标准库里有,同时也要多看看标准库的实现,吸取经验。

尽量减少磁盘IO次数

IO操作尽量批量进行。比如我们的网关会记录访问日志,类似Nginx的access.log。在生产环境/压测环境下,会生成大量的日志,虽然操作系统写入文件是有缓冲的,但是这个缓冲机制我们应用程序没法直接控制,而且写入文件时调用系统API,也比较耗时。我们可以在应用层面,给日志留缓冲区(buffer),定时或达到一定量(4k,跟虚拟文件系统的块大小保持一致)时调用操作系统IO操作来写入日志。

总结一下,就是写入日志是异步的,同时是攒够一批之后,再调用操作系统的写入

具体实现:进来的数据,先放到一个2048字节大小的channel里,由一个固定的go routine负责不断的从channel里读取数据,写入到buffered io里。这里2048字节的channel,类似队列一样,是有削峰作用的。当有大批日志写入时,channel可以暂时缓冲一下,降低 buffer.io 真正flush的频率。;写入文件时,套上一个 bufio.Writer(size=512),即内部是有512字节大小的缓冲区,满了才使用整块数据调用Write();

尽量复用资源

资源的申请和释放,跟内存(也是一种资源)的申请和释放其实是一样的,尽量复用,避免重复/频繁申请;
比如下面的这个time.Tick,适用于使用者不需要关闭它,即非频繁调用的情况。使用它很方便,但是要注意,它没法关闭,所以垃圾回收器也没法回收它。来看一下下面的这段代码修改记录:

+	ticker := time.NewTicker(time.Second)
+	defer ticker.Stop()
+
 	for {
 		select {
-		case <-time.Tick(time.Second):
+		case <-ticker.C:

修改前,for循环里会频繁创建time.Ticker,但都没有回收机制。改动后,for循环里复用同一个time.Ticker,而且会在当前函数执行结束时释放time.Ticker。

sync.Map的使用

其实看清楚map.go里的注释,注意使用场景。

sync.Map适合两种用途:

  1. 指定的key,value只会被写入一次,但是会被读取很多次
  2. 多个goroutine读取、写入、覆盖的数据都是没有交集的

只有上述情况下,sync.Map才能相比Go map搭配单独的Mutex或RWMutex而言,显著降低锁的竞争,均摊复杂度是常数(amortized constant time)

大部分情况下,应该用 map ,然后用单独的锁或者同步机制,这样类型安全,而且可以有其他的逻辑

锁相关

Mutexes

锁在满足以下条件的情况下,是很快的:

  • 没有其他人竞争 (想象为挤公交车,此时没人跟你抢,你直接上车)
  • 锁覆盖的代码,执行时间非常快 (想象为挤公交车,大家速度都很快,嗖嗖就上去了,下一个人等待上一个人挤上去的时间很短)

当竞争越激烈,锁的性能下降的越厉害。

Reference:

locks aren't slow, lock contention is

锁的粒度尽量小

比如我们的pipeline生命周期的管理,一开始是通过一把大锁来控制并发的,后续优化时,发现里面可以细分成两块,各自可以用一把锁来控制,这样锁的粒度变小,并发程度会提高。

这里比较好的例子是BigCache的实现。它使用分片(sharding)的方式,
跟Java 7里的concurrent hash map的实现类似,对数据进行分片,分片之间是独立的,可以并发的进行写操作。对细分后的分片进行并发控制,这样能有效减小锁的粒度,让并发度尽可能高。

Reference: Writing Fast Cache Service in Go

RWMutexes

  1. 是否有多读少写的场景,如果是,尽量用读写锁;这样尽量把写锁的粒度缩小,能用读锁解决的,就不需要用写锁,真正需要修改结果时,才使用写锁。

比如:

func (b *DataBucket) QueryDataWithBindDefault(key interface{}, defaultValueFunc DefaultValueFunc) (interface{}, error) {
	先上读锁,看key是否存在,如果存在,就返回 // 大部分情况下是这样,所以这个优化肯定很有意义
	否则,上写锁,把默认值加上 // 这种情况只会发生一次
 }

尽量使用无锁的方式:

是否真的需要使用加锁的方式来保证整个代码块是互斥的?是否能用原子操作(CAS)来代替锁?
原子操作和锁的主要区别在于锁的粒度,使用锁,可以让锁保护的整个代码块是互斥的,使用原子操作,只能让操作的这个变量是互斥的。所以原子操作适合修改某个值的情况。

例如:

利用 atmoic int stopped = 0/1 来代表是否停止,需要停止时,设置为1。

golang里Atomic操作有:Atomic.CompareAndSet, LoadInt(), StoreInt()

如果利用某个变量代表现在是否在干活,close时需要等别人干完活,那么在close时,需要通过spin的方式等待干活的人结束:

for atomic.LoadInt(&doing) > 0 {
	sleep(1ms)
}

关于原子操作,可以看看 JDK 7 里 ConcurrentHashMap的实现,大量使用了原子操作,保证是无锁,非阻塞的。比如里面segments[]是懒初始化,如果需要writer初始化一个segment,赋值给segments时,就可以用CompareAndSet。

内存相关

减少内存分配的次数

生成字符串时,尽量写入 bytes.Buffer, 而不是用 fmt.Sprintf()

+	var repeatingRune rune
-	result := string(s[0])	
+	result := bytes.NewBuffer(nil)
	for _, r := range s[:1] {
-		result = fmt.Sprintf("%s%s", result, string(r))	
+		result.WriteRune(r)
+	}

数据结构初始化时,尽量指定合适的容量

比如Java或者Go里面,如果数组,Map的大小已知,可以在声明时指定大小,这样避免后续追加数据时需要扩展内部容量,造成多次内存分配

-	eventStream := make(chan cluster.Event)
+	eventStream := make(chan cluster.Event, 1024)

语言(Go)相关

语言相关的其实还有很多,但是随着语言的发展,基本上都会被解决掉,所以这里只提一下下面的这个,对Go语言感兴趣的同学,可以看So You Wanna Go Fast

避免内存拷贝

如下的代码,两者有什么区别?

-	for _, bucket := range s.buckets {
-		bucket.Update(v)
+	for i := 0; i < len(s.buckets); i++ {
		buckets[i].Update(v)

修改前的这种方式,bucket是通过拷贝生成的临时变量;而且这种方式下,由于操作的是临时变量,所以 s.buckets并不会被更新!

Go routine虽好,也有代价

我们的网关,一开始的时候,由于大家也都是刚接触Go语言,用Go routine用的也顺手,所以很喜欢用Go routine;比如我们的主流程里,需要记录本次请求的一些指标,为了不影响主流程的执行,这些记录指标的逻辑都是启动一个新的go routine去执行的。后来发现我们在一台机器上,一个程序里,某一时刻启动了十万计的go routine,而这些go routine生命周期很短,会不断的销毁和创建。我也简单的用Go Benchmark测试模拟了一个场景,测试了之后发现go routine数量上去后,性能下降很大,说明此时的调度开销也比较大了。后来我们修改了设计,让大家把需要更新的数据放到channel里,启动固定的go routine去做更新的事情,这样可以避免频繁创建go routine的情况。

使用多个http.Client来发送请求

一开始我们是通过一个http.Client来发送同一个API的请求,后来担心这里可能存在并发的瓶颈,尝试了创建多个http.Client,发送时随机使用某一个发送的机制,发现性能提升了。其实性能有多少提升,取决于使用场景的,还是得实际测量,用数值说话,我们的方法不一定对你们有用!

Go语言在benchmark方面,提供了很多强有力的工具,可以参加下面的文章:

High performance go workshop

An Introduction to go tool trace

Writing and Optimizing Go code

Go tooling essentials

好了,以上就是所有内容了,欢迎留下你的性能优化的思路和方法!


如果您看了本篇博客,有所收获,请点击右下角的“推荐”,让更多人看到!
打赏也是对自己的肯定
pay_weixin
微信打赏

posted on 2018-07-31 23:11  生栋  阅读(1772)  评论(2编辑  收藏  举报

导航