ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

图形验证码限流优化:从 3 秒阻塞到毫秒级拒绝的踩坑记录

图形验证码限流优化:从 3 秒阻塞到毫秒级拒绝的踩坑记录 一、背景医生说验证码打不开了事情从一次线上反馈开始。有医生报告医生移动端登录页的图形验证码加载不出来图片裂开多刷几次才有概率出来。高峰时段尤其明显。查日志定位到那个时间点07:50:06 到 07:50:47Redis 抖动了约 40 秒。期间 Redis 上的读写全部变慢甚至无响应之后自行恢复。看起来只是一次 40 秒的中间件抖动但服务端的实际表现远比 40 秒严重网关有约 30 秒的整体冻结恢复后还持续出现PrematureClose报错有请求体读取延迟超过 10 秒的记录。一次 40 秒的故障为什么会被放大成一场几十分钟的风波把当时的链路画出来看07:50:06 Redis 抖动开始持续约 40 秒 │ ├─► 网关 RateLimiterFilter 同步调用 Redis调在 Netty 事件循环线程上 │ → 事件循环被卡住 → 整个网关冻结约 30 秒 │ └─► 下游服务的验证码接口要写 Redis → 请求线程全部挂在 Redis 上 │ ├─► Tomcat 线程池被占满 │ → 同进程其他接口跟着无响应 │ └─► Redis 恢复后积压请求集中涌入 → 验证码接口自身限流参数太紧、还阻塞排队 → 二次雪上加前端大面积裂图网关侧的问题限流过滤器同步调 Redis是另一个改造话题。这份文档聚焦的是放大链条的最后一环验证码接口自己的限流代码怎么把一次 40 秒的抖动放大成了几倍的故障时长。二、先补课这个接口原本的限流设计验证码接口的原始代码很短出自LoginControllerprivate static final RateLimiter rateLimiter RateLimiter.create(2.0); ​ PostMapping(value /getImageCaptcha) Operation(summary 生成图形验证码) public void getImageCaptcha(HttpServletResponse response, RequestParam(sign) String sign) { boolean retBol rateLimiter.tryAcquire(3, TimeUnit.SECONDS); if (!retBol) { log.warn(不可频繁获取图片验证码); return; } loginService.getImageCaptcha(sign, response); }给不熟悉限流的同学补三个概念。QPSQueries Per Second每秒能处理多少次请求。核心公式QPS 并发数 ÷ 平均响应时间。RateLimiter.create(2.0)表示这个接口每秒最多放行 2 个请求。【注意这里是单机qps如果是多实例多机器情况单机器qps*机器数实例数】RateLimiter令牌桶限流器Guava 提供的单机限流工具。想象食堂的餐票窗口机器每秒只印 2 张票来打饭的人必须凭票入场。RateLimiter就是那台印票机它只管这一秒进了几个人不管是谁进来的。因为是纯内存计数所以它只能管住自己这台服务器——部署 3 台机器集群实际放行能力就是 6 QPS这叫单机限流。tryAcquire伸手要一张票。有两种要法tryAcquire()看一眼有票拿票走人没票扭头就走——非阻塞tryAcquire(3, TimeUnit.SECONDS)没票的话站在窗口前等 3 秒这 3 秒里要是新票印出来了就拿票进去3 秒后还没票才走——阻塞等待。代码里的意思很直白每秒只放 2 个人进挤不进去的可以排队等 3 秒还等不到就返回。另外要交代一下整个系统的限流分层后面会用到。这个接口其实被两层限流罩着层级工具管什么网关层Redis ZSet 滑动窗口用户级rate:{path}:{userId}限制单个用户对单接口的调用频率网关层Redis ZSet 滑动窗口接口级rate:{path}:total限制单接口的整体流量服务层本接口Guava RateLimiter单机兜底这台机器每秒最多放行 N 个请求记住这个分层后面讲QPS 为什么定 10 而不是 100时会用到。三、发现的 3 个问题点问题1tryAcquire(3, TimeUnit.SECONDS)——限流器自己成了雪崩放大器这是三个问题里最致命的一个。tryAcquire(3, TimeUnit.SECONDS)在没抢到令牌时会占着线程等 3 秒。每次调用它的请求都跑在一个 Tomcat 工作线程上默认一共 200 个等待期间这个线程什么也干不了。平时没流量问题不大因为每秒的请求量很少超过 2大家都有票。但故障恢复期是另一幅画面Redis 抖动的 40 秒里前端的请求要么卡着要么失败网关、浏览器都在重试40 秒后 Redis 一恢复积压的请求同时涌进这个接口。假设一秒进来 100 个请求2 个抢到令牌正常返回98 个抢不到每个都占着线程准备等满 3 秒第二秒又来 100 个又占 98 个线程……Tomcat 的 200 个工作线程在两三秒内全部挂在等令牌上。线程池满了之后这个服务上的所有接口都失去响应——不只是验证码登录、查询统统排队。这就是限流器反而成为雪崩放大器它的本意是保护服务结果它自己先把线程吃光了。生活中的例子理发店只有 10 个座位线程池。合理做法是客人进门发现没座位就先去逛商场过会儿再来非阻塞快速失败。但这家店的规矩是没座位的客人必须坐在门口的等候椅上干等 3 分钟等不到才能走。客流一大等候椅坐满了人10 个座位也被坐着干等的客人占住结果连那些只想快速剪个头就走的客人也进不来了——整个店瘫痪。正确姿势限流拒绝时应该立刻放行失败让线程毫秒级回到池子里。也就是把tryAcquire(3, TimeUnit.SECONDS)改成tryAcquire()。问题2QPS2——把兜底限流定得比业务峰值还紧每秒 2 个请求意味着部署 3 台机器的集群每秒总共只放行 6 个验证码请求。先估一下正常业务量。验证码只在两个时机被请求用户打开登录页时加载一次点换一张或登录失败时再来一次。假设最紧张的场景——早上 8 点300 名医生在 10 分钟内集中登录平均每人要 1.5 张验证码单机 QPS ≈ (登录人数 × 每人验证码次数) ÷ 时段秒数 ÷ 机器数 (300 × 1.5) ÷ 600 ÷ 3 ≈ 0.25日常峰值单机不到 1 QPS2 看似够用。但够用的前提是一切正常。问题3会讲到故障恢复期的请求是瞬时洪峰不是平滑流量。2 QPS 的门缝正常时期排队 3 秒还能挤过去问题1的阻塞机制让它看起来能用高峰期和恢复期就直接把大量正常用户挡在门外了。这里的关键是认清这层限流的定位。回看第二节的分层表防刷、防单用户轰炸是网关用户级限流的职责这台机器上的 Guava 限流器只是单机兜底防止单台实例被瞬间打挂。兜底的取值逻辑是正常峰值乘以余量而不是越紧越安全。把兜底定得比业务峰值还紧等于平时就戴着枷锁跑步。生活中的例子银行网点每天接待 200 个客户大厅本来就该按这个量配置取号上限。结果这家支行把取号机设置成每天只发 20 个号——防黄牛吗黄牛自有保安和黑名单网关层管着最后卡的只是正常储户。问题3静默拒绝——限流了但没人知道看这段if (!retBol) { log.warn(不可频繁获取图片验证码); return; // 方法返回 void此时响应状态码是 200响应体是空的 }被限流的请求前端拿到的是HTTP 200 空 body。后果有两个。第一用户端裂图。img标签拿不到图片数据直接显示裂图图标。用户点换一张这次没超限图片出来了——所以那天医生反馈的现象是多刷几次才有概率出来完全吻合。第二监控盲区。状态码是 200任何基于状态码的监控、网关统计、Nginx 日志分析都认为这次请求成功了。限流明明发生了运维却完全看不见排查时只能靠翻应用日志里的 warn。生活中的例子银行号发完了门口保安把新来的客人放进大厅让他坐在窗口前干等什么也不说200 空 body。客人以为自己排上了队其实永远轮不到他。正确的做法是挂一块今日号已取完的牌子——客人看见就走了门口还能统计今天拦了多少人。挂什么牌子HTTP 429全名Too Many RequestsHTTP 协议为限流拒绝专门定义的状态码。它的好处浏览器加载img遇到 429 会判定失败触发onerror事件——前端配上自动重试用户甚至感觉不到被限流过Nginx、网关、监控大盘都能按状态码统计到 429限流量一目了然告警规则也能直接写。四、改造方案三处改动都不大放在一起对比// 改造前 private static final RateLimiter rateLimiter RateLimiter.create(2.0); ​ public void getImageCaptcha(HttpServletResponse response, RequestParam(sign) String sign) { boolean retBol rateLimiter.tryAcquire(3, TimeUnit.SECONDS); // 没票等 3 秒 if (!retBol) { log.warn(不可频繁获取图片验证码); return; // 200 空 body } loginService.getImageCaptcha(sign, response); } ​ // 改造后 Value(${ihm.captcha-qps:10}) private Double captchaQps; ​ private RateLimiter rateLimiter; ​ PostConstruct public void initRateLimiter() { rateLimiter RateLimiter.create(captchaQps); log.info(图形验证码单机限流初始化, qps{}, captchaQps); } ​ public void getImageCaptcha(HttpServletResponse response, RequestParam(sign) String sign) { boolean retBol rateLimiter.tryAcquire(); // 没票立即走 if (!retBol) { log.warn(不可频繁获取图片验证码); response.setStatus(429); // 显式拒绝 return; } loginService.getImageCaptcha(sign, response); }改动1阻塞改非阻塞tryAcquire(3, TimeUnit.SECONDS)改成tryAcquire()一行。效果是线程占用从最多 3 秒降到毫秒级。被限流的请求不再堆积在线程池里第二波请求来的时候线程都是空闲的整个服务不会被验证码接口拖死。改完顺手删掉了不再使用的import java.util.concurrent.TimeUnit;。改动2拒绝时返回 429return前加一句response.setStatus(429)。前端行为从收到 200 但图片为空变成收到 429确定性地失败监控从完全看不见变成按状态码可统计。这里有个小插曲值得记录第一版写的是response.setStatus(HttpServletResponse.SC_TOO_MANY_REQUESTS)——用常量代替魔法数字看起来更专业结果编译不过。原因见第七节坑1。改动3QPS 从 2 提到 10并做成配置项取值 10 的依据不是拍脑袋往大了改。生成验证码只是 CPU 画一张图加一次 Redis 写入单机扛 50 QPS 毫无压力性能上 10 和 50 没区别真正的约束是定位。这层是单机兜底见第二节分层表防刷交给网关。兜底值要高于正常峰值乘余量——按第三节的估算正常峰值单机不到 1 QPS10 给了 10 倍以上的余量覆盖故障恢复期的洪峰不支持更高的原因兜底的意义就是Redis 抖动之类的异常场景下别让太多请求冲向下游。放太宽就失去兜底意义了。同时把值挪到了配置项ihm.captcha-qps默认 10。好处是调参不用改代码发版Nacos 里加一行ihm.captcha-qps: 20重启即生效。配置的查找顺序JVM 参数-D 环境变量 Nacos 远程配置 本地配置文件 代码里的默认值。有一个坑要提醒Value不支持热刷新。在 Nacos 改了值要等实例重启才会用新值不是改完即生效。想要不重启热生效得加RefreshScope但那会给 Bean 生成代理、引入额外的复杂度目前没必要。五、同样的故障再来一次会怎样改造的价值要用场景检验。假设同样的 40 秒 Redis 抖动再次发生阶段改造前改造后抖动期间请求线程挂在 Redis 上紧接着抢不到令牌的请求每个再占线程等 3 秒写 Redis 的请求照旧会慢这是 Redis 的问题但抢不到令牌的请求毫秒级返回 429不占线程线程池几秒内被等令牌的请求占满全服务瘫痪只有真正在处理业务写 Redis的线程被占用其他接口正常响应Redis 恢复瞬间积压洪峰涌入2 QPS 门缝 3 秒阻塞二次雪上加洪峰涌入10 QPS 门缝放行超出的立即 429不排队不占线程恢复速度服务恢复远慢于 Redis残留 PrematureClose 连锁验证码接口在 Redis 恢复的瞬间同步恢复监控表现200 空 body限流发生但不可见429 可统计能看到限流量曲线判断是否需要调参诚实的边界也要说清楚Redis 抖动发生的那几十秒内裂图依然会出现——验证码写不进 Redis接口本身就失败这不是限流能解决的。改造消除的是故障被放大和恢复被拖长把故障影响从服务级瘫痪几十分钟压到验证码接口抖 40 秒。至于那 40 秒里让用户完全无感要靠前端img配onerror自动重试见第八节。六、测试方案6.1 先确认配置生效在initRateLimiter()里加了一行启动日志是配置生效最直接的证据。启动 mpc-server 后在日志里搜图形验证码单机限流初始化, qps10.0测试限流时建议把 QPS 调到 1观察窗口最大化不要改代码里的默认值用 IDEA 的 VM options 加-Dihm.captcha-qps1。它的优先级高于 Nacos 和本地配置测完删掉即可不碰任何代码。为什么强调不改默认值假如为了测试把Value(${ihm.captcha-qps:1})里的 10 改成 1测完忘了改回来合入主干生产上这个接口的单机限流就变成 1 QPS 了——测试手段本身变成线上事故是这类顺手改默认值的经典翻车路径。启动日志此时会显示qps1.0证明配置注入链路通了。6.2 压测脚本模拟 QPS 打满captcha-qps-test.ps1 压测脚本# .SYNOPSIS 图形验证码接口限流压测脚本模拟 QPS 打满观察 200/429 拦截行为。 ​ .EXAMPLE # 突发模式50 个请求尽快连发默认观察限流拦截 .\captcha-qps-test.ps1 ​ # 突发模式自定义数量 .\captcha-qps-test.ps1 -Count 100 ​ # 匀速模式以 20 QPS 持续压 10 秒需限流 QPS 20 才能打满 .\captcha-qps-test.ps1 -Mode rate -Rate 20 -Duration 10 ​ # 匀速低速观察限流恢复节奏QPS1 时每 400ms 发一次应约每秒放行 1 个 200 .\captcha-qps-test.ps1 -Mode rate -Rate 2.5 -Duration 12 ​ .NOTES 执行策略受限时用: powershell -ExecutionPolicy Bypass -File .\captcha-qps-test.ps1 必须直连 mpc-server(8886)走网关(8888)会被网关自己的限流拦截干扰判断。 # param( [string]$Url http://localhost:8886/user/v1/login/getImageCaptcha?signtest123, [ValidateSet(burst, rate)] [string]$Mode burst, [int]$Count 50, # burst 模式总请求数 [double]$Rate 20, # rate 模式发送速率 QPS [int]$Duration 10 # rate 模式持续秒数 ) ​ $ErrorActionPreference Stop Add-Type -AssemblyName System.Net.Http ​ if ($Count -le 0) { throw Count 必须大于 0 } if ($Mode -eq rate -and ($Rate -le 0 -or $Duration -le 0)) { throw rate 模式下 Rate 和 Duration 必须大于 0 } ​ # 本机 system.net/defaultProxy 配置节可能损坏预置空代理避免 HttpClientHandler 解析它 [System.Net.WebRequest]::DefaultWebProxy $null $handler New-Object System.Net.Http.HttpClientHandler $handler.UseProxy $false $client New-Object System.Net.Http.HttpClient($handler) $client.Timeout [TimeSpan]::FromSeconds(5) $script:totalSw [System.Diagnostics.Stopwatch]::StartNew() ​ function Send-One { param([int]$Seq) $req New-Object System.Net.Http.HttpRequestMessage([System.Net.Http.HttpMethod]::Post, $Url) $sw [System.Diagnostics.Stopwatch]::StartNew() $sec [int]$script:totalSw.Elapsed.TotalSeconds try { $resp $client.SendAsync($req).GetAwaiter().GetResult() $sw.Stop() $code [int]$resp.StatusCode $ms [math]::Round($sw.Elapsed.TotalMilliseconds, 1) $color if ($code -eq 200) { Green } elseif ($code -eq 429) { Yellow } else { Red } Write-Host ([{0,3}] t{1,6:0.000}s - {2} ({3} ms) -f $Seq, $script:totalSw.Elapsed.TotalSeconds, $code, $ms) -ForegroundColor $color return [pscustomobject]{ Code $code; Second $sec } } catch { $sw.Stop() $e $_.Exception while ($e.InnerException) { $e $e.InnerException } Write-Host ([{0,3}] t{1,6:0.000}s - ERR ({2}) -f $Seq, $script:totalSw.Elapsed.TotalSeconds, $e.Message) -ForegroundColor Red return [pscustomobject]{ Code ERR; Second $sec } } } ​ Write-Host 目标: $Url -ForegroundColor Cyan Write-Host (模式: {0} (burst: {1} 连发 | rate: {2} QPS x {3}s) -f $Mode, $Count, $Rate, $Duration) -ForegroundColor Cyan Write-Host (- * 62) ​ $results () if ($Mode -eq burst) { for ($i 1; $i -le $Count; $i) { $results Send-One -Seq $i } } else { $intervalMs 1000.0 / $Rate $seq 0 while ($script:totalSw.Elapsed.TotalSeconds -lt $Duration) { $seq $results Send-One -Seq $seq # 按绝对时间轴对齐下一次发送点保证发送速率精确 $sleep $seq * $intervalMs - $script:totalSw.Elapsed.TotalMilliseconds if ($sleep -gt 0) { Start-Sleep -Milliseconds $sleep } } } $script:totalSw.Stop() $elapsed $script:totalSw.Elapsed.TotalSeconds ​ $ok ($results | Where-Object { $_.Code -eq 200 }).Count $rej ($results | Where-Object { $_.Code -eq 429 }).Count $err ($results | Where-Object { $_.Code -eq ERR }).Count $other $results.Count - $ok - $rej - $err ​ Write-Host (- * 62) Write-Host (总计 {0} 个请求, 总耗时 {1:0.00}s -f $results.Count, $elapsed) -ForegroundColor Cyan Write-Host ( 200 放行 : {0} -f $ok) -ForegroundColor Green Write-Host ( 429 拦截 : {0} -f $rej) -ForegroundColor Yellow if ($other -gt 0) { Write-Host ( 其他状态 : {0} -f $other) -ForegroundColor Red } if ($err -gt 0) { Write-Host ( 连接错误 : {0} (服务未启动? 确认 mpc-server 端口 8886) -f $err) -ForegroundColor Red } if ($Mode -eq rate -and $ok -gt 0 -and $elapsed -gt 0) { Write-Host ( 实际放行速率 : {0:0.0} req/s -- 应约等于限流 QPS 配置值 -f ($ok / $elapsed)) -ForegroundColor Cyan } ​ Write-Host (- * 62) Write-Host 按秒分布 (200 放行 / 429 拦截): $results | Group-Object Second | Sort-Object { [int]$_.Name } | ForEach-Object { $c200 ($_.Group | Where-Object { $_.Code -eq 200 }).Count $c429 ($_.Group | Where-Object { $_.Code -eq 429 }).Count Write-Host ( 第 {0,2} 秒: 200 x {1,-3} | 429 x {2,-3} -f $_.Name, $c200, $c429) }写了一个 PowerShell 脚本captcha-qps-test.ps1项目根目录Windows 自带 PS 环境可直接跑支持两种模式burst 突发模式N 个请求尽快连发模拟故障恢复期洪峰rate 匀速模式以固定速率持续压一段时间验证放行速率精确等于配置值。三种典型场景测试目的命令预期结果打满看拦截.\captcha-qps-test.ps1QPS1 时50 连发1 个 200约 49 个 429看恢复节奏.\captcha-qps-test.ps1 -Mode rate -Rate 2.5 -Duration 12每 400ms 发 1 个请求大约每秒放行 1 个 200验证默认值 10去掉 VM 参数重启后.\captcha-qps-test.ps1 -Mode rate -Rate 20 -Duration 10200 个请求200/429 约各一半实际放行速率显示约 10.0 req/s脚本输出长这样rate 模式示意总计 200 个请求, 总耗时 10.01s 200 放行 : 101 429 拦截 : 99 实际放行速率 : 10.1 req/s -- 应约等于限流 QPS 配置值 按秒分布 (200 放行 / 429 拦截): 第 0 秒: 200 x 10 | 429 x 10 第 1 秒: 200 x 10 | 429 x 10 ...如果遇到执行策略限制用powershell -ExecutionPolicy Bypass -File .\captcha-qps-test.ps1启动。1qps2时50请求连发预计每秒放行2个230连发每 400ms 发 1 个请求大约每秒放行 1 个 2003qps1050连发4qps250连发5qps2每400ms发一次请求6.3 怎么读懂测试结果两个容易误判的点提前解释清楚。第一启动后第一个请求一定是 200这不是 bug。Guava 的RateLimiter创建后第一次获取令牌会立即成功内部是预支机制把下一次令牌的产生时间往后挪。所以 burst 50 连发的预期不是全部 429而是第 1 个 200剩下 49 个 429QPS1 时。第二令牌是匀速恢复的。QPS1 就是每秒补 1 张票。rate 模式下用 2.5 的速率发送每 400ms 一个请求能看到清晰的节奏第 1 个请求放行接下来两三个被拒攒满 1 秒再放行一个。按秒分布直方图里每行的 200 数量就是实际放行速率QPS1 时每行恰好 1 个。判定限流改造成功的三条标准对一下状态码序列符合预期首请求 200超速部分 429响应体为空Content-Length: 0rate 模式统计出的实际放行速率约等于配置值1.0 或 10.0 req/s服务日志同步出现不可频繁获取图片验证码的 WARN且压测期间同服务的其他接口不受影响——这正是问题1修复的直接证据。另一个细节压测必须直连 mpc-server 的 8886 端口不要走网关 8888。网关有自己那两层限流第二节分层表从网关进来的请求会先被网关限流拦下你观察到的 429 是网关返回的和这次改造的服务层限流不是一回事。七、踩坑实录改造和压测脚本开发过程中踩了四个坑按踩的顺序记录。坑1编译不过——Servlet 6.0 没有 SC_TOO_MANY_REQUESTS 常量现象response.setStatus(HttpServletResponse.SC_TOO_MANY_REQUESTS)编译报错找不到符号: 变量 SC_TOO_MANY_REQUESTS。原因项目用的 Spring Boot 3.0.2 对应Servlet 6.0。SC_TOO_MANY_REQUESTS429这个常量当年在 javax.servlet 4.0 时代是有的迁移到 jakarta 命名空间时被弄丢了直到Servlet 6.1才加回来Spring Boot 3.2 起才用 6.1。所以在当前版本里这个常量就是不存在。解法直接写response.setStatus(429)并留了一行注释Servlet 6.0 无 SC_TOO_MANY_REQUESTS 常量勿替换为常量写法——不加这行注释后续维护的同学或者 IDE 的自动优化很可能顺手改回常量写法再踩一遍。坑2JDK 21 编译报 NoSuchFieldError——Lombok 版本不兼容现象本地mvn compile报java.lang.NoSuchFieldError: Class com.sun.tools.javac.tree.JCTree$JCImport does not have member field qualid。原因我的本地默认 JDK 是 21而项目的 Lombok 1.18.22 太老不支持 JDK 21 里 javac 内部类的结构变化。JDK 21 是能编译这个项目的但需要 Lombok 升到 1.18.30而升级 Lombok 不在这次改动范围内。解法编译时把 JAVA_HOME 指向本机已装的 JDK 17C:\Program Files\Java\jdk-17。在 IDEA 里则是 Project Structure 里把 SDK 切到 17。坑3PowerShell 脚本中文乱码——BOM 的锅现象压测脚本写完执行满屏乱码鎸夌...还伴随一堆语法错误Missing ) in function parameter list。原因脚本以不带 BOM 的 UTF-8保存而 Windows PowerShell 5 对没有 BOM 的文件一律按系统 ANSI 代码页中文系统是 GBK解码。UTF-8 的中文按 GBK 读出来就是乱码乱码字符里恰好包含引号和括号把 PowerShell 的语法解析也搞坏了。解法把脚本重新保存为UTF-8 with BOM。BOM 是文件开头的三个特殊字节EF BB BFPowerShell 看到它就知道这是 UTF-8。命令行转换$content [System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8) [System.IO.File]::WriteAllText($path, $content, (New-Object System.Text.UTF8Encoding $true))坑4压测脚本连不上——系统代理劫持了 localhost 请求现象脚本跑起来每个请求都报Error creating the Web Proxy specified in the system.net/defaultProxy configuration section。原因这台机器的系统代理配置节system.net/defaultProxy有问题而 .NET 的HttpClient构造时会去解析它——连压 localhost 都会先经过这个解析步骤解析失败就直接抛异常。更隐蔽的是光给HttpClientHandler设置UseProxy $false都不够因为配置节的解析发生在更早的时机。解法在创建 HttpClient 之前先清空默认代理让它完全跳过配置节解析[System.Net.WebRequest]::DefaultWebProxy $null $handler New-Object System.Net.Http.HttpClientHandler $handler.UseProxy $false $client New-Object System.Net.Http.HttpClient($handler)压测 localhost 本来也不该走任何代理——走代理测出来的响应耗时是假的。八、遗留事项改造到这里后端侧已经完整但离用户完全无感还差一步记录下来留给后续前端img配 onerror 自动重试。后端 429 已就绪前端在验证码图片的onerror事件里做一次延迟重试比如 500ms被限流或抖动期的用户就感觉不到裂图。这是收益最大、改动最小的下一步。要留意一个配套逻辑重试要设次数上限比如 2 次否则限流期间会形成前端和后端之间的小型重试风暴反而加剧 429。网关侧限流改造Redis 调用加硬超时 失败放行策略。这是本次故障链条里网关冻结 30 秒的根源属于另一场改造本文不展开。调参路径上线后观察 429 的量这次改造后它可见了持续接近 10 QPS 说明配置该调大长期为零说明还有下调空间。配置在 Nacos 的ihm.captcha-qps改完记得要重启才生效第四节解释过 Value 不热刷新。
返回列表