PAYATHON 2026

为多表数据创建 global ID 并设计 notification system

支付老李

结论

不建议创建包含 75 个可空外键列的 dataObjects 表。这个方案勉强可以实现统一编号,但表结构会非常臃肿,约束和查询也会越来越复杂。每增加一种对象类型,还要修改表结构,长期维护成本很高。

通常可以考虑以下两种设计:

  1. 如果所有对象都需要统一且可验证的引用,使用“父表统一分配 ID,业务表共享主键”的 supertype/subtype 模型。
  2. 如果统一引用只用于通知展示,可以在通知表中保存 ObjectType + ObjectId,并保存生成通知时的显示文本快照。数据库不必强行实现跨表的 generic foreign key。

付款与发票之间的关系等会计系统核心业务关系,仍应使用普通外键。通知系统不能成为保存业务数据关系的唯一位置。

为什么不建议使用 75 个可空外键列

下面这样的表很快就会变得难以管理:

CREATE TABLE DataObjects
(
    DataObjectId   BIGINT IDENTITY PRIMARY KEY,
    UserId         BIGINT NULL,
    OrganizationId BIGINT NULL,
    InvoiceId      BIGINT NULL,
    PaymentId      BIGINT NULL
    -- 其余约 71 个外键列
);

问题不只是磁盘空间,更在于数据模型本身:

  • 每行只能有一个外键不是 NULL,需要复杂的 CHECK constraint 来保证。
  • 每增加一种对象类型,都要执行 ALTER TABLE。
  • 大量外键和索引都需要维护。
  • 查询通常要检查多个列,很难写出通用 SQL。
  • 很难阻止同一个业务对象被重复注册。
  • 75 个可空定长列确实会占用空间,但在 SQL Server 中,NULL 并不总是按数据类型的完整宽度存储。实际开销还取决于 null bitmap、行格式、索引和 sparse columns。即使空间可以接受,这种结构仍然不值得采用。

方案一:使用统一对象父表

如果系统确实需要数据库级 global ID,可以先在父表中插入一行,再把生成的 ID 作为具体业务表的主键。父表只记录 ID 和对象类型,不需要设置 75 个可空外键列。

CREATE TABLE dbo.ObjectTypes
(
    ObjectTypeId SMALLINT NOT NULL
        CONSTRAINT PK_ObjectTypes PRIMARY KEY,
    ObjectTypeName SYSNAME NOT NULL
        CONSTRAINT UQ_ObjectTypes_Name UNIQUE
);

CREATE TABLE dbo.DataObjects
(
    DataObjectId BIGINT IDENTITY(1, 1) NOT NULL
        CONSTRAINT PK_DataObjects PRIMARY KEY,
    ObjectTypeId SMALLINT NOT NULL,
    CreatedAt DATETIME2(3) NOT NULL
        CONSTRAINT DF_DataObjects_CreatedAt DEFAULT SYSUTCDATETIME(),

    CONSTRAINT FK_DataObjects_ObjectTypes
        FOREIGN KEY (ObjectTypeId)
        REFERENCES dbo.ObjectTypes(ObjectTypeId)
);

业务表使用同一个 ID:

CREATE TABLE dbo.Users
(
    UserId BIGINT NOT NULL
        CONSTRAINT PK_Users PRIMARY KEY,
    DisplayName NVARCHAR(200) NOT NULL,

    CONSTRAINT FK_Users_DataObjects
        FOREIGN KEY (UserId)
        REFERENCES dbo.DataObjects(DataObjectId)
);

CREATE TABLE dbo.Invoices
(
    InvoiceId BIGINT NOT NULL
        CONSTRAINT PK_Invoices PRIMARY KEY,
    InvoiceNumber NVARCHAR(50) NOT NULL,
    TotalAmount DECIMAL(19, 4) NOT NULL,

    CONSTRAINT FK_Invoices_DataObjects
        FOREIGN KEY (InvoiceId)
        REFERENCES dbo.DataObjects(DataObjectId)
);

创建用户时,应在同一个 transaction 中先创建父对象,再插入用户:

BEGIN TRANSACTION;

INSERT INTO dbo.DataObjects(ObjectTypeId)
VALUES (1); -- 1 = User

DECLARE @UserId BIGINT = CONVERT(BIGINT, SCOPE_IDENTITY());

INSERT INTO dbo.Users(UserId, DisplayName)
VALUES (@UserId, N'Steve');

COMMIT TRANSACTION;

这样,通知参数就可以安全地引用父表:

CREATE TABLE dbo.NotificationEvents
(
    NotificationEventId BIGINT IDENTITY(1, 1) NOT NULL
        CONSTRAINT PK_NotificationEvents PRIMARY KEY,
    NotificationTypeId INT NOT NULL,
    OccurredAt DATETIME2(3) NOT NULL
        CONSTRAINT DF_NotificationEvents_OccurredAt
        DEFAULT SYSUTCDATETIME()
);

CREATE TABLE dbo.NotificationEventObjects
(
    NotificationEventId BIGINT NOT NULL,
    ArgumentPosition TINYINT NOT NULL,
    DataObjectId BIGINT NOT NULL,

    CONSTRAINT PK_NotificationEventObjects
        PRIMARY KEY (NotificationEventId, ArgumentPosition),

    CONSTRAINT FK_NotificationEventObjects_Event
        FOREIGN KEY (NotificationEventId)
        REFERENCES dbo.NotificationEvents(NotificationEventId),

    CONSTRAINT FK_NotificationEventObjects_Object
        FOREIGN KEY (DataObjectId)
        REFERENCES dbo.DataObjects(DataObjectId)
);

这种设计能提供真正的统一 ID,但也有一个限制:普通外键只能证明 DataObjectId 存在,无法证明位置 0 引用的必然是 User,位置 1 引用的必然是 Invoice。“某个参数位置允许哪些对象类型”这类规则,通常要由存储过程、trigger 或应用服务验证。

父表模型还要求所有相关写入都遵循统一的创建流程。对于已经有 75 张表的成熟系统,迁移成本可能很高,不应只为通知功能贸然改造整个业务模型。

方案二:保存对象类型和原表主键

如果 global ID 只用于通知、审计记录或页面跳转,可以使用 polymorphic reference:

CREATE TABLE dbo.NotificationEventValues
(
    NotificationEventId BIGINT NOT NULL,
    ArgumentPosition TINYINT NOT NULL,
    ObjectTypeId SMALLINT NULL,
    ObjectId BIGINT NULL,
    TextValue NVARCHAR(500) NOT NULL,

    CONSTRAINT PK_NotificationEventValues
        PRIMARY KEY (NotificationEventId, ArgumentPosition),

    CONSTRAINT FK_NotificationEventValues_Event
        FOREIGN KEY (NotificationEventId)
        REFERENCES dbo.NotificationEvents(NotificationEventId),

    CONSTRAINT FK_NotificationEventValues_ObjectType
        FOREIGN KEY (ObjectTypeId)
        REFERENCES dbo.ObjectTypes(ObjectTypeId),

    CONSTRAINT CK_NotificationEventValues_ObjectReference
        CHECK
        (
            (ObjectTypeId IS NULL AND ObjectId IS NULL)
            OR
            (ObjectTypeId IS NOT NULL AND ObjectId IS NOT NULL)
        )
);

示例数据如下:

NotificationEventId  ArgumentPosition  ObjectTypeId  ObjectId  TextValue
21                   0                 1             42        Steve
21                   1                 3             1234      1234

ObjectTypeId + ObjectId 可以用来定位原对象,但 SQL Server 无法声明一个根据 ObjectTypeId 动态指向不同表的外键。引用有效性只能通过以下方式维护:

  • 由创建通知的存储过程根据类型检查目标表。
  • 在产生通知的应用服务中验证对象。
  • 使用 trigger 检查引用,但 75 种分支会让 trigger 非常复杂,通常不推荐。
  • 定期运行一致性检查并清理失效引用。

这种弱化数据库外键的方式只适合辅助数据。付款、发票和订单等核心业务关系仍然必须使用真实外键。

通知最好保存显示快照

通知内容不应完全依赖业务表中的当前数据。假设用户后来把 Steve 改名为 Stephen,或者发票已被归档,历史通知是否也应该变化?多数系统希望通知保留事件发生时的语义。

因此,建议同时保存:

  • 可选的对象引用,供用户点击通知后打开详情。
  • 事件发生时的显示文本,用于稳定呈现历史通知。

例如:

CREATE TABLE dbo.NotificationEvents
(
    NotificationEventId BIGINT IDENTITY(1, 1) NOT NULL
        CONSTRAINT PK_NotificationEvents PRIMARY KEY,
    NotificationTypeId INT NOT NULL,
    ActorUserId BIGINT NULL,
    TargetObjectTypeId SMALLINT NULL,
    TargetObjectId BIGINT NULL,
    ActorDisplayName NVARCHAR(200) NULL,
    TargetDisplayText NVARCHAR(500) NULL,
    OccurredAt DATETIME2(3) NOT NULL
        CONSTRAINT DF_NotificationEvents_OccurredAt
        DEFAULT SYSUTCDATETIME(),

    CONSTRAINT FK_NotificationEvents_Actor
        FOREIGN KEY (ActorUserId)
        REFERENCES dbo.Users(UserId),

    CONSTRAINT FK_NotificationEvents_TargetType
        FOREIGN KEY (TargetObjectTypeId)
        REFERENCES dbo.ObjectTypes(ObjectTypeId)
);

对于结构稳定的通知,“参与者 + 目标对象”通常比任意数量的 {0}、{1} 参数更容易查询和维护。只有参数数量确实会变化时,才有必要单独创建参数表。

需要强引用完整性时,按事件类型建明细表

会计应用通常很重视引用完整性。如果通知类型的数量可控,可以用公共表保存时间、接收人和状态,再为不同事件分别创建明细表:

CREATE TABLE dbo.PaymentNotificationDetails
(
    NotificationEventId BIGINT NOT NULL
        CONSTRAINT PK_PaymentNotificationDetails PRIMARY KEY,
    UserId BIGINT NOT NULL,
    PaymentId BIGINT NOT NULL,
    InvoiceId BIGINT NOT NULL,

    CONSTRAINT FK_PaymentNotificationDetails_Event
        FOREIGN KEY (NotificationEventId)
        REFERENCES dbo.NotificationEvents(NotificationEventId),

    CONSTRAINT FK_PaymentNotificationDetails_User
        FOREIGN KEY (UserId)
        REFERENCES dbo.Users(UserId),

    CONSTRAINT FK_PaymentNotificationDetails_Payment
        FOREIGN KEY (PaymentId)
        REFERENCES dbo.Payments(PaymentId),

    CONSTRAINT FK_PaymentNotificationDetails_Invoice
        FOREIGN KEY (InvoiceId)
        REFERENCES dbo.Invoices(InvoiceId)
);

这种方式会增加明细表的数量,但有一些实际优势:

  • 每个引用都有真正的外键。
  • 数据含义清楚,不依赖参数位置。
  • 查询和索引更直接。
  • 不同通知类型可以保存各自需要的业务字段。
  • 修改一种通知时,不会影响其他通知的结构。

如果通知只是业务事件的投影,也可以让通知记录直接引用已有的 PaymentId、OrderId 等事件来源,无需再设计一套通用对象关系。

模板设计注意事项

通知模板可以保留,但模板文本不应被视为不可变的历史事实:

CREATE TABLE dbo.NotificationTypes
(
    NotificationTypeId INT NOT NULL
        CONSTRAINT PK_NotificationTypes PRIMARY KEY,
    TemplateKey VARCHAR(100) NOT NULL
        CONSTRAINT UQ_NotificationTypes_TemplateKey UNIQUE,
    MessageTemplate NVARCHAR(1000) NOT NULL
);

建议使用稳定的 TemplateKey,例如:

invoice.paid
product.purchased

应用层再根据语言选择对应模板。与直接把英文句子作为通知类型相比,这种方式更适合国际化。

模板的更新策略也需要事先明确。如果历史通知必须保持原样,可以保存最终渲染文本:

ALTER TABLE dbo.NotificationEvents
ADD RenderedMessage NVARCHAR(1000) NULL;

如果希望修改模板后,历史通知也随之变化,就需要保存结构化参数,并在读取时重新渲染。但这种方式必须处理对象删除、名称变化和旧模板兼容等问题。

推荐取舍

如果只为通知功能服务,优先选择:

NotificationType
+ structured event fields
+ ObjectType/ObjectId optional link
+ display text snapshot

只有在能够接受全局数据模型改造,而且其他模块也需要统一对象身份时,才考虑使用 DataObjects 父表和共享主键。

如果要求数据库为每种通知参数提供严格的引用完整性,应按事件类型建立明细表。不要为了实现一个 generic foreign key,把 75 种对象塞进一张包含 75 个可空外键列的表。

备注:内容仅供参考。