利用 FinalizationRegistry 自动回收 CRDT 中被删除的历史操作节点
大家好,欢迎来到今天的讲座。今天我们来探讨一个在分布式系统和协同编辑领域非常重要的主题:如何利用 JavaScript 的 FinalizationRegistry 自动回收 CRDT(冲突自由的复制数据类型)中被删除的历史操作节点。
如果你正在构建类似 Google Docs、Notion 或协作白板这样的实时协同应用,那么你一定遇到过这样一个问题:
“我的 CRDT 数据结构越来越大,因为历史操作节点永远不会被释放,导致内存占用持续增长。”
这个问题看似简单,实则复杂。它不仅关系到性能优化,还涉及垃圾回收机制的理解、对象生命周期管理以及现代 JavaScript 特性的合理使用。
一、什么是 CRDT?为什么我们需要回收它的历史节点?
CRDT(Conflict-Free Replicated Data Type)是一种可以在多个副本之间同步且无需协调就能保持一致的数据结构。常见的 CRDT 包括 G-Set、OR-Set、LWW-Register 等。
在实际应用中,比如多人在线编辑文档时,每一条编辑操作(插入、删除、修改)都会被记录为一个“操作节点”,并作为 CRDT 的一部分传播给所有客户端。这些节点构成了整个变更历史。
举个例子:
class OperationNode {
constructor(id, type, payload) {
this.id = id;
this.type = type; // 'insert', 'delete'
this.payload = payload;
this.timestamp = Date.now();
}
}
// 假设这是你的 CRDT 存储器
class CRDT {
constructor() {
this.operations = new Map(); // 所有操作节点
this.deleted = new Set(); // 已标记删除的操作 ID
}
addOperation(op) {
this.operations.set(op.id, op);
}
deleteOperation(id) {
this.deleted.add(id);
// 注意:这里只是标记删除,并没有真正释放内存!
}
getActiveOperations() {
return Array.from(this.operations.values()).filter(op => !this.deleted.has(op.id));
}
}
在这个模型中,即使某个操作被标记为“已删除”(比如用户撤销了一次删除),它仍然存在于 operations Map 中,不会被 GC 回收 —— 这就是内存泄漏的根源!
二、传统解决方案的问题:手动清理 vs. 惰性回收
方法 1:定时扫描 + 清理
你可以写一个定时器定期检查哪些操作已经不再需要,并手动删除它们:
setInterval(() => {
const now = Date.now();
for (const [id, op] of crdt.operations.entries()) {
if (crdt.deleted.has(id) && op.timestamp < now - 86400000) { // 超过一天
crdt.operations.delete(id);
}
}
}, 30 * 60 * 1000); // 每半小时执行一次
✅ 优点:可控性强
❌ 缺点:
- 需要维护额外逻辑;
- 可能漏掉某些边缘情况;
- 对于高频操作场景(如打字),可能频繁触发清理,影响性能。
方法 2:弱引用 + 手动通知
使用 WeakMap 来追踪操作是否还在被引用,但这无法解决“被标记删除但未被清除”的问题。
三、终极方案:利用 FinalizationRegistry 实现自动回收
什么是 FinalizationRegistry?
这是 ECMAScript 提案中的一个新特性(ES2021+),允许你在对象即将被垃圾回收时注册回调函数。它是实现“资源自动释放”的理想工具。
语法如下:
const registry = new FinalizationRegistry((heldValue) => {
console.log(`对象 ${heldValue} 即将被回收`);
});
⚠️ 注意:这不是强制回收,而是“当对象被 GC 触发时通知你”。这意味着你必须确保该对象是唯一的、无外部引用的,否则不会触发回调。
应用于 CRDT:为每个操作节点绑定 FinalizationRegistry 回调
我们改造之前的 CRDT 类,让它支持自动回收:
class CRDT {
constructor() {
this.operations = new Map();
this.deleted = new Set();
// 创建 FinalizationRegistry 实例
this.finalizer = new FinalizationRegistry((opId) => {
console.log(`[GC] 操作节点 ${opId} 被自动回收`);
this.operations.delete(opId);
});
}
addOperation(op) {
this.operations.set(op.id, op);
// 绑定 finalizer,只要这个 op 不再被任何地方持有,就会触发回收
this.finalizer.register(op, op.id);
}
deleteOperation(id) {
this.deleted.add(id);
// 如果此时还有其他引用(比如被某个模块缓存),就不会触发回收
// 所以我们要做的是:让这个操作节点变得“不可达”
}
getActiveOperations() {
return Array.from(this.operations.values()).filter(op => !this.deleted.has(op.id));
}
}
💡 关键点解析:
| 步骤 | 描述 |
|---|---|
addOperation |
将操作节点注册到 finalizer,传入其唯一 ID |
deleteOperation |
标记为删除,但不立即删除;等待 JS 引擎判断该对象是否可回收 |
finalizer.register() |
注册回调,一旦对象被 GC,会调用回调并删除对应 entry |
示例:模拟一个典型场景
const crdt = new CRDT();
// 添加三个操作
crdt.addOperation(new OperationNode('a', 'insert', 'hello'));
crdt.addOperation(new OperationNode('b', 'delete', 'world'));
crdt.addOperation(new OperationNode('c', 'insert', '!'));
console.log("初始操作数:", crdt.operations.size); // 输出: 3
// 删除操作 b
crdt.deleteOperation('b');
console.log("删除后操作数:", crdt.operations.size); // 仍为 3,因为未被 GC
// 此时如果没有任何变量持有这些操作节点(比如全局引用没了)
// 在下一次 GC 循环中,'b' 对象会被自动回收,触发回调删除 map entry
📌 关键洞察:
只有当一个操作节点既不在 operations 中(已被标记删除),又没有任何外部引用时,它才会进入 FinalizationRegistry 的触发条件。
这正是我们想要的效果:自动、安全、低侵入地回收无用节点。
四、性能对比与实践建议
让我们通过表格比较三种方法:
| 方案 | 内存效率 | 开发成本 | 性能开销 | 可靠性 | 推荐场景 |
|---|---|---|---|---|---|
| 定时扫描 | ⭐⭐☆ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ | 中小型项目,控制精确 |
| 弱引用手动清理 | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ | ⭐⭐ | 复杂状态管理 |
| FinalizationRegistry | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | 生产级 CRDT,高并发场景 |
✅ FinalizationRegistry 的优势:
- 无需主动轮询;
- 不依赖时间窗口;
- 自动感知对象生命周期;
- 最接近“零配置”的自动回收机制。
🚫 注意事项:
FinalizationRegistry是实验性 API,需确认运行环境支持(Node.js ≥ 16 / Chrome ≥ 90);- 回调不是即时执行,可能延迟几秒甚至几分钟;
- 不适用于强一致性要求的场景(比如事务回滚);
- 若存在循环引用或闭包持有,则不会触发回收。
五、进阶技巧:结合 WeakRef 实现更精细控制
为了进一步提升可靠性,我们可以结合 WeakRef 和 FinalizationRegistry,实现“软删除 + 自动清理”的模式:
class CRDT {
constructor() {
this.operations = new Map();
this.deleted = new Set();
this.finalizer = new FinalizationRegistry((weakRef) => {
const op = weakRef.deref();
if (op) {
console.log(`[GC] 软删除节点 ${op.id} 已被彻底移除`);
this.operations.delete(op.id);
}
});
}
addOperation(op) {
this.operations.set(op.id, op);
const weakRef = new WeakRef(op);
this.finalizer.register(weakRef, weakRef);
}
deleteOperation(id) {
this.deleted.add(id);
// 不做立即删除,等 GC 自动处理
}
}
这种方式更加健壮,因为它可以检测到 op 是否真的不存在了(通过 deref() 返回 null 表示已被回收)。
六、总结:为何这个方案值得推广?
- ✅ 内存友好:避免长期积累无用操作节点;
- ✅ 代码简洁:只需一行注册逻辑,无需额外调度;
- ✅ 适合大规模协同系统:如多端同步、版本控制、审计日志;
- ✅ 符合现代 JS 最佳实践:充分利用语言特性进行资源管理。
🔍 思考题:如果你的 CRDT 支持版本快照(snapshot),是否也可以用同样的思路回收旧版本?答案是肯定的 —— 只要确保快照对象不再被引用即可。
七、附录:完整可运行示例(Node.js)
// demo.js
class OperationNode {
constructor(id, type, payload) {
this.id = id;
this.type = type;
this.payload = payload;
}
}
class CRDT {
constructor() {
this.operations = new Map();
this.deleted = new Set();
this.finalizer = new FinalizationRegistry((opId) => {
console.log(`[GC] 操作节点 ${opId} 被自动回收`);
this.operations.delete(opId);
});
}
addOperation(op) {
this.operations.set(op.id, op);
this.finalizer.register(op, op.id);
}
deleteOperation(id) {
this.deleted.add(id);
}
getActiveOperations() {
return Array.from(this.operations.values()).filter(op => !this.deleted.has(op.id));
}
}
// 测试
const crdt = new CRDT();
crdt.addOperation(new OperationNode('a', 'insert', 'Hello'));
crdt.addOperation(new OperationNode('b', 'delete', 'World'));
console.log('初始:', crdt.getActiveOperations().length); // 2
crdt.deleteOperation('b');
console.log('删除后:', crdt.getActiveOperations().length); // 1
// 手动触发 GC(仅 Node.js 支持)
if (global.gc) global.gc();
setTimeout(() => {
console.log('最终:', crdt.getActiveOperations().length); // 应该是 1
}, 5000);
运行方式:
node --expose-gc demo.js
你会看到 [GC] 操作节点 b 被自动回收 的输出,说明机制生效!
结语
今天我们深入探讨了如何利用 FinalizationRegistry 实现 CRDT 中历史操作节点的自动化内存回收。这不是一个简单的技术细节,而是一个关乎系统可扩展性和稳定性的核心设计决策。
在未来的分布式协同系统中,这种轻量级、无侵入式的资源管理方式将成为标配。希望今天的讲解让你对 JS 垃圾回收机制有了更深理解,也为你今后构建高性能、可维护的 CRDT 系统提供了实用参考。
谢谢大家!