审计日志分页必须手写Limit+Offset或游标分页,禁用Paginate;必须显式Order("created_at DESC, id DESC")确保排序唯一稳定,索引需为(created_at, id),Count查询须隔离db实例或手写子查询,大数据量应切游标分页。

审计日志分页不能用 Paginate,必须手写 Limit + Offset 或切游标分页——GORM 本身不提供原生分页方法,第三方插件在高并发写入场景下容易漏数据或返回重复记录。
为什么审计日志必须显式加 Order("created_at DESC, id DESC")
审计日志高频写入,created_at 可能重复(毫秒级精度、批量插入、时钟回拨),只按时间排序会导致同一页结果不稳定:某条日志可能出现在第 3 页和第 5 页,或直接消失。
-
created_at DESC保证新日志优先,但必须搭配id DESC做二级排序,确保顺序唯一可预测 - 数据库索引需建为
INDEX idx_audit_created_id (created_at, id),否则ORDER BY会触发 filesort - 禁止用
Order("RAND()")或无序查询,审计场景下“漏一条=丢证据”
Count 查询必须隔离 *gorm.DB 实例
审计日志表常带复杂条件(如 WHERE user_id = ? AND event_type IN (?) AND created_at BETWEEN ? AND ?),如果复用同一个 db 链调用 Count,它会继承前面的 Limit 和 Offset,导致 total 永远 ≤ 每页条数。
- 正确做法:
db.Session(&gorm.Session{NewDB: true}).Model(&AuditLog{}).Where(...).Count(&total) - 更稳妥的写法是手写子查询:
db.Raw("SELECT COUNT(*) FROM (SELECT 1 FROM audit_logs WHERE ...) AS t").Scan(&total),避免 GORM 链式状态污染 - 不要在事务中查
Count再查列表——两次查询之间若有新日志写入,总数和当前页数据对不上,审计系统应接受“近似总数”,而非强一致
何时必须放弃 Offset 改用游标分页
审计日志接口若支持“滚动加载”或需翻到 100 页以后(比如排查历史异常行为),Offset(999999) 在 MySQL 中实际要扫描并丢弃百万行,响应从 20ms 拉到 3s+,且结果仍可能因写入而偏移。
- 游标值应取上一页最后一条的
created_at和id,SQL 形如:WHERE (created_at, id) - 前端首次请求不带游标,后端返回
next_cursor: "2026-08-20T14:22:33Z_12345"这类字符串,避免暴露原始字段值 - 别在游标分页里用
Preload加载关联用户信息——先查出日志 ID 列表,再用IN批量查用户,否则关联 N+1 问题会放大游标分页的延迟
参数校验不能依赖前端传值
审计日志接口常被脚本或自动化工具调用,page=0、page_size=10000、page=abc 是常态,GORM 不做任何拦截,直接透传给数据库会引发 panic 或慢查询。
-
page小于 1 时强制设为 1,不报错也不跳过 -
page_size必须硬限制(如 1–100),超出则截断为 50,防 DOS 和索引失效 - 用
c.ShouldBindQuery(&p)绑定结构体,配合binding:"required,min=1,max=100"标签,比手动c.Query()更安全 - 空字符串或非数字参数应直接返回
400 Bad Request,不 fallback 默认值——审计场景下模糊容错等于埋雷
最容易被忽略的是:审计日志的“总数”本质是辅助信息,真正关键的是每一页数据的确定性。哪怕 Count 查得不准,只要每页 ORDER BY 稳、游标值准、参数拦得死,就能保证行为可追溯。


















