数据库基础体系 · 第 51/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

Cassandra 一致性与运维:复制、Quorum、Compaction、Repair 和故障

Cassandra 的一致性与传统单机关系型数据库不同:它通常不通过单一主节点、集中式锁或全局事务来保证所有请求立即看到同一份数据,而是把数据复制到多个节点,由客户端在每次读写时选择一致性级别,再通过后台的反熵修复、提示移交和存储压实逐步恢复副本状态。

因此,理解 Cassandra 不能只记住“它是最终一致的”。必须同时回答几个问题:

  1. 一行数据由哪些节点保存?
  2. 一次写入需要多少副本确认?
  3. 一次读取需要询问多少副本?
  4. 节点故障时,请求是失败、等待,还是由其他节点代为保存?
  5. 删除数据后,如何避免旧副本把数据“复活”?
  6. SSTable 越来越多时,Compaction 如何影响读写和磁盘?
  7. Repair 到底修复什么,为什么不能被 Compaction 替代?
  8. QUORUMLOCAL_QUORUM 与“强一致”之间是什么关系?

下文以 Apache Cassandra 的公开稳定语义为准。具体命令选项可能随版本变化,生产环境应先执行 nodetool help 或对应子命令的 --help 确认。


一、先建立数据模型:复制发生在 Partition 层,而不是任意一行之间

1. Partition Key 决定数据落在哪里

Cassandra 的表通常由以下部分组成:

  • Partition Key:决定一个分区的哈希值和负责它的节点;
  • Clustering Columns:决定同一分区内部的排序和组织方式;
  • 普通列:保存属性值。

例如:

CREATE KEYSPACE app
WITH replication = {
  'class': 'NetworkTopologyStrategy',
  'dc1': 3
};

CREATE TABLE app.user_events (
    user_id text,
    event_day date,
    event_time timestamp,
    event_id uuid,
    event_type text,
    payload text,
    PRIMARY KEY ((user_id, event_day), event_time, event_id)
);

这里:

(user_id, event_day)

是复合 Partition Key。相同用户、相同日期的事件属于同一个 partition,并被映射到同一组副本节点。

event_timeevent_id 是 Clustering Columns,它们只影响该 partition 内部的排序,不能把一条记录分散到多个 partition。

因此,复制的基本单位可以理解为:

一个 partition key
    ↓
哈希为 token
    ↓
根据复制策略找到多个副本节点

这也是 Cassandra 查询必须围绕 Partition Key 设计的原因。若查询不知道 Partition Key,协调节点无法直接确定有限的副本集合,可能需要扫描大量节点;这类查询通常不是 Cassandra 的正常访问模式。


二、复制:一个 partition 的数据为什么会存在多个节点

2.1 Token Ring 与副本集合

Cassandra 使用 partition key 的哈希值映射到 token。节点负责 token 环上的某些范围。对一个 partition,系统会根据复制策略选择若干节点保存副本。

设某个 partition 的复制因子为:

RF=NRF = N

则该 partition 的数据逻辑上有 NN 个副本。例如 RF=3 时,数据可能保存于:

Node A
Node B
Node C

这并不意味着客户端一定连接到其中某个固定节点。客户端可以连接到任意节点,该节点充当本次请求的 Coordinator,再把读写请求转发给真正负责该 partition 的副本节点。

所以一次请求至少包含两个角色:

  • Coordinator:接收客户端请求、确定副本、收集响应;
  • Replica:实际保存该 partition 数据的节点。

Coordinator 不一定是数据副本。

2.2 NetworkTopologyStrategy 才是多数据中心的正常选择

常见复制策略有:

SimpleStrategy

SimpleStrategy 沿 token ring 顺序选择后续节点,不考虑机架或数据中心拓扑。它适合测试,不适合生产多机架、多数据中心部署。

NetworkTopologyStrategy

NetworkTopologyStrategy 按数据中心分别指定复制因子,并尽量将副本分布到不同机架。

例如:

CREATE KEYSPACE app
WITH replication = {
  'class': 'NetworkTopologyStrategy',
  'dc1': 3,
  'dc2': 3
};

这表示每个 partition 在 dc1 有 3 个副本,在 dc2 也有 3 个副本。全局副本数为 6,但“每个数据中心内的副本数”仍分别是 3。

需要注意:

  • 复制因子是 keyspace 级别 的配置;
  • 同一集群中,不同 keyspace 可以有不同复制策略;
  • 修改 keyspace 的复制配置后,已有数据不会自动完成所有物理副本迁移,通常需要结合 rebuildrepair 等运维流程使数据状态达到预期;
  • 复制因子不应大于实际可用的合适节点数,否则会造成副本分布和运维目标不合理。

可以通过以下命令检查:

nodetool getendpoints app user_events 'u-100|2025-01-01'

该命令根据给定的 partition key 查找副本端点。复合 Partition Key 在命令行中的序列化方式依赖版本和实现,生产环境应以当前版本 nodetool help getendpoints 为准。

也可以在 cqlsh 中查看 keyspace:

DESCRIBE KEYSPACE app;

三、Cassandra 的一致性模型:副本数和确认数是两个不同概念

复制因子只表示“有多少份数据”,不表示“每次操作必须等多少份确认”。

每次读写还要选择一个 Consistency Level,简称 CL,一致性级别

3.1 常见写一致性级别

对于单数据中心部署,常见写 CL 包括:

  • ANY
  • ONE
  • TWO
  • THREE
  • QUORUM
  • ALL

多数据中心部署还常用:

  • LOCAL_ONE
  • LOCAL_QUORUM
  • EACH_QUORUM

ANY 是一个特殊的写级别:协调节点可以把写入作为 hint 保存,而不要求真正的副本当时可用。它只适用于普通写,不适用于读;使用它会降低写入语义的直观性,生产系统通常慎用。

3.2 常见读一致性级别

读操作常用:

  • ONE
  • TWO
  • THREE
  • QUORUM
  • LOCAL_ONE
  • LOCAL_QUORUM
  • ALL

读取时,Coordinator 向足够数量的副本发送请求,比较返回值,并可能在后台修复发现不一致的副本。

3.3 Quorum 的定义

对某个副本集合,设副本数为 NN,则:

QUORUM=N2+1QUORUM = \left\lfloor \frac{N}{2} \right\rfloor + 1

也就是超过半数。

例如 RF=3:

QUORUM=32+1=2QUORUM = \left\lfloor \frac{3}{2} \right\rfloor + 1 = 2

RF=4:

QUORUM=42+1=3QUORUM = \left\lfloor \frac{4}{2} \right\rfloor + 1 = 3

RF=5:

QUORUM=3QUORUM = 3

在 Cassandra 中,QUORUM 的具体计算基于本次请求涉及的副本集合。多数据中心场景下,LOCAL_QUORUM 只等待本地数据中心内超过半数的副本;它不是整个集群的全局 quorum。

对于某个数据中心的局部复制因子 NlocalN_{local}

LOCAL_QUORUM=Nlocal2+1LOCAL\_QUORUM = \left\lfloor \frac{N_{local}}{2} \right\rfloor + 1

例如:

dc1 RF = 3
dc2 RF = 3

dc1 发起 LOCAL_QUORUM 请求时,通常只需等待 dc1 的 2 个副本,而不是等待 6 个全局副本中的 4 个。


四、用 R+W>N 推导 Quorum 的读写重叠

设:

  • NN:副本数;
  • WW:一次写入至少等待的副本确认数;
  • RR:一次读取至少查询的副本数。

若:

R+W>NR + W > N

则读集合和写集合至少有一个副本相交。

4.1 RF=3、读写都使用 QUORUM

设:

N = 3
W = 2
R = 2

因为:

R+W=2+2=4>3R + W = 2 + 2 = 4 > 3

假设一次写入 v2

Node A: v2
Node B: v2
Node C: v1

写入因为已经得到 A、B 两个确认而成功。

随后读取需要两个副本返回。可能的读集合有:

A + B → 都是 v2
A + C → 至少 A 是 v2
B + C → 至少 B 是 v2

无论读到哪两个节点,至少有一个看到 v2。Coordinator 会基于 Cassandra 的冲突解决规则选择最新值,并可以把较新的值修复到落后的副本。

4.2 反例:ONE 写加 ONE

仍然是 RF=3,但:

W = 1
R = 1

于是:

R+W=23R + W = 2 \not> 3

一次写入可能只落在 Node A:

Node A: v2
Node B: v1
Node C: v1

下一次读取如果只读到 Node B,就可能返回 v1

这不是 Cassandra 违反了自身语义,而是客户端选择了允许较弱可见性的 CL。

4.3 这个公式不是“绝对强一致”的完整证明

R+W>N 只说明读写集合存在交集,实际语义还受到以下因素影响:

  1. Cassandra 的冲突解决方式
    普通列值通常通过写入时间戳进行 last-write-wins 冲突解决,而不是通过事务日志顺序。

  2. 客户端时间戳问题
    如果客户端显式提供了错误的时间戳,未来时间戳可能长期压制后续写入。

  3. 删除也是带时间戳的墓碑
    一个较新的 tombstone 可以压过旧值;如果墓碑被错误清理,问题会变得更复杂。

  4. 超时不等于没有写入
    Coordinator 在超时前可能已经把数据写入部分副本。客户端重试时,必须考虑请求是否幂等。

  5. 一致性级别只约束本次操作
    一次写用 QUORUM,下一次读用 ONE,不能把整个业务流程简单称作“Quorum 强一致”。

更准确的说法是:在正常故障模型和正确时间戳条件下,合适的读写重叠可以提高读到最新已确认值的概率和保证;它不等同于所有操作都具有数据库事务的全局线性一致性。


五、LOCAL_QUORUMEACH_QUORUM 与多数据中心取舍

假设:

dc1 RF=3
dc2 RF=3

LOCAL_QUORUM

dc1 中:

LOCAL_QUORUM = 2

读写主要等待 dc1 的副本完成。这通常适合:

  • 应用有明确的本地数据中心;
  • 希望跨数据中心链路故障时本地仍可读写;
  • 接受远端副本稍后通过异步机制追平。

但它不能保证一次本地读一定观察到另一数据中心刚完成的写入。如果写入和读取发生在不同数据中心,还需要更谨慎地设计 CL 和故障模型。

EACH_QUORUM

EACH_QUORUM 要求每个数据中心都达到本地 quorum。

上述配置中,一次操作至少需要:

dc1: 2
dc2: 2

它提供更强的跨数据中心确认语义,但代价是:

  • 任一数据中心无法达到 quorum,操作就可能失败;
  • 跨地域网络延迟直接进入请求路径;
  • 数据中心级故障会明显降低可用性。

不要把 QUORUMLOCAL_QUORUM 混用后再套简单公式

全局 QUORUM 的计算与本地 quorum 不同。多数据中心下,使用 LOCAL_QUORUM 时,正确的分析单位通常是“目标数据中心内的副本集合”,还要明确:

  • 写入在哪个数据中心发起;
  • 读取在哪个数据中心发起;
  • 应用是否允许跨数据中心读取;
  • 远端副本是否必须在请求成功前确认。

六、普通写入的路径:从 Coordinator 到副本

执行一个普通写入:

INSERT INTO app.user_events
    (user_id, event_day, event_time, event_id, event_type, payload)
VALUES
    ('u-100', '2025-01-01', '2025-01-01 10:00:00+0000',
     11111111-1111-1111-1111-111111111111, 'login', '{}')
USING TIMESTAMP 1735725600000000;

这里的 USING TIMESTAMP 单位通常是微秒。若不指定,Cassandra 会使用协调节点生成或接收的写入时间戳。手工指定时间戳时必须极其谨慎,因为较大的时间戳可能使后续正常写入都被判定为旧值。

典型流程是:

  1. 客户端连接到任意 Cassandra 节点;
  2. 该节点成为 Coordinator;
  3. Coordinator 对 Partition Key 计算 token;
  4. 根据 keyspace 的复制策略确定副本;
  5. 向副本发送写入;
  6. 副本先写入 Commit Log,再更新内存中的 Memtable;
  7. 达到写一致性级别所需的确认数量;
  8. Coordinator 向客户端返回成功。

Commit Log、Memtable 和 SSTable 的关系

写入路径可简化为:

客户端
  ↓
Coordinator
  ↓
Replica
  ├─ Commit Log:保证节点崩溃后的恢复能力
  └─ Memtable:内存中的有序结构
           ↓ flush
       SSTable:磁盘上的不可变文件

Commit Log 不是副本间的复制日志。每个节点本地记录自己的写入,用于节点崩溃后恢复。副本间的数据复制由 Cassandra 的请求转发和后台同步机制完成。

Memtable flush 后会生成新的 SSTable。SSTable 通常不可变,更新同一主键并不是原地覆盖旧文件,而是生成新的记录,后续由读取合并或 Compaction 清理。


七、读取路径:为什么读一个值可能访问多个副本

执行:

CONSISTENCY QUORUM;

SELECT *
FROM app.user_events
WHERE user_id = 'u-100'
  AND event_day = '2025-01-01';

对于 RF=3、QUORUM 的情况,Coordinator 至少需要得到 2 个副本的结果。

读取过程通常包括:

  1. Coordinator 根据 Partition Key 找到副本;
  2. 向足够的副本发起读请求;
  3. 某些情况下向额外副本发送 digest 请求,用于比较数据摘要;
  4. 如果摘要不一致,Coordinator 请求完整数据;
  5. 根据列、时间戳和墓碑等信息合并结果;
  6. 对发现落后的副本执行 read repair 相关动作,具体行为和版本实现有关;
  7. 返回给客户端。

因此,读到“不一致”的中间状态不一定意味着 Cassandra 无法工作,而可能表示:

  • 某副本尚未被 Repair;
  • 某次写入只在部分副本上完成;
  • 读取 CL 较低;
  • 节点曾经离线;
  • 发生了时间戳冲突;
  • tombstone 与旧值在不同副本上状态不同。

不能把 read repair 当作完整 Repair 的替代品。读取只覆盖被访问到的 partition,不能系统性检查整个 token 范围。


八、故障时会发生什么:失败、超时、Hint 和部分成功

8.1 节点不可用与请求超时不是同一件事

Cassandra 客户端通常会遇到两类重要错误:

  • Unavailable:Coordinator 根据故障检测器判断,当前可响应的副本数量不足以满足 CL;
  • Timeout:在等待时间内没有收到足够响应,但可能已有部分副本完成了操作。

Timeout 的关键特征是:客户端不知道操作到底有没有生效

例如 RF=3、写 CL=QUORUM

Node A: 已写入
Node B: 已写入,但响应丢失
Node C: 未完成

Coordinator 可能返回 timeout,但 A、B 实际已经保存了数据。客户端如果把 timeout 当作“肯定没写入”并立即执行非幂等重试,可能造成业务重复。

因此:

  • 使用业务唯一键设计幂等写入;
  • 对计数器、追加事件等操作单独分析重试语义;
  • 客户端应使用与驱动匹配的重试策略,不要简单地对所有异常无限重试;
  • 对“结果未知”的请求,可以先查询状态,或者使用业务请求 ID 去重。

8.2 Hinted Handoff:副本暂时不可用时由其他节点代存

当一个目标副本暂时离线时,协调节点或其他节点可以为该副本保存 hint,记录“以后要把哪些写入交给它”。

简化流程:

目标副本 B 离线
写入到达 Coordinator A
A 将数据写给可用副本 C
A 或相关节点保存给 B 的 hint
B 恢复
hint 被回放给 B
B 追上部分缺失写入

Hint 不是永久的完整副本,也不能替代 Repair。它主要降低短时间节点故障造成的数据缺口。

若节点长时间离线:

  • hint 可能不能覆盖全部离线时段;
  • 节点恢复后仍需要 Repair;
  • 在涉及删除时,不能只依赖 hint 来证明所有副本都看到了 tombstone。

8.3 读写可用性示例

RF=3、写 CL=QUORUM 时,至少需要 2 个副本可完成写入:

可用副本数 3 → 可以写
可用副本数 2 → 可以写
可用副本数 1 → Unavailable 或无法达到 CL

写 CL=ONE 时,理论上 1 个副本可确认:

可用副本数 1 → 可能写成功

但这不代表其他副本立即拥有同样的数据,也不代表读 CL=ONE 能读到该值。

高可用与一致性之间不是简单的“选择 Cassandra 参数”,而是业务对以下问题的选择:

  • 能否接受短暂读旧值?
  • 跨数据中心是否必须同步确认?
  • 节点故障时能否继续写?
  • 是否能处理 timeout 后的未知结果?

九、Compaction:把许多不可变 SSTable 合并成更适合读取的状态

9.1 为什么需要 Compaction

Cassandra 的 SSTable 不可变。更新和删除不会立即修改旧文件,而是写入新的 SSTable:

旧 SSTable:
  k → v1

新 SSTable:
  k → v2

读取时需要检查多个 SSTable,再根据时间戳和删除标记合并结果。随着 SSTable 增多,会带来:

  • 更高的读放大;
  • 更多 Bloom Filter、索引和摘要需要检查;
  • 更多磁盘文件;
  • 更高的合并与 I/O 成本。

Compaction 就是后台把多个 SSTable 读取、合并并生成新 SSTable 的过程。它不是副本同步机制,也不是事务提交机制。

Compaction 的主要目标是:

  1. 减少读取需要检查的文件;
  2. 清理被新值覆盖的旧版本;
  3. 在满足条件时清理 tombstone;
  4. 控制磁盘空间和写放大。

9.2 Tombstone 为什么重要

执行删除:

DELETE FROM app.user_events
WHERE user_id = 'u-100'
  AND event_day = '2025-01-01'
  AND event_time = '2025-01-01 10:00:00+0000'
  AND event_id = 11111111-1111-1111-1111-111111111111;

Cassandra 通常不会马上从所有 SSTable 物理擦除数据,而是写入一个 tombstone,表示某个值或范围已被删除。

原因是:如果只物理删除当前节点上的值,而另一个副本还保存旧值,旧值在后续同步时可能重新出现。墓碑可以传播“这个值已经被删除”的事实。

墓碑也有代价:

  • 读取时需要处理墓碑;
  • 大量删除会增加磁盘和读取开销;
  • 范围删除可能造成更广泛的 tombstone 扫描;
  • tombstone 不能在副本尚未充分同步前随意清理。

9.3 gc_grace_seconds 的作用

gc_grace_seconds 表示 tombstone 在满足条件前至少保留一段时间,避免某个长期离线副本恢复后,把已删除的旧数据重新带回集群,也就是所谓的 zombie resurrection。

关键风险路径:

T0: Node B 离线
T1: A、C 写入 tombstone
T2: tombstone 在 A、C 上被 Compaction 清理
T3: B 恢复,仍保存旧值
T4: B 的旧值被重新同步到其他节点

如果 tombstone 的保留时间、节点离线时间和 Repair 计划不匹配,就可能出现删除复活。

因此,调整 gc_grace_seconds 不是简单的磁盘优化。必须同时考虑:

  • 节点最长可能离线多久;
  • 是否按计划完成 Repair;
  • 使用的 Compaction Strategy;
  • 是否有大范围删除;
  • 是否存在网络分区或集群运维窗口。

在没有可靠 Repair 能力和验证机制的情况下,不应为了节省磁盘盲目把它设得很小。

9.4 常见 Compaction Strategy

Size-Tiered Compaction Strategy(STCS)

将大小相近的 SSTable 归为一组进行合并。适合写入更新较随机、数据生命周期相对均匀的场景。

特点:

  • 写放大通常较低;
  • 可能需要额外磁盘空间完成合并;
  • 不同年龄的数据可能被合并到一起;
  • 时间范围删除的清理不一定理想。

Leveled Compaction Strategy(LCS)

把 SSTable 组织成层级,并尽量限制同一 key 在同一层的重叠范围。

特点:

  • 适合读多、更新较频繁、希望控制读放大的场景;
  • Compaction 活跃度和写放大可能更高;
  • 对磁盘和 I/O 有更高要求。

TimeWindowCompactionStrategy(TWCS)

按时间窗口组织 SSTable,适合时间序列和“写入后很少更新、按时间过期”的数据。

例如:

ALTER TABLE app.user_events
WITH compaction = {
  'class': 'TimeWindowCompactionStrategy',
  'compaction_window_unit': 'DAYS',
  'compaction_window_size': '1'
};

配置含义是按一天一个时间窗口组织数据。实际窗口大小要结合写入时间跨度、TTL、查询范围和删除模式选择,不能机械套用“一天”。

TWCS 的优势是让旧时间窗口更容易整体过期,降低跨时间段的 Compaction。它并不自动解决:

  • 错误的数据模型;
  • 超大 partition;
  • 过多 tombstone;
  • 未完成的 Repair;
  • 跨窗口更新。

9.5 查看和手动触发 Compaction

nodetool compactionstats
nodetool tablestats app.user_events

compactionstats 可以观察正在运行的 Compaction、待处理任务等。输出字段和格式会随版本变化,重点关注:

  • pending tasks 是否长期堆积;
  • 正在执行的任务数量;
  • 读写延迟是否与 Compaction 高峰同时升高;
  • 磁盘空间是否足够。

手动触发:

nodetool compact app user_events

这会强制对表执行一次主要 Compaction。它可能:

  • 消耗大量磁盘 I/O;
  • 需要额外临时磁盘空间;
  • 与自动 Compaction 竞争;
  • 在业务高峰造成延迟抖动。

不能把 nodetool compact 当成日常“清理磁盘”命令。先确认 Compaction Strategy、磁盘余量、SSTable 数量和业务窗口。


十、Repair:用反熵同步修复副本差异

10.1 Repair 解决什么问题

Repair 用于发现并修复副本之间的数据差异。它的核心思想是:对同一个 token 范围的副本计算摘要,找出不同部分,再通过流式传输同步缺失或较新的数据。

可抽象为:

相同 token 范围的多个副本
        ↓
各自构建摘要 / Merkle Tree
        ↓
比较摘要
        ↓
定位不一致范围
        ↓
Streaming 传输缺失数据
        ↓
副本收敛

Repair 不是:

  • Compaction;
  • 备份;
  • 删除误操作的恢复;
  • 应用级冲突解决;
  • 自动纠正错误时间戳;
  • 对所有历史错误数据的语义判断。

如果错误值已经按照 Cassandra 的冲突规则成为“最新值”,Repair 会把它传播得更一致,但不会知道业务上它是错误的。

10.2 为什么 Repair 是删除安全的一部分

考虑 RF=3:

Node A: 旧值
Node B: 旧值
Node C: 旧值

删除后,两个节点成功保存 tombstone:

Node A: tombstone
Node B: tombstone
Node C: 旧值

如果 C 长时间离线,而 A、B 上的 tombstone 先被清理,C 恢复后可能仍带着旧值。及时 Repair 的作用是让 C 也看到 tombstone,使所有副本在墓碑清理前完成同步。

所以 Repair 与 gc_grace_seconds 是一组约束:

节点可能离线的最长时间
+ 故障发现和恢复时间
+ Repair 调度延迟

必须小于墓碑安全保留窗口,且实际运维需要留出余量。若无法满足,就不能仅依赖默认参数保证删除安全。

10.3 Repair 的范围

Repair 可以针对:

  • 某个 keyspace;
  • 某个 table;
  • 全部 token 范围;
  • 某些 token 范围;
  • 某个数据中心或拓扑范围,具体取决于版本和命令选项。

常见查看方式:

nodetool help repair

某些版本中可以使用类似:

nodetool repair app user_events

但不要直接把历史命令参数复制到所有版本。Repair 的选项、增量 Repair 行为和运维推荐会随 Cassandra 版本变化,应以当前集群版本的命令帮助和官方文档为准。

10.4 Repair 期间要观察什么

Repair 不是“执行命令后等成功”这么简单,应同时观察:

nodetool status
nodetool netstats
nodetool compactionstats
nodetool tpstats

重点包括:

  • nodetool status:节点状态、数据中心、机架、负载;
  • nodetool netstats:Streaming 是否持续、是否失败;
  • nodetool compactionstats:Repair 触发的读写压力及 Compaction 堆积;
  • nodetool tpstats:线程池是否出现大量 pending 或 rejected;
  • 监控系统中的读写延迟、超时、磁盘使用率和 GC。

Repair 失败后不能简单认为“下次会自动补齐”。应检查:

  1. 失败发生在哪个节点和 token 范围;
  2. 是否由磁盘空间、网络、超时或节点状态造成;
  3. 是否需要重试特定范围;
  4. 是否已接近 tombstone 保留窗口;
  5. 是否需要暂停业务变更或扩大维护窗口。

十一、Compaction 和 Repair 的区别

二者经常被混淆,但作用完全不同。

机制 主要对象 解决的问题
Compaction 单个节点上的 SSTable 减少 SSTable、合并版本、处理可清理墓碑
Repair 多个副本之间的数据 找出副本差异并同步
Hint 暂时不可用的目标副本 缓存短期缺失写入
Read repair 相关机制 被读取到的 partition 在读路径发现并修复部分差异

一个具体反例:

Node A 的 SSTable 有 v2
Node B 的 SSTable 有 v1

对 A 和 B 各自运行 Compaction,只会分别把本机文件整理得更好:

Node A: 整理后的 v2
Node B: 整理后的 v1

副本之间仍然不一致。只有 Repair 或某些读路径同步机制,才可能让 B 得到 v2。

反过来,Repair 也不等于压实 SSTable。Repair 传输数据后,目标节点可能生成新的 SSTable,仍然需要正常 Compaction。


十二、故障转移与节点替换:状态变化比命令本身更重要

12.1 Gossip 与故障检测

Cassandra 节点通过 Gossip 传播节点状态。故障检测器根据心跳和响应情况估计某个节点是否不可达。

这里的“节点被怀疑故障”不等于“节点数据已经被安全迁移”。它只影响请求路由和副本可用性判断。

网络分区时可能出现:

节点 A 认为 B down
节点 B 认为 A down

如果运维人员在没有确认网络状态的情况下强制移除节点,可能造成:

  • 同一节点被错误替换;
  • 数据复制不完整;
  • token 所属关系与实际数据不一致;
  • 后续 Repair 需要处理更大范围的差异。

12.2 节点重启不等于节点替换

节点短暂重启通常保留原来的 token 和本地数据。启动后,它会通过 Commit Log 恢复内存状态,并继续参与集群。

节点永久损坏或需要替换时,运维语义不同,常见流程包括:

  1. 判断原节点是否真的永久不可恢复;
  2. 防止旧节点在网络中“复活”并使用相同身份;
  3. 选择适合的替换或移除流程;
  4. 等待新节点完成数据流式传输;
  5. 对相关范围执行 Repair;
  6. 验证复制因子、节点状态和查询结果。

Cassandra 提供 removenodeassassinatereplace_address 等不同操作,但它们的危险程度和适用条件不同。特别是强制移除类操作不能作为“节点一时没有响应”的常规处理方式。

执行任何拓扑变更前,应先确认:

nodetool status
nodetool ring
nodetool describecluster

不同版本对 ring 输出的推荐程度可能不同;关键是确认节点身份、状态、数据中心、机架和 token 分布,而不是只看某一行命令输出。

12.3 扩容和缩容也是复制一致性问题

新节点加入后,不是自动立即拥有完整数据。它需要通过 Bootstrap 从负责相关 token 范围的节点流式获取数据。

扩容期间应关注:

  • 新节点是否处于正常加入状态;
  • Streaming 是否完成;
  • 旧节点是否有异常负载;
  • 磁盘和网络是否足够;
  • 扩容后是否需要 Repair;
  • 客户端驱动是否及时发现拓扑变化。

缩容、移动 token 或替换节点同样会触发数据流动。不能只修改配置文件就认为数据已经完成迁移。


十三、常见故障案例

案例一:使用 QUORUM 但仍读到旧值

可能的错误推理是:

RF=3,写和读都是 QUORUM,所以任何情况下都不可能读旧值。

实际还要检查:

  1. 读写是否针对同一个 Partition Key;
  2. 是否使用了不同 keyspace 或错误的复制配置;
  3. 是否跨数据中心并混用了 LOCAL_QUORUM
  4. 是否有客户端显式时间戳;
  5. 是否使用了轻量级事务、普通写和批处理的不同语义;
  6. 是否是写入 timeout 后的重试;
  7. 是否返回的是不同 clustering key 的另一行;
  8. 是否发生了时钟异常或应用层缓存。

诊断可以从以下开始:

nodetool getendpoints app user_events '...'
nodetool status
nodetool gossipinfo

再用不同 CL 查询同一个明确的完整主键,并核对应用传入的时间戳。

案例二:删除后数据又出现

优先排查:

  • 是否有节点在删除期间长期离线;
  • tombstone 是否在 Repair 前被清理;
  • gc_grace_seconds 是否被不当缩短;
  • 是否存在错误的未来时间戳;
  • 是否有旧备份或离线导入重新写回数据;
  • 是否使用了范围删除并误判删除范围。

恢复思路不是立刻反复执行 DELETE。应先确认所有副本状态、执行适当 Repair,并在必要时用明确、递增且受控的时间戳重新写入删除标记。直接盲目降低 gc_grace_seconds 可能把问题变成不可逆的数据复活。

案例三:Compaction pending tasks 长期增长

这通常说明生成 SSTable 的速度超过 Compaction 消化速度,可能原因包括:

  • 写入吞吐上升;
  • 磁盘 I/O 饱和;
  • Compaction Strategy 与数据模型不匹配;
  • 大量更新或删除;
  • Repair、Streaming 与 Compaction 竞争;
  • 磁盘剩余空间不足导致 Compaction 无法安全进行。

不能只执行一次 nodetool compact。强制 Compaction 可能进一步增加 I/O 和临时空间压力。应先查看:

nodetool compactionstats
nodetool tablestats app.user_events
df -h
iostat -x 1

再判断是短期积压还是持续性容量问题。

案例四:节点恢复后状态正常,但数据仍不一致

节点能加入 Gossip 并显示 UN,只说明它在集群中可通信、状态正常,不等于它已经拥有所有应有数据。

还要检查:

  • hint 是否回放完成;
  • Bootstrap 或替换流程是否完成;
  • Repair 是否覆盖离线期间的 token 范围;
  • 是否发生了 tombstone 清理窗口风险;
  • nodetool netstats 是否仍有 Streaming;
  • 关键 partition 在不同副本上的结果是否一致。

十四、事务边界:普通写入、Batch 和 LWT 不能混为一谈

14.1 普通写入不是跨 partition 事务

Cassandra 的普通 INSERTUPDATEDELETE 主要围绕单 partition 设计。即使应用连续执行多个语句,也不会自动获得关系型数据库那样的全局事务。

Cassandra 的 BATCH 也不能简单理解为通用事务。Logged Batch 主要用于把多个写操作作为一个批次传播,并降低同一批次部分写入的暴露;跨多个 partition 的大 Batch 可能把协调和日志压力集中到一个节点,不能用来替代合理的数据模型或分布式事务系统。

14.2 Lightweight Transaction(LWT)

需要条件判断时,可以使用轻量级事务:

INSERT INTO app.user_events
    (user_id, event_day, event_time, event_id, event_type, payload)
VALUES
    ('u-200', '2025-01-01', '2025-01-01 10:00:00+0000',
     22222222-2222-2222-2222-222222222222, 'login', '{}')
IF NOT EXISTS;

该操作使用 Cassandra 的 Paxos 机制,在单个 partition 范围内提供条件更新语义。结果会表明条件是否成立。

例如:

applied
-------
True

或:

applied
-------
False

LWT 不是普通写的“更强版本”这么简单。它有更高的协调和消息交互成本,也有独立的 SERIALLOCAL_SERIAL 一致性选项。它适合并发条件竞争,例如:

  • 创建唯一记录;
  • CAS 更新;
  • 防止同一状态被多个客户端同时覆盖。

不适合把大量普通写全部改成 LWT,也不能据此获得跨多个 partition 的全局事务。


十五、一个可复现的最小实验

以下示例假设:

  • 已有 Cassandra 集群;
  • keyspace 使用 NetworkTopologyStrategy
  • dc1 中至少有 3 个节点;
  • 客户端通过 cqlsh 连接;
  • 具备创建 keyspace 和 table 的权限。

1. 创建测试表

CREATE KEYSPACE IF NOT EXISTS consistency_lab
WITH replication = {
  'class': 'NetworkTopologyStrategy',
  'dc1': 3
};

CREATE TABLE IF NOT EXISTS consistency_lab.kv (
    id text PRIMARY KEY,
    value text
);

2. 使用一致性级别写入和读取

CONSISTENCY QUORUM;

INSERT INTO consistency_lab.kv (id, value)
VALUES ('k1', 'v1');

SELECT * FROM consistency_lab.kv
WHERE id = 'k1';

预期结果:

Consistency: QUORUM

 id | value
----+-------
 k1 | v1

这里的逻辑是:

  • RF=3;
  • QUORUM 需要 2 个副本确认;
  • 读取 QUORUM 需要查询 2 个副本;
  • 正常条件下读写集合相交。

3. 构造 timeout 后的未知结果

在测试环境中人为停止一个或多个副本,或者制造网络延迟,然后执行:

CONSISTENCY QUORUM;

INSERT INTO consistency_lab.kv (id, value)
VALUES ('k1', 'v2');

可能出现:

WriteTimeout:
  received: 1
  required: 2

这并不能推出:

k1 一定仍然是 v1

也不能推出:

k1 一定已经是 v2

正确处理方式是:

  1. 记录请求的业务 ID;
  2. 等待集群状态稳定;
  3. 重新读取该 key;
  4. 根据业务幂等规则决定是否重试;
  5. 检查副本和 Repair 状态。

4. 查看表的存储状态

nodetool tablestats consistency_lab.kv
nodetool compactionstats
nodetool status

这些命令不能直接证明业务数据已经跨所有副本一致,但可以帮助确认:

  • 表是否产生大量 SSTable;
  • Compaction 是否积压;
  • 节点是否正常;
  • 集群是否处于拓扑变更或故障状态。

十六、生产诊断的因果顺序

当出现“读旧值、删除复活、写入超时、磁盘暴涨”时,建议按因果链排查,而不是先执行破坏性命令。

第一步:确认请求目标

核对:

  • keyspace 和 table;
  • 完整 Partition Key;
  • clustering 条件;
  • 读写 CL;
  • 是否跨数据中心;
  • 是否使用 LWT;
  • 是否带客户端时间戳。

很多所谓“一致性问题”其实是读写了不同 partition,或者 clustering 条件不完整。

第二步:确认副本集合

nodetool getendpoints <keyspace> <table> '<partition-key>'

确认实际副本分布在哪些节点、数据中心和机架。

第三步:确认节点和网络状态

nodetool status
nodetool gossipinfo
nodetool netstats

重点区分:

  • 节点是否真正 down;
  • 是否只是响应慢;
  • 是否正在 Bootstrap、搬迁或 Streaming;
  • 是否存在数据中心间网络问题。

第四步:确认存储压力

nodetool compactionstats
nodetool tablestats <keyspace>.<table>
df -h

重点观察:

  • SSTable 数量;
  • pending compaction;
  • tombstone 相关读取警告;
  • 磁盘空间;
  • Compaction 与 Repair 是否争抢资源。

第五步:确认 Repair 与墓碑窗口

如果问题涉及删除、长期离线节点或节点替换,必须确认:

  • 上次 Repair 的范围和时间;
  • 是否存在失败或未覆盖的 token 范围;
  • 节点离线时间;
  • tombstone 是否已经可能被清理;
  • 是否需要先隔离异常节点,避免旧数据重新传播。

十七、必须避免的几个错误结论

“RF=3,所以坏一个节点就完全没问题”

错误。RF=3 只说明有三份副本。能否继续读写取决于一致性级别:

RF=3,CL=QUORUM → 通常需要 2 个可用副本
RF=3,CL=ALL    → 需要 3 个可用副本
RF=3,CL=ONE    → 可能只需要 1 个可用副本

可用性、延迟和一致性是共同决定的。

“Compaction 完成后副本就一致了”

错误。Compaction 只整理本地 SSTable,不负责副本间同步。

“Repair 是备份”

错误。Repair 会让副本收敛,但不能提供按时间点恢复、误删回滚或历史版本保留。需要恢复能力时,应使用专门的备份和恢复方案。

“写入返回 timeout 就可以安全重试”

错误。timeout 可能代表部分副本已经成功。重试是否安全取决于操作是否幂等,以及业务是否能识别重复请求。

“所有读写都用 QUORUM 就是强一致数据库”

错误。QUORUM 通过副本集合重叠提供较强的普通读写语义,但 Cassandra 仍有时间戳冲突解决、最终一致性后台同步、跨数据中心选择、LWT 独立语义和 timeout 未知结果等边界。

“把 gc_grace_seconds 调小就能解决墓碑和磁盘问题”

错误。它可能减少墓碑保留时间,却增加删除复活风险。调整它必须建立在可验证的 Repair 计划、故障恢复时间边界和删除模式分析之上。


十八、如何把这些机制组合起来理解

对一个 Cassandra partition,可以用下面的链条理解:

Partition Key
  ↓
Token
  ↓
复制策略选择副本
  ↓
Coordinator 按 CL 发起读写
  ↓
副本写 Commit Log + Memtable
  ↓
flush 生成 SSTable
  ↓
Compaction 整理本地 SSTable 和墓碑
  ↓
Hint 处理短期副本不可达
  ↓
Repair 检查并同步长期差异
  ↓
故障检测、Streaming 和拓扑操作维持集群成员关系

其中每个机制解决不同问题:

  • 复制:让数据有多个副本;
  • Consistency Level:规定当前请求需要多少副本响应;
  • Quorum:通过读写集合重叠降低读到旧值的可能性并提供相应保证;
  • Compaction:整理单节点本地存储;
  • Hinted Handoff:缓冲短期副本缺失;
  • Repair:修复副本之间的长期差异;
  • 故障处理:在节点、网络和拓扑变化中维持可用性与数据收敛;
  • LWT:在单 partition 范围内提供条件更新的 Paxos 语义。

Cassandra 的可靠性不是由某一个参数单独决定的。复制因子、读写一致性级别、数据中心拓扑、时间戳、Compaction、Repair、墓碑保留时间和故障恢复流程必须一起设计。只有把“请求当下是否成功”和“所有副本最终是否收敛”区分开,才能正确分析 Cassandra 的一致性与运维行为。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。