三高问题下的系统优化
在互联网早期的时候,单体架构就足以支撑起日常的业务需求,大家的所有业务服务都在一个项目里,部署在一台物理机器上
所有的业务包括你的交易系统、会员信息、库存、商品等等都夹杂在一起,当流量一旦起来之后,单体架构的问题就暴露出来了,机器挂了所有的业务全部无法使用了。我们会进行集群部署来处理这个问题
后来随着业务发展,用户越来越多,单机无法承受巨量的 QPS,因此出现了业务拆分,每个服务只负责业务的一部分,我们在维护这一部分业务的时候,需要考虑的问题大同小异,出现高 QPS 拖垮机器的时候,可以从下面的思路去考虑:
- 1,保证用户请求的数据尽量少:我们可以做一些动静分离等操作,将静态数据放进 CDN
- 2,过滤尽可能多的请求:你会想我一个高 QPS 的系统本来就是为了处理大量请求的,现在却需要让请求数尽量少,这是不是不合理?事实上,用少量资源无论怎么设计,资源的限制就在那里。如果少量资源扛住了大量请求,那一定是丢弃了用户的无效请求。我们可以做一个业务漏斗,逐层校验并且过滤无效请求
- 3,依赖尽量少:能用缓存用缓存,能不调用下游不调用下游。同时也不要相信下游提供的能力,做好降级。这块可以将热点数据放进缓存。如果是写请求,可以先将请求主要内容存下来,并且解析放进 MQ,然后异步的保存额外数据
并发高 qps 的核心优化理念是尽量减少用户到服务端来读数据,或者让他们读更少的数据。链路和数据越多,不确定因素越多,风险越高,大部分秒杀系统只涉及核心数据,并且需要保证并发性
# 高并发
高并发的优化方案无非三种
- Scale-out(横向扩展)集群部署:分而治之是一种常见的高并发系统设计方法,采用分布式部署的方式把流量分流开,让每个服务器都承担一部分并发和流量。与之相对的是 Scale-up,升级机器性能,比如将4核8g 升级成8核16g。不过升级机器的时候,为了不影响当前业务,可能需要新增机器,先将部分流量迁移到新的机器上,等新机器稳定运行了一段时间,再将全流量切到新机器上
请注意,动态横向扩展的前期是集群已经有了优秀的负载均衡设计,并且所有的用户状态都是无状态服务设计,不能在机器上保存用户 session,我们一般使用 JWT 或者 redis 保存用户登录信息方式处理用户状态
- 缓存:使用缓存来提高系统的性能,就好比用“拓宽河道”的方式抵抗高并发大流量的冲击
- 异步:在某些场景下,未处理完成之前,我们可以让请求先返回,在数据准备好之后再通知请求方,这样可以在单位时间内处理更多的请求
但是在具体实现方案上可以玩出花来
# 高性能 RPC、业务拆分(微服务架构)与数据库分库分表
传统 HTTP 的通信方式性能首先并不太好,大量的请求头之类无效的信息是对性能的浪费,这时候就需要引入诸如 Dubbo 类的 RPC 框架
有小伙伴进行对比测试,DubboRPC 的性能,是 FeignRPC 的性能10倍。我们假设原来来自客户端的 QPS 是9000的话,那么通过负载均衡策略分散到每台机器就是3000,而 FeignHTTPRPC 改为 DubboRPC 之后,接口的耗时缩短了,单体服务和整体的 QPS 就提升了
而 RPC 框架本身一般都自带负载均衡、熔断降级的机制,可以更好的维护整个系统的高可用性
而 Dubbo 的优秀性能源自它的序列化机制和通信协议,框架对于请求头做了很多删减,并且序列化机制和通信协议都可以做切换
针对微服务而言分库本身已经是做过的,如果没做的话说明之前的业务体量不大,一个业务可以支持大多数功能,如果体量大了,需要按业务维度进行拆分,每个业务独立出来负责每个业务自己的功能来分摊 QPS 压力
剩下是分表的方案了,我接触过的分表方案是没有用组件做的,是业务抽了一层出来,对于 ID 的最后一位做分表,也可以参考一致性哈希做分表,水平分表后,表可以存放在不同的 db 中,以此实现了类似负载均衡的效果。对于垂直分表我接触的比较少,毕竟在建表的时候我们应该就考虑到了业务需要以及 BC 范式
# 增加中间层
处理巨量读请求的时候用 ES,解耦削峰用 MQ,不知道用啥但是想优化系统考虑 Redis
这里的核心思想是将数据放在离用户较近的地方,比如 CDN,是将资源冷热分离,只有涉及到动态变化的数据,需要访问后端的服务,减少请求打到后端的次数,系统所支持的 qps 自然也会提高。这种将资源拆分并且分离的例子在后端也是可以使用的
我在刚开始工作的时候有一个段子,公司的高并发其实是在前端做了一个随机过滤的功能,用户每次点击页面只有百分之五十的概率访问后端接口,其他情况会直接给用户返回系统忙请稍后
虽然这是个笑话,但是大家想一下这样肯定可以增加系统的最大并发量,我们在实际工程的时候肯定不能这么处理,但是可以借鉴这个思路
# 消息队列消峰解耦
对于 MQ 的作用大家都应该很了解了,主要功能:
- 削峰填谷、解耦
- 同步转异步的方式,可以降低微服务之间的耦合
对于一些不需要同步执行的接口,可以通过引入消息队列的方式异步执行以提高接口响应时间。在交易完成之后需要扣库存,然后可能需要给会员发放积分,本质上,发积分的动作应该属于履约服务,对实时性的要求也不高,我们只要保证最终一致性也就是能履约成功就行了
对于这种同类性质的请求就可以走 MQ 异步,也就提高了系统抗压能力了
与此同时需要考虑引入 MQ 的各种问题,比如消息队列满了怎么办,数据量太多我们接受不过来怎么办
# 缓存(将数据放到尽可能离用户近的地方)
缓存作为高性能的代表,在某些特殊业务可能承担90%以上的热点流量,这里的缓存不单单指的是 redis 分布式缓存,在业务链路上涉及到的任何缓存都是我们优化系统的关键,比如浏览器缓存、CDN 缓存、本地缓存、分布式缓存等,这些缓存起到的作用就是将数据放到尽可能离用户近的地方,让更少的用户请求走完完整的链路,让我们整个业务形成一个流量漏斗,这个漏斗做的越优秀,我们系统可以承载的 QPS 越大
对于一些活动比如秒杀这种并发 QPS 可能几十万的场景,引入缓存事先预热可以大幅降低对数据库的压力,10万的 QPS 对于单机的数据库来说可能就挂了,但是对于如 redis 这样的缓存来说就完全不是问题
以秒杀系统举例,活动预热商品信息可以提前缓存提供查询服务,库存数据可以提前缓存,下单流程可以完全走缓存扣减,秒杀结束后再异步写入数据库,采用这种异步读写穿透模式数据库承担的压力就小的太多了
同时还可以用来做分布式锁和普通的读缓存,在处理性能问题时引入缓存一般错不了。但是引入缓存主要考虑缓存三大问题以及数据不一致问题
# ES 查询优化
我们分库分表后,用户需要根据一些字段查询自己的数据,有可能这些字段不在一个库中,这时候我们就需要使用 ES 做查询优化了,他底层查询速度因为使用了倒排索引拆分执行导致非常快,是标准的数据拆分后的聚合方案
对于 ES、redis 这种天然支持分布式架构的中间件来说,如果整个集群都挂掉了,我们需要启动从集群,我们有两个机房,分别是机房 A 和机房 B。我们把 ES 主集群部署在机房 A,把 ES 备集群部署在机房 B。会员系统的读写都在 ES 主集群,通过 MQ 将数据同步到 ES 备集群
此时,如果 ES 主集群崩了,通过统一配置,将会员系统的读写切到机房 B 的 ES 备集群上,这样即使 ES 主集群挂了,也能在很短的时间内实现故障转移,确保会员系统的稳定运行
最后,等 ES 主集群故障恢复后,打开开关,将故障期间的数据同步到 ES 主集群,等数据同步一致后,再将会员系统的读写切到 ES 主集群。这么做也是主从架构,不过是主从集群而已,维持了集群的高可用性
# 负载均衡和读写分离
对于整个系统而言,最终所有的流量的查询和写入都落在数据库上,数据库是支撑系统高并发能力的核心,但是一台 db 所能支持的 qps 上限大概只有1w左右
怎么降低数据库的压力,提升数据库的性能是支撑高并发的基石。主要的方式就是通过读写分离来解决这个问题
对于整个系统而言,流量应该是一个漏斗的形式。比如我们的日活用户 DAU 有20万,实际可能每天来到提单页的用户只有3万 QPS,最终转化到下单支付成功的 QPS 只有1万
那么对于系统来说读是大于写的,这时候可以通过读写分离的方式来降低数据库的压力
与此同时,需要考虑到读写分离带来的数据不一致问题,我就遇到过由于 DB 没有设置为全同步,导致营运导入两批账号后,发现账户的机构数据错误的情况。虽然这种问题发生的概率比较小,当时我们也可以做一些处理,比如修改是上分布式锁,修改结束后不释放锁,而是等待一会解锁,来确保主从已经全同步了
常见的负载均衡算法有随机、轮询、加权随机、加权轮询、最小连接数、一致性 hash 等等
# 高并发写优化
高 QPS 下写流量过大,会导致 db 扛不住,尤其是 db 强制写主库,主从同步需要一定时间,这就导致就算数据库使用了事务加全同步的方式,还是可能出现修改丢失的问题,因此我们可以做以下优化:
- 引入 mq 异步写入
- 将一段时间的数据存起来(可以存到 redis、本地缓存等地方,甚至可以存到 mq 中然后批量读取,这一条优化建议与第一条不冲突),然后批量写入
- 如果是秒杀场景,则需要把秒杀库存数据先写到 redis(因为需要频繁写入,redis 的抗压能力比 db 更强)中,然后使用 lua 脚本、上分布式锁、watch 乐观锁配合事务等方式实现库存扣减的原子操作
- 如果是秒杀场景,我们可以将库存按均分配到多台机器上,每个机器缓存一部分库存,这样就利用网关做了一个分流的功能。这个库存也可以分到 reids 中
# 集群部署和多活、容灾
- 集群部署:利用冗余消除单点是高可用基石。无论是主从部署、多主部署、读写分离,都或多或少优化了集群的高可用性
- 负载均衡:这里的负载均衡是指高可用负载,某台机器出现故障后可以快速路由为其他机器
集群部署中,除了我们平时的单个微服务项目部署,在整体上,又分冷备热备、同城双活、异地双活、异国双活等部署方式,需要按照真实的线上问题去部署不同的集群,以下是集群部署的一些注意事项
- 接入层(DNS / LB / 网关)多活:负责把用户流量按规则路由到不同地域/单元。
- 服务层(应用、微服务)多活:多个地域同时跑业务代码,尽量做到无状态。
- 数据层(DB / 缓存 / 消息)多活:最关键,要做到“单元封闭 + 跨单元同步”
比如跨机房读取数据的情况,B 机房中的应用就会跨机房读取 A 机房的数据,如下图所示。如果 B 和 A 相隔很远的话,这个时延就很高了

- 同城双机房专线延迟在1ms~3ms之间
- 就国内的异地双机房专线延迟在50ms之内
- 国际异地双机房专线的网络延迟一般会在100ms~200ms
因此两个机房中,每个机房会承担一部分流量,涉及到服务的调用和数据读写时,尽量在本机房内完成,如果是 RPC 调用,不同机房的 RPC 服务可以向注册中心注册不同的服务分组,不同机房的 RPC 消费者只订阅本机房内的服务分组
这样就可以实现 RPC 调用尽量发生在本机房内。如果是写数据,则可以向一个机房写数据,而实时同步到另一个机房。这样解决了时延问题,也解决了容灾问题

而同时,大厂中跨城异地多活一般采用两地三中心部署方式,即两个服务中心 + 一个灾备中心,这么做复杂度更高,但是能够提供更好的容灾能力
# 数据一致性和延迟性
上面说的高并发写优化一定会导致一个问题就是多个系统间数据一致性问题,上文的解决方案中,引入 mq 异步写入、批量写入、库存切分等方案,一定会导致 db 中的数据在没有及时更新的问题。处理方案如下:
1,强一致性,利用 XA、2PC、3PC 这些满足分布式事务的协议来实现强一致性。不过一般实际应用中使用的少,因为锁住的资源较多 2,最终一致性:通过 TCC、SAGA、可靠消息队列的方式实现柔性事务以满足最终一致性,落地可用 seate
# 高可用
关于高可用的内容可能是架构组的同学考虑的比较多的事情,但是我们平时工作的过程中或多或少接触过,同时高可用也是设计高 QPS 系统的必要一环
# 服务治理
单体拆成微服务后,复杂度从代码级耦合变成了网络级耦合,治理就是把这种网络复杂度可视化、可控化、可运维化。在服务之间我们一般从下面几点出发优化应用
- 熔断:比如营销服务挂了或者接口大量超时的异常情况,不能影响下单的主链路,涉及到积分的扣减一些操作可以在事后做补救。熔断之后降级方案就是短时间内不再调用服务,等到营销恢复之后再调用
- 限流:对突发如大促秒杀类的高并发,如果一些接口不做限流处理,可能直接就把服务打挂了,针对每个接口的压测性能的评估做出合适的限流尤为重要。在网关层,做一些令牌桶、漏桶、滑动窗口、固定窗口算法即可
- 降级:降级一般在代码内部做,先梳理出系统的强弱依赖,对于一些弱依赖调用失败的情况,我们可以返回默认的回复。这里我们应当对系统的强弱依赖做分类,强依赖应当熔断限流,防止打跨下游机器,弱依赖进行降级
- 路由、灰度、隔离:流量流向需要额外考虑,这里指的是重点流量或者高消耗客户的流量隔离,为他们专门搭建 VIP 集群,保证流量高可用,或者在发布的时候做 AB 等等情况
- 超时:服务调用超时需要做额外处理
- 持久化机制:让崩溃机器快速恢复,其中包括冷备热备双活等
# 自动恢复与人工处理
自动化是效率之源
- 自动选主:中间件崩溃后都有自动选主自动恢复的功能
- 预案:一般来说,就算是有统一配置中心,在业务的高峰期也是不允许做出任何的变更的,但是通过配置合理的预案可以在紧急的时候做一些修改
- 监控和报警
# 高性能
高性能支持了高并发,常见的高性能优化如下:
- 池化:后台开发过程中你一定离不开各种池子:内存池、连接池、线程池、对象池。内存、连接、线程这些都是资源,创建线程、分配内存、数据库连接这些操作都有一个特征, 那就是创建和销毁过程都会涉及到很多系统调用或者网络 IO。 每次都在请求中去申请创建这些资源,就会增加请求处理耗时,但是如果我们用一个 容器(池) 把它们保存起来,下次需要的时候,直接拿出来使用,避免重复创建和销毁浪费的时间
- IO 多路复用和零拷贝:netty 性能好就是用了这两个优化
- 多线程:充分利用 CPU 资源,但是使用多线程又必须额外拿出一些资源处理线程安全问题
- 批量处理:在涉及到网络连接、IO 等情况时,将操作批量进行处理能够有效提高系统的传输速率和吞吐量,kafka 就是这么做的,生产者发送消息时,可以将消息合并发送,消费者拉取消息时,也可以将消息批量拉取
- sql 优化以及算法优化:这属于业务逻辑优化
- 扩容:扩容治百病,重启解千愁
# 额外考量
# 压测
一个高性能的系统,一定是需要压测看性能的。根据压测结果可以推断线上运行的危险值在哪,方便我们即使推断定位问题,以及及时扩容
## 使用wrk进行压测
## 预热阶段
wrk -t4 -c100 -d30s --latency http://api.example.com/product/1
## 正式压测
wrk -t12 -c1000 -d300s --latency http://api.example.com/product/1
## 混合场景压测
vegeta attack -duration=300s -rate=2000 \
-targets=targets.txt | vegeta report
2
3
4
5
6
7
8
9
10
# 可观测性
监控项应该考虑以下几点:
应用层
- QPS/TPS
- 响应时间(P99/P95)
- 错误率或者异常指标
- JVM 指标
- 机器 CPU、内存使用率,如果过高可能需要考虑扩容
Redis
- 内存使用率
- 连接数
- 命中率
- 慢查询
MySQL
- 活跃连接数
- InnoDB 缓冲池命中率
- 慢 SQL 数
- 主从延迟
做好全面的告警配置
# 分布式 ID
推荐使用雪花算法,当然用 UUID 或者 redis、mysql 的步长机制也可以
// 雪花算法实现
public class SnowflakeIdGenerator {
private final long datacenterId;
private final long workerId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public synchronized long nextId() {
long timestamp = timeGen();
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨");
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & 4095;
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - 1288834974657L) << 22) |
(datacenterId << 17) |
(workerId << 12) |
sequence;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 总结
其实可以看到,怎么设计高并发系统这个问题本身他是不难的,无非是基于你知道的知识点,从物理硬件层面到软件的架构、代码层面的优化,使用什么中间件来不断提高系统的抗压能力
但是这个问题本身会带来更多的问题,微服务本身的拆分带来了分布式事务的问题,http、RPC 框架的使用带来了通信效率、路由、容错的问题,MQ 的引入带来了消息丢失、积压、事务消息、顺序消息的问题,缓存的引入又会带来一致性、雪崩、击穿的问题
数据库的读写分离、分库分表又会带来主从同步延迟、分布式 ID、事务一致性的问题,而为了解决这些问题我们又要不断的加入各种措施熔断、限流、降级、离线核对、预案处理等等来防止和追溯这些问题
额外提一下秒杀系统,秒杀本质上就是一个满足大并发、高性能和高可用的分布式系统,需要处理并发读和并发写,因此秒杀的解决方案在本章中也有,除了并发写中有一个并发安全问题需要处理以外,秒杀的设计完全可以按照本章总结的内容来