前段时间接手了一个老项目的接口服务维护,项目代号沿用旧名叫“168开奖网”,听起来像那么回事,实际上它就是一个给下游客户端提供实时数据订阅的网关层。交接文档基本等于没有,代码里还留着各种历史分支,最直观的表现就是API三天两头被下游反馈报错:有时候是请求超时,有时候是数据解析失败,有时候是鉴权直接给拦了。我前后花了一周多的时间,把这堆接口的问题过了一遍,能修的都修了,不能立刻修的也做了降级方案。这篇文章就是把这段修复过程完整复盘一下,重点不是这个项目本身,而是我在排查和修复过程中用到的思路、工具和具体操作,遇到类似API问题的朋友可以直接照着这个路子走。
先说明一下,这篇文章不会涉及业务本身的逻辑细节,只讲API服务端常见的故障类型和修复方法。我尽量把每一步都写得具体一点,包括参数怎么配、脚本怎么写、踩了哪些坑、为什么这么修,而不是只给一个“换个超时时间”这种谁都懂的建议。
1. 接手后的第一件事:把故障接口盘清楚
刚接手的时候,下游反馈的问题五花八门,有人说接口完全打不开,有人说数据经常少字段,有人说偶尔慢得离谱。如果直接扑上去改代码,很容易被带偏。我习惯先花半天时间把所有故障现象收集起来,分类整理,再决定先动哪里。
1.1 接口现状盘点:不能只听下游说“挂了”
首先要明确一个事实:下游说的“挂了”往往不是一个原因。我接手时的第一件事是拉接口清单,把每个接口的调用方、依赖的下游数据源、平均响应时间、错误率、最近一周的调用量全部列出来。这个项目大概有十几个对外接口,其中几个核心接口的日调用量在几十万次级别,剩下的都是低频接口。
我用的方法很简单:先把Nginx或网关的访问日志导出来,按接口维度做聚合统计。不需要复杂的监控系统,一条命令就能看出问题:
awk '{print $7}' access.log | sort | uniq -c | sort -rn | head -30这一步能把访问量最大的接口列表拉出来。然后再结合下游的工单和群里的反馈,定位到具体是哪些接口报错。做完这一步,我发现真正的问题主要集中在三个接口上:两个高频查询接口的超时严重,一个数据回传接口老是报参数校验失败。
关键是要把现象、影响范围、调用方反馈时间三条线对齐。很多接口其实报错已经持续了一两周,只是没有人在意。我盘完之后列了一张表,把所有故障按“影响面”和“紧急程度”打分,而不是按“我能不能修”来排序。这一步很重要——有些接口技术难度很低但影响面很大,应该优先处理;有些接口只有一两个调用方,可以先放一放。
1.2 把报错分类:超时、数据、鉴权、参数校验是四大类
我习惯把API故障分成四类:
- 超时类:请求发出去之后没有在预期时间内收到响应,细分为连接超时、读超时、整体超时。
- 数据类:响应回来了,但内容不对,比如字段缺失、类型不匹配、值超出预期范围。
- 鉴权类:签名校验失败、token过期、IP白名单拦截。
- 参数类:调用方传的参数不符合接口定义,服务端直接返回4xx。
分类之后,修起来会轻松很多。超时类问题优先查网络链路、下游服务性能、线程池配置;数据类问题优先查序列化/反序列化逻辑和下游数据源;鉴权类问题优先查密钥管理和时间戳校验;参数类问题优先查接口文档和入参校验。
我当时统计了一下,超时类占了大概一半,数据类占三成,其余两类占两成。这说明问题主要出在链路性能和稳定性上,而不是业务逻辑上。
1.3 确定修复优先级:影响面优先而不是技术难度优先
很多技术人接到老项目习惯先挑自己感兴趣的部分改,但交付压力大的时候,还是要按影响面来排优先级。我排的顺序是这样的:
- 先修完全不可用的接口:连不通、一直超时、直接5xx的。
- 再修数据不准确的接口:能返回但是内容有问题,下游还要做一堆兼容处理的。
- 最后修体验问题:响应慢但不至于超时,报错信息不友好等。
这个优先级原则在后续排期里帮了大忙。因为项目本身时间紧,我每天记录修复进度,确保所有改动都是可回滚的。后面做每一步修复之前,都先确认老逻辑是什么、为什么旧代码会写成这样,再动手。
2. 接口超时与重试:第一个要解决的老大难
超时问题是这个项目里最头痛的。高频接口平均响应时间在800ms左右,但P99响应时间能到3秒以上,下游的客户端基本都在3秒超时,所以经常会收到报错。
2.1 为什么老接口总是超时:没有超时控制与重试风暴
翻代码发现,问题不只是单次请求慢,更糟糕的是调用链路上没有任何超时控制。服务端在等待一个下游数据源返回时,最长能等30秒;而下游客户端自己的超时时间是3秒。也就是说客户端早就放弃了,服务端还在那傻等。更坑的是,客户端超时之后会立即重试,重试又打进来,服务端线程池被占满,反而让原本能正常处理的请求也被拖死。
这种问题在行业里叫“重试风暴”,本质上是超时配置不合理导致的连锁反应。理解了这个机制,修复方向就明确了:第一,给服务端加合理的超时控制;第二,给调用方的重试行为加限制;第三,从根上缩短下游数据源的响应时间。
2.2 给外部调用设置超时参数:连接超时、读超时、整体超时分开配
我在修复时把服务端调用下游数据源的请求参数统一改成“连接超时1秒、读超时2秒、整体超时3秒”。很多人不明白为什么分开设置,简单解释一下:
- 连接超时:建立TCP连接最多等多久。如果目标机器IP不可达,通常在几百毫秒到1秒就能判断出来,没必要等30秒。
- 读超时:连接建立后,等待响应数据的时间。这个要根据业务特征来定,实时性要求高的接口给短一点,批处理接口可以给长一点。
- 整体超时:从发起请求到拿到完整响应的总时间上限,用来兜底。
给这三个参数赋值的时候,我参考的是下游数据源平时的P95响应时间。直接按下游给的“承诺值”来配不靠谱,得看实际数据。我的做法是拉取下游数据源最近一周的响应时间分布,如果P95是1.5秒,那读超时给3秒就足够了,再长就没意义。配完之后,服务端的线程占用率立刻降了下来,因为大部分慢请求在3秒内就被主动断掉了。
2.3 重试不能无脑加:必须带退避和幂等控制
修复重试逻辑的时候,我把代码里的“失败就重试3次,每次间隔200ms”改成了“最多重试2次,第一次退避500ms,第二次退避1秒”。为什么退避时间要递增?因为如果服务端已经过载,你固定间隔重试很容易在同一时刻再次打满它。递增退避是给服务端留出恢复时间。
另外还要强调幂等。重试的前提是请求本身允许重复执行,否则一个创建订单的接口被重试两次,就产生了双份订单。这次项目里有一个回传接口,下游在超时后重试,服务端却因为第一次请求还在处理中,导致重复插入数据。修复方案是给请求加一个全局唯一的 requestId,服务端根据 requestId 做去重。这个操作看起来很简单,但很多老项目就是没做。
2.4 响应时间SLA打点:没有数据就谈不上优化
超时问题修完,不代表就完事了。我给核心接口加了响应时间日志,按接口、按分钟、按状态码打点,把P50、P95、P99三个指标暴露出来。为什么要打这三个值?因为平均值很有欺骗性。P95能够代表绝大多数用户的体验,P99则能看到长尾慢请求。
从运维角度来说,这个数据太重要了。改完配置之后,我每天都会看一眼P95和P99是否在下降,如果某个接口的P99持续异常,那就说明还有隐藏问题没暴露出来。
3. 数据格式与状态码的兼容处理
超时问题捋顺之后,第二大类问题浮出水面:数据格式和状态码不统一。这个项目对接了多个不同的数据渠道,每个渠道返回的JSON结构都不太一样,服务端做了适配,但适配逻辑写得很粗糙,导致经常出现字段解析失败。
3.1 下游返回结构不一致:兼容但要有底线
举一个实际遇到的例子:同一个字段,有的渠道返回字符串“123”,有的渠道返回数字123,还有的渠道返回null。老代码统一用String类型去接收,数字123在序列化时会被自动转成字符串,问题还不大;但有一个渠道直接返回空对象{},老代码没做判空就直接取字段,于是抛了空指针异常。
我的修复思路是:在服务端入口统一做一次数据清洗和协议转换,而不是把兼容逻辑散落在各个业务方法里。具体做法是一个DTO专门承接外部渠道的原始结构,然后写一个转换器,把不同结构统一成内部标准结构。转换器里必须处理这些情况:
- 字段类型不匹配时,能转就转,不能转就给默认值。
- 字段缺失时,用默认值兜底,但不允许空指针。
- 数组字段为空时,返回空数组而不是null,避免下游二次处理。
这样做的价值在于:下游数据源再怎么变化,业务层看到的结构始终是固定的。后续再加新渠道,只需要写一个新的适配器。
3.2 状态码语义不统一:该透传的透传,该转化的转化
状态码问题也很有意思。有的渠道用200表示成功,有的渠道用0表示成功,还有的用字符串“success”表示成功。服务端在做转发时,有的接口把渠道状态码原样透传给了客户端,客户端那边就得做各种判断,经常判断出错。
修复方案是统一内部错误码规范,然后在服务端把下游状态码映射成内部状态码,最后对客户端只暴露统一的状态码和错误信息。比如:
- 成功:内部码200。
- 参数错误:内部码400。
- 鉴权失败:内部码401。
- 下游数据源异常:内部码502。
- 超时:内部码504。
下游渠道返回的具体错误信息也不要丢,我建议放在响应体的 detail 字段里,方便排查问题,但客户端的主判断逻辑只看内部状态码。
3.3 空数据与异常数据的兜底默认值
还有一个常见问题:接口返回空数据时,老代码直接返回null或者直接抛异常。这会引发下游连锁报错。正确做法是返回一个结构完整但内容为空的对象,比如“data: []”或“data: {}”。不要小看这个细节,下游很多客户端框架在解析null时会直接崩,而空数组是安全的。
我在项目里专门写了一个全局响应包装器,强制所有接口返回统一结构。返回值必须是固定的外层结构,内层data字段可以为空,但外层结构绝不能缺失。这种做法后续带来一个好处:前端和客户端不用再为每个接口单独做判空,因为结构是稳定的。
4. 缓存与限流:防止接口被打垮
超时和数据结构修完后,接口整体可用性已经好很多了。但还有一个隐患:流量稍微一涨,接口响应时间就明显恶化。原因也很典型:每次请求都直接打到底层数据源,没有做缓存保护,也没有任何限流措施。
4.1 缓存穿透和缓存雪崩同时存在
检查缓存代码时发现,老项目虽然用了Redis,但缓存逻辑基本是“查一次就扔,过期时间也乱配”。这导致两个典型问题:
第一个是缓存穿透。某些固定的查询参数查不到数据,每次都会绕过缓存直接打到数据库或数据源,一旦这种无效请求多了,底层就会被压垮。修复方案是布隆过滤器或者缓存空值。考虑到项目体量不算大,我选择缓存空值,代价小,实现简单。具体做法是把“查不到结果”也缓存起来,过期时间比正常缓存短一些,比如5分钟。
第二个是缓存雪崩。大量缓存在同一时间点过期,瞬时流量全部打到下游。修复方案也很标准:给缓存过期时间增加一个随机偏移量。比如原来统一过期时间是10分钟,现在设置为“10分钟 + 0到60秒的随机数”,错开过期时间点。
4.2 接口级限流和熔断:保底方案
限流这个话题说起来简单,落地的时候需要注意粒度。我用的方案是基于Redis的滑动窗口计数器,针对每个接口做独立的限流阈值。阈值怎么定?不能拍脑袋,得参考线上峰值QPS再留出余量。我拉出了最近一周每个接口的峰值QPS,把阈值设在峰值的1.5倍左�右,超过之后直接返回“请求过于频繁”的统一提示。
熔断逻辑用的是简单的状态机:连续失败次数超过阈值就打开熔断,在一段时间内直接快速失败不再调用下游;等过了冷却时间再放少量请求试探,成功率达到预期就关闭熔断。这个模式在很多框架里都有现成实现,但老项目没有引入框架,我手写了一个精简版,也只用了几个原子变量和定时任务,效果够用。
4.3 鉴权header的统一处理
还有一个高频报错是鉴权。这个项目用的是简单的token机制,但每个接口取token的方式都不一样,有的从header取,有的从query参数取,有的甚至写死在代码里。一旦token过期,报错信息也是五花八门。
我把鉴权逻辑收敛成了一个统一的拦截器,统一从header里取Authorization字段,统一校验过期时间,统一返回鉴权失败的响应结构。对于已过期的token,给出明确的code和message。这里有个经验:鉴权失败的响应不要在业务代码里判断,应该在入口层统一处理,避免漏写。
5. 回归验证与上线
代码改动不少,如果没有一套回归验证方案就上线,风险太高。这个项目没有自动化测试,所以我采用“本地模拟+压测+灰度验证”三步走,每一步都有具体可操作的内容。
5.1 压测脚本怎么配:压到什么标准才算合格
压测工具我用的JMeter和wrk。因为接口大部分是HTTP接口,用wrk做简单压测就够了。压测之前,先从线上日志里统计出接口的平均QPS和P95响应时间,以此作为压测基线。我的目标很简单:在2倍峰值QPS的压力下,P95响应时间不超过之前P95的1.5倍。
wrk的压测命令大概长这样:
wrk -t8 -c100 -d60s -s post.lua http://127.0.0.1:8080/api/query注意 -t 是线程数,-c 是连接数,模拟的是并发请求规模。压测的时候不要只看平均响应时间,重点看P99和错误率。如果P99飙升而P50平稳,说明存在少量长尾请求,这些请求往往就是超时问题的根源。
5.2 灰度与回滚:改坏了能立刻退回来
上线方式我选了灰度发布,也就是先让5%的流量走新版本,观察10到20分钟再逐步放量。这个项目用的是Nginx+多实例部署,所以灰度操作很简单:把一台实例从Nginx的upstream里摘掉,部署新版本,然后只把这一台加回来,权重调低。等确认没有报错,再把权重逐步拉高。
回滚方案更重要。每次上线前我都把上一个版本的构建包保留下来,配置备份好。一旦新版本出现问题,直接把Nginx upstream切回老实例,比重新构建快得多。很多人忽略回滚预案,结果上线出问题后手忙脚乱地重新打包,这个时间浪费非常不值得。
5.3 日志与告警补全:让下次故障不再靠人肉发现
这次修复过程中,我顺手把日志和告警补上了。具体做了三件事:
- 接口访问日志里加上了耗时、状态码、请求ID。
- 把错误日志单独输出到一个文件,避免和业务日志混在一起。
- 在核心接口的错误率超过阈值时,触发告警通知。
这三件事看起来基础,但老项目之前都没有。没有告警就意味着只能等下游来反馈问题,非常被动。补完之后,再来一次故障,至少能在下游发现之前就收到通知。
6. 常见问题和排查速查表
项目收尾之后,我把这次过程中遇到的高频问题整理成了一个速查表,后面再有人接手可以直接对照排查。
6.1 现象到原因的排查对照表
| 现象 | 可能原因 | 建议排查动作 |
|---|---|---|
| 偶发超时,但平均响应时间正常 | 慢请求长尾、线程池阻塞 | 看P99指标,抓慢日志 |
| 下游反馈数据经常少字段 | 序列化配置或DTO类型不匹配 | 对比实际返回结构,检查转换器 |
| 缓存命中率低 | 过期时间设置不合理 | 统计缓存过期时间和访问频率 |
| 上游回调重复请求 | 调用方重试无幂等 | 服务端加requestId去重 |
| 鉴权偶尔失败 | 网关/服务时间不同步 | 检查服务器时钟同步 |
| 流量一高就报错 | 限流阈值过小或没有熔断 | 校准限流阈值,检查熔断状态 |
这张表不是万能的,但它适合作为排查起点。很多问题不看日志就能先排除一半可能性。
6.2 几个实际操作中踩过的坑
最后分享几个这次修复过程中真实踩到的坑,每个都花了不少时间才定位。
第一个坑是改超时配置时只改了应用层,忘了走一遍底层连接池。结果应用层主动断开了,底层连接池里的连接还在傻等,导致大量连接泄露。修复超时问题的时候,一定要检查HTTP客户端连接池的最大连接数和空闲回收时间,否则即使应用层断得快,连接池也会被耗尽。
第二个坑是缓存空值的时候没有设过期时间,导致Redis里塞满了无效key,最后内存被打满。缓存空值一定要设置相对短的过期时间,一般3到5分钟就够了。这个教训很痛,因为Redis内存告警是在深夜被触发的。
第三个坑是压测时只关注QPS,忽略了压测本身的损耗。用wrk压测时,压测机本身资源不够,结果压出来的数据并不能真实反映服务端性能。压测机的CPU和网络带宽都不能成为瓶颈,否则数据没有参考价值。我在第二次压测时换了一台性能更好的机器,数据才稳定下来。
第四个坑是上线之后以为完事了,没有做持续的响应时间趋势观察。API修复不是一次性的工作,性能数据要持续看。如果某一天P99突然上涨,那背后大概率有新的变更或新的慢请求出现。没有打点和指标,这种变化就很难察觉。
做这个项目最大的感触是,API修复工作大多数时候不是“一个bug改一行代码”那么简单,更像是对整个调用链路的重新梳理。超时、重试、缓存、鉴权、限流,每一个环节都是环环相扣的。只要把数据打点做好、把底层逻辑理顺,后续再怎么迭代都有底气。希望这篇实录能给正在和API问题缠斗的朋友一些参考。