構建消息推送系統之HTTP長連接實踐

前言

從Servlet3規範出來以後,利用Servlet3支持的異步特性,我們創建異步上下文asyncContext之後將它保存下來,同時不釋放,那麼這樣就達到了長連接的目的。同時在配合tomcat nio的使用,利用Servlet3構建一個http長連接推送系統就有了支持基礎,本篇文章將重點介紹基於Servlet3構建http長連接推送系統的實踐。有關Servlet3異步的詳細介紹可以參看《servlet3異步原理與實踐》

一、WEB網絡結構及配置

1.1、網絡結構

WEB網絡結構.png

用戶訪問vip–>vip發佈在lvs上–>lvs將請求轉發給後端的haproxy–>haproxy再把請求代理轉發給後端的nginx。vip實際路由發佈在lvs上,但是vip配置屬性在haproxy上(比如ACL, 域名,規則之類)
這裏lvs轉發給後端的haproxy,用戶請求經過lvs,但是響應是haproxy直接反饋給客戶端的,這也就是lvs的dr模式。

1.2、基本配置

我們知道http連接的特點就是一個request,一個response,然後關閉連接。這個過程包括建立連接和關閉連接。再往深處說就是調用了TCP/IP協議的三次握手,TCP協議多次傳輸,以及關閉連接的時候四次握手。頻繁的做這些操作肯定很耗費系統的資源。從HTTP1.1以後,開始支持keepalive ,比如瀏覽器一旦與服務器建立連接後,會保持住一段時間,也就是減少了上面的握手和傳輸的次數,在這個時間段內傳輸數據都是複用同一個連接。當客戶端主動告知關閉,或者達到了TCP關閉的條件,TCP/IP再關閉。那麼通過HTTP keepalive 機制就可以讓TCP連接保持住,具體保持多長時間可以通過參數來設置,下文會有介紹。
如果要保持長連接,那麼根據上圖的結構,瀏覽器與haproxy之間保持長連接(timeout http-keep-alive),haproxy與nginx之間保持長連接,nginx與tomcat之間保持長連接。我們的web應用架構一般都是如上圖所示,會包含LVS、轉發、反向代理。但簡單起來說就是nginx+tomcat,也就是虛線框內標識的,其實我們研發人員能接觸到的也是這兩層,其餘由運維和網絡組的同學來維護。那麼我重點介紹一下nginx層的配置參數。

http {
    //...
    keepalive_timeout       3600s; //Nginx 默認是支持 keepalive的,是通過 keepalive_timeout 設置的,默認值是75s。它表示在長連接開啓的情況下,在75s內如果沒有 http 請求,則關閉長連接(其實就是關閉 tcp)
    keepalive_requests      800; //此值容易被忽略,它是值在 keepalive_timeout 的時間範圍內,一個長連接最大允許的請求次數,如果超過此值,也會關閉此長連接。默認值爲100。
    gzip                    off; //這個在1.3中敘述
    //...
    upstream  TEST_BACKEND {
        server   192.168.1.18080  weight=1 max_fails=2 fail_timeout=30s;
        server   192.168.1.28080  weight=1 max_fails=2 fail_timeout=30s;

        keepalive 1000;        //此處keepalive的含義不是開啓、關閉長連接的開關;也不是用來設置超時的timeout;更不是設置長連接池最大連接數;而是連接程池中最大空閒連接的數量
    }

    server {
        listen 8080 default_server;
        server_name "";

        location /  {
            proxy_pass http://TEST_BACKEND;

            //...

            proxy_http_version 1.1;         //指定 HTTP 版本,防止 1.0 版本導致 keepalive 無效。
            proxy_set_header Connection ""; //清空將客戶端的一些設置,防止導致 keepalive 無效

            //...
        }
    }
}

1.3、Transfer-Encoding: chunked

普通短連接的時候瀏覽器根據連接關閉的狀態來寫response的內容。在長連接下,一段時間內傳輸的內容,連接都是不關閉的。因此如果沒有一種機制來告知什麼節點吐出內容,瀏覽器就只能一直等待後面是否還有數據,則遲遲不會寫response的內容。那麼我們可以想到利用Content-Length在傳輸之前標識一個包的大小,但是對於動態輸出的內容,傳輸之前就不太好判斷Content-Length的長度。在HTTP1.1最新的規範中定義了一種傳輸方式,就是chunked,分塊編碼。請求頭部加入 Transfer-Encoding: chunked 之後,就代表這個報文采用了分塊編碼。報文中的實體需要改爲用一系列分塊來傳輸。每個分塊包含十六進制的長度值和數據,長度值獨佔一行,長度不包括它結尾的 CRLF(\r\n),也不包括分塊數據結尾的 CRLF。最後一個分塊長度值必須爲 0,對應的分塊數據沒有內容,表示實體結束。這樣在長連接下動態輸出內容的時候瀏覽器就能夠判斷當前這次報文結束的位置了。
在1.2中我們留了一個gzip沒有介紹,我們知道開啓gzip,在文本傳輸的情況下,所需流量大約會降至1/4-1/3。在gzip關閉的情況下,以前長連接沒有任何問題,但是如果gzip打開,長連接則會失效。這是因爲整個壓縮過程在內存中完成,是流式的。也就是說,Nginx 不會等文件 gzip 完成再返回響應,而是邊壓縮邊響應,這樣可以顯著提高 TTFB(Time To First Byte,首字節時間,WEB 性能優化重要指標)。這樣唯一的問題是,Nginx 開始返回響應時,它無法知道將要傳輸的文件最終有多大,
也就是無法給出 Content-Length 這個響應頭部。因此根據chunked傳輸方式原理,解決了既可壓縮傳輸也能支持長連接方式傳輸了。

二、HTTP長連接系統組成結構

系統組成.png

2.1、SESSION管理

SESSION是客戶端到服務端的一次會話或者說是連接會話,會話信息中保存了用戶PIN、連接創建時間、這次request產生的AsyncContext上下文信息。我們會將會話信息保存到內存一份,
private Map

2.2、心跳

心跳的目的是判斷連接客戶端是否還活着,隔一段時間比如5s發一次心跳包,一般是從客戶端往服務端發送心跳包,我們現在HTTP長連接是從服務端往客戶端發送,當初的想法是節省客戶端資源。心跳的邏輯是從當前服務器內存中輪詢出所有的會話信息,在發送心跳包後如果收到錯誤信息則標記會失敗,關閉上下文asyncContext.complete();this.asyncContext = null;同時從會話列表中刪除,內存和redis中都要刪除。

2.3、消息接收

消息推送系統負責消息會話的創建、保持、心跳、通知推送。另外一部分就是通過MQ接收業務變更信息,通過MQ的廣播機制保證每臺推送系統服務器都能夠收到業務變更信息。

2.4、消息推送

利用了MQ的廣播所有的服務器都會收到消息,那麼推送的時候是如何找到需要哪一臺服務器來負責推送任務呢,在創建會話的時候我們將用戶會話信息保存到了本臺服務器的內存中,那麼只需要判斷消息中的USERPIN是否在本機內存中即可。如果不在本機內存直接丟棄該條消息。通過MQ接收到業務信息,解析出USERPIN,再根據USERPIN找到會話,拿到asyncContext,然後將通知包發送給客戶端。

2.5、消息追蹤

整個消息推送鏈相對比較長,需要做到對每個環節的埋點和跟蹤,便與後續問題的跟蹤處理。在業務中是通過kafka+hbase的方式,系統中把埋點數據寫到本地,由採集器將數據發送到kafka,進而消費kafka插入到hbase集羣。

三、HTTP長連接系統時序調用

時序圖.png

結合第二節和本節的時序圖我們清楚的知道實現一個推送系統主要包含會話維護、心跳、消息接收、消息推送,這其中共涉及以下三個數據包

創建會話連接包:{"protocol":1,"time":1510210650650,"state":"registered"}
心跳包:{"protocol":0,"time":1510211080780}
發送通知包:{"protocol":2,"time":1448610190241,"cmd":110001}

接下來看下重要環節的代碼實現:

3.1、創建會話(連接)

public  Session createSession(String sessionId, HttpServletRequest request, HttpServletResponse response) {
        //省略代碼...

        try {
            //省略代碼...
            session = new HttpStreamingSession();
            session.setSessionId(sessionId);
            session.setValid(true);
            session.setMaxInactiveInterval(this.getMaxInactiveInterval());
            session.setCreationTime(System.currentTimeMillis());
            session.setLastAccessedTime(System.currentTimeMillis());
            session.setSessionManager(this);

            session.setConnection(createHttpConnection(session, request, response));

            //省略代碼...

            return session;
        } catch (Exception e) {
            //省略代碼...
        } finally {
            //省略代碼...
        }
        return null;
    }

public void connect(){
        //省略代碼...
        if (isClosed()) {
            PushException e = new PushException("use a closed connection " + connectionId);
            this.fireError(e);
        }
        try {
            AsyncContext ac = request.startAsync();//開啓上下文
            ac.setTimeout(this.asyncTimeout);
            ac.addListener(new AsyncAdapter() {

                /**
                *
                * @param asyncevent
                *
                **/
                @Override
                public void onError(AsyncEvent asyncevent) throws IOException {
                    session.close();
                }

                /**
                *
                * @param asyncevent
                *
                **/
                @Override
                public void onTimeout(AsyncEvent asyncevent) throws IOException {
                    session.close();
                }
            });
            this.asyncContext = ac;//保存上下文

        } catch (Exception e) {
            this.fireError(new PushException("StartAsync exception! May be the servlet or filter is not async.", e));
        } finally {
            //省略代碼...
        }
    }

3.2、心跳邏輯

public void run() {//線程循環發送
        while (!this.stop) {
            try {
                Thread.sleep(getCheckPeriod());//停5秒
            } catch (InterruptedException e) {
            }

            if(this.stop)
                break;

            //省略代碼...
            try {
                //省略代碼...

                Map<String, Set<String>> result = heartbeatBroadcast(MessageProtocol.generateHeartBeat());//調用心跳方法

                //省略代碼...
            } catch (Exception e) {
                //省略代碼...
                _logger.error("check destination! ", e);
            } finally {
                //省略代碼...
            }
        }
    }

protected Map<String, Set<String>> heartbeatBroadcast(String msg) {
        if(isEmpty())
            return null;

        Map<String, Set<String>> result = new HashMap<String, Set<String>>(2);
        //省略代碼...
        for(Iterator<String> it = httpSessionManager.getSessionKeys().iterator(); it.hasNext(); ) {
            try {
                identity = it.next();
                session = httpSessionManager.getSession(identity);
                if(session.expire()) {//只有 session 過期後才發送心跳
                    _logger.info("--befor hear beat --SessionId:"+session.getSessionId());
                    session.getConnection().send(msg);
                    session.access();
                    //省略代碼...
                }
            } catch (Exception e) {
                //省略代碼...
            }
        }

        return result;
    }               

3.3、消息接收

public void onMessage(List<Message> messages) throws Exception {
        if (messages == null || messages.isEmpty()) {
            return;
        }

        for (Message message : messages) {
            //省略代碼...

            //處理消息
        }

    }

3.4、消息推送

public void sendMessage(String key,String context) throws DispatchException, PushException {
​        
        //獲取USERPIN
        String userPin = mem.hget(key,SessionProtocol.SESSION_FIELD_LOCALHOST);
        if(!localhostUserPin.equals(localhostRedis)){//如果消息中的USERPIN不在當前主機內存中則直接丟棄該消息,由其它主機來消費發送

            return ;
        }
        Session session = httpSessionManager.getSession(key);
        if (session == null) {
            _logger.info("session " + key + " no exist!");
            return;
        }
        try {
            //省略代碼...

            session.getConnection().send(context);
            session.access();
        } catch (PushException e) {
            session.close();
            throw new PushException(e);
        } catch (Exception e) {
            session.close();
            throw new PushException(e);
        }
    }

四、半推半拉

4.1、消息存儲

消息體存儲.png

消息實體保存到redis集羣,根據每個UERPIN組成N個HASH結構的數據體,如上圖所示數據結構。因爲USERPIN的數量很大,會均勻的散落到redis集羣裏,大量用戶訪問不會造成熱點問題。不過有些大用戶數據量會比較大,訪問頻率又比較高的,可以做二次HASH。

4.2、拉取方式

消息拉取圖示.png

我們在長連接中推送的是消息通知,並不是消息實體。在第三節中當瀏覽器收到通知後會發送一次http請求帶上CMD標識,服務器接收到USERPIN+CMD標識到對應的redis集羣中查詢數據,返回給客戶端。這也就是我們說的半推半拉方式,那麼我們爲什麼不直接把消息實體推送過去呢?推送一個簡短的通知命令字,只是告訴客戶端有數據變化,那麼用戶很有可能是不去看的,這種情況下如果直接推送實體數據,則會浪費數據傳輸。其實這個類似我們的公衆號,比如我們收到的是一個標題和概要。如果我不去點擊則不會發生文章大量內容的數據傳輸。

五、系統優化

5.1、NIO

長連接推送系統的最大特點就是服務器要HOLD住大量的連接,這個時候我們首先要考慮的IO模型就是要使用基於I/O複用模型的NIO。基於事件驅動利用Selector機制使用少量的線程保持住大量的連接是NIO擅長的能力。如果你使用的是tomcat7以下版本,在Connector節點配置protocol=”org.apache.coyote.http11.Http11NioProtocol”,以便啓用Http11NioProtocol協議。該協議下默認最大連接數是10000,可以重新修改maxConnections的值。有關tomcat nio詳細介紹請參看《深度解讀Tomcat中的NIO模型》

5.2、參數優化

一臺Linux服務器可以負載多少個連接?首先我們來看如何標識一個TCP連接?系統是通過一個四元組來識別,(src_ip,src_port,dst_ip,dst_port)即源IP、源端口、目標IP、目標端口。比如我們有一臺服務192.168.0.1,開啓端口80.那麼所有的客戶端都會連接到這臺服務的80端口上面。有一種誤解,就是我們常說一臺機器有65536個端口,那麼承載的連接數就是65536個,這個說法是極其錯誤的,這就混淆了源端口和訪問目標端口。我們做壓測的時候,利用壓測客戶端,這個客戶端的連接數是受到端口數的限制,但是服務器上面的連接數可以達到成千上萬個,一般可以達到百萬(4C8G配置),至於上限是多少,需要看優化的程度。最重要的一步是修改文件句柄數量限制。

查看當前用戶允許TCP打開的文件句柄最大數
ulimit -n

修改文件句柄
vim /etc/security/limits.conf

soft nofile 655350
hard nofile 655350

修改後,退出終端窗口,重新登錄(不需要重啓服務器),就能看到最新的結果了。
還有其他有關TCP參數的修改,請參看
《一臺Linux服務器可以負載多少個連接?》

六、測試

在做http長連接測試的時候,無論使用chrome還是Firefox瀏覽器,都因爲緩存的原因測試不出長連接下通過web服務動態吐內容的效果,所以我們自己寫一個client。

  public class HttpConnectionTest {

    public static final String URL = "http://push.test.com/async?pin=123";

    public static void main(String[] args) throws Exception {

        ExecutorService es = Executors.newFixedThreadPool(1);
        for(int i=0;i<1;i++){
            es.submit(new Runnable() {
                public void run() {
                    String URL=URL+"&client_id="+UUID.randomUUID().toString();
                    connection(URL);
                }
            });
        }
    }

    static void connection(String url) {

    InputStream is = null;
    URLConnection conn = null;
    byte[] buf = new byte[1024];
    try {
        URL a = new URL(url);
        conn = a.openConnection();
        is = conn.getInputStream();
        int ret = 0;
        while ((ret = is.read(buf)) > 0) {
            processBuf(buf, ret);
        }
        // close the inputstream
        is.close();
    } catch (IOException e) {
        try {
            int respCode = ((HttpURLConnection) conn).getResponseCode();
            InputStream es = ((HttpURLConnection) conn).getErrorStream();
            int ret = 0;
            // read the response body
            while ((ret = es.read(buf)) > 0) {
                processBuf(buf, ret);
            }
            // close the errorstream
            es.close();
        } catch (IOException ex) {
            e.printStackTrace();
        }
    }

    }

    static void processBuf(byte[] buf, int length) {
        System.out.println(new String(buf, 0, length));
    }
  }

七、總結

在這篇文章裏我們從web系統的部署結構,http1.1和nginx的配置,再到實現一個http長連接系統的組成部分,推送系統的流程時序關係,最後說到系統參數調整如何來支持海量的連接。當然實現一個類似http長連接推送系統的方式還有其他比如websocket等技術,但是長連接推送系統的組成部分基本不會變也就是會話連接、心跳邏輯、消息接收、消息存儲、消息推送。那麼servlet3異步+tomcat nio給我們提供了一個實現http長連接推送的基礎支持與實踐參考。

轉載請註明作者及出處,並附上鍊接http://www.jianshu.com/p/b060bb158631
關注公衆號同步更新技術文章

參考資料:
http://nginx.org/en/docs/http/ngx_http_upstream_module.html#keepalive
https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.6.1

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章