Cassandra 6 中 ACID 事务的演进之路

Lobsters Hottest 新闻

摘要

本文探讨了 Cassandra 中 ACID 事务的演进历程,直至即将到来的 Cassandra 6 版本通过 Accord 支持严格可序列化的跨分区事务。

<p><a href="https://lobste.rs/s/c9xng0/road_acid_transactions_cassandra_6">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/08/21 12:55

# Cassandra 6中的ACID事务之路 来源:https://theconsensus.dev/p/2026/08/16/transactions-in-cassandra.html 关于软件基础设施。 ## Cassandra 6中的ACID事务之路 2013年推出原子批处理,同年晚些时候引入单分区Paxos比较-交换操作,如今在未发布的Cassandra 6中,借助Accord实现了严格可串行化的跨分区事务。本文将通过一个会计工作负载在三节点集群上逐一探讨这些技术。 作者:Phil Eaton 日期:2026年8月16日 关注(https://theconsensus.dev/p/2026/08/16/transactions-in-cassandra.html#) 作为订阅者,您正在提前阅读本文。您的支持使这类文章得以发表,谢谢。 Cassandra是一个引人注目的数据系统。它是极少数支持类SQL查询语言、**内置分片**和**内置复制**的供应商中立型开源数据库之一。这种组合颇具吸引力,也使得Cassandra拥有众多(大型)用户(https://cassandra.apache.org/_/case-studies.html?utm_source=theconsensus.dev&utm_medium=referral),包括苹果、eBay、彭博和Netflix等公司。 自首次发布以来,Cassandra已经历了显著演变。从最初通过Thrift提供无事务支持、最终一致性数据模型及无模式NoSQL接口,发展到如今(即将发布的6.0版本中)成为支持ACID事务的类SQL数据库(诚然存在严重SQL限制,且事务非交互式,后文将详细说明)。 同时,由于缺乏连接操作、自动分片(以及有限的二级索引功能),一个关键特征始终保持不变:用户需要根据查询需求设计表结构。这可能导致应用程序采用数据反范式化设计,将单次写入操作分解为多次写入,以便数据库后续能高效响应不同查询。 本文将在单台机器上搭建三节点Cassandra集群,运行cassandra-6.0(https://github.com/apache/cassandra/tree/cassandra-6.0?utm_source=theconsensus.dev&utm_medium=referral)分支(预发布状态),测试Cassandra四种事务选项中的四种工作负载:无事务(默认)、BATCH(https://cassandra.apache.org/doc/6.0/cassandra/developing/cql/dml.html?utm_source=theconsensus.dev&utm_medium=referral#batch_statement)更新、轻量级事务(https://cassandra.apache.org/doc/6.0/cassandra/architecture/guarantees.html?utm_source=theconsensus.dev&utm_medium=referral#lightweight-transactions-with-linearizable-consistency)(LWT)更新以及Accord(https://cassandra.apache.org/doc/6.0/cassandra/architecture/cql-on-accord.html?utm_source=theconsensus.dev&utm_medium=referral)(即ACID)更新。Accord事务功能仅在Cassandra 6正式发布(可能在今年晚些时候)后可用,因此我们使用预发布分支进行测试。 ## 集群搭建\# (https://theconsensus.dev/p/2026/08/16/transactions-in-cassandra.html#setting-up-a-cluster) 安装Java 21和`ant`构建系统,同时为我们的并发测试运行器Monastery(https://github.com/theconsensuslabs/monastery?utm_source=theconsensus.dev&utm_medium=referral)安装gcc和Go语言。 ``` sudo apt-get install -y openjdk-21-jdk ant gcc golang git clone https://github.com/theconsensuslabs/monastery cd monastery CGO_ENABLED=1 go build -buildmode=plugin -o cql.so ./plugins/cql CGO_ENABLED=1 go build -o monastery . ``` 随后获取并构建Cassandra。 ``` git clone https://github.com/apache/cassandra cd cassandra git checkout cassandra-6.0 ant artifacts -Dcheck.skip=true -Dant.gen-doc.skip=true -Dno-javadoc=true ``` 为三个节点设置目录和配置,分配唯一的IP地址和JMX(https://docs.oracle.com/javase/8/docs/technotes/guides/jmx/index.html?utm_source=theconsensus.dev&utm_medium=referral)端口。 ``` for i in 1 2 3; do n=node$i # 运行 `killall java` 后执行此命令可清理数据目录,便于重复运行 rm -rf /etc/cassandra/$n /var/log/cassandra/$n /var/lib/cassandra mkdir -p /etc/cassandra/$n /var/log/cassandra/$n cp -r ~/cassandra/conf/* /etc/cassandra/$n/ echo " cassandra_storagedir=\"/var/lib/cassandra/$n\" JVM_OPTS=\"\$JVM_OPTS -Dcassandra.jmx.local.port=7${i}99\"" >> /etc/cassandra/$n/cassandra-env.sh echo " cluster_name: 'theconsensus-lab' listen_address: 127.0.0.$i rpc_address: 127.0.0.$i seed_provider: - class_name: org.apache.cassandra.locator.SimpleSeedProvider parameters: - seeds: \"127.0.0.1:7000\" accord: enabled: true" >> /etc/cassandra/$n/cassandra.yaml # 设置最大内存使用量 echo " -Xms4G -Xmx4G" >> /etc/cassandra/$n/jvm-server.options done ``` 现在依次启动三个节点。(使用`-R`参数允许以root权限运行。) ``` CASSANDRA_CONF=/etc/cassandra/node1 CASSANDRA_LOG_DIR=/var/log/cassandra/node1 /root/cassandra/bin/cassandra -R >> /var/log/cassandra/node1/console.log 2>&1 ``` 等待节点完全启动(最初几秒会出现连接拒绝错误,直到节点完全就绪)。最终您将看到: ``` $ /root/cassandra/bin/nodetool -p 7199 status Datacenter: datacenter1 ======================= Status=Up/Down |/ State=Normal/Leaving/Joining/Moving -- Address Load Tokens Owns (effective) Host ID Rack UN 127.0.0.1 72.73 KiB 16 100.0% 6d194555-f6eb-41d0-c000-000000000001 rack1 ``` 其中"UN"表示"在线"和"正常"状态。 接下来添加节点2。 ``` CASSANDRA_CONF=/etc/cassandra/node2 CASSANDRA_LOG_DIR=/var/log/cassandra/node2 /root/cassandra/bin/cassandra -R >> /var/log/cassandra/node2/console.log 2>&1 ``` 启动完成后,`nodetool status`输出将如下所示。 ``` $ /root/cassandra/bin/nodetool -p 7299 status Datacenter: datacenter1 ======================= Status=Up/Down |/ State=Normal/Leaving/Joining/Moving -- Address Load Tokens Owns (effective) Host ID Rack UN 127.0.0.1 74.46 KiB 16 100.0% 6d194555-f6eb-41d0-c000-000000000001 rack1 UN 127.0.0.2 79.89 KiB 16 100.0% 6d194555-f6eb-41d0-c000-000000000002 rack1 ``` 现在启动最后一个节点。 ``` CASSANDRA_CONF=/etc/cassandra/node3 CASSANDRA_LOG_DIR=/var/log/cassandra/node3 /root/cassandra/bin/cassandra -R >> /var/log/cassandra/node3/console.log 2>&1 ``` 等待其加入集群。 ``` $ /root/cassandra/bin/nodetool -p 7399 status Datacenter: datacenter1 ======================= Status=Up/Down |/ State=Normal/Leaving/Joining/Moving -- Address Load Tokens Owns (effective) Host ID Rack UN 127.0.0.1 163.14 KiB 16 64.7% 6d194555-f6eb-41d0-c000-000000000001 rack1 UN 127.0.0.2 173.47 KiB 16 59.3% 6d194555-f6eb-41d0-c000-000000000002 rack1 UN 127.0.0.3 76.7 KiB 16 76.0% 6d194555-f6eb-41d0-c000-000000000003 rack1 ``` 由于这是集群的全局视图,在任何节点上查询`nodetool status`都会得到相同结果(至少在无故障环境下)。 以下是节点2当前的视图。 ``` $ /root/cassandra/bin/nodetool -p 7299 status Datacenter: datacenter1 ======================= Status=Up/Down |/ State=Normal/Leaving/Joining/Moving -- Address Load Tokens Owns (effective) Host ID Rack UN 127.0.0.1 163.14 KiB 16 64.7% 6d194555-f6eb-41d0-c000-000000000001 rack1 UN 127.0.0.2 173.47 KiB 16 59.3% 6d194555-f6eb-41d0-c000-000000000002 rack1 UN 127.0.0.3 76.7 KiB 16 76.0% 6d194555-f6eb-41d0-c000-000000000003 rack1 ``` 让我们开始事务测试吧! ## 会计场景\# (https://theconsensus.dev/p/2026/08/16/transactions-in-cassandra.html#accounting) 假设我们有一个账户表用于追踪转账后的余额。我们将生成转账操作,并使用所有可用方法(原生Cassandra、BATCH、逐行LWT、使用LWT的条件批处理以及Accord)将其应用于Cassandra。我们将按客户ID对账户表进行分区,并按账户ID排序。 系统中只有两个账户,初始余额均为1,000。我们将让两个写入器并发地在这两个账户间生成转账。除了第一个维度(例如LWT与Accord对比),还有第二个维度:一个变体执行读-改-写操作来产生转账,另一个变体则执行盲写(不涉及读取)来产生转账。 当两个写入器并发写入时,第三个线程将并发读取并断言两个账户余额之和为2,000。 当并发写入完成且工作负载结束时,我们将断言余额总和仍为2,000。对于读-改-写型工作负载变体,我们还将断言两个账户的最终余额是已知数值(因为工作负载的执行意图是确定性的)。 还有第三个最终维度:一组工作负载将跨越分区边界(在不同客户拥有的账户间转账),另一组工作负载则不跨越分区边界(仅在同一客户拥有的账户间转账)。 表中还将包含两个`op`列,供支持条件写入(LWT和Accord)的操作用作幂等键。原生更新和非LWT批处理更新不使用幂等列并非作弊,因为它们*无法*使用这些幂等列。 具体实现细节将在后文逐步说明。 ## 原生更新\# (https://theconsensus.dev/p/2026/08/16/transactions-in-cassandra.html#plain-updates) Monastery(https://github.com/theconsensuslabs/monastery?utm_source=theconsensus.dev&utm_medium=referral)允许我们通过固定数量的客户端对数据库执行并发操作脚本。Monastery将执行脚本操作数据库。 首先定义脚本的设置部分。 ``` CREATE KEYSPACE IF NOT EXISTS lab WITH replication = {'class':'NetworkTopologyStrategy','datacenter1':3}; DROP TABLE IF EXISTS lab.accounts; CREATE TABLE lab.accounts (customer int, account_id int, balance int, PRIMARY KEY (customer, account_id)); INSERT INTO lab.accounts (customer, account_id, balance) VALUES (1, 1, 1000); INSERT INTO lab.accounts (customer, account_id, balance) VALUES (1, 2, 1000); ``` blind-plain-same.cql 由于复制因子为3且集群中仅有3个节点,实际上不会发生分片。但如果向集群添加更多节点而保持复制因子为3,则会产生实际分片效果。 接着定义并发部分,包含两个写入器和一个读取器。每个客户端将重复执行特定操作:写入器反复发送盲更新在账户间转移金额;读取器则反复尝试断言两个账户余额之和为常量。 ``` --- concurrent w1: repeat 400 as x { UPDATE lab.accounts SET balance = {x} WHERE customer = 1 AND account_id = 1; -- assert ok UPDATE lab.accounts SET balance = 2000 - {x} WHERE customer = 1 AND account_id = 2; -- assert ok } w2: repeat 400 as x { UPDATE lab.accounts SET balance = 2000 - {x} WHERE customer = 1 AND account_id = 1; -- assert ok UPDATE lab.accounts SET balance = {x} WHERE customer = 1 AND account_id = 2; -- assert ok } r1: repeat 1500 { SELECT balance FROM lab.accounts WHERE customer = 1; -- assert sum(0) = 2000 or error } ``` blind-plain-same.cql 脚本最后是验证阶段,可执行最终查询和断言。 ``` --- check: SELECT balance FROM lab.accounts WHERE customer = 1; -- assert sum(0) = 2000 ``` blind-plain-same.cql 由于两个账户完全竞争,无法预知账户1和账户2的最终余额。但整体不变量应保持:资金总额不应增减。 使用Monastery运行此脚本时,我们经常看到并发阶段出现隔离性违规(符合预期),即余额之和不等于2,000。最终断言可能成功也可能失败,若成功纯属运气而非保证。 ``` $ ./monastery cql '127.0.0.1?consistency=quorum' blind-plain-same.cql COUNT CLIENT ASSERTION GOT 939 r1 sum(0) = 2000 or error ({1847}, {1850}) +776 more f48ab59a-62cb-4061-bb0e-5883b369ef7d 939 assertion(s) failed ``` 本次运行中,并发读取器在1,500次中有939次看到不匹配的余额。但最终写入结果仍保持平衡。(由于是盲写,这比读-改-写操作更可能恢复平衡,后者会放大不一致性。) 显然原生Cassandra并不适合银行系统,至少在此特定数据模型下如此。但我们还有其他选择!接下来查看BATCH方案。 ## BATCH更新\# (https://theconsensus.dev/p/2026/08/16/transactions-in-cassandra.html#batch-updates) 批处理功能在Cassandra 1.2(https://github.com/apache/cassandra/blob/cassandra-1.2/NEWS.txt?utm_source=theconsensus.dev&utm_medium=referral)(2013年1月)中以当前形式完成,允许将多条语句合并为单分区内的单次变更操作,实现隔离且原子化的执行。若批处理跨越多个分区,还能保证语句最终应用(即使发生节点故障)。 批处理中的所有语句共享相同时间戳,而正常情况下每条语句拥有独立时间戳。冲突解决基于单元格时间戳而非行级时间戳。因此当两个冲突批处理携带不同时间戳时,时间戳较晚的批处理会赢得所有双方写入的单元格,但列值不会混入其他批处理的结果。由于时间戳由客户端生成,可能出现平局情况。Cassandra通过保留较大值解决单元格级时间戳冲突,因此两个时间戳相同的批处理可能各自赢得部分列而失去其他列。下文将演示这种情况。 如果将`blind-plain-same.cql`中的更新包裹在BATCH中,实际上会达到一致状态。 ``` CREATE KEYSPACE IF NOT EXISTS lab WITH replication = {'class':'NetworkTopologyStrategy','datacenter1':3}; DROP TABLE IF EXISTS lab.accounts; CREATE TABLE lab.accounts (customer int, account_id int, balance int, PRIMARY KEY (customer, account_id)); INSERT INTO lab.accounts (customer, account_id, balance) VALUES (1, 1, 1000); INSERT INTO lab.accounts (customer, account_id, balance) VALUES (1, 2, 1000); --- concurrent w1: repeat 400 as x { BEGIN BATCH \ UPDATE lab.accounts SET balance = {x} WHERE customer = 1 AND account_id = 1; \ UPDATE lab.accounts SET balance = 2000 - {x} WHERE customer = 1 AND account_id = 2; \ APPLY BATCH; } w2: repeat 400 as x { BEGIN BATCH \ UPDATE lab.accounts SET balance = 2000 - {x} WHERE customer = 1 AND account_id = 1; \ UPDATE lab.accounts SET balance = {x} WHERE customer = 1 AND account_id = 2; \ APPLY BATCH; } r1: repeat 1500 { SELECT balance FROM lab.accounts WHERE customer = 1; -- assert sum(0) = 2000 } --- check: SELECT balance FROM lab.accounts WHERE customer = 1; -- assert sum(0) = 2000 ``` blind-batch-same.cql 执行测试。 ``` $ ./monastery cql '127.0.0.1?consistency=quorum' blind-batch-same.cql no assertion failures a98088c0-5aec-421a-8ef7-10e15642b243 ``` 结果很好!至少在单分区内效果显著。 通常如此,多数运行都会像这样顺利。但多次运行后,偶尔会出现总和异常的情况。 ``` $ ./monastery cql '127.0.0.1?consistency=quorum' blind-batch-same.cql COUNT CLIENT ASSERTION GOT 6 r1 sum(0) = 2000 ({1897}, {1893}) +3 more 252c3bcc-35b0-4586-9741-239abf1b577c 6 assertion(s) failed ``` 观察两个余额:1,897和1,893,总和3,790,远超2,000。出现两个

相似文章

Calvin - 确定性、分布式ACID事务 (2020)

Lobsters Hottest

解释了Calvin协议,该协议使用确定性锁来实现分布式ACID事务,无需两阶段提交(2PC),相比传统方法提高了可扩展性并减少了争用。

apache/cassandra

GitHub Trending (daily)

Apache Cassandra 是一个高度可扩展的分区行存储数据库,通过其 Cassandra 查询语言(CQL)自动在机器间分布数据。

在 TLA+ 中扩展 MVCC 以实现可串行化 (2024)

Lobsters Hottest

这篇博客文章讨论了如何使用 TLA+ 形式化建模将多版本并发控制(MVCC)扩展以实现可串行化隔离,这项工作建立在先前工作的基础上,并引用了 Cahill、Röhm 和 Fekete 的研究。