并发系统设计

按照场景区分不同情况下的并发设计

Posted by ALID on December 10, 2019

BASE

虽然都是高QPS但有不同的类型, 首先按照并发查并发写来进行分类说明

但是在说这个问题的时候, 我们先来看一下会影响我们系统QPS的因素.

1. 响应时间和 QPS

对于大部分的 Web 系统而言,响应时间一般都是由 CPU 执行时间和线程等待时间(比如 RPC、IO 等待、Sleep、Wait 等)组成,即服务器在处理一个请求时,一部分是 CPU 本身在做运算,还有一部分是在各种等待。理解了服务器处理请求的逻辑,估计你会说为什么我们不去减少这种等待时间。很遗憾,根据我们实际的测试发现,减少线程等待时间对提升性能的影响没有我们想象得那么大,它并不是线性的提升关系,这点在很多代理服务器(Proxy)上可以做验证。

如果代理服务器本身没有 CPU 消耗,我们在每次给代理服务器代理的请求加个延时,即增加响应时间,但是这对代理服务器本身的吞吐量并没有多大的影响,因为代理服务器本身的资源并没有被消耗,可以通过增加代理服务器的处理线程数,来弥补响应时间对代理服务器的 QPS 的影响。

其实,真正对性能有影响的是 CPU 的执行时间。这也很好理解,因为 CPU 的执行真正消耗了服务器的资源。经过实际的测试,如果减少 CPU 一半的执行时间,就可以增加一倍的 QPS。

也就是说,我们应该致力于减少 CPU 的执行时间。

2. 线程数对 QPS 的影响

单看“总 QPS”的计算公式,你会觉得线程数越多 QPS 也就会越高,但这会一直正确吗?显然不是,线程数不是越多越好,因为线程本身也消耗资源,也受到其他因素的制约。例如,线程越多系统的线程切换成本就会越高,而且每个线程也都会耗费一定内存。

那么,设置什么样的线程数最合理呢?其实很多多线程的场景都有一个默认配置,即线程数 = 2 * CPU 核数 + 1。除去这个配置,还有一个根据最佳实践得出来的公式:

1
线程数 = [(线程等待时间 + 线程 CPU 时间) / 线程 CPU 时间] × CPU 

数量当然,最好的办法是通过性能测试来发现最佳的线程数

换句话说,要提升性能我们就要减少 CPU 的执行时间,另外就是要设置一个合理的并发线程数,通过这两方面来显著提升服务器的性能。

3. I/O对QPS的影响

而除此之外I/O也是限制我们的瓶颈之一, 尤其是数据库的I/O对于高并发系统来说基本无法忍受的, 而减少I/O的最有效的方法就是使用缓存.

并发查

全量缓存

这种场景,一般都可以将使用计算结果全部存到内存或redis中, 这样我们可以不用考虑缓存穿透, 缓存雪崩以及缓存击穿的问题.

但也需要考虑一些问题

  1. 缓存更新: 需要定时或增加触发机制保证缓存的更新

  2. 缓存同步: 如果使用本地缓存,则需要保证不同机器缓存同步更新

  3. 机器限流: 即使是本地缓存也不能无限制承受QPS,需要考虑使用队列削峰,或者根据业务场景也可以采用拒绝策略.

  4. 监控: 如果缓存不是实时计算的就要有监控保证我缓存的有效性. 监控缓存为空和查询超时的情况.并且缓存为空应该及时报警并刷新缓存.

查询实时结果

这种场景, 需要根据每次请求计算不同的结果. 设计就要复杂很多, 需要考虑是否异步,以及缓存穿透, 缓存雪崩以及缓存击穿的问题.

1. 交互方式

首先从和调用方的交互说起, 这样的请求一般有两种情况.

  1. 不需要大量计算, 可以很快返回结果: 一般不需要考虑异步请求, 直接使用同步请求即可

  2. 需要实时计算, 或者生成结果较慢的情况: 这种情况也可以采用两种方式.

    • 异步回调: 这种方式的请求方不需要等待返回, 而是消费者再生成结果的时候回调请求方的回调接口将信息返回. dubbo就支持这种异步请求的方式, 请求结果放到一个future中, 提前请求使用所需资源. 之后集中使用.

    • 多次请求: 这种方式会至少发两次请求,接收到请求的时候消费者会直接查缓存, 如果缓存中有结果(热点数据)可以直接返回 (这里要注意更新热点缓存). 如果没有结果直接返回默认值, 并开始计算. 计算完成后存到缓存中. 当调用方再次请求的时候, 还是在缓存中查询. 但这次因为刚刚生成了缓存, 就可以查询到结果.

对应以上两种方式, 多次请求的方式可以减少系统的耦合, 适用于会生成多批结果的场景. 例如机票报价查询平台就会请求多次, 每次会返回计算较快的报价, 之后每次查询可能都会得到新的报价.

而异步回调的方式更适用于返回结果单一对结果依赖性较强, 调用方需要拿到结果进行下一步计算的情况. 例如机票报价系统中, 会在产生报价的时候请求很多资源, 比如:政策, 代理商接口等. 其中代理商报价系统因为是http外部接口一般要慢一些, 这样的话我就可以异步请求代理商系统, 该系统在拿到报价后回调, 接受到回调之后就可以将结果写到缓存中, 以供之后计算使用.

这只是交互方式, 我们还需要考虑缓存/DB等问题

2. 缓存设计

一般情况下, 都有热点数据, 这里也是基于考虑热点数据的场景进行设计

本地缓存

一般每次请求都会先查询本地缓存, 可以使用LRU算法保证热点数据在缓存中驻留. 如果是时效性较强的场景, 还需要对于每个缓存记录查询次数. 如果超过限制次数也会和失效一样触发缓存更新.

分布式缓存

本机内存做的缓存是查询最快的, 但是因为分布式等问题, 除了可以做全量缓存的情况, 例如酒店搜索, 其他大多数情况一般都不考虑每台机器全量缓存. 所以就需要使用分布式缓存存放热点数据, 例如redis.

redis的数据可以在本地缓存失效的时候刷新到redis中, 这里也要考虑缓存的时效性问题. 但实际上缓存时效性问题的确会发生, 例如机票报价的价格之后改变了, 可以在下单的时候再次check价格, 如果变化较大可以提示用户价格变动, 避免损失. P.s.一般情况还是按照查询时价格生单.

虽然我们设计好了缓存, 但是缓存也会面临一些问题.

缓存穿透

访问一个不存在的key,缓存不起作用,请求会穿透到DB,流量大时DB会挂掉。可以采用以下方式尝试:

  1. 采用布隆过滤器,使用一个足够大的bitmap,用于存储可能访问的key,不存在的key直接被过滤;
  2. 访问key未在DB查询到值,也将空值写进缓存,但可以设置较短过期时间。

缓存雪崩

大量的key设置了相同的过期时间,导致在缓存在同一时刻全部失效,造成瞬时DB请求量大、压力骤增,引起雪崩。可以采用以下方式尝试:

可以给缓存设置过期时间时加上一个随机值时间,使得每个key的过期时间分布开来,不会集中在同一时刻失效。

缓存击穿

一个存在的key,在缓存过期的一刻,同时有大量的请求,这些请求都会击穿到DB,造成瞬时DB请求量大、压力骤增。可以采用以下方式尝试:

在访问key之前,采用SETNX(set if not exists)来设置另一个短期key来锁住当前key的访问,访问结束再删除该短期key。

3. 系统可用性设计

如果要对应理论依据的话是保证了BASE理论中的基本可用

限流

即使再稳定的系统也不能无限制接受请求. 根据排队理论,具有延迟的服务随着请求量的不断提升,其平均响应时间也会迅速提升,为了保证服务的SLA,有必要控制单位时间的请求量

限流一般分为以下几类:

qps限流: 限制每秒处理请求数不超过阈值。

并发限流: 限制同时处理的请求数目。Java 中的 Semaphore 是做并发限制的好工具,特别适用于资源有效的场景。

单机限流: Guava 中的 RateLimiter。

限流算法

  1. 漏桶算法

    漏桶算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率。 例如: 在旗舰店运价库对航司报价的离线抓取中,由于航司接口有qps限制,需要限制访问速率。所以我们借助redis队列作为漏桶,实现了对航司接口访问速率的控制。

  2. 令牌桶算法

    对于很多应用场景来说,除了要求能够限制数据的平均传输速率外,还要求允许某种程度的突发传输。这时候漏桶算法可能就不合适了,令牌桶算法更为适合。 令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。例如刚刚提到的 Guava 的 RateLimiter就是使用了令牌桶算法.

  3. 滑动窗口

    滑动窗口就是记录一定时间的请求次数或者失败次数, 如果超过则可以限流, 而如果是失败次数超过限制也可以作熔断或降级操作. 熔断框架 Hystrix 也采用滑动窗口来统计服务调用信息

一般限流策略有两种: 超出限制排队拒绝, 两种方案各有优劣势. 排队可能会让之后的请求都变动很慢, 而且如果不能快速失败也可能拖垮整个系统; 如果是拒绝的话, 可能会影响用户体验.

降级

降级也就是服务降级,当我们的服务器压力剧增为了保证核心功能的可用性 ,而选择性的降低一些功能的可用性, 或者选择一些兜底方案或低优先级方案,或者直接关闭该功能

  1. 对于复杂的系统, 可以降级掉一些不太重要的部分, 优先保证核心系统运行.

  2. 对于有多套解决方案的系统, 可以降级采用保底方案.

熔断

降级一般而言指的是我们自身的系统出现了故障而降级。而熔断一般是指依赖的外部接口出现故障的情况断绝和外部接口的关系。

例如你的A服务里面的一个功能依赖B服务,这时候B服务出问题了,返回的很慢。这种情况可能会因为这么一个功能而拖慢了A服务里面的所有功能,因此我们这时候就需要熔断!即当发现A要调用这B时就直接返回错误(或者返回其他默认值啊啥的),就不去请求B了。我这还是举了两个服务的调用,有些那真的是一环扣一环,出问题不熔断,那真的是会雪崩。

这里的熔断和是不是感觉和降级很像, 都是在异常超限后的一些处理操作. 而我的理解是如果有降级策略应该优先使用降级. 而如果单一接口调用失败次数超限, 这些请求也都已经降级过了, 所以也可以理解为降级太多就可以直接熔断.

还有一点区别是,降级也任务是成功的处理, 而熔断后需要有恢复的策略.

并发写

秒杀

系统整体设计

这种场景和刚刚的查询不一样的地方, 是需要修改记录. 最需要关注的是修改库存

我们知道只要创建订单,就要频繁操作数据库 IO。那么有没有一种不需要直接操作数据库 IO 的方案呢?

这就是预扣库存。先在本地缓存中扣除了库存,保证不超卖。之后在返回给用户支付页面的时候, 异步生成用户订单,这样响应给用户的速度就会快很多;那么怎么保证不少卖呢?用户拿到了订单,不支付怎么办?我们都知道现在订单都有有效期,比如说用户五分钟内不支付,订单就失效了,订单一旦失效,就会加入新的库存,这也是现在很多网上零售企业保证商品不少卖采用的方案。

订单的生成是异步的,一般都会放到 MQ、Kafka 这样的即时消费队列中处理,如果再加上队列的话对数据库的压力也比较小. 并且订单量比较少的情况下,生成订单非常快,用户几乎不用排队。

img

以上就是大致的系统设计图, 用户100w请求量, 均摊到100台机器上. 每台机器分配好100单库存, 这里你可能注意到了我标出的+50.

说清楚这点需要我们再考虑一种情况, 如果有机器在秒杀的时候宕机怎么办? 那不就少卖了吗. 所以考虑再每个机器上增加一些冗余库存 (当然不一定要定为50单) 也就是每台机器都存放150单. 因为每次生单前必须要本地和redis都减库存成功, 可以保证不超卖. 而且这样就可以防止有些机器宕机后, 该机器上的票没有卖出去的情况.

之后在减库存成功后, 异步发送MQ队列修改DB, 已经生单的操作.

对于这种方案, 你可以会有点疑惑. 既然用redis了为什么还要在每台机器上放一部分库存. 我们来对比一下两种方式的redis请求量, 按照这种方式请求量为: (100+50) * 100 就是最大请求量了. 而如果没有本地库存, 那所有请求都会打到redis上.

其他方案

如果可以忍受一定的本地脏数据的方式, 还有一种处理方案, 将划分成动态数据和静态数据分别进行处理:

  • 像商品中的“标题”和“描述”这些本身不变的数据,会在秒杀开始之前全量推送到秒杀机器上,并一直缓存到秒杀结束;

  • 像库存这类动态数据,会采用“被动失效”的方式缓存一定时间(一般是数秒),失效后再去缓存拉取最新的数据。

这里库存不是实时更新的, 可能出现下单后支付的时候再提示已经无票的情况.

这种设计读的场景可以允许一定的脏数据,因为这里的误判只会导致少量原本无库存的下单请求被误认为有库存,可以等到真正写数据时再保证最终的一致性,通过在数据的高可用性和一致性之间的平衡,来解决高并发的数据读取问题。

负载均衡

负载均衡除了要考虑尽量均分需求以外, 还要把同一个唯一建的请求全部通过hash打到同一台机器, 这样就可以在机器层面加防刷单校验

削峰

主要为了防止瞬时大量请求的情况, 我们在前面提到的限流的方法就是一种削峰的方法. 但是如果考虑到整体系统设计, 还可以使用分层过滤的方式处理

在刚刚的系统设计中就是:

  1. 大部分数据和流量在用户浏览器或者 CDN 上获取,这一层可以拦截大部分数据的读取

  2. 经过第二层(即前台系统)时数据(包括强一致性的数据)尽量得走 Cache,过滤一些无效的请求

  3. 再到第三层后台系统,在缓存中做数据的检验,对系统做好保护和限流,这样数据量和请求就进一步减少

  4. 在此过程中很少的请求会打到redis上, 进行二次校验, 保证不会超卖和少卖

  5. 最后真实的下单请求通过MQ队列发送到订单系统上, 保证不会压垮DB和后续系统. 并且在这里(支付)完成强一致性校验.

其实这是一种整体的设计思路, 并不是某种具体的解决方案. 这种思路中要求我们对于请求尽可能的在每一步进行过滤, 在不同的层次尽可能地过滤掉无效请求,让“漏斗”最末端的才是有效请求。

DB设计

对于秒杀的数据库, 还可以对热点数据单独建立热点库, 就不会影响其他大量数据的使用了. 并且以为DB查询较缓存还是太慢, 在DB之前尽量做排队.

一致性问题

设计到分布式, 并发就不得不提到一致性问题. 在CAP设计原理中,在保证分区容错性的前提下,追求高可用就必然牺牲强一致性, 但也不是放弃一致性, 而是容数据延迟(BASE理论的软状态), 但要保证可以达到BASE理论最终一致性.

缓存一致性

当我们用用缓存直接返回给后台的时候. 假设有一部分数据是经常变动的, 但是既然做了缓存, 那就不能再每个请求都实时计算了. 这样的话我们就要考虑缓存更新的问题.

热点数据更新

这里可以通过3点来保证

  1. 定时更新: 如果缓存一直不失效也要定时取更新缓存

  2. qps更新: 当打到一定的请求就去更新一次缓存

  3. 通知更新: 如果更新源是可控的, 可以在源数据发送更新的时候通知对应缓存更新.

通过以上三点基本就可以来保证热点缓存的一致性了, 但既然是缓存就要容许一定的不一致. 一定要在最后一步再次校验最终一致性, 比如在支付的时候再次验价.

非热点数据更新

这部分数据, 大概率缓存中根本没有. 每次都需要进行计算, 或者是从2级缓存中读取. 这里即需要保证本地缓存的一致性也好保证redis数据的一致性.

本地数据, 因为缓存已经过期所以自己重新读取/计算就好了

redis缓存, 如果缓存中有驻留数据, 也应该按照热点数据的方式设定更新方案进行更新. 但是如果恰好拿到的不是最新数据, 这里有一点不同的地方. 只要发现本地没有缓存就应该可以计算一次, redis缓存只作为快速查询使用. 这次新算出的结果在超时后就会刷新到redis中. 更新redis的缓存.