---
name: cassandra
title: Cassandra 核心架构与设计原理
date: 2026-04-26
---

我们知道 RocksDB 本质上就是一个单机的基于本地磁盘的 key-value 数据系统，而今天要介绍的 Cassandra 简单来说就是一个分布式的 RocksDB，支持超级大的容量扩展，但是请记住它本质上还是一个 key-value 系统，所以它的适用范围就是：你知道一个 key，要找它的 value 这种场景。

RocksDB 的核心设计是 LSM-Tree：WAL、MemTable、SSTable、Compaction 等，到 Cassandra 这里，这些概念都是类似的，Cassandra 的单节点存储层本质上就是同一套 LSM-Tree 架构。

不过 Cassandra 真正有意思的地方不在存储层，而在它上面那一整层分布式的设计：数据怎么分散到多台机器上、节点挂了怎么办、多个节点同时写入怎么协调等，这些问题是它的核心关注点。如果你想理解分布式存储系统是怎么工作的，Cassandra 是一个非常好的学习样本。

本文主要介绍 Cassandra 的核心设计思路、适用场景和局限性。有基本的数据库知识就够了，如果读过 RocksDB 那两篇文章，理解起来会更轻松一些，因为存储层的很多概念是共通的，没读过其实也没多大关系，本文会再次介绍，只是可能没有那么深入。

## Cassandra 是什么

Apache Cassandra 是一个开源的分布式 NoSQL 数据库。它最早由 Facebook 开发，用来解决 Inbox Search（收件箱搜索）的问题，2008 年开源，后来成为 Apache 的顶级项目。如今，Apple、Netflix、Instagram、Discord 等公司都在大规模使用它。

Apple 据说运行着超过 15 万个 Cassandra 节点，存储了几百 PB 的数据，这是好几年前的数据了。Netflix 用 Cassandra 来支撑用户画像、观看历史等核心服务。Discord 用它来存储消息数据——每天几十亿条消息的写入量。

Cassandra 的设计受到了两篇经典论文的深刻影响：

- Google 的 Bigtable 论文：Cassandra 的存储层（MemTable、SSTable、Compaction）直接借鉴了 Bigtable 的设计，这也是为什么 RocksDB 里的那些概念在 Cassandra 里又出现了，因为 RocksDB 是 LevelDB 的优化版本，而 LevelDB 可以理解为 Google 开源的单点的 Bigtable 系统
- Amazon 的 Dynamo 论文：Cassandra 的分布式层（一致性哈希、去中心化架构、可调一致性）借鉴了 Dynamo 的设计

简单来说，Cassandra = Dynamo 的分布式架构 + Bigtable 的存储模型。这个组合非常强大，使得 Cassandra 既有优秀的分布式能力，又有高效的存储性能。

## 快速上手

### 安装与启动

用 Docker 是最快的方式：

```bash
docker run -d --name cassandra -p 9042:9042 cassandra:5.0
```

等大约 30 秒让 Cassandra 启动完成，然后连接客户端：

```bash
docker exec -it cassandra cqlsh
```

连上之后，你会看到一个交互式命令行。Cassandra 的查询语言叫 CQL（Cassandra Query Language），语法和 SQL 很像，但能力上有不少限制（后面会说）。

![image-20260426112017680](https://assets.javadoop.com/imgs/20510079/cassandra/cqlsh.png)

### Keyspace 和建表

Cassandra 的数据层次是这样的：Keyspace → Table → Row → Column。Keyspace 相当于 MySQL 里的 Database，是最顶层的命名空间。

先创建一个 Keyspace：

```sql
CREATE KEYSPACE my_app
WITH replication = {
  'class': 'SimpleStrategy',
  'replication_factor': 1
};

USE my_app;
```

`replication_factor: 1` 表示数据只存一份（单机测试用）。生产环境你可能通常会设为 3，表示每份数据存 3 个副本。

然后建表：

```sql
CREATE TABLE users (
    user_id    UUID,
    name       TEXT,
    age        INT,
    city       TEXT,
    email      TEXT,
    created_at TIMESTAMP,
    PRIMARY KEY (user_id)
);
```

到这里你可能会觉得：这不就是 SQL 吗？别急，看起来像 SQL，但底层的行为有很大的区别，因为我一开始就说了 Cassandra 本质上还是一个 KV 系统，它不可能会有关系型数据库那么丰富的功能。我们后面会详细说 PRIMARY KEY 在 Cassandra 里到底意味着什么。

### 插入与查询

```sql
-- 插入数据
INSERT INTO users (user_id, name, age, city, email, created_at)
VALUES (uuid(), '张三', 28, '北京', 'zhangsan@example.com', toTimestamp(now()));

INSERT INTO users (user_id, name, age, city, email, created_at)
VALUES (uuid(), '李四', 35, '上海', 'lisi@example.com', toTimestamp(now()));

-- 查询
SELECT * FROM users;
```

![image-20260426112219388](https://assets.javadoop.com/imgs/20510079/cassandra/query-demo.png)

写到这里，和 MySQL 几乎没区别。但如果你试一下这个：

```sql
SELECT * FROM users WHERE city = '北京';
```

你会得到一个报错：

![image-20260426112430143](https://assets.javadoop.com/imgs/20510079/cassandra/query-error.png)

```
InvalidRequest: Error from server: code=2200 [Invalid query] message="Cannot execute this query as it might involve data filtering and thus may have unpredictable performance. If you want to execute this query despite the performance unpredictability, use ALLOW FILTERING"
```

这就是 Cassandra 和关系型数据库最大的区别之一——你不能随意地按任意列进行过滤查询。Cassandra 要求查询必须按照特定的模式走，具体来说就是必须走 Partition Key（注意：是 Partition Key，不是 Primary Key）。

## 数据模型：理解 Primary Key

Primary Key 是 Cassandra 数据模型中最核心的概念，Primary Key 决定了数据怎么分布、怎么排序、怎么查询。

### Partition Key 和 Clustering Key

Primary Key 由两部分组成：Partition Key 和 Clustering Key。Partition Key 决定数据存在哪个节点上，Clustering Key 决定数据在这个节点上按什么顺序排列。

我们来看几个例子，从简单到复杂：

**最简单的情况——只有 Partition Key**：

```sql
CREATE TABLE users (
    user_id UUID,
    name TEXT,
    ...
    PRIMARY KEY (user_id)
);
```

这里 `user_id` 就是 Partition Key，没有 Clustering Key。每个 `user_id` 的数据被哈希到某个节点上，一个 `user_id` 只对应一行数据。

**有 Clustering Key 的情况**：

```sql
CREATE TABLE user_events (
    user_id    UUID,
    event_time TIMESTAMP,
    event_type TEXT,
    detail     TEXT,
    PRIMARY KEY (user_id, event_time)
);
```

这里 user_id 是 Partition Key，event_time 是 Clustering Key。同一个 user_id 的所有事件会被存储在同一个节点上，并且按 event_time 排序。

同一个用户的所有事件在物理上是连续存储的，而且按时间排好序了。所以查询"用户 A 最近 7 天的事件"会非常快——Cassandra 只需要去一个节点上，做一次顺序读就行了。

**复合 Partition Key**：

```sql
CREATE TABLE sensor_data (
    sensor_id  TEXT,
    date       DATE,
    time       TIME,
    value      DOUBLE,
    PRIMARY KEY ((sensor_id, date), time)
);
```

注意这里 `(sensor_id, date)` 外面多了一层括号，表示 sensor_id 和 date 一起组成 Partition Key。这样同一个传感器同一天的数据会被分到同一个分区，按 time 排序。

为什么要这么做？如果只用 sensor_id 做 Partition Key，一个传感器几年下来可能积累了几百万条数据，全部堆在一个分区里会形成"大分区"，影响性能。加上 date 之后，每个分区只有一天的数据，大小可控。

### 查询必须从 Partition Key 出发

现在回到前面那个报错的问题。为什么 `SELECT * FROM users WHERE city = '北京'` 会失败？

因为 Cassandra 的数据是按 Partition Key 分散到不同节点上的。如果查询条件里没有 Partition Key，Cassandra 就不知道该去哪个节点找数据，只能扫描所有节点的所有数据——这在分布式环境下开销巨大，所以 Cassandra 默认禁止这种查询。

合法的查询模式是这样的：

```sql
-- 指定 Partition Key（必须）
SELECT * FROM user_events WHERE user_id = ?;

-- 指定 Partition Key + Clustering Key 范围
SELECT * FROM user_events WHERE user_id = ? AND event_time > '2023-01-01';

-- 指定 Partition Key + Clustering Key 精确值
SELECT * FROM user_events WHERE user_id = ? AND event_time = '2023-01-01 10:00:00';
```

不合法的查询模式：

```sql
-- 没有 Partition Key，不知道去哪个节点找
SELECT * FROM user_events WHERE event_type = 'login';

-- 只有 Clustering Key，没有 Partition Key
SELECT * FROM user_events WHERE event_time > '2023-01-01';
```

这个限制很多人刚接触 Cassandra 时会觉得很别扭——我就是想按 `event_type` 查怎么就不行了？但换个角度想，这正是 Cassandra 能做到超大规模的关键原因。它强制你在建表的时候就想清楚查询模式，这样每次查询都能精确定位到数据所在的节点和分区，不需要做全集群扫描。

所以在 Cassandra 中有一句经常说的话：Query-Driven Modeling。意思是你要先想好要查什么，再决定怎么建表。这和 MySQL 的思路完全相反——MySQL 是先设计好范式化的表结构，然后通过索引和 JOIN 来支持各种查询。

### 如果确实需要按非主键列查询怎么办

Cassandra 提供了几种方案：

**二级索引（Secondary Index）**：

```sql
CREATE INDEX ON users (city);
SELECT * FROM users WHERE city = '北京';
```

二级索引可以让你按非主键列查询，但性能通常不太好。因为 city 的数据分散在所有节点上，这个查询需要发到所有节点，每个节点在本地查找，然后汇总结果。数据量大的时候会很慢。

**物化视图（Materialized View）**：

```sql
CREATE MATERIALIZED VIEW users_by_city AS
SELECT * FROM users
WHERE city IS NOT NULL AND user_id IS NOT NULL
PRIMARY KEY (city, user_id);
```

物化视图本质上是换一个 Primary Key 把数据再存一份。这样按 `city` 查询就能直接走 Partition Key 了。代价是写入时需要同时维护多份数据，磁盘空间和写入延迟都会增加。

**反范式化建表**——这是 Cassandra 社区最推荐的做法。说白了就是：你有几种查询模式，就建几张表，每张表的 Primary Key 针对一种查询模式做优化。用冗余存储来换查询性能。

## 分布式架构

前面我们一直在聊数据模型，现在进入 Cassandra 最核心的部分，它的分布式架构。

### 去中心化：没有 Master

这是 Cassandra 最独特的设计之一。和很多分布式系统不同，Cassandra 的集群中没有 Master 节点，所有节点都是对等的（Peer-to-Peer）。

![image-20260426112639301](https://assets.javadoop.com/imgs/20510079/cassandra/p2p.png)

没有 Master 意味着没有单点故障。任何一个节点挂了，其他节点可以继续正常服务。客户端可以连接集群中的任意节点，这个节点会充当这次请求的"协调者"（Coordinator），把请求路由到正确的节点上。

那没有 Master 的话，各个节点怎么知道彼此的状态呢？答案是 **Gossip** 协议。

### Gossip 协议

Cassandra 的节点之间通过 Gossip 协议交换状态信息。

这里简单介绍一下这个协议，Gossip 其实使用非常广泛，比如 Solana 使用它来快速同步庞大的集群状态和元数据。Gossip 的意思就是"八卦"，非常形象：每个节点每秒钟随机挑几个节点，互相交换自己知道的集群信息——谁还活着、谁挂了、谁的负载高、谁新加入了。用下面这张图示意一下：

![image-20260426124918353](https://assets.javadoop.com/imgs/20510079/cassandra/gossip.png)

通过这种方式，集群的状态信息会在几秒钟内传播到所有节点。不需要一个中心化的注册中心来维护集群状态。

很多分布式系统会用一个中心化的协调服务（比如 ZooKeeper）来维护集群状态，但 Cassandra 选择了一条更"去中心化"的路——靠节点之间的 Gossip 就够了。

### 一致性哈希：数据怎么分布

Cassandra 用一致性哈希（Consistent Hashing）来决定数据存放在哪个节点上，我想这个协议大家都很熟悉。

基本思路是这样的：假设有一个从 0 到 2^63 - 1 的哈希环，集群中的每个节点负责环上的一段范围。当一条数据写入时，Cassandra 对 Partition Key 做哈希，得到一个 Token 值，然后根据 Token 落在环上的位置，确定数据归哪个节点管。

```
                    Token 环 (0 ~ 2^63-1)
                         0
                        ╱ ╲
                      ╱     ╲
               Node D         Node A
              (0~25%)         (25%~50%)
                    ╲         ╱
                      ╲     ╱
               Node C         Node B
              (75%~100%)    (50%~75%)

  数据 Partition Key = "user_123"
  → hash("user_123") = Token 值落在 25%~50% 之间
  → 这条数据归 Node A 负责
```

一致性哈希的一个重要优势是：当集群扩缩容时，只有相邻节点的数据需要迁移，不会导致全局数据重新分布。比如在 Node A 和 Node B 之间加入一个新节点 Node E，只需要把 Node B 管辖范围的一部分数据迁移给 Node E 就行了，Node C 和 Node D 完全不受影响。

当然，这种设计几乎仅存在于教科书上，如果 Node C/D 不受影响，那意味着 E 的加入并没有给 C/D 带来任何的压力缓解。

在实际的生产部署中，Cassandra 使用的是 Vnodes（Virtual Nodes），这也是大多数成熟分布式系统的实践方式。简单理解就是一个物理节点不只占环上的一段，而是占很多小段。比如每个节点默认有 256 个 Vnode，这样数据的分布会更均匀，新节点加入到集群时，它从每个节点迁移一部分数据，这样达到集群的节点平衡。

### 数据复制

仅仅把数据分散到不同节点还不够，还需要冗余存储来防止丢数据。这就是副本（Replication）。

建 Keyspace 时我们设了 `replication_factor: 3`，意思是每条数据在集群中存 3 份，分别放在 Token 环上连续的 3 个节点上：

```
  数据 Token 落在 Node A 的范围
  → 副本 1 存在 Node A（主副本）
  → 副本 2 存在 Node B（顺时针下一个节点）
  → 副本 3 存在 Node C（再下一个节点）
```

这样即使 Node A 挂了，Node B 和 Node C 上还有数据，可以继续提供服务。

生产环境下通常会跨数据中心部署，使用 NetworkTopologyStrategy：

```sql
CREATE KEYSPACE my_app
WITH replication = {
  'class': 'NetworkTopologyStrategy',
  'dc-east': 3,
  'dc-west': 3
};
```

这表示在东部数据中心存 3 份，西部数据中心也存 3 份。即使一整个数据中心挂了，服务也不受影响。

## 可调一致性（Tunable Consistency）

这个词可能不常见，字面意思就是说 Cassandra 集群的一致性是可以根据自己的要求进行调整的。

这是 Cassandra 另一个非常有特色的设计。在分布式系统中，CAP 定理告诉我们一致性（Consistency）和可用性（Availability）不可兼得。Cassandra 的做法是：让你自己选。

每次读写操作，你都可以指定一个一致性级别（Consistency Level）：

写入时的一致性级别——"多少个副本确认写入成功后，才返回给客户端"：

- ONE：只要 1 个副本写入成功就返回。最快，但风险最高
- QUORUM：多数副本（3 副本的话就是 2 个）写入成功才返回。兼顾性能和安全
- ALL：所有副本都写入成功才返回。最安全，但最慢，而且任何一个副本挂了写入就失败

读取时的一致性级别——"从多少个副本读取数据后，才返回给客户端"：

- ONE：只读 1 个副本，最快
- QUORUM：读多数副本，取最新的值
- ALL：读所有副本，取最新的值

一个关键的公式：如果 `W + R > N`（W = 写入的副本数，R = 读取的副本数，N = 总副本数），那么读取一定能读到最新的数据——因为至少有一个副本同时参与了最近的写入和当前的读取。

```
N = 3（3 个副本）

QUORUM 写 + QUORUM 读：W=2, R=2, W+R=4 > 3 ✓ 强一致
ONE 写 + ALL 读：     W=1, R=3, W+R=4 > 3 ✓ 强一致
ALL 写 + ONE 读：     W=3, R=1, W+R=4 > 3 ✓ 强一致
ONE 写 + ONE 读：     W=1, R=1, W+R=2 < 3 ✗ 可能读到旧值
```

大多数生产场景用 QUORUM 写 + QUORUM 读，在一致性和性能之间取一个比较好的平衡。如果你的场景对一致性要求没那么高（比如日志采集、计数器），可以用 ONE 来获得更高的吞吐。

这就是可调一致性，你可以根据每个业务场景的实际需要来调整一致性强度。这在 MySQL 这种传统数据库里是不可能的事情，MySQL 要么强一致，要么你别用。

## 存储引擎

好了，分布式架构讲完了，我们来看 Cassandra 的单节点存储层是怎么工作的。如果你读过前面 RocksDB 的文章，这部分可以快速阅览。

### 整体写入流程

Cassandra 的存储引擎也是基于 LSM-Tree 的。一次写入的流程如下：

![image-20260426120842015](https://assets.javadoop.com/imgs/20510079/cassandra/write.png)

是不是很熟悉？和 RocksDB 的写入流程几乎是一个模子出来的：先写日志（Commit Log / WAL），再写内存（MemTable），内存攒够了 Flush 到磁盘（SSTable / SST），后台 Compaction 合并整理。

这里有几个关键点：

1. Commit Log 就是 RocksDB 里的 WAL，作用完全一样——保证进程崩溃后数据不丢。重启时会回放 Commit Log 恢复还没来得及 Flush 的数据。

2. MemTable 在 Cassandra 中默认也是用跳表实现的。数据按 Partition Key 分组，每个分区内按 Clustering Key 排序。

3. 写入操作只涉及一次顺序的 Commit Log 追加写 + 一次内存写。没有磁盘上的随机 I/O，这就是为什么 Cassandra 的写入性能非常好。

4. 和 RocksDB 一样，Delete 不是立即删除数据，而是写入一个 Tombstone（墓碑标记）。实际的数据清理在 Compaction 时才会发生。

### SSTable

MemTable Flush 到磁盘后生成的文件叫 SSTable（Sorted String Table），这个和 RocksDB 里的 SST 文件本质上是同一个概念。

一个 SSTable 由多个文件组成：

```
xx-Data.db          # 实际的数据文件，按 Partition Key + Clustering Key 排序
xx-Index.db         # 分区索引，记录每个 Partition Key 在 Data.db 中的偏移量
xx-Summary.db       # 索引的索引，对 Index.db 做采样，加速定位
xx-Filter.db        # Bloom Filter，快速判断某个 Partition Key 是否在这个 SSTable 中
xx-Statistics.db    # 统计信息（行数、大小、时间戳范围等）
xx-TOC.txt          # Table of Contents，列出这个 SSTable 包含哪些文件
```

在一个 SST 文件中读取某个 Partition Key 时，流程大致是这样的：

![image-20260426122706133](https://assets.javadoop.com/imgs/20510079/cassandra/sst-read.png)

这个多层索引的思路和 RocksDB 的 SST 文件也是一样的，通过 Bloom Filter 快速排除不相关的文件，通过多级索引快速定位到目标数据。

### 读取流程

Cassandra 的读取流程和 RocksDB 的读取路径非常类似，读取需要检查多个数据源（MemTable + 多个 SSTable），然后合并结果。这也是 LSM-Tree 架构的一个固有代价——写入快了，但读取需要做更多的工作。

![read](https://assets.javadoop.com/imgs/20510079/cassandra/read.png)

### Compaction

和 RocksDB 一样，随着写入的持续进行，SSTable 文件会越来越多，读取性能会下降（因为要检查更多的 SSTable）。Compaction 就是用来解决这个问题的。

Cassandra 提供了几种 Compaction 策略：

**SizeTieredCompactionStrategy（STCS）**——默认策略：

- 当大小相近的 SSTable 积累到一定数量时，合并成一个更大的 SSTable
- 适合写多读少的场景
- 缺点是空间放大比较大——合并过程中需要同时保留新旧两份数据，最坏情况下磁盘使用量会翻倍

**LeveledCompactionStrategy（LCS）**：

- 和 RocksDB 默认的 Leveled Compaction 一样，把 SSTable 按层级组织
- 每层内 SSTable 的 key 范围不重叠
- 读取性能更好（通常每层只需要查一个 SSTable），空间放大也更小
- 缺点是写放大更高

**TimeWindowCompactionStrategy（TWCS）**：

- 专门为时序数据设计
- 按时间窗口把 SSTable 分组，只合并同一时间窗口内的 SSTable
- 过期数据可以通过直接删除整个时间窗口的 SSTable 来清理，非常高效
- 适合日志、监控、IoT 这类带 TTL 的时序数据

```sql
-- 建表时指定 Compaction 策略
CREATE TABLE sensor_data (
    ...
) WITH compaction = {
    'class': 'TimeWindowCompactionStrategy',
    'compaction_window_unit': 'DAYS',
    'compaction_window_size': 1
};
```

TWCS 的核心思路是：让数据的物理组织方式和过期策略对齐。同一时间窗口的数据在同一批 SSTable 里，过期后直接删除整个 SSTable 文件就行了，不需要一行一行地判断和清理。

## Tombstone 和数据删除

前面提到了 Tombstone，这个概念在 Cassandra 中特别重要，值得单独拿出来说一下。

在 Cassandra 中执行 `DELETE` 时，数据并不会被立即删除。Cassandra 会写入一条 Tombstone 记录来标记"这条数据已经被删除了"。实际的删除发生在 Compaction 期间。

为什么不能立即删除？因为在分布式环境下，如果你在一个节点上直接删除数据，其他持有副本的节点并不知道。下次读修复（Read Repair）或者反熵修复（Anti-Entropy Repair）的时候，其他节点可能会把"已删除"的数据重新同步回来——这就是所谓的"数据复活"问题。

Tombstone 通过保留删除标记一段时间（默认 10 天，由 `gc_grace_seconds` 控制）来解决这个问题。在这段时间内，所有节点都有机会通过 Gossip 和修复机制同步到这个删除信息。过了这个时间后，Compaction 才会真正清理掉 Tombstone 和被它标记的数据。

这里有一个实际使用中容易踩的坑：如果你的业务频繁删除数据，Tombstone 会在 SSTable 中积累。读取时遇到大量 Tombstone，Cassandra 需要逐个跳过，会严重影响读取性能。严重的话，Cassandra 甚至会抛出 `TombstoneOverwhelmingException` 拒绝查询。

所以在 Cassandra 中，删除是一个重操作。如果你的业务模式涉及大量删除，需要提前设计好数据模型。比如用 TTL 来自动过期数据，或者用 TWCS 来按时间窗口整体清理。

## 一些重要的内部机制

### Hinted Handoff

当某个副本节点暂时不可用时，协调者节点会把本该写给它的数据先暂存在本地，等目标节点恢复后再发给它。这就是 Hinted Handoff。

```
正常情况：
  Client → Coordinator → Node A (副本1) ✓
                       → Node B (副本2) ✓
                       → Node C (副本3) ✓

Node C 临时挂了：
  Client → Coordinator → Node A (副本1) ✓
                       → Node B (副本2) ✓
                       → Node C (副本3) ✗
                       → Coordinator 本地暂存 Hint

Node C 恢复后：
  Coordinator → Node C："这是你挂掉期间漏掉的数据"
```

这个机制保证了短暂的节点故障不会导致数据丢失。但注意，Hint 默认只保存 3 小时。如果节点挂太久，就需要通过 Repair 来修复数据了。

### Read Repair

读取时，如果 Cassandra 发现不同副本返回的数据版本不一致（比如某个副本少了最近的一次更新），它会在后台自动修复——把最新的数据同步给落后的副本。这就是 Read Repair。

```
Client 读取（QUORUM，需要 2 个副本）
  → Node A 返回 v3（最新）
  → Node B 返回 v2（落后了）
  → 返回 v3 给客户端
  → 后台异步：把 v3 同步给 Node B
```

这是一种"搭便车"式的修复——借着读取请求顺便修复不一致的数据，不需要额外的修复操作。但它只在数据被读到的时候才会触发，如果某些数据很久没被读到，不一致可能会一直存在。所以生产环境还是需要定期跑全量 Repair。

### Anti-Entropy Repair

这是 Cassandra 最"重"的数据一致性保证机制。通过 `nodetool repair` 命令触发，它会比较不同副本之间的数据，找出差异并修复。

内部使用 Merkle Tree（默克尔树）来高效比较数据：

```
1. 每个节点为自己持有的数据构建 Merkle Tree
   → 叶子节点是每个数据范围的哈希值
   → 父节点是子节点哈希的组合

2. 节点之间交换 Merkle Tree 的根哈希
   → 如果根哈希一样，说明数据完全一致，结束
   → 如果不一样，递归比较子树，找出不一致的数据范围

3. 只传输不一致的那部分数据来修复
```

Merkle Tree 的好处是：即使数据量很大，也只需要交换很少的哈希值就能找出差异。不需要把所有数据都传一遍。

不过 Repair 是一个很重的操作，会消耗大量的磁盘 I/O 和网络带宽。生产环境下需要错峰执行，通常每隔 `gc_grace_seconds`（默认 10 天）之内至少跑一次，确保在 Tombstone 被清理之前，所有副本的数据都已经同步。

## 集群运维

前面讲了这么多原理，下面我们来看看实际操作层面的东西：怎么搭建一个 Cassandra 集群、怎么扩容、怎么缩容、日常要关注什么。了解这些才算是真正把 Cassandra 用起来了，也能加深对 Cassandra 的理解。

### 初始化集群

生产环境肯定不能用前面那个单节点的 Docker 了。我们假设有 3 台机器，分别是 `10.0.0.1`、`10.0.0.2`、`10.0.0.3`，要搭建一个 3 节点的集群。

核心配置文件是 `cassandra.yaml`，每个节点都需要改几个关键配置：

```yaml
# 集群名称，所有节点必须一致，否则加入不了同一个集群
cluster_name: 'my_cluster'

# 当前节点的 IP 地址，其他节点通过这个地址和它通信
listen_address: 10.0.0.1   # 每台机器填自己的 IP

# 客户端连接地址，cqlsh 和应用程序通过这个地址连接
rpc_address: 10.0.0.1      # 通常和 listen_address 一样

# Seed 节点列表
seed_provider:
  - class_name: org.apache.cassandra.locator.SimpleSeedProvider
    parameters:
      - seeds: "10.0.0.1,10.0.0.2"
```

这里解释一下 Seed 节点。前面说了 Cassandra 是去中心化的，没有 Master，但一个新节点加入集群的时候，总得先知道集群里有谁吧？Seed 节点就是起这个"引荐人"的作用——新节点启动时先联系 Seed 节点，通过它了解整个集群的拓扑信息，然后就可以和所有节点进行 Gossip 了。

几个注意点：

- Seed 节点本身没有什么特殊地位，它不是 Master，只是启动时用来引导发现的。集群运行起来之后，所有节点都是对等的
- 通常每个数据中心选 2-3 个稳定的节点做 Seed 就够了，不要把所有节点都设成 Seed（全设成 Seed 反而会影响 Gossip 效率）
- 不需要所有节点的 `seeds` 配置完全一样。只要每个节点能通过自己的 seeds 列表联系到至少一个已有节点，就能通过 Gossip 发现整个集群。对于小集群，大家配一样的 seeds 是最简单的做法；但大规模多数据中心的场景下，通常每个 DC 内的节点配本 DC 的 Seed，再交叉配一两个其他 DC 的 Seed 来保证跨 DC 发现

配置好之后，逐个启动节点：

```bash
# 在每台机器上启动 Cassandra
cassandra    # 前台启动，方便看日志
# 或者
cassandra -R # 后台启动
```

建议先启动 Seed 节点，等它完全起来后再启动其他节点。不需要同时启动——后面的节点启动时会自动通过 Seed 发现已有的节点并加入集群。

启动完之后，用 `nodetool status` 检查集群状态：

```bash
nodetool status
```

如果一切正常，你会看到类似这样的输出：

```
Datacenter: datacenter1
=======================
Status=Up/Down
|/ State=Normal/Leaving/Joining/Moving
--  Address     Load       Tokens  Owns    Host ID                               Rack
UN  10.0.0.1    256.12 KiB  256     33.3%   xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx  rack1
UN  10.0.0.2    248.67 KiB  256     33.3%   xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx  rack1
UN  10.0.0.3    251.45 KiB  256     33.3%   xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx  rack1
```

`UN` 表示 Up + Normal，说明节点状态正常。`Tokens: 256` 就是前面提到的 Vnodes，每个节点默认分配 256 个虚拟节点。`Owns: 33.3%` 表示每个节点大约负责三分之一的数据，分布很均匀。

### 扩容：加入新节点

随着数据量增长，3 个节点扛不住了，需要加机器。Cassandra 的扩容非常简单——这也是它的一大优势。

假设我们要加入第 4 台机器 `10.0.0.4`。只需要在新机器上配好 `cassandra.yaml`，`cluster_name` 保持一致，`seeds` 配置指向已有的 Seed 节点，然后直接启动就行了：

```yaml
# 新节点的 cassandra.yaml
cluster_name: 'my_cluster'
listen_address: 10.0.0.4
rpc_address: 10.0.0.4
seed_provider:
  - class_name: org.apache.cassandra.locator.SimpleSeedProvider
    parameters:
      - seeds: "10.0.0.1,10.0.0.2"   # 指向已有的 Seed 节点
```

新节点启动后会自动完成以下事情：

1. 联系 Seed 节点，通过 Gossip 了解整个集群的拓扑
2. 在 Token 环上获取自己的 Token 范围（自动分配的）
3. 从其他节点流式传输（Stream）它应该负责的数据——这个过程叫 Bootstrap

Bootstrap 过程中可以通过 `nodetool netstats` 查看数据传输进度：

```bash
# 在新节点上执行
nodetool netstats
```

数据量大的话 Bootstrap 可能要跑比较久，这期间新节点的状态是 `Joining`，不会接收客户端请求。等 Bootstrap 完成后，状态变成 `Normal`，就可以正常服务了。

再跑一下 `nodetool status`，你会看到 4 个节点，每个大约负责 25% 的数据：

```
UN  10.0.0.1    1.2 GiB    256     25.1%   ...
UN  10.0.0.2    1.1 GiB    256     24.8%   ...
UN  10.0.0.3    1.2 GiB    256     25.0%   ...
UN  10.0.0.4    1.1 GiB    256     25.1%   ...
```

整个过程不需要停机，不需要手动迁移数据，对线上服务零影响。这就是一致性哈希 + Vnodes 架构带来的好处——前面讲原理的时候说过，新节点加入只需要从相邻节点迁移一部分数据，不会导致全局重新分布。

有一点要注意：新节点加入后，建议跑一次 `nodetool cleanup` 来清理旧节点上已经不属于它们的数据：

```bash
# 在每个旧节点上执行（不是新节点）
nodetool cleanup
```

cleanup 会扫描本地数据，删除已经不归这个节点管的 Token 范围的数据，释放磁盘空间。

### 缩容：移除节点

反过来，如果想把某个节点从集群中移除呢？Cassandra 提供了 `decommission` 命令：

```bash
# 在要移除的节点上执行
nodetool decommission
```

Decommission 会把这个节点负责的数据全部流式传输给其他节点，然后自己从集群中退出。和扩容一样，整个过程不停机，对线上服务透明。

如果节点已经挂了、起不来了，没办法在上面执行 decommission，那就在其他任意节点上用 `removenode`：

```bash
# 在其他节点上执行，需要指定要移除的节点的 Host ID
nodetool removenode <host-id>
```

Host ID 可以通过 `nodetool status` 查到。removenode 会通知集群重新分配被移除节点的数据。

### 日常运维要点

简单列一下生产环境中需要持续关注的几件事：

**定期 Repair**

前面讲 Anti-Entropy Repair 的时候提过，这是保证数据一致性的最后一道防线。生产环境必须在 `gc_grace_seconds`（默认 10 天）之内对每个节点跑一次完整的 Repair：

```bash
nodetool repair -full
```

不跑 Repair 的后果：Tombstone 到期后被 Compaction 清理掉，但如果某个副本还没同步到这个删除操作，被删的数据就会"复活"。这个问题在实际生产中遇到过不止一次，非常难排查。

建议用 Reaper（cassandra-reaper）这样的工具来自动化管理 Repair 任务，手动跑太容易遗漏了。

**监控 Compaction 和磁盘**

Compaction 是后台持续运行的，但如果写入速度长期大于 Compaction 速度，待合并的 SSTable 就会越堆越多，读取性能会持续下降。可以通过以下命令查看 Compaction 状态：

```bash
nodetool compactionstats    # 查看当前正在进行的 Compaction
nodetool tablestats <keyspace>.<table>   # 查看表级别的 SSTable 数量等统计
```

如果发现 SSTable 数量持续增长不收敛，可能需要调整 Compaction 策略或者增加节点分摊压力。

磁盘方面，Cassandra 的经验法则是：磁盘使用率不要超过 50%。因为 Compaction 过程中需要临时持有新旧两份数据，如果磁盘太满可能会导致 Compaction 失败，进而引发恶性循环。

**关注 Tombstone**

这个前面已经详细说了，频繁删除数据的场景一定要关注 Tombstone 堆积。可以通过以下方式排查：

```bash
# 查看某个表的 Tombstone 统计
nodetool tablestats <keyspace>.<table> | grep -i tombstone
```

如果发现读取延迟突然升高，首先怀疑 Tombstone 问题。

**备份**

虽然 Cassandra 有多副本保障，但备份还是要做的——多副本防的是硬件故障，备份防的是人为误操作（比如不小心 TRUNCATE 了一张表）。

Cassandra 支持快照备份：

```bash
nodetool snapshot -t my_backup <keyspace>
```

快照通过硬链接实现，几乎不占额外空间，也不影响线上性能。但注意快照只是当前节点的本地数据，要做完整备份需要在所有节点上都跑一次。

## Cassandra 的局限性

说了这么多优点，最后我们来说说 Cassandra 不擅长什么。任何技术都有它的适用边界，了解局限性和了解优势一样重要。

1. 不支持复杂查询。没有 JOIN、没有子查询、没有 GROUP BY（CQL 4.0 之后有限支持）。Cassandra 的查询能力非常有限，它只擅长基于 Primary Key 的精确查找和范围扫描。需要复杂分析查询的场景，Cassandra 不是正确的选择。

2. 不支持事务。Cassandra 没有传统意义上的 ACID 事务。虽然 4.0 版本引入了轻量级事务（Lightweight Transaction，基于 Paxos），但性能开销很大，不建议在高频操作中使用。

3. 聚合能力弱。虽然 CQL 支持 COUNT、SUM、AVG 等聚合函数，但它们需要扫描大量数据，性能很差。Cassandra 不是为聚合分析设计的，需要做报表分析的话应该用专门的 OLAP 系统。

4. 数据建模复杂。Query-Driven Modeling 意味着你必须提前规划好所有查询模式。如果后期查询需求变了，可能需要重建表和迁移数据。这对于需求变化频繁的早期项目来说是一个不小的负担。

5. 运维复杂度。虽然 Cassandra 没有单点故障，但 Repair、Compaction、Tombstone 管理、数据迁移这些日常运维工作还是需要持续关注的。特别是 Repair，如果长期不做，数据一致性会慢慢漂移。

6. 不适合小数据量。如果你的数据量只有几个 GB，没有跨地域部署的需求，用 Cassandra 就是杀鸡用牛刀。MySQL 或者 PostgreSQL 可能更简单、更高效。

这些局限性不是缺点，而是设计上的取舍。Cassandra 选择在高可用和写入吞吐这个赛道做到极致，代价就是放弃了关系型数据库的一些能力。

## 适用场景

说了不适合的，我们再说说 Cassandra 真正擅长的场景：

- 时序数据：IoT 传感器数据、监控 metrics、日志。高写入吞吐 + TTL 自动过期 + TWCS 高效清理，天然适配
- 消息/通知系统：用户收件箱、消息历史、通知列表。按用户分区，按时间排序，查询模式固定
- 用户画像/个性化：用户偏好、浏览历史、推荐数据。按用户 ID 分区，读写模式简单
- 商品目录/内容管理：大量的读取，数据量大但查询模式固定
- 任何需要跨地域部署、7×24 高可用的场景

一个简单的判断标准：如果你的数据量很大（TB 级以上）、写入吞吐要求高、查询模式相对固定、对可用性要求极高、可以接受最终一致性——那 Cassandra 是一个很好的选择。

## 总结

本文从数据模型到分布式架构，再到存储引擎，过了一遍 Cassandra 的核心设计。几个关键要点：

1. Cassandra 是一个分布式 NoSQL 数据库，设计受 Amazon Dynamo（分布式层）和 Google Bigtable（存储层）两篇论文的影响
2. Primary Key = Partition Key + Clustering Key，它决定了数据的分布、排序和查询方式。查询必须从 Partition Key 出发
3. 去中心化的 P2P 架构，没有 Master 节点，所有节点对等。通过 Gossip 协议交换状态信息
4. 一致性哈希决定数据存在哪个节点上，Vnodes 让数据分布更均匀
5. 可调一致性让你可以根据业务需要在一致性和性能之间做选择。`W + R > N` 保证强一致
6. 存储引擎基于 LSM-Tree，写入路径是 Commit Log → MemTable → SSTable，和 RocksDB 思路一致
7. Tombstone 是分布式环境下处理删除的核心机制，大量 Tombstone 会影响读取性能
8. Hinted Handoff、Read Repair、Anti-Entropy Repair 三层机制保证数据最终一致

如果你读过前面的 RocksDB 文章，会发现 Cassandra 的存储层几乎就是 RocksDB 思路的一个翻版——WAL + MemTable + SSTable + Compaction，连 Bloom Filter 加速读取这种优化手段都一样。区别在于 Cassandra 在上面多了一整层分布式的东西：一致性哈希、副本、Gossip、可调一致性。把这两层拆开来看，每一层都不复杂；合在一起，就是一个能支撑超大规模、高可用的分布式存储系统。

（全文完）
