如何设计高效的月度 postpaid invoice module?
结论
不要等到月末再扫描并计算用户当月的数百万条交易。更合适的做法是采用“持续增量汇总 + 月末快照结算”:
- 每笔交易入账时,同步或异步更新对应用户、账期和计费维度的累计金额。
- 月末只读取汇总结果,并生成不可变的 invoice snapshot。
- 原始 transactions 继续作为事实数据保留,供审计、争议处理和定期对账使用。
- 采用异步处理时,通过 Transactional Outbox、幂等消费和 reconciliation 保证财务数据正确。
这种设计可以归入 CQRS read model、materialized view 或 event-driven aggregation。是否拆分 invoice service 属于部署层面的选择,本身解决不了全表扫描问题。应先改变计算模型,再考虑服务拆分。
为什么月末集中扫描成本高
现有流程的时间复杂度基本与当月交易数量成正比:
invoice generation cost ≈ O(number_of_monthly_transactions)
当单个用户每月有数百万条交易时,即使查询使用了正确的索引,系统仍要完成大量工作:
- 从数据库读取大量记录;
- 解析并执行计费规则;
- 完成分组、求和和税费计算;
- 构造账单明细;
- 在 billing point 与其他在线业务争抢 CPU、I/O 和数据库连接。
问题不在于 invoice module 是否位于 monolith,而在于整个月的计算工作都堆到了月末。把相同逻辑直接迁移到独立服务,只会把瓶颈从 monolith 搬到新服务。
推荐的数据模型
至少应保留三类数据:
transactions:原始交易事实,原则上不可变。billing_aggregates:按用户、账期、币种、产品或其他计费维度持续汇总的数据。invoices和invoice_lines:生成账单时固化的快照。
示例结构如下:
CREATE TABLE transactions (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
occurred_at TIMESTAMP NOT NULL,
amount_minor BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
product_code VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
created_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_transactions_user_time
ON transactions (user_id, occurred_at);
CREATE TABLE billing_aggregates (
user_id BIGINT NOT NULL,
billing_period DATE NOT NULL,
currency CHAR(3) NOT NULL,
product_code VARCHAR(64) NOT NULL,
transaction_count BIGINT NOT NULL DEFAULT 0,
amount_minor BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL,
PRIMARY KEY (
user_id,
billing_period,
currency,
product_code
)
);
CREATE TABLE invoices (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
billing_period DATE NOT NULL,
status VARCHAR(32) NOT NULL,
subtotal_minor BIGINT NOT NULL,
tax_minor BIGINT NOT NULL,
total_minor BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
issued_at TIMESTAMP,
version INTEGER NOT NULL,
UNIQUE (user_id, billing_period, version)
);
金额应使用最小货币单位的整数,例如分,不要使用 FLOAT 或 DOUBLE。如果复杂费率计算需要超过整数精度,应使用精度明确的 DECIMAL,并统一舍入规则。
billing_period 可以保存账期第一天,例如 2026-08-01。不要依赖服务器本地时间临时推算账期。业务时区和账期边界都应明确定义。
方案一:交易写入时同步汇总
如果 transaction module 和 invoice module 仍使用同一个数据库,最简单可靠的起点,是在写入交易的同一数据库事务中更新汇总表。
BEGIN;
INSERT INTO transactions (
id,
user_id,
occurred_at,
amount_minor,
currency,
product_code,
status,
created_at
) VALUES (
:transaction_id,
:user_id,
:occurred_at,
:amount_minor,
:currency,
:product_code,
'POSTED',
CURRENT_TIMESTAMP
);
INSERT INTO billing_aggregates (
user_id,
billing_period,
currency,
product_code,
transaction_count,
amount_minor,
updated_at
) VALUES (
:user_id,
:billing_period,
:currency,
:product_code,
1,
:amount_minor,
CURRENT_TIMESTAMP
)
ON CONFLICT (
user_id,
billing_period,
currency,
product_code
)
DO UPDATE SET
transaction_count =
billing_aggregates.transaction_count + 1,
amount_minor =
billing_aggregates.amount_minor + EXCLUDED.amount_minor,
updated_at = CURRENT_TIMESTAMP;
COMMIT;
这段语法以 PostgreSQL 为例,其他数据库需要改用相应的 UPSERT 或 MERGE 语法。
月末生成账单时,只需要读取少量汇总行:
SELECT
currency,
product_code,
transaction_count,
amount_minor
FROM billing_aggregates
WHERE user_id = :user_id
AND billing_period = :billing_period;
这种方式的一致性较强。只要交易提交成功,汇总数据也会一同提交。代价是交易写入路径多了一次更新,而且高并发用户可能频繁更新同一条汇总记录,引发锁竞争。
可以增加汇总粒度来缓解热点,例如先按天或分片汇总:
(user_id, billing_period, shard_id, product_code, currency)
月末只需合并几十或几百个分片,不必处理数百万条交易。
方案二:事件驱动的异步汇总
如果不想增加交易写入路径的负担,或者计划拆分 invoice service,可以在交易确认后发布事件,由 invoice service 维护自己的 billing projection。
典型流程如下:
Transaction committed
↓
Outbox event created in the same DB transaction
↓
Publisher sends event to message broker
↓
Invoice service consumes event
↓
Idempotently updates billing aggregate
↓
Billing period closes
↓
Invoice snapshot is generated
不要采用“先写数据库,再直接发送消息”的简单双写:
write transaction → publish message
如果数据库写入成功,但消息发送失败,账单侧就会永久遗漏这笔交易。更稳妥的方案是 Transactional Outbox:在同一个本地事务中写入交易和待发送事件,再由后台进程可靠地发布事件。
BEGIN;
INSERT INTO transactions (...) VALUES (...);
INSERT INTO outbox_events (
event_id,
aggregate_type,
aggregate_id,
event_type,
payload,
created_at
) VALUES (
:event_id,
'transaction',
:transaction_id,
'TransactionPosted',
:payload,
CURRENT_TIMESTAMP
);
COMMIT;
消费端必须按消息可能重复投递来设计。可以使用事件 ID 建立去重表:
BEGIN;
INSERT INTO processed_events (
consumer_name,
event_id,
processed_at
) VALUES (
'invoice-aggregator',
:event_id,
CURRENT_TIMESTAMP
)
ON CONFLICT (consumer_name, event_id) DO NOTHING;
应用程序需要检查受影响的行数。如果插入结果为零,说明该事件已经处理,可以直接结束事务。否则继续更新汇总数据:
INSERT INTO billing_aggregates (
user_id,
billing_period,
currency,
product_code,
transaction_count,
amount_minor,
updated_at
) VALUES (
:user_id,
:billing_period,
:currency,
:product_code,
1,
:amount_minor,
CURRENT_TIMESTAMP
)
ON CONFLICT (
user_id,
billing_period,
currency,
product_code
)
DO UPDATE SET
transaction_count =
billing_aggregates.transaction_count + 1,
amount_minor =
billing_aggregates.amount_minor + EXCLUDED.amount_minor,
updated_at = CURRENT_TIMESTAMP;
COMMIT;
这里要实现的是业务层面的 exactly-once effect,而不是假设消息系统绝不会重复投递。
月末应生成不可变快照
月末关账时,已经出具的 invoice 不应继续引用仍在变化的汇总表。应把最终结果复制到 invoice 和 invoice lines,保存为当时的财务快照。
一种可行的状态流转是:
OPEN → CLOSING → CLOSED → INVOICED
各状态分别承担以下职责:
OPEN:继续接收并汇总当前账期的交易。CLOSING:停止直接修改该账期,等待在途事件处理完成。CLOSED:汇总值已经冻结,可以计算税费和折扣。INVOICED:账单快照已经生成,并获得唯一 invoice number。
生成账单的操作也必须幂等:
INSERT INTO invoices (
id,
user_id,
billing_period,
status,
subtotal_minor,
tax_minor,
total_minor,
currency,
issued_at,
version
) VALUES (
:invoice_id,
:user_id,
:billing_period,
'ISSUED',
:subtotal_minor,
:tax_minor,
:total_minor,
:currency,
CURRENT_TIMESTAMP,
1
)
ON CONFLICT (user_id, billing_period, version) DO NOTHING;
即使月末任务发生重试,也不能为同一用户和账期重复生成相同版本的 invoice。
延迟交易、退款和冲正怎么处理
实际系统很难保证所有交易都能在自然月最后一秒前处理完,因此要提前定义业务规则。
常见做法有:
- 设置 grace period,在账期结束后等待一段由业务规定的时间再关账;
- 根据事件的业务发生时间
occurred_at判断所属账期,而不是使用消费时间; - 对账单出具后才到达的交易生成 adjustment;
- 退款和冲正通过新的负向交易记录,不直接修改历史交易;
- 必要时生成 credit note、debit note 或新版本账单。
具体等待时间和会计处理方式取决于业务合同、税务要求及当地监管规定,不能仅从技术角度决定。
事件还可能乱序到达。例如,退款事件可能先于原交易抵达 invoice service。消费端需要暂存无法关联的事件,或者按照交易状态模型处理,不能依赖消息严格有序。
原始交易明细仍然需要保留
增量汇总不能取代原始交易数据,两者用途不同:
- 汇总表用于高效生成账单;
- 原始交易用于审计、重算、争议处理和故障恢复。
如果账单必须展示每一笔交易,生成 PDF 时也不应一次加载数百万条记录。可以把内容拆开处理:
- 主 invoice:展示总额、税费,以及按产品或日期汇总的数据;
- transaction statement:作为独立附件或下载文件;
- 明细查询 API:使用 cursor pagination 分页;
- 大文件导出:异步生成 CSV、Parquet 或压缩文件,并存入对象存储。
数据库查询应使用稳定游标,避免大偏移量分页:
SELECT
id,
occurred_at,
amount_minor,
currency,
product_code
FROM transactions
WHERE user_id = :user_id
AND occurred_at >= :period_start
AND occurred_at < :period_end
AND id > :last_seen_id
ORDER BY id
LIMIT :page_size;
这段查询的前提是 id 可以提供稳定顺序。如果交易 ID 与时间顺序无关,应使用 (occurred_at, id) 作为复合游标。
对账和重算不能省略
异步架构采用最终一致性,但这不代表可以容忍金额永久不一致。至少应设置两层校验。
日常增量校验
记录每个账期的以下信息:
- 交易数量;
- 金额合计;
- 最后处理的事件位置;
- 消费延迟;
- 重复事件和失败事件数量。
周期性全量对账
在低峰期按用户、日期或数据分区重新聚合原始交易,再与 billing_aggregates 比较:
SELECT
user_id,
DATE_TRUNC('month', occurred_at) AS billing_period,
currency,
product_code,
COUNT(*) AS transaction_count,
SUM(amount_minor) AS amount_minor
FROM transactions
WHERE occurred_at >= :range_start
AND occurred_at < :range_end
AND status = 'POSTED'
GROUP BY
user_id,
DATE_TRUNC('month', occurred_at),
currency,
product_code;
这类查询不在开票的关键路径上,可以按分区执行、限制速度,并放在只读副本或分析系统中运行。发现差异后,不要直接覆盖已经出具的账单。应先查明原因,再通过 adjustment 或受控的账单版本机制修正。
是否应该拆分为独立 invoice service
出现以下情况时,拆分通常更有价值:
- transaction 和 invoice 的扩缩容需求明显不同;
- 开票规则变化频繁,需要独立发布;
- 多个业务系统都向同一个计费系统发送交易;
- invoice 有独立的权限、合规或数据保留要求;
- 团队已经具备消息投递、监控、追踪和故障恢复能力。
如果眼下的主要问题只是月末查询太慢,可以先在 monolith 内建立汇总表和 outbox,以较低风险验证数据模型。以后拆分服务时,现有的事件契约和 projection 仍可继续使用。
拆分后,invoice service 应拥有自己的数据库,不应跨服务直接查询 transaction service 的内部表。它可以通过以下方式取得所需数据:
- 消费 transaction domain events;
- 调用受控的 transaction API,获取少量补充信息;
- 对账时读取专门提供的导出数据或数据仓库。
“两个服务共享同一数据库”虽然迁移起来简单,却会保留强耦合,使服务难以独立部署、修改 schema 和控制负载。
建议的落地顺序
可以分阶段实施:
- 明确账期边界、时区、交易状态、舍入规则和迟到交易策略。
- 为现有查询补齐
(user_id, occurred_at)等必要索引,并检查执行计划。 - 在 monolith 内新增
billing_aggregates,从新交易开始维护增量汇总。 - 使用历史交易回填当前账期的汇总数据。
- 建立对账任务,确认增量结果可以根据原始交易重算。
- 将月末开票流程改为读取汇总表,并生成 invoice snapshot。
- 引入 outbox,把同步汇总逐步改为异步 projection。
- 等事件契约、幂等机制和监控稳定后,再决定是否拆分 invoice service。
迁移期间可以暂时同时运行新旧两套计算流程,只比较结果,不让新流程正式出账。确认金额、交易数、税费和舍入结果一致后,再切换开票数据来源。
需要特别注意的事项
- 不要把 cache 当作财务事实来源。缓存丢失后,所有数据都必须能够恢复。
- 不要使用浮点数计算金额。
- 不要假设消息只会投递一次,或一定会严格按顺序到达。
- 不要静默修改已经出具的 invoice 金额。
- 不要只保存累计总额而丢掉计费维度,否则税费、折扣和审计数据可能无法重算。
- 汇总逻辑和历史重算逻辑应共用同一套计费规则,避免两套实现出现偏差。
- 计费规则需要版本化,历史账期应能识别当时使用的规则版本。
- 对于热点用户,可以按天、产品或 hash shard 分散写入,再在关账时合并。
- 原始交易表可以按时间分区,但分区只能降低查询和维护成本,不能替代增量汇总。
- 开票任务应支持 checkpoint、重试、限流和单用户隔离,避免一个超大账户阻塞整个批次。
核心原则是把集中在月末的批量计算,分散为整个月持续进行的小规模计算。月末只负责冻结数据、校验结果和生成账单快照。服务拆分可以放在后面考虑。数据一致性、幂等、关账和对账机制,才决定这套架构能否可靠运行。
备注:内容仅供参考。
