才疏学浅,笔拙。
在各种因素的推动下,分布式数据库已经成为一种技术潮流,甚至是新基建的一部分。
学习分布式数据库的另一个原因在于,可以通过学习它的设计思想,提高自己的架构设计水平和代码能力。
这个专栏记录了分布式数据库原理层面分析,以一个中立的视角去剖析主流产品的运行机制和理论依据,横向比较它们的差异,分析这些技术决策背后的动机,快速建立起对分布式数据库全面的认知体系。
该栏目引自 极客时间 - 分布式数据库 30 讲 专栏,建议有兴趣了解的同学去购买正式课程学习。
本篇文章描述四方向内容:什么是分布式数据库,以及它的数据一致性、事务一致性、架构风格(NewSQL 和 PGXC)等。数据一致性主要从状态视角和操作视角分析;事务一致性从 ACID 到两种隔离级别分析。
本篇文章描述三方向内容:全局时钟、分片机制和数据复制。方向一对常见授时方案(TrueTime、HLC、TSO、STP)进行阐述,着重分析中心化授时以及分布式授时。方向二描述分片定义、对两种架构风格(NewSQL 和 PGXC)的分片机制进行阐述。方向三主要从分片元数据的存储和复制效率方向分析。
本篇文章描述两方向内容:原子性(协议保证)和原子性(优化事务延迟)。方向一对面向应用层的 TCC 协议和两阶段提交的 2PC 协议,以及对 2PC 改进的分布式数据库(NewSQL 和 PGXC)。方向二主要是事务延迟的估算和优化方法(缓存写提交、管道、并行提交)。
本篇文章描述两方向内容:隔离性(显式读写冲突)和隔离性(隐式读写冲突)。方向一主要描述单体数据库的 MVCC(多版本并发控制)以及工作过程、快照的工作原理、PGXC 和 NewSQL 读写冲突处理。方向二主要描述不确定时间窗口、写等待和读等待。
本篇文章描述两方向内容:隔离性(乐观控制协议)和隔离性(悲观控制协议)。方向一主要描述乐观锁、并发控制的三个阶段、狭义乐观并发控制等。方向二主要描述悲观协议分类、两阶段封锁、串行化图检测、工程实现等。
本篇文章描述两方向内容:为什么不建议什么存储过程以及为什么不建议使用自增主键。方向一从 C / S 时代、触发器、分布式数据库的支持情况等方面分析。方向二主要从自增主键的特性、单体数据库的自增主键、自增主键的问题、随机主键方案等方向分析。
本篇文章描述两方向内容:HTAP 是不是赢者通吃的游戏以及查询性能优化。方向一从业务场景、两种解决思路、两种存储设计以及 TiFlash 的存储分离来描述。方向二主要描述优化方案(计算下垂)以及TiDB 的下推分析,对索引分布、分区索引、全局索引进行描述分析。
本篇文章着重写了三类关联查询算法(嵌套循环连接算法、排序归并连接算法、哈希连接算法)以及它的分布式数据库实现,从并行框架、大小表关联(复制表)、大表关联(重分布)三方面分析。
本篇文章描述两方向内容:查询执行引擎和 RUM 猜想。方向一主要对三种模型(火山模型、向量化模型、代码生成)进行分析。方向二对 RUM 猜想进行分析,主要有存储结构和分布式数据库的实现两方面。
本篇文章描述两方向内容:全球化部署以及容灾与备份。方向一主要从单体数据库、分布式数据库、架构问题等方向分析。方向二从 CDC、逃生方案分析。
本篇文章描述两方向内容:容器化和产品测试。方向一主要从 Kbuernetes 基本概念、有状态服务、拓扑状态、存储状态、Operator 等方向描述。方向二从 TPC-C、Jepsen、混沌工程、TLA 等方向描述。
本篇文章描述两方向内容:银行是怎么选择分布式数据库的以及哪些分布式数据库值得看。方向一主要描述工商银行、邮储银行、交通银行、中信银行、北京银行、光大银行的数据库选择以及选型建议。方向二主要描述 NewSQL、CockroachDB、TIDB、YugabyteDB、OceanBase、GoldenDB 等分布式数据库。
数据库作为一款基础软件,稳定性和可靠性就是它的立身之本。而对稳定性要求最严苛的行业,就是金融业,尤其是银行业。所以,只有经过金融场景的考验,一款数据库产品才能真正扬名立万,让其他行业的用户放心使用。
分布式数据库的应用场景主要特征是海量并发,所以理论上说,业务规模越大,使用分布式数据库的需求也就越迫切。无论从用户数量还是资产规模,国内最大的银行肯定是工商银行。
工行之前的数据库主要是 Oracle 和 IBM 的 DB2,这很代表性,过去的 20 年银行业基本上就是在这两种产品中二选一。
Oracle 和 DB2 都是单体数据库,只能采用垂直扩展方式,在碰到技术天花板后就会限制业务的发展。作为业务量最大的『宇宙行』,工行面对这个问题的选择是,从单体数据库向以 MySQL 为基础的分库分表方案转型。
为什么工行没有选择真正的分布式数据库呢?
其中主要原因是产品成熟度。工行的架构改造在 2018 年大规模落地,而调研和试点工作则在更早的 2016-2017 年。这个时点上,商用 NewSQL 数据库刚刚推出不久,而金融场景的种种严苛要求,注定了银行不会做第一个吃螃蟹的人,那么这种可能性就被排除了。同样,另一种 PGXC 风格的分布式数据库也正待破茧而出,反而是它的前身,『分布式中间件 + 开源单体数据库』的组合更加普及。
所以,对当时的工行来说,产品架构上是没有什么选择余地的,能做的就是选择产品。后来的结果是选择了 DBLE + MySQL 的组合,选择 MySQL 是因为它的普及程度足够高;而选择 DBLE 则因为它是在 MyCat 的基础上研发,号称是『增强版 MyCat』,由于 MyCat 已经有较多的应用案例,所以这一点给 DBLE 带来不少加分。
现在看来,这个方案显得有点平淡无奇。但正是这种稳妥,或者说有点保守的选择,最大程度规避了风险,也坚定了工行从主机下移应用系统的决心。从后来 MySQL 上千节点的使用规模看,这个方案更大的意义在于,使工行逐步脱离了对 IBM 主机和 Oracle 数据库的依赖。分布式数据库的尝试只是这个大目标下的副产品。
相对于 OLTP 技术应用上的平淡,工行在 OLAP 方面的技术创新则令人瞩目。基本是在同期,工行联合华为成功研发了 GaussDB 200,并在生产环境中投入使用。这款数据库对标了 Teradata 和 Greenplum 等国外 OLAP 数据库。在工行案例的加持下,目前不少银行计划或者正在使用这款产品替换 Teradata 数据库。
邮政储蓄银行,简称邮储银行,由于特殊的历史沿革,它没有被归入通常所说『四大国有银行』,但这不意味着它的业务规模小。事实上,邮储银行的零售用户在 2019 年已经超过 6 亿。
这个庞大的用户基数使得邮储银行也早早地就开始探讨分布式数据库的使用。那他们采用了什么方案呢?
他们也没有选择分布式数据库。邮储的核心业务系统改造方案更接近于单元化架构,所以连分布式中间件都没使用。它的设计思路就是将原来商业数据库拆分成若干个小的单体数据库,分别设置对应的应用实例。邮储在单体数据库上选择了 PostgreSQL,这个在银行业中是相对较少使用的。
当然,单元化方案从应用整体看也是一种分布式架构,通过应用层面的重构弱化了对数据库的性能和稳定性等方面的要求。说到这,你可能会有个疑问,如果两种方式都能解决问题,那要怎么在单元化和分布式数据库之间选择呢?
从成本上看,系统的单元化改造要付出巨大的代价,是一个推倒重建的过程,远高于过渡到分布式数据库的代价。我认为,邮储之所以会选择这个方式,可能有两个原因:
同样,民生银行也是在核心系统改造的背景下,完成了向分布式架构的升级。在民生银行的一份宣传材料中对分布式技术平台做了整体描述,其中有两段是和数据库相关,是这么写的『通过分库分表和读写分离实现分布式数据访问功能;基于可靠消息的最终一致性和基于冲正模型的反向处理实现分布式事务功能』。
可见民生银行也选择了与邮储银行大致相同的路线,弱化了分布式数据库的作用,更加强调整体架构改造,在应用系统层面做了更多的工作。
交通银行的分布式数据库之路走得比较特别,它采用联合高校研发的方式,与华东师范大学和西北工业大学共同研发了分布式数据库 CBase。
CBase 研发开始于 2014 年,在分布式数据库中算是非常早的了,但它的整体架构风格非常接近于 NewSQL。不同于前几家银行,CBase 并不是某个重要业务系统的附属品,已经有点技术驱动的味道。
它最先用于历史库系统的数据存储,而后逐步实现了复杂 SQL 语句处理和高并发事务处理能力,在供应链、贷记卡授权和网联支付等系统使用。
通过架构图,可以看到 CBase 的主要工作负载放在三类节点上,其中的数据存储节点与 NewSQL 风格完全一致,而剩下的 SQL 处理节点和事务处理节点,就是计算节点的细化。而且,CBase 也基于 Raft 协议设计和实现了轻量级分布式选举协议,分布式事务同样是在 2PC 上进行改进的。
这款数据库主要在交通银行内部使用,目前并没有看到其他的商业化案例,所以能够找到的资料比较少。不过,CBase 开发团队在 2019 年发表了一篇论文《分布式数据库在金融应用场景中的探索与实践》(刘雷 等 (2019)),介绍了 CBase 的部分设计。
交通银行研发分布式数据库在银行业是很有代表性的,因为之前还很少有在基础软件上进行大规模投入的先例。
GoldenDB 就是中信银行与中兴通讯联合研发的产品,而目前 GoldenDB 主要用户也是中信银行。中信银行的核心业务系统在 2020 年 5 月正式上线切换到 GoldenDB。这之前,基于 GoldenDB 的信用卡新核心系统已经在 2019 年 10 月投产运行。这两个重要系统的运行,使中信银行无可争议地成为分布式数据库应用最为深入的一家银行。核心业务系统是银行业务的心脏,它的稳定运行无疑为其他银行树立了标杆,客观上也加快分布式数据库的普及速度。
GoldenDB 和 CBase 有大致相同的发展路径,从产品研发到试点应用,只不过中信银行的步子更快些。当然,这并不是说交行研发人员的能力不行,因为这个速度上的差别确实有架构上的因素。GoldenDB 是 PGXC 风格的分布式数据库,遇到的技术挑战更小;当然 NewSQL 架构上的优势,也让我们对 CBase 的未来充满期待。
中信的目标就是完成 AS400 小机下移,将应用程序翻写到开放平台,但是对应用架构本身并没有改造诉求。所以,数据库层面的平滑过渡就有很大的优势,编码逻辑改动小,测试成本低,最重要的是不会因为技术原因变动业务流程,这样就大大降低了项目的实施难度。
北京银行是城市商业银行中的佼佼者,但相对于前几家银行,在资产规模和用户数量上有较大的差距。北京银行从 2018 年开始,先后在网联支付系统和网贷系统中应用了 TiDB。
事实上,很多比北京银行规模更小的城市商业银行,比如南京银行(OceanBase)、张家港银行(TDSQL)都已经上线了分布式数据库。表面上,似乎很难捕捉到他们替换数据库的动因。从业务压力的角度,业务量通常没有达到海量并发级别;同时城商行通常也不涉及『主机下移』带来可用性下降问题。
那么,他们为什么要做出这个选择呢?主要有三点原因:
1、国产化的诉求
由于各种原因,继续依赖 Oracle 这样的国外商业产品,很可能让银行将面临更大的风险。而对于小型银行来说,使用开源数据库还是分布式数据库,在成本上可能差异并不大。
随着国内厂商加大技术投入,隐约有一种趋势,就是分布式数据库正在逐步成为国产数据库的代名词。那些原本深耕单体数据库技术的厂商,比如达梦、人大金仓,也在朝着分布式架构转型。
所以,选择分布式数据库也就满足了国产化的诉求。
2、实际收益
由于小型银行的数据量并不大,上线分布式数据库后集群的节点规模没有大幅增长,对运维的冲击也相对小些。此外,利用分布式数据库的多租户特性,转变成类似 Aurora 使用方式,还能降低数据库实例管理的复杂度。所以,使用分布式数据库也是有一些实际收益的。
3、技术潮流
一旦技术的趋势形成,就会在无形中影响人们的选择。就像时尚潮流那样,在同等价位下,大家当然更愿意选那些流行款式。今天,分布式架构转型就是这样的潮流,微服务架构、分布式数据库甚至容器云都是这个大潮下的浪花。
在风险可控的前提下,受到技术潮流的影响,可能还会有更多的小型银行会选择分布式数据库。
简单来说,光大使用了双路线策略,也就是同时使用 NewSQL 和分库分表方案。
在云缴费系统中,光大使用了自研的分库分表方案。首先,这个系统的业务量,也就是缴费的业务量是非常大的。今天支付宝、微信甚至很多银行的缴费服务,在后台都是要调用光大的云缴费系统的。截至 2019 年,云缴费系统的累计用户达到了 5.49 亿。
其实,缴费业务是非常互联网化的业务,就是银行提供服务对接用户和缴费企业,所以它的业务模型比较简单和统一。这也意味着,它对分布式事务这样的复杂操作没有那么强烈的诉求。最后,用分库方案就很好地解决了海量业务的问题。
光大在另一个系统,新一代财富管理平台使用了 NewSQL 数据库,也就是 TiDB。这个系统是理财业务的全流程管理平台,业务量相对缴费要小很多,但业务要更复杂,而且在联机和批量方面都有计算需求。
这个架构选择更多是面向未来,因为理财业务是光大银行重点发力的业务,对未来业务量的增长还是有很大预期的。同时,伴随着 NewSQL 技术的发展,保持团队对新技术的感知和掌握,应该也是一个重要的原因。
1、产品选型要服从于项目整体目标
局部最优的选择拼装在一起未必是全局最优的方案。如果目标是要对整个应用系统做彻底重构,例如把单体架构改为微服务架构,那么要解决原来某些局部的问题,可能会有更多选择。这时候要从整体上评估技术复杂度、工程实施等因素,而不是仅选择局部最合理的方案。
2、先进的产品可能会延长项目交付时间
最先进的产品不一定是完美的选择。尤其是有进度要求时,往往会选择更稳妥、快速的办法。但是,这本质上是在短期利益和长期利益之间做权衡,没有绝对的对错,搞清楚你想要的是什么就行。
3、当产品选型可能导致业务流程变更时,请慎重对待
对任何项目来说,协作范围的扩大一定会增加实施难度。当技术部门对业务流程变更没有决定权时,通过技术手段避免这种变更往往是更好的选择。
4、产品选型中的非技术因素
正视非技术因素,评估它的合理性不是技术团队的职责。
5、评估技术潮流对选型影响
跟随潮流并不是人云亦云,必须能够独立对技术发展趋势做出研判。太过小众的技术往往不能与工程化要求兼容。但同时,保持对新技术的敏感度和掌控力,也是非常必要的。
加餐:产品选型是系统建设中非常重要的工作,你觉得针对一个新建系统做产品选型时还要考虑哪些因素呢?这个产品,并不限于数据库?
对于分布式数据库的选型,最重要的是分析业务场景对于事务和查询方面的要求。事实上,这两部分内容也占据了我们课程的大半。在实践中,有的海量并发业务其实并没有相关性,也就是说,不同事务间数据重叠的概率非常低,这时就可以考虑分库分表方案。不过,多数的分库分表方案只能对数据做单向的路由分发,如果有跨分片的查询需求则很难满足。虽然,也可以通过 CDC 等工具导出到分析型系统实现这一点,但这时就要平衡系统的整体复杂度了。
跳出具体技术,产品选型要结合具体项目目标,选择尽量少的产品或技术组件,尽量不采用同质化的多种技术,让架构更加简洁和优雅。
既然要说分布式数据库产品,第一个必须是 Google 的 Spanner。严格来说,是 Spanner 和 F1 一起开创了 NewSQL 风格,它是这一流派当之无愧的开山鼻祖。
在 2012 年 Google 论文 "F1: A Distributed SQL Database That Scales" 中首先描述了这个组合的整体架构。
其中 F1 主要作为 SQL 引擎,而事务一致性、复制机制、可扩展存储等特性都是由 Spanner 完成的,所以有时会忽略 F1,而更多地提到 Spanner。
Google 在 2012 年的另一篇论文 "Spanner: Google’s Globally-Distributed Database" 中介绍了 Spanner 的主要设计。
Spanner 架构中的核心处理模块是 Spanserver,下面是它的架构图。
从图中可以看到,Spanserver 的核心工作有三部分:
软件架构会随着业务发展而演进,2017 年,Google 又发表了两篇新论文,介绍了 Spanner 和 F1 的最新变化。它们从原来的『金牌组合』走向了『单飞』模式。
Spanner 的论文是 "Spanner: Becoming a SQL System"。就像论文名字所说的,Spanner 完善了 SQL 功能,这样就算不借助 F1,也能成为一个完整的数据库。
这篇论文用大量篇幅介绍了 SQL 处理机制,同时在系统定位上相比 2012 版有一个大的变化,强调了兼容 OLTP 和 OLAP,也就是 HTAP。对应的,Spanner 在存储层的设计也从 2012 版中的 CFS 切换到了 Ressi。Ressi 是类似 PAX 的行列混合数据布局(Data Layout)。
F1 的新论文是 "F1 Query: Declarative Querying at Scale"。这一版论文中 F1 不再强调和 Spanner 的绑定关系,而是支持更多的底层存储。非常有意思的是,F1 也声称自己可以兼顾 OLTP 和 OLAP。
按照时间顺序,在 Spanner 之后出现的 NewSQL 产品是 CockroachDB。CockroachDB 和 TiDB、YugabyteDB 都公开声称设计灵感来自 Spanner,所以往往会被认为是同构的产品。尤其是 CockroachDB 和 TiDB,经常会被大家拿来比较。但是,从系统架构上看,这两个产品有非常大的差别,先从 CockroachDB 角度来总结下。
最大的差别来自架构的整体风格。CockroachDB 采用了标准的 P2P 架构,这是一个非常极客的设计。只有 P2P 架构能够做到绝对的无中心,这意味着只要损坏的节点不超过总数一半,那么集群就仍然可以正常工作。因此,CockroachDB 具有超强的生存能力,而这也很符合产品名称的语义。
第二个重要差异是全球化部署。CockroachDB 采用了混合逻辑时钟(HLC),所以能够在全球物理范围下做到数据一致性。这一点对标了 Spanner 的特性,不同之处是 Spanner 的 TrueTime 是依赖硬件的,而 HLC 机制完全基于软件实现。
第三个点则是分片管理机制的不同。因为整体架构的差异,CockroachDB 在分片管理上也跟 TiDB 有明显的区别。
2020 年,CockroachDB 发表的论文 "CockroachDB: The Resilient Geo-DistributedSQL Database" 被 SIGMOD 收录。这篇论文对 CockroachDB 的架构设计做了比较全面的介绍。
TiDB 也是对标 Spanner 的 NewSQL 数据库,因为开源的运行方式和良好的社区运营,它在工程师群体中拥有很高的人气。
不同于 CockroachDB 的 P2P 架构,TiDB 采用了分层架构,由 TiDB、TiKV 和 PD 三类角色节点构成,TiKV 作为底层分布式键值存储,TiDB 作为 SQL 引擎,PD 承担元数据管理和全局时钟的职责。
与 Spanner 不同的是,底层存储 TiKV 并不能独立支持事务,而是通过 TiDB 协调实现,事务控制模型采用了 Percolator。
作为与 CockroachDB 的另一个显著区别,TiDB 更加坚定的走向 HTAP,在原有架构上拓展 TiSpark 和 TiFlash,同时,TiDB 对于周边生态工具建设投入了大量资源,由此诞生了一些衍生项目:
当然,TiDB 架构也存在一些明显的缺陷,比如不支持全球化部署,这为跨地域大规模集群应用 TiDB 设置了障碍。
与 CockroachDB 一样,TiDB 在 2020 年也发布了一篇论文 "TiDB, A Raft-based HTAP Database" 被 VLDB 收录,论文全面介绍了 TiDB 的架构设计。
YugabyteDB 是较晚推出的 NewSQL 数据库,在架构上和 CockroachDB 有很多相似之处,比如支持全球化部署,采用混合逻辑时钟(HLC),基于 Percolator 的事务模型,兼容 PostgreSQL 协议。所以,课程中对 CockroachDB 的介绍会帮助你快速了解 YugabyteDB。
为数不多的差异是,YugabyteDB 选择直接复用 PostgreSQL 的部分代码,所以它的语法兼容性更好。
可能是由于高度的相似性,YugabyteDB 与 CockroachDB 的竞争表现得更加激烈。YugabyteDB 率先抛出了产品比对测试,证明自己处理性能全面领先,这引发了 CockroachDB 的反击,随后双方不断回应。
从历史沿革看,OceanBase 诞生的时间可以追溯到 2010 年,但是那时产品形态是 KV 存储,直到在 1.0 版本后才确立了目前的产品架构。OceanBase 大体上也是 P2P 架构,但会多出一个 Proxy 层。
由于 OceanBase 是一款商业软件,所以对外披露的信息比较有限,期待 OceanBase 团队能够放出更多有价值的材料。
GoldenDB 几乎是国内银行业应用规模最大的分布式数据库,和 TDSQL 同样在数据节点上选择了 MySQL,但全局时钟节点的增加使它称为一个标准的 PGXC 架构。
分库分表方案是一个长期流行的方案,有各种同质化产品,所以进一步演化出多少种 PGXC 风格的数据库很难历数清楚。借助单体数据库的优势,无疑会发展的更加快捷,但始终存在一个隐患。那就是单体数据库的许可证问题,尤其是那些选择 MySQL 作为数据节点的商业数据库。
MySQL 采用的 GPL 协议,这意味着不允许修改后和衍生的代码作为商业化软件发布和销售,甚至是只要采用了开源软件的接口和库都必须开放源码,否则将面对很大的法律风险。
加餐:除了上述数据库之外,还有哪些有特点的分布式数据库?
FoundationDB 和 FaunaDB。
FoundationDB 是一个开源项目,而后被苹果收购后闭源,2018 年又重新开源。严格来说,它是一个支持 ACID 语义的 NoSQL 数据库,有比较独特的乐观并发控制。FoundationDB 可以通过上层的扩展可以支持更多的场景,而在增加了 Record Layer 后,它就成为一个关系型数据库,不过还不支持 SQL 接口。
对于 FaunaDB,它是一款商业数据库,从 NoSQL 逐渐过渡成为分布式数据库。FaunaDB 的理论原型是 Calvin,它和 Spanner 一样在 2012 年发表了 原型论文,被 SIGMOD 收录。虽然它们的目标都是提供强一致性的分布式关系型数据库,但设计思路截然不同。
在 Calvin 的架构中设计了 Sequencer 和 Scheduler 两个组件。
其中 Sequencer 是基于锁机制为输入的事务操作进行排序,这样在事务真正执行前就能获得确定性的顺序,避免了过程中的无效交互和资源冲突。Scheduler 则是按照这个顺序执行,调度不同的事务执行线程完成最终操作。
这个设计思路被称为确定性并发控制,对应的数据库则称为确定性数据库(Deterministic Database)。
确定性并发控制算法的基本思路是,确保不同的副本始终能够获得相同的输入,所以就能独立地产生相同的结果,从而避免使用开销更大的提交协议和复制协议。通常,这类算法可以降低协调工作的通信开销,提高分布式事务的处理效率。而它的代价之一是无法为客户端提供一个交互操作过程,这就限制了它的使用场景。
目前基于 Calvin 理论模型的工业产品,似乎只有 FaunaDB。作为一种有代表性的分布式数据库,它值得我们继续关注。
此外,确定性数据库也是学术领域的重要研究方向。VLDB2020 收录的一篇论文 "Aria: A Fast and Practical Deterministic OLTP Database",就探讨了对确定性控制算法的优化方案。
刘雷 等: 分布式数据库在金融应用场景中的探索与实践
Bart Samwel et al: F1 Query: Declarative Querying at Scale
David F. Bacon et al: Spanner: Becoming a SQL System
Dongxu Huang et al.: TiDB, A Raft-based HTAP Database
Jeff Shute et al.: F1: A Distributed SQL Database That Scales
James C. Corbett et al.: Spanner: Google’s Globally-Distributed Database
Rebecca Taft et al.: CockroachDB: The Resilient Geo-DistributedSQL Database
容器化技术可以将资源虚拟化,从而更灵活快速地调配。容器镜像为应用打包提供了完美的解决方案,也为 DevOps 理念的落地扫清了技术障碍。可以说,容器已经成为现代软件工程化的基础设施,容器化已经成为一个不可逆的发展趋势。
1、Container
容器化就是将物理机划分为若干容器(Container),应用程序是直接部署在容器上的,并不会感知到物理机的存在。具体来说,这个容器就是 Docker,它使用的主要技术包括 Cgroup 和 Namespace。
Cgroup 是控制组群(Control Groups)的缩写,用来限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等,本质上实现了资源的隔离。Namespace 修改了进程视图,使当前容器处于一个独立的进程空间,无法看到宿主机上的其他进程,本质上实现了权限上的隔离。这两项其实都是 Linux 平台上的成熟技术,甚至在 Docker 出现前已经被用在 Cloud Foundary 的 PaaS 平台上。
Docker 能够快速崛起的重要原因是,它通过容器镜像可以将文件系统与应用程序一起打包。这个文件系统是指操作系统所包含的文件、配置和目录。这样,就不会出现各种环境参数差异导致的错误,应用的部署变得非常容易,完美解决了应用打包的问题。
容器的本质是进程,但复杂的应用系统往往是一个进程组。如果为每个进程建立一个容器,那么容器间就有非常密切的交互关系,这样管理起来就更加复杂。
那么,有什么简化的办法吗?
Kubernetes 给出的答案就是 Pod。
2、Pod
在 Kubernetes 的设计中,最基本的管理单位是 Pod,而不是容器。
Pod 是 Kubernetes 在容器之上的一层封装,它由运行在同一主机上的一个或者多个容器构成。而且,同一个 Pod 中的容器可以共享一个 Network Namespace 和同一组数据卷,从而高效的信息交换。因为具有了这些特性,Pod 通常会被类比为物理机。
Pod 存在的意义还在于可以屏蔽容器之间可能存在依赖关系,这样做到 Pod 在拓扑关系上的完全对等,调度起来更加简单。
那么 Pod 又是如何被管理的呢?这就要说到 Kubernetes 的整体架构。
3、整体架构
总体上,Kubernetes 是一个主从结构(Master-Slave)。其中 Master 是一个控制面板,每个 Salve 就是物理节点上代理 Kubelet,Master 与 Kubelet 通讯完成对物理节点上 Pod 和 Container 的管理。外部网络可以直接通过节点上的 Proxy,调用容器中的服务,不需要经过 Master。
对于无状态服务的管理,用户可以定义好无状态服务的副本数量,Kubernetes 调度器就会在不同节点上启动多个 Pod,实现负载均衡和故障转移。多个副本对应的 Pod 是无差别的,所以在节点出现故障时,可以直接在新节点上启动新的 Pod 替换已经失效的 Pod。整个过程不涉及状态迁移问题,管理起来很简单。
但是,作为应用系统的基石,数据库和存储系统都是有状态服务。如果将它们排除在外,那容器化的价值将大打折扣。所以,Kubernetes 在 V1.3 版本开始推出 PetSet 用于管理有状态服务,并在 V1.5 版本更名为 StatefulSet。
通常,我们说数据库是一个有状态的服务,是指服务要依赖持久化数据,也就是存储状态。而对于 StatefulSet 来说,状态又分为两部分,除了存储状态,还有系统的拓扑状态。
拓扑状态是指集群内节点之间的关系,在 Kubernetes 中就是 Pod 之间的关系。
对于应用服务器来说,因为它们互相之间是不感知、不依赖的,所以可以随意创建和销毁。但数据库就不一样了,即使是单体数据库,也会有主从复制的关系,从节点是依赖于主节点的。
而分布式数据库的节点角色更加复杂。除了 CockroachDB 这样的 P2P 架构,多数分布式数据库中的节点拓扑关系是不对等的,会明确的划分为计算节点、数据节点、元数据和全局时钟等。
拓扑状态管理的第一步是标识每个 Pod。我们已经知道,Pod 随时会被销毁、重建,而重建之后 IP 会发生变化,这将导致无法确定每个 Pod 的角色。StatefulSet 采用记录域名的方式,再通过 DNS 解析出 Pod 的 IP 地址。这样 Pod 就具有了稳定的网络标识。
同时,为了确保体现 Pod 之间的依赖关系,StatefulSet 还引入顺序的概念,将 Pod 的拓扑状态,也就是启动顺序,按照 Pod 的“名字 + 编号”的方式固定了下来。
数据库的核心功能是存储,再来看看如何实现存储状态的管理。
早期版本中,提供了两种存储方式,分别是本地临时存储和远程存储。顾名思义,本地临时存储中的数据会随着 Pod 销毁而被清空,所以无法用做数据库的底层持久存储。
远程存储可以保证数据持久化,所以是一种可选方案。具体来说,就是使用持久化卷(Persistent Volume)作为数据的存储载体。当 Pod 进行迁移时,对应的 PV 也会重新挂载,而 PV 的底层实现是分布式文件系统,所以新的 Pod 仍然能访问之前保存的数据。
远程存储虽然可以使用,但是它与分布式数据库的架构设计理念存在很大冲突。分布式数据库都是采用本地磁盘存储的模式,并且对存储层做了很多针对性的优化。如果改为远程存储,这些设计将会失效,导致性能上有较大的损失。
事实上,这就是很多人反对数据库容器化部署的最主要的原因,数据密集型应用对 I/O 资源的消耗巨大,远程存储无法适应这个场景。
针对这个不足,Kubernetes 尝试引入本地持久卷(Local Persistent Volume)来解决。简单来说,就是一个 Local PV 对应一块本地磁盘。开启 Local PV 特性后,调度器会先获取所有节点与 Local PV 对应磁盘的关系,基于这些信息来调度 Pod。
与普通 PV 不同,当挂载 Local PV 的节点宕机并无法恢复时,数据可能就会丢失。这和物理机部署方案面临的问题是一样的,而分布式数据库的多副本机制正好弥补了这一点。
不过 Local PV 推出得较晚,直到 V1.10(2018 年发布)Kubernetes 才相对稳定地支持调度功能,在 2019 年 3 月的 V1.14 中正式发布。
通过对拓扑状态和存储状态的管理,StatefulSet 初步解决了有状态服务的管理问题,但其中的一些关键特性(比如 Local PV)还不够成熟。另外,由于 Kubernetes 的抽象程度比较高,所以在真正实现分布式数据库的部署还需要很多复杂的控制。
控制逻辑的复杂性是与具体软件产品相关的,比如一个常见的问题就是对于 Pod 状态的判断。
Kubernetes 判断节点故障依据是每个节点上的 Kubelet 服务上报的节点状态。但是,Kubelet 作为一个独立的进程,从理论上说,它能否正常工作与用户应用并没有必然联系。那么就有可能出现,Kubelet 无法正常启动,但对应节点上的容器还可以正常运行。
这时,如果不处置原来的 Pod,立即在其他节点创建新的 Pod,应用系统可能就会出现异常。所以,调度过程还需要结合应用本身的状态来进行,其中的复杂性无法被统一封装,于是也就催生了一系列的 Operator。
Operator 的工作原理就是利用了 Kubernetes 的自定义资源,来描述用户期望的部署状态;然后在自定义控制器里,根据自定义 API 对象的变化,来完成具体的部署和运维工作。简单来说,Operator 就是将运维人员对软件操作的知识代码化。
etcd-operator 是最早出现的 Operator,用于实现 etcd 的容器部署。Operator 封装了有状态服务容器化的操作复杂性,对于应用系统上云的有重要的意义,有的分布式数据库厂商也开发了自己的 Operator,比如 TiDB 和 CockroachDB。
加餐:资源调度是 Kubernetes 的一项核心功能,那么除了 Kubernetes 还有哪些集群资源调度系统?它们在设计上有什么差异吗?
随着很多基础系统向分布式架构过渡,集群资源调度成为一个非常广泛的需求,尤其是数据密集型系统。除 Kubernetes 外,知名度最高的可能就是 Yarn 和 Mesos,它们都是大数据生态体系下的工具。
Yarn 是 Hadoop 体系下的核心组件,被称为『数据操作系统』,所调度的资源单位和 Docker 有些相似,比如也使用了 Cgroup 隔离资源。Mesos 更是直接从大数据领域杀入容器编排领域,成为 Kubernetes 的直接竞争对手。
无论是作为程序员还是架构师,都不会忽视测试的重要性,它贯穿于软件工程的整个生命周期,是软件质量的重要保障手段。
POC 的意思是概念验证,通常是指对客户具体应用的验证性测试。那验证性测试又具体要测些什么呢?对于数据密集型系统,很多企业的 POC 都会使用 TPC 基准测试。
TPC(Transaction Processing Performance Council),也就是国际事务性能委员会,是数十家会员公司参与的非盈利组织。它针对数据库不同的使用场景组织发布了多项测试标准,其中被业界广泛接受有 TPC-C 、TPC-H 和 TPC-DS。
这三个测试标准针对不同的细分场景。简单来说,TPC-C 针对 OLTP 场景;TPC-H 针对 OLAP 场景;而更晚些时候推出的 TPC-DS,在 TPC-H 的基础上又针对数据仓库的建模特点做了更新,并且在 2.0 版本中又增加对大数据技术的针对性测试。
这里说的分布式数据库主要服务于 OLTP 场景,所以重点关注 TPC-C。
TPC-C 发布的 标准规范 中,模拟了一家大型电子商务网站的日常业务。根据规范中的背景设定,这家公司的业务覆盖了很大的地理范围,所以设立了很多的仓库来支持邻近的销售区域,每个仓库都要维护 100,000 种商品的库存记录并支持 10 个销售区域,每个销售区域服务 3,000 个客户。
这个场景对应的数据库模型一共包含 9 张表,覆盖了订单创建、支付、订单状态查询、发货和检查库存等五种事务操作。客户会查询已经存在订单的状态或者下一个新的订单。平均每个订单有 10 个订单行(Order-Line),有 1% 订单行的商品在其对应的仓库中没有存货,必须由其他区域的仓库来供货。
可以看出,TPC-C 模拟的整个业务场景和我们日常使用的电子商务网站是非常相似的。所以说,TPC-C 测试场景是很有代表性的 OLTP 业务。
TPC-C 为数据库测试提供了一个开放的测试标准,很多 POC 甚至会直接套用这些数据模型和事务操作。可 POC 做起来真有这么容易吗?
TPC-C 的测试用例除了性能测试,也包含了事务一致性的测试,但实际测试中这部分往往会被忽略。这一方面是为了简化测试过程,另一方面是因为大家会觉得没有必要。既然这些产品都有不少实际案例了,那事务一致性应该就没问题了吧。
可是,对于分布式数据库来说,这真不是个简单的事情,甚至要更加严谨的技术手段来证明。事务一致性方面比 TPC-C 更权威的测试标准是 Jepsen。
Jepsen 是一个开源的分布式一致性验证框架,专门用来测试分布式存储系统,比如分布式数据库、分布式键值系统和分布式消息队列等等。
Jepsen 曾经对很多知名的分布式存储系统进行了测试,而且往往都会发现一些问题,其中分布式数据库就包括 CockroachDB、YugabyteDB、TiDB、VoltDB 和 FaunaDB 等。以下是摘自 Jepsen 官网 的所有测试系统列表。
要知道这些测试并不是 Jepsen 单方面开展的,而都是和产品团队共同协作完成的,并且这些产品厂商还要向 Jepsen 支付费用。由此可见,Jepsen 在分布式系统测试方面,已经具有一定的权威性。
作为开源软件,可以从 Github 上下载到 Jepsen 的源码,所以有些厂商就在它的基础上定制自己的测试系统。但是比较遗憾的是,Jepsen 的作者选择了一种小众的开发语言 Clojure。我猜,这给多数程序员带来了障碍,因为我能想到的 Clojure 项目似乎只有 Storm。
这里,简单介绍下 Jepsen 的架构。
按照 Jepsen 的推荐方案,被测试的分布式系统通常部署在 5 个节点上,而 Jepsen 的程序主要部署在另外的控制节点上。这个控制节点会初始化若干个进程作为分布式系统的访问客户端,当然这里也包括了分布式系统提供的客户端代码。
测试过程中,控制节点要完成三项工作,第一是通过 Generator 生成每个客户端的操作,第二是通过 Nemesis 实现故障注入,最后使用 Checker 分析每个客户端的操作记录来验证一致性。
在整个测试框架中,Nemesis 是特别重要的部分,这是因为 Jepsen 的核心逻辑就是要在各种错误情况下,检测分布式系统还能否正常运行。
『故障注入』在普通测试中并不常见,这里的故障是特指网络分区、时钟不同步这样的底层基础设施层面的问题。因为分布式系统的架构复杂,节点间有千丝万缕的联系,任何软硬件基础设施的错误都可能造成不可收拾的后果,但业务逻辑层面的测试用例又无法覆盖这类场景,所以要靠 Jepsen 来填补这块空白。
既然故障注入这么重要,那么 Jepsen 注入的这些故障就够了吗?是不是要按照自己的业务场景增加一些故障呢?这就要说一下混沌工程的概念了。
混沌工程(Chaos Engineering)最早是由 Netflix 工程师提出来的。他们给出了这样的定义:混沌工程是在分布式系统上进行实验的学科, 旨在提升系统的容错性,建立对系统抵御生产环境中发生不可预知问题的信心。
可以从三个层面来理解这个定义。
1、复杂性
首先,分布式系统的复杂性是混沌工程产生的基础。相比传统的单体系统,分布式系统中包含更多硬件设备,多样化的服务和复杂交互机制。这些因素单独来看似乎是可控的,也有完备的异常处置手段,但当它们组合在一起就会相互影响从而引发不可预知的结果,导致故障发生。而人力是不可能完全阻止这些故障。
混沌工程就是在这些故障发生前,尽可能多的识别出导致这些异常的因素,主动找出系统脆弱环节的方法学。
2、实验
第二关键点是实验。混沌工程与单纯的故障注入是有区别的,混沌工程的输入是尝试性,目的是探索更多可能发生的奇怪场景,促使正常情况下不可预测的事情发生,从而确认系统的稳定性。我想,正是因为结果具有很大的不确定性,这个过程才会称为『实验』。
最早的混沌测试工具是 Netflix 的 Chaos Monkey,它只会注入一种混乱,那就是随机杀死节点。后来逐步发展,混沌测试框架引入的故障越来越多,包括模拟网络通讯延迟、磁盘故障、CPU 负载过高等等。而混沌测试的观察对象也不仅是一致性(像 Jepsen 那样),而是从系统的各个维度上定义一系列稳态指标,观察混乱注入后系统是否能够快速恢复。
3、生产环境
第三点,也是混沌工程非常核心的理念,混沌实验在生产环境进行才会获得更大的价值,因为这样才能真正建立起信心,相信系统能抵御各种故障。不过,这个理念也有一定的争议。比如,你今天坐飞机出差,这时混沌工程师要在飞机的控制系统上注入一些故障,想看看系统会不会崩溃,你能接受吗?我想,正常人都会拒绝吧。
目前,一些分布式数据库也应用混沌工程进行系统测试,例如 GoldenDB、CockroachDB 和 TiDB。
Leslie Lamport 提出的 TLA(Temporal Logical of Actions,行为时态逻辑),是使用数理逻辑来描述系统的时序状态,并验证程序的正确性。
1994 年 Lamport 发表了 同名论文。1999 年 Lamport 又发表 "Specifying Concurrent Systems with TLA+" 论文,提出了 TLA+。TLA+ 是一种软件建模语言,再加上配套的模型校验工具 TLC,这样我们就可以像写程序一样编写 TLA,可以运行来验证最终结果的。2002 年 Lamport 又发布了一本完整的 TLA+ 教科书 Specifying Systems: The TLA+ Language and Tools for Software Engineers。因为 TLA+ 使用的是数学化的表达方式,对程序员并不友好,所以后来又出现了 PlusCal。它比 TLA+ 更接近于编程语言,写好的代码可以很方便的转换成 TLA+ 并使用 TLA+ 的模型验证。
加餐:除了分布式数据库,对于其他类型的分布式存储系统,你知道有哪些主流的测试工具吗?
由于分布式系统的复杂性,测试工具就显得更加重要。除数据库外,比较典型是针对分布式键值系统的测试工具,例如 YCSB。YCSB(Yahoo! Cloud Serving Benchmark)是一个开源的测试框架,你可以从 Github 上下载到源码。
YCSB 支持几乎所有的主流分布式键值系统,包含 HBase、Redis、Cassandra 和 BigTable 等。YCSB 支持典型的 PUT\GET\SCAN 操作,可以很容易地在它的基础上扩展,增加对其他键值系统的支持。
GitHub: jepsen
Jepsen: Analyses
Leslie Lamport: A Temporal Logic of Actions
Leslie Lamport: Specifying Concurrent Systems with TLA+
Leslie Lamport: Specifying Systems: The TLA+ Language and Tools for Software Engineers
Transaction Processing Performance Council: TPC BENCHMARK™ C:Standard Specification (Revision 5.11)
全球化部署本质就是全球范围下的异地多活。总体上看,异地多活的直接目标是要预防区域级的灾难事件,比如城市级的断电,或是地震、洪水等自然灾害。也就是说,在这些灾难发生时,要让系统还能保障关键业务的持续开展。
因此,这里的『异地』通常是指除同城机房外,在距离较远的城市配备的独立机房,在物理距离上跳出区域级灾难的覆盖范围。这个区域有多大呢?从银行业的实践来看,两地机房的布局通常会部署在南北或者东西两个大区,比如深圳到上海,或者北京到武汉,又或者北京到西安,距离一般会超过 1000 公里。
对于银行业的异地机房建设,监管机构是有具体要求的,也就是大中型银行的『两地三中心』布局。而对于互联网行业来说,虽然没有政策性要求,但业务本身的高可用需求,也推动了头部公司进行相应的布局。
异地多活是高可用架构的一种实现方式,它是以整个应用系统为单位,一般来说会分为应用和数据库两部分。
应用部分通常是无状态的,这个无状态就是说应用处理每个请求时是不需要从本地加载上下文数据的。这样启动多个应用服务器就没有什么额外的成本,应用之间也没有上下文依赖,所以就很容易做到多活。
数据库节点要最终持久化数据,所有的服务都要基于已有的数据,并且这些数据内容还在不断地变化。任何新的服务节点在接入这个体系后,相互之间还会存在影响,所以数据库服务有逻辑很重的上下文。因此数据库的多活的难度就大多了,也就产生了不同版本的解读。
数据库层面的异地多活,本质上是要实现数据库的高可用和低延迟,也就是『永不宕机』和『近在咫尺』。即便是单体数据库时代的技术方案,也是朝着这个方向努力的。
异地容灾是异地多活的低配版,它往往是这样的架构。
整个方案涉及同城和异地两个机房,都部署了同样的应用服务器和数据库,其中应用服务器都处于运行状态可以处理请求,也就是应用多活。只有同城机房的数据库处于运行状态,异地机房数据库并不启动,不过会通过底层存储设备向异地机房同步数据。然后,所有应用都链接到同城机房的数据库。
这样当同城机房整体不可用时,异地机房的数据库会被拉起并加载数据,同时异地机房的应用切换到异地机房的数据库。
显然,这个多活只是应用服务器的多活,两地的数据库并不同时提供服务。这种模式下,异地应用虽然靠近用户,但仍然要访问远端的数据库,对延迟的改善并不大。在系统正常运行情况下,异地数据库并没有实际产出,造成了资源的浪费。
按照正常的商业逻辑,当然不能容忍这种资源浪费,所以有了异地读写分离模式。
在异地读写分离模式下,异地数据库和主机房数据库同时对外提供服务,但服务类型限制为只读服务,但只读服务的数据一致性是不保证的。
当主机房完全不可用时,异地机房的运作方式和异地容灾模式大体是一样的。
读写分离模式下,异地数据库也投入了使用,不再是闲置的资源。但是很多场景下,只读服务在业务服务中的占比还是比较低的,再加上不能保证数据的强一致性,适用范围又被进一步缩小。所以,对于部分业务场景,异地数据库节点可能还是运行在低负载水平下。
于是,又有了进一步的双向同步模式。
双向同步模式下,同城和异地的数据库同时提供服务,并且是读写服务均开放。但有一个重要的约束,就是两地读写的对象必须是不同的数据,区分方式可以是不同的表或者表内的不同记录,这个约束是为了保证两地数据操作不会冲突。因为不需要处理跨区域的事务冲突,所以两地数据库之间就可以采用异步同步的方式。
这个模式下,两地处理的数据是泾渭分明的,所以实质上是两个独立的应用实例,或者可以说是两个独立的单元,也就是单元化架构。而两个单元之间又相互备份数据,有了这些数据可以容灾,也可以开放只读服务。当然,这个只读服务同样是不保证数据一致性的。
可以说,双向同步是单元化架构和异地读写分离的混合,异地机房的资源被充分使用了。但双向同步没有解决一个根本问题,就是两地仍然不能处理同样的数据,对于一个完整的系统来说,这还是有很大局限性的。
分布式数据库的数据组织单位是更细粒度的分片,又有了 Raft 协议的加持,所以就有了更加灵活的模式。
比较典型的分布式数据库部署模式是两地三中心五副本。这种模式下,每个分片都有 5 个副本,在同城的双机房各部署两个副本,异地机房部署一个副本。
这个模式有三个特点:
异地备份:保留了『异地容灾』模式下的数据同步功能,但因为同样要保证低延迟,所以也做不到 RPO(Recovery Point Objective, 恢复点目标)为零。
容灾能力:如果同城机房有一个不可用或者是同城机房间的网络出现故障,异地机房节点的投票就会发挥作用,依然可以和同城可用的那个机房共同达成多数投票,那么数据库的服务就仍然可以正常运行,当然这时提交过程必须要异地通讯,所以延迟会受到一定程度影响。
同城访问低延迟:由于 Raft 或 Paxos 都是多数派协议,那么任何写操作时,同城的四个副本就能够超过半数完成提交,这样就不会因为与异地机房通讯时间长而推高数据库的操作延迟。
两地三中心虽然可以容灾,但对于异地机房来说 RPO 不为零,在更加苛刻的场景下,仍然受到挑战。这也就催生了三地五副本模式,来实现 RPO 为零的城市级容灾。
三地五副本模式是两地三中心模式的升级版。两个同城机房变成两个相临城市的机房,这样总共是在三个城市部署。
这种模式在容灾能力和延迟之间做了权衡,牺牲一点延迟,换来城市级别的容灾。比如,在北京和天津建立两座机房,两个城市距离不算太远,延迟在 10 毫秒以内,还是可以接受的。不过,距离较近的城市对区域性灾难的抵御能力不强,还不是真正意义上的异地。
顺着这思路,还有更大规模的三地五中心七副本。但无论如何,只要不放弃低延迟,真正异地机房就无法做到 RPO 为零。
无论是两地三中心五副本还是三地五副本,它们更像是单体数据库异地容灾的加强版。因为,其中的异地机房始终是一个替补的角色,而那些异地的应用服务器也依然花费很多时间访问远端的数据库。
这个并不满足『近在咫尺』的要去。
为什么会这样呢?因为有些分布式数据库会有一个限制条件,就是所有的 Leader 节点必须固定在同城主机房,而这就导致了资源使用率大幅下降。TiDB 和 OceanBase 都是这种情况。
Raft 协议下,所有读写都是发送到 Leader 节点,Follower 节点是没有太大负载的。Raft 协议的复制单位是分片级的,所以理论上一个节点可以既是一些分片的 Leader,又是另一些分片的 Follower。也就是说,通过 Leader 和 Follower 混合部署可以充分利用硬件资源。
但是如果主副本只能存在同一个机房,那就意味着另外三个机房的节点,也就是有整个集群五分之三的资源,在绝大多数时候都处于低负载状态。这显然是不经济的。
这个限制条件是怎么来的,一定要有吗?
其实,这个限制条件就是全局时钟导致的。具体来说,就是单时间源的授时服务器不能距离 Leader 太远,否则会增加通讯延迟,性能就受到很大影响,极端情况下还会出现异常。
增加延迟比较好理解,因为距离远了嘛。那异常是怎么回事呢?
这种异常称为『远端写入时间戳异常』,它的发生过程是这样的:
这个例子说明,如果远端计算节点距离时钟节点过远,那么当并发较大且事务冲突较多时,异地机房就会出现频繁的写入失败。这种业务场景并不罕见,当网购付款时就会出现多个事务在短时间内竞争修改商户的账户余额的情况。
全球化部署的前提是多时间源、多点授时,这样不同分片的主副本就可以分散在多个机房。那么数据库服务可以尽量靠近用户,而应用系统也可以访问本地的分片主副本,整体效果达到等同于单元化部署的效果。
当出现跨多地分片参与同一个分布式事务的情况,全球化部署模式也可以很好地支持。由于参与分片跨越更大的物理范围,所以延迟就受到影响,这一点是无法避免的。还有刚刚提到的『远端写入时间戳异常』,因为每个机房都可以就近获得时钟,那么发生异常的概率也会大幅下降。
全球化部署模式下,异地机房所有的节点都是处于运行状态的,异地机房不再是替补角色,和同城机房一样提供对等的支持能力,所有机房同等重要。
在任何机房发生灾难时,主副本会漂移到其他机房,整个系统处于较为稳定的高可用状态。而应用系统通过访问本地机房的数据库分片主副本就可以完成多数操作,这些发生在距离用户最近的机房,所以延迟可以控制到很低。这样就做到了『永不宕机』和『近在咫尺』。
还有一些例外情况,比如下面这种架构。
主机房保留了过半的副本,这意味着即使是同城备用机房,也不能实现 RPO 为零。那么主备机房之间就退化成了异步复制,这不更像一个单体数据库的主备模式吗?这样部署的意图是什么呢?
有人这么解释这个部署架构:这样可以保证其他机房不存在时,主机房能够单独工作。持这种观点的并不是极少数。还有人会提出来:当主机房只有少数副本时,是不是可以继续工作呢?如果对 Raft 协议有所了解,你会觉得这个要求不可思议,少数副本还继续工作就意味着可能出现脑裂,这怎么可以呢?这里的脑裂是指发生网络分区时,两个少数节点群仍可以保持内部的通讯,然后各自选出的 Leader,分别对外提供服务。
但是换个角度去想,就会发现这些人也有他的理由。既然数据还是完整的,为什么不能提供服务呢?虽然,损失掉了备份机房,但这不影响主机房的工作呀。
通常来说,架构设计的高可用都是面对正常情况的,机器、网络的不可用都是源于设备自身故障不是外力损坏。但是,有没有可能发生恶意攻击呢?
回到两地三中心的模式,如果三个机房之间的光纤网络被挖断,整个数据库就处于不可用状态,因为这时已经不可能有过半数的节点参与投票了。自然状态下,这个事情发生的概率太低了,三中心之间同时有三条路线,甚至有些机构为了提高安全性,会设置并行的多条线路。但是,如果真的是恶意攻击,多搞几台挖掘机就能让银行的系统瘫痪掉,这比黑客攻击容易多了,而且成本也很低的。
同城双机房的设计,其实一种比较保守的方案,它力图规避了主机房之外因素的干扰因素。为了系统平稳运行,甚至可以放弃 RPO 为零这个重要的目标。虽然我并不认为这是最优的方案,但也确实可以引发一些思考。
RPO 为零是一种保障手段,而持续服务才是目标。那么,也就不应该为了追求 RPO 为零这个手段,而让原本还能正常运行的服务终止掉。这是为了手段而放弃了目标,不就成了舍本求末吗?在必要的时候,还是要保证主要目标舍弃次要的东西。
所以,Raft 协议还需要一个降级机制,也就是说不一定要过半投票,仍然维持服务。类似这样的设计在有些分布式数据库中已经可以看到了。因此,我觉得三地五副本模式加上 Raft 降级,应该算是目前比较完善的方案了。
全球化部署还有一个远端读的性能问题。如果分片的主副本在主机房,而异地机房要读取这些数据,如何高效实现呢?读写分离当然可以,但这损失了数据一致性,在有的场景下是不能接受的。CockroachDB 目前支持的 Follower Read 虽然提升了性能,但也是不保证数据一致性的;而 TiDB 的 Follower 目前还不支持跨机房部署。
一个思路就是利用 Raft 协议无『日志空洞』的特点,等到日志时间戳超过查询时间戳,数据就足够新了。但这仅限于写入操作密集的分片,如果分片上的数据比较冷,根本等不到时间戳增长,又该怎么办呢?
其实还有可优化的地方,那就是利用 Raft 协议的合并发送机制。事实上,在真正实现 Raft 协议时,因为每个 Raft 组单独通讯的成本太高,通常会将同一节点的多个 Raft 协议打包后对外发送,这样可以考虑增加其他分片的最后更新时间戳,再通过协议包的发送时间戳来判断包内分片的最新状态。由于节点级别的 Raft 协议是源源不断发送的,这样只要冷分片和热分片在同一个包内,就可以及时得到它的状态。
异地多活的终极目标应该是让异地机房提供全服务,也就是读写服务。这样的意义在于让备用机房的设备处于全面运行的状态,这不仅提升了资源利用率,也时刻确保了各种设备处于可运行的状态,它们的健康状态是实时可知的。
而在异地容灾模式下,备机房必须通过定期演练来确认是可用的,这耗费了人力物力,但并没有转化为真正的生产力,而且仍然存在风险。演练的业务场景足够多吗?出现问题时,这个定期演练的系统真的能够顶上去吗?很多人心中或许也是一个问号。显然,一个时刻运行着的系统比三个月才演练一次的系统更让人放心。
所以,我认为一个具有全球化部署能力,或者说是能真正做到异地多活的分布式数据库,是有非常重要的意义的。
加餐:Raft 的协议降级处理,它允许数据库在仅保留少数副本的情况下,仍然可以继续对外提供服务。这和标准 Raft 显然是不同的,应该如何设计这种降级机制呢?
这个机制并不复杂。首先是固定主副本的位置,只有主机房的副本才能被选举为 Leader,这样就能保证主机房的数据足够新;其次是引入一个『低水位线』的概念,约定节点数量的下限,允许节点数量少于半数但高于下限时,仍然可以对外提供服务。
事实上,这两点不单是理论探讨。第一点在很多分布式数据库的实际应用中都有实现,因为这种约束本身就能降低运维的复杂度;第二点在 GoldenDB 等数据库中已经可以看到类似的设计。
在系统领域,逃生通道是指让业务能够脱离已经不可用的原有系统,在一个安全的备用系统中继续运转。
可以说逃生通道就是系统高可用的一种特殊形式。它的特别之处在于,备用系统要提供差异化的、更高级别的可靠性。为什么备用系统能够提供更高级别的可靠性呢?这是主要由于它采用了异构方案。
对于分布式数据库来说,逃生通道存在的意义应该更容易理解。作为一种新兴的技术,分布式数据库还没有足够多的实践案例来证明自身的稳定性,即使是它本身的高可用架构也不足以打消用户的顾虑。这时候就需要设计一种异构的高可用方案,采用更加稳定、可靠的数据库作为备用系统。通常把这个备用数据库称为『逃生库』。
在分布式数据库的实践没有成为绝对主流前,逃生通道都是一个不容忽视的用户需求。它可以降低实施风险,打消用户的顾虑,减少新技术应用中遇到的各种阻力。
作为一个数据库的高可用方案,首先要解决的是数据恢复的完整性,也就是 RPO。这就需要及时将数据复制到逃生库。通常异构数据库间的数据复制有三种可选方式:
数据文件是指,在数据库之间通过文件导入导出的方式同步数据。这是一种针对全量数据的批量操作,如果要实现增量加载,则需要在数据库表的结构上做特殊设计。显然,数据文件的方式速度比较慢,而且对表结构设计有侵入性,所以不是最优选择。
ETL 是指包含了抽取(Extract)、转换(Transform)和加载(Load)三个阶段的数据加工过程,可以直连两端的数据库,也可以配合数据文件一起用,同样必须依赖表结构的特殊设计才能实现增量抽取,而且在两个数据库之间的耦合更加紧密。
最后,CDC 其实是一个更合适的选择。CDC 的功能可以直接按照字面意思理解,就是用来捕获变更数据的组件,它的工作原理是通过读取上游的 redo log 来生成 SQL 语句发送给下游。它能够捕捉所有 DML 的变化,比如 delete 和 update,而且可以配合 redo log 的设置记录前值和后值。
相比之下,CDC 的业务侵入性非常小,不改动表结构的情况下,下游系统就可以准确同步数据的增量变化。另外,CDC 的时效性较好,对源端数据库的资源占用也不大,通常在 5%-10% 之间。
CDC 是常规的配套工具,技术也比较成熟,很多流式数据的源头就是 CDC。CDC 工具的提供者通常是源端的数据库厂商,传统数据库中知名度较高的有 Oracle 的 OGG(Gold Gate)和 DB2 的 Inforsphere CDC。
对于分布式数据库来说,要选择一个更加成熟、稳定的逃生库,答案显然就是单体数据库了。那具体要选择哪种产品呢?从合理性角度来说,肯定就要选用户最熟悉、最信任、运维能力最强的单体数据库,也就是能够『兜底』的数据库。
按照这个思路,这个异构高可用方案就由分布式数据库和单体数据库共同组成,分布式数据库向单体数据库异步复制数据。使用异步复制的原因是不让单体数据库的性能拖后腿。
这样,当分布式数据库出现问题时,应用就可以切换到原来的单体数据库,继续提供服务。
这个方案看似简单,但其实还有一些具体问题要探讨。
1、日志格式适配
首先是单体数据库的日志适配问题。
逃生方案的关键设计就是数据异步复制,而载体就是日志文件。既然是异构数据库,那日志文件就有差别吧,要怎么处理呢?
PGXC 风格分布式数据库,它的数据节点是直接复用了 MySQL 和 PostgreSQL 单体数据库。这就意味着,如果本来熟悉的单体数据库是 MySQL,现在又恰好采用了 MySQL 为内核的分布式数据库,比如 GoldenDB、TDSQL,那么这个方案处理起来就容易些,因为两者都是基于 Binlog 完成数据复制的。
而如果选择了 NewSQL 分布式数据库也没关系。虽然 NewSQL 没有复用单体数据库,但为了与技术生态更好的融合,它们通常都会兼容某类单体数据库,大多数是在 MySQL 和 PostgreSQL 中选择一个,比如 TiDB 兼容 MySQL,而 CockroachDB 兼容 PostgreSQL。TiDB 还扩展了日志功能,通过 Binlog 组件直接输出 SQL,可以加载到下游的 MySQL。
如果很不幸,选择的分布式数据库并没有兼容原有的单体数据库规范,也没有提供开放性接口,那么就需要做额外的转换工作。
2、处理性能适配
第二个问题是性能匹配问题。用单体数据库来做逃生库,这里其实有一个悖论。那就是,选择分布式的多数原因就是单体不能满足性能需求,那么这个高可用方案要再切换回单体,那单体能够扛得住这个性能压力呢?
比如,用 MySQL+x86 来做 TiDB+x86 的逃生库,这个方案就显得有点奇怪。事实上,在分布式数据库出现前,性能拓展方式是垂直扩展,导致相当一部分对性能和稳定性要求更高的数据库已经切换到 Oracle、DB2 等商用数据库加小型机的模式上。所以,对于金融行业来说,往往需要用 Oracle 或 DB2+ 小型机来做逃生库。
当然,性能问题还有缓释手段,就是增加一个分布式消息队列来缓存数据,降低逃生库的性能压力。这样就形成下面的架构。
另外,有了分布式消息队列的缓冲,就可以更加方便地完成异构数据的格式转换。当然,日志格式的处理是没有统一解决方案的,如果没有能力做二次开发,就只能寄希望于产品厂商提供相应的工具。
这样,完成了日志格式的适配,增加了消息队列做性能适配,但是并不意味者逃生方案就搞定了!
3、事务一致性
数据库事务中操作顺序是非常重要的,而日志中记录顺序必须与事务实际操作顺序严格一致,这样才能确保通过重放日志恢复出相同的数据库。可以说,数据库的备份恢复完全建立在日志的有序性基础上。逃生库也是一样的,要想准确的体现数据,必须得到顺序严格一致的日志,只不过这个日志不再是文件的形式,而是实时动态推送的变更消息流(Change Feed)。
如果一个单体数据库向外复制数据,这是很容易实现的,因为只要将 WAL 的内容对外发布就能形成了顺序严格一致的变更消息流。但对于分布式数据库来说,就是一个设计上的挑战了。
如果事务都在分片内完成,那么每个分片的处理逻辑和单体数据库就是完全一样的,将各自的日志信息发送给 Kafka 即可。
对于 NewSQL 来说,因为每个分片就是一个独立的 Raft Group,有对应的 WAL 日志,所以要按分片向 Kafka 发送增量变化。比如,CockroachDB 就是直接将每个分片 Leader 的 Raft 日志发送出去,这样处理起来最简单。
但是,分布式数据库的复杂性就在于跨分片操作,尤其是跨分片事务,也就是分布式事务。一个分布式事务往往涉及很多数据项,这些数据都可能被记录在不同的分片中,理论上可以包含集群内的所有分片。如果每个分片独立发送数据,下游收到数据的顺序就很可能与数据产生的顺序不同,那么逃生库就会看到不同的执行结果。
通过下面的例子来说明。
小明打算购买银行的自有理财产品。之前他的银行账号有 60,000 元活期存款,他在了解产品的收益情况后,购买了 50,000 元理财并支付了 50 元的手续费。最后,小明账户上有 50,000 元的理财产品和 9,950 元的活期存款。
假设,银行的系统通过下面这段 SQL 来处理相应的数据变更:
begin;// 在小明的活期账户上扣减50,000元,剩余10,000元update balance_num = balance_num - 50000 where id = 2234; // 在小明的理财产品账户上增加50,000元update fund_num = fund_num + 50000 = where id = 2234;// 扣减手续费50元update balance_num = balance_num - 50 where id = 2234;// 其他操作……commit;
小明的理财记录和活期账户记录对应两个分片,这两个分片恰好分布在 Node1 和 Node2 这两个不同节点上,两个分片各自独立发送变更消息流。
同一个事务中发生变更的数据必定拥有用相同的提交时间戳,所以使用时间戳就可以回溯同一事务内的相关操作。同时,晚提交的事务一定拥有更大的时间戳。
那么按照这个规则,逃生库在 T1 时刻发现已经收到了时间戳为 ts2 的变更消息,而且 ts2 > ts1,所以判断 ts1 事务的操作已经执行完毕。执行 ts1 下的所有操作,于是我们在逃生库看到小明的活期账户余额是 10,000 元,理财账户余额是 50,000 元。
但是,这个结果显然是不正确的。在保证事务一致性的前提下,其他事务看到的小明活期账户余额只可能是 60,000 元或 9,950,这两个数值一个是在事务开始前,一个是在事务提交后。活期账户余额从 60,000 变更到 10,000 对于外部是不可见的,逃生库却暴露了这个数据,没有实现已提交读(Read Committed)隔离级别,也就是说,没有实现基本的事务一致性。
产生这个问题原因在于,变更消息中的时间戳只是标识了数据产生的时间,这并不代表逃生库能够在同一时间收到所有相同时间戳的变更消息,也就不能用更晚的时间戳来代表前一个事务的变更消息已经接受完毕。那么,要怎么知道同一时间戳的数据已经接受完毕了呢?
为了解决这个问题,CockroachDB 引入了一个特殊时间戳标志『Resolved』,用来表示当前节点上这个时间戳已经关闭。结合上面的例子,它的意思就是一旦 Node1 发出“ts1:Resolved”消息后,则 Node1 就不会再发出任何时间戳小于或者等于 ts1 的变更消息。
在每个节点的变更消息流中增加了 Resolved 消息后,逃生库就可以在 T2 时间判断,所有 ts1 的变更消息已经发送完毕,可以执行整个事务操作了。
CDC 的主要工作场景是为分析性系统推送准实时的数据变更,支持类似 Kappa 架构的整体方案。但是对于分布式数据库来说,CDC 成为了逃生通道的核心组件,远远超过它在单体数据库中的重要性。目前很多分布式数据库已经推出了相关的功能或组件,例如 CockroachDB、TiDB、GoldenDB 等。
加餐:对单个数据分片来说 WAL 本身就是有序的,直接开放就可以提供顺序一致的变更消息。而逃生通道作为一个异构高可用方案,往往会使用 Kafka 这样的分布式消息队列。那么单分片的变更消息流在逃生通道中还能保证顺序一致吗?
答案是不能保证顺序一致,原因在于 Kafka 的分布式架构设计。
Kafka 按照 Topic 组织消息,而为了提升性能,一个 Topic 会包含多个 Partition,分布到不同节点(Broker)。那么,所有发送到 Kafka 的消息,实际上是会按照一定的负载均衡策略发送到不同的 Broker 上。
这样的设计意味着,在 Broker 内部消息是有序的,但多个 Broker 之间消息是无序的。作为消息的消费者,即使接收的只是单个分片的变更消息流,也不能用当前消息的时间戳来判定小于这个时间戳的消息已经发送完毕。
计算引擎在海量数据查询下的一些优化策略,包括计算下推和更复杂的并行执行框架。这些策略对应了从查询请求输入到查询计划这个阶段的工作。那么,整体查询任务的下一个阶段就是查询计划的执行,承担这部分工作的组件一般称为查询执行引擎。
单从架构层面看,查询执行引擎与分布式架构无关,但是由于分布式数据库要面对海量数据,所以对提升查询性能相比单体数据库有更强烈的诉求,更关注这部分的优化。
查询执行引擎是否高效与其采用的模型有直接关系,模型主要有三种:火山模型、向量化模型和代码生成。
火山模型(Volcano Model)也称为迭代模型(Iterator Model),是最著名的查询执行模型,早在 1990 年就在论文 "Volcano, an Extensible and Parallel Query Evaluation System" 中被提出。主流的 OLTP 数据库 Oracle、MySQL 都采用了这种模型。
在火山模型中,一个查询计划会被分解为多个代数运算符(Operator)。每个 Operator 就是一个迭代器,都要实现一个 next() 接口,通常包括三个步骤:
通过火山模型,查询执行引擎可以优雅地将任意 Operator 组装在一起,而不需要考虑每个 Operator 的具体处理逻辑。查询执行时会由查询树自顶向下嵌套调用 next() ,数据则自底向上地被拉取处理。所以,这种处理方式也称为拉取执行模型(Pull Based)。
为了更好地理解火山模型的拉取执行过程,这里举一个聚合计算的例子,它来自 Databricks 的一篇文章(Sameer Agarwal et al. (2016))。
select count(*) from store_sales where ss_item_sk = 1000;
开始从扫描运算符 TableScan 获取数据,通过过滤运算符 Filter 开始推动元组的处理。然后,过滤运算符传递符合条件的元组到聚合运算符 Aggregate。
『元组』这个词大致就是指数据记录(Record),讨论算法时学术文献中普遍会使用元组这个词。
火山模型的优点是处理逻辑清晰,每个 Operator 只要关心自己的处理逻辑即可,耦合性低。但是它的缺点也非常明显,主要是两点:
什么是虚函数呢?:
在火山模型中,处理一个元组最少需要调用一次 next() 函数,这个 next() 就是虚函数。这些函数的调用是由编译器通过虚函数调度实现的;虽然虚函数调度是现代计算机体系结构中重点优化部分,但它仍然需要消耗很多 CPU 指令,所以相当慢。
CPU 寄存器和内存:
在火山模型中,每次一个算子给另外一个算子传递元组的时候,都需要将这个元组存放在内存中,以行为组织单位很容易带来 CPU 缓存失效。
循环展开(Loop unrolling):
当运行简单的循环时,现代编译器和 CPU 是非常高效的。编译器会自动展开简单的循环,甚至在每个 CPU 指令中产生单指令多数据流(SIMD)指令来处理多个元组。
单指令多数据流(SIMD):
SIMD 指令可以在同一 CPU 时钟周期内,对同列的不同数据执行相同的指令。这些数据会加载到 SIMD 寄存器中。
Intel 编译器配置了 AVX-512(高级矢量扩展)指令集,SIMD 寄存器达到 512 比特,就是说可以并行运算 16 个 4 字节的整数。
在过去大概 20 年的时间里火山模型都运行得很好,主要是因为这一时期执行过程的瓶颈是磁盘 I / O。而现代数据库大量使用内存后,读取效率大幅提升,CPU 就成了新的瓶颈。因此,现在对火山模型的所有优化和改进都是围绕着提升 CPU 运行效率展开的。
要对火山模型进行优化,一个最简单的方法就是减少执行过程中 Operator 的函数调用。比如,通常来说 Project 和 Filter 都是常见的 Operator,在很多查询计划中都会出现。OceanBase1.0 就将两个 Operator 融合到了其它的 Operator 中。这样做有两个好处:
分支预测能力:
分支预测是指 CPU 执行跳转指令时的一种优化技术。当出现程序分支时 CPU 需要执行跳转指令,在跳转到目的地址之前无法确定下一条指令,就只能让流水线等待,这就降低了 CPU 效率。为了提高效率,设计者在 CPU 中引入了一组寄存器,用来专门记录最近几次某个地址的跳转指令。
这样,当下次执行到这个跳转指令时,就可以直接取出上次保存的指令,放入流水线。等到真正获取到指令时,如果证明取错了则推翻当前流水线中的指令,执行真正的指令。
这样即使出现分支也能保持较好的处理效率,但是寄存器的大小总是有限的,所以总的来说还是要控制程序分支,分支越少流水线效率就越高。
刚刚说的运算符融合是一种针对性的优化方法,优点是实现简便而且快速见效,但进一步的提升空间很有限。
因此,学术界还有一些更积极的改进思路,主要是两种。一种是优化现有的迭代模型,每次返回一批数据而不是一个元组,这就是向量化模型(Vectorization);另一种是从根本上消除迭代计算的性能损耗,这就是代码生成(Code Generation)。
向量化模型最早提出是在 MonerDB-X100(Vectorwise)系统,已成为现代硬件条件下广泛使用的两种高效查询引擎之一。
向量化模型与火山模型的最大差异就是,其中的 Operator 是向量化运算符,是基于列来重写查询处理算法的。所以简单来说,向量化模型是由一系列支持向量化运算的 Operator 组成的执行模型。
看一下向量化模型怎么处理聚合计算:
通过这个执行过程可以发现,向量化模型依然采用了拉取式模型。它和火山模型的唯一区别就是 Operator 的 next() 函数每次返回的是一个向量块,而不是一个元组。向量块是访问数据的基本单元,由固定的一组向量组成,这些向量和列 / 字段有一一对应的关系。
向量处理背后的主要思想是:按列组织数据和计算,充分利用 CPU,把从多列到元组的转化推迟到较晚的时候执行。这种方法在不同的操作符间平摊了函数调用的开销。
向量化模型首先在 OLAP 数据库中采用,与列式存储搭配使用可以获得更好的效果,例如 ClickHouse。
这里定义的分布式数据库都是面向 OLTP 场景的,所以不能直接使用列式存储,但是可以采用折中的方式来实现向量化模型,也就是在底层的 Operator 中完成多行到向量块的转化,上层的 Operator 都是以向量块作为输入。这样改造后,即使是与行式存储结合,仍然能够显著提升性能。在 TiDB 和 CockroachDB 的实践中,性能提升可以达到数倍,甚至数十倍。
以 Hash Join 为例,来看下向量化模型的执行情况。
Hash Join 的执行逻辑,就是两表关联时,以 Inner 表的数据构建 Hash 表,然后以 Outer 表中的每行记录,分别去 Hash 表查找。
Class HashJoin Primitives probeHash_, compareKeys_, bulidGather_; ... int HashJoin::next() // 消费构建侧的数据构造 Hash 表,代码省略 ... // 获取探测侧的元组 int n = probe->next() // 计算 Hash 值 vec<int> hashes = probeHash_.eval(n) // 找到 Hash 匹配的候选元组 vec<Entry*> candidates = ht.findCandidates(hashes) vec<Entry*, int> matches = {} // 检测是否匹配 while(candidates.size() > 0) vec<bool> isEqual = compareKeys_.eval(n, candidates) hits, candidates = extractHits(isEqual, candidates) matches += hits // 从 Hash 表收集数据为下个 Operator 缓存 buildGather_.eval(matches) return matches.size()
可以看到这段处理逻辑中的变量都是 Vector,还有事先定义一些专门处理 Vector 的元语(Primitives)。
总的来说,向量化执行模型对火山模型做了针对性优化,在以下几方面有明显改善:
与向量化模型并列的另一种高效查询执行引擎就是『代码生成』,这个名字听上去可能有点奇怪,但确实没有更好翻译。代码生成的全称是以数据为中心的代码生成(Data-Centric Code Generation),也被称为编译执行(Compilation)。
在解释『代码生成』前,先来分析一下手写代码和通用性代码的执行效率问题。还是继续使用讲火山模型时提到的例子,将其中 Filter 算子的实现逻辑表述如下:
class Filter(child: Operator, predicate: (Row => Boolean)) extends Operator { def next(): Row = { var current = child.next() while (current == null || predicate(current)) { current = child.next() } return current }}
如果专门对这个操作编写代码(手写代码),那么大致是下面这样:
var count = 0for (ss_item_sk in store_sales) { if (ss_item_sk == 1000) { count += 1 }}
在两种执行方式中,手写代码显然没有通用性,但 Databricks 的工程师对比了两者的执行效率,测试显示手工代码的吞吐能力要明显优于火山模型。
手工编写代码的执行效率之所以高,就是因为它的循环次数要远远小于火山模型。而代码生成就是按照一定的策略,通过即时编译(JIT)生成代码可以达到类似手写代码的效果。
此外,代码生成是一个推送执行模型(Push Based),这也有助于解决火山模型嵌套调用虚函数过多的问题。与拉取模型相反,推送模型自底向上地执行,执行逻辑的起点直接就在最底层 Operator,其处理完一个元组之后,再传给上层 Operator 继续处理。
Hyper 是一个深入使用代码生成技术的数据库,Hyper 实现的论文(Thomas Neumann (2011))中有一个例子,这里用来理解它的执行过程。
要执行的查询语句是这样的:
select * from R1,R3, (select R2.z,count(*) from R2 where R2.y=3 group by R2.z) R2 where R1.x=7 and R1.a=R3.b and R2.z=R3.c
SQL 解析后会得到一棵查询树,就是下图的左侧的样子,可以找到 R1、R2 和 R3 对应的是三个分支。
要获得最优的 CPU 执行效率,就要使数据尽量不离开 CPU 的寄存器,这样就可以在一个 CPU 流水线(Pipeline)上完成数据的处理。但是,查询计划中的 Join 操作要生成 Hash 表加载到内存中,这个动作使数据必须离开寄存器,称为物化(Materilaize)。所以整个执行过程会被物化操作分隔为 4 个 Pipeline。而像 Join 这种会导致物化操作的 Operator,在论文称为 Pipeline-breaker。
通过即时编译生成代码得到对应 Piepline 的四个代码段,可以表示为下面的伪码:
代码生成消除了火山模型中的大量虚函数调用,让大部分指令可以直接从寄存器取数,极大地提高了 CPU 的执行效率。
代码生成的基本逻辑清楚了,但它的工程实现还是挺复杂的,所以会有不同粒度的划分。比如,如果是整个查询计划的粒度,就会称为整体代码生成(Whole-Stage Code Generation),这个难度最大;相对容易些的是代码生成应用于表达式求值(Expression Evaluation),也称为表达式代码生成。在 OceanBase 2.0 版本中就实现了表达式代码生成。
向量化和代码生成是两种高效查询模型,并没有最先出现在分布式数据库领域,反而是在 OLAP 数据库和大数据计算领域得到了更广泛的实践。ClickHouse 和 Spark 都同时混用了代码生成和向量化模型这两项技术。目前 TiDB 和 CockroachDB 都应用向量化模型,查询性能得到了一个数量级的提升。OceanBase 中则应用了代码生成技术优化了表达式运算。
RUM 猜想来自论文 "Designing Access Methods: The RUM Conjecture"(Manos Athanassoulis et al.(2016)),同时被 SIGMOD 和 EDBT 收录。它说的是,对任何数据结构来说,在 Read Overhead(读)、Update Overhead(写) 和 Memory or Storage Overhead(存储) 中,同时优化两项时,需要以另一项劣化作为代价。论文用一幅图展示了常见数据结构在这三个优化方向中的位置。
在这张图中,可以看到两个非常熟悉的数据结构 B-Tree 和 LSM,它们被用于分布式数据库的存储引擎中,前者(实际是 B+Tree,B-Tree 的变体)主要用于 PGXC,后者则主要用于 NewSQL。这是不是代表 PGXC 就是要针对读操作,而 NewSQL 是针对写操作呢?并没有这么简单,还是要具体分析数据结构的使用过程。
B+Tree 是对读操作优化的存储结构,能够支持高效的范围扫描,叶节点之间保留链接并且按主键有序排列,扫描时避免了耗时的遍历树操作。它是单体数据库广泛使用的数据结构。
用一个例子来演示下 MySQL 数据库的 B+Tree 写操作过程:
下面这张图中展示了一棵高度为 2 的 B+Tree,数据存储在 5 个页表中,每页可存放 4 条记录。为了方便理解,略去了叶子节点指向数据的指针以及叶子节点之间的顺序指针。
B+Tree 由内节点(InterNode)和叶节点(LeafNode)两类节点构成,前者仅包含索引信息,后者则携带了指向数据的指针。当插入一个索引值为 70 的记录,由于对应页表的记录已满,需要对 B+Tree 重新排列,变更其父节点所在页表的记录,并调整相邻页表的记录。完成重新分布后的效果如下:
在这个写入过程中存在两个问题:
1、写放大
本来仅需要一条写入记录(黄色标注),实际上更新了 3 个页表中的 7 条索引记录,额外的 6 条记录(绿色标注)是为了维护 B+Tree 结构产生的写放大。
为了度量写放大的程度,相关研究中引入了写放大系数(Write Amplification Factor,WAF)这个指标,就是指实际写入磁盘的数据量和应用程序要求写入数据量之比。对于空间放大有类似的度量单位,也就是空间放大系数(Space Amplification Factor, SAF)。
这个例子中的 WAF 是 7。
2、存储不连续
虽然新增叶节点会加入到原有叶节点构成的有序链表中,整体在逻辑上是连续的,但是在磁盘存储上,新增页表申请的存储空间与原有页表很可能是不相邻的。这样,在后续包含新增叶节点的查询中,将会出现多段连续读取,磁盘寻址的时间将会增加。
也就是说,虽然 B+Tree 结构是为读取做了优化,但如果进行大量随机写还是会造成存储的碎片化,从而导致写放大和读放大。
填充因子(Factor Fill)是一种常见的优化方法,它的原理就是在页表中预留一些空间,这样不会因为少量的数据写入造成树结构的大幅变动。但填充因子的设置也很难拿捏,过大则无法解决写放大问题;过小会造成页表数量膨胀,增大对磁盘的扫描范围,降低查询性能。
相对于 PGXC,NewSQL 风格分布式数据库的底层存储引擎则主要采用 LSM-Tree。
LSM-Tree(Log Structured-Merge Tree)由 Patrick O’Neil 在 1996 年的 同名论文中首先提出。而后 Google 在 Bigtable(Fay Chang et al.(2008))中使用了这个模型,它的大致处理过程如下图所示:
系统接收到写操作后会记录日志(Tablet Log)并将数据写入内存(Memtable),这时写操作就可以返回成功了。而在系统接收到读操作时,会在内存和磁盘文件中查找对应的数据。
LSM 是分成三步完成了数据的落盘:
与 B+Tree 的最大不同是 LSM 将随机写转换为顺序写,这样提升了写入性能。另外,Flush 操作不会像 B+Tree 那样产生写放大。
真正的写放大就发生在 Compact 这个动作上。Compact 有两个关键点,一是选择什么时候执行,二是要将哪些 SSTable 合并成一个 SSTable。这两点加起来称为『合并策略』。
刚刚在例子中描述的就是一种合并策略,称为 Size-Tiered Compact Strategy,简称 Tiered。BigTable 和 HBase 都采用了 Tiered 策略。它的基本原理是,每当某个尺寸的 SSTable 数量达到既定个数时,将所有 SSTable 合并成一个大的 SSTable。这种策略的优点是比较直观,实现简单,但是缺点也很突出。下面从 RUM 的三个角度来分析下:
1、读放大
执行读操作时,由于单个 SSTable 内部是按照 Key 顺序排列的,那么查找方法的时间复杂度就是 O(logN)。因为 SSTable 文件是按照时间间隔产生的,在 Key 的范围上就会存在交叉,所以每次读操作都要遍历所有 SSTable。如果有 M 个 SSTable,整体时间复杂度就是 O(MlogN)。执行 Compact 后,时间复杂度降低为 O(log(MN))。在只有一个 SSTable 时,读操作没有放大。
2、写放大
Compact 会降低读放大,但却带来更多的写放大和空间放大。其实 LSM 只是推迟了写放大,短时间内,可以承载大量并发写入,但如果是持续写入,则需要一部分 I / O 开销用于处理 Compact。
如果是采用 Tiered 策略,LSM 的写放大比 B+Tree 还严重。此外,Compact 是一个很重的操作,占用大量的磁盘 I / O,会影响同时进行的读操作。
3、空间放大
从空间放大的角度看,Tiered 策略需要有两倍于数据大小的空间,分别存储合并前的多个 SSTable 和合并后的一个 SSTable,所以 SAF 是 2,而 B+Tree 的 SAF 是 1.33。
LSM 还有另一种合并策略,Leveled Compact Strategy,简称 Leveled 策略。
Leveled Compact Strategy
Tiered 策略之所以有严重的写放大和空间放大问题,主要是因为每次 Compact 需要全量数据参与,开销自然就很大。那么如果每次只处理小部分 SSTable 文件,就可以改善这个问题了。
Leveled 就是这个思路,它的设计核心就是将数据分成一系列 Key 互不重叠且固定大小的 SSTable 文件,并分层(Level)管理。同时,系统记录每个 SSTable 文件存储的 Key 的范围。Leveled 策略最先在 LevelDB 中使用,也因此得名。后来从 LevelDB 上发展起来的 RocksDB 也采用这个策略。
接下来,详细说明一下这个处理过程:
说完处理流程,再从 RUM 三个角度来分析下:
1、读放大
因为存在多个 SSTable,Leveled 策略显然是存在读放大的。因为 SSTable 是有序的,如果有 M 个文件,则整体计算的时间复杂度是 O(MlogN)。这个地方还可以优化。通常的方法是在 SSTable 中引入 Bloom Filter(BF),这是一个基于概率的数据结构,可以快速地确定一个 Key 是否存在。这样执行 Get 操作时,先读取每一个 SSTable 的 BF,只有这个 Key 存在才会真的去文件中查找。
那么对于多数 SSTable,时间复杂度是 O(1)。L0 层的 SSTable 无序,所有都需要遍历,而从 L1 层开始,每层仅需要查找一个 SSTable。那么优化后的整体时间复杂度就是 O(X+L-1+logN),其中 X 是 L0 的 SSTable 数量,L 是层数。
2、写放大
Leveled 策略对写放大是有明显改善的,除了 L0 以外,每次更新仅涉及少量 SSTable。但是 L0 的 Compact 比较频繁,所以仍然是读写操作的瓶颈。
3、空间放大
数据在同层的 SSTable 不重叠,这就保证了同层不会存在重复记录。而由于每层存储的数据量是按照比例递增的,所以大部分数据会存储在底层。因此,大部分数据是没有重复记录的,所以数据的空间放大也得到了有效控制。
在分布式数据库中,数据存储是在每个数据节点上各自进行的,所以原理和过程是完全一样的。因此,TiDB 和 CockroachDB 干脆直接使用 RocksDB 作为单机存储引擎。在这两个分布式数据库的架构中,RocksDB 都位于 Raft 协议之下,承担具体的数据落地工作。
他们为什么选择 RocksDB 呢?CockroachDB 的官方解释是,他们的选择完全不是因为要考虑 LSM 的写优化能力,而是因为 RocksDB 作为一个成熟的单机存储引擎,可以加速 CockroachDB 的开发过程,而 RocksDB 本身也非常优秀,是他们能找到的最好选择。另一方面,TiDB 虽然没有提到选择一个写优化的存储引擎是否有特殊意义,但同样也认为 RocksDB 是一个非常优秀开源项目,可以满足他们对单机引擎的各种要求。
不过,很有意思的地方是,经过了早期版本的演进,TiDB 和 CockroachDB 不约而同都推出了自己的单机存储引擎。
OceanBase 选择了自研方式,也就没有直接引用 RocksDB,但它的存储模型基本上也是 LSM,也就同样需要面对 Compact 带来的一系列问题。来看看 OceanBase 是怎么优化的。
宏块与微块
在 Compact 过程中,被合并文件中的所有数据都要重写到新文件中。其实,对于那些没有任何修改的数据,这个过程是可以被优化的。
OceanBase 引入了宏块与微块的概念,微块是数据的组织单元,宏块则由微块组成。这样在进行 Compact 操作时,可以在宏块和微块两个级别判断,是否可以复用。如果在这两个级别数据没有发生实质变化,则直接进行块级别的拷贝,这样就省去了更细粒度的数据解析、编码以及计算校验和(Checksum)等操作。
轮转合并
OceanBase 还在多副本基础上设计了轮转合并机制。根据 Raft 协议或者 Paxos 协议,总有多个副本同时存储着最新的数据,那么就可以用多副本来解耦 Compact 操作和同时段的查询操作,避免磁盘 I/O 上的竞争。
它的大致设计思路是这样的,将 Compact 操作放在与 Leader 保持数据同步的 Follower 上执行,而 Leader 节点则保持对外提供查询服务。当 Compact 完成后,改由那个 Follower 对外提供查询服务,Leader 和其他副本则去执行 Compact。
OceanBase 的这两项优化虽然没有降低写放大系数,但却有效减少了 Compact 过程中的 I/O 竞争。
OceanBase 的优化还停留在工程层面,那么还有没有更好的理论模型呢?
2016 年真的出现了新的模型,这就是 WiscKey。论文 "WiscKey: Separating Keys from Values in SSD-conscious Storage"(Lanyue Lu et al.(2016))阐述了这种模型的设计思想。WiscKey 提出的改进是通过将 value 分离出 LSM-Tree 的方法来降低写放大。
WiscKey 的主要设计思想是,在 SSTable 的重写过程中,核心工作是对 Key 进行整理,保证多个 SSTable 的 Key 范围不重叠,且内部有序。而这个过程中 Value 的重写是没有太大价值的,而从实践看,Value 占用的存储空间远远大于 Key。这意味着大量的写操作和空间成本被浪费了。所以 WiscKey 提出将 Value 从 SSTable 中分离出来单独存储,这样就降低了写放大系数。
Value 单独存储的问题是按照 Key 连续读取数据时,对应的 Value 并不是连续存储,磁盘寻址成本增大。而 WiscKey 的设计前提是使用 SSD 替换 HDD,SSD 的随机读写效率接近于顺序读写,所以能够保持较高的整体效率。事实上,过高的写放大也会严重缩短 SSD 的使用寿命。WiscKey 就是针对 SSD 提出的存储模型。
TiDB 的新存储引擎 TiTan 就是受到 WiscKey 的启发,它目标之一就是将 Value 从 LSM-Tree 中分离出来单独存储,以降低写放大。
CockroachDB 的单机存储引擎 Pebble。Pebble 并不是由于 WiscKey 的模型改进,它的出现完全是工程因素。首先是 Go 与 C 之间的调用障碍(CGo barrier)。
CGo barrier
CockroachDB 采用 Go 作为编程语言,而 RocksDB 是由 C++ 编写的。所以 CockroachDB 要面临 Go 到 C 的之间的调用障碍。测试表明,每一次对 RocksDB 的调用都要额外付出 70 纳秒的延迟。这个数字虽然看上去并不大,但是由于 K / V 操作非常频繁,总体成本仍然很可观。CockroachDB 也通过一些设计来降低延迟,比如将多次 K / V 操作合并成一次对 RocksDB 的操作,但是这一方面带来设计的复杂性,另外也无法从根本上解决问题。
值得一提的是,TiDB 也使用了 Go 语言作为主力开发语言,同样面临了这个问题。TiDB 最终是在底层存储 TiVK 放弃 Go 而选择 Rust 的,部分原因就是 Rust 与 C++ 之间的调用成本要低得多。
代码膨胀
CockroachDB 替换存储引擎的另一个原因是,RocksDB 的代码量日益膨胀。早期 RocksDB 的代码行数仅是 30k,而今天已经增加到 350k+。这很大程度是由于 RocksDB 的成功,很多软件选择了 RocksDB 作为存储引擎,包括 MySQL(MyRocks)、Flink 等,这驱动 RocksDB 增加了更丰富的特性,也导致体量越来越大。但这些丰富的特性对 CockroachDB 来说并不是必须的,反而引入了不必要的变更风险。而 Cockraoch 开发的 Pebble,代码行数则仅有 45k,的确是小巧得多了。
所以,总的来说,CockraochDB 替换存储引擎是工程原因,而其中 CGO barrier 的这个问题更像是在偿还技术债。
到这里,分布式数据库下典型的存储引擎就介绍完了,它们都适用于 OLTP 场景,要求在小数据量下具有高效的读写能力。而 OLAP 下的存储引擎则有很大的不同,通常不会对单笔写入有太高的要求,但也有例外,OLAP 存储引擎 TiFlash,由于实时分析的要求,它必须及时落盘来自 TiKV 的数据,同样需要很高的写入速度。
高效写入的秘密就在于它的存储模型 Delta Tree 采用了类似 LSM 的结构。其中的 Delta Layer 和 Stable Layer,分别对应 LSM Tree 的 L0 和 L1,Delta Layer 可以顺序写入。
加餐:Scan 操作是否可以使用 Bloom Filter 来加速,如果可以又该如何设计呢?
Bloom Filter 是很有意思的数据结构,通过多个 Hash 函数将一个数值映射到某几个字节上。这样用少量的字节就可以存储大量的数值,同时能快速地判断某个数值是否存在。虽然没有做映射的数值会有一定概率的误报,但可以保证“数值不存在”是绝对准确的,这就是假阳性。
这种模式显然是不能直接支持 Scan 操作的,这是需要将数值做一定的转化。这个方法在 RocksDB 中称为『Prefix Bloom Filter』,也就是取 Key 的左前缀(Prefix)进行判断。因为 K / V 系统是按照 Key 字典序排列的,那就是说相邻的 Key 通常具有相同的 Prefix,这种匹配方式相当于对一组 Key 做了检验,可以更好地适应 Scan 的特点。
Goetz Graefe: Volcano, an Extensible and Parallel Query Evaluation System
Peter Boncz et al.: MonetDB/X100: Hyper-Pipelining Query Execution
Sameer Agarwal et al.: Apache Spark as a Compiler: Joining a Billion Rows per Second on a Laptop
Thomas Neumann: Efficiently Compiling Efficient Query Plans for Modern Hardware
Fay Chang et al.: Bigtable: A Distributed Storage System for Structured Data
Lanyue Lu et al.: WiscKey: Separating Keys from Values in SSD-conscious Storage
Manos Athanassoulis et al: Designing Access Methods: The RUM Conjecture
Patrick O’Neil et al.: The Log-Structured Merge-Tree (LSM-Tree)
无论是单体数据库还是分布式数据库,关联操作的语义始终没有变,一些经典算法也保持了很好的延续性。
常见的关联算法有三大类,分别是嵌套循环(Nested Loop Join)、排序归并(Sort-Merge Join)和哈希(Hash Join)。
所有的嵌套循环算法都由内外两个循环构成,分别从两张表中顺序取数据。其中,外层循环表称为外表(Outer 表),内层循环表则称为内表(Inner 表)。因为这个算法的过程是由遍历 Outer 表开始,所以 Outer 表也称为驱动表。在最终得到的结果集中,记录的排列顺序与 Outer 表的记录顺序是一致的。
根据在处理环节上的不同,嵌套循环算法又可以细分为三种,分别是 Simple Nested-Loop Join(SNLJ)、Block Nested-Loop Join(BNJ)和 Index Lookup Join(ILJ)。
Simple Nested Loop Join:
SNLJ 是最简单粗暴的算法,所以也称为 Simple Nested-Loop Join。有些资料中会用 NLJ 指代 SNLJ。
SNLJ 的执行过程是这样的:
这样看,SNLJ 算法虽然简单,但也很笨拙,存在非常明显的性能问题。原因在于,每次为了匹配 Outer 表的一条记录,都要对 Inner 表做一次全表扫描操作。而全表扫描的磁盘 I / O 开销很大,所以 SNLJ 的成本很高。
Block Nested-Loop Join:
BNJ 是对 SNLJ 的一种优化,改进点就是减少 Inner 表的全表扫描次数。BNJ 的变化主要在于步骤 1,读取 Outer 表时不再只取一条记录,而是读取一个批次的 x 条记录,加载到内存中。这样执行一次 Inner 表的全表扫描就可以比较 x 条记录。在 MySQL 中,这个 x 对应一个叫做 Join Buffer 的设置项,它直接影响了 BNJ 的执行效率。
与 SNLJ 相比,BNJ 虽然在时间复杂度都是 O(m*n)(m 和 n 分别是 Outer 表和 Inner 表的记录行数),但磁盘 I / O 的开销却明显降低了,所以效果优于 SNLJ。
Index Lookup Join:
SNLJ 和 BNJ 都是直接在数据行上扫描,并没有使用索引。所以,这两种算法的磁盘 I / O 开销还是比较大的。
Index Lookup Join(ILJ)就是在 BNJ 的基础上使用了索引,算法执行过程是这样的:
ILJ 的主要优化点是对 Inner 表进行索引扫描。为什么不让 Outer 表也做索引扫描呢?
Outer 表当然也可以走索引。但是,BNJ 在 Inner 表上要做多次全表扫描成本最高,所以 Inner 表上使用索引的效果最显著,也就成为了算法的重点。而对 Outer 表来说,因为扫描结果集要放入内存中暂存,这意味着它的记录数是比较有限的,索引带来的效果也就没有 Inner 表那么显著,所以在定义中没有强调这部分。
排序归并算法就是 Sort-Merge Join(SMJ),也被称为 Merge Join。SMJ 可以分为排序和归并两个阶段:
简单来说,SMJ 就是先要把两个数据集合变成两个数据序列,也就是有序的数据单元,然后再做循环比对。这样算下来,它的计算成本是两次排序再加两次循环。
这成本比 NLJ 还要高,所以选择 SMJ 是有前提的,而这个前提就是表的记录本身就是有序的,否则就不划算了。索引是天然有序的,如果表的连接键刚好是索引列,那么 SMJ 就是三种嵌套循环算法中成本最低的,它的时间复杂度只有 O(m+n)。
哈希连接的基本思想是取关联表的记录,计算连接键上数据项的哈希值,再根据哈希值映射为若干组,然后分组进行匹配。这个算法体现了一种分治思想。具体来说,常见的哈希连接算法有三种,分别是 Simple Hash Join、Grace Hash Join 和 Hybrid Hash Join。
Simple Hash Join:
Simple Hash Join,也称为经典哈希连接(Classic Hash Join),它的执行过程包括建立阶段(Build Phase)和探测阶段(Probe Phase)。
1、建立阶段
选择一张表作为 Inner 表,对其中每条记录上的连接属性(Join Attribute)使用哈希函数得到哈希值,从而建立一个哈希表。在计算逻辑允许的情况下,建立阶段选择数据量较小的表作为 Inner 表,以减少生成哈希表的时间和空间开销。
2、探测阶段
另一个表作为 Outer 表,扫描它的每一行并计算连接属性的哈希值,与建立阶段生成的哈希表进行对比。当然,哈希值相等不代表连接属性相等,还要再做一次判断,返回最终满足条件的记录。
通过 Simple Hash Join 这个命名,可以知道它也是一个简单的算法。这里的简单是说,它做了非常理想化的假设,也就是 Inner 表形成的哈希表小到能够放入内存中。可实际上,即使对于单体数据库来说,这个哈希表也是有可能超过内存容量的。
Grace Hash Join:
GHJ 算法与 SHJ 的不同之处在于,GHJ 正视了哈希表大于内存这个问题,将哈希表分块缓存在磁盘上。GHJ 中的 Grace 并不是指某项技术,而是首个采用该算法的数据库的名字。
GHJ 算法的执行过程,也是分为两个阶段。
第一阶段,Inner 表的记录会根据哈希值分成若干个块(Bucket)写入磁盘,而且每个 Bucket 必须小于内存容量。Outer 表也按照同样的方法被分为若干 Bucket 写入磁盘,但它的大小并不受到内存容量限制。
第二阶段和 SHJ 类似,先将 Inner 表的 Bucket 加载到内存,再读取 Outer 表对应 Bucket 的记录进行匹配,所有 Inner 表和 Outer 表的 Bucket 都读取完毕后,就得到了最终的结果集。
Hybrid Hash Join:
Hybrid Hash Join,也就是混合哈希,字面上是指 Simple Hash Join 和 Grace Hash Join 的混合。实际上,它主要是针对 Grace Hash Join 的优化,在内存够用的情况下,可以将 Inner 表的第一个 Bucket 和 Outer 表的第一个 Bucket 都保留在内存中,这样建立阶段一结束就可以进行匹配,节省了先写入磁盘再读取的两次 I/O 操作。
总体来说,哈希连接的核心思想和排序归并很相似,都是对内外表的记录分别只做一次循环。哈希连接算法不仅能够处理大小表关联,对提升大表之间关联的效率也有明显效果,但限制条件就是适用于等值连接。
HJ 就是将一个大任务拆解成若干子任务并执行的过程,这些子任务本身是独立的,如果调度到不同的节点上运行,那这就是一个并行框架。由此,可以说分布式架构下关联算法的优化和并行框架密切相关。
计算下推,换个角度看,其实它就是一种并行框架,不过是最简单的并行框架。因为在很多情况下,计算任务的执行节点和对应数据的存储节点并不是完全对应的,也就没办法只依据数据分布就拆分出子任务。
那么,要想在数据交错分布的情况下,合理地划分和调度子任务就需要引入更复杂的计算引擎。这种并行执行引擎在 OLAP 数据库中比较常见,通常称为 MPP(Massively Parallel Processing)。很明显,MPP 已经超出了 OLTP 计算引擎的范畴,并不是所有分布式数据库都支持的。
比如 TiDB,在最初的 TiDB + TiKV 的体系中,就没有 MPP 引擎。TiDB 的存储节点之间是不能通讯的(除了 Raft 协议),这就意味着如果子任务之间有数据传输就必须以计算节点为通道。这样,计算节点很容易成为瓶颈,同时增加了网络传输负载。由此可见,必须经过计算节点这个约束,是生成高效并行计划的一个障碍。后来,TiDB 也没有打破这个约束,而是通过引入 Spark 来处理复杂的 OLAP 计算任务,这就是 TiSpark 组件。
但并不是所有分布式数据库都采用引入外部组件的方式,比如 OceanBase 就在原有设计中拓展了并行执行框架,实现了更复杂的任务调度,在存储节点间也可以直接进行数据交换。
OceanBase 大致也是 P2P 架构,每个 Observer 部署了相同的服务,在运行过程中,动态的承担不同角色。图中一个 Observer 节点承担了入口处的查询协调器,其他节点作为子查询协调器,上面的工作线程是真正的任务执行者。
多表关联的复杂度,主要看参与表的数据量。其中,小表之间的关联都比较简单,所以接下来主要关注小表与大表关联和大表之间的关联。
大小表关联时,可以把小表复制到相关存储节点,这样全局关联就被转换为一系列的本地关联,再汇总起来就得到了最终结果。这种算法的具体实现方式有两种。
1、静态的方式
静态的方式,其实就是在创建表的时候,直接使用关键字将表声明为复制表,这样每个节点上都会保留一份数据副本。当它与大表关联时,计算节点就可以将关联操作下推到每个存储节点进行。很多分布式数据库,比如 TBase、TDSQL 等,都支持定义复制表。
2、动态方式
动态方式也称为『小表广播』,这种方式不需要人工预先定义,而是在关联发生时,系统自行处理。这就是说,当关联的某张表足够小时,在整个集群中分发不会带来太大的网络开销,系统就将其即时地复制到相关的数据节点上,实现本地关联。
下面这张图体现了小表广播的过程:
动态方式和并行执行引擎有直接的联系,例如 Spark 并行执行引擎中的 Broadcast Hash Join 就是先采用动态广播方式,而后在每个节点上再执行哈希连接。
当然,这里的『复制』和『广播』只表达了自然语义,不能作为静态还是动态的判断标准。比如,TDSQL 中的『广播表』,TBase 中的『复制表』,说的都是指静态方式。
复制表解决了大小表关联的问题,还剩下最棘手的大表间关联,它的解决方案通常就是重分布。
直接看一个例子,现在要对 A、B 两张大表进行关联,执行下面的 SQL:
select A.C1,B.C2 from A,B where A.C1=B.C1;
这个 SQL 可能会引发两种不同的重分布操作。
第一种,如果 C1 是 A 表的分区键,但不是 B 表的分区键,则 B 表按照 C1 做重分布,推送到 A 的各个分片上,实现本地关联。
第二种,如果两张表的分区键都不是 C1,则两张表都要按照 C1 做重分布,而后在多个节点上再做本地关联。当然这种情况的执行代价就比较高了。
这个基于重分布的关联过程,其实和 MapReduce、Spark 等并行计算引擎的思路是一样的,基本等同于它们的 Shuffle 操作。可以用 Spark 的 Shuffle Hash Join 来对比学习一下:
关联计算是查询场景中比较复杂的操作,即使面向 OLTP 场景的传统单体数据库也没有完善的处理,比如 MySQL 直到 8.0 版本才支持 Hash Join。而分布式数据库也由于自身定位不同,对关联算法支持程度存在差异。总的来说,越倾向于支持 OLAP 场景,对关联算法的支持度也就越高。
加餐:当执行 Hash Join 时,在计算逻辑允许的情况下,建立阶段会优先选择数据量较小的表作为 Inner 表。在什么情况下,系统无法根据数据量决定 Inner 表呢?
选择数据量较小的作为 Inner 表,这是典型的基于代价的优化,也就是 CBO(Cost Based Optimizer),属于物理优化阶段的工作。在这之前还有一个逻辑优化阶段,进行基于关系代数运算的等价转化,有时就是计算逻辑限制了系统不能按照数据量来选择 Inner 表。比如执行左外连接(Left Outer Join),它的语义是包含左表的全部行(不管右表中是否存在与它们匹配的行),以及右表中全部匹配的行。这样就只能使用右表充当 Inner 表并在之上建哈希表,使用左表来当 Outer 表,也就是我们的驱动表。
OLTP 是面向交易的处理过程,单笔交易的数据量很小,但是要在很短的时间内给出结果;而 OLAP 场景通常是基于大数据集的运算。
OLAP 和 OLTP 通过 ETL 进行衔接。为了提升 OLAP 的性能,需要在 ETL 过程中进行大量的预计算,包括数据结构的调整和业务逻辑处理。这样的好处是可以控制 OLAP 的访问延迟,提升用户体验。但是,因为要避免抽取数据对 OLTP 系统造成影响,所以必须在日终的交易低谷期才能启动 ETL 过程。这样一来, OLAP 与 OLTP 的数据延迟通常就在一天左右,习惯上大家把这种时效性表述为 T+1。其中,T 日就是指 OLTP 系统产生数据的日期,T+1 日是 OLAP 中数据可用的日期,两者间隔为 1 天。
这个体系的主要问题就是 OLAP 系统的数据时效性,T+1 太慢了。进入大数据时代后,商业决策更加注重数据的支撑,而且数据分析也不断向一线操作渗透,这都要求 OLAP 系统更快速地反映业务的变化。
解决思路主要有两种:重建 OLAP 体系、新建 HTAP 系统。
重建 OLAP 体系,重视数据加工的时效性,正是近年来大数据技术的主要发展方向。Kappa 架构就是新体系的代表,它最早由 LinkedIn 的 Jay Kreps 在 2014 年的 一篇文章 中提出。
在 Kappa 架构中,原来的批量文件传输方式完全被 Kafka 替代,通过流计算系统完成数据的快速加工,数据最终落地到 Serving DB 中提供查询服务。这里的 Serving DB 泛指各种类型的存储,可以是 HBase、Redis 或者 MySQL。
要注意的是,Kappa 架构还没有完全实现,因为在实践中流计算仍然无法替代批量计算,Serving DB 也无法满足各种类型的分析查询需求。未来,Kappa 架构需要在两方面继续完善:
总的来说,新的 OLAP 体系试图提升即时运算能力,去除批量 ETL,降低数据延迟。这个新体系是流计算的机遇,也是 OLAP 数据库的自我救赎。
HTAP(Hybrid Transaction/Analytical Processing)就是混合事务分析处理,它最早出现在 2014 年 Gartner 的一份报告中,很巧和 Kappa 架构是同一年。Gartner 用 HTAP 来描述一种新型数据库,它打破了 OLTP 和 OLAP 之间的隔阂,在一个数据库系统中同时支持事务型数据库场景和分析型数据库场景。这个构想非常美妙,HTAP 可以省去繁琐的 ETL 操作,避免批量处理造成的滞后,更快地对最新数据进行分析。
这个构想很快表现出它侵略性的一面,由于数据产生的源头在 OLTP 系统,所以 HTAP 概念很快成为 OLTP 数据库,尤其是 NewSQL 风格的分布式数据库,向 OLAP 领域进军的一面旗帜。
那么,NewSQL 在初步解决 OLTP 场景的高并发、强一致性等问题后,能不能兼顾 OLAP 场景,形成赢者通吃的局面呢?
其实还很难讲,因为从技术实践看,重建 OLAP 路线的相关技术似乎发展得更快,参与厂商也更加广泛,在实际生产环境的落地效果也不断改善。
相比之下,HTAP 的进展比较缓慢,鲜有生产级的工业实践,但仍有不少厂商将其作为产品的演进方向。目前,厂商官宣的 HTAP 至少包括 TiDB 和 TBase, 而 OceanBase 也宣布在近期版本中推出 OLAP 场景的特性。基于商业策略的考虑,我相信未来还会有更多分布式数据库竖起 HTAP 的大旗。那么接下来,我们分析下 HTAP 面临的挑战,让你更好地识别什么是 HTAP。
这要先说回 OLTP 和 OLAP,在架构上,它们的差异在于计算和存储两方面。
计算是指计算引擎的差异,目标都是调度多节点的计算资源,做到最大程度地并行处理。因为 OLAP 是海量数据要追求高吞吐量,而 OLTP 是少量数据更重视低延迟,所以它们计算引擎的侧重点不同。
存储是指数据在磁盘上的组织方式不同,而组织方式直接决定了数据的访问效率。OLTP 和 OLAP 的存储格式分别为行式存储和列式存储。
分布式数据库的主流设计理念是计算与存储分离,那么计算就比较容易实现无状态化,所以在一个 HTAP 系统内构建多个计算引擎显然不是太困难的事情,而真的要将 HTAP 概念落地为可运行系统,根本性的挑战就是存储。面对这个挑战,业界有两个不同的解决思路:
首先,先看看 Spanner 的方案。Spanner2017 论文“Spanner: Becoming a SQL System”中介绍了它的新一代存储 Ressi,其中使用了类似 PAX 的方式。这个 PAX 并不是 Spanner 的创新,早在 VLDB2002 的论文 "Data Page Layouts for Relational Databases on Deep Memory Hierarchies" 中就被提出了。论文从 CPU 缓存友好性的角度,对不同的存储方式进行了探讨,涉及 NSM、DSM、PAX 三种存储格式。
NSM(行式存储)
NSM(N-ary Storage Model)就是行式存储,也是 OLTP 数据库默认的存储方式,始终伴随着关系型数据库的发展。常用的 OLTP 数据库,比如 MySQL(InnoDB)、PostgreSQL、Oracle 和 SQL Server 等等都使用了行式存储。
顾名思义,行式存储的特点是将一条数据记录集中存在一起,这种方式更加贴近于关系模型。写入的效率较高,在读取时也可以快速获得一个完整数据记录,这种特点称为记录内的局部性(Intra-Record Spatial Locality)。
但是,行式存储对于 OLAP 分析查询并不友好。OLAP 系统的数据往往是从多个 OLTP 系统中汇合而来,单表可能就有上百个字段。而用户一次查询通常只访问其中的少量字段,如果以行为单位读取数据,查询出的多数字段其实是无用的,也就是说大量 I / O 操作都是无效的。同时,大量无效数据的读取,又会造成 CPU 缓存的失效,进一步降低了系统的性能。
图中显示 CPU 缓存的处理情况,可以看到很多无效数据被填充到缓存中,挤掉了那些原本有机会复用的数据。
DSM(列式存储)
DSM(Decomposition Storage Model)就是列式存储,它的出现要晚于行式存储。典型代表系统是 C-Store,它是迈克尔 · 斯通布雷克(Micheal Stonebraker)主导的开源项目,后来的商业化产品就是 Vertica。
列式存储就是将所有列集中存储,不仅更加适应 OLAP 的访问特点,对 CACHE 也更友好。这种特点称为记录间的局部性(Inter-Record Spatial Locality)。列式存储能够大幅提升查询性能,以速度快著称的 ClickHouse 就采用了列式存储。
列式存储的问题是写入开销更大,这是因为根据关系模型,在逻辑上数据的组织单元仍然是行,改为列式存储后,同样的数据量会被写入到更多的数据页(page)中,而数据页直接对应着物理扇区,那么磁盘 I / O 的开销自然增大了。
列式存储的第二个问题,就是很难将不同列高效地关联起来。毕竟在多数应用场景中,不只是使用单列或单表数据,数据分散后,关联的成本会更高。
PAX
PAX 增加了 minipage 这个概念,是原有的数据页下的二级单位,这样一行数据记录在数据页上的基本分布不会被破坏,而相同列的数据又被集中地存储在一起。PAX 本质上还是更接近于行式存储,但它也在努力平衡记录内局部性和记录间局部性,提升了 OLAP 的性能。
理论上,PAX 提供了一种兼容性更好的存储方式,可让人有些信心不足的是其早在 2002 年提出,但在 Spanner 之前却少有落地实现。
与这个思路类似的设计还有 HyPer 的 DataBlock(SIGMOD2016),DataBlock 构造了一种独有的数据结构,同时面向 OLTP 和 OLAP 场景。
如果底层存储是一份数据,那么天然就可以保证 OLTP 和 OLAP 的数据一致性,这是 PAX 的最大优势,但是由于访问模式不同,性能的相互影响似乎也是无法避免,只能尽力选择一个平衡点。TiDB 展现了一种不同的思路,介于 PAX 和传统 OLAP 体系之间,那就是 OLTP 和 OLAP 采用不同的存储方式,物理上是分离的,然后通过创新性的复制策略,保证两者的数据一致性。
TiDB 是在较早的版本中就提出了 HTAP 这个目标,并增加了 TiSpark 作为 OLAP 的计算引擎,但仍然共享 OLTP 的数据存储 TiKV,所以两种任务之间的资源竞争依旧不可避免。直到近期的 4.0 版本中,TiDB 正式推出了 TiFlash 作为 OLAP 的专用存储。
关注点集中在 TiFlash 与 TiKV 之间的同步机制上。其实,这个同步机制仍然是基于 Raft 协议的。TiDB 在 Raft 协议原有的 Leader 和 Follower 上增加了一个角色 Learner。这个 Learner 和 Paxos 协议中的同名角色,有类似的职责,就是负责学习已经达成一致的状态,但不参与投票。这就是说,Raft Group 在写入过程中统计多数节点时,并没有包含 Learner,这样的好处是 Learner 不会拖慢写操作,但带来的问题是 Learner 的数据更新必然会落后于 Leader。
Raft 协议能够实现数据一致性,是因为限制了只有主节点提供服务,否则别说是 Learner 就是 Follower 直接对外服务,都不能满足数据一致性。所以,这里还有另外一个设计。
Learner 每次接到请求后,首先要确认本地的数据是否足够新,而后才会执行查询操作。怎么确认足够新呢? Learner 会拿着读事务的时间戳向 Leader 发起一次请求,获得 Leader 最新的 Commit Index,就是已提交日志的顺序编号。然后,就等待本地日志继续 Apply,直到本地的日志编号等于 Commit Index 后,数据就足够新了。而在本地 Region 副本完成同步前,请求会一直等待直到超时。
这种同步机制有效运转的前提是 TiFlash 不能落后太多,否则每次请求都会带来数据同步操作,大量请求就会超时,也就没法实际使用了。但是,TiFlash 是一个列式存储,列式存储的写入性能通常不好,TiFlash 怎么能够保持与 TiKV 接近的写入速度呢?
这就要说到 TiFlash 的存储引擎 Delta Tree,它参考了 B+ Tree 和 LSM-Tree 的设计,分为 Delta Layer 和 Stable Layer 两层,其中 Delta Layer 保证了写入具有较高的性能。
当然,TiFlash 毕竟是 OLAP 系统,首要目标是保证读性能,因此写入无论多么重要,都要让位于读优化。作为分布式系统,还有最后一招可用,那就是通过扩容降低单点写入的压力。
加餐:每次 TiFlash 接到请求后,都会向 TiKV Leader 请求最新的日志增量,本地 replay 日志后再继续处理请求。这种模式虽然能够保证数据一致性,但会增加一次网络通讯。这个模式还能优化吗?
可以利用 Raft 协议的特性进行优化。Raft 在同步数据时是不允许出现“日志空洞”的,这意味着如果 Follower 节点收到时间戳为 300 的日志,则代表一定已经收到了小于这个时间戳的所有日志。所以,在 TiFlash 接收到查询请求时,如果查询时间戳小于对应分片的最后写入时间戳,那么本地分片的数据一定是足够新的,不用再与 TiKV 的 Leader 节点通讯。
分布式数据库的主体架构是朝着计算和存储分离的方向发展的,这一点在 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 子句。但场景更复杂时,比如事务包含了写入操作时,对于某些分布式数据库来说,就没这么简单了。
下面的例子就是关于 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,就得到了最后的结果。这样,缓存数据就不会阻碍读请求的下推了。
除了谓词下推,还有一个对下推来讲很重要的关联设计,那就是分区键。分区键是沿用单体数据库的说法,这里的分区实质是指分片,也就是在定义建表语句时,显式指定的分片对应的键值。
不同的分片机制与架构风格直接相关。通常,在 PGXC 架构中是显式指定分片的,所以会出现分区键;而 NewSQL 主要采用 Range 分片,用户感受不到分片的存在,所以往往无法利用这个特性。
只要 SQL 的谓词条件中包含分区键,那么很多时候是可以下推到各个存储节点执行的。就算是面对多表关联的情况,只要这些表使用了相同的分区键,也是可以下推的,类似的方式在 PolarDB 中被称为『Join 下推』,在 Greenplum 中被称为本地连接(Local Joins)。Join 下推可以保证数据移动最少,减少网络开销。
但是,多表使用相同的分区键并不是一个通用的方法,很多时候会在性能的均衡上面临挑战。例如,对用户表和交易表同样使用『机构』来做分区键,这时在每个分片内用户数量和交易数量往往不成正比。这是因为少量用户贡献了多数的交易,同时这些少量用户可能又会集中在几个节点上,就会出现局部资源紧张。
最后,也不是所有计算都能下推的。比如,排序操作,业务需求往往不只是在一个分区内进行排序;还有关联查询(Join),即使关联的多张表都使用了分区键,但如果查询条件中没有包含分区键,也是很难处理。
分布式数据库执行计算下推的目的就是为了加速查询。那单体数据库的查询优化手段是什么呢?
索引是数据库加速查询的重要手段。索引优化的基本逻辑是:索引实质是数据库表的子表,它的数据量更少,所以查询索引比查询数据表更高效。那么,先通过索引确定记录的主键后再『回表』查询,也就比直接查询数据表的速度更快。当然,在有些情况下,索引包含的数据项已经能够满足查询的需要,可以免去『回表』这个步骤,性能表现会更好。
索引优化对于分布式数据库来说仍然是重要的优化手段,并且和前面介绍的计算下推有密切的关系。
对于单体数据库,索引和数据表必然在同一节点上;而在分布式架构下,索引和数据既可能是同节点的,也可能是跨节点的,而这对于读写性能有很大影响。按照索引的分布情况和作用范围,可以分为全局索引和分区索引两种类型。在很多分布式数据库中都有对应实现,支持情况稍有差异。
分区索引就是索引与数据在同一分区,这个分区实际就是我们之前说的分片。因为分片是最小调度单位,那就意味着在分区索引下,索引和数据是确保存储在同一物理节点。我们把索引和数据在同一个物理节点的情况称为同分布(co_located)。
分区索引的优点很明显,那就是性能好,因为所有走索引的查询都可以下推到每个存储节点,每个节点只把有效查询结果返回给计算节点,避免了大量无效的数据传输。分区索引的实现难点在于如何保证索引与数据始终同分布。
索引与数据同分布又和分片的基本策略有关。动态分片的分拆和调度,都会影响同分布。Spanner2017 论文中简短地介绍了父子表模式(parent-child)的同分布策略,原理就是利用键值存储系统左前缀匹配 Key 区间的特性,通过设置子表记录与父表记录保持相同的前缀,来实现两者的同分布。索引作为数据的子表,也采用了类似的设计理念。
NewSQL 分布式数据库的底层就是分布式键值存储系统,所以下面用 BigTable 的开源实现 HBase 来介绍具体实现原理。
在 HBase 下,每个分片都有一个不重叠的 Key 区间,这个区间左闭右开。当新增一个键值对(Key / Value)时,系统会先判断这个 Key 与哪个分片的区间匹配,而后就分配到那个匹配的分片中保存,匹配算法一般采用左前缀匹配方式。
这个场景中,要操作的是一张用户信息表 T_USER,它有四个字段,分别是主键 PID、客户名称(Name)、城市(City)和年龄(Age)。T_USER 映射到 HBase 这样的键值系统后,主键 PID 作为 Key,其他数据项构成 Value。事实上,HBase 的存储格式还要更复杂些,这里做了简化。
在『Ctiy』字段上建立索引,索引与数据行是一对一的关系。索引存储也是 KV 形式,Key 是索引自身的主键 ID,Value 是反序列化信息用于解析主键内容。索引主键由三部分构成,分别是分片区间起始值、索引值和所指向数据行的主键(PID)。因为 PID 是唯一的,索引主键在它的基础上增加了前缀,所以也必然是唯一的。
整个查询的流程是这样的:
实现分区索引的难点在于如何始终保持索引与数据的同分布,尤其是发生分片分裂时,这是很多索引方案没有完美解决的问题。有些方案是在分裂后重建索引,这样开销太大,而且有短暂的不一致。其实,设计思想并不复杂,那就是把同分布的索引和数据装入一个更小的组织单元 (Bucket),而在分片分裂时要保持 Bucket 的完整性。这样一来,因为 Bucket 的粒度足够小,就不会影响分片分裂本身的目标,也就是平衡分片的数据量和访问压力,又能维持索引数据同分布。
当然,分区索引也是有缺陷的。如果期望的是一个唯一索引,那么分区索引就无法实现。因为唯一值的判定显然是一个全局性的约束,而所有全局性的约束,都无法在一个分片内完成。
唯一索引对应的方案就是全局索引。全局索引并不保持索引与数据同分布,于是就带来两个问题:
所以,在使用分布式数据库时,是否有必要建立全局索引,是一个非常谨慎的决定。
回到产品层面,并不是所有分布式数据库都支持了分区索引和全局索引供用户选择,比如 TiDB 的二级索引只支持全局索引。
其实,计算与存储分离架构自诞生起,就伴随着对其性能的质疑,这也推动了各种分布式数据库进行计算下推的优化,在存储节点支持更多的计算函数。但是计算下推终归要受到运算逻辑的限制,并不是所有计算都可以无冗余地下推。分区索引是计算下推的一种特殊形式,但很多分布式数据库并没支持这个特性,而是用实现起来更简单的全局索引代替,也因此增加了读、写两个方面的性能开销。
加餐:将『单表排序』操作进行下推,该如何设计一种有冗余的下推算法?
排序是一个全局性的处理,任何全局性的控制对分布式架构来说都是挑战。这个设计的关键是有冗余。假如执行下面这一条 SQL,查询账户余额最多的 1,000 条记录。
select * from balance_info order by balance_num limit 1000;
一个比较简单的思路是计算节点将这个 SQL 直接推送给所有数据节点,每个数据节点返回 top 1,000,再由计算节点二次排序选择前 1,000 条记录。
不过,这个方式有点太笨拙。因为当集群规模比较大时,比如有 50 个节点,计算节点会收到 50,000 条记录,其中 49,000 都是无效的,如果 limit 数量再增加,那无效的数据会更多。这种方式在网络传输上不太经济,有一点像读放大情况。
可以基于集群节点的数量适当缩小下推的规模,比如只取 top 500,这样能够降低传输成本。但相应地要增加判断逻辑,因为也许数据分布很不均衡,top 1,000 账户都集中在某个节点上,那么就要进行二次通讯。这个方式如果要再做优化,就是在计算节点保留数据统计信息,让数据量的分配符合各节点的情况,这就涉及到 CBO 的概念了。
Anastassia Ailamaki et al.: Data Page Layouts for Relational Databases on Deep Memory Hierarchies
Harald Lang et al: Data Blocks: Hybrid OLTP and OLAP on Compressed Storage using both Vectorization and Compilation
Jay Kreps: Questioning the Lambda Architecture
Nigel Rayner et al.: Hybrid Transaction/Analytical Processing Will Foster Opportunities for Dramatic Business Innovation
David F. Bacon et al.: Spanner: Becoming a SQL System
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
TiDB 的乐观锁基本上就是 Percolator 模型,它的运行过程可以分为三个阶段。
1、选择 Primary Row
收集所有参与修改的行,从中随机选择一行,作为这个事务的 Primary Row,这一行是拥有锁的,称为 Primary Lock,而且这个锁会负责标记整个事务的完成状态。所有其他修改行也有锁,称为 Secondary Lock,都会保留指向 Primary Row 的指针。
2、写入阶段
按照两阶段提交的顺序,执行第一阶段。每个修改行都会执行上锁并执行“prewrite”,prewrite 就是将数据写入私有版本,其他事务不可见。注意这时候,每个修改行都可能碰到锁冲突的情况,如果冲突了,就终止事务,返回给 TiDB,那么整个事务也就终止了。如果所有修改行都顺利上锁,完成 prewrite,第一阶段结束。
3、提交阶段
这是两阶段提交的第二阶段,提交 Primary Row,也就是写入新版本的提交记录并清除 Primary Lock,如果顺利完成,那么这个事务整体也就完成了,反之就是失败。而 Secondary Rows 上的锁,则会交给异步线程根据 Primary Lock 的状态去清理。
这个过程中不仅有锁,而且锁的数量还不少。为什么又说它是乐观协议呢?
在经典理论教材 "Principles of Distributed Database Systems" 中,作者将乐观协议和悲观协议的操作,都统一成四个阶段,分别是有效性验证(V)、读(R)、计算(C)和写(W)。两者的区别就是这四个阶段的顺序不同:悲观协议的操作顺序是 VRCW,而乐观协议的操作顺序则是 RCVW。因为在比较两种协议时,计算(C)这个阶段没有实质影响,可以忽略掉。那么简化后,悲观协议的顺序是 VRW,而乐观协议的顺序就是 RVW。
RVW 的三阶段划分,也见于研究乐观协议的经典论文 "On Optimistic Methods for Concurrency Control"。
关于三个阶段的定义在不同文献中稍有区别,其中“Principles of Distributed Database Systems”对这三个阶段的定义通用性更强,对于 RVW 和 VRW 都是有效的,先看下具体内容。
每个事务对数据项的局部拷贝进行更新。
需要注意的是:此时的更新结果对于其他事务是不可见的。这个阶段的命名特别容易让人误解,明明做了写操作,却叫做『读阶段』。我想它大概是讲:那些后面要写入的内容,先要暂时加载到一个仅自己可见的临时空间内。这有点像抄录的过程,先读取原文并在脑子里记住,然后誊写出来。
验证准备提交的事务。
这个验证就是指检查这些更新是否可以保证数据库的一致性,如果检查通过就进入下一个阶段,否则取消事务。再深入一点,这段话有两层意思。首先这里提到的检查与隔离性目标有直接联系;其次就是检查可以有不同的手段,也就是不同的并发控制技术,比如可以是基于锁的检查,也可以是基于时间戳排序。
将读阶段的更新结果写入到数据库中,接受事务的提交结果。
这个阶段的工作就比较容易理解了,就是完成最终的事务提交操作。
还有一种关于乐观与悲观的表述,也与三阶段的顺序相呼应。乐观,重在事后检测,在事务提交时检查是否满足隔离级别,如果满足则提交,否则回滚并自动重新执行。悲观,重在事前预防,在事务执行时检查是否满足隔离级别,如果满足则继续执行,否则等待或回滚。
再回到 TiDB 的乐观锁。虽然对于每一个修改行来说,TiDB 都做了有效性验证,而且顺序是 VRW,可以说是悲观的,但这只是局部的有效性验证;从整体看,TiDB 没有做全局有效性验证,不符合 VRW 顺序,所以还是相对乐观的。
Transactional Information Systems : Theory, Algorithms, and the Practice of Concurrency Control and Recovery 给出了一个专用于 RVW 的三阶段定义。也就是说,它是专门描述乐观协议的。其中主要差别在『有效性确认阶段』,是针对可串行化的检查,检查采用基于时间戳的特定算法。
这个定义是一个更加具体的乐观协议,严格符合 RVW 顺序,所以把它称为狭义上的乐观并发控制(Optimistic Concurrency Control),也称为基于有效性确认的并发控制(Validation-Based Concurrency Control)。很多学术论文中的 OCC,就是指它。而在工业界,真正生产级的分布式数据库还很少使用狭义 OCC 进行并发控制,唯一的例外就是 FoundationDB。与之相对应的,则是 TiDB 这种广义上的乐观并发控制,说它乐观是因为它没有严格遵循 VRW 顺序。
为啥乐观要改成悲观呢?主要是两方面的挑战:
一是事务冲突少是使用乐观协议的前提,但这个前提是否普遍成立;
二是现有应用系统使用的单体数据库多是悲观协议,兼容性上的挑战。
首先,事务冲突少这个前提,随着分布式数据库的适用场景越来越广泛,显得不那么有通用性了。比如,金融行业就经常会有一些事务冲突多又要保证严格事务性的业务场景,一个简单的例子就是银行的代发工资。代发工资这个过程,其实就是从企业账户给一大批个人账户转账的过程,是一个批量操作。在这个大的转账事务中可能涉及到成千上万的更新,那么事务持续的时间就会比较长。如果使用乐观协议,在这段时间内,只要有一个人的账户余额发生变化,事务就要回滚,那么这个事务很可能一直都在重试、回滚,永远也执行不完。这个时候,就一点也不要乐观了,像传统单体数据库那样,使用最悲观的锁机制,就很容易实现也很高效。
当然,为了避免这种情况的出现,TiDB 的乐观锁约定了事务的长度,默认单个事务包含的 SQL 语句不超过 5000 条。但这种限制其实是一个消极的处理方式,毕竟业务需求是真实存在的,如果数据库不支持,就必须通过应用层编码去解决了。
回到悲观协议还有一个重要的原因,那就是保证对遗留应用系统的兼容性。这个很容易理解,因为单体数据库都是悲观协议,甚至多数都是基于锁的悲观协议,所以在 SQL 运行效果上与乐观协议有直接的区别。一个非常典型的例子就是 select for update。这是一个显式的加锁操作,或者说是显式的方式进行有效性确认,广义的乐观协议都不提供严格的 RVW,所以也就无法支持这个操作。
select for update 是不是一个必须的操作呢?其实也不是的,这个语句出现是因为数据库不能支持可串行化隔离,给应用提供了一个控制手段,主导权交给了应用。但是,这就是单体数据库长久以来的规则,已经是生态的一部分,为了降低应用的改造量,新产品还是必须接受。
因为上面这些挑战,TiDB 的并发控制机制也做出了改变,增加了“悲观锁”并作为默认选项。TiDB 悲观锁的理论基础很简单,就是在原有的局部有效性确认前,增加一轮全局有效性确认。这样就是严格的 VRW,自然就是标准的悲观协议了。具体采用的方式就是增加了悲观锁,这个锁是实际存在的,表现为一个占位符,随着 SQL 的执行即时向存储系统(TiKV)发出,这样事务就可以在第一时间发现是否有其他事务与自己冲突。
另外,悲观锁还触发了一个变化。TiDB 原有的事务模型并不是一个交互事务,它会把所有的写 SQL 都攒在一起,在 commit 阶段一起提交,所以有很大的并行度,锁的时间较短,死锁的概率也就较低。因为增加了悲观锁的加锁动作,变回了一个可交互事务,TiDB 还要增加一个死锁检测机制。
要搞清楚悲观协议的分类,其实是要从并发控制技术整体的分类体系来看。
事实上,并发控制的分类体系,连学术界的标准也不统一。比如,"Principles of Distributed Database Systems" 的分类是按照比较宽泛的乐观协议和悲观协议进行分类,子类之间又有很多重叠的概念,理解起来有点复杂。
而 "Transactional Information Systems : Theory, Algorithms, and the Practice of Concurrency Control and Recovery" 采用的划分方式,是狭义乐观协议和其他悲观协议。这里狭义乐观协议,是指基于有效性验证的并发控制,也是学术上定义的 OCC。
这里以 "Transactional Information Systems : Theory, Algorithms, and the Practice of Concurrency Control and Recovery" 中的划分体系为主。下面摘录了书中的一幅图,用来梳理不同的并发控制协议:
这个体系首先分为悲观和乐观两个大类。因为这里的乐观协议是指狭义乐观并发控制,所以包含内容就比较少,只有前向乐观并发控制和后向乐观并发控制;而悲观协议又分为基于锁和非锁两大类,其中基于锁的协议是数量最多的。
基于锁的协议显然不只是 2PL,还包括有序共享(Ordered Sharing 2PL, O2PL)、利他锁(Altruistic Locking, AL)、只写封锁树(Write-only Tree Locking, WTL)和读写封锁树(Read / Write Tree Locking, RWTL)。但这几种协议在真正的数据库系统中很少使用,重点放在数据库系统主要使用的 2PL 上。
2PL 就是事务具备两阶段特点的并发控制协议,这里的两个阶段指加锁阶段和释放锁阶段,并且加锁阶段严格区别于紧接着的释放锁阶段。我们可以通过一张图来加深对 2PL 理解。
在 t1 时刻之前是加锁阶段,在 t1 之后则是释放锁阶段,可以从时间上明确地把事务执行过程划分为两个阶段。2PL 的关键点就是释放锁之后不能再加锁。而根据加锁和释放锁时机的不同,2PL 又有一些变体。
保守两阶段封锁协议(Conservative 2PL,C2PL),事务在开始时设置它需要的所有锁。
严格两阶段封锁协议(Strict 2PL,S2PL),事务一直持有已经获得的所有写锁,直到事务终止。
强两阶段封锁协议(Strong Strict 2PL,SS2PL),事务一直持有已经获得的所有锁,包括写锁和读锁,直到事务终止。SS2PL 与 S2PL 差别只在于一直持有的锁的类型,所以它们的图形是相同的。
理解了这几种 2PL 的变体后,再回想一下 Percolator 模型。当主锁(Primary Lock)没有释放前,所有的记录上的从锁(Secondary Lock)实质上都没有释放,在主锁释放后,所有从锁自然释放。所以,Percolator 也属于 S2PL。TiDB 的乐观锁机制是基于 Percolator 的,那么 TiDB 就也是 S2PL。
事实上,S2PL 可能是使用最广泛的悲观协议,几乎所有单体数据都依赖 S2PL 实现可串行化。而在分布式数据库中,甚至需要使用 SS2PL 来保证可串行化执行,典型的例子是 TDSQL。但 S2PL 模式下,事务持有锁的时间过长,导致系统并发性能较差,所以实际使用中往往不会配置到可串行化级别。这就意味着我们还是没有生产级技术方案,只能期望出现新的方式,既达到可串行化隔离级别,又能有更好的性能。最终,我们等到了一种可能是性能更优的工程化实现,这就是 CockroachDB 的串行化快照隔离(SSI)。而 SSI 的核心,就是串行化图检测(SGT)。
SSI 是一种隔离级别的命名,最早来自 PostgreSQL,CockroachDB 沿用了这个名称。它是在 SI 基础上实现的可串行化隔离。同样,作为 SSI 核心的 SGT 也不是 CockroachDB 首创,学术界早就提出了这个理论,但真正的工程化实现要晚得多。
PostgreSQL 在论文 "Serializable Snapshot Isolation in PostgreSQL" 中最早提出了 SSI 的工程实现方案,这篇论文也被 VLDB2012 收录。
为了更清楚地描述 SSI 方案,要先了解一点理论知识。
串行化理论的核心是串行化图(Serializable Graph,SG)。这个图用来分析数据库事务操作的冲突情况。每个事务是一个节点,事务之间的关系则表示为一条有向边。那么,什么样的关系可以表示为边呢?
串行化图的构建规则是这样的,事务作为节点,当一个操作与另一个操作冲突时,在两个事务节点之间就可以画上一条有向边。
具体来说,事务之间的边又分为三类情况:
通过一个例子,看看如何用这几条规则来构建一个简单的串行化图。
图中一共有三个事务先后执行,事务 T1 先执行 W(A),T2 再执行 R(A),所以 T1 与 T2 之间存在 WR 依赖,因此形成一条 T1 指向 T2 的边;同理,T2 的 W(B) 与 T3 的 R(B) 也存在 WR 依赖,T1 的 W(A) 与 T3 的 R(A) 之间也是 WR 依赖,这样就又形成两条有向边,分别是 T2 指向 T3 和 T1 指向 T3。
最终,产生了一个有向无环图(Directed Acyclic Graph,DAG)。能够构建出 DAG,就说明相关事务是可串行化执行的,不需要中断任何事务。
可以使用 SGT,验证一下典型的死锁情况。我们知道,事务 T1 和 T2 分别以不同的顺序写两个数据项,那么就会形成死锁。
用串行化图来体现就是这个样子,显然构成了环。
在 SGT 中,WR 依赖和 WW 依赖都与我们的直觉相符,而 RW 反向依赖就比较难理解了。在 PostgreSQL 的论文中,专门描述了一个 RW 反向依赖的场景。
这个场景一共需要维护两张表:一张收入表(reciepts)会记入当日的收入情况,每行都会记录一个批次号;另一张独立的控制表(current_batch),里面只有一条记录,就是当前的批次号。也可以把这里的批次号理解为一个工作日。
同时,还有三个事务 T1、T2、T3。
其实,这个例子很像银行存款系统的日终翻牌。
因为 T1 要报告当天的收入情况,所以它必须要在 T3 之后执行。事务 T2 记录了当天的每笔入账,必须在 T3 之前执行,这样才能出现在当天的报表中。三者顺序执行可以正常工作,否则就会出现异常,比如下面这样的:
T2 先拿到一个批次号 x,随后 T3 执行,批次号关闭后,x 这个批次号其实已经过期,但是 T2 还继续使用 x,记录当前的这笔收入。T1 正常在 T3 后执行,此时 T2 尚未提交,所以 T1 的报告中漏掉了 T2 的那笔收入。因为 T2 使用时过期的批次号 x,第二天的报告中也不会统计到这笔收入,最终这笔收入就神奇地消失了。
在理解了这个例子的异常现象后,用串行化图方法来验证一下。通过把事务中的 SQL 抽象为对数据项的操作,可以得到下面这张图。
图中 batch 是指批次号,reps 是指收入情况。
接下来,按照先后顺序提取有向边,先由 T2.R(batch) -> T3.W(batch),得到 T2 到 T3 的 RW 依赖;再由 T3.W(batch)->T1.R(batch),得到 T3 到 T1 的 WR 依赖;最后由 T1.R(reps)->T2.W(reps),得到 T1 到 T2 的 RW 依赖。这样就构成了下面的串行化图。
显然这三个事务之间是存在环的,那么这三个事务就是不能串行化的。
这个异常现象中很有意思的一点是,虽然 T1 是一个只读事务,但如果没有 T1 的话,T2 与 T3 不会形成环,依然是可串行化执行的。这里就澄清了一点:直觉上认为的只读事务不会影响事务并发机制,其实是不对的。
RW 反向依赖是一个非常特别的存在,而特别之处就在于传统的锁机制无法记录这种情况。因此在论文 "Serializable Snapshot Isolation in PostgreSQL" 中提出,增加一种锁 SIREAD,用来记录快照隔离(SI)上所有执行过的读操作(Read),从而识别 RW 反向依赖。本质上,SIREAD 并不是锁,只是一种标识。但这个方案面临的困境是,读操作涉及到的数据范围实在太大,跟踪标识带来的成本可能比 S2PL 还要高,也就无法达到最初的目标。
针对这个问题,CockroachDB 做了一个关键设计,读时间戳缓存(Read Timestamp Cache),简称 RTC。
基于 RTC 的新方案是这样的,当执行任何的读取操作时,操作的时间戳都会被记录在所访问节点的本地 RTC 中。当任何写操作访问这个节点时,都会以将要访问的 Key 为输入,向 RTC 查询最大的读时间戳(MRT),如果 MRT 大于这个写入操作的时间戳,那继续写入就会形成 RW 依赖。这时就必须终止并重启写入事务,让写入事务拿到一个更大的时间戳重新尝试。
具体来说,RTC 是以 Key 的范围来组织读时间戳的。这样,当读取操作携带了谓词条件,比如 where 子句,对应的操作就是一个范围读取,会覆盖若干个 Key,那么整个 Key 的范围也可以被记录在 RTC 中。这样处理的好处是,可以兼容一种特殊情况。
例如,事务 T1 第一次范围读取(Range Scan)数据表,where 条件是『>=1 and <=5』,读取到 1、2、5 三个值,T1 完成后,事务 T2 在该表插入了 4,因为 RTC 记录的是范围区间 [1,5],所以 4 也可以被检测出存在 RW 依赖。这个地方,有点像 MySQL 间隙锁的原理。
RTC 是一个大小有限的,采用 LRU(Least Recently Used,最近最少使用)淘汰算法的缓存。当达到存储上限时,最老的时间戳会被抛弃。为了应对缓存超限的情况,会将 RTC 中出现过的所有 Key 上最早的那个读时间戳记录下来,作为低水位线(Low Water Mark)。如果一个写操作将要写的 Key 不在 RTC 中,则会返回这个低水位线。
相对乐观
SGT 的运行机制,和传统的 S2PL 一样属于悲观协议。但 SGT 没有锁的管理成本,所以性能比 S2PL 更好。
CockroachDB 基于 SGT 理论进行工程化,使可串行化真正成为生产级可用的隔离级别。从整体并发控制机制看,CockroachDB 和上一讲的 TiDB 一样,虽然在局部看是悲观协议,但因为不符合严格的 VRW 顺序,所以在全局来看仍是一个相对乐观的协议。
这种乐观协议同样存在问题,所以 CockroachDB 也在原有基础上进行了改良,通过增加全局的锁表(Lock Table),使用加锁的方式,先进行一轮全局有效性验证,确定无冲突的情况下,再使用单个节点的 SGT。
Gerhard Weikum and Gottfried Vossen: Transactional Information Systems : Theory, Algorithms, and the Practice of Concurrency Control and Recovery
H. T. Kung and John T. Robinson: On Optimistic Methods for Concurrency Control
M. Tamer Özsu and Patrick Valduriez: Principles of Distributed Database Systems
最早的 SSI 工程实现方案:Serializable Snapshot Isolation in PostgreSQL
按照狭义乐观协议和其他悲观协议划分并发控制协议:Transactional Information Systems : Theory, Algorithms, and the Practice of Concurrency Control and Recovery
多版本并发控制(Multi-Version Concurrency Control,MVCC)就是 通过记录数据项历史版本的方式,来提升系统应对多事务访问的并发处理能力。今天,几乎所有主流的单体数据库都实现了 MVCC,它已经成为一项非常重要也非常普及的技术。
MVCC 出现前,读写操作之间是互斥的,具体是通过锁机制来实现的。示例如下:
图中事务 T1、T2 先后启动,分别对数据库执行写操作和读操作。写操作是一个过程,在过程中任意一点,数据的变更都是不完整的,所以 T2 必须在数据写入完成后才能读取,也就形成了读写阻塞。反之,如果 T2 先启动,T1 也要等待 T2 将数据完全读取后,才能执行写入。
如果先执行的是 T1 写事务,除了磁盘写入数据的时间,由于要保证数据库的高可靠,至少还有一个备库同步复制主库的变更内容。这样,阻塞时间就要再加上一次网络通讯的开销。
如果先执行的是 T2 只读事务,虽然不用考虑复制问题,但是读操作通常会涉及更大范围的数据,这样一来加锁的记录会更多,被阻塞的写操作也就更多。而且,只读事务往往要执行更加复杂的计算,阻塞的时间也就更长。
所以说,用锁解决读写冲突问题,带来的事务阻塞开销还是不小的。相比之下,用 MVCC 来解决读写冲突,就不存在阻塞问题,要优雅得多了。
MVCC 的设计随架构风格不同而不同。在 PGXC 架构中,因为数据节点就是单体数据库,所以 PGXC 的 MVCC 实现方式其实就是单体数据库的实现方式。
MVCC 要记录数据的历史版本,这就涉及到存储的问题。
MVCC 的存储方式
MVCC 有三类存储方式,一类是将历史版本直接存在数据表中的,称为 Append-Only,典型代表是 PostgreSQL。另外两类都是在独立的表空间存储历史版本,它们区别在于存储的方式是全量还是增量。增量存储就是只存储与版本间变更的部分,这种方式称为 Delta,也就是数学中常作为增量符号的那个 Delta,典型代表是 MySQL 和 Oracle。全量存储则是将每个版本的数据全部存储下来,这种方式称为 Time-Travle,典型代表是 HANA。
每种方式都有一定的优缺点:
Append-Only 方式
优点:
缺点:
Delta 方式
优点:
缺点:
Oracle 早期版本中经常会出现的 ORA-01555 『快照过旧』(Snapshot Too Old),就是回滚段中的历史版本被覆盖造成的。通常,设置更大的回滚段和缩短事务执行时间可以解决这个问题。随着 Oracle 后续版本采用自动管理回滚段的设计,这个问题也得到了缓解。
Time-Travel 方式
优点:
缺点:
当然,无论采用三种存储方式中的哪一种,都需要进行历史版本清理。
历史版本存储下来要解决多事务的并发控制问题,也就是保证事务的隔离性。隔离性的多个级别中,可接受的最低隔离级别就是『已提交读』(Read Committed,RC)。
先来看 RC 隔离级别下 MVCC 的工作过程。
用一个例子来说明:
T1 到 T7 是七个数据库事务,它们先后运行,分别操作数据库表的记录 R1 到 R7。事务 T6 要读取 R1 到 R6 这六条记录,在 T6 启动时(T6-1)会向系统申请一个活动事务列表,活动事务就是已经启动但尚未提交的事务,这个列表中会看到 T3、T4、T5 等三个事务。
T6 查询到 R3、R4、R5 时,看到它们最新版本的事务 ID 刚好在活动事务列表里,就会读取它们的上一版本。而 R1、R2 最新版本的事务 ID 小于活动事务列表中的最小事务 ID(即 T3),所以 T6 可以看到 R1、R2 的最新版本。
这个例子中 MVCC 的收益非常明显,T6 不会被正在执行写入操作的三个事务阻塞,而如果按照原来的锁方式,T6 要在 T3、T4、T5 三个事务都结束后,才能执行。
MVCC 在 RC 级别的效果还不错。那么,如果隔离级别是更严格一些的『可重复读』(RR)呢?
当 T6 执行到下一个时间点(T6-2),T1 到 T4 等 4 个事务都已经提交,此时 T6 再次向系统申请活动事务列表,列表包含 T5 和 T7。遵循同样的规则,这次 T6 可以看到 R1 到 R4 等四条记录的最新版本,同时看到 R5 的上一版本。
很明显,T6 刚才和现在这两次查询得到了不同的结果集,这是不符合 RR 要求的。
实现 RR 的办法也很简单,我们只需要记录下 T6-1 时刻的活动事务列表,在 T6-2 时再次使用就行了。那么,这个反复使用的活动事务列表就被称为『快照』(Snapshot)。
快照是基于 MVCC 实现的一个重要功能,从效果上看, 快照就是快速地给数据库拍照片,数据库会停留在你拍照的那一刻。所以,用『快照』来实现 RR 是很方便的。
从上面的例子可以发现,RC 与 RR 的区别在于 RC 下每个 SQL 语句会有一个自己的快照,所以看到的数据库是不同的,而 RR 下,所有 SQL 语句使用同一个快照,所以会看到同样的数据库。
为了提升效率,快照不是单纯的事务 ID 列表,它会统计最小活动事务 ID,还有最大已提交事务 ID。这样,多数事务 ID 通过比较边界值就能被快速排除掉,如果事务 ID 恰好在边界范围内,再进一步查找是否与活跃事务 ID 匹配。
快照在 MySQL 中称为 ReadView,在 PostgreSQL 中称为 SnapshotData,组织方式都是类似的。
在 PGXC 架构中,实现 RC 隔离级的处理过程与单体数据库差异并不大。PGXC 在实现 RR 时遇到两个挑战,也就是实现快照的两个挑战。
一是如何保证产生单调递增事务 ID。每个数据节点自行处理显然不行,这就需要由一个集中点来统一生成。
二是如何提供全局快照。每个事务要把自己的状态发送给一个集中点,由它维护一个全局事务列表,并向所有事务提供快照。
所以,PGXC 风格的分布式数据库都有这样一个集中点,通常称为全局事务管理器(GTM)。又因为事务 ID 是单调递增的,用来衡量事务发生的先后顺序,和时间戳作用相近,所以全局事务管理器也被称为『全局时钟』。
这里主要介绍 TiDB 和 CockroachDB 两种实现方式,因为它们是比较典型的两种情况。
TiDB 底层是分布式键值系统,假设两个事务操作同一个数据项。其中,事务 T1 执行写操作,由 Prewrite 和 Commit 两个阶段构成,对应了之前描述的两阶段提交协议(2PC)。这里也可以简单理解为 T1 的写操作分成了两个阶段,T2 在这两个阶段之间试图执行读操作,但是 T2 会被阻塞,直到 T1 完成后,T2 才能继续执行。
TiDB 为什么没有使用快照读取历史版本呢? TiDB 官方文档并没有说明背后的思路,猜测问题出在全局事务列表上,因为 TiDB 根本没有设计全局事务列表。当然这应该不是设计上的疏忽,可以把它理解为一种权衡,是在读写效率和全局事务列表的维护代价之间的选择。
事实上,PGXC 中的全局事务管理器就是一个单点,很容易成为性能的瓶颈,而分布式系统一个普遍的设计思想就是要避免对单点的依赖。当然,TiDB 的这个设计付出的代价也是巨大的。虽然,TiDB 在 3.0 版本后增加了悲观锁,设计稍有变化,但大体仍是这样。
CockroachDB 设计了一张全局事务列表,但它不是照搬了单体数据库的『快照』。
依旧是 T1 事务先执行写操作,中途 T2 事务启动,执行读操作,此时 T2 会被优先执行。待 T2 完成后,T1 事务被重启。重启的意思是 T1 获得一个新的时间戳(等同于事务 ID)并重新执行。
这里还是会产生读写阻塞,Why?
CockroachDB 没有使用快照,不是因为没有全局事务列表,而是因为它的隔离级别目标不是 RR,而是 SSI,也就是可串行化。
对于串行化操作来说,没有与读写并行操作等价的处理方式,因为先读后写和先写后读,读操作必然得到两个不同结果。更加学术的解释是:先读后写操作会产生一个 读写反向依赖,可能影响串行化事务调度。
在上面的例子中,为了方便描述,简化了读写冲突的处理过程。事实上,被重启的事务并不一定是执行写操作的事务。CockroachDB 的每个事务都有一个优先级,出现事务冲突时会比较两个事务的优先级,高优先级的事务继续执行,低优先级的事务则被重启。而被重启事务的优先级也会提升,避免总是在竞争中失败,最终被『饿死』。
要特别说明的是,虽然 NewSQL 架构的分布式数据库没有普遍使用快照处理读写事务,但它们仍然实现了 MVCC,在数据存储层保留了历史版本。所以,NewSQL 产品往往也会提供一些低数据一致性的只读事务接口,提升读取操作的性能。
关于时间,我们得接受一个事实,那就是无法在工程层面得到绝对准确的时间。其实,任何度量标准都没有绝对意义上的准确,这是因为量具本身就是有误差的,时间、长度、重量都是这样的。
时间会带来什么问题,用下图说明:
图中共有 7 个数据库事务,T1 到 T7,其中 T6 是读事务,其他都是写事务。事务 T2 结束的时间点(记为 T2-C)早于事务 T6 启动的时间点(记为 T6-S),这是基于数据记录上的时间戳得出的判断,但实际上这个判断很可能是错的。
这是因为时间误差的存在,T2-C 时间点附近会形成一个不确定时间窗口,也称为置信区间或可信区间。严格来说,我们只能确定 T2-C 在这个时间窗口内,但无法更准确地判断具体时间点。同样,T6-S 也只是一个时间窗口。时间误差不能消除,但可以通过工程方式控制在一定范围内,例如在 Spanner 中这个不确定时间窗口(记为 ɛ)最大不超过 7 毫秒,平均是 4 毫秒。
当我们还原两个时间窗口后,发现两者存在重叠,所以无法判断 T2-C 与 T6-S 的先后关系。只有避免时间窗口出现重叠。『waiting out the uncertainty』,用等待来消除不确定性。
在实践中,有两种方式可供选择:写等待 和 读等待。
Spanner 选择了写等待方式,更准确地说是用提交等待(commit-wait)来消除不确定性。
Spanner 是直接将时间误差暴露出来的,所以调用当前时间函数 TT.now() 时,会获得的是一个区间对象 TTinterval。它的两个边界值 earliest 和 latest 分别代表了最早可能时间和最晚可能时间,而绝对时间就在这两者之间。另外,Spanner 还提供了 TT.before() 和 TT.after() 作为辅助函数,其中 TT.after() 用于判断当前时间是否晚于指定时间。
对于一个绝对时间点 S,至少需要等到 S + ɛ 时刻 TT.after(S) 才为真,这个 ɛ 就是不确定时间窗口的宽度。如下图所示:
从直觉上说,标识数据版本的『提交时间戳』和事务的真实提交时间应该是一个时间。那么就有如下过程:有当前事务 Ta,已经获得了一个绝对时间 S 作为『提交时间戳』。Ta 在 S 时刻写盘,保存的时间戳也是 S。事务 Tb 在 Ta 结束后的 S + X 时刻启动,获得时间区间的最小值是 TT1.earliest。如果 X 小于时间区间 ɛ,则 TT1.earliest 就会小于 S,那么 Tb 就无法读取到 Ta 写入的数据。
Tb 在 Ta 提交后启动却读取不到 Ta 写入的数据,这显然不符合线性一致性的要求。
写等待的处理方式是这样的。事务 Ta 在获得『提交时间戳』S 后,再等待 ɛ 时间后才写盘并提交事务。真正的提交时间是晚于『提交时间戳』的,中间这段时间就是等待。这样 Tb 事务启动后,能够得到的最早时间 TT2.earliet 肯定不会早于 S 时刻,所以 Tb 就一定能够读取到 Ta。这样就符合线性一致性的要求了。
综上,事务获得『提交时间戳』后必须等待 ɛ 时间才能写入磁盘,即 commit-wait。
但是 Spanner 拿不到绝对时间 S,会有误差。
Spanner 将含有写操作的事务定义为读写事务。读写事务的写操作会以两阶段提交(2PC)的方式执行。
2PC 的第一阶段是预备阶段,每个参与者都会获取一个『预备时间戳』,与数据一起写入日志。第二阶段,协调节点写入日志时需要一个『提交时间戳』,而它必须要大于任何参与者的『预备时间戳』。所以,协调节点调用 TT.now() 函数后,要取该时间区间的 lastest 值(记为 s),而且 s 必须大于所有参与者的『预备时间戳』,作为『提交时间戳』。
这样,事务从拿到提交时间戳到 TT.after(s) 为 true,实际等待了两个单位的时间误差。如下图所示。
针对同一个数据项,事务 T8 和 T9 分别对进行写入和读取操作。T8 在绝对时间 100ms 的时候,调用 TT.now() 函数,得到一个时间区间 [99,103],选择最大值 103 作为提交时间戳,而后等待 8 毫秒(即 2ɛ)后提交。
这样,无论如何 T9 事务启动时间都晚于 T8 的“提交时间戳”,也就能读取到 T8 的更新。
这个过程中,第一个时间差是 2PC 带来的,如果换成其他事务模型也许可以避免,而第二个时间差是真正的 commit-wait,来自时间的不确定性,是不能避免的。
TrueTime 的平均误差是 4 毫秒,commit-wait 需要等待两个周期,那 Spanner 读写事务的平均延迟必然大于等于 8 毫秒。为啥有人会说 Spanner 的 TPS 是 125 呢?原因就是这个。其实,这只是事务操作数据出现重叠时的吞吐量,而无关的读写事务是可以并行处理的。
读等待的代表产品是 CockroachDB。
因为 CockroachDB 采用混合逻辑时钟(HLC),所以对于没有直接关联的事务,只能用物理时钟比较先后关系。CockroachDB 各节点的物理时钟使用 NTP 机制同步,误差在几十至几百毫秒之间,用户可以基于网络情况通过参数『maximum clock offset』设置这个误差,默认配置是 250 毫秒。
写等待模式下,所有包含写操作的事务都受到影响,要延后提交;而读等待只在特殊条件下才被触发,影响的范围要小得多。用以下例子说明:
事务 T6 启动获得了一个时间戳 T6-S1,此时虽然事务 T2 已经在 T2-C 提交,但 T2-C 与 T6-S1 的间隔小于集群的时间偏移量,所以无法判断 T6 的提交是否真的早于 T2。
这时,CockroachDB 的办法是重启(Restart)读操作的事务,就是让 T6 获得一个更晚的时间戳 T6-S2,使得 T6-S2 与 T2-C 的间隔大于 offset,那么就能读取 T2 的写入了。
不过,接下来又出现更复杂的情况, T6-S2 与 T3 的提交时间戳 T3-C 间隔太近,又落入了 T3 的不确定时间窗口,所以 T6 事务还需要再次重启。而 T3 之后,T6 还要重启越过 T4 的不确定时间窗口。
最后,当 T6 拿到时间戳 T6-S4 后,终于跳过了所有不确定时间窗口,读等待过程到此结束,T6 可以正式开始它的工作了。
在这个过程中,可以看到读等待的两个特点:一是偶发,只有当读操作与已提交事务间隔小于设置的时间误差时才会发生;二是等待时间的更长,因为事务在重启后可能落入下一个不确定时间窗口,所以也许需要经过多次重启。
Spanner 采用了写等待方案,也就是 Commit Wait,理论上每个写事务都要等待一个时间置信区间。对 Spanner 来说这个区间最大是 7 毫秒,均值是 4 毫秒。但是,由于 Spanner 的 2PC 设计,需要再增加一个时间置信区间,来确保提交时间戳晚于预备时间戳。所以,实际上 Spanner 的写等待时间就是两倍时间置信区间,均值达到了 8 毫秒。传说中,Spanner 的 TPS 是 125 就是用这个均值计算的(1 秒 /8 毫秒),但如果事务之间操作的数据不重叠,其实是不受这个限制的。
CockroachDB 采用了读等待方式,就是在所有的读操作执行前处理时间置信区间。读等待的优点是偶发,只有读操作落入写操作的置信区间才需要重启,进行等待。但是,重启后的读操作可能继续落入其他写操作的置信区间,引发多次重启。所以,读等待的缺点是等待时间可能比较长。
加餐:读等待和写等待都是通过等待的方式,度过不确定的时间误差,从而给出确定性的读写顺序,但性能会明显下降。那么在什么情况下,不用『等待』也能达到线性一致性或因果一致性?
这个问题可以有两个解决方法:
根据 Lamport 的论文 "Time, Clocks, and the Ordering of Events in a Distributed System"。
假设两个事件发生地的距离除以光速得到一个时间 X,两个事件的时间戳间隔是 Y,时钟误差是 Z。如果 X>Y+Z,那么可以确定两个事件是并行发生的,事件 2 就不用读等待了。这是因为既然事件是并行的,事件 2 看不到事件 1 的结果也就是正常的了。
Leslie Lamport: Time, Clocks, and the Ordering of Events in a Distributed System
Atomicity: Either all the changes from the transaction occur (writes, and messages sent), or none occur.
原子性就是要求事务只有两个状态:
多数情况下事务是由多个操作构成的序列。而分布式事务原子性的外在表现与事务原子性一致,但前者要涉及多个物理节点,而且增加了网络这个不确定性因素。为了协调内部的多项操作,对外表现出统一的成功或失败状态,这需要一系列的算法或协议保证。
TCC 协议是『面向应用层』中比较典型的协议,是Try、Confirm 和 Cancel 三个单词的缩写,它们是事务过程中的三个操作。
用一个例子来阐明 TCC 处理流程:
系统架构图如下:
银行的存款系统是单元化架构的,也就是说,系统由多个单元构成,每个单元包含了一个存款系统的部署实例和对应的数据库,专门为某一个地区的用户服务。比如,单元 A 为北京用户服务,单元 B 为上海用户服务。
单元化架构的好处是每个单元只包含了部分用户,这样运行负载比较小,而且一旦出现问题,也只影响到少部分客户,可以提升整个存款系统的可靠性。
不过这种架构也有局限性。那就是虽然单元内的客户转账非常容易,但是跨单元的转账需要引入额外的处理机制,而 TCC 就是一种常见的选择。
TCC 的整个过程由两类角色参与,一类是事务管理器,只能有一个;另一类是事务参与者,也就是具体的业务服务,可以是多个,每个服务都要提供 Try、Confirm 和 Cancel 三个操作。
下面是 TCC 的具体执行过程。
A 和 B 的账户分别在单元 A 和单元 B 上。现在 A 的账户余额是 4,900 元,需要给 B 转 2,000 元,一个正常流程如下:
第一阶段,事务管理器会发出 Try 操作,要求进行资源的检查和预留。也就是说,单元 A 要检查 A 账户余额并冻结其中的 2,000 元,而单元 B 要确保 B 的账户合法,可以接收转账。在这个阶段,两者账户余额始终不会发生变化。
第二阶段,因为参与者都已经做好准备,所以事务管理器会发出 Confirm 操作,执行真正的业务,完成 2,000 元的划转。
但是很不幸,B 账户是无法接收转账的非法账户,处理过程就变成下面的样子:
第一阶段,事务管理器发出 Try 指令,单元 B 对 B 账户的检查没有通过,回复 No。而单元 A 检查 A 账户余额正常,并冻结了 2,000 元,回复 Yes。
第二阶段,因为前面有参与者回复 No,所以事务管理器向所有参与者发出 Cancel 指令,让已经成功执行 Try 操作的单元 A 执行 Cancel 操作,撤销在 Try 阶段的操作,也就是单元 A 解除 2,000 元的资金冻结。
从上述流程可以发现,TCC 仅是应用层的分布式事务框架,具体操作完全依赖于业务编码实现,可以做针对性的设计,但是这也意味着业务侵入会比较深。
此外,考虑到网络的不可靠,操作指令必须能够被重复执行,这就要求 Try、Confirm、Cancel 必须是幂等性操作,也就是说,要确保执行多次与执行一次得到相同的结果。显然,这又增加了开发难度。
2PC 的首次正式提出是在 Jim Gray 1977 年发表的一份文稿中,文稿的题目是 "Notes on Data Base Operating Systems",对当时数据库系统研究成果和实践进行了总结,而 2PC 在工程中的应用还要再早上几年。
2PC 的处理过程也分为准备和提交两个阶段,每个阶段都由事务管理器与资源管理器共同完成。其中,事务管理器作为事务的协调者只有一个,而资源管理器作为参与者执行具体操作允许有多个。
用一个例子来阐明 2PC 处理流程:
系统架构图如下:
在该银行的存款系统中,所有客户的数据被分散存储在多个数据库实例中,这些数据库实例具有完全相同的表结构。业务逻辑部署在应用服务器上,通过数据库中间件访问底层的数据库实例。数据库中间件作为事务管理器,资源管理器就是指底层的数据库实例。
假设,A 和 B 的数据分别被保存在数据库 D1 和 D2 上。
下面是 2PC 的具体执行过程。
第一阶段是准备阶段,事务管理器首先向所有参与者发送待执行的 SQL,并询问是否做好提交事务的准备(Prepare);参与者记录日志、分别锁定了 A 和 B 的账户,并做出应答,协调者接收到反馈 Yes,准备阶段结束。
第二阶段是提交阶段,如果所有数据库的反馈都是 Yes,则事务管理器会发出提交(Commit)指令。这些数据库接受指令后,会进行本地操作,正式提交更新余额,给 A 的账户扣减 2,000 元,给 B 的账户增加 2,000 元,然后向协调者返回 Yes,事务结束。
那如果 A 的账户出了问题,导致转账失败,处理过程如下:
第一阶段,事务管理器向所有数据库发送待执行的 SQL,并询问是否做好提交事务的准备。
由于 A 之前在银行购买了基金定投产品,按照约定,每月银行会自动扣款购买基金,刚好这个自动扣款操作正在执行,先一步锁定了账户。数据库 D1 发现无法锁定 A 的账户,只能向事务管理器返回失败。
第二阶段,因为事务管理器发现数据库 D1 不具备执行事务的条件,只能向所有数据库发出『回滚』(Rollback)指令。所有数据库接收到指令后撤销第一阶段的操作,释放资源,并向协调者返回 Yes,事务结束。A 和 B 的账户余额均保持不变。
相比于 TCC,2PC 的优点是借助了数据库的提交和回滚操作,不侵入业务逻辑。但是,它也存在一些明显的问题:
事实上,多数分布式数据库都是在 2PC 协议基础上改进,来保证分布式事务的原子性。这里介绍两个有代表性的 2PC 改进模型,它们分别来自分布式数据库的两大阵营,NewSQL 和 PGXC。
Percolator 来自 Google 的论文 "Large-scale Incremental Processing Using Distributed Transactions and Notifications",因为它是基于分布式存储系统 BigTable 建立的模型,所以可以和 NewSQL 无缝链接。
Percolator 模型同时涉及了隔离性和原子性的处理。这里主要关注原子性的部分。
使用 Percolator 模型的前提是事务的参与者,即数据库,要 支持多版本并发控制(MVCC)。现在主流的单体数据库和分布式数据库都是支持的 MVCC。
在转账事务开始前,A 和 B 的账户分别存储在分片 P1 和 P2 上。
分片的账户表中各有两条记录,第一行记录的指针(write)指向第二行记录,实际的账户余额存储在第二行记录的 Bal. data 字段中。
Bal.data 分为两个部分,冒号前面的是时间戳,代表记录的先后次序;后面的是真正的账户余额。我们可以看到,现在 A 的账户上有 4,900 元,B 的账户上有 300 元。
下面是 Percolator 的具体执行过程。
第一,准备阶段,事务管理器向分片发送 Prepare 请求,包含了具体的数据操作要求。
分片接到请求后要做两件事,写日志和添加私有版本。关于私有版本,可以简单理解为,在 lock 字段上写入了标识信息的记录就是私有版本,只有当前事务能够操作,通常其他事务不能读写这条记录。
这里要注意一下,两个分片上的 lock 内容并不一样。
主锁的选择是随机的,参与事务的记录都可能拥有主锁,但一个事务只能有一条记录拥有主锁,其他参与事务的记录在 lock 字段记录了指针信息『primary@Ming.bal
』,指向主锁记录。
准备阶段结束的时候,两个分片都增加了私有版本记录,余额正好是转账顺利执行后的数字。
第二,提交阶段,事务管理器只需要和拥有主锁的分片通讯,发送 Commit 指令,且不用附带其他信息。
分片 P1 增加了一条新记录时间戳为 8,指向时间戳为 7 的记录,后者在准备阶段写入的主锁也被抹去。这时候 7、8 两条记录不再是私有版本,所有事务都可以看到 A 的余额变为 2,700 元,事务结束。
在提交阶段是不用更新 B 的记录的。因为分片 P2 的最后一条记录,保存了指向主锁的指针。其他事务读取到 B7 这条记录时,会根据指针去查找 A.bal,发现记录已经提交,所以 B 的记录虽然是私有版本格式,但仍然可视为已经生效了。
当然,这种通过指针查找的方式,会给读操作增加额外的工作。如果每个事务都照做,性能损耗就太大了。所以,还会有其他异步线程来更新 B 的余额记录,最终变成下面的样子。
对于 2PC 的问题,Percolator 有以下几点改进:
1、数据不一致
2PC 的一致性问题主要缘自第二阶段,不能确保事务管理器与多个参与者的通讯始终正常。
但在 Percolator 的第二阶段,事务管理器只需要与一个分片通讯,这个 Commit 操作本身就是原子的。所以,事务的状态自然也是原子的,一致性问题被完美解决了。
2、单点故障
Percolator 通过日志和异步线程的方式弱化了这个问题。
一是,Percolator 引入的异步线程可以在事务管理器宕机后,回滚各个分片上的事务,提供了善后手段,不会让分片上被占用的资源无法释放。
二是,事务管理器可以用记录日志的方式使自身无状态化,日志通过共识算法同时保存在系统的多个节点上。这样,事务管理器宕机后,可以在其他节点启动新的事务管理器,基于日志恢复事务操作。
Percolator 模型在分布式数据库的工程实践中被广泛借鉴。比如,分布式数据库 TiDB,完全按照该模型实现了事务处理;CockroachDB 也从 Percolator 模型获得灵感,设计了自己的 2PC 协议。
CockroachDB 的变化在于没有随机选择主锁,而是引入了一张全局事务表,所有分片记录的指针指向了这个事务表中对应的事务记录。
GoldenDB 展现了另外一种改良思路,称之为『一阶段提交』。
GoldenDB 遵循 PGXC 架构,包含了四种角色:协调节点、数据节点、全局事务器和管理节点,其中协调节点和数据节点均有多个。GoldenDB 的数据节点由 MySQL 担任,后者是独立的单体数据库。
虽然名字叫『一阶段提交』,但 GoldenDB 的流程依然可以分为两个阶段。
第一阶段,GoldenDB 的协调节点接到事务后,在全局事务管理器(GTM)的全局事务列表中将事务标记成活跃的状态。这个标记过程是 GoldenDB 的主要改进点,实质是通过全局事务列表来申请资源,规避可能存在的事务竞争。
这样的好处是避免了与所有参与者的通讯,也减少了很多无效的资源锁定动作。
第二阶段,协调节点把一个全局事务分拆成若干子事务,分配给对应的 MySQL 去执行。如果所有操作成功,协调者节点会将全局事务列表中的事务标记为结束,整个事务处理完成。如果失败,子事务在单机上自动回滚,而后反馈给协调者节点,后者向所有数据节点下发回滚指令。
GoldenDB 的『一阶段提交』,本质上是改变了资源的申请方式,更准确的说法是,并发控制手段从锁调度变为时间戳排序(Timestamp Ordering)。这样,在正常情况下协调节点与数据节点只通讯一次,降低了网络不确定性的影响,数据库的整体性能有明显提升。因为第一阶段不涉及数据节点的操作,也就弱化了数据一致性和单点故障的问题。
加餐:2PC 第一阶段『准备阶段』也被称为『投票阶段』,和 Paxos 协议处理阶段的命名相近,2PC 和 Paxos 协议有没有关系,如果有又是什么关系呢?
Paxos 是对单值或者一种状态达成共识的过程,而 2PC 是对多个不同数据项的变更或者多个状态,达成一致的过程。它们是有区别的,Paxos 不能替换 2PC,但它们也是有某种联系的:Paxos Commit。
Paxos Commit 协议是 2006 年在论文 "Consensus on Transaction Commit" 中首次提出的。值得一提的是,这篇论文的两位作者,是 Jim Gray 和 Leslie Lamport。他们分别是数据库和分布式领域的图灵奖获得者,也分别是 2PC 和 Paxos 的提出者。Paxos Commit 的处理过程图如下:
Paxos Commit 协议中有四个角色,有两个与 2PC 对应,分别是 TM(Transaction Manager,事务管理者)也就是事务协调者,RM(Resource Manager,资源管理者)也就是事务参与者;另外两个角色与 Paxos 对应,一个是 Leader,一个是 Acceptor。其中,TM 和 Leader 在逻辑上是不可能分的,所以在图中隐去了。因为 Leader 是选举出来的,所以第一个 Leader 标识为 Initial Leader。处理过程如下:
这个过程中,如果 Acceptor 总数是 2F+1,那么每个 RM 就有 2F+1 条路径与 Leader 通讯。只要保证其中 F+1 条路径是畅通的,整个协议就可以正常运行。因此,Paxos Commit 协议的优势之一,就是在很大程度上避免了网络故障对系统的影响。但是,相比于 2PC 来说,它的消息数量大幅增加,而且多了一次消息延迟。目前,实际产品中还很少有使用 Paxos Commit 协议的。
整个 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$
分布式数据库的写入,并不是简单的本地操作,而是使用共识算法同时在多个节点上写入数据。所以,一次写入操作延迟等于一轮共识算法开销,我们用 $L_c$ 代表一轮共识算法的用时,可以得到下面的公式:
$L_{prep}$ = W * $L_c$
再来看第二阶段,提交阶段,Percolator 模型的提交阶段只需要写入一次数据,修改整个事务的状态。对于 CockroachDB,这个事务标识可以保存在本地。那么提交操作的延迟也是一轮共识算法,也就是:
$L_{commit}$ = $L_c$
分别得到两个阶段的延迟后,带入最开始的公式,可以得到:
$L_{txn}$ = (W + 1) * $L_c$
把这个公式带入具体例子里来看一下。还是 A 给 B 转账,金额是 500 元。
在这个转账事务中,包含两条写操作 SQL,分别是扣减 A 账户余额和增加 B 账户余额,W 等于 2。再加上提交操作,一共有 3 个 $L_c$。可以看到,这个公式里事务的延迟是与写操作 SQL 的数量线性相关的,而真实场景中通常都会包含多个写操作,那事务延迟肯定不能让人满意。
第一个办法是将所有写操作缓存起来,直到 commit 语句时一起执行,这种方式称为 Buffering Writes until Commit,把它翻译为『缓存写提交』。而 TiDB 的事务处理中就采用这种方式,借用 TiDB 官网的一张交互图来说明执行过程。
所有从 Client 端提交的 SQL 首先会缓存在 TiDB 节点,只有当客户端发起 Commit 时,TiDB 节点才会启动两阶段提交,将 SQL 被转换为 TiKV 的操作。这样,显然可以压缩第一阶段的延迟,把多个写操作 SQL 压缩到大约一轮共识算法的时间。那么整个事务延迟就是:
$L_{txn}$ = 2 * $L_c$
但缓存写提交存在两个明显的缺点。
首先是在客户端发送 Commit 前,SQL 要被缓存起来,如果某个业务场景同时存在长事务和海量并发的特点,那么这个缓存就可能被撑爆或者成为瓶颈。
其次是客户端看到的 SQL 交互过程发生了变化,在 MySQL 中如果出现事务竞争,判断优先级的规则是 First Write Win,也就是对同一条记录先执行写操作的事务获胜。而 TiDB 因为缓存了所有写 SQL,所以就变成了 First Commit Win,也就是先提交的事务获胜。用一个具体的例子来演示这两种情况。
在 MySQL 中同时执行 T1,T2 两个事务,T1 先执行了 update,所以获得优先权成功提交。而 T2 被阻塞,等待 T1 提交后才完成提交。
在 TiDB 中执行同样的 T1、T2,虽然 T2 晚于 T1 执行 update,但却先执行了 commit,所以 T2 获胜,T1 失败。
First Write Win 与 First Commit Win 在交互上是显然不同的,这虽然不是大问题,但对于开发者来说,还是有一定影响的。可以说,TiDB 的『缓存写提交』方式已经不是完全意义上的交互事务了。
有一种方法,既能缩短延迟,又能保持交互事务的特点。CockroachDB 采用的就是这种方式,称为 Pipeline。具体过程就是在准备阶段是按照顺序将 SQL 转换为 K / V 操作并执行,但是并不等待返回结果,直接执行下一个 K / V 操作。
这样,准备阶段的延迟,等于最慢的一个写操作延迟,也就是一轮共识算法的开销,所以整体延迟同样是:
$L_{prep}$ = $L_c$
加上提交阶段的一轮共识算法开销:
$L_{txn}$ = 2 * $L_c$
再结合转账的例子看一下:
同样的操作,按照 Pipeline 方式,增加小红账户余额时并不等待小明扣减账户的动作结束,两条 SQL 的执行时间约等于 1 个 $L_c$。加上提交阶段的 1 个 $L_c$,一共是 2 个 $L_c$,并且延迟也不再随着 SQL 数量增加而延长。
2 个 $L_c$ 是多久,需要带入真实场景计算一下。
首先,评估一下期望值。对于联机交易来说,延迟通常不超过 1 秒,如果用户体验良好,则要控制在 500 毫秒以内。其中留给数据库的处理时间不会超过一半,也就是 250-500 毫秒。这样推算,$L_c$ 应该控制在 125-250 毫秒之间。
再来看看真实的网络环境。目前人类现有的科技水平是不能超越光速的,这个光速是指光在真空中的传播速度,大约是 30 万千米每秒。而光纤由于传播介质不同和折线传播的关系,传输速度会降低 30%,大致是 20 万千米每秒。但是,这仍然是一个比较理想的速度,因为还要考虑网络上的各种设备、协议处理、丢包重传等等情况,实际的网络延迟还要长很多。
这里引用了论文 "Highly Available Transactions: Virtues and Limitations" 中的一些数据,这篇论文发表在 VLDB2014 上,在部分章节中初步探讨了系统全球化部署面临的延迟问题。论文作者在亚马逊 EC2 上,使用 Ping 包的方式进行了实验,并统计了一周时间内 7 个不同地区机房之间的 RTT(Round-Rip Time,往返延迟)数据。
简单来说,RTT 就是数据在两个节点之间往返一次的耗时。在讨论网络延迟的时候,为了避免歧义,通常使用 RTT 这个概念。
实验中,地理跨度较大两个机房是巴西圣保罗和新加坡,两地之间的理论 RTT 是 106.7 毫秒(使用光速测算),而实际测试的 RTT 均值为 362.8 毫秒,P95(95%)RTT 均值为 649 毫秒。将 649 毫秒代入公式,那 $L_{txn}$ 就是接近 1.3 秒,这显然太长了。考虑到共识算法的数据包更大,这个延迟还会更长。
像 CockroachDB、YugabyteDB 这样分布式数据库,它们的目标就是全球化部署,所以还要努力去压缩事务延迟。
准备阶段的操作已经压缩到极限了,目前的办法就是让两个动作并行执行。
在优化前的处理流程中,CockroachDB 会记录事务的提交状态:
TransactionRecord{ Status: COMMITTED, ...}
并行执行的过程是这样的:
准备阶段的操作,在 CockroachDB 中被称为意向写。这个并行执行就是在执行意向写的同时,就写入事务标志,当然这个时候不能确定事务是否提交成功的,所以要引入一个新的状态『Staging』,表示事务正在进行。那么这个记录事务状态的落盘操作和意向写大致是同步发生的,所以只有一轮共识算法开销。事务表中写入的内容是类似这样的:
TransactionRecord{ Status: STAGING, Writes: []Key{"A", "C", ...}, ...}
Writes 部分是意向写的 Key。这是留给异步进程的线索,通过这些 Key 是否写成功,可以倒推出事务是否提交成功。
而客户端得到所有意向写的成功反馈后,可以直接返回调用方事务提交成功。客户端只在当前进程内判断事务提交成功后,不维护事务状态,而直接返回调用方;事后由异步线程根据事务表中的线索,再次确认事务的状态,并落盘维护状态记录。这样事务操作中就减少了一轮共识算法开销。
并行提交的优化思路其实和 Percolator 很相似,那就是不要纠结于在一次事务中搞定所有事情,可以只做最少的工作,留下必要的线索,就可以达到极致的速度。而后续的异步进程,只要根据线索,完成收尾工作就可以了。
加餐:并行提交中的 异步写事务日志只是根据每个数据项的写入情况,追溯出事务的状态,然后落盘保存,整个过程并没有任何重试或者回滚的操作。这是因为,在之前的同步操作过程中,负责管道写入的同步线程,已经明确知道了每个数据项的写入情况,也就是确定了事务的状态,不同步落盘只是为了避免由此带来的共识算法延迟。
事务状态置为staging,是表示事务已经开始但状态未知,而后在所有写入执行完毕后,事务的状态是明确的。但是,如果此时更新事务状态会带来一轮多副本写入的开销,增加延迟。所以,协调者直接向调用方返回事务处理的结果,再由异步线程来更新持久化的事务状态。这个更新过程,不需要考虑多副本的问题,因为所有写入操作都已经完成了多副本的一致性投票。只是要确认每个写入都成功,则可以判定事务成功,否则事务失败。
Daniel Peng and Frank Dabek: Large-scale Incremental Processing Using Distributed Transactions and Notifications
Jim Gray: [Notes on Data Base Operating Systems](Jim Gray: Notes on Data Base Operating Systems)
Peter Bailis et al.: [Highly Available Transactions: Virtues and Limitations
分布式数据库的很多设计都和时间有关,更确切地说是和全局时钟有关。
区分授时机制抓住三个要素就可以:
根据排列组合,一共产生了 8 种可能性,其中 NTP(Network Time Protocol)误差大,也不能保证单调递增,所以就没有单独使用 NTP 的产品;还有一些方案在实践中则是不适用的(N / A)。因此常见的方案主要只有 4 类:
TrueTime
Spanner 采用的方案是 TrueTime。它的时间源是 GPS 和原子钟,所以属于多时间源和物理时钟,同时它也采用了多点授时机制,就是说集群内有多个时间服务器都可以提供授时服务。
TrueTime 作为全局时钟的一种实现形式,是 Google 通过 GPS 和原子钟两种方式混合提供的授时机制,误差可以控制在 7 毫秒以内。例如,A、B 两个进程先后调用 TrueTime 服务,各自拿到一个时间区间,如果在其中随机选择,则可能出现 B 的时间早于 A 的时间。不只是 TrueTime,任何物理时钟都会存在时钟偏移甚至回拨。
它也有两个显著的优势:首先是高可靠高性能,多时间源和多授时点实现了完全的去中心化设计,不存在单点;其次是支持全球化部署,客户端与时间服务器的距离也是可控的,不会因为两者通讯延迟过长导致时钟失效。
HLC
CockroachDB 和 YugabyteDB 也是以高性能高可靠和全球化部署为目标,不过 Truetime 是 Google 的独门绝技,它依赖于特定硬件设备的思路,不适用于开源软件。所以,它们使用了混合逻辑时钟(Hybrid Logical Clock,HLC),同样是多时间源、多点授时,但时钟采用了物理时钟与逻辑时钟混合的方式。HLC 在实现机制上也是蛮复杂的,而且和 TrueTime 同样有整体性的时间误差。
TSO
其他的分布式数据库大多选择了单时间源、单点授时的方式,承担这个功能的组件在 NewSQL 风格架构中往往被称为 TSO(Timestamp Oracle),而在 PGXC 风格架构中被称为全局事务管理器(Golobal Transcation Manager,GTM)。这就是说一个单点递增的时间戳和全局事务号基本是等效的。这种授时机制的最大优点就是实现简便,如果能够保证时钟单调递增,还可以简化事务冲突时的设计。但缺点也很明显,集群不能大范围部署,同时性能也有上限。TiDB、OceanBase、GoldenDB 和 TBase 等选择了这个方向。
STP
最后,还有一些小众的方案,比如巨杉的 STP(SequoiaDB Time Protoco)。它采用了单时间源、多点授时的方式,优缺点介于 HLC 和 TSO 之间。
最早提出 TSO 的,大概是 Google 的论文 "Large-scale Incremental Processing Using Distributed Transactions and Notifications"。这篇论文主要是介绍分布式存储系统 Percolator 的实现机制,其中提到通过一台 Oracle 为集群提供集中授时服务,称为 Timestamp Oracle。所以,后来的很多分布式系统也用它的缩写来命名自己的单点授时机制,比如 TiDB 和 Yahoo 的 Omid。
考虑到 TiDB 的使用更广泛些,这里主要介绍 TiDB 的实现方式。
TiDB 的全局时钟是一个数值,它由两部分构成,其中高位是物理时间,也就是操作系统的毫秒时间;低位是逻辑时间,是一个 18 位的数值。那么从存储空间看,1 毫秒最多可以产生 262,144 个时间戳(2^18),这已经是一个很大的数字了,一般来说足够使用了。
单点授时首先要解决的肯定是单点故障问题。TiDB 中提供授时服务的节点被称为 Placement Driver,简称 PD。多个 PD 节点构成一个 Raft 组,这样通过共识算法可以保证在主节点宕机后马上选出新主,在短时间内恢复授时服务。
那问题来了,如何保证新主产生的时间戳一定大于旧主呢?那就必须将旧主的时间戳存储起来,存储也必须是高可靠的,所以 TiDB 使用了 etcd。但是,每产生一个时间戳都要保存吗?显然不行,那样时间戳的产生速度直接与磁盘 I / O 能力相关,会存在瓶颈的。
如何解决性能问题呢?TiDB 采用预申请时间窗口的方式,用一张图来表示这个过程:
当前 PD(主节点)的系统时间是 103 毫秒,PD 向 etcd 申请了一个『可分配时间窗口』。要知道时间窗口的跨度是可以通过参数指定的,系统的默认配置是 3 毫秒,示例采用了默认配置,所以这个窗口的起点是 PD 当前时间 103,时间窗口的终点就在 106 毫秒处。写入 etcd 成功后,PD 将得到一个从 103 到 106 的『可分配时间窗口』,在这个时间窗口内 PD 可以使用系统的物理时间作为高位,拼接自己在内存中累加的逻辑时间,对外分配时间戳。
上述设计意味着,所有 PD 已分配时间戳的高位,也就是物理时间,永远小于 etcd 存储的最大值。那么,如果 PD 主节点宕机,新主就可以读取 etcd 中存储的最大值,在此基础上申请新的『可分配时间窗口』,这样新主分配的时间戳肯定会大于旧主了。
此外,为了降低通讯开销,每个客户端一次可以申请多个时间戳,时间戳数量作为参数,由客户端传给 PD。但要注意的是,一旦在客户端缓存,多个客户端之间时钟就不再是严格单调递增的,这也是追求性能需要付出的代价。
HLC 作为一种纯软的实现方式,更加灵活,在 CockroachDB、YugabyteDB 和很多分布式存储系统得到了广泛使用。
HLC 不只是字面上的意思, TiDB 的 TSO 也混合了物理时钟与逻辑时钟,但两者截然不同。HLC 代表了一种计时机制,它的首次提出是在论文 "Logical Physical Clocks and Consistent Snapshots in Globally Distributed Databases" 中,CockroachDB 和 YugabyteDB 的设计灵感都来自于这篇论文。下面结合图片介绍一下这个机制:
假如有 ABCD 四个节点,方框是节点上发生的事件,方框内的三个数字依次是节点的本地物理时间(简称本地时间,Pt)、HLC 的高位(简称 L 值)和 HLC 的低位(简称 C 值)。
A 节点的本地时间初始值为 10,其他节点的本地时间初始值都是 0。四个节点的第一个事件都是在节点刚启动的一刻发生的。首先看 A1,它的 HLC 应该是 (10,0),其中高位直接取本地时间,低位从 0 开始。同理,其他事件的 HLC 都是 (0,0)。
事件 D2 发生时,首先取上一个事件 D1 的 L 值和本地时间比较。L 值等于 0,本地时间已经递增变为 1,取最大值,那么用本地时间作为 D2 的 L 值。高位变更了,低位要归零,所以 D2 的 HLC 就是 (1,0)。
如果看懂了 D2 的计时逻辑就会发现,D1 其实是一样的,只不过 D1 没有上一个事件的 L 值,只能用 0 代替,是一种特殊情况。
如果节点间有调用关系,计时逻辑会更复杂一点。我们看事件 B2,要先判断 B2 的 L 值,就有三个备选:
这三个值分别是 0、1 和 10。按照规则取最大值,所以 B2 的 L 值是 10,也就是 A1 的 L 值,而 C 值就在 A1 的 C 值上加 1,最终 B2 的 HLC 就是 (10,1)。
B3 事件发生时,发现当前本地时间比 B2 的 L 值还要小,所以沿用了 B2 的 L 值,而 C 值是在 B2 的 C 值上加一,最终 B3 的 HLC 就是 (10,2)。
在 HLC 机制下,每个节点会使用本地时钟作为参照,但不受到时钟回拨的影响,可以保证单调递增。本质上,HLC 还是 Lamport 逻辑时钟的变体,所以对于不同节点上没有调用关系的两个事件,是无法精确判断先后关系的。比如,上面例子中的 C2 和 D2 有同样的 HLC,但从上帝视角看,C2 是早于 D2 发生的,因为两个节点的本地时钟有差异,就没有体现这种先后关系。HLC 是一种松耦合的设计,所以不会去校正节点的本地时钟,本地时钟是否准确,还要靠 NTP 或类似的协议来保证。
巨杉采用了单时间源、多点授时机制,它有自己的全局时间协议,称为 STP(Serial Time Protocol),是内部逻辑时间同步的协议,并不依赖于 NTP 协议。下面是 STP 体系下各角色节点的关系:
STP 是独立于分布式数据库的授时方案,该体系下的各角色节点与巨杉的其他角色节点共用机器,但没有必然的联系。
STP 下的所有角色统称为 STP Node,具体分为两类:
巨杉数据库的其他角色节点,如编目节点(CATALOG)、协调节点(COORD)和数据节点(DATA)等,都从本地的 STP Node 节点获得时间。
STP 与 TSO 一样都是单时间源,但通过增加更多的授时点,避免了单点性能瓶颈,而负副作用是多点授时就会造成全局性的时间误差,因此和 HLC 一样需要做针对性设计。
分片就是解决性能和存储这两个问题的关键设计,甚至不仅是分布式数据库,在所有分布式存储系统中,分片这种设计都是广泛存在的。
分片在不同系统中有各自的别名,Spanner 和 YugabyteDB 中被称为 Tablet,在 HBase 和 TiDB 中被称为 Region,在 CockraochDB 中被称为 Range。无论叫什么,概念都是一样的,分片是一种水平切分数据表的方式,它是数据记录的集合,也是数据表的组成单位。
分布式数据库的分片与单体数据库的分区非常相似,区别在于:分区虽然可以将数据表按照策略切分成多个数据文件,但这些文件仍然存储在单节点上;而分片则可以进一步根据特定规则将切分好的文件分布到多个节点上,从而实现更强大的存储和计算能力。
分片机制通常有两点值得关注:
分片机制的两个要点与提到的两种架构风格对应如下:
从表格中可以看出,PGXC 只支持静态的 Hash 分片和 Range 分片,实现机制较为简单。
Hash 分片,就是按照数据记录中指定关键字的 Hash 值将数据记录映射到不同的分片中。下图来表示 Hash 分片的过程:
图中的表格部分显示了一个社交网站的记录表,包括主键、用户 ID、分享内容和分享时间等字段。假设以用户 ID 作为关键字进行分片,系统会通过一个 Hash 函数计算用户 ID 的 Hash 值而后取模,分配到对应的分片。模为 4 的原因是系统一共有四个节点,每个节点作为一个分片。
因为 Hash 计算会过滤掉数据原有的业务特性,所以可以保证数据非常均匀地分布到多个分片上,这是 Hash 分片最大的优势,而且它的实现也很简洁。但示例中采用的分片方法直接用节点数作为模,如果系统节点数量变动,模也随之改变,数据就要重新 Hash 计算,从而带来大规模的数据迁移。显然,这种方式对于扩展性是非常不友好的。
一致性 Hash 可以提升系统的扩展性,该算法首次提出是在论文 "Consistent Hashing and Random Trees : Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web" 当中。
要在工业实践中应用一致性 Hash 算法,首先会引入虚拟节点,每个虚拟节点就是一个分片。为了便于说明,在这个案例中将分片数量设定为 16。但实际上,因为分片数量决定了集群的最大规模,所以它通常会远大于初始集群节点数。
16 个分片构成了整个 Hash 空间,数据记录的主键和节点都要通过 Hash 函数映射到这个空间。这个 Hash 空间是一个 Hash 环。换一种方式画图,可以看得更清楚些。
节点和数据都通过 Hash 函数映射到 Hash 环上,数据按照顺时针找到最近的节点。
当新增一台服务器,即节点 E 时,受影响的数据仅仅是新服务器到其环空间中前一台服务器(即沿着逆时针方向的第一台服务器)之间数据。结合示例,只有小红分享的消息从节点 B 被移动到节点 E,其他节点的数据保持不变。此后,节点 B 只存储 Hash 值 6 和 7 的消息,节点 E 存储 Hash 值 4 和 5 的消息。
Hash 函数的优点是数据可以较为均匀地分配到各节点,并发写入性能更好。
本质上,Hash 分片是一种静态分片方式,必须在设计之初约定分片的最大规模。同时,因为 Hash 函数已经过滤掉了业务属性,也很难解决访问业务热点问题。所谓业务热点,就是由于局部的业务活跃度较高,形成系统访问上的热点。这种情况普遍存在于各类应用中,比如电商网站的某个商品卖得比较好,或者外卖网站的某个饭店接单比较多,或者某个银行网点的客户业务量比较大等等。
与 Hash 分片不同,Range 分片的特点恰恰是能够加入对于业务的预估。例如,用『Location』作为关键字进行分片时,不是以统一的行政级别为标准。因为注册地在北京、上海的用户更多,所以这两个区域可以按照区县设置分片,而海外用户较少,可以按国家设置为分片。这样,分片间的数据更加平衡。
但是,这种方式依然是静态的,如果海外业务迅速增长,服务海外用户的分片将承担更大的压力,可能导致性能下降,用户体验不佳。
相对 Hash 分片,Range 分片的适用范围更加广泛。其中一个非常重要的原因是,Range 分片可以更高效地扫描数据记录,而 Hash 分片由于数据被打散,扫描操作的 I/O 开销更大。但是,PGXC 的 Range 分片受限于单体数据库的实现机制,很难随数据变动和负载变化而调整。
虽然有些 PGXC 同时支持两种分片方式,但 Hash 分片仍是主流,比如 GoldenDB 默认使用 Hash 分片,而 TBase 仅支持 Hash 分片。
总体上,NewSQL 也是支持 Hash 和 Range 两种分片方式的。具体就产品来说,CockroachDB 和 YugabyteDB 同时支持两种方式,TiDB 仅支持 Range 分片。
NewSQL 数据库的 Hash 分片也是静态的,所以与 PGXC 差别不大,这里就不再赘述,着重讲述下 Range 动态分片。
NewSQL 的 Range 分片,多数是用主键作为关键字来分片的,当然主键可以是系统自动生成的,也可以是用户指定的。既然提供了用户指定主键的方式,那么理论上可以通过设定主键的产生规则,控制数据流向哪个分片。但是,主键必须保证唯一性,甚至是单调递增的,导致这种控制就会比较复杂,使用成本较高。所以,基本可以认为,分片是一个系统自动处理的过程,用户是感知不到的。这样做的好处显然是提升了系统的易用性。
将 NewSQL 的 Range 分片称为动态分片,主要有两个原因:
1、分片可以自动完成分裂与合并:当单个分片的数据量超过设定值时,分片可以一分为二,这样就可以保证每个分片的数据量较为均衡。多个数据量较少的分片,会在一定的周期内被合并为一个分片。
根据消息的数量来自动分片,我们可以得到 R1、R2、R3 三个分片。
分片也会被均衡地调度到各个节点上,节点间的数据量也保持总体平衡。
2、可以根据访问压力调度分片:系统之所以尽量维持分片之间,以及节点间的数据量均衡,存储的原因外,还可以更大概率地将访问压力分散到各个节点上。但是,有少量的数据可能会成为访问热点,就是上面提到的业务热点,从而打破这种均衡。比如,A 和 B 都是娱乐明星,有很多粉丝关注她们分享的内容,其访问量远超过普通人。这时候,系统会根据负载情况,将 R2 和 R3 分别调度到不同的节点,来均衡访问压力。
存储均衡 和 访问压力均衡,是 NewSQL 分片调度机制普遍具备的两项能力。此外,还有两项能力在 "Spanner" 论文中被提及,但在其他产品中没有看到工程化实现。
第一是减少分布式事务。对分布式数据库来说,有一个不争的事实,那就是分布式事务的开销永远不会小于单节点本地事务的开销。因此,所有分布式数据库都试图通过减少分布式事务来提升性能。
Spanner 在 Tablet,也就是 Range 分片,之下增加了目录(Directory),作为数据调度的最小单位,它的调度范围是可以跨 Tablet 的。通过调度 Directory 可以将频繁参与同样事务的数据,转移到同一个 Tablet 下,从而将分布式事务转换为本地事务。
第二是缩短服务延时。对于全球化部署的分布式数据库,数据可能存储在相距很远的多个数据中心,如果用户需要访问远端机房的数据,操作延时就比较长,这受制于数据传输速度。而 Spanner 可以将 Directory 调度到靠近用户的数据中心,缩短数据传输时间。当然,这里的调度对象都是数据的主副本,跨中心的数据副本仍然存在,负责保证系统整体的高可靠性。
Directory 虽然带来新的特性,但显然也削弱了分片的原有功能,分片内的记录不再连续,扫描要付出更大成本。而减少分布式事务和靠近客户端位置这本身就是不能兼顾的,再加上存储和访问压力,分片调度机制要在四个目标间进行更复杂的权衡。
高可靠是分布式数据库的重要特性,分片是数据记录的最小组织单位,也必须是高可靠的。
NewSQL 与 PGXC 的区别在于,对于 NewSQL 来说,分片是高可靠的最小单元;而对于 PGXC,分片的高可靠要依附于节点的高可靠。
NewSQL 的实现方式是复制组(Group)。在产品层面,通常由一个主副本和若干个副本组成,通过 Raft 或 Paxos 等共识算法完成数据同步,称为 Raft Group 或 Paxos Group,所以我们简称这种方式为 Group。因为不相关的数据记录会被并发操作,所以同一时刻有多个 Group 在工作。因此,NewSQL 通常支持 Multi Raft Group 或者 Multi Paxos Group。
每个 Group 是独立运行的,只是共享相同的网络和节点资源,所以不同复制组的主副本是可以分布在不同节点的。
PGXC 的最小高可靠单元由一个主节点和多个备节点组成,借用 TDSQL 中的术语,将其称为 Set。一个 PGXC 是由多个 Set 组成。Set 的主备节点间复制,多数采用半同步复制,平衡可靠性和性能。这意味着,所有分片的主副本必须运行在 Set 的主节点上。
从架构设计角度看,Group 比 Set 更具优势,原因主要有两个方面。首先,Group 的高可靠单元更小,出现故障时影响的范围就更小,系统整体的可靠性就更高。其次,在主机房范围内,Group 的主副本可以在所有节点上运行,资源可以得到最大化使用,而 Set 模式下,占大多数的备节点是不提供有效服务的,资源白白浪费掉。
数据复制典型的算法就是 Paxos 和 Raft。其中比较重要的两个点就是分片元数据的存储和数据复制的效率。
在任何一个分布式存储系统中,收到客户端请求后,承担路由功能的节点首先要访问分片元数据(简称元数据),确定分片对应的节点,然后才能访问真正的数据。这里说的元数据,一般会包括分片的数据范围、数据量、读写流量和分片副本处于哪些物理节点,以及副本状态等信息。
从存储的角度看,元数据也是数据,但特别之处在于每一个请求都要访问它,所以元数据的存储很容易成为整个系统的性能瓶颈和高可靠性的短板。如果系统支持动态分片,那么分片要自动地分拆、合并,还会在节点间来回移动。这样,元数据就处在不断变化中,又带来了多副本一致性(Consensus)的问题。
以下是不同产品存储元数据的具体:
最简单的情况是静态分片。可以忽略元数据变动的问题,只要把元数据复制多份放在对应的工作节点上就可以了,这样同时兼顾了性能和高可靠。TBase 大致就是这个思路,直接将元数据存储在协调节点上。即使协调节点是工作节点,随着集群规模扩展,会导致元数据副本过多,但由于哈希分片基本上就是静态分片,也就不用考虑多副本一致性的问题。
但如果要更新分片信息,这种方式显然不适合,因为副本数量过多,数据同步的代价太大了。所以对于动态分片,通常是不会在有工作负载的节点上存放元数据的。
所以解决方法一般是专门给元数据搞一个小规模的集群,用 Paxos 协议复制数据。这样保证了高可靠,数据同步的成本也比较低。TiDB 大致就是这个思路,但具体的实现方式会更巧妙一些。
在 TiDB 架构中,TiKV 节点是实际存储分片数据的节点,而元数据则由 Placement Driver 节点管理。Placement Driver 这个名称来自 Spanner 中对应节点角色,简称为 PD。
在 PD 与 TiKV 的通讯过程中,PD 完全是被动的一方。TiKV 节点定期主动向 PD 报送心跳,分片的元数据信息也就随着心跳一起报送,而 PD 会将分片调度指令放在心跳的返回信息中。等到 TiKV 下次报送心跳时,PD 就能了解到调度的执行情况。
由于每次 TiKV 的心跳中包含了全量的分片元数据,PD 甚至可以不落盘任何分片元数据,完全做成一个无状态服务。这样的好处是,PD 宕机后选举出的新主根本不用处理与旧主的状态衔接,在一个心跳周期后就可以工作了。当然,在具体实现上,PD 仍然会做部分信息的持久化,这可以认为是一种缓存。
通讯过程大致如下:
三个 TiKV 节点每次上报心跳时,由主副本(Leader)提供该分片的元数据,这样 PD 可以获得全量且没有冗余的信息。
虽然无状态服务有很大的优势,但 PD 仍然是一个单点,也就是说这个方案还是一个中心化的设计思路,可能存在性能方面的问题。
CockroachDB 的解决方案是使用 Gossip 协议。不采用 Paxos 协议的原因是 Paxos 协议本质上是一种广播机制,也就是由一个中心节点向其他节点发送消息。当节点数量较多时,通讯成本就很高。
CockroachDB 采用了 P2P 架构,每个节点都要保存完整的元数据,这样节点规模就非常大,当然也就不适用广播机制。而 Gossip 协议的原理是谣言传播机制,每一次谣言都在几个人的小范围内传播,但最终会成为众人皆知的谣言。这种方式达成的数据一致性是 “最终一致性”,即执行数据更新操作后,经过一定的时间,集群内各个节点所存储的数据最终会达成一致。
CockroachDB 真的是基于“最终一致性”的元数据实现了强一致性的分布式数据库。
过程图如下:
可以看到,CockroachDB 在寻址过程中会不断地更新分片元数据,促成各节点元数据达成一致。
复制协议的选择和数据副本数量有很大关系:如果副本少,参与节点少,可以采用广播方式,也就是 Paxos、Raft 等协议;如果副本多,节点多,那就更适合采用 Gossip 协议。
具体来说就是 Raft 与 Paxos 在效率上的差异,以及 Raft 的一些优化手段。在分布式数据库中,采用 Paxos 协议的比较少,知名产品就只有 OceanBase,下面的差异分析会基于 Raft 展开。
顺序投票是影响 Raft 算法复制效率的一个关键因素。
完整的 Raft 日志复制过程如下:
以上是单个事务的运行情况。那么,当多事务并行操作时,又是什么样子的呢?过程如下图:
设定这个 Raft 组由 5 个节点组成,T1 到 T5 是先后发生的 5 个事务操作,被发送到这个 Raft 组。
事务 T1 的操作是将 X 置为 1,5 个节点都 Append 成功,Leader 节点 Apply 到本地状态机,并返回客户端提交成功。事务 T2 执行时,虽然有一个 Follower 没有响应,但仍然得到了大多数节点的成功响应,所以也返回客户端提交成功。
现在,轮到 T3 事务执行,没有得到超过半数的响应,这时 Leader 必须等待一个明确的失败信号,比如通讯超时,才能结束这次操作。因为有顺序投票的规则,T3 会阻塞后续事务的进行。T4 事务被阻塞是合理的,因为它和 T3 操作的是同一个数据项,但是 T5 要操作的数据项与 T3 无关,也被阻塞,显然这不是最优的并发控制策略。
同样的情况也会发生在 Follower 节点上,第一个 Follower 节点可能由于网络原因没有收到 T2 事务的日志,即使它先收到 T3 的日志,也不会执行 Append 操作,因为这样会使日志出现空洞。
Raft 的顺序投票是一种设计上的权衡,虽然性能有些影响,但是节点间日志比对会非常简单。在两个节点上,只要找到一条日志是一致的,那么在这条日志之前的所有日志就都是一致的。这使得选举出的 Leader 与 Follower 同步数据非常便捷,开放 Follower 读操作也更加容易。要知道,我说的可是保证一致性的 Follower 读操作,它可以有效分流读操作的访问压力。
在真正的工程实现中,Raft 主副本也不是挨个处理请求,还是有一些优化手段的。TiDB 的官方文档对 Raft 优化说得比较完整,这里引用过来,着重介绍下它的四个优化点:
其实,Raft 算法的这四项优化并不是 TiDB 独有的,CockroachDB 和一些 Raft 库也做了类似的优化。比如,SOFA-JRaft 也实现了 Batch 和 Pipeline 优化。
etcd 是最早的、生产级的 Raft 协议开源实现,TiDB 和 CockroachDB 都借鉴了它的设计。甚至可以说,它们选择 Raft 就是因为 etcd 提供了可靠的工程实现,而 Paxos 则没有同样可靠的工程实现。既然是开源,为啥不直接用呢?因为 etcd 是单 Raft 组,写入性能受限。所以,TiDB 和 CockroachDB 都改造成多个 Raft 组,这个设计被称为 Multi Raft,所有采用 Raft 协议的分布式数据库都是 Multi Raft。这种设计,可以让多组并行,一定程度上规避了 Raft 的性能缺陷。
同时,Raft 组的大小,也就是分片的大小也很重要,越小的分片,事务阻塞的概率就越低。TiDB 的默认分片大小是 96M,CockroachDB 的分片不超过 512M。那么,TiDB 的分片更小,就是更好的设计吗?也未必,因为分片过小又会增加扫描操作的成本,这又是另一个权衡点了。
加餐:Raft 由于顺序投票的限制,在复制效率上比 Paxos 稍差。但是因为 Raft 有高质量的开源实现项目 etcd;而 Paxos 因为算法复杂没有稳定的开源能实现,所有 TiDB 和 CockroachDB 还是选择了 Raft 协议。同时,TiDB 和 CockroachDB 采用了 Multi Raft 的方式,让多分片并行处理提升性能。两者在 Raft 协议实现上也进行了若干改进。这些改进思路很有普适性,一些独立的 Raft 项目也同样实现了,比如 SOFA-JRaft。
Daniel Peng and Frank Dabek: Large-scale Incremental Processing Using Distributed Transactions and Notifications
Sandeep S. Kulkarni et al.: Logical Physical Clocks and Consistent Snapshots in Globally Distributed Databases
由表及里、由外到内是人们认识事物的普遍规律,所以从两个视角来观察:
外部视角,外部特性:分布式数据库是服务于写多读少、低延时、海量并发 OLTP(联机交易)场景的,具备海量数据存储能力和高可靠性的关系型数据库。
内部视角,内部构成:将技术细节收敛到产品内部,以一个整体面对业务应用。
对于分布式系统而言,一致性是在探讨当系统内的一份逻辑数据存在多个物理的数据副本时,对其执行读写操作会产生什么样的结果,这也符合 CAP 理论对一致性的表述。而在数据库领域,『一致性』与事务密切相关,又进一步细化到 ACID 四个方面。其中,I 所代表的隔离性(Isolation),是『一致性』的核心内容,研究的就是如何协调事务之间的冲突。因此,在谈论分布式数据库的一致性时,实质上是在谈论 数据一致性 和 事务一致性 两个方面。
数据一致性有一个前提,就是同时存在读操作和写操作。把两个因素加在一起,就是多副本数据上的一组读写策略,被称为『一致性模型』(Consistency Model)。
观察数据一致性有以下两个视角:
从状态的视角来看,任何变更操作后,数据只有两种状态:所有副本一致或者不一致。在某些条件下,不一致的状态是暂时,还会转换到一致的状态;而那些永远不一致的情况几乎不会去讨论,所以习惯上会把不一致称为『弱一致』。相对的,一致就叫做『强一致』。
强一致:在全同步复制模式下,主备库同步 binlog 时,主库只有在收到备库的成功响应后,才能向客户端反馈提交成功。客户端获得响应时,主备库的数据副本已经达成一致,后续操作没有问题,但这种模式的副作用非常大,主要有以下两点:
弱一致:NoSQL 产品是应用弱一致性的典型代表,但对弱一致性的接受仍然是有限度的,这就是 BASE 理论中的 E 所代表的最终一致性(Eventually Consistency),弱于最终一致性的产品就几乎没有了。
对于最终一致性,可以这样理解:在主副本执行写操作并反馈成功时,不要求其他副本与主副本保持一致,但在经过一段时间后这些副本最终会追上主副本的进度,重新达到数据状态的一致。
这个『经过一段时间』含义比较模糊,需要从操作视角分析。
最终一致性,在语义上包含了很大的不确定性,所以很多时候并不是直接使用,而是加入一些限定条件,也就衍生出了若干种一致性模型。因为它们是在副本不一致的情况下,进行操作层面的封装来对外表现数据的状态,所以都可以纳入操作视角。有以下几个模型代表:
综上所述,提到的一致性模型强度排序如下:
线性一致性 > 顺序一致性 > 因果一致性 > { 写后读一致性,单调一致性,前缀一致性 }
注:CAP 的 C 也是 Consistency,是多副本、单操作的数据一致性;而 ACID 里的 C 是指单副本、多操作的事务一致性。Paxos 这类共识算法,可以看作是复制协议的一种,虽然有时也叫做一致性协议,但这个一致性是指 Consensus。Consensus 是实现数据一致性目标下的具体技术,但并不是唯一的选择。
加餐:Aurora 不是分布式数据库,主要原因就是 Aurora 依然是不支持写入能力的水平扩展。Aurora 是亚马逊推出的云原生数据库,它采用计算与存储分离的思想,计算能力垂直扩展,存储能力水平扩展。究其原因,它的存储系统是直接架设在自家的分布式存储系统(S3)之上的;而计算节点仍然是单节点,所以是垂直扩展。当然 Aurora 也像 MySQL 一样是支持一写多读的,根据亚马逊的官方说明,可以配置 15 个备节点来分流读操作的压力。由于 Aurora 的元数据会缓存在主节点上的,在发生变更时,主备同步数据有一个小的延迟(小于 100 毫秒),这就造成备节点不能承接写入功能,读也不能保证严格的数据一致性。
NoSQL 以 BASE 为理论基础,BASE 其实代表了三个特性:
BASE 的意义只在于放弃了 ACID 的一些特性,从而更简单地实现了高性能和可用性,达到一个新的平衡。
在数据库中,『事务』是由多个操作构成的序列。1970 年詹姆斯 · 格雷(Jim Gray)提出了事务的 ACID 四大特性,将广义上的事务一致性具化到了原子性、一致性、隔离性和持久性这 4 个方面。
虽然 ACID 名义上并列为事务的四大特性,但它们对于数据库的重要程度并不相同。
第一个是一致性,它是其中存在感最低的特性,可以看作是对『事务』整体目标的阐述。它并没有提出任何具体的功能需求,所以在数据库中也很难找到针对性的设计。
第二个是持久性,它不仅是对数据库的基本要求。如果仔细琢磨下持久性的定义,就会发现它的核心思想就是要应对系统故障。可以把系统故障分为两种:
第三个是原子性,是数据库区别于其他存储系统的重要标志。在单体数据库时代,原子性问题已经得到妥善解决,但随着向分布式架构的转型,在引入不可靠的网络因素后,原子性又成为一个新的挑战。
最后一个是隔离性,它是事务中最复杂的特性。隔离性分为多个隔离级别,较低的隔离级别就是在正确性上做妥协,将一些异常现象交给应用系统的开发人员去解决,从而获得更好的性能。
可以说,事务模型的发展过程就是在隔离性和性能之间不断地寻找更优的平衡点。甚至可以说事务的核心就是隔离性。而不同产品在事务一致性上的差别,也完全体现在隔离性的实现等级上,所以必须搞清楚隔离等级具体是指什么。
最早、最正式的对隔离级别的定义,是 ANSI SQL-92(简称 SQL-92),它定义的隔离级别和异常现象如下所示:
虽然 SQL-92 得到了广泛应用,不少数据库也都遵照这个标准来命名自己的隔离级别,但它对异常现象的分析还是过于简单了。所以在不久之后的 1995 年,Jim Gray 等人发表了论文 "A Critique of ANSI SQL Isolation Levels"(以下简称 Critique),对于事务隔离性进行了更加深入的分析。
Critique 丰富和细化了 SQL-92 的内容,定义了六种隔离级别和八种异常现象。其中最关注的是快照隔离级别。因为在 SQL-92 中可重复读(Repeatable Read, RR)与可串行化(Serializable)两个隔离级别的主要差别是对幻读(Phantom)的处理。但随着 Critique 的发表,快照隔离被明确提出,这个说法就不适用了,因为快照隔离能解决幻读的问题,但却无法处理写倾斜(Write Skew)问题,也不符合可串行化要求。
因此,今天,使用最广泛的隔离级别有四个,就是已提交读、可重复读、快照隔离、可串行化。
Critique 对幻读的描述大致是这样的,事务 T1 使用特定的查询条件获得一个结果集,事务 T2 插入新的数据,并且这些数据符合 T1 刚刚执行的查询条件。T2 提交成功后,T1 再次执行同样的查询,此时得到的结果集会增大。这种异常现象就是幻读。
幻读与不可重复读都是在一个事务内用相同的条件查询两次,但两次的结果不一样。差异在于,对不可重复读来说,第二次的结果集相对第一次,有些记录被修改(Update)或删除(Delete)了;而幻读是第二次结果集里出现了第一次结果集没有的记录 (Insert)。一个更加形象的说法,幻读是在第一次结果集的记录『间隙』中增加了新的记录。所以,MySQL 将防止出现幻读的锁命名为间隙锁(Gap Lock)。
跟幻读相比,写倾斜 要稍微复杂一点,这里用一个黑白球的例子来说明。
首先,箱子里有三个白球和三个黑球,两个事务(T1,T2)并发修改,不知道对方的存在。T1 要让 6 个球都变成白色;T2 则希望 6 个球都变成黑色。
/* T1: */begin;update ball set color = white where color = black;commit;/* T2: */begin;update ball set color = black where color = color;commit;
最终的执行结果是,盒子里仍然有三个黑球和三个白球。如果还没有发现问题,可以看看下面串行执行的效果图,比较一下有什么不同。
如果先执行 T1 再执行 T2,6 个球都会变成黑色;调换 T1 与 T2 的顺序,则 6 个球都是白色。
根据可串行化的定义,多事务并行执行所得到的结果,与串行执行(一个接一个)完全相同
。比照两张图,很容易发现事务并行执行没有达到串行的同等效果,所以这是一种异常现象。也可以说,写倾斜是一种更不易察觉的更新丢失。
既然『快照隔离』这么重要,为什么会被 SQL-92 漏掉呢?
这是由于 SQL-92 主要考虑了基于锁(Lock-base)的并发控制,而快照隔离的实现基础则是多版本并发控制(MVCC),很可能是由于当时 MVCC 的应用还不普遍。当然,后来,MVCC 成为一项非常重要的技术,一些经典教材会将 MVCC 作为一种独立的选择,与乐观并发控制和悲观并发控制并列。其实,在现代数据库中 MVCC 已经成为一种底层技术,用于更高效地实现乐观或悲观并发控制。有了 MVCC 这个基础,快照隔离就成为一个普遍存在的隔离级别了。
总的来说,分布式数据库大多可以分为两种架构风格,一种是 NewSQL,它的代表系统是 Google Spanner;另一种是从单体数据库中间件基础上演进出来的,被称为 Prxoy 风格,没有公认的代表系统。Prxoy 这个名字太笼统,没有反映架构的全貌,要有一个具体的架构模板来指代这种风格,这里选择 PostgreSQL-XC(PGXC)。
要搞清楚分布式数据库的架构风格,就要先了解『数据库』的架构。这里说的数据库仍然默认是关系型数据库。以下是一张数据库的架构图:
这张图从约瑟夫 · 海勒斯坦 (Joseph M. Hellerstein) 等人的论文 "Architecture of a Database System" 中翻译而来。文中将数据库从逻辑上拆分为 5 个部分:
单体数据库的功能看似已经很完善了,但在面临高并发场景的时候,还是会碰到写入性能不足的问题,很难解决。因此,也就有了向分布式数据库演进的动力。要解决写入性能不足的问题,大家首先想到的,最简单直接的办法就是分库分表。
分库分表方案就是在多个单体数据库之前增加代理节点,本质上是增加了 SQL 路由功能。这样,代理节点首先解析客户端请求,再根据数据的分布情况,将请求转发到对应的单体数据库。
代理节点需要实现三个主要功能,它们分别是客户端接入、简单的查询处理器和进程管理中的访问控制。
另外,分库分表方案还有一个重要的功能,那就是分片信息管理,分片信息就是数据分布情况,是区别于编目数据的一种元数据。不过考虑到分片信息也存在多副本的一致性的问题,大多数情况下它会独立出来。
显然,如果把每一次的事务写入都限制在一个单体数据库内,业务场景就会很受局限。因此,跨库事务成为必不可少的功能,但是单体数据库是不感知这个事情的,所以要在代理节点增加分布式事务组件。
同时,简单的分库分表不能满足全局性的查询需求,因为每个数据节点只能看到一部分数据,有些查询运算是无法处理的,比如排序、多表关联等。所以,代理节点要增强查询计算能力,支持跨多个单体数据库的查询。
随着分布式事务和跨节点查询等功能的加入,代理节点已经不再只是简单的路由功能,更多时候会被称为协调节点。
协调节点与数据节点,实现了一定程度上的计算与存储分离,这也是所有分布式数据库的一个架构基调。但是,因为 PGXC 的数据节点本身就是完整的单体数据库,所以也具备很强的计算能力。
相对于 PGXC,NewSQL 有着完全不同的发展路线。NewSQL 也叫原生分布式数据库,它的每个组件在设计之初都是基于分布式架构的,不像 PGXC 那样带有明显的单体架构痕迹。
NewSQL 的基础是 NoSQL,更具体地说,是类似 BigTable 的分布式键值(K / V)系统。分布式键值系统选择做了一个减法,完全放弃了数据库事务处理能力,然后将重点放在对存储和写入能力的扩展上,这个能力扩展的基础就是分片。引入分片的另一个好处是,系统能够以更小的粒度调度数据,实现各节点上的存储平衡和访问负载平衡。
分布式键值系统由于具备这些鲜明的特点,所以在不少细分场景获得了成功(比如电商网站对于商品信息的存储),但在面对大量的事务处理场景时就无能为力了(比如支付系统)。这种状况直到 Google Spanner 横空出世才被改变,因为 Spanner 基于 BigTable 构建了新的事务能力。
除了上述内容,NewSQL 还有两个重要的革新,分别出现在高可靠机制和存储引擎的设计上。
高可靠机制的变化在于,放弃了粒度更大的主从复制,转而以分片为单位采用 Paxos 或 Raft 等共识算法。这样,NewSQL 就实现了更小粒度的高可靠单元,获得了更高的系统整体可靠性。存储引擎层面,则是使用 LSM-Tree 模型替换 B+ Tree 模型,大幅提升了写入性能。
从系统架构上看,NewSQL 的设计思想更加领先,具有里程碑意义,而 PGXC 的架构偏于保守。但 PGXC 的优势则在于稳健,直接采用单机数据库作为数据节点,大幅降低了工程开发的工作量,也减少了引入风险的机会。总的来说,NewSQL 的长处在架构设计,PGXC 的长处则在工程实现。
加餐:预写日志(WAL)写成功,但是数据表写失败,要怎么处理?
事实上,对大多数的数据库来说,实时写入数据时,并不是真的将数据写入数据表在磁盘中的对应文件里,因为数据表的组织形式复杂,不像 WAL 那样只是在文件尾部追加,所以 I / O 操作的延迟太长。因此,写入过程往往是这样的:记录 WAL 日志,同时将数据写入内存,两者都成功就返回客户端了。这些内存中的数据,在 Oracle 和 MySQL 中都被称为脏页,达到一定比例时会批量写入磁盘。而 NewSQL 所采用的 LSM-Tree 存储模型也是大致的思路,只不过在磁盘的数据组织上不同。写入内存和 WAL 这两个操作构成了一个事务,必须一起成功或失败。
Jim Gray 等:A Critique of ANSI SQL Isolation Levels
Joseph M. Hellerstein et al.:Architecture of a Database System
加西亚 - 莫利纳 等:《数据库系统实现》
scroll-view 组件是为了特定的滚动场景设计的。与 movable-view、movable-area、cover-view 等组件一样都是为了开发者实现特定场景下的业务功能而设计开发的。
scroll-view 的滚动属性主要实现了两套功能:左右或上下滚动、下拉更新。
与滚动有关的属性有以下几个:
white-space: nowrap; display: inline-block;
。如果同时开启横向、纵向两个方向的滚动,当通过 scroll-into-view 滚动时,滚动行为变化有两种:如果不加 scroll-with-animation 属性,也就是不开启动画,可以同时在 x、y 两个方向上瞬时移动到目标位置;如果开启动画,同一时间只能在一个方向上滚动,有时在 x 轴滚动,有时在 y 轴滚动。所以 scroll-x 和 scroll-y 最好不要同时开启。示例代码如下:
index.wxml:
<view class="page-section"><view class="page-section-spacing"><scroll-view enable-flex scroll-into-view="{{scrollIntoViewId}}" bindscroll="onScroll" scroll-y scroll-x scroll-with-animation="{{false}}" style="width: 100%;height:300rpx;"><view id="childview{{item}}0" wx:for="{{[1, 2, 3, 4, 5, 6, 7, 8, 9,10]}}" class="scroll-row">{{item}}<view wx:for="{{[0, 1, 2, 3, 4, 5,6,7,8,9]}}" class="scroll-item" id="childview{{item}}{{item2}}" wx:for-item="item2">{{item}}:{{item2}}</view></view></scroll-view></view><view class="btn-area"><button type="primary" bindtap="scrollToView1">滚动到子组件2</button></view></view>
index.js:
let viewId = 5Page({ scrollToView1(){ viewId += 2 this.setData({ scrollIntoViewId:'childview'+viewId }) console.log(this.data.scrollIntoViewId) },onScroll(e){ console.log(e.detail.scrollTop, e.detail.scrollLeft, e.detail.scrollHeight,e.detail.scrollWidth) },})
下拉更新的属性主要有以下几个:
最佳实践为以下步骤:
overflow-anchor:auto
的样式,用于解决 Android 机型不兼容的问题。white-space: nowrap; display: inline-block;
display: flex;
样式,如果是我们自己添加,是加在了外围的容器上,只有通过这个属性才能加到内部真正的容器上。微信在 WeUI 扩展组件库中给出了一个长列表组件:recycle-view,用于渲染无限长的列表。
通过监听 scroll 事件,只渲染当前视图窗口内的 list 列表,看不见的地方用空白的占位符代替。
在使用 recycle-view 扩展组件的时候,batch 属性的值必须为 batchSetRecycleData。
在 JavaScript 代码中,调用 createRecycleContext 时传入的 dataKey 是 recycleList。这个名称必须与 WXML 中的 wx:for 指定的数据名称一致。如果一个页面中还使用了另外一个长列表,则需要再换一个名字。
使用方法如下:
1、安装组件。安装完后在菜单栏的工具里,点击『构建 npm』。
npm install --save miniprogram-recycle-view
2、在页面的 json 配置文件中添加 recycle-view 和 recycle-item 自定义组件的配置。
{ "usingComponents": { "recycle-view": "miniprogram-recycle-view/recycle-view", "recycle-item": "miniprogram-recycle-view/recycle-item" }}
3、index.wxml:
<view class="page-section"> <recycle-view height="200" batch="{{batchSetRecycleData}}" id="recycleId" batch-key="batchSetRecycleData" style="background:white;"> <recycle-item wx:for="{{recycleList}}" wx:key="index" class='item'> <view> {{item.id}}: {{item.name}} </view> </recycle-item> </recycle-view></view>
4、index.js:
const createRecycleContext = require('miniprogram-recycle-view')function rpx2px(rpx) { return (rpx / 750) * wx.getSystemInfoSync().windowWidth}Page({ onReady: function () { var ctx = createRecycleContext({ id: 'recycleId', dataKey: 'recycleList', page: this, itemSize: { width: rpx2px(650), height: rpx2px(100) } }) let newList = [] for (let i = 0; i < 20; i++) { newList.push({ id: i, name: `标题${i + 1}` }) } ctx.append(newList) const arr = [] for (let i = 0; i < 20; i++) arr.push(i) this.setData({ arr }) setTimeout(() => { this.setData({ triggered: true, }) }, 1000) // let activeTab = 0, page=1, res = {something:''} let tabsData = this.data.tabs[activeTab] || {list:[]} tabsData.page = page+1 tabsData.list.push(res) let key = `tabs[${activeTab}]` this.setData({ [key]: tabsData }) console.log(this.data.tabs) },})
主要实现两个功能:1、单击左侧菜单,右侧区域自动滚动到相应的位置;2、在右侧滚动的时候,左侧菜单自动同步选择并高亮显示。
第一个功能点通过 scroll-into-view 属性去实现。
<!-- 左侧菜单 --><scroll-view scroll-y class="nav"> <view wx:for='{{list}}' wx:key='{{item.id}}' id='{{item.id}}' class='navList{{currentIndex==index?"active":""}}' bindtap="menuListOnClick" data-index='{{index}}'>{{item.name}}</view></scroll-view><!-- 右侧菜单 --><scroll-view scroll-y scroll-into-view='{{activeViewId}}' bindscroll='scrollFunc'> <view class="fishList" wx:for='{{content}}' id='{{item.id}}' wx:key='{{item.id}}'> <p>{{item.name}}</p> </view></scroll-view>
scroll-into-view 绑定了一个 activeViewId 变量,需要与左侧菜单中的 id 对应起来。在点击左侧每一个具体菜单的时候,bindtap 绑定的 JavaScript 函数中需要将 activeViewId 指定为当前点击的这个 item.id。
// 点击左侧菜单menuListOnClick:function(e){ let me= this; me.setData({ activeViewId: e.target.id, currentIndex: e.target.dataset.index })}
e 是事件对象,取到 target.id,赋值给 activeViewId,设置完后功能就实现了。
第二个功能点是在右侧滚动的时候,左侧菜单同时自动去选择并带高亮。
在右侧滚动的时候,将 bind 的 scroll 事件绑定到 scrollFunc 的 JavaScript 函数上面。
// 滚动时触发,计算当前滚动到的位置对应的菜单是哪个scrollFunc:function(e){ this.setData({ scrollTop: e.detail.scrollTop }) for(let i= 0; i< this.data.heightList.height; i++){ let height1= this.data.heightList[i]; let height2= this.data.heightList[i+ 1]; if(!height2|| (e.detail.scrollTop>= height1 && e.detail.scrollTop < height2)){ this.setData({ currentIndex: 1 }) return; } } this.setData({ currentIndex: 0 })}
WeUI 组件库中有一个 vtabs 组件,是一个有侧边栏分类的商品浏览组件。
vtabs 是一个选项卡组件,在使用这个组件的时候,要把它和 vtabs-content 结合起来进行实现。其中 vtabs-content 是 vtabs 的一个子组件。
1、安装组件。
npm i @miniprogram-component-plus/vtabs --save
npm i @miniprogram-component-plus/vtabs-content --save
安装完后在菜单栏的工具里,构建 npm。
2、在页面的 json 配置文件中添加 recycle-view 和 recycle-item 自定义组件的配置。
{ "usingComponents": { "mp-vtabs": "@miniprogram-component-plus/vtabs/index", "mp-vtabs-content": "@miniprogram-component-plus/vtabs/index" }}
3、index.wxml:
<mp-vtabs vtabs="{{vtabs}}" activeTab="{{activeTab}}" bindtabclick="onTabCLick" bindchange="onChange" class="test" > <block wx:for="{{vtabs}}" wx:key="title" > <mp-vtabs-content tabIndex="{{index}}"> <view class="vtabs-content-item">我是第{{index + 1}}项: {{item.title}}</view> </mp-vtabs-content> </block></mp-vtabs>
4、index.js:
Page({ data: { vtabs: [], activeTab: 0, }, onLoad() { const titles = ['热搜推荐', '手机数码', '家用电器', '生鲜果蔬', '酒水饮料', '生活美食', '美妆护肤', '个护清洁', '女装内衣', '男装内衣', '鞋靴箱包', '运动户外', '生活充值', '母婴童装', '玩具乐器', '家居建材', '计生情趣', '医药保健', '时尚钟表', '珠宝饰品', '礼品鲜花', '图书音像', '房产', '电脑办公'] const vtabs = titles.map(item => ({title: item})) this.setData({vtabs}) }, onTabCLick(e) { const index = e.detail.index console.log('tabClick', index) }, onChange(e) { const index = e.detail.index console.log('change', index) }})
5、index.wxss:
.vtabs-content-item { width: 100%; height: 300px; box-sizing: border-box; border-bottom: 1px solid #ccc; padding-bottom: 20px;}
picker 本身有一个模式是 region,是省市区三级联动的。这个默认组件在某些特定场景下,不能满足我们的样式需求。另外不一定是省市区,还有像基于其它数据源的选择器,也可以自定义实现,样式也可以自如控制。
index.wxml:
<view class="section"> <view class="section__title">省市区选择器</view> <picker mode="region" bindchange="bindRegionChange" value="{{region}}" custom-item="{{customItem}}"> <view class="picker"> 当前选择:{{region[0]}},{{region[1]}},{{region[2]}} </view> </picker></view>
pick-view 基于子组件 picker-view-column 这种松耦合的架构实现的,本身没有数据源。它的所有数据都是在 picker-view-column 这个子组件里面,由开发者通过 wx:for 循环去绑定。
picker 是底部滑出的,picker-view 是页面嵌入的,为了实现底部滑出效果,可以把 picker-view 放在滑出的面板上。至于嵌入组件的蒙层的样式效果,可以通过 mask-style 或者 mask-class 控制,这两个属性是专门用于控制蒙层效果的。
region-picker-view 目前有两个问题。
一是最好不要在它的 change 事件里面去改变视图,因为在滑动的时候可能会涉及到连续的多次的滑动,在用户还没有选到目标值之前,可能会涉及到多次的 change 事件派发。最好是在用户选择结束之后,例如在 touchend 事件中,再去判断有没有变化。如果有变化,再去改变数据源。
二是通过测试发现关于 picker 组件当 mode 为 multiSelector 时,当手指滑动时,它的 columnchange 事件会有多次派发。picker 组件的 change 事件是在单击『确定』按钮之后才派发的,而对于 picker-view 组件,在滑动选择的过程中,change 事件是不派发的,change 事件只在选定之后派发了一次。在 picker-view 组件中,还有 pickerstart 和 pickend 事件,分别代表滚动选择的开始和结束,这就没必要从 touchend 事件中自己做状态的判断了。可以从 change 事件中先拿到 value,然后在 pickend 事件里,在选择结束的时候再去作这个逻辑的处理。
从以上两点看,picker-view 组件的涉及是优于 picker 组件的,picker 组件的功能,使用 picker-view 也是可以完全实现的;关于交互的操作,最好是写在 WXS 模块里面,而不是在 JavaScript 里面。减少视图层与逻辑层之间的通讯,可以显著提高界面的流畅性。
region-picker-view2 就是使用 WXS 脚本,将 region-picker-view 自定义组件改写。
index.wxml:
<!-- 自定义选择器 --><view class="page__section"> <view class="page__section-title">两个自定义实现选择器</view> <!-- js滚动选择器 --> <region-picker-view bindchange="onRegionChange"></region-picker-view> <!-- wxs滚动选择器 --> <region-picker-view2 bindchange="onRegionChange"></region-picker-view2></view>
index.json:
{ "usingComponents": { "region-picker-view": "/components/region-picker-view/index", "region-picker-view2": "/components/region-picker-view2/index" }}
一个 ComponentDescriptor 组件描述对象有以下几个方法:
#
开头的组件 id,也可以是以 .
开头的样式类名称。index.wxml:
<view class="section section_gap"> <text class="section__title">设置最小 / 最大值,步进为 5</text> <view class="body-view"> <slider bindchange="slider4change" bindchanging="onSliderChanging" min="50" max="200" show-value step="5" /> </view></view>
index.js
var pageData = {}for (var i = 1; i < 5; ++i) { (function (index) { pageData[`slider${index}change`] = function (e) { console.log(`slider${index}发生change事件,携带值为`, e.detail.value) } })(i);}Page(Object.assign({ data:{}, onSliderChanging(e){ console.log(e.type, e.detail.value); },}, pageData))
自定义纵向 slider 采用 WXS 脚本编写。
竖向 slider 是以底部为起点的,滑动到底部为 min 值,滑动到顶部为 max 值,整个 slider 分成上、中、下三部分:灰色的竖条、白色的滑块、绿色的竖条。中间滑块 slider-middle 是不占用大小的,宽高都是 0,它的子组件 slider-block 是有大小的,并且它的 postion 样式是 relative。它是以相对定位的方式挂在中间位置的,也就是在 slider-middle 里。
index.wxml:
<view class="section section_gap"> <text class="section__title">自定义竖向slider</text> <view class="body-view"> <view style="height: 400rpx;margin: 20px;display: flex;justify-content: space-around"> <slider-vertical block-color="#ffffff" block-size="28" backgroundColor="#e9e9e9" activeColor="#1aad19" bindchange="slider1change" bindchanging="slider1changing" step="1" min="50" max="200" value="0" disabled="{{false}}" show-value="{{true}}"></slider-vertical> <slider-vertical block-color="#ffffff" block-size="28" backgroundColor="#e9e9e9" activeColor="#1aad19" bindchange="slider1change" bindchanging="slider1changing" step="5" min="50" max="200" value="115" disabled="{{false}}" show-value="{{false}}"></slider-vertical> </view> </view></view>
index.js:
var pageData = {}for (var i = 1; i < 5; ++i) { (function (index) { pageData[`slider${index}change`] = function (e) { console.log(`slider${index}发生change事件,携带值为`, e.detail.value) } })(i);}Page(Object.assign({ data:{}, slider1change: function (e) { console.log("change:",e) }, slider1changing: function (e) { console.log("changing:",e) }}, pageData))
index.json:
{ "usingComponents": { "slider-vertical": "/components/vertical-slider/index" }, "navigationStyle": "custom", "navigationBarTitleText": "自定义导航标题"}
小程序的导航组件有两个:functional-page-navigator 和 navigator。
前者是在插件中使用的,仅能跳转到插件的功能页。后者是小程序标准的导航组件,可以通过设置不同的跳转方式,实现不同的跳转功能。
open-type 一共有 6 个合法值:
在小程序页面的 json 配置文件中,设置 navigationStyle 为 custom,开启页面导航栏的自定义。在开启以后,系统状态栏也会透明。
index.wxml:
<navigation-bar ext-class="page-navigator-bar" active="{{active}}" loading="{{loading}}"> <view class="left" slot="left"> <icon bindtap="goBack" class="iconfont icon-back"></icon> <icon bindtap="goHome" class="iconfont icon-home"></icon> </view> <view slot="center"> <view>自定义导航标题</view> </view></navigation-bar><view style="width:100%;height:400px;"></view><image class="top-banner" src="https://qiniu-image.qtshe.com/1557133211411_684.jpg" mode="widthFix" /><view class="operate-wraper" style="background-color:#f2f2f2;--topBarHeight:{{topBarHeight}}px;"></view>
index.js:
Page({ data: { loading: false, active: true }, //点击back事件处理 goBack: function () { wx.navigateBack(); this.triggerEvent('back'); }, //返回首页 goHome:function(){ wx.reLaunch({ url: '/pages/index/index' }) }, onPageScroll(res) { console.log(res); if (res.scrollTop > 400) { if (!this.data.active) { this.setData({ active: true }) } } else { if (this.data.active) { this.setData({ active: false }) } } }})
index.wxss:
@font-face { font-family: 'iconfont'; /* Project id 2503355 */ src: url('//at.alicdn.com/t/font_2503355_3o3ks3b2xfn.woff2?t=1620132700001') format('woff2'), url('//at.alicdn.com/t/font_2503355_3o3ks3b2xfn.woff?t=1620132700001') format('woff'), url('//at.alicdn.com/t/font_2503355_3o3ks3b2xfn.ttf?t=1620132700001') format('truetype');}.iconfont{ font-family: 'iconfont';}.icon-back::after{ content: '\e67c'; font-size: 22px;}.icon-home::after{ content: '\e64e'; font-size: 22px;}.left icon:last-child{ padding-left: 20rpx;}.page-navigator-bar .navigator-normal .icon-back{ color: white;}.page-navigator-bar .navigator-normal .icon-home{ color: white;}.page-navigator-bar .navigator-active .icon-back{ color: black;}.page-navigator-bar .navigator-active .icon-home{ color: black;}
index.json:
{ "usingComponents": { "navigation-bar":"/components/navigation-bar/index" }, "navigationStyle": "custom"}
返回按钮和主页按钮是在外面定义的,它们的切换是通过 ext-class 扩展样式属性实现的。设置这个属性,是为了在外面可以控制内部组件的样式。只要自定义组件使用插槽,基本上这个机制就是需要的。
image 组件是最常使用的媒体组件之一,这个组件本身有一个 lazy-load 属性,该属性已经实现了图片的懒加载功能。下面是一些 image 组件的技术问题:
Webp 介绍:是 image 组件的布尔属性,开启这个属性,代表 url 可以设置 Webp 这种格式的图片。Webp 是一种同时提供了有损压缩与无损压缩,并且是可逆的图片压缩的这种文件格式。image 组件默认不解析这种图片格式。
Webp 优势:它具有更优的图像数据压缩算法,能带来更小的图片体积,并且拥有肉眼识别无差异的图像质量。同时提供了无损及有损的两种图片压缩模式,还提供 alpha 透明,以及动画的特性,对 JPEG 和 PNG 等这些图片格式的转化都有支持。Webp 既可以替代 JPEG、PNG 这些静态的图片,也可以替代 GIF 这种动态的图片。
show-menu-by-longpress 属性:是图片组件的一个布尔属性,开启这个属性,就是开启了长按图片,显示识别小程序码的一个菜单。这种识别仅是在 wx.previewImage 接口调用,开始预览图片的时候,才可以识别。
mina-lazy-image 主要实现原理是使用 wx.createIntersectionObserver 接口,创建 IntersectionObserver 实例,用这个实例去判断图片是否出现在用户的视图窗口中。如果出现了,再进行加载。
IntersectionObserver 主要用于推断某些组件节点是否可以被用户看见、有多大比例可以被用户看见。一共有下面四个方法:
npm install --save mina-lazy-image
index.wxml:
<view class="page-head"> <text class="page-head__title">slider</text> <text class="page-head__desc">滑块</text></view><view class="page-section"> <text class="page-section__title">use image</text> <scroll-view class="cardbox"> <button wx:if="{{item.live.play_urls}}" class="card" hover-class='none' wx:for="{{content}}" wx:key="*this" bindtap="gotoLive" data-url="{{item.live.play_urls.hdl.ORIGIN}}" data-ava="{{item.live.user_info.avatar}}" data-name="{{item.live.user_info.name}}" data-audience="{{item.live.audience_num}}" data-lid="{{item.live.id}}" data-cacheprepic="{{item.live.pic}}" data-prepic="{{item.live.pic_320}}" data-share_desc="{{item.live.share_info.wechat_contact.cn.text}}" style="position: relative;"> <view class="image_card"> <image class="showpic" mode="aspectFill" src="{{item.live.pic_320}}" lazy-load="{{true}}" /> <view class="cover" /> <text class="audience">{{item.live.audience_num}}观众</text> </view> <view class="user_card" catchtap="gotoHome" data-uid="{{item.live.user_info.id}}"> <view class="avabox"> <image src="{{item.live.user_info.avatar}}" lazy-load="{{true}}" class="ava" data-uid="{{item.live.user_info.id}}" /> <image class="vip" wx:if="{{item.live.vip}}" lazy-load="{{true}}" src="http://img08.oneniceapp.com/upload/resource/9e7ca7ece11143b49fc952cfb2520e43.png" /> </view> <text class="user_name">{{item.live.user_info.name}}</text> </view> </button> <button wx:if="{{item.live.playback_urls}}" class="card" open-type='getUserInfo' bindtap="gotoPlayback" wx:for="{{content}}" data-url="{{item.live.playback_urls.hls.ORIGIN}}" wx:key="*this" > <view class="image_card"> <image className="showpic" mode="aspectFill" src="{{item.live.pic_320}}" lazy-load="{{true}}" /> <view class="cover" /> <text class="audience">{{item.live.audience_num}}观众</text> <image class="back" lazy-load="{{true}}" src="http://img08.oneniceapp.com/upload/resource/002bdceaa732f300e33ab8b2cb84dd17.png" /> </view> <view class="user_card"> <view class="avabox"> <image src="{{item.live.user_info.avatar}}" class="ava" lazy-load="{{true}}" /> <image class="vip" wx:if="{{item.live.vip}}" lazy-load="{{true}}" src="http://img08.oneniceapp.com/upload/resource/9e7ca7ece11143b49fc952cfb2520e43.png" /> </view> <text class="user_name">{{item.live.user_info.name}}</text> </view> </button> </scroll-view></view><view class="page-section"> <text class="page-section__title">use mina-lazy-image</text> <scroll-view class="cardbox"> <button wx:if="{{item.live.play_urls}}" class="card" hover-class='none' wx:for="{{content}}" wx:key="*this" bindtap="gotoLive" data-url="{{item.live.play_urls.hdl.ORIGIN}}" data-ava="{{item.live.user_info.avatar}}" data-name="{{item.live.user_info.name}}" data-audience="{{item.live.audience_num}}" data-lid="{{item.live.id}}" data-cacheprepic="{{item.live.pic}}" data-prepic="{{item.live.pic_320}}" data-share_desc="{{item.live.share_info.wechat_contact.cn.text}}" style="position: relative;"> <view class="image_card"> <mina-lazy-image mode="aspectFill" src="{{item.live.pic_320}}" /> <view class="cover" /> <text class="audience">{{item.live.audience_num}}观众</text> </view> <view class="user_card" catchtap="gotoHome" data-uid="{{item.live.user_info.id}}"> <view class="avabox"> <mina-lazy-image src="{{item.live.user_info.avatar}}" data-uid="{{item.live.user_info.id}}" /> <image class="vip" wx:if="{{item.live.vip}}" lazy-load="{{true}}" src="http://img08.oneniceapp.com/upload/resource/9e7ca7ece11143b49fc952cfb2520e43.png" /> </view> <text class="user_name">{{item.live.user_info.name}}</text> </view> </button> <button wx:if="{{item.live.playback_urls}}" class="card" open-type='getUserInfo' bindtap="gotoPlayback" wx:for="{{content}}" data-url="{{item.live.playback_urls.hls.ORIGIN}}" wx:key="*this" > <view class="image_card"> <mina-lazy-image mode="aspectFill" src="{{item.live.pic_320}}" /> <view class="cover" /> <text class="audience">{{item.live.audience_num}}观众</text> <image class="back" lazy-load="{{true}}" src="http://img08.oneniceapp.com/upload/resource/002bdceaa732f300e33ab8b2cb84dd17.png" /> </view> <view class="user_card"> <view class="avabox"> <mina-lazy-image src="{{item.live.user_info.avatar}}" /> <image class="vip" wx:if="{{item.live.vip}}" lazy-load="{{true}}" src="http://img08.oneniceapp.com/upload/resource/9e7ca7ece11143b49fc952cfb2520e43.png" /> </view> <text class="user_name">{{item.live.user_info.name}}</text> </view> </button> </scroll-view></view><view class="page-section"> <text class="page-section__title">设置step</text> <image bindtap="previewImage" data-url="http://t.cn/A622upBw" show-menu-by-longpress src="http://t.cn/A622upBw" mode="widthFit"></image></view>
index.js:
const app = getApp()Page({ /** * 页面的初始数据 */ data: { }, onLoad: function () { wx.request({ url: 'https://wxapi.kkgoo.cn/live/discover?type=hot', method:'POST', success:(res) => { this.setDis(res); } }) }, setDis(r) { let newData = r.data.data; this.data.nextKey = newData.nextkey ? newData.nextkey : this.data.nextKey; this.setData({ content: newData.discover ? newData.discover : this.data.content, banneritem: newData.cards ? newData.cards.slice(0, newData.cards.length - 1) : this.data.banneritem }) }, previewImage(e){ console.log(e); let url = e.currentTarget.dataset.url wx.previewImage({ current:url, urls: [url], }) }})
index.wxss:
/* miniprogram/pages/2.14/index.wxss */.lazy-image{}/* 用户列表相关样式 */.main{ font-size:0; width:100%; height: 100%; font-family: 'PingFangSC-Semibold';}.title{ text-align:center; font-size: 0;}.u_title{ display: inline-block; width:100%; font-size: 24rpx; line-height: 24rpx; margin:20rpx 0; font-weight: bold;}.d_title{ display: inline-block; width:100%; line-height: 22rpx; font-size: 22rpx;}.cardbox{ width: 100%; font-size: 0; box-sizing: border-box; padding: 0 32rpx; /*margin-top:60rpx;*/ display: inline-block;}button::after{ border: none}button{ width: auto !important; padding-left: 0 !important; padding-right: 0 !important; background-color: #fff;}.card{ display: inline-block; float:left; /* margin-top:60rpx; */}.card .image_card{ width: 268rpx; height: 268rpx; border-radius: 8rpx; position: relative;}.cover{ position: absolute; /* width: 327rpx; height: 327rpx; */ top: 0; left: 0; background-color: rgba(0,0,0,0.3); z-index: 99; border-radius: 8rpx;}.card .image_card .audience{ font-size: 22rpx; color:#fff; position: absolute; left:16rpx; top:16rpx; z-index:999; font-weight: bold;}.card .image_card .back{ position: absolute; right:16rpx; top:16rpx; width: 56rpx; height: 32rpx; z-index: 999;}.card .user_card{ margin-top: 20rpx; margin-bottom: 20rpx; float:left; margin-right: 15rpx;}.card .user_card .avabox{ width:48rpx; height: 48rpx; margin-right: 15rpx; position: relative; display: inline-block; vertical-align: middle;}.card .user_card .avabox .ava{ width: 100%; height: 100%; border-radius: 8rpx; vertical-align: top}.card .user_card .avabox .vip{ position: absolute; width: 32rpx; height: 32rpx; bottom:-5rpx; right:-5rpx; border-radius: 50%; background: red;}.card .user_name { width: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-block; font-size: 24rpx; text-align: start; display: inline-block; vertical-align: middle; font-weight: bold;}.card:nth-child(odd){ margin-right:32rpx;}.showpic{ width: 100%; height: 100%; border-radius: 8rpx; overflow: hidden;}.scroll-end{ float: left; height: 50rpx; width: 100%; color: #999; line-height: 50rpx; font-size: 28rpx; text-align: center;}
index.json:
{ "usingComponents": { "mina-lazy-image": "mina-lazy-image/index" }}
image 组件拉取图片的本质是使用 wx.downloadFile 接口加载图片的资源,当加载以后,把加载的图像再绘制出来。很多时候由于图片的格式不规范,例如线上的 SSL 证书有问题,或者是文件的描述信息,例如 content-type、length 等信息不标准不完整,还有可能是由于这个服务器发生了 302 跳转等等原因导致图片拉取不成功,看到的现象就是这个图片没有显示出来。有时候网络不好,加载超时,图片也不会显示。
对于网络不好的情况,可以用 image 组件的 binderror 事件属性处理,监听 error 事件。当监听到错误以后,重新给 src 属性赋值,一般通过这种方法可以解决。
因为机型不同,尺寸也不一样。image 组件有一个关于缩放的属性 mode,经常使用的值有三个:scaleToFill、aspectFit、aspectFill。
scaleToFill:不保持纵横比例缩放图片,使图片的宽高完全拉伸填充整个 image 元素。
aspectFit:保持纵横比例去缩放图片,使图片的长边可以完全显示出来,可以完整的将图片显示出来,不会对图片有任何的裁剪。
aspectFill:保持纵横比例缩放图片,并且只保证图片的短边可以完全显示出来。图片同时只能在一个方向水平或垂直方向是完整的,在另外一个方向上将会发生裁剪。
由于 image 在加载图片的时候会有一些缺陷,所以实现背景图片适配所有机型这个问题,最好不要使用 mode 属性去实现,最好使用 WXSS 样式来实现。
.container{ position: fixed; width: 100%; height: 100%; background-color: azure; top: 0; bottom: 0; left: 0; right: 0; z-index: -1;}.container::after{ content: ""; background: url(https://caroly.site/caroly_img/86009625_p0_1607479719466.png) no-repeat center center; background-size: cover; opacity: 0.5; top: 0; bottom: 0; left: 0; right: 0; position: absolute;}
要用一个尽量覆盖所有屏幕比例的大小来做这个背景图片。一般情况下是新建一个 750*1334 这样一个大小的背景图片,并且把分辨率设置为 72,用这样的尺寸做背景图片。
通过 image-cropper 组件进行裁剪,它可以让图片通过拖拽的方式,选择范围可以任意裁剪。
实现原理:通过四角的一个控制点去控制选择的范围,四角的控制点是通过 view 渲染出来的,图片加载完成以后绘制到 canvas 画布上,选定裁剪范围,再通过 wx.canvasToTempFilePath 接口生成一个临时的图片,临时图片就是裁剪的结果。
index.wxml:
<view class="page-section"> <text class="page-section__title">图片裁剪</text> <image-cropper id="image-cropper" limit_move="{{true}}" disable_rotate="{{true}}" width="{{width}}" height="{{height}}" imgSrc="{{src}}" bindload="cropperload" bindimageload="loadimage" bindtapcut="clickcut"></image-cropper></view>
index.js:
Page({/** * 页面的初始数据 */ data: { src: '', width: 250, //宽度 height: 250, //高度 }, startCuting() { //获取到image-cropper对象 this.cropper = this.selectComponent("#image-cropper"); //开始裁剪 this.setData({ src: "https://cdn.nlark.com/yuque/0/2020/jpeg/1252071/1590847767698-f511e86d-f183-4f75-a04d-1b99cd9f0bd7.jpeg", }); wx.showLoading({ title: '加载中' }) },/** * 生命周期函数--监听页面加载 */ onLoad: function (options) { this.startCuting() }, cropperload(e) { console.log("cropper初始化完成"); }, loadimage(e) { console.log("图片加载完成", e.detail); wx.hideLoading(); //重置图片角度、缩放、位置 this.cropper.imgReset(); }, clickcut(e) { console.log(e.detail); console.log(e.detail.url) //点击裁剪框阅览图片 wx.previewImage({ current: e.detail.url, // 当前显示图片的http链接 urls: [e.detail.url] // 需要预览的图片http链接列表 }) }})
index.json:
{ "usingComponents": { "image-cropper": "../../../components/image-cropper/index" }}
自定义图标有多种方法,比如使用图片、精灵图、CSS 样式绘制、矢量字体、SVG 矢量文件等方法。这里推荐使用矢量字体自定义小程序的 icon 组件图标。
字体类型有两类:点阵字体 和 矢量字体。目前使用最广泛的是 矢量字体。
矢量字体大概分三类:Adobe 的 Type1、Apple 和 Microsoft 主导的 TrueType、Adobe 和 Apple 以及 Microsoft 主导的开源字体 OpenType。在矢量字体里,每一个 Unicode 仅是编码的索引,每个字符描述信息是一个几何矢量绘图描述信息。以 Type1 为例,它使用三次贝塞尔曲线来绘制字形,TrueType 则使用二次贝塞尔曲线描述字形。由于矢量字体是绘制出来的,所以它可以实时填充任何颜色,可以无级缩放而没有锯齿。
阿里巴巴矢量图标库 不仅提供常用图标下载,还提供自定义矢量图标字体的生成与下载。我们可以在这个网站上搜索自己想要的图标,在线编辑,并下载样式文件,然后在小程序里使用。
打开 阿里巴巴矢量图标库 ,首页如下:
随意选择一个图标集,比如第一个:
随意选择一个图标加入购物车,比如第一行第四个:
此时右上角的购物车的红色角标变为 1,点一下购物车按钮。
点击『添加至项目』,会弹出以下界面。如果有项目就选择一个;如果没有就新建一个。这里选择 Test 项目。
加入项目后,网页会自动跳转到项目详情界面。此时中间代码区域为灰色。
点击界面中间红色字体『下方新 icon 来袭,点击更新代码,更新后将支持 WOFF2 格式』。
复制代码,粘贴到 WXSS 中,编写自定义的 iconfont 样式,最后在 icon 组件中引用即可。其代码如下:
@font-face { font-family: 'iconfont'; /* project id 2503355 */ src: url('//at.alicdn.com/t/font_2503355_mfxf6hykc88.eot'); src: url('//at.alicdn.com/t/font_2503355_mfxf6hykc88.eot?#iefix') format('embedded-opentype'), url('//at.alicdn.com/t/font_2503355_mfxf6hykc88.woff2') format('woff2'), url('//at.alicdn.com/t/font_2503355_mfxf6hykc88.woff') format('woff'), url('//at.alicdn.com/t/font_2503355_mfxf6hykc88.ttf') format('truetype'), url('//at.alicdn.com/t/font_2503355_mfxf6hykc88.svg#iconfont') format('svg');}.iconfont{ font-family: 'iconfont'; color: red; font-size: 50px;}.icon-hhh::before{ content: '\e7ed';}
需要注意的是,在 iconfont 中复制的 font-face 代码格式需要转换,将 &#x
转为 \
。
WXML 代码如下:
<icon class="iconfont icon-hhh"></icon>
运行效果如下:
如果这个图标不满足我们的需求,可以对这个矢量图标进行修改。
在 阿里巴巴矢量图标库 中将我们要编辑的图标下载到本地,选择『SVG 下载』。
打开 Photoshop 编辑工具 ,选择右上角的『返回旧版』。点击左上角的『文件』,打开刚下载的 SVG 图标。
点击图层,可以修改图标的颜色样式,可以添加自定义的图形,比如添加一个矩形。
修改完成后点击左上角的『文件』,导出为 SVG 格式图标。
在 阿里巴巴矢量图标库 中,选择要编辑的图标点击『编辑图标』,点击红色字体『点击上传替换 icon』上传 SVG 图标。
这里可以对图层进行编辑,比如旋转、位移等。编辑完后点击『仅保存』。我们需要重新更新一下代码,然后复制到 WXSS 中替换。结果如下:
index.wxml:
使用 Canvas 绘制,使用 Component 创建一个自定义组件,例如名字为:circle-progress。在这个组件的 WXML 代码里放置一个 Canvas 组件,该组件用于绘制外面绿色的圆,灰色的圆圈由一个灰色的底圆 bigCircle 加一个白色的稍微小一点的圆 littleCircle 组合出来的。
<view class='canvasBox'> <!-- 外部灰色的圆 --> <view class='bigCircle'></view> <!-- 内部白色的圆 --> <view class='littleCircle'></view> <canvas canvas-id="runCanvas" id="runCanvas" class='canvas'></canvas></view>
index.js:
在自定义组件中,通过一个 percent 的属性标识进度;observer 用于自动监听属性变化,当进度增加时,调用 draw 函数绘制新增的绿色进度条。在 draw 函数及后续调用的函数中,计算出需要绘制的弧度及使用 Canvas 的弧度绘制 Apiece arc 进行绘制是实现环形效果的关键。
Component({ runTimerid:0, behaviors: [], properties: { percent: { type: Number, value: 0, observer: function (newVal, oldVal) { this.draw(newVal); } }, }, data: { percentage: '', //百分比 animTime: '', // 动画执行时间 }, // 私有数据,可用于模版渲染 lifetimes: { // 生命周期函数,可以为函数,或一个在methods段中定义的方法名 attached: function () { }, moved: function () { }, detached: function () { }, }, // 生命周期函数,可以为函数,或一个在methods段中定义的方法名 attached: function () { }, // 此处attached的声明会被lifetimes字段中的声明覆盖 pageLifetimes: { // 组件所在页面的生命周期函数 show: function () { }, }, created() { }, ready() { if (this.data.percent) this.draw(this.data.percent); }, methods: { // 绘制圆形进度条方法 run(c, w, h) { let that = this; var num = (2 * Math.PI / 100 * c) - 0.5 * Math.PI; that.ctx2.arc(w, h, w - 8, -0.5 * Math.PI, num) that.ctx2.setStrokeStyle("#09bb07");//绿色 that.ctx2.setLineWidth("16"); that.ctx2.setLineCap("butt"); that.ctx2.stroke(); that.ctx2.beginPath(); that.ctx2.setFontSize(40); //注意不要加引号 that.ctx2.setFillStyle("#b2b2b2");//浅灰色字体 that.ctx2.setTextAlign("center"); that.ctx2.setTextBaseline("middle"); that.ctx2.fillText(c + "%", w, h); that.ctx2.draw(); }, // 动画效果实现 canvasTap(start, end, time, w, h) { let that = this; start++; if (start > end) { return false; } that.run(start, w, h); that.runTimerid = setTimeout(function () { that.canvasTap(start, end, time, w, h); }, time); }, draw(percent) { const id = 'runCanvas' const animTime = 500 if (percent > 100) return if (!this.ctx2) { // 在自定义组件中,使用 createCanvasContext 创建画布的上下文绘制对象时,需要在第二个参数处传递 this 对象。 // 这样才是在组件中查找画布,不然只是在主页面查找,也就是在引用它的页面查找,这样是查不到的。 const ctx2 = wx.createCanvasContext(id, this) this.ctx2 = ctx2 } let oldPercentValue = this.data.percentage this.setData({ percentage: percent, animTime: animTime }); var time = this.data.animTime / (this.data.percentage-oldPercentValue); // 使用 wx.createSelectorQuery() 创建的对象的 select 方法以 ID 查找组件对象时, // 如果在自定义组件中,必须在查找前先调用一下它的 in 方法,把 this 对象传递进去, // 不然组件是查找不到的,默认组件查询也只仅是在主页面中查找,不会涉及主页面中的子组件。 const query = wx.createSelectorQuery().in(this) query.select('#' + id).boundingClientRect((res) => { var w = parseInt(res.width / 2); var h = parseInt(res.height / 2); if (this.runTimerid) clearTimeout(this.runTimerid) this.canvasTap(oldPercentValue, percent, time, w, h) }).exec() } }})
index.json:
{ "component": true, "usingComponents": {}}
index.wxss:
.canvasBox{ height: 500rpx; position: relative; background-color: white;}/* 外部灰色的圆 */.bigCircle{ width: 420rpx; height: 420rpx; border-radius: 50%; position: absolute; top:0; bottom: 0; left: 0; right: 0; margin: auto auto; background-color: #f2f2f2;}/* 内部白色的圆 */.littleCircle{ width: 350rpx; height: 350rpx; border-radius: 50%; position: absolute; top:0; bottom: 0; left: 0; right: 0; margin: auto auto; background-color: white;}.canvas{ width: 420rpx; height: 420rpx; position: absolute; left: 0; top: 0; bottom: 0; right: 0; margin: auto auto; z-index: 99;}
在使用时,需要先在 JSON 配置中声明对组件的引用。circle-progress 是声明的名称,声明后在 WXML 中就可以把它当做标签使用了。
index.json:
{ "usingComponents": { "circle-progress": "../circle-progress/index" }}
index.wxml:
在 button 触发的 JSON 函数中,模拟网络变化改变进度值,就可以看到动画效果了。
<view class="gap">环形进度条</view><circle-progress id="progress1" percent="{{percentValue}}" /><button bindtap="drawProgress">redraw</button><progress show-info bindtap="onTapProgressBar" stroke-width="11" percent="{{percentValue}}" backgroundColor="#f2f2f2" active-mode="forwards" active bindactiveend="onProgressActiveEnd" border-radius="5" /><button bindtap="onTapReloadBtn">重新加载</button><!-- show-info:代表是否在进度条右侧显示百分比数字,一般不显示,因为进度条本身标明了进度bindtap:绑定 tap 事件,所有可视的 view 组件都可以绑定 tap 事件,即使属性列表中没有显式标明stroke-width:表示进度条的宽度percent:是进度,在 0-100 之间active-mode:是动画停止后重新启动的模式,有两个值:backwards:表示动画从头播;forwards:表示动画从上次结束的位置继续播放。默认值是 backwardsacitve:表示是否展示动画,与 show-info 一样是布尔类型。布尔属性为真,只需要列上属性就行了,如果想显示设置为 false,需要写成 active="{{false}}" 这样的形式bindactiveend:用于绑定动画结束的事件,在动画结束时候触发border-radius:设置进度条外框的圆角大小,默认为 0wx-progress-inner-bar:控制内部已经前进的进度条样式-->
index.js:
onTapProgressBar(e){ console.log(e) let progress = this.data.percentValue if (progress < 100){ progress += 5 this.setData({percentValue:Math.min(100, progress)}) }},onTapReloadBtn(e){ this.setData({percentValue:0}) this.setData({percentValue:100})},drawProgress(){ if (this.data.percentValue >= 100){ this.setData({ percentValue:0 }) } this.setData({ percentValue:this.data.percentValue+10 })}
index.wxss:
drawProgress(){ if (this.data.percentValue >= 100){ this.setData({ percentValue:0 }) } this.setData({ percentValue:this.data.percentValue+10 })}
在富文本组件 rich-text 中,节点的事件是被屏蔽的,例如节点里面的图片,它的单击事件是不能监听的。
rich-text 示例代码:
<rich-text space="emsp" nodes="{{nodes}}" bindtap="tap"></rich-text><!--space:控制中文空格显示的大小,有三种值,在中文环境中直接取 emsp 就好。nodes:可以取字符串也可以取数组。如果是字符串的话会影响性能,一般使用数组。-->
nodes 示例代码:
nodes: [{ // nodes 是一个数组,数组中每个元素都可以是复合的 node 节点,也可以是末节的 text 节点,是一个树状结构。简单分辨节点类型的方法,就是看节点有没有 name 属性。 name: 'div', // 节点名称,例如 p、div、span、img、ruby 等,支持大部分 HTML 标签 attrs: {// 表示节点属性,是定义在 HTML 标签上的属性,例如 img 标签的 src、width、height 属性 class: 'div_class', style: 'line-height: 20px;padding:20px;' }, children: [// 代表子节点列表,是一个数组 { type: 'text',// 代表节点类型,有两种:node 与 text,默认是 node,可不写。当类型为 node 时,有 children 属性;为 text,只有 text 属性,text 节点只能包括纯文本 text: '小程序实践' }, { name: 'img', // name 代表标签名称,有 name 代表是复合节点;没有并且 type 属性为 text,代表是简单的文本节点。当是 text 节点时, 它代表的是最基本的文本,没有样式,它所有的样式都是来自父节点的设定 attrs: { src: 'https://caroly.site/caroly_img/%E7%9C%8B%E4%B9%A6%E7%9A%84%E5%A5%B3%E5%AD%A9_1618912429845.jpg', style: 'width:100%' } }, { name: 'img', attrs: { src: 'https://caroly.site/caroly_img/%E8%BF%9E%E8%A1%A3%E5%A5%B3%E5%AD%A9_1618912429933.jpg', style: 'width:100%' // ,style:'width:100%;font-size:0;display:block;'//修改样式 ,class: 'img' } }, { name: 'img', attrs: { src: 'http://caroly.site/caroly_img/86009625_p0_1607479719466.png', style: 'width:100%' } } ]}]
在 rich-text 组件上添加 tap 事件,在事件函数中,使用 wx.previewImage 这个接口预览图片,然后选择需要的图片下载。在预览之前需要遍历 rich-text 中的 nodes 数据,将所有图片地址预先取出来。当单击 rich-text 富文本组件时,触发预览,在 tap 事件句柄中,事件对象 e 是一个 TouchEvent 对象,使用它的 pageX、pageY 属性,还可以取到用户大概单击了什么位置。如果以位置可以判断出图片是哪一张,就可以在调用 wx.previewImage 预览图片时,作为第一个参数传递进去,这样就可以实现指定图片的预览与下载了;如果不能确定,取第一张就可以了。
index.wxml:
<rich-text space="emsp" nodes="{{nodes}}" bindtap="tap"></rich-text>
index.js:
data: { nodes: [{ name: 'div', attrs: { class: 'div_class', style: 'line-height: 20px;padding:20px;' }, children: [ { type: 'text', text: '小程序实践' }, { name: 'img', attrs: { src: 'https://caroly.site/caroly_img/%E7%9C%8B%E4%B9%A6%E7%9A%84%E5%A5%B3%E5%AD%A9_1618912429845.jpg', style: 'width:100%' }, class: 'img' }, { name: 'img', attrs: { src: 'https://caroly.site/caroly_img/%E8%BF%9E%E8%A1%A3%E5%A5%B3%E5%AD%A9_1618912429933.jpg', style: 'width:100%' // ,style:'width:100%;font-size:0;display:block;'//修改样式 ,class: 'img' }, class: 'img' }, { name: 'img', attrs: { src: 'http://caroly.site/caroly_img/86009625_p0_1607479719466.png', style: 'width:100%' }, class: 'img' } ] }], urls: [], tagStyle: { img: 'font-size:0;display:block;', }, html:"<div>小程序实践<span>message</span><img src='https://caroly.site/caroly_img/%E8%BF%9E%E8%A1%A3%E5%A5%B3%E5%AD%A9_1618912429933.jpg' /><img src='http://caroly.site/caroly_img/86009625_p0_1607479719466.png' /></div>"},tap(e) { let urls = this.data.urls wx.previewImage({// 预览 current: urls[1], urls: urls })},onReady: function () { // 取出 urls function findUrl(nodes) {// 对 nodes 数据进行遍历,判断节点 name 是不是 img,再看它的属性有没有 src,如果有的话,就推到 urls 数组中去,最后放到 data 中去 let urls = [] nodes.forEach(item => { if (item.name == 'img' && item.attrs) { for (const key in item.attrs) { if (key == 'src') { urls.push(item.attrs[key]) } } } if (item.children) { urls = urls.concat(findUrl(item.children)) } }) return urls } this.data.urls = findUrl(this.data.nodes)}
如果 rich-text 中有多张图片,上下图片间会有间隙,这是样式问题。小程序的 rich-text 是通过 Web Component 实现的,它不允许外部修改内部的 img 元素的样式,可以直接修改 nodes 数据中的 img 样式,加两个内联样式。
这个缝隙是行内容引起的,通过设置元素为块元素,并设置字体为 0,图片间的间隙就没有了。
.img{ font-size:0; display:block;}
使用 parset 组件。parset 支持单击预览及下载,并且单击时还向外层派发了一个 imgtap。在使用这个组件时,可以添加一个对 imgtap 事件的监听,在运行时就能拿到被单击图片的网址了。
index.json:
{ "usingComponents": { "circle-progress": "../circle-progress/index", "parser":"../../components/parser/parser" }}
index.wxml:
<parser bindimgtap="onTapImage" html="{{html}}" tag-style="{{tagStyle}}" />
index.js:
onTapImage(e) { // 获取点击的图片网址 console.log('iamge url', e.detail.src)}
view 组件是小程序最基础的容器组件,基本上使用它就可以实现所有常见的 UI 布局。
view 示例 index.wxml:
<view hober-class="bc_red" class="section_title">parent<!--hover-class:指定按下去的类样式,让容器有一个单击效果。当为 none 或者没有设置属性时,就没有单击态的效果。--><view hover-stop-propagation hober-class="bc_red" class="section_title">child view<!--hover-stop-propagation:该属性可以阻止父节点出现单击态,默认为 fasle,不阻止。注:在运行效果中,子容器有单击态,父容器没有,虽然父容器也设置了 hover-class 属性,但触碰事件被子容器给阻止了。--> </view> </view>
按钮示例 index.wxml:
<!-- 普通按钮 --><view class="section"><button class="btn" type="primary">完成</button></view><!-- 圆形按钮 --><view class="section"><button hover-class="circle-btn__hover_btn"><icon type="success" size="80px"></icon></button></view><!-- 距形按钮 --><view class="section"><button type="default" class="btn" plain hover-class="rect-btn__hover_btn"><icon type="success_no_circle" size="26px"></icon>完成</button></view>
index.wxss:
/* 普通按钮 */.btn{ display: flex; // flex 与 align-items 是为了实现文本与图标的横向对齐 align-items: middle; padding: 8px 50px 8px; border: 1px solid #b2b2b2; background-color: #f2f2f2; width:auto;}/* 圆角按钮 */.circle-btn__hover_btn { opacity: 0.8; transform: scale(0.95, 0.95);// 圆形按钮在单击时缩小 0.05,}/* 方框按钮 */.rect-btn__hover_btn { position: relative; top: 3rpx; left: 3rpx; box-shadow: 0px 0px 8px rgba(175, 175, 175, .2) inset;// 20% 透明格式作为方形按钮按下时状态的内阴影颜色}
view 容器组件最大的作用就是实现 UI 布局,最常用的是 Flex 布局。
Flex 布局将 display 样式设置为 flex,再加以其他相关的样式实现的布局。
关于 Flex 布局有三个重要的样式:
以默认的 flex-direction 设置为 row 来看,从左到右是主轴,自上而下是侧轴,也叫辅轴。
在这种情况下,justify-content 管制的是元素在 X 方向的排列策略;align-items 管制的是主轴上排列的元素在侧轴方向即 Y 方向的对齐方式;align-content 管制的是当出现多行后,多行内容在辅轴方向上即 Y 轴方向的排列策略。
排列:一般指两个或多个元素它们间隔多少。
对齐:一般指多个元素它们的两边或者中心线对齐的方式。
justify-content:
align-items:
flex-wrap:
flex-direction:用于决定是 X 轴还是 Y 轴是主轴,默认情况下是以 X 轴为主轴。如果将 flex-direction 的值设置为 column,区域块是纵向排列的;如果将 Y 轴定义为主轴,决定元素横向排列的是 align-items。
view 目前不能直接转绘到画布上,这里有一种可行的办法:先使用 wx.createCanvasContext 创建一个画布,接着在画布上绘制内容、文本或者是图片,再通过 wx.canvasToTempFilePath 保存到本地并获取一个临时图片路径,最后通过 wx.saveImageToPhotosAlbum 保存临时文件到本地相册里。
借助开源的小程序组件 Painter,封装了 JSON 数据绘制海报的功能。
index.wxml:
<view class="section"><!-- 生成分享图,将view转绘为图片 --><button type="primary" class="intro" open-type="getUserInfo" bindgetuserinfo="getUserInfo" wx:if="{{!nickName}}">获取分享图头像昵称</button><button type="primary" class="intro" bindtap="createShareImage" wx:else>点我生成分享图</button><share-box isCanDraw="{{isCanDraw}}" bind:initData="createShareImage" /></view>
index.js:
getUserInfo(e) { this.setData({ nickName: e.detail.userInfo.nickName, avatarUrl: e.detail.userInfo.avatarUrl }) wx.setStorageSync('avatarUrl', e.detail.userInfo.avatarUrl) wx.setStorageSync('nickName', e.detail.userInfo.nickName)},createShareImage() { this.setData({ isCanDraw: !this.data.isCanDraw })}
index.json:
{ "usingComponents": { "circle-progress": "../circle-progress/index", "parser":"../../components/parser/parser", "share-box": "../../components/shareBox/index" }}
该功能使用 weui 组件库实现。
weui 组件库可以采用 npm 安装的方式去使用,还可以通过扩展声明的方式进行使用。后者不占用代码包的大小,优先选择后面的方式。
在小程序的配置文件 app.json 文件里,通过 useExtendedLib 配置字段,添加对 weui 引用,添加完后在微信开发者工具的菜单里面,选择工具,构建 npm 构建一下。构建以后就完成了对 weui 组件库的引用配置。
如果是 npm 安装,需要在 app.wxss 文件里添加对 weui.wxss 全局样式的引用;如果是扩展声明这种方式,这种引用代码可以省略。
在 json 文件里面使用 usingComponents 添加对 mp-slideview 组件的引用声明,就可以在 wxml 代码里面使用这个组件。
index.wxml:
<view class="page__bd"> <view class="weui-cells"> <mp-slideview ext-class="slideViewClass" buttons="{{slideButtons}}" bindbuttontap="slideButtonTap"> <mp-cell value="标题文字1"></mp-cell> </mp-slideview> </view> <view class="weui-cells"> <mp-slideview buttons="{{slideButtons}}" icon="{{true}}" bindbuttontap="slideButtonTap"> <view class="weui-slidecell"> 左滑可以删除(图标Button) </view> </mp-slideview></view></view>
index.js:
onLoad: function (options) { this.widget = this.selectComponent('.widget') this.setData({ icon: base64.icon20, slideButtons: [{ text: '普通1', src: '/images/icon_love.svg', // icon的路径 },{ text: '普通2', extClass: 'test', src: '/images/icon_star.svg', // icon的路径 },{ type: 'warn', text: '警示3', extClass: 'test', src: '/images/icon_del.svg', // icon的路径 }], });}
index.json:
{ "usingComponents": { "circle-progress": "../circle-progress/index", "parser":"../../components/parser/parser", "share-box": "../../components/shareBox/index", "mp-slideview": "weui-miniprogram/slideview/slideview", "mp-cell": "weui-miniprogram/cell/cell", "slide-view": "../../miniprogram_npm/miniprogram-slide-view/index" }}
npm install --save wxml-to-canvas
index.wxml:
<view class="page-section"> <view class="page-section-title">wxml</view> <view class="container" > <view class="item-box red"> </view> <view class="item-box green" > <text class="text">yeah!</text> </view> <view class="item-box blue"> <image class="img" src="https://caroly.site/caroly_img/%E8%BF%9E%E8%A1%A3%E5%A5%B3%E5%AD%A9_1618912429933.jpg"></image> </view> </view></view><!-- 渲染wxml --><view class="page-section"> <view class="page-section-title">渲染wxml</view> <!-- 组件 --> <wxml-to-canvas class="widget"></wxml-to-canvas> <view class="page-section-title">导出图片</view> <!-- 图片 --> <image src="{{src}}" style="width: {{width}}px; height: {{height}}px"></image></view><view class="btn-area"> <button type="primary" bindtap="renderToCanvas">渲染到canvas</button> <button bindtap="extraImage">导出图片</button> <button bindtap="onTapSaveBtn">保存图片</button></view>
index.wxss:
.widget {}.container { width: 300px; height: 200px; min-height: 200px; flex-direction: row; justify-content: space-around; background-color: #ccc; align-items: center; padding: 60px 0 60px; display: flex;}.item-box { width: 80px; height: 60px; display: flex;}.red{ background-color: #ff0000}.green { background-color: #00ff00}.blue{ background-color: #0000ff; align-items: center; justify-content: center;}.text{ width: 80px; height: 60px; text-align: center; vertical-align: middle; line-height: 60px;}.img{ width: 40px; height: 40px; border-radius: 50%;}
index.js:
const { wxml, style } = require('./demo')Page({ data: { src: '' }, onLoad() { this.widget = this.selectComponent('.widget') }, onTapSaveBtn(e){ wx.saveImageToPhotosAlbum({ filePath:this.data.src, complete(res) { console.log(res) } }) }, renderToCanvas() { const p1 = this.widget.renderToCanvas({ wxml, style }) p1.then((res) => { console.log('container', res.layoutBox) this.container = res }) }, extraImage() { const p2 = this.widget.canvasToTempFilePath() p2.then(res => { this.setData({ src: res.tempFilePath, width: this.container.layoutBox.width, height: this.container.layoutBox.height }) }) }})
demo.js:
const wxml = `<view class="container" ><view class="item-box red"></view><view class="item-box green" ><text class="text">yeah!</text></view><view class="item-box blue"><image class="img" src="https://caroly.site/caroly_img/%E8%BF%9E%E8%A1%A3%E5%A5%B3%E5%AD%A9_1618912429933.jpg"></image></view></view>`const style = { container: { width: 300, height: 200, flexDirection: 'row', justifyContent: 'space-around', backgroundColor: '#ccc', alignItems: 'center', }, itemBox: { width: 80, height: 60, }, red: { backgroundColor: '#ff0000' }, green: { backgroundColor: '#00ff00' }, blue: { backgroundColor: '#0000ff', alignItems: 'center', justifyContent: 'center', }, text: { width: 80, height: 60, textAlign: 'center', verticalAlign: 'middle', }, img: { width: 40, height: 40, borderRadius: 20, }}module.exports = { wxml, style}
index.json:
{ "usingComponents": { "wxml-to-canvas": "wxml-to-canvas" }}
创建这个专栏是为了记录一下自己学习大数据的经历,需要学习的知识真的太多了,温故而知新。
为什么会选择大数据呢?我们知道:Hadoop 大数据框架基于 Java 语言开发;Spark 流式计算框架是基于 Scala 语言,而 Scala 则是基于 Java 语言。这样在理解大数据储存和计算思想和编写程序进行实现时,会有一定优势。相比于机器学习人工智能,大数据非常适合过渡。
大数据门槛比 Java 高一点,除了对数据库操作外,还需要学习大数据生态的东西,比如 Hadoop 生态、Spark 生态、Flink 生态等。
有意向的同窗,可以留言或发邮获取资料学习。
本文详细展示了从 0 到 1 搭建高可用集群 Hadoop 的步骤方法,包括环境准备、Hadoop 配置、Zookeeper 配置、Hadoop MapReduce V2 等内容。文末还有启动 / 关闭集群的命令以及启动集群后的单词统计用例。
本文详细写了单词统计(WordCount)详解,包括 MapReduce 编程模型、处理过程、打包运行 WordCount 程序、WordCount 处理过程、SQL 处理统计操作等内容。
本文着重分析单词统计的源码,从流程分析、源码分析入手,详细分析 Mapper 和 Reducer 代码及其实现流程。
本文描述 TF-IDF 算法的原理与实现,叙述算法公式含义,佐以代码进行分析。
本文详细展示了搭建 Hive 多节点的过程,在 Hadoop(一)高可用集群搭建 的基础上搭建,包括单节点模式、多节点模式。文章后面对搭建好的环境进行测试使用,阐述内外表区别,对单分区和双分区进行演示,并开启动态分区和分桶。
本文主要描述 Hive 的优化及高可用。从 Hive 的排序、Hive Join、Map-Side 聚合、合并小文件、控制 Hive 中 Map 以及 Reduce 的数量等方面进行优化。配置 Hive 的高可用并通过代码进行测试。开启压缩及描述文件存储。
本文主要描述 HBase 的分布式安装,同样也是在 Hadoop(五)Hive 多节点搭建 的基础上搭建,包括单节点安装以及分布式安装。通过 hbase shell 指令对搭建好的环境进行验证测试。
本文主要描述 ClouderaManager 的部署,分析 ClouderaManager 的功能、架构。文章后面详细记录从系统环境准备到 CM 的安装的过程,安装好后可以通过 CM 来安装我们需要的集群服务。
本文从功能和基本概念等方面对 Elasticsearch 进行剖析,对比 ES 与关系型数据库。基于 Hadoop(八)ClouderaManager 部署 进行 ES 的安装。
本文通过对专业名词进行解析来剖析 Storm,主要描述 Storm 的安装过程,在 Hadoop(七)HBase 分布式安装 的基础上搭建,包括单机模式以及全分布式。
本文描述 Kafka 的拓扑结构以及名词解析,对 Producer 发布消息、Broker 保存消息、 Consumer 消费消息以及常用 API 进行详细描述。文章最后记录安装 Kafka 的全过程,在 Hadoop(十)Storm 安装 的基础上搭建。
本文从 Flume 的特点和名词概念等方面剖析,阐述 Flume NG 的体系结构。在 Hadoop(十一)Kafka 安装 的基础上进行 Flume 的安装。
Hadoop(十三)Spark Standalone 集群搭建
]]>本文分析 Spark 的流程图及特点,在 Hadoop(十二)Flume 安装 的基础上安装 Spark。描述基于 Standalone 和 Yarn 任务提交、基于 Standalone client 和 cluster 任务提交、基于 Yarn-client 模式任务提交、基于 Yarn-cluster 模式任务提交等四种模式的指令、过程及特点。
在 A.xaml 界面将 B.xaml 界面导出为 PDF,B.xaml 为 Window 窗体。
调用 windows api 通过打印机导出 PDF。
A.xaml 核心代码:
反射调用 B 界面的方法,获得传入参数后的 B 界面。
private void Export_Click(object sender, RoutedEventArgs e){ try { thread = new Thread(new ThreadStart(delegate{this.Dispatcher.Invoke(DispatcherPriority.Background, (ThreadStart)delegate{Assembly assembly = Assembly.GetExecutingAssembly();var type = assembly.GetType($"namespace");var assem = assembly.CreateInstance($"namespace", false, BindingFlags.CreateInstance, null, new object[] { "B 界面参数" }, null, null) as Window;MethodInfo method = assem.GetType().GetMethod("ExportPDF");Object[] paras = new Object[] { "ExportPDF 方法参数 1", "ExportPDF 方法参数 2" };method.Invoke(assem, paras);});}));thread.Start(); } catch (Exception ex) { throw ex; } finally { }}
B.xaml 核心代码:
将要打印的控件内容重新布局,细节可以自调。可传入多个控件名字,这里取两个。
public void GetPDF(string 参数 1, string 参数 2){ // obj1、obj2 分别为要打印的控件名。 // 其它参数随意,这里取导出的 PDF 的名字 (name) 以及路径 (path) GetPDF(ojb1, obj2, name, path);}private void GetPDF(object object1, object object2, string name, string path){ PrintDialog pd = new PrintDialog(); var stack = new StackPanel() { }; var stack1 = new StackPanel() { HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, 0, 0, 0) }; string childXaml1 = XamlWriter.Save(object2); StringReader stringReader1 = new StringReader(childXaml1); XmlReader xmlReader1 = XmlReader.Create(stringReader1); UIElement clonedChile1 = (UIElement)XamlReader.Load(xmlReader1); stack.Children.Add(clonedChile); string childXaml = XamlWriter.Save(object1); StringReader stringReader = new StringReader(childXaml); XmlReader xmlReader = XmlReader.Create(stringReader); UIElement clonedChile = (UIElement)XamlReader.Load(xmlReader); stack1.Children.Add(clonedChile1); stack.Children.Add(stack1); FixedDocumentValuePdf(GetFixedDocumentHorizontal(stack, pd), name, path);}
若是要打印的内容超过一页,则需要分页。
private FixedDocument GetFixedDocumentHorizontal(FrameworkElement toPrint, PrintDialog printDialog){ double sizeWidths = 800; double sizeHeights = 1045; Size pageSize = new Size(printDialog.PrintableAreaWidth, printDialog.PrintableAreaHeight); FixedDocument fixedDoc = new FixedDocument(); toPrint.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); toPrint.Arrange(new Rect(new System.Windows.Point(0, 0), toPrint.DesiredSize)); Size size = toPrint.DesiredSize; double yOffset = 0; while (yOffset < size.Height) { VisualBrush vb = new VisualBrush(toPrint) { Stretch = Stretch.None, AlignmentX = AlignmentX.Left, AlignmentY = AlignmentY.Top, ViewboxUnits = BrushMappingMode.Absolute, TileMode = TileMode.None, Viewbox = new Rect(0, yOffset, sizeWidths, sizeHeights) }; PageContent pageContent = new PageContent(); FixedPage page = new FixedPage(); ((IAddChild)pageContent).AddChild(page); fixedDoc.Pages.Add(pageContent); page.Width = pageSize.Width; page.Height = pageSize.Height; var stack = new StackPanel() { Width = sizeWidths, Height = sizeHeights, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, Background = vb, Margin = new Thickness(0, 0, 0, 0) }; page.Children.Add(stack); yOffset += sizeHeights; } return fixedDoc;}
private void FixedDocumentValuePdf(FixedDocument fd, string name, string path){ MemoryStream ms = new MemoryStream(); Package package = Package.Open(ms, FileMode.Create); XpsDocument doc = new XpsDocument(package); var writer = XpsDocument.CreateXpsDocumentWriter(doc); writer.Write(fd.DocumentPaginator); doc.Close(); package.Close(); var bytes = ms.ToArray(); ms.Dispose(); string root = AppDomain.CurrentDomain.SetupInformation.ApplicationBase; string dir = root + "Data\\"; if (!System.IO.Directory.Exists(dir)) { System.IO.Directory.CreateDirectory(dir); } var outputFilePath = dir + "\\" + name + ".pdf"; PrintXpsToPdf(bytes, outputFilePath, "pdf", false);}
打印 PDF:
private void PrintXpsToPdf(byte[] bytes, string outputFilePath, string documentTitle, bool flag= true){ var pdfPrintQueue = GetMicrosoftPdfPrintQueue(); var ptrUnmanagedBytes = Marshal.AllocCoTaskMem(bytes.Length); Marshal.Copy(bytes, 0, ptrUnmanagedBytes, bytes.Length); var di = new DOCINFOA { pDocName = documentTitle, pOutputFile = outputFilePath, pDataType = "RAW" }; var errorCode = SendBytesToPrinter(pdfPrintQueue.Name, ptrUnmanagedBytes, bytes.Length, di, out var jobId, flag); Marshal.FreeCoTaskMem(ptrUnmanagedBytes);}
private int SendBytesToPrinter(string szPrinterName, IntPtr pBytes, int dwCount, DOCINFOA documentInfo, out int jobId, bool flag= true){ jobId = 0; var dwWritten = 0; var success = false; if (OpenPrinter(szPrinterName.Normalize(), out var hPrinter, IntPtr.Zero)) { jobId = StartDocPrinter(hPrinter, 1, documentInfo); if (jobId > 0) { if (StartPagePrinter(hPrinter)) { success = WritePrinter(hPrinter, pBytes, dwCount, out dwWritten); } } ClosePrinter(hPrinter); } if (success == false) { return Marshal.GetLastWin32Error(); } return 0;}
[DllImport("winspool.drv", EntryPoint = "OpenPrinterA", SetLastError = true, CharSet = CharSet.Ansi, ExactSpelling = true, CallingConvention = CallingConvention.StdCall)]private static extern bool OpenPrinter([MarshalAs(UnmanagedType.LPStr)] string szPrinter, out IntPtr hPrinter, IntPtr pd);[DllImport("winspool.drv", EntryPoint = "ClosePrinter", SetLastError = true, ExactSpelling = true, CallingConvention = CallingConvention.StdCall)]private static extern bool ClosePrinter(IntPtr hPrinter);[DllImport("winspool.drv", EntryPoint = "StartDocPrinterA", SetLastError = true, CharSet = CharSet.Ansi, ExactSpelling = true, CallingConvention = CallingConvention.StdCall)]private static extern int StartDocPrinter(IntPtr hPrinter, int level, [In, MarshalAs(UnmanagedType.LPStruct)] DOCINFOA di);[DllImport("winspool.drv", EntryPoint = "StartPagePrinter", SetLastError = true, ExactSpelling = true, CallingConvention = CallingConvention.StdCall)]private static extern bool StartPagePrinter(IntPtr hPrinter);[DllImport("winspool.drv", EntryPoint = "WritePrinter", SetLastError = true, ExactSpelling = true, CallingConvention = CallingConvention.StdCall)]private static extern bool WritePrinter(IntPtr hPrinter, IntPtr pBytes, int dwCount, out int dwWritten);
上传解压(『caroly01』)
tar xf spark-2.3.1-bin-hadoop2.6.tgz -C /opt/carolycd /opt/carolymv spark-2.3.1-bin-hadoop2.6 spark-2.3.1
修改配置文件(『caroly01』)
cd /opt/caroly/spark-2.3.1/confcp slaves.template slavesvi slaves
change 19:修改 19 行:配置从节点caroly02caroly03
cp spark-env.sh.template spark-env.shvi spark-env.sh
add 58:在 58 行增加:指定 master 节点、提交任务端口、每台 worker 核数、每台 worker 可支配的内存、配置 Master - HAexport SPARK_MASTER_HOST=caroly01export SPARK_MASTER_PORT=7077export SPARK_WORKER_CORES=2export SPARK_WORKER_MEMORY=3gexport SPARK_MASTER_WEBUI_PORT=8888export SPARK_DAEMON_JAVA_OPTS="-Dspark.deploy.recoveryMode=ZOOKEEPER -Dspark.deploy.zookeeper.url=caroly02:2181,caroly03:2181,caroly04:2181 -Dspark.deploy.zookeeper.dir=/MasterHA1114"
vi /opt/caroly/spark-2.3.1/sbin/spark-config.shadd 34:export JAVA_HOME=/usr/java/jdk1.8.0_251-amd64
为了方便使用 WebUI,可以进行以下配置(客户端配置)
cd /opt/caroly/spark-2.3.1/confcp spark-defaults.conf.template spark-defaults.confvi spark-defaults.conf
add 28: 在 28 行增加:增加配置spark.eventLog.enabled truespark.eventLog.dir hdfs://caroly01:8020/spark/data/logspark.history.fs.logDirectory hdfs://caroly01:8020/spark/data/logspark.eventLog.compress true
hdfs dfs -mkdir -p /spark/data/log
分发到其他节点(『caroly01』)
cd /opt/caroly/scp -r spark-2.3.1/ caroly02:`pwd`scp -r spark-2.3.1/ caroly03:`pwd`
启动(『caroly01』)
cd /opt/caroly/spark-2.3.1/sbin/./start-all.sh
启动从节点(『caroly02』)
cd /opt/caroly/spark-2.3.1/sbin/./start-master.sh
访问
http://caroly01:8080/
在每台节点中的 yarn-site.xml
中配置关闭虚拟内存检查
<property> <name>yarn.nodemanager.vmem-check-enabled</name> <value>false</value> </property>
任务提交
./spark-submit --master spark://caroly01:7077 --class org.apache.spark.examples.SparkPi ../examples/jars/spark-examples_2.11-2.3.1.jar 100
命令:
./spark-submit --master spark://caroly01:7077 --deploy-mode client --class org.apache.spark.examples.SparkPi ../examples/jars/spark-examples_2.11-2.3.1.jar 100
过程:
特点:Spark 基于 standalone-client 模式提交任务,每个 Spark Application 都有自己的独立的 Driver,如果在客户端提交 100 个 Application,会有 100 个 Driver 进程在客户端启动,Driver 负责发送 task,监控 task 执行,回收结果,很容易造成客户端网卡流量激增问题。这种模式适用于程序测试,不适用于生产环境。在客户端可以看到 task 的执行和结果。
命令:
./spark-submit --master spark://caroly01:7077 --deploy-mode cluster --class org.apache.spark.examples.SparkPi ../examples/jars/spark-examples_2.11-2.3.1.jar 100
过程:
特点:Spark 基于 Standalone-cluster 模式提交任务,当在客户端提交多个 Application 时,Driver 是随机在某些 Worker 节点启动,客户端就没有网卡流量激增问题,将这种问题分散到集群中。在客户端看不到 task 执行和结果。这种模式适用于生产环境。
命令:
./spark-submit --master yarn --class org.apache.spark.examples.SparkPi ../examples/jars/spark-examples_2.11-2.3.1.jar 100./spark-submit --master yarn-client --class org.apache.spark.examples.SparkPi ../examples/jars/spark-examples_2.11-2.3.1.jar 100./spark-submit --master yarn --deploy-mode client --class org.apache.spark.examples.SparkPi ../examples/jars/spark-examples_2.11-2.3.1.jar 100
过程:
特点:Spark 基于 Yarn-client 模式提交任务,当在 Driver 提交多个 Application 时,会有网卡流量激增问题,这种模式适用于程序测试,不适用于生产环境。在客户端可以看到 task 的执行和结果。
Exception in thread "main" java.lang.Exception: When running with master 'yarn' either HADOOP_CONF_DIR or YARN_CONF_DIR must be set in the environment. at org.apache.spark.deploy.SparkSubmitArguments.validateSubmitArguments(SparkSubmitArguments.scala:288) at org.apache.spark.deploy.SparkSubmitArguments.validateArguments(SparkSubmitArguments.scala:248) at org.apache.spark.deploy.SparkSubmitArguments.<init>(SparkSubmitArguments.scala:120) at org.apache.spark.deploy.SparkSubmit$.main(SparkSubmit.scala:130) at org.apache.spark.deploy.SparkSubmit.main(SparkSubmit.scala)
解决方法:在命令行中执行如下指令:
export HADOOP_CONF_DIR=/opt/caroly/hadoop-2.9.2/etc/hadoop/
命令:
./spark-submit --master yarn --deploy-mode cluster --class org.apache.spark.examples.SparkPi ../examples/jars/spark-examples_2.11-2.3.1.jar 100./spark-submit --master yarn-cluster --class org.apache.spark.examples.SparkPi ../examples/jars/spark-examples_2.11-2.3.1.jar 100
过程:
特点:Spark 基于 Yarn-cluster 模式提交任务,当有多个 Application 提交时,每个 Application 的 Driver(AM)是分散到集群中的 NM 中启动,没有客户端的网卡流量激增问题。将这种问题分散到集群中。在客户端看不到 task 的执行和结果,要去 webui 中查看。这种模式适用于生产环境。
public String Post(String PostUrl, String[] Parameters) { if (null == PostUrl || null == Parameters || Parameters.length == 0) { return null; } String result = ""; PrintWriter out = null; BufferedReader in = null; try { //建立 url 之间的连接 URLConnection conn = new URL(PostUrl).openConnection(); //设置通用的请求属性 conn.setRequestProperty("Host", "data.zz.baidu.com"); conn.setRequestProperty("User-Agent", "curl/7.12.1"); conn.setRequestProperty("Content-Length", "83"); conn.setRequestProperty("Content-Type", "text/plain"); // 发送 POST 请求必须设置如下两行 conn.setDoInput(true); conn.setDoOutput(true); // 获取 conn 对应的输出流 out = new PrintWriter(conn.getOutputStream()); // 发送请求参数 String param = ""; for (String s : Parameters) { param += s + "\n"; } out.print(param.trim()); // 进行输出流的缓冲 out.flush(); // 通过 BufferedReader 输入流来读取 url 的响应 in = new BufferedReader(new InputStreamReader(conn.getInputStream())); String line; while ((line = in.readLine()) != null) { result += line; } } catch (Exception e) { System.out.println("发送post请求出现异常!" + e); e.printStackTrace(); } finally { try { if (out != null) { out.close(); } if (in != null) { in.close(); } } catch (IOException ex) { ex.printStackTrace(); } } return result;}
@Testpublic void testBuidu() { String url = "http://data.zz.baidu.com/urls?site=https://www.xxxx.xxx&token=xxxxxxxx";// 网站的服务器连接 String[] param = { "https://www.xxxx.xxx/", "https://www.xxxx.xxx/xxxxx", "https://www.xxxx.xxx/xxxxxx", "https://www.xxxx.xxx/xxxxxxx" }; String json = Post(url, param);// 执行推送方法 System.out.println("结果是" + json); // 打印推送结果}
会显示推送成功的条数以及未推送成功的链接。
{ "remain":2999,// 当天剩余的可推送 url 条数 "success":1,// 成功推送的条数 "not_same_site":[// 由于不是本站 url 而未处理的 url 列表 "https://www.xxxx.xxx/", "https://www.xxxx.xxx/xxxxx" ], "not_valid":[// 不合法的 url 列表 "https://www.xxxx.xxx/xxxxx" ]}
{ "error":401,// 错误码,与状态码相同 "message":"token is not valid"// 错误描述}