在 MySQL 8.0 之前,窗口函数并不是内置能力,但业务分析中经常需要计算 running total(累计求和),例如按日期汇总销售额、按用户累计积分、按月份显示余额变化等。窗口函数本身就是为了解决这类“逐行计算”问题而设计的,但如果你使用的 MySQL 版本低于 8.0,或者某些云数据库尚未完全兼容窗口函数语法,就需要手动模拟。最常用的模拟手段就是用户变量(user-defined variables),利用 MySQL 对 SELECT 语句中变量赋值和列求值的顺序特性,可以在一次查询中维护一个可变状态,从而实现逐行累计。

用户变量以 @ 符号开头,在会话级别生效,不需要提前声明,可以直接在 SQL 语句中赋值和读取。关键点在于 MySQL 对 SELECT 列表中表达式的求值顺序:MySQL 文档明确指出,对涉及用户变量的表达式求值顺序是未定义的,但在实际使用中,尤其是 MySQL 5.x 版本,SELECT 列表通常按照从左到右的顺序处理(除非优化器改变顺序)。因此开发者广泛利用这个“非官方但稳定”的行为,将变量赋值和累加写在同一行查询中。理解这一点是模拟 running total 的核心前提。
为什么窗口函数需要被模拟,以及变量模拟的运行机制
窗口函数允许在一组相关的行上执行计算而不需要分组,每一行仍然保留自己的数据,同时可以看到前后行的信息。例如 SUM(amount) OVER (ORDER BY created_at) 就是按时间排序后计算累计和。这种能力在旧版本 MySQL 中完全缺失,而子查询或者自连接往往性能低下且代码复杂。用户变量提供了一种轻量级的状态保持机制,它相当于在查询执行过程中维护一个内存中的计数器,每一行读取该计数器并更新它,再把更新后的值作为输出返回。
运行时,MySQL 逐行处理结果集。对于每一行,SELECT 列表中的表达式依次求值。假设我们写出如下查询片段:@running_total := @running_total + amount AS running_total。这里有一个隐式的顺序:右侧的 @running_total 先被读取(读取的是上一行留下的值),然后与当前行的 amount 相加,最后把结果赋给 @running_total,同时这个赋值表达式本身的值也被输出为 running_total 列。正是这个“读旧值、计算、写新值、同时输出新值”的流程,使得逐行累计成为可能。
必须强调的是,变量初始化和排序顺序是成功与否的关键。变量必须在查询开始前被初始化为 0 或 NULL,常见的做法是在 FROM 子句中添加一个子查询,或者使用 SET @running_total := 0; 单独执行。排序也必须显式通过 ORDER BY 指定,否则 MySQL 返回行的顺序可能不是预期的,导致累计结果错乱。此外,如果结果集包含多个分组,还需要引入另一个变量记住上一条记录的分组键,当分组键变化时重置累计变量。
完整示例:按日期累计销售额
假设有一张订单表 orders,包含 order_date 和 amount 两个字段,我们需要计算每一天的销售额以及截至当天的累计销售额。首先创建表并插入测试数据:
CREATE TABLE orders (
id INT PRIMARY KEY AUTO_INCREMENT,
order_date DATE,
amount DECIMAL(10,2)
);
INSERT INTO orders (order_date, amount) VALUES
('2024-01-01', 100.00),
('2024-01-01', 50.00),
('2024-01-02', 200.00),
('2024-01-03', 80.00),
('2024-01-03', 120.00);
如果按天汇总销售额并计算累计,可以先用子查询或 GROUP BY 得到每日汇总,然后在汇总结果上应用变量累加。这样能避免在明细行上直接累加带来的复杂性。以下是查询语句:
SET @running_total := 0; SELECT order_date, daily_total, (@running_total := @running_total + daily_total) AS running_total FROM ( SELECT order_date, SUM(amount) AS daily_total FROM orders GROUP BY order_date ORDER BY order_date ) AS daily_sums;
在这个查询中,内层子查询已经按照 order_date 排序并计算出每天的总和,外层再基于这个有序结果逐行累加。变量 @running_total 在每一行读取旧值,加上当前行的 daily_total,然后把新值写回并输出。注意外层的 ORDER BY order_date 是可选的,因为内层已经排序,但为了保险起见可以再次显式排序。实际执行时,MySQL 会先执行内层子查询(因为它在 FROM 中),内层的排序会传递给外层,因此变量累加的顺序是确定的。
有些开发者会尝试直接在明细行上做累加而不先分组,例如:
SELECT order_date, amount, (@running_total := @running_total + amount) AS running_total FROM orders, (SELECT @running_total := 0) AS init ORDER BY order_date;
这种写法可以工作,但含义不同:它计算的是每个订单明细的累计,而不是每天的汇总累计。如果同一天有多条订单,累计和会逐条增加,而不是在该日期结束后一次性增加当天总额。业务需求决定采用哪种方式。上面的方法中使用了一个额外的初始化子查询 (SELECT @running_total := 0),它会在查询开始时将变量置零,避免了单独执行 SET 语句。这也是一种常用技巧。
分组累计与变量重置技巧
如果数据有多个分组,例如按客户或产品类别分别计算累计,就需要在分组键变化时重置累计变量。假设我们有一张按客户记录积分的表,需要计算每个客户自己的积分累计。可以先按客户和日期排序,然后在查询中用一个变量记住上一个客户 ID,当客户 ID 变化时,把累计变量重置为 0 后再累加当前行的值。
示例表 customer_points 包含 customer_id、point_date 和 points。查询语句如下:
SET @current_customer := NULL;
SET @running_total := 0;
SELECT
customer_id,
point_date,
points,
@running_total := IF(@current_customer = customer_id,
@running_total + points,
points) AS running_total,
@current_customer := customer_id AS dummy
FROM customer_points
ORDER BY customer_id, point_date;
这里需要注意几个细节。首先,@current_customer 用来记录上一行的 customer_id。在每一行中,先比较当前行的 customer_id 是否等于 @current_customer(即上一行保存的客户 ID)。如果相等,说明还是同一个客户,累加 points;如果不相等,说明新客户开始,将 running_total 设置为当前行的 points 值。然后关键的一步:将当前行的 customer_id 赋值给 @current_customer,以便下一行比较使用。由于 SELECT 列表按顺序执行,这个赋值发生在计算 running_total 之后,因此不会影响当前行的比较结果。
输出中多出了一个 dummy 列,这是因为我们利用 SELECT 列表的副作用来更新 @current_customer 变量。如果不希望看到这个辅助列,可以在外层再包一层查询,只选择需要的列,或者在应用层忽略它。另一种更优雅的做法是把变量赋值放到 ORDER BY 子句中,但那样会改变语义,不推荐。务必理解这个“假列”的作用,它是保证分组重置正确性的关键。
如果在同一查询中同时使用多个变量,且它们的赋值顺序相互依赖,可能会因为优化器重排顺序而导致不可预测的结果。MySQL 官方文档明确指出,涉及用户变量的表达式求值顺序未定义,因此上述技巧属于对特定版本和特定执行计划的依赖。对于关键业务逻辑,建议升级到 MySQL 8.0 使用原生窗口函数,或者用更稳定的方法如自连接或临时表。
用户变量模拟的局限性与替代方案
用户变量模拟 running total 的方法虽然简单直观,但存在明显的局限。首先,查询的可读性较差,尤其是分组重置逻辑,需要额外的辅助变量和 dummy 列,代码维护成本高。其次,安全性不高,因为 MySQL 可能在未来的版本中改变对用户变量的求值顺序,导致现有查询结果悄然出错。第三,用户变量在同一个查询中不能可靠地用于多个赋值且相互引用的情况,比如同时计算累计和和累计平均值,可能因为优化器重排而混淆。
性能方面,用户变量本身并不昂贵,它只是内存操作,不会带来额外的 IO 负担。但前提是结果集已经正确排序。如果 ORDER BY 无法使用索引,排序操作的开销可能远大于累加本身。对于大数据量,更推荐使用自连接或子查询生成序号,然后再计算累计,虽然写法复杂但语义更清晰。另一个思路是使用临时表,先把数据插入临时表并添加自增列或序号,再通过连接完成累计计算。当数据量极大且查询频繁时,直接升级 MySQL 8.0 使用窗口函数是最佳选择,因为窗口函数经过优化器优化,且语法标准、可维护性高。
总结来说,在旧版 MySQL 中使用用户变量模拟 running total 是一种务实且广泛使用的方案,特别适合一次性分析查询或简单报表。只要注意初始化、排序、分组重置这几个核心点,就能得到正确结果。但必须清楚地认识到它依赖于非标准行为,不适合写入关键性的、需要长期维护的生产代码。如果条件允许,优先升级数据库版本,使用原生窗口函数才是更稳健的长期方案。