[{"content":"现象 https://oss.example.com/storages/\u0026lt;storage_id\u0026gt;//firmware.zip\n1）在浏览器下载固件文件经常出现中断，原本3g的文件，每次都在1g左右下载完成，文件不完整。\n2）内网下载速度较慢，插网线为 20M/s 左右，WIFI 状态下为 8M/s 左右。\n3）使用网线有部分用户产生问题 1 的中断现象，一部分用户则不会，使用 WIFI 则必现中断。\n分析 1）分析业务：在内部平台构建完成之后，后台会自动对整个固件文件夹进行转存，上传到对象存储服务，后续可以随时下载，那么这里就涉及上传和下载，我们可以对多次下载的同一个固件进行 hash 对比，发现他们并不是一致的，首先可以排除是上传文件的问题。\n2）我们使用 wget 进行下载，表现为连接关闭重试，自动重新连接之后能成功下载完整文件。所以确实不是因为上传的问题，那么我们从下载方面找问题。\n每下载 1G 中断一次\n3）我们抓包查看具体细节，可以看到是服务器发送了一个 FIN 包主动断开连接，而且没有出现RST包，可以排除网络错误或网络波动，也不是客户端关闭的连接，而是服务端主动关闭的。\n4）接下来需要定位具体是服务端哪个节点主动关闭连接，在哪个节点会限速。分析内部链路，并在每个节点进行测试，测试结果如下。\n直连对象存储服务 Pod IP，不会出现下载中断和限速的问题，问题都在网关上。\n我们可以暂时定位中断问题在APISIX发生，而限速是在F5发生。如图，使用域名下载文件速度较慢，为18M/s左右，使用后端服务的 Pod IP 下载速度能达到 66M/s。\n5）验证猜想，在本地快速搭建Nginx服务进行验证，并查看 Nginx 日志。\n可以看到 Nginx 主动 closed connection，这跟我们抓包的现象一致。还看到出现“upstream response is buffered to a temporary file”字样，说明 Nginx 有缓存文件的相关机制。\n6）查看官网的相关配置文档，可以看到 proxy_max_temp_file_size 这个参数，配置文件默认不会显示该配置，但是默认是 1G，我们的现象也是 1G 中断，基本可以确认就是这个配置导致的，我们可以尝试修改为1200M，并验证，发现在下载到 1200M 时会中断。\n7）那么如何解释这个现象：使用网线有部分用户产生下载中断现象，一部分用户不会。使用 WIFI 则必现中断。\n用户 A 测试结果是有线必现中断，用户 B 测试结果是不会中断。观察到两个用户的有线网速也有区别（用户 A 的拓展坞限速 100M ），用户 A 的下载速度大概是 12M/s，用户 B 大概 30M/s。Wifi 的下载速度都是 10M/s 左右。那么我们可以猜测网速跟下载中断有关。\n我们可以测试一下，正常不限速时：\n使用 wget \u0026ndash;limit-rate 限速时：\n8）F5 的限速问题通过协调相关负责人排查，最终确定是某机房的 F5 存在性能问题。\n3、根因分析 3.1 Nginx 源码分析 Nginx 临时文件为什么跟客户端下载速度有关？官方文档和社区也没有相关的描述，我们可以看一下源码。\n1）配置解析：我们直接搜 proxy_max_temp_file_size 这个配置，可以看到该值会从配置中读取后会设置到upstream.max_temp_file_size结构成员。\n2）配置合并：继续查看upstream.max_temp_file_size_conf 这个成员变量，可以看到相关的初始化设置，这也印证了我们之前的猜测，如果没有这个配置，Nginx 默认会设置一个 1G 的值。\n3）请求处理：在具体的请求中，会初始化事件管道并传递配置值\n4）接下来是最关键的代码，根据配置值决定是否继续写入临时文件\nA、proxy_cache 配置影响 p-\u0026gt;cacheable 的值，默认是 off，这里只可能是 off 才可能走两个不同的判断分支。\nB、下载速度影响条件分支的选择，我们可以看到代码里面的官方注释，Nginx优先使用内存缓冲区，只有在以下情况才使用临时文件：\n内存缓冲区已满\n客户端暂时无法接收更多数据\n也就是说当下载速度较慢时，临时文件的增长会更快达到 1G.\n3.2 抓包分析 尝试在服务器端进行抓包，客户端下载截图如下，对抓到的包分阶段分析。\n阶段一：正常传输期 0-35秒（31:06 ~ 31:41） 一开始下载时都是客户端与上游服务器之间的[PSH,ACK]和[ACK]包， 偶尔出现[TCP Window Full]和[TCP ZeroWindow] 解释：\n[PSH,ACK]和[ACK]：表示正常的数据传输 偶尔出现[TCP Window Full]和[TCP ZeroWindow]：表明客户端处理速度偶尔跟不上接收速度 这与我们之前讨论的情况一致：当客户端下载速度较慢时，数据会暂时积累 此时Nginx可能开始使用临时文件存储数据 但由于这只是偶尔发生，临时文件可能还未达到限制 对应Nginx行为：\n此阶段，内存缓冲区和临时文件交替使用 临时文件大小逐渐增长，但尚未达到proxy_max_temp_file_size限制 阶段二：临时文件限制触发期 35-44秒（31:41~31:50） 到下载的第 35 s，开始出现大量上游服务器发送的 HTTP Continuation 包， 以及大量的[TCP Fast Retransmission]和[TCP Dup ACK] 解释：\n大量HTTP Continuation包：表明上游服务器在持续发送大量数据 [TCP Fast Retransmission]：表示数据包丢失或网络拥塞 [TCP Dup ACK]：客户端重复发送相同的ACK，表明期望接收到特定序列号的数据 这个时间点（35秒）很可能是临时文件达到proxy_max_temp_file_size限制的时刻！\n对应Nginx行为：\n临时文件达到限制（例如1GB） Nginx停止从上游读取数据（break退出读取循环） 但上游服务器不知道这一点，继续发送数据 TCP接收窗口很快填满（ZeroWindow） 上游服务器被迫进行重传尝试（Fast Retransmission） 阶段三：连接状态变化期（44-93秒） 到第 44s 时，报文 ip 发生了变化，变成了上游服务器跟一个 ip 为\u0026#34;\u0026lt;private-ip\u0026gt;\u0026#34;的少量报文通信，并且这个阶段客户端没有报文没有出现 解释：\n报文IP变化：\u0026quot;\u0026quot; 很可能是网络中的代理、负载均衡器或默认网关 客户端IP报文消失：表明客户端和上游服务器之间的直接通信暂停 少量报文通信：通过观察看到是对根路径/的 HTTP 请求，并且每 3 秒一次，这是内部配置的健康检查机制。 这个阶段上游服务器跟客户端之间没有数据传输，而是 Nginx 跟客户端之间的数据传输。\n对应Nginx行为：\nNginx仍在等待客户端消费一些数据以释放缓冲区 此时上游可能已经开始超时倒计时 阶段四：连接中断期（93秒） 直到第 93s，出现了客户端发送的[TCP Window Update]报文， 接着上游服务器发送 RST 包回复客户端，此时刚好发生下载中断 解释：\n[TCP Window Update]：Nginx 和 客户端终于传输完临时文件数据，开始向上游服务器请求新的数据，增加了接收窗口，但是上游服务器超时机制已触发 上游服务器发送RST包：表明上游服务器已经关闭或重置了连接，不再接受新的通信 对应Nginx行为：\n客户端终于消费了足够的数据 Nginx准备恢复从上游读取 但为时已晚，上游连接已超时 上游服务器回应以RST包，重置连接 Nginx检测到上游连接关闭，中断下载 总结 时间 事件 临时文件状态 连接状态 0s 开始下载 逐渐增长阶段 正常 35s 触发临时文件限制 达到限制（如1GB） Nginx停止读取上游 44s Nginx 停止接收数据，但仍给客户端传输临时文件数据 无变化（已停止增长） 上游等待，开始超时计时 93s 客户端更新窗口，服务器RST 无变化（已停止增长） 上游超时，连接重置，下载中断 正常下载 → 临时文件达到限制 → Nginx停止读取 → 上游缓冲区填满 → 等待客户端消费 → 上游超时 → 连接重置 → 下载中断\n所以，客户端下载速度决定是否产生中断，如果下载速度比较快，在第 4 阶段客户端更新窗口时，服务器可能还没达到超时，这时不会重置连接。下载速度更快的话，可能都不会使 Nginx 临时文件增长到 1G，那么就永远处于第1 阶段。\n4、解决方案 方案 描述 优点 缺点 1) 绕过 Nginx 客户端直接从上游服务器下载，不经过Nginx代理 彻底避免Nginx限制，性能提升，实现简单 牺牲安全性和可维护性，上游服务器直接暴露 2) 修改 Nginx 配置 将 proxy_max_temp_file_size 设置为 0，禁用临时文件缓冲 彻底解决问题，配置简单，无磁盘占用 内存压力可能增加，需配合调整其他参数 3) 修改服务器超时时间 增加上游服务器超时设置，避免连接重置 改动范围小，只影响目标服务 无法根本解决问题，可能造成资源消耗和僵尸连接 方案总结 方案1：仅适用于简单测试或内部环境，不推荐生产使用 方案3：无法根本解决问题，可作为补充措施 方案2：在保留Nginx优势的前提下解决核心问题，配置简单、风险可控 推荐方案：方案2（修改Nginx配置，设置 proxy_max_temp_file_size 0）\n","permalink":"/posts/%E6%96%87%E4%BB%B6%E4%B8%8B%E8%BD%BD%E4%B8%AD%E6%96%AD%E9%97%AE%E9%A2%98%E6%8E%92%E6%9F%A5%E4%B8%8E%E4%BF%AE%E5%A4%8D/","summary":"\u003ch1 id=\"现象\"\u003e现象\u003c/h1\u003e\n\u003cp\u003e\u003ca href=\"https://oss.example.com/storages/\"\u003ehttps://oss.example.com/storages/\u003c/a\u003e\u0026lt;storage_id\u0026gt;/\u003cpath\u003e/firmware.zip\u003c/p\u003e\n\u003cp\u003e1）在浏览器下载固件文件经常出现中断，原本3g的文件，每次都在1g左右下载完成，文件不完整。\u003c/p\u003e\n\u003cp\u003e2）内网下载速度较慢，插网线为 20M/s 左右，WIFI 状态下为 8M/s 左右。\u003c/p\u003e","title":"大文件下载中断问题的全链路排查与稳定性优化"},{"content":"1、现象 收到Memory hit original limit内存告警与CPU容量水位告警，随后发生OOM，容器无限重启\n2、应急解决方案 最重要的事情是先保证生产可用，并增加JVM参数用来观测，做完以下调整后对容器状态进行观察。\n1）我们需要快速调高内存，内存参数调整为-Xmx3g -Xms3g。\n2）加上JVM参数 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/app/logs/dump ，并挂载/app/logs/dump目录，确保堆内存OOM时可以将堆内存dump下来进行分析。\n3）加上-XX:NativeMemoryTracking=summary，用来分析堆外内存。\n3、收集相关数据信息 1）容器成功恢复健康状态，但是过了一段时间还是触发了OOMKilled，查看/app/logs/dump目录，并没有dump文件，说明有可能不是堆内存溢出，我们查看JVM状态，包括堆内存信息。\n2）JVM状态分析\n在容器重启之前，CPU占用在15%-30%之间，属于正常水平，堆内存也一直处于低水位状态，非堆内存也没有明显起伏，但是可以看到系统内存占用偏高，我们需要继续追踪堆外内存的情况。\n3）\n可以看到总的预留内存4.8GB，使用的物理内存3.56GB，而且通过在进程稳定时和内存到达上限时的快照信息分析，每项的值没有明显的变化。\n4）查看容器详情\n容器重启之前RSS全程没变，但是可以看到PageCache在重启之前飙升上去了。PageCache是文件缓存占用的内存，按道理来说，在内存达到瓶颈时，系统会自动回收才对。\n4、问题分析，追究根因 想知道PageCache为什么在内存达到容器限制之前没被回收，我们需要知道OOMKilled机制，在什么情况下会触发。\n1）OOMKilled是Linux内核的机制，当容器内存达到memory.limit_in_bytes（由k8s通过cgroup设置）且无法回收时会触发。我们找一个容器进行测试，将memory.limit_in_bytes调为524M（内部监控平台容器配额）。\n查看memory.limit_in_bytes：cat /sys/fs/cgroup/memory/memory.limit_in_bytes\nwatch -n 1 \u0026ldquo;cat /sys/fs/cgroup/memory/memory.usage_in_bytes\u0026rdquo; 观察内存增长\n找一个大文件下载，wget \u0026ldquo;xxx\u0026rdquo;，并继续观察memory.usage_in_bytes，可以看到该值在文件下载的过程中一直上升，上升到接近memory.limit_in_bytes时触发了137错误码，即oomkilled。从现象可以得知，Linux内核会监控该cgroup内RSS、PageCache、mapped_file等内存的总和，RSS包含 JVM 进程的所有内存区域（如堆、非堆、JVM 自身代码、Native Memory等），当总和接近或超过memory.limit_in_bytes时，Linux内核会触发OOMKilled。\n2）为什么没有触发PageCache的回收，PageCache回收的速度也不可能赶不上带宽下行网速，原因是PageCache的回收是基于全局物理内存压力，不是单个容器的内存限制。而我们是在docker容器内，可以用命令查看物理内存。\n容器是 Cgroups 隔离的独立环境，内核无法感知容器内的内存超限风险。即使容器内存即将超限，内核也不会优先回收其 PageCache，导致容器因 PageCache 触发 OOMKilled。\n5、项目分析 随着效能平台的迭代，目前已经有了很多大文件下载或转存的场景，目前主要有：\n1）效能客户端 APK 数据流上传（蓝线）\n使用 MultipartFile 上传文件，spring.servlet.multipart.file-size-threshold 设置为 10M 时，超过 10M 则会存到临时文件，所以当 APK 文件过大时，也有 PageCache 上升的风险。（注释翻译：将基础输出流从基于内存的流切换到由磁盘支持的流。这是我们意识到有过多数据正在写入，无法再保存在内存中，因此选择切换到基于磁盘的存储。）\n2）合作伙伴平台上传的 apk url下载（黑线）\n2）固件URL转存对象存储服务做长期存储（红线） 3）固件转存对象存储加速服务做OTA全球加速。\n6、解决方案 1、JDK 10+ / 8u191+ 支持让 JVM 感知容器内存的参数：-XX:MaxRAMPercentage=...、-XX:InitialRAMPercentage=...\n2、JDK 10+ 支持Direct I/O，可以在读写磁盘时不使用到 Pagecache 。\n1）固件转存可以不下载，直接通过数据流在内存进行处理，而不涉及磁盘。\n2）手动触发PageCache回收，主动进行刷盘操作。\n临时快速解决方案，直接开启同步刷盘，后续再将固件转存改为纯流处理，边下载边上传。而APK是必须下载的，因为需要在服务端进行解析。大文件不使用 MultipartFile，改为上传对象存储并自己处理下载逻辑。后续可以拆开一个服务，用来做下载与上传大文件，不影响效能平台的整体带宽与 IO，且区分下载传任务的优先级。\n开启后查看PageCache及网络IO、磁盘IO，一切正常，且不会再触发137 OOMKilled。\n","permalink":"/posts/oomkilled-%E6%8E%92%E6%9F%A5%E5%AE%9E%E5%BD%95/","summary":"\u003ch3 id=\"1现象\"\u003e1、现象\u003c/h3\u003e\n\u003cp\u003e收到Memory hit original limit内存告警与CPU容量水位告警，随后发生OOM，容器无限重启\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"image-20240821091810567\" loading=\"lazy\" src=\"/imgs/image-20240821091810567.png\"\u003e\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"image-20240821091835761\" loading=\"lazy\" src=\"/imgs/image-20240821091835761.png\"\u003e\u003c/p\u003e\n\u003ch3 id=\"2应急解决方案\"\u003e2、应急解决方案\u003c/h3\u003e\n\u003cp\u003e最重要的事情是先保证生产可用，并增加JVM参数用来观测，做完以下调整后对容器状态进行观察。\u003c/p\u003e","title":"OOMKilled 深度排查：内存、PageCache 与 I/O 的联合分析"},{"content":"背景 为提高后台整体质量，目前大部分后台系统都接入了 Error 日志监控告警，接入初期产生了非常多的告警，消耗了我们大量的时间去排查。当前线上异常日志排查主要存在以下痛点：\n服务链路复杂，定位困难： 后台系统通常由众多服务构成（包括上下游依赖），排查一个问题往往需要跨多个监控平台应用、多个集群日志、多个服务进行搜索，并定位代码才能准确定位根源。 告警噪音干扰严重： 存在大量 Error 级别的告警日志并非真正的系统异常（例如预期的业务校验失败、可忽略的第三方短暂异常），而是需要调整日志级别或优化处理逻辑。识别此类“非问题”告警并推动修改，同样耗费大量时间。 跨团队协作成本高： 当排查指向下游服务或需要其他团队协助时，需耗费大量时间手动整理详细的异常上下文信息（如时间戳、TraceID、关键参数、异常堆栈）并转交给相关方。 缺乏智能化辅助： 大部分异常的分析本质上是梳理代码执行链路和业务逻辑。此过程高度依赖人工经验，若能借助 AI 能力智能推导出完整的调用链路、关键变量状态及潜在逻辑缺陷，将极大提升排查效率，甚至在部分场景下自动修复代码解决。 目标 建立自动化异常日志分析系统： 构建智能化的日志处理与分析平台，减少人工介入。 实现日志到代码的快速精准定位： 将异常日志信息快速关联到对应的代码仓库、文件乃至代码行。 显著提升异常排查效率： 缩短单个异常的平均排查时间（MTTR），释放开发运维人力。 构建可复用的异常分析知识库： 积累处理经验，形成可检索的知识沉淀，辅助未来同类问题的解决。 方案 1、人工 OR 自动 AI 辅助我们排查问题，主要有两种方式：\n方案 优点 缺点 方案一：开发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 异常问题。根据两种方式使用不同的大模型来控制成本。\n2、拿到日志后，如何找到对应的代码仓库 方式一：从报错堆栈信息里找到项目三级包名映射到代码仓库 映射关系初始化与更新 部门维护的项目其实有限，可能就几十个，我们可以利用 MCP 扫描 gitlab 今年有 commit 记录的所有项目，加入映射索引文件。达到快速建立索引的目的。并且可以让部门其他成员分享出他们的映射表，让 AI 帮我们合并到自己的映射表里。\n映射规则 1）使用三级包名，如 com.bytello.account，一般就能对应一个项目，如果有多个项目使用了这个包名，则让用户干预进行选择。\n2）如果找不到，那问题排查就到此为止了，很有可能排查到的下游服务是其他部门或其他 bg 的服务了，我们把 AI 总结的内容抛出去就行了。\n方式二：使用日志里的 container.image.name 镜像名称解析出项目名 应用日志里有一个镜像名称字段，我们可以通过该字段获取到镜像仓库名称。镜像仓库名称和 gitlab 仓库名称可能有区别，比如，镜像名称是 registry.example.com//:，那么取到镜像仓库名称为 ，一般 gitlab 仓库也会是一致的，如果不一致，提示 AI 根据关键字查询 Gitlab 仓库，比如查询 ，找到相似度最高的那个仓库名称。\n对比 方案 优点 缺点 从报错堆栈信息里去项目三级包名映射到代码仓库 匹配比较精确 处理起来比较麻烦，获取到的仓库有限 使用日志里的 container.image.name 镜像名称解析出项目名 可以获取到自己有权限的所有仓库代码 没办法处理镜像仓库名称和 gitlab 仓库名称完全不一致的项目。 总结 综上，采取方案二，局限性没有大，不依赖本地映射配置，也可以获取到自己有权限的所有仓库。\n3、怎么引导 AI 将所有工具串联起来排查问题？ 提供一个 Tool 返回指导框架 Prompt，所有排查操作都先经过过去 Prompt，然后再继续排查。\n那么如何提高稳定性，有时 AI 并不会按照 Prompt 引导的流程去执行？\n可以通过在每个阶段的完成提供下一步的 Prompt 提示，比如在查询日志的 Tool 里根据不同的查询结果附加不同 Prompt，如果成功查询出日志，Prompt 提示下一步进行代码分析。如果没查出结果，可以附加扩大时间范围重新查询等提示。\n4、优化 如果错误是当前 Cursor 打开的项目，直接在当前项目代码进行排查，这样可以利用到 Cursor 的文件索引，而不需要去查 Gitlab 仓库代码，那怎么判断报错的项目是不是当前项目？\n通过 git 命令查询当前项目仓库名，对应日志里的 container.image.name 镜像名称，如果项目名大致一直（比如大小写区别，分隔符不同等，可以通过 prompt 实现），则认为是同个项目。\nMCP 流程图 1、系统架构概览 graph TB subgraph \"MCP 异常分析系统架构\" subgraph \"服务层\" C[FastMCP 服务器] end subgraph \"工具层\" K[GitLab 工具30+ 项目管理工具] L[Kibana 工具Cookie 管理工具] M[日志搜索工具应用/Nginx 日志搜索] N[问题分析工具分析指导框架] end subgraph \"API 客户端层\" G[GitLabClientGitLab API 封装] H[KibanaClientKibana API 封装] I[ElasticsearchClientES 查询封装] J[KibanaClusterManager多集群管理] end subgraph \"配置管理层\" D[GitLab 配置GITLAB_CONFIG] E[Kibana 集群配置KIBANA_CLUSTERS] F[日志配置logging_config] end subgraph \"外部服务层\" O[GitLab 服务器代码仓库] P[Kibana 集群新加坡/德国/美国] Q[Elasticsearch日志存储] end %% 服务层到工具层（工具注册） C -.-\u003e K C -.-\u003e L C -.-\u003e M C -.-\u003e N %% 工具层调用API客户端层 K --\u003e G L --\u003e H M --\u003e I M --\u003e J %% API客户端层获取配置 G --\u003e D H --\u003e E I --\u003e E J --\u003e E %% API客户端层访问外部服务 G --\u003e O H --\u003e P I --\u003e Q J --\u003e P %% 样式定义 classDef entryLayer fill:#e1f5fe classDef serviceLayer fill:#f3e5f5 classDef toolLayer fill:#fce4ec classDef clientLayer fill:#fff3e0 classDef configLayer fill:#e8f5e8 classDef externalLayer fill:#f1f8e9 class A,B entryLayer class C serviceLayer class K,L,M,N toolLayer class G,H,I,J clientLayer class D,E,F configLayer class O,P,Q externalLayer end 2、数据流向图 graph LR subgraph \"输入层\" A[用户请求] B[工具参数] end subgraph \"处理层\" C[参数验证] D[配置获取] E[客户端选择] F[API 调用] end subgraph \"外部服务\" G[GitLab API] H[Kibana API] end subgraph \"输出层\" J[数据处理] K[格式化] L[结果返回] end A --\u003e C B --\u003e C C --\u003e D D --\u003e E E --\u003e F F --\u003e G F --\u003e H G --\u003e J H --\u003e J J --\u003e K K --\u003e L 3、详细运行时流程 sequenceDiagram participant User as 用户/客户端 participant Cursor as Cursor participant Server as MCP服务器 participant Config as 本地文件配置 participant GitLab as GitLab participant Kibana as Kibana Note over User,Kibana: 1、系统启动阶段 User-\u003e\u003eCursor: 启动 Cursor IDE Cursor-\u003e\u003eServer: 初始化 MCP 连接 Server-\u003e\u003eConfig: 加载配置文件 (settings.py) Config--\u003e\u003eServer: 返回 GitLab Token 和 Kibana Cookies Note over User,Kibana: 2. 服务初始化与鉴权 Server-\u003e\u003eGitLab: 验证 GitLab Token GitLab--\u003e\u003eServer: 返回 Token 验证结果 Server-\u003e\u003eKibana: 验证 Kibana Cookies Kibana--\u003e\u003eServer: 返回 Cookie 验证结果 alt 鉴权失败 Server--\u003e\u003eCursor: 返回鉴权失败提示 Cursor--\u003e\u003eUser: 提示更新 Token/Cookie User-\u003e\u003eCursor: 调用刷新工具 Cursor-\u003e\u003eServer: refresh_kibana_cookies / update_gitlab_token Server-\u003e\u003eGitLab: 更新 Token Server-\u003e\u003eKibana: 自动登录获取新 Cookie Kibana--\u003e\u003eServer: 返回新 Cookie Server-\u003e\u003eConfig: 更新本地配置 end Note over User,Kibana: 3. 服务就绪 Server--\u003e\u003eCursor: MCP 服务器启动完成 Cursor--\u003e\u003eUser: 显示可用工具列表 Note over User,Kibana: 4. 问题排查流程 User-\u003e\u003eCursor: 报告问题 \"用户登录失败\" Cursor-\u003e\u003eServer: get_problem_analysis_prompt Server--\u003e\u003eCursor: 返回问题分析框架 Note over User,Kibana: 4.1 第一步：查看应用日志 Cursor-\u003e\u003eServer: search_application_logs(app_id=\"user-service\", log_level=\"ERROR\") Server-\u003e\u003eKibana: 构建查询并搜索 Kibana--\u003e\u003eServer: 返回错误日志 Server--\u003e\u003eCursor: 格式化日志结果 Note over User,Kibana: 4.2 第二步：根据需要查看 Nginx 日志 Cursor-\u003e\u003eServer: search_nginx_logs(status_range=\"4xx,5xx\") Server-\u003e\u003eKibana: 搜索 HTTP 错误日志 Kibana--\u003e\u003eServer: 返回 HTTP 错误记录 Server--\u003e\u003eCursor: 分析请求失败原因 Note over User,Kibana: 4.3 第三步：查看相关代码 Cursor-\u003e\u003eServer: list_gitlab_projects(search=\"user\") Server-\u003e\u003eGitLab: 搜索相关项目 GitLab--\u003e\u003eServer: 返回用户相关项目列表 Server--\u003e\u003eCursor: 项目列表 Note over User,Kibana: 4.4 第四步：根据需要查看依赖项目代码 Cursor-\u003e\u003eServer: read_gitlab_file(project=\"auth-service\", file_path=\"src/token/verify.py\") Server-\u003e\u003eGitLab: 读取认证相关代码 GitLab--\u003e\u003eServer: 返回认证代码 Server--\u003e\u003eCursor: 认证代码内容 Note over User,Kibana: 5. 问题定位完成 Cursor--\u003e\u003eUser: 展示完整的排查结果和问题分析 Cursor--\u003e\u003eUser: 提供上下文链路信息与分析报告 4、Kibana 日志搜索流程 flowchart TD A[用户调用日志搜索] --\u003e B{搜索类型} B --\u003e|应用日志| C[search_application_logs] B --\u003e|Nginx日志| D[search_nginx_logs] C --\u003e E[构建应用日志查询] D --\u003e F[构建Nginx日志查询] E --\u003e G[选择目标集群] F --\u003e G G --\u003e H[获取集群客户端] H --\u003e I[发起 Elasticsearch 查询] I --\u003e J[通过 Kibana 代理] J --\u003e K[Elasticsearch 执行查询] K --\u003e L[返回搜索结果] L --\u003e M[格式化响应数据] M --\u003e N[返回给用户] 最终效果 我们把群里报的告警直接发给 Cursor，首先会获取分析指导框架，然后进入排查流程。\n1、第一步会根据我们发送的内容提取关键信息，然后查询应用日志。\n2、从应用日志分析错误，分析出应用里的调用链路。如果涉及调用其他服务，会查找相关服务的代码。\n3、输出分析报告，总结错误原因。\n4、提出解决建议，最终查出是业务正常情况。\n总结 我们可以看到它帮我们查找了指定集群日志，给出清晰的调用链路，以及相关的代码分析，包括下游服务。帮助我们确定告警是不是无效的告警，以及帮助我们节省大量的问题排查时间，我们也可以根据调用链路自己更快地排查解决问题。\n不足之处：目前还没法排查解决复杂问题，比如数据库相关的问题、涉及接口性能问题、复杂路由问题（MDC），后续可以接入数据库、APM 等工具，使问题排查更加全面\n未来展望 1、结合更多工具来帮助 AI 更加精准地定位问题，以及提供给我们更多的上下文信息，比如：MySQL查询，APM 日志等等\n2、结合自动化的方式帮助我们过滤或标注出需要重点关注的 Error 异常问题。根据两种方式使用不同的大模型来控制成本，大致会是这样一个架构：\nError 日志上报 ——\u0026gt; 低成本 AI + 知识库 ——\u0026gt; 过滤与分析 ——\u0026gt; 转发企业 IM 并标注重点异常 ——\u0026gt; 点击附带链接跳转报告 Cursor + MCP ——\u0026gt; ES + Gitlab ——\u0026gt; 分析报告 ","permalink":"/posts/%E7%9B%91%E6%8E%A7%E5%91%8A%E8%AD%A6%E4%B8%8E%E5%BC%82%E5%B8%B8%E5%88%86%E6%9E%90mcp%E5%AE%9E%E8%B7%B5/","summary":"\u003ch1 id=\"背景\"\u003e背景\u003c/h1\u003e\n\u003cp\u003e为提高后台整体质量，目前大部分后台系统都接入了 Error 日志监控告警，接入初期产生了非常多的告警，消耗了我们大量的时间去排查。当前线上异常日志排查主要存在以下痛点：\u003c/p\u003e","title":"从告警泛滥到高效定位：MCP 异常分析实践"},{"content":"现象 补全翻译接口（填充空白的语料）接口执行失败，MySQL 检测到死锁快速抛异常，接口执行耗时 426ms。\n无论是从接口、具体表现、根因，都与上个问题有明显区别。\n分析 1、查看死锁日志，可以看到有两个事务，是对同一个项目的不同语言的批量插入更新操作（INSERT ON DUPLICATE KEY UPDATE）。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 ------------------------ LATEST DETECTED DEADLOCK ------------------------ \u0026lt;timestamp\u0026gt; \u0026lt;os_thread_hex\u0026gt; *** (1) TRANSACTION: TRANSACTION \u0026lt;trx_1\u0026gt;, ACTIVE 0 sec inserting mysql tables in use 1, locked 1 LOCK WAIT 6 lock struct(s), heap size 1136, 4 row lock(s) MySQL thread id \u0026lt;tid_1\u0026gt;, OS thread handle \u0026lt;handle_1\u0026gt;, query id \u0026lt;qid_1\u0026gt; \u0026lt;private-ip\u0026gt; \u0026lt;db_name\u0026gt; update INSERT INTO multilingual_item (item_id, corpus_id, lang, customize,`key`,value, status, type) VALUES (\u0026lt;item_id_1\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;my-MM\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;下一步\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_2\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;my-MM\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;分组竞争\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_3\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;my-MM\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;判断对错\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_4\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;my-MM\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;完成\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_5\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;my-MM\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;球球拼词\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_6\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;my-MM\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;知识排序\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_7\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;my-MM\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;知识配对\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_8\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;my-MM\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;记忆卡片\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_9\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;my-MM\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;试玩\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_10\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;my-MM\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;超级分类\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_11\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;my-MM\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;趣味分类\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_12\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;my-MM\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;趣味选择\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_13\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;my-MM\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;返回\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_14\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;my-MM\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;选词填空\u0026#39;, \u0026#39;\u0026#39;, 0, 1) ON DUPLICATE KEY UPDATE status = IF(value = \u0026#39;\u0026#39;, VALUES(status), status), value = IF(value = \u0026#39;\u0026#39;, VALUES(value), value) *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 345 page no 93049 n bits 144 index PRIMARY of table `\u0026lt;db_name\u0026gt;`.`multilingual_item` trx id \u0026lt;trx_1\u0026gt; lock_mode X insert intention waiting Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0 0: len 8; hex 73757072656d756d; asc supremum;; *** (2) TRANSACTION: TRANSACTION \u0026lt;trx_2\u0026gt;, ACTIVE 0 sec inserting mysql tables in use 1, locked 1 7 lock struct(s), heap size 1136, 4 row lock(s) MySQL thread id \u0026lt;tid_2\u0026gt;, OS thread handle \u0026lt;handle_2\u0026gt;, query id \u0026lt;qid_2\u0026gt; \u0026lt;private-ip\u0026gt; \u0026lt;db_name\u0026gt; update INSERT INTO multilingual_item (item_id, corpus_id, lang, customize,`key`,value, status, type) VALUES (\u0026lt;item_id_a1\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;ar-EG\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;下一步\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_a2\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;ar-EG\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;分组竞争\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_a3\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;ar-EG\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;判断对错\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_a4\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;ar-EG\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;完成\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_a5\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;ar-EG\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;球球拼词\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_a6\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;ar-EG\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;知识排序\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_a7\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;ar-EG\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;知识配对\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_a8\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;ar-EG\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;记忆卡片\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_a9\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;ar-EG\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;试玩\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_a10\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;ar-EG\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;超级分类\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_a11\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;ar-EG\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;趣味分类\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_a12\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;ar-EG\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;趣味选择\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_a13\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;ar-EG\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;返回\u0026#39;, \u0026#39;\u0026#39;, 0, 1) , (\u0026lt;item_id_a14\u0026gt;, \u0026lt;corpus_id\u0026gt;, \u0026#39;ar-EG\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;选词填空\u0026#39;, \u0026#39;\u0026#39;, 0, 1) ON DUPLICATE KEY UPDATE status = IF(value = \u0026#39;\u0026#39;, VALUES(status), status), value = IF(value = \u0026#39;\u0026#39;, VALUES(value), value) *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 345 page no 93049 n bits 144 index PRIMARY of table `\u0026lt;db_name\u0026gt;`.`multilingual_item` trx id \u0026lt;trx_2\u0026gt; lock_mode X Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0 0: len 8; hex 73757072656d756d; asc supremum;; *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 345 page no 93049 n bits 144 index PRIMARY of table `\u0026lt;db_name\u0026gt;`.`multilingual_item` trx id \u0026lt;trx_2\u0026gt; lock_mode X insert intention waiting Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0 0: len 8; hex 73757072656d756d; asc supremum;; *** WE ROLL BACK TRANSACTION (1) 根据死锁日志画出时序图，从下面这个时序图可以看到死锁的形成过程。\nsequenceDiagram participant T1 as 事务1 (my-MM) participant DB as 数据库引擎 participant T2 as 事务2 (ar-EG) Note over T1,T2: 同时开始执行 INSERT ON DUPLICATE KEY UPDATE T1-\u003e\u003eDB: 请求 Next-Key Lock (my-MM 范围) DB--\u003e\u003eT1: 授予锁 T2-\u003e\u003eDB: 请求 Next-Key Lock (ar-EG 范围) DB--\u003e\u003eT2: 授予锁 Note over T1,T2: 两个事务都持有各自的范围锁 T1-\u003e\u003eDB: 请求 Insert Intention Lock (Supremum) Note right of DB: 被 T2 的 Next-Key Lock 阻塞 T2-\u003e\u003eDB: 请求 Insert Intention Lock (Supremum) Note right of DB: 被 T1 的 Next-Key Lock 阻塞 Note over T1,T2: 循环等待形成，死锁检测器介入 DB--\u003e\u003eT1: 死锁检测，回滚事务1 DB--\u003e\u003eT2: 事务2 继续执行 2、查看项目代码，可以看到这个接口确实有多线程并发写数据库的操作，导致了死锁。\n根因分析 1、有个明显的问题，两个事务是针对两门不同语言的插入更新，为什么会争同一个锁？我们可以看到两个事务都有一个supremum 记录锁，为什么会这样大范围加锁？\nsupremum记录：InnoDB索引页中的虚拟最大记录（逻辑上界），代表“大于该页所有实际记录的范围”（例如，页内实际记录是id=100、200、300，supremum就是\u0026gt;300的虚拟边界）。 2、查看相关资料，发现官方有一个说明，原因是为了解决更严重的数据一致性问题，引入了 supremum 记录锁，同时也引入了并发死锁的问题。可以参考 https://bugs.mysql.com/bug.php?id=98324 里 Jakub Lopuszanski 给出的解释。\n假设有一个表：\n1 2 3 4 5 6 7 CREATE TABLE t ( id INT PRIMARY KEY, name VARCHAR(100) UNIQUE ); -- 初始数据 INSERT INTO t VALUES (1, \u0026#39;alice\u0026#39;); 两个并发事务，使用相同的主键值但不同的唯一键值：\n1 2 3 4 5 -- 事务1 INSERT INTO t VALUES (2, \u0026#39;bob\u0026#39;) ON DUPLICATE KEY UPDATE name = \u0026#39;bob_new\u0026#39;; -- 事务2 INSERT INTO t VALUES (2, \u0026#39;carol\u0026#39;) ON DUPLICATE KEY UPDATE name = \u0026#39;carol_new\u0026#39;; 没有 Supremum 锁时的并发问题：\nsequenceDiagram participant T1 as 事务1 (bob) participant DB as 数据库 participant T2 as 事务2 (carol) Note over DB: 初始: (1, 'alice') par 并发检查约束 T1-\u003e\u003eDB: 检查 id=2 → 不存在 ✓ T2-\u003e\u003eDB: 检查 id=2 → 不存在 ✓ end par 并发检查约束 T1-\u003e\u003eDB: 检查 name='bob' → 不存在 ✓ T2-\u003e\u003eDB: 检查 name='carol' → 不存在 ✓ end Note over T1,T2: 两边都认为可以插入！ T2-\u003e\u003eDB: INSERT (2, 'carol') DB--\u003e\u003eT2: ✓ 成功 T1-\u003e\u003eDB: INSERT (2, 'bob') DB--\u003e\u003eT1: ✗ 主键冲突！ Note right of DB: 但 T1 已经检查过了id=2 不存在... 问题本质：约束检查和实际插入之间存在时间窗口，其他事务可能在这个窗口内插入数据，导致约束检查结果失效。\n有 Supremum 锁时：\nsequenceDiagram participant T1 as 事务1 (bob) participant DB as 数据库 participant T2 as 事务2 (carol) Note over DB: 初始: (1, 'alice') T1-\u003e\u003eDB: 请求 Supremum 锁 DB--\u003e\u003eT1: ✓ 获得 Note right of T1: 在锁保护下检查 T1-\u003e\u003eDB: 检查 id=2 → 不存在 T1-\u003e\u003eDB: 检查 name='bob' → 不存在 T1-\u003e\u003eDB: INSERT (2, 'bob') DB--\u003e\u003eT1: ✓ 成功 T1-\u003e\u003eDB: 释放锁 Note over T2: 等待中... T2-\u003e\u003eDB: 请求 Supremum 锁 DB--\u003e\u003eT2: ✓ 获得 Note right of T2: 在锁保护下检查 T2-\u003e\u003eDB: 检查 id=2 → 存在！ Note right of DB: 发现主键冲突执行 UPDATE T2-\u003e\u003eDB: UPDATE ... WHERE id=2 DB--\u003e\u003eT2: ✓ 更新为 (2, 'carol_new') T2-\u003e\u003eDB: 释放锁 MySQL 的修复策略\n1、锁定所有可能的冲突点 - 不仅锁定实际冲突的记录，还锁定所有可能冲突的位置\n2、引入 Supremum 记录锁 - 将\u0026quot;检查-执行\u0026quot;这个复合操作变成原子操作\n3、保证约束检查的原子性 - 避免在检查和执行之间被其他事务干扰\n总结 1、不是代码bug：这是 MySQL 为了修复更严重的一致性问题而引入的必要机制，所以这是预期行为，需要在应用层面处理。\n2、Supremum 记录锁：所有 INSERT \u0026hellip; ON DUPLICATE KEY UPDATE 操作都需要获取这个锁\n3、死锁是副作用：为了保证数据一致性，MySQL 宁愿承受更多死锁的代价\n解决方案 避免并发执行INSERT \u0026hellip; ON DUPLICATE KEY UPDATE 语句，并发翻译所有语言，全都翻译完成之后，再合并为一个事务去写数据。\n","permalink":"/posts/mysql%E6%AD%BB%E9%94%81%E6%8E%92%E6%9F%A5%E5%AE%9E%E6%88%98/","summary":"\u003ch1 id=\"现象\"\u003e现象\u003c/h1\u003e\n\u003cp\u003e补全翻译接口（填充空白的语料）接口执行失败，MySQL 检测到死锁快速抛异常，接口执行耗时 426ms。\u003c/p\u003e\n\u003cp\u003e无论是从接口、具体表现、根因，都与上个问题有明显区别。\u003c/p\u003e","title":"MySQL 死锁问题的系统化排查与并发优化"},{"content":"背景与现象 背景：在C知汇项目（GPT私有库问答系统）中，我们期望GPT的回答通过流式返回，采用了SSE（Server-Sent Events）的服务端推送技术做流式传输。 现象：本地运行没有问题，但是上线之后发现有时是流式，有时是一次性返回。 产品界面 分析过程 1、首先确认技术选型是否存在问题。流式响应有常见的两种方案，WebFlux和SSE，我们使用WebFlux响应式编程技术替代SSE，写最小demo排除业务的影响，发现仍然有问题。可以暂时排除代码层面的问题。\n2、分析数据传输链路，用户发送提问并成功响应时，回答通过流式返回链路：GPT -\u0026gt; C知汇后台 -\u0026gt; 后台Nginx -\u0026gt; C知汇前端 -\u0026gt; 前端Nginx -\u0026gt; 浏览器 我们可以选择从源头开始验证。 1）后台打印GPT响应，收到GPT的响应是流式的，没有问题。 2）通过直连后台服务容器IP排除Nginx的影响，发现问题消失了。\n3、验证猜想：查阅nginx配置资料，发现nginx有缓冲区的相关配置 proxy_buffers。查看鲸云Nginx配置如下图所示。通过自建nginx验证了猜想。那么为什么线上环境有时能复现，有时又是正常的呢？原来是响应内容的大小决定的，如果响应内容没有达到缓冲区容量，那么会一次返回，如果超过了缓冲区容量，那么缓冲区容量以外的数据会流式返回。\n解决方案 方案一：取消网关的响应缓冲。可能需要修改网关配置，然而修改网关配置是一个不小的动作，而且可能对其他应用造成影响。\n方案二：绕过nginx，直连访问。但是鲸云我们通常采用滚动更新镜像的方式，所以容器IP是经常会变更的。后台可以开放获取IP接口，客户端可以先通过访问后台接口获取IP，然后再直连，这样也能使用到网关的负载均衡。主要的缺点有：1、不通过网关过滤直连有一定的安全风险。2、访问记录不会被网关日志收集，会对数据的统计和问题的排查造成一定的影响。\n方案二的方式有点hack，可以直接排除，我们尝试从方案一尝试入手解决。\n尝试解决 关闭响应缓冲的方式主要有两种\n1）将Nginx的proxy_buffers配置设置为off。\n2）将请求头X-Accel-Buffering设置为 no，Cache-Control设置为no-cache。\n1、我们在应用代码上去手动设置请求头，发现仍然不行。我们查看请求返回的响应头里没有我们设置的X-Accel-Buffering，而使用Arthas执行watch，看到X-Accel-Buffering是设置进去了的。\n代码里设置响应头 返回的响应头 Arthas Watch 2、怀疑是配置优先级的问题，如果我们将Nginx的proxy_buffers设置为on，请求头X-Accel-Buffering设置为no，会以哪一个为准？我们通过自建Nginx验证发现，Nginx 会遵循请求头中的指示，所以是请求头X-Accel-Buffering优先。\nAPISIX响应重写 3、我们验证了正常情况下使用X-Accel-Buffering是可以生效的且优先级比较高的。回到第1点，如果我们在APISIX设置X-Accel-Buffering响应头，那么会不会透传回客户端呢？测试表明该响应头仍然丢失了。\n4、我们去掉前端部分再对整条链路进行分析，GPT -\u0026gt; 应用后台 -\u0026gt; APISIX -\u0026gt; 客户端，我们在后台和APISIX都设置了响应头，但是响应里却没有出现X-Accel-Buffering头。那么我们可以猜测，APISIX和客户端之间还有一些链路，导致X-Accel-Buffering没有被透传。一般实际生产中，Nginx也不止一层，那么需要运维协助排查解决。\n链路 协调资源解决问题 通过运维协助，展开了业务端不可见的完整链路： GPT -\u0026gt; C知汇后台 -\u0026gt; APISIX -\u0026gt; 雷池 WAF -\u0026gt; 负载均衡 F5 -\u0026gt; 浏览器\n具体原因：雷池 WAF（网络应用防火墙）底层为 Nginx 变体，默认开启响应处理并强制写入缓冲区（由于需要等待检测结果才能放行），导致 X-Accel-Buffering 在此处失效并停止下传。 解决方法：关闭 WAF 的响应处理。 风险影响：会失去雷池 WAF 对响应内容的部分监控功能，如识别软件报错信息、代码泄漏、Webshell 执行及信息泄漏检测等。 ","permalink":"/posts/sseemitter%E6%B5%81%E5%BC%8F%E5%93%8D%E5%BA%94%E4%B8%8Enginx%E7%BC%93%E5%AD%98%E9%97%AE%E9%A2%98%E6%8E%92%E6%9F%A5/","summary":"\u003ch2 id=\"背景与现象\"\u003e背景与现象\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e背景\u003c/strong\u003e：在C知汇项目（GPT私有库问答系统）中，我们期望GPT的回答通过流式返回，采用了SSE（Server-Sent Events）的服务端推送技术做流式传输。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e现象\u003c/strong\u003e：本地运行没有问题，但是上线之后发现有时是流式，有时是一次性返回。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cimg src=\"/imgs/image-20260316110143274.png\" alt=\"image-20260316110143274\" style=\"zoom:50%;\" /\u003e\n\u003ccenter\u003e\u003csmall\u003e产品界面\u003c/small\u003e\u003c/center\u003e\n\u003ch2 id=\"分析过程\"\u003e分析过程\u003c/h2\u003e\n\u003cp\u003e1、\u003cstrong\u003e首先确认技术选型是否存在问题\u003c/strong\u003e。流式响应有常见的两种方案，WebFlux和SSE，我们使用WebFlux响应式编程技术替代SSE，写最小demo排除业务的影响，发现仍然有问题。可以暂时排除代码层面的问题。\u003c/p\u003e","title":"SSE 流式响应在 Nginx 下失效的排查与修复实践"},{"content":"前言 在企业信息化建设中，一个常见诉求是：员工继续使用本地 AD 域账号，同时能够无缝访问 Office 365、Teams 等云端应用，并在统一策略下完成认证与权限控制。\n这个问题的本质并不是“本地目录还是云目录二选一”，而是如何在安全、体验、合规之间取得平衡。混合身份认证正是为此而生：既保留本地目录的管理基础，又借助云端身份服务提升统一登录能力与安全治理能力。\n本文会围绕实际落地最常遇到的核心问题展开：\nAD 与 Entra ID 的职责边界与协作关系 PHS、PTA、ADFS 三种方案的取舍逻辑 面向教育/组织接入场景的扩展实践（如 OneRoster、外部目录接入） 什么是混合身份认证？ Hybrid Identity 是指通过本地目录（如Microsoft Active Directory、OpenLDAP 等）与云目录（如 Microsoft Entra ID、Google Cloud Identity 等）的集成，为用户提供统一的身份验证和授权机制。这种解决方案允许用户使用单一身份访问本地和云中的资源，无论资源的位置如何。具体场景如下：\n允许你或你的组织内的用户，使用 Azure AD 登录你开发的应用。 允许其他组织的用户，使用 Azure AD 登录你开发的应用。 为什么会有混合身份认证，是否可以不使用本地目录，而完全依赖云目录？ 取决于企业的具体需求，包括现有架构、协议支持、网络条件、合规性要求等。对于大多数企业，采用混合架构（本地目录+云目录）可能是更现实的选择。\n我们能做的？ 了解术语概念 业务相关 沟通需要 理解深层次架构 提供产品方向 解决技术问题 一、基础概念 身份 (Identity)： 用户/设备/服务的唯一代表（用户名、邮箱等）。 认证 (Authentication, AuthN)： 证明“你是谁”（密码、指纹、短信验证码、MFA）。 授权 (Authorization, AuthZ)： 决定“你能做什么”（RBAC角色权限、ABAC属性权限）。 身份生命周期： 创建账号 -\u0026gt; 启用 -\u0026gt; 更新（改密码） -\u0026gt; 禁用 -\u0026gt; 删除。 IAM、IDP、IDM三者共同构成了现代企业身份管理与安全体系\nIAM (身份与访问管理): 目标： 确保正确的人/物在正确的时间访问正确的资源。 核心： 管身份（谁）、管认证（验身份）、管授权（给权限）。 举例：RBAC、MFA IDP (身份提供者): 作用： 存身份信息 + 做认证。 举例： Entra ID、公司AD、Google、微信登录。 IDM (身份管理): 作用： 管身份的一生（创建、更新、禁用、删除）。 工具： Azure AD Connect、SailPoint（自动化账号）。 三者关系 IAM 是宏观体系，依赖 IDM 提供数据、IDP 提供认证，但自身核心是权限管理。 IDM 和 IDP 可独立使用，但在企业级场景中通常被纳入 IAM 架构。 选择解决方案时需明确需求： 只需统一账户管理？→ IDM 只需单点登录？→ IDP 需要全流程管控？→ IAM 平台（含 IDM + IDP 功能） 二、本地目录与云目录 AD (Active Directory): 本质： 本地目录服务（装Windows Server上）。 功能： 管公司电脑登录、管文件服务器权限、用Kerberos认证。 AAD / Microsoft Entra ID (原Azure AD): 本质： 云身份服务（SaaS）。 功能： 管Office 365等云应用登录、做SSO、安全策略（如强制MFA）、支持B2B/B2C。 入口： https://entra.microsoft.com AD 与 Entra ID (原AAD) 核心关系 特性 Active Directory (AD) Microsoft Entra ID (原AAD) 类型 本地目录服务 (装Win Server上) 云身份服务 (IDaaS) 核心作用 管本地内网登录、权限、组策略 管云应用访问、SSO、安全策略、B2B/C 主要协议 LDAP, Kerberos SAML, OIDC, OAuth 2.0 登录对象 公司电脑、本地应用 Office 365, SaaS应用, 手机App 关系 互补协作 (通过Azure AD Connect连接) 不是替代 现代/云核心 ❌ ✅ 关键协作点 混合身份认证 (PHS/PTA) 同步用户 成为统一的访问控制点与策略执行层 一句话总结： AD管好本地基础，Entra ID作为通向云端和现代化应用的身份桥梁与安全控制中心。两者通过Azure AD Connect紧密协作。\n三、关键技术与协议 LDAP： 查改目录信息（AD用它）。 Kerberos： AD域内安全登录协议（不用传密码）。 SSO协议 (让登录一次通行多个应用): SAML 2.0： 企业网页应用常用（如用AD登录Salesforce）。 OIDC (OpenID Connect)： 现代APP/网页常用（如用微信登录）。 OAuth 2.0： 授权协议（授权App访问你的资源），常配合OIDC。 SCIM： 自动同步用户账号信息（如人事系统 -\u0026gt; AD/Entra ID）。 四、微软混合身份认证 (本地AD + 云Entra ID) 微软工具：Azure AD Connect\n功能： 同步本地AD用户到Entra ID。 核心方法： 密码哈希同步 PHS (Password Hash Synchronization) ： 同步密码哈希到云（单向），登录在云验证。最简单常用。 优点： 快（云验证）、云灾备可用。 直通认证 PTA (Pass-through Authentication) ： 登录时云把密码传给本地代理，由本地AD验证。 优点： 强制本地密码策略。 联合认证 ADFS (Active Directory Federation Services) ： 登录重定向到本地认证服务器 (如ADFS)。 缺点： 复杂，依赖本地服务器。尽量少用。 方法 密码在哪验证? 优点 缺点 微软推荐 适用场景 PHS (哈希) 云 简单、快、云灾备登录 本地策略更新有延迟 首选 可以接受密码哈希存储在云端 PTA (直通) 本地 (经代理) 强制即时本地密码策略 需代理+本地在线，稍慢 ✅ 需要保持密码验证在本地，对实时身份验证有要求 联合 (ADFS) 本地 (ADFS) 协议兼容性强 复杂、慢、依赖本地服务器在线 ❌ (特殊情况) 1）需要与不支持现代认证协议的遗留应用集成，比如只支持SAML 2.0 的应用。2）合规性要求，需要保持身份验证完全在本地。 五、其他玩法 1、Google Cloud Directory Sync (GCDS) 类似微软的 Connect，Google 提供了 GCDS 工具，用于将本地 Active Directory 用户同步到 Google Workspace。部署在内网环境即可，只需要确保服务器能够访问 Active Directory 和 Google Workspace API。但是也有跟我们同样的问题，无法同步密码。\n优势 单向同步：确保本地 Active Directory 的数据不会被修改，仅更新 Google Workspace 的数据。 安全可靠：GCDS 运行在本地服务器，所有数据同步过程都在本地完成，不会将敏感的 Active Directory 数据暴露到外部。 那么 Google 怎么处理密码问题 用户首次登录 Google Workspace 时，强制用户重置密码。\n2、OneRoster（主要针对教育场景，脱离混合身份认证范畴） OneRoster 是由 1EdTech 联盟制定的一种行业标准，用于在教育机构的系统之间安全、可靠地交换教师学生信息、课程数据和成绩数据。它旨在解决教育机构在数据同步和管理上的需求，尤其是在 学生信息系统 (SIS) 和 学习管理系统 (LMS) 之间的数据交换问题。\n（OneRoster 已经成为教育技术领域的重要标准，ClassLink、Microsoft 和 Google Workspace 教育版等厂商均使用了 1EdTech 的 OneRoster 标准）\n具体流程（以微软的实现举例） sequenceDiagram participant SDS as School Data Sync (SDS) participant SIS as SIS (支持 OneRoster API) participant Entra as Microsoft Entra ID participant Teams as Microsoft Teams Note over SDS: 同步周期开始 (每12小时) SDS-\u003e\u003eSIS: 1. 获取 OAuth 2.0 访问令牌\n(client_id + client_secret) SIS--\u003e\u003eSDS: 返回访问令牌 (access_token) loop 增量数据请求 SDS-\u003e\u003eSIS: 2. 调用 OneRoster API\nGET /users?filter=dateLastModified\u003e{lastSyncTime} SIS--\u003e\u003eSDS: 返回增量用户数据 SDS-\u003e\u003eSIS: 3. 调用 OneRoster API\nGET /classes?filter=dateLastModified\u003e{lastSyncTime} SIS--\u003e\u003eSDS: 返回增量班级数据 SDS-\u003e\u003eSIS: 4. 调用 OneRoster API\nGET /enrollments?filter=dateLastModified\u003e{lastSyncTime} SIS--\u003e\u003eSDS: 返回增量注册关系数据 end SDS-\u003e\u003eSDS: 5. 数据验证与转换\n• 检查必填字段\n• 映射用户角色\n• 关联班级-用户关系 SDS-\u003e\u003eEntra: 6. 同步用户账户\n• 创建/更新教师账号\n• 创建/更新学生账号 Entra--\u003e\u003eSDS: 返回同步结果 SDS-\u003e\u003eEntra: 7. 创建 Microsoft 365 组\n• 每个班级对应一个组\n• 教师设为所有者\n• 学生设为成员 Entra--\u003e\u003eSDS: 返回组创建结果 SDS-\u003e\u003eTeams: 8. 为每个班级创建 Team\n• 关联 Microsoft 365 组\n• 添加班级频道\n• 配置作业/文件结构 Teams--\u003e\u003eSDS: 返回 Team 创建状态 SDS-\u003e\u003eSDS: 9. 记录同步元数据\n• 更新 lastSyncTime\n• 生成同步报告 Note over SDS: 同步完成，等待下次周期 参考 https://mslearningcampus.com/User/Login?ReturnUrl=%2F（AAD 登录）\nhttps://old-docs.authing.cn/connections/azure-ad.html\nhttps://learn.microsoft.com/en-us/entra/architecture/auth-ldap\nhttps://www.1edtech.org/\nhttps://support.google.com/a/answer/106368?hl=zh-Hant\n","permalink":"/posts/%E5%BE%AE%E8%BD%AFhybrididentity%E6%B7%B7%E5%90%88%E8%BA%AB%E4%BB%BD%E8%AE%A4%E8%AF%81%E5%AE%9E%E8%B7%B5/","summary":"\u003ch2 id=\"前言\"\u003e前言\u003c/h2\u003e\n\u003cp\u003e在企业信息化建设中，一个常见诉求是：员工继续使用本地 AD 域账号，同时能够无缝访问 Office 365、Teams 等云端应用，并在统一策略下完成认证与权限控制。\u003c/p\u003e","title":"微软 Hybrid Identity 混合身份认证实践"},{"content":"书籍链接：https://book.douban.com/subject/34782232/\n主要针对第二章的全球区域化部署技术\n1 总体架构 基本原则 问题 解决方案 总体架构 2 路由服务 2.1 路由服务架构 透传技术优势：\n1、无需在每一层维护一个路由表，不用过多关注路由数据一致性问题。\n2、无需每次都调用路由表RPC服务，对RPC服务的依赖过大，调用量过高。\n2.2 路由表设计 1、基本模型 类似位图，通过用户id将bitmap对应的位置置为1。但是我们不仅需要存储用户是否存在的信息，还需要存储用户到机房的路由，所以用4个二进制位来存储用户到机房的映射。\n前三位：机房标识\n最后一位：用户状态\n2、分段存储 为了解决ID分布不均匀，存储浪费的问题，采取分段模式进行存储。用户没命中的段为null，不会占用内存。\n3、逻辑机房 1、路由信息巨大，机房迁移成本很高。\n2、使流量均匀分布。\n4、一致性保障 定期MD5进行对比\n2.3 路由表更新机制 基于Zookeeper实现，引入“禁写”状态，即用户的路由表更新时，该用户无法进行业务写入，从而确保业务数据的全局一致性。架构如下：\n整体流程\n2.4 用户路由更新方案 1、确定用户归属机房 通过对各个机房的访问统计，将延迟最少的机房确认为最近的机房。\n2.5 多层路由实现 1、统一接入层路由技术 为所有网站用户提供统一的接入点，对多区域进行对等部署，并将用户归属到不同的区域。\n方案：Openresty + Lua共享内存 + Redis + ProxyServer\n2、服务层路由技术 业务相关，处理不同类型的数据，如：独享数据、共享数据。\n1）用户优先级原则，如买家 \u0026gt; 卖家 \u0026gt; 平台运营人员。\n2）数据类型及处理方法\n只读类型：不需要实时一致；处理：数据异步复制 独享类型：只有一个用户可以对数据进行变更。处理：本地读写 共享数据：多个用户需要共同变更同一条数据。处理：优先级最高的用户区域作为Master，可进行本地读写。非Master区域的用户需要区分情况决定是本地读写还是跨区域读写。 全局共享数据：所有用户的行为都可以改变的数据，对一致性要求较高。处理：使用网络质量最好的机房存放数据，所有用户对全局共享数据的变更都被路由到这个机房。 3、消息层路由 异步调用时，如使用MQ进行调用，需要将消息路由到指定的机房。\n","permalink":"/posts/%E5%A4%A7%E5%9E%8B%E7%B3%BB%E7%BB%9F%E5%BA%94%E7%94%A8%E6%9E%B6%E6%9E%84%E5%AE%9E%E8%B7%B5/","summary":"\u003cp\u003e书籍链接：https://book.douban.com/subject/34782232/\u003c/p\u003e\n\u003cp\u003e主要针对第二章的全球区域化部署技术\u003c/p\u003e\n\u003ch2 id=\"1-总体架构\"\u003e1 总体架构\u003c/h2\u003e\n\u003ch4 id=\"基本原则\"\u003e基本原则\u003c/h4\u003e\n\u003cp\u003e\u003cimg alt=\"image-20230504173921620\" loading=\"lazy\" src=\"/imgs/image-20230504173921620.png\"\u003e\u003c/p\u003e\n\u003ch4 id=\"问题\"\u003e问题\u003c/h4\u003e\n\u003cp\u003e\u003cimg alt=\"image-20230504173955711\" loading=\"lazy\" src=\"/imgs/image-20230504173955711.png\"\u003e\u003c/p\u003e","title":"《大型系统应用架构实践》笔记：全球区域化部署与多层路由设计"},{"content":"长轮询在配置平台的应用 1. 配置平台简介 略\n2. 长轮询简介 传统的短轮询方式存在一个严重缺陷：程序在每次请求时都会新建一个HTTP请求，然而并不是每次都能返回所需的新数据。当同时发起的请求达到一定数目时，会对服务器造成较大负担。这时我们可以采用长轮询方式解决这个问题。\n长轮询的基本思想是在每次客户端发出请求后，服务器检查上次返回的数据与此次请求时的数据之间是否有更新，如果有更新则返回新数据并结束此次连接，否则服务器“hold”住此次连接，直到有新数据时再返回相应。而这种长时间的保持连接可以通过设置一个较大的HTTP timeout实现。在服务端消息推送方面，长轮询有着广泛的应用。\n3. 请求模型 3.1 同步请求模型 这是我们日常最常用同步请求模型，所有动作都交给同一个 Tomcat 线程处理，所有动作处理完成，线程才会被释放回线程池。\n如果业务需要较长时间处理，那么这个 Tomcat 线程其实一直在被占用，随着请求越来越多，可用 I/O 线程越来越少，直到被耗尽。这时后续请求只能等待空闲 Tomcat 线程，这将会加长了请求执行时间，或者直接被拒绝，客户端会报connect refused异常。\n如果客户端不关心返回业务结果，这时我们可以自定义线程池，将请求任务提交给线程池，然后立刻返回。\n3.2 异步请求模型 Servlet3 引入异步 Servelt 新特性，可以完美解决上面的需求。\n异步 Servelt 执行请求流程：\n将请求信息解析为 HttpServletRequest 分发到具体 Servlet 处理，将业务提交给自定义业务线程池，请求立刻返回，Tomcat 线程立刻被释放 当业务线程将任务执行结束，将会将结果转交给 Tomcat 线程 通过 HttpServletResponse 将响应结果返回给等待客户端 引入异步 Servelt3 整体流程如下：\n3.3 应用场景 1、增加系统吞吐量 拿Tomcat作为Servlet容器来说，无论是计算型请求还是IO型请求，都是交给Tomcat容器线程来建立连接和负责业务逻辑处理，如果将IO型请求或者RT（响应时间）比较高的请求业务逻辑处理，通过异步请求来实现，可以尽早地释放连接线程，业务逻辑交由业务线程池处理，那么连接线程池可以接收更多的请求，从而提高了系统吞吐量。\n2、服务端消息推送 消息推送，对于一些服务端发生变更，需要向客户端发送消息通知的场景，可以通过异步请求来实现。\n3.4 异步请求实现 1、 AsyncContext HttpServletRequest#startAsync 获取 AsyncContext 异步上下文对象 使用自定义的业务线程池处理业务逻辑 业务线程处理结束，通过 AsyncContext#complete 返回响应结果 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 ExecutorService executorService = Executors.newFixedThreadPool(10); @RequestMapping(\u0026#34;/hello\u0026#34;) public void hello(HttpServletRequest request) { AsyncContext asyncContext = request.startAsync(); // 超时时间 asyncContext.setTimeout(10000); executorService.submit(() -\u0026gt; { try { // 休眠 5s，模拟业务操作 TimeUnit.SECONDS.sleep(5); // 输出响应结果 asyncContext.getResponse().getWriter().println(\u0026#34;hello world\u0026#34;); log.info(\u0026#34;异步线程处理结束\u0026#34;); } catch (Exception e) { e.printStackTrace(); } finally { asyncContext.complete(); } }); log.info(\u0026#34;servlet 线程处理结束\u0026#34;); } 2、 DeferredResult Servlet3.0 提供了异步处理请求的特性，DeferredResult 是Spring基于 Servlet 3.0 对异步请求的支持实现，SpringMVC 3.2 之后引入新的类 DeferredResult，目的是对于请求提供异步处理方式，释放容器连接，支持更多的并发。或者基于它的超时机制来做一些长轮询相关的事情。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 ExecutorService executorService = Executors.newFixedThreadPool(10); @RequestMapping(\u0026#34;/hello_v1\u0026#34;) public DeferredResult\u0026lt;String\u0026gt; hello_v1() { // 设置超时时间 DeferredResult\u0026lt;String\u0026gt; deferredResult = new DeferredResult\u0026lt;\u0026gt;(7000L); // 异步线程处理结束，将会执行该回调方法 deferredResult.onCompletion(() -\u0026gt; { log.info(\u0026#34;异步线程处理结束\u0026#34;); }); // 如果异步线程执行时间超过设置超时时间，将会执行该回调方法 deferredResult.onTimeout(() -\u0026gt; { log.info(\u0026#34;异步线程超时\u0026#34;); // 设置返回结果 deferredResult.setErrorResult(\u0026#34;timeout error\u0026#34;); }); deferredResult.onError(throwable -\u0026gt; { log.error(\u0026#34;异常\u0026#34;， throwable); // 设置返回结果 deferredResult.setErrorResult(\u0026#34;other error\u0026#34;); }); executorService.submit(() -\u0026gt; { try { TimeUnit.SECONDS.sleep(5); deferredResult.setResult(\u0026#34;hello_v1\u0026#34;); // 设置返回结果 } catch (Exception e) { e.printStackTrace(); // 若异步方法内部异常 deferredResult.setErrorResult(\u0026#34;error\u0026#34;); } }); log.info(\u0026#34;servlet 线程处理结束\u0026#34;); return deferredResult; } 4. 长轮询的简易实现 服务端 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 @Slf4j @RestController public class LongPollingController { //模拟配置表 private HashMap\u0026lt;String, HashMap\u0026lt;String, Item\u0026gt;\u0026gt; configs = new HashMap\u0026lt;\u0026gt;(); //长轮询超时时间 private static final long TIMEOUT = 30 * 1000;//30 seconds //key：配置ID，value：DeferredResult private Multimap\u0026lt;String, DeferredResult\u0026lt;ResponseEntity\u0026lt;Item\u0026gt;\u0026gt;\u0026gt; deferredResults = Multimaps.synchronizedSetMultimap(HashMultimap.create()); //长轮询超时默认返回 private static final ResponseEntity\u0026lt;String\u0026gt; NOT_MODIFIED_RESPONSE = new ResponseEntity\u0026lt;\u0026gt;(HttpStatus.NOT_MODIFIED); /** * 初始化配置内容 */ @PostConstruct public void init() { HashMap\u0026lt;String, Item\u0026gt; config = new HashMap\u0026lt;\u0026gt;(); config.put(\u0026#34;key1\u0026#34;, new Item(\u0026#34;key1\u0026#34;, \u0026#34;value1\u0026#34;)); config.put(\u0026#34;key2\u0026#34;, new Item(\u0026#34;key2\u0026#34;, \u0026#34;value2\u0026#34;)); config.put(\u0026#34;key3\u0026#34;, new Item(\u0026#34;key3\u0026#34;, \u0026#34;value3\u0026#34;)); configs.put(\u0026#34;1\u0026#34;, config); } /** * 更新并发布配置内容 * * @param configId * @param key * @param value */ @RequestMapping(\u0026#34;/updateAndPublish/{configId}/{key}/{value}\u0026#34;) public void updateAndPublish(@PathVariable String configId, @PathVariable String key, @PathVariable String value) { HashMap\u0026lt;String, Item\u0026gt; config = configs.get(configId); if (config == null || !config.containsKey(key)) { return; } Item item = new Item(key, value); config.put(key, item); if (!deferredResults.containsKey(configId)) { return; } Collection\u0026lt;DeferredResult\u0026lt;ResponseEntity\u0026lt;Item\u0026gt;\u0026gt;\u0026gt; results = deferredResults.get(configId); Iterator\u0026lt;DeferredResult\u0026lt;ResponseEntity\u0026lt;Item\u0026gt;\u0026gt;\u0026gt; iterator = results.iterator(); while (iterator.hasNext()) { DeferredResult deferredResult = iterator.next(); deferredResult.setResult(new ResponseEntity\u0026lt;\u0026gt;(item, HttpStatus.OK)); } } /** * 长轮询接口 * @param configId * @return */ @GetMapping(\u0026#34;/longPolling/{configId}\u0026#34;) public DeferredResult\u0026lt;ResponseEntity\u0026lt;Item\u0026gt;\u0026gt; longPolling(@PathVariable String configId) { DeferredResult\u0026lt;ResponseEntity\u0026lt;Item\u0026gt;\u0026gt; deferredResult = new DeferredResult\u0026lt;\u0026gt;(TIMEOUT, NOT_MODIFIED_RESPONSE); deferredResult.onTimeout(() -\u0026gt; { log.info(\u0026#34;长轮询超时\u0026#34;); }); deferredResult.onCompletion(() -\u0026gt; { log.info(\u0026#34;调用完成, 返回内容：{} \u0026#34;, deferredResult.getResult()); deferredResults.remove(configId, deferredResult); }); deferredResults.put(configId, deferredResult); return deferredResult; } } 客户端 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public void pull() throws InterruptedException { OkHttpClient client = new OkHttpClient().newBuilder().readTimeout(35, TimeUnit.SECONDS).build(); Request request = new Request.Builder().url(\u0026#34;http://localhost:8081/longPolling/1\u0026#34;).get().build(); Response response; for (; ; ) { try { response = client.newCall(request).execute(); if (response.code() == 304) { log.info(\u0026#34;长轮询超时，重新发起请求\u0026#34;); } else if (response.code() == 200) { log.info(\u0026#34;收到通知，内容：{}\u0026#34;, response.body().string()); } } catch (IOException e) { log.info(\u0026#34;调用异常,即将重试,{}\u0026#34;,e.getMessage()); TimeUnit.SECONDS.sleep(5); } } } 5. 详细设计 5.1 集群问题 5.1.1 问题描述 ConfigClient：长轮询客户端，拉取配置。 ConfigService：长轮询服务端，提供拉取配置，发布配置接口。 AdminClient：管理后台客户端，发布配置。 ​\t由于配置服务需要集群部署，ConfigClient 和 AdminClient 在调用时可能负载均衡到不同的 ConfigService，而 DeferredResult 只保存在服务器本地，AdminClient发布配置通知不到客户端。\n5.1.2 解决 1、负载均衡 ConfigClient和AdminClient使用相同的负载均衡策略，通过对配置名进行Hash取余，使它们根据配置名定位到相同的服务器。\n2、广播 AdminClient发布配置时，将这个消息通知到每一个服务器，每个服务器收到消息后，判断当前没有监听这个配置，没有就不进行处理。至于通知的形式，可以采取MQ或者扫描MySQL表。\n5.2 消息丢失与重复 5.2.1 问题描述 1、消息丢失 发布的时候其中一台客户端网络闪断，导致接受不到更新通知，那么这个客户端将更新不了，除非在网络正常的时候再次发布。 长轮询需要客户端发起请求，在收到响应和发起下一次请求之间有一瞬间的时间间隙，如果在这个瞬间刚好发布，那么将通知不到客户端。 2、消息重复 在3.1集群问题中，如果采取了广播的形式，无论是MQ或者扫描数据库表都有可能会有消息的延迟，导致不同服务器会收到的通知的时间有先后。 5.2.2 NotificationId机制 请求远端服务，带上自己的配置ID以及notification信息\n服务端针对传过来的每一个configId和对应的notificationId，检查notificationId是否是最新的\n如果都是最新的，则保持住请求60秒，如果60秒内没有配置变化，则返回HttpStatus 304。如果60秒内有配置变化，则返回对应configId的最新notificationId, HttpStatus 200。\n如果传过来的notification信息中发现有notificationId比服务端老，则直接返回对应configId的最新notificationId, HttpStatus 200。\n客户端拿到服务端返回后，判断返回的HttpStatus\n如果返回的HttpStatus是304，说明配置没有变化，重新执行第1步\n如果返回的HttpStauts是200，说明配置有变化，针对变化的configId重新去服务端拉取配置。\n6.QA 6.1 为什么采用长轮询？ 1、实时性与效率的平衡：相比于传统的短轮询，长轮询通过在服务端“挂起”请求（使用 Servlet 3.0 的 DeferredResult），仅在数据发生变化或超时时才返回结果，避免了大量无效的空轮询对服务器 I/O 的消耗。\n2、资源占用低：通过异步处理机制，服务端不需要为每个连接阻塞线程，从而能够支撑更高并发的客户端连接（实测单机可达 8k+）。\n3、长轮询基于 HTTP 协议，对现有的负载均衡和防火墙更加友好，有更高的通用性和接入的简便性，考虑到客户端可能有来自各种各样的语言或者平台，而且 HTTP 对客户端来说接入成本相对较低。\n4、另一种方法是基于TCP实现的长连接，但需要对 socket 做保活，有两种方式：\n保活方案 介绍 优点 缺点 传输层 TCP KeepAlive TCP 连接闲置一段时间后，通过发送数据包（ack包）等待回复确认。几次都没有回复的话，认为断开。 使用简单，可以利用 TCP 协议提供的检活。 1、KeepAlive 的目的是探测连接是否存在，无法检测能不能发送数据，比如服务器由于负载过大到处无法响应请求，应用层的的原因导致数据无法传输，但是连接还是正常。 2、如果TCP连接的一端断网或者断电，应用层并不知晓，继续发送数据，这个数据包的优先级是高于 KeepAlive 的数据包，因此这个 KeepAlive 包是无法发送出去的，只有在长时间的重传失败后，我们才能判断连接断开，这段长时间，应用及其容易产生业务逻辑BUG。 应用层 HeartBeat 客户端每隔一小段时间向服务器发送一个数据包，通知服务器自己仍然在线。 自己实现检测机制，有更高的灵活性。 缺点就是要应用层自己实现，自己利用 socket 编程实现。 所以我们需要自己在应用层实现心跳机制，更好的方式是接入 im 系统，但是作为一个基础平台性的服务，应该尽量减少依赖。\n6.2 单机能支持多少个客户端接入 理论上取决于服务器内存的大小和系统对最大连接数的限制。在本机测试时，连接数到 3000 会 OOM，因为 JVM 默认最大堆为 512m，调为 1.5G 后，可以支撑8k 连接（Jmeter线程限制单机4k）。\n","permalink":"/posts/%E9%95%BF%E8%BD%AE%E8%AF%A2%E5%9C%A8%E9%85%8D%E7%BD%AE%E5%B9%B3%E5%8F%B0%E7%9A%84%E5%BA%94%E7%94%A8%E5%AE%9E%E8%B7%B5/","summary":"\u003ch1 id=\"长轮询在配置平台的应用\"\u003e长轮询在配置平台的应用\u003c/h1\u003e\n\u003ch2 id=\"1-配置平台简介\"\u003e1. 配置平台简介\u003c/h2\u003e\n\u003cp\u003e略\u003c/p\u003e\n\u003ch2 id=\"2-长轮询简介\"\u003e2. 长轮询简介\u003c/h2\u003e\n\u003cp\u003e传统的短轮询方式存在一个严重缺陷：程序在每次请求时都会新建一个HTTP请求，然而并不是每次都能返回所需的新数据。当同时发起的请求达到一定数目时，会对服务器造成较大负担。这时我们可以采用长轮询方式解决这个问题。\u003c/p\u003e","title":"长轮询在配置平台的工程化实践与性能权衡"},{"content":"从数据结构理解 MySQL 联合索引 前言 索引的本质是一种通过特定数据结构来优化数据检索速度的机制。是我们开发岗接触 MySQL 最重要的概念之一，与我们的应用开发息息相关。\n结合应用思考 1）在语料平台中的 Item 表中，假设我们的目标是快速搜索 key，只考虑完全匹配的情况下，如何建立索引？\n索引的字段（业务驱动、是否要覆盖） 索引顺序（最左前缀匹配、高选择性或离散性） 2）考虑大批量写的情况下，怎样的索引可以减少性能损耗？\n3）高选择性的字段是不是一定排最前？\n1、MySQL 索引的数据结构种类 索引类型 存储引擎 底层数据结构 特点与用途 普通索引 / 主键 / 唯一索引 InnoDB B+Tree 默认且绝对主流。聚簇索引叶子节点存数据，二级索引叶子节点存主键。 普通索引 / 主键 / 唯一索引 MyISAM B+Tree 非聚簇索引，叶子节点存数据行的物理地址。 哈希索引 Memory Hash Table 默认类型，等值查询极快，用于临时表和缓存。 自适应哈希索引 InnoDB Hash Table 内部自动功能，自动缓存热点数据，加速等值查询。 全文索引 (FULLTEXT) InnoDB, MyISAM 倒排索引 解决 LIKE ‘%…%’ 性能问题，用于全文检索。 空间索引 (SPATIAL) InnoDB, MyISAM R-Tree 专用于地理空间数据类型查询。 其他索引数据结构：Skip List跳表、红黑树、BitMap 位图、Trie 前缀树\n2、B+树 B+Tree 是多路平衡搜索树，一种专为磁盘等外部存储设备设计的、多路的、平衡的搜索树。它的主要目标是减少磁盘 I/O 次数，从而高效地支持大规模数据的增删改查，尤其是范围查询。\nhttps://www.cs.usfca.edu/~galles/visualization/BPlusTree.html\n3、从 B+树看联合索引设计原则 最左前缀匹配原则 高选择性在前 快速过滤：在B+树的早期层次就能大幅缩小搜索范围\n减少I/O：避免扫描大量无关数据页\n提高缓存效率：集中访问少量相关页面\n无序字段放后 空间局部性：相关数据分散存储，缓存效率低\n时间局部性：随机访问模式，无法利用预读机制\n结构局部性：频繁页分裂破坏树的平衡性\n等值查询\u0026amp;范围查询顺序 等值条件优先：把 =、IN 这类高过滤条件放在联合索引前部，尽量先缩小搜索范围。 范围条件靠后：\u0026gt;、\u0026lt;、BETWEEN、LIKE 'x%' 一旦命中范围，后续列通常难以继续高效利用索引。 排序字段紧随其后：如果需要 ORDER BY，尽量让排序字段仍处于可用前缀中，减少 filesort。 覆盖索引原则 尽量让查询所需字段都在二级索引中，避免回表。 对高频读接口，覆盖索引通常是最稳定的收益项。 覆盖并不等于“越多列越好”，要结合写放大和索引体积做权衡。 宽度权衡原则 联合索引列越多、列越宽，索引页能容纳的记录越少，树高更容易上升。 写入时需要维护更多索引页，页分裂与随机 I/O 成本更高。 建议优先放入高价值查询列，低频列通过回表获取，整体通常更划算。 4、应用 回到应用场景，语料平台上的 corpusId、lang、customize、key 这四个字段的索引顺序应该如何排？\n选择性又高到低排序：key \u0026gt; corpusId \u0026gt; lang \u0026gt; customize（读性能最佳）\n随机性字段：key \u0026gt; lang \u0026gt; customize \u0026gt; corpusId （写性能最差）\nkey 作为随机、高选择性字段，如何权衡？\n可以按“读写目标优先级”做拆分：\n读优先：(key, corpusId, lang, customize)，等值查 key 路径最短。 写优先：(corpusId, lang, customize, key)，把随机性更强的 key 放后，降低页分裂概率。 折中策略：保留一个主业务联合索引，再根据核心慢查询补一个窄索引，避免一次上太多大索引。 5、实验 1) 索引顺序：key,lang,customize,corpusId a. 删除一个项目环境的所有语料\nb. 导入 8w 条语料\n2) 索引顺序：corpusId,lang,customize,key a. 删除一个项目环境的所有语料\nb. 导入 8w 条语料\n实验结论 两组索引顺序在导入场景下表现存在差异，核心原因是随机字段位置不同。 当 key 前置时，写入更容易触发随机写和页分裂，批量导入成本更高。 当 corpusId 前置、key 后置时，写入局部性更好，更符合批量写场景。 这也说明“高选择性字段不一定必须放最前”，要结合查询模式与写放大综合决策。 6、BTW，简单说下语料平台写场景下的并发控制机制 问题 如何控制并发，防止修改覆盖，防止死锁？\n批量新增或更新 1）方式一：ON DUPLICATE KEY UPDATE\n适合幂等写入，语义简单，吞吐稳定。 需要关注唯一索引冲突路径的锁竞争，批量并发时可能出现死锁。 2）方式二：应用层 SELECT + INSERT/UPDATE\n业务可控性更强，可插入更多校验逻辑。 网络往返和代码复杂度更高，并发窗口更难完全收敛。 锁控制 并发读写有 MySQL 的 MVCC 机制，写冲突需要我们处理：\n1、乐观锁 单个写：版本乐观锁（CAS CompareAndSwap，无锁并发）\n2、悲观锁 批量写：粒度尽量小，产品-\u0026gt; 项目 -\u0026gt;环境\n死锁问题在批量并发写时无法完全避免，具体原因看这篇 MySQL 死锁问题的系统化排查与并发优化。对 corpusId 加锁可以避免修改覆盖，但不能彻底消除死锁风险。可以捕获死锁异常后快速失败并重试，重试次数设置上限。\n","permalink":"/posts/%E4%BB%8E%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E8%A7%86%E8%A7%92%E7%90%86%E8%A7%A3mysql%E8%81%94%E5%90%88%E7%B4%A2%E5%BC%95/","summary":"\u003ch1 id=\"从数据结构理解-mysql-联合索引\"\u003e从数据结构理解 MySQL 联合索引\u003c/h1\u003e\n\u003ch3 id=\"前言\"\u003e前言\u003c/h3\u003e\n\u003cp\u003e索引的本质是一种\u003cstrong\u003e通过特定数据结构来优化数据检索速度\u003c/strong\u003e的机制。是我们开发岗接触 MySQL 最重要的概念之一，与我们的应用开发息息相关。\u003c/p\u003e\n\u003ch4 id=\"结合应用思考\"\u003e结合应用思考\u003c/h4\u003e\n\u003cp\u003e\u003cimg alt=\"image-20250929105634261\" loading=\"lazy\" src=\"/imgs/image-20250929105634261.png\"\u003e\u003c/p\u003e\n\u003cp\u003e1）在语料平台中的 Item 表中，假设我们的目标是快速搜索 key，只考虑完全匹配的情况下，如何建立索引？\u003c/p\u003e","title":"从数据结构理解 MySQL 联合索引"},{"content":"背景 在我们的一个 Web 课堂系统中，开启课堂会调用接口发送邀请链接公告，后台要限制一个课堂只能发送一次。一旦重复发送，用户会看到多条重复公告，直接影响课堂体验。\n现象 同一个课堂出现了多条公告记录\n解析 直接看代码，使用了分布式锁进行加锁校验，锁等待时间为0，也就是获取不到锁立刻返回。锁超时时间为课堂的超时时间，为12小时。\n结论先行 根因并不是并发冲突，而是锁本身可重入：两个请求虽然间隔较长，但恰好复用了同一个应用线程，导致同线程二次获取锁成功，最终重复发送公告。\n1、猜想一：发送公告有重试逻辑，有可能是重试逻辑有问题导致的。\n在Google的接口调用中，使用动态代理进行接口的统一处理，如果调用Google接口返回了401状态码，就需要刷新AccessToken然后重新调用，如果刷新AccessToken失败就需要用户重新授权。\n初步看代码逻辑没有问题，我们可以查看应用日志，如果是重试的问题导致的，那么这两次发送公告都应该是同一个请求，也就会是同一个TraceId，可以看到这两次发送成功是属于两个不同的请求，TraceId也是不一样的，那么可以排除这个猜想。\n2、猜想二：锁没有生效，有并发问题。\n我们使用 Jmeter 对这个接口进行压力测试，开启100个线程，结果是只有一个线程返回成功，其他线程都返回10304业务状态码，表示已经发送过公告了，而且始终没办法复现，返回成功状态码的请求始终只有一个。\n3、猜想三：第一次获取锁成功了，后面删除了锁，所以第二次获取锁又成功了\n查看Redis数据库也能看到锁对应的数据，TTL剩余9379秒，通过时间计算，可以得出这个Key的设置时间为9:49，跟第一次请求的时间是一致的，所以第二次获取锁的时候，这个锁是存在的。\n4、猜想四：从以上的现象可以看出，这不是并发问题导致的，而且是属于不同的请求，两个请求基本没有什么关联，间隔也有8分钟左右。第二次获取锁的时候，锁的key也确实存在，确实是重复获取到了锁，那么基本确定只有一种可能，这是可重入锁，如果两个请求是同一个线程，那么就能重复获取到锁。查看应用日志，可以发现这两个请求确实属于同一个线程。\n验证猜想：将Tomcat的工作线程数设置为1，这样每次请求都会是同一个请求，结果是每次请求都发送成功。\n那么压力测试为什么复现不了这个问题呢，原因是压力测试时，100个请求基本同一时间发出，而Tomcat默认的最大线程池数量为200个线程，所以这100个请求都属于不同的线程。\n解决方案 改为不可重入的原子加锁方式，使用 SET key value NX EX（或 Lua 脚本）一次性完成“加锁 + 过期时间设置”。同时补充了并发回归测试，重点覆盖同线程复用和高并发压测场景，避免问题再次出现。\n附 分布式锁加锁流程图\n","permalink":"/posts/%E5%88%86%E5%B8%83%E5%BC%8F%E9%94%81%E5%AE%9E%E6%88%98%E8%B8%A9%E5%9D%91%E4%B8%8E%E9%81%BF%E5%9D%91/","summary":"\u003ch2 id=\"背景\"\u003e背景\u003c/h2\u003e\n\u003cp\u003e在我们的一个 Web 课堂系统中，开启课堂会调用接口发送邀请链接公告，后台要限制\u003cstrong\u003e一个课堂只能发送一次\u003c/strong\u003e。一旦重复发送，用户会看到多条重复公告，直接影响课堂体验。\u003c/p\u003e","title":"分布式锁实战踩坑与避坑"},{"content":"这篇文章整理了中间件相关知识的思维导图，便于搭建整体认知框架。\n","permalink":"/posts/%E4%B8%AD%E9%97%B4%E4%BB%B6%E6%80%9D%E7%BB%B4%E5%AF%BC%E5%9B%BE/","summary":"\u003cp\u003e这篇文章整理了中间件相关知识的思维导图，便于搭建整体认知框架。\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"中间件思维导图\" loading=\"lazy\" src=\"/imgs/%E4%B8%AD%E9%97%B4%E4%BB%B6.svg\"\u003e\u003c/p\u003e","title":"思维导图3：中间件"},{"content":"这篇文章整理了 Java 相关知识的思维导图，便于快速回顾核心知识点。\n","permalink":"/posts/java%E6%80%9D%E7%BB%B4%E5%AF%BC%E5%9B%BE/","summary":"\u003cp\u003e这篇文章整理了 Java 相关知识的思维导图，便于快速回顾核心知识点。\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"Java思维导图\" loading=\"lazy\" src=\"/imgs/java.svg\"\u003e\u003c/p\u003e","title":"思维导图2：Java"},{"content":"这篇文章整理了计算机基础知识体系的思维导图，便于整体回顾与查漏补缺。\n","permalink":"/posts/%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%9F%BA%E7%A1%80%E6%80%9D%E7%BB%B4%E5%AF%BC%E5%9B%BE/","summary":"\u003cp\u003e这篇文章整理了计算机基础知识体系的思维导图，便于整体回顾与查漏补缺。\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"计算机基础思维导图\" loading=\"lazy\" src=\"/imgs/%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%9F%BA%E7%A1%80.svg\"\u003e\u003c/p\u003e","title":"思维导图1：计算机基础知识体系"}]