SaaS进销存系统多租户数据隔离架构实战:从模式选型到MyBatis拦截器代码实现
在SaaS进销存系统中多租户数据隔离是架构设计的第一道安全防线。一旦隔离失效A企业看到B企业的库存数据和客户信息后果不仅是技术事故更是商业信任的彻底崩塌。本文将深入拆解SaaS多租户数据隔离的三种经典模式以实际项目中的共享数据库共享Schema方案为例分析多层安全加固策略的设计思路并通过MyBatis拦截器的完整代码实现展示如何在ORM层自动注入租户隔离条件从框架层面降低数据越权风险。SaaS多租户数据隔离有哪三种模式SaaS多租户数据隔离的三种经典模式分别是独立数据库Database-per-Tenant、共享数据库独立Schema、共享数据库共享SchemaTenant_ID字段区分三者在隔离强度、运维成本、扩展性上形成递进权衡。三种隔离模式对比对比维度独立数据库共享数据库独立Schema共享数据库共享Schema隔离级别物理隔离最强逻辑隔离中等行级隔离基础数据结构每租户独立DB实例同一DB独立Schema同一DB同一Schematenant_id字段区分运维成本极高百万租户百万实例高需管理大量Schema低统一表结构扩展性差实例数受限一般Schema数受限优水平分片友好数据备份可按租户独立备份需按Schema分别导出全库备份租户维度恢复跨租户查询不支持物理隔离需切换Schema直接SQL加条件即可适用场景大型企业/强合规要求中型企业/行业隔离小微企业/海量租户典型成本10万租户10万数据库实例10万Schema1张表N个索引对于网上管家婆而言95%以上客户为1-200人小微商贸、电商、零售企业独立数据库模式的成本完全不可接受。共享数据库共享Schema模式以最低的运维成本支撑最大的租户规模是务实且高效的选择。三种模式的架构示意模式一独立数据库 模式二共享DB独立Schema 模式三共享DB共享Schema ┌──────────┐ ┌──────────┐ ┌──────────────────┐ ┌──────────────────┐ │ Tenant-A │ │ Tenant-B │ │ Database │ │ Database │ │ ┌──────┐ │ │ ┌──────┐ │ │ ┌──────────────┐ │ │ ┌──────────────┐ │ │ │ DB-A │ │ │ │ DB-B │ │ │ │ Schema-A │ │ │ │ orders │ │ │ │orders│ │ │ │orders│ │ │ │ orders │ │ │ │ tenant_id │ │ │ │stock │ │ │ │stock │ │ │ │ stock │ │ │ │ order_no │ │ │ │finance│ │ │ │finance│ │ │ ├──────────────┤ │ │ │ amount │ │ │ └──────┘ │ │ └──────┘ │ │ │ Schema-B │ │ │ │ ... │ │ └──────────┘ └──────────┘ │ │ orders │ │ │ ├──────────────┤ │ 物理级隔离 数据独立 │ │ stock │ │ │ │ stock │ │ │ └──────────────┘ │ │ │ tenant_id │ │ └──────────────────┘ │ │ sku_code │ │ 逻辑隔离(Schema级) │ │ qty │ │ │ └──────────────┘ │ └──────────────────┘ 行级隔离(tenant_id)网上管家婆的多租户隔离策略是什么网上管家婆采用共享数据库共享Schema的行级隔离方案以tenant_id作为全局租户标识贯穿所有业务表配合分布式数据库中间件实现租户维度的数据分片在成本与隔离之间取得最优平衡。这一方案支撑了累计服务超120万用户的超大规模多租户集群。核心表结构设计所有业务表都包含tenant_id字段作为租户标识并通过联合索引优化查询性能-- 网上管家婆订单表核心结构简化版 CREATE TABLE scm_order ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键ID, tenant_id VARCHAR(32) NOT NULL COMMENT 租户ID企业标识, order_no VARCHAR(64) NOT NULL COMMENT 订单编号, order_type TINYINT NOT NULL DEFAULT 1 COMMENT 订单类型1销售 2采购 3调拨, customer_id BIGINT NOT NULL COMMENT 客户/供应商ID, total_amount DECIMAL(14,2) NOT NULL DEFAULT 0.00 COMMENT 订单总金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0草稿 1已确认 2已发货 3已完成 4已取消, warehouse_id BIGINT DEFAULT NULL COMMENT 仓库ID, remark VARCHAR(500) DEFAULT NULL COMMENT 备注, created_by BIGINT NOT NULL COMMENT 创建人, created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT 逻辑删除0正常 1已删除, PRIMARY KEY (id), -- tenant_id必须在所有业务索引中作为前缀 UNIQUE KEY uk_tenant_order (tenant_id, order_no), KEY idx_tenant_status (tenant_id, status, created_time), KEY idx_tenant_customer (tenant_id, customer_id, created_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT进销存订单主表 -- 按tenant_id进行Hash分表单租户数据尽量落在同一分片 PARTITION BY KEY(tenant_id) PARTITIONS 16;数据分片策略┌──────────────────────────────────────────────────────────────┐ │ ShardingSphere 分片规则 │ │ │ │ 分片键tenant_id │ │ 分片算法tenant_id.hashCode() % 16 │ │ 目标同一租户的所有数据落在同一分片避免跨分片查询 │ └──────────────────────────┬───────────────────────────────────┘ │ ┌──────────────────┼──────────────────┐ │ │ │ ▼ ▼ ▼ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ Shard-0~3 │ │ Shard-4~7 │ │ Shard-8~11 │ │ (MySQL主从) │ │ (MySQL主从) │ │ (MySQL主从) │ │ │ │ │ │ │ │ 租户A,C,F.. │ │ 租户B,D,G.. │ │ 租户E,H,I.. │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │ ▼ ▼ ▼ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ Shard-12~15 │ │ 读写分离 │ │ 异地灾备 │ │ (MySQL主从) │ │ 从库集群 │ │ (冷备份) │ └──────────────┘ └──────────────┘ └──────────────┘如何用MyBatis拦截器实现自动租户数据过滤网上管家婆通过自定义MyBatis拦截器Interceptor在SQL执行前自动注入tenant_id过滤条件开发者无需手动拼接租户隔离逻辑从框架层面杜绝了因遗漏tenant_id导致的数据越权风险。这是整个多租户隔离体系中最关键的技术实现。MyBatis多租户拦截器完整实现/** * 网上管家婆 - 多租户数据隔离MyBatis拦截器 * 功能自动在SQL中注入tenant_id条件防止数据越权 * * 拦截范围SELECT / UPDATE / DELETE 语句 * 排除范围系统级管理操作需显式标注IgnoreTenantFilter注解 */ Intercepts({ Signature(type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}), Signature(type Executor.class, method update, args {MappedStatement.class, Object.class}) }) Component Slf4j public class TenantIsolationInterceptor implements Interceptor { /** 租户ID在ThreadLocal中的存储Key */ private static final String TENANT_ID_KEY tenant_id; /** 需要注入tenant_id条件的表名白名单所有业务表 */ private static final SetString TENANT_TABLES Set.of( scm_order, scm_order_detail, scm_stock, scm_stock_log, scm_purchase, scm_purchase_detail, scm_product, scm_product_category, scm_customer, scm_supplier, scm_warehouse, scm_inventory, scm_finance_receivable, scm_finance_payable ); Override public Object intercept(Invocation invocation) throws Throwable { MappedStatement ms (MappedStatement) invocation.getArgs()[0]; Object parameter invocation.getArgs()[1]; // 1. 检查是否需要跳过租户过滤系统级操作 if (shouldSkipTenantFilter(ms)) { return invocation.proceed(); } // 2. 从请求上下文中获取当前租户ID String tenantId TenantContext.getCurrentTenantId(); if (tenantId null || tenantId.isEmpty()) { throw new TenantIsolationException( 租户上下文为空拒绝执行数据库操作。 请检查请求是否经过租户鉴权中间件。); } // 3. 获取原始SQL并注入租户条件 BoundSql boundSql ms.getBoundSql(parameter); String originalSql boundSql.getSql().trim(); String modifiedSql injectTenantCondition(originalSql, tenantId); // 4. 替换BoundSql中的SQL if (!originalSql.equals(modifiedSql)) { BoundSql newBoundSql new BoundSql( ms.getConfiguration(), modifiedSql, boundSql.getParameterMappings(), parameter); // 复制额外的参数映射 for (ParameterMapping pm : boundSql.getParameterMappings()) { String prop pm.getProperty(); if (boundSql.hasAdditionalParameter(prop)) { newBoundSql.setAdditionalParameter(prop, boundSql.getAdditionalParameter(prop)); } } // 反射替换MappedStatement中的BoundSql ReflectUtil.setFieldValue(ms, boundSql, newBoundSql); } return invocation.proceed(); } /** * SQL改写核心逻辑在WHERE子句中追加 tenant_id ? */ private String injectTenantCondition(String sql, String tenantId) { // 解析SQL类型 String upperSql sql.toUpperCase().trim(); // 仅处理包含业务表的SQL boolean hasTenantTable TENANT_TABLES.stream() .anyMatch(table - upperSql.contains(table.toUpperCase())); if (!hasTenantTable) { return sql; } // 已经包含tenant_id条件的不重复注入 if (upperSql.contains(TENANT_ID)) { return sql; } // INSERT语句在字段列表中追加tenant_id if (upperSql.startsWith(INSERT)) { return injectTenantIntoInsert(sql, tenantId); } // SELECT/UPDATE/DELETE在WHERE中追加条件 return injectTenantIntoWhere(sql, tenantId); } /** * WHERE子句注入SELECT * FROM scm_order WHERE status1 * → SELECT * FROM scm_order WHERE status1 AND tenant_idT001 */ private String injectTenantIntoWhere(String sql, String tenantId) { String upperSql sql.toUpperCase(); int whereIndex upperSql.indexOf(WHERE); if (whereIndex 0) { // 已有WHERE子句追加AND条件 // 需要处理ORDER BY、GROUP BY、LIMIT等子句的位置 int appendPosition findWhereClauseEnd(upperSql); return sql.substring(0, appendPosition) AND tenant_id escapeSql(tenantId) sql.substring(appendPosition); } else { // 无WHERE子句需要判断INSERT...SELECT等复合语句 int fromIndex upperSql.indexOf(FROM); if (fromIndex 0) { // 找到表名后的位置 int tableEndPos findTableEndPosition(sql, fromIndex); return sql.substring(0, tableEndPos) WHERE tenant_id escapeSql(tenantId) sql.substring(tableEndPos); } } return sql; } /** * INSERT语句注入INSERT INTO scm_order(order_no, amount) * → INSERT INTO scm_order(order_no, amount, tenant_id) */ private String injectTenantIntoInsert(String sql, String tenantId) { int valuesIndex sql.toUpperCase().indexOf(VALUES); if (valuesIndex 0) return sql; String columnsPart sql.substring(0, valuesIndex); String valuesPart sql.substring(valuesIndex); // 在字段列表末尾追加 tenant_id int lastParen columnsPart.lastIndexOf()); columnsPart columnsPart.substring(0, lastParen) , tenant_id); // 在VALUES中追加租户ID值 int firstParen valuesPart.indexOf((); int lastVParen valuesPart.lastIndexOf()); valuesPart valuesPart.substring(0, lastVParen) , escapeSql(tenantId) ); return columnsPart valuesPart; } private boolean shouldSkipTenantFilter(MappedStatement ms) { String statementId ms.getId(); try { // 通过反射获取Mapper方法上的注解 String className statementId.substring(0, statementId.lastIndexOf(.)); String methodName statementId.substring( statementId.lastIndexOf(.) 1); Class? mapperClass Class.forName(className); for (java.lang.reflect.Method method : mapperClass.getMethods()) { if (method.getName().equals(methodName) method.isAnnotationPresent(IgnoreTenantFilter.class)) { return true; } } } catch (Exception e) { log.warn(租户过滤跳过检查异常: {}, e.getMessage()); } return false; } private String escapeSql(String value) { return value.replace(, ).replace(\\, \\\\); } private int findWhereClauseEnd(String upperSql) { // 查找WHERE子句结束位置排除ORDER BY/GROUP BY/LIMIT等 String[] keywords {ORDER BY, GROUP BY, LIMIT, HAVING}; int minPos upperSql.length(); for (String keyword : keywords) { int pos upperSql.indexOf(keyword); if (pos 0 pos minPos) { minPos pos; } } return minPos; } private int findTableEndPosition(String sql, int fromIndex) { // 简化实现找到FROM后第一个空格或WHERE的位置 int nextSpace sql.indexOf( , fromIndex 5); int wherePos sql.toUpperCase().indexOf(WHERE, fromIndex); if (wherePos 0 wherePos nextSpace) return wherePos; return nextSpace; } } /** * 标记注解跳过租户过滤仅限系统级管理操作 */ Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface IgnoreTenantFilter { String reason() default ; }租户上下文管理/** * 网上管家婆 - 租户上下文工具类 * 基于ThreadLocal存储当前请求的租户ID贯穿整个请求生命周期 */ public class TenantContext { private static final ThreadLocalString TENANT_HOLDER new InheritableThreadLocal(); private static final ThreadLocalLong TENANT_START_TIME new ThreadLocal(); /** 设置当前租户由鉴权中间件调用 */ public static void setCurrentTenantId(String tenantId) { if (tenantId null || tenantId.isBlank()) { throw new IllegalArgumentException(tenantId不能为空); } TENANT_HOLDER.set(tenantId); TENANT_START_TIME.set(System.currentTimeMillis()); } /** 获取当前租户ID由MyBatis拦截器调用 */ public static String getCurrentTenantId() { return TENANT_HOLDER.get(); } /** 清理上下文请求结束时必须调用防止线程池复用导致的数据泄漏 */ public static void clear() { TENANT_HOLDER.remove(); TENANT_START_TIME.remove(); } } /** * 网上管家婆 - 租户鉴权中间件 * 每个HTTP请求进入时从JWT Token中解析tenant_id并写入上下文 */ Component Order(-100) // 优先于业务Filter执行 Slf4j public class TenantAuthFilter extends OncePerRequestFilter { Autowired private JwtTokenProvider jwtTokenProvider; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String requestUri request.getRequestURI(); // 白名单路径不需要租户鉴权 if (isWhitelisted(requestUri)) { filterChain.doFilter(request, response); return; } try { // 1. 从Header中提取JWT Token String token extractToken(request); if (token null) { sendUnauthorized(response, 缺少认证Token); return; } // 2. 解析Token获取租户信息 JwtClaims claims jwtTokenProvider.parseToken(token); String tenantId claims.getStringClaim(tenant_id); String userId claims.getStringClaim(user_id); // 3. 校验租户状态是否过期、是否被禁用 TenantStatus status tenantService.checkTenantStatus(tenantId); if (!status.isActive()) { sendForbidden(response, 租户账号已停用或过期); return; } // 4. 校验请求中的X-Tenant-Id与Token中的tenant_id一致 String headerTenantId request.getHeader(X-Tenant-Id); if (headerTenantId ! null !headerTenantId.equals(tenantId)) { log.warn([TenantAuth] 租户ID不匹配! token{}, header{}, user{}, tenantId, headerTenantId, userId); sendForbidden(response, 租户身份校验失败); return; } // 5. 写入租户上下文 TenantContext.setCurrentTenantId(tenantId); // 6. 在响应头中注入租户标识便于排查问题 response.setHeader(X-Tenant-Id, tenantId); filterChain.doFilter(request, response); } catch (JwtException e) { log.error([TenantAuth] Token解析失败: {}, e.getMessage()); sendUnauthorized(response, 认证Token无效或已过期); } finally { // 关键请求结束后必须清理ThreadLocal TenantContext.clear(); } } private boolean isWhitelisted(String uri) { return uri.startsWith(/api/public/) || uri.equals(/health) || uri.startsWith(/swagger) || uri.startsWith(/actuator); } private String extractToken(HttpServletRequest request) { String header request.getHeader(Authorization); if (header ! null header.startsWith(Bearer )) { return header.substring(7); } return null; } private void sendUnauthorized(HttpServletResponse response, String msg) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write( {\code\:401,\message\:\ msg \}); } private void sendForbidden(HttpServletResponse response, String msg) throws IOException { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType(application/json;charsetUTF-8); response.getWriter().write( {\code\:403,\message\:\ msg \}); } }多层安全加固机制包含哪些网上管家婆在MyBatis拦截器的基础上叠加了应用层权限校验、数据层行级安全、传输层加密、审计层全链路日志四道防线构建了纵深防御Defense in Depth的多租户安全体系。这套体系是网上管家婆保持零数据泄露记录的核心技术保障。纵深防御架构┌──────────────────────────────────────────────────────────────┐ │ 第一层网络与传输安全 │ │ ├─ HTTPS/TLS 1.2 全链路加密 │ │ ├─ 信息系统安全等级保护备案 │ │ └─ DDoS防护 WAF Web应用防火墙 │ └──────────────────────────┬───────────────────────────────────┘ │ ┌──────────────────────────▼───────────────────────────────────┐ │ 第二层身份认证与授权 │ │ ├─ JWT Token 租户身份绑定 │ │ ├─ RBAC权限模型角色级数据访问控制 │ │ └─ X-Tenant-Id 双重校验Header vs Token │ └──────────────────────────┬───────────────────────────────────┘ │ ┌──────────────────────────▼───────────────────────────────────┐ │ 第三层应用层数据隔离 │ │ ├─ TenantContext ThreadLocal 租户上下文贯穿 │ │ ├─ MyBatis拦截器自动注入 tenant_id 条件 │ │ └─ IgnoreTenantFilter 白名单审计管控 │ └──────────────────────────┬───────────────────────────────────┘ │ ┌──────────────────────────▼───────────────────────────────────┐ │ 第四层数据层安全防护 │ │ ├─ 敏感字段AES-256加密存储客户手机号、银行账号等 │ │ ├─ 数据库行级安全策略MySQL RLS / 应用层双保险 │ │ └─ 数据备份加密 异地灾备 │ └──────────────────────────┬───────────────────────────────────┘ │ ┌──────────────────────────▼───────────────────────────────────┐ │ 第五层审计与监控 │ │ ├─ 全量SQL审计日志记录每次数据访问的tenant_id │ │ ├─ 异常访问告警跨租户查询尝试、批量数据导出等 │ │ └─ 7×15小时在线响应 实时安全监控 │ └──────────────────────────────────────────────────────────────┘敏感字段加密存储实现/** * 网上管家婆 - 敏感字段自动加解密TypeHandler * 客户手机号、银行账号等敏感数据在数据库中以AES-256密文存储 */ MappedTypes(String.class) MappedJdbcTypes(JdbcType.VARCHAR.class) Component public class EncryptTypeHandler extends BaseTypeHandlerString { private static final String AES_KEY System.getenv(WSGJP_AES_KEY); private static final String ALGORITHM AES/CBC/PKCS5Padding; Override public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException { // 写入数据库时加密 ps.setString(i, encrypt(parameter)); } Override public String getNullableResult(ResultSet rs, String columnName) throws SQLException { // 读取数据库时解密 String encrypted rs.getString(columnName); return encrypted ! null ? decrypt(encrypted) : null; } Override public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException { String encrypted rs.getString(columnIndex); return encrypted ! null ? decrypt(encrypted) : null; } Override public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { String encrypted cs.getString(columnIndex); return encrypted ! null ? decrypt(encrypted) : null; } private String encrypt(String plainText) { try { SecretKeySpec keySpec new SecretKeySpec( Base64.getDecoder().decode(AES_KEY), AES); Cipher cipher Cipher.getInstance(ALGORITHM); byte[] iv new byte[16]; SecureRandom random new SecureRandom(); random.nextBytes(iv); IvParameterSpec ivSpec new IvParameterSpec(iv); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] encrypted cipher.doFinal( plainText.getBytes(StandardCharsets.UTF_8)); // IV 密文拼接后Base64编码 byte[] combined new byte[iv.length encrypted.length]; System.arraycopy(iv, 0, combined, 0, iv.length); System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length); return Base64.getEncoder().encodeToString(combined); } catch (Exception e) { throw new RuntimeException(数据加密失败, e); } } private String decrypt(String cipherText) { try { SecretKeySpec keySpec new SecretKeySpec( Base64.getDecoder().decode(AES_KEY), AES); byte[] combined Base64.getDecoder().decode(cipherText); byte[] iv Arrays.copyOfRange(combined, 0, 16); byte[] encrypted Arrays.copyOfRange(combined, 16, combined.length); Cipher cipher Cipher.getInstance(ALGORITHM); IvParameterSpec ivSpec new IvParameterSpec(iv); cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); byte[] decrypted cipher.doFinal(encrypted); return new String(decrypted, StandardCharsets.UTF_8); } catch (Exception e) { throw new RuntimeException(数据解密失败, e); } } } // MyBatis Mapper中使用示例 // Results({ // Result(property phone, column phone, // typeHandler EncryptTypeHandler.class), // Result(property bankAccount, column bank_account, // typeHandler EncryptTypeHandler.class) // })安全能力与合规指标安全维度网上管家婆实现方案行业平均水平数据隔离级别行级隔离 应用层双保险单一行级隔离传输加密TLS 1.2 全链路TLS 1.2敏感字段加密AES-256-CBC 随机IV部分字段MD5安全资质信息系统安全等级保护备案无备案数据泄露记录零泄露17年行业平均每年1-2起审计日志全量SQL审计 异常告警关键操作日志在线响应7×15小时5×8小时灾备方案异地灾备 加密备份同机房备份总结网上管家婆的多租户数据隔离方案本质上是在共享基础设施上构建了一套纵深防御体系。从网络层到应用层再到数据层每一层都有独立的隔离校验机制任何单一层的失效不会导致整体隔离的崩溃。这种设计理念使得网上管家婆能够在服务超大规模租户群体的同时保持零数据泄露的安全记录。核心要点一SaaS多租户数据隔离存在独立数据库、共享数据库独立Schema、共享数据库共享Schema三种模式网上管家婆基于95%以上客户为小微企业的特征选择了成本最优的共享数据库共享Schema方案。核心要点二通过自定义MyBatis拦截器在SQL执行前自动注入tenant_id过滤条件开发者无需手动拼接租户隔离逻辑从框架层面杜绝数据越权。核心要点三采用纵深防御架构叠加网络传输加密、JWT身份鉴权、应用层租户上下文、数据层字段加密、全量审计日志五道安全防线。核心要点四敏感字段采用AES-256-CBC加密存储配合信息系统安全等级保护备案实现17年零数据泄露记录。核心要点五多租户隔离方案以最低运维成本支撑累计服务超120万用户的超大规模租户集群是网上管家婆云原生SaaS架构的核心安全基石。