概念不同但属性相似的 entities 应存储在一个还是多个 table?
结论
不要只因为字段相似,就把 OnlinePayments 和 ManualPayments 合并成一张带有大量可空列的表。
通常更合适的做法是:
- 用
Payments保存所有支付共有的属性; - 用
OnlinePayments和ManualPayments分别保存各类支付的专属属性; - 将两个明细表的主键同时设为指向
Payments的外键。
这就是“父表 + 类型明细表”的设计。统一查询支付记录时可以访问父表,各种支付方式的约束和业务含义则保留在对应的明细表中。
如果两类记录几乎不需要统一查询,生命周期、状态流转和关联关系也完全不同,直接使用两张独立表也可以。是否合并不能只看字段有多相似,也不能在没有基准测试的情况下,用“性能更好”作为决策依据。
为什么不建议直接合并成一张大表
如果把两种支付合并到一张表中,结构可能如下:
CREATE TABLE Payments
(
ID uniqueidentifier NOT NULL,
PaymentType varchar(16) NOT NULL,
UserID uniqueidentifier NOT NULL,
BankingAccID uniqueidentifier NULL,
Amount decimal(19, 4) NOT NULL,
Comments nvarchar(768) NULL,
CreatedAt datetime2 NOT NULL,
-- Online payment fields
TrackingCode nvarchar(32) NULL,
ReferingCode nvarchar(32) NULL,
Status int NULL,
ShoppingCartID uniqueidentifier NULL,
-- Manual payment fields
BankingOrgID uniqueidentifier NULL,
BranchName nvarchar(64) NULL,
BranchCode nvarchar(16) NULL,
SlipNumber nvarchar(64) NULL,
SlipImage varbinary(max) NULL,
PaidAt datetime2 NULL,
IsApproved bit NULL,
ApprovedByID uniqueidentifier NULL
);
这种设计不一定错误,通常称为 Single Table Inheritance 或 Table per Hierarchy。它的问题是,大多数类型专属字段都必须允许 NULL,普通的 NOT NULL 约束很难表达这些规则:
- Online payment 必须有
TrackingCode; - Manual payment 必须有
SlipNumber; - Online payment 不应该填写
BranchCode; - Manual payment 不应该出现
ShoppingCartID; ApprovedByID只能用于经过审批的 Manual payment。
这些规则可以通过复杂的 CHECK 约束实现,但随着支付类型和字段不断增加,表结构与约束都会变得越来越难维护。
单表方案更适合以下情况:
- 各类型之间的差异很小;
- 类型数量少且相对稳定;
- 经常需要一次查询所有类型;
- 可空列的数量在可接受范围内;
- 类型规则可以用清晰、可靠的约束表达。
推荐结构:公共支付表加类型明细表
先提取真正共有的属性:
CREATE TABLE dbo.Payments
(
ID uniqueidentifier NOT NULL
CONSTRAINT PK_Payments PRIMARY KEY,
PaymentType varchar(16) NOT NULL,
UserID uniqueidentifier NOT NULL,
BankingAccID uniqueidentifier NULL,
Amount decimal(19, 4) NOT NULL,
Comments nvarchar(768) NULL,
CreatedAt datetime2 NOT NULL
CONSTRAINT DF_Payments_CreatedAt DEFAULT sysdatetime(),
CONSTRAINT CK_Payments_PaymentType
CHECK (PaymentType IN ('ONLINE', 'MANUAL')),
CONSTRAINT CK_Payments_Amount
CHECK (Amount >= 0)
);
Online payment 的专属字段放在自己的明细表中:
CREATE TABLE dbo.OnlinePayments
(
PaymentID uniqueidentifier NOT NULL
CONSTRAINT PK_OnlinePayments PRIMARY KEY,
TrackingCode nvarchar(32) NOT NULL,
ReferringCode nvarchar(32) NULL,
Status int NOT NULL,
ShoppingCartID uniqueidentifier NOT NULL,
CONSTRAINT FK_OnlinePayments_Payments
FOREIGN KEY (PaymentID)
REFERENCES dbo.Payments(ID)
);
Manual payment 也使用独立的明细表:
CREATE TABLE dbo.ManualPayments
(
PaymentID uniqueidentifier NOT NULL
CONSTRAINT PK_ManualPayments PRIMARY KEY,
BankingOrgID uniqueidentifier NOT NULL,
BranchName nvarchar(64) NULL,
BranchCode nvarchar(16) NULL,
SlipNumber nvarchar(64) NOT NULL,
SlipImage varbinary(max) NULL,
PaidAt datetime2 NOT NULL,
IsApproved bit NOT NULL
CONSTRAINT DF_ManualPayments_IsApproved DEFAULT 0,
ApprovedByID uniqueidentifier NULL,
CONSTRAINT FK_ManualPayments_Payments
FOREIGN KEY (PaymentID)
REFERENCES dbo.Payments(ID),
CONSTRAINT CK_ManualPayments_Approval
CHECK
(
(IsApproved = 0 AND ApprovedByID IS NULL)
OR
(IsApproved = 1 AND ApprovedByID IS NOT NULL)
)
);
采用这种结构后,查询公共信息时只需访问 Payments:
SELECT
ID,
PaymentType,
UserID,
Amount,
CreatedAt
FROM dbo.Payments
WHERE UserID = @UserID
ORDER BY CreatedAt DESC;
需要 Online payment 的详细信息时,再连接对应的明细表:
SELECT
p.ID,
p.UserID,
p.Amount,
p.CreatedAt,
op.TrackingCode,
op.Status,
op.ShoppingCartID
FROM dbo.Payments AS p
INNER JOIN dbo.OnlinePayments AS op
ON op.PaymentID = p.ID
WHERE p.ID = @PaymentID
AND p.PaymentType = 'ONLINE';
保证类型与明细表一致
只建立普通外键,仍然可能产生以下无效数据:
Payments.PaymentType = 'ONLINE',记录却被插入ManualPayments;- 同一个
PaymentID同时出现在两个明细表中; - 父表记录已经存在,对应的明细记录却尚未创建。
这些规则涉及多张表,简单的 CHECK 约束无法全部保证。常见的处理方式包括:
- 在同一个事务中创建父记录和明细记录;
- 只允许通过存储过程或受控的数据访问层写入;
- 使用包含类型列的复合外键,强化类型一致性;
- 为重要规则增加触发器,同时控制触发器的复杂度;
- 定期执行一致性检查,找出缺少明细或明细重复的数据。
例如,可以让子表同时保存固定的类型值,再建立复合外键:
ALTER TABLE dbo.Payments
ADD CONSTRAINT UQ_Payments_ID_PaymentType
UNIQUE (ID, PaymentType);
CREATE TABLE dbo.OnlinePayments
(
PaymentID uniqueidentifier NOT NULL,
PaymentType varchar(16) NOT NULL
CONSTRAINT DF_OnlinePayments_PaymentType DEFAULT 'ONLINE',
TrackingCode nvarchar(32) NOT NULL,
Status int NOT NULL,
ShoppingCartID uniqueidentifier NOT NULL,
CONSTRAINT PK_OnlinePayments
PRIMARY KEY (PaymentID),
CONSTRAINT CK_OnlinePayments_PaymentType
CHECK (PaymentType = 'ONLINE'),
CONSTRAINT FK_OnlinePayments_Payments
FOREIGN KEY (PaymentID, PaymentType)
REFERENCES dbo.Payments(ID, PaymentType)
);
这样可以防止 Online 明细关联到标记为 MANUAL 的父记录。至于“同一支付不能同时拥有两种明细”这条规则,仍然需要通过写入流程或额外约束来保证。
是否需要统一父表,取决于业务语义
字段相同,不等于业务概念相同。两个实体都有 Amount 和 CreatedAt,并不足以说明它们应该放进同一张表。判断时需要考虑:
- 它们是否都表示同一个业务概念,也就是支付;
- 其他表是否需要用一个外键引用任意类型的支付;
- 是否需要统一生成账单、退款、对账或审计记录;
- 是否具有相同的生命周期和通用状态;
- 是否经常需要按用户、时间或金额统一检索。
如果订单、退款或会计流水需要引用任意一种支付,统一的 Payments.ID 会很有用:
CREATE TABLE dbo.Refunds
(
ID uniqueidentifier NOT NULL
CONSTRAINT PK_Refunds PRIMARY KEY,
PaymentID uniqueidentifier NOT NULL,
Amount decimal(19, 4) NOT NULL,
CONSTRAINT FK_Refunds_Payments
FOREIGN KEY (PaymentID)
REFERENCES dbo.Payments(ID)
);
没有父表时,Refunds 往往需要设置两个可空外键,或者无法通过一个普通外键保证引用完整性。
反过来,如果所谓的 Manual payment 其实只是“用户提交的付款凭证”,在审批通过前还不能算作支付,那么更合适的模型可能是 PaymentClaims、DepositSlips 等独立实体。审批通过后,再生成真正的 Payment。这种情况下,需要先厘清领域概念,而不是先选择继承映射方式。
性能不应作为预设结论
把所有字段放进一张表不一定更快,拆成多张表也不一定更慢。实际性能取决于:
- 查询是否需要连接明细表;
- 表的宽度和记录数量;
- 索引设计;
- 查询选择性;
- 是否频繁读取大字段;
- SQL Server 的执行计划和数据分布。
对于支付列表查询,较窄的公共表可能效率更高,因为查询不必读取凭证图片等大字段。如果查询总是同时读取公共字段和类型详情,表连接会产生一定成本。不过,通常应先保证模型正确,再根据真实查询和执行计划进行优化。
可以针对常见的访问路径建立索引,例如:
CREATE INDEX IX_Payments_UserID_CreatedAt
ON dbo.Payments (UserID, CreatedAt DESC)
INCLUDE (PaymentType, Amount);
在缺少真实数据、查询负载和执行计划时,不能断言单表方案一定更快。
其他注意事项
- SQL Server 的
image类型已不适合用于新设计,二进制内容可以使用varbinary(max)。如果图片较大,也可以只在数据库中保存对象存储地址及必要的元数据。 - 金额可以使用
decimal(p, s),明确控制精度和小数位。是否继续使用money,应根据现有系统的兼容性和计算要求决定。 - 新表优先使用
datetime2。与datetime相比,它的时间范围更大,可选精度也更高。 Status最好设置明确的取值约束,或者引用状态定义表,避免任意整数写入数据库。ReferingCode和ShopingCartID看起来可能有拼写问题。如果外部程序已经依赖这些名称,重命名前需要评估兼容性。- 不要为了消除少量重复列而过度抽象。数据库规范化是为了表达业务事实、减少异常并维护数据完整性,不是机械地追求字段永不重复。
综合考虑,这个场景应优先采用 Payments 公共表加两个类型明细表的设计。如果进一步分析后发现两类记录并不共享“支付”这一业务身份,就应该将它们保留为两个完全独立的实体。只有当类型差异很小,而且相关约束容易维护时,才适合使用带类型标识的单一大表。
备注:内容仅供参考。
