概述
我们知道事务性在数据处理里面是非常重要的,事务性决定了你最终数据的正确与否。在 OLTP
领域里面事务一般通过底层存储提供的事务机制就可以搞定了。但是在分布式数据处理领域里面,由于数据在被很多节点同时大规模分布式地写,OLTP
里面的简单事务处理就很难处理这种场景了。在 Presto 里面也需要解决这样的问题,我们今天就来看看 Presto 里面提供了什么样的机制来达到数据写入的事务性。
物理执行计划的基本单元: Operator
在正式介绍数据事务性写入之前,我们先来看看 Presto 里面物理执行计划的基本单元: Operator
。
我们知道在一般的“语言”里面,把语言从文本编译到最终可执行程序的过程中都会经历从文本到逻辑执行计划,再从逻辑执行计划到物理执行计划的编译过程,Presto 也不例外,在 Presto 里面逻辑执行计划是由 PlanNode
组成的, 物理执行计划则是由 Operator
组成的。这里先简单介绍一下 Operator
的设计。
语言的编译过程
下面是 Operator
接口主要方法的定义(为了行文的简洁性删除了一些不那么重要的方法):
public interface Operator extends AutoCloseable { /** * Gets the column types of pages produced by this operator. */ List<Type> getTypes(); /** * Notifies the operator that no more pages will be added and the * operator should finish processing and flush results. This method * will not be called if the Task is already failed or canceled. */ void finish(); /** * Adds an input page to the operator. This method will only be called if * {@code needsInput()} returns true. */ void addInput(Page page); /** * Gets an output page from the operator. If no output data is currently * available, return null. */ Page getOutput(); }
这个接口算是 Presto 里面比较良心的接口了, 接口设计优良、每个方法有注释、方法的含义也很好理解。
从上述接口定义可以看出 Operator
定义了一个数据处理单元,处理的数据是 Page
, 一批数据流进 Operator
,经过一系列处理逻辑,转换成新的一批数据流出到下游别的 Operator
继续处理。
Operator
数据处理的粒度是 Page, 但是不是所有的 Page 都一样,Page 里面的数据到底长什么样是靠 List<Type> getTypes()
这个方法来描述。Operator 通过 addInput
方法接收来自上游的输入,通过 getOuput
把数据吐给下游。
getOutput()
这个方法名字取得很不好,给人的感觉好像是把这个 Operator 的输出全部拿出来,它实际的作用是拿一个 Page 的输出出来,后面还会有很多输出,因此我觉得 nextOutput() 可能更表意一点。
Presto 里面的事务性数据写入
在 Presto 里面我们是可以事务性地把数据插入底层数据存储的 -- 当然,具体的事务性还是底层存储而不是 Presto 能够决定的,但是 Presto 里面提供了相应的机制以使得我们能够配合底层存储引擎一起来实现事务性的数据写入。这个机制是这样的:
Presto里面的事务性数据写入机制
这个机制本身其实不是很复杂,在写开始之前给你一个初始化的钩子(Hook), 然后你分布式地去写数据,最后给你一个集中式提交的钩子(Hook),有点两阶段提交的意味。
TableWriterOperator 这个名字起的有问题,对应的读数据的 Operator 是 TableScanOperator, 那么这个怎么也应该叫 TableWriteOperator 啊。
上面说的还是太抽象了,我们来看个具体的例子来看看,怎么通过这个”框架”来实现事务性写数据。这个例子就是 Presto 的 JDBC Connector:
JDBC Connector 在 BeginTableWrite 的时候执行了下面这段代码(BaseJdbcClient#beginWriteTable
):
String temporaryName = "tmp_presto_" + UUID.randomUUID().toString().replace("-", ""); StringBuilder sql = new StringBuilder() .append("CREATE TABLE ") .append(quoted(catalog, schema, temporaryName)) .append(" ("); ImmutableList.Builder<String> columnList = ImmutableList.builder();for (ColumnMetadata column : tableMetadata.getColumns()) { columnList.add(new StringBuilder() .append(quoted(columnName)) .append(" ") .append(toSqlType(column.getType())) .toString()); } Joiner.on(", ").appendTo(sql, columnList.build()); sql.append(")"); execute(connection, sql.toString());
可以看出这段代码建了一个临时表, 在后面的 TableWriterOperator
算子里面,数据其实是在分布式地往这个临时表里面去写。
在最后的 TableFinisherOperator
里面执行了这么一段代码(BaseJdbcClient#finishInsertTable
):
String temporaryTable = quoted(handle.getCatalogName(), getRealSchemaName(handle), handle.getTemporaryTableName()); String targetTable = quoted(handle.getCatalogName(), getRealSchemaName(handle), getRealTableName(handle)); String insertSql = format("INSERT INTO %s SELECT * FROM %s", targetTable, temporaryTable); String cleanupSql = "DROP TABLE " + temporaryTable; execute(connection, insertSql); execute(connection, cleanupSql);
从代码可以看出来,JDBC Connector 在最后把数据从 temporaryTable
插入到了 targetTable
里面去了,并且把临时表 DROP 掉了,由于最后这个操作是事务性的,因此保证了整个数据写入的事务性。
目前 Presto 的这个方案其实是有点脏的,细心的同学可能已经看出来了,上面图中的 BeginTableWrite
的形状跟其它节点的形状不一样。这是因为 BeginTableWrite 在 Presto 里面不是一个 Operator
。 它甚至不是在任务的运行时执行的,而是在编译期执行的(也就是下文中说的 planning
)。Presto 的作者在 BeginTableWrite
的文件注释里面也承认了这一点。
Major HACK alert!!!
This logic should be invoked on query start, not during planning. At that point, the token
returned by beginCreate/beginInsert should be handed down to tasks in a mapping separate
from the plan that links plan nodes to the corresponding token.
总结
不管怎么样 Presto 还是能够支持对数据的事务性插入,虽然方法有点Hack。从这一点也可以看出 Presto 对待代码的态度是实用性优先,先解决问题再说,方法好不好,是不是最正确的方案,以后再说。
作者:xumingmingv
链接:https://www.jianshu.com/p/59d322f64a28