Dunwu Blog

大道至简,知易行难

领域驱动设计简介

DDD 简介

软件架构模式的演进

第一阶段是单机架构:采用面向过程的设计方法,系统包括客户端 UI 层和数据库两层,采用 C/S 架构模式,整个系统围绕数据库驱动设计和开发,并且总是从设计数据库和字段开始。

第二阶段是集中式架构:采用面向对象的设计方法,系统包括业务接入层、业务逻辑层和数据库层,采用经典的三层架构,也有部分应用采用传统的 SOA 架构。这种架构容易使系统变得臃肿,可扩展性和弹性伸缩性差。

第三阶段是分布式微服务架构:随着微服务架构理念的提出,集中式架构正向分布式微服务架构演进。微服务架构可以很好地实现应用之间的解耦,解决单体应用扩展性和弹性伸缩能力不足的问题。

在单机和集中式架构时代,系统分析、设计和开发往往是独立、分阶段割裂进行的。

什么是 DDD

DDD 是一种处理高度复杂领域的设计思想,它试图分离技术实现的复杂性,并围绕业务概念构建领域模型来控制业务的复杂性,以解决软件难以理解,难以演进的问题。DDD 不是架构,而是一种架构设计方法论,它通过边界划分将复杂业务领域简单化,帮我们设计出清晰的领域和应用边界,可以很容易地实现架构演进。DDD 分为两个思维层面:

  • 战略设计主要从业务视角出发,建立业务领域模型,划分领域边界,建立通用语言的上下文边界,上下文边界可以作为微服务设计的参考边界。
  • 战术设计则从技术视角出发,侧重于领域模型的技术实现,完成软件开发和落地,包括:聚合根、实体、值对象、领域服务、应用服务和资源库等代码逻辑的设计和实现。

DDD 与微服务的关系

DDD 是一种架构设计方法,微服务是一种架构风格。两者都是为了拆解业务复杂度:合理划分领域边界,持续调整现有架构,优化现有代码,以保持架构和代码的生命力,也就是我们常说的演进式架构。

DDD 主要关注:从业务领域视角划分领域边界,构建通用语言进行高效沟通,通过业务抽象,建立领域模型,维持业务和代码的逻辑一致性。

微服务主要关注:运行时的进程间通信、容错和故障隔离,实现去中心化数据管理和去中心化服务治理,关注微服务的独立开发、测试、构建和部署。

DDD 核心概念

img

  • 领域:领域具体指一种特定的范围。领域是用来限定业务边界的,那么就会有大小之分,领域越大,业务范围就越大,反之则相反。
  • 子域:领域可以进一步划分为子领域。我们把划分出来的多个子领域称为子域,每个子域对应一个更小的问题域或更小的业务范围。
  • 核心域:决定产品和公司核心竞争力的子域是核心域。
  • 通用域:同时被多个子域使用的通用功能子域是通用域。
  • 支撑域:还有一种功能子域是必需的,但既非核心域也非通用域,它就是支撑域。

领域的核心思想就是将问题域逐级细分,来降低业务理解和系统实现的复杂度。通过领域细分,逐步缩小微服务需要解决的问题域,构建合适的领域模型,而领域模型映射成系统就是微服务了。

核心域、支撑域和通用域的主要目标是:通过领域划分,区分不同子域在公司内的不同功能属性和重要性,从而公司可对不同子域采取不同的资源投入和建设策略,其关注度也会不一样。

通用语言和上下文边界

通用语言:通过团队交流达成共识性的,能够简单、清晰、准确描述业务涵义和规则的语言。

上下文边界:限界就是领域的边界,而上下文则是语义环境。通过领域的上下文边界,我们就可以在统一的领域边界内用统一的语言进行交流。综合一下,上下文边界的定义就是:用来封装通用语言和领域对象,提供上下文环境,保证在领域之内的一些术语、业务相关对象等(通用语言)有一个确切的含义,没有二义性。这个边界定义了模型的适用范围,使团队所有成员能够明确地知道什么应该在模型中实现,什么不应该在模型中实现。

实体和值对象

实体是多个属性、操作或行为的载体。在事件风暴中,我们可以根据命令、操作或者事件,找出产生这些行为的业务实体对象,进而按照一定的业务规则将依存度高和业务关联紧密的多个实体对象和值对象进行聚类,形成聚合。你可以这么理解,实体和值对象是组成领域模型的基础单元。

本质上,实体是看得到、摸得着的实实在在的业务对象,实体具有业务属性、业务行为和业务逻辑。而值对象只是若干个属性的集合,只有数据初始化操作和有限的不涉及修改数据的行为,基本不包含业务逻辑。值对象的属性集虽然在物理上独立出来了,但在逻辑上它仍然是实体属性的一部分,用于描述实体的特征。

聚合和聚合跟

领域模型内的实体和值对象就好比个体,而能让实体和值对象协同工作的组织就是聚合,它用来确保这些领域对象在实现共同的业务逻辑时,能保证数据的一致性。

聚合就是由业务和逻辑紧密关联的实体和值对象组合而成的,聚合是数据修改和持久化的基本单元,每一个聚合对应一个仓储,实现数据的持久化。聚合有一个聚合根和上下文边界,这个边界根据业务单一职责和高内聚原则,定义了聚合内部应该包含哪些实体和值对象,而聚合之间的边界是松耦合的。

聚合根的主要目的是为了避免由于复杂数据模型缺少统一的业务规则控制,而导致聚合、实体之间数据不一致性的问题。如果把聚合比作组织,那聚合根就是这个组织的负责人。聚合根也称为根实体,它不仅是实体,还是聚合的管理者。

img

聚合设计步骤

  • 第 1 步:采用事件风暴,根据业务行为,梳理出所有的实体和值对象。
  • 第 2 步:从众多实体中选出适合作为对象管理者的根实体,也就是聚合根。判断一个实体
    是否是聚合根,你可以结合以下场景分析:是否有独立的生命周期?是否有全局唯一 ID?
    是否可以创建或修改其它对象?是否有专门的模块来管这个实体。
  • 第 3 步:根据业务单一职责和高内聚原则,找出与聚合根关联的所有紧密依赖的实体和值
    对象。
  • 第 4 步:在聚合内根据聚合根、实体和值对象的依赖关系,画出对象的引用和依赖模型。
  • 第 5 步:多个聚合根据业务语义和上下文一起划分到同一个限界上下文内。

聚合设计原则

  • 在一致性边界内建模真正的不变条件。
  • 设计小聚合。
  • 通过唯一标识引用其它聚合。
  • 在边界之外使用最终一致性。
  • 通过应用层实现跨聚合的服务调用。

架构模型

DDD 架构

img

三层架构向 DDD 分层架构演进,主要发生在业务逻辑层和数据访问层。

DDD 分层架构包含用户接口层、应用层、领域层和基础层。通过这些层次划分,我们可以明确微服务各层的职能,划定各领域对象的边界,确定各领域对象的协作方式。

整洁架构

在整洁架构里,同心圆代表应用软件的不同部分,从里到外依次是领域模型、领域服务、应用服务和最外围的容易变化的内容,比如用户界面和基础设施。

整洁架构最主要的原则是依赖原则,它定义了各层的依赖关系,越往里依赖越低,代码级别越高,越是核心能力。外圆代码依赖只能指向内圆,内圆不需要知道外圆的任何情况。

img

六边形架构

六边形架构的核心理念是:应用是通过端口与外部进行交互的。我想这也是微服务架构下 API 网关盛行的主要原因吧。

也就是说,在下图的六边形架构中,红圈内的核心业务逻辑(应用程序和领域模型)与外部资源(包括 APP、Web 应用以及数据库资源等)完全隔离,仅通过适配器进行交互。它解决了业务逻辑与用户界面的代码交错问题,很好地实现了前后端分离。六边形架构各层的依赖关系与整洁架构一样,都是由外向内依赖。

六边形架构将系统分为内六边形和外六边形两层,这两层的职能划分如下:

红圈内的六边形实现应用的核心业务逻辑;

外六边形完成外部应用、驱动和基础资源等的交互和访问,对前端应用以 API 主动适配的方式提供服务,对基础资源以依赖倒置被动适配的方式实现资源访问。

三种架构对比

这三种架构模型的设计思想正是微服务架构高内聚低耦合原则的完美体现。

img

架构模型和中台、微服务的联系

中台本质上是领域的子域,它可能是核心域,也可能是通用域或支撑域。通常大家认为阿里的中台对应 DDD 的通用域,将通用的公共能力沉淀为中台,对外提供通用共享服务。

DDD、中台、微服务这三者之间似乎没什么关联,实际上它们的关系是非常紧密的,组合在一起可以作为
一个理论体系用于你的中台和微服务设计。

中台建设要聚焦领域模型

中台需要站在全企业的高度考虑能力的共享和复用。

中台设计时,我们需要建立中台内所有限界上下文的领域模型,DDD 建模过程中会考虑架构演进和功能的重新组合。领域模型建立的过程会对业务和应用进行清晰的逻辑和物理边界(微服务)划分。领域模型的结果会影响到后续的系统模型、架构模型和代码模型,最终影响到微服务的拆分和项目落地。

微服务要有合理的架构分层

微服务设计要有分层的设计思想,让各层各司其职,建立松耦合的层间关系。

不要把与领域无关的逻辑放在领域层实现,保证领域层的纯洁和领域逻辑的稳定,避免污染领域模型。也不要把领域模型的业务逻辑放在应用层,这样会导致应用层过于庞大,最终领域模型会失焦。

应用和资源的解耦与适配

传统以数据为中心的设计模式,应用会对数据库、缓存、文件系统等基础资源产生严重依赖。

正是由于它们之间的这种强依赖的关系,我们一旦更换基础资源就会对应用产生很大的影响,因此需要为应用和资源解耦。

中台战略

平台不是中台

中台源于平台,但它的战略高度要比平台高很多。

平台只是将部分通用的公共能力独立为共享平台。虽然可以通过 API 或者数据对外提供公共共享服务,解决系统重复建设的问题,但这类平台并没有和企业内的其它平台或应用,实现页面、业务流程和数据从前端到后端的全面融合,并且没有将核心业务服务链路作为一个整体方案考虑,各平台仍然是分离且独立的

简单的理解就是把传统的前后台体系中的后台进行了细分。阿里巴巴提出了大中台小前台的战略。就是强化业务和技术中台,把前端的应用变得更小更灵活。当中台越强大,能力就越强,越能更好的快速响应前台的业务需求。打个比喻,就是土壤越肥沃,越适合生长不同的生物,打造好的生态系统。

img

什么是中台

中台是一个基础的理念和架构,我们要把所有的基础服务用中台的思路建设,进行联通,共同支持上端的业务。业务中台更多的是支持在线业务,数据中台提供了基础数据处理能力和很多的数据产品给所有业务方去用。业务中台、数据中台、算法中台等等一起提供对上层业务的支撑。

中台的关键词:共享、联通、融合和创新。联通是前台以及中台之间的联通,融合是前台流程和数据的融合,并以共享的方式支持前端一线业务的发展和创新。其中最关键的是快速响应能力和企业级的无缝联通和融合能力,尤其是对于跨业经营的超大型企业来说至关重要。

数字化转型中台

前中后台协同

前台

在前台设计中我们可以借鉴微前端的设计思想,在企业内不仅实现前端解耦和复用,还可以根据核心链路和业务流程,通过对微前端页面的动态组合和流程编排,实现前台业务的融合。

前端页面可以很自然地融合到不同的终端和渠道应用核心业务链路中,实现前端页面、流程和功能复用。

中台

业务中台的建设可采用领域驱动设计方法,通过领域建模,将可复用的公共能力从各个单体剥离,沉淀并组合,采用微服务架构模式,建设成为可共享的通用能力中台。

同样的,我们可以将核心能力用微服务架构模式,建设成为可面向不同渠道和场景的可复用的核心能力中台。 业务中台向前台、第三方和其它中台提供 API 服务,实现通用能力和核心能力的复用。

数据中台的主要目标是打通数据孤岛,实现业务融合和创新,包括三大主要职能:

  • 一是完成企业全域数据的采集与存储,实现各不同业务类别中台数据的汇总和集中管理。
  • 二是按照标准的数据规范或数据模型,将数据按照不同主题域或场景进行加工和处理,形成面向不同主题和场景的数据应用,比如客户视图、代理人视图、渠道视图、机构视图等不同数据体系。
  • 三是建立业务需求驱动的数据体系,基于各个维度的数据,深度萃取数据价值,支持业务和商业模式的创新。

相应的,数据中台的建设就可分为三步走:

  • 第一步实现各中台业务数据的汇集,解决数据孤岛和初级数据共享问题。
  • 第二步实现企业级实时或非实时全维度数据的深度融合、加工和共享。
  • 第三步萃取数据价值,支持业务创新,加速从数据转换为业务价值的过程。

后台

前台主要面向客户以及终端销售者,实现营销推广以及交易转化;中台主要面向运营人员,完成运营支撑;后台主要面向后台管理人员,实现流程审核、内部管理以及后勤支撑,比如采购、人力、财务和 OA 等系统。

DDD、中台和微服务的协作

传统企业可以将需要共享的公共能力进行领域建模,建设可共享的通用中台。除此之外,传统企业还会将核心能力进行领域建模,建设面向不同渠道的可复用的核心中台。

如何构建中台

自顶向下策略

自顶向下的策略适用于全新的应用系统建设,或旧系统推倒重建的情况。这种策略是先做顶层设计,从最高领域逐级分解为中台,分别建立领域模型,根据业务属性分为通用中台或核心中台。领域建模过程主要基于业务现状,暂时不考虑系统现状。

自顶向下策略

自底向上策略适用于遗留系统业务模型的演进式重构。这种策略是基于业务和系统现状完成领域建模。首先分别完成系统所在业务域的领域建模;然后对齐业务域,找出具有同类或相似业务功能的领域模型,对比分析领域模型的差异,重组领域对象,重构领域模型。这个过程会沉淀公共和复用的业务能力,会将分散的业务模型整合。

构建步骤

第一步:锁定系统所在业务域,构建领域模型。

img

第二步:对齐业务域,构建中台业务模型。

第三步:中台归类,根据领域模型设计微服务。

边界

逻辑边界:微服务内聚合之间的边界是逻辑边界。它是一个虚拟的边界,强调业务的内聚,可根据需要变成物理边界,也就是说聚合也可以独立为微服务。

物理边界:微服务之间的边界是物理边界。它强调微服务部署和运行的隔离,关注微服务的服务调用、容错和运行等。

代码边界:不同层或者聚合之间代码目录的边界是代码边界。它强调的是代码之间的隔离,方便架构演进时代码的重组。

通过以上边界,我们可以让业务能力高内聚、代码松耦合,且清晰的边界,可以快速实现微服务代码的拆分和组合,轻松实现微服务架构演进。但有一点一定要格外注意,边界清晰的微服务,不是大单体向小单体的演进。

参考资料

系统架构面试

如何设计一个秒杀系统?

秒杀系统的挑战

秒杀的核心问题就是极高并发处理,由于系统要在瞬时承受平时数十倍甚至上百倍的流量,这往往超出系统上限,因此处理秒杀的核心思路是限流和缓存

秒杀系统的解决思路

  • 系统上有拦截流量:尽可能在上游拦截和限制请求,限制流入后端的量,保证后端系统正常。 因为无论多少人参与秒杀,实际成交往往是有限的,而且远小于参加秒杀的人数,因此可以通过前端系统进行拦截,限制最终流入系统的请求数量,来保证系统正常进行。
  • 充分利用缓存:这是一个典型的读多写少的应用场景(一趟火车其实只有 2000 张票,200w 个人来买,最多 2000 个人下单成功,其他人都是查询库存,写比例只有 0.1%,读比例占 99.9%),非常适合使用缓存。

秒杀系统的解决方案

秒杀系统具体方案如下:

(1)浏览器、客户端拦截重复请求

  • 用户点击查询或购买按钮后,禁用按钮,避免用户重复提交请求。
  • JS 代码中限制用户在限定时间内只允许提交一次请求

基于此,大部分流量已被拦截。

(2)应用层拦截请求

浏览器、客户端拦截重复请求只能应付通过浏览器访问的用户。如果有人通过程序发送 http 请求,则无法拦截。针对这种情况的方案是:

以页面缓存的方式,针对短时间内的同一个访问源(如同一个 IP、同一个 Session、同一个用户 ID 多次发送 HTTP 请求)或同样的查询请求(如大量请求都是查询某类商品的库存),都返回相同的展示页面。

如此限流,又有大部分的流量被拦截

(3)服务层请求拦截与数据缓存

加入有黑客,控制了 10w 台肉鸡(并且假设买票不需要实名认证),前面的的限制都不起作用了。这时应该怎么办?

  • 读请求(查库存) - 对于读请求,直接使用缓存即可,一般缓存服务器单机处理每秒 10w 个请求应该没什么问题。

  • 写请求(下单) - 由于服务层清楚的知道库存数量,所以完全可以根据库存数量进行限流。具体来说,就是把所有下单请求都丢该消息队列中,每次只取有限的写请求去数据层处理。当这些写请求处理完,更新一下缓存中的库存数,再去取下一批写请求,如果库存数不够,则消息队列的写请求全部返回”已售罄”的结果。

参考:

参考资料

Java 面向对象

Java 基本数据类型 中我们了解 Java 中支持的基本数据类型(值类型)。本文开始讲解 Java 中重要的引用类型——类。

面向对象

每种编程语言,都有自己的操纵内存中元素的方式。

Java 中提供了基本数据类型,但这还不能满足编写程序时,需要抽象更加复杂数据类型的需要。因此,Java 中,允许开发者通过类(类的机制下面会讲到)创建自定义类型。

有了自定义类型,那么数据类型自然会千变万化,所以,必须要有一定的机制,使得它们仍然保持一些必要的、通用的特性。

Java 世界有一句名言:一切皆为对象。这句话,你可能第一天学 Java 时,就听过了。这不仅仅是一句口号,也体现在 Java 的设计上。

  • 首先,所有 Java 类都继承自 Object 类(从这个名字,就可见一斑)。
  • 几乎所有 Java 对象初始化时,都要使用 new 创建对象(基本数据类型、String、枚举特殊处理),对象存储在堆中。
1
2
3
// 下面两
String s = "abc";
String s = new String("abc");

其中,String s 定义了一个名为 s 的引用,它指向一个 String 类型的对象,而实际的对象是 “abc” 字符串。这就像是,使用遥控器(引用)来操纵电视机(对象)。

与 C/C++ 这类语言不同,程序员只需要通过 new 创建一个对象,但不必负责销毁或结束一个对象。负责运行 Java 程序的 Java 虚拟机有一个垃圾回收器,它会监视 new 创建的对象,一旦发现对象不再被引用,则会释放对象的内存空间。

封装

封装(Encapsulation)是指一种将抽象性函式接口的实现细节部份包装、隐藏起来的方法。

封装最主要的作用在于我们能修改自己的实现代码,而不用修改那些调用我们代码的程序片段。

适当的封装可以让程式码更容易理解与维护,也加强了程式码的安全性。

封装的优点:

  • 良好的封装能够减少耦合。
  • 类内部的结构可以自由修改。
  • 可以对成员变量进行更精确的控制。
  • 隐藏信息,实现细节。

实现封装的步骤:

  1. 修改属性的可见性来限制对属性的访问(一般限制为 private)。
  2. 对每个值属性提供对外的公共方法访问,也就是创建一对赋取值方法,用于对私有属性的访问。

继承

继承是 java 面向对象编程技术的一块基石,因为它允许创建分等级层次的类。

继承就是子类继承父类的特征和行为,使得子类对象(实例)具有父类的实例域和方法,或子类从父类继承方法,使得子类具有父类相同的行为。

现实中的例子:

狗和鸟都是动物。如果将狗、鸟作为类,它们可以继承动物类。

img

类的继承形式:

1
2
3
class 父类 {}

class 子类 extends 父类 {}

继承类型

img

继承的特性

  • 子类可以继承父类的属性和方法。需要注意的是,构造方法除外,构造方法只能被调用,而不能被继承。
  • 子类可以拥有自己的属性和方法,即子类可以对父类进行扩展。
  • 子类可以用自己的方式实现父类的方法。
  • Java 的继承是单继承,但是可以多重继承,单继承就是一个子类只能继承一个父类,多重继承就是,例如 A 类继承 B 类,B 类继承 C 类,所以按照关系就是 C 类是 B 类的父类,B 类是 A 类的父类,这是 Java 继承区别于 C++ 继承的一个特性。
  • 提高了类之间的耦合性(继承的缺点,耦合度高就会造成代码之间的联系越紧密,代码独立性越差)。

继承关键字

继承可以使用 extendsimplements 这两个关键字来实现继承,而且所有的类都是继承于 java.lang.Object,当一个类没有继承的两个关键字,则默认继承 Object(这个类在 java.lang 包中,所以不需要 **import**)祖先类。

多态

刚开始学习面向对象编程时,容易被各种术语弄得云里雾里。所以,很多人会死记硬背书中对于术语的定义。

但是,随着应用和理解的深入,应该会渐渐有更进一步的认识,将其融汇贯通的理解。

学习类之前,先让我们思考一个问题:Java 中为什么要引入类机制,设计的初衷是什么?

Java 中提供的基本数据类型,只能表示单一的数值,这用于数值计算,还 OK。但是,如果要抽象模拟现实中更复杂的事物,则无法做到。

试想,如果要让你抽象狗的数据模型,怎么做?狗有眼耳口鼻等器官,有腿,狗有大小,毛色,这些都是它的状态,狗会跑、会叫、会吃东西,这些是它的行为。

类的引入,就是为了抽象这种相对复杂的事物。

对象是用于计算机语言对问题域中事物的描述。对象通过方法和属性来分别描述事物所具有的行为和状态。

类是用于描述同一类的对象的一个抽象的概念,类中定义了这一类对象所具有的行为和状态。

类可以看成是创建 Java 对象的模板。

什么是方法?扩展阅读:面向对象编程的弊端是什么? - invalid s 的回答

与大多数面向对象编程语言一样,Java 使用 class (类)关键字来表示自定义类型。自定义类型是为了更容易抽象现实事物。

在一个类中,可以设置一静一动两种元素:属性(静)和方法(动)。

  • 属性(有的人喜欢称为成员、字段) - 属性抽象的是事物的状态。类属性可以是任何类型的对象。
  • 方法(有的人喜欢称为函数) - 方法抽象的是事物的行为。

类的形式如下:

img

方法

方法定义

1
2
3
4
5
6
修饰符 返回值类型 方法名(参数类型 参数名){
...
方法体
...
return 返回值;
}

方法包含一个方法头和一个方法体。下面是一个方法的所有部分:

  • 修饰符:修饰符,这是可选的,告诉编译器如何调用该方法。定义了该方法的访问类型。
  • 返回值类型 :方法可能有返回值。如果没有返回值,这种情况下,返回值类型应设为 void。
  • 方法名:是方法的实际名称。方法名和参数表共同构成方法签名。
  • 参数类型:参数像是一个占位符。当方法被调用时,传递值给参数。这个值被称为实参或变量。参数列表是指方法的参数类型、顺序和参数的个数。参数是可选的,方法可以不包含任何参数。
  • 方法体:方法体包含具体的语句,定义该方法的功能。

示例:

1
2
3
public static int add(int x, int y) {
return x + y;
}

方法调用

Java 支持两种调用方法的方式,根据方法是否返回值来选择。

当程序调用一个方法时,程序的控制权交给了被调用的方法。当被调用方法的返回语句执行或者到达方法体闭括号时候交还控制权给程序。

当方法返回一个值的时候,方法调用通常被当做一个值。例如:

1
int larger = max(30, 40);

如果方法返回值是 void,方法调用一定是一条语句。例如,方法 println 返回 void。下面的调用是个语句:

1
System.out.println("Hello World");

构造方法

每个类都有构造方法。如果没有显式地为类定义任何构造方法,Java 编译器将会为该类提供一个默认构造方法。

在创建一个对象的时候,至少要调用一个构造方法。构造方法的名称必须与类同名,一个类可以有多个构造方法。

1
2
3
4
5
6
7
8
public class Puppy{
public Puppy(){
}

public Puppy(String name){
// 这个构造器仅有一个参数:name
}
}

变量

Java 支持的变量类型有:

  • 局部变量 - 类方法中的变量。
  • 实例变量(也叫成员变量) - 类方法外的变量,不过没有 static 修饰。
  • 类变量(也叫静态变量) - 类方法外的变量,用 static 修饰。

特性对比:

局部变量 实例变量(也叫成员变量) 类变量(也叫静态变量)
局部变量声明在方法、构造方法或者语句块中。 实例变量声明在方法、构造方法和语句块之外。 类变量声明在方法、构造方法和语句块之外。并且以 static 修饰。
局部变量在方法、构造方法、或者语句块被执行的时候创建,当它们执行完成后,变量将会被销毁。 实例变量在对象创建的时候创建,在对象被销毁的时候销毁。 类变量在第一次被访问时创建,在程序结束时销毁。
局部变量没有默认值,所以必须经过初始化,才可以使用。 实例变量具有默认值。数值型变量的默认值是 0,布尔型变量的默认值是 false,引用类型变量的默认值是 null。变量的值可以在声明时指定,也可以在构造方法中指定。 类变量具有默认值。数值型变量的默认值是 0,布尔型变量的默认值是 false,引用类型变量的默认值是 null。变量的值可以在声明时指定,也可以在构造方法中指定。此外,静态变量还可以在静态语句块中初始化。
对于局部变量,如果是基本类型,会把值直接存储在栈;如果是引用类型,会把其对象存储在堆,而把这个对象的引用(指针)存储在栈。 实例变量存储在堆。 类变量存储在静态存储区。
访问修饰符不能用于局部变量。 访问修饰符可以用于实例变量。 访问修饰符可以用于类变量。
局部变量只在声明它的方法、构造方法或者语句块中可见。 实例变量对于类中的方法、构造方法或者语句块是可见的。一般情况下应该把实例变量设为私有。通过使用访问修饰符可以使实例变量对子类可见。 与实例变量具有相似的可见性。但为了对类的使用者可见,大多数静态变量声明为 public 类型。
实例变量可以直接通过变量名访问。但在静态方法以及其他类中,就应该使用完全限定名:ObejectReference.VariableName。 静态变量可以通过:ClassName.VariableName 的方式访问。
无论一个类创建了多少个对象,类只拥有类变量的一份拷贝。
类变量除了被声明为常量外很少使用。

变量修饰符

  • 访问级别修饰符 - 如果变量是实例变量或类变量,可以添加访问级别修饰符(public/protected/private)
  • 静态修饰符 - 如果变量是类变量,需要添加 static 修饰
  • final - 如果变量使用 final 修饰符,就表示这是一个常量,不能被修改。

创建对象

对象是根据类创建的。在 Java 中,使用关键字 new 来创建一个新的对象。创建对象需要以下三步:

  • 声明:声明一个对象,包括对象名称和对象类型。
  • 实例化:使用关键字 new 来创建一个对象。
  • 初始化:使用 new 创建对象时,会调用构造方法初始化对象。
1
2
3
4
5
6
7
8
9
10
public class Puppy{
public Puppy(String name){
//这个构造器仅有一个参数:name
System.out.println("小狗的名字是 : " + name );
}
public static void main(String[] args){
// 下面的语句将创建一个Puppy对象
Puppy myPuppy = new Puppy( "tommy" );
}
}

访问实例变量和方法

1
2
3
4
5
6
/* 实例化对象 */
ObjectReference = new Constructor();
/* 访问类中的变量 */
ObjectReference.variableName;
/* 访问类中的方法 */
ObjectReference.methodName();

访问权限控制

代码组织

当编译一个 .java 文件时,在 .java 文件中的每个类都会输出一个与类同名的 .class 文件。

MultiClassDemo.java 示例:

1
2
3
4
5
6
7
class MultiClass1 {}

class MultiClass2 {}

class MultiClass3 {}

public class MultiClassDemo {}

执行 javac MultiClassDemo.java 命令,本地会生成 MultiClass1.class、MultiClass2.class、MultiClass3.class、MultiClassDemo.class 四个文件。

Java 可运行程序是由一组 .class 文件打包并压缩成的一个 .jar 文件。Java 解释器负责这些文件的查找、装载和解释。Java 类库实际上是一组类文件(.java 文件)。

  • 其中每个文件允许有一个 public 类,以及任意数量的非 public 类
  • public 类名必须和 .java 文件名完全相同,包括大小写。

程序一般不止一个人编写,会调用系统提供的代码、第三方库中的代码、项目中其他人写的代码等,不同的人因为不同的目的可能定义同样的类名/接口名,这就是命名冲突。

Java 中为了解决命名冲突问题,提供了包(package)和导入(import)机制。

package

包(package)的原则:

  • 包类似于文件夹,文件放在文件夹中,类和接口则放在包中。为了便于组织,文件夹一般是一个有层次的树形结构,包也类似。
  • 包名以逗号 . 分隔,表示层次结构。
  • Java 中命名包名的一个惯例是使用域名作为前缀,因为域名是唯一的,一般按照域名的反序来定义包名,比如,域名是:apache.org,包名就以 org.apache 开头。
  • 包名和文件目录结构必须完全匹配。Java 解释器运行过程如下:
    • 找出环境变量 CLASSPATH,作为 .class 文件的根目录。
    • 从根目录开始,获取包名称,并将逗号 . 替换为文件分隔符(反斜杠 /),通过这个路径名称去查找 Java 类。

import

同一个包下的类之间互相引用是不需要包名的,可以直接使用。但如果类不在同一个包内,则必须要知道其所在的包,使用有两种方式:

  • 通过类的完全限定名
  • 通过 import 将用到的类引入到当前类

通过类的完全限定名示例:

1
2
3
4
5
6
public class PackageDemo {
public static void main (String[]args){
System.out.println(new java.util.Date());
System.out.println(new java.util.Date());
}
}

通过 import 导入其它包的类到当前类:

1
2
3
4
5
6
7
8
import java.util.Date;

public class PackageDemo2 {
public static void main(String[] args) {
System.out.println(new Date());
System.out.println(new Date());
}
}

说明:以上两个示例比较起来,显然是 import 方式,代码更加整洁。

扩展阅读:https://www.cnblogs.com/swiftma/p/5628762.html

访问权限修饰关键字

访问权限控制的等级,从最大权限到最小权限依次为:

1
public > protected > 包访问权限(没有任何关键字)> private
  • public - 表示任何类都可以访问;
  • 包访问权限 - 包访问权限,没有任何关键字。它表示当前包中的所有其他类都可以访问,但是其它包的类无法访问。
  • protected - 表示子类可以访问,此外,同一个包内的其他类也可以访问,即使这些类不是子类。
  • private - 表示其它任何类都无法访问。

接口

接口是对行为的抽象,它是抽象方法的集合,利用接口可以达到 API 定义和实现分离的目的。

接口,不能实例化;不能包含任何非常量成员,任何 field 都是隐含着 public static final 的意义;同时,没有非静态方法实现,也就是说要么是抽象方法,要么是静态方法。

Java 标准类库中,定义了非常多的接口,比如 java.util.List

1
2
3
public interface Comparable<T> {
public int compareTo(T o);
}

抽象类

抽象类是不能实例化的类,用 abstract 关键字修饰 class,其目的主要是代码重用。除了不能实例化,形式上和一般的 Java 类并没有太大区别,可以有一个或者多个抽象方法,也可以没有抽象方法。抽象类大多用于抽取相关 Java 类的共用方法实现或者是共同成员变量,然后通过继承的方式达到代码复用的目的。

Java 标准库中,比如 collection 框架,很多通用部分就被抽取成为抽象类,例如 java.util.AbstractList

  1. 抽象类不能被实例化(初学者很容易犯的错),如果被实例化,就会报错,编译无法通过。只有抽象类的非抽象子类可以创建对象。
  2. 抽象类中不一定包含抽象方法,但是有抽象方法的类必定是抽象类。
  3. 抽象类中的抽象方法只是声明,不包含方法体,就是不给出方法的具体实现也就是方法的具体功能。
  4. 构造方法,类方法(用 static 修饰的方法)不能声明为抽象方法。
  5. 抽象类的子类必须给出抽象类中的抽象方法的具体实现,除非该子类也是抽象类。

参考资料

Java 虚拟机之工具

JVM 命令行工具

Java 程序员免不了故障排查工作,所以经常需要使用一些 JVM 工具。

JDK 自带了一些实用的命令行工具来监控、分析 JVM 信息,掌握它们,非常有助于 TroubleShooting。

以下是较常用的 JDK 命令行工具:

名称 描述
jps 虚拟机进程状况工具。显示系统内的所有 JVM 进程。
jstat JVM 统计监控工具。监控虚拟机运行时状态信息,它可以显示出 JVM 进程中的类装载、内存、GC、JIT 编译等运行数据。
jmap JVM 堆内存分析工具。用于打印 JVM 进程对象直方图、类加载统计。并且可以生成堆转储快照(一般称为 heapdump 或 dump 文件)。
jstack JVM 栈查看工具。用于打印 JVM 进程的线程和锁的情况。并且可以生成线程快照(一般称为 threaddump 或 javacore 文件)。
jhat 用来分析 jmap 生成的 dump 文件。
jinfo JVM 信息查看工具。用于实时查看和调整 JVM 进程参数。
jcmd JVM 命令行调试 工具。用于向 JVM 进程发送调试命令。

jps:虚拟机进程状况工具

jps(JVM Process Status Tool) 是虚拟机进程状态工具。它可以列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main() 函数所在的类)名称以及这些进程的本地虚拟机唯一 ID(LVMID,Local Virtual Machine Identifier)。对于本地虚拟机进程来说,LVMID 与操作系统的进程 ID(PID,Process Identifier)是一致的。

jps 命令格式:

1
jps [option] [hostid]

jps 还可以通过 RMI 协议查询开启了 RMI 服务的远程虚拟机进程状态,参数 hostid 为 RMI 注册表中 注册的主机名。

  • option - 选项参数
    • -m - 输出 JVM 启动时传递给 main() 的参数。
    • -l - 输出主类的全名,如果进程执行的是 jar 包,输出 jar 路径。
    • -v - 显示传递给 JVM 的参数。
    • -q - 仅输出本地 JVM 进程 ID。
    • -V - 仅输出本地 JVM 标识符。
  • hostid - RMI 注册表中注册的主机名。如果不指定 hostid 就默认为当前主机或服务器。

其中 optionhostid 参数也可以不写。

jps 使用示例:

::: tabs#jps 使用示例

@tab 列出本地 Java 进程

【示例】列出本地 Java 进程

1
2
3
4
$ jps
18027 Java2Demo.JAR
18032 jps
18005 jstat

@tab 列出本地 Java 进程 ID

【示例】列出本地 Java 进程 ID

1
2
3
4
$ jps -q
8841
1292
5398

@tab 列出本地 Java 进程 ID,并输出主类的全名

【示例】列出本地 Java 进程 ID,并输出主类的全名

如果进程执行的是 jar 包,输出 jar 路径

1
2
3
$ jps -l remote.domain
3002 /opt/jdk1.7.0/demo/jfc/Java2D/Java2Demo.JAR
2857 sun.tools.jstatd.jstatd

:::

jstat:虚拟机统计信息监视工具

jstat(JVM statistics Monitoring) 是虚拟机统计信息监视工具。jstat 用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时数据。

jstat 命令格式:

1
jstat [option] VMID [interval] [count]

对于命令格式中的 VMID 与 LVMID 需要特别说明一下:如果是本地虚拟机进程,VMID 与 LVMID 是一致的;如果是远程虚拟机进程,那 VMID 的格式应当是:

1
[protocol:][//]lvmid[@hostname[:port]/servername]

常用参数:

  • option - 选项参数,用于指定用户需要查询的虚拟机信息
    • -class - 监视类装载、卸载数量、总空间以及类装载所耗费的时间
    • -compiler:显示 JIT 编译的相关信息;
    • -gc:监视 Java 堆状况,包括 Eden 区、两个 survivor 区、老年代、永久代等区的容量、已用空间、GC 时间合计等信息。
    • -gccapacity:显示各个代的容量以及使用情况;
    • -gcmetacapacity:显示 Metaspace 的大小;
    • -gcnew:显示新生代信息;
    • -gcnewcapacity:显示新生代大小和使用情况;
    • -gcold:显示老年代和永久代的信息;
    • -gcoldcapacity:显示老年代的大小;
    • -gcutil:显示垃圾回收统计信息;
    • -gccause:显示垃圾回收的相关信息(通 -gcutil),同时显示最后一次或当前正在发生的垃圾回收的诱因;
    • -printcompilation:输出 JIT 编译的方法信息。
  • VMID - 如果是本地虚拟机进程,则 VMID 与 LVMID 是一致的;如果是远程虚拟机进程,那 VMID 的格式应当是:[protocol:][//]lvmid[@hostname[:port]/servername]
  • interval - 查询间隔
  • count - 查询次数

【参考】更详细说明可以参考:jstat 命令查看 jvm 的 GC 情况

jstat 使用示例:

::: tabs#jstat 使用示例

@tab 类加载统计

类加载统计

使用 jstat -class pid 命令可以查看编译统计信息。

【参数】

  • Loaded - 加载 class 的数量
  • Bytes - 所占用空间大小
  • Unloaded - 未加载数量
  • Bytes - 未加载占用空间
  • Time - 时间

【示例】查看类加载信息

1
2
3
$ jstat -class 7129
Loaded Bytes Unloaded Bytes Time
26749 50405.3 873 1216.8 19.75

@tab 编译统计

编译统计

使用 jstat -compiler pid 命令可以查看编译统计信息。

【示例】

1
2
3
$ jstat -compiler 7129
Compiled Failed Invalid Time FailedType FailedMethod
42030 2 0 302.53 1 org/apache/felix/framework/BundleWiringImpl$BundleClassLoader findClass

【参数】

  • Compiled - 编译数量
  • Failed - 失败数量
  • Invalid - 不可用数量
  • Time - 时间
  • FailedType - 失败类型
  • FailedMethod - 失败的方法

@tab GC 统计

GC 统计

使用 jstat -gc pid time 命令可以查看 GC 统计信息。

【示例】以 250 毫秒的间隔进行 7 次采样,并显示-gcutil 选项指定的输出。

1
2
3
4
5
6
7
8
$ jstat -gcutil 21891 250 7
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 97.02 70.31 66.80 95.52 89.14 7 0.300 0 0.000 0.300
0.00 97.02 86.23 66.80 95.52 89.14 7 0.300 0 0.000 0.300
0.00 97.02 96.53 66.80 95.52 89.14 7 0.300 0 0.000 0.300
91.03 0.00 1.98 68.19 95.89 91.24 8 0.378 0 0.000 0.378
91.03 0.00 15.82 68.19 95.89 91.24 8 0.378 0 0.000 0.378
91.03 0.00 17.80 68.19 95.89 91.24 8 0.378 0 0.000 0.378

【示例】以 1 秒的间隔进行 4 次采样,并显示-gc 选项指定的输出。

1
2
3
$ jstat -gc 25196 1s 4
S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT
20928.0 20928.0 0.0 0.0 167936.0 8880.5 838912.0 80291.2 106668.0 100032.1 12772.0 11602.2 760 14.332 580 656.218 670.550

参数说明:

  • S0C:年轻代中 To Survivor 的容量(单位 KB);
  • S1C:年轻代中 From Survivor 的容量(单位 KB);
  • S0U:年轻代中 To Survivor 目前已使用空间(单位 KB);
  • S1U:年轻代中 From Survivor 目前已使用空间(单位 KB);
  • EC:年轻代中 Eden 的容量(单位 KB);
  • EU:年轻代中 Eden 目前已使用空间(单位 KB);
  • OC:Old 代的容量(单位 KB);
  • OU:Old 代目前已使用空间(单位 KB);
  • MC:Metaspace 的容量(单位 KB);
  • MU:Metaspace 目前已使用空间(单位 KB);
  • YGC:从应用程序启动到采样时年轻代中 gc 次数;
  • YGCT:从应用程序启动到采样时年轻代中 gc 所用时间 (s);
  • FGC:从应用程序启动到采样时 old 代(全 gc)gc 次数;
  • FGCT:从应用程序启动到采样时 old 代(全 gc)gc 所用时间 (s);
  • GCT:从应用程序启动到采样时 gc 用的总时间 (s)。

注:更详细的参数含义可以参考官方文档:http://docs.oracle.com/javase/8/docs/technotes/tools/unix/jstat.html

:::

jinfo:Java 配置信息工具

jinfo(JVM Configuration info) 是 Java 配置信息工具。jinfo 用于实时查看和调整虚拟机运行参数。如传递给 Java 虚拟机的-X(即输出中的 jvm_args)、-XX参数(即输出中的 VM Flags),以及可在 Java 层面通过System.getProperty获取的-D参数(即输出中的 System Properties)。

之前的 jps -v 口令只能查看到显示指定的参数,如果想要查看未被显示指定的参数的值就要使用 jinfo 口令。

jinfo 命令格式:

1
jinfo [option] pid

option 选项参数:

  • -flag - 输出指定 args 参数的值
  • -sysprops - 输出系统属性,等同于 System.getProperties()

【示例】jinfo 使用示例

1
2
3
4
5
6
$ jinfo -sysprops 29527
Attaching to process ID 29527, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.222-b10
...

jmap:Java 内存映像工具

jmap(JVM Memory Map) 是 Java 内存映像工具。jmap 用于生成堆转储快照(一般称为 heapdump 或 dump 文件)。jmap 不仅能生成 dump 文件,还可以查询 finalize 执行队列、Java 堆和永久代的详细信息,如当前使用率、当前使用的是哪种收集器等。

如果不使用这个命令,还可以使用 -XX:+HeapDumpOnOutOfMemoryError 参数来让虚拟机出现 OOM 的时候,自动生成 dump 文件。

jmap 命令格式:

1
jmap [option] pid

option 选项参数:

  • -dump - 生成堆转储快照。-dump:live 只保存堆中的存活对象。
  • -finalizerinfo - 显示在 F-Queue 队列等待执行 finalizer 方法的对象
  • -heap - 显示 Java 堆详细信息。
  • -histo - 显示堆中对象的统计信息,包括类、实例数量、合计容量。-histo:live 只统计堆中的存活对象。
  • -permstat - to print permanent generation statistics
  • -F - 当-dump 没有响应时,强制生成 dump 快照

::: tabs#jstat 使用示例

@tab 生成 heapdump 快照

生成 heapdump 快照

dump 堆到文件,format 指定输出格式,live 指明是活着的对象,file 指定文件名

1
2
3
$ jmap -dump:live,format=b,file=dump.hprof 28920
Dumping heap to /home/xxx/dump.hprof ...
Heap dump file created

dump.hprof 这个后缀是为了后续可以直接用 MAT(Memory Anlysis Tool) 等工具打开。

@tab 查看实例数最多的类

查看实例数最多的类

1
2
3
4
5
6
7
$ jmap -histo 29527 | head -n 6

num #instances #bytes class name
----------------------------------------------
1: 13673280 1438961864 [C
2: 1207166 411277184 [I
3: 7382322 347307096 [Ljava.lang.Object;

@tab 查看指定进程的堆信息

查看指定进程的堆信息

注意:使用 CMS GC 情况下,jmap -heap PID 的执行有可能会导致 java 进程挂起。

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
$ jmap -heap 12379
Attaching to process ID 12379, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 17.0-b16

using thread-local object allocation.
Parallel GC with 6 thread(s)

Heap Configuration:
MinHeapFreeRatio = 40
MaxHeapFreeRatio = 70
MaxHeapSize = 83886080 (80.0MB)
NewSize = 1310720 (1.25MB)
MaxNewSize = 17592186044415 MB
OldSize = 5439488 (5.1875MB)
NewRatio = 2
SurvivorRatio = 8
PermSize = 20971520 (20.0MB)
MaxPermSize = 88080384 (84.0MB)

Heap Usage:
PS Young Generation
Eden Space:
capacity = 9306112 (8.875MB)
used = 5375360 (5.1263427734375MB)
free = 3930752 (3.7486572265625MB)
57.761608714788736% used
From Space:
capacity = 9306112 (8.875MB)
used = 3425240 (3.2665634155273438MB)
free = 5880872 (5.608436584472656MB)
36.80634834397007% used
To Space:
capacity = 9306112 (8.875MB)
used = 0 (0.0MB)
free = 9306112 (8.875MB)
0.0% used
PS Old Generation
capacity = 55967744 (53.375MB)
used = 48354640 (46.11457824707031MB)
free = 7613104 (7.2604217529296875MB)
86.39733629427693% used
PS Perm Generation
capacity = 62062592 (59.1875MB)
used = 60243112 (57.452308654785156MB)
free = 1819480 (1.7351913452148438MB)
97.06831451706046% used

:::

jhat:虚拟机堆转储快照分析工具

jhat(JVM Heap Analysis Tool) 是虚拟机堆转储快照分析工具。jhat 与 jmap 搭配使用,用来分析 jmap 生成的 dump 文件。jhat 内置了一个微型的 HTTP/HTML 服务器,生成 dump 的分析结果后,可以在浏览器中查看。

提示:一般来说,使用 jhat 分析 dump 快照不是一个好的选择。因为 jhat 是一个耗时并且耗费硬件资源的过程。而在其他服务器上分析快照,不如使用 VisualVM、Eclipse Memory Analyzer、IBM HeapAnalyzer 等 UI 工具来分析,分析功能更加强大。

jhat 命令格式:

1
jhat [dumpfile]

【示例】使用 jhat 分析 dump 文件

1
2
3
4
5
6
7
8
9
10
jhat eclipse.bin
Reading from eclipse.bin...
Dump file created Fri Nov 19 22:07:21 CST 2010
Snapshot read, resolving...
Resolving 1225951 objects...
Chasing references, expect 245 dots....
Eliminating duplicate references...
Snapshot resolved.
Started HTTP server on port 7000
Server is ready.

显示“Server is ready.”的提示后,用户在浏览器中输入 http://localhost:7000/ 可以看到分析结果。

jstack:Java 堆栈跟踪工具

jstack(Stack Trace for java) 是 Java 堆栈跟踪工具。jstack 用于生成虚拟机当前时刻的线程快照(一般称为 threaddump 或者 javacore 文件)。

线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的 目的通常是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间挂 起等,都是导致线程长时间停顿的常见原因。

jstack 通常会结合 top -Hp pidpidstat -p pid -t 一起查看具体线程的状态,也经常用来排查一些死锁的异常。

jstack 命令格式:

1
jstack [option] pid

option 选项参数

  • -F - 当正常输出请求不被响应时,强制输出线程堆栈
  • -l - 除堆栈外,显示关于锁的附加信息
  • -m - 打印 java 和 jni 框架的所有栈信息

::: tabs#jstack 使用示例

@tab 找出某 Java 进程中最耗费 CPU 的 Java 线程

找出某 Java 进程中最耗费 CPU 的 Java 线程

(1)找出 Java 进程

假设应用名称为 myapp:

1
2
$ jps | grep myapp
29527 myapp.jar

得到进程 ID 为 21711

(2)找出该进程内最耗费 CPU 的线程,可以使用 ps -Lfp pid 或者 ps -mp pid -o THREAD, tid, time 或者 top -Hp pid

img

TIME 列就是各个 Java 线程耗费的 CPU 时间,CPU 时间最长的是线程 ID 为 21742 的线程,用

1
printf "%x\n" 21742

得到 21742 的十六进制值为 54ee,下面会用到。

(3)使用 jstack 打印线程堆栈信息

下一步终于轮到 jstack 上场了,它用来输出进程 21711 的堆栈信息,然后根据线程 ID 的十六进制值 grep,如下:

1
2
$ jstack 21711 | grep 54ee
"PollIntervalRetrySchedulerThread" prio=10 tid=0x00007f950043e000 nid=0x54ee in Object.wait() [0x00007f94c6eda000]

可以看到 CPU 消耗在 PollIntervalRetrySchedulerThread 这个类的 Object.wait()

注:上面的例子中,默认只显示了一行信息,但很多时候我们希望查看更详细的调用栈。可以通过指定 -A <num> 的方式来显示行数。例如:jstack -l <pid> | grep <thread-hex-id> -A 10

(4)分析代码

我找了下我的代码,定位到下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Idle wait
getLog().info("Thread [" + getName() + "] is idle waiting...");
schedulerThreadState = PollTaskSchedulerThreadState.IdleWaiting;
long now = System.currentTimeMillis();
long waitTime = now + getIdleWaitTime();
long timeUntilContinue = waitTime - now;
synchronized(sigLock) {
try {
if(!halted.get()) {
sigLock.wait(timeUntilContinue);
}
}
catch (InterruptedException ignore) {
}
}

它是轮询任务的空闲等待代码,上面的 sigLock.wait(timeUntilContinue) 就对应了前面的 Object.wait()

@tab 生成 threaddump 文件

生成 threaddump 文件

可以使用 jstack -l <pid> > <file-path> 命令生成 threaddump 文件

【示例】生成进程 ID 为 8841 的 Java 进程的 threaddump 文件。

1
jstack -l 8841 > /home/threaddump.txt

:::

JVM GUI 工具

Java 程序员免不了故障排查工作,所以经常需要使用一些 JVM 工具。

JDK 中除了附带大量的命令行工具外,还提供了几个功能集成度更高的可视化工具,用户可以使 用这些可视化工具以更加便捷的方式进行进程故障诊断和调试工作。这类工具主要包括 JConsole、JHSDB、VisualVM 和 JMC 四个。

JHSDB:基于服务性代理的调试工具

JDK 中提供了 JCMD 和 JHSDB 两个集成式的多功能工具箱。

JHSDB 是一款基于服务性代理(Serviceability Agent,SA)实现的进程外调试工具。服务性代理是 HotSpot 虚拟机中一组用于映射 Java 虚拟机运行信息的、主要基于 Java 语言(含少量 JNI 代码)实现的 API 集合。通过服务性代理的 API,可以在一个独立的 Java 虚拟 机的进程里分析其他 HotSpot 虚拟机的内部数据,或者从 HotSpot 虚拟机进程内存中 dump 出来的转储快 照里还原出它的运行状态细节。

JConsole:基于 JMX 的可视化监视与管理工具

JConsole(Java Monitoring and Management Console) 是一种基于 JMX 的可视化监视与管理工具。它的主要功能是通过JMX的MBean(Managed Bean)对系统进行信息收集和参数动态调整。由于 MBean 可以使用代码、中间件服务器的管理控制台或所有符合 JMX 规范的软件进行访问。

注意:使用 jconsole 的前提是 Java 应用开启 JMX。

开启 JMX

Java 应用开启 JMX 后,可以使用 jconsolejvisualvm 进行监控 Java 程序的基本信息和运行情况。

开启方法是,在 java 指令后,添加以下参数:

1
2
3
4
5
-Dcom.sun.management.jmxremote=true
-Dcom.sun.management.jmxremote.ssl=false
-Dcom.sun.management.jmxremote.authenticate=false
-Djava.rmi.server.hostname=127.0.0.1
-Dcom.sun.management.jmxremote.port=18888
  • -Djava.rmi.server.hostname - 指定 Java 程序运行的服务器
  • -Dcom.sun.management.jmxremote.port - 指定 JMX 服务监听端口

连接 jconsole

如果是本地 Java 进程,jconsole 可以直接绑定连接。

如果是远程 Java 进程,需要连接 Java 进程的 JMX 端口。

Connecting to a JMX Agent Using the JMX Service URL

jconsole 界面

进入 jconsole 应用后,可以看到以下 tab 页面。

  • 概述 - 显示有关 Java VM 和监视值的概述信息。
  • 内存 - 显示有关内存使用的信息。内存页相当于可视化的 jstat 命令。
  • 线程 - 显示有关线程使用的信息。
  • - 显示有关类加载的信息。
  • VM 摘要 - 显示有关 Java VM 的信息。
  • MBean - 显示有关 MBean 的信息。

img

VisualVM

jvisualvm 是 JDK 自带的 GUI 工具。jvisualvm(All-In-One Java Troubleshooting Tool) 是多合一故障处理工具。它支持运行监视、故障处理、性能分析等功能。

个人觉得 jvisualvm 比 jconsole 好用。

jvisualvm 概述页面

jvisualvm 概述页面可以查看当前 Java 进程的基本信息,如:JDK 版本、Java 进程、JVM 参数等。

img

jvisualvm 监控页面

在 jvisualvm 监控页面,可以看到 Java 进程的 CPU、内存、类加载、线程的实时变化。

img

jvisualvm 线程页面

jvisualvm 线程页面展示了当前的线程状态。

img

jvisualvm 还可以生成线程 Dump 文件,帮助进一步分析线程栈信息。

img

jvisualvm 抽样器页面

jvisualvm 可以对 CPU、内存进行抽样,帮助我们进行性能分析。

MAT

MAT 即 Eclipse Memory Analyzer Tool 的缩写。

MAT 本身也能够获取堆的二进制快照。该功能将借助 jps 列出当前正在运行的 Java 进程,以供选择并获取快照。由于 jps 会将自己列入其中,因此你会在列表中发现一个已经结束运行的 jps 进程。

MAT 可以独立安装(官方下载地址),也可以作为 Eclipse IDE 的插件安装。

MAT 配置

MAT 解压后,安装目录下有个 MemoryAnalyzer.ini 文件。

MemoryAnalyzer.ini 中有个重要的参数 Xmx 表示最大内存,默认为:-vmargs -Xmx1024m

如果试图用 MAT 导入的 dump 文件超过 1024 M,会报错:

1
An internal error occurred during: "Parsing heap dump from XXX"

此时,可以适当调整 Xmx 大小。如果设置的 Xmx 数值过大,本机内存不足以支撑,启动 MAT 会报错:

1
Failed to create the Java Virtual Machine

MAT 分析

img

点击 Leak Suspects 可以进入内存泄漏页面。

(1)首先,可以查看饼图了解内存的整体消耗情况

img

(2)缩小范围,寻找问题疑似点

img

可以点击进入详情页面,在详情页面 Shortest Paths To the Accumulation Point 表示 GC root 到内存消耗聚集点的最短路径,如果某个内存消耗聚集点有路径到达 GC root,则该内存消耗聚集点不会被当做垃圾被回收。

为了找到内存泄露,我获取了两个堆转储文件,两个文件获取时间间隔是一天(因为内存只是小幅度增长,短时间很难发现问题)。对比两个文件的对象,通过对比后的结果可以很方便定位内存泄露。

MAT 同时打开两个堆转储文件,分别打开 Histogram,如下图。在下图中方框 1 按钮用于对比两个 Histogram,对比后在方框 2 处选择 Group By package,然后对比各对象的变化。不难发现 heap3.hprof 比 heap6.hprof 少了 64 个 eventInfo 对象,如果对代码比较熟悉的话想必这样一个结果是能够给程序员一定的启示的。而我也是根据这个启示差找到了最终内存泄露的位置。
img

JProfile

JProfiler 是一款性能分析工具。

由于它是收费的,所以我本人使用较少。但是,它确实功能强大,且方便使用,还可以和 Intellij Idea 集成。

Arthas

Arthas 是 Alibaba 开源的 Java 诊断工具,深受开发者喜爱。在线排查问题,无需重启;动态跟踪 Java 代码;实时监控 JVM 状态。

Arthas 支持 JDK 6+,支持 Linux/Mac/Windows,采用命令行交互模式,同时提供丰富的 Tab 自动补全功能,进一步方便进行问题的定位和诊断。

img

Arthas 基础命令

  • help——查看命令帮助信息
  • cat——打印文件内容,和 linux 里的 cat 命令类似
  • echo–打印参数,和 linux 里的 echo 命令类似
  • grep——匹配查找,和 linux 里的 grep 命令类似
  • tee——复制标准输入到标准输出和指定的文件,和 linux 里的 tee 命令类似
  • pwd——返回当前的工作目录,和 linux 命令类似
  • cls——清空当前屏幕区域
  • session——查看当前会话的信息
  • reset——重置增强类,将被 Arthas 增强过的类全部还原,Arthas 服务端关闭时会重置所有增强过的类
  • version——输出当前目标 Java 进程所加载的 Arthas 版本号
  • history——打印命令历史
  • quit——退出当前 Arthas 客户端,其他 Arthas 客户端不受影响
  • stop——关闭 Arthas 服务端,所有 Arthas 客户端全部退出
  • keymap——Arthas 快捷键列表及自定义快捷键

Arthas jvm 相关命令

  • dashboard——当前系统的实时数据面板
  • thread——查看当前 JVM 的线程堆栈信息
  • jvm——查看当前 JVM 的信息
  • sysprop——查看和修改 JVM 的系统属性
  • sysenv——查看 JVM 的环境变量
  • vmoption——查看和修改 JVM 里诊断相关的 option
  • perfcounter——查看当前 JVM 的 Perf Counter 信息
  • logger——查看和修改 logger
  • getstatic——查看类的静态属性
  • ognl——执行 ognl 表达式
  • mbean——查看 Mbean 的信息
  • heapdump——dump java heap, 类似 jmap 命令的 heap dump 功能

Arthas class/classloader 相关命令

  • sc——查看 JVM 已加载的类信息
  • sm——查看已加载类的方法信息
  • jad——反编译指定已加载类的源码
  • mc——内存编译器,内存编译.java文件为.class文件
  • redefine——加载外部的.class文件,redefine 到 JVM 里
  • dump——dump 已加载类的 byte code 到特定目录
  • classloader——查看 classloader 的继承树,urls,类加载信息,使用 classloader 去 getResource

Arthas monitor/watch/trace 相关命令

请注意,这些命令,都通过字节码增强技术来实现的,会在指定类的方法中插入一些切面来实现数据统计和观测,因此在线上、预发使用时,请尽量明确需要观测的类、方法以及条件,诊断结束要执行 stop 或将增强过的类执行 reset 命令。

  • monitor——方法执行监控
  • watch——方法执行数据观测
  • trace——方法内部调用路径,并输出方法路径上的每个节点上耗时
  • stack——输出当前方法被调用的调用路径
  • tt——方法执行数据的时空隧道,记录下指定方法每次调用的入参和返回信息,并能对这些不同的时间下调用进行观测

thread dump 文件

img

一个 Thread Dump 文件大致可以分为五个部分。

第一部分:Full thread dump identifier

这一部分是内容最开始的部分,展示了快照文件的生成时间和 JVM 的版本信息。

1
2
2017-10-19 10:46:44
Full thread dump Java HotSpot(TM) 64-Bit Server VM (24.79-b02 mixed mode):

第二部分:Java EE middleware, third party & custom application Threads

这是整个文件的核心部分,里面展示了 JavaEE 容器(如 tomcat、resin 等)、自己的程序中所使用的线程信息。

1
2
3
4
5
6
7
"resin-22129" daemon prio=10 tid=0x00007fbe5c34e000 nid=0x4cb1 waiting on condition [0x00007fbe4ff7c000]
java.lang.Thread.State: WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:315)
at com.caucho.env.thread2.ResinThread2.park(ResinThread2.java:196)
at com.caucho.env.thread2.ResinThread2.runTasks(ResinThread2.java:147)
at com.caucho.env.thread2.ResinThread2.run(ResinThread2.java:118)

参数说明:

  • "resin-22129" 线程名称:如果使用 java.lang.Thread 类生成一个线程的时候,线程名称为 Thread-(数字) 的形式,这里是 resin 生成的线程;
  • daemon 线程类型:线程分为守护线程 (daemon) 和非守护线程 (non-daemon) 两种,通常都是守护线程;
  • prio=10 线程优先级:默认为 5,数字越大优先级越高;
  • tid=0x00007fbe5c34e000 JVM 线程的 id:JVM 内部线程的唯一标识,通过 java.lang.Thread.getId() 获取,通常用自增的方式实现;
  • nid=0x4cb1 系统线程 id:对应的系统线程 id(Native Thread ID),可以通过 top 命令进行查看,现场 id 是十六进制的形式;
  • waiting on condition 系统线程状态:这里是系统的线程状态;
  • [0x00007fbe4ff7c000] 起始栈地址:线程堆栈调用的其实内存地址;
  • java.lang.Thread.State: WAITING (parking) JVM 线程状态:这里标明了线程在代码级别的状态。
  • 线程调用栈信息:下面就是当前线程调用的详细栈信息,用于代码的分析。堆栈信息应该从下向上解读,因为程序调用的顺序是从下向上的。

第三部分:HotSpot VM Thread

这一部分展示了 JVM 内部线程的信息,用于执行内部的原生操作。下面常见的集中内置线程:

“Attach Listener”

该线程负责接收外部命令,执行该命令并把结果返回给调用者,此种类型的线程通常在桌面程序中出现。

1
2
"Attach Listener" daemon prio=5 tid=0x00007fc6b6800800 nid=0x3b07 waiting on condition [0x0000000000000000]
java.lang.Thread.State: RUNNABLE

“DestroyJavaVM”

执行 main() 的线程在执行完之后调用 JNI 中的 jni_DestroyJavaVM() 方法会唤起 DestroyJavaVM 线程,处于等待状态,等待其它线程(java 线程和 native 线程)退出时通知它卸载 JVM。

1
2
"DestroyJavaVM" prio=5 tid=0x00007fc6b3001000 nid=0x1903 waiting on condition [0x0000000000000000]
java.lang.Thread.State: RUNNABLE

“Service Thread”

用于启动服务的线程

1
2
"Service Thread" daemon prio=10 tid=0x00007fbea81b3000 nid=0x5f2 runnable [0x0000000000000000]
java.lang.Thread.State: RUNNABLE

“CompilerThread”

用来调用 JITing,实时编译装卸类。通常 JVM 会启动多个线程来处理这部分工作,线程名称后面的数字也会累加,比如 CompilerThread1。

1
2
3
4
5
"C2 CompilerThread1" daemon prio=10 tid=0x00007fbea814b000 nid=0x5f1 waiting on condition [0x0000000000000000]
java.lang.Thread.State: RUNNABLE

"C2 CompilerThread0" daemon prio=10 tid=0x00007fbea8142000 nid=0x5f0 waiting on condition [0x0000000000000000]
java.lang.Thread.State: RUNNABLE

“Signal Dispatcher”

Attach Listener 线程的职责是接收外部 jvm 命令,当命令接收成功后,会交给 signal dispather 线程去进行分发到各个不同的模块处理命令,并且返回处理结果。
signal dispather 线程也是在第一次接收外部 jvm 命令时,进行初始化工作。

1
2
"Signal Dispatcher" daemon prio=10 tid=0x00007fbea81bf800 nid=0x5ef runnable [0x0000000000000000]
java.lang.Thread.State: RUNNABLE

“Finalizer”

这个线程也是在 main 线程之后创建的,其优先级为 10,主要用于在垃圾收集前,调用对象的 finalize() 方法;关于 Finalizer 线程的几点:

  • 只有当开始一轮垃圾收集时,才会开始调用 finalize() 方法;因此并不是所有对象的 finalize() 方法都会被执行;
  • 该线程也是 daemon 线程,因此如果虚拟机中没有其他非 daemon 线程,不管该线程有没有执行完 finalize() 方法,JVM 也会退出;
  • JVM 在垃圾收集时会将失去引用的对象包装成 Finalizer 对象(Reference 的实现),并放入 ReferenceQueue,由 Finalizer 线程来处理;最后将该 Finalizer 对象的引用置为 null,由垃圾收集器来回收;

JVM 为什么要单独用一个线程来执行 finalize() 方法呢?

如果 JVM 的垃圾收集线程自己来做,很有可能由于在 finalize() 方法中误操作导致 GC 线程停止或不可控,这对 GC 线程来说是一种灾难。

1
2
3
4
5
6
7
"Finalizer" daemon prio=10 tid=0x00007fbea80da000 nid=0x5eb in Object.wait() [0x00007fbeac044000]
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait(Native Method)
at java.lang.ref.ReferenceQueue.remove(ReferenceQueue.java:135)
- locked <0x00000006d173c1a8> (a java.lang.ref.ReferenceQueue$Lock)
at java.lang.ref.ReferenceQueue.remove(ReferenceQueue.java:151)
at java.lang.ref.Finalizer$FinalizerThread.run(Finalizer.java:209)

“Reference Handler”

JVM 在创建 main 线程后就创建 Reference Handler 线程,其优先级最高,为 10,它主要用于处理引用对象本身(软引用、弱引用、虚引用)的垃圾回收问题 。

1
2
3
4
5
6
"Reference Handler" daemon prio=10 tid=0x00007fbea80d8000 nid=0x5ea in Object.wait() [0x00007fbeac085000]
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait(Native Method)
at java.lang.Object.wait(Object.java:503)
at java.lang.ref.Reference$ReferenceHandler.run(Reference.java:133)
- locked <0x00000006d173c1f0> (a java.lang.ref.Reference$Lock)

“VM Thread”

JVM 中线程的母体,根据 HotSpot 源码中关于 vmThread.hpp 里面的注释,它是一个单例的对象(最原始的线程)会产生或触发所有其他的线程,这个单例的 VM 线程是会被其他线程所使用来做一些 VM 操作(如清扫垃圾等)。
在 VM Thread 的结构体里有一个 VMOperationQueue 列队,所有的 VM 线程操作 (vm_operation) 都会被保存到这个列队当中,VMThread 本身就是一个线程,它的线程负责执行一个自轮询的 loop 函数(具体可以参考:VMThread.cpp 里面的 void VMThread::loop()) ,该 loop 函数从 VMOperationQueue 列队中按照优先级取出当前需要执行的操作对象 (VM_Operation),并且调用 VM_Operation->evaluate 函数去执行该操作类型本身的业务逻辑。
VM 操作类型被定义在 vm_operations.hpp 文件内,列举几个:ThreadStop、ThreadDump、PrintThreads、GenCollectFull、GenCollectFullConcurrent、CMS_Initial_Mark、CMS_Final_Remark….. 有兴趣的同学,可以自己去查看源文件。

1
"VM Thread" prio=10 tid=0x00007fbea80d3800 nid=0x5e9 runnable

第四部分:HotSpot GC Thread

JVM 中用于进行资源回收的线程,包括以下几种类型的线程:

“VM Periodic Task Thread”

该线程是 JVM 周期性任务调度的线程,它由 WatcherThread 创建,是一个单例对象。该线程在 JVM 内使用得比较频繁,比如:定期的内存监控、JVM 运行状况监控。

1
"VM Periodic Task Thread" prio=10 tid=0x00007fbea82ae800 nid=0x5fa waiting on condition

可以使用 jstat 命令查看 GC 的情况,比如查看某个进程没有存活必要的引用可以使用命令 jstat -gcutil 250 7 参数中 pid 是进程 id,后面的 250 和 7 表示每 250 毫秒打印一次,总共打印 7 次。
这对于防止因为应用代码中直接使用 native 库或者第三方的一些监控工具的内存泄漏有非常大的帮助。

“GC task thread#0 (ParallelGC)”

垃圾回收线程,该线程会负责进行垃圾回收。通常 JVM 会启动多个线程来处理这个工作,线程名称中#后面的数字也会累加。

1
2
3
4
5
6
7
"GC task thread#0 (ParallelGC)" prio=5 tid=0x00007fc6b480d000 nid=0x2503 runnable

"GC task thread#1 (ParallelGC)" prio=5 tid=0x00007fc6b2812000 nid=0x2703 runnable

"GC task thread#2 (ParallelGC)" prio=5 tid=0x00007fc6b2812800 nid=0x2903 runnable

"GC task thread#3 (ParallelGC)" prio=5 tid=0x00007fc6b2813000 nid=0x2b03 runnable

如果在 JVM 中增加了 -XX:+UseConcMarkSweepGC 参数将会启用 CMS (Concurrent Mark-Sweep)GC Thread 方式,以下是该模式下的线程类型:

“Gang worker#0 (Parallel GC Threads)”

原来垃圾回收线程 GC task thread#0 (ParallelGC) 被替换为 Gang worker#0 (Parallel GC Threads)。Gang worker 是 JVM 用于年轻代垃圾回收 (minor gc) 的线程。

1
2
3
"Gang worker#0 (Parallel GC Threads)" prio=10 tid=0x00007fbea801b800 nid=0x5e4 runnable

"Gang worker#1 (Parallel GC Threads)" prio=10 tid=0x00007fbea801d800 nid=0x5e7 runnable

“Concurrent Mark-Sweep GC Thread”

并发标记清除垃圾回收器(就是通常所说的 CMS GC)线程, 该线程主要针对于年老代垃圾回收。

1
"Concurrent Mark-Sweep GC Thread" prio=10 tid=0x00007fbea8073800 nid=0x5e8 runnable

“Surrogate Locker Thread (Concurrent GC)”

此线程主要配合 CMS 垃圾回收器来使用,是一个守护线程,主要负责处理 GC 过程中 Java 层的 Reference(指软引用、弱引用等等)与 jvm 内部层面的对象状态同步。

1
2
"Surrogate Locker Thread (Concurrent GC)" daemon prio=10 tid=0x00007fbea8158800 nid=0x5ee waiting on condition [0x0000000000000000]
java.lang.Thread.State: RUNNABLE

这里以 WeakHashMap 为例进行说明,首先是一个关键点:

  • WeakHashMap 和 HashMap 一样,内部有一个 Entry[] 数组;
  • WeakHashMap 的 Entry 比较特殊,它的继承体系结构为 Entry->WeakReference->Reference;
  • Reference 里面有一个全局锁对象:Lock,它也被称为 pending_lock,注意:它是静态对象;
  • Reference 里面有一个静态变量:pending;
  • Reference 里面有一个静态内部类:ReferenceHandler 的线程,它在 static 块里面被初始化并且启动,启动完成后处于 wait 状态,它在一个 Lock 同步锁模块中等待;
  • WeakHashMap 里面还实例化了一个 ReferenceQueue 列队

假设,WeakHashMap 对象里面已经保存了很多对象的引用,JVM 在进行 CMS GC 的时候会创建一个 ConcurrentMarkSweepThread(简称 CMST)线程去进行 GC。ConcurrentMarkSweepThread 线程被创建的同时会创建一个 SurrogateLockerThread(简称 SLT)线程并且启动它,SLT 启动之后,处于等待阶段。
CMST 开始 GC 时,会发一个消息给 SLT 让它去获取 Java 层 Reference 对象的全局锁:Lock。直到 CMS GC 完毕之后,JVM 会将 WeakHashMap 中所有被回收的对象所属的 WeakReference 容器对象放入到 Reference 的 pending 属性当中(每次 GC 完毕之后,pending 属性基本上都不会为 null 了),然后通知 SLT 释放并且 notify 全局锁:Lock。此时激活了 ReferenceHandler 线程的 run 方法,使其脱离 wait 状态,开始工作了。
ReferenceHandler 这个线程会将 pending 中的所有 WeakReference 对象都移动到它们各自的列队当中,比如当前这个 WeakReference 属于某个 WeakHashMap 对象,那么它就会被放入相应的 ReferenceQueue 列队里面(该列队是链表结构)。 当我们下次从 WeakHashMap 对象里面 get、put 数据或者调用 size 方法的时候,WeakHashMap 就会将 ReferenceQueue 列队中的 WeakReference 依依 poll 出来去和 Entry[] 数据做比较,如果发现相同的,则说明这个 Entry 所保存的对象已经被 GC 掉了,那么将 Entry[] 内的 Entry 对象剔除掉。

第五部分:JNI global references count

这一部分主要回收那些在 native 代码上被引用,但在 java 代码中却没有存活必要的引用,对于防止因为应用代码中直接使用 native 库或第三方的一些监控工具的内存泄漏有非常大的帮助。

1
JNI global references: 830

系统线程状态

系统线程有如下状态:

deadlock

死锁线程,一般指多个线程调用期间进入了相互资源占用,导致一直等待无法释放的情况。

【示例】deadlock 示例

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
"DEADLOCK_TEST-1" daemon prio=6 tid=0x000000000690f800 nid=0x1820 waiting for monitor entry [0x000000000805f000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.nbp.theplatform.threaddump.ThreadDeadLockState$DeadlockThread.goMonitorDeadlock(ThreadDeadLockState.java:197)
- waiting to lock <0x00000007d58f5e60> (a com.nbp.theplatform.threaddump.ThreadDeadLockState$Monitor)
at com.nbp.theplatform.threaddump.ThreadDeadLockState$DeadlockThread.monitorOurLock(ThreadDeadLockState.java:182)
- locked <0x00000007d58f5e48> (a com.nbp.theplatform.threaddump.ThreadDeadLockState$Monitor)
at com.nbp.theplatform.threaddump.ThreadDeadLockState$DeadlockThread.run(ThreadDeadLockState.java:135)

Locked ownable synchronizers:
- None

"DEADLOCK_TEST-2" daemon prio=6 tid=0x0000000006858800 nid=0x17b8 waiting for monitor entry [0x000000000815f000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.nbp.theplatform.threaddump.ThreadDeadLockState$DeadlockThread.goMonitorDeadlock(ThreadDeadLockState.java:197)
- waiting to lock <0x00000007d58f5e78> (a com.nbp.theplatform.threaddump.ThreadDeadLockState$Monitor)
at com.nbp.theplatform.threaddump.ThreadDeadLockState$DeadlockThread.monitorOurLock(ThreadDeadLockState.java:182)
- locked <0x00000007d58f5e60> (a com.nbp.theplatform.threaddump.ThreadDeadLockState$Monitor)
at com.nbp.theplatform.threaddump.ThreadDeadLockState$DeadlockThread.run(ThreadDeadLockState.java:135)

Locked ownable synchronizers:
- None

"DEADLOCK_TEST-3" daemon prio=6 tid=0x0000000006859000 nid=0x25dc waiting for monitor entry [0x000000000825f000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.nbp.theplatform.threaddump.ThreadDeadLockState$DeadlockThread.goMonitorDeadlock(ThreadDeadLockState.java:197)
- waiting to lock <0x00000007d58f5e48> (a com.nbp.theplatform.threaddump.ThreadDeadLockState$Monitor)
at com.nbp.theplatform.threaddump.ThreadDeadLockState$DeadlockThread.monitorOurLock(ThreadDeadLockState.java:182)
- locked <0x00000007d58f5e78> (a com.nbp.theplatform.threaddump.ThreadDeadLockState$Monitor)
at com.nbp.theplatform.threaddump.ThreadDeadLockState$DeadlockThread.run(ThreadDeadLockState.java:135)

Locked ownable synchronizers:
- None

runnable

一般指该线程正在执行状态中,该线程占用了资源,正在处理某个操作,如通过 SQL 语句查询数据库、对某个文件进行写入等。

blocked

线程正处于阻塞状态,指当前线程执行过程中,所需要的资源长时间等待却一直未能获取到,被容器的线程管理器标识为阻塞状态,可以理解为等待资源超时的线程。

【示例】blocked 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
"BLOCKED_TEST pool-1-thread-2" prio=6 tid=0x0000000007673800 nid=0x260c waiting for monitor entry [0x0000000008abf000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.nbp.theplatform.threaddump.ThreadBlockedState.monitorLock(ThreadBlockedState.java:43)
- waiting to lock <0x0000000780a000b0> (a com.nbp.theplatform.threaddump.ThreadBlockedState)
at com.nbp.theplatform.threaddump.ThreadBlockedState$2.run(ThreadBlockedState.java:26)
at java.util.concurrent.ThreadPoolExecutor$Worker.runTask(ThreadPoolExecutor.java:886)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:908)
at java.lang.Thread.run(Thread.java:662)
Locked ownable synchronizers:
- <0x0000000780b0c6a0> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
"BLOCKED_TEST pool-1-thread-3" prio=6 tid=0x00000000074f5800 nid=0x1994 waiting for monitor entry [0x0000000008bbf000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.nbp.theplatform.threaddump.ThreadBlockedState.monitorLock(ThreadBlockedState.java:42)
- waiting to lock <0x0000000780a000b0> (a com.nbp.theplatform.threaddump.ThreadBlockedState)
at com.nbp.theplatform.threaddump.ThreadBlockedState$3.run(ThreadBlockedState.java:34)
at java.util.concurrent.ThreadPoolExecutor$Worker.runTask(ThreadPoolExecutor.java:886
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:908)
at java.lang.Thread.run(Thread.java:662)
Locked ownable synchronizers:
- <0x0000000780b0e1b8> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)

waiting on condition

线程正处于等待资源或等待某个条件的发生,具体的原因需要结合下面堆栈信息进行分析。

(1)如果堆栈信息明确是应用代码,则证明该线程正在等待资源,一般是大量读取某种资源且该资源采用了资源锁的情况下,线程进入等待状态,等待资源的读取,或者正在等待其他线程的执行等。

(2)如果发现有大量的线程都正处于这种状态,并且堆栈信息中得知正等待网络读写,这是因为网络阻塞导致线程无法执行,很有可能是一个网络瓶颈的征兆:

  • 网络非常繁忙,几乎消耗了所有的带宽,仍然有大量数据等待网络读写;

  • 网络可能是空闲的,但由于路由或防火墙等原因,导致包无法正常到达;

所以一定要结合系统的一些性能观察工具进行综合分析,比如 netstat 统计单位时间的发送包的数量,看是否很明显超过了所在网络带宽的限制;观察 CPU 的利用率,看系统态的 CPU 时间是否明显大于用户态的 CPU 时间。这些都指向由于网络带宽所限导致的网络瓶颈。

(3)还有一种常见的情况是该线程在 sleep,等待 sleep 的时间到了,将被唤醒。

【示例】等待状态样例

1
2
3
4
5
6
7
8
9
10
"IoWaitThread" prio=6 tid=0x0000000007334800 nid=0x2b3c waiting on condition [0x000000000893f000]
java.lang.Thread.State: WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x00000007d5c45850> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:156)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:1987)
at java.util.concurrent.LinkedBlockingDeque.takeFirst(LinkedBlockingDeque.java:440)
at java.util.concurrent.LinkedBlockingDeque.take(LinkedBlockingDeque.java:629)
at com.nbp.theplatform.threaddump.ThreadIoWaitState$IoWaitHandler2.run(ThreadIoWaitState.java:89)
at java.lang.Thread.run(Thread.java:662)

waiting for monitor entry 或 in Object.wait()

Moniter 是 Java 中用以实现线程之间的互斥与协作的主要手段,它可以看成是对象或者 class 的锁,每个对象都有,也仅有一个 Monitor。

img

从上图可以看出,每个 Monitor 在某个时刻只能被一个线程拥有,该线程就是 “Active Thread”,而其他线程都是 “Waiting Thread”,分别在两个队列 “Entry Set”和”Waint Set”里面等待。其中在 “Entry Set” 中等待的线程状态是 waiting for monitor entry,在 “Wait Set” 中等待的线程状态是 in Object.wait()

(1)”Entry Set”里面的线程。

我们称被 synchronized 保护起来的代码段为临界区,对应的代码如下:

1
2
synchronized(obj) {
}

当一个线程申请进入临界区时,它就进入了 “Entry Set” 队列中,这时候有两种可能性:

  • 该 Monitor 不被其他线程拥有,”Entry Set”里面也没有其他等待的线程。本线程即成为相应类或者对象的 Monitor 的 Owner,执行临界区里面的代码;此时在 Thread Dump 中显示线程处于 “Runnable” 状态。
  • 该 Monitor 被其他线程拥有,本线程在 “Entry Set” 队列中等待。此时在 Thread Dump 中显示线程处于 “waiting for monity entry” 状态。

临界区的设置是为了保证其内部的代码执行的原子性和完整性,但因为临界区在任何时间只允许线程串行通过,这和我们使用多线程的初衷是相反的。如果在多线程程序中大量使用 synchronized,或者不适当的使用它,会造成大量线程在临界区的入口等待,造成系统的性能大幅下降。如果在 Thread Dump 中发现这个情况,应该审视源码并对其进行改进。

(2)”Wait Set”里面的线程

当线程获得了 Monitor,进入了临界区之后,如果发现线程继续运行的条件没有满足,它则调用对象(通常是被 synchronized 的对象)的 wait() 方法,放弃 Monitor,进入 “Wait Set”队列。只有当别的线程在该对象上调用了 notify() 或者 notifyAll() 方法,”Wait Set”队列中的线程才得到机会去竞争,但是只有一个线程获得对象的 Monitor,恢复到运行态。”Wait Set”中的线程在 Thread Dump 中显示的状态为 in Object.wait()。通常来说,当 CPU 很忙的时候关注 Runnable 状态的线程,反之则关注 waiting for monitor entry 状态的线程。

参考资料

Java_虚拟机_故障处理

故障定位思路

Java 应用出现线上故障,如何进行诊断?

我们在定位线上问题时要有一个整体的思路,顺藤摸瓜,才能较快的找到问题原因。

一般来说,服务器故障诊断的整体思路如下:

img

应用故障诊断思路:

img

CPU 问题

一、CPU 使用率过高:往往是由于程序逻辑问题导致的。常见导致 CPU 飙升的问题场景如:死循环,无限递归、频繁 GC、线程上下文切换过多。

二、CPU 始终升不上去:往往是由于程序中存在大量 IO 操作并且时间很长(数据库读写、日志等)。

查找 CPU 占用率较高的进程、线程

线上环境的 Java 应用可能有多个进程、线程,所以,要先找到 CPU 占用率较高的进程、线程。

(1)使用 ps 命令查看 xxx 应用的进程 ID(PID)

1
ps -ef | grep xxx

也可以使用 jps 命令来查看。

(2)如果应用有多个进程,可以用 top 命令查看哪个占用 CPU 较高。

(3)用 top -Hp pid 来找到 CPU 使用率比较高的一些线程。

(4)将占用 CPU 最高的 PID 转换为 16 进制,使用 printf '%x\n' pid 得到 nid

(5)使用 jstack pic | grep 'nid' -C5 命令,查看堆栈信息:

1
2
3
4
5
6
7
8
9
10
11
12
$ jstack 7129 | grep '0x1c23' -C5
at java.lang.Object.wait(Object.java:502)
at java.lang.ref.Reference.tryHandlePending(Reference.java:191)
- locked <0x00000000b5383ff0> (a java.lang.ref.Reference$Lock)
at java.lang.ref.Reference$ReferenceHandler.run(Reference.java:153)

"main" #1 prio=5 os_prio=0 tid=0x00007f4df400a800 nid=0x1c23 in Object.wait() [0x00007f4dfdec8000]
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait(Native Method)
- waiting on <0x00000000b5384018> (a org.apache.felix.framework.util.ThreadGate)
at org.apache.felix.framework.util.ThreadGate.await(ThreadGate.java:79)
- locked <0x00000000b5384018> (a org.apache.felix.framework.util.ThreadGate)

(6)更常见的操作是用 jstack 生成堆栈快照,然后基于快照文件进行分析。生成快照命令:

1
jstack -F -l pid >> threaddump.log

(7)分析堆栈信息

一般来说,状态为 WAITINGTIMED_WAITINGBLOCKED 的线程更可能出现问题。可以执行以下命令查看线程状态统计:

1
cat threaddump.log | grep "java.lang.Thread.State" | sort -nr | uniq -c

如果存在大量 WAITINGTIMED_WAITINGBLOCKED ,那么多半是有问题啦。

是否存在频繁 GC

如果应用频繁 GC,也可能导致 CPU 飙升。为何频繁 GC 可以使用 jstack 来分析问题(分析和解决频繁 GC 问题,在后续讲解)。

那么,如何判断 Java 进程 GC 是否频繁?

可以使用 jstat -gc pid 1000 命令来观察 GC 状态。

1
2
3
4
5
6
7
$ jstat -gc 29527 200 5
S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT
22528.0 22016.0 0.0 21388.2 4106752.0 921244.7 5592576.0 2086826.5 110716.0 103441.1 12416.0 11167.7 3189 90.057 10 2.140 92.197
22528.0 22016.0 0.0 21388.2 4106752.0 921244.7 5592576.0 2086826.5 110716.0 103441.1 12416.0 11167.7 3189 90.057 10 2.140 92.197
22528.0 22016.0 0.0 21388.2 4106752.0 921244.7 5592576.0 2086826.5 110716.0 103441.1 12416.0 11167.7 3189 90.057 10 2.140 92.197
22528.0 22016.0 0.0 21388.2 4106752.0 921244.7 5592576.0 2086826.5 110716.0 103441.1 12416.0 11167.7 3189 90.057 10 2.140 92.197
22528.0 22016.0 0.0 21388.2 4106752.0 921244.7 5592576.0 2086826.5 110716.0 103441.1 12416.0 11167.7 3189 90.057 10 2.140 92.197

是否存在频繁上下文切换

针对频繁上下文切换问题,可以使用 vmstat pid 命令来进行查看。

1
2
3
4
$ vmstat 7129
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
1 0 6836 737532 1588 3504956 0 0 1 4 5 3 0 0 100 0 0

其中,cs 一列代表了上下文切换的次数。

【解决方法】

如果,线程上下文切换很频繁,可以考虑在应用中针对线程进行优化,方法有:

  • 无锁并发:多线程竞争时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的 ID 按照 Hash 取模分段,不同的线程处理不同段的数据;
  • CAS 算法:Java 的 Atomic 包使用 CAS 算法来更新数据,而不需要加锁;
  • 最少线程:避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态;
  • 使用协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换;

内存问题

内存问题诊断起来相对比 CPU 麻烦一些,场景也比较多。主要包括 OOM、GC 问题和堆外内存。一般来讲,我们会先用 free 命令先来检查一发内存的各种情况。

诊断内存问题,一般首先会用 free 命令查看一下机器的物理内存使用情况。

1
2
3
4
$ free
total used free shared buff/cache available
Mem: 8011164 3767900 735364 8804 3507900 3898568
Swap: 5242876 6836 5236040

磁盘问题

查看磁盘空间使用率

可以使用 df -hl 命令查看磁盘空间使用率。

1
2
3
4
5
6
7
8
9
$ df -hl
Filesystem Size Used Avail Use% Mounted on
devtmpfs 494M 0 494M 0% /dev
tmpfs 504M 0 504M 0% /dev/shm
tmpfs 504M 58M 447M 12% /run
tmpfs 504M 0 504M 0% /sys/fs/cgroup
/dev/sda2 20G 5.7G 13G 31% /
/dev/sda1 380M 142M 218M 40% /boot
tmpfs 101M 0 101M 0% /run/user/0

查看磁盘读写性能

可以使用 iostat 命令查看磁盘读写性能。

1
2
3
4
5
6
7
8
iostat -d -k -x
Linux 3.10.0-327.el7.x86_64 (elk-server) 03/07/2020 _x86_64_ (4 CPU)

Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
sda 0.00 0.14 0.01 1.63 0.42 157.56 193.02 0.00 2.52 11.43 2.48 0.60 0.10
scd0 0.00 0.00 0.00 0.00 0.00 0.00 8.00 0.00 0.27 0.27 0.00 0.27 0.00
dm-0 0.00 0.00 0.01 1.78 0.41 157.56 177.19 0.00 2.46 12.09 2.42 0.59 0.10
dm-1 0.00 0.00 0.00 0.00 0.00 0.00 16.95 0.00 1.04 1.04 0.00 1.02 0.00

查看具体的文件读写情况

可以使用 lsof -p pid 命令

网络问题

无法连接

可以通过 ping 命令,查看是否能连通。

通过 netstat -nlp | grep <port> 命令,查看服务端口是否在工作。

网络超时

网络超时问题大部分出在应用层面。超时大体可以分为连接超时和读写超时,某些使用连接池的客户端框架还会存在获取连接超时和空闲连接清理超时。

  • 读写超时。readTimeout/writeTimeout,有些框架叫做 so_timeout 或者 socketTimeout,均指的是数据读写超时。注意这边的超时大部分是指逻辑上的超时。soa 的超时指的也是读超时。读写超时一般都只针对客户端设置。
  • 连接超时。connectionTimeout,客户端通常指与服务端建立连接的最大时间。服务端这边 connectionTimeout 就有些五花八门了,jetty 中表示空闲连接清理时间,tomcat 则表示连接维持的最大时间。
  • 其他。包括连接获取超时 connectionAcquireTimeout 和空闲连接清理超时 idleConnectionTimeout。多用于使用连接池或队列的客户端或服务端框架。

我们在设置各种超时时间中,需要确认的是尽量保持客户端的超时小于服务端的超时,以保证连接正常结束。

在实际开发中,我们关心最多的应该是接口的读写超时了。

如何设置合理的接口超时是一个问题。如果接口超时设置的过长,那么有可能会过多地占用服务端的 tcp 连接。而如果接口设置的过短,那么接口超时就会非常频繁。

服务端接口明明 rt 降低,但客户端仍然一直超时又是另一个问题。这个问题其实很简单,客户端到服务端的链路包括网络传输、排队以及服务处理等,每一个环节都可能是耗时的原因。

TCP 队列溢出

tcp 队列溢出是个相对底层的错误,它可能会造成超时、rst 等更表层的错误。因此错误也更隐蔽,所以我们单独说一说。
img

如上图所示,这里有两个队列:syns queue(半连接队列)、accept queue(全连接队列)。三次握手,在 server 收到 client 的 syn 后,把消息放到 syns queue,回复 syn+ack 给 client,server 收到 client 的 ack,如果这时 accept queue 没满,那就从 syns queue 拿出暂存的信息放入 accept queue 中,否则按 tcp_abort_on_overflow 指示的执行。

tcp_abort_on_overflow 0 表示如果三次握手第三步的时候 accept queue 满了那么 server 扔掉 client 发过来的 ack。tcp_abort_on_overflow 1 则表示第三步的时候如果全连接队列满了,server 发送一个 rst 包给 client,表示废掉这个握手过程和这个连接,意味着日志里可能会有很多connection reset / connection reset by peer

那么在实际开发中,我们怎么能快速定位到 tcp 队列溢出呢?

netstat 命令,执行 netstat -s | egrep “listen|LISTEN”
img
如上图所示,overflowed 表示全连接队列溢出的次数,sockets dropped 表示半连接队列溢出的次数。

ss 命令,执行 ss -lnt
img
上面看到 Send-Q 表示第三列的 listen 端口上的全连接队列最大为 5,第一列 Recv-Q 为全连接队列当前使用了多少。

接着我们看看怎么设置全连接、半连接队列大小吧:

全连接队列的大小取决于 min(backlog, somaxconn)。backlog 是在 socket 创建的时候传入的,somaxconn 是一个 os 级别的系统参数。而半连接队列的大小取决于 max(64, /proc/sys/net/ipv4/tcp_max_syn_backlog)。

在日常开发中,我们往往使用 servlet 容器作为服务端,所以我们有时候也需要关注容器的连接队列大小。在 tomcat 中 backlog 叫做acceptCount,在 jetty 里面则是acceptQueueSize

RST 异常

RST 包表示连接重置,用于关闭一些无用的连接,通常表示异常关闭,区别于四次挥手。

在实际开发中,我们往往会看到connection reset / connection reset by peer错误,这种情况就是 RST 包导致的。

端口不存在

如果像不存在的端口发出建立连接 SYN 请求,那么服务端发现自己并没有这个端口则会直接返回一个 RST 报文,用于中断连接。

主动代替 FIN 终止连接

一般来说,正常的连接关闭都是需要通过 FIN 报文实现,然而我们也可以用 RST 报文来代替 FIN,表示直接终止连接。实际开发中,可设置 SO_LINGER 数值来控制,这种往往是故意的,来跳过 TIMED_WAIT,提供交互效率,不闲就慎用。

客户端或服务端有一边发生了异常,该方向对端发送 RST 以告知关闭连接

我们上面讲的 tcp 队列溢出发送 RST 包其实也是属于这一种。这种往往是由于某些原因,一方无法再能正常处理请求连接了(比如程序崩了,队列满了),从而告知另一方关闭连接。

接收到的 TCP 报文不在已知的 TCP 连接内

比如,一方机器由于网络实在太差 TCP 报文失踪了,另一方关闭了该连接,然后过了许久收到了之前失踪的 TCP 报文,但由于对应的 TCP 连接已不存在,那么会直接发一个 RST 包以便开启新的连接。

一方长期未收到另一方的确认报文,在一定时间或重传次数后发出 RST 报文

这种大多也和网络环境相关了,网络环境差可能会导致更多的 RST 报文。

之前说过 RST 报文多会导致程序报错,在一个已关闭的连接上读操作会报connection reset,而在一个已关闭的连接上写操作则会报connection reset by peer。通常我们可能还会看到broken pipe错误,这是管道层面的错误,表示对已关闭的管道进行读写,往往是在收到 RST,报出connection reset错后继续读写数据报的错,这个在 glibc 源码注释中也有介绍。

我们在诊断故障时候怎么确定有 RST 包的存在呢?当然是使用 tcpdump 命令进行抓包,并使用 wireshark 进行简单分析了。tcpdump -i en0 tcp -w xxx.cap,en0 表示监听的网卡。
img

接下来我们通过 wireshark 打开抓到的包,可能就能看到如下图所示,红色的就表示 RST 包了。
img

TIME_WAIT 和 CLOSE_WAIT

TIME_WAIT 和 CLOSE_WAIT 是啥意思相信大家都知道。
在线上时,我们可以直接用命令netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'来查看 time-wait 和 close_wait 的数量

用 ss 命令会更快ss -ant | awk '{++S[$1]} END {for(a in S) print a, S[a]}'

img

TIME_WAIT

time_wait 的存在一是为了丢失的数据包被后面连接复用,二是为了在 2MSL 的时间范围内正常关闭连接。它的存在其实会大大减少 RST 包的出现。

过多的 time_wait 在短连接频繁的场景比较容易出现。这种情况可以在服务端做一些内核参数调优:

1
2
3
4
#表示开启重用。允许将TIME-WAIT sockets重新用于新的TCP连接,默认为0,表示关闭
net.ipv4.tcp_tw_reuse = 1
#表示开启TCP连接中TIME-WAIT sockets的快速回收,默认为0,表示关闭
net.ipv4.tcp_tw_recycle = 1

当然我们不要忘记在 NAT 环境下因为时间戳错乱导致数据包被拒绝的坑了,另外的办法就是改小tcp_max_tw_buckets,超过这个数的 time_wait 都会被干掉,不过这也会导致报time wait bucket table overflow的错。

CLOSE_WAIT

close_wait 往往都是因为应用程序写的有问题,没有在 ACK 后再次发起 FIN 报文。close_wait 出现的概率甚至比 time_wait 要更高,后果也更严重。往往是由于某个地方阻塞住了,没有正常关闭连接,从而渐渐地消耗完所有的线程。

想要定位这类问题,最好是通过 jstack 来分析线程堆栈来诊断问题,具体可参考上述章节。这里仅举一个例子。

开发同学说应用上线后 CLOSE_WAIT 就一直增多,直到挂掉为止,jstack 后找到比较可疑的堆栈是大部分线程都卡在了countdownlatch.await方法,找开发同学了解后得知使用了多线程但是确没有 catch 异常,修改后发现异常仅仅是最简单的升级 sdk 后常出现的class not found

GC 问题

GC 问题除了影响 CPU 也会影响内存,诊断思路也是一致的。

(1)通常,先使用 jstat 来查看分代变化情况,比如 minor gcfull gc 次数是不是太频繁、耗时太久。

线程量太大,且不被及时 GC 也会引发 OOM,大部分就是之前说的 unable to create new native thread。除了 jstack 细细分析 dump 文件外,我们一般先会看下总体线程。

可以执行以下命令中任意一个,没来查看当前进程创建的总线程数。

1
2
pstreee -p pid | wc -l
ls -l /proc/pid/task | wc -l

堆内内存泄漏总是和 GC 异常相伴。不过 GC 问题不只是和内存问题相关,还有可能引起 CPU 负载、网络问题等系列并发症,只是相对来说和内存联系紧密些,所以我们在此单独总结一下 GC 相关问题。

我们在 cpu 章介绍了使用 jstat 来获取当前 GC 分代变化信息。而更多时候,我们是通过 GC 日志来诊断问题的,在启动参数中加上-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps来开启 GC 日志。
常见的 Minor GC、Full GC 日志含义在此就不做赘述了。

针对 gc 日志,我们就能大致推断出 Minor GC 与 fullGC 是否过于频繁或者耗时过长,从而对症下药。我们下面将对 G1 垃圾收集器来做分析,这边也建议大家使用 G1-XX:+UseG1GC

OOM

查看 GC 日志,如果有明显提示 OOM 问题,那就可以根据提示信息,较为快速的定位问题。

OOM 定位可以参考:JVM 内存区域之 OutOfMemoryError

Minor GC

Minor GC 过频

Minor GC 频繁一般是短周期的 Java 小对象较多

(1)先考虑是不是 Eden 区/新生代设置的太小了,看能否通过调整 -Xmn、-XX:SurvivorRatio 等参数设置来解决问题。

(2)如果参数正常,但是 Minor GC 频率还是太高,就需要使用 jmapMAT 对 dump 文件进行进一步诊断了。

Minor GC 耗时过长

Minor GC 耗时过长问题就要看 GC 日志里耗时耗在哪一块了。

以 G1 GC 日志为例,可以关注 Root Scanning、Object Copy、Ref Proc 等阶段。Ref Proc 耗时长,就要注意引用相关的对象。Root Scanning 耗时长,就要注意线程数、跨代引用。Object Copy 则需要关注对象生存周期。而且耗时分析它需要横向比较,就是和其他项目或者正常时间段的耗时比较。

Full GC 过频

G1 中更多的还是 mixedGC,但 mixedGC 可以和 Minor GC 思路一样去诊断。触发 fullGC 了一般都会有问题,G1 会退化使用 Serial 收集器来完成垃圾的清理工作,暂停时长达到秒级别,可以说是半跪了。

fullGC 的原因可能包括以下这些,以及参数调整方面的一些思路:

  • 并发阶段失败:在并发标记阶段,MixGC 之前老年代就被填满了,那么这时候 G1 就会放弃标记周期。这种情况,可能就需要增加堆大小,或者调整并发标记线程数-XX:ConcGCThreads
  • 晋升失败:在 GC 的时候没有足够的内存供存活/晋升对象使用,所以触发了 Full GC。这时候可以通过-XX:G1ReservePercent来增加预留内存百分比,减少-XX:InitiatingHeapOccupancyPercent来提前启动标记,-XX:ConcGCThreads来增加标记线程数也是可以的。
  • 大对象分配失败:大对象找不到合适的 region 空间进行分配,就会进行 fullGC,这种情况下可以增大内存或者增大-XX:G1HeapRegionSize
  • 程序主动执行 System.gc():不要随便写就对了。

另外,我们可以在启动参数中配置-XX:HeapDumpPath=/xxx/dump.hprof来 dump fullGC 相关的文件,并通过 jinfo 来进行 gc 前后的 dump

1
2
jinfo -flag +HeapDumpBeforeFullGC pid
jinfo -flag +HeapDumpAfterFullGC pid

这样得到 2 份 dump 文件,对比后主要关注被 gc 掉的问题对象来定位问题。

常用 Linux 命令

在故障排查时,有一些 Linux 命令十分有用,建议掌握。

top

top 命令可以实时动态地查看系统的整体运行情况,是一个综合了多方信息监测系统性能和运行信息的实用工具。

通常,会使用 top -Hp pid 查看具体线程使用系统资源情况。

命令详情参考:http://man.linuxde.net/top

vmstat

vmstat 是一款指定采样周期和次数的功能性监测工具,我们可以看到,它不仅可以统计内存的使用情况,还可以观测到 CPU 的使用率、swap 的使用情况。但 vmstat 一般很少用来查看内存的使用情况,而是经常被用来观察进程的上下文切换。

  • r:等待运行的进程数;
  • b:处于非中断睡眠状态的进程数;
  • swpd:虚拟内存使用情况;
  • free:空闲的内存;
  • buff:用来作为缓冲的内存数;
  • si:从磁盘交换到内存的交换页数量;
  • so:从内存交换到磁盘的交换页数量;
  • bi:发送到块设备的块数;
  • bo:从块设备接收到的块数;
  • in:每秒中断数;
  • cs:每秒上下文切换次数;
  • us:用户 CPU 使用时间;
  • sy:内核 CPU 系统使用时间;
  • id:空闲时间;
  • wa:等待 I/O 时间;
  • st:运行虚拟机窃取的时间。

参考资料

HBase 架构

HBase 是一个在 HDFS 上开发的面向列的分布式数据库。

HBase 存储架构

在 HBase 中,表被分割成多个更小的块然后分散的存储在不同的服务器上,这些小块叫做 Regions,存放 Regions 的地方叫做 RegionServer。Master 进程负责处理不同的 RegionServer 之间的 Region 的分发。

概览

img

HBase 主要处理两种文件:预写日志(WAL)和实际数据文件 HFile。一个基本的流程是客户端首先联系 ZooKeeper 集群查找行键。上述过程是通过 ZooKeeper 获取欧含有 -ROOT- 的 region 服务器来完成的。通过含有 -ROOT- 的 region 服务器可以查询到含有 .META. 表中对应的 region 服务器名,其中包含请求的行键信息。这两种内容都会被缓存下来,并且只查询一次。最终,通过查询 .META. 服务器来获取客户端查询的行键数据所在 region 的服务器名。

Region

HBase Table 中的所有行按照 Row Key 的字典序排列。HBase Table 根据 Row Key 的范围分片,每个分片叫做 Region。一个 Region 包含了在 start key 和 end key 之间的所有行。

img

HBase 支持自动分区:每个表初始只有一个 Region,随着数据不断增加,Region 会不断增大,当增大到一个阀值的时候,Region 就会分裂为两个新的 Region。当 Table 中的行不断增多,就会有越来越多的 Region

Region 是 HBase 中分布式存储和负载均衡的最小单元。这意味着不同的 Region 可以分布在不同的 Region Server 上。但一个 Region 是不会拆分到多个 Server 上的。

img

Region Server

Region 只不过是表被拆分,并分布在 Region Server。

Region Server 运行在 HDFS 的 DataNode 上。它具有以下组件:

  • **WAL(Write Ahead Log,预写日志)**:用于存储尚未进持久化存储的数据记录,以便在发生故障时进行恢复。如果写 WAL 失败了,那么修改数据的完整操作就是失败的。
    • 通常情况,每个 RegionServer 只有一个 WAL 实例。在 2.0 之前,WAL 的实现叫做 HLog
    • WAL 位于 /hbase/WALs/ 目录下
    • 如果每个 RegionServer 只有一个 WAL,由于 HDFS 必须是连续的,导致必须写 WAL 连续的,然后出现性能问题。MultiWAL 可以让 RegionServer 同时写多个 WAL 并行的,通过 HDFS 底层的多管道,最终提升总的吞吐量,但是不会提升单个 Region 的吞吐量。
  • BlockCache读缓存。它将频繁读取的数据存储在内存中,如果存储不足,它将按照 最近最少使用原则 清除多余的数据。
  • MemStore写缓存。它存储尚未写入磁盘的新数据,并会在数据写入磁盘之前对其进行排序。每个 Region 上的每个列族都有一个 MemStore。
  • HFile将行数据按照 Key/Values 的形式存储在文件系统上。HFile 是 HBase 在 HDFS 中存储数据的格式,它包含多层的索引,这样在 HBase 检索数据的时候就不用完全的加载整个文件。HFile 存储的根目录默认为为 /hbase。索引的大小(keys 的大小,数据量的大小)影响 block 的大小,在大数据集的情况下,block 的大小设置为每个 RegionServer 1GB 也是常见的。
    • 起初,HFile 中并没有任何 Block,数据还存在于 MemStore 中。
    • Flush 发生时,创建 HFile Writer,第一个空的 Data Block 出现,初始化后的 Data Block 中为 Header 部分预留了空间,Header 部分用来存放一个 Data Block 的元数据信息。
    • 而后,位于 MemStore 中的 KeyValues 被一个个 append 到位于内存中的第一个 Data Block 中:

img

Region Server 存取一个子表时,会创建一个 Region 对象,然后对表的每个列族创建一个 Store 实例,每个 Store 会有 0 个或多个 StoreFile 与之对应,每个 StoreFile 则对应一个 HFile,HFile 就是实际存储在 HDFS 上的文件。

HBase 系统架构

img

和 HDFS、YARN 一样,HBase 也遵循 master / slave 架构

  • HBase 有一个 master 节点。master 节点负责协调管理 region server 节点
    • master 负责将 region 分配给 region server 节点;
    • master 负责恢复 region server 节点的故障。
  • HBase 有多个 region server 节点。region server 节点负责零个或多个 region 的管理并响应客户端的读写请求。region server 节点还负责 region 的划分并通知 master 节点有了新的子 region
  • HBase 依赖 ZooKeeper 来实现故障恢复。

Master Server

Master Server 负责协调 Region Server。具体职责如下:

  • 为 Region Server 分配 Region ;
  • 负责 Region Server 的负载均衡 ;
  • 发现失效的 Region Server 并重新分配其上的 Region;
  • GFS 上的垃圾文件回收;
  • 处理 Schema 的更新请求。

img

Region Server

  • Region Server 负责维护 Master Server 分配给它的 Region,并处理发送到 Region 上的 IO 请求;
  • 当 Region 过大,Region Server 负责自动分区,并通知 Master Server 记录更新。

img

ZooKeeper

HBase 依赖 ZooKeeper 作为分布式协调服务来维护集群中的服务器状态。Zookeeper 维护哪些服务器是活动的和可用的,并提供服务器故障通知。集群至少应该有 3 个节点。

ZooKeeper 的作用:

  • 保证任何时候,集群中只有一个 Master;
  • 存储所有 Region 的寻址入口;
  • 实时监控 Region Server 的状态,将 Region Server 的上线和下线信息实时通知给 Master;
  • 存储 HBase 的 Schema,包括有哪些 Table,每个 Table 有哪些 Column Family 等信息。

img

以上,最重要的一点是 ZooKeeper 如何保证 HBase 集群中只有一个 Master Server 的呢?

  • 所有 Master Server 会竞争 Zookeeper 的 znode 锁(一个临时节点),只有一个 Master Server 能够创建成功,此时该 Master 就是主 Master。
  • 主 Master 会定期向 Zookeeper 发送心跳。从 Master 则通过 Watcher 机制对主 Master 所在节点进行监听。
  • 如果,主 Master 未能及时发送心跳,则其持有的 ZooKeeper 会话会过期,相应的 znode 锁(一个临时节点)会被自动删除。这会触发定义在该节点上的 Watcher 事件,所有从 Master 会得到通知,并再次开始竞争 znode 锁,直到完成主 Master 的选举。

HBase 内部保留名为 hbase:meta 的特殊目录表(catalog table)。它维护着当前集群上所有 region 的列表、状态和位置。hbase:meta 表中的项使用 region 作为键。region 名由所属的表名、region 的起始行、region的创建时间以及基于整体计算得出的 MD5 组成。

HBase 读写流程

写入数据的流程

  1. Client 向 Region Server 提交写请求;
  2. Region Server 找到目标 Region;
  3. Region 检查数据是否与 Schema 一致;
  4. 如果客户端没有指定版本,则获取当前系统时间作为数据版本;
  5. 将更新写入 WAL Log;
  6. 将更新写入 Memstore;
  7. 判断 Memstore 存储是否已满,如果存储已满则需要 flush 为 Store Hfile 文件。

更为详细写入流程可以参考:HBase - 数据写入流程解析

读取数据的流程

以下是客户端首次读写 HBase 上数据的流程:

  1. 客户端从 Zookeeper 获取 META 表所在的 Region Server;
  2. 客户端访问 META 表所在的 Region Server,从 META 表中查询到访问行键所在的 Region Server,之后客户端将缓存这些信息以及 META 表的位置;
  3. 客户端从行键所在的 Region Server 上获取数据。

如果再次读取,客户端将从缓存中获取行键所在的 Region Server。这样客户端就不需要再次查询 META 表,除非 Region 移动导致缓存失效,这样的话,则将会重新查询并更新缓存。

注:META 表是 HBase 中一张特殊的表,它保存了所有 Region 的位置信息,META 表自己的位置信息则存储在 ZooKeeper 上。

img

更为详细读取数据流程参考:

HBase 原理-数据读取流程解析

HBase 原理-迟到的‘数据读取流程部分细节

参考资料

Kafka 流式处理

简介

什么是流式处理

数据流是无边界数据集的抽象表示。无边界意味着无限和持续增长。无边界数据集之所以是无限的,是因为随着时间的推移,新的记录会不断加入进来。

  • 事件流是有序的。事件的发生总是有先后顺序。而数据库里的记录是无序的。
  • 不可变的数据记录。事件一旦发生,就不能被改变。
  • 事件流是可重播的。对于大多数业务来说,重播发生在几个月前(甚至几年前)的原始事件流是一个很重要的需求。可能是为了尝试使用新的分析方法纠正过去的错误,或是为了进行审计。如果没有这项能力,流式处理充其量只是数据科学实验室里的一个玩具而已。

流式处理是指实时地处理一个或多个事件流。流式处理是一种编程范式,就像请求与响应范式和批处理范式那样。

编程范式对比

  • 请求与响应 - 这是延迟最小的一种范式,响应时间处于亚毫秒到毫秒之间,而且响应时间一般非常稳定。这种处理模式一般是阻塞的,应用程序向处理系统发出请求,然后等待响应。
  • 批处理 - 这种范式具有高延迟高吞吐量的特点。处理系统按照设定的时间启动处理进程,读取所有的输入数据(从上一次执行之后的所有可用数据,或者从月初开始的所有数据等),输出结果,然后等待下一次启动。处理时间从几分钟到几小时不等,并且用户从结果里读到的都是旧数据。一般用于 BI 生成分析报表。
  • 流式处理 - 这种范式介于上述两者之间。大部分的业务不要求亚毫秒级的响应,不过也接受不了长时间的等待。大部分业务流程都是持续进行的,只要业务报告保持更新,业务产品线能够持续响应,那么业务流程就可以进行下去,而无需等待特定的响应,也不要求在几毫秒内得到响应。一些业务流程具有持续性和非阻塞的特点。

流的定义不依赖任何一个特定的框架、 API 或特性。只要持续地从一个无边界的数据集读取数据,然后对它们进行处理并生成结果,那就是在进行流式处理。重点是,整个处理过程必须是持续的

流处理的核心概念

时间

时间或许是流式处理最为重要的概念。大部分流式应用的操作都是基于时间窗口的。有这么几个时间概念:

  • 事件时间 - 事件时间是指所追踪事件的发生时间和记录的创建时间。
  • 日志追加时间 - 日志追加时间是指事件保存到 broker 的时间。
  • 处理时间 - 处理时间是指应用程序在收到事件之后要对其进行处理的时间。这个时间可以是在事件发生之后的几毫秒、几小时或几天。同一个事件可能会被分配不同的时间戳,这取决于应用程序何时读取这个事件。如果应用程序使用了两个线程来读取同一个事件,这个时间戳也会不一样!所以这个时间戳非常不可靠,应该避免使用它。

注意:在处理与时间有关的问题时,需要注意时区问题。整个数据管道应该使用同一个时区。

状态

如果只是单独处理每一个事件,那么流式处理就很简单。

如果操作里包含了多个事件,流式处理就会变得复杂而有趣。事件与事件之间的信息被称为状态。这些状态一般被保存在应用程序的本地变量里。

流式处理含以下几种状态:

  • 本地状态或内部状态 - 这种状态只能被单个应用程序实例访问,它们一般使用内嵌在应用程序里的数据库进行维护和管理。本地状态的优势在于它的速度,不足之处在于它受到内存大小的限制 。 所以,流式处理的很多设计模式都将数据拆分到多个子流,这样就可以使用有限的本地状态来处理它们。
  • 外部状态 - 这种状态使用外部的数据存储来维护,一般使用 NoSQL 系统,比如 Cassandra。大部分流式处理应用尽量避免使用外部存储,或者将信息缓存在本地,减少与外部存储发生交互,以此来降低延迟,而这就引入了如何维护内部和外部状态一致性的问题。

流和表

流是一系列事件,每个事件就是一个变更。表包含了当前的状态,是多个变更所产生的结果。所以说, 表和流是同一个硬币的两面,世界总是在发生变化,用户有时候关注变更事件,有时候则关注世界的当前状态。如果一个系统允许使用这两种方式来查看数据,那么它就比只支持一种方式的系统强大。

时间窗口

时间窗口有不同的类型,基于以下属性决定:

  • 窗口的大小
  • 窗口移动的频率
  • 窗口的可更新时间多长

流处理的设计模式

单个事件处理

处理单个事件是流式处理最基本的模式。这个模式也叫 mapfilter 模式,因为它经常被用于过滤无用的事件或者用于转换事件( map 这个术语是从 Map-Reduce 模式中来的, map 阶段转换事件, reduce 阶段聚合转换过的事件)。

在这种模式下,应用程序读取流中的事件 ,修改它们,然后把事件生成到另一个流上。

使用本地状态

大部分流式处理应用程序关心的是如何聚合信息,特别是基于时间窗口进行聚合。

要实现这些聚合操作,需要维护流的状态,可以通过本地状态(而不是共享状态)来实现。

如果流式处理应用包含了本地状态,会变得非常复杂,还需要解决下列问题:

  • 内存使用 - 应用实例必须有可用的内存来保存本地状态。
  • 持久化 - 要确保在应用程序关闭时不会丢失状态,并且在应用程序重启后或者切换到另一个应用实例时可以恢复状态。
  • 再均衡 - 有时候,分区会被重新分配给不同的消费者。在这种情况下,失去分区的实例必须把最后的状态保存起来 , 同时获得分区的实例必须知道如何恢复到正确的状态。

多阶段处理和重分区

数据量不大的时候,可以使用本地状态。但面对海量的流数据时,可以使用多阶段处理(类似 Hadoop 的 map reduce)

流和表的连接

有些场景下,流式处理需要将外部数据和流集成在一起。

可以考虑将外部的数据信息(如数据库存储)缓存到流式处理应用程序里。

流和流的连接

有些场景下,需要连接两个真实的事件流。

将两个流里具有相同键和发生在相同时间窗口内的事件匹配起来。这就是为什么流和流的连接也叫作基于时间窗口的连接( windowed-join )。

乱序的事件

不管是对于流式处理还是传统的 ETL 系统来说,处理乱序事件都是一个挑战。

要让流处理应用程序处理好这些场景,需要做到以下几点:

  • 识别乱序的事件。应用程序需要检查事件的时间,并将其与当前时间进行比较。
  • 规定一个时间段用于重排乱序的事件。比如 3 个小时以内的事件可以重排,但 3 周以外的事件就可以直接扔掉。
  • 具有在一定时间段内重排乱序事件的能力。这是流式处理应用与批处理作业的一个主要不同点。假设有一个每天运行的作业, 一些事件在作业结束之后才到达,那么可以重新运行昨天的作业来更新事件。而在流式处理中,“重新运行昨天的作业”这种情况是不存在的,乱序事件和新到达的事件必须一起处理。
  • 具备更新结果的能力。如果处理的结果保存到数据库里,那么可以通过 put 或 update 对结果进行更新。如果流应用程序通过邮件发送结果,那么要对结果进行更新,就需要很巧妙的手段。

重新处理

有两种模式:

模式一:使用新版本应用处理同一个事件流,生成新的结果,并比较两种版本的结果,然后在某个时间点将客户端切换到新的结果流上。

模式二:重置应用,让应用回到输入流的起始位置开始处理,同时重置本地状态(这样就不会将两个版本应用的处理结果棍淆起来了),而且还可能需要清理之前的输出流。

Kafka Streams 的架构

每个流式应用程序至少会实现和执行一个拓扑。拓扑(在其他流式处理框架里叫作 DAG,即有向无环图)是一个操作和变换的集合,每个事件从输入到输出都会流经它。

img

分区和任务

Kafka 的消息传递层对数据进行分区以进行存储和传输。 Kafka Streams 对数据进行分区以进行处理。Kafka Streams 使用分区和任务的概念作为基于 Kafka 主题分区的并行模型的逻辑单元。

每个流分区都是数据记录的完全有序序列,并映射到 Kafka 主题分区。流中的数据记录映射到该主题的 Kafka 消息。更具体地说,Kafka Streams 根据应用程序的输入流分区创建固定数量的任务,每个任务分配了输入流中的分区列表(即 Kafka 主题)。分区对任务的分配永远不会改变,因此每个任务都是应用程序并行性的固定单元。然后,任务可以根据分配的分区实例化其自己的处理器拓扑。它们还为其分配的每个分区维护一个缓冲区,并一次从这些记录缓冲区处理消息。结果,可以在没有人工干预的情况下独立且并行地处理流任务。

img

参考资料

微服务基本原理

微服务技术架构

img

第一层:接入层

外部设备访问的统一接入层。

第二层:聚合服务层

对下层的基础服务做一些聚合,剪裁的工作,适配上层不同设备的数据输出。

第三层:基础服务层

比较细粒度的微服务层,提供基础的核心服务,公共服务。

img

服务通信

通过注册中心,服务消费者和服务提供者就可以感知彼此,但是,要实现交互还必须解决通信问题:

  • 通信协议。即服务提供者和服务消费者之间以什么样的 协议 进行网络通信,说白了,是要解决客户端和服务端如何建立连接、管理连接以及服务端如何处理请求的问题。是采用四层 TCP、UDP 协议,还是采用七层 HTTP 协议,还是采用其他协议?例如:Dubbo 基于 TCP 通信;而 Spring Cloud 基于 HTTP REST 通信。TCP 通信方式,传输效率更高;但是 HTTP 方式天然可以提供对外服务。
  • 传输方式。即服务提供者和服务消费者之间的数据传输采用哪种方式。是同步还是异步?是在单连接上传输,还是多路复用。
  • 序列化和反序列化。它主要解决客户端和服务端采用哪种数据编解码的问题。常见的序列化方式包括:XML、JSON;二进制类如:thriftprotobufhessian、JDK。

序列化方式

序列化方式的选型,一般基于以下考虑:

  • 支持数据结构类型的丰富度
  • 跨语言支持
  • 性能

👉 参考:Java 序列化

通信协议

微服务框架对比:

RPC REST
耦合性 强耦合 松散耦合
协议 Tcp Http、Http2
序列化 二进制(Thrift、Protobuf、Hessian、Avro、JDK 等) Xml、Json
性能
客户端 对编程语言有限制 跨语言支持更好(支持 Http 即可)
代表技术 Dubbo、Motan、Tars、gRpc、Thrift Spring Cloud

服务监控

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

监控对象

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

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

  • 业务监控:核心指标、登录、登出、下单、支付等。
  • 应用监控:访问接口、访问服务、SQL、内存使用率、响应时间、TPS、QPS 等。
  • 系统监控:CPU、内存、网络、磁盘等。
  • 基础监控:网络流量、丢包数、错包数、连接数等。
  • 客户端监控:性能、返回码、地域、运营商、版本、系统等。

系统监控原理

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

监控系统主要包括四个环节:数据采集、数据传输、数据处理和数据展示

数据采集

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

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

数据传输

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

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

数据处理

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

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

聚合后的数据需要持久化到数据库中存储,所选用的数据库一般分为两种:

  • 全文检索数据库:比如 Elasticsearch,以倒排索引的数据结构存储,需要查询的时候,根据索引来查询。
  • 时序数据库:比如 OpenTSDB,以时序序列数据的方式存储,查询的时候按照时序如 1min、5min 等维度来查询。

数据展示

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

监控技术

img

  • ELK 的技术栈比较成熟,应用范围也比较广,除了可用作监控系统外,还可以用作日志查询和分析。
  • Graphite 是基于时间序列数据库存储的监控系统,并且提供了功能强大的各种聚合函数比如 sum、average、top5 等可用于监控分析,而且对外提供了 API 也可以接入其他图形化监控系统如 Grafana。
  • TICK 的核心在于其时间序列数据库 InfluxDB 的存储功能强大,且支持类似 SQL 语言的复杂数据处理操作。
  • Prometheus 的独特之处在于它采用了拉数据的方式,对业务影响较小,同时也采用了时间序列数据库存储,而且支持独有的 PromQL 查询语言,功能强大而且简洁。

服务治理

微服务治理平台就是与服务打交道的统一入口,无论是开发人员还是运维人员,都能通过这个平台对服务进行各种操作,比如开发人员可以通过这个平台对服务进行降级操作,运维人员可以通过这个平台对服务进行上下线操作,而不需要关心这个操作背后的具体实现。

微服务治理平台关键之处就在于它能够封装对微服务架构内的各个基础设施组件的调用,从而对外提供统一的服务操作 API,而且还提供了可视化的界面,以方便开发人员和运维人员操作。

img

服务治理的常用手段有:

  • 节点管理
    • 注册中心主动摘除机制
    • 服务消费者摘除机制
  • 负载均衡
    • 轮询
    • 随机
    • 最近最少连接
    • 一致性 Hash
  • 服务路由
    • 业务存在灰度发布的需求
    • 多机房就近访问的需求
  • 服务容错
    • FailOver:失败自动切换
    • FailBack:失败通知
    • FailCache:失败缓存
    • FailFast:快速失败

API 网关

API 网关是一个服务器,是系统的唯一入口。从面向对象设计的角度看,它与外观模式类似。API 网关封装了系统内部架构,为每个客户端提供一个定制的 API。它可能还具有其它职责,如身份验证、监控、负载均衡、缓存、请求分片与管理、静态响应处理。
API 网关方式的核心要点是,所有的客户端和消费端都通过统一的网关接入微服务,在网关层处理所有的非业务功能。通常,网关也是提供 REST/HTTP 的访问 API。服务端通过 API-GW 注册和管理服务。

Zuul

img

在 zuul 中, 整个请求的过程是这样的,首先将请求给 zuulservlet 处理,zuulservlet 中有一个 zuulRunner 对象,该对象中初始化了 RequestContext:作为存储整个请求的一些数据,并被所有的 zuulfilter 共享。zuulRunner 中还有 FilterProcessor,FilterProcessor 作为执行所有的 zuulfilter 的管理器。FilterProcessor 从 filterloader 中获取 zuulfilter,而 zuulfilter 是被 filterFileManager 所加载,并支持 groovy 热加载,采用了轮询的方式热加载。有了这些 filter 之后,zuulservelet 首先执行的 Pre 类型的过滤器,再执行 route 类型的过滤器,最后执行的是 post 类型的过滤器,如果在执行这些过滤器有错误的时候则会执行 error 类型的过滤器。执行完这些过滤器,最终将请求的结果返回给客户端。

负载均衡

参考:负载均衡基本原理

服务路由

服务路由就是服务消费者在发起服务调用时,必须根据特定的规则来选择服务节点,从而满足某些特定的需求

服务路由的应用场景

  • 分组调用。一般来讲,为了保证服务的高可用性,实现异地多活的需求,一个服务往往不止部署在一个数据中心,而且出于节省成本等考虑,有些业务可能不仅在私有机房部署,还会采用公有云部署,甚至采用多家公有云部署。服务节点也会按照不同的数据中心分成不同的分组,这时对于服务消费者来说,选择哪一个分组调用,就必须有相应的路由规则。
  • 灰度发布。在服务上线发布的过程中,一般需要先在一小部分规模的服务节点上先发布服务,然后验证功能是否正常。如果正常的话就继续扩大发布范围;如果不正常的话,就需要排查问题,解决问题后继续发布。这个过程就叫作灰度发布,也叫金丝雀部署。
  • 流量切换。在业务线上运行过程中,经常会遇到一些不可抗力因素导致业务故障,比如某个机房的光缆被挖断,或者发生着火等事故导致整个机房的服务都不可用。这个时候就需要按照某个指令,能够把原来调用这个机房服务的流量切换到其他正常的机房。
  • 读写分离。对于大多数互联网业务来说都是读多写少,所以在进行服务部署的时候,可以把读写分开部署,所有写接口可以部署在一起,而读接口部署在另外的节点上。

服务路由的规则

服务路由主要有两种规则:一种是条件路由,一种是脚本路由。

条件路由

条件路由是基于条件表达式的路由规则。

1
condition://0.0.0.0/dubbo.test.interfaces.TestService?category=routers&dynamic=true&priority=2&enabled=true&rule=" + URL.encode(" host = 10.20.153.10=> host = 10.20.153.11")

这里面 condition:// 代表了这是一段用条件表达式编写的路由规则,具体的规则是

1
host = 10.20.153.10 => host = 10.20.153.11

分隔符“=>”前面是服务消费者的匹配条件,后面是服务提供者的过滤条件。当服务消费者节点满足匹配条件时,就对该服务消费者执行后面的过滤规则。那么上面这段表达式表达的意义就是 IP 为“10.20.153.10”的服务消费者都调用 IP 为“10.20.153.11”的服务提供者节点。

如果服务消费者的匹配条件为空,就表示对所有的服务消费者应用,就像下面的表达式一样。

1
=> host != 10.20.153.11

如果服务提供者的过滤条件为空,就表示禁止服务消费者访问,就像下面的表达式一样。

1
host = 10.20.153.10=>

下面我举一些 Dubbo 框架中的条件路由,来给你讲解下条件路由的具体应用场景。

  • 排除某个服务节点
1
=> host != 172.22.3.91

一旦这条路由规则被应用到线上,所有的服务消费者都不会访问 IP 为 172.22.3.91 的服务节点,这种路由规则一般应用在线上流量排除预发布机以及摘除某个故障节点的场景。

  • 白名单和黑名单功能
1
host != 10.20.153.10,10.20.153.11 =>

这条路由规则意思是除了 IP 为 10.20.153.10 和 10.20.153.11 的服务消费者可以发起服务调用以外,其他服务消费者都不可以,主要用于白名单访问逻辑,比如某个后台服务只允许特定的几台机器才可以访问,这样的话可以机器控制访问权限。

1
host = 10.20.153.10,10.20.153.11 =>

同理,这条路由规则意思是除了 IP 为 10.20.153.10 和 10.20.153.11 的服务消费者不能发起服务调用以外,其他服务消费者都可以,也就是实现了黑名单功能,比如线上经常会遇到某些调用方不管是出于有意还是无意的不合理调用,影响了服务的稳定性,这时候可以通过黑名单功能暂时予以封杀。

  • 机房隔离
1
host = 172.22.3.* => host = 172.22.3.*

这条路由规则意思是 IP 网段为 172.22.3.* 的服务消费者,才可以访问同网段的服务节点,这种规则一般应用于服务部署在多个 IDC,理论上同一个 IDC 内的调用性能要比跨 IDC 调用性能要好,应用这个规则是为了实现同 IDC 就近访问。

  • 读写分离
1
2
method = find*,list*,get*,is* => host =172.22.3.94,172.22.3.95
method != find*,list*,get*,is* => host = 172.22.3.97,172.22.3.98

这条路由规则意思是 find、get、is* 等读方法调用 IP 为 172.22.3.94 和 172.22.3.95 的节点,除此以外的写方法调用 IP 为 172.22.3.97 和 172.22.3.98 的节点。对于大部分互联网业务来说,往往读请求要远远大于写请求,而写请求的重要性往往要远远高于读请求,所以需要把读写请求进行分离,以避免读请求异常影响到写请求,这时候就可以应用这种规则。

脚本路由

脚本路由是基于脚本语言的路由规则,常用的脚本语言比如 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));

服务路由的获取方式

服务路由的获取方式主要有三种:

  • 本地配置

顾名思义就是路由规则存储在服务消费者本地上。服务消费者发起调用时,从本地固定位置读取路由规则,然后按照路由规则选取一个服务节点发起调用。

  • 配置中心管理

这种方式下,所有的服务消费者都从配置中心获取路由规则,由配置中心来统一管理。

  • 动态下发

这种方式下,一般是运维人员或者开发人员,通过服务治理平台修改路由规则,服务治理平台调用配置中心接口,把修改后的路由规则持久化到配置中心。因为服务消费者订阅了路由规则的变更,于是就会从配置中心获取最新的路由规则,按照最新的路由规则来执行。

内部服务调用

基础服务之间的调用:结合服务注册中心以及专属的具有负载均衡功能的客户端,如 Eureka+(restTemplate+Ribbon)或者 Eureka+Feign
聚合服务调用:结合服务注册中心以及专属的具有负载均衡功能的客户端,如 Eureka+(restTemplate+Ribbon)或者 Eureka+Feign

img

外部服务调用

基于 Netflix 的 zuul,做了简单了解,SpringCloud 与 zuul 集成的方式。这里先对核心流程做个简单了解,后续会有深入的应用、分析。

Spring Cloud 很好的集成了 zuul,并且可以通过注解的形式来进行请求的反向路由以及 API 网关功能
Spring Cloud 集成 zuul,对与 url 映射的处理方式与 SpringMVC 对 url 的请求方式类似,都是通过 RequestMapping 来进行请求绑定的。核心类:ZuulHandlerMapping
zuul 的核心是 ZuulServlet,一个请求核心流程:HttpServletRequest –>ZuulHandlerMapping –>ZuulController –> ZuulServlet –> ZuulFilter –> HttpServletResponse

配置中心

配置中心的思路就是把服务的各种配置,如代码里配置的各种参数、服务降级的开关甚至依赖的资源等都在一个地方统一进行管理。服务启动时,可以自动从配置中心中拉取所需的配置,并且如果有配置变更的情况,同样可以自动从配置中心拉取最新的配置信息,服务无须重新发布。

配置中心一般包含下面几个功能:

  • 配置注册功能
  • 配置反注册功能
  • 配置查看功能
  • 配置变更订阅功能

Apollo

携程开源的分布式配置中心,支持 Java 和.Net 语言,客户端和配置中心通过 HTTP 长连接实现实时推送,并且有统一的管理界面来实现配置管理。

img

Spring Cloud Git

Spring Cloud 中使用的配置中心组件,只支持 Java 语言,配置存储在 git 中,变更配置也需要通过 git 操作,如果配置中心有配置变更,需要手动刷新。

img

链路追踪

链路追踪的作用

  • 优化系统瓶颈
  • 优化链路调用
  • 生成网络拓扑
  • 透明传输数据

链路追踪的原理

理解链路追踪必须先了解以下概念:

  • traceId,用于标识某一次具体的请求 ID。当用户的请求进入系统后,会在 RPC 调用网络的第一层生成一个全局唯一的 traceId,并且会随着每一层的 RPC 调用,不断往后传递,这样的话通过 traceId 就可以把一次用户请求在系统中调用的路径串联起来。
  • spanId,用于标识一次 RPC 调用在分布式请求中的位置。当用户的请求进入系统后,处在 RPC 调用网络的第一层 A 时 spanId 初始值是 0,进入下一层 RPC 调用 B 的时候 spanId 是 0.1,继续进入下一层 RPC 调用 C 时 spanId 是 0.1.1,而与 B 处在同一层的 RPC 调用 E 的 spanId 是 0.2,这样的话通过 spanId 就可以定位某一次 RPC 请求在系统调用中所处的位置,以及它的上下游依赖分别是谁。
  • annotation,用于业务自定义埋点数据,可以是业务感兴趣的想上传到后端的数据,比如一次请求的用户 UID。

img

链路追踪的实现

一个服务追踪系统一般可以分为三层:

  • 数据采集层,负责数据埋点并上报。
  • 数据处理层,负责数据的存储与计算。
  • 数据展示层,负责数据的图形化展示。

数据采集层

一次 RPC 请求可以分为四个阶段。

  • CS(Client Send)阶段 : 客户端发起请求,并生成调用的上下文。
  • SR(Server Recieve)阶段 : 服务端接收请求,并生成上下文。
  • SS(Server Send)阶段 : 服务端返回请求,这个阶段会将服务端上下文数据上报,下面这张图可以说明上报的数据有:traceId=123456,spanId=0.1,appKey=B,method=B.method,start=103,duration=38。
  • CR(Client Recieve)阶段 : 客户端接收返回结果,这个阶段会将客户端上下文数据上报,上报的数据有:traceid=123456,spanId=0.1,appKey=A,method=B.method,start=103,duration=38。

数据处理层

数据处理层的作用就是把数据采集层上报的数据按需计算,然后落地存储供查询使用。

  • 实时数据处理

针对实时数据处理,一般采用 Storm 或者 Spark Streaming 来对链路数据进行实时聚合加工,存储一般使用 OLTP 数据仓库,比如 HBase,使用 traceId 作为 RowKey,能天然地把一整条调用链聚合在一起,提高查询效率。

  • 离线数据处理

针对离线数据处理,一般通过运行 MapReduce 或者 Spark 批处理程序来对链路数据进行离线计算,存储一般使用 Hive。

数据展示层

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

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

链路追踪方案对比

img

限流熔断

一般而言,集群故障的产生原因不外乎有两种:

一种是代码 bug 所导致,比如说某一段 Java 代码不断地分配大对象,但没有及时回收导致 JVM OOM 退出;

另一种是突发的流量冲击,超出了系统的最大承载能力,比如“双 11”这种购物活动,电商系统会在零点一瞬间涌入大量流量,超出系统的最大承载能力,一下子就把整个系统给压垮了。

应付集群故障的思路,主要有两种:限流降级

限流

限流就是限制流量。通常情况下,系统能够承载的流量根据集群规模的大小是固定的,可以称之为系统的最大容量。当真实流量超过了系统的最大容量后,就会导致系统响应变慢,服务调用出现大量超时,反映给用户的感觉就是卡顿、无响应。所以,应该根据系统的最大容量,给系统设置一个阈值,超过这个阈值的请求会被自动抛弃,这样的话可以最大限度地保证系统提供的服务正常。

除此之外,通常一个微服务系统会同时提供多个服务,每个服务在同一时刻的请求量也是不同的,很可能出现的一种情况就是,系统中某个服务的请求量突增,占用了系统中大部分资源,导致其他服务没有资源可用。因此,还要针对系统中每个服务的请求量也设置一个阈值,超过这个阈值的请求也要被自动抛弃,这样的话不至于因为一个服务影响了其他所有服务。

在实际项目中,可以用两个指标来衡量服务的请求量,一个是 QPS 即每秒请求量,一个是工作线程数。不过 QPS 因为不同服务的响应快慢不同,所以系统能够承载的 QPS 相差很大,因此一般选择工作线程数来作为限流的指标,给系统设置一个总的最大工作线程数以及单个服务的最大工作线程数,这样的话无论是系统的总请求量过大导致整体工作线程数量达到最大工作线程数,还是某个服务的请求量超过单个服务的最大工作线程数,都会被限流,以起到保护整个系统的作用。

降级

什么是降级呢?在我看来,降级就是通过停止系统中的某些功能,来保证系统整体的可用性。降级可以说是一种被动防御的措施,为什么这么说呢?因为它一般是系统已经出现故障后所采取的一种止损措施。

那么降级一般是如何实现的呢?根据我的实践来看, 一种可行的方案是通过开关来实现。

具体来讲,就是在系统运行的内存中开辟一块区域,专门用于存储开关的状态,也就是开启还是关闭。并且需要监听某个端口,通过这个端口可以向系统下发命令,来改变内存中开关的状态。当开关开启时,业务的某一段逻辑就不再执行,而正常情况下,开关是关闭的状态。

开关一般用在两种地方,一种是新增的业务逻辑,因为新增的业务逻辑相对来说不成熟,往往具备一定的风险,所以需要加开关来控制新业务逻辑是否执行;另一种是依赖的服务或资源,因为依赖的服务或者资源不总是可靠的,所以最好是有开关能够控制是否对依赖服务或资源发起调用,来保证即使依赖出现问题,也能通过降级来避免影响。

在实际业务应用的时候,降级要按照对业务的影响程度进行分级,一般分为三级:一级降级是对业务影响最小的降级,在故障的情况下,首先执行一级降级,所以一级降级也可以设置成自动降级,不需要人为干预;二级降级是对业务有一定影响的降级,在故障的情况下,如果一级降级起不到多大作用的时候,可以人为采取措施,执行二级降级;三级降级是对业务有较大影响的降级,这种降级要么是对商业收入有重大影响,要么是对用户体验有重大影响,所以操作起来要非常谨慎,不在最后时刻一般不予采用。

DEVOPS

容器和容器平台

Mesos、Marathon、Kubernetes

RPC 选型

限定语言 RPC

跟语言平台绑定的开源 RPC 框架主要有下面几种。

  • Dubbo:国内最早开源的 RPC 框架,由阿里巴巴公司开发并于 2011 年末对外开源,仅支持 Java 语言。
  • Motan:微博内部使用的 RPC 框架,于 2016 年对外开源,仅支持 Java 语言。
  • Tars:腾讯内部使用的 RPC 框架,于 2017 年对外开源,仅支持 C++ 语言。
  • Spring Cloud:国外 Pivotal 公司 2014 年对外开源的 RPC 框架,仅支持 Java 语言,最近几年生态发展得比较好,是比较火的 RPC 框架。

所以很明显,如果你的业务场景仅仅局限于一种语言的话,可以选择跟语言绑定的 RPC 框架中的一种;如果涉及多个语言平台之间的相互调用,就应该选择跨语言平台的 RPC 框架。

仔细分析,可以看出 Spring Cloud 不仅提供了基本的 RPC 框架功能,还提供了服务注册组件、配置中心组件、负载均衡组件、断路器组件、分布式消息追踪组件等一系列组件,也难怪被技术圈的人称之为“Spring Cloud 全家桶”。如果你不想自己实现以上这些功能,那么 Spring Cloud 基本可以满足你的全部需求。而 Dubbo、Motan 基本上只提供了最基础的 RPC 框架的功能,其他微服务组件都需要自己去实现。不过由于 Spring Cloud 的 RPC 通信采用了 HTTP 协议,相比 Dubbo 和 Motan 所采用的私有协议来说,在高并发的通信场景下,性能相对要差一些,所以对性能有苛刻要求的情况下,可以考虑 Dubbo 和 Motan。

跨语言 RPC

而跨语言平台的开源 RPC 框架主要有以下几种。

  • gRPC:Google 于 2015 年对外开源的跨语言 RPC 框架,支持常用的 C++、Java、Python、Go、Ruby、PHP、Android Java、Objective-C 等多种语言。
  • Thrift:最初是由 Facebook 开发的内部系统跨语言的 RPC 框架,2007 年贡献给了 Apache 基金,成为 Apache 开源项目之一,支持常用的 C++、Java、PHP、Python、Ruby、Erlang 等多种语言。

从成熟度上来讲,Thrift 因为诞生的时间要早于 gRPC,所以使用的范围要高于 gRPC,在 HBase、Hadoop、Scribe、Cassandra 等许多开源组件中都得到了广泛地应用。而且 Thrift 支持多达 25 种语言,这要比 gRPC 支持的语言更多,所以如果遇到 gRPC 不支持的语言场景下,选择 Thrift 更合适。

但 gRPC 作为后起之秀,因为采用了 HTTP/2 作为通信协议、ProtoBuf 作为数据序列化格式,在移动端设备的应用以及对传输带宽比较敏感的场景下具有很大的优势,而且开发文档丰富,根据 ProtoBuf 文件生成的代码要比 Thrift 更简洁一些,从使用难易程度上更占优势,所以如果使用的语言平台 gRPC 支持的话,建议还是采用 gRPC 比较好。

Service Mesh

img

Service Mesh 的实现原理

Service Mesh 实现的关键就在于两点:

一个是上面提到的轻量级的网络代理也叫 SideCar,它的作用就是转发服务之间的调用;

一个是基于 SideCar 的服务治理也被叫作 Control Plane,它的作用是向 SideCar 发送各种指令,以完成各种服务治理功能。下面我就来详细讲解这两点是如何实现的。

参考资料

MySQL 架构

大体来说,MySQL 可以分为 Server 层和存储引擎层两部分。

Server 层包括连接器、查询缓存、解析器、优化器、执行器等,涵盖 MySQL 的大多数核心服务功能,以及所有的内置函数(如日期、时间、数学和加密函数等),所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图等。

存储引擎层负责数据的存储和提取。其架构模式是插件式的,支持 InnoDB、MyISAM、Memory 等多个存储引擎。现在最常用的存储引擎是 InnoDB,它从 MySQL 5.5.5 版本开始成为了默认存储引擎。

MySQL 查询流程

SQL 语句在 MySQL 中是如何执行的?

MySQL 整个查询执行过程,总的来说分为 6 个步骤:

  1. 连接器 - 客户端和 MySQL 服务器建立连接;连接器负责跟客户端建立连接获取权限维持和管理连接
  2. 查询缓存 - MySQL 服务器首先检查查询缓存,如果命中缓存,则立刻返回结果。否则进入下一阶段。MySQL 缓存弊大于利,因为失效非常频繁——任何更新都会清空查询缓存。
  3. 分析器 - MySQL 服务器进行 SQL 解析:语法分析词法分析
  4. 优化器 - MySQL 服务器用优化器生成对应的执行计划根据策略选择最优索引
  5. 执行器 - MySQL 服务器根据执行计划,调用存储引擎的 API 来执行查询
  6. 返回结果 - MySQL 服务器将结果返回给客户端,同时缓存查询结果。

连接器

使用 MySQL 第一步自然是要连接数据库。连接器负责跟客户端建立连接、获取权限、维持和管理连接

MySQL 客户端/服务端通信是半双工模式:即任一时刻,要么是服务端向客户端发送数据,要么是客户端向服务器发送数据。客户端用一个单独的数据包将查询请求发送给服务器,所以当查询语句很长的时候,需要设置 max_allowed_packet 参数。但是需要注意的是,如果查询实在是太大,服务端会拒绝接收更多数据并抛出异常。

MySQL 客户端连接命令形式为:mysql -h<主机> -P<端口> -u<用户名> -p<密码>。如果没有显式指定密码,会要求输入密码才能访问。

连接完成后,如果没有后续的动作,这个连接就处于空闲状态可以执行 show processlist 命令查看当前有多少个客户端连接客户端如果空闲太久,连接器就会自动将它断开。客户端连接维持时间是由参数 wait_timeout 控制的,默认值是 8 小时。如果在连接被断开之后,客户端再次发送请求的话,就会收到一个错误提醒: Lost connection to MySQL server during query。这时候如果你要继续,就需要重连,然后再执行请求了。

建立连接的过程通常是比较复杂的,建议在使用中要尽量减少建立连接的动作,也就是尽量使用长连接。为了在程序中提高数据库连接的服用了,一般会使用数据库连接池来维护管理。

但是全部使用长连接后,你可能会发现,有些时候 MySQL 占用内存涨得特别快,这是因为 MySQL 在执行过程中临时使用的内存是管理在连接对象里面的。这些资源会在连接断开的时候才释放。所以如果长连接累积下来,可能导致内存占用太大,被系统强行杀掉(OOM),从现象看就是 MySQL 异常重启了。

怎么解决这个问题呢?你可以考虑以下两种方案。

  • 定期断开长连接。使用一段时间,或者程序里面判断执行过一个占用内存的大查询后,断开连接,之后要查询再重连。
  • 如果你用的是 MySQL 5.7 或更新版本,可以在每次执行一个比较大的操作后,通过执行 mysql_reset_connection 来重新初始化连接资源。这个过程不需要重连和重新做权限验证,但是会将连接恢复到刚刚创建完时的状态。

查询缓存

不建议使用数据库缓存,因为往往弊大于利

解析一个查询语句前,如果查询缓存是打开的,那么 MySQL 会检查这个查询语句是否命中查询缓存中的数据。如果当前查询恰好命中查询缓存,在检查一次用户权限后直接返回缓存中的结果。这种情况下,查询不会被解析,也不会生成执行计划,更不会执行。

MySQL 将缓存存放在一个引用表(不要理解成table,可以认为是类似于HashMap的数据结构),通过一个哈希值索引,这个哈希值通过查询本身、当前要查询的数据库、客户端协议版本号等一些可能影响结果的信息计算得来。所以两个查询在任何字符上的不同(例如:空格、注释),都会导致缓存不会命中。

如果查询中包含任何用户自定义函数、存储函数、用户变量、临时表、mysql 库中的系统表,其查询结果都不会被缓存。比如函数NOW()或者CURRENT_DATE()会因为不同的查询时间,返回不同的查询结果,再比如包含CURRENT_USER或者CONNECION_ID()的查询语句会因为不同的用户而返回不同的结果,将这样的查询结果缓存起来没有任何的意义。

不建议使用数据库缓存,因为往往弊大于利。查询缓存的失效非常频繁,只要有对一个表的更新,这个表上所有的查询缓存都会被清空。因此很可能你费劲地把结果存起来,还没使用呢,就被一个更新全清空了。对于更新压力大的数据库来说,查询缓存的命中率会非常低。除非你的业务就是有一张静态表,很长时间才会更新一次。比如,一个系统配置表,那这张表上的查询才适合使用查询缓存。

好在 MySQL 也提供了这种“按需使用”的方式。你可以将参数 query_cache_type 设置成 DEMAND,这样对于默认的 SQL 语句都不使用查询缓存。而对于你确定要使用查询缓存的语句,可以用 SQL_CACHE 显式指定,像下面这个语句一样:

1
select SQL_CACHE * from T where ID=10;

注意:MySQL 8.0 版本直接将查询缓存的整块功能删掉了。

分析器

如果没有命中查询缓存,就要开始真正执行语句了。首先,MySQL 需要知道你要做什么,因此需要对 SQL 语句做解析。MySQL 通过关键字对 SQL 语句进行解析,并生成一颗对应的语法解析树。这个过程中,分析器主要通过语法规则来验证和解析。比如 SQL 中是否使用了错误的关键字或者关键字的顺序是否正确等等。预处理则会根据 MySQL 规则进一步检查解析树是否合法。比如检查要查询的数据表和数据列是否存在等等。

  • 分析器先会先做“词法分析”。你输入的是由多个字符串和空格组成的一条 SQL 语句,MySQL 需要识别出里面的字符串分别是什么,代表什么。MySQL 从你输入的”select”这个关键字识别出来,这是一个查询语句。它也要把字符串“T”识别成“表名 T”,把字符串“ID”识别成“列 ID”。
  • 接下来,要做“语法分析”。根据词法分析的结果,语法分析器会根据语法规则,判断你输入的这个 SQL 语句是否满足 MySQL 语法。如果你的语句不对,就会收到“You have an error in your SQL syntax”的错误提醒,比如下面这个语句 select 少打了开头的字母“s”。

优化器

经过了分析器,MySQL 就知道你要做什么了。在开始执行之前,还要先经过优化器的处理。

经过前面的步骤生成的语法树被认为是合法的了,并且由优化器将其转化成执行计划。多数情况下,一条查询可以有很多种执行方式,最后都返回相应的结果。优化器的作用就是找到这其中最好的执行计划。

MySQL 使用基于成本的优化器,它尝试预测一个查询使用某种执行计划时的成本,并选择其中成本最小的一个。在 MySQL 可以通过查询当前会话的 last_query_cost 的值来得到其计算当前查询的成本。

1
2
3
4
5
6
7
8
9
mysql> select * from t_message limit 10;
...省略结果集

mysql> show status like 'last_query_cost';
+-----------------+-------------+
| Variable_name | Value |
+-----------------+-------------+
| Last_query_cost | 6391.799000 |
+-----------------+-------------+

示例中的结果表示优化器认为大概需要做 6391 个数据页的随机查找才能完成上面的查询。这个结果是根据一些列的统计信息计算得来的,这些统计信息包括:每张表或者索引的页面个数、索引的基数、索引和数据行的长度、索引的分布情况等等。

有非常多的原因会导致 MySQL 选择错误的执行计划,比如统计信息不准确、不会考虑不受其控制的操作成本(用户自定义函数、存储过程)、MySQL 认为的最优跟我们想的不一样(我们希望执行时间尽可能短,但 MySQL 值选择它认为成本小的,但成本小并不意味着执行时间短)等等。

MySQL 的查询优化器是一个非常复杂的部件,它使用了非常多的优化策略来生成一个最优的执行计划:

  • 重新定义表的关联顺序(多张表关联查询时,并不一定按照 SQL 中指定的顺序进行,但有一些技巧可以指定关联顺序)
  • 优化MIN()MAX()函数(找某列的最小值,如果该列有索引,只需要查找 B+Tree 索引最左端,反之则可以找到最大值,具体原理见下文)
  • 提前终止查询(比如:使用 Limit 时,查找到满足数量的结果集后会立即终止查询)
  • 优化排序(在老版本 MySQL 会使用两次传输排序,即先读取行指针和需要排序的字段在内存中对其排序,然后再根据排序结果去读取数据行,而新版本采用的是单次传输排序,也就是一次读取所有的数据行,然后根据给定的列排序。对于 I/O 密集型应用,效率会高很多)

随着 MySQL 的不断发展,优化器使用的优化策略也在不断的进化,这里仅仅介绍几个非常常用且容易理解的优化策略,其他的优化策略,大家自行查阅吧。

执行器

在完成解析和优化阶段以后,MySQL 会生成对应的执行计划,查询执行引擎根据执行计划给出的指令逐步执行得出结果。整个执行过程的大部分操作均是通过调用存储引擎实现的接口来完成,这些接口被称为 handler API。查询过程中的每一张表由一个 handler 实例表示。实际上,MySQL 在查询优化阶段就为每一张表创建了一个 handler 实例,优化器可以根据这些实例的接口来获取表的相关信息,包括表的所有列名、索引统计信息等。存储引擎接口提供了非常丰富的功能,但其底层仅有几十个接口,这些接口像搭积木一样完成了一次查询的大部分操作。

返回结果

查询过程的最后一个阶段就是将结果返回给客户端。即使查询不到数据,MySQL 仍然会返回这个查询的相关信息,比如该查询影响到的行数以及执行时间等等。

如果查询缓存被打开且这个查询可以被缓存,MySQL 也会将结果存放到缓存中。

结果集返回客户端是一个增量且逐步返回的过程。有可能 MySQL 在生成第一条结果时,就开始向客户端逐步返回结果集了。这样服务端就无须存储太多结果而消耗过多内存,也可以让客户端第一时间获得返回结果。需要注意的是,结果集中的每一行都会以一个数据包发送,再通过 TCP 协议进行传输,在传输过程中,可能对 MySQL 的数据包进行缓存然后批量发送。

MySQL 更新流程

MySQL 更新过程和 MySQL 查询过程类似,也会将流程走一遍。不一样的是:更新流程还涉及两个重要的日志模块,:redo log(重做日志)和 binlog(归档日志)

redo log

如果每一次的更新操作都需要写进磁盘,然后磁盘也要找到对应的那条记录,然后再更新,整个过程 IO 成本、查找成本都很高。为了解决这个问题,MySQL 采用了 WAL 技术(全程是 Write-Ahead Logging),它的关键点就是先写日志,再写磁盘

具体来说,当有一条记录需要更新的时候,InnoDB 引擎就会先把记录写到 redo log 里,并更新内存,这个时候更新就算完成了。同时,InnoDB 引擎会在适当的时候,将这个操作记录更新到磁盘里面,而这个更新往往是在系统比较空闲的时候做。

InnoDB 的 redo log 是固定大小的,比如可以配置为一组 4 个文件,每个文件的大小是 1GB,那么总共就可以记录 4GB 的操作。从头开始写,写到末尾就又回到开头循环写,如下面这个图所示。

write pos 是当前记录的位置,一边写一边后移,写到第 3 号文件末尾后就回到 0 号文件开头。checkpoint 是当前要擦除的位置,也是往后推移并且循环的,擦除记录前要把记录更新到数据文件。

write pos 和 checkpoint 之间的是还空着的部分,可以用来记录新的操作。如果 write pos 追上 checkpoint,表示“粉板”满了,这时候不能再执行新的更新,得停下来先擦掉一些记录,把 checkpoint 推进一下。

有了 redo log,InnoDB 就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失,这个能力称为crash-safe

binlog

redo log 是 InnoDB 引擎特有的日志,而 Server 层也有自己的日志,称为 binlog(归档日志)。

redo log 和 binlog 的差异:

  1. redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。
  2. redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这一行的 c 字段加 1 ”。
  3. redo log 是循环写的,空间固定会用完;binlog 是追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。

再来看一下:update 语句时的内部流程

  1. 执行器先找引擎取主键所在的行记录。如果这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。
  2. 执行器拿到引擎给的行数据,设置数值,得到新的一行数据,再调用引擎接口写入这行新数据。
  3. 引擎将这行新数据更新到内存中,同时将这个更新操作记录到 redo log 里面,此时 redo log 处于 prepare 状态。然后告知执行器执行完成了,随时可以提交事务。
  4. 执行器生成这个操作的 binlog,并把 binlog 写入磁盘。
  5. 执行器调用引擎的提交事务接口,引擎把刚刚写入的 redo log 改成提交(commit)状态,更新完成。

两阶段提交

为什么日志需要“两阶段提交”

由于 redo log 和 binlog 是两个独立的逻辑,如果不用两阶段提交,要么就是先写完 redo log 再写 binlog,或者采用反过来的顺序。

  1. 先写 redo log 后写 binlog。假设在 redo log 写完,binlog 还没有写完的时候,MySQL 进程异常重启。由于我们前面说过的,redo log 写完之后,系统即使崩溃,仍然能够把数据恢复回来,所以恢复后这一行 c 的值是 1。
  • 但是由于 binlog 没写完就 crash 了,这时候 binlog 里面就没有记录这个语句。因此,之后备份日志的时候,存起来的 binlog 里面就没有这条语句。
  • 然后你会发现,如果需要用这个 binlog 来恢复临时库的话,由于这个语句的 binlog 丢失,这个临时库就会少了这一次更新,恢复出来的这一行 c 的值就是 0,与原库的值不同。
  1. 先写 binlog 后写 redo log。如果在 binlog 写完之后 crash,由于 redo log 还没写,崩溃恢复以后这个事务无效,所以这一行 c 的值是 0。但是 binlog 里面已经记录了“把 c 从 0 改成 1”这个日志。所以,在之后用 binlog 来恢复的时候就多了一个事务出来,恢复出来的这一行 c 的值就是 1,与原库的值不同。

可以看到,如果不使用“两阶段提交”,那么数据库的状态就有可能和用它的日志恢复出来的库的状态不一致。

参考资料

MySQL 索引

索引是提高 MySQL 查询性能的一个重要途径,但过多的索引可能会导致过高的磁盘使用率以及过高的内存占用,从而影响应用程序的整体性能。应当尽量避免事后才想起添加索引,因为事后可能需要监控大量的 SQL 才能定位到问题所在,而且添加索引的时间肯定是远大于初始添加索引所需要的时间,可见索引的添加也是非常有技术含量的。

接下来将向你展示一系列创建高性能索引的策略,以及每条策略其背后的工作原理。但在此之前,先了解与索引相关的一些算法和数据结构,将有助于更好的理解后文的内容。

索引简介

“索引”是数据库为了提高查找效率的一种数据结构

日常生活中,我们可以通过检索目录,来快速定位书本中的内容。索引和数据表,就好比目录和书,想要高效查询数据表,索引至关重要。在数据量小且负载较低时,不恰当的索引对于性能的影响可能还不明显;但随着数据量逐渐增大,性能则会急剧下降。因此,设置合理的索引是数据库查询性能优化的最有效手段

索引的优缺点

B 树是最常见的索引,按照顺序存储数据,所以 MySQL 可以用来做 ORDER BYGROUP BY 操作。因为数据是有序的,所以 B 树也就会将相关的列值都存储在一起。最后,因为索引中存储了实际的列值,所以某些查询只使用索引就能够完成全部查询。

✔️️️️ 索引的优点:

  • 索引大大减少了服务器需要扫描的数据量,从而加快检索速度。
  • 索引可以帮助服务器避免排序和临时表
  • 索引可以将随机 I/O 变为顺序 I/O
  • 支持行级锁的数据库,如 InnoDB 会在访问行的时候加锁。使用索引可以减少访问的行数,从而减少锁的竞争,提高并发
  • 唯一索引可以确保每一行数据的唯一性,通过使用索引,可以在查询的过程中使用优化隐藏器,提高系统的性能。

❌ 索引的缺点:

  • 创建和维护索引要耗费时间,这会随着数据量的增加而增加。
  • 索引需要占用额外的物理空间,除了数据表占数据空间之外,每一个索引还要占一定的物理空间,如果要建立组合索引那么需要的空间就会更大。
  • 写操作(INSERT/UPDATE/DELETE)时很可能需要更新索引,导致数据库的写操作性能降低

基于以上,可以归纳出索引的基本使用规则:

  • 索引不是越多越好,不要为所有列都创建索引
  • 要尽量避免冗余和重复索引
  • 要考虑删除未使用的索引
  • 尽量的扩展索引,不要新建索引
  • 频繁作为 WHERE 过滤条件的列应该考虑添加索引

何时使用索引

✔️️️️ 什么情况适用索引?

  • 字段的数值有唯一性的限制,如用户名。
  • 频繁作为 WHERE 条件或 JOIN 条件的字段,尤其在数据表大的情况下
  • 频繁用于 GROUP BYORDER BY 的字段。将该字段作为索引,查询时就无需再排序了,因为 B+ 树
  • DISTINCT 字段需要创建索引

❌ 什么情况不适用索引?

  • 频繁写操作INSERT/UPDATE/DELETE ),也就意味着需要更新索引。
  • 很少作为 WHERE 条件或 JOIN 条件的字段,也就意味着索引会经常无法命中,没有意义,还增加空间开销。
  • 非常小的表,对于非常小的表,大部分情况下简单的全表扫描更高效。
  • 特大型的表,建立和使用索引的代价将随之增长。可以考虑使用分区技术或 Nosql。

索引的分类

MySQL 索引可以从以下四个维度来分类:

  • 按【数据结构】分类:B+tree索引、Hash索引、Full-text索引
  • 按【物理存储】分类:聚簇索引、二级索引(辅助索引)
  • 按【字段特性】分类:主键索引、普通索引、前缀索引
  • 按【字段个数】分类:单列索引、联合索引(复合索引、组合索引)

索引的数据结构

在 MySQL 中,索引是在存储引擎层而不是服务器层实现的,所以,并没有统一的索引标准。不同存储引擎的索引的数据结构也不相同。下面是 MySQL 常用存储引擎对一些主要索引数据结构的支持:

索引数据结构/存储引擎 InnoDB 引擎 MyISAM 引擎 Memory 引擎
B+Tree 索引 ✔️️️️️ ✔️️️️️ ✔️️️️️
Hash 索引 ✔️️️️️
Full Text 索引 ✔️️️️️ ✔️️️️️

下面,我们将逐一探讨各种可能作为索引的数据结构,了解其特性、利弊、应用场景。相信通过这样的对比,可以让读者更加明确 MySQL 中为什么选择某些数据结构作为索引,而放弃了另外一些数据结构,依据是什么。

有序数组

“数组”用连续的内存空间来存储数据,并且支持随机访问

有序数组可以使用二分查找法,其时间复杂度为 O(log n),无论是等值查询还是范围查询,都非常高效。

但有序数组有两个重要限制:

  • 数组的空间大小固定,如果要扩容只能采用复制数组的方式,比较低效。
  • 插入、删除操作开销较大,时间复杂度为 O(n) (要保证数组有序)。

这意味着,如果直接使用有序数组作为索引,为了保证数组有序,其更新操作代价高昂。正因为如此,几乎没有数据库会采用有序数组作为索引。

哈希索引

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

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

img

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

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

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

在 MySQL 中,只有 Memory 存储引擎显示支持哈希索引。

✔️️️️️ 哈希索引的优点

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

❌ 哈希索引的缺点

  • 只支持等值比较查询 - 包括 =IN()<=>
    • 不支持范围查询,如 WHERE price > 100
    • 不支持模糊查询,如 % 开头。
  • 无法用于排序 - 因为 Hash 索引指向的数据是无序的,因此无法起到排序优化的作用。
  • 不支持联合索引的最左侧原则 - 对于联合索引来说,Hash 索引在计算 Hash 值的时候是将索引键合并后再一起计算 Hash 值,所以不会针对每个索引单独计算 Hash 值。因此如果用到联合索引的一个或者几个索引时,联合索引无法被利用。例如:在数据列 (A,B) 上建立哈希索引,如果查询只有数据列 A,无法使用该索引。
  • 不能用索引中的值来避免读取行 - 因为哈希索引只包含哈希值和行指针,不存储字段,所以不能使用索引中的值来避免读取行。不过,访问内存中的行的速度很快,所以大部分情况下这一点对性能影响不大。
  • 哈希索引有可能出现哈希冲突
    • 出现哈希冲突时,必须遍历链表中所有的行指针,逐行比较,直到找到符合条件的行。
    • 如果哈希冲突多的话,维护索引的代价会很高。

提示:因为种种限制,所以哈希索引只适用于特定的场合。而一旦使用哈希索引,则它带来的性能提升会非常显著。

B+ 树索引

B 树是最常见的索引,按照顺序存储数据,所以 MySQL 可以用来做 ORDER BYGROUP BY 操作。因为数据是有序的,所以 B 树也就会将相关的列值都存储在一起。最后,因为索引中存储了实际的列值,所以某些查询只使用索引就能够完成全部查询。

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

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

二叉搜索树

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

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

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

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

B+ 树

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

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

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

img

聚簇索引和非聚簇索引

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

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

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

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

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

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

在创建表时,InnoDB 存储引擎会根据不同的场景选择不同的列作为索引:

  • 如果有主键,默认会使用主键作为聚簇索引的索引键(key);
  • 如果没有主键,就选择第一个不包含 NULL 值的唯一列作为聚簇索引的索引键(key);
  • 在上面两个都没有的情况下,InnoDB 将自动生成一个隐式自增 id 列作为聚簇索引的索引键(key);

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

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

由于没有其他索引,所以也就不用考虑其他索引的叶子节点大小的问题。

这时候我们就要优先考虑上一段提到的“尽量使用主键查询”原则,直接将这个索引设置为主键,可以避免每次查询需要搜索两棵树。

全文索引

MyISAM 存储引擎支持全文索引,用于查找文本中的关键词,而不是直接比较是否相等。查找条件使用 MATCH AGAINST,而不是普通的 WHERE。

全文索引一般使用倒排索引实现,它记录着关键词到其所在文档的映射。

InnoDB 存储引擎在 MySQL 5.6.4 版本中也开始支持全文索引。

全文索引主要用来查找文本中的关键字,而不是直接与索引中的值相比较。

全文索引跟其它索引大不相同,它更像是一个搜索引擎,而不是简单的 WHERE 语句的参数匹配。全文索引配合 match against 操作使用,而不是一般的 WHERE 语句加 LIKE。它可以在 CREATE TABLEALTER TABLECREATE INDEX 使用,不过目前只有 charvarchartext 列上可以创建全文索引。值得一提的是,在数据量较大时候,先将数据放入一个没有全局索引的表中,然后再用 CREATE INDEX 创建全文索引,要比先为一张表建立全文索引然后再将数据写入的速度快很多。

1
2
3
4
5
CREATE TABLE `table` (
`content` text CHARACTER NULL,
...
FULLTEXT (content)
)

空间数据索引

MyISAM 存储引擎支持空间数据索引(R-Tree),可以用于地理数据存储。空间数据索引会从所有维度来索引数据,可以有效地使用任意维度来进行组合查询。

必须使用 GIS 相关的函数来维护数据。

主键索引

主键索引:一种特殊的唯一索引,不允许有空值。一个表只能有一个主键(在 InnoDB 中本质上即聚簇索引),一般是在建表的时候同时创建主键索引。

1
2
3
4
5
CREATE TABLE `user` (
`id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
# ...
PRIMARY KEY (`id`)
);

唯一索引

“唯一索引”确保索引中的值是唯一的,不允许有重复值,如果是组合索引,则列值的组合必须唯一

在 MySQL 中,可以使用 CREATE UNIQUE INDEX 语句来创建唯一索引。

直接创建唯一索引:

1
CREATE UNIQUE INDEX `uniq_name` ON `user`(`name`);

创建表时,添加唯一索引示例:

1
2
3
4
5
6
CREATE TABLE `user` (
`id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(64) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_name`(`name`)
);

修改表时,添加唯一索引示例:

1
2
ALTER TABLE `user`
ADD UNIQUE `uniq_name`(`name`);

普通索引

普通索引是最基本的索引,没有任何限制。

直接创建索引:

1
CREATE INDEX `idx_name` ON `user`(`name`);

创建表时,添加索引示例:

1
2
3
4
5
6
CREATE TABLE `user` (
`id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(64) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
KEY `idx_name`(`name`)
);

修改表时,添加索引示例:

1
2
ALTER TABLE `user`
ADD INDEX `idx_name`(`name`);

前缀索引

有时候需要索引很长的字符列,这使得存储索引占用大量空间,且导致查询变慢。这种情况下,可以使用前缀索引。

“前缀索引”是指索引开始的部分字符。对于 BLOB/TEXT 这种文本类型的列,必须使用前缀索引,因为数据库往往不允许索引这些列的完整长度。

✔️️️️ 前缀索引的优点是:

  • 可以大大节约索引空间,从而提高索引效率

❌ 前缀索引的缺点是:

  • 会降低索引的区分度
  • 此外,**order by 无法使用前缀索引,无法把前缀索引用作覆盖索引**。

使用前缀索引,定义好长度,就可以做到既节省空间,又不用额外增加太多的查询成本。

直接创建前缀索引:

1
CREATE INDEX `idx_name` ON `user`(`name`(10));

创建表时,添加前缀索引示例:

1
2
3
4
5
6
CREATE TABLE `user` (
`id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(64) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
KEY `idx_name`(`name`(10))
);

修改表时,添加前缀索引示例:

1
2
ALTER TABLE `user`
ADD INDEX `idx_name`(`name`(10));

那么,如何确定前缀索引合适的长度呢?

可以使用下面这个语句,算出这个列上有多少个不同的值:

1
select count(distinct email) as L from SUser;

然后,依次选取不同长度的前缀来看这个值,比如我们要看一下 4~7 个字节的前缀索引,可以用这个语句:

1
2
3
4
5
6
select
count(distinct left(email,4))as L4,
count(distinct left(email,5))as L5,
count(distinct left(email,6))as L6,
count(distinct left(email,7))as L7,
from SUser;

当然,使用前缀索引很可能会损失区分度,所以你需要预先设定一个可以接受的损失比例,比如 5%。然后,在返回的 L4~L7 中,找出不小于 L * 95% 的值,假设这里 L6、L7 都满足,你就可以选择前缀长度为 6。

索引优化策略

索引基本原则

  • 索引不是越多越好,不要为所有列都创建索引。要考虑到索引的维护代价、空间占用和查询时回表的代价。索引一定是按需创建的,并且要尽可能确保足够轻量。一旦创建了多字段的联合索引,我们要考虑尽可能利用索引本身完成数据查询,减少回表的成本。
  • 尽量避免冗余和重复索引
  • 考虑删除未使用的索引
  • 尽量的扩展索引,不要新建索引

覆盖索引

覆盖索引是指:索引上的信息足够满足查询请求,不需要回表查询数据

【示例】范围查询

1
2
3
4
5
6
7
8
9
10
create table T (
ID int primary key,
k int NOT NULL DEFAULT 0,
s varchar(16) NOT NULL DEFAULT '',
index k(k))
engine=InnoDB;

insert into T values(100,1, 'aa'),(200,2,'bb'),(300,3,'cc'),(500,5,'ee'),(600,6,'ff'),(700,7,'gg');

select * from T where k between 3 and 5

需要执行几次树的搜索操作,会扫描多少行?

  1. 在 k 索引树上找到 k=3 的记录,取得 ID = 300;
  2. 再到 ID 索引树查到 ID=300 对应的 R3;
  3. 在 k 索引树取下一个值 k=5,取得 ID=500;
  4. 再回到 ID 索引树查到 ID=500 对应的 R4;
  5. 在 k 索引树取下一个值 k=6,不满足条件,循环结束。

在这个过程中,回到聚簇索引树搜索的过程,称为“回表”。可以看到,这个查询过程读了 k 索引树的 3 条记录(步骤 1、3 和 5),回表了两次(步骤 2 和 4)。

如果执行的语句是 select ID from T where k between 3 and 5,这时只需要查 ID 的值,而 ID 的值已经在 k 索引树上了,因此可以直接提供查询结果,不需要回表。索引包含所有需要查询的字段的值,称为覆盖索引。

由于覆盖索引可以减少树的搜索次数,显著提升查询性能,所以使用覆盖索引是一个常用的性能优化手段

最左匹配原则

不只是索引的全部定义,只要满足最左前缀,就可以利用索引来加速检索。这里的最左,可以是联合索引的最左 N 个字段,也可以是字符串索引的最左 M 个字符

如果是联合索引,那么 key 也由多个列组成,同时,索引只能用于查找 key 是否存在(相等),遇到范围查询 (><BETWEENLIKE) 就不能进一步匹配了,后续退化为线性查找。因此,列的排列顺序决定了可命中索引的列数

应该将选择性高的列或基数大的列优先排在多列索引最前列。但有时,也需要考虑 WHERE 子句中的排序、分组和范围条件等因素,这些因素也会对查询性能造成较大影响。

“索引的选择性”是指不重复的索引值和记录总数的比值,最大值为 1,此时每个记录都有唯一的索引与其对应。索引的选择性越高,查询效率越高。如果存在多条命中前缀索引的情况,就需要依次扫描,直到最终找到正确记录。

例如下面显示的结果中 customer_id 的选择性比 staff_id 更高,因此最好把 customer_id 列放在多列索引的前面。

1
2
3
4
SELECT COUNT(DISTINCT staff_id)/COUNT(*) AS staff_id_selectivity,
COUNT(DISTINCT customer_id)/COUNT(*) AS customer_id_selectivity,
COUNT(*)
FROM payment;
1
2
3
   staff_id_selectivity: 0.0001
customer_id_selectivity: 0.0373
COUNT(*): 16049

使用索引来排序

MySQL 有两种方式可以生成排序结果:通过排序操作;或者按索引顺序扫描。

索引最好既满足排序,又用于查找行。这样,就可以通过命中覆盖索引直接将结果查出来,也就不再需要排序了。

这样整个查询语句的执行流程就变成了:

  1. 从索引 (city,name,age) 找到第一个满足 city=’杭州’条件的记录,取出其中的 city、name 和 age 这三个字段的值,作为结果集的一部分直接返回;
  2. 从索引 (city,name,age) 取下一个记录,同样取出这三个字段的值,作为结果集的一部分直接返回;
  3. 重复执行步骤 2,直到查到第 1000 条记录,或者是不满足 city=’杭州’条件时循环结束。

= 和 in 可以乱序

不需要考虑 =IN 等的顺序,MySQL 会自动优化这些条件的顺序,以匹配尽可能多的索引列。

【示例】如有索引 (a, b, c, d),查询条件 c > 3 and b = 2 and a = 1 and d < 4a = 1 and c > 3 and b = 2 and d < 4 等顺序都是可以的,MySQL 会自动优化为 a = 1 and b = 2 and c > 3 and d < 4,依次命中 a、b、c、d。

索引失效的场景

创建了索引,并非一定有效。比如不满足前缀索引、最左前缀匹配原则、查询条件涉及函数计算等情况都无法使用索引。此外,即使 SQL 本身符合索引的使用条件,MySQL 也会通过评估各种查询方式的代价,来决定是否走索引,以及走哪个索引。

对索引使用左模糊匹配

使用左或者左右模糊匹配的时候,也就是 like %xx 或者 like %xx% 这两种方式都会造成索引失效。这是因为:B+ 树索引是按照“索引值”有序存储的,只能根据前缀进行比较。

对索引使用函数或表达式

查询语句中,如果对索引字段使用“函数”或“表达式”,会导致索引失效

因为索引树存储的是索引字段的原始值,因此无法索引经过函数计算或表达式计算后的值。

❌ 错误示例:

1
2
SELECT actor_id FROM actor WHERE actor_id + 1 = 5;
SELECT ... WHERE TO_DAYS(current_date) - TO_DAYS(date_col) <= 10;

对索引隐式类型转换

查询语句中,如果对索引字段进行隐式类型转换,会导致索引失效。由于隐式类型转换是通过 CAST 函数实现的,等同于对索引列使用了函数,所以会导致索引失效。

联合索引不遵循最左匹配原则

联合索引如果不遵循最左匹配原则,就会导致索引失效。原因是,在联合索引的情况下,数据是按照索引第一列排序,第一列数据相同时才会按照第二列排序。

索引列判空

索引列与 NULL 或者 NOT NULL 进行判断的时候也会失效。这是因为索引并不存储空值,所以最好在设计数据表的时候就将字段设置为 NOT NULL 约束,比如你可以将 INT 类型的字段,默认值设置为 0。将字符类型的默认值设置为空字符串 (’’)。

WHERE 子句中的 OR 前后条件存在非索引列

在 WHERE 子句中,如果在 OR 前的条件列是索引列,而在 OR 后的条件列不是索引列,那么索引会失效。

比如下面的 SQL 语句,comment_id 是主键,而 comment_text 没有进行索引,因为 OR 的含义就是两个只要满足一个即可,因此只有一个条件列进行了索引是没有意义的,只要有条件列没有进行索引,就会进行全表扫描,因此索引的条件列也会失效:

1
EXPLAIN SELECT comment_id, user_id, comment_text FROM product_comment WHERE comment_id = 900001 OR comment_text = '462eed7ac6e791292a79'

运行结果:

1
2
3
4
5
+----+-------------+-----------------+------------+------+---------------+------+---------+------+--------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-----------------+------------+------+---------------+------+---------+------+--------+----------+-------------+
| 1 | SIMPLE | product_comment | NULL | ALL | PRIMARY | NULL | NULL | NULL | 996663 | 10.00 | Using where |
+----+-------------+-----------------+------------+------+---------------+------+---------+------+--------+----------+-------------+

如果我们把 comment_text 创建了索引会是怎样的呢?

1
2
3
4
5
+----+-------------+-----------------+------------+-------------+----------------------+----------------------+---------+------+------+----------+------------------------------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-----------------+------------+-------------+----------------------+----------------------+---------+------+------+----------+------------------------------------------------+
| 1 | SIMPLE | product_comment | NULL | index_merge | PRIMARY,comment_text | PRIMARY,comment_text | 4,767 | NULL | 2 | 100.00 | Using union(PRIMARY,comment_text); Using where |
+----+-------------+-----------------+------------+-------------+----------------------+----------------------+---------+------+------+----------+------------------------------------------------+

你能看到这里使用到了 index merge,简单来说 index merge 就是对 comment_id 和 comment_text 分别进行了扫描,然后将这两个结果集进行了合并。这样做的好处就是避免了全表扫描。

参考资料