一台 2 核 Linux 小机器 CPU 跑满、全站 50x一次 Web 服务雪崩的完整排查手记各位看官今天这篇不聊框架、不追新花样咱聊点能救命的东西——一次真实的线上雪崩排查。事情大概发生在某个平平无奇的半夜我被一连串报警短信炸醒一台 2 核的小机器负载飙到 100%页面大面积 50x。那一刻手是最诚实的——我的第一反应居然是赶紧重启试试。幸亏手比脑子慢了半拍被自己一把拦住。重启会销毁现场连接状态、进程内存、打开的文件描述符、正在跑的 SQL全蒸发。运气好服务恢复了明天同一时间它再挂一次你依然一无所知。这篇文章就是我从入口层到数据层、一步步把这场雪崩按住的全过程。所有业务信息我已提前剥离只留方法论和技术本身。这大概是我近几年成长最快、也最后怕的一次排障写出来既是复盘也是给未来的自己留个底。零、先立个保命的规矩服务出问题时人最强的冲动是动一下试试。这是最坏的选择。诊断阶段的纪律只有一条只读。只读不代表束手无策。恰恰相反一台正在冒烟的机器上可观测信息是最丰富的。你要做的是在它还在烧的时候把证据拍下来。分层定位的顺序也别乱入口层 → 应用层 → 数据层。顺着请求流向走而不是凭直觉跳。原因很简单——下游的病会在上游显现症状但上游的症状反推不了下游的病。你在 nginx 看到 502它可能是 nginx 的问题、应用的问题还可能是数据库的问题。只有顺序排除才能避免修了半天上游根因其实在数据库。一、入口层先看清谁在烧 CPU拿到全局画面# 按 CPU 降序只要关键列避免刷屏ps-eopid,ppid,user,%cpu,%mem,etime,cmd--sort-%cpu|head-20# 负载与核数对照load 10 在 2 核上 严重过载在 16 核上 还好uptime;nproc负载数字必须除以核数才有意义。load average 5 在 8 核机器上健康在单核机器上意味着大量任务在排队。这个换算是判断严重程度的第一道门槛我当时就是先nproc看了核数才确认这机器是真的扛不住了。连接状态才是雪崩的指纹# 总连接数ss-tnstate established|wc-l# 按本地端口聚合 —— 看清连接堆在哪个服务上ss-tnstate established|awk{print $3}|seds/.*://|sort|uniq-c|sort-rn|head# 按远端 IP 聚合 —— 区分外部攻击还是内部自循环ss-tnstate established|awk{print $4}|cut-d:-f1|sort|uniq-c|sort-rn|head这一步的判断力在于区分攻击与自噬。如果连接数上万但远端 IP 高度集中在127.0.0.1那不是有人打你是你自己在打自己——反向代理转发给本机上游上游响应不了连接就在 loopback 上堆成山。这是典型的连接雪崩上游变慢 → 请求不释放 → 连接堆积 → 资源耗尽 → 更慢 → 更堆积一个正反馈死循环。反之远端 IP 分散且来自公网才要考虑攻击或爬虫。本案结论连接一万二其中近七千是 loopback全部进程都是合法业务进程没有可疑二进制。不是攻击是自噬。从错误日志锁定失败模式sudotail-2000/var/log/nginx/error.log|grep-oEupstream timed out|Too many open files|connect\(\) failed|sort|uniq-c用grep -oE uniq -c把日志降维成错误类型的频次分布比人眼翻日志快一个数量级。本案跑出两类错误错误类型含义所在层级Too many open filesnginx worker 的文件描述符fd耗尽入口层自身瓶颈upstream timed out上游应用响应超时下游真实病灶两类错误同时存在时先修自身瓶颈因为它会污染你对下游的观测——fd 耗尽会让 nginx 连尝试请求上游都做不到你根本无法准确评估上游到底有多慢。这条顺序后来救了我至少半小时的瞎折腾。二、nginx 文件描述符三层限制缺一不可为什么我调了参数却不生效这是最经典的坑也是我当时差点栽的坑。nginx 的连接能力受三层独立限制约束任何一层没放开整体就卡在最小值┌─────────────────────────────────────────────┐ │ 第 1 层systemd LimitNOFILE │ ← 进程能拿到的 fd 硬上限 │ ↓ 约束 │ │ 第 2 层nginx worker_rlimit_nofile │ ← nginx 主动 setrlimit 的值 │ ↓ 约束 │ │ 第 3 层nginx worker_connections │ ← 单 worker 允许的连接数 └─────────────────────────────────────────────┘只改worker_connections是新手最常犯的错。你把它从 1024 调到 10240但进程 fd 上限还是 1024结果一个连接都多不了——nginx 每个连接至少占 1 个 fd反向代理场景要占 2 个客户端一个、上游一个。正确做法是三层一起改# /etc/nginx/nginx.conf worker_processes auto; worker_rlimit_nofile 65535; # 关键让 nginx 自己抬高 fd 上限 events { worker_connections 10240; # 单 worker 连接数 }# /etc/systemd/system/nginx.service.d/limits.conf [Service] LimitNOFILE65535sudosystemctl daemon-reloadsudonginx-tsudosystemctl reload nginx用systemddrop-in 而不是直接改nginx.service是为了让包管理器升级时不覆盖你的改动。这是 systemd 配置的标准姿势少这一步下次apt upgrade一个 nginx你的配置就灰飞烟灭。验证时的致命陷阱# ❌ 错误pgrep -o 取的是最老的进程 mastercat/proc/$(pgrep-onginx)/limits|grepopen files# ✅ 正确必须查 worker 进程forpidin$(pgrep-fnginx: worker);doecho-nPID$pid: ;grepMax open files/proc/$pid/limitsdonereload是优雅重载master 进程不重启所以它的 limits 还是旧值只 fork 出新 worker 接管新连接。真正干活的是 worker。查错了进程你会得出配置没生效的错误结论然后开始怀疑人生。我当时就差点在这步怀疑自己手残——记住master 保留旧限制是正常现象不是 bug。效果止住了血没治好病调整后Too many open files归零fd 类 50x 消失。但upstream timed out依然刷屏连接数仍在一万五。这正是有价值的信息——它排除了入口层把嫌疑锁死在下游。表层止血的意义不只是缓解更是通过排除法收敛问题空间。很多新手卡在我修了啊怎么还不行其实就是没读懂修了还不行本身就是一条线索。三、缓存止血带不是手术刀上游慢很自然会想到加缓存。配置本身不难难的是理解它的边界。http { proxy_cache_path /var/cache/nginx/content levels1:2 # 两级目录避免单目录文件过多 keys_zonecontent_cache:10m # 10MB 元数据区 ≈ 8 万个 key max_size2g inactive30m # 30 分钟没被访问就淘汰 use_temp_pathoff; # 直接写目标目录避免跨盘 mv } location /path/ { proxy_cache content_cache; proxy_cache_valid 200 301 302 10m; proxy_cache_valid 404 1m; proxy_cache_lock on; # 防击穿并发未命中只放一个请求到上游 proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; proxy_cache_background_update on; # 过期时先返回旧内容后台异步刷新 proxy_ignore_headers Set-Cookie Cache-Control; proxy_hide_header Set-Cookie; # 防止把 A 用户的 Cookie 缓存给 B 用户 add_header X-Cache-Status $upstream_cache_status; # HIT/MISS/EXPIRED/STALE调试神器 }几个配置的价值排序我按实战重要性排proxy_cache_lock— 防缓存击穿。热点内容过期瞬间一万个请求同时打向上游本就摇摇欲坠的上游直接被打死。加锁后只放行一个其余等待。proxy_cache_use_stale— 灾难兜底。上游挂了宁可返回 10 分钟前的旧内容也好过一个 502。可用性优先于新鲜度这是缓存设计的核心权衡。proxy_hide_header Set-Cookie— 安全红线。共享缓存里混进用户态 Cookie会造成跨用户串号属于严重安全事故。冷缓存悖论配好之后50x 一点没降。我当时愣了几秒为什么缓存需要被填充才有价值而填充需要至少一次成功的上游响应。此时上游已经完全无响应了。冷缓存 死上游 缓存形同虚设。连use_stale都救不了你——它要stale 的内容而你压根没有任何内容。这个教训值得刻在脑子里缓存是给慢用的不是给死用的。它能削峰、能扛流量尖刺但不能凭空创造出你从未成功生成过的响应。想让缓存在灾难时救命前提是灾难来临前它已经是热的。顺带一个调试细节如果 50x 走了error_page跳转到独立的location /50x.html那个 location 里没有add_header X-Cache-Status所以错误响应上看不到缓存头。这是正常现象别误判成配置失效。四、下钻数据层慢查询与索引排除了入口层和缓存层只剩数据库。此时数据库进程 CPU 长期在 100% 附近——嫌疑基本坐实。第一步让数据库自己告诉你哪里慢不要凭猜测优化。先开慢查询日志SETGLOBALslow_query_logON;SETGLOBALlong_query_time2;SETGLOBALlog_queries_not_using_indexesON;-- 关键log_queries_not_using_indexes是精髓所在。一个全表扫描如果表还不大可能只要 0.5 秒逃过long_query_time2的过滤——但它在高 QPS 下会把 CPU 吃干净。慢的定义不只是单次耗时长还包括单次不慢但代价高且调用极频繁。这个认知是后面能根治问题的关键。写进配置文件持久化避免重启后失效# /etc/my.cnf.d/zz-slowlog.cnf [mysqld] slow_query_log 1 slow_query_log_file slow.log long_query_time 2 log_queries_not_using_indexes 1文件名前缀用zz-是个小技巧配置目录按字母序加载后加载的覆盖先加载的zz-保证你的配置有最高优先级。第二步读懂 EXPLAINEXPLAINSELECT*FROMcontentWHEREchannel_id43ANDstatusPUBLISHEDORDERBYtimeDESCLIMIT20;只需要盯死三列EXPLAIN 列危险信号健康状态typeALL全表扫描ref/const/eq_refrows数万扫描行数估算几行到几十行ExtraUsing filesort、Using temporary、Creating sort indexUsing index覆盖索引、Using where本案优化前typeALLrows41967ExtraUsing filesort。翻译成人话——每次请求都把四万行数据从磁盘捞出来再在内存里排一次序。QPS 一上来CPU 不烧才怪。还有个不依赖 EXPLAIN 的判断法SHOWGLOBALSTATUSLIKEHandler_read%;Handler_read_rnd_next数值巨大 大量顺序全表扫描Handler_read_key占比高 索引用得好。这两个计数器是全局体检指标比单条 EXPLAIN 更能反映整体健康度。第三步复合索引的设计法则ESR复合索引的字段顺序不是随便排的它直接决定索引能否被用上。业界通用的口诀是ESREquality等值→Sort排序→Range范围对于这样一条查询WHEREchannel_id?ANDstatus?ORDERBYistopDESC,idDESC等值条件channel_id、status排序字段istop、id于是索引设计为ALTERTABLEcontentADDINDEXidx_channel_status_istop_id(channel_id,status,istop,id);为什么排序字段放在等值字段之后就能消除 filesortB 树索引是有序的。当channel_id和status被等值锁定后索引中这一段连续区间内的记录天然就是按istop, id排好序的。存储引擎顺着叶子节点扫过去读出来的顺序就是你要的顺序排序动作被彻底省掉了。如果顺序写反了比如(istop, id, channel_id, status)最左前缀是istop而查询里istop不是等值条件索引直接失效或退化。最左前缀原则——索引只能从左往右连续使用中间断一个右边全废。这条我当年在别的 project 上栽过这次格外敏感。优化结果对照查询类型优化前优化后列表查询ALL/ 41967 行 /filesortref/ 1 行计数查询range/ 20983 行range/ 2691 行 /Using index排序查询Creating sort indexUsing wherefilesort 消失第四步线上加索引不能停机生产环境的 DDL 必须显式声明算法和锁级别ALTERTABLEcontentADDINDEXidx_channel_status_istop_id(channel_id,status,istop,id),ALGORITHMINPLACE,LOCKNONE;ALGORITHMINPLACE— 原地修改不复制整张表。COPY算法会重建全表大表上意味着漫长阻塞和翻倍磁盘占用。LOCKNONE— 执行期间读写都不阻塞。为什么要显式写而不依赖默认值因为如果当前操作不支持 INPLACE/NONE数据库会直接报错拒绝执行而不是悄悄降级成锁表模式把你的生产打挂。这是一道保险丝——宁可失败不可静默降级。五、索引治不了的病索引加完慢查询日志空了50x 雪崩解除。但数据库 CPU 依然在 89% 高位。这时必须承认索引优化到头了剩下的是架构问题。剩余的两个 CPU 消耗源1. 高频小写入UPDATEcontentSEThitshits1WHEREid?;每次页面访问同步写一次计数器。单条 SQL 快得可怜但在高 QPS 下每次都要拿行锁每次都要写 redo log热点行爆款内容上锁竞争激烈索引对写入毫无帮助反而每多一个索引写入就多一份维护成本。这是索引的固有代价读性能的提升是用写性能换来的。解法在应用层内存累加定时批量落库、计数器迁到 Redis、异步队列削峰。2. 大范围扫描即便走了索引某些数据量大的分区依然要扫上万行。索引能让你精确定位起点但不能减少你要读取的数据总量。解法同样在应用层用游标分页WHERE id ?替代OFFSET——深翻页时OFFSET 10000要先扫过并丢弃前一万行缓存COUNT(*)或改用近似值数据归档、冷热分离。一个通用判据当你已经消除了全表扫描和 filesortCPU 却依然高企那答案一定在应用层的访问模式上继续加索引只会让写入更慢。索引解决怎么找得快解决不了为什么要找这么多次。六、让优化活下来把索引写进代码这是最容易被忽视、却最致命的一环。手工索引的脆弱性线上手动ALTER TABLE加的索引只存在于那台数据库实例里。它不在 git 里不在任何人的代码审查视野里。于是新同事搭本地环境 → 没索引 → 本地飞快数据少、上线爆炸灾难恢复重建库 → 索引全丢某人跑了一次 schema 同步 → 索引可能被抹掉半年后没人记得为什么加过这个索引也没人敢删任何不在版本控制里的基础设施变更都是一颗定时炸弹。这次雪崩之后我第一件事就是把线上所有救命索引回填进 ORM 声明。在 ORM 里声明索引以 Sequelize 为例正确做法是在模型定义的第三个参数options里声明indexesconstMODEL_INDEXES{Content:[{name:idx_channel_id,fields:[channel_id]},{name:idx_channel_status_time,fields:[channel_id,status,time]},{name:idx_channel_status_istop_id,fields:[channel_id,status,istop,id]}]}Object.keys(models).forEach(name{sequelize.define(name,models[name],{freezeTableName:true,tableName:name.toLowerCase(),timestamps:false,indexes:MODEL_INDEXES[name]||[]// ← 注入到 options})})两个必须避开的坑坑 1不要把indexes写进属性定义对象。模型的第二个参数是字段定义往里塞indexes会被 ORM 当成一个名叫indexes的数据列去建表。参数位置错了语义完全变味。坑 2不要为了让索引生效而改用sync({ alter: true })。ORM 调用方式行为生产可用性sync()force:false, alter:false表已存在则跳过只建不删不改✅ 安全sync({ alter: true })对比模型与实际表结构自动改列、增删索引⚠️ 危险sync({ force: true })DROP TABLE 后重建☠️ 生产禁用alter: true听起来很美实际上可能改字段类型、删掉它不认识的索引、在大表上触发长时间锁表。生产环境的 schema 变更应该走 migration而不是靠 ORM 猜。那么在sync()无参模式下声明indexes还有意义吗有而且很大。它的价值在于索引进入版本控制、新环境自动带索引、未来任何人改用 migration 索引都在声明里不会丢失。它是一份声明式意图记录成本几乎为零收益长期。零风险验证法只 define不 sync改完之后怎么在不碰生产数据库的前提下确认写对了关键洞察ORM 的define()是纯内存操作只有sync()/查询才会真正连接数据库。constSequelizerequire(sequelize)constmodelsrequire(./app/model/models.js)// 连接参数随便填因为我们永远不会真的连过去constsequelizenewSequelize(db,user,x,{dialect:mysql,logging:false})constMsequelize.define(Content,models.Content,{freezeTableName:true,tableName:content,timestamps:false,indexes:MODEL_INDEXES.Content})// 1. 检查 ORM 解析后的索引结构console.log(JSON.stringify(M.options.indexes,null,2))// 2. 检查索引引用的字段是否真实存在防拼写错误constcolsObject.keys(M.rawAttributes);[channel_id,status,time,istop,id].forEach(cconsole.log(c,cols.includes(c)?OK:❌ 字段不存在))// 绝不调用 sync()这个脚本能一次性验证语法正确性、索引解析结果、字段名拼写、表名映射。全程零数据库交互零风险。配合数据库侧SHOW INDEX FROM content;做交叉比对就能确认代码声明与线上状态完全一致。这套代码声明 ↔ dry-run 解析 ↔ 数据库实际三方对账的方法可以推广到任何 ORM 的 schema 校验场景。七、远程运维的工程细节这些不写在任何文档里但每一条都是踩出来的。1. 长输出会被截断甚至杀死远端进程远程命令输出过大时本地读取端提前关闭管道会向远端发送SIGPIPE把你正在跑的脚本直接杀掉。你以为脚本执行完了其实它在中途暴毙。对策远端脚本把完整结果写进日志文件终端只输出精简摘要。sshhostbash /tmp/diag.sh /tmp/diag.log 21; tail -40 /tmp/diag.log2. 复杂命令一律写脚本文件别用内联引号# ❌ 引号地狱且不同 shell 行为不一致sshhostmysql -u root -ppa\$\$word -e\SELECT * FROM t WHERE ax\# ✅ 本地写文件 → 传过去 → 显式指定解释器执行scp-q/tmp/task.sh host:/tmp/sshhostbash /tmp/task.sh两个额外理由bash必须显式写。远端默认 shell 可能是dash/sh不支持${VAR:0:3}、[[ ]]、数组等 bash 扩展。少写一个bash脚本就在陌生的解释器里跑。脚本文件可以留档、可复现、可 code review。3. 所有变更脚本必须幂等# 配置追加前先检查是否已存在grep-qworker_rlimit_nofile/etc/nginx/nginx.conf||\sed-i/worker_processes/a worker_rlimit_nofile 65535;/etc/nginx/nginx.conf# DDL 用 IF NOT EXISTSALTER TABLE t ADD INDEX IF NOT EXISTS idx_x(col);幂等的本质是脚本跑第二遍不会造成任何额外影响。网络抖动导致的重试、不确定是否执行成功时的重跑都依赖这个性质。不幂等的脚本你永远不敢重跑出问题时只能靠猜。4. 改配置前先备份改完立刻验证cp/etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak.$(date%Y%m%d%H%M%S)# ...修改...nginx-tsystemctl reload nginx# 先语法校验通过才 reloadnginx -t 的短路逻辑是保命的语法不过就不 reload避免把语法错误的配置推上线导致服务彻底起不来。同理每个变更都要预先想好回滚命令-- 加索引的回滚ALTERTABLEcontentDROPINDEXidx_channel_status_istop_id;没有回滚方案的变更不叫变更叫赌博。八、方法论沉淀复盘完技术我把整条链路沉淀成一张图方便下次照着走现象CPU 高 / 大面积 50x │ ┌─────────▼─────────┐ │ 只读诊断不动现场 │ └─────────┬─────────┘ │ ┌─────────▼──────────────────────────┐ │ 入口层ss 看连接 / error.log 分类 │ │ loopback 堆积 → 自噬非攻击 │ │ Too many open files → fd 瓶颈 │ └─────────┬──────────────────────────┘ │ 修完自身瓶颈症状仍在 ┌─────────▼──────────────────────────┐ │ 缓存层能削峰救不了死上游 │ └─────────┬──────────────────────────┘ │ 继续下钻 ┌─────────▼──────────────────────────┐ │ 数据层慢日志 → EXPLAIN → 索引 │ │ ALL/filesort → 复合索引(ESR) │ │ 在线 DDL: INPLACE LOCKNONE │ └─────────┬──────────────────────────┘ │ 慢查询消失CPU 仍高 ┌─────────▼──────────────────────────┐ │ 架构层高频写入 / 大范围扫描 │ │ → 缓冲、异步、游标分页、冷热分离 │ └─────────┬──────────────────────────┘ │ ┌─────────▼──────────────────────────┐ │ 固化层索引写进代码纳入版本控制 │ └──────────────────────────────────────┘整条排查链路按分层定位 排除法收敛可以浓缩成下面这张表建议直接收藏层级核心信号典型手段排除标志入口层loopback 连接堆积 /Too many open filesss聚合 / 放开三层 fd 限制表层止血后症状仍在缓存层upstream timed out刷屏 / 冷缓存无效proxy_cache_lock/use_stale上游恢复前缓存无用数据层DB CPU 100% /ALLfilesort慢日志 / EXPLAIN / 复合索引 ESR慢查询日志清空架构层无全表扫描但 CPU 仍高缓冲写入 / 游标分页 / 冷热分离访问模式已优化固化层手工索引游离于版本控制ORM 声明 / migration / 三方对账索引进代码可复现最后把那些能跨项目复用的原则单独列出来这十条我贴在工位上了#原则一句话记法1诊断期只读重启销毁现场现场只有一次2顺着请求流向分层排查别凭直觉跳3负载数字必须除以核数load 10 在 2 核上才叫炸4loopback 堆积自噬公网外部压力攻击与雪崩的分水岭5表层止血价值在收敛问题空间修了还不行也是线索6缓存给慢用不给死用冷缓存救不了死上游7慢单次耗时×调用频次别只盯long_query_time8复合索引按 ESR 排最左前缀断一个全废9索引治找得快不治找太多次后者是架构病10不在版本控制里的变更等于不存在手工索引是定时炸弹最后一条也是最重要的排查的终点不是服务恢复了而是下次不会再这样且任何人都能看懂当初为什么这么做。前者是救火后者才是工程。这场半夜的雪崩让我从一个会重启的人变成一个敢不动现场、顺着链路把根因揪出来的人——这大概就是这次成长最值钱的部分。本文由 DeepSeek 辅助整理技术手记、并经人工校订首发于 CSDN。如果这篇雪崩排查帮你避了坑发财的小手点个小赞咱们下篇接着聊后端那些事。相关阅读Flutter/Android Release 包连不上网AndroidManifest INTERNET 权限排查实录Flutter 带 TTL 的多级缓存设计内存磁盘网络三层实战Flutter Web token 存储陷阱crypto.subtle 在非安全上下文失效排查实录下一篇我会写《为什么用 Cloudflare Workers D1 扛起了整个多租户 SaaS 后端架构决策全景复盘》把这套 serverless 边缘栈的架构决策掰开讲——后端系列第一篇敬请期待。