问题分类

侧重于 高并发读 的系统:搜索引擎、电商的商品搜索、电商系统的描述&图片&价格。这些读比写要频繁的多。

侧重于 高并发写 的系统:点击付费系统。

侧重于 高并发读和写 的系统:库存 & 秒杀系统、支付系统、微信红包、微博&朋友圈。

高并发读

策略 1: 加缓存

  1. 本地缓存 or Redis 集中式缓存
  2. MySQL 的 Master / Slave
  3. CDN 静态文件加速(动静文件分离)

策略 2: 并发读

案例 1:异步 RPC

现在的 RPC 框架基本都支持了异步 RPC,对于用户的一个请求,如果需要调用 3 个 RPC 接口,则耗时分别是 T1、T2、T3。

如果是同步调用,则所消耗的总时间 T=T1+T2+T3;如果是异步调用,则所消耗的总时间 T=Max(T1,T2,T3)。

当然,这有个前提条件:3 个调用之间没有耦合关系,可以并行。如果必须在拿到第 1 个调用的结果之后,根据结果再去调用第 2、第 3 个接口,就不能做异步调用了。

案例 2:Google 的“冗余请求”

Google 公司的 Jeaf Dean 在 The Tail at Scale 一文中讲过这样一个案例:假设一个用户的请求需要 100 台服务器同时联合处理,每台服务器有 1%的概率发生调用延迟(假设定义响应时间大于 1s 为延迟),那么对于 C 端用户来说,响应时间大于 1s 的概率是 63%。

这个数字是怎么计算出来的呢?如果用户的请求响应时间小于 1s,意味着 100 台机器的响应时间都小于 1s,这个概念是 100 个 99% 相乘,即 99% ^ 100。
反过来,只要任意一台机器的响应时间大于 1s,用户的请求就会延迟,这个概率是 1 - 99% ^ 100 = 0.63

这意味着:虽然每一台机器的延迟率只有 1%,但对于 C 端用户来说,延迟率却是 63%。机器数越多,问题越严重。

而越是大规模的分布式系统,服务越多,机器越多,一个用户请求调动的机器也就越多,问题就越严重。

文中给出了问题的解决方法:冗余请求。客户端同时向多台服务器发送请求,哪个返回得快就用哪个,其他的丢弃,但这会让整个系统的调用量翻倍。

把这个方法调整一下,就变成了:客户端首先给服务端发送一个请求,并等待服务端返回的响应;如果客户端在一定的时间内没有收到服务端的响应,则马上给另一台(或多台)服务器发送同样的请求;客户端等待第一个响应到达之后,终止其他请求的处理。上面“一定的时间”定义为:95%请求的响应时间。

文中提到了 Google 公司的一个测试数据:采用这种方法,可以仅用 2% 的额外请求将系统 99.9%的请求响应时间从 1800ms 降低到 74ms。

策略 3: 重写轻读

案例 1:微博 Feeds 流

微博首页或微信朋友圈都存在类似的查询场景:用户关注了 n 个人(或者有 n 个好友),每个人都在不断地发微博,然后系统需要把这 n 个人的微博按时间排序成一个列表,也就是 Feeds 流并展示给用户。同时,用户也需要查看自己发布的微博列表。

所以对于用户来说,最基本的需求有两个:查看关注的人的微博列表(Feeds 流)和查看自己发布的微博列表。

如果查找某个用户发布的微博列表(分页显示),直接查一个表就可以了。

但是如果要查某个用户的 Feeds 流,并且按照时间排序、分页显示,则需要两个 SQL 语句。先查询关注列表,再查询 Feeds。

改成重写轻读,不是查询的时候再去聚合,而是提前为每个 user_id 准备一个 Feeds 流,或者叫收件箱。

如图 8-8 所示,每个用户都有一个发件箱和收件箱。假设某个用户有 1000 个粉丝,发布 1 条微博后,只写入自己的发件箱就返回成功。然后后台异步地把这条微博推送到 1000 个粉丝的收件箱,也就是“写扩散”。这样,每个用户读取 Feeds 流的时候不需要再实时地聚合了,直接读取各自的收件箱就可以。这也就是“重写轻读”,把计算逻辑从“读”的一端移到了“写”的一端。

700|600

这里的关键问题是收件箱是如何实现的?因为从理论上来说,这是个无限长的列表。

很显然,这个列表必须在内存里面。假设用 Redis 的 <key,list> 来实现,key 是 user_id,list 是 msg_id 的列表。但这个 list 不能无限制地增长,假设设置一个上限为 2000。

那么用户在屏幕上一直往下翻,当翻到 2000 个以外时,怎么分页查询呢?

最简单的方法:限制数量!最多只保存 2000 条,2000 条以外的丢弃。因为按常识,手机屏幕一屏通常显示 4~6 条,2000 条意味着用户可以翻 500 屏,一般的用户根本翻不到这么多。而这实际上就是 Twitter 的做法,据公开的资料显示,Twitter 实际限制为 800 条。

但对于用户发布的微博,不希望过了一段时间之后,系统把历史数据删掉了,而是希望系统可以全量地保存数据。

但 Redis 只能保存最近的 2000 个,2000 个以前的数据如何持久化地存储并且支持分页查询呢?

还是考虑用 MySQL 来存储表 8-2 中的数据,很显然这个数据会一直增长,不可能放在一个数据库里面。

那就涉及按什么维度进行数据库的分片。

一种是按 user_id 进行分片,一种是按时间范围进行分片(比如每个月存储一张表)。

如果只按 user_id 分片,显然不能完全满足需求。因为数据会随着时间一直增长,并且增长得还很快,用户在频繁地发布微博。

如果只按时间范围分片,会冷热不均。假设每个月存储一张表,则绝大部分读和写的请求都发生在当前月份里,历史月份的读请求很少,写请求则没有。

所以需要同时按 user_id 和时间范围进行分片。

但分完之后,如何快速地查看某个 user_id 从某个 offset 开始的微博呢?比如一页有 100 个,现在要显示第 50 页,也就是 offset=5000 的位置开始之后的微博。如何快速地定位到 5000 所属的库呢?

这就需要一个二级索引:另外要有一张表,记录<user_id,月份,count>。也就是每个 user_id 在每个月份发表的微博总数。基于这个索引表才能快速地定位到 offset=5000 的微博发生在哪个月份,也就是哪个数据库的分片。

解决了读的高并发问题,但又带来一个新问题:假设一个用户的粉丝很多,给每个粉丝的收件箱都复制一份,计算量和延迟都很大。比如某个明星的粉丝有 8000 万,如果复制 8000 万份,对系统来说是一个沉重负担,也没有办法保证微博及时地传播给所有粉丝。

这就又回到了最初的思路,也就是读的时候实时聚合,或者叫作“拉”。

具体怎么做呢?

在写的一端,对于粉丝数量少的用户(假设定个阈值为 5000,小于 5000 的用户),发布一条微博之后推送给 5000 个粉丝;

对于粉丝数多的用户,只推送给在线的粉丝们(系统要维护一个全局的、在线的用户列表)。

有一点要注意:实际上一个用户的粉丝数会波动,这里不一定是一个阈值,可以设定个范围,比如 [4500,5500]。

对于读的一端,一个用户的关注的人当中,有的人是推给他的(粉丝数少于 5000),有的人是需要他去拉的(粉丝数大于 5000),需要把两者聚合起来,再按时间排序,然后分页显示,这就是“推拉结合”。

案例 2:多表的关联查询:宽表与搜索引擎

在策略 1 里提到了一个场景:后端需要对业务数据做多表关联查询,通过加 Slave 解决,但这种方法只适合没有分库的场景。

如果数据库已经分了库,那么需要从多个库查询数据来聚合,无法使用数据的原生 Join 功能,则只能在程序中分别从两个库读取数据,再做聚合。

但存在一个问题:如果需要把聚合出来的数据按某个维度排序并分页显示。这个维度是一个临时计算出来的维度,而不是数据库本来就有的维度。

由于无法使用数据库的排序和分页功能,也无法在内存中通过实时计算来实现排序、分页(数据量太大),这时如何处理呢?

还是采用类似微博的重写轻读的思路:提前把关联数据计算好,存在一个地方,读的时候直接去读聚合好的数据,而不是读取的时候再去做 Join。

具体实现来说,可以另外准备一张宽表:把要关联的表的数据算好后保存在宽表里。依据实际情况,可以定时算,也可能任何一张原始表发生变化之后就触发一次宽表数据的计算。

也可以用 ES 类的搜索引擎来实现:把多张表的 Join 结果做成一个个的文档,放在搜索引擎里面,也可以灵活地实现排序和分页查询功能。

总结 :读写分离(CQRS 架构)

无论加缓存、动静分离,还是重写轻读,其实本质上都是读写分离,这也就是微服务架构里经常提到的 CQRS(Command Query Responsibility Separation)。

500|600

  1. 分别为读和写设计不同的数据结构。在 C 端,当同时面临读和写的高并发压力时,把系统分成读和写两个视角来设计,各自设计适合高并发读和写的数据结构或数据模型。
    可以看到,缓存其实是读写分离的一个简化,或者说是特例:左边的写(业务 DB)和右边的读(缓存)用了基本一样的数据结构。
  2. 写的这一端,通常也就是在线的业务 DB,通过分库分表抵抗写的压力。读的这一端为了抵抗高并发压力,针对业务场景,可能是<K,V>缓存,也可能是提前做好 Join 的宽表,又或者是 ES 搜索引擎。如果 ES 的性能不足,则自己建立倒排索引和搜索引擎。
  3. 读和写的串联。定时任务定期把业务数据库中的数据转换成适合高并发读的数据结构;或者是写的一端把数据的变更发送到消息中间件,然后读的一端消费消息;或者直接监听业务数据库中的 Binlog,监听数据库的变化来更新读的一端的数据。
  4. 读比写有延迟。因为左边写的数据是在实时变化的,右边读的数据肯定会有延迟,读和写之间是最终一致性,而不是强一致性,但这并不影响业务的正常运行。

拿库存系统举例,假设用户读到某个商品的库存是 9 件,实际可能是 8 件(某个用户刚买走了 1 件),也可能是 10 件(某个用户刚刚取消了一个订单),但等用户下单的一刻,会去实时地扣减数据库里面的库存,也就是左边的写是“实时、完全准确”的,即使右边的读有一定时间延迟也没有影响。

同样,拿微博系统举例,一个用户发了微博后,并不要求其粉丝立即能看到。延迟几秒钟才看到微博也可以接受,因为粉丝并不会感知到自己看到的微博是几秒钟之前的。

这里需要做一个补充:对于用户自己的数据,自己写自己读(比如账号里面的钱、用户下的订单),在用户体验上肯定要保证自己修改的数据马上能看到。

这种在实现上读和写可能是完全同步的(对一致性要求非常高,比如涉及钱的场景);也可能是异步的,但要控制读比写的延迟非常小,用户感知不到。

虽然读的数据可以比写的数据有延迟(最终一致性),但还是要保证数据不能丢失、不能乱序,这就要求读和写之间的数据传输通道要非常可靠。抽象地来看,数据通道传输的是日志流,消费日志的一端只是一个状态机。

高并发写

策略 1: 数据分片

案例 1: 数据库的分库分表
案例 2: JDK 的 ConcurrentHashMap 的实现
案例 3: Kafka 的 partition
案例 4: ES 的分布式索引

策略 2: 任务分片

案例 1: CPU 的指令流水线
案例 2: Map/Reduce
案例 3: Tomcat 的 1+N+M 的网络模型,参见 操作系统 IO模型 最后

策略 3: 异步化

异步在不同语境的含义:

  • 业务层面:同步接口 + 服务器后台任务 + 客户端轮询(或服务器通知)
  • 接口层面:异步 HTTP、RPC、MySQL、Redis 等接口(客户端调用时传入一个 callable 或者 future 对象)
  • Java JDK 层面:BIO、NIO、AIO
  • Linux 层面:同步阻塞 I/O、同步非阻塞 I/O、I/O 多路复用、AIO

其中接口的异步有两种实现方式:

  • 假异步:在接口内部做一个线程池,把异步接口调用转换为同步接口调用。
  • 真异步。在接口内部通过 NIO 实现真异步,不需要开很多线程。

案例 1: 短信验证码注册或登录

案例 2: 电商的订单系统(先下单,再支付,后续异步进行订单风控、优惠券、新老用户属性修改、一个订单多个包裹的拆单处理..)

案例 3: 广告计费系统(C 端用户每次浏览或点击后,扣除广告主的钱)

600|600

案例 4:LSM 树(写内存+Write-Ahead 日志)

LSM 树是什么?

为了提高磁盘 I/O 的写性能,可以使用 Write-Ahead 日志,也就是 Redo Log。其实除数据库的 B+树外,LSM 树也采用了同样的原理。LSM(Log Structured Merged Tree) 用到的一个核心思想就是“异步写”。LSM 树支撑的是 KV 存储,当插入的时候,K 是无序的;但是在磁盘上又需要按照 K 的大小顺序地存储,也就是说要在磁盘上实现一个 Sorted HashMap。按 K 的大小顺序存储是为了方便检索。但不可能在插入的同时对磁盘上的数据进行排序。

LSM 是怎么解决这个问题的呢?

首先,既然磁盘写入速度很慢,就不写从磁盘,而是在内存中维护一个 Sorted HashMap,这样写的性能就提高了;但数据都在磁盘里,如果系统宕机则数据就丢了,于是再写一条日志,也就是 Write-Ahead 日志。日志有一个关键的优点是顺序写入,即只会在日志尾部追加,而不会随机地写入。
有了日志的顺序写入,加上一个内存的 Sorted HashMap,再有一个后台任务定期地把内存中的 Sorted HashMap 合并到磁盘文件中。后台任务会执行磁盘数据的合并排序。所以可以发现这个思路和数据库的实现原理有异曲同工之妙。
当然,因为是 KV 存储,所以使用了 LSM 树,而没有用 B+树。关系型数据库之所以用 B+树,是因为关系型数据库除做等值查询外,还要支持两个关键的特性:范围查询,还有前缀模糊查询,也是转换成了范围查询;排序和分页。

案例 5: Kafka 的 Pipeline

Kafka 为了高可用性,会为每个 Topic 的每个 Partition 准备多个副本。Leader 并不会主动给两个 Follower 同步数据,而是等 Follower 主动拉取,并且是批量拉取。
当 Leader 收到客户端的消息 msg1 并把它存到本地文件后,就去做其他事情了!比如接收下一个消息 msg2,此时客户端还处于阻塞状态,等待 msg1 返回。
只有等两个 Follower 把消息 msg1 拖过去后,Leader 才会返回客户端说 msg1 接收成功了。
为什么叫作 PipeLine 呢?因为 Leader 并不是一个个地处理消息,而是一批批地处理。Leader 和 Follower1、Follower2 像是组成了一个管道,消息像水一样流过管道。
PipeLine 是异步化的一个典型例子,同时它也是策略 2 所讲的任务分片的典型例子。因为对于 Leader 来说,它把两个任务分离了,一个是接受和存储客户端消息的任务,一个是同步消息到两个 Follower 的任务,这两个任务并行了。

策略 4: 批量

案例 1: Kafka 的百万 QPS 写入

Kafka 快的原因:一个是 partition 分片,一个是磁盘的顺序写入,一个是批量。Kafka 的客户端在内存中为每个 Partition 准备了一个队列,称为 RecordAccumulator。Producer 线程一条条地发送消息,这些消息都进入内存队列。然后通过 Sender 线程从这些队列中批量地提取消息发送给 Kafka 集群。

600|600

对于具体的批量策略,Kafka 提供了几种参数进行配置,可以按 Batch 的大小或等待时间来批量操作。

案例 2: 广告计费系统的合并扣费
案例 3: MySQL 的小事务合并机制

比如扣库存,对同一个 SKU,本来是扣 10 次、每次扣 1 个,也就是 10 个事务;在 MySQL 内核里面合并成 1 次扣 10 个,也就是 10 个事务变成了 1 个事务。同样,在多机房的数据库多活(跨数据中心的数据库复制)场景中,事务合并也是加速数据库复制的一个重要策略。

策略 5: 串行化 + 多进程单线程 + 异步 I/O

在 Java 里面,为了提高并发度,经常喜欢使用多线程。但多线程有两大问题:锁竞争;线程切换开销大,导致线程数无法开很多

然后看 Nginx、Redis,它们都是单线程模型,因为有了异步 I/O 后,可以把请求串行化处理。第一,没有了锁的竞争;第二,没有了 I/O 的阻塞,这样单线程也非常高效。既然要利用多核优势,那就开多个实例。

再复杂一些,开多个进程,每个进程专职负责一个业务模块,进程之间通过各种 IPC 机制实现通信,这种方法在 C++中广泛使用。这种做法综合了任务分片、异步化、串行化三种思路。

容量规划

如果说高并发“读”和高并发“写”的策略是一种“定性分析”,那么接下来要介绍的压力测试和容量规划就是“定量分析”。

应对策略有了,系统模块也设计得差不多了,接下来就面临一个绕不开的问题:系统要部署多少台机器?具体来讲:应用服务器要部署多少台机器?数据库要分多少个库?如果采用简单的做法,可以凭借过去的经验决定要多少台机器;如果采用更专业的方法,则需要进行各种压力测试,再结合对业务的容量预估,计算出需要多少台机器。

吞吐量、响应时间与并发数

在正式展开分析之前,需要先介绍三个最常见的概念:吞吐量、响应时间与并发数。

  • 吞吐量:单位时间内处理的请求数。通常所说的 QPS、TPS,其实都是吞吐量的一种衡量方式。
  • 响应时间:处理每个请求所需的时间。
  • 并发数:服务器同时并行处理地请求个数。

(1)三个指标的数学关系。并发数目 = 吞吐量 x 响应时间。

(2)并发系统。响应时间与吞吐量(QPS)的关系。对于串行系统,吞吐量与响应时间成反比,这很容易理解:处理一个请求的时间越小,单位时间内能处理的请求数越多。但对于一个并发系统(多机多进程或者多线程),却不符合这个规律:我们往往看到的情况是 QPS 越大,响应时间也越长。

举一个现实生活中的例子:1 个理发店只有 1 个理发师,洗剪吹都是理发师 1 个人做。以前去理发,从洗到剪再到吹,中间没有间隔,他一个人全程服务;现在生意变好了,他雇了两个人,三个人分别负责洗、剪、吹三道工序。这次去了之后,先是第一个人帮你洗;洗完之后,再等一小会儿开始剪;剪完之后,再等一小会儿开始吹。从理发店的角度来说,他同时服务的人变多了,也就是吞吐量变大了;但从顾客的角度来看,服务时间也变长了(体验下降了)。这就是典型的吞吐量和响应时间同时变大的场景。

对于计算机系统来说,也有类似的原理。请求的处理被分成了多个环节(任务分片),每个环节又都是多线程(数据分片)的,请求与请求之间是并行处理的,多个环节之间也是并行的。在这种情况下,响应时间与吞吐量之间的关系不是一个简单的数学公式可以描述的,只能大致知道两者之间的变化曲线,如图 8-21 所示。

500|600

(3)指标的测算方法。

现在的监控系统已经很成熟,无论大公司自研的,还是开源的,都可以在监控面板上直接看到每台机器的每个接口的 QPS、平均响应时间、最大响应时间、95 线、99 线等指标。

关于 QPS、95 线、99 线具体是如何计算的,本书不做深究。有兴趣的读者可以参考监控系统相关的书籍和文章。
至于并发数,通常是一个“隐形指标”。通过吞吐量(QPS)和响应时间,大致可以推算出并发数是多少。

注意:这里有一个关键点需要说明:当谈论吞吐量(QPS)的时候,一定需要谈对应的响应时间是多少。随着 QPS 的增加,响应时间也在增加,虽然 QPS 提上来了,但用户端的响应时间却变长了,客户端的超时率增加,用户体验变差。所以这两者需要权衡,不能一味地提升 QPS,而不顾及响应时间。

压力测试与容量评估

1.容量评估的基本思路

容量评估是一个系统性工程,但其基本思路其实很简单:机器数目 = 预估总流量 / 单机容量。其中,分母是一个预估的值,分子通过压力测试得到。

如何预估呢?一般通过历史数据来估算。在监控系统中可以很容易地看到一个服务在过去 24 个小时中的调用量分布,取其中的峰值再乘以一个余量系数(比如 2 倍或 3 倍),就可以大概算出服务的预估流量。

这里特别要说明的是:需要用峰值测算,而不能用均值。对于很多系统来说,峰值通常是均值的好几倍。虽然峰值持续的时间很短,但没有办法,的确需要准备这么多台机器。所以,实际上有很多机器大部分时间都是闲置的,就是为了抵抗那短暂的峰值,又不能下掉。这也正是云计算(弹性计算)要解决的问题,通过动态地加机器、减机器,来减少资源浪费。

2.压力测试策略

压力测试方法并没有一个标准答案,通常需要因时、因地制宜。一些大公司都会有测试工程师制订详细的压力测试方案。这里大致介绍压力测试涉及的各种策略:

(1)线上压力测试对比测试环境压力测试。

对于压力测试,首先涉及的一个问题是在线上真实环境测试,还是测试环境中测试。如果是测试环境,即使机器宕机了也没有关系。
但测试环境有个最大的问题是搭建麻烦。尤其是当服务调用了很多其他团队的服务,里面又涉及缓存和数据库,要搭建一个与线上基本一样的测试环境,花费的精力非常巨大。并且即使搭建好了,功能要快速迭代,频繁地发新版本,也很难持续。
所以我们将重点讨论线上压力测试。

(2)读接口压力测试对比写接口压力测试。

如果完全是读接口,可以对线上流量进行重放,这没有问题。如果是写接口,则会对线上数据库造成大量测试数据,怎么解决呢?
一种是通过摘流量的方式,也就是不重放流量,只是把线上的真实流量划一部分出来集中导入集群中的几台机器中。需要说明的是:这种方法也只能压力测试应用服务器,对于 Redis 或数据库,只能大致估算。
另一种是在线上部署一个与真实数据库一样的“影子数据库”,对测试数据打标签,测试数据不进入线上数据库,而是进入这个“影子数据库”。通常会由数据库的中间件来实现,如果判断是测试数据,则进入“影子数据库”。

(3)单机压力测试对比全链路压力测试。

单机压力测试相对简单,比如一个服务没有调用其他的服务,背后就是 Redis 或数据库,通过压力测试比较容易客观地得出服务的容量。
但如果服务存在着层层调用,整个调用链路像树状一样展开,即使测算出了每一个单个服务的容量,也不能代表整个系统的容量,这时就需要全链路压力测试。
全链路压力测试涉及多个团队开发的服务,这就需要团队之间密切协作,制订完备的压力测试方案。