天天看點

長連接配接的watch機制實作

前言:對于于HTTP協定來說,服務端給一次響應後整個請求就結束了,這是HTTP請求最大的特點,也是由于這個特點,HTTP請求無法做到的是服務端向用戶端主動推送資料。但由于HTTP協定的廣泛應用,很多時候确實又想使用HTTP協定去實作實時的資料擷取,這種時候應當怎麼辦呢?下面首先介紹幾種基于HTTP協定的實時資料擷取方法。

一、連接配接

TCP連接配接中四個要素組合體的唯一性:

用戶端的IP、用戶端的port、伺服器端的IP、伺服器端的port

1、長連接配接: 長連接配接是指的TCP連接配接,而不是HTTP連接配接。從HTTP1.1協定以後,連接配接預設都是長連接配接。http 長連接配接, 就是多個 http 請求共用一個 tcp 連接配接; 這樣可以減少多次臨近 http 請求導緻 tcp建立關閉所産生的時間消耗.。它最大的特點的就是 TCP 連接配接能夠保持一段時間(超過這個時間會自動斷開),不會在一次資訊互動後馬上斷開,下一個請求會繼續使用該 TCP 連接配接,達到 TCP 連接配接複用的效果。

http 1.1 中在請求頭和相應頭中用 connection字段辨別是否是 http長連接配接, connection: keep-alive, 表明是 http 長連接配接; connection:closed, 表明伺服器關閉 tcp 長連接配接,是短連接配接的方式。

  • 優點:有效複用 TCP 連接配接,減少網絡延遲。
  • 缺點:需要對每個 TCP 連接配接增加管理,占用伺服器的更多的記憶體。因為 TCP 連接配接能夠保持一段時間,是以需要判斷該 TCP 連接配接是否失效、是否應該釋放連接配接;無論 TCP 連接配接是否正處于通信狀态,隻要是在有效期内的都要存儲。

一次長連接配接調用怎麼關閉TCP:

  • 浏覽器的重新整理也會斷開長連接配接
  • 浏覽器頁面關閉
  • 長連接配接逾時

2、短連接配接: 這裡的連接配接指的是 TCP 連接配接。一個 TCP 連接配接從建立到結束一共有 3 個階段,分别為“三次握手”建立連接配接、用戶端與服務端進行資料包傳輸、“四次揮手”斷開連接配接。用戶端與服務端的每一次完整的消息互動(發請求——響應)都建立一次 TCP 連接配接,當這次互動完畢後就釋放該 TCP 連接配接。這個過程就是

短連接配接

3、一個TCP連接配接可以發多少個HTTP請求?

比如請求一個普通的網頁,這個網頁裡肯定包含了若幹CSS、JS等一系列資源,如果是短連接配接(也就是每次都要重建立立TCP連接配接)的話,那每次打開一個網頁,基本就要建立幾個甚至幾十個TCP連接配接,浪費很多網絡資源。如果是長連接配接的話,那麼這麼多HTTP請求(包括請求網頁的内容、CSS檔案、JS檔案、圖檔等)都是使用的一個TCP連接配接,顯然可以節省很多資源。

二、輪詢

輪詢是最普遍的基于HTTP協定采用拉的方式擷取實時資料的,輪詢又分為短輪詢和長輪詢。 輪詢和是否為長連接配接之間的關系是獨立。不使用長連接配接,其實也可以使用輪詢(短輪詢)。當然長輪詢仍然依賴長連接配接。HTTP1.1上通信預設是長連接配接。

1、長輪詢: http 長輪詢是伺服器收到請求後如果有資料, 立刻響應請求; 如果沒有資料就會 hold 一段時間,這段時間内如果有資料立刻響應請求; 如果時間到了還沒有資料, 則響應 http 請求; 浏覽器受到 http 響應後立在發送一個同樣http 請求查詢是否有資料。差别在于服務端收到請求不再直接給響應,而是将請求挂起,自己去定時判斷資料的變化,有變化就立馬傳回給用戶端,沒有就等到逾時為止。

http 長輪詢的優點:

  • 實時性高

http 長輪詢的缺點:

  • 浏覽器端對統一伺服器同時 http 連接配接有最大限制, 最好同一使用者隻存在一個長輪詢;
  • 伺服器端沒有資料 hold 住連接配接時會造成浪費, 容易産生伺服器瓶頸;
長連接配接的watch機制實作

2、短輪詢:用戶端按照一定時間的間隔去請求http伺服器,伺服器會立即響應,不管有沒有可用資料。 http端輪詢是伺服器收到請求不管是否有資料都直接響應 http 請求; 浏覽器受到 http 響應隔一段時間在發送同樣的http 請求查詢是否有資料;

http短輪詢的優點:

  • 短連結、伺服器處理友善。

http 短輪詢的缺點:

  • 實時性低、很多無效請求、性能開銷大

差別:間隔發生在服務端還是浏覽器端: http 長輪詢在服務端會 hold 一段時間, http 短輪詢在浏覽器端 “hold”一段時間;

長連接配接的watch機制實作

3、應用場景:

  • 長輪詢一般用在 web im, im 實時性要求高, http 長輪詢的控制權一直在伺服器端, 而資料是在伺服器端的,是以實時性高;像新浪微薄的im 以及 webQQ 都是用 http 長輪詢實作的;
  • http 短輪詢一般用在實時性要求不高的地方, 比如新浪微薄的未讀條數查詢就是浏覽器端每隔一段時間查詢的。

4、AJAX輪詢

三、實時推資料

熱更新、watch機制的實作。

1、http chuncked

HTTP1.1支援持久連接配接,在一個TCP連接配接上可以傳送多個HTTP請求和響應,減少了建立和關閉連接配接的消耗和延遲(也就是一次TCP的連接配接不馬上釋放,允許許多的請求跟響應在一個TCP的連接配接上發送),是以客戶機與伺服器需要某種方式來标示一個封包在哪裡結束和下一個封包從哪裡開始。簡單的方法是使用Content-Length,但這隻有當封包長度可以預先判斷的時候才起作用。

HTTP分塊傳輸編碼允許伺服器為動态生成的内容維持HTTP持久連接配接。通常,持久連結需要伺服器在開始發送消息體前發送Content-Length消息頭字段,但是對于動态生成的内容來說,在内容建立完之前是不可知的。使用分塊傳輸編碼,資料分解成一系列資料塊,并以一個或多個塊發送,這樣伺服器可以發送資料而不需要預先知道發送内容的總大小。隻要浏覽器沒有遇到結束辨別,就會邊解析邊執行對應的響應内容。

在長連接配接模式中,除了通過

Content-Length

指定響應體的長度外,還有另外一種傳輸方式,分塊傳輸編碼。

分塊傳輸編碼(Chunked transfer encoding)是超文本傳輸協定(HTTP)中的一種資料傳輸機制,允許HTTP由網頁伺服器發送給用戶端應用( 通常是網頁浏覽器)的資料可以分成多個部分。分塊傳輸編碼隻在HTTP協定1.1版本(HTTP/1.1)中提供。

1.1版規定可以不使用

Content-Length

字段,而使用分塊傳輸編碼(chunked transfer encoding)。隻要請求或回應的頭資訊有

Transfer-Encoding

字段,就表明回應将由數量未定的資料塊組成,基于長連接配接持續推送動态内容。

k8s中watch應用: 當用戶端調用

watch API

時,

apiserve

r 在

response

HTTP Header

中設定

Transfer-Encoding

的值為

chunked

,表示采用

分塊傳輸

編碼,用戶端收到該資訊後,便和服務端該連結,并等待下一個資料塊,即資源的事件資訊。

etcd 會有一個線程持續不斷地周遊所有的 watch 請求,每個 watch 對象都會負責維護其監控的 key 事件,看其推送到了哪個 revision

2、websocket:

2.1 原理: http協定本身是無狀态協定,每一個新的http請求,隻能通過用戶端主動發起,通過 建立連接配接–>傳輸資料–>斷開連接配接 的方式來傳輸資料,傳送完連接配接就斷開了,也就是這次http請求已經完全結束了(雖然http1.1增加了keep-alive請求頭可以通過一條通道請求多次,但本質上還是一樣的)。并且伺服器是不能主動給用戶端發送資料的(因為之前的請求得到響應後連接配接就斷開了,之後伺服器根本不知道誰請求過),用戶端也不會知道之前請求的任何資訊。(這裡的持久通信能力指的是協定本身的能力,我們當然可以通過程式設計的方式實作這種功能,比如輪詢的方式,但這不是協定原生的能力。)

WebSocket本質上一種計算機網絡應用層的協定,用來彌補http協定在持久通信能力上的不足。WebSocket 的最大特點就是,伺服器可以主動向用戶端推送資訊,用戶端也可以主動向伺服器發送資訊,是真正的雙向平等對話。Websocket 其實是一個新協定,跟 HTTP 協定基本沒有關系,隻是為了相容現有浏覽器,是以在握手階段使用了 HTTP 。Websocket是應用層第七層上的一個應用層協定,它必須依賴 HTTP 協定進行一次握手 ,握手成功後,資料就直接從 TCP 通道傳輸,與 HTTP 無關了。即websocket分為握手和資料傳輸階段,即進行了HTTP握手 + 雙工的TCP連接配接。

2.2 握手階段: 協定辨別符是

ws

(如果加密,則為

wss

),伺服器網址就是 URL。

ws://example.com:80/some/path
           

用戶端發送請求,請求頭中重要的字段:

//Connection和Upgrade字段告訴伺服器,表示要更新的協定,用戶端發起的是WebSocket協定請求
Connection:Upgrade 

//Upgrade: websocket:表示要更新到websocket協定
Upgrade:websocket  

//Sec-WebSocket-Extensions表示用戶端想要表達的協定級的擴充
Sec-WebSocket-Extensions:permessage-deflate; client_max_window_bits   

//Sec-WebSocket-Key是一個Base64編碼值,由浏覽器随機生成,提供基本的防護,比如惡意的連接配接或者無意的連接配接。
//在用戶端每次發起協定更新請求的時候都會産生一個唯一碼:Sec-WebSocket-Key。服務端拿到這個碼後,
//通過一個算法進行校驗,然後通過Sec-WebSocket-Accept響應給用戶端,用戶端再對Sec-WebSocket-Accept進行校驗來完成驗證。
Sec-WebSocket-Key:mg8LvEqrB2vLpyCNnCJV3Q==

//Sec-WebSocket-Version表明用戶端所使用的協定版本
Sec-WebSocket-Version:13
           

然後伺服器會傳回下列東西,表示已經接受到請求, 成功建立Websocket連接配接。

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat
           

至此,HTTP已經完成它所有工作(握手),接下來就是完全按照Websocket協定進行資料傳輸。

2.3 幀傳輸階段

websocket協定是通過分片打包資料進行轉發的,不過政策上和HTTP的分包不一樣。frame(幀)是websocket發送資料的基本機關,一旦WebSocket連接配接建立後,後續資料都以幀序列的形式傳輸。websocket通信中,用戶端發送資料分片是有序的,用戶端和服務端進行Websocket消息傳遞是這樣的: 用戶端将消息切割成多個幀,并發送給服務端; 服務端:接收消息幀,并将關聯的幀重新組裝成完整的消息。服務端在接收到用戶端發送的幀消息的時候,将這些幀進行組裝。優點是:

  • a、大資料的傳輸可以分片傳輸,不用考慮到資料大小導緻的長度标志位不足夠的情況。
  • b、和http的chunk一樣,可以邊生成資料邊傳遞消息,即提高傳輸效率。

在用戶端斷開WebSocket連接配接或Server端中斷連接配接前,不需要用戶端和服務端重新發起連接配接請求。在海量并發及用戶端與伺服器互動負載流量大的情況下,極大的節省了網絡帶寬資源的消耗,有明顯的性能優勢,且用戶端發送和接受消息是在同一個持久連接配接上發起,實時性優勢明顯。這種單向請求的特點,注定了如果伺服器有連續的狀态變化,用戶端要獲知就非常麻煩。我們隻能使用輪詢:每隔一段時候,就發出一個詢問,了解伺服器有沒有新的資訊。最典型的場景就是聊天室。輪詢的效率低,非常浪費資源(因為必須不停連接配接,或者 HTTP 連接配接始終打開)。

長連接配接的watch機制實作

2.4 連接配接保持政策

Websocket是長連接配接,為了保持用戶端和服務端的實時雙向通信,需要確定用戶端和服務端之間的TCP通道保持連接配接沒有斷開。但是對于長時間沒有資料往來的連接配接,如果依舊保持着,可能會浪費服務端資源。但是不排除有些場景,用戶端和服務端雖然長時間沒有資料往來,仍然需要保持連接配接,用戶端和服務端一直再采用心跳來檢查連接配接。

2.5 事件處理

用戶端應用程式不需要輪詢伺服器來得到更新的資料。消息和事件将在伺服器發送它們的時候異步到達。WebSocket程式設計遵循異步程式設計模式,也就是說,隻要WebSocket連接配接打開,應用程式就簡單地監聽事件。用戶端不需要主動輪詢伺服器得到更多的資訊。要開始監聽事件,隻要為WebSocket對象添加回調函數。如果要指定多個回調函數,可以使用addEventListener方法。

webSocket對象排程4個不同的用戶端事件:

  • open: 一旦伺服器響應了WebSocket連接配接請求,open事件觸發并建立一個連接配接。open事件對應的回調函數稱作onopen.
  • message: message事件在接收到消息時觸發,對應于該事件的回調函數是onmessage。
  • error: error事件在響應意外故障的時候觸發。與該事件對應的回調函數為onerror。
  • close: close事件在WebSocket連接配接關閉時觸發。對應于close事件的回調函數是onclose。一旦連接配接關閉,用戶端和伺服器不再能接收或者發送消息。
    // 連接配接請求open事件處理:
     ws.onopen = e => {
         console.log('Connection success');
         ws.send(`Hello ${e}`);
      };
         
      ws.addEventListener('open', e => {
      		ws.send(`Hello ${e}`);
      });
               

服務端事件監聽:

  • connection

    ——用戶端成功連接配接到伺服器。
  • message

    ——捕獲用戶端

    send

    資訊。。
  • disconnect

    ——用戶端斷開連接配接。
  • error

    ——發生錯誤。
    router.ws("/test", (ws, req) => {
      ws.send("連接配接成功")
      let interval
      // 連接配接成功後使用定時器定時向用戶端發送資料,同時要注意定時器執行的時機,要在連接配接開啟狀态下才可以發送資料
      interval = setInterval(() => {
        if (ws.readyState === ws.OPEN) {
          ws.send(Math.random().toFixed(2))
        } else {
          clearInterval(interval)
        }
      }, 1000)
      // 監聽用戶端發來的資料,直接将資訊原封不動傳回回去
      ws.on("message", msg => {
        ws.send(msg)
      })
    })
               

相比HTTP長連接配接,WebSocket有以下特點:

  • 真正的全雙工方式,建立連接配接後用戶端與伺服器端是完全平等的,可以互相主動請求。而HTTP長連接配接基于HTTP,是傳統的用戶端對伺服器發起請求的模式。HTTP是一個request隻能有一個response。而且這個response也是被動的,不能主動發起。HTTP的生命周期通過Request來界定,也就是一個Request 一個Response。HTTP 協定做不到伺服器主動向用戶端推送資訊。
  • HTTP長連接配接中,每次資料交換除了真正的資料部分外,伺服器和用戶端還要大量交換HTTP header,資訊交換效率很低。Websocket協定通過第一個request建立了TCP連接配接之後,之後交換的資料都不需要發送 HTTP header就能交換資料,這顯然和原有的HTTP協定有差別是以它需要對伺服器和用戶端都進行更新才能實作(主流浏覽器都已支援HTML5)。此外還有 multiplexing、不同的URL可以複用同一個WebSocket連接配接等功能。這些都是HTTP長連接配接不能做到的。

3、http 多路複用與流機制

HTTP 1.1 基于串行檔案傳輸資料,是以這些請求必須是有序的,是以實際上我們隻是節省了建立連接配接的時間,而擷取資料的時間并沒有減少。HTTP/2 引入二進制資料幀和流的概念,其中幀對資料進行順序辨別,這樣浏覽器收到資料之後,就可以按照序列對資料進行合并,而不會出現合并後資料錯亂的情況。同樣是因為有了序列,伺服器就可以并行的傳輸資料。

HTTP/1.1中的消息是“管道串形化”的:隻有等一個消息完成之後,才能進行下一條消息;而HTTP/2中多個消息交織在了一起,這無疑提高了“通信”的效率。這就是多路複用:在一個HTTP的連接配接上,多路“HTTP消息”同時工作。

長連接配接的watch機制實作

四、事件監聽watch機制的測試實作

package main

import (
	"errors"
	"fmt"
	"log"
	"math/rand"
	"net"
	"net/rpc"
	"sync"
	"time"
)

// 測試使用的的記憶體 KV 資料庫
type TestKVStoreService struct {
	m      map[string]string           // 存儲資料
	filter map[string]func(key string) // Watch 調用時的過濾器函數清單,key->func
	mu     sync.Mutex
}

func NewTestKVStoreService() *TestKVStoreService {
	return &KVStoreService{
		m:      make(map[string]string),
		filter: make(map[string]func(key string)),
	}
}

func (p *TestKVStoreService) Get(key string, value *string) error {
	p.mu.Lock()
	defer p.mu.Unlock()
	if v, ok := p.m[key]; ok {
		*value = v
		return nil
	}
	return errors.New("not found")
}

// 輸入參數是 key 和 value 組成的數組,匿名結構體則表示忽略輸出參數
func (p *TestKVStoreService) Set(kv [2]string, reply *struct{}) error {
	p.mu.Lock()
	defer p.mu.Unlock()
	key, value := kv[0], kv[1]
	oldValue := p.m[key]
	// 當修改 key 對應的 value 時,調用每一個過濾器函數
	if oldValue != value {
		for _, fn := range p.filter {
			fn(key)
		}
	}
	// 更新
	p.m[key] = value
	return nil
}

func (p *TestKVStoreService) Watch(timeoutSecond int, keyChanged *string) error {
	id := fmt.Sprintf("watch-%s-%03d", time.Now(), rand.Int())
	ch := make(chan string, 10)
	p.mu.Lock()
	// 注冊過濾器函數
	p.filter[id] = func(key string) {
		ch <- key
	}
	p.mu.Unlock()
	select {
	// 是否逾時
	case <-time.After(time.Duration(timeoutSecond) * time.Second):
		return errors.New("timeout")
	case key := <-ch:
		*keyChanged = key
		return nil
	}
	return nil
}

func main() {
	// 将 KVStoreService 對象注冊為一個 RPC 服務
	// 将對象中所有滿足 RPC 規則的對象方法注冊為 RPC 函數
	// 所有注冊的方法會放在 “KVStoreService” 服務空間執行
	_ = rpc.RegisterName("TestKVStoreService", NewKVStoreService())
	listener, err := net.Listen("tcp", ":1234")
	if err != nil {
		log.Fatal(err)
	}
	conn, err := listener.Accept()
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()
	// 在該 TCP 連接配接上為對方提供 RPC 服務
	rpc.ServeConn(conn)
}

           

五、 apollo配置中心應用長輪詢:

總體流程:

Apollo用戶端和服務端保持了一個長連接配接,進而能第一時間獲得配置更新的推送。長連接配接實際上是通過Http Long Polling實作的,具體而言:

  • 用戶端發起一個Http請求到服務端
  • 服務端會保持住這個連接配接60秒
  • 如果在60秒内有用戶端關心的配置變化,被保持住的用戶端請求會立即傳回,并告知用戶端有配置變化的namespace資訊,用戶端會據此拉取對應namespace的最新配置
  • 如果在60秒内沒有用戶端關心的配置變化,那麼會傳回Http狀态碼304給用戶端
  • 用戶端在收到服務端請求後會立即重新發起連接配接,回到第一步

考慮到會有數萬用戶端向服務端發起長連,在服務端使用了async servlet(Spring DeferredResult)來服務Http Long Polling請求。

Apollo用戶端的實作原理:

  1. 用戶端和服務端保持了一個長連接配接,進而能第一時間獲得配置更新的推送。(通過Http Long Polling實作)
  2. 用戶端還會定時從Apollo配置中心服務端拉取應用的最新配置。
    • 這是一個fallback機制,為了防止推送機制失效導緻配置不更新
    • 用戶端定時拉取會上報本地版本,是以一般情況下,對于定時拉取的操作,服務端都會傳回304 - Not Modified
    • 定時頻率預設為每5分鐘拉取一次,用戶端也可以通過在運作時指定System Property:

      apollo.refreshInterval

      來覆寫,機關為分鐘。
  3. 用戶端從Apollo配置中心服務端擷取到應用的最新配置後,會儲存在記憶體中
  4. 用戶端會把從服務端擷取到的配置在本地檔案系統緩存一份
    • 在遇到服務不可用,或網絡不通的時候,依然能從本地恢複配置
  5. 應用程式可以從Apollo用戶端擷取最新的配置、訂閱配置更新通知

Apollo服務端的實作原理:

用戶端調用管理接口在配置釋出後,需要通知所有的Config Service有配置釋出,進而Config Service可以通知對應的用戶端來拉取最新的配置。從概念上來看,這是一個典型的消息使用場景,Admin Service作為producer發出消息,各個Config Service作為consumer消費消息。通過一個消息元件(Message Queue)就能很好的實作Admin Service和Config Service的解耦。

在實作上,考慮到Apollo的實際使用場景,以及為了盡可能減少外部依賴,我們沒有采用外部的消息中間件,而是通過資料庫實作了一個簡單的消息隊列。考慮到Apollo的實際使用場景,以及為了盡可能減少外部依賴,Apollo沒有采用外部的消息中間件,而是通過資料庫實作了一個簡單的消息隊列。

  • Admin Service在配置釋出後會往ReleaseMessage表插入一條消息記錄,消息内容就是配置釋出的AppId+Cluster+Namespace。
  • Config Service有一個線程會每秒掃描一次ReleaseMessage表,看看是否有新的消息記錄。
  • Config Service如果發現有新的消息記錄,那麼就會通知到所有的消息監聽器。

服務端在得知有配置釋出後是如何通知到用戶端的呢?實作方式如下:

  1. 用戶端會發起一個Http請求到Config Service的

    notifications/v2

    接口,也就是NotificationControllerV2,參見RemoteConfigLongPollService
  2. NotificationControllerV2不會立即傳回結果,而是通過Spring DeferredResult把請求挂起
  3. 如果在60秒内沒有該用戶端關心的配置釋出,那麼會傳回Http狀态碼304給用戶端
  4. 如果有該用戶端關心的配置釋出,NotificationControllerV2會調用DeferredResult的setResult方法,傳入有配置變化的namespace資訊,同時該請求會立即傳回。用戶端從傳回的結果中擷取到配置變化的namespace後,會立即請求Config Service擷取該namespace的最新配置。

監聽器中包含監聽的key和client address,update_time,資料庫中更新時大于監聽器對象的update_time的配置會通知給監聽器的用戶端,用go實作的話可以考慮加channel, 大部分實作把監聽器對象和監聽的key放到一個hashmap(key, arrary(watcher))或key可重複的multimap(key, watcher)中。用于快速定位watcher。