2403 字
约 8 分钟
14
S10-X10 · 数据处理脚本的健壮性:编码、缺失与并行

一次性脚本和数据管道脚本的差别不在代码量,而在输入变了会怎样、跑到一半挂了会怎样、再跑一次会怎样。前者写完就扔,后者要在没人盯着的时候重复执行几百次。这篇按"能反复跑"的标准拆四件事:编码、缺失、规模、失败处理。


1. 能跑一次与能反复跑

维度 一次性脚本 可反复跑的脚本
幂等 追加写入,跑两次翻倍 按主键覆盖(upsert)
断点 挂了从头来 记进度,跳过已完成分片
输入漂移 列名变了 KeyError 校验列集合,缺列报可读错误
部分失败 中断整批 失败落盘,其余继续
输出 直接写目标 临时文件后原子替换
观测 只 print 日志 + 进度计数

自检只需一个问题:把昨天的输出当今天的输入再跑一遍,会崩还是会收敛?


2. 编码:显式永远胜过默认

open() 不传 encoding 时用的是 locale.getpreferredencoding(False):Linux 容器里一般是 UTF-8,中文 Windows 上常是 cp936。同一份 CSV 在两台机器上读出不同乱码,根因就在这里(CPython 3.11 仍是这个行为)。

场景 该写什么 不写会怎样
读外部 CSV encoding="utf-8-sig" 首列名带 \ufeff,df["id"] 找不到列
写给人看的 CSV utf-8-sig + newline="" Excel 里中文乱码;行尾多空行
读写内部数据 encoding="utf-8" 平台差异
不确定来源 errors="replace" + 统计替换数 静默丢字符,下游对不上账
import pathlib

def read_text(path: str) -> tuple[str, int]:
    raw = pathlib.Path(path).read_bytes()
    for enc in ("utf-8-sig", "gb18030"):
        try:
            return raw.decode(enc), 0
        except UnicodeDecodeError:
            pass
    text = raw.decode("utf-8", errors="replace")
    return text, text.count("\ufffd")   # 替换数必须落盘,问题才不被藏起来

errors="replace" 的代价是把解码失败变成静默错误:数据读进来了,字段却已变成 �,拿它做主键就丢行。用它必须统计替换数。


3. 缺失值的三种形态

形态 谁产生 isna() == None 备注
None Python 层、object 列 True True 语义最干净
NaN 数值列、read_csv 空单元格 True False NaN != NaN 恒成立
pd.NA 可空整数/布尔(Int64) True False 比较返回 pd.NA
"" 空串 CSV、手工清洗 False False 要单独 (s == "").sum()
import pandas as pd

df = pd.DataFrame({"a": [1.0, None, float("nan")], "s": ["x", "", None]})
print(df.isna().sum().to_dict())     # {'a': 2, 's': 1}
print((df["s"] == "").sum())         # 1:空串不算缺失
print(df["a"].eq(None).sum())        # 0:NaN 与 None 不等

# 清洗:先统一成 None,再落业务默认值
df["s"] = df["s"].replace("", None).fillna("unknown")
print(df["s"].tolist())              # ['x', 'unknown', 'unknown']

规则:判缺失只用 isna()/notna(),别用 == None,也别用 != ""。三种形态混在一列时,先统一成一种。


4. 类型推断的陷阱

现象 原因 处理
ID 前导零消失(007 → 7) 列被推断成 int64 dtype={"id": "string"}
整列变 object 混了数字与字符串 显式 dtype=str 后业务层转换
空值列变 float64 整数列有 NaN 只能用浮点 dtype="Int64"
日期变字符串 未指定 parse_dates parse_dates=["ts"]
"NA" 被当缺失 默认 na_values 含 NA、NULL keep_default_na=False, na_values=[""]
import pandas as pd

df = pd.read_csv(
    "orders.csv",
    dtype={"order_id": "string", "qty": "Int64", "amount": "float64"},
    usecols=["order_id", "qty", "amount", "ts"],
    parse_dates=["ts"],
    keep_default_na=False, na_values=[""],
)
print(df.dtypes.to_dict())
print(df["order_id"].head(3).tolist())   # 前导零保留

keep_default_na=False 是双刃剑:挡住"字符串 NA 被误判成缺失",也让真空值变 "",必须配 na_values=[""] 补回。读入阶段定死 dtype 比事后 astype 安全。


5. 大文件:不要一次读进内存

手段 适用 代价
chunksize=N 逐批处理、边读边写 跨批聚合要自己维护状态
usecols=[...] 列多但只用几列 无
dtype=... 降内存(float64 → float32) 精度损失
csv 模块逐行 极简 ETL 无向量化收益

循环写法就是 for i, chunk in enumerate(pd.read_csv("big.csv", chunksize=200_000)),逐块 process 后写出,块内务必 .copy() 再赋值,否则会踩到视图告警。

分块写要盯住 mode="a" 与 header=(i == 0) 的配合,否则要么表头重复要么丢表头。更稳的是每块写独立分片再合并,中途失败好定位。


6. 并行:先分清瓶颈在等还是在算

瓶颈 选择 原因
网络/磁盘等待 ThreadPoolExecutor 等的时候 GIL 已释放
纯 Python 计算 ProcessPoolExecutor 3.11 有 GIL,线程不并行执行字节码
大量小文件解析 线程池 + 进程池两级 分开调,别混一个池
from concurrent.futures import ProcessPoolExecutor, as_completed
import pathlib

def parse_one(path: str) -> tuple[str, int]:
    return path, pathlib.Path(path).stat().st_size

files = [str(p) for p in pathlib.Path("data").glob("*.csv")]
with ProcessPoolExecutor(max_workers=4) as pool:
    futs = [pool.submit(parse_one, f) for f in files]
    for fut in as_completed(futs):
        try:
            print(fut.result())
        except Exception as exc:      # 单个失败不拖垮整批
            print("failed:", exc)

上面的池要在 if __name__ == "__main__": 里创建(spawn 平台必需)。隐性成本:每次 submit 都要 pickle 参数与返回值,传 DataFrame 等于复制内存;Windows/macOS 默认 spawn,模块顶层不能有副作用;子进程不共享全局状态。单任务只有几十毫秒时,启动与序列化会吃掉全部收益。


7. 失败要落盘,不要中断整批

import csv, datetime

def run_batch(items, handle):
    ok, failed = 0, []
    with open("failed.csv", "w", newline="", encoding="utf-8") as log:
        w = csv.writer(log)
        w.writerow(["key", "error", "ts"])
        for key in items:
            try:
                handle(key); ok += 1
            except Exception as e:
                failed.append(key)
                w.writerow([key, f"{type(e).__name__}: {e}",
                            datetime.datetime.now().isoformat(timespec="seconds")])
                w.flush()        # 进程被 kill 前的记录也要保住
    return ok, failed

三个细节决定日志有没有用:flush() 让进程被 kill 时记录还在;记 key 才能重跑;记时间戳才分得清本次和历史。失败清单要成为下次运行的输入。


8. 日志、进度与原子输出

需求 做法 反例
进度可见 每 N 条打印 done/total 与速率 只在结束打印
可被日志系统收集 logging 到 stderr 混在数据输出里
输出不残缺 写 .tmp 后 os.replace 直接覆盖目标
幂等 主键 upsert 或先删同日分区 无脑 to_csv(mode="a")
import os, pathlib, tempfile

def atomic_write(target: str, text: str) -> None:
    fd, tmp = tempfile.mkstemp(dir=pathlib.Path(target).parent, suffix=".tmp")
    with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
        f.write(text)
        f.flush()
        os.fsync(f.fileno())
    os.replace(tmp, target)      # 同一文件系统内才是原子替换

os.replace 在同一文件系统内原子,跨盘退化成复制、失去原子性。崩溃留下 .tmp 可接受,留下半个 target 才是事故。


9. 幂等地重跑:按主键覆盖而非追加

策略 幂等 适用
to_csv(mode="a") 否 一次性导出
按日期分区整体重写 是 日批、月批
主键 upsert 进库 是 增量同步
drop_duplicates(subset=主键, keep="last") 是 文件型落地
带运行 ID 的新文件 是但不收敛 需要留历史
import pandas as pd

old = pd.read_csv("clean.csv", dtype={"order_id": "string"})
new = pd.read_csv("batch.csv", dtype={"order_id": "string"})
(pd.concat([old, new], ignore_index=True)
   .drop_duplicates(subset=["order_id"], keep="last")
   .to_csv("clean.next.csv", index=False, encoding="utf-8"))

keep="last" 表达的是"新批次覆盖旧值",正是重跑要的效果。主键不唯一时(一单多行明细)要换成"主键 + 行号"复合键,否则误删合法明细。


10. 排错:六种典型现场

现象 根因 处理
首列名带不可见前缀 有 BOM,用了 utf-8 改 utf-8-sig
SettingWithCopyWarning 链式赋值,改的是切片副本 df.loc[mask, "col"] = v
循环几十万行极慢 iterrows 每行构造 Series 向量化,或 itertuples
fillna(0, inplace=True) 后仍空 inplace 返回 None,被赋给变量 用返回值赋值
跑完目标文件是空的 中途异常,直接写了目标 临时文件 + os.replace
换机器结果不同 默认编码/na_values/精度差异 全部显式指定

SettingWithCopyWarning 不是"可以忽略":你以为改了原表,其实改的是临时副本。看到就该改成 .loc,别用 filterwarnings("ignore")。


11. 边界:这些事不该用脚本硬扛

需求 更合适的工具 原因
超内存一个数量级 DuckDB / Polars / Spark pandas 全量进内存
定时调度与重试 Airflow / cron + 幂等脚本 自己实现调度是重复造轮子
事务性写入 数据库 upsert 文件保证不了并发一致
复杂清洗规则 dbt / SQL Python 规则难审计
回溯历史版本 数据湖 + 分区 覆盖式落地丢历史

一句话:脚本负责"这一批数据",调度与存储负责"很多批"。


12. 自测

  1. open() 不指定 encoding 时,实际用的是哪个编码?为什么跨平台会不一致?
  2. NaN != NaN 对"判缺失"的写法有什么直接影响?
  3. 为什么 keep_default_na=False 之后还要再写 na_values=[""]?
  4. 什么时候该用线程池而不是进程池?GIL 在其中扮演什么角色?
  5. inplace=True 与"把返回值赋回变量"混用会出什么问题?
  6. 为什么输出要先写临时文件再 os.replace?
S10-X10 · 数据处理脚本的健壮性:编码、缺失与并行
https://www.clxhxhhr.top/posts/5859/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。