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 用“分类缩写 组件名 行为序号”而不是纯数字方案纯数字编号最典型的问题是“人类不可读”。线上排查时你看到code504003这一串编码不进文档查一次根本不知道在说什么而在告警和日志里频繁跳转文档又是最浪费时间的动作。t3code 的做法是把可读部分放在前缀把序号部分只承担“唯一性”职责。组件名使用稳定缩写不随版本变化例如编号组件缩写对应场景T3-INF-REDIS-3021REDIS读取缓存数据超时T3-INF-MQ-1052MQ消息发送被流控T3-INF-POOL-2038POOL数据库连接池等待超时T3-INF-HTTP-4072HTTP调用下游服务返回超时这里的 3021 不需要有 3001 到 3021 的完整递进它只是“新增一个编号”的自然累计。开发新增编号时会发现我根本没有按场景预设几百个码全凭注册表自动分配避免出现人力预编号导致的大段空洞。组件名后面不要跟上实例地址、机器 IP 这类临时信息实例信息放到日志的target字段否则一个实例缩容编号可能就要废弃错误码的稳定性就没了。2.2 严重级别和是否可重试放到元数据里而不是塞进编号我见过不少团队把错误码设计成T3-INF-REDIS-READ-TIMEOUT-ERROR这种长字符串把“读取超时”和“严重级别”全部写进名字里。这样做的直接后果是同一个组件故障换一种等级就要新增一个编号实际上两个编号描述的是同一个现象。t3code 的原则是编号只回答“发生了什么”至于“有多严重”和“要不要重试”放到注册表元数据字段里让告警平台和客户端读元数据而不是解析字符串。这能带来一个很实用的效果假设T3-INF-REDIS-3021的retryabletrue那么告警系统可以直接判断这个故障可以通过短时间重试解决不用直接拉起故障大群而T3-INF-POOL-2038的retryablefalse说明是连接池已经耗尽立刻重试只会加剧问题。元数据字段由一个独立配置跟踪失败后处理策略可以不改代码就调整。这是我后来在推广 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、stagelevelERROR appsku-service traceabc123 codeT3-INF-REDIS-3021 targetredis-cluster-a stagesku:detail cacheKeyitem: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 日志平台按codeT3-INF-REDIS-3021聚合立刻看到触发量从 0 涨到每分钟千级定位到targetredis-cluster-a。14:02 查retryabletrue客户端已自动重试所以看到的大多是延迟增大真正失败的请求占比不高。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 状态码。真实案例是业务接口返回codeT3-INF-REDIS-3021, retryabletrue, http_status200表示当前接口内部依赖故障请稍后重试但网关没有透传而是统一改成了http_status503前端拿到 503 后直接判定“服务不可用”展示成系统故障页面用户只能硬刷。对于本来可以平滑重试的场景这种误判带来的体验最差也让错误码失去了语义价值。修复不能只靠文档约束我在网关侧加了一层映射规则HTTP 状态码的生成要看注册表里的retryable字段。retryabletrue时接口透传 200 或 409由调用方决定重试retryablefalse时再考虑 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 时犯过的最大的错误是想把三层一次性覆盖结果业务错误和基础设施错误混在一起大家反而更不知道选什么码。后来改成先扫第三层基础设施异常问题立刻变清楚。错误码这种东西设计出再漂亮的规范也不值钱真正值钱的是在排障现场能让人少花五分钟搞清楚“这条报错到底在说什么”只要朝着这个方向走每一条规范都值得。