第03篇:MySQL 数据类型选型深度解析 + SaaS 多租户表设计实战

系列 :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-changegh-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 中 BOOLEANTINYINT(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-finallyafterCompletion 确保清理,二选一,绝不省略。

步骤三:跳过租户插件的场景处理

某些操作需要跨租户访问(如超管后台统计所有租户的总数据量、定时任务批量处理所有租户数据),这时需要临时关闭租户插件:

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_atis_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 的自动填充。


如果这篇文章对你有帮助,欢迎点赞收藏,你的支持是持续更新的动力。有问题欢迎在评论区留言,我会逐一回复。