多租户数仓怎么设计主键?为什么加了 tenant_id 仍会串数据?
面试怎么回答
我会先确认业务键的唯一范围,把来源和租户纳入实体身份,再检查 UPSERT、窗口去重、维表 Join、汇总与缓存是否使用同一范围。代理键或哈希不能替代这个判断。身份建模和访问授权还要分别验证,最后用两个租户同号的样本跑通更新、删除、查询和缓存,主动测试是否串数据。
先把这段讲顺;面试官追问时,再展开下面的原理和细节。
详细讲解
两家商户的订单号都叫 1001,在各自业务库里完全合法,合进同一张数仓表后却互相覆盖。团队给表补上 tenant_id,问题就彻底解决了吗?如果去重、关联和缓存仍只认订单号,风险只是换了一个位置。
租户和来源是业务身份的一部分,必须贯穿唯一键、去重、关联和服务缓存;身份正确与访问权限隔离要分别验证。
主键回答的不是“这一列是否唯一”
先问业务编号在哪个范围内唯一。订单号可能只在商户内唯一,用户号可能只在源系统内唯一;同一租户迁移过系统后,还可能重新从小编号开始。合仓时要区分来源系统、租户、实体类型及自然键,必要时再增加来源实例或编号时代。
这个范围不是字段越多越安全。把每天变化的分区日期塞进订单身份,可能让同一订单跨天变成两个实体。应根据源端编号契约选最小但充分的身份组合,并保留自然键来源,方便定位历史记录。
身份要贯穿四个容易遗漏的位置
第一是写入目标。UPSERT 冲突键如果只有 order_id,即使行里存了 tenant_id,仍可能覆盖另一租户。数据库中定义的唯一约束或引擎合并键,要和逻辑身份一致,不能只看建表注释。
第二是窗口去重。ROW_NUMBER 的 PARTITION BY 只写 order_id,会把不同租户的订单排到同一组里,最后只保留一个。第三是维表关联,事实表中的租户字段不参与 Join,可能关联到别人的用户和商品。第四是汇总及缓存,分组键和缓存键同样不能丢掉身份范围。

一个典型场景:总量没少,用户归属却错了
比如订单事实表已按租户加订单号去重,但关联用户维表时只使用 user_id。两个租户恰好有同号用户,Join 可能放大;如果后面又随意取一个维度值,结果甚至恢复为一行,却随机带上另一商户的用户信息。
最初可能把它当成维表重复,简单加 DISTINCT。实际是关联关系缺了租户边界。原有检查只看订单数,没有验证关联后租户一致性,也没准备同号跨租户样本,所以没有及时发现。
改进应检查关联基数、租户字段一致性和未匹配记录;对于全局共享维度,则显式标明它确实不属于任何单一租户。不是所有 Join 都机械加 tenant_id,而是每种维度都要有明确的身份契约。
代理键和哈希不能自动修复身份
可以建立自然键组合到代理键的映射,但生成代理键之前仍要识别正确的源实体。把错误的 order_id 单列映射成全局数字,只是给原来的冲突换了一个名字。
如果使用哈希,输入应包含完整身份并采用无歧义编码。直接拼接字符串可能让不同字段组合得到相同原文;同时要考虑 NULL、空串、大小写与字符规范。保留原始字段并检测碰撞,不能把哈希位数足够大等同于业务建模正确。
数据身份与访问权限是两道检查
完整主键防止混淆实体,但不代表查询者只能看到自己的数据。服务层仍需把已验证的租户身份注入查询范围,并在适用系统里设置行级策略、授权视图或物理隔离。不能信任客户端随意传来的 tenant_id 来决定可访问范围。
缓存键也要包含会影响结果的租户及授权上下文,否则一次正确执行的 SQL 仍可能通过错误缓存暴露给其他用户。共享字典、全局报表和跨租户管理账号属于单独授权场景,应有明确入口与审计。
验收要主动制造“撞号”
准备两个租户相同订单号、同用户号的样本,覆盖写入更新、删除、重试、维表关联、导出和缓存命中;再验证源系统迁移后的编号复用。正常租户各自随机抽几条,往往碰不到真正的冲突条件。
历史表已经串数据时,应先圈定影响范围并从保留的来源字段或原始层恢复身份。仅修改以后写入的键,不会自动修复已覆盖的数据,也不能据此宣称历史问题已经解决。