1. CSV 没你想的那么简单
一句话总结: CSV 不是「逗号分隔的文本」,而是有引号、转义、换行嵌入等规则的结构化格式,用
cut -d,处理迟早出错。
RFC 4180 规定:字段可以双引号包裹,字段内的逗号、换行、双引号都被允许,双引号用两个双引号转义。这意味着一个「行」不一定对应一条记录。
# 危险:字段里含逗号时,cut 会把一条记录切成多段
cut -d, -f2 data.csv
# 危险:字段里含换行时,wc -l 统计的不是记录数
wc -l data.csv
# 安全:用专门的解析器,例如 python 的 csv 模块
python3 -c "
import csv, sys
for row in csv.reader(open('data.csv', newline='', encoding='utf-8')):
print(row[1])
"
1.1 先探测文件真实结构
一句话总结: 清洗前先确认分隔符、编码、是否含 BOM、行尾风格,四个问题任一搞错都会让后续全错。
file data.csv # 编码与行尾提示
head -c 3 data.csv | xxd # 检查是否 UTF-8 BOM (efbbbf)
head -1 data.csv # 看表头
awk -F, 'NR<=3 {print NF}' data.csv # 每行字段数是否一致
1.2 字段数不一致是常见脏数据
一句话总结: 用 awk 统计每行字段数,找出与表头不一致的行,往往是未转义的引号或换行。
# 表头字段数
n=$(head -1 data.csv | awk -F, '{print NF}')
# 找出字段数异常的物理行
awk -F, -v n="$n" 'NF != n {print NR": "NF" 字段"}' data.csv
2. csvkit 与 miller 的取舍
一句话总结: csvkit 提供
csvcut/csvgrep/csvsql等一套工具,miller 用统一的mlr命令覆盖更复杂变换,两者都懂 CSV 规则。
csvkit 适合「选择、过滤、统计、转 JSON」这类常见操作,命令名直观;miller 适合链式变换与多格式转换,表达力更强但学习曲线略陡。
# 选择列(按名字,不怕列序变化)
csvcut -c name,email data.csv
# 过滤行(正则匹配)
csvgrep -c status -m 'active' data.csv
# 转成 JSON,便于下游程序消费
csvjson data.csv | jq '.[0]'
# 统计
csvstat data.csv
2.1 miller 的链式变换
一句话总结:
mlr用动词链(cut、filter、sort、stats1)描述数据流,一条命令完成多步处理。
# 选列、过滤、排序、聚合
mlr --icsv --opprint \
cut -f name,amount,region then \
filter '$amount > 100' then \
sort -f region then \
stats1 -a sum,count -f amount -g region \
data.csv
# 格式转换:CSV 进 JSON 出
mlr --icsv --ojson cat data.csv | jq '.[0]'
2.2 工具缺失时的降级方案
一句话总结: 生产环境不一定有 csvkit,用 Python 标准库写一段小脚本是零依赖的兜底。
# 零依赖:只用 Python 标准库做选列
python3 - <<'PY'
import csv
with open('data.csv', newline='', encoding='utf-8') as f:
r = csv.DictReader(f)
for row in r:
print(f"{row['name']}\t{row['amount']}")
PY
3. awk 处理字段的正确姿势
一句话总结: 当数据确实是「简单 CSV」且不含引号与嵌入换行时,awk 的
-F处理是最快最方便的;一旦有引号就必须换工具。
# 简单 CSV:按逗号切分求和
awk -F, 'NR>1 {sum += $3} END {print sum}' data.csv
# 输出带表头的报表
awk -F, 'BEGIN {OFS="\t"; print "区域","金额"} NR>1 {print $2,$3}' data.csv
3.1 FPAT 处理带引号的 CSV
一句话总结: GNU awk 的
FPAT可以按「字段模式」切分,识别被引号包裹的字段,比-F更接近真实 CSV 语义。
# FPAT:字段要么是引号包裹的任意内容,要么是不含逗号的内容
gawk -v FPAT='([^,]*)|("[^"]*")' '
NR>1 { gsub(/^"|"$/, "", $2); print $2 }
' data.csv
3.2 字段校验与清洗
一句话总结: 在 awk 里对每个字段做类型校验,把不合规的行分流到错误文件,而不是让它们污染结果。
# 校验金额是数字,日期符合格式,不合规的行写入 reject.csv
awk -F, -v OFS=',' '
NR==1 { print > "clean.csv"; next }
$3 !~ /^[0-9]+(\.[0-9]+)?$/ { print > "reject.csv"; next }
$4 !~ /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/ { print > "reject.csv"; next }
{ print > "clean.csv" }
' data.csv
4. 引号、转义与编码
一句话总结: 引号与换行决定用哪个解析器,编码决定字段比较与排序是否正确,两者都必须显式处理。
# 去掉 UTF-8 BOM(否则第一个字段名会带不可见前缀)
sed -i '1s/^\xEF\xBB\xBF//' data.csv
# 转成 UTF-8(源为 GBK)
iconv -f GBK -t UTF-8 data.csv > data.utf8.csv
# 行尾从 CRLF 归一为 LF
dos2unix data.csv
4.1 编码探测
一句话总结: 不确定源编码时先用
file -I或uchardet探测,再用iconv转换,转换失败的行要显式处理。
# 探测编码
file -I data.csv
# 转换时用 -c 丢弃无法转换的字符,或保留以便排查
iconv -f GBK -t UTF-8 -c data.csv > data.utf8.csv
# 统计丢弃了多少字符
iconv -f GBK -t UTF-8 data.csv 2>/dev/null | wc -c
4.2 转义与重新引用
一句话总结: 输出 CSV 时字段必须重新引用并转义,否则含逗号的字段会破坏下游解析。
# 用 Python 正确输出 CSV
python3 - <<'PY'
import csv, sys
w = csv.writer(sys.stdout, quoting=csv.QUOTE_MINIMAL)
w.writerow(['name', 'note'])
w.writerow(['张三', '含,逗号与"引号"的备注'])
PY
# 反向:把 TSV 转成合法 CSV
mlr --itsv --ocsv cat data.tsv
5. 聚合、连接与透视
一句话总结: 分组聚合、多表连接、行列转换是报表三件套,用 awk 关联数组或
csvjoin都能实现。
# 按区域分组求和(awk 关联数组)
awk -F, 'NR>1 {sum[$2] += $3} END {for (k in sum) printf "%s\t%.2f\n", k, sum[k]}' \
data.csv | sort
# 两表连接(csvkit)
csvjoin -c id users.csv orders.csv | csvcut -c name,amount
5.1 用 sqlite 做复杂查询
一句话总结: 数据一旦超过几千行或需要多表 join,导入 SQLite 用 SQL 处理远比 awk 可靠。
# CSV 直接导入内存数据库查询
sqlite3 :memory: <<'SQL'
.mode csv
.import data.csv sales
SELECT region, SUM(amount) AS total
FROM sales GROUP BY region ORDER BY total DESC;
SQL
5.2 透视与行列转换
一句话总结: 用
mlr reshape或 awk 双层循环把长表转宽表,是生成交叉报表的关键一步。
# 长表转宽表
mlr --icsv --opprint reshape -s metric,value data.csv
# awk 方式:按 (行,列) 累积到二维数组
awk -F, 'NR>1 {a[$1][$2]=$3} END {
for (r in a) { printf "%s", r; for (c in a[r]) printf "\t%s", a[r][c]; print "" }
}' data.csv
6. 报表生成与校验
一句话总结: 报表先固定列顺序与格式,再做行数与合计校验,最后才是交付。
# 生成固定格式的报表
{
printf '区域\t订单数\t金额\n'
awk -F, 'NR>1 {n[$2]++; s[$2]+=$3}
END {for (k in s) printf "%s\t%d\t%.2f\n", k, n[k], s[k]}' data.csv \
| sort
} > report.tsv
6.1 对账校验
一句话总结: 明细合计与报表合计必须相等,源记录数减去拒收数应等于清洗后记录数,这两条断言能拦住大多数处理错误。
src_total=$(awk -F, 'NR>1 {s+=$3} END {print s}' data.csv)
rep_total=$(awk -F'\t' 'NR>1 {s+=$3} END {print s}' report.tsv)
awk -v a="$src_total" -v b="$rep_total" 'BEGIN {
if (a - b > 0.01 || b - a > 0.01) { print "对账不平: " a " vs " b > "/dev/stderr"; exit 1 }
print "对账通过: " b
}'
6.2 输出格式选择
一句话总结: 给程序消费用 CSV 或 JSON,给人看用对齐文本或 HTML 表格,按下游决定格式。
# JSON 输出
mlr --icsv --ojson cat report.tsv | jq .
# 对齐文本输出(column 或 mlr 的 pprint)
mlr --itsv --opprint cat report.tsv
# HTML 表格(csvkit)
csvjson report.tsv | jq -r '["区域","订单数","金额"], (.[]|[.[0],.[1],.[2]]) | "<tr>" + (map("<td>" + (.|tostring) + "</td>")|join("")) + "</tr>"'
7. 实战:销售数据清洗流水线
一句话总结: 把探测、编码归一、清洗校验、聚合、报表、对账串成一条脚本,是数据交付的完整闭环。
#!/usr/bin/env bash
set -euo pipefail
SRC="${1:?用法: pipeline.sh <源 CSV>}"
WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
# 第一步:编码归一,去掉 BOM 与 CRLF
iconv -f "$(file -I "$SRC" | sed 's/.*charset=//')" -t UTF-8 -c "$SRC" \
| sed '1s/^\xEF\xBB\xBF//' | tr -d '\r' > "$WORK/norm.csv"
# 第二步:校验并分流
awk -F, -v OFS=',' '
NR==1 { print > "'"$WORK"'/clean.csv"; next }
NF != 4 { print > "'"$WORK"'/reject.csv"; next }
$3 !~ /^[0-9]+(\.[0-9]+)?$/ { print > "'"$WORK"'/reject.csv"; next }
{ print > "'"$WORK"'/clean.csv" }
' "$WORK/norm.csv"
7.1 聚合与报表
一句话总结: 清洗后的干净数据做分组聚合,生成按区域汇总的报表并输出多份格式。
# 分组聚合
awk -F, 'NR>1 {n[$2]++; s[$2]+=$3}
END {for (k in s) printf "%s\t%d\t%.2f\n", k, n[k], s[k]}' \
"$WORK/clean.csv" | sort > "$WORK/report.tsv"
# 输出交付格式
{ printf '区域\t订单数\t金额\n'; cat "$WORK/report.tsv"; } > report.tsv
mlr --itsv --ojson cat report.tsv > report.json
# 统计拒收
printf '清洗完成:干净 %s 行,拒收 %s 行\n' \
"$(( $(wc -l < "$WORK/clean.csv") - 1 ))" \
"$(( $(wc -l < "$WORK/reject.csv" 2>/dev/null || echo 1) - 1 ))"
7.2 对账与交付
一句话总结: 交付前做合计对账与记录数守恒校验,任一不通过就失败退出,绝不交付可疑报表。
src=$(awk -F, 'NR>1 {s+=$3} END {print s+0}' "$WORK/norm.csv")
out=$(awk -F'\t' '{s+=$3} END {print s+0}' "$WORK/report.tsv")
awk -v a="$src" -v b="$out" 'BEGIN {
if ((a-b > 0.01) || (b-a > 0.01)) { print "对账失败: " a " vs " b > "/dev/stderr"; exit 1 }
}'
printf '对账通过,报表已生成: report.tsv / report.json\n'
8. 总结
| 环节 | 要点 |
|---|---|
| 认知 | CSV 有引号、转义、嵌入换行,非简单分隔文本 |
| 探测 | 先确认分隔符、编码、BOM、行尾、字段数 |
| 工具 | csvkit 直观,miller 表达力强,Python 零依赖兜底 |
| awk | 仅用于无引号简单 CSV,FPAT 可处理引号 |
| 编码 | iconv 转换,显式处理不可转换字符 |
| 输出 | 字段重新引用,避免破坏下游解析 |
| 聚合 | 关联数组、csvjoin 或导入 SQLite 用 SQL |
| 对账 | 合计守恒与记录数守恒,不通过不交付 |
结构化数据清洗的价值,不在于「把数据变干净」这个结果,而在于过程中每一步都可验证、可追溯:编码归一有日志、校验分流有拒收文件、聚合结果有对账。把这套闭环固化成流水线,脏数据就不会以「报表数字不对」的形式在几天后才暴露。数据处理好之后,如何让脚本在文件变化时自动触发,就是事件驱动要解决的问题。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。