PAYATHON 2026

支付系统中 key/value table 与 nullable columns 的取舍

支付老李

结论

建议采用“主表 + 类型子表”,但不要在主表里保存“目标表名 + row ID”。更合适的结构是:

  • payment 保存各类支付共有的数据,同时记录 payment_type。
  • 每种支付类型使用独立子表,子表主键也是指向 payment.id 的外键。
  • 使用 payment_allocation 关联支付与订单、账单或其他应付对象,不再限制一次支付只能对应一个对象。
  • 只有不参与检索、计算和约束的非核心扩展数据,才适合放入 JSON。通用 key/value table 不适合承载核心支付字段。

在四种方案中,这属于改良后的方案 2,在性能、可读性、类型约束和长期维护成本之间较为均衡。新增支付类型需要执行数据库迁移并非缺陷。如果新类型带来了新的业务数据和约束,修改 schema 恰好可以明确记录这些变化。

推荐的数据模型

支付本身与“支付了什么”是两个不同的概念。要解除单对象限制,首先需要增加支付分配关系,而不是改变支付类型的存储方式。

payment
   ├── card_payment
   ├── bank_transfer_payment
   └── wallet_payment

payment
   └── payment_allocation
           └── payable object

主表保存支付生命周期中的通用信息,例如金额、币种、状态、创建时间和外部交易号。类型子表只保存对应支付方式特有的字段。

下面是一个简化示例,使用 PostgreSQL 语法:

CREATE TABLE payment (
    id              bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payment_type    varchar(30) NOT NULL,
    status          varchar(20) NOT NULL,
    amount          numeric(19, 4) NOT NULL CHECK (amount > 0),
    currency_code   char(3) NOT NULL,
    provider_ref    varchar(100),
    created_at      timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT payment_type_check
        CHECK (payment_type IN ('card', 'bank_transfer', 'wallet')),

    CONSTRAINT payment_status_check
        CHECK (status IN ('pending', 'authorized', 'captured', 'failed', 'refunded'))
);

CREATE UNIQUE INDEX payment_provider_ref_uq
    ON payment (provider_ref)
    WHERE provider_ref IS NOT NULL;

银行卡支付和银行转账分别使用结构明确的子表:

CREATE TABLE card_payment (
    payment_id          bigint PRIMARY KEY,
    processor           varchar(50) NOT NULL,
    payment_token       varchar(255) NOT NULL,
    card_brand          varchar(30),
    last_four           char(4),

    CONSTRAINT card_payment_fk
        FOREIGN KEY (payment_id)
        REFERENCES payment (id)
        ON DELETE CASCADE
);

CREATE TABLE bank_transfer_payment (
    payment_id          bigint PRIMARY KEY,
    bank_reference      varchar(100) NOT NULL,
    sender_name         varchar(200),
    settlement_date     date,

    CONSTRAINT bank_transfer_payment_fk
        FOREIGN KEY (payment_id)
        REFERENCES payment (id)
        ON DELETE CASCADE
);

子表与主表共用同一个 ID,无须另设 row ID,也无须动态记录表名。这样查询路径是固定的,数据库也能建立真正的外键。

如果一次支付需要分配给多个对象,可以增加关联表:

CREATE TABLE payment_allocation (
    payment_id          bigint NOT NULL,
    payable_type        varchar(30) NOT NULL,
    payable_id          bigint NOT NULL,
    allocated_amount    numeric(19, 4) NOT NULL
                        CHECK (allocated_amount > 0),

    PRIMARY KEY (payment_id, payable_type, payable_id),

    CONSTRAINT payment_allocation_payment_fk
        FOREIGN KEY (payment_id)
        REFERENCES payment (id)
        ON DELETE CASCADE
);

这里的 payable_type + payable_id 仍是多态引用,普通外键无法验证目标对象是否存在。如果可付款对象的种类固定,可以建立统一的 payable 主表,让约束更加严谨:

CREATE TABLE payable (
    id              bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payable_type    varchar(30) NOT NULL
);

CREATE TABLE payment_allocation (
    payment_id          bigint NOT NULL REFERENCES payment (id),
    payable_id          bigint NOT NULL REFERENCES payable (id),
    allocated_amount    numeric(19, 4) NOT NULL
                        CHECK (allocated_amount > 0),

    PRIMARY KEY (payment_id, payable_id)
);

这样一来,支付和应付对象两端都有可验证的外键。还需要在同一事务中检查:

SUM(payment_allocation.allocated_amount) <= payment.amount

如果业务规定支付必须完全分配,则应在支付完成或结算时检查两者是否相等。这个规则涉及多行数据,通常需要通过事务内业务逻辑、延迟触发器或结算存储过程执行,单个 CHECK 约束无法完成。

为什么不推荐通用 key/value table

典型的 EAV(Entity-Attribute-Value)结构可能如下:

CREATE TABLE payment_attribute (
    payment_id      bigint NOT NULL REFERENCES payment (id),
    attribute_name  varchar(100) NOT NULL,
    attribute_value text,
    PRIMARY KEY (payment_id, attribute_name)
);

它确实允许在不修改 schema 的情况下增加属性,但代价很高。

类型信息会丢失

所有值都保存为 text 后,数据库无法区分日期、金额、布尔值和普通字符串。例如:

settlement_date = 'tomorrow'
fee = 'unknown'
verified = 'maybe'

数据库不能使用原生数据类型拒绝这些值。即使增加 value_number、value_date、value_boolean 等列,也需要添加额外约束,确保每行只使用正确的值列。这并没有消除复杂度,只是把它转移到了别处。

必填项和属性组合难以约束

关系表可以直接声明:

bank_reference varchar(100) NOT NULL

EAV 则需要额外逻辑才能保证:

  • bank_transfer 必须有 bank_reference;
  • card 不能出现 settlement_date;
  • 同一属性不能使用错误的数据类型;
  • 某些属性必须成组出现。

这些规则通常会分散在应用代码、触发器和配置表里,数据库 schema 也无法再清楚表达数据结构。

查询和索引更复杂

使用普通表结构时,查询很直接:

SELECT p.id, b.bank_reference
FROM payment AS p
JOIN bank_transfer_payment AS b
  ON b.payment_id = p.id
WHERE b.settlement_date >= DATE '2026-01-01';

EAV 查询往往需要多次自连接、条件聚合或类型转换:

SELECT p.id
FROM payment AS p
JOIN payment_attribute AS a
  ON a.payment_id = p.id
WHERE a.attribute_name = 'settlement_date'
  AND CAST(a.attribute_value AS date) >= DATE '2026-01-01';

这类查询较难优化,还可能因为某个无效字符串而导致转换失败。索引也很难同时满足日期范围、金额比较和字符串匹配等不同需求。

EAV 更适合属性确实由用户定义,而且属性集合无法预先确定的场景,例如自定义标签、动态问卷或产品自由属性。支付方式通常由系统代码、支付渠道和合规规则共同定义,并非完全不可预知的数据。

nullable columns 是否真的浪费空间

方案 3 所说的“空值浪费大量空间”并不一定成立。多数关系数据库使用 null bitmap 或类似机制记录空值,NULL 通常不会占用对应数据类型的完整存储空间。实际开销取决于数据库、行格式和列类型,不能直接按照列宽累加。

不过,单宽表还有更值得关注的问题:

CREATE TABLE payment (
    id                    bigint PRIMARY KEY,
    payment_type          varchar(30) NOT NULL,
    card_token            varchar(255),
    card_last_four        char(4),
    bank_reference        varchar(100),
    settlement_date       date,
    wallet_provider       varchar(50)
);

随着支付类型增加,这种设计会逐渐出现以下问题:

  • 表越来越宽,不同支付类型的字段混在一起;
  • 很难通过普通 NOT NULL 声明某个类型的必填字段;
  • 需要大量条件约束来防止非法字段组合;
  • 开发者只查看 schema 时,仍然无法确定每种类型允许使用哪些字段;
  • 新增支付类型通常依然需要增加列,并没有真正避免迁移。

如果支付类型较少,专用字段也少而且长期稳定,单表方案完全可以使用。此时应通过条件 CHECK 约束明确规则,例如:

ALTER TABLE payment
ADD CONSTRAINT payment_columns_match_type
CHECK (
    (
        payment_type = 'card'
        AND card_token IS NOT NULL
        AND bank_reference IS NULL
        AND settlement_date IS NULL
    )
    OR
    (
        payment_type = 'bank_transfer'
        AND card_token IS NULL
        AND bank_reference IS NOT NULL
    )
);

如果这类表达式开始频繁增长,通常就该考虑拆分子表了。

主表加子表需要注意的约束

普通外键无法自动保证“每个 payment 恰好有且只有一条类型正确的子表记录”。例如,数据库默认不会阻止把一个 payment_type = 'card' 的 ID 插入 bank_transfer_payment。

可以根据系统复杂度采用以下方式:

  • 在同一事务中先写入 payment,再写入对应子表,并由服务层统一封装。
  • 使用数据库触发器,检查子表与 payment_type 是否匹配。
  • 如果数据库支持合适的分区和约束能力,可以采用按类型分区的设计。
  • 支付类型只有少数几种时,也可以让主表保存各子表的外键,但这种结构通常不如共享主键自然。

无论采用哪种方式,都应通过集成测试验证同一个支付不能同时存在多条不同类型的子记录。ORM 中定义了继承关系,不代表数据库会自动拥有相同的约束。

也不建议保存动态表名:

payment.detail_table = 'card_payment'
payment.detail_row_id = 123

数据库无法为这种引用声明标准外键。表名重构、数据删除和一致性检查也都必须依赖应用代码。共享主键已经可以表达同样的关系,没有必要再增加一层间接寻址。

可以保留有限的 JSON 扩展区

如果支付渠道经常返回一些不稳定、仅用于审计或故障排查的附加字段,可以采用混合方案:

ALTER TABLE payment
ADD COLUMN provider_metadata jsonb;

适合放入 JSON 的内容包括:

  • 第三方渠道返回的非核心附加信息;
  • 不参与金额计算和状态判断的数据;
  • 不需要外键、唯一性或严格必填约束的数据;
  • 很少查询的原始响应摘要。

金额、币种、支付状态、结算日期和渠道交易号等核心字段仍应使用正式列。判断方法很直接:如果一个字段需要频繁查询、排序、关联、唯一约束、范围检查或业务计算,就应该把它定义为明确的 column,而不是 key/value 或随意的 JSON 属性。

选择标准

可以按以下顺序判断:

  1. 数据集合能否预先定义

    能够定义时,使用正式列和子表。只有属性确实由客户动态创建时,才考虑 EAV。

  2. 数据库是否需要执行约束

    金额、状态、必填字段、唯一性和引用完整性等数据,应保留关系模型提供的类型和约束能力。

  3. 字段是否参与查询

    经常用于筛选、排序、聚合和关联的字段应使用普通列,并建立有针对性的索引。

  4. 支付类型增长速度如何

    类型少但字段差异大时,主表加子表最清晰。类型少且差异很小时,可以考虑包含 nullable columns 的单表。

  5. 部署迁移是否真的无法接受

    对于客户自托管的数据库,应建立版本化迁移、向前兼容和升级检查机制,而不是借助 EAV 完全回避 schema 变更。应用升级本来就可能包含新的约束、索引和数据修复。

  6. 变化属于业务结构还是附加元数据

    业务结构发生变化时,应修改 schema。只有附加元数据适合进入 JSON 扩展字段。

综合考虑后,比较合适的组合是:核心支付数据使用 payment + typed subtype tables,支付对象通过独立的 allocation 关联表管理,少量不稳定的元数据保存为 JSON。它无法实现“无须迁移即可任意扩展”,但可以保留支付系统更需要的可验证性、可查询性和审计能力。

备注:内容仅供参考。