高并发系统演进之路(上)--基础篇
高并发、高可用、可扩展是互联网技术井喷后软件系统演进的基本要求。
性能指标:度量性能的指标是系统接口的响应时间:平均值、最大值、分位数
高并发下的性能优化:
1)提高系统的处理核心数,
但随着并发进程数的增加,并行的任务对于系统资源的争抢也会愈发严重。在某一个临界点上继续增加并发进程数,反而会造成系统性能的下降,这就是性能测试中的拐点模型
2)减少单次任务响应时间
要看你的系统是 CPU 密集型还是 IO 密集型的,因为不同类型的系统性能优化方式不尽相同。优化方案会随着问题的不同而不同。比方说,如果是数据库访问慢,那么就要看是不是有锁表的情况、是不是有全表扫描、索引加得是否合适、是否有 JOIN 操作、需不需要加缓存,等等;如果是网络的问题,就要看网络的参数是否有优化的空间,抓包来看是否有大量的超时重传,网卡是否有大量丢包等。
高可用性
高可用性度量指标:
1)MTBF(Mean Time Between Failure)是平均故障间隔的意思,代表两次故障的间隔时间,也就是系统正常运转的平均时间。这个时间越长,系统稳定性越高。
2)MTTR(Mean Time To Repair)表示故障的平均恢复时间,也可以理解为平均故障时间。这个值越小,故障对于用户的影响越小。
Availability = MTBF / (MTBF + MTTR)
高可用系统设计思路:
1)failover(故障转移)
A. 是在完全对等的节点之间做 failover。B. 是在不对等的节点之间,即系统中存在主节点也存在备节点。
2)超时控制以及降级和限流。
A.降级是为了保证核心服务的稳定而牺牲非核心服务的做法。
B.限流完全是另外一种思路,它通过对并发的请求进行限速来保护系统。
高可用系统运维:
1)灰度发布
2)故障演练
开发注重的是如何处理故障,关键词是冗余和取舍。冗余指的是有备用节点,集群来顶替出故障的服务,比如文中提到的故障转移,还有多活架构等等;取舍指的是丢卒保车,保障主体服务的安全。从运维角度来看则更偏保守,注重的是如何避免故障的发生,比如更关注变更管理以及如何做故障的演练。
可扩展性高可用系统
拆分是提升系统扩展性最重要的一个思路,它会把庞杂的系统拆分成独立的,有单一职责的模块。
1)存储层的扩展性
2)业务层的扩展,业务纬度,重要性纬度和请求来源纬度。
数据库篇
1)用连接池预先建立数据库连接:频繁的创建释放连接池会导致请求变慢
最小连接数和最大连接数,它们控制着从连接池中获取连接的流程:
如果当前连接数小于最小连接数,则创建新的连接处理数据库请求;
如果连接池中有空闲连接则复用空闲连接;
如果空闲池中没有连接并且当前连接数小于最大连接数,则创建新的连接处理请求;
如果当前连接数已经大于等于最大连接数,则按照配置中设定的时间(C3P0 的连接池配置是 checkoutTimeout)等待旧的连接可用;
如果等待超过了这个设定时间则向用户抛出错误。
池子的最大值和最小值的设置很重要,初期可以依据经验来设置,后面还是需要根据实际运行情况做调整。
池子中的对象需要在使用之前预先初始化完成,这叫做池子的预热,比方说使用线程池时就需要预先初始化所有的核心线程。如果池子未经过预热可能会导致系统重启后产生比较多的慢请求。
池化技术核心是一种空间换时间优化方法的实践,所以要关注空间占用情况,避免出现空间过度使用出现内存泄露或者频繁垃圾回收等问题。
2)查询请求增加,主从分离
大部分系统的访问模型是读多写少,读写请求量的差距可能达到几个数量级。
主从读写的两个技术关键点:
A.数据的拷贝,我们称为主从复制
B.屏蔽主从分离带来的访问数据库方式的变化
主从分离不足:
部署上的复杂度,还有就是会带来一定的主从同步的延迟
1. 主从读写分离以及部署一主多从可以解决突发的数据库读流量,是一种数据库横向扩展的方法;
2. 读写分离后,主从的延迟是一个关键的监控指标,可能会造成写入数据之后立刻读的时候读取不到的情况;
3. 业界有很多的方案可以屏蔽主从分离之后数据库访问的细节,让开发人员像是访问单一数据库一样,包括有像 TDDL、Sharding-JDBC 这样的嵌入应用内部的方案,也有像 Mycat 这样的独立部署的代理方案。
3)分库分表
问题点:
A.如何提升查询性能
B.数据库存储量大
C.如何做到不同业务线故障隔离
D.数据库系统如何来处理更高的并发写入请求
解决办法:
分库分表是一种常见的将数据分片的方式,它的基本思想是依照某一种策略将数据尽量平均地分配到多个数据库节点或者多个表中。不同于主从复制时数据是全量地被拷贝到多个节点,分库分表后,每个节点只保存部分的数据,这样可以有效地减少单个数据库节点和单个数据表中存储的数据量,在解决了数据存储瓶颈的同时也能有效地提升数据查询的性能。同时,因为数据被分配到多个数据库节点上,那么数据的写入请求也从请求单一主库变成了请求多个数据分片节点,在一定程度上也会提升并发写入的性能。
1)垂直拆分,顾名思义就是对数据库竖着拆分,也就是将数据库的表拆分到多个不同的数据库中。按业务分
2)水平拆分,单一数据表按照某一种规则拆分到多个数据库和多个数据表中,关注点在数据的特点
A. 按照某一个字段的哈希值做拆分,这种拆分规则比较适用于实体表,比如说用户表,内容表,我们一般按照这些实体表的 ID 字段来拆分
B.另一种比较常用的是按照某一个字段的区间来拆分,比较常用的是时间字段。
1. 如果在性能上没有瓶颈点那么就尽量不做分库分表;
2. 如果要做,就尽量一次到位,比如说 16 库,每个库 64 表就基本能够满足为了几年内你的业务的需求。
3. 很多的 NoSQL 数据库,例如 Hbase,MongoDB 都提供 auto sharding 的特性,如果你的团队内部对于这些组件比较熟悉,有较强的运维能力,那么也可以考虑使用这些 NoSQL 数据库替代传统的关系型数据库。
分库分表后ID的全局唯一性:
A. 使用业务字段作为主键,比如说对于用户表来说,可以使用手机号,email 或者身份证号作为主键。
B. 使用生成的唯一 ID 作为主键。
NOSQL对关系数据库的互补:
A. 在性能方面,NoSQL 数据库使用一些算法将对磁盘的随机写转换成顺序写,提升了写的性能;
B. 在某些场景下,比如全文搜索功能,关系型数据库并不能高效地支持,需要 NoSQL 数据库的支持;
C. 在扩展性方面,NoSQL 数据库天生支持分布式,支持数据冗余和数据分片的特性。
4)缓存
常见的缓存主要就是静态缓存、分布式缓存和热点本地缓存这三种;
对于静态的资源的缓存你可以选择静态缓存,对于动态的请求你可以选择分布式缓存,热点本地缓存主要部署在应用服务器的代码中,用于阻挡热点查询对于分布式缓存节点或者数据库的压力。
缓存不足:
A.首先,缓存比较适合于读多写少的业务场景,并且数据最好带有一定的热点属性
B.其次,缓存会给整体系统带来复杂度,并且会有数据不一致的风险
C.再次,之前提到缓存通常使用内存作为存储介质,但是内存并不是无限的。
D.最后,缓存会给运维也带来一定的成本,运维需要对缓存组件有一定的了解,在排查问题的时候也多了一个组件需要考虑在内。
缓存可以有多层,比如上面提到的静态缓存处在负载均衡层,分布式缓存处在应用层和数据库层之间,本地缓存处在应用层。我们需要将请求尽量挡在上层,因为越往下层,对于并发的承受能力越差;缓存命中率是我们对于缓存最重要的一个监控项,越是热点的数据,缓存的命中率就越高。
缓存的读写策略:
读策略的步骤是:
从缓存中读取数据;
如果缓存命中,则直接返回数据;
如果缓存不命中,则从数据库中查询数据;
查询到数据后,将数据写入到缓存中,并且返回给用户。
写策略的步骤是:
更新数据库中的记录;
删除缓存记录。
Cache Aside(旁路缓存)策略
写数据库,然后清理缓存
Cache Aside 存在的最大的问题是当写入比较频繁时,缓存中的数据会被频繁地清理,这样会对缓存的命中率有一些影响。
A. 一种做法是在更新数据时也更新缓存,只是在更新缓存前先加一个分布式锁,因为这样在同一时间只允许一个线程更新缓存,就不会产生并发问题了。当然这么做对于写入的性能会有一些影响;
B. 另一种做法同样也是在更新数据时更新缓存,只是给缓存加一个较短的过期时间,这样即使出现缓存不一致的情况,缓存的数据也会很快地过期,对业务的影响也是可以接受。
Read/Write Through(读穿 / 写穿)策略
这个策略的核心原则是用户只与缓存打交道,由缓存和数据库通信,写入或者读取数据。
Write Through 的策略是这样的:先查询要写入的数据在缓存中是否已经存在,如果已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,如果缓存中数据不存在,我们把这种情况叫做“Write Miss(写失效)”。
Read Through 策略就简单一些,它的步骤是这样的:先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库中同步加载数据。
Write Back(写回)策略
这个策略的核心思想是在写入数据时只写入缓存,并且把缓存块儿标记为“脏”的。而脏块儿只有被再次使用时才会将其中的数据写入到后端存储中。
在“Write Miss”的情况下,我们采用的是“Write Allocate”的方式,也就是在写入后端存储的同时要写入缓存,这样我们在之后的写请求中都只需要更新缓存即可,而无需更新后端存储了,我将 Write back 策略的示意图放在了下面:
读的策略也有一些变化了。我们在读取缓存时如果发现缓存命中则直接返回缓存数据。如果缓存不命中则寻找一个可用的缓存块儿,如果这个缓存块儿是“脏”的,就把缓存块儿中之前的数据写入到后端存储中,并且从后端存储加载数据到缓存块儿,如果不是脏的,则由缓存组件将后端存储中的数据加载到缓存中,最后我们将缓存设置为不是脏的,返回数据就好了。
1.Cache Aside 是我们在使用分布式缓存时最常用的策略,你可以在实际工作中直接拿来使用。
2.Read/Write Through 和 Write Back 策略需要缓存组件的支持,所以比较适合你在实现本地缓存组件的时候使用;
3.Write Back 策略是计算机体系结构中的策略,不过写入策略中的只写缓存,异步写入后端存储的策略倒是有很多的应用场景。
分布式缓存的高可用方案:
客户端方案、中间代理层方案和服务端方案三大类:
客户端方案就是在客户端配置多个缓存的节点,通过缓存写入和读取算法策略来实现分布式,从而提高缓存的可用性。
中间代理层方案是在应用代码和缓存节点之间增加代理层,客户端所有的写入和读取的请求都通过代理层,而代理层中会内置高可用策略,帮助提升缓存系统的高可用。
服务端方案就是 Redis 2.4 版本后提出的 Redis Sentinel 方案。
客户端方案:
A.缓存数据如何分片:hash分片、一致性hash分片
B.Memcached 的主从机制
C.多副本
分布式缓存的高可用方案主要有三种,首先是客户端方案,一般也称为 Smart Client。我们通过制定一些数据分片和数据读写的策略,可以实现缓存高可用。这种方案的好处是性能没有损耗,缺点是客户端逻辑复杂且在多语言环境下不能复用。其次,中间代理方案在客户端和缓存节点之间增加了中间层,在性能上会有一些损耗,在代理层会有一些内置的高可用方案,比如 Codis 会使用 Codis Ha 或者 Sentinel。最后,服务端方案依赖于组件的实现,Memcached 就只支持单机版没有分布式和 HA 的方案,而 Redis 在 2.4 版本提供了 Sentinel 方案可以自动进行主从切换。服务端方案会在运维上增加一些复杂度。
缓存穿透解决方案:
回种空值以及使用布隆过滤器
1. 回种空值是一种最常见的解决思路,实现起来也最简单,如果评估空值缓存占据的缓存空间可以接受,那么可以优先使用这种方案;2. 布隆过滤器会引入一个新的组件,也会引入一些开发上的复杂度和运维上的成本。所以只有在存在海量查询数据库中,不存在数据的请求时才会使用,在使用时也要关注布隆过滤器对内存空间的消耗;3. 对于极热点缓存数据穿透造成的“狗桩效应”,可以通过设置分布式锁或者后台线程定时加载的方式来解决。
CDN 对静态资源进行加速的原理和使用的核心技术,
这里你需要了解的重点有以下几点:
1.DNS 技术是 CDN 实现中使用的核心技术,可以将用户的请求映射到 CDN 节点上;
2.DNS 解析结果需要做本地缓存,降低 DNS 解析过程的响应时间;
3.GSLB 可以给用户返回一个离着他更近的节点,加快静态资源的访问速度
5)消息队列
削峰填谷,解耦合
削峰填谷是消息队列最主要的作用,但是会造成请求处理的延迟。
异步处理是提升系统性能的神器,但是你需要分清同步流程和异步流程的边界,同时消息存在着丢失的风险,我们需要考虑如何确保消息一定到达。
解耦合可以提升你的整体系统的鲁棒性。
消息丢失:
要保证消息只被消费一次,首先就要保证消息不会丢失。那么消息从被写入到消息队列,到被消费者消费完成,这个链路上会有哪些地方存在丢失消息的可能呢?
其实,主要存在三个场景:
消息从生产者写入到消息队列的过程。
消息在消息队列中的存储场景。
消息被消费者消费的过程。
A. 在消息生产的过程中丢失消息:重传一次
B.消息队列中丢失:以集群方式部署 Kafka 服务,通过部署多个副本备份数据,保证消息尽量不丢失
C.在消费的过程中存在消息丢失的可能,等待消费完成信号,否则重传
如何保证消息只被消费一次:
幂等,它的含义是多次执行同一个操作和执行一次操作,最终得到的结果是相同的。
在生产、消费过程中增加消息幂等性的保证消息在生产和消费的过程中都可能会产生重复,所以你要做的是,在生产过程和消费过程中增加消息幂等性的保证,这样就可以认为从“最终结果上来看”,消息实际上是只被消费了一次的。
做法是给每一个生产者一个唯一的 ID,并且为生产的每一条消息赋予一个唯一 ID,消息队列的服务端会存储 < 生产者 ID,最后一条消息 ID> 的映射。当某一个生产者产生新的消息时,消息队列服务端会比对消息 ID 是否与存储的最后一条 ID 一致,如果一致,就认为是重复的消息,服务端会自动丢弃
减少消息延迟:
提升消费者的消息处理能力:
优化消费代码提升性能;消息的存储;零拷贝技术
增加消费者的数量(这个方式比较简单)
消息队列
服务架构