Dunwu Blog

大道至简,知易行难

链路追踪

链路追踪简介

什么是链路追踪

链路追踪系统广义的概念是:由数据采集数据处理数据展示三个相对独立的模块所构成的分布式追踪系统;链路追踪系统狭义的概念是:特指链路追踪的数据采集。譬如 Spring Cloud Sleuth 就属于狭义的链路追踪系统,通常会搭配 Zipkin 作为数据展示,搭配 Elasticsearch 作为数据存储来组合使用;而 ZipkinPinpointSkyWalkingCAT 都属于广义的链路追踪系统。

个人理解,链路追踪的本质就是,通过全局唯一的 ID,将分布在各个服务节点上的同一次请求产生的数据串联起来,从而梳理出调用关系,进而辅助分析系统问题、分析调用数据并统计各种系统指标。

为什么需要链路追踪

链路追踪主要有以下作用

  • 分析系统瓶颈:通过记录调用经过的每一条链路上的耗时,我们能快速定位整个系统的瓶颈点在哪里。比如你访问微博首页发现很慢,肯定是由于某种原因造成的,有可能是运营商网络延迟,有可能是网关系统异常,有可能是某个服务异常,还有可能是缓存或者数据库异常。通过链路追踪,可以从全局视角上去观察,找出整个系统的瓶颈点所在,然后做出针对性的优化。
  • 分析链路调用:通过链路追踪可以分析调用所经过的路径,然后评估是否合理。比如一个服务调用下游依赖了多个服务,通过调用链分析,可以评估是否每个依赖都是必要的,是否可以通过业务优化来减少服务依赖。还有就是,一般业务都会在多个数据中心都部署服务,以实现异地容灾,这个时候经常会出现一种状况就是服务 A 调用了另外一个数据中心的服务 B,而没有调用同处于一个数据中心的服务 B。根据我的经验,跨数据中心的调用视距离远近都会有一定的网络延迟,像北京和广州这种几千公里距离的网络延迟可能达到 30ms 以上,这对于有些业务几乎是不可接受的。通过对调用链路进行分析,可以找出跨数据中心的服务调用,从而进行优化,尽量规避这种情况出现。
  • 生成网络拓扑:通过链路追踪中记录的链路信息,可以生成一张系统的网络调用拓扑图,它可以反映系统都依赖了哪些服务,以及服务之间的调用关系是什么样的,可以一目了然。除此之外,在网络拓扑图上还可以把服务调用的详细信息也标出来,也能起到服务监控的作用。
  • 透明传输数据:除了链路追踪,业务上经常有一种需求,期望能把一些用户数据,从调用的开始一直往下传递,以便系统中的各个服务都能获取到这个信息。比如业务想做一些 A/B 测试,这时候就想通过链路追踪,把 A/B 测试的开关逻辑一直往下传递,经过的每一层服务都能获取到这个开关值,就能够统一进行 A/B 测试。

链路追踪原理

Google 发布的一篇的论文 Dapper, a Large-Scale Distributed Systems Tracing Infrastructure,里面详细讲解了链路追踪的实现原理。Dapper 论文几乎成了现代链路追踪的理论基石,很多主流的链路追踪系统都是基于 Dapper 衍生出来的,比较有名的有 Twitter 的Zipkin、阿里的鹰眼、美团的MTrace等。

链路追踪核心概念

Dapper 提出了一些很重要的核心概念:Trace、Span、Annonation 等,这是理解链路追踪原理的前提。

Trace 和 Spans(图片来源于Dapper 论文

  • Trace (追踪) - 代表一次完整的请求。一次完整的请求是指,从客户端发起请求,记录请求流转的每一个服务,直到客户端收到响应为止。整个过程中,当请求分发到第一层级的服务时,就会生成一个全局唯一的 Trace ID,并且会随着请求分发到每一层级。因此,通过 Trace ID 就可以把一次用户请求在系统中调用的链路串联起来。
  • Span (跨度) - 代表一次调用,也是链路追踪的基本单元。由于每次 Trace 都可能会调用数量不定、坐标不定的多个服务,为了能够记录具体调用了哪些服务,以及调用的顺序、开始时点、执行时长等信息,每次开始调用服务前都要先埋入一个调用记录,这个记录称为一个 Span。
    • Span 的数据结构应该足够简单,以便于能放在日志或者网络协议的报文头里;也应该足够完备,起码应含有时间戳、起止时间、Trace 的 ID、当前 Span 的 ID、父 Span 的 ID 等能够满足追踪需要的信息。
    • Trace 实际上都是由若干个有顺序、有层级关系的 Span 所组成一颗 Trace Tree (追踪树)。
  • Annotation:用于业务自定义埋点数据,例如:一次请求的用户 ID,某一个支付订单的订单 ID 等。

数据埋点阶段

数据采集的作用就是在系统的各个不同模块中进行埋点,采集数据并上报给数据处理层进行处理。而一次请求可以分为四个阶段:

  • CS(Client Send)阶段 - 客户端发起请求时埋点,需要传递一些参数,比如服务的方法名等。
  • SR(Server Recieve)阶段 - 服务端接收请求时埋点,需要回填一些参数,比如 traceId,spanId。
  • SS(Server Send)阶段 - 服务端返回请求时埋点,这时会将上下文数据传递到异步上传队列中。
  • CR(Client Recieve)阶段 - 客户端接收返回结果时埋点,这时会将上下文数据传递到异步上传队列中。

下图显示了 Span 和 Trace 在系统中的样子。

img

(图片来源于 spring-cloud-sleuth 文档

图片说明:

每种颜色表示一个跨度(有七个跨度 - 从 A 到 G)

1
2
3
Trace Id = X
Span Id = D
Client Sent

类似上面的注释,表示当前跨度的跟踪 ID 设置为 X,跨度 ID 设置为 D。此外,从 RPC 的角度来看,发生了客户端发送事件。

下图显示了 span 的父子关系:

(图片来源于 spring-cloud-sleuth 文档

链路追踪实现

一个完整的数据链路系统大致可以分为三个相对独立的模块:

  • 数据采集 - 负责数据埋点并上报。
  • 数据处理 - 负责数据的存储与计算。
  • 数据展示 - 负责数据的可视化展示。

数据采集

数据采集负责数据埋点并上报。数据采集有三种主流的实现方式,分别是基于日志的追踪(Log-Based Tracing),基于服务的追踪(Service-Based Tracing)和基于边车代理的追踪(Sidecar-Based Tracing)。

基于日志的追踪

基于日志的追踪的思路是:将 Trace、Span 等信息直接输出到应用日志中,然后随着所有节点的日志采集汇聚到一起,再从全局日志信息中反推出完整的调用链拓扑关系。

基于日志的追踪有以下特点:

  • 侵入性小、性能影响低 - 对网络消息完全没有侵入性,对应用程序只有很少量的侵入性,对性能影响也非常低。
  • 依赖于日志采集过程,导致不够实时、精准 - 直接依赖于日志采集过程,日志本身不追求绝对的连续与一致,这也使得基于日志的追踪往往不如其他两种追踪实现来的精准。另外,业务服务的调用与日志的归集并不是同时完成的,也通常不由同一个进程完成,有可能发生业务调用已经顺利结束了,但由于日志归集不及时或者精度丢失,导致日志出现延迟或缺失记录,进而产生追踪失真。

日志追踪的代表产品是 Spring Cloud Sleuth,下面是一段由 Sleuth 在调用时自动生成的日志记录,可以从中观察到 TraceID、SpanID、父 SpanID 等追踪信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
# 以下为调用端的日志输出:
Created new Feign span [Trace: cbe97e67ce162943, Span: bb1798f7a7c9c142, Parent: cbe97e67ce162943, exportable:false]
2019-06-30 09:43:24.022 [http-nio-9010-exec-8] DEBUG o.s.c.s.i.web.client.feign.TraceFeignClient - The modified request equals GET http://localhost:9001/product/findAll HTTP/1.1

X-B3-ParentSpanId: cbe97e67ce162943
X-B3-Sampled: 0
X-B3-TraceId: cbe97e67ce162943
X-Span-Name: http:/product/findAll
X-B3-SpanId: bb1798f7a7c9c142

# 以下为服务端的日志输出:
[findAll] to a span [Trace: cbe97e67ce162943, Span: bb1798f7a7c9c142, Parent: cbe97e67ce162943, exportable:false]
Adding a class tag with value [ProductController] to a span [Trace: cbe97e67ce162943, Span: bb1798f7a7c9c142, Parent: cbe97e67ce162943, exportable:false]

基于服务的追踪

基于服务的追踪是目前最为常见的实现方式,被 ZipkinPinpointSkyWalking 等主流链路追踪系统广泛采用。其实现思路是:通过某些手段给目标应用注入追踪探针(Probe),针对 Java 应用一般就是通过 Java Agent 注入的。探针在结构上可视为一个寄生在目标服务身上的小型微服务系统,它一般会有自己专用的服务注册、心跳检测等功能,有专门的数据收集协议,把从目标系统中监控得到的服务调用信息,通过另一次独立的 HTTP 或者 RPC 请求发送给追踪系统。

基于服务的追踪有以下特点:

  • 侵入性强,会有性能损耗
  • 追踪更加精准、稳定

因此,基于服务的追踪会比基于日志的追踪消耗更多的资源,也有更强的侵入性,换来的收益是追踪的精确性与稳定性都有所保证,不必再依靠日志归集来传输追踪数据。

基于边车代理的追踪

基于边车代理的追踪是服务网格的专属方案,也是最理想的分布式追踪模型,它对应用完全透明,无论是日志还是服务本身都不会有任何变化;它与编程语言无关,无论应用采用什么编程语言实现,只要它还是通过网络(HTTP 或者 gRPC)来访问服务就可以被追踪到;它有自己独立的数据通道,追踪数据通过控制平面进行上报,避免了追踪对程序通信或者日志归集的依赖和干扰,保证了最佳的精确性。如果要说这种追踪实现方式还有什么缺点的话,那就是服务网格现在还不够普及,未来随着云原生的发展,相信它会成为追踪系统的主流实现方式之一。还有就是边车代理本身的对应用透明的工作原理决定了它只能实现服务调用层面的追踪,本地方法调用级别的追踪诊断是做不到的。

现在市场占有率最高的代理 Envoy 就提供了相对完善的追踪功能,但没有提供自己的界面端和存储端,所以 Envoy 和 Sleuth 一样都属于狭义的追踪系统,需要配合专门的 UI 与存储来使用,现在 ZipkinSkyWalkingJaegerLightStep Tracing 等系统都可以接受来自于 Envoy 的追踪数据,充当它的界面端。

数据处理

数据处理负责数据的存储与计算,就是将数据采集的数据按需计算,然后落地存储供查询使用。

数据处理的需求一般分为两类,一类是实时计算需求,一类是离线计算需求。

实时计算需求对计算效率要求比较高,一般要求对收集的链路数据能够在秒级别完成聚合计算,以供实时查询。而离线计算需求对计算效率要求就没那么高了,一般能在小时级别完成链路数据的聚合计算即可,一般用作数据汇总统计。针对这两类不同的数据处理需求,采用的计算方法和存储也不相同。

  • 实时数据处理:针对实时数据处理,一般采用 Flink、Storm、Spark Streaming 来对链路数据进行实时聚合加工,存储一般使用 OLTP 数据仓库,比如 HBase,使用 traceId 作为 RowKey,能天然地把一整条调用链聚合在一起,提高查询效率。
  • 离线数据处理:针对离线数据处理,一般通过运行 MapReduce 或者 Spark 批处理程序来对链路数据进行离线计算,存储一般使用 Hive。

数据展示

数据展示层的作用就是将处理后的链路信息以图形化的方式展示给用户。

实际项目中主要用到两种图形展示,一种是调用链路图,一种是调用拓扑图。

调用链路图

调用链路图一般展示服务总耗时、服务调用的网络深度、每一层经过的系统,以及多少次调用。调用链路图在实际项目中,主要是被用来做故障定位,比如某一次用户调用失败了,可以通过调用链路图查询这次用户调用经过了哪些环节,到底是哪一层的调用失败所导致。

下面是 Zipkin 的调用链路图:

img

调用拓扑图

调用拓扑图一般展示系统内都包含哪些应用,它们之间是什么关系,以及依赖调用的 QPS、平均耗时情况。调用拓扑图是一种全局视野图,在实际项目中,主要用作全局监控,用于发现系统中异常的点,从而快速做出决策。比如,某一个服务突然出现异常,那么在调用链路拓扑图中可以看出对这个服务的调用耗时都变高了,可以用红色的图样标出来,用作监控报警。

下面是 Pinpoint 的调用链路图:

img

链路追踪主流技术

链路追踪的主流开源产品比较丰富,主要有:

  • Zipkin - Zipkin 是 Twitter 开源的调用链分析工具,目前基于 spring-cloud-sleuth 得到了广泛的使用,特点是轻量,使用、部署简单。
  • Pinpoint - 是韩国人开源的基于字节码注入的调用链分析,以及应用监控分析工具。特点是支持多种插件,UI 功能强大,接入端无代码侵入。
  • SkyWalking - 是本土开源的基于字节码注入的调用链分析,以及应用监控分析工具。特点是支持多种插件,UI 功能较强,接入端无代码侵入。目前已加入 Apache 孵化器。
  • CAT - CAT 是美团点评开源的基于编码和配置的调用链分析,应用监控分析,日志采集,监控报警等一系列的监控平台工具。
  • OpenTelemetry - OpenCensus 和 OpenTracing 两个项目的合并。OpenTelemetry 是工具、API 和 SDK 的集合。用于检测、生成、收集和导出遥测数据(指标、日志和和追踪),以辅助分析软件的性能和行为。
  • OpenTracing - 是一套与平台无关、与厂商无关、与语言无关的追踪协议规范。官方提供多种语言的链路追踪库实现。目前官方已经不再维护。

参考资料

如何建设监控体系

当服务消费者与服务提供者之间建立了通信,作为管理者需要通过监控手段来观察服务是否正常,调用是否成功。服务监控是很复杂的,在微服务架构下,一次用户调用会因为服务化拆分后,变成多个不同服务之间的相互调用,这也就需要对拆分后的每个服务都监控起来。

监控的意义

  • 发现问题:当系统出现问题或故障,监控系统应根据监控对象的数据异常,及时发现问题,触发告警。
  • 定位问题:监控系统的告警提示,通常应该指明问题的影响范围(如某机器 IP、某机房),触发故障的内容(数据库、MQ 或某服务的某监控数据异常),触发时间等等。有了这些必要的信息,有利于工程师分析问题时缩小排查范围,更快找到问题原因。
  • 解决问题:一旦分析清楚故障的原因后,就需要根据故障的重要度、紧急程度、影响范围等要素,去决定应该如何应对故障。
  • 总结问题:如果发生了重大故障后,需要对故障进行复盘,总结故障的原因和应对故障时的措施,思考在事前有没有更好的防范手段;在事后的应对故障的处理有没有改进的空间。

监控目标

  • 对系统不间断实时监控:实际上是对系统不间断的实时监控(这就是监控)
  • 实时反馈系统当前状态:我们监控某个硬件、或者某个系统,都是需要能实时看到当前系统的状态,是正常、异常、或者故障
  • 保证服务的可靠性、安全性:我们监控的目的就是要保证系统、服务、业务正常运行
  • 保证业务持续稳定运行:如果我们的监控做得很完善,即使出现故障,能第一时间接收到故障告警,在第一时间处理解决,从而保证业务持续性的稳定运行。

监控方法

  • 明确监控对象:根据业务和系统的实际需要,明确需要监控的对象。
  • 确定性能基准指标:确定了监控对象,接下来,要确定该监控对象的性能基准。如:CPU 使用率、吞吐量等。
  • 定义告警阈值:监控对象什么情况是正常的,什么情况是异常的,什么情况是有故障的?
  • 故障处理流程:当监控对象达到告警阈值时,应如何应对?触发怎样的告警?有没有自动化处理机制,如弹性扩容等?有没有熔断、降级等?

监控流程

一旦明确了要监控的对象,接下就是考虑如何监控。

完整的监控流程主要包括以下环节:采集、传输、存储、分析、展示、告警、处理。

数据采集

通常有两种数据收集方式:

  • 服务主动上报:这种处理方式通过在业务代码或者服务框架里加入数据收集代码逻辑,在每一次服务调用完成后,主动上报服务的调用信息。这种方式在链路跟踪中较为常见,主流的技术方案有:Zipkin。
  • 代理收集:这种处理方式通过服务调用后把调用的详细信息记录到本地日志文件中,然后再通过代理去解析本地日志文件,然后再上报服务的调用信息。主流的技术方案有:ELK、Flume。

数据传输

数据传输最常用的方式有两种:

  • UDP 传输:这种处理方式是数据处理单元提供服务器的请求地址,数据采集后通过 UDP 协议与服务器建立连接,然后把数据发送过去。
  • Kafka 传输:这种处理方式是数据采集后发送到指定的 Topic,然后数据处理单元再订阅对应的 Topic,就可以从 Kafka 消息队列中读取到对应的数据。由于 Kafka 有非常高的吞吐能力,所以很适合作为大数据量的缓冲池。

数据存储

上报的监控数据需要存储,不同监控系统选择的存储非常多样化。比较常见的有:

  • 时序数据库:InfluxDB(如:Prometheus)
  • 列式数据库:OpenTSDB 用 Hbase 存储所有时序(无须采样)的数据,来构建一个分布式、可伸缩的时间序列数据库。它支持秒级数据采集,支持永久存储,可以做容量规划,并很容易地接入到现有的告警系统里。
  • SQL 数据库:Zabbix 使用关系型数据库 Mysql 存储数据。
  • 搜索引擎数据库:ELK 使用 Elasticsearch 存储数据,以倒排索引的数据结构存储,需要查询的时候,根据索引来查询。

数据处理

数据处理是对收集来的原始数据进行聚合计算并存储。数据聚合通常有两个维度:

  • 接口维度聚合:这个维度是把实时收到的数据按照接口名维度实时聚合在一起,这样就可以得到每个接口的每秒请求量、平均耗时、成功率等信息。
  • 机器维度聚合:这个维度是把实时收到的数据按照调用的节点维度聚合在一起,这样就可以从单机维度去查看每个接口的实时请求量、平均耗时等信息。

数据展示

数据展示是把处理后的数据以 Dashboard 的方式展示给用户。数据展示有多种方式,比如曲线图、饼状图、格子图展示等。

监控告警

监控告警的形式很多,如:电话告警、邮件告警、短信告警、IM 告警等。

此外,告警需要根据甄别故障的影响范围,以确定故障级别,如:重要度、紧急度等。根据故障的级别,通知需要介入的人员,快速响应处理。

监控对象

服务监控一定是通过观察数据来量化分析,所以首先要明确需要监控什么。

一般来说,服务监控数据有以下分类:

  • 基础层监控
    • CPU:CPU 利用率、用户态利用率、内核态利用率、单核平均负载
    • 内存:内存使用量、内存剩余量
    • 磁盘:磁盘使用量、磁盘使用率
    • 网络:网络流量、丢包数、错包数、连接数等。
    • 温度
    • 电压
    • 等等
  • 中间层监控
    • 数据库
      • Mysql:集群健康状况、磁盘使用率、连接数、慢日志等
      • Redis:集群健康状况、内存使用量、CPU 使用率、内存使用率、连接数、对象数、慢日志等
      • Elasticsearch:集群健康状况、CPU 使用率、内存使用率
      • MongoDB:集群健康状况、
      • 等等
    • 中间件
      • MQ:QPS、消息成功数、消息失败数、传输耗时、消息堆积量
      • 任务调度
      • 等等
  • 应用层监控:接口监控、访问服务、SQL、内存使用率、响应时间、TPS、QPS 等。
  • 业务监控:核心指标、登录、登出、下单、支付等。
  • 客户端监控:性能、返回码、地域、运营商、版本、系统等。

监控维度

一般来说,要从多个维度来对业务进行监控,具体来讲可以包括下面几个维度:

  • 全局维度。从整体角度监控对象的的请求量、平均耗时以及错误率,全局维度的监控一般是为了让你对监控对象的调用情况有个整体了解。
  • 机房维度。一般为了业务的高可用性,服务通常部署在不止一个机房,因为不同机房地域的不同,同一个监控对象的各种指标可能会相差很大,所以需要深入到机房内部去了解。
  • 单机维度。即便是在同一个机房内部,可能由于采购年份和批次的不同,位于不同机器上的同一个监控对象的各种指标也会有很大差异。一般来说,新采购的机器通常由于成本更低,配置也更高,在同等请求量的情况下,可能表现出较大的性能差异,因此也需要从单机维度去监控同一个对象。
  • 时间维度。同一个监控对象,在每天的同一时刻各种指标通常也不会一样,这种差异要么是由业务变更导致,要么是运营活动导致。为了了解监控对象各种指标的变化,通常需要与一天前、一周前、一个月前,甚至三个月前做比较。
  • 核心维度。业务上一般会依据重要性程度对监控对象进行分级,最简单的是分成核心业务和非核心业务。核心业务和非核心业务在部署上必须隔离,分开监控,这样才能对核心业务做重点保障。

监控技术

  • ELK 的技术栈比较成熟,应用范围也比较广,除了可用作监控系统外,还可以用作日志查询和分析。
  • Graphite 是基于时间序列数据库存储的监控系统,并且提供了功能强大的各种聚合函数比如 sum、average、top5 等可用于监控分析,而且对外提供了 API 也可以接入其他图形化监控系统如 Grafana。
  • TICK 的核心在于其时间序列数据库 InfluxDB 的存储功能强大,且支持类似 SQL 语言的复杂数据处理操作。
  • Prometheus 的独特之处在于它采用了拉数据的方式,对业务影响较小,同时也采用了时间序列数据库存储,而且支持独有的 PromQL 查询语言,功能强大而且简洁。
  • OpenTSDB 用 Hbase 存储所有时序(无须采样)的数据,来构建一个分布式、可伸缩的时间序列数据库。它支持秒级数据采集,支持永久存储,可以做容量规划,并很容易地接入到现有的告警系统里。OpenTSDB 可以从大规模的集群(包括集群中的网络设备、操作系统、应用程序)中获取相应的采集指标,并进行存储、索引和服务,从而使这些数据更容易让人理解,如 Web 化、图形化等。
  • Zabbix 是一个分布式监控系统,支持多种采集方式和采集客户端,有专用的 Agent 代理,也支持 SNMP、IPMI、JMX、Telnet、SSH 等多种协议,它将采集到的数据存放到数据库,然后对其进行分析整理,达到条件触发告警。其灵活的扩展性和丰富的功能是其他监控系统所不能比的。相对来说,它的总体功能做的非常优秀。

参考资料

网关路由

什么是网关

网关的首要职责就是:作为统一的出口,对外提供服务;将外部访问网关地址的流量,根据适当的规则路由到内部集群中正确的服务节点之上。因此,微服务中的网关,也常被称为“服务网关”或“API 网关”。

网关首先应该是个路由器,在满足此前提的基础上,网关还可以根据需要作为流量过滤器来使用,提供某些额外的可选的功能。网关常见的能力如下:

  • 动态路由:根据请求路由到对应的服务上去,如果服务不可用还会有重试机制
  • 负载均衡:多服务器提供同一种服务,网关会从配置中心拉取各服务注册信息,然后将请求负载均衡风阀到这些服务器进行处理
  • 流量控制:限制并发请求的流量,避免内部系统受到冲击
  • 安全认证:网关对相关权限验证、脱敏和流量清洗、签名和黑名单功能
  • 熔断降级:当服务不可用或者访问量过大,网关可以将请求做降级,将流量打到其他服务器或者做其他处理,提示用户暂时不可用
  • 灰度发布:先进行小部分服务器升级,通过网关将少量的服务路由到已升级的服务器用来测试服务是否正常,大部分请求依旧在老版本服务器上处理
  • 日志服务:服务访问情况监控和统计报表,请求的吞吐量、并发数、流量监控、性能监控和日常告警等

简单来说:

网关 = 路由器(基础职能) + 过滤器(可选职能)

什么是服务路由

服务路由是指通过一定的规则从集群中选择合适的节点。

负载均衡的作用和服务路由的功能看上去很近似,二者有什么区别呢?

负载均衡的目标是提供服务分发而不是解决路由问题,常见的静态、动态负载均衡算法也无法实现精细化的路由管理,但是负载均衡也可以简单看做是路由方案的一种。

服务路由通常用于以下场景,目的在于实现流量隔离:

  • 分组调用:一般来讲,为了保证服务的高可用性,实现异地多活的需求,一个服务往往不止部署在一个数据中心,而且出于节省成本等考虑,有些业务可能不仅在私有机房部署,还会采用公有云部署,甚至采用多家公有云部署。服务节点也会按照不同的数据中心分成不同的分组,这时对于服务消费者来说,选择哪一个分组调用,就必须有相应的路由规则。
  • 蓝绿发布:蓝绿发布场景中,一共有两套服务群组:一套是提供旧版功能的服务群组,标记为绿色;另一套是提供新版功能的服务群组,标记为蓝色。两套服务群组都是功能完善的,并且正在运行的系统,只是服务版本和访问流量不同。新版群组(蓝色)通常是为了做内部测试、验收,不对外部用户暴露。
    • 如果新版群组(蓝色)运行稳定,并测试、验收通过后,则通过服务路由、负载均衡等手段逐步将外部用户流量导向新版群组(蓝色)。
    • 如果新版群组(蓝色)运行不稳定,或测试、验收不通过,则排查、解决问题后,再继续测试、验收。
  • 灰度发布:灰度发布(又名金丝雀发布)是指在黑与白之间,能够平滑过渡的一种发布方式。在其上可以进行 A/B 测试,即让一部分用户使用特性 A,一部分用户使用特性 B:如果用户对 B 没有什么反对意见,那么逐步扩大发布范围,直到把所有用户都迁移到 B 上面来。灰度发布可以保证整体系统的稳定,在初始灰度的时候就可以发现、调整问题,以保证其影响度。要支持灰度发布,就要求服务能够根据一定的规则,将流量隔离。
  • 流量切换:在业务线上运行过程中,经常会遇到一些不可抗力因素导致业务故障,比如某个机房的光缆被挖断,或者发生着火等事故导致整个机房的服务都不可用。这个时候就需要按照某个指令,能够把原来调用这个机房服务的流量切换到其他正常的机房。
  • 线下测试联调:线下测试时,可能会缺少相应环境。可以将测试应用注册到线上,然后开启路由规则,在本地进行测试。
  • 读写分离。对于大多数互联网业务来说都是读多写少,所以在进行服务部署的时候,可以把读写分开部署,所有写接口可以部署在一起,而读接口部署在另外的节点上。

服务路由的规则

条件路由

条件路由是基于条件表达式的路由规则。各个 RPC 框架的条件路由表达式各不相同。

我们不妨参考一下 Dubbo 的条件路由。Dubbo 的条件路由有两种配置粒度,如下:

  • 应用粒度

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # app1的消费者只能消费所有端口为20880的服务实例
    # app2的消费者只能消费所有端口为20881的服务实例
    ---
    scope: application
    force: true
    runtime: true
    enabled: true
    key: governance-conditionrouter-consumer
    conditions:
    - application=app1 => address=*:20880
    - application=app2 => address=*:20881
  • 服务粒度

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # DemoService的sayHello方法只能消费所有端口为20880的服务实例
    # DemoService的sayHi方法只能消费所有端口为20881的服务实例
    ---
    scope: service
    force: true
    runtime: true
    enabled: true
    key: org.apache.dubbo.samples.governance.api.DemoService
    conditions:
    - method=sayHello => address=*:20880
    - method=sayHi => address=*:20881

其中,conditions 定义具体的路由规则内容。conditions 部分是规则的主体,由 1 到任意多条规则组成。详见:Dubbo 路由规则

Dubbo 的条件路由规则由两个条件组成,分别用于对服务消费者和提供者进行匹配。条件路由规则的格式如下:

1
[服务消费者匹配条件] => [服务提供者匹配条件]
  • 服务消费者匹配条件:所有参数和消费者的 URL 进行对比,当消费者满足匹配条件时,对该消费者执行后面的过滤规则。
  • 服务提供者匹配条件:所有参数和提供者的 URL 进行对比,消费者最终只拿到过滤后的地址列表。

condition:// 代表了这是一段用条件表达式编写的路由规则,下面是一个条件路由规则示例:

1
host = 10.20.153.10 => host = 10.20.153.11

该条规则表示 IP 为 10.20.153.10 的服务消费者只可调用 IP 为 10.20.153.11 机器上的服务,不可调用其他机器上的服务。

下面列举一些 Dubbo 条件路由的典型应用场景:

  • 如果服务消费者的匹配条件为空,就表示所有的服务消费者都可以访问,就像下面的表达式一样。
1
=> host != 10.20.153.11
  • 如果服务提供者的过滤条件为空,就表示禁止所有的服务消费者访问,就像下面的表达式一样。
1
host = 10.20.153.10 =>
  • 排除某个服务节点
1
=> host != 172.22.3.91
  • 白名单
1
register.ip != 10.20.153.10,10.20.153.11 =>
  • 黑名单
1
register.ip = 10.20.153.10,10.20.153.11 =>
  • 只暴露部分机器节点
1
=> host = 172.22.3.1*,172.22.3.2*
  • 为重要应用提供额外的机器节点
1
application != kylin => host != 172.22.3.95,172.22.3.96
  • 读写分离
1
2
method = find*,list*,get*,is* => host = 172.22.3.94,172.22.3.95,172.22.3.96
method != find*,list*,get*,is* => host = 172.22.3.97,172.22.3.98
  • 前后台分离
1
2
application = bops => host = 172.22.3.91,172.22.3.92,172.22.3.93
application != bops => host = 172.22.3.94,172.22.3.95,172.22.3.96
  • 隔离不同机房网段
1
host != 172.22.3.* => host != 172.22.3.*
  • 提供者与消费者部署在同集群内,本机只访问本机的服务
1
=> host = $host

脚本路由

脚本路由是基于脚本语言的路由规则,常用的脚本语言比如 JavaScript、Groovy、JRuby 等。

1
"script://0.0.0.0/com.foo.BarService?category=routers&dynamic=false&rule=" + URL.encode("(function route(invokers) { ... } (invokers))")

这里面 script:// 就代表了这是一段脚本语言编写的路由规则,具体规则定义在脚本语言的 route 方法实现里,比如下面这段用 JavaScript 编写的 route() 方法表达的意思是,只有 IP 为 10.20.153.10 的服务消费者可以发起服务调用。

1
2
3
4
5
6
7
8
9
function route(invokers){
var result = new java.util.ArrayList(invokers.size());
for(i =0; i < invokers.size(); i ++){
if("10.20.153.10".equals(invokers.get(i).getUrl().getHost())){
result.add(invokers.get(i));
}
}
return result;
} (invokers));

标签路由

标签路由通过将某一个或多个服务的提供者划分到同一个分组,约束流量只在指定分组中流转,从而实现流量隔离的目的,可以作为蓝绿发布、灰度发布等场景的能力基础。

标签主要是指对服务提供者的分组,目前有两种方式可以完成实例分组,分别是动态规则打标静态规则打标。一般,动态规则优先级比静态规则更高,当两种规则同时存在且出现冲突时,将以动态规则为准。

以 Dubbo 的标签路由用法为例

(1)动态规则打标,可随时在服务治理控制台下发标签归组规则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# governance-tagrouter-provider应用增加了两个标签分组tag1和tag2
# tag1包含一个实例 127.0.0.1:20880
# tag2包含一个实例 127.0.0.1:20881
---
force: false
runtime: true
enabled: true
key: governance-tagrouter-provider
tags:
- name: tag1
addresses: ["127.0.0.1:20880"]
- name: tag2
addresses: ["127.0.0.1:20881"]
...

(2)静态规则打标

1
<dubbo:provider tag="tag1"/>

or

1
<dubbo:service tag="tag1"/>

or

1
java -jar xxx-provider.jar -Ddubbo.provider.tag={the tag you want, may come from OS ENV}

(3)服务消费者指定标签路由

1
RpcContext.getContext().setAttachment(Constants.REQUEST_TAG_KEY,"tag1");

请求标签的作用域为每一次 invocation,使用 attachment 来传递请求标签,注意保存在 attachment 中的值将会在一次完整的远程调用中持续传递,得益于这样的特性,我们只需要在起始调用时,通过一行代码的设置,达到标签的持续传递。

路由规则获取方式

路由规则的获取方式主要有三种:

  • 本地静态配置:顾名思义就是路由规则存储在服务消费者本地上。服务消费者发起调用时,从本地固定位置读取路由规则,然后按照路由规则选取一个服务节点发起调用。
  • 配置中心管理:这种方式下,所有的服务消费者都从配置中心获取路由规则,由配置中心来统一管理。
  • 注册中心动态下发:这种方式下,一般是运维人员或者开发人员,通过服务治理平台修改路由规则,服务治理平台调用配置中心接口,把修改后的路由规则持久化到配置中心。因为服务消费者订阅了路由规则的变更,于是就会从配置中心获取最新的路由规则,按照最新的路由规则来执行。

一般来讲,服务路由最好是存储在配置中心,由配置中心来统一管理。这样的话,所有的服务消费者就不需要在本地管理服务路由,因为大部分的服务消费者并不关心服务路由的问题,或者说也不需要去了解其中的细节。通过配置中心,统一给各个服务消费者下发统一的服务路由,节省了沟通和管理成本。

但也不排除某些服务消费者有特定的需求,需要定制自己的路由规则,这个时候就适合通过本地配置来定制。

而动态下发可以理解为一种高级功能,它能够动态地修改路由规则,在某些业务场景下十分有用。比如某个数据中心存在问题,需要把调用这个数据中心的服务消费者都切换到其他数据中心,这时就可以通过动态下发的方式,向配置中心下发一条路由规则,将所有调用这个数据中心的请求都迁移到别的地方。

参考资料

微服务简介

本文关键词:定义演进利弊如何拆分容量规划核心组件

什么是微服务

微服务定义

微服务是由单一应用程序构成的小服务,拥有自己的进程与轻量化处理,服务依业务功能设计,以全自动的方式部署,与其他服务使用 HTTP API 通讯。同时,服务会使用最小规模的集中管理 (例如 Docker)技术,服务可以用不同的编程语言与数据库等。

——Martin Fowler 和 James Lewis 对应微服务( Microservices)的定义

个人理解,微服务是一种架构模式,它提倡将一个单一应用拆分为一些可独立运行可协同工作小的服务。在微服务架构中,每个小服务都拥有独立的进程和轻量级通信。这些服务是围绕业务能力构建的,并且可以通过全自动化部署机制独立部署。这些服务会使用最小规模的集中式管理能力(例如 Docker) ,可以用不同的编程语言编写并使用不同的数据存储技术。

从以上定义,我们可以提炼出微服务的关键特性:

  • 可独立运行 - 微服务是一个个可以独立开发、独立部署、独立运行的系统或进程。
  • 可协同工作 - 微服务之间不是完全隔离的,彼此需要协同工作,通常是采用 RPC 方式。
  • 分而治之 - 微服务本质上是一种分治思想,即把一个复杂业务拆分为多个子业务。这使得每个子业务更加高内聚、低耦合,从而能聚焦自身的功能。

微服务的演进

互联网应用架构大致的演进方向为:单体架构 -> 服务化架构 -> 微服务架构。在演化过程中,架构越来越复杂,一个应用被拆分的服务也越来越细。

互联网早期的技术栈通常为 LAMP(Linux + Apache + MySQL + PHP)或 MVC(Spring + iBatis/Hibernate + Tomcat)。这两种架构都是典型的单体应用架构。其优点是技术栈简单,因此学习上手快,部署也容易。

随着业务越来越复杂,开发团队规模不断扩张,单体应用架构就难以适应开发迭代节奏,主要有以下问题:

  • 构建、部署效率低:代码越多,依赖资源越多,则构建、部署的耗费时间自然会越长。即使每次修改一个很小的功能点,也不得不全量构建、全部部署,耗时耗力。
  • 团队协作成本高:单体应用的代码往往在一个工程中,而一个工程中的开发人员越多,显然沟通成本越高。
  • 可用性差:因为所有的功能开发最后都部署到同一个 WAR 包里,运行在同一个 Tomcat 进程之中,一旦某一功能涉及的代码或者资源有问题,那就会影响整个 WAR 包中部署的功能。

服务化:本地方法调用 转为 远程方法调用(RPC)

微服务和服务化的差异:

  • 服务拆分粒度更细
  • 服务独立部署、维护
  • 服务治理要求高

微服务架构有以下 4 个特点:

  • 服务拆分粒度更细:根据业务拆分。
  • 独立部署:每个服务在物理上相互隔离。独立部署的好处在于:如果没有拆分服务,那么任何修改都必须重新部署才能更新;而拆分为多个服务后,只有被修改的服务需要重部署。
  • 独立维护:根据组织架构拆分,分团队维护。
  • 服务治理:服务数量变多,需要有统一的服务治理平台。

简单来说,微服务就是将庞杂臃肿的单体应用拆分成细粒度的服务,独立部署,并交给各个中小团队来负责开发、测试、上线和运维整个生命周期。

  • 通过服务组件化
  • 独立的进程
  • 独立部署
  • 轻量级通信
  • 基于业务能力
  • 无集中式管理

微服务的利弊

《人月神话》中有一个软件工程界的著名理论——“没有银弹”,即世间没有能包治百病的良药,也没有能解决所有问题的架构。微服务架构的利和弊都非常突出,在实际业务场景中是否采用,如何采用,需要具体去分析、权衡。

微服务的利弊如下:

  • 优点
    • 易于扩展
    • 部署简单
    • 技术异构性
  • 缺点
    • 分布式复杂度
    • 最终一致性
    • 测试复杂度
    • 运维复杂度

康威定律

  • 第一定律:组织沟通方式会通过系统设计表达出来
  • 第二定律:时间再多一件事情也不可能做的完美,但总有时间做完一件事情
  • 第三定律:线型系统和线型组织架构间有潜在的异质同态特性
  • 第四定律:大的系统组织总是比小系统更倾向于分解

如何拆分微服务

应用微服务化架构前,要思考几个问题:什么时候进行服务化拆分?如何拆分服务?

一般来说,当应用复杂度、开发团队膨胀到难以维护时,就该考虑服务化拆分了。从经验上来看,一般开发人员超 10 人,就可以考虑服务化拆分了。

拆分微服务的思考维度

拆分服务的思考维度:

  • 业务维度:业务和数据关系密切的应该拆分为一个微服务,而功能相对比较独立的业务适合单独拆分为一个微服务。
  • 功能维度:公共功能聚合为一个服务。标准是是否被多个其他服务调用,且依赖的资源独立不与其他业务耦合。
  • 组织架构:根据实际组织架构,天然分为不同的团队,每个团队独立维护若干微服务。

但并不是说功能拆分的越细越好,过度的拆分反而会让服务数量膨胀变得难以管理,因此找到符合自己业务现状和团队人员技术水平的拆分粒度才是可取的。

拆分微服务的原则

  • 单一职责 - 高内聚,低耦合
  • 先粗后细,逐渐细化
  • 渐进式迭代
  • 考虑扩展性

拆分微服务的前置条件

微服务主要依赖几个基本组件:

  • 服务如何定义
    • 对于单体应用来说,不同功能模块之前相互交互时,通常是以类库的方式来提供各个模块的功能。
    • 对于微服务来说,每个服务都运行在各自的进程之中,无论采用哪种通讯协议,是 HTTP 还是 RPC,服务之间的调用都通过接口来约定如何交互。约定内容包括接口名、接口参数以及接口返回值。
  • 服务如何发布和订阅
    • 单体应用由于部署在同一个 WAR 包里,接口之间的调用属于进程内的调用。
    • 对于微服务来说,服务提供者需要向注册中心发布自己提供的服务(暴露接口信息以及接口地址);服务消费者向注册中心订阅哪些服务可用。
  • 服务如何监控?通常对于一个服务,我们最关心的是 QPS(调用量)、AvgTime(平均耗时)以及 P999(99.9% 的请求性能在多少毫秒以内)这些指标。这时候你就需要一种通用的监控方案,能够覆盖业务埋点、数据收集、数据处理,最后到数据展示的全链路功能。
  • 服务如何治理?可以想象,拆分为微服务架构后,服务的数量变多了,依赖关系也变复杂了。比如一个服务的性能有问题时,依赖的服务都势必会受到影响。可以设定一个调用性能阈值,如果一段时间内一直超过这个值,那么依赖服务的调用可以直接返回,这就是熔断,也是服务治理最常用的手段之一。
  • 故障如何定位?在单体应用拆分为微服务之后,一次用户调用可能依赖多个服务,每个服务又部署在不同的节点上,如果用户调用出现问题,你需要有一种解决方案能够将一次用户请求进行标记,并在多个依赖的服务系统中继续传递,以便串联所有路径,从而进行故障定位。

应用微服务架构,必须要先解决以上问题。

服务容量规划

容量规划系统的作用是根据各个微服务部署集群的最大容量和线上实际运行的负荷,来决定各个微服务是否需要弹性扩缩容,以及需要扩缩容多少台机器

微服务容量规划的挑战

微服务容量规划的复杂度主要来自以下方面:

  • 服务数量众多
  • 服务的接口表现差异巨大
  • 服务部署的集群规模大小不同
  • 服务之间还存在依赖关系

如何评估容量

容量评估需要关注的维度:

  • 选择合适的压测指标 - 主要有两类
    • 系统类指标 - CPU 使用率、内存占有量、I/O 使用率、网卡带宽等
    • 服务类指标 - 接口响应的平均耗时、P999 耗时、错误率等
  • 压测获取单机的最大容量
    • 单机压测 - 可以采用以下流量回放手段来模拟线上流量:
      • 日志回放
      • TCP-Copy
    • 集群压测 - 一般做法是通过不断把线上集群的节点摘除,以减少机器数的方式,来增加线上节点单机的流量,从而达到压测的目的。
  • 实时获取集群的运行负荷 - 集群的运行负荷也需要通过采用区间加权的方式来计算,但是因为集群的规模可能很大,超过上千台机器,显然通过计算每台单机运行的负荷再加在一起的方式效率不高。一种参考方式是:统计每台单机在不同耗时区间内的请求数,推送到集中处理的地方进行聚合,将同一个集群内的单机位于不同耗时区间内的请求进行汇总,就得到整个集群的请求在不同耗时区间内的分布了,再利用区间加权的方式就可以计算整个集群的运行负荷。

如何伸缩容量

伸缩容量的一种参考方式是使用水位线来决策扩容或是缩容水位线就是集群的最大容量除以集群的实际运行负荷,可以实时监控集群的水位线。

通常,可以为集群监控设置两条水位线:一条是安全线,一条是致命线。当集群的水位线位于致命线以下时,就需要立即扩容;在扩容一定数量的机器后,水位线回到安全线以上并保持一段时间后,就可以进行缩容了。

  • 扩容 - 扩容有两种方式:按数量、按比例(更常见做法)。
  • 缩容 - 为了避免抖动,缩容不应该一次性完成,而应该按比例逐步完成。过程中,应该多次采集水位线,满足一定比例才继续缩容。

微服务的核心组件

微服务架构下,服务调用主要依赖下面几个核心组件:

  • 服务定义
  • 注册中心
  • 服务调用
  • 服务监控
  • 服务治理

服务定义

服务调用首先要解决的问题就是服务如何对外描述。比如,你对外提供了一个服务,那么这个服务的服务名叫什么?调用这个服务需要提供哪些信息?调用这个服务返回的结果是什么格式的?该如何解析?这些就是服务定义要解决的问题。

常用的服务定义方式包括 REST API、XML 配置以及 IDL 文件三种。

  • REST API - REST API 方式通常用于 HTTP 协议的服务定义,并且常用 Wiki 或者Swagger来进行管理。
  • XML - XML 配置方式多用作 RPC 协议的服务定义,通过 *.xml 配置文件来定义接口名、参数以及返回值类型等。
  • IDL - IDL 文件方式通常用作 Thrift 和 gRPC 这类跨语言服务调用框架中,比如 gRPC 就是通过 Protobuf 文件来定义服务的接口名、参数以及返回值的数据结构。

注册中心

有了服务的接口描述,下一步要解决的问题就是服务的发布和订阅,就是说你提供了一个服务,如何让外部想调用你的服务的人知道。这个时候就需要一个类似注册中心的角色,服务提供者将自己提供的服务以及地址登记到注册中心,服务消费者则从注册中心查询所需要调用的服务的地址,然后发起请求。

一般来讲,注册中心的工作流程是:

  • 服务提供者在启动时,根据服务发布文件中配置的发布信息向注册中心注册自己的服务。
  • 服务消费者在启动时,根据消费者配置文件中配置的服务信息向注册中心订阅自己所需要的服务。
  • 注册中心返回服务提供者地址列表给服务消费者。
  • 当服务提供者发生变化,比如有节点新增或者销毁,注册中心将变更通知给服务消费者。

服务调用

服务消费者发起调用需解决以下问题:

  • 服务通信采用什么协议?就是说服务提供者和服务消费者之间以什么样的协议进行网络通信,是采用四层 TCP、UDP 协议,还是采用七层 HTTP 协议,还是采用其他协议?
  • 数据传输采用什么方式?就是说服务提供者和服务消费者之间的数据传输采用哪种方式,是同步还是异步,是在单连接上传输,还是多路复用。
  • 序列化采用什么方式?通常数据传输都会对数据进行序列化、压缩,来减少网络传输的数据量,从而减少带宽消耗和网络传输时间,比如常见的 JSON 序列化、Java 对象序列化以及 Protobuf 序列化等。

服务监控

一旦服务消费者与服务提供者之间能够正常发起服务调用,你就需要对调用情况进行监控,以了解服务是否正常。通常来讲,服务监控主要包括三个流程。

  • 数据收集。就是要把每一次服务调用的请求耗时以及成功与否收集起来,并上传到集中的数据处理中心。
  • 数据处理。有了每次调用的请求耗时以及成功与否等信息,就可以计算每秒服务请求量、平均耗时以及成功率等指标。
  • 数据展示。数据收集起来,经过处理之后,还需要以友好的方式对外展示,才能发挥价值。通常都是将数据展示在 Dashboard 面板上,并且每隔 10s 等间隔自动刷新,用作业务监控和报警等。

除了需要对服务调用情况进行监控之外,你还需要记录服务调用经过的每一层链路,以便进行问题追踪和故障定位。

服务链路追踪的工作原理大致如下:

  • 服务消费者发起调用前,会在本地按照一定的规则生成一个 requestid,发起调用时,将 requestid 当作请求参数的一部分,传递给服务提供者。
  • 服务提供者接收到请求后,记录下这次请求的 requestid,然后处理请求。如果服务提供者继续请求其他服务,会在本地再生成一个自己的 requestid,然后把这两个 requestid 都当作请求参数继续往下传递。

以此类推,通过这种层层往下传递的方式,一次请求,无论最后依赖多少次服务调用、经过多少服务节点,都可以通过最开始生成的 requestid 串联所有节点,从而达到服务追踪的目的。

服务治理

服务监控能够发现问题,服务追踪能够定位问题所在,而解决问题就得靠服务治理了。服务治理就是通过一系列的手段来保证在各种意外情况下,服务调用仍然能够正常进行。

在生产环境中,你应该经常会遇到下面几种状况。

  • 单机故障。通常遇到单机故障,都是靠运维发现并重启服务或者从线上摘除故障节点。然而集群的规模越大,越是容易遇到单机故障,在机器规模超过一百台以上时,靠传统的人肉运维显然难以应对。而服务治理可以通过一定的策略,自动摘除故障节点,不需要人为干预,就能保证单机故障不会影响业务。
  • 单 IDC 故障。你应该经常听说某某 App,因为施工挖断光缆导致大批量用户无法使用的严重故障。而服务治理可以通过自动切换故障 IDC 的流量到其他正常 IDC,可以避免因为单 IDC 故障引起的大批量业务受影响。
  • 依赖服务不可用。比如你的服务依赖依赖了另一个服务,当另一个服务出现问题时,会拖慢甚至拖垮你的服务。而服务治理可以通过熔断,在依赖服务异常的情况下,一段时期内停止发起调用而直接返回。这样一方面保证了服务消费者能够不被拖垮,另一方面也给服务提供者减少压力,使其能够尽快恢复。

上面是三种最常见的需要引入服务治理的场景,当然还有一些其他服务治理的手段比如自动扩缩容,可以用来解决服务的容量问题。

参考资料

《极客时间教程 - 从 0 开始学架构》笔记

架构到底是指什么?

系统和子系统

模块与组件

框架与架构

架构设计的历史背景

机器语言 -> 汇编语言 -> 高级语言 -> 结构化设计 -> 面向对象设计

架构设计的目的

架构设计的主要目的是为了解决软件复杂度带来的问题

复杂度来源:高性能

复杂度来源:高可用

复杂度来源:可扩展性

复杂度来源:低成本、安全、规模

架构设计三原则

架构设计原则案例

参考资料

读写分离基本原理

读写分离的基本原理是:主服务器用来处理写操作以及实时性要求比较高的读操作,而从服务器用来处理读操作

为何要读写分离

  • 有效减少锁竞争 - 主服务器只负责写,从服务器只负责读,能够有效的避免由数据更新导致的行锁竞争,使得整个系统的查询性能得到极大的改善。
  • 提高查询吞吐量 - 通过一主多从的配置方式,可以将查询请求均匀的分散到多个数据副本,能够进一步的提升系统的处理能力。
  • 提升数据库可用性 - 使用多主多从的方式,不但能够提升系统的吞吐量,还能够提升数据库的可用性,可以达到在任何一个数据库宕机,甚至磁盘物理损坏的情况下仍然不影响系统的正常运行。

读写分离的原理

读写分离的实现是根据 SQL 语义分析,将读操作和写操作分别路由至主库与从库。

读写分离

读写分离的基本实现是:

img

  • 数据库服务器搭建主从集群,一主一从、一主多从都可以。
  • 数据库主机负责读写操作,从机只负责读操作。
  • 数据库主机通过复制将数据同步到从机,每台数据库服务器都存储了全量数据。
  • 业务服务器将写操作发给数据库主机,将读操作发给数据库从机。
  • 主机会记录请求的二进制日志,然后推送给从库,从库解析并执行日志中的请求,完成主从复制。这意味着:复制过程存在时延,这段时间内,主从数据可能不一致。

读写分离的问题

读写分离存在两个问题:数据一致性分发机制

数据一致性

读写分离产生了主库与从库之间的数据一致性的问题。

数据分片 + 读写分离

分发机制

数据库读写分离后,一个 SQL 请求具体分发到哪个数据库节点?一般有两种分发方式:客户端分发和中间件代理分发。

客户端分发,是基于程序代码,自行控制数据分发到哪个数据库节点。更细一点来说,一般程序中建立多个数据库的连接,根据一定的算法,选择合适的连接去发起 SQL 请求。这种方式也被称为客户端中间件,代表有:jdbc-sharding。

中间件代理分发,指的是独立一套系统出来,实现读写操作分离和数据库服务器连接的管理。中间件对业务服务器提供 SQL 兼容的协议,业务服务器无须自己进行读写分离。对于业务服务器来说,访问中间件和访问数据库没有区别,事实上在业务服务器看来,中间件就是一个数据库服务器。代表有:Mycat。

参考资料

JavaAgent

Javaagent 是什么?

Javaagent 是 java 命令的一个参数。参数 javaagent 可以用于指定一个 jar 包,它利用 JVM 提供的 Instrumentation API 来更改加载 JVM 中的现有字节码。

  1. 这个 jar 包的 MANIFEST.MF 文件必须指定 Premain-Class 项。
  2. Premain-Class 指定的那个类必须实现 premain() 方法。

premain 方法,从字面上理解,就是运行在 main 函数之前的的类。当 Java 虚拟机启动时,在执行 main 函数之前,JVM 会先运行-javaagent所指定 jar 包内 Premain-Class 这个类的 premain 方法 。

在命令行输入 java可以看到相应的参数,其中有 和 java agent 相关的:

1
2
3
4
5
6
7
-agentlib:<libname>[=<选项>]
加载本机代理库 <libname>, 例如 -agentlib:hprof
另请参阅 -agentlib:jdwp=help 和 -agentlib:hprof=help
-agentpath:<pathname>[=<选项>]
按完整路径名加载本机代理库
-javaagent:<jarpath>[=<选项>]
加载 Java 编程语言代理, 请参阅 java.lang.instrument

Java Agent 技术简介

Java Agent 直译为 Java 代理,也常常被称为 Java 探针技术。

Java Agent 是在 JDK1.5 引入的,是一种可以动态修改 Java 字节码的技术。Java 中的类编译后形成字节码被 JVM 执行,在 JVM 在执行这些字节码之前获取这些字节码的信息,并且通过字节码转换器对这些字节码进行修改,以此来完成一些额外的功能。

Java Agent 是一个不能独立运行 jar 包,它通过依附于目标程序的 JVM 进程,进行工作。启动时只需要在目标程序的启动参数中添加-javaagent 参数添加 ClassFileTransformer 字节码转换器,相当于在 main 方法前加了一个拦截器。

Java Agent 功能介绍

Java Agent 主要有以下功能

  • Java Agent 能够在加载 Java 字节码之前拦截并对字节码进行修改;
  • Java Agent 能够在 Jvm 运行期间修改已经加载的字节码;

Java Agent 的应用场景

  • IDE 的调试功能,例如 Eclipse、IntelliJ IDEA ;
  • 热部署功能,例如 JRebel、XRebel、spring-loaded;
  • 各种线上诊断工具,例如 Btrace、Greys,还有阿里的 Arthas;
  • 各种性能分析工具,例如 Visual VM、JConsole 等;
  • 全链路性能检测工具,例如 Skywalking、Pinpoint 等;

Java Agent 实现原理

在了解 Java Agent 的实现原理之前,需要对 Java 类加载机制有一个较为清晰的认知。一种是在 man 方法执行之前,通过 premain 来执行,另一种是程序运行中修改,需通过 JVM 中的 Attach 实现,Attach 的实现原理是基于 JVMTI。

主要是在类加载之前,进行拦截,对字节码修改

下面我们分别介绍一下这些关键术语:

  • JVMTI 就是 JVM Tool Interface,是 JVM 暴露出来给用户扩展使用的接口集合,JVMTI 是基于事件驱动的,JVM 每执行一定的逻辑就会触发一些事件的回调接口,通过这些回调接口,用户可以自行扩展

    JVMTI 是实现 Debugger、Profiler、Monitor、Thread Analyser 等工具的统一基础,在主流 Java 虚拟机中都有实现

  • JVMTIAgent是一个动态库,利用 JVMTI 暴露出来的一些接口来干一些我们想做、但是正常情况下又做不到的事情,不过为了和普通的动态库进行区分,它一般会实现如下的一个或者多个函数:

    • Agent_OnLoad 函数,如果 agent 是在启动时加载的,通过 JVM 参数设置
    • Agent_OnAttach 函数,如果 agent 不是在启动时加载的,而是我们先 attach 到目标进程上,然后给对应的目标进程发送 load 命令来加载,则在加载过程中会调用 Agent_OnAttach 函数
    • Agent_OnUnload 函数,在 agent 卸载时调用
  • javaagent 依赖于 instrument 的 JVMTIAgent(Linux 下对应的动态库是 libinstrument.so),还有个别名叫 JPLISAgent(Java Programming Language Instrumentation Services Agent),专门为 Java 语言编写的插桩服务提供支持的

  • instrument 实现了 Agent_OnLoad 和 Agent_OnAttach 两方法,也就是说在使用时,agent 既可以在启动时加载,也可以在运行时动态加载。其中启动时加载还可以通过类似-javaagent:jar 包路径的方式来间接加载 instrument agent,运行时动态加载依赖的是 JVM 的 attach 机制,通过发送 load 命令来加载 agent

  • JVM Attach 是指 JVM 提供的一种进程间通信的功能,能让一个进程传命令给另一个进程,并进行一些内部的操作,比如进行线程 dump,那么就需要执行 jstack 进行,然后把 pid 等参数传递给需要 dump 的线程来执行

Java Agent 案例

加载 Java 字节码之前拦截

App 项目

(1)创建一个名为 javacore-javaagent-app 的 maven 工程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>io.github.dunwu.javacore</groupId>
<artifactId>javacore-javaagent-app</artifactId>
<version>1.0.1</version>
<name>JavaCore :: JavaAgent :: App</name>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>1.8</java.version>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
</properties>
</project>

(2)创建一个应用启动类

1
2
3
4
5
6
7
8
public class AppMain {

public static void main(String[] args) {
System.out.println("APP 启动!!!");
AppInit.init();
}

}

(3)创建一个模拟应用初始化的类

1
2
3
4
5
6
7
8
9
10
11
12
public class AppInit {

public static void init() {
try {
System.out.println("APP初始化中...");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

}

(4)输出

1
2
APP 启动!!!
APP初始化中...

Agent 项目

(1)创建一个名为 javacore-javaagent-agent 的 maven 工程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>io.github.dunwu.javacore</groupId>
<artifactId>javacore-javaagent-agent</artifactId>
<version>1.0.1</version>
<name>JavaCore :: JavaAgent :: Agent</name>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>1.8</java.version>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
</properties>

<dependencies>
<!--javaagent 工具包-->
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.26.0-GA</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.5.1</version>
<!--指定 maven 编译的 jdk 版本。若不指定,maven3 默认用 jdk 1.5;maven2 默认用 jdk1.3-->
<configuration>
<source>8</source>
<target>8</target>
</configuration>
</plugin>

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.2.0</version>
<configuration>
<archive>
<!--自动添加META-INF/MANIFEST.MF -->
<manifest>
<addClasspath>true</addClasspath>
</manifest>
<manifestEntries>
<Menifest-Version>1.0</Menifest-Version>
<Premain-Class>io.github.dunwu.javacore.javaagent.RunTimeAgent</Premain-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>

(2)创建一个 Agent 启动类

1
2
3
4
5
6
7
8
public class RunTimeAgent {

public static void premain(String arg, Instrumentation instrumentation) {
System.out.println("探针启动!!!");
System.out.println("探针传入参数:" + arg);
instrumentation.addTransformer(new RunTimeTransformer());
}
}

这里每个类加载的时候都会走这个方法,我们可以通过 className 进行指定类的拦截,然后借助 javassist 这个工具,进行对 Class 的处理,这里的思想和反射类似,但是要比反射功能更加强大,可以动态修改字节码。

(3)使用 javassist 拦截指定类,并进行代码增强

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package io.github.dunwu.javacore.javaagent;

import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;

import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;

public class RunTimeTransformer implements ClassFileTransformer {

private static final String INJECTED_CLASS = "io.github.dunwu.javacore.javaagent.AppInit";

@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
String realClassName = className.replace("/", ".");
if (realClassName.equals(INJECTED_CLASS)) {
System.out.println("拦截到的类名:" + realClassName);
CtClass ctClass;
try {
// 使用javassist,获取字节码类
ClassPool classPool = ClassPool.getDefault();
ctClass = classPool.get(realClassName);

// 得到该类所有的方法实例,也可选择方法,进行增强
CtMethod[] declaredMethods = ctClass.getDeclaredMethods();
for (CtMethod method : declaredMethods) {
System.out.println(method.getName() + "方法被拦截");
method.addLocalVariable("time", CtClass.longType);
method.insertBefore("System.out.println(\"---开始执行---\");");
method.insertBefore("time = System.currentTimeMillis();");
method.insertAfter("System.out.println(\"---结束执行---\");");
method.insertAfter("System.out.println(\"运行耗时: \" + (System.currentTimeMillis() - time));");
}
return ctClass.toBytecode();
} catch (Throwable e) { //这里要用Throwable,不要用Exception
System.out.println(e.getMessage());
e.printStackTrace();
}
}
return classfileBuffer;
}

}

(4)输出

指定 VM 参数 -javaagent:F:\code\myCode\agent-test\runtime-agent\target\runtime-agent-1.0-SNAPSHOT.jar=hello,运行 AppMain

1
2
3
4
5
6
7
8
9
探针启动!!!
探针传入参数:hello
APP 启动!!!
拦截到的类名:io.github.dunwu.javacore.javaagent.AppInit
init方法被拦截
---开始执行---
APP初始化中...
---结束执行---
运行耗时: 1014

运行时拦截(JDK 1.6 及以上)

如何实现在程序运行时去完成动态修改字节码呢?

动态修改字节码需要依赖于 JDK 为我们提供的 JVM 工具,也就是上边我们提到的 Attach,通过它去加载我们的代理程序。

首先我们在代理程序中需要定义一个名字为 agentmain 的方法,它可以和上边我们提到的 premain 是一样的内容,也可根据 agentmain 的特性进行自己逻辑的开发。

1
2
3
4
5
6
7
8
9
10
11
/**
* agentmain 在 main 函数开始运行后才启动(依赖于Attach机制)
*/
public class RunTimeAgent {

public static void agentmain(String arg, Instrumentation instrumentation) {
System.out.println("agentmain探针启动!!!");
System.out.println("agentmain探针传入参数:" + arg);
instrumentation.addTransformer(new RunTimeTransformer());
}
}

然后就是我们需要将配置中设置,让其知道我们的探针需要加载这个类,在 maven 中设置如下,如果是 META-INF/MANIFEST.MF 文件同理。

1
2
<!--<Premain-Class>com.zhj.agent.agentmain.RunTimeAgent</Premain-Class>-->
<Agent-Class>com.zhj.agent.agentmain.RunTimeAgent</Agent-Class>

这样其实我们的探针就已经改造好了,然后我们需要在目标程序的 main 方法中植入一些代码,使其可以读取到我们的代理程序,这样我们也无需去配置 JVM 的参数,就可以加载探针程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class APPMain {

public static void main(String[] args) {
System.out.println("APP 启动!!!");
for (VirtualMachineDescriptor vmd : VirtualMachine.list()) {
// 指定的VM才可以被代理
if (true) {
System.out.println("该VM为指定代理的VM");
System.out.println(vmd.displayName());
try {
VirtualMachine vm = VirtualMachine.attach(vmd.id());
vm.loadAgent("D:/Code/java/idea_project/agent-test/runtime-agent/target/runtime-agent-1.0-SNAPSHOT.jar=hello");
vm.detach();
} catch (Exception e) {
e.printStackTrace();
}
}
}
AppInit.init();
}
}

其中 VirtualMachine 是 JDK 工具包下的类,如果系统环境变量没有配置,需要自己在 Maven 中引入本地文件。

1
2
3
4
5
6
7
8
<dependency>
<groupId>com.sun</groupId>
<artifactId>tools</artifactId>
<version>1.8</version>
<scope>system</scope>
<systemPath>D:/Software/java_dev/java_jdk/lib/tools.jar</systemPath>
</dependency>
复制代码

这样我们在程序启动后再去动态修改字节码文件的简单案例就完成了。

参考资料

《后端存储实战课》笔记

课前加餐丨电商系统是如何设计的?

创建和更新订单时,如何保证数据准确无误?

流量大、数据多的商品详情页系统该如何设计?

复杂而又重要的购物车系统,应该如何设计?

事务:账户余额总是对不上账,怎么办?

分布式事务:如何保证多个系统间的数据是一致的?

分布式事务常见解决方案:

  • 2PC
  • 3PC
  • TCC
  • Saga
  • 本地消息表

个人以前总结:分布式事务

如何用 Elasticsearch 构建商品搜索系统?

搜索领域的核心问题是进行全文匹配。一般的关系型数据库,如 Mysql 的索引(InnoDB 为 B 树索引)不适用于全文检索,导致查询时只能全表扫描,性能很差。

搜索引擎(典型代表:Elasticsearch)通过倒排索引技术,很好的支持了全文检索。但是,倒排索引的写入和更新性能相较于 B 树索引较差,因此不适用于更新频繁的数据。

MySQL HA:如何将“删库跑路”的损失降到最低?

Mysql 复制(略)

一个几乎每个系统必踩的坑儿:访问数据库超时

数据库超时分析经验:

  • 根据故障时段在系统忙时,推断出故障是跟支持用户访问的功能有关。
  • 根据系统能在流量峰值过后自动恢复这一现象,排除后台服务被大量请求打死的可能性。
  • 根据 CPU 利用率的变化曲线,如果满足一定的周期性波动,可推断出大概率和定时任务有关。这些定时任务负责刷新数据缓存。如果确实是因为刷新缓存定时任务导致的,需要针对性优化。
  • 如果 Mysql CPU 过高,大概率是慢 SQL 导致的,优先排查慢 SQL 日志,找出查询特别慢的表。看看该表是不是需要加缓存。

避免访问数据库超时的注意点:

  • 开发时,考虑 SQL 相关表的数据规模,查询性能,是否匹配索引等等,避免出现慢 SQL
  • 设计上,考虑减少查询次数,如使用缓存
  • 系统支持自动杀慢 SQL
  • 支持熔断、降级,减少故障影响范围

怎么能避免写出慢 SQL?

数据表不宜过大,一般不要超过千万条数据。

根据实际情况,尽量设计好索引,以提高查询、排序效率。

如果出现慢 SQL,需要改造索引时,可以通过执行计划进行分析。

走进黑盒:SQL 是如何在数据库中执行的?

MySQL 如何应对高并发(一):使用缓存保护 MySQL

MySQL 如何应对高并发(二):读写分离

img

MySQL 主从数据库同步是如何实现的?

基于 binlog 进行数据同步

订单数据越来越多,数据库越来越慢该怎么办?

针对大表,为了优化其查询性能,可以将历史数据归档。一般可以考虑归档到列式数据库,如:Hive

MySQL 存储海量数据的最后一招:分库分表

分库分表

用 Redis 构建缓存集群的最佳实践有哪些

Redis 3.0 后,官方提供 Redis Cluster 来解决数据量大、高可用和高并发问题。

相关文章:Redis 集群

大厂都是怎么做 MySQL to Redis 同步的?

缓存穿透:把全量数据都放在 Redis 集群,服务通过接受 MQ 消息,去触发更新缓存数据。

使用 Binlog 实时更新 Redis 缓存,如 Canal

分布式存储:你知道对象存储是如何保存图片文件的吗?

保存图片、音频、视频这种相对较大的文件,一般使用对象存储。如:HDFS 等。

元数据管理:ZooKeeper、etcd、Nacos

对象如何拆分和保存:将大文件分块(block),提升 IO 效率并方便维护。

跨系统实时同步数据,分布式事务是唯一的解决方案吗?

跨系统实时同步数据:

  • 早期方案:使用 ETL 定时同步数据,在 T+1 时刻去同步上一周期的数据,然后进行计算和分析。
  • 使用 Binlog 和 MQ 构建实时数据同步系统

如何保证数据同步的实时性

  • 为了能够支撑众多下游数据库实时同步的需求,可以通过 MQ 解耦上下游,Binlog 先发送到 MQ 中,下游各业务方可以消费 MQ 中的消息再写入各自的数据库。
  • 如果下游处理能力不能满足要求,可以增加 MQ 中的分区数量实现并发同步,但需要结合同步的业务数据特点,把具有因果关系的数据哈希到相同分区上,才能避免因为并发乱序而出现数据同步错误的问题。

如何在不停机的情况下,安全地更换数据库?

  • 停机迁移/扩容
    • 优点:简单粗暴;没有数据一致性问题
    • 缺点:需要停机
  • 双写迁移
    • 优点:不需要停机
    • 缺点:方案较复杂
  • 主从升级
    • 优点:不需要停机;无需数据迁移
    • 缺点:需要冗余的从库

类似“点击流”这样的海量数据应该如何存储?

使用 Kafka 暂存海量原始数据,然后再使用大数据计算框架(Spark、Flink)进行计算。

其他方案:

分布式流数据存储,如:Pravega、Pulsar 的存储引擎 BookKeeper

时序数据库,如:InfluxDB、OpenTSDB 等。

面对海量数据,如何才能查得更快

实时计算:Flink、Storm

批处理计算:Map-Reduce、Spark

海量数据存储:

  • 列式数据库(在正确使用的前提下,10GB 量级的数据查询基本上可以做到秒级返回):HBase、Cassandra
  • 搜索引擎(对于 TB 量级以下的数据,如果可以接受相对比较贵的硬件成本):Elasticsearch

MySQL 经常遇到的高可用、分片问题,NewSQL 是如何解决的?

安利 CockroachDB、RocksDB、OceanBase

RocksDB:不丢数据的高性能 KV 存储、

越来越多的新生代数据库,都选择 RocksDB 作为它们的存储引擎。

Redis 是一个内存数据库,所以它很快。

RocksDB 是一个持久化的 KV 存储,它需要保证每条数据都要安全地写到磁盘上。磁盘的读写性能和内
存读写性能差着一两个数量级,读写磁盘的 RocksDB,能和读写内存的 Redis 做到相近的性能,这就是 RocksDB 的价值所在了。

RocksDB 性能好,是由于使用了 LSM 树结构。

LSM-Tree 的全称是:The Log-Structured Merge-Tree,是一种非常复杂的复合数据结构,它包含了 WAL(Write Ahead Log)、跳表(SkipList)和一个分层的有序表(SSTable,Sorted String Table)。

参考资料

  • 后端存储实战课 - 极客教程【入门】:讲解存储在电商领域的种种应用和一些基本特性

数据结构与数据库索引

关键词:链表、数组、散列表、红黑树、B+ 树、LSM 树、跳表

引言

数据库是“按照 数据结构 来组织、存储和管理数据的仓库”。是一个长期存储在计算机内的、有组织的、可共享的、统一管理的大量数据的集合。

——上面这句定义对数据库的定义来自百度百科。通过这个定义,我们也能明显看出数据结构是实现数据库的基石。

从本质来看,数据库只负责两件事:读数据、写数据;而数据结构研究的是如何合理组织数据,尽可能提升读、写数据的效率,这恰好是数据库的核心问题。因此,数据结构与数据库这两个领域有非常多的交集。其中,数据库索引最能体现二者的紧密关联。

索引是数据库为了提高查找效率的一种数据结构。索引基于原始数据衍生而来,它的主要作用是缩小检索的数据范围,提升查询性能。通俗来说,索引在数据库中的作用就像是一本书的目录索引。索引对于良好的性能非常关键,在数据量小且负载较低时,不恰当的索引对于性能的影响可能还不明显;但随着数据量逐渐增大,性能则会急剧下降。因此,索引优化应该是查询性能优化的最有效手段

很多数据库允许单独添加和删除索引,而不影响数据库的内容,它只会影响查询性能。维护额外的结构势必会引入开销,特别是在新数据写入时。对于写入,它很难超过简单地追加文件方式的性能,因为那已经是最简单的写操作了。由于每次写数据时,需要更新索引,因此任何类型的索引通常都会降低写的速度。

本文以一些常见的数据库为例,分析它们的索引采用了什么样的数据结构,有什么利弊,为何如此设计。

数组和链表

数组和链表分别代表了连续空间和不连续空间的存储方式,它们是线性表(Linear List)的典型代表。其他所有的数据结构,比如栈、队列、二叉树、B+ 树等,实际上都是这两者的结合和变化。

数组用连续的内存空间来存储数据。数组**支持随机访问,根据下标随机访问的时间复杂度为 O(1)**。但这并不代表数组的查找时间复杂度也是 O(1)

  • **对于无序数组,只能顺序查找,其时间复杂度为 O(n)**。
  • **对于有序数组,可以应用二分查找法,其时间复杂度为 O(log n)**。

在有序数组上应用二分查找法如此高效,为什么几乎没有数据库直接使用数组作为索引?这是因为它的限制条件:数据有序——为了保证数据有序,每次添加、删除数组数据时,都必须要进行数据调整,来保证其有序,而 **数组的插入/删除操作,时间复杂度为 O(n)**。此外,由于数组空间大小固定,每次扩容只能采用复制数组的方式。数组的这些特性,决定了它不适合用于数据频繁变化的应用场景。

img

链表用不连续的内存空间来存储数据;并通过一个指针按顺序将这些空间串起来,形成一条链

区别于数组,链表中的元素不是存储在内存中连续的一片区域,链表中的数据存储在每一个称之为“结点”复合区域里,在每一个结点除了存储数据以外,还保存了到下一个节点的指针(Pointer)。由于不必按顺序存储,**链表的插入/删除操作,时间复杂度为 O(1)**,但是,链表只支持顺序访问,其 **查找时间复杂度为 O(n)**。其低效的查找方式,决定了链表不适合作为索引。

img

哈希索引

哈希表是一种以键 - 值(key-value)对形式存储数据的结构,我们只要输入待查找的值即 key,就可以找到其对应的值即 Value。

哈希表 使用 哈希函数 组织数据,以支持快速插入和搜索的数据结构。哈希表的本质是一个数组,其思路是:使用 Hash 函数将 Key 转换为数组下标,利用数组的随机访问特性,使得我们能在 O(1) 的时间代价内完成检索。

img

有两种不同类型的哈希表:哈希集合哈希映射

  • 哈希集合 是集合数据结构的实现之一,用于存储非重复值。
  • 哈希映射 是映射 数据结构的实现之一,用于存储键值对。

哈希索引基于哈希表实现,只适用于等值查询。对于每一行数据,哈希索引都会将所有的索引列计算一个哈希码(hashcode),哈希码是一个较小的值。哈希索引将所有的哈希码存储在索引中,同时在哈希表中保存指向每个数据行的指针。

✔️️️ 哈希索引的优点

  • 因为索引数据结构紧凑,所以查询速度非常快

❌ 哈希索引的缺点

  • 哈希索引值包含哈希值和行指针,而不存储字段值,所以不能使用索引中的值来避免读取行。不过,访问内存中的行的速度很快,所以大部分情况下这一点对性能影响不大。
  • 哈希索引数据不是按照索引值顺序存储的,所以无法用于排序
  • 哈希索引不支持部分索引匹配查找,因为哈希索引时使用索引列的全部内容来进行哈希计算的。如,在数据列 (A,B) 上建立哈希索引,如果查询只有数据列 A,无法使用该索引。
  • 哈希索引只支持等值比较查询,包括 =IN()<=>;不支持任何范围查询,如 WHERE price > 100
  • 哈希索引有可能出现哈希冲突
    • 出现哈希冲突时,必须遍历链表中所有的行指针,逐行比较,直到找到符合条件的行。
    • 如果哈希冲突多的话,维护索引的代价会很高。

因为种种限制,所以哈希索引只适用于特定的场合。而一旦使用哈希索引,则它带来的性能提升会非常显著。例如,Mysql 中的 Memory 存储引擎就显示的支持哈希索引。

B-Tree 索引

通常我们所说的 B 树索引是指 B-Tree 索引,它是目前关系型数据库中查找数据最为常用和有效的索引,大多数存储引擎都支持这种索引。使用 B-Tree 这个术语,是因为 MySQL 在 CREATE TABLE 或其它语句中使用了这个关键字,但实际上不同的存储引擎可能使用不同的数据结构,比如 InnoDB 使用的是 B+Tree索引;而 MyISAM 使用的是 B-Tree索引。

B-Tree 索引中的 B 是指 balance,意为平衡。需要注意的是,B-Tree 索引并不能找到一个给定键值的具体行,它找到的只是被查找数据行所在的页,接着数据库会把页读入到内存,再在内存中进行查找,最后得到要查找的数据。

二叉搜索树

二叉搜索树的特点是:每个节点的左儿子小于父节点,父节点又小于右儿子。其查询时间复杂度是 O(log n)

当然为了维持 O(log n) 的查询复杂度,你就需要保持这棵树是平衡二叉树。为了做这个保证,更新的时间复杂度也是 O(log n)

随着数据库中数据的增加,索引本身大小随之增加,不可能全部存储在内存中,因此索引往往以索引文件的形式存储的磁盘上。这样的话,索引查找过程中就要产生磁盘 I/O 消耗,相对于内存存取,I/O 存取的消耗要高几个数量级。可以想象一下一棵几百万节点的二叉树的深度是多少?如果将这么大深度的一颗二叉树放磁盘上,每读取一个节点,需要一次磁盘的 I/O 读取,整个查找的耗时显然是不能够接受的。那么如何减少查找过程中的 I/O 存取次数?

一种行之有效的解决方法是减少树的深度,将二叉树变为 N 叉树(多路搜索树),而 B+ 树就是一种多路搜索树

B+Tree 索引

B+ 树索引适用于全键值查找键值范围查找键前缀查找,其中键前缀查找只适用于最左前缀查找。

理解 B+Tree,只需要理解其最重要的两个特征即可:

  • 第一,所有的关键字(可以理解为数据)都存储在叶子节点,非叶子节点并不存储真正的数据,所有记录节点都是按键值大小顺序存放在同一层叶子节点上。
  • 其次,所有的叶子节点由指针连接。如下图为简化了的B+Tree

img

根据叶子节点的内容,索引类型分为主键索引和非主键索引。

  • 聚簇索引(clustered):又称为主键索引,其叶子节点存的是整行数据。因为无法同时把数据行存放在两个不同的地方,所以一个表只能有一个聚簇索引InnoDB 的聚簇索引实际是在同一个结构中保存了 B 树的索引和数据行
  • 非主键索引的叶子节点内容是主键的值。在 InnoDB 里,非主键索引也被称为二级索引(secondary)。数据存储在一个位置,索引存储在另一个位置,索引中包含指向数据存储位置的指针。可以有多个,小于 249 个。

聚簇表示数据行和相邻的键值紧凑地存储在一起,因为数据紧凑,所以访问快。因为无法同时把数据行存放在两个不同的地方,所以一个表只能有一个聚簇索引

聚簇索引和非聚簇索引的查询有什么区别

  • 如果语句是 select * from T where ID=500,即聚簇索引查询方式,则只需要搜索 ID 这棵 B+ 树;
  • 如果语句是 select * from T where k=5,即非聚簇索引查询方式,则需要先搜索 k 索引树,得到 ID 的值为 500,再到 ID 索引树搜索一次。这个过程称为回表

也就是说,基于非聚簇索引的查询需要多扫描一棵索引树。因此,我们在应用中应该尽量使用主键查询。

显然,主键长度越小,非聚簇索引的叶子节点就越小,非聚簇索引占用的空间也就越小。

自增主键是指自增列上定义的主键,在建表语句中一般是这么定义的: NOT NULL PRIMARY KEY AUTO_INCREMENT。从性能和存储空间方面考量,自增主键往往是更合理的选择。有没有什么场景适合用业务字段直接做主键的呢?还是有的。比如,有些业务的场景需求是这样的:

  • 只有一个索引;
  • 该索引必须是唯一索引。

由于没有其他索引,所以也就不用考虑其他索引的叶子节点大小的问题。这时候我们就要优先考虑上一段提到的“尽量使用主键查询”原则,直接将这个索引设置为主键,可以避免每次查询需要搜索两棵树。


内存是半导体元件。对于内存而言,只要给出了内存地址,我们就可以直接访问该地址取出数据。这个过程具有高效的随机访问特性,因此内存也叫随机访问存储器(Random Access Memory,即 RAM)。内存的访问速度很快,但是价格相对较昂贵,因此一般的计算机内存空间都相对较小。

而磁盘是机械器件。磁盘访问数据时,需要等磁盘盘片旋转到磁头下,才能读取相应的数据。尽管磁盘的旋转速度很快,但是和内存的随机访问相比,性能差距非常大。一般来说,如果是随机读写,会有 10 万到 100 万倍左右的差距。但如果是顺序访问大批量数据的话,磁盘的性能和内存就是一个数量级的。

磁盘的最小读写单位是扇区,较早期的磁盘一个扇区是 512 字节。随着磁盘技术的发展,目前常见的磁盘扇区是 4K 个字节。操作系统一次会读写多个扇区,所以操作系统的最小读写单位是块(Block),也叫作簇(Cluster)。当我们要从磁盘中读取一个数据时,操作系统会一次性将整个块都读出来。因此,对于大批量的顺序读写来说,磁盘的效率会比随机读写高许多。

假设有一个有序数组存储在硬盘中,如果它足够大,那么它会存储在多个块中。当我们要对这个数组使用二分查找时,需要先找到中间元素所在的块,将这个块从磁盘中读到内存里,然后在内存中进行二分查找。如果下一步要读的元素在其他块中,则需要再将相应块从磁盘中读入内存。直到查询结束,这个过程可能会多次访问磁盘。我们可以看到,这样的检索性能非常低。

由于磁盘相对于内存而言访问速度实在太慢,因此,对于磁盘上数据的高效检索,我们有一个极其重要的原则:对磁盘的访问次数要尽可能的少!

将索引和数据分离就是一种常见的设计思路。在数据频繁变化的场景中,有序数组并不是一个最好的选择,二叉检索树或者哈希表往往更有普适性。但是,哈希表由于缺乏范围检索的能力,在一些场合也不适用。因此,二叉检索树这种树形结构是许多常见检索系统的实施方案。

随着索引数据越来越大,直到无法完全加载到内存中,这是需要将索引数据也存入磁盘中。B+ 树给出了将树形索引的所有节点都存在磁盘上的高效检索方案。操作系统对磁盘数据的访问是以块为单位的。因此,如果我们想将树型索引的一个节点从磁盘中读出,即使该节点的数据量很小(比如说只有几个字节),但磁盘依然会将整个块的数据全部读出来,而不是只读这一小部分数据,这会让有效读取效率很低。B+ 树的一个关键设计,就是让一个节点的大小等于一个块的大小。节点内存储的数据,不是一个元素,而是一个可以装 m 个元素的有序数组。这样一来,我们就可以将磁盘一次读取的数据全部利用起来,使得读取效率最大化。

B+ 树还有另一个设计,就是将所有的节点分为内部节点和叶子节点。内部节点仅存储 key 和维持树形结构的指针,并不存储 key 对应的数据(无论是具体数据还是文件位置信息)。这样内部节点就能存储更多的索引数据,我们也就可以使用最少的内部节点,将所有数据组织起来了。而叶子节点仅存储 key 和对应数据,不存储维持树形结构的指针。通过这样的设计,B+ 树就能做到节点的空间利用率最大化。此外,B+ 树还将同一层的所有节点串成了有序的双向链表,这样一来,B+ 树就同时具备了良好的范围查询能力和灵活调整的能力了。

因此,B+ 树是一棵完全平衡的 m 阶多叉树。所谓的 m 阶,指的是每个节点最多有 m 个子节点,并且每个节点里都存了一个紧凑的可包含 m 个元素的数组。

即使是复杂的 B+ 树,我们将它拆解开来,其实也是由简单的数组、链表和树组成的,而且 B+ 树的检索过程其实也是二分查找。因此,如果 B+ 树完全加载在内存中的话,它的检索效率其实并不会比有序数组或者二叉检索树更
高,也还是二分查找的 log(n) 的效率。并且,它还比数组和二叉检索树更加复杂,还会带来额外的开销。

另外,这一节还有一个很重要的设计思想需要你掌握,那就是将索引和数据分离。通过这样的方式,我们能将索引的数组大小保持在一个较小的范围内,让它能加载在内存中。在许多大规模系统中,都是使用这个设计思想来精简索引的。而且,B+ 树的内部节点和叶子节点的区分,其实也是索引和数据分离的一次实践。

MySQL 中的 B+ 树实现其实有两种,一种是 MyISAM 引擎,另一种是 InnoDB 引擎。它们的核心区别就在于,数据和索引是否是分离的。

在 MyISAM 引擎中,B+ 树的叶子节点仅存储了数据的位置指针,这是一种索引和数据分离的设计方案,叫作非聚集索引。如果要保证 MyISAM 的数据一致性,那我们需要在表级别上进行加锁处理。

在 InnoDB 中,B+ 树的叶子节点直接存储了具体数据,这是一种索引和数据一体的方案。叫作聚集索引。由于数据直接就存在索引的叶子节点中,因此 InnoDB 不需要给全表加锁来保证一致性,它只需要支持行级的锁就可以了。

LSM 树

B+ 树的数据都存储在叶子节点中,而叶子节点一般都存储在磁盘中。因此,每次插入的新数据都需要随机写入磁盘,而随机写入的性能非常慢。如果是一个日志系统,每秒钟要写入上千条甚至上万条数据,这样的磁盘操作代价会使得系统性能急剧下降,甚至无法使用。

操作系统对磁盘的读写是以块为单位的,我们能否以块为单位写入,而不是每次插入一个数据都要随机写入磁盘呢?这样是不是就可以大幅度减少写入操作了呢?解决方案就是:LSM 树(Log Structured Merge Trees)。

LSM 树就是根据这个思路设计了这样一个机制:当数据写入时,延迟写磁盘,将数据先存放在内存中的树里,进行常规的存储和查询。当内存中的树持续变大达到阈值时,再批量地以块为单位写入磁盘的树中。因此,LSM 树至少需要由两棵树组成,一棵是存储在内存中较小的 C0 树,另一棵是存储在磁盘中较大的 C1 树。

LSM 树具有以下 3 个特点:

  1. 将索引分为内存和磁盘两部分,并在内存达到阈值时启动树合并(Merge Trees);
  2. 用批量写入代替随机写入,并且用预写日志 WAL 技术(Write AheadLog,预写日志技术)保证内存数据,在系统崩溃后可以被恢复;
  3. 数据采取类似日志追加写的方式写入(Log Structured)磁盘,以顺序写的方式提高写
    入效率。

LSM 树的这些特点,使得它相对于 B+ 树,在写入性能上有大幅提升。所以,许多 NoSQL 系统都使用 LSM 树作为检索引擎,而且还对 LSM 树进行了优化以提升检索性能。

倒排索引

倒排索引的核心其实并不复杂,它的具体实现其实是哈希表,只是它不是将文档 ID 或者题目作为 key,而是反过来,通过将内容或者属性作为 key 来存储对应的文档列表,使得我们能在 O(1) 的时间代价内完成查询。

尽管原理并不复杂,但是倒排索引是许多检索引擎的核心。比如说,数据库的全文索引功能、搜索引擎的索引、广告引擎和推荐引擎,都使用了倒排索引技术来实现检索功能。

索引的维护

创建索引

  • 数据压缩:一个是尽可能地将数据加载到内存中,因为内存的检索效率大大高于磁盘。那为了将数据更多地加载到内存中,索引压缩是一个重要的研究方向。
  • 分支处理:另一个是将大数据集合拆成多个小数据集合来处理。这其实就是分布式系统的核心思想。

更新索引

(1)Double Buffer(双缓冲)机制

就是在内存中同时保存两份一样的索引,一个是索引 A,一个是索引 B。两个索引保持一个读、一个写,并且来回切换,最终完成高性能的索引更新。

优点:简单高效

缺点:达到一定数据量级后,会带来翻倍的内存开销,甚至有些索引存储在磁盘上的情况下,更是无法使用此机制。

(2)全量索引和增量索引

将新接收到的数据单独建立一个可以存在内存中的倒排索引,也就是增量索引。当查询发生的时候,我们会同时查询全量索引和增量索引,将合并的结果作为总的结果输出。

因为增量索引相对全量索引而言会小很多,内存资源消耗在可承受范围,所以我们可以使用 Double Buffer 机制
对增量索引进行索引更新。这样一来,增量索引就可以做到无锁访问。而全量索引本身就是只读的,也不需要加锁。因此,整个检索过程都可以做到无锁访问,也就提高了系统的检索效率。

参考资料

复杂度分析

为什么需要复杂度分析

衡量算法的优劣,有两种评估方式:事前估计和后期测试。

后期测试有性能测试、基准测试(Benchmark)等手段。

但是,后期测试有以下限制:

  • 测试结果非常依赖测试环境。如:不同机型、不同编译器版本、不同硬件配置等等,都会影响测试结果。
  • 测试结果受数据规模的影响很大

所以,需要一种方法,可以不受环境或数据规模的影响,粗略地估计算法的执行效率。这种方法就是复杂度分析。

时间复杂度分析

大 O 表示法

假设问题的规模为 n,则程序的时间复杂度表示为 T(n)代码的执行时间 T(n) 与每行代码的执行次数 n 成正比

当 n 增大时,T(n) 也随之增大,想要准确估计其变化比较困难。所以,可以采用大 O 时间复杂度来粗略估计其复杂度,其表达式为:**T(n) = O(f(n))**。

大 O 表示法实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度(asymptotic time complexity),简称时间复杂度

时间复杂度分析的要点

  • 只关注循环执行次数最多的一段代码
  • 加法法则:总复杂度等于量级最大的那段代码的复杂度
  • 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积

最好、最坏和平均情况

  • 最好情况时间复杂度(best case time complexity):在最理想的情况下,执行代码的时间复杂度。例如:在最理想的情况下,要查找的变量 x 正好是数组的第一个元素,此时最好情况时间复杂度为 1。
  • 最坏情况时间复杂度(worst case time complexity):在最糟糕的情况下,执行代码的时间复杂度。例如:在最理想的情况下,要查找的变量 x 正好是数组的最后个元素,此时最好情况时间复杂度为 n。
  • 平均情况时间复杂度(average case time complexity):平均时间复杂度的全称应该叫加权平均时间复杂度或者期望时间复杂度

时间复杂度分析示例

【示例】从 1 累加到 100 的时间复杂度是多少?

1
2
3
4
5
int sum = 0;
int N = 100;
for (int i = 1; i <= N; i++) {
sum = sum + i;
}

时间复杂度计算:显然,这段代码执行了 100 次加法,其时间复杂度和 N 的大小完全一致

1
T(n) = O(n)

【示例】嵌套循环的时间复杂度是多少?

1
2
3
4
5
6
7
int M = 10;
int N = 20;
for (int i = 1; i < M; i++) {
for (int j = 1; j < N; j++) {
System.out.println("i = " + i + ", j = " + j);
}
}

时间复杂度计算:

1
T(n) = (M-1)(N-1) = O(M*N) ≈ O(N^2)

【示例】递归函数的时间复杂度是多少?思考一下斐波那契数列 f(n) = f(n-1) + f(n-2) 的时间复杂度是多少?

img

1
T(n) = O(2^N)

空间复杂度分析

时间复杂度的全称是渐进时间复杂度表示算法的执行时间与数据规模之间的增长关系

类比一下,空间复杂度全称就是渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系

复杂度量级

复杂度有以下量级:

  • **O(1)**:常数复杂度
  • **O(log n)**:对数复杂度
  • **O(n)**:线性复杂度
  • **O(nlog n)**:线性对数阶复杂度
  • **O(n^2)**:平方复杂度
  • **O(n^3)**:立方复杂度
  • **O(n^k)**:K 次方复杂度
  • **O(2^n)**:指数复杂度
  • **O(n!)**:阶乘复杂度

在数据量比较小的时候,复杂度量级差异并不明显;但是,随着数据规模大小的变化,差异会逐渐突出。

img

O(1) 复杂度示例:

1
2
int num = 100;
System.out.println("num = " + num);

O(log n) 对数复杂度示例:

1
2
3
4
int max = 100;
for (int i = 1; i < max; i = i * 2) {
System.out.println("i = " + i);
}

O(n) 复杂度示例:

1
2
3
4
int max = 100;
for (int i = 1; i < max; i++) {
System.out.println("i = " + i);
}

O(n^2) 复杂度示例:

1
2
3
4
5
6
7
int M = 10;
int N = 20;
for (int i = 1; i < M; i++) {
for (int j = 1; j < N; j++) {
System.out.println("i = " + i + ", j = " + j);
}
}

O(k^n) 复杂度示例:

1
2
3
4
int max = 10;
for (int i = 1; i <= Math.pow(2, max); i++) {
System.out.println("i = " + i);
}

常见数据结构的复杂度

img

参考资料