3. 租户元数据与路由设计
多租户系统里,业务数据可以按租户隔离,但“租户本身是谁、该连到哪个库、当前处于什么状态”这类信息必须集中管理,这就是租户元数据与路由设计要解决的问题。它决定了新租户如何进入系统、请求如何被导向正确的数据存储,以及在高并发下如何避免每次请求都去查库。本节围绕租户注册、租户标识生成、连接路由策略、元数据存储与缓存四条主线展开。
租户注册:从申请到可用的状态推进
租户注册不是简单插入一行记录,而是一个跨多个资源的状态推进过程。以共享库独立 Schema 模式为例,一次注册通常包含:校验租户编码唯一性、生成租户标识、写入租户元数据、创建租户专属 Schema、初始化基础数据、将租户状态置为可用。这些步骤中任何一步失败,都需要可回滚或可重试,否则会出现“元数据存在但 Schema 不存在”的脏状态。
因此租户表通常带有状态字段,例如 PENDING、PROVISIONING、ACTIVE、SUSPENDED、DELETED。注册请求先落一条 PENDING 记录并返回租户标识,随后由异步任务推进到 PROVISIONING,完成资源创建后置为 ACTIVE。这样即使创建 Schema 耗时较长,也不会阻塞注册接口,同时状态字段让运维可以清楚判断租户卡在哪一步。
租户标识生成:稳定、可路由、不可猜测
租户标识是贯穿全系统的核心键,它既要稳定,又要能支撑路由。常见做法是使用不可变的 tenant_id 作为内部主键,同时保留一个可读的 tenant_code 供人工识别。内部标识建议使用 UUIDv7 或雪花算法生成的 64 位整数:前者按时间有序,便于索引;后者紧凑且可反解出时间与节点信息,适合分库分表场景。
需要特别区分“标识”和“路由键”。tenant_id 是逻辑标识,路由键则可能由 tenant_id 经过哈希或取模得到。例如分 8 个库时,可以用 shard = hash(tenant_id) % 8 计算目标库。哈希取模的优点是分布均匀,缺点是扩容时需要迁移数据;一致性哈希或预分片(如固定 1024 个逻辑槽,再映射到物理库)能显著降低扩容成本。选择哪种方式,取决于租户规模增长预期和运维迁移能力。
连接路由策略:请求如何找到正确的库
路由策略的核心是“在什么时机、依据什么信息、选择哪个数据源”。常见策略有三类:
- 按租户直连:每个租户独享数据库实例,路由表直接记录
tenant_id → jdbc_url。隔离性最好,但连接数和成本随租户数线性增长。 - 按分片路由:多个租户共享一组分片库,通过
tenant_id计算分片。适合中小租户海量场景,需要处理跨分片查询和扩容。 - 按 Schema 路由:共享同一个库,不同租户使用不同 Schema,路由时切换
search_path或使用带 Schema 前缀的表名。资源开销小,但单库容量和连接数会成为瓶颈。
工程实现上,路由通常发生在数据访问层。以 Java 为例,可以在请求入口解析租户标识,写入 ThreadLocal 上下文,再由动态数据源或 MyBatis 拦截器在获取连接时读取上下文并选择数据源。关键点是:路由必须发生在连接获取之前,且同一请求内多次数据库操作要复用同一租户上下文,避免中途切换导致数据串租户。
// 路由上下文示例
public class TenantContext {
private static final ThreadLocal<String> HOLDER = new ThreadLocal<>();
public static void set(String tenantId) { HOLDER.set(tenantId); }
public static String get() { return HOLDER.get(); }
public static void clear() { HOLDER.remove(); }
}
// 动态数据源选择(伪代码)
String tenantId = TenantContext.get();
String shard = "db_" + (Math.floorMod(tenantId.hashCode(), 8));
DataSource ds = dataSourceMap.get(shard);
元数据存储与缓存:让路由查询足够快
租户元数据本身也需要存储。它通常放在一个独立的“元数据库”或配置中心里,与业务库分离,避免业务库故障影响路由。元数据表至少包含:tenant_id、tenant_code、status、shard_key、schema_name、created_at、version。其中 version 用于缓存失效判断。
如果每次请求都查元数据库,路由就会成为性能瓶颈。因此需要缓存,且缓存要解决三个问题:一致性、穿透、失效。推荐采用“本地缓存 + 分布式缓存”两级结构:本地缓存(如 Caffeine)保存热点租户路由,TTL 设为 30~60 秒;分布式缓存(如 Redis)保存全量租户路由,TTL 设为 5~10 分钟。元数据变更时,先更新数据库,再删除 Redis 缓存,并通过消息广播让各节点失效本地缓存。
对于不存在的租户标识,缓存空值或使用布隆过滤器,防止恶意请求反复穿透到数据库。租户被停用或删除时,除了更新状态,还应主动清除缓存,避免请求继续路由到已下线的资源。
缓存一致性:变更时如何不串租户
缓存最大的风险是“旧路由被继续使用”。假设租户从 db_0 迁移到 db_1,如果缓存未及时失效,部分请求仍会写入旧库,造成数据分裂。因此元数据变更必须遵循“先库后缓存”的顺序:先提交数据库事务,再删除缓存;删除失败时通过消息队列重试。对于本地缓存,可以订阅 Redis 的失效频道,收到通知后清除本机对应条目。
另一个细节是缓存键的设计。建议使用 tenant:route:{tenant_id} 作为键,值中同时包含 shard_key、schema_name 和 version。读取时校验版本号,若发现版本落后于数据库,则主动刷新。这样即使删除缓存失败,也能通过版本比对兜底。
TenantContext.get() 为空且当前操作被标记为租户相关,直接抛出异常,防止误用默认数据源导致全租户数据泄露。一个可落地的元数据表与路由配置示例
下面给出一个简化的元数据表结构和路由配置,便于直接参考。字段命名和分片数可根据实际调整。
CREATE TABLE tenant_meta (
tenant_id BIGINT PRIMARY KEY,
tenant_code VARCHAR(64) NOT NULL UNIQUE,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
shard_key VARCHAR(32) NOT NULL,
schema_name VARCHAR(64),
version BIGINT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
-- 路由配置示例(8 分片)
shards:
- name: db_0
url: jdbc:mysql://10.0.0.10:3306/saas_0
- name: db_1
url: jdbc:mysql://10.0.0.11:3306/saas_1
# ... db_2 ~ db_7
routing:
strategy: hash_mod
shard_count: 8
cache:
local_ttl_seconds: 45
redis_ttl_seconds: 600
整体来看,租户元数据与路由设计的关键在于:注册过程要状态化、可重试;租户标识要稳定且能支撑分片计算;路由要在连接获取前完成并绑定请求上下文;元数据存储要与业务库隔离;缓存要兼顾性能与一致性。把这五点落实,后续的租户隔离、扩容迁移和故障排查才有可靠基础。