PAYATHON 2026

如何在 SQL Server 中计算每笔交易后的累计余额?

支付小周

结论

不要在 SELECT 中逐行修改和复用 @total_balance。SQL Server 不保证结果集会按照某个隐含顺序逐行计算,因此变量赋值不适合用来计算运行余额。

可以改用窗口函数 SUM() OVER (...)。先将 payment 记为负数,将 refund 记为正数,再计算累计和,并加上客户的初始余额。

按示例中的记录顺序计算

示例的预期结果是按 id 升序计算的,可以这样写:

DECLARE @client_id int = 1;

SELECT
    bm.id,
    bm.movementType,
    bm.[import],
    c.balance
        + SUM(
            CASE bm.movementType
                WHEN 'payment' THEN -bm.[import]
                WHEN 'refund'  THEN  bm.[import]
                ELSE 0
            END
        ) OVER (
            ORDER BY bm.id
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) AS total
FROM balance_Movements AS bm
INNER JOIN client AS c
    ON c.id = bm.clientId
WHERE bm.clientId = @client_id
ORDER BY bm.id;

查询结果如下:

id  movementType  import  total
--  ------------  ------  ------
1   payment       50.00   450.00
2   refund        25.05   475.05
3   refund        60.00   535.05
4   payment       100.00  435.05

累计余额的计算方式是:

初始余额 + 截至当前行的所有带符号变动金额

金额的正负由 CASE 表达式决定:

CASE movementType
    WHEN 'payment' THEN -[import]
    WHEN 'refund'  THEN  [import]
    ELSE 0
END

如果需要按交易时间计算

计算累计余额时,必须指定明确且稳定的记录顺序。如果业务上要求按交易发生时间处理,窗口函数和最终结果应使用相同的排序条件:

DECLARE @client_id int = 1;

SELECT
    bm.id,
    bm.dateMovement,
    bm.movementType,
    bm.[import],
    c.balance
        + SUM(
            CASE bm.movementType
                WHEN 'payment' THEN -bm.[import]
                WHEN 'refund'  THEN  bm.[import]
                ELSE 0
            END
        ) OVER (
            ORDER BY bm.dateMovement, bm.id
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) AS total
FROM balance_Movements AS bm
INNER JOIN client AS c
    ON c.id = bm.clientId
WHERE bm.clientId = @client_id
ORDER BY bm.dateMovement, bm.id;

将 id 作为第二排序字段,可以在多笔交易时间相同时确定它们的计算顺序。

示例数据中的 dateMovement 顺序与 id 顺序相反。如果改为按日期升序计算,就不会得到题目中的 450、475.05、535.05、435.05。因此,需要先确定业务采用的真实顺序是 id、交易时间,还是其他流水号。

同时计算多个客户

如果查询包含多个客户,需要使用 PARTITION BY,分别计算每个客户的累计余额:

SELECT
    bm.clientId,
    bm.id,
    bm.dateMovement,
    bm.movementType,
    bm.[import],
    c.balance
        + SUM(
            CASE bm.movementType
                WHEN 'payment' THEN -bm.[import]
                WHEN 'refund'  THEN  bm.[import]
                ELSE 0
            END
        ) OVER (
            PARTITION BY bm.clientId
            ORDER BY bm.dateMovement, bm.id
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) AS total
FROM balance_Movements AS bm
INNER JOIN client AS c
    ON c.id = bm.clientId
ORDER BY bm.clientId, bm.dateMovement, bm.id;

PARTITION BY bm.clientId 会按客户划分数据,每个客户都从头开始累计。

为什么不应使用变量逐行计算

下面这种写法无法可靠地计算运行余额:

SELECT @total_balance = @total_balance + ...
FROM balance_Movements;

原因主要有两点:

  1. SQL 查询处理的是结果集合,不是程序语言中的逐行循环。没有明确的窗口排序规则,就不能依赖某一行先于另一行执行。
  2. 即使某次执行得到了正确结果,执行计划、索引或并行处理发生变化后,计算顺序仍可能改变。

下面的形式在 SQL Server 中也不是合法的“赋值并返回表达式”:

CASE
    WHEN movementType = 'payment'
    THEN (@total_balance = @total_balance - [import])
END

SQL Server 的变量赋值不能作为普通表达式嵌入 CASE 的返回值。这个场景应使用窗口累计函数,以集合方式完成计算。

SQL Server 表结构注意事项

问题中的建表语句包含 MySQL 语法,例如反引号、unsigned、DEFAULT CHARSET 和 CREATE TABLE IF NOT EXISTS,不能直接在 SQL Server 中使用。

金额字段也不应只声明为 decimal。在 SQL Server 中,如果没有指定精度和小数位数,字段可能无法保存 25.05 这样的金额。建议明确声明:

balance decimal(19, 2) NOT NULL,
[import] decimal(19, 2) NOT NULL

import 容易与技术术语混淆。引用现有列名时可以写成 [import];如果可以调整表结构,使用 amount、movementAmount 等名称会更清楚。

最终方案不需要维护一个持续变化的变量。将每笔交易转换为带正负号的金额,再通过 SUM() OVER (ORDER BY ...),即可得到顺序明确的累计余额。

备注:内容仅供参考。