- 前情提要:
- 原帖省流:
有人在知乎再次发现了axum/hyper的内存泄漏问题。坛内大佬们找到了原始issue,基本的结论是更换成MiMalloc内存分配器会最大程度的降低泄漏可能性。
- 测试准备:
在VMware的虚拟机里测了一下,环境是ubuntu 24.04
桌面版,分配2.6Ghz8C8G。
rust是最新版本rustc 1.81.0-nightly (b5b13568f 2024-06-10)
,--release
模式。
所有终端都是ulimit -n 65536
开启最大文件数。
同环境下直接访问http。
服务端axum写了一个最基础的hello,分别测的是默认内存分配器,以及前一个帖子里提到的MiMalloc、JeMalloc、Dhat一共四种内存分配模式。
首先试了下性能,先用oha随便打了下默认模式,改改oha客户端并发参数,报告axum最大qps能到130000+。
计划客户端用reqwest直接秒内1000/10000qps并发打进axum里,用tokio的interval每秒打一次,连续打到一分钟以上。
代码用的就是官方范例,全都tokio异步化,不存在同步阻塞代码,报告打印皆是一秒一次,基本不影响并发性能。
此轮测试没有用脚本检测内存,就用自带的系统监视器看了一下,截了一些图,但论坛不好传图,就四舍五入分享一下数据。
- 测试结果:
启动的时候MiMalloc模式400+K内存,默认分配器、JeMelloc、Dhat都是不到300K内存。
客户端持续1000qps时,大概几秒后,MiMalloc的内存就会在30+M这样,其他三者在50~60M这样。
客户端持续10000qps时,大概几秒后,MiMalloc的内存会在300+M这样,其他三者在500~600M这样。
客户端压测结束时,所有内存分配模式的服务端,都会保持高内存占用,如果没有新请求,短时间内不会自动释放内存。
但此时低频率的发送请求(间歇性1~100qps),则会逐步释放内存。
- 经过连续1000qps和10000qps且又间歇性的低频请求后,某次测试过程的各阶段近似状态:
状态--------(0qps)----(1000qps)-(10000qps)--(10qps)
默认分配器: 300K -> 60M -> 600M -> 560M
dHat: 300K -> 60M -> 600M -> 560M
MiMalloc: 500K -> 35M -> 350M -> 9M
JeMalloc: 300K -> 56M -> 560M -> 30M
- 内存问题成因推测:
首先,必须得承认axum/hyper确实存在内存分配问题,在经过多轮请求后,并没有恢复到接近最初的内存状态。
在默认分配器的情况下,用reqwest以1000qps打了axum两个小时,72万次请求,内存还是在55~60M这样。
内存无法正确回收,疑似和请求并发数有关,和总请求数关系甚微。
Dhat和默认分配器表现基本一致,对Dhat不太熟,查看了大致文档,感觉还是以监控为主,对内存分配释放并没有什么优化。
MiMalloc和JeMalloc都比默认分配器更好的释放内存,尽管两者最终内存状态仍有区别,但基本都可以认为在承受范围之内。
测试结果和hyper的issues里讨论结果基本一致。
- 测试中遇到一些其他问题:
因为在同一个环境里,CPU爆表以及端口拥堵会同时影响到客户端和服务端。
虽然reqwest用了tokio的semaphore来控制并发,但如果瞬时并发数过大,CPU就会跑满,客户端这边就会产生大量send error,具体原因不明,猜测可能是TCP端口分配满了。
在这个环境下,reqwest大约能短时间连续秒内发28000不报错,但几秒后都会出现大量send error,会阻塞系统tcp通道,服务器即使没挂也无法提供服务。
所以最后决定只测最高10000qps的情况,基本能实现秒内吞吐,大约200~300ms就能完成所有请求的收发。
在CPU没爆表的情况下,各个内存分配器的服务端吞吐性能基本没有太大区别。
但即使是10000qps,跑了一段时间后,仍然有可能CPU爆表,挤压请求。
在连续10000qps的情况下,
其中默认分配器和Dhat大约十几秒后CPU开始跑满,开始出现send error的报错;
MiMalloc大约三十几秒后CPU开始跑满,开始出现send error的报错;
JeMalloc大约八十多秒后CPU开始跑满,开始出现send error的报错。
CPU跑满时,axum服务端会无法访问,在客户端停止发送请求后,服务端会在几秒内恢复正常。
- 结论:
根据这个测试过程,axum即使存在内存分配问题,但看起来内存并不会无限增加。
如果能够承受生产环境下最大qps所占用的内存,也可以选择不更换内存分配器。
如果需要内存预热,则需要用压测来进行预热。
MiMalloc和JeMalloc都是axum服务端内存分配优化的可选项,
MiMalloc在测试中对内存分配释放表现更好,
JeMalloc在兼顾内存释放的前提下,CPU占用率表现更好。
评论区
写评论文章里已经提到ulimit -n 65536了,我这虚拟机的配置,qps十几万基本到头了,同环境大批量吞吐瓶颈反倒在于客户端,客户端和服务端两者基本占满cpu了。
我还没测局域网的情况,客户端服务端不同,理论上应该还能更高。
不知道你用wrk是什么压测参数,等等我改用wrk试试
--
👇
我心飞翔: 感觉你这个压测服务器就没有达到极限,我这里压测 axum 最简单 hello world,用 wrk 进行压测 qps 能达到 40万/秒,此时服务器的 CPU 占用达到 2000 - 3000 之间,压测之前,需要经服务器的 ulimit 限制进行调优。
感觉你这个压测服务器就没有达到极限,我这里压测 axum 最简单 hello world,用 wrk 进行压测 qps 能达到 40万/秒,此时服务器的 CPU 占用达到 2000 - 3000 之间,压测之前,需要经服务器的 ulimit 限制进行调优。
笑了,以为自己又能了,直接一次性发1000万请求,结果虚拟机ubuntu的图形界面卡死了,CPU还在起飞。
强制重启后测了500万个请求一次性发出,带假permit的。
再开高点感觉系统会爆炸,虽然一次性500W个请求可以处理,但qps也下降到36000了,如果只有100-300万,qps基本可以稳定在95000左右,axum还是挺能顶的。
--
👇
lithbitren: 刚说完又打自己脸,我以为把semaphore调成1000万就伪装成了无阻塞,然后注释掉了semaphore的permit,
200万次请求发送直接tokio线程崩溃:
下调到了3万才能不崩溃,但是占用大几千个端口,并出现了2万个send error。
换成了MiMalloc,不知道是服务端处理速度快还是咋地,一次性8万请求以上才会出现客户端线程崩溃。
合着原来是semaphore的permit帮我把tcp端口冲突挡住了,即使semaphore信号量是1000万,只要在请求前加个permit判断,就能让reqwest客户端连续发请求,比sleep一纳秒还好使。。
--
👇
lithbitren: 趁着程序崩溃,从零开始测了一下,单客户端100万请求无阻塞发送。
服务端最大CPU38%但很快就降下来了,内存380+M保持。
用其他服务端API查看,确实不多不少接收了整整100万个请求。
这是200W个请求的情况:
感觉短时间发送请求问题不大,主要是端口数也不会增加太多,脉冲式长时间发送才有可能出现问题,目前见到某轮次最大独立端口数15000左右开始崩溃。
刚回复完你,客户端就崩溃了🤭,8万qps坚持了1500多秒,先是开始积压请求50多秒,然后每轮请求间歇性的个位数send error(第一次出现send error时1472秒),持续到1538秒开始出现连续的线程崩溃。
--
👇
saberlong: 使用了多少并发测试的? 之前提到的问题是在我的笔记本上好几万并发时出现的。不开reuse,TIME_WAIT会有4万多,开启后只有4千多。这个并发数量和机器,以及服务器的状况有关。
最后的log
这个线程崩溃的测试信号量9万,interval秒内最多发9w个请求,如果出现吞吐降速(可能是cpu问题,也可能是端口分配问题),还是有可能出现请求挤压,然后线程崩溃。
第207秒,准备了59秒,代表这轮请求,被积压了59秒。(reqwest客户端设定的是1秒超时)
不限信号量,15万一次性无阻塞发出,大约3秒还是5秒客户端完成全量收发。
现在8万信号量,8万qps,默认分配器,跑了半个多小时了,服务端cpu26-30%,内存340+M。
--
👇
saberlong: 使用了多少并发测试的? 之前提到的问题是在我的笔记本上好几万并发时出现的。不开reuse,TIME_WAIT会有4万多,开启后只有4千多。这个并发数量和机器,以及服务器的状况有关。
--
👇
lithbitren: 原来客户端大部分原因还是在我的垃圾代码,我之前用的tokio的interval,每秒从零建一次客户端,每秒的客户端相互独立clone,请求积压的时候,可能导致多个独立client在向系统索要分配tcp端口的时候出现了冲突,整体导致了客户端qps小了很多。
现在全局用一个client,每个协程的client都同源,并发qps一下就大很多了,即使十几万的qps也能坚持好一会,正常情况下会在2000-4000个端口里io复用。
但可能是在同一个环境下,客户端和服务端的综合吞吐只有八九万,所以当客户端qps大于9万时,整个客户端服务端系统还是会产生任务积压,积压一段时间后会导致tokio的线程崩溃,不过send error倒是没有再出现过了。
崩溃的时候端口总数一般在15000个独立端口。
说明单机压测还是有局限性,得在内网客户端服务端主机分离再测测看。
--
👇
saberlong: send error问题检查下客户端的TIME_WAIT,大量连接导致端口数量不够用,设置为65536也是不够用的。我之前压测时也碰到大量send error,查看tcp连接状态后发现是这个原因。修改tcp_tw_reuse等sysctl配置后并发开到5万都没再出现问题。生产环境谨慎设置。
使用了多少并发测试的? 之前提到的问题是在我的笔记本上好几万并发时出现的。不开reuse,TIME_WAIT会有4万多,开启后只有4千多。这个并发数量和机器,以及服务器的状况有关。
--
👇
lithbitren: 原来客户端大部分原因还是在我的垃圾代码,我之前用的tokio的interval,每秒从零建一次客户端,每秒的客户端相互独立clone,请求积压的时候,可能导致多个独立client在向系统索要分配tcp端口的时候出现了冲突,整体导致了客户端qps小了很多。
现在全局用一个client,每个协程的client都同源,并发qps一下就大很多了,即使十几万的qps也能坚持好一会,正常情况下会在2000-4000个端口里io复用。
但可能是在同一个环境下,客户端和服务端的综合吞吐只有八九万,所以当客户端qps大于9万时,整个客户端服务端系统还是会产生任务积压,积压一段时间后会导致tokio的线程崩溃,不过send error倒是没有再出现过了。
崩溃的时候端口总数一般在15000个独立端口。
说明单机压测还是有局限性,得在内网客户端服务端主机分离再测测看。
--
👇
saberlong: send error问题检查下客户端的TIME_WAIT,大量连接导致端口数量不够用,设置为65536也是不够用的。我之前压测时也碰到大量send error,查看tcp连接状态后发现是这个原因。修改tcp_tw_reuse等sysctl配置后并发开到5万都没再出现问题。生产环境谨慎设置。
延伸下,多网卡使用多ip之外,其实还可以使用虚拟ip, docker等方式增加单机上的ip
tcp正常挥手时,TIME_WAIT会出现在主动关闭的一端。tcp_tw_reuse可以重用TIME_WAIT状态的连接。 启用这个参数确实会出问题,我的经历中,生产环境公网访问确实出现过引起RST的情况,也仅出现在一台机器上。内网间访问还没有碰到过问题。
转发类服务,短链接确实会存在这个问题。这个无法分配端口来解决,因为这个是同一个ip下端口有限,TIME_WAIT导致可用端口跟不上需求属于tcp层面的限制。所以高并发内部调用都是通过长链接的rpc来处理,性能也更高。或者使用udp类的协议,比如http3/QUIC之类的协议。
另外的方式如多网卡通过不同ip发送,多个proxy负载均衡等。
不过通常情况下,性能够用,选择短链简单好维护。
--
👇
lithbitren: 原来如此,根据关键字,我看了一些文章,几乎每个文章都提到改这个参数属于邪道,作用于客户端压测没大问题,但放在服务端里会影响服务端的接收成功率?
如果服务端句柄里存在http请求的微服务,在qps大的时候是不是也会因为崩掉?
解决思路是什么?
有些语言是可以指定http客户端发射端口的,如果提前分配好客户端端口是否能避免这个问题,不过不知道rust里怎么搞这个,reqwest里不会弄。。
还是说大qps的内部调用只能搞全双工的长连接当作访问池?以避免端口的分配问题?
--
👇
saberlong: send error问题检查下客户端的TIME_WAIT,大量连接导致端口数量不够用,设置为65536也是不够用的。我之前压测时也碰到大量send error,查看tcp连接状态后发现是这个原因。修改tcp_tw_reuse等sysctl配置后并发开到5万都没再出现问题。生产环境谨慎设置。
原来客户端大部分原因还是在我的垃圾代码,我之前用的tokio的interval,每秒从零建一次客户端,每秒的客户端相互独立clone,请求积压的时候,可能导致多个独立client在向系统索要分配tcp端口的时候出现了冲突,整体导致了客户端qps小了很多。
现在全局用一个client,每个协程的client都同源,并发qps一下就大很多了,即使十几万的qps也能坚持好一会,正常情况下会在2000-4000个端口里io复用。
但可能是在同一个环境下,客户端和服务端的综合吞吐只有八九万,所以当客户端qps大于9万时,整个客户端服务端系统还是会产生任务积压,积压一段时间后会导致tokio的线程崩溃,不过send error倒是没有再出现过了。
崩溃的时候端口总数一般在15000个独立端口。
说明单机压测还是有局限性,得在内网客户端服务端主机分离再测测看。
--
👇
saberlong: send error问题检查下客户端的TIME_WAIT,大量连接导致端口数量不够用,设置为65536也是不够用的。我之前压测时也碰到大量send error,查看tcp连接状态后发现是这个原因。修改tcp_tw_reuse等sysctl配置后并发开到5万都没再出现问题。生产环境谨慎设置。
原来如此,根据关键字,我看了一些文章,几乎每个文章都提到改这个参数属于邪道,作用于客户端压测没大问题,但放在服务端里会影响服务端的接收成功率?
如果服务端句柄里存在http请求的微服务,在qps大的时候是不是也会因为崩掉?
解决思路是什么?
有些语言是可以指定http客户端发射端口的,如果提前分配好客户端端口是否能避免这个问题,不过不知道rust里怎么搞这个,reqwest里不会弄。。
还是说大qps的内部调用只能搞全双工的长连接当作访问池?以避免端口的分配问题?
--
👇
saberlong: send error问题检查下客户端的TIME_WAIT,大量连接导致端口数量不够用,设置为65536也是不够用的。我之前压测时也碰到大量send error,查看tcp连接状态后发现是这个原因。修改tcp_tw_reuse等sysctl配置后并发开到5万都没再出现问题。生产环境谨慎设置。
这倒也不是对内存严格的问题,hyper使用默认分配器在请求积压过后确实不会释放,实际处理10000qps时axum的内存不会超过200M(160-180M),但积压了就会超过500M,估计就算没有客户端的问题,一个实例跑满50k-60k的连接,总内存也不会超过1G,不过这个是几乎空白的axum-demo,如果加入业务屎山的话,不知道会怎样。
换了musl编译目前看来也一样,而且似乎musl状态下,cargo.toml加jemalloc就会报错(尽管正文里已经注释掉了)。
--
👇
我心飞翔: 1、使用内存池,肯定会有这个问题,内存池就是为了快速分配用的,用空间换时间,释放后不会立即释放; 2、既然对内存这样严格,可以使用target musl 进行编译,这个就不会有 glibc 和其他内存池的问题,释放就真的释放掉了。
send error问题检查下客户端的TIME_WAIT,大量连接导致端口数量不够用,设置为65536也是不够用的。我之前压测时也碰到大量send error,查看tcp连接状态后发现是这个原因。修改tcp_tw_reuse等sysctl配置后并发开到5万都没再出现问题。生产环境谨慎设置。
吃完饭后等了几个小时,内存还是静止在80.0M,毫无释放。
然后间歇发1-10qps的请求,也没释放,如果是MiMalloc或JeMalloc应该就放了。
后来又提高了qps,
之前2000qps,cpu2%,内存80M,
改成5000,cpu5%,110M,
改成8000,cpu8%,140M,
改成9000,cpu9.5%,160M,
数字挺好看,我以为客户端又能了,改成了10000qps。
结果54秒后客户端开始报错send error,比上次测的十几秒好看点。
服务端CPU0.0X-8%闪烁,内存瞬间冲到500M+,但客户端CPU已经爆了。
http2的io复用似乎也是看tcp连接是否被占用的情况,reqwest瞬间发2个请求的时候,也只有不到一成的请求轮次是公用一个端口的。
构建10000个请求只用了十几毫秒,稍微积压几秒就会冲到端口上限。
服务器一直确实都没崩,服务器这边用局域网别的机子访问统计的时候, 大约每秒还能处理几百个请求,不过有时候也有超时的情况,看客户端的运气了。
不过还存在一些细节上的bug,客户端用tokio的semaphore来控制连接数。
qps比较小的时候,客户端和服务端还是比较正常的。
服务端句柄睡眠20ms,同时最大连接数一般就比semaphore的size小一两个值。
但如果qps到达10000了,不知道semaphore是否是限制有延迟,还是tcp的端口分配有问题,
服务端显示,句柄的同时连接数甚至会大于semaphore的信号量的size,
比如semaphore信号量限制1000,服务器连接数视情况最高可到两千多,真是怪事了。。
--
👇
lithbitren: 10个小时结束了,2000360010=72,000,000个请求,现在等凉,最后几分钟看到大约是80M左右,最低79.7M,最高83.1M,最终停留在了80.0M,从结论上说不能完全排除总请求数的影响,但确实影响不大吧。
--
10个小时结束了,2000360010=72,000,000个请求,现在等凉,最后几分钟看到大约是80M左右,最低79.7M,最高83.1M,最终停留在了80.0M,从结论上说不能完全排除总请求数的影响,但确实影响不大吧。
--
👇
zzl221000: 现在使用默认的内存分配器也不会出现以前那样每个新连接占用内存的情况了 现在的内存只和qps有关,这算是符合预期的
👇
lithbitren: 说的不是你代码的问题,可能是rust编译优化的问题,axum/hyper这几年变化也挺多,指不定哪里就不小心优化了。
--
👇
lithbitren: 😂可不敢暴露小水管的ip,你之前测的可能问题比较大,三年过去了,你可以重新试试。
我在不止一个地方看到有人用axum/warp在生产环境运行或半年以上的,理论上即使泄露也没太夸张。
我出门前看的还是80M左右,不敢保证完全跟总请求数无关,不过感觉短时间确实看不出啥。
--
👇
zzl221000: 开个公网端口,我可以帮忙测试下
现在使用默认的内存分配器也不会出现以前那样每个新连接占用内存的情况了 现在的内存只和qps有关,这算是符合预期的
👇
lithbitren: 说的不是你代码的问题,可能是rust编译优化的问题,axum/hyper这几年变化也挺多,指不定哪里就不小心优化了。
--
👇
lithbitren: 😂可不敢暴露小水管的ip,你之前测的可能问题比较大,三年过去了,你可以重新试试。
我在不止一个地方看到有人用axum/warp在生产环境运行或半年以上的,理论上即使泄露也没太夸张。
我出门前看的还是80M左右,不敢保证完全跟总请求数无关,不过感觉短时间确实看不出啥。
--
👇
zzl221000: 开个公网端口,我可以帮忙测试下
说的不是你代码的问题,可能是rust编译优化的问题,axum/hyper这几年变化也挺多,指不定哪里就不小心优化了。
--
👇
lithbitren: 😂可不敢暴露小水管的ip,你之前测的可能问题比较大,三年过去了,你可以重新试试。
我在不止一个地方看到有人用axum/warp在生产环境运行或半年以上的,理论上即使泄露也没太夸张。
我出门前看的还是80M左右,不敢保证完全跟总请求数无关,不过感觉短时间确实看不出啥。
--
👇
zzl221000: 开个公网端口,我可以帮忙测试下
😂可不敢暴露小水管的ip,你之前测的可能问题比较大,三年过去了,你可以重新试试。
我在不止一个地方看到有人用axum/warp在生产环境运行或半年以上的,理论上即使泄露也没太夸张。
我出门前看的还是80M左右,不敢保证完全跟总请求数无关,不过感觉短时间确实看不出啥。
--
👇
zzl221000: 开个公网端口,我可以帮忙测试下
开个公网端口,我可以帮忙测试下
--
👇
lithbitren: 目前的api是回复SocketAddr给客户端,reqwest客户端我暂时没搞清默认Client请求的io复用情况,暂时只能这么测了。
客户端这边每秒统计一次,大约每秒走了1200~1400个端口。
之前统计过单次连接响应速度3-5ms这样,2000qps平均每个链接的响应时间在50-60ms这样,应该是存在一定的io复用的。
2000qps压力目前来看还比较低,CPU不高,客户端也暂时未出现过send error的报错。
--
👇
lithbitren: 我今天9:20开压,2000qps,开局262k,五分钟后9:25,内存稳定在77M-81M之间,现在9:45,还是79.5M左右。
计划开十个小时,下午再看看。
--
1、使用内存池,肯定会有这个问题,内存池就是为了快速分配用的,用空间换时间,释放后不会立即释放; 2、既然对内存这样严格,可以使用target musl 进行编译,这个就不会有 glibc 和其他内存池的问题,释放就真的释放掉了。
目前的api是回复SocketAddr给客户端,reqwest客户端我暂时没搞清默认Client请求的io复用情况,暂时只能这么测了。
客户端这边每秒统计一次,大约每秒走了1200~1400个端口。
之前统计过单次连接响应速度3-5ms这样,2000qps平均每个链接的响应时间在50-60ms这样,应该是存在一定的io复用的。
2000qps压力目前来看还比较低,CPU不高,客户端也暂时未出现过send error的报错。
--
👇
lithbitren: 我今天9:20开压,2000qps,开局262k,五分钟后9:25,内存稳定在77M-81M之间,现在9:45,还是79.5M左右。
计划开十个小时,下午再看看。
--