导出别裸奔、导入别崩:vibe 项目的数据导入导出实战
导入是脏数据入口,导出是数据主权出口。本指南讲透导入三段式(暂存、校验预览、确认导入)、五类校验引擎、幂等 upsert、流式导出、异步队列加签名链接,以及十条反模式清单,附可直接复制的 TypeScript 代码。

引子:三个周一早上的真实翻车
先讲三个故事,全部来自真实的独立项目群聊记录,名字隐去。
故事一:导入把生产打挂了。某记账 SaaS 的作者给客户做了个「批量导入历史账单」功能,上线第一天,客户上传了一个 12 万行的 CSV。服务端用 fs.readFile 一次性读进内存,再 JSON.parse 式地逐行 insert。十分钟后,2GB 内存的 VPS 直接 OOM,进程重启,数据库里躺着 4 万多行半成品数据——导入了一半,失败了,也不知道哪些成功了。作者花了整个周末手写 SQL 删脏数据。
故事二:导出的 CSV 全是乱码。某 CRM 工具的作者加了个「导出客户列表」按钮,用户兴高采烈地点了,用 Excel 一打开——全部中文变方块。用户在评论区留下一句「连导出都做不好」,配了张截图。作者这才知道,Excel 打开不带 BOM 的 UTF-8 CSV 默认按系统本地编码解析。修复只用了一行代码:'\uFEFF'。但口碑的损失,一行代码补不回来。
故事三:续费前夜的灵魂拷问。某小团队工具的最大客户在续费前一周发来邮件:「我们明年可能换系统,请问支持把全部数据导出成 Excel 吗?」作者的回答是「暂时不支持」。客户没有再回复,续费也没再提。后来作者在复盘里写:「用户第一次认真看待你的产品,往往是从问能不能把数据拿走开始的。」
这三个故事指向同一个被严重低估的功能:数据导入导出。它不炫技、不上 Product Hunt、不值得发朋友圈,但在 B2B 和工具类产品里,它是续费、迁移、口碑的三重开关。今天这篇指南,把导入导出的实战打法一次讲透。
核心观点:先回答四个问题,再写一行代码
我的核心判断是:导入导出不是「文件上传下载」的变体,而是两个独立的产品功能——导入是「脏数据入口」,导出是「数据主权出口」。一个管进,一个管出,设计逻辑完全不同,千万别用同一套代码糊弄。
动笔之前,先回答四个问题。答案决定了后面所有的技术选型:
| 维度 | 问题 | 两种答案走向 |
|---|---|---|
| 谁在用 | 点按钮的是最终用户,还是你自己的运营? | 最终用户 → 强校验、防呆、模板;运营 → 可容忍粗糙,但要可审计 |
| 多大 | 典型文件多少行?峰值多少行? | < 1 万行 → 同步直做;> 5 万行 → 必须异步 + 流式 |
| 多快 | 用户能等几秒? | < 30 秒 → 同步;更长 → 队列 + 通知 |
| 多脏 | 用户上传的数据有多不靠谱? | 越脏 → 预览确认 + 错误回放越重要 |
记住一句口诀:导出先定「多大」,导入先定「多脏」。导出最大的坑是内存和超时,导入最大的坑是脏数据和重复提交。搞反了就会发现:给导出做了精美预览(没人看),给导入做了流式优化(数据还是脏的)。
第一部分:导出实战——把数据体面地交出去
3.1 格式选择:一张决策表终结纠结
vibe 项目最常见的纠结是「到底导出 CSV 还是 Excel」。我的建议:默认给 CSV,把 Excel 当付费功能,把 JSON 当开发者功能。理由很现实:CSV 生成成本最低、体积最小、任何系统都能吃;真正的 Excel(.xlsx)需要库、需要样式、需要处理合并单元格,成本高一个量级,适合放在 Pro 套餐里当差异化卖点。
| 格式 | 适合场景 | 成本 | 注意 |
|---|---|---|---|
| CSV | 默认导出、数据迁移、给别的系统吃 | 最低 | 必须加 BOM,否则 Excel 中文乱码 |
| XLSX | 要直接打印/汇报、带多 sheet | 高(exceljs 等库) | 适合做付费差异点 |
| JSON | 开发者、API 用户、备份 | 最低 | 字段名用英文 key,别用中文列名 |
| 发票、对账单等「定稿」文档 | 最高 | 这是排版问题,不是数据问题,单独做 |
3.2 第一行代码:BOM 与文件名
故事二的教训,浓缩成两行代码。CSV 内容前面拼上 \uFEFF(UTF-8 BOM),Excel 就会按 UTF-8 解析,中文不再乱码。文件名含中文时,Content-Disposition 要按 RFC 5987 编码,否则某些浏览器下载下来是 %E8%AE%A2%E5%8D%95.csv 这种:
// 导出 CSV 的最小正确姿势(Node.js)
const BOM = '\uFEFF';
const csv = BOM + rows.map(r => r.map(escapeCsvCell).join(',')).join('\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader(
'Content-Disposition',
`attachment; filename="export.csv"; filename*=UTF-8''${encodeURIComponent('订单列表.csv')}`
);
res.send(csv);
// 单元格转义:含逗号/引号/换行就包起来,引号翻倍
function escapeCsvCell(v: unknown): string {
const s = String(v ?? '');
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
注意 escapeCsvCell 不是可选项:用户填的备注里一定有逗号和换行,不转义就会串列。这是 CSV 导出里最高频的隐性 bug。
3.3 大导出的生死线:别把全表读进内存
这是导出侧最危险的坑。典型翻车代码长这样:const rows = await db.order.findMany() —— 50 万行数据一次性进内存,Node 进程直接去世。正确姿势是流式导出:数据库用游标分批取,HTTP 用流式写,内存里永远只留一小批。
import { Readable } from 'node:stream';
// 流式导出:内存占用恒定在 BATCH 大小,与总行数无关
async function streamExportCsv(res: Response, where: object) {
const BATCH = 1000;
let cursor: string | undefined;
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent('订单.csv')}`);
res.write('\uFEFF');
res.write('订单号,金额,下单时间\n'); // 表头只写一次
const source = new Readable({
async read() {
const batch = await db.order.findMany({ where, take: BATCH, ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}) });
if (batch.length === 0) { this.push(null); return; }
for (const o of batch) this.push(`${o.id},${o.amount},${o.createdAt.toISOString()}\n`);
cursor = batch[batch.length - 1].id;
},
});
source.pipe(res);
}
经验数字:1 万行以内可以直接同步导出;超过 5 万行,或者单次查询超过 10 秒,就必须走异步。中间地带(1~5 万行)看服务器内存,保守起见直接上流式,代码多不了几行。
3.4 异步导出:队列 + 签名链接 + 通知
当导出超过 30 秒还做不完,同步 HTTP 请求会被网关超时掐掉(Vercel/Cloudflare 默认 30~60 秒)。这时候要切换成异步模式,架构如下图:
异步导出的五个关键细节:
- 任务状态机:pending → processing → done/failed,用户可以在「导出记录」页看到进度,而不是对着转圈发呆。
- 文件落盘到对象存储(S3/R2),别放本地磁盘——多实例部署下本地文件是找不到的。
- 签名下载链接:用 presigned URL,有效期 24 小时。千万别把对象存储的桶设成公开可读,「图省事公开桶」是真实发生过的数据泄露事故。
- 完成后通知:站内信或邮件推一条「你的导出已就绪」,链接直接可点。用户不会守着页面等 20 分钟。
- 自动清理:导出文件 7 天后删除,并告诉用户有效期。磁盘和存储账单都是钱。
3.5 导出是越权的高发区
容易被忽视的一点:导出接口是水平越权的重灾区。列表页做了权限校验,导出接口往往是后加的,一个 GET /api/export/orders 忘记校验 where 里的租户条件,就能把全站数据拖出来。规则很简单:导出用的查询条件,必须和列表页用同一套权限过滤函数,不许手写第二套。
// 反模式:导出接口手写 where,漏了租户隔离
// const rows = await db.order.findMany({ where: { status: 'paid' } }); // 危险!
// 正模式:复用列表页的权限过滤
const where = buildOrderScope(req.user, req.query); // 同一函数,列表和导出共用
await streamExportCsv(res, where);
另外两个导出侧的安全项:手机号、身份证号等敏感字段导出时脱敏(中间四位打星),以及给导出加频率限制(每人每天 N 次),防止有人写脚本把你的库拖空。
第二部分:导入实战——把脏数据挡在门外
如果说导出是「体面地交出去」,导入就是「不体面地收进来」。用户上传的 Excel 里,日期可能是 2026/10/14 也可能是 45291(Excel 序列号),手机号里可能混着空格和横杠,同一批数据可能传三遍。导入功能的设计目标只有一个:让脏数据在进库之前现形,而不是进库之后爆炸。
4.1 三段式:上传 → 校验预览 → 确认导入
这是导入功能最重要的架构决策:永远不要「上传即导入」。正确流程是三段式,缺一段都是坑:
- 上传暂存:文件先存到临时区(对象存储或本地 temp),返回一个
uploadId,不碰业务表。 - 解析校验:服务端流式解析,对每一行跑校验规则,生成「N 行通过、M 行有问题」的预览报告。
- 预览确认:用户看到预览:通过的行数、有问题的行(行号 + 原因),点「确认导入」才真正写库。也可以选择「仅导入通过的行」。
为什么必须有预览?因为用户对自己数据的质量永远过度自信。没有预览,他会把 300 个错误怪到你头上;有了预览和错误回放,同样的 300 个错误,他会自己去修 Excel。这是从「你的 bug」到「他的数据问题」的分水岭。
4.2 先给模板,再谈校验
减少脏数据最便宜的手段,不是更强的校验,而是一个下载即用的模板文件。模板里包含:表头行、2~3 行填写示例、必填列标红、枚举列直接做好 Excel 数据验证下拉。实测经验:给了模板的项目,导入错误率能降一半以上。
关键细节:模板的列顺序和校验规则必须同一份代码生成。最常见的腐烂是:模板 v1 的列叫「手机号」,三个月后代码改成校验「手机」,老模板传上来全军覆没。做法是定义一份 IMPORT_SCHEMA,模板下载和行校验都从它派生:
// 一份 schema,两处使用:模板生成 + 行校验
const IMPORT_SCHEMA = [
{ key: 'name', header: '姓名', required: true },
{ key: 'phone', header: '手机号', required: true, pattern: /^1\d{10}$/ },
{ key: 'level', header: '等级', enum: ['普通', 'VIP', '黑卡'] },
{ key: 'joinedAt', header: '入会日期', type: 'date' },
] as const;
// 模板:表头 + 示例行
function buildTemplate() { /* 按 IMPORT_SCHEMA 生成 xlsx,略 */ }
// 校验:同一份 schema 逐行跑
function validateRow(row: Record<string, string>, lineNo: number) { /* 按 IMPORT_SCHEMA 校验,略 */ }
4.3 校验引擎:五类校验一张网
把校验分成五类,每类独立、可测试、可单独开关。顺序也有讲究:便宜的先跑,贵的后跑:
| 类别 | 校验内容 | 示例 | 成本 |
|---|---|---|---|
| 1. 格式 | 类型、正则、长度 | 手机号 11 位、邮箱格式 | 最低,先跑 |
| 2. 必填 | 非空、非纯空格 | 姓名不能为空 | 最低 |
| 3. 枚举/范围 | 值在白名单内、数字在区间内 | 等级只能是 普通/VIP/黑卡 | 低 |
| 4. 外键 | 关联的记录存在 | 门店编号在门店表里存在 | 中(查库,批量预加载) |
| 5. 重复 | 文件内重复、与库内重复 | 同一手机号在文件里出现两次 | 中 |
两个工程细节:外键校验不要逐行查库——先把文件里出现的所有门店编号收集起来,一次 IN 查询拉回集合,再逐行比对,N 次查询变 1 次。Excel 日期要特殊处理:单元格可能是 Date 对象、可能是 2026-10-14 字符串、可能是 45291 这样的序列号,三种都要能解析,否则「日期列全军覆没」是你一定会收到的工单。
校验推荐用 zod 这类 schema 库逐行跑,而不是手写一堆 if。手写校验是技术债的温床:三个月后没人敢动那 200 行 if。
4.4 错误回放:把行号还给用户
预览页的核心不是「通过了多少行」,而是「没通过的行到底怎么了」。错误回放要做到三件事:
- 精确到行号:错误信息必须带 Excel 里的真实行号(注意表头占第 1 行,数据从第 2 行开始,别 off-by-one)。
- 说人话:不要显示
ValidationError: phone pattern mismatch,要显示「第 8 行:手机号「138 1234 5678」格式不对,请去掉空格后重试」。 - 可下载错误报告:生成一个只有错误行的 CSV(原行数据 + 错误原因列),用户照着改完重新上传。这一步把客服工作量降了一个量级。
上限策略:错误超过 200 行就别全列了,显示前 200 条并提示「共 1342 行有问题,请下载错误报告查看全部」。预览页渲染 10 万条错误行会把浏览器卡死,这是前端也要配合的点。
4.5 幂等导入:允许用户手抖点两次
用户一定会重复提交:网络卡了多点一次、导入慢了刷新页面、同一个文件传三遍。没有幂等设计,库里就会出现三倍的数据。这是导入功能里最贵的 bug,因为事后去重几乎不可能——你分不清哪条是用户手填的、哪条是重复导入的。
幂等三件套:
- 业务键去重:每个导入场景定义一个天然的业务键(订单导入用订单号、会员导入用手机号),用
upsert而不是insert。重复导入同一批,第二次变成更新,不产生新行。 - 批次号:每次导入生成
importBatchId,所有行都带上。出了问题可以按批次整体回滚或排查,这是运营视角的「后悔药」。 - 文件指纹:对上传文件算 SHA256,同一用户 24 小时内上传相同指纹的文件,直接提示「该文件已导入过(批次 #1234),是否重新导入?」而不是默默再导一遍。
// 幂等导入核心:upsert + 批次号
await db.member.upsert({
where: { phone: row.phone }, // 业务键:手机号
update: { name: row.name, level: row.level, importBatchId: batchId },
create: { ...row, importBatchId: batchId },
});
4.6 批量与事务:失败要隔离
10 万行数据,不要开一个横跨 10 万次写入的大事务——锁表时间太长,失败回滚也慢。也不要一行一个事务——慢到不可接受。折中是分批提交:每 500~1000 行为一批,一个事务。某一批失败,只回滚这一批,记录断点,可重跑。
配合 4.5 的批次号,即使中途失败,已成功的批次也在库里标得清清楚楚,重跑时跳过已导入的业务键即可。这就是「断点续传」的平民实现,不需要 fancy 的基础设施。
4.7 大文件:流式解析,别 readFile
和导出侧对称,导入侧最大的坑也是一次性读文件。fs.readFile + 全量解析,100MB 的 Excel 能吃掉 1GB+ 内存。正确姿势:CSV 用流式解析(如 csv-parse 的流模式),Excel(.xlsx 本质是 zip 包)用流式读取库(如 exceljs 的流式读),边读边校验,内存占用恒定。
文件大小上限也要设:Nginx/网关层先卡(比如 50MB),应用层再卡一次,并给用户明确提示。超过上限的,引导他拆分成多个文件——这比你去支持 500MB 文件便宜得多。
第三部分:进度反馈——别让用户对着转圈猜
导入导出的等待时间是分钟级的,没有进度反馈,用户就会做最坏的假设:「卡死了」,然后刷新、重传、开工单。三档反馈策略:
| 时长 | 反馈方式 | 说明 |
|---|---|---|
| < 5 秒 | 按钮 loading + 禁止重复点击 | 同步完成即可 |
| 5 秒 ~ 2 分钟 | 进度条(SSE 推送真实进度) | 进度必须是真实的:已处理行数 / 总行数 |
| > 2 分钟 | 异步任务 + 完成后通知 | 用户可以关掉页面,好了通知他 |
重点批判一种反模式:假进度条。前端用 setInterval 慢慢涨到 90% 然后卡住,等后端真完成了再跳 100%。用户不傻,第二次他就再也不信你的进度条了。SSE 推送真实行数很难吗?导入循环里每 500 行推一次,总共十几行代码。
完成后通知走站内信即可,邮件是加分项。通知里带上结果摘要:「导入完成:9820 行成功,180 行失败,点此查看错误报告」——用户点进来就能闭环,不用来回找入口。
第四部分:十条反模式,条条都是学费
- 上传即导入:没有预览确认,脏数据直接进库,事后清洗成本是事前校验的 10 倍。
- 导出先查全表:
findMany()不带分页,数据量一上来就 OOM。 - CSV 不加 BOM:Excel 中文乱码,一行代码的教训换来一条差评。
- 错误只说「有错」:不给行号、不给原因、不给错误报告下载,等于把客服工作量留给自己。
- 无幂等设计:用户多点一次数据翻倍,且永远无法证明哪些是重复的。
- 导出接口不复用权限过滤:列表页有租户隔离,导出接口漏了,等于开了个拖库后门。
- 大任务同步做:超过网关超时时间还走同步请求,用户看到 504,你的任务其实还在跑,点一次跑一次。
- 模板和校验两张皮:模板更新了校验没更新(或反过来),用户按模板填的照样报错。
- 假进度条:透支用户信任,比没有进度条更糟。
- 导出文件永久有效:签名链接不设过期、文件不清理,既是安全隐患也是存储账单。
一页纸 Checklist:上线前逐项打勾
- □ 导出:1 万行以下同步、5 万行以上异步,中间用流式
- □ 导出:CSV 带 BOM,文件名按 RFC 5987 编码,单元格转义
- □ 导出:复用列表页权限过滤,敏感字段脱敏,加频率限制
- □ 导出:异步任务有状态机、签名链接 24h 过期、7 天自动清理、完成通知
- □ 导入:三段式(上传暂存 → 校验预览 → 确认导入),不许上传即导入
- □ 导入:模板与校验共用同一份 schema,模板含示例行
- □ 导入:五类校验(格式/必填/枚举/外键/重复),外键批量预加载,Excel 日期三形态兼容
- □ 导入:错误回放精确到行号、说人话、可下载错误报告
- □ 导入:业务键 upsert 幂等 + 批次号 + 文件指纹防重传
- □ 导入:分批提交(500~1000 行/事务),失败隔离可重跑
- □ 导入:流式解析大文件,网关 + 应用双层大小限制
- □ 进度:SSE 推真实进度,完成后通知带结果摘要
与站内已发指南的边界
站内已经有几篇容易混淆的指南,这里划清界限:事务邮件基础设施指南讲的是「邮件怎么发得可靠」,本文只把邮件当作导出完成的通知通道,不展开;数据备份指南讲的是「运维视角的全库备份与恢复」,本文讲的是「用户视角的自助导入导出」,一个是救命的一个是日常的;文件上传下载是另一个话题,本文的上传暂存只是导入流程的第一步,不展开讲分片上传和秒传。
最后回到开头那个问题:导入导出值不值得投入一周时间?我的答案是:如果你做的是工具类或 B2B 产品,值得,而且越早越好。它不会帮你获客,但会帮你留住那些认真用你产品的客户——而认真用你产品的客户,恰恰是最可能续费、也最可能把数据带走的那批人。把「拿走」这件事做得体面,他们反而更愿意留下来。
评论 (0)
相关文章

Web Push 看似半小时能跑通,上线后全是坑:iOS 收不到、VAPID 报 401、订阅表越养越肥。这篇代码续集带你走完完整链路——VAPID 密钥生成与保管、权限申请的最佳时机、Service Worker 推送接收与点击处理、服务端 web-push 实战、iOS 16.4 适配、410 过期订阅清理与排错清单,所有代码均可直接落地。

备份回答的是数据丢没丢,灾难恢复回答的是丢了之后多久能回来、回来多少。这篇实战指南覆盖 RPO/RTO 定级、五种故障场景矩阵、Postgres/Supabase/Neon/SQLite 四套可直接抄的 PITR 方案、每周自动恢复演练脚本、一页纸 runbook 模板与降级开关。

用户的 credits 不是一个余额字段,而是一本账。这篇指南为 AI SaaS 打造复式记账式积分账本:可直接抄的不可变流水表结构、幂等键 + 行锁的并发扣减、应对 token 用量不可预测的预授权/结算两阶段、退款与过期的回写规则、三方每日对账、负余额熔断,以及防薅清单。