我上周排查了一个线上问题,印象特别深:接口返回的HTTP状态码是200,页面却白屏,前端说“后端报错了”,后端说“我没抛异常啊,日志里全是业务失败”。两边各执一词,最后翻了半天日志才发现,真正的异常早被某个中间层catch住,然后塞进了data字段里的一个错误对象里,谁都没想到去解析它。
这种问题在项目里太常见了。根子不在某个代码写错了,而在于整个团队没有一套统一的东西来回答三个基本问题:程序什么时候该返回数据,什么时候该抛异常,业务失败和系统故障又该怎么表达。这个统一的东西,就是我理解的“错误模型”。它本质上是一套关于“数据、异常与正常返回”这三类结果如何分类、如何传播、如何呈现的规范。如果你正在维护接口、写SDK、搭数据管道,或者天天被各种异常日志折腾,这套东西值得认真梳理一遍。
1. 为什么一个“约定”能决定线上故障的处理速度
1.1 同一个接口,三种失败表达方式
先看一个很典型的场景。团队里三个不同时期的人写过同一个getUser接口,函数签名完全一样,但失败时的表达方式完全不同:
- 早期版本:查不到用户时返回null,调用方拿到null之后做判空,基本都漏判了,NPE到处飞。
- 中期版本:查不到用户时直接抛一个RuntimeException,导致前端收到500,用户看到“系统繁忙”。
- 近期版本:返回一个Result对象,code为1001,message为“用户不存在”,HTTP状态码还是200。
这三个版本同时存在于同一个系统里,因为历史包袱没法一次性清理。结果就是,调用方拿到返回值之后要写三套防御逻辑:先判空,再try-catch,再检查code。每多一个调用方,这种重复劳动就多一次。更麻烦的是,新来的同事根本不知道该按哪套规则来,于是第四种表达方式诞生了:把错误信息直接写进data字段里的一个map里。
这种混乱的代价平时看不出来,一旦线上出问题就非常难受。日志里既有空指针异常,又有业务错误码,还有各种自定义的失败标识,监控系统根本没法归因——到底有多少请求是真正失败的?失败的原因是哪一类?完全说不清楚。
1.2 错误模型定义的三件事
所以我一直觉得,错误模型不是一个抽象概念,它其实只回答三件事。
第一,错误如何分类。就是说你要给程序里可能出现的所有“不对劲”分门别类:哪些是调用方传参不对,哪些是业务状态冲突,哪些是我们自己的系统故障,哪些是第三方依赖出了问题。分类是后面一切的基础,分类不清,后面全乱。
第二,错误如何传播。就是说当错误发生时,它是通过异常堆栈一层层抛上去,还是通过返回值一路传回去,还是在某个边界被转换成统一的错误码。这个决定了每一层代码要怎么写,也决定了日志里最终能看到什么。
第三,错误如何呈现。就是说最终对外部世界(前端、调用方、监控系统、运维平台)表现出什么形式:HTTP状态码是多少,响应体里有什么字段,日志里记哪些信息,告警系统按什么维度去聚合。
有个很容易理解的类比是医院分诊。病人来医院,护士要先判断他是普通感冒、慢性病复发还是突发急症,然后决定让他去门诊、住院部还是急诊,最终体现在挂号单上。如果没有这套分诊逻辑,所有病人都挤在一个窗口,急诊病人排队等两小时,结果可想而知。代码里的错误处理也是一样,不分类就到处catch,最后一定是小问题拖成大故障。
1.3 没有错误模型时,日志和监控会“说谎”
没有统一错误模型的时候,最可怕的不是代码乱,而是日志和监控会给你传递错误的信息。
举个例子,某个账号系统对异常登录的提示是“您的网络存在异常流量,请稍后重试”。从用户视角看,这像是一个网络问题;从日志来看,如果网关层把这股流量记成了“网络异常”,监控系统就会误以为链路质量出问题了,但实际上链路完全正常,是业务风控在拦截。你说这个算网络异常还是业务异常?如果错误模型里没有“第三方风控拦截”这个分类,它就很容易被归到网络错误里去,误导一大片排查方向。
还有一个常见情况:DB连接池被耗尽时,业务代码抛的是“获取连接超时”异常,但很多团队习惯把它包一层,变成“系统繁忙请稍后再试”返回给前端。前端看到了错误提示,日志里也确实有记录,但如果没人把“获取连接超时”这个根因错误透传出来,监控系统统计的是一堆“业务繁忙”信号,而不是真正的“数据库连接池不足”信号,DBA就永远不会被叫醒。
所以说,错误模型不是文档层面的洁癖,它直接决定了一个故障从发生到被发现、定位、恢复要花多长时间。
2. 数据、异常与正常返回:边界划错了,代码就会歪
2.1 正常返回:你和调用方之间的数据契约
正常返回这件事,听起来很简单——函数就是返回一个结果呗。但实际上“正常”这两个字特别容易被理解窄了。
一个正常的返回结果,不光是“没报错就返回”,它还需要明确:返回的数据结构是什么,字段能不能为null,集合为空时返回空数组还是null,有没有分页信息,数据的时间范围是什么。这些都属于数据契约的一部分。带不带这几个信息,调用方写出来的代码完全不一样。
比如一个返回最近订单列表的接口,如果订单为空时返回一个空数组,调用方直接遍历就行;如果返回null,调用方就得先判空;如果返回的是一个带total和list的包装对象,调用方要取两次字段。这些都不是什么高深技术,但要是接口文档没写清楚,调用方就只能靠猜,猜错了就出线上事故。
我见过最离谱的情况是,同一个c端接口在订单为空时返回了空数组,在用户不存在时返回了null,在活动未开始时返回了一个空对象。同一个函数,三种空的表现形式,调用方要写三个分支。这个接口的“正常返回”实际上是不正常的,因为它没有统一的数据契约。正确做法是无论什么情况,响应结构都保持一致,只在业务码字段上做区分。
2.2 异常:把中断变成有含义的信号
异常机制的发明理由很简单:当深层代码出错时,它没法保证每一层调用者都愿意处理这个错误,所以需要通过栈回退把这个信号一路传上去,传到某个愿意处理它的地方。
异常本质上是一个跨层传播的“中断信号”。它的优势很明显:带着完整的堆栈,能告诉你是哪一行代码引发了问题;不需要每一层都显式传递错误返回值。但它的代价也同样明显:一旦抛出来,控制流就中断了,如果没人catch,整个请求就挂了。
所以什么时候该抛异常,什么时候该返回数据,一个比较实用的判断标准是看“调用方还有没有机会继续做正事”。如果错误发生之后调用方根本没法继续进行后续逻辑,那就应该抛异常,强制中断;如果调用方拿到错误信息后还能有别的选择(比如换个参数重试、走降级分支),那就该把它放到返回值里,让调用方自己决定下一步。
2.3 业务异常与系统异常:最容易被混为一谈的两类
我观察到的绝大多数团队,代码里只有一个Exception类,所有错误都用它来表示,顶多message文案不一样。这种做法短期看省事,长期看就是在给排障埋雷。
业务异常,指的是调用方可以通过修正自己的行为来避免的错误。典型例子包括:参数校验不通过、请求的资源不存在、账户余额不足、订单状态已变更、频率超限。这类错误的共同点是:它们是业务规则的一部分,属于可预期的、有明确业务含义的失败。对这类错误,建议用错误码+返回结构的方式表达,让调用方可以根据错误码做针对性处理。
系统异常,指的是当前服务因为自身问题无法完成请求的错误。典型例子包括:数据库连接失败、Redis超时、磁盘空间不足、代码里出现空指针。这类错误对调用方来说通常意味着“这个服务目前不可用”,调用方只能做有限的处理(比如重试、熔断、降级),更多时候需要的是服务方自己去修复。对这类错误,建议直接抛出带有完整堆栈的异常,由统一异常处理器接管,并且一定要触发监控告警。
这两类错误的边界,决定了错误处理代码往哪个方向写。如果把业务异常当成系统异常处理,前端会收到一堆500,用户体验极差;如果把系统异常当成业务异常处理,那数据库挂了接口还是返回200,监控系统全程哑巴,直到用户大量投诉才发现问题。
2.4 边界模糊地带:部分成功、降级返回、第三方依赖错误
现实世界不是非黑即白,有些错误场景天生就卡在“正常返回”和“异常”之间。
最典型的是批量接口。比如一个批量导入接口,用户一次提交100条数据,只有85条导入成功,15条因为各种原因失败。这种情况你没法返回正常,也没法直接抛异常,因为确实有一部分工作是成功的。正确的表达方式是:在正常返回结构里增加“成功数量”和“失败明细”两个字段,把15条失败的具体原因逐条列出来。这就是错误模型里说的“部分成功”,它也是一种正常返回,只是带着错误明细的正常返回。
还有一个麻烦的场景是降级返回。比如推荐接口依赖的算法服务超时了,你选择返回缓存中的旧数据。这个接口本质上是遇到了系统异常,但你在错误模型层面把它转化成了正常返回,只是数据的时间戳是旧的。这种降级一定要在响应体里明确标注“数据降级”“数据时间”相关信息,否则调用方会拿旧数据当新数据用,整个业务逻辑就跑偏了。
第三方依赖错误也要小心。你的系统调了一个外部接口,外部接口返回了502,或者返回了一个业务错误码“当前网络异常请使用正确网段”,你该怎么处理?把它原样透传给自己的调用方是不负责任的,因为你调用方根本没法处理这个外部错误。把它吞掉假装成功更危险。正确做法是转换成系统异常分类下的“依赖服务异常”,带上原始错误信息,让监控系统知道是哪个外部依赖出了问题,同时考虑是否要触发重试或者熔断。
3. 设计一套能落到代码里的错误模型
3.1 错误码:既要给机器看,也要给文档看
错误码这个东西,很多团队要么完全不用,要么就随便弄几个数字,完全没有规则。但实际上,错误码是错误模型里最容易被机器和文档共同消费的部分——代码可以根据它做分支判断,运维可以按它聚合告警,用户支持也能按它查知识库。
我建议错误码采用带前缀的编号规则,前缀表示错误大类,数字表示具体错误:
| 错误码前缀 | 所属大类 | 典型错误示例 |
|---|---|---|
| A | 参数校验类 | A1001参数缺失,A1002参数格式错误 |
| B | 业务规则类 | B2001用户不存在,B2002余额不足,B2003订单已关闭 |
| C | 依赖服务类 | C3001外部接口超时,C3002外部接口返回错误码 |
| D | 系统内部类 | D4001数据库连接失败,D4002Redis超时,D4003未知异常 |
| E | 权限认证类 | E5001未登录,E5002无权限 |
这个设计有几个好处。第一,看到错误码的第一位字母,你就能立刻判断这是哪一类问题,排查方向马上就清晰了。第二,错误码是稳定的,不像message那样可以随意改,可以作为机器对账的凭证,也可以作为文档检索的key。第三,监控系统可以按前缀聚合告警,A类错误多说明调用方接入有问题,C类错误多说明外部依赖不稳,D类错误多说明自己系统出毛病了。
另外提醒一句,错误码手册要像API文档一样长期维护,每个错误码都要有对应的解释、触发条件、处理建议。我见过很多团队最初的几十个错误码规范得挺好,后来随着业务迭代新增了几百个,绝大多数没有文档,最后整个体系等于废了。
3.2 异常层级:自定义异常别乱继承
Java里自定义异常,最忌讳的是直接继承Exception或者RuntimeException,然后所有地方都用这一个类。这样的话,异常的分类信息就完全丢了。
我实践下来比较好用的一组层级是这样的:
// 基础异常,持有错误码和上下文 public class BaseException extends RuntimeException { private String code; private Map<String, Object> context; public BaseException(String code, String message, Map<String, Object> context) { super(message); this.code = code; this.context = context; this.time = System.currentTimeMillis(); } } // 业务异常:调用方可以通过修正行为来解决 public class BusinessException extends BaseException { public BusinessException(String code, String message) { super(code, message, null); } } // 系统异常:服务自身故障,需要告警 public class SystemException extends BaseException { public SystemException(String code, String message, Throwable cause) { super(code, message, cause); } } // 依赖异常:第三方服务出了问题 public class ThirdPartyException extends BaseException { public ThirdPartyException(String code, String message, Throwable cause) { super(code, message, cause); } }这里面有一个细节值得特别提一下:异常一定要带时间戳和上下文信息。基础异常带上创建时间戳,就能知道这个异常是什么时候产生的,而不依赖日志系统的时间。更重要的是context字段,可以把当时的用户ID、请求ID、关键参数都放进去。
throw new BusinessException("B2002", "账户余额不足") .withContext("userId", userId) .withContext("orderNo", orderNo) .withContext("balance", balance);这样在异常监控平台里,你一眼就能看到是哪个用户、哪个订单、差多少钱,不用再翻一堆日志去拼上下文。这个习惯在排查“同一种异常大量出现但很难定位原因”的场景里非常管用。
3.3 返回结构:一个统一的外壳可以解决很多问题
对于对外接口(HTTP、RPC),我建议所有的正常返回和业务异常都走统一的外壳结构,而不是靠HTTP状态码来区分业务成败。HTTP状态码只表达传输层的含义:2xx代表请求被处理了,4xx代表调用方问题,5xx代表服务方问题。至于具体业务是否成功,由业务码来表达到底是哪一种结果。
一个比较实用的外壳是这样的:
{ "code": "0", "message": "success", "data": {}, "traceId": "a1b2c3d4e5f6", "time": 1723456789012 }code为0代表成功,非0代表业务失败;message给前端用户看;data放实际业务数据;traceId用于串联日志;time方便排查时间相关的问题。如果遇到数据库连接失败这类系统异常,HTTP状态码返回500,外壳里code为D4001,data为null,message是“系统繁忙”,但日志和监控里记录的是完整的堆栈和上下文。
这个外壳设计最核心的原则是:结构恒定,字段语义恒定。无论成功失败,响应体永远是这几个字段。调用方只需要判断code,不需要根据HTTP状态码去猜流程。
Python里也是一样的思路,可以写一个统一的小工具:
@dataclass class ApiResult: code: int = 0 message: str = "success" data: any = None trace_id: str = "" time: int = field(default_factory=lambda: int(time.time() * 1000)) def ok(data=None): return ApiResult(data=data) def err(code: int, message: str): return ApiResult(code=code, message=message)3.4 什么时候抛异常,什么时候返回错误码
这是错误模型设计里最实操的一个问题,也是团队里争论最多的。我的原则是三句话:越靠近内部逻辑,越用异常;越靠近系统边界,越用错误码;业务异常走返回,系统异常走抛出。
内部逻辑层,比如service层内部调用dao层,如果查不到数据,直接抛一个BusinessException("B2001", "用户不存在")是最省事的。因为service层知道自己没有能力恢复这个错误,只能把信号传上去,让上层的controller做翻译。
到了controller层,直接catch业务异常并转成外壳里的错误码。此时业务异常已经被“翻译”成了调用方可以看到的结果,而不是堆栈里的一个异常。系统异常则不要catch,统一交给最外层的异常处理器,或者由全局拦截器接管,记录完整堆栈并按需返回5xx。
有一个特别需要避免的反模式:catch住异常之后打印一行日志,然后返回一个null或者空对象。举个例子,有些程序在启动时弹窗“应用程序中发生了无法处理的异常,如果单击继续...”,这种半死不活的状态最坑人——你既不知道错误是什么,也不知道程序还能不能用。代码里catch到异常又吞掉,就是这个效果:进程看似活着,逻辑已经死了,调用方还拿不到任何信号。我甚至见过有同事catch之后在日志里写“忽略此异常”,然后过了三个月这个隐藏问题终于爆发成了线上事故。
4. 语言决定错误模型的表达方式
很多团队在设计错误模型时有一个误区:只在自己的语言里思考,完全不管语言本身的哲学。但实际上,Java、Python、Go、Rust对“错误应该怎么表达”有截然不同的理解,强行套用一种模式往往事倍功半。
4.1 Java:受检异常带来的纠结
Java是唯一一个在语言层面分“受检异常”和“非受检异常”的主流语言。受检异常要求方法签名里显式声明throws,调用方必须处理,这理论上很美好,实践上却容易让人烦——没人愿意在每一层都写try-catch。
所以在Java世界里,我见过的主流实践是:受检异常用在“调用方必须被强迫处理”的场景里,比如文件不存在、网络连接失败;非受检异常(RuntimeException及其子类)用在业务规则类错误上,因为这类错误通常不需要调用方做什么,统一异常处理器接管就行。这个取舍没有绝对对错,但团队内部必须统一。
4.2 Python:EAFP与“异常即控制流”
Python社区提倡EAFP(Easier to Ask Forgiveness than Permission),意思是“先做了再说,出错了再处理”。比如从一个字典里取值,Pythoner通常会直接d[key],如果KeyError就catch,而不是先判断key在不在然后才取值。
这种风格导致Python代码里的异常处理非常频繁,异常几乎成了控制流的一部分。设计错误模型时要特别注意:不要试图用复杂的异常层级去约束Python代码,而应该保持轻量,用自定义异常携带业务错误码,然后让一个统一的app级异常处理器去兜底。Python的灵活性决定了它的错误模型应该是“约定优于配置”,用规范文档来约束,而不是用强制的类继承结构。
4.3 Go:error是值,处处显式
Go的错误模型在最开始就走了另一条路:异常panic只能用于真正不可恢复的系统级错误,普通错误全部用返回值传递——通常是一个error类型的对象。所以Go代码里到处都是if err != nil,这种写法很啰嗦,但很诚实,每一层都知道自己处理了什么错误。
在Go里设计错误模型,最关键的是error的包装与解包。标准库里的fmt.Errorf("%w: %v", err, context)可以保留错误链,配合errors.Is和errors.As来做分类判断。实践中我建议团队在公共包里定义一组哨兵错误(sentinel errors),比如ErrNotFound、ErrConflict,让上层用errors.Is去判断错误类别,而不依赖message字符串匹配。
4.4 Rust:Result与?,把异常当数据处理
Rust的Result<T, E>把错误处理彻底“数据化”了:错误和正常值一样,都是普通的数据,只是通过枚举类型区分。配合?运算符,错误可以在函数调用链里自动向上传播,非常简洁。
Rust的错误模型哲学是“错误就应该被当成正常数据流的一部分来处理”,这和“异常是运行时中断”完全不同。所以在Rust项目里设计错误模型,最好的做法是定义自己的错误枚举(比如实现std::error::Error的enum),把业务异常和系统异常都映射成枚举变体,然后用?或match去逐层处理。这套方式可以说是把“正常返回”和“错误表达”做到了最优雅的统一。
四种语言对比下来,没有哪一个模型是绝对正确的。它们各自适合不同的场景,真正重要的是:你的错误模型设计必须和你用的语言哲学一致,而不是反着来。
5. 从日志和错误码反推问题:一次线上异常排查全链路复盘
前面讲了不少设计层面的东西,这一节我想完整复盘一次我实际经历过的排障过程,你可以把它当成“错误模型落地之后怎么用”的参考。
5.1 现象:接口大量报“网络异常990002,请使用正确网段”
某个工作日早上,业务群里突然有人说很多用户反馈页面打不开,接口报错提示是“网络异常990002”。运维同事第一反应是查机房链路状态,结果链路完全正常。网络组的同事一脸无辜,业务方又说就是网络问题,两边推了半天。
这个现象恰好暴露了一个错误模型的核心缺陷:这个系统里没有“外部风控拦截”这个错误分类,所以990002这个来自风控系统的错误码被网关层原样透传成了“网络异常”,不管前端还是运维,看到的都是误导信息。
5.2 排查思路:先给异常分类,再决定往哪查
我当时做的第一件事不是查日志,而是先给这个异常定性。990002是谁返回的?我查了链路图,发现流量会经过一个第三方风控网关,这个网关在检测到高频访问时会返回一个特定错误码。所以这个990002根本不是网络层的“网络异常”,而是风控层的“当前访问被限制”。
这两个分类在错误模型里天差地别。如果归到网络异常,你就得去查机房、查CDN、查运营商,完全查不到东西。如果归到业务风控异常,你就知道该去查访问频次、触发规则、用户分布,五分钟就能定位。
所以排查异常的第一步永远是分类。你手上有一个错误码的时候,先问自己:这是哪个系统返回的?它属于参数类、业务类、依赖类还是系统类?分对了,方向就对了。
5.3 从异常类型和堆栈快速定位根因
排障时常见的另一类问题是“看到一堆异常堆栈,不知道该从哪看起”。我整理了三个比较典型的场景:
- 场景一:日志里出现“数组越界异常”,但堆栈很浅,直接指向某一个集合操作的循环。这种通常不是逻辑复杂,而是上游数据出了问题——可能一条数据里的数组本来就是空或长度不足。所以排查时要先看数据源,再看代码逻辑,别一上来就改循环代码。
- 场景二:Flink作业启动时抛“JDBC连接器异常”,堆栈指向创建连接的那一行。这种情况下十有八九是目标数据库连接参数配错了或者目标库网络不通,跟Flink本身没关系。你看到什么异常,要先去想它旁边依赖的系统是不是正常。
- 场景三:开发环境IDE里启动终端报“无法启动conpty,已移除winpty”。这几乎肯定不是业务代码问题,而是开发工具链和系统环境的兼容性问题,常见于Windows下的VSCode、终端模拟器或某些容器环境,和线上服务完全无关。如果你在排障时看到这种异常,千万别往业务错误模型里去归类,直接按环境问题处理就行。
总的来说,一个异常的类型词(如数组越界、连接失败、超时、拒绝访问)本身就携带了大量信息。结合错误码前缀分类,通常可以把排查范围从“整个系统”缩小到“某个子系统”甚至“某一行调用”。这比漫无目的地翻日志高效得多。
5.4 修复与预防:错误码、日志和告警必须联动
那次990002的问题最终定位到了风控配置上,把限制规则放宽之后恢复。但这个事的真正教训不是风控规则本身,而是错误模型没有对第三方风控错误做“翻译”和“分类”。
我把修复分为两层:
- 第一层:网关层增加一个错误码映射,看到第三方特定的限制错误码,统一转成业务错误码C3002(依赖服务拦截请求),message变成“访问过于频繁,请稍后再试”,不再透传“网络异常990002”这种让人误会的文案。
- 第二层:监控告警从“统计某个HTTP状态码的数量”改成“按错误码前缀聚合再告警”。C开头的错误码集中告警给后端团队,A开头的错误码走调用方工单,D开头的错误码直接打给值班手机,不用层层转述。
这一套联动之后,后面再出现类似问题,谁该起床、该看哪个看板、该联系哪个团队,都是确定的了。
6. 数据工程场景下,错误模型要换一套打法
如果你以为错误模型只在接口服务里有意义,那就小看它了。在数据采集、数据管道、数据分析这些场景里,错误模型的逻辑和传统后端服务很不一样,但同样重要。
6.1 数据管道的异常不是“抛出”,而是“记录+跳过+重试”
传统服务里,有一个请求失败,影响的是一个用户的一次操作。但数据处理管道里,一个批处理任务可能同时处理几十万条数据,如果处理到第10万条时抛了一个异常,你是要整个任务回滚重跑?还是跳过这条继续?
实践经验是:对于数据管道,错误模型要预先定义好“跳过”和“终止”两个级别。脏数据导致的错误(字段缺失、格式不对、类型不匹配),应当跳过并记录错误详情,不影响整批处理。基础设施故障(数据库连接断开、源文件读取失败),应当终止整个任务并触发重试。
6.2 脏数据与schema漂移:把数据质量问题纳入错误分类
做数据采集和处理的人一定遇到过这类异常:用pandas或DataFrame做清洗时,明明逻辑写好了,跑起来报“KeyError”或者“ValueError”,打开原始数据一看,原来是某一列的值里混入了特殊字符,或者新版本的数据源新增了一个字段。
这种质量类异常,光靠try-catch是处理不过来的。我建议数据团队在错误模型里增加一个“数据质量异常”分类,专门表达“数据本身不符合预期”。当这类异常累计到一定阈值时,应该触发告警,而不是静默跳过。比如我在做一个多模态数据集清洗任务时,会上报每批数据的“字段缺失率”“类型不匹配率”“空值占比”,一旦超过阈值就停止后续流程,先人工确认数据源是否有变化。
6.3 幂等、断点续跑与重试设计
数据管道里的另一个核心错误场景是“重试”。重试本身不是错误处理,但它跟错误模型紧密相连——你必须定义一个“这个错误是否可以重试”的策略。
比如从数据库或采集卡写入临时数据,写完之后立即读取,经常会遇到“数据还没落盘”或“索引还没建好”的读取失败。这种失败的重试策略应该是短间隔、多次数。而如果错误是上游数据源本身有问题,比如某天的数据文件缺失,重试就没有意义,应该直接跳过并标记“该时段数据缺失”,等后续数据修复任务去补。这就是数据备份与恢复场景里最常见的错误处理方式。
我一般把错误按照“可重试”和“不可重试”分类:
| 错误类别 | 可否重试 | 策略 |
|---|---|---|
| 网络抖动、服务暂时不可用 | 可重试 | 指数退避重试,最多3-5次 |
| 脏数据、格式错误 | 不可重试 | 记录错误详情,跳过该条 |
| 数据源缺失 | 不可重试 | 标记缺失时间段,走补偿任务 |
| 权限问题 | 不可重试 | 立即告警并停止任务 |
6.4 告警分级:哪些数据异常必须半夜叫醒人
数据工程师最怕的是半夜被无意义的告警吵醒。要避免这种情况,告警必须跟错误分类严格挂钩。
我习惯把数据管道告警分成三级。P0级,任务彻底跑挂了或者数据大面积缺失,需要立刻处理,通常和基础设施故障相关。P1级,数据质量异常率超过阈值或者关键翻表延迟,需要在几小时内处理。P2级,单条数据异常、某个小口径数据对不上,记录到工单里白天处理就行。分级的核心原则是:只有那些会持续产生损失的问题才需要半夜叫醒人,其余的一律写入待办列表。
7. 一些实操习惯,送给正在改错误模型的你
最后分享几个在我自己的项目里反复验证过、比较有效的实操习惯。这些都不是什么高深理论,但每一件都能实打实地改善排障效率。
第一,维护一份错误码手册。不要只把错误码定义在代码里,要在文档里维护一份完整的错误码对照表,包含触发条件、处理建议、负责人。每次新增错误码都要过一遍评审,确保不是随便复制粘贴的。这份手册的价值会在跨团队协作时体现得淋漓尽致。
第二,异常日志要固定模板。我现在的团队要求异常日志必须包含时间戳、traceId、错误码、关键上下文、堆栈五要素。没有上下文信息的异常日志基本没有排查价值,只会让你多翻半天日志。
第三,review代码时对“catch吞异常”零容忍。如果看到有人catch住异常后不做任何处理,或只打一行日志就继续往下走,打回重写。吞异常是错误模型失效的头号原因。
第四,空值返回要统一。要么全部用Optional,要么全部用空对象,要么全部用Result包装,禁止一个项目里三套并存。这一点看起来简单,但在团队协作里是最容易扯皮的。
第五,监控告警按错误码分类聚合,而不是按异常数量。按异常数量聚合的告警,会把业务异常和系统异常混在一起,完全失去指导意义。
错误模型这个东西,短期内投入产出比不高,你不会因为定义了几个异常类就立刻看到效果。但一旦系统复杂度上来,团队人数多了,线上问题频繁了,这套规范的价值就会越来越明显。它不会让你的代码没有bug,但能让你在bug出现的时候,用最短的时间找到它、修复它、避免它再犯。