美團金融的業務在過去的一段時間裏發展很是快速。在業務增加的同時,咱們也注意到,不少用戶的支付環境,實際上是在弱網環境中的。javascript
你們知道,前端可以服務用戶的前提是 JavaScript 和 CSS 等靜態資源可以正確加載。若是網絡環境惡劣,那麼咱們的靜態資源尺寸越大,用戶下載失敗的機率就越高。前端
根據咱們的數據統計,咱們的業務中有2%的用戶流失與資源加載有關。所以每次更新的代價越小、加載成功率越高,用戶流失率也就會越低,從而就可以變相提升訂單的轉化率。java
做爲一個發版頻繁的業務,要下降發版的影響,能夠作兩方面優化:node
針對第一點,咱們有本身的模塊加載器來作,這裏先按下不表,咱們來重點聊聊增量更新的問題。webpack
看圖說話。git
咱們的增量更新經過在瀏覽器端部署一個 SDK 來發起,這個 SDK 咱們稱之爲 Thunder.js 。github
Thunder.js 在頁面加載時,會從頁面中讀取最新靜態資源的版本號。同時, Thunder.js 也會從瀏覽器的緩存(一般是 localStorage)中讀取咱們已經緩存的版本號。這兩個版本號進行匹配,若是發現一致,那麼咱們能夠直接使用緩存當中的版本;反之,咱們會向增量更新服務發起一個增量補丁的請求。web
增量服務收到請求後,會調取新舊兩個版本的文件進行對比,將差別做爲補丁返回。Thunder.js 拿到請求後,便可將補丁打在老文件上,這樣就獲得了新文件。算法
總之一句話:老文件 + 補丁 = 新文件。docker
增量補丁的生成,主要依賴於 Myers 的 diff 算法。生成增量補丁的過程,就是尋找兩個字符串最短編輯路徑的過程。算法自己比較複雜,你們能夠在網上找一些比較詳細的算法描述,好比這篇 《The Myers diff algorithm》,這裏就不詳細介紹了。
補丁自己是一個微型的 DSL(Domain Specific Language)。這個 DSL 一共有三種微指令,分別對應保留、插入、刪除三種字符串操做,每種指令都有本身的操做數。
例如,咱們要生成從字符串「abcdefg」到「acdz」的增量補丁,那麼一個補丁的全文就相似以下:
=1\t-1\t=2\t-3\t+z
複製代碼
這個補丁當中,製表符\t
是指令的分隔符,=
表示保留,-
表示刪除,+
表示插入。整個補丁解析出來就是:
z
具體的 JavaScript 代碼就不在這裏粘貼了,流程比較簡單,相信你們均可以本身寫出來,只須要注意轉義和字符串下標的維護便可。
增量更新其實不是前端的新鮮技術,在客戶端領域,增量更新早已經應用多年。看過咱們《美團金融掃碼付靜態資源加載優化實踐》的朋友,應該知道咱們其實以前已有實踐,在當時僅僅靠增量更新,日均節省流量達30多GB。而如今這個數字已經隨着業務量變得更高了。
那麼咱們是否是就已經作到萬事無憂了呢?
咱們最主要的問題是增量計算的速度不夠快。
以前的優化實踐中,咱們絕大部分的優化其實都是爲了優化增量計算的速度。文本增量計算的速度確實慢,慢到什麼程度呢?之前端比較常見的JS資源尺寸——200KB——來進行增量計算,進行一次增量計算的時間依據文本不一樣的數量,從數十毫秒到十幾秒甚至幾十秒都有可能。
對於小流量業務來講,計算一次增量補丁而後緩存起來,即便第一次計算耗時一些也不會有太大影響。但用戶側的業務流量都較大,每個月的增量計算次數超過 10 萬次,併發計算峯值超過 100 QPS 。
那麼不夠快的影響是什麼呢?
咱們以前的設計大體思想是用一個服務來承接流量,再用另外一個服務來進行增量計算。這兩個服務均由 Node.js 來實現。對於前者, Node.js 的事件循環模型本就適合進行 I/O 密集型業務;然而對於後者,則實際爲 Node.js 的軟肋。 Node.js 的事件循環模型,要求 Node.js 的使用必須時刻保證 Node.js 的循環可以運轉,若是出現很是耗時的函數,那麼事件循環就會陷入進去,沒法及時處理其餘的任務。常見的手法是在機器上多開幾個 Node.js 進程。然而一臺普通的服務器也就8個邏輯CPU而已,對於增量計算來講,當咱們遇到大計算量的任務時,8個併發可能就會讓 Node.js 服務很難繼續響應了。若是進一步增長進程數量,則會帶來額外的進程切換成本,這並非咱們的最優選擇。
「讓 JavaScript 跑的更快」這個問題,不少前輩已經有所研究。在咱們思考這個問題時,考慮過三種方案。
Node.js Addon 是 Node.js 官方的插件方案,這個方案容許開發者使用 C/C++ 編寫代碼,然後再由 Node.js 來加載調用。因爲原生代碼的性能自己就比較不錯,這是一種很是直接的優化方案。
後兩種方案是瀏覽器側的方案。
其中 ASM.js 由 Mozilla 提出,使用的是 JavaScript 的一個易於優化的子集。這個方案目前已經被廢棄了。
取而代之的 WebAssembly ,由 W3C 來領導,採用的是更加緊湊、接近彙編的字節碼來提速。目前在市面上剛剛嶄露頭角,相關的工具鏈還在完善中。 Mozilla 本身已經有一些嘗試案例了,例如將 Rust 代碼編譯到 WebAssembly 來提速 sourcemap 的解析。
然而在考慮了這三種方案以後,咱們並無獲得一個很好的結論。這三個方案的均可以提高 JavaScript 的運行性能,可是不管採起哪種,都沒法將單個補丁的計算耗時從數十秒降到毫秒級。何況,這三種方案若是不加以複雜的改造,依然會運行在 JavaScript 的主線程之中,這對 Node.js 來講,依然會發生嚴重的阻塞。
因而咱們開始考慮 Node.js 以外的方案。換語言這一想法應運而生。
更換編程語言,是一個很慎重的事情,要考慮的點不少。在增量計算這件事上,咱們主要考慮新語言如下方面:
固然,除了這些點以外,咱們還考慮了調優、部署的難易程度,以及語言自己是否可以快速駕馭等因素。
最終,咱們決定使用 Go 語言進行增量計算服務的新實踐。
增量補丁的生成算法,在 Node.js 的實現中,對應 diff 包;而在 Go 的實現中,對應 go-diff 包。
在動手以前,咱們首先用實際的兩組文件,對 Go 和 Node.js 的增量模塊進行了性能評測,以肯定咱們的方向是對的。
結果顯示,儘管針對不一樣的文件會出現不一樣的狀況,Go 的高性能依然在計算性能上碾壓了 Node.js 。這裏須要注意,文件長度並非影響計算耗時的惟一因素,另外一個很重要的因素是文件差別的大小。
Go 語言是 Google 推出的一門系統編程語言。它語法簡單,易於調試,性能優異,有良好的社區生態環境。和 Node.js 進行併發的方式不一樣, Go 語言使用的是輕量級線程,或者叫協程,來進行併發的。
專一於瀏覽器端的前端同窗,可能對這種併發模型不太瞭解。這裏我根據我本身的理解來簡要介紹一下它和 Node.js 事件驅動併發的區別。
如上文所說, Node.js 的主線程若是陷入在某個大計算量的函數中,那麼整個事件循環就會阻塞。協程則與此不一樣,每一個協程中都有計算任務,這些計算任務隨着協程的調度而調度。通常來講,調度系統不會把全部的 CPU 資源都給同一個協程,而是會協調各個協程的資源佔用,儘量平分 CPU 資源。
相比 Node.js ,這種方式更加適合計算密集與 I/O 密集兼有的服務。
固然這種方式也有它的缺點,那就是因爲每一個協程隨時會被暫停,所以協程之間會和傳統的線程同樣,有發生競態的風險。所幸咱們的業務並無多少須要共享數據的場景,競態的狀況很是少。
實際上 Web 服務類型的應用,一般以請求 -> 返回
爲模型運行,每一個請求不多會和其餘請求發生聯繫,所以使用鎖的場景不多。一些「計數器」類的需求,靠原子變量也能夠很容易地完成。
Go 語言的模塊依賴管理並不像 Node.js 那麼成熟。儘管吐槽 node_modules 的人不少,但卻不得不認可,Node.js 的 CMD 機制對於咱們來講不只易於學習,同時每一個模塊的職責和邊界也是很是清晰的。
具體來講,一個 Node.js 模塊,它只需關心它本身依賴的模塊是什麼、在哪裏,而不關心本身是如何被別人依賴的。這一點,能夠從 require
調用看出:
const util = require('./util');
const http = require('http');
module.exports = {};
複製代碼
這是一個很是簡單的模塊,它依賴兩個其餘模塊,其中 util
來自咱們本地的目錄,而 http
則來自於 Node.js 內置。在這種情形下,只要你有良好的模塊依賴關係,一個本身寫好的模塊想要給別人複用,只須要把整個目錄獨立上傳到 npm
上便可。
簡單來講, Node.js 的模塊體系是一棵樹,最終本地模塊就是這樣:
|- src
|- module-a
|- submodule-aa
|- submodule-ab
|- module-b
|- module-c
|- submodule-ca
|- subsubmodule-caa
|- bin
|- docs
複製代碼
但 Go 語言就不一樣了。在 Go 語言中,每一個模塊不只有一個短的模塊名,同時還有一個項目中的「惟一路徑」。若是你須要引用一個模塊,那麼你須要使用這個「惟一路徑」來進行引用。好比:
package main
import (
"fmt"
"github.com/valyala/fasthttp"
"path/to/another/local/module"
)
複製代碼
第一個依賴的 fmt
是 Go 自帶的模塊,簡單明瞭。第二個模塊是一個位於 Github 的開源第三方模塊,看路徑形式就可以大體推斷出來它是第三方的。而第三個,則是咱們項目中一個可複用模塊,這就有點不太合適了。其實若是 Go 支持嵌套的模塊關係的話,至關於每一個依賴從根目錄算起就能夠了,可以避免出現 ../../../../root/something
這種尷尬的向上查找。可是, Go 是不支持本地依賴之間的文件夾嵌套的。這樣一來,全部的本地模塊,都會平鋪在同一個目錄裏,最終會變成這樣:
|- src
|- module-a
|- submodule-aa
|- submodule-ab
|- module-b
|- module-c
|- submodule-ca
|- subsubmodule-caa
|- bin
|- docs
複製代碼
如今你不太可能直接把某個模塊按目錄拆出去了,由於它們之間的關係徹底沒法靠目錄來判定了。
較新版本的 Go 推薦將第三方模塊放在 vendor
目錄下,和 src
是平級關係。而以前,這些第三方依賴也是放在 src
下面,很是使人困惑。
目前咱們項目的代碼規模還不算很大,能夠經過命名來進行區分,但當項目繼續增加下去,就須要更好的方案了。
和有 npm
的 Node.js 另外一個不同是: Go 語言沒有本身的包管理平臺。對於 Go 的工具鏈來講,它並不關心你的第三方包究竟是誰來託管的。 社區裏 Go 的第三方包遍及各個 Git 託管平臺,這不只讓咱們在搜索包時花費更多時間,更麻煩的是,咱們沒法經過在企業內部搭建一個相似 npm
鏡像的平臺,來下降你們每次下載第三方包的耗時,同時也難以在不依賴外網的狀況下,進行包的自由安裝。
Go 有一個命令行工具,專門負責下載第三方包,叫作「 go-get
」。和你們想的不同,這個工具沒有版本描述文件。在 Go 的世界裏並無 package.json
這種文件。這給咱們帶來的直接影響就是咱們的依賴不只在外網放着,同時還沒法有效地約束版本。同一個go-get
命令,這個月下載的版本,可能到下個月就已經悄悄地變了。
目前 Go 社區有不少種不一樣的第三方工具來作,咱們最終選擇了 glide
。這是咱們能找到的最接近 npm
的工具了。目前官方也在孕育一個新的方案來進行統一,咱們拭目以待吧。
對於鏡像,目前也沒有太好的方案,咱們參考了 moby (就是 docker )的作法,將第三方包直接存入咱們本身項目的 Git 。這樣雖然項目的源代碼尺寸變得更大了,但不管是新人蔘與項目,仍是上線發版,都不須要去外網拉取依賴了。
Go 語言在美團內部的應用較少,直接結果就是,美團內部至關一部分基礎設施,是缺乏 Go 語言 SDK 支持的。例如公司自建的 Redis Cluster ,因爲根據公司業務需求進行了一些改動,致使開源的 Redis Cluster SDK ,是沒法直接使用的。再例如公司使用了淘寶開源出 KV 數據庫—— Tair ,大概因爲開源較早,也是沒有 Go 的 SDK 的。
因爲咱們的架構設計中,須要依賴 KV 數據庫進行存儲,最終咱們仍是選擇用 Go 語言實現了 Tair 的 SDK。所謂「工欲善其事,必先利其器」,在 SDK 的編寫過程當中,咱們逐漸熟悉了 Go 的一些編程範式,這對以後咱們系統的實現,起到了很是有益的做用。因此有時候手頭可用的設施少,並不必定是壞事,但也不能盲目去製造輪子,而是要思考本身造輪子的意義是什麼,以結果來評判。
要經受生產環境的考驗,只靠更換語言是不夠的。對於咱們來講,語言其實只是一個工具,它幫咱們解決的是一個局部問題,而增量更新服務有不少語言以外的考量。
由於有前車可鑑,咱們很清楚本身面對的流量是什麼級別的。所以這一次從系統的架構設計上,就優先考慮瞭如何面對突發的海量流量。
首先咱們來聊聊爲何咱們會有突發流量。
對於前端來講,網頁每次更新發版,其實就是發佈了新的靜態資源,和與之對應的 HTML 文件。而對於增量更新服務來講,新的靜態資源也就意味着須要進行新的計算。
有經驗的前端同窗可能會說,雖然新版上線會創造新的計算,但只要前面放一層 CDN ,緩存住計算結果,就能夠輕鬆緩解壓力了不是嗎?
這是有必定道理的,但並非這麼簡單。面向普通消費者的 C 端產品,有一個特色,那就是用戶的訪問頻度千差萬別。具體到增量更新上來講,就是會出現大量不一樣的增量請求。所以咱們作了更多的設計,來緩解這種狀況。
這是咱們對增量更新系統的設計。
放在首位的天然是 CDN 。面對海量請求,除了幫助咱們削峯以外,也能夠幫助不一樣地域的用戶更快地獲取資源。
在 CDN 以後,咱們將增量更新系統劃分紅了兩個獨立的層,稱做 API 層和計算層。爲何要劃分開呢?在過往的實踐當中,咱們發現即便咱們再當心再謹慎,仍然仍是會有犯錯誤的時候,這就須要咱們在部署和上線上足夠靈活;另外一方面,對於海量的計算任務,若是實在扛不住,咱們須要保有最基本的響應能力。基於這樣的考慮,咱們把 CDN 的回源服務獨立成一個服務。這層服務有三個做用:
那若是 API 層沒能將流量攔截下來,進一步傳遞到了計算層呢?
爲了防止過量的計算請求進入到計算環節,咱們還針對性地進行了流量控制。經過壓測,咱們找到了單機計算量的瓶頸,而後將這個限制配置到了系統中。一旦計算量逼近這個數字,系統就會對超量的計算請求進行降級,再也不進行增量計算,直接返回全量文件。
另外一方面,咱們也有相應的線下預熱機制。咱們爲業務方提供了一個預熱工具,業務方在上線前調用咱們的預熱工具,就能夠在上線前預先獲得增量補丁並將其緩存起來。咱們的預熱集羣和線上計算集羣是分離的,只共享分佈式存儲,所以雙方在實際應用中互不影響。
有關容災,咱們總結了以往見到的一些常見故障,分了四個門類來處理。
最後,在這套服務以外,咱們瀏覽器端的 SDK 也有本身的容災機制。咱們在增量更新系統以外,單獨部署了一套 CDN ,這套 CDN 只存儲全量文件。一旦增量更新系統沒法工做, SDK 就會去這套 CDN 上拉取全量文件,保障前端的可用性。
服務上線運轉一段時間後,咱們總結了新實踐所帶來的效果:
日均增量計算成功率 | 日均增量更新佔比 | 單日人均節省流量峯值 | 項目靜態文件總量 |
---|---|---|---|
99.97% | 64.91% | 164.07 KB | 1184 KB |
考慮到每一個業務實際的靜態文件總量不一樣,在這份數據裏咱們刻意包含了總量和人均節省流量兩個不一樣的值。在實際業務當中,業務方本身也會將靜態文件根據頁面進行拆分(例如經過 webpack 中的 chunk 來分),每次更新實際不會須要所有更新。
因爲一些邊界狀況,增量計算的成功率受到了影響,但隨着問題的一一修正,將來增量計算的成功率會愈來愈高。
如今來回顧一下,在咱們的新實踐中,都有哪些你們能夠真正借鑑的點:
對於 Go 語言,咱們也是摸着石頭過河,但願咱們這點經驗可以對你們有所幫助。
最後,若是你們對咱們所作的事情也有興趣,想要和咱們一塊兒共建大前端團隊的話,歡迎發送簡歷至 liuyanghe02@meituan.com 。
洋河,2013年加入攜程UED實習,參與研發了人生中第一個星數超過100的 Github 開源項目。2014年加入小米雲平臺,同時負責網頁前端開發、客戶端開發及路由器固件開發,積累了豐富的端開發經驗。2017年加入美團,現負責金服平臺基礎組件的開發工做。