级联删除的思考
实际工作中经常遇到级联删除的需求:但是后续新增一张关联表对应删除逻辑太可能忘记添加对应删除了。
限制条件
- 数据库层面没有外键约束
- 使用mybatis
主要问题
- 构建实体的关系
- 实体关系的完整性
- 防止腐化
- 防止幽灵数据行的存在
实现思路
- 使用注解在实体类上描述清晰关联关系,删除策略
- 统一的删除出口
- 防止腐化的手段
具体实现
实体定义
package com.ml.entity;
public class Order {
private String uuid;
}
public class OrderItem {
private String uuid;
@RelationshipField(name = "order_uuid", targetEntity = Order.class)
private String orderUuid;
}
package com.ml.entity;
import com.ml.relationship.RelationshipField;
public class OrderExpress {
private String uuid;
// 通过父订单关联
@RelationshipField(name = "parent_order_uuid", targetEntity = Order.class)
private String parentOrderUuid;
// 通过根订单关联
@RelationshipField(name = "root_order_uuid", targetEntity = Order.class)
private String rootOrderUuid;
}
package com.ml.entity;
import com.ml.relationship.DeleteStrategy;
import com.ml.relationship.RelationshipField;
public class OrderSearchToken {
private String uuid;
@RelationshipField(name = "order_uuid", targetEntity = Order.class, deleteStrategy = DeleteStrategy.HARD)
private String orderUuid;
}
关系的定义
package com.ml.relationship;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RelationshipField {
// 数据库真实的列名,如 order_uuid
String name();
// 关联的目标主题,如 Order.class
Class<?> targetEntity();
// 默认软删,支持硬删
DeleteStrategy deleteStrategy() default DeleteStrategy.SOFT;
}
package com.ml.relationship;
import com.ml.entity.*;
import com.ml.entity.*;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
public enum GlobalCascadeTopology {
ORDER_ITEM(Order.class, OrderItem.class),
ORDER_EXPRESS(Order.class, OrderExpress.class),
ACCOUNT_ADDRESS(Account.class, AccountAddress.class),
;
private final Class<?> mainEntityClass;
private final Class<?> relationshipClass;
private final List<RelationshipPath> relationshipPaths;
GlobalCascadeTopology(Class<?> mainEntityClass, Class<?> relationshipClass) {
this.mainEntityClass = mainEntityClass;
this.relationshipClass = relationshipClass;
this.relationshipPaths = TopologyEngine.resolvePaths(relationshipClass, mainEntityClass);
}
public Class<?> getMainEntityClass() {
return mainEntityClass;
}
public Class<?> getRelationshipClass() {
return relationshipClass;
}
public List<RelationshipPath> getRelationPaths() {
return relationshipPaths;
}
public static class TopologyEngine {
public static List<RelationshipPath> resolvePaths(Class<?> relationshipClass,
Class<?> targetMainClass) {
List<RelationshipPath> paths = new ArrayList<>();
Field[] fields = relationshipClass.getDeclaredFields();
for (Field f : fields) {
if (f.isAnnotationPresent(RelationshipField.class)) {
RelationshipField a = f.getAnnotation(RelationshipField.class);
if (a.targetEntity() == targetMainClass) {
paths.add(new RelationshipPath(f.getName(), a.name(), a.deleteStrategy()));
}
}
}
if (paths.isEmpty()) {
throw new IllegalStateException(String.format("I can't find any @RelationshipField annotations pointing to the main entity [%s] in the entity class [%s]!", relationshipClass.getName(), targetMainClass.getName()));
}
return paths;
}
public static List<GlobalCascadeTopology> getSubTopologies(Class<?> mainEntityClass) {
List<GlobalCascadeTopology> results = new ArrayList<>();
for (GlobalCascadeTopology topology : GlobalCascadeTopology.values()) {
if (topology.getMainEntityClass() == mainEntityClass) {
results.add(topology);
}
}
return results;
}
}
public static class RelationshipPath {
private final String propertyName;
private final String columnName;
private final DeleteStrategy deleteStrategy;
public RelationshipPath(String propertyName, String columnName, DeleteStrategy deleteStrategy) {
this.propertyName = propertyName;
this.columnName = columnName;
this.deleteStrategy = deleteStrategy;
}
public String getPropertyName() {
return propertyName;
}
public String getColumnName() {
return columnName;
}
public DeleteStrategy getDeleteStrategy() {
return deleteStrategy;
}
}
}
模拟的逻辑层
package com.ml.service;
import com.ml.relationship.DeleteStrategy;
import com.ml.relationship.GlobalCascadeTopology;
import com.ml.repository.GenericMapper;
import java.util.List;
public class CommonRepository {
private GenericMapper genericMapper;
public void deleteAggregateRoot(Class<?> mainEntityClass, String mainUuid) {
List<GlobalCascadeTopology> subTables = GlobalCascadeTopology.TopologyEngine.getSubTopologies(mainEntityClass);
for (GlobalCascadeTopology table : subTables) {
Class<?> childClass = table.getRelationshipClass();
for (GlobalCascadeTopology.RelationshipPath path : table.getRelationPaths()) {
String columnName = path.getColumnName();
if (path.getDeleteStrategy() == DeleteStrategy.SOFT) {
genericMapper.softDeleteByColumn(childClass, columnName, mainUuid);
} else if (path.getDeleteStrategy() == DeleteStrategy.HARD) {
genericMapper.hardDeleteByColumn(childClass, columnName, mainUuid);
}
}
}
genericMapper.softDeleteById(mainEntityClass, mainUuid);
}
}
防止腐化
@Test
public void verifyNoOrphanTablesInUniverse() throws Exception {
// 1. 从物理数据库捞出所有以 "order_" 和 "account_" 开头的表
Set<String> allPhysicalTables = dbHelper.getAllDomainTables();
// 假设有: ["order", "order_item", "order_express", "order_coupon"]
// 2. 从全宇宙唯一的 GlobalCascadeTopology 中捞出所有已经注册的子表
Set<String> registeredTables = Arrays.stream(GlobalCascadeTopology.values())
.map(t -> t.getRelationshipClass().getSimpleName().toLowerCase()) // 转成表名风格
.collect(Collectors.toSet());
// 加上主表本身,因为主表不需要级联自己
registeredTables.add("order");
registeredTables.add("account");
// 3. 差集计算:物理世界存在,但逻辑世界未注册的“孤儿表”
Set<String> orphanTables = new HashSet<>(allPhysicalTables);
orphanTables.removeAll(registeredTables);
// 4. 宪法最高判决
if (!orphanTables.isEmpty()) {
throw new AssertionError(
"【架构宪法警告】物理数据库中存在未注册到级联拓扑图中的‘孤儿表’!\n" +
"这可能意味着你漏写了业务要求的级联删除逻辑!\n" +
"涉嫌漏删的表: " + orphanTables + "\n" +
"请立刻去对应的实体类上补充 @RelationshipField 注解,并在 GlobalCascadeTopology 中登记!"
);
}
}
public class BaseAggregateIntegrateTest {
@Autowired
private javax.sql.DataSource dataSource;
/**
* 终极动态防御:传入一个刚刚被执行了级联删除的主主键,去全库肉眼雷达扫描
*/
protected void assertAbsoluteVacuum(String deletedMainUuid) throws Exception {
List<String> tablesWithResidualData = new ArrayList<>();
try (var conn = dataSource.getConnection()) {
// 1. 遍历全数据库“所有”的表,不管它叫什么名字
var metaData = conn.getMetaData();
try (var rs = metaData.getTables(null, null, "%", new String[]{"TABLE"})) {
while (rs.next()) {
String tableName = rs.getString("TABLE_NAME");
// 2. 动态检查这张表里有没有任何一列,存了当前被删掉的 uuid
// 执行探测 SQL: SELECT COUNT(1) FROM [tableName] WHERE [每列名] = 'deletedMainUuid' AND is_deleted = 0
if (dbHelper.hasAnyColumnContainingValue(conn, tableName, deletedMainUuid)) {
tablesWithResidualData.add(tableName);
}
}
}
}
// 3. 铁律断言:主根已死,万物湮灭。全库不应该再有任何正常的活跃数据和这个 UUID 沾亲带故
if (!tablesWithResidualData.isEmpty()) {
throw new AssertionError(
"【业务完整性坍塌】动态检测发现幽灵数据残留!\n" +
"主实体 Uuid [" + deletedMainUuid + "] 已被执行级联删除," +
"但在以下异构表中依然发现了未被清理的活跃关联记录: " + tablesWithResidualData + "\n" +
"请立刻检查是否漏写了业务级联关系声明!"
);
}
}
}

浙公网安备 33010602011771号