1. 这不是教科书里的ETL,是数据科学家每天在Jupyter里真实敲出来的三步流水线
“ETL”这个词一出来,很多人脑子里立刻浮现出厚重的调度平台、凌晨三点告警的Airflow DAG失败邮件、DBA皱着眉头问你“这个全量同步为什么又锁表两小时”。但如果你现在正坐在工位上,手边开着一个刚跑完pd.read_csv()就卡住的Notebook,Excel里堆着七份命名规则不一致的销售日报,而老板刚在钉钉发来一句“下午三点前要看到渠道转化漏斗的周环比”,那你需要的从来就不是一套企业级数据中台架构图——你需要的是三段能立刻粘贴进.py文件、改两行路径就能跑通、跑完自动把清洗好的DataFrame塞进analysis_df变量里的代码逻辑。这就是我今天要说的ETL:面向数据科学家个体工作流的轻量级、可调试、带日志、能单步执行的端到端数据搬运链路。它不解决千万级并发写入,但能让你从“数据还没准备好”这种无效等待中抢回每天2.3小时;它不承诺SLA 99.99%,但保证每次运行失败时,错误信息精准指向第47行fillna()里那个没处理的空字符串;它不替代DataOps团队,但让你在需求评审会上能拍着桌子说:“这个字段缺失率37%,我建议上游补数,否则下游模型特征工程得重写。”关键词——数据科学家、ETL Pipeline、Pandas、PySpark、增量同步、数据质量校验、Jupyter调试友好——这些不是课程大纲里的术语标签,而是你明天早上九点打开电脑后,真正要和它们搏斗的具体对象。
我带过12个不同行业的数据科学项目,从电商用户行为埋点到医院检验报告结构化,发现一个铁律:83%的数据类项目延期,根源不在算法调参,而在ETL环节的“黑盒式等待”。比如某次给连锁药店做会员复购预测,算法团队花两周调出AUC 0.82的XGBoost模型,结果上线前发现上游CRM系统导出的customer_id字段里混进了47个带不可见Unicode字符的脏数据,导致特征匹配全错。问题本身一行正则就能修,但排查花了三天——因为没人知道ETL脚本在哪、谁写的、上次运行日志存哪。所以这篇内容不讲抽象概念,只拆解我在真实项目里反复验证过的最小可行ETL骨架:用Python原生模块搭底座,用Pandas做核心转换,用轻量日志+断点检查替代重型监控,用文件时间戳或数据库updated_at实现真正的增量感知。它不要求你部署Kubernetes,但要求你理解为什么pd.concat([df1, df2], ignore_index=True)比循环append()快17倍;它不教你如何写Airflow Operator,但会告诉你怎么用pathlib.Path().stat().st_mtime在5行代码内判断本地CSV是否比昨天新。如果你正在被“数据还没接进来”拖慢迭代节奏,或者总在重复写df.dropna(subset=['order_date'])这类救火代码——那接下来的内容,就是你该抄进自己utils/etl_core.py里的生产级模板。
2. 为什么放弃“标准ETL工具链”,选择手写Python流水线?
2.1 数据科学家的真实工作场景倒逼架构选择
先说结论:在MVP阶段和中小规模数据场景下,硬套Informatica或Talend这类传统ETL工具,相当于给自行车装涡轮增压——结构复杂度飙升,但实际通勤效率反而下降。这不是技术偏见,而是我在三个典型项目里踩坑后算出来的账。第一个是某教育SaaS公司的课程完课率分析,原始数据源是MySQL(用户行为)、MongoDB(课程章节树)、本地Excel(讲师排期)。如果走传统方案,得先配ODBC连接MySQL,再装Mongo Connector,最后写VBA宏读Excel——光环境配置就耗掉两天。而实际采用纯Python方案:pymysql直连MySQL,pymongo查Mongo,pandas.read_excel()读本地文件,三段代码加起来不到50行,15分钟完成首版数据拉取。关键差异在于:传统ETL工具把“连接器管理”做成核心能力,而数据科学家真正需要的是“转换逻辑的快速迭代”。当你在Jupyter里发现course_id字段在Mongo里是ObjectId类型,在MySQL里却是VARCHAR,传统工具要重启服务、重配字段映射;而Python里只需加一行df_mongo['_id'] = df_mongo['_id'].astype(str),立刻生效。
第二个案例更典型:某快消品牌做经销商库存预警,数据源是SFTP服务器上的每日压缩包(inventory_20240520.zip),解压后是23个命名混乱的CSV(inv_data_final_v2_clean.csv、inv_raw_20240520_backup.csv...)。传统ETL工具面对这种非标文件名,要么写复杂正则匹配规则,要么人工干预重命名。而我们用Python写的调度脚本,核心逻辑只有三行:
latest_zip = max(Path("sftp/").glob("inventory_*.zip"), key=lambda x: x.stat().st_mtime) unzip_to = Path("temp/") / latest_zip.stem shutil.unpack_archive(latest_zip, unzip_to) csv_files = list(unzip_to.rglob("*.csv"))它不关心文件叫什么,只认“最新修改时间”——这恰恰是业务方最确定的语义(“我要最新的库存数据”)。这里暴露了根本矛盾:传统ETL强调“元数据驱动”,而数据科学家需要“业务语义驱动”。当业务方说“用昨天的销售数据”,他不关心数据库里sales_fact表的load_timestamp字段值,只关心文件系统里sales_20240519.csv是否存在。手写Python流水线的优势,正在于能用最贴近业务语言的代码,直接表达这种意图。
2.2 技术选型背后的性能与调试权衡
有人会质疑:Python不是慢吗?Pandas处理千万行数据会不会OOM?这确实是关键考量点,但答案取决于数据规模和操作类型。我做过一组实测对比:处理1200万行订单数据(每行15字段,平均长度80字节),三种方案耗时如下:
| 方案 | 内存峰值 | 单次运行耗时 | 调试便利性 |
|---|---|---|---|
Pandas +chunksize=50000 | 3.2GB | 4分17秒 | ★★★★★(Jupyter单步调试) |
| PySpark本地模式 | 5.8GB | 6分03秒 | ★★☆☆☆(需看driver日志) |
| DuckDB(SQL方式) | 1.9GB | 2分41秒 | ★★★☆☆(需写SQL,调试逻辑分散) |
注意:这个测试在16GB内存的MacBook Pro上进行,所有方案都开启in_memory=True。结果很反直觉——Pandas在中等规模数据上,综合效率反而最高。原因在于:Pandas的向量化操作在CPU缓存友好性上远超Spark的JVM序列化开销,而DuckDB虽快但牺牲了Python生态的无缝衔接(比如无法直接调用sklearn.preprocessing.StandardScaler)。更重要的是调试成本:当fillna()填错列导致后续groupby().sum()结果异常,Pandas能在Jupyter里用df.iloc[100:105]瞬间定位问题行;而Spark报错信息常是Task not serializable这种模糊提示,需翻阅Application UI找Stage ID。
所以我们的技术栈选择逻辑非常务实:以Pandas为绝对核心,仅在明确遇到性能瓶颈时,才用DuckDB加速聚合计算,或用PySpark处理超大规模(>5亿行)且需分布式计算的场景。具体阈值怎么定?我的经验是:当单次ETL运行时间超过15分钟,或内存占用持续高于机器总内存的60%,就该考虑升级引擎。但升级不是简单换库,而是带着明确目标——比如把df.groupby('region').agg({'revenue': 'sum', 'cost': 'mean'})这段换成DuckDB SQL,其他清洗逻辑仍保留在Pandas里。这种混合架构,既避免了过早优化的陷阱,又保留了未来平滑演进的空间。
2.3 “可调试性”是数据科学家ETL的生命线
传统ETL工具最大的隐形成本,是调试过程中的上下文丢失。举个真实例子:某金融风控项目,ETL流程包含12个步骤,其中第7步“用户设备指纹去重”突然产出记录数锐减50%。在Airflow里排查,你要先登录Web UI找对应DAG Run ID,再点进Task Instance看Logs,发现报错KeyError: 'device_id'——但这个字段在第3步才由json.loads()解析出来,中间经过4次drop_duplicates()和merge()。你得手动还原整个数据血缘,而日志里只显示最终失败点。换成手写Python流水线,我们强制每个关键步骤后保存中间状态:
def extract_device_data(raw_df): # 步骤1:解析JSON parsed = raw_df['event_json'].apply(json.loads) save_checkpoint(parsed, "step1_parsed_json") # 自动存为parquet # 步骤2:展开嵌套字段 flattened = pd.json_normalize(parsed) save_checkpoint(flattened, "step2_flattened") # 步骤3:提取device_id result = flattened[['user_id', 'device_id', 'timestamp']] save_checkpoint(result, "step3_device_extracted") return resultsave_checkpoint()函数会自动生成带时间戳的Parquet文件(如checkpoint_step3_device_extracted_20240520_1423.parquet),并记录当前git commit hash。当问题出现时,你直接用pd.read_parquet("checkpoint_step3_device_extracted_20240520_1423.parquet")加载中间态,5秒内复现问题。这种“时间旅行式调试”,让故障定位从小时级降到分钟级。而它的实现成本极低——核心代码只有12行:
def save_checkpoint(df, name): timestamp = datetime.now().strftime("%Y%m%d_%H%M") path = Path("checkpoints") / f"{name}_{timestamp}.parquet" path.parent.mkdir(exist_ok=True) df.to_parquet(path, index=False) print(f"✓ Checkpoint saved: {path.name}")这背后是深刻认知:对数据科学家而言,ETL的价值不在于“一次跑通”,而在于“每次都能快速理解发生了什么”。所以我们的架构设计,永远把“可观测性”放在“自动化程度”之前——宁可多写两行print(),也不接受一个黑盒调度器。
3. 核心细节拆解:从零构建可落地的ETL骨架
3.1 提取(Extract)层:如何优雅地应对千奇百怪的数据源
提取层的核心挑战,从来不是“能不能连上”,而是“如何让连接逻辑不成为后续迭代的绊脚石”。我见过太多项目,因为数据库密码硬编码在脚本里,导致测试环境切换时全员停摆。所以第一原则:所有连接参数必须外部化,且支持多环境隔离。我们不用.env文件(易被Git误提交),而是采用分层配置方案:
config/base.yaml:存放通用参数(如data_dir: "/opt/data")config/dev.yaml:开发环境专属(db_host: "localhost",db_port: 3306)config/prod.yaml:生产环境专属(db_host: "prod-db.internal",db_port: 5432)
加载逻辑用pyyaml实现,关键代码如下:
import yaml from pathlib import Path def load_config(env="dev"): base_cfg = yaml.safe_load((Path("config") / "base.yaml").read_text()) env_cfg = yaml.safe_load((Path("config") / f"{env}.yaml").read_text()) # 深度合并,env_cfg覆盖base_cfg return {**base_cfg, **env_cfg} # 使用示例 cfg = load_config("dev") conn_str = f"mysql+pymysql://{cfg['db_user']}:{cfg['db_pass']}@{cfg['db_host']}:{cfg['db_port']}/{cfg['db_name']}"这种设计让环境切换变成一行命令:load_config("prod"),彻底杜绝密码泄露风险。
针对不同数据源,我们封装了标准化提取器。以最常见的API接口为例,很多同学直接用requests.get(url),结果遇到分页、限流、Token过期就崩溃。我们的APIDataExtractor类内置三大防护机制:
class APIDataExtractor: def __init__(self, base_url, auth_token, rate_limit=10): # 每秒最多10次请求 self.session = requests.Session() self.session.headers.update({"Authorization": f"Bearer {auth_token}"}) self.rate_limiter = RateLimiter(calls=rate_limit, period=1) # 基于time.sleep的简易限流 def fetch_paginated(self, endpoint, params=None): all_data = [] page = 1 while True: with self.rate_limiter: # 自动控制请求间隔 try: resp = self.session.get(f"{self.base_url}{endpoint}", params={**(params or {}), "page": page}) resp.raise_for_status() data = resp.json() if not data.get("results"): # 空结果表示结束 break all_data.extend(data["results"]) page += 1 except requests.exceptions.HTTPError as e: if resp.status_code == 401: self._refresh_token() # 自动刷新Token continue raise e return all_data重点看RateLimiter和_refresh_token():前者用装饰器模式确保不触发API限流,后者在401错误时自动调用刷新接口。这种封装让业务代码极度干净:
extractor = APIDataExtractor("https://api.salesforce.com/", "xyz123") leads = extractor.fetch_paginated("/services/data/v58.0/query/", params={"q": "SELECT Id,Name FROM Lead"})无需关心分页逻辑、Token续期、错误重试——这些都下沉到提取器内部。同理,对于SFTP源,我们封装SFTPExtractor,自动处理密钥认证、目录遍历、文件时间戳过滤;对于本地文件,LocalFileExtractor支持按modified_time或file_pattern智能筛选。所有提取器遵循同一接口:.extract()方法返回pd.DataFrame,屏蔽底层差异。这样当业务方说“下周开始用Snowflake替代MySQL”,你只需替换提取器实例,后续转换逻辑一行不动。
3.2 转换(Transform)层:Pandas实战中的12个避坑技巧
转换层是ETL的灵魂,也是Bug高发区。我整理了在12个项目中高频出现的12个Pandas陷阱及解决方案,全是血泪教训:
inplace=True的幻觉
错误写法:df.dropna(inplace=True)
问题:某些操作(如query())不支持inplace,导致代码风格不统一;更严重的是,inplace=True在链式调用中失效。
正确做法:永远显式赋值df = df.dropna(),配合# type: ignore注释(如用mypy)。copy()的隐形消耗
错误写法:df_new = df.copy()
问题:深拷贝耗时耗内存,尤其大DataFrame。
正确做法:df_new = df.copy(deep=False)(浅拷贝),或直接用df.assign()创建新列。concat()的索引灾难
错误写法:pd.concat([df1, df2])
问题:若df1.index=[0,1,2],df2.index=[0,1],结果索引重复,后续groupby()出错。
正确做法:pd.concat([df1, df2], ignore_index=True),强制重置索引。merge()的笛卡尔积陷阱
错误写法:pd.merge(df_left, df_right, on="key")
问题:若df_right.key有重复值,结果行数爆炸。
正确做法:pd.merge(df_left, df_right.drop_duplicates("key"), on="key"),或提前校验df_right.key.nunique() == len(df_right)。fillna()的类型污染
错误写法:df["amount"].fillna(0)
问题:若原列是Int64(可空整型),填0后变成float64。
正确做法:df["amount"] = df["amount"].fillna(pd.NA).astype("Int64")。datetime解析的时区地狱
错误写法:pd.to_datetime(df["ts"])
问题:未指定utc=True,本地时区解析导致跨时区数据错乱。
正确做法:pd.to_datetime(df["ts"], utc=True),统一转UTC后再转目标时区。str.contains()的正则注入
错误写法:df["name"].str.contains(user_input)
问题:user_input = "[abc"会引发正则编译错误。
正确做法:df["name"].str.contains(user_input, regex=False)。groupby().agg()的列名丢失
错误写法:df.groupby("cat").agg({"val": "sum"})
问题:结果列名为val,丢失聚合函数信息。
正确做法:df.groupby("cat").agg(val_sum=("val", "sum"))(Pandas 0.25+)。apply()的性能黑洞
错误写法:df["new_col"] = df.apply(lambda x: expensive_func(x.a, x.b), axis=1)
问题:逐行调用Python函数,比向量化慢100倍。
正确做法:用np.where()、pd.cut()等向量化函数,或swifter.apply()自动并行。pivot_table()的内存泄漏
错误写法:df.pivot_table(index="a", columns="b", values="c")
问题:若b列唯一值过多(如UUID),生成稀疏矩阵占满内存。
正确做法:先df = df[df["b"].isin(top_100_b)]限制维度。query()的字符串插值风险
错误写法:df.query(f"price > {min_price}")
问题:min_price = "100 or 1==1"导致SQL注入式逻辑错误。
正确做法:df.query("price > @min_price"),用@符号安全引用变量。to_parquet()的分区陷阱
错误写法:df.to_parquet("data/", partition_cols=["date"])
问题:若date列含NaT,分区目录名变成date=NaT,后续读取报错。
正确做法:df = df.dropna(subset=["date"]); df.to_parquet(...)。
这些技巧不是凭空而来,而是我在某电商项目中因fillna()类型污染导致特征工程全错、在某IoT项目中因merge()笛卡尔积使内存飙到64GB后总结的。真正的ETL高手,不是写得多,而是错得少;不是功能全,而是边界清。
3.3 加载(Load)层:如何让数据“稳稳落地”而非“野蛮写入”
加载层常被忽视,但它是数据质量的最后一道闸门。很多团队把df.to_csv("output.csv")当终点,结果下游分析发现日期格式错乱、数字被Excel自动转成科学计数法。我们的加载策略围绕三个核心:格式安全、原子性、可追溯。
首先是格式安全。CSV天生不适合结构化数据交换,我们强制使用Parquet作为默认输出格式:
def safe_load_to_parquet(df, output_path, partition_cols=None): """ 安全Parquet写入:自动处理空值、类型兼容、分区健壮性 """ # 步骤1:清理列名(移除空格、特殊字符) df.columns = [re.sub(r"[^a-zA-Z0-9_]", "_", col) for col in df.columns] # 步骤2:处理不支持Parquet的类型(如list, dict) for col in df.columns: if df[col].dtype == "object": if df[col].apply(lambda x: isinstance(x, (list, dict))).any(): df[col] = df[col].apply(json.dumps) # 序列化为JSON字符串 # 步骤3:确保分区列存在且非空 if partition_cols: for pc in partition_cols: if pc not in df.columns: raise ValueError(f"Partition column '{pc}' not found in DataFrame") if df[pc].isnull().all(): raise ValueError(f"Partition column '{pc}' is all null") # 步骤4:写入(带压缩,节省空间) df.to_parquet(output_path, partition_cols=partition_cols, compression="snappy", index=False) print(f"✓ Loaded {len(df)} rows to {output_path}") # 使用示例 safe_load_to_parquet(cleaned_df, "data/sales/", partition_cols=["year", "month"])这段代码解决了Parquet写入的四大痛点:列名非法字符、嵌套对象序列化、分区列空值、存储空间。其中compression="snappy"是关键——相比默认None,它让文件体积缩小60%,读取速度提升20%,且无CPU负担。
其次是原子性。绝不能让下游读到“半截数据”。我们采用临时目录+原子重命名:
def atomic_load(df, final_path): temp_dir = Path(final_path).parent / f".tmp_{uuid.uuid4().hex}" temp_dir.mkdir() try: # 写入临时目录 safe_load_to_parquet(df, str(temp_dir)) # 原子移动(Linux/macOS下是rename系统调用,毫秒级) shutil.move(str(temp_dir), str(final_path)) print(f"✓ Atomic load completed: {final_path}") except Exception as e: shutil.rmtree(str(temp_dir), ignore_errors=True) raise eshutil.move()在同文件系统下是原子操作,避免了os.remove()+os.rename()的竞态条件。当final_path是/data/latest/时,下游永远读到完整数据集。
最后是可追溯性。每份产出数据必须自带“出生证明”:
def add_provenance_metadata(df, source_info): """ 在DataFrame中添加元数据列,记录来源、时间、版本 """ now = datetime.now(timezone.utc) df["_provenance_source"] = source_info df["_provenance_load_time"] = now df["_provenance_git_commit"] = subprocess.check_output( ["git", "rev-parse", "HEAD"]).decode().strip() return df # 使用示例 df_with_meta = add_provenance_metadata(cleaned_df, "salesforce_api_v2") atomic_load(df_with_meta, "data/sales_enriched/")下游分析时,df["_provenance_load_time"]能精确到秒,_provenance_git_commit可回溯到具体代码变更。这比任何文档都可靠。
4. 实操全流程:从需求到交付的72小时速建指南
4.1 第1小时:需求解构与数据探查(绝不跳过!)
接到需求“需要近30天用户活跃度报表”,别急着写代码。先做三件事:
明确业务语义:和产品确认“活跃”的定义——是登录即算,还是必须完成某个关键动作?时间窗口是自然日(00:00-23:59)还是滚动24小时?这直接决定SQL的
WHERE条件。定位数据源:查公司数据字典(如有),或直接问DBA:“用户登录事件表叫什么?字段有哪些?分区策略?” 我们曾在一个项目里,因没问清楚,把
user_login_log表当成事实表,结果发现它只存最近7天数据,历史数据在user_login_log_historical里。快速探查样本:用最小代价验证数据可用性。例如:
# 查看表结构(MySQL) mysql -h prod-db -u reader -e "DESCRIBE user_login_log;" # 抽样10行(避免全表扫描) mysql -h prod-db -u reader -e "SELECT * FROM user_login_log LIMIT 10;" # 统计关键字段分布 mysql -h prod-db -u reader -e "SELECT DATE(login_time), COUNT(*) FROM user_login_log GROUP BY 1 ORDER BY 1 DESC LIMIT 5;"这些命令10分钟内完成,却能暴露90%的潜在问题:字段名拼写错误、时间字段是字符串而非datetime、数据延迟严重等。
4.2 第2-4小时:搭建骨架与连接验证
基于探查结果,初始化项目结构:
mkdir -p my_etl/{config,src,tests,data/{raw,processed},checkpoints} touch config/{base,dev,prod}.yaml touch src/{extract,transform,load}.pyconfig/base.yaml内容:
data_dir: "./data" raw_dir: "./data/raw" processed_dir: "./data/processed"config/dev.yaml内容:
db_host: "localhost" db_port: 3306 db_name: "analytics_db" db_user: "dev_reader" db_pass: "dev_pass"编写src/extract.py,实现首个提取器:
# src/extract.py import pandas as pd from sqlalchemy import create_engine from config import load_config def extract_user_login_logs(days_back=30): cfg = load_config("dev") engine = create_engine(f"mysql+pymysql://{cfg['db_user']}:{cfg['db_pass']}@{cfg['db_host']}:{cfg['db_port']}/{cfg['db_name']}") # 关键:用参数化查询防注入,且利用数据库索引 query = """ SELECT user_id, login_time, device_type FROM user_login_log WHERE login_time >= DATE_SUB(NOW(), INTERVAL %(days)s DAY) """ df = pd.read_sql(query, engine, params={"days": days_back}) print(f"✓ Extracted {len(df)} login records") return df运行验证:
# test_extract.py from src.extract import extract_user_login_logs df = extract_user_login_logs(days_back=1) # 先试1天,快 print(df.head())成功输出即证明连接通、SQL语法对、基础数据可取。这一步卡住,后面全是空谈。
4.3 第5-24小时:转换逻辑开发与质量校验
这是最耗时也最关键的阶段。我们采用“小步快跑”策略:每完成一个转换子步骤,立即校验。
以“计算日活用户数(DAU)”为例,分解为:
- 步骤1:清洗
login_time,转为DATE类型 - 步骤2:去重
user_id(同一用户一天多次登录只计1次) - 步骤3:按日期分组计数
对应代码:
# src/transform.py import pandas as pd from datetime import datetime def transform_to_dau(df): # 步骤1:清洗时间 df["login_date"] = pd.to_datetime(df["login_time"], utc=True).dt.date print(f"✓ Converted {len(df)} timestamps to date") # 步骤2:去重(关键校验点) before_dedup = len(df) df_dedup = df.drop_duplicates(subset=["user_id", "login_date"]) after_dedup = len(df_dedup) print(f"✓ Deduplicated: {before_dedup} -> {after_dedup} rows ({(before_dedup-after_dedup)/before_dedup:.1%} removed)") # 步骤3:聚合 dau_df = df_dedup.groupby("login_date").agg(dau_count=("user_id", "count")).reset_index() print(f"✓ Generated DAU report for {len(dau_df)} days") return dau_df质量校验必须嵌入代码:print()语句不仅是日志,更是契约。当dau_count突降50%,print()会第一时间报警。我们还加入断言:
# 在transform_to_dau末尾添加 assert dau_df["dau_count"].min() > 0, "DAU count cannot be zero!" assert len(dau_df) <= 31, "DAU report should cover at most 31 days"这些断言在CI流水线中自动执行,拦截明显错误。
4.4 第25-48小时:加载与自动化集成
编写src/load.py:
# src/load.py import pandas as pd from pathlib import Path from src.transform import transform_to_dau def load_dau_report(df, output_path): # 添加元数据 df["_load_time"] = pd.Timestamp.now(tz="UTC") df["_source_version"] = "v1.0" # 写入Parquet output_path = Path(output_path) output_path.parent.mkdir(parents=True, exist_ok=True) df.to_parquet(output_path, index=False) print(f"✓ DAU report loaded to {output_path}") # 主流程 if __name__ == "__main__": raw_df = extract_user_login_logs(days_back=30) dau_df = transform_to_dau(raw_df) load_dau_report(dau_df, "data/processed/dau_report.parquet")然后集成到自动化:
# 创建crontab任务(每天凌晨2点跑) 0 2 * * * cd /path/to/my_etl && python src/load.py >> logs/etl_cron.log 2>&1但更推荐用schedule库做Python内调度(避免crontab环境变量问题):
# scheduler.py import schedule import time from src.load import main_etl_flow def daily_etl(): try: main_etl_flow() print("✅ Daily ETL completed successfully") except Exception as e: print(f"❌ Daily ETL failed: {e}") schedule.every().day.at("02:00").do(daily_etl) while True: schedule.run_pending() time.sleep(60)4.5 第49-72小时:监控、告警与文档沉淀
ETL上线后,监控是生命线。我们用最简方案:
- 日志监控:用
logging模块记录关键指标到文件:import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s", handlers=[ logging.FileHandler("logs/etl_main.log"), logging.StreamHandler() # 同时输出到控制台 ] ) logging.info(f"Loaded {len(dau_df)} DAU records") - 失败告警:当脚本异常退出,用
subprocess调用企业微信机器人:import subprocess import json def send_alert(msg): payload = {"msgtype": "text", "text": {"content": f"🚨 ETL Alert: {msg}"}} subprocess.run([ "curl", "-X", "POST", "-H", "Content-Type: application/json", "-d", json.dumps(payload), "https://qyapi.weixin.qq.com/your_webhook_url" ]) - 文档沉淀:在
README.md中用表格定义数据契约:
| 字段名 | 类型 | 描述 | 示例 | 是否为空 |
|---|---|---|---|---|
login_date | DATE | 用户登录日期(UTC) | 2024-05-20 | ❌ |
dau_count | INT | 当日活跃用户数 | 12458 | ❌ |
_load_time | TIMESTAMP | 报表生成时间(UTC) | 2024-05-20T02:00:00Z | ❌ |
这份文档比代码更持久——当新人接手时,看表格3分钟就懂数据含义,无需读500行代码。
5. 常见问题与排查技巧实录
5.1 “数据量突降”问题的三层排查法
这是ETL中最常被问的问题:“为什么今天DAU只有1000,昨天是10000?” 别猜,按顺序查:
第一层:源头数据是否异常?
直接查原始表:
-- 检查原始日志表当日数据量 SELECT COUNT(*) FROM user_login_log WHERE DATE(login_time) = '2024-05-20'; -- 检查是否有ETL未覆盖的时间段 SELECT MIN(login_time), MAX(login_time) FROM user_login_log;如果原始表数据就少,说明是上游采集故障,立即通知运维。
第二层:提取逻辑是否过滤过严?
检查extract.py中的WHERE条件:
# 错误:用NOW()导致时区错乱 query = "WHERE login_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)" # 正确:用UTC时间,且固定基准 base_date = datetime.utcnow().date() query = f"