唯一键vs软删除/逻辑删除

461 阅读4分钟

在数据库表设计中,容易出现唯一键与软删除/逻辑删除冲突的场景,数据库的问题建议描述如下。

经典案例:

案例1 关联关系表

背景:该表记录a实体和b实体的关联关系,唯一键是(a_id,b_id)。软删除标记deleted,取值范围0/1,0为未删除,1为已删除。

问题:当a_id和b_id解除关联后再关联时,尝试insert a_table values (a_id, b_id) ,会报唯一键冲突。即便将deleted加入唯一键,可以再次关联,当再次关联的数据想解除关联时,依然会报唯一键冲突。

案例2 对实体表(如用户表),同一个字段(如user_name)可能是业务唯一的场景(但不是实体唯一id)。已删除时,仍允许其他用户使用相同user_name

此时希望使用唯一键来保证业务唯一性(虽然笔者看来此时已经不是系统唯一了,似乎不应该引入唯一键)。

解决思路:

思路1:硬删除。

对关联关系表放弃软删除,改为直接删除。对软删除的价值,寻找其他方式补偿。例如软删除的运维价值,通过请求流水记录表来实现(此时流水记录表不携带唯一键,因为操作天然允许多次)。

劣势:部分场景对软删除强依赖(比如用户表使用user_name作唯一键,不希望删除后和用户数据的关联关系无法追溯)。

思路2:局部索引。

部分数据库(如Postgres)支持创建唯一索引时携带类似 WHERE deleted = 0 结构的从句(即建立局部索引)。仅在指定字段为0时,才加入唯一索引。对这类数据库,可以完美解决此问题。

需关注,PostgreSQL对于UNIQUE CONSTRAINT是不支持局部索引语法的,仅对UNIQUE INDEX支持。

-- 该语句会报错,提示WHERE部分不支持。
ALTER TABLE a_table ADD CONSTRAINT ukey_part_user_name_deleted UNIQUE(user_name, deleted) WHERE deleted = 0;
-- 该语句ok,局部唯一索引,仅deleted为0的行加入唯一限制。
CREATE UNIQUE INDEX ukey_part_user_name_deleted ON a_table (user_name, deleted) WHERE deleted = 0;

劣势:依赖数据库版本。

思路3:逻辑删除赋值为自增id。

修改索引与deleted标记。标记不再是0,1,而是一个INT或者BIGINT。将deleted加入到唯一键定义。同时在关联关系表中引入自增主键,逻辑删除时,deleted赋值为自增主键。

劣势:对关联表引入了唯一id(一个负担)。以及团队成员的适应成本。

思路4:复活已删除条目。

保持索引,deleted标记也依然是0,1。再关联时,使用insert ... on duplicate key update 或者 upsertdeleted标记恢复为0(复活原条目),完成恢复。

劣势:1)部分场景不应该恢复已删除条目(比如用户表和用户关系表,重建会导致将前用户的数据关联到当前用户)。2)将已删除的数据直接还原,从技术角度感觉比较Tricky,新数据不应该直接使用原数据的条目,可能引入其他未考量到的问题。

思路5:放弃唯一索引。

放弃索引,保持逻辑删除0,1。数据唯一性基于业务代码支持。

劣势:唯一性无法依赖数据库保证,不推荐。

建议方案

思路1推荐,但是部分业务场景仍然期望保留已删除的关系,需要另外寻求方案。

思路2推荐,但是依赖数据库版本。此外不确定是否有其他的技术负担。可作为Postgres数据库的方案。

思路3推荐,会引入额外的主键维护成本。可作为非Postgres数据库的替代方案。

思路4不推荐,在部分业务场景会有数据错乱的风险(见劣势的描述),不建议作为标准方案使用。

思路5不推荐。

案例2的解决思路可参考案例1的思路1,思路2,思路3。

思路2、思路3需要重新统一团队认知。在无法打破团队历史认知壁垒时,可考虑思路1。

补充思路

补充思路1:基于MySQL的唯一索引会无视字段为NULL的行,设置专门的索引列(如unarchived),已删除为null,未删除为1。将之引入唯一索引,实现类似上文思路3的效果。

ALTER TABLE `dummy` ADD `not_deleted` tinyint AS (if(`deleted_at` is null,1,NULL)) STORED;
CREATE UNIQUE INDEX `IDX_4b8d12e176ac3d69d21ebebd5a` ON `dummy` (`code`, `not_deleted`);