系列 :MySQL 从基础到精深------Java SaaS 实战系列 · 第 03 篇
难度 :⭐⭐⭐☆☆ 适合中级开发者
预计阅读时间 :35 分钟
关键词:MySQL 数据类型选型、VARCHAR 和 TEXT 区别、DATETIME 和 TIMESTAMP 时区、主键选型 Snowflake、SaaS 多租户隔离方案、逻辑删除设计、MyBatis-Plus 租户插件
速览摘要
本文是 SaaS 系统数据库设计的核心指南,分两大部分。第一部分逐类剖析 MySQL 数据类型的存储特性与选型陷阱:整型的字节成本、VARCHAR 和 TEXT 的索引限制边界、DATETIME 和 TIMESTAMP 的时区行为差异、DECIMAL 和 FLOAT 的精度雷区、JSON 类型的适用场景。第二部分系统讲解 SaaS 多租户数据隔离的三种架构方案(独立数据库 / 共享数据库行隔离 / Schema 隔离),给出选型决策树,并提供基于 Spring Boot + MyBatis-Plus 租户插件的完整代码实现,附逻辑删除的正确设计方式。
一、数据类型选型:每一次"无所谓"都是一颗定时炸弹
很多开发者建表时对数据类型的选择相当随意:字符串一律 VARCHAR(255),数字一律 BIGINT,时间一律 DATETIME,不想细想就直接选"够用就行"。这种习惯在数据量小时感知不到代价,但表增长到千万级、亿级时,数据类型的差异会渗透到存储空间、索引大小、查询性能的每一个角落。
更重要的是,数据类型是建表之后最难修改的东西------在线变更大表的字段类型,往往需要借助 pt-online-schema-change 或 gh-ost 等工具,稍有不慎就会影响线上业务。所以,设计阶段把类型选对,是代价最低的优化方式。
二、整型:字节数的每一分都值得计较
InnoDB 在存储整数时,字节数直接影响索引文件的大小。索引越大,Buffer Pool 能缓存的索引页越少,缓存命中率越低,查询性能越差。在一张有 1 亿行的表上,主键从 INT(4 字节)改为 BIGINT(8 字节),主键索引文件会增大约 800MB------这不是小数字。
markdown
整型类型速查表(选型时对着这张表看):
类型 字节 有符号范围 无符号范围(UNSIGNED)
TINYINT 1 -128 ~ 127 0 ~ 255
SMALLINT 2 -32768 ~ 32767 0 ~ 65535
MEDIUMINT 3 -8388608 ~ 8388607 0 ~ 16777215
INT 4 -2147483648 ~ 2147483647 0 ~ 4294967295(约 42 亿)
BIGINT 8 极大 0 ~ 18446744073709551615
选型原则:
- 状态/类型/布尔等枚举值:TINYINT
- 小范围计数(如用户配额、等级):SMALLINT 或 MEDIUMINT
- 普通业务 ID(租户数 < 42 亿):INT UNSIGNED
- 主键 / 雪花 ID / 需要面向未来扩容:BIGINT UNSIGNED
sql
-- 一个合理使用整型的 SaaS 租户表示例
CREATE TABLE tenant (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键,面向未来',
plan_type TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=免费 1=基础 2=专业 3=企业',
user_quota SMALLINT UNSIGNED NOT NULL DEFAULT 10 COMMENT '最大用户数,上限 65535 足够',
storage_gb MEDIUMINT UNSIGNED NOT NULL DEFAULT 5 COMMENT '存储配额 GB',
is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Boolean 用 TINYINT(1) 表示',
PRIMARY KEY (id)
) ENGINE=InnoDB;
关于 TINYINT(1) 的补充说明:MySQL 中 BOOLEAN 是 TINYINT(1) 的别名,存储的是 0 和 1。括号里的数字叫"显示宽度",对存储没有任何影响------INT(11) 和 INT(1) 占用的字节数完全一样,11 只是告诉某些命令行客户端"显示时至少保留 11 位宽度"。MySQL 8.0.17 之后官方已废弃整型显示宽度语法(但没有删除,仍然兼容),新写的表不建议加显示宽度,唯一的例外是 TINYINT(1) 作为布尔值的惯用写法------很多 ORM 框架(包括 MyBatis-Plus)会根据 TINYINT(1) 自动映射到 Java Boolean 类型,这个约定俗成的用法保留下来是合理的。
三、VARCHAR vs TEXT:一个影响索引设计的本质区别
这是选型中最容易被轻视、实际上最有工程价值的一个决策。很多人以为 VARCHAR 和 TEXT 的区别就是"VARCHAR 短、TEXT 长"------这个理解是不完整的,甚至会误导索引设计。
3.1 两者最核心的区别:是否能直接建索引
markdown
VARCHAR(N):
- 最大长度 N 个字符(utf8mb4 下每字符最多 4 字节)
- 可以直接建普通索引、唯一索引(受 innodb_large_prefix 影响,索引前缀上限 768 字节)
- 存储在行内(小于等于某个阈值时),访问速度更快
TEXT / MEDIUMTEXT / LONGTEXT:
- 存储在行外(单独的溢出页),行内只存指针
- 只能建前缀索引:INDEX (content(100)),前缀长度有限,精确匹配无法命中
- 无法作为唯一索引(因为无法精确匹配)
这个区别对查询设计有直接影响。如果你的业务需要对某个字段做精确匹配(WHERE slug = ?、WHERE order_no = ?),就必须用 VARCHAR,因为 TEXT 无法建精确匹配的索引。TEXT 只适合"存下来但不直接按精确值查询"的内容。
sql
-- 常见字段的类型选择示例
CREATE TABLE article (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
-- 需要精确查询 / 唯一约束 / 索引:用 VARCHAR
slug VARCHAR(128) NOT NULL COMMENT '文章 URL slug,需要唯一索引',
title VARCHAR(256) NOT NULL COMMENT '标题,可能需要 LIKE 前缀搜索',
author_id BIGINT UNSIGNED NOT NULL COMMENT '作者 ID,外键关联',
category_code VARCHAR(32) NOT NULL COMMENT '分类编码,查询过滤条件',
-- 不需要精确查询,只是存储内容:用 TEXT
summary VARCHAR(512) NULL COMMENT '摘要,长度可控,偶有 LIKE 搜索',
content MEDIUMTEXT NULL COMMENT '正文,最大 16MB,不做精确匹配',
seo_meta TEXT NULL COMMENT 'SEO meta 信息,结构化存储考虑 JSON',
-- JSON 类型(MySQL 5.7.8+):半结构化配置,需要按 JSON 路径查询时使用
extra_config JSON NULL COMMENT '扩展配置,字段不固定时用 JSON',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_slug (slug), -- slug 是 VARCHAR,可以建唯一索引
INDEX idx_author_category (author_id, category_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
踩坑记录 :在一个电商项目中,商品 SKU(一般 20 字符以内的编码)被定义为 TEXT 类型。功能完全正常,但按 SKU 查询时全表扫描,耗时 800ms+。原因很简单:TEXT 无法建精确匹配索引。改为
VARCHAR(64)并添加唯一索引后,查询降到 1ms 以内。这种 Bug 藏得很深------因为功能上完全正确,只有通过慢查询日志才能发现。教训:每一个"会作为查询条件"的字段,类型选择时就应该问自己:它能建索引吗?
3.2 VARCHAR 的长度设置:别一律 VARCHAR(255)
"VARCHAR 最大长度是 65535 字节,我设 255 反正够用"------这个思路并没有错,但也不够精确。VARCHAR 的长度上限其实和行格式有关,并且在建索引时,字段的最大长度会影响索引前缀的字节占用:
sql
-- utf8mb4 字符集下,每个字符最多占 4 字节
-- 索引前缀上限(innodb_large_prefix=ON,MySQL 5.7.7+ 默认):3072 字节
-- 所以 VARCHAR(768) 是 utf8mb4 下单列索引的字节上限
-- 建议:按业务语义设置长度,不要一律 255
name VARCHAR(64) -- 人名,64 字符足够
email VARCHAR(255) -- RFC 标准最大 254 字符,给 1 个余量
phone VARCHAR(20) -- 国际格式电话号码,20 字符足够
id_card VARCHAR(18) -- 中国身份证,18 位固定长度
url VARCHAR(2048) -- URL 理论最大 2048,按实际业务缩减
order_no VARCHAR(32) -- 订单号,业务自定义格式,给点余量
四、DATETIME vs TIMESTAMP:时区陷阱可能悄悄腐蚀你的数据
这是 MySQL 数据类型选型中最容易被"我以前一直这么用没出过问题"的态度所掩盖的一个坑。两者的差异不是"精度"或"范围"的问题,而是时区语义的本质不同。
markdown
DATETIME:
- 存储"字面时间",就是你写进去的那个日期时间值
- 不受 MySQL 时区设置影响,读出来和写进去的值完全一致
- 范围:1000-01-01 00:00:00 ~ 9999-12-31 23:59:59
- 占用 5 字节(秒精度),加小数部分最多 8 字节
TIMESTAMP:
- 存储 UTC 时间戳(内部是整数,相对于 1970-01-01 00:00:00 UTC 的秒数)
- 写入时按会话时区转换为 UTC 存储,读取时按会话时区转换回来
- 范围:1970-01-01 00:00:01 UTC ~ 2038-01-19 03:14:07 UTC(2038 年问题!)
- 占用 4 字节(秒精度)
TIMESTAMP 的时区自动转换听起来很方便,但在以下场景会带来麻烦:
问题场景一:多时区部署。你的 SaaS 系统同时服务中国和欧洲用户,MySQL 服务器部署在北京(UTC+8)。用 TIMESTAMP 存储时,北京时间写入,欧洲用户读取时 MySQL 按什么时区转换?取决于会话时区配置------不同的连接可能有不同的会话时区,读出的结果可能不一致。
问题场景二:服务器迁移或时区变更。假设历史数据是在 UTC+8 环境下写入的,某天把 MySQL 迁移到 UTC 环境的服务器,所有 TIMESTAMP 字段读出的值都会"偏移 8 小时",但 DATETIME 字段完全不受影响。
推荐方案:全部使用 DATETIME,应用层统一处理时区。
sql
-- 推荐的时间字段定义方式
CREATE TABLE orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
-- DATETIME(3):精确到毫秒,不受时区影响
-- 约定:数据库始终存 UTC 时间,应用层根据租户时区做转换
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
COMMENT '创建时间(UTC)',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3)
COMMENT '更新时间(UTC),自动维护',
deleted_at DATETIME(3) NULL
COMMENT '逻辑删除时间(UTC),NULL 表示未删除',
PRIMARY KEY (id)
) ENGINE=InnoDB;
DATETIME(3) 中括号里的 3 是精度(小数秒位数),表示精确到毫秒。不加括号默认精度为 0(精确到秒)。对于需要精确排序的业务(如订单流水),毫秒精度很重要------同一秒内创建的两条记录,秒精度下顺序不确定,毫秒精度下顺序精确。额外存储成本:秒精度 5 字节,毫秒精度 7 字节,代价极小。
java
// Spring Boot 中处理 UTC 存储 + 本地化展示
@Configuration
public class TimeZoneConfig {
@PostConstruct
public void init() {
// 强制 JVM 使用 UTC,避免 JDBC 驱动做隐式时区转换
TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
}
}
// Service 层:按租户时区转换时间展示
@Service
public class OrderDisplayService {
public OrderVO toVO(Order order, String tenantTimeZone) {
ZoneId zoneId = ZoneId.of(tenantTimeZone); // 如 "Asia/Shanghai"
return OrderVO.builder()
.orderId(order.getId())
// 数据库存的是 UTC,展示时转换为租户时区的本地时间
.createdAtDisplay(order.getCreatedAt()
.atZone(ZoneOffset.UTC)
.withZoneSameInstant(zoneId)
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")))
.build();
}
}
踩坑记录:一个曾经遇到的真实问题:SaaS 系统使用 TIMESTAMP 存储订单时间,在春节后某天运维临时调整了 MySQL 服务器的系统时区(原因是某个旧系统的兼容需求),导致当天所有的 TIMESTAMP 字段读出值偏移了 8 小时。订单查询按时间范围过滤的结果全部"错位",用户看到今天的订单出现在昨天,客服接到大量投诉。用 DATETIME 存储则完全不受时区设置影响,不会有这个问题。
五、DECIMAL vs FLOAT:金融数据的精度问题不容马虎
这个选型几乎没有争议,但每年仍然有人在生产环境因为用了 FLOAT/DOUBLE 存储金额而导致数据问题。
sql
-- FLOAT 和 DOUBLE 是浮点数,遵循 IEEE 754 标准
-- 十进制小数在二进制浮点表示中存在精度损失
-- 反面示例(不要这么做):
CREATE TABLE bad_finance (
amount FLOAT -- 危险!0.1 + 0.2 ≠ 0.3,累积误差会导致账目对不上
);
-- 正确做法:金额类字段一律用 DECIMAL
-- DECIMAL(M, D):M 是总位数,D 是小数位数
-- DECIMAL(12, 2):最大 9999999999.99,适合绝大多数人民币金额
-- DECIMAL(18, 6):适合汇率、加密货币等高精度场景
CREATE TABLE orders (
total_amount DECIMAL(12, 2) NOT NULL DEFAULT 0.00 COMMENT '订单总金额',
discount_rate DECIMAL(5, 4) NOT NULL DEFAULT 0.0000 COMMENT '折扣率,如 0.8500',
tax_rate DECIMAL(6, 4) NOT NULL DEFAULT 0.0000 COMMENT '税率'
);
java
// Java 侧:金额类型始终使用 BigDecimal,禁止 double/float
// MyBatis / JPA 会自动在 DECIMAL 和 BigDecimal 之间做映射
@Column(name = "total_amount", precision = 12, scale = 2)
private BigDecimal totalAmount;
// 金额计算示例:BigDecimal 的运算要显式指定精度和舍入模式
public BigDecimal calculateDiscountedPrice(BigDecimal originalPrice, BigDecimal discountRate) {
return originalPrice
.multiply(discountRate)
.setScale(2, RoundingMode.HALF_UP); // 四舍五入到分
}
踩坑记录:某支付系统在上线初期使用 DOUBLE 存储金额,在低流量期间一切正常。上线半年后,累计到了几千万条交易记录,对账时发现账目差了几元钱。排查了整整两天才定位到根本原因:DOUBLE 的精度损失在单次计算中极小(十万分之一左右),但几千万次计算的累积误差最终显现。修复方案是全量历史数据重新计算,代价极大。DECIMAL 从一开始就能避免这个问题------在金融场景,这不是"可选优化",而是"必须遵守的规范"。
六、JSON 类型:灵活存储的边界在哪里
MySQL 5.7.8 引入了 JSON 类型,8.0 进一步增强了 JSON 函数。它解决了一类特定问题:字段结构不固定、但又需要按路径查询的半结构化数据。
sql
-- JSON 类型适用场景:字段结构不固定的扩展属性
CREATE TABLE product (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(256) NOT NULL,
category_code VARCHAR(32) NOT NULL,
-- 不同品类的商品有不同的规格属性:手机有内存/颜色,衣服有尺码/材质
-- 用 JSON 存储,避免大量 nullable 列或 EAV(实体-属性-值)模式
specs JSON NULL COMMENT '商品规格,按品类不同结构各异',
PRIMARY KEY (id)
) ENGINE=InnoDB;
-- 写入 JSON 数据
INSERT INTO product (name, category_code, specs) VALUES
('iPhone 15 Pro', 'phone',
JSON_OBJECT(
'storage', '256GB',
'color', '钛金属',
'memory', '8GB',
'screen_size', 6.1
));
-- 按 JSON 路径查询(MySQL 5.7+)
SELECT name, specs->>'$.storage' AS storage
FROM product
WHERE category_code = 'phone'
AND specs->>'$.memory' = '8GB';
-- MySQL 8.0:函数索引,对 JSON 路径建索引(解决 JSON 查询无法利用普通索引的问题)
ALTER TABLE product
ADD COLUMN specs_memory VARCHAR(16)
GENERATED ALWAYS AS (specs->>'$.memory') VIRTUAL,
ADD INDEX idx_specs_memory (specs_memory);
-- 或者直接用函数索引(MySQL 8.0.13+):
ALTER TABLE product
ADD INDEX idx_json_memory ((CAST(specs->>'$.memory' AS CHAR(16))));
JSON 类型有几个使用边界需要了解:JSON 字段不能建普通的 B+树索引(需要借助函数索引或生成列),因此按 JSON 路径过滤的查询在数据量大时需要额外处理;JSON 字段的修改是整字段替换(即使只改一个子字段,也要读取整个 JSON 修改后再写回),写放大问题在大 JSON 时明显;JSON 字段不支持 UNIQUE 约束。这三个限制决定了 JSON 适合"偶尔按路径查询的配置类数据",不适合"高频更新的核心业务字段"。
七、主键选型:自增 ID、UUID、Snowflake 的完整对比
主键的选择不仅影响存储效率,还影响分布式扩展能力、Binlog 同步效率和对外接口的安全性。这是一个值得认真思考的设计决策。
markdown
主键选型对比矩阵:
自增 INT/BIGINT UUID v4(字符串) Snowflake / ULID
存储空间 4~8 字节 36 字节字符串 8 字节(BIGINT)
写入性能 最优(顺序插入) 较差(随机插入,页分裂) 较优(趋势递增)
全局唯一性 单库唯一 全局唯一 全局唯一
分布式友好性 差(需要中央分配) 强(无需协调) 强(ID 中含机器号)
信息泄露风险 有(暴露数据量) 无 低(含时间戳但不含序号)
可读性 好(纯数字) 差(UUID 格式) 中(纯数字但较长)
时间可排序性 是(单库内) 否 是(全局有序)
推荐方案:Snowflake ID 存为 BIGINT UNSIGNED,在 Java 层生成。
Snowflake ID 结合了自增 BIGINT 的写入性能优势(趋势递增,页分裂少)和 UUID 的分布式唯一性(含机器号,不需要中央协调),同时对 InnoDB 聚簇索引非常友好,是现代 SaaS 系统的主流选择。
java
// 使用 Hutool 的 Snowflake 实现(或 Baidu uid-generator、美团 Leaf)
// pom.xml: <dependency>cn.hutool:hutool-core:5.8.25</dependency>
@Component
public class IdGenerator {
private final Snowflake snowflake;
public IdGenerator(
@Value("${app.snowflake.worker-id:1}") long workerId,
@Value("${app.snowflake.datacenter-id:1}") long datacenterId) {
// workerId 和 datacenterId 在多节点部署时需要保证唯一
// 可以从 Redis、Zookeeper 或环境变量动态获取
this.snowflake = IdUtil.getSnowflake(workerId, datacenterId);
}
public long nextId() {
return snowflake.nextId();
}
public String nextIdStr() {
// JavaScript 的 Number 类型最大安全整数是 2^53-1 ≈ 9007 亿
// Snowflake ID 最大是 2^63,超出了 JS 安全范围
// 如果 API 需要返回给前端 JS,应该转为字符串
return String.valueOf(snowflake.nextId());
}
}
java
// MyBatis-Plus 全局 Snowflake ID 配置
@Configuration
public class MybatisPlusConfig {
@Bean
public IdentifierGenerator idGenerator(IdGenerator idGenerator) {
// 覆盖 MyBatis-Plus 默认的雪花 ID 生成器,使用自定义实例
return new IdentifierGenerator() {
@Override
public Number nextId(Object entity) {
return idGenerator.nextId();
}
};
}
}
// Entity 使用
@TableId(type = IdType.ASSIGN_ID) // 使用自定义 ID 生成器
private Long id;
八、SaaS 多租户数据隔离:三种方案的完整选型指南
多租户隔离是 SaaS 系统最核心的架构决策之一,没有"最好的方案",只有"最适合当前阶段的方案"。不同的隔离级别对应不同的实现成本和运维复杂度。
8.1 三种方案全景对比
markdown
方案对比一览:
独立数据库 共享库 Schema 隔离 共享库行隔离
(Database Per Tenant) (Schema Per Tenant) (Shared Table)
数据隔离程度 物理隔离,最彻底 逻辑隔离,较高 行级隔离,最低
单租户数据量上限 不限 不限 受单表大小影响
租户总数上限 受服务器资源限制 数百个(Schema 太多影响性能) 无限制
运维复杂度 高(N 个实例) 中(一个实例 N 个 Schema) 低(一个库)
成本 最高 中 最低
合规/审计友好性 最强(物理隔离) 中 差(数据混合)
适用阶段 成熟期大客户专属 中期过渡 初创期首选
典型场景 政府/金融大客户 中型企业客户 SMB/长尾客户
8.2 方案三(共享表 + 行隔离):初创 SaaS 的首选实现
绝大多数 SaaS 产品在初期和中期使用共享表方案,因为它实现成本最低、运维最简单、最容易快速迭代。核心挑战是:如何在架构层面保证租户数据隔离,而不依赖开发者每次写 SQL 时手动添加 tenant_id = ? 条件。
步骤一:所有业务表加 tenant_id,索引以 tenant_id 为前缀
sql
-- 正确的多租户表设计模板
CREATE TABLE orders (
id BIGINT UNSIGNED NOT NULL COMMENT '雪花 ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户 ID,所有索引前缀',
order_no VARCHAR(32) NOT NULL COMMENT '订单号',
user_id BIGINT UNSIGNED NOT NULL COMMENT '用户 ID(租户内用户)',
total_amount DECIMAL(12, 2) NOT NULL DEFAULT 0.00,
status TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=待支付 1=已支付 2=已取消',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
deleted_at DATETIME(3) NULL COMMENT '逻辑删除时间,NULL=未删除',
PRIMARY KEY (id),
-- 关键:所有索引必须以 tenant_id 为前缀
-- 因为任何业务查询的第一个过滤条件都是"当前租户"
UNIQUE KEY uk_tenant_order_no (tenant_id, order_no),
INDEX idx_tenant_user (tenant_id, user_id),
INDEX idx_tenant_status (tenant_id, status, created_at),
INDEX idx_tenant_created (tenant_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
为什么所有索引都要以 tenant_id 为前缀?因为 InnoDB 的 B+树索引使用最左前缀原则,如果你的索引是 (status, created_at) 而没有 tenant_id,那么 WHERE tenant_id = 1001 AND status = 1 就无法利用这个索引------即使加了 tenant_id 的过滤条件,优化器仍然需要扫描所有租户的数据后再过滤。在 SaaS 系统中,这等于"每次查询都在所有租户的数据里捞针",是一个会随租户数量线性增长的性能问题。
步骤二:MyBatis-Plus 租户插件,自动注入 tenant_id 条件
手动在每条 SQL 里加 tenant_id = ? 既繁琐又危险(任何一处遗漏都可能导致数据越权访问)。MyBatis-Plus 的租户插件通过拦截器在 SQL 执行前自动注入租户条件:
java
@Configuration
public class MybatisPlusConfig {
/**
* 多租户插件配置
* 原理:MyBatis-Plus 拦截所有 SQL,在 WHERE 子句中自动添加 tenant_id = ? 条件
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 1. 添加租户插件(必须在分页插件之前)
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(
new TenantLineHandler() {
@Override
public Expression getTenantId() {
// 从当前请求上下文中获取租户 ID
Long tenantId = TenantContext.getCurrentTenantId();
if (tenantId == null) {
throw new BusinessException("未获取到租户信息,拒绝数据库操作");
}
return new LongValue(tenantId);
}
@Override
public String getTenantIdColumn() {
return "tenant_id"; // 租户 ID 的列名
}
@Override
public boolean ignoreTable(String tableName) {
// 不需要租户隔离的表(系统配置表、全局字典表等)
return GLOBAL_TABLES.contains(tableName.toLowerCase());
}
}
));
// 2. 分页插件
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
// 全局表(不需要租户隔离)
private static final Set<String> GLOBAL_TABLES = Set.of(
"tenant",
"plan",
"sys_config",
"area_code",
"currency"
);
}
java
// 租户上下文:通过 ThreadLocal 在请求生命周期内传递租户 ID
public class TenantContext {
private static final ThreadLocal<Long> TENANT_ID = new ThreadLocal<>();
public static void setCurrentTenantId(Long tenantId) {
TENANT_ID.set(tenantId);
}
public static Long getCurrentTenantId() {
return TENANT_ID.get();
}
public static void clear() {
TENANT_ID.remove(); // 请求结束后必须清理,防止线程池复用时数据泄露
}
}
// 请求拦截器:从 HTTP 请求中解析租户,设置到 TenantContext
@Component
@Order(1)
public class TenantResolutionInterceptor implements HandlerInterceptor {
@Autowired
private TenantService tenantService;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) {
// 租户识别策略(根据业务选一种):
// 1. 子域名:acme.yoursaas.com → tenantCode = "acme"
// 2. 请求头:X-Tenant-ID: 1001
// 3. JWT Token Claim:token 中包含 tenant_id
String tenantCode = resolveTenantCode(request);
if (tenantCode == null) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return false;
}
Tenant tenant = tenantService.getByCode(tenantCode);
if (tenant == null || !tenant.getIsActive()) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
return false;
}
TenantContext.setCurrentTenantId(tenant.getId());
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
TenantContext.clear(); // 请求完成后清理,极其重要
}
private String resolveTenantCode(HttpServletRequest request) {
// 策略一:从子域名解析
String host = request.getServerName(); // 如 acme.yoursaas.com
if (host.contains(".yoursaas.com")) {
return host.split("\\.")[0]; // 返回 "acme"
}
// 策略二:从请求头解析
return request.getHeader("X-Tenant-Code");
}
}
踩坑记录 :TenantContext 的
clear()方法在afterCompletion中调用,这是必须的。如果忘记清理,线程池中的线程在处理下一个请求时,ThreadLocal里还残留着上一个请求的租户 ID,导致数据串租------A 租户的请求读到了 B 租户的数据。这个 Bug 在测试环境几乎不可复现(测试时通常是顺序请求,不涉及线程复用),但在生产环境高并发时必然发生,是一个严重的安全漏洞。try-finally或afterCompletion确保清理,二选一,绝不省略。
步骤三:跳过租户插件的场景处理
某些操作需要跨租户访问(如超管后台统计所有租户的总数据量、定时任务批量处理所有租户数据),这时需要临时关闭租户插件:
java
// MyBatis-Plus 提供 @InterceptorIgnore 注解,可跳过租户插件
@Mapper
public interface TenantAdminMapper extends BaseMapper<Tenant> {
// 超管接口:统计所有租户总数,不需要租户过滤
@InterceptorIgnore(tenantLine = "true")
@Select("SELECT COUNT(*) FROM tenant WHERE is_active = 1")
long countAllActiveTenants();
// 统计各租户的订单量(跨租户聚合)
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT tenant_id, COUNT(*) AS cnt
FROM orders
WHERE created_at >= #{since}
GROUP BY tenant_id
""")
List<TenantOrderCountDTO> countOrdersByTenant(@Param("since") LocalDateTime since);
}
8.3 方案一(独立数据库):大客户专属隔离的 Spring 实现
对于有强数据隔离要求的大客户,独立数据库方案的核心技术挑战是"动态数据源路由"------不同的租户请求路由到不同的数据库连接:
java
// Spring AbstractRoutingDataSource:动态数据源路由核心
@Component
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
// 返回当前租户的数据源 key
// key 对应 setTargetDataSources() 中注册的数据源
return TenantContext.getCurrentTenantId();
}
}
// 数据源注册:启动时加载所有租户的数据源,或按需延迟加载
@Configuration
public class DynamicDataSourceConfig {
@Autowired
private TenantDataSourceRepository tenantDsRepo;
@Bean
@Primary
public DataSource dataSource() {
TenantRoutingDataSource routingDs = new TenantRoutingDataSource();
// 加载所有独立数据库租户的数据源配置
Map<Object, Object> targetDataSources = new HashMap<>();
tenantDsRepo.findAllIsolatedTenants().forEach(config -> {
HikariDataSource ds = buildDataSource(config);
targetDataSources.put(config.getTenantId(), ds);
});
// 设置默认数据源(共享库,兜底)
routingDs.setDefaultTargetDataSource(sharedDataSource());
routingDs.setTargetDataSources(targetDataSources);
routingDs.afterPropertiesSet();
return routingDs;
}
private HikariDataSource buildDataSource(TenantDsConfig config) {
HikariConfig hc = new HikariConfig();
hc.setJdbcUrl(config.getJdbcUrl());
hc.setUsername(config.getUsername());
hc.setPassword(config.getPassword());
hc.setMaximumPoolSize(10); // 独立库连接池不宜过大
hc.setMinimumIdle(2);
hc.setMaxLifetime(1800000L);
hc.setPoolName("Tenant-" + config.getTenantId());
return new HikariDataSource(hc);
}
}
九、逻辑删除的正确设计
逻辑删除(软删除)在 SaaS 系统中几乎是标配,但实现方式有高下之分。
sql
-- 常见但次优的做法:is_deleted 布尔值
-- 缺点:只知道"删了没有",不知道"什么时候删的"
is_deleted TINYINT(1) NOT NULL DEFAULT 0;
-- 推荐做法:deleted_at 时间戳
-- 优点:同时记录"是否删除"和"何时删除",审计日志更完整
-- NULL 表示未删除,非 NULL 表示已删除且值就是删除时间
deleted_at DATETIME(3) NULL COMMENT '逻辑删除时间,NULL 表示未删除';
为什么推荐 deleted_at 而不是 is_deleted?因为 deleted_at 是 is_deleted 的超集------你可以从 deleted_at IS NOT NULL 得到"是否已删除",还能额外知道删除发生的时间,这对数据审计、定时清理归档(如"删除超过 90 天的数据才物理删除")等场景非常有价值。
java
// MyBatis-Plus 逻辑删除配置(使用 deleted_at 字段)
// application.yml:
// mybatis-plus:
// global-config:
// db-config:
// logic-delete-field: deletedAt
// logic-delete-value: "now()" # MP 对函数支持有限,建议手动处理删除时间
// logic-not-delete-value: "null"
// Entity 定义
@Data
@TableName("orders")
public class Order {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
// MyBatis-Plus 逻辑删除字段注解
// 查询时自动追加 AND deleted_at IS NULL 条件
@TableLogic(value = "null", delval = "now(3)")
private LocalDateTime deletedAt;
}
sql
-- 逻辑删除后,需要保证唯一索引不被历史删除数据干扰
-- 场景:订单号在同一租户内唯一,但删除的订单号应该允许被复用
-- 方案:唯一索引包含 deleted_at(MySQL 中 NULL 值不参与唯一性判断)
-- 这是一个利用 NULL 特性的技巧:两条 (tenant_id='1', order_no='A', deleted_at=NULL) 的记录
-- 在 MySQL 中不会冲突,因为 NULL != NULL
-- 但有个限制:如果你删除了 order_no='A' 的订单(deleted_at 从 NULL 变为时间戳),
-- 又想复用 order_no='A' 创建新订单,新记录的 deleted_at=NULL,
-- 而已删除的记录 deleted_at 是具体时间,两者不冲突------这正是我们想要的行为。
UNIQUE KEY uk_tenant_order_no (tenant_id, order_no, deleted_at);
-- 注意:这个技巧只在 deleted_at 参与唯一键时有效
-- 如果你用 is_deleted(0/1),已删除的记录 is_deleted=1,新记录 is_deleted=0,仍然冲突
-- 这是 deleted_at 方案的又一个优势
十、AI 融合实战:用大模型辅助生成 SaaS 多租户建表 DDL
在系统设计初期,可以用大模型快速生成符合团队规范的建表 DDL,然后人工审查和调整:
java
@Service
public class SchemaGeneratorService {
@Autowired
private ChatClient chatClient;
/**
* 根据业务描述生成符合 SaaS 多租户规范的建表 DDL
*/
public String generateTableDDL(String businessDescription) {
String prompt = """
你是一位 MySQL 数据库架构师,专注于 SaaS 多租户系统设计。
根据以下业务描述,生成符合规范的 MySQL 建表 DDL。
【必须遵守的规范】
1. 主键:BIGINT UNSIGNED NOT NULL,类型为 ASSIGN_ID(雪花 ID 在应用层生成)
2. 必须包含 tenant_id BIGINT UNSIGNED NOT NULL,用于多租户隔离
3. 金额字段:DECIMAL(12,2),禁止 FLOAT/DOUBLE
4. 时间字段:DATETIME(3),禁止 TIMESTAMP
5. 字符集:ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
6. 所有索引必须以 tenant_id 为前缀
7. 逻辑删除字段:deleted_at DATETIME(3) NULL(不用 is_deleted)
8. 布尔字段:TINYINT(1)
9. 必须包含 created_at 和 updated_at 字段
10. 每个字段必须有 COMMENT
【业务描述】
%s
只返回 CREATE TABLE SQL,不要加任何解释。
""".formatted(businessDescription);
return chatClient.prompt(prompt).call().content();
}
}
// 使用示例
// String ddl = schemaGen.generateTableDDL(
// "SaaS 系统的工单(Ticket)表:工单有标题、描述、优先级(低/中/高/紧急)、" +
// "状态(待处理/处理中/待确认/已关闭)、指派给谁(用户 ID)、" +
// "所属项目(项目 ID)、截止时间,支持逻辑删除"
// );
生成的 DDL 仍然需要人工审查(类型是否合适、索引是否完整、注释是否准确),但可以大幅减少从零开始写表结构的时间,同时通过提示词中的规范约束,保证生成结果符合团队标准。
十一、本篇小结与下篇预告
本篇建立了两个核心能力:数据类型选型能力(整型字节成本、VARCHAR 和 TEXT 的索引边界、DATETIME 的时区安全、DECIMAL 的精度保证)和 SaaS 多租户架构设计能力(三种隔离方案的选型决策树、共享表方案的 MyBatis-Plus 自动隔离实现、独立数据库方案的动态数据源路由)。
这两个能力在任何 Java SaaS 项目的表设计阶段都是必需的,且一旦上线就极难修改------这也是本篇内容密度较高的原因。
第 04 篇预告:索引原理深度解析与 Java 慢查询治理。从 B+树的数据结构讲起,彻底讲清楚聚簇索引与二级索引的物理结构差异、覆盖索引消除回表的原理、最左前缀原则的底层逻辑,以及七种常见的索引失效场景(含最隐蔽的隐式类型转换)。Java 侧给出基于 P6Spy + Spring AI 的慢查询自动分析链路。
FAQ
Q:SaaS 系统从共享表方案迁移到独立数据库方案,最合理的时机是什么?
A:没有普遍适用的时机,但有几个信号值得关注:某个大客户的数据量超过整体数据量的 30%(影响其他租户的查询性能);大客户提出明确的数据隔离合规要求(如 ISO 27001、SOC 2);大客户愿意为独立数据库支付更高的套餐费用(说明业务价值能覆盖运维成本)。迁移应该是"为单个大客户单独做",而不是"全量切换"------混合模式(大客户独立库 + 中小客户共享库)是成熟 SaaS 产品的常见状态。
Q:主键选 Snowflake ID,在前端展示时有什么注意事项?
A:Snowflake ID 是 64 位整数,最大值约为 9.2 × 10¹⁸,超过了 JavaScript Number 类型的安全整数范围(2⁵³ - 1 ≈ 9 × 10¹⁵)。如果直接通过 JSON API 把 Snowflake ID 以数字形式返回给前端 JS,会发生精度丢失(最后几位变成 0)。解决方案是在 JSON 序列化时把 Long 类型的主键转为字符串:在 Spring Boot 中配置 @JsonSerialize(using = ToStringSerializer.class) 或全局配置 Jackson2ObjectMapperBuilderCustomizer。
Q:MyBatis-Plus 的租户插件能保证 100% 的数据隔离安全吗?
A:租户插件能覆盖通过 MyBatis-Plus 执行的所有 SQL(包括 BaseMapper 的标准方法和自定义 Mapper 方法),但以下场景需要额外注意:① 通过 JdbcTemplate 或原生 JDBC 执行的 SQL 不经过插件,需要手动添加 tenant_id 条件;② 被 @InterceptorIgnore(tenantLine = "true") 标记的方法需要开发者自行保证安全;③ 存储过程、触发器中的 SQL 也不经过插件。安全审计建议对这三类绕过插件的路径单独做代码 Review,确保没有租户越权访问的漏洞。
Q:deleted_at 方案中,如何配合 MyBatis-Plus 的 @TableLogic 注解正确使用?
A:@TableLogic(value = "null", delval = "now(3)") 这个配置的含义是"未删除时值为 null,删除时更新为 now(3)"。但 MyBatis-Plus 的逻辑删除实现是用 UPDATE 语句设置 deleted_at = now(3),这个 now(3) 在某些版本下会被当成字符串处理而不是函数调用,导致字段被设置成字符串 "now(3)" 而不是当前时间戳。更稳妥的做法是在 Service 层手动设置删除时间:entity.setDeletedAt(LocalDateTime.now()); entityMapper.updateById(entity);,而不是依赖 @TableLogic 的自动填充。
如果这篇文章对你有帮助,欢迎点赞收藏,你的支持是持续更新的动力。有问题欢迎在评论区留言,我会逐一回复。