数据库基础体系 · 第 50/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
Cassandra 分布式数据模型:Partition Key、Clustering 与查询驱动设计
Cassandra 的数据建模不能从“实体有哪些字段”开始,而应从“系统需要执行哪些查询”开始。原因在于:Cassandra 的表结构同时决定了数据如何分布、查询是否能够直接定位副本,以及节点是否需要扫描大量无关数据。
在关系数据库中,通常先建立较为规范化的实体模型,再通过索引、连接和执行计划支持多种查询。Cassandra 则更接近以下过程:
- 列出业务查询;
- 为每个重要查询设计一张或多张表;
- 用 Partition Key 决定数据落在哪个分区和哪些副本;
- 用 Clustering Columns 决定分区内数据的排序和可检索范围;
- 通过冗余表避免运行时 Join;
- 通过分桶、分页和写入策略控制分区大小与热点。
这就是查询驱动设计(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_id 和 event_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_id和device_id共同确定 Partition;event_time和event_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 主要决定三个问题:
- 一行数据属于哪个 Partition;
- 该 Partition 的 token 是什么;
- 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 内部有意义。它决定:
- Partition 内的行排序;
- 哪些范围查询可以直接执行;
- 完整主键如何区分同一 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)
它采用字典序比较:
- 先比较
event_time; - 如果相等,再比较
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 典型业务需求
假设系统需要支持:
- 查询某个用户某天的最新事件;
- 查询某个用户在时间区间内的事件;
- 按设备查询某天的事件;
- 查询某个订单的状态变更历史。
这些查询的 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;
为什么成立:
user_id和bucket_date唯一确定一个 Partition;event_time是第一个 Clustering Column;- 查询对它使用连续范围;
- 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 (...);
典型路径如下:
- 客户端连接到任意一个 Cassandra 节点;
- 该节点成为协调节点;
- 协调节点根据完整 Partition Key 计算 token;
- 根据 Keyspace 的复制策略确定副本节点;
- 将写入发送给相关副本;
- 按一致性级别等待足够副本确认;
- 达到要求后向客户端返回成功。
读取路径类似:
- 协调节点计算 Partition Key;
- 向副本发起读取;
- 根据一致性级别等待响应;
- 比较副本数据版本;
- 必要时在后台进行修复或提示数据不一致;
- 返回结果。
如果查询涉及多个 Partition,例如:
WHERE user_id = ?
AND bucket_date IN (?, ?, ?, ?)
或者应用为了查询所有哈希桶而并发读取 16 个 Partition,那么协调节点需要处理多个独立的读取范围。请求数、响应合并、超时概率都会增加。
因此,“单个查询是否高效”不能只看 CQL 语句短不短,而要看:
触及多少 Partition
每个 Partition 读取多少行
命中多少副本
是否需要服务端过滤
是否需要应用层合并
十四、故障时如何诊断数据模型问题
14.1 查询被拒绝
常见原因:
- 没有提供完整 Partition Key;
- 跳过了前面的 Clustering Column;
- 使用了不支持的排序方向;
- 查询条件与主键顺序不匹配;
- 某些版本或数据类型不支持当前语法。
诊断步骤:
- 展开表的完整
PRIMARY KEY; - 标记 Partition Key 和 Clustering Columns;
- 检查查询是否提供了完整 Partition Key;
- 检查 Clustering 条件是否遵循从左到右的前缀;
- 确认是否错误使用了
ALLOW FILTERING; - 用实际部署版本的 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
可以用以下问题验证一张表是否适合某个查询:
- 查询是否包含完整 Partition Key?
- 查询是否只访问有限数量的 Partition?
- Partition 内是否按 Clustering Key 的前缀和连续范围读取?
- 是否需要跳过 Clustering Column?
- 是否依赖非主键列过滤?
- 是否需要跨 Partition 排序或聚合?
- 单个 Partition 的数据量和写入速率是否可接受?
- 重试、删除、TTL 和多表投影是否具有可处理的故障语义?
如果第 1 项不成立,通常是路由模型错误;如果第 3 项不成立,通常是聚簇顺序错误;如果第 7 项不成立,通常需要分桶或热点拆分;如果第 8 项不成立,则需要补偿、幂等或更强的事务控制。
Cassandra 数据模型的核心不是把关系表“改写成 CQL”,而是把每个高价值查询转换成:
有限的 Partition 定位
+ Partition 内连续的 Clustering 范围
+ 可接受的数据量、热点和一致性代价
这也是 Partition Key、Clustering Columns 与查询驱动设计之间最重要的联系。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:SQL Server 高可用与运维:Backup、Always On、监控和故障恢复
- 下一篇:Cassandra 一致性与运维:复制、Quorum、Compaction、Repair 和故障
- 延伸:数据库复制、分片与高可用:一致性、路由、故障转移和扩容
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论