公司有 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 消费者是....
两个运营同时改了同一个规则,后保存的把先保存的覆盖了
有次运营跑过来问我:"我昨天配的满减规则怎么没了?活动已经上线了,用户下单也没用券。" 查了一下操作日志,真相很简单:下午 3 点,运营 A 打开了规则编辑页,改了满减门槛从 100 降到 80。改完她没立刻保存,先去开会了。下午 3:15,运营 B 也打开同一个规则,把适用商品从"全场"改成了"指定分类",保存。 下午 4 点,运营 A 开完会回来点了保存——她手里还是下午 3 点的旧版本,里面"适用商品"还是"全场"。她一保存,运营 B 改的"指定分类"被覆盖回去了。 这就是典型的并发编辑覆盖。QLExpress 规则引擎本身只管执行规则,不管谁在什么时候改了规则。多人同时操作的场景下,最后一个保存的人赢——前一个人的修改悄无声息地丢了。 一、为什么这个问题比看起来严重 如果只是规则配错了,运营发现后可以改。但并发覆盖的可怕之处在于——你根本不知道丢了什么。 运营 A 保存后,规则内容是 A 想要的样子,B 的修改完全消失。没有报错,没有提示冲突,没有操作日志记录 B 的修改被覆盖了。B 可能三天后才发现自己配的东西没生效,中间已经发了上万张券。 还有一个隐患:如果 A 和 B ....
规则引擎每改一次就加载一次类,Metaspace默默涨到1G把服务打挂了
凌晨四点,一台订单服务的 Pod 重启了三次。 不是 OOM killer,是老年代也正常,JVM 挂的时候堆才用了 2G(最大 4G)。诡异的是 Metaspace 占了 1.2G,而且每次重启后不到两小时又回到 1.2G。 翻 Metaspace 的内存分布,Class Count 已经飙到 15 万。正常一个 Spring Boot 服务也就 1-2 万个类。这多出来的十几万个类哪来的? 顺着加载的类名找过去——全部是 com.alibaba.qlexpress.ExpressionRunner 加上不同的数字后缀: com.alibaba.qlexpress.ExpressionRunner$1 com.alibaba.qlexpress.ExpressionRunner$2 com.alibaba.qlexpress.ExpressionRunner$3 ... com.alibaba.qlexpress.ExpressionRunner$154327 QLExpress 每次执行 QLExpressRunner.execute() 时,会把规则脚本编译成字节码,然后通过一....
一个导出接口把整台机器CPU打满,查了半天是JSON序列化
有次运维群里收到报警:一台 8 核 16G 的服务 CPU 突然飙到 100%,持续 3 分钟没下来。 上去 top -H 一看,GC 线程没跑,业务线程没打满。占了 700% CPU 的是 tomcat 的 http-nio-8080-exec——就一个线程。 这就邪门了。一个请求,一个线程,吃的 CPU 顶得上 7 个核。 顺着线程 dump 看下去,热方法不是业务逻辑,不是数据库查询,是 com.fasterxml.jackson.core.json.UTF8JsonGenerator——Jackson 的序列化方法。 翻了一下接口参数:前端传了 pageSize=100000。数据库一条 SELECT * FROM order_history WHERE ... 跑出来 10 万行,MyBatis 映射完开始在 controller 里序列化成 JSON 写回 Response。 10 万条订单记录,每条约 80 个字段,序列化完 200MB 的 JSON。Jackson 在单线程里逐条序列化,边序列化边往内存里攒——CPU 不炸才怪。 一、全量序列化为什么这么耗CPU 很多....
线程池用了LinkedBlockingQueue没设容量,把整台机器搞OOM了
凌晨运维群炸了,一条告警:订单服务响应超时率飙到 80%,紧接着 JVM 挂掉,Pod 重启。 上去一看,OOM 前老年代被打满了。heap dump 里最大的对象是一个 LinkedBlockingQueue,里面堆了 300 多万个任务对象,光队列就占了将近 2G。 翻代码,线程池是这么配的: ExecutorService executor = new ThreadPoolExecutor( 10, // corePoolSize 20, // maximumPoolSize 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>() // ← 没设容量,默认 Integer.MAX_VALUE ); 10 个核心线程忙不过来的时候,任务进了队列。队列没有上限,上游的定时任务每 10 毫秒投递一个任务,半小时就塞了 300 万个。 线程在消费,队列在膨胀——消费速度跟不上生产速度,OOM 只是时间问题。 这就是 LinkedBlockingQueue 最坑人的地方:默认构造是无界的。 一、为什么无界队列是定时炸弹 new L....
100个微服务100种日志格式:排一次故障要开10个面板,直到统一了
去年我们团队跑了差不多半年微服务,拆分得挺爽——订单、支付、库存、物流、用户中心,每个服务独立开发独立部署。架构图上画得漂漂亮亮。 直到有一次线上故障,把我整破防了。 一个下单请求超时,从网关一路追下去:Gateway → 订单服务 → 库存服务 → 支付服务 → 回调通知。五个服务,五个开发者写的日志,五种不同的格式。 订单服务的日志长这样: 2026-06-15 14:23:11.456 [http-nio-8080-exec-3] INFO c.o.s.OrderService - 订单创建成功,订单号: ORD20260615001 库存服务的日志长这样: [INFO] 2026/06/15 14:23:12.789 - StockServiceImpl: 扣减库存成功 [skuId=SKU8823, qty=1] 支付服务的日志干脆连时间戳格式都不一样: {"level":"info","msg":"支付回调处理完成","tradeNo":"TRD20260615001","time":"2026-06-15T14:23:13.456+08:00"} 我在 Kibana....
秒杀开始3秒,数据库连接池就被打满了,100万用户在页面上干等
去年我们搞了一次周年庆秒杀。活动页面提前一周预热,到点那一刻,后台监控直接变成一片红。 不是 QPS 被打爆——网关和应用层都扛住了。是数据库连接池,三秒之内从 0 飙到 200(最大连接数),然后所有请求开始报 Cannot get JDBC Connection。 最离谱的是,库存表一行没动。 因为所有请求刚到数据库门口,就被连接池耗尽的异常挡回去了。秒杀还没开始,数据库先倒下了。 一、连接池为什么瞬间被榨干 常规架构下,每次请求处理到数据库这一步才会获取连接。秒杀场景的流量是瞬时脉冲——0 秒之前无人问津,0 秒之后几十万并发同时冲到数据库门口。 连接池有个坑:它不会拒绝你,它只会让你排队等。 HikariCP 默认最大连接数 10(或者你手动设的 200),当并发的数据库请求数超过这个值时,获取连接的线程进入等待队列。每个请求最多等 connectionTimeout(默认 30 秒)。秒杀场景下 30 秒太长了—— 所有线程都在等连接 连接被前面进来的请求占用着(那些请求可能正在执行慢查询) 后面的请求继续涌进来抢连接 线程池里的线程全卡在获取连接上,CPU 空转 tom....
慢SQL日志一天吞65G磁盘?加了动态采样率降到1.2G
凌晨两点,报警群里炸了。 "订单服务三台机器磁盘使用率全部超过 95%,其中一台已经 99%。下单接口开始超时,用户付不了款了。" 我迷迷糊糊掏出手机看了一眼,心头一紧。这是线上大促前夕,每秒几千单的交易量,磁盘满了意味着日志写不进去、服务随时可能挂掉。 赶紧登上服务器一看,/data/logs 目录占了整整 230G。顺着 du 一层层摸下去: /data/logs/slow-sql/ ├── 2026-06-20.log 68G ├── 2026-06-19.log 61G ├── 2026-06-18.log 55G └── ... 好家伙,慢 SQL 日志每天能写六七十 G。 我们慢 SQL 阈值设的 500ms,全量记录每一条慢查询的完整 SQL、执行时间、调用堆栈。业务高峰期每秒几千个查询,大促期间并发一上来,哪怕只有 5% 的 SQL 超过 500ms,每天就是几百万条记录。 那一刻我突然意识到一个问题:我们到底需要记录这么多慢 SQL 吗? 一、全量记录的代价,不光是磁盘 回过头来看,全量慢 SQL 日志的代价远比磁盘空间要大: 磁盘 IO 争抢。 每一条慢 SQL....
服务一直返回200,K8s却说它活着,直到业务全挂了
去年双十一前一天,凌晨3点,被叫起来复盘一次诡异的事故。 事故的表现很魔幻:K8s 集群里所有 Pod 状态都是 Running,健康检查全部通过,Grafana 大盘一片绿。但用户反馈说下单按钮点不动,支付页面转圈圈,客诉量每分钟十几条。 运维同学第一个反应是"网络问题"。查了半小时,网络一切正常。第二个反应是"数据库挂了",DBA 看了一眼连接池——没问题。 最后顺着日志一层层摸,发现 Redis Cluster 里有一个分片的 Master 选举失败,变成了只读模式。订单服务在查 Redis 缓存的时候拿不到写入权限,但它的 /health 接口还在正常返回 200。 因为健康检查只做了 ping——一个什么都没验证的空壳。 一、返回 200 不等于活着 大多数 Spring Boot 项目配健康检查都是这样: GET /actuator/health → {"status":"UP"} 加个 Spring Security 的登录校验就算完事了。看起来挺稳的,直到某天业务挂了,你才发现这玩意儿根本没用。 为什么?因为 Spring Boot Actuator 默认的健康检查....
对象存储生命周期自动化:闲置文件自动转冷存储,云存储成本直降 50%
公司用阿里云 OSS 存用户上传的文件,标准存储每月 0.12 元/GB。半年后发现月账单从几百涨到了两万——文件总量从 1TB 涨到了 20TB,其中 80% 是三个月前上传的、再也没人访问过的文件和日志备份。这些文件一直按标准存储计费,实际上低频存储的价格只要标准存储的三分之一。 云存储的价格差异很大——标准存储最贵,低频存储便宜一半,归档存储几乎不要钱(但取回要等几小时)。问题是很多公司的文件从上传第一天就放在标准存储里,从来没动过,也从来没降级。 今天聊聊怎么用生命周期策略,让不同类型的文件自动流转到合适的存储层级,存储账单直接砍半。 云存储的三种温度 各大云厂商的存储分层都差不多,本质上是"访问频率越高价格越贵、访问频率越低价格越便宜但取回有代价"的 trade-off: 存储类型单价(约)数据取回最短存储适合场景 标准存储0.12 元/GB/月免费无限制频繁访问的热数据 低频存储0.08 元/GB/月按量付费30 天一个月访问几次的温数据 归档存储0.03 元/GB/月解冻 1~5 分钟60 天几乎不访问的冷数据 深度归档0.015 元/GB/月解冻 12 小时....
