作者:东东拿铁
链接:juejin.cn/post/7370327567763095602
今天想和大家聊聊,高并发系统的背后都有什么,设计思路是什么,具体是如何优化的。
以及顺带吆喝一句,技术大厂,前后端测试捞人,感兴趣一起共事。
许多刚工作几年的朋友,因为工作内容的原因,可能很难接触到高并发、大数据这些场景,因此在一定程度上,很多人觉着高并发系统���着天然的憧憬,刚毕业几年的我也是如此。
本文更多的是一些设计思路、实战经验分享,希望你看完之后有所收获。为了尽可能一文能够讲完,文章一些概念我不会具体展开,可能需要你自己去学习基础概念。
普通人只是缺少一个机会、一个环境,事实上每个人都可以做出高并发系统。
架构设计三个思维
合适即可
选择适合当前现状的,而不是追求最好的。合适也就是适应当前业务的要求是首位的,不要追求完美与过度设计。
针对合适原则,我们有几个考虑方向,人力资源、业务需求、公司资源几个角度考虑。
比如当前开发人员只有2个,那么能用单体就用单体架构,微服务都不需要用。
业务需求就是满足低频次的数据写入和读取,那么直接读数据库就好,缓存也不需要。
简单原则
依赖系统、组件越多,就越有可能某个组件出故障。
尽量减少服务调用链路 微服务架构已经不是什么新鲜事了,一次请求依赖几十个服务也不是没有可能,我们需要做的就是保证尽量少的依赖关系,模块之间的依赖关系应尽量减少,避免过多的依赖链条。
这样可以降低修改一个模块对其他模块造成的影响,提高系统的稳定性和可维护性。
在面对多种设计选择时,优先选择简单的解决方案。简单的解决方案往往更易于理解和实现,并且更不容易出错。
演化原则
唯一不变的就是变化,所有的系统,都不是一开始就是这样设计的,而是一步步演变来的。
迭代思维在架构设计中的应用可以体现在多个方面:
渐进式完善系统:架构设计不必一开始就完全确定所有细节,而是可以先设计一个初步的架构,然后通过迭代不断优化和完善。
快速验证想法:通过迭代,可以快速验证不同的架构想法和解决方案,从而找到最合适的方案。
及时反馈和调整:迭代过程中可以及时获取用户和利益相关者的反馈,从而及时调整架构设计,保证系统的实际需求和预期一致。
系统背景
业务背景
某大型促销活动系统,展示不同的活动页面,属于CPU密集型服务,读多写少,主要提供了页面数据获取。
(针对于高并发写,有机会的话,下次再说吧。)
指标评估
系统用户数 假设注册用户1亿人。 活跃用户数 日活按照注册用户的10%,有1000w人。 目标用户数 促销活动,只针对部分用户生效,可能我们目标是覆盖到100w人。 并发用户数 100w人是我们期望覆盖的用户数量,中间会产生一些折损,而且这部分人也不会同一时间访问我们的系统。我们假设并发用户数为10w人。
因此,我们估算指标如下: 峰值QPS10w+,平均RT:200-300ms
设计思路
扩展能力
设计思路的第一点,就是系统要具有扩展的能力,扩展又分为垂直和水平两种。
垂直扩展
一类是传统大型软件系统的技术方案,被称作垂直伸缩方案。所谓的垂直伸缩就是提升单台服务器的处理能力,比如用更多核的 CPU、更大的内存、更快的网卡、更多的磁盘组成一台服务器,从普通服务器升级到小型机,从小型机提升到中型机,从中型机提升到大型机,从而使单台服务器的处理能力得到提升。通过这种手段提升系统的处理能力。
水平扩展
第二类就是水平扩展方案,不用更厉害的硬件,而使用更多的服务器,来构建成一个分布式集群,以此提升整体能力。
水平扩展其实也是随着互联网应用场景必备的解决方案,因为你没法预估有多少人来访问你的系统,或者在一些促销活动时,会有多少倍的流量增加。
简单来说,假设在4c8g(4核CPU,8g内存)情况下,单机支持500QPS时,10wQPS的流量洪峰情况下,200台机器就可以满足系统整体要求了。
那100W QPS的情况下,理论上2000台机器就可以满足系统要求了。
当然,横向扩展也不是无限扩展的,机器数量在达到一定量级的时候,可能就会带来一些架构上的问题,这个我们下面再说。
多线程
假设依赖下游较多,较多数据需要处理,那么就要合理的利用多线程。
比如我们的活动系统,一个活动页面包含了不同的组件,共同组合成了这一个页面,可能包含优惠券模块、秒杀模块、内容展示模块等。
如果我们使用串行方式加载不同模块,假定每一个模块都需要100ms的时间,那么10个模块的加载时间就需要1000ms。如果运营在页面上配置更多的模块,时间也会随着模块数量直线上升。
比如下面这个例子
void test(){
String x = query1();
String y = query2();
String z = query3();
return x+y+z;
}
在方法test中调用了方法query1,那么在方法 query1 返回之前,就不会调用方法 query2和query3了,即方法 a 被方法 m 阻塞了。这种编程模型下,方法 m 和方法 n 不能同时执行,系统的运行速度就不会 快,并发处理能力就不会很高。
但是我们采用模块化的设计方案,每一个模块逻辑独立,我们可以采用多线程的方式来进行加载。
Java中就离不开线程池和CompletableFuture了
public class Example {
public static void main(String[] args) {
long start = System.currentTimeMillis();
ThreadFactory namedThreadFactory = new ThreadFactoryBuilder()
.setNameFormat("activity-thread-%d").build();
// 线程池
ExecutorService pool = new ThreadPoolExecutor(5, 5, 5, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(500), namedThreadFactory, new ThreadPoolExecutor.AbortPolicy());
CompletableFuture<String> query1 = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "query1";
}, pool);
CompletableFuture<String> query2 = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "query2";
}, pool);
CompletableFuture<String> query3 = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(550);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "query3";
}, pool);
try {
// 资源获取完成,走后续主逻辑 executeNext(query1.get(),query2.get(),query3.get(2,TimeUnit.SECONDS));
}catch (Exception e){
e.printStackTrace();
}
System.out.println("耗时:"+ (System.currentTimeMillis() - start));
pool.shutdown();
}
private static void executeNext(String a1,String a2,String a3) {
System.out.println(a1);
System.out.println(a2);
System.out.println(a3);
}
}
![图片](data:image/gif;base64,R0lGODlhAQABAPABAP///wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==)
异步
核心流程,经过我们多线程的处理,我们已经极大的减少了耗时。
异步其实最常见的是优化并发写的场景,常见的就是引入消息队列。
应用服务器接收到用户的写操作请求后,并不直接与数据库进行交互,而是将写操作请求发送至消息队列服务器。随后,消息消费者服务器从消息队列服务器消费消息,最终完成对数据库的写入操作。
这种设计带来了两个主要好处。
首先,用户的写操作请求发送至消息队列后,应用服务器可以立即返回响应给用户。由于消息队列服务器的处理速度远远快于数据库,因此用户端的响应时间大大缩短。
其次,当消息队列执行写入数据库操作时,可以根据数据库的负载能力来控制写入速度。即使在用户请求并发很高的情况下,也不会导致数据库崩溃。因此,消息队列可以确保系统在性能最优的负载压力范围内稳定运行。
缓存
可以说,在计算机的世界中,凡是想要提高性能的场合都会使用到缓存的思想。利用好缓存,是每一个高并发系统必须要去考虑的。缓存是指将数据存储在访问速度相对较快的存储介质中,所以从缓存中读取数据的速度更快。
所以这部分也会用比较长的篇幅来讲。
缓存是一个比较大的概念,在系统、软件不同级别有不同层级的缓存:
浏览器级别的缓存,会用来存储之前在网络上下载过的静态资源;
CDN缓存,属于部署在网络服务供应商机房中的缓存;
反向代理服务器本质上同样也是缓存,属于用户数据中心最前端的缓存,比如Nginx
针对数据库中的“热点”数据,在应用服务器集群中有一级缓存,在缓存服务集群中有二级缓存
我们主要聚焦在一个支持高并发的活动系统,因此缓存这块,我们主要考虑两类缓存:Redis集中式缓存,和类似Guava Cache的本地缓存。
分布式缓存
先简单聊聊老生常谈的三个问题 缓存穿透 当一个请求查询一个不存在于缓存中的数据,而且这个数据也不在持久化存储(如数据库)中时,就会发生缓存穿透。这种情况下,大量的请求会直接穿过缓存层,直接访问底层的数据存储,导致数据库等存储系统压力增大,并可能导致性能下降甚至宕机。为了解决这个问题,可以在缓存层设置一个空值缓存(即将查询结果为空的键值对也缓存起来),或者使用布隆过滤器等数据结构来过滤掉不存在的键,避免无效的查询访问数据库。
缓存击穿 缓存击穿指的是针对某个热点数据的大量并发请求同时到达缓存系统,而这个热点数据的缓存刚好失效或被淘汰,导致这些请求都绕过了缓存直接访问底层存储系统。
比如10W QPS/s的情况下,加载数据->写入缓存需要耗费100ms的时间,假设请求在1s内分布均匀,那么打到DB的请求量可能会有1w+,有击垮DB的风险。
与缓存穿透不同的是,缓存击穿通常指的是某个具体的热点数据的缓存失效引起的问题,而不是查询的数据本身不存在。
这个场景在大流量下很常见,针对这个我们可能要考虑热点数据加载加互斥锁、预加载、异步更新等场景。
缓存雪崩 缓存雪崩是指在缓存中存储了大量的缓存数据,且这些缓存数据在同一时间失效,导致大量请求直接落在了底层存储系统上,造成存储系统的瞬时压力过大,甚至导致宕机。这种情况通常发生在缓存的过期时间设置不合理,或者大量的缓存数据在同一时间失效的情况下。为了避免缓存雪崩,可以采取多种策略,如给缓存数据设置不同的过期时间。
除了老生常谈的三个问题,集中式缓存还需要解决需要解决热点数据、数据一致性问题等
热点数据,导致Redis集群某个实例压力过大,甚至打满网络带宽,那么我们可能需要考虑热点数据分片。
DB、缓存数据一致性,这个话题是没有标准答案的,并且没有完美的方案,只需要适合你的场景就可以。比如先更新数据库、删除缓存,或者延迟双删,这块就不展开了。
本地缓存
说起本地缓存,大家可能觉着,已经有了像Redis这样的分布式缓存,本地缓存是不是已经很难派上用场了,实际中应用到的场景还多吗?
但在高并发场景中,利用本地缓存的场景还是存在的,甚至在很多场景下,必然要从分布式缓存切换到本地缓存。
比如缓存数据较大的时候,比如1GB大小时,Redis明显就无法支持这类场景了。
那么对于本地缓存,缓存数据加载方式是我们首先要考虑的,常见几种方式
预加载
增量更新
过期失效
预加载:服务启动时进行查询,不设置过期时间,当数据有变更的时候通知所有服务重新加载。
看起来很美好,预加载可能会有相当多的问题,比如服务启动慢,滚动发布时间长。当集群规模上万台,重新加载时数据库的压力依旧很大等。
增量更新 如果重新加载的代价太高,那么就需要设计增量更新的方案,比如1GB的数据包,当只有1%的数据发生变更,那我们没必要完整的加载这1GB的数据。
过期失效 本地缓存设置过期时间,过期后回源查询。这个方案就需要业务能容容忍一段时间的数据不一致情况。
本地缓存除了需要考虑缓存和DB之间的一致性问题,还想需要考虑服务器之间数据Diff问题,比如A服务器和B服务器,当B服务器更新本地缓存失败,就会造成在同一时间同一个key的结果不一致,这个也是需要提前考虑到的。
极致优化
说了很多方法论上的内容,上述内容做好,一个基本的高并发系统就成型了,但实际生产环境运行中,一个高并发系统依然有很多问题需要持续优化。
服务抖动
10W+QPS的服务,我们的RT在200-300ms,假设TP99为200ms
TP99 = 200ms,标识这段时间 99% 的请求执行时间都在 200ms 以内,TP50 和 TP999 也是相同计算策略。
但是1%的流量就有1000QPS,这时你的TP999时延的大小可能直接会到1000ms,如果控制不好,影响面也是极广的。
这里的TP99的200ms,只是为了给大家举例子,让大家直观的感受到TP99和TP999影响面的差距,真正生产环境,对于RT在200-300ms之间的要求,一定是针对TP999来要求的。
那么影响RT的地方可能就有很多,比如垃圾回收产生的抖动。比如CPU占用率达到60%以上时,系统的PCT999一定会有一个影响。
抛砖引玉,这块不展开了,有经验的大佬可以在评论区交流。
容灾策略
当你负责了这么大的流量系统,你就要考虑一件事情,容灾。
你的系统出问题1s,那就意味着有10w人在这一刻,无法加载出这一个活动页面,这是不可接受的。
针对你服务中依赖的所有中间件,你需要考虑,他们故障的时候,你应该怎么办。
比如我们的活动系统,Redis承担了你的绝大部分读取流量,如果Redis故障了,怎么办。
常见的方案如下:
限流:保护系统不被超出其处理能力的请求冲垮,通过拒绝请求的方式,保证系统的可用性。
降级:降级就是牺牲一些非核心功能,来保证系统核心功能可用性。比如当Redis服务故障,我们就启用我们的降级策略,读取数据逻辑先走本地缓存逻辑,虽然可能会产生服务之间的数据Diff,但是整体保证了我们系统时可用的。,
熔断:比如我们活动页面的query1依赖的服务故障,出现超时、错误率过高或资源不足等过载现象时,我们需要切断对该下游服务的请求,以避免出现故障扩散的情况。
当然,具体的容灾策略,需要经过一系列配置细化,方便精细化管理。整体改造完后,需要进行容灾演练,并有对应的操作面板,避免需要容灾时,操作太过复杂导致故障时间过长。
说在最后
好了,文章到这里就要结束啦,很感谢你能看到最后。能力不足,水平有限,文章中如果有问题,希望你能够指正。
希望你看完之后,对于高并发系统能够有一种“高并发也不过如此的感觉”,那我这篇文章的目标就达到了。没有什么高深莫测,都是细节的积累与持续的迭代。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。