1 Star 4 Fork 11

LiefZhou / JavaStudyNotes

加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
克隆/下载
高并发系统40问.md 80.71 KB
一键复制 编辑 原始数据 按行查看 历史

高并发系统40问

学习目标

  • 掌握高并发系统设计的“套路”;

  • 理解基本的系统设计思想,对新的知识触类旁通,举一反三;

  • 突破技术的瓶颈,突破所处平台的限制,具备一个优秀架构师的资质。

基础篇

01.高并发系统的通用设计方法是什么?

三种方法

  • Scale-out(横向扩展):分而治之是一种常见的高并发系统设计方法,采用分布式部署的方式把流量分流开,让每个服务器都承担一部分并发和流量。
  • 缓存:使用缓存来提高系统的性能,就好比用“拓宽河道”的方式抵抗高并发大流量的冲击。
  • 异步:在某些场景下,未处理完成之前我们可以让请求先返回,在数据准备好之后再通知请求方,这样可以在单位时间内处理更多的请求。

系统演进的建议:

  • 最简单的系统设计满足业务需求和流量现状,选择最熟悉的技术体系。
  • 随着流量的增加和业务的变化修正架构中存在问题的点,如单点问题、横向扩展问题、性能无法满足需求的组件。在这个过程中,选择社区成熟的、团队熟悉的组件帮助我们解决问题,在社区没有合适解决方案的前提下才会自己造轮子。
  • 当对架构的小修小补无法满足需求时,考虑重构、重写等大的调整方式以解决现有的问题。

归根结底一句话:高并发系统的演进应该是循序渐进,以解决系统中存在的问题为目的和驱动力的

02.架构分层:我们为什么一定要这么做?

常见的软件架构分层:

“MVC”(Model-View-Controller)架构。它将整体的系统分成了 Model(模型),View(视图)和 Controller(控制器)三个层次,也就是将用户视图和业务处理隔离开,并且通过控制器连接起来,很好地实现了表现和逻辑的解耦,是一种标准的软件分层架构。

另外一种常见的分层方式是将整体架构分为表现层、逻辑层和数据访问层:表现层,顾名思义嘛,就是展示数据结果和接受用户指令的,是最靠近用户的一层;逻辑层里面有复杂业务的具体实现;数据访问层则是主要处理和存储之间的交互。

分层有什么好处

分层的设计可以简化系统设计,让不同的人专注做某一层次的事情

再有,分层之后可以做到很高的复用

最后一点,分层架构可以让我们更容易做横向扩展

分层架构的不足

最主要的一个缺陷就是增加了代码的复杂度。

另外一个可能的缺陷是,如果我们把每个层次独立部署,层次间通过网络来交互,那么多层的架构在性能上会有损耗。

那我们是否要选择分层的架构呢?

答案当然是肯定的。你要知道,任何的方案架构都是有优势有缺陷的,天地尚且不全何况我们的架构呢?分层架构固然会增加系统复杂度,也可能会有性能的损耗,但是相比于它能带给我们的好处来说,这些都是可以接受的,或者可以通过其它的方案解决的。我们在做决策的时候切不可以偏概全,因噎废食。

03.系统设计目标(一):如何提升系统性能?

高并发系统设计的三大目标:高性能、高可用、可扩展

性能优化原则

首先,性能优化一定不能盲目,一定是问题导向的。脱离了问题,盲目地提早优化会增加系统的复杂度,浪费开发人员的时间,也因为某些优化可能会对业务上有些折中的考虑,所以也会损伤业务。

**其次,性能优化也遵循“八二原则”,**即你可以用 20% 的精力解决 80% 的性能问题。所以我们在优化过程中一定要抓住主要矛盾,优先优化主要的性能瓶颈点。

再次,性能优化也要有数据支撑。在优化过程中,你要时刻了解你的优化让响应时间减少了多少,提升了多少的吞吐量。

最后,性能优化的过程是持续的。高并发的系统通常是业务逻辑相对复杂的系统,那么在这类系统中出现的性能问题通常也会有多方面的原因。因此,我们在做性能优化的时候要明确目标,比方说,支撑每秒 1 万次请求的吞吐量下响应时间在 10ms,那么我们就需要持续不断地寻找性能瓶颈,制定优化方案,直到达到目标为止。

总结出几点:

  • 数据优先,你做一个新的系统在上线之前一定要把性能监控系统做好;

  • 掌握一些性能优化工具和方法,这就需要在工作中不断地积累;

  • 计算机基础知识很重要,比如说网络知识、操作系统知识等等,掌握了基础知识才能让你在优化过程中抓住性能问题的关键,也能在性能优化过程中游刃有余。

04.系统设计目标(二):系统怎样做到高可用?

高可用性(High Availability,HA)是你在系统设计时经常会听到的一个名词,它指的是系统具备较高的无故障运行的能力。

可用性的度量

可用性是一个抽象的概念,你需要知道要如何来度量它,与之相关的概念是:MTBF 和 MTTR。

  • MTBF(Mean Time Between Failure)是平均故障间隔的意思,代表两次故障的间隔时间,也就是系统正常运转的平均时间。这个时间越长,系统稳定性越高。

  • MTTR(Mean Time To Repair)表示故障的平均恢复时间,也可以理解为平均故障时间。这个值越小,故障对于用户的影响越小

    可用性与 MTBF 和 MTTR 的值息息相关,我们可以用下面的公式表示它们之间的关系:Availability = MTBF / (MTBF + MTTR)

    这个公式计算出的结果是一个比例,而这个比例代表着系统的可用性。一般来说,我们会使用几个九来描述系统的可用性。

    img

高可用系统设计的思路

一个成熟系统的可用性需要从系统设计和系统运维两方面来做保障,两者共同作用,缺一不可。那么如何从这两方面入手,解决系统高可用的问题呢?

1. 系统设计

“Design for failure”是我们做高可用系统设计时秉持的第一原则。在承担百万 QPS 的高并发系统中,集群中机器的数量成百上千台,单机的故障是常态,几乎每一天都有发生故障的可能。

未雨绸缪才能决胜千里。我们在做系统设计的时候,要把发生故障作为一个重要的考虑点,预先考虑如何自动化地发现故障,发生故障之后要如何解决。当然了,除了要有未雨绸缪的思维之外,我们还需要掌握一些具体的优化方法,比如 failover(故障转移)、超时控制以及降级和限流。

既然要做超时控制,那么我们怎么来确定超时时间呢?

这是一个比较困难的问题。超时时间短了,会造成大量的超时错误,对用户体验产生影响;超时时间长了,又起不到作用。我建议你通过收集系统之间的调用日志,统计比如说 99% 的响应时间是怎样的,然后依据这个时间来指定超时时间。

降级是为了保证核心服务的稳定而牺牲非核心服务的做法。

限流完全是另外一种思路,它通过对并发的请求进行限速来保护系统。

2.系统运维

在系统设计阶段为了保证系统的可用性可以采取上面的几种方法,那在系统运维的层面又能做哪些事情呢?其实,我们可以从灰度发布、故障演练两个方面来考虑如何提升系统的可用性。

故障演练和时下比较流行的“混沌工程”的思路如出一辙,作为混沌工程的鼻祖,Netfix 在 2010 年推出的“Chaos Monkey”工具就是故障演练绝佳的工具。它通过在线上系统上随机地关闭线上节点来模拟故障,让工程师可以了解,在出现此类故障时会有什么样的影响。

05.系统设计目标(三):如何让系统易于扩展?

站在整体架构的角度,而不仅仅是业务服务器的角度来考虑系统的扩展性 。所以说,数据库、缓存、依赖的第三方、负载均衡、交换机带宽等等都是系统扩展时需要考虑的因素。我们要知道系统并发到了某一个量级之后,哪一个因素会成为我们的瓶颈点,从而针对性地进行扩展。

高可扩展性的设计思路

拆分是提升系统扩展性最重要的一个思路,它会把庞杂的系统拆分成独立的,有单一职责的模块。相对于大系统来说,考虑一个一个小模块的扩展性当然会简单一些。将复杂的问题简单化,这就是我们的思路

存储层的扩展性

无论是存储的数据量,还是并发访问量,不同的业务模块之间的量级相差很大,比如说成熟社区中,关系的数据量是远远大于用户数据量的,但是用户数据的访问量却远比关系数据要大。所以假如存储目前的瓶颈点是容量,那么我们只需要针对关系模块的数据做拆分就好了,而不需要拆分用户模块的数据。所以存储拆分首先考虑的维度是业务维度。

按照业务拆分,在一定程度上提升了系统的扩展性,但系统运行时间长了之后,单一的业务数据库在容量和并发请求量上仍然会超过单机的限制。这时,我们就需要针对数据库做第二次拆分。

这次拆分是按照数据特征做水平的拆分,比如说我们可以给用户库增加两个节点,然后按照某些算法将用户的数据拆分到这三个库里面,具体的算法我会在后面讲述数据库分库分表时和你细说。

当数据库按照业务和数据维度拆分之后,我们尽量不要使用事务。因为当一个事务中同时更新不同的数据库时,需要使用二阶段提交,来协调所有数据库要么全部更新成功,要么全部更新失败。这个协调的成本会随着资源的扩展不断升高,最终达到无法承受的程度。

业务层的扩展性

我们一般会从三个维度考虑业务层的拆分方案,它们分别是:业务维度,重要性维度和请求来源维度

首先,我们需要把相同业务的服务拆分成单独的业务池,比方说上面的社区系统中,我们可以按照业务的维度拆分成用户池、内容池、关系池、评论池、点赞池和搜索池。

每个业务依赖独自的数据库资源,不会依赖其它业务的数据库资源。这样当某一个业务的接口成为瓶颈时,我们只需要扩展业务的池子,以及确认上下游的依赖方就可以了,这样就大大减少了扩容的复杂度。

除此之外,我们还可以根据业务接口的重要程度,把业务分为核心池和非核心池。

最后,你还可以根据接入客户端类型的不同做业务池的拆分。

06.面试现场第一期

演进篇:数据库篇

07.池化技术:如何减少频繁创建数据库连接的性能损耗?

连接池

用连接池预先建立数据库连接

在开发过程中我们会用到很多的连接池,像是数据库连接池、HTTP 连接池、Redis 连接池等等。而连接池的管理是连接池设计的核心,我就以数据库连接池为例,来说明一下连接池管理的关键点。

数据库连接池有两个最重要的配置:最小连接数和最大连接数,它们控制着从连接池中获取连接的流程:

  • 如果当前连接数小于最小连接数,则创建新的连接处理数据库请求;
  • 如果连接池中有空闲连接则复用空闲连接;
  • 如果空闲池中没有连接并且当前连接数小于最大连接数,则创建新的连接处理请求;
  • 如果当前连接数已经大于等于最大连接数,则按照配置中设定的时间(C3P0 的连接池配置是 checkoutTimeout)等待旧的连接可用;
  • 如果等待超过了这个设定时间则向用户抛出错误。
如何保证启动中的连接可用?
  1. 启动一个线程来定期检测连接池中的连接是否可用,比如使用连接发送“select 1”的命令给数据库看是否会抛出异常,如果抛出异常则将这个连接从连接池中移除,并且尝试关闭。目前 C3P0 连接池可以采用这种方式来检测连接是否可用,也是我比较推荐的方式。
  2. 在获取到连接之后,先校验连接是否可用,如果可用才会执行 SQL 语句。比如 DBCP 连接池的 testOnBorrow 配置项,就是控制是否开启这个验证。这种方式在获取连接时会引入多余的开销,在线上系统中还是尽量不要开启,在测试服务上可以使用。

线程池

用线程池预先创建线程

JDK 1.5 中引入的 ThreadPoolExecutor 就是一种线程池的实现,它有两个重要的参数:coreThreadCount 和 maxThreadCount,这两个参数控制着线程池的执行过程。

  • 如果线程池中的线程数少于 coreThreadCount 时,处理新的任务时会创建新的线程;
  • 如果线程数大于 coreThreadCount 则把任务丢到一个队列里面,由当前空闲的线程执行;
  • 当队列中的任务堆积满了的时候,则继续创建线程,直到达到 maxThreadCount;
  • 当线程数达到 maxTheadCount 时还有新的任务提交,那么我们就不得不将它们丢弃了。
使用线程池时的注意事项

首先, **JDK 实现的这个线程池优先把任务放入队列暂存起来,而不是创建更多的线程,它比较适用于执行 CPU 密集型的任务,也就是需要执行大量 CPU 运算的任务。**这是为什么呢?因为执行 CPU 密集型的任务时 CPU 比较繁忙,因此只需要创建和 CPU 核数相当的线程就好了,多了反而会造成线程上下文切换,降低任务执行效率。所以当前线程数超过核心线程数时,线程池不会增加线程,而是放在队列里等待核心线程空闲下来。

但是,我们平时开发的 Web 系统通常都有大量的 IO 操作,比方说查询数据库、查询缓存等等。任务在执行 IO 操作的时候 CPU 就空闲了下来,这时如果增加执行任务的线程数而不是把任务暂存在队列中,就可以在单位时间内执行更多的任务,大大提高了任务执行的吞吐量。所以你看 Tomcat 使用的线程池就不是 JDK 原生的线程池,而是做了一些改造,当线程数超过 coreThreadCount 之后会优先创建线程,直到线程数到达 maxThreadCount,这样就比较适合于 Web 系统大量 IO 操作的场景了,你在实际使用过程中也可以参考借鉴。

其次线程池中使用的队列的堆积量也是我们需要监控的重要指标,对于实时性要求比较高的任务来说,这个指标尤为关键

我在实际项目中就曾经遇到过任务被丢给线程池之后,长时间都没有被执行的诡异问题。最初,我认为这是代码的 Bug 导致的,后来经过排查发现,是因为线程池的 coreThreadCount 和 maxThreadCount 设置得比较小,导致任务在线程池里面大量的堆积,在调大了这两个参数之后问题就解决了。跳出这个坑之后,我就把重要线程池的队列任务堆积量,作为一个重要的监控指标放到了系统监控大屏上。

最后,如果你使用线程池请一定记住不要使用无界队列(即没有设置固定大小的队列)。也许你会觉得使用了无界队列后,任务就永远不会被丢弃,只要任务对实时性要求不高,反正早晚有消费完的一天。但是,大量的任务堆积会占用大量的内存空间,一旦内存空间被占满就会频繁地触发 Full GC,造成服务不可用,我之前排查过的一次 GC 引起的宕机,起因就是系统中的一个线程池使用了无界队列。

池化技术及其缺陷

这是一种常见的软件设计思想,叫做池化技术,它的核心思想是空间换时间,期望使用预先创建好的对象来减少频繁创建对象的性能开销,同时还可以对对象进行统一的管理,降低了对象的使用的成本,总之是好处多多。

不过,池化技术也存在一些缺陷,比方说存储池子中的对象肯定需要消耗多余的内存,如果对象没有被频繁使用,就会造成内存上的浪费。再比方说,池子中的对象需要在系统启动的时候就预先创建完成,这在一定程度上增加了系统启动时间

数据库连接池C3P0和Druid

08. 数据库优化方案(一):查询请求增加时,如何做主从分离?

课程要点

  1. 主从读写分离以及部署一主多从可以解决突发的数据库读流量,是一种数据库横向扩展的方法;
  2. 读写分离后,主从的延迟是一个关键的监控指标,可能会造成写入数据之后立刻读的时候读取不到的情况;
  3. 业界有很多的方案可以屏蔽主从分离之后数据库访问的细节,让开发人员像是访问单一数据库一样,包括有像 TDDL、Sharding-JDBC 这样的嵌入应用内部的方案,也有像 Mycat 这样的独立部署的代理方案。

主从读写的两个技术关键点

一般来说在主从读写分离机制中,我们将一个数据库的数据拷贝为一份或者多份,并且写入到其它的数据库服务器中,原始的数据库我们称为主库,主要负责数据的写入,拷贝的目标数据库称为从库,主要负责支持数据查询。可以看到,主从读写分离有两个技术上的关键点:

  1. 一个是数据的拷贝,我们称为主从复制;
  2. 在主从分离的情况下,我们如何屏蔽主从分离带来的访问数据库方式的变化,让开发同学像是在使用单一数据库一样。

Mysql如何实现主从复制的

MySQL 的主从复制是依赖于 binlog 的,也就是记录 MySQL 上的所有变化并以二进制形式保存在磁盘上二进制日志文件。主从复制就是将 binlog 中的数据从主库传输到从库上,一般这个过程是异步的,即主库上的操作不会等待 binlog 同步的完成。

主从复制的过程是这样的:首先从库在连接到主节点时会创建一个 IO 线程,用以请求主库更新的 binlog,并且把接收到的 binlog 信息写入一个叫做 relay log 的日志文件中,而主库也会创建一个 log dump 线程来发送 binlog 给从库;同时,从库还会创建一个 SQL 线程读取 relay log 中的内容,并且在从库中做回放,最终实现主从的一致性。这是一种比较常见的主从复制方式。

那么你可能会说,是不是我无限制地增加从库的数量就可以抵抗大量的并发呢?

实际上并不是的。因为随着从库数量增加,从库连接上来的 IO 线程比较多,主库也需要创建同样多的 log dump 线程来处理复制的请求,对于主库资源消耗比较高,同时受限于主库的网络带宽,所以在实际使用中,一般一个主库最多挂 3~5 个从库

如何解决主从复制的延迟问题

这个问题解决的思路有很多,核心思想就是尽量不去从库中查询信息,纯粹以上面的例子来说,我就有三种解决方案:

第一种方案是数据的冗余。你可以在发送消息队列时不仅仅发送微博 ID,而是发送队列处理机需要的所有微博信息,借此避免从数据库中重新查询数据。

第二种方案是使用缓存。我可以在同步写数据库的同时,也把微博的数据写入到 Memcached 缓存里面,这样队列处理机在获取微博信息的时候会优先查询缓存,这样也可以保证数据的一致性。

最后一种方案是查询主库。我可以在队列处理机中不查询从库而改为查询主库。不过,这种方式使用起来要慎重,要明确查询的量级不会很大,是在主库的可承受范围之内,否则会对主库造成比较大的压力。

我会优先考虑第一种方案,因为这种方式足够简单,不过可能造成单条消息比较大,从而增加了消息发送的带宽和时间。缓存的方案比较适合新增数据的场景,更新场景容易造成数据的不一致。第三种方案较难控制接口被滥用。

读写分离后怎么访问数据库?

为了降低实现的复杂度,业界涌现了很多数据库中间件来解决数据库的访问问题,这些中间件可以分为两类。

**第一类以淘宝的 TDDL( Taobao Distributed Data Layer)为代表,以代码形式内嵌运行在应用程序内部。**你可以把它看成是一种数据源的代理,它的配置管理着多个数据源,每个数据源对应一个数据库,可能是主库,可能是从库。当有一个数据库请求时,中间件将 SQL 语句发给某一个指定的数据源来处理,然后将处理结果返回。

这一类中间件的优点是简单易用,没有多余的部署成本,因为它是植入到应用程序内部,与应用程序一同运行的,所以比较适合运维能力较弱的小团队使用;缺点是缺乏多语言的支持,目前业界这一类的主流方案除了 TDDL,还有早期的网易 DDB,它们都是 Java 语言开发的,无法支持其他的语言。另外,版本升级也依赖使用方更新,比较困难。

另一类是单独部署的代理层方案,这一类方案代表比较多,如早期阿里巴巴开源的 Cobar,基于 Cobar 开发出来的 Mycat,360 开源的 Atlas,美团开源的基于 Atlas 开发的 DBProxy 等等。

这一类中间件部署在独立的服务器上,业务代码如同在使用单一数据库一样使用它,实际上它内部管理着很多的数据源,当有数据库请求时,它会对 SQL 语句做必要的改写,然后发往指定的数据源。

它一般使用标准的 MySQL 通信协议,所以可以很好地支持多语言。由于它是独立部署的,所以也比较方便进行维护升级,比较适合有一定运维能力的大中型团队使用。它的缺陷是所有的 SQL 语句都需要跨两次网络:从应用到代理层和从代理层到数据源,所以在性能上会有一些损耗。

09.数据库优化方案(二):写入数据量增加时,如何实现分库分表?

分库分表的原则

  1. 如果在性能上没有瓶颈点那么就尽量不做分库分表;
  2. 如果要做,就尽量一次到位,比如说 16 库,每个库 64 表就基本能够满足几年内你的业务的需求。
  3. 很多的 NoSQL 数据库,例如 Hbase,MongoDB 都提供 auto sharding 的特性,如果你的团队内部对于这些组件比较熟悉,有较强的运维能力,那么也可以考虑使用这些 NoSQL 数据库替代传统的关系型数据库。

分库分表的必要性:

随着业务数据量的上升,单库单表的查询和写入性能下降,需要考虑的问题是:

  1. 单个表的数据量超过了千万甚至到了亿级别。这时即使你使用了索引,索引占用的空间也随着数据量的增长而增大,数据库就无法缓存全量的索引信息,那么就需要从磁盘上读取索引数据,就会影响到查询的性能了。那么这时你要如何提升查询性能呢?
  2. 数据量的增加也占据了磁盘的空间,数据库在备份和恢复的时间变长,你如何让数据库系统支持如此大的数据量呢?
  3. 不同模块的数据,比如用户数据和用户关系数据,全都存储在一个主库中,一旦主库发生故障,所有的模块都会受到影响,那么如何做到不同模块的故障隔离呢?
  4. 你已经知道了,在 4 核 8G 的云服务器上对 MySQL 5.7 做 Benchmark,大概可以支撑 500TPS 和 10000QPS,你可以看到数据库对于写入性能要弱于数据查询的能力,那么随着系统写入请求量的增长,数据库系统如何来处理更高的并发写入请求呢?

不少人会在“分库分表”这里踩坑,主要体现在:

  1. 对如何使用正确的分库分表方式一知半解,没有明白使用场景和方法。比如,一些同学会在查询时不使用分区键;
  2. 分库分表引入了一些问题后,没有找到合适的解决方案。比如,会在查询时使用大量连表查询等等。

如何对数据库做垂直拆分

分库分表是一种常见的将数据分片的方式,它的基本思想是依照某一种策略将数据尽量平均地分配到多个数据库节点或者多个表中。

**不同于主从复制时数据是全量地被拷贝到多个节点,分库分表后,每个节点只保存部分的数据,这样可以有效地减少单个数据库节点和单个数据表中存储的数据量,在解决了数据存储瓶颈的同时也能有效地提升数据查询的性能。**同时,因为数据被分配到多个数据库节点上,那么数据的写入请求也从请求单一主库变成了请求多个数据分片节点,在一定程度上也会提升并发写入的性能。

垂直拆分,顾名思义就是对数据库竖着拆分,也就是将数据库的表拆分到多个不同的数据库中。

垂直拆分的原则一般是按照业务类型来拆分,核心思想是专库专用,将业务耦合度比较高的表拆分到单独的库中。举个形象的例子,就是在整理衣服的时候,将羽绒服、毛衣、T 恤分别放在不同的格子里。这样可以解决我在开篇提到的第三个问题:把不同的业务的数据分拆到不同的数据库节点上,这样一旦数据库发生故障时只会影响到某一个模块的功能,不会影响到整体功能,从而实现了数据层面的故障隔离。

不过拆分之后,虽然可以暂时缓解存储容量的瓶颈,但并不是万事大吉,因为数据库垂直拆分后依然不能解决某一个业务模块的数据大量膨胀的问题

如何对数据库做水平拆分

垂直拆分的关注点在于业务相关性,和垂直拆分的关注点不同,而水平拆分指的是将单一数据表按照某一种规则拆分到多个数据库和多个数据表中,水平拆分的关注点在数据的特点。

  1. 按照某一个字段的哈希值做拆分,这种拆分规则比较适用于实体表,比如说用户表,内容表,我们一般按照这些实体表的 ID 字段来拆分。比如说我们想把用户表拆分成 16 个库,每个库是 64 张表,那么可以先对用户 ID 做哈希,哈希的目的是将 ID 尽量打散,然后再对 16 取余,这样就得到了分库后的索引值;对 64 取余,就得到了分表后的索引值。
  2. 另一种比较常用的是按照某一个字段的区间来拆分,比较常用的是时间字段。你知道在内容表里面有“创建时间”的字段,而我们也是按照时间来查看一个人发布的内容。我们可能会要看昨天的内容,也可能会看一个月前发布的内容,这时就可以按照创建时间的区间来分库分表,比如说可以把一个月的数据放入一张表中,这样在查询时就可以根据创建时间先定位数据存储在哪个表里面,再按照查询条件来查询。

一般来说,列表数据可以使用这种拆分方式

解决分库分表引入的问题

**分库分表引入的一个最大的问题就是引入了分库分表键,也叫做分区键,**也就是我们对数据库做分库分表所依据的字段。

这带来一个问题是:我们之后所有的查询都需要带上这个字段,才能找到数据所在的库和表,否则就只能向所有的数据库和数据表发送查询命令。

当然,方法总比问题多,针对这个问题,我们也会有一些相应的解决思路。比如,在用户库中我们使用 ID 作为分区键,这时如果需要按照昵称来查询用户时,你可以按照昵称作为分区键再做一次拆分,但是这样会极大地增加存储成本,如果以后我们还需要按照注册时间来查询时要怎么办呢,再做一次拆分吗?

所以最合适的思路是你要建立一个昵称和 ID 的映射表,在查询的时候要先通过昵称查询到 ID,再通过 ID 查询完整的数据,这个表也可以是分库分表的,也需要占用一定的存储空间,但是因为表中只有两个字段,所以相比重新做一次拆分还是会节省不少的空间的。

分库分表引入的另外一个问题是一些数据库的特性在实现时可能变得很困难

比如说多表的 JOIN 在单库时是可以通过一个 SQL 语句完成的,但是拆分到多个数据库之后就无法跨库执行 SQL 了,不过好在我们对于 JOIN 的需求不高,即使有也一般是把两个表的数据取出后在业务代码里面做筛选,复杂是有一些,不过是可以实现的。再比如说在未分库分表之前查询数据总数时只需要在 SQL 中执行 count() 即可,现在数据被分散到多个库表中,我们可能要考虑其他的方案,比方说将计数的数据单独存储在一张表中或者记录在 Redis 里面。

10. 发号器:如何保证分库分表后ID的全局唯一性?

10.1如何选择数据库主键

  1. 使用业务字段作为主键,比如说对于用户表来说,可以使用手机号,email 或者身份证号作为主键。
  2. 使用生成的唯一 ID 作为主键。

由于第一种方案存在主键变化的可能性,更倾向于使用生成的 ID 作为数据库的主键。不单单是因为它的唯一性,更是因为一旦生成就不会变更,可以随意引用。

10.2为什么不使用UUID作为主键

首先,生成的 ID 最好具有单调递增性,也就是有序的,而 UUID 不具备这个特点。为什么 ID 要是有序的呢?因为在系统设计时,ID 有可能成为排序的字段

另一个原因在于 **ID 有序也会提升数据的写入性能。**我们知道 MySQL InnoDB 存储引擎使用 B+ 树存储索引数据,而主键也是一种索引。

10.3Snowflake的原理

Snowflake 的核心思想是将 64bit 的二进制数字分成若干部分,每一部分都存储有特定含义的数据,比如说时间戳、机器 ID、序列号等等,最终生成全局唯一的有序 ID。

img

不同公司也会依据自身业务的特点对 Snowflake 算法做一些改造,比如说减少序列号的位数增加机器 ID 的位数以支持单 IDC 更多的机器,也可以在其中加入业务 ID 字段来区分不同的业务。比方说我现在使用的发号器的组成规则就是:1 位兼容位恒为 0 + 41 位时间信息 + 6 位 IDC 信息(支持 64 个 IDC)+ 6 位业务信息(支持 64 个业务)+ 10 位自增信息(每毫秒支持 1024 个号)

两种实现形式:

一种是嵌入到业务代码里,也就是分布在业务服务器中

另外一个部署方式是作为独立的服务部署,这也就是我们常说的发号器服务。微博和美图都是使用独立服务的方式来部署发号器的,性能上单实例单 CPU 可以达到两万每秒。

最大的缺点就是它依赖于系统的时间戳,一旦系统时间不准,就有可能生成重复的 ID。所以如果我们发现系统时钟不准,就可以让发号器暂时拒绝发号,直到时钟准确为止。

10.4如何解决QPS不高导致的分库分表不均匀的问题?

如果请求发号器的 QPS 不高,比如说发号器每毫秒只发一个 ID,就会造成生成 ID 的末位永远是 1,那么在分库分表时如果使用 ID 作为分区键就会造成库表分配的不均匀。这一点,也是我在实际项目中踩过的坑,而解决办法主要有两个:

  1. 时间戳不记录毫秒而是记录秒,这样在一个时间区间里可以多发出几个号,避免出现分库分表时数据分配不均。
  2. 生成的序列号的起始号可以做一下随机,这一秒是 21,下一秒是 30,这样就会尽量地均衡了。

11.NoSQL:在高并发场景下,数据库和NoSQL如何做到互补?

课程小结

本节课我带你了解了 NoSQL 数据库在性能、扩展性上的优势,以及它的一些特殊功能特性,主要有以下几点:

  1. 在性能方面,NoSQL 数据库使用一些算法将对磁盘的随机写转换成顺序写,提升了写的性能;
  2. 在某些场景下,比如全文搜索功能,关系型数据库并不能高效地支持,需要 NoSQL 数据库的支持;
  3. 在扩展性方面,NoSQL 数据库天生支持分布式,支持数据冗余和数据分片的特性。

这些都让它成为传统关系型数据库的良好的补充,你需要了解的是,NoSQL 可供选型的种类很多,每一个组件都有各自的特点。你在做选型的时候需要对它的实现原理有比较深入的了解,最好在运维方面对它有一定的熟悉,这样在出现问题时才能及时找到解决方案。否则,盲目跟从地上了一个新的 NoSQL 数据库,最终可能导致会出了故障无法解决,反而成为整体系统的拖累

NoSQL数据库类型

  • Redis、LevelDB 这样的 KV 存储。这类存储相比于传统的数据库的优势是极高的读写性能,一般对性能有比较高的要求的场景会使用。
  • Hbase、Cassandra 这样的列式存储数据库。这种数据库的特点是数据不像传统数据库以行为单位来存储,而是以列来存储,适用于一些离线数据统计的场景。
  • 像 MongoDB、CouchDB 这样的文档型数据库。这种数据库的特点是 Schema Free(模式自由),数据表中的字段可以任意扩展,比如说电商系统中的商品有非常多的字段,并且不同品类的商品的字段也都不尽相同,使用关系型数据库就需要不断增加字段支持,而用文档型数据库就简单很多了。

演进篇:缓存篇

12.缓存:数据库成为瓶颈后,动态数据的查询要如何加速?

12.1 常见硬件组件的延时情况

img

12.2 缓存与缓冲区的区别

缓存可以提高低速设备的访问速度,或者减少复杂耗时的计算带来的性能问题。理论上说,我们可以通过缓存解决所有关于“慢”的问题,比如从磁盘随机读取数据慢,从数据库查询数据慢,只是不同的场景消耗的存储成本不同。缓冲区则是一块临时存储数据的区域,这些数据后面会被传输到其他设备上。

缓冲区更像“消息队列篇”中即将提到的消息队列,用以弥补高速设备和低速设备通信时的速度差。比如,我们将数据写入磁盘时并不是直接刷盘,而是写到一块缓冲区里面,内核会标识这个缓冲区为脏。当经过一定时间或者脏缓冲区比例到达一定阈值时,由单独的线程把脏块刷新到硬盘上。这样避免了每次写数据都要刷盘带来的性能问题。

12.3 缓存的分类

常见的缓存主要就是静态缓存、分布式缓存和热点本地缓存这三种

静态缓存在 Web 1.0 时期是非常著名的,它一般通过生成 Velocity 模板或者静态 HTML 文件来实现静态缓存,在 Nginx 上部署静态缓存可以减少对于后台应用服务器的压力。

静态缓存只能针对静态数据来缓存,对于动态请求就无能为力了。那么我们如何针对动态请求做缓存呢?这时你就需要分布式缓存了。

分布式缓存的大名可谓是如雷贯耳了,我们平时耳熟能详的 Memcached、Redis 就是分布式缓存的典型例子。它们性能强劲,通过一些分布式的方案组成集群可以突破单机的限制。所以在整体架构中,分布式缓存承担着非常重要的角色。

对于静态的资源的缓存你可以选择静态缓存,对于动态的请求你可以选择分布式缓存,那么什么时候要考虑热点本地缓存呢?

答案是当我们遇到极端的热点数据查询的时候。热点本地缓存主要部署在应用服务器的代码中,用于阻挡热点查询对于分布式缓存节点或者数据库的压力。

12.4 缓存的不足

首先,缓存比较适合于读多写少的业务场景,并且数据最好带有一定的热点属性

其次,缓存会给整体系统带来复杂度,并且会有数据不一致的风险

再次,之前提到缓存通常使用内存作为存储介质,但是内存并不是无限的

最后,缓存会给运维也带来一定的成本

13.缓存的使用姿势(一):如何选择缓存的读写策略?

今天,我们先讲讲缓存的读写策略。你可能觉得缓存的读写很简单,只需要优先读缓存,缓存不命中就从数据库查询,查询到了就回种缓存。实际上,针对不同的业务场景,缓存的读写策略也是不同的。

而我们在选择策略时也需要考虑诸多的因素,比如说,缓存中是否有可能被写入脏数据,策略的读写性能如何,是否存在缓存命中率下降的情况等等。接下来,我就以标准的“缓存 + 数据库”的场景为例,带你剖析经典的缓存读写策略以及它们适用的场景。这样一来,你就可以在日常的工作中根据不同的场景选择不同的读写策略。

13.1 Cache Aside(旁路缓存)策略

背景:保存数据时先更新数据库再更新缓存导致数据不一致的问题。

产生问题的原因:因为变更数据库和变更缓存是两个独立的操作,而我们并没有对操作做任何的并发控制。那么当两个线程并发更新它们的时候,就会因为写入顺序的不同造成数据的不一致。

解决方案:我们可以在更新数据时不更新缓存,而是删除缓存中的数据,在读取数据时,发现缓存中没了数据之后,再从数据库中读取数据,更新到缓存中。

这个策略就是我们使用缓存最常见的策略,Cache Aside 策略(也叫旁路缓存策略),这个策略数据以数据库中的数据为准,缓存中的数据是按需加载的。它可以分为读策略和写策略,其中读策略的步骤是:

  • 从缓存中读取数据;

  • 如果缓存命中,则直接返回数据;

  • 如果缓存不命中,则从数据库中查询数据;

  • 查询到数据后,将数据写入到缓存中,并且返回给用户。

写策略的步骤是:

  • 更新数据库中的记录;

  • 删除缓存记录。

反例:Cache Aside 策略是我们日常开发中最经常使用的缓存策略,不过我们在使用时也要学会依情况而变。比如说当新注册一个用户,按照这个更新策略,你要写数据库,然后清理缓存(当然缓存中没有数据给你清理)。可当我注册用户后立即读取用户信息,并且数据库主从分离时,会出现因为主从延迟所以读不到用户信息的情况。而解决这个问题的办法恰恰是在插入新数据到数据库之后写入缓存,这样后续的读请求就会从缓存中读到数据了。并且因为是新注册的用户,所以不会出现并发更新用户信息的情况。

存在问题及解决方案:

Cache Aside 存在的最大的问题是当写入比较频繁时,缓存中的数据会被频繁地清理,这样会对缓存的命中率有一些影响。如果你的业务对缓存命中率有严格的要求,那么可以考虑两种解决方案:

  1. 一种做法是在更新数据时也更新缓存,只是在更新缓存前先加一个分布式锁,因为这样在同一时间只允许一个线程更新缓存,就不会产生并发问题了。当然这么做对于写入的性能会有一些影响;
  2. 另一种做法同样也是在更新数据时更新缓存,只是给缓存加一个较短的过期时间,这样即使出现缓存不一致的情况,缓存的数据也会很快过期,对业务的影响也是可以接受。

13.2 Read/Write Through(读穿 / 写穿)策略

这个策略的核心原则是用户只与缓存打交道,由缓存和数据库通信,写入或者读取数据。这就好比你在汇报工作的时候只对你的直接上级汇报,再由你的直接上级汇报给他的上级,你是不能越级汇报的。

Write Through 的策略是这样的:先查询要写入的数据在缓存中是否已经存在,如果已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,如果缓存中数据不存在,我们把这种情况叫做“Write Miss(写失效)”。

一般来说,我们可以选择两种“Write Miss”方式:一个是“Write Allocate(按写分配)”,做法是写入缓存相应位置,再由缓存组件同步更新到数据库中;另一个是“No-write allocate(不按写分配)”,做法是不写入缓存中,而是直接更新到数据库中。

在 Write Through 策略中,我们一般选择“No-write allocate”方式,原因是无论采用哪种“Write Miss”方式,我们都需要同步将数据更新到数据库中,而“No-write allocate”方式相比“Write Allocate”还减少了一次缓存的写入,能够提升写入的性能。

Read Through 策略就简单一些,它的步骤是这样的:先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库中同步加载数据。

img

13.3 Write Back(写回)策略

这个策略的核心思想是在写入数据时只写入缓存,并且把缓存块儿标记为“脏”的。而脏块儿只有被再次使用时才会将其中的数据写入到后端存储中。

需要注意的是,在“Write Miss”的情况下,我们采用的是“Write Allocate”的方式,也就是在写入后端存储的同时要写入缓存,这样我们在之后的写请求中都只需要更新缓存即可,而无需更新后端存储了,我将 Write back 策略的示意图放在了下面:

img

如果使用 Write Back 策略的话,读的策略也有一些变化了。我们在读取缓存时如果发现缓存命中则直接返回缓存数据。如果缓存不命中则寻找一个可用的缓存块儿,如果这个缓存块儿是“脏”的,就把缓存块儿中之前的数据写入到后端存储中,并且从后端存储加载数据到缓存块儿,如果不是脏的,则由缓存组件将后端存储中的数据加载到缓存中,最后我们将缓存设置为不是脏的,返回数据就好了。

img

13.4 课程小结

  1. Cache Aside 是我们在使用分布式缓存时最常用的策略,你可以在实际工作中直接拿来使用。

  2. Read/Write Through 和 Write Back 策略需要缓存组件的支持,所以比较适合你在实现本地缓存组件的时候使用;

  3. Write Back 策略是计算机体系结构中的策略,不过写入策略中的只写缓存,异步写入后端存储的策略倒是有很多的应用场景。

而且,你还需要了解,我们今天提到的策略都是标准的使用姿势,在实际开发过程中需要结合实际的业务特点灵活使用甚至加以改造。这些业务特点包括但不仅限于:整体的数据量级情况,访问的读写比例的情况,对于数据的不一致时间的容忍度,对于缓存命中率的要求等等。理论结合实践,具体情况具体分析,你才能得到更好的解决方案。

14.缓存的使用姿势(二):缓存如何做到高可用?

分布式缓存的高可用方案。在我的项目中,我主要选择的方案有客户端方案、中间代理层方案和服务端方案三大类:

客户端方案就是在客户端配置多个缓存的节点,通过缓存写入和读取算法策略来实现分布式,从而提高缓存的可用性。

中间代理层方案是在应用代码和缓存节点之间增加代理层,客户端所有的写入和读取的请求都通过代理层,而代理层中会内置高可用策略,帮助提升缓存系统的高可用。

服务端方案就是 Redis 2.4 版本后提出的 Redis Sentinel 方案。掌握这些方案可以帮助你,抵御部分缓存节点故障导致的,缓存命中率下降的影响,增强你的系统的鲁棒性。

小结:

分布式缓存的高可用方案主要有三种,

首先是客户端方案,一般也称为 Smart Client。我们通过制定一些数据分片和数据读写的策略,可以实现缓存高可用。这种方案的好处是性能没有损耗,缺点是客户端逻辑复杂且在多语言环境下不能复用。

其次,中间代理方案在客户端和缓存节点之间增加了中间层,在性能上会有一些损耗,在代理层会有一些内置的高可用方案,比如 Codis 会使用 Codis Ha 或者 Sentinel。

最后,服务端方案依赖于组件的实现,Memcached 就只支持单机版没有分布式和 HA 的方案,而 Redis 在 2.4 版本提供了 Sentinel 方案可以自动进行主从切换。服务端方案会在运维上增加一些复杂度。

总体而言,分布式缓存的三种方案各有所长,有些团队可能在开发过程中已经积累了 Smart Client 上的一些经验;而有些团队在 Redis 运维上经验丰富,就可以推进 Sentinel 方案;有些团队在存储研发方面有些积累,就可以推进中间代理层方案,甚至可以自研适合自己业务场景的代理层组件,具体的选择还是要看团队的实际情况而定。

15.缓存的使用姿势(三):缓存穿透了怎么办?

课程小结:重点是对布隆过滤器的理解

本节课,我带你了解了一些解决缓存穿透的方案,你可以在发现自己的缓存系统命中率下降时从中得到一些借鉴的思路。我想让你明确的重点是:

  • 回种空值是一种最常见的解决思路,实现起来也最简单,如果评估空值缓存占据的缓存空间可以接受,那么可以优先使用这种方案;

  • 布隆过滤器会引入一个新的组件,也会引入一些开发上的复杂度和运维上的成本。所以只有在存在海量查询数据库中,不存在数据的请求时才会使用,在使用时也要关注布隆过滤器对内存空间的消耗;

  • 对于极热点缓存数据穿透造成的“狗桩效应”,可以通过设置分布式锁或者后台线程定时加载的方式来解决。

除此之外,你还需要了解数据库是一个脆弱的资源,它无论是在扩展性、性能还是承担并发的能力上,相比缓存都处于绝对的劣势,所以我们解决缓存穿透问题的核心目标在于减少对于数据库的并发请求。了解了这个核心的思想,也许你还会在日常工作中找到其他更好的解决缓存穿透问题的方案。

16.CDN:静态资源如何加速?

课程小结:

本节课,我主要带你了解了 CDN 对静态资源进行加速的原理和使用的核心技术,这里你需要了解的重点有以下几点:

  1. DNS 技术是 CDN 实现中使用的核心技术,可以将用户的请求映射到 CDN 节点上

  2. DNS 解析结果需要做本地缓存,降低 DNS 解析过程的响应时间;

  3. GSLB 可以给用户返回一个离着他更近的节点,加快静态资源的访问速度。

作为一个服务端开发人员,你可能会忽略 CDN 的重要性,对于偶尔出现的 CDN 问题嗤之以鼻,觉得这个不是我们应该关心的内容,这种想法是错的。

CDN 是我们系统的门面,其缓存的静态数据,如图片和视频数据的请求量很可能是接口请求数据的几倍甚至更高,一旦发生故障,对于整体系统的影响是巨大的。另外 CDN 的带宽历来是我们研发成本的大头,尤其是目前处于小视频和直播风口上,大量的小视频和直播研发团队都在绞尽脑汁地减少 CDN 的成本。由此看出,CDN 是我们整体系统至关重要的组成部分,而它作为一种特殊的缓存,其命中率和可用性也是我们服务端开发人员需要重点关注的指标。

选学篇:数据的迁移怎么做?

本节课的重点内容:

双写的方案是数据库、Redis 迁移的通用方案,你可以在实际工作中直接加以使用。双写方案中最重要的,是通过数据校验来保证数据的一致性,这样就可以在迁移过程中随时回滚;

如果你需要将自建机房的数据迁移到云上,那么也可以考虑使用级联复制的方案,这种方案会造成数据的短暂停写,需要在业务低峰期执行;

缓存的迁移重点是保证云上缓存的命中率,你可以使用改进版的副本组方式来迁移,在缓存写入的时候异步写入云上的副本组,在读取时放少量流量到云上副本组,从而又可以迁移部分数据到云上副本组,又能尽量减少穿透给自建机房造成专线延迟的问题。

如果你作为项目的负责人,那么在迁移的过程中,你一定要制定周密的计划:如果是数据库的迁移,那么数据的校验应该是你最需要花费时间来解决的问题。

如果是自建机房迁移到云上,那么专线的带宽一定是你迁移过程中的一个瓶颈点,你需要在迁移之前梳理清楚有哪些调用需要经过专线,占用带宽的情况是怎样的,带宽的延时是否能够满足要求。你的方案中也需要尽量做到在迁移过程中同机房的服务调用同机房的缓存和数据库,尽量减少对于专线带宽资源的占用。

演进篇:消息队列篇

17.消息队列:秒杀时如何处理每秒上万次的下单请求?

课程小结

本节课,我结合自己的实际经验,主要带你了解了消息队列在高并发系统设计中起到的作用以及一些注意事项,你需要了解的重点如下:

  • 削峰填谷是消息队列最主要的作用,但是会造成请求处理的延迟。

  • 异步处理是提升系统性能的神器,但是你需要分清同步流程和异步流程的边界,同时消息存在着丢失的风险,我们需要考虑如何确保消息一定到达。

  • 解耦合可以提升你的整体系统的鲁棒性。

当然,你要知道,在使用消息队列之后虽然可以解决现有的问题,但是系统的复杂度也会上升。比如上面提到的业务流程中,同步流程和异步流程的边界在哪里?消息是否会丢失,是否会重复?请求的延迟如何能够减少?消息接收的顺序是否会影响到业务流程的正常执行?如果消息处理流程失败了之后是否需要补发?这些问题都是我们需要考虑的。我会利用接下来的两节课针对最主要的两个问题来讲讲解决思路:一个是如何处理消息的丢失和重复,另一个是如何减少消息的延迟。

引入了消息队列的同时也会引入了新的问题,需要新的方案来解决,这就是系统设计的挑战,也是系统设计独有的魅力,而我们也会在这些挑战中不断提升技术能力和系统设计能力。

18.消息投递:如何保证消息仅仅被消费一次?

18.1 消息丢失的场景

  • 消息从生产者写入到消息队列的过程;

  • 消息在消息队列中的存储场景;

  • 消息被消费者消费的过程。

img

18.2 生产过程中消息丢失原因及解决

原因:生产者与消息队列之间的网络抖动等问题

解决方案:消息重传,也就是当你发现发送超时后就将消息重新发一次,但也不能无限制地重传消息。一般来说,如果不是消息队列发生故障或者是到消息队列的网络断开了,重试 2~3 次就可以了。

18.3 在消息队列中丢失原因及结局

原因:以Kafka为例,消息在 Kafka 中是存储在本地磁盘上的,而为了减少消息存储时对磁盘的随机 I/O,我们一般会将消息先写入到操作系统的 Page Cache 中,然后再找合适的时机刷新到磁盘上。不过如果发生机器掉电或者机器异常重启,Page Cache 中还没有来得及刷盘的消息就会丢失了。

解决方案:一是设置很小的时间间隔进行刷盘,但是这种方案对性能影响较大。

二是搭建集群。如果你的系统对消息丢失的容忍度很低,你可以考虑以集群方式部署 Kafka 服务,通过部署多个副本备份数据保证消息尽量不丢失。

使用集群的建议:

  1. 如果你需要确保消息一条都不能丢失,那么建议不要开启消息队列的同步刷盘,而是用集群的方式来解决,可以配置当所有 ISR Follower 都接收到消息才返回成功。
  2. 如果对消息的丢失有一定的容忍度,那么建议不部署集群,即使以集群方式部署,也建议配置只发送给一个 Follower 就可以返回成功了。
  3. 我们的业务系统一般对于消息的丢失有一定的容忍度,比如说以上面的红包系统为例,如果红包消息丢失了,我们只要后续给没有发送红包的用户补发红包就好了。

18.4 在消费的过程中存在消息丢失的可能

原因:接收消息和处理消息的过程都可能会发生异常或者失败,比如消息接收时网络发生抖动,导致消息并没有被正确地接收到;处理消息时可能发生一些业务的异常导致处理流程未执行完成,这时如果更新消费进度,这条失败的消息就永远不会被处理了,也可以认为是丢失了。

18.5 如何保证消息只被消费一次

简单说,保持幂等性。幂等是一个数学上的概念,它的含义是多次执行同一个操作和执行一次操作,最终得到的结果是相同的

在消息生产过程中,在 Kafka0.11 版本和 Pulsar 中都支持“producer idempotency”的特性,翻译过来就是生产过程的幂等性,这种特性保证消息虽然可能在生产端产生重复,但是最终在消息队列存储时只会存储一份。

它的做法是给每一个生产者一个唯一的 ID,并且为生产的每一条消息赋予一个唯一 ID,消息队列的服务端会存储 < 生产者 ID,最后一条消息 ID> 的映射。当某一个生产者产生新的消息时,消息队列服务端会比对消息 ID 是否与存储的最后一条 ID 一致,如果一致就认为是重复的消息,服务端会自动丢弃。

img

而在消费端,幂等性的保证会稍微复杂一些,你可以从通用层和业务层两个层面来考虑。

在通用层面,你可以在消息被生产的时候使用发号器给它生成一个全局唯一的消息 ID,消息被处理之后把这个 ID 存储在数据库中,在处理下一条消息之前先从数据库里面查询这个全局 ID 是否被消费过,如果被消费过就放弃消费。

在业务层面怎么处理呢?这里有很多种处理方式,其中有一种是增加乐观锁的方式。比如你的消息处理程序需要给一个人的账号加钱,那么你可以通过乐观锁的方式来解决。

18.6 课程小结

本节课,我主要带你了解了在消息队列中消息可能会发生丢失的场景和应对方法,以及在消息重复的场景下,你要如何保证尽量不影响消息最终的处理结果。我想强调的重点是:

  • 消息的丢失可以通过生产端的重试、消息队列配置集群模式以及消费端合理处理消费进度三种方式来解决;

  • 为了解决消息的丢失通常会造成性能上的问题以及消息的重复问题;

  • 通过保证消息处理的幂等性可以解决消息的重复问题。

虽然我讲了很多应对消息丢失的方法,但并不是说消息丢失一定不能被接受,毕竟你可以看到在允许消息丢失的情况下,消息队列的性能更好,方案实现的复杂度也最低。比如像是日志处理的场景,日志存在的意义在于排查系统的问题,而系统出现问题的几率不高,偶发的丢失几条日志是可以接受的。

所以方案设计看场景,这是一切设计的原则,你不能把所有的消息队列都配置成防止消息丢失的方式,也不能要求所有的业务处理逻辑都要支持幂等性,这样会给开发和运维带来额外的负担。

19.消息队列:如何降低消息队列系统中消息的延迟?

19.1 如何监控消息延迟

监控消息的延迟有两种方式:

  • 使用消息队列提供的工具,通过监控消息的堆积来完成;

  • 通过生成监控消息的方式来监控消息的延迟情况

消息队列提供的工具

Kafka 提供了工具叫做“kafka-consumer-groups.sh”(它在 Kafka 安装包的 bin 目录下)。为了帮助你理解,我简单地搭建了一个 Kafka 节点并且写入和消费了一些信息,然后我来使用命令看看消息累积情况,具体的命令如下:

./bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group test-consumer-group

img

图中的前两列是队列的基本信息,包括话题名和分区名;

第三列是当前消费者的消费进度;

第四列是当前生产消息的总数;

第五列就是消费消息的堆积数(也就是第四列与第三列的差值)。

通过这个命令你可以很方便地了解消费者的消费情况。

JMS方式

Kafka 通过 JMX 暴露了消息堆积的数据,我在本地启动了一个 console consumer,然后使用 jconsole 连接 consumer 就可以看到 consumer 的堆积数据了(就是下图中红框里的数据)。这些数据你可以写代码来获取,这样也可以方便地输出到监控系统中,我比较推荐这种方式。

img

生成监控消息

除了使用消息队列提供的工具以外,你还可以通过生成监控消息的方式来监控消息的延迟。具体怎么做呢?

你先定义一种特殊的消息,然后启动一个监控程序将这个消息定时地循环写入到消息队列中,消息的内容可以是生成消息的时间戳并且也会作为队列的消费者消费数据。业务处理程序消费到这个消息时直接丢弃掉,而监控程序在消费到这个消息时就可以和这个消息的生成时间做比较,如果时间差达到某一个阈值就可以向我们报警。

img

19.2 减少消息延迟的正确姿势

想要减少消息的处理延迟,我们需要在消费端和消息队列两个层面来完成。

在消费端的目标是提升消费者的消息处理能力,你能做的是:

  • 优化消费代码提升性能;

  • 增加消费者的数量(这个方式比较简单)。

再来说说消息队列本身在读取性能优化方面做了哪些事情?

  • 消息的存储;
  • 零拷贝技术。

20.面试现场第二期:当问到项目经历时,面试官究竟想要了解什么?

20.1回答项目经历的套路

  1. 针对复杂的需求你设计了哪些方案,这些方案中技术的难点是什么,你在方案设计中是如何解决的?
  2. 在项目中遇到了哪些阴暗诡异的问题,你排查问题的思路是怎么样的?
  3. 在项目运维过程中出现了哪些性能方面的问题,你有事怎么来优化的?

20.2 项目回答案例

img

演进篇: 分布式服务

21. 系统架构:每秒1万次请求的系统要做服务化拆分吗?

21.1 课程小结(实际业务中会基于什么样的考虑对系统做微服务化拆分)

实际业务中会基于什么样的考虑对系统做微服务化拆分,其实系统的 QPS 并不是决定性的因素。影响的因素我归纳为以下几点:

  • 系统中使用的资源出现扩展性问题,尤其是数据库的连接数出现瓶颈;

  • 大团队共同维护一套代码,带来研发效率的降低和研发成本的提升;

  • 系统部署成本越来越高。

从中你应该有所感悟:在架构演进的初期和中期,性能、可用性、可扩展性是我们追求的主要目标,高性能和高可用给用户带来更好的使用体验,扩展性可以方便我们支撑更大量级的并发。但是当系统做的越来越大,团队成员越来越多,我们就不得不考虑成本了。这里面的“成本”有着复杂的含义,它不仅代表购买服务器的费用,还包括研发团队,内部的开发成本,沟通成本以及运维成本等等,甚至有些时候,成本会成为架构设计中的决定性因素。

22. 微服务架构:微服务化后系统架构要如何改造?

课程解答问题:

  • 服务拆分时要遵循哪些原则?

  • 服务的边界如何确定?服务的粒度是怎样的?

  • 在服务化之后会遇到哪些问题呢?我们又将如何来解决?

22.1微服务拆分原则

原则一,做到单一服务内部功能的高内聚和低耦合

也就是说每个服务只完成自己职责之内的任务,对于不是自己职责的功能交给其它服务来完成

原则二,你需要关注服务拆分的粒度,先粗略拆分再逐渐细化

推荐的做法是:拆分初期可以把服务粒度拆得粗一些,后面随着团队对于业务和微服务理解的加深,再考虑把服务粒度细化。比如对于一个社区系统来说,你可以先把和用户关系相关的业务逻辑,都拆分到用户关系服务中,之后,再把比如黑名单的逻辑独立成黑名单服务。

原则三,拆分的过程,要尽量避免影响产品的日常功能迭代

剥离的顺序你可以参考以下几点:

  1. 优先剥离比较独立的边界服务(比如短信服务、地理位置服务),从非核心的服务出发减少拆分对现有业务的影响,也给团队一个练习、试错的机会;

  2. 当两个服务存在依赖关系时优先拆分被依赖的服务。比如内容服务依赖于用户服务获取用户的基本信息,那么如果先把内容服务拆分出来,内容服务就会依赖于一体化架构中的用户模块,这样还是无法保证内容服务的快速部署能力。

    所以正确的做法是理清服务之间的调用关系,比如内容服务会依赖用户服务获取用户信息,互动服务会依赖内容服务,所以要按照先用户服务再内容服务,最后互动服务的顺序来进行拆分。

原则四,服务接口的定义要具备可扩展性

服务接口的参数类型最好是封装类,这样如果增加参数就不必变更接口的签名,而只需要在类中添加字段就可以了。

22.2微服务化带来的问题和解决思路

为了在分布式环境下协调多个服务正常运行,就必然引入一定的复杂度,这些复杂度主要体现在以下几个方面:

  1. 服务接口的调用不再是同一进程内的方法调用而是跨进程的网络调用,这会增加接口响应时间的增加。此时我们就要选择高效的服务调用框架,同时接口调用方需要知道服务部署在哪些机器的哪个端口上,这些信息需要存储在一个分布式一致性的存储中,于是就需要引入服务注册中心,这一点,是我在 24 讲会提到的内容。不过在这里我想强调的是,注册中心管理的是服务完整的生命周期,包括对于服务存活状态的检测。

  2. 多个服务之间有着错综复杂的依赖关系。一个服务会依赖多个其它服务也会被多个服务所依赖,那么一旦被依赖的服务的性能出现问题产生大量的慢请求,就会导致依赖服务的工作线程池中的线程被占满,依赖的服务也会出现性能问题。接下来问题就会沿着依赖网逐步向上蔓延,直到整个系统出现故障为止。

    为了避免发生这种情况,我们需要引入服务治理体系针对出问题的服务采用熔断、降级、限流、超时控制的方法,使问题被限制在单一服务中,保护服务网络中的其它服务不受影响。

  3. 服务拆分到多个进程后,一条请求的调用链路上涉及多个服务,那么一旦这个请求的响应时间增长或者是出现错误,我们就很难知道是哪一个服务出现的问题。另外,整体系统一旦出现故障,很可能外在的表现是所有服务在同一时间都出现了问题,你在问题定位时很难确认哪一个服务是源头,这就需要引入分布式追踪工具,以及更细致的服务端监控报表。

23. RPC框架:10万QPS下如何实现毫秒级的服务调用?

23.1 课程总结

为了优化 RPC 框架的性能,本节课我带你了解了网络 I/O 模型和序列化方式的选择,它们是实现高并发 RPC 框架的要素,总结起来有三个要点:

  • 选择高性能的 I/O 模型,这里我推荐使用同步多路 I/O 复用模型;

  • 调试网络参数,这里面有一些经验值的推荐。比如将 tcp_nodelay 设置为 true,也有一些参数需要在运行中来调试,比如接受缓冲区和发送缓冲区的大小,客户端连接请求缓冲队列的大小(back log)等等;

  • 序列化协议依据具体业务来选择。如果对性能要求不高可以选择 JSON,否则可以从 Thrift 和 Protobuf 中选择其一。

23.2 RPC调用过程

那么我们要如何优化 RPC 的性能,从而尽量减少网络调用对于性能的影响呢?在这里,你首先需要了解一次 RPC 的调用都经过了哪些步骤,因为这样你才可以针对这些步骤中可能存在的性能瓶颈点提出优化方案。步骤如下:

  1. 在一次 RPC 调用过程中,客户端首先会将调用的类名、方法名、参数名、参数值等信息,序列化成二进制流;
  2. 然后客户端将二进制流通过网络发送给服务端;
  3. 服务端接收到二进制流之后将它反序列化,得到需要调用的类名、方法名、参数名和参数值,再通过动态代理的方式调用对应的方法得到返回值;
  4. 服务端将返回值序列化,再通过网络发送给客户端;
  5. 客户端对结果反序列化之后,就可以得到调用的结果了。

24. 注册中心:分布式系统如何寻址?

课程总结

了解了在微服务架构中,注册中心是如何实现服务的注册和发现的,以及在实现中遇到的一些坑,除此之外,我还带你了解了服务治理的含义,以及后续我们会讲到的一些技术点。在这节课中,我想让你明确的重点如下:

  • 注册中心可以让我们动态地变更 RPC 服务的节点信息,对于动态扩缩容,故障快速恢复,以及服务的优雅关闭都有重要的意义;

  • 心跳机制是一种常见的探测服务状态的方式,你在实际的项目中也可以考虑使用;

  • 我们需要对注册中心中管理的节点提供一些保护策略,避免节点被过度摘除导致的服务不可用。

你看,注册中心虽然是一种简单易懂的分布式组件,但是它在整体架构中的位置至关重要,不容忽视。同时,在它的设计方案中,也蕴含了一些系统设计的技巧,比如上面提到的服务状态检测的方式,还有上面提到的优雅关闭的方式,了解注册中心的原理,会给你之后的研发工作提供一些思路。

25.分布式Trace:横跨几十个分布式组件的慢请求要如何排查?

25.1 课程总结

本节课我带你了解了在一体化架构和服务化架构中,你要如何排查单次慢请求中,究竟哪一个步骤是瓶颈,这里你需要了解的主要有以下几个重点:

  • 服务的追踪的需求主要有两点,一点对代码要无侵入,你可以使用切面编程的方式来解决;另一点是性能上要低损耗,我建议你采用静态代理和日志采样的方式,来尽量减少追踪日志对于系统性能的影响;

  • 无论是单体系统还是服务化架构,无论是服务追踪还是业务问题排查,你都需要在日志中增加 requestId,这样可以将你的日志串起来,给你呈现一个完整的问题场景。如果 requestId 可以在客户端上生成,在请求业务接口的时候传递给服务端,那么就可以把客户端的日志体系也整合进来,对于问题的排查帮助更大。

其实,分布式追踪系统不是一项新的技术,而是若干项已有技术的整合,在实现上并不复杂,却能够帮助你实现跨进程调用链展示、服务依赖分析,在性能优化和问题排查方面提供数据上的支持。所以,在微服务化过程中,它是一个必选项,无论是采用 Zipkin,Jaeger 这样的开源解决方案,还是团队内自研,你都应该在微服务化完成之前,尽快让它发挥应有的价值。

25.2排查单次请求响应时间长的做法

  1. 在记录打点日志时,我们使用 requestId 将日志串起来,这样方便比较一次请求中的多个步骤的耗时情况;
  2. 我们使用静态代理的方式做切面编程,避免在业务代码中,加入大量打印耗时的日志的代码,减少了对于代码的侵入性,同时编译期的代码注入可以减少;
  3. 我们增加了日志采样率,避免全量日志的打印;
  4. 最后为了避免在排查问题时需要到多台服务器上搜索日志,我们使用消息队列将日志集中起来放在了 Elasticsearch 中。

25.3 如何做分布式Trace

你可能会问:题目既然是“分布式 Trace:横跨几十个分布式组件的慢请求要如何排查?”,那么我为什么要花费大量的篇幅来说明在一体化架构中如何排查问题呢?这是因为在分布式环境下,你基本上也是依据上面我提到的这几点来构建分布式追踪的中间件的。

在一体化架构中,单次请求的所有的耗时日志都被记录在一台服务器上,而在微服务的场景下,单次请求可能跨越多个 RPC 服务,这就造成了单次的请求的日志会分布在多个服务器上。

当然,你也可以通过 requestId 将多个服务器上的日志串起来,但是仅仅依靠 requestId 很难表达清楚服务之间的调用关系,所以从日志中就无法了解服务之间是谁在调用谁。因此,我们采用 traceId + spanId 这两个数据维度来记录服务之间的调用关系(这里 traceId 就是 requestId),也就是使用 traceId 串起单次请求,用 spanId 记录每一次 RPC 调用。

img

26. 负载均衡:怎样提升系统的横向扩展能力?

26.1课程总结

本节课,我带你了解了与负载均衡服务相关的一些知识点,以及在实际工作中的运用技巧。我想强调几个重点:

  • 网站负载均衡服务的部署,是以 LVS 承接入口流量,在应用服务器之前,部署 Nginx 做细化的流量分发和故障节点检测。当然,如果你的网站的并发不高,也可以考虑不引入 LVS。

  • 负载均衡的策略可以优先选择动态策略,保证请求发送到性能最优的节点上;如果没有合适的动态策略,那么可以选择轮询的策略,让请求平均分配到所有的服务节点上。

  • Nginx 可以引入 nginx_upstream_check_module,对后端服务做定期的存活检测,后端的服务节点在重启时,也要秉承着“先切流量后重启”的原则,尽量减少节点重启对于整体系统的影响。

你可能会认为,像 Nginx、LVS 应该是运维所关心的组件,作为开发人员不用操心维护。不过通过今天的学习你应该可以看到:负载均衡服务是提升系统扩展性和性能的重要组件,在高并发系统设计中,它发挥的作用是无法替代的。理解它的原理,掌握使用它的正确姿势,应该是每一个后端开发同学的必修课。

27. API网关:系统的门面要如何做呢?

27.1 课程总结

本节课我带你了解了 API 网关在系统中的作用,在实现中的一些关键的点,以及如何将 API 网关引入你的系统,我想强调的重点如下:

  1. API 网关分为入口网关和出口网关两类,入口网关作用很多,可以隔离客户端和微服务,从中提供协议转换、安全策略、认证、限流、熔断等功能。出口网关主要是为调用第三方服务提供统一的出口,在其中可以对调用外部的 API 做统一的认证、授权、审计以及访问控制;

  2. API 网关的实现重点在于性能和扩展性,你可以使用多路 I/O 复用模型和线程池并发处理,来提升网关性能,使用责任链模式来提升网关的扩展性;

  3. API 网关中的线程池可以针对不同的接口或者服务做隔离和保护,这样可以提升网关的可用性;

  4. API 网关可以替代原本系统中的 Web 层,将 Web 层中的协议转换、认证、限流等功能挪入到 API 网关中,将服务聚合的逻辑下沉到服务层。

API 网关可以为 API 的调用提供便捷,也可以为将一些服务治理的功能独立出来,达到复用的目的,虽然在性能上可能会有一些损耗,但是一般来说,使用成熟的开源 API 网关组件,这些损耗都是可以接受的。所以,当你的微服务系统越来越复杂时,你可以考虑使用 API 网关作为整体系统的门面。

28. 多机房部署:跨地域的分布式系统如何做?

28.1 课程总结(暂用不到)

本节课,为了提升系统的可用性和稳定性,我带你探讨了多机房部署的难点以及同城双机房和异地多活的部署架构,在这里,我想强调几个重点:

  • 不同机房的数据传输延迟是造成多机房部署困难的主要原因,你需要知道,同城多机房的延迟一般在 1ms~3ms,异地机房的延迟在 50ms 以下,而跨国机房的延迟在 200ms 以下。

  • 同城多机房方案可以允许有跨机房数据写入的发生,但是数据的读取和服务的调用应该尽量保证在同一个机房中。

  • 异地多活方案则应该避免跨机房同步的数据写入和读取,而是采取异步的方式,将数据从一个机房同步到另一个机房。

多机房部署是一个业务发展到一定规模,对于机房容灾有需求时才会考虑的方案,能不做则尽量不要做。一旦你的团队决定做多机房部署,那么同城双活已经能够满足你的需求了,这个方案相比异地多活要简单很多。而在业界,很少有公司能够搭建一套真正的异步多活架构,这是因为这套架构在实现时过于复杂,所以,轻易不要尝试。总之,架构需要依据系统的量级和对可用性、性能、扩展性的要求,不断演进和调整,盲目地追求架构的“先进性”只能造成方案的复杂,增加运维成本,从而给你的系统维护带来不便。

29. Service Mesh:如何屏蔽服务化系统的服务治理细节?(仅了解)

29.1 课程总结

本节课,为了解决跨语言场景下服务治理策略的复用问题,我带你了解了什么是 Service Mesh,以及如何在实际项目中落地,你需要的重点内容如下:

  1. Service Mesh 分为数据平面和控制平面。数据平面主要负责数据的传输;控制平面用来控制服务治理策略的植入。出于性能的考虑,一般会把服务治理策略植入到数据平面中,控制平面负责服务治理策略数据的下发。
  2. Sidecar 的植入方式目前主要有两种实现方式,一种是使用 iptables 实现流量的劫持;另一种是通过轻量级客户端来实现流量转发。

目前,在一些大厂中,比如微博、蚂蚁金服,Service Mesh 已经开始在实际项目中大量的落地实践,而我建议你持续关注这项技术。它本身是一种将业务与通信基础设施分离的技术,如果你的业务上遇到多语言环境下服务治理的困境;如果你的遗留服务,需要快速植入服务治理策略;如果你想要将你在服务治理方面积累的经验快速地与其他团队共享,那么 Service Mesh 就是一个不错的选择。

演进篇:维护篇

30. 给系统加上眼睛:服务端监控要怎么做?(了解)

30.1 课程总结

本节课,我带你了解了服务端监控搭建的过程,在这里,你需要了解以下几个重点:

  1. 耗时、请求量和错误数是三种最通用的监控指标,不同的组件还有一些特殊的监控指标,你在搭建自己的监控系统的时候可以直接使用;

  2. Agent、埋点和日志是三种最常见的数据采集方式;

  3. 访问趋势报表用来展示服务的整体运行情况,性能报表用来分析资源或者依赖的服务是否出现问题,资源报表用来追查资源问题的根本原因。这三个报表共同构成了你的服务端监控体系。

总之,监控系统是你发现问题,排查问题的重要工具,你应该重视它,并且投入足够的精力来不断地完善它。只有这样,才能不断地提高对系统运维的掌控力,降低故障发生的风险。

实战篇

37. 计数系统设计(一):面对海量数据的计数器要如何做?

37.1 课程总结

  1. 一开始用mysql进行计数,后来加入了主从架构,分库分表架构。
  2. 因为计数访问量太大了,加入了缓存,但是这个会造成相应的那个缓存和数据库数据不一致,如果要保证一性的话,就需要采用内存队列,对于同一个id的数量只能用单线程进行处理,这个会造成性能问题。
  3. 后来直接抛弃了mysql,直接用redis cluster来支持计数服务,因为redis通过rdb和aof来支持持久化,可以通过设置保证至少有一台从redis机器同步了数据,从redis来做相应的那个持久化操作达到数据不丢失,因为原生的redis数据结构会占用比较多的字节,这里直接进行改造,让redis的数据结构占用内存加少。
  4. 但是redis是全内存的,随着量越来越大肯定没法支持了,这里进行改造,引入ssd,支持把冷数据放到ssd中,热数据在内存中,当要访问冷数据时利用一个线程异步把冷数据加载到一个cold cache里面去。这个有很多开源的实现,如Pika,SSDB用ssd来替代内存存储冷数据。

38.计数系统设计(二):50万QPS下如何设计未读数系统?

38.1 课程总结

以上就是本节课的全部内容了,本节课我带你了解了未读数系统的设计,这里你需要了解的重点是:

  1. 评论未读、@未读、赞未读等一对一关系的未读数可以使用上节课讲到的通用计数方案来解决;

  2. 在系统通知未读、全量用户打点等存在有限的共享存储的场景下,可以通过记录用户上次操作的时间或者偏移量,来实现未读方案;

  3. 最后,信息流未读方案最为复杂,采用的是记录用户博文数快照的方式。

这里你可以看到,这三类需求虽然都和未读数有关,但是需求场景不同、对于量级的要求不同,设计出来的方案也就不同。因此,就像我刚刚提到的样子,你在做方案设计的时候,要分析需求的场景,比如说数据的量级是怎样的,请求的量级是怎样的,有没有一些可以利用的特点(比如系统通知未读场景下的有限共享存储、信息流未读场景下关注人数是有限的等等),然后再制定针对性的方案,切忌盲目使用之前的经验套用不同的场景,否则就可能造成性能的下降,甚至危害系统的稳定性。

马建仓 AI 助手
尝试更多
代码解读
代码找茬
代码优化
1
https://gitee.com/lordum/java-study-notes.git
git@gitee.com:lordum/java-study-notes.git
lordum
java-study-notes
JavaStudyNotes
master

搜索帮助

344bd9b3 5694891 D2dac590 5694891