PAYATHON 2026

如何对多个不相关 models 的数据排序和分页?

支付小周

结论

可以直接在数据库中完成。无需建立模型关联,只要把 4 张表中需要展示的字段映射为相同的列结构,再通过 UNION ALL 合并,最后对合并结果统一执行 ORDER BY 和分页即可。

数据量较小时,可以直接运行这类查询。如果报表查询频繁,或者数据量已经很大,可以增加一张统一的“收入变动流水表”,在业务发生时同步写入。

统一数据结构

四类记录至少要映射出以下字段:

  • event_type:记录类型,例如 payment、refund
  • event_id:原始记录 ID
  • occurred_at:收入变动发生时间
  • revenue_delta:对收入的影响,增加为正数,减少为负数
  • description:展示信息,可选

每个 SELECT 返回的列数、列顺序和数据类型必须保持一致。表名和字段名则要根据实际数据库调整。

SELECT
    revenue_changes.event_type,
    revenue_changes.event_id,
    revenue_changes.occurred_at,
    revenue_changes.revenue_delta,
    revenue_changes.description
FROM (
    SELECT
        'payment' AS event_type,
        id AS event_id,
        changed_at AS occurred_at,
        amount AS revenue_delta,
        description
    FROM payments

    UNION ALL

    SELECT
        'refund' AS event_type,
        id AS event_id,
        changed_at AS occurred_at,
        -amount AS revenue_delta,
        description
    FROM refunds

    UNION ALL

    SELECT
        'fee_charge' AS event_type,
        id AS event_id,
        charged_at AS occurred_at,
        -amount AS revenue_delta,
        description
    FROM fee_charges

    UNION ALL

    SELECT
        'credit' AS event_type,
        id AS event_id,
        credited_at AS occurred_at,
        amount AS revenue_delta,
        description
    FROM credits
) AS revenue_changes
ORDER BY
    revenue_changes.occurred_at DESC,
    revenue_changes.event_type DESC,
    revenue_changes.event_id DESC
LIMIT :page_size
OFFSET :offset;

这里应使用 UNION ALL。不同表中的业务记录即使字段值相同,也不代表是重复记录。普通 UNION 会额外去重,可能误删结果,还会增加查询成本。

分页时必须保证排序稳定

只按时间排序并不可靠。多条记录可能使用相同的时间戳,如果缺少其他排序字段,同一条记录在翻页时可能重复出现,也可能被跳过。

因此,需要使用一组能够唯一确定顺序的字段:

ORDER BY occurred_at DESC, event_type DESC, event_id DESC

由于 event_id 只在各自的表内唯一,排序条件中还要包含 event_type。

数据量较大时使用游标分页

LIMIT ... OFFSET ... 简单直观,适合数据量不大,且需要跳转到指定页码的场景。但页码越靠后,数据库需要跳过的记录越多,查询成本通常也越高。如果查询期间有新数据插入,相邻页面之间还可能出现重复或遗漏。

对于“加载更多”或时间线式报告,可以将上一页最后一条记录的排序字段作为游标:

SELECT *
FROM (
    SELECT
        'payment' AS event_type,
        id AS event_id,
        changed_at AS occurred_at,
        amount AS revenue_delta,
        description
    FROM payments

    UNION ALL

    SELECT
        'refund' AS event_type,
        id AS event_id,
        changed_at AS occurred_at,
        -amount AS revenue_delta,
        description
    FROM refunds

    UNION ALL

    SELECT
        'fee_charge' AS event_type,
        id AS event_id,
        charged_at AS occurred_at,
        -amount AS revenue_delta,
        description
    FROM fee_charges

    UNION ALL

    SELECT
        'credit' AS event_type,
        id AS event_id,
        credited_at AS occurred_at,
        amount AS revenue_delta,
        description
    FROM credits
) AS revenue_changes
WHERE
    occurred_at < :cursor_time
    OR (
        occurred_at = :cursor_time
        AND event_type < :cursor_type
    )
    OR (
        occurred_at = :cursor_time
        AND event_type = :cursor_type
        AND event_id < :cursor_id
    )
ORDER BY occurred_at DESC, event_type DESC, event_id DESC
LIMIT :page_size;

返回当前页后,把最后一条记录的 occurred_at、event_type 和 event_id 编码为下一页游标。游标参数必须通过数据库驱动绑定,不能直接拼接到 SQL 中。

ORM 中如何处理

如果 ORM 支持 UNION ALL,可以先让每个模型投影出完全一致的字段,再进行合并、排序和分页。伪代码如下:

payments = Payment.objects.select(
    event_type="payment",
    event_id="id",
    occurred_at="changed_at",
    revenue_delta="amount",
)

refunds = Refund.objects.select(
    event_type="refund",
    event_id="id",
    occurred_at="changed_at",
    revenue_delta="-amount",
)

fee_charges = FeeCharge.objects.select(
    event_type="fee_charge",
    event_id="id",
    occurred_at="charged_at",
    revenue_delta="-amount",
)

credits = Credit.objects.select(
    event_type="credit",
    event_id="id",
    occurred_at="credited_at",
    revenue_delta="amount",
)

changes = (
    payments
    .union_all(refunds, fee_charges, credits)
    .order_by("-occurred_at", "-event_type", "-event_id")
)

这段代码只用于说明查询结构,并不对应某个特定 ORM 的固定 API。实际使用时,union_all、字段别名、负数表达式和分页方式都要根据框架及版本调整。如果 ORM 限制了合并查询后的排序、过滤或切片,可以改用参数化原生 SQL,也可以把合并查询封装为数据库视图。

性能和业务上的注意事项

应为每张源表的时间字段建立合适的索引,例如:

CREATE INDEX idx_payments_changed_at_id
    ON payments (changed_at, id);

CREATE INDEX idx_refunds_changed_at_id
    ON refunds (changed_at, id);

还需要注意以下问题:

  • 四个金额字段应使用兼容的精确数值类型,财务数据不要使用浮点数。
  • 明确退款、手续费和抵扣的正负号规则,不要把展示金额与收入影响混在一起。
  • 统一时区和时间精度,否则跨表排序结果可能与用户看到的时间不一致。
  • 如果报表只统计某个账户、商户或日期范围,应分别在四个子查询中加入过滤条件,尽量减少参与合并的数据量。
  • 如果记录可以修改,需要明确排序依据是“业务发生时间”还是“最后更新时间”。
  • 如果需要页码和总页数,可以在同一个合并结果外执行 COUNT(*),但在大数据量下,精确计数可能比较昂贵。

如果需要频繁执行这类查询,可以建立一张类似 revenue_events 的统一流水表,保存来源类型、来源 ID、发生时间和收入变动金额。这样更便于排序、分页、建立索引和审计,同时仍能通过 source_type 与 source_id 找回原始业务记录。

备注:内容仅供参考。