一个95分位延迟要求5ms的场景,如何做性能优化

存储
组内的数据系统在承接一个业务需求时无法满足性能需求,于是针对这个场景做了一些优化,在此写篇文章做记录。

[[414231]]

本文转载自微信公众号「薯条的编程修养」,作者程序员薯条。转载本文请联系薯条的编程修养公众号。

组内的数据系统在承接一个业务需求时无法满足性能需求,于是针对这个场景做了一些优化,在此写篇文章做记录。

业务场景是这样:调用方一次获取某个用户的几百个特征(可以把特征理解为属性),特征以 redis hash 的形式存储在持久化 KV 数据库中,特征数据以天级别为更新粒度。要求 95 分位的延迟在 5ms 左右。

这个数据系统属于无状态的服务,为了增大吞吐量和降低延迟,从存储和代码两方面进行优化。

存储层面

存储层面,一次调用一个用户的三百个特征原方案是用 redis hash 做表,每个 field 为用户的一个特征。由于用户单个请求会获取几百个特征,即使用hmget做合并,存储也需要去多个 slot 中获取数据,效率较低,于是对数据进行归一化,即:把 hash 表的所有 filed 打包成一个 json 格式的 string,举个例子:

  1. // 优化前的特征为 hash 格式 
  2. hash key : user_2837947 
  3. 127.0.0.1:6379> hgetall user_2837947 
  4. 1) "name"    // 特征1 
  5. 2) "薯条"     // 特征1的值 
  6. 3) "age"    // 特征2 
  7. 4) "18"     // 特征2的值 
  8. 5) "address" // 特征3 
  9. 6) "China"   // 特征3的值 
  10.  
  11. // 优化后的特征为 string json格式 
  12. string key: user_2837947 
  13. val: 
  14.   "name":"薯条"
  15.   "age":18, 
  16.   "address":"China" 

特征进行打包后解决了一次请求去多个 slot 获取数据时延较大的问题。但是这样做可能带来新的问题:若 hash filed 过多,string 的 value 值会很大。目前想到的解法有两种,一种是按照类型将特征做细分,比如原来一个 string 里面有 300 的字段,拆分成 3 个有 100 个值的 string 类型。第二种是对 string val 进行压缩,在数据存储时压缩存储,读取数据时在程序中解压缩。这两种方法也可以结合使用。

如果这样仍不能满足需求,可以在持久化 KV 存储前再加一层缓存,缓存失效时间根据业务特点设置,这样程序交互的流程会变成这样:

代码层面

接着来优化一下代码。首先需要几个工具去协助我们做性能优化。首先是压测工具,压测工具可以模拟真实流量,在预估的 QPS 下观察系统的表现情况。发压时注意渐进式加压,不要一下次压得太死。

然后还需要 profiler 工具。Golang 的生态中相关工具我们能用到的有 pprof 和 trace。pprof 可以看 CPU、内存、协程等信息在压测流量进来时系统调用的各部分耗时情况。而 trace 可以查看 runtime 的情况,比如可以查看协程调度信息等。本次优化使用 压测工具+pprof 的 CPU profiler。

下面来看一下 CPU 运行耗时情况:

右侧主要是 runtime 部分,先忽略

火焰图中圈出来的大平顶山都是可以优化的地方,

这里的三座平顶山的主要都是json.Marshal和json.Unmarshal操作引起的,对于 json 的优化,有两种思路,一种是换个高性能的 json 解析包 ,另一种是根据业务需求看能否绕过解析。下面分别来介绍:

高性能解析包+一点黑科技

这里使用了陶师傅的包github.com/json-iterator/go。看了他的 benchmark 结果,比 golang 原生库还是要快很多的。自己再写个比较符合我们场景的Benchmark看陶师傅有没有骗我们:

  1. package main 
  2.  
  3. import ( 
  4.  "encoding/json" 
  5.  jsoniter "github.com/json-iterator/go" 
  6.  "testing" 
  7.  
  8. var s = `{....300多个filed..}` 
  9.  
  10. func BenchmarkDefaultJSON(b *testing.B) { 
  11.  for i := 0; i < b.N; i++ { 
  12.   param := make(map[string]interface{}) 
  13.   _ = json.Unmarshal([]byte(s), &param) 
  14.  } 
  15.  
  16. func BenchmarkIteratorJSON(b *testing.B) { 
  17.  for i := 0; i < b.N; i++ { 
  18.   param := make(map[string]interface{}) 
  19.   var json = jsoniter.ConfigCompatibleWithStandardLibrary 
  20.   _ = json.Unmarshal([]byte(s), &param) 
  21.  } 

运行结果:

这个包易用性也很强,在原来 json 代码解析的上面加一行代码就可以了:

  1. var json = jsoniter.ConfigCompatibleWithStandardLibrary 
  2. err = json.Unmarshal(datautil.String2bytes(originData), &fieldMap 

还有一个可以优化的地方是string和[]byte之间的转化,我们在代码里用的参数类型是string,而 json 解析接受的参数是[]byte,所以一般在json解析时需要进行转化:

  1. err = json.Unmarshal([]byte(originData), &fieldMap) 

那么string转化为[]byte发生了什么呢。

  1. package main 
  2.  
  3. func main(){ 
  4.   a := "string" 
  5.   b := []byte(a) 
  6.   println(b) 

我们用汇编把编译器悄悄做的事抓出来:

来看一下这个函数做了啥:

这里底层会发生拷贝现象,我们可以拿到[]byte和string的底层结构后,用黑科技去掉拷贝过程:

  1. func String2bytes(s string) []byte { 
  2.  x := (*[2]uintptr)(unsafe.Pointer(&s)) 
  3.  h := [3]uintptr{x[0], x[1], x[1]} 
  4.  return *(*[]byte)(unsafe.Pointer(&h)) 
  5.  
  6. func Bytes2String(b []byte) string { 
  7.  return *(*string)(unsafe.Pointer(&b)) 

下面写 benchmark 看一下黑科技好不好用:

  1. package main 
  2.  
  3. import ( 
  4.  "strings" 
  5.  "testing" 
  6.  
  7. var s = strings.Repeat("hello", 1024) 
  8.  
  9. func testDefault() { 
  10.  a := []byte(s) 
  11.  _ = string(a) 
  12.  
  13. func testUnsafe() { 
  14.  a := String2bytes(s) 
  15.  _ = Bytes2String(a) 
  16.  
  17. func BenchmarkTestDefault(b *testing.B) { 
  18.  for i := 0; i < b.N; i++ { 
  19.   testDefault() 
  20.  } 
  21.  
  22. func BenchmarkTestUnsafe(b *testing.B) { 
  23.  for i := 0; i < b.N; i++ { 
  24.   testUnsafe() 
  25.  } 

运行速度,内存分配上效果都很明显,黑科技果然黑:

加 cache,空间换时间

项目中有一块代码负责处理 N 个请求中的参数。代码如下:

  1. for _, item := range items { 
  2.   var params map[string]string 
  3.   err := json.Unmarshal([]byte(items[1]), &params) 
  4.   if err != nil { 
  5.     ... 
  6.   } 

在这个需要优化的场景中,上游在单次请求获取某个用户300多个特征,如果用上面的代码我们需要json.Unmarshal300多次,这是个无用且非常耗时的操作,可以加 cache 优化一下:

  1. paramCache := make(map[string]map[string]string) 
  2.  for _, item := range items { 
  3.   var params map[string]string 
  4.  
  5.   tmpParams, ok := cacheDict[items[1]] 
  6.   // 没有解析过,进行解析 
  7.   if ok == false { 
  8.    err := json.Unmarshal([]byte(items[1]), &params) 
  9.    if err != nil { 
  10.     ... 
  11.    } 
  12.    cacheDict[items[1]] = params 
  13.   } else { 
  14.       // 解析过,copy出一份 
  15.       // 这里的copy是为了预防并发问题 
  16.    params = DeepCopyMap(tmpParams) 
  17.   } 
  18.  } 

这样理论上不会存在任何的放大现象,读者朋友如果有批处理的接口,代码中又有类似这样的操作,可以看下这里是否有优化的可能性。

  1. for { 
  2.   dosomething() 

替换耗时逻辑

火焰图中的 TplToStr 模板函数同样占到了比较大的 CPU 耗时,此函数的功能是把用户传来的参数和预制的模板拼出一个新的 string 字符串,比如:

  1. 入参:Tpl: shutiao_test_{{user_id}} user_id: 123478 
  2. 返回:shutiao_test_123478 

在我们的系统中,这个函数根据模板和用户参数拼出一个 flag,根据这个 flag 是否相同作为某个操作的标记。这个拼模板是一个非常耗时的操作,这块可以直接用字符串拼接去代替模板功能,比如:

  1. 入参:Tpl: shutiao_test_{{user_id}} user_id: 123478 
  2. 返回:shutiao_test_user_id_123478 

优化完之后,火焰图中已经看不到这个函数的平顶山了,直接节省了 5%的 CPU 的调用百分比。

prealloc

还发现一些 growslice 占得微量 cpu 耗时,本以为预分配可以解决问题,但做 benchmark 测试发现 slice 容量较小时是否做预分配在性能上差异不大:

  1. package main 
  2.  
  3. import "testing" 
  4.  
  5. func test(m *[]string) { 
  6.  for i := 0; i < 300; i++ { 
  7.   *m = append(*m, string(i)) 
  8.  } 
  9.  
  10. func BenchmarkSlice(b *testing.B) { 
  11.  for i := 0; i < b.N; i++ { 
  12.   b.StopTimer() 
  13.   m := make([]string, 0) 
  14.   b.StartTimer() 
  15.  
  16.   test(&m) 
  17.  } 
  18.  
  19. func BenchmarkCapSlice(b *testing.B) { 
  20.  for i := 0; i < b.N; i++ { 
  21.   b.StopTimer() 
  22.   m := make([]string, 300) 
  23.   b.StartTimer() 
  24.  
  25.   test(&m) 
  26.  } 

对于代码中用到的 map 也可以做一些预分配,写 map 时如果能确认容量尽量用 make 函数对容量进行初始化。

  1. package main 
  2.  
  3. import "testing" 
  4.  
  5. func test(m map[string]string) { 
  6.  for i := 0; i < 300; i++ { 
  7.   m[string(i)] = string(i) 
  8.  } 
  9.  
  10. func BenchmarkMap(b *testing.B) { 
  11.  for i := 0; i < b.N; i++ { 
  12.   b.StopTimer() 
  13.   m := make(map[string]string) 
  14.   b.StartTimer() 
  15.  
  16.   test(m) 
  17.  } 
  18.  
  19. func BenchmarkCapMap(b *testing.B) { 
  20.  for i := 0; i < b.N; i++ { 
  21.   b.StopTimer() 
  22.   m := make(map[string]string, 300) 
  23.   b.StartTimer() 
  24.  
  25.   test(m) 
  26.  } 

这个优化还是比较有效的:

异步化

接口流程中有一些不影响主流程的操作完全可以异步化,比如:往外发送的统计工作。在 golang 中异步化就是起个协程。

总结一下套路:

代码层面的优化,是 us 级别的,而针对业务对存储进行优化,可以做到 ms 级别的,所以优化越靠近应用层效果越好。对于代码层面,优化的步骤是:

压测工具模拟场景所需的真实流量

pprof 等工具查看服务的 CPU、mem 耗时

锁定平顶山逻辑,看优化可能性:异步化,改逻辑,加 cache 等

局部优化完写 benchmark 工具查看优化效果

整体优化完回到步骤一,重新进行 压测+pprof 看效果,看 95 分位耗时能否满足要求(如果无法满足需求,那就换存储吧~。

 

另外推荐一个不错的库,这是 Golang 布道师 Dave Cheney 搞的用来做性能调优的库,使用起来非常方便:https://github.com/pkg/profile,可以看 pprof和 trace 信息。有兴趣读者可以了解一下。

 

责任编辑:武晓燕 来源: 薯条的编程修养
相关推荐

2022-08-03 09:11:31

React性能优化

2023-12-29 08:29:15

QPS系统应用

2011-03-01 10:42:23

无线局域网局域网性能优化

2012-05-07 08:49:57

Clojure

2020-02-05 14:49:04

网络性能优化微调

2020-08-24 08:34:03

命令性能优化

2012-12-17 12:58:18

WebjQuery重构

2017-06-30 15:18:24

对账系统互联网

2021-12-28 09:23:58

数据中心

2022-07-25 08:02:57

Tomcat调优组件

2024-02-22 16:55:13

2020-10-30 15:04:16

开发技能代码

2021-12-29 08:21:01

Performance优化案例工具

2015-09-08 14:42:17

Android性能优化

2015-07-30 11:21:16

代码审查

2020-12-18 10:40:00

ExcelJava代码

2023-06-01 07:49:51

2022-01-04 09:01:10

开源项目开源技术

2012-03-12 16:42:54

测试

2022-09-22 08:05:23

架构
点赞
收藏

51CTO技术栈公众号