gorm软删除需deletedat为*time.time类型并嵌入gorm.model或加gorm:"index"标签,否则delete()执行物理删除;默认查询自动过滤软删记录,查全部或恢复须用unscoped()。

软删除字段没加对,分页直接查不到数据
很多人在用 db.Find(&users) 时发现空结果,但数据库里明明有记录——大概率是 DeletedAt 字段类型写成了 time.Time 而不是 *time.Time。GORM 把零值 0001-01-01 当作“已删除”,所有新插入的记录都自动被过滤掉。
正确做法是:要么嵌入 gorm.Model(自带指针型 DeletedAt),要么手动定义为 DeletedAt *time.Time 并加上 gorm:"index" 标签。别省那几行代码,否则分页列表永远为空。
分页查询自动跳过软删除记录,但总数统计没跳过
默认情况下,Find()、First() 等都会自动加 WHERE deleted_at IS NULL,但 Count() 不会——除非你显式调用 Unscoped() 或者漏写了条件。
常见错误写法:db.Where("status = ?", "active").Count(&total).Limit(20).Offset(40).Find(&users)。这里 Count() 没走软删除过滤,Find() 却走了,导致 total 是 1000,但实际只返回 800 条。
安全写法是拆开两步:
- 先构造带相同条件的 scoped 查询算总数:
db.Where("status = ?", "active").Model(&User{}).Count(&total) - 再用同一条件查数据:
db.Where("status = ?", "active").Order("id ASC").Limit(20).Offset(40).Find(&users)
Unscoped() 一开,关联预加载也失效了
Unscoped() 不只是绕过当前模型的软删除,它会让整个链式调用(包括 Preload())都跳过所有软删除逻辑。比如你查一个软删除的 User,又 Preload("Posts"),结果连用户已发布的文章也都被一起捞出来——即使那些文章本身也被软删除了。
这不是 bug,是设计行为。如果你只想恢复主表、不碰关联表,不能靠 Unscoped() 一把梭。得单独处理:
- 主表用
Unscoped().Where(...).Find() - 关联表仍走普通查询,或手动加
Where("deleted_at IS NULL")
尤其在回收站、审计日志等场景,这个细节容易让后台看到一堆不该出现的“已删内容”。
高偏移分页 + 软删除 = 性能雪崩
当 OFFSET 超过 5000(MySQL)或 10000(PostgreSQL),数据库要扫描并跳过大量已被软删除的记录,而这些记录其实早该被忽略。更糟的是,索引可能无法覆盖 deleted_at IS NULL 和排序字段的组合,导致全表扫描。
真正可行的解法不是调大超时或加缓存,而是换游标分页:
- 用上一页最后一条记录的
id或created_at作为下一页起点,例如WHERE id > ? AND deleted_at IS NULL ORDER BY id ASC LIMIT 20 - 确保排序字段有复合索引,如
INDEX idx_active_id (deleted_at, id) - 别指望 GORM 自动帮你生成游标逻辑——它没这个能力,得自己拼条件
软删除和传统分页叠加后,问题会被放大。越晚意识到这点,线上慢查询越多。











