登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

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 被识别,空白姓名和错误邮箱进入错误清单,而不是让整批导入在一个模糊的数据库报错处停住。

Go encoding/csv 读取客户 CSV,首列去除 UTF-8 BOM 并跳过空行后进入字段校验

先处理 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
    }
}

真实项目里,邮箱校验可以换成更严格的规则;但不要为了“校验很完整”引入一套与业务不匹配的正则。导入程序首先要能给出稳定、可解释的错误。

Go CSV 导入在字段校验后按 customer_no 分流,合法记录入库,重复记录进入处理分支

用 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 和统计字段一起调整,例如分别统计 insertedskippedupdated,这样导入结束后才知道文件实际发生了什么。

insertCustomer 是示例中的业务封装函数,内部调用数据库驱动的参数化写入方法;把这层封装留在项目里,后续切换“跳过重复”或“更新重复”时,事务循环和错误统计都不用重写。

常见问题:CSV 导入为什么看似成功却少了数据

为什么第一列会多出一串看不见的字符?

多数情况是 UTF-8 BOM。只对首行首列做 strings.TrimPrefix(value, "\ufeff"),不要把所有字段的内容都改写。

空行应该报错还是跳过?

纯空行通常可以跳过;如果一行只有部分字段,应该保留行号并进入错误清单,因为它可能代表被截断的记录。

只用 Go 的 map 去重够不够?

不够。map 只能覆盖当前文件,数据库唯一索引才能覆盖并发导入和重复重跑。

导入失败后可以直接重新上传吗?

先确认事务是否回滚、哪些批次已经提交,再根据批次号或导入指纹重跑。没有幂等设计时,直接重跑可能制造新的重复记录。

最后检查这四项,导入结果才算完成

  • 抽查第一列表头,确认 BOM 没有进入字段名。
  • 统计原始行、空行、错误行、合法行和重复行,数字能对上。
  • 查询 uk_customer_no,确认数据库中没有重复客户号。
  • 保留导入批次号和失败行号,确保同一文件可以安全复查或回滚。

CSV 导入的难点不在 for 循环,而在边界是否被记录清楚:输入怎么清洗、坏行怎么定位、重复如何裁决、失败怎样恢复。把这些状态显式化后,Go 的标准库就足够支撑一条小而稳的导入链路。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>