分页与游标
主线位置:查询主线的一个分叉。
Select<E>在.all(&db)之前先交给paginate或cursor_by:前者每次取页时往语句上加LIMIT/OFFSET,另外能发一条COUNT;后者在all的时候才加WHERE 键 > 值、ORDER BY 键和LIMIT。加完之后都还是走query_all→FromQueryResult,和普通查询一样(一次 find 的全过程)。
flowchart TB sel["Select E"] -->|paginate| pg[Paginator] pg --> fp["fetch_page n"] pg --> ni[num_items] fp --> lo["LIMIT 和 OFFSET"] ni --> cnt["COUNT 包子查询"] sel -->|cursor_by| cur[Cursor] cur --> ab["after 和 before"] cur --> fl["first 和 last"] ab --> allq["all 时拼 WHERE"] fl --> allq allq --> ob["ORDER BY 键列"]
实验 crate 依赖 sea-orm =2.0.4(sqlx-postgres、runtime-tokio-rustls、macros、with-json、postgres-array、serde,加上默认 feature),对照组是 sea-orm 1.1.20(本站用的版本)。平台 M2 Max、macOS、rustc 1.99.0、Postgres 16.14。表 item(id serial, cat text, score int) 有 10 行:
| id | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|
| cat | a | b | a | c | b | a | c | b | a | c |
| score | 50 | 40 | 40 | 30 | 30 | 30 | 20 | 20 | 10 | 10 |
SQL 都是 sqlx 的语句日志,即实际发出的文本;LIMIT / OFFSET 是绑定参数(一次 find 的全过程 里验证过)。
Paginator:LIMIT / OFFSET,外加一条 COUNT
PaginatorTrait::paginate(db, page_size) 实现在 Select、SelectTwo(先 into_model())、Selector(直接装)、SelectorRaw(把原始 SQL 包成 Expr::cust)上,结果都是一个 Paginator:
pub struct Paginator<'db, C, S>
where
C: ConnectionTrait,
S: SelectorTrait + 'db,
{
pub(crate) query: SelectStatement,
pub(crate) page: u64,
pub(crate) page_size: u64,
pub(crate) db: &'db C,
pub(crate) selector: PhantomData<S>,
}
一条语句、一个页码、一个页大小,加上连接的引用。paginate 里有一句 assert!(page_size != 0, "page_size should not be zero"),页大小为 0 是 panic,不是错误。取页是 fetch_page:
pub async fn fetch_page(&self, page: u64) -> Result<Vec<S::Item>, DbErr> {
let query = self
.query
.clone()
.limit(self.page_size)
.offset(self.page_size * page)
.to_owned();
let rows = self.db.query_all(&query).await?;
// ...
}
每次克隆原语句,加 LIMIT page_size OFFSET page_size * page,页码从 0 开始。总数是 num_items:
pub async fn num_items(&self) -> Result<u64, DbErr> {
let query = SelectStatement::new()
.expr(Expr::cust("COUNT(*) AS num_items"))
.from_subquery(
self.query
.clone()
.reset_limit()
.reset_offset()
.clear_order_by()
.to_owned(),
"sub_query",
)
.to_owned();
let result = match self.db.query_one(&query).await? {
Some(res) => res,
None => return Ok(0),
};
Ok(result.try_get::<i64>("", "num_items")? as u64)
}
原语句去掉 LIMIT、OFFSET、ORDER BY,整个包成子查询再 COUNT(*)。num_pages 是 num_items 之后按 compute_pages_number 向上取整。src/bin/paginate.rs:
let p = item::Entity::find().order_by_asc(item::Column::Id).paginate(&db, 3);
println!("num_items = {}", p.num_items().await?);
println!("num_pages = {}", p.num_pages().await?);
println!("page 0 = {:?}", ids(&p.fetch_page(0).await?));
println!("page 3 = {:?}", ids(&p.fetch_page(3).await?));
println!("page 4 = {:?}", ids(&p.fetch_page(4).await?));
--- 1. paginate(3): num_items / num_pages / fetch_page
INFO sqlx::query: summary="SELECT COUNT(*) AS num_items …" db.statement="\n\nSELECT COUNT(*) AS num_items FROM (SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\") AS \"sub_query\"\n" rows_affected=1 rows_returned=1 elapsed=2.217917ms elapsed_secs=0.002217917
num_items = 10
INFO sqlx::query: summary="SELECT COUNT(*) AS num_items …" db.statement="\n\nSELECT COUNT(*) AS num_items FROM (SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\") AS \"sub_query\"\n" rows_affected=1 rows_returned=1 elapsed=268.541µs elapsed_secs=0.000268541
num_pages = 4
INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" ORDER BY \"item\".\"id\" ASC LIMIT $1 OFFSET $2\n" rows_affected=3 rows_returned=3 elapsed=2.098583ms elapsed_secs=0.002098583
page 0 = [1, 2, 3]
INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" ORDER BY \"item\".\"id\" ASC LIMIT $1 OFFSET $2\n" rows_affected=1 rows_returned=1 elapsed=325.416µs elapsed_secs=0.000325416
page 3 = [10]
INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" ORDER BY \"item\".\"id\" ASC LIMIT $1 OFFSET $2\n" rows_affected=0 rows_returned=0 elapsed=323.541µs elapsed_secs=0.000323541
page 4 = []
num_items和num_pages各发了一条COUNT,结果不缓存。两个都要时用num_items_and_pages,只数一次。- 子查询里保留了全部列:
COUNT(*)用不着它们,但 sea-orm 不改写原语句的SELECT列表,交给 Postgres 的优化器。 - 超出范围的页(
page 4)是空Vec,不是错误。fetch_and_next/into_stream就靠「拿到空页」判断结束。 - 每次
fetch_page都是独立的一条语句,各自借一次连接,两页之间没有快照,这是后面「数据变化」一节的根源。
Paginator 不检查有没有 ORDER BY,结构体文档只写了一句「Add an ORDER BY to your query — without one, page contents are not deterministic across calls.」。没有 ORDER BY 时 Postgres 不保证两次扫描的顺序相同,OFFSET 3 跳过的三行和上一页给你的三行不一定是同一批。Loader 与 N+1 里 Entity Loader 的 paginate 就是没有 ORDER BY 的例子。
group_by 与 distinct
因为整条原语句原样进了子查询,GROUP BY、DISTINCT 的语义都保留,数出来的是「组数」「去重后的行数」,正好是分页要的总数:
--- 2. group_by
INFO sqlx::query: summary="SELECT COUNT(*) AS num_items …" db.statement="\n\nSELECT COUNT(*) AS num_items FROM (SELECT \"item\".\"cat\", COUNT(\"item\".\"id\") AS \"n\" FROM \"item\" GROUP BY \"item\".\"cat\") AS \"sub_query\"\n" rows_affected=1 rows_returned=1 elapsed=1.612167ms elapsed_secs=0.001612167
num_items = 3
INFO sqlx::query: summary="SELECT \"item\".\"cat\", COUNT(\"item\".\"id\") AS …" db.statement="\n\nSELECT \"item\".\"cat\", COUNT(\"item\".\"id\") AS \"n\" FROM \"item\" GROUP BY \"item\".\"cat\" ORDER BY \"item\".\"cat\" ASC LIMIT $1 OFFSET $2\n" rows_affected=2 rows_returned=2 elapsed=837.541µs elapsed_secs=0.000837541
page 0 = [("a", 4), ("b", 3)]
--- 3. distinct
INFO sqlx::query: summary="SELECT COUNT(*) AS num_items …" db.statement="\n\nSELECT COUNT(*) AS num_items FROM (SELECT DISTINCT \"item\".\"score\" FROM \"item\") AS \"sub_query\"\n" rows_affected=1 rows_returned=1 elapsed=869.708µs elapsed_secs=0.000869708
num_items = 5
对应的代码(select_only().column(Cat).column_as(Id.count(), "n").group_by(Cat).into_tuple::<(String, i64)>().paginate(&db, 2) 和 select_only().column(Score).distinct().into_tuple::<i32>())都是先 into_tuple 再 paginate,Selector 也实现了 PaginatorTrait。如果 num_items 是在原语句上把列换成 COUNT(*),GROUP BY 的结果就会变成「每组一个计数」,所以包一层子查询是唯一不用理解原语句就能数对的写法,代价是 Postgres 要把子查询先算出来。
count 与 num_items
PaginatorTrait 还有一个 count,也是 SELECT COUNT(*) FROM (..),但子查询只去掉 ORDER BY:
let paginator = self.paginate(db, 1);
let query = SelectStatement::new()
.expr(Expr::cust("COUNT(*) AS num_items"))
.from_subquery(
paginator.query.clone().clear_order_by().to_owned(),
"sub_query",
)
.to_owned();
同一个带 limit(2) 的查询,2.0.4 和 1.1.20 各数一次(src/bin/paginate.rs 的第 4 段,exp-seaorm-select2-old/src/bin/count_old.rs):
let q = item::Entity::find().order_by_asc(item::Column::Id).limit(2);
println!("count = {}", q.clone().count(&db).await?);
println!("num_items = {}", q.paginate(&db, 2).num_items().await?);
--- 4. limit(2) then count vs paginate.num_items
INFO sqlx::query: summary="SELECT COUNT(*) AS num_items …" db.statement="\n\nSELECT COUNT(*) AS num_items FROM (SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" LIMIT $1) AS \"sub_query\"\n" rows_affected=1 rows_returned=1 elapsed=644.208µs elapsed_secs=0.000644208
count = 2
INFO sqlx::query: summary="SELECT COUNT(*) AS num_items …" db.statement="\n\nSELECT COUNT(*) AS num_items FROM (SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\") AS \"sub_query\"\n" rows_affected=1 rows_returned=1 elapsed=246.292µs elapsed_secs=0.000246292
num_items = 10
sea-orm 1.1.20: count = 10
sea-orm 1.1.20: num_items = 10
2.0.4 的 count 尊重 LIMIT,num_items 不尊重;1.1.20 两个都是 10。num_items 回答的是「不分页一共有多少」,所以必须扔掉 LIMIT / OFFSET;count 回答的是「这条查询会返回多少行」。
count() 在 2.0.0 才开始尊重 limit1.1.20 的 count 只有一行 self.paginate(db, 1).num_items().await,借用了 num_items 的 reset_limit() / reset_offset(),于是 .limit(2).count() 得到全表行数。#3097(「Move count() to CountTrait」)把它列为破坏性改动:「Move the count method into CountTrait to prevent accidental resets of limit and offset」。合并时没有真的新建 CountTrait,提交说明写的是「Instead of a dedicated CountTrait, keep count() as a default method on PaginatorTrait. It wraps the query as-is in a SELECT COUNT(*) FROM (..) subquery, so it honors any limit/offset set on the query, unlike num_items which resets them.」。首次发布于 2.0.0(不在 2.0.0-rc.43 里)。本站在 1.1.20 上,升级后凡是对带 limit 的查询调 count 的地方,结果会变。
Cursor:按键值翻页
OFFSET n 要让数据库先数出并扔掉 n 行,页越靠后越慢,而且「第 n 行」随数据变化而移动。游标分页(keyset pagination)换一种说法:「给我键比上一页最后一个值大的前 k 行」。Select::cursor_by(cols)(cursor_by)返回 Cursor:
pub struct Cursor<S>
where
S: SelectorTrait,
{
query: SelectStatement,
table: DynIden,
order_columns: Identity,
secondary_order_by: Vec<(DynIden, Identity)>,
first: Option<u64>,
last: Option<u64>,
before: Option<ValueTuple>,
after: Option<ValueTuple>,
sort_asc: bool,
is_result_reversed: bool,
phantom: PhantomData<S>,
}
after / before / first / last / asc / desc 只往字段里记值,语句在 all 里才改:
pub async fn all<C>(&mut self, db: &C) -> Result<Vec<S::Item>, DbErr>
where
C: ConnectionTrait,
{
self.apply_limit();
self.apply_order_by();
self.apply_filters()?;
let rows = db.query_all(&self.query).await?;
// ...
if self.is_result_reversed {
buffer.reverse()
}
Ok(buffer)
}
apply_order_by 先 clear_order_by(),再按游标列排序:你在 Select 上写的 order_by 会被丢掉,排序只能由游标列决定。方向由 sort_asc 和「是不是 last」共同决定:last(n) 把方向反过来取 n 行,再在内存里 reverse(),让结果仍按原方向排列。apply_filters 里比较符也跟着方向走:
let condition = self.apply_filter(values, |c, v| {
let exp = Expr::col((self.table.clone(), c.clone()));
if self.sort_asc { exp.gt(v) } else { exp.lt(v) }
});
self.query.cond_where(condition);
after 的意思是「排序方向上在它后面」:升序是 >,降序是 <;before 反过来。src/bin/cursor.rs 的前两段:
let mut c = item::Entity::find().cursor_by(item::Column::Id);
println!("{:?}", ids(&c.desc().after(8).first(3).all(&db).await?));
let mut c = item::Entity::find().cursor_by(item::Column::Id);
println!("{:?}", ids(&c.after(2).before(7).last(2).all(&db).await?));
--- 1. cursor_by(id).desc().after(8).first(3)
INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" WHERE \"item\".\"id\" < $1 ORDER BY \"item\".\"id\" DESC LIMIT $2\n" rows_affected=3 rows_returned=3 elapsed=3.39ms elapsed_secs=0.00339
[7, 6, 5]
--- 2. cursor_by(id).after(2).before(7).last(2)
INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" WHERE \"item\".\"id\" > $1 AND \"item\".\"id\" < $2 ORDER BY \"item\".\"id\" DESC LIMIT $3\n" rows_affected=2 rows_returned=2 elapsed=1.145084ms elapsed_secs=0.001145084
[5, 6]
第二段是升序游标(默认 asc)取「2 和 7 之间的最后两行」:SQL 里是 DESC LIMIT 2 拿到 6、5,返回前倒成 [5, 6]。
Cursor 没有总数,也没有页码,只能「下一页」「上一页」。需要「共 N 页、跳到第 k 页」的界面用 Paginator;无限滚动、导出、按时间线翻用 Cursor。
all 里拼#2037(「Add cursor pagination desc order」)之前,游标只有升序。那时的 before / after 在被调用的当下就 self.query.cond_where(..),比较符写死成 lt / gt。加了 desc() 之后,比较符取决于方向,而方向可能在 after 之后才设,于是 PR 说明写着「Moved query builder logic inside all because it depends on sort order asc/desc」:after / before 改成只存 ValueTuple,apply_filters 在 all 里按 sort_asc 选 gt 还是 lt,就是上面那段代码。首次发布于 0.12.11,CHANGELOG 记为「Added desc to Cursor paginator」。
复合键
单列游标要求这一列唯一。按一个会重复的列翻页,页边界落在一组相同值的中间时,同组剩下的行会被下一页的 < 跳过。
score 有重复(40 有 id 2、3 两行)。每页 2 行、降序,第二页用第一页最后一行的 score 做 after:
let mut c = item::Entity::find().cursor_by(item::Column::Score);
let v = c.desc().first(2).all(&db).await?;
let last = v.last().map(|m| m.score).unwrap_or_default();
let mut c = item::Entity::find().cursor_by(item::Column::Score);
let v = c.desc().after(last).first(2).all(&db).await?;
办法是在游标里加一个唯一的列做决胜,最常见的是主键:cursor_by((Score, Id))。apply_filter 把两列的「字典序大于」展开成 OR:
(Identity::Binary(c1, c2), ValueTuple::Two(v1, v2)) => Condition::any()
.add(
Condition::all()
.add(Expr::col((self.table.clone(), c1.clone())).eq(v1.clone()))
.add(f(c2, v2)),
)
.add(f(c1, v1)),
即 (c1 = v1 AND c2 > v2) OR c1 > v1。三列再多一层,Identity::Many 用一个 fold 生成 n 个 AND 组。实测(另外加了一个普通过滤条件,看它们怎么拼):
let mut c = item::Entity::find()
.filter(item::Column::Cat.ne("z"))
.cursor_by((item::Column::Score, item::Column::Id));
let v = c.desc().after((30, 5)).first(3).all(&db).await?;
--- 4. cursor_by((score, id)) desc after((30, 5)) first(3), with a filter
INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" WHERE \"item\".\"cat\" <> $1 AND ((\"item\".\"score\" = $2 AND \"item\".\"id\" < $3) OR \"item\".\"score\" < $4) ORDER BY \"item\".\"score\" DESC, \"item\".\"id\" DESC LIMIT $5\n" rows_affected=3 rows_returned=3 elapsed=840.958µs elapsed_secs=0.000840958
[(4, 30), (8, 20), (7, 20)]
- 原来的
WHERE和游标条件用AND连起来,游标条件整体加了括号。 - 两列同一个方向:
ORDER BY score DESC, id DESC。Cursor没有「第一列降序、第二列升序」的写法。 - 值 30 绑了两次(
$2、$4),每个比较都是独立的参数。 - 写成
OR链,没有用 Postgres 支持的行比较(score, id) < ($1, $2)。两种写法结果相同,能不能用上(score, id)的复合索引要看EXPLAIN。
值的个数和列数不一致时,all 在拼语句前返回 DbErr::KeyArityMismatch:
--- 6. arity mismatch
Err(KeyArityMismatch { expected: 2, received: 1 })
一个 Cursor 只用一次
all 收的是 &mut self,三个 apply_* 直接改 self.query。同一个 Cursor 调两次 all,第一次加的条件还在:
let mut c = item::Entity::find().cursor_by(item::Column::Id);
println!("{:?}", ids(&c.after(2).first(2).all(&db).await?));
println!("{:?}", ids(&c.after(4).all(&db).await?));
--- 5. reuse one cursor: after(2).first(2) then after(4)
INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" WHERE \"item\".\"id\" > $1 ORDER BY \"item\".\"id\" ASC LIMIT $2\n" rows_affected=2 rows_returned=2 elapsed=504.417µs elapsed_secs=0.000504417
[3, 4]
INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" WHERE \"item\".\"id\" > $1 AND \"item\".\"id\" > $2 ORDER BY \"item\".\"id\" ASC LIMIT $3\n" rows_affected=2 rows_returned=2 elapsed=512.792µs elapsed_secs=0.000512792
[5, 6]
第二次是 id > 2 AND id > 4。向前翻时多出来的条件恰好被新的条件包含,结果碰巧正确。往回翻就不行了(src/bin/cursor_back.rs,先 after(4).first(2) 拿到 5、6,再在同一个游标上 before(5).last(2) 想拿 3、4):
INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" WHERE \"item\".\"id\" > $1 ORDER BY \"item\".\"id\" ASC LIMIT $2\n" rows_affected=2 rows_returned=2 elapsed=4.910542ms elapsed_secs=0.004910542
[5, 6]
INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" WHERE \"item\".\"id\" > $1 AND \"item\".\"id\" > $2 AND \"item\".\"id\" < $3 ORDER BY \"item\".\"id\" DESC LIMIT $4\n" rows_affected=0 rows_returned=0 elapsed=1.259666ms elapsed_secs=0.001259666
[]
两处在累积:语句里留着第一次的 id > 4,字段 after 也还是 Some(4),第二次 apply_filters 又加了一遍,再加上新的 id < 5,结果是空集。ORDER BY 和 LIMIT 每次会重新设(clear_order_by / limit 覆盖),只有 WHERE 会累积。
想先 clone 一份模板也不行。Cursor 写了 #[derive(Clone)],但派生要求 S: Clone,而 SelectModel<M> 没有实现 Clone(src/bin/cursor_clone.rs):
error[E0599]: the method `clone` exists for struct `sea_orm::Cursor<SelectModel<exp_seaorm_select2::ent::item::Model>>`, but its trait bounds were not satisfied
--> src/bin/cursor_clone.rs:9:17
|
9 | let page0 = c.clone().all(&db).await?;
| ^^^^^ method cannot be called due to unsatisfied trait bounds
|
::: ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/sea-orm-2.0.4/src/executor/cursor.rs:27:1
|
27 | pub struct Cursor<S>
| -------------------- doesn't satisfy `_: Clone`
|
::: ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/sea-orm-2.0.4/src/executor/select.rs:100:1
|
100 | pub struct SelectModel<M>
| ------------------------- doesn't satisfy `_: Clone`
|
= note: the following trait bounds were not satisfied:
`SelectModel<exp_seaorm_select2::ent::item::Model>: Clone`
which is required by `sea_orm::Cursor<SelectModel<exp_seaorm_select2::ent::item::Model>>: Clone`
所以每一页都从 Select 重新 cursor_by,把上一页的最后一个键传进来(下一节的 cursor_page 就是这样写的)。这也符合游标分页的实际用法:每一页通常是一个新的 HTTP 请求,状态只有客户端带回来的那个键。
数据在翻页之间变化
一个「最新在前、每页 3 条」的列表。用户看完第一页,有人插入了一行;稍后又删掉了两行。两种分页各取一次第二页(src/bin/drift.rs):
async fn cursor_page(db: &DatabaseConnection, after: Option<i32>) -> Result<Vec<item::Model>, DbErr> {
let mut c = item::Entity::find().cursor_by(item::Column::Id);
c.desc().first(3);
if let Some(id) = after {
c.after(id);
}
c.all(db).await
}
// ...
let p = item::Entity::find().order_by_desc(item::Column::Id).paginate(&db, 3);
println!("offset page 0 = {:?}", ids(&p.fetch_page(0).await?));
db.execute_unprepared("INSERT INTO item (cat, score) VALUES ('d', 99)").await?;
println!("offset page 1 (insert) = {:?}", ids(&p.fetch_page(1).await?));
db.execute_unprepared("DELETE FROM item WHERE id IN (11, 10)").await?;
println!("offset page 1 (delete) = {:?}", ids(&p.fetch_page(1).await?));
// 重建表,用 cursor_page 重复同样的步骤,第二页传 after(第一页最后的 id)
offset page 0 = [10, 9, 8]
offset page 1 (insert) = [8, 7, 6]
offset page 1 (delete) = [6, 5, 4]
cursor page 0 = [10, 9, 8]
cursor page 1 (insert) = [7, 6, 5]
cursor page 1 (delete) = [7, 6, 5]
| 变化 | OFFSET 3 的第二页 | id < 8 的第二页 |
|---|---|---|
| 前面插入了 id 11 | [8, 7, 6]:8 重复 | [7, 6, 5] |
| 之后删掉了 11、10 | [6, 5, 4]:7 被跳过 | [7, 6, 5] |
OFFSET 按位置数:前面多一行,所有行往后挪一位,上一页的最后一行被挤进下一页;前面少一行,下一页的第一行被挤到上一页去,而上一页已经看过了,这一行就谁也看不到。游标按值比较,id < 8 不受前面发生什么影响,插入的 11 和删掉的 10 都在已经看过的那一侧。
游标也不是快照:在 id < 8 那一侧插入或删除的行照样会出现或消失,只是不会因为别处的变化而重复或漏掉。要两页之间完全一致,只能在一个可重复读的事务里翻完(见事务),这对面向用户的分页通常不现实。
动脑筋答案
src/bin/cursor.rs 的第 3 段:
--- 3. cursor_by(score) desc first(2): ties on score
INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" ORDER BY \"item\".\"score\" DESC LIMIT $1\n" rows_affected=2 rows_returned=2 elapsed=853.917µs elapsed_secs=0.000853917
page 0 = [(1, 50), (2, 40)]
INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" WHERE \"item\".\"score\" < $1 ORDER BY \"item\".\"score\" DESC LIMIT $2\n" rows_affected=2 rows_returned=2 elapsed=688.5µs elapsed_secs=0.0006885
page 1 = [(5, 30), (4, 30)]
1 行。第一页拿到了 50 和一个 40(这次是 id 2),第二页的条件是 score < 40,另一个 40(id 3)既不在第一页、也不满足第二页的条件,从此看不到。哪一个 40 被拿走、哪一个丢掉也不确定:ORDER BY score DESC 不规定同分行的先后,同一页里的 (5, 30), (4, 30) 也不是按 id 排的。这就是要用 cursor_by((Score, Id)) 的原因:主键让排序成为全序,(score = 40 AND id < 2) OR score < 40 会把 id 3 带进下一页。
小结
Paginator::fetch_page(n)是原语句加LIMIT page_size OFFSET page_size * n,页码从 0 开始,越界是空页;num_items把去掉LIMIT/OFFSET/ORDER BY的原语句包成子查询再COUNT(*),GROUP BY、DISTINCT因此都数得对;num_pages每次再数一遍。Paginator不要求ORDER BY,没有它页内容不确定;Entity Loader 的paginate默认就没有。- 2.0 的
count保留LIMIT/OFFSET,1.1.20 的count等于num_items(#3097,2.0.0)。 Cursor在all时才拼WHERE、ORDER BY、LIMIT;after是排序方向上的「之后」,last(n)反向取再倒回来;原有的order_by被清掉。- 复合键展开成
(c1 = v1 AND c2 > v2) OR c1 > v1,所有列同一方向;单列游标遇到重复值会漏行,用主键做决胜列。 Cursor::all会把条件累积在自己身上,Cursor<SelectModel<M>>又不能clone,每页重新cursor_by。- 翻页之间有插入时
OFFSET会重复上一页的行,有删除时会漏行;按键值比较的游标两种情况都不受影响,但也不是快照。