AWS 服务介绍

Amazon EC2

Amazon Elastic Compute Cloud(Amazon EC2)提供最广泛、最深入的计算平台,拥有超过 500 个实例,可选择最新的处理器、存储、网络、操作系统和购买模型,以帮助您最好地满足工作负载的需求。我们是首家支持英特尔、AMD 和 Arm 处理器的主要云提供商,既是唯一具有按需 EC2 Mac 实例的云,也是唯一具有 400 Gbps 以太网网络的云。我们为机器学习培训提供最佳性价比,同时也为云中的每个推理实例提供了最低的成本。与任何其他云相比,有更多的 SAP、高性能计算 (HPC)、机器学习 (ML) 和 Windows 工作负载在 AWS 上运行。

Amazon S3

Amazon Simple Storage Service (Amazon S3) 是一种对象存储服务,提供行业领先的可扩展性、数据可用性、安全性和性能。各种规模和行业的客户可以为几乎任何使用案例存储和保护任意数量的数据,例如数据湖、云原生应用程序和移动应用程序。借助高成本效益的存储类和易于使用的管理功能,您可以优化成本、组织数据并配置精细调整过的访问控制,从而满足特定的业务、组织和合规性要求。

Amazon Aurora

Amazon Aurora 在全球范围内提供无与伦比的高性能和可用性,完全兼容 MySQL 和 PostgreSQL,而成本仅为商业数据库的十分之一。Aurora 的吞吐量是 MySQL 的 5 倍,是 PostgreSQL 的 3 倍。Aurora 拥有广泛的合规性标准和一流的安全功能。Aurora 通过使数据在 3 个可用区内持久耐用(客户只需支付 1 个副本的费用)来提供存储弹性。Aurora 的可用性高达 99.99%,跨 AWS 区域部署时,客户可以使用全球数据库访问本地读取性能。使用无服务器功能,Aurora 可在不到一秒钟的时间内扩展到能够处理数十万个事务的能力。Aurora 与 Amazon Redshift 的零 ETL 集成可近乎实时地对事务数据进行分析。

Amazon DynamoDB

Amazon DynamoDB 是一项无服务器的 NoSQL、完全托管的数据库,在任何规模下均具有个位数毫秒级的性能,您可以通过它来开发任何规模的现代应用程序。作为无服务器数据库,您只需按使用量为其付费,DynamoDB 可以扩展到零,没有冷启动,没有版本升级,没有维护窗口,没有修补,也没有停机维护。DynamoDB 提供一系列广泛的安全控制措施和合规性标准。对于全球分布式应用程序,DynamoDB 全局表是一个多区域、多活动数据库,具有 99.999% 的可用性 SLA 和更高的弹性。托管备份、时间点恢复等功能有助于确保 DynamoDB 的可靠性。借助 DynamoDB 流,您可以构建无服务器的事件驱动型应用程序。

Amazon RDS

Amazon Relational Database Service(Amazon RDS)是一个托管式服务的集合,可以简化在云中设置、运营和扩展数据库的过程。提供八种热门引擎以供选择:Amazon Aurora PostgreSQL 兼容版、Amazon Aurora MySQL 兼容版、RDS for PostgreSQL、RDS for MySQL、RDS for MariaDB、RDS for SQL Server、RDS for Oracle 和 RDS for Db2。 使用 Amazon RDS 在 AWS Outposts 上进行本地部署,或者使用 Amazon RDS Custom 提高对底层操作系统和数据库环境的访问权限。

AWS Lambda

AWS Lambda 是一项无服务器事件驱动型计算服务,该服务使您可以运行几乎任何类型的应用程序或后端服务的代码,而无需预置或管理服务器。您可以从 200 多个 AWS 服务和软件即服务 (SaaS) 应用程序中触发 Lambda,且只需按您的使用量付费。

Amazon VPC

Amazon Virtual Private Cloud (Amazon VPC) 让您能够全面地控制自己的虚拟网络环境,包括资源放置、连接性和安全性。首先在 AWS 服务控制台中设置 VPC。然后,向其中添加资源,例如 Amazon Elastic Compute Cloud (EC2) 和 Amazon Relational Database Service (RDS) 实例。最后,您可以定义 VPC 相互之间以及跨账户、可用区或 AWS 区域通信的方式。

Amazon Lightsail

Amazon Lightsail 以经济实惠的月度价格提供易于使用的虚拟专用服务器 (VPS) 实例、容器、存储、数据库等。虚拟专用服务器,价格低廉且可预测。只需几次点击即可创建网站或应用程序。自动配置联网、访问和安全环境。随着您的发展轻松扩展,或将您的资源迁移到更广泛的 AWS 生态系统,例如 Amazon EC2。

使用案例

  • 启动简单的 Web 应用程序。使用预配置的开发堆栈,如 LAMP、Nginx、MEAN 和 Node.js,以快速轻松地上网。
  • 创建自定义网站。使用预配置的应用程序,如 WordPress、Magento、Prestashop 和 Joomla,只需几次点击,就可以构建和个性化您的博客、电子商务或个人网站。
  • 构建小型业务应用程序。启动业务线软件,如文件存储和共享、备份、财务和会计软件等等。
  • 启动测试环境。轻松创建和删除开发沙箱和测试环境,您可以在其中无风险地尝试新想法。

Amazon SageMaker

Amazon SageMaker 通过完全托管的基础设施、工具和工作流程为任何用例构建、训练和部署机器学习(ML)模型。Amazon SageMaker 是一项完全托管的服务,它汇集了大量工具,可为任何使用案例提供高性能、低成本的机器学习(ML)。借助 SageMaker,您可以使用笔记本、调试器、分析器、管道、MLOps 等工具大规模构建、训练和部署机器学习模型——这一切都在一个集成式开发环境(IDE)中完成。SageMaker 通过简化的访问控制和机器学习项目的透明度来支持治理要求。此外,您可以使用专门构建的工具来微调、实验、再训练和部署基础模型,构建自己的基础模型(在海量数据集上训练过的大型模型)。 SageMaker 提供对数百个预训练模型的访问权限,包括公开的基础模型,您只需点击几下即可部署这些模型。

git-svn

git-svn

svngit 都是常用的版本管理软件,但是git无论在理念或是功能上都比svn更为先进。
但是有的公司是以svn作为中央仓库,这时git与svn代码的同步就可以通过 git-svn这个软件进行,从而用git管理svn代码。
最后的效果相当于把svn仓库当作git的一个remote(远程仓库),而你本地的代码都是通过git来管理,只有push到svn时才会把你本地的commit同步到svn。

详细说明参考:git-svn:通过git来管理svn代码,但是我们还是推荐完成从svn迁移到git。

从svn克隆

下面以将乐短信服务仓库为例:

1
git svn clone https://140.143.33.11/svn/ykhl/代码/平台/将乐/sms/MobileServer --no-metadata MobileServer

关联git远程仓库

先在git仓库创建一个mobileserver的仓库

1
2
3
cd MobileServer
git remote add origin git@192.168.0.99:lijun/mobileserver.git
git remote –v

提交记录至git

1
git push -u origin master

一文读懂Vitess

概述

什么是Vitess

Vitess是一个为部署、扩展、管理大型集群的开源数据库解决方案。当前支持MySQL和MariaDB。在专用硬件上为高效的运行公有、私有云架构而设计。Vitess结合并扩展了许多重要的SQL功能和NoSQL数据库的可扩展性。Vitess可以解决下面的问题:

  1. 通过对SQL数据库进行分片来扩展SQ 数据库,同时将应用程序更改保持在最低限度。
  2. 支持从裸机部署到共有或私有云。
  3. 支持部署和管理大量的SQL实例。

Vitess支持JDBC和Go数据库驱动使用native查询协议。此外,它实现了几乎与任何其他语言兼容的MySQL server协议。

Vitess在YouTube使用超过5年,许多企业也考虑在生产环境Vitess。

特性

性能

  • Connection pooling: 将前端应用程序查询多路复用到 MySQL 连接池以优化性能。
  • Query de-duping: 对于重复的查询请求,重用结果。
  • Transaction Manager: 限制并发事务的数量并管理超时,优化整体吞吐量。

保护

  • Query rewriting and sanitization: 添加限制并避免非确定性更新。
  • Query blacklisting: 自定义规则,防止有问题的查询攻击数据库
  • Query killer: 中断长时间的查询
  • Table ACLs: 对数据表指定访问控制列表

监控

  • 性能分析工具帮助你监控、诊断、分析数据库性能

拓扑管理工具

  • Master管理工具
  • 基于Web的GUI
  • 支持管理多数据中心

分片

  • 几乎无缝的动态重新分片
  • 支持水平、垂直分片
  • 多种分片方案,支持自定义实现

架构

Vitess平台由许多服务器进程、命令行工具和Web工具组成,并由一致的元数据存储提供支持。

通过一系列流程可以实现一个完整的Vitess。比如,如果要从头开始构建服务,那么使用 Vitess 的第一步就是定义数据库拓扑。但是,如果需要扩展现有数据库,可能会从部署连接代理开始。

Vitess的工具或服务可以提供帮助,无论是从一开始快速开始就创建一个完整的数据或是一步一步从小处着手。往小处讲,vttablet的连接池和查询重新特性可以让你体验到它的好处;往大处说,Vitess的自动化工具可以提供快速创建一个数据库。

下面的图展示了Vitess的组件:
Vitess Runtime

支持的数据库

Vitess当前支持MySQL、Percona和MariaDB数据库。

MySQL 5.6-8.0

Vitess支持MySQL 5.6-8.0的关键特性,但是也有一些限制。Vitess同样支持Percona Server for MySQL 5.6-8.0版本。MySQL 5.6在2021年2月不再维护,建议使用MySQL 5.7及以上版本。

MariaDB 10.0-10.3

Vitess支持MariaDB 10.0-10.3的关键特性,暂不支持10.4版本。

可扩展性理念

许多方式都可以解决扩展性问题,下面看看Vitess是怎么做的:

小实例

一般数据库要分片,自然想到的就是适合一台物理机的大小的分片。通常做法是一台物理机一个实例。

Vitess 建议将实例分解为可管理的块(每个 MySQL 服务器 250GB),并且不回避在每个主机上运行多个实例。净资源使用量将大致相同。但是当 MySQL 实例较小时,可管理性大大提高。跟踪端口和分离 MySQL 实例的路径很复杂。然而,一旦跨越了这个障碍,其他一切都会变得更简单。

需要担心的是会有锁竞争,复制会更频繁;但中断对生产的影响变得更小,备份和恢复运行得更快,并且可以实现更多次要优势。例如,您可以对实例进行混洗以获得更好的机器或机架多样性,从而减少对生产中断的影响,并提高资源使用率。

通过复制获得持久性

传统数据存储软件在数据刷新到磁盘后立即将其视为持久数据。然而,这种方法在当今的商品硬件世界中是不切实际的。这种方法也不能解决灾难场景。

新的持久性方法是通过将数据复制到多台机器甚至地理位置来实现的。这种形式的持久性解决了设备故障和灾难的现代问题。

Vitess 中的许多工作流都是用这种方法构建的。例如,强烈建议开启半同步复制。这允许 Vitess 在 master 宕机时故障转移到新副本,而不会丢失数据。 Vitess 还建议避免恢复崩溃的数据库。而是从最近的备份中创建一个新的并让它赶上。

依靠复制还允许您放宽一些基于磁盘的持久性设置。例如,您可以关闭sync_binlog,这将大大减少对磁盘的IOPS数,从而提高有效吞吐量。

一致性模型

在将表分片或移动到不同的键空间之前,需要验证(或更改)应用程序,以便它可以容忍以下更改:

  • 跨分片读取可能彼此不一致。相反,分片决策也应尽量减少此类事件的发生,因为跨分片读取的成本更高。
  • best-effort mode模式下,跨分片事务可能会在中间失败并导致部分提交。您可以改为使用2PC mode事务,提供分布式原子保证。但是,选择此选项会使写入成本增加约 50%。

单个分片事务仍然保持 ACID。

如果可以容忍轻微陈旧数据的只读情况,则应将查询发送到用于 OLTP 的 REPLICA 片,以及用于 OLAP 工作负载的 RDONLY 片。这可以更轻松地扩展读流量,并使您能够在地理上分布它们。

这种权衡允许以过时或可能不一致的读取为代价获得更好的吞吐量,因为随着数据的变化(并且可能在不同分片上具有不同的延迟),读取可能落后于 MASTER 服务器。为了缓解这种情况,VTGate 服务器能够监控副本滞后,并且可以配置为避免副本滞后超过 X 秒的数据。

对于真正的快照,必须在事务中将查询发送到 master。对于read-after-write一致性,在没有事务的情况下从 master 读取就足够了。

总而言之,支持的各种等级的一致性:

  • __REPLICA/RDONLY read__:服务器可以在地理上扩展。本地读取速度很快,但可能会因副本滞后而过时。
  • __MASTER read__:每个分片只有一个Master。来自远程位置的读取将受到网络延迟和可靠性的影响,但数据将是最新的(read-after-write一致性)。隔离级别为 READ_COMMITTED。
  • __MASTER transactions__:它们表现出与MASTER read相同的属性。但是,对于单个分片,您可以获得 REPEATABLE_READ 一致性和 ACID 写入。对跨分片原子交易的支持正在进行中。

至于原子性,支持以下级别:

  • __SINGLE__:禁止多数据库事务。
  • __MULTI__:尽最而为的多数据库事务。
  • __TWOPC__:具有 2PC 提交的多数据库事务。

不支持多主(Master)

Vitess 不支持多主配置。它具有解决大多数通常由多主解决的用例的替代方法:

  • 可扩展性:在某些情况下,多Master会为您提供一些额外的运行方式。但是,由于这些语句最终必须应用于所有Master,因此这不是一个可持续的策略。 Vitess 通过分片解决了这个问题,分片可以无限扩展。
  • 高可用性:Vitess 与 Orchestrator 集成,能够在检测到故障后几秒钟内执行故障转移到新主服务器。这对于大多数应用程序来说通常是足够的。
  • 低延迟地理分布式写入:这是 Vitess 未解决的一种情况。当前的建议是避免长距离往返的延迟成本的写入。如果数据分布允许,您仍然可以选择基于地理亲和力进行分片。然后,您可以为不同的分片设置主节点,使其位于不同的地理位置。这样,大多数 master 写入仍然可以是本地的。

多cell

Vitess 旨在在多个数据中心/区域/单元中运行。在这里,我们将使用Cell来表示一组非常接近的服务器,并共享相同的区域可用性。

一个 cell 通常包含一组 tablet、一个 vtgate 池和使用 Vitess 集群的应用服务器。使用 Vitess 可以根据需要配置和启动所有组件:

  • 分片的主节点可以在任何 cell 中。如果需要cross-cell master 访问,可以通过 vtgate 轻松配置(通过将包含 master 的 cell 作为要监视的单元传递)。
  • master cell比read-only cell配置得更多的情况也不少见。这些具有master-capable的单元可能需要一个更多的副本来处理可能的故障转移,同时仍保持相同的副本服务容量。
  • 从一个cell中的主节点故障转移到不同cell中的主节点与本地故障转移没有区别。它对流量和延迟有影响,但如果应用流量也被重定向到新cell,最终结果是稳定的。
  • 也可以有一些分片与master在一个cell中,而其他一些分片与其他master在另一个cell中。vtgate 只会将流量路由到正确的位置,仅在远程访问时会产生额外的延迟成本。例如,在拥有美国的master数据库中创建美国用户记录,在欧洲master的数据库中创建欧洲用户记录很容易做到。副本无论如何都可以存在于每个cell中,并快速为副本流量提供服务。
  • 副本服务单元是减少用户可见延迟的一个很好的折衷方案:它们只包含副本服务器,并且master访问始终是远程完成的。如果应用程序的主要场景是读取,这非常有效。
  • 并非所有cell都需要 rdonly(或批处理)实例。只有运行批处理作业或 OLAP 作业的cell才真正需要。

注意 Vitess 首先使用本地cell数据,并且对于任何cell宕机都非常有弹性,Vitess的大多数进程都会优雅地处理这种情况。

Cloud Native

Vitess 非常适合云部署,因为它使数据库能够逐步增加容量。 运行 Vitess 的最简单方法是通过 Kubernetes。

Kubernetes 可以使用 Docker 容器编排系统,Vitess 可以感知 Kubernetes 云原生环境运行分布式数据库。

Kubernetes 处理计算集群中节点的调度,主动管理这些节点上的工作负载,并将包含应用程序的容器分组以便于管理和发现。这为 Vitess 在 YouTube 中运行的方式提供了一个类似的开源环境,这也是 Kubernetes 的前身。

历史

Vitess 创建于 2010 年,旨在解决 YouTube 团队面临的 MySQL 可扩展性挑战。本节简要总结了 Vitess 发展的一系列事件:

  1. YouTube 的 MySQL 数据库达到了高峰流量并将很快超过数据库服务能力的地步。为了暂时缓解这个问题,YouTube 创建了一个用于写入流量的主数据库和一个用于读取流量的副本数据库。
  2. 由于对视频的需求空前高涨,只读流量仍然使副本数据库过载。所以 YouTube 增加了更多的副本,再次提供了一个临时解决方案。
  3. 最终,写入流量变得太高,主数据库无法处理,需要 YouTube 对数据进行分片来处理传入流量。顺便说一句,如果数据库的整体大小对于单个 MySQL 实例来说变得太大,分片也将变得必要。
  4. YouTube 的应用层经过修改,以便在执行任何数据库操作之前,代码可以识别正确的数据库分片以接收特定查询。

Vitess 让 YouTube 从源代码中删除了该逻辑,在应用程序和数据库之间引入了一个代理来路由和管理数据库交互。从那时起,YouTube 将其用户群扩大了 50 多倍,大大提高了其访问页面、处理新上传视频等的能力。更重要的是,Vitess 是一个不断扩展的平台。

CNCF 是许多快速增长的开源项目的中立供应商。2018 年 2 月,技术监督委员会 (TOC) 投票接受 Vitess 作为 CNCF 孵化项目。 Vitess 成为 2019 年 11 月毕业的第八个 CNCF 项目,加入了 Kubernetes、Prometheus、Envoy、CoreDNS、containerd、Fluentd 和 Jaeger的 CNCF。

开始

通过Docker本地安装

本指南说明了如何通过 Docker 运行本地 Vitess 测试环境。 Vitess 环境与本地安装相同,除了 Docker 无需安装其他软件。

检出仓库

  • SSH:git clone git@github.com:vitessio/vitess.git,或者:

  • HTTP:git clone https://github.com/vitessio/vitess.git

    cd vitess

构建docker镜像

make docker_local

将创建一个名为vitess/local的docker镜像(vitess/local:lateest)

运行docker镜像

./docker/local/run.sh

这一步将安装 MySQL replication拓扑,以及 etcdvtctldvtgate 服务。

为方便起见,在 docker shell 中设置了别名。尝试使用以下 mysql 命令连接到各种 tablets:

  • mysql commerce
  • mysql commerce@master
  • mysql commerce@replica
  • mysql commerce@rdonly

你会发现 Vitess 运行在一个单keyspace、单分片的集群。

总结

在这个例子中,我们部署了一个名为 commerce 的未分片的keyspace。未分片的keyspace有一个名为 0 的分片。以下schema反映了由脚本创建的常见电子商务场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
create table product (
sku varbinary(128),
description varbinary(128),
price bigint,
primary key(sku)
);
create table customer (
customer_id bigint not null auto_increment,
email varbinary(128),
primary key(customer_id)
);
create table corder (
order_id bigint not null auto_increment,
customer_id bigint,
sku varbinary(128),
price bigint,
primary key(order_id)
);

上面的schema仅仅列出比较重要的属性:

  • product 表包含所有产品的产品信息。
  • customer 表有一个 auto_increment 的customer_id列,一个典型的customer表包含很多列,甚至还包含扩展表。
  • corder 表(应该命名为order,因为和SQL关键字冲突)有一个 auto_increment 的order_id列,同时有两个外键customer(customer_id)product(sku)

本地安装

通过Homebrew本地安装

Vitess Operator for Kubernetes

PlanetScale 为 Kubernetes 提供了一个 Vitess Operator,在 Apache 2.0 许可下发布。 以下步骤显示了如何开始使用 Minikube 安装:

前置条件

在开始之前,需要准备 Kubernetes环境:

  1. 安装 Minikube 并启动 Minikube,推荐使用1.14版本,方便跨公有云:

    minikube start –kubernetes-version=v1.14.10 –cpus=8 –memory=11000 –disk-size=50g

如果你没有一台有 11GB 内存的机器,也可以考虑使用 GKE 来代替。可以使用以下命令从 Cloud Shell 部署等效设置:

gcloud container clusters create vitess --cluster-version 1.14 --zone us-east1-b --num-nodes 5
  1. 安装 kubectl 并确保在PATH下。比如,Linux下:

    curl -LO https://storage.googleapis.com/kubernetes-release/release/v1.14.9/bin/linux/amd64/kubectl

  2. 本地安装 MySQL 客户端。比如,Ubuntu下:

    apt install mysql-client

  3. 本地安装 vtctlclient :

如果熟悉 GO 开发环境,最简单的方式是:

go get vitess.io/vitess/go/cmd/vtctlclient

如果没有,可以下载最新的 Vitess 版本并从中提取 vtctlclient。

安装 Operator

切换到operator example目录下:

git clone git@github.com:vitessio/vitess.git
cd vitess/examples/operator

安装operator:

kubectl apply -f operator.yaml

初始化集群

在此目录中,您将看到一组 yaml 文件。每个文件名的第一位数字表示示例的阶段。接下来的两位数字表示执行它们的顺序。例如,101_initial_cluster.yaml 是第一阶段的第一个文件。现在将执行:

kubectl apply -f 101_initial_cluster.yaml

我们提供了一个示例 yaml,用于使用实验性 vtorc 组件启动 Vitess。您可以使用以下命令进行尝试:kubectl apply -f vtorc_example.yaml。一旦 vtorc 正式发布,示例将相应更新。

验证集群

可以使用 kubectl get pods 检查集群的状态。几分钟后,它应该显示所有 Pod 都处于运行状态:

$ kubectl get pods
NAME                                             READY   STATUS    RESTARTS   AGE
example-etcd-faf13de3-1                          1/1     Running   0          78s
example-etcd-faf13de3-2                          1/1     Running   0          78s
example-etcd-faf13de3-3                          1/1     Running   0          78s
example-vttablet-zone1-2469782763-bfadd780       3/3     Running   1          78s
example-vttablet-zone1-2548885007-46a852d0       3/3     Running   1          78s
example-zone1-vtctld-1d4dcad0-59d8498459-kwz6b   1/1     Running   2          78s
example-zone1-vtgate-bc6cde92-6bd99c6888-vwcj5   1/1     Running   2          78s
vitess-operator-8454d86687-4wfnc                 1/1     Running   0          2m29s

设置 Port-forward

port-forward 只会转发到特定的 pod。目前,由于应用/升级操作导致 pod 消失,kubectl 不会自动终止端口转发。需要手动重新配置端口转发。

为了方便使用,Vitess 提供了一个脚本来将 Kubernetes 端口转发到您的本地机器。此脚本还建议为 mysqlvtctlclient 设置别名:

./pf.sh &
alias vtctlclient="vtctlclient -server=localhost:15999"
alias mysql="mysql -h 127.0.0.1 -P 15306 -u user"

设置别名将 mysql 更改为始终连接到 Vitess 以进行当前会话。要还原这个,输入 unalias mysql && unalias vtctlclient 或关闭会话。

创建 Schema

载入初始化schema:

vtctlclient ApplySchema -sql="$(cat create_commerce_schema.sql)" commerce
vtctlclient ApplyVSchema -vschema="$(cat vschema_commerce_initial.json)" commerce

连接到集群

现在应该能够使用 MySQL 客户端连接到集群中的 VTGate 服务器:

~/vitess/examples/operator$ mysql
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 3
Server version: 5.7.9-Vitess MySQL Community Server (GPL)

Copyright (c) 2000, 2020, Oracle and/or its affiliates. All rights reserved.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql> show databases;
+-----------+
| Databases |
+-----------+
| commerce  |
+-----------+
1 row in set (0.00 sec)

总结

在这个例子中,我们部署了一个名为 commerce 的未分片的keyspace。未分片的keyspace有一个名为 0 的分片。以下schema反映了由脚本创建的常见电子商务场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
create table product (
sku varbinary(128),
description varbinary(128),
price bigint,
primary key(sku)
);
create table customer (
customer_id bigint not null auto_increment,
email varbinary(128),
primary key(customer_id)
);
create table corder (
order_id bigint not null auto_increment,
customer_id bigint,
sku varbinary(128),
price bigint,
primary key(order_id)
);

上面的schema仅仅列出比较重要的属性:

  • product 表包含所有产品的产品信息。
  • customer 表有一个 auto_increment 的customer_id列,一个典型的customer表包含很多列,甚至还包含扩展表。
  • corder 表(应该命名为order,因为和SQL关键字冲突)有一个 auto_increment 的order_id列,同时有两个外键customer(customer_id)product(sku)

删除集群

kubectl delete -f 101_initial_cluster.yaml

Vttestserver Docker镜像

本指南涵盖使用 vttestserver docker 映像进行测试。这也是我们在 Vitess Framewok Testing 中用来测试的 docker 镜像。

获取docker镜像

第一步是获取docker镜像,有两种方式获取:

  1. 从vitessio/vitess仓库获取

检出仓库

  • SSH:git clone git@github.com:vitessio/vitess.git,或者:

  • HTTP:git clone https://github.com/vitessio/vitess.git

    cd vitess

构建docker镜像

make docker_vttestserver

这将创建 2 个名为 vitess/vttestserver:mysql57vitess/vttestserver:mysql80 的 docker 镜像。

  1. 从docker hub拉取
    或者,您可以从 docker hub 获取最新的 docker 镜像。在 shell 中,执行:

    docker pull vitess/vttestserver:mysql57
    docker pull vitess/vttestserver:mysql80

运行docker镜像

此时,您应该有一个名为 vitess/vttestserver:mysql57vitess/vttestserver:mysql80 的 docker 镜像。

环境变量

docker 镜像需要设置一些环境变量才能正常运行。下表列出了所有可用的环境变量及其用法。

Environment variable Required Use
KEYSPACES yes Specifies the names of the keyspaces to be created as a comma separated value.
NUM_SHARDS yes Specifies the number of shards in each keyspace. It is a comma separated value as well, read in conjunction with the KEYSPACES.
PORT yes The starting of the port addresses that vitess will use to register its components like vtgate, etc.
MYSQL_MAX_CONNECTIONS no Maximum number of connections that the MySQL instance will support. If unspecified, it defaults to 1000.
MYSQL_BIND_HOST no Which host to bind the MySQL listener to. If unspecified, it defaults to 127.0.0.1.
MYSQL_SERVER_VERSION no MySQL server version to advertise. If unspecified, it defaults to 8.0.21-vitess or 5.7.9-vitess according to the version of vttestserver run.
CHARSET no Default charset to use. If unspecified, it defaults to utf8mb4.
FOREIGN_KEY_MODE no This is to provide how to handle foreign key constraint in create/alter table. Valid values are: allow (default), disallow.
ENABLE_ONLINE_DDL no Allow users to submit, review and control Online DDL. Valid values are: true (default), false.
ENABLE_DIRECT_DDL no Allow users to submit direct DDL statements. Valid values are: true (default), false.

在docker环境变量可以通过 -e--env 指定。

从外面发生请求给 vttestserver

vtgate 在3个以上的 PORT 环境变量上侦听 MySQL 连接。即,如果您将 PORT 指定为 33574,则 vtgate 将在 33577 上监听连接,主机地址 MYSQL_BIND_HOST 默认为 localhost,但是这个端口将在 docker 容器端。要从 MySQL 客户端外部连接到 vtgate,还需要发布该端口并将 MYSQL_BIND_HOST 指定为 0.0.0.0,这可以通过 docker 的 -p--publish 标志来完成。例如:将 -p 33577:33577 添加到 docker run 命令会将容器的 33577 端口发布到本地的 33577 端口,就可用于连接到 vtgate。

示例

运行 docker 镜像的示例命令如下:

docker run --name=vttestserver -p 33577:33577 -e PORT=33574 -e KEYSPACES=test,unsharded -e NUM_SHARDS=2,1 -e MYSQL_MAX_CONNECTIONS=70000 -e MYSQL_BIND_HOST=0.0.0.0 --health-cmd="mysqladmin ping -h127.0.0.1 -P33577" --health-interval=5s --health-timeout=2s --health-retries=5 vitess/vttestserver:mysql57

现在,可以从 MySQL 客户端连接到 vtgate,如下所示:

mysql --host 127.0.0.1 --port 33577 --user "root"

这里有 2 个可以使用的键空间,test 有2个分片,unsharded 有1个分片。

概念

Cell

数据中心、可用区或计算资源组

cell是一组服务器和网络基础设施并置在一个区域中,并与其他单元中的故障隔离。它通常是完整的数据中心或数据中心的子集,有时称为区域或可用性区域。 Vitess 可以优雅地处理cell-level故障,例如当一个cell断网时。

Vitess 实现中的每个单元都有一个本地拓扑服务,该服务托管在该单元中。拓扑服务在其单元中包含有关 Vitess tablet的大部分信息。这使得一个单元能够被删除或重建。

Vitess 限制数据和元数据的跨单元访问。虽然具有将读取流量路由到单个单元格的能力可能很有用,但 Vitess 目前仅支持来自本地单元格的读取流量。必要时,写入将跨单元进行,写入该分片的主节点所在的任何位置。

Execution Plans

Vitess 在 VTGate 和 VTablet 层解析查询,以评估执行查询的最佳方法。这种评估称为查询计划,并产生查询执行计划。

执行计划取决于查询和关联的 VSchema。 Vitess 规划策略的基本目标之一是将尽可能多的工作下推到底层 MySQL 实例。当这不可能时,Vitess 将使用从多个来源收集输入并合并结果以生成正确查询结果的计划。

评估模型

一个执行计划由操作符组成,每个操作符执行一个特定的工作。运算符组合成一个树状结构,代表整体执行计划。该计划将每个运算符表示为树中的一个节点。每个运算符将零或更多行作为输入,并产生零或更多行作为输出。这意味着一个操作符的输出成为下一个操作符的输入。连接树中两个分支的运算符组合来自两个传入流的输入并产生单个输出。

执行计划的评估从树的叶节点开始。叶节点从 VTablet、拓扑服务中提取数据,并且在某些情况下还能够在本地评估表达式值。每个叶节点不会有来自其他算子的输入,并且将它们产生的任何节点通过管道传输到其父节点。然后,父节点将通过管道将节点传送到它们的父节点,一直到根节点。根节点产生查询的最终结果并将结果传递给用户。

观察执行计划

通过浏览 /queryz 端点,可以在 VTGate 级别观察缓存的执行计划。

从 Vitess 6 开始,还可以使用 EXPLAIN FORMAT=vitess <query> 观察单个语句计划。

Keyspace

键空间是一个逻辑数据库。如果你使用分片,一个键空间映射到多个 MySQL 数据库;如果您不使用分片,键空间将直接映射到 MySQL 数据库名称。在任何一种情况下,从应用程序的角度来看,键空间都显示为单个数据库。

从键空间读取数据就像从 MySQL 数据库读取数据。但是,根据读取操作的一致性要求,Vitess 可能会从主数据库或副本中获取数据。 通过将每个查询路由到适当的数据库,Vitess 允许您的代码像从单个 MySQL 数据库读取一样。

Keyspace ID

键空间 ID 是用于决定给定行所在的分片的值。基于范围的分片是指创建每个覆盖特定范围的键空间 ID 的分片。

使用此技术意味着您可以通过用两个或更多新分片替换、拆分给定分片,这些新分片组合在一起以覆盖键空间 ID 的原始范围,而无需移动其他分片中的任何记录。

键空间 ID 本身是使用数据中某些列的函数计算的,例如用户 ID。Vitess 允许您从各种函数(vindexes)中进行选择来执行此映射。这使您可以选择正确的方法来实现数据在分片之间的最佳分布。

MoveTables

MoveTables 是一种基于 VReplication 的新工作流。它使您能够在键空间之间重新定位表,从而在不停机的情况下重新定位物理 MySQL 实例。

识别候选表

建议将需要相互连接的表保留在同一键空间中,因此 MoveTables 操作的典型候选对象是一组逻辑上组合在一起或以其他方式隔离的表。

如果您有多组表作为候选,最有意义的移动可能取决于您的环境的具体情况。例如,一个更大的表将需要更多的时间来移动,但移动之后你能够利用额外的或更新的硬件,这些硬件在你需要执行额外的操作(如分片)之前有更多的空间。

同样,更新频率高的表也可能会增加移动时间。

对生产交通的影响

在内部,MoveTables 操作由表副本和对表所做的所有更改的订阅组成。Vitess 使用批处理来提高表复制和应用订阅更改的性能,更新率较低的表移动得更快。

在主动移动过程中,数据是从副本而不是主服务器复制的。这有助于确保最小的生产流量影响。

在 MoveTables 操作的 SwitchWrites 阶段,Vitess 可能暂时不可用。这种不可用性通常是几秒钟,取决于主服务器到副本的复制延迟。

Query Rewriting

Vitess 努力营造一种用户与单个数据库的单一连接的错觉。实际上,单个查询可能与多个数据库交互,也可能多个连接连接到同一数据库。在这里,我们将讨论 Vitess 的作用以及它对您的影响。

查询拆分

具有交叉分片连接的复杂查询可能需要首先从 vindex 查找表的 tablet 中获取信息。然后使用此信息查询两个不同的分片以获取更多数据,然后将传入的结果连接到用户接收的单个结果中。 MySQL 获取的查询通常只是原始查询的一部分,最终结果将在 vtgate 级别组装。

连接池

当 tablet 与 MySQL 执行查询时,它不会为每个用户使用专用连接,而是会在用户之间共享底层连接。这意味着在会话中存储任何状态都是不安全的,因为你不能确定它会继续在同一个连接上执行查询,你也不能确定这个连接以后是否会被其他用户使用。

用户定义变量

使用 MySQL 时,用户定义的变量在会话状态保持。可以使用 SET 为它们赋值:

SET @my_user_variable = 'foobar'

可以使用 SELECT 进行查询:

> SELECT @my_user_variable;
+-------------------+
| @my_user_variable |
+-------------------+
| foobar            |
+-------------------+

如果您针对 VTGate 执行这些查询,则第一个 SET 查询不会发送到 MySQL。而是在 VTGate 中进行计算,并且 VTGate 将为您保留此状态。第二个查询也没有发送下来。像这样的琐碎查询实际上完全在 VTGate 上执行。

如果我们尝试需要来自 MySQL 的数据的更复杂的查询,VTGate 将在发送之前重写查询。 如果我们要写这样的东西:

WHERE col = @my_user_variable

MySQL看到的是:

WHERE col = 'foobar'

这样,就不需要会话状态来计算 MySQL 中的查询。

服务器系统变量

用户可能还想更改 MySQL 公开的许多不同系统变量。Vitess 以四种不同方式之一处理系统变量:

  • No op。对于某些设置,Vitess 会默默地忽略该设置。这适用于在分片设置中没有多大意义的系统变量,并且不会改变 MySQL 的行为。
  • Check and fail if not already set。这些是不应更改的设置,但 Vitess 将允许 SET 语句尝试将变量设置为已经是的任何值。
  • Not supported。对于这些设置,尝试更改它们总是会导致错误。
  • Vitess aware。这些是改变 Vitess 行为的设置,不会发送到 MySQL
  • Reserved connection。对于某些设置,允许设置它们是有意义的,但这也意味着我们不能为此用户使用共享连接。这意味着代表该用户完成的每个连接都需要首先设置这些系统变量,然后保持连接专用。连接池对 Vitess 的性能很重要,保留的连接不能被池化,所以这不应该是在 Vitess 上运行应用程序的正常方式。只需确保将全局变量设置为应用程序将设置的相同值,Vitess 就可以使用连接池。

除此之外,Vitess 确保@@version 包含 MySQL 版本和 Vitess 版本,例如:5.7.9-vitess-10.0.0-SNAPSHOT。可以使用 vtgate 标志 -mysql_server_version 更改此值。

特殊功能

Vitess 可以处理一些特殊功能,而无需委托给 MySQL。

  • DATABASE() - 键空间名称和基础数据库名称不必相等。Vitess 将重写这些调用以使用文本字符串作为键空间名称。(这也适用于同义词 SCHEMA())
  • ROW_COUNT() 和 FOUND_ROWS() - 这些函数返回上次查询影响/返回的行数。因为这可能是在不同的连接上执行的,所以这些被重写为使用返回行数的文字值。
  • LAST_INSERT_ID() - 与 FOUND_ROWS() 非常相似,我们不能相信这些函数调用的池化连接,因此它们在命中 MySQL 之前会被重写。

Replication Graph

Replication Graph标识了主数据库与其各自副本之间的关系。在主故障转移期间,Replication Graph使 Vitess 能够将所有现有副本指向新指定的主数据库,以便复制可以继续。

Shard

shard 是 keyspace 的子集。一个键空间将始终包含一个或多个分片。一个分片通常包含一个 MySQL 主节点和潜在的多个 MySQL 副本。

分片中的每个 MySQL 实例都具有相同的数据(如果忽略任何复制延迟)。副本可以提供只读流量(具有最终一致性保证)、执行长时间运行的数据分析查询或执行管理任务(备份、恢复、差异等)。

未分片的键空间是只有一个分片的键空间。 Vitess 按照惯例将分片命名为 0(或有时 -)。分片时,一个键空间有 N 个不重叠数据的分片。一个键空间中的分片数量可以根据用例和负载特性而变化,一些 Vitess 用户在某些键空间中有数百个分片。

分片命名

分片名称具有以下特征:

  • 代表无符号整数空间中的一个范围,其中包括左边的数字,但不包括右边的数字。
  • 符号是十六进制的。
  • 它们左对齐,右填充零。
  • 一个 - 前缀意味着:任何小于右边值的意思。
  • 一个 - 后缀表示:任何大于或等于左边值的意思。
  • 普通 - 表示完整的键范围。

因此:-80 == 00-80 == 0000-8000 == 000000-800000 == 0000000000000000-8000000000000000

80-80-FF 不等价。为什么呢?

80-FF == 8000-FF00。因此 FFFF 将超出 80-FF 范围。

80- 表示:任何大于或等于 0x80

散列类型 vindex 产生一个无符号的 64 位整数作为输出。这意味着所有小于 0x8000000000000000 的整数都将落入分片 -80。任何设置了最高位的数字将 >= 0x8000000000000000,因此属于分片 80-

这种左对齐的方法允许您拥有任意长度的键空间 ID。因此最重要的位是左边的位。

例如,一个 md5 哈希产生 16 个字节。这也可以用作键空间 ID。

任意长度的 varbinary 也可以按原样映射到 keyspace id。这就是二进制 vindex 所做的。

重新分片

Vitess 支持重新分片,即在实时集群上更改分片的数量。这可以是将一个或多个分片拆分为更小的部分,或者将相邻的分片合并为更大的部分。

在重新分片期间,源分片中的数据被复制到目标分片中,允许赶上复制,然后与原始分片进行比较以确保数据完整性。然后将实时服务基础设施转移到目标分片,并删除源分片。

Tablet

一个tablet是一个mysqld进程和一个对应的vttablet进程的组合,通常运行在同一台机器上。每个tablet都被分配了一个tablet类型,它指定了它当前执行的角色。

查询通过 VTGate 服务器路由到tablet。

tablet类型

  • master - 一个副本tablet,恰好当前是其分片的 MySQL master。
  • replica - 有资格提升为主节点的 MySQL 副本。通常,这些副本作为候选者用于服务实时的、面向用户的请求(例如来自网站的前端)。
  • rdonly - 无法提升为 master 的 MySQL 副本。通常,这些用于后台处理作业,例如备份、将数据转储到其他系统、大量分析查询、MapReduce 和重新分片。
  • backup - 已在一致性快照处停止复制的tablet,因此它可以为其分片上传新备份。完成后,它将恢复复制并返回到以前的类型。
  • restore - 启动时没有数据的tablet,并且正在从最新备份中恢复自身。完成后,它将在备份的 GTID 位置开始复制,并成为 replica 或 rdonly。
  • drained - 由 Vitess 后台进程保留的tablet(例如用于重新分片的 rdonly tablet)。

Topology Service

也称为 TOPO 或 lock service

拓扑服务是一组运行在不同服务器上的后端进程。这些服务器存储了拓扑数据并提供分布式锁定服务。

Vitess 使用plug-in系统来支持存储拓扑数据的各种后端,前提是这些后端提供分布式、一致的键值存储。默认的拓扑服务插件是 etcd2。

拓扑服务的存在有几个原因:

  • 它使tablet能够作为一个集群在它们之间进行协调。
  • 它使 Vitess 能够发现tablet,因此它知道将查询路由到哪里。
  • 它存储集群中许多不同服务器需要的数据库管理员提供的 Vitess 配置,并且必须在服务器重新启动期间保持不变。

一个 Vitess 集群有一个全局拓扑服务,每个单元有一个本地拓扑服务。

全局拓扑

全局拓扑服务存储不经常更改的 Vitess 级别的数据。具体来说,它包含有关密钥空间和分片的数据以及每个分片的主tablet别名。

全局拓扑用于一些操作,包括 reparenting 和 resharding。按照设计,全局拓扑服务的使用并不多。

为了在任何单个单元发生故障时容灾,全局拓扑服务应该在多个单元中具有节点,以便在单元发生故障时维持仲裁。

本地拓扑

每个本地拓扑都包含与其自身单元相关的信息。具体来说,它包含有关单元格中tablet的数据、该单元的 keyspace graph 以及该单元的 replication graph。

本地拓扑服务必须可供 Vitess 使用以发现tablet并在tablet往返时调整路由。但是,在稳定状态下提供查询服务的关键路径中不会调用拓扑服务。这意味着在拓扑暂时不可用期间仍会提供查询服务。

VSchema

VSchema 允许您描述数据在键空间和分片中的组织方式。此信息用于路由查询,也用于重新分片操作。

对于 Keyspace,您可以指定它是否被分片。对于sharded keyspace,您可以为每个表指定 vindexes 列表。

Vitess 还支持序列生成器,可用于生成新的 id,其工作方式类似于 MySQL 自动增量列。 VSchema 允许您将table columns关联到sequence tables。如果没有为这样的列指定值,那么 VTGate 将使用sequence table为它生成一个新值。

VStream

VStream 是一种可通过 VTGate 访问的更改通知服务。 VStream 的目的是从 Vitess 集群的底层 MySQL 分片提供与 MySQL 二进制日志等效的信息。gRPC 客户端,包括 Vitess 组件,如 VTablets,可以订阅 VStream 以接收来自其他分片的更改事件。VStream从VTTablet实例上的一个或多个VStreamer实例拉取事件,后者又从底层MySQL实例的二进制日志拉取事件。这允许有效执行诸如 VReplication 之类的功能,其中订阅者可以从一个或多个 MySQL 实例分片的二进制日志中间接接收事件,然后将其应用于目标实例。用户可以利用 VStream 获取有关给定 Vitess 键空间、分片和位置的数据更改事件的详细信息。单个 VStream 还可以整合来自键空间中多个分片的更改事件,使其成为从 Vitess 数据存储向下游提供 CDC(Change Data Capture)过程的便捷工具。

作为参考,请参考下图:
VStream

注意:VStream 不同于 VStreamer。前者位于VTGate,后者位于VTTablet。

vtctl

vtctl 是一个命令行工具,用于管理 Vitess 集群。它可作为独立工具 (vtctl) 和客户端-服务器(vtctlclient 与 vtctld 结合使用)。建议使用客户端-服务器,因为它在远程使用客户端时提供了额外的安全层。

使用 vtctl,您可以识别主数据库和副本数据库、创建表、启动故障转移、执行重新分片操作等。

随着 vtctl 执行操作,拓扑服务会根据需要进行更新。其他 Vitess 服务器会观察这些变化并做出相应的反应。例如,如果您使用 vtctl 故障转移到新的 master 数据库,vtgate 会看到更改并将未来的写入操作定向到新的 master。

vtctld

vtctld 是一个 HTTP 服务器,可让您浏览存储在拓扑服务中的信息。它对于故障排除或获取服务器及其当前状态的概览信息很有用。

vtctld 还充当 vtctlclient 连接的服务器。

VTGate

VTGate 是一个轻量级的代理服务器,它可以将流量路由到正确的 VTTablet 服务器并将合并的结果返回给客户端。它同时使用 MySQL 协议和 Vitess gRPC 协议。因此,您的应用程序可以像连接 MySQL 服务器一样连接到 VTGate。

在将查询路由到适当的 VTablet 服务器时,VTGate 会考虑分片方案、所需的延迟以及表及其底层 MySQL 实例的可用性。

用户手册

参考

设计文档

FAQ

一文读懂ZGC

关于ZGC

ZGC(Z Garbage Collector)是一种可扩展的低延迟垃圾回收器,旨在满足以下目标:

  • 亚毫秒(Sub-millisencond)级的最大暂停时间
  • 暂停时间不会随着heap、live-set、root-set的增大而增加
  • 可以处理8MB到16TB的堆大小

ZGC支持:

  • 并发(Concurrent)
  • 基于Region
  • 压缩(Compacting)
  • NUMA-aware
  • 使用着色指针
  • 使用负载屏障

ZGC的核心是一个并发垃圾收集器,这意味着所有繁重的工作都在Java线程执行的同时完成。这极大地限制了垃圾收集对应用程序响应时间的影响。

JVM如何设置

JVM一般通过JAVA_OPTS环境变量设置,如果使用Tomcat,可以使用CATALINA_OPTS设置。JAVA_OPTS与CATALINA_OPTS的不同是:

  • [JAVA_OPTS]: (optional) Java runtime options used when the “start”, “stop” or “run” command is executed
  • [CATALINA_OPTS]: (optional) Java runtime options used when the “start” or “run” command is executed

支持的平台

Platform Supported Since Comment
Linux/AArch64 支持 JDK 13
Linux/x64 支持 JDK 11
macOS 支持 JDK 14
Windows 支持 JDK 14 Requires Windows version 1803 (Windows 10 or Windows Server 2019) or later.

快速开始

如果您是第一次尝试 ZGC,请从使用以下 GC 选项开始:

-XX:+UseZGC -Xmx -Xlog:gc

如需更详细的日志记录,请使用以下选项:(在VSCode中加*,启动报错)

-XX:+UseZGC -Xmx -Xlog:gc*

示例代码:

JAVA_OPTS=”-XX:+UseZGC -Xmx1024m -Xlog:gc”

配置和调优

General GC Options

  • -XX:MinHeapSize, -Xms
  • -XX:InitialHeapSize, -Xms
  • -XX:MaxHeapSize, -Xmx
  • -XX:SoftMaxHeapSize
  • -XX:ConcGCThreads
  • -XX:ParallelGCThreads
  • -XX:UseLargePages
  • -XX:UseTransparentHugePages
  • -XX:UseNUMA
  • -XX:SoftRefLRUPolicyMSPerMB
  • -XX:AllocateHeapAt

ZGC Options

  • -XX:ZAllocationSpikeTolerance
  • -XX:ZCollectionInterval
  • -XX:ZFragmentationLimit
  • -XX:ZMarkStackSpaceLimit
  • -XX:ZProactive
  • -XX:ZUncommit
  • -XX:ZUncommitDelay

ZGC Diagnostic Options (-XX:+UnlockDiagnosticVMOptions)

  • -XX:ZStatisticsInterval
  • -XX:ZVerifyForwarding
  • -XX:ZVerifyMarking
  • -XX:ZVerifyObjects
  • -XX:ZVerifyRoots
  • -XX:ZVerifyViews

启用ZGC

使用-XX:+UseZGC参数启用ZGC。

设置Heap大小

ZGC最重要的调优选项是设置最大堆(Heap)大小 (-Xmx<size>)。由于ZGC是并发收集器,因此必须选择最大堆大小:

  1. 堆可以容纳应用程序的live-set,
  2. 在GC运行期间堆中有足够的空间分配给应用程序。

需要多少空间取决于应用程序的分配率和实时设置大小。一般来说,你给ZGC的内存越多越好。但同时,浪费内存也是不可取的,所以这一切都是为了在内存使用和GC需要运行的频率之间找到平衡。

设置并发GC线程数

第二个调优选项是设置并发 GC 线程的数量 (-XX:ConcGCThreads=<number>)。 ZGC 有启发式自动选择这个数字。这种启发式通常效果很好,但根据应用程序的特性,这可能需要进行调整。这个选项本质上决定了应该给 GC 多少 CPU 时间。给它太多,GC 会从应用程序中窃取太多 CPU 时间。给它太少,应用程序可能会比 GC 收集垃圾的速度更快地收集垃圾。

一般来说,如果低延迟(即低应用程序响应时间)对您的应用程序很重要,那么永远不要过度配置您的系统。理想情况下,您的系统的 CPU 利用率不应超过 70%。

将未使用的内存归还给操作系统

默认情况下,ZGC 不提交未使用的内存给操作系统。这对于关注内存占用的应用程序和环境很有用。可以使用 -XX:-ZUncommit禁用此功能。此外,内存不会未提交,因此堆大小会缩小到最小堆大小 (-Xms) 以下。这意味着如果最小堆大小 (-Xms) 配置为等于最大堆大小 (-Xmx),则此功能将被隐式禁用。
可以使用-XX:ZUncommitDelay=<senconds>(默认为 300 秒)配置取消提交延迟。此延迟指定内存在有资格取消提交之前应该被使用多长时间。

在 Linux 上,取消提交未使用的内存需要具有 FALLOC_FL_PUNCH_HOLE 支持的 fallocate(2),它首先出现在内核版本 3.5(用于 tmpfs)和 4.3(用于 Hugetlbfs)中。

在Linux上使用Large Pages

将 ZGC 配置为使用大页面通常会产生更好的性能(在吞吐量、延迟和启动时间方面)并且没有真正的缺点,只是设置稍微复杂一些。设置过程通常需要 root 权限,这就是默认情况下不启用它的原因。

在 Linux/x86 上,large pages(也称为“huge pages”)的大小为 2MB。

假设您想要一个 16G 的 Java 堆。这意味着您需要 16G / 2M = 8192 个大页面。
首先为大页面池分配至少 16G(8192 页)的内存。 “至少”部分很重要,因为在 JVM 中启用大页面意味着不仅 GC 将尝试将这些用于 Java 堆,而且 JVM 的其他部分将尝试将它们用于各种内部数据结构(代码堆、标记位图等)。因此,在此示例中,我们将保留 9216 个页面 (18G) 以允许 2G 的非 Java 堆分配以使用大页面。
配置系统的大页面池,使其拥有所需数量的页面(需要root权限):

$ echo 9216 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages

请注意,如果内核找不到足够的空闲大页面来满足请求,则不能保证上述命令会成功。另请注意,内核处理请求可能需要一些时间。在继续之前,请检查分配给池的大页面数量以确保请求成功并已完成。

$ cat /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
9216

如果您使用的是Linux kernel >= 4.14内核,则可以跳过下一步(您挂载 Hugetlbfs 文件系统的位置)。否则,如果您使用的是较旧的内核,则 ZGC 需要通过 Hugetlbfs 文件系统访问大页面。
挂载一个hugetlbfs 文件系统(需要root 权限)并使运行JVM 的用户可以访问它(在本例中,我们假设该用户的uid 为123)。

$ mkdir /hugepages
$ mount -t hugetlbfs -o uid=123 nodev /hugepages 

现在使用-XX:+UseLargePages选项启动 JVM。

$ java -XX:+UseZGC -Xms16G -Xmx16G -XX:+UseLargePages ...

如果有多个可访问的 Hugetlbfs 文件系统可用,那么(并且只有这样)您还必须使用-XX:AllocateHeapAt来指定要使用的文件系统的路径。例如,假设安装了多个可访问的hugetlbfs 文件系统,但您特别希望使用它的文件系统安装在/hugepages 上,然后使用以下选项。

$ java -XX:+UseZGC -Xms16G -Xmx16G -XX:+UseLargePages -XX:AllocateHeapAt=/hugepages ...

除非采取必要的措施,否则巨页池的配置和 Hugetlbfs 文件系统的安装在重新启动后会丢失。

在Linux上启用Transparent Huge Pages

使用显式大页面(如上所述)的替代方法是使用透明大页面。对于延迟敏感的应用程序,通常不推荐使用透明大页面,因为它往往会导致不必要的延迟峰值。但是,可能值得尝试一下,看看您的工作负载是否/如何受到它的影响。但请注意,您的里程可能会有所不同。

在 Linux 上,在启用透明大页面的情况下使用 ZGC 需要kernel >= 4.7

使用以下选项在 VM 中启用透明大页面:

-XX:+UseLargePages -XX:+UseTransparentHugePages

这些选项告诉 JVM 为其映射的内存发出 madvise(…, MADV_HUGEPAGE) 调用,这在 madvise 模式下使用透明大页面时很有用。
要启用透明大页面,您还需要通过启用 madvise 模式来配置内核。

$ echo madvise > /sys/kernel/mm/transparent_hugepage/enabled

$ echo advise > /sys/kernel/mm/transparent_hugepage/shmem_enabled

启用NUMA支持

ZGC 具有 NUMA 支持,这意味着它会尽量将 Java 堆分配定向到 NUMA 本地内存。默认启用此功能。但是,如果 JVM 检测到它绑定到系统中的 CPU 子集,它将自动禁用。通常,您无需担心此设置,但如果您想明确覆盖 JVM 的决定,您可以使用-XX:+UseNUMA-XX:-UseNUMA选项来实现。

在 NUMA 机器(例如多路 x86 机器)上运行时,启用 NUMA 支持通常会显着提升性能。

启用GC Logging

使用以下命令行选项启用 GC 日志记录:

-Xlog:<tag set>,[<tag set>, ...]:<log file>

有关此选项的一般信息/帮助:

-Xlog:help

要启用基本日志记录(每个 GC 输出一行):

-Xlog:gc:gc.log

要启用对调优/性能分析有用的 GC 日志记录:

-Xlog:gc*:gc.log

其中 gc* 表示记录包含 gc 标记的所有标记组合,而 :gc.log 表示将日志写入名为 gc.log 的文件。

一文读懂分布式存储

分布式理论

分布式系统的特性

  • 可拓展
  • 低成本
  • 高性能
  • 易用性。提供易用的对外接口,具备完善的监控、运维工具,并方便和其他系统集成。

分布式存储的数据需求可以分为三类:

  • 非结构化数据:包括所有格式的办公文档、文本、图片、图像、音频和视频信息。
  • 结构化数据:一般存储在关系数据库中,可以用二维关系表结构来表示。结构化数据的模式(Schema,包括属性、数据类型以及数据之间的关系)和内容是分开的,数据的模式需要预先定义。
  • 半结构化数据:介于非结构化数据和结构化数据之间,HTML文档就数据半结构化数据。它一般是自描述的,与结构化数据最大的区别在于,半结构化数据的模式结构和内容混在一起,没有明显的区分,也不需要预先定义数据的模式结构。

分布式存储系统

分布式文件系统

互联网应用需要存储大量的图片、照片、视频等非结构化数据对象,这类数据以对象的形式组织,对象之间没有关联,这样的数据一般成为Blob(Binary Large Object,二进制大对象)数据。

分布式文件系统用于存储Blob对象,典型的系统有Facebook Haystack以及Taobao File System(TFS)。另外,分布式文件系统常作为分布式表格系统、分布式数据库的底层存储,如谷歌的GFS(Google File System)可以作为分布式表格系统Google Bigtable的底层存储,Amazon的EBS(Elastic Block Store)系统可以作为分布式数据库Amazon RDS的底层存储。

总体上看,分布式文件系统存储三种类型的数据:Blob对象、定长块、大文件。在系统实现层面,分布式文件系统内部按照数据块(chunk)来组织数据,每个数据块的大小大致相同,每个数据块可以包含多个Blob对象或者定长块,一个大文件也可以拆分为多个数据块。分布式文件系统将这些数据块分散到存储集群,处理数据复制、一致性、负载均衡、容错等分布式系统难题,并将用户对Blob对象、定长块以及大文件的操作映射为对底层数据块的操作。
数据块与Blob对象、定长块、大文件之间的关系

分布式键值系统

分布式键值系统用于存储关系简单的半结构化数据,它只提供基于主键的CRUD(Create/Read/Update/Delete)功能。

典型的系统有Amazon Dynamo以及Taobao Tair。从数据结构的角度看,分布式键值系统与传统的哈希表比较类似,不同的是,分布式键值系统支持将数据分布到集群中的多个存储节点。分布式键值系统是分布式表格系统的一种简化实现,一般用作缓存,比如淘宝Tair以及Memcache。一致性哈希是分布式键值系统中常用的数据分布技术,因其被Amazon DynamoDB系统使用而变得相当有名。

分布式表格系统

分布式表格系统用于存储关系较为复杂的半结构化数据,与分布式键值系统相比,分布式表格系统不仅仅支持简单的CRUD操作,而且支持扫描某个主键范围。分布式表格系统以表格为单位组织数据,每个表格包括很多行,通过主键标示一行,支持根据主键的CRUD功能以及范围查找功能。

分布式表格系统借鉴了很多关系数据库的技术,例如支持某种程度上的事务,比如单行事务,某个实体组(Entity Group,一个用户下的所有数据往往构成实体组)下的多行事务。典型的系统包括Google Bigtable以及Megastore,Microsoft Azure Table Storage,Amazon DynamoDB等。与分布式数据库相比,分布式表格系统主要支持针对单张表格的操作,不支持一些特别复杂的操作,比如多表关联、多表连接、嵌套子查询;另外,在分布式表格系统中,同一个表格的多个数据行也不要求包含相同类型的列,适合半结构化数据。分布式表格系统是一个很好的权衡,这类系统可以做到超大规模,而且支持较多功能,但实现往往比较复杂,而且有一定的使用门槛。

分布式数据库

分布式数据库一般是从单机关系数据库扩展而来,用于存储结构化数据。分布式数据库采用二维表组织数据,提供SQL关系查询语言,支持多表关联、嵌套子查询等复杂操作,并提供事务以及并发控制。

典型的系统包括MySQL数据分片(MySQL Sharding)集群,Amazon RDS以及Microsoft SQL Azure。分布式数据库支持的功能最为丰富,符合用户使用习惯,但可扩展性往往受到限制。当然,这一点并不是绝对的。Google Spanner系统是一个支持多数据中心的分布式数据库,它不仅支持丰富的关系数据库功能,还能扩展到多个数据中心的成千上万台机器。除此之外,阿里巴巴OceanBase系统也是一个支持自动扩展的分布式关系数据库。

关系数据库是目前为止最为成熟的存储技术,它的功能极其丰富,产生了商业的关系数据库软件(例如Oracle、Microsoft SQL Server、IBM DB2、MySQL)以及上层的工具及应用软件生态链。然而,关系数据库在可以扩展上面临着巨大挑战。传统关系数据库的事务以及二维关系模型很难高效的扩展到多个存储节点上,另外,关系数据库对于要求高并发的应用在性能上优化空间较大。

基础硬件对存储系统的影响

常用硬件性能参数

单机存储引擎

存储引擎是存储系统的发动机,直接决定了存储系统能够提供的性能和功能。存储系统的基本功能包括:增、删、读、改,其中,读取操作又分为随机读取和顺序扫描。哈希存储引擎是哈希表的持久化实现,支持增、删、改,以及随机读取,但不支持顺序扫描,对应的存储系统为键值(Key-Value)存储系统;B树(B-Tree)存储引擎是B树的持久化实现,不仅支持单条记录的增、删、读、改操作,还支持顺序扫描,对应的存储系统是关系数据库。当然,键值系统也可以通过B树存储引擎实现;LSM树(Log-Structured Merge Tree)存储引擎和B树存储引擎一样,支持增、删、改、随机读取以及顺序扫描。它通过批量转储技术规避磁盘随机写入问题,广泛应用于互联网的后台存储系统,例如Google Bigtable、Google LevelDB以及Facebook开源的Cassandra系统。

哈希存储引擎

Bitcask是一个基于哈希表结构的键值存储系统,它仅支持追加操作(Appendonly)。在Bitcask系统中,每个文件有一定的大小限制,当文件增加到相应大小是,就会产生一个新的文件,老的文件只读不写。在任意时刻,只有一个文件是可写的,用于数据追加,称为活跃数据文件(active data file)。而其他已经达到大小限制的文件,称为老数据文件(older data file)。

数据结构

Bitcask数据文件中的数据是一条一条的写入操作,每一条记录的数据项分别为主键(key)、value内容(value)、主键长度(key_sz)、value长度(value_sz)、时间戳(timestamp)、crc校验值。数据删除操作也不会删除旧的条目,而是将value设定为一个特殊的值用作标识。内存中采用基于哈希表的索引数据结构,哈希表的作用是通过主键快速的定位到value的位置。哈希表结构中的每一项包括了三个用于定位数据的信息,分别是文件编号(file id),value在文件中的位置(value_pos),value长度(value_sz),通过读取file_id对应文件的value_pos开始的value_sz个字节,就得到了最终的value值。写入是首先将Key-Value记录追加到活跃数据文件的末尾,接着更新内存哈希表,接着更新哈希表,因此,每个写操作总共需要进行一次顺序的磁盘写入和一次内存操作。
Bitcask数据结构

Bitcask在内存中存储了主键和value的索引信息,磁盘文件中存储了主键和value的实际内容。系统基于一个假设,value的长度远大于主键的长度。假如value的平均长度为1KB,每条记录在内存中的索引信息为32字节,那么,磁盘内存比为32:1。这样,32GB内存索引的数据量为32GB*32=1TB。

定期合并

Bitcask系统中的记录删除或者更新后,原来的记录成为垃圾数据。Bitcask需要定期执行合并(Compaction)操作已实现垃圾回收。

快速恢复

Bitcask系统中的哈希索引存储在内存中,如果不做额外的工作,服务器断电重启重建哈希表需要扫描一遍数据文件,如果数据文件很大,这是一个非常耗时的过程。Bitcask通过索引文件(hint file)来提高重建哈希表的速度。

简单来说,索引文件就是将内存中的哈希索引表转储到磁盘生成的结果文件。Bitcask对老数据文件进行合并操作时,会产生新的数据文件,这个过程中还会产生一个索引文件,这个索引文件记录每一条记录的哈希索引信息。重建索引表时,仅仅需要将索引文件中的数据一行行读取并重建即可,减少重启后的恢复时间。

B树存储引擎

相比哈希存储引擎,B树存储引擎不仅支持随机读取,还支持范围扫描。关系数据库中通过索引访问数据,在MySQL InnoDB中,有一个称为聚集索引的特殊索引,行的数据存于其中,组织成B+树数据结构。

数据结构

MySQL InnoDB按照页面(Page)来组织数据,每个页面对应B+树的一个节点。其中,叶子节点保存每行的完整数据,非叶子节点保存索引信息。数据在每个节点中有序存储,数据库查询是需要从根节点开始二分查找直到叶子节点,每次读取一个节点,如果对应的页面不在内存中,需要从磁盘中读取并缓存起来。B+树的根节点是常驻内存的,因此,B+树一次检索最多需要h-1次磁盘IO,复杂度为O(h)=O(logdN)(N为元素个数,d为每个节点的出度,h为B+树高度)。修改操作首先需要记录提交日志,接着修改内存中的B+树。如果内存中被修改过的页面超过一定的比例,后台线程会将这些页面刷到磁盘中持久化。
B+树存储引擎

缓冲区管理

缓冲区管理器负责将可用的内存划分成缓冲区,缓冲区是与页面同等大小的区域,磁盘块的内容可以传送到缓冲区中。缓冲区管理器的关键在于替换策略,即选择哪些页面淘汰出缓冲池。常见的算法有以下两种。

LRU(Least Recently Used)

LRU算法淘汰最近最少使用的块。这种算法要求缓冲区管理器按照页面最后一次被访问的时间组成一个链表,每次淘汰链表尾部的页面。

LRU局限性:假如某一个查询做了一个全表扫描,将导致缓冲池中的大量页面(可能包含很多很快被访问的热点页面)被替换,从而污染缓冲池。

LIRS(Low Inter-reference Recency Set)

LIRS使用IRR(Inter-Reference Recency)来表示数据块访问历史信息,IRR表示最近连续访问同一个数据块之间访问其他不同数据块非重复个数。

现代数据库一般采用LIRS算法,将缓冲池分为两级,数据首页进入第一级,如果数据在较短的时间内被访问2次或以上,则成为热点数据进入第二级,每一级内部还是采用LRU替换算法。Oracle数据库中的Touch Count算法和MySQL InnoDB中的替换算法都采用了类似的分级思想。以MySQL InnoDB为例,InnoDB内部的LRU链表分为两部分:新子链表(new sublist)和老子链表(old sublist),默认情况下,前者占5/8,后者占3/8。页面首先插入到老子链表,InnoDB要求页面在老子链表停留时间超过一定值,比如1秒,才有可能被转移到新子链表。当出现全表扫描时,InnoDB将数据页面载入到老子链表,由于数据页面在老子链表中的停留时间不够,不会被转移到新子链表中,这就避免了新子链表中的页面被替换出去的情况。

LSM树存储引擎

LSM树(Log Structured Merge Tree)的思想非常朴素,就是将对数据的修改增量保持在内存中,达到指定大小限制后将这些修改操作批量写入磁盘,读取时需要合并磁盘中的历史数据和内存中最近的修改操作。LSM树的优势在于有效地规避了磁盘随机写入问题,但读取时可能需要访问较多的磁盘文件。下面介绍LevelDB中的LSM树存储引擎。

存储结构

LevelDB存储引擎主要包括:内存中的MemTable和不可变MemTable(Immutable MemTable,也称为Frozen MemTable)以及磁盘上的几种主要文件:当前(Current)文件、清单(Manifest)文件、操作日志(Commit Log)文件以及SSTable文件。当应用写入一条记录时,LevelDB会首先将修改操作写入到操作日志文件,成功后再将修改操作应用到MemTable,这样就完成了写入操作。
LevelDB存储引擎

当MemTable占用的内存达到一个上限值后,需要将内存的数据转储到外存文件中。LevelDB会将原先的MemTable冻结成为不可变MemTable,并生成一个新的MemTable。新到来的数据被记入新的操作日志文件和新生成的MemTable中。顾名思义,不可变MemTable的内容是不可更改的,只能读取不能写入或者删除。LevelDB后台线程会将不可变MemTable的数据排序后转储到磁盘,形成一个新的SSTable文件,这个操作称为Compaction。SSTable文件是内存中的数据不断进行Compaction操作后形成的,且SSTable的所有文件是一种层级结构,第0层为Level0,第1层为Level1,以此类推。

SSTable中的文件是按照记录的主键排序的,每个文件有最小的主键和最大的主键。LevelDB的清单文件记录了这些元数据,包括属于哪个层级、文件名称、最小主键和最大主键。当前文件记录了当前使用的清单文件名。在LevelDB的运行过程中,随着Compaction的进行,SSTable文件会发生变化,新的文件会产生,老的文件被废弃,此时往往会生成新的清单文件来记载这种变化,而当前文件则用来指出哪个清单文件才是当前有效的。

直观上,LevelDB每次查询都需要从老到新读取每个层级的SSTable文件以及内存中的MemTable。LevelDB做了一个优化,由于LevelDB对外只支持随机读取单条记录,查询时LevelDB首先会去查看内存中的MemTable,如果MemTable包含记录的主键及其对应的值,则返回记录即可;如果MemTable没有读到该主键,则接下来到同样处于内存中的不可变Memtable中去读取;类似地,如果还是没有读到,只能依次从新到老读取磁盘中的SSTable文件。

合并

LevelDB写入操作很简单,但是读取操作比较复杂,需要在内存以及各个层级文件中按照从新到老依次查找,代价很高。为了加快读取速度,LevelDB内部会执行Compaction操作来对已有的记录进行整理压缩,从而删除一些不再有效的记录,减少数据规模和文件数量。

LevelDB的Compaction操作分为两种:minor compaction和major compaction。Minor compaction是指当内存中的MemTable大小到了一定值时,将内存数据转储到SSTable文件中。每个层级下有多个SSTable,当某个层级下的SSTable文件数目超过一定设置值后,levelDB会从这个层级中选择SSTable文件,将其和高一层级的SSTable文件合并,这就是major compaction。major compaction相当于执行一次多路归并:按照主键顺序依次迭代出所有SSTable文件中的记录,如果没有保存价值,则直接抛弃;否则,将其写入到新生成的SSTable文件中。

数据模型

如果说存储引擎相当于存储系统的发动机,那么,数据模型就是存储系统的外壳。存储系统的数据模型主要包括三类:文件、关系以及随着NoSQL技术流行起来的键值模型。传统的文件系统和关系数据库系统分别采用文件和关系模型。关系模型描述能力强,产业链完整,是存储系统的业界标准。然而,随着应用在可扩展性、高并发以及性能上提出越来越高的要求,大而全的关系数据库有时显得力不从心,因此,产生了一些新的数据模型,比如键值模型,关系弱化的表格模型,等等。

文件模型

文件系统以目录树的形式组织文件,以类UNIX操作系统为例,根目录为/,包含/usr、/bin、/home等子目录,每个子目录又包含其他子目录或者文件。文件系统的操作涉及目录以及文件,例如,打开/关闭文件、读写文件、遍历目录、设置文件属性等。POSIX(Portable Operating System Interface)是应用程序访问文件系统的API标准,它定义了文件系统存储接口及操作集。

POSIX主要接口如下所示。

  • Open/close:打开/关闭一个文件,获取文件描述符;
  • Read/write:读取一个文件或者往文件中写入数据;
  • Opendir/closedir:打开或者关闭一个目录;
  • Readdir:遍历目录。

POSIX标准不仅定义了文件操作接口,而且还定义了读写操作语义。例如,POSIX标准要求读写并发时能够保证操作的原子性,即读操作要么读到所有结果,要么什么也读不到;另外,要求读操作能够读到之前所有写操作的结果。POSIX标准适合单机文件系统,在分布式文件系统中,出于性能考虑,一般不会完全遵守这个标准。NFS(Network File System)文件系统允许客户端缓存文件数据,多个客户端并发修改同一个文件时可能出现不一致的情况。

对象模型与文件模型比较类似,用于存储图片、视频、文档等二进制数据块,典型的系统包括AmazonSimple Storage(S3),Taobao File System(TFS)。这些系统弱化了目录树的概念,Amazon S3只支持一级目录,不支持子目录,Taobao TFS甚至不支持目录结构。与文件模型不同的是,对象模型要求对象一次性写入到系统,只能删除整个对象,不允许修改其中某个部分。

关系模型

每个关系是一个表格,由多个元组(行)构成,而每个元组又包含多个属性(列)。关系名、属性名以及属性类型称作该关系的模式(schema)。例如,Movie关系的模式为Movie(title,year,length),其中,title、year、length是属性,假设它们的类型分别为字符串、整数、整数。数据库语言SQL用于描述查询以及修改操作。数据库修改包含三条命令:INSERT、DELETE以及UPDATE,查询通常通过select-from-where语句来表达,它具有图2-9所示的一般形式。Select查询语句计算过程大致如下(不考虑查询优化):

  1. 取FROM子句中列出的各个关系的元组的所有可能的组合。
  2. 将不符合WHERE子句中给出的条件的元组去掉。
  3. 如果有GROUP BY子句,则将剩下的元组按GROUP BY子句中给出的属性的值分组。
  4. 如果有HAVING子句,则按照HAVING子句中给出的条件检查每一个组,去掉不符合条件的组。
  5. 按照SELECT子句的说明,对于指定的属性和属性上的聚集(例如求和)计算出结果元组。
  6. 按照ORDER BY子句中的属性列的值对结果元组进行排序。

SQL查询还有一个强大的特性是允许在WHERE、FROM和HAVING子句中使用子查询,子查询又是一个完整的select-from-where语句。

另外,SQL还包括两个重要的特性:索引以及事务。其中,数据库索引用于减少SQL执行时扫描的数据量,提高读取性能;数据库事务则规定了各个数据库操作的语义,保证了多个操作并发执行时的ACID特性。

键值模型

大量的NoSQL系统采用了键值模型(也称为Key-Value模型),每行记录由主键和值两个部分组成,支持基于主键的如下操作:

  • Put:保存一个Key-Value对。
  • Get:读取一个Key-Value对。
  • Delete:删除一个Key-Value对。

Key-Value模型过于简单,支持的应用场景有限,NoSQL系统中使用比较广泛的模型是表格模型。表格模型弱化了关系模型中的多表关联,支持基于单表的简单操作,典型的系统是Google Bigtable以及其开源Java实现HBase。表格模型除了支持简单的基于主键的操作,还支持范围扫描,另外,也支持基于列的操作。主要操作如下:

  • Insert:插入一行数据,每行包括若干列;
  • Delete:删除一行数据;
  • Update:更新整行或者其中的某些列的数据;
  • Get:读取整行或者其中某些列数据;
  • Scan:扫描一段范围的数据,根据主键确定扫描的范围,支持扫描部分列,支持按列过滤、排序、分组等。

与关系模型不同的是,表格模型一般不支持多表关联操作,Bigtable这样的系统也不支持二级索引,事务操作支持也比较弱,各个系统支持的功能差异较大,没有统一的标准。另外,表格模型往往还支持无模式(schema-less)特性,也就是说,不需要预先定义每行包括哪些列以及每个列的类型,多行之间允许包含不同列。

SQL与NoSQL

关系数据库在海量数据场景面临如下挑战:

  • 事务 关系模型要求多个SQL操作满足ACID特性,所有的SQL操作要么全部成功,要么全部失败。在分布式系统中,如果多个操作属于不同的服务器,保证它们的原子性需要用到两阶段提交协议,而这个协议的性能很低,且不能容忍服务器故障,很难应用在海量数据场景。
  • 联表 传统的数据库设计时需要满足范式要求,例如,第三范式要求在一个关系中不能出现在其他关系中已包含的非主键信息。假设存在一个部门信息表,其中每个部门有部门编号、部门名称、部门简介等信息,那么在员工信息表中列出部门编号后就不能加入部门名称、部门简介等部门有关的信息,否则就会有大量的数据冗余。而在海量数据的场景,为了避免数据库多表关联操作,往往会使用数据冗余等违反数据库范式的手段。实践表明,这些手段带来的收益远高于成本。
  • 性能 关系数据库采用B树存储引擎,更新操作性能不如LSM树这样的存储引擎。另外,如果只有基于主键的增、删、查、改操作,关系数据库的性能也不如专门定制的Key-Value存储系统。

随着数据规模越来越大,可扩展性以及性能提升可以带来越来越明显的收益,而NoSQL系统要么可扩展性好,要么在特定的应用场景性能很高,广泛应用于互联网业务中。然而,NoSQL系统也面临如下问题:

  • 缺少统一标准 经过几十年的发展,关系数据库已经形成了SQL语言这样的业界标准,并拥有完整的生态链。然而,各个NoSQL系统使用方法不同,切换成本高,很难通用。
  • 使用以及运维复杂 NoSQL系统无论是选型,还是使用方式,都有很大的学问,往往需要理解系统的实现,另外,缺乏专业的运维工具和运维人员。而关系数据库具有完整的生态链和丰富的运维工具,也有大量经验丰富的运维人员。

总而言之,关系数据库很通用,是业界标准,但是在一些特定的应用场景存在可扩展性和性能的问题,NoSQL系统也有一定的用武之地。从技术学习的角度看,不必纠结SQL与NoSQL的区别,而是借鉴二者各自不同的优势,着重理解关系数据库的原理以及NoSQL系统的高可扩展性。

事务与并发控制

多个事务并发执行时,如果它们的执行结果和按照某种顺序一个接着一个串行执行的效果等同,这种隔离级别称为可串行化。可串行化是比较理想的情况,商业数据库为了性能考虑,往往会定义多种隔离级别。事务的并发控制一般通过锁机制来实现,锁可以有不同的粒度,可以锁住行,也可以锁住数据块甚至锁住整个表格。由于互联网业务中读事务的比例往往远远高于写事务,为了提高读事务性能,可以采用写时复制(Copy-On-Write,COW)或者多版本并发控制(Multi-Version ConcurrencyControl,MVCC)技术来避免写事务阻塞读事务。

原子性(Atomicity)

事务的原子性体现在事务对数据的修改,即要么全都执行,要么全都不执行,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态。

一致性(Consistency)

在事务开始之前和事务结束以后,数据库的完整性没有被破坏。写入的数据必须完全符合所有的预设约束、触发器、级联回滚等。

隔离性(Isolation)

数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括位提交读、提交读、可重复读和串行化。

持久性(Durability)

事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。

事务隔离(Transaction Isolation)级别

隔离性是手段,通过事务的隔离级别,解决数据在高并发下所产生的问题:

  • 脏读(Dirty Read):事务A读取了事务B未提交的数据,并在这个基础上又做了其他操作。
  • 不可重复读(Unrepeatable Read):事务A读取了事务 B 已提交的更改数据。
  • 幻读(Phantom Read):事务A读取了事务B已提交的新增数据。

未提交读

未提交读(READ UNCOMMITTED)是最低的隔离级别。允许「脏读」(dirty reads),事务可以看到其他事务“尚未提交”的修改。

提交读

在提交读(READ COMMITTED)级别中,基于锁机制并发控制的DBMS需要对选定对象的写锁一直保持到事务结束,但是读锁在SELECT操作完成后马上释放(因此“不可重复读”现象可能会发生,见下面描述)。和前一种隔离级别一样,也不要求“范围锁”。

可重复读

在可重复读(REPEATABLE READS)隔离级别中,基于锁机制并发控制的DBMS需要对选定对象的读锁(read locks)和写锁(write locks)一直保持到事务结束,但不要求“范围锁”,因此可能会发生“幻影读”。

可串行化

可串行化(Serializable)是最高的隔离级别。在基于锁机制并发控制的DBMS上,可串行化要求在选定对象上的读锁和写锁直到事务结束后才能释放。在SELECT的查询中使用一个“WHERE”子句来描述一个范围时应该获得一个“范围锁”(range-locks),这种机制可以避免“幻影读”现象。当采用不基于锁的并发控制时不用获取锁,但当系统检测到几个并发事务有写冲突时,只有其中一个是允许提交的。

并发控制

数据库锁

事务分为几种类型:读事务,写事务以及读写混合事务。相应地,锁也分为两种类型:读锁以及写锁,允许对同一个元素加多个读锁,但只允许加一个写锁,且写事务将阻塞读事务。这里的元素可以是一行,也可以是一个数据块甚至一个表格。事务如果只操作一行,可以对该行加相应的读锁或者写锁;如果操作多行,需要锁住整个行范围。

写时复制

互联网业务中读事务占的比例往往远远超过写事务,很多应用的读写比例达到6:1,甚至10:1。写时复制(Copy-On-Write,COW)读操作不用加锁,极大地提高了读取性能。

写时复制B+树执行写操作的步骤如下。

  1. 拷贝:将从叶子到根节点路径上的所有节点拷贝出来。
  2. 修改:对拷贝的节点执行修改。
  3. 提交:原子地切换根节点的指针,使之指向新的根节点。

如果读操作发生在第3步提交之前,那么,将读取老节点的数据,否则将读取新节点,读操作不需要加锁保护。写时复制技术涉及引用计数,对每个节点维护一个引用计数,表示被多少节点引用,如果引用计数变为0,说明没有节点引用,可以被垃圾回收。写时复制技术原理简单,问题是每次写操作都需要拷贝从叶子到根节点路径上的所有节点,写操作成本高,另外,多个写操作之间是互斥的,同一时刻只允许一个写操作。

多版本并发控制

除了写时复制技术,多版本并发控制,即MVCC(Multi-Version Concurrency Control),也能够实现读事务不加锁。MVCC对每行数据维护多个版本,无论事务的执行时间有多长,MVCC总是能够提供与事务开始时刻相一致的数据。

以MySQL InnoDB存储引擎为例,InnoDB对每一行维护了两个隐含的列,其中一列存储行被修改的“时间”,另外一列存储行被删除的“时间”,注意,InnoDB存储的并不是绝对时间,而是与时间对应的数据库系统的版本号,每当一个事务开始时,InnoDB都会给这个事务分配一个递增的版本号,所以版本号也可以被认为是事务号。对于每一行查询语句,InnoDB都会把这个查询语句的版本号同这个查询语句遇到的行的版本号进行对比,然后结合不同的事务隔离级别,来决定是否返回该行。

下面分别以SELECT、DELETE、INSERT、UPDATE语句来说明。

SELECT

对于SELECT语句,只有同时满足了下面两个条件的行,才能被返回:

  1. 行的修改版本号小于等于该事务号。
  2. 行的删除版本号要么没有被定义,要么大于事务的版本号。

如果行的修改或者删除版本号大于事务号,说明行是被该事务后面启动的事务修改或者删除的。在可重复读取隔离级别下,后开始的事务对数据的影响不应该被先开始的事务看见,所以应该忽略后开始的事务的更新或者删除操作。

INSERT

对新插入的行,行的修改版本号更新为该事务的事务号。

DELETE

对于删除,InnoDB直接把该行的删除版本号设置为当前的事务号,相当于标记为删除,而不是物理删除。

UPDATE

在更新行的时候,InnoDB会把原来的行复制一份,并把当前的事务号作为该行的修改版本号。

MVCC读取数据的时候不用加锁,每个查询都通过版本检查,只获得自己需要的数据版本,从而大大提高了系统的并发度。当然,为了实现多版本,必须对每行存储额外的多个版本的数据。另外,MVCC存储引擎还必须定期删除不再需要的版本,及时回收空间。

故障恢复

数据库运行过程中可能会发生故障,这个时候某些事务可能执行到一半但没有提交,当系统重启时,需要能够恢复到一致的状态,即要么提交整个事务,要么回滚。数据库系统以及其他的分布式存储系统一般采用操作日志(有时也称为提交日志,即Commit Log)技术来实现故障恢复。操作日志分为回滚日志(UNDO Log)、重做日志(REDO Log)以及UNDO/REDO日志。如果记录事务修改前的状态,则为回滚日志;相应地,如果记录事务修改后的状态,则为重做日志。

操作日志

为了保证数据库的一致性,数据库操作需要持久化到磁盘,如果每次操作都随机更新磁盘的某个数据块,系统性能将会很差。因此,通过操作日志顺序记录每个数据库操作并在内存中执行这些操作,内存中的数据定期刷新到磁盘,实现将随机写请求转化为顺序写请求。

重做日志

存储系统如果采用REDO日志,其写操作流程如下:
1)将REDO日志以追加写的方式写入磁盘的日志文件。
2)将REDO日志的修改操作应用到内存中。
3)返回操作成功或者失败。

REDO日志的约束规则为:在修改内存中的元素X之前,要确保与这一修改相关的操作日志必须先刷入到磁盘中。顾名思义,用REDO日志进行故障恢复,只需要从头到尾读取日志文件中的修改操作,并将它们逐个应用到内存中,即重做一遍。

为什么需要先写操作日志再修改内存中的数据呢?假如先修改内存中的数据,那么用户就能立刻读到修改后的结果,一旦在完成内存修改与写入日志之间发生故障,那么最近的修改操作无法恢复。然而,之前的用户可能已经读取了修改后的结果,这就会产生不一致的情况。

优化手段

成组提交

存储系统要求先将REDO日志刷入磁盘才可以更新内存中的数据,如果每个事务都要求将日志立即刷入磁盘,系统的吞吐量将会很差。因此,存储系统往往有一个是否立即刷入磁盘的选项,对于一致性要求很高的应用,可以设置为立即刷入;相应地,对于一致性要求不太高的应用,可以设置为不要求立即刷入,首先将REDO日志缓存到操作系统或者存储系统的内存缓冲区中,定期刷入磁盘。这种做法有一个问题,如果存储系统意外故障,可能丢失最后一部分更新操作。

成组提交(Group Commit)技术是一种有效的优化手段。REDO日志首先写入到存储系统的日志缓冲区中:

  • 日志缓冲区中的数据量超过一定大小,比如512KB;
  • 距离上次刷入磁盘超过一定时间,比如10ms。

当满足以上两个条件中的某一个时,将日志缓冲区中的多个事务操作一次性刷入磁盘,接着一次性将多个事务的修改操作应用到内存中并逐个返回客户端操作结果。与定期刷入磁盘不同的是,成组提交技术保证REDO日志成功刷入磁盘后才返回写操作成功。这种做法可能会牺牲写事务的延时,但大大提高了系统的吞吐量。

检查点

如果所有的数据都保存在内存中,那么可能出现两个问题:

  • 故障恢复时需要回放所有的REDO日志,效率较低。如果REDO日志较多,比如超过100GB,那么,故障恢复时间是无法接受的。
  • 内存不足。即使内存足够大,存储系统往往也只能够缓存最近较长一段时间的更新操作,很难缓存所有的数据。

因此,需要将内存中的数据定期转储(Dump)到磁盘,这种技术称为checkpoint(检查点)技术。系统定期将内存中的操作以某种易于加载的形式(checkpoint文件)转储到磁盘中,并记录checkpoint时刻的日志回放点,以后故障恢复只需要回放checkpoint时刻的日志回放点之后的REDO日志。

由于将内存数据转储到磁盘需要很长的时间,而这段时间还可能有新的更新操作,checkpoint必须找到一个一致的状态。checkpoint流程如下:

  1. 日志文件中记录“START CKPT”。
  2. 将内存中的数据以某种易于加载的组织方式转储到磁盘中,形成checkpoint文件。checkpoint文件中往往记录“START CKPT”的日志回放点,用于故障恢复。
  3. 日志文件中记录“END CKPT”。故障恢复流程如下:
    • 将checkpoint文件加载到内存中,这一步操作往往只需要加载索引数据,加载效率很高。
    • 读取checkpoint文件中记录的“START CKPT”日志回放点,回放之后的REDO日志。

上述checkpoint故障恢复方式依赖REDO日志中记录的都是修改后的结果这一特性,也就是说,即使checkpoint文件中已经包含了某些操作的结果,重新回放一次或者多次这些操作的REDO日志也不会造成数据错误。如果同一个操作执行一次与重复执行多次的效果相同,这种操作具有“幂等性”。有些操作不具备这种特性,例如,加法操作、追加操作。如果REDO日志记录的是这种操作,那么checkpoint文件中的数据一定不能包含“START CKPT”与“END CKPT”之间的操作。为此,主要有两种处理方法:

  • checkpoint过程中停止写服务,所有的修改操作直接失败。这种方法实现简单,但不适合在线业务。
  • 内存数据结构支持快照。执行checkpoint操作时首先对内存数据结构做一次快照,接着将快照中的数据转储到磁盘生成checkpoint文件,并记录此时对应的REDO日志回放点。生成checkpoint文件的过程中允许写操作,但checkpoint文件中的快照数据不会包含这些操作的结果。

数据压缩

数据压缩分为有损压缩与无损压缩两种,有损压缩算法压缩比率高,但数据可能失真,一般用于压缩图片、音频、视频;而无损压缩算法能够完全还原原始数据,本文只讨论无损压缩算法。早期的数据压缩技术就是基于编码上的优化技术,其中以Huffman编码最为知名,它通过统计字符出现的频率计算最优前缀编码。1977年,以色列人Jacob Ziv和Abraham Lempel发表论文《顺序数据压缩的一个通用算法》,从此,LZ系列压缩算法几乎垄断了通用无损压缩领域,常用的Gzip算法中使用的LZ77,GIF图片格式中使用的LZW,以及LZO等压缩算法都属于这个系列。设计压缩算法时不仅要考虑压缩比,还要考虑压缩算法的执行效率。Google Bigtable系统中采用BMDiff和Zippy压缩算法,这两个算法也是LZ算法的变种,它们通过牺牲一定的压缩比,换来执行效率的大幅提升。

压缩算法的核心是找重复数据,列式存储技术通过把相同列的数据组织在一起,不仅减少了大数据分析需要查询的数据量,还大大地提高了数据的压缩比。传统的OLAP(Online Analytical Processing)数据库,如Sybase IQ、Teradata,以及Bigtable、HBase等分布式表格系统都实现了列式存储。本节介绍数据压缩以及列式存储相关的基础知识。

压缩算法

压缩是一个专门的研究课题,没有通用的做法,需要根据数据的特点选择或者自己开发合适的算法。压缩的本质就是找数据的重复或者规律,用尽量少的字节表示。

Huffman编码

LZ系列压缩算法

BMDif与Zippy

列式存储

传统的行式数据库将一个个完整的数据行存储在数据页中。如果处理查询时需要用到大部分的数据列,这种方式在磁盘IO上是比较高效的。一般来说,OLTP(Online Transaction Processing,联机事务处理)应用适合采用这种方式。

数据分布

哈希分布

哈希取模

根据数据的某一特征计算哈希值,并将哈希值与集群中的服务器建立映射关系,从而将不同哈希值的数据分布到不同的服务器上。例如,将集群中的服务器按0到N-1编号(N为服务器的数量),根据数据的主键(hash(key)% N)或者数据所属的用户id(hash(user_id)% N)计算哈希值,来决定将数据映射到哪一台服务器。

哈希取模的问题

  • 如果按照主键散列,同一用户id下的数据可能被分散到多台服务器,这会是的一次操作一个用户id下的多条记录变得困难;
  • 如果按照用户id散列,容易出现“数据倾斜”问题,某些大用户的数据量很大,无论集群多大,这些用户始终有一台服务器处理;
  • 当服务器上下线是,N值发生变化,数据映射完全被打乱,几乎所有的数据都需要重新分布,这将带来大量的数据迁移。

解决办法

一致性哈希算法是其中一种思路:给系统中每个节点分配一个随机token,这些token构成一个哈希环。执行数据存放操作时,先计算Key(主键)的哈希值,然后存放到顺时针方向第一个大于或者等于该哈希值的token所在的节点。一致性哈希的优点在于节点加入/删除时只会影响到在哈希环中相邻的节点,而对其他节点没影响。

顺序分布

哈希散列破坏了数据的有序性,只支持随机读取操作,不能够支持顺序扫描。某些系统可以在应用层做折衷,比如互联网应用经常按照用户来进行数据拆分,并通过哈希方法进行数据分布,同一个用户的数据分布到相同的存储节点,允许对同一个用户的数据执行顺序扫描,由应用层解决跨多个用户的操作问题。另外,这种方式可能出现某些用户的数据量太大的问题,由于用户的数据限定在一个存储节点,无法发挥分布式存储系统的多机并行处理能力。

顺序分布在分布式表格系统中比较常见,一般的做法是将大表顺序划分为连续的范围,每个范围称为一个子表,总控服务器负责将这些子表按照一定的策略分配到存储节点上。如图3-3所示,用户表(User表)的主键范围为1~7000,在分布式存储系统中划分为多个子表,分别对应数据范围1~1000,1001~2000,…6001~7000。Meta表是可选的,某些系统只有根表(Root表)一级索引,在Root表中维护用户表的位置信息,即每个User子表在哪个存储节点上。为了支持更大的集群规模,Bigtable这样的系统将索引分为两级:根表以及元数据表(Meta表),由Meta表维护User表的位置信息,而Root表用来维护Meta表的位置信息。读User表时,需要通过Meta表查找相应的User子表所在的存储节点,而读取Meta表又需要通过Root表查找相应的Meta子表所在的存储节点。

负载均衡

分布式存储系统的每个集群中一般有一个总控节点,其他节点为工作节点,由总控节点根据全局负载信息进行整体调度。工作节点刚上线时,总控节点需要将数据迁移到该节点,另外,系统运行过程中也需要不断地执行迁移任务,将数据从负载较高的工作节点迁移到负载较低的工作节点。

工作节点通过心跳包(Heartbeat,定时发送)将节点负载相关的信息,如CPU,内存,磁盘,网络等资源使用率,读写次数及读写数据量等发送给主控节点。主控节点计算出工作节点的负载以及需要迁移的数据,生成迁移任务放入迁移队列中等待执行。

负载均衡需要执行数据迁移操作。在分布式存储系统中往往会存储数据的多个副本,其中一个副本为主副本,其他副本为备副本,由主副本对外提供服务。迁移备副本不会对服务造成影响,迁移主副本也可以首先将数据的读写服务切换到其他备副本。整个迁移过程可以做到无缝,对用户完全透明。

分布式文件系统

分布式文件系统主要由两个功能:一个是存储文档、图像、视频之类的Blob类型数据;另一个是作为分布式表格系统的持久化层。

GFS(Google File System)介绍

TFS(Taobao File System)介绍

Facebook Haystack介绍

CDN(内容分发网络)介绍

CDN通过将网络内容发布到靠近用户的边缘节点,使不同地域的用户在访问相同网页时可以就近获取。这样既可以减轻源服务器的负担,也可以减少整个网络中的流量分布不均的情况,进而改善整个网络性能。所谓的边缘节点是CDN服务提供商经过精心挑选的距离用户非常近的服务器节点,仅“一跳”(Single Hop)之遥。用户在访问时就无需再经过多个路由器,大大减少访问时间。

从图可以看出,DNS在对域名解析时不再向用户返回源服务器的IP,而是返回了由智能CDN负载均衡系统选定的某个边缘节点的IP。用户利用这个IP访问边缘节点,然后该节点通过其内部DNS解析得到源服务器IP并发出请求来获取用户所需的页面,如果请求成功,边缘节点会将页面缓存下来,下次用户访问时可以直接读取,而不需要每次都访问源服务器。
用户访问CDN的整体流程

CDN架构

淘宝CDN系统用于支持用户购物,尤其是“双11”光棍节时的海量图片请求。如图所示,图片存储在后台的TFS集群中,CDN系统将这些图片缓存到离用户最近的边缘节点。CDN采用两级Cache:L1-Cache以及L2-Cache。用户访问淘宝网的图片时,通过全局调度系统(Global Load Balancing)调度到某个L1-Cache节点。如果L1-Cache命中,那么直接将图片数据返回用户;否则,请求L2-Cache节点,并将返回的图片数据缓存到L1-Cache节点。如果L2-Cache命中,直接将图片数据返回给L1-Cache节点;否则,请求源服务器的图片服务器集群。每台图片服务器是一个运行着Nginx的Web服务器,它还会在本地缓存图片,只有当本地缓存也不命中时才会请求后端的TFS集群,图片服务器集群和TFS集群部署在同一个数据中心内。
淘宝网CDN整体架构
对于每个CDN节点,其架构如图所示。从图中可以看出,每个CDN节点内部通过LVS+Haproxy的方式进行负载均衡。其中,LVS是四层负载均衡软件,性能好;Haproxy是七层负载均衡软件,能够支持更加灵活的负载均衡策略。通过有机结合两者,可以将不同的图片请求调度到不同的Squid服务器。

Squid服务器用来缓存Blob图片数据。用户的请求按照一定的策略发送给某台Squid服务器,如果缓存命中则直接返回;否则,Squid服务器首先会请求源服务器获取图片缓存到本地,接着再将图片数据返回给用户。数据通过一致性哈希的方式分布到不同的Squid服务器,使得增加/删除服务器只需要移动1/n(n为Squid服务器总数)的对象。
淘宝网单个CDN节点架构
相比分布式存储系统,分布式缓存系统的实现要容易很多。这是因为缓存系统不需要考虑数据持久化,如果缓存服务器出现故障,只需要简单地将它从集群中剔除即可。

  1. 分级存储 分级存储是淘宝CDN架构的一个很大创新。由于缓存数据有较高的局部性,在Squid服务器上使用SSD+SAS+SATA混合存储,图片随着热点变化而迁移,最热门的存储到SSD,中等热度的存储到SAS,轻热度的存储到SATA。通过这样的方式,能够很好地结合SSD的性能和SAS、SATA磁盘的成本优势。

  2. 低功耗服务器定制 淘宝CDN架构的另外一个亮点是低功耗服务器定制。CDN缓存服务是IO密集型而不是CPU密集型的服务,因此,选用Intel Atom CPU定制低功耗服务器,在保证服务性能的前提下大大降低了整体功耗。

分布式键值系统

分布式键值模型可以看成是分布式表格模型的一种特例。然而,由于它只支持针对单个key-value的增、删、查、改操作,因此,适用前文提到的哈希分布算法。

Amazon Dynamo

淘宝Tair

分布式表格系统

分布式表格系统对外提供表格模型,每个表格由很多行组成,通过主键唯一标识,每一行包含很多列。整个表格在系统中全局有序,适用前文讲的顺序分布。

Google Bigtable

Google Megastore

Windows Azure Storage

分布式数据库

MySQL Sharding

Microsoft SQL Azure

Google Spanner

名词解释

一致性哈希(Distributed Hash Table,DHT)

适应条件

一致性哈希提出了在动态变化的Cache环境中,哈希算法应该满足的4个适应条件:

  • 均衡性(Balance) 哈希的结果能够尽可能分布到所有的缓冲中去,这样可以使得所有的缓冲空间都得到利用。
  • 单调性(Monotonicity) 单调性是指如果已经有一些内容通过哈希分派到了相应的缓冲中,又有新的缓冲区加入到系统中,那么哈希的结果应能够保证原有已分配的内容可以被映射到新的缓冲区中去,而不会被映射到旧的缓冲集合中的其他缓冲区。当缓冲区大小变化时一致性哈希尽量保护已分配的内容不会被重新映射到新缓冲区。
  • 分散性(Spread) 在分布式环境中,终端有可能看不到所有的缓冲,而是只能看到其中的一部分。当终端希望通过哈希过程将内容映射到缓冲上时,由于不同终端所见的缓冲范围有可能不同,从而导致哈希的结果不一致,最终的结果是相同的内容被不同的终端映射到不同的缓冲区中。这种情况显然是应该避免的,因为它导致相同内容被存储到不同缓冲中去,降低了系统存储的效率。分散性的定义就是上述情况发生的严重程度。好的哈希算法应能够尽量避免不一致的情况发生,也就是尽量降低分散性。
  • 负载(Load) 负载问题实际上是从另一个角度看待分散性问题。既然不同的终端可能将相同的内容映射到不同的缓冲区中,那么对于一个特定的缓冲区而言,也可能被不同的用户映射为不同的内容。与分散性一样,这种情况也是应当避免的,因此好的哈希算法应能够尽量降低缓冲的负荷。

设计

  • 环形哈希空间 一致性哈希算法通过一个叫作一致性哈希环的数据结构实现。这个环的起点是 0,终点是 2^32 - 1,并且起点与终点连接,故这个环的整数分布范围是 [0, 2^32-1]。
  • 映射服务器节点 将各个服务器使用Hash进行一个哈希,具体可以选择服务器的ip或唯一主机名作为关键字进行哈希,这样每台机器就能确定其在哈希环上的位置。
  • 映射数据 现在我们将objectA、objectB、objectC、objectD四个对象通过特定的Hash函数计算出对应的key值,然后散列到Hash环上,然后从数据所在位置沿环顺时针“行走”,第一台遇到的服务器就是其应该定位到的服务器。
  • 服务器的删除与添加 如果此时NodeC宕机了,此时Object A、B、D不会受到影响,只有Object C会重新分配到Node D上面去,而其他数据对象不会发生变化。如果在环境中新增一台服务器Node X,通过hash算法将Node X映射到环中,通过按顺时针迁移的规则,那么Object C被迁移到了Node X中,其它对象还保持这原有的存储位置。通过对节点的添加和删除的分析,一致性哈希算法在保持了单调性的同时,还是数据的迁移达到了最小,这样的算法对分布式集群来说是非常合适的,避免了大量数据迁移,减小了服务器的的压力。
  • 虚拟节点 到目前为止一致性hash也可以算做完成了,但是有一个问题还需要解决,那就是平衡性。当服务器节点比较少的时候,会出现一个问题,就是此时必然造成大量数据集中到一个节点上面,极少数数据集中到另外的节点上面。为了解决这种数据倾斜问题,一致性哈希算法引入了虚拟节点机制,即对每一个服务节点计算多个哈希,每个计算结果位置都放置一个此服务节点,称为虚拟节点。具体做法可以先确定每个物理节点关联的虚拟节点数量,然后在ip或者主机名后面增加编号。例如上面的情况,可以为每台服务器计算三个虚拟节点,于是可以分别计算 “Node A#1”、“Node A#2”、“Node A#3”、“Node B#1”、“Node B#2”、“Node B#3”的哈希值,于是形成六个虚拟节点;同时数据定位算法不变,只是多了一步虚拟节点到实际节点的映射,例如定位到“Node A#1”、“Node A#2”、“Node A#3”三个虚拟节点的数据均定位到Node A上。这样就解决了服务节点少时数据倾斜的问题。每个物理节点关联的虚拟节点数量就根据具体的生产环境情况在确定。

B+ Tree

写时复制(Copy-On-Write,COW)

多版本并发控制(Mutli-Version Concurrency Control, MVCC)

Paxos算法

一种基于消息传递且具有高度容错性的共识(consensus)算法。Apache Zookeeper使用一个类Multi-Paxos的共识算法作为底层存储协同的机制。

二阶段提交(Two-phase Commit)

二阶段提交是指在计算机网络以及数据库领域内,为了使基于分布式系统架构下的所有节点在进行事务提交时保持一致性而设计的一个演算法。通常,二阶段提交也被称为一种协议。在分布式系统中,每个节点虽然可以知晓自己的操作是成功或失败,却无法知道其他节点的操作是成功或失败。当一个事务跨越多个节点时,为了保持事务的ACID特性,需要引入一个作为协调者的组件来统一掌控所有节点(成为参与者)的操作结果并最终指示这些节点是否要把操作结果进行真正的提交。因此,二阶段提交的算法思路可以概括为:参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是终止操作。

需要注意的是,二阶段提交(2PC)不应该与并发控制中的二阶段锁(2PL)混淆。

以下对二阶段提交算法分阶段进行说明。

第一阶段(提交请求阶段)

  1. 协调者节点向所有参与者节点询问是否可以执行提交操作,并开始等待各参与者节点响应。
  2. 参与者节点执行询问发起未知的所有事务操作,并将Undo信息和Redo信息写入日志。
  3. 各参与者节点响应协调者节点发起的询问。如果参与者节点的事务操作实际执行成功,则返回一个“同意”消息;否则返回一个“终止”消息。

第二阶段(提交执行阶段)

成功

当协调者从所有参与者节点获得的响应消息都为“同意”时:

  1. 协调者节点向所有参与者节点发出“正式提交”请求。
  2. 参与者节点正式完成操作,并释放在整个事务期间内占用的资源。
  3. 参与者节点向协调者节点发送“完成”消息。
  4. 协调者节点收到所有参与者节点反馈的“完成”消息后,完成事务。

失败

如果任意参与者节点在第一阶段返回的响应消息为“终止”,或者协调者节点在第一阶段的询问超时之前无法获得所有参与者节点的响应消息时:

  1. 协调者节点向所有参与者节点发出“回滚操作”请求。
  2. 参与者节点利用之前写入Undo信息执行回滚,并释放在整个事务期间内占用的资源。
  3. 参与者节点向协调者节点发送“回滚完成”消息。
  4. 协调者节点收到所有参与者节点反馈的“回滚完成”消息后,取消事务。

缺点

二阶段提交算法的最大缺点就在于它的执行过程中间,节点都处于阻塞状态。另外,协调者节点只是参与者节点进行提交等操作时,如有参与者节点出现了崩溃等情况而导致协调者始终无法获取参与者的响应信息,这是协调者将只能依赖协调者自身的超时机制来生效。但往往超时机制生效时,协调者都会指示参与者进行回滚操作。

三阶段提交(Three-phase Commit)

三阶段提交也叫三阶段提交协议,是在计算机网络及数据库的范畴下,使得一个分布式系统内的所有节点能够执行事务提交的一种分布式算法。三阶段提交是为了解决二阶段提交协议的缺点二设计的。

与二阶段提交不同的是,三阶段提交是“非阻塞”协议。三阶段提交在二阶段提交的第一阶段与第二阶段之间插入了一个准备阶段,使得原先在两阶段提交中,参与者在投票之后,由于协调者发生崩溃或者错误,而导致参与者处于无法知晓是否提交或者中止的“不确定状态”所产生的可能相当长的延时的问题得以解决。

拜占庭将军问题

起源

拜占庭位于如今的土耳其的伊斯坦布尔,是东罗马帝国的首都。由于当时拜占庭罗马帝国国土辽阔,为了达到防御目的,每个军队都分隔很远,将军与将军之间只能靠信差传消息。在战争的时候,拜占庭军队内所有将军和副官必须达成一致的共识,决定是否有赢的机会才去攻打敌人的阵营。但是,在军队内有可能存有叛徒和敌军的间谍,左右将军们的决定又扰乱整体军队的秩序。在进行共识时,结果并不代表大多数人的意见。这时候,在已知有成员谋反的情况下,其余忠诚的将军在不受叛徒的影响下如何达成一致的协议,拜占庭问题就此形成。

简介

拜占庭将军问题是一个协议问题,拜占庭帝国军队的将军们必须全体一致的决定是否攻击某一支敌军。问题是这些将军在地理上是分隔开来的,并且将军中存在叛徒。叛徒可以任意行动以达到以下目标:欺骗某些将军采取进攻行动;促成一个不是所有将军都同意的决定,如当将军们不希望进攻时促成进攻行动;或者迷惑某些将军,使他们无法做出决定。如果叛徒达到了这些目的之一,则任何攻击行动的结果都是注定要失败的,只有完全达成一致的努力才能获得胜利。

拜占庭假设是对现实世界的模型化,由于硬件错误、网络拥塞或断开以及遭到恶意攻击,计算机和网络可能出现不可预料的行为。在互联网大背景下,当需要与不熟悉的对方进行价值交换活动时,人们如何才能防止不会被其中的恶意破坏者欺骗、迷惑从而作出错误的决策。进一步将“拜占庭将军问题”延伸到技术领域中来,其内涵可概括为:在缺少可信任的中央节点和可信任的通道的情况下,分布在网络中的各个节点应如何达成共识。

问题

在中本聪发明比特币以前,世界上并没有一个非常完美的方法来解决“拜占庭将军问题”。
究其根底,“拜占庭将军问题”最终想解决的是互联网交易、合作过程中的四个问题:

  1. 信息发送的身份追溯;
  2. 信息的私密性;
  3. 不可伪造的签名;
  4. 发送信息的规则。

“拜占庭将军问题”其实就是网络世界的模型化。

解决方法

区块链轻而易举地解决了这一问题,它为信息发送加入了成本,降低了信息传递的速率,而且加入了一个随机元素使得在一定时间内只有一个将军可以广播信息。这里所说的成本就是区块链系统中基于随机哈希算法的“工作量证明”。哈希算法所做的事情就是计算获得的输入,得到一串64位的随机数字和字母的字符串。哈希算法对信息传递速率的限制加上加密工具使得区块链构成了一个无须信任的数据交互系统。在区块链上,一系列的交易、时间约定、域名记录、政治投票系统或者任何其他需要建立分布式协议的地方,参与者都可以达成一致。

参考

  • 大规模分布式存储系统:原理解析与架构实践(杨传辉编著)

一文读懂Java

本文记录学习Java过程中的知识点。为什么会有这篇文章呢?因为在网上总是看到这要的面试题:

  • Java的类加载机制是什么?
  • JVM的内存模型是什么?

我不禁要问,这些问题和我写代码有什么关系?我平时不知道这些不是照样写代码吗?所以写篇文章来研究一下这些问题和我们平常写代码都有写什么关系。

类加载器

类加载器就是用来加载我们写的Java代码,JVM组成结构之一就是类加载子系统

通过下面这个流程图,了解一下我们写好的Java代码是怎么执行的。其中要经历类加载器这个流程。Java代码执行流程图

类加载子系统

类加载系统架构图

类加载系统架构图

类的生命周期

类的生命周期包括:加载、链接、初始化、使用、卸载。其中加载链接初始化属于类加载的过程。

类加载器严格按顺序执行以下三个基础任务:

  1. 加载:寻找并导入特定类型的二进制数据;
  2. 链接:执行验证、准备及(可选)解析操作;
    • 验证:确保导入类型的正确性。
    • 准备:为类变量分配内存并初始化内存为默认值。
    • 解析:将类型的符号引用转化为直接引用。
  3. 初始化:调用初始化类变量至正确起始值的Java代码。

类的加载过程

类的加载机制

双亲委派机制

反向委派机制

第三方包加载方式就是使用反向委派机制。

沙箱安全机制

类加载器分类

启动类加载器:Bootstrap ClassLoader

这个类加载器使用C/C++实现,嵌套在JVM内部,Java程序无法直接操作这个类,它用来加载Java核心类库。如:JAVA_HOME/jre/lib/rt.jarresources.jarsun.boot.class.path路径下的包,用于提供JVM运行所需要的包。

它没有父加载器,它加载扩展类加载器应用程序类加载器,并成为他们的父类加载器。出于安全考虑,启动类只加载包名为:javajavaxsun开头的类。

扩展类加载器:Extension ClassLoader

Java语言编写,由sun.misc.Launcher$ExtClassLoader实现,可以使用Java程序操作这个加载器,派生继承自java.lang.ClassLoader,父加载器为启动类加载器。

从系统属性java.ext.dirs目录中加载类库,或者从JDK安装目录jre/lib/ext目录中加载类库。可以把我们自己的包放在以下目录,就会自动加载进来。

应用程序类加载器:Application ClassLoader

Java语言编写,由sun.misc.Launcher$AppClassLoader实现。派生继承自java.lang.ClassLoader,父类加载器为启动类加载器。

它负责加载环境变量classpath或者系统属性java.class.path指定路径下的类库,它是程序中的默认的类加载器,我们Java程序中的类,都是由它加载完成的。可以通过ClassLoader#getSystemClassLoader()获取并操作这个加载器。

用户定义类加载器:User Defined ClassLoader

一般情况下,以上3种加载器能满足我们日常的开发工作,不满足是可以自定义类加载器。比如用网络加载Java类,为了保证传输中的安全性,还需采用加密操作。那么上面3种类加载器就无法加载这个类,这就需要用户定义类加载器。

用户定义类加载器实现步骤:

继承java.lang.ClassLoader,重写findClass()方法;如果没有太复杂的需求,可以直接继承URLClassLoader,重写loadClass()方法;具体可以参考AppClassLoaderExtClassLoader

获取ClassLoader几种方式:

1
2
3
4
5
6
7
8
// 方式一:获取当前类的 ClassLoader
clazz.getClassLoader();
// 方式二:获取当前线程上下文的 ClassLoader
Thread.currentThread().getContextClassLoader();
// 方式三:获取系统的 ClassLoader
ClassLoader.getSystemClassLoader();
// 方式四:获取调用者的 ClassLoader
DriverManager.getCallerClassLoader();

垃圾回收

目前的内存分配策略于垃圾回收技术已经相对成熟,为什么要去了解垃圾回收(Garbage Collection)呢?答案很简单:当需要排查各种问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些技术进行必要的监控、调节。

Java内存运行时区域的各个部分,其中程序计数器VM栈本地方法栈三个区域随线程而生,随线程而灭;栈中的帧随着方法的进入、退出而有条不紊的进行着出入栈操作;每一个帧中分配多少内存基本上在Class文件生成时就已知,可能会由JIT动态晚期编译进行一些优化,但大体上可以认为是编译期可知;因此这几个区域的内存分配和回收具备很高的确定性,不需要过多考虑回收的问题。而 Java堆方法区(包括运行时常量池)则必须等到程序实际运行期间才能知道会创建哪些对象,这部分的分配和回收都是动态的。

里面存放着Java世界大部分对象,在回收前要确定这些对象之中哪些还存活,哪些已经不再被任何途径使用的对象。

引用计数法(Reference Counting)

存在引用时计数器加1,引用失效后计数器减1,任何时刻计数器为0的对象就是可以回收的对象。但引用计数法无法解决对象循环引用的问题。

根搜索算法(GC Roots Tracing)

在实际生产的语言中(Java、C#、Lisp),都是根据根搜索算法判定对象是否存活。基本思路就是通过一系列称为“GC Roots”的点作为起始进行向下搜索,当一个对象到GC Roots没有任何引用链(Reference Chain)相连,则证明此对象可以回收。

在Java语言中,GC Roots包括:

  1. VM栈(帧中的本地变量)中的引用。
  2. 方法区中的静态引用。
  3. JNI(即一般说的Native方法)中的引用。

判定一个对象被回收,至少经历两次标记过程:如果对象在进行根搜索后,发现没有于GC Roots相连接的引用,将会第一次标记,并在稍后执行它的finalize()方法(如果它有的话)。这里说的执行是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。这点是必须的,否则一个对象在finalize()方法执行缓慢,甚至有死循环的将会很容易导致整个系统崩溃。finalize()方法是对象被回收的最后一次机会,稍后GC将进行第二次规模稍小的标记,如果在对象在 finalize()中被重新引用,那第二次标记时它将被移除即将回收的集合,否则将被回收。

关于方法区,方法区即永久代,很多人认为永久代是没有GC的,Java虚拟机规范中不要求虚拟机在方法区实现GC,而且方法区GC的性价比一般比较低:在堆中,尤其是在新生代,常规应用进行一次GC可以回收70%-95%的空间,而永久代的GC效率远小于此。虽然虚拟机规范不要求,但当前生产中的商业JVM都有实现永久代的GC,主要回收两部分内容:废弃常量无用类。回收思想与Java堆中的对象回收很类似,都是搜索是否存在引用,常量相对简单,与对象类似的判定即可。而类的回收则比较苛刻,需要满足下面3个条件:

  1. 该类所有的实例都已经被GC,JVM中不存在该类的任何实例。
  2. 加载该类的ClassLoader已经被GC。
  3. 该类对象的java.lang.Class对象没有在任何地方被引用。如不能在任何地方通过反射访问该类的方法。

在大量使用反射、动态代理、CGLib等bytecode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要JVM具备类卸载的支持,以保证永久代不会溢出。

标记-清除算法(Mark-Sweep)

算法分标记清除两个阶段,首先标记出所有需要回收的对象,然后回收所有需要回收的对象。它是最基础的垃圾收集算法,后续算法都是基于这种思路,优化其缺点得到的。它的主要缺点有两个:一是效率问题,标记和清理两个过程效率都不高;二是空间问题,标记清理之后产生大量不连续的内存碎片,空间碎片大多可能会导致后续使用中无法找到足够的连续内存而提前触发另一次的垃圾搜集动作。

复制搜集算法(Copying)

为了解决标记-清理算法的效率问题,一种称为“复制”的搜集算法出现,它将可用内存划分为两块,每次只使用其中一块,当半区内存用完了,仅将还存活的对象复制到另外一块上面,然后把原来整块内存空间清理掉。这样使得每次内存回收都是对整个半区的回收,内存分配时也不用考虑内存碎片等复杂情况。但是代价时将内存缩小为原来的一半。

IBM有研究表明新生代中98%的对象是朝夕生死的,所以不需要按照1:1的比例来划分内存空间,而是将内存分为1块较大的eden空间和2块较少的survivor空间,每次使用eden和其中1块survivor,当回收时将eden和survivor还存活的对象一次性拷贝到另一块survivor空间上,然后清理掉eden和用过的survivor。现在的商业虚拟机都是用这种收集算法来回收新生代。Sun Hotspot的虚拟机默认eden和survivor的大小比例是8:1,也就是每次只有10%的内存是“浪费”的。当然,98%的对象可回收这是一般场景下的数据,没办法保证每次回收都只有10%以内的对象存活,当survivor空间不够用是,需要依赖其他内存(比如老年代)进行分配担保(Handle Promotion)。

标记-整理算法:(Mark-Compact)

此算法结合了“标记-清除”和“复制”两个算法的优点。也是分两阶段,第一阶段从根节点开始标记所有被引用对象,第二阶段遍历整个堆,把清除未标记对象及存活对象“压缩”到堆的其中一块,按顺序排放。此算法避免了“标记-清除”的碎片问题,同时也避免了“复制”算法的空间问题。

CMS:(Concurrent Mark Sweep)

为什么要分代

分代的垃圾回收策略,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采用不同的收集方式,以便提高回收效率。

试想,在不进行对象存活时间区分的情况下,每次垃圾回收都是对整个堆空间进行回收,花费时间相对会长,同时,因为每次回收都需要遍历所有存活对象。但实际上,对于生命周期长的对象而言,这种遍历是没有效果的,因为遍历很多次,它们依旧存在。因此,分代垃圾回收采用分治的思想,进行代的划分,把不同生命周期的对象放在不同代上,不同代采用最合适的方式进行垃圾回收。

如何分代

分代示意图

虚拟机中共分为三个代:年轻代(Young Generation)、年老代(Old Generation)和持久代(Permanent Generation)。其中持久代主要存放的是Java类的类信息,与垃圾收集要收集的Java对象关系不大。年轻代和年老代的划分是对垃圾收集影响比较大的。

年轻代:所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。年轻代分三个区。一个Eden区,两个Survivor区(一般而言)。大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到Survivor区(两个中的一个),当这个Survivor区满时,此区的存活对象将被复制到另外一个Survivor区,当这个Survivor去也满了的时候,从第一个Survivor区复制过来的并且此时还存活的对象,将被复制“年老区(Tenured)”。需要注意,Survivor的两个区是对称的,没先后关系,所以同一个区中可能同时存在从Eden复制过来 对象,和从前一个Survivor复制过来的对象,而复制到年老区的只有从第一个Survivor去过来的对象。而且,Survivor区总有一个是空的。同时,根据程序需要,Survivor区是可以配置为多个的(多于两个),这样可以增加对象在年轻代中的存在时间,减少被放到年老代的可能。

年老代:在年轻代中经历N次垃圾回收后仍然存活的对象,就会放在年老代中。

持久代:用户存放静态文件,如Java类、方法等。持久代对垃圾回收没有显著影响。但是有些应用可能动态生成或者调用一些class,例如Hibernate等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。持久代大小通过-XX:MaxPermSize=进行设置。

何时触发垃圾回收

由于对象进行了分代处理,触发GC运行的条件要分新生代和老生代的情况来进行讨论,GC有两种类型:Scavenge GC和Full GC。

Scavenge GC:一般情况下,当新对象生成,并且在Eden申请空间失败是,就会触发Scavenge GC。

Full GC:对整个堆进行清理,包括Young、Tenured和Perm。Full GC因为需要对整个堆进行回收,所以比Scavenge GC要慢,因此应该尽可能减少Full GC的次数。对JVM调优的过程,很大一部分工作就是对Full GC的调节。

有以下几点会触发GC:

  • 当Eden区和From Survivor区满时;
  • 调用System.gc时,系统建议建议执行Full GC,但不必然执行;
  • 年老代空间不足;
  • 方法区(持久代)空间不足;
  • 通过Minor GC后进入老年代的平均大小大于老年代的可用内存;
  • 有Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。

JVM调优工具

  • JConsole:JDK自带,功能简单
  • JProfiler:商业软件,功能强大。
  • VisualVM:JDK6-8自带,JDK9后不再自带,功能强大,与JProfiler类似。

内存管理

运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干不同的数据区域。这些区域有各自不同的用途,以及创建和销毁时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。根据Java虚拟机规范规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域。
JVM运行时数据区域

程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,他可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过这个计数器的值来取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。每个线程都拥有独立的程序计数器。各线程之间计数器互不影响,独立存储,这类内存区域为”线程私有“的内存。此区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

Java虚拟机栈

与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧从入栈到出栈的过程。

经常有人把Java内存区域笼统的划分为堆(Heap)内存和栈(Stack)内存,栈通常就是指虚拟机栈,或者更多的情况下只是指虚拟机栈中局部变量表部分。局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、long、float、double、对象引用(reference类型,不等于对象本身,可能是指向一个代表对象起始地址的引用地址,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向一条字节码指令的地址)。

这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的long和double类型会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧栈中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。这里说的“大小”是指变量槽的数量,虚拟机真正使用多大的内存空间(譬如按照1个变量槽占用32个比特、64个比特或者更多)来实现一个变量槽,完成有具体的虚拟机实现自行决定。

在Java虚拟机规范中,对这个内存区域规定了两类异常状况:如果线程的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展无法申请到足够的内存会抛出OutOfMemoryError异常。

本地方法栈

本地方法栈(Native Method Stacks)与与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

Java虚拟机规范对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(譬如Hot-Spot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会栈深度溢出或者栈扩展失败时分别抛出StackOverflowErrorOutOfMemoryError异常。

Java堆

对于Java应用程序来说,Java堆(Heap)是虚拟机所管理内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存。在Java虚拟机规范中对Java堆的描述是:“所有的对象实例以及数组都应当在堆上分配”,这里的“几乎”是指从现实角度来看,随着Java语言的发展,现在已经能看到些许迹象表明日后可能出现值类型的支持,及时只考虑现在,由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已经导致一些微妙的变化悄然发生,所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。

Java堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作“GC堆”(Garbage Collected Heap)。从回收内存的角度看,由于现代垃圾收集器都是基于分代收集理论设计的,所以Java堆中经常会出现“新生代”、“年老代”、“永久代”、“Eden空间”、“From Survivor空间”、“To Survivor空间”等名词,这些区域的划分仅是一部分垃圾收集器的共同特性或者设计风格而已,不是Java虚拟机规范对Java堆的划分。

如果从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓存区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。不过无论从什么角度,无论如何划分,都不会改变Java堆中存储内容的共性,无论哪个区域,存储的都是对象的实例,将Java堆细分的目的只是为了更好的回收内存,或者更快的分配内存。

根据Java虚拟机规范,Java堆可以处于物理上不连续的内存空间中,但逻辑上应该被视为连续的,这点有点像用磁盘空间去存储文件一样,并不要求每个文件都是连续存放。但对于大对象(典型的如数组对象),多数虚拟机实现处于简单实现、存储高效的考虑,很多可能会要求连续的内存空间。

Java堆既可以被实现成固定大小的,也可以是可扩展的,不多当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

方法区

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然Java虚拟机规范中把方法区描述为堆的一个逻辑部分,它还有一个别名叫做“非堆”(Non-Heap),目的是与Java堆区分开来。

说到方法区,不得不提以下“永久代”这个概念,尤其是在JDK 8以前,很多人把方法区和永久代混为一谈。当时仅仅HotSpot虚拟机选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已。对于其他虚拟机实现(譬如IBM J9)是不存在永久代概念的。

Java虚拟机规范对方法区的约束是非常宽松的,除了和Java堆一样不需要连续的内存空间和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域的确实比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件非常苛刻,但是这部分区域的回收有时又确实是必要的。以前Sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。

根据Java虚拟机规范的规定,如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

运行时常量池相对于Class文件常量池的另一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用的比较多的便是String类的intern()方法。

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这个部分内存也被频繁的使用,而且也可能导致OutOfMemoryError异常出现。

在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

显然,本机直接内存的分配不会收到Java堆大小的限制,但是受本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存区设置-Xmx等参数信息,但经常忽略直接内存,是的各个内存区域总和大于物理内存限制(包括物理内存和操作系统内存的限制),从而导致动态扩展时出现OutOfMemoryError异常。

前端web方向面试题

基础题

  • HTML5有什么新特性、新标签?CSS3(CSS4)有什么新特性?

  • CSS中的选择器有哪些?它们权重(优先级)的顺序是什么?

  • 如何理解CSS中的盒子模型?Flex如何布局?

  • less、sass、stylus三者的区别?

  • ECMAScript2015~2019有什么新特性?

  • Javascript的基本数据类型有哪些?Set、Map的区别是什么?

  • 为什么要组件化(模块化)开发?如何实现组件化(模块化)开发?

  • 详细介绍Javascript、Typescript、React、Vue、微信、Flutter、IOS、Android开发?(选其中2项详细介绍)

  • HTTP常见的状态码、请求头有哪些?RESTful常用方法有哪些?

  • Git常用命令有哪些?webpack如何配置?

  • 一个常见的前后端交互发生了什么?比如访问:https://qq.com,越详细越好。

  • CSS、HTML、Javascript如何性能优化?

  • React、Vue如何实现SSR(服务端渲染)?

  • 是否了解nodejs、Java开发?熟悉哪些方面?

  • 如何实现分页、菜单级联如何实现?后端同学如何提供数据?(详见视频)

加分题

  • 平时喜欢读哪些技术方面的书籍?前端方面、其他技术栈或是其他?

Java方向面试题

基础题

  1. Java 9及以上版本有什么新特性?

  2. 是否熟悉前端ES6(ECMAScript)、Typescript、React、Vue、微信、Flutter、IOS、Android开发?(选其中2项详细介绍)

  3. 面向对象的特征有哪些?抽象类和接口有什么区别?

  4. Java如何进行异常处理?关键字:throws、throw、try、catch、finally分别如何使用?

  5. Java有哪些方式实现网络通信?

  6. 主键、唯一索引、索引、联合索引的区别是什么?左连接、右连接、内连接、外连接的区别是什么?工作中是如何优化SQL查询的?

  7. Mybtais中#$有什么区别?Mybatis元素(标签)如何实现1..1(一对一)1..N(一对多)映射查询?

  8. Git常用命令有哪些?为了减少应用的大小,Maven如何排除多级依赖中不使用的Jar包?

  9. 如何理解Spring的依赖注入、控制反转?Spring Framework包含哪些内容?

  10. Spring MVC注解@RequestBoby、@ResponseBody有什么区别?什么注解可以校验用户输入?如何接收用户文件上传?

  11. HTTP常见的状态码、请求头有哪些?RESTful API方法有哪些?Spring MVC注解如何实现RESTful API?

  12. Spring Cloud是通过哪些组件治理微服务的?工作中如何使用Spring Cloud?

  13. 描述一下Redis有哪些数据结构?工作中是如何使用的?

  14. 熟悉哪些设计模式?选择3个,介绍一下他们适合哪些场景?

  15. Kafka(或其他消息中间件)可以做什么?工作中是如何使用的?

  16. 是否熟悉Elastic Stack?简单介绍一下它们的作用?

加分题

  1. 平时喜欢读哪些技术方面的书籍?Java方面、其他技术栈、区块链、人工智能或是其他?

  2. 是否熟悉Kubernetes、Istio?Kubernetes、Istio包含什么组件,组件之间是如何工作的?

Spring-Boot-with-Docker

本文介绍如何构建Spring Boot应用的Docker镜像。Docker是一个具有“社交”特性的linux容器管理的工具箱,允许用户发布和使用他人发布的容器镜像,一个Docker镜像就是一个容器化进程,本文介绍如何构建一个Spring Boot应用镜像。

基本的Dockerfile

一个Spring Boot应用很容易制作一个可执行的JAR文件,比如Maven可以使用mvn install,Gradle可以使用gradle build构建,制作可执行JAR的一个基本的Dockerfile类似这样,文件放在项目的顶级目录:

1
2
3
4
5
FROM openjdk:8-jdk-alpine
VOLUME /tmp
ARG JAR_FILE
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

JAR_FILE可以作为docker命令的一部分作为参数传入(对于Maven、Gradle是不同的),比如对于Maven:

$ docker build --build-arg=target/*.jar -t myorg/myapp .

对于Gradle:

$ docker build --build-arg=build/libs/*.jar -t myorg/myapp .

当然,一旦确定了构建系统,你可以不需要ARG,直接硬编码。比如对于Maven:

1
2
3
4
FROM openjdk:8-jdk-alpine
VOLUME /tmp
COPY target/*.jar app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

然后可以简化构建命令:

$ docker build -t myorg/myapp .

运行镜像:

1
2
3
4
5
6
7
8
9
10
11
$ docker -p 8080:8080 myorg/myapp
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.1.6.RELEASE)

2019-11-18 03:52:01.912 INFO 1 --- [ main] hello.Application : Starting Application v0.1.0 on 12cf47053074 with PID 1 (/app.jar started by root in /)
...

如果你想看看镜像的内部结构,可以执行:

1
2
3
4
5
docker run -ti --entrypoint /bin/sh myorg/myapp
/ # ls
app.jar dev home media opt root sbin sys usr
bin etc lib mnt proc run srv tmp var
/ #

Entry Point

Dockerfile的执行方式使用ENTRYPOINT而没有使用shell打包java进程,这样做的好处是java进程可以响应KILL信号指令,比如在本例中使用CTRL-C中止进程。如果ENTRYPOINT的命令比较长,可以单独制作一个shell脚本,并将脚本拷贝到镜像:

1
2
3
4
5
FROM openjdk:8-jdk-alpine
VOLUME /tmp
COPY run.sh .
COPY target/*.jar app.jar
ENTRYPOINT ["run.sh"]

run.sh

1
2
#!/bin/sh
exec java -jar /app.jar

ENTRYPOINT还可以注入环境变量,比如加入运行时java命令行参数:

1
2
3
4
5
FROM openjdk:8-jdk-alpine
VOLUME /tmp
ARG JAR_FILE
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app.jar"]
$ docker build --build-arg JAR_FILE=./target/gs-spring-boot-docker-0.1.0.jar  -t ljun51/docker .
$ docker run -p 8080:8080 -e "JAVA_OPTS=-Ddebug -Xmx128m" ljun51/docker

上面的示例以Spring Boot的-Ddebug参数输出DEBUG日志。

上面的示例使用ENTRYPOINT并带有明确的shell可以传递环境变量的参数给java command,但是不能传递命令行参数给Spring Boot应用。下面这样修改端口不会生效:

$ docker run -p 9000:9000 ljun51/docker --server.port=9000

不生效的原因是docker命令的--server.port=9000部分传给了ENTRYPOINT(sh),而没有传给它启动的java进程。要修复这个问题可以通过添加CMD

1
2
3
4
5
FROM openjdk:8-jdk-alpine
VOLUME /tmp
ARG JAR_FILE
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app.jar ${0} ${@}"]
$ docker build --build-arg JAR_FILE=./target/gs-spring-boot-docker-0.1.0.jar  -t ljun51/docker .
$ docker run -p 9000:9000 ljun51/docker --server.port=9000

${0}表示“command”(第一个参数),${@}表示“command arguments”(命令行其他参数)。如果使用shell脚本,则不需要${0}.run.sh:

1
2
#!/bin/sh
exec java ${JAVA_OPTS} -jar /app.jar ${@}

到目前为止,docker配置都比较简单,生成的镜像也不是非常高效。docker镜像在JAR中打包了一个单独的文件系统层,它的大小在10MB以上,对于某些应用甚至50MB以上,我们可以通过分离成多层来改进。

使用工具构建镜像

编写一个基本的Spring Boot应用

创建一个简单的应用,src/main/java/hello/Application.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package hello;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
@RestController
public class Application {

@RequestMapping("/")
public String home() {
return "Hello Docker World";
}

public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}

}

现在可以运行这个应用,而不必有Docker容器,使用Gradle:

./gradlew build && java -jar build/libs/gs-spring-boot-docker-0.1.0.jar

或是用Maven:

./mvnw package && java -jar target/gs-spring-boot-docker-0.1.0.jar

访问localhost:8080会返回”Hello Docker World”。

容器化应用

Docker使用Dockerfile文件格式指定镜像的“layers”,在Spring Boot工程的顶级目录下创建一个Dockerfile文件,文件名就叫Dockerfile

1
2
3
4
5
FROM openjdk:8-jdk-alpine
VOLUME /tmp
ARG JAR_FILE
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"]

这个Dockerfile非常简单但是包含了运行Spring Boot应用需要的内容:Java和JAR文件。项目JAR文件被COPY到容器中,并叫”app.jar”,然后执行ENTRYPOINT,没有shell包裹java进程。

文件中添加了一个指向”/tmp”的VOLUME,是因为默认情况下Spring Boot应用在该目录中创建工作目录。实际结果是在主机上的“/var/lib/docker”下创建一个临时文件,并将其链接到“/tmp”下的容器。对于我们在此处编写的简单应用程序,此步骤是可选的,但对于其他Spring Boot应用程序,如果它们需要实际在文件系统中进行写操作,则可能是必需的。

为减少Tomcat启动时间,添加了一个系统属性指向了"/dev/urandom"作为熵的来源,如果使用的是较新的Spring Boot或Tomcat的标准版本,这不是必须的。

为了利用Spring Boot胖JAR文件中的依赖项和应用程序资源之间的明确分割,我们将只要稍微不同的Dockerfile实现:

1
2
3
4
5
6
7
FROM openjdk:8-jdk-alpine
VOLUME /tmp
ARG DEPENDENCY=target/dependency
COPY ${DEPENDENCY}/BOOT-INF/lib /app/lib
COPY ${DEPENDENCY}/META-INF /app/META-INF
COPY ${DEPENDENCY}/BOOT-INF/classes /app
ENTRYPOINT ["java","-cp","app:app/lib/*","hello.Application"]

使用Maven构建Docker镜像

在Maven的pom.xml新增插件信息,更多信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<properties>
<docker.image.prefix>ljun51</docker.image.prefix>
</properties>
<build>
<plugins>
<plugin>
<groupId>com.spotify</groupId>
<artifactId>dockerfile-maven-plugin</artifactId>
<version>1.4.9</version>
<configuration>
<repository>${docker.image.prefix}/${project.artifactId}</repository>
</configuration>
</plugin>
</plugins>
</build>

该配置指定一项强制性的内容:有镜像名的仓库,镜像以ljun51/gs-spring-boot-docker命名。

其他可选属性:

  • 解压的fat jar的目录名,作为构建docker镜像的参数可以通过<buildArgs/>插件配置指定。
  • 镜像标签,如果未指定默认使用”latest”,可以通过<tag/>元素设置。

为了确保docker镜像创建之前JAR包被解压,添加下面的插件依赖配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>unpack</id>
<phase>package</phase>
<goals>
<goal>unpack</goal>
</goals>
<configuration>
<artifactItems>
<artifactItem>
<groupId>${project.groupId}</groupId>
<artifactId>${project.artifactId}</artifactId>
<version>${project.version}</version>
</artifactItem>
</artifactItems>
</configuration>
</execution>
</executions>
</plugin>

使用命令行构建docker镜像:

$ ./mvnw install dockerfile:build

推送镜像到dockhub,./mvnw dockerfile:push。Maven运行install时自动推送镜像的配置:

1
2
3
4
5
6
7
8
9
10
<executions>
<execution>
<id>default</id>
<phase>install</phase>
<goals>
<goal>build</goal>
<goal>push</goal>
</goals>
</execution>
</executions>

使用Gradle构建Docker镜像

如果使用Gradle需要这样添加插件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
buildscript {
...
dependencies {
...
classpath('gradle.plugin.com.palantir.gradle.docker:gradle-docker:0.13.0')
}
}

group = 'ljun51'

...
apply plugin: 'com.palantir.docker'

task unpack(type: Copy) {
dependsOn bootJar
from(zipTree(tasks.bootJar.outputs.files.singleFile))
into("build/dependency")
}
docker {
name "${project.group}/${bootJar.baseName}"
copySpec.from(tasks.unpack.outputs).into("dependency")
buildArgs(['DEPENDENCY': "dependency"])
}

这个配置说明4个事情:

  • 解压fat jar文件
  • 创建的镜像名为ljun51/gs-spring-boot-docker
  • 解压jar file的位置,可以使用硬编码
  • 指向jar file的构建参数

使用Gradle构建docker镜像并推送到dockerhub:

$ ./gradlew build docker

如果没有dockerhub的账号,推送应该会报错;推送的的步骤不是必须的,即使没有推送也是可以使用docker运行的:

1
2
3
4
$ docker run -p 8080:8080 -t ljun51/gs-spring-boot-docker
....
2015-03-31 13:25:48.035 INFO 1 --- [ main] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat started on port(s): 8080 (http)
2015-03-31 13:25:48.037 INFO 1 --- [ main] hello.Application : Started Application in 5.613 seconds (JVM running for 7.293)

查看正在运行的docker容器:

1
2
3
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
01cae1671836 ljun51/gs-spring-boot-docker "java -cp app:app/li…" 22 seconds ago Up 21 seconds 0.0.0.0:8080->8080/tcp elated_cori

通过上面的容器ID停止运行:

1
2
$ docker stop 01cae1671836
01cae1671836

使用Spring Profiles

使用Spring配置文件运行刚创建的Docker镜像和将环境变量传递给Docker run命令一样比较容易:

$ docker run -e "SPRING_PROFILES_ACTIVE=prod" -p 8080:8080 -t ljun51/gs-spring-boot-docker

$ docker run -e "SPRING_PROFILES_ACTIVE=dev" -p 8080:8080 -t ljun51/gs-spring-boot-docker

在Docker容器中调试应用

可以使用JPDA Transport像调试远程服务一样。使用JAVA_OPTS环境变量传递java agent设置启用这个功能,映射agent端口到本机。使用Docker for Mac会有一些限制,可以通过一些黑魔法解决。

$ docker run -e "JAVA_OPTS=-agentlib:jdwp=transport=dt_socket,address=5005,server=y,suspend=n" -p 8080:8080 -p 5005:5005 -t ljun51/gs-spring-boot-docker

参考:

  1. Spring Boot with Docker
  2. Spring Boot Docker