C / S 架构时代的末期,最流行的开发套件是 PowerBuilder 和 Sybase 数据库。PowerBuilder 是一款可视化开发工具,有点像 VB,开发好的程序运行在用户的 PC 终端上,通过驱动程序连接远端的数据库。而 Sybase 当时正与 Oracle 争夺数据库的头把交椅,它和 SQL Server 有很深的渊源,两者在架构和语言上都很像。
在这个 C/S 架构中,数据库不仅承担了数据存储、计算功能,还要运行很重的业务逻辑,相当于数据库同时承担了应用服务器(Application Server)的大多数功能。而这些业务逻辑的技术载体就是存储过程。所以,不管是 Sybase 还是 Oracle,它们存储过程的功能都非常强大。
进入 B / S 时代,大家对数据库的理解发生了变化,应用服务器承载了服务器端的主要业务逻辑。当时的主流观点认为存储过程还有存在价值的,但是它的同胞兄弟触发器则被彻底抛弃了。
触发器和存储过程一样也是一种自定义函数。但它并不是显式调用,而是在操作数据表的时候被动触发,也就是执行 insert、update 和 delete 时;而且还可以选择触发时机是在操作前还是操作后,也就 before 和 after 的语义。
听上去这个功能很强大,有点面向事件编程的意思。但是,如果维护过触发器的逻辑就会发现,这是一个大坑。随着业务的发展和变更,触发器的逻辑会越来越复杂,就有人会在触发器的逻辑里操纵另一张表,而那张表上又有其他触发器牵连到其他表,这样慢慢就变成一个交错网络。
只要做错一小步,经过一串连锁反应就会演变成一场大灾难。所以,触发器毫无悬念地退出了历史舞台。
优点:存储过程的调用清晰,不存在触发器的问题。它的优点很明显,逻辑运行在数据库,没有网络传输数据的开销,所以在进行数据密集型操作时,性能优势很突出。
问题:难以调试和扩展,移植性差。存储过程对于环境有很重的依赖,而这个环境并不是操作系统和 Java 虚拟机这样遵循统一标准、有大量技术资料的开放环境,而是数据库这个不那么标准的黑盒子。
天的存储过程和当年的触发器,本质上面临的是同样的问题:一种技术必须要匹配同时代的工程化水平,与整个技术生态相融合,否则它就要退出绝大多数应用场景。
目前,多数 NewSQL 分布式数据库仍然是不支持存储过程的。OceanBase 是一个例外,它在 2.2 版本中增加了对 Oracle 存储过程的支持。我认为这是它全面兼容 Oracle 策略的产物。但是,OceanBase 的官方说明也说得很清楚,目前存储过程的功能还不能满足生产级的要求。
其实,对遗留系统的兼容,可能就是今天存储过程最大的意义。而对于那些从 MySQL 向分布式数据库迁移的系统,这个诉求可能就没那么强烈,因为这些系统没有那么倚重存储过程。其中的原因就是,MySQL 在较晚的版本才提供存储过程,而且功能上也没有 Oracle 那么强大,用户对它的依赖自然也就小了。
当然,存储过程没有得到 NewSQL 的广泛支持,还因为架构上存在的难题。但是业界还有一些尝试。
Google 在 2018 年 VLDB 上发布了 F1 的新论文 "F1 Query: Declarative Querying at Scale"。论文中提出,通过独立的 UDF Server 支持自定义函数,也就是存储过程。这个架构中,因为 F1 是完全独立于数据存储的,所以 UDF Server 自然也就被抽了出来。从论文提供的测试数据看,这个设计保持了比较高的性能,但这和 Google 强大的网络设施有很大关系,在普通企业网络条件下能否适用,这还很难说。
关于 UDF Server 的设计,还有两点也是非常重要的。
这两点变化意味着存储过程的调试问题可能会得到明显的改善,使其与 DevOps 体系的对接成为可能。
不仅是 F1,其实更早的 VoltDB 也已经对存储过程进行了改革。VoltDB 是一款基于内存的分布式数据库,由数据库领域的传奇人物,迈克尔 · 斯通布雷克(Micheal Stonebraker)主导开发。VoltDB 将存储过程作为主要操作方式,并支持使用 Java 语言编写。开发者可以继承系统提供的父类(VoltProcedure)来开发自己的存储过程。下面是一个简单的示例。
import org.voltdb.*;
public class LeastPopulated extends VoltProcedure {
//待执行的SQL语句
public final SQLStmt getLeast = new SQLStmt(
" SELECT TOP 1 county, abbreviation, population "
+ " FROM people, states WHERE people.state_num=?"
+ " AND people.state_num=states.state_num"
+ " ORDER BY population ASC;" );
//执行入口
public VoltTable[] run(int state_num)
throws VoltAbortException {
//赋输入参数
voltQueueSQL( getLeast, state_num );
//SQL执行函数
return voltExecuteSQL();
}
}
这段代码的逻辑非常简单,首先定义 SQL,其中“state_num=?”是预留参数位置,而后在入口函数 run() 中赋参并执行。
VoltDB 在设计理念上非常与众不同,很重视 CPU 的使用效率。他们对传统数据库进行了分析,认为普通数据库只有 12% 的 CPU 时间在做真正有意义的数据操作,所以它的很多设计都是围绕着充分利用 CPU 资源这个理念展开的。
具体来说,存储过程实质上是预定义的事务,没有人工交互过程,也就避免了相应的 CPU 等待。同时,因为存储过程的内容是预先可知的,所以能够尽早的将数据加载到内存中,这又进一步减少了网络和磁盘 I/O 带来的 CPU 等待。
正是由于存储过程和内存的使用,VoltDB 即使在单线程模型下也获得了很好的性能。反过来,单线程本身也让事务控制更加简单,避免了传统的锁管理的开销和 CPU 等待,提升了 VoltDB 的性能。
可以说,与其他数据库相比,存储过程对于 VoltDB 意义已经是截然不同了。
加餐:VoltDB 的设计思路很特别,在数据的复制上的设计也是别出心裁,既不是 NewSQL 的 Paxos 协议也不是 PGXC 的主从复制,这是如何设计的?
VoltDB 数据复制的方式是 K-safety,也叫做同步多主复制,其中 K 是指分区副本的数量。这种模式下,当前分区上的任何操作都会发送给所有副本去执行,以此来保证数据一致性。也就是说,VoltDB 是将执行逻辑复制到了多个分区上,来得到同样的结果,并不是复制数据本身。
数据库除了事务处理、查询引擎这些核心功能外,还会提供一些小特性。这些特性的设计往往是以单体数据库架构和适度的并发压力为前提的。随着业务规模扩大,在真正的海量并发下,这些特性就可能被削弱或者失效。在分布式架构下,是否要延续这些特性也存在不确定性。
自增主键在不同的数据库中的存在形式稍有差异。在 MySQL 中,可以在建表时直接通过关键字 auto_increment 来定义自增主键,例如这样:
create table ‘test’ (
‘id’ int(16) NOT NULL AUTO_INCREMENT,
‘name’ char(10) DEFAULT NULL,
PRIMARY KEY(‘id’)
) ENGINE = InnoDB;
而在 Oracle 中则是先声明一个连续的序列,也就是 sequence,而后在 insert 语句中可以直接引用 sequence,例如下面这样:
create sequence test_seq increment by 1 start with 1;
insert into test(id, name) values(test_seq.nextval, ' An example ');
自增主键给开发人员提供了很大的便利。因为,主键必须要保证唯一,而且多数设计规范都会要求,主键不要带有业务属性,所以如果数据库没有内置这个特性,应用开发人员就必须自己设计一套主键的生成逻辑。数据库原生提供的自增主键免去了这些工作量,而且似乎还能满足开发人员的更多的期待。大概有三个:
set
auto_increment_increment=10
)。有些应用系统甚至会基于自增主键的『连续递增』特性来设计业务逻辑。但是,除了最基本的唯一性,另外的两层期待都是无法充分满足的。
首先说连续递增。在多数情况下,自增主键确实表现为连续递增。但是当事务发生冲突时,主键就会跳跃,留下空洞。下面,用一个例子简单介绍下 MySQL 的处理过程。
两个事务 T1 和 T2 都要在同一张表中插入记录,T1 先执行,得到的主键是 25,而 T2 后执行,得到是 26。
但是,T1 事务还要操作其他数据库表,结果不走运,出现了异常,T1 必须回滚。T2 事务则正常执行成功,完成了事务提交。
这样,在数据表中就缺少主键为 25 的记录,而当下一个事务 T3 再次申请主键时,得到的就是 27,那么 25 就成了永远的空洞。
为什么不支持连续递增呢?这是因为自增字段所依赖的计数器并不是和事务绑定的。如果要做到连续递增,就要保证计数器提供的每个主键都被使用。
怎么确保每个主键都被使用呢?那就要等待使用主键的事务都提交成功。这意味着,必须前一个事务提交后,计数器才能为后一个事务提供新的主键,这个计数器就变成了一个表级锁。
显然,如果存在这么大粒度的锁,性能肯定会很差,所以 MySQL 优先选择了性能,放弃了连续递增。至于那些因为事务冲突被跳过的数字呢,系统也不会再回收重用了,这是因为要保证自增主键的单调递增。
对于单体数据库自身来说,自增主键确实是单调递增的。但使用自增主键也是有前提的,那就是 主键生成的速度要能够满足应用系统的并发需求。而在高并发量场景下,每个事务都要去申请主键,数据库如果无法及时处理,自增主键就会成为瓶颈。那么,这时只用自增主键已经不能解决问题了,往往还要在应用系统上做些优化。
比如,对于 Oracle 数据库,常见的优化方式就是由 Sequence 负责生成主键的高位,由应用服务器负责生成低位数字,拼接起来形成完整的主键。
图中展示这样的例子,数据库的 Sequence 是一个 5 位的整型数字,范围从 10001 到 99999。每个应用系统实例先拿到一个号,比如 10001,应用系统在使用这 5 位为作为高位,自己再去拼接 5 位的低位,这样得到一个 10 位长度的主键。这样,每个节点访问一次 Sequence 就可以处理 99999 次请求,处理过程是基于应用系统内存中的数据计算主键,没有磁盘 I / O 开销,而相对的 Sequence 递增时是要记录日志的,所以方案改进后性能有大幅度提升。
这个方案虽然使用了 Sequence,但也只能保证全局唯一,数据表中最终保存的主键不再是单调递增的了。
因为,几乎所有数据库中的自增字段或者自增序列都是要记录日志的,也就都会产生磁盘 I / O,也就都会面临这个性能瓶颈的问题。所以,可以得出一个结论:在一个海量并发场景下,即使借助单体数据库的自增主键特性,也不能实现单调递增的主键。
对于分布式数据库,自增主键带来的麻烦就更大。具体来说是两个问题,一是在自增主键的产生环节,二是在自增主键的使用环节。
首先,产生自增主键难点就在单调递增。单调递增这个要求和全局时钟中的 TSO 是很相似的。TSO 实现起来比较复杂,也容易成为系统的瓶颈,如果再用作主键的发生器,显然不大合适。
其次,使用单调递增的主键,也会给分布式数据库的写入带来问题。这个问题是在 Range 分片下发生的,我们通常将这个问题称为『尾部热点』。
先通过一组性能测试数据来看看尾部热点问题的现象,这些数据和图表来自 CockroachDB 官网。
这本身是一个 CockraochDB 与 YugabyteDB 的对比测试。测试环境使用亚马逊跨机房的三节点集群,执行 SQL insert 操作时,YugabyteDB 的 TPS 达到 58,877,而 CockroachDB 的 TPS 是 34,587。YugabyteDB 集群三个节点上的 CPU 都得到了充分使用,而 CockroachDB 集群中负载主要集中在一个节点上,另外两个节点的 CPU 多数情况都处于空闲状态。
为什么 CockroachDB 的节点负载这么不均衡呢?这是由于 CockroachDB 默认设置为 Range 分片,而测试程序的生成主键是单调递增的,所以新写入的数据往往集中在一个 Range 范围内,而 Range 又是数据调度的最小单位,只能存在于单节点,那么这时集群就退化成单机的写入性能,不能充分利用分布式读写的扩展优势了。当所有写操作都集中在集群的一个节点时,就出现了我们常说的数据访问热点(Hotspot)。
图中也体现了 CockroachDB 改为 Hash 分片时的情况,因为数据被分散到多个 Range,所以 TPS 一下提升到 61,113,性能达到原来的 1.77 倍。
现在性能问题的根因已经找到了,就是同时使用自增主键和 Range 分片。Range 分片很多优势,这使得 Range 分片成为一个不能轻易放弃的选择。于是,主流产品的默认方案是保持 Range 分片,放弃自增主键,转而用随机主键来代替。
随机主键的产生方式可以分为数据库内置和应用外置两种方式。当然对于应用开发者来说,内置方式使用起来会更加简便。
UUID(Universally Unique Identifier)可能是最经常使用的一种唯一 ID 算法,CockroachDB 也建议使用 UUID 作为主键,并且内置了同名的数据类型和函数。UUID 是由 32 个的 16 进制数字组成,所以每个 UUID 的长度是 128 位(1632 = 2128)。UUID 作为一种广泛使用标准,有多个实现版本,影响它的因素包括时间、网卡 MAC 地址、自定义 Namesapce 等等。
但是,UUID 的缺点很明显,那就是键值长度过长,达到了 128 位,因此存储和计算的代价都会增加。
TiDB 默认是支持自增主键的,对未声明主键的表,会提供了一个隐式主键 _tidb_rowid,因为这个主键大体上是单调递增的,所以也会出现前面说的『尾部热点』问题。
TiDB 也提供了 UUID 函数,而且在 4.0 版本中还提供了另一种解决方案 AutoRandom。TiDB 模仿 MySQL 的 AutoIncrement,提供了 AutoRandom 关键字用于生成一个随机 ID 填充指定列。
这个随机 ID 是一个 64 位整型,分为三个部分:
AutoRandom 可以保证表内主键唯一,用户也不需要关注分片情况。
雪花算法(Snowflake)是 Twitter 公司分布式项目采用的 ID 生成算法。
这个算法生成的 ID 是一个 64 位的长整型,由四个部分构成:
这样,根据数据结构推算,雪花算法支持的 TPS 可以达到 419 万左右(2^22*1000),对于绝大多数系统来说是足够了。
但实现雪花算法时,有个小问题往往被忽略,那就是要注意时间回拨带来的影响。机器时钟如果出现回拨,产生的 ID 就有可能重复,这需要在算法中特殊处理一下。
加餐:使用 Range 分片加单调递增主键会引发『尾部热点』问题,但是使用随机主键是不是一定能避免出现『热点』问题?
随机主键可能会出现热点问题。因为按照 Range 分片原理,一张数据表初始仅有一个分片,它的 Key 范围是从无穷小到无穷大。随着数据量的增加,这个分片会发生分裂(Split),数据存储才逐渐散开。这意味着,在一段时间内,分片数量会远小于集群节点数量时,所以仍然会出现热点。
解决的方法就是采用预分片机制(Presplit),在没有任何数据的情况下,先初始化若干分片并分配不同的节点。这样在初始阶段,写入负载就可以被分散开,避免了热点问题。目前 Presplit 在分布式键值系统中比较常见,例如 HBase,但不是所有的分布式数据库都支持。
Bart Samwel: F1 Query: Declarative Querying at Scale
CockroachDB: Yugabyte vs CockroachDB: Unpacking Competitive Benchmark Claims
本文由 caroly 创作,如果您觉得本文不错,请随意赞赏
采用 知识共享署名4.0 国际许可协议进行许可
本站文章除注明转载 / 出处外,均为本站原创或翻译,转载前请务必署名
原文链接:https://caroly.fun/archives/分布式数据库六
最后更新:2022-02-07 13:36:02
Update your browser to view this website correctly. Update my browser now