首页 / 资讯中心 / 文章详情

Java大文件上传流量控制实战:基于令牌桶组件限流

Java大文件上传流量控制实战:基于令牌桶组件限流 ★ FEATURED ARTICLE
1. 项目场景与需求破题1.1 金融保险行业里的大文件上传有多“痛”做过金融保险核心系统的朋友应该都有体会这个行业表面上以交易、账务、保单数据为主但实际上影像流、附件流的体量一点都不小。理赔材料、投保单证、医疗票据、现场查勘照片、甚至一段事故视频随便一个报案单关联的附件加起来就可能上百MB。在没有做任何控制的时候一线业务人员直接把大文件拖进web页面上传请求一发应用服务器CPU立刻飙升带宽被占满其他正常交易接口跟着变慢数据库连接池也频繁告警。这种情况在集中出单、理赔高峰期尤其突出往往一个业务员上传一个80MB的视频整个前端页面卡死后台服务线程被阻塞最后拖垮同一JVM里的其他业务。这里要区分一个问题标题说的是“大文件上传的流量控制”很多人第一反应是限制文件大小比如限制只能上传5MB、10MB。但实际上在金融保险场景中很多文件根本没法硬性限制大小——理赔视频、高清现场照片、带签名的PDF扫描件业务上必须收你不可能让客户把文件压缩之后再提交。真正的需求是文件该收还得收但上传的速率和行为必须可控不能任由单个请求把所有带宽、内存、CPU全部吃光从而导致整个应用不可用。换句话说流量控制不是说“不允许大文件进来”而是说“大文件可以进来但要以我们规定好的节奏和速率进来”。1.2 流量控制与文件大小限制是两回事我最早踩过这个坑。刚接手保险影像平台时产品经理提了一个需求把单文件上传上限从20MB提升到200MB说业务那边很多高清视频传不进来。我当时直接改了下Spring配置里的max-file-size参数结果上线第二天出现大批量超时和内存溢出。表面上看200MB的文件偶尔传几个好像没多大问题但问题是高峰期同时上传的请求可能上百个每个请求都在和服务端抢占带宽、占用Servlet工作线程Tomcat的默认线程池很快被打满。后面再有普通的查询请求进来全部排队等待整个服务就像堵死在早晚高峰的高架桥上。后来我理清楚了。文件大小限制解决的是“这个文件能不能传的问题”而流量控制解决的是“服务端资源怎么分配的问题”。一个200MB的文件业务上允许你传但如果一次性让200个并发上传每个都到200MB任何单机应用都扛不住。流量控制要做的事情是在请求进入上传处理逻辑之前通过限流组件判断当前系统是否有足够的资源来处理这个请求。如果没有就让请求排队等待或者直接拒绝并告诉客户端“当前上传繁忙请稍后重试”从而保证系统的整体可用性。另外一个容易混淆的点是“带宽控制”和“请求速率控制”。网卡带宽是服务器的物理瓶颈但应用层能做的主要是请求速率控制。比如限制每秒钟最多处理多少MB的上传数据或者每分钟最多处理多少个上传请求。这二者往往配合使用请求速率控制管的是并发数量带宽控制管的是IO吞吐量。1.3 为什么不能靠一刀切方案扛过去最开始团队成员提过一个方案把所有大文件上传都改到存储服务器上通过FTP、NFS或者对象存储的直传链接搞定应用服务器只负责下发上传凭证。这个思路完全正确但有一个现实问题过渡期怎么办存量系统的改造周期很长而且很多核心保单系统已经运行了十几年不可能在一两个迭代里全部切到新链路。在改造没有完成之前仍然需要Java应用本身能够优雅地承受大文件上传的流量压力。还有一种“粗暴疗法”是给Tomcat增加线程数、调大JVM堆内存、加大服务器带宽。这些确实能延缓问题但解决不了本质。线程数越多每个上传请求占用的内存就越大堆内存越大Full GC时停顿时间越长对整个应用的抖动影响越大。而且这些参数调完了系统会在下一个高峰期继续打回原形。所以最合理的技术路线是在Java应用层引入流量控制组件用成熟的限流算法把上传流量控制在系统可承受的范围之内。2. 流量控制组件选型2.1 应用内组件 vs 外围网关组件做流量控制金融项目里常见的做法是分两条路径一种是在Java应用内部通过Servlet Filter、Spring HandlerInterceptor或者AOP切面来实现限流逻辑。这种方式的优势是灵活可控能够拿到当前用户、订单号、文件大小这些业务上下文甚至可以针对不同业务渠道设置不同的限流阈值。比如核心承保系统上传扫描件和理赔系统上传事故视频就可以配置两套不同的速率。另一种是在外围网关层做比如用Nginx的limit_req_zone模块或者Spring Cloud Gateway配合Redis做分布式限流。这种方式对Java应用本身无侵入性能开销也更低但坏处是网关层拿不到深入的业务信息只能简单按照IP、URL路径、请求体大小粗略限流。而且在保险行业里很多内网系统直接访问应用服务器IP的情况还大量存在完全依赖网关并不现实。我个人的建议是网关层做粗粒度拦截应用层做细粒度控制。网关负责挡住明显的恶意请求和超大规模流量Java应用内组件负责根据业务场景精细调控上传速率。标题里强调“Java应用中通过组件实现”核心落点就是第二种方式即在应用内部通过组件、拦截器、过滤器和限流算法来完成流量控制。2.2 令牌桶算法与Guava RateLimiter流量控制组件的核心是限流算法。金融保险场景中常用的两种是漏桶算法和令牌桶算法。漏桶算法的特点是请求像水一样流进桶里由桶底匀速流出所以出口速率恒定适合保护下游不被突发流量冲击。令牌桶算法则是桶里按固定速率生成令牌请求需要拿到令牌才能被执行桶最多累积一定数量的令牌所以它允许一定程度的突发流量又限制了整体的平均速率。Java生态中最常用的封装好的令牌桶组件是Guava的RateLimiter。它基于令牌桶思路实现使用简单而且经过了大规模生产环境验证。create(double permitsPerSecond)创建限流器tryAcquire()尝试获取一个令牌获取不到就返回false。对大文件上传来说我们还可以用acquire(int permits)来按文件大小取多个令牌实现更细粒度的流量控制。事实上一个200MB文件和一个2MB文件对系统的影响完全不同如果只按请求次数限流大文件照样会把带宽打满。更合理的方案是按“MB/秒”来限流每个上传请求根据文件大小预先估算需要的“流量令牌数”用完了就等待或者拒绝。另外需要注意Guava的RateLimiter是进程内单机限流不是分布式限流。如果应用部署了多个实例每个实例都有自己独立的令牌桶那么全局速率上限实际上是单机限流阈值乘以机器数。这个问题我会在第5章详细讲怎么处理。2.3 Servlet 3.0与Spring Boot的流式上传支持流量控制如果想做得精细不能等整个文件上传完成后再判断速录。金融保险项目中常见的做法是在上传过程中不断读取文件流一边读一边统计速率超过阈值就中断传输或者让客户端降速。要支持这种“流式读取”Servlet 3.0之后的Part接口和Spring MVC的MultipartFile都提供了getInputStream()我们可以不用把整个文件装载进内存而是通过输入流逐块读取。Spring Boot项目中文件上传默认走Tomcat的Servlet实现。spring.servlet.multipart.max-file-size和max-request-size只是做了静态大小限制不关心速率。真正的流量控制需要我们在MultipartFile的输入流外面包一层自定义的InputStream在read()时统计读取的字节数并和令牌桶联动。也就是说每读到一个阈值字节就尝试从限流器获取一个令牌如果获取不到就阻塞当前读取线程通过背压机制自然放慢整个上传的速度。这就是“流式限流”的底层逻辑。3. 实操实现在Java应用里落地上传流量控制3.1 项目基础结构与依赖准备以一个典型的Spring Boot项目为例假设我们正在维护一个保险理赔影像上传服务使用Java 8和Spring Boot 2.x。在pom.xml里加依赖Guava是核心dependency groupIdcom.google.guava/groupId artifactIdguava/artifactId version31.1-jre/version /dependencySpring Boot Web依赖本身已经包含Servlet容器不需要额外引入。我们还需要一个地方存放限流配置这里我建议写入application.yml方便后续通过配置中心动态调整upload: traffic: enabled: true # 全网服务端每秒最多接收多少MB的请求体数据 permits-per-second: 20.0 # 每次申请令牌的字节数相当于令牌粒度 chunk-size-mb: 1 # 超过限流阈值时的等待策略block阻塞等待reject直接拒绝 over-limit-strategy: block # 单请求最大字节数超过则拒绝 max-request-size-mb: 200这里permits-per-second表示整个JVM每秒允许接收20MB的上传流量。chunk-size-mb表示按1MB为粒度申请令牌。也就是说一个10MB的文件在传输过程中会被切分成10个1MB的块每读取1MB就申请一次令牌。这样如果当前系统繁忙文件自然就传得慢了但不会因为流量太猛导致崩溃。3.2 自定义限流输入流核心是写一个包装了RateLimiter的InputStream让文件的读取动作和令牌申请绑定。这个类的实现我直接贴出来public class TrafficLimitedInputStream extends InputStream { private final InputStream delegate; private final RateLimiter rateLimiter; private final int chunkBytes; private int readSinceLastAcquire 0; public TrafficLimitedInputStream(InputStream delegate, RateLimiter rateLimiter, int chunkBytes) { this.delegate delegate; this.rateLimiter rateLimiter; this.chunkBytes chunkBytes; } Override public int read() throws IOException { int b delegate.read(); if (b ! -1) { onRead(1); } return b; } Override public int read(byte[] b, int off, int len) throws IOException { int n delegate.read(b, off, len); if (n 0) { onRead(n); } return n; } private void onRead(int bytes) throws IOException { readSinceLastAcquire bytes; if (readSinceLastAcquire chunkBytes) { rateLimiter.acquire(); readSinceLastAcquire 0; } } Override public void close() throws IOException { delegate.close(); } }这段代码的逻辑并不复杂就是在调用底层输入流read()方法读取数据后把已经读取的字节数累加到一个计数器中。一旦累计读取量达到chunkBytes就调用rateLimiter.acquire()申请一个令牌。RateLimiter.acquire()在没有令牌时会阻塞当前线程从而放慢文件的读取速度达到流量控制的效果。注意一个细节read()单字节方法其实很少在真实场景被框架调用Spring的MultipartFile.transferTo()和文件复制逻辑通常使用byte[]批量读取所以重点实现read(byte[], int, int)这个批量方法就够了。但为了完整性单字节的read()也实现一下避免一些特殊流处理逻辑直接调用时绕过了限流。3.3 过滤器注入限流组件有了限流输入流下一步要让它真正作用于上传请求。Spring的CommonsMultipartFile内部持有Part或FileItem我们需要在上传文件对象创建后把原本的文件输入流替换成我们包装过的限流流。最简单的方式是写一个javax.servlet.Filter在请求进入Controller之前做处理。由于Servlet过滤器拿到的HttpServletRequest还没有经过Spring MVC的参数解析此时不能直接拿到MultipartFile对象但可以拿到Part对象。Servlet 3.0规范中通过request.getPart(file)就可以拿到上传文件的Part然后调用part.getInputStream()替换成限流流是比较麻烦的因为Part接口的getInputStream()并不支持set。一个更符合Spring Boot习惯的做法是使用MultipartFile的包装机制。Spring提供了MultipartFile接口我们可以在Controller入参前用RequestParam(file) MultipartFile file接收然后用一个AOP切面或者HandlerMethodArgumentResolver对MultipartFile进行包装。不过这种方式对业务代码侵入比较大。还有一个更实用的方案直接配置一个CommonsMultipartResolver的子类在parseRequest方法中拿到FileItem之后通过反射或者标准的FileItem实现类替换其中的InputStream。这样业务层完全无感知Controller照常接收MultipartFile参数但我们实际上已经在底层把输入流替换成了限流流。这里我给出一个更简洁、更容易维护的实现。在Spring Boot中使用OncePerRequestFilter在请求的最开始用HttpServletRequestWrapper包装请求重写getPart()和getParts()方法让返回的Part包装一层可代理的Part这个代理Part的getInputStream()返回限流流。完整代码比较长我列核心部分Component public class UploadTrafficFilter extends OncePerRequestFilter { private RateLimiter rateLimiter; Value(${upload.traffic.permits-per-second:20}) private double permitsPerSecond; Value(${upload.traffic.chunk-size-mb:1}) private int chunkSizeMb; Override protected void initFilterBean() { rateLimiter RateLimiter.create(permitsPerSecond); } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { if (isMultipart(request)) { UploadTrafficRequestWrapper wrapper new UploadTrafficRequestWrapper(request, rateLimiter, chunkSizeMb * 1024 * 1024); chain.doFilter(wrapper, response); } else { chain.doFilter(request, response); } } private boolean isMultipart(HttpServletRequest request) { String contentType request.getContentType(); return contentType ! null contentType.toLowerCase().contains(multipart/form-data); } }UploadTrafficRequestWrapper里面做的事情是对Part进行透明包装在调用getInputStream()时返回限流流。核心思路就是这样代码量大一点点但结构非常清晰。3.4 阻塞等待还是直接拒绝限流策略要在代码里体现出来。RateLimiter.acquire()是阻塞式的如果当前没有令牌线程会卡住等待。这种方式的优点是用户体验好上传请求一般不会失败只是变慢缺点是在极端情况下如果网络一直很慢大量线程会堆积在等待令牌这个环节导致Tomcat线程池耗尽。另一种方式是使用tryAcquire(timeout, TimeUnit.SECONDS)设置一个合理的等待上限比如5秒。5秒内拿到令牌就继续上传拿不到就直接返回一个JSON错误给客户端提示“当前系统上传并发较高请稍后重试”。我强烈推荐后者。金融保险系统的业务特性决定了我们不能让线程无限期等待毕竟这个线程还要服务其他业务。实际编码时在TrafficLimitedInputStream的onRead()方法里加入超时逻辑private void onRead(int bytes) throws IOException { readSinceLastAcquire bytes; if (readSinceLastAcquire chunkBytes) { boolean acquired rateLimiter.tryAcquire(5, TimeUnit.SECONDS); if (!acquired) { throw new IOException(upload traffic limited, please retry later); } readSinceLastAcquire 0; } }3.5 压测验证效果代码写完不能直接上线先做压测。我通常用JMeter模拟并发上传测试场景是10个线程同时上传10MB的文件持续3分钟。在不加限流组件的基准版中Tomcat线程数会飙到满响应时间从200ms一直涨到3000ms以上CPU使用率接近100%。加上限流组件之后同样10个并发上传响应时间稳定在500-800ms左右CPU使用率控制在70%以下其他普通接口的响应时间变化不大。具体压测数据我记录过一组参考值场景并发数文件大小平均耗时服务端CPU上传失败率无限流1010MB2100ms98%12%限流20MB/s1010MB760ms72%0%限流5MB/s1010MB1950ms45%3%压测结果说明流量控制确实能“削峰填谷”让系统在高峰期保持稳定的处理能力。4. 金融保险场景下的进阶与优化4.1 限流与熔断、降级的配合在金融保险应用里上传流量控制不应该是孤立的它还需要和熔断、降级机制配合。通常的链路是这样的首先外部流量到达网关网关识别路径规则对明显超出系统承载能力的请求直接拒绝。然后进入应用层的流量控制过滤器这一步使用令牌桶按字节细粒度限流。第三步如果当前存储系统比如对象存储服务已经出现异常上传请求不停地重试仍然失败这时候应该触发熔断机制直接返回“存储服务暂不可用”不要再让业务用户继续尝试上传造成系统压力放大。我在某保险理赔系统里就遇到过这种情况一开始只接了限流没有接熔断。有一次对象存储服务响应缓慢上传请求大量堆积在等待令牌的队列里限流器反而成了新的瓶颈。后来加了熔断逻辑当最近1分钟内上传失败率超过30%就启动熔断后续上传请求直接快速失败不在限流器上排队。效果立竿见影系统迅速恢复稳定。4.2 客户端预检与断点续传大文件上传光靠服务端硬限流用户体验其实不好。一个200MB的视频断断续续传了好几分钟最后告诉你超时或者失败用户会直接打不满意的客服电话。更好的做法是在服务端增加一个“预检接口”客户端在上传前先调用这个接口把文件名、大小、类型发过来服务端根据当前限流器的剩余令牌情况返回一个预计可用的上传速率客户端根据这个速率动态调整上传行为。如果预检接口返回“当前上传负荷较高建议3分钟后再试”客户端就展示一个排队提示甚至定时自动重试。这种机制在保险理赔移动端APP里非常有用因为提交理赔材料的用户往往在事故现场网络不稳定上传体验直接决定整个理赔流程的效率。断点续传也是金融保险大文件上传中不可缺少的能力。将大文件切片成例如2MB一个的分片客户端并行或串行地上传分片服务端按分片接收再合并。这样的话即使某一个分片没有上传成功也只需要重传那一个小分片流量控制针对每个分片生效压力也缩小很多。从服务端角度建议每个分片在限流器上的独立令牌申请这样整体流量能被精细控制。4.3 监控告警与日志流量控制组件上线后一定要配套监控和日志否则出了问题你根本不知道是限流组件导致的还是业务导致的。我习惯在每个上传请求的限流环节记录以下指标当前每秒令牌消耗量限流等待次数和等待耗时被拒绝请求的数量和拒绝原因每个上传请求的原始文件大小和传输总时长这些指标通过Micrometer暴露到Prometheus配置Grafana看板实时展示。日志方面在onRead()中如果发生限流等待要输出一条日志包含线程编号、块大小、累计等待时间方便事后分析瓶颈。我还发现一个有意思的经验监控限流器的RateLimiter内部队列长度非常有用。Guava的RateLimiter虽然不直接暴露队列长度但可以通过getRate()和等待时长的分布来推算积压情况。一旦发现等待时间中位数持续超过1秒就要考虑扩容或者调整限流阈值了。5. 常见问题与排查技巧实录5.1 上传大文件时出现OutOfMemoryError在加了流量控制之后仍然出现内存溢出大多不是限流器本身的问题而是文件存储方式没有处理好。Spring Boot默认的单体上传如果使用MultipartFile.getBytes()或transferTo()时不小心把整个文件读入内存就是致命的。更常见的错误是使用CommonsMultipartResolver时maxInMemorySize参数设置得过大导致文件被暂存在内存中而不是临时磁盘文件。排查方法是查看堆栈和GC日志。如果大量byte[]对象占用内存且堆栈指向DiskFileItem或StandardMultipartFile就是上传文件被放进了内存。把spring.servlet.multipart.file-size-threshold设置到一个较小的值比如200KB超过这个大小的一部分文件直接写入临时目录不要驻留在内存里。配合流式限流内存占用可以做到非常平稳。5.2 加了限流后上传失败率不降反升这是一个容易掉进去的陷阱。限流器阈值设置得太低导致普通的小文件上传也频繁触发等待超时。我之前在理赔系统压测时就试过把permits-per-second调到5MB结果一个2MB的保单扫描件都要等几秒钟业务人员反馈上传经常卡住。解决思路是把限流粒度分成两层第一层按请求数量限流比如每秒最多放行10个上传请求第二层才按字节数限流比如每秒最多20MB。配合tryAcquire超时时间尽量让文件在等待可控的情况下排队进行同时小文件不会被大文件拖累。换句话说给大文件设一条独立的带宽通道而不是让所有上传共享一个版本的令牌桶。5.3 多实例部署时流量控制失效单机版的RateLimiter在多实例环境下无法保证全局限流。如果一个应用部署了4个实例每个实例的限流阈值都是20MB/s那实际上是80MB/s的总量这往往超过了后端存储系统的承受能力。金融行业的核心系统通常还要求严格可预期不能允许这种“乘数效应”存在。建议方案有两种一种是使用分布式限流组件比如基于RedisLua实现的令牌桶另一种是把上传流量通过负载均衡集中到有限的专门实例上其他实例不对外提供上传服务。考虑到改动成本如果现有系统无法快速切换到分布式限流优先选第二种也就是把上传请求通过路由规则固定转发到1-2个专用节点上在这些节点上使用进程内限流即可。5.4 限流组件与Spring Cloud Gateway的关系不少保险系统已经引入了Spring Cloud Gateway我把这里的责任边界再说清楚。Gateway层适合做基于路径和IP的静态限流比如限制/api/upload/**每秒最多进入100个请求而应用内的流量控制负责动态的、面向文件字节的流量控制。二者不是二选一而是天然配合。在Gateway中通过RequestRateLimiter过滤器基于Redis实现请求计数限流可以屏蔽掉大部分连接数过高的异常请求保护应用层不过载。应用层再把每秒接收的字节数控制住保证即便网关漏进来不少请求也不会把后端IO打满。总之就是网关挡“量”应用控“速”。6. 一点实践心得其实这一切的核心并不复杂用组件把上传的文件流串起来在数据读取的每一个关键节点上加入令牌桶控制让系统的资源消耗以可控、平滑的方式推进。很多团队一上来就改Tomcat配置和JVM参数效果不佳后又退回“限制文件大小”的老路实际上只要把限流组件的粒度细到“按块读取即限流”效果会好很多。另外我个人强烈建议在流量控制组件之外同步推进文件异步上传的改造。把上传请求接收下来后立即返回“已受理”后台线程池异步处理文件流转存。这样可以进一步降低上传请求对Web线程的占用时间配合流量控制系统能扛住的并发量还会有明显提升。金融保险系统的稳定性和用户体验往往就是在这些细节里拉开的差距。
阅读完成 · 觉得有帮助?
咨询建站