SQL窗口函数实现现金日记账累计余额:sum() over()与rownum实战
2026/9/16 23:36:39 网站建设 项目流程

1. 需求拆解:财务现金日记账为什么写SQL会头疼

做财务系统、ERP、OA里带资金模块的兄弟,十有八九都接过这类需求:把资金流水按日期排好,逐条显示收入、支出,然后右边给一列“余额”,第一行是上一个工作日的期末余额加第一条流水,第二条加第二条,滚雪球一样滚下去。业务方管这一列叫“本次余额”,本质上就是资金的累计结余。

我刚工作那会儿遇到的第一个有点含金量的SQL,就是现金日记账。当时用的还是Oracle,接收到的需求描述是:“给我把现金日记账导出来,日期升序,同日期的按凭证号排,余额要连下去”。我第一反应是:这有什么难的,把表查出来,收入减支出,自己心算一下加进去不就行了?真正动手才发现,SQL里没有“上一行”的概念,你想让余额自己滚起来,光靠普通查询根本做不到,得用变量或者自关联去模拟“逐行累加”的效果。

这个标题里写的组合很有意思——sum() over(order by, rownum)。这行SQL老手一看就懂,新手八成会卡住。sum() over()是窗口函数里最常用的累加函数,order by负责指定累加的顺序,而Oracle里的rownum则是这个案例里最微妙的细节,它不是为了取行号做分页,而是为了保证累加过程的顺序稳定。

这篇就围绕这个案例,把窗口函数累加的原理、rownum在这里的真实作用、完整的可落地SQL,以及我在多个版本Oracle、SQL Server上踩过的坑,一次讲透。

1.1 业务方要的“余额”到底是什么

先明确业务口径,不然SQL写出来也是错的。现金日记账的余额,通常是这么算的:

  • 期初余额:上一日(或者上一个期间)账面的结余金额。
  • 当日每笔发生额:收入增加余额,支出减少余额。
  • 当前余额:期初余额 + 截止到当前行的收入累计 - 截止到当前行的支出累计。

放到SQL里,就是把收入和支出都转成带符号的数字,收入记正数,支出记负数,然后对所有行从第一行开始做累加。这就是sum(金额) over(order by 日期, 凭证号)做的事情。

举个例子更直观:假设期初余额是1000元,流水如下表:

日期凭证号摘要收入支出
2024-01-02001收到货款5000
2024-01-02002办公用品0200
2024-01-03003收到押金10000

业务方要看到的结果是:

日期凭证号收入支出余额
2024-01-0200150001500
2024-01-0200202001300
2024-01-03003100002300

最后一行2300元,等于1000+500-200+1000。这个“滚雪球”的列,就是整个报表的灵魂。业务方每天看的就是它,月底对账对的也是它,银行余额调节表的核心数据来源同样是它。

1.2 传统写法为什么又慢又绕

在窗口函数普及之前,实现“累计余额”主要有两种路子,我现在想起来都觉得头大。

第一种是自连接,思路是把每一行和它之前的每一行关联起来,然后用group by分组累计。逻辑上完全正确,SQL写着也短,但性能是个灾难。流水表一旦有个几万条,相关子查询或者分组连接就会出现严重的笛卡尔积爆炸,日记账这个场景的数据量通常是“多年流水、长期保留”,跑一次报表几分钟出不来很正常。

第二种是PL/SQL游标循环,或者在SQL Server里用declare变量逐行累加。这个性能没问题,但必须在存储过程或脚本里写循环,而且只有Oracle和SQL Server支持,可移植性差,维护成本也高。关键是它绕开了SQL的集合思想,回到了“逐行处理”的老路上。

所以后来窗口函数一普及,sum() over()几乎是立刻取代了上面两种方案。一条语句,一次扫描,结果全出来,性能还远超自连接。标题里这个SQL能成为经典案例,核心就在这个函数上。

2. 核心原理:sum() over()到底在做什么

窗口函数(Window Function)最早在SQL标准里出现是1999年,但真正进入主流数据库的速度很慢。Oracle在9i里率先支持,SQL Server从2005版本开始有,MySQL直到8.0才总算补上。所以在Oracle环境中讨论窗函数,是历史最悠久、案例也最丰富的场景。

要理解sum() over(),先得搞清楚它和普通聚合函数sum()的区别。

2.1 和group by聚合的最大区别:谁被折叠了

普通写select 日期, sum(金额) from 流水 group by 日期,结果是什么?每个日期只剩一行,所有明细行被折叠了。你再也看不到“每一笔凭证的摘要、收入、支出”这些明细,剩下的只有汇总。

而窗口函数的写法是:

select 日期, 凭证号, 摘要, 收入, 支出, sum(收入 - 支出) over(order by 日期, 凭证号) as 余额 from 流水

注意同一行查询里,既有普通列(日期、凭证号、摘要),又有聚合列(sum计算出来的余额)。窗口函数over()的作用就是在明细行不折叠的前提下,把一个计算范围(窗口)的值算出来放在每一行旁边。这个特性特别适合做报表,因为它天然保留了所有原始信息。

简单说,普通sum()是“我只要一个总数”,窗口sum()是“每行都能看到从起点到当前行的累计数”。后者就是over(order by ...)带来的效果。

2.2 over(order by)里的累计语义

over()括号里写order by,效果是让窗口从分组的第一行开始,一直累加到当前行。这个“当前行”是随着查询结果逐行移动的,每一行计算时看到的数据范围都不一样。

我经常用一个生活化类比来解释:窗口函数就像你在看一段滚动播放的记账流水视频,视频播放到哪一行,屏幕上显示的“累计余额”就是从开头播到这一刻的总数。视频没播到的那些流水,不参与当前行的计算。

代码层面,order by 日期, 凭证号决定了滚动的顺序。这个顺序极其重要,因为如果日期排序不对,后面累加的余额全错。这也是为什么标题里单独把order by拎出来讲,它不是可选项,而是这个SQL的“方向盘”。

2.3 为什么这里要带上rownum

这是这个案例里最值得展开的细节。

Oracle里rownum是个伪列,在结果集生成的同时分配序号。它能用来分页(where rownum <= 10),但在这个日记账案例里,它最重要的作用是:固定排序的稳定性

什么叫“排序不稳定”?如果order by后面的字段存在重复值,比如同一天有多笔凭证,或者凭证号有相同的,数据库返回这些并列行的顺序是不保证的。执行计划一变、数据量一增,同一查询跑两次,并列行之间的顺序可能就不一样,而sum() over()会严格按照这个顺序做累加。顺序一变,余额就变,报表就对不上账。

有人会问:那用order by 日期, 凭证号不就行了吗?问题是凭证号虽然在业务上要求唯一,但如果表里存在历史数据不规范、或凭证号允许为空的情况,重复值就这么产生了。数据库不会报错,它只会默默按“物理存储顺序”给你个结果,而这个顺序你根本没法预期。

标题里写sum() over(order by , rownum),我理解的意思是:在子查询里先用rownum给每行分配一个绝对稳定的序号,然后窗口函数里用这个序号作为最终排序条件。这样哪怕日期、凭证号有重复,rownum也是唯一的,累加顺序就100%可控。

我在实际开发里还会加一句:

select t.*, sum(t.金额) over(order by t.rn) as 余额 from ( select 日期, 凭证号, 摘要, 收入, 支出, 收入 - 支出 as 金额, rownum as rn from 流水表 t order by 日期, 凭证号 ) t

先在子查询里order by 日期, 凭证号得出业务上正确的顺序,再用rownum把这个顺序固化成编号rn,外层窗口函数就按rn累加。这个写法在Oracle里非常可靠,也是我能把这个方案稳定用在生产报表里的关键。

需要说明一个更进阶的替代方案:Oracle 9i之后有了row_number(),可以更优雅地生成唯一序号,比如row_number() over(order by 日期, 凭证号) as rn。标题里用的rownum是更早期、更朴素的写法。正式上线时我更推荐row_number(),但理解rownum的逻辑依然是理解Oracle执行原理的必修课。

3. 完整实例:从建表到SQL落地全流程

光讲原理不过瘾,我把这个案例从零到一完整走一遍。以Oracle为例,建表、插数、写SQL、验结果,全程贴代码。

3.1 建表与测试数据准备

现金日记账的业务表结构,我简化成最常见的样子,实际项目里无非就是再加些辅助字段。

-- 现金流水表 create table cash_flow ( flow_id number, -- 唯一ID voucher_date date, -- 记账日期 voucher_no varchar2(20), -- 凭证号 summary varchar2(100),-- 摘要 income_amt number(18,2) default 0, -- 收入金额 expense_amt number(18,2) default 0 -- 支出金额 ); -- 测试数据,故意构造同日多笔、金额不规律的情况 insert into cash_flow values (1, date'2024-01-02', '001', '期初结转', 10000.00, 0); insert into cash_flow values (2, date'2024-01-02', '002', '收到货款', 5000.00, 0); insert into cash_flow values (3, date'2024-01-03', '003', '办公用品采购', 0, 800.00); insert into cash_flow values (4, date'2024-01-04', '004', '收到押金', 2000.00, 0); insert into cash_flow values (5, date'2024-01-04', '005', '差旅费报销', 0, 1200.50); insert into cash_flow values (6, date'2024-01-05', '006', '银行提现', 30000.00, 0); insert into cash_flow values (7, date'2024-01-05', '007', '支付供应商货款', 0, 15000.00); commit;

这里有个容易忽略的点:第一条流水我放的是“期初结转”,金额10000元。这张表里没有单独的“期初余额”字段,期初余额就用一条特殊流水来表示。这样做的好处是所有计算口径统一,期初余额和后续明细共用一套累计逻辑;坏处是财务对账时得注意区分,别把期初结转当成普通收入流水。

3.2 关键SQL:逐行累计余额的两种写法

先上最朴素、也最能看懂的版本:

select voucher_date as 日期, voucher_no as 凭证号, summary as 摘要, income_amt as 收入, expense_amt as 支出, income_amt - expense_amt as 发生额, sum(income_amt - expense_amt) over(order by voucher_date, voucher_no) as 余额 from cash_flow order by voucher_date, voucher_no;

这个SQL已经把需求解决了,逻辑上完全正确。但问题在哪?就是前面说的,order by voucher_date, voucher_no如果存在并列,顺序不稳定。这张测试表里voucher_datevoucher_no都唯一,所以跑出的结果没毛病。生产环境呢?凭证号可能为空,日期可能重复,历史数据可能乱过一段时间。

所以我生产推荐版是这个:

select t.voucher_date as 日期, t.voucher_no as 凭证号, t.summary as 摘要, t.income_amt as 收入, t.expense_amt as 支出, t.occur_amt as 发生额, sum(t.occur_amt) over(order by t.rn) as 余额 from ( select cf.*, cf.income_amt - cf.expense_amt as occur_amt, rownum as rn from cash_flow cf order by cf.voucher_date, cf.voucher_no ) t order by t.voucher_date, t.voucher_no;

这个写法把“业务排序”和“窗口排序”分成两层。内层子查询先按业务规则排序,然后通过rownum生成稳定的rn;外层窗口函数不再依赖日期和凭证号排序,而是依赖唯一且稳定的序号rn。只要内层排序逻辑不变,rn就不会变,累加结果就永远一致。

这已经不是一句SQL的问题,而是一种工程习惯。多个团队里,大家写的窗口函数排序条件五花八门,谁的能上线后一直不出问题,谁的就是好方案。在我维护的报表系统里,统一要求窗函数内部必须使用唯一键(要么是主键,要么是row_number()生成的序号),宁可麻烦一点,也不能让“看起来正确”的SQL埋雷。

3.3 结果验证与余额校验

执行上面那段推荐版SQL,得到结果:

日期凭证号摘要收入支出发生额余额
2024-01-02001期初结转10000.00010000.0010000.00
2024-01-02002收到货款5000.0005000.0015000.00
2024-01-03003办公用品采购0800.00-800.0014200.00
2024-01-04004收到押金2000.0002000.0016200.00
2024-01-04005差旅费报销01200.50-1200.5014999.50
2024-01-05006银行提现30000.00030000.0044999.50
2024-01-05007支付供应商货款015000.00-15000.0029999.50

手动验一下最后一行:10000 + 5000 - 800 + 2000 - 1200.50 + 30000 - 15000 = 29999.50,结果正确。

我在交付报表前会做两层校验:

第一层是整表校验:用sum(发生额)等于最后一行余额减去第一行发生额加上期初?不对,更简单的方式是直接用普通聚合验证总额差。看最后一行余额29999.50,是否等于全表sum(income_amt) - sum(expense_amt)加上第一条之前的值。因为第一条本身就是期初,所以这里直接全表发生额累计就是29999.50,和最后一行余额一致,说明累加没有漏行、没有多行。

第二层是抽查:任选中间一行,手工把它之前所有的发生额加起来,和该行余额比对。比如第4行余额16200.00,等于10000+5000-800+2000,手工算,一致。

这两层校验花不了几分钟,却能避免SQL写错导致整张报表报废。

4. 常见问题与避坑指南

这个SQL看起来只有一行,但生产环境里跑起来,各种边角问题能让你从下午排查到下班。我把这些年遇到的高频坑,按“现象-原因-解决办法”整理成一套速查表。

4.1 日期并列时余额顺序不稳定

这是最隐蔽的一个坑。表现是:同样一条SQL,同一批数据,10点跑出一个余额,11点再跑变成另一个余额。原因就是over(order by 日期)里,日期相同的那几行,累加顺序不确定。

比如2024-01-04这一天有两笔流水,凭证号004和005。如果数据库先处理004再处理005,中间状态余额是16200;反过来先处理005再处理004,中间状态就变成14999.50。最终结果虽然最后一行一样,但过程行不一样,一旦业务方截个图、对个账,差异就出来了。

解决办法就是我前面写的:子查询里用rownum或者row_number()固定顺序,外层窗口函数按序号累加。记住一句话:窗口函数的order by必须唯一,不唯一就加序号。

4.2 凭证号不连续或为空的处理

很多流水表凭证号是独立的序列,但历史迁移数据里常常有断号、空号。如果order by voucher_no排序,空隙本身没问题,但空值在Oracle里默认排在最前面,可能导致期初余额被空凭证号的流水挤到后面。

遇到这种情况,我一般会给凭证号补一个排序辅助列,比如nvl(voucher_no, 'zzz'),或者直接在业务表里加一个sort_no字段专门用于排序。排序的事交给数据库,但排序的口径必须由业务定,不要依赖字典序来猜。

4.3 金额精度与舍入问题

现金账金额不能有半点误差,SQL里我统一用number(18,2)存金额,但累加过程中可能有浮点误差。Oracle的number类型对十进制计算处理得比较好,但SQL Server的float、MySQL的double可能出现0.01的舍入偏差。

我的习惯是:涉及金额计算一律用定点数类型,绝不用浮点。如果必须从别的系统拿数,清洗入库时就转成number(18,2)decimal(18,2)。计算累计余额时再配合round做最终展示层的四舍五入,计算层保持精确累加。另外,财务系统里负数用“红字冲销”表示,比如支出负值实际是退回了的钱,这种业务逻辑要在SQL注释里写清楚,不然后来接手的人看到负数容易懵。

4.4 不同数据库的语法差异

标题里的rownum是Oracle专属,换了SQL Server或MySQL,语法就不一样了。

SQL Server从2005开始支持窗函数,写法是sum(...) over(order by ...),但生成序号的函数是row_number(),没有rownum伪列。如果想实现同样的效果,可以把内层嵌套改为:

select t.*, sum(t.occur_amt) over(order by t.rn) as 余额 from ( select cf.*, cf.income_amt - cf.expense_amt as occur_amt, row_number() over(order by cf.voucher_date, cf.voucher_no) as rn from cash_flow cf ) t

MySQL 8.0同样支持row_number()sum() over(),语法几乎和SQL Server一致。PostgreSQL也类似。所以这套方案切换到非Oracle平台,核心思路相同,只是把rownum换成row_number()

这里要强调:row_number()本身就是窗口函数,和sum()可以一起写,但顺序很重要。如果你在同一个查询里对两个不同窗口分别计算序号和累计值,完全可以:

select voucher_date, voucher_no, sum(income_amt - expense_amt) over(order by row_number() over(order by voucher_date, voucher_no)) as 余额 from cash_flow;

这种嵌套写法Oracle和SQL Server都支持,但不建议在生产里用,可读性太差。老老实实子查询包一层,多写几行,维护的人会感谢你。

4.5 性能问题:别让窗口函数引爆临时表

窗函数性能大多数情况下比自连接好,但也不是没有坑。如果底层表数据量巨大,且over(order by ...)的排序列没有索引,Oracle会在排序区或临时表空间做一次全量排序。流水表几百万行时,排序能把临时表空间撑爆,SQL直接报错。

优化方向有两个。第一,给排序列建索引,比如(voucher_date, voucher_no)复合索引,让排序走索引避免额外sort。第二,如果按月份查询,先WHERE过滤月份再计算累计,让窗口变小。注意,如果查询条件里过滤掉了一部分行,累计结果是基于过滤后的行的,不是全表的。具体业务上,现金日记账通常是查某个月,月初余额要手工带入,这点要和业务方确认口径。

4.6 期初余额怎么进SQL

这个问题几乎每次做财务报表都会被问。两种方案:

方案一:期初余额作为一行流水写入表里,比如“期初结转”。优点是SQL不用特殊处理,余额从第一行开始自动累加。缺点是要保证每月只写一条期初,不能重复。

方案二:期初余额用参数带入SQL,比如with init_amt as (select 10000 as amt from dual),然后把期初和累计值相加:

select t.voucher_date, t.voucher_no, t.occur_amt, sum(t.occur_amt) over(order by t.rn) + :init_amt as 余额 from ...

这个写法更灵活,不用在流水表里塞期初数据,但每次报表都得传参,稍麻烦。项目里如果业务方要求“期初余额来自上月末报表”,方案二更贴合。我一般是优先用方案一,因为数据可追溯,出现差异时能直接查期初流水,不用翻报表参数。

4.7 和lag()、lead()的配合

现金日记账有时候需要在余额之外再算“环比发生额”,也就是和上一笔的金额差。这时候sum() over()不擅长,得用lag()窗口函数:

select voucher_date, voucher_no, income_amt - expense_amt as occur_amt, sum(income_amt - expense_amt) over(order by rownum) as 余额, (income_amt - expense_amt) - lag(income_amt - expense_amt) over(order by rownum) as diff_amt from cash_flow;

lag()取上一行的值,lead()取下一行,它们是窗口函数家族里和sum()搭配最频繁的两个。做资金变动分析、异常流水检测时经常用到。

5. 一次实际项目里的排查实录

这个案例虽然标题只有一句话,但我在生产环境里真实处理过一个和它几乎一模一样的故障,拿出来说说,也算给上面那些经验做个落地验证。

当时是一家做零售的客户,门店几十家,每天的现金流水表数据量大概两百万行。月初财务导出上个月的现金日记账,发现门店A的账号余额和门店的现金盘点差了30多块,不多,但财务不接受。

我去排查时先把SQL拿出来看,发现同事写的是:

select 门店, 日期, 凭证号, 金额, sum(金额) over(partition by 门店 order by 日期) as 余额 from 流水 where 月份 = 上月

一眼就看出了问题:order by 日期没有第二排序条件,同一天同门店存在多条流水,顺序不稳定。30多块的差异,就是同一天里某几笔收入的顺序颠倒了,余额列中间过程错了。

修复方式就是在子查询里加序号:

select t.门店, t.日期, t.凭证号, t.金额, sum(t.金额) over(partition by t.门店 order by t.rn) as 余额 from ( select s.*, row_number() over(partition by s.门店 order by s.日期, s.凭证号, s.流水号) as rn from 流水表 s where s.记账月份 = 上月 ) t order by t.门店, t.日期, t.凭证号;

这里有两个关键改动。第一个,排序加上了第三级“流水号”,确保唯一;第二个,窗口函数内部改用rn序号,而不是直接排日期。改完之后,重新跑报表,余额和门店现金盘点完全一致。

这类问题普遍到什么程度呢?我在代码评审里看到过太多“order by 日期”就敢写窗口函数的写法,几乎每个都要我提醒一句:补个唯一排序列。尤其是财务模块,金额字段和顺序字段是同一个数据质量级别的,不能有一点点儿含糊。

6. 把这条SQL当模板用的扩展思路

现金日记账只是窗口函数累加最经典的应用场景之一,这个写法抽出来,可以套到很多日常需求里。

最常见的变体包括:

  • 库存台账:按入库、出库时间排序,累计得到当前库存量。只是把金额换成数量,SQL结构一模一样。
  • 银行对账单余额核对:把银行流水按日期排好,用sum() over()算出账户余额,与银行提供的余额核对。
  • 销售业绩累计:按销售员和时间维度累计销售额,实现“年初至今”的销售额口径。
  • 积分流水:用户积分变动明细加累计剩余积分,做法完全一样,多一个partition by user_id

如果要在门店维度分别累计,只需要加partition by

sum(金额) over(partition by 门店 order by rn) as 门店余额

这样每个门店独立计算自己的累计余额,互不干扰,这是窗口函数另一个核心能力——分组内的累计。现金日记账通常不分区,因为期初余额是全账套统一的,但多机构、多门店场景下就要用partition by了。

我还见过一个实际需求:每个分公司要一个截至到当月的累计现金流。这个用sum() over(partition by 分公司 order by 月份 rn),分分钟解决。

7. 从实战里总结的几条经验

最后讲几条实操经验,都是被生产环境教训过后才真正记住的。

第一条,写窗口函数务必检查order by唯一性。这是整个案例最核心的一条。只要order by字段在数据上不唯一,结果过程行就差。别说服自己“数据质量好着呢”,数据质量这东西在跨系统对接后谁都说不好。

第二条,期初余额最好以流水形式入库,不要单独存一张参数表。日记账报表要的是“从某一天开始累计”,把期初作为第一条流水,SQL统一,校验也统一。缺点是每个月要自动生成一条期初流水,这个用定时任务或者触发器都能解决。

第三条,金额字段绝对不允许float参与累计。别问为什么,我曾经对接过一台ERP,里面应收金额是float类型,累计到第500多行时差出1分钱,财务硬是查了一下午。从那以后,所有入账金额字段一律定点数,计算层用number,展示层再处理格式。

第四条,测试SQL别只用三五行数据就认为万事大吉。至少构造这些边界情况:同一天多笔流水、凭证号为空、收入或支出为0、金额为负数(红冲)、大金额导致累加值超过10亿。能扛住这些边界的SQL,上线才不用天天提心吊胆。

第五条,报表的最终排序和窗口函数的排序要分开写。外层order by只负责展示顺序,窗口函数内部控制计算顺序。很多人习惯窗口函数里排好序就不管了,外层不再order by,这样数据库是可以的,但对读报表的人不友好,因为查询结果的行顺序并不一定和窗口顺序一致。明确分开写,一层业务排序,一层计算排序,逻辑清晰得多。

现金日记账这个案例,SQL本身并不复杂,复杂的是里面的业务口径、排序稳定性、精度控制这些东西。把这份SQL吃透,本质上不是学会一个函数,而是建立一种“逐行加上下文”的思维。以后不管是库存台账、积分余额、账单核对,还是更复杂的财务归集,都能套上这套逻辑,而且知道哪里有坑、怎么避开。这大概就是“一个案例顶十个函数”的意义所在。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询