写点什么

从 0 实现一个延迟代理服务

  • 2019-08-23
  • 本文字数:6609 字

    阅读完需:约 22 分钟

从0实现一个延迟代理服务

需求背景:

后台业务逻辑类服务,其实现通常都会依赖其他外部服务,比如存储,或者其他的逻辑 server。


有一类比较典型的问题:


假设主调方 A 是同步处理模型,有一个关键路径是访问 B 服务。


当被调服务 B 延迟很高时,主调方 A 的进程会挂起等待,导致后来的 A 请求也无法及时处理,从而影响整个 A 服务的处理能力。甚至出现 A 服务不可用。


当然,比较理想的是 B 出现过载或者故障时,A 的服务能力能够降到和 B 同等的服务能力,而非不可用。


因此,部门会定期进行容灾演习,也期望能够验证到各个服务的"最差服务能力"。即验证被调出现较高延迟或者过载的时候,主调的服务能力是否符合预期。


要想做这种演习,其核心技术点是模拟"被调服务出现延迟"。


以前类似场景是直接使用 tc 系列工具,但是由于操作麻烦,以及需要内核支持,实际使用范围非常有限。

目标:

实现一个通用的毫秒级的延迟代理服务,该代理服务用于模拟各种有延迟的被调服务。


1、支持各种应用层协议的接入,无需修改后台代码。


2、高性能。因为该服务就是用于压测其他服务的,不能自己先垮掉

为什么不使用 spp?

spp 是腾讯 SNG 内部广泛使用的后台服务器框架,封装了网络数据收发、监控等。


使用 spp 开发一个服务器程序,不需要使用者关注网络相关的编程,只需按照其接口规范开发插件(linux 动态库,需实现 spp_handle_input/spp_handle_process 等接口),然后挂到 spp 框架上即可运行。


spp 框架通过回调插件内的 spp_handle_input 接口来检查数据包是否接收完整;当数据包接收完整后,框架会回调 spp_handle_process 对数据包进行处理


spp 是基于数据包的处理模型,proxy 处理请求时第一步就是断包,即调用 spp_handle_input 来检查当前收包是否完整。


断包的规则是与应用层协议相关的,比如 SSO 协议的断包规则和单行文本协议的断包规则显然不一样


但是本程序的目的,是实现一个通用的延迟代理服务,即支持不同的应用层协议。


如果使用 spp 基于请求包的服务模型,则每次接入新的应用层协议时都需要修改代码。不符合需求。


可能有同学会想到一个变通的方法:让 spp_handle_input 直接返回当前长度。事实上,当请求包转给真正的被调后,还是无法解决收包时机的问题,以及同一连接上的多个请求被转发后,收包时序的问题。


这里采用的解决方案,是实现一个基于连接的代理服务,而非基于请求包的。


代理服务使用端口来区分不同的转发规则。对于每一个接受的 tcp 连接,代理服务创建一个指向目标服务的连接,将前者透传到后者。回包时也是一样,按照连接对应关系反向透传即可


这样代理服务可以做到并不关注 tcp 上的数据协议,完全透传即可。


不使用 spp 其实还有一个原因,spp 的 proxy/woker 的模型,其实并不是适合特别高性能的服务。


在 worker 足够轻量的时候,单线程的 proxy 可能成为系统的瓶颈,无法发挥出多 CPU 的优势。

高性能

提到高性能,作为有情怀的程序员,通常会想到


尽量无锁


尽量无拷贝


尽量无多余的系统调用


内存分配要足够快


。。。


本服务在实现过程中会尽力以这些作为目标,比如使用了一些如下的小技巧:


使用 SO_REUSEPORT 选项(linux3.9 以上支持)来将负载均衡到各 CPU,也避免使用消息队列(带来拷贝和锁开销)


使用指针操作,内存管理会麻烦一些,但避免拷贝。


使用 accept4 等函数,一步设置异步 socket; 创建 socket 的函数也可以同时设置异步,减少系统调用。


使用 close 关闭句柄,不需要从 epoll 中删除句柄了(close 时会自动从 epoll 中清理掉)。避免多余的系统调用。


获取系统时间足够快,64 位机器上已经不是问题。


在运行时 strace,没有一个多余的调用~~

连接管理

对于每个 tcp 连接,程序会维护一些属性,比如活跃时间等,是一个较大的结构体。


因此,需要维护一个映射关系,能够根据句柄查找连接属性。可以使用 map 或者 hashmap 来实现。


但是最简单的还是。。直接使用数组。这里的 fddtable 是在启动时分配的数组。


struct FDDATA {       int             fd;     uint16_t        flag;     uint16_t        state;     int             seq;     int             pair_fd;    
int type; uint32_t local_ip; //网络字节序。该字段可能用作其他意义 uint32_t remote_ip; //网络字节序 uint16_t local_port; //网络字节序。该字段可能用作其他意义 uint16_t remote_port; //网络字节序
uint32_t events; uint32_t delay; TIMEOUT_KEY tkey; union { struct { TTcpBuffer* first; //for tcp TTcpBuffer* last; //for tcp }; TUdpBuffer* ub; //for udp};
};static FDDATA* fddtable; #define fddptr(fd) (fddtable+(fd))
复制代码


Linux 上的句柄是整数值,所以这里可以使用这个技巧,直接以 fd 的值作为数组的索引


优点:简单,快,无生命周期问题


缺点:存在一定空间浪费


这里提到快,其实是有疑议的。


虽然使用数组来管理连接的数据结构是 O(1)的,足够快。但事实上内核管理句柄是使用红黑树的,其时间复杂度为 O(logN)。综合来说,时间复杂度依然是 O(logN)。但是依然足够快了。

连接超时机制

作为一个 7*24 运行的服务,需要考虑其健壮性。考虑如下几个场景


1.某客户端连接该服务后,客户端机器掉电,或者网线断开,或者中间路由器故障。此时服务器并不知晓,会继续保持连接。


2.客户端编程使用了长连接,然后一直没有断开,也没有继续请求。此时服务器也会保持连接。


如果没有清理机制,


场景 1 会导致服务端的句柄数随时间增长,最终耗尽资源后宕机,不可能 7*24 小时持续运行。


场景 2 类似,如果这样的客户端太多,会占用服务端的资源,却没有真正用于提供服务


所以每一个成熟的服务器都会自带清理基因:


对于长时间不活跃的连接,服务器会主动断开,以节约服务器资源。


这个问题其实是比较有趣的,可以抽象为如下的问题模型:


有 N 个 tcp 连接,


1.如果一个连接连续 n 秒没有收到数据,则该连接已到期。


2.如果某连接有数据到来,则从最后一次收数据开始,重新计时 n 秒


3.连接有可能在中途被关闭


4.随时可能有新的连接加入


简单来说,就是在一堆到期时间变化的句柄中找出已到期的句柄。


显然最直接的办法是遍历,时间复杂度 O(N)。


然而,一个 server 可管理的句柄数可达数十万,甚至百万级,这种方法显然效率太低

方案 1:O(1)级的超时机制(假设 n 是常量)

维护一个链表,按照到期时间对数据排序,最先到期的项都在链表头部。


如果要寻找所有已到期的句柄,只需从头部开始遍历,注意只要遇到一个未到期的句柄,就可以退出遍历了。因为由于有序性,后面的节点更不可能到期。


由于"在当前时刻刚好到期"的句柄数,相对于所有句柄数而言是一个非常小的值,所以节约了大量的遍历时间。


不过问题来了,当一个句柄收到数据时,到期时间变化了,该如何处理?


假设句柄的最后收包时间为 t, 则到期时间应为 t+n,由于 n 是常量,所以 t+n 的顺序其实就是 t 的顺序。


所以只需删除掉链表中原有节点,然后添加到链表尾端即可,时间复杂度 O(1)


细心的同学会发现,其实还是有一个问题,如何根据 fd 找到链表中原有的项?


通常的解决方法是使用侵入式的链表(侵入式链表可以参考 linux 内核中链表的实现方式),可以避免这种查找以及节点拷贝等问题。


很多 LRU 算法也使用这种实现方式。spp 的 proxy 也使用了这种实现方式。

方案 2:O(logN)级别的超时机制

从方案 1 可以看到,虽然实现了 O(1)的查找,但是该方案有一个限制:即 n 必须是常量。


如果 n 不是常量,则"最后收包的连接一定在最后超时"这一结论不成立了,则意味着不能简单的将连接放到链表尾部。即方案 1 无法正常处理这种情况。


n 为变量时,比较典型的实现方式是使用红黑树。而 c++中使用红黑树,最简单的办法就是直接使用 std::multimap


由于本服务器实现上允许使用方配置各种不同的超时时间,所以使用了红黑树的方案。


以句柄的到期时间(微秒级时间戳)作为 key,清理函数(及参数)作为 value



std::multimap<int64_t,STRUCT_TIMEOUT> mmt;static inline void remove_timeout_handler(TIMEOUT_KEY key) { mmt.erase(key); }static inline TIMEOUT_KEY register_timeout_handler(int (*proc)(void*), void* param, int ms, int flag){ STRUCT_TIMEOUT value; value.proc = proc; value.param = param; value.flag = flag; return mmt.insert(std::make_pair(current+ms*1000,value));}
复制代码


红黑树的第一个元素是整棵树中 key 最小的,如果第一个元素都没有超时,则可以断定整棵树肯定不存在已超时的元素了。所以只需要循环检查第一个元素是否超时,如果已超时,则回调对应的清理函数(由红黑树的元素的 value 指定的),然后删除第一个元素;否则退出循环。


        int next_timeout = -1;         while(!mmt.empty()) {             if(mmt.begin()->first-current<1000) {                int flag = mmt.begin()->second.flag;                mmt.begin()->second.proc(mmt.begin()->second.param);                 if(flag) mmt.erase(mmt.begin());            } else {                int64_t t = (mmt.begin()->first-current)/1000;                 if(next_timeout<0||t<next_timeout) next_timeout = t;                    break;            }        } 
复制代码


使用红黑树来管理超时也是比较一个常用的实现方式。在 linux 内核的句柄管理,以及 nginx 中都有使用。

延迟机制与定时器

1、数据结构

如果只是为了管理连接超时,只需把 int 型的句柄值作为 std::multimap 的 value 也可以实现,然后在适当的时机去检查 std::multimap 中的句柄的超时情况即可。


但事实上,在一个异步处理的服务器程序中,有很多类似的场景,比如本服务器中涉及的 tcp 句柄到期清理,udp 句柄到期清理,请求包延迟,以及 connect 超时等,其处理逻辑均不同。


但是其本质是相同的,都是在指定时间后执行一个逻辑。这种"指定时间后执行一个逻辑"可以抽象为统一的定时器,以便代码中所有地方都可以很容易的复用到这种定时机制。


实现定时器时,实际上是将 value 设计为 STRUCT_TIMEOUT 数据结构,这只是一个简单的结构体,包含了回调函数,回调参数,标识 3 个字段。并提供注册定时器事件、移除定时器事件的接口。


这样,tcp 句柄到期清理,udp 句柄到期清理,请求包延迟,connect 超时这几类场景,其触发及回调的机制是相同的,只是 value 的值不同


"将收到的数据延迟后再转发"也只是其中一个具体场景,程序收到数据后注册一个定时器事件,被唤醒后执行回调函数(转发数据)即可。


将 64 位函数指针放到 value 中会浪费一定空间。如果红黑树有 100w 个元素,则需要约 8M 的空间用于存储函数指针。


若严格考虑内存使用效率,其实有一个简单的优化方案:用一个数组来存储回调函数列表,然后将数组的索引放到 value 中(代替函数指针)。


通常来说,回调函数的可选值是很少的,比如我们可以使用 1 个字节或者 2 个字节的整数索引即可标识一个回调函数。


此外 flag 字段其实目前只需要 1 位即可标识。


不过对于本服务来说,这个 value 的内存浪费并不是大问题,所以暂未针对 value 的大小做优化

2、定时器唤醒

使用红黑树实现定时器,很容易找到当前已经到期的节点。


但是还有个问题未解决:程序应该在什么时机做超时检查?即定时器唤醒时机的问题。


服务器总框架是运行在一个 epoll 事件循环中,当有网络事件发生(比如句柄可读可写)时 epoll 就会返回。如果没有事件发生,则 epoll 处于阻塞状态。


那程序如何知道有定时器事件到期?


很容易想到,epoll 本身是可以指定毫秒级的超时时间的。在 epoll 最后一个参数指定的超时时间到期时,即使没有网络事件发生,epoll 也会返回。


所以我们若指定 epoll 的超时时间,比如 100ms,则可以肯定每 100ms 内 epoll 至少会返回 1 次,我们就有可靠的时机去检查红黑树上的超时情况。


这样做,虽然是可行的,但有个问题:epoll 超时时间设置为多少是合适的?


问题 1 如果该值很小,那么在没有网络事件发生的时候,epoll 也会频繁返回。而且大部分情况下的检查,都会发现并没有超时事件到期。即浪费 cpu 做无用功。


问题 2 如果该值很大,则会导致定时器的精度很低。比如若设置 epoll 超时时间为 500ms,则对于一个 10ms 后即将到期的事件来说,最多可能需要等待 500ms 之后才被发现。


理想的情况当然是按需返回:


即 epoll 最好能在下次(红黑树内的)超时到期的时候返回,然后程序会检查红黑树,正好发现此事件到期。


这种策略下,每一次 epoll 唤醒都是有效的(对比问题 1),而且到期时间是准确的(对比问题 2)


事实上,很容易就做到这一点,因为下一次的到期时间就是红黑树的第一个元素的 key 值!


所以 epoll 的最后一个参数取红黑树的首节点 key 值与当前时间的差即可。


    while(1) {        int next_timeout = -1;         while(!mmt.empty()) {                 if(mmt.begin()->first-current<1000) {                int flag = mmt.begin()->second.flag;                mmt.begin()->second.proc(mmt.begin()->second.param);                   if(flag) mmt.erase(mmt.begin());            } else {                int64_t t = (mmt.begin()->first-current)/1000;                  if(next_timeout<0||t<next_timeout) next_timeout = t;                 break;            }        }
n = epoll_wait(epfd, e, 1024, next_timeout); 。。。 }
复制代码

内存分配策略

这里面临的内存分配,其实也是一个有意思的问题。


由于该 server 的实现目标是可以透传任意的数据,对接入的服务没有要求,这意味着我们事先并不知道连接上数据量有多少,可能是几十个字节,也可能是几兆的文件。


那么 server 接收数据时应该使用多大的缓冲区?


如果缓冲区太小,在数据量非常大时,则可能需要循环很多次调用系统调用(read),影响性能。


如果缓冲区太大,在数据量非常小时,则会浪费内存,对于几十万连接的服务来说,这个内存浪费会比较可观。


我使用了一个折中的办法,指数增长的缓冲区大小,以期望在 系统调用的次数 和 浪费的内存 间取一个平衡。


目前默认配置的缓冲区大小为 512Byte,系统首先使用这么大的缓冲区去 recv


如果缓冲区收满,则继续收包,但再次分配的缓冲区大小翻倍,使用 1024Byte


如果缓冲区又收满,则继续收包,但再次分配的缓冲区大小再翻倍,2048Byte


。。。


    while(1) {        ret=read(fd,tb->buffer+tb->bytes,tb->blk_size-sizeof(TTcpBuffer)-tb->bytes);         if(ret<0) {                       if(errno==EAGAIN) break;             if(errno==EINTR) continue;            __tb_free(tb); clean_tcp(fdd,strerror(errno)); return -2;        } else if(ret==0) {            __tb_free(tb); clean_tcp(fdd,strerror(errno)); return -3;        } else {            tb->bytes += ret;        }
DL_NORMAL("read. fd=%d, src=%s:%d, ret=%d", fd, NTOA_IP(tmpip0,fdd->remote_ip), NTOH_PORT(fdd->remote_port), ret); if(tb->bytes==tb->blk_size-(int)sizeof(TTcpBuffer)) { if(tb->bytes>=TConfig::MAX_BUFFER_SIZE) { __tb_free(tb); clean_tcp(fdd,"Memory limit"); return -4; } size_t size = tb->blk_size * 2; TTcpBuffer* tb2 = (TTcpBuffer*)__tb_realloc(tb, size); if(!tb2) { __tb_free(tb); clean_tcp(fdd, "Alloc memory fail"); return -5; } tb = tb2; } } 。。。 }
复制代码


这种策略下,分配的缓冲区理论上最多浪费一倍(最后一次分配后只使用了很少比如才 1 个字节),系统调用次数为 log(total/512)也不会特别夸张。


这是一个可以接受的平衡点。


此外,对于连接数据结构的自身由于使用了预分配,并且是 O(1)的效率,不涉及内存分配问题了。


对于其他的通用的内存分配,比如 STL 内的内存分配,目前暂未做特别处理。


由于目前都运行在 64 位的 tlinux2.2 上,其 glibc 的性能已经很高,所以暂未使用 tcmalloc 类的第三方库。


本文转载自公众号小时光茶舍(ID:gh_7322a0f167b5)。


原文链接:


https://mp.weixin.qq.com/s/tgK7Fn3lQJ4cznNzKPrIXw


2019-08-23 11:021723

评论

发布
暂无评论
发现更多内容

低代码治好了CIO们的 “精神内耗”

高端章鱼哥

软件开发 低代码 数字化 软件交付 降低成本

Flink 最佳实践:TDSQL Connector 的使用(上)

腾讯云大数据

flink 流计算 Oceanus

澜舟科技成立两周年|“孟子GPT”大模型开启邀测

澜舟孟子开源社区

FTP文件传输工具:简单、高效、实用的数据传输方式

镭速

快速文件传输 FTP文件传输工具

iOS MachineLearning 系列(21)——CoreML模型的更多训练模板

珲少

软件测试/测试开发丨Python 封装 学习笔记

测试人

Python 软件测试 自动化测试 封装

点云标注在自动驾驶中的精度提升

来自四九城儿

基于 Databend 实现的海量日志实时查询服务 | 多点DMALL

Databend

直播预告 | 博睿学院:揭开docker的神秘面纱

博睿数据

Docker 可观测性 智能运维 博睿数据 博睿学院

一文带你全面了解openGemini

华为云开发者联盟

数据库 后端 华为云 华为云开发者联盟 企业号 7 月 PK 榜

Hybrid App 技术解析:热更新的原理

Onegun

混合应用开发 native 热更新 Hybrid

金融领域:产业链知识图谱包括上市公司、行业和产品共3类实体,构建并形成了一个节点10w+,关系边16w的十万级别产业链图谱

汀丶人工智能

自然语言处理 知识图谱

2023 年 10 大前端发展趋势

互联网工科生

前端 发展趋势

低代码是开发者的未来吗?浅谈低代码平台

互联网工科生

云原生 低代码 数字化 系统开发

Nautilus Chain:主权模块化区块链的早期实践

BlockChain先知

网络安全(黑客)自学

网络安全学海

黑客 网络安全 信息安全 渗透测试 WEB安全

uni-app开发小程序:项目架构以及经验分享

陇锦

小程序 uni-app 前端

图技术在 LLM 下的应用:知识图谱驱动的大语言模型 Llama Index

NebulaGraph

图数据库 知识图谱 LLM

在Java中的空指针异常怎么避免?

java易二三

指针 java‘ #编程

从零开始搭建医药领域知识图谱实现智能问答与分析服务(含码源):含Neo4j基于垂直网站数据的医药知识图谱构建、医药知识图谱的自动问答等

汀丶人工智能

人工智能 知识图谱 智能问答

快速玩转 Llama2!阿里云机器学习 PAI 推出最佳实践(一)——低代码 Lora 微调及部署

阿里云大数据AI技术

AI新场景 安全新边界技术高峰会定档8月9日

权说安全

如何判断某个视频是深度伪造的?

高端章鱼哥

人工智能 伪造视频 AI视频

2023深圳高交会|第二十五届中国国际高新技术展览会

AIOTE智博会

高交会

「大模型之家」对话澜舟科技创始人兼CEO周明:马上采取行动,拥抱大模型

澜舟孟子开源社区

你还在用命令式编程?Python函数式编程让你的代码更优雅!

高端章鱼哥

Python 函数式编程

货拉拉基于 Flink 计算引擎的应用与优化实践

Apache Flink

大数据 flink 实时计算

AIIA杭州大会|李京梅:基于孟子GPT生成式大模型的技术与行业应用实践

澜舟孟子开源社区

从0实现一个延迟代理服务_文化 & 方法_王雄_InfoQ精选文章