本文面向:需要大规模导入对话、关注性能和稳定性的开发者。
预计阅读时间: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 被恢复,避免导出过程中外键约束被意外关闭。
保存瞬间,内存中同时存在三份数据库:
- sql.js WASM 内存(常态)
db.export()的Uint8Array(短暂)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: 1、intervalCap: 1、interval: 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 或私信交流,很乐意解答。