Pandas+Redshift ETL实战:绕过to_sql,用COPY+staging高效加载
1. 项目概述用Pandas API做Redshift ETL真不是“把DataFrame.to_sql()敲两遍”那么简单你是不是也试过直接在Jupyter里写df.to_sql(sales, conredshift_engine, if_existsreplace, indexFalse)结果一跑就卡死、报错、超时或者数据进去了但时间字段全变成NULL、小数精度全乱、中文变问号——这根本不是Pandas的问题而是你没意识到Redshift不是MySQL更不是PostgreSQL的翻版它是一台为PB级分析而生的列式数据仓库它的写入逻辑、类型映射、并发机制、网络路径和传统OLTP数据库有本质差异。我在金融风控团队实操过37个Redshift ETL链路从日均500万条交易流水清洗到跨12个业务系统的客户标签宽表构建踩过的坑足够填满一个S3桶。所谓“用Pandas API做Redshift ETL”绝不是把本地CSV读成DataFrame再硬塞进去——它是一整套围绕数据形态适配、网络传输优化、Redshift原生加载能力复用、错误韧性设计的工程实践。核心关键词是Pandas API、Redshift、ETL、copy命令、staging表、类型对齐、批量分片、连接池管理。这篇文章适合三类人一是刚接手Redshift数据管道的Python工程师手头只有pandas和sqlalchemy二是数据分析师想摆脱SQL Workbench手动导出导入把清洗逻辑全部收口到Notebook里三是架构师在评估是否值得为Redshift定制一套轻量级ETL SDK。我会全程不碰AWS Console、不依赖Data Pipeline或Glue只用pandas sqlalchemy boto3 原生Redshift SQL把每一步为什么这么写、参数怎么算、失败怎么救掰开揉碎讲清楚。2. 整体设计思路与方案选型为什么放弃to_sql转向copystaging的组合拳2.1 直接to_sql在Redshift上必然失败的三大硬伤先说结论在生产环境对Redshift执行pandas.DataFrame.to_sql()等同于用自行车拉集装箱——理论上可行实际上会散架。我不是危言耸听这是我们在某保险客户POC阶段用真实保单数据压测得出的结论单次插入10万行平均耗时48秒失败率23%。原因有三第一协议层不兼容。Redshift底层基于PostgreSQL 8.0.2但Amazon大幅阉割了其INSERT性能。原生PostgreSQL支持COPY FROM STDIN流式写入吞吐可达100MB/s以上而Redshift的JDBC/ODBC驱动对INSERT ... VALUES做了强限制单条INSERT最多只能带1000行且每行长度不能超过4MB。pandas的to_sql()默认走的就是逐条INSERT或批量INSERTchunksize控制一旦DataFrame含TEXT/CLOB字段或行数超限立刻触发Query execution timeout或String length exceeds limit。第二类型系统错位。pandas的object类型在推断Redshift列类型时极不可靠。比如一个含空值的日期列pandas可能推为datetime64[ns]但Redshift的DATE类型不接受NaT强制转换会变成1970-01-01又比如pandas的float64对应Redshift的DOUBLE PRECISION但若源数据有inf或-infRedshift直接拒绝入库。我们曾因一个float64列含np.inf导致整批23万条记录被COPY命令静默跳过日志里只有一行Load error: 0 rows processed排查了6小时才发现问题出在pandas侧的数据洁化漏项。第三无事务原子性保障。to_sql()的if_existsreplace本质是DROP TABLE CREATE TABLE INSERT三步中间任何一步失败表就没了。而Redshift的DROP TABLE是阻塞操作会锁住整个schema影响其他查询。我们线上曾因此导致实时看板中断22分钟——就因为一个ETL脚本里to_sql(..., if_existsreplace)执行到一半网络抖动表被删了但没重建成功。提示别信网上那些“加chunksize1000就能跑通”的教程。chunksize只是把大INSERT拆成小INSERT没解决协议瓶颈它让失败更频繁而不是更稳定。2.2 copystaging方案借Redshift原生引擎之力绕过所有协议陷阱我们的最终方案是完全弃用to_sql()改用pandas.DataFrame.to_csv()生成临时CSV → 上传至S3 → 调用RedshiftCOPY命令从S3加载 → 加载后通过SQL完成staging表到目标表的INSERT INTO ... SELECT。这个流程看似多三步实则带来质的提升吞吐量提升17倍实测100万行、20列的销售明细表COPY从S3加载仅需3.2秒网络带宽充足前提下而to_sql(chunksize1000)需54秒错误可追溯COPY命令支持MAXERROR和ERROR AS TABLE能把解析失败的原始行存入单独错误表方便定位是源数据问题还是类型映射问题原子性可控staging表加载成功后再用单条INSERT INTO target SELECT * FROM staging完成切换整个过程在事务内失败则回滚目标表始终可用资源隔离S3作为中间介质避免Python进程与Redshift集群直连造成的连接池争抢尤其适合Airflow中多DAG并发调度场景。这个方案的核心思想是让Redshift干它最擅长的事并行解析S3文件让pandas干它最擅长的事内存计算与数据洁化两者通过S3这个“缓冲区”解耦。这不是妥协而是对系统边界的清醒认知——就像不会让厨师去修锅炉也不会让锅炉工去切菜。2.3 为什么必须用staging表一次加载失败如何保证下游零感知staging表不是可选项是生产环境的强制安全阀。它的价值体现在三个层面第一加载与消费解耦。目标表如dwd_sales_fact可能被上百个BI报表、即席查询实时访问。如果直接COPY到目标表加载期间表结构可能被锁尤其COPY带TRUNCATECOLUMNS时导致查询超时。而staging表如stg_sales_fact_20240520_1423是临时私有表加载过程完全不影响线上服务。第二数据校验前置。加载到staging后我们可以执行一系列轻量SQL校验SELECT COUNT(*) FROM stg_sales_fact_20240520_1423确认行数SELECT MIN(event_time), MAX(event_time) FROM stg_sales_fact_20240520_1423检查时间范围是否符合预期SELECT COUNT(*) FROM stg_sales_fact_20240520_1423 WHERE user_id IS NULL扫描关键字段空值率。这些校验在INSERT INTO target之前完成避免脏数据污染主表。第三回滚成本趋近于零。若校验失败只需DROP TABLE stg_sales_fact_20240520_1423整个操作毫秒级完成。而如果直接加载到目标表再发现错误回滚意味着要从备份恢复RTO恢复时间目标可能长达小时级。我们约定staging表命名规则为stg_{target_table}_{yyyymmdd}_{hhmm}例如stg_dwd_sales_fact_20240520_1423。这个时间戳不仅是标识更是幂等性保障——同一时间窗口的ETL任务无论重试多少次都操作同一个staging表名避免重复创建。3. 核心细节解析与实操要点从pandas DataFrame到S3 CSV的每一处魔鬼细节3.1 pandas侧CSV导出不是“df.to_csv(tmp.csv)”就完事pandas的to_csv()方法有12个关键参数其中5个对Redshift加载成败起决定性作用。我按重要性排序说明indexFalse—— 必须关闭索引列Redshift表结构不含索引列若to_csv()保留indexTrue生成的CSV第一列是行号0,1,2...COPY命令会尝试将该列映射到目标表第一列导致Column id does not exist错误。这是新手最高频失误占我们内部ETL故障报告的38%。headerFalse—— 必须禁用CSV表头RedshiftCOPY默认不读取CSV首行作为列名它严格按COLUMNS子句或表结构顺序匹配字段。若CSV带headerCOPY会把header行当作数据行加载导致第一行数据错位。正确做法是在COPY语句中显式指定COLUMNS (col1, col2, ...)或确保CSV纯数据无header。na_rep\\N—— NULL值必须用Redshift标准转义符Redshift识别\\N两个反斜杠N为NULL而非NULL字符串或空字符串。pandas默认na_rep即空字符串。若源数据有np.nan导出后是空字段COPY会将其转为空字符串而非NULL。这会导致后续WHERE col IS NULL查询永远不命中。必须强制na_rep\\N。date_format%Y-%m-%d和float_format%.6f—— 时间与浮点数格式必须精确控制Redshift的DATE类型只认YYYY-MM-DD格式TIMESTAMP认YYYY-MM-DD HH:MI:SS。pandas的datetime64导出默认带毫秒2024-05-20 14:23:15.123Redshift会截断为秒级但若字段定义为TIMESTAMP WITHOUT TIME ZONE毫秒部分丢失是允许的可若定义为TIMESTAMP WITH TIME ZONE且源数据含时区信息如2024-05-20 14:23:15.12308Redshift会报Invalid timezone。因此统一转为无时区、无毫秒格式最稳妥。浮点数同理float64默认导出1.23456789012345e10科学计数法Redshift无法解析必须用float_format%.12f固定小数位数根据业务精度需求定金融场景常用12位。quotingcsv.QUOTE_MINIMAL—— 引号策略必须最小化RedshiftCOPY对字段内逗号、换行符极其敏感。若quotingcsv.QUOTE_ALL所有字段都加双引号COPY能处理但若quotingcsv.QUOTE_NONE默认含逗号的字符串如地址Beijing, Chaoyang District会被COPY误判为多列。QUOTE_MINIMAL只在必要时含分隔符或引号加引号是平衡兼容性与体积的最佳选择。实测100万行数据QUOTE_MINIMAL比QUOTE_ALL节省23% S3存储。import csv # 正确的to_csv调用示例 df.to_csv( staging_data.csv, indexFalse, headerFalse, na_rep\\N, date_format%Y-%m-%d %H:%M:%S, # 注意时间格式按目标列类型调整 float_format%.12f, quotingcsv.QUOTE_MINIMAL, encodingutf-8 # 必须UTF-8Redshift不支持GBK )注意encodingutf-8是铁律。若源数据含中文且to_csv()用encodinggbk上传S3后COPY会报Invalid UTF-8 byte sequence。Redshift所有I/O强制UTF-8。3.2 S3上传不是“boto3.client(s3).upload_file()”就高枕无忧S3上传表面简单实则暗藏三处性能与可靠性雷区第一分块上传Multipart Upload阈值设置boto3默认对8MB文件启用分块上传但RedshiftCOPY对S3对象大小有隐式要求单个CSV文件不宜超过1GB官方文档建议上限否则COPY解析内存压力剧增易OOM。我们设定硬性规则单个CSV文件最大500MB对应约500万行按平均每行100字节估算。若DataFrame超限必须在pandas侧df_split np.array_split(df, ceil(len(df)/5000000))分片每片单独to_csv上传。第二S3路径的版本化与清理不要把所有CSV都扔进s3://my-bucket/redshift-staging/一个目录。应按日期任务ID组织s3://my-bucket/redshift-staging/20240520/dwd_sales_fact_142300123.csv。这样做的好处1便于按天清理过期文件aws s3 rm s3://my-bucket/redshift-staging/20240519/ --recursive2避免COPY时FROM s3://my-bucket/redshift-staging/误加载历史残留文件3配合IAM策略可精确控制某天数据的读写权限。第三上传后立即验证对象完整性S3上传可能因网络波动导致文件损坏如最后几个字节丢失。COPY命令遇到损坏CSV会静默失败日志只显示Load error: 0 rows processed。必须在上传后立即head -c 1000 s3://.../file.csv | wc -c验证前1000字节可读或用aws s3api head-object --bucket my-bucket --key path/to/file.csv检查ContentLength是否与本地文件os.path.getsize()一致。我们封装了一个verify_s3_object()函数上传后必调用。import boto3 from botocore.exceptions import ClientError def upload_to_s3_and_verify(local_path, s3_key, bucket_name): s3_client boto3.client(s3) # 上传 s3_client.upload_file(local_path, bucket_name, s3_key) # 验证 try: response s3_client.head_object(Bucketbucket_name, Keys3_key) local_size os.path.getsize(local_path) if response[ContentLength] ! local_size: raise ValueError(fS3 object size {response[ContentLength]} ! local size {local_size}) print(f✅ S3 upload verified: {s3_key}) except ClientError as e: raise RuntimeError(fS3 verification failed for {s3_key}: {e})3.3 Redshift侧COPY命令不是拼SQL而是精密调优的艺术RedshiftCOPY命令有37个参数但生产环境只需关注7个核心参数其余保持默认即可。我按执行顺序解释FROM s3://...—— S3路径必须带arn:aws:s3:::前缀吗不需要。FROM s3://my-bucket/redshift-staging/20240520/file.csv完全合法。但必须确保Redshift集群所在IAM Role有s3:GetObject权限且Bucket策略允许该Role访问。常见错误是Bucket策略写了Principal: {AWS: arn:aws:iam::123456789012:role/MyRedshiftRole}但实际Role名是MyRedshiftRole-dev导致Access Denied。CREDENTIALS aws_iam_role...—— IAM Role ARN必须完整必须写全ARN如aws_iam_rolearn:aws:iam::123456789012:role/RedshiftS3ReadRole。若只写角色名aws_iam_roleRedshiftS3ReadRoleRedshift会尝试在本账号下查找失败。DELIMITER ,—— 分隔符必须与CSV导出一致pandasto_csv()默认,所以这里写DELIMITER ,。若导出时用了sep|这里必须同步改为DELIMITER |。不一致会导致Extra column错误。IGNOREHEADER 0—— 必须为0因为CSV无headerheaderFalse所以IGNOREHEADER 0。若误设为1COPY会跳过第一行数据导致整批少一行。NULL AS \\N—— 与pandas的na_rep\\N严格对应这是NULL映射的契约。两边必须完全一致包括反斜杠数量。写成\\NPython字符串中两个反斜杠表示一个或\\\\NSQL中需四个反斜杠都是错的。正确是NULL AS \\N。TRUNCATECOLUMNS—— 字符串超长时自动截断而非报错Redshift列定义如VARCHAR(50)若CSV某行该字段有55字符默认会报String length exceeds limit。加TRUNCATECOLUMNS则自动截为50字符。这是生产环境必备开关避免单条脏数据阻塞整批加载。但需配合后续数据质量监控确保截断不损业务含义。MAXERROR 10—— 允许最多10行解析失败MAXERROR不是越大越好。设为10意味着COPY会跳过最多10行错误数据继续加载其余行。若设为0默认第一行错就全盘失败。我们设为10并配合ERROR AS TABLE stg_sales_fact_20240520_1423_error把错误行存入独立表供人工核查。错误表结构由Redshift自动生成含colname,colvalue,err_code,err_reason四列。-- 完整COPY命令示例 COPY stg_dwd_sales_fact_20240520_1423 FROM s3://my-bucket/redshift-staging/20240520/dwd_sales_fact_142300123.csv CREDENTIALS aws_iam_rolearn:aws:iam::123456789012:role/RedshiftS3ReadRole DELIMITER , IGNOREHEADER 0 NULL AS \\N TRUNCATECOLUMNS MAXERROR 10 ERROR AS TABLE stg_dwd_sales_fact_20240520_1423_error;4. 实操过程与核心环节实现从零搭建一个可复用的Redshift ETL函数4.1 环境准备最小依赖集与连接池配置我们只依赖4个包pandas1.5.0,sqlalchemy1.4.0,boto31.26.0,psycopg2-binary2.9.0。注意绝不用redshift_connector因其对COPY命令支持不完善且社区维护弱。psycopg2是事实标准兼容性最好。Redshift连接字符串格式postgresql://user:passwordcluster-endpoint:5439/database但生产环境严禁明文密码必须用IAM认证postgresql://user:passwordcluster-endpoint:5439/database?sslmoderequireiamtruedb_userdb_user其中db_user是Redshift中已创建的IAM用户CREATE USER my_user IAMDISTRIBUTED;password可任意如unused因实际认证走IAM。连接池至关重要。Redshift单节点连接数上限为500但高并发ETL易打满。我们用sqlalchemy.pool.QueuePool配置pool_size10保持10个常驻连接max_overflow20突发时最多额外创建20个连接pool_recycle3600连接使用1小时后强制回收避免长连接老化pool_pre_pingTrue每次取连接前执行SELECT 1检测存活防止网络闪断导致的OperationalError。from sqlalchemy import create_engine from sqlalchemy.pool import QueuePool def get_redshift_engine(): return create_engine( postgresql://etl_user:unusedmy-redshift-cluster.xxxxxxx.us-east-1.redshift.amazonaws.com:5439/prod_db ?sslmoderequireiamtruedb_useretl_user, poolclassQueuePool, pool_size10, max_overflow20, pool_recycle3600, pool_pre_pingTrue, echoFalse # 生产关掉避免日志刷屏 )4.2 核心ETL函数pandas_to_redshift()的完整实现以下函数已在线上稳定运行14个月处理日均2.3TB数据。我逐行注释关键逻辑import pandas as pd import numpy as np import csv import os import boto3 from botocore.exceptions import ClientError from sqlalchemy import text from datetime import datetime def pandas_to_redshift( df: pd.DataFrame, target_table: str, schema: str public, s3_bucket: str my-bucket, s3_prefix: str redshift-staging, iam_role_arn: str arn:aws:iam::123456789012:role/RedshiftS3ReadRole, chunksize: int 5000000, # 单CSV最大行数 max_error: int 10, truncate_columns: bool True, verify_s3: bool True ): 将pandas DataFrame高效加载至Redshift采用COPYstaging模式 Args: df: 待加载的DataFrame target_table: 目标表名如dwd_sales_fact schema: 目标schema默认public s3_bucket: S3桶名 s3_prefix: S3路径前缀如redshift-staging iam_role_arn: Redshift访问S3的IAM Role ARN chunksize: 单个CSV文件最大行数防止单文件过大 max_error: COPY允许的最大错误行数 truncate_columns: 是否开启字符串截断 verify_s3: 是否校验S3上传完整性 # 1. 生成唯一staging表名和S3 key now_str datetime.now().strftime(%Y%m%d_%H%M%S) staging_table fstg_{target_table}_{now_str} s3_date_dir datetime.now().strftime(%Y%m%d) # 2. 创建staging表结构与目标表一致但无约束 engine get_redshift_engine() with engine.connect() as conn: # 获取目标表结构 result conn.execute(text(f SELECT column_name, data_type, character_maximum_length, numeric_precision, numeric_scale FROM information_schema.columns WHERE table_schema {schema} AND table_name {target_table} ORDER BY ordinal_position )) columns list(result) # 构建CREATE TABLE语句无PK, no NOT NULL, no DEFAULT create_sql fCREATE TEMP TABLE {staging_table} ( col_defs [] for col in columns: col_name, data_type, char_len, num_prec, num_scale col if data_type character varying: col_def f{col_name} VARCHAR({char_len or 256}) elif data_type numeric: col_def f{col_name} NUMERIC({num_prec or 18}, {num_scale or 0}) elif data_type in [timestamp without time zone, timestamp with time zone]: col_def f{col_name} {data_type} elif data_type date: col_def f{col_name} DATE else: col_def f{col_name} {data_type.upper()} col_defs.append(col_def) create_sql , .join(col_defs) ) conn.execute(text(create_sql)) conn.commit() # 3. 分片DataFrame并上传至S3 s3_client boto3.client(s3) df_splits np.array_split(df, max(1, (len(df)-1) // chunksize 1)) s3_keys [] for i, df_split in enumerate(df_splits): # 生成本地临时文件 local_file f/tmp/{target_table}_{now_str}_{i}.csv # 导出CSV应用所有pandas侧参数 df_split.to_csv( local_file, indexFalse, headerFalse, na_rep\\N, date_format%Y-%m-%d %H:%M:%S, float_format%.12f, quotingcsv.QUOTE_MINIMAL, encodingutf-8 ) # 生成S3 key s3_key f{s3_prefix}/{s3_date_dir}/{target_table}_{now_str}_{i}.csv s3_keys.append(s3_key) # 上传 s3_client.upload_file(local_file, s3_bucket, s3_key) # 验证 if verify_s3: local_size os.path.getsize(local_file) try: response s3_client.head_object(Buckets3_bucket, Keys3_key) if response[ContentLength] ! local_size: raise ValueError(fS3 size mismatch for {s3_key}) except ClientError as e: raise RuntimeError(fS3 verify failed: {e}) # 清理本地文件 os.remove(local_file) # 4. 执行COPY命令 copy_sql fCOPY {staging_table} FROM s3://{s3_bucket}/ # 拼接所有S3 keysCOPY支持多个路径 for s3_key in s3_keys: copy_sql fs3://{s3_bucket}/{s3_key} copy_sql fCREDENTIALS aws_iam_role{iam_role_arn} copy_sql DELIMITER , IGNOREHEADER 0 NULL AS \\N if truncate_columns: copy_sql TRUNCATECOLUMNS copy_sql fMAXERROR {max_error} ERROR AS TABLE {staging_table}_error; try: with engine.connect() as conn: conn.execute(text(copy_sql)) conn.commit() except Exception as e: # 记录错误但不抛出留给后续校验 print(fCOPY failed: {e}) raise # 5. 数据校验与加载 with engine.connect() as conn: # 校验staging表行数 staging_count conn.execute(text(fSELECT COUNT(*) FROM {staging_table})).scalar() if staging_count 0: raise ValueError(fStaging table {staging_table} is empty! Check COPY errors.) # 校验错误表 error_count conn.execute(text(fSELECT COUNT(*) FROM {staging_table}_error)).scalar() if error_count 0: print(f⚠️ {error_count} rows failed in COPY. See table {staging_table}_error.) # 可选导出错误表供分析 # error_df pd.read_sql(fSELECT * FROM {staging_table}_error LIMIT 10, conn) # 原子性加载到目标表追加模式 load_sql fINSERT INTO {schema}.{target_table} SELECT * FROM {staging_table}; conn.execute(text(load_sql)) conn.commit() print(f✅ Loaded {len(df)} rows to {schema}.{target_table} via {staging_table}) return staging_table # 使用示例 if __name__ __main__: # 构造测试数据 test_df pd.DataFrame({ id: range(1, 100001), name: [fuser_{i} for i in range(1, 100001)], amount: np.random.randn(100000) * 1000, event_time: pd.date_range(2024-01-01, periods100000, freq1T), desc: [desc_ str(i % 10) for i in range(100000)] }) # 执行ETL staging_name pandas_to_redshift( dftest_df, target_tabletest_load, schemapublic, s3_bucketmy-bucket, iam_role_arnarn:aws:iam::123456789012:role/RedshiftS3ReadRole )4.3 关键参数计算chunksize、S3分片、并发度如何量化设定参数不是拍脑袋必须可计算chunksize单CSV行数目标单CSV文件≤500MB。估算公式chunksize ≈ 500 * 1024 * 1024 / (avg_row_bytes)avg_row_bytes怎么算用样本数据sample_df df.sample(min(10000, len(df))) # 取1万行样本 avg_bytes sample_df.to_csv(indexFalse, headerFalse, na_rep\\N).encode(utf-8).__sizeof__() / len(sample_df) chunksize int(500 * 1024 * 1024 / avg_bytes)我们线上通用值是5000000500万行覆盖90%场景。S3分片数num_shards ceil(len(df) / chunksize)。但RedshiftCOPY对S3路径数也有限制单次COPY最多支持1000个S3路径。若num_shards 1000必须合并路径将多个CSV压缩为ZIPCOPY时指定GZIP或ZIP参数。我们极少遇到因500万行/片10亿行才需200片。并发度同时运行的ETL任务数由Redshift集群规格决定。dc2.large2节点最大连接数500每个ETL任务占用1个连接get_redshift_engine()的pool_size10是每个任务内连接池非全局。因此并发任务数 ≤ 500 / 10 50。我们保守设为30留20连接给BI查询。5. 常见问题与排查技巧实录那些让你凌晨三点爬起来的错误5.1 COPY命令失败从日志定位根因的黄金三步法RedshiftCOPY失败日志只给一句Load error: X rows processed毫无头绪按此流程10分钟定位第一步查STL_LOAD_ERRORS系统表SELECT * FROM STL_LOAD_ERRORS WHERE starttime 2024-05-20 14:00:00 ORDER BY starttime DESC LIMIT 10;关键字段filename哪个S3文件、line_number第几行、colname哪一列、raw_line原始行内容、err_reason错误原因如Invalid digit。第二步用ERROR AS TABLE查错误详情如COPY语句含ERROR AS TABLE stg_test_20240520_1423_error直接查该表SELECT colname, colvalue, err_reason FROM stg_test_20240520_1423_error LIMIT 5;colvalue显示原始字符串err_reason明确指出是Invalid timestamp还是Numeric value out of range。第三步人工复现解析取raw_line用Python模拟Redshift解析line 123,user_456,1234.56789012,2024-05-20 14:23:15,desc_7 parts line.split(,) # 检查parts[2]能否转为Decimal(18,12) parts[3]能否转为datetime这能100%复现Redshift的解析逻辑。实操心得我们把这三步封装成debug_copy_error(staging_table)函数输入staging表名自动输出错误摘要。上线后ETL故障平均修复时间从47分钟降至6分钟。5.2 连接池耗尽sqlalchemy.exc.TimeoutError的终极解法现象Airflow DAG并发跑10个ETL任务第7个开始报TimeoutError: QueuePool limit of size 10 overflow 20 reached, connection timed out, timeout 30。这不是代码问题是连接池配置与Redshift规格不匹配。**