Blog

缓存写提交(Buffering Writes until Commit)

Content #

为了缩短写操作的延迟,可以将所有写操作缓存起来,直到 commit 语句时一起执行,这种方式称为 Buffering Writes until Commit,我把它翻译为“缓存写提交”。而 TiDB 的事务处理中就采用这种方式。

所有从 Client 端提交的 SQL 首先会缓存在 TiDB 节点,只有当客户端发起 Commit 时,TiDB 节点才会启动两阶段提交,将 SQL 被转换为 TiKV 的操作。这样,显然可以压缩第一阶段的延迟,把多个写操作 SQL 压缩到大约一轮共识算法的时间。那么整个事务延迟就是:

\[L_{txn}=2*L_c\]

Viewpoints #

From #

10 | 原子性:如何打破事务高延迟的魔咒?

toc:Hardware

电路 #

门电路标识 半加器 全加器 RS锁存器 D锁存器 时钟信号的硬件实现 RS触发器 D型触发器 程序计数器电路设计

硬盘 #

软盘的结构 柱面(Cylinder) 机械硬盘随机访问延时 Partial Stroking SSD和机械硬盘的优缺点 SLC、MLC、TLC和QLC SSD硬盘的结构 SSD硬盘数据写入原理 SSD硬盘的预留空间 FTL和磨损均衡 SSD硬盘不知道操作系统删除了文件 SSD硬盘容易越用越慢(写入放大) DMAC数据传输原理

分区索引

Content #

分区索引就是索引与数据在同一分区,这个分区实际就是我们之前说的分片。因为分片是最小调度单位,那就意味着在分区索引下,索引和数据是确保存储在同一物理节点。我们把索引和数据在同一个物理节点的情况称为同分布(co_located)。

分区索引的优点很明显,那就是性能好,因为所有走索引的查询都可以下推到每个存储节点,每个节点只把有效查询结果返回给计算节点,避免了大量无效的数据传输。分区索引的实现难点在于如何保证索引与数据始终同分布。

Viewpoints #

From #

19 | 查询性能优化:计算与存储分离架构下有哪些优化思路?

HBase的分区索引

HBase的分区索引

Content #

在 HBase 下,每个分片都有一个不重叠的 Key 区间,这个区间左闭右开。当新增一个键值对(Key/Value)时,系统会先判断这个 Key 与哪个分片的区间匹配,而后就分配到那个匹配的分片中保存,匹配算法一般采用左前缀匹配方式。

这个场景中,我们要操作的是一张用户信息表 T_USER,它有四个字段,分别是主键 PID、客户名称(Name)、城市(City)和年龄(Age)。T_USER 映射到 HBase 这样的键值系统后,主键 PID 作为 Key,其他数据项构成 Value。事实上,HBase 的存储格式还要更复杂些,这里为了便于你理解,做了简化。

我们在“City”字段上建立索引,索引与数据行是一对一的关系(建立索引时所用的主键会生成下图所示的Key,会加上其他信息,不是只有City的值)。索引存储也是 KV 形式,Key 是索引自身的主键 ID,Value 是反序列化信息用于解析主键内容。索引主键由三部分构成,分别是分片区间起始值、索引值和所指向数据行的主键(PID)。因为 PID 是唯一的,索引主键在它的基础上增加了前缀,所以也必然是唯一的。

整个查询的流程是这样的:

  1. 客户端发起查询 SQL。
  2. 计算节点将 SQL 下推到各个存储节点。
  3. 存储节点在每个 Region 上执行下推计算,取 Region 的起始值加上查询条件中的索引值,拼接在一起作为左前缀,扫描索引数据行。
  4. 根据索引扫描结果中的 PID,回表查询。
  5. 存储节点将 Region 查询结果,反馈给计算节点。
  6. 计算节点汇总结果,反馈给客户端。

实现分区索引的难点在于如何始终保持索引与数据的同分布,尤其是发生分片分裂时,这是很多索引方案没有完美解决的问题。

Viewpoints #

From #

19 | 查询性能优化:计算与存储分离架构下有哪些优化思路?

分布式2PC事务延迟估算

Content #

整个 2PC 的事务延迟由两个阶段组成,可以用公式表达为:

\[L_{txn}=L_{prep} + L_{commit}\]

其中,\(L_{prep}\)​ 是准备阶段的延迟,\(L_{commit}\)​ 是提交阶段的延迟。

我们先说准备阶段,它是事务操作的主体,包含若干读操作和若干写操作。我们把读操作的次数记为 R,读操作的平均延迟记为 Lr​,写操作次数记为 W,写操作平均延迟记为 Lw​。那么整个准备阶段的延迟可以用公式表达为:

\[L_{prep}=R*L_r + W*L_w\]

在不同的产品架构下,读操作的成本是不一样的。我们选一种最乐观的情况, CockroachDB。因为它采用 P2P 架构,每个节点既承担了客户端服务接入的工作,也有请求处理和数据存储的职能。所以,最理想的情况是,读操作的客户端接入节点,同时是当前事务所访问数据的 Leader 节点,那么所有读取就都是本地操作。

磁盘操作相对网络延迟来说是极短的,所以我们可以忽略掉读取时间。那么,准备阶段的延迟主要由写入操作决定,可以用公式表达为:

\[L_{prep}=W*L_w\]

分布式数据库的写入,并不是简单的本地操作,而是使用共识算法同时在多个节点上写入数据。所以,一次写入操作延迟等于一轮共识算法开销,我们用 Lc​ 代表一轮共识算法的用时,可以得到下面的公式:

\[L_{prep}=W*L_c\]

我们再来看第二阶段,提交阶段,使用 Percolator 的情况下,它的提交阶段只需要写入一次数据,修改整个事务的状态。对于 CockroachDB,这个事务标识可以保存在本地。那么提交操作的延迟也是一轮共识算法,也就是:

\[L_{commit}=L_c\]

分别得到两个阶段的延迟后,带入最开始的公式,可以得到:

\[L_{txn}=(W+1)*L_c\]

我们把这个公式带入具体例子里来看一下。

这次还是小明给小红转账,金额是 500 元。

在这个转账事务中,包含两条写操作 SQL,分别是扣减小明账户余额和增加小红账户余额,W 等于 2。再加上提交操作,一共有 3 个 Lc​。我们可以看到,这个公式里事务的延迟是与写操作 SQL 的数量线性相关的,而真实场景中通常都会包含多个写操作,那事务延迟肯定不能让人满意。

Viewpoints #

From #

10 | 原子性:如何打破事务高延迟的魔咒?

TiDB的计算下推

Content #

假如有一张数据库表 test,目前有四条记录。

下面的例子就是关于 TiDB 如何处理下推的,我们首先来看这组 SQL。

begin;
insert into test (id, value, cond) values(5,V5,C4);
select * from test where cond=C4;

SQL 的逻辑很简单,先插入一条记录后,再查询符合条件的所有记录。结合上一个例子中 test 表的数据存储情况,得到的查询结果应该是两条记录,一条是原有 ID 等于 4 的记录,另一条是刚插入的 ID 等于 5 的记录。这对单体数据库来说,是很平常的操作,但是对于 TiDB 来说,就是一个有挑战的事情了。

TiDB 采用了“缓存写提交”技术,就是将所有的写 SQL 缓存起来,直到事务 commit 时,再一起发送给存储节点。这意味着执行事务中的 select 语句时, insert 的数据还没有写入存储节点,而是缓存在计算节点上的,那么 select 语句下推后,查询结果将只有 ID 为 4 的记录,没有 ID 等于 5 的记录。

这个结果显然是错误的。为了解决这个问题,TiDB 开始的设计策略是,当计算节点没有缓存数据时,就执行下推,否则就不执行下推。

这种策略限制了下推的使用,对性能的影响很大。所以,之后 TiDB 又做了改进,将缓存数据也按照存储节点的方式组织成 Row 格式,再将缓存和存储节点返回结果进行 Merge,就得到了最后的结果。这样,缓存数据就不会阻碍读请求的下推了。

Viewpoints #

From #

19 | 查询性能优化:计算与存储分离架构下有哪些优化思路?

...

计算下推

Content #

分布式数据库的主体架构是朝着计算和存储分离的方向发展的,这一点在 NewSQL 架构中体现得尤其明显。但是计算和存储是一个完整的过程,架构上的分离会带来一个问题:是应该将数据传输到计算节点 (Data Shipping),还是应该将计算逻辑传输到数据节点 (Code Shipping)?

从直觉上说,肯定要选择 Code Shipping,因为 Code 的体量远小于 Data,因此它能传输得更快,让系统的整体性能表现更好。

这个将 code 推送到存储节点的策略被称为“计算下推”,是计算存储分离架构下普遍采用的优化方案。

将计算节点的逻辑推送到存储节点执行,避免了大量的数据传输,也达到了计算并行执行的效果。

假如有一张数据库表 test,目前有四条记录。

我们在客户端执行下面这条查询 SQL。

select value from test where cond=’C1’;

计算节点接到这条 SQL 后,会将过滤条件“cond=‘C1’“下推给所有存储节点。

存储节点 S1 有符合条件的记录,则返回计算节点,其他存储节点没有符合的记录,返回空。计算节点直接将 S1 的结果集返回给客户端。

这个过程因为采用了下推方式,网络上没有无效的数据传输,否则,就要把四个存储节点的数据都送到计算节点来过滤。

这个例子是计算下推中比较典型的“谓词下推”(Predicate Pushdown),很直观地说明了下推的作用。这里的谓词下推,就是把查询相关的条件下推到数据源进行提前的过滤操作,表现形式主要是 Where 子句。

Viewpoints #

From #

19 | 查询性能优化:计算与存储分离架构下有哪些优化思路?

IOPS

Content #

HDD硬盘数据传输率在200MB/s左右。

SSD 硬盘使用 PCI Express 的接口。它的数据传输率,在读取的时候就能做到 2GB/s 左右,差不多是 HDD 硬盘的 10 倍,而在写入的时候也能有 1.2GB/s。

除了数据传输率这个吞吐率指标,另一个我们关心的指标响应时间,其实也可以在 AS SSD 的测试结果里面看到,就是这里面的 Acc.Time 指标。

这个指标,其实就是程序发起一个硬盘的写入请求,直到这个请求返回的时间。可以看到,在上面的两块 SSD 硬盘上,大概时间都是在几十微秒这个级别。如果你去测试一块 HDD 的硬盘,通常会在几毫秒到十几毫秒这个级别。这个性能的差异,就不是 10 倍了,而是在几十倍,乃至几百倍。

光看响应时间和吞吐率这两个指标,似乎我们的硬盘性能很不错。即使是廉价的 HDD 硬盘,接收一个来自 CPU 的请求,也能够在几毫秒时间返回。一秒钟能够传输的数据,也有 200MB 左右。你想一想,我们平时往数据库里写入一条记录,也就是 1KB 左右的大小。我们拿 200MB 去除以 1KB,那差不多每秒钟可以插入 20 万条数据呢。但是这个计算出来的数字,似乎和我们日常的经验不符合啊?这又是为什么呢?

答案就来自于硬盘的读写。在顺序读写和随机读写的情况下,硬盘的性能是完全不同的。

我们回头看一下上面的 AS SSD 的性能指标。你会看到,里面有一个“4K”的指标。这个指标是什么意思呢?它其实就是我们的程序,去随机读取磁盘上某一个 4KB 大小的数据,一秒之内可以读取到多少数据。

你会发现,在这个指标上,我们使用 SATA 3.0 接口的硬盘和 PCI Express 接口的硬盘,性能差异变得很小。这是因为,在这个时候,接口本身的速度已经不是我们硬盘访问速度的瓶颈了。更重要的是,你会发现,即使我们用 PCI Express 的接口,在随机读写的时候,数据传输率也只能到 40MB/s 左右,是顺序读写情况下的几十分之一。

我们拿这个 40MB/s 和一次读取 4KB 的数据算一下。

40MB / 4KB = 10,000

...

ZAB领导者选举

Content #

假设投票信息的格式是 <proposedLeader, proposedEpoch, proposedLastZxid,node>,其中:

  1. proposedLeader,节点提议的,领导者的集群 ID,也就是在集群配置(比如 myid 配置文件)时指定的 ID。
  2. proposedEpoch,节点提议的,领导者的任期编号。
  3. proposedLastZxid,节点提议的,领导者的事务标识符最大值(也就是最新提案的事务标识符)。
  4. node,投票的节点,比如节点 B。

假设一个 ZooKeeper 集群,由节点 A、B、C 组成,其中节点 A 是领导者,节点 B、C 是跟随者(为了方便演示,假设 epoch 分别是 1 和 1,lastZxid 分别是 101 和 102,集群 ID 分别为 2 和 3)。那么如果节点 A 宕机了,会如何选举呢?

首先,当跟随者检测到连接领导者节点的读操作等待超时了,跟随者会变更节点状态,将自己的节点状态变更成 LOOKING,然后发起领导者选举(为了演示方便,我们假设这时节点 B、C 都已经检测到了读操作超时):

接着,每个节点会创建一张选票,这张选票是投给自己的,也就是说,节点 B、 C 都“自告奋勇”推荐自己为领导者,并创建选票 <2, 1, 101, B> 和 <3, 1, 102, C>,然后各自将选票发送给集群中所有节点,也就是说,B 发送给 B、C, C 也发送给 B、C。

一般而言,节点会先接收到自己发送给自己的选票(因为不需要跨节点通讯,传输更快),也就是说,B 会先收到来自 B 的选票,C 会先收到来自 C 的选票:

...

ZAB的三种成员身份

Content #

ZAB 支持 3 种成员身份(领导者、跟随者、观察者)。

  1. 领导者(Leader): 作为主(Primary)节点,在同一时间集群只会有一个领导者。需要你注意的是,所有的写请求都必须在领导者节点上执行。

  2. 跟随者(Follower):作为备份(Backup)节点, 集群可以有多个跟随者,它们会响应领导者的心跳,并参与领导者选举和提案提交的投票。需要你注意的是,跟随者可以直接处理并响应来自客户端的读请求,但对于写请求,跟随者需要将它转发给领导者处理。

  3. 观察者(Observer):作为备份(Backup)节点,类似跟随者,但是没有投票权,也就是说,观察者不参与领导者选举和提案提交的投票。

Viewpoints #

From #

加餐 | ZAB协议(一):主节点崩溃了,怎么办?