用 Rust 从零开始写 QUIC:实现连接迁移
连接迁移
什么是连接迁移
QUIC 虽然是基于 UDP 之上的传输协议,但是 QUIC 连接在设计上并没有和 UDP 以及更底层的协议层严格绑死。这意味着,如果 QUIC 连接正在通信的客户端和服务端双方状态可以正常运行,那么基于 QUIC 协议层之下的各种网络层可以进行切换,只要切换后的网络路径依然可以保证当前 QUIC 连接双方可以正常通信即可。用一句话来概括,连接迁移的本质是什么:QUIC 将连接与网络路径进行了解耦,即 UDP 四元组的变化不会影响 QUIC 连接正常运行。
QUIC 面向连接的实现
关于连接标识符的选择
其实这个问题是在之前握手的博客里面就应该好好讨论一下的,但是显然我当时沉迷于 QUIC 和 TLS 的融合,有点忽略了这个话题。所有传输层协议都要解决一个共性问题,如何确定一条连接,即连接标识符 connection identifiers
是什么。TCP 的连接标识符是很经典的由通信双方的 IP 地址和 Port 构成的四元组。
而基本市面上所有的基于 UDP 的传输协议,都没有使用 UDP 四元组来作为自身的连接标识符。可能大家都意识到 NAT 设备对于 UDP 的支持不够友好,映射表中的 UDP 映射关系很容易被淘汰掉,导致 UDP 四元组发生变化,如果单纯只依赖 UDP 四元组的传输协议,无疑会因此而断开连接。另外,我觉得 NAT 设备倒不是刻意针对 UDP,只不过 UDP 本身设计上就是无连接的,不像 TCP 的流量,NAT 设备可以很容易的从 TCP 握手和挥手来维护 TCP 映射关系,当 NAT 设备资源紧张的时候,我觉得优先淘汰 UDP 映射关系,无疑是个合理的选择。总不能让 NAT 设备去感知五花八门基于 UDP 的自研协议吧。
更进一步的说,基于 UDP 的传输协议,放弃了使用 UDP 四元组作为连接标识符,无疑是用一点额外的带宽,换取了更广阔的空间。不仅协议上连接不会再与底层网络路径绑死,另外也可以提升安全性,防止流量被人通过 UDP 四元组这种外在信息,来轻易的判断是不是相同的连接。并且 QUIC 更进一步的支持了连接迁移,并且基于连接标识符提供了更为完善的设计。下面,我们来具体看一看 QUIC 是怎么做的。
QUIC Connection ID 设计
QUIC 的连接标识符选择了 QUIC Connection ID,这里 Connection ID 被分成了两部分,QUIC 连接双方都会生成自己的 Connection ID,双方 Connection ID 组合在一起才能准确标识一个独一无二的 QUIC 连接,毕竟 QUIC 连接状态是连接双方共同维护的。QUIC 的报文头一般都会承载 Destination Connection ID
,而 Long Header QUIC 报文还会携带 Source Connection ID
。我觉得这里,可能第一个需要理顺的点在于,QUIC 连接通信双方各自发送报文的 Connection ID 是相反的,换句话说,就是 QUIC 报文总是携带的 Destination Connection ID
是相对于发送方而言,从接收方的角度看,其实是 Source Connection ID
。但是考虑到平时理解网络编程里面的 TCP 套接字的 local ip address 和 remote ip address,也是基本一样的道理,倒也没什么好说的。
接下来,按道理应该说一说,QUIC 握手时候是如何协商双方各自生成的 Connection ID,但是这个和普通的协议协商非常类似,RFC 给了非常详细的说明,唯一值得自我吐槽的一点是,我之前在实现 QUIC 握手的时候,忘记了对里面 Connection ID 相关的 Transport Parameter
进行校验,毕竟 RFC 里面是明确规定了这个环节。虽然我觉得,不校验其实问题也不大,毕竟 TLS 握手已经预防了中间人攻击,另外只要双方实现够严谨,不同的 Connection ID 会对应新的连接,即新的 TLS 握手协商,那安全性其实是可以得到保证的。但是,我们还是要遵循 RFC 里面的规定,做好相关校验,防止通信对端实现细节不严谨导致的安全漏洞。
还有一个值得关注的点,和很多传输层协议不同,在 QUIC 连接生命周期中,Connection ID 并不是不是唯一的,更准确的说 QUIC 的连接标识符不是唯一,我觉得这是一个很好的设计。这样的话,传输协议可以避免被人嗅探,毕竟 Connection ID 是没办法加密保护的,如果 QUIC 连接一直使用同一个 Connection ID,是很容易让人判断出流量是否属于该连接。
接着就是看 QUIC 是如何维护多个 Connection ID。这里面,QUIC 在维护 Connection ID 的时候,设计了显式的序列号,比如说握手协商出的 Connection ID 序列号默认是 0,如果对端发送了 Preferred Address
传输参数,那么这里面携带的 Connection ID 序列号是 1,接着每次对端发送的 New Connection ID frame
中也会声明新的 Connection ID 和对应的序列号,同时也在这个 New Connection ID frame
中,对端也可以主动通过序列号淘汰之前声明过的 Connection ID。另外,本端也可以通过 Retrie Connection ID frame
来淘汰对端发送的 Connection ID,即双方都具备淘汰某一端颁发的 Connection ID 的能力。本端如果想切换对端声明的 Connection ID,只需要在发送选择新的 Connection ID 即可。
其实 Connection ID 的妙用还不止于此,大家肯定会想过把一些额外的信息编码到 Connection ID 里面,这样我们就可以做很多有趣的事情。但是,QUIC RFC 明确规定了 Connection ID 不能持有可以被外部嗅探到的信息。我记得,我好久之前在看 IETF 线上会议录像的时候,发现有一个提案就是建议在 QUIC Connection ID 里面引入额外的信息,方便底层网络设备可以针对性的做路径选择等调优工作,从而提升 QUIC 连接中高优流量的传输质量。
不过,由于这个草案某种程度上打破了上面提到对 Connection ID 的要求,所以我看录像里面要求提问的人排成了长队,主要是质疑了安全性和可行性。毕竟,如果根据 Connection ID 某种编码规则来进行传输质量调优,而规则想要进行标准化的,那所有人是不是都想去申明自己的流量是最高优,所有流量都是高优意味着没有调优,更别说冒着暴露流量优先级这种隐私的安全风险。不过感觉这个方案还是适合在自建数据中心或者内部传输网络中来实现,这样每个人都会是安分守己的好公民,不越雷池一步,达到设计中预想的传输效果。
最后,QUIC RFC 还规定了 Connection ID 可以长度是零,即可以不存在,这样的话,QUIC 连接标识符就只能依赖 UDP 四元组了,如上文所说的那样,会导致 QUIC 连接变得异常脆弱,而且 UDP socket 使用也有特殊的要求。但是既然提及了,那总可能会有人想试试?不晓得出于什么样的目的,可能是纯粹以学习为目的吧。
Matching Packets to Connections
这里,我直接使用了 QUIC RFC 5.2 小节的标题,主要是我没想到太好的翻译,另外我想更深入的探讨一下这个问题,虽然 RFC 只是在讨论怎么根据 Connection ID 将 QUIC 数据包匹配到对应的连接上,以及怎么处理各种异常情况的数据包。但在实际的服务端工程场景下,这个问题会变得复杂并且有趣。这里,将 QUIC 数据包匹配到对应的连接会话上,主要会存在两个难点:
- 在集群场景下,如何正确的将 QUIC 数据包转发到连接所在的机器上
- 在多进程服务架构下,如何正确的将 QUIC 数据包转发到连接所在的进程上
第一个难点,更具体的场景细节是,正常集群场景下四层负载均衡器是根据 TCP 或者 UDP 四元组来将流量转发到对应的机器上。而 QUIC 并没有使用 UDP 四元组作为 QUIC 连接的唯一标识,当发生连接迁移时,由于 UDP 四元组的变化,普通的负载均衡器很容易将同一个 QUIC 连接的流量转发到不同的后端机器,这也意味着 QUIC 连接会直接不可用。
QUIC-LB 针对这一难题,完整的给出了解决方案,本质上就是在 Connection ID 引入额外的信息,帮助集群入口的负载均衡器识别 QUIC 数据包,转发到正确的机器上。这里可能会有人问,上面不是刚讨论过,不要把关键信息放到 Connection ID 上面去,这样会导致安全隐患。确实是这样,但是如果路由信息被足够安全的方式保护住,不会被集群外的嗅探者感知到这里面潜在的规则,那就不会违背 Connection ID 对安全的要求了。
这里特别值得注意的一个点是,Connection ID 和后端机器的映射关系,肯定不是通过维护一个全局映射表的方式来维护的。毕竟这样的开销太大了,还存在资源攻击的安全隐患。通过潜在的路由映射关系来维护是一个更好的选择,即 Connection ID 携带的路由信息,在负载均衡器上可以通过特定的规则轻松计算出对应的具体后端机器。最后,再给 Connection ID 携带的路由信息做好加密保护,即可满足 QUIC RFC 对 Connection ID 安全性的要求。
至于第二个难点,即如何确保多个进程都持有 Bind 了相同地址的 UDP socket 的情况下,将承载 QUIC 流量的 UDP 数据报转发到正确的进程上,特别是在某些特殊的场景下,比如说热升级发生之后。关于这个问题的讨论,我想放在下一篇博客中,方便更详细聊聊当前业界有哪些主流的解决方案。值得一提的是,在这次实现连接迁移功能的时候,我特意取消了 UDP Socket connect 系统调用,防止持有的 UDP socket 中 remote address 被设置死,导致内核无法将连接迁移后的 UDP 数据报正确的分配到 feather-quic 持有的 UDP Socket 上面。
如何实现连接迁移
触发连接迁移的时机
关于连接迁移什么时候会触发的,QUIC RFC 有一句话讲的非常清晰,就是连接迁移要么是 QUIC 连接某一方主动的进行了底层网络链路的切换,要么是原有的转发网络路径自身发生了变化。所以,我们接下来就是看更具体的切换时机分析。
对于第一个场景来说,QUIC 连接通信的某一方主动发生切换,这个倒是非常好理解。举个最经典的例子,用户从室内走到室外或者到电梯里面,手机原本连接上的 WIFI 无法维持,所以网络发生了切换,从原来的 WIFI 信号切换到了运营商 5G 或者 4G 信号。如果是实现比较成熟的客户端,一般都会监控网卡切换事件,来主动去做网络传输层的重新建联,而不是傻乎乎的等待网络连接的超时。而由于 QUIC 有连接迁移功能,所以客户端不需要进行耗时耗力的传输层协议重建工作,而只需要继续向新的地址发送 QUIC 流量即可。我们上面一直提到的 Connection ID 会确保更改了底层网络路径的流量(更明确一点,变更了 UDP 四元组的流量),能够顺利抵达 QUIC 通信另一方的实例中被继续正确的处理。
看起来第一个场景主要是发生在客户端身上,毕竟服务端的网络环境足够稳定,很难发生这样的问题,更关键的一个点是,绝大部分场景下,客户端基本上没有对外服务的公网 IP,服务端如果网络环境发生变化,想再重新把流量打给客户端,基本是不可能的,因为客户端隐藏在 NAT 设备之后,直接访问客户端的原地址意义不是很大。当然如果原本走的就是 NAT 穿透 P2P 方案的话,可能还有一线生机,即尽可能快的借助旁路信令通道再次完成 NAT 穿透,但这也是一条艰辛的路。
不过,QUIC 还为服务端提供了一个额外的 preferred_address Transport Parameter 来通知客户端,服务端有新的网络地址,希望客户端自行连接迁移到新的服务端地址上。但是这个有一个很明显的局限性,就是只能在握手时候传递,如果建联完成以后过了很久,服务端有了新的网络地址就没办法通知给客户端了,如果服务端有特殊的业务需要,得自行做应用层的信令来控制了。另外客户端在握手之后,什么时候开始向该地址发起连接迁移,RFC 也没有明确的规定,这个是留给实现者根据自身的业务情况来自我发挥了。还有一个可以补充的点是,RFC 规定 QUIC 连接迁移只能发生在握手之后。我理解,这是考虑到只有握手完成之后,连接通信才是可信的,接下来做连接迁移才算有据可依。
至于第二个场景,还是一个非常经典的例子,就是上文提到的 NAT Rebinding 场景,如果客户端和服务端之间的 NAT 设备更新了 UDP 四元组的映射关系,下图是我举的一个例子。在这个场景中,还是那个众所周知的原因,一般只有服务端有稳定的 IP 和 Port,而 NAT 设备也往往是位于客户端的网络出口处。所以当 NAT Rebinding 发生的时候,一般是服务端的 QUIC 协议栈负责感知到这个状态。通过 UDP 四元组的变化,更具体的说,是四元组中对端 IP 地址或者 Port 的变化,即客户端 IP 地址和 Port。当服务端感知到连接迁移发生之后,一般会主动切换 Connection ID 以及完成 QUIC 连接迁移需要必做清单,这也是我下面会接着聊的内容。
QUIC 如何确保连接迁移的安全性
当 QUIC 开始发起连接迁移的时候,有哪些工作需要完成。其实,正常从上文介绍的 QUIC 设计上来说,QUIC 通信双方似乎不需要做任何事情,毕竟 Connection ID 的核心作用,只是实现 QUIC 面向连接,让流量能够抵达正确的通信双方,被准确处理。但是不能忘记的是,在协议设计中,安全性才是第一位。所以,接下来我们看 QUIC 连接迁移过程中,有哪些安全挑战,以及应该如何应对。
连接迁移其实要解决的核心安全问题很明确,就是在发生网络路径切换后,如何信任这条网络路径,并且整个过程可以保证安全和高效。一般来说,风险的防范更主要是发生在被动进行连接迁移的一方,因为被动方更需要确认新的网络路径是否可以信任,是否可以把后续的数据包切换到新的网络路径上发送。而主动发起的那一方,选择的新网络路径本身就是自己创建的,倒是不太需要担心什么。
考虑到新的网络路径是否可信,那么这其实又有点像之前讨论过的问题,传输层协议握手的核心作用有哪些,最关键的就是确认通信双方是可信的。如果在没有路径验证的情况下,考虑到老生常谈的放大攻击,攻击者很容易干扰正在运行的 QUIC 连接,通过伪造报文的方式,让 QUIC 服务将流量转发到受害者。但是这里可能会有人说,QUIC 有 Connection ID 以及 TLS,接收方可以通过 Connection ID 是否合法,以及 TLS 解密是否成功(AEAD 算法可以通过校验 TAG 的方式确认数据完整性和真实性)。另外 QUIC RFC 也强调了只有最大序列号、并且是非探测数据包,才能够通过 UDP 地址改动来触发连接迁移。
但是哪怕有了这一系列的努力,还是会存在安全问题。如果中间人攻击者,拿着当前连接的流量报文,恶意修改了源地址,并且该篡改报文在原始报文之前抵达到了 QUIC 服务。那 QUIC 服务还是会被迷惑,将流量打到受害者,造成放大攻击。间接也会导致自身连接不可用或者性能受影响。
所以 QUIC 协议针对这个情况,提出了对应的解决方案,最主要的就是 Path Validation
。QUIC 为了路径校验,专门设计了两个 QUIC Frame,即 PATH_CHALLENGE
和 PATH_RESPONSE
。整个流程非常简单清晰,QUIC 连接任意一方都可以发起路径校验,即发送 PATH_CHALLENGE QUIC Frame
,其中会携带 8 字节随机数据,而对端会返回携带这 8 字节随机数据的 PATH_RESPONSE QUIC Frame
。考虑到攻击者根本没办法自己生成 QUIC 连接的报文,所以这套路径校验机制是可以确保新路径是可信的。
接着,QUIC 还推荐发生连接迁移的时候,可以切换成新的 Connection ID,并且淘汰正在使用的 Connection ID,对端如果感知到这一切,正常还会重新声明新的 Connection ID,以备本端不时之需。需要注意的是,这一套 Connection ID 切换机制不是非强制的,不然无法应对中间路径切换导致的连接迁移场景,毕竟 NAT Rebinding 这种,正常来说,客户端根本是一头雾水,哪里晓得要切换 Connection ID,只有看到服务端发起路径校验了,客户端可能才迷迷糊糊的可能也配合着切换一发 Connection ID,但也不强制要求。
安全性之外的工作
连接迁移发生之后,QUIC 除了路径校验和 Connection ID 切换,还有哪些工作要做。这个其实也很容易想到,毕竟网络路径可能发生了改变,那原来基于网路路径统计出来的 RTT 和拥塞窗口数据肯定不能用了,另外 Path MTU 数值也可能发生改变。所以,在完成路径校验之后,QUIC 需要重置 RTT 和拥塞控制的状态,重新进行探测估算,并且重新进行 MTU 探测。所以,我们要清晰的意识到,连接迁移是存在代价的,可能会有短暂的性能影响。
其他协议类似的方案
WebRTC Mobility
不仅仅是 QUIC 有连接迁移的设计,WebRTC 也提出了类似的设想,即 Mobility,也就是在上文描述的 QUIC 连接迁移触发场景下,WebRTC 也能够做到快速恢复,维持视频会话的正常运行。大家都知道,WebRTC 视频会话的建立,是通过 ICE 来完成的,甚至可以说 WebRTC 的面向连接,也是基于 ICE 协商出的 Candidate Pair
来保障的。这里再简单介绍下,一般 ICE 会为 WebRTC 协商出三种可能的网络连接组合情况,一种是客户端和服务端直连(要求某一方持有可以访问的公网 IP 地址),一种是通过中继服务来转发,也就是 TURN 服务,最后一种就是 P2P 打洞的方式来实现客户端和服务端通信,即 NAT 穿透。
当 ICE 按照优先级为 WebRTC 选择并构建完了网络路径之后,ICE 还会持续的进行 Connectivity Checks,确保连接迁移场景出现后,能够第一时间发现问题。这里 ICE 的 Connectivity Checks
应该还承担了像 QUIC 路径校验的作用,都通过一些校验机制来验证新路径的有效性和归属,防止伪造和劫持。不过我个人觉得 Connectivity Checks
生效的时机有点慢,不如直接根据报文的四元组变化,来判断是否发生连接迁移来得快。当然 ICE 协议本身没有限制死这些,我相信通常的 WebRTC 实现肯定会结合四元组变化,来快速判断的。
所以,发现了网络切换的场景,ICE 需要怎么做,其实倒是很简单,就是去切换到之前协商好的其他 Candidate Pair
,但是这里可能会出现原来的 Candidate Pair
现在不可用的情况,比如说 TURN 服务的 Candidate Pair
就存在有效期的限制。如果这些都不能用,那么就得走 ICE Restart,重新协商新的 Candidate Pair
了。当然我觉得这些倒不是证明 QUIC 比 ICE 强,这两个协议其实不是同一层面的事物。由于 QUIC 没有像 ICE 那样考虑那么多复杂的网络场景,其实 QUIC 就支持一种情况,即上文提及的 ICE Candidate Pair
中的第一种情况,要求通信的一方是在公网 IP 地址上进行服务。如果 QUIC 想要走 P2P 打洞的方案,得开发者自己搞定打洞需要的一切,不像 ICE 已经默默搞定了这些。
其实我好几年前搞过 WebRTC Mobility 的野生实现方案,客户端只需要正常瞄准我们维护好的 SFU(Selective Forwarding Unit)集群发送数据包即可,最初的 ICE 协商也只是过家家,Candidate Pair
注定会使用 SFU 集群的公开服务地址。如果客户端出现网络路径切换,不管是 NAT Rebinding 还是说 WIFI 和 5G 的网络信号切换。客户端都不需要额外的感知,只需要正常的发送 RTC 流量到 SFU 集群,SFU 集群中,如果因为网络地址的变化,导致 RTC 流量被转发到了集群里面的新机器。新机器会回复一个特殊信令,这个时候客户端会重新按照我们当时设计的私有协议也做一个特殊回复,两边完成新路径确认以及获取到原有集群通信机器的真实地址,新机器会把 RTC 流量转发到原来的机器上,维持视频服务正常运行。
当然,上面这个野生方案,我只是顺嘴一提,里面糅合了大量的私有业务逻辑,很难短时间介绍清楚,不过确实是挺有意思的一个功能设计和实现,尤其是在我们当时使用的 LVS 负载均衡器配置的是经典的 DR 模式,所以下行流量不管在哪台机器上,都完全可以直接发送给客户端,效率会比预想的高不少。另外,如果运营商发生切换,比如 WIFI 是电信的,手机 5G 是联通的,但是 SFU 集群不是三线机房,而是单电信运营商,那么在这种情况下也要特别注意,连接迁移也是无计可施,毕竟服务端的状态只能在单线机房的机器上,没办法转移到新机房(不要跟我说把服务器状态也一并迁移过去,这个也是史诗级巨坑,工程实现上风险和收益不成正比)。所以只能让客户端先 DNS 解析拿到新的 SFU 集群地址,再重新发起 RTC 请求了。
TCP 实现连接迁移的困难之处
上文介绍到,TCP 的面向连接是依赖 TCP 四元组,而四元组唯一基本意味着 TCP 连接是和底层网络路径绑死的。像 NAT Rebinding 这种事情很少发生在 TCP 连接的身上,只是 NAT 设备支持 TCP 支持的好,以及 TCP 连接生命周期容易把控罢了。所以 TCP 是没办法实现完美的连接迁移,即切换底层网络路径,却不会影响 TCP 连接正常运行,除非有人魔改 TCP 协议栈,但是那样应该也不是 TCP 了😂。
所以只能另辟蹊径,这里方案有很多,但是本质上都是在 TCP 之上再封装一层,然后发生连接迁移的时候,重新创建新的 TCP 连接,然后让应用层协议无感。这里肯定会有人要问,为什么要那么费事再包装一层,直接新建 TCP 连接,然后让应用层无感的继续使用不就行了吗。首先答案是肯定不行的,这也是我在这里想表达的一个关键点,就是如果连接迁移不能够在负责 reliability 的传输层中完成,那么在上层还需要再次设计一套简化版的 reliability 机制。
举个简单的例子,在 TCP 套接字上面调用 send
发送完一部分应用层的数据,并不代表这些数据已经成功抵达了对端,只能说这些数据成功的放入了 TCP 的发送队列里面。发送方只有通过对端 ACK 信息来判断数据有没有被成功接收。如果这个时候发生连接迁移,应用层切换到新的 TCP 套接字上,那应用层该从哪里继续发送数据呢,发送方的应用层可不能简单的认为之前的数据一定被对端接收到了。所以在 TCP 和应用层之间,需要额外引入一层设计,来统计确认到底有多少数据被对端确认了,并且维护未被确认的队列。当发生网络切换,在新的 TCP 连接上,还需要再次协商到底有哪些数据被对端接收,然后将未被确认接收的数据再次在新的 TCP 连接上重传。
讲了一大堆,突然感觉这个确认机制问题,比较容易解决,只是因为这里场景很简单,仅仅是连接的双方互相确认状态。如果是更复杂的场景,多个候选人需要达成状态的一致性确认,那不就是经典分布式一致性算法问题吗?有机会一定要学习下 Raft 算法之类的。像我以前搬砖的 CDN 系统更像是一个分布式的集群,使用分区分片无状态的负载均衡,存在的调度、配置等弱一致性的业务状态也多是依赖中心的控制面来完成。而不像数据库服务那样,对节点间的一致性有着更严格的要求。
这里最后再简单提一下 MPTCP,即 TCP 多路径方案,讲道理它比起连接迁移来说是更进一步,直接使用多条 TCP 连接来支持同一个应用层会话,来提升质量,某种程度上,也是帮助 TCP 连接和网络路径实现了解耦,本质上也是我们上面说的方案,在 TCP 之上包了一层,重新维护了数据字节流的 reliability。后面我会在实现 QUIC 多路径草案 的时候,再对它进行详细对比分析吧,感觉要到猴年马月了。
编码实践细节
连接迁移使用场景
服务端 Preferred_address
第一种场景的情况是服务端主动通过颁布 Preferred_address
传输参数,来告知客户端可以进行连接迁移。其实在实现这个功能的时候,还是有蛮多细节要考虑的。比如说,客户端什么时候开始对 Preferred_address
地址进行迁移,客户端是否要进行路径校验,在客户端主动进行路径校验的时候,同时存在的传输数据是应该被暂停发送,还是在握手的路径上正常发送。这些问题有的是有很明确的答案,比如说迁移到新地址的时候,肯定需要做路径校验。但是有的问题,RFC 也没有一个明确的答案,比如说客户端什么时候开始迁移到 Preferred_address
,我个人理解就是根据使用者的需求了,不过我这里是决定在 QUIC 连接握手成功之后,就立刻开始迁移。并且考虑到传输效果,新路径在被校验成功之前,需要被传输的数据不会被暂停,还是在老路径上发送。
feather-quic 集成测试中,是使用 quinn 来实现 QUIC 服务端的,而我尝试在实现 quinn 服务端 Preferred_address
的时候遇到了一些问题。其实我一开始理解 quinn 是通过提供多个 endpoint,每个 endpoint 绑定一个 socket 的方式,来实现不同路径,这些 endpoint 都共享同一个 quinn 协议栈上下文。当然,我按照这个思路,创建了两个 endpoint bind 原始监听地址和 Preferred_address
新地址,发现迁移到新路径的数据包,都因为 Connection ID 非法而被丢弃了,再加上 endpoint 直接使用 accept 来接收新连接,我就知道每个 quinn endpoint 必然是独享 QUIC 协议栈的上下文,我原先思路是有问题的。而我又死活没有找到 endpoint 可以连续关联多个 socket 的接口,当然这里有人会提及 rebind 接口,但是我第一反应就是排除了这个接口,因为我需要的新增 UDP socket,而不是替换 UDP socket,不然我连接迁移之前的 QUIC 握手怎么完成呢?
悲伤的是,我在 quinn 文档里面翻了半天也没有找到我想要的(endpoint 关联多个 UDP socket),然后我又翻了 quinn 的样例程序,以及测试用例,特别是 Preferred_address
新增功能的代码 PR,但还是一无所获,我甚至还在 github 全局搜索了使用 quinn Preferred_address
相关接口(preferred_address_v4)的代码,还是没有找到有使用这块功能的代码样例。不得已,我只能开始翻 quinn 代码了,感觉这不是第一次了,好多 RUST 三方库功能都齐全,但是文档和样例程序缺失严重,我突然反应过来,feather-quic 也是一个吊样,但是考虑到这是一个学习项目,而且还在起步阶段,所以好像文档其实也不是太重要,后面有缘再说。最后,我看了代码之后发现,rebind pr 接口其实被增强了,rebind 调用的时候,并不是替换原有的 UDP socket,而是等待新增的 UDP socket 有流量进来之后,才会开始真正替换,所以问题终结。
客户端根据自身需求主动发起连接迁移
第二种常见的场景是客户端根据自身的需求主动发起连接迁移,feather-quic 需要给使用者这样的自由,让使用者自行判断是否需要发起连接迁移。而这里的实现关键点是,feather-quic 需要设计一个新的接口,让 feather-quic 的使用者可以自己自行切换 UDP socket 目标地址进行迁移,当 feather-quic 完成了新地址的 Path Validation 之后,所有流量就会被切到新的路径上。考虑到 feather-quic 现在还是丑陋的异步回调接口开发模式,所以只能在 QuicConnection 中新增一个 migrate_to_address
接口,让用户在自己注册的异步回调接口里面主动进行调用,来允许开发者主动选择一个自己准备好的地址进行连接迁移。
网络环境变化
对于网络环境发生变化,一般多是 NAT Rebinding,或者是客户端本来使用的是有线,然后突然切到了 WIFI 网络,这样的 Mobility 事件。但是对于这样的情况,客户端一般是很难有很好的感知。比如说第一种情况,客户端藏身于复杂的网络拓扑背后,如果客户端身前的 NAT 设备进行了 Rebinding,客户端肯定是一无所知。
第二种情况,feather-quic runtime 是很难通过 UDP socket 的错误来发现这样的问题的。虽然 UDP socket 发送有目标主机不可达、网络不可达以及连接被拒等错误,但是考虑到我们只是网络切换,并不是失去网络,同时我们的 UDP socket 现在没有绑死四元组,而且本端 bind 的是全零地址,所以很多代表异常错误的 ICMP 报文,Linux 内核也不会转交到 UDP socket 上面的应用层来。只能依赖使用者通过系统其他机制进行监控,比如说,在 Linux 平台也可以通过 netlink socket 来感知这些网络事件,从而使用 feather-quic 提供的主动切换接口来进行连接迁移,然而这样的方案却过于麻烦。
QUIC 的连接迁移完美解决了这些复杂的情况,QUIC 服务端的实现者会通过 UDP 对端 IP 地址和 Port 的变化来感知到网络路径的情况。如果出现客户端地址的变化,服务端会积极的发起连接迁移,确保整个 QUIC 连接的通信不会因此收到干扰。由于 feather-quic 目前只支持了客户端的能力,而如上文所说,客户端去感知对端地址的变化,其实意义不大,因为服务端的流量地址一旦变化,就基本发送不到处于 NAT 设备之后的客户端,除非客户端和服务端的网络环境非常干净清晰。所以,这种场景下的重担就完全交给了服务端😁。
目前 feather-quic 的问题
在实现连接迁移的时候,第一个让我比较痛苦的地方,就是 feather-quic 既不是一个不包含 runtime 的纯粹协议栈(像 ngtcp2 那样),也不是一个基于现代异步协程解决方案的协议栈方案(像 quinn 那样)。这意味着想给 feather-quic 提供一个不那么 tricky 的连接迁移使用方式,有点麻烦。我最后只能折中在回调里面提供特定接口的方式,让用户使用,但是还得额外新增一个 migration_switch_result
回调,来告知库使用者连接迁移的最后结果。希望后续能早点给 feather-quic 接上 async/await 这种现代异步协程编程模型,现在 feather-quic 连定时器都没给用户提供,也是非常难顶,我测试用例都不好写。
feather-quic 还有很多细节不够完善,每一个大功能的 PR 都要去填之前埋的坑。像这次,我就不得不重新修改了 Connection ID 相关代码,另外把协议栈里面的错误处理逻辑又重新整理了一下。还有就是一些数据结构和框架流程设计不够合理,这个我觉得也蛮致命的。因为我目前只有零碎的时间放在上面,只能靠后续持续迭代来打磨了。另外,在写代码的时候,一直有一种轻微矛盾的感觉,因为在写代码时候,有太多的细节需要处理,我一直在想,要不要把这些细节都照顾到了,如果要有一个比较好的完成度,那需要的精力还是蛮多的,但我对这个项目的预期其实就是拿来自娱自乐的,着实有点蛋疼,只能靠着记 TODO 来自我安慰。
尾声
我这次开发模式换了个方式,以往我是先写代码,然后再开始写相关博客,所以往往在写完博客之后,代码还会调整不少地方,毕竟在写博客的过程中,会对很多设计细节有了新的认知。而这次我改成了先完成博客的主体部分,然后再开始写代码,并且把功能点拆分的足够细,然后交给 cursor 来完成。于是乎,这次大部分代码都是通过 Cursor 生成的,直接干完了我 Cursor Pro 这个月的份额(这里是pr)。我不得不说,大模型生成代码越来越让人惊叹了,虽然生成时候可能会有一些问题,但我每次给出修改建议时,大模型基本能做出正确的响应。
基于以上,我还是想更新一下我对大模型生成代码的感受和看法,我没有深入的去学习大模型相关的知识,但是道听途说或者基于自身使用体验,感觉目前大模型生成代码还是存在两个问题,一个是大模型生成代码本质上还是文本重复生成,缺乏创新能力,或者说是深层次的推理和理解能力,当拿大模型生成一些非常新的库和功能相关代码,而又没有一些靠谱的参考时,大模型生成的代码基本上就是胡言乱语了。另外一个问题,就是大模型的上下文存在限制,所以在使用的时候,我们需要减轻大模型上下文的负担,也就是我之前一直理解的,让 AI 代码生成去做确定的任务,我们来拆解细化好明确的任务,然后坐享其成。
最近还看到一篇有意思的 v2ex 帖子,我觉得第一个观点我特别赞同,那就是 AI 是把大量信息喂到了嘴边,但这样丧失的是对细节的把控,很多时候,学习是一个缓慢而又痛苦的过程(至少对我而言😂),但慢也是快,这样反复思考得到的知识,也记得牢理解的深。所以,我在利用 AI 学习新领域的时候,得时刻反省这一点,不能因小失大。
另外还有一篇很深刻的博客,我觉得观点也很有道理,值钱的永远不是代码,而是能够维护代码的人。大模型快速生成代码,只是转嫁了成本,对代码的评审、测试、维护等等工作仍然至关重要。对大模型生成的代码还是要保持警惕、以及认真 review,确保生成的是自己预期的那样,特别是要进产品的代码。不然,就像博客里面所说的那样,如果不小心,很多被大模型引入的微妙错误,可能突然有一天会爆发出来,让人付出比自己写代码多得多的代价。不过,大模型生成代码对我开发体验的提升是一个质变,用 AI 来帮我干脏活累活,极大地提升了我写代码的乐趣。