租户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 + 租户内序列就是我铁打不动的标准。
索引策略:别让索引成为拖油瓶
表结构再完美,索引设计失败,查询照样慢成狗。我一般按查询频率给审计日志建索引,以下是我总结的经验:
- 主键索引已经是 (tenant_id, event_id),这已经覆盖了大多数按租户+时间范围查的需求。
- 再加一个
(tenant_id, event_time)的二级索引,用于按时间范围分析。注意不要让索引列太宽,event_time 选 TIMESTAMP 而不是 TIMESTAMPTZ,避免时区转换开销。 - 如果经常按用户ID查,可以建
(tenant_id, user_id, event_time)复合索引,把 user_id 放在第二列。 - 不要建多余的单独索引。比如只给
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方案开始,如果你只有单机房、数据量不大,走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的问题翻车。