30、实战项目(下):Web服务器性能测试、压力测试、问题排查、优化迭代、项目总结与经验分享
上一章我们把Web服务器跑起来了,能响应请求了。但说实话,那只是万里长征第一步。一个能跑的服务器和一个能扛住高并发的服务器,中间差着十万八千里。这一章,我们就来聊聊怎么把服务器从「能用」变成「好用」。
性能测试:先摸清家底
做优化之前,你得先知道服务器现在什么水平。我个人习惯用 wrk 和 ab 这两个工具。它们简单粗暴,能快速给出吞吐量和延迟数据。
先看一个典型的测试命令:
wrk -t12 -c400 -d30s http://localhost:8080/
这个命令的意思是:用12个线程,保持400个并发连接,持续压30秒。跑完之后,你会看到类似这样的输出:
Running 30s test @ http://localhost:8080/
12 threads and 400 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 45.67ms 12.34ms 198.76ms 75.23%
Req/Sec 7.23k 1.02k 9.87k 68.45%
2598764 requests in 30.00s, 3.47GB read
Requests/sec: 86625.47
Transfer/sec: 118.43MB
这里有几个关键指标:Latency(延迟)、Req/Sec(每秒请求数)、Requests/sec(总吞吐量)。我一般先看延迟的均值和中位数,如果中位数超过100ms,那就要警惕了。
压力测试:找到系统的天花板
性能测试是看「好不好」,压力测试是看「会不会挂」。说白了,就是要把服务器往死里压,找到它的极限在哪里。
我常用的压测策略是阶梯式加压:
- 先以100并发跑5分钟,观察CPU和内存
- 加到200并发,再跑5分钟
- 继续加到400、800、1600...
- 直到出现大量超时或错误
记录下每个阶段的指标,你会得到一条曲线。这条曲线能告诉你很多事情:
| 并发数 | 吞吐量 (req/s) | 平均延迟 (ms) | 错误率 | CPU使用率 |
|---|---|---|---|---|
| 100 | 25000 | 4.2 | 0% | 35% |
| 200 | 48000 | 4.5 | 0% | 60% |
| 400 | 86000 | 5.1 | 0.1% | 85% |
| 800 | 92000 | 12.8 | 2.3% | 95% |
| 1600 | 78000 | 45.6 | 15.7% | 98% |
看到没?到800并发时,吞吐量几乎不再增长,延迟却翻倍了。这就是典型的「拐点」。我建议你把服务器的运行水位控制在拐点以下,留出30%的余量。
问题排查:从现象到根因
压测发现问题后,怎么排查?我有一套固定的排查流程:
- 看CPU:用
top或htop,看每个核心的使用率。如果某个核心跑满,其他核心空闲,那就是惊群效应或锁竞争。 - 看内存:用
free -m和vmstat,看有没有内存泄漏。我习惯每5秒采样一次,连续看几分钟。 - 看网络:用
netstat -s看丢包和重传。重传率超过0.1%就要注意了。 - 看IO:用
iostat -x 1看磁盘IO。如果await超过10ms,磁盘可能是瓶颈。
举个例子。有一次我压测时发现吞吐量上不去,CPU使用率只有40%。用 perf top 一看,发现 epoll_wait 占了60%的CPU时间。这说明什么?说明事件循环在空转,没有事件可处理。后来发现是连接数太少,请求处理太快,导致CPU大部分时间在等新连接。
优化迭代:小步快跑
找到问题之后,就是优化了。我一般遵循「一次只改一个变量」的原则。改完就跑一次压测,看效果。这样你能清楚知道每个改动带来了多少收益。
常见的优化方向:
- 线程模型:从单线程到多线程,再到Reactor模型。我建议用线程数等于CPU核心数,避免上下文切换。
- 内存管理:用对象池代替频繁的new/delete。我曾在项目中用了一个简单的内存池,吞吐量提升了30%。
- 锁优化:用读写锁代替互斥锁,或者用无锁队列。记住,锁的粒度越小越好。
- IO模型:从阻塞IO到非阻塞IO,再到异步IO。epoll是Linux下的首选。
看一个具体的优化案例。这是优化前的代码:
// 优化前:每次请求都new一个对象
void handle_request(int fd) {
Request* req = new Request();
req->parse(fd);
// ... 处理逻辑
delete req;
}
优化后:
// 优化后:使用对象池
ObjectPool<Request> pool(1024);
void handle_request(int fd) {
Request* req = pool.acquire();
req->parse(fd);
// ... 处理逻辑
pool.release(req);
}
改动很小,但效果很明显。因为 new 和 delete 涉及系统调用和内存分配,在高并发下会成为瓶颈。
项目总结:那些踩过的坑
做完这个项目,我总结了几条经验,分享给你:
- 不要过早优化:先把功能跑通,再考虑性能。我见过太多人一开始就纠结于用epoll还是select,结果代码写了一周还没跑起来。
- 压测要模拟真实场景:不要只压一个接口,要混合压。因为不同接口的处理时间不同,会影响整体表现。
- 监控要到位:没有监控的优化就是盲人摸象。我建议至少监控CPU、内存、网络、磁盘四个维度。
- 文档要跟上:每次优化都要记录做了什么、为什么做、效果如何。不然三个月后你自己都看不懂当时的代码。
下面这张图是我对这个项目的整体总结,你可以对照着回顾一下:
经验分享:给后来者的建议
最后,我想分享几点个人体会:
- 动手比看书重要:网络编程的书我看了不下十本,但真正让我理解透彻的,是亲手写一个服务器并把它压垮的过程。
- 学会看源码:遇到问题别急着搜博客,先去翻翻nginx、redis的源码。这些顶级项目的代码就是最好的教科书。
- 重视基础:TCP/IP协议栈、操作系统原理、数据结构——这些基础打牢了,遇到任何问题你都能从底层去分析。
- 保持好奇心:为什么这个参数设成64性能最好?为什么用这个锁比那个锁快?多问几个为什么,你会学到更多。
嗯,这个项目到这里就结束了。从设计到实现,从测试到优化,我们走完了一个完整的闭环。希望你能从中收获的,不只是代码,更是一种解决问题的思路和方法。
公众号:蓝海资料掘金营,微信deep3321