4. 异常处置核心概念

在算法执行系统中,异常不是“意外”,而是必须被建模、被观测、被处置的一等公民。很多团队把异常处置等同于“加 try-catch”,结果线上仍然频繁出现任务卡死、状态不一致、重复扣款、数据缺口等问题。根本原因在于:没有把异常当作一条完整的生命周期来管理。本节围绕五个核心概念展开:异常定义、异常分类、异常传播、异常恢复与容错机制,帮助大家建立可落地的异常处置心智模型。

一、异常定义:从“报错”到“可判定事件”

在工程语境中,异常定义不能停留在“程序出错了”。一个可处置的异常,必须满足三个条件:可判定、可定位、可决策。可判定是指系统能通过明确规则识别异常发生;可定位是指能关联到具体任务、数据分片、模型版本或调用链;可决策是指处置策略有明确分支,而不是靠人临时拍脑袋。

因此,异常定义应包含结构化字段,例如异常码、异常级别、发生阶段、影响范围、是否可重试、建议动作。下面是一个典型的异常事件结构:

{
  "error_code": "ALGO_DATA_TIMEOUT",
  "level": "P1",
  "stage": "feature_fetch",
  "task_id": "task_20240612_0091",
  "shard": "shard-17",
  "retryable": true,
  "suggest_action": "retry_with_backoff",
  "occur_time": "2024-06-12T10:23:41+08:00"
}

只有把异常从“字符串日志”升级为“结构化事件”,后续的分类、传播和恢复才有依据。

二、异常分类:决定处置策略的第一道分水岭

异常分类的核心目的不是“归类好看”,而是让不同异常走不同处置通道。最常见的分类维度包括:按来源分为数据异常、算法异常、系统异常、依赖异常;按可恢复性分为可重试、可降级、需人工介入、不可恢复;按影响面分为单任务、单分片、单租户、全局。

其中最关键的是“可重试性”判断。把不可重试异常反复重试,会放大故障;把可重试异常直接判死,会造成不必要的业务损失。下表给出一个实战中常用的分类与处置对照清单:

异常类型 典型示例 可重试性 建议处置
数据异常 字段缺失、格式错误、分布漂移 部分可重试 校验拦截、隔离脏数据、触发补数
算法异常 除零、溢出、模型输出越界 通常不可重试 熔断当前分片、回退旧模型、人工复核
系统异常 OOM、磁盘满、线程池耗尽 可重试但需限流 退避重试、资源隔离、扩容
依赖异常 下游超时、限流、连接拒绝 可重试 指数退避、降级缓存、熔断
逻辑异常 状态机非法迁移、幂等冲突 不可重试 冻结任务、告警、人工介入
分类不是一次性工作。建议每季度复盘一次异常分类表,把“新出现的异常”补进去,把“从未触发的分支”清理掉,保持分类表与真实故障分布一致。

三、异常传播:从单点故障到系统雪崩的路径

异常传播描述的是异常如何在调用链、数据流和任务依赖中扩散。算法执行系统通常存在多层调用:调度层 → 执行层 → 数据层 → 模型层 → 下游服务。如果异常在某一层没有被正确拦截或转换,就会向上传播,最终表现为“整个任务失败”甚至“整个集群不可用”。

异常传播有三种典型模式:向上冒泡、横向扩散、延迟爆发。向上冒泡是调用栈逐层抛出;横向扩散是一个分片失败导致重试风暴,拖垮同批次其他任务;延迟爆发是异常被吞掉后,在后续环节以数据不一致的形式出现,排查成本最高。

控制异常传播的关键手段是“边界拦截”和“异常转换”。在每一层边界处,把底层异常转换为上层可理解的异常,并决定是继续抛出、降级还是终止。下面是一个边界拦截的伪代码示例:

def execute_shard(shard):
    try:
        data = fetch_features(shard)
        result = run_model(data)
        return result
    except DataTimeoutError as e:
        raise AlgoStageError(
            code="FEATURE_TIMEOUT",
            retryable=True,
            cause=e
        )
    except ModelOutputError as e:
        log.error("model output invalid", shard=shard)
        return fallback_result(shard)

这段代码的价值在于:它没有让底层异常直接穿透到调度层,而是把异常转换成了带有处置语义的上层异常。

异常传播与边界拦截机制 调度层 任务编排 / 重试决策 执行层 分片执行 / 边界拦截 数据/模型层 取数 / 推理 / 校验 异常冒泡 未拦截 → 任务失败 异常转换 底层异常 → 上层语义 降级/熔断 阻断横向扩散 恢复与容错决策 重试(退避) · 降级(缓存/旧模型) · 隔离(分片/租户) · 人工介入

四、异常恢复与容错机制:从“能恢复”到“恢复得对”

异常恢复关注的是“异常发生后如何回到正确状态”,容错机制关注的是“系统在部分异常下仍能继续提供服务”。两者互补:恢复是事后动作,容错是事前设计。

常见的恢复策略包括:重试(带指数退避和抖动)、补偿(对已产生副作用进行反向操作)、回滚(回到上一个一致状态)、重放(基于日志或消息重新执行)。容错机制则包括:冗余(多副本)、隔离(舱壁模式)、降级(返回兜底结果)、熔断(快速失败防止雪崩)。

在算法执行场景中,最容易被忽视的是“幂等性”。如果重试或重放不具备幂等性,恢复动作本身就会制造新的数据错误。因此,恢复机制设计必须与幂等键、状态机、去重表配合使用。

不要在没有幂等保障的情况下开启自动重试。重试次数越多,重复写入、重复扣款、重复推送的风险越大。建议先做幂等,再做重试。

五、案例与复盘:一次特征超时引发的连锁故障

某推荐算法任务在晚高峰出现大面积失败。初始异常是特征服务超时,属于依赖异常,理论上可重试。但由于执行层没有做退避和限流,所有分片同时重试,导致特征服务负载进一步升高,超时加剧,最终演变为全局雪崩。复盘发现三个问题:第一,异常分类中未区分“可重试但需限流”;第二,异常传播没有边界拦截,底层超时直接冒泡到调度层;第三,恢复策略只有“立即重试”,没有降级和熔断。

改进方案是:在异常分类中增加“重试需限流”标签;在执行层增加异常转换和退避重试;对特征服务增加熔断和本地缓存降级;对任务增加幂等键,确保重放安全。改进后,同类故障的影响面从“全局失败”缩小到“单分片延迟”,恢复时间从小时级降到分钟级。

异常处置的核心不是消灭异常,而是让异常在可控范围内发生、被正确识别、被有序传播、被合理恢复。定义清楚、分类准确、传播可控、恢复可靠、容错到位,五者缺一不可。