PAYATHON 2026

MySQL LEFT JOIN 导致付款与退款记录重复

支付阿杰

结论

问题不在 LEFT JOIN,而在于查询同时连接了两个一对多关系:

  • 一张 invoice 对应多条 payment
  • 同一张 invoice 也对应多条 refund

假设某张 invoice 有 2 条 payment 和 2 条 refund,连接后会产生 2 × 2 = 4 行。此时直接执行 SUM(payment.amount) 和 SUM(refund.amount),会重复累计每笔金额。

解决方法是分别按 invoice_id 汇总 payment 和 refund,确保两个汇总结果都是“一张 invoice 一行”,然后再将它们连接到 invoice。

推荐写法:先聚合,再 JOIN

MySQL 8.0 可以使用 CTE:

WITH payment_totals AS (
    SELECT
        pi.invoice_id,
        SUM(p.amount) AS payment_amount
    FROM payment_invoice AS pi
    INNER JOIN payment AS p
        ON p.payment_id = pi.payment_id
    WHERE p.processor_payment_id IS NOT NULL
    GROUP BY pi.invoice_id
),
refund_totals AS (
    SELECT
        ri.invoice_id,
        SUM(r.amount) AS refund_amount
    FROM refund_invoice AS ri
    INNER JOIN refund AS r
        ON r.refund_id = ri.refund_id
    WHERE r.processor_refund_id IS NOT NULL
    GROUP BY ri.invoice_id
)
SELECT
    i.invoice_id,
    i.company_id,
    ist.invoice_status,
    i.total AS invoice_total_amount,
    COALESCE(
        CAST(res.reservation_id AS CHAR),
        'ADHOC INVOICE'
    ) AS reservation_id,
    COALESCE(rt.refund_amount, 0) AS refund_amount,
    COALESCE(pt.payment_amount, 0) AS payment_amount,
    i.payment_processor_fee,
    i.vquip_fee,
    COALESCE(pt.payment_amount, 0)
        - COALESCE(rt.refund_amount, 0)
        AS gross_amount_to_be_dispersed,
    COALESCE(pt.payment_amount, 0)
        - COALESCE(rt.refund_amount, 0)
        - COALESCE(i.vquip_fee, 0)
        - CASE
            WHEN i.charge_customers_payment_processing_fee = 0
                THEN COALESCE(i.payment_processor_fee, 0)
            ELSE 0
          END
        AS amount_to_be_dispersed,
    i.charge_customers_payment_processing_fee
FROM invoice AS i
INNER JOIN invoice_status AS ist
    ON ist.invoice_status_id = i.status_id
LEFT JOIN reservation AS res
    ON res.reservation_id = i.reservation_id
LEFT JOIN reservation_status AS rst
    ON rst.reservation_status_id = res.reservation_status_id
LEFT JOIN payment_totals AS pt
    ON pt.invoice_id = i.invoice_id
LEFT JOIN refund_totals AS rt
    ON rt.invoice_id = i.invoice_id
WHERE ist.invoice_status IN ('Paid', 'Partially Refunded')
  AND i.has_been_dispersed = 0
  AND rst.reservation_status IN ('Closed', 'Ended', 'Cancelled')
  AND (
      pt.invoice_id IS NOT NULL
      OR rt.invoice_id IS NOT NULL
  );

payment_totals 和 refund_totals 最多只会为每张 invoice 返回一行,所以外层查询不会产生 payment 与 refund 的交叉组合,也不再需要 GROUP BY。

MySQL 5.7 及更早版本

不支持 CTE 时,可以改用派生表,处理逻辑相同:

SELECT
    i.invoice_id,
    i.company_id,
    ist.invoice_status,
    i.total AS invoice_total_amount,
    COALESCE(
        CAST(res.reservation_id AS CHAR),
        'ADHOC INVOICE'
    ) AS reservation_id,
    COALESCE(rt.refund_amount, 0) AS refund_amount,
    COALESCE(pt.payment_amount, 0) AS payment_amount,
    i.payment_processor_fee,
    i.vquip_fee,
    COALESCE(pt.payment_amount, 0)
        - COALESCE(rt.refund_amount, 0)
        AS gross_amount_to_be_dispersed,
    COALESCE(pt.payment_amount, 0)
        - COALESCE(rt.refund_amount, 0)
        - COALESCE(i.vquip_fee, 0)
        - CASE
            WHEN i.charge_customers_payment_processing_fee = 0
                THEN COALESCE(i.payment_processor_fee, 0)
            ELSE 0
          END
        AS amount_to_be_dispersed,
    i.charge_customers_payment_processing_fee
FROM invoice AS i
INNER JOIN invoice_status AS ist
    ON ist.invoice_status_id = i.status_id
LEFT JOIN reservation AS res
    ON res.reservation_id = i.reservation_id
LEFT JOIN reservation_status AS rst
    ON rst.reservation_status_id = res.reservation_status_id
LEFT JOIN (
    SELECT
        pi.invoice_id,
        SUM(p.amount) AS payment_amount
    FROM payment_invoice AS pi
    INNER JOIN payment AS p
        ON p.payment_id = pi.payment_id
    WHERE p.processor_payment_id IS NOT NULL
    GROUP BY pi.invoice_id
) AS pt
    ON pt.invoice_id = i.invoice_id
LEFT JOIN (
    SELECT
        ri.invoice_id,
        SUM(r.amount) AS refund_amount
    FROM refund_invoice AS ri
    INNER JOIN refund AS r
        ON r.refund_id = ri.refund_id
    WHERE r.processor_refund_id IS NOT NULL
    GROUP BY ri.invoice_id
) AS rt
    ON rt.invoice_id = i.invoice_id
WHERE ist.invoice_status IN ('Paid', 'Partially Refunded')
  AND i.has_been_dispersed = 0
  AND rst.reservation_status IN ('Closed', 'Ended', 'Cancelled')
  AND (
      pt.invoice_id IS NOT NULL
      OR rt.invoice_id IS NOT NULL
  );

这里通常不需要创建临时表。派生表或 CTE 可以在一条查询中完成同样的工作,还能省去临时表的命名和清理问题。

如果确实需要 payment 和 refund 分别占一行

汇总时不需要把两类记录拆成独立行。如果要展示明细,可以使用 UNION ALL:

SELECT
    pi.invoice_id,
    NULL AS refund_amount,
    p.amount AS payment_amount
FROM payment_invoice AS pi
INNER JOIN payment AS p
    ON p.payment_id = pi.payment_id

UNION ALL

SELECT
    ri.invoice_id,
    r.amount AS refund_amount,
    NULL AS payment_amount
FROM refund_invoice AS ri
INNER JOIN refund AS r
    ON r.refund_id = ri.refund_id;

这样 payment 与 refund 不会互相配对。如果只是计算每张 invoice 的总额,仍应优先分别聚合,再进行连接。

注意事项

不要使用 SUM(DISTINCT amount) 修复重复

不同 payment 可能具有相同金额。例如,两笔付款都是 100.00,SUM(DISTINCT amount) 只会计算一次,结果仍然不对。去重应依据记录关系,而不是金额。

invoice 自身字段不应再次 SUM

预聚合后,外层查询中的每张 invoice 只有一行,因此没有必要这样写:

SUM(invoice.vquip_fee)

直接使用下面的写法即可:

COALESCE(i.vquip_fee, 0)

如果以后在外层增加其他一对多连接,使用 SUM 还可能导致 invoice 级费用被再次重复累计。

检查关联表是否存在重复关系

即使已经预聚合,只要 payment_invoice 中存在重复的 (invoice_id, payment_id),对应的 payment 仍会被重复计算。可以根据业务规则添加唯一约束:

ALTER TABLE payment_invoice
ADD UNIQUE KEY uq_payment_invoice (invoice_id, payment_id);

ALTER TABLE refund_invoice
ADD UNIQUE KEY uq_refund_invoice (invoice_id, refund_id);

执行前,需要先确认现有数据中没有重复关系。

LEFT JOIN 后的 WHERE 条件可能排除空记录

当前条件是:

AND rst.reservation_status IN ('Closed', 'Ended', 'Cancelled')

没有 reservation 的 invoice 会被排除,因为此时 rst.reservation_status 为 NULL。在这个条件下,相关的 LEFT JOIN 实际效果接近 INNER JOIN。

如果还要保留 ADHOC invoice,可以根据业务要求改成:

AND (
    rst.reservation_status IN ('Closed', 'Ended', 'Cancelled')
    OR i.reservation_id IS NULL
)

使用 OR 代替 ||

MySQL 默认支持用 || 表示逻辑 OR,但启用 PIPES_AS_CONCAT SQL mode 后,|| 会被解释为字符串连接。为避免不同环境产生不同结果,应明确写成:

pt.invoice_id IS NOT NULL OR rt.invoice_id IS NOT NULL

多个一对多表参与汇总时,应先将每个明细表聚合到主表粒度,再执行 JOIN。这样可以避免组合膨胀,并让 payment、refund 和 invoice 级费用各自独立计算。

备注:内容仅供参考。