一次openresty的网关性能优化之旅

我们公司是使用openresty作为网关,用于进行权限认证,反向代理,以及一些请求的处理。在使用过程中,我们遇到过这种情况,大部分时候openresty的性能都很好,cpu使用率都很低,但是有时候会突然cpu飙升,nginx的cpu打满,然后请求处理开始失败。但是这种情况出现次数少,持续时间不长,没法把cpu火焰图抓出来查看原因。

因此这里进行一次网关性能优化的探索,主要有两个目的,一是提高openresty的网关性能,处理偶现的这种异常情况。二是希望能有一种通用的智能的openresty限流方案,能确保在极限请求压力下,openresty能通过拒绝一部分请求的方式保障大部分请求正常被处理。

一、openresty网关性能优化

这里首先尝试探索openresty网关的性能优化方案。主要的思路就是先模拟出线上出现问题的情况,因为线上情况比较复杂,不好完全模拟,这里设计一个接口,将线上最常见的鉴权代码放进去,然后限制openresty的cpu上限,接着用高并发压测这个接口,把openresty的cpu打满,模拟线上的情况。

模拟后通过监控查看openresty的qps以及接口成功率,以这两者的乘积,即每秒的成功请求数作为最终压测的结果。 后面通过几种方案修改并重复压测,通过每秒成功数来判断方案是否有效提高了openresty的性能。

1.1 压测环境介绍

这里首先介绍下压测环境:

  1. openresty使用k8s部署,将其cpu limit设置为0.3,通过一个较低的cpu上线能更加容易的把cpu打满,方便压测。
  2. 压测工具使用hey,hey -t 5 -n 999999999 -c 3700 -q 1 'http://172.16.1.97:8001/test/nomal/request' 通过这种命令持续压测,-c 代表并发是3700,-q代表每秒每个并发最多请求一次,这样限制了总的qps最多为3700。
  3. 接口/test/nomal/request的逻辑如下,简单点说的话,就是先查redis查找认证信息,没查到就从mongo数据库中找,并设置到redis中供下次使用,是很常见的授权认证方式。这里简化了下,大家可以简单看下代码体会下。后面做了个处理,如果redis或者mongo中查到了数据,则返回200状态码,否则返回503状态码,这样可以在nginx监控或者压测结果中比较清楚的看出来成功率。
bash 复制代码
       location /test/nomal/request {
             content_by_lua_block {
               function getAuthInfo(id)
                 local authInfo = redis:get("L-" .. id)
                 if not authInfo then
                  local authInfo = get_from_mongo(id)
                  if authInfo and cjson.encode(authInfo) ~= "" then
                    redis:set("L-" .. id, authInfo)
                  else
                    return nil
                  end
                end
                return authInfo
              end

                local id = getAuthInfo("testId")
                if not id or tostring(id)=="" then
                  return ngx.exit(ngx.HTTP_SERVICE_UNAVAILABLE)
                end
                return ngx.exit(ngx.HTTP_OK)
            }
       }
  1. redis和mongo都做了限流保护,避免因为网关占用了太多连接,导致其他服务连接redis或者mongo出现问题,redis的连接池数量是500,超过这个数量的redis请求会被放在一个400长度的队列中,超出这个队列的请求会直接失败。
1.2 线上环境模拟

这里首先模拟线上环境,把openresty的cpu打满,并且通过抓取火焰图来分析cpu使用情况。压测命令都是一样的,这个后续不再说明: hey -t 5 -n 999999999 -c 3700 -q 1 'http://172.16.1.97:8001/test/nomal/request'

测试结果:

qps: 2500

成功率:14%

每秒成功请求数:2500*14%=350

导出的lua火焰图:

分析:

从导出的lua火焰图可以看出,lua的大部分cpu都被用在了mongo库的处理上,因此我们下一步的优化方向就是通过减少mongo请求来提升处理能力。这里推荐下,如果想要配置同款的nginx监控和lua导出火焰图,可以查看笔者之前的文章:openresty监控 以及 openresty容器导出火焰图

1.3 优化mongo请求

仔细查看日志后发现,因为cpu打满了,openresty的处理速度变慢,很多redis的连接可能等待了一秒都没被处理,这就超过了redis的连接超时时间,导致缓存穿透,请求都去查mongo了,即使mongo连接也有限制,但是查询mongo的库估计比较占用cpu,导致这部分cpu使用率较高。

于是我们可以进行代码优化,只有在redis查询成功,但是没查到数据的情况下才去查mongo,这样就能避免redis失败时穿透到mongo。

bash 复制代码
              function getAuthInfo(id)
                 local authInfo,err = redis:get("L-" .. id)
                 if not authInfo and not err then
                  local authInfo = get_from_mongo(id)

测试结果:

qps: 2816

成功率:27.4%

每秒成功请求数:2816*27.4%=771

导出的lua火焰图:

分析:

从压测的结果可以看出,qps提高了,错误率反而下降了,每秒成功请求数是之前的2倍,说明优化是有效的,但是错误率还是有点高,这个是否有办法降低。目前比较容易想到的方式是限流,只要我们根据一定指标把多余的请求拒绝掉,是否就可以确保openresty始终能处理一定量的请求了呢。

1.4 限流

我们希望找一种限流方案,最好是比较智能的方案,可以根据一个固定的阈值触发。比如一个openresty实例在固定cpu下只能处理1000个请求,那最好是能找到个指标,当超出这个指标时就进行限流,把超过openresty处理能力的请求拒绝掉,从而最大化的保障openresty处理能力,避免因为雪崩导致大部分请求无法被正常处理。

这里额外说明下为什么不用常见的漏桶和令牌等限流方式,因为我们觉得这个方式的阈值比较固定,不够智能,很多时候可能阈值达到了,但是nginx实际上还没跑满,或者nginx已经跑满出问题了,但是qps阈值还没达到。我们希望找到一个更智能的限流方式。

这个我们尝试了几个指标,都不太理想,这里直接说结论,最能体现nginx到达处理能力瓶颈的指标就是cpu使用率,当其达到阈值时就是openresty出问题的时候。 这里的探索我们后面再说明,先来看修改方案和测试结果。

我们首先简单做了个限流,当检测到openresty cpu跑满时就开始限流,按照比例限制流量,比如20%的请求直接失败,并且如果cpu没有降下来就继续提升限流比例,cpu降下来了就降低限流比例。 不过目前没有实现完整的方案,而是先简单做了一版,手动控制cpu是否跑满的开关,限流也是默认是20%,也就是默认限制20%的请求。

测试结果:

qps: 3084

成功率:29.7%

每秒成功请求数:3084*29.7%=915

导出的nginx火焰图对比:

分析:

这里的火焰图是限流之前和之后的nginx cpu火焰图,黑圈中的unknown部分就是lua代码使用的cpu,可以看出限流后确实lua cpu使用率降低了,同时每秒成功请求数也提升了16%左右。但是并没有达到我们希望的理想效果,异常率还是有点高,这部分需要后面继续优化下限流方案。

这里我们重新考虑下redis,因为redis的连接池导致有部分请求失败,但是实际上redis支持的连接数上限是很高的,是否可以通过调整redis连接池配置的方式进一步优化?

1.4 redis连接池配置调整

我们这里不直接修改redis的连接池的最大连接数,修改连接数用满时的存放redis请求的队列的最大值,之前是400,我们改成2000来进行测试下。

测试结果:

qps: 2424

成功率:56.6%

每秒成功请求数:2424*56.6%=1,371

分析:

我们发现qps下降了,但是请求的成功率上升了很多,从而每秒成功请求数也上升了很多。这里分析下原因,应该是因为我们使用hey的压测是3700并发,并且每个连接每秒最多请求一次,通过这种方式控制总qps最多是3700/秒。 这里把redis请求放入了队列,导致更多的请求能够被成功处理,只是请求本身的延迟变高了,因此在每个连接上的qps就降低了。

举个例子,本来一个连接1秒一次,因为redis超过队列,就失败了,而队列加长后,可能变成每个连接的请求2秒才处理完成,但是redis请求在队列中等待,请求处理成功了,因此qps降低了,但是请求成功率上升了。

二、openresty网关限流方案探索

这里探索下openresty网关的限流方案,我们希望找到的最理想的方案就是能有一个指标可以明确表示出nginx的处理能力,这个指标出问题就说明nginx超过了其最大处理能力,请求会出问题。因此最主要的步骤就是找出这个能体现nginx处理能力是否到达阈值的指标。

我们首先参考了java的web服务器的架构,java的请求是在worker线程中一个一个处理的,未被处理的请求被放在一个队列中,因此我们可以监控这个队列,只要队列里面堆积的请求数超过一定数量,就代表到达了web服务器的处理能力上限,我们就可以直接丢弃新请求进行限流,避免服务器被冲垮。

2.1 架构区别

但是在nginx中我们发现这种方案并不适用,因为nginx和java的web服务器处理模型不一样。java的web服务器虽然也有异步,但是异步比较少,像是redis和mong这种数据库处理基本都是同步的,因此他处理请求都是一个一个处理,上一个处理完成了再处理下一个,因为查询redis和mongo的同步导致请求的处理过程也基本是同步的。

而nginx不一样,nginx是异步模型,请求被nginx的worker进程accept接收后进行处理时,数据到了就会执行lua代码进行处理,等lua执行到redis查询时,这个是一个io,nginx会异步处理,直接切换下一个请求并处理,等到redis的回复数据到了之后再回来处理之前的请求。这就导致监控他的队列没什么效果,因为他接收请求的速度很快,只要有cpu就会把请求接进来,用队列监控的方式没有效果。

那么监控正在处理中的请求数量有用么? 这个可以通过nginx的ngx.var.connections_writing变量来查询,他代表nginx正在处理的请求数,但是我们发现这个也不好使,因为你不能判断到多少的时候nginx是有问题的。那用什么指标比较合适呢?下面列一下我们尝试使用作为阈值的指标。

2.2 可能的指标
  1. socket中未读的数据,epollo中待处理的io数量:如果nginx来不及处理,则建立的socket中会有来不及处理的数据,Established 状态下的 Recv-Q会变大,同时因为worker实例因为cpu跑满也没时间去accept连接,则LISTEN状态下的Recv-Q也会变大,但是这个指标并不能很好的获取并用于限流,因为nginx没到极限时,这些值也会有,只是多和少的区别。
  2. ngx.var.connections_writing数量,nginx正在处理的请求数:这个值没法很好的体现是否堆积,因为他代表当前正在处理的请求,等于qps 乘以 延迟,比如有些请求比较慢,但是qps不高,也会导致这个值有一定的数量,无法用于判断现在是否过载。

因此,我感觉目前最合适的指标就是nginx实例的cpu使用率,这个指标显而易见,但是也是最有效的。

2.3 cpu过载的后果

假设此时qps超过了nginx本身的处理能力,cpu打满是必然现象,因为nginx会异步的不断的接收新请求并进行处理,只要cpu没满,就能接收更多的请求并处理。

cpu打满后,请求会变慢,有些io如redis,mysql和mongo请求就会超时,导致虽然请求返回200,但是状态码是失败的。同时达到最大值后,nginx从socket中获取数据处理请求的速度变慢,socket中的请求会一直得不到处理,甚至nginx建立redis连接都会失败,从而连接池中的redis连接下降,进一步导致雪崩,本来能处理的请求也处理不了了。

结论:

综上所述,只要nginx的cpu没跑满,就代表nginx没问题,可以处理更多请求。但是一旦nginx的cpu跑满,就容易出现雪崩状态,所有的请求都正在被处理,但是因为cpu不足,请求会因为各种原因失败,整体失败率大幅上升,造成雪崩。

如果出现nginx cpu跑满的情况时,可以通过动态限流或者增加nginx实例避免雪崩。

相关推荐
我是一颗柠檬9 小时前
【Java后端技术亮点】Feed流三级缓存设计,从10秒到100毫秒的优化实战
java·开发语言·后端·缓存
JaguarJack9 小时前
PHP 应用 security.txt 漏洞披露实践
后端·php
程序员三明治9 小时前
【AI】RAG 数据分块(Chunk)策略与实践
java·人工智能·后端·ai·大模型·llm·rag
RemainderTime9 小时前
Spring Boot脚手架集成Sa-Token实现生产级RBAC权限管理
java·spring boot·后端·系统架构
llz_11213 小时前
web-第二次课后作业
前端·后端·web
红尘散仙18 小时前
我把终端小说阅读器接上了 AI Agent:TRNovel 现在能用 skill 生成书源了
人工智能·后端·rust
卷毛的技术笔记20 小时前
告别硬编码!Spring AI Alibaba 实现 AI Agent 智能工具调用(Tool Calling)
java·人工智能·后端·python·spring·ai编程
会编程的土豆20 小时前
Go 语言反射(Reflection)详解
开发语言·后端·golang
喵个咪20 小时前
GoWind Toolkit Go后端代码生成 完整全流程实战
后端·go·orm
basketball61621 小时前
Go 语言从入门到进阶:4. 数组和MAP使用方法总结
开发语言·后端·golang