作者: 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(); 后来我们优化了一下, 采用了标准库中 https://github.com/golang/go/blob/master/src/path/filepath/path.go#L21-L24 中的 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 https://github.com/golang/go/blob/master/src/time/tick.go#L49-L53 , 适用于使用者不需要关闭它, 即非频繁调用的情况. 使用它很方便, 但是要注意, 它没法关闭, 所以垃圾回收器也没法回收它. 来看一下下面的这段代码修改记录:
- + 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 的使用
其实看清楚 https://github.com/golang/go/blob/master/src/sync/map.go#L12-L26 里的注释, 注意使用场景.
sync.Map 适合两种用途:
指定的 key,value 只会被写入一次, 但是会被读取很多次
多个 goroutine 读取, 写入, 覆盖的数据都是没有交集的
只有上述情况下, sync.Map 才能相比 Go map 搭配单独的 Mutex 或 RWMutex 而言, 显著降低锁的竞争, 均摊复杂度是常数(amortized constant time)
大部分情况下, 应该用 map , 然后用单独的锁或者同步机制, 这样类型安全, 而且可以有其他的逻辑
锁相关
Mutexes
锁在满足以下条件的情况下, 是很快的:
没有其他人竞争 (想象为挤公交车, 此时没人跟你抢, 你直接上车)
锁覆盖的代码, 执行时间非常快 (想象为挤公交车, 大家速度都很快, 嗖嗖就上去了, 下一个人等待上一个人挤上去的时间很短)
当竞争越激烈, 锁的性能下降的越厉害.
Reference:
locks aren't slow, lock contention is http://preshing.com/20111118/locks-arent-slow-lock-contention-is/
锁的粒度尽量小
比如我们的 pipeline 生命周期的管理, 一开始是通过一把大锁来控制并发的, 后续优化时, 发现里面可以细分成两块, 各自可以用一把锁来控制, 这样锁的粒度变小, 并发程度会提高.
这里比较好的例子是 BigCache https://github.com/allegro/bigcache 的实现. 它使用分片 (sharding) 的方式,
跟 Java 7 里的 concurrent hash map https://github.com/openjdk-mirror/jdk7u-jdk/blob/master/src/share/classes/java/util/concurrent/ConcurrentHashMap.java#L107 的实现类似, 对数据进行分片, 分片之间是独立的, 可以并发的进行写操作. 对细分后的分片进行并发控制, 这样能有效减小锁的粒度, 让并发度尽可能高.
- Reference: Writing Fast Cache Service in Go https://allegro.tech/2016/03/writing-fast-cache-service-in-go.html
- RWMutexes
是否有多读少写的场景, 如果是, 尽量用读写锁; 这样尽量把写锁的粒度缩小, 能用读锁解决的, 就不需要用写锁, 真正需要修改结果时, 才使用写锁.
https://github.com/megaease/easegateway/commit/04d42e9779a276ef222b23186c9c65d79d48a85b
比如:
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 语言感兴趣的同学, 可以看 So You Wanna Go Fast https://www.slideshare.net/TylerTreat/so-you-wanna-go-fast-80300458
避免内存拷贝
如下的代码, 两者有什么区别?
- - 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 https://talks.godoc.org/github.com/davecheney/high-performance-go-workshop/high-performance-go-workshop.slide
An Introduction to go tool trace https://about.sourcegraph.com/go/an-introduction-to-go-tool-trace-rhys-hiltner/
- Writing and Optimizing Go code https://github.com/dgryski/go-perfbook/blob/master/performance.md
- Go tooling essentials https://rakyll.org/go-tool-flags/
好了, 以上就是所有内容了, 欢迎留下你的性能优化的思路和方法!
来源: https://www.cnblogs.com/Jack47/p/performance-optimizing-for-gateway.html