出入日志分页必须用游标分页而非limit+offset,因高并发下offset会引发性能陡降、数据丢失及不稳定性;应基于created_at+id双字段条件分页,建联合索引,禁用count,校验游标与参数。

直接用 Limit + Offset 做出入日志分页,在大并发下会卡死或丢数据——这不是 GORM 的锅,是 SQL 分页模型本身在高写入场景下的硬伤。
为什么出入日志不能用 Offset 分页
门禁系统每秒可能写入数百条日志(刷卡、人脸识别、远程开门),同时有几十个管理端/APP 在翻页查看。这时 Offset(100000).Limit(20) 会强制 MySQL 扫描并丢弃前 10 万行,CPU 和 I/O 拉满;更严重的是,如果第 10 万零 5 条被新插入,你翻到第 5001 页时就会漏掉它,或者重复刷出同一条。
- MySQL 对无序
LIMIT不保证结果稳定性,哪怕加了ORDER BY id ASC,只要没锁表或没快照隔离,就可能跳变 -
Offset越大,查询耗时越非线性增长;实测在千万级日志表上,OFFSET > 50000后平均响应超 1.2s,QPS 掉到 30 以下 - 前端传
page=1000&page_size=20,后端算出Offset(199980),MySQL 直接报ERROR 1235 (42000): This version of MySQL doesn't yet support 'LIMIT & OFFSET'(某些旧版本或配置)
必须用游标分页:基于 created_at + id 的双字段条件
出入日志天然带时间戳,且写入顺序基本与 id 一致。游标分页不依赖页码,只依赖“上一页最后一条的 created_at 和 id”,查下一批数据只扫增量,性能恒定。
- 首次请求不带游标:
db.Order("created_at DESC, id DESC").Limit(20).Find(&logs) - 后续请求带游标:
db.Where("created_at - 必须给
(created_at, id)建联合索引:CREATE INDEX idx_log_cursor ON access_logs (created_at DESC, id DESC); - 前端需保存并透传两个值:
cursor=2026-08-21T13:22:11Z_123456789,后端拆解后校验格式和非空
总数统计要砍掉,改用 has_next 布尔判断
出入日志总量动辄上亿,COUNT(*) 全表扫描在高峰期直接拖垮数据库。用户根本不需要知道“总共多少页”,只需要知道“还能不能再往下翻”。
- 每次查
Limit(21),如果返回 21 条,说明还有下一页,返回has_next: true并把第 21 条的created_at/id当作新游标;否则has_next: false - 完全避开
Count()调用,也不用Session(&gorm.Session{NewDB: true})隔离——省掉一次网络往返和全表扫描 - 如果业务强依赖总数(如导出报表),单独走异步任务或物化视图,绝不塞进实时分页接口
GORM 层必须封死非法参数入口
门禁系统常暴露在公网或弱口令内网,攻击者会故意传畸形参数刷库。GORM 不做任何校验,全靠你手动堵。
-
page_size从不接受前端直传:固定设为 20,或从配置读取最大值(如config.MaxPageSize = 50),再取min(50, p.PageSize) -
page参数直接丢弃——游标分页根本不该出现这个字段,GIN 绑定时用c.ShouldBindQuery(&p)并检查p.Cursor == ""是否成立,不成立就拒掉 - 所有时间字段解析用
time.Parse(time.RFC3339, s),失败立即返回 400;绝不容忍"2026-02-30"这类非法日期进 WHERE - WHERE 条件里禁止自由拼接字段名(如
OrderBy(c.Query("sort"))),排序字段只允许白名单:created_at、id、card_no
游标分页不是“可选项”,是门禁日志这种高吞吐、强实时、不可丢数据场景下的唯一合理选择。别想着先上 Offset 再优化,上线第一天并发上来就跪。











