PAYATHON 2026

概念不同但属性相似的 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 约束无法全部保证。常见的处理方式包括:

  1. 在同一个事务中创建父记录和明细记录;
  2. 只允许通过存储过程或受控的数据访问层写入;
  3. 使用包含类型列的复合外键,强化类型一致性;
  4. 为重要规则增加触发器,同时控制触发器的复杂度;
  5. 定期执行一致性检查,找出缺少明细或明细重复的数据。

例如,可以让子表同时保存固定的类型值,再建立复合外键:

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 公共表加两个类型明细表的设计。如果进一步分析后发现两类记录并不共享“支付”这一业务身份,就应该将它们保留为两个完全独立的实体。只有当类型差异很小,而且相关约束容易维护时,才适合使用带类型标识的单一大表。

备注:内容仅供参考。