结构化数据清洗与报表生成实战

面向 CSV 与 TSV 的清洗流水线:csvkit 与 miller 的取舍、awk 字段处理、引号与转义陷阱、编码转换、字段校验与聚合,最终生成可直接交付的报表。

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
对账合计守恒与记录数守恒,不通过不交付

结构化数据清洗的价值,不在于「把数据变干净」这个结果,而在于过程中每一步都可验证、可追溯:编码归一有日志、校验分流有拒收文件、聚合结果有对账。把这套闭环固化成流水线,脏数据就不会以「报表数字不对」的形式在几天后才暴露。数据处理好之后,如何让脚本在文件变化时自动触发,就是事件驱动要解决的问题。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「shell」更多文章

  1. 任务编排与 Makefile 实战
  2. 文件监控与事件驱动流水线实战
  3. 并发控制与文件锁实战