Go 读取 CSV 入库时如何处理 BOM、空行和重复记录?
来源:17golang原创
时间:2026-07-27 16:21:20 408浏览 收藏
客户名单从 Excel 导出成 CSV 后,最容易出问题的不是数据库连接,而是文件第一列带着不可见的 UTF-8 BOM、末尾混着空行,或者同一个客户在文件里出现两次。Go 的 encoding/csv 能稳定读取这些行,但“读出来”不等于“可以入库”:要在写入 customer_import 前完成字段清洗、列数校验和重复策略确认。
一套可靠的 CSV 导入流程应该把输入行变成“可定位、可校验、可回滚”的记录:首行去 BOM,空行跳过,坏行记录行号,重复客户交给唯一键和明确的业务策略处理。
- 只在第一列第一次读取时移除 UTF-8 BOM,不要对整行做粗暴替换。
- 先检查列数和必填字段,再做数据库写入,错误行保留 CSV 行号。
customer_no建唯一索引,重复记录选择跳过或更新,不能静默覆盖。- 导入使用事务和分批提交,失败时能回滚并输出最后处理行。
一行 CSV 到一条数据库记录,中间缺了哪些环节
把 CSV 当成“按逗号切字符串”会很快踩坑。字段里可能有逗号,双引号还会改变字段边界;所以读取层交给 csv.Reader,业务层再负责规范化。本文示例使用这样的文件:
customer_no,name,email
C001,"上海,一号店",shop@example.com
C001,一号店,shop@example.com
C002, ,bad-email
最终要得到的是:合法行进入 customer_import,重复的 C001 被识别,空白姓名和错误邮箱进入错误清单,而不是让整批导入在一个模糊的数据库报错处停住。

先处理 BOM、空行和列数,再谈字段校验
UTF-8 BOM 通常只出现在文件开头。它会让第一列表头变成 \ufeffcustomer_no,如果代码按表头查找,就会误以为文件缺少 customer_no。最稳妥的办法是在第一行的第一个字段上做一次性处理,同时打开 FieldsPerRecord 让列数错误尽早暴露。
func readRows(r io.Reader) ([][]string, error) {
cr := csv.NewReader(bufio.NewReader(r))
cr.FieldsPerRecord = 3
cr.TrimLeadingSpace = true
header, err := cr.Read()
if err != nil {
return nil, fmt.Errorf("read header: %w", err)
}
if len(header) > 0 {
header[0] = strings.TrimPrefix(header[0], "\ufeff")
}
if !reflect.DeepEqual(header, []string{"customer_no", "name", "email"}) {
return nil, fmt.Errorf("unexpected header: %v", header)
}
var rows [][]string
for {
row, err := cr.Read()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return rows, fmt.Errorf("read line %d: %w", cr.InputOffset(), err)
}
if allBlank(row) {
continue
}
rows = append(rows, row)
}
return rows, nil
}
func allBlank(row []string) bool {
for _, value := range row {
if strings.TrimSpace(value) != "" {
return false
}
}
return true
}
这里的 TrimLeadingSpace 只处理未被引号保护的字段前空格,不能代替业务清洗。比如姓名要用 strings.TrimSpace,邮箱还需要单独验证。
把规范化和错误行保存下来,导入才可复查
建议把“原始行号”一直带到校验结果里。不要只返回一个 invalid data,否则运营人员无法在原 CSV 中定位。规范化函数可以只做确定性动作:去掉首尾空白、把空字符串识别为缺失、统一邮箱的小写。
type CustomerRow struct {
Line int
CustomerNo string
Name string
Email string
}
type RowError struct {
Line int
Reason string
}
func normalize(line int, row []string) (CustomerRow, *RowError) {
item := CustomerRow{
Line: line,
CustomerNo: strings.TrimSpace(row[0]),
Name: strings.TrimSpace(row[1]),
Email: strings.ToLower(strings.TrimSpace(row[2])),
}
switch {
case item.CustomerNo == "":
return item, &RowError{line, "customer_no 为空"}
case item.Name == "":
return item, &RowError{line, "name 为空"}
case !strings.Contains(item.Email, "@"):
return item, &RowError{line, "email 格式不正确"}
default:
return item, nil
}
}
真实项目里,邮箱校验可以换成更严格的规则;但不要为了“校验很完整”引入一套与业务不匹配的正则。导入程序首先要能给出稳定、可解释的错误。

用 customer_no 唯一键兜住重复记录
内存里的 map[string]struct{} 可以发现同一文件内的重复,但它看不到数据库中已经存在的客户。最终边界必须由数据库唯一索引承担:
CREATE TABLE customer_import (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
customer_no VARCHAR(32) NOT NULL,
name VARCHAR(120) NOT NULL,
email VARCHAR(160) NOT NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY uk_customer_no (customer_no)
);
重复策略要先问清楚业务。名单是“首次导入快照”时,重复行可以记录为 skipped;客户资料允许同步时,可以使用数据库方言提供的 upsert。不要把重复当成异常直接吞掉,也不要默认用最后一行覆盖前一行。
| 重复位置 | 建议处理 | 核对方式 |
|---|---|---|
| 同一 CSV 内 | 保留首条并记录行号,或合并前先报错 | map 计数 |
| CSV 与数据库之间 | 唯一键拦截,再按业务选择跳过/更新 | 受影响行数 |
| 重跑同一批文件 | 批次号或导入指纹幂等 | import_batch 表 |
事务和分批提交:让失败可回滚、进度可追踪
小文件可以一个事务完成;较大的客户名单适合每 500 或 1000 行提交一次。每批提交前记录起止行号,遇到数据库错误时回滚当前批次,并把批次号、最后成功行和错误信息写入日志。这里别急着把提交粒度调得很大,事务越大,锁和重试成本也越难控制。
tx, err := db.BeginTx(ctx, nil)
if err != nil { return err }
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO customer_import (customer_no, name, email, created_at)
VALUES (?, ?, ?, NOW())`)
if err != nil { return err }
defer stmt.Close()
for _, item := range validRows {
if err := insertCustomer(ctx, stmt, item); err != nil {
return fmt.Errorf("line %d: insert customer_no=%s: %w", item.Line, item.CustomerNo, err)
}
}
return tx.Commit()
上面的示例展示的是“重复即失败”的保守策略。若选择跳过或更新,应把 SQL 和统计字段一起调整,例如分别统计 inserted、skipped、updated,这样导入结束后才知道文件实际发生了什么。
insertCustomer 是示例中的业务封装函数,内部调用数据库驱动的参数化写入方法;把这层封装留在项目里,后续切换“跳过重复”或“更新重复”时,事务循环和错误统计都不用重写。
常见问题:CSV 导入为什么看似成功却少了数据
为什么第一列会多出一串看不见的字符?
多数情况是 UTF-8 BOM。只对首行首列做 strings.TrimPrefix(value, "\ufeff"),不要把所有字段的内容都改写。
空行应该报错还是跳过?
纯空行通常可以跳过;如果一行只有部分字段,应该保留行号并进入错误清单,因为它可能代表被截断的记录。
只用 Go 的 map 去重够不够?
不够。map 只能覆盖当前文件,数据库唯一索引才能覆盖并发导入和重复重跑。
导入失败后可以直接重新上传吗?
先确认事务是否回滚、哪些批次已经提交,再根据批次号或导入指纹重跑。没有幂等设计时,直接重跑可能制造新的重复记录。
最后检查这四项,导入结果才算完成
- 抽查第一列表头,确认 BOM 没有进入字段名。
- 统计原始行、空行、错误行、合法行和重复行,数字能对上。
- 查询
uk_customer_no,确认数据库中没有重复客户号。 - 保留导入批次号和失败行号,确保同一文件可以安全复查或回滚。
CSV 导入的难点不在 for 循环,而在边界是否被记录清楚:输入怎么清洗、坏行怎么定位、重复如何裁决、失败怎样恢复。把这些状态显式化后,Go 的标准库就足够支撑一条小而稳的导入链路。
-
502 收藏
-
502 收藏
-
Golang · Go问答 | 3星期前 | go · 性能 · bufio · 日志处理 · 错误排查 · 分块读取 Go bufio.Scanner token too long Scanner.Buffer 大日志行501 收藏
-
501 收藏
-
501 收藏
-
226 收藏
-
Golang · Go问答 | 5天前 | 错误处理 · go · 性能 · bytes.Buffer · Go 1.26 · io.EOF 版本迁移 Go 1.26 bytes.Buffer.Peek 缓冲区预览428 收藏
-
488 收藏
-
160 收藏
-
158 收藏
-
Golang · Go问答 | 5天前 | golang · 连接池 · database/sql · Go问答 · 数据库事务 · 连接池 事务 DBStats rows.Close Go database/sql374 收藏
-
271 收藏
-
Golang · Go问答 | 6天前 | golang · 错误处理 · 泛型 · Go问答 · Go 1.26 · errors.As Go问答 Go 1.26 errors.AsType 泛型错误处理255 收藏
-
187 收藏
-
382 收藏
-
158 收藏
-
279 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习