GORM 默认分页对ULID主键不友好,因其为时间有序但非自增字符串,LIMIT OFFSET 在大数据量下无法利用索引高效跳转,且字符串比较开销大、易全表扫描;应改用游标分页,依赖 ORDER BY id DESC + WHERE id < ?,并确保主键显式建索引、校验ULID长度、统一UTC时区及保障生成单调性。

为什么 GORM 默认分页对 ULID 主键不友好
因为 ULID 是时间有序但非自增的字符串(如 "01HR8Z4YXGQZQF2V7K9TJZQW5D"),GORM 的 Paginate 或 LIMIT OFFSET 分页在大数据量下会严重拖慢查询——每次都要跳过前 N 条,而数据库无法用索引高效定位“第 50000 页”。更糟的是,字符串比较开销比整型大,且排序依赖完整值而非前缀,容易触发全表扫描。
ULID 场景下该用游标分页而非偏移分页
游标分页靠上一页最后一条记录的 id 值做条件筛选,只查下一页数据,不依赖 OFFSET。对 ULID 尤其合适:它天然带时间戳,按字典序排序即等效于时间倒序。
- 必须确保查询带
ORDER BY id DESC,且id字段有索引(GORM默认不会为string类型主键建索引,需显式声明) - 游标值应取上一页结果中最后一个
id,作为下一页WHERE id < ?的条件(注意是<,不是<=) - 不要拼接
id做范围查询(如BETWEEN),ULID不保证严格连续,中间可能有空缺 - 示例片段:
db.Where("id < ?", lastID).Order("id DESC").Limit(20).Find(&items)
GORM 中实现安全游标分页的关键配置
直接用 Limit/Where 手写容易漏掉边界或并发问题,建议封装成可复用方法,并处理空游标、越界等场景。
- 主键字段定义必须加
gorm:primarykey,index标签:type User struct { ID string `gorm:"primaryKey;index;size:26"` } - 游标参数应校验长度(
ULID固定 26 字符),避免 SQL 注入或无效查询 - 首次请求无游标时,不加
WHERE条件,仅靠ORDER BY ... LIMIT拿首屏 - 返回结果需附带下一页游标(即最后一条的
ID),前端无需解析或拼接 - 注意时区:若 ULID 由客户端生成,需统一使用 UTC,否则跨服务排序错乱
别忽略 ULID 生成时机对分页一致性的影响
如果 ULID 在应用层生成(如用 github.com/oklog/ulid),且多实例并发插入,同一毫秒内生成多个 ULID,它们的字典序与真实插入顺序可能不一致——导致分页漏数据或重复。这不是分页逻辑的问题,而是数据写入层的隐性风险。
- 生产环境建议在数据库层生成(PostgreSQL 可用
ulid_generate()扩展,MySQL 需自定义函数) - 若必须应用层生成,请确保单调递增:例如加锁 + 时间戳回退机制,或改用
ksuid等带序列号的变体 - 分页查询前,务必确认表中
id字段已按字典序严格单调(可用SELECT id FROM t ORDER BY id DESC LIMIT 10快速抽检)
ULID 的“时间有序性”真正落地到每一行数据的物理插入顺序和索引行为上。很多人卡在测试阶段没问题,上线后流量一上来就漏数据——往往不是代码写错了,而是没意识到生成、写入、索引三者之间存在微妙的时间差和排序假设。


















