工程部门是如何进行性能测试的?
在Kong,我们深知网关产品的性能与稳定性对于客户业务的重要性。为此,我们采用了先进的内部基准测试框架和完善的基础设施,确保我们的产品在这两方面都能达到最高标准。特别值得一提的是,我们专门搭建了一个高性能的裸金属集群,专用于执行严格的基准测试,并且这一测试集群已经无缝集成到我们的持续集成/持续部署(CI/CD)流程中,以实现自动化和高效的测试流程。
此外,我们对产品的每一个主要版本、次要版本以及补丁版本都执行了详尽的性能测试,这不仅展现了我们对产品质量的执着追求,更是为了确保我们的客户在升级到新版本时,能够享受到无懈可击的性能体验。我们相信,通过这些举措,Kong的网关产品将为客户的业务连续性和发展提供坚实的技术保障。
下面这张图简单介绍了我们的裸金属集群的部署情况。
让我们以每日的基准测试为例来简单介绍一下它的工作流程。
基准测试会在一天的某个时间点被启动,并被分配到一个裸金属集群。这些测试由许多场景组成,由于一天的时间有限,我们只对每个场景运行较短时间的测试并得到一个小样本集。同时,我们会在每次发布新版本时进行长时间的测试以得到一个很大的样本集。我们会将这一大一小两个样本集放在一起进行统计学上的假设检验,这可以帮助我们用较低的成本来辅助确定是否在近期引入了性能问题。
10% 的性能回归
在最近一个开发周期中,我们在开发分支上发现 RPS(Request Per Second)降低了约 10%。我们立即启动了快速的性能测试并确认了这个问题,并且它甚至可以在最简单的测试场景下复现,这表示问题应该出现在核心的代理路径上。
随后,我们使用快速的基准测试结合二分查找定位了引入性能问题的代码变更,这部分变更涉及到对 Router 缓存算法的重构。
Kong Gateway 是如何路由请求的?
Router 是 Kong Gateway 的核心组件之一,由客户使用特定格式配置,用以指导 Gateway 如何将每个传入请求与预先建立的 Route、Service 和 Plugin 匹配。从 Kong Gateway 3.0 版本开始,我们使用 Rust 重新设计了我们的 Router(atc-router),相较于之前的实现,它更加灵活,同时可以更高效地利用计算和内存资源,详情请阅读我们的官方文档。
为什么要重构缓存算法?
Route 匹配是 Kong Gateway 代理路径上最昂贵的工作之一,为了降低在匹配大量 Route 时的开销,我们引入了缓存机制来在合适的情况下跳过昂贵的匹配操作。
local cache_key = (req_method or "") .. "|" ..
(req_uri or "") .. "|" ..
(req_host or "") .. "|" ..
(sni or "") .. "|" ..
(headers_key or "") .. "|" ..
(queries_key or "")
重构之前的算法将所有条件合并为一个单一的缓存键,这种做法虽然准确,但是依然有值得优化的地方。假设客户仅仅按照 HTTP 请求路径来匹配不同的 Route,我们的缓存命中率就可能会因为频繁变化的 HTTP 请求头而降低。
因此,在 3.6 版本的开发周期中,我们决定重构算法,使其更高效。
假设客户配置了下面两条 Route:
http.path == /example && http.headers.x_foo == "foo"
和
http.host == “example.com”
那么新的算法将会生成下面这样的缓存 key(对应的值会被动态地填充为实际值)
http.path:<value>|http.headers.x_foo:<value>|http.host:<value>
由于新的基于 Rust 的 Router 知道所有正在使用的字段,它可以更高效地生成缓存信息,这有助于提高缓存命中率并提高 RPS。
在了解了我们重构算法的原因后,我们紧接着就对这部分代码进行性能分析。
性能分析
我们使用了包含我们的内置的 LuaJIT 性能分析工具在内的一系列工具生成了算法重构前后的火焰图。
基于定时器的火焰图
基于定时器的火焰图由重复计时器的处理程序生成,它在较短的时间间隔内收集正在执行的代码的堆栈跟踪,结果代表了 LuaJIT 在执行每个代码路径时所花费的百分比时间。
我们搜索了 route 这个关键词,第一个火焰图中没有找到匹配的代码路径,而在第二个图中却占据了13.3%(高亮的样本)。
基于指令计数器的火焰图
这种火焰图是通过计数器机制产生的。每当 LuaJIT 解释器执行一条指令时,计数器就会增加一次。一旦达到指定的阈值,它就会捕获当前执行代码的堆栈跟踪。这种方法可以帮助我们识别指令过多的代码路径。
我们继续搜索 route 这个关键词,结果与基于定时器的火焰图相似。
在确认了重构后的算法确实引起了性能问题之后,我们仔细地检查了这部分代码却没发现潜在的行为问题,于是我们决定直接对缓存算法本身进行性能测试,即微基准测试。
微基准测试
我们隔离了 Router 代码以便直接调用,进行微基准测试,用于测试的代码十分简单:
for _i = 1, N do
assert(router.match(request) is not false)
end
奇怪的是,我们没有发现新旧算法之间有任何显著的性能差异。
重新回到火焰图
这很奇怪,两种火焰图都将性能瓶颈指向了 Router 相关的代码,但是在微基准测试中却无法复现,于是我们决定从火焰图中挖掘更多的信息。
一个重要的细节是,即使我们只在测试时访问单个 Route,依然可以看到性能的下降,这引起了我们对 LRU 缓存的兴趣,因为在这种场景下缓存命中率是 100%。
我们在火焰图中以 lru 为关键词进行搜索 ,发现它占据了 2.6% 的CPU时间,但与之关联的 Lua 指令只有 0.1%。一般来说,如果一个代码路径执行大约10%的字节码,它应该消耗大约10%的 CPU 时间。虽然这个数字并不总是完全匹配,因为不同的字节码的执行速度不同,但差距不应该如此巨大。
此外,在重构算法之前的火焰图中,即使缓存命中率为 100%,也没有出现 “lru” 的代码路径,表明在变更之前,它并不是一个重要的性能因素。然而,在缓存键算法重构之后,它突然出现在分析结果中。LRU 这个库的代码已经很长时间没有更新过了,这种看似神秘的性能变化是什么原因呢?
调试 JIT 编译器
LuaJIT 在幕后做了哪些工作?
在处理复杂的动态语言运行时(如 LuaJIT)时,性能特性往往受到微妙代码路径变化的影响,这些影响难以被肉眼注意到。这在 LuaJIT 中尤其明显,因为它作为一个 “高风险、高回报” 的 JIT 编译器,能够在运行时对热代码路径进行快速编译,从而实现更快的执行速度,这通常会带来很多倍的性能提升,而无法编译的情况可能导致明显的性能下降。
LuaJIT 是一种基于 Trace 的 JIT 编译器,它实时跟踪 Lua 指令的执行流程,并实时生成名为 Trace 的数据结构,它包含了指令序列和运行时上下文信息,然后它会被编译到机器码并执行。
检查 JIT 编译器的内部状态
LuaJIT 为我们提供了一些 调试 JIT 编译器的接口。
local dump = require("jit.dump")
dump.on(nil, "/path/to/jit.log")
在检查了日志后,我们在热路径上发现了一个可疑的 Trace Abort,它发生在缓存算法的代码路径上。
---- TRACE 1545 start fields.lua:258
0001 UGET 3 0 ; str_buf (fields.lua:259)
0002 MOV 5 3 (fields.lua:259)
0003 TGETS 3 3 0 ; "reset" (fields.lua:259)
0004 CALL 3 1 2 (fields.lua:259)
0000 . FUNCC ; buffer.method.reset
0005 UGET 3 1 ; fields_visitor (fields.lua:262)
0006 MOV 5 0 (fields.lua:262)
0007 MOV 6 1 (fields.lua:262)
0008 MOV 7 2 (fields.lua:262)
0009 FNEW 8 1 ; fields.lua:262 (fields.lua:295)
---- TRACE 1545 abort fields.lua:295 -- NYI: bytecode 51(FNEW)
这是对应的源代码 github.com/Kong/kong/b…
fields_visitor(fields, params, nil, function(field, value)
...
end)
这段代码位于关键的热路径上,它将用于后续匹配 Route 的字段添加到 Router 中。问题在于这个代码会在每次请求时生成一个新的闭包(由字节码 FNEW 完成),这导致了Trace Abort,因为 LuaJIT 目前无法将这个字节码编译成机器码。
什么是 Trace Abort?
Trace Abort 是指 LuaJIT 在跟踪指令流的过程中发现了一些特定的上下文,这些情况无法被编译到机器码,或者不值得被编译,于是停止了对这段指令流的跟踪,使其全部由解释器执行。
例如,LuaJIT 不能对标准库函数 error 进行 JIT 编译,也就是说这个函数会被交给解释器去执行,而不是编译到机器码。这是合理的,因为这个函数用于在 Lua 中引发一个错误(类似于 C++/Java 中的异常概念),这类函数不应该出现在热路径上,所以将其编译到机器码并不会代码显著的性能改善。
以下是一些列表,显示了 LuaJIT 不能或决定不编译的一些函数:
| debug.* | 决定不编译 |
|---|---|
| getfenv | 仅编译 getfenv(0) |
| string.dump | 决定不编译 |
Trace Abort 如何影响性能?
LuaJIT 跟踪指令流并编译到机器码的过程会引入额外的性能开销,这种开销相对于解释器来说是显著的。因此 ,LuaJIT 只会在识别到代码热点后才会启动 Trace 流程,一旦 Trace Abort 发生,LuaJIT 就会立即终止流程,直到发现下一个代码热点。这有时可能会导致后续的代码片段失去被编译到机器码的机会,进而使其一直由相对更慢的解释器执行。
解决方案及效果
解决这个问题并不复杂,我们可以使用一个预先定义的函数来替换这个闭包,这样就不需要在每次处理请求时生成一个新的了。
local function foo() ... end
...
fields_visitor(fields, params, nil, foo)
随后,我们进行了另一轮性能比较,涉及三个版本:
目前,我们观察到只有 2.6% 的 RPS 下降,这在我们的性能测量误差范围内,并且Router 缓存命中率的提高为客户带来了额外的性能收益。最终的基准测试证实了没有显著的RPS 性能下降。
还有更多
鉴于 Trace Abort 对性能有如此显著的影响,我们在正式发布后进行更多类似的性能分析,并修复了一些类似的问题,快速的性能测试显示这些优化提升了大约 7% 的 RPS。
结语
LuaJIT 作为一个高度精密的系统,其内部机制的复杂性意味着并非所有工程师都能完全掌握其细节。为了提升其调试的便捷性,我们计划引入一系列关键性能指标,例如关键路径上 Trace Abort 的计数,这将有助于我们识别那些对即时编译器(JIT)不够友好的代码段。接下来,我们将把这项优化工作整合进我们的内部持续集成/持续部署(CI/CD)流程,以便在开发周期的早期阶段就能发现并解决这些问题。我们所做的一切努力都是为了达成一个共同的目标:为我们的客户提供一个既稳定又高效的高性能平台。
如果您对底层故障排查和性能优化充满热情,并且渴望在这一领域内施展才华,不妨关注一下 Kong Gateway 性能工程师的职位空缺。本文的英文版已发布至 Kong 官方博客 by QiQi