第10章:Python ETL实战:使用Python编写完整ETL脚本、调度与日志、错误处理与重试机制

说实话,很多刚入行的朋友问我:「Python写ETL,不就是读个CSV再写进数据库吗?」

嗯,如果只是这样,那确实简单。但真正的生产环境,哪有这么温柔?

数据源可能断连、格式可能突变、网络可能抖动、磁盘可能写满……你写的脚本,得像个老兵一样,能扛得住这些意外。

这一章,我就带你手写一个完整的Python ETL脚本。从数据抽取、转换、加载,到调度、日志、错误处理与重试,一步到位。

10.1 一个完整的ETL脚本长什么样?

我个人习惯把ETL脚本拆成三个核心模块:

  • 抽取(Extract):从源系统拿数据,可能是数据库、API、文件
  • 转换(Transform):清洗、过滤、聚合、类型转换
  • 加载(Load):写入目标系统,比如数据仓库、文件、消息队列

但光有这三个还不够。你还需要:

  • 调度入口:让脚本能被定时或手动触发
  • 日志系统:记录每一步的执行情况,方便排查问题
  • 错误处理与重试:遇到异常别直接崩,先试试能不能自己恢复

下面是一个我常用的脚本骨架:

import logging
import time
from datetime import datetime
from typing import Optional

# ---------- 日志配置 ----------
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler(f'etl_{datetime.now().strftime("%Y%m%d")}.log'),
        logging.StreamHandler()
    ]
)
logger = logging.getLogger(__name__)

# ---------- 抽取 ----------
def extract(source_config: dict) -> Optional[list]:
    """从数据源抽取数据,返回列表或None"""
    try:
        logger.info("开始抽取数据...")
        # 模拟从数据库或API读取
        data = [
            {"id": 1, "name": "张三", "amount": 100.5},
            {"id": 2, "name": "李四", "amount": 200.3},
        ]
        logger.info(f"抽取完成,共 {len(data)} 条记录")
        return data
    except Exception as e:
        logger.error(f"抽取失败: {e}")
        return None

# ---------- 转换 ----------
def transform(data: list) -> list:
    """数据清洗与转换"""
    logger.info("开始转换数据...")
    transformed = []
    for row in data:
        # 类型转换、空值处理、字段映射
        row['amount'] = float(row.get('amount', 0))
        row['etl_time'] = datetime.now().isoformat()
        transformed.append(row)
    logger.info(f"转换完成,共 {len(transformed)} 条")
    return transformed

# ---------- 加载 ----------
def load(data: list, target_config: dict) -> bool:
    """加载到目标系统"""
    try:
        logger.info("开始加载数据...")
        # 模拟写入数据库或文件
        for row in data:
            logger.debug(f"写入: {row}")
        logger.info(f"加载完成,共 {len(data)} 条")
        return True
    except Exception as e:
        logger.error(f"加载失败: {e}")
        return False

# ---------- 主流程 ----------
def run_etl(source_cfg, target_cfg, max_retries=3):
    """带重试机制的ETL主流程"""
    for attempt in range(1, max_retries + 1):
        logger.info(f"=== 第 {attempt} 次尝试 ===")
        data = extract(source_cfg)
        if data is None:
            logger.warning("抽取失败,准备重试...")
            time.sleep(2 ** attempt)  # 指数退避
            continue
        data = transform(data)
        if not load(data, target_cfg):
            logger.warning("加载失败,准备重试...")
            time.sleep(2 ** attempt)
            continue
        logger.info("ETL执行成功!")
        return True
    logger.error("重试次数耗尽,ETL失败")
    return False

if __name__ == "__main__":
    source = {"type": "mysql", "host": "localhost"}
    target = {"type": "postgres", "host": "dw-server"}
    run_etl(source, target)

核心要点:

  • 每个阶段独立成函数,方便单元测试
  • 日志同时输出到文件和控制台,生产环境必备
  • 主流程带重试,避免单次失败就全盘崩溃

10.2 调度:让脚本自己跑起来

脚本写好了,总不能每次都手动执行吧?

我在项目中遇到过,有人用Windows任务计划器跑Python脚本,结果路径写错,日志全丢了……

这里我推荐几种常见的调度方式:

调度方式 适用场景 优点 缺点
Linux Crontab 简单定时任务 轻量、稳定 无依赖管理、无监控
Airflow 复杂DAG依赖 可视化、重试、告警 部署较重
Python schedule库 轻量级脚本内调度 代码内嵌、无需外部依赖 不支持分布式
Kubernetes CronJob 容器化环境 弹性、隔离性好 需要K8s运维能力

如果你只是小团队或个人项目,用 schedule 库就够了。举个例子:

import schedule
import time

def job():
    run_etl(source, target)

# 每天早上8点执行
schedule.every().day.at("08:00").do(job)

while True:
    schedule.run_pending()
    time.sleep(60)

我的建议:生产环境至少用Crontab + 日志监控。如果团队有Airflow,那就用Airflow,它的重试和告警机制非常成熟。

10.3 日志:你的「黑匣子」

日志不是随便print一下就行。我曾经排查一个凌晨3点的ETL失败问题,发现日志里只有一行「Error」,连堆栈都没有……那感觉,就像飞机失事找不到黑匣子。

好的日志系统应该做到:

  • 分级输出:DEBUG、INFO、WARNING、ERROR,别一股脑全打出来
  • 包含上下文:时间、模块、行号、请求ID
  • 自动轮转:日志文件别无限增长,否则磁盘会爆

下面是一个带轮转的日志配置:

import logging
from logging.handlers import RotatingFileHandler

handler = RotatingFileHandler(
    'etl.log',
    maxBytes=10*1024*1024,  # 10MB
    backupCount=5
)
formatter = logging.Formatter(
    '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
handler.setFormatter(formatter)

logger = logging.getLogger('ETL')
logger.addHandler(handler)
logger.setLevel(logging.INFO)

注意:日志级别别设太低。DEBUG日志在线上会刷爆磁盘,我见过有人把DEBUG开了一周,结果100G磁盘全写满了……

10.4 错误处理与重试机制

ETL脚本最怕什么?怕「一次性失败」。比如网络闪断、数据库连接池满、临时锁表……这些错误往往过几秒就能恢复。

所以,重试机制是ETL的标配。

我常用的重试策略有三种:

  • 固定间隔重试:每隔5秒重试一次,最多3次
  • 指数退避:第一次等2秒,第二次等4秒,第三次等8秒……避免雪崩
  • 带抖动的退避:在指数退避基础上加随机值,防止多个任务同时重试

下面是一个带抖动退避的实现:

import random
import time

def retry_with_backoff(func, max_retries=3, base_delay=2):
    for attempt in range(1, max_retries + 1):
        try:
            return func()
        except Exception as e:
            logger.warning(f"第{attempt}次失败: {e}")
            if attempt == max_retries:
                raise
            delay = base_delay ** attempt + random.uniform(0, 1)
            time.sleep(delay)

避坑指南:

  • 不是所有错误都值得重试。比如SQL语法错误、数据格式错误,重试一万次也没用。这种应该直接失败并告警。
  • 重试次数别设太大。我见过有人设了10次重试,结果数据库被重试请求打挂了……
  • 记得记录每次重试的上下文,方便事后分析。

10.5 知识体系总览

下面这张图,是我对本章核心逻辑的总结。你可以把它当作一个「ETL脚本设计检查清单」:

Python ETL 脚本核心架构 抽取 (Extract) 数据库 / API / 文件 转换 (Transform) 清洗 / 过滤 / 聚合 加载 (Load) 数据仓库 / 文件 / 消息 调度层:Crontab / Airflow / Schedule 日志层:分级输出 / 轮转 / 上下文 错误处理层:重试 / 指数退避 / 告警 每一层都独立可替换,便于维护和扩展

10.6 写在最后

Python写ETL,门槛不高,但要做好却不容易。

你想想看,一个脚本要稳定跑几个月甚至几年,中间可能经历数据源变更、网络波动、磁盘扩容……没有健壮的日志和重试机制,运维起来会非常痛苦。

我个人习惯是:先写日志,再写逻辑;先做错误处理,再做功能。顺序反了,后面补起来很麻烦。

好了,这一章的内容就到这里。代码示例你可以直接拿去用,但记得根据你的实际场景调整配置。


公众号:蓝海资料掘金营,微信 deep3321