大屏实时滚动分页必须用基于主键或时间戳的游标分页,禁用OFFSET——因数据持续写入会导致跳行、重复或漏记录;GORM虽无原生游标分页封装,但可通过Where("created_at < ?", lastCursor).Order("created_at DESC").Limit(20)安全实现。

大屏实时滚动分页(如监控看板、日志流、交易流水)不能用 OFFSET 做传统分页——它会随着数据持续写入而跳行、重复或漏掉新记录。GORM 本身不提供“游标分页”开箱即用的封装,但完全支持基于主键/时间戳的游标分页实现,这才是大屏场景下唯一可靠的做法。
为什么 LIMIT + OFFSET 在大屏滚动中必然出错
假设你每秒刷新一次,用 db.Offset(100).Limit(20) 查第 6 页:
- 第 1 次请求返回 ID 101–120;
- 中间有 5 条新记录插入(ID 102、103、104、105、106);
- 第 2 次请求仍从 OFFSET=100 开始,实际会跳过刚插入的 102–106,且把原 101–120 中的后段重复刷出;
- 用户看到数据“抖动”“回滚”“重复”,根本无法用于实时追踪。
用 GORM 实现安全的游标分页(基于 created_at 或 id)
核心是:不依赖行号,而依赖上一页最后一条记录的排序字段值(游标)。GORM 只需拼条件 + 排序 + LIMIT,无需特殊插件。
示例:按 created_at 降序滚动最新 20 条
// 上一页最后一条的 created_at(ISO8601 字符串或 time.Time)
lastCursor := time.Now().Add(-5 * time.Minute)
<p>var logs []Log
err := db.Where("created_at < ?", lastCursor).
Order("created_at DESC").
Limit(20).
Find(&logs).Error
if err != nil {
// handle
}
// 下一页游标 = logs[len(logs)-1].CreatedAt(如果 logs 非空)
- 必须加索引:
CREATE INDEX idx_logs_created_at ON logs(created_at DESC); - 避免用
SELECT *,只查必要字段,降低网络和 GC 压力 - 若用
id(自增整型),条件改为id ,更高效但要求严格单调递增 - 注意时区:确保 DB、Go 应用、前端展示统一用 UTC,否则游标比较失效
如何让 GORM 游标分页可复用、防错
直接裸写 Where + Order 易出错。建议封装成链式方法:
func (l Log) CursorPage(db *gorm.DB, cursor interface{}, limit int) (*gorm.DB, error) {
switch v := cursor.(type) {
case time.Time:
return db.Where("created_at < ?", v).Order("created_at DESC").Limit(limit), nil
case uint:
return db.Where("id < ?", v).Order("id DESC").Limit(limit), nil
default:
return nil, errors.New("unsupported cursor type")
}
}
<p>// 使用
db, _ := log.CursorPage(db, lastID, 20)
db.Find(&logs)
- 游标参数类型要显式检查,避免传错类型导致全表扫描
- 不要在游标条件里用函数包裹字段(如
WHERE DATE(created_at) = ?),会失效索引 - 大屏首次加载可不带游标,用
ORDER BY ... LIMIT N拿最新一批;后续请求必须带游标
真正难的不是写对一次游标查询,而是保证所有分页入口(WebSocket 推送、HTTP 轮询、长连接续传)都使用同一套游标生成/校验逻辑,并且游标值在网络传输中不被 JSON 序列化截断精度(time.Time 要用 RFC3339,别用 Unix timestamp int64)。这些细节漏掉一个,大屏就会在凌晨三点开始乱跳数据。


















