为多表数据创建 global ID 并设计 notification system
结论
不建议创建包含 75 个可空外键列的 dataObjects 表。这个方案勉强可以实现统一编号,但表结构会非常臃肿,约束和查询也会越来越复杂。每增加一种对象类型,还要修改表结构,长期维护成本很高。
通常可以考虑以下两种设计:
- 如果所有对象都需要统一且可验证的引用,使用“父表统一分配 ID,业务表共享主键”的 supertype/subtype 模型。
- 如果统一引用只用于通知展示,可以在通知表中保存
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,需要复杂的CHECKconstraint 来保证。 - 每增加一种对象类型,都要执行
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 个可空外键列的表。
备注:内容仅供参考。
