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

Cassandra 分布式数据模型:Partition Key、Clustering 与查询驱动设计

Cassandra 的数据建模不能从“实体有哪些字段”开始,而应从“系统需要执行哪些查询”开始。原因在于:Cassandra 的表结构同时决定了数据如何分布、查询是否能够直接定位副本,以及节点是否需要扫描大量无关数据。

在关系数据库中,通常先建立较为规范化的实体模型,再通过索引、连接和执行计划支持多种查询。Cassandra 则更接近以下过程:

  1. 列出业务查询;
  2. 为每个重要查询设计一张或多张表;
  3. Partition Key 决定数据落在哪个分区和哪些副本;
  4. Clustering Columns 决定分区内数据的排序和可检索范围;
  5. 通过冗余表避免运行时 Join;
  6. 通过分桶、分页和写入策略控制分区大小与热点。

这就是查询驱动设计(query-driven design)。

本文示例基于 Apache Cassandra 的公开 CQL 语义,适用于 Cassandra 4.x 及后续稳定版本中仍保持兼容的核心数据模型概念。具体驱动程序 API、索引能力和部分运维命令,应以实际部署版本为准。


一、先建立 Cassandra 的数据模型边界

1.1 Keyspace、Table、Partition 和 Row

Cassandra 中常见的层次可以表示为:

Keyspace
└── Table
    ├── Partition
    │   ├── Row
    │   ├── Row
    │   └── Row
    └── Partition
  • Keyspace:类似数据库命名空间,包含复制策略和复制因子等配置。
  • Table:定义列、主键和数据类型。
  • Partition:表中具有相同 Partition Key 的行集合。
  • Row:一条具体数据,唯一性由完整主键决定。

例如:

CREATE TABLE user_events (
    user_id text,
    event_date date,
    event_time timestamp,
    event_id timeuuid,
    event_type text,
    payload text,
    PRIMARY KEY ((user_id, event_date), event_time, event_id)
);

这里:

  • ((user_id, event_date)) 是复合 Partition Key;
  • event_time, event_id 是 Clustering Columns;
  • 完整主键是:
((user_id, event_date), event_time, event_id)

具有相同 user_idevent_date 的所有行属于同一个 Cassandra Partition。

但是,Partition 不是一个固定物理文件,也不是一个单独的 Cassandra 节点。它是数据分布和排序的基本逻辑单位,底层数据会在 Memtable、SSTable 等结构中保存,并由多个副本节点持有。


1.2 主键的三种常见形式

只有单列 Partition Key

PRIMARY KEY (user_id)

含义是:

  • user_id 是 Partition Key;
  • 表中每个 user_id 对应一个 Partition;
  • 没有 Clustering Column;
  • 同一用户只能有一行。

Partition Key 加一个 Clustering Column

PRIMARY KEY (user_id, event_time)

这等价于:

PRIMARY KEY ((user_id), event_time)

含义是:

  • user_id 是 Partition Key;
  • event_time 是 Clustering Column;
  • 同一个用户可以有多条记录;
  • 行在用户分区内按 event_time 排序。

复合 Partition Key 加多个 Clustering Column

PRIMARY KEY (
    (tenant_id, device_id),
    event_time,
    event_id
)

含义是:

  • tenant_iddevice_id 共同确定 Partition;
  • event_timeevent_id 决定 Partition 内的排序和行唯一性;
  • 不同的 (tenant_id, device_id) 是不同 Partition。

必须注意括号:

PRIMARY KEY ((tenant_id, device_id), event_time)

和:

PRIMARY KEY (tenant_id, device_id, event_time)

不是同一个结构。前者表示两个列组成一个复合 Partition Key;后者在 CQL 语法中表示单列 Partition Key 加两个 Clustering Columns。


二、Partition Key:数据分布和路由的第一决定因素

2.1 Partition Key 的核心作用

Partition Key 主要决定三个问题:

  1. 一行数据属于哪个 Partition;
  2. 该 Partition 的 token 是什么;
  3. Cassandra 应该向哪些节点查找或写入这个 Partition 的副本。

Cassandra 通常使用分区器将 Partition Key 映射为 token。以常见的 Murmur3Partitioner 为例,可以抽象表示为:

token = hash(PartitionKey)

节点通过 token ring 负责一段 token 范围。某个 Partition 的 token 落入某个范围后,该范围对应的节点成为首选副本,再依据复制策略选择其他副本。

因此,客户端执行:

SELECT *
FROM user_events
WHERE user_id = 'u-1001'
  AND event_date = '2025-03-08';

时,协调节点可以根据:

(user_id = 'u-1001', event_date = '2025-03-08')

计算 Partition Key 的 token,并定位负责该 Partition 的副本。

协调节点(coordinator)不一定就是数据所在节点。接收请求的任意节点都可以暂时充当协调节点,然后把请求转发给目标副本。


2.2 复合 Partition Key 的哈希对象

对于:

PRIMARY KEY ((tenant_id, device_id), event_time)

哈希的对象不是单独的 tenant_id,也不是单独的 device_id,而是二者组成的复合键。

因此:

(tenant-a, device-1)
(tenant-a, device-2)
(tenant-b, device-1)

通常会产生三个不同的 token,并可能分布在不同节点上。

这意味着复合 Partition Key 可以提高分布粒度,但也会改变查询条件:

-- 可以直接定位一个 Partition
WHERE tenant_id = 'tenant-a'
  AND device_id = 'device-1'

而只提供:

WHERE tenant_id = 'tenant-a'

并不能唯一确定一个 Partition。Cassandra 不会像关系数据库那样自动把所有 device_id 的分区拼接起来,除非客户端自己维护设备列表并发起多次查询,或者表结构专门支持这种访问方式。


2.3 Partition Key 不是“分片键”的全部

把 Partition Key 类比为分片键有助于理解,但还不完整。

它确实决定数据的分布位置,但 Cassandra 的系统还涉及:

  • token ring;
  • 复制策略;
  • 副本数(Replication Factor,RF);
  • 一致性级别(Consistency Level,CL);
  • 数据中心和机架感知;
  • 节点加入、离开和 token 范围迁移。

假设某 Keyspace 在一个数据中心中配置:

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

对一个 Partition,Cassandra 会保存三个副本,但这三个副本可能分布在三个不同节点上。写入请求并不是“写一个节点”,而是由协调节点根据一致性级别决定需要等待多少副本确认。

例如在 RF=3 时:

  • ONE:一个副本确认即可返回;
  • QUORUM:通常需要多数副本确认,即 2 个;
  • ALL:所有副本确认;
  • LOCAL_QUORUM:在当前数据中心内等待本地多数副本确认。

这些级别影响可用性、延迟和读写一致性,但不改变 Partition Key 的逻辑含义。

QUORUM 并不自动意味着全局线性一致性。多数据中心部署中,LOCAL_QUORUM 只等待本地数据中心;此外,副本修复、读写时间戳和故障情况也会影响实际观察结果。


2.4 好的 Partition Key 需要同时满足两个条件

一个可用的 Partition Key 通常需要满足:

条件一:查询能够提供它

如果最重要的查询无法给出完整 Partition Key,Cassandra 就无法直接定位单个 Partition。

条件二:数据和流量能够均匀分布

如果大量请求都命中一个 Partition,即使这个 Partition 所在的节点有多个副本,真正处理该 Partition 的节点仍然可能成为热点。

例如:

PRIMARY KEY ((status), created_at)

如果绝大多数数据的 status 都是 'ACTIVE',那么所有活跃记录都会进入同一个 Partition。查询条件虽然完整,但数据量和请求量都集中在少数副本上。

相反,下面的键通常分布更好:

PRIMARY KEY ((user_id, event_date), event_time)

它通过用户和日期共同分区,使数据既能按用户查询,又能按日期切分。


三、Clustering Columns:Partition 内部的排序结构

3.1 Clustering Column 的作用

Clustering Column 只在一个 Partition 内部有意义。它决定:

  1. Partition 内的行排序;
  2. 哪些范围查询可以直接执行;
  3. 完整主键如何区分同一 Partition 中的多行。

例如:

PRIMARY KEY ((user_id, event_date), event_time, event_id)

对于固定的:

user_id = 'u-1001'
event_date = '2025-03-08'

Partition 内的数据按以下逻辑顺序排列:

(event_time, event_id)

可以把 Clustering Key 看成一个元组:

C = (event_time, event_id)

它采用字典序比较:

  1. 先比较 event_time
  2. 如果相等,再比较 event_id

因此:

(10:00, a) < (10:00, b) < (10:01, c)

event_id 在这里不仅用于去重,也用于处理多个事件拥有相同时间戳的情况。


3.2 为什么完整主键才能唯一定位一行

在同一个 Partition 内,Partition Key 相同。此时,行是否相同取决于完整 Clustering Key。

对于:

PRIMARY KEY ((user_id, event_date), event_time, event_id)

以下两行不同:

(user-1, 2025-03-08, 10:00:00, id-a)
(user-1, 2025-03-08, 10:00:00, id-b)

以下两次写入则针对同一个逻辑行:

(user-1, 2025-03-08, 10:00:00, id-a)
(user-1, 2025-03-08, 10:00:00, id-a)

第二次写入会更新同一行的非主键列,而不是插入另一行。

这也是为什么事件表经常使用 timeuuid 作为最后一个 Clustering Column:

event_id timeuuid

时间戳用于主要排序,timeuuid 用于在相同时间戳下提供唯一性和时间相关的顺序。


3.3 Clustering Order

可以在建表时指定排序方向:

CREATE TABLE user_events (
    user_id text,
    event_date date,
    event_time timestamp,
    event_id timeuuid,
    event_type text,
    payload text,
    PRIMARY KEY ((user_id, event_date), event_time, event_id)
) WITH CLUSTERING ORDER BY (
    event_time DESC,
    event_id ASC
);

这表示:

  • event_time 默认按降序排列;
  • event_time 相同的情况下,event_id 按升序排列。

于是查询:

SELECT event_time, event_id, event_type, payload
FROM user_events
WHERE user_id = 'u-1001'
  AND event_date = '2025-03-08'
LIMIT 20;

可以从该 Partition 的较新数据开始读取。

需要区分两件事:

  • Clustering Order 是表的物理逻辑排序定义;
  • ORDER BY 是查询对结果顺序的要求。

Cassandra 只能在满足表的 Clustering 顺序约束时支持排序。它不是一个可以任意按任意列排序的通用排序引擎。


四、从主键结构推导可执行查询

Cassandra 的查询限制不是任意的语法限制,而是数据布局的直接结果。

设表的主键为:

PRIMARY KEY ((P1, P2), C1, C2, C3)

其中:

  • P1, P2 是 Partition Key;
  • C1, C2, C3 是按顺序排列的 Clustering Columns。

4.1 定位单个 Partition

必须给出完整 Partition Key:

WHERE P1 = ?
  AND P2 = ?

这是最直接、最重要的查询形式。

也可以使用某些支持的等值集合条件,例如:

WHERE P1 = ?
  AND P2 IN (?, ?)

这实际上可能涉及多个 Partition。结果顺序不能依赖多个 Partition 之间的自然顺序,应用应自行排序。


4.2 Partition 内的 Clustering 查询

对 Clustering Columns,通常遵循“从左到右”的约束规则:

C1 可以等值过滤;
在 C1 被等值固定后,C2 可以等值过滤;
在前面的列都被等值固定后,某一列可以使用范围;
范围之后的列通常不能再作为独立范围进行有效裁剪。

例如:

WHERE P1 = ?
  AND P2 = ?
  AND C1 = ?
  AND C2 >= ?
  AND C2 < ?

这是典型的 Partition 内范围查询。

而下面的条件没有固定 C1

WHERE P1 = ?
  AND P2 = ?
  AND C2 = ?

它跳过了第一个 Clustering Column。Cassandra 通常无法利用聚簇排序直接定位这段数据,可能拒绝查询,也可能只有在显式 ALLOW FILTERING 时才执行。


4.3 形式化理解:前缀可搜索性

对固定 Partition,Clustering Key 是有序元组:

(C1, C2, C3)

如果查询指定:

C1 = a
C2 = b

那么数据范围可以缩小为:

(a, b, 最小值) 到 (a, b, 最大值)

如果查询指定:

C1 = a
C2 >= b
C2 < c

那么范围是:

(a, b, 最小值) 到 (a, c, 最大值)

这仍然是一个连续的有序区间,可以通过 SSTable 的索引和排序结构读取。

但如果只指定:

C2 = b

数据中所有不同的 C1 都可能包含 C2 = b。在排序结构中,这些记录并不一定形成一个连续区间。因此 Cassandra 无法仅凭聚簇排序快速定位它们。

这就是以下两个模型的本质差异:

PRIMARY KEY ((user_id), event_time, event_type)

适合:

WHERE user_id = ?
  AND event_time >= ?
  AND event_time < ?

不适合直接支持:

WHERE user_id = ?
  AND event_type = ?

如果第二种查询很重要,应为它建立另一张按 event_type 组织的表,而不是指望运行时扫描。


4.4 Tuple 查询

对于多个 Clustering Columns,可以使用元组范围表达式:

WHERE user_id = ?
  AND (event_time, event_id) > (?, ?)

其含义是按字典序比较整个 Clustering Key:

event_time > given_time
OR (
    event_time = given_time
    AND event_id > given_event_id
)

这对基于游标的分页很有用。不过实际支持细节应结合 Cassandra 版本和客户端驱动验证,尤其是不同数据类型、排序方向和复杂条件组合。


五、查询驱动设计:先写查询,再写表

5.1 典型业务需求

假设系统需要支持:

  1. 查询某个用户某天的最新事件;
  2. 查询某个用户在时间区间内的事件;
  3. 按设备查询某天的事件;
  4. 查询某个订单的状态变更历史。

这些查询的 Partition Key 不同,不能强行用一张规范化表解决。

可以分别设计:

-- 按用户和日期查询
CREATE TABLE user_events_by_day (
    user_id text,
    bucket_date date,
    event_time timestamp,
    event_id timeuuid,
    event_type text,
    payload text,
    PRIMARY KEY ((user_id, bucket_date), event_time, event_id)
) WITH CLUSTERING ORDER BY (
    event_time DESC,
    event_id ASC
);

-- 按设备和日期查询
CREATE TABLE device_events_by_day (
    device_id text,
    bucket_date date,
    event_time timestamp,
    event_id timeuuid,
    event_type text,
    payload text,
    PRIMARY KEY ((device_id, bucket_date), event_time, event_id)
) WITH CLUSTERING ORDER BY (
    event_time DESC,
    event_id ASC
);

写入同一个事件时,应用同时写入两张表:

INSERT INTO user_events_by_day (
    user_id, bucket_date, event_time, event_id,
    event_type, payload
) VALUES (
    'u-1001', '2025-03-08', '2025-03-08 10:15:00+0000',
    now(), 'login', '{"ip":"203.0.113.10"}'
);

INSERT INTO device_events_by_day (
    device_id, bucket_date, event_time, event_id,
    event_type, payload
) VALUES (
    'device-9', '2025-03-08', '2025-03-08 10:15:00+0000',
    now(), 'login', '{"ip":"203.0.113.10"}'
);

示例中两次 now() 会生成两个不同的 timeuuid。如果两张表需要严格保存同一个事件标识,应用应在写入前生成一个 event_id,然后在两个 INSERT 中复用它。

例如:

INSERT INTO user_events_by_day (
    user_id, bucket_date, event_time, event_id,
    event_type, payload
) VALUES (
    'u-1001', '2025-03-08', '2025-03-08 10:15:00+0000',
    8d7c3f20-0b7b-11f0-8f1e-000000000001,
    'login', '{"ip":"203.0.113.10"}'
);

这里的 UUID 必须是有效的 timeuuid。生产代码通常由驱动程序或应用库生成,而不是手工拼接。


5.2 为什么冗余表是正常设计

Cassandra 的表不是“一个事实集合的唯一存储形式”,而是面向查询的存储投影。

同一事实可能被复制到:

user_events_by_day
device_events_by_day
order_events_by_order

这会带来写放大和数据一致性维护成本,但换来:

  • 查询不需要 Join;
  • 查询可以直接定位目标 Partition;
  • 查询延迟更可预测;
  • 节点不需要扫描大量无关数据。

这不是传统关系数据库中的反规范化例外,而是 Cassandra 数据模型的常规方式。


5.3 多表写入的事务边界

应用向多张查询表写入同一个事实时,需要明确事务语义。

普通写入

普通 INSERT 是幂等设计的候选操作:只要相同主键和相同值重复执行,最终结果通常相同。但两张表之间没有自动的跨表事务保证。

可能出现:

user_events_by_day 写入成功
device_events_by_day 写入超时或失败

处理方式包括:

  • 使用可重试的幂等写入;
  • 在应用层记录待补偿事件;
  • 通过消息队列或 CDC 构建异步投影;
  • 定期校验和修复查询表。

Batch

Cassandra 的 logged batch 可以提供一定的批处理原子性语义,但它不是关系数据库事务,也不提供跨任意 Partition 的完整隔离能力。跨多个 Partition 的批量写入还可能增加协调和日志开销。

因此,Batch 更适合有明确原子写入需求、且范围较小的操作,不应作为“把所有冗余表一次性更新”的默认工具。

Lightweight Transaction

IF 条件的写入使用 Cassandra 的轻量级事务机制,例如:

INSERT INTO order_state (
    order_id, version, state
) VALUES (
    'order-1', 3, 'PAID'
) IF NOT EXISTS;

或者:

UPDATE order_state
SET state = 'SHIPPED', version = 4
WHERE order_id = 'order-1'
IF version = 3;

这适合需要条件竞争控制的场景,例如状态机的版本检查,但代价高于普通写入。它不能替代所有跨表事务。


六、分桶:控制 Partition 大小和热点

6.1 为什么“一个用户一个 Partition”不总是正确

下面的表看起来简单:

CREATE TABLE user_events (
    user_id text,
    event_time timestamp,
    event_id timeuuid,
    payload text,
    PRIMARY KEY ((user_id), event_time, event_id)
);

如果每个用户只有少量事件,它可能没有问题。

但如果某个用户每天产生数百万事件:

  • Partition 会持续变宽;
  • 查询和分页需要处理更大的数据范围;
  • 删除和 TTL 会产生更多墓碑;
  • compaction、repair 和流量会受到影响;
  • 单个高活跃用户可能形成热点。

Cassandra 没有适用于所有业务的固定“最大行数”或“最大字节数”阈值。可接受大小取决于:

  • 单行大小;
  • 查询范围;
  • 写入速率;
  • TTL 和删除比例;
  • compaction 策略;
  • repair 窗口;
  • 节点资源;
  • 故障恢复目标。

因此应通过实际负载测试建立分区预算,而不是套用一个脱离业务的数字。


6.2 时间分桶

常见做法是把时间加入 Partition Key:

PRIMARY KEY ((user_id, bucket_date), event_time, event_id)

应用侧计算:

bucket_date = UTC(event_time).date

这样,一名用户一天对应一个 Partition。

查询一天的数据:

SELECT event_time, event_id, event_type, payload
FROM user_events_by_day
WHERE user_id = 'u-1001'
  AND bucket_date = '2025-03-08'
LIMIT 100;

查询跨天范围时,应用需要计算涉及的日期,并分别查询多个 Partition:

2025-03-08
2025-03-09
2025-03-10

然后在应用层合并结果。

如果单日仍然过大,可以使用:

(user_id, bucket_date, bucket_number)

或按小时、按固定时间窗口分桶:

PRIMARY KEY ((user_id, bucket_hour), event_time, event_id)

分桶的代价是查询需要 Fan-out。分桶太细会增加请求数量,分桶太粗会形成宽 Partition。合理边界取决于数据量和查询延迟目标。


6.3 哈希分桶和随机分片

如果单个业务键存在突发热点,例如一个直播间、热门商品或公共租户,可以引入哈希桶:

bucket = hash(event_id) % 16

表结构:

CREATE TABLE room_events (
    room_id text,
    bucket int,
    event_time timestamp,
    event_id timeuuid,
    payload text,
    PRIMARY KEY ((room_id, bucket), event_time, event_id)
);

写入时按事件 ID 计算桶:

(room-9, 0)
(room-9, 1)
...
(room-9, 15)

查询一个房间的全部事件时,需要查询 16 个 Partition,并在应用层合并。

这种方式可以分散写入热点,但会放大读取请求。它本质上是在“单 Partition 的写入集中度”和“多 Partition 的查询 Fan-out”之间取舍。


七、查询示例:哪些能执行,哪些会失败

先创建示例表:

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

USE app;

CREATE TABLE IF NOT EXISTS user_events_by_day (
    user_id text,
    bucket_date date,
    event_time timestamp,
    event_id timeuuid,
    event_type text,
    payload text,
    PRIMARY KEY ((user_id, bucket_date), event_time, event_id)
) WITH CLUSTERING ORDER BY (
    event_time DESC,
    event_id ASC
);

7.1 正确:完整 Partition Key 加时间范围

SELECT event_time, event_id, event_type
FROM user_events_by_day
WHERE user_id = 'u-1001'
  AND bucket_date = '2025-03-08'
  AND event_time >= '2025-03-08 00:00:00+0000'
  AND event_time <  '2025-03-09 00:00:00+0000'
LIMIT 100;

为什么成立:

  1. user_idbucket_date 唯一确定一个 Partition;
  2. event_time 是第一个 Clustering Column;
  3. 查询对它使用连续范围;
  4. Cassandra 可以在这个 Partition 内读取对应范围,而不是扫描所有 Partition。

7.2 正确:查询最新若干条

SELECT event_time, event_id, event_type, payload
FROM user_events_by_day
WHERE user_id = 'u-1001'
  AND bucket_date = '2025-03-08'
LIMIT 20;

由于表定义了:

CLUSTERING ORDER BY (event_time DESC, event_id ASC)

结果会按照该 Partition 的聚簇顺序返回,通常先得到较新的事件。

如果查询跨越多个日期,必须查询多个 Partition。不能期待 Cassandra 自动提供全局的“最新 20 条”:

user_id = u-1001, 2025-03-07
user_id = u-1001, 2025-03-08
user_id = u-1001, 2025-03-09

应用需要分别分页并合并,或者设计专门的“最近事件”表。

7.3 可能失败或需要过滤:跳过前面的 Clustering Column

SELECT *
FROM user_events_by_day
WHERE user_id = 'u-1001'
  AND bucket_date = '2025-03-08'
  AND event_id = 8d7c3f20-0b7b-11f0-8f1e-000000000001;

这里没有限制 event_time,却直接限制第二个 Clustering Column event_id。由于 event_id 不是独立的全局索引,Cassandra 无法仅通过聚簇顺序快速定位。

可能的结果包括:

  • 查询被拒绝;
  • 提示需要 ALLOW FILTERING
  • 在特定情况下由服务端执行过滤。

即使加上:

ALLOW FILTERING

也不意味着查询变成了高效索引查询。它只是允许 Cassandra 执行可能扫描大量数据的操作,生产环境中应通过实际执行计划、请求追踪和负载测试确认风险。

7.4 错误:不提供 Partition Key

SELECT *
FROM user_events_by_day
WHERE event_type = 'login';

这要求系统在整个集群范围内寻找数据。Cassandra 通常会拒绝此类查询,因为它无法通过 Partition Key 路由到有限副本。

如果这个查询是业务核心需求,应建立:

CREATE TABLE login_events_by_day (
    bucket_date date,
    event_time timestamp,
    event_id timeuuid,
    user_id text,
    payload text,
    PRIMARY KEY ((bucket_date), event_time, event_id)
);

如果还需要按用户过滤,则可以把用户或用户分桶纳入 Partition Key,具体取决于查询的选择性和流量分布。


八、分页不是 OFFSET:利用 Clustering 顺序继续读取

Cassandra 的分页通常使用驱动程序的自动分页或 page state,而不是关系数据库常见的:

OFFSET 100000 LIMIT 100

OFFSET 需要跳过前面的结果,随着偏移量增大通常越来越昂贵。Cassandra 驱动程序一般会将一次读取拆成多个页面,并保存服务端返回的 page state。

应用需要注意:

  • page state 是不透明值,不应自行解析;
  • page state 应与原查询绑定使用;
  • 不要把它当成永久游标;
  • 在数据持续写入或删除时,分页结果可能受到并发变化影响;
  • 页面大小应根据行大小和延迟目标调节。

如果业务需要稳定的时间线游标,可以将最后一条记录的 Clustering Key 作为应用层游标:

SELECT event_time, event_id, event_type, payload
FROM user_events_by_day
WHERE user_id = 'u-1001'
  AND bucket_date = '2025-03-08'
  AND (event_time, event_id) < (?, ?)
LIMIT 100;

这里的具体 <> 方向必须与表的排序方向和“向前翻页”定义一致。应用不能只保存 event_time,因为同一时间可能存在多条记录,还需要保存完整的 Clustering Key。


九、常见数据建模反例

9.1 用高基数字段作为低选择性查询的唯一入口

PRIMARY KEY ((country), created_at)

如果系统需要“按用户查询”,但表只按国家分区,那么查询某个用户就没有路由入口。

问题不是 country 一定不好,而是它没有覆盖核心查询。

9.2 使用低基数 Partition Key 造成热点

PRIMARY KEY ((tenant_type), event_time)

如果只有少数几种 tenant_type,整个集群可能只有几个活跃 Partition。

即使 Partition 数量不小,也要观察每个 Partition 的写入速率和副本所在节点的负载,不能只看平均分布。

9.3 试图用 Clustering Column 替代 Partition Key

PRIMARY KEY ((tenant_id), status, created_at)

这个结构支持:

WHERE tenant_id = ?
  AND status = ?

但它不支持不带 tenant_id 的全局状态查询:

WHERE status = 'PENDING'

Clustering Column 只在 Partition 内排序,不提供集群范围的路由能力。

9.4 误把 Cassandra 表当成关系表

下面的查询在 Cassandra 中通常不可行:

SELECT u.name, e.event_type
FROM users u
JOIN events e ON u.id = e.user_id;

Cassandra 的 CQL 不以通用 Join 为数据访问基础。需要 Join 的结果应:

  • 在写入时预先构造;
  • 在应用层分别查询后合并;
  • 交给专门的分析系统或搜索系统处理。

9.5 为了避免重复而强行规范化

如果一个页面必须同时展示:

user_id
user_name
event_time
event_type

而查询表只保存 user_id,应用还需要每次读取用户表,再读取事件表,那么请求会产生额外网络往返和失败组合。

在 Cassandra 中,适度复制 user_name 往往比运行时 Join 更符合系统目标。但这也意味着用户改名时需要处理历史投影是否同步更新的问题。是否复制,应由查询一致性要求决定。


十、非主键列、索引和过滤的边界

10.1 非主键列不能自动参与高效定位

例如:

CREATE TABLE orders_by_customer (
    customer_id text,
    order_id uuid,
    status text,
    created_at timestamp,
    PRIMARY KEY ((customer_id), order_id)
);

以下查询不能因为 status 是普通列就自动高效:

WHERE customer_id = ?
  AND status = 'PAID'

它可能需要读取该客户 Partition 中的多行再过滤。

如果“按客户查询已支付订单”是核心查询,应直接设计表:

CREATE TABLE paid_orders_by_customer (
    customer_id text,
    created_at timestamp,
    order_id uuid,
    PRIMARY KEY ((customer_id), created_at, order_id)
);

10.2 二级索引不是主键建模的替代品

Cassandra 提供过多种索引能力,现代版本还包括不同实现和适用范围。索引是否适合生产,取决于 Cassandra 版本、数据规模、基数、节点数量、查询模式和索引实现。

不能简单推导出:

有索引 = 可以像关系数据库一样自由查询

尤其要警惕:

  • 低选择性字段;
  • 高写入字段;
  • 跨大量 Partition 的查询;
  • 索引节点本身成为瓶颈;
  • 索引与基础数据的一致性和恢复复杂度。

主键模型仍应优先覆盖核心查询。索引更适合经过验证的补充场景,而不是用来掩盖 Partition Key 设计缺失。


十一、删除、TTL 与墓碑会反过来影响数据模型

Cassandra 的删除通常不是立即从所有 SSTable 中物理擦除数据,而是写入墓碑(tombstone)。TTL 到期也会产生类似的逻辑删除信息。只有在 compaction 等过程满足条件后,旧数据和墓碑才可能被清理。

因此,以下设计可能带来较大运维压力:

一个超大的 Partition
+ 高频更新
+ 大量 TTL
+ 频繁删除

可能表现为:

  • 读取延迟升高;
  • compaction 压力增加;
  • tombstone 警告;
  • repair 和流量放大;
  • 某些查询因墓碑扫描过多而超时。

时间分桶不仅控制查询范围,也控制墓碑和 compaction 的影响范围。对于日志、事件、会话等有明确保留期的数据,应同时设计:

  • 时间分桶;
  • TTL;
  • compaction 策略;
  • 删除方式;
  • repair 和监控窗口。

这些配置的具体组合属于部署和工作负载问题,不能仅凭表结构决定。


十二、写入并发、时间戳和“最后写入获胜”

Cassandra 的普通写入不是通过行锁来实现并发控制。不同副本之间使用单元格级时间戳来决定哪个值胜出。抽象地说,对于同一个列,较新的写入时间戳会覆盖较旧的写入。

因此,多个客户端并发更新同一逻辑行时,可能出现:

客户端 A:status = 'PAID'
客户端 B:status = 'CANCELLED'

最终值取决于 Cassandra 看到的写入时间戳,而不一定是业务上“最后完成”的请求。

这带来几个边界:

  • 应避免让多个写入者无协调地更新同一业务状态;
  • 重试必须考虑幂等性;
  • 不要把网络响应时间简单等同于数据库写入顺序;
  • 需要条件更新时使用 LWT 或应用层版本控制;
  • 不要随意手工设置时间戳,除非已经明确理解其覆盖语义。

例如:

UPDATE order_state
SET state = 'SHIPPED', version = 4
WHERE order_id = 'order-1'
IF version = 3;

如果当前版本不是 3,条件更新不会成功,并返回条件未满足的信息。应用必须检查返回结果,而不是把“请求发送成功”当成状态更新成功。


十三、从客户端到副本的读写路径

以一个写入请求为例:

INSERT INTO user_events_by_day (...)
VALUES (...);

典型路径如下:

  1. 客户端连接到任意一个 Cassandra 节点;
  2. 该节点成为协调节点;
  3. 协调节点根据完整 Partition Key 计算 token;
  4. 根据 Keyspace 的复制策略确定副本节点;
  5. 将写入发送给相关副本;
  6. 按一致性级别等待足够副本确认;
  7. 达到要求后向客户端返回成功。

读取路径类似:

  1. 协调节点计算 Partition Key;
  2. 向副本发起读取;
  3. 根据一致性级别等待响应;
  4. 比较副本数据版本;
  5. 必要时在后台进行修复或提示数据不一致;
  6. 返回结果。

如果查询涉及多个 Partition,例如:

WHERE user_id = ?
  AND bucket_date IN (?, ?, ?, ?)

或者应用为了查询所有哈希桶而并发读取 16 个 Partition,那么协调节点需要处理多个独立的读取范围。请求数、响应合并、超时概率都会增加。

因此,“单个查询是否高效”不能只看 CQL 语句短不短,而要看:

触及多少 Partition
每个 Partition 读取多少行
命中多少副本
是否需要服务端过滤
是否需要应用层合并

十四、故障时如何诊断数据模型问题

14.1 查询被拒绝

常见原因:

  • 没有提供完整 Partition Key;
  • 跳过了前面的 Clustering Column;
  • 使用了不支持的排序方向;
  • 查询条件与主键顺序不匹配;
  • 某些版本或数据类型不支持当前语法。

诊断步骤:

  1. 展开表的完整 PRIMARY KEY
  2. 标记 Partition Key 和 Clustering Columns;
  3. 检查查询是否提供了完整 Partition Key;
  4. 检查 Clustering 条件是否遵循从左到右的前缀;
  5. 确认是否错误使用了 ALLOW FILTERING
  6. 用实际部署版本的 CQL 文档和测试环境验证。

14.2 查询超时

查询超时不一定意味着节点宕机,也可能是:

  • 查询触及过多 Partition;
  • 单个 Partition 过宽;
  • 读取大量墓碑;
  • 节点负载过高;
  • 一致性级别需要等待不可用副本;
  • 网络或 GC 延迟;
  • 应用并发 Fan-out 过大。

应结合:

  • 客户端请求指标;
  • Cassandra 节点读写延迟;
  • 超时和不可用错误;
  • 请求追踪(tracing);
  • compaction、pending compaction 和墓碑相关指标;
  • Partition 大小和热点分布。

不要通过无限增加客户端超时时间来掩盖错误的查询模型。

14.3 写入超时但数据可能已经写入

分布式写入中,客户端收到 timeout 并不等于所有副本都没有写入。可能的状态是:

客户端未收到足够确认
但部分副本已经保存数据

因此,对写入进行重试时必须设计幂等键。使用相同完整主键重新写入,通常比每次重试都生成新事件 ID 更安全;否则一次业务操作可能产生多条重复事件。

14.4 数据跨查询表不一致

如果一条业务事实写入多张表,发现一张表有数据、另一张表没有数据,优先检查:

  • 应用是否使用了同一个业务事件 ID;
  • 是否有部分写入失败;
  • 是否发生了错误重试;
  • 是否使用了不同的一致性级别;
  • 异步投影队列是否积压;
  • 是否存在 TTL 或删除时间差;
  • repair 解决的是副本一致性,不会自动理解应用层两张表是否应当一致。

repair 能修复同一个 Partition 的副本差异,但不能自动补写另一张查询表。


十五、建模过程的完整算例

假设需求是:

每个用户每月产生大量操作事件;
需要查询:
1. 某用户某天的事件;
2. 某用户某个时间范围内的事件;
3. 返回最新事件;
4. 单个查询不能扫描全表;
5. 用户数据量可能高度不均匀。

第一步:确定查询定位字段

所有查询都包含:

user_id

同时查询按日期或时间范围执行,因此候选 Partition Key 是:

(user_id, time_bucket)

第二步:确定 Partition 内排序字段

需要按时间范围和最新事件查询,因此第一个 Clustering Column 应是:

event_time

同一时间可能有多条事件,因此增加:

event_id

第三步:形成主键

PRIMARY KEY ((user_id, time_bucket), event_time, event_id)

第四步:确定桶粒度

如果按天后单个用户仍可能产生过多数据,则使用小时桶:

CREATE TABLE user_events_by_hour (
    user_id text,
    bucket_hour timestamp,
    event_time timestamp,
    event_id timeuuid,
    event_type text,
    payload text,
    PRIMARY KEY ((user_id, bucket_hour), event_time, event_id)
) WITH CLUSTERING ORDER BY (
    event_time DESC,
    event_id ASC
);

应用需要把时间归一化到小时边界:

2025-03-08 10:42:17 UTC
→ bucket_hour = 2025-03-08 10:00:00 UTC

第五步:推导查询

查询一个小时:

SELECT *
FROM user_events_by_hour
WHERE user_id = 'u-1001'
  AND bucket_hour = '2025-03-08 10:00:00+0000'
LIMIT 100;

查询三小时范围:

应用计算:
10:00
11:00
12:00

分别查询三个 Partition,再在应用层按 (event_time, event_id) 合并。

第六步:检查代价

这个设计的代价是:

  • 查询跨较长时间范围时会 Fan-out;
  • 写入和读取都需要计算桶;
  • 跨桶全局排序由应用负责;
  • 如果需要“用户所有历史事件的总数”,需要单独维护计数模型,不能每次扫描所有桶。

但它明确控制了:

  • 单个 Partition 的最大时间跨度;
  • 查询的路由范围;
  • 单个热点用户的影响范围;
  • 时间线读取的排序方式。

十六、Partition Key、Clustering 和查询驱动设计的关系

三者不是三个独立的优化选项,而是一条因果链:

查询条件
   ↓
Partition Key 决定查询能否路由到有限副本
   ↓
Clustering Columns 决定 Partition 内能否读取连续范围
   ↓
分桶和冗余表决定 Partition 大小、热点和 Fan-out

可以用以下问题验证一张表是否适合某个查询:

  1. 查询是否包含完整 Partition Key?
  2. 查询是否只访问有限数量的 Partition?
  3. Partition 内是否按 Clustering Key 的前缀和连续范围读取?
  4. 是否需要跳过 Clustering Column?
  5. 是否依赖非主键列过滤?
  6. 是否需要跨 Partition 排序或聚合?
  7. 单个 Partition 的数据量和写入速率是否可接受?
  8. 重试、删除、TTL 和多表投影是否具有可处理的故障语义?

如果第 1 项不成立,通常是路由模型错误;如果第 3 项不成立,通常是聚簇顺序错误;如果第 7 项不成立,通常需要分桶或热点拆分;如果第 8 项不成立,则需要补偿、幂等或更强的事务控制。

Cassandra 数据模型的核心不是把关系表“改写成 CQL”,而是把每个高价值查询转换成:

有限的 Partition 定位
+ Partition 内连续的 Clustering 范围
+ 可接受的数据量、热点和一致性代价

这也是 Partition Key、Clustering Columns 与查询驱动设计之间最重要的联系。


系列导航与关联阅读

官方资料

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