
1. 项目背景与需求拆解1.1 “114”项目到底在做什么先说背景。我这边接到的任务代号就叫“114”表面上看是114个需要接入OCR识别的业务方入口实际操作起来完全不是这么简单。每个入口背后挂着不同类型的业务——有上传合同扫描件做关键字段抽取的有拍照识别问卷结果的有解析PDF银行流水的还有处理韩文、日文等小语种文档的。业务方在群里喊“OCR识别挂了”“识别结果出不来”我的手机直接被打爆。真正让“114”这个项目出名的是后半段限流控制。这里要解释一下不管是自建的 PaddleOCR 服务还是调百度OCR、腾讯OCR的API接口底层都有一个看不见的约束——单位时间内的请求配额。这个配额一旦被打满轻则响应变慢重则直接被服务端拒绝报429或 error_code到那时你会发现所有业务方的调用全部挤成一锅粥。限流控制说到底就是要在“业务方不限量地提交识别请求”和“下游OCR服务能力有限”之间加一道闸门让流量按节奏走。我在这篇实战里会把整个限流方案从原理到落地讲透包括令牌桶怎么实现、Redis崩了怎么办、超时重试怎么配合、以及我踩过的几个典型坑。适合三类人看正在自建OCR服务的后端开发接了第三方OCR API但经常被限流的同学以及任何想把“限流”这件事做到生产级水准的技术负责人。1.2 限流控制为什么是最大的坑先泼一盆冷水限流控制看起来简单写个计数器谁都会但真正放到OCR场景里坑比想象中多得多。第一个坑在于OCR请求的“重”属性。一般HTTP接口限流大家关心的是QPS每秒请求数但OCR不一样。一份合同PDF可能几十页转成图片后单张请求就要几百KB甚至几MB识别一张图在服务端可能耗时几百毫秒到几秒。这种场景下单纯限制QPS是不够的你还要考虑并发连接数和带宽占用。我实际压测时发现同一个实例上跑普通接口和OCR接口资源消耗完全不是一个量级。第二个坑在于OCR服务的限流参数非常复杂。以百度OCR为例它的配额维度包括QPS上限、每日调用量上限、并发数上限不同接口的配额还不一样。腾讯OCR的限流策略又不同它会针对同一个app_id做全局控制。你如果只在自己的代码里加一层简单限流两边配额的叠加关系完全理不清线上随时可能爆。第三个坑是人的因素。114个业务入口每个入口的调用量差异巨大。有的业务方一天就几百次调用有的业务方高峰期每秒几十次。如果限流策略是“一刀切”的小业务方会被大业务方拖累大业务方又会觉得你卡得太死。所以限流控制表面上是技术问题实质上是个资源分配问题。我这套方案最终落地时核心就三句话总量控制、按入口配额、异常快速降级。下面我会把这些拆开讲。2. 限流核心原理与方案选型2.1 OCR限流的三种典型形态聊方案之前先把限流在OCR场景里的形态捋清楚。我把它分成三层每一层限流的意义完全不同。第一层是入口限流也就是业务方调用你们OCR网关这一层。这一层的目的是保护你们自己的后端服务不被冲垮同时保证不同业务方之间公平使用。入口限流通常做在网关或者独立的限流中间件里按app_id、来源IP、调用方应用名等维度做配额。第二层是下游配额保护也就是调用百度OCR、腾讯OCR或自建PaddleOCR服务时要确保你的请求量不超出下游允许的范围。这一层最容易被忽略。很多人以为“反正下游有配额超了它会报错”但下游报错之后你的重试会再次冲击下游形成恶性循环。更麻烦的是很多第三方OCR服务的配额是按天或按小时重置的一旦被限制恢复周期非常长。第三层是资源隔离也就是识别任务本身的执行层限流。自建OCR时GPU显存、CPU核数、内存大小都是硬性资源并发拉太高模型推理速度会骤降甚至直接把服务进程拖死。这一层通常用信号量或线程池来控制并发数。我在“114”项目里三层都做了但投入产出比最高的还是第一层和第二层的联动设计。第三层反而好办因为自建PaddleOCR服务可以用现成的推理框架自带的并发配置先把前面的入口控制住后面压力就小很多。2.2 令牌桶与滑动窗口怎么选说到限流算法网上资料一大把固定窗口、滑动窗口、漏桶、令牌桶各有各的说法。我只聊两个在OCR场景里真正用得上的滑动窗口和令牌桶。固定窗口算法有个天然缺陷就是临界突变问题。举个例子如果限制每分钟100次某一秒内先来了1次请求然后这一秒结束时计数器归零下一秒又来了99次——看起来每分钟都不到100次但那一秒钟内实际打过去了100次请求服务端直接顶不住。滑动窗口能解决这个问题但它需要记录每个请求的时间戳内存占用偏高而且是“硬上限”思维——到了上限直接拒绝。令牌桶就灵活得多。它允许一定程度的突发流量桶里攒着令牌来一波突发请求可以先把令牌打空后面再慢慢补。这对OCR场景太合适了因为OCR识别天然就有“业务方集中上传一批文件”的突发特征如果你用硬性滑动窗口业务方高峰期全被拒掉体验很差用令牌桶业务方可以在短时间内冲一波只要平均速率控制在配额内就行。我自己最终的选型是入口层用令牌桶Redis实现下游保护层用固定限流器基于Semaphore 时间窗口。为什么下游保护不用令牌桶因为下游OCR服务的配额是服务端硬性设置的它不关心你的突发能力只关心你每秒、每天真实打过来多少请求。你这边令牌桶再灵活打到下游超了就是超了没有商量余地。所以下游保护层必须做“硬限制”把每秒出网请求数严格压在配额以下。2.3 我最终选定的限流架构整套限流架构分四块接入层、限流层、转发层、降级层。画不了流程图我用文字把链路讲清楚。接入层就是114个业务方统一走API网关每个请求带app_id和业务类型参数。网关拿到参数后先到限流层去申请令牌。限流层是核心我部署了两个组件Redis令牌桶服务和本地信号量闸门。Redis负责跨实例的全局配额控制——同一份配额对集群内所有实例生效本地信号量闸门负责保护单实例的下游连接数算是一个快速容错机制因为如果所有请求都去查Redis再判断Redis一旦变慢限流本身就成了瓶颈。转发层拿到限流层的“通行证”之后调用下游OCR SDK。这里做了双通道自建的PaddleOCR走内网通道百度OCR、腾讯OCR走公网API通道。两个通道分别配独立的限流参数避免互相干扰。降级层是最容易被忽视的。OCR识别挂了或者限流了业务不能直接报错。我的方案是识别失败的请求先写入MQ延迟重试如果重试仍然失败走人工作业兜底——把图片转存到OSS生成一个待人工识别的任务。这一层在关键时刻能救命后面我会详细说。提示限流架构不用追求一步到位。我建议先做入口层限流和下游保护层限流跑通线上之后再加降级层。一上来就全部铺开每个环节都是新的故障点排查起来很痛苦。3. 实战落地代码与配置全解3.1 基于Redis的令牌桶实现令牌桶算法本身不复杂但落地到Redis里有讲究。我用的是Lua Redis原子脚本方案保证判断和扣减令牌两个操作不可分割。为什么一定要原子因为一旦出现并发请求同时读到桶里有令牌、同时扣减、同时放行的情况限流就形同虚设了。下面这版Lua脚本我改过几轮稳定性基本没问题-- key: 令牌桶的Redis键名 -- capacity: 桶容量 -- rate: 每秒补充的令牌数 -- requested: 本次请求需要的令牌数 -- timestamp: 当前时间戳秒 local key KEYS[1] local capacity tonumber(ARGV[1]) local rate tonumber(ARGV[2]) local requested tonumber(ARGV[3]) local now tonumber(ARGV[4]) local current_token tonumber(redis.call(hget, key, token)) if current_token nil then current_token capacity end local last_time tonumber(redis.call(hget, key, timestamp)) if last_time nil then last_time now end -- 计算时间流逝期间补充的令牌 local delta math.max(0, now - last_time) current_token math.min(capacity, current_token delta * rate) if current_token requested then current_token current_token - requested redis.call(hset, key, token, current_token) redis.call(hset, key, timestamp, now) return 1 else redis.call(hset, key, token, current_token) redis.call(hset, key, timestamp, now) return 0 end这段脚本在Java里通过Spring Data Redis调用核心代码长这样public boolean tryAcquire(String bucketKey, int requestedTokens) { ListString keys Collections.singletonList(bucketKey); ListString args Arrays.asList( String.valueOf(capacity), String.valueOf(rate), String.valueOf(requestedTokens), String.valueOf(System.currentTimeMillis() / 1000) ); Long result redisTemplate.execute(redisScript, keys, args.toArray()); return result ! null result 1L; }这里有几个细节务必要说清楚。第一令牌桶的capacity参数要结合OCR服务的实际时延来设。如果下游识别一张图要200毫秒那你单个实例的并发连接数不宜超过5因为1000/200×525TPS已是极限再高也消化不了。如果你Redis令牌桶的容量设成100等于给业务方开了个“可以突发100个并发”的口子冲到下游照样被打回来。第二rate参数不要想着一次性配好我建议先按下游行能力下限的80%来配留20%的buffer给重试和突发。比如百度OCR的QPS配额是10那就设rate8capacity8。这样既不会浪费配额也不会因为业务方手滑一次性打满。第三hget这个键最好带实例信息或者服务维度信息。我在线上是把键名设计成ocr_limit:{app_id}:{api_type}。这样每个业务方、每个OCR通道都有独立的桶。千万不要用一个大桶扛所有流量否则一个业务方把桶打空了其他业务方全部饿死。3.2 调用百度OCR与腾讯OCR时的限流对接有了令牌桶之后下一步是把限流逻辑和具体的OCR SDK对接起来。这一步细节非常多我先拿百度OCR举例。百度OCR调用的标准流程是先获取access_token然后调用具体识别接口。限流通常发生在识别接口这一层。对接时我封装了一个BaiduOcrClient在真实调用前先走一遍限流器public class BaiduOcrClient { private final TokenBucketLimiter limiter; private final ExecutorService executor Executors.newFixedThreadPool(8); public OcrResponse recognize(String imageBase64, OcrType type) { // 1. 从令牌桶申请令牌等价于下游QPS为10时我们给自己限到8 boolean allowed limiter.tryAcquire(ocr_limit:baidu: type.getCode(), 1); if (!allowed) { throw new RateLimitExceededException(百度OCR调用超过限流阈值请稍后重试); } // 2. 获取token并调用SDK String accessToken getAccessToken(); return doRecognize(accessToken, imageBase64, type); } }这里有一个我个人强烈建议不要把令牌桶的key按“百度OCR”整体做一个桶。实际操作中百度OCR的通用文字识别、身份证识别、合同关键字段抽取配额是分开计算的。你在控制台开服务的时候每个接口的QPS配额是独立的。按接口维度分别建桶才能精准保护。腾讯OCR的对接逻辑类似但它有一个额外的坑腾讯OCR对同一SecretId的全局调用频率也有限制。也就是说就算你每个接口都控制好了多个接口合在一起的请求总量也可能超限。解决方法是加一层全局桶key是ocr_limit:tencent:globalrate配成所有接口rate之和。这层全局桶相当于一个“总闸门”防止合流之后冲垮下游。PaddleOCR自建服务这一块反而简单。因为服务是自己的你可以直接修改PaddleOCR的部署参数在模型推理层限制并发。比如FastDeploy部署时可以通过配置runtime的线程数、batch_size来控制GPU实际处理负载。前端再加一层信号量限流就够了private final Semaphore paddleSemaphore new Semaphore(4); public OcrResponse recognizeByPaddle(String imagePath) throws InterruptedException { if (!paddleSemaphore.tryAcquire(3, TimeUnit.SECONDS)) { throw new RateLimitExceededException(PaddleOCR服务繁忙请稍后重试); } try { return paddleClient.recognize(imagePath); } finally { paddleSemaphore.release(); } }信号量限流和令牌桶限流的区别在于令牌桶管的是“速率”信号量管的是“并发数”。自建服务最大的瓶颈是并发推理占用的显存和CPU所以用信号量卡并发数更有效。如果识别任务的耗时波动大有的图快有的图慢信号量天然能把速率也平滑下来——并发数固定平均吞吐也就固定了。3.3 超时、重试与熔断的配合技巧限流不是孤立的一层。OCR链路里必须有超时、重试、熔断三个机制和限流打配合否则限流只会让故障从“快速报错”变成“排队卡死”。超时设置我查了大量线上日志后最终把第三方OCR调用的超时定在5秒连接超时定在2秒。为什么是5秒因为OCR识别不是普通接口图片大、服务端计算时间长3秒超时太激进正常请求也会被误杀。但超过5秒还没返回大概率是下游已经过载了再等下去只是浪费线程。重试策略重试区分两种情况。如果异常是限流类异常RateLimitExceededException、HTTP 429不要立即重试而是退避重试。如果异常是网络超时可以快速重试一次但重试后仍然失败就要放下来绝对不能“重试到成功为止”。我最终的重试参数是限流异常退避重试2次间隔分别是1秒和3秒网络异常快速重试1次间隔200毫秒。重试都要消耗令牌否则第一次失败后重试请求直接因为令牌不足继续失败形成死循环。熔断这是我在“114”项目后期才加上的因为线上出过一次事故——百度OCR那边某个接口连续报错我的限流层判断不出来还在不断放流量过去结果下游挂了不说业务方侧看到的是“大量识别失败”。后来我加了一个断路器连续失败率达到50%就把对应接口的熔断器打开直接拒绝新请求等30秒之后再放少量试探流量。这个模式就是经典的Hystrix思路但现在我用的是轻量实现直接在BaiduOcrClient里加了一个状态机。public class CircuitBreaker { private final AtomicInteger failCount new AtomicInteger(); private final AtomicLong openedAt new AtomicLong(); private static final int THRESHOLD 10; private static final long OPEN_DURATION_MS 30_000; public boolean isAvailable() { if (openedAt.get() 0) return true; return System.currentTimeMillis() - openedAt.get() OPEN_DURATION_MS; } public void recordFailure() { if (failCount.incrementAndGet() THRESHOLD) { openedAt.compareAndSet(0, System.currentTimeMillis()); } } }这里要注意熔断打开后并不是什么都不做业务方需要拿到一个明确的响应“服务暂不可用请稍后重试”而不是一个超时空白。同时熔断期间要把请求直接转到备用通道。比如自建PaddleOCR通道是主用百度OCR通道就可以作为备用的降级目标。多通道互为备份才能真正保障业务连续性。4. 线上问题与排查实录4.1 流量高峰时429错误暴增上线后遇到的第一个大问题是高峰期的429。现象是每天上午10点到11点、下午2点到3点业务方集中提交合同和问卷识别任务限流层开始大批量拒绝请求业务群炸锅了。第一反应以为是配额配小了但我调大令牌桶容量后发现429并没有减少——因为下游OCR服务的配额是写死的不是你调大令牌桶就能突破的。真正的问题出在“预约识别”机制缺失上。业务方提交任务时如果识别失败理应把图片放到队列里慢慢消化而不是一直重试。后来我加了削峰填谷的处理在接入层前面加了一个内存队列请求进来先入队限流层从队列里取任务去识别而不是直接同步调用。高峰期的超额任务全部排队低峰期慢慢清。这相当于用时间换空间识别总量没变但瞬时压力被抹平了。4.2 Redis抖动导致的限流雪崩第二个问题更隐蔽。某天下午我看到监控大屏上接口成功率骤降排查限流日志发现大量RedisCommandTimeoutException。令牌桶在Redis里Redis抖动了所有请求都无法判断是否拿到令牌我倾向于“拿不到令牌就拒绝”结果就是全链路拒绝。这个问题的根源是限流依赖了强一致性的Redis。Redis本身就是高可用的但它也有抖动窗口比如主从切换、大Key清理、网络分区。一旦查询超时限流组件比下游先挂掉反而成了新的故障点。解决方案是分两档本地快速判断 异步同步到Redis。具体做法是每个实例维护一个本地令牌桶每次请求先从本地桶拿令牌同时定期把消耗量同步到Redis里的全局桶。如果Redis超时本地桶仍然能独立工作一段时间虽然做不到全局严格一致但至少不会出现“Redis一抖服务全挂”的情况。这里要明确一个理念限流的最终目的是保护系统不是执行审判。在极端情况下宁可短暂放宽一点限流也不能因为限流组件本身故障导致全链路不可用。4.3 实例扩容后限流策略失效这个坑让我印象最深。最开始只有2个实例Redis全局令牌桶控制得很好。后来流量上涨我把实例扩到6台结果发现下游OCR服务的调用量直接超了配额。按理说Redis桶是全局的不应该超啊排查下来发现问题出在我的rate参数上。每台实例每秒消耗2个令牌Redis桶的rate是10按道理6台实例最多每秒消耗12个超过就会被拒绝。但真实情况是每台实例的本地信号量层先把请求放进来信号量不判断下游配额真正的判断在Redis桶那里。也就是说6台实例的请求同时到达RedisRedis桶判断通过就放行但同一毫秒内Redis桶只能处理一部分处理不过来的时候发生了并发穿透——多个请求同时读到桶里还有令牌于是同时放行实际消耗超出了容量。解决方法是两段式限流第一段本地信号量控制单机并发第二段Redis令牌桶控制全局速率第二段在请求真正调用下游前执行。同时我重新算了每台实例的rate。原来配的是单机每秒2个令牌现在每台实例的令牌桶rate不变但Redis全局桶的rate从10调低了稍微留了一点buffer。核心公式是全局rate 单机rate × 实例数 × 0.8。比如单机rate2实例数6全局rate配9.6。留下0.4的buffer给重试和突发。4.4 常见问题速查表整理一个线上排查速查表都是我亲身踩过的问题按症状、原因、解决方案三列展开。症状可能原因解决方案接口返回429业务方投诉令牌桶容量配小或下游配额耗尽检查配额使用率调大capacity或切换备通道Redis CPU飙升限流变慢令牌桶key过多每请求多次读Redis批量Lua合并操作或增加本地桶图片识别明明成功但耗时翻倍本地并发信号量过小请求排队调大Semaphore或增加实例调用百度OCR偶发file format error图片过大或格式不符与限流无关前置校验图片大小与格式先压缩再识别某一业务方拖垮所有业务方令牌桶key没有按app_id隔离按app_id维度建桶设置独立配额重试风暴导致下游彻底挂掉重试策略未做退避失败请求无限重试使用指数退避限制最大重试次数PaddleOCR GPU显存溢出并发推理数超过显存容量降batch_size限Semaphore并发数关于“图片识别不了韩文”这类问题我也多说一句。限流控制的是请求量但识别质量同样会影响体验。PaddleOCR默认模型对韩文支持并不好需要额外下载多语言模型或者用OCR文字识别能力更强的服务。如果业务方反馈识别结果乱码这通常不是限流问题而是模型语种覆盖问题。建议先在网关层根据lang_type参数做路由把韩文、日文等小语种请求路由到对应的模型或服务通道不要和中文请求混在一起抢占限流配额。5. 实操心得这几点让我少走了弯路回过头看整个“114”项目最值钱的经验其实不在技术方案本身而在几个很细节的判断上。第一个心得是先定指标再写代码。项目启动时我和团队定了三个指标——下游配额不被打满、业务方请求成功率不低于99.5%、P99时延不超过3秒。后面所有限流参数设计都以这三个指标为锚点。没有指标限流参数就是拍脑袋。第二个心得是所有限流参数都要可视化。我用Prometheus Grafana把每个令牌桶的剩余令牌、拒绝数、Redis耗时、下游调用量全部埋点。限流这种组件出了故障最怕的是看不见。一张实时监控面板比一百行日志都有用。第三个心得是备通道永远要提前准备。很多团队把宝全押在百度OCR上百度一挂全线瘫痪。我这边至少保持两个可用通道即便备通道的单张识别成本高一些关键时刻能顶上就是胜利。第四个心得非常有实用价值限流拒绝的响应要带上“可重试时间”。很多限流组件直接返回false调用方并不知道什么时候能重试。我后来在响应头里加了Retry-After比如限流超时是3秒就告诉业务方3秒后再来。这一个小改动让业务方的重试行为从“疯狂乱撞”变成了“有序等待”整个链路稳定了很多。最后再说一个容易被忽略的细节——日志。限流组件每拒绝一个请求都要打印一条有上下文的日志哪个app_id、哪个接口、当时令牌桶剩余多少、Redis耗时多长。很多线上问题排查到最后都是靠这些日志还原现场。你永远不知道哪条日志会在深夜救你一命。“114”项目上线跑了大半年限流这块从最初的每天上百次报错降到后来一周都未必有一次。说实话限流很难做到百分百完美但只要你把每一层机制都打磨到位它就能给你足够的信心把流量放出去。希望这套实战经验能帮同行少走些弯路。