PAYATHON 2026

T-SQL:使用跨表 GUID 存储公共信息是否合理?

支付小周

结论

不建议让 BillingInfo 只保存一个“可能来自任意业务表的 GUID”,再通过扫描多张表判断它属于哪个对象。

这是一种多态关联(polymorphic association)。它表面上减少了外键列,却失去了数据库的参照完整性保障。SQL Server 无法通过普通 FOREIGN KEY 确保这个 GUID 一定存在于 VehicleRegistration、VehicleLoan 或其他指定表中。

这个场景通常有两种设计方式:

  1. 建立统一的“可计费对象”父表,让所有计费信息通过外键引用它。
  2. 如果各类业务对象的计费关系差异较大,为每种关系分别建立关联表。

这里通常更适合第一种。

为什么不宜只使用跨表 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 所在的物理表来判断业务类型。少写几个外键列所带来的便利,通常抵不过数据完整性、查询复杂度和长期维护成本上的损失。

备注:内容仅供参考。