1. 第一次看到这个作业要求,我就知道事情不简单
HCLA第二次作业这个标题,在我电脑里躺了整整三周。这门课的完整名字是High Concurrent Load Application,翻译过来就是高并发负载应用实战,课程目标不是让你背概念,而是逼着你亲手把一个"能跑但很烂"的服务,调优成一个"扛得住压力"的服务。第二次作业的核心任务,是搭建一个模拟秒杀场景的库存扣减服务,要求至少支撑5000并发连接,接口平均响应时间低于500ms,P99响应时间低于1秒,最关键的一条是——库存绝对不能超卖。
当时看到这个要求,我第一反应是"这不就是个普通的后端接口嘛",但真正动手以后才发现,这作业的精髓根本不在业务逻辑,而在于它考验的是你对整个技术栈的理解深度:操作系统资源限制、网络协议栈、并发模型、连接池、缓存一致性、日志IO,哪一块没吃透,高压之下都会露馅。这篇文章我就把从设计、压测、调优到被答辩追问的全过程全部拆开,把我踩过的坑、算过的账、改过的代码,以及最终数据对比都记录下来。如果你也在做类似的并发作业、秒杀项目,或者只是想弄明白"性能优化到底该怎么入手",这篇复盘应该能帮到你。
先说说作业的完整要求。这次作业分为四个交付物:源码(必须带README)、可复现的压测脚本、压测报告(优化前后的对比数据缺一不可)、以及一份不超过15页的复盘文档。老师明确说了一句话,"我不要看你怎么把代码写得天花乱坠,我要看你能不能拿数据讲清楚为什么慢、为什么快"。这句话基本就是整门课的指导思想,也是我后面所有操作的主线。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 并发架构选型:为什么我没有一上来就堆线程池
2.1 三种并发模型的实测对比
拿到作业题目之后,第一个拦路虎就是并发模型选型。当时我们班分成了几派:一组坚持用Java的线程池,理由是Spring Boot熟、生态全;一组想用Node.js的事件循环,觉得单线程异步省心;我一开始也犹豫了很久,最后选了Go的goroutine方案。这里我不是说Java不好,而是把三种方案放在"秒杀扣减库存"这个具体场景下做了个对比实测。
先看多线程模型。Java里最经典的做法是FixedThreadPool配合BlockingQueue,把每个请求扔进线程池执行。但线程是有成本的,一个线程默认栈大小1MB,即使真没用到那么多内存,JVM也要为它预留地址空间。实际压测中,我把核心线程数调到200,压到3000并发时,线程上下文切换带来的CPU开销已经非常明显了,top里看到大量sy占比,也就是内核态时间超过30%。更无语的是,任何涉及数据库连接的线程阻塞时,它背着一个完整线程栈在等IO,这1MB内存就白白挂着。
多进程方案我也简单试过,通常都会配合nginx做负载均衡部署多个worker进程。好处是隔离性好,一个进程挂了不影响全局,但进程间通信、共享状态管理成本很高,尤其是扣库存这种高频操作需要共享数据,要么走Redis原子操作,要么走数据库行锁,多了很多网络往返,延迟很难压下去。
然后说Go的goroutine。goroutine的初始栈只有2KB,按需增长,一个4GB内存的实例轻轻松松拉起几万个goroutine。更重要的是Go在语言层面把异步IO封装成了同步写法,你用net/http写一个handler,代码看起来是同步的,底层却是epoll在驱动。这对开发效率的提升是肉眼可见的,不用自己搞回调嵌套,也不用手动管理线程生命周期。
2.2 Go方案的落地细节与踩坑
选型之后就是落地。我的服务结构其实特别简单:一个HTTP接口接收请求,请求参数是用户名和商品ID,然后通过一个Redis Lua脚本完成库存预扣,再异步把扣减结果写入MySQL。整体架构图我在文档里画了,这里不贴图,重点讲代码和坑。
先看初始版本的handler代码骨架:
go复制func (s *Server) handlePurchase(w http.ResponseWriter, r *http.Request) {
userID := r.FormValue("userId")
productID := r.FormValue("productId")
// 检查库存并扣减,走Redis Lua脚本保证原子性
ok, err := s.redis.EvalSha(s.ctx, s.scriptHash, []string{s.stockKey(productID)}, 1).Int()
if err != nil {
http.Error(w, "internal error", 500)
return
}
if ok == 0 {
http.Error(w, "sold out", 400)
return
}
// 异步落库,这里用的是Go channel
s.asyncQueue <- &Order{UserID: userID, ProductID: productID, Time: time.Now()}
w.Write([]byte("ok"))
}
这段代码看起来没问题,但我在做完第一轮压测后发现两个隐患。第一个隐患是asyncQueue这个channel的容量,一开始我设了1024,压测时一旦队列满了,handler就会阻塞在发送操作上,反过来阻塞HTTP请求处理,表现就是响应时间突然飙升。第二个隐患是goroutine泄漏,如果某个业务panic没有恢复,或者Redis调用一直不返回,goroutine不会自动回收,积少成多整个进程就崩了。后来我在每个handler入口加了defer的recover,并给channel发送操作增加了超时控制:
go复制select {
case s.asyncQueue <- &Order{UserID: userID, ProductID: productID, Time: time.Now()}:
default:
// 队列已经满了,丢弃本次落库请求但返回用户成功
// 这里要接一个补偿机制,后面细说
}
这个select...default是典型的非阻塞发送模式,宁可丢消息也不能拖垮主流程。但丢消息就得有补偿,我的方案是每隔10秒扫描Redis中未落库的扣减记录,补写MySQL。这是作业里一个加分项,也确实是生产系统中常见的"最终一致"思路。
2.3 为什么说这个选型不是随便拍脑袋
可能有人觉得,一个作业而已,用Java线程池不也能交差?确实能。但如果你看数据,就会明白差距在哪。我后来在压测机上拿同样的场景分别跑过Java线程池版和Go版,在5000并发、读多写少的情况下,Go版本的内存占用只有Java版本的四分之一左右,15秒内的平均响应时间也要低20%左右。这背后的原理说穿了就是两条:一是Go的goroutine调度器把M对N的映射做到了极致,空闲goroutine几乎不占资源;二是Go标准库的netpoller让每个连接不需要独占一个线程。
不过我也要泼一盆冷水,Go不是银弹。如果你的业务是CPU密集型的纯计算任务,goroutine的优势就没那么大,反而可能因为GC停顿影响稳定性。选型要先看清楚自己的场景是IO密集型还是计算密集型,压测数据会告诉你答案。
3. 压测前的环境准备:这些系统参数不改,压测数据全是废的
3.1 文件描述符、端口范围与TCP超时参数
在写压测代码之前,我先花了一天时间调环境。很多人第一次做压测就直接上工具,结果数据一塌糊涂还找不到原因,其实多半是系统参数没调。第一个必改的就是文件描述符限制,Linux默认ulimit -n是1024,也就是一个进程最多同时打开1024个文件描述符。做压测时,每个TCP连接都要占用一个fd,5000并发一压上来,不到3000连接就报"too many open files"。调低限制很简单:
bash复制ulimit -n 100000
但注意,这个命令只对当前shell有效,如果要永久生效,得改/etc/security/limits.conf,加上:
code复制* soft nofile 100000
* hard nofile 100000
然后就是TCP层的参数。压测会产生大量短连接,尤其是wrk这类工具默认会频繁重建连接,导致客户端机出现大量TIME_WAIT状态的socket。TIME_WAIT本身是为了保证数据可靠传输,但积累多了会占满本地端口,让新连接无法建立。我当时压测机上ss -s显示TIME_WAIT一度超过3万个,端口范围默认只有net.ipv4.ip_local_port_range = 32768 60999,也就是最多28000多个端口可用,直接就不够用了。调整方案是扩大端口范围并开启TIME_WAIT复用:
bash复制sysctl -w net.ipv4.ip_local_port_range="1024 65535"
sysctl -w net.ipv4.tcp_tw_reuse=1
sysctl -w net.ipv4.tcp_tw_recycle=0
tcp_tw_recycle这个参数我不建议开,它在NAT环境下会导致丢包问题,很多生产事故就是它造成的。tcp_tw_reuse只对发起连接的一方生效,配合时间戳机制可以安全复用TIME_WAIT,基本够用了。
3.2 压测工具选型:k6、wrk、JMeter到底用哪个
压测工具这一块,我们宿舍四个人分别用了四套工具,最后汇总数据的时候差点吵起来,因为工具不同,同一套服务压出来的QPS能差一倍。这里我把我自己用过的三个工具做了个对比表格:
| 工具 | 脚本能力 | 资源占用 | 分布式支持 | 适合场景 |
|---|---|---|---|---|
| wrk | 弱,只能写lua片段 | 极低 | 不方便 | 快速测单机极限吞吐 |
| JMeter | 强,GUI配置 | 高 | 支持,但配置麻烦 | 复杂场景和团队协作 |
| k6 | 强,JS脚本 | 低 | 支持,Cloud或K8s | 常态化压测与CI集成 |
我最后选了k6,原因是它既能写逻辑,又能轻松输出P95、P99这类分位值,而且还支持options里定义不同阶段的并发数,正好符合我"阶梯加压"的需求。举个例子,我的k6脚本里这样定义场景:
javascript复制export const options = {
stages: [
{ duration: '60s', target: 100 },
{ duration: '60s', target: 500 },
{ duration: '60s', target: 1000 },
{ duration: '60s', target: 2000 },
{ duration: '60s', target: 5000 },
{ duration: '30s', target: 0 },
],
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(99)<1000'],
},
};
stages定义的就是阶梯加压的档位,每档跑60秒,让服务有充足的时间暴露问题。thresholds是断言,如果错误率超过1%或者P99超过1秒,脚本跑完会直接返回非零退出码,方便挂在CI上。
3.3 设计压测场景:并发数、QPS、响应时间怎么设定
设计压测场景不是拍脑袋,得先算清楚目标。作业要求5000并发连接,平均响应时间小于500ms,那么理论上QPS上限就是5000 / 0.5 = 10000。但我心里清楚,这个10万QPS是极限情况,真实业务里不可能每个请求都均匀分布,所以我把目标定在压测时能达到8000QPS左右,P99小于1秒,这样平均响应时间自然就能控制在500ms以内。
这里解释一个新手容易搞混的概念:并发数和QPS不是一回事。并发数是在线请求的"同时在途"数量,QPS是每秒完成的请求数量。用生活化的例子说,餐厅一共有5000个座位,每个客人吃饭需要0.5分钟,那么一小时内能接待的客人是5000×(60/0.5)=600000人,这里的5000是并发数,600000除以3600秒大概就是166QPS每秒。所以并发数、响应时间和QPS这三者必须同时看,缺一个数据就是不完整的。
压测时我还会做一个"冷启动"处理:正式记录数据前,先跑一组低并发请求,比如50并发跑30秒,让服务完成JIT编译、连接池预热、缓存预热。否则你记录的可能是服务还没准备好的数据,后面分析会很误导。
4. 第一轮压测翻车实录:瓶颈定位的完整排查链路
4.1 现象:请求大面积超时,CPU却只有30%
环境准备妥当之后,我第一次信心满满地跑起了5000并发压测。k6的终端里,错误率一路飙升,最终定格在23%,P99响应时间高达2.3秒,平均响应时间900多毫秒。最诡异的是,我去看服务所在机器的top,CPU使用率居然只有30%左右,内存也没满,看起来根本没有被压垮。
这个现象在性能问题的排查里非常典型:"资源没用满,服务却已经不行了"。它意味着请求阻塞在了某个外部依赖上,而不是计算资源不够。按照我的排查顺序,第一步先看应用日志,第二步看网络连接状态,第三步看数据库和中间件指标。
4.2 排查过程:日志、连接数、数据库轮番排查
先翻日志,发现大量"获取连接超时"的错误,这个信息直接指向数据库连接池。我用的连接池是GORM默认配置,最大连接数只有10。这是个什么概念?如果每个SQL查询平均执行30ms,那么10个连接一秒最多执行333个查询,也就是说这个服务处理数据库请求的天花板就是333QPS,而我的目标是8000QPS,差了快25倍。这个计算一定要现场算给评审看:连接数 = QPS × 平均查询耗时,如果目标是8000QPS,平均查询耗时30ms,那至少需要8000×0.03=240个连接。
然后我又用ss -s查看网络状态,发现服务端有大量SYN_RECV状态的连接,说明客户端的握手请求进来了,但服务端的accept队列已经满了,内核在丢弃后续的新连接请求。这个队列的长度由net.core.somaxconn和应用程序listen时的backlog决定。Go的http.Server默认backlog是128,虽然远超Linux的默认backlog了,但在5000并发面前还是远远不够,我把它调到了1024。
接着看数据库。通过SHOW PROCESSLIST能看到一堆查询在等待,基本都卡在同一张表上。我当时的表结构没给库存表加索引,导致每次扣减库存走全表扫描。30ms的平均延迟就是这么来的。这里我加了一个联合索引(product_id, user_id),查询时间直接降到1ms级别。
4.3 真正的元凶:日志同步写和连接池两个坑叠加
当连接池调到了240个,数据库查询加上了索引,我本以为问题解决了,再压一轮还是发现P99在600ms左右徘徊,始终降不下去。后来我用pprof抓采样,发现大量goroutine阻塞在日志写入的IO操作上。我用的日志库默认是同步写盘,每个请求的访问日志都要经过一次write系统调用刷入磁盘,在高并发下这无异于在流水线上给每个工人加了一道签字工序,直接拖慢了整个链路。
解决方案是把日志改成异步写。我在代码里封装了一个带buffer的日志channel,业务goroutine只需要把日志消息丢进channel,由单独的goroutine批量刷盘。这样业务线程永远不会因为落盘而阻塞。改完之后,P99从600ms降到了250ms左右。再加上前面连接池和索引的优化,整体数据终于好看了。
第一轮压测的排查过程让我彻底明白一件事:性能问题从来不是一个点的问题,而是一条链路上多个环节叠加的结果。连接池太小、查询太慢、日志阻塞,每一条看着都不致命,但三者叠加,5000并发就成了压垮骆驼的最后一根稻草。
5. 优化三板斧:从连接池、缓存到批量写入的实操记录
5.1 数据库连接池参数调优的计算逻辑
连接池的大小不是越大越好。我一开始直接从10调到了500,结果压测时数据库CPU飙升,查询延迟反而上升了。这是因为MySQL每个连接都要占用内存,而且并发事务太多会加剧锁竞争和buffer pool争用。正确做法是先按公式估算,再通过压测验证修正。
公式很简单:最大连接数 = 期望QPS × 单次查询平均耗时 + 预留Buffer。当时我的目标是8000QPS,MySQL查询平均耗时优化后为1ms,那么理论需要8000×0.001=8个连接就够,但实际因为网络开销、GC停顿、连接池本身的分配策略,我加了安全系数,把最大连接数设置为8×20≈160,最小空闲连接保持在20。这里和之前的"240"并不矛盾,因为之前查询是30ms,现在优化到1ms了,需要的连接数自然大幅减少。
用表格对比一下优化前后连接池参数:
| 参数 | 优化前 | 优化后 |
|---|---|---|
| 最大连接数 | 10 | 160 |
| 最小空闲连接 | 2 | 20 |
| 连接最大生命周期 | 不设置 | 60s |
| 获取连接超时 | 默认 | 3s |
设置连接最大生命周期很有必要,因为超过wait_timeout的MySQL连接会被服务端主动断开,如果不让客户端定期重建连接,就会踩到"连接已失效但客户端不知道"的坑,请求第一次执行时白白多一次错误重试。
5.2 Redis缓存引入后的穿透、击穿和雪崩处理
连接池调优之后,性能瓶颈转移到了数据库本身。虽然单次查询只要1ms,但QPS到6000以上时,数据库CPU已经到80%了,而且这还只是一个只读操作——如果真实的秒杀场景里有库存检查、订单查询、用户校验等一堆读操作,数据库根本扛不住。于是我引入了Redis做缓存层,把库存数量和商品热点信息放在Redis里。
这里有个关键点:扣减库存必须用Redis的Lua脚本保证原子性。因为秒杀场景下多个并发请求同时扣减库存,如果用先GET再DECR的方式,中间一定会出现竞态条件。Lua脚本在Redis里是原子执行的,我把检查库存和扣减库存两步合并成一个脚本,这样就不会超卖。示例脚本如下:
lua复制local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock <= 0 then
return 0
end
redis.call('DECR', KEYS[1])
return stock
缓存引入后又来了三个新问题。第一个是缓存穿透:如果有人恶意用一个不存在的商品ID频繁刷接口,请求会直接打到数据库,绕过缓存。解决方式是缓存空值并设置短过期时间,或者在查询前用布隆过滤器拦截。第二个是缓存击穿:某个热点商品的缓存正好在同一时刻过期,导致大量请求同时打到数据库。解决方式是加互斥锁,只有拿到锁的请求才能去查数据库重建缓存,其他请求先等一会儿再重新读缓存。第三个是缓存雪崩:大量key在同一时间段过期,数据库瞬间被洪水淹没。解决方式最简单实用——给过期时间加一个随机值,比如60+rand(0,30)秒,避免集中失效。
5.3 日志异步化与批量刷盘的改造细节
刚才提到日志是第二轮瓶颈,我在这里展开讲改造细节。原本我用的是log.Printf直接输出,高并发下每次输出都是一个磁盘写操作。改为异步后,我在项目里加了一个全局的日志channel:
go复制var logCh = make(chan []byte, 4096)
func init() {
go func() {
var buf bytes.Buffer
for msg := range logCh {
buf.Write(msg)
buf.WriteByte('\n')
if buf.Len() >= 4096 {
buf.WriteTo(logFile)
buf.Reset()
}
}
buf.WriteTo(logFile)
}()
}
func logAsync(msg string) {
select {
case logCh <- []byte(time.Now().Format("2006-01-02 15:04:05") + " " + msg):
default:
// channel满了直接丢弃,保证业务无阻塞
}
}
这里有一个关键设计:logCh的容量是4096条,满了之后我选择丢弃日志而不是阻塞业务线程。这个决策在线上系统里是有争议的,因为丢日志意味着丢了排障线索,但在我这个作业场景里,日志只是辅助分析数据,优先级远低于业务可用性。真实生产系统通常会增加一个本地文件日志的兜底方案,或者把队列加大,不到万不得已不丢。大家根据自己的场景取舍。
5.4 优化后的压测数据对比
最后把优化前后的核心指标放在一起对比,这是复盘报告里最值钱的一页:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 最大QPS | 约2100 | 约10500 |
| 平均响应时间 | 912ms | 42ms |
| P95响应时间 | 1800ms | 120ms |
| P99响应时间 | 2300ms | 180ms |
| 错误率 | 23% | 0.2% |
| CPU使用率 | 30%(阻塞) | 70%(有效利用) |
| 内存占用 | 600MB | 780MB |
内存涨了一点,主要来自Redis客户端缓存和异步日志队列,这个幅度完全可接受。整个优化过程没有换更贵的机器,没有加服务器,只是在软件层面解决了连接复用、查询效率、缓存一致性、日志IO四个问题,性能翻了好几倍。这就是这种作业和教科书上理论的最大区别:你永远不知道你的系统瓶颈在哪,除非你压了它。
6. 优化结果对比与课堂答辩中被追问的高频问题
6.1 答辩现场三个高频问题,附我的回答思路
作业提交后,有一轮类似答辩的技术评审。评审老师盯着我的压测报告问了很多问题,其中三个问题的杀伤力最大,我在这里整理下,几乎可以适用于所有性能优化类项目。
第一个问题:为什么连接池从10调到160,QPS就翻了几倍?底层原理是什么?我的回答分两层:第一,连接池复用让TCP三次握手的开销被彻底摊薄,原来每次查询都可能重新建连,现在一条TCP长连接反复用,省下的握手时间非常可观;第二,更关键的是并发能力好了,连接池本质上是一个"信号量",它限制了同时执行的数据库查询个数,10个连接意味着即使来了5000个请求,同一时刻只有10个能去访问数据库,其余4890个全部在排队等连接,这个排队时间就是响应时间的大头。调大连接池等于扩宽了闸门,让请求能更快通过。
第二个问题:Redis缓存和数据库的数据一致性怎么保证?我当时用的是Cache Aside模式,就是读的时候先读缓存,读不到再读数据库然后回填缓存;写的时候先更新数据库,再删除缓存。这个模式好在实现简单,但脑子里要知道它有个经典的竞态窗口:如果线程A更新完数据库还没来得及删缓存,线程B读取了旧缓存并回填,就会导致脏数据长期存在。我的处理是配合"延迟双删"——先删一次缓存,更新数据库,等200毫秒再删一次缓存,把竞态窗口内的旧数据清掉。当然这也不是百分之百可靠,生产上更稳的路子是订阅数据库binlog异步同步缓存,但作业里做到延迟双删已经完全够用了。
第三个问题:如果并发再翻10倍到5万,你的这套架构哪些地方会最先撑不住?这个问题值得每个人都好好想想。我当时给的答案有三个:第一是单机HTTP服务的accept队列和goroutine调度会先到极限,必须水平扩容加负载均衡;第二是Redis单实例的带宽会成为瓶颈,需要做集群分片,把热点商品分散到不同节点;第三是MySQL主从同步的延迟会被放大,如果读多写少还可以靠加从库解决,但库存扣减是写操作,单点写很容易就撞到天花板,可能要引入分库分表甚至本地事务加MQ异步对账的复杂架构。我觉得这个问题问得很妙,因为它逼着你跳出"调参优化"的舒适区,去思考架构设计,而架构设计才是真正决定系统上限的东西。
6.2 这次作业里,我觉得最难的不是写代码
复盘整个HCLA第二次作业,最让我难受的不是连接池参数怎么配,也不是Lua脚本怎么写,而是"定位瓶颈"这个过程。说难听点,调优就是一次跨界侦探工作,你既要懂操作系统,又要懂网络协议栈,还要懂数据库、缓存、日志、运行时,任何一个环节有知识盲区,你都会在数据面前手足无措。反过来,一旦你把压测、监控、日志、pprof这些工具链用熟练了,排查问题就会变得有章法,不会再靠玄学改代码。
比如我第一次看到CPU只有30%时,如果不懂线程阻塞和IO等待的关系,大概率会去调大worker数量,结果越调越糟糕。而我当时做了个正确的判断:高CPU使用率说明计算瓶颈,低CPU使用率说明等待瓶颈,这两者处理方向是完全相反的。这就是经验的意义,知道往哪个方向排查,比知道怎么改代码重要得多。
这次作业的数据里还有个让我印象很深的细节:优化后QPS超过了1万,达到了10500左右,但离我把5000并发和500ms响应时间换算出来的理论值10000只高了一点点。这说明什么呢?说明这个服务在压测条件下已经基本上把硬件能力吃干净了,后续想再提升,只能靠增加并发机器或者拆分服务,单机调优的天花板就在这里了。做完这个分析,我对"性能优化是有尽头的"这句话有了更真实的体会。
最后说一个这次作业学到的小技巧:压测报告里的图表,不要只放折线图,最好加一张瓶颈定位表格,把每个阶段的现象、数据、定位方法、解决方案写清楚。这比单纯展示最终优化结果更容易让别人信服,也是真正体现你专业能力的地方。后来我把这份报告推荐给下一届学弟学妹当模板,他们按照这个思路去做HCLA第三次作业,据说少踩了一大半坑。
