编者按:本文作者是蚂蚁集团前端工程师零弌,介绍了 tegg v3 究竟有多快,快在哪,以及 tegg v3 如何做到性能飞跃。
AsyncLocalStorage 与 eggjs
AsyncLocalStorage [1] 可以在一个 AsyncFunction 以及其相关的异步操作内安全的获取到在 store 内存储的变量。我们通过一段简单的代码就可以看到其演示效果。
AsyncLocalStorage 演示
const http = require('http'); const { AsyncLocalStorage } = require('async_hooks'); const asyncLocalStorage = new AsyncLocalStorage(); function logWithId(msg) { const id = asyncLocalStorage.getStore(); console.log(`${id}: `, msg); } let idSeq = 0; http.createServer((req, res) => { asyncLocalStorage.run(idSeq++, () => { logWithId('start'); setImmediate(() => { logWithId('finish'); res.end(); }); }); }) .listen(8080);
发起两次 HTTP 请求
curl http://127.0.0.1:8080 curl http://127.0.0.1:8080
打印内容为
0: start 0: finish 1: start 1: finish
AsyncLocalStorage 实现原理
nodejs 通过 v8 提供的 Promise lifecycle hooks [2] 实现了 Promise 执行追踪 [3] 。也就是说在一个 Promise 实例化、resolve、reject、then、catch 时均能被追踪到,AsyncLocalStorage 通过这些 hook 将 asyncId 进行了传播,因此可以通过一个 storage 在异步函数中安全的获取到当前的上下文变量。
node 中除了 Promise 之外还有 timer、fs、net 这些模块也能执行移步操作,比如上面演示代码中就是有 http server 和 setTimeout。node 通过 AsyncResource [4] 这层抽象对这些方式进行了实现,如果有自己的 addon 实现,也可以通过这个类来实现。
egg
koa 已支持 [5] ,通过 asyncLocalStorage 参数开启。
egg 已支持 [6] ,通过 app.currentContext 即可获取当前 egg context。
Benchmark
测试地址:https://github.com/eggjs/tegg_benchmark/actions/runs/4025979558
测试场景
项目规模测试
通过在项目里创建大量的 controller 和 service 来模拟项目规模。Benchmark 分别测试了 1, 10, 100, 1000, 10000 个 controller/service 的情况。
业务复杂度测试
通过在 controller 访问 service 来模拟业务复杂度。Benchmark 分别测试了 1, 10, 100, 1000, 10000 个 service 的情况。
结论
- tegg v3 不会因为项目规模扩张而引起性能衰退。
- tegg v3 因为业务复杂度扩张而引起的性能衰退比 egg/tegg v1 慢。
Profile
CPU
egg 项目规模复杂度(1w)
性能热点集中在 egg 中的 defineProperty 和 ClassLoader,原因是 controller/service 过多导致。
tegg 项目规模复杂度(1w)
热点耗时在 node 本身,gc、async_hook。
内存
egg 项目规模复杂度(1w)
由于 controller/service 数量过多,导致存在内存分配瓶颈。可以看到 controller 每次实例化都会占用大量内存。
tegg 项目规模复杂度(1w)
目前内存压力存在于 async_hook。
如何飞跃
tegg 注入原理
tegg 实例代码,HelloWorldController 注入了 Foo 类, Foo 类注入了 Tracer 类。
@HTTPController() class HelloWorldController { @Inject() private readonly foo: Foo; } @Context() class Foo { @Inject() private readonly tracer: Tracer; }
请求在进入框架执行阶段后,首先会找到入口类。本例中为 HelloWorldController,并根据对象图实例化所有的对象。
tegg v1 性能瓶颈
每个请求需要创建 10001 个对象,需要分配大量的内存,导致 gc 压力很大。
tegg v3 优化原理
减少实例化对象,看示例代码,HelloWorkerController, Foo 与请求上下文是无关的,只有 Tracer 是与请求上下文相关。因此 HelloWorldController, Foo 不应该需要每次都实例化,这对于 CPU 和 内存 都是利好。
使用 AsyncLocalStorage 来代理对象,HelloWorldController、Foo 将会改造成单例模式,如何在不同的请求中获取到正确的对象将会是一个问题。我们需要将 tracer 改造成一个代理,通过 ctxStorage 来获取到正确的对象。
优化后效果
从 10001 到 0。极大的降低了内存压力,tegg v1 的堆大小从 200M 到 1G 波动,tegg v3 可以稳定在 200M。
tegg v3 代码改造
修改注解
仅需将 @ContextProto() 替换为 @SingletonProto。
~~@ContextProto()~~ @SingletonProto() class Foo { @Inject() private readonly tracer: Tracer; }
实现有状态
Foo 这个类的状态和当前上下文有关,如果改成 Singleton 模式,所有上下文中共享会导致对象用串了,所以需要保持 ContextProto。
@ContextProto() class Foo { state: State; foo() { this.state = 'foo'; } bar() { this.state = 'bar'; } }
单测改造
describe('test/index.test.ts', () => { let foo: Foo; beforeEach(async () => { foo = await app.getEggObject(Foo); }); it('should work', () => { assert(foo.hello()); }); });
🔗 相关链接
[1] https://nodejs.org/dist/latest-v18.x/docs/api/async_context.html#class-asynclocalstorage
[2] https://docs.google.com/document/d/1rda3yKGHimKIhg5YeoAmCOtyURgsbTH_qaYR79FELlk/edit
[3]https://nodejs.org/dist/latest-v18.x/docs/api/async_hooks.html#promise-execution-tracking
[4]https://nodejs.org/dist/latest-v18.x/docs/api/async_context.html#class-asyncresource
[5]https://github.com/koajs/koa/pull/1721
[6]https://github.com/eggjs/egg-core/pull/251