SpringBoot + 多租户规则沙箱隔离:数据串号、规则越权?彻底杜绝的架构设计!

做多租户系统的同学肯定都踩过这个坑:某个租户的数据莫名其妙出现在了另一个租户的账号里,或者租户 A 配置的规则被租户 B 给用上了。这些问题听起来像是低级 bug,但背后折射出的是多租户架构中数据隔离和权限控制的复杂性。

特别是在规则引擎场景下,租户会有自己的业务规则,比如优惠券规则、会员等级规则、风控规则等。如果规则引擎没有做好租户隔离,后果可能是:

  • 租户 A 领到了租户 B 的优惠券,造成营销损失
  • 租户 A 的风控规则被租户 B 绕过,导致业务风险
  • 财务规则串号,造成账务错误

今天我们就来聊聊多租户规则沙箱隔离的架构设计,彻底杜绝数据串号和规则越权问题。

为什么多租户隔离这么难?

先来分析一下多租户隔离的难点在哪里。很多团队会说:"我们用了数据库租户字段,做了 WHERE tenant_id = xxx 的查询啊,怎么还会串号?"

实际情况远比这复杂:

1. 规则引擎的上下文污染

规则引擎通常会缓存已加载的规则,如果缓存 key 设计不当,可能会出现:

  • 规则 key 只用了 ruleId,没有包含 tenantId
  • 规则执行时的上下文(Context)被多个租户共享
  • 规则变量(Variable)存储在全局 Map 里被覆盖

2. 事务边界的模糊地带

在分布式事务或者长事务场景下:

  • 某个租户的规则在异步线程中执行
  • 规则执行过程中发生了上下文切换
  • 子事务或内部方法没有传递租户标识

3. 缓存和资源池化

为了性能优化,我们经常会使用缓存、连接池等技术:

  • 本地缓存没有租户维度
  • Redis 缓存 key 缺少租户标识
  • 数据库连接池中的信息残留

4. 规则之间的依赖

复杂的业务规则之间可能有依赖关系:

  • 子规则被多个父规则引用
  • 规则模版在租户间共享
  • 规则执行结果被缓存复用

整体架构设计

我们的多租户规则沙箱隔离方案由以下几个核心组件构成:

  1. TenantContextHolder:租户上下文持有器,基于 ThreadLocal 实现
  2. TenantRuleExecutor:租户规则执行器,每次执行规则都是独立的沙箱环境
  3. TenantRuleCache:租户规则缓存,按租户维度隔离缓存
  4. TenantRuleValidator:租户规则校验器,校验规则归属和权限
  5. TenantDataFilter:租户数据过滤器,自动注入租户条件

让我们看看如何在 SpringBoot 中实现这套隔离系统:

1. 租户上下文管理

首先定义租户上下文的核心组件:

/**
 * 租户上下文持有器
 * 使用 ThreadLocal 存储当前线程的租户信息
 */
public class TenantContextHolder {
    
    private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
    
    /**
     * 设置当前租户ID
     */
    public static void setTenantId(String tenantId) {
        if (tenantId == null) {
            throw new IllegalArgumentException("租户ID不能为空");
        }
        CURRENT_TENANT.set(tenantId);
    }
    
    /**
     * 获取当前租户ID
     */
    public static String getTenantId() {
        return CURRENT_TENANT.get();
    }
    
    /**
     * 清除租户上下文
     */
    public static void clear() {
        CURRENT_TENANT.remove();
    }
    
    /**
     * 租户上下文工具类
     */
    public static class TenantContext {
        
        /**
         * 在租户上下文中执行代码
         */
        public static <T> T execute(String tenantId, Supplier<T> supplier) {
            String oldTenantId = getTenantId();
            try {
                setTenantId(tenantId);
                return supplier.get();
            } finally {
                if (oldTenantId != null) {
                    setTenantId(oldTenantId);
                } else {
                    clear();
                }
            }
        }
        
        /**
         * 在租户上下文中执行 void 方法
         */
        public static void execute(String tenantId, Runnable runnable) {
            String oldTenantId = getTenantId();
            try {
                setTenantId(tenantId);
                runnable.run();
            } finally {
                if (oldTenantId != null) {
                    setTenantId(oldTenantId);
                } else {
                    clear();
                }
            }
        }
    }
}

2. 租户上下文拦截器

通过 SpringMVC 拦截器自动注入租户上下文:

@Component
public class TenantContextInterceptor implements HandlerInterceptor {
    
    private static final String TENANT_HEADER = "X-Tenant-ID";
    private static final String TENANT_PARAM = "tenantId";
    
    @Autowired
    private TenantService tenantService;
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String tenantId = extractTenantId(request);
        
        if (tenantId == null || tenantId.isEmpty()) {
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            response.getWriter().write("{\"code\": 401, \"message\": \"缺少租户标识\"}");
            return false;
        }
        
        if (!tenantService.isValidTenant(tenantId)) {
            response.setStatus(HttpStatus.FORBIDDEN.value());
            response.getWriter().write("{\"code\": 403, \"message\": \"无效的租户\"}");
            return false;
        }
        
        TenantContextHolder.setTenantId(tenantId);
        return true;
    }
    
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        TenantContextHolder.clear();
    }
    
    private String extractTenantId(HttpServletRequest request) {
        String tenantId = request.getHeader(TENANT_HEADER);
        if (tenantId == null || tenantId.isEmpty()) {
            tenantId = request.getParameter(TENANT_PARAM);
        }
        return tenantId;
    }
}

3. 租户规则执行器

核心的规则执行器,确保每次执行都在独立的沙箱环境中:

@Service
@Slf4j
public class TenantRuleExecutor {
    
    @Autowired
    private RuleEngine ruleEngine;
    
    @Autowired
    private TenantRuleCache ruleCache;
    
    @Autowired
    private TenantRuleValidator ruleValidator;
    
    @Autowired
    private RuleExecuteLogMapper ruleExecuteLogMapper;
    
    /**
     * 执行单个规则
     */
    public RuleExecuteResult executeRule(String ruleCode, Map<String, Object> context) {
        String tenantId = TenantContextHolder.getTenantId();
        if (tenantId == null) {
            throw new TenantContextException("租户上下文未设置");
        }
        
        long startTime = System.currentTimeMillis();
        
        try {
            RuleExecuteResult result = TenantContextHolder.TenantContext.execute(tenantId, () -> {
                return doExecuteRule(ruleCode, context, tenantId);
            });
            
            long costTime = System.currentTimeMillis() - startTime;
            saveExecuteLog(ruleCode, tenantId, context, result, costTime, null);
            
            return result;
        } catch (Exception e) {
            long costTime = System.currentTimeMillis() - startTime;
            RuleExecuteResult errorResult = RuleExecuteResult.fail(e.getMessage());
            saveExecuteLog(ruleCode, tenantId, context, errorResult, costTime, e);
            throw e;
        }
    }
    
    /**
     * 批量执行规则
     */
    public List<RuleExecuteResult> executeRules(List<String> ruleCodes, Map<String, Object> context) {
        String tenantId = TenantContextHolder.getTenantId();
        if (tenantId == null) {
            throw new TenantContextException("租户上下文未设置");
        }
        
        List<RuleExecuteResult> results = new ArrayList<>();
        
        for (String ruleCode : ruleCodes) {
            results.add(executeRule(ruleCode, context));
        }
        
        return results;
    }
    
    private RuleExecuteResult doExecuteRule(String ruleCode, Map<String, Object> context, String tenantId) {
        BusinessRule rule = ruleCache.getRule(tenantId, ruleCode);
        if (rule == null) {
            throw new RuleNotFoundException("规则不存在: " + ruleCode);
        }
        
        ruleValidator.validateRule(tenantId, rule);
        
        Map<String, Object> sandboxContext = buildSandboxContext(context, tenantId);
        
        return ruleEngine.execute(rule, sandboxContext);
    }
    
    private Map<String, Object> buildSandboxContext(Map<String, Object> context, String tenantId) {
        Map<String, Object> sandboxContext = new HashMap<>();
        if (context != null) {
            sandboxContext.putAll(context);
        }
        sandboxContext.put("_tenantId", tenantId);
        sandboxContext.put("_executeTime", System.currentTimeMillis());
        return sandboxContext;
    }
    
    private void saveExecuteLog(String ruleCode, String tenantId, Map<String, Object> context, 
                                RuleExecuteResult result, long costTime, Exception e) {
        try {
            RuleExecuteLog log = new RuleExecuteLog();
            log.setId(UUID.randomUUID().toString());
            log.setTenantId(tenantId);
            log.setRuleCode(ruleCode);
            log.setContextJson(JSON.toJSONString(context));
            log.setResultJson(JSON.toJSONString(result));
            log.setCostTime(costTime);
            log.setStatus(e == null ? "SUCCESS" : "FAIL");
            log.setErrorMsg(e != null ? e.getMessage() : null);
            log.setCreateTime(new Date());
            
            ruleExecuteLogMapper.insert(log);
        } catch (Exception logException) {
            TenantRuleExecutor.log.error("保存规则执行日志失败", logException);
        }
    }
}

4. 租户规则缓存

按租户维度隔离的规则缓存:

@Component
@Slf4j
public class TenantRuleCache {
    
    private final Map<String, LoadingCache<String, BusinessRule>> tenantCaches = new ConcurrentHashMap<>();
    
    @Autowired
    private BusinessRuleMapper ruleMapper;
    
    @Value("${rule.cache.max-size:1000}")
    private int maxCacheSize;
    
    @Value("${rule.cache.expire-minutes:30}")
    private int expireMinutes;
    
    public BusinessRule getRule(String tenantId, String ruleCode) {
        LoadingCache<String, BusinessRule> tenantCache = getTenantCache(tenantId);
        
        try {
            return tenantCache.get(ruleCode, () -> {
                return loadRuleFromDb(tenantId, ruleCode);
            });
        } catch (ExecutionException e) {
            TenantRuleCache.log.error("加载规则失败: tenantId={}, ruleCode={}", tenantId, ruleCode, e);
            return null;
        }
    }
    
    public void invalidateRule(String tenantId, String ruleCode) {
        LoadingCache<String, BusinessRule> tenantCache = getTenantCache(tenantId);
        tenantCache.invalidate(ruleCode);
    }
    
    public void invalidateAll(String tenantId) {
        LoadingCache<String, BusinessRule> tenantCache = tenantCaches.get(tenantId);
        if (tenantCache != null) {
            tenantCache.invalidateAll();
        }
    }
    
    private LoadingCache<String, BusinessRule> getTenantCache(String tenantId) {
        return tenantCaches.computeIfAbsent(tenantId, k -> {
            return CacheBuilder.newBuilder()
                .maximumSize(maxCacheSize)
                .expireAfterWrite(Duration.ofMinutes(expireMinutes))
                .removalListener(notification -> {
                    TenantRuleCache.log.debug("规则缓存失效: tenantId={}, ruleCode={}", 
                        tenantId, notification.getKey());
                })
                .build();
        });
    }
    
    private BusinessRule loadRuleFromDb(String tenantId, String ruleCode) {
        BusinessRule rule = ruleMapper.selectByTenantAndCode(tenantId, ruleCode);
        if (rule == null) {
            throw new RuleNotFoundException("规则不存在: " + ruleCode);
        }
        return rule;
    }
}

5. 租户规则校验器

校验规则的归属和权限:

@Component
@Slf4j
public class TenantRuleValidator {
    
    @Autowired
    private TenantService tenantService;
    
    @Autowired
    private RolePermissionService rolePermissionService;
    
    @Autowired
    private RuleAuditLogMapper ruleAuditLogMapper;
    
    public void validateRule(String tenantId, BusinessRule rule) {
        if (!rule.getTenantId().equals(tenantId)) {
            String message = String.format("越权访问: 租户%s试图访问租户%s的规则%s", 
                tenantId, rule.getTenantId(), rule.getRuleCode());
            TenantRuleValidator.log.warn(message);
            saveAuditLog(tenantId, rule.getRuleCode(), "ILLEGAL_ACCESS", message);
            throw new RuleAccessDeniedException("无权访问该规则");
        }
        
        if (!rule.getStatus().equals("ENABLED")) {
            throw new RuleDisabledException("规则已停用: " + rule.getRuleCode());
        }
        
        validateRulePermission(tenantId, rule);
    }
    
    private void validateRulePermission(String tenantId, BusinessRule rule) {
        String userId = getCurrentUserId();
        if (userId == null) {
            return;
        }
        
        boolean hasPermission = rolePermissionService.checkPermission(
            userId, 
            "RULE", 
            rule.getRuleCode()
        );
        
        if (!hasPermission) {
            String message = String.format("用户%s试图执行无权限规则%s", userId, rule.getRuleCode());
            TenantRuleValidator.log.warn(message);
            saveAuditLog(tenantId, rule.getRuleCode(), "NO_PERMISSION", message);
            throw new RuleAccessDeniedException("当前用户无权执行该规则");
        }
    }
    
    private String getCurrentUserId() {
        return UserContextHolder.getUserId();
    }
    
    private void saveAuditLog(String tenantId, String ruleCode, String action, String message) {
        try {
            RuleAuditLog auditLog = new RuleAuditLog();
            auditLog.setId(UUID.randomUUID().toString());
            auditLog.setTenantId(tenantId);
            auditLog.setRuleCode(ruleCode);
            auditLog.setAction(action);
            auditLog.setMessage(message);
            auditLog.setCreateTime(new Date());
            
            ruleAuditLogMapper.insert(auditLog);
        } catch (Exception e) {
            TenantRuleValidator.log.error("保存审计日志失败", e);
        }
    }
}

6. 租户数据过滤器

自动为查询注入租户条件:

@Component
@Slf4j
public class TenantDataFilter {
    
    @Autowired
    private TenantContextHolder tenantContextHolder;
    
    /**
     * 包装查询,自动注入租户条件
     */
    public <T> Specification<T> apply(Specification<T> spec, Class<T> entityClass) {
        String tenantId = TenantContextHolder.getTenantId();
        if (tenantId == null) {
            TenantDataFilter.log.warn("未设置租户上下文,查询将返回空结果");
            return Specification.where((root, query, cb) -> cb.equal(root.get("id"), -1));
        }
        
        if (!entityClass.isAnnotationPresent(TenantEntity.class)) {
            return spec;
        }
        
        Specification<T> tenantSpec = (root, query, cb) -> 
            cb.equal(root.get("tenantId"), tenantId);
        
        if (spec == null) {
            return tenantSpec;
        }
        
        return Specification.where(tenantSpec).and(spec);
    }
    
    /**
     * 保存前自动填充租户ID
     */
    public <T> void prePersist(T entity) {
        String tenantId = TenantContextHolder.getTenantId();
        if (tenantId == null) {
            throw new TenantContextException("保存数据时缺少租户上下文");
        }
        
        try {
            Method setTenantIdMethod = entity.getClass().getMethod("setTenantId", String.class);
            setTenantIdMethod.invoke(entity, tenantId);
        } catch (NoSuchMethodException e) {
            TenantDataFilter.log.debug("实体类没有setTenantId方法,跳过租户ID填充");
        } catch (Exception e) {
            throw new RuntimeException("填充租户ID失败", e);
        }
    }
    
    /**
     * 租户实体注解
     */
    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface TenantEntity {
    }
}

7. 数据库层面隔离

在数据库层通过视图和行级安全实现额外保障:

-- 创建租户规则视图,确保只能访问本租户的数据
CREATE OR REPLACE VIEW v_tenant_rules AS
SELECT * FROM business_rule 
WHERE tenant_id = CURRENT_SETTING('app.current_tenant', true);

-- 创建规则变更历史表
CREATE TABLE rule_change_history (
    id VARCHAR(36) PRIMARY KEY,
    tenant_id VARCHAR(36) NOT NULL,
    rule_code VARCHAR(100) NOT NULL,
    change_type VARCHAR(20) NOT NULL,
    old_value TEXT,
    new_value TEXT,
    change_user VARCHAR(36),
    change_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    change_reason VARCHAR(500),
    INDEX idx_tenant_rule (tenant_id, rule_code),
    INDEX idx_change_time (change_time)
);

-- 创建规则执行日志表
CREATE TABLE rule_execute_log (
    id VARCHAR(36) PRIMARY KEY,
    tenant_id VARCHAR(36) NOT NULL,
    rule_code VARCHAR(100) NOT NULL,
    context_json TEXT,
    result_json TEXT,
    cost_time BIGINT,
    status VARCHAR(20),
    error_msg TEXT,
    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_tenant_create (tenant_id, create_time),
    INDEX idx_rule_status (rule_code, status)
);

8. 配置和初始化

配置类和启动初始化:

@Configuration
public class TenantAutoConfiguration {
    
    @Bean
    public FilterRegistrationBean<TenantFilter> tenantFilter() {
        FilterRegistrationBean<TenantFilter> registration = new FilterRegistrationBean<>();
        registration.setFilter(new TenantFilter());
        registration.addUrlPatterns("/*");
        registration.setOrder(1);
        return registration;
    }
    
    @Bean
    public TenantContextInterceptor tenantContextInterceptor() {
        return new TenantContextInterceptor();
    }
    
    @Bean
    public WebMvcConfigurer tenantWebMvcConfigurer(TenantContextInterceptor interceptor) {
        return new WebMvcConfigurer() {
            @Override
            public void addInterceptors(InterceptorRegistry registry) {
                registry.addInterceptor(interceptor)
                    .addPathPatterns("/api/**")
                    .excludePathPatterns("/api/public/**", "/api/health");
            }
        };
    }
}
# 租户隔离配置
tenant:
  enabled: true
  header-name: X-Tenant-ID
  super-admin-tenants:
    - platform-admin

# 规则引擎配置
rule:
  cache:
    max-size: 1000
    expire-minutes: 30
  execute:
    timeout-seconds: 10
    max-depth: 5
  sandbox:
    enable: true
    allow-system-call: false

实际应用效果

通过这套方案,我们可以实现:

1. 数据层面隔离

租户A 查询规则 -> 只返回 tenant_id = 'A' 的规则
租户B 查询规则 -> 只返回 tenant_id = 'B' 的规则
跨租户访问 -> 拒绝并记录审计日志

2. 执行层面隔离

租户A 执行规则 -> 在独立的沙箱环境中执行
              -> 使用租户A的规则缓存
              -> 记录到租户A的执行日志

3. 缓存层面隔离

租户A 的规则缓存 key -> "rule:A:ruleCode001"
租户B 的规则缓存 key -> "rule:B:ruleCode001"
互不影响,独立失效

总结

通过多租户规则沙箱隔离架构,我们可以彻底杜绝数据串号和规则越权问题:

  1. ThreadLocal 上下文隔离:确保每个请求都在正确的租户上下文中执行
  2. 租户维度缓存:每个租户独立的规则缓存,避免缓存串扰
  3. 多层校验:执行前、执行中多层校验,确保规则归属正确
  4. 审计日志:完整的操作审计,便于问题追溯
  5. 数据库视图:在数据库层面再添一道防线

希望这篇文章能对你有所帮助,如果你觉得有用,欢迎关注"服务端技术精选",我会持续分享更多实用的技术干货。


标题:SpringBoot + 多租户规则沙箱隔离:数据串号、规则越权?彻底杜绝的架构设计!
作者:jiangyi
地址:http://jiangyi.space/articles/2026/05/08/1777866030368.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消