Repository navigation
多租户缺陷:unique 物化为全局唯一索引、无视 tenancy,与按租户分裂的 autonumber 序列自相矛盾(跨租户必然撞号 + 存在性探测泄露) #3696
Description
Activity
补充:发布的软件包如何装进单租户/多租户环境 —— 唯一性语义必须由环境决定,不能由包作者写死
软件包是一份 dist 工件装进不同拓扑的环境(单租户自部署 / 多租户 SaaS / 先单后多)。包作者在元数据里只能表达业务意图(「编码在一个数据空间内唯一」),不可能预知安装环境的租户拓扑。这带来三个必须由平台收口的问题:
1. 作者手写复合索引是死路——单租户环境下唯一性会静默失效
如果让下游项目自己绕(删
unique: true、手写(organization_id, display_name)复合唯一),这份包装到单租户环境时organization_id恒为 NULL/缺失,而 SQLite/Postgres 默认 NULL 互不相等(NULLs distinct)——复合唯一索引对全部存量行一条都不约束,唯一性静默关闭,比撞号更糟(重复数据无声进库,没有任何报错)。反过来,作者写单列
unique: true装进多租户环境,就是本 issue 的跨租户撞号。同一份包,两种环境,两种写法各废一头 → 物化决策只能在安装/启动时由 driver 按环境的 tenancy 模式做,不属于包作者。
2. 期望的安装矩阵
包声明 单租户环境 多租户环境 unique: true(默认=数据空间内唯一)物化为单列 UNIQUE 物化为 (tenantField, field)复合 UNIQUEunique: 'global'(平台级身份,如 openid)单列 UNIQUE 单列 UNIQUE(跨租户仍全局) 包作者零感知:同一份 dist,
os install/启动同步时 driver 读环境 tenancy 配置决定物化形态。3. 环境形态迁移(先单后多 / 先多后单)
拓扑不是一次定终身:典型路径是先单租户上线、后开多组织(下游项目 os-tianshun-ehr 正是这个路径,#457/#463 关过又要开)。索引形态必须跟着环境迁移:
- 单→多:drop 单列 UNIQUE → create 复合 UNIQUE。纯放松,存量天然满足,零数据处理;但存量行的租户列须已回填(平台打标机制保证),否则 NULL 行落在约束外(见第 1 点)。
- 多→单:复合 → 单列是收紧,可能存在跨租户同值存量,
os migrate plan必须先查重报告冲突,不能盲 apply。
建议这两个转换都进
os migrate的 plan/apply,作为 tenancy 模式切换的标准动作,而不是留给各项目手工 DDL。已由 #3717 修复(合并为
b90086a),配套的 cloud 侧标注是objectstack-ai/cloud#890。这条 issue 被
Closes自动关成 completed,但你提的三条建议里有一条我没有采纳,不希望它被埋在关闭状态里 —— 说明理由,如果你不同意请直接 reopen 或回复。未采纳:建议 #1 里的「declared indexes」那条路径
你建议三条物化路径(建表 / rebuild / declared indexes)都自动升为
(tenantField, field)复合唯一。前两条做了,第三条刻意没做。原因是它会打断真正需要全局唯一的索引。cloud 仓库里的实例:
sys_slug_reservation { fields: ['slug'], unique: true } ← 全局 slug 抢注,加租户列直接失效 sys_environment { fields: ['hostname'], unique: true } ← DNS 主机名,加租户列 = 两个租户抢同一域名 sys_billing_customer { fields: ['stripe_customer_id'], unique: true } ← 外部 provider id sys_billing_invoice { fields: ['invoice_number'], unique: true } sys_package_version { fields: ['package_id', 'version'], unique: true }而且声明式索引的租户语义一直是显式表达的,本仓库里早就有先例:
sys_team { fields: ['name', 'organization_id'] } sys_member { fields: ['organization_id', 'user_id'] } sys_business_unit { fields: ['code', 'organization_id'] } sys_metadata { fields: ['type', 'name', 'organization_id', 'package_id'] }关键区别在于:字段级
unique没有任何语法能表达复合,所以默认值必须承担租户作用域 —— 这正是你诊断对的地方。而声明式索引里作者已经把列清单写死了,自动注入会与既有约定打架,并且悄悄改掉一批本来正确的索引。所以最终契约是:
声明 物化 字段级 unique: true+ 租户列复合 (tenantField, field)字段级 unique: 'global'单列全局 声明式 indexes[]按列表逐字物化,从不注入租户列 'global'在声明式索引上作为true的同义词被接受,只为两种写法共用一套词汇,不改变行为。部分采纳:存量迁移没进
os migrate你建议迁移进
os migrate的 plan/apply。我改成在 schema sync 时就地收敛 —— 因为没跑过 migrate 的部署会一直坏着,而故障形态是插入被拒。收敛之后os migrate plan永远看不到待处理的索引漂移。但这留下一个你原本要的东西:DDL 现在发生在启动时,只有一行日志,运维没有预检手段。已单独开 #3727 跟踪。
一处补充:你没提到的第 4 条路径
driver-mongodb有同样的单字段 unique 物化。那里刻意保持原样 —— 该 driver 完全没有行级租户隔离(读不加谓词、写不打戳),造复合索引会假装有隔离,比单字段更糟。租户缺失本身已开 #3724。另外
你诊断里「影响面:全平台所有多租户部署」这个判断比看上去还要准。
objectql/registry.ts的applySystemFields给每个注册对象无条件注入organization_id,而computeTenantField有隐式organization_id兜底 —— 所以即使对象没声明tenancy,driver 也把它当租户对象。覆盖面确实是全量。
Generated by Claude Code
更正:上一条里的「已单独开 #3727 跟踪」编号写错了,
os migrate可见性那条实际是 #3728。本 issue 衍生的三条跟踪:
- unique 索引迁移在启动时静默执行 DDL,os migrate plan 看不到 —— 运维无预检手段 #3728 — unique 索引迁移在启动时静默执行 DDL,
os migrate plan看不到(对应你建议里「进os migrate的 plan/apply」未落地的那部分) - driver-mongodb 完全没有行级租户隔离:读不加谓词、写不打戳,多租户下跨租户可读写 #3724 —
driver-mongodb完全没有行级租户隔离(你没提到的第 4 条 unique 物化路径所在处) - DataQualityRulesSchema 是 2026-06 字段剪除留下的孤儿:字段键已删,schema + 类型仍公开导出 #3726 —
DataQualityRulesSchema孤儿(顺带发现,与本 issue 无直接关系)
Generated by Claude Code
- unique 索引迁移在启动时静默执行 DDL,os migrate plan 看不到 —— 运维无预检手段 #3728 — unique 索引迁移在启动时静默执行 DDL,
- added 5 commits that reference this issue
on Jul 28, 2026 - added 3 commits that reference this issue
on Aug 4, 2026 - added a commit that references this issue
on Oct 7, 2026
一句话说明
unique: true被物化成不带租户列的全局唯一索引,与平台自己按租户分裂的 autonumber 序列直接矛盾:多租户部署下,各租户从 1 起发号 → 撞进全局唯一索引 →SQLITE_CONSTRAINT_UNIQUE。不需要用户做错任何事,平台左手发的号被平台右手拒掉。(下游项目实测记录:steedos-labs/os-project-titanwind-ehr#599,排查「导入用户报错」时定位到)
两段平台代码互相打架
_objectstack_sequences主键含tenant_id(object, tenant_id, field, scope),每个租户各自从 1 起driver-sql/dist/index.js两处,见下建表路径(
createTable):重建路径(schema-drift rebuild):
两条路径都无视对象的
tenancy: { enabled, tenantField }元数据。租户隔离的承诺在读侧、写侧、发号器三个环节都兑现了,唯独 unique 物化不兑现。影响面
unique: true字段(编号、编码、工号、名称…)在多租户下变成「全平台抢注」:租户 A 占了PROD-00001,其他所有租户永远插不进同值。配合租户分裂的 autonumber,是必然撞而非偶然撞。建议修法
tenancy.enabled的对象自动升为(tenantField, field)复合唯一。unique: 'global'(openid 这类平台级身份)。unique: true默认 = 租户内唯一。存量迁移:纯放松,零数据处理
旧「全局唯一」严格于新「租户内唯一」,存量数据天然满足新约束。迁移只需 drop 旧
uniq_<table>_<field>单列索引 → create 复合索引,无需查重、无需清洗、不可能失败。建议进os migrate的 plan/apply。验证方式
SQLITE_CONSTRAINT_UNIQUE,修后各自成功。unique: 'global'字段跨租户同值:仍被拒。