欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

web server性能优化浅谈

程序员文章站 2022-03-18 16:30:56
作者: "Jack47, ZhiYan" 转载请保留作者和 "原文出处" 性能优化,优化的东西一定得在主路径上,结合测量的结果去优化。不然即使性能再好,逻辑相对而言执行不了几次,其实对提示性能的影响微乎其微。记得抖哥以前说多隆在帮忙查广告搜索引擎的问题,看到了一处代码,激动的说这里用他的办法,性能可 ......

作者:

转载请保留作者和

性能优化,优化的东西一定得在主路径上,结合测量的结果去优化。不然即使性能再好,逻辑相对而言执行不了几次,其实对提示性能的影响微乎其微。记得抖哥以前说多隆在帮忙查广告搜索引擎的问题,看到了一处代码,激动的说这里用他的办法,性能可以提升至少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();后来我们优化了一下,采用了标准库中 中的 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();

尽量复用资源

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

+   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的使用

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

sync.Map适合两种用途:

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

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

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

锁相关

Mutexes

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

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

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

Reference:

锁的粒度尽量小

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

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

Reference:

RWMutexes

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

比如:

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

尽量使用无锁的方式:

是否真的需要加锁?是否能用CAS的操作来代替Mutex?

例如:

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

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

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

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

内存相关

减少内存分配的次数

生成字符串时,尽量写入 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语言感兴趣的同学,可以看

避免内存拷贝

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

-   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方面,提供了很多强有力的工具,可以参加下面的文章:

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



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