← Buffer.poolSize keyObject.export() 密钥导出 →

Node.js fs.ftruncateSync():文件描述符级截断的工程实践

著
原创 2026-10-18 Node.js 已有人查阅

一、它到底解决什么问题

fs.truncateSync(path,len)通过路径操作文件,而fs.ftruncateSync(fd,len)通过已打开的文件描述符操作。这一区别在这些场景中产生实质影响:

  • 文件已经以r+模式打开,持有fd,后续还要继续读写,不想反复开关文件;

  • 需要在同一个文件句柄上完成“写入→回退长度→重写”的循环,避免路径竞争;

  • 对临时文件先写入内容,处理后需要清空但保留句柄以便复用。

官方文档将fs.ftruncateSync(fd[,len])描述为ftruncate(2)的同步封装,len默认值为0。POSIX语义下,若len小于当前文件长度,尾部数据被丢弃;若len大于当前长度,文件被扩展,新增区域填充\0。

二、参数与返回值的工程含义

fd:必须是可写描述符

fd来自fs.openSync()或fs.open(),且打开模式必须包含写权限。用'r'模式拿到的只读描述符调用ftruncateSync,会在POSIX层触发EBADF(Badfiledescriptor)。推荐以'r+'打开需要原地截断的文件。

len:可选,默认0

len是截断后的目标字节数,不是“截掉多少字节”。len=0等价于清空内容但保留文件本身。传入浮点数时,Node.js会在内部做取整处理,但建议始终传整数以避免歧义。

返回值

返回undefined。出错时抛出Error,需要try/catch捕获。同步方法不会通过回调返回错误,这是与异步版本fs.ftruncate(fd,len,callback)的核心区别。

三、与fs.truncateSync()的职责分界

维度 fs.ftruncateSync(fd,len) fs.truncateSync(path,len)
操作对象 已打开的文件描述符 文件路径
是否需要先打开 是 否
路径竞争风险 低(句柄已绑定) 存在(路径可能被替换)
文件权限依赖 取决于fd的打开模式 取决于进程对路径的权限
典型场景 已持有句柄时的长度调整 一次性截断未知文件

一个容易被忽略的点:向fs.truncateSync()传入文件描述符已被标记为弃用,未来可能抛出错误。新代码中如果需要基于fd截断,应直接使用fs.ftruncateSync()。

四、真实单元测试:边界与异常

以下测试代码覆盖了正常截断、扩展填充、负值边界和无效描述符四种情形。运行环境为Node.js20.x,Linuxx64。

const fs = require('fs');
const assert = require('assert');
const path = require('path');
const os = require('os');

const tmpFile = path.join(os.tmpdir(), `ftruncate_test_${Date.now()}.txt`);

// 场景1:截断到更短长度
fs.writeFileSync(tmpFile, 'ABCDEFGHIJ');
let fd = fs.openSync(tmpFile, 'r+');
fs.ftruncateSync(fd, 4);
assert.strictEqual(fs.readFileSync(tmpFile, 'utf8'), 'ABCD');
fs.closeSync(fd);

// 场景2:扩展到更长长度,检查零填充
fs.writeFileSync(tmpFile, 'AB');
fd = fs.openSync(tmpFile, 'r+');
fs.ftruncateSync(fd, 5);
const buf = fs.readFileSync(tmpFile);
assert.strictEqual(buf.length, 5);
assert.strictEqual(buf[2], 0x00);
assert.strictEqual(buf[4], 0x00);
fs.closeSync(fd);

// 场景3:负值边界(Node.js 行为与 POSIX 存在偏差,见下文)
fs.writeFileSync(tmpFile, 'HELLO');
fd = fs.openSync(tmpFile, 'r+');
try {
  fs.ftruncateSync(fd, -1);
  // 部分 Node.js 版本不会抛出,而是静默视为 0
  const size = fs.fstatSync(fd).size;
  console.log(`negative len 处理后 size = ${size}`);
} catch (e) {
  console.log(`negative len 抛出: ${e.code}`);
}
fs.closeSync(fd);

// 场景4:无效描述符
try {
  fs.ftruncateSync(99999, 10);
} catch (e) {
  assert.strictEqual(e.code, 'EBADF');
  console.log('无效 fd 正确抛出 EBADF');
}

fs.unlinkSync(tmpFile);

关于负值,Node.js历史上存在一个已知问题:ftruncateSync对负偏移量可能不抛出EINVAL,而是静默按0处理。在依赖严格POSIX语义的跨平台代码中,应当在调用前自行校验len>=0。

五、常见错误码与异常处理

错误码 触发条件 处理方式
EBADF fd无效、已关闭,或打开模式不含写权限 检查openSync的flag是否为'r+'或'w+'
EINVAL len为负数(部分版本/平台) 调用前做Number.isInteger(len)&&len>=0校验
ENOSPC 扩展文件时磁盘空间不足 捕获后记录日志,回退到不扩展的策略
EPERM/EACCES 文件权限不允许截断 检查进程对文件的所有权或目录权限

同步方法在抛出错误时没有回调可以兜底,因此生产代码中建议用一层包装函数统一处理:

function safeFtruncateSync(fd, len) {
  if (!Number.isInteger(len) || len < 0) {
    throw new RangeError(`len 必须为非负整数,收到: ${len}`);
  }
  try {
    fs.ftruncateSync(fd, len);
  } catch (err) {
    if (err.code === 'EBADF') {
      throw new Error('文件描述符不可写或已关闭,请确认以 r+ 模式打开');
    }
    throw err;
  }
}

六、性能特征:同步的代价与文件系统差异

fs.ftruncateSync()阻塞事件循环,直到系统调用返回。在本地SSD上,截断本身极快——对2GB文件执行ftruncateSync扩展的耗时通常在毫秒级。真正的性能不在截断操作本身,而在截断后写入的行为:

在WindowsNTFS上,对扩展区域进行稀疏写入时,首次写入特定偏移位置可能触发数秒级延迟(文件系统元数据开销),而相同操作在Linuxext4上通常在亚毫秒级完成。如果项目需要跨平台处理大文件预分配,不能假设ftruncateSync扩展后的写入性能与平台无关。

与fs.writeFileSync的对比:在“反复覆盖同一文件、每次内容长度可能缩短”的场景中,复用fd配合writeSync+ftruncateSync可以避免每次重新打开文件的开销。但收益取决于写入频率和文件系统缓存策略,并非在所有场景下都显著。

七、一个被低估的场景:基于句柄的日志截断

日志文件持续增长时,常见做法是用外部工具轮转。但在某些嵌入式或单进程守护场景中,需要程序自己保留“最近N字节”。使用ftruncateSync配合fstatSync可以在不关闭日志句柄的前提下完成:

function trimLogFile(fd, maxBytes = 64 * 1024) {
  const { size } = fs.fstatSync(fd);
  if (size <= maxBytes) return false;
  
  // 保留尾部 maxBytes 字节:先定位读取尾部内容,再重写
  const keepBuf = Buffer.alloc(maxBytes);
  fs.readSync(fd, keepBuf, 0, maxBytes, size - maxBytes);
  
  // 回到开头写入保留内容,然后截断
  fs.writeSync(fd, keepBuf, 0, maxBytes, 0);
  fs.ftruncateSync(fd, maxBytes);
  return true;
}

这里的关键是:ftruncateSync只负责“设定最终长度”,内容的搬移需要先用readSync/writeSync完成。如果直接截断到maxBytes而不搬移,保留的是文件开头的字节,而非最近条目。很多日志轮转实现会忽略这一区别。

八、常见误区速查

误区一:ftruncateSync可以“删除中间某段内容”。不能。截断只影响len之后的部分。要删除中间数据,必须手动重写。

误区二:len超过文件当前大小时会“浪费磁盘空间”。在多数现在文件系统上,ftruncate扩展创建的是稀疏文件或零填充区域,物理磁盘占用不一定立即增加,但逻辑大小会变。写入扩展区域时才按需分配块。

误区三:同步方法在脚本中总是比异步方便,所以随便用。在HTTP服务或任何事件驱动进程中,对较大文件调用ftruncateSync会阻塞所有并发请求的处理。如果截断目标文件在远程挂载(NFS)上,阻塞时间可能达到数十毫秒以上。这类场景应优先考虑fs.promises.ftruncate。

误区四:打开文件用'w'模式再截断是多余的。'w'模式打开时已经将文件清空,再调用ftruncateSync属于重复操作。需要保留原内容做长度调整时,必须用'r+'。

九、练习与思考

  1. 编写一个函数,接收fd和目标长度,在截断前先校验fd是否可写(提示:可以尝试fs.fstatSync(fd)配合一次微小的writeSync,但要注意副作用)。

  2. 在一个包含10万行JSON日志的文件中,用ftruncateSync实现“只保留之后500行”的功能。思考:需要先知道每行的字节长度吗?如果需要精确到行而非字节,ftruncateSync是否足够?

  3. 对比fs.ftruncateSync(fd,0)和fs.closeSync(fd)后fs.unlinkSync(path)的语义差异。什么场景下应该清空内容而不是删除文件?

十、延伸阅读与参考文献

Node.js官方文档—fs.ftruncateSync(fd[,len])
https://nodejs.org/api/fs.html#fsftruncatesyncfd-len
最权威的参数说明与版本变更记录。重点关注len默认值为0以及同步方法抛出异常的约定。

POSIXftruncate(2)手册页(Linuxman-pages)
https://man7.org/linux/man-pages/man2/ftruncate.2.html
Node.js该方法直接映射的系统调用语义来源。负值应返回EINVAL的规范出处,可用于理解Node.js在某些版本中行为偏差的参照。

Node.jsGitHubIssue#35632—fs.ftruncatesilentlyacceptsnegativeoffsets
https://github.com/nodejs/node/issues/35632
记录了负值len被静默当作0处理的实际案例。如果你编写跨平台文件工具,这个issue解释了为什么不能依赖Node.js自动抛出EINVAL。

StackOverflow—Mostperformantoverwritingfilemultipletimes
https://stackoverflow.com/questions/57834114
讨论了复用fd+ftruncateSync与反复writeFileSync的性能取舍,包含实际基准测试思路。适合需要做覆盖写优化的场景参考。

← Buffer.poolSize keyObject.export() 密钥导出 →
分享笔记 (共有 篇笔记)
验证码:
微信公众号