市面上讲 Spring Boot 集成 Cassandra 的教程不少,但大多数都停在“能连上、能跑通”的层面,一旦涉及数据建模、一致性选择、真正生产环境会遇到的问题,就很少有人讲透。我这两年用 Cassandra 做了几个项目,从最初照着文档抄配置踩坑,到后来能根据业务场景独立设计表结构,中间绕了不少弯路。这篇就把我验证过的完整链路写出来,包括选型理由、环境搭建、Spring Boot 集成细节、数据建模核心逻辑、三种常用操作姿势、事务边界,以及一次真实排错过程的完整复盘。
1. 为什么把 Cassandra 放进 Spring Boot 项目里?——选型理由与适用边界
先说一个我在技术交流群里经常看到的问题:明明用 MySQL 好好的,为什么要引入 Cassandra?这个问题问得很实际,如果连“为什么选它”都想不清楚,后面无论怎么集成都是给自己挖坑。
Cassandra 本质上是一个分布式、无主节点的 NoSQL 数据库。它最大的特点是天生就是集群形态,数据自动分片到多个节点,每个节点地位平等,任何一个节点挂掉都不影响整个集群继续对外服务。这种架构和 MySQL 主从复制有本质区别,MySQL 的主从更多是“备份 + 读写分离”,而 Cassandra 从设计第一天起就是“多节点同时写、同时读”。
我用一个实际业务场景来说。之前做一个物联网设备数据上报系统,每台设备每 30 秒上报一次状态数据,一天下来单台设备就有 2880 条记录,1000 台设备一天就是接近 300 万条。用 MySQL 存也能存,但写入压力集中在单库单表上,随着设备数量增长,索引膨胀、写入锁竞争这些问题会越来越明显。Cassandra 的写入能力非常强,因为它的写入路径本质上是追加写,配合内存 memtable 和磁盘 SSTable,高并发写入场景下优势非常明显。
但 Cassandra 也有明显的短板。它不支持真正的多表关联查询,没有外键约束,事务支持非常有限。如果你现在的业务核心逻辑需要大量 join 查询、强事务保证、灵活的多条件组合查询,那 Cassandra 不适合你。我曾经见过一个团队硬把订单系统搬上 Cassandra,最后为了兼容后台复杂的统计查询,不得不在应用层做大量数据聚合,代码复杂度直线上升。
所以选型结论就三条:
- 写多读少、写入并发高的场景,优先考虑 Cassandra,比如日志采集、IoT 数据、用户行为轨迹。
- 需要水平扩展、节点故障容忍度高的场景,Cassandra 有明显优势,扩容直接加节点,数据自动 rebalance。
- 强事务、复杂 join、灵活查询为主的业务场景,别选 Cassandra,老老实实用关系型数据库。
顺便提一下它和 MongoDB 的区别。MongoDB 更适合文档型数据模型,读写性能也不错,但 Cassandra 在“多数据中心同步”和“写入线性扩展”这两个维度上做得更彻底。MongoDB 的复制集有主从之分,Cassandra 完全没有主从概念,所有节点都是对等的,这个差异在跨机房部署时体会特别深。
如果确定要用 Cassandra,接下来第一个实际的问题就是:本地环境怎么搭起来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 本地环境准备:Docker 起一个单机 Cassandra 集群
很多教程会让你去官网下载 Cassandra 的二进制包,然后解压、改配置、启动。我强烈建议本地开发直接用 Docker,原因很简单:干净、可重复、不污染系统环境。你只需要一个 docker-compose 文件,随时可以销毁重建。
下面这个配置我一直在用,基于 Cassandra 4.1 版本:
yaml复制version: "3.9"
services:
cassandra:
image: cassandra:4.1
container_name: local-cassandra
ports:
- "9042:9042"
environment:
- CASSANDRA_CLUSTER_NAME=LocalCluster
- CASSANDRA_DC=dc1
- CASSANDRA_RACK=rack1
volumes:
- cassandra-data:/var/lib/cassandra
volumes:
cassandra-data:
简单解释几个配置项的用意。CASSANDRA_DC 和 CASSANDRA_RACK 是数据中心和机架名称,4.1 版本要求必须显式指定,否则启动时会有提示信息。本地单节点不存在真正的数据中心概念,但保留这个配置是为了和后面生产环境的 local-datacenter 配置对齐,避免本地能跑、上了测试环境就报数据中心不匹配的问题。9042 端口是 Cassandra 的 CQL 原生协议端口,Spring Boot 连接时走的就是这个端口。还有一个 7000 端口是集群节点间通信用的,本地单节点不需要映射出来。
启动命令:
bash复制docker compose up -d
启动完成后,用 docker ps 确认容器状态。Cassandra 启动相对比较慢,首次启动可能需要 30 秒到 1 分钟,所以不要急着立刻连接。可以用 docker logs 看启动日志,看到 Starting listening for CQL clients on /0.0.0.0:9042 这行日志,就说明 CQL 服务已经就绪。
接下来需要创建 keyspace。keyspace 这个概念跟 MySQL 的 database 很像,是一组表的容器,同时定义了数据副本策略。进入容器用 cqlsh 执行:
bash复制docker exec -it local-cassandra cqlsh
然后执行 CQL 创建 keyspace:
sql复制CREATE KEYSPACE IF NOT EXISTS demo_keyspace
WITH replication = {
'class': 'SimpleStrategy',
'replication_factor': 1
};
这里有个关键点必须说清楚:SimpleStrategy 只适合本地开发和单节点环境。生产环境部署多节点集群时,必须使用 NetworkTopologyStrategy,并为每个数据中心配置副本数。原因很简单,SimpleStrategy 不考虑数据中心拓扑,副本可能落在同一个机架甚至同一个机房,一旦机房故障数据就全丢了。NetworkTopologyStrategy 会按照数据中心分别指定副本因子,这才是生产可用的配置。这个知识点在面试里也经常被问到,属于 Cassandra 的基础概念。
创建完 keyspace 后,先不用急着建表。Spring Boot 应用启动时,如果配置了 schema-action: create-if-not-exists,框架会自动根据实体类创建表。但你最好养成手动管理 schema 的习惯,别把表结构变更完全交给 ORM 框架,后面讲数据建模的时候我再详细说原因。
3. Spring Boot 项目集成第一课:依赖、配置与快速连通验证
环境就绪之后,开始创建 Spring Boot 项目。我用的是 Spring Boot 2.7 版本,Java 8 以上均可。如果你用的是 Spring Boot 3.x,需要注意 Jakarta 命名空间的变化,但 Cassandra 相关的核心配置方式基本不变。
依赖方面,在 pom.xml 里加入 Spring Data Cassandra 的 starter:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-cassandra</artifactId>
</dependency>
这一个坐标就够了,它会把你需要的 Cassandra 驱动、Spring Data 核心组件全部带进来。版本由 Spring Boot 的 BOM 统一管理,不需要手动指定。
然后是 application.yml 配置。这是最容易出错的一步,我把完整的配置写出来:
yaml复制spring:
data:
cassandra:
contact-points: localhost
port: 9042
local-datacenter: dc1
keyspace-name: demo_keyspace
username: cassandra
password: cassandra
schema-action: create-if-not-exists
consistency-level: local_quorum
几个配置项逐个解释。
contact-points 是 Cassandra 节点的地址列表,多个节点用逗号分隔。这里要注意一个常见误解:不是所有节点都必须配全,驱动连上任何一个节点后,会自动从系统表中获取整个集群的拓扑信息。但生产环境建议至少配置两个以上节点,避免单个节点故障时驱动完全无法建立连接。
local-datacenter 必须显式指定。Cassandra 4.x 的驱动强制要求指定本地数据中心,如果不配,启动时会直接报错。而且这个值必须和 Cassandra 节点的实际数据中心名称一致,否则连接会被拒绝。
keyspace-name 就是刚才用 CQL 创建的 demo_keyspace。
username 和 password 是认证信息。默认情况下,Docker 镜像启动的 Cassandra 会创建一个默认的 cassandra 用户,密码也是 cassandra。如果你在本地测试时不想用密码认证,可以在 docker-compose 里通过环境变量关闭认证,但我不推荐这样做,因为很容易导致开发环境与生产环境行为不一致。
schema-action 有几个枚举值:none、create-if-not-exists、create。本地开发用 create-if-not-exists 方便,实体类改了能自动同步表结构。生产环境必须设为 none,用专门的 CQL 脚本管理表结构变更,否则线上表结构被框架自动改了,出了问题你都不知道是哪里动的。
consistency-level 是读取和写入的一致性级别,后面专门讲事务的时候细说。这里先记住一个原则:本地开发用 local_quorum 不会错,生产环境再根据业务选。
配置完成后,需要写一个入口类并验证连接。先定义一个简单的实体类和 Repository,跑一个最简单的写入读取流程。
实体类:
java复制@Table("device_status")
public class DeviceStatus {
@PrimaryKeyColumn(name = "device_id", type = PrimaryKeyType.PARTITIONED)
private String deviceId;
@PrimaryKeyColumn(name = "report_time", type = PrimaryKeyType.CLUSTERED, ordering = Ordering.DESCENDING)
private LocalDateTime reportTime;
@Column("temperature")
private Double temperature;
@Column("humidity")
private Double humidity;
// 构造方法、getter/setter 省略
}
这个实体类定义了两列组成联合主键。device_id 是分区键,决定数据存储在哪个节点;report_time 是聚类键,决定同一分区内数据的排序方式。这样设计之后,同一个设备的数据会在物理上连续存储,并且按上报时间倒序排列,查询某台设备最近的状态就非常高效。
仓库接口:
java复制public interface DeviceStatusRepository extends CrudRepository<DeviceStatus, DeviceStatusKey> {
List<DeviceStatus> findByDeviceIdOrderByReportTimeDesc(String deviceId);
}
然后写一个启动时验证的 ApplicationRunner:
java复制@Component
public class CassandraStartupVerifier implements ApplicationRunner {
private final DeviceStatusRepository repository;
public CassandraStartupVerifier(DeviceStatusRepository repository) {
this.repository = repository;
}
@Override
public void run(ApplicationArguments args) {
DeviceStatus status = new DeviceStatus();
status.setDeviceId("DEVICE-001");
status.setReportTime(LocalDateTime.now());
status.setTemperature(26.5);
status.setHumidity(60.0);
repository.save(status);
List<DeviceStatus> list =
repository.findByDeviceIdOrderByReportTimeDesc("DEVICE-001");
System.out.println("查询结果数:" + list.size());
}
}
跑起来之后,控制台能看到写入和查询都成功,说明整个链路已经通了。但这里我要提醒一点:到了这步,只是迈过了第一道坎,真正的难点在数据建模。Cassandra 和关系型数据库的建模思路完全是两套逻辑,如果带着 MySQL 的惯性思维来设计 Cassandra 表,后面会踩很多坑。
4. 数据建模与类映射:主键设计才是 Cassandra 的核心
我在带团队的时候反复强调一句话:在 Cassandra 里,表结构不是根据“业务实体”设计的,而是根据“查询语句”设计的。这句话怎么理解?
MySQL 的建模思路是“实体关系优先”。先确定有哪些实体,再定义实体之间的关系,然后设计表结构。如果有新的查询需求,可以通过添加索引、调整 SQL 来解决。
Cassandra 恰恰相反。因为 Cassandra 不支持灵活的 join 和二级索引查询,所以你在建表之前,脑子里必须先把最核心的那几条查询语句写出来,然后围绕这些查询语句反推表结构。
主键是理解 Cassandra 数据分布的关键。Cassandra 的主键分为两个部分:
- 分区键(Partition Key):决定这一行数据存储在集群中的哪个节点。Cassandra 对分区键做哈希计算,然后根据哈希值把数据分布到不同节点上。
- 聚类键(Clustering Key):决定同一分区内多行数据的物理排序顺序。这个排序是 Cassandra 在写入时就确定好的,读的时候直接按顺序扫描,不需要额外的排序操作。
举个例子,还是上面那个 device_status 表。如果主键是 (device_id, report_time),那么数据分布时的单位是 device_id 对应的整个分区,即同一个设备的所有上报数据都会存储在同一个节点上。这种设计非常适合“查一台设备最近 N 条状态”的查询场景,因为数据都在同一个节点上,一次请求就能搞定,查询延迟极低。
但如果你的查询需求是“查所有设备在某个时间段内的状态”,这个表结构就不合适了。因为数据分布是按设备 ID 分的,时间条件只能在分区内部过滤,跨设备的全量时间范围查询会变成全集群扫描,性能不可接受。
这就是为什么 Cassandra 建模时必须逆向思考。先列出最核心的查询模式,再有针对性地设计分区键和聚类键。为了不同的查询需求,故意冗余多张表是非常正常的做法,这叫查询表设计模式。比如同时维护 device_status_by_time 和 device_status_by_device 两张表,分别服务于不同的查询场景,写入时同步更新。这在关系型数据库里会显得不可理喻,但 Cassandra 的设计哲学就是“数据冗余换取查询性能”。
我再用一个真实的例子加深理解。某电商平台需要记录用户登录日志,同时支持两个查询:
- 查询某个用户最近 30 天每天的登录次数。
- 查询某个时间段内所有用户的登录记录。
如果按照“一个实体一张表”的关系型思维,会设计成:
sql复制CREATE TABLE user_login_log (
user_id TEXT,
login_time TIMESTAMP,
ip_address TEXT,
PRIMARY KEY (user_id, login_time)
);
这个表只能高效支持第一个查询。第二个查询要求按 login_time 做范围扫描,但 login_time 不是分区键的一部分,Cassandra 会拒绝执行这个查询。
正确的做法是再建一张以时间为分区键的表:
sql复制CREATE TABLE user_login_log_by_day (
day TEXT,
login_time TIMESTAMP,
user_id TEXT,
ip_address TEXT,
PRIMARY KEY ((day), login_time, user_id)
);
这样按天查询所有用户登录记录,效率就非常高。虽然数据冗余了,但 Cassandra 的写入性能足够强,这种冗余的成本完全可控。
因此在实际编码中,实体类映射必须紧跟表结构设计。Spring Data Cassandra 的注解规则如下:
@Table标注实体类对应的表名。@PrimaryKeyColumn标注主键列,需要指定type为PARTITIONED还是CLUSTERED,聚类键还可以指定ordering为ASCENDING或DESCENDING。@Column标注普通列,可以指定列名,如果没有指定则默认使用字段名作为列名。
这里特别提醒一个容易踩坑的点:分区键的选择直接影响数据分布是否均匀。如果你选了一个区分度很差的字段做分区键,比如只有几个固定值,那所有数据都会堆到少数几个节点上,集群的负载严重倾斜。我之前遇到过一个问题,表的分区键选了 status 字段,取值范围只有 SUCCESS 和 FAILED 两种,结果写入量一大,只有两个节点在干活,其他节点全在闲着。这个问题在早期数据量小的时候完全看不出来,等量上来之后才发现性能急剧下降。所以分区键要高基数的字段,比如用户 ID、设备 ID、订单号,千万别用枚举值。
5. 三种数据操作姿势:CassandraTemplate、Repository 与自定义查询
Spring Data Cassandra 提供了三种操作 Cassandra 的方式,从底层到高层分别是:CassandraTemplate、继承 CrudRepository 的接口方法、基于 @Query 注解的自定义 CQL。我分别讲清楚它们的使用场景和注意事项。
5.1 CassandraTemplate:灵活操作的底层入口
CassandraTemplate 类似 JdbcTemplate,是直接操作 Cassandra 的底层模板类。当你需要执行一些不那么标准化的操作,或者不想为每个查询都定义 Repository 方法时,直接注入 CassandraTemplate 是最快的方式。
比如查询需要指定一致性级别的场景:
java复制@Service
public class DeviceStatusService {
private final CassandraTemplate cassandraTemplate;
public DeviceStatusService(CassandraTemplate cassandraTemplate) {
this.cassandraTemplate = cassandraTemplate;
}
public List<DeviceStatus> queryWithCustomConsistency(String deviceId) {
Query query = Query.query(Criteria.where("device_id").is(deviceId))
.withPageSize(100)
.setConsistencyLevel(ConsistencyLevel.ONE);
return cassandraTemplate.select(query, DeviceStatus.class);
}
}
这段代码展示了几个关键点:Criteria.where 构造查询条件,withPageSize 控制每次从 Cassandra 拉取的行数,setConsistencyLevel 单独设置本次查询的一致性级别。当某次查询对实时性要求不高、允许读取到稍旧的数据时,把一致性级别降为 ONE 可以显著降低响应延迟。
CassandraTemplate 的另一个常用场景是批量数据操作。虽然 Repository 接口也继承了 saveAll 方法,但 CassandraTemplate 的 insert 方法支持 WriteOptions,可以设置 TTL(自动过期时间)。比如用户行为日志只需要保留 30 天,可以直接在插入时设置 TTL,省去定期清扫数据的任务:
java复制WriteOptions options = WriteOptions.builder()
.ttl(Duration.ofDays(30))
.build();
cassandraTemplate.insert(status, options);
这个功能在日志类场景里太实用了,Cassandra 会自动清理过期数据,不需要额外开发定时任务。
5.2 继承 Repository:最省代码的日常用法
对于常规的增删改查,继承 CrudRepository 是最简单的方式。Spring Data 会根据方法名自动生成 CQL,这跟 Spring Data JPA 的机制很像。
但有一个重要区别需要知道:Spring Data Cassandra 的方法命名查询,只支持有限的条件组合。比如:
java复制public interface DeviceStatusRepository extends CrudRepository<DeviceStatus, DeviceStatusKey> {
List<DeviceStatus> findByDeviceId(String deviceId);
List<DeviceStatus> findByDeviceIdAndReportTimeGreaterThan(
String deviceId, LocalDateTime startTime);
List<DeviceStatus> findByDeviceIdOrderByReportTimeDesc(String deviceId);
}
这些方法命名看起来都很正常,但底层都被转换成对主键的查询。如果方法名里写了非主键列的查询条件,比如 findByTemperatureGreaterThan,Spring Data 也能生成对应的 CQL,但执行时 Cassandra 会因为没有二级索引而直接报错,数据量小可能侥幸成功,数据量一大必然出问题。
所以我的建议是:Repository 方法只用来写简单的、基于主键的查询,复杂查询一律用 @Query 自定义 CQL。
5.3 @Query 自定义 CQL:突破方法命名限制
@Query 注解允许你在方法上直接写 CQL 语句,这是处理复杂查询的正确姿势。举一个按时间范围查询的例子:
java复制public interface DeviceStatusRepository extends CrudRepository<DeviceStatus, DeviceStatusKey> {
@Query("SELECT * FROM device_status " +
"WHERE device_id = :deviceId " +
"AND report_time >= :startTime " +
"AND report_time <= :endTime")
List<DeviceStatus> findStatusInTimeRange(
@Param("deviceId") String deviceId,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
}
注意看,这个查询的条件都是主键列。device_id 是分区键,report_time 是聚类键。Cassandra 对分区键要求必须是等值查询,对聚类键可以是范围查询。这样的 CQL 能充分发挥 Cassandra 的顺序读写优势。
但如果查询条件里出现非主键列,比如按温度过滤,即使写成 CQL 也会报错。这时候有两条路:一是建二级索引,二是用 ALLOW FILTERING。二级索引在低基数场景下有一定用处,但 Cassandra 官方其实不建议频繁使用;ALLOW FILTERING 更是高危操作,它会强制 Cassandra 全分区扫描,生产环境一定慎用。
我见过一个案例,有人把 MySQL 的查询习惯带过来,写了一条带 ALLOW FILTERING 的查询,线上数据量到亿级之后,每次查询都让 CPU 飙到 100%,查一次卡几秒。后来通过调整表结构,把过滤条件纳入主键设计,彻底避免了这个性能瓶颈。
6. 实际排查过程:连接超时与鉴权配置问题复盘
这部分用一个我实际经历过的排查案例来还原完整过程。当时刚接手一个 Spring Boot 项目,启动时报错信息是 NoHostAvailableException: All host(s) tried for query failed,数据根本连不上。这类问题看起来很让人头疼,但只要思路清晰,定位并不难。
先看完整的报错栈。控制台里看到:
code复制Caused by: com.datastax.oss.driver.api.core.AllNodesFailedException:
All host(s) tried for query failed (tried: /127.0.0.1:9042 (com.datastax.oss.driver.api.core.connection.ConnectionInitException: [s0|control|connecting...] ))
这个错误的重点是 ConnectionInitException,说明驱动尝试建立连接时失败了。排查步骤按下面来做:
第一步,检查 Cassandra 节点是否真的在运行。执行 docker ps 确认容器状态。如果容器没起来,基本是启动失败,看 docker logs local-cassandra 的末尾日志找原因。常见原因是镜像首次启动时初始化较慢,还没来得及监听 9042 端口。
第二步,确认端口可达。在宿主机执行:
bash复制nc -vz localhost 9042
如果端口没有监听,说明 Cassandra 虽然容器起来了,但 CQL 服务还没就绪,等待一段时间再试。如果端口通,但应用依然报连接失败,继续往下查。
第三步,检查应用配置中的 contact-points 和端口。这个问题特别容易出在“开发环境用 localhost、测试环境用容器名”的切换上。我当时遇到的坑就是:应用跑在 Docker 容器里,配置里写的 contact-points: localhost,但这个 localhost 指的是容器自身而不是宿主机,导致连接被拒绝。
解决方案有两种。如果应用在容器中运行,contact-points 必须写宿主机在 Docker 网络中的可访问地址,比如 host.docker.internal;如果应用在宿主机运行,写 localhost 没问题。
第四步,如果端口通、地址也对,仍然报 AuthenticationException,那问题就出在用户名密码上。Cassandra 的默认用户是 cassandra,默认密码也是 cassandra。如果你改了密码,应用配置里必须同步更新。还有一个容易忽略的点:如果 Cassandra 节点启用了 PasswordAuthenticator,那么 cassandra 默认密码其实需要先登录后修改。但 Docker 镜像的初始化脚本通常已经处理了这个流程,所以直接用默认密码即可。
另外还有一类问题虽然不报连接失败,但查询时总是报错“配置的 keyspace 不存在”。排查思路是:用 docker exec -it local-cassandra cqlsh 进入 cqlsh,执行 DESCRIBE KEYSPACES; 看 keyspace 是否真的创建成功。很多时候是 CQL 创建 keyspace 时用了 IF NOT EXISTS,而实际上执行脚本时连接到了错误的节点,或者脚本没执行成功。要注意 container 重启后数据是否存在,这也依赖于 docker volume 是否配置正确。如果没挂 volume,容器一删,数据全没了,但你的应用启动时 schema-action 也不会自动重建 keyspace,因为 keyspace 是数据库层面的对象,框架只会根据实体类建表,不会替你建 keyspace。
排错到这里基本能解决九成以上的连接问题。剩下的一些疑难问题,比如驱动日志里报 ProtocolVersion 不兼容,通常是 Cassandra 版本和驱动版本不匹配导致的。Cassandra 4.x 对应 DataStax Java Driver 4.x,Spring Boot 2.7 自带的驱动版本是兼容的,尽量不要手动升级驱动大版本,除非你清楚兼容性矩阵。
我把这些常见场景整理成一个表方便对照:
| 报错现象 | 可能原因 | 排查方向 |
|---|---|---|
| Connection refused | Cassandra 未启动 / 端口未监听 | docker ps、nc -vz localhost 9042 |
| NoHostAvailableException | contact-points 配置错误 / 网络不通 | 检查 localhost 与容器网络 |
| AuthenticationException | 用户名密码错误 / 认证配置不一致 | 核对 cassandra 用户密码 |
| InvalidQueryException: keyspace not found | keyspace 未创建 / 创建后未持久化 | cqlsh DESCRIBE KEYSPACES |
| 协议版本报错 | 驱动版本与 Cassandra 大版本不匹配 | 检查 BOM 版本管理 |
7. 事务边界与一致性选择:轻量事务与可调一致性的实战理解
很多初次接触 Cassandra 的 Java 开发者会下意识地把 Spring 的 @Transactional 直接用在 Service 方法上,想当然地以为这样就能获得和 MySQL 一样的事务保证。但实际上,这个认知需要修正。
Cassandra 是 NoSQL 数据库,传统意义上的 ACID 分布式事务它并不支持。Spring Data Cassandra 中的 @Transactional 注解只在单分区操作场景下才有意义,而且它的语义不是“回滚”,而是把多条操作绑定到同一个会话中执行。如果你在一个事务里先后更新两个不同的分区,然后希望第二个操作失败时第一个操作自动回滚,Cassandra 做不到。
那在有强一致性要求的业务场景下怎么办?Cassandra 提供了轻量事务(LWT)机制。轻量事务基于 Paxos 协议实现,可以确保某个单分区操作满足 compare-and-set 语义。最常见的使用场景是“库存扣减”和“账户余额变更”。
举个例子,扣减账户余额:
java复制@Query("UPDATE account SET balance = balance - :amount " +
"WHERE user_id = :userId " +
"IF balance >= :amount")
boolean deductBalance(@Param("userId") String userId,
@Param("amount") BigDecimal amount);
这条 CQL 的 IF balance >= :amount 就是轻量事务的核心。Cassandra 会先读取当前余额,检查是否满足 balance >= :amount 这个条件,满足才执行扣减,否则不执行。返回的 boolean 值表示条件是否满足。这样即使多个请求同时扣减同一账户,也只有一个能成功,不会出现余额扣成负数的情况。
使用 LWT 时有几个性能上的注意点。LWT 的延迟比普通写入高不少,因为它需要多轮 Paxos 协商,通常要一次读 + 两次写。如果你的业务场景每秒几十万次写入,而且大量写入都触发 LWT,性能会明显下降。所以 LWT 只用于真正需要强一致的少量关键操作,而不是所有写操作都套上。
再来看可调一致性。这是 Cassandra 另一个和关系型数据库差异很大的设计。每次读写操作都可以指定一致性级别,从 ONE 到 ALL,不同级别代表不同数量的副本必须确认这次操作。
| 一致性级别 | 含义 | 适用场景 |
|---|---|---|
| ONE | 任一副本确认即可 | 日志、物联网数据,允许少量丢失 |
| QUORUM | 多数副本确认 | 账户、库存等核心数据 |
| LOCAL_ONE | 本地数据中心任一副本确认 | 多数据中心部署时降低延迟 |
| LOCAL_QUORUM | 本地数据中心多数副本确认 | 多数据中心部署时兼顾可用性和一致性 |
| ALL | 所有副本确认 | 几乎不使用,任一节点故障就不可用 |
这个表格看起来很简单,但真正难的是读写一致性的配合。比如你写入时用 QUORUM,读取时用 ONE,理论上读到的可能是旧数据。要保证“读后读”的一致,需要 W + R > RF,即写副本数加上读副本数大于副本因子。假设副本因子是 3,写用 QUORUM(需要 2 个副本确认),读用 ONE(1 个副本确认),2 + 1 = 3,这个数值等于 RF 而不是大于 RF,极端情况下仍可能读到旧数据。稳妥的做法是读写都用 LOCAL_QUORUM。
在 Spring Boot 中配置一致性级别:
yaml复制spring:
data:
cassandra:
consistency-level: local_quorum
serial-consistency-level: local_serial
serial-consistency-level 专门约束轻量事务的读取阶段,默认是 SERIAL,多数据中心场景建议配置为 LOCAL_SERIAL,这样性能更好,同时当前数据中心的强一致语义仍然成立。
最后再补充一个经验:如果你真的需要传统意义上的跨分区事务,Cassandra 不是好的选择。可以考虑用事件驱动加最终一致性的方案来替代强事务。比如订单创建和库存扣减,可以先写订单事件,再由消费者异步扣减库存。这种方案在大规模分布式系统中反而更健壮,也更符合 Cassandra 能发挥优势的架构风格。
写到最后的一点体会
把 Spring Boot 集成 Cassandra 这条路完整走一遍之后,我的一个深感触是:技术工具的复杂度从来不在 API 层面,而在数据模型和一致性模型的思维方式上。Cassandra 的 Java API 并不难,甚至可以说很简洁,难的是你能不能放下关系型数据库的那套直觉,真正从数据分布和查询路径的角度去设计表结构。我见过太多人第一版表结构用 JPA 思维设计,上线后查询性能一塌糊涂,最后不得不推倒重来。如果你正在规划一个新项目,建议动手写代码之前,先把核心查询语句列出来,再开始设计表结构。磨刀不误砍柴工,这一步值得多花时间。
