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. 规则之间的依赖
复杂的业务规则之间可能有依赖关系:
- 子规则被多个父规则引用
- 规则模版在租户间共享
- 规则执行结果被缓存复用
整体架构设计
我们的多租户规则沙箱隔离方案由以下几个核心组件构成:
- TenantContextHolder:租户上下文持有器,基于 ThreadLocal 实现
- TenantRuleExecutor:租户规则执行器,每次执行规则都是独立的沙箱环境
- TenantRuleCache:租户规则缓存,按租户维度隔离缓存
- TenantRuleValidator:租户规则校验器,校验规则归属和权限
- 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"
互不影响,独立失效
总结
通过多租户规则沙箱隔离架构,我们可以彻底杜绝数据串号和规则越权问题:
- ThreadLocal 上下文隔离:确保每个请求都在正确的租户上下文中执行
- 租户维度缓存:每个租户独立的规则缓存,避免缓存串扰
- 多层校验:执行前、执行中多层校验,确保规则归属正确
- 审计日志:完整的操作审计,便于问题追溯
- 数据库视图:在数据库层面再添一道防线
希望这篇文章能对你有所帮助,如果你觉得有用,欢迎关注"服务端技术精选",我会持续分享更多实用的技术干货。
标题:SpringBoot + 多租户规则沙箱隔离:数据串号、规则越权?彻底杜绝的架构设计!
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/05/08/1777866030368.html
公众号:服务端技术精选
评论