Skip to content

逻辑删除与删表删列

在宠物商店里,下架一只宠物、清退一个标签、甚至下线一张中间表,都不应该让数据「凭空消失」。Forge 默认走安全侧:行级删除写时间戳归档,列与表级删除做重命名归档,只有显式开启开关才会真正物理删除。本篇给出可直接复制的实体写法、application.yml 配置项,以及启动与调用时控制台打印的代表性日志,帮助你确认行为符合预期。

TIP

本篇只讲「怎么用、怎么验」。逻辑删除拦截器如何改写 SQL、删表归档链路的内部编排,见设计篇 逻辑删除与删表删列设计

一、行级逻辑删除:deleted 存删除时间戳

Forge 的逻辑删除标记列默认叫 deleted,它存的不是 0/1 布尔,而是删除时间戳:

  • 未删除:deleted 为 0(或等价的「未删」状态)。
  • 已删除:deleted 写入删除发生的时间戳。

这样设计的直接收益是唯一索引可复用:一只宠物软删后,它原本占用的唯一值能被新数据再次使用。详见下文「唯一索引含 deleted 列」。

默认即开启

继承 IdModel<T> 的 STORE 模型默认跟随全局逻辑删除配置。以宠物主角为例:

java
@Data
@EqualsAndHashCode(callSuper = true)
@Model(name = "pet.Pet", displayName = "宠物")
public class Pet extends IdModel<Pet> {

    @Field(displayName = "名称")
    @Field.String(length = 64)
    private String name;

    @Field(displayName = "状态")
    @Field.Enum
    private PetStatus status;

    @Field(displayName = "售价")
    @Field.BigDecimal(precision = 10, scale = 2)
    private BigDecimal price;

    @Field(displayName = "分类ID")
    @Field.Long
    private Long categoryId;
}

不需要写任何 deleted 字段,逻辑删除列由框架统一注入与维护。

模型级开关与列名定制

如需对单个模型覆盖全局行为,用 @Model.Advanced

java
// 强制启用逻辑删除,并自定义标记列名
@Model(name = "pet.Pet", displayName = "宠物")
@Model.Advanced(logicDelete = LogicDelete.ENABLED, logicDeleteColumn = "deleted_at")
public class Pet extends IdModel<Pet> {
    // ...
}

logicDelete 是三态枚举,三者语义如下:

取值含义
DEFAULT跟随全局配置 forge.model.logic-delete.enabled(默认值)
ENABLED该模型强制启用逻辑删除
DISABLED该模型强制关闭逻辑删除(DELETE 即物理删行)

logicDeleteColumn 为空时取全局配置 forge.model.logic-delete.column,全局默认 deleted

全局配置

yaml
forge:
  model:
    logic-delete:
      enabled: true        # 全局逻辑删除开关(默认开启)
      column: deleted      # 全局逻辑删除标记列名

删除调用与行为

业务侧的删除调用方式不变,逻辑删除由拦截器在底层透明改写:

java
// 下架并删除一只宠物(启用逻辑删除时,底层改写为 UPDATE)
Models.origin(Pet.class).deleteById(petId);

// 后续查询自动过滤已软删行,无需手写 deleted 条件
List<Pet> onSale = Models.origin(Pet.class)
        .where(w -> w.eq(Pet::getStatus, PetStatus.ON_SALE))
        .queryList();

验证结果

application.yml 开启 SQL 与框架 debug 日志:

yaml
logging:
  level:
    cn.cvking.forge: debug
    # MyBatis-Plus 执行的 SQL(mapper 包按实际工程调整)
    com.baomidou.mybatisplus: debug

启用逻辑删除时,deleteById 在底层不是 DELETE,而是被 LogicDeleteInnerInterceptor 改写为 UPDATE,且后续 SELECT 自动追加未删过滤条件,控制台可见类似:

sql
-- 删除被改写为更新(写入删除时间戳)
UPDATE pet SET deleted = ? WHERE id = ? AND deleted = 0;

-- 查询自动追加未删过滤
SELECT * FROM pet WHERE status = ? AND deleted = 0;

TIP

看到 DELETE 改写成 UPDATESELECT 末尾自动带上 deleted 条件,就说明逻辑删除已生效。若想确认是哪个模型在生效,可对照下一节启动期落库的 logicDelete / logicDeleteColumn 元数据。

二、唯一索引含 deleted 列

行级逻辑删除最容易踩的坑就是「软删后唯一值无法复用」。Forge 在模型启用逻辑删除时,会让唯一索引在末尾自动追加逻辑删除列,使唯一性约束变成「未删数据内唯一」。

Categorycode 唯一约束为例:

java
@Model(name = "pet.Category", displayName = "分类")
@Model.Advanced(unique = "code")
public class Category extends IdModel<Category> {

    @Field(displayName = "名称")
    @Field.String(length = 64)
    private String name;

    @Field(displayName = "编码")
    @Field.String(length = 64)
    private String code;
}

启用逻辑删除后,框架生成的唯一索引生效列不是单独的 code,而是 (code, deleted)。语义上:同一个 code,未删行至多一条;当那条被软删(deleted 写入时间戳)后,新行可以再次使用同一个 code,二者 deleted 不同因而不冲突。

WARNING

这正是 deleted 必须用「时间戳」而非 0/1 的根因。若用 0/1,多条软删行的 deleted 都等于 1,(code, 1) 会互相撞唯一索引,软删一旦发生第二次就会抛唯一键冲突。详见文末反例。

三、列级与表级删除:默认逻辑删归档

当你从模型里删掉一个字段、或整类删掉一个模型,启动期 DDL 链路不会贸然 DROP。默认走逻辑删除:

  • 删列:把原列重命名为 _d_ 前缀的归档列,保留数据。
  • 删表:把原表重命名为 _d_{原表名}_{时间戳} 的归档表,保留数据。

是否物理删除由两个开关控制,二者默认都为 false(安全侧):

yaml
forge:
  ddl:
    allow-drop: false           # 删列开关:false=逻辑删除(重命名 _d_ 归档列);true=物理 DROP COLUMN
    allow-drop-index: false     # 删/重建索引开关:false=拦截保留;true=物理 DROP/REBUILD INDEX

对应的属性类是 DdlPropertiesallowDropallowDropIndex 字段默认值均为 false

删表归档命名

归档表名带 _d_ 前缀与时间戳后缀,即 _d_{原表名}_{时间戳}。框架在检测「消失表」时会主动跳过已带 _d_ 前缀的表,因此归档表不会被二次归档或误删。

验证结果

启动期 DDL 链路会打印 lifecycle 与删表归档日志。先开启日志:

yaml
logging:
  level:
    cn.cvking.forge: debug

默认(allow-drop: false)下线一张表,控制台可见归档日志:

[DDL] 逻辑删表,归档为 _d_pet_tag_1717459200000
Lifecycle=DDL, 模块 2 个, 扫描模型 7 个, 耗时 138ms

把开关改为 allow-drop: true 后再启动,则是物理删表日志:

[DDL] 物理删表 DROP TABLE pet_tag(allow-drop=true)

TIP

分辨二者很简单:日志出现「归档为 _d_...」就是安全侧的逻辑删;出现「物理删表 DROP TABLE」就是真的删了。生产环境务必让 allow-drop 保持 false

启动期还会打印 lifecycle 概览与模型扫描数量,可用来确认本轮扫描到的模型集合是否符合预期:

[lifecycle] Module topology order: [base, pet]
Lifecycle=DDL, 模块 2 个, 扫描模型 7 个, 耗时 138ms
DDL dry-run 模式

若以 dry-run 方式运行 DDL,链路结束后会把变更脚本写入文件并退出 JVM,日志形如:[DDL] Lifecycle=DDL completed, dry-run file: /path/to/xxx.sql, JVM will exit now.。此时不会真正改库,适合上线前预审。

四、常见反例与排查

反例 1:把 deleted 当 0/1,软删第二次撞唯一索引

现象:第一只 code=CAT-001 的分类软删后,新建同 code 正常;再把新的也软删,启动或写入时报唯一键冲突。

原因:把 deleted 当作 0/1 布尔使用时,所有软删行的 deleted 都等于 1。唯一索引生效列是 (code, deleted),多条 (CAT-001, 1) 互相冲突。

修复:不要自定义 0/1 的 deleted 列,沿用框架默认的时间戳语义——未删为 0、已删写删除时间戳。每条软删行时间戳不同,(code, 时间戳) 自然不冲突,唯一值得以复用。

反例 2:生产环境把 allow-drop 设成 true,删字段即丢数据

现象:调整模型字段后启动,控制台打印 [DDL] 物理删表 DROP TABLE ...,旧列旧表数据彻底消失,无法回滚。

原因forge.ddl.allow-drop 被显式设为 true,删列删表走物理 DROP,不再 _d_ 归档。

修复:生产环境保持 allow-drop: false(这也是默认值)。需要物理回收 _d_ 归档表/列时,在确认归档数据无用后由 DBA 单独人工执行,而不是把全局开关常开。

反例 3:TRANSIENT/非 STORE 模型不参与建表,别误以为「消失表」

现象:把某模型从 STORE 改成 TRANSIENT(或本就是 TRANSIENT/ABSTRACT/PROXY),启动后发现库里相关表没了,怀疑被误删。

原因:只有 STORE 模型才有数据库表。SysModeltableName 对非 STORE 模型为 null,它们本就不参与建表。若一张存量物理表对应的模型不再是 STORE,启动期会把它视为「消失表」按归档规则处理。

修复:确认模型类型是否真的需要落库;需要保留表就维持 ModelTypeEnum.STORE。即便被当作消失表,默认 allow-drop: false 也只是归档为 _d_ 前缀表,数据仍在,重命名回来即可恢复。

反例 4:删除不生效或全表数据「凭空过滤」

现象:调用 deleteById 后行真的没了(物理删),或查询结果莫名少了很多行。

原因:模型 @Model.Advanced(logicDelete = LogicDelete.DISABLED) 关闭了逻辑删除(物理删),或全局 forge.model.logic-delete.column 与实际表里的删除列名不一致,导致拦截器追加的过滤条件命中了非预期列。

修复:核对模型的 logicDelete 三态与全局 enabled;确保 logicDeleteColumn / 全局 column 与表结构一致。可对照启动期落库的 SysModel.logicDeleteSysModel.logicDeleteColumn 元数据确认生效值。