租户ID字段设计:全局唯一标识、表结构设计、索引策略

租户ID是整个隔离体系里的"身份证"。你想想看,所有审计日志从产生、存储到查询,第一步就是跟这个ID打交道。如果ID设计不合理,后面再折腾什么权限、加密、归档,都是白搭。

我个人习惯,在设计阶段先想清楚三件事:唯一性、可解析性、性能。说白了,你要能一眼看出这条日志属于哪个租户,同时不能拖慢数据库写入和查询。

我当年第一次做多租户系统时,就觉得租户ID随便给个自增整数就行。结果上线第一天就出事了——某大客户导数据时ID冲突,把另一家公司的日志全盖了。嗯,那之后我才明白,全局唯一标识不是可选项,是必须项。

全局唯一标识的选择

租户ID作为跨所有表的公共键,不能用数据库自增ID。理由很简单:你没法保证多个分片、多个集群、甚至跨地域的ID不重复。

我当时对比过几种方案,列个表给你参考:

方案 示例 长度 可读性 唯一性保障 性能
UUID v4 550e8400-e29b-41d4-a716-446655440000 36字符 差 随机 + 碰撞概率极低 一般(无顺序)
Snowflake ID 1057728769386414080 19数字 中 机器位 + 时间戳 + 序列 高(有序)
ULID 01ARZ3NDEKTSV4RRFFQ69G5FAV 26字符 中 时间戳 + 随机 高(可按时间排序)
KSUID 0ujsswThIGTUYm2K8FjOOfXtY1A 27字符 中 时间戳 + 随机 + 全局 高
自定义前缀ID crm_ten_001 可变 好 业务组合保证 中等(可能长度增长)

我建议:如果你们的审计日志量在单集群每日千万级以内,ULID就够用了。如果将来可能扩展到亿级,或者需要跨数据中心合并,直接用Snowflake变种(比如美团Leaf、百度UidGenerator)。

我一般这么生成租户ID(Java伪代码):

// 使用 ULID 库生成唯一租户ID
String tenantId = Ulid.fast().toString();
// 结果: “01ARZ3NDEKTSV4RRFFQ69G5FAV”
// 优点:可按生成时间全局有序,且不会像UUID那样有破折号

表结构设计:审计日志主表

有了租户ID,接下来就是怎么放。我见过的两种主流模式:

  • 共享表 + 租户ID列:所有租户的数据在同一张表里,靠 tenant_id 区分。
  • 独立表(每租户一张表):审计日志分库分表,表名带租户ID后缀。

我个人更倾向共享表 + 分区。理由很简单:跨租户的全局查询(比如安全审计需要分析所有租户的异常行为)非常方便,管理元数据也简单。但要注意——租户ID一定要作为主键的第一列,否则查询计划很容易走全表扫描。

这是一个典型的设计:

CREATE TABLE audit_logs (
    tenant_id          VARCHAR(26) NOT NULL,  -- 租户ID,用ULID
    event_id           BIGINT NOT NULL,       -- 事件ID,自增或Snowflake
    event_time         TIMESTAMP NOT NULL,
    user_id            VARCHAR(64),
    action_type        VARCHAR(32),
    resource_id        VARCHAR(128),
    detail             JSONB,
    ip_address         INET,
    PRIMARY KEY (tenant_id, event_id)
) PARTITION BY LIST (tenant_id);

你可能会问:为什么不用自增主键做 BIGINT AUTO_INCREMENT?我踩过这个坑——当时自增主键在分布式场景下不好迁移,而且多个租户的event_id会出现空洞和冲突。用 (tenant_id, event_id) 复合主键,每个租户内部可以独立生成 event_id,互不干扰。

我曾经在一个项目里把 event_id 设计成全局自增,结果某天运维做了数据迁移,新集群的自增值跟旧集群重叠,导致部分日志被错误归到另一个租户。排查了三天才找到原因。自此之后,租户ID + 租户内序列就是我铁打不动的标准。

索引策略:别让索引成为拖油瓶

表结构再完美,索引设计失败,查询照样慢成狗。我一般按查询频率给审计日志建索引,以下是我总结的经验:

  1. 主键索引已经是 (tenant_id, event_id),这已经覆盖了大多数按租户+时间范围查的需求。
  2. 再加一个 (tenant_id, event_time) 的二级索引,用于按时间范围分析。注意不要让索引列太宽,event_time 选 TIMESTAMP 而不是 TIMESTAMPTZ,避免时区转换开销。
  3. 如果经常按用户ID查,可以建 (tenant_id, user_id, event_time) 复合索引,把 user_id 放在第二列。
  4. 不要建多余的单独索引。比如只给 action_type 建个普通索引,那基本是废的——因为查询一定会带上 tenant_id 条件,MySQL 可能选错索引。

给你一个实际的索引DDL:

-- 主键已含 tenant_id, event_id,无需再建
-- 按时间查询的索引
CREATE INDEX idx_tenant_time ON audit_logs (tenant_id, event_time);

-- 按用户查询的索引(如果业务需要)
CREATE INDEX idx_tenant_user_time ON audit_logs (tenant_id, user_id, event_time);

这里有一个小坑:索引列的顺序很重要。你把条件最左匹配的列放前面,比如 tenant_id 固定 = 查,放第一列;后面才放范围查询的字段。否则数据库没法用上索引的前缀匹配。

避坑指南:如果你用的是 PostgreSQL 且审计日志表非常大,可以考虑用 BRIN 索引代替 B-tree 索引。我记得在 Pg 12 之后,BRIN 对时间序列数据的索引大小可以缩小到 B-tree 的 1/100,写性能提升明显。代价是范围查询稍慢一点,但审计日志很少需要毫秒级的即时查询。

租户ID设计决策点

我把整个设计流程梳理成一张决策图,你写代码时对着看就行:

租户ID字段设计决策流程 租户ID字段设计决策流程 开始设计 选择全局唯一标识方案 UUID v4 Snowflake / Leaf ULID / KSUID 可读性差,不推荐 适合大规模分布式 推荐常规场景 表结构:共享表 + 分区 索引策略 PK: (tenant_id, event_id) + 二级索引 可用的审计日志表 关键提示 • 租户ID放PK首位 • 避免单独索引 • 时序数据用BRIN • 字段长度固定

这张图一目了然。从选择ID方案开始,如果你只有单机房、数据量不大,走ULID;如果未来可能要全球部署,走Snowflake。然后统一进入共享表设计,最后给好索引,完事。

再说一个实操点

租户ID的字段长度一定要固定。我之前见过有人用可变长度 VARCHAR,结果每个租户的ID长度不一样,导致 MySQL 排序时出现诡异的行为。建议都用 26字符的ULID 或者 19位数字的Snowflake,长度一致,索引也更紧凑。

另外,审计日志表几乎总是写多读少。如果你用的是 MySQL,可以开启 innodb_flush_log_at_trx_commit = 2 提高写入性能,但要注意断电时可能丢失一秒的数据。审计场景下,这通常可以接受。

千万别在审计日志表上建全文索引或过多的 JSON 索引。我记得有个团队为了做全文检索,给 detail 字段加了 GIN 索引,结果写入性能直接掉了70%,而且查询时还经常走错索引。需要全文搜索的话,单独建一个搜索引擎(比如 Elasticsearch),不要跟主表耦合。

最后总结一句:租户ID字段设计的核心是稳。选一种唯一ID方案用到底,表结构分区明确,索引精准克制。这样你以后做数据迁移、主从切换、分库分表,都不会因为租户ID的问题翻车。