GORM 软删除方案:使用 deleted_at 替代 is_deleted,用来支持表唯一索引创建

62 阅读7分钟

GORM 软删除方案:使用 deleted_at 替代 is_deleted

统一规范,建议时间字段改为 xxx_at,deleted_at 为 gorm 默认认可的删除字段。

注意:deleted_at 使用 bigint 类型存储毫秒时间戳,默认值为 0 表示未删除。

公共表结构规范

CREATE TABLE `common` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `created_at` datetime NOT NULL COMMENT '创建时间',
  `updated_at` datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `deleted_at` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '删除时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

说明deleted_at 字段使用 毫秒时间戳 存储,0 表示未删除,非零值为删除时的毫秒时间戳。

一、问题背景

现状问题

  • 使用 is_deleted(0/1)实现软删除
  • 唯一索引包含 is_deletedUNIQUE KEY (merchant_id, sku_no, is_deleted)
  • 问题:同一业务字段组合的多条已删除记录会违反唯一约束

问题示例

-- 记录1:merchant_id=1, sku_no='SKU001', is_deleted=1 ✅

-- 记录2:merchant_id=1, sku_no='SKU001', is_deleted=1 ❌ 违反唯一约束!

二、解决方案

使用 deleted_at(毫秒时间戳)替代 is_deleted唯一索引包含 deleted_at

核心原理

  • 未删除deleted_at = 0
  • 已删除deleted_at = 毫秒时间戳(如:1703318400000)
  • 唯一索引:包含业务字段 + deleted_at

为什么不用 datetime 类型?

关键问题:MySQL 中 NULL != NULL,导致唯一索引失效!

如果使用 datetime NULL DEFAULT NULL 方案:

  • 未删除记录:deleted_at = NULL
  • 唯一索引:UNIQUE KEY (merchant_id, sku_no, deleted_at)
-- 问题示例:
INSERT INTO merchant_product (merchant_id, sku_no, deleted_at) VALUES (1, 'SKU001', NULL);  -- ✅
INSERT INTO merchant_product (merchant_id, sku_no, deleted_at) VALUES (1, 'SKU001', NULL);  -- ✅ 也能插入!

-- 原因:在 MySQL 中 NULL != NULL,所以两条 deleted_at = NULL 的记录被认为是不同的
-- 唯一索引无法约束 NULL 值,导致同一业务字段组合可以插入多条"未删除"的记录!

使用 bigint 毫秒时间戳方案解决此问题:

  • 未删除记录:deleted_at = 0(确定的值,可被唯一索引约束)
  • 已删除记录:deleted_at = 毫秒时间戳(每次删除时间不同,不会冲突)
-- 正确示例:
INSERT INTO merchant_product (merchant_id, sku_no, deleted_at) VALUES (1, 'SKU001', 0);  -- ✅
INSERT INTO merchant_product (merchant_id, sku_no, deleted_at) VALUES (1, 'SKU001', 0);  -- ❌ 违反唯一约束!

-- deleted_at = 0 是确定的值,唯一索引可以正常工作

优势

  • 唯一索引有效(用 0 替代 NULL,解决 NULL != NULL 问题)
  • ✅ 支持多次删除(每次删除的毫秒时间戳不同,不会冲突)
  • ✅ 自动处理(GORM 自动过滤已删除记录)
  • ✅ 记录删除时间(deleted_at 保存删除时间的毫秒数)
  • ✅ 可以恢复(将 deleted_at 设置为 0)
  • ✅ 高性能(bigint 比 datetime 索引更高效)

三、数据库设计

CREATE TABLE `merchant_product` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `merchant_id` bigint NOT NULL COMMENT '商家ID',
  `sku_no` varchar(32) NOT NULL COMMENT '商品sku唯一编码',
  `merchant_product_name` varchar(200) NOT NULL COMMENT '商家自定义商品名称',
  `created_at` datetime NOT NULL COMMENT '创建时间',
  `updated_at` datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `deleted_at` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '删除时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_merchant_sku` (`merchant_id`, `sku_no`, `deleted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商家商品表';

说明deleted_at 使用毫秒时间戳,0 表示未删除。

四、gorm_gen 代码生成配置

使用 gorm_gen 自动生成模型代码,关键配置如下:

package main

import (
	"log"

	"github.com/huandu/xstrings"
	"github.com/shopspring/decimal"
	"gorm.io/driver/mysql"
	"gorm.io/gen"
	"gorm.io/gen/field"
	"gorm.io/gorm"
	"gorm.io/plugin/soft_delete"
)

var (
	_ = decimal.New(1, 1)
	_ = soft_delete.DeletedAt(0)
)

func main() {
	g := gen.NewGenerator(gen.Config{
		OutPath:       "./internal/dal/query",
		ModelPkgPath:  "./internal/dal/model",
		FieldNullable: true,
		Mode:          gen.WithoutContext | gen.WithDefaultQuery | gen.WithQueryInterface,
	})

	db, _ := gorm.Open(mysql.Open("root:123456@(127.0.0.1:3306)/hh_mms?charset=utf8mb4&parseTime=True&loc=Local"))
	g.UseDB(db)
	sqlDB, _ := db.DB()
	if err := sqlDB.Ping(); err != nil {
		log.Fatal(err)
		return
	}

	// 处理表名以 s 结尾被截掉问题
	g.WithModelNameStrategy(func(tableName string) (modelName string) {
		return xstrings.ToPascalCase(tableName)
	})

	// 自定义数据类型映射
	g.WithDataTypeMap(map[string]func(columnType gorm.ColumnType) (dataType string){
		"tinyint": func(columnType gorm.ColumnType) string { return "int32" },
		"decimal": func(columnType gorm.ColumnType) string { return "decimal.Decimal" },
		"bigint": func(columnType gorm.ColumnType) string {
			// deleted_at 字段使用 soft_delete.DeletedAt 类型
			if columnType.Name() == "deleted_at" {
				return "soft_delete.DeletedAt"
			}
			return "int64"
		},
	})

	// 为 deleted_at 字段添加 softDelete:milli tag(milli 表示毫秒时间戳)
	g.WithOpts(
		gen.FieldGORMTag("deleted_at", func(tag field.GormTag) field.GormTag {
			tag.Set("softDelete", "milli")
			return tag
		}),
	)

	// 生成模型
	g.ApplyBasic(
		g.GenerateModel("mms_device"),
		g.GenerateModel("mms_device_relation"),
		// ... 其他表
	)

	g.Execute()
}

关键配置说明

配置项说明
WithDataTypeMapdeleted_at 字段的 bigint 类型映射为 soft_delete.DeletedAt
WithOpts + FieldGORMTagdeleted_at 字段添加 softDelete:"milli" tag,milli 表示使用毫秒时间戳

五、生成的 GORM 模型示例

import (
    "time"
    "gorm.io/plugin/soft_delete"
    "github.com/shopspring/decimal"
)

type MerchantProduct struct {
    ID                   int64                 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"`
    MerchantID           int64                 `gorm:"column:merchant_id;not null;comment:商家ID" json:"merchant_id"`
    MerchantNo           string                `gorm:"column:merchant_no;not null;comment:商家编号" json:"merchant_no"`
    SkuNo                string                `gorm:"column:sku_no;not null;comment:商品sku唯一编码" json:"sku_no"`
    MerchantProductName  string                `gorm:"column:merchant_product_name;not null;comment:商家自定义商品名称" json:"merchant_product_name"`
    ReferencePrice       decimal.Decimal       `gorm:"column:reference_price;not null;default:0.00;comment:商品参考售价" json:"reference_price"`
    CreatedAt            time.Time             `gorm:"column:created_at;not null;comment:创建时间" json:"created_at"`
    UpdatedAt            time.Time             `gorm:"column:updated_at;not null;comment:更新时间" json:"updated_at"`
    DeletedAt            soft_delete.DeletedAt `gorm:"column:deleted_at;not null;default:0;softDelete:milli;comment:删除时间" json:"deleted_at"`
}

关键字段说明

  • DeletedAt soft_delete.DeletedAt:使用 gorm 的 soft_delete 插件类型
  • softDelete:milli:表示使用毫秒时间戳存储删除时间
  • default:0:未删除时默认值为 0

六、代码使用指南

1. 软删除

// 方法1:使用 Delete()(推荐,GORM 自动处理)
product := &MerchantProduct{ID: 1}
err := db.Delete(product).Error
// SQL: UPDATE merchant_product SET deleted_at = 1703318400000 WHERE id = 1 AND deleted_at = 0
// 注:1703318400000 为毫秒时间戳

// 方法2:批量软删除
err := db.Where("merchant_id = ?", 1).Delete(&MerchantProduct{}).Error

2. 真删除(永久删除)

// 使用 Unscoped() 绕过软删除机制
err := db.Unscoped().Delete(&MerchantProduct{ID: 1}).Error
// SQL: DELETE FROM merchant_product WHERE id = 1

// 批量真删除
err := db.Unscoped().Where("merchant_id = ?", 1).Delete(&MerchantProduct{}).Error

3. 更新数据(不触发软删除)

// 正常更新业务字段,不会影响 deleted_at
err := db.Model(&MerchantProduct{}).
    Where("id = ?", 1).
    Update("merchant_product_name", "新商品名称").Error
// SQL: UPDATE merchant_product SET merchant_product_name = '新商品名称' WHERE id = 1 AND deleted_at = 0

4. 查询数据

4.1 默认查询(自动过滤已删除记录)
// 默认查询:自动过滤已删除的记录(推荐)
var products []MerchantProduct
err := db.Where("merchant_id = ?", 1).Find(&products).Error
// SQL: SELECT * FROM merchant_product WHERE merchant_id = 1 AND deleted_at = 0
// ✅ 不需要手动加 deleted_at = 0 条件

// 查询单条
var product MerchantProduct
err := db.First(&product, 1).Error
// 如果记录已删除,会返回 gorm.ErrRecordNotFound
4.2 查询所有记录(包括已删除的)
// 使用 Unscoped() 查询所有记录
var allProducts []MerchantProduct
err := db.Unscoped().Where("merchant_id = ?", 1).Find(&allProducts).Error
// SQL: SELECT * FROM merchant_product WHERE merchant_id = 1
4.3 只查询已删除的记录
// 只查询已删除的记录
var deletedProducts []MerchantProduct
err := db.Unscoped().
    Where("merchant_id = ? AND deleted_at > 0", 1).
    Find(&deletedProducts).Error

5. 恢复已删除的记录

// 恢复记录(将 deleted_at 设置为 0)
err := db.Unscoped().Model(&MerchantProduct{}).
    Where("id = ?", 1).
    Update("deleted_at", 0).Error
// SQL: UPDATE merchant_product SET deleted_at = 0 WHERE id = 1

七、关键点总结

操作方法说明
软删除db.Delete(&product)GORM 自动设置 deleted_at = 毫秒时间戳
真删除db.Unscoped().Delete(&product)永久删除记录
正常更新db.Update() / db.Updates()不会影响 deleted_at
默认查询db.Find()自动过滤 deleted_at > 0 的记录
查询全部db.Unscoped().Find()包含已删除记录
恢复记录db.Unscoped().Update("deleted_at", 0)恢复已删除记录

八、与 datetime 方案对比

特性bigint 毫秒时间戳方案(推荐)datetime 方案
字段类型bigint unsigned NOT NULL DEFAULT '0'datetime NULL DEFAULT NULL
未删除值0NULL
已删除值毫秒时间戳(如:1703318400000)具体时间(如:2023-12-23 12:00:00)
唯一索引✅ 有效(0 是确定值)❌ 失效(NULL != NULL)
索引性能更高(bigint 比较更快)一般
GORM 类型soft_delete.DeletedAtgorm.DeletedAt
gorm tagsoftDelete:milli无需额外 tag

九、注意事项

  1. 唯一索引:包含 deleted_at 字段,用 0 替代 NULL 解决唯一索引失效问题

  2. 默认行为:有 soft_delete.DeletedAt 字段时,Delete() 是软删除,查询自动过滤

  3. 真删除:必须使用 Unscoped().Delete()

  4. 更新已删除记录:使用 Unscoped() 才能更新到已删除的记录

  5. 依赖包:需要引入 gorm.io/plugin/soft_delete

  6. 时间戳单位softDelete:milli 表示毫秒,如需秒级可使用 softDelete:unix