【笔记】《百亿级并发系统设计》和《高并发系统设计 40 问》
Jul 23, 2021笔记架构高并发《百亿级并发系统设计》和《高并发系统设计 40 问》
先附上资源地址:
- 《百亿级并发系统设计》:百度网盘链接 链接: https://pan.baidu.com/s/1ANSZkq-ngIbw9sxwGN60eA 提取码: 4hx5
- 《高并发系统设计 40 问》:https://time.geekbang.org/column/intro/230
由于两者内容差不多,所以可以直接通过网盘下载《百亿级并发系统设计》。
问题列表
先附上问题列表,可以先自我灵魂拷问一下:
- 高并发系统:它的通用设计方法是什么?
- 架构分层:我们为什么一定要这么做?
- 系统设计目标(一):如何提升系统性能?
- 系统设计目标(二):系统怎样做到高可用?
- 系统设计目标(三):如何让系统易于扩展?
- 池化技术:如何减少频繁创建数据库连接的性能损耗?
- 数据库优化方案(一):查询请求增加时,如何做主从分离?
- 数据库优化方案(二):写入数据量增加时,如何实现分库分表?
- 发号器:如何保证分库分表后 ID 的全局唯一性?
- NoSQL:在高并发场景下,数据库和 NoSQL 如何做到互补?
- 缓存:数据库成为瓶颈后,动态数据的查询要如何加速?
- 缓存的使用姿势(一):如何选择缓存的读写策略?
- 缓存的使用姿势(二):缓存如何做到高可用?
- 缓存的使用姿势(三):缓存穿透了怎么办?
- CDN:静态资源如何加速?
- 消息队列:秒杀时如何处理每秒上万次的下单请求?
- 消息投递:如何保证消息仅仅被消费一次?
- 消息队列:如何降低消息队列系统中消息的延迟?
- 系统架构:每秒 1 万次请求的系统要做服务化拆分吗?
- 微服务架构:微服务化后系统架构要如何改造?
- RPC 框架:10 万 QPS 下如何实现毫秒级的服务调用?
- 注册中心:分布式系统如何寻址?
- 分布式 Trace:横跨几十个分布式组件的慢请求要如何排查?
- 负载均衡:怎样提升系统的横向扩展能力?
- API 网关:系统的门面要如何做呢?
- 多机房部署:跨地域的分布式系统如何做?
- Service Mesh:如何屏蔽服务化系统的服务治理细节?
- 给系统加上眼睛:服务端监控要怎么做?
- 应用性能管理:用户的使用体验应该如何监控?
- 压力测试:怎样设计全链路压力测试平台?
- 配置管理:成千上万的配置项要如何管理?
- 降级熔断:如何屏蔽非核心系统故障的影响?
- 流量控制:高并发系统中我们如何操纵流量?
- 计数系统设计(一):面对海量数据的计数器要如何做?
- 计数系统设计(二):50 万 QPS 下如何设计未读数系统?
- 信息流设计(一):通用信息流系统的推模式要如何做?
- 信息流设计(二):通用信息流系统的拉模式要如何做?
一、归纳方案
Scale-out(横向扩展):分而治之是一种常见的高并发系统设计方法,采用分布式部署的方式把流量分流开,让每个服务器都承担一部分并发和流量。
缓存:使用缓存来提高系统的性能,就好比用“拓宽河道”的方式抵抗高并发大流量的冲击。
异步:在某些场景下,未处理完成之前,我们可以让请求先返回,在数据准备好之后再通知请求方,这样可以在单位时间内处理更多的请求。
高并发系统的演进应该是循序渐进,以解决系统中存在的问题为目的和驱动力的。
Scale-up 和 Scale-out
Scale-up:通过购买性能更好的硬件来提升系统的并发处理能力。
Scale-out:通过将多个低性能的机器组成一个分布式集群来共同抵御高并发流量的冲击。
一般在系统设计初期会考虑使用 Scale-up 的方式,因为这种方案足够简单,所谓能用堆砌硬件解决的问题就用硬件来解决,但是当系统并发超过了单机的极限时,我们就要使用 Scale-out 的方式。
缓存的意义
数据是放在持久化存储中的,一般的持久化存储都是使用磁盘作为存储介质的,普通磁盘的寻道时间是 10ms 左右,而相比于磁盘寻道花费的时间,CPU 执行指令和内存寻址的时间都在是 ns(纳秒)级别,从千兆网卡上读取数据的时间是在 μs(微秒)级别。所以在整个计算机体系中,磁盘是最慢的一环。
缓存的语义已经丰富了很多,我们可以将任何降低响应时间的中间存储都称为缓存。
二、分层架构
将整体系统拆分成 N 个层次,每个层次有独立的职责,多个层次协同提供完整的功能。
典型的分层设计:系统的 MVC 设计;OSI 网络模型,TCP/IP 模型;Linux 文件系统等。以下是一个典型等系统分层设计规约:
分层的作用
- 1.分层的设计可以简化系统设计,让不同的人专注做某一层次的事情;
- 2.分层之后可以做到很高的复用;
- 3.分层架构可以让我们更容易做横向扩展。
分层的问题
- 1.增加了代码的复杂度;
- 2.多层架构的性能损耗
高并发
高并发系统设计的三大目标:高性能、高可用、可扩展。
是指运用设计手段让系统能够处理更多的用户并发请求,也就是承担更大的流量。而性能和可用性,是我们实现高并发系统设计必须考虑的因素。
性能优化原则
- 1.不能盲目,一定是问题导向的;
- 2.遵循“八二原则”;
- 3.也要有数据支撑;
- 4.过程是持续的。
性能的度量指标
一般度量性能的指标是系统接口的响应时间,但是单次的响应时间是没有意义的,你需要知道一段时间的性能情况是什么样的。所以,我们需要收集这段时间的响应时间数据,然后依据一些统计方法计算出特征值,这些特征值就能够代表这段时间的性能情况。我们常见的特征值有以下几类。
- 平均值:平均值可以在一定程度上反应这段时间的性能,但它敏感度比较差,如果这段时间有少量慢请求时,在平均值上并不能如实的反应。
- 最大值;
- 分位值:最适合作为时间段内,响应时间统计值来使用的。
从用户使用体验的角度来看,200ms 是第一个分界点:接口的响应时间在 200ms 之内,用户是感觉不到延迟的,就像是瞬时发生的一样。而 1s 是另外一个分界点:接口的响应时间在 1s 之内时,虽然用户可以感受到一些延迟,但却是可以接受的,超过 1s 之后用户就会有明显等待的感觉,等待时间越长,用户的使用体验就越差。
健康系统的 99 分位值的响应时间通常需要控制在 200ms 之内,而不超过 1s 的请求占比要在 99.99% 以上。
高并发下的性能优化
主要有两种思路:
- 1.提高系统的处理核心数。增加系统的并行处理能力;
阿姆达尔定律(Amdahl’s law)是吉恩·阿姆达尔在 1967 年提出的。它描述了并发进程数与响应时间之间的关系,含义是在固定负载下,并行计算的加速比,也就是并行化之后效率提升情况。可以用下面公式来表示:
1 | (Ws + Wp) / (Ws + Wp/s) |
其中,Ws 表示任务中的串行计算量,Wp 表示任务中的并行计算量,s 表示并行进程数。从这个公式我们可以推导出另外一个公式:
1 | 1/(1-p+p/s) |
其中,s 还是表示并行进程数,p 表示任务中并行部分的占比。当 p 为 1 时,也就是完全并行时,加速比与并行进程数相等;当 p 为 0 时,即完全串行时,加速比为 1,也就是说完全无加速;当 s 趋近于无穷大的时候,加速比就等于 1/(1-p),你可以看到它完全和 p 成正比。特别是,当 p 为 1 时,加速比趋近于无穷大。
但是实际上随着并发进程数的增加,并行的任务对于系统资源的争抢也会愈发严重。在某一个临界点上继续增加并发进程数,反而会造成系统性能的下降,这就是性能测试中的拐点模型。
所以我们在评估系统性能时通常需要做压力测试,目的就是找到系统的“拐点”,从而知道系统的承载能力,也便于找到系统的瓶颈,持续优化系统性能。
- 2.减少单次任务的响应时间。
想要减少任务的响应时间,首先要看你的系统是 CPU 密集型还是 IO 密集型的,因为不同类型的系统性能优化方式不尽相同。
CPU 密集型系统中,需要处理大量的 CPU 运算,那么选用更高效的算法或者减少运算次数就是这类系统重要的优化手段。比方说,如果系统的主要任务是计算 Hash 值,那么这时选用更高性能的 Hash 算法就可以大大提升系统的性能。发现这类问题的主要方式,是通过一些 Profile 工具来找到消耗 CPU 时间最多的方法或者模块,比如 Linux 的 perf、eBPF 等。
IO 密集型系统指的是系统的大部分操作是在等待 IO 完成,这里 IO 指的是磁盘 IO 和网络 IO。我们熟知的系统大部分都属于 IO 密集型,比如数据库系统、缓存系统、Web 系统。这类系统的性能瓶颈可能出在系统内部,也可能是依赖的其他系统,而发现这类性能瓶颈的手段主要有两类。 - 第一类是采用工具,Linux 的工具集很丰富,完全可以满足你的优化需要,比如网络协议栈、网卡、磁盘、文件系统、内存,等等。这些工具的用法很多,你可以在排查问题的过程中逐渐积累。除此之外呢,一些开发语言还有针对语言特性的分析工具,比如说 Java 语言就有其专属的内存分析工具。 - 另外一类手段就是可以通过监控来发现性能问题。在监控中我们可以对任务的每一个步骤做分时的统计,从而找到任务的哪一步消耗了更多的时间。这一部分在演进篇中会有专门的介绍,这里就不再展开了。
高可用(High Availability,HA)
可用性则表示系统可以正常服务用户的时间。
可用性的度量
MTBF(Mean Time Between Failure)是平均故障间隔的意思,代表两次故障的间隔时间,也就是系统正常运转的平均时间。这个时间越长,系统稳定性越高。
MTTR(Mean Time To Repair)表示故障的平均恢复时间,也可以理解为平均故障时间。这个值越小,故障对于用户的影响越小。
它们之间的关系:
1 | Availability = MTBF / (MTBF + MTTR) |
“四个九”
系统可用性 | 年故障时间 | 日故障时间
——– | ——— | ——–
90%(一个九) | 36.5 天 | 2.4 小时
99%(二个九) | 3.65 天 | 14.4 分
99.9%(三个九) | 8 小时 | 1.44 分
99.99%(四个九) | 52 分钟 | 8.6 秒
99.999%(五个九) | 5 分钟 | 0.86 秒
99.9999%(六个九) | 32 秒 | 86 毫秒
高可用系统设计的思路
一个成熟系统的可用性需要从系统设计和系统运维两方面来做保障,两者共同作用,缺一不可。
1. 系统设计
“Design for failure”是我们做高可用系统设计时秉持的第一原则。我们在做系统设计的时候,要把发生故障作为一个重要的考虑点,预先考虑如何自动化地发现故障,发生故障之后要如何解决。当然了,我们还需要掌握一些具体的优化方法,比如 failover(故障转移)、超时控制以及降级和限流。
使用最广泛的故障检测机制是 “心跳”。你可以在客户端上定期地向主节点发送心跳包,也可以从备份节点上定期发送心跳包。当一段时间内未收到心跳包,就可以认为主节点已经发生故障,可以触发选主的操作。
除了故障转移以外,对于系统间调用超时的控制也是高可用系统设计的一个重要考虑方面。
复杂的高并发系统通常会有很多的系统模块组成,同时也会依赖很多的组件和服务,比如说缓存组件,队列服务等等。它们之间的调用最怕的就是延迟而非失败,因为失败通常是瞬时的,可以通过重试的方式解决。而一旦调用某一个模块或者服务发生比较大的延迟,调用方就会阻塞在这次调用上,它已经占用的资源得不到释放。当存在大量这种阻塞请求时,调用方就会因为用尽资源而挂掉。
超时时间短了,会造成大量的超时错误,对用户体验产生影响;超时时间长了,又起不到作用。我建议你通过收集系统之间的调用日志,统计比如说 99% 的响应时间是怎样的,然后依据这个时间来指定超时时间。如果没有调用的日志,那么你只能按照经验值来指定超时时间。不过,无论你使用哪种方式,超时时间都不是一成不变的,需要在后面的系统维护过程中不断地修改。
降级是为了保证核心服务的稳定而牺牲非核心服务的做法。
限流完全是另外一种思路,它通过对并发的请求进行限速来保护系统。
2. 系统运维
其实,我们可以从灰度发布、故障演练两个方面来考虑如何提升系统的可用性。
可拓展
流量分为平时流量和峰值流量两种,峰值流量可能会是平时流量的几倍甚至几十倍,在应对峰值流量的时候,我们通常需要在架构和方案上做更多的准备。而易于扩展的系统能在短时间内迅速完成扩容,更加平稳地承担峰值流量。
无状态的服务和组件更易于扩展,而像 MySQL 这种存储服务是有状态的,就比较难以扩展。因为向存储集群中增加或者减少机器时,会涉及大量数据的迁移,而一般传统的关系型数据库都不支持。这就是为什么提升系统扩展性会很复杂的主要原因。
我们需要站在整体架构的角度,而不仅仅是业务服务器的角度来考虑系统的扩展性 。所以说,数据库、缓存、依赖的第三方、负载均衡、交换机带宽等等都是系统扩展时需要考虑的因素。我们要知道系统并发到了某一个量级之后,哪一个因素会成为我们的瓶颈点,从而针对性地进行扩展。
高可扩展性的设计思路
拆分是提升系统扩展性最重要的一个思路,它会把庞杂的系统拆分成独立的,有单一职责的模块。相对于大系统来说,考虑一个一个小模块的扩展性当然会简单一些。将复杂的问题简单化,这就是我们的思路。
1.存储层的扩展性
无论是存储的数据量,还是并发访问量,不同的业务模块之间的量级相差很大,比如说成熟社区中,关系的数据量是远远大于用户数据量的,但是用户数据的访问量却远比关系数据要大。所以假如存储目前的瓶颈点是容量,那么我们只需要针对关系模块的数据做拆分就好了,而不需要拆分用户模块的数据。所以存储拆分首先考虑的维度是业务维度。
拆分之后,这个简单的社区系统就有了用户库、内容库、评论库、点赞库和关系库。这么做还能隔离故障,某一个库“挂了”不会影响到其它的数据库。
按照业务拆分,在一定程度上提升了系统的扩展性,但系统运行时间长了之后,单一的业务数据库在容量和并发请求量上仍然会超过单机的限制。这时,我们就需要针对数据库做第二次拆分。
这次拆分是按照数据特征做水平的拆分,比如说我们可以给用户库增加两个节点,然后按照某些算法将用户的数据拆分到这三个库里面。水平拆分之后,我们就可以让数据库突破单机的限制了。当数据库按照业务和数据维度拆分之后,我们尽量不要使用事务。因为当一个事务中同时更新不同的数据库时,需要使用二阶段提交,来协调所有数据库要么全部更新成功,要么全部更新失败。这个协调的成本会随着资源的扩展不断升高,最终达到无法承受的程度。
2. 业务层的扩展性
一般会从三个维度考虑业务层的拆分方案,它们分别是:业务维度,重要性维度和请求来源维度。
每个业务依赖独自的数据库资源,不会依赖其它业务的数据库资源。这样当某一个业务的接口成为瓶颈时,我们只需要扩展业务的池子,以及确认上下游的依赖方就可以了,这样就大大减少了扩容的复杂度。
除此之外,我们还可以根据业务接口的重要程度,把业务分为核心池和非核心池。打个比方,就关系池而言,关注、取消关注接口相对重要一些,可以放在核心池里面;拉黑和取消拉黑的操作就相对不那么重要,可以放在非核心池里面。这样,我们可以优先保证核心池的性能,当整体流量上升时优先扩容核心池,降级部分非核心池的接口,从而保证整体系统的稳定性。
最后,你还可以根据接入客户端类型的不同做业务池的拆分。比如说,服务于客户端接口的业务可以定义为外网池,服务于小程序或者 HTML5 页面的业务可以定义为 H5 池,服务于内部其它部门的业务可以定义为内网池,等等。
数据库优化
连接
频繁创建连接会造成响应时间慢。
- 1.TCP 三次握手
- 2.数据库服务端校验客户端密码的过程
解决方案也很简单,只要使用连接池将数据库连接预先建立好,这样在使用的时候就不需要频繁地创建连接了。
用连接池预先建立数据库连接
其实,在开发过程中我们会用到很多的连接池,像是数据库连接池、HTTP 连接池、Redis 连接池等等。而连接池的管理是连接池设计的核心。
数据库连接池有两个最重要的配置:最小连接数和最大连接数,它们控制着从连接池中获取连接的流程:
- 如果当前连接数小于最小连接数,则创建新的连接处理数据库请求;
- 如果连接池中有空闲连接则复用空闲连接;
- 如果空闲池中没有连接并且当前连接数小于最大连接数,则创建新的连接处理请求;
- 如果当前连接数已经大于等于最大连接数,则按照配置中设定的时间(C3P0 的连接池配置是 checkoutTimeout)等待旧的连接可用;
- 如果等待超过了这个设定时间则向用户抛出错误。
对于数据库连接池,一般建议最小连接数控制在 10 左右,最大连接数控制在 20 ~ 30 左右即可。
用线程池预先创建线程
池化技术
这两种技术,会发现它们都有一个共同点:它们所管理的对象,无论是连接还是线程,它们的创建过程都比较耗时,也比较消耗系统资源。所以,我们把它们放在一个池子里统一管理起来,以达到提升性能和资源复用的目的。
这是一种常见的软件设计思想,叫做池化技术,它的核心思想是空间换时间,期望使用预先创建好的对象来减少频繁创建对象的性能开销,同时还可以对对象进行统一的管理,降低了对象的使用的成本。
不过,池化技术也存在一些缺陷,比方说存储池子中的对象肯定需要消耗多余的内存,如果对象没有被频繁使用,就会造成内存上的浪费。再比方说,池子中的对象需要在系统启动的时候就预先创建完成,这在一定程度上增加了系统启动时间。
池化后的系统架构如下:
主从分离
它其实是个流量分离的问题,大部分系统的访问模型是读多写少,读写请求量的差距可能达到几个数量级。刷朋友圈的请求量肯定比发朋友圈的量大,淘宝上一个商品的浏览量也肯定远大于它的下单量。因此,我们优先考虑数据库如何抗住更高的查询请求,那么首先你需要把读写流量区分开,因为这样才方便针对读流量做单独的扩展,这就是我们所说的主从读写分离。
在一个大的项目中,它也是一个应对数据库突发读流量的有效方法。
一般来说在主从读写分离机制中,我们将一个数据库的数据拷贝为一份或者多份,并且写入到其它的数据库服务器中,原始的数据库我们称为主库,主要负责数据的写入,拷贝的目标数据库称为从库,主要负责支持数据查询。可以看到,主从读写分离有两个技术上的关键点:
- 一个是数据的拷贝,我们称为主从复制;
- 在主从分离的情况下,我们如何屏蔽主从分离带来的访问数据库方式的变化,让开发同学像是在使用单一数据库一样。
1. 主从复制
主从复制的过程是这样的:首先从库在连接到主节点时会创建一个 IO 线程,用以请求主库更新的 binlog,并且把接收到的 binlog 信息写入一个叫做 relay log 的日志文件中,而主库也会创建一个 log dump 线程来发送 binlog 给从库;同时,从库还会创建一个 SQL 线程读取 relay log 中的内容,并且在从库中做回放,最终实现主从的一致性。这是一种比较常见的主从复制方式。
做了主从复制之后,我们就可以在写入时只写主库,在读数据时只读从库,这样即使写请求会锁表或者锁记录,也不会影响到读请求的执行。是不是无限制地增加从库的数量就可以抵抗大量的并发呢?实际上并不是的。因为随着从库数量增加,从库连接上来的 IO 线程比较多,主库也需要创建同样多的 log dump 线程来处理复制的请求,对于主库资源消耗比较高,同时受限于主库的网络带宽,所以在实际使用中,一般一个主库最多挂 3 ~ 5 个从库。
从复制也有一些缺陷,除了带来了部署上的复杂度,还有就是会带来一定的主从同步的延迟,这种延迟有时候会对业务产生一定的影响。
比如在发微博的过程中会有些同步的操作,像是更新数据库的操作,也有一些异步的操作,比如说将微博的信息同步给审核系统,所以我们在更新完主库之后,会将微博的 ID 写入消息队列,再由队列处理机依据 ID 在从库中获取微博信息再发送给审核系统。此时如果主从数据库存在延迟,会导致在从库中获取不到微博信息,整个流程会出现异常。解决方案:
第一种方案是数据的冗余。你可以在发送消息队列时不仅仅发送微博 ID,而是发送队列处理机需要的所有微博信息,借此避免从数据库中重新查询数据。
第二种方案是使用缓存。我可以在同步写数据库的同时,也把微博的数据写入到 Memcached 缓存里面,这样队列处理机在获取微博信息的时候会优先查询缓存,这样也可以保证数据的一致性。
最后一种方案是查询主库。我可以在队列处理机中不查询从库而改为查询主库。不过,这种方式使用起来要慎重,要明确查询的量级不会很大,是在主库的可承受范围之内,否则会对主库造成比较大的压力。
2. 如何访问数据库
为了降低实现的复杂度,业界涌现了很多数据库中间件来解决数据库的访问问题,这些中间件可以分为两类。
- 第一类以淘宝的 TDDL( Taobao Distributed Data Layer)为代表,以代码形式内嵌运行在应用程序内部。
- 另一类是单独部署的代理层方案,这一类方案代表比较多,如早期阿里巴巴开源的 Cobar,基于 Cobar 开发出来的 Mycat,360 开源的 Atlas,美团开源的基于 Atlas 开发的 DBProxy 等等。这一类中间件部署在独立的服务器上,业务代码如同在使用单一数据库一样使用它。
分离后的系统架构如下:
分库分表
数据库的写入请求量大造成的性能和可用性方面的问题,要解决这些问题,你所采取的措施就是对数据进行分片,对数据进行分片,可以很好地分摊数据库的读写压力,也可以突破单机的存储瓶颈,而常见的一种方式是对数据库做 “分库分表”。
分库分表是一种常见的将数据分片的方式,它的基本思想是依照某一种策略将数据尽量平均的分配到多个数据库节点或者多个表中。
不同于主从复制时数据是全量地被拷贝到多个节点,分库分表后,每个节点只保存部分的数据,这样可以有效地减少单个数据库节点和单个数据表中存储的数据量,在解决了数据存储瓶颈的同时也能有效的提升数据查询的性能。同时,因为数据被分配到多个数据库节点上,那么数据的写入请求也从请求单一主库变成了请求多个数据分片节点,在一定程度上也会提升并发写入的性能。
数据库分库分表的方式有两种:
- 一种是垂直拆分,就是对数据库竖着拆分,也就是将数据库的表拆分到多个不同的数据库中。原则一般是按照业务类型来拆分,核心思想是专库专用,将业务耦合度比较高的表拆分到单独的库中。
- 另一种是水平拆分。垂直拆分的关注点在于业务相关性,而水平拆分指的是将单一数据表按照某一种规则拆分到多个数据库和多个数据表中,关注点在数据的特点。拆分的规则有下面这两种:
- 按照某一个字段的哈希值做拆分,这种拆分规则比较适用于实体表,比如说用户表,内容表,我们一般按照这些实体表的 ID 字段来拆分。
- 另一种比较常用的是按照某一个字段的区间来拆分,比较常用的是时间字段。
数据库在分库分表之后,数据的访问方式也有了极大的改变,原先只需要根据查询条件到从库中查询数据即可,现在则需要先确认数据在哪一个库表中,再到那个库表中查询数据。这种复杂度也可以通过数据库中间件来解决。
分库分表引入的一个最大的问题就是引入了分库分表键,也叫做分区键,也就是我们对数据库做分库分表所依据的字段。从分库分表规则中你可以看到,无论是哈希拆分还是区间段的拆分,我们首先都需要选取一个数据库字段,这带来一个问题是:我们之后所有的查询都需要带上这个字段,才能找到数据所在的库和表,否则就只能向所有的数据库和数据表发送查询命令。如果像上面说的要拆分成 16 个库和 64 张表,那么一次数据的查询会变成 16*64=1024 次查询,查询的性能肯定是极差的。
针对这个问题,我们也会有一些相应的解决思路。比如,在用户库中我们使用 ID 作为分区键,这时如果需要按照昵称来查询用户时,你可以按照昵称作为分区键再做一次拆分,但是这样会极大的增加存储成本,如果以后我们还需要按照注册时间来查询时要怎么办呢,再做一次拆分吗?
所以最合适的思路是你要建立一个昵称和 ID 的映射表,在查询的时候要先通过昵称查询到 ID,再通过 ID 查询完整的数据,这个表也可以是分库分表的,也需要占用一定的存储空间,但是因为表中只有两个字段,所以相比重新做一次拆分还是会节省不少的空间的。
分库分表引入的另外一个问题是一些数据库的特性在实现时可能变得很困难。
如何保证铸剑的唯一性
- 使用业务字段作为主键,比如说对于用户表来说,可以使用手机号,email 或者身份证号作为主键。
- 使用生成的唯一 ID 作为主键。
分库分表后的系统架构如下:
NoSQL
NoSQL 有着天生分布式的能力,能够提供优秀的读写性能,可以很好地补充传统关系型数据库的短板。
NoSQL 指的是不同于传统的关系型数据库的其他数据库系统的统称,它不使用 SQL 作为查询语言,提供优秀的横向扩展能力和读写性能。
Redis、LevelDB 这样的 KV 存储。这类存储相比于传统的数据库的优势是极高的读写性能,一般对性能有比较高的要求的场景会使用。
Hbase、Cassandra 这样的列式存储数据库。这种数据库的特点是数据不像传统数据库以行为单位来存储,而是以列来存储,适用于一些离线数据统计的场景。
像 MongoDB、CouchDB 这样的文档型数据库。这种数据库的特点是 Schema Free(模式自由),数据表中的字段可以任意扩展,比如说电商系统中的商品有非常多的字段,并且不同品类的商品的字段也都不尽相同,使用关系型数据库就需要不断增加字段支持,而用文档型数据库就简单很多了。
优点:
- 弥补了传统数据库在性能方面的不足;
- 数据库变更方便,不需要更改原先的数据结构;
- 适合互联网项目常见的大数据量的场景;
使用 NoSQL 提升写入性能
很多 NoSQL 数据库都在使用的基于 LSM(Log-Structured Merge Tree) 树的存储引擎,牺牲了一定的读性能来换取写入数据的高性能,Hbase、Cassandra、LevelDB 都是用这种算法作为存储的引擎。
和 LSM 树类似的算法有很多,比如说 TokuDB 使用的名为 Fractal tree 的索引结构,它们的核心思想就是将随机 IO 变成顺序的 IO,从而提升写入的性能。
你可能会觉得,NoSQL 已经成熟到可以代替关系型数据库了,但是就目前来看,NoSQL 只能作为传统关系型数据库的补充而存在,弥补关系型数据库在性能、扩展性和某些场景下的不足,所以你在使用或者选择时要结合自身的场景灵活地运用。
缓存
从整体上看,数据库分了主库和从库,数据也被切分到多个数据库节点上。但随着并发的增加,存储数据量的增多,数据库的磁盘 IO 逐渐成了系统的瓶颈,我们需要一种访问更快的组件来降低请求响应时间,提升整体系统性能。这时我们就会使用缓存。
缓存,是一种存储数据的组件,它的作用是让对数据的请求更快地返回。
我们经常会把缓存放在内存中来存储,在某些场景下我们可能还会使用 SSD 作为冷数据的缓存。实际上,凡是位于速度相差较大的两种硬件之间,用于协调两者数据传输速度差异的结构,均可称之为缓存。
缓冲区则是一块临时存储数据的区域,这些数据后面会被传输到其他设备上。比如,我们将数据写入磁盘时并不是直接刷盘,而是写到一块缓冲区里面,内核会标识这个缓冲区为脏。当经过一定时间或者脏缓冲区比例到达一定阈值时,由单独的线程把脏块刷新到硬盘上。这样避免了每次写数据都要刷盘带来的性能问题。
缓存分类
常见的缓存主要就是静态缓存、分布式缓存和热点本地缓存这三种。
- 静态缓存:处理静态请求,它一般通过生成 Velocity 模板或者静态 HTML 文件来实现静态缓存,在 Nginx 上部署静态缓存可以减少对于后台应用服务器的压力。
- 分布式缓存:处理动态请求,Memcached、Redis 就是分布式缓存的典型例子。通过一些分布式的方案组成集群可以突破单机的限制。
- 本地缓存:处理极端的热点数据查询,主要部署在应用服务器的代码中,用于阻挡热点查询对于分布式缓存节点或者数据库的压力。如 HashMap,Guava Cache 或者是 Ehcache 等。
缓存缺点
- 缓存比较适合于读多写少的业务场景,并且数据最好带有一定的热点属性;
- 缓存会给整体系统带来复杂度,并且会有数据不一致的风险。
- 之前提到缓存通常使用内存作为存储介质,但是内存并不是无限的。
- 缓存会给运维也带来一定的成本
缓存读写策略
Cache Aside(旁路缓存)策略
最常用的策略,在更新数据时不更新缓存,而是删除缓存中的数据,在读取数据时,发现缓存中没了数据之后,再从数据库中读取数据,更新到缓存中。
读策略的步骤是:
- 从缓存中读取数据;
- 如果缓存命中,则直接返回数据;
- 如果缓存不命中,则从数据库中查询数据;
- 查询到数据后,将数据写入到缓存中,并且返回给用户。
写策略的步骤是:
- 更新数据库中的记录;
- 删除缓存记录。
Read/Write Through(读穿 / 写穿)策略
先查询要写入的数据在缓存中是否已经存在,如果已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,如果缓存中数据不存在,我们把这种情况叫做“Write Miss(写失效)”。
这个策略的核心原则是用户只与缓存打交道,由缓存和数据库通信,写入或者读取数据。
Write Back(写回)策略
这个策略的核心思想是在写入数据时只写入缓存,并且把缓存块儿标记为“脏”的。而脏块儿只有被再次使用时才会将其中的数据写入到后端存储中。
这个策略是计算机体系结构中的策略,不过写入策略中的只写缓存,异步写入后端存储的策略倒是有很多的应用场景。
Read/Write Through 和 Write Back 策略需要缓存组件的支持,所以比较适合你在实现本地缓存组件的时候使用。
落地建议是:你在向低速设备写入数据的时候,可以在内存里先暂存一段时间的数据,甚至做一些统计汇总,然后定时地刷新到低速设备上。比如说,你在统计你的接口响应时间的时候,需要将每次请求的响应时间打印到日志中,然后监控系统收集日志后再做统计。但是如果每次请求都打印日志无疑会增加磁盘 I/O,那么不如把一段时间的响应时间暂存起来,经过简单的统计平均耗时,每个耗时区间的请求数量等等,然后定时地,批量地打印到日志中。
缓存命中率
1 | 缓存命中率 = 命中缓存的请求数 / 总请求数 |
一般来说,在你的电商系统中,核心缓存的命中率需要维持在 99% 甚至是 99.9%,哪怕下降 1%,系统都会遭受毁灭性的打击。
客户端方案-缓存数据分片
单一的缓存节点受到机器内存、网卡带宽和单节点请求量的限制,不能承担比较高的并发,因此我们考虑将数据分片,依照分片算法将数据打散到多个不同的节点上,每个节点上存储部分数据。
分片算法常见的就是 Hash 分片算法和一致性 Hash 分片算法两种。
Hash 分片的算法就是对缓存的 Key 做哈希计算,然后对总的缓存节点个数取余。你可以这么理解:
比如说,我们部署了三个缓存节点组成一个缓存的集群,当有新的数据要写入时,我们先对这个缓存的 Key 做比如 crc32 等 Hash 算法生成 Hash 值,然后对 Hash 值模 3,得出的结果就是要存入缓存节点的序号。
用一致性 Hash 算法可以很好地解决增加和删减节点时,命中率下降的问题。在这个算法中,我们将整个 Hash 值空间组织成一个虚拟的圆环,然后将缓存节点的 IP 地址或者主机名做 Hash 取值后,放置在这个圆环上。当我们需要确定某一个 Key 需要存取到哪个节点上的时候,先对这个 Key 做同样的 Hash 取值,确定在环上的位置,然后按照顺时针方向在环上“行走”,遇到的第一个缓存节点就是要访问的节点。比方说下面这张图里面,Key 1 和 Key 2 会落入到 Node 1 中,Key 3、Key 4 会落入到 Node 2 中,Key 5 落入到 Node 3 中,Key 6 落入到 Node 4 中。
这时如果在 Node 1 和 Node 2 之间增加一个 Node 5,你可以看到原本命中 Node 2 的 Key 3 现在命中到 Node 5,而其它的 Key 都没有变化;同样的道理,如果我们把 Node 3 从集群中移除,那么只会影响到 Key 5 。所以你看,在增加和删除节点时,只有少量的 Key 会“漂移”到其它节点上,而大部分的 Key 命中的节点还是会保持不变,从而可以保证命中率不会大幅下降。
不过,事物总有两面性。虽然这个算法对命中率的影响比较小,但它还是存在问题:
缓存节点在圆环上分布不平均,会造成部分缓存节点的压力较大;当某个节点故障时,这个节点所要承担的所有访问都会被顺移到另一个节点上,会对后面这个节点造成压力。
一致性 Hash 算法的脏数据问题。
虚拟节点:它将一个缓存节点计算多个 Hash 值分散到圆环的不同位置,这样既实现了数据的平均,而且当某一个节点故障或者退出的时候,它原先承担的 Key 将以更加平均的方式分配到其他节点上,从而避免雪崩的发生。
其次,就是一致性 Hash 算法的脏数据问题。为什么会产生脏数据呢?比方说,在集群中有两个节点 A 和 B,客户端初始写入一个 Key 为 k,值为 3 的缓存数据到 Cache A 中。这时如果要更新 k 的值为 4,但是缓存 A 恰好和客户端连接出现了问题,那这次写入请求会写入到 Cache B 中。接下来缓存 A 和客户端的连接恢复,当客户端要获取 k 的值时,就会获取到存在 Cache A 中的脏数据 3,而不是 Cache B 中的 4。
所以,在使用一致性 Hash 算法时一定要设置缓存的过期时间,这样当发生漂移时,之前存储的脏数据可能已经过期,就可以减少存在脏数据的几率。
很显然,数据分片最大的优势就是缓解缓存节点的存储和访问压力,但同时它 也让缓存的使用 更加复杂。在 MultiGet(批量获取)场景下,单个节点的访问量并没有减少,同时节点数太多会造成缓存访问的 SLA(即“服务等级协议”,SLA 代表了网站服务可用性)得不到很好的保证,因为根据木桶原则,SLA 取决于最慢、最坏的节点的情况,节点数过多也会增加出问题的概率,因此我推荐 4 到 6 个节点为佳。
客户端方案—Memcached 的主从机制
Redis 本身支持主从的部署方式,但是 Memcached 并不支持。为每一组 Master 配置一组 Slave,更新数据时主从同步更新。读取时,优先从 Slave 中读数据,如果读取不到数据就穿透到 Master 读取,并且将数据回种到 Slave 中以保持 Slave 数据的热度。
主从机制最大的优点就是当某一个 Slave 宕机时,还会有 Master 作为兜底,不会有大量请求穿透到数据库的情况发生,提升了缓存系统的高可用性。
客户端方案—多副本
主从方式已经能够解决大部分场景的问题,但是对于极端流量的场景下,一组 Slave 通常来说并不能完全承担所有流量,Slave 网卡带宽可能成为瓶颈。
为了解决这个问题,我们考虑在 Master/Slave 之前增加一层副本层
在这个方案中,当客户端发起查询请求时,请求首先会先从多个副本组中选取一个副本组发起查询,如果查询失败,就继续查询 Master/Slave,并且将查询的结果回种到所有副本组中,避免副本组中脏数据的存在。
中间代理层方案
客户端方案已经能解决大部分的问题,但是只能在单一语言系统之间复用。中间代理层的方案就可以解决这个问题。你可以将客户端解决方案的经验移植到代理层中,通过通用的协议(如 Redis 协议)来实现在其他语言中的复用。
如果你来自研缓存代理层,你就可以将客户端方案中的高可用逻辑封装在代理层代码里面,这样用户在使用你的代理层的时候就不需要关心缓存的高可用是如何做的,只需要依赖你的代理层就好了。
除此以外,业界也有很多中间代理层方案,比如 Facebook 的 Mcrouter,Twitter 的 Twemproxy,豌豆荚的 Codis。
服务端方案
Redis 在 2.4 版本中提出了 Redis Sentinel 模式来解决主从 Redis 部署时的高可用问题,它可以在主节点挂了以后自动将从节点提升为主节点,保证整体集群的可用性。
Redis Sentinel 不属于代理层模式,因为对于缓存的写入和读取请求不会经过 Sentinel 节点。Sentinel 节点在架构上和主从是平级的,是作为管理者存在的。
缓存穿透
缓存穿透其实是指从缓存中没有查到数据,而不得不从后端系统(比如数据库)中查询的情况。
回种空值
回顾上面提到的场景,你会发现最大的问题在于数据库中并不存在用户的数据,这就造成无论查询多少次,数据库中永远都不会存在这个用户的数据,穿透永远都会发生。
类似的场景还有一些:比如由于代码的 bug 导致查询数据库的时候抛出了异常,这样可以认为从数据库查询出来的数据为空,同样不会回种缓存。
那么,当我们从数据库中查询到空值或者发生异常时,我们可以向缓存中回种一个空值。但是因为空值并不是准确的业务数据,并且会占用缓存的空间,所以我们会给这个空值加一个比较短的过期时间,让空值在短时间之内能够快速过期淘汰。
回种空值虽然能够阻挡大量穿透的请求,但如果有大量获取未注册用户信息的请求,缓存内就会有有大量的空值缓存,也就会浪费缓存的存储空间,如果缓存空间被占满了,还会剔除掉一些已经被缓存的用户信息反而会造成缓存命中率的下降。
所以这个方案,我建议你在使用的时候应该评估一下缓存容量是否能够支撑。
使用布隆过滤器
布隆过滤器的算法,用来判断一个元素是否在一个集合中。这种算法由一个二进制数组和一个 Hash 算法组成。它的基本思路如下:
我们把集合中的每一个值按照提供的 Hash 算法算出对应的 Hash 值,然后将 Hash 值对数组长度取模后得到需要计入数组的索引值,并且将数组这个位置的值从 0 改成 1。在判断一个元素是否存在于这个集合中时,你只需要将这个元素按照相同的算法计算出索引值,如果这个位置的值为 1 就认为这个元素在集合中,否则则认为不在集合中。
布隆过滤器拥有极高的性能,无论是写入操作还是读取操作,时间复杂度都是 O(1),是常量值。在空间上,相对于其他数据结构它也有很大的优势,比如,20 亿的数组需要 2000000000/8/1024/1024 = 238M 的空间,而如果使用数组来存储,假设每个用户 ID 占用 4 个字节的空间,那么存储 20 亿用户需要 2000000000 * 4 / 1024 / 1024 = 7600M 的空间,是布隆过滤器的 32 倍。
布隆过滤器主要有两个缺陷:
- 它在判断元素是否在集合中时是有一定错误几率的,比如它会把不是集合中的元素判断为处在集合中;
- 不支持删除元素。
回种空值和布隆过滤器是解决缓存穿透问题的两种最主要的解决方案,但是它们也有各自的适用场景,并不能解决所有问题。比方说当有一个极热点的缓存项,它一旦失效会有大量请求穿透到数据库,这会对数据库造成瞬时极大的压力,这个场景叫做 “dog-pile effect”(狗桩效应)
解决狗桩效应的思路是尽量地减少缓存穿透后的并发,方案也比较简单:
- 在代码中,控制在某一个热点缓存项失效之后启动一个后台线程,穿透到数据库,将数据加载到缓存中,在缓存未加载之前,所有访问这个缓存的请求都不再穿透而直接返回。
- 通过在 Memcached 或者 Redis 中设置分布式锁,只有获取到锁的请求才能够穿透到数据库。
分布式锁的方式也比较简单,比方说 ID 为 1 的用户是一个热点用户,当他的用户信息缓存失效后,我们需要从数据库中重新加载数据时,先向 Memcached 中写入一个 Key 为”lock.1”的缓存项,然后去数据库里面加载数据,当数据加载完成后再把这个 Key 删掉。这时,如果另外一个线程也要请求这个用户的数据,它发现缓存中有 Key 为“lock.1”的缓存,就认为目前已经有线程在加载数据库中的值到缓存中了,它就可以重新去缓存中查询数据,不再穿透数据库了。
CDN
如何让用户的请求到达 CDN 节点
首先我们考虑一如何让用户的请求到达 CDN 节点,你可能会觉得只需要告诉用户 CDN 节点的 IP 地址,然后请求这个 IP 上部署的 CDN 服务就可以了。但是这样会有一个问题:我们使用的是第三方厂商的 CDN 服务,如果这个节点的 IP 发生了了变更咋办?或者说我们更改了 CDN 厂商怎么办?是不是要修改所有的商品 url 域名呢?所以,我们要做的事情就是将第三方提供的 IP 隐藏起来,给到用户的最好是一个本公司域名的子域名。
如何做到这一点?需要依靠 DNS 来帮我们解决域名映射的问题。
如何找到离用户最近的 CDN 节点
SLB(全局负载均衡)的含是对于部署在不同地域的服务器之间做负载均衡,下面可能管理了很多本地的负载均衡组件。它有两方面作用。
- 它是一种负载均衡服务器,负载均衡,指的是让流量平均分配使得下面管理的服务器的负载更平均;
- 另一方面它需要保证流量流经的服务器与流量源头在地缘上是比较接近的。
消息队列
秒杀场景
削去秒杀场景下的峰值写流量
将秒杀请求暂存在消息队列中,然后业务服务器会响应用户“秒杀结果正在计算中”,释放了系统资源之后再处理其它用户的请求。
这就是消息队列在秒杀系统中最主要的作用:削峰填谷,也就是说它可以削平短暂的流量高峰,虽说堆积会造成请求被短暂延迟处理,但是只要我们时刻监控消息队列中的堆积长度,在堆积量超过一定量时,增加队列处理机数量来提升消息的处理能力就好了,而且秒杀的用户对于短暂延迟知晓秒杀的结果也是有一定容忍度的。
通过异步处理简化秒杀请求中的业务流程
假如发放优惠券的耗时是 50ms,增加用户积分的耗时也是 50ms,那么如果我们将发放优惠券、增加积分的操作放在另外一个队列处理机中执行,那么整个流程就缩短到了 400ms,性能提升了 20%,处理这 1000 件商品的时间就变成了 400s。如果我们还是希望能在 50s 之内看到秒杀结果的话,只需要部署 8 个队列程序就好了。
解耦实现秒杀系统模块之间松耦合
秒杀系统产生一条购买数据后,我们可以先把全部数据发送给消息队列,然后数据团队再订阅这个消息队列的话题,这样它们就可以接收到数据,然后再做过滤和处理了。
秒杀系统在这样解耦合之后,数据系统的故障就不会影响到秒杀系统了,同时当数据系统需要新的字段时,只需要解析消息队列中的消息,拿到需要的数据就好了。
系统想要提升写入性能实现系统的低耦合,想要抵挡高并发的写流量,那么你就可以考虑使用消息队列来完成。
在生产、消费过程中增加消息幂等性的保证
在消息生产过程中,在 Kafka0.11 版本和 Pulsar 中都支持“producer idempotency”的特性,翻译过来就是生产过程的幂等性,这种特性保证消息虽然可能在生产端产生重复,但是最终在消息队列存储时只会存储一份。
它的做法是给每一个生产者一个唯一的 ID,并且为生产的每一条消息赋予一个唯一 ID,消息队列的服务端会存储 < 生产者 ID,最后一条消息 ID>
的映射。当某一个生产者产生新的消息时,消息队列服务端会比对消息 ID 是否与存储的最后一条 ID 一致,如果一致就认为是重复的消息,服务端会自动丢弃。
而在消费端,幂等性的保证会稍微复杂一些,你可以从通用层和业务层两个层面来考虑。
在通用层面,你可以在消息被生产的时候使用发号器给它生成一个全局唯一的消息 ID,消息被处理之后把这个 ID 存储在数据库中,在处理下一条消息之前先从数据库里面查询这个全局 ID 是否被消费过,如果被消费过就放弃消费。
你可以看到,无论是生产端的幂等性保证方式还是消费端通用的幂等性保证方式,它们的共同特点都是为每一个消息生成一个唯一的 ID,然后在使用这个消息的时候,先比对这个 ID 是否已经存在,如果存在则认为消息已经被使用过。所以这种方式是一种标准的实现幂等的方式,你在项目之中可以拿来直接使用。
如何监控消息延迟
- 使用消息队列提供的工具,通过监控消息的堆积来完成;
- 通过生成监控消息的方式来监控消息的延迟情况。
减少消息延迟的正确姿势
想要减少消息的处理延迟,我们需要在消费端和消息队列两个层面来完成。在消费端的目标是提升消费者的消息处理能力,你能做的是:
- 优化消费代码提升性能;
- 增加消费者的数量(这个方式比较简单)。
分布式服务
在架构演进的初期和中期,性能、可用性、可扩展性是我们追求的主要目标,高性能和高可用给用户带来更好的使用体验,扩展性可以方便我们支撑更大量级的并发。但是当系统做的越来越大,团队成员越来越多,我们就不得不考虑成本了。
微服务拆分
拆分原则
- 原则一,做到单一服务内部功能的高内聚,和低耦合。也就是说,每个服务只完成自己职责之内的任务,对于不是自己职责的功能,交给其它服务来完成。
- 原则二,你需要关注服务拆分的粒度,先粗略拆分,再逐渐细化。在服务拆分的初期,你其实很难确定,服务究竟要拆分成什么样。但是,从“微服务”这几个字来看,服务的粒度貌似应该足够小,甚至有“一方法一服务”的说法。不过,服务多了也会带来问题,像是服务个数的增加会增加运维的成本。再比如,原本一次请求只需要调用进程内的多个方法,现在则需要跨网络调用多个 RPC 服务,在性能上肯定会有所下降。
推荐的做法是:拆分初期可以把服务粒度拆的粗一些,后面随着团队对于业务和微服务理解的加深,再考虑把服务粒度细化。比如说,对于一个社区系统来说,你可以先把和用户关系相关的业务逻辑,都拆分到用户关系服务中,之后,再把比如黑名单的逻辑独立成黑名单服务。 - 原则三,拆分的过程,要尽量避免影响产品的日常功能迭代,也就是说,要一边做产品功能迭代,一边完成服务化拆分。
拆分只能在现有一体化系统的基础上,不断剥离业务独立部署,剥离的顺序,可以参考以下几点: - 1.优先剥离比较独立的边界服务(比如短信服务、地理位置服务),从非核心的服务出发,减少拆分对现有业务的影响,也给团队一个练习、试错的机会; - 2.当两个服务存在依赖关系时,优先拆分被依赖的服务。比方说,内容服务依赖于用户服务获取用户的基本信息,那么如果先把内容服务拆分出来,内容服务就会依赖于一体化架构中的用户模块,这样还是无法保证内容服务的快速部署能力。
所以正确的做法是,要理清服务之间的调用关系,比如,内容服务会依赖用户服务获取用户信息,互动服务会依赖内容服务,所以要按照先用户服务,再内容服务,最后互动服务的顺序来进行拆分。 - 原则四,服务接口的定义要具备可扩展性。服务拆分之后,由于服务是以独立进程的方式部署,所以服务之间通信,就不再是进程内部的方法调用,而是跨进程的网络通信了。在这种通信模型下需要注意,服务接口的定义要具备可扩展性,否则在服务变更时,会造成意想不到的错误。
在之前的项目中,某一个微服务的接口有三个参数,在一次业务需求开发中,组内的一个同学将这个接口的参数调整为了四个,接口被调用的地方也做了修改,结果上线这个服务后,却不断报错,无奈只能回滚。
想必你明白了,这是因为这个接口先上线后,参数变更成了四个,但是调用方还未变更,还是在调用三个参数的接口,那就肯定会报错了。所以,服务接口的参数类型最好是封装类,这样如果增加参数,就不必变更接口的签名,而只需要在类中添加字段即就可以了。
复杂度问题
- 1.服务接口的调用,不再是同一进程内的方法调用,而是跨进程的网络调用,这会增加接口响应时间的增加。此时,我们就要选择高效的服务调用框架,同时,接口调用方需要知道服务部署在哪些机器的哪个端口上,这些信息需要存储在一个分布式一致性的存储中,于是就需要引入服务注册中心,注册中心管理的是服务完整的生命周期,包括对于服务存活状态的检测。
- 2.多个服务之间有着错综复杂的依赖关系。一个服务会依赖多个其它服务,也会被多个服务所依赖,那么一旦被依赖的服务的性能出现问题,产生大量的慢请求,就会导致依赖服务的工作线程池中的线程被占满,那么依赖的服务也会出现性能问题。接下来,问题就会沿着依赖网,逐步向上蔓延,直到整个系统出现故障为止。
为了避免这种情况的发生,我们需要引入服务治理体系,针对出问题的服务,采用熔断、降级、限流、超时控制的方法,使得问题被限制在单一服务中,保护服务网络中的其它服务不受影响。 - 3.服务拆分到多个进程后,一条请求的调用链路上,涉及多个服务,那么一旦这个请求的响应时间增长,或者是出现错误,我们就很难知道,是哪一个服务出现的问题。另外,整体系统一旦出现故障,很可能外在的表现是所有服务在同一时间都出现了问题,你在问题定位时,很难确认哪一个服务是源头,这就需要引入分布式追踪工具,以及更细致的服务端监控报表。
RPC 优化
如果要提升 RPC 框架的性能,需要从网络传输和序列化两方面来优化。
- 1.选择高性能的 I/O 模型,这里我推荐使用同步多路 I/O 复用模型;
- 2.调试网络参数,这里面有一些经验值的推荐。比如将 tcp_nodelay 设置为 true,也有一些参数需要在运行中来调试,比如接受缓冲区和发送缓冲区的大小,客户端连接请求缓冲队列的大小(back log)等等;
- 3.序列化协议依据具体业务来选择。如果对性能要求不高,可以选择 JSON,否则可以从 Thrift 和 Protobuf 中选择其一。
五种 I/O 模型:
- 同步阻塞 I/O
- 同步非阻塞 I/O
- 同步多路 I/O 复用
- 信号驱动 I/O
- 异步 I/O
注册中心
比如说,Nginx 是一个反向代理组件,那么 Nginx 需要知道,应用服务器的地址是什么,这样才能够将流量透传到应用服务器上,这就是服务发现的过程。
那么 Nginx 是怎么实现的呢?它是把应用服务器的地址配置在了文件中。
这固然是一种解决的思路,实际上,RPC 服务端的地址,就是配置在了客户端的代码中,不过,这样做之后出现了几个问题:
首先在紧急扩容的时候,就需要修改客户端配置后,重启所有的客户端进程,操作时间比较长;
其次,一旦某一个服务器出现故障时,也需要修改所有客户端配置后重启,无法快速修复,更无法做到自动恢复;
最后,RPC 服务端上线无法做到提前摘除流量,这样在重启服务端的时候,客户端发往被重启服务端的请求还没有返回,会造成慢请求甚至请求失败。
考虑使用注册中心来解决这些问题:目前业界有很多可供你来选择的注册中心组件,比如说老派的 ZooKeeper,Kubernetes 使用的 ETCD,阿里的微服务注册中心 Nacos,Spring Cloud 的 Eureka 等等。
这些注册中心的基本功能有两点:
- 其一是提供了服务地址的存储;
- 其二是当存储内容发生变化时,可以将变更的内容推送给客户端。
第二个功能是使用注册中心的主要原因。因为无论是需要紧急扩容,还是在服务器发生故障时,需要快速摘除节点,都不用重启服务器就可以实现了。
服务注册和发现的过程:
- 客户端会与注册中心建立连接,并且告诉注册中心,它对哪一组服务感兴趣;
- 服务端向注册中心注册服务后,注册中心会将最新的服务注册信息通知给客户端;
- 客户端拿到服务端的地址之后就可以向服务端发起调用请求了。
trace 排查
因为在分布式环境下,请求要在多个服务之间调用,所以对于慢请求问题的排查会更困难。
最简单的思路是:打印下单操作的每一个步骤的耗时情况,然后通过比较这些耗时的数据,找到延迟最高的一步,然后再来看看这个步骤要如何优化。如果有必要的话,你还需要针对步骤中的子步骤,再增加日志来继续排查。
切面编程的实现分为两类:
- 一类是静态代理,典型的代表是 AspectJ,它的特点是在编译期做切面代码注入;
- 另一类是动态代理,典型的代表是 Spring AOP,它的特点是在运行期做切面代码注入。
分布式 Trace 中间件的实现原理:
- 1.用户到 A 服务之后会初始化一个 traceId 为 100,spanId 为 1;
- 2.A 服务调用 B 服务时,traceId 不变,而 spanId 用 1.1 标识代表上一级的 spanId 是 1,这一级的调用次序是 1;
- 3.A 调用 C 服务时,traceId 依然不变,spanId 则变为了 1.2,代表上一级的 spanId 还是 1,而调用次序则变成了 2,以此类推。
负载均衡
负载均衡服务大体上可以分为两大类:一类是代理类的负载均衡服务;另一类是客户端负载均衡服务。
代理类的负载均衡服务,以单独的服务方式部署,所有的请求都要先经过负载均衡服务,在负载均衡服务中,选出一个合适的服务节点后,再由负载均衡服务,调用这个服务节点来实现流量的分发。
由于这类服务需要承担全量的请求,所以对于性能的要求极高。代理类的负载均衡服务有很多开源实现,比较著名的有 LVS,Nginx 等等。LVS 在 OSI 网络模型中的第四层,传输层工作,所以 LVS 又可以称为四层负载;而 Nginx 运行在 OSI 网络模型中的第七层,应用层,所以又可以称它为七层负载。
在项目的架构中,一般会同时部署 LVS 和 Nginx 来做 HTTP 应用服务的负载均衡。也就是说,在入口处部署 LVS,将流量分发到多个 Nginx 服务器上,再由 Nginx 服务器分发到应用服务器上:
因为:主要和 LVS 和 Nginx 的特点有关,LVS 是在网络栈的四层做请求包的转发,请求包转发之后,由客户端和后端服务直接建立连接,后续的响应包不会再经过 LVS 服务器,所以相比 Nginx,性能会更高,也能够承担更高的并发。
可 LVS 缺陷是工作在四层,而请求的 URL 是七层的概念,不能针对 URL 做更细致地请求分发,而且 LVS 也没有提供探测后端服务是否存活的机制;而 Nginx 虽然比 LVS 的性能差很多,但也可以承担每秒几万次的请求,并且它在配置上更加灵活,还可以感知后端服务是否出现问题。
因此,LVS 适合在入口处,承担大流量的请求分发,而 Nginx 要部署在业务服务器之前做更细维度的请求分发。建议如果你的 QPS 在十万以内,那么可以考虑不引入 LVS 而直接使用 Nginx 作为唯一的负载均衡服务器,这样少维护一个组件,也会减少系统的维护成本。
不过这两个负载均衡服务适用于普通的 Web 服务,对于微服务架构来说,它们是不合适的。因为微服务架构中的服务节点存储在注册中心里,使用 LVS 就很难和注册中心交互,获取全量的服务节点列表。另外,一般微服务架构中,使用的是 RPC 协议而不是 HTTP 协议,所以 Nginx 也不能满足要求。
所以,我们会使用另一类的负载均衡服务,客户端负载均衡服务,也就是把负载均衡的服务内嵌在 RPC 客户端中。
API 网关
API 网关起到的作用
API 网关(API Gateway)不是一个开源组件,而是一种架构模式,它是将一些服务共有的功能整合在一起,独立部署为单独的一层,用来解决一些服务治理的问题。你可以把它看作系统的边界,它可以对出入系统的流量做统一的管控。
在我看来,API 网关可以分为两类:一类叫做入口网关,一类叫做出口网关。
入口网关是我们经常使用的网关种类,它部署在负载均衡服务器和应用服务器之间,主要有几方面的作用。
- 它提供客户端一个统一的接入地址,API 网关可以将用户的请求动态路由到不同的业务服务上,并且做一些必要的协议转换工作。在你的系统中,你部署的微服务对外暴露的协议可能不同:有些提供的是 HTTP 服务;有些已经完成 RPC 改造,对外暴露 RPC 服务;有些遗留系统可能还暴露的是 Web Service 服务。API 网关可以对客户端屏蔽这些服务的部署地址以及协议的细节,给客户端的调用带来很大的便捷。
- 另一方面,在 API 网关中,我们可以植入一些服务治理的策略,比如服务的熔断、降级、流量控制和分流等等(关于服务降级和流量控制的细节,我会在后面的课程中具体讲解,在这里你只要知道它们可以在 API 网关中实现就可以了)。
- 再有,客户端的认证和授权的实现,也可以放在 API 网关中。你要知道,不同类型的客户端使用的认证方式是不同的。在我之前项目中,手机 APP 使用 Oauth 协议认证,HTML5 端和 Web 端使用 Cookie 认证,内部服务使用自研的 Token 认证方式。这些认证方式在 API 网关上可以得到统一处理,应用服务不需要了解认证的细节。
- 另外,API 网关还可以做一些与黑白名单相关的事情,比如针对设备 ID、用户 IP、用户 ID 等维度的黑白名单。
- 最后,在 API 网关中也可以做一些日志记录的事情,比如记录 HTTP 请求的访问日志,我在 25 讲中讲述分布式追踪系统时,提到的标记一次请求的 requestId 也可以在网关中来生成。
API 网关实现
在 Zuul2.0 中,Netfix 团队将 servlet 改造成了一个 netty server(netty 服务),采用 I/O 多路复用的模型处理接入的 I/O 请求,并且将之前同步阻塞调用后端服务的方式,改造成使用 netty client(netty 客户端)非阻塞调用的方式。改造之后,Netfix 团队经过测试发现性能提升了 20% 左右。
除此之外,API 网关中执行的动作有些是可以预先定义好的,比如黑白名单的设置,接口动态路由;有些则是需要业务方依据自身业务来定义。所以,API 网关的设计要注意扩展性,也就是你可以随时在网关的执行链路上增加一些逻辑,也可以随时下掉一些逻辑(也就是所谓的热插拔)。
所以一般来说,我们可以把每一个操作定义为一个 filter(过滤器),然后使用“责任链模式”将这些 filter 串起来。责任链可以动态地组织这些 filter,解耦 filter 之间的关系,无论是增加还是减少 filter,都不会对其他的 filter 有任何的影响。
Zuul 就是采用责任链模式,Zuul1 中将 filter 定义为三类:pre routing filter(路由前过滤器)、routing filter(路由过滤器)和 after routing filter(路由后过滤器)。每一个 filter 定义了执行的顺序,在 filter 注册时,会按照顺序插入到 filter chain(过滤器链)中。这样 Zuul 在接收到请求时,就会按照顺序依次执行插入到 filter chain 中的 filter 了。
如何在你的系统中引入 API 网关
一方面是对服务层接口数据的聚合。比如,商品详情页的接口可能会聚合服务层中,获取商品信息、用户信息、店铺信息以及用户评论等多个服务接口的数据;
另一方面 Web 层还需要将 HTTP 请求转换为 RPC 请求,并且对前端的流量做一些限制,对于某些请求添加设备 ID 的黑名单等等。
多机房部署
多机房部署的含义是:在不同的 IDC 机房中部署多套服务,这些服务共享同一份业务数据,并且都可以承接来自用户的流量。
- 一个思路是直接跨机房读取 A 机房的从库
- 另一个思路是在机房 B 部署一个从库,跨机房同步主库的数据,然后机房 B 的应用就可以读取这个从库的数据了。
无论是哪一种思路,都涉及到跨机房的数据传输,这就对机房之间延迟情况有比较高的要求了。而机房之间的延迟和机房之间的距离息息相关,你可以记住几个数字。
- 1.北京同地双机房之间的专线延迟一般在 1ms~3ms。
- 2.国内异地双机房之间的专线延迟会在 50ms 之内;
- 3.如果你的业务是国际化的服务,需要部署跨国的双机房,那么机房之间的延迟就更高了,依据各大云厂商的数据来看,比如,从国内想要访问部署在美国西海岸的服务,这个延迟会在 100ms~200ms 左右。在这个延迟下,就要避免数据跨机房同步调用,而只做异步的数据同步。
逐步迭代多机房部署方案
- 1.同城双活:
假设这样的场景:你在北京有 A 和 B 两个机房,A 是联通的机房,B 是电信的机房,机房之间以专线连接,方案设计时,核心思想是尽量避免跨机房的调用。具体方案如下。
首先,数据库的主库可以部署在一个机房中,比如部署在 A 机房中,那么 A 和 B 机房数据都会被写入到 A 机房中。然后,在 A、B 两个机房中各部署一个从库,通过主从复制的方式,从主库中同步数据,这样双机房的查询请求可以查询本机房的从库。一旦 A 机房发生故障,可以通过主从切换的方式将 B 机房的从库提升为主库,达到容灾的目的
缓存也可以部署在两个机房中,查询请求也读取本机房的缓存,如果缓存中数据不存在,就穿透到本机房的从库中加载数据。数据的更新可以更新双机房中的数据,保证数据的一致性。
不同机房的 RPC 服务会向注册中心注册不同的服务组,而不同机房的 RPC 客户端,也就是 Web 服务,只订阅同机房的 RPC 服务组,这样就可以实现 RPC 调用尽量发生在本机房内,避免跨机房的 RPC 调用。
- 2.异地多活:
在数据写入时,你要保证只写本机房的数据存储服务再采取数据同步的方案,将数据同步到异地机房中。一般来说,数据同步的方案有两种: - 一种基于存储系统的主从复制,比如 MySQL 和 Redis。也就是在一个机房部署主库,在异地机房部署从库,两者同步主从复制实现数据的同步。 - 另一种是基于消息队列的方式。一个机房产生写入请求后,会写一条消息到消息队列,另一个机房的应用消费这条消息后再执行业务处理逻辑,写入到存储服务中。
Service Mesh
有没有考虑过这个问题?我们实现的一整套服务治理的组件 熔断、限流、认证、负载均衡器、分布式日志追踪怎么来平滑的使用到跨语言的应用中呢?
可以考虑将服务治理的细节,从 RPC 客户端中拆分出来,形成一个代理层单独部署。这个代理层可以使用单一的语言实现,所有的流量都经过代理层来使用其中的服务治理策略。这是一种“关注点分离”的实现方式,也是 Service Mesh 的核心思想。
Service Mesh 主要处理服务之间的通信,它的主要实现形式就是在应用程序同主机上部署一个代理程序。一般来讲,我们将这个代理程序称为“Sidecar(边车)”,服务之间的通信也从之前的客户端和服务端直连。
在这种形式下,RPC 客户端将数据包先发送给与自身同主机部署的 Sidecar,在 Sidecar 中经过服务发现、负载均衡、服务路由、流量控制之后,再将数据发往指定服务节点的 Sidecar,在服务节点的 Sidecar 中,经过记录访问日志、记录分布式追踪日志、限流之后,再将数据发送给 RPC 服务端。
这种方式可以把业务代码和服务治理的策略隔离开,将服务治理策略下沉,让它成为独立的基础模块。这样一来,不仅可以实现跨语言服务治理策略的复用,还能对这些 Sidecar 做统一的管理。
目前,业界提及最多的 Service Mesh 方案当属 Istio, 它的玩法是这样的:
Istio 这个框架在业界最为著名,它提出了数据平面和控制平面的概念,是 Service Mesh 的先驱,缺陷就是刚才提到的 Mixer 的性能问题。
Linkerd 是第一代的 Service Mesh,使用 Scala 语言编写,其劣势就是内存的占用。
SOFAMesh 是蚂蚁金服开源的 Service Mesh 组件,在蚂蚁金服已经有大规模落地的经验。
监控
监控指标如何选择
比如,谷歌针对分布式系统监
控的经验总结,四个黄金信号(Four Golden Signals)。它指的是,在服务层面一般需要监控四个指标,分别是延迟,通信量、错误和饱和度。
除此之外,你还可以借鉴 RED 指标体系。这个体系,是四个黄金信号中衍生出来的,其中,R 代表请求量(Request rate),E 代表错误(Error),D 代表响应时间(Duration),少了饱和度的指标。你可以把它当作一种简化版的通用监控指标体系。
如何采集数据指标
- Agent 是一种比较常见的,采集数据指标的方式。我们通过在数据源的服务器上,部署自研或者开源的 Agent,来收集收据,发送给监控系统,实现数据的采集。在采集数据源上的信息时,Agent 会依据数据源上,提供的一些接口获取数据。
- 是在代码中埋点。面向切面编程的方式;也可以在资源客户端中,直接计算调用资源或者服务的耗时、调用量、慢请求数,并且发送给监控服务器。
监控数据的处理和存储
在采集到监控数据之后,你就可以对它们进行处理和存储了,在此之前,我们一般会先用消息队列来承接数据,主要的作用是削峰填谷,防止写入过多的监控数据,让监控服务产生影响。
与此同时,我们一般会部署两个队列处理程序,来消费消息队列中的数据。
一个处理程序接收到数据后,把数据写入到 Elasticsearch,然后通过 Kibana 展示数据,这份数据主要是用来做原始数据的查询;另一个处理程序是一些流式处理的中间件,比如,Spark、Storm。它们从消息队列里,接收数据后会做一些处理。数据处理包括:
- 解析数据格式,尤其是日志格式
- 对数据做一些聚合运算
- 将数据存储在时间序列数据库中
- 最后,你就可以通过 Grafana 来连接时序数据库,将监控数据绘制成报表
压力测试
如何搭建全链路压测平台
有两个关键点:
一点是流量的隔离。由于压力测试是在正式环境进行,所以需要区分压力测试流量和正式流量,这样可以针对压力测试的流量做单独的处理。
另一点是风险的控制。也就是尽量避免压力测试对于正常访问用户的影响。因此,一般来说全链路压测平台需要包含以下几个模块:
- 流量构造和产生模块;
- 压测数据隔离模块;
- 系统健康度检查和压测流量干预模块。
压测数据的产生
一般来说,我们系统的入口流量是来自于客户端的 HTTP 请求。所以,我们会考虑在系统高峰期时,将这些入口流量拷贝一份,在经过一些流量清洗的工作之后(比如过滤一些无效的请求),将数据存储在像是 HBase、MongoDB 这些 NoSQL 存储组件或者云存储服务中,我们称之为流量数据工厂。
这样,当我们要压测的时候,就可以从这个工厂中获取数据,将数据切分多份后下发到多个压测节点上了。
轻型的流量拷贝工具 GoReplay(或 tcpcopy),它可以劫持本机某一个端口的流量,将它们记录在文件中,传送到流量数据工厂中。在压测时,你也可以使用这个工具进行加速的流量回放,这样就可以实现对正式环境的压力测试了。
数据如何隔离
一方面,针对读取数据的请求(一般称之为下行流量),我们会针对某些不能压测的服务或者组件,做 Mock 或者特殊的处理。
另一方面,针对写入数据的请求(一般称之为上行流量),我们会把压测流量产生的数据写入到影子库,也就是和线上数据存储完全隔离的一份存储系统中。
通过对下行流量的特殊处理以及对上行流量增加影子库的方式,我们就可以实现压测流量的隔离了。
压力测试如何实施
在拷贝了线上流量和完成了对线上系统的改造之后,我们就可以进行压力测试的实施了。在此之前,一般会设立一个压力测试的目标,比如说,整体系统的 QPS 需要达到每秒 20 万。
不过,在压测时,不会一下子把请求量增加到每秒 20 万次,而是按照一定的步长(比如每次压测增加一万 QPS),逐渐地增加流量。在增加一次流量之后,让系统稳定运行一段时间,观察系统在性能上的表现。如果发现依赖的服务或者组件出现了瓶颈,可以先减少压测流量,比如,回退到上一次压测的 QPS,保证服务的稳定,再针对此服务或者组件进行扩容,然后再继续增加流量压测。
为了能够减少压力测试过程中人力投入成本,可以开发一个流量监控的组件,在这个组件中,预先设定一些性能阈值。比如,容器的 CPU 使用率的阈值可以设定为 60%~ 70%;系统的平均响应时间的上限可以设定为 1 秒;系统慢请求的比例设置为 1% 等等。
当系统性能达到这个阈值之后,流量监控组件可以及时发现,并且通知压测流量下发组件减少压测流量,并且发送报警给到开发和运维的同学,开发和运维同学就迅速地排查性能瓶颈,在解决问题或者扩容之后再继续执行压测。
配置管理
在开发应用的时候,都有哪些管理配置的方式呢?主要有两种:
一种是通过配置文件来管理;
另一种是使用配置中心来管理。
熔断和降级
熔断机制
熔断机制的实现方式。这个机制参考的是电路中保险丝的保护机制,当电路超负荷运转的时候,保险丝会断开电路,保证整体电路不受损害。而服务治理中的熔断机制指的是在发起服务调用的时候,如果返回错误或者超时的次数超过一定阈值,则后续的请求不再发向远程服务而是暂时返回错误。
这种实现方式在云计算领域又称为断路器模式,在这种模式下,服务调用方为每一个调用的服务维护一个有限状态机,在这个状态机中会有三种状态:关闭(调用远程服务)、半打开(尝试调用远程服务)和打开(返回错误)。
当调用失败的次数累积到一定的阈值时,熔断状态从关闭态切换到打开态。一般在实现时,如果调用成功一次,就会重置调用失败次数。
当熔断处于打开状态时,我们会启动一个超时计时器,当计时器超时后,状态切换到半打开态。你也可以通过设置一个定时器,定期地探测服务是否恢复。
在熔断处于半打开状态时,请求可以达到后端服务,如果累计一定的成功次数后,状态切换到关闭态;如果出现调用失败的情况,则切换到打开态。
降级机制
相比熔断来说,降级是一个更大的概念。因为它是站在整体系统负载的角度上,放弃部分非核心功能或者服务,保证整体的可用性的方法,是一种有损的系统容错方式。这样看来,熔断也是降级的一种,除此之外还有限流降级、开关降级等等。
开关降级指的是在代码中预先埋设一些“开关”,用来控制服务调用的返回值。比方说,开关关闭的时候正常调用远程服务,开关打开时则执行降级的策略。这些开关的值可以存储在配置中心中,当系统出现问题需要降级时,只需要通过配置中心动态更改开关的值,就可以实现不重启服务快速地降级远程服务了。
开关降级的实现策略主要有返回降级数据、降频和异步三种方案。
其实,开关不仅仅应该在你的降级策略中使用,在我的项目中,只要上线新的功能必然要加开关控制业务逻辑是运行新的功能还是运行旧的功能。这样,一旦新的功能上线后,出现未知的问题(比如性能问题),那么可以通过切换开关的方式来实现快速地回滚,减少问题的持续时间。
限流
限流指的是通过限制到达系统的并发请求数量,保证系统能够正常响应部分用户请求,而对于超过限制的流量,则只能通过拒绝服务的方式保证整体系统的可用性。限流策略一般部署在服务的入口层,比如 API 网关中,这样可以对系统整体流量做塑形。而在微服务架构中,也可以在 RPC 客户端中引入限流的策略,来保证单个服务不会被过大的流量压垮。
在 TCP 协议中有一个滑动窗口的概念,可以实现对网络传输流量的控制。
而无论是在一体化架构还是微服务化架构中,也可以在多个维度上对到达系统的流量做控制,比如:
- 可以对系统每分钟处理多少请求做限制;
- 可以针对单个接口设置每分钟请求流量的限制;
- 可以限制单个 IP、用户 ID 或者设备 ID 在一段时间内发送请求的数量;
固定窗口与滑动窗口的算法
漏桶算法与令牌筒算法
漏桶算法的原理很简单,它就像在流量产生端和接收端之间增加一个漏桶,流量会进入和暂存到漏桶里面,而漏桶的出口处会按照一个固定的速率将流量漏出到接收端(也就是服务接口)。
如果流入的流量在某一段时间内大增,超过了漏桶的承受极限,那么多余的流量就会触发限流策略,被拒绝服务。
经过了漏桶算法之后,随机产生的流量就会被整形成为比较平滑的流量到达服务端,从而避免了突发的大流量对于服务接口的影响。
令牌桶算法的基本算法是这样的:
- 如果需要在一秒内限制访问次数为 N 次,那么就每隔 1/N 的时间,往桶内放入一个令牌;
- 在处理请求之前先要从桶中获得一个令牌,如果桶中已经没有了令牌,那么就需要等待新的令牌或者直接拒绝服务;
- 桶中的令牌总数也要有一个限制,如果超过了限制就不能向桶中再增加新的令牌了。这样可以限制令牌的总数,一定程度上可以避免瞬时流量高峰的问题。
使用令牌桶算法就需要存储令牌的数量,如果是单机上实现限流的话,可以在进程中使用一个变量来存储;但是如果在分布式环境下,不同的机器之间无法共享进程中的变量,就一般会使用 Redis 来存储这个令牌的数量。这样的话,每次请求的时候都需要请求一次 Redis 来获取一个令牌,会增加几毫秒的延迟,性能上会有一些损耗。因此,一个折中的思路是: 可以在每次取令牌的时候,不再只获取一个令牌,而是获取一批令牌,这样可以尽量减少请求 Redis 的次数。
限流是一种常见的服务保护策略,你可以在整体服务、单个服务、单个接口、单个 IP 或者单个用户等多个维度进行流量的控制;
基于时间窗口维度的算法有固定窗口算法和滑动窗口算法,两者虽然能一定程度上实现限流的目的,但是都无法让流量变得更平滑;
令牌桶算法和漏桶算法则能够塑形流量,让流量更加平滑,但是令牌桶算法能够应对一定的突发流量,所以在实际项目中应用更多。
一般在实际项目中,我们会把阈值放置在配置中心中方便动态调整;同时,可以通过定期地压力测试得到整体系统以及每个微服务的实际承载能力,然后再依据这个压测出来的值设置合适的阈值。
随着业务的不断增加,系统要求也会越来越高,随着而来的并发处理也越来越多元化。还有些不错的文章可以参考学习(后续继续补充):
Author
My name is Micheal Wayne and this is my blog.
I am a front-end software engineer.
Contact: michealwayne@163.com