批量导入 1000 条对话的性能优化实战

本文面向:需要大规模导入对话、关注性能和稳定性的开发者。

预计阅读时间:10 分钟

最终效果:掌握从「导入会崩」到「1000 条对话 47 秒完成」的六个性能瓶颈定位与优化手法。


从一次「灾难性导入」说起

故事开始得很普通。我用了半年 Claude Code,积累了 1200 多条对话。装好 ChatCrystal,点击「全部导入」,然后------进程内存从 200 MB 飙到 1.8 GB,Electron 窗口冻结,最后 OOM 崩溃。

这不是 bug。这是 1000 条对话导入时,多个性能瓶颈叠加的必然结果。这篇文章记录了从「导入会崩」到「1000 条对话 47 秒导入完成」的完整优化过程。

瓶颈一:sql.js 的内存模型

ChatCrystal 使用 sql.js(SQLite 的 WASM 移植)作为数据库引擎。它的核心特点是整个数据库常驻内存

ini 复制代码
// server/src/db/index.ts
const buffer = readFileSync(DB_PATH);
db = new SQL.Database(buffer);

readFileSync.db 文件读成 Buffer,new SQL.Database(buffer) 在 WASM 内存中再复制一份。初始化时,内存中就有两份完整数据库。

1000 条 Claude Code 对话(平均每条 20 条消息),数据库文件约 80-120 MB。加上 WASM 副本,初始化就占 200+ MB。每 INSERT 一条对话,内存实时增长。这不是可以「优化掉」的开销,而是 sql.js 的架构约束。

结论:不要试图减少 sql.js 本身的内存占用,而是控制导入过程中的峰值。

瓶颈二:JSONL 解析器的隐藏陷阱

Claude Code 和 Codex 的对话存储为 JSONL 格式,ChatCrystal 使用 createReadStream + readline 逐行解析。这个方案本身是流式的,内存友好。但问题出在解析后的消息对象积累

原始实现中,parse() 方法会把一个 JSONL 文件的所有行都解析成消息数组,然后一次性返回:

ini 复制代码
// 优化前:一次性加载所有消息
async parse(meta: ConversationMeta): Promise<ParsedConversation> {
  const messages: Message[] = [];
  const stream = createReadStream(meta.filePath, { encoding: 'utf-8' });
  const rl = readline.createInterface({ input: stream });

  for await (const line of rl) {
    const entry = JSON.parse(line);
    // ... 过滤、清洗、转换
    messages.push(transformedMessage);
  }

  return { messages, ... };
}

一条 Claude Code 对话可能有 200+ 条消息(包括工具调用),每条消息的 JSON 对象包含完整内容。解析完所有行后,这些对象同时驻留在内存中,等待被写入数据库。

优化方案:边解析边写入,不在 parse 内部积累消息。

ini 复制代码
// 优化后:流式解析 + 逐条写入
async parseAndInsert(meta: ConversationMeta, db: Database): Promise<void> {
  const stream = createReadStream(meta.filePath, { encoding: 'utf-8' });
  const rl = readline.createInterface({ input: stream });
  let messageIndex = 0;

  for await (const line of rl) {
    const entry = JSON.parse(line);
    const message = transformMessage(entry);
    if (!message) continue;

    // 立即写入数据库,不积累在内存中
    insertMessage(db, meta.id, messageIndex, message);
    messageIndex++;

    // 主动释放引用,帮助 GC
    message.content = null;
  }
}

关键变化:每解析一行,立即 INSERT 到数据库,然后释放消息对象的引用。JavaScript 引擎的 GC 可以在下一个事件循环回收这些对象,内存峰值从「整个对话的所有消息」降低到「当前一条消息」。

实测:导入 1000 条对话,优化前峰值内存 1.2 GB,优化后峰值 380 MB。

瓶颈三:数据库自动保存的瞬间峰值

sql.js 的 saveDatabase() 调用 db.export(),生成一个 Uint8Array 包含完整数据库内容,然后写入磁盘。

scss 复制代码
// server/src/db/index.ts
export function saveDatabase(): void {
  const data = exportDatabasePreservingForeignKeys(db);
  writeFileSync(DB_PATH, Buffer.from(data));
}

exportDatabasePreservingForeignKeys() 包裹了 db.export(),在 try/finally 中确保 PRAGMA foreign_keys = ON 被恢复,避免导出过程中外键约束被意外关闭。

保存瞬间,内存中同时存在三份数据库:

  1. sql.js WASM 内存(常态)
  2. db.export()Uint8Array(短暂)
  3. Buffer.from(data) 的 Node.js Buffer(短暂)

对 120 MB 的数据库,保存时瞬时额外占用约 240 MB。如果自动保存恰好发生在导入过程中(30 秒间隔),叠加导入本身的内存使用,就可能触发 OOM。

优化方案:导入期间暂停自动保存,导入完成后再保存。

ChatCrystal 的数据库层提供了 startAutoSave()stopAutoSave() 两个函数:

javascript 复制代码
// server/src/db/index.ts
export function startAutoSave(intervalMs = 30_000): void {
  if (saveInterval) return;
  saveInterval = setInterval(() => saveDatabase(), intervalMs);
}

export function stopAutoSave(): void {
  if (saveInterval) {
    clearInterval(saveInterval);
    saveInterval = null;
  }
}

导入服务可以在开始前调用 stopAutoSave() 暂停定时保存,导入完成后调用 startAutoSave() 恢复并立即 saveDatabase() 一次。这样导入过程中不会有额外的内存峰值叠加。

瓶颈四:Cursor/Trae 适配器的双实例问题

Cursor 和 Trae 的数据存储在 SQLite 数据库(state.vscdb)中。解析时需要用 sql.js 打开这个文件,这意味着内存中同时存在两个 sql.js 实例:ChatCrystal 自己的数据库 + 源数据的 vscdb。

如果你的 Cursor 工作区积累了大量对话,vscdb 可能有 50+ MB。两个实例加起来,内存占用翻倍。

优化方案:解析完成后立即关闭源数据库实例。

csharp 复制代码
// server/src/parser/adapters/cursor.ts
async parse(meta: ConversationMeta): Promise<ParsedConversation> {
  const sourceDb = new SQL.Database(readFileSync(meta.filePath));
  try {
    // ... 查询、解析、返回
    const messages = extractMessages(sourceDb);
    return { messages, ... };
  } finally {
    sourceDb.close(); // 立即释放源数据库内存
  }
}

close() 会释放 WASM 内存中的数据库结构。配合前面的「边解析边写入」优化,可以确保同一时间只有一个额外的 sql.js 实例存在。

瓶颈五:任务队列的并发策略

导入完成后,每条对话会自动进入摘要队列。如果 1000 条对话同时入队,p-queue 虽然限制了 concurrency=1,但 TaskTracker 会为每个任务创建一个 TaskEntry 对象。1000 个对象本身不占多少内存,但前端通过 SSE 接收状态更新时,每秒推送 1000 个任务的状态列表,JSON 序列化会产生大量临时字符串。

优化方案:利用 p-queue 的限速机制,控制入队节奏。

ChatCrystal 使用 p-queue 作为任务队列,配置为 concurrency: 1intervalCap: 1interval: 1000(每秒最多 1 个任务)。入队函数 enqueueWithRetry 会将任务加入队列并自动处理 429 重试:

typescript 复制代码
// server/src/queue/index.ts
export const summarizeQueue = new PQueue({
  concurrency: 1,
  intervalCap: 1,
  interval: 1000,
  carryoverConcurrencyCount: true,
});

export async function enqueueWithRetry<T>(
  taskId: string,
  taskTitle: string,
  fn: () => Promise<T>,
  maxRetries = 3,
): Promise<T> {
  taskTracker.add(taskId, taskTitle);
  const result = await summarizeQueue.add(async () => {
    taskTracker.start(taskId);
    // ... 429 重试逻辑
  });
  return result as T;
}

由于 p-queue 的 intervalCap=1 限制,即使 1000 条对话瞬间入队,实际执行速率也被限制在每秒 1 个。TaskTracker 中同一时间只有正在执行和排队中的任务,SSE 推送的数据量天然受控。

瓶颈六:文件监听的防抖风暴

chokidar 监听所有数据源目录,文件变更时触发导入。当你在多个项目间快速切换时,Claude Code 可能在短时间内创建多个对话文件。3 秒防抖意味着这些文件会被一次性导入。

如果刚好你正在手动操作其他事情,突然涌入 50 个新文件需要导入,系统会卡顿。

优化方案:导入过程中对新文件变更做排队,不中断当前导入。

ini 复制代码
// server/src/watcher/index.ts
let isImporting = false;
let pendingImport = false;

async function runImport() {
  if (isImporting) {
    pendingImport = true;
    return;
  }
  isImporting = true;
  try {
    await importAll();
  } finally {
    isImporting = false;
    if (pendingImport) {
      pendingImport = false;
      setTimeout(runImport, 2000);
    }
  }
}

这个锁机制确保同一时间只有一个导入任务在执行。排队的导入会在当前导入完成后 2 秒触发,给系统一个喘息的时间。

优化前后的对比

指标 优化前 优化后
导入 1000 条对话耗时 崩溃(未完成) 47 秒
峰值内存占用 1.8 GB(OOM) 380 MB
自动保存时额外峰值 +240 MB 0(导入期间暂停)
摘要队列入队方式 瞬间 1000 条 p-queue 限速(1 req/s)
导入期间系统响应 冻结 流畅

实际操作建议

1. 先用 crystal status 评估规模。

bash 复制代码
crystal status
# 观察 conversations 数量和数据库大小

超过 500 条时,建议分批导入。

2. 分数据源导入。

bash 复制代码
# 先导入最重要的数据源
crystal import --source claude-code

# 等摘要队列消化完后再导入下一个
crystal import --source cursor

3. 监控内存和队列。

bash 复制代码
# 队列状态
crystal status

# 系统内存(Windows)
tasklist /fi "imagename eq node.exe" /fo list

4. 如果内存紧张,重启服务。

arduino 复制代码
crystal serve stop
crystal serve

sql.js 不会自动压缩内存。重启可以重置 WASM 内存状态。

5. Electron 用户留足内存。

Electron 版包含 Chromium 渲染进程 + Node.js 主进程。导入 1000 条对话时,建议系统至少有 4 GB 可用内存。

技术决策复盘

回头看这些优化,有几个共同的思路:

  • 不改变 sql.js 的架构约束,而是适应它。 sql.js 全内存模型是既定事实,优化空间在于控制「什么时候分配多少内存」,而不是试图让它按需加载。
  • 流式处理贯穿始终。 从 JSONL 解析到数据库写入,每一步都尽量避免积累中间数据。
  • 错峰执行。 自动保存和导入错开,摘要入队分批进行,减少资源竞争的窗口。
  • 锁机制防止并发。 isImporting 锁和 p-queue 的 concurrency=1 从不同层面保证了同一时间只有一个重操作在执行。

这些模式不只适用于 ChatCrystal。任何涉及「批量数据处理 + 有限内存」的场景,都可以参考类似的策略。


项目地址:github.com/ZengLiangYi...

如有疑问欢迎在 GitHub Issues 或私信交流,很乐意解答。

相关推荐
wei_shuo2 小时前
KES 数据库迁移实战:从 Oracle/MySQL 到 KingbaseES 的平滑过渡指南
后端
竹林8182 小时前
用 wagmi v2 + viem 监听合约事件时踩的坑,我花了两天才把"遗漏事件"修好
javascript
长栎2 小时前
Lombok @Builder 越用越爽,直到生产上构造函数的参数顺序全乱了
后端
长栎2 小时前
Spring 的 prototype scope 你用对了吗?原型模式的三个正确打开方式
后端
XovH2 小时前
MySQL 系列:第13篇 索引,不止是目录
后端
云技纵横2 小时前
Gap Lock 死锁实战:5 秒在本地复现 MySQL 间隙锁死锁
后端·mysql
XovH2 小时前
MySQL 系列:第12篇 用户、权限与安全基础
后端
张居邪2 小时前
GitHub Actions + 阿里云 OSS:OIDC 免密同步构建产物
后端·github
小花酱酱2 小时前
QQ群里只有你一个人?邪门歪道破局之路——AstrBot
javascript