优化实战 第 10 期 - 使用WebSocket和共享Worker优化消息通知

2,339 阅读4分钟

由于 TCP 协议是面向有连接的,只有在确认通信接收端存在的情况下才会发送数据,并且在连接断开时也会确认另一方是否还需要发送数据,这也是 TCP 协议的 三次握手四次挥手

建立TCP连接

通过三次握手,客户端与服务器利用 SYN 报文段交换彼此的初始序列号,互相维持一个具有连接特性的状态

handshake.jpeg

关闭TCP连接

通过四次挥手,确保数据传输的完整性,当被动方的数据全部传输给主动方后进行连接的关闭

wave.jpeg

性能分析

客户端和服务端进行数据交互,只需要使用 2 次网络传输,而建立和断开连接却使用了 7 次网络传输,如果发送端和接收端之间频繁发送小的数据包,则可能会出现建立和断开连接的开销比发送数据的开销还要大,比如:消息通知

除此之外,每次在发送数据前,都需要等待连接建立,也会增加数据的发送等待时间

所以针对客户端和服务端需要频繁传输数据的场景,可以使用 长连接的方式 来提升服务性能,即发送方和接收方建立连接后,保持连接不断开,下次发送数据时可以直接使用已经建立的 TCP 连接,而无需再等待

HTTP1.1协议的长连接

客户端在 HTTP 请求的 header 中添加 Connection: Keep-Alive 来告知服务器发送完响应数据后要维持这个 TCP 连接不断开,服务端会通过 Keep-Alive: timeout=30, max=100 来告知客户端,维持这个长连接的时间和当前维持连接可发送的请求数

如果客户端不再发送数据,服务端可以通过 Connection: close 断开连接

pipeline.jpeg

性能分析:在接收响应报文时,必须依赖请求顺序接收;如果前一个请求遇到了阻塞,后面的请求即使已经处理完毕了,仍然需要等待阻塞的请求处理完,也叫 队头阻塞

HTTP2协议的长连接

使用 TCP 的长连接,还升级为复用此长连接,解决了 队头阻塞 的问题

multiple.jpeg

WebSocket协议的长连接

  • 什么是WekSocket

    是一种在单个 TCP 连接上进行全双工通讯的协议,服务端可以主动向客户端推送信息,客户端也可以主动向服务端发送信息

  • WebSocket特点

    可以发送文本,也可以发送二进制数据

    没有同源策略的限制,客户端可以与任意服务端通信

    协议标识是ws(如果加密,则为wss)

    无法自动重连

建立WebSocket的重连机制

  • 为什么需要重连

    在使用过程当中,如果出现网络断开连接的情况,服务端并不会断开连接

    所以服务端会继续向客户端发送数据,就会导致数据丢失

  • 心跳机制

    每隔一段时间向服务端发送一个数据包,告诉服务端自己还活着

    如果服务端回传一个数据包,说明服务端也活着,否则有可能是网络断开连接了,就需要重连

  • 代码示例

    const heartbeat = Object.assign(Object.create(null), {
      interval: 30000, // 每隔 30 秒发送心跳
      timeout: 50000, // 服务端响应超时时间
      timer: null,
      serverTimer: null,
      // 重置心跳
      reset(ws) {
        clearTimeout(this.timer)
        clearTimeout(this.serverTimer)
        this.start(ws)
      },
      // 开启心跳
      start(ws) {
        this.timer = setTimeout(() => {
          ws.send(JSON.stringify({ userId: 5, msg: 'ping' }))
          this.serverTimer = setTimeout(() => {
            ws.close()
          }, this.timeout)
        }, this.interval)
      }
    })
    

    当连接成功时,开启心跳;在收到消息时,重置心跳并开启下一轮检测

    所以我们只需要在 onopenonmessage 中加入心跳检测

  • 属性readyState表示的连接状态

    0 对应常量 CONNECTING,表示正在建立连接,还没有完成

    1 对应常量 OPEN,表示连接成功建立,可以进行通信

    2 对应常量 CLOSING,表示连接正在进行关闭握手,即将关闭

    3 对应常量 CLOSED,表示连接已经关闭或者根本没有建立

消息通知

  • 实现WebSocket的封装(worker/lib/socket.js)
    class Socket {
      constructor(user_id, callback) {
        Object.assign(this, { 
          interval: 30000, timeout: 50000, retryTimer: null, user_id, callback 
        })
        this.init(user_id)
      }
      init(user_id) {
        const { host } = location
        // 区分测试环境还是生产环境
        const socket_url = `${host.startsWith('test') ? 'ws' : 'wss'}://${host}/ws/message/${user_id}`
        const ws = new WebSocket(socket_url)
        // 在连接成功时,开启心跳检测
        ws.onopen = event => {
          ws.send(JSON.stringify({ userId: 5, msg: 'ping' }))
          this.heartbeat()
        }
        // 在收到消息时,重置心跳并开启下一轮新的检测
        ws.onmessage = event => {
          callback(event.data)
          this.heartblock()
        }
        ws.onerror = event => {
          this.reset(ws, callback)
        }
        Object.assign(this, { ws })
      }
      // 开启心跳
      heartbeat() {
        const { interval, timeout, user_id, callback, ws } = this
        this.timer = setTimeout(() => {
          if (ws.readyState == 1) {
            ws.send(JSON.stringify({ userId, msg: 'ping' }))
          }
          this.serverTimer = setTimeout(() => {
            this.reset(ws, callback)
          }, timeout)
        }, interval)
      }
      // 重置心跳,并开启一个新的心跳检测
      heartblock() {
        clearTimeout(this.timer)
        clearTimeout(this.serverTimer)
        this.heartbeat()
      }
      // 重置连接,并创建一个新的连接
      reset(ws) {
        if (ws.readyState == 3) {
          const { user_id, callback } = this
          this.timer && clearTimeout(this.timer)
          this.serverTimer && clearTimeout(this.serverTimer)
          this.retryTimer && clearTimeout(this.retryTimer)
          // 由于 pending 时间无法具体估算,经调试给 100 秒,防止出现多个连接
          this.retryTimer = setTimeout(() => {
            ws && ws.close()
            new Socket(user_id, callback)
          }, 100000)
        }
      }
    }
    
  • 主线程环境(worker/share.index.js)
    const workerSocket = (data = {}, callback) => {
      const sharedWorker = new SharedWorker(`${location.origin}/worker/socket.worker.js`, {
        name: 'socket_worker'
      })
      const worker = sharedWorker.port
      worker.postMessage(data)
      worker.onmessage = ({ data: message }) => callback(message)
    }
    export default workerSocket
    
  • 工作线程环境(worker/socket.worker.js)
    importScripts(`${location.origin}/worker/lib/socket.js`)
    self.addEventListener('connect', ({ ports }) => {
      const client = ports[0]
      client.onmessage = ({ data }) => {
        new self.Socket(data, message => client.postMessage(message))
      }
    })