為了快速上線啸箫,早期很多代碼基本是怎么方便怎么來耸彪,這樣就留下了很多隱患,性能也不是很理想忘苛,python 因為 GIL 的原因蝉娜,在性能上有天然劣勢,即使用了 gevent/eventlet 這種協(xié)程方案扎唾,也很容易因為耗時的 CPU 操作阻塞住整個進程召川。前陣子對基礎代碼做了些重構,效果顯著胸遇,記錄一些荧呐。
設定目標:
性能提高了,最直接的效果當然是能用更少的機器處理相同流量纸镊,目標是關閉 20% 的 stateless webserver.盡量在框架代碼上做改動倍阐,不動業(yè)務邏輯代碼。低風險 (歷史經(jīng)驗告訴我們薄腻,動態(tài)一時爽收捣,重構火葬場....)
治標
常見場景是大家開開心心做完一個 feature, sandbox 測試也沒啥問題庵楷,上線了罢艾,結(jié)果 server load 飆升,各種 timeout 都來了尽纽,要么 rollback 代碼咐蚯,要么加機器。問題代碼在哪?
我們監(jiān)控用的是 datadog (statsd協(xié)議)弄贿,對這種問題最有效的指標是看每個接口的 avg_latency * req_count 得到每個接口在一段時間內(nèi)的總耗時春锋,在柱狀圖上最長的那塊就是對性能影響最大的接口。進一步的調(diào)試就靠 cProfile 和讀代碼了差凹。
但很多時候出問題的代碼邏輯巨復雜期奔,還很多人改動過侧馅,開發(fā)和 sandbox 環(huán)境數(shù)據(jù)的量和線上差距太大,無法復現(xiàn)問題呐萌,在線上用 cProfile 只能測只讀接口(為了不寫壞用戶數(shù)據(jù))馁痴。
而且這種方式只能治標,調(diào)試個別慢的業(yè)務接口肺孤,目標里說了只想改框架罗晕,提高整體性能,怎么整?
治本
我希望能對運行時進程狀態(tài)打 snapshot赠堵,每次快照記錄下當前的函數(shù)調(diào)用棧小渊,疊合多次采樣,出現(xiàn)次數(shù)多的函數(shù)必然就是瓶頸所在. 這思想在其他語言里用的也很多茫叭,其實就是 Brendan Gregg 的 flamegraph.
以前內(nèi)部做過類似的事情酬屉,不過代碼是侵入式的,在運行時通過 signal, inspect, traceback 等模塊杂靶,定期打調(diào)用棧的 snapshot, 輸出到文件梆惯,轉(zhuǎn)成 svg 的 flamegraph 來看酱鸭,但是 overhead 太高吗垮,后來棄用了。
后來利用了 uber 開源的一個工具: https://github.com/uber/pyflame, 可以非侵入式得對運行中的 python 進程做 snapshot, 輸出成 svg.
效果如圖:
橫條越長的部分凹髓,表示被采樣到的次數(shù)越多烁登,從下往上可以看到在每一層上的函數(shù)耗時分布。
使用非常簡單:
pyflame -s60-r0.01${pid} | flamegraph.pl > myprofile.svg
-s 60蔚舀, 總采樣時間為 60s-r 0.01饵沧, 以0.01s 的頻率做采樣
在最終的輸出圖上可能有比較長的 IDLE 時間, pyflame 只能捕獲到當前獲取了 GIL 的代碼的調(diào)用錢,其他的部分就會是 IDLE, 包括幾種情況:
IO wait, 比如 call 一個很慢的 rpc server赌躺, client 等待過程中狼牺,采集到的時間就是 IDLEC 編寫的部分進程處于空閑時間。
大體可以認為 pyflame 上采樣到的部分是 CPU heavy的代碼礼患。
通過 pyflame, 可以很快得對進程運行時耗時分布有個大概的感覺是钥,即使你完全不了解業(yè)務邏輯.
重構
線上 web 應用,前面是基于 flask的 web 端和api server, 后面是幾組業(yè)務不同的 RPC server缅叠,兩者之間通過 msgpack 通信. 為了方便悄泥, RPC server 也是基于 flask 的,通過 pyflame 調(diào)試肤粱,發(fā)現(xiàn) flask 的 overhead 還是很高的弹囚,在 RPC 那層, 一些接口實際業(yè)務代碼的采樣次數(shù)领曼,只有總采樣的1/6左右 (并不能反應實際耗時分布)鸥鹉,其余都耗在了 flask 層蛮穿。
RPC server
RPC 層不處理web邏輯, flask完全用不到毁渗,可以干掉绪撵。有想過替換成 thrift/protobuf 這種二進制通行協(xié)議,傳輸?shù)臄?shù)據(jù)不帶 schema 信息祝蝠,效率能高不少音诈,但這樣勢必要大改接口,還要考慮之后schema改動绎狭,升級時候server 和 client 端的兼容性問題细溅。本著不動業(yè)務代碼和低風險的原則,還是保守的 http + msgpack.
對于 RPC server, 索性跳過 web 框架儡嘶,直接實現(xiàn) WSGI喇聊,參考 pep333 , 非常簡單,改完 rpc server入口代碼不到200行蹦狂,用 wrk 做下 helloworld 的 benchmark, 并發(fā)輕松變3倍.
RPC client
改完 rpc server 層誓篱,負載已經(jīng)有了顯著降低(20% 左右),還有個性價比很高的優(yōu)化是替換 rpc client. 之前用的是 requests, 說實話凯楔,個人對這種接口漂亮窜骄,使用方便的庫一直是持保留態(tài)度的,尤其是在這種性能敏感的場景摆屯,在 pyflame 的采樣圖上也能看到 requests 代碼里的耗時很長.
嘗試用 https://github.com/gwik/geventhttpclient 替換掉 requests. 簡單的 benchmark 腳本測試下來邻遏,完成相同的請求數(shù), geventhttpclient 只用了 requests 1/4 的時間 (gevent patch 過的情況下).
修改完 RPC client 的代碼虐骑,上線后卻傻眼了, server load 降得很明顯准验,可是latency 卻直接上升了 30% 多???
經(jīng)過排查,發(fā)現(xiàn)替換 client 過后廷没,內(nèi)網(wǎng)流量莫名增加了糊饱,拿兩臺機器做 A/B testing, 效果很明顯。開始懷疑是 geventhttpclient 的 connection pool 實現(xiàn)有問題颠黎,導致 tcp 連接沒有復用另锋。
嘗試用 tcpdump 抓 sync 包: tcpdump "tcp[tcpflags] & (tcp-syn) != 0"
對比了 requests 和 geventhttpclient 的兩臺機器,syn 包的數(shù)目并沒有太大差別盏缤。
但抓包過程中偶然發(fā)現(xiàn)砰蠢,geventhttpclient 在發(fā)送 http 請求的時候,header 和 body 竟然是用兩個 packet 發(fā)送的, requests 底層是用的標準庫的 httplib, 會將 header buffer 起來和 body 通過一個packet 發(fā)出去唉铜,所以每發(fā)一次請求台舱,geventhttpclient 會多發(fā)一個 ip + tcp header(40字節(jié)),怪不得流量變多了。
把這個問題修了下, 上線后 latency 立刻回復了正常竞惋。順手把改動推到了官方: https://github.com/gwik/geventhttpclient/pull/85
總結(jié)
經(jīng)過一輪修改柜去,最后關閉了30% 的 stateless server. 總共動到的代碼也就幾百行,業(yè)務開發(fā)無感知拆宛。應該說性價比很高嗓奢。
在復雜業(yè)務邏輯下,調(diào)試性能問題總是特別頭疼浑厚,單機的 benchmark QPS 數(shù)據(jù)也就估個天花板股耽,意義不大,關鍵還是要完善監(jiān)控和工具鏈钳幅,幫助快速定位問題物蝙。下一步打算上 opentracing, 完善分布式環(huán)境下的性能追蹤。
最后小編自己也是一個有著6年工作經(jīng)驗的工程師敢艰,關于python編程诬乞,自己有做材料的整合,一個完整的python編程學習路線钠导,學習資料和工具震嫉。想要這些資料的可以關注小編,加入python學習交流Q群735967233牡属。