PAYATHON 2026

SQL Server 2014 中批量更新多个 running totals

支付老李

结论

不需要使用 loop 或 cursor。单条 UPDATE 对每个目标行只修改一次,但可以先算出该行受到的累计撤销金额,再一次性减去合计值。

核心公式如下:

新的 running total
= 原 running total
- SUM(所有排序日期不晚于当前行日期的待取消交易金额)

三个日期维度分别计算各自的累计撤销金额。先将待取消交易物化到临时表,并排除已经取消的行,就能保证操作幂等,重复执行时不会再次扣减。

准备待取消的数据

SQL Server 2014 没有 STRING_SPLIT,因此存储过程参数更适合使用 table-valued parameter,不建议传入 CSV 字符串。

CREATE TYPE dbo.TransactionIdList AS TABLE
(
    Id INT NOT NULL PRIMARY KEY
);
GO

在存储过程中,将确实需要取消的交易复制到临时表:

CREATE TABLE #Cancel
(
    Id              INT      NOT NULL PRIMARY KEY,
    EventDate       DATETIME NOT NULL,
    TransactionDate DATETIME NULL,
    PaymentDate     DATETIME NULL,
    Amount          BIGINT   NOT NULL
);

INSERT #Cancel
(
    Id,
    EventDate,
    TransactionDate,
    PaymentDate,
    Amount
)
SELECT
    t.Id,
    t.EventDate,
    t.TransactionDate,
    t.PaymentDate,
    CONVERT(BIGINT, COALESCE(t.Amount, 0))
FROM dbo.MyTransaction AS t
JOIN @Ids AS i
    ON i.Id = t.Id
WHERE t.IsCancelled = 0;

这里只复制 IsCancelled = 0 的行。否则同一批 ID 再次提交时,running total 会被重复扣减。

合计使用 BIGINT,可以避免多个 INT 金额在 SUM 中相加时溢出。正式系统中的 running total 列通常也应使用 BIGINT 或合适的 DECIMAL。

单条 set-wise UPDATE

下面的语句会为每个目标行计算三个累计撤销金额,并且只更新该目标行一次:

UPDATE t
SET
    t.RunningTotalByEventDate =
        t.RunningTotalByEventDate - d.EventDelta,

    t.RunningTotalByTransactionDate =
        t.RunningTotalByTransactionDate - d.TransactionDelta,

    t.RunningTotalByPaymentDate =
        t.RunningTotalByPaymentDate - d.PaymentDelta,

    t.IsCancelled =
        CASE
            WHEN own.Id IS NOT NULL THEN CONVERT(BIT, 1)
            ELSE t.IsCancelled
        END
FROM dbo.MyTransaction AS t
LEFT JOIN #Cancel AS own
    ON own.Id = t.Id
CROSS APPLY
(
    SELECT
        EventDelta =
            COALESCE(SUM(
                CASE
                    WHEN c.EventDate <= t.EventDate
                    THEN c.Amount
                    ELSE CONVERT(BIGINT, 0)
                END
            ), 0),

        TransactionDelta =
            COALESCE(SUM(
                CASE
                    WHEN c.TransactionDate IS NOT NULL
                     AND t.TransactionDate IS NOT NULL
                     AND c.TransactionDate <= t.TransactionDate
                    THEN c.Amount
                    ELSE CONVERT(BIGINT, 0)
                END
            ), 0),

        PaymentDelta =
            COALESCE(SUM(
                CASE
                    WHEN c.PaymentDate IS NOT NULL
                     AND t.PaymentDate IS NOT NULL
                     AND c.PaymentDate <= t.PaymentDate
                    THEN c.Amount
                    ELSE CONVERT(BIGINT, 0)
                END
            ), 0)
    FROM #Cancel AS c
) AS d
WHERE own.Id IS NOT NULL
   OR d.EventDelta <> 0
   OR d.TransactionDelta <> 0
   OR d.PaymentDelta <> 0;

假设要取消两笔金额分别为 30 和 50 的交易,排在它们之后的某个目标行仍然只更新一次,但它的 EventDelta、TransactionDelta 或 PaymentDelta 可以是 80。目标行只更新一次,不代表调整值只能来自一条源记录,它完全可以是多条记录的合计。

为什么使用 <=

上面的代码假设 running total 包含当前行:

RunningTotal(current)
= SUM(排序位置不晚于 current 的有效交易)

取消一笔交易时,需要从该交易自身及其后续行中扣除相应金额,因此条件是:

c.EventDate <= t.EventDate

如果表中保存的是不包含当前行的余额,则应改用严格小于:

c.EventDate < t.EventDate

原来的插入逻辑使用 > 更新后续行,是因为新插入行的 running total 通常会在插入时单独初始化。批量取消的情况不同。如果 running total 包含当前行,除了后续行,还要调整被取消行自身。

更适合大表的三次 UPDATE

上面的单条语句比较直观,但它要同时处理三个不同的排序维度。表很大时,拆成三条 set-wise UPDATE 往往更容易利用各自的日期索引,也能避免包含多个日期条件的宽范围扫描。

按 EventDate 更新的写法如下:

UPDATE t
SET t.RunningTotalByEventDate =
    t.RunningTotalByEventDate - d.Delta
FROM dbo.MyTransaction AS t
CROSS APPLY
(
    SELECT Delta = SUM(c.Amount)
    FROM #Cancel AS c
    WHERE c.EventDate <= t.EventDate
) AS d
WHERE d.Delta IS NOT NULL;

另外两个日期可以采用相同的模式:

UPDATE t
SET t.RunningTotalByTransactionDate =
    t.RunningTotalByTransactionDate - d.Delta
FROM dbo.MyTransaction AS t
CROSS APPLY
(
    SELECT Delta = SUM(c.Amount)
    FROM #Cancel AS c
    WHERE c.TransactionDate IS NOT NULL
      AND c.TransactionDate <= t.TransactionDate
) AS d
WHERE d.Delta IS NOT NULL;
UPDATE t
SET t.RunningTotalByPaymentDate =
    t.RunningTotalByPaymentDate - d.Delta
FROM dbo.MyTransaction AS t
CROSS APPLY
(
    SELECT Delta = SUM(c.Amount)
    FROM #Cancel AS c
    WHERE c.PaymentDate IS NOT NULL
      AND c.PaymentDate <= t.PaymentDate
) AS d
WHERE d.Delta IS NOT NULL;

最后设置取消标志:

UPDATE t
SET t.IsCancelled = 1
FROM dbo.MyTransaction AS t
JOIN #Cancel AS c
    ON c.Id = t.Id;

这四条语句仍然属于 set-wise 操作,不是逐行循环。单条组合更新和三条按索引更新哪一种更快,需要结合实际数据分布与执行计划判断,不能只看语句数量。

完整事务边界

running total 的调整和 IsCancelled 的修改必须放在同一个事务中:

SET XACT_ABORT ON;

BEGIN TRY
    BEGIN TRANSACTION;

    -- 填充 #Cancel
    -- 更新三个 running totals
    -- 设置 IsCancelled

    COMMIT TRANSACTION;
END TRY
BEGIN CATCH
    IF @@TRANCOUNT > 0
        ROLLBACK TRANSACTION;

    THROW;
END CATCH;

插入、取消和其他会改变 running total 的存储过程还要采用一致的并发控制策略。否则,两个会话可能同时读取旧状态并计算调整值,最终造成余额不一致。

具体可以根据业务吞吐量选择适当的锁提示、SERIALIZABLE 隔离,或者使用 sp_getapplock,将同一账户的余额维护操作串行化。如果只有取消语句使用事务,而插入逻辑不遵守相同规则,并发问题仍然无法彻底解决。

日期重复时必须使用确定性排序

如果数据中可能出现相同日期,只按日期计算 running total 就无法确定唯一顺序。这时应明确使用复合排序键,例如:

ORDER BY EventDate, Id

对应的比较条件也要改为:

c.EventDate < t.EventDate
OR (c.EventDate = t.EventDate AND c.Id <= t.Id)

TransactionDate 和 PaymentDate 也要采用相同处理。索引应按同样的复合顺序建立,例如:

CREATE INDEX IX_MyTransaction_EventDate_Id
ON dbo.MyTransaction(EventDate, Id);

设计上的注意事项

持久化 running total 会带来明显的写放大。交易发生得越早,插入、修改或取消时需要更新的后续行就越多。改用 set-wise SQL 可以消除逐行循环,但无法减少这些实际写入。

如果此类操作很频繁,可以考虑:

  • 保留不可变的交易流水,用冲正记录表示取消;
  • 按账户、月份或其他周期保存余额检查点;
  • 查询时从最近的检查点开始计算,不必每次都从最早记录算起;
  • 分别为三个日期维度维护汇总或检查点结构。

如果继续使用当前表结构,直接替代 cursor 的 set-wise 解法就是:先物化待取消行,再为每个目标行聚合这些取消记录对它造成的累计影响。

备注:内容仅供参考。