数据采集失败怎么办?异常处理和重试机制代码详解
国资报送的数据采集组件,写个 Happy Path 不难(查源库、转换、写目标库),难的是异常处理:源库偶尔超时、网络抖一下、目标库锁表。生产环境的采集组件,八成的代码量在异常处理和重试上。
这篇把一套实战的异常处理和重试机制代码拆开讲。代码是Python实现(逻辑通用,Java或Go同理),来源是实际报送项目的组件(脱敏简化)。
一、异常分类:先分清能不能重试
异常处理的第一原则:不是所有异常都值得重试。
- 可重试异常:网络超时(ConnectionTimeout)、连接中断(ConnectionReset)、数据库死锁(Deadlock)、临时不可用(数据库重启中)。特征:瞬时的环境问题,重试大概率自愈
- 不可重试异常:SQL语法错、字段类型不匹配、唯一键冲突(数据本身有问题)、权限不足。特征:重试一万次还是同样结果,重试只是浪费资源
- 灰度异常:源库慢查询超时(可能自愈可能持续)、目标库磁盘满(人工处理前不会好)。策略:有限次重试,超限告警转人工
分类的价值:可重试的才进重试流程,不可重试的直接进错误队列(数据修复后重新采集)。不分类型的傻重试,把错误数据反复撞库,日志爆炸、库压力大、问题还解决不了。
二、重试的核心参数:退避策略
重试的节奏比次数重要。
import time
import random
from functools import wraps
def retry_with_backoff(max_retries=5, base_delay=1.0, max_delay=60.0,
retryable=(ConnectionError, TimeoutError)):
"""指数退避+抖动的重试装饰器"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
attempt = 0
while True:
try:
return func(*args, **kwargs)
except retryable as e:
attempt += 1
if attempt > max_retries:
raise RetryExhaustedError(
f"重试耗尽: {func.__name__}, 共{max_retries}次") from e
# 指数退避:1s, 2s, 4s, 8s...上限60s
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
# 抖动:避免多个采集任务同步重试(雪崩)
delay = delay * (0.5 + random.random())
logger.warning(
f"可重试异常 {type(e).__name__},"
f"第{attempt}次重试,{delay:.1f}s后: {e}")
time.sleep(delay)
except Exception as e:
# 不可重试异常直接抛出
logger.error(f"不可重试异常: {e}")
raise
return wrapper
return decorator
三个设计点:
- 指数退避:重试间隔翻倍(1秒、2秒、4秒),给下游恢复时间。固定间隔的重试(每秒一次)在下游过载时是补刀
- 抖动(Jitter):重试间隔加随机量。多个采集任务同时失败时(数据库重启),没有抖动的重试会在同一时刻齐刷刷涌回,二次压垮刚恢复的服务
- 重试上限:五次够多数场景。重试不是无限续命,超出就转异常队列,别让任务卡死在重试循环里
三、断点续传:批次和位点
采集任务中途失败,重来还是续传?位点管理。
class CheckpointManager:
"""采集位点管理:记录进度,失败续传"""
def __init__(self, store):
self.store = store # 位点存储(redis或本地文件)
def save(self, task_id, batch_no, last_pk):
"""每批完成即存位点"""
self.store.hset(f"ckpt:{task_id}",
mapping={"batch": batch_no, "pk": last_pk,
"ts": time.time()})
def resume(self, task_id):
"""失败重启后取位点,从断点继续"""
ckpt = self.store.hgetall(f"ckpt:{task_id}")
if not ckpt:
return 0, 0 # 从头开始
return int(ckpt["batch"]), int(ckpt["pk"])
def run_collect(task_id, query_sql, pk_field):
"""主流程:分批采集+位点保存"""
batch_no, last_pk = CheckpointManager(store).resume(task_id)
while True:
# 按主键游标分批,不依赖offset(数据在变,offset会漂)
rows = fetch_batch(query_sql, pk_field, last_pk, size=500)
if not rows:
break
try:
write_target(rows) # 带重试装饰的写入
except RetryExhaustedError:
# 重试耗尽:本批进错误队列,不阻塞后续批次
send_to_dead_letter(rows, task_id, batch_no)
last_pk = rows[-1][pk_field]
batch_no += 1
CheckpointManager(store).save(task_id, batch_no, last_pk)
要点:
- 主键游标分页:用last_pk(上一批最后的主键值)翻页,不用offset。采集期间源库有新增数据时,offset会漏数据或重复,主键游标稳定
- 每批存位点:失败重启后从断点继续,不从头再来。位点存储要可靠(redis持久化或本地文件加fsync)
- 死信队列:某批写不进(数据问题)先进死信,不阻塞整个任务。任务跑完再统一处理死信(修数据重放),实时性和吞吐都保住
四、幂等写入:重试不重复
重试的前提是写入幂等(同批数据写两次效果一样),不然重试制造重复数据。
def write_idempotent(rows, batch_key):
"""幂等写入:唯一键+UPSERT"""
# 方案1:数据库UPSERT(存在则更新)
upsert_sql = """
INSERT INTO report_data (batch_key, biz_id, field1, field2)
VALUES (%s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
field1 = VALUES(field1),
field2 = VALUES(field2)
"""
# 方案2:批次号防重(整批去重)
if store.sismember("done_batches", batch_key):
logger.info(f"批次已写入,跳过: {batch_key}")
return
with transaction():
bulk_write(upsert_sql, rows)
store.sadd("done_batches", batch_key) # 事务内记录
两层防重:行级UPSERT(单行重复无害)加批次级标记(整批跳过)。为什么两层都要:UPSERT防的是行级重复(数据层面安全),批次标记防的是无效重写(性能层面省力)。事务里同时写数据和标记,保证原子性。
五、告警和可观测:失败要有人知道
异常处理最后一块:让失败可见。
- 分级告警:重试成功的不告(日志记录即可,这是正常波动)、重试耗尽的告警(企业微信或短信,这是需要关注的)、连续多个任务失败的升级告警(电话,这是系统性故障)
- 指标埋点:采集任务的成功率、重试次数分布、死信批次趋势。这些指标进看板(搭贝低代码平台搭个监控看板,任务运行数据自动汇总),异常趋势(成功率连续三天下滑)比单次失败更有预警价值
- 告警收敛:同一根因的告警合并(数据库挂了会导致五十个任务同时告,合并成一条根因告警)。告警风暴的后果是所有告警被忽略——狼来了的故事在运维圈天天上演
六、完整的异常处理清单
采集组件上线前的自查清单,逐项过。
- 异常分类:可重试和不可重试分开处理了吗(傻重试比不重试更糟)
- 退避参数:指数退避加抖动加上限,配置化(不写死,环境不同最优参数不同)
- 位点续传:中途失败能从断点继续吗(从头重来的任务在数据量大时不可接受)
- 幂等保障:重试会不会产生重复数据(UPSERT或批次标记,选一种或都上)
- 死信队列:坏数据有去处吗(不阻塞主流程,事后可修复重放)
- 告警链路:重试耗尽有人知道吗(没人知道的失败等于没处理)
- 日志规范:失败日志带任务号、批次号、根因异常栈(排障时这三样缺一不可)
常见问题
Q:重试次数设多少合适?
经验值:网络类异常三到五次(秒级退避)、数据库维护类五到八次(分钟级退避上限)。更好的做法:按任务的重要度分级配置(监管报送任务的实时性要求高,重试两三次就该转备用链路或告警;内部同步任务宽松些)。别拍一个全局值,分场景。
Q:死信队列的数据怎么重放?
死信记录带上下文(原始数据、失败原因、批次信息),修复后按批次重放(不是单条,同批的数据可能有同样的问题)。重放走和主流程一样的幂等写入(防重复)。死信的清理策略:重放成功后归档,超过N天的死信说明没人管,升级告警——死信队列堆着不动,等于把问题扫进地毯下面。
Q:多任务并发采集互相影响怎么办?
资源隔离:不同源库的任务用不同的连接池(一个源库故障不影响其他源的任务)、同源库的任务限并发(信号量控制同时跑的任务数,别把源库连接打满)。调度侧的错峰(大任务排夜间、小任务白天插空),采集组件管好自己的资源池,调度错峰交给调度层。
Q:这套机制的代码量占多大比例?
生产级采集组件里,Happy Path(查询转换写入)约占两成,异常处理加重试加位点加幂等占八成。这个比例是正常的——demo看功能,生产看异常。图快省掉这八成的,上线两周后会在凌晨的告警电话里把这八成补回来(代价更高)。
浙公网安备 33010602011771号