级联删除的思考

实际工作中经常遇到级联删除的需求:但是后续新增一张关联表对应删除逻辑太可能忘记添加对应删除了。

限制条件

  1. 数据库层面没有外键约束
  2. 使用mybatis

主要问题

  1. 构建实体的关系
  2. 实体关系的完整性
  3. 防止腐化
  4. 防止幽灵数据行的存在

实现思路

  1. 使用注解在实体类上描述清晰关联关系,删除策略
  2. 统一的删除出口
  3. 防止腐化的手段

具体实现

实体定义

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" +
                "请立刻检查是否漏写了业务级联关系声明!"
            );
        }
    }
}
posted @ 2026-05-18 17:44  小马快跑3  阅读(8)  评论(0)    收藏  举报