K8s Ingress重试引发的幽灵请求:从重复扣款到幂等治理实战

发布时间:2026/9/23 4:26:03
K8s Ingress重试引发的幽灵请求:从重复扣款到幂等治理实战 凌晨一点多线上群突然炸了用户反馈“我只点了一次提交订单怎么扣了两次款”。订单中心的日志翻了一遍两个几乎同时到达的请求时间戳只差了不到两秒来源 IP 都是 Ingress Controller 的 Pod IP请求体一模一样UUID 也一样。第一反应是前端按钮没做防抖或者是客户端重试逻辑重复提交。但前端同学信誓旦旦埋点确认只发了一次请求。当时我们 K8s 集群里跑的 Nginx Ingress后端订单服务两个副本资源占用也没什么异常。怎么也想不通一次客户端请求怎么会在集群里变成两次后端调用这就是典型的 K8s Ingress 重试引发的“幽灵请求”。这类问题在集群规模不大、服务偶发变慢的时候特别容易踩中而且隐藏得很深应用日志看不到超时异常Ingress 也没有明显报错但重复调用的事实已经发生。这篇文章我会把当时定位整条链路的全过程、Ingress 重试机制的原理、以及最终的配置和代码层面的解法完整拆开希望对正在维护 K8s 集群、或者写后端接口的同学有实际帮助。1. 现象回放一次请求为何变成两次调用1.1 故障现场记录先描述一下当时的环境。集群版本是 1.20Ingress Controller 用的是官方 ingress-nginx基于 Nginx版本 1.1.3部署方式为 Deployment 双副本通过 LoadBalancer 暴露入口。后端订单服务是 Java Spring Boot 应用Deployment 同样两个副本业务接口是POST /api/orders/submit会做库存扣减、生成订单、调用支付网关下单三步。故障那段时间后端服务所在节点 CPU 有些抖动订单接口响应时间从平时的 200~300ms 偶尔飙升到 3~5 秒。客户端设置的是 5 秒超时Ingress 默认的proxy-read-timeout是 60 秒所以客户端层面还没有超时但 Nginx 转发层已经“替”客户端做了一次重试。具体时间线是这样的时间事件20:03:21.521客户端请求到达 IngressIngress 转发给订单服务 Pod A20:03:26.118Pod A 处理中数据库慢查询导致响应延迟尚未返回20:03:26.130Ingress 判定 Pod A 响应超时或出错将同一请求转发给 Pod B20:03:27.482Pod A 处理完成写出订单和扣减记录但响应回传时 Ingress 已放弃等待20:03:27.483Pod B 收到同样请求再次执行订单创建和扣减20:03:28.211Pod B 返回成功客户端收到 200以为只提交了一次从结果上看用户只操作了一次后端却执行了两次。两个请求的 body 完全相同所以订单号相同但数据库主键、订单编号是服务端生成的于是产生了“两条主键不同、业务内容完全一样”的订单记录。1.2 第一轮排查所有常规手段全部落空一开始我们先查了应用日志和 Nginx 日志。Ingress 的 access log 里确实能看到两条几乎一样的请求记录。但坏就坏在这两条记录的request_id是同一个因为 ingress-nginx 默认会把客户端的X-Request-ID透传而后端应用日志打印的 traceId 也是同一个。我们翻遍了应用日志没有找到任何一条异常堆栈也没有超时异常。数据库里也只有一条“INSERT 操作超时”的告警但没有具体错误记录。这意味着从应用视角看两个请求都“正常成功”了。我们甚至一度怀疑是不是网关层做了重放把客户端的请求原样复制了一份。于是我们打开了 Ingress 的 debug 日志同时在 Nginx 配置里开启log_format增加了上游响应时间的记录才慢慢摸到重试的痕迹。这里的第一个认知是Ingress 重试时重试请求和原始请求共享同一个 TCP 连接和 request_id所以从日志上极难分辨。如果你的应用日志只看业务字段、traceId不记录“收到请求的源 IP 和端口”这种问题很容易被当成灵异事件。1.3 关键线索抓包与上游耗时对比为了确认重试的存在我们在 Ingress Pod 上用 tcpdump 抓了 8080 端口流量同时在订单服务 Pod 上抓了业务端口流量。对比发现同一个 TCP 连接上Nginx 先向后端 Pod A 发送了完整请求然后在请求尚未结束时又向后端 Pod B 发送了完全相同的请求字节流。看到这里基本可以确定请求重试发生在 Ingress 这一层由 Nginx 的proxy_next_upstream机制触发。接下来就是彻底搞清楚它为什么触发以及怎么控制它。2. 拆开 Ingress 的重试黑盒2.1 NGINX 的 upstream 重试机制Nginx Ingress Controller 本质上还是一个 Nginx 进程它把后端 Pod IP 和端口动态生成到 upstream 配置里。当 Nginx 把请求转发给某个 upstream server 时如果连接失败、读取响应超时、或者收到了 5xx 错误码它可能将请求重新发给另一台 server也就是 upstream 列表里的下一个节点。这个行为由proxy_next_upstream指令控制。默认值在不同场景下不一样。官方 ingress-nginx 的默认策略是如果后端 Pod 只有一个副本proxy_next_upstream为off如果后端是多个副本则为error timeout。这意味着只要后端属于“多副本”且出现“连接错误”或“超时”Nginx 就会在下一次请求处理时自动重试到另一个 Pod。对于当时的环境订单服务是 2 个副本所以 Nginx 自动启用了重试。后端 Pod A 因为数据库慢查询未能在读取超时默认 60 秒内返回响应Nginx 干脆放弃等待转而请求 Pod B。这里的“重试”不是客户端重发的复制而是 Nginx 代理层在同一连接上重新向后端发起的一次全新 TCP 请求。2.2 proxy_next_upstream 到底会在哪些情况下触发proxy_next_upstream后面可以跟多种条件常见的包括error与上游服务器建立连接、发送请求、读取响应头时发生错误。timeout连接超时、发送请求超时、读取响应头超时。invalid_header上游返回空响应或非法响应头。http_500、http_502、http_503、http_504上游返回对应状态码。non_idempotent允许对非幂等请求如 POST、PATCH、LOCK也进行重试。off完全关闭重试。需要特别注意的是Nginx 默认认为“重试”只能用于幂等请求也就是 GET、HEAD、PUT、DELETE 这类。POST 请求必须显式加上non_idempotent才会被重试。但当时我们团队里并没有人主动配置过non_idempotent为什么 POST 还是被重试了这里有个很多人忽略的细节non_idempotent只影响“是否重试非幂等请求”。但如果请求体在第一次发送时已经完整传递给了上游Nginx 已经读完客户端的 request body 并缓存且错误发生在读取响应阶段Nginx 的判定逻辑会更为宽松。简单说当 Nginx 已经完整发送请求体以后对 POST 类请求仍然可能重试尤其是在proxy_next_upstream配置里包含了错误触发条件时。2.3 关键参数和默认值Ingress 层面与重试相关的常用注解有这几个注解作用默认值nginx.ingress.kubernetes.io/proxy-next-upstream控制重试触发条件单副本off多副本error timeoutnginx.ingress.kubernetes.io/proxy-next-upstream-timeout重试尝试的超时时间0不限制nginx.ingress.kubernetes.io/proxy-next-upstream-tries最大重试次数0不限制nginx.ingress.kubernetes.io/proxy-connect-timeout连接后端超时5 秒nginx.ingress.kubernetes.io/proxy-read-timeout读取后端响应超时60 秒nginx.ingress.kubernetes.io/proxy-send-timeout向后端发送请求超时60 秒从表格能看到多副本情况下只要后端在 60 秒内没返回响应头Nginx 就会认为“读取超时”从而触发重试。如果后端业务还在继续处理只是响应慢了重试机制就会造成重复调用。也就是说重试能治病也能要命尤其在后端处理耗时接近超时阈值时。3. 根因分析重试为何会变成重复调用3.1 迟到成功上游处理与代理重试的赛跑我们把根因总结成一句话“请求没有失败只是慢”而代理层把“慢”当成了“失败”。Nginx 没有能力感知上游 Pod A 是否还在继续处理请求。它只负责等待响应等不到就换一个节点重新发。这样 Pod A 的处理结果就变成了一次“迟到成功”订单数据已经写进去了但没人告诉调用方“这笔订单其实已经创建成功了”。这里涉及一个分布式系统里著名的概念分布式调用中的超时并不等同于失败。超时只能说明“在约定时间内没收到结果”而不代表“操作没有发生”。Ingress 的自动重试等于把这种不确定性问题从客户端转移到了代理层却没有任何机制去和大后方对账。当时我们数据库里的数据最能说明问题两条订单记录、两次库存扣减、两次支付下单请求。如果下游支付网关的接口不是幂等的用户甚至可能被扣两笔款。3.2 NGINX 对“幂等”的理解和业务不一定一致在前面的机制里我们提到Nginx 对 POST 请求的默认重试策略是保守的。但生产环境里还有一种常见情况很多同学对待“查询类”或“提交类”接口习惯性使用PUT或GET来实现。比如GET /api/orders?actionsubmit或者PUT /api/orders/{id}/submit这类设计。Nginx 认为GET和PUT都是幂等的所以当后端超时或返回 5xx 时它非常乐意帮忙重试。但对业务系统而言“重试”意味着业务逻辑再跑一遍再发一个 MQ 消息、再调用一次第三方接口、再扣一次库存。哪怕最终数据库层面是幂等的消息队列里的重复消息、外部系统收到的重复通知也会造成一系列麻烦。所以第二个认知是代理层的幂等判断基于 HTTP 方法而业务层的幂等需要靠业务代码保证。你接口写成功了不代表重试是安全的。如果一个接口会触发外部副作用哪怕它是 GET也应该通过去重表或 Token 机制兜底。3.3 跨服务调用时的放大效应如果说 Ingress 重试只是第一层那后端的重试还会继续放大。我们的订单服务调用支付网关的时候SDK 里还配了一套超时重试机制支付网关接到请求后又会调用支付渠道的接口渠道侧也有自己的重试。也就是说一次 Ingress 层面的重试可能在整条链路里像雪球一样滚成三四次重复调用。这种放大效应在很多微服务系统里都存在只是平时不触发。只有当下游依赖出现抖动、耗时变长时整条链路的“隐式重试”才会浮出水面。我们在排查时就发现除了订单服务两条记录支付网关也收到了两个“下单”请求虽然支付渠道最终只成功了一笔但实际上另外一笔被网关自己的幂等机制给拦截了。如果没有这道拦截损失会更大。3.4 为什么应用日志看起来一切正常日志里没有异常的原因很简单两次请求都处理成功了。对于应用本身来说它并不知道这是一个“重复请求”它只知道自己收到了一个合法请求参数完整、校验通过那就正常执行业务逻辑返回成功。所以这种问题的迷惑性就在这里没有错误只有重复。可能在日志里看到的是两个间隔极短、完全相同的请求但如果没有针对性地去比对请求内容很容易忽略。4. 解决方案配置与代码双管齐下4.1 方案 A按业务场景收窄重试策略最直接的办法是调整 Ingress 的重试配置。如果你的核心业务接口不希望在代理层进行自动重试可以直接在 Ingress 注解中关掉apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: order-service-ingress annotations: nginx.ingress.kubernetes.io/proxy-next-upstream: off spec: rules: - host: api.example.com http: paths: - path: /api/orders pathType: Prefix backend: service: name: order-service port: number: 8080这样配置以后后端只要发生超时或错误Nginx 不会重试而是直接把错误返回给客户端。客户端根据自己的超时和重试策略决定是否重发。好处是业务逻辑更可控坏处是可用性会有所下降因为一次后端抖动就会导致请求失败。所以这种做法更适合“对一致性要求高、对可用性要求相对没那么极端”的核心写接口。如果你的接口想保留重试能力但只允许在明确返回 5xx 错误码时重试可以这样配置nginx.ingress.kubernetes.io/proxy-next-upstream: http_500 http_502 http_503 http_504这里的关键逻辑是把重试条件从“超时”收窄为“明确的 5xx 错误码”。超时本身不代表业务失败而 5xx 通常是后端明确说“我失败了”这种情况下重试的收益最大误伤最小。4.2 方案 B超时参数联动调优如果不想关闭重试就要把超时和重试配置整体联动起来。一个比较合理的经验值是这样nginx.ingress.kubernetes.io/proxy-connect-timeout: 3s nginx.ingress.kubernetes.io/proxy-read-timeout: 30s nginx.ingress.kubernetes.io/proxy-send-timeout: 30s nginx.ingress.kubernetes.io/proxy-next-upstream: error timeout http_500 http_502 nginx.ingress.kubernetes.io/proxy-next-upstream-timeout: 5s nginx.ingress.kubernetes.io/proxy-next-upstream-tries: 2解释一下这几个参数proxy-read-timeout从 60 秒降到 30 秒避免一个慢请求卡住代理连接太久。proxy-next-upstream-tries设置为 2代表原始请求 最多 1 次重试避免重试风暴。proxy-next-upstream-timeout设置为 5 秒限制整个重试等待时间防止 Nginx 在后端持续无响应时无限等待。这样调整以后单个请求的最长等待时间约为 30 秒 5 秒用户可以接受服务端也不会出现大量线程被慢请求拖死的情况。注意一点超时设置要和后端业务实际耗时匹配。如果你的接口本身就需要 40 秒才能处理完把proxy-read-timeout调成 30 秒就不合适。这里一定要先统计业务接口的 P99 耗时再决定超时阈值不要拍脑袋。4.3 方案 C业务接口幂等兜底无论代理层怎么配置真正解决“重复调用”问题的最后一道防线还是业务侧的幂等。我们当时的做法是引入了一个全局唯一的“请求幂等键”客户端生成 UUID 作为Idempotency-Key请求头传给后端后端在 Redis 里以这个 key 做去重// 伪代码基于 Redis 的幂等校验 public void submitOrder(SubmitOrderRequest request, String idempotencyKey) { String redisKey idempotency:order: idempotencyKey; Boolean firstCall redisTemplate.opsForValue() .setIfAbsent(redisKey, 1, Duration.ofMinutes(30)); if (!firstCall) { // 如果不是第一次调用直接返回上一次的成功结果或抛出重复提交异常 throw new DuplicateRequestException(重复请求); } // 执行业务逻辑 createOrder(request); deductStock(request); callPayment(request); }这个方案的原理很简单利用 Redis 原子操作保证同一个幂等键只会执行业务逻辑一次。如果 Ingress 重试把相同请求再发过来后端能识别出来并拒绝重复执行。但需要提醒的是Redis 的SETNX和业务逻辑之间不是绝对原子性的较真的话需要引入事务或分布式锁来保证“检查幂等”和“执行业务”的一致性。不过对大多数场景来说这个方案已经足够。另外幂等键不仅要从后端生成也要支持客户端传入。当时我们做的是如果请求头里有Idempotency-Key就以它为准如果没有后端自动生成一个。但这会带来一个问题Ingress 重试时复制的是同一个客户端请求所以请求头一致后端能识别出来如果是后端自动生成幂等键第一次请求还没走到生成逻辑就超时了第二次进来又会生成新的幂等键起不到去重作用。所以面向用户的写接口幂等键最好强制客户端生成并传入。4.4 配置生效验证改完配置以后重启 Ingress Controller 让配置生效。之后我们用压测工具专门模拟了慢响应场景在订单服务 Pod 里加了一个/slow接口故意 sleep 10 秒然后通过 Ingress 访问这个接口观察 Nginx 的 access log 和上游日志。如果配置正确Ingress 的日志里只会出现一条记录后端也只会收到一次请求。我们同时检查了 Nginx 的 upstream 响应时间字段确认不再出现两条相同的日志记录。这里推荐一个可以日常检查的指令在 Ingress Controller Pod 里执行nginx -T查看实际生效的 nginx 配置直接搜proxy_next_upstream确认当前值是否符合预期。kubectl exec -it ingress-nginx-controller-xxxx -- nginx -T 2/dev/null | grep -n proxy_next_upstream5. 常见问题与排查手段5.1 重试把所有请求都打向了同一个 Pod有同学在排查时发现明明后端有两个 Pod重试请求却仍然发给同一个 Pod。这是因为proxy_next_upstream的“下一台”是根据 Nginx 的负载均衡算法来的如果 upstream 里只有一个可用节点或者算法恰好选中同一个节点重试还是会打到原来的 Pod。这种情况在单副本服务上特别常见所以如果你是单副本默认重试配置是off不用担心如果是多副本但某个 Pod 已经不健康被摘除重试就只会打到剩下的健康节点此时要注意容量是否足够。5.2 WebSocket 长连接也被重试Ingress 的proxy_next_upstream机制对 WebSocket 同样适用但表现很奇怪。我们在另一个项目里遇到过WebSocket 连接建立后如果一段时间没有数据Nginx 会认为读取超时然后尝试和后端重连。这个过程中客户端完全不知道发生了重连就会出现消息丢失或断线重连的情况。对 WebSocket 这类长连接场景建议直接关闭重试或者单独为 WebSocket 的路径设置更长的超时时间。比如给 WebSocket 的 Ingress path 加上nginx.ingress.kubernetes.io/proxy-read-timeout: 3600s nginx.ingress.kubernetes.io/proxy-next-upstream: off避免代理层闲着没事干自动换后端。5.3 重试风暴拖垮后端如果后端的 5xx 是系统性问题比如数据库连接池耗尽Ingress 对所有请求都执行重试等于把压力翻倍打回去数据库压力更大故障恢复更慢。这时候重试从“故障转移手段”变成了“故障放大器”。这种场景需要把重试次数调小并配合熔断和限流。Ingress 层面的proxy-next-upstream-tries设置成 1 或 2同时开启后端服务的优雅降级避免故障时所有流量都在代理层重试。还可以利用nginx.ingress.kubernetes.io/limit-rps之类的注解做入口限流保护后端。5.4 排查命令与思路速查我把这类问题的排查步骤整理成一个速查表方便以后直接照着做步骤操作关键观察点1查看 Ingress access log按request_id过滤是否存在多条相同 request_id、时间间隔很短2对比后端应用日志筛选相同 traceId后端是否收到多个相同请求3在 Ingress Pod 上nginx -T查配置proxy_next_upstream当前值是什么4在 Ingress 和后端 Pod 分别抓包同一请求字节流是否重复发送到不同后端5查看后端接口耗时分布是否出现过接近超时阈值的耗时6查看 Nginx 错误日志是否记录了upstream timed out6. 可观测性建设让幽灵请求提前现形经历过这次事故我的一个体会是代理层重试本身不是坏事但如果你看不到它它就是坏事。所以事后我们做了一件事在 Ingress 的 access log 中增加$upstream_addr和$upstream_response_time字段这样每条请求日志都能看到它实际被转发到了哪台后端、耗时多少。之后如果再有类似问题扫一眼日志就能发现“同一个 request_id 出现在两个 upstream_addr 上”定位速度会快很多。另外我们给 Ingress Controller 配置了按状态码和延迟的告警。当 P99 延迟超过阈值或者 5xx 比例突然上升时不只会通知还会自动把最近一小时的重试相关日志聚合出来方便值班同学直接定位。具体实现是在日志采集侧加了针对upstream response time的监控项并用 Prometheus 的 histogram 展示不同阶段的耗时分布。对于业务系统我建议把“幂等键校验失败”也作为一个可观测指标通过日志和监控反映出来。它本身就是一种非常明确的重复调用信号。如果这个指标开始上涨说明上游或代理层有人在重试值得关注。最后再分享一点个人体会做故障排查这些年我发现大多数让人抓狂的问题根源都是“系统的某个中间环节在替你做决定且没告诉你”。Ingress 重试就是典型的善意机制带来恶果它本意是提升成功率却没有办法感知业务语义。如果你正在设计一个新的微服务系统建议从一开始就把“代理层是否重试”和“业务是否幂等”这两件事分开考虑。代理层可以重试但前提是业务接口做到天然幂等业务接口做不到幂等时就必须在代理层收缩甚至关闭重试。离开具体业务场景去谈“该不该配置重试”永远得不出正确答案。这篇文章记录的配置和排查方法在我们后续的几个项目里反复用到过尤其是proxy-next-upstream的收窄、超时联动和幂等键方案基本成了新服务上线前的默认配置模板。希望这些踩坑经验也能帮你少熬几个夜。