实战技巧:多租户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%。
产品亮点:
- 完整的多租户隔离,数据安全可靠
- 灵活的租户配置,支持个性化定制
- 完善的权限体系,细粒度控制
总结
软删除 + 多租户看似简单,但组合使用时容易踩坑:
- 新增数据:务必设置
delFlag = 0 - 多租户隔离:使用拦截器全局处理,不要手动拼接
- 批量操作:检查每条数据的完整性
- 数据巡检:定期检查异常数据
希望这些经验能帮助大家避坑,少走弯路!
相关阅读:
- MyBatis-Plus官方文档:逻辑删除
- 多租户架构设计最佳实践
- SaaS系统数据隔离方案对比

浙公网安备 33010602011771号