公司排查一个异步任务异常,在 ELK 里搜对应的 requestId,搜出来的日志只有主线程的。异步线程里的日志一条都没搜到——因为 MDC 里的 requestId 根本没有传到异步线程里。异步线程打出来的日志,requestId 是空的。一整条调用链在异步这个节点断掉了。 问题很直接:@Async 用一个线程池执行任务,但 MDC 是基于 ThreadLocal 的,ThreadLocal 不会自动从主线程传到子线程。异步线程里 MDC.get("requestId") 返回 null,日志里就少了这条 key。 今天聊聊怎么用 TransmittableThreadLocal(TTL)把主线程的上下文透传到异步线程里。 为什么 ThreadLocal 不行,TTL 就行 ThreadLocal 的一个基本特性:子线程拿不到父线程的值。 主线程里 MDC.put("requestId", "abc123"),然后 @Async 开一个新线程去执行,新线程里 MDC.get("requestId") 是 null。 一个直觉的解法是调用线程池时手动传参数。把 requestId ....
批处理任务内存 OOM:一次性加载百万数据?流式查询+分片处理+游标分批提交
公司的日报任务每天半夜 2 点跑,统计前一天所有订单的销售额。代码逻辑很简单——SELECT * FROM orders WHERE date = '2026-06-30',然后 for 循环逐条累加。平时几万条订单没问题,大促那天 300 万条订单,SELECT * 把 300 万行全部加载到了 JVM 堆里,直接 OOM。运维半夜被电话叫醒,手动分批 SQL 跑了一个小时才完成。 批处理的 OOM 跟文件下载的 OOM 原因一样——把全部数据一把梭进内存。 数据库能存那么大的表,不代表你的 JVM 堆能装下它。今天聊聊三种让批处理不再 OOM 的方法:流式查询、分片处理、游标分批提交。 问题:JDBC 默认是一次性加载全部结果集 MySQL 的 JDBC 驱动默认行为是:执行 SELECT 后,把所有结果行都拉到客户端内存里。你的代码写的虽然是 while (rs.next()),但在 next() 之前,数据已经在内存里了。 JDBC 默认行为: → 执行 SELECT → 100 万行全部加载到 ResultSet(JVM 堆) → while (rs.next()) 遍历....
SpringBoot + 数据库连接池假死排查:HikariCP 获取连接超时?连接泄漏检测 + 线程 Dump 分析
一、问题背景:线上故障的惊魂时刻 某日下午,线上系统突然出现大量接口超时,错误日志显示: Could not get JDBC Connection; nested exception is java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms. 更诡异的是:数据库服务器正常,网络连接正常,应用日志中却没有任何 SQL 执行超时。 这正是数据库连接池"假死"现象的典型特征。 二、核心概念:HikariCP 连接池机制 2.1 连接池工作原理 ┌────────────────────────────────────────────────────────────────┐ │ HikariCP 连接池架构 │ ├────────────────────────────────────────────────────────────────┤ │ │ │ 应用线程 │ │ │ │ │ ▼ │ │ ┌───....
SpringBoot + 分布式锁续约防过期:长任务执行中途锁失效?WatchDog 自动续期机制
一、问题背景:分布式锁失效的隐患 在分布式系统中,分布式锁是保证数据一致性的重要手段。最常见的实现方式是基于 Redis 的分布式锁,通常设置一个过期时间来防止死锁。 然而,在实际生产环境中,我们遇到了这样的问题: 2024-01-15 10:30:15 ERROR - 订单处理异常:库存扣减成功,但订单状态更新失败 2024-01-15 10:30:15 WARN - 分布式锁已过期释放,其他线程获取了锁 2024-01-15 10:30:16 INFO - 另一个线程开始处理同一订单... 问题分析: 锁的过期时间设置为 30 秒 订单处理任务实际执行了 45 秒 锁在第 30 秒自动过期释放 其他线程获取锁并开始处理 前一个线程继续执行,导致数据不一致 这就是典型的分布式锁续约问题:长任务执行中途锁失效,引发并发安全问题。 二、核心概念:分布式锁与 WatchDog 机制 2.1 分布式锁的基本原理 ┌────────────────────────────────────────────────────────────────┐ │ Redis 分布式锁工作流程 │ ├....
Redis 大 Key 热删除阻塞主线程:DEL 命令卡顿?UNLINK 异步清理 + 分片扫描方案
一、问题背景:生产环境的"定时炸弹" 凌晨 3 点,线上 Redis 突然出现大量请求超时,监控告警疯狂刷屏。排查发现: Redis 主线程 CPU 使用率飙升至 100% 大量命令堆积,响应时间超过 10 秒 应用服务出现雪崩,数据库连接池打满 根因分析:运维人员执行了一条 DEL big_hash_key 命令,该 Key 包含 500 万条字段,Redis 主线程需要遍历所有字段并逐一释放内存,导致阻塞长达 15 秒。 这就是 Redis 大 Key 删除的"阻塞陷阱"——看似简单的删除操作,背后隐藏着巨大的性能风险。 二、核心概念:DEL 与 UNLINK 的本质区别 2.1 DEL 命令的工作机制 DEL big_key 执行过程: ┌────────────────────────────────────────────────────────────────┐ │ Redis 主线程 │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ 1. 查找 key 的内存结构 │ │ │....
SpringBoot + MySQL 覆盖索引优化:回表查询拖慢接口?联合索引 + 延迟关联实战
一、问题背景:电商列表接口的性能瓶颈 某电商平台的商品列表接口响应时间突然从 100ms 飙升至 3 秒,用户体验急剧下降。排查发现: 慢查询日志:商品列表查询耗时超过 2.8 秒 EXPLAIN 分析:Extra 列显示 Using where; Using filesort 执行计划:虽然命中了索引,但存在大量回表操作 根因分析: 原始 SQL: SELECT id, name, price, category, status, created_at FROM products WHERE category = 'electronics' AND status = 1 ORDER BY created_at DESC LIMIT 10; 这条看似简单的查询存在两个严重问题: 回表查询:索引只包含 category 和 status,查询其他字段需要回到主键索引查找 文件排序:ORDER BY created_at 无法利用索引,导致额外的排序开销 二、核心概念:理解覆盖索引与回表 2.1 索引结构与回表机制 InnoDB 索引结构示意: ┌───────────────....
SpringBoot + Canal 数据同步丢失补偿:MySQL binlog 位点跳跃导致 ES 数据不全?位点校验 + 自动追平
一、问题背景:Canal 同步的"黑洞效应" 你是否遇到过这样的场景: 使用 Canal 监听 MySQL binlog 同步数据到 Elasticsearch 系统运行一段时间后,发现 ES 中的数据与 MySQL 不一致 某些时间段的数据完全丢失,却找不到任何错误日志 这就是典型的binlog 位点跳跃问题。Canal 在解析 binlog 时,可能因为网络抖动、服务重启、位点记录失败等原因,跳过部分 binlog 事件,导致数据同步丢失。 真实案例:某电商平台使用 Canal 同步商品数据到 ES,在一次服务重启后发现最近2小时的商品库存更新丢失,导致用户看到的库存与实际库存不一致,造成了严重的业务损失。 二、核心概念:Canal 位点管理机制 2.1 Canal 位点存储方式 存储方式描述优点缺点适用场景 MetaStore内存存储性能高重启丢失测试环境 ZookeeperZK 存储可靠依赖 ZK生产环境 MySQL数据库存储可靠依赖 DB生产环境 File文件存储简单单节点小规模 2.2 位点跳跃的常见原因 ┌────────────────────────....
Spring Cloud Gateway 限流降级插件冲突:多个 Filter 同时拦截报错?责任链优先级调度+冲突检测
公司的网关配了限流和降级两个 Filter。一次大促,流量触发了限流——RequestRateLimiter 返回了 429。按说限流拦截了就不该再走后续的 Filter 了。但 Hystrix 降级 Filter 也触发了,返回了 503。两个 Filter 同时想写响应,冲突了——客户端收到的状态码是 429,但 body 是 503 的降级 JSON。前端判断逻辑直接崩了。 Spring Cloud Gateway 的 Filter 是按责任链模式执行的,一个请求依次经过所有 Filter。问题在于:当一个 Filter 已经决定要拦截并返回了,它之后的 Filter 不知道前面已经拦截了,还在继续执行。 今天聊聊怎么给 Filter 设明确的优先级,让它们在冲突时按规则来,而不是同时抢着写响应。 责任链里的冲突怎么来的 Gateway 的 Filter 链执行顺序由 @Order 注解决定。默认情况下,Spring Cloud Gateway 的内置 Filter 顺序大致是: NettyRoutingFilter(-1) → 自定义 Filter A(0) → 自定义 F....
SpringBoot + 本地消息表与主库写入不一致:分布式 ID 冲突导致插入失败?雪花算法+唯一索引防重
公司用本地消息表做分布式事务的最终一致性。订单库 INSERT 了订单,消息表 INSERT 了一条"待发送"记录。偶尔消息表 INSERT 失败报唯一键冲突——两个不同的服务实例生成了同样的消息 ID。订单已经入库了,消息没有入库,订单的状态就永远卡在"待发送"。对账的时候才发现有几十笔订单"消息未下发"。 问题出在 ID 生成方式上。他们用的是数据库自增 ID——但在分布式环境下,两个实例各写各的订单库,各自生成了相同的自增 ID(比如都是从 1 开始),然后在消息表里产生了冲突。 今天聊聊怎么用雪花算法生成全局唯一 ID,配合唯一索引做防重,让消息表的 INSERT 不再因为 ID 冲突而失败。 数据库自增 ID 为什么不行 本地消息表的标准做法是: 1. 订单库 INSERT 订单(ID=1001) 2. 消息表 INSERT 消息(msg_id=1001, 关联订单 1001) 3. 定时任务轮询消息表,发送消息 4. 发送成功 → UPDATE 消息状态 = SENT 但如果你有两个服务实例,各自的订单库都有自增 ID=1001。两条订单对应同一个 msg_id=10....
Spring Cloud Gateway CORS 配置混乱:多服务重复定义跨域易冲突?网关统一拦截+动态响应头注入
公司有 30 多个微服务,每个服务的后端开发都自己配了一份 CORS。结果前端调接口时,有的服务返回了 Access-Control-Allow-Origin,有的没返回,有的返回了 *,有的返回了具体域名。浏览器一看响应头不一致——有的 OPTIONS 预检过了,有的直接被 CORS 策略拦住,前端报了满屏的 blocked by CORS policy。查了半天才发现是服务 A 配了 allowedOrigins(*, example.com),服务 B 配了 allowedOrigins(example.com),服务 C 根本没配。 CORS 应该是网关的活,不是每个微服务的活。所有跨域请求都先到网关,网关统一返回 CORS 响应头,后端服务根本不应该感知到 CORS 的存在。 为什么 CORS 不应该散落在微服务里 CORS 本质是浏览器的一个安全机制——它确保一个域名的脚本不能随意访问另一个域名的资源。网关是所有请求的入口,天然适合做 CORS 的"守门员"。 散落在各个微服务的问题: 配置不一致 —— 30 个服务 30 份 CORS 配置,改一个域名要改 30 个....
SpringBoot 微服务优雅停机失败:K8s 滚动更新丢请求?PreStop 钩子+连接排空机制实战
公司 K8s 每次发版都有几个 502。查了监控,规律很明显——总是在旧 Pod 被 kill 的那几秒。原来是 K8s 滚动更新的时候,先发 SIGTERM 给旧 Pod,然后立刻把流量切到新 Pod。但旧 Pod 上还有正在处理的请求——SIGTERM 一来,进程直接退,请求半途而废。前端就 502 了。 Spring Boot 2.3 以后支持优雅停机,但光配 server.shutdown=graceful 还不够。K8s 的流量切换和 Pod 停机之间有时间差——你得让 Pod 在被 kill 之前有足够时间把正在处理的请求跑完。 问题拆解:K8s 发版一分钟内发生了什么 K8s 滚动更新流程: T+0s 新 Pod 创建,等待就绪 T+5s 新 Pod Ready → 加入 Service Endpoint T+5s K8s 发 SIGTERM 给旧 Pod T+5s 旧 Pod 上的请求还在跑 → 但 Service 已经不分配新请求了 T+5s 旧 Pod 收到 SIGTERM → Spring 开始优雅停机 ├─ 不再接受新请求 ├─ 等待正在处理的请求完成(30....
WebSocket连到65535条突然全断了,文件描述符耗尽不是Bug是物理极限
我们做一个实时行情推送系统,WebSocket 长连接撑到 65535 条的时候突然全断。新连接报 Too many open files,老连接陆续被操作系统干掉,连 SSH 都登不上去了。 运维以为是 DDoS,查了半天发现攻击源是自己的业务流量——推送服务每一条 WebSocket 连接占用一个文件描述符(fd),65535 条连接刚好打满一台机器默认的 fd 上限。 Linux 里一切皆文件——Socket 是文件,管道是文件,连 tail -f 也要一个 fd。WebSocket 每维持一条连接就吃掉一个 fd。 单机理论上限 65535,但系统进程还要用(SSH、日志、数据库连接),实际留给业务的大概 60000 个连接就到头了。 这意味着什么?单机 WebSocket 能承载的用户数,被文件描述符硬编码了。 不调系统参数,加到 60000 个连接就等着挂。 一、先调系统级:把 fd 上限打开 # 系统级硬上限 echo "fs.file-max = 2000000" >> /etc/sysctl.conf sysctl -p # 进程级软硬限制 echo "....
消费者挂了半小时,RocketMQ里堆了800万条消息,恢复后消费了6个小时
那天下午,库存服务一次 fullGC 把消费者线程全停了。GC 持续了 28 分钟,消费者一直没心跳,RocketMQ 以为它还活着就没触发重平衡。等 GC 结束消费者恢复,队列里已经堆了 800 万条订单消息。 按正常消费速度,一个消费者每秒 200 条,800 万条要 11 个小时才能消化完。下游的物流、短信、积分服务全在等这条消费者的结果——11 个小时的延迟等于业务停摆。 这不是"能不能消费完"的问题。这是"等你消费完,用户早就不关心了"的问题。 我们当时做了一个临时救急:在不停原有消费者的前提下,多开了 10 个临时订阅组来瓜分积压。 一、为什么不能直接加消费者实例 第一反应是给原消费者组多加几个 Pod。但因为原有消费者的队列分区是固定的——假设 8 个 MessageQueue,原消费者组 8 个 Pod,每个 Pod 独占 1 个 Queue,已经分配好了。 直接加 Pod 到同一个组里没用——RocketMQ 是 Queue 粒度分配,8 个 Queue 最多 8 个消费者实例。加第 9 个 Pod 它会被晾在那,一个 Queue 也分不到。 也不能直接增加 Que....
一个 if-else 写了 800 行,产品让我加一个条件我加了三天
新来的同事第一次接手营销规则模块,打开 CouponStrategy.java 沉默了五分钟。 800 行,全是 if-else。 if ("FULL_REDUCTION".equals(couponType)) { if (orderAmount >= 100) { if ("VIP".equals(userLevel)) { if (isFirstOrder) { discount = orderAmount * 0.7; } else { discount = orderAmount * 0.8; } } else { if (isFirstOrder) { discount = orderAmount * 0.85; } else { discount = orderAmount * 0.9; } } } else { if ("VIP".equals(userLevel)) { discount = 5; } else { discount = 0; } } } else if ("DISCOUNT".equals(couponType)) { // 又 200 行 }....
Kafka消费者重启后重复处理了80万条消息,库存直接扣成负数
凌晨一点,库存服务报警:有 30 个商品库存变成了负数。扣成了 -500、-1200,离谱的是这些商品白天就卖完了。 查了半小时,线索指向凌晨零点的一次容器滚动发布。新 Pod 启动后重新消费,从上次提交的 offset 开始读。但问题出在——上次提交的 offset 是 3 小时前的。 日志里拉出一条关键记录: 2026-06-21 00:16:23.456 WARN o.a.k.c.c.i.ConsumerCoordinator - Auto offset commit failed for group inventory-consumer: Connection to node -1 (broker/192.168.1.10:9092) failed due to network error 凌晨那次提交因为网络抖动失败了。Kafka 消费者没报错继续消费,下一个 commit 周期也没重试。直到滚动重启时,消费者重新从上一个成功提交的 offset 开始读——等于把这中间的 80 万条消息重新处理了一遍。 库存又扣了一次。 一、自动提交的陷阱 绝大多数 Kafka 消费者是....
