Cassandra与传统关系型数据库的分页方式差别很大。它没有 LIMIT OFFSET 这种语法意义上的偏移量分页,取而代之的是一种基于游标的机制。每次查询执行后,服务端会返回一个不透明的二进制串,也就是paging state,客户端把它带回下一次请求,就能从上次中断的位置继续读取。这个设计看似简单,背后却牵扯到Cassandra存储引擎、一致性协议以及安全边界等一整套问题。理解透它,是用好Cassandra大结果集查询的前提。

paging state到底是什么,里面装了什么
从Cassandra native protocol的角度看,paging state是服务端在返回查询结果时附带的一个bytes类型字段。当一次SELECT语句的结果集超过设定的fetch size时,服务端只返回前N行,并把这N行之后的位置信息序列化成一个不透明字符串放进响应消息。客户端下一次携带这个字符串重发同一条语句,服务端就能定位到上次结束的位置,继续返回后续数据,如此往复直到结果集读完。
之所以说它不透明,是因为这个二进制串的结构属于内部实现细节,不同版本之间并不保证兼容。不过大致可以拆成几部分:一部分是分区键的剩余部分(Remainder of the partition key),记录当前正在读取的分区从哪一行之后继续;一部分是CQL行位置的Cell名称(CQL row cell name),用于标记静态行或分区内部的具体位置;还有一部分是分页范围内最后一行 last-replayed batch log UUID 的序号,用于保证重放场景下的幂等语义。早期2.x版本里甚至直接嵌入了聚类键(clustering key)的值,后来为了安全考虑做了模糊化处理,避免攻击者通过伪造字符串读取任意行。
拿Java驱动4.x来说,取值和回传的API非常直接:
PreparedStatement ps = session.prepare("SELECT id, name FROM users WHERE bucket = ?");
BoundStatement bs1 = ps.bind(1).setPageSize(100);
ResultSet rs1 = session.execute(bs1);
// 读取完本页数据后取出分页状态
ByteBuffer state = rs1.getExecutionInfo().getPagingState();
if (state != null) {
// 下一页请求把状态带回服务端
BoundStatement bs2 = ps.bind(1).setPageSize(100)
.setPagingState(state);
ResultSet rs2 = session.execute(bs2);
// rs2 从上次结束的位置继续返回
}注意getPagingState返回的ByteBuffer是可以复用的,但如果你的查询会并发执行多次,建议对它做一次拷贝(duplicate或asReadOnlyBuffer),因为ByteBuffer内部有position指针,被多个线程消费时互相影响,这是实际项目中反复出现的一类诡异Bug来源。
为什么不能把paging state直接丢给前端
很多团队在给App或Web端做列表接口时,习惯把关系型数据库时代的offset、cursor直接下发给客户端。paging state如果照搬这个思路,会埋下严重的安全隐患。前面提到,这个串编码了分区键和行位置的原始信息,虽然新版本做了模糊化,但它本质上仍然是服务端内部状态的暴露。恶意用户拿到一个合法的paging state后,可以尝试篡改或拼接,探测数据库中本不该看到的数据行,这就是典型的越权读取风险。
第二个问题是绑定关系。paging state与具体的查询语句严格绑定,包括分区键的取值、WHERE条件、ORDER BY甚至一致性级别。如果客户端下一次请求携带的语句和生成状态时哪怕有一点差异,服务端会直接抛出InvalidRequestException,提示不能在不同语句之间复用分页状态。所以在对外接口里,正确的做法是服务端自己保存状态:客户端只传一个业务层面的页码或token,服务端用类似Redis的结构把token映射到对应的paging state,设置合理过期时间后再执行查询回传。这样既隐藏了内部细节,又能控制会话生命周期。
第三个坑是数据一致性。Cassandra的分页状态不是快照语义。在翻页过程中,如果数据发生了写入或删除,可能出现重复读或漏读:某一行在第一页已经返回,随后被更新且聚类键变化,第二页可能再次读到它;反过来,某行被删除后重写,翻页也可能跳过。对绝大多数展示类场景这可以接受,但如果做数据导出、对账这类要求严格的任务,就不能依赖裸的分页状态,而应该结合TOKEN函数做范围切分,或者用Spark connector这类基于token range的方案,保证每个分片只处理一次。
深度分页的工程实践与替代方案
在数据量特别大的场景下,比如单分区几百万行,即便fetch size调大,靠不断回传paging state逐页翻下去,延迟和资源消耗也会越来越不理想。更工程化的做法是利用Cassandra的数据分布特性:先通过system.peers和本地节点拿到全部token范围,再用TOKEN函数切分:
-- 按token范围扫描,天然可并行、可断点续传 SELECT * FROM events WHERE token(pk) > -9223372036854775808 AND token(pk) <= -4611686018427387904 AND bucket = '2024-01';
每个token范围内部仍然使用paging state逐页推进,外部则用token切分成大量独立子任务。这样组合出来的方案,既保留了分页状态的连续性,又具备了并行度和断点重跑能力,是做全表导出和增量迁移的主流做法。DataStax Bulk Loader内部走的就是类似的思路。
另外几个实践要点值得记下来。其一,fetch size不是越大越好,默认5000在多数场景偏大,一般建议100到500,让单次网络往返的数据量控制在合理范围,配合驱动内部的预取机制体验更顺滑。其二,异步驱动下要小心分页状态的时序:必须等当前页全部消费完再取getPagingState,否则拿到的是中间态。其三,二级索引查询同样支持分页状态,但由于二级索引底层要先查索引表再回表,状态里携带的信息链路更长,翻页成本更高,大结果集场景尽量用物化视图或手工反规范化表替代。
总结一下,paging state是Cassandra提供的高效游标机制,轻量、服务端无状态存储成本,适合绝大多数顺序翻页场景;但它不保证快照一致性、与语句强绑定、不宜直接暴露给外部客户端。对外做接口时封装成业务token,对内做大数据量扫描时叠加token range切分,把这两种手段用对,Cassandra的分页问题基本就稳了。
Cassandra分页paging stateCassandraToken修改时间:2026-09-14 22:34:43