实战技巧:多租户SaaS系统中的软删除陷阱与解决方案

实战技巧:多租户SaaS系统中的软删除陷阱与解决方案

背景

最近在开发一个多租户SaaS系统时,遇到了一个"诡异"的Bug:明明数据库里有数据,但查询结果却是空的。排查了半天,发现问题出在软删除字段和多租户隔离的配合使用上

这个问题看似简单,但在实际项目中很容易踩坑,今天就来分享一下。

问题复现

假设有一个简单的会员表:

CREATE TABLE member (
    id VARCHAR(32) PRIMARY KEY,
    username VARCHAR(100),
    mobile VARCHAR(20),
    tenant_id INT,           -- 租户ID
    del_flag INT DEFAULT 0   -- 软删除标记:0-正常,1-删除
);

使用MyBatis-Plus的软删除功能:

@Data
@TableName("member")
public class Member {
    @TableId
    private String id;
    private String username;
    private String mobile;
    private Integer tenantId;
    
    @TableLogic  // 标记为软删除字段
    private Integer delFlag;
}

场景一:新增数据查不到

Member member = new Member();
member.setUsername("test");
member.setMobile("13800138000");
member.setTenantId(1);
// 注意:没有设置 delFlag

memberService.save(member);

// 立即查询
Member result = memberService.getById(member.getId());
// result 为 null!

原因:新增时没有设置 delFlag = 0,而MyBatis-Plus查询时会自动加上 WHERE del_flag = 0,导致查不到。

场景二:多租户隔离失效

// 租户A的数据
Member memberA = new Member();
memberA.setMobile("13800138001");
memberA.setTenantId(1);
memberService.save(memberA);

// 租户B的用户登录后查询
TenantContext.setTenantId(2);
List<Member> list = memberService.list();
// 结果竟然包含租户A的数据!

原因:多租户过滤逻辑没有正确应用,或者在某些场景下被绕过了。

解决方案

1. 软删除字段必须显式初始化

错误做法

Member member = new Member();
member.setUsername("test");
memberService.save(member);

正确做法

Member member = new Member();
member.setUsername("test");
member.setDelFlag(0);  // 显式设置
memberService.save(member);

更好的做法:在实体类中使用默认值:

@Data
public class Member {
    // ... 其他字段
    
    @TableLogic
    private Integer delFlag = 0;  // 直接赋默认值
}

或者在数据库层面设置默认值:

del_flag INT DEFAULT 0 NOT NULL

2. 多租户隔离的正确姿势

方案A:拦截器自动注入(推荐)

@Component
public class TenantInterceptor implements HandlerInterceptor {
    
    @Override
    public boolean preHandle(HttpServletRequest request, 
                            HttpServletResponse response, 
                            Object handler) {
        // 从请求头或Token中获取租户ID
        Integer tenantId = extractTenantId(request);
        TenantContext.setTenantId(tenantId);
        return true;
    }
    
    @Override
    public void afterCompletion(HttpServletRequest request, 
                                HttpServletResponse response, 
                                Object handler, Exception ex) {
        // 清理上下文,防止内存泄漏
        TenantContext.clear();
    }
}

方案B:MyBatis-Plus拦截器

@Configuration
public class MybatisConfig {
    
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        
        // 多租户插件
        TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor();
        tenantInterceptor.setTenantLineHandler(new TenantLineHandler() {
            @Override
            public Expression getTenantId() {
                Integer tenantId = TenantContext.getTenantId();
                return new LongValue(tenantId != null ? tenantId : 0);
            }
            
            @Override
            public boolean ignoreTable(String tableName) {
                // 某些公共表不需要租户隔离
                return "sys_config".equals(tableName);
            }
        });
        
        interceptor.addInnerInterceptor(tenantInterceptor);
        return interceptor;
    }
}

3. 复合查询的正确写法

当需要同时使用软删除和多租户过滤时:

// 错误:可能漏掉租户条件
List<Member> list = memberService.list(
    new QueryWrapper<Member>().eq("del_flag", 0)
);

// 正确:同时添加两个条件
Integer tenantId = TenantContext.getTenantId();
List<Member> list = memberService.list(
    new QueryWrapper<Member>()
        .eq("tenant_id", tenantId)
        .eq("del_flag", 0)  // 如果配置了@TableLogic,这行可以省略
);

4. 批量操作要特别注意

批量操作是最容易出问题的地方:

// 危险:批量插入时可能漏掉关键字段
List<Member> members = new ArrayList<>();
for (int i = 0; i < 100; i++) {
    Member m = new Member();
    m.setUsername("user" + i);
    m.setTenantId(tenantId);
    m.setDelFlag(0);  // 必须设置!
    members.add(m);
}
memberService.saveBatch(members);

常见误区总结

误区 后果 正确做法
新增时不设置delFlag 数据查不到 实体类默认值 + 数据库默认值
忘记设置tenantId 数据越权 拦截器自动注入
查询时不加租户条件 数据泄露 全局拦截器 + 手动检查
批量操作漏字段 数据不一致 编写统一的构建方法

最佳实践建议

1. 封装统一的实体构建器

public abstract class BaseEntity {
    protected Integer tenantId;
    protected Integer delFlag = 0;
    protected Date createTime;
    protected String createBy;
    
    public void init(String createBy, Integer tenantId) {
        this.createBy = createBy;
        this.tenantId = tenantId;
        this.delFlag = 0;
        this.createTime = new Date();
    }
}

// 使用
Member member = new Member();
member.setUsername("test");
member.init(currentUser, tenantId);  // 统一初始化

2. 使用填充策略自动处理

MyBatis-Plus提供了字段自动填充功能:

@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
    
    @Override
    public void insertFill(MetaObject metaObject) {
        this.strictInsertFill(metaObject, "delFlag", Integer.class, 0);
        this.strictInsertFill(metaObject, "tenantId", Integer.class, 
            TenantContext.getTenantId());
        this.strictInsertFill(metaObject, "createTime", Date.class, new Date());
    }
    
    @Override
    public void updateFill(MetaObject metaObject) {
        this.strictUpdateFill(metaObject, "updateTime", Date.class, new Date());
    }
}

3. 定期数据巡检

-- 检查del_flag为NULL的数据
SELECT COUNT(*) FROM member WHERE del_flag IS NULL;

-- 检查tenant_id为NULL的数据
SELECT COUNT(*) FROM member WHERE tenant_id IS NULL;

-- 修复数据
UPDATE member SET del_flag = 0 WHERE del_flag IS NULL;

关于我们的SaaS产品

这套多租户架构已成功应用于我们开发的智慧协会云平台

这是一个面向协会/商会的数字化管理SaaS系统,主要功能包括:

  • 协会官网搭建:可视化设计器,拖拽即可搭建专业门户
  • 会员管理系统:入会审核、会员档案、等级管理、标签分组
  • 证书管理系统:证书模板设计、批量颁发、在线验真
  • 活动会议管理:在线报名、签到、缴费一体化
  • 财务收费管理:会费缴纳、发票申请、数据统计

系统稳定性达 99.9%

产品亮点:

  • 完整的多租户隔离,数据安全可靠
  • 灵活的租户配置,支持个性化定制
  • 完善的权限体系,细粒度控制

官网:https://www.huixiehui.cn


总结

软删除 + 多租户看似简单,但组合使用时容易踩坑:

  1. 新增数据:务必设置 delFlag = 0
  2. 多租户隔离:使用拦截器全局处理,不要手动拼接
  3. 批量操作:检查每条数据的完整性
  4. 数据巡检:定期检查异常数据

希望这些经验能帮助大家避坑,少走弯路!


相关阅读

  • MyBatis-Plus官方文档:逻辑删除
  • 多租户架构设计最佳实践
  • SaaS系统数据隔离方案对比
posted @ 2026-04-08 00:08  .拿来吧你  阅读(5)  评论(0)    收藏  举报