介绍
并发控制在数据库管理系统中至关重要,以确保多个用户对共享数据的一致且安全的访问。关系数据库(RDBMS),例如MySQL(InnoDB)[1]和分析数据库(例如数据仓库),已经提供了强大的并发控制机制来有效地处理此机制。随着数据的规模和复杂性的增长,管理并发访问变得更具挑战性,尤其是在大型分布式系统(如数据湖)[2]中,预计将处理分析领域中的不同类型的工作负载。尽管由于缺乏存储引擎[3]和ACID保证,数据湖传统上一直在并发操作中挣扎,但Lakehouse架构具有带有Apache Hudi,Apache Iceberg和Delta Lake等开放式格式的体系结构,从某些广泛使用的并发控制方法中汲取灵感高并发工作负载。
该博客进入并发控制的基本原理,探讨了为什么它对Lakehouse的必不可少的,并研究了诸如Apache Hudi之类的开放式格式如何实现强大的并发控制机制来维护ACID特性并处理各种工作量。
并发控制基础
并发控制的核心是隔离和序列化性的概念,它定义了并发操作的预期行为,并确ACID中的“ I” 。让我们从一般数据库系统的角度快速讨论这些概念。
隔离和序列化
在事务系统中,隔离确保每个交易独立于其他交易,就好像是在单用户环境中执行的一样。这意味着事务应该是“全部”,不受其他并发操作的干扰,防止并发异常(如肮脏的读取或丢失更新)。这种隔离使最终用户(例如开发人员或分析师)能够理解事务的影响,而不必担心其他同时操作的冲突。
序列化性通过定义并发事务的正确执行顺序进一步实现了这个想法。它可以保证执行事务的结果同时将与串行执行,一个接一个地执行。换句话说,即使事务交错,它们的综合效果也应该看起来似乎根本没有平行执行。因此,序列化性是一个严格的正确性标准,该标准是数据库中努力执行的并发控制模型,为事务工作负载提供了可预测的环境。
例如,想象一个在线音乐会票务系统,其中多个客户试图同时购买同一音乐会的门票。假设剩下5张门票,两个客户 - 客户A和客户B尝试同时购买3张门票。如果没有适当的并发控制,这些事务可能会互相干扰,从而导致场景比库存中可用的门票“出售”更多,从而导致不一致。为了维持序列化,系统必须确保处理这些事务的结果同时与一次(串行)处理的交易相同,即出售不超过5张票,以确保库存一致性。
并发控制方法可以广泛地分为三种方法:悲观的并发控制,乐观的并发控制和多次并发控制(MVCC)。
悲观的并发控制(2PL)
悲观的并发控制假设交易之间的冲突经常发生,并且首先避免遇到“问题”。最常用的方法是严格的两相锁定(2PL),以这种方式起作用:
o 交易在阅读数据之前先获得共享锁,并在撰写本文之前获得独家锁定。 o 锁一直持有锁,直到交易提交或中止,但在提交命令执行后立即发布,以确保序列化。
如果我们以在线音乐会票务系统示例为例,我们剩下5张门票,客户A和客户B都尝试同时购买3张门票。通过严格的两阶段锁定(2PL),Transaction T1(客户A的购买)获得了库存的独家锁定,从而防止T2(客户B的购买)访问交易,直到T1完成为止。 T1检查库存,扣除客户A的3张门票,将计数减少到2,然后释放锁。只有这样,T2才能进行,锁定库存,查看已更新的2张门票,并完成对客户的购买。这可以通过锁定通过隔离交易来确保序列化,从而产生相同的结果,就好像交易互相运行。
虽然严格的2PL保证正确性,但它带有一些缺点:
o 等待收购锁的交易可能会在长时间内被阻塞,尤其是在高态场景中,导致吞吐量减少。 o 如果两项交易在不同的资源上锁定并等待彼此释放它们,则会发生僵局,需要干预(例如,通过中止一笔交易)。 o 严格的正确性要求可能会导致较长的交易时间,从而使其不适合高频率工作负载。
严格的2PL存在于关系数据库系统(例如PostgreSQL和Oracle数据库)中。
乐观的并发控制(OCC)
乐观的并发控制采用了相反的方法 - 假设冲突很少发生,如果存在这种情况,那么它将在冲突时处理。 OCC以这种方式工作:
o 交易跟踪读写操作,并在完成后验证这些更改以检查冲突。 o 如果检测到冲突,一项或多项冲突的交易将被撤回,可以在需要时重新进行。
OCC在低并发环境中尤其有效,因为交易之间的冲突很少。但是,在频繁冲突的情况下,例如试图修改相同数据的多个交易,OCC可能会导致大量回滚,从而降低其效率。它允许多次事务而无需锁定的能力使其成为竞争较低且吞吐量优先于严格阻止机制的工作负载的理想选择。
在我们的示例中,通过OCC,这两项交易都将继续,每次读取5张门票的初始计数并准备扣除3。结果,一项交易(例如,客户B)被退回,使客户可以完成购买,将库存减少到2个。客户B然后重新恢复,仅查看2张门票,并进行了相应的调整。
多次并发控制(MVCC)
MVCC通过维护每个数据项的多个版本来启用并发事务,从而允许事务读取在特定时间点出现的数据。这是MVCC高级工作的方式:
o 每个事务都分为“读取集”和“写入集”。读写集的这种分离可以通过减少冲突来增强并发。 o 事务中的所有读取都好像他们在特定时刻访问数据的单个“快照”一样。 o 将写入就像是“后来快照”的一部分一样,确保事务所做的任何更改都与其他并发事务隔离,直到事务完成为止。
在我们的示例中,使用MVCC,每个客户都可以看到5张门票的一致快照。客户A首先完成购买,将库存减少到2张门票。当客户B完成时,他们会根据最新的快照进行交易,仅剩下2张门票并相应地调整购买。
开放式格式的并发控制
数据湖是用于可扩展存储,更便宜的成本,并解决数据仓库(例如处理多样的数据类型)的某些局限性,但它们缺乏执行酸保证所需的交易存储引擎。我们在上一节中了解到,隔离(ACID中的“I”)如何通过确保每个交易独立运作而不会出现其他人的意外干扰,在管理并发方面发挥关键作用。这种隔离水平对于防止并发异常,例如肮脏的读取,丢失的更新以及其他可能损害数据完整性的问题。具有开放表格式的Data Lakehouse体系结构,例如Apache Hudi,Apache Iceberg和Delta Lake作为存储层的基础,通过应用数据库系统中可用的一些并发控制方法来解决此问题。
让我们看一下这些格式中可以使用哪种类型的并发控制方法,重点是Apache Hudi 。
Apache Hudi
如今,Lakehouse表格式的大多数并发控制实现都集中在乐观地处理冲突上。 OCC依赖于冲突很少见的假设,使其适用于简单,附加的作业,但不足以进行需要频繁更新或删除的方案。在OCC中,每个作业通常都采用表级锁定,以通过确定是否存在多个作业影响的重叠文件来检查冲突。如果检测到冲突,该工作将完全中止其运作。这可能是某些类型的工作负载的问题。例如,每30分钟编写数据的摄入作业和每两个小时运行一次的删除作业通常会发生冲突,从而导致删除作业失败。在这种情况下,尤其是在长期运行的交易中,OCC是有问题的,因为冲突的机会随着时间的推移而增加。
Apache Hudi的独特性在于,它清楚地区分了与格式相互作用的不同参与者,即写流程(该问题用户的UpSerts/deletes),表服务(例如聚簇,压缩)和读者(执行查询和读取数据) 。 Hudi提供了所有三种过程之间的快照隔离[4],这意味着它们都在表的一致快照上运行。对于写入者来说,Hudi实现了可序列化快照隔离(SSI)[5]的变体。这是Hudi支持不同类型的并发控制方法的方式,提供对并发数据访问和更新的细粒度控制。
OCC(多写入)
OCC主要用于管理Hudi中的并发作者流程。例如,两个不同的Spark作业与同一HUDI表进行交互以执行更新。 Hudi的OCC工作流程涉及一系列检查和处理冲突的检查,以确保在任何给定时间都可以成功地对特定文件组进行更改。这是关于文件组和切片在Hudi中的含义的快速摘要。
文件组:分组基本文件的多个版本(例如Parquet)。文件组由文件ID唯一标识。每个版本都对应于该文件的时间戳记录更新到文件中的记录。
文件切片:可以将文件组进一步分为多个切片。文件组中的每个文件切片都是由创建它的提交时间戳唯一标识的。
OCC分为三个阶段 - 阅读,验证和写如。当写入者开始进行交易时,它首先进行了更改,即孤立地提交。在验证阶段,作者将其提议的更改与时间表中的现有文件组进行比较,以检测冲突。最后,在写入阶段,如果发现冲突未发现冲突,则进行更改要么进行。
对于多写入的方案,当写入端开始提交过程时,它将从锁定提供商中获取短期锁定,通常使用Zookeeper,Hive Metastore或DynamoDB进行外部服务实现。确保锁定后,写入端将加载当前的时间表,[6]以检查目标文件组上先前completed
操作。之后,它扫描了标记为时间戳大于目标文件切片时间戳的任何实例。如果找到任何此类完成的实例,则表明另一个写入端已经修改了目标文件组,导致冲突。在这种情况下,Hudi的OCC逻辑可以通过中止写入端的操作来防止当前事务进行,从而确保仅提交一个写入端的更新。如果不存在冲突的瞬间,则允许进行交易,而写入端完成了写操作,并将新文件切片添加到时间表中。最后,Hudi使用新文件切片的位置更新时间表并发布表锁,从而可以进行其他交易。这种方法遵守提供一致性保证的I原则。
重要的是要注意,Hudi仅在临界点(例如在提交期间或安排表服务时)而不是在整个交易中获取锁。这种方法通过允许写入端在没有争议的情况下并行工作,从而显着改善了并发。
此外,Hudi的OCC在文件级别运行,这意味着基于修改的文件检测并解决了冲突。例如,当两个写入端在非重叠文件上工作时,允许两个写入能够成功。但是,如果他们的操作重叠并修改了相同的文件集,则只有一个交易将成功,另一笔交易将被回滚。在许多实际情况下,此文件级粒度是一个重要的优势,因为它使多个作者只要在处理不同的文件,改善并发和整体吞吐量就可以进行无问题的过程。
MVCC(写入表服务和表服务)
Apache Hudi为写入端和表服务(例如,更新Spark作业和聚类[7])以及不同的表服务(例如压缩[8]和聚类[9])之间的多元相关控制(MVCC)提供了支持。与OCC相似,HUDI时间轴在Hudi的MVCC实施中发挥了作用,该实现可以跟踪在特定的Hudi表中发生的所有事件(即时)。每个写入端和读者都依靠文件系统的状态来决定在哪里执行操作,从而提供读写隔离。
当写操作开始时,Hudi将操作标记为时间轴上的requested
或inflight
上,从而使所有流程都意识到正在进行的操作。这样可以确保表诸如压缩和聚类之类的表管理操作知道活动的写入,并且不包括当前正在修改的文件切片。随着HUDI 1.0的新时间表[10]设计,现在基于请求和完成操作时间的压缩和聚类操作,将这些时间戳视为动态确定文件切片的间隔。这意味着像压缩这样的服务不再需要阻止正在进行的写入,并且可以在任何瞬间安排而不会干扰主动操作。
在新设计下,文件切片仅包括那些在压实或聚类过程开始之前完成时间的文件切片。这种智能切片机制可确保这些表管理服务仅在最终数据上起作用,无缝地写下而不会影响压缩的基本文件。通过将表服务的调度与活动写入分解,HUDI 1.0消除了对严格的调度序列或阻止行为的需求。
非阻滞并发控制(多写入)
从一般的意义上讲,非阻滞并发控制(NBCC)允许多个交易可以同时进行而无需锁定,减少延迟并改善高电流环境中的吞吐量。 HUDI 1.0[11]引入了新的并发模式, NON_BLOCKING_CONCURRENCY_CONTROL
,与OCC不同的是,多个写入端可以同时在同一表上与非阻塞冲突解决方案一起操作。这种方法消除了对序列化写入的明确锁的必要性,从而实现了更高的并发性。 NBCC并没有要求每个写入端等待,而是允许并发写入继续进行,因此非常适合需要更快的数据摄入的实时应用程序。
在NBCC中,唯一需要的锁是将提交元数据写入HUDI时间轴,这确保了准确跟踪完成交易的顺序和状态。随着版本1.0的发布,Hudi在时间轴上介绍了即时时间的Truetime[12]语义,从而确保了独特且单调地增加即时值。 Hudi时间轴上的每个操作现在都包含请求的时间和完成时间,从而使这些动作被视为间隔。这可以通过在这些时间间隔内推理重叠动作来进行更精确的冲突检测。 NBCC中写入的最终序列化取决于完成时间。这意味着多个作家可以修改同一文件组,并通过查询读取器和压实器自动解决冲突。 NBCC可提供新的HUDI 1.0版本,从而提供了更多的控件,即使在重大的并发工作负载下,也可以平衡速度与数据一致性。
Hudi中的并发控制部署模式
Hudi提供了几种部署模型来满足不同的并发需求,从而使用户可以根据要求优化性能,简单性或高频率方案。
带有内联表服务的单写入
在此模型中,只有一个写入端可以处理数据摄入或更新,并在每次写入后顺序运行表格服务(例如清理,压缩和聚簇)。这种方法消除了对并发控制的需求,因为所有操作都在一个过程中发生。 Hudi中的MVCC保证读者可以看到一致的快照,将其隔离在正在进行的写入端和表服务中。该模型是直接用例的理想选择,在这种情况下,重点是将数据纳入Lakehouse而没有管理多个写入端的复杂性。
单一写入与异步表服务
对于需要更高吞吐量的工作负载而不会阻止写入端,Hudi支持异步表服务。在此模型中,单个写入端不断摄入数据,而表服务(例如压缩和聚簇)在同一过程中不同步运行。 MVCC允许这些背景作业与摄入同时运作而不会产生冲突,因为它们协调以避免冲突条件。该模型适合摄入速度至关重要的应用程序,因为异步服务有助于在背景中优化表,从而降低了操作复杂性而无需外部编排。
多写入端配置
如果多个写入端作业需要访问同一张表,则HUDI支持多写入端的设置。该模型允许不同的过程,例如多个摄入端或摄入和单独的表服务作业的混合物,以同时写入。为了管理冲突,Hudi使用occ与文件级别的冲突解决方案,允许通过仅允许一个成功来解决冲突写入时进行不重叠的写入。对于这些类型的多写入端设置,需要使用Amazon DynamoDB,Zookeeper或Hive Metastore(例如协调并发访问)的外部[13]锁定提供商。此设置非常适合生产级别的高频率环境,在这些环境中,需要同时修改表的不同过程。
请注意,虽然Hudi提供了OCC来与多个写入端打交道,但如果表服务与写入端在相同的过程中运行,则表面服务仍然可以异步运行,而无需锁。这是因为Hudi明智地区分了与表相互作用的不同类型的参与者(写入端,表服务)。
需要设置以下属性以用锁激活OCC。
hoodie.write.concurrency.mode=optimistic_concurrency_control
hoodie.write.lock.provider=
hoodie.cleaner.policy.failed.writes=LAZY
Hoodie.write.lock.provider
定义了管理锁定锁定的锁定提供商类。默认为 org.apache.hudi.client.transaction.lock.ZookeeperBasedLockProvider
LAZY
模式清理仅在清理服务运行时进行心跳超时后才写入,并在使用多个写入端时建议使用。
Apache Hudi和Apache Spark如何使用OCC
这是一个简单的示例,我们通过将hoodie.write.concurrency.mode
设置为 optimistic_concurrency_control
。我们还指定了一个锁定提供商(在这种情况下为Zookeeper)来管理并发访问,以及必需的表选项,例如预防效果字段,记录键和分区路径。
from pyspark.sql import SparkSession
# Initialize Spark session
spark = SparkSession.builder \
.appName("Hudi Example with OCC") \
.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") \
.getOrCreate()
# Sample DataFrame
inputDF = spark.createDataFrame([
(1, "2024-11-19 10:00:00", "A", "partition1"),
(2, "2024-11-19 10:05:00", "B", "partition1")
], ["uuid", "ts", "value", "partitionpath"])
tableName = "my_hudi_table"
basePath = "s3://path-to-your-hudi-table"
# Write DataFrame to Hudi with OCC and Zookeeper lock provider
inputDF.write.format("hudi") \
.option("hoodie.datasource.write.precombine.field", "ts") \
.option("hoodie.cleaner.policy.failed.writes", "LAZY") \
.option("hoodie.write.concurrency.mode", "optimistic_concurrency_control") \
.option("hoodie.write.lock.provider", "org.apache.hudi.client.transaction.lock.ZookeeperBasedLockProvider") \
.option("hoodie.write.lock.zookeeper.url", "zk-cs.hudi-infra.svc.cluster.local") \
.option("hoodie.write.lock.zookeeper.port", "2181") \
.option("hoodie.write.lock.zookeeper.base_path", "/test") \
.option("hoodie.datasource.write.recordkey.field", "uuid") \
.option("hoodie.datasource.write.partitionpath.field", "partitionpath") \
.option("hoodie.table.name", tableName) \
.mode("overwrite") \
.save(basePath)
spark.stop()
Apache Iceberg
Apache Iceberg通过乐观的并发控制(OCC)支持多个并发。这里要注意的最重要的部分是Iceberg需要目录组件来遵守I保证。每个写入端都认为这是唯一进行更改的人,为其操作生成了新的表元数据。当写入端完成更新时,它试图通过在目录中执行最新的metadata.json
文件的原子交换来提交更改,从而用新的元数据替换了现有的元数据文件。
如果此原子交换失败(由于另一位写入端在同时进行了变化),则写入端的提交被拒绝。然后,写入端通过根据表的最新状态创建新的元数据树来重新检验整个过程,并再次尝试原子交换。
当涉及到表维护任务(例如优化(例如压缩)或大型删除作业)时,Iceberg将其按照常规写作对待。这些操作可能与摄入工作重叠,但它们遵循相同的OCC原则 - 通过基于最新的表状态重试解决冲突。建议用户在官方维护期间安排此类工作以避免争夺,因为由于冲突而频繁的恢复会影响性能。
Delta Lake
Delta Lake通过乐观的并发控制(OCC)提供并发控制,以提供写入端之间的事务保证。 OCC允许多个写入端在不频繁的情况下独立尝试更改。当写入端试图提交时,它会检查交易日志[14]中其他交易的任何相互冲突的更新。如果发现冲突,则交易会回滚,并根据最新版本的数据进行重新验证。
此外,Delta Lake在文件系统中采用多反转并发控制(MVCC),以将读取与写入分开。通过保持数据对象和交易日志不变,MVCC允许读者访问数据的一致快照,即使添加了新写入。这不仅可以保护现有数据在并发交易期间的修改中,还可以启用时间旅行查询,从而使用户可以查询历史快照。
结论
并发控制对于开放的Lakehouse架构至关重要,尤其是当体系结构具有多个并发管道与同一表相互作用时。诸如Apache Hudi之类的开放式格式将从传统数据库系统从传统数据库系统带入Lakehouse体系结构,以处理这些操作,同时保持数据一致性和可扩展性。 Apache Hudi的独特设计是区分写入端,表服务和读取端,可确保在所有三个过程中的快照隔离。通过支持多种并发控制方法,例如用于管理写入端冲突的OCC,用于隔离表服务和写入端的MVCC以及用于非阻滞,实时摄入的新颖的NBCC,Hudi可以通过复杂的工作负载提供更大的灵活性。
引用链接
[1]
MySQL(InnoDB):https://dev.mysql.com/doc/refman/5.7/en/innodb-locking-transaction-model.html[2]
):https://hudi.apache.org/blog/2024/07/11/what-is-a-data-lakehouse/[3]
存储引擎:https://hudi.apache.org/docs/hudi_stack#storage-engine[4]
快照隔离:https://en.wikipedia.org/wiki/Snapshot_isolation[5]
快照隔离(SSI):https://distributed-computing-musings.com/2022/02/transactions-serializable-snapshot-isolation/[6]
当前的时间表,:https://hudi.apache.org/docs/next/timeline[7]
聚类:https://hudi.apache.org/docs/clustering[8]
压缩:https://hudi.apache.org/docs/compaction[9]
聚类:https://hudi.apache.org/docs/clustering[10]
时间表:https://hudi.apache.org/docs/timeline/[11]
HUDI 1.0:https://hudi.apache.org/blog/2024/12/16/announcing-hudi-1-0-0[12]
Truetime:https://hudi.apache.org/docs/timeline#truetime-generation[13]
外部:https://hudi.apache.org/docs/concurrency_control#external-locking-and-lock-providers[14]
交易日志:https://www.databricks.com/blog/2019/08/21/diving-into-delta-lake-unpacking-the-transaction-log.html