如何对多个不相关 models 的数据排序和分页?
结论
可以直接在数据库中完成。无需建立模型关联,只要把 4 张表中需要展示的字段映射为相同的列结构,再通过 UNION ALL 合并,最后对合并结果统一执行 ORDER BY 和分页即可。
数据量较小时,可以直接运行这类查询。如果报表查询频繁,或者数据量已经很大,可以增加一张统一的“收入变动流水表”,在业务发生时同步写入。
统一数据结构
四类记录至少要映射出以下字段:
event_type:记录类型,例如payment、refundevent_id:原始记录 IDoccurred_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 找回原始业务记录。
备注:内容仅供参考。
