结论:IoT设备状态分页必须用游标分页而非Limit+Offset,因高写入频次下Offset会导致性能骤降与数据错乱;游标需基于id或created_at+id组合排序,配合正确索引与校验机制。

直接说结论:用 Limit + Offset 做设备状态分页,在 IoT 场景下极易翻车,尤其当设备上报频率高、历史记录写入密集时——必须用游标分页,且排序字段得是 id 或 created_at + id 组合。
为什么 Limit + Offset 在设备历史记录里特别危险
设备状态表(比如 device_status)通常每秒插入数十到数百条记录,OFFSET 一过万,MySQL 就开始全扫索引树前 N 行再丢弃,响应从 20ms 拉到 2s+;更致命的是,设备重连补传、服务端重试写入,会导致同一页反复出现或跳过某条心跳/电量/温度记录。
- 常见错误现象:
page=100&limit=20返回空 slice,但err == nil;同一设备的两条间隔 1 秒的状态,在第 99 页和第 101 页都出现 - 根本原因:OFFSET 是物理行偏移,不是逻辑序号;设备写入不按“页面”节奏,数据库无法保证两次查询间中间状态稳定
- 即使加了
ORDER BY created_at DESC,高并发下毫秒级时间戳重复极多,MySQL 内部排序仍可能打乱顺序——必须补, id DESC
游标分页怎么写才不漏数据
前端不再传 page,而是传上一页最后一条记录的 id(或 created_at + id),后端拼 WHERE 条件做增量扫描。这是唯一能保证“不重不漏”的方式。
- 首次请求:用
db.Order("id DESC").Limit(20).Find(&statuses),取回后把statuses[0].ID当作下一页的last_id - 后续请求:查
db.Where("id ,注意是 <code> 不是 <code> - 如果用时间戳,必须组合主键:
db.Where("created_at - 别在游标查询里用
Preload加载设备元信息——先查出这批device_id列表,再用IN批量查设备表
Count 总数查询不能省,但得防拖垮数据库
运维看板需要总条数,但对百万级设备状态表执行 COUNT(*) 会锁表或超时。折中方案是:只对带明确过滤条件的查询走精确 Count,否则降级为估算或缓存值。
- 精确场景(如查某台设备最近 7 天状态):
db.Where("device_id = ? AND created_at > ?", deviceID, weekAgo).Model(&DeviceStatus{}).Count(&total) - 非精确场景(如全量设备状态概览):用 MySQL 的
SHOW TABLE STATUS查Rows字段(误差 ±10%),或 Redis 缓存每小时更新的计数器 - 绝对不要写:
db.Where(...).Limit(20).Offset(0).Count(&total)—— GORM v2 会复用 Limit,total永远 ≤20 - 总数查询必须独立构造 DB 实例:
countDB := db.Session(&gorm.Session{NewDB: true}),再链式调用Where和Count
容易被忽略的边界细节
IoT 数据的脏和快,会让很多“看起来能跑”的代码在线上突然失效。最常被跳过的三件事:
-
created_at字段必须建联合索引:INDEX idx_device_created (device_id, created_at DESC, id DESC),否则游标查询退化成全表扫 - 前端传的
last_id必须校验存在且为正整数,strconv.ParseUint失败直接 400,别让它进 SQL 拼接 - 设备离线期间积压的状态补传,可能导致某秒内插入上千条记录——游标分页的
Limit要配合业务限流,比如单次最多返回 50 条,避免网关超时


















