这是一个非常高频的面试题,面试官可以从多个角度,考查技术的广度和深度。今天这篇文章,我们就来深入聊聊设计高并发系统时需要关注的那些关键点。

1 页面静态化
对于高并发系统的页面功能,我们必须进行静态化设计。如果每次用户访问页面时,都通过服务器动态渲染,海量并发访问会导致服务端压力巨大,页面可能无法正常加载。
我们可以使用Freemarker或Velocity这类模板引擎来实现页面静态化。以商城首页为例,我们可以通过一个Job定时任务,每隔一段时间就查询出所有需要在首页展示的数据,然后汇总,并使用模板引擎生成一个独立的html文件。
接着,将这个html文件通过shell脚本自动同步到前端页面服务器上。
2 CDN加速
页面静态化虽然能提升速度,但还不够。用户分布在全国各地,网络延迟各不相同。如何才能让用户以最快速度访问到活动页面呢?答案就是使用CDN(Content Delivery Network,即内容分发网络)。

CDN加速的基本原理,是将网站的静态内容(如图片、CSS、JavaScript文件等)复制并存储到分布在全球各地的服务器节点上。当用户请求访问网站时,CDN系统会根据用户的地理位置,自动将内容从离用户最近的服务器节点分发出去,从而实现快速访问。这能有效降低网络拥塞,提高用户访问的响应速度和命中率。
国内常见的CDN提供商有阿里云CDN、腾讯云CDN、百度云加速等。
3 缓存
在高并发系统中,缓存可以说是必不可少的技术之一。目前缓存主要有两种:
- 基于应用服务器内存的缓存,也就是我们常说的本地缓存或二级缓存。
- 使用缓存中间件,比如:Redis、Memcached等,这种是分布式缓存。
这两种缓存各有优缺点。二级缓存的性能更好,但因为是基于应用服务器内存,如果系统部署了多个节点,可能会存在数据不一致的情况。而Redis或Memcached虽然性能上可能略逊一筹,但它们是分布式缓存,可以避免多个服务器节点间的数据不一致问题。
缓存的典型用法如下:

引入缓存后,可以显著减轻数据库的压力,提升系统性能。有些场景甚至会同时使用分布式缓存和二级缓存。比如获取商品分类数据,流程可能如下:

不过,缓存带来性能提升的同时,也引入了新的问题,比如数据库与缓存的双写一致性问题、缓存穿透、击穿和雪崩等。我们在使用缓存时,一定要结合实际业务场景,切记不要为了缓存而缓存。
4 异步
在高并发系统中,某些接口的业务逻辑,并不需要都同步执行。例如一个用户请求接口,里面包含业务操作、发送站内通知和记录操作日志。为了方便,我们通常将这些逻辑同步执行,但这势必会影响接口性能。
其接口内部流程图可能如下:

仔细梳理一下,你会发现只有业务操作是核心逻辑,发通知和记日志都是非核心逻辑。这里有个原则:核心逻辑同步执行,同步写库;非核心逻辑,可以异步执行,异步写库。
像发站内通知和用户操作日志这类功能,对实时性要求不高,完全可以异步处理。常见的异步手段主要有两种:多线程和消息队列(MQ)。
4.1 线程池
使用线程池改造之后,接口逻辑如下:

发站内通知和用户操作日志被提交到两个单独的线程池中执行。这样接口只需关注核心业务操作,其他逻辑交给线程异步处理,接口性能瞬间得到提升。
但使用线程池有个小问题:如果服务器重启,或者执行出现异常,任务无法重试,可能导致数据丢失。
4.2 MQ
使用消息队列改造之后,接口逻辑如下:

对于发站内通知和用户操作日志,接口中并不真正执行,而是发送一条MQ消息到消息服务器。然后由对应的MQ消费者消费消息时,才真正执行这两个功能。
这样改造之后,接口性能同样得到提升,因为发送MQ消息的速度很快,我们只需关注业务操作代码即可。
5 多线程处理
在高并发系统中,用户的请求量巨大。假设我们用MQ处理业务逻辑,瞬间产生的大量用户请求会转化为海量MQ消息。如果消费者消费速度慢,就会导致消息积压,严重影响数据的实时性。
我们需要对消息消费者进行优化。最快捷的方式就是使用多线程消费消息,例如将其改造为线程池消费。当然,核心线程数、最大线程数、队列大小和线程回收时间等参数,一定要做成可配置的,以便后期根据实际情况动态调整。
这样改造后,可以快速缓解消息积压问题。此外,在很多数据导入场景中,使用多线程也能显著提升效率。
温馨提醒:使用多线程消费消息,可能会破坏消息的顺序性。如果你的业务场景需要严格保证消息顺序,则需要采用其他方案。
6 分库分表
有时,限制高并发系统吞吐量的不是应用层,而是数据库。当系统发展到一定阶段,巨大的用户并发量会产生海量数据库请求,占用大量数据库连接,并带来磁盘IO的性能瓶颈。
同时,随着数据量的爆炸式增长,单表可能无法存下所有数据。即使SQL走了索引,查询也会变得异常缓慢。这时该怎么办?答案是:需要做分库分表。
如下图所示:

图中将用户库拆分成了三个库,每个库又包含四张用户表。用户请求过来时,先根据用户id路由到特定的库,再定位到具体的表。
路由算法有很多:
- 根据id取模,例如:id=7,有4张表,则7%4=3,路由到用户表3。
- 给id指定区间范围,例如:id在0-10万之间,数据存入用户表0;id在10-20万之间,存入用户表1。
- 一致性hash算法。
分库分表主要有两个方向:垂直(按业务拆分)和水平(按数据拆分)。垂直拆分相对简单。水平方向上,分库和分表的作用其实不同:
- 分库:主要解决数据库连接资源不足和磁盘IO的性能瓶颈问题。
- 分表:主要解决单表数据量过大导致的查询缓慢问题,同时也能缓解CPU资源消耗。
- 分库分表:可以同时解决上述所有问题。
因此,需要根据实际业务场景灵活选择:并发大但数据量少,可只分库;数据量大但并发不高,可只分表;两者都高,则需分库分表。
7 池化技术
池化技术并非高并发系统独有,许多低并发系统为了性能也会使用,比如数据库连接池、线程池等。它本质上是多例设计模式的一种体现。
我们都知道,创建和销毁数据库连接是非常耗时、耗资源的操作。如果每次用户请求都需要新建连接,会严重影响程序性能。
为了提升性能,我们可以预先创建一批数据库连接,缓存在内存的集合中。当需要时直接从集合中获取,用完及时归还,从而避免重复创建和销毁的开销。

目前常用的数据库连接池有:Druid、C3P0、HikariCP和DBCP等。
8 读写分离
听说过二八原则吗?在一个系统中,大约80%的请求是读操作,只有20%是写操作(比例并非绝对,但读远大于写是普遍情况)。
如果读写请求都访问同一个数据库,它们会相互抢占宝贵的数据库连接资源。为了互不影响,就需要对数据库做读写分离。
于是,就出现了主从读写分离架构:

初期用户量不大时,可以采用一主一从架构。所有写请求指向主库(Master)。主库写入数据后,通过异步同步机制将数据同步到从库(Slave)。这样,所有读请求就可以从从库获取数据了。
但这里有个问题:如果Master挂了,将Slave升级为新Master。万一这个新Master扛不住所有读写请求怎么办?这就需要一主多从架构:

上图是一主两从。如果Master挂了,可以选择一个从库(如从库1)升级为新Master,另一个从库(从库2)则成为新Master的Slave。

随着查询请求量的进一步增加,架构还可以升级为一主三从、一主四从等。
9 索引
在高并发系统中,用户频繁查询数据,为数据库表增加合适的索引是必不可少的环节。尤其是当表中数据量巨大时,有无索引,执行同一条SQL查询的耗时可能相差数个数量级。
不过,索引也并非越多越好。在插入数据时,数据库需要为索引维护额外的数据结构,这会对写入性能产生一定损耗。我们需要根据实际业务场景来权衡:索引太少影响查询,索引太多影响写入。
通常,我们需要经常对索引进行优化:
- 将多个单列索引合并为合适的联合索引。
- 删除无效或使用率极低的索引。
- 使用
EXPLAIN命令分析SQL执行计划,查看索引使用情况。
- 注意避免导致索引失效的常见场景(如对索引列进行函数计算)。
- 必要时可以使用
FORCE INDEX强制查询走某个索引。
10 批处理
有时候,我们需要从一个用户集合中,查询哪些用户已经在数据库中存在。初版代码可能这样写:
public List<User> queryUser(List<User> searchList) {
if (CollectionUtils.isEmpty(searchList)) {
return Collections.emptyList();
}
List<User> result = Lists.newArrayList();
searchList.forEach(user -> result.add(userMapper.getUserById(user.getId())));
return result;
}
如果searchList有50个用户,就需要循环查询数据库50次。每次查询都是一次远程调用,50次调用无疑非常耗时。如何优化?答案是:批处理。
优化后的代码如下:
public List<User> queryUser(List<User> searchList) {
if (CollectionUtils.isEmpty(searchList)) {
return Collections.emptyList();
}
List<Long> ids = searchList.stream().map(User::getId).collect(Collectors.toList());
return userMapper.getUserByIds(ids);
}
我们提供一个根据用户id集合批量查询的接口,只需一次远程调用就能获取所有数据。需要注意,id集合的大小要有所限制,建议单次请求的记录条数控制在500以内,具体需根据实际情况调整。
11 集群
任何服务器节点都可能因为磁盘损坏、内存溢出等原因宕机。为了保证系统的高可用性,我们需要部署多个节点,构成一个集群,防止单点故障导致整个服务不可用。
集群有多种类型:应用服务器集群、数据库集群、中间件集群、文件服务器集群等。我们以Redis为例。
在高并发系统中,用户缓存数据可能非常庞大,比如总计40G。而单个服务器节点可能只有16G内存。这时就需要部署3个服务器节点来分担。普通的master/slave或哨兵模式无法满足这种数据分布需求。
40G数据需要均分到3个Master节点上,每个Master保存约13.3G数据。用户请求过来时,先经过路由层,根据用户id或ip,将请求定向到指定的节点。

这样就构成了一个集群。但为了防止某个Master节点宕机导致部分用户数据无法访问,我们还需要为数据做备份,即每个Master都配一个Slave。

这样,即使Master挂了,也可以将对应的Slave升级为新Master,保证服务不间断。
12 负载均衡
当系统部署在多台服务器节点上时,我们需要一种机制,来决定哪些用户的请求访问节点A,哪些访问节点B。这就需要负载均衡。
在Linux下,可以使用Nginx、LVS、Haproxy等软件提供负载均衡服务。在Spring Cloud微服务架构中,常用的负载均衡组件是Ribbon、OpenFeign或Spring Cloud LoadBalancer。硬件方面,可以使用F5,它基于交换机实现,性能更好但价格昂贵。
常用的负载均衡策略有:
- 轮询 (Round Robin):请求按时间顺序逐一分配到不同节点,宕机节点自动剔除。
- 权重 (Weight):权重越高,节点被分配到的概率越大,适用于节点性能不均的场景。
- IP哈希 (IP Hash):根据访问IP的哈希结果分配,同一IP的请求固定访问同一节点,可用于解决Session共享问题。
- 最少连接 (Least Connections):将请求转发给当前连接数最少的节点,适用于请求处理时间长短不一的情况。
- 最短响应时间 (Least Time):按节点的响应时间来分配,响应时间短的优先。
13 限流
对于高并发系统,为了保证稳定性,必须对用户请求量进行限流。特别是在秒杀系统中,如果不加限制,商品很可能被机器脚本抢走,对正常用户不公平。因此,识别并限制这些非法请求非常必要。
目前常用的限流方式有:基于Nginx限流和基于Redis限流。
13.1 对同一用户限流
为了防止单个用户请求过于频繁,可以针对用户ID进行限制。

例如,限制同一用户ID每分钟只能请求5次接口。
13.2 对同一IP限流
仅对用户ID限流可能不够,攻击者可以模拟多个用户。这时需要增加对同一IP的限流。

例如,限制同一IP每分钟只能请求5次接口。但这种策略可能存在误杀,比如同一个公司出口IP下的多个正常用户可能被连带限制。
13.3 对接口限流
攻击者还可能使用代理不断更换IP。这时可以对特定接口的总请求次数进行限制。

但这种全局限制可能导致因非法请求过多而影响正常用户访问。
13.4 加验证码
相比于上述方式,添加验证码更为精准,且不易误杀。

用户请求前需先输入验证码,服务端校验正确后才允许后续操作。并且,验证码通常是一次性的。
普通图片验证码生成快但有被破解风险;“移动滑块”验证码安全性更高,是当前主流选择。
14 服务降级
对于高并发系统,仅做限流还不够。我们需要合理利用服务器资源,保留核心功能,而将部分非核心功能暂时屏蔽或下线,这就是服务降级。
在设计系统时,可以预留服务降级的开关。例如在秒杀系统中,商品秒杀是核心,商品评论则可以暂时屏蔽。可以在配置中心(如Apollo)设置一个开关控制是否展示评论功能。
此外,还应设计一些兜底方案。例如,某个接口从Redis获取分类数据,如果Redis故障,可以转而从配置中心获取一份默认的静态数据返回。
目前常用的熔断降级中间件是Hystrix和Sentinel。Hystrix是Netflix开源的组件,而Sentinel是阿里开源的,功能更全面,支持系统负载保护。

15 故障转移
在高并发系统中,如果某个应用服务器节点假死(如CPU使用率100%),会导致该节点上的用户请求大量超时。为了不影响整体服务,需要建立故障转移机制。
当检测到接口频繁超时、CPU打满或内存溢出时,能够自动重启该节点上的应用,或者将流量切换到其他健康节点。
在Spring Cloud微服务中,可以利用Ribbon实现负载均衡和故障转移。Ribbon可以检测服务可用性,当请求超时或服务不可用时,会自动将请求转发到其他可用实例。
同时,可以结合Hystrix实现熔断。Hystrix会监控服务调用,当故障率超过阈值时自动熔断,快速失败或降级,防止故障扩散。
16 异地多活
有些高并发系统为了抵御机房级故障(如断电、自然灾害),会将系统部署在多个地理位置的机房,这就是异地多活架构。
例如,可以将系统部署在深圳、天津、成都三个机房,并按照一定比例(如40%、30%、30%)分配用户流量。当某个机房故障时,流量会被自动切换到其他存活机房。

用户请求经过DNS解析后,到达路由层服务器,路由服务器根据算法将请求分配到具体的机房。
异地多活架构的难点在于如何保证多个机房之间的数据一致性。
17 压测
高并发系统在上线前,必须进行充分的压力测试。我们需要预估生产环境的请求量,然后通过压测评估系统需要多少服务器节点来支撑。
例如,预估QPS(每秒查询率)为10000,而单节点最大支撑1000 QPS,那么至少需要10个节点。但为了应对流量突增,通常需要预留缓冲区,比如按预估值的2-3倍来部署,即部署20-30个节点。
压测结果与环境强相关。在开发或测试环境的压测数据只能作为趋势参考。更真实的压测应在预发布环境或与生产环境配置相同的专门压测环境中进行。
目前市面上的压测工具有很多,开源的如JMeter、LoadRunner、Locust;商业的如阿里云的PTS。
18 监控
为了在系统或SQL出现问题时能快速发现和定位,必须建立完善的监控体系。目前业界广泛使用的开源监控系统是Prometheus,它提供了丰富的监控和预警功能。

我们可以用它监控各类指标:
- 接口响应时间、调用第三方服务耗时
- 慢查询SQL耗时
- CPU、内存、磁盘使用率
- 数据库连接数、缓存命中率等
其监控面板可能长这样:

如上图所示,可以清晰看到MySQL的当前QPS、活跃线程数、连接池大小等信息。如果发现连接数异常高,可能是连接泄漏或并发量过大,需要进一步排查优化。
这只是Prometheus功能的冰山一角,更多信息可以访问其官网:https://prometheus.io
当然,高并发系统设计还涉及诸多安全与弹性话题,例如如何应对不断变换IP的刷接口行为、防止DDoS攻击、如何实现服务的动态扩容等,这些都是在云栈社区中开发者们经常深入探讨的课题。