1. 本周核心:一个差点翻车的网关重构
20260302到20260308这周,我大部分时间都耗在一个内部系统的API网关重构上。不说虚的,这周的目标很明确——把原来单体服务里耦合得乱七八糟的鉴权、限流、路由逻辑拆出来,放到独立网关层,同时把数据同步模块从定时轮询改成事件驱动。
为什么做这件事?原架构扛不住了。每天早上八点半到九点这个高峰段,接口平均响应时间能从平时的120ms飙到600ms+,偶尔还出现雪崩式的超时。问题就出在鉴权逻辑每次都要查一次数据库,限流又是单机内存版的,水平扩展等于白扩展。网关重构这个方案我们内部讨论了两周,最后选定的是基于OpenResty做流量入口,配合集中式配置中心管理路由规则,理由很实在:能用Nginx的并发模型扛住流量,又能用Lua脚本灵活处理业务逻辑,不需要额外引入一套Java技术栈的网关组件。
这周的产出可以归纳成三条:
- 完成网关核心链路开发,包括路由转发、JWT鉴权、分布式限流三个模块,代码全部过完CR并合入主干;
- 数据同步模块完成事件化改造的第一阶段,订阅消息队列的消费程序已经跑通,单条消息处理耗时从原来的平均45ms降到了18ms;
- 周四压测时踩了一个连接池配置的坑,排查了整整一个下午,这个后面单独说,值得所有做服务化改造的人记一笔。
2. 逐日推进:从需求评审到联调混乱
2.1 周一:需求评审和接口梳理
周一上午开需求评审会,我没法回避的一个问题是:原来的网关逻辑散落在三个服务里,每个服务都有自己的鉴权代码,有的用Redis存token,有的直接用数据库session表。这次要统一,先把所有接口列出来,按路径前缀、鉴权方式、是否限流三个维度做了一张表。
这是整个重构里最基础也最容易被忽略的环节。接口梳理这件事看着简单,实际做起来完全不是那么回事。我们最终整理了83个接口,其中有7个接口连负责人都不确定还在不在用,还有3个接口存在一个路径匹配两种业务语义的情况。这周我用了一个下午把这些不确定项全部标记成"待确认",在周报里也单独列了一栏。
下午做的事是确定技术选型。网关层用OpenResty,这个没什么争议,因为团队里几个老成员都有Nginx模块开发经验。配置中心选的是内部自研的老系统,虽然界面不好看,但胜在稳定,而且权限体系和公司账号系统已经是打通的。这里我想多说一句,很多团队在做技术选型的时候,容易冲着"最好"去选,但真实项目里,"最熟悉"往往比"最好"更靠谱。
2.2 周二:核心链路开发和第一个设计决策
周二开始写代码。我按照拆好的模块,把工作量分成了两块:路由转发和鉴权自己负责,限流模块交给了另一位同事做。这里有一个关键设计决策,就是网关和后端服务的通信协议。最初方案是走HTTP/1.1,但考虑到部分接口一次调用涉及多个内部服务,最终改成了HTTP/1.1 + keep-alive,同时在后端服务前面保留一层内网DNS做负载均衡。为什么不直接上HTTP/2?因为后端服务里有两个老系统用的是JDK 8的旧HTTP客户端,改造成本太大,性价比不高。
鉴权这边的实现,我用了JWT + Redis黑名单的双层方案。签发token时把token ID存到Redis,设置过期时间与服务端一致。每次请求进来先验JWT签名,然后拿着token ID去Redis查一下是否在黑名单里。黑名单用来处理"用户修改密码后让旧token立即失效"的场景。这个方案的优点是大部分请求不需要碰数据库,Redis的读性能足够扛住峰值。
代码写完后,我自己先在本地用docker起了一套完整环境,包括网关、两个模拟后端服务、Redis和消息队列。这一步省了后面联调至少半天的时间,因为很多低级错误,比如路径匹配前缀写错、参数传递类型对不上这类问题,本地就能发现。
2.3 周三联调:一场预料之中的混乱
周三进入联调阶段,混乱是意料之中的。两个后端团队加上前端三个同学,一起在联调环境里测。联调刚一开始就发现了一个接口网关转发出去后,后端收到的请求头里丢了原始的客户端IP。原因是我们的网关在发起上游请求时,用了自定义的header构造逻辑,漏掉了X-Forwarded-For的拼接。这个问题说大不大,但直接影响后端的风控逻辑,排查了大概四十分钟。
这种联调期的问题,本质上都是设计阶段考虑不周导致的。后来我在代码里加了一个统一的header处理工具函数,所有请求出口强制经过这个函数,把X-Forwarded-For、X-Request-Id、Authorization这些公共header统一处理,才算把这一类问题从根上解决。
下午还处理了一个更隐蔽的问题——POST请求的body重放。在一次超时重试的测试里,发现网关自动重试时,把上一个请求的body原样发给了后端,导致用户在下单接口提交了两次同样的数据。这其实是重试策略设计的问题。我们的网关重试里,只对GET请求做了自动重试,POST请求一律不重试,直接返回502让客户端自己决定。这个规则也在周报里明确写了出来,避免后续有人踩坑。
2.4 周五压测:连接池引发的性能血案
周五压测,问题集中爆发。我们预期网关单机能扛2000 QPS,但实际压到800 QPS的时候,后端服务的Tomcat线程池就已经全部占满,新的请求排队等线程,导致响应时间直线上升。
最开始我以为是后端服务本身的问题,结果看监控曲线,发现Tomcat的线程数跟网关发过来的并发连接数完全同步。再往网关这边查,Nginx的upstream keepalive设置为0,等于每个请求都新建一条TCP连接到后端。TCP握手开销加上TIME_WAIT状态的堆积,直接把后端的端口资源耗光了。
这个问题的根因说透了就是一句话:网关层的连接池配置没有跟后端的处理能力做匹配。我们最后把keepalive设成了128,同时在压测脚本里把请求的并发数控制在合理的范围,再进行了一轮测试,数据立刻不一样了,800 QPS下后端线程池占用下降到原来的四分之一,响应时间也回到了200ms以内。
3. 关键技术复盘:从数据同步事件化到限流降级策略
3.1 数据同步模块:从轮询到事件驱动
数据同步这块,原来是一个定时任务,每30秒扫描一次订单表的更新时间字段,把增量数据拉出来同步到报表库。这个方案的痛点是:数据量小的时候没事,一旦订单量上来,每30秒一次的全表扫描让数据库的IO压力越来越大。而且实时性太差,业务方想在报表里看到当日订单数据,得等最多30秒。
这周做的事件化改造方案,思路其实不复杂:业务服务在订单创建和更新的时候,往消息队列里发一条领域事件,消费程序收到事件后,把相关数据写入报表库。为了兼容一些历史数据,启动时做一次全量初始化,之后只靠事件驱动。
消费程序这里有一个值得注意的细节:我们用了手动ACK + 重试队列的方式。消费端拿到消息先做业务处理,成功之后才ACK。如果处理失败,不ACK,消息会重新进入队列。但这里有个陷阱,如果某条消息一直处理失败,就会被反复投递,导致后续消息被阻塞。我们的解决办法是单独配置了一个重试队列,失败超过三次的消息转到重试队列,重试队列里的消息延迟十分钟再消费,连续失败超过五次就进入死信队列,由人工介入处理。
这套机制上线第一天就发挥了作用。有两条消息因为报表库字段长度限制导致消费失败,如果没有死信队列,这两条消息会一直卡在队列头部,后面几万条消息都别想消费。死信队列救了我们一次。
3.2 分布式限流:算法选型和降级方案
限流模块最初讨论了两个方案:固定窗口和滑动窗口。固定窗口的实现最简单,在Redis里以秒为key存一个计数器,超过阈值就拒绝请求。但这个方案有个已知缺陷,就是临界点问题:假设限制是100次/秒,用户在第一个窗口的最后100ms里发了100次,又在下一个窗口的前100ms里发了100次,实际上200ms内就放过了200个请求。
滑动窗口的实现要稍微复杂一点,需要记录窗口内每个请求的时间戳,或者用更省内存的算法近似实现。我们最终选了滑动窗口的简化版:用Redis的ZSET,每个请求的时间戳作为score,窗口大小1秒,每次请求先删除窗口外的记录,再统计窗口内的数量。ZSET在key的数量大的时候比较耗内存,但我们的接口维度最多几百个,完全扛得住。
再往下走是降级策略。限流触发后,我们不是直接返回错误,而是把多余的请求转发到一个集群的专属降级池来处理。降级池里的请求只返回热门缓存数据,不走完整业务逻辑。这样在极端流量下,用户看到的只是页面上的部分数据不更新,而不是直接报错。这周调整了限流阈值,从初始的每秒500调整到每秒800,是基于压测数据来的,同时也配置了限流触发后的降级策略,避免服务雪崩。
4. 数据与结果:这周的各项指标变化
这周结束后,我对各项数据做了一个统计,这里放出来,方便大家对比参考:
| 指标 | 改造前 | 改造后 | 变化 |
|---|---|---|---|
| 网关层平均响应时间 | 560ms | 213ms | 降低62% |
| P95响应时间 | 1100ms | 429ms | 降低61% |
| 每日暴露的5xx错误数 | 日均37次 | 日均4次 | 降低89% |
| 后端服务CPU占用(高峰) | 84% | 56% | 降低28% |
| 数据同步延时 | 最长30秒 | 平均1.2秒 | 降低96% |
| 数据同步成功率 | 99.2% | 99.97% | 提升0.77% |
这些数字看着挺提气的,但其中有一定客观因素:压测环境和自己搭的测试环境本身比生产环境简单。很多同学容易掉进一个误区,就是一看到改造后的数据变好,就觉得改造成功了。看数据要综合考虑。拿同步延时来说,30秒到1.2秒的变化主要是架构调整带来的,但如果消息队列中间件本身抖动,延时还是会上去,这个不是一劳永逸的。
再说一个让我比较在意的数值:P95从1100ms降到了429ms,但P99只从1800ms降到了1350ms。这说明长尾请求的优化效果还不够。排查下来,这部分请求多数是第一次调用某个后端服务时的冷启动开销,包括DNS解析、连接建立、类加载。下一步可以考虑在网关层做upstream的预热,或者在压测的时候覆盖这个场景。
5. 问题排查实录与避坑指南
5.1 连接池问题:一个下午换来的教训
上面提到的keepalive连接池问题,值得展开讲一下,因为这是很多做网关改造的团队都会遇到的问题。
现象是这样的:压测到800 QPS时,后端服务日志开始疯狂报错,报的是"Connection reset by peer"。我一开始以为是后端服务的线程池满了,把Tomcat的maxThreads从200调到了400,重启后再压,错误消失了一段时间,但继续压到1200 QPS时又出来了。后来我抓了一下网络连接状态:
- 网关所在机器的TIME_WAIT连接数达到38000多个;
- 后端服务机器的ESTABLISHED连接数也在持续增长;
- 用netstat检查发现,后端服务有大量来自网关不同端口的新连接。
问题就很明显了。网关每个请求都新建TCP连接,请求结束后连接进入TIME_WAIT,而新的请求又在不断发起新的连接,导致后端的文件描述符和端口资源被大量占用。最终在Nginx的upstream配置里加上keepalive 128,并在location块里设置proxy_http_version 1.1和Connection空头之后,问题解决了。
这中间还有一个细节:改完配置以后,压测前几次的数据还是不太稳定。后来发现是keepalive连接池的冷启动问题,连接池是慢慢被填满的,前几百个请求还是会走新建连接的老路。所以我们压测脚本里加了30秒的预热阶段,等连接池稳定之后再统计正式数据。
5.2 消息队列消费阻塞:一次半夜告警
周四晚上十点半,告警群里突然弹出一条消息,说数据同步消费积压超过两万条。我第一反应是消费程序挂了,结果登上去看,进程还在,日志也没报错。再往下查才发现,是消费程序里有一个数据库操作没有设置超时时间,而当时数据库刚好在做备份,导致这个操作的连接被阻塞,消费线程全部卡住。
这个问题的根因说白了:消费程序里不应该直接同步调用数据库操作,必须要设置超时和重试机制。我们的消费逻辑里有一个写报表库的操作,这个操作没有走我们统一封装的DAO层,而是用了最原始的JDBC连接,连接不设置超时。数据库正常的时候没事,一抖动就是灾难。
修复方案有两步:第一步,立刻停了消费程序,手动把积压的消息写了一个补偿脚本处理掉;第二步,在消费逻辑里加入超时控制和失败重试,超时时间设置为3秒,超过3秒直接放弃本次写入,把消息重新放回队列,等下一轮再处理。这样数据库抖动只会影响到当条消息的消费,不会把整个消费者拖死,代价只是消费延时稍微变长。
5.3 联合排查:联调环境里的请求头丢失
联调阶段还遇到一个请求头丢失的问题,具体表现是:前端调用网关的接口时,在header里放了一个traceId,后端服务收到之后发现traceId变成了空。排查链路:
- 先用curl直连网关,发现有同样的现象;
- 在网关代码里加了日志,发现请求进来的时候header里的traceId是正常的;转发出去之后丢了;
- 检查HttpClient的header构造逻辑,发现自定义header需要从原始请求里显式拷贝,而我们的代码只拷贝了默认的几个header。
修复很简单,把所有需要透传的header列入一个白名单,在统一的转发函数里做拷贝。这里想提醒大家一句:网关类的项目,header透传一定提前列好规则,不要让每个开发按自己的需求往代码里加header,最后会乱得没法维护。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 后端连接被重置 | 网关未启用keepalive | 配置Nginx upstream keepalive,设置合理的数量 |
| POST接口数据重复 | 自动重试机制导致 | POST请求禁止自动重试,只允许GET重试 |
| 消费积压但进程正常 | 数据库连接无超时 | 统一DAO层,必须设置连接超时 |
| 请求头字段丢失 | header拷贝逻辑不全 | 使用header白名单统一透传 |
| 限流触发后无提示 | 未配置降级响应 | 返回降级缓存数据或明确错误码 |
6. 下周计划与风险预警
下周的事情已经在周报里列好了,我在这里也同步出来,方便各位对照:
第一,完成网关与剩余未迁移接口的切换。目前83个接口里,还有14个老接口没有走到新网关,计划下周三之前全部切完。切换的时候会采用灰度策略,先切流量占比5%的低风险接口,观察半天再逐步放量。
第二,把压测环境扩展到三台网关节点,做一次集群级别的压测。这里的重点是验证网关横向扩展后,限流是否还能正常工作。我们的分布式限流依赖Redis,当网关节点变多之后,Redis的写入量会成倍增加,要提前关注Redis的性能瓶颈。
第三,是数据同步事件化的第二阶段,把订单之外的另外两类业务数据也切到事件驱动。第二阶段涉及的改造范围更大,需要业务团队的配合,沟通成本会是一个风险点。
风险方面,我在这周的周报里明确写了一个:Redis的可用性已经成为单一故障点。网关的限流、鉴权的黑名单、数据同步的消息队列中间件都依赖Redis,如果Redis集群出现问题,影响面会非常大。这个需要运维团队支持,计划下周把Redis的主从切换演练做一次,验证故障转移的时间窗口。
另外一个预警是:联调环境的稳定性堪忧。这周联调时,联调环境的后端服务重启了三次,每次重启都会导致网关到后端的连接池清空,短时间内请求大量超时。下周会跟运维确认,联调环境的服务重启流程能不能加一个优雅下线机制,给网关一个连接池主动重建的时间窗口。
7. 写周报背后的几点个人体会
周报这个东西,很多团队写成了流水账,我自己以前也这样。但干了几年之后,我越来越觉得,一份好的周报不只是给领导看的,更是给自己做项目复盘用的。
这周的周报,我实际是按"目标-进展-问题-数据-计划"这个框架来写的。为什么坚持写"问题"和"数据"这两个板块?因为很多项目的价值恰恰体现在解决问题和指标变化上,如果不记录下来,过两周自己都会忘掉当初为什么做这个决策。就拿连接池这个坑来说,如果这周周报里不记录故障现象和排查过程,下次换个人踩到同一个坑,又得花一个下午去查。
写周报的时候,还有一个小建议:把"下一步计划"写得具体,不要只写"优化网关性能"这六个字,写成"优化网关连接池配置,将keepalive从128调整到256并压测验证P99降幅",这样下周自己拿到周报就能直接开工。
这周的情况大概就是这样。有一个改动虽然还没完全收尾,但方向是对的。下周的计划已经列好,按照节奏推进就行。