☰
记账小程序月度预算建模:一条生效区间记录撑起任意月份查询
2026/11/2 13:10:57 网站建设 项目流程

做记账小程序做到第七篇,终于绕不开月度预算这块硬骨头。后台私信里被问得最多的一个问题是:“设置月度预算的时候,是不是要给每个月都添加一条记录?”问的人多了,我发现大家卡住的点其实不是代码怎么写,而是数据该怎么建模——脑子里默认把“预算”和“账单流水”当成了同一类东西,于是自然而然地想每个月塞一条进去。这恰恰是后面所有麻烦的源头。这篇就把我的方案完整摊开:预算到底该存成什么形态、为什么不给每个月建记录、一条长期有效的记录怎么撑起任意月份的查询、变更和结转,以及我在真实项目里踩过的坑和排查手法。不管你是刚入门的独立开发者,还是已经写了两三个版本想重构记账小程序的老手,下面这套思路都能直接拿去改。核心结论先摆在这里:月度预算不需要每个月建一条记录,一条带生效区间的规则记录就够了,月份是查询参数,不是存储实体。

1. 先把“预算”这个词从流水里拆出来

很多人第一反应是“每个月一条记录”,本质上是因为记账小程序里最熟悉的数据结构就是流水表:一条支出、一条收入,天然带上日期和金额。看到“月度预算”四个字,大脑自动套用这个模板,觉得预算也是一个“带月份的金额”。这个类比看起来很顺,但它混淆了两个完全不同的东西。

1.1 流水是事件,预算是规则

流水是已经发生的事实。今天买了一杯咖啡,十二块五,这个事件发生了就永远钉在那里,不会被修改成昨天的咖啡,也不会因为换个心情变成十五块。它是一条事件记录,特点是一次性、不可逆、必须精确到具体时间点。

预算不是事件,它是你对未来的一个约束。你说“我每个月餐饮控制在 1500 块以内”,这句话表达的不是“2024 年 3 月我要花 1500”这个具体事实,而是一条持续有效的规则:只要我没改主意,每个月都按这个数走。规则的特点是可重复、可持续、可以被撤销和替换,和事件属于完全不同的语义层级。

一旦你意识到这个区别,就会明白为什么“每个月建一条”是错位的。你不是在记录每个月发生了什么,你是在为一个重复周期声明一个阈值。用事件表去承载规则,就像用快递单号去记录“我以后每个月都要寄快递”这件事,形式上能跑,逻辑上别扭,维护上灾难。

1.2 每个月建一条,代价藏在后面三个月

先别急着否定,我们把“每个月一条”这个方案完整推演一遍,看看它在第几个月开始咬人。假设小程序上线了,用户 1 月设了预算 3000,你插入一条 2024-01 的记录。

到了 2 月,用户没有主动设置。你的程序怎么办?两种选择,一是懒加载:用户打开预算页时发现 2 月没有记录,自动复制 1 月的数值插一条;二是定时任务:每月 1 号凌晨给所有用户批量插入新记录。第一种会让预算页带写操作,打开一次页面就可能触发一次插入,并发场景下还得处理重复插入;第二种更难受,你得为所有沉寂用户(三个月没打开过的、已经卸载的)也生成记录,数据量随用户数乘以月份数线性膨胀,一年之后一个中等规模的用户池就能攒出几十万条毫无信息量的“空白预算”。

更麻烦的是修改。用户在 6 月回头想把全年餐饮预算从 1500 调到 1800,如果你的数据是每个月一条,这条指令就变成了“修改 12 条记录”或者“修改未来 6 条记录”,还要额外判断用户是个什么意思——是只改这个月,还是改以后每个月,还是把历史也一起改掉?每多一种语义,就多一套分支逻辑和一次上线风险。

1.3 一条区间记录,把复杂度压到查询层

我的方案是:预算只存一条记录,带上“从哪个月开始生效”和“到哪个月为止”,未来某个月的预算值通过查询推导出来,不落库。这样做的好处是任何时刻数据库里描述的都是“用户当前意图”,而不是“程序猜测出来的月度快照”。

这个思路其实在不少成熟系统里都能看到:会员价格不是每个月存一条,而是存一条带生效期的定价规则;个税扣除标准不是每年存一条,而是存一条带适用区间的参数。把周期性重复的东西描述成区间,把变化点描述成边界,是数据建模里非常通用的一条经验。

2. 预算表怎么设计才经得起改

定下“一条记录”的方向,接下来是字段怎么落。我踩过的坑主要集中在三个地方:月份用什么类型存、金额用什么类型存、变更时是覆盖还是新增。下面逐个说。

2.1 月份用 YYYY-MM 字符串,别用日期

第一版我用的是DATE类型存生效月份,统一取每月 1 号。用了两个月就发现两处别扭:一是所有查询都要做DATE_FORMAT或者区间比较,SQL 写得又长又容易写错;二是每次读出来还得在业务层再转一次,前端传 2024-03 过来,后端要拼成 2024-03-01,稍不注意就碰上时区把日期挪到上个月的最后一天。

换成CHAR(7)存2024-03之后,一切顺畅了。原因很简单:YYYY-MM 这种格式的字典序和时间序完全一致,字符串比较就等于时间比较。start_period <= '2024-03'和end_period >= '2024-03'直接就能用,索引也正常工作。而且它天然回避了“这个月有几天”“闰年二月怎么办”“月末是 28 还是 31”这一整类问题——我们根本不关心具体哪一天,只关心是哪一个自然月。

提示:如果以后要支持周预算或者季度预算,建议不要往同一张表里硬塞不同粒度的 period_key,而是加一个period_type字段区分,让唯一约束变成(user_id, scope, category_id, period_type, start_period)。混在一起会让区间匹配的 SQL 迅速变成噩梦。

2.2 金额一律用整数分,别用浮点

这个坑太经典了,但每过一段时间就会看到有人踩。FLOAT和DOUBLE存金额会在累加时产生误差,0.1 + 0.2 不等于 0.3 这件事在预算场景里会被放大:用户设了 3000 元预算,累加到 2999.9999999 的时候进度条显示 99.9% 却不触发“已超支”,用户体验直接崩。

我的做法是金额统一用BIGINT存整数分,1500 元存成 150000。展示层再除 100 格式化。这样做的好处是加减乘除全部精确,预算进度、剩余额度、超支金额的计算不会有任何精度残留。顺带一提,DECIMAL(12,2)也是可行选项,但我更偏好整数分,因为前端 JS 处理大整数和浮点之间的转换容易出意外,整数分在前后端之间传输时是最不容易出错的形式。

2.3 变更用“关旧开新”,保留可追溯

用户调整预算时,我采用的是关闭旧记录、新开一条,而不是原地UPDATE。听起来麻烦,但它解决了一个真实需求:用户想知道“我三月份预算为什么是 1500”,而三月份的预算可能来自 1 月设的规则,也可能是 2 月底临时调过。如果原地覆盖,这段历史就永久丢了。

具体操作是:把当前生效记录的end_period设为变更月的前一个月,再插入一条start_period为变更月的新记录。比如原来是 2024-01 起 3000 元且无结束月,用户在 6 月改成 3500,就把旧记录改成start=2024-01, end=2024-05,新记录start=2024-06, end=NULL, amount=350000。这样 1 到 5 月查出来还是 3000,6 月往后是 3500,语义完全正确,还能顺便展示一条预算变更时间线。

下面是我实际用的建表语句,字段做了简化但关键约束都在:

CREATE TABLE budget_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, scope VARCHAR(16) NOT NULL COMMENT 'total 总预算 / category 分类预算', category_id BIGINT NULL COMMENT 'scope=category 时必填', amount_cents BIGINT NOT NULL COMMENT '单位:分', start_period CHAR(7) NOT NULL COMMENT 'YYYY-MM,含', end_period CHAR(7) NULL COMMENT 'YYYY-MM,含;NULL 表示长期有效', rollover TINYINT NOT NULL DEFAULT 0 COMMENT '超支是否结转到下月', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at DATETIME NULL, KEY idx_user_scope_period (user_id, scope, category_id, start_period, end_period) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

索引顺序是有讲究的:user_id和scope选择性最高放在最前,start_period放后面用于范围过滤。实测在几十万行量级下,单用户单范围的区间查询稳定在毫秒级。

2.4 唯一性约束只能在业务层兜

有一个细节值得单独说:预算记录在数据库层面很难加严格的唯一约束。因为“同时只能有一条生效记录”是一个跨行的时间区间不重叠约束,MySQL 的普通唯一索引表达不了。你不可能在(user_id, scope)上建唯一索引,那样就没法保留历史;也不能在(user_id, scope, start_period)上建,因为同一月份理论上只会有一次“开新”,但防不住代码 bug 写出两条重叠区间。

我的处理是在服务层用事务加行锁保证:写入前先SELECT ... FOR UPDATE锁住该用户该范围内未删除的记录,校验新记录的区间与现有区间没有重叠,再执行关闭和插入。同时在测试环境加了一个定时巡检脚本,每天跑一次重叠检测,发现异常直接告警。这个脚本救过我一次——某次并发调预算的接口没加锁,两个请求同时通过校验,插出了两条重叠记录,巡检当天就报出来了。

3. 不建月度记录,那这个月预算怎么查

这是所有人最关心的问题:既然不按月存,用户打开小程序的时候,你凭什么告诉他“本月预算 3000,已花 1200”?答案是一段带区间条件的查询,加上一点缓存。

3.1 区间匹配查询的核心写法

给定用户和当前月份'2024-06',找到生效预算的 SQL 是这样的:

SELECT amount_cents, rollover FROM budget_rule WHERE user_id = ? AND scope = 'total' AND deleted_at IS NULL AND start_period <= '2024-06' AND (end_period IS NULL OR end_period >= '2024-06') ORDER BY start_period DESC LIMIT 1;

ORDER BY start_period DESC LIMIT 1这一步是保险丝。正常数据下只会命中一条,但万一历史上出现了重叠(比如手工修数据修出来的),取开始月最晚的那条,语义上最接近“用户最近一次意图”。这个兜底逻辑看起来多余,线上真出事的时候能救命。

查询性能方面,(user_id, scope, category_id, start_period, end_period)这个联合索引对于单用户查询完全是够用的,因为它先把数据缩到“这个用户这个范围的所有预算历史”,通常只有几条到几十条,再在内存里过滤区间,代价可以忽略。真正需要担心的不是这条 SQL,而是预算页同时要查总预算、十几个分类预算、以及每个分类的已花金额——那才是 N+1 的重灾区。

3.2 已花金额怎么算,别在预算表里冗余

第二版我犯过一个错:在预算记录上挂一个spent_cents字段,每次记账就UPDATE budget_rule SET spent_cents = spent_cents + ?。想法很美好,性能看着也高,但很快就崩了:用户删除一笔账单,得反向扣减;用户修改账单金额,得先减后加;用户改账单日期跨月了,得同时更新两个月的预算消耗;分类改了,得从旧分类的预算扣掉加到新分类。每一个操作都是一次可能的对不上的机会,而且一旦对不上,你根本不知道从哪天开始错的。

正确做法是预算消耗永远是聚合查询算出来的,不落库。给定月份,直接对流水表按月聚合:

SELECT category_id, SUM(amount_cents) AS spent FROM transaction WHERE user_id = ? AND type = 'expense' AND deleted_at IS NULL AND occur_date >= '2024-06-01' AND occur_date < '2024-07-01' GROUP BY category_id;

一次查询拿到本月所有分类的支出,和预算记录在内存里按category_id拼装即可,总预算就是各分类之和(或者全表汇总)。如果数据量大到聚合变慢,再考虑加一张按月预聚合的汇总表,用定时任务或记账时异步更新,把它当成缓存而不是真相来源。这个顺序很重要:先保证正确,再考虑快。

提示:聚合时一定记得occur_date用左闭右开区间< '2024-07-01',不要用BETWEEN '2024-06-01' AND '2024-06-30'。后者在字段是DATETIME时会漏掉 6 月 30 日当天带时分秒的记录,这是我在测试环境排查了半小时才发现的坑,因为测试数据全是整点。

3.3 预算还没设过的月份,怎么显示

有个边界情况经常被忽略:用户 5 月开始用小程序,6 月设了预算,那他回看 3 月的时候,预算该显示什么?我的选择是显示“未设置预算”,而不是显示 0 或者显示 6 月的数值。理由是预算本质是用户主动声明的约束,没声明就是没声明,用 0 表示会让人误以为“我当时设置了零预算”,用未来值则是错位。

对应的实现细节是:如果查询返回空,前端展示一个引导卡片“这个月还没有设置预算,去设置”,而不是把进度条画成 0%。这个小小的区分会让用户对数据的信任度高很多。

4. 从设置页到进度条:完整实现链路

模型讲清楚了,接下来走一遍真实的实现路径,从用户在设置页输入数字,到首页进度条渲染出来,中间每一步我都标注了关键判断。

4.1 设置页的交互与校验

设置页看起来简单,其实要考虑的分支不少。用户可能在三种状态下进入:从来没设过预算、当前有生效预算想改、想取消预算。

第一种情况直接插入新记录,start_period取当前月。第二种情况要走前面说的“关旧开新”,但这里有个产品层面的决策:变更从哪个月开始生效。我给了两个选项,“从本月生效”和“从下月生效”。默认选下月,因为很多人是在月底发现超支了才去调预算,如果从本月生效,当月已花的钱会立刻被新预算重新衡量,容易产生困惑。这个小设计看起来不起眼,但能减少相当一部分用户投诉。

第三种取消预算,不是删记录,而是把当前生效记录的end_period设为上一个已结束的月份,让它在当前月失效。这样做的好处是历史月份的数据完整性完全不受影响。

校验方面有几个必须卡的点:金额必须大于 0,且必须是整数分(前端输入元,转分时用Math.round(value * 100)而不是直接截断);分类预算之和超过总预算时给出提醒但不阻断,因为有人确实会把分类预算设得宽松一些,只要总预算卡住就行;单条上限设一个合理值(我设的 100 万元),防止用户误输入多打几个零导致展示溢出。

// 前端金额转整数分,注意浮点误差 function toCents(yuan) { if (typeof yuan !== 'number' || !isFinite(yuan)) return null; const cents = Math.round(yuan * 100); if (cents <= 0) return null; if (cents > 100 * 10000 * 100) return null; // 上限 100 万元 return cents; }

4.2 进度计算里的三个坑

进度条的计算公式看着就是spent / amount,但真正上线后我发现至少三个地方需要小心。

第一个是除零。预算为 0 的情况理论上被校验挡住了,但历史数据、导入数据、后台手工改的数据都可能出现 0。我加了一层保护:amount <= 0时按“未设置预算”处理,不画进度条。

第二个是超支后的展示。超支 5% 和超支 300% 如果都用进度条长度表示,前者看不出来,后者会溢出容器。我的做法是进度条最大填充到 100%,超过部分用颜色和角标表示(红色加“超支 XXX 元”),同时保留真实百分比数值用于文案。这样视觉上永远不会撑破布局,信息也不丢。

第三个是分类预算和总预算口径不一致。如果用户设了总预算 3000,只给餐饮和交通设了分类预算,那剩下没设分类的钱花掉了怎么算?我的规则是:总预算按全部支出算,分类预算只按该分类算,两者独立统计,不做加总校验。这是最不容易让人困惑的口径——用户看到的“总支出”永远等于账单列表里的总和,对得上账。

4.3 月末、时区与“这个月”的定义

“本月”这两个字在代码里其实很危险,因为服务器时区和用户时区可能不一样。假设用户在 UTC+8,凌晨 0 点刚过记账,服务器如果是 UTC,那一刻服务器时间还停在上个月最后一天的 16 点,预算就会算到上个月去。

我的处理是:月份边界一律按用户所在时区计算。用户表里存一个tz_offset或者时区标识,所有涉及“本月”的判断都用用户时区转换后的年月作为period_key。前端传period_key过来时,后端做一次白名单校验,只接受YYYY-MM格式且在当前月份前后 24 个月内的值,防止被构造出奇怪参数。

月末的问题则被period_key天然规避了。因为我们从来不存具体日期,查询月份的时候也不会去构造2024-02-30这种不存在的日期,整个链路里没有“这个月有几天”这个概念。这算是选择YYYY-MM字符串的一个意外红利。

还有一个小众但真实的场景:用户跨时区旅行。他 3 月 31 日晚上在 A 地记账,4 月 1 日回到 B 地看账单,这笔钱到底算 3 月还是 4 月?我的方案是按记账时用户当时的时区落occur_date,之后不再变动。不追求绝对正确,追求的是记录稳定——用户看到的数字不会因为我们什么时候改规则而变化。

5. 超支结转:什么时候需要,怎么加

“超支结转到下月”是一个看起来高级、实际需求挺强的功能:这个月省下 200,下个月预算就多 200;这个月超了 300,下个月就少 300。不少用户会问能不能这么做。但加这个功能之前,得先想清楚它和“一条区间记录”的模型怎么共存。

5.1 结转是计算出来的派生值

最关键的一点:结转金额绝对不能写回预算记录。一旦写回,你就在存储层造出了“每个月一条”的等价物,前面辛苦省下的复杂度全白费了。

正确做法是把结转当成一个递归的派生计算。给定目标月份 M,它的可用额度 = 基础额度 + 上月结转额,而上月结转额 = 上月可用额度 - 上月实际支出。这个递归往前推,会推到这个预算规则的start_period为止,因为再往前不属于这条规则的管辖范围。

实际实现时不会真的每次递归到最后,而是设一个回溯上限,比如最多往前推 12 个月。超过 12 个月的结转在心理上已经失去意义(用户不会因为两年前省了 200 块而觉得今天多花得合理),而且能防止查询退化成 O(n)。

// 简化版结转计算:只回溯最近 N 个月 function calcAvailable(baseCents, monthKey, spentMap, months, N = 12) { let carry = 0; const startIdx = Math.max(0, months.indexOf(monthKey) - N + 1); for (let i = startIdx; i < months.indexOf(monthKey); i++) { const m = months[i]; const avail = baseCents + carry; carry = avail - (spentMap[m] || 0); } return baseCents + carry; }

5.2 结转的三条使用建议

第一,结转默认关闭。我一开始想着“这功能用户肯定喜欢”就默认打开了,结果后台数据显示,开启结转的用户里有一半在两个月后看到“本月预算 3400”时一脸懵,因为他们早就忘了上个月省过钱。默认关闭,让用户主动开,并且在进度条上方用一句话解释“含上月结转 +400 元”,这是最省事的做法。

第二,结转要不要允许为负,得给用户选。有的人希望超支能挤占下月,起到自我惩罚的作用;有的人只想要“省下的能攒着”,超支就清零。我最终做成了一个三选一的开关:不结转 / 只结转结余 / 结转结余和超支。

第三,结转和预算变更的交互要提前想好。如果用户 6 月换了预算基数,5 月之前的结转还要不要接着算?我的规则是:预算规则一变,结转从头算,从新规则的起始月开始重新累计。理由是旧的基数已经不代表用户当下的意图,继续把旧结余带过来会让数字失去解释力。

5.3 一个真实的数据修复案例

有个用户反馈说自己的预算数字很奇怪,本月显示预算是负数。排查下来发现,他那条预算规则的amount_cents在后台被改成过 0(应该是早期一个管理操作留下的),而结转逻辑里“可用额度 = 0 + 结转”,上个月又超支了,结果算出来是负数。

修复思路很直接:在结转计算入口加一道防御,基础额度小于等于 0 时直接按未设置处理,不参与结转;然后写个一次性脚本,把历史上amount_cents <= 0的记录软删除,并给这些用户推一条站内提示重新设置。这件事给我的教训是:任何计算逻辑的入口都要对输入做兜底,不能假设“校验层已经挡住了”。校验层只能挡住新数据,管不住历史数据和老接口。

6. 问题排查与经验速查

这一节把我在这个模块上遇到过的典型问题整理成表,基本都是“现象看着莫名其妙,根因其实很明确”的类型。你如果正在做类似的功能,可以直接拿去对照。

6.1 常见问题速查表

现象高概率根因排查动作修复方向
用户本月预算显示为上个月的数服务端按 UTC 取月份,用户在东八区打印服务端当前时间和用户时区换算结果统一按用户时区生成 period_key
同一用户同一月出现两条生效预算并发调预算接口未加锁查该用户预算记录按 start_period 排序事务内 SELECT FOR UPDATE,加重叠巡检
已花金额比账单列表少一笔聚合用了 BETWEEN 导致漏掉月末带时分秒的记录对比聚合查询和列表查询的边界条件改成左闭右开区间
修改预算后历史月份数值也变了变更走的是原地 UPDATE检查变更接口是 UPDATE 还是关旧开新改为关旧开新,保留区间历史
进度条显示 99.9% 但不触发超支提示金额用了浮点类型累加查表字段类型是否为 FLOAT/DOUBLE改为整数分存储
取消预算后历史月份也变成未设置删除逻辑是物理删除或全区间失效查被取消记录的 end_period 值只把 end_period 设为上月
分类预算之和超过总预算但没有提示缺少前端联动校验复现设置流程加提醒但不阻断提交
结转金额每月都在漂移结转结果被写回预算记录检查预算表是否有 spent/carry 字段改为纯派生计算

6.2 我的几条实操心得

关于查询次数。预算页加载时,我一开始是循环查每个分类的预算和支出,一个用户有 15 个分类就是 30 次查询,页面打开明显卡顿。后来改成两次查询解决:一次拿该用户当前生效的所有预算记录(用IN或者按 scope 全拿),一次拿本月按分类聚合的支出。在内存里做 map 拼装,接口耗时从 400 多毫秒降到 40 毫秒左右。这个优化不需要任何缓存中间件,纯粹是减少往返。

关于软删除。预算记录我用的是deleted_at软删除而不是物理删除,因为出现过用户误操作取消预算后想恢复的情况。软删除之后,恢复就是把deleted_at置空,同时检查区间是否与其他记录冲突。代价是所有查询都必须记得带deleted_at IS NULL,我见过不止一个项目在这里翻车——漏了一个条件,用户看到的是好几份叠加的预算。我的做法是在数据访问层统一封装查询方法,禁止业务代码直接拼 SQL。

关于测试数据。预算这个模块最容易被测试数据骗。如果你的测试账单全是整点、全是当月日期、全是整数金额,那你永远发现不了时区、月末、浮点这三类问题。我后来专门准备了一套“脏数据”:月末 23:59:59 的账单、跨月修改过日期的账单、金额带小数的账单、删了又恢复的账单。每次改动预算相关逻辑,都拿这套数据跑一遍,比写多少单元测试都直观。

关于功能边界。有用户问能不能做“按周预算”“按项目预算”“按季度预算”。我的建议是先把月度这一个周期做扎实,把变更、结转、跨时区这些边角磨平,再考虑扩展。因为每增加一种周期粒度,区间匹配、重叠校验、结转回溯全都要跟着改。用period_type字段预留扩展位是对的,但不要一上来就实现全部。真要做周预算,period_key换成2024-W23这种格式同样满足字典序等于时间序,模型是不用动的,这是当初选字符串格式留给自己的后路。

关于用户看不懂这件事。预算功能最大的敌人不是技术难度,是用户不理解。用户看到“本月可用 2800”会问为什么不是 3000,你得能一句话说清楚“因为你上个月结转了 -200”。所以我在预算卡片上加了一行小字,永远展示计算过程的关键项:基础额度、结转、已花、剩余。这行字的开发成本很低,但能挡掉大量客服咨询。

最后分享一个我自己的判断标准:每次想给预算表加字段的时候,我都会先问一句“这个值能不能从流水和规则算出来”。如果能算出来,就不存。这条原则让我在这个模块上少走了太多弯路,也让后来接手的人不用去猜某个冗余字段到底什么时候该更新。记账类产品的数据一旦不可信,用户流失是瞬间的事,而可信的前提就是——每一个数字都有唯一、可解释的来源。

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

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

立即咨询