0%

长连接网关设计文档

背景介绍

Plato 为保证消息的及时性需要使用 tcp 长连接与客户端进行通信(节省 DNS, 握手等开销,并可主主动 push 消息给客户端)。长连接服务器需要一直维护连接状态。连接状态通常分为系统部分和应用部分:

  • 系统部分指的是 socket 的管理
  • 应用部分指的是连接过程中的 uid/did/fd 之前的映射关系,以及 clientID 等信息的存储

这些信息的生命周期是跟随一个长连接的创建而产生,长连接的断开而消亡。持久化除了用于数据分析,同时这些信息也是收发消息维度的访问频率,QPS 极高,因此需要存储在内存中被使用。

基本流程

当客户端初始化建立长连接时

  1. 向某个 IP 的长连接服务发送创建连接信令
  2. 网关 server 解析信令得知其为创建连接信令
  3. 网关 server 获得底层 socket 的 FD,以及用户的 uid/did,建立注册表
  4. 回复客户端连接建立成功

当客户端发送消息时

  1. 客户端发送上行消息信令
  2. 网关服务接收到消息,并解析信令为上行消息信令
  3. 根据 clientID 和 sessionID 进行路由,分配 seqID 等状态更新逻辑
  4. 然后转发给业务层服务处理,确认业务层收到消息后立即回复客户端 ACK

当业务处理后,将消息转发给接收客户端时

  1. 业务根据 sessionID 定位到该会话的接收者的连接在哪一个网关服务上
  2. 然后将消息通过 RPC 交给网关服务,网关拿到数据后通过 uid 对应 connID,确定 fd
  3. 然后根据 fd 找到对应的 socket,将消息拼接固定消息头发送给接收方客户端

当连接断开时

  1. 心跳超时,连接断开/异常断开
  2. 状态回收释放

需要的信令

  1. CreateConn 创建连接
  2. 发送上行消息
  3. 发送下行消息
  4. 上行 ACK 回复消息
  5. 下行 ACK 确认消息

需要的 RPC:
6. 消息路由

设计目标和技术挑战

长连接接入层主要解决的问题就是实现服务端主动及时地将消息发送给客户端的功能。而在这个过程中,会有非常多的技术挑战:

  1. 客户端如何选择网关 IP 地址才能降低延迟,保证连接可靠,负载均衡?
  2. 网关服务如何接收客户端的消息,获得最大的并发度获得消息的高吞吐,低延迟?
  3. 为了能使用长连接收发消息,需要维护哪些状态,如何使其占用更少的内存,单机承载更多的连接?
  4. 业务层是怎么感知到连接在哪一个网关机器,并把消息分发下去的呢?如何降低网络请求的局中?
  5. 客户端进入地铁切换基站/连接 wifi 等情况导致连接断开,如何能快速重连,而不影响用户体验?
  6. 如何尽可能的减少长连接服务的崩溃/重启次数,做到永不宕机?
  7. 长连接服务如何做限流/熔断/降级策略?实现对网关的过载保护,提高可靠性?
  8. 长连接服务如何做到通用性,灵活对接各种业务场景?
  9. 如何多数据中心部署长连接网关?

评估标准

  1. 【可靠性】永不宕机,快速重连,快速重启,水平扩展,负载均衡(内存 cpu/网络稳定)
  2. 【低延迟】收发消息在接入层的耗时 p99 不能超过 5ms。
  3. 【高吞吐】单机持有长连接数量以及活跃连接每秒收发消息数。
  4. 【上线频率】指长连接网关在一周时间内开发需求&bug fix导致上线的频率。
  5. 【可扩展性】不影响用户的情况下增加网关机器或接入其他信令或业务,跨数据中心部署

方案选型

客户端如何选择网关IP地址?

方案一: 写死 ip 列表

  • 实现简单
  • 代价:需无废死性,更新扩展需要发版

使用 httpDNS 服务

  • 优势:
    1. 可以水平扩展长连接网关
    2. 精准调度
    3. 防止劫持
    4. 实时解析
  • 代价:
    1. 不能针对长连接来做精准调度
    2. httpDNS 本身也会带来可用性问题

自建一个 http server 作为 ip config server

  1. 通过一个域名+https协议访问 ipconfig 服务
  2. 从中获得一批ip列表(减少请求&负载均衡&快速重连)
  3. 客户端通过ip列表直接tcp连接长连接网关

httpDNS + ip config

  1. httpDNS 解析获得正确的 http server 的公网 ip 地址
  2. 然后通过此 ip 地址访问 ip config server 获得 ip 列表

长连接网关设计方案

1. 基于Channel空间的实现方案 (goim)

方案描述

  1. 一个线程监听accept
  2. N个accept socket返回后建立连接消息
  3. 服务端验证会话成功后建立会话消息
  4. 每发送一个消息启动一个时间对象对应答

收益

  1. 实现简单,开发快速
  2. 基于GO的协程机制,可以快速上线

代价

  1. 内存占用率高,每个客户端都要有一个4G等大内存
  2. 资源占用多导致负载很高,服务器资源浪费严重
  3. 一个协程占用4k内存,万方长连接时会占用8G内存
  4. 每个下行消息都会启动一个定时器任务,将会push很多定时器OOM

2. 单Reactor + 单Select处理

方案描述

  1. 一个conn对象组建后,分配一个专用客户端conn/Read和send所在channel工
  2. 在一个select循环上监听所有channel,请有消息到来会立即处理
  3. 业务回调时,开启一个协程进行注册返回send channel交给业务处理

收益

  1. 节省了一个协程开销,内存占用减少到三分之一
  2. 协程数量少于10个,runtime调度开销降低
  3. 延迟很低,去掉所有定时器和锁的管理

代价

  1. 同一个机器上响应延迟,需要等待被筛选
  2. 协程回调会做简单同步
  3. 外层没有解决下行消息定时器和锁内存的问题

3. Goroutine Pool方案

方案描述

  1. 一个程序函数监听socket的read动作
  2. 有信号到达后继续标识
  3. 业务层回调时,也需要goroutine pool限定一个用来处理socket send消息

收益

  1. 业务层回调函数化,使用协程池技术,减少了协程调度的开销
  2. 限制了协程的数量上限,最大减少内存分配

代价

  1. 还是有一个协程队列
  2. 需要照顾好conn的引用,引数认为从中获取的协程修改socket
  3. 还是重工

4. React池 + Goroutine Pool

特点

  • 通过epoll系统调用,带收发消息无需事件序列化
  • 收发消息无需特别程度
  • 当业务层回调时间时,真的从goroutine池中取一个goroutine来处理

收益

  • 收发清空完全无阻塞,减少了内存占用

5. 状态连接数表

方案描述

  1. 创建connect时储存注册相关信息,比如connID等
  2. 将多个socket连接对应到相同endpoint节点
  3. 中心化存储sessionID等,uid/did,业务层连接信息
  4. 映射sessionID到connID的MAP,以及connID到connect对应的Map
  5. 业务层需要发送下行消息时,可以通过sessionID找到uid,再通过uid找到connect

收益

  1. 点到点连接可控
  2. 状态可恢复

代价

  1. 与持久化相关的高内存,成为难题
  2. 空间占用相对较高
  3. session集中化存储时,需要进行约定提纯逻辑

后续内容包括了state server、分布式系统等更高级设计,我可以继续整理如果需要。

消息可用性

什么是IM系统的消息时序一致性

实现时序性的技术难点:

  1. 没有全局时钟,各个节点的时间戳不一致:
    在真正的生产模式,分布式环境下,客户端+服务端后台的各种后台服务,都各自分布在不同的机器上,机器之间都是使用的本地时钟,没有一个所谓的“全局时钟”(也没办法做到真正的全局时钟),那么所谓的消息时序也就没有真正意义上的时序基准点。所以消息时序问题显然不是“本地时间”可以完全决定的。

  2. 多方发送问题:
    服务端分布式的情况下,不能用“本地时钟”来保证时序性,那么能否用接收方本地时间表示时序呢?答案是不行的,因为消息的发送方可能有多个(群聊),多个发送方的消息到达接收方的时间是不一样的,所以接收方的本地时间也不能作为时序的基准。

  3. 多方接收问题:
    多发送方不能保证时序,假设只有一个发送方,能否用发送方的本地时间表示时序呢? 很遗憾,由于多个接收方的存在,无法用发送方的本地时间,表示“绝对时序”

  4. 网络传输与多线程问题:
    既然多发送方与多接收方都难以保证绝对时序,那么假设只有单一的发送方与单一的接收方,能否保证消息的绝对时序一致性呢? 结论是悲观的,由于网络传输与多线程的存在,这仍然不行

如何保证绝对的消息时序一致性?

我们可以先从简单的角度开始考虑,例如一对一单聊模式:

假设两人一对一聊天,发送方A一次发出msg1、msg2、msg3三条消息,给接收方B,如何保证这三条消息的时序一致性。

我们知道,发送方A依次发出的msg1、msg2、msg3三条消息,到底服务端后,再由服务端中转发出时,这个顺序由于多线程的网络的问题,是有可能乱序的。但是实际上一对一聊天的两个人,并不需要全局消息时序的一致(因为聊天只在两人的同一会话在发生),只需要对于同一个发送方A,发给B的消息时序一致就行了。

常见优化方案,在A往B发出的消息中,加上发送方A本地的一个绝对时序(比如本机时间戳),来表示接收方B的展现时序。

那么当接收方B收到消息后,即使极端情况下消息可能存在乱序到达,但因为这个乱序的时间差对于普通用户来说体感是很短的,在UI展现层按照消息中自带的绝对时序排个序后再显示,用户其实是没有太多感知的。

多对多群聊的消息一致性保证思路:

假设N个群友在一个IM群里聊天,应该怎样保证所有群员收到消息的显示时序一致性呢?

首先:不能像一对一聊天那样利用发送方的绝对时序来保证消息顺序,因为群聊发送方不单点,时间也不一致。

或许:我们可以利用服务器的单点做序列化。

如上图所示,此时IM群聊的发送流程为:

1)sender1发出msg1,sender2发出msg2;
2)msg1和msg2经过接入集群,服务集群;
3)service层到底层拿一个唯一seq,来确定接收方展示时序;
4)service拿到msg2的seq是20,msg1的seq是30;
5)通过投递服务讲消息给多个群友,群友即使接收到msg1和msg2的时间不同,但可以统一按照seq来展现。

这个方法:

1)优点是:能实现所有群友的消息展示时序相同;
2)缺点是:这个生成全局递增序列号的服务很容易成为系统瓶颈。

还有没有进一步的优化方法呢?

从技术角度看:群消息其实也不用保证全局消息序列有序,而只要保证一个群内的消息有序即可,这样的话,“消息id序列化”就成了一个很好的思路。

上图这个方案中,service层不再需要去一个统一的后端拿全局seq(序列号),而是在service连接池层面做细小的改造,保证一个群的消息落在同一个service上,这个service就可以用本地seq来序列化同一个群的所有消息,保证所有群友看到消息的时序是相同的。

长连接与短链接的区别

  1. HTTP协议与TCP/IP协议的关系

    HTTP的长连接和短连接本质上是TCP长连接和短连接。HTTP属于应用层协议,在传输层使用TCP协议,在网络层使用IP协议。IP协议主要解决网络路由和寻址问题,TCP协议主要解决如何在IP层之上可靠的传递数据包,使在网络上的另一端收到发端发出的所有包,并且顺序与发出顺序一致。TCP有可靠,面向连接的特点。

  2. 如何理解HTTP协议是无状态的:

    HTTP协议是无状态的,指的是协议对于事务处理没有记忆能力,服务器不知道客户端是什么状态。也就是说,打开一个服务器上的网页和你之前打开这个服务器上的网页之间没有任何联系。HTTP是一个无状态的面向连接的协议,无状态不代表HTTP不能保持TCP连接,更不能代表HTTP使用的是UDP协议(无连接)。

  3. 什么是长连接、短链接:

    在HTTP/1.0中,默认使用的是短连接。也就是说,浏览器和服务器每进行一次HTTP操作,就建立一次连接,但任务结束就中断连接。如果客户端浏览器访问的某个HTML或其他类型的 Web页中包含有其他的Web资源,如JavaScript文件、图像文件、CSS文件等;当浏览器每遇到这样一个Web资源,就会建立一个HTTP会话。

    但从HTTP/1.1起,默认使用长连接,用以保持连接特性。使用长连接的HTTP协议,会在响应头有加入这行代码:

    1
    Connection:keep-alive

    在使用长连接的情况下,当一个网页打开完成后,客户端和服务器之间用于传输HTTP数据的 TCP连接不会关闭,如果客户端再次访问这个服务器上的网页,会继续使用这一条已经建立的连接。Keep-Alive不会永久保持连接,它有一个保持时间,可以在不同的服务器软件(如Apache)中设定这个时间。实现长连接要客户端和服务端都支持长连接。

    HTTP协议的长连接和短连接,实质上是TCP协议的长连接和短连接。

    • TCP连接:

      当网络通信时采用TCP协议时,在真正的读写操作之前,server与client之间必须建立一个连接,当读写操作完成后,双方不再需要这个连接 时它们可以释放这个连接,连接的建立是需要三次握手的,而释放则需要4次握手,所以说每个连接的建立都是需要资源消耗和时间消耗的

      经典的三次握手示意图:

      经典的四次握手关闭图:

    • TCP短链接:

      我们模拟一下TCP短连接的情况,client向server发起连接请求,server接到请求,然后双方建立连接。client向server 发送消息,server回应client,然后一次读写就完成了,这时候双方任何一个都可以发起close操作,不过一般都是client先发起 close操作。为什么呢,一般的server不会回复完client后立即关闭连接的,当然不排除有特殊的情况。从上面的描述看,短连接一般只会在 client/server间传递一次读写操作

      短连接的优点是:管理起来比较简单,存在的连接都是有用的连接,不需要额外的控制手段

    • TCP 长连接:

      接下来我们再模拟一下长连接的情况,client向server发起连接,server接受client连接,双方建立连接。Client与server完成一次读写之后,它们之间的连接并不会主动关闭,后续的读写操作会继续使用这个连接。

      首先说一下TCP/IP详解上讲到的TCP保活功能,保活功能主要为服务器应用提供,服务器应用希望知道客户主机是否崩溃,从而可以代表客户使用资源。如果客户已经消失,使得服务器上保留一个半开放的连接,而服务器又在等待来自客户端的数据,则服务器将应远等待客户端的数据,保活功能就是试图在服务 器端检测到这种半开放的连接。

      如果一个给定的连接在两小时内没有任何的动作,则服务器就向客户发一个探测报文段,客户主机必须处于以下4个状态之一:

      客户主机依然正常运行,并从服务器可达。客户的TCP响应正常,而服务器也知道对方是正常的,服务器在两小时后将保活定时器复位。

      客户主机已经崩溃,并且关闭或者正在重新启动。在任何一种情况下,客户的TCP都没有响应。服务端将不能收到对探测的响应,并在75秒后超时。服务器总共发送10个这样的探测 ,每个间隔75秒。如果服务器没有收到一个响应,它就认为客户主机已经关闭并终止连接。

      客户主机崩溃并已经重新启动。服务器将收到一个对其保活探测的响应,这个响应是一个复位,使得服务器终止这个连接。

      客户机正常运行,但是服务器不可达,这种情况与2类似,TCP能发现的就是没有收到探查的响应。

    • 长连接短链接操作过程:

      短连接的操作步骤是:

      建立连接——数据传输——关闭连接…建立连接——数据传输——关闭连接

      长连接的操作步骤是:

      建立连接——数据传输…(保持连接)…数据传输——关闭连接

  4. 长连接和短连接的优点和缺点:

    由上可以看出,长连接可以省去较多的TCP建立和关闭的操作,减少浪费,节约时间。对于频繁请求资源的客户来说,较适用长连接。不过这里存在一个问题,存活功能的探测周期太长,还有就是它只是探测TCP连接的存活,属于比较斯文的做法,遇到恶意的连接时,保活功能就不够使了。在长连接的应用场景下,client端一般不会主动关闭它们之间的连接,Client与server之间的连接如果一直不关闭的话,会存在一个问题,随着客户端连接越来越多,server早晚有扛不住的时候,这时候server端需要采取一些策略,如关闭一些长时间没有读写事件发生的连接,这样可 以避免一些恶意连接导致server端服务受损;如果条件再允许就可以以客户端机器为颗粒度,限制每个客户端的最大长连接数,这样可以完全避免某个蛋疼的客户端连累后端服务。

    短连接对于服务器来说管理较为简单,存在的连接都是有用的连接,不需要额外的控制手段。但如果客户请求频繁,将在TCP的建立和关闭操作上浪费时间和带宽。

    长连接和短连接的产生在于client和server采取的关闭策略,具体的应用场景采用具体的策略,没有十全十美的选择,只有合适的选择。

背景介绍

可靠性:消息一单显示发送成功就必定送达到对端, 一致性: 任意时刻消息保证与发送端顺序一致。

消息可靠一致对于IM来说就是指:可达有序,不重不漏

设计IM必须要有端到端的设计思维,底层对可靠性的保证仅能保证底层的可靠,而不能保证上层的可靠,底层的可靠近视减少了发生故障的概率:底层可靠不等于上层可靠

  • 消息的端到端可靠性 = 上行消息可靠 + 服务端业务可靠 + 下行消息可靠
  • 消息的端到端一致性 = 上行消息一致 + 服务端业务一致 + 下行消息一致

TCP帮助我们做到了哪一步呢?

  1. 客户端A发送msg1和msg2两个消息给服务端
  2. msg1和msg2在一个tcp连接上到达服务端

现在的问题:

  1. 在传递给业务层时服务端进程崩溃,但是客户端A认为以及送达,服务端业务层无感知,因此消息丢失
  2. msg1和msg2在到达应用层,解析后交由两个线程处理,msg2暹罗表并发给客户端B,造成消息乱序
  3. msg1消息存储失败,msg2消息存储成功先发送给了客户端B,造成丢失且乱序

TCP/UDP是双方通信,而IM本质是三方通信。

我们的设计目标:

  • 设计一个能够保证消息端到端可用性的协议
  • 满足 基本协议 中的评估标准

技术难点:

  1. 三方通信,网络层面无法保证消息必达
  2. 没有全局始终,确定唯一顺序,确实符合因果顺序的
  3. 多客户端发送消息/多服务端接收消息/多线程多协程处理消息,顺序难以确定

方案选型

及时性,可达性,幂等性,时序性

  1. 消息及时:服务端实时接收消息并实施在线发送。
  2. 消息可达:超时重试,ACK确认
  3. 消息幂等: 分配seqID,服务端存储seqID
  4. 消息有序:seqID可比较,接收端能按照发送端端顺序对消息排序

上行消息

方案:

Client 严格递增:

  1. 客户端A创建绘画与服务器建立长连接
  2. 在发送消息msg1时分配一个clientID 此值在会话内严格递增
  3. 连接建立时clientID 初始位0
  4. 服务端将上一次收到消息的clientID 缓存位preClientID, 当且仅当clientID = preClientID + 1时,才接受消息
  5. 仅当服务端接收到消息后才回复客户端A,ACK消息
  6. 仅当客户端A收到服务端对消息ACK的回复,才禁止重发(可设置最大三次)

!! 弱网环境下,可以通过优化传输层协议(比如协议升级为Quic)来优化,长连接不适合在弱网环境工作,丢包和断线 属于传输层问题。

收益:

  1. 任意时刻仅存储一个消息ID
  2. 保证严格的有序性
  3. 实现简单,可用
  4. 长连接通信延迟低
  5. 以发送方顺序为标准

代价:

  1. 弱网情况下,消息丢包严重时将造成大规模消息重发,导致网络瘫痪影响消息及时性
  2. 无法保证群聊中的消息因果顺序

ClientID 链表

  1. 客户杜昂A使用本地时间戳作为clientID,并在每次发送消息的时候携带上个消息的clientID
  2. 服务端存储上个消息的clientID 记作preClientID, 只有preClientID 和当前消息的preClientID 对比,匹配上则说明消息未丢失,否则拒绝

代价:

  1. 协议的消息带宽

client list

  1. 服务端正对每一个连接存储多个clientID,形成clientID list
  2. 使用此client List作为滑动窗口,来保证消息幂等

收益:

  1. 减少弱网充传时的消息风暴问题

代价:

  1. 实现更加复杂
  2. 网关需要更多的内存维护连接状态
  3. 由于传输层使用tcp,已经对弱网有一定的优化,应用层也维护滑动窗口收益不大

消息转发

服务端对于接收到的信息分配seqID,异步存储消息,处理业务逻辑,将消息转发给客户端B。

为什么要分配seqID?

IM场景中聊天会话至少有两个客户端参与(单聊/群聊),因此任何一个客户端分配的clientID都无法保证消息的全局有序性,因此服务端需要分配seqID来保证消息的全局有序性。因此clientID仅是保证消息按客户端A发送的顺序到达服务端,服务端需要在整个会话范围内分配一个全局递增的ID。

消息转发的可用性如何保证?

方案:

如果服务端在分配seqID前此请求失败或进程崩溃怎么办? 服务端在分配seqID之后再回复ACK消息

收益:

保证了分配seqID消息的可用性

代价:

  1. ACK回复变慢,收发消息变慢
  2. 如果消息存储失败消息丢失
  3. seqID分配成为性能瓶颈

方案:

如果服务端在存储消息,业务处理,接入层路由时失败怎么办?

  1. 消息存储后再回复ACK,如果ACK失败则客户端重试时再次幂等地回复ACK
  2. 一单消息存储,如果服务端崩溃导致长连接断开,客户端重新建立连接时可以发送一个pull信令,拉去历史消息进行消息补洞,一次保证消息可用性
  3. 如果消息存储后,仅是业务层失败,接入层长连接无感知,业务层需要做一场捕获,并追加pull信令请求给到客户端B,主动出发其拉去历史消息

收益:

  1. 保证了业务处理全流程等可用性
  2. 在出现异常情况时,可毫秒级出发接收端,保证消息及时性

代价:

  1. 上行消息的p95延迟增加
  2. 整体通信复杂度增加
  3. 应对弱网环境需要协议升降级机制

方案:

可以将消息交给MQ异步存储,MQ来保证消息不丢失

seqID无需全局有序,只需要保证在一个会话内有序即可,因此可以使用分布式ID生成器来生成seqID,保证全局唯一性。

收益:

异步写入,优化p95延迟
解决了seqID分配的单点瓶颈

下行消息

方案:

方案 收益 代价
客户端定期轮询发起pull请求参到最新消息 实现简单,保证可用性 1. 客户端耗电高(用户体验差)
2. 消息时延高,不满足及时性
依赖seqID的严格递增

1. 用redis incrby 生成seqID, key是sessionID/connID
2. 按消息到达服务端的顺序分配seqID,使其具有会话范围内的全局序
3. 服务端保证seqID严格递增的前提下将消息发送给客户端B,客户端B也是按preSeqID == seqID+1的方式来做到顺序
4. 服务端需等待客户端的ACK消息,否则超时后需要重传
1. 实现简单,可以快速上线
2. 最大程度的保证严格递增
1. 弱网重传问题
2. Redis 存在单点问题,难以保证严格递增
3. 需要维护超时重传消息队列以及定时器
4. 不能解决客户端B不在线时消息的传递
应对redis的单点故障,seqID的趋势递增

1. 使用lua脚本,存储maxSeqID以及当前的nodeID的runID
2. lua脚本每次获取ID时,都会检查当前node的runID和存储的runID是否一致
3. 发现不一致时,说明发生了主从切换,然后对maxSeqID进行一次跳变保证递增,避免从节点由于同步数据不及时分配了一个曾经分配过的ID出去
4. 客户端B在发现消息不连续时不直接拒绝,而是发送pull信号进行增补
5. 如果获取不到消息,则说明是seqID的跳变导致,不再进一步处理
6. 如果客户端B不在线,查询用户状态后仅存储不推送即可
1. 尽最大可能保证连续性
2. 任意时刻保证单调递增性
3. 由于使用会话级别的seqID,则不需要全局分布式ID生成,redis可以使用cluster模式进行水平扩展
4. 识别了用户是否在线状态,减少了网络带宽资源耗费
1. 协议交互变得更加复杂,实现难度上升
2. 可评估用户规模进行决策是否支持如此级别的可用性
3. 群聊场景,将造成消息风暴
推拉结合+服务端打包整流

1. 客户端B在本地存储最收到的seqID的值记作maxSeqID
2. 服务端发送消息时,携带上一条消息的seqID记作preSeqID和当前seqID
3. 客户端B按收消息时通过对比maxSeqID == preSeqID 则接收否则拒绝
4. 服务端在存储消息存储时,要存储上一条消息的seqID,形成逻辑链表
5. 客户端发现preSeqID不一致,则退化为pull请求去拉缺失的消息
解决消息丢失问题 1. 收益不大,且在消息存储时要多一个preSeqID
SeqID 链表 1. 屏蔽了对seqID 趋势递增的依赖 1. 收益不大,且在消息存储时要多一个preSeqID

Plato v1.0.0 设计文档

1. 系统架构

系统包含以下核心组件:

  • clientA: 消息发送端
  • clientB: 消息接收端
  • Server: 中心服务器
  • Redis: 用于存储seqID
  • DataBus: 消息总线

2. 消息流程

详细流程步骤:

  1. 客户端A创建连接后,分配一个clientID,从0开始即可,发送一个消息时获取clientID并自增
  2. 启动一个消息计时器,等待ack消息的回复,或者超时后触发重传
  3. 基于tcp连接将msg1发送给服务端
  4. 服务端请求redis使用sessionID进行分片,incrBy获得seqID
  5. 异步写入MQ,保证消息可靠存储
  6. 立即回复客户端A ack消息,告诉他消息已经可靠送达
  7. 启动一个下行消息定时器,等等客户端B的ack消息,或者超时后触发重传
  8. 客户端A收到ack消息后,取消定时器
  9. 服务端发起下行消息请求,将msg1发送给客户端B
  10. 客户端B根据当前session的maxSeqID+1是否等于当前消息的seqID来决定是否接收
  11. 客户端B回复服务端消息已经确认或者拒绝
  12. 服务端根据客户端B回复决定是进行消息补洞还是关闭定时器

3. Plato协议规范

3.1 基础消息协议

消息格式由以下部分组成:

  • FixedHeader: 固定头部
  • VarHeader: 可变头部
  • MsgBody: 消息体

3.2 FixedHeader结构定义

1
2
3
4
5
6
7
type FixedHeader struct {
version byte // 版本号
msgType byte // 消息类型
msgLen uint32 // 消息长度
varHeadLen uint32 // 可变头长度
crc32Sum uint32 // CRC32校验和
}

3.3 消息类型定义

3.3.1 Uplink消息

1
2
3
4
5
6
syntax = "proto3";

message UplinkMessageHeader {
uint32 client_id = 1;
uint64 session_id = 2;
}

3.3.2 Downlink消息

1
2
3
4
5
6
syntax = "proto3";

message DownlinkMessageHeader {
uint32 seq_id = 1;
uint64 session_id = 2;
}

TCP粘包拆包问题极其解决方案

TCP协议的特性

TCP协议是面向字节流的协议。TCP中的“流”(stream)指的是流入到进程或从进程流出的字节序列。
面向字节流的含义是:虽然应用程序和TCP的交互是一次一个数据块(大小不等),但是,TCP把应用程序交付下来的数据仅仅看成是一串无结构的字节流,TCP并不知道所传送的字节流的含义。对于应用程序来说,它看到的数据之间没有边界,也无法得知一条报文从哪里开始,到哪里结束,每条报文有多少字节。
而UDP是面向消息的协议,每个UDP段都是一条消息,应用程序必须以消息为单位提取数据,不能一次提取任意字节的数据。因此,UDP通信不会发生粘包问题。

导致粘包的情况

连续发送较短数据

在发送数据时,TCP会根据nagle算法,将数据量小的,且时间间隔较短的数据一次性发给对方。也就是说,如果发送端连续发送了好几个数据包,经过nagle算法的优化,这些小的数据包就可能被封装成一个大的数据包,一次性发送给接收端,而TCP是面向字节流的通信,没有消息保护边界,所以就产生了粘包问题。

接收端没有及时接收数据

还有一种情况会产生粘包,就是接收方没有及时接收数据。可能发送端发来了一段数据,但接收端只接收了部分数据,剩下的小部分数据还遗留在接收缓冲区。那么在下一次接收时,接收缓冲区上不但有上一次遗留的数据,还可能有来自其它报文数据,它们作为一个整体被接收端接收了,这就也造成了粘包。
综上所述,在接收缓冲区上的粘包表现形式主要有以下三种:

  1. packet1和packet2倍合并在一起,一起发送
  2. packet1发生了拆包,packet2与packet1的部分数据被合并起来一起发送
  3. packet2的部分数据没有被及时接收,留在缓冲区与packet1合并起来一起发送

导致拆包的情况

如果发送端缓冲区的长度大于网卡的MTU时,TCP会将这次发送的数据拆成几个数据包发送出去。也就是说,发送端可能只发送了一次数据,接收端却要分好几次才能收到完整的数据

自定义协议

虽然我们无法决定TCP会如何处理发送端发出来的数据,但我们可以借助序列化和反序列化的思想,人为地为数据添加边界。比如,在发送端给待发送的数据加上自定义协议作为报文头,在接收端接收数据时,再把数据还原成我们想要的样子。

举个例子,报文头可以按如下方式构造:

head 协议头 + cmd 控制码 + len 报文数据长度 + crc 校验码 + data 报文数据

  • 协议头(head)是我们在接收缓冲区中识别本程序所需报文的基本依据;
  • 控制码(cmd)用来标识程序中不同报文的作用;
  • 报文数据长度(len)是一个数据报中真实数据的长度,当然也可以是一个数据报的完整长度;
  • 校验码(crc)一般在head、cmd、len的基础上生成,为了进一步确保之前通过协议头判断的数据报是我们要的,(单凭协议头判断目标报文是否存在是不够严谨的,报数据部分也有出现协议头序列的可能)
  • 报文数据(data)就是发送端真正需要发送的数据,也是接收端经过反序列化后,需要得到的数据。

解析报文

接收端调用一次readAll(),会把当前接收缓冲区上的所有数据读取出来,这时候的接收缓冲区上可能有如下几种情况:

不包含协议头

对于这种情况,还需要进行进一步判断:

  • 是目标报文的数据部分。说明数据发送的过程中发生了拆包,需要多次接收数据,直到所接收的数据总长度达到协议中指定的报文数据长度。
  • 不是目标报文的数据部分。可以直接丢弃。

包含一个或多个完整的协议头

对于这种情况,还需要进行进一步判断:

  • 能通过CRC校验。说明当前缓冲区发生了粘包,需要进行循环处理。
  • 不能通过CRC校验。也分两种情况:
  1. 如果读取到的是长度完整的协议,但仍不通过CRC校验,说明当前缓冲区的数据不是目标报文的数据,可以直接丢弃。
    
  2. 如果读取到的是长度不完整的协议(比如协议在拆包时被截断),才导致没有通过CRC校验,就不能判断接下来读取的数据不是目标报文的数据。【对于这种情况,要特殊处理】
    

包含不完整的协议头

对于这种情况,首先肯定是无法通过是否包含协议头的判断的,但如果直接丢弃这段数据,就会造成丢包。所以,要防止这种情况的发生,就要防止在读取数据时,读取过短的数据。
知道缓冲区可能有以上这几种情况,有助于我们对症下药,下面来梳理接收端处理数据的流程:

解析报文的流程:

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
   package main

import (
"bytes"
"encoding/binary"
"fmt"
"io"
"log"
)

// 常量定义
const (
PRIVATE_HEAD uint32 = 0x55AA55AA // 私有头部标识
PROTOCOL_LENGTH uint32 = 16 // 协议长度
)

// BufferData 结构体,用于存储接收到的数据
type BufferData struct {
recevData []byte
hasHead bool
totalLen uint32
}

// CProtocalData 结构体,用于存储解析后的协议数据
type CProtocalData struct {
Header uint32
Cmd uint32
Len uint32
Crc uint32
Data []byte
}

// CDataRecver 结构体,用于接收和处理数据
type CDataRecver struct {
buff BufferData
recvDataVector []CProtocalData
dataArrivedChan chan []byte
}

// NewCDataRecver 创建一个新的 CDataRecver 实例
func NewCDataRecver() *CDataRecver {
return &CDataRecver{
dataArrivedChan: make(chan []byte, 100), // 使用缓冲通道
}
}

// SlotDataArrived 处理到达的数据
func (c *CDataRecver) SlotDataArrived(array []byte) {
c.buff.recevData = append(c.buff.recevData, array...)
c.checkBufferHasHead(&c.buff)

size := uint32(len(c.buff.recevData))
if size >= c.buff.totalLen {
c.parseBufferData(&c.buff, &c.recvDataVector)
}

log.Printf("current recv: %d, total size: %d", len(array), len(c.buff.recevData))
}

// checkBufferHasHead 检查缓冲区是否包含报文头
func (c *CDataRecver) checkBufferHasHead(bufferData *BufferData) {
if bufferData.hasHead {
return
}

index := bytes.Index(bufferData.recevData, uint32ToBytes(PRIVATE_HEAD))
if index == -1 {
bufferData.recevData = bufferData.recevData[:0]
return
}

if index > 0 {
bufferData.recevData = bufferData.recevData[index:]
}

if len(bufferData.recevData) < int(PROTOCOL_LENGTH) {
return
}

header, cmd, length, crc, _ := parseProtocolData(bufferData.recevData)

if !checkCRC(header, cmd, length, crc) {
log.Println("wrong crc")
bufferData.recevData = bufferData.recevData[:0]
bufferData.totalLen = 0
bufferData.hasHead = false
return
}

bufferData.hasHead = true
bufferData.totalLen = length
}

// checkCRC 进行CRC校验
func checkCRC(header, cmd, length, crc uint32) bool {
rightCRC := header + cmd + length - PROTOCOL_LENGTH
return rightCRC == crc
}

// parseBufferData 解析缓冲区的数据
func (c *CDataRecver) parseBufferData(bufferData *BufferData, vector *[]CProtocalData) {
for {
index := bytes.Index(bufferData.recevData, uint32ToBytes(PRIVATE_HEAD))
if index == -1 || len(bufferData.recevData) == 0 {
return
}

if index > 0 {
bufferData.recevData = bufferData.recevData[index:]
}

if len(bufferData.recevData) < int(PROTOCOL_LENGTH) {
break
}

header, cmd, length, crc, data := parseProtocolData(bufferData.recevData)

if !checkCRC(header, cmd, length, crc) {
log.Println("wrong crc")
bufferData.recevData = bufferData.recevData[:0]
bufferData.hasHead = false
bufferData.totalLen = 0
break
}

dataSize := uint32(len(data)) + PROTOCOL_LENGTH
if length > dataSize {
break
}

*vector = append(*vector, CProtocalData{
Header: header,
Cmd: cmd,
Len: length,
Crc: crc,
Data: data,
})

bufferData.recevData = bufferData.recevData[dataSize:]
bufferData.hasHead = false

if len(bufferData.recevData) < 4 {
break
}
}
}

// parseProtocolData 解析协议数据
func parseProtocolData(data []byte) (header, cmd, length, crc uint32, protocolData []byte) {
reader := bytes.NewReader(data)
binary.Read(reader, binary.BigEndian, &header)
binary.Read(reader, binary.BigEndian, &cmd)
binary.Read(reader, binary.BigEndian, &length)
binary.Read(reader, binary.BigEndian, &crc)
protocolData = make([]byte, length-PROTOCOL_LENGTH)
io.ReadFull(reader, protocolData)
return
}

// uint32ToBytes 将uint32转换为字节切片
func uint32ToBytes(n uint32) []byte {
b := make([]byte, 4)
binary.BigEndian.PutUint32(b, n)
return b
}

func main() {
receiver := NewCDataRecver()

// 模拟数据到达
go func() {
testData := []byte{0x55, 0xAA, 0x55, 0xAA, 0, 0, 0, 1, 0, 0, 0, 20, 0, 0, 0, 60, 1, 2, 3, 4}
receiver.dataArrivedChan <- testData
}()

// 处理到达的数据
for data := range receiver.dataArrivedChan {
receiver.SlotDataArrived(data)
}

fmt.Println("Received data:", receiver.recvDataVector)
}

协议设计目标

消息可达有序,不充不漏 ‼️:
  • 消息及时可达
  • 消息不能遗漏和重复
  • 保重消息时序性:接收端收到的消息必须保证按照发送时间排序的

协议选择的标准

  1. 性能:协议的传输效率,尽可能低的端到端延迟
  2. 兼容:允许向前和向后兼容,以便在升级时不会影响现有的用户
  3. 存储:减少消息包的大小,降低空间占用率
  4. 计算:减少编码时造成的CPU使用率的权衡
  5. 网络:尽可能减少网络带宽的消耗
  6. 安全:消息安全性,防止协议被破解
  7. 迭代:协议的可扩展性,以便在未来支持IM复杂业务的演进
  8. 通用:跨平台性,支持多种开发语言和操作系统
  9. 透明:协议的可读性,方便调试和维护

协议的基本架构

纵向分为三层:应用层/安全层/传输层

应用层

文本协议:可读性强,方便调试和维护,性能差(xml,Json)

二进制协议:性能好,空间占用小,可读性差(Protobuf,Thrift)

1
2
3
4
5
6
type MsgHeader struct {
Version uint32 // 版本号
Cmd uint32 // 命令号
Length uint32 // 消息长度
Data []byte // 数据
}
1
2
3
4
5
6
7
message LoginReq {
optional string username = 1;
optional string password = 2;
}
message LoginResp {
optional uint64 uid = 1;
}

安全层

基于密钥的生命周期可以分为:

  1. TLS/SSL:加密效果好,但是证书管理相对复杂
  2. 固定加密:通信前客户端和服务端约定好密钥,通信时使用密钥加密,但是密钥的安全性难以保证
  3. 一人一密:在通信前客户端回先向服务端请求密钥,服务端会用用户特有属性生成密钥进行加密,然后进行加密通信
  4. 一次一密:创建链接简历一次会话时,双方进行加密三次握手,使用非堆成加密握手,对称加密传输,类似TSL握手过程

加密会消耗CPU计算资源,安全性也要考虑在服务端存储的安全性和合规性要求,要做出取舍
网关对数据包进行TLS3.0协议的密钥协商握手,加密解密操作,这会消耗大量CPU资源,所以对于加密解密操作可以使用GPU加速,提高效率

传输层

  1. TCP:面向连接的可靠传输写,仅能保证数据到达传输层,维护状态消耗资源,网络不稳定时频繁重连性能差。
  2. UDP:无状态的传输协议,弱网环境下,消息丢失率高,但是性能好,适合实时性要求高的场景

TCP 保证数据可靠传输到服务器,减少复杂度,使用epoll技术以及应用层设计,可以克服有状态链接到弊端

开源协议

IMPP 协议

特性:

  1. RFC2798 | RFC2799
  2. 这只是一个协议标准,并没有具体的实现
  3. 适用于IM系统的基本功能

取舍:

  1. 抽象模型,可读性差

XMPP 协议

特性:

  1. 一种基于XML应用层协议
  2. XML可以跨平台,跨IM服务传输
  3. 适用于一些邮箱的应用例如Spark

取舍:

  1. 文本协议性能差,信息冗余压缩率低
  2. 解析dom极其耗时,性能极差
  3. 很难保证消息可靠性

SIMPLE协议

特性:

  1. SIP协议RFC,应用于流媒体,音视频场景,这是对其的扩展
  2. 针对于IM聊天场景的扩展,类似于HTTP

取舍:

  1. 文本协议,压缩率低,占用网络带宽
  2. 没有找到直接相关的SIMPLE,SIP/SDP都需要巨大成本进行改造
  3. 不满足性能和可迭代性
  4. 难以保证消息的可靠性

MQTT 协议

特性:

  1. 一种基于发布/订阅模式的消息协议,适合推送场景
  2. 适用于物联网场景,轻量级,适合弱网环境
  3. 支持消息可靠性
  4. 代码量少,适合嵌入式设备
  5. 详细介绍

取舍:

  1. 需要增加可变头并加一些改造,才能支持时序性
  2. 基于IM需要定制化开发的场景很多,扩展性差

websocket 协议

特性:

  1. 客户端和服务端之间的仅需一次握手就可以创建链接
  2. 使用简单,支持全双工通信
  3. 复用HTTP通信,在HTTP基础上进行协议升级
  4. 基于TCP,支持二进制和文本数据传输
  5. 详细介绍

取舍:

  1. 需要业务层自己实现消息的时序性
  2. 需要业务处理断线重连等场景,扩展性差
  3. 简历长链接时,需要通过HTTP协议升级,简历和重连都很慢
  4. 数据帧格式定制化能力差,信息冗余
  5. 原生客户端难以扩展,需要二次开发
  6. websocket的协议还是字符流协议,信息压缩率差,浪费宽带差

my IM 协议设计

对于传输层,我们选择TCP协议,安全层选择TLS协议,应用层使用自研二进制协议+开元序列化协议

  1. TCP协议保障了消息可靠的传输到网关服务上,相对于udp来说会少很多消息丢失的情况,简化开发成本,同时我们可以在业务层实现断线重连等弱网优化手段,来应对网络不稳定的情况tcp频繁断链等情况
  2. TLS3.0协议,优化了握手的速度提升了性能,同时可以较好的兼顾性能和安全性是一个高性价选择,但是如果在gateway server实现,由于TLS的握手/加密都是CPU密集型操作,极端情况下会拉高gateway server的CPU使用率使其造成性能抖动,为此我们选择在L7层负载均衡器上实现TLS终止,使用L7层负载均衡器会增加一跳的数据包的转发这会造成性能损耗,不过可以使用TLS加速卡等硬件加速技术解决,对于IM场景,如果仅考虑性能等话,可以在L4负载均衡器上实现TLS终止,减少对L7负载均衡器等依赖,因为gateway server本身也工作在L7层
  3. 对于应用层一个简单灵活的二进制协议实现可以分为固定消息头,变长消息头,消息体三部分

Encoder pesudo code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type Message struct {
fixedHeader *FixedHeader
varHeader PBData
msgBody PBData
}
func (msg *Message) Encode() []byte {
buf := new([]byte,14)
buf[0] = msg.fixedHeader.Version
buf[1] = msg.fixedHeader.msgType
copy(buf[2:6], uint32ToBytes(msg.fixedHeader.MsgLen))
copy(buf[6:10], uint32ToBytes(msg.crc32Sum))
buf = append(buf, msg.varHeader.Bytes())
buf = append(buf, msg.msgBody.Bytes())
return buf
}
func send(data []byte) {
conn.Send(data)
....
}

Decoder pesudo code:
TCP是基于字节流的传输协议,所以没有物理上的消息边界,这就会导致数据包传输过程中存在下面的情况:
假设发送方发送了两个数据包p1和p2:

  1. p1的部分数据发送到接收端
  2. p1的后半部分数据和p2的前半段数据发送到接收端
  3. p2的后半段数据发送到了接收端
  4. p1和p2合并大一起发送到接收端
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func Accept() {
buf := make([]byte, 14)
for conn.Read(buf) {
msg := &Message{}
msg.fixedHeader.Version = buf[0]
msg.fixedHeader.msgType = buf[1]
msg.fixedHeader.MsgLen = bytesToUint32(buf[2:6])
msg.crc32Sum = bytesToUint32(buf[6:10])
varHeaBuf := make([]byte, msg.fixedHeader.MsgLen)
conn.Read(varHeaBuf)
msg.varHeader = pb.Data(varHeaBuf)
bodyBuf := make([]byte, msg.fixedHeader.MsgLen)
conn.Read(bodyBuf)
msg.msgBody = pb.Data(bodyBuf)
header.Pool(msg)
}
}

网络编程实践资源

本总结涵盖了网络编程实践的各种资源,包括TCP和UDP通信示例、NIO框架入门教程,以及MINA和Netty的源码和API文档链接。

TCP通信示例

  1. 网络编程懒人入门(八):手把手教你写基于TCP的Socket长连接

    • 详细指导如何实现基于TCP的Socket长连接
  2. Android端与服务端基于TCP协议的Socket通讯

    • 介绍Android平台上如何实现基于TCP的Socket通信
  3. iOS平台的CocoaAsyncSocket托管代码

    • 包含许多TCP的官方Demo代码,值得参考

UDP和NIO框架入门

  1. NIO框架入门(一):服务端基于Netty4的UDP双向通信Demo演示
  2. NIO框架入门(二):服务端基于MINA2的UDP双向通信Demo演示
  3. NIO框架入门(三):iOS与MINA2、Netty4的跨平台UDP双向通信实战
  4. NIO框架入门(四):Android与MINA2、Netty4的跨平台UDP双向通信实战

MINA和Netty源码阅读

  1. MINA-2.x 源码:在线阅读
  2. MINA-1.x 源码:在线阅读
  3. Netty-4.1.x 源码:在线阅读推荐
  4. Netty-4.0.x 源码:在线阅读
  5. Netty-3.x 源码:在线阅读

MINA和Netty API文档

  1. MINA-2.x API文档:在线版
  2. MINA-1.x API文档:在线版
  3. Netty-4.1.x API文档:在线版推荐
  4. Netty-4.0.x API文档:在线版
  5. Netty-3.x API文档:在线版

实践要点

  1. TCP通信:

    • 掌握Socket长连接的实现
    • 了解不同平台(Android、iOS)的TCP通信实现方式
  2. UDP和NIO框架:

    • 学习Netty和MINA框架的基本使用
    • 理解跨平台UDP通信的实现方法
  3. 源码学习:

    • 深入理解MINA和Netty框架的内部实现
    • 关注Netty 4.1.x版本,这是目前最推荐的版本
  4. API使用:

    • 熟悉MINA和Netty的API,特别是Netty 4.1.x的API
    • 通过API文档学习如何正确使用这些框架

通过学习和实践这些资源,开发者可以全面提升网络编程技能,从基础的Socket编程到高性能的NIO框架应用,为开发高效、可靠的网络应用奠定坚实基础。

互联网编程和高性能网络编程知识总结

本总结涵盖了P2P技术、高性能网络编程以及移动端网络优化等方面的重要知识。

P2P技术详解

  1. P2P技术详解(一):NAT详解——详细原理、P2P简介

    • 深入解析NAT技术的原理及其在P2P中的应用
  2. P2P技术详解(二):P2P中的NAT穿越(打洞)方案详解(基本原理篇)

    • 介绍P2P网络中NAT穿越的基本原理
  3. P2P技术详解(三):P2P中的NAT穿越(打洞)方案详解(进阶分析篇)

    • 深入分析P2P网络中NAT穿越的高级技术
  4. P2P技术详解(四):P2P技术之STUN、TURN、ICE详解

    • 详细讲解P2P通信中常用的STUN、TURN和ICE协议

高性能网络编程

  1. 高性能网络编程(一):单台服务器并发TCP连接数到底可以有多少

    • 探讨单台服务器能够支持的最大并发TCP连接数
  2. 高性能网络编程(二):上一个10年,著名的C10K并发连接问题

    • 回顾C10K问题及其解决方案
  3. 高性能网络编程(三):下一个10年,是时候考虑C10M并发问题了

    • 展望未来的C10M并发挑战及可能的解决方向

移动端网络优化

  1. 移动端IM开发者必读(一):通俗易懂,理解移动网络的”弱”和”慢”

    • 深入浅出地解释移动网络的特性及其带来的挑战
  2. 移动端IM开发者必读(二):史上最全移动弱网络优化方法总结

    • 全面总结移动弱网络环境下的优化策略
  3. 现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障

    • 详细介绍移动端短连接优化的各种技术手段

知识要点

  1. P2P技术:

    • NAT原理及其对P2P通信的影响
    • NAT穿越(打洞)技术的基本原理和进阶策略
    • STUN、TURN、ICE等协议在P2P通信中的应用
  2. 高性能网络编程:

    • 服务器并发连接数的限制因素
    • C10K问题的由来、影响及解决方案
    • 面向未来的C10M并发挑战
  3. 移动端网络优化:

    • 移动网络的特性(”弱”和”慢”)及其影响
    • 移动弱网络环境下的优化策略
    • 短连接优化技术,包括速度提升、弱网适应和安全保障

这些知识对于开发高性能、高可靠性的网络应用至关重要,特别是在复杂的网络环境和移动场景下。通过深入理解这些概念和技术,开发者可以设计出更加高效、稳定的网络应用和服务。

UDP和NAT学习资源总结

相比TCP协议,UDP数据传输协议就显得非常轻量和易于理解。UDP通常被用于需要快速响应的数据传输场景下,对应于IM中的应用形态有:P2P通信、实时音视频等。另外,通常的IM都会被应用于互联网上(而非局域网),那么了解所谓的NAT路由技术原理等,也将有助于您对P2P打洞、UDP端口老化等概念有一个清楚的认知。

UDP基础和应用

  1. UDP中一个包的大小最大能多大?

    • 探讨UDP数据包的最大大小限制及其影响因素
  2. 不为人知的网络编程(五):UDP的连接性和负载均衡

    • 讨论UDP协议的连接特性及其在负载均衡中的应用
  3. 不为人知的网络编程(六):深入地理解UDP协议并用好它

    • 深入解析UDP协议的特性及其最佳实践

NAT技术和P2P通信

  1. NAT详解:基本原理、穿越技术(P2P打洞)、端口老化等
    • 全面介绍NAT技术,包括其工作原理、P2P穿透技术和端口老化机制

UDP在即时通讯中的应用

UDP协议因其轻量级和快速响应的特性,在即时通讯(IM)系统中有广泛应用:

  1. P2P通信:UDP适用于点对点直接通信,特别是在NAT穿透后的场景。

  2. 实时音视频:UDP的低延迟特性使其成为实时音视频传输的首选协议。

  3. 快速消息传递:对于不需要保证送达的即时消息,UDP可以提供更快的传输速度。

NAT与P2P在IM中的重要性

在互联网环境下的IM系统中,理解NAT(网络地址转换)技术至关重要:

  1. P2P打洞:了解NAT原理有助于实现有效的P2P通信,突破NAT的限制。

  2. 端口老化:理解UDP端口老化机制,可以更好地维护长连接和处理网络异常。

  3. 网络穿透:NAT穿透技术是实现跨网络、跨运营商通信的关键。

通过学习这些资源,你可以深入理解UDP协议的特性和应用场景,以及NAT技术在现代网络通信中的重要作用。这些知识对于开发高效、可靠的即时通讯系统极为重要,特别是在需要处理复杂网络环境的情况下。

TCP学习资源总结

本总结包含了一系列关于TCP协议的学习资源,涵盖了从基础到进阶的多个方面。

基础知识

  1. 脑残式网络编程入门(一):跟着动画来学TCP三次握手和四次挥手

    • 通过动画形式直观理解TCP的三次握手和四次挥手过程
  2. 脑残式网络编程入门(二):我们在读写Socket时,究竟在读写什么?

    • 深入理解Socket编程中的读写操作
  3. 理论经典:TCP协议的3次握手与4次挥手过程详解

    • 详细解析TCP连接建立和断开的过程
  4. 理论联系实际:Wireshark抓包分析TCP 3次握手、4次挥手过程

    • 使用Wireshark工具实际观察TCP连接的建立和断开

深入理解

  1. 通俗易懂-深入理解TCP协议(上):理论基础

    • TCP协议的基础理论知识
  2. 通俗易懂-深入理解TCP协议(下):RTT、滑动窗口、拥塞处理

    • TCP的高级特性,包括往返时间、滑动窗口和拥塞控制
  3. 网络编程懒人入门(八):手把手教你写基于TCP的Socket长连接

    • 实践指导:如何实现基于TCP的长连接

进阶主题

  1. 不为人知的网络编程(一):浅析TCP协议中的疑难杂症(上篇)

  2. 不为人知的网络编程(二):浅析TCP协议中的疑难杂症(下篇)

    • 探讨TCP协议中的一些复杂问题
  3. 不为人知的网络编程(三):关闭TCP连接时为什么会TIME_WAIT、CLOSE_WAIT

    • 解释TCP连接关闭时的状态问题
  4. 不为人知的网络编程(四):深入研究分析TCP的异常关闭

    • 分析TCP连接异常关闭的情况
  5. 不为人知的网络编程(十二):彻底搞懂TCP协议层的KeepAlive保活机制

    • 详解TCP的KeepAlive机制
  6. 不为人知的网络编程(十三):深入操作系统,彻底搞懂127.0.0.1本机网络通信

    • 探讨本机网络通信的原理

通过学习这些资源,你可以从基础到进阶全面了解TCP协议,包括其工作原理、实现细节、常见问题及解决方案。这些知识对于网络编程和系统优化都非常重要。

在网络编程的基础掌握后,我们往往需要为即时通讯(IM)进行传输协议的选型。而IM该选择UDP还是TCP作为传输协议,这一直是个颇有争议的话题。以下是关于这个问题的分析与参考资料。

IM传输协议的选择:UDP还是TCP?

UDP和TCP各有其应用场景。在IM的早期阶段,由于服务器硬件、网络带宽等资源较为昂贵,UDP常被选用,例如早期的QQ。时至今日,TCP的服务端负载解决方案已比较成熟,加上服务器资源成本的降低,目前很多IM和消息推送解决方案都使用TCP。

然而,在一些特殊场景下,UDP依然占据优势,特别是在弱网络环境、跨国高延迟网络或物联网通信中。此外,IM中的实时音视频通信通常也选择UDP。

因此,关于IM该选择UDP还是TCP,主要取决于具体的应用场景和性能需求。为了更好地理解两者的区别和应用场景,以下文章会对你有所帮助:

  1. 《简述传输层协议TCP和UDP的区别》
  2. 《为什么QQ用的是UDP协议而不是TCP协议?》
  3. 《移动端即时通讯协议选择:UDP还是TCP?》
  4. 《网络编程懒人入门(五):快速理解为什么说UDP有时比TCP更有优势》
  5. 《微信对网络影响的技术试验及分析(论文全文)》
  6. 《为何基于TCP协议的移动端IM仍然需要心跳保活机制?》

IM的数据通信格式选型

在IM的开发中,选择合适的数据通信格式同样是一个关键问题。常见的通信格式包括XMPP、Protobuf、JSON、私有二进制协议、MQTT、定格化XML、纯文本等。

不同的通信格式适用于不同的应用场景。例如,在移动端IM的开发中,Protobuf因其高效的序列化和反序列化性能被广泛推荐。然而,XMPP在移动端环境中的表现较差,因此多数开发者在移动端IM开发中不建议使用它。

以下文章会对你的IM的数据通信格式选型有所帮助:

  1. 《如何选择即时通讯应用的数据传输格式》
  2. 《Protobuf通信协议详解:代码演示、详细原理介绍等》
  3. 《强列建议将Protobuf作为你的即时通讯应用数据传输格式》
  4. 《全方位评测:Protobuf性能到底有没有比JSON快5倍?》
  5. 《移动端IM开发需要面对的技术问题(含通信协议选择)》
  6. 《简述移动端IM开发的那些坑:架构设计、通信协议和客户端》
  7. 《理论联系实际:一套典型的IM通信协议设计详解》
  8. 《58到家实时消息系统的协议设计等技术实践分享》
  9. 《金蝶随手记团队分享:还在用JSON? Protobuf让数据传输更省更快(原理篇)》
  10. [《扫盲贴:认识MQTT通信协议》](http://​⬤