Java高并发系统的设计是现代软件开发中至关重要的一环,特别是在处理大量用户请求的应用程序中。
一、明确业务需求
在设计任何系统之前,首先需要明确业务需求,哪些功能对于高并发的支持是特别重要的。此阶段不需要编写代码,但需要详细记录用户案例和场景,以便后续设计和优化。
二、选择合适的架构模式
在高并发系统中,选择合适的架构模式至关重要。常见的架构模式包括:
- 微服务架构:将系统拆分为多个独立的服务,每个服务负责特定的业务功能,独立部署和扩展。微服务架构有助于提高系统的可扩展性和可维护性。
- 事件驱动架构:通过事件来驱动系统的运行,事件生产者发布事件,事件消费者订阅并处理事件。这种架构模式有助于实现系统的解耦和异步处理。
- 管道/过滤器架构:将请求通过一系列的处理管道进行传输,每个管道对请求进行一定的处理,然后传递给下一个管道。这种架构模式有助于实现系统的模块化和可重用性。
三、使用线程池
线程池是Java多线程编程中常用的技术之一。通过预先创建一定数量的线程,并将任务提交给线程池中的线程执行,可以有效地减少线程的创建和销毁开销,提高系统的响应速度和处理能力。Java提供了java.util.concurrent.ExecutorService和java.util.concurrent.Executors等类来方便地创建和管理线程池。
四、使用并发集合
在高并发环境下,对集合的并发修改可能会导致数据不一致问题。Java提供了多种并发集合类,如ConcurrentHashMap、CopyOnWriteArrayList等,这些集合类在内部采用了高效的并发控制机制,可以支持高并发访问,避免线程安全问题。
五、使用分布式缓存
在高并发场景下,使用分布式缓存(如Redis)来缓存热点数据,可以显著减少对数据库的访问压力,提高系统响应速度。通过将部分数据缓存到内存中,可以加快数据的读取速度,并降低数据库的负载。
六、使用异步编程
异步编程是一种非阻塞的编程模式,可以让程序在等待某个操作完成的过程中继续执行其他任务。Java中的Future、CompletableFuture等类提供了异步编程的支持,可以用于实现高性能的网络通信和数据处理。通过异步编程,可以提高系统的吞吐量和响应速度。
七、数据库优化
数据库往往是高并发系统的瓶颈之一。因此,需要对数据库进行优化,以提高系统的性能。常见的数据库优化方法包括:
- 索引优化:为数据库表添加合适的索引,以加快数据的查询速度。
- SQL优化:优化SQL语句,减少不必要的查询和更新操作,提高SQL的执行效率。
- 读写分离:将数据库的读操作和写操作分离到不同的数据库实例上,以减轻单个数据库实例的负担。
- 分库分表:将数据库拆分为多个库或多个表,以分散数据的存储和访问压力。
八、使用负载均衡
在生产环境中,可以使用负载均衡器(如Nginx或Apache)将用户请求分发到多台服务器。通过负载均衡,可以分散请求以提高系统稳定性,并防止单台服务器过载导致系统崩溃。
九、监控与优化
高并发架构设计不是一次性工作,而是持续迭代与优化的过程。需要使用监控工具(如Prometheus、Grafana等)来实时监控系统的性能指标,并根据监控结果进行针对性的优化。常见的优化方法包括:
- 代码优化:对代码进行性能分析,找出瓶颈并进行优化。
- 架构优化:根据业务需求和技术发展,对架构进行调整和优化。
- 资源优化:合理分配和利用系统资源,提高资源的利用率和系统的性能。
实战
架构设计的三个思维
1、合适即可
选择适合当前现状的,而不是追求最好的。合适也就是适应当前业务的要求是首位的,不要追求完美与过度设计。
针对合适原则,我们有几个考虑方向,人力资源、业务需求、公司资源几个角度考虑。
比如当前开发人员只有2个,那么能用单体就用单体架构,微服务都不需要用。
业务需求就是满足低频次的数据写入和读取,那么直接读数据库就好,缓存也不需要。
2、简单原则
依赖系统、组件越多,就越有可能某个组件出故障。
尽量减少服务调用链路,微服务架构已经不是什么新鲜事了,一次请求依赖几十个服务也不是没有可能,我们需要做的就是保证尽量少的依赖关系,模块之间的依赖关系应尽量减少,避免过多的依赖链条。
这样可以降低修改一个模块对其他模块造成的影响,提高系统的稳定性和可维护性。
在面对多种设计选择时,优先选择简单的解决方案。简单的解决方案往往更易于理解和实现,并且更不容易出错。
3、演化原则
唯一不变的就是变化,所有的系统,都不是一开始就是这样设计的,而是一步步演变来的。迭代思维在架构设计中的应用可以体现在多个方面:
1、渐进式完善系统:架构设计不必一开始就完全确定所有细节,而是可以先设计一个初步的架构,然后通过迭代不断优化和完善。
2、快速验证想法:通过迭代,可以快速验证不同的架构想法和解决方案,从而找到最合适的方案。
3、及时反馈和调整:迭代过程中可以及时获取用户和利益相关者的反馈,从而及时调整架构设计,保证系统的实际需求和预期一致。
系统背景
eg: 业务背景:某大型促销活动系统,展示不同的活动页面,属于CPU密集型服务,读多写少,主要提供了页面数据获取。
指标评估:
- 系统用户数假设注册用户1亿人。
- 活跃用户数日活按照注册用户的10%,有1000w人。
- 目标用户数促销活动,只针对部分用户生效,可能我们目标是覆盖到100w人。
- 并发用户数100w人是我们期望覆盖的用户数量,中间会产生一些折损,而且这部分人也不会同一时间访问我们的系统。我们假设并发用户数为10w人。
- 因此,我们估算指标如下:峰值QPS10w+,平均RT:200-300ms。
设计思路
1、扩展能力
设计思路的第一点,就是系统要具有扩展的能力,扩展又分为垂直和水平两种。
垂直扩展
一类是传统大型软件系统的技术方案,被称作垂直伸缩方案。所谓的垂直伸缩就是提升单台服务器的处理能力,比如用更多核的 CPU、更大的内存、更快的网卡、更多的磁盘组成一台服务器,从普通服务器升级到小型机,从小型机提升到中型机,从中型机提升到大型机,从而使单台服务器的处理能力得到提升。通过这种手段提升系统的处理能力。
水平扩展
第二类就是水平扩展方案,不用更厉害的硬件,而使用更多的服务器,来构建成一个分布式集群,以此提升整体能力。
水平扩展其实也是随着互联网应用场景必备的解决方案,因为你没法预估有多少人来访问你的系统,或者在一些促销活动时,会有多少倍的流量增加。
简单来说,假设在4c8g(4核CPU,8g内存)情况下,单机支持500QPS时,10wQPS的流量洪峰情况下,200台机器就可以满足系统整体要求了。
那100W QPS的情况下,理论上2000台机器就可以满足系统要求了。
当然,横向扩展也不是无限扩展的,机器数量在达到一定量级的时候,可能就会带来一些架构上的问题。
2、多线程
假设依赖下游较多,较多数据需要处理,那么就要合理的利用多线程。
比如我们的活动系统,一个活动页面包含了不同的组件,共同组合成了这一个页面,可能包含优惠券模块、秒杀模块、内容展示模块等。
如果我们使用串行方式加载不同模块,假定每一个模块都需要100ms的时间,那么10个模块的加载时间就需要1000ms。
如果运营在页面上配置更多的模块,时间也会随着模块数量直线上升。
我们采用模块化的设计方案,每一个模块逻辑独立,我们可以采用多线程的方式来进行加载。
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);
}
}
3、异步
核心流程,经过我们多线程的处理,我们已经极大的减少了耗时。
异步其实最常见的是优化并发写的场景,常见的就是引入消息队列。应用服务器接收到用户的写操作请求后,并不直接与数据库进行交互,而是将写操作请求发送至消息队列服务器。随后,消息消费者服务器从消息队列服务器消费消息,最终完成对数据库的写入操作。
这种设计带来了两个主要好处。
首先,用户的写操作请求发送至消息队列后,应用服务器可以立即返回响应给用户。由于消息队列服务器的处理速度远远快于数据库,因此用户端的响应时间大大缩短。
其次,当消息队列执行写入数据库操作时,可以根据数据库的负载能力来控制写入速度。即使在用户请求并发很高的情况下,也不会导致数据库崩溃。因此,消息队列可以确保系统在性能最优的负载压力范围内稳定运行。
4、缓存
可以说,在计算机的世界中,凡是想要提高性能的场合都会使用到缓存的思想。利用好缓存,是每一个高并发系统必须要去考虑的。缓存是指将数据存储在访问速度相对较快的存储介质中,所以从缓存中读取数据的速度更快。
缓存是一个比较大的概念,在系统、软件不同级别有不同层级的缓存:
- 浏览器级别的缓存,会用来存储之前在网络上下载过的静态资源;
- 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的结果不一致,这个也是需要提前考虑到的。
5、容灾策略
当你负责了这么大的流量系统,你就要考虑一件事情,容灾。
你的系统出问题1s,那就意味着有10w人在这一刻,无法加载出这一个活动页面,这是不可接受的。
针对你服务中依赖的所有中间件,你需要考虑,他们故障的时候,你应该怎么办。
比如我们的活动系统,Redis承担了你的绝大部分读取流量,如果Redis故障了,怎么办。
常见的方案如下:限流:保护系统不被超出其处理能力的请求冲垮,通过拒绝请求的方式,保证系统的可用性。
降级:降级就是牺牲一些非核心功能,来保证系统核心功能可用性。比如当Redis服务故障,我们就启用我们的降级策略,读取数据逻辑先走本地缓存逻辑,虽然可能会产生服务之间的数据Diff,但是整体保证了我们系统时可用的。,
熔断:比如我们活动页面的query1依赖的服务故障,出现超时、错误率过高或资源不足等过载现象时,我们需要切断对该下游服务的请求,以避免出现故障扩散的情况。
当然,具体的容灾策略,需要经过一系列配置细化,方便精细化管理。整体改造完后,需要进行容灾演练,并有对应的操作面板,避免需要容灾时,操作太过复杂导致故障时间过长。
6、极致优化
服务抖动,涉及垃圾回收等。