红帽白皮书新鲜出炉!点击获取,让你的云战略更胜一筹! 了解详情
写点什么

从 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:021619

评论

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

【上汽零束SOA】云管端一体化SOA软件平台系列介绍之六:数字生态篇

SOA开发者平台

软件 SOA 生态 汽车 OTA

等保三级多久测评一次?每年都要测评吗?

行云管家

网络安全 信息安全 等保

围绕低代码开发存在的三个误解

低代码小观

程序员 低代码 企业管理 低代码开发平台 应用开发

Java 17 正式发布,Oracle 宣布从 JDK 17 开始正式免费,Java 迈入新时代

Java 编程 源码 架构

北鲲云SaaS平台为生物制药研发进程带来“加速度”

北鲲云

ONNX 浅析:如何加速深度学习算法工程化?

拍乐云Pano

人工智能 深度学习 音视频

自动化驱动的高可用网络:爱奇艺B2网络流量自动调度系统建设实践

爱奇艺技术产品团队

流量

让全链路压测变得更简单!Takin2.0重磅来袭!

TakinTalks稳定性社区

【Vuex 源码学习】第五篇 - Vuex 中 Mutations 和 Actions 的实现

Brave

源码 vuex 9月日更

集齐海外主流云平台,EMQ X Cloud 正式支持 Google Cloud Platform 部署

EMQ映云科技

IoT Google Cloud AIOT 云 原生云 CTO

【IM开源推荐】前微信技术专家打造的开源的即时通讯组件OpenIM

OpenIM

观测云品牌正式亮相,携手中国信通院共推国内可观测性概念与技术发展!

观测云

【云计算】从事云计算运维可以考取哪些证书?

行云管家

云计算 阿里云 运维 华为云 云运维

重磅发布!百度飞桨获2021全球智博会产品金奖

百度大脑

人工智能

花了3个小时解决了和异地女朋友一起看电影的需求(内附源码)

ZEGO即构

android 音视频 一起看电影

到底什么是区块链?是咋样的运营原理

CECBC

Alibaba 80k Star《Java面试突击手册》(全彩版)开源

Java 程序员 架构 面试 计算机

使用分布式锁的正确姿势

张大彪

分布式锁

【OpenIM原创】IM服务端docker、源码、集群部署 非常实用

OpenIM

软件测试工程师应该怎样规划自己

程序员阿沐

程序员 职业规划 软件测试 自动化测试 经验分享

Android如何用代码执行shell命令

Changing Lin

9月日更

2021智博会全国区块链大赛暨首届“星火杯”区块链应用大赛正式启动

浪潮云

云计算,

数字货币钱包开发,imtoken钱包系统搭建

13530558032

量化交易APP开发,量化交易系统源码

13530558032

财经违规自媒体集体扑街,必须打击违规自媒体账号

石头IT视角

Alibaba iOS 工程架构腐化治理实践

阿里巴巴终端技术

ios 研发效能 客户端 架构治理

NQI质量基础设施一站式方案,NQI一站式线上平台搭建

13530558032

【上汽零束SOA】云管端一体化SOA软件平台系列介绍之六:数字生态篇

SOA开发者

自动驾驶 SOA 智能汽车 OTA

区块链是如何运作的、是如何防止被篡改的?

CECBC

WorkPlus即时通讯软件,满足政企局域网办公需求

WorkPlus

高可用 | 关于 Xenon 高可用的一些思考

RadonDB

MySQL 数据库

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