最近机票业务有一个比较棘手的需求,就是从供应商获取资源,要从各个供应商的资源中pk出价格最有优势的政策。乍看上去,这样的需求很简单,和之前做的酒店,景点等等价格PK类似么,无非就是从原始数据中,获取最具有优势的价格而已,然而就是这么一个任务,前前后后坑了半个月。
一般根据资源计算最小价的需求,都是一个db
或者cache
,然后通过用户的请求入参,去数据库里动态添加where
条件,然后循环PK
这些资源,最后胜出出结果。可是这次的需求有点不同,因为后段资源池比较大,所以整个请求链路是异步的。具体流程如下:
- 用户发起查询请求到PK平台,PK平台把请求分别打到后面计算资源池,并开始计算,返回用户等待
- 用户轮询发起查询PK平台,(为什么没做成推送,历史原因),当有资源池先返回的,就PK完成直接返回给用户,直到所有资源池的价格都PK完毕
所以这里就出现来这样的现象,资源池有10几个,用户一次请求,会导致10几个资源池的回写请求,然后PK平台需要在内存缓存这些请求,等待用户异步的查询请求,并完成PK输出数据。
一开始的技术选型是OpenResty
,为什么选择这个呢,看中以下几点:
sharedict
可以保存资源池返回的数据,多进程共享,而且自带ttl
,因为还有缓存过期的需求- 代码变更只需要执行
nginx
的reload
,无需重启整个进程,sharedict
中的缓存也存在,所以无需关心发布的平滑 - 利用
OpenResty
的自带反向代理功能,可以自组网络路由,因为,为了缓解压力,我们会对用户请求不同的入参进行分片处理,外部写入或读取,请求可以打到集群中的任意一个节点 lua
脚本语言的灵活性,方便编写业务逻辑,同时也在脚本语言里的性能排名靠前
大概开发了1周多,开始打样的压力测试,先说写性能,完全无压力。为了加速,我们把接收到的资源以protoBuffer
的形式存储到sharedict
中。因为实际测试,cjson
大概会比protoBuffer
慢20%-30%。但是当有读取请求的时候,性能的瓶颈就出现了。读取的响应时间非常糟糕,因为当时我们用elk
收集了整个openresty
的性能指标(手动打点),发现读取的时候,从sharedict
中读取缓存数据并序列化,耗时占比最大,因为每一个资源池回写的数据都非常大,500k,甚至上4mb(姑且不讨论合理性)。
想着OpenResty
的方案是不是可能宣告失败了,因为这块代码无法优化,OpenResty
的特征就是这样。本着救一救的心态,我们在每次写sharedict
的时候,增加eventPost
,通知每一个进程,保存在一个LRUCache
里,这样可以避免从共享读取和序列化的开销。修改完后,我们继续压测读写,发现性能还是不能令人满意。至此,OpenResty
方案宣告失败。
考虑到,OpenResty
方案是因为序列化的开销而失败的,那我们使用一个多线程语言来处理这个需求,应该就可以避免序列化的开销,于是,我们采用golang
来重写整个项目。我们设计了类似Java
的concurrent hashmap
来避免读写锁带来的性能开销。同时还自己实现了一个带TTL
的concurrent hashmap
组件。web开发框架,则用熟悉的gin
。大家信心满满,不到一周,代码就开发完毕,正式进入压测。
然而,第一轮压测后,平均响应时间依然不够理想。虽然比OpenResty
快了一些,但是仍然达不到我们预期那样的质的飞跃。于是我们开始寻找性能瓶颈,根据容器内的CPU和内存使用情况,发现我们内存使用和GC都很正常,CPU却只能使用400%,我们可是设置了16核的容器。我们第一个想到的,就是读写锁。因为读写锁的缘故,导致大量goroutine
的竞争和等待,所以导致CPU使用率上不去,响应时间慢。
大家开始怀疑,我们自己实现的concurrent hashmap
性能不如golang
标准包的sync.map
,毕竟sync.map
就是为了竞争而诞生的。但是在我们深入了解了sync.map
实现原理后,心里凉了半截,似乎sync.map
并不能帮助我们,在写多读少的场景下,sync.map
似乎还不如读写锁。硬着头皮,改了一版本,压测了下,性能果然比我们实现的concurrent hashmap
慢4倍。
那么问题究竟出在哪里呢?幸好,golang
有pprof
,可以看火焰图和锁的情况,于是我们打开pprof
,在压测后看报告。想着这样应该能找到问题了吧,但是报告的结果令人震惊,火焰图展示出来的是json
序列化最大,锁的情况也很健康。
拿到报告后,我们甚至觉得,pprof
有问题,或者是我们的使用方式不对,于是,开始了原始的2分法找任务。很幸运,我们把计算完成输出结果的那一部分代码注释了,每次返回空数据出去,但是计算PK,获取资源数据逻辑都还在,只是最后响应出去的数据变成了字符串。然后进行一轮压测,性能提升了4倍多,原来平均响应时间80ms,代码注释掉直接10几ms。
终于找到了,性能的瓶颈点,但是怎么着手优化呢?看上去似乎无能为力,于是我们找到了fasthttp
的框架,经过对比测试后,发现fasthttp
在响应大数据的时候,比gin
要快1倍多,于是我们通过gzip
,和fasthttp
改造,顺利的把响应时间控制在30ms左右了,性能终于达标。
回顾整个项目开发和优化过程,确实踩了不少的坑,也更加清晰的明确了,什么场景使用什么技术栈。fasthttp
以chunk
的方式响应,确实比gin
直接响应btye
要快一些,到是关键还是gzip
压缩响应出去的内容。