先看一段代码。这是从我们线上一个 CSV 导入接口里抠出来的,变量名改过,逻辑一点没动:
async function importCsv(userId, filePath) {
const lockToken = await acquireLock(`import:${userId}`);
if (!lockToken) {
throw new Error('请勿重复提交');
}
let conn;
let fh;
const timer = setTimeout(() => {
controller.abort(new Error('导入超时'));
}, 30_000);
try {
fh = await fs.promises.open(filePath, 'r');
conn = await pool.getConnection();
await conn.beginTransaction();
const raw = await fh.readFile('utf8');
const rows = parseCsv(raw);
for (const row of rows) {
await conn.query('INSERT INTO orders SET ?', row);
}
await conn.commit();
return { imported: rows.length };
} catch (err) {
if (conn) {
await conn.rollback().catch(() => {});
}
throw err;
} finally {
clearTimeout(timer);
if (conn) conn.release();
if (fh) await fh.close().catch(() => {});
await releaseLock(`import:${userId}`, lockToken);
}
}
这段代码能跑,review 的时候也没人挑出毛病。但它有三个隐患:
第一,释放顺序靠人记。正确顺序应该是先回滚事务、再关文件、再放锁。现在的 finally 是先放锁后关文件,虽然本例中没出问题,但哪天有人调换了三行代码的位置,谁也发现不了。
第二,每个清理动作都要写 catch(() => {})。因为不写的话,fh.close() 抛错会把 catch 块里正在处理的原异常顶掉,变成一个八竿子打不着的报错。
第三,变量声明被撕成了两半。为了在 finally 里判断有没有初始化成功,conn 和 fh 必须提到 try 外面声明,类型系统上它们就是”可能为 undefined”的,每次用都要做空判断。
这种写法在 Node 里太常见了,常见到大家默认这就是正确姿势。但 JavaScript 已经在规范层面给了更好的答案:显式资源管理(Explicit Resource Management),也就是 using 声明。
一、using 到底做了什么
它的语义非常窄,一句话能说完:把一个变量和它的释放逻辑绑定,在离开作用域时自动释放,无论是因为正常返回、抛出异常,还是提前 break。
释放逻辑从哪来?从对象上的 Symbol.dispose 方法:
const handle = {
name: 'demo',
[Symbol.dispose]() {
console.log('cleanup', this.name);
},
};
function run() {
using h = handle;
console.log('working');
}
run();
// working
// cleanup demo
就这么简单。没有继承、没有接口检查、没有装饰器,只要对象上挂着 Symbol.dispose,它就能被 using 管起来。
如果释放是个异步操作(关连接、提交事务、删远程文件),那就用异步版本 Symbol.asyncDispose,声明时写 await using:
const conn = {
async [Symbol.asyncDispose]() {
await this.flush();
await this.close();
},
};
async function run() {
await using c = conn;
await c.query('...');
} // 此处 await 等待释放完成
这里有第一个必须记住的坑:await using 里的 await 只管”释放时要不要等”,它不会 await 初始化表达式的值。你要是这么写:
await using lock = acquireLock(key); // 错!lock 拿到的是一个 Promise 对象
那 lock 绑定的就是 Promise 本身,后面访问 lock.token 会是 undefined,而 dispose 时又会去找 Promise 上的 Symbol.dispose(找不到,静默跳过)。代码不报错,锁也不会被释放,这是最阴的一类 bug。正确写法是老老实实加 await:
await using lock = await acquireLock(key); // 对
记住这个区别,能少熬一个通宵。
二、把上面的导入接口重写一遍
思路是把每种资源包成一个”自带释放逻辑的薄包装”,然后在业务函数里声明它们。先写三个包装函数。
2.1 分布式锁
import crypto from 'node:crypto';
const RELEASE_LUA = `
if redis.call("get", KEYS[1]) == ARGV[1]
then return redis.call("del", KEYS[1])
else return 0 end
`;
async function acquireLock(key, ttlMs = 60_000) {
const token = crypto.randomUUID();
const ok = await redis.set(key, token, { NX: true, PX: ttlMs });
if (!ok) {
throw new Error(`资源被占用: ${key}`);
}
return {
token,
async [Symbol.asyncDispose]() {
// 用 Lua 保证只删自己持有的锁,避免超时后误删别人的
await redis.eval(RELEASE_LUA, 1, key, token);
},
};
}
2.2 文件句柄
import fs from 'node:fs/promises';
async function openRead(path) {
const fh = await fs.open(path, 'r');
return {
handle: fh,
async [Symbol.asyncDispose]() {
await fh.close();
},
};
}
2.3 数据库事务
这个包装稍微讲究一点,它要同时支持”显式提交”和”没提交就自动回滚”两种路径:
async function beginTransaction() {
const conn = await pool.getConnection();
await conn.beginTransaction();
let settled = false;
return {
conn,
async commit() {
if (settled) return;
await conn.commit();
settled = true;
},
async [Symbol.asyncDispose]() {
// 走到这里说明要么没提交,要么中途抛异常
if (!settled) {
await conn.rollback();
settled = true;
}
conn.release();
},
};
}
关键在于 settled 这个内部状态。业务代码显式调用 commit() 之后,dispose 时就只负责把连接还回池子;如果没调用 commit()(包括抛异常的情况),dispose 就自动回滚。这样一来,”忘了回滚”这个经典事故在结构上就不可能发生了。
2.4 超时控制器
function timeoutAfter(ms) {
const ac = new AbortController();
const id = setTimeout(() => ac.abort(new Error('导入超时')), ms);
return {
signal: ac.signal,
[Symbol.dispose]() {
clearTimeout(id);
},
};
}
注意这个用的是同步的 Symbol.dispose,因为 clearTimeout 是同步操作。能用同步就别用异步,少一层 microtask。
2.5 重写后的业务函数
async function importCsv(userId, filePath) {
using guard = timeoutAfter(30_000);
await using lock = await acquireLock(`import:${userId}`);
await using file = await openRead(filePath);
await using tx = await beginTransaction();
const raw = await file.handle.readFile('utf8');
const rows = parseCsv(raw);
for (const row of rows) {
if (guard.signal.aborted) {
throw guard.signal.reason;
}
await tx.conn.query('INSERT INTO orders SET ?', row);
}
await tx.commit();
return { imported: rows.length };
}
少了 21 行。更重要的是,这段代码的正确性不再依赖开发者的细心:
- 释放顺序是声明顺序的逆序:tx → file → lock → guard。事务先收尾,文件再关,锁再放,定时器最后清。这个顺序天然正确,不用记。
- 无论从哪一行抛出去(包括
parseCsv里抛、循环里抛、commit自己抛),四个 dispose 都会被依次调用。 - 不用再写
catch(() => {})吞异常,重复释放和异常覆盖的问题规范已经处理掉了,下面第三节会讲。 conn和fh不再需要在try外面提前声明,作用域收窄到了一行。
另外顺带说一句,guard.signal 的检查我特意放在循环里而不是靠 AbortSignal 自动中断查询。因为 mysql2 的连接不会自动响应 signal,这个检查点得自己埋。资源管理和取消传播是两件事,别混着指望。
三、异常处理:SuppressedError 是怎么回事
以前写 finally 的时候,如果 try 里抛了 A,finally 里又抛了 B,那最终冒出来的是 B,A 就丢了。所以大家习惯性地在清理代码后面挂 .catch(() => {})。
using 采用了一个更聪明的策略:主异常保留,清理异常被包装进去。如果两处都抛错,你拿到的是一个 SuppressedError:
const bad = {
[Symbol.dispose]() {
throw new Error('dispose 失败');
},
};
try {
using x = bad;
throw new Error('业务失败');
} catch (err) {
console.log(err.name); // SuppressedError
console.log(err.error.message); // 业务失败 ← 主异常
console.log(err.suppressed.message); // dispose 失败 ← 被压制的
}
如果只有 dispose 抛错、业务没抛错,那抛出的就是 dispose 本身的错误,没有包装。如果连续多个 dispose 都抛错,SuppressedError 会嵌套,suppressed 字段里可能还是一个 SuppressedError。日志系统里最好加一个专门展开它的函数:
function flattenError(err) {
const out = [];
let cur = err;
while (cur instanceof Error) {
out.push(`${cur.name}: ${cur.message}`);
cur = cur.suppressed;
}
return out.join(' <-- suppressed by --> ');
}
实测下来,日志里能看到完整链条比只看最后一条有用得多。我们就是因为这条日志,才发现某个连接的 rollback 在特定网络抖动下会超时抛错。
四、不止一个资源:DisposableStack
有些场景没法在函数开头一口气声明完,比如”遍历一个目录,给每个文件开一个句柄”。这时候 using 就不够用了,得上 DisposableStack:
import { readdir } from 'node:fs/promises';
async function scanDir(dir) {
using stack = new AsyncDisposableStack();
const names = await readdir(dir);
const handles = [];
for (const name of names) {
const h = await openRead(`${dir}/${name}`);
// use 会把对象登记进栈,并在返回时把它交还给你
handles.push(stack.use(h));
}
const total = await Promise.all(
handles.map((h) => h.handle.stat())
);
return total.map((s) => s.size);
} // 出栈时,所有句柄按登记的逆序关闭
AsyncDisposableStack 有几个方法值得记:
use(obj):登记一个已经实现了 dispose 协议的对象,返回它本身。adopt(value, fn):登记一个没有 dispose 协议的普通值,并指定释放函数。适合包装第三方库返回的裸句柄。defer(fn):登记一个任意回调,等价于adopt的简化版。写日志、埋点计数用它最方便。move():把栈里已登记的资源转移到一个新栈,原栈立刻变为空。这是唯一能安全地把资源所有权交出去的方式。disposed:布尔属性,判断是否已经释放过。
举个 adopt 的实际用法。假设有个第三方库返回裸的文件描述符数字:
using stack = new DisposableStack();
const fd = stack.adopt(legacyLib.open(path), (d) => legacyLib.close(d));
stack.defer(() => metrics.inc('legacy_open_total'));
doSomething(fd);
关于 move(),这里有个容易翻车的点。using 声明的资源会在函数返回时被释放,所以你不能把 using 出来的对象直接 return 出去——调用方拿到的是一个已经关掉的东西。真要交出去,得这样:
function makeSession() {
using stack = new DisposableStack();
const a = stack.use(resourceA());
const b = stack.use(resourceB());
// 把所有权转移出来,原栈清空,出函数时不会释放
return stack.move();
}
// 调用方负责收尾
using session = makeSession();
五、环境支持和降级
这是最需要提前确认的一点,因为它直接决定了你敢不敢往生产代码里写。
规范的落地情况大概是:Node 24 上开箱可用;Node 20 / 22 需要启动时加 --harmony-explicit-resource-management 标志。浏览器那边 Chrome 已经跟进,Safari 和 Firefox 相对滞后。所以如果你的服务要跑在固定的旧 Node 版本上,先确认一遍。
比引擎支持更容易忽略的是构建工具。TypeScript 5.2 起支持 using 语法,但如果你的 tsconfig.json 里 target 设的是 ES2020 或更低,编译器会把它降级成等价的 try/finally 结构——语义是对的,但你就拿不到运行时的原生优化了,而且降级后产生的代码量比你手写的还多。想要原生行为,target 至少要到 ES2022,并且确保 Node 版本够新。
还有一个非常隐蔽的坑,跟构建无关,跟运行时有关:Symbol.dispose 在旧引擎里是 undefined。如果你的代码是运行时动态构建对象(比如 obj[Symbol.dispose] = fn,或者用计算属性名 [Symbol.dispose]() {...}),在旧引擎里这个键会退化成字符串 "undefined",对象上挂了一个毫无意义的属性,而 using 语句真正找的 symbol 键上是空的。如果同一份代码还要在旧环境跑(比如 SSR 里的多版本混部),建议加个显式探测:
const probe = { [Symbol.dispose]() {} };
const supportsUsing = (() => {
try {
return new Function('u', 'using x = u; return x === u;')(probe);
} catch {
return false;
}
})();
if (!supportsUsing) {
// 走降级分支,或者干脆在启动阶段 fail fast
throw new Error('当前 Node 版本不支持 using,请升级到 24+ 或添加启动标志');
}
在旧引擎里 new Function 会直接抛 SyntaxError,被 catch 吞掉返回 false。这段检测放在启动引导里,比上线后才发现某些容器镜像的 Node 版本不一致要省事得多。
六、几个我踩过的坑
坑一:只有 Symbol.asyncDispose 的对象不能用同步 using。 如果对象只实现了异步释放协议,你写 using x = obj,运行时会抛 TypeError,提示找不到可释放的方法。反过来是可以的:await using 遇到只有 Symbol.dispose 的对象,会正常调用它。所以拿不准的时候,用 await using 更保险,代价是即使同步释放也要等一个 microtask。
坑二:循环里的 using 每次迭代都会释放。 这通常是你想要的,但如果你希望所有迭代共用一批资源、最后统一释放,就写错了:
// 每个文件开一次、关一次,句柄不会堆积
for (const name of names) {
using h = await openRead(name);
await process(h);
}
// 全部开着,最后统一关
using stack = new AsyncDisposableStack();
for (const name of names) {
stack.use(await openRead(name));
}
批量场景下第二种写法的 FD 占用会飙升,如果文件多,可能直接撞上 ulimit。这不是 using 的问题,是用法选错了。
坑三:不要在 dispose 里做耗时操作。 dispose 是在离开作用域时同步串行执行的,如果里面塞了一个 3 秒的网络请求,整个函数的返回就被拖了 3 秒。我们最早把”上报埋点”放进了某个 dispose 里,结果 P99 直接涨了 400ms。后来改成扔进队列异步处理,dispose 只负责入队。
坑四:using 不能用在模块顶层。 它必须是块级作用域内的声明,不能在模块的顶层作用域声明(规范里禁止的原因是模块的求值顺序和生命周期不好定义)。写在模块顶层会直接是语法错误。如果需要模块级的单例资源,还是得用手动的 process.on('exit') 或者显式的 shutdown() 函数。
七、用测试锁住行为
重构完之后,光靠跑一遍业务不够,得写测试确认 dispose 真的被调用了、顺序真的对。Node 自带的 node:test 足够:
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('资源按逆序释放', async () => {
const order = [];
const make = (name) => ({
async [Symbol.asyncDispose]() {
order.push(name);
},
});
await (async () => {
await using a = make('a');
await using b = make('b');
await using c = make('c');
})();
assert.deepEqual(order, ['c', 'b', 'a']);
});
test('业务抛异常时资源仍然释放', async () => {
let disposed = false;
await assert.rejects(async () => {
await using r = {
async [Symbol.asyncDispose]() {
disposed = true;
},
};
throw new Error('boom');
}, /boom/);
assert.equal(disposed, true);
});
test('dispose 抛错不会顶掉业务异常', async () => {
await assert.rejects(async () => {
await using r = {
async [Symbol.asyncDispose]() {
throw new Error('dispose 失败');
},
};
throw new Error('业务失败');
}, (err) => {
assert.equal(err.name, 'SuppressedError');
assert.match(err.error.message, /业务失败/);
assert.match(err.suppressed.message, /dispose 失败/);
return true;
});
});
第二个用例特别值得留,因为它是”忘了在 finally 里清理”这类 bug 的回归测试。只要有人在重构时把 using 改成手动调用,或者误删了某一行,这个测试立刻变红。
八、什么时候不该用
前面夸了这么多,最后说点反向的。有三种情况我不建议用 using:
一是团队里有成员还在用不支持的编辑器/工具链。语法高亮和格式化跟不上,写起来会很别扭,代码审查也容易漏看。这时候先统一工具链。二是资源释放逻辑本身很复杂、有分支,比如”成功时提交、失败时回滚、超时时转人工队列”。这种逻辑塞进 dispose 会变得很不透明,放在显式的 finally 里反而更清楚。三是需要在多处共享同一个资源对象的场景,所有权不清晰的时候,using 只会让问题更隐蔽。
说到底,using 解决的是”作用域与资源生命周期一一对应”这一类问题,而且解决得干净利落。它不解决所有权归属、不解决跨请求的资源池管理、也不解决逻辑本身的分支复杂度。把这两件事分清,用起来就不会别扭。
如果你的代码里现在还有超过三层的 try/finally 嵌套,或者有一个 finally 块塞了七八行清理代码,从最外层的那个函数开始试着改。改完你会发现代码短了,更重要的是,你晚上睡觉的时候不用再担心”哪天有人调换了清理顺序”了。

