关于软件设计的两三事:高并发到底会带来什么问题?

人生苦短,不如养狗

作者:闲宇

公众号:Brucebat的伪技术鱼塘

每当我们谈到软件架构设计时,总有一个绕不过去的话题:高并发。可以说,这是架构设计中最为经典的话题之一,以至于围绕这个话题展开的讨论与研究经久不息。虽然已经经过了无数的讨论、产生了许多通用的方案,但当我们真正想要独立去研究这个话题的时候,有时又会觉得思绪万千,无从下手。

那么,是什么原因导致了我们无从下手的处境呢?是软件知识掌握得不够吗?还是编程能力不够强吗?窃以为,都不是。真正导致这个问题的原因恰恰相反,过于丰富的软件知识和过于关注编程细节的行为导致我们陷于具体操作的编程问题中无法自拔,无法真正意义上从架构设计的角度去研究这个话题。要想上升到架构设计的角度去思考问题,其实也非常简单,那就是熟练掌握”是什么、为什么、怎么做“这样一个思考问题的方法。听起来是不是非常简单,那么下面我们就来按照这个思路来去研究一下:高并发到底会带来什么问题?

一、什么是高并发

在不少博客中,高并发被认为是描述系统同一时间处理请求的能力指标。但是在我看来,这样的描述并不准确。高并发真正描述的应该是系统所处的同一时间内出现大量请求或者事务的场景,而并非指系统的能力指标。

为什么要这么理解呢?因为架构设计实际上就是一种面向场景的设计,我们并不会为了一个不存在的场景而去设计一套可能不会使用的系统。也就是说,我们在去探究“高并发”这样一个概念时需要立足于对应的场景思考可能出现的问题,然后基于这些问题再去思考可能得应对方案,通过逐层分析来去获取一个较为完善全面的设计方案。而不是一上来就站在系统服务的层面思考如何提高并发处理的能力,这样很容易造成设计出来的方案内容零散、不成体系的情况。

二、高并发环境下系统面临的考验

那么,在高并发场景中,系统到底会面临怎样的考验呢?要想弄清楚这个问题,我们就必须从系统的两个方面进行分析:可靠性可用性

可靠性

可靠性就是指系统能否正确地处理数据并保证数据不丢失。简单来说,就是正确地修改和查询数据,并保证数据不丢失。

1. 数据一致性问题

无论是否处于高并发的场景,其实只要存在并发地读和写同一个数据的情况,都会产生数据处理是否正确的问题,也就是我们常说的数据一致性问题。到了这里,想必已经进入到不少朋友的舒适圈了。嘛?数据一致性问题?这不是背烂了的问题嘛。先别急着高兴,在看完了下面关于可用性的问题之后再高兴也不迟。

2. 数据持久化问题

同样的,无论是否处于高并发的场景,保证数据不丢失是每一个系统必须要实现的功能。当然,这里说的不丢失指的不是没有持久化,而是在已经持久化的情况下,如何保证数据不会因为存储节点故障而导致丢失(不可读)。

可用性

可用性就是指系统能否提供服务。简单来说,就是能不能用、用起来慢不慢。听起来是不是很好实现,但事实真的是这样吗?

1. 资源耗尽导致的不可用问题

大量请求意味着什么?意味着需要大量的CPU计算内存占用磁盘I/O以及网络带宽,而当资源的需求量远超服务器可以提供的资源总量时,崩溃也就随之而来了。

为了让大家能够更好地理解这个问题,这里闲宇借助一个现实中的例子来说明一下。在现实生活中我们经常会发现一些小而美的饭馆在客流量非常小的时候提供的服务快且好。而当这些饭馆变成网红饭馆之后,客流量变大了,服务反而变慢了、变差了,也就是出现了不可用的情况。出现这样一个情况的原因其实就是因为用户数量远超饭馆的负载,导致饭馆没有资源去处理这些用餐请求,也就是资源耗尽导致的不可用。

可以说,在软件没有任何bug异常、逻辑错误的情况下,硬件资源有限是导致服务不可用的根本原因。

2. 同步阻塞方式执行耗时任务导致的阻塞问题

耗时任务本身就需要执行较长的时间了,还要再使用同步阻塞的执行方式,那只会慢上加慢,甚至会出现响应超时的情况。而在高并发的场景下,这种问题会被进一步放大,最终可能导致整个服务不可用。

当然,在实际去分析、处理这个问题之前,我们还需要对耗时任务本身进行一个判断:这是否是一个真实的耗时任务。这里列举一些虚假的耗时任务:

  • 未经优化的查询逻辑(未命中索引、未添加索引、极其复杂的查询逻辑);

  • 不恰当地使用了长事务;

需要注意,这里所说的两个问题其实是系统本身就已经存在的问题,而不是因为高并发引起的问题。只不过在高并发场景中,这两个问题所造成的影响会被进一步放大,导致原本可用的系统最终变得无法使用。

三、如何应对这些考验

经过上面的分析,想必大家对于高并发场景带来的问题应该有所了解,那么应该如何解决这些问题呢?

让我们暂且先把可靠性相关的问题搁置在一边,首先来解决一下可用性的问题。

可用性问题解决

1. 资源耗尽导致的不可用问题的解决方案分析

首先是资源耗尽导致的不可用问题,这个问题最好、最快、最简单的解决方案就是加机器加资源。但是这个方案并不是万能的,主要有两个问题:

  • 加资源意味着加钱,就算你可以获得无限的资源,你也不能有无限的钱去买它;

  • 受限于软件架构的特性,不是所有的软件都能通过加机器的方式来获得性能上的提升,或者说存在上限,比如MySQL、Zookeeper这种有状态的软件系统只能进行垂直扩展(即增加单个服务器的硬件资源),而这种垂直扩展是存在扩展上限的,并不能做到无限制地扩展;

也就是说单纯地加机器是没有办法完全解决可用性的问题的。那么,我们就需要找到系统当中没有办法通过加机器来实现性能提升的部分,然后针对这部分节点的特性给出对应的解决方案。

从大量的实践当中可以发现,当遭遇高并发场景时,最先出问题的一般不是应用系统本身,多数情况都是底层数据库出现崩溃,然后异常逐层上浮,最终导致整个服务不可用。那么为什么会出现这样一个现象呢?理论上,应用系统本身和底层数据库所面对的流量应该是一样的,为什么前者能够顶住,后者就这么轻易地崩溃了呢?这里主要有以下两个原因:

  • 在多数情况下,应用系统本身处理的都是一些轻量级的请求,本身不会涉及过多的IO或者CPU处理。而数据库则需要进行频繁的IO操作(读、写以及网络传输)和CPU处理,这使得两者对于资源的需求不可同日而语;

  • 在现今的架构设计中应用层架构大多被设计成无状态或者尽量少状态,这使得应用层系统更容易通过水平扩展来应对高并发场景,也就是我们上面说的”加机器、加副本“。但是数据库却很难做到,它只能进行单机器的垂直扩展,而这种有限的扩展最终还是会导致无法承载高并发的请求而出现崩溃不可用的情况;

那么,我们要如何解决高并发场景下数据库出现崩溃不可用的情况呢?办法其实很简单,既然数据库承载不了这么多的流量,那就不要让这些流量触达数据库,而是将这些流量引导给应用系统和数据库的缓冲层——缓存。当然,这里可能有人要问了,凭什么缓存可以处理数据库都无法处理的高并发场景?这个问题也是一个非常关键、也非常有意思的问题,但是由于篇幅的限制,这里就不过多赘述,后续我会另写一篇博客仔细分析一下。

在引入了缓存之后,因为硬件资源导致的可用性的问题就解决了吗?不好意思,依然没有,因为这里仍然存在两个问题:

  • 第一,即使加入了缓存,查询或者更改的数据的其来源还是数据库,也就是说在缓存中查询不到数据的时候,这些请求依然会到达数据库中。而在高并发场景下,这种情况则会演变成缓存穿透缓存击穿缓存雪崩缓存污染等缓存风险,依然会导致数据库崩溃;

  • 第二,缓存中的数据的来源是数据库,即使我们使用了正确的方案来应对了缓存风险,仍然还是会有请求会到达数据库。虽然此时大部分的请求已经被缓存分走,但在高并发场景下,剩余的请求依然会给数据库带来不少的压力;

前一个问题已经有了非常多的处理方案,闲宇就不再做过多的分析,大家可以自行检索了解一下。而对于后一个问题,也有解法,那就是对数据库的架构做拆分处理,分解为主库和从库,主库处理写请求,从库处理读请求,也即我们常说的读写分离。这种做法的本质是为了将流量进一步分摊,尽可能保证数据库中每个节点接受到的请求量是在它承载范围内的,同时减少读写操作引起的锁争用,提高数据库的读写性能。但这种做法也会有存在对应的问题,那就是数据一致性问题。之前我们说过,数据库是一个有状态的系统,即使做了这种拆分,依然没有改变它有状态的特性。这就意味着,如果想要让每个节点的状态保持一致,那就需要进行数据的同步处理。而数据的同步需要时间,在同步的这段时间内,应用层系统进行查询时就会出现数据不一致的情况。不过,在这个小结中,我们讨论的是可用性的问题,关于数据一致性的问题可以留到下面可靠性问题解决方案中再行讨论。

分析到这里其实可以发现,要想让系统可用、不崩,其实就是通过各种手段不断地去将高并发场景中的流量分摊,尽可能地避免将所有的流量都集中到系统中某个单一的节点上。但即使我们做出了这么多的努力,辛苦搭建的系统依然存在流量承载的上限。那么,在我们做出了如此之多的尝试依然无法抵挡流量的洪峰时,应该如何处理?无他,唯有限流

这个时候可能有人会说了:搞了半天,最终还是要限流,那为什么不一开始就限流呢?呃,这个问题很难评,我只能说挣十个人的钱和挣十万个人的钱大概还是有点区别的吧。

2. 同步阻塞方式执行耗时任务导致的阻塞问题的解决方案分析

在上面进行问题分析时我就提到了,这个问题并不属于高并发场景引发的问题,而是系统本身就存在的问题。在流量小的情况下可能不会引发系统的不可用,但是在高并发场景中则会有很大可能性导致系统出现大面积的响应超时。所以这个问题依然是需要解决的问题。

首先可以想到的解决方案就是异步化。这里的异步化有两种方案,一种是将同步阻塞式的执行方式变更为基于Reactor模型的执行方式,通过减少事件循环线程来减少线程上下文的切换,以此来提高系统的性能。另外一种则是通过消息队列实现真正意义上的异步处理,将主线程从同步处理的执行过程中释放出来,避免出现响应超时的问题。

除了异步化,我们还可以对耗时任务本身进行优化。对于一般的业务场景来说,耗时任务要么是IO密集型任务,要么就是CPU密集型任务,要么就是两者的混合。

  • IO密集型任务优化:可以尝试将多个IO任务合并成一个IO来处理。如果没有办法合并,则可以尝试使用多线程方式来并行处理IO任务。除此以外,在实际读写数据时,我们还可以借助零拷贝技术来去进一步优化IO操作;

  • CPU密集型任务优化:对于需要CPU进行计算的任务,可以尝试将任务拆解成多个子任务,然后通过多线程方式并行处理多个子任务,以此来提高计算效率。同时还可以将一些通用的计算结果缓存,避免重复计算;

可靠性问题解决

分析完了可用性问题的解决方案,终于来到了我们的“舒适区”——可靠性问题的解决。但是这真的是我们的“舒适区”吗?

在上面可用性问题的解决方案分析过程中,其实有新增了两个对于系统可靠性的考验,即缓存和数据库数据一致性问题以及“主-从”架构数据库数据一致性问题。再加上并发场景下数据一致性问题,关于数据一致性问题的考验,我们需要同时处理三个。

想想就有点头疼。不过,在头疼之前,我还是要告诉你一个好消息,由于在上面的方案中我们对数据库做了主-从拆分,在一定程度上解决了数据库单点故障可能导致的数据丢失问题。再结合对应的数据同步方式、自动故障转移以及定期备份,基本上就可以保障数据的安全性问题。那么,下面我们就将关注点集中到数据一致性问题上的处理。

1. 缓存和数据库数据一致性问题解决

缓存和数据库之间的数据不一致问题归根结底来源于数据是会发生变更这样一个现实。如果数据永远不会发生变更,那么也就不会存在缓存和数据库之间的数据一致性问题。但这是不可能。

不过,在我们实际去思考这个问题的解决方案时,我们需要先建立一个概念:缓存通常不追求强一致性。也就是说,我们在处理缓存和数据库的一致性问题时,通常情况来说只需要保证最终一致性即可。基于这样一个目标,这里我推荐使用Cache Aside模式中写数据的方式作为我们进行数据更新时的策略:

  • 先写数据库,再写缓存;

  • 应当失效缓存,而不是尝试更新缓存;

这是一个非常低成本且相对可靠的数据更新方案,但是需要注意,在高并发场景中,单独使用Cache Aside模式有可能会引发缓存击穿问题,所以在实际使用时我们还需要配合锁来进行使用,保证只有第一个请求会进入到数据库中,其他的请求采取阻塞或重试的策略。

2. “主-从”架构数据库数据一致性问题解决

对于这个问题,数据库的设计者其实已经提供对应的方案:即数据同步策略

  • 异步复制:主库写入数据后立即返回成功,从库异步地获取并应用这些变更。在这种策略下,数据库的性能最高,但同步存在延迟,可能会导致数据存在不一致;

  • 半同步复制:主库在确认写操作成功之前,至少需要等待一个从库确认已经接收到并记录了该操作的日志。这种策略一定程度上保证了数据的一致性,减少了数据丢失的风险,但增加了写操作的延迟;

  • 同步复制:每次写操作必须在所有节点上都成功提交后才返回确认给客户端。这种操作提供了最高的数据一致性保障,相对的,性能也是最低的;

根据不同场景对于一致性和性能的要求,我们可以自主选择对应的数据同步方案。同时,在某些关键性操作上,可以采取强制读主库的操作,保证能够看到最新的数据;

3. 并发场景下数据一致性问题解决

这个问题同样需要根据实际业务场景来考虑。根据实际业务场景对于数据一致性的要求,可以将解决方案分为两类:

  • 追求强一致性的解决方案;

  • 追求最终一致性的解决方案;

其实对于大部分业务场景来说,强一致性的解决方案最终落地实现的时候都会演变成最终一致性的解决方案。因为只要我们进行的不是同一个数据源的数据操作,就很难做到传统数据库所能达到的强一致性。这里我们可以借助秒杀活动的场景来分析一下。

在秒杀场景中,库存数据其实就是一个需要保证强一致性的数据,因为库存就那么多,一定不能出现超售的情况。但在实际的处理中,只有在涉及数据库的部分是强一致性的,对于缓存和数据库之间的数据其实并没保证强一致性。因为在这个过程中,大多会采取预扣库存的策略(类似TCC模式),先去扣减缓存的库存数据,只有实际支付成功才会真实地去扣减数据库中的数据库(这部分操作会通过数据库的本地事务保障一致性),如果没有支付或者支付失败受影响的只会是缓存。

所以,很多时候看起来要求强一致性的业务场景也能够通过最终一致性的方案解决,关键就在于如何找到业务中可以容忍的点和绝对不可以容忍的点,通过平衡这两者的利弊来设计出最终合适当前场景的方案。

四、总结

从上面的分析其实不难发现,应对高并发场景的关键就在于如何合理地去分摊系统中每个节点所需要处理的流量。同时在这个基础上,根据不同场景对于不同数据的一致性的容忍程度设计出符合业务逻辑的保障数据可靠的方案。抓住这两个关键点,解决高并发场景带来的问题就会显得不是那么困难了。

当然,如果经过了这么多的努力,还是解决不了流量过高的的问题,那就只能祭出终极大招:限流。简单来说,就是:客满,请下次再来!

最后,新的一年,希望各位打工人、同学都能身体健康,心想事成,早日暴富。新年快乐~~