数据采集失败怎么办?异常处理和重试机制代码详解

国资报送的数据采集组件,写个 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防的是行级重复(数据层面安全),批次标记防的是无效重写(性能层面省力)。事务里同时写数据和标记,保证原子性。

五、告警和可观测:失败要有人知道

异常处理最后一块:让失败可见。

  • 分级告警:重试成功的不告(日志记录即可,这是正常波动)、重试耗尽的告警(企业微信或短信,这是需要关注的)、连续多个任务失败的升级告警(电话,这是系统性故障)
  • 指标埋点:采集任务的成功率、重试次数分布、死信批次趋势。这些指标进看板(搭贝低代码平台搭个监控看板,任务运行数据自动汇总),异常趋势(成功率连续三天下滑)比单次失败更有预警价值
  • 告警收敛:同一根因的告警合并(数据库挂了会导致五十个任务同时告,合并成一条根因告警)。告警风暴的后果是所有告警被忽略——狼来了的故事在运维圈天天上演

六、完整的异常处理清单

采集组件上线前的自查清单,逐项过。

  1. 异常分类:可重试和不可重试分开处理了吗(傻重试比不重试更糟)
  2. 退避参数:指数退避加抖动加上限,配置化(不写死,环境不同最优参数不同)
  3. 位点续传:中途失败能从断点继续吗(从头重来的任务在数据量大时不可接受)
  4. 幂等保障:重试会不会产生重复数据(UPSERT或批次标记,选一种或都上)
  5. 死信队列:坏数据有去处吗(不阻塞主流程,事后可修复重放)
  6. 告警链路:重试耗尽有人知道吗(没人知道的失败等于没处理)
  7. 日志规范:失败日志带任务号、批次号、根因异常栈(排障时这三样缺一不可)

常见问题

Q:重试次数设多少合适?
经验值:网络类异常三到五次(秒级退避)、数据库维护类五到八次(分钟级退避上限)。更好的做法:按任务的重要度分级配置(监管报送任务的实时性要求高,重试两三次就该转备用链路或告警;内部同步任务宽松些)。别拍一个全局值,分场景。

Q:死信队列的数据怎么重放?
死信记录带上下文(原始数据、失败原因、批次信息),修复后按批次重放(不是单条,同批的数据可能有同样的问题)。重放走和主流程一样的幂等写入(防重复)。死信的清理策略:重放成功后归档,超过N天的死信说明没人管,升级告警——死信队列堆着不动,等于把问题扫进地毯下面。

Q:多任务并发采集互相影响怎么办?
资源隔离:不同源库的任务用不同的连接池(一个源库故障不影响其他源的任务)、同源库的任务限并发(信号量控制同时跑的任务数,别把源库连接打满)。调度侧的错峰(大任务排夜间、小任务白天插空),采集组件管好自己的资源池,调度错峰交给调度层。

Q:这套机制的代码量占多大比例?
生产级采集组件里,Happy Path(查询转换写入)约占两成,异常处理加重试加位点加幂等占八成。这个比例是正常的——demo看功能,生产看异常。图快省掉这八成的,上线两周后会在凌晨的告警电话里把这八成补回来(代价更高)。

posted @ 2026-08-25 11:33  人生404  阅读(14)  评论(0)    收藏  举报