
两周前我们内部把“OCR限流控制”单独排成了一个专项需求单编号恰好是114所以大家私下都管它叫“114 OCR限流控制”。一开始我也没把四个字当回事——限流谁没做过但真正动手以后才发现OCR场景的限流和普通接口限流完全是两码事。我们后端同时接了第三方OCR平台百度、腾讯这类和自建的PaddleOCR服务业务场景包括用户问卷拍照上传后的OCR识别、上传合同的金额/单位/时间等关键字段抽取、还有存量PDF文件批量回灌识别。上线后第一天就出事了第三方平台的QPS配额被打爆返回了一堆错误码服务端还把我们临时降了配。复盘后结论很明确——对OCR这类“高耗时、强配额、混合流量”的服务不做限流控制等于裸奔。这篇文章就把我们从参数计算到落地实现、再到上线后踩坑的完整过程记录下来。1. OCR服务的流量特征为什么突发一上来就挂1.1 OCR请求和普通HTTP请求的差别普通接口请求比如查询订单、获取用户信息单次耗时通常在几十毫秒以内线程被占用的时间极短。OCR请求完全不一样图片解码、预处理、版面分析、文字检测、文字识别一个流程走下来平均耗时800毫秒大一点的PDF转图或扫描件能到3秒以上。这意味着什么一个线程同时只能处理一个OCR请求假设线程池有20个线程每个请求跑800毫秒理论最大吞吐只有25 QPS但这25个线程全被占满了。一旦有新的请求进来要么排队要么直接拒绝。更麻烦的是OCR请求的耗时方差很大——一张清晰的白底黑字图片和一张倾斜、模糊、带水印的拍照图时间可能差出4到5倍。1.2 不控流的三种死法第一被上游平台限流封禁。第三方OCR服务都有明确的QPS或并发配额比如某个接口申请下来是10 QPS你瞬间打出30个请求平台会直接返回错误码频繁超限还会触发封禁或降配。我们当时就被降过一次配处理周期还不短。第二自建服务雪崩。PaddleOCR或者Tesseract这类自建服务没有平台配额限制但CPU和内存是硬约束。并发一高CPU跑到100%单次RT从800毫秒涨到8秒然后客户端大量超时重试把系统彻底压垮。这是一种典型的“没有外部闸门”的雪崩。第三费用失控。很多OCR平台是按调用量计费的尤其是高精度版或专用版接口。超限后有些平台不会拒绝请求而是继续处理并计费月底账单会让你怀疑人生。1.3 我们线上的真实流量模型分析之后我们把自己的流量分成两路实时通道用户拍照上传问卷、合同字段识别这类请求需要快速响应期望RT在2秒以内QPS不算高但集中在早晚高峰突发性强。批量通道存量文件夜间回灌识别数量大、QPS平缓但对延迟不敏感跑一晚上都行。这两路请求如果共用同一个限流器一定会互相干扰——批量任务跑起来实时请求就得排队。这也是后面坑一的伏笔。所以第1章的核心结论是OCR限流要解决的核心问题不是“怎么把请求挡住”而是“怎么让流量平滑同时保护下游不被打爆”。2. 限流算法选型令牌桶、滑动窗口、漏桶到底选哪个2.1 三种方案的公平对比限流算法主流就那几种各自的行为差异非常明显算法核心思想突发处理实现复杂度适用场景固定窗口计数按时间窗口统计请求数超限拒绝窗口边界容易突发双倍流量低要求最简单的场景滑动窗口计数把窗口切分成小格子细化统计能抑制边界突发但不够平滑中对突发敏感的场景漏桶请求进入桶里以固定速率流出突发请求会被排队或丢弃无法吸收低对出口速率要求严格的场景令牌桶按固定速率生成令牌桶满则丢弃令牌请求消耗令牌允许短期突发但长期速率恒定中有配额上限、允许突发的场景固定窗口有个经典临界问题如果窗口是1秒第0.9秒来了10个请求第1.1秒又来了10个请求两秒内实际放行了20个请求但按固定窗口统计每个窗口都只有10个没有超限。这对OCR这种单次高耗时的服务是致命的——瞬时20个请求同时打到自建服务上CPU直接被打满。2.2 为什么我们最终选了令牌桶核心原因有三个。第一第三方OCR平台的配额模型是按“每秒速率”算的而令牌桶的模型正好也是“长期速率恒定、短期允许突发”。只要平均速率控制在配额以内瞬时的小尖峰不会触发平台风控。第二OCR服务不能容忍漏桶那种“严格排队”的方式。某个瞬间来了十几个请求如果全排队实时请求的RT就会被拉长到好几秒用户端根本等不了。令牌桶在桶里有存量令牌时可以让部分请求立即通过体验好很多。第三滑动窗口计数虽然能解决边界突发但实现起来要维护多个时间片的计数而且对所有突发请求一刀切没有“借未来配额”的概念。用令牌桶我们只需要调一个桶容量参数就能控制“允许多大程度的突发”。2.3 限流到底放在哪一层限流位置有三种选择我们最终做了双层防护客户端/业务层限流在我们的Java服务内部做令牌桶所有OCR请求必须经过这个令牌桶拿令牌后才允许发出。好处是能感知业务类型实时和批量可以走不同的桶坏处是只能管住我们自己如果其他服务也直接调OCR接口管不住。网关层限流在Nginx或者API网关加一层按IP/按应用的限流作为兜底。好处是集中管理坏处是没法感知OCR业务的具体优先级。服务端限流如果是自建OCR服务在PaddleOCR这一层也做限流防止客户端限流失效。自建服务没有配额概念可以直接用信号量控制并发数。我们的最终方案是业务层令牌桶做主控网关层限流做兜底。后面讲实现和坑的时候也都是围绕这条主线展开的。3. 限流参数怎么定从业务指标反推QPS阈值3.1 三步计算链路限流参数最忌讳拍脑袋。比如随便把限流设成10 QPS结果业务高峰实际只有8 QPS多余配额浪费了反过来设成5 QPS高峰请求全被拒用户体验崩了。我们是用三步推出来的。第一步确定下游安全阈值。对第三方OCR接口安全阈值就是平台分配的配额比如高精度文字识别接口申请到10 QPS。对自建PaddleOCR没有外部配额但可以通过压测得一个保守值。我们压测后发现自建服务单机在P99延迟1秒以内时最高能稳定跑12 QPS但为了容灾只按8 QPS算。第二步统计单次OCR请求的平均耗时。我们从全链路日志里拉了一个星期的数据平均RT是800毫秒P99是1.5秒。这个数字决定了并发上限。第三步计算并发上限和安全并发数。有一个基本公式安全并发数 安全QPS × 平均耗时以我们的数据为例10 QPS × 0.8秒 8。也就是说任何时刻同时飞在途的OCR请求不应该超过8个。超过8个即使QPS指标没超下游的并发压力也会超。3.2 令牌桶参数的实操设置我们最终把参数定成了这样参数业务层实时通道业务层批量通道说明令牌补充速率6 QPS4 QPS预留20%~30%余量令牌桶容量810允许突发但受并发上限约束信号量并发数88超过8个在途请求直接等待拿令牌等待超时200ms1s实时请求快速失败批量请求宽容一点这里有个很多文章不会细讲的点令牌桶容量到底怎么算不能只看QPS还要看并发上限。如果桶容量设成20虽然令牌补充速率只有6 QPS但突发的瞬间能放出去20个请求。对于配额只有10 QPS的第三方平台这20个请求就是实打实的超限。所以桶容量的上限就是并发上限我宁可把突发周期缩短也不让并发一次冲得过高。3.3 预留余量别把限流值顶到配额极限我们把速率设计成配额上限的70%~80%比如配额10 QPS业务层速率设6~8 QPS。有人会觉得浪费但这么做有三个理由第三方平台的限流判定有延迟和波动你压着线跑它一抖动就会误杀。重试请求会额外消耗令牌如果完全跑满配额根本挤不出余量给重试。日志、监控、告警本身也会产生少量错误重试需要缓冲。预留余量之后我们再看“一次重试要消耗一次令牌”这个认知就自然引出了落地实现里的一个关键细节。4. 落地实现从手写令牌桶到分布式限流4.1 单机版组合RateLimiter SemaphoreJava服务内最直接的做法是用Guava的RateLimiter配合Semaphore。RateLimiter管速率Semaphore管并发。注意两者不是一回事RateLimiter保证“每秒最多N个请求”但没法保证“同时只有M个请求在飞”Semaphore恰恰补上这个口子。import com.google.common.util.concurrent.RateLimiter; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; public class OcrRateLimiter { // 每秒最多6个令牌允许突发积累 private final RateLimiter rateLimiter RateLimiter.create(6.0); // 同时最多8个请求在途 private final Semaphore concurrencyLimiter new Semaphore(8); public boolean tryAcquire(long timeout, TimeUnit unit) throws InterruptedException { // 1. 先拿令牌拿不到直接失败 boolean token rateLimiter.tryAcquire(timeout, unit); if (!token) { return false; } // 2. 再获取并发许可 boolean permit concurrencyLimiter.tryAcquire(timeout, unit); if (!permit) { // 注意信号量拿不到要把令牌还回去否则令牌就被白白消耗了 return false; } return true; } public void release() { concurrencyLimiter.release(); } }这个组合拳里最容易犯的错误是拿了令牌之后信号量没拿到忘了把令牌还回去。RateLimiter默认不支持“归还”令牌但Google的RateLimiter在0.14以后支持reserve和smooth相关接口。如果用的是标准tryAcquire拿不到信号量时令牌已经消耗掉了这一秒的配额就白白少了一次。我们当时的处理是信号量拿不到时记录一条“并发超限”的日志让监控能看出限流具体卡在哪一层。4.2 手写一个轻量令牌桶原理不需要黑盒虽然生产上用Guava或者Redisson更稳但为了让自己心里有底我们手写了一个简化版做了个实验理解了令牌桶的几个关键细节public class TokenBucket { private final long capacity; private final double refillRatePerMs; private double tokens; private long lastRefillTime; public TokenBucket(long capacity, double refillRatePerSecond) { this.capacity capacity; this.refillRatePerMs refillRatePerSecond / 1000.0; this.tokens capacity; this.lastRefillTime System.currentTimeMillis(); } public synchronized boolean tryAcquire() { long now System.currentTimeMillis(); long elapsed now - lastRefillTime; tokens Math.min(capacity, tokens elapsed * refillRatePerMs); lastRefillTime now; if (tokens 1) { tokens - 1; return true; } return false; } }这段代码有几个关键点synchronized保证线程安全因为令牌扣减和补充必须是原子的。每次取令牌时计算“上次补充到现在”的时间差再往桶里补令牌而不是被动等一个定时器。定时器会有延迟和资源浪费。Math.min(capacity, ...)防止桶里的令牌超过容量上限。这个版本能跑通但生产不建议直接用——Java的System.currentTimeMillis()存在时钟回拨问题synchronized在高并发下也会造成锁竞争Guava的SmoothRateLimiter在冷启动和突发处理上做得好得多。我们只是拿它验证了原理。4.3 多实例部署下的分布式限流如果服务只有一个实例上面的方案完全够用。但一旦水平扩展成3个实例每个实例都建一个RateLimiter等于限流被放大了3倍。第三方配额看的是整体出口流量不是单机流量所以必须做分布式限流。我们用的是Redis Lua脚本实现分布式令牌桶。选择Lua是因为它能在Redis里原子执行不会因为并发操作产生数据错乱。核心逻辑和手写版本一致-- KEYS[1] bucketKey -- ARGV[1] capacity -- ARGV[2] refillRatePerSecond -- ARGV[3] now local tokens tonumber(redis.call(get, KEYS[1] .. :tokens) or ARGV[1]) local lastTime tonumber(redis.call(get, KEYS[1] .. :time) or ARGV[3]) local delta (ARGV[3] - lastTime) / 1000 tokens math.min(ARGV[1], tokens delta * ARGV[2]) redis.call(set, KEYS[1] .. :tokens, tokens, PX, 60000) redis.call(set, KEYS[1] .. :time, ARGV[3], PX, 60000) if tokens 1 then redis.call(set, KEYS[1] .. :tokens, tokens - 1, PX, 60000) return 1 end return 0这里要注意生产环境优先用Redisson的RRateLimiter或者阿里Sentinel的集群限流它们把锁、过期、时钟校准都处理好了。自己写的Lua脚本应对Redis主从切换、时钟跳变时会有各种问题我们线上实际上后来换成了Sentinel 集中式Token Server才彻底放心。4.4 代码里的三个常见反面做法我审代码时见过三种特别典型的错误如果你也在做OCR限流避开这三个坑能省很多事用Thread.sleep()限流。比如算完间隔后睡100毫秒这在单线程场景可能看起来有效但多线程下所有请求同时睡醒限流瞬间失效。把限流写在异步回调里。OCR识别往往是异步的一些同学把限流逻辑写在回调线程里请求已经发出去才限流等于没限。限流器和重试循环叠加。限流器拒绝了请求外层重试循环又立刻重试且重试不走限流器——这等于限了一个寂寞。重试与限流叠加的问题几乎和限流本身一样重要我们上线后就被这个坑折腾了一晚上下一章详细说。5. 上线后踩的坑排查链路完整复盘5.1 坑一批量任务和实时请求互相挤占上线限流后的第一天晚上我们启动了夜间批量回灌任务。凌晨两点告警就响了实时OCR请求的P99延迟从800毫秒涨到了5秒。排查链路是这样的先看限流日志发现批量任务一启动令牌桶的命中率长时间保持在100%几乎每个实时请求都在tryAcquire(200ms)里等超时。再看监控曲线批量任务的首次编码是晚上10点启动实时指标从10点开始就异常时间完全吻合。最后看代码确认了根因实时和批量共用了一个RateLimiter。修复方案很直接拆成两个独立的限流器实时通道速率6 QPS批量通道速率4 QPS信号量也各自独立。同时加了一个优先级规则批量请求在拿令牌前先检查实时通道的令牌余量如果余量小于2批量请求暂时让路。这样实时请求在高峰期永远能拿到令牌批量任务慢一些没关系但不会把实时拖死。这个坑让我认识到限流设计的第一步不是选算法而是把流量的业务特征梳理清楚。单一限流器面对多路混合流量时一定会出现强者抢饭、弱者饿死的现象。5.2 坑二限流等待超时引起请求堆积雪崩修完坑一之后实时请求恢复了但两周后又出了新问题某天下午3点高峰期总QPS并没有超过限流值但有一个核心服务的线程池被打满了用户端超时率飙升。排查链路复盘先看线程池监控活跃线程数600接近最大值800但队列里有大量任务排队。看限流日志未出现大量超限拒绝反而有大量“成功获取令牌等待中”的记录。再看代码发现我们当时把所有拿不到令牌的请求都设置成了tryAcquire(2, SECONDS)批量任务甚至设置了10秒。算了一笔账如果有100个请求都卡在tryAcquire等待线程池里就有100个线程被占着不做任何事后续的请求还得进入队列排队最终线程池被打满新的请求直接抛RejectedExecutionException。根因很清楚限流器把请求在出口挡住但入口的请求仍然全部涌入线程池被“等待中的请求”占满。这就像把水龙头关了管道里还蓄满了水压力一点没减。修复分三步实时请求的令牌等待时间从2秒缩短到200毫秒超时直接返回429或降级提示。线程池设置拒绝策略为CallerRunsPolicy并发太高时让调用线程自己执行而不是无限排队。在入口加一层信号量控制“同时等待令牌”的请求数超过阈值直接快速失败。这次之后我们才真正意识到限流不是只挡住出口流量入口流量也需要有对应的兜底。否则限流器只是把压力从下游转移到了自己的线程池里。5.3 坑三重试逻辑绕过了限流器这个问题最隐蔽也是我们差点在线上环境就带病上线的原因。现象是我们给OCR调用加了一个“最多重试3次间隔200毫秒”的通用重试机制目的是应对第三方平台的瞬时抖动。结果上线后QPS不降反升第三方平台反而开始报我们超配额。排查链路看限流器的拒绝日志发现限流器确实拒绝了大量请求但再往下追发现有请求在同一秒内限流器拒绝一次重试循环又发起了一次新的请求。看调用链发现重试代码写在Service层的外层限流器只是内层的一行调用。重试发生时新请求并没有重新经过限流器而是直接执行HTTP调用。又看了一个更深的问题对返回的错误类型没有区分。第三方平台返回“限流超限”和返回“内部繁忙”时我们都触发了重试“限流超限”的重试等于在限流器已经明确拒绝的情况下硬生生把流量又放出去了。修复方案是这样落地的重试请求必须重新获取令牌不允许绕过硬性限流。对OCR返回的错误做分类配额类错误错误码带Quota/Limit字样不重试而是退避等待繁忙类错误最多重试1次间隔500毫秒超时类错误最多重试1次立即执行。重试次数从3次压到2次把“可能的重试流量”也计入限流的余量里。5.4 坑四永久性错误被反复重试最后一个坑和OCR本身关系更大。日志里出现过一件奇怪的事同一份PDF文件对应的OCR请求在20分钟内重复了60多次。当时看代码重试逻辑是“只要HTTP状态不是2xx就重试”结果这个文件由于图片格式问题每次第三方平台都返回{error_msg: file format error}。这个错误并不是临时故障而是图片本身坏了重试一千次也不会有结果。另外补充一个经验OCR的语言识别问题和限流无关但如果识别不了韩文等非中文内容通常不是限流的锅而是模型或语言包的问题。PaddleOCR这类框架默认可能只加载了中英文识别模型需要在初始化流水线时显式启用多语言模型有时候还要确认前端上传的图片是不是真的包含对应文字还是上传后被压缩成了黑白图。我们线上就遇到过用户上传的韩文合同OCR完全没识别出来查到最后是语言包没加载而不是OCR被限流了。回到坑四的修复我们给所有的OCR调用结果加了一个错误分类器——永久性错误格式错误、参数错误、鉴权失败直接丢弃或进入人工处理队列不重试临时性错误超时、繁忙、限流才进入重试逻辑。这样既避免了无意义重试也避免了重试流量挤占限流配额。6. 压测验证怎么证明限流真的有效6.1 压测方案与结果改完上面几个坑之后我们做了三轮压测核心目的是验证限流是否在“正确的时间、以正确的力度”生效。场景一稳定流量8 QPS持续3分钟观察是否触发超限拒绝。预期完全通过因为限流速率设的是6 QPS8 QPS理应超过但允许少量突发。这里我们特意测试了“预留余量”是否合理。场景二30个请求在同一时刻突发观察通过率和拒绝数。预期桶容量8最多8个立即通过其余被限流拒绝。场景三批量任务和实时请求混流压测观察实时通道的P99延迟和批量任务吞吐量。压测结果基本符合预期我截取了几个关键指标指标限流前限流后峰值QPS25超配额8可控突发实时通道P99延迟5s900ms第三方平台错误码次数大量0线程池活跃线程峰值800满210批量任务回灌吞吐不稳定稳定4 QPS持续运行6.2 压测时务必观察的三个隐患压测不仅要看QPS和延迟还要额外观察三个容易被忽视的地方。第一被限流拒绝的请求返回的HTTP状态码。我们统一返回429并带了Retry-After头方便调用方理解。如果有的地方返回500调用方会当成系统故障重试反而增加流量。第二限流拒绝日志里要记录来源和类型。我们每条拒绝日志都有sourcerealtime/batch和reasontoken_limit/concurrency_limit这样排查线上问题时能直接看出是哪个通道、哪个环节被拦截了。第三压测时要把第三方平台侧的错误码拉出来对账单。有些限流在客户端看似生效了但平台报表里仍然有超限记录说明本机参数还是太激进需要继续降低速率或桶容量。结尾这套限流控制做完之后我最大的体会是限流不是一道算术题而是一套需要持续调节的平衡机制。我们最初以为只要设一个QPS上限就完事结果被实时和批量混流、重试绕过、线程池堆积这些现实问题轮番教育。现在每次上线新的OCR业务我都会先问三个问题这个业务是实时还是批量它最大的突发量是多少如果它把令牌吃光了别的业务能接受排队等多久把这三个问题想清楚限流方案的核心就已经定了大半。最后再分享一个小技巧限流参数不要上线后就不管了每个月至少根据线上实际流量曲线调整一次。业务增长、活动大促、新接入的调用方都会让原有参数逐渐失效提前调参数比出事后再救火舒服得多。