• 前言

    涉足服务端开发已经有一段时间了,每当做点什么东西都难免和数据库打交道。不过直到最近,上手开发一套功能完备的API网关后,才发现自己对服务端架构一无所知😭。坑很多,以后慢慢填,今天就先来记录下数据库软删除为我带来的“惊喜”。

1. 什么是软删除?

  • 一句话来讲,软删除并不会真的将数据记录从数据库中删掉,而是通过修改某个字段来标记这条记录是被删除的。比如:
    1. 使用is_deleted字段,表示是否被删除
    2. 使用deleted_at时间戳,null表示未被删除,时间表示被删除时间
    3. 使用“影子表”,这个使用场景少,我也没用过
  • 许多ORM都对软删除提提供了支持,并且我最开始也觉得软删除会让我的系统鲁棒性倍增,于是在开发初期就无脑引入了:
    	import { Entity, DeleteDateColumn } from 'typeorm'
    	@Entity()
    	{
    	  //...
    	  @DeleteDateColumn()
    	  deletedAt: Date
    	}
    
  • 看起来它的引入十分简单,并且软删除给我们带来了相当重要的数据恢复手段,当字段被误删除后,不需要用过bin文件来对表重建就能轻松恢复。不过...它所带来的影响,或许会让开发过程变得无比煎熬。

2. 软删除的痛苦

唯一索引:

我们通常会给表中的某些字段加入唯一索引,来确保数据的唯一性。比如,我给user表中staffId字段加上唯一索引:

{
  //...
  @Index({ unique: true })
  @Column('varchar', { nullable: false })
  staffId: string
  //...
}

这看起来没什么问题,可是当我调用自己的api把某个用户删除之后,这个用户就再也不能被加入到系统中了。

staffId (unique)其他字段deletedAt
12345...2021-11-17 22:27:54.751479

如上表所示,我们软删除后的数据依然在表中,当我们再往表里插入staffId=12345的用户的时候,会直接出现数据库错误。
这时,我自作聪明地想到了一个办法:

@Index(['staffId', 'deletedAt'], { unique: true })
@Entity()
export default class User {
  @PrimaryGeneratedColumn()
  id: number

  @Column('varchar',{ nullable: false})
  staffId: string
  //...
}

通过建立staffId-deletedAt的联合索引,来确保上表被删除后的数据不会妨碍新插入的数据。

staffId其他字段deletedAt
12345...2021-11-17 22:27:54.751479
12345...2021-11-17 22:27:57.261345
12345...null

可事实并非如此,不妨碍插入的原因是唯一索引的作用消失了。说得简单一点就是,我插入两条staffId=123456的数据,并不会得到数据库报错。 因为在mysql中,index('12345', null)index('12345', null)是不等的。这要得益于mysql对NULL的解释,说人话就是:null是空,表示未知,含有未知的索引进行比较,结果就是未知。未知 不代表 相等,所以这个联合索引失效了。

  • 如何解决?
  1. 我试过将deletedAt字段改为number,存入时间戳,用0代表未被删除。这样确实没有逻辑上的问题,但是之前ORM带来的福利全都无法享受了。插入时需要添加转换函数,取出的时候还是需要自己判断deletedAt != 0,给整个项目带来不小的负担。
  2. 添加额外的一列数据,拼接成${staffId} + '-' + ${deletedAt}的形式,然后加上hash,将这一列作为唯一索引。这样同样会给开发带来麻烦。

外键约束:

如果使用了外键来约束多张表,那么软删除会直接导致外键失效。删除操作为例:软删除主表记录时,外键不能约束,也不能连级删除,因为软删除仅仅是改了deletedAt字段的值。

连表查询:

这还算比较良心,没啥坑。唯一缺点就是极度麻烦,每次联表查询都需要加上一堆Where('deletedAt == 0')

3. 真的需要软删除么?

这时候回过头来看,我发现很多时候并不是真的需要软删除。引入它不过是一时兴起,觉得这东西有助于自己写出更棒的代码。
但事实是,它给我带来了无数的坑,我不知道花了多少白给的commit在修复软删除带来的bug。
很多表中(如配置记录),我都引入了开关字段来代表是否启用,大多数时候只需要操作这个字段就能满足场景的需求,真正delete被调用的次数可谓少之又少。某些场景下,不用删除就能达到同样的效果,那还需要刻意引入软删除吗?
理性分析一波,感觉只有极少数表才会用到软删除的需求吧。

  • 最后

    还是自己架构设计太烂,入坑服务端开发之后一直发现,很多可以通过更优秀的架构来解决的问题,自己却经常苦苦陷入其中,写出一些非常hack的代码来作为workaround...啥时候孩子才能不这么菜呢🥲

What is broken can be reforged.