大文件下载中断问题的全链路排查与稳定性优化

现象 https://oss.example.com/storages/<storage_id>//firmware.zip 1)在浏览器下载固件文件经常出现中断,原本3g的文件,每次都在1g左右下载完成,文件不完整。 2)内网下载速度较慢,插网线为 20M/s 左右,WIFI 状态下为 8M/s 左右。 3)使用网线有部分用户产生问题 1 的中断现象,一部分用户则不会,使用 WIFI 则必现中断。 分析 1)分析业务:在内部平台构建完成之后,后台会自动对整个固件文件夹进行转存,上传到对象存储服务,后续可以随时下载,那么这里就涉及上传和下载,我们可以对多次下载的同一个固件进行 hash 对比,发现他们并不是一致的,首先可以排除是上传文件的问题。 2)我们使用 wget 进行下载,表现为连接关闭重试,自动重新连接之后能成功下载完整文件。所以确实不是因为上传的问题,那么我们从下载方面找问题。 每下载 1G 中断一次 3)我们抓包查看具体细节,可以看到是服务器发送了一个 FIN 包主动断开连接,而且没有出现RST包,可以排除网络错误或网络波动,也不是客户端关闭的连接,而是服务端主动关闭的。 4)接下来需要定位具体是服务端哪个节点主动关闭连接,在哪个节点会限速。分析内部链路,并在每个节点进行测试,测试结果如下。 直连对象存储服务 Pod IP,不会出现下载中断和限速的问题,问题都在网关上。 我们可以暂时定位中断问题在APISIX发生,而限速是在F5发生。如图,使用域名下载文件速度较慢,为18M/s左右,使用后端服务的 Pod IP 下载速度能达到 66M/s。 5)验证猜想,在本地快速搭建Nginx服务进行验证,并查看 Nginx 日志。 可以看到 Nginx 主动 closed connection,这跟我们抓包的现象一致。还看到出现“upstream response is buffered to a temporary file”字样,说明 Nginx 有缓存文件的相关机制。 6)查看官网的相关配置文档,可以看到 proxy_max_temp_file_size 这个参数,配置文件默认不会显示该配置,但是默认是 1G,我们的现象也是 1G 中断,基本可以确认就是这个配置导致的,我们可以尝试修改为1200M,并验证,发现在下载到 1200M 时会中断。 ...

May 8, 2025

OOMKilled 深度排查:内存、PageCache 与 I/O 的联合分析

1、现象 收到Memory hit original limit内存告警与CPU容量水位告警,随后发生OOM,容器无限重启 2、应急解决方案 最重要的事情是先保证生产可用,并增加JVM参数用来观测,做完以下调整后对容器状态进行观察。 1)我们需要快速调高内存,内存参数调整为-Xmx3g -Xms3g。 2)加上JVM参数 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/app/logs/dump ,并挂载/app/logs/dump目录,确保堆内存OOM时可以将堆内存dump下来进行分析。 3)加上-XX:NativeMemoryTracking=summary,用来分析堆外内存。 3、收集相关数据信息 1)容器成功恢复健康状态,但是过了一段时间还是触发了OOMKilled,查看/app/logs/dump目录,并没有dump文件,说明有可能不是堆内存溢出,我们查看JVM状态,包括堆内存信息。 2)JVM状态分析 在容器重启之前,CPU占用在15%-30%之间,属于正常水平,堆内存也一直处于低水位状态,非堆内存也没有明显起伏,但是可以看到系统内存占用偏高,我们需要继续追踪堆外内存的情况。 3) 可以看到总的预留内存4.8GB,使用的物理内存3.56GB,而且通过在进程稳定时和内存到达上限时的快照信息分析,每项的值没有明显的变化。 4)查看容器详情 容器重启之前RSS全程没变,但是可以看到PageCache在重启之前飙升上去了。PageCache是文件缓存占用的内存,按道理来说,在内存达到瓶颈时,系统会自动回收才对。 4、问题分析,追究根因 想知道PageCache为什么在内存达到容器限制之前没被回收,我们需要知道k8s的oomkilled机制,在什么情况下会触发。 1)k8s 检测到容器内存达到memory.limit_in_bytes时且没有回收,便会触发 OOMKilled,我们找一个容器进行测试,将memory.limit_in_bytes调为524M(内部监控平台容器配额)。 查看memory.limit_in_bytes:cat /sys/fs/cgroup/memory/memory.limit_in_bytes watch -n 1 “cat /sys/fs/cgroup/memory/memory.usage_in_bytes” 观察内存增长 找一个大文件下载,wget “xxx”,并继续观察memory.usage_in_bytes,可以看到该值在文件下载的过程中一直上升,上升到接近memory.limit_in_bytes时触发了137错误码,即oomkilled。从现象可以得知,k8s的内存限制计算,会包含RSS和PageCache,RSS包含 JVM 进程的所有内存区域(如堆、非堆、JVM 自身代码、Native Memory等),memory.limit_in_bytes = RSS + PageCache。 2)为什么没有触发PageCache的回收,PageCache回收的速度也不可能赶不上带宽下行网速,原因是PageCache的回收是基于全局物理内存压力,不是单个容器的内存限制。而我们是在docker容器内,可以用命令查看物理内存。 容器是 Cgroups 隔离的独立环境,内核无法感知容器内的内存超限风险。即使容器内存即将超限,内核也不会优先回收其 PageCache,导致容器因 PageCache 触发 OOMKilled。 5、项目分析 随着效能平台的迭代,目前已经有了很多大文件下载或转存的场景,目前主要有: 1)效能客户端 APK 数据流上传(蓝线) 使用 MultipartFile 上传文件,spring.servlet.multipart.file-size-threshold 设置为 10M 时,超过 10M 则会存到临时文件,所以当 APK 文件过大时,也有 PageCache 上升的风险。(注释翻译:将基础输出流从基于内存的流切换到由磁盘支持的流。这是我们意识到有过多数据正在写入,无法再保存在内存中,因此选择切换到基于磁盘的存储。) ...

April 10, 2025

SSE 流式响应在 Nginx 下失效的排查与修复实践

背景与现象 背景:在C知汇项目(GPT私有库问答系统)中,我们期望GPT的回答通过流式返回,采用了SSE(Server-Sent Events)的服务端推送技术做流式传输。 现象:本地运行没有问题,但是上线之后发现有时是流式,有时是一次性返回。 产品界面 分析过程 1、首先确认技术选型是否存在问题。流式响应有常见的两种方案,WebFlux和SSE,我们使用WebFlux响应式编程技术替代SSE,写最小demo排除业务的影响,发现仍然有问题。可以暂时排除代码层面的问题。 2、分析数据传输链路,用户发送提问并成功响应时,回答通过流式返回链路:GPT -> C知汇后台 -> 后台Nginx -> C知汇前端 -> 前端Nginx -> 浏览器 我们可以选择从源头开始验证。 1)后台打印GPT响应,收到GPT的响应是流式的,没有问题。 2)通过直连后台服务容器IP排除Nginx的影响,发现问题消失了。 3、验证猜想:查阅nginx配置资料,发现nginx有缓冲区的相关配置 proxy_buffers。查看鲸云Nginx配置如下图所示。通过自建nginx验证了猜想。那么为什么线上环境有时能复现,有时又是正常的呢?原来是响应内容的大小决定的,如果响应内容没有达到缓冲区容量,那么会一次返回,如果超过了缓冲区容量,那么缓冲区容量以外的数据会流式返回。 解决方案 方案一:取消网关的响应缓冲。可能需要修改网关配置,然而修改网关配置是一个不小的动作,而且可能对其他应用造成影响。 方案二:绕过nginx,直连访问。但是鲸云我们通常采用滚动更新镜像的方式,所以容器IP是经常会变更的。后台可以开放获取IP接口,客户端可以先通过访问后台接口获取IP,然后再直连,这样也能使用到网关的负载均衡。主要的缺点有:1、不通过网关过滤直连有一定的安全风险。2、访问记录不会被网关日志收集,会对数据的统计和问题的排查造成一定的影响。 方案二的方式有点hack,可以直接排除,我们尝试从方案一尝试入手解决。 尝试解决 关闭响应缓冲的方式主要有两种 1)将Nginx的proxy_buffers配置设置为off。 2)将请求头X-Accel-Buffering设置为 no,Cache-Control设置为no-cache。 1、我们在应用代码上去手动设置请求头,发现仍然不行。我们查看请求返回的响应头里没有我们设置的X-Accel-Buffering,而使用Arthas执行watch,看到X-Accel-Buffering是设置进去了的。 代码里设置响应头 返回的响应头 Arthas Watch 2、怀疑是配置优先级的问题,如果我们将Nginx的proxy_buffers设置为on,请求头X-Accel-Buffering设置为no,会以哪一个为准?我们通过自建Nginx验证发现,Nginx 会遵循请求头中的指示,所以是请求头X-Accel-Buffering优先。 APISIX响应重写 3、我们验证了正常情况下使用X-Accel-Buffering是可以生效的且优先级比较高的。回到第1点,如果我们在APISIX设置X-Accel-Buffering响应头,那么会不会透传回客户端呢?测试表明该响应头仍然丢失了。 4、我们去掉前端部分再对整条链路进行分析,GPT -> 应用后台 -> APISIX -> 客户端,我们在后台和APISIX都设置了响应头,但是响应里却没有出现X-Accel-Buffering头。那么我们可以猜测,APISIX和客户端之间还有一些链路,导致X-Accel-Buffering没有被透传。一般实际生产中,Nginx也不止一层,那么需要运维协助排查解决。 链路 协调资源解决问题 通过运维协助,展开了业务端不可见的完整链路: GPT -> C知汇后台 -> APISIX -> 雷池 WAF -> 负载均衡 F5 -> 浏览器 ...

October 20, 2024

从告警泛滥到高效定位:MCP 异常分析实践

背景 为提高后台整体质量,目前大部分后台系统都接入了 Error 日志监控告警,接入初期产生了非常多的告警,消耗了我们大量的时间去排查。当前线上异常日志排查主要存在以下痛点: 服务链路复杂,定位困难: 后台系统通常由众多服务构成(包括上下游依赖),排查一个问题往往需要跨多个监控平台应用、多个集群日志、多个服务进行搜索,并定位代码才能准确定位根源。 告警噪音干扰严重: 存在大量 Error 级别的告警日志并非真正的系统异常(例如预期的业务校验失败、可忽略的第三方短暂异常),而是需要调整日志级别或优化处理逻辑。识别此类“非问题”告警并推动修改,同样耗费大量时间。 跨团队协作成本高: 当排查指向下游服务或需要其他团队协助时,需耗费大量时间手动整理详细的异常上下文信息(如时间戳、TraceID、关键参数、异常堆栈)并转交给相关方。 缺乏智能化辅助: 大部分异常的分析本质上是梳理代码执行链路和业务逻辑。此过程高度依赖人工经验,若能借助 AI 能力智能推导出完整的调用链路、关键变量状态及潜在逻辑缺陷,将极大提升排查效率,甚至在部分场景下自动修复代码解决。 目标 建立自动化异常日志分析系统: 构建智能化的日志处理与分析平台,减少人工介入。 实现日志到代码的快速精准定位: 将异常日志信息快速关联到对应的代码仓库、文件乃至代码行。 显著提升异常排查效率: 缩短单个异常的平均排查时间(MTTR),释放开发运维人力。 构建可复用的异常分析知识库: 积累处理经验,形成可检索的知识沉淀,辅助未来同类问题的解决。 方案 1、人工 OR 自动 AI 辅助我们排查问题,主要有两种方式: 方案 优点 缺点 方案一:开发MCP Server,人工将企业 IM 群内的报错信息发给 AI Client,AI 结合接收到的日志内容、关联的代码仓库信息,进行代码定位与异常链路分析,并输出报告。 1. 可控性强: 人工筛选关键告警触发分析,避免无效请求。2. 利用成熟能力: 可集成现有 MCP 平台及 Cursor AI 强大的代码分析能力。3. 易于初期试点: 实施复杂度相对较低。 1. 依赖人工介入: 仍需人工识别并转发告警信息,无法实现全流程自动化。2. 响应有时延: 依赖于人工操作,响应速度不如自动触发快。3. 上下文可能不全: 人工转发可能遗漏关联日志或上下文信息,影响分析准确性。 方案二:开发 AI 服务,将Error日志直接上报到AI 服务,利用 AI 自动化分析并输出报告 1. 自动化程度高: 无需人工干预,实现告警到分析的自动闭环。 1. 初期处理压力大: 线上错误日志量大且繁杂,直接全量上报会造成 AI 服务巨大吞吐压力与分析资源浪费。2. 需完善过滤机制: 必须设计高效的过滤规则,避免无效分析。 总结:方案一适合初期试点、或处理复杂低频、需要人工确认的高优先级问题。其优势在于风险可控,能充分利用现有工具链。所以我们可以先采取方案一,人工根据严重程度和优先级识别筛选问题,并发给 AI 分析,后续再使用自动化的方式帮助我们过滤或标注出需要重点关注的Error 异常问题。根据两种方式使用不同的大模型来控制成本。 ...

August 18, 2024

长轮询在配置平台的工程化实践与性能权衡

长轮询在配置平台的应用 1. 配置平台简介 略 2. 长轮询简介 传统的短轮询方式存在一个严重缺陷:程序在每次请求时都会新建一个HTTP请求,然而并不是每次都能返回所需的新数据。当同时发起的请求达到一定数目时,会对服务器造成较大负担。这时我们可以采用长轮询方式解决这个问题。 长轮询的基本思想是在每次客户端发出请求后,服务器检查上次返回的数据与此次请求时的数据之间是否有更新,如果有更新则返回新数据并结束此次连接,否则服务器“hold”住此次连接,直到有新数据时再返回相应。而这种长时间的保持连接可以通过设置一个较大的HTTP timeout实现。在服务端消息推送方面,长轮询有着广泛的应用。 3. 请求模型 3.1 同步请求模型 这是我们日常最常用同步请求模型,所有动作都交给同一个 Tomcat 线程处理,所有动作处理完成,线程才会被释放回线程池。 如果业务需要较长时间处理,那么这个 Tomcat 线程其实一直在被占用,随着请求越来越多,可用 I/O 线程越来越少,直到被耗尽。这时后续请求只能等待空闲 Tomcat 线程,这将会加长了请求执行时间,或者直接被拒绝,客户端会报connect refused异常。 如果客户端不关心返回业务结果,这时我们可以自定义线程池,将请求任务提交给线程池,然后立刻返回。 3.2 异步请求模型 Servlet3 引入异步 Servelt 新特性,可以完美解决上面的需求。 异步 Servelt 执行请求流程: 将请求信息解析为 HttpServletRequest 分发到具体 Servlet 处理,将业务提交给自定义业务线程池,请求立刻返回,Tomcat 线程立刻被释放 当业务线程将任务执行结束,将会将结果转交给 Tomcat 线程 通过 HttpServletResponse 将响应结果返回给等待客户端 引入异步 Servelt3 整体流程如下: 3.3 应用场景 1、增加系统吞吐量 拿Tomcat作为Servlet容器来说,无论是计算型请求还是IO型请求,都是交给Tomcat容器线程来建立连接和负责业务逻辑处理,如果将IO型请求或者RT(响应时间)比较高的请求业务逻辑处理,通过异步请求来实现,可以尽早地释放连接线程,业务逻辑交由业务线程池处理,那么连接线程池可以接收更多的请求,从而提高了系统吞吐量。 2、服务端消息推送 消息推送,对于一些服务端发生变更,需要向客户端发送消息通知的场景,可以通过异步请求来实现。 3.4 异步请求实现 1、 AsyncContext HttpServletRequest#startAsync 获取 AsyncContext 异步上下文对象 使用自定义的业务线程池处理业务逻辑 业务线程处理结束,通过 AsyncContext#complete 返回响应结果 ExecutorService executorService = Executors.newFixedThreadPool(10); @RequestMapping("/hello") public void hello(HttpServletRequest request) { AsyncContext asyncContext = request.startAsync(); // 超时时间 asyncContext.setTimeout(10000); executorService.submit(() -> { try { // 休眠 5s,模拟业务操作 TimeUnit.SECONDS.sleep(5); // 输出响应结果 asyncContext.getResponse().getWriter().println("hello world"); log.info("异步线程处理结束"); } catch (Exception e) { e.printStackTrace(); } finally { asyncContext.complete(); } }); log.info("servlet 线程处理结束"); } 2、 DeferredResult Servlet3.0 提供了异步处理请求的特性,DeferredResult 是Spring基于 Servlet 3.0 对异步请求的支持实现,SpringMVC 3.2 之后引入新的类 DeferredResult,目的是对于请求提供异步处理方式,释放容器连接,支持更多的并发。或者基于它的超时机制来做一些长轮询相关的事情。 ...

October 21, 2023

分布式锁高并发场景下的典型问题与优化实践

背景 开启课堂或者刷新课堂界面时会调用接口发送邀请链接公告,后台要限制一个课堂只能发送一次。 现象 同一个课堂出现了多条公告记录 解析 直接看代码,使用了分布式锁进行加锁校验,锁等待时间为0,也就是获取不到锁立刻返回。锁超时时间为课堂的超时时间,为12小时。 1、猜想一:发送公告有重试逻辑,有可能是重试逻辑有问题导致的。 在Google的接口调用中,使用动态代理进行接口的统一处理,如果调用Google接口返回了401状态码,就需要刷新AccessToken然后重新调用,如果刷新AccessToken失败就需要用户重新授权。 初步看代码逻辑没有问题,我们可以查看应用日志,如果是重试的问题导致的,那么这两次发送公告都应该是同一个请求,也就会是同一个TraceId,可以看到这两次发送成功是属于两个不同的请求,TraceId也是不一样的,那么可以排除这个猜想。 2、猜想二:锁没有生效,有并发问题。 我们使用 Jmeter 对这个接口进行压力测试,开启100个线程,结果是只有一个线程返回成功,其他线程都返回10304业务状态码,表示已经发送过公告了,而且始终没办法复现,返回成功状态码的请求始终只有一个。 3、猜想三:第一次获取锁成功了,后面删除了锁,所以第二次获取锁又成功了 查看Redis数据库也能看到锁对应的数据,TTL剩余9379秒,通过时间计算,可以得出这个Key的设置时间为9:49,跟第一次请求的时间是一致的,所以第二次获取锁的时候,这个锁是存在的。 4、猜想四:从以上的现象可以看出,这不是并发问题导致的,而且是属于不同的请求,两个请求基本没有什么关联,间隔也有8分钟左右。第二次获取锁的时候,锁的key也确实存在,确实是重复获取到了锁,那么基本确定只有一种可能,这是可重入锁,如果两个请求是同一个线程,那么就能重复获取到锁。查看应用日志,可以发现这两个请求确实属于同一个线程。 验证猜想:将Tomcat的工作线程数设置为1,这样每次请求都会是同一个请求,结果是每次请求都发送成功。 那么压力测试为什么复现不了这个问题呢,原因是压力测试时,100个请求基本同一时间发出,而Tomcat默认的最大线程池数量为200个线程,所以这100个请求都属于不同的线程。 解决方案 方案一:自实现不可重入锁,使用 setnx 指令简单实现。 方案二:加一层校验,在加锁之前判断这个 Key 是否存在。 这两种方案都可以,选择一种即可。 附 分布式锁加锁流程图

January 15, 2023