如何在 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;
原因主要有两点:
- SQL 查询处理的是结果集合,不是程序语言中的逐行循环。没有明确的窗口排序规则,就不能依赖某一行先于另一行执行。
- 即使某次执行得到了正确结果,执行计划、索引或并行处理发生变化后,计算顺序仍可能改变。
下面的形式在 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 ...),即可得到顺序明确的累计余额。
备注:内容仅供参考。
