基于 GORM 实现软删除用法和原理解析

3,113 阅读8分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第4天,点击查看活动详情

物理删除 vs 逻辑删除

当我们不再需要某些业务数据时,通常会将其【删除】,但删除本身是个语义比较宽泛的概念,因为本质上很多时候我们需要的是让这些数据无法再影响到当前的业务系统,无法被用户看到,而做到这一点并不需要完完全全删掉它。

所以,我们经常会看到这样的方案对比:【物理删除】vs【逻辑删除】。

  • 物理删除:通常也被叫做硬删除,即直接将该记录从数据库中删除。但是是人总会犯错误,在误操作删除了重要数据后,如果想要恢复该数据,需要锁表再去访问日志文件。这样会造成大量的人力资源浪费,现在的开发不推介这种方式。

  • 逻辑删除:也叫标记删除,或者软删除。与我们常说的删除不同,并不是真的从数据库中将这条记录去除,而是会设置一个字段,常见的有 isDelete 或者 state 等字段来标记删除状态。当该字段为0的时候为未删除状态,为1时则是删除状态。

从业务系统对外的呈现来看,我们很少会直接暴露【删除】这种概念,通常被封装到某个业务概念下,比如【取消订单】,【解雇员工】,【下架商品】等。更多的是一个状态,而非物理删除。

软删除目前用的越来越多,毕竟你可以保存原有数据,真出现了什么问题,大不了写个脚本恢复即可。而一旦物理删除,要恢复数据的成本会显著增高。

当然,软删除也不是没有成本的:

  1. 你需要消耗更多存储空间,因为数据是只增不减的;
  2. 查询的时候需要过滤掉【被软删除标记】的记录;
  3. 删除的时候需要额外注意写法。

下面我们来看看 Golang 下如何基于 GORM 来实现软删除。

DeleteAt

基于原生 GORM,我们可以在自己的持久化 model 中添加 gorm.DeleteAt 字段,从而自动获取 Soft-Delete 的能力。

// gorm.Model (注:在 gorm 项目的 model.go 中)
type Model struct {
  ID        uint           `gorm:"primaryKey"`
  CreatedAt time.Time
  UpdatedAt time.Time
  DeletedAt gorm.DeletedAt `gorm:"index"`
}

type UserV1 struct {
  gorm.Model
  Name string
}
// equals
type UserV2 struct {
  ID        uint           `gorm:"primaryKey"`
  CreatedAt time.Time
  UpdatedAt time.Time
  DeletedAt gorm.DeletedAt `gorm:"index"`
  Name string
}

gorm.Model 这个自带的 Model 也包含 DeleteAt 这个字段,我们可以复用,或者在自己的结构体里直接定义 gorm.DeleteAt 类型的字段(如上面的 UserV2)。

声明字段后,当我们调用 Delete 时, 指定的记录并不会从数据库中物理删除。GORM 会将 gorm.DeleteAt 字段的值设置为当前时间,在调用查询方法时不会被返回。

// user's ID is `111`
db.Delete(&user)
// UPDATE users SET deleted_at="2013-10-29 10:23" WHERE id = 111;

// Batch Delete
db.Where("age = ?", 20).Delete(&User{})
// UPDATE users SET deleted_at="2013-10-29 10:23" WHERE age = 20;

// Soft deleted records will be ignored when querying
db.Where("age = 20").Find(&user)
// SELECT * FROM users WHERE age = 20 AND deleted_at IS NULL;

这里可以看到,GORM 内部做了相关适配,原来的 Delete 调用会被转换为一次 Update,自动处理了 delete_at 字段的更新逻辑。此外,查询的时候,如果发现 model 中包含 gorm.DeleteAt 字段,也会自动加上 deleted_at IS NULL 作为 Where 条件。使用起来非常方便。

而如果我们想查询到已经被软删除的 record,可以在 GORM 查询时加上 Unscoped 即可:

db.Unscoped().Where("age = 20").Find(&users)
// SELECT * FROM users WHERE age = 20;

原理解析

这里我们参照源码,简单了解一下底层 gorm 是如何对 gorm.DeleteAt 类型进行支持,在删除和查询时调整生成的 SQL 的。相关实现在 soft_delete.go 中。

type DeletedAt sql.NullTime

// Scan implements the Scanner interface.
func (n *DeletedAt) Scan(value interface{}) error {
	return (*sql.NullTime)(n).Scan(value)
}

// Value implements the driver Valuer interface.
func (n DeletedAt) Value() (driver.Value, error) {
	if !n.Valid {
		return nil, nil
	}
	return n.Time, nil
}

// NullTime represents a time.Time that may be null.
// NullTime implements the Scanner interface so
// it can be used as a scan destination, similar to NullString.
type NullTime struct {
	Time  time.Time
	Valid bool // Valid is true if Time is not NULL
}

看到定义可以发现,其实 gorm.DeleteAt 对应到 Golang 的 sql 包中的 NullTime 字段,代表了一个可能为 null 的 time.Time 对象,底层利用了一个 Valid 的布尔值。

这也是为什么,我们前面提到:

当我们调用 Delete 时, 指定的记录并不会从数据库中物理删除。GORM 会将 gorm.DeleteAt 字段的值设置为当前时间。

因为最底层来看,这就是个 time.Time 对象。

我们知道,在 schema/interface.go 中,gorm 对增删改查四种场景都定义了 Clauses 接口:


// CreateClausesInterface create clauses interface
type CreateClausesInterface interface {
	CreateClauses(*Field) []clause.Interface
}

// QueryClausesInterface query clauses interface
type QueryClausesInterface interface {
	QueryClauses(*Field) []clause.Interface
}

// UpdateClausesInterface update clauses interface
type UpdateClausesInterface interface {
	UpdateClauses(*Field) []clause.Interface
}

// DeleteClausesInterface delete clauses interface
type DeleteClausesInterface interface {
	DeleteClauses(*Field) []clause.Interface
}

schema的Parse方法会回调这些interface的对应方法:

image.png

最终写入到 Schema 中:

image.png

所以,要做相应的操作,就需要实现对应的 Clauses 接口,这一点 DeleteAt 也不例外,在软删除的场景中,我们看到 DeleteAt 实现了 QueryClausesInterface , UpdateClausesInterfaceDeleteClausesInterface ,在查询,更新,删除三个场景下进行适配。

func (DeletedAt) QueryClauses(f *schema.Field) []clause.Interface {
	return []clause.Interface{SoftDeleteQueryClause{Field: f}}
}

func (DeletedAt) UpdateClauses(f *schema.Field) []clause.Interface {
	return []clause.Interface{SoftDeleteUpdateClause{Field: f}}
}

func (DeletedAt) DeleteClauses(f *schema.Field) []clause.Interface {
	return []clause.Interface{SoftDeleteDeleteClause{Field: f}}
}

这里的三个 SoftDeleteClause 结构就承载我们构建 SQL 语句的核心逻辑:

  • SoftDeleteQueryClause:软删除查询逻辑
type SoftDeleteQueryClause struct {
	Field *schema.Field
}

func (sd SoftDeleteQueryClause) ModifyStatement(stmt *Statement) {
	if _, ok := stmt.Clauses["soft_delete_enabled"]; !ok && !stmt.Statement.Unscoped {
		if c, ok := stmt.Clauses["WHERE"]; ok {
			if where, ok := c.Expression.(clause.Where); ok && len(where.Exprs) >= 1 {
				for _, expr := range where.Exprs {
					if orCond, ok := expr.(clause.OrConditions); ok && len(orCond.Exprs) == 1 {
						where.Exprs = []clause.Expression{clause.And(where.Exprs...)}
						c.Expression = where
						stmt.Clauses["WHERE"] = c
						break
					}
				}
			}
		}

		stmt.AddClause(clause.Where{Exprs: []clause.Expression{
			clause.Eq{Column: clause.Column{Table: clause.CurrentTable, Name: sd.Field.DBName}, Value: nil},
		}})
		stmt.Clauses["soft_delete_enabled"] = clause.Clause{}
	}
}

这里会对 statement 执行一次 AddClause 给 Where 加上一个条件,软删除的列sd.Field.DBName 的值应当等于 nil。这样就保证了一旦 DeleteAt 列赋值,就无法查到。

  • SoftDeleteUpdateClause:软删除更新逻辑
type SoftDeleteUpdateClause struct {
	Field *schema.Field
}

func (sd SoftDeleteUpdateClause) ModifyStatement(stmt *Statement) {
	if stmt.SQL.Len() == 0 && !stmt.Statement.Unscoped {
		SoftDeleteQueryClause(sd).ModifyStatement(stmt)
	}
}

这里只是复用了查询里的逻辑,也就是给 Where 语句增加 DeleteAt 列的值判空的条件,从而保证更新的时候不影响到此前软删除的列。

  • SoftDeleteDeleteClause: 软删除的删除逻辑
type SoftDeleteDeleteClause struct {
	Field *schema.Field
}

func (sd SoftDeleteDeleteClause) ModifyStatement(stmt *Statement) {
	if stmt.SQL.Len() == 0 && !stmt.Statement.Unscoped {
		curTime := stmt.DB.NowFunc()
		stmt.AddClause(clause.Set{{Column: clause.Column{Name: sd.Field.DBName}, Value: curTime}})
		stmt.SetColumn(sd.Field.DBName, curTime, true)

		if stmt.Schema != nil {
			_, queryValues := schema.GetIdentityFieldValuesMap(stmt.Context, stmt.ReflectValue, stmt.Schema.PrimaryFields)
			column, values := schema.ToQueryValues(stmt.Table, stmt.Schema.PrimaryFieldDBNames, queryValues)

			if len(values) > 0 {
				stmt.AddClause(clause.Where{Exprs: []clause.Expression{clause.IN{Column: column, Values: values}}})
			}

			if stmt.ReflectValue.CanAddr() && stmt.Dest != stmt.Model && stmt.Model != nil {
				_, queryValues = schema.GetIdentityFieldValuesMap(stmt.Context, reflect.ValueOf(stmt.Model), stmt.Schema.PrimaryFields)
				column, values = schema.ToQueryValues(stmt.Table, stmt.Schema.PrimaryFieldDBNames, queryValues)

				if len(values) > 0 {
					stmt.AddClause(clause.Where{Exprs: []clause.Expression{clause.IN{Column: column, Values: values}}})
				}
			}
		}

		SoftDeleteQueryClause(sd).ModifyStatement(stmt)
		stmt.AddClauseIfNotExists(clause.Update{})
		stmt.Build(stmt.DB.Callback().Update().Clauses...)
	}
}

重头戏来了。其实查询和更新的时候很好理解,只需要对 DeleteAt 列判空即可。重点在于怎么做到的这个【软删除】。我们希望将 Delete 操作替换为一次 Update,并且只是将 DeleteAt 列置为当前的时间(毕竟它本质是个 time.Time)。

参照这里的实现就清楚了,最终生成的是 Update 语句,一上来就 Set sd.Field.DBName 等于当前时间,并根据 model 里其他列的值生成 Where 条件。

此处还是调用了 SoftDeleteQueryClause(sd).ModifyStatement(stmt) ,这也意味着生成的 UPDATE 语句中必定也有针对 DeleteAt 列值为 nil 的 Where 条件。所以,多次软删除同一个记录是不会生效的,因为一旦第一次软删除成功,这里的 Where 校验此后就无法再通过。

小结一下:从实现上来看,基于 GORM 强大的扩展性,实现软删除中对于 DeleteAt 列在查询,更新,删除时替换生成的 SQL 并不复杂,针对各个场景实现 ModifyStatement ,就能对 SQL 的生成进行个性化定制。

// StatementModifier statement modifier interface
type StatementModifier interface {
	ModifyStatement(*Statement)
}

扩展插件

在开发设计表结构时,为了满足不同的需求,在一套系统中,经常会存在着多种不同的软删除用法。

  • DeletedAt (timestamp): NULL vs DeletedTimestamp
  • DeletedAt (int64): 0 vs DetetedUnixTime
  • IsDel (bool): 0 vs 1
  • IsDel (bool) + DelTime (timestamp): 0 vs 1

上面我们提到,原生的软删除支持的类型仅为 sql.NullTime,对应到第一种。除此外,GORM 在官方的 soft_delete 扩展库里也提供了更完善的软删除支持。

我们需要新增一个 import,替换类型从 gorm.DeleteAt 换成 soft_delete.DeleteAt 即可:

import "gorm.io/plugin/soft_delete"

type User struct {
  ID        uint
  Name      string                `gorm:"uniqueIndex:udx_name"`
  DeletedAt soft_delete.DeletedAt `gorm:"uniqueIndex:udx_name"`
}

Unix second

对应到上面的第二种写法,在扩展的 soft_delete 包这是默认支持的,按照 unix 时间戳来判断,而非 datetime,删除的时间不太直观。

import "gorm.io/plugin/soft_delete"

type User struct {
  ID        uint
  Name      string
  DeletedAt soft_delete.DeletedAt
}

// Query
SELECT * FROM users WHERE deleted_at = 0;

// Delete
UPDATE users SET deleted_at = /* current unix second */ WHERE ID = 1;

这里的时间戳不仅仅支持【秒】,还可以支持【毫秒】,【纳秒】,不过需要通过 softDelete tag 来指明:

type User struct {
  ID    uint
  Name  string
  DeletedAt soft_delete.DeletedAt `gorm:"softDelete:milli"`
  // DeletedAt soft_delete.DeletedAt `gorm:"softDelete:nano"`
}

// Query
SELECT * FROM users WHERE deleted_at = 0;

// Delete
UPDATE users SET deleted_at = /* current unix milli second or nano second */ WHERE ID = 1;

0/1 flag

对应第三种模式。有些时候我们不在意时间,而是仅仅需要一个标志位即可,这时候可以使用 0 / 1 这种模式:

import "gorm.io/plugin/soft_delete"

type User struct {
  ID    uint
  Name  string
  IsDel soft_delete.DeletedAt `gorm:"softDelete:flag"`
}

// Query
SELECT * FROM users WHERE is_del = 0;

// Delete
UPDATE users SET is_del = 1 WHERE ID = 1;

我们只需要将 gorm:"softDelete:xxxx" 中的值,从上面时间戳的 milli/nano 替换为 flag 即可。

混合模式

对应第四种,个人比较不推荐这种做法。需要两个字段,一个承载 flag 的能力,一个承载时间戳。 可以通过 gorm 的 tag 来适配。

type User struct {
  ID        uint
  Name      string
  DeletedAt time.Time
  IsDel     soft_delete.DeletedAt `gorm:"softDelete:flag,DeletedAtField:DeletedAt"` // use `1` `0`
  // IsDel     soft_delete.DeletedAt `gorm:"softDelete:,DeletedAtField:DeletedAt"` // use `unix second`
  // IsDel     soft_delete.DeletedAt `gorm:"softDelete:nano,DeletedAtField:DeletedAt"` // use `unix nano second`
}

// Query
SELECT * FROM users WHERE is_del = 0;

// Delete
UPDATE users SET is_del = 1, deleted_at = /* current unix second */ WHERE ID = 1;

小结

GORM 整体的扩展性还是很赞的,无论是官方原生支持,还是扩展库,可以看到代码结构都是类似的,支持成本不高。

需要注意的是,既然是软删除,拼出来的 SQL 会在 Query 和 Update 时增加对 DeleteAt 列的 Where 条件,默认的在 Unique key 下有问题,联合索引中具有 NULL 会失效。这一点在扩展库的默认实现里,用 uint 时间戳能够比较好的解决这个问题,除了不太直观外,问题不大。建议大家可以优先考虑。