先说说我这边的背景。前两年公司有个业务要上大促,业务方提了一个在我看来有点“不讲理”的需求:预算不批、集群不给,就一台物理机,要扛住上万并发。我当时第一反应是“又要马儿跑又要马儿不吃草”,但需求既然接了就得上。后来我把目标拆细、一层层做容量评估,发现单机在特定场景下做到上万并发并不是天方夜谭——前提是你得搞清楚到底要扛的是哪种“并发”。
如果现在有同学也要做这类单机高并发的方案设计,我的建议是别急着搜“高并发怎么办”然后背一堆中间件,先想清楚这几件事:并发指标怎么定义?业务是CPU密集还是IO密集?单机资源上限在哪里?这篇文章我就按我当时从目标拆解到落地压测的完整过程来写,尽量还原每个决策是怎么做的,哪些地方容易翻车,也会拿出实际可用的参数和配置,希望能给你省点弯路。
1. 先搞清楚“上万并发”到底是什么含义
1.1 并发连接数和请求吞吐是两码事
很多人聊“并发”的时候,会把连接数和QPS混在一起。实际上,这两个指标对应的工作压力完全不一样。
假设你的服务是长连接网关,比如WebSocket服务器或者IM消息推送入口,那么“上万并发”通常指在线连接数有1万个。每个客户端连着但大部分时间不发消息,这时候服务端大部分工作其实是维持连接、周期心跳。1万连接听着吓人,其实只要设计合理,普通的8核16G服务器就扛得住,真正吃CPU的反而是心跳包解析和广播推送。
但如果你说的“上万并发”是RPC接口每秒要处理1万次请求,那情况完全不同。这种短连接、高频请求的场景,服务端要处理的是网络报文拆包、路由分发、业务逻辑执行、结果序列化回包。照我经验,纯业务逻辑的前提下,一个8核机器要稳定跑到单实例上万QPS,不是不行,但对接口耗时和复杂度的要求非常严格,平均响应时间得控制在10毫秒以内,而且绝大部分请求不能触碰慢存储。
所以动手前第一件事就是把指标拆清楚,到底是Tps、Qps,还是同时在线连接数?我自己当时做方案汇报第一页就是画表,明确写目标值,免得测量口径不一致,后面扯皮。
1.2 单机资源上限的粗略估算
按我个人习惯,接到这种目标后,会先做一个粗颗粒度的容量估算。理论上可以做数学推导,但工程上更实用的办法是按“核心数x单核心处理能力”来估算。
单核CPU每秒能处理多少次简单请求,可以用这样一个粗账来算:假设每请求平均耗用1毫秒CPU时间(包含业务逻辑、序列化、网络读写),单核每秒理论极限是1000请求;一个8核的机器,理论极限就是8000QPS。要超过这个数,要么降低单请求平均CPU时间,要么靠多核并行,要么接受一定比例的排队。
IO密集场景另算。比如大部分时间是等待下游数据库返回,CPU那一毫秒可能只是发起调用和等待,那瓶颈就到了数据库连接数和网络RTT上。只要数据库能扛住,单机应用可以通过连接池和协程扛更多的并发请求,操作系统帮忙做IO等待调度。
我当时给自己定了个基本策略:先把无状态接口的单机撑到极限,再把所有涉及数据库、缓存的部分能挡就挡、能批量就批量,最后再回头优化可能产生线程阻塞的资源点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 系统层配置决定并发上限的天花板
2.1 文件句柄、端口和连接队列
很多项目代码写得没毛病,但压测到几千连接就报错,一查是操作系统层限制没放开。这类问题在本地开发环境根本暴露不出来,因为并发量太小。
第一道门槛是最大文件句柄数。Linux下一切皆文件,每个网络连接都对应一个文件描述符。系统默认的ulimit -n通常是1024,这点数量连一个像样的压测客户端都不够用,更别提服务端要接收上万个连接。
我当时在服务部署脚本里写死了这几行配置,直接改/etc/security/limits.conf和sysctl.conf,这里贴出来给需要的人参考:
bash复制# /etc/security/limits.conf
* soft nofile 1048576
* hard nofile 1048576
* soft nproc 131072
* hard nproc 131072
再有就是TCP层的内核参数。尤其是服务端主动关闭连接后大量进入TIME_WAIT状态的情况。如果服务端和压测机器/客户端都在同一网段且不会经过NAT转换,可以开启TIME_WAIT复用,缩短TIME_WAIT等待时间;对于新建连接频率非常高的服务端,端口范围也要撑大。
bash复制# /etc/sysctl.conf
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_tw_reuse = 1
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
这里特别说明一下,系统为了防SYN Flood攻击,默认开启tcp_syncookies,不会因为syn backlog满就直接拒绝,但如果backlog太小,高并发握手阶段还是会出现大量连接超时,所以调大是必要的。修改之后记得sysctl -p加载,并在nginx或应用里把accept队列也调大,比如nginx的backlog参数如果还是默认512,那么操作系统参数调得再大也白搭。
2.2 网络IO模型选择,别让线程成为瓶颈
操作系统层面的配置调完之后,决定并发上限的另一大头在应用程序的IO模型。
传统阻塞式IO模型下,一个线程同时只能处理一个连接。如果并发连接有1万,就需要1万个线程,而线程本身是有内存开销的——JVM里一个线程默认栈大小通常1MB,1万线程光是栈就占了10GB内存,这显然不现实。所以Java领域做高并发一般会引入Netty这类基于NIO或者AIO的框架,核心思路就是少量IO线程配合事件循环,一个线程管几千个连接,代价是编程模型变复杂,连接上的业务逻辑不能长时间阻塞IO线程。
Go语言在这块有天然优势,goroutine初始栈只有2KB左右,可以轻松开几万个。而且调度器把阻塞操作处理得比较优雅,写起来还是同步代码的思维,不用像Netty那样搞一堆回调。当时我们目标明确,短期内不可能把大量存量Java服务全部改成Netty,所以新写的高吞吐接入服务直接用Go来落地,现有Java系统只做内部接口,把上万连接的压力挡在外面。
如果你问我做了哪些取舍,核心就一句话:连接管理、报文解析这类IO密集组件选Go或者Netty;重CPU计算、复杂业务事务留到Java/其他成熟框架里,通过接口调用。能共享内存就不走网络,能走协程就不建线程,这个原则能让单机并发上限提升一个数量级。
3. 应用层的线程模型与请求处理设计
3.1 用亚线性模型承接高连接数
连接数上来之后,如果应用进程的线程数跟连接数一比一增长,那无论怎么调内核参数都没意义。所以我在设计接入层时重点就是做“有限线程 + 异步处理”,让固定数量的worker循环去处理队列里的事件。
这个思路实际操作起来有点像餐厅的运营逻辑:一个餐厅座位坐满几千人,如果每个客人配一个专属服务员,餐馆早倒闭了。靠谱的做法是只有少量服务员在过道来回走动,客人招手就响应一下。服务员的精力只放在“接下单、传菜”上,做菜是后厨的另一拨人。客人等菜的时间用叫号器通知,不用服务员一直盯着一桌。
我把这个思路映射到程序里:前端链接器(acceptor)只负责接收新连接并且把连接注册到epoll事件循环里,socket上来的数据触发可读事件后,拆包、解析、放入内存队列。后端固定几十个业务worker消费队列里面的完整请求消息,处理完再通过事件循环把响应写回连接。这样一个8核机器上运行的worker数量可以压在几十以内,但承载的连接可以到几万。
用Go写的话,标准库的net.Listener配合goroutine per connection其实已经不错了。真要追求极致性能,还得注意读写缓冲区的复用,避免每个连接都new一个2KB的临时切片,GC压力在大量并发时会变成隐形杀手。
3.2 限流、背压与超时控制
单机并发做得再高,防御性编程也不能丢。如果上游突然把请求灌进来,进程可能直接在内存里累积出几千兆的积压数据,然后发生OOM。我这几次大促总结下来,接入层必须有这几道闸门:
- 线程池队列长度设上限。队列满了不能无限堆积,要立刻触发拒绝策略,比如直接丢弃并返回503,让上游感知到压力后会自己降级。
- 每个连接的读写超时必须有值。别设0表示无限等待,否则某个客户端断网之后,服务端的连接资源和goroutine全部被占住不放。我通常设ReadTimeout=10s,WriteTimeout=10s,空闲连接超过60s就主动断开,让客户端重连。
- 背压信号要能传给上游。如果服务端已经处理不过来,最好在响应头或者错误码里明确标识,上游看到之后可以做本地熔断而不是继续加重压力。
另有一个很有用的做法是全局计数器做滑窗限流。我会在网关内存里按秒统计请求数,超过阈值就直接返回“系统繁忙”。单机方案下线上的保护条件就是限流,这比依赖监控报警再扩容要实在得多。哪怕集群里只有一台机器,在入口守住“接受的并发不超过系统设计上限”这条线,就能保证存活。
3.3 无状态化设计,让伸缩成为可能
单机架构里没有服务发现和负载均衡,开发的时候容易写出用本地内存保存用户会话的代码。这种做法在并发低的时候没事,一旦后续要把单机扩容成多机,这类隐式状态就全部变成了改造障碍。
我在当时设计接口时强制要求:允许的单机内存状态只能是最新计数、滑动窗口等这类可丢失的数据;用户的登录态、购物车、关键业务数据统统一律放到Redis或者数据库里。哪怕现在只部署一台机器,也按照将来会拆成多机的方式来写。
这是我从很多案例里学到的思路:单机高并发本质上是对资源利用率和代码质量的极限挑战,做出来的设计绝不能是“一次性单机版”,要保留将来能水平扩展的接口语义。这套做法很关键,后来业务量上来后,我们从单机PHP/Java进程扩展到多机,只改了部署层和接入层,没有重写业务代码。
4. 中间件与存储层的“并发锁”与吞吐设计
4.1 缓存设计:击穿、穿透、雪崩一个都不能少
应用层扛住了并发,存储层往往是真瓶颈。如果每个请求都去数据库查一次,在几千QPS下数据库就废了。所以绝大多数高并发系统的第一道防线是加缓存。
我习惯在项目里引入Redis做热点数据缓存,但缓存用不好会有三件套问题:穿透、击穿、雪崩。
穿透是指查询一个一定不存在的数据,因为缓存里没有,每次请求都要去数据库查。我的惯用方案是缓存一个空值对象,设置较短的过期时间比如60秒,或者用布隆过滤器先挡一层。布隆过滤器对这种“不存在”查询的过滤效果最好,但实现和维护要一点成本,适合key非常多的场景。
击穿是指某个热点key突然失效,同一时刻大量请求同时回源到数据库。这个情况概率不小,比如活动开秒那一瞬间恰好缓存过期。我的做法是做互斥锁,在缓存重建的时候只允许一个线程去数据库加载,其他线程等待几毫秒后重新查缓存。这个“单飞”逻辑虽然代码很简单,但特实用,可以避免数据库被同一瞬间的并发流量打爆。
雪崩是大量key同时过期,压力瞬间全部落到数据库。处理方式比较粗暴:给过期时间加一个随机偏移量,比如原本10分钟过期,实际TTL在8~12分钟之间随机分布,避免所有缓存key在同一时刻集体失效。
4.2 数据库并发控制与优化策略
如果缓存命中率已经接近95%,剩下那5%的请求落到数据库也还是有一定并发量。此时数据库侧要做的不是开一万个连接,而是要控制好连接池大小和事务粒度。
先说说连接池,很多人有个误区,觉得单机并发高,数据库连接池就要配很大。其实连接数大于CPU核心数之后,再增加连接并不能提升吞吐,反而会引入更多的上下文切换。PostgreSQL和MySQL都是如此,每个活跃连接背后都有一个线程或进程在消耗CPU资源。连接池的合适大小可以粗略按“CPU核心数x2+磁盘数”来设置,如果纯SSD环境,8核配20左右就足够支撑几千QPS的业务了。
然后是锁的问题。高并发场景下,数据库行锁竞争是拖慢响应的大头。假设一个update语句更新同一条记录,每秒有上千次并发,那数据库的锁等待队列会非常长。我的建议是在业务设计上就规避热点行更新,比如库存扣减,不要每次直接在总库存字段上update,而是把总库存拆成100个独立的库存桶,每次随机选桶扣减。这样单行并发从1000次除以100变成10次,冲突概率大幅度下降。
还有一种是悲观锁改乐观锁。很多不需要严格实时一致性的场景,比如更新“已读标记”、累计用户积分等,可以先查询出来,然后带版本号做条件更新。影响行数为0再重试或者忽略。这种乐观策略能省掉数据库行锁带来的无数阻塞。
我的最终落地方案里,写操作全部走MQ异步削峰,数据库只负责最终一致性落库,应用层不再同步等待写成功再返回。比如下单后先返回“已受理”,真正去数据库落地是后台慢慢消费消息。这样用户侧看到的响应时间极其稳定,数据库的压力也平滑很多。这种方案要注意消息丢失和重复消费,我一般设置消费记录表做幂等,保证业务最终正确。
4.3 Kafka削峰与消息积压兜底
前面提到MQ,我们把Kafka作为削峰核心组件。高并发瞬时流量很容易就把单机的处理能力跑满,但有了消息队列以后,流量先进Kafka缓冲,消费者按自己的能力慢慢消费。
有人看到这里会问:单机架构用Kafka是不是太重了?我用下来的感受是,这取决于你的峰值到底多高。如果峰值是每秒几千请求,Redis的List做简易队列也够;但如果是每秒几万条消息涌入,Kafka的分布式日志存储优势就很明显,能攒住大量数据不丢。
Kafka这边要做好的事情主要有三个:分区数调成和消费者并发度一致,避免某个分区成为热点;消费端处理一定要快,不能在消费逻辑里做耗时操作,可以把结果先写到临时表或者Redis,再批量刷数据库;要监控消费延迟,一旦积压超过阈值就加机器或者动态调整批量大小。当时的经验是单个分区顺序写盘的能力很强,但消费者如果每消费一条请求就同步调一次远程服务,吞吐会受限于远程服务性能,所以把消费端也都改成了批量聚合再调用,省掉大量RTT开销。
5. 真实压测与问题排查实录
5.1 压测工具选择与参数设计
系统开发完成后并不是直接上线的,我习惯做一轮完整的压测和调优。压测工具我常用的有wrk、Apache ab、JMeter,还有Go自带的load generator。选哪个取决于你要模拟什么类型的流量:ab擅长简单HTTP GET/POST,wrk支持Lua脚本可以构造复杂请求,JMeter适合有业务流的压测。
比如当时按HTTP短连接做接口压测,我直接用wrk写了压测命令:
bash复制wrk -t8 -c1000 -d120s --latency -s ./post.lua http://target/api
-t8表示开8个压测线程,-c1000表示保持1000个并发TCP连接,-d120s表示压测持续120秒,--latency打印延迟分布。Lua脚本里可以放置每个请求的body,甚至可以对token做预处理。
压测的时候要留意一个点:压测机本身也会成为瓶颈。如果压测机不是多核大内存,它自己可能先撑不住导致产生不了足够的请求。最好用独立的几台压测机器,逐个增加并发,这样得到的数据才是服务端的真实能力。
压测过程不是跑一次就拿到最终值,而是阶梯式压测:200并发跑3分钟,500并发跑3分钟,1000并发再跑,一边观察TPS、响应时间、错误率、CPU、内存、GC等指标的变化,定位到某个点突然恶化就停,然后分析。
5.2 一次“QPS卡在5000”的复盘
我记得有次压一个Go写的网关服务,压到大概5000QPS后死活上不去,CPU还有余量,但延迟开始明显抖动。我看了pprof,发现锁等待占了很大比例,仔细排查才发现问题出在日志库的全局锁上。
当时为了方便排查问题,每请求打印了一行访问日志,里面包含耗时、用户ID等字段,而且在业务逻辑里同步写到了stdout,再由systemd-rsyslog进行转发。这个同步写日志的IO操作在负载上来时直接拖垮了吞吐。后来改成异步批量日志写入,先在内存缓冲,达到一定条数或者间隔一段时间才write一次,QPS瞬间涨了上去。
这个案例值得所有做高并发开发的同事记住:日志、监控、埋点这些看似非业务的逻辑,往往是高并发下最容易先崩的点。上线前我特意扫了一遍全项目,把所有循环里的日志打印全部干掉,保留的日志也统一走异步。
5.3 连接文件句柄耗尽的定位方法
另一次遇到的经典错误是莫名其妙的“too many open files”,进程没有崩溃但拒绝新连接。用lsof或者ss检查才发现连接数确实飙到了系统上限,修改limits.conf后问题消失。
这类问题遇到得多了,我会在压测前先执行一遍环境检查脚本,把关键参数都拉出来打出来对比确认,防呆。你要是临时接手一个别人搭好的服务,接手时也先看这几个数字,很多高并发问题在运行前就已经注定了。
5.4 常见问题速查表
我把压测和上线后遇到比较多的问题整理成了一张表,方便后续排查时快速定位:
| 现象 | 大概率原因 | 排查方式 | 解决手段 |
|---|---|---|---|
| 大量TIME_WAIT连接 | 服务端主动关闭连接;短连接过多 | ss -s统计连接状态 | 开启tw_reuse;改用长连接/连接池 |
| CPU不高但QPS上不去 | 锁竞争、日志同步、内存分配频繁 | pprof/lock contention分析 | 优化锁粒度、异步日志、复用对象 |
| 连接数到一定值后新请求失败 | nofile限制、backlog队列满 | ulimit -n;ss -lnt | 调大文件句柄数、somaxconn |
| 偶发超时严重 | GC停顿 | 查看GC日志/CADVISOR | 堆调优、减少大对象分配 |
| 数据库连接池爆满 | 线程阻塞在DB查询上 | show processlist | 优化SQL、加缓存、调整池大小 |
| 接口耗时低但总吞吐上不去 | 网络小包太频繁 | wrk压测观察包量 | 合并请求、使用批量接口 |
6. 避坑指南与个人体会
6.1 进程线程数不是越大越好
有些同学的优化思路是把线程池调到两三万,以为这样就能自然扛住高并发。CPU密集型业务里,这种做法只会导致大量上下文切换,每个任务都分不到连续的执行时间,结果吞吐反而下降,响应时间也急剧升高。理想的线程数应该略大于CPU核心数,让每个线程一直活跃在计算状态。对于IO密集场景,线程数可以适当多一些,但也不能无上限,需要根据压测定出合适的值。
线程池的队列也不要设置成无界队列,无界队列同样会导致内存无限膨胀。一个LinkedList塞几百万个请求,进程离OOM也不远了。有界队列配合拒绝策略才是正解。
6.2 所有耗时操作都要想好降级预案
单机架构最怕的就是“处处是单点”。我经常把每个外部依赖都排一遍,想好如果它挂了,我要怎么让进程依然活着。比如Redis挂了,是不是可以先直接查库?数据库挂了,是不是可以先返回缓存的旧数据,哪怕过期也行?消息队列挂了,是不是可以先本地写文件,等恢复以后重放?
这些降级熔断的逻辑在高并发系统中是保命手段。平时流量小可能一辈子不触发,但每年一次大促,一次触发就能救整个系统。所以做单机方案时,哪怕评审时间再紧,我也要求把这个部分写清楚。
6.3 指标监控与容量水位
单机并发做上去以后,没人能靠肉眼判断当前系统离崩溃还有多远,所以一定要有实时监控。我用Prometheus加上Grafana,把每分钟请求数、P99延迟、错误率、JVM/Go运行时指标、CPU、内存、磁盘IO、网络带宽全部铺到大屏上面。还有业务自定义指标,比如队列堆积长度,消费落后差值,都是定位问题的重要信号。
容量水位这个概念很重要。比如系统压测极限是1万QPS,那我就把线上报警阈值设置在7000,超过这个数就触发扩容或限流。别等到系统完全打满才反应,到那会儿可能已经发生了很多错误请求,这类的损失都是不可逆的,尤其是涉及到下单或者支付的场景。
6.4 个人经历的心得总结
做这一轮单机上万并发的整体设计下来,我最大的体会有两个。第一个是“先定义问题,再找方案”,因为不同“并发”的含义会导致完全不同的设计方向;第二个是“单机方案要为未来留退路”,做设计时多考虑一层可扩展性,以后不会太痛苦。
最后再分享一个比较实用的经验:压测的结果一定要拿生产环境的真实流量来校准。我在实验室环境压到1.2万QPS很轻松,上了生产发现业务逻辑复杂、日志更重、下游延迟更高,实际只能跑到6000多。后来逐步做了日志异步、缓存优化、减少跨机房调用,才把线上压到接近1万。所以压测数据只能作为参考基准,要留好40%以上余量,别把理论极限当作线上的保证容量。
如果真的遇到单机方案扛不住的那天,也别硬扛,该加机器就加机器。单机架构的首要目标是充分利用资源,架构的下一步进化是把它从单点变成集群。掌握这些底层的并发设计原理,后面哪怕换到任意一个分布式环境,思路依然是通用的。
