1. 为什么是 t3code:代号背后先要有一套三层故障分类
我先说个背景。早期我们的服务报错基本靠人肉读日志,遇到线上故障时,每个人从不同服务里翻出一堆redis timeout、connection refused、db unavailable这样的字符串,互相都看不懂对方在说什么。那段时间我一直在想,报错这个东西看起来人人都能做,但把错误码统一起来之后,真正被考验的其实是分类能力。后来我干脆给整套方案起了个内部代号叫t3code,核心就是先把故障按“层”拆开,再给每个故障一个稳定编号,让线上发生的任何基础设施类异常都能用同一套语言说清楚。
t3code 里这个 t3 不是版本号,而是指第三层。我把整个系统的故障分类切成三层:接入层、业务层、基础设施层。第一层负责 HTTP 路由、参数校验、鉴权这类请求入口问题;第二层负责业务规则、状态流转、数据完整性校验这类和具体场景强相关的问题;第三层是所有存储、缓存、消息队列、第三方接口、连接池、网络链路这类底层依赖问题。我之所以先说清楚分层,是因为后续所有号码段、字段设计、映射规则都是建立在这层分类之上的,分类如果模糊,后面接什么都乱。
1.1 三层分类和各层边界
这套分层不建议照搬教科书上的架构图,要按你们团队实际踩过的故障来切。我这里给一个具体参考:
- 第一层(接入层):客户端参数错误、请求头缺失、鉴权失败、路由不存在、频控触发。特征是问题在入口就拦住了,跟业务状态基本无关。
- 第二层(业务层):订单状态不合法、库存不足、重复提交、权限不足。特征是必须懂业务才能判断,不同模块之间无法共用一套编号逻辑。
- 第三层(基础设施层):缓存读取超时、数据库连接池耗尽、消息积压、第三方接口响应慢、熔断器打开、跨机房网络抖动。特征是组件大家共用,同一个异常会在多个业务点重复出现。
这里有个容易弄混的地方:数据库返回的某些错误并不一定都归第三层。比如“订单不存在”虽然是从数据库查出来的,但它是业务规则,归第二层;而“数据库连接超时”才是第三层。判断标准很简单——抛开当前业务,换个服务来调用同一组件是否还会出现同类问题,如果会,就是第三层。
1.2 为什么先收敛第三层,而不是一次铺开
我当时没有把三层一次性全部编码,而是优先把第三层做扎实。原因是第三层故障的重复度最高,而且最容易出现“A 服务说业务没错,B 服务也说业务没错,最后发现是共享 Redis 或者数据库连接池被打满”的情况。这种故障最大的成本不是修复,而是定位。错误码如果不分类,各服务各自抛redis timeout这种纯文本,最终日志系统里能聚合出来的只有一串互相矛盾的字符串。
反过来说,第一层和第二层的错误大多能在本地拿到明确请求参数,定位链路短;第三层错误一旦发生,通常会穿透多个组件,涉及链路长、可复现性差,更需要一个稳定编号来支撑监控聚合和告警收敛。所以 t3code 的第一个决定就是:先把基础设施错误全部编号化,业务错误保持原有处理方式,后面再逐步迁移。这个取舍帮我省了一半的推广阻力,因为开发不用一次性改完所有异常处理,只需要在处理基础设施异常时走新通道。
2. 编号结构怎么定:从 T3-INF-REDIS-3021 看字段取舍
把分类切成三层之后,下一步就是设计编号本身。听上去很简单,实际上一不小心就做成了“换个花样的字符串大杂烩”。t3code 最后定的格式是T3-分类-组件名-行为序号,比如T3-INF-REDIS-3021。其中INF表示基础设施层,REDIS表示故障组件是缓存中间件,3021表示具体行为是“读取缓存超时”。这个格式看起来比纯数字复杂,但换来了两个实在的好处:日志里一眼能看出是什么组件,Grep 命令按字符串就能直接聚合,不需要维护一张数字含义对照表去猜。
2.1 用“分类缩写 + 组件名 + 行为序号”,而不是纯数字方案
纯数字编号最典型的问题是“人类不可读”。线上排查时你看到code=504003,这一串编码不进文档查一次根本不知道在说什么,而在告警和日志里频繁跳转文档又是最浪费时间的动作。t3code 的做法是把可读部分放在前缀,把序号部分只承担“唯一性”职责。组件名使用稳定缩写,不随版本变化,例如:
| 编号 | 组件缩写 | 对应场景 |
|---|---|---|
| T3-INF-REDIS-3021 | REDIS | 读取缓存数据超时 |
| T3-INF-MQ-1052 | MQ | 消息发送被流控 |
| T3-INF-POOL-2038 | POOL | 数据库连接池等待超时 |
| T3-INF-HTTP-4072 | HTTP | 调用下游服务返回超时 |
这里的 3021 不需要有 3001 到 3021 的完整递进,它只是“新增一个编号”的自然累计。开发新增编号时会发现我根本没有按场景预设几百个码,全凭注册表自动分配,避免出现人力预编号导致的大段空洞。组件名后面不要跟上实例地址、机器 IP 这类临时信息,实例信息放到日志的target字段,否则一个实例缩容编号可能就要废弃,错误码的稳定性就没了。
2.2 严重级别和是否可重试,放到元数据里而不是塞进编号
我见过不少团队把错误码设计成T3-INF-REDIS-READ-TIMEOUT-ERROR这种长字符串,把“读取超时”和“严重级别”全部写进名字里。这样做的直接后果是:同一个组件故障换一种等级就要新增一个编号,实际上两个编号描述的是同一个现象。t3code 的原则是编号只回答“发生了什么”,至于“有多严重”和“要不要重试”,放到注册表元数据字段里,让告警平台和客户端读元数据,而不是解析字符串。
这能带来一个很实用的效果:假设T3-INF-REDIS-3021的retryable=true,那么告警系统可以直接判断这个故障可以通过短时间重试解决,不用直接拉起故障大群;而T3-INF-POOL-2038的retryable=false,说明是连接池已经耗尽,立刻重试只会加剧问题。元数据字段由一个独立配置跟踪,失败后处理策略可以不改代码就调整。这是我后来在推广 t3code 时觉得收益最大的一条——不是把错误码写得金光闪闪,而是让机器能基于错误码做决策。
3. 服务端落地的三件套:注册表、返回体、日志格式
分类和编号定了之后,最难的不是写一个异常拦截器,而是保证所有服务在报错时真的会按同一套规则打出来。t3code 的落地我拆成三块:集中注册表、API 返回体、日志格式。这三块对于微服务和单体应用都适用,只是规模不同,单体可以简化成一张表,微服务则建议挂在配置中心上。
3.1 集中注册表:先解决“每个人都自己发明编号”的问题
我见过最快翻车的模式是把错误码定义散落在各个模块里,有人说RedisTimeout,有人说CACHE_TIMEOUT,最后监控平台根本没办法统一聚合。t3code 落地的第一件事是建一个注册表文件,挂在配置中心,用 YAML 维护所有T3开头的基础设施错误码:
T3-INF-REDIS-3021: name: REDIS_READ_TIMEOUT description: "读取缓存数据超时" level: ERROR retryable: true owner: "cache-oncall" runbook: "doc/t3code/redis-timeout.md" T3-INF-MQ-1052: name: MQ_SEND_BLOCKED description: "消息发送被流控" level: WARN retryable: true owner: "mq-oncall" runbook: "doc/t3code/mq-flowcontrol.md"注册表同时承担两件事:一是通过配置中心下发,服务本地不需要硬编码错误码列表;二是做 CI 检查,凡是代码里出现的T3-开头的错误码,必须在注册表中能找到定义,否则直接拦截发布。这个强制约束能挡住绝大多数随手发明的编号。团队内部的顺手收益是,任何人收到一个T3-INF-REDIS-3021错误,都可以打开注册表看看描述、负责人和 runbook,不用到处找人问。
3.2 API 返回体要统一结构,不能同一个服务两种格式
如果错误只在服务端日志里规范化,客户端和上游服务仍然拿到的是老格式文本,那 t3code 的价值就被砍了一半。我在返回体上统一了字段格式:
{ "code": "T3-INF-REDIS-3021", "message": "读取缓存数据超时,请稍后重试", "retryable": true, "request_id": "a6f9c24e81ad4f7" }这里有一个容易被忽略的细节:message不要直接透出底层异常文本。以前经常看到返回redis.clients.jedis.exceptions.JedisConnectionException: java.net.SocketTimeoutException这种东西,对客户端一点帮助都没有。t3code 要求message只放给调用方看的可读信息,内部堆栈仍然全程保留在服务日志里,靠request_id关联,而不是塞到接口响应里。retryable字段要跟着 code 一起返回,否则调用端拿到一个错误码还要本地猜测能不能重试,客户端使用方最烦这种不确定。
3.3 日志格式跟踪:code 必须是独立可搜索字段
很多系统的日志格式五花八门,有的把错误码拼进 message 文本里,比如log.error("cached timeout code=" + code),结果日志平台里搜出来全是长文本,聚合字段也没法单独用。t3code 要求日志中必须有一组结构化字段,至少包含 code、trace、target、stage:
level=ERROR app=sku-service trace=abc123 code=T3-INF-REDIS-3021 target=redis-cluster-a stage=sku:detail cacheKey=item:10086这样在日志平台里做code:T3-INF-REDIS-3021检索时,能直接拿到配对的所有日志,再配合trace字段拉出同一个请求的完整调用链。target记录具体组件的实例标识,比如redis-cluster-a,它和兜底字段cacheKey可以一起做精细筛选。日志格式定完以后,很多老排查套路完全被替代掉了——以前是先登录服务器看目录,再手动 grep 进程路径下的文本,现在直接进日志平台按 code 查就能定位范围,省下来的时间都留给处理问题本身。
4. 一场超时事故里,t3code 是怎么缩短定位路径的
理论讲再多,不如看一次它实际发挥作用的过程。这里我用一个很典型的缓存超时事件来说明 t3code 在排障链路里的真实体验。这个事件发生在我们切完大版本之后的第一个高峰期,新逻辑每次查询商品都会先读一次缓存,结果某个 Redis 分片响应变慢,整个详情页接口跟着变慢。这类问题在没有 t3code 之前会怎么处理,我放到对比里说。
4.1 事故发生当天的时间线
- 14:00 开始有用户反馈详情页打开慢,监控曲线显示 P95 延迟从 80ms 飙升到 800ms。
- 14:05 值班同学翻出异常日志,看到
Redis connection timeout和Read timed out混在一起,由于没有统一错误码,先要判断是缓存问题还是数据库问题。 - 14:12 确认是某 Redis 分片超时,但日志里没有 target 字段,又去查连接配置才知道业务访问的是哪个分片。
- 14:20 定位到分片之后,因为同类超时文本散落在多个服务里,无法快速统计影响面,只能几台机器轮流登录查日志。
整个过程大约 1 到 2 小时,真正动手修可能只要半小时,大部分时间花在确认“这个超时是从哪里来的、影响多大范围”上面。
4.2 用 t3code 重新读一遍同样的事故
如果当时 t3code 已经覆盖所有缓存读写,日志会变成这样:
- 14:00 日志平台按
code=T3-INF-REDIS-3021聚合,立刻看到触发量从 0 涨到每分钟千级,定位到target=redis-cluster-a。 - 14:02 查
retryable=true,客户端已自动重试,所以看到的大多是延迟增大,真正失败的请求占比不高。 - 14:05 按
trace字段拉出一两个超时请求的完整链路,发现慢点在淘宝平台对分片的访问,而不是业务代码逻辑。
同样级别的故障,定位阶段从 1 到 2 小时压缩到 10 分钟以内。因为code是稳定字段,不需要人再去人工识别文本含义;因为target在日志里,不用再查配置确认实例;因为元数据里标记了retryable,值班同学能直接判断这是一个可重试问题,先观察再处理,不用立刻拉起高优告警造成慌乱。
4.3 为什么异常到错误码的映射必须放在底层统一做
刚开始有人提议在各业务代码里自己判断 Redis 异常并打自定义错误码,我立刻否定了。如果每个业务都自己映射,MalformedURLException、JedisConnectionException、ReadTimeoutException每个服务可能打出三个不同编号,等于变回老路。t3code 的做法是把异常映射收敛在一个消息件或 SDK 的公共层,由底层组件统一识别异常类型,统一转成注册表里的编号。这样组件版本升级时只需要在一个地方补充映射,而且不同业务访问同一个组件时产生的故障编号必然相同,监控聚合才真正有意义。
5. 推广后遭遇的重灾区:枚举膨胀、网关误判、文档僵化
t3code 不是上线第一天就岁月静好。真正推广到多个服务之后,我陆续发现几个坑,这里挑三个最典型的展开说一下,每个都直接影响排查效率。
5.1 枚举膨胀:一个超时错误长出六个别名
接入服务一多,注册表里很快就出现了一堆相似描述的错误码。有人叫T3-INF-REDIS-3021,有人为了表示超时发生在get操作上,又新增T3-INF-REDIS-3099,还有人把“读缓存熔断”单独建码。第三个月一统计,注册表里有 6 个码描述的是同一个现象,只是操作粒度不同。这种现象的根因是大家把错误码当成了“我要表达得更精确”的工具,而实际上它只需要承担唯一的定位职责,任何更细的上下文应该靠target、stage、cacheKey这些业务字段去表达,而不是再造一个码。
治理办法有两个:一是 PR 提交里的新增错误码必须写明和已有 code 的差异,含糊描述直接打回;二是每周用脚本扫描注册表里name字段相近的条目,人工确认是否可以合并。一开始会觉得流程重,但坚持一个月之后,新增错误码的数量大幅下降,大家对编号的态度也变成了“先查表而不是先造码”。
5.2 网关层把错误语义吞掉了
t3code 设计时我刻意把retryable放进元数据,但实际跑起来发现网关层很多组件根本不会看这个字段,它们只认 HTTP 状态码。真实案例是:业务接口返回code=T3-INF-REDIS-3021, retryable=true, http_status=200,表示当前接口内部依赖故障,请稍后重试,但网关没有透传,而是统一改成了http_status=503,前端拿到 503 后直接判定“服务不可用”,展示成系统故障页面,用户只能硬刷。对于本来可以平滑重试的场景,这种误判带来的体验最差,也让错误码失去了语义价值。
修复不能只靠文档约束,我在网关侧加了一层映射规则:HTTP 状态码的生成要看注册表里的retryable字段。retryable=true时接口透传 200 或 409,由调用方决定重试;retryable=false时再考虑 503 或 500。这样网关不会粗暴抹平错误语义,错误码的决策能力真正链路化,不再只在服务端内部自嗨。
5.3 注册表没人维护就僵化了
错误码体系最怕的不是设计不合理,而是大家已经使用但没人更新。t3code 的中后期,部分服务的负责人换了几轮,注册表里的owner还指向旧团队名,报警时找到负责人经常是查无此人。另外 runbook 链接失效,点进去是空白文档,等第二次故障发生时还要重新摸索处理流程。我发现系统有没有人愿意维护,比设计本身还影响成败。
于是我定了一条规矩:错误码不只是一个数字,它必须在一周内能对应到至少一个明确负责人,owner 必须是服务团队的一级分组而不是随便一个虚拟角色。同时每季度做一次注册表健康检查,把超过 90 天未访问或 runbook 无效的 code 从核心告警策略中降级,促使团队主动清理。经过几轮清理,注册表里的有效条目大概只保留了最初一半,但准确率大幅提高,报警时按 owner 找人基本一次成功。
6. 如果现在重来,我只保留三样东西
把所有经验和教训摊开看,t3code 真正被反复使用的并不是复杂的监控大盘,也不是一个庞大的错误码管理平台。如果再让我从零搭建一遍,我不会再为做而做,只会保留这里最本质的三样东西:注册表、可重试性标识、结构化日志。
6.1 这三样东西怎么落到最小可用状态
第一,注册表不需要做成在线系统,一个挂在项目目录下的error-codes.yaml加一个 CI 校验就够。只要保证“代码里用到的码一定在表里”,整个体系就有了锚点。第二,retryable和owner这两个字段必须在注册表里强制填写,否则错误码只是把字符串换了个马甲,机器依旧无法决策,人也找不到责任人。第三,日志输出必须做到code可单独检索,并且每条日志都要带trace和target,这两个字段能把一次故障从“需要现场分批查”变成“一条检索指令”。
6.2 我踩过坑之后的心得
如果给同样在做错误码治理的小团队一个建议,我会说:不要一上来追求全量切换,先把新增代码和核心链路的报错切到新格式,留出兼容期让老日志保留原样,同时用一个月时间向团队证明“按 code 查日志确实快”。我在推 t3code 时犯过的最大的错误是想把三层一次性覆盖,结果业务错误和基础设施错误混在一起,大家反而更不知道选什么码。后来改成先扫第三层基础设施异常,问题立刻变清楚。错误码这种东西,设计出再漂亮的规范也不值钱,真正值钱的是在排障现场能让人少花五分钟搞清楚“这条报错到底在说什么”,只要朝着这个方向走,每一条规范都值得。