tidb中offset分页更慢,因其分布式架构放大offset缺陷:tikv需扫描并传输offset+limit行(如100000,10需传100010行),再由tidb丢弃前100000行,导致跨节点io、网络带宽和内存双重浪费;而游标分页(如where id > ? order by id limit 10)可精准定位单region,避免无效扫描。

为什么TiDB里Offset分页会更慢
TiDB的分布式架构放大了OFFSET的固有缺陷:每个大偏移查询都会让TiKV节点扫描并传输大量无用数据到TiDB Server,造成网络带宽和内存双重浪费。比如LIMIT 100000, 10实际要从TiKV拉取100010行,再由TiDB丢弃前100000行——这比单机MySQL更伤,因为跨节点IO成本更高。
- 扫描行数 = OFFSET + LIMIT,不因分布而减少
- 网络传输量随OFFSET线性增长,不是常量
- TiDB解析器虽兼容MySQL语法,但执行计划无法跳过物理扫描
- 即使主键有索引,TiKV仍需逐Region遍历匹配OFFSET位置
游标分页在TiDB中怎么写才有效
游标分页是TiDB分页唯一推荐路径,但它依赖两个硬条件:排序字段必须单调、前端必须透传last_id(或last_created_at+id组合),不能做任何中间转换。
- 首次请求用
db.Order("id ASC").Limit(21).Find(&users)(多查1条判断是否有下一页) - 后续请求严格用
db.Where("id > ?", lastID).Order("id ASC").Limit(20).Find(&users) - 若用
created_at DESC,条件必须是created_at ,否则高并发下重复时间戳会导致漏数据 - 前端传来的
last_id必须校验为正整数,非法值直接返回400,不静默fallback
GORM Count在TiDB里为什么容易不准
TiDB对复杂JOIN和子查询的COUNT优化不如MySQL成熟,且GORM默认的Count()调用极易复用主查询的Limit/Offset或Joins状态,导致结果虚高或偏低。
-
db.Joins("JOIN profiles ON users.id = profiles.user_id").Where("profiles.active", true).Count(&total)会忽略JOIN条件,只扫users表 - 正确做法是手写子查询:
db.Raw("SELECT COUNT(*) FROM (SELECT 1 FROM users u JOIN profiles p ON u.id = p.user_id WHERE p.active = ?) AS t", true).Scan(&total) - 如果业务允许(如后台列表不要求精确总页数),直接查
size + 1条,用len(results) > size判断has_next,省掉COUNT
索引设计必须匹配TiDB的Region切分逻辑
TiDB按主键或指定索引切分Region,所以游标分页的WHERE条件必须命中索引最左前缀,否则会触发全Region扫描。
- 用
id分页时,确保PRIMARY KEY(id)存在且未被覆盖索引干扰 - 用
created_at分页时,建复合索引INDEX idx_created_id (created_at, id),顺序不能颠倒 - 避免在WHERE中混用
created_at BETWEEN ? AND ?和游标条件,会导致索引失效 - TiDB 7.5+支持表达式索引,但
WHERE id > ?这类简单比较仍优先走主键索引











