T-SQL:使用跨表 GUID 存储公共信息是否合理?
结论
不建议让 BillingInfo 只保存一个“可能来自任意业务表的 GUID”,再通过扫描多张表判断它属于哪个对象。
这是一种多态关联(polymorphic association)。它表面上减少了外键列,却失去了数据库的参照完整性保障。SQL Server 无法通过普通 FOREIGN KEY 确保这个 GUID 一定存在于 VehicleRegistration、VehicleLoan 或其他指定表中。
这个场景通常有两种设计方式:
- 建立统一的“可计费对象”父表,让所有计费信息通过外键引用它。
- 如果各类业务对象的计费关系差异较大,为每种关系分别建立关联表。
这里通常更适合第一种。
为什么不宜只使用跨表 GUID
假设 BillingInfo.RelatedId 可能指向多张表:
BillingInfo.RelatedId
├── VehicleRegistration.VehicleRegistrationId
├── VehicleLoan.VehicleLoanId
└── Bill.BillId
这种设计会带来几个问题。
无法建立真正的外键
一个外键只能引用一张确定的表及其候选键,无法表达:
RelatedId必须存在于 A、B、C 三张表中的任意一张。
因此,下面这样的约束无法定义:
FOREIGN KEY (RelatedId)
REFERENCES (VehicleRegistration OR VehicleLoan OR Bill)
数据库也就无法阻止以下情况:
- 写入不存在的 GUID;
- 删除业务记录后留下孤立的
BillingInfo; - 同一个 GUID 意外出现在两张业务表中;
RelatedId指向错误类型的业务对象;- 无法可靠地使用
ON DELETE CASCADE。
GUID 发生碰撞的概率虽然极低,但“概率上唯一”不等于“由数据库约束保证跨表唯一”。每张表上的 UNIQUE 约束只能保证表内唯一。
查询时需要扫描多张表
要查出一个 GUID 属于哪张表,通常只能使用 UNION ALL,或者依次查询所有候选表:
SELECT VehicleRegistrationId AS EntityId,
'VehicleRegistration' AS EntityType
FROM dbo.VehicleRegistration
UNION ALL
SELECT VehicleLoanId,
'VehicleLoan'
FROM dbo.VehicleLoan;
以后每增加一种业务对象,都要修改这个查询、相关函数以及依赖它的代码。
表名不是业务数据
object_id 和表名属于数据库元数据,不适合成为业务关系的一部分。业务代码需要知道的是:
这是 VehicleRegistration 类型的计费对象
而不是:
它位于当前数据库中 object_id 为 123456 的表里
数据库重建或对象删除后重新创建时,object_id 可能发生变化,因此不能把它保存为稳定的业务标识。
推荐设计:建立统一的可计费对象表
先创建一个父表,由它集中分配跨类型唯一的 ID:
CREATE TABLE dbo.BillableItem
(
BillableItemId uniqueidentifier NOT NULL
CONSTRAINT PK_BillableItem PRIMARY KEY
CONSTRAINT DF_BillableItem_Id DEFAULT NEWSEQUENTIALID(),
ItemType varchar(30) NOT NULL,
CONSTRAINT CK_BillableItem_ItemType
CHECK (ItemType IN (
'VehicleRegistration',
'VehicleLoan',
'Bill'
))
);
业务表使用同一个 ID 作为主键,并将其作为指向父表的外键:
CREATE TABLE dbo.VehicleRegistration
(
BillableItemId uniqueidentifier NOT NULL
CONSTRAINT PK_VehicleRegistration PRIMARY KEY,
LicensePlate varchar(20) NOT NULL,
ValidFrom date NOT NULL,
ValidUntil date NOT NULL,
CONSTRAINT FK_VehicleRegistration_BillableItem
FOREIGN KEY (BillableItemId)
REFERENCES dbo.BillableItem (BillableItemId)
);
CREATE TABLE dbo.VehicleLoan
(
BillableItemId uniqueidentifier NOT NULL
CONSTRAINT PK_VehicleLoan PRIMARY KEY,
AmountFinanced decimal(19, 4) NOT NULL,
TermMonths smallint NOT NULL,
Apr decimal(9, 6) NOT NULL,
PurchaseDate date NOT NULL,
CONSTRAINT FK_VehicleLoan_BillableItem
FOREIGN KEY (BillableItemId)
REFERENCES dbo.BillableItem (BillableItemId)
);
计费信息只需引用父表:
CREATE TABLE dbo.BillingInfo
(
BillingInfoId bigint IDENTITY(1, 1) NOT NULL
CONSTRAINT PK_BillingInfo PRIMARY KEY,
BillableItemId uniqueidentifier NOT NULL,
BillingDate date NOT NULL,
Occurrence varchar(30) NULL,
EstimatedAmount decimal(19, 4) NOT NULL,
CONSTRAINT FK_BillingInfo_BillableItem
FOREIGN KEY (BillableItemId)
REFERENCES dbo.BillableItem (BillableItemId)
);
这样就能建立真正的外键约束:
VehicleRegistration ─┐
VehicleLoan ─────────┼── BillableItem ──< BillingInfo
Bill ────────────────┘
每张业务表仍需定义一个外键,因为数据库必须知道并验证实体之间的关系。不过,BillableItemId 同时也是业务表的主键,不需要再增加一个指向 BillingInfo 的列。
数据写入示例
新增车辆注册及其计费信息时,应在同一个事务中完成写入:
BEGIN TRANSACTION;
DECLARE @BillableItemId uniqueidentifier = NEWID();
INSERT INTO dbo.BillableItem
(
BillableItemId,
ItemType
)
VALUES
(
@BillableItemId,
'VehicleRegistration'
);
INSERT INTO dbo.VehicleRegistration
(
BillableItemId,
LicensePlate,
ValidFrom,
ValidUntil
)
VALUES
(
@BillableItemId,
'ABC-123',
'2026-01-01',
'2026-12-31'
);
INSERT INTO dbo.BillingInfo
(
BillableItemId,
BillingDate,
Occurrence,
EstimatedAmount
)
VALUES
(
@BillableItemId,
'2026-01-01',
'Annual',
180.00
);
COMMIT TRANSACTION;
这里需要手动调用 NEWID(),因为同一个值要写入多张表。NEWSEQUENTIALID() 只能出现在列的 DEFAULT 表达式中,不能像普通函数一样赋值给局部变量。
如何查询 GUID 对应的对象类型
引入父表后,无需扫描各个业务表,直接查询 BillableItem 即可:
DECLARE @BillableItemId uniqueidentifier =
'00000000-0000-0000-0000-000000000000';
SELECT
BillableItemId,
ItemType
FROM dbo.BillableItem
WHERE BillableItemId = @BillableItemId;
如果需要可复用的查询接口,可以创建内联表值函数:
CREATE FUNCTION dbo.GetBillableItem
(
@BillableItemId uniqueidentifier
)
RETURNS TABLE
AS
RETURN
(
SELECT
BillableItemId,
ItemType
FROM dbo.BillableItem
WHERE BillableItemId = @BillableItemId
);
调用方式如下:
SELECT *
FROM dbo.GetBillableItem(@BillableItemId);
通常没有必要返回 object_id。如果管理工具确实需要显示表名,可以建立显式映射:
CREATE VIEW dbo.vBillableItemLocation
AS
SELECT
BillableItemId,
ItemType,
CASE ItemType
WHEN 'VehicleRegistration' THEN 'dbo.VehicleRegistration'
WHEN 'VehicleLoan' THEN 'dbo.VehicleLoan'
WHEN 'Bill' THEN 'dbo.Bill'
END AS TableName
FROM dbo.BillableItem;
这个视图适合查询和展示,不应拿来建立业务关系,也不应通过它动态拼接常规 CRUD SQL。
另一种选择:明确的关联表
如果不同对象与计费信息之间的基数或生命周期不同,可以分别建立关联表:
CREATE TABLE dbo.VehicleRegistrationBilling
(
VehicleRegistrationId uniqueidentifier NOT NULL,
BillingInfoId bigint NOT NULL,
CONSTRAINT PK_VehicleRegistrationBilling
PRIMARY KEY (VehicleRegistrationId, BillingInfoId),
CONSTRAINT FK_VehicleRegistrationBilling_Registration
FOREIGN KEY (VehicleRegistrationId)
REFERENCES dbo.VehicleRegistration (VehicleRegistrationId),
CONSTRAINT FK_VehicleRegistrationBilling_BillingInfo
FOREIGN KEY (BillingInfoId)
REFERENCES dbo.BillingInfo (BillingInfoId)
);
这种设计需要更多表,但每种关系都很明确,数据库也能完整验证。对于小型个人项目,几个含义清楚的外键通常比一个通用却无法约束的 RelatedId 更容易维护。
还要考虑业务含义
字段相似,并不意味着它们属于同一种实体。
车辆注册费和车辆贷款虽然都有日期、金额和发生频率,但其业务规则可能完全不同:
- 注册费可能每年发生一次;
- 贷款可能需要按月还款,并包含本金、利息和剩余余额;
- 普通账单可能允许金额浮动;
- 一次性购买可能没有重复周期。
因此,公共表应该对应一个明确的业务概念,例如 PaymentSchedule、FinancialObligation 或 BillingSchedule,而不是含义模糊的 CommonInfo。如果几类数据只是碰巧有几个同名字段,分别保存可能更合理。
此外,Occurrence 最好不要使用任意文本。可以将它设计为受约束的代码,例如 Once、Weekly、Monthly、Annual,也可以进一步拆分为周期单位和周期数量。
最终建议
GUID 可以用作标识符,但不能代替外键和关系建模。如果多个业务实体确实共享同一种计费能力,可以建立 BillableItem 这样的父实体,让 BillingInfo 只引用这个父实体。这样既有跨类型统一的 ID,又能保留数据库的参照完整性。
不要让 BillingInfo 保存一个引用目标不明确的 GUID,也不要通过查找 GUID 所在的物理表来判断业务类型。少写几个外键列所带来的便利,通常抵不过数据完整性、查询复杂度和长期维护成本上的损失。
备注:内容仅供参考。
