软删除之痛

目录


软删除简介

如果你对“软删除”这个词有点陌生,可以用“逻辑删除”、“标记删除”来代替,相信你看到“标记”二字时,已然猜出了其中含义:软删除不会真的将数据记录从数据库中 delete 掉,而是通过修改某个字段来标记这条记录是被删除的。

软删除提供了一颗后悔药,当不小心删除了数据记录,由于实质性地删除操作并没有在数据库里真实发生,恢复这些数据是很容易的操作。无论怎么看,这貌似都是一个难有争议的优秀做法。

一般说软删除有三种方式:

  1. 添加类似于 is_deleted 的布尔型字段,true 表示已经被删除了,这是最容易想到的方法;
  2. 添加类似以 deleted_at 的时间戳字段,null 表示未被删除,非 null 表示已经被删除了,并额外透露了删除的时间,比第一种方法看起来更先进一些;
  3. 使用“影子表”,即 user 表会有一个配套的 user_deleted 表,在删除 user 表中的数据之前,复制数据到 user_deleted 表。

你应该跟明显地注意到,第三种方案虽然也叫“软删除”,但和前两种方案的思路完全不一样,它更接近于一种“copy on delete”的备份策略,而不是一种“标记删除”的删除策略。所以这里我们不讨论第三种方案,暂时认为“软删除”特指“通过特殊字段标记删除”。

软删除是一个很常见的需求,很多 ORM 框架本身就支持软删除,这里以 golang 的 gorm 为例,它的文档里很清楚地展示了它是如何支持软删除的:

/*
如果一个 model 有 DeletedAt 字段,他将自动获得软删除的功能! 当调用 Delete 方法时, 记录不会真正的从数据库中被删除, 只会将DeletedAt 字段的值会被设置为当前时间
*/

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

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

// 查询记录时会忽略被软删除的记录
db.Where("age = 20").Find(&user)
// SELECT * FROM users WHERE age = 20 AND deleted_at IS NULL;

// Unscoped 方法可以查询被软删除的记录
db.Unscoped().Where("age = 20").Find(&users)
// SELECT * FROM users WHERE age = 20;

// Unscoped 方法可以强制物理删除记录
db.Unscoped().Delete(&order)
// DELETE FROM orders WHERE id=10;

可以看到,gorm 是使用上文中第二种 deleted_at 方式实现软删除的,且实现的非常优雅,对于使用者来说几乎是透明的。

至此,相信你我对“软删除”一词的理解已经达成共识了,那么下面就要进入正题:一切方案皆有成本,今天我是来泼冷水的,讲一讲软删除的弊端。

唯一索引

数据库的 unique index 很多时候是保证业务逻辑正确的强效手段,假设我们不希望 user 表中的 username 重名,势必需要加一个 unique key (username) 这样的唯一索引。假设这张表长这样:

id username deleted_at
1 xiaoming null

因为唯一索引的约束,这时候再添加一条 username 为 xiaoming 的记录肯定是会失败的。接着,id 为 1 的“小明”滚蛋了,数据记录被软删除了:

id username deleted_at
1 xiaoming 2019-08-28 15:14:36

这个时候又有另外一个“小鸣”来注册了,一看用户列表里并没有 username 叫“xiaoming”的(已经被删了),于是开开心心地用“xiaoming”注册,发现诡异的事情发生了:他无法注册,因为触发了数据库唯一索引的限制,毕竟唯一索引可不理解什么是软删除。

针对这个情况我曾自以为聪明地提出了使用联合 username 和 deleted_at 作为唯一索引的方法,即 unique key (username, deleted_at),这样的话数据表可能长这样:

id username deleted_at
1 xiaoming 2019-08-28 15:14:36
2 xiaoming 2019-08-28 15:14:37
3 xiaoming 2019-08-28 15:14:38
4 xiaoming null

因为已经删除的记录 deleted_at 的值各不相同,所以彼此并不冲突,但这里有个错误的认知,就是“('xiaoming', null)('xiaoming', null) 是相等的,不能出现两个 xiaoming 的 deleted_at 都为空”,但实际情况是 ('xiaoming', null)('xiaoming', null) 既不相等,也“不不相等”,并不受唯一索引的限制,见《关于 SQL,感觉自己学了假书》中的“谜之 NULL”一节,所以有可能出现两个 deleted_at 为 null 的 xiangming,唯一索引形同虚设。

这是使用 deleted_at 的情况,那么使用 is_deleated 呢,使用 unique key (username, is_deleated)?那情况就更糟了:

id username is_deleted
1 xiaoming 1
2 xiaoming 0

这个时候我如果想删除 id 为 2 的 xiaoming,竟然提示无法删除,因为无法将 id 为 2 的记录的 is_deleted 置为 true(1),因为触发了唯一索引的限制。

可见,软删除模式下,唯一索引很难正确工作,稍不留意你就可能踩坑。但如果你坚持既要软删除又要唯一索引,也并非毫无办法,我曾经和同事讨论这个问题,得出两个可能可行的方案:

一是将 deleted_at 的类型改为数字类型,存 unix 时间戳,用 0 而不是 null 来表示未删除,但这样需要改造 gorm,甚至改成我们自己的 ORM 包,比如叫 x-gorm 之内的,但自己造轮子是有风险的且需要时间的。

二是添加一个字段,这个字段的值是用 username 和 deleted_at 拼接后取哈希生成的,类似于 hash_username_deleted_at char(40) generated always as (sha(concat_ws('-', username, deleted_at))) stored,再给 hash_username_deleted_at 字段加唯一索引,大功告成!但很可惜,种种原因 DBA 并不接受使用 generated 字段,不得不作罢。

可见,虽然方法可行,但都是需要成本的,这是软删除带给唯一索引的成本。

外键约束

唯一索引的麻烦虽然恼人,但也并非完全无解,而如果你还惦记着外键,指望外键约束能在软删除模式下继续帮你维护数据的一致性,那我劝你真的别想了。

还是举上文的例子,假设每个用户都有一个外键字段 company_id 指示其所在的公司:

id username company_id deleted_at
1 xiaoming 1 null

同时有公司表:

id company_name deleted_at
1 abc null

这个时候麻烦来了。

首先,如果你试图删除 id 为 1 的公司,外键既不会不允许删除,也不会帮你级联删除,它啥都不管,因为软删除对于外键来说,只是数据记录的一个字段变化了而已:

id company_name deleted_at
1 abc 2019-08-28 15:14:36

这个时候如果你想查询小明公司的名字,可能程序可能就会报错:“record not found”。

麻烦还有没有完,这个时候又来个小红,她把自己的 company_id 设为已经被删除了的 1,而外键约束并不会阻止她,因为在外键眼里确实有 id 为 1 的公司,即使它被软删除了。

在软删除的模式下,外键基本上彻底报废了,但这不见得是一件坏事,现在很多数据表设计规范中都不建议使用外键了,而是建议在业务逻辑中手动去保证外键约束,以此换取数据库的性能,比如这篇《MySQL数据库设计规范》里的介绍。所以这外键这件事情上,软删除自然无功,但也未必有过。

指定 ID

上面举的例子中,我们都是假设 id 是自动生成的,这也是绝大多数情况下的做法:将主键设置为 auto_increment。然而总有例外情况,有些时候我们就是要指定 id 呢?

这个问题和“不允许 username 重复的逻辑类似”,但情况要更复杂一点,因为 username 毕竟只是个字段,xiaoming 冲突了,大不了我叫 xiaoming.1、xiaoming.2019、xiaoming.haha 不就得了。

但是如果表中的 id 不是自增 id,而是身份证号码呢?假设 xiaoming 的账户长这样:

id username company_id deleted_at
123456201908291234 xiaoming 1 null

后来小明跑路了,离职撂挑子了,账户被删除了:

id username company_id deleted_at
123456201908291234 xiaoming 1 2019-08-28 15:14:36

但是现在外面工作不好找哇,小明在家蹲了两个月,找不到工作又觍着脸回来求老板让他重新入职,老板慈悲心肠同意了,如果小明重新注册账户,却发现他无论如果也无法用自己的身份证号码注册,提示主键冲突。

这时候咋办?改让小明去派出所申请改身份证号码吗?《中华人民共和国居民身份证法》第一章第三条明确规定:公民身份号码是每个公民唯一的、终身不变的身份代码,由公安机关按照公民身份号码国家标准编制。

你可能会说,可以重新置 deleted_at 字段为 null 呀,这不是软删除带来的福利吗?但我之前说了,软删除是用来在误删数据时做补救措施的,是非常手段,不应该成为正常流程。

当然,这里这里举的例子略显粗糙,实际情况可能不会删除小明的账户,而是标记为“离职”状态而已。但这不是重点,重点是:创建和删除原本是完全对冲的操作,我们直觉上会认为“创建 A 之后再删除 A”,是“零和”的,就类似以“+1 + -1”,结果一定等于 0,宛如啥都没发生。但软删除打破了这个直觉,创建 A 之后再删除 A,就不能再次创建 A 了,你只能用创建 B 来代替。

这是个微妙的陷阱,可能会让你在设计方案的过程中吃上苦头。

复杂查询

这一节的标题说”复杂查询“,那是要多复杂呢?要不要又来:“请用 SQL 查询选了张老师至少一门专业课且没有不及格课程的男同学的按生日年份分组的平均身高”呢?不不,我们简单一点好吧,脑子不够用的。

还是这两张表:

id username company_id deleted_at
1 xiaoming 1 null
id company_name deleted_at
1 abc null

我们需要得到 username -> company_name 的映射,SQL 可能需要写成这样:

select user.username, company.company_name from user inner join company on user.company_id = company.id

可能虽然有点长,但还不算复杂,一个简单的联表查询。可是别忘了,这样查可是会把已经删除的记录也显示出来的,所以还需要把查询范围限制在 deleted_at 为空以内,所以原本简单的 sql 语句瞬间变得面目可憎:

select user.username, company.company_name from (select * from user where deleted_at is null) as user inner join (select * from company where deleted_at is null) as company on user.company_id = company.id

且不说性能问题,看着就让人头大。

写复杂 sql 本是分内的事情,毕竟如果业务需要,该写的还是要写的,但是恼人的地方在于,当你的大脑在编写某些精巧的、复杂的 sql 时,却要时常考虑软删除,频繁地添加 where deleted_at is null,你可以试一试找一些你原本游刃有余的 sql 面试题,试试看在软删除的模式下,你是否还能继续保持清晰的思路并得出准确的答案,我敢打赌你会像戴着镣铐跳舞一般难受,且这镣铐比你想象的要沉重得多。

数据堆积

最后一个问题其实是我拿来凑数的,但它确实也是个问题。

由于软删除不会真正地删除数据,所以你永远无法通过”清理旧数据“来释放磁盘空间或提高查询速度。当然在”用户信息表“这种场景下你完全不需要考虑这种情况,毕竟如果真有一天你的用户数量已经多到让你开始考虑清理数据时,你优先要解决的问题是如果把你的公司卖一笔好价钱,哪有功夫考虑数据库的事情。

但换一个场景吧,比如“用户操作记录表”,即使你只有一万个用户,每天有五千个用户登录,每个用户平均做 20 次操作,那你每天就要往“用户操作记录表”塞 10 万条数据。你当然会意识到这个问题,于是你设计了“定时删除 15 天以前的用户操作记录”的绝佳方案。哦,差点忘了说了,你是个构架师,只负责做技术方案设计,并不插手实际开发,更不会过问数据表每个字段的设计,那都是开发的活儿不是吗?可负责开发的哥儿们是我,我并不知晓你设计的初衷,给“用户操作记录表”也使用了软删除,这导致虽然定时删除这个逻辑被正常执行了,但数据库的磁盘还是很快被吃满。

总结

本文的观点看起来并不中立,大部分篇幅都是在吐槽软删除,但之所以会这样,是因为软删除确实很有用且很好用,鲜有贬义之词,而大多数人(比如说我)看到 ORM 框架支持软删除的时候一定会无所顾忌的启用,并感觉自己的代码瞬间“鲁棒”了很多。而本文就是反方辩手,让你多一分顾忌,让你知道软删除也是有使用成本的,你需要进一步地思考表设计是否合理。

比如上文中的“用户操作记录表”确实没有软删除的必要,数据误删了、丢失了,带来的损失并不严重。而上文中的用户表,可以完全不允许删除,对于“注销”操作,可以通过维护用户状态来完成,该用户仍然是存在的,并没有被删除,而是状态是“已注销”,这是个业务逻辑而不是数据库设计。

而确实有这样的表,它数据很重要,不希望被误删,且不需要唯一索引,不需要外键,数据量也不会太多,那用软删除模式自然是再好不过。

说完了。

最后扯个淡来做结尾哈。

在学校的时候曾经选修过一门小班讨论课,课名叫“系统思考与防错设计”,开课的是一位严谨而活泼的机自学院教授老头儿,一整个学期,他举了大量亲身经历的现实案例,来证明一个工程设计领域的终极真理:天下没有免费的午餐。这里不是在教育理工直男们防电信诈骗,而是说任何工程设计有优点的同时,一定有缺点,没有例外。

我擅自改编了一下,直白一些但意思一样:一切方案,皆有成本。

因为一切方案皆有成本,所以新方案即使再好也是有成本的,既然我们忌惮成本,那保守一点,不采用新方案不就得了?那有趣的地方来了:因为“不采用新方案”也是一个方案,而一切方案皆有成本,“不采用新方案”这个方案的成本你也要忌惮。

——是不是感觉到一点哲学式的诡谲?