秒杀对账必须用游标分页而非offset分页,因后者在大数据量高并发下会拖垮数据库;游标分页需满足三条件:排序字段有联合索引、游标值来自上页最后id原值、首次请求显式排序且后续严格类型匹配。

秒杀系统结算订单分页对账,不能用 Offset 分页。它在数据量大、并发高时必然拖垮数据库,不是“慢一点”,而是直接触发连接超时或主从延迟雪崩。
为什么 Offset 分页在秒杀对账里是定时炸弹
秒杀订单结算后,对账服务要拉取“过去 5 分钟内所有已支付但未出库的订单”做逐条核验。这类查询常带时间范围 + 状态过滤,但只要加了 OFFSET,MySQL 就得真实扫描并丢弃前 N 行——哪怕你只想要最后 20 条。
常见错误现象:
-
db.Where("status = ? AND created_at > ?", "paid", fiveMinAgo).Order("id ASC").Offset(50000).Limit(100)在压测中导致主库 CPU 持续 95%+,从库复制延迟跳到 30 秒以上 - 同一笔订单在两次分页请求中被漏掉或重复出现(因无显式
ORDER BY或排序字段未索引) - 前端传
page=1000&limit=50,后端没校验直接算Offset(49950),DB 连接池瞬间耗尽
游标分页必须满足的三个硬条件
游标分页不是加个 WHERE id > ? 就完事。它依赖确定性顺序和稳定锚点,缺一不可。
必须做到:
- 排序字段必须有**联合索引**,且覆盖查询条件:比如
WHERE status = ? AND created_at > ? ORDER BY id ASC,对应索引应为(status, created_at, id),不能只建id单列索引 - 游标值(
last_id)必须来自上一页**最后一条记录的id字段原值**,不能是前端拼接、不能是字符串截断、不能是时间戳转 int 后再传——GORM 不会帮你类型转换,传错就查不到 - 首次请求不带游标,但必须显式写
.Order("id ASC");后续请求用.Where("id > ?", lastID),且lastID类型必须与结构体中ID字段完全一致(uint64就不能传int)
GORM 对账分页的最小安全封装
不要复用通用 Paginate() 函数。秒杀对账场景下,参数、校验、SQL 构造逻辑都更重。
推荐这样写:
// 对账专用分页查询
func (s *SettlementService) PaginateUnshippedOrders(lastID uint64, limit int) ([]Order, uint64, error) {
if limit 100 {
return nil, 0, errors.New("limit must be between 1 and 100")
}
var orders []Order
tx := s.db.Where("status = ? AND created_at > ?", "paid", time.Now().Add(-5*time.Minute))
if lastID > 0 {
tx = tx.Where("id > ?", lastID)
}
// 强制按 id ASC 排序,确保游标语义成立
err := tx.Order("id ASC").Limit(limit).Find(&orders).Error
if err != nil {
return nil, 0, err
}
if len(orders) == 0 {
return orders, 0, nil // 无更多数据
}
// 返回下一页游标:最后一条的 ID
return orders, orders[len(orders)-1].ID, nil
}
注意:Count() 在这里不适用。对账不需要总页数,只需要“有没有下一页”。如果真要总数,必须手写子查询,且带上全部 WHERE 条件,否则结果毫无意义。
容易被忽略的软故障点
游标分页看似简单,但在秒杀对账这种强一致性场景下,一个细节松动就会引发资金差错:
- 时间字段用
created_at做游标?高并发下单可能同毫秒创建,id才是唯一可靠锚点 - 没给
status和created_at字段建联合索引,导致WHERE走全表扫描,游标失效 - 把
last_id当字符串接收再转uint64,遇到空字符串或非数字直接 panic,整个对账流水卡死 - GORM 日志没开,线上查不到实际执行的 SQL,问题定位靠猜
真正难的从来不是写出来,而是让每一页都可重现、不丢不重、不拖垮 DB——这需要索引、类型、边界、日志四者严丝合缝。











