Cassandra 6 中 ACID 事务的演进之路
摘要
本文探讨了 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)
解释了Calvin协议,该协议使用确定性锁来实现分布式ACID事务,无需两阶段提交(2PC),相比传统方法提高了可扩展性并减少了争用。
apache/cassandra
Apache Cassandra 是一个高度可扩展的分区行存储数据库,通过其 Cassandra 查询语言(CQL)自动在机器间分布数据。
@msimoni: 我一直在思考的一件事:以类似S3的对象存储为原语,你可以构建一个具有无限吞吐量的事务数据库…
一条推文讨论了使用类似S3的对象存储和内容寻址构建具有无限吞吐量的事务数据库的想法,其中块被并行写入,根哈希定期更新。
在 TLA+ 中扩展 MVCC 以实现可串行化 (2024)
这篇博客文章讨论了如何使用 TLA+ 形式化建模将多版本并发控制(MVCC)扩展以实现可串行化隔离,这项工作建立在先前工作的基础上,并引用了 Cahill、Röhm 和 Fekete 的研究。
@vlad_mihalcea:JTA 事务类型初学者指南 https://vladmihalcea.com/jta-transaction-type/…
Vlad Mihalcea 解释了 JTA 事务类型的工作原理,包括 2PC 协议,以及如何在 Spring 中使用 JTA 来跨越多个数据源(如 PostgreSQL 和 Ehcache)进行全局事务。