前言:对于于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 住连接时会造成浪费, 容易产生服务器瓶颈;
2、短轮询:客户端按照一定时间的间隔去请求http服务器,服务器会立即响应,不管有没有可用数据。 http端轮询是服务器收到请求不管是否有数据都直接响应 http 请求; 浏览器受到 http 响应隔一段时间在发送同样的http 请求查询是否有数据;
http短轮询的优点:
- 短链接、服务器处理方便。
http 短轮询的缺点:
- 实时性低、很多无效请求、性能开销大
区别:间隔发生在服务端还是浏览器端: http 长轮询在服务端会 hold 一段时间, http 短轮询在浏览器端 “hold”一段时间;
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 连接始终打开)。
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机制的测试实现
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客户端的实现原理:
- 客户端和服务端保持了一个长连接,从而能第一时间获得配置更新的推送。(通过Http Long Polling实现)
- 客户端还会定时从Apollo配置中心服务端拉取应用的最新配置。
- 这是一个fallback机制,为了防止推送机制失效导致配置不更新
- 客户端定时拉取会上报本地版本,所以一般情况下,对于定时拉取的操作,服务端都会返回304 - Not Modified
- 定时频率默认为每5分钟拉取一次,客户端也可以通过在运行时指定System Property:
apollo.refreshInterval
来覆盖,单位为分钟。
- 客户端从Apollo配置中心服务端获取到应用的最新配置后,会保存在内存中
- 客户端会把从服务端获取到的配置在本地文件系统缓存一份
- 在遇到服务不可用,或网络不通的时候,依然能从本地恢复配置
- 应用程序可以从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如果发现有新的消息记录,那么就会通知到所有的消息监听器。
服务端在得知有配置发布后是如何通知到客户端的呢?实现方式如下:
- 客户端会发起一个Http请求到Config Service的
notifications/v2
接口,也就是NotificationControllerV2,参见RemoteConfigLongPollService - NotificationControllerV2不会立即返回结果,而是通过Spring DeferredResult把请求挂起
- 如果在60秒内没有该客户端关心的配置发布,那么会返回Http状态码304给客户端
- 如果有该客户端关心的配置发布,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。