
兄弟们看到这个标题点进来的估计都是经历过被服务器“暗算”的苦命人。今天不聊虚的就聊聊我们团队那个“黑子”上线时是如何在一次平平无奇的发布中凭借一己之力把生产服务器干到 CPU 100%、数据库连接池打满、前端页面彻底白屏的。这不是段子这是一篇带着“血泪”的技术复盘。这篇文章不仅会还原事故现场更重要的是我会把排查思路、核心命令、代码层面的优化方案以及事后如何做系统加固全部掰开揉碎讲清楚。无论是刚入行的新手还是负责系统运维的老手相信都能从中找到共鸣和可复用的排错经验。文章内容会比较长建议先收藏遇到类似问题再翻出来对照排查。1. 到底发生了什么一次典型的“服务器干爆”现场1.1 所谓“干爆”的真实技术含义很多同学对“服务器干爆”没有概念以为就是网站打不开。其实在技术层面它通常意味着服务器资源被耗尽或者某个核心组件陷入了不可用状态导致整个请求链路雪崩。我们那次的情况非常典型一个平时日活几百人的内部系统在“黑子”上线了一个导出报表的功能后瞬间涌入大量请求。这些请求没有经过缓存全部直击数据库执行了多条没走索引的全表扫描 SQL。最终数据库连接数被占满新的请求全部在等待连接池释放Tomcat 的线程池也随之耗尽整个 Web 容器失去响应。从监控上看服务器的 CPU 使用率在几分钟内从 10% 飙升到 100%内存占用率持续走高磁盘 I/O 读写等待时间拉满。这就是一次教科书式的“服务器资源耗尽型”故障。1.2 为什么一个功能就能击垮服务器很多人会问一个导出功能怎么会有这么大威力问题不在于功能本身而在于它的实现方式。那个导出功能在实现时采用了同步查询的方式前端点一下“导出”后端在一个 HTTP 请求里完成“查全部数据 → 组装 Excel → 返回文件流”。当“黑子”把这个功能分享到公司大群后几百个人同时点“导出”每个请求都要把数据库里几十万条记录加载到内存里。数据库扛不住Web 服务器也扛不住。这就是典型的“请求并发量突然暴增 后端接口存在性能瓶颈”双重因素叠加的结果。所以每一个上线的新功能都应该经过并发评估和性能测试否则就是埋雷。2. 事故复盘从告警到恢复的完整排查链路2.1 第一反应登录服务器查看全局负载收到监控告警的短信后我做的第一件事就是 SSH 登录服务器。在服务器宕机边缘会话可能会有延迟但必须要冷静。登录后第一步永远是看系统整体负载。# 查看系统负载和 CPU 核心数 uptime nproc # 实时查看进程状态、CPU 和内存占用 Top 10 top -cuptime会输出三个负载值分别代表 1 分钟、5 分钟、15 分钟的平均负载。如果 1 分钟负载远高于 15 分钟负载说明系统刚刚经历了突发流量。而top -c能看到到底是哪个进程在吃 CPU 和内存-c参数可以显示完整的命令行方便定位是 Java 进程还是 MySQL 进程。2.2 定位“真凶”是 CPU 耗尽还是内存溢出通过top命令我们发现 CPU 消耗最高的并不是 Web 应用本身而是 MySQL 进程。这是一个非常关键的信号问题大概率不在应用代码的循环计算而在数据库查询。为了进一步确认内存和磁盘状况还需要看这几个命令的输出# 查看内存使用总量 free -h # 查看磁盘剩余空间和 Inode 使用率 df -h df -i # 查看磁盘 I/O 是否繁忙关注 %util 列 iostat -x 1 5在故障排查中free -h可以判断是否存在内存溢出风险df -i则是很多人会忽略的坑——如果 Inode 被小文件占满即使磁盘还有空间服务器也无法写入任何文件。2.3 数据库视角连接数被打满的典型症状确认是 MySQL 的问题后我们需要立刻连接数据库查看运行状态。这里我强烈建议优先执行以下几个 SQL-- 查看当前有多少连接以及每个连接在做什么 SHOW FULL PROCESSLIST; -- 查看连接数相关状态变量 SHOW GLOBAL STATUS LIKE Threads_connected; SHOW GLOBAL STATUS LIKE Max_used_connections;SHOW FULL PROCESSLIST是排查数据库故障的核心命令。它能看到每个数据库连接正在执行什么 SQL。我当时看到的场景就是大量State为Sending data的查询堆积这些查询大多是同一个慢 SQL。如果Threads_connected已经达到了max_connections的上限新连接就无法建立应用层就会报Too many connections异常。这也是为什么我们后来在应用日志里看到大量连接超时的原因。3. 代码层面的“罪魁祸首”全表扫描与同步导出3.1 一条慢 SQL 如何拖垮整个数据库通过抓取慢查询日志和processlist我们找到了元凶 SQL。原本应该加在WHERE条件上的索引字段因为函数包裹导致索引失效最终走了全表扫描。假设我们有一张订单表t_order常见的错误写法如下-- 错误示例对索引列使用函数导致索引失效 SELECT * FROM t_order WHERE DATE(create_time) 2024-01-01 ORDER BY id DESC;正确写法应该改为范围查询让优化器能命中create_time上的索引-- 正确示例使用时间范围查询 SELECT * FROM t_order WHERE create_time 2024-01-01 00:00:00 AND create_time 2024-01-02 00:00:00 ORDER BY id DESC;这是一个非常经典的问题。永远不要在索引列上使用函数或隐式类型转换。排查 SQL 是否走了索引最直接的方式是使用EXPLAINEXPLAIN SELECT * FROM t_order WHERE create_time 2024-01-01 00:00:00 AND create_time 2024-01-02 00:00:00;重点看输出结果中的type和rows字段。type如果是ALL说明全表扫描如果是range或ref说明索引生效了。rows则能估算扫描了多少行。3.2 同步导出接口的设计缺陷看完 SQL再看应用接口设计。当时“黑子”写的 Controller 大概是这样的伪代码逻辑RequestMapping(/export) public void export(HttpServletResponse response) { // 1. 一次性查出全量数据 ListOrder list orderMapper.selectAll(); // 2. 在内存中循环组装 Excel for (Order order : list) { // 处理单元格... } // 3. 写回响应流 response.getOutputStream().write(bytes); }这个接口至少有四个问题全量查询没有分页数据量一大内存直接溢出。同步导出导出是耗时操作同步接口会长时间占用 Tomcat 线程。没有限流任何人都可以无限制调用。大对象未释放List一直持有引用GC 压力巨大。改造的思路是查询改分页 导出改异步任务 通过消息队列削峰。你可以先把导出任务丢进队列立即返回“正在导出请稍后查看”的提示后台任务生成 Excel 后再上传到文件服务器或 OSS用户通过通知拿到下载链接。4. 服务器层面的应急止血方案在代码修复上线之前必须先在服务器层面做紧急处理先把流量和资源占用降下来恢复服务。4.1 快速重启与临时扩容策略如果你的服务器是云服务器且负载已经打到 100%可以立即在云控制台进行临时扩容配置如升配 CPU/内存这通常是最快的止血手段之一。如果无法快速扩容就需要“断臂求生”# 重启 MySQL 服务这是最后手段不建议在业务高峰期轻易执行 # systemctl restart mysqld 或 service mysql restart重启数据库可以快速释放被占用的连接和锁但风险也很高。重启前应用层最好先摘流量或者设置数据库连接池最大等待时间避免应用侧继续重试。4.2 Nginx 层面主动降级限流在应用代码无法快速修改的情况下我们可以通过 Nginx 配置实现临时限流和并发控制。比如限制同一 IP 的请求频率或者限制单个接口的并发连接数。# 定义限流区域每秒允许 1 个请求突发 5 个请求 limit_req_zone $binary_remote_addr zoneapi_limit:10m rate1r/s; server { listen 80; server_name example.com; location /api/export { limit_req zoneapi_limit burst5 nodelay; proxy_pass http://backend_server; } }这段配置的意思是针对/api/export这个接口的请求进行频率控制。rate1r/s表示每秒只能处理 1 个请求burst5表示允许瞬间有 5 个突发请求排队。通过这种暴力限流可以把突发的几百个并发瞬间砍掉保护后端服务。4.3 从 Docker 与系统层面清理僵尸进程如果服务器上跑着 Docker可能还会出现容器不断重启导致的僵尸进程问题。排查和清理可以这样操作# 查看所有容器状态 docker ps -a # 清除异常退出的容器注意会删除容器谨慎操作 docker rm $(docker ps -qf statusexited) # 清理悬空镜像 docker image prune -f在系统层面如果发现过多的孤儿进程可以通过ps -ef查看 PPID 为 1 的进程确认后酌情处理。但无论何时操作数据库和生产服务器都要格外谨慎遵循最小权限原则。5. 系统加固从“治标”到“治本”的完整优化应急恢复只是第一步。如果不想下次再被干爆必须做系统性的加固。这部分是最重要的。5.1 引入 Redis 缓存保护数据库对于读取远大于写入的热点数据一定要加缓存。使用 Redis 缓存订单列表或统计数据可以显著降低数据库压力。// 伪代码先查缓存再查数据库 public ListOrder getOrderList(String date) { String key order:list: date; ListOrder cacheList redisTemplate.opsForList().range(key, 0, -1); if (cacheList ! null !cacheList.isEmpty()) { return cacheList; } // 缓存未命中查询数据库并回填 ListOrder dbList orderMapper.selectByDate(date); if (dbList ! null !dbList.isEmpty()) { redisTemplate.opsForList().rightPushAll(key, dbList); redisTemplate.expire(key, 30, TimeUnit.MINUTES); // 设置过期时间 } return dbList; }这里要注意一个经典问题——缓存穿透。如果查询的数据在数据库中不存在缓存也不会写入那么每次请求都会打到数据库。解决方案有缓存空值或使用布隆过滤器。在实际项目中建议先用缓存空值代码简单且直观。5.2 数据库连接池参数调优很多应用默认的数据库连接池配置并不适合高并发场景。以 HikariCP 为例可以参考如下配置进行调优spring: datasource: hikari: # 连接池中允许的最大连接数 maximum-pool-size: 20 # 连接池中维护的最小空闲连接数 minimum-idle: 5 # 连接的最大存活时间 max-lifetime: 1800000 # 连接超时时间毫秒 connection-timeout: 3000 # 空闲连接回收时间 idle-timeout: 600000connection-timeout推荐设置得短一点如 3 秒这样当连接池被占满时应用可以快速失败而不是让用户长时间无响应。这其实是另一种“快速失败”的设计思路能有效防止线程堆积。5.3 线程池隔离与异步化改造对于导出、发送短信、推送通知这类耗时操作一定要使用线程池或消息队列进行异步化。下面是一个使用CompletableFuture快速实现异步任务的示例Async(taskExecutor) public void exportAsync(String startDate, String endDate, Long userId) { // 异步生成报表 ListOrder orders orderMapper.selectListByRange(startDate, endDate); byte[] excelData buildExcel(orders); // 上传到 OSS / 发送下载链接给用户 } // Spring 配置类中定义线程池 Bean(name taskExecutor) public ThreadPoolTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(20); executor.setQueueCapacity(100); executor.setThreadNamePrefix(export-); executor.initialize(); return executor; }同时建议为不同的业务配置独立的线程池比如“导出线程池”和“短信线程池”分开防止一个业务排队阻塞另一个业务。5.4 Web 服务器连接与超时配置以常见的 Tomcat 为例server.xml中的连接器配置也需要优化。生产环境建议显式配置maxThreads、acceptCount和connectionTimeout。Connector port8080 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 maxThreads200 minSpareThreads10 maxConnections8192 acceptCount100 /maxThreads处理请求的最大线程数不建议盲目设置过大。acceptCount等待队列长度当线程满了新请求会进入队列。connectionTimeout连接建立的超时时间。如果在前面加了 Nginx那么 Tomcat 的maxThreads可以根据后端服务能力适当调低让压力在 Nginx 层就能被拦截。6. 构建高可用架构从单机到集群如果系统的业务量还在增长单靠一台服务器的优化是不够的必须在架构层面做设计。6.1 负载均衡Nginx 多台应用服务器这是最基础的集群方案。Nginx 作为前置代理将请求分发到多台后端服务器。即使其中一台挂掉流量也会自动切到其他服务器。upstream backend_app { server 192.168.1.10:8080 weight5 max_fails3 fail_timeout30s; server 192.168.1.11:8080 weight5 max_fails3 fail_timeout30s; server 192.168.1.12:8080 backup; # 备用节点 } server { listen 80; location / { proxy_pass http://backend_app; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }weight表示权重max_fails和fail_timeout配合可以在后端异常时自动摘除节点backup节点平时不参与服务只有主节点全挂时才启用。6.2 数据库读写分离与主从复制数据库层面单库单表在高并发下迟早会成为瓶颈。常规做法是做一主多从的读写分离架构。写入走主库查询走从库从库通过主从复制同步数据。配置步骤通常如下在主库开启二进制日志log-binmysql-bin。创建用于复制的专用账号并授权。在从库上配置主库连接信息。-- 在从库执行 CHANGE MASTER TO MASTER_HOST192.168.1.100, MASTER_USERrepl_user, MASTER_PASSWORDyour_password, MASTER_LOG_FILEmysql-bin.000001, MASTER_LOG_POS154; START SLAVE;配置完成后通过SHOW SLAVE STATUS\G;查看Slave_IO_Running和Slave_SQL_Running是否都为Yes。读写分离能极大缓解单库的压力。6.3 服务器虚拟化与容器化资源隔离如果一台服务器上运行着多个业务强烈建议使用 Docker 或 Kubernetes 进行资源隔离。通过 Docker Compose 可以快速限定某容器的 CPU 和内存上限services: web: image: nginx:latest deploy: resources: limits: cpus: 1.0 memory: 512M reservations: cpus: 0.5 memory: 256M这样即便某个应用出现内存泄漏也不会立刻拖垮宿主机和其他应用。这就是容器化技术和服务器虚拟化带来的稳定性红利。7. 事故发生后的复盘与监控体系建设7.1 建立全链路监控从指标到日志服务器被干爆这种事完全可以在发生前提前预警。推荐建立三层监控体系第一层主机监控。使用 Prometheus Node Exporter 采集 CPU、内存、磁盘、网络等基础指标。第二层应用监控。在 Spring Boot 中集成 Actuator 和 Micrometer将 JVM 内存、线程数、HTTP 接口耗时暴露给 Prometheus。第三层链路追踪。使用 SkyWalking 或 Zipkin 追踪一次请求在网关、应用、数据库之间的耗时。# docker-compose 中一键启动监控全家桶简化版 services: prometheus: image: prom/prometheus ports: - 9090:9090 volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml grafana: image: grafana/grafana ports: - 3000:3000通过 Grafana 配置告警规则比如“CPU 使用率超过 80% 持续 5 分钟”就触发告警。这样我们可以在故障发生前就介入处理。7.2 上线前的压测如何做为了避免“黑子”事件重演所有核心接口在迭代上线前都必须做压力测试。推荐使用 JMeter 或 wrk 工具简单易用。以wrk为例一条命令就可以压测 HTTP 接口# 使用 100 个线程模拟并发连接持续压测 30 秒 wrk -t100 -c100 -d30s http://your-server.com/api/export-t100模拟的线程数。-c100保持的连接数。-d30s压测时长。压测后重点看两个数据QPS每秒请求数和延迟Latency。如果压测时发现接口的 QPS 上不去或者错误率升高就需要尽早优化而不是等到用户来教育你。8. 常见问题与排查清单最后整理一份服务器在“被干爆”边缘时的排查清单。建议截图保存或者收藏本文。问题现象常见原因解决思路CPU 使用率 100%慢 SQL 全表扫描代码死循环通过top定位高 CPU 进程抓取慢查询日志使用EXPLAIN分析 SQL内存占用持续走高大对象未释放并发请求过多使用jmap或heap dump分析内存快照检查代码中是否持有不释放的引用数据库连接数打满连接池配置过小慢 SQL 长期占用连接调大max_connections临时优化慢 SQL合理配置连接池参数磁盘 I/O 100%大量读写日志数据库刷盘频繁通过iostat -x查看磁盘繁忙度考虑为数据库单独挂载高性能磁盘访问超时Tomcat 线程池耗尽后端无响应查看 Tomcat 访问日志和线程栈增加maxThreads或前置限流网络连接数过多连接未释放遭受异常扫描使用ss -s统计连接数查看 TIME_WAIT 与 CLOSE_WAIT 状态检查应用代码关闭连接如果遇到“服务器无法连接”的问题通常是轻量级防护软件拦截或者安全组规则变动导致的先用ping和telnet ip port测试网络连通性再逐层排查。9. 写在最后的经验之谈一次“服务器被干爆”的经历虽然有惊无险但它几乎每时每刻都在互联网的某个角落里重复上演。通过这次复盘我最大的感受是技术方案没有绝对的正确只有是否适合当前阶段的业务规模。那个“第一个把服务器干爆的学生”其实并不是代码写得有多烂而是缺乏对“生产环境”的敬畏。生产环境的数据是无价的任何变更都要遵循“先在测试环境验证、做好备份、遵循最小权限”的原则。我们后面对他的培养方向也不再只是让他写业务代码而是让他参与到压测、监控告警配置、容量评估这些系统性工作中来让他理解一台服务器的极限在哪里。如果你也刚经历过类似的事故别气馁。把这次事故当成一次免费的架构课把上面这些排查命令和优化方案在自己公司的项目里实际跑一遍比看十篇水文都有用。希望这篇文章能帮你在下一次“黑子”上线之前提前把服务器的“防弹衣”穿上。有疑问欢迎在评论区交流。