淘寶線上應用的傳統軟體堆疊結構為 Nginx Velocity Java,即:
在這個體系中,Nginx 將請求轉送給 Java 應用,後者處理完事務,再將資料用 Velocity 範本渲染成最終的頁面。
引入 Node.js 之後,我們勢必要面臨以下幾個問題:
技術堆疊的拓樸結構該如何設計,部署方式該如何選擇,才算是科學合理?專案完成後,該如何切割流量,對維運來說才算方便快速?遇到線上的問題,如何最快解除險情,避免更大的損失?如何確保應用的健康狀況,在負載平衡調度的層面加以管理?承製系統拓撲
按照我們在前後端分離的思考與實踐(二)- 基於前後端分離的模版探索一文中的思路,Velocity 需要被 Node.js 取代,從而讓這個結構變成:
這當然是最理想的目標。然而,在傳統堆疊中首次引入 Node.js 這一層畢竟是個新嘗試。為了穩健起見,我們決定只在收藏夾的寶貝收藏頁面(shoucang.taobao.com/item_collect.htm)啟用新的技術,其它頁面沿用傳統方案。即,由 Nginx 判斷請求的頁面類型,決定這個請求究竟要轉送給 Node.js 還是 Java。於是,最後的結構成了:
部署方案
上面的結構看起來沒什麼問題了,但其實新問題還等在前面。在傳統結構中,Nginx 與 Java 是部署在同一台伺服器上的,Nginx 監聽 80 端口,與監聽高位 7001 端口的 Java 通訊。現在引進了 Node.js ,需要新跑一個監聽埠的進程,到底是將 Node.js 與 Nginx Java 部署在同一台機器,還是將 Node.js 部署在單獨的叢集呢?
讓我們來比較一下兩種方式各自特徵:
淘寶網收藏夾是一個擁有千萬級日均 PV 的應用,對穩定性的要求性極高(事實上任何產品的線上不穩定都是不能接受的)。如果採用同集群部署方案,只需要一次文件分發,兩次應用重啟即可完成發布,萬一需要回滾,也只需要操作一次基線包。效能上來說,同集群部署也有一些理論優勢(雖然內網的交換器頻寬與延遲都是非常樂觀的)。至於一對多或多對一的關係,理論上可能做到伺服器更充分的利用,但相較於穩定性上的要求,這一點並不那麼急迫需要去解決。所以在收藏夾的改造中,我們選擇了同集群部署方案。
灰階方式
為了確保最大程度的穩定,這次改造並沒有直接將 Velocity 程式碼完全去除。應用叢集中有將近 100 台伺服器,我們以伺服器為粒度,逐漸引入流量。也就是說,雖然所有的伺服器上都跑著 Java Node.js 的進程,但 Nginx 上有沒有對應的轉送規則,決定了取得這台伺服器上請求寶貝收藏的請求是否會經過 Node.js 來處理。其中 Nginx 的配置為:
location = "/item_collect.htm" { proxy_pass http://127.0.0.1:6001; # Node.js 进程监听的端口 }
只有添加了这条 Nginx 规则的服务器,才会让 Node.js 来处理相应请求。通过 Nginx 配置,可以非常方便快捷地进行灰度流量的增加与减少,成本很低。如果遇到问题,可以直接将 Nginx 配置进行回滚,瞬间回到传统技术栈结构,解除险情。
第一次发布时,我们只有两台服务器上启用了这条规则,也就是说大致有不到 2% 的线上流量是走 Node.js 处理的,其余的流量的请求仍然由 Velocity 渲染。以后视情况逐步增加流量,最后在第三周,全部服务器都启用了。至此,生产环境 100% 流量的商品收藏页面都是经 Node.js 渲染出来的(可以查看源代码搜索 Node.js 关键字)。
转
灰度过程并不是一帆风顺的。在全量切流量之前,遇到了一些或大或小的问题。大部分与具体业务有关,值得借鉴的是一个技术细节相关的陷阱。
健康检查
在传统的架构中,负载均衡调度系统每隔一秒钟会对每台服务器 80 端口的特定 URL 发起一次 <font face="NSimsun">get</font>
请求,根据返回的 HTTP Status Code 是否为 <font face="NSimsun">200</font>
来判断该服务器是否正常工作。如果请求 1s 后超时或者 HTTP Status Code 不为 <font face="NSimsun">200</font>
,则不将任何流量引入该服务器,避免线上问题。
这个请求的路径是 Nginx -> Java -> Nginx,这意味着,只要返回了 <font face="NSimsun">200</font>
,那这台服务器的 Nginx 与 Java 都处于健康状态。引入 Node.js 后,这个路径变成了 Nginx -> Node.js -> Java -> Node.js -> Nginx。相应的代码为:
var http = require('http'); app.get('/status.taobao', function(req, res) { http.get({ host: '127.1', port: 7001, path: '/status.taobao' }, function(res) { res.send(res.statusCode); }).on('error', function(err) { logger.error(err); res.send(404); }); });
但是在测试过程中,发现 Node.js 在转发这类请求的时候,每六七次就有一次会耗时几秒甚至十几秒才能得到 Java 端的返回。这样会导致负载均衡调度系统认为该服务器发生异常,随即切断流量,但实际上这台服务器是能够正常工作的。这显然是一个不小的问题。
排查一番发现,默认情况下, Node.js 会使用 <font face="NSimsun">HTTP Agent</font>
这个类来创建 HTTP 连接,这个类实现了 socket 连接池,每个主机+端口对的连接数默认上限是 5。同时 <font face="NSimsun">HTTP Agent</font>
类发起的请求中默认带上了 <font face="NSimsun">Connection: Keep-Alive</font>
,导致已返回的连接没有及时释放,后面发起的请求只能排队。
最后的解决办法有三种:
禁用 <font face="NSimsun">HTTP Agent</font>
,即在在调用 <font face="NSimsun">get</font>
方法时额外添加参数 <font face="NSimsun">agent: false</font>
,最后的代码为:
var http = require('http'); app.get('/status.taobao', function(req, res) { http.get({ host: '127.1', port: 7001, agent: false, path: '/status.taobao' }, function(res) { res.send(res.statusCode); }).on('error', function(err) { logger.error(err); res.send(404); }); });
设置 <font face="NSimsun">http</font>
对象的全局 socket 数量上限:
http.globalAgent.maxSockets = 1000;
在请求返回的时候及时主动断开连接:
http.get(options, function(res) { }).on("socket", function (socket) { socket.emit("agentRemove"); // 监听 socket 事件,在回调中派发 agentRemove 事件 });
实践上我们选择第一种方法。这么调整之后,健康检查就没有再发现其它问题了。
合
Node.js 与传统业务场景结合的实践才刚刚起步,仍然有大量值得深入挖掘的优化点。比比如,让 Java 应用彻底中心化后,是否可以考分集群部署,以提高服务器利用率。或者,发布与回滚的方式是否能更加灵活可控。等等细节,都值得再进一步研究。